fixed
This commit is contained in:
@@ -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)
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user