fixes
This commit is contained in:
@@ -55,20 +55,20 @@ public class ColorbookFlowController : IAsyncStartable, IDisposable
|
||||
await _drawingCatalog.InitializeAsync(cancellation);
|
||||
if (!_navigatingToGameplay) _loadingScreen.Hide();
|
||||
|
||||
PrewarmRewardedAdAsync(_scopeCts.Token).Forget();
|
||||
PrewarmInterstitialAdAsync(_scopeCts.Token).Forget();
|
||||
}
|
||||
|
||||
private async UniTaskVoid PrewarmRewardedAdAsync(CancellationToken ct)
|
||||
private async UniTaskVoid PrewarmInterstitialAdAsync(CancellationToken ct)
|
||||
{
|
||||
try
|
||||
{
|
||||
if (!_ads.IsInitialized) await _ads.InitializeAsync(ct);
|
||||
if (!_ads.IsReady(AdFormat.Rewarded)) await _ads.LoadAsync(AdFormat.Rewarded, ct);
|
||||
if (!_ads.IsReady(AdFormat.Interstitial)) await _ads.LoadAsync(AdFormat.Interstitial, ct);
|
||||
}
|
||||
catch (OperationCanceledException) { }
|
||||
catch (Exception ex)
|
||||
{
|
||||
UnityEngine.Debug.LogWarning($"[ColorbookFlow] Rewarded prewarm failed: {ex.Message}");
|
||||
UnityEngine.Debug.LogWarning($"[ColorbookFlow] Interstitial prewarm failed: {ex.Message}");
|
||||
}
|
||||
}
|
||||
|
||||
@@ -101,40 +101,51 @@ public class ColorbookFlowController : IAsyncStartable, IDisposable
|
||||
_loadingScreen.Show();
|
||||
_loadingScreen.SetProgress(0f);
|
||||
|
||||
await ShowRewardedAdAsync(ct);
|
||||
// Fire the interstitial but never await it: the ad overlays the transition while the level
|
||||
// loads underneath, so a missed/dropped ad callback can't stall the flow at 0% anymore.
|
||||
ShowInterstitialAdAsync(ct).Forget();
|
||||
|
||||
// Ad SDKs can resume the continuation on a background thread; the scene load below
|
||||
// must run on the Unity main thread. No-op when already there.
|
||||
await UniTask.SwitchToMainThread(ct);
|
||||
|
||||
var progress = new Progress<float>(p => _loadingScreen.SetProgress(p * 0.5f));
|
||||
var mappedProgress = new Progress<float>(p => _loadingScreen.SetProgress(0.5f + p * 0.25f));
|
||||
await _progression.SetLastOpenedAsync(templateId);
|
||||
await _scenes.LoadSceneAsync(nameof(GameScene.Gameplay), progress: progress, cancellationToken: default);
|
||||
await _scenes.UnloadSceneAsync(nameof(GameScene.Colorbook), progress: mappedProgress,
|
||||
cancellationToken: default);
|
||||
}
|
||||
|
||||
private async UniTask ShowRewardedAdAsync(CancellationToken ct)
|
||||
{
|
||||
const int InitTimeoutMs = 4000;
|
||||
try
|
||||
{
|
||||
if (!_ads.IsInitialized)
|
||||
var progress = new Progress<float>(p => _loadingScreen.SetProgress(p * 0.5f));
|
||||
var mappedProgress = new Progress<float>(p => _loadingScreen.SetProgress(0.5f + p * 0.25f));
|
||||
await _progression.SetLastOpenedAsync(templateId);
|
||||
await _scenes.LoadSceneAsync(nameof(GameScene.Gameplay), progress: progress, cancellationToken: default);
|
||||
await _scenes.UnloadSceneAsync(nameof(GameScene.Colorbook), progress: mappedProgress,
|
||||
cancellationToken: default);
|
||||
}
|
||||
catch (OperationCanceledException) { /* scope disposed */ }
|
||||
catch (Exception ex)
|
||||
{
|
||||
// Navigation failed mid-flight: release the latch and drop the loading screen so the
|
||||
// user can retry instead of being stuck on a loader frozen at 0%.
|
||||
UnityEngine.Debug.LogException(ex);
|
||||
_navigatingToGameplay = false;
|
||||
_loadingScreen.Hide();
|
||||
}
|
||||
}
|
||||
|
||||
// Fire-and-forget interstitial. Shows only if one is already prewarmed; otherwise it kicks a
|
||||
// load for next time and returns immediately. Never blocks the level load — by design the
|
||||
// scene swap below does not depend on the ad's close callback, so the ad can never stall it.
|
||||
private async UniTaskVoid ShowInterstitialAdAsync(CancellationToken ct)
|
||||
{
|
||||
try
|
||||
{
|
||||
if (!_ads.IsInitialized) return;
|
||||
|
||||
if (!_ads.IsReady(AdFormat.Interstitial))
|
||||
{
|
||||
using var timeoutCts = CancellationTokenSource.CreateLinkedTokenSource(ct);
|
||||
timeoutCts.CancelAfter(InitTimeoutMs);
|
||||
await _ads.InitializeAsync(timeoutCts.Token);
|
||||
_ads.LoadAsync(AdFormat.Interstitial, ct).Forget();
|
||||
return;
|
||||
}
|
||||
|
||||
if (!_ads.IsReady(AdFormat.Rewarded)) return;
|
||||
|
||||
await _ads.ShowAsync(AdFormat.Rewarded, ct);
|
||||
await _ads.ShowAsync(AdFormat.Interstitial, ct);
|
||||
}
|
||||
catch (OperationCanceledException) { }
|
||||
catch (Exception ex)
|
||||
{
|
||||
UnityEngine.Debug.LogWarning($"[ColorbookFlow] Rewarded ad skipped: {ex.Message}");
|
||||
UnityEngine.Debug.LogWarning($"[ColorbookFlow] Interstitial skipped: {ex.Message}");
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -23,6 +23,8 @@ namespace Darkmatter.Services.Ads
|
||||
[SerializeField, Min(1f)] private float reloadDelaySeconds = 5f;
|
||||
[Tooltip("Max reload attempts before giving up.")]
|
||||
[SerializeField, Min(1)] private int reloadMaxAttempts = 6;
|
||||
[Tooltip("Hard fallback (seconds) to recover a full-screen show if AdMob never raises its close callback. Android focus-return recovery usually fires far sooner; this cap covers iOS/edge cases. Must exceed max plausible ad length so a real ad is never cut short.")]
|
||||
[SerializeField, Min(15f)] private float showWatchdogSeconds = 60f;
|
||||
|
||||
public bool IsInitialized => _initialized;
|
||||
public event Action<AdFormat, AdLoadState> LoadStateChanged;
|
||||
@@ -32,6 +34,13 @@ namespace Darkmatter.Services.Ads
|
||||
private bool _isChildDirected;
|
||||
private CancellationTokenSource _lifetimeCts;
|
||||
|
||||
// App interruption state, fed by the Unity lifecycle messages below. A full-screen ad
|
||||
// pushes the app into this state (Android raises focus-loss, iOS raises pause); the
|
||||
// watchdog uses the return-to-foreground transition to recover a missed close callback.
|
||||
private bool _appPaused;
|
||||
private bool _appUnfocused;
|
||||
private bool AppInterrupted => _appPaused || _appUnfocused;
|
||||
|
||||
private readonly Dictionary<AdFormat, AdLoadState> _states = new();
|
||||
|
||||
#if GOOGLE_MOBILE_ADS
|
||||
@@ -64,6 +73,11 @@ namespace Darkmatter.Services.Ads
|
||||
#endif
|
||||
}
|
||||
|
||||
// Android raises focus-loss for full-screen ads; iOS raises pause. Track both so the show
|
||||
// watchdog can detect the ad's return-to-foreground regardless of platform.
|
||||
private void OnApplicationPause(bool pauseStatus) => _appPaused = pauseStatus;
|
||||
private void OnApplicationFocus(bool hasFocus) => _appUnfocused = !hasFocus;
|
||||
|
||||
public async UniTask InitializeAsync(CancellationToken cancellationToken)
|
||||
{
|
||||
if (_initialized) return;
|
||||
@@ -364,81 +378,116 @@ namespace Darkmatter.Services.Ads
|
||||
}
|
||||
}
|
||||
|
||||
private async UniTask<AdShowResult> ShowInterstitialAsync(CancellationToken cancellationToken)
|
||||
{
|
||||
var tcs = new UniTaskCompletionSource<AdShowResult>();
|
||||
Action onClosed = () => tcs.TrySetResult(AdShowResult.Success());
|
||||
Action<AdError> onFailed = err => tcs.TrySetResult(AdShowResult.Failure(err?.GetMessage()));
|
||||
|
||||
_interstitial.OnAdFullScreenContentClosed += onClosed;
|
||||
_interstitial.OnAdFullScreenContentFailed += onFailed;
|
||||
_interstitial.Show();
|
||||
|
||||
try
|
||||
{
|
||||
using (cancellationToken.Register(() => tcs.TrySetCanceled(cancellationToken)))
|
||||
return await tcs.Task;
|
||||
}
|
||||
finally
|
||||
{
|
||||
if (_interstitial != null)
|
||||
private UniTask<AdShowResult> ShowInterstitialAsync(CancellationToken cancellationToken) =>
|
||||
ShowFullScreenAsync(
|
||||
AdFormat.Interstitial,
|
||||
(c, f) =>
|
||||
{
|
||||
_interstitial.OnAdFullScreenContentClosed -= onClosed;
|
||||
_interstitial.OnAdFullScreenContentFailed -= onFailed;
|
||||
}
|
||||
ScheduleReload(AdFormat.Interstitial);
|
||||
}
|
||||
}
|
||||
_interstitial.OnAdFullScreenContentClosed += c;
|
||||
_interstitial.OnAdFullScreenContentFailed += f;
|
||||
},
|
||||
(c, f) =>
|
||||
{
|
||||
if (_interstitial == null) return;
|
||||
_interstitial.OnAdFullScreenContentClosed -= c;
|
||||
_interstitial.OnAdFullScreenContentFailed -= f;
|
||||
},
|
||||
() => AdShowResult.Success(),
|
||||
() => _interstitial.Show(),
|
||||
cancellationToken);
|
||||
|
||||
private async UniTask<AdShowResult> ShowRewardedAsync(CancellationToken cancellationToken)
|
||||
private UniTask<AdShowResult> ShowRewardedAsync(CancellationToken cancellationToken)
|
||||
{
|
||||
var tcs = new UniTaskCompletionSource<AdShowResult>();
|
||||
bool earned = false;
|
||||
AdReward reward = default;
|
||||
|
||||
Action onClosed = () => tcs.TrySetResult(earned ? AdShowResult.WithReward(reward) : AdShowResult.Success());
|
||||
Action<AdError> onFailed = err => tcs.TrySetResult(AdShowResult.Failure(err?.GetMessage()));
|
||||
|
||||
_rewarded.OnAdFullScreenContentClosed += onClosed;
|
||||
_rewarded.OnAdFullScreenContentFailed += onFailed;
|
||||
_rewarded.Show(r =>
|
||||
{
|
||||
earned = true;
|
||||
reward = new AdReward(r.Type, r.Amount);
|
||||
});
|
||||
|
||||
try
|
||||
{
|
||||
using (cancellationToken.Register(() => tcs.TrySetCanceled(cancellationToken)))
|
||||
return await tcs.Task;
|
||||
}
|
||||
finally
|
||||
{
|
||||
if (_rewarded != null)
|
||||
return ShowFullScreenAsync(
|
||||
AdFormat.Rewarded,
|
||||
(c, f) =>
|
||||
{
|
||||
_rewarded.OnAdFullScreenContentClosed -= onClosed;
|
||||
_rewarded.OnAdFullScreenContentFailed -= onFailed;
|
||||
}
|
||||
ScheduleReload(AdFormat.Rewarded);
|
||||
}
|
||||
_rewarded.OnAdFullScreenContentClosed += c;
|
||||
_rewarded.OnAdFullScreenContentFailed += f;
|
||||
},
|
||||
(c, f) =>
|
||||
{
|
||||
if (_rewarded == null) return;
|
||||
_rewarded.OnAdFullScreenContentClosed -= c;
|
||||
_rewarded.OnAdFullScreenContentFailed -= f;
|
||||
},
|
||||
() => earned ? AdShowResult.WithReward(reward) : AdShowResult.Success(),
|
||||
() => _rewarded.Show(r =>
|
||||
{
|
||||
earned = true;
|
||||
reward = new AdReward(r.Type, r.Amount);
|
||||
}),
|
||||
cancellationToken);
|
||||
}
|
||||
|
||||
private async UniTask<AdShowResult> ShowRewardedInterstitialAsync(CancellationToken cancellationToken)
|
||||
private UniTask<AdShowResult> ShowRewardedInterstitialAsync(CancellationToken cancellationToken)
|
||||
{
|
||||
var tcs = new UniTaskCompletionSource<AdShowResult>();
|
||||
bool earned = false;
|
||||
AdReward reward = default;
|
||||
return ShowFullScreenAsync(
|
||||
AdFormat.RewardedInterstitial,
|
||||
(c, f) =>
|
||||
{
|
||||
_rewardedInterstitial.OnAdFullScreenContentClosed += c;
|
||||
_rewardedInterstitial.OnAdFullScreenContentFailed += f;
|
||||
},
|
||||
(c, f) =>
|
||||
{
|
||||
if (_rewardedInterstitial == null) return;
|
||||
_rewardedInterstitial.OnAdFullScreenContentClosed -= c;
|
||||
_rewardedInterstitial.OnAdFullScreenContentFailed -= f;
|
||||
},
|
||||
() => earned ? AdShowResult.WithReward(reward) : AdShowResult.Success(),
|
||||
() => _rewardedInterstitial.Show(r =>
|
||||
{
|
||||
earned = true;
|
||||
reward = new AdReward(r.Type, r.Amount);
|
||||
}),
|
||||
cancellationToken);
|
||||
}
|
||||
|
||||
Action onClosed = () => tcs.TrySetResult(earned ? AdShowResult.WithReward(reward) : AdShowResult.Success());
|
||||
Action<AdError> onFailed = err => tcs.TrySetResult(AdShowResult.Failure(err?.GetMessage()));
|
||||
private UniTask<AdShowResult> ShowAppOpenAsync(CancellationToken cancellationToken) =>
|
||||
ShowFullScreenAsync(
|
||||
AdFormat.AppOpen,
|
||||
(c, f) =>
|
||||
{
|
||||
_appOpen.OnAdFullScreenContentClosed += c;
|
||||
_appOpen.OnAdFullScreenContentFailed += f;
|
||||
},
|
||||
(c, f) =>
|
||||
{
|
||||
if (_appOpen == null) return;
|
||||
_appOpen.OnAdFullScreenContentClosed -= c;
|
||||
_appOpen.OnAdFullScreenContentFailed -= f;
|
||||
},
|
||||
() => AdShowResult.Success(),
|
||||
() => _appOpen.Show(),
|
||||
cancellationToken);
|
||||
|
||||
_rewardedInterstitial.OnAdFullScreenContentClosed += onClosed;
|
||||
_rewardedInterstitial.OnAdFullScreenContentFailed += onFailed;
|
||||
_rewardedInterstitial.Show(r =>
|
||||
{
|
||||
earned = true;
|
||||
reward = new AdReward(r.Type, r.Amount);
|
||||
});
|
||||
// Shared full-screen show flow. AdMob can drop OnAdFullScreenContentClosed for a shown ad
|
||||
// (focus loss/regain mid-ad, reloaded-ad reuse on a 2nd show, SDK edge cases); without a
|
||||
// fallback the awaiting caller hangs forever and the post-ad scene load never runs (loading
|
||||
// bar frozen at 0%). WatchShowAsync force-resolves via idempotent TrySetResult if the real
|
||||
// close event never arrives. buildResult is reused so a granted reward survives recovery.
|
||||
private async UniTask<AdShowResult> ShowFullScreenAsync(
|
||||
AdFormat format,
|
||||
Action<Action, Action<AdError>> subscribe,
|
||||
Action<Action, Action<AdError>> unsubscribe,
|
||||
Func<AdShowResult> buildResult,
|
||||
Action show,
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
var tcs = new UniTaskCompletionSource<AdShowResult>();
|
||||
bool resolved = false;
|
||||
|
||||
Action onClosed = () => { resolved = true; tcs.TrySetResult(buildResult()); };
|
||||
Action<AdError> onFailed = err => { resolved = true; tcs.TrySetResult(AdShowResult.Failure(err?.GetMessage())); };
|
||||
|
||||
subscribe(onClosed, onFailed);
|
||||
show();
|
||||
WatchShowAsync(tcs, () => resolved, buildResult, cancellationToken).Forget();
|
||||
|
||||
try
|
||||
{
|
||||
@@ -447,39 +496,59 @@ namespace Darkmatter.Services.Ads
|
||||
}
|
||||
finally
|
||||
{
|
||||
if (_rewardedInterstitial != null)
|
||||
{
|
||||
_rewardedInterstitial.OnAdFullScreenContentClosed -= onClosed;
|
||||
_rewardedInterstitial.OnAdFullScreenContentFailed -= onFailed;
|
||||
}
|
||||
ScheduleReload(AdFormat.RewardedInterstitial);
|
||||
resolved = true; // stop the watchdog within one poll
|
||||
unsubscribe(onClosed, onFailed);
|
||||
ScheduleReload(format);
|
||||
}
|
||||
}
|
||||
|
||||
private async UniTask<AdShowResult> ShowAppOpenAsync(CancellationToken cancellationToken)
|
||||
// Recovers a full-screen show when AdMob never raises its close callback. Primary signal:
|
||||
// the ad interrupts the app (focus-loss on Android, pause on iOS) and then the app returns
|
||||
// to the foreground — independent of OnAdFullScreenContentOpened, which can also be dropped.
|
||||
// Fallback: a hard time cap for platforms/cases where neither lifecycle event fires.
|
||||
// Realtime delays so a paused game (timeScale = 0) can't freeze the watchdog.
|
||||
private async UniTaskVoid WatchShowAsync(
|
||||
UniTaskCompletionSource<AdShowResult> tcs,
|
||||
Func<bool> isResolved,
|
||||
Func<AdShowResult> buildResult,
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
var tcs = new UniTaskCompletionSource<AdShowResult>();
|
||||
Action onClosed = () => tcs.TrySetResult(AdShowResult.Success());
|
||||
Action<AdError> onFailed = err => tcs.TrySetResult(AdShowResult.Failure(err?.GetMessage()));
|
||||
|
||||
_appOpen.OnAdFullScreenContentClosed += onClosed;
|
||||
_appOpen.OnAdFullScreenContentFailed += onFailed;
|
||||
_appOpen.Show();
|
||||
const int PollMs = 250;
|
||||
const int ForegroundGraceMs = 750;
|
||||
bool sawInterrupted = false;
|
||||
float elapsed = 0f;
|
||||
|
||||
try
|
||||
{
|
||||
using (cancellationToken.Register(() => tcs.TrySetCanceled(cancellationToken)))
|
||||
return await tcs.Task;
|
||||
}
|
||||
finally
|
||||
{
|
||||
if (_appOpen != null)
|
||||
while (!cancellationToken.IsCancellationRequested && !isResolved())
|
||||
{
|
||||
_appOpen.OnAdFullScreenContentClosed -= onClosed;
|
||||
_appOpen.OnAdFullScreenContentFailed -= onFailed;
|
||||
await UniTask.Delay(PollMs, DelayType.Realtime, PlayerLoopTiming.Update, cancellationToken);
|
||||
if (isResolved()) return;
|
||||
elapsed += PollMs / 1000f;
|
||||
|
||||
if (AppInterrupted)
|
||||
{
|
||||
sawInterrupted = true; // ad took the foreground
|
||||
}
|
||||
else if (sawInterrupted)
|
||||
{
|
||||
// App returned to foreground after the ad held it => ad was dismissed but
|
||||
// the close callback was dropped. Brief grace for the real event, then force.
|
||||
await UniTask.Delay(ForegroundGraceMs, DelayType.Realtime, PlayerLoopTiming.Update, cancellationToken);
|
||||
if (!isResolved() && tcs.TrySetResult(buildResult()))
|
||||
Debug.LogWarning("[AdMobAdService] Close callback missed; recovered via foreground watchdog.");
|
||||
return;
|
||||
}
|
||||
|
||||
if (elapsed >= showWatchdogSeconds)
|
||||
{
|
||||
if (!isResolved() && tcs.TrySetResult(buildResult()))
|
||||
Debug.LogWarning($"[AdMobAdService] Close callback missed; recovered via {showWatchdogSeconds:0}s watchdog cap.");
|
||||
return;
|
||||
}
|
||||
}
|
||||
ScheduleReload(AdFormat.AppOpen);
|
||||
}
|
||||
catch (OperationCanceledException) { }
|
||||
}
|
||||
|
||||
private void WireFullScreenEvents(InterstitialAd ad, AdFormat format) =>
|
||||
|
||||
Reference in New Issue
Block a user