diff --git a/Assets/Scripts/FirstLook/FirstLookBannerController.cs b/Assets/Scripts/FirstLook/FirstLookBannerController.cs index 7abfd04..a361fde 100644 --- a/Assets/Scripts/FirstLook/FirstLookBannerController.cs +++ b/Assets/Scripts/FirstLook/FirstLookBannerController.cs @@ -5,10 +5,16 @@ /* * First Look banner: CloudX gets the first chance to fill, AdMob loads lazily - * as the fallback only after CloudX fails. Copy this file and FirstLookSource.cs - * into your project; it is the whole flow, top to bottom, with no base class to - * bring along. Reading order: state, the Load/Show/Hide entry points, the pass - * cycle, then each SDK's callbacks. + * as the fallback only after CloudX fails. + * + * This file decides which SDK fills a pass. It does not decide when the next + * pass starts, because it is a plain class with no clock. Copy three files: + * this one, FirstLookBannerCycle.cs for the timing, and FirstLookSource.cs for + * the enum every event reports. Taking this file alone leaves nothing driving + * the cycle, and the banner stops after its first pass. + * + * Reading order: state, the Load/Show/Hide entry points, the pass cycle, then + * each SDK's callbacks. * * A banner is not the interstitial with different method names. A fullscreen ad * is consumed by being shown, so the SDKs' own readiness answers go false and @@ -19,13 +25,27 @@ * scene is destroyed and one CloudX no-fill hands the slot to the fallback for * the rest of the session. * - * Two things the host has to do, or the cycle stalls: + * Three things the host has to do, or the cycle stalls or loops. + * FirstLookBannerCycle does all three; they are written out here for anyone + * driving this controller from their own component instead: * * 1. Start the next pass on PassSpent, after a cooldown of your choosing. * Reloading immediately is a request loop, because the new fill renders * into the visible view and spends the next pass at once. * 2. Cancel that pending pass when it calls Hide(), or a hidden slot keeps * requesting. Show() starts the cycle again. + * 3. Do not call Load() on a slot the player dismissed. Load() means the + * slot is wanted, so it lifts the cancellation below - including on a + * pass still out on the network - and a load asked for after a Hide puts + * the requests back with nothing to stop them. The preload before the + * first Show is a different thing and is fine: nothing has been + * dismissed yet. + * + * Hide also ends the pass already running, and that part is this controller's + * job rather than the host's: a CloudX load still in flight will not hand over + * to the fallback, and a later Show does not revive it. Only the next Load + * does - including one the in-flight guard drops, so a host tick that produces + * no callback cannot leave the cycle with nothing left to schedule from. * * Set Automatic refresh to Disabled on the AdMob ad unit you use as the * fallback. The Google Mobile Ads Unity plugin has no refresh API, so that @@ -78,6 +98,20 @@ public sealed class FirstLookBannerController : IDisposable private bool _isLoadingAdMob; private bool _wantShown; private bool _isShown; + + /* + * Whether the pass currently in flight was cancelled by a Hide. It tracks + * the pass, not the slot: clearing it on Show would revive a pass the + * player just cancelled, so only Load clears it. A hide-then-quick-show + * otherwise lets the old request's terminal callback land after the show + * and start the fallback at once, skipping the cooldown that show just + * restarted. + * + * _wantShown cannot do this job either, because it is also false during + * the preload before the first Show, and the preload has to be allowed to + * reach the fallback. + */ + private bool _passCancelled; private bool _isDisposed; /* @@ -136,7 +170,23 @@ public FirstLookSource? ReadySource */ public void Load() { - if (_isDisposed || _isLoadingCloudX || _isLoadingAdMob || ReadySource != null) + if (_isDisposed) + { + return; + } + + /* + * Whatever a Hide cancelled is history. This has to happen before the + * guard below rather than after it. A load is only ever asked for on a + * slot that is wanted, so it supersedes the cancellation even when the + * cancelled pass is still out on the network and the guard drops this + * call: leaving the flag set there would suppress that pass's terminal + * callback as well, and the host would get neither the PassSpent nor + * the failure it needs to schedule anything after it. + */ + _passCancelled = false; + + if (_isLoadingCloudX || _isLoadingAdMob || ReadySource != null) { return; } @@ -196,6 +246,7 @@ public void Hide() _wantShown = false; _isShown = false; + _passCancelled = true; HideCloudX(); HideAdMob(); @@ -260,8 +311,14 @@ private void ShowSource(FirstLookSource source, bool spendsPass) } } - /* Returns whether the fill went on screen. */ - private bool ShowIfWanted(FirstLookSource source, bool spendsPass) + /* + * Returns whether the fill went on screen. Two flags, because they answer + * different questions: "ours" is whether this controller asked for the + * load, and "spendsPass" is whether it should re-time the cycle. They part + * company for a pass a Hide cancelled - the fill is still ours to bank and + * show, but the cooldown belongs to whatever the host has scheduled since. + */ + private bool ShowIfWanted(FirstLookSource source, bool ours, bool spendsPass) { if (!_wantShown) { @@ -269,13 +326,13 @@ private bool ShowIfWanted(FirstLookSource source, bool spendsPass) } /* - * A fill from a pass legitimately replaces the ad the previous pass put - * up, so there is no _isShown check. A fill that is not part of a pass - * is different: letting AdMob's own refresh take the slot from CloudX + * A fill we asked for legitimately replaces the ad the previous pass + * put up, so there is no _isShown check. A fill nobody asked for is + * different: letting AdMob's own refresh take the slot from CloudX * would undo the source decision this pass made, so it only re-shows the * source that is already up. */ - if (!spendsPass && _shownSource != null && _shownSource != source) + if (!ours && _shownSource != null && _shownSource != source) { return false; } @@ -293,8 +350,8 @@ private bool ShowIfWanted(FirstLookSource source, bool spendsPass) * Nothing is lost by forgetting it; the native view keeps the creative and * the next pass reloads that side anyway. */ - private static bool KeepsUnspentFill(bool spendsPass, bool wentOnScreen) => - spendsPass && !wentOnScreen; + private static bool KeepsUnspentFill(bool ours, bool wentOnScreen) => + ours && !wentOnScreen; private void HideCloudX() { @@ -356,14 +413,22 @@ private void CloudXOnLoadSuccess(CloudXAd ad) * off, so in practice this is always true; the check keeps the two * sources reading the same way. */ - var spendsPass = _isLoadingCloudX; + var ours = _isLoadingCloudX; + + /* + * A fill for a pass a Hide cancelled is still worth banking and showing + * - it is an ad we paid a request for - but it must not raise PassSpent + * and reset the cooldown, which by now belongs to the show that came + * after the hide. + */ + var spendsPass = ours && !_passCancelled; _isLoadingCloudX = false; _cloudXLoaded = true; AdLoaded?.Invoke(FirstLookSource.CloudX); - var wentOnScreen = ShowIfWanted(FirstLookSource.CloudX, spendsPass); - _cloudXLoaded = KeepsUnspentFill(spendsPass, wentOnScreen); + var wentOnScreen = ShowIfWanted(FirstLookSource.CloudX, ours, spendsPass); + _cloudXLoaded = KeepsUnspentFill(ours, wentOnScreen); } private void CloudXOnLoadFailed(string adUnitId, CloudXError _) @@ -373,8 +438,21 @@ private void CloudXOnLoadFailed(string adUnitId, CloudXError _) return; } - /* The one place the fallback is triggered: CloudX had its first look. */ _isLoadingCloudX = false; + + /* + * A Hide cancelled this pass while the load was still running. Do not + * hand over to the fallback: the request would land on a slot the + * player dismissed, and if they have since shown it again, it would + * also jump the cooldown that show restarted. The next pass begins at + * CloudX, as every pass does. + */ + if (_passCancelled) + { + return; + } + + /* The one place the fallback is triggered: CloudX had its first look. */ LoadAdMobFallback(); } @@ -425,10 +503,17 @@ private void AdMobCreateAndLoad() { _isLoadingAdMob = false; - if (!_isDisposed) + /* + * Same reason as the CloudX leg: a cancelled pass must not reach + * the host, or its retry would run against a dismissed slot - or + * jump the cooldown, if the player has shown the banner again. + */ + if (_isDisposed || _passCancelled) { - AdLoadFailed?.Invoke(FirstLookSource.AdMob, error.GetMessage()); + return; } + + AdLoadFailed?.Invoke(FirstLookSource.AdMob, error.GetMessage()); }); _adMobBanner.OnAdClicked += () => MobileAdsEventExecutor.ExecuteInUpdate(() => @@ -453,7 +538,13 @@ private void OnAdMobLoaded() * but it does not count as a pass, so the pending pass keeps its * original schedule. */ - var spendsPass = _isLoadingAdMob; + var ours = _isLoadingAdMob; + + /* + * Same as the CloudX leg: a cancelled pass banks and shows, but does + * not re-time the cycle. + */ + var spendsPass = ours && !_passCancelled; _isLoadingAdMob = false; @@ -466,8 +557,8 @@ private void OnAdMobLoaded() _adMobLoaded = true; AdLoaded?.Invoke(FirstLookSource.AdMob); - var wentOnScreen = ShowIfWanted(FirstLookSource.AdMob, spendsPass); - _adMobLoaded = KeepsUnspentFill(spendsPass, wentOnScreen); + var wentOnScreen = ShowIfWanted(FirstLookSource.AdMob, ours, spendsPass); + _adMobLoaded = KeepsUnspentFill(ours, wentOnScreen); } private void DestroyAdMobAd() diff --git a/Assets/Scripts/FirstLook/FirstLookBannerCycle.cs b/Assets/Scripts/FirstLook/FirstLookBannerCycle.cs new file mode 100644 index 0000000..06ea63c --- /dev/null +++ b/Assets/Scripts/FirstLook/FirstLookBannerCycle.cs @@ -0,0 +1,184 @@ +using System; +using UnityEngine; + +/* + * The host half of the First Look banner contract, in one file. + * + * FirstLookBannerController decides which SDK fills a pass. It cannot decide + * when the next pass starts, because it is a plain class with no clock - no + * Update, no coroutine, no Invoke. That is what this MonoBehaviour adds, and + * it is the whole of what a scene has to contribute: + * + * 1. Start the next pass on PassSpent, after a cooldown. Reloading + * immediately is a request loop, because the new fill renders into the + * visible view and spends the next pass at once. + * 2. Cancel that pending pass on Hide, so a hidden slot stops requesting. + * 3. Do not retry a failed load while the banner is hidden. Cancelling is + * not enough on its own: a load already out on the network when the + * player hides completes afterwards, long after CancelInvoke had + * anything to cancel. A fill is harmless - it is banked for the next + * Show. A failure is not, because its retry would start the requests + * up again. + * + * Copy this file together with FirstLookBannerController.cs and + * FirstLookSource.cs. In your own project the ad unit ids would come from + * wherever you keep them - a serialized field, your remote config - instead of + * being passed to Begin by a demo screen. + * + * Background and the reasoning behind each rule: + * https://docs.cloudx.io/en/unity/integrations/first-look + */ +public sealed class FirstLookBannerCycle : MonoBehaviour +{ + /* + * How long a displayed banner stays up before the next First Look pass + * starts. Displaying an ad spends the pass (see FirstLookBannerController), + * and a fill into a visible view renders immediately, so reloading without + * a cooldown would be a request loop. Treat it like a banner refresh + * interval - 30s matches the usual default; anything very short both burns + * requests and hurts CPM. + */ + private const float PassCooldownSeconds = 30f; + + /* + * Retry policy after a failed load: 2s, 4s, 8s ... capped, and reset once a + * load succeeds. A fixed short delay turns sustained no-fill into a tight + * request loop against the fallback network, which ad networks penalise. + */ + private const float RetryBaseDelaySeconds = 2f; + private const float RetryMaxDelaySeconds = 60f; + + public event Action AdLoaded; + public event Action AdLoadFailed; + public event Action AdShown; + public event Action AdClicked; + + /* The banner left the screen because Toggle hid it. */ + public event Action AdHidden; + + /* Toggle wanted to show, but no source had an ad yet; a load is running. */ + public event Action ShowPending; + + private FirstLookBannerController _banner; + private int _retries; + + /* + * Whether the slot should hold an ad at all. IsShown is not enough: it is + * also false during the preload before the first Toggle, when a retry is + * still wanted. + */ + private bool _wanted = true; + + public bool IsShown => _banner != null && _banner.IsShown; + + /* + * Creates the controller and preloads one pass, so an ad is ready the first + * time the player asks for one. Call once, after CloudX has answered - + * initialized, failed, or past a timeout of your own - passing whether it + * actually came up. + * + * Google Mobile Ads does not have to be ready. It queues loads issued while + * it is still initializing, and the fallback is lazy in any case, so + * waiting for it as well would only delay the first pass. + */ + public void Begin(string cloudXAdUnitId, string adMobAdUnitId, bool cloudXAvailable) + { + if (_banner != null) + { + return; + } + + _banner = new FirstLookBannerController(cloudXAdUnitId, adMobAdUnitId, cloudXAvailable); + _banner.AdLoaded += OnAdLoaded; + _banner.AdLoadFailed += OnAdLoadFailed; + _banner.AdShown += source => AdShown?.Invoke(source); + _banner.AdClicked += source => AdClicked?.Invoke(source); + _banner.PassSpent += ScheduleNextPass; + + LoadBanner(); + } + + /* Shows the banner if it is hidden, hides it if it is up. */ + public void Toggle() + { + if (_banner == null) + { + return; + } + + if (_banner.IsShown) + { + _wanted = false; + _banner.Hide(); + /* Nothing on screen, so the pass cycle stops until the next show. */ + CancelInvoke(nameof(LoadBanner)); + AdHidden?.Invoke(); + return; + } + + _wanted = true; + + /* AdShown fires once a source actually goes on screen. */ + if (!_banner.Show()) + { + ShowPending?.Invoke(); + LoadBanner(); + } + } + + private void OnAdLoaded(FirstLookSource source) + { + _retries = 0; + AdLoaded?.Invoke(source); + } + + private void OnAdLoadFailed(FirstLookSource source, string message) + { + AdLoadFailed?.Invoke(source, message); + + /* + * Rule 3: a load that fails after the hide must not revive the slot. + * FirstLookBannerController already drops the terminal failure of a + * cancelled pass, so nothing reaches this in practice. It stays because + * the rule is the host's to keep - drive that controller from your own + * component and this is the line that keeps it true. + */ + if (!_wanted) + { + return; + } + + Invoke(nameof(LoadBanner), NextRetryDelay()); + } + + /* + * Rule 1. Cancelling first collapses a pending backoff retry into this one - + * both end up calling LoadBanner, and two pending invokes would arbitrate + * the placement twice. Showing again after a hide raises PassSpent too, + * which restarts the cooldown from that moment. + */ + private void ScheduleNextPass() + { + CancelInvoke(nameof(LoadBanner)); + Invoke(nameof(LoadBanner), PassCooldownSeconds); + } + + private float NextRetryDelay() + { + var delay = Mathf.Min(RetryBaseDelaySeconds * Mathf.Pow(2f, _retries), RetryMaxDelaySeconds); + _retries++; + return delay; + } + + /* Named so Invoke(nameof(...)) can reach it. */ + private void LoadBanner() + { + _banner?.Load(); + } + + private void OnDestroy() + { + _banner?.Dispose(); + _banner = null; + } +} diff --git a/Assets/Scripts/FirstLook/FirstLookBannerCycle.cs.meta b/Assets/Scripts/FirstLook/FirstLookBannerCycle.cs.meta new file mode 100644 index 0000000..2ec45fd --- /dev/null +++ b/Assets/Scripts/FirstLook/FirstLookBannerCycle.cs.meta @@ -0,0 +1,2 @@ +fileFormatVersion: 2 +guid: 8c44f9a7815024a5f93fc019485654e7 \ No newline at end of file diff --git a/Assets/Scripts/FirstLook/FirstLookConfig.cs b/Assets/Scripts/FirstLook/FirstLookConfig.cs index c229634..c2f6aef 100644 --- a/Assets/Scripts/FirstLook/FirstLookConfig.cs +++ b/Assets/Scripts/FirstLook/FirstLookConfig.cs @@ -1,12 +1,15 @@ /* - * Fallback-side configuration for the First Look demo. The CloudX ad unit ids - * come from DemoConfig; these are Google's official AdMob TEST ad unit ids. + * The AdMob ad unit ids the First Look demo falls back to. The CloudX ad unit + * ids come from DemoConfig; these are Google's official AdMob TEST ad unit ids. * Replace them with your own AdMob ad units in a real integration. * * When you do, set Automatic refresh to Disabled on the banner unit in the * AdMob console. The Unity plugin cannot control it, and a refreshing AdMob * banner would replace the ad that won the First Look pass. * + * The banner pass cooldown is not here; it lives in FirstLookBannerCycle, + * which paces it. + * * https://docs.cloudx.io/en/unity/integrations/first-look */ public static class FirstLookConfig @@ -18,25 +21,4 @@ public static class FirstLookConfig public const string AdMobInterstitialAdUnitId = "ca-app-pub-3940256099942544/1033173712"; public const string AdMobBannerAdUnitId = "ca-app-pub-3940256099942544/6300978111"; #endif - - /* - * How long a displayed banner stays up before the next First Look pass - * starts. Displaying an ad spends the pass (see FirstLookBannerController), - * and a fill into a visible view renders immediately, so reloading without - * a cooldown would be a request loop. Treat it like a banner refresh - * interval - 30s matches the usual default; anything very short both burns - * requests and hurts CPM. - */ - public const float PassCooldownSeconds = 30f; - - /* - * Flip to true to exercise the AdMob fallback path: CloudX is asked to fill - * an unknown ad unit, fails to load, and the controllers fall back to AdMob. - */ - public const bool ForceCloudXNoFill = false; - - private const string InvalidCloudXAdUnitId = "first-look-invalid-unit"; - - public static string CloudXAdUnitOrInvalid(string realAdUnitId) => - ForceCloudXNoFill ? InvalidCloudXAdUnitId : realAdUnitId; } diff --git a/Assets/Scripts/FirstLook/FirstLookScreen.cs b/Assets/Scripts/FirstLook/FirstLookScreen.cs index a7e8f66..329c0b3 100644 --- a/Assets/Scripts/FirstLook/FirstLookScreen.cs +++ b/Assets/Scripts/FirstLook/FirstLookScreen.cs @@ -16,14 +16,16 @@ * banner exactly, so adding them here would only repeat a pattern; the General * screen already shows the SDK calls for all four formats. * - * The flow lives entirely in this folder, and each controller is one - * self-contained file, so integrating a format means copying two files: that - * controller and FirstLookSource.cs. AdScreenUi is demo-only layout and is kept - * out on purpose; this screen hides the two buttons it does not use. + * The flow lives entirely in this folder. Integrating the interstitial means + * copying two files, FirstLookInterstitialController.cs and FirstLookSource.cs; + * the banner adds FirstLookBannerCycle.cs for the clock. AdScreenUi is demo-only + * layout and is kept out on purpose; this screen hides the two buttons it does + * not use. * - * This screen is also the reference for the half of the banner contract the - * controller cannot keep for you: ScheduleNextBannerPass starts the next pass a - * cooldown after PassSpent, and ToggleBanner cancels it on hide. + * The banner needs a second half the controller cannot provide - a clock, to + * time the next pass and to stop requesting once the slot is hidden. That half + * is FirstLookBannerCycle, kept in its own file so it can be copied alongside the + * controller; this screen only binds it to buttons and status text. * * https://docs.cloudx.io/en/unity/integrations/first-look */ @@ -34,20 +36,20 @@ public class FirstLookScreen : MonoBehaviour private const float InitializationUiTimeoutSeconds = 15f; /* - * Retry policy after a load or show failure: 2s, 4s, 8s ... capped, and - * reset once a load succeeds. A fixed short delay turns sustained no-fill - * into a tight request loop against the fallback network, which ad - * networks penalise. + * Interstitial retry policy after a load or show failure: 2s, 4s, 8s ... + * capped, and reset once a load succeeds. A fixed short delay turns + * sustained no-fill into a tight request loop against the fallback network, + * which ad networks penalise. The banner runs the same backoff inside + * FirstLookBannerCycle, so that file stands alone. */ private const float RetryBaseDelaySeconds = 2f; private const float RetryMaxDelaySeconds = 60f; private AdScreenUi _ui; private FirstLookInterstitialController _interstitial; - private FirstLookBannerController _banner; + private FirstLookBannerCycle _banner; private bool _cloudXInitAnswered; private int _interstitialRetries; - private int _bannerRetries; private string _cloudXStatus = "CloudX: Initializing"; private string _adMobStatus = "AdMob: Initializing"; @@ -105,8 +107,7 @@ void OnDestroy() _interstitial?.Dispose(); _interstitial = null; - _banner?.Dispose(); - _banner = null; + /* _banner is a component on this GameObject; its OnDestroy disposes it. */ } /* @@ -215,7 +216,7 @@ private void CreateControllers(bool cloudXAvailable) } _interstitial = new FirstLookInterstitialController( - FirstLookConfig.CloudXAdUnitOrInvalid(DemoConfig.InterstitialAdUnitId), + DemoConfig.InterstitialAdUnitId, FirstLookConfig.AdMobInterstitialAdUnitId, cloudXAvailable); _interstitial.AdLoaded += source => @@ -225,14 +226,14 @@ private void CreateControllers(bool cloudXAvailable) }; _interstitial.AdLoadFailed += (source, message) => { - var delay = NextRetryDelay(ref _interstitialRetries); + var delay = NextInterstitialRetryDelay(); SetInterstitialStatus($"Load failed ({source}): {message}\nRetrying in {delay:0}s..."); Invoke(nameof(LoadInterstitial), delay); }; _interstitial.AdShown += source => SetInterstitialStatus($"Showing ({source})"); _interstitial.AdShowFailed += (source, message) => { - var delay = NextRetryDelay(ref _interstitialRetries); + var delay = NextInterstitialRetryDelay(); SetInterstitialStatus($"Show failed ({source}): {message}\nRetrying in {delay:0}s..."); Invoke(nameof(LoadInterstitial), delay); }; @@ -244,31 +245,36 @@ private void CreateControllers(bool cloudXAvailable) }; _interstitial.AdClicked += source => Log($"Interstitial clicked ({source})"); - _banner = new FirstLookBannerController( - FirstLookConfig.CloudXAdUnitOrInvalid(DemoConfig.BannerAdUnitId), - FirstLookConfig.AdMobBannerAdUnitId, - cloudXAvailable); + /* + * The cycle is added here rather than sitting in the scene because the ad + * unit ids are only settled once initialization has answered. + */ + _banner = gameObject.AddComponent(); _banner.AdLoaded += source => { - _bannerRetries = 0; Log($"Banner loaded ({source})"); if (!_banner.IsShown) { _ui.SetBannerButtonLabel("Show Banner"); } }; + /* + * Only a live pass reaches this: the controller drops the terminal + * failure of a pass a Hide cancelled, so there is no hidden-slot case + * to report here. + */ _banner.AdLoadFailed += (source, message) => - { - var delay = NextRetryDelay(ref _bannerRetries); - Log($"Banner load failed ({source}): {message}; retrying in {delay:0}s"); - Invoke(nameof(LoadBanner), delay); - }; + Log($"Banner load failed ({source}): {message}; retrying"); _banner.AdShown += source => _ui.SetBannerButtonLabel($"Hide Banner ({source})"); - _banner.PassSpent += ScheduleNextBannerPass; + _banner.AdHidden += () => _ui.SetBannerButtonLabel("Show Banner"); + _banner.ShowPending += () => _ui.SetBannerButtonLabel("Banner: loading..."); _banner.AdClicked += source => Log($"Banner clicked ({source})"); LoadInterstitial(); - LoadBanner(); + _banner.Begin( + DemoConfig.BannerAdUnitId, + FirstLookConfig.AdMobBannerAdUnitId, + cloudXAvailable); _ui.SetActionsInteractable(true); } @@ -296,58 +302,25 @@ private void ShowInterstitial() private void ToggleBanner() { - if (_banner.IsShown) - { - _banner.Hide(); - /* Nothing on screen, so the pass cycle stops until the next Show. */ - CancelInvoke(nameof(LoadBanner)); - _ui.SetBannerButtonLabel("Show Banner"); - return; - } - - /* AdShown updates the label once a source actually shows. */ - if (!_banner.Show()) - { - _ui.SetBannerButtonLabel("Banner: loading..."); - LoadBanner(); - } - } - - /* - * Banner only: putting one on screen spends its First Look pass, so the - * next pass is scheduled a cooldown later. Cancelling first collapses a - * pending backoff retry into this one - both end up calling LoadBanner, and - * two pending invokes would arbitrate the placement twice. Showing again - * after a Hide raises PassSpent too, which restarts the cooldown from that - * moment. - */ - private void ScheduleNextBannerPass() - { - CancelInvoke(nameof(LoadBanner)); - Invoke(nameof(LoadBanner), FirstLookConfig.PassCooldownSeconds); + /* The cycle owns the timing; the label follows from its events. */ + _banner.Toggle(); } - private static float NextRetryDelay(ref int retries) + private float NextInterstitialRetryDelay() { - var delay = Mathf.Min(RetryBaseDelaySeconds * Mathf.Pow(2f, retries), RetryMaxDelaySeconds); - retries++; + var delay = Mathf.Min( + RetryBaseDelaySeconds * Mathf.Pow(2f, _interstitialRetries), + RetryMaxDelaySeconds); + _interstitialRetries++; return delay; } - /* - * Named methods so terminal failures can retry via Invoke(nameof(...)). - */ - + /* Named so terminal interstitial failures can retry via Invoke(nameof(...)). */ private void LoadInterstitial() { _interstitial?.Load(); } - private void LoadBanner() - { - _banner?.Load(); - } - /* * Status plumbing */ diff --git a/README.md b/README.md index cbcaf23..3187ac8 100644 --- a/README.md +++ b/README.md @@ -124,6 +124,8 @@ The status text names which SDK won, so you can see the pattern working:

Left: CloudX filled. Right: the same button after CloudX no-filled, showing Google's test creative. +To see the fallback yourself, point the CloudX ad unit ids in `DemoConfig.cs` at a string that is not +in your dashboard, so every CloudX load fails and AdMob has to serve. Everything the flow needs lives in `Assets/Scripts/FirstLook`, and none of it calls into the General screen: @@ -131,20 +133,19 @@ screen: | File | Role | | --- | --- | | `FirstLookInterstitialController.cs` | The whole interstitial flow, self-contained. | -| `FirstLookBannerController.cs` | The whole banner flow, self-contained, including the pass cycle. | +| `FirstLookBannerController.cs` | Which SDK fills a banner pass, self-contained. | +| `FirstLookBannerCycle.cs` | When the next pass starts: the clock the controller has no way to keep. | | `FirstLookSource.cs` | The `CloudX` / `AdMob` enum every event reports. | -| `FirstLookConfig.cs` | AdMob ad unit ids, the banner pass cooldown, and the fallback test switch below. | -| `FirstLookScreen.cs` | Initializes both SDKs, wires the controllers to the buttons. | +| `FirstLookConfig.cs` | The AdMob fallback ad unit ids. | +| `FirstLookScreen.cs` | Initializes both SDKs, wires the controller and the cycle to the buttons. | -**To integrate one format, copy two files:** that format's controller and `FirstLookSource.cs`. Each -controller is one file you can read top to bottom - state, the entry points, then each SDK's +**To integrate the interstitial, copy two files:** `FirstLookInterstitialController.cs` and +`FirstLookSource.cs`. The banner adds a third, `FirstLookBannerCycle.cs`, because a plain class has no +clock. Each is one file you can read top to bottom - state, the entry points, then each SDK's callbacks - with no base class to chase. The two controllers repeat about fifty lines of ad-unit and dispose bookkeeping between them; that is deliberate, so neither file drags a shared base along into your project. -To see the fallback path yourself, set `ForceCloudXNoFill = true` in `FirstLookConfig.cs` and rebuild. -It points CloudX at an unknown ad unit, so every CloudX load fails and AdMob serves instead. - The banner toggles Show/Hide and the button label names the SDK that filled (e.g. `Hide Banner (CloudX)`). It sits at the top in both orientations. @@ -156,8 +157,8 @@ A banner is not consumed the way a fullscreen ad is, so it needs one thing the i One pass is one ad opportunity: CloudX is asked first, AdMob only if CloudX fails, and the winner goes on screen. Putting an ad on screen **spends** the pass - CloudX inline ads report only load and click, and a load into a view that is already visible renders straight away, so that is the one moment the -code can treat as "this fill has been used". The screen then schedules the next pass -`FirstLookConfig.PassCooldownSeconds` later (30 s by default), and that pass starts at CloudX again. +code can treat as "this fill has been used". `FirstLookBannerCycle` then schedules the next pass +`PassCooldownSeconds` later (30 s by default), and that pass starts at CloudX again. Without the cycle the first fill would latch: after one CloudX no-fill the AdMob fallback would own the placement until the scene was destroyed, and CloudX would never get another first look. @@ -167,10 +168,13 @@ Three details worth copying as they are: - **Reloading is in place, not a recreate** - `LoadBanner` on the existing view, allowed because refresh was stopped for that ad unit - so a visible ad is replaced only once the new one has filled, and the slot never blanks. -- **The cycle only turns while an ad is on screen.** Hiding cancels the pending pass, so a hidden slot - never keeps requesting in the background, and showing it again puts the same ad back up and restarts - the cooldown from that tap. The screen still preloads once before the first tap, so an ad is ready - when the user asks for it. +- **The cycle only turns while an ad is on screen.** Hiding cancels the pending pass, so a hidden + slot never keeps requesting in the background; showing it again puts the same ad back up and + restarts the cooldown from that tap. Cancelling alone is not enough - a request already out on the + network completes after the hide, long after `CancelInvoke` had anything to cancel. A fill is + harmless, because it is banked for the next show; a failure is not, because its retry would start + the requests up again, so the cycle also tracks whether the banner is still wanted. It still + preloads once before the first tap, so an ad is ready when the user asks for it. - **An ad the AdMob console refreshed on its own does not count as a pass.** Only a fill the controller asked for spends one, so an AdMob unit that still has Automatic refresh enabled cannot keep postponing CloudX's next first look - which it otherwise would, on every refresh. The demo's