This commit is contained in:
Savya Bikram Shah
2026-06-07 18:41:02 +05:45
parent a15b08df0c
commit 729ddf9277
8 changed files with 1791 additions and 69 deletions

View File

@@ -28,7 +28,12 @@ public class ColorbookFlowController : IAsyncStartable, IDisposable
private IDisposable _selectedSub;
private IDisposable _returnToMainMenuSubscription;
private bool _navigatingToGameplay;
private int _selectCount;
// App-session counter, NOT instance state. ColorbookFlowController is scene-scoped: a fresh one
// is built every time the Colorbook scene loads, and selecting a drawing unloads the scene — so
// each visit makes exactly ONE selection. An instance counter would reset to 0 each visit, land
// on 1 (odd) every time, and the "% 2 == 0" show branch would NEVER fire. static persists across
// visits for the whole app run (resets on restart), matching the per-session interstitial cap.
private static int _selectCount;
private CancellationTokenSource _scopeCts;
public ColorbookFlowController(
@@ -65,15 +70,21 @@ public class ColorbookFlowController : IAsyncStartable, IDisposable
if (!_navigatingToGameplay) _loadingScreen.Hide();
PrewarmInterstitialAdAsync(ct).Forget();
PrewarmInterstitialAdAsync().Forget();
}
private async UniTaskVoid PrewarmInterstitialAdAsync(CancellationToken ct)
// Ad ops MUST NOT use the Colorbook scope token. This scene unloads inside HandleSelectionAsync —
// the same moment the interstitial should show — which disposes ColorBookLifetimeScope, calls our
// Dispose(), and cancels _scopeCts. A scope-bound load/show then aborts mid-flight (load: never
// ready; show: await throws OCE, handlers unsubscribed). AdMobAdService is the Boot/Root-scoped,
// app-lifetime singleton and self-manages teardown (_lifetimeCts + OnDestroy), so drive ad ops on
// CancellationToken.None — they intentionally outlive this scene.
private async UniTaskVoid PrewarmInterstitialAdAsync()
{
try
{
if (!_ads.IsInitialized) await _ads.InitializeAsync(ct);
if (!_ads.IsReady(AdFormat.Interstitial)) await _ads.LoadAsync(AdFormat.Interstitial, ct);
if (!_ads.IsInitialized) await _ads.InitializeAsync(CancellationToken.None);
if (!_ads.IsReady(AdFormat.Interstitial)) await _ads.LoadAsync(AdFormat.Interstitial, CancellationToken.None);
}
catch (OperationCanceledException) { }
catch (Exception ex)
@@ -106,19 +117,18 @@ public class ColorbookFlowController : IAsyncStartable, IDisposable
private async UniTaskVoid HandleSelectionAsync(string templateId)
{
var ct = _scopeCts?.Token ?? CancellationToken.None;
_loadingScreen.Show();
_loadingScreen.SetProgress(0f);
// Frequency cap: show an interstitial on every 2nd level open (2nd, 4th, ...). On skip turns
// just keep one prewarmed so the next show has an ad ready. Fire-and-forget either way — the
// ad overlays the transition while the level loads underneath, so a missed/dropped ad callback
// can't stall the flow at 0% anymore.
// can't stall the flow at 0% anymore. Ad ops run on CancellationToken.None, NOT the scene
// scope token: this scene unloads further down, which would otherwise cancel the show.
if (++_selectCount % 2 == 0)
ShowInterstitialAdAsync(ct).Forget();
ShowInterstitialAdAsync().Forget();
else
PrewarmInterstitialAdAsync(ct).Forget();
PrewarmInterstitialAdAsync().Forget();
try
{
@@ -143,7 +153,8 @@ public class ColorbookFlowController : IAsyncStartable, IDisposable
// 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)
// CancellationToken.None: the show must survive this scene's unload (see PrewarmInterstitialAdAsync).
private async UniTaskVoid ShowInterstitialAdAsync()
{
try
{
@@ -151,11 +162,11 @@ public class ColorbookFlowController : IAsyncStartable, IDisposable
if (!_ads.IsReady(AdFormat.Interstitial))
{
_ads.LoadAsync(AdFormat.Interstitial, ct).Forget();
_ads.LoadAsync(AdFormat.Interstitial, CancellationToken.None).Forget();
return;
}
await _ads.ShowAsync(AdFormat.Interstitial, ct);
await _ads.ShowAsync(AdFormat.Interstitial, CancellationToken.None);
}
catch (OperationCanceledException) { }
catch (Exception ex)

View File

@@ -19,27 +19,40 @@ namespace Darkmatter.Services.Ads
public class AdMobAdService : MonoBehaviour, IAdService
{
[SerializeField] private AdUnitCatalogSO catalog;
[Tooltip("Auto-reload after dismiss/failure for non-banner formats.")]
[SerializeField] private bool autoReload = true;
[Tooltip("Seconds between auto-reload retries on failure.")]
[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;
[Tooltip("Max interstitials shown per app session (run). Once reached, ShowAsync(Interstitial) no-ops until the app restarts. Counts only ads actually shown, not failed/skipped attempts. 0 disables interstitials.")]
[SerializeField, Min(0)] private int maxInterstitialsPerSession = 8;
[Tooltip("Auto-reload after dismiss/failure for non-banner formats.")] [SerializeField]
private bool autoReload = true;
[Tooltip("Seconds between auto-reload retries on failure.")] [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;
[Tooltip(
"Max interstitials shown per app session (run). Once reached, ShowAsync(Interstitial) no-ops until the app restarts. Counts only ads actually shown, not failed/skipped attempts. 0 disables interstitials.")]
[SerializeField, Min(0)]
private int maxInterstitialsPerSession = 8;
public bool IsInitialized => _initialized;
public event Action<AdFormat, AdLoadState> LoadStateChanged;
private IAnalyticsService _analytics;
private bool _initialized;
// Per-session interstitial counter. The service is the app-lifetime singleton (it retains
// loaded ads across scene swaps), so this survives Colorbook<->Gameplay transitions and only
// resets on app restart — i.e. a true per-session cap.
private int _interstitialsShownThisSession;
private bool _hasUserConsent = true;
// Coloring book is a child-directed app, so default to true. SetConsent can still
// override if a consent flow later supplies a different value.
private bool _isChildDirected = true;
@@ -165,6 +178,7 @@ namespace Darkmatter.Services.Ads
SetState(format, AdLoadState.Failed);
return false;
}
return false;
#else
await UniTask.CompletedTask;
@@ -198,7 +212,7 @@ namespace Darkmatter.Services.Ads
// Per-session interstitial cap. Check before load so a capped show wastes no fill. Skip
// is silent (Failure, Shown=false); the fire-and-forget caller just keeps playing.
if (format == AdFormat.Interstitial && _interstitialsShownThisSession >= maxInterstitialsPerSession)
return AdShowResult.Failure("Session interstitial cap reached.");
return AdShowResult.Failure("[AdMobAdService] Session interstitial cap reached.");
if (!IsReady(format))
{
@@ -206,6 +220,7 @@ namespace Darkmatter.Services.Ads
if (!loaded) return AdShowResult.Failure($"{format} failed to load.");
}
Debug.Log($"[AdMobAdService] Showing {format} ad.");
switch (format)
{
case AdFormat.Interstitial:
@@ -218,6 +233,7 @@ namespace Darkmatter.Services.Ads
case AdFormat.RewardedInterstitial: return await ShowRewardedInterstitialAsync(cancellationToken);
case AdFormat.AppOpen: return await ShowAppOpenAsync(cancellationToken);
}
return AdShowResult.Failure($"{format} not supported by ShowAsync.");
#else
await UniTask.CompletedTask;
@@ -225,7 +241,8 @@ namespace Darkmatter.Services.Ads
#endif
}
public async UniTask<bool> ShowBannerAsync(BannerSize size, BannerPosition position, CancellationToken cancellationToken)
public async UniTask<bool> ShowBannerAsync(BannerSize size, BannerPosition position,
CancellationToken cancellationToken)
{
if (!_initialized) return false;
@@ -309,7 +326,8 @@ namespace Darkmatter.Services.Ads
MobileAds.SetRequestConfiguration(config);
}
private async UniTask<bool> LoadInterstitialAsync(string unitId, AdRequest request, CancellationToken cancellationToken)
private async UniTask<bool> LoadInterstitialAsync(string unitId, AdRequest request,
CancellationToken cancellationToken)
{
var tcs = new UniTaskCompletionSource<bool>();
InterstitialAd.Load(unitId, request, (ad, error) =>
@@ -320,6 +338,7 @@ namespace Darkmatter.Services.Ads
tcs.TrySetResult(false);
return;
}
_interstitial?.Destroy();
_interstitial = ad;
WireFullScreenEvents(ad, AdFormat.Interstitial);
@@ -334,7 +353,8 @@ namespace Darkmatter.Services.Ads
}
}
private async UniTask<bool> LoadRewardedAsync(string unitId, AdRequest request, CancellationToken cancellationToken)
private async UniTask<bool> LoadRewardedAsync(string unitId, AdRequest request,
CancellationToken cancellationToken)
{
var tcs = new UniTaskCompletionSource<bool>();
RewardedAd.Load(unitId, request, (ad, error) =>
@@ -345,6 +365,7 @@ namespace Darkmatter.Services.Ads
tcs.TrySetResult(false);
return;
}
_rewarded?.Destroy();
_rewarded = ad;
WireFullScreenEvents(ad, AdFormat.Rewarded);
@@ -359,7 +380,8 @@ namespace Darkmatter.Services.Ads
}
}
private async UniTask<bool> LoadRewardedInterstitialAsync(string unitId, AdRequest request, CancellationToken cancellationToken)
private async UniTask<bool> LoadRewardedInterstitialAsync(string unitId, AdRequest request,
CancellationToken cancellationToken)
{
var tcs = new UniTaskCompletionSource<bool>();
RewardedInterstitialAd.Load(unitId, request, (ad, error) =>
@@ -370,6 +392,7 @@ namespace Darkmatter.Services.Ads
tcs.TrySetResult(false);
return;
}
_rewardedInterstitial?.Destroy();
_rewardedInterstitial = ad;
WireFullScreenEvents(ad, AdFormat.RewardedInterstitial);
@@ -384,7 +407,8 @@ namespace Darkmatter.Services.Ads
}
}
private async UniTask<bool> LoadAppOpenAsync(string unitId, AdRequest request, CancellationToken cancellationToken)
private async UniTask<bool> LoadAppOpenAsync(string unitId, AdRequest request,
CancellationToken cancellationToken)
{
var tcs = new UniTaskCompletionSource<bool>();
AppOpenAd.Load(unitId, request, (ad, error) =>
@@ -395,6 +419,7 @@ namespace Darkmatter.Services.Ads
tcs.TrySetResult(false);
return;
}
_appOpen?.Destroy();
_appOpen = ad;
WireFullScreenEvents(ad, AdFormat.AppOpen);
@@ -513,8 +538,16 @@ namespace Darkmatter.Services.Ads
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())); };
Action onClosed = () =>
{
resolved = true;
tcs.TrySetResult(buildResult());
};
Action<AdError> onFailed = err =>
{
resolved = true;
tcs.TrySetResult(AdShowResult.Failure(err?.GetMessage()));
};
subscribe(onClosed, onFailed);
show();
@@ -570,21 +603,26 @@ namespace Darkmatter.Services.Ads
{
// 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);
await UniTask.Delay(ForegroundGraceMs, DelayType.Realtime, PlayerLoopTiming.Update,
cancellationToken);
if (!isResolved() && tcs.TrySetResult(buildResult()))
Debug.LogWarning("[AdMobAdService] Close callback missed; recovered via foreground watchdog.");
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.");
Debug.LogWarning(
$"[AdMobAdService] Close callback missed; recovered via {showWatchdogSeconds:0}s watchdog cap.");
return;
}
}
}
catch (OperationCanceledException) { }
catch (OperationCanceledException)
{
}
}
private void WireFullScreenEvents(InterstitialAd ad, AdFormat format)
@@ -658,9 +696,12 @@ namespace Darkmatter.Services.Ads
if (IsReady(format)) return;
if (await LoadAsync(format, cancellationToken)) return;
}
Debug.LogWarning($"[AdMobAdService] {format} reload gave up after {reloadMaxAttempts} attempts.");
}
catch (OperationCanceledException) { }
catch (OperationCanceledException)
{
}
}
private static AdSize MapBannerSize(BannerSize size) => size switch
@@ -671,7 +712,8 @@ namespace Darkmatter.Services.Ads
BannerSize.FullBanner => AdSize.IABBanner,
BannerSize.Leaderboard => AdSize.Leaderboard,
BannerSize.SmartBanner => AdSize.SmartBanner,
BannerSize.AnchoredAdaptive => AdSize.GetCurrentOrientationAnchoredAdaptiveBannerAdSizeWithWidth(AdSize.FullWidth),
BannerSize.AnchoredAdaptive => AdSize.GetCurrentOrientationAnchoredAdaptiveBannerAdSizeWithWidth(
AdSize.FullWidth),
_ => AdSize.Banner
};
@@ -694,4 +736,4 @@ namespace Darkmatter.Services.Ads
LoadStateChanged?.Invoke(format, state);
}
}
}
}