diff --git a/Assets/Darkmatter/Code/Core/Contracts/Services/Ads.meta b/Assets/Darkmatter/Code/Core/Contracts/Services/Ads.meta new file mode 100644 index 0000000..a988e8d --- /dev/null +++ b/Assets/Darkmatter/Code/Core/Contracts/Services/Ads.meta @@ -0,0 +1,8 @@ +fileFormatVersion: 2 +guid: 683c2f491e8042d0a46bb7b5ad497be3 +folderAsset: yes +DefaultImporter: + externalObjects: {} + userData: + assetBundleName: + assetBundleVariant: diff --git a/Assets/Darkmatter/Code/Core/Contracts/Services/Ads/IAdService.cs b/Assets/Darkmatter/Code/Core/Contracts/Services/Ads/IAdService.cs new file mode 100644 index 0000000..01a4e48 --- /dev/null +++ b/Assets/Darkmatter/Code/Core/Contracts/Services/Ads/IAdService.cs @@ -0,0 +1,26 @@ +using System; +using System.Threading; +using Cysharp.Threading.Tasks; +using Darkmatter.Core.Data.Dynamic.Services.Ads; +using Darkmatter.Core.Enums.Services.Ads; + +namespace Darkmatter.Core.Contracts.Services.Ads +{ + public interface IAdService + { + bool IsInitialized { get; } + event Action LoadStateChanged; + + UniTask InitializeAsync(CancellationToken cancellationToken); + + UniTask LoadAsync(AdFormat format, CancellationToken cancellationToken); + bool IsReady(AdFormat format); + UniTask ShowAsync(AdFormat format, CancellationToken cancellationToken); + + UniTask ShowBannerAsync(BannerSize size, BannerPosition position, CancellationToken cancellationToken); + void HideBanner(); + void DestroyBanner(); + + void SetConsent(bool hasUserConsent, bool isChildDirected); + } +} diff --git a/Assets/Darkmatter/Code/Core/Contracts/Services/Ads/IAdService.cs.meta b/Assets/Darkmatter/Code/Core/Contracts/Services/Ads/IAdService.cs.meta new file mode 100644 index 0000000..761b6fe --- /dev/null +++ b/Assets/Darkmatter/Code/Core/Contracts/Services/Ads/IAdService.cs.meta @@ -0,0 +1,11 @@ +fileFormatVersion: 2 +guid: ffaede2f106c45a68197d70ec929c7de +MonoImporter: + externalObjects: {} + serializedVersion: 2 + defaultReferences: [] + executionOrder: 0 + icon: {instanceID: 0} + userData: + assetBundleName: + assetBundleVariant: diff --git a/Assets/Darkmatter/Code/Core/Data/Static/Services/Ads.meta b/Assets/Darkmatter/Code/Core/Data/Static/Services/Ads.meta new file mode 100644 index 0000000..c2dc952 --- /dev/null +++ b/Assets/Darkmatter/Code/Core/Data/Static/Services/Ads.meta @@ -0,0 +1,8 @@ +fileFormatVersion: 2 +guid: 4dcdb224825b4764a080e5b881b996e4 +folderAsset: yes +DefaultImporter: + externalObjects: {} + userData: + assetBundleName: + assetBundleVariant: diff --git a/Assets/Darkmatter/Code/Core/Data/Static/Services/Ads/AdUnitCatalogSO.cs b/Assets/Darkmatter/Code/Core/Data/Static/Services/Ads/AdUnitCatalogSO.cs new file mode 100644 index 0000000..020a1b1 --- /dev/null +++ b/Assets/Darkmatter/Code/Core/Data/Static/Services/Ads/AdUnitCatalogSO.cs @@ -0,0 +1,67 @@ +using System; +using System.Collections.Generic; +using Darkmatter.Core.Enums.Services.Ads; +using UnityEngine; + +namespace Darkmatter.Core.Data.Static.Services.Ads +{ + [CreateAssetMenu(fileName = "AdUnitCatalog", menuName = "Darkmatter/Ads/Ad Unit Catalog")] + public class AdUnitCatalogSO : ScriptableObject + { + [Serializable] + public class Entry + { + public AdFormat Format; + public string AndroidUnitId; + public string IosUnitId; + } + + [Header("App IDs")] + [SerializeField] private string androidAppId; + [SerializeField] private string iosAppId; + + [Header("Test Mode")] + [Tooltip("If true, returns Google sample ad unit IDs (safe for development).")] + [SerializeField] private bool useTestUnits = true; + + [Tooltip("Device IDs to treat as test devices (hashed IDs from logcat).")] + [SerializeField] private List testDeviceIds = new(); + + [Header("Production Unit IDs")] + [SerializeField] private List entries = new(); + + public string AndroidAppId => androidAppId; + public string IosAppId => iosAppId; + public bool UseTestUnits => useTestUnits; + public IReadOnlyList TestDeviceIds => testDeviceIds; + + public string GetUnitId(AdFormat format, RuntimePlatform platform) + { + if (useTestUnits) + { + return GetTestUnitId(format, platform); + } + + foreach (var e in entries) + { + if (e == null || e.Format != format) continue; + return platform == RuntimePlatform.IPhonePlayer ? e.IosUnitId : e.AndroidUnitId; + } + return null; + } + + private static string GetTestUnitId(AdFormat format, RuntimePlatform platform) + { + bool ios = platform == RuntimePlatform.IPhonePlayer; + return format switch + { + AdFormat.Banner => ios ? "ca-app-pub-3940256099942544/2934735716" : "ca-app-pub-3940256099942544/6300978111", + AdFormat.Interstitial => ios ? "ca-app-pub-3940256099942544/4411468910" : "ca-app-pub-3940256099942544/1033173712", + AdFormat.Rewarded => ios ? "ca-app-pub-3940256099942544/1712485313" : "ca-app-pub-3940256099942544/5224354917", + AdFormat.RewardedInterstitial => ios ? "ca-app-pub-3940256099942544/6978759866" : "ca-app-pub-3940256099942544/5354046379", + AdFormat.AppOpen => ios ? "ca-app-pub-3940256099942544/5575463023" : "ca-app-pub-3940256099942544/9257395921", + _ => null + }; + } + } +} diff --git a/Assets/Darkmatter/Code/Core/Data/Static/Services/Ads/AdUnitCatalogSO.cs.meta b/Assets/Darkmatter/Code/Core/Data/Static/Services/Ads/AdUnitCatalogSO.cs.meta new file mode 100644 index 0000000..0b6ed04 --- /dev/null +++ b/Assets/Darkmatter/Code/Core/Data/Static/Services/Ads/AdUnitCatalogSO.cs.meta @@ -0,0 +1,11 @@ +fileFormatVersion: 2 +guid: e6352f0162df4adf99a5945e25c6bf40 +MonoImporter: + externalObjects: {} + serializedVersion: 2 + defaultReferences: [] + executionOrder: 0 + icon: {instanceID: 0} + userData: + assetBundleName: + assetBundleVariant: diff --git a/Assets/Darkmatter/Code/Core/Enums/Services/Ads.meta b/Assets/Darkmatter/Code/Core/Enums/Services/Ads.meta new file mode 100644 index 0000000..28be429 --- /dev/null +++ b/Assets/Darkmatter/Code/Core/Enums/Services/Ads.meta @@ -0,0 +1,8 @@ +fileFormatVersion: 2 +guid: 40846ca6e71249d68a99e1d226fd162d +folderAsset: yes +DefaultImporter: + externalObjects: {} + userData: + assetBundleName: + assetBundleVariant: diff --git a/Assets/Darkmatter/Code/Core/Enums/Services/Ads/AdFormat.cs b/Assets/Darkmatter/Code/Core/Enums/Services/Ads/AdFormat.cs new file mode 100644 index 0000000..6e669f0 --- /dev/null +++ b/Assets/Darkmatter/Code/Core/Enums/Services/Ads/AdFormat.cs @@ -0,0 +1,11 @@ +namespace Darkmatter.Core.Enums.Services.Ads +{ + public enum AdFormat + { + Banner, + Interstitial, + Rewarded, + RewardedInterstitial, + AppOpen + } +} diff --git a/Assets/Darkmatter/Code/Core/Enums/Services/Ads/AdFormat.cs.meta b/Assets/Darkmatter/Code/Core/Enums/Services/Ads/AdFormat.cs.meta new file mode 100644 index 0000000..4df66a5 --- /dev/null +++ b/Assets/Darkmatter/Code/Core/Enums/Services/Ads/AdFormat.cs.meta @@ -0,0 +1,11 @@ +fileFormatVersion: 2 +guid: ce1e736d19fc4569af80b06274dc3935 +MonoImporter: + externalObjects: {} + serializedVersion: 2 + defaultReferences: [] + executionOrder: 0 + icon: {instanceID: 0} + userData: + assetBundleName: + assetBundleVariant: diff --git a/Assets/Darkmatter/Code/Core/Enums/Services/Ads/AdLoadState.cs b/Assets/Darkmatter/Code/Core/Enums/Services/Ads/AdLoadState.cs new file mode 100644 index 0000000..16ea894 --- /dev/null +++ b/Assets/Darkmatter/Code/Core/Enums/Services/Ads/AdLoadState.cs @@ -0,0 +1,10 @@ +namespace Darkmatter.Core.Enums.Services.Ads +{ + public enum AdLoadState + { + Idle, + Loading, + Loaded, + Failed + } +} diff --git a/Assets/Darkmatter/Code/Core/Enums/Services/Ads/AdLoadState.cs.meta b/Assets/Darkmatter/Code/Core/Enums/Services/Ads/AdLoadState.cs.meta new file mode 100644 index 0000000..5d26424 --- /dev/null +++ b/Assets/Darkmatter/Code/Core/Enums/Services/Ads/AdLoadState.cs.meta @@ -0,0 +1,11 @@ +fileFormatVersion: 2 +guid: 44f09c3955b34814abfa83407118a6a5 +MonoImporter: + externalObjects: {} + serializedVersion: 2 + defaultReferences: [] + executionOrder: 0 + icon: {instanceID: 0} + userData: + assetBundleName: + assetBundleVariant: diff --git a/Assets/Darkmatter/Code/Core/Enums/Services/Ads/BannerPosition.cs b/Assets/Darkmatter/Code/Core/Enums/Services/Ads/BannerPosition.cs new file mode 100644 index 0000000..4dd4833 --- /dev/null +++ b/Assets/Darkmatter/Code/Core/Enums/Services/Ads/BannerPosition.cs @@ -0,0 +1,13 @@ +namespace Darkmatter.Core.Enums.Services.Ads +{ + public enum BannerPosition + { + Top, + Bottom, + TopLeft, + TopRight, + BottomLeft, + BottomRight, + Center + } +} diff --git a/Assets/Darkmatter/Code/Core/Enums/Services/Ads/BannerPosition.cs.meta b/Assets/Darkmatter/Code/Core/Enums/Services/Ads/BannerPosition.cs.meta new file mode 100644 index 0000000..1423c11 --- /dev/null +++ b/Assets/Darkmatter/Code/Core/Enums/Services/Ads/BannerPosition.cs.meta @@ -0,0 +1,11 @@ +fileFormatVersion: 2 +guid: 2223e7f2cedb4f479f7c6722e0ca7fd1 +MonoImporter: + externalObjects: {} + serializedVersion: 2 + defaultReferences: [] + executionOrder: 0 + icon: {instanceID: 0} + userData: + assetBundleName: + assetBundleVariant: diff --git a/Assets/Darkmatter/Code/Core/Enums/Services/Ads/BannerSize.cs b/Assets/Darkmatter/Code/Core/Enums/Services/Ads/BannerSize.cs new file mode 100644 index 0000000..79aa47c --- /dev/null +++ b/Assets/Darkmatter/Code/Core/Enums/Services/Ads/BannerSize.cs @@ -0,0 +1,13 @@ +namespace Darkmatter.Core.Enums.Services.Ads +{ + public enum BannerSize + { + Banner, + LargeBanner, + MediumRectangle, + FullBanner, + Leaderboard, + SmartBanner, + AnchoredAdaptive + } +} diff --git a/Assets/Darkmatter/Code/Core/Enums/Services/Ads/BannerSize.cs.meta b/Assets/Darkmatter/Code/Core/Enums/Services/Ads/BannerSize.cs.meta new file mode 100644 index 0000000..3276742 --- /dev/null +++ b/Assets/Darkmatter/Code/Core/Enums/Services/Ads/BannerSize.cs.meta @@ -0,0 +1,11 @@ +fileFormatVersion: 2 +guid: 570a6e9b47c5451c8ef78aa7a3923ead +MonoImporter: + externalObjects: {} + serializedVersion: 2 + defaultReferences: [] + executionOrder: 0 + icon: {instanceID: 0} + userData: + assetBundleName: + assetBundleVariant: diff --git a/Assets/Darkmatter/Code/Core/Enums/Services/Audio/SfxId.cs b/Assets/Darkmatter/Code/Core/Enums/Services/Audio/SfxId.cs index f28ce69..1bcec86 100644 --- a/Assets/Darkmatter/Code/Core/Enums/Services/Audio/SfxId.cs +++ b/Assets/Darkmatter/Code/Core/Enums/Services/Audio/SfxId.cs @@ -8,5 +8,6 @@ namespace Darkmatter.Core.Enums.Services.Audio ShapeReturn = 102, UiTap = 200, PlayButtonTap = 201, + LevelComplete = 300, } } diff --git a/Assets/Darkmatter/Code/Features/ColorbookFlow/System/ColorbookFlowController.cs b/Assets/Darkmatter/Code/Features/ColorbookFlow/System/ColorbookFlowController.cs index 1d73d26..0796969 100644 --- a/Assets/Darkmatter/Code/Features/ColorbookFlow/System/ColorbookFlowController.cs +++ b/Assets/Darkmatter/Code/Features/ColorbookFlow/System/ColorbookFlowController.cs @@ -6,8 +6,10 @@ using Darkmatter.Core; using Darkmatter.Core.Contracts.Features.DrawingCatalog; using Darkmatter.Core.Contracts.Features.Loading; using Darkmatter.Core.Contracts.Features.Progression; +using Darkmatter.Core.Contracts.Services.Ads; using Darkmatter.Core.Contracts.Services.Scenes; using Darkmatter.Core.Data.Signals.Features.Drawing; +using Darkmatter.Core.Enums.Services.Ads; using Darkmatter.Core.Enums.Services.Scenes; using Darkmatter.Libs.Observer; using VContainer.Unity; @@ -20,33 +22,54 @@ public class ColorbookFlowController : IAsyncStartable, IDisposable private readonly ILoadingScreen _loadingScreen; private readonly IProgressionSystem _progression; private readonly ISceneService _scenes; + private readonly IAdService _ads; private readonly IEventBus _bus; private IDisposable _selectedSub; private IDisposable _returnToMainMenuSubscription; private bool _navigatingToGameplay; + private CancellationTokenSource _scopeCts; public ColorbookFlowController( IDrawingCatalogController drawingCatalog, ILoadingScreen loadingScreen, IProgressionSystem progression, ISceneService scenes, + IAdService ads, IEventBus bus) { _drawingCatalog = drawingCatalog; _loadingScreen = loadingScreen; _progression = progression; _scenes = scenes; + _ads = ads; _bus = bus; _selectedSub = _bus.Subscribe(OnDrawingSelected); } public async UniTask StartAsync(CancellationToken cancellation = new CancellationToken()) { + _scopeCts = CancellationTokenSource.CreateLinkedTokenSource(cancellation); _returnToMainMenuSubscription = _bus.Subscribe(OnReturnToMainMenu); _loadingScreen.SetProgress(1f); await _drawingCatalog.InitializeAsync(cancellation); if (!_navigatingToGameplay) _loadingScreen.Hide(); + + PrewarmRewardedAdAsync(_scopeCts.Token).Forget(); + } + + private async UniTaskVoid PrewarmRewardedAdAsync(CancellationToken ct) + { + try + { + if (!_ads.IsInitialized) await _ads.InitializeAsync(ct); + if (!_ads.IsReady(AdFormat.Rewarded)) await _ads.LoadAsync(AdFormat.Rewarded, ct); + } + catch (OperationCanceledException) { } + catch (Exception ex) + { + UnityEngine.Debug.LogWarning($"[ColorbookFlow] Rewarded prewarm failed: {ex.Message}"); + } } private void OnReturnToMainMenu(ReturnToMainMenuSignal signal) @@ -72,6 +95,10 @@ public class ColorbookFlowController : IAsyncStartable, IDisposable private async UniTaskVoid HandleSelectionAsync(string templateId) { + var ct = _scopeCts?.Token ?? CancellationToken.None; + + await ShowRewardedAdAsync(ct); + _loadingScreen.Show(); var progress = new Progress(p => _loadingScreen.SetProgress(p * 0.5f)); var mappedProgress = new Progress(p => _loadingScreen.SetProgress(0.5f + p * 0.25f)); @@ -81,9 +108,25 @@ public class ColorbookFlowController : IAsyncStartable, IDisposable cancellationToken: default); } + private async UniTask ShowRewardedAdAsync(CancellationToken ct) + { + try + { + if (!_ads.IsInitialized) await _ads.InitializeAsync(ct); + await _ads.ShowAsync(AdFormat.Rewarded, ct); + } + catch (OperationCanceledException) { } + catch (Exception ex) + { + UnityEngine.Debug.LogWarning($"[ColorbookFlow] Rewarded ad failed: {ex.Message}"); + } + } + public void Dispose() { _selectedSub?.Dispose(); _returnToMainMenuSubscription?.Dispose(); + _scopeCts?.Cancel(); + _scopeCts?.Dispose(); } } \ No newline at end of file diff --git a/Assets/Darkmatter/Code/Features/GameplayFlow/Systems/GameplayFlowController.cs b/Assets/Darkmatter/Code/Features/GameplayFlow/Systems/GameplayFlowController.cs index 9376f5e..1a5dcdd 100644 --- a/Assets/Darkmatter/Code/Features/GameplayFlow/Systems/GameplayFlowController.cs +++ b/Assets/Darkmatter/Code/Features/GameplayFlow/Systems/GameplayFlowController.cs @@ -9,12 +9,14 @@ using Darkmatter.Core.Contracts.Features.GameplayFlow; using Darkmatter.Core.Contracts.Features.Loading; using Darkmatter.Core.Contracts.Features.Progression; using Darkmatter.Core.Contracts.Features.ShapeBuilder; +using Darkmatter.Core.Contracts.Services.Audio; using Darkmatter.Core.Contracts.Services.Scenes; using Darkmatter.Core.Data.Dynamic.Features.Progression; using Darkmatter.Core.Data.Signals.Features.Coloring; using Darkmatter.Core.Data.Signals.Features.Drawing; using Darkmatter.Core.Data.Signals.Features.ShapeBuilder; using Darkmatter.Core.Enums.Features.Progression; +using Darkmatter.Core.Enums.Services.Audio; using Darkmatter.Core.Enums.Services.Scenes; using Darkmatter.Libs.Observer; using UnityEngine; @@ -34,6 +36,7 @@ namespace Darkmatter.Features.GameplayFlow.Systems private readonly ICaptureFeature _capture; private readonly ILoadingScreen _loadingScreen; private readonly IGameplaySceneRefs _refs; + private readonly ISfxPlayer _sfx; private readonly IEventBus _bus; private IDrawingTemplate _template; @@ -55,6 +58,7 @@ namespace Darkmatter.Features.GameplayFlow.Systems ICaptureFeature capture, ILoadingScreen loadingScreen, IGameplaySceneRefs refs, + ISfxPlayer sfx, IEventBus bus) { _progression = progression; @@ -65,6 +69,7 @@ namespace Darkmatter.Features.GameplayFlow.Systems _capture = capture; _loadingScreen = loadingScreen; _refs = refs; + _sfx = sfx; _bus = bus; } @@ -125,6 +130,7 @@ namespace Darkmatter.Features.GameplayFlow.Systems { await SaveCurrentAsync(ct); _refs.Confetti.Play(); + _sfx.Play(SfxId.LevelComplete); await _coloring.PlayCompletionAnimationAsync(ct); _progression.MarkCompleted(_templateId); diff --git a/Assets/Darkmatter/Code/Services/Ads.meta b/Assets/Darkmatter/Code/Services/Ads.meta new file mode 100644 index 0000000..d2c3bcd --- /dev/null +++ b/Assets/Darkmatter/Code/Services/Ads.meta @@ -0,0 +1,8 @@ +fileFormatVersion: 2 +guid: 3a320ad839a14f578bb9ff149807ed84 +folderAsset: yes +DefaultImporter: + externalObjects: {} + userData: + assetBundleName: + assetBundleVariant: diff --git a/Assets/Darkmatter/Code/Services/Ads/Installers.meta b/Assets/Darkmatter/Code/Services/Ads/Installers.meta new file mode 100644 index 0000000..0d8fccd --- /dev/null +++ b/Assets/Darkmatter/Code/Services/Ads/Installers.meta @@ -0,0 +1,8 @@ +fileFormatVersion: 2 +guid: fd05451b7a0c4e04ae9521e8052ee1e2 +folderAsset: yes +DefaultImporter: + externalObjects: {} + userData: + assetBundleName: + assetBundleVariant: diff --git a/Assets/Darkmatter/Code/Services/Ads/Installers/AdServiceModule.cs b/Assets/Darkmatter/Code/Services/Ads/Installers/AdServiceModule.cs new file mode 100644 index 0000000..372cd66 --- /dev/null +++ b/Assets/Darkmatter/Code/Services/Ads/Installers/AdServiceModule.cs @@ -0,0 +1,31 @@ +using Darkmatter.Core.Contracts.Services.Ads; +using Darkmatter.Core.Data.Static.Services.Ads; +using Darkmatter.Libs.Installers; +using UnityEngine; +using VContainer; +using VContainer.Unity; + +namespace Darkmatter.Services.Ads +{ + public class AdServiceModule : MonoBehaviour, IModule + { + [SerializeField] private AdUnitCatalogSO adUnitCatalog; + [SerializeField] private AdMobAdService adService; + + public void Register(IContainerBuilder builder) + { + if (adUnitCatalog != null) + builder.RegisterComponent(adUnitCatalog); + + var resolved = adService; + if (resolved == null) resolved = GetComponent(); + if (resolved == null) resolved = GetComponentInChildren(includeInactive: true); + if (resolved == null) resolved = FindFirstObjectByType(FindObjectsInactive.Include); + + if (resolved != null) + builder.RegisterComponent(resolved); + else + Debug.LogError("[AdServiceModule] No AdMobAdService component found. Assign 'adService' field or add AdMobAdService MonoBehaviour to scene."); + } + } +} diff --git a/Assets/Darkmatter/Code/Services/Ads/Installers/AdServiceModule.cs.meta b/Assets/Darkmatter/Code/Services/Ads/Installers/AdServiceModule.cs.meta new file mode 100644 index 0000000..d4e8ad2 --- /dev/null +++ b/Assets/Darkmatter/Code/Services/Ads/Installers/AdServiceModule.cs.meta @@ -0,0 +1,11 @@ +fileFormatVersion: 2 +guid: 3a17cb41935543a6b4f67be8f398d774 +MonoImporter: + externalObjects: {} + serializedVersion: 2 + defaultReferences: [] + executionOrder: 0 + icon: {instanceID: 0} + userData: + assetBundleName: + assetBundleVariant: diff --git a/Assets/Darkmatter/Code/Services/Ads/Services.Ads.asmdef b/Assets/Darkmatter/Code/Services/Ads/Services.Ads.asmdef new file mode 100644 index 0000000..1c756d3 --- /dev/null +++ b/Assets/Darkmatter/Code/Services/Ads/Services.Ads.asmdef @@ -0,0 +1,25 @@ +{ + "name": "Services.Ads", + "rootNamespace": "Darkmatter.Services.Ads", + "references": [ + "GUID:6a0a834eb41764f12ba55c3fb04a40cb", + "GUID:f51ebe6a0ceec4240a699833d6309b23", + "GUID:b0214a6008ed146ff8f122a6a9c2f6cc", + "GUID:c1c03c0e5b2f4412b9f2be1c20d6a9b1" + ], + "includePlatforms": [], + "excludePlatforms": [], + "allowUnsafeCode": false, + "overrideReferences": false, + "precompiledReferences": [], + "autoReferenced": true, + "defineConstraints": [], + "versionDefines": [ + { + "name": "com.google.ads.mobile", + "expression": "1.0.0", + "define": "GOOGLE_MOBILE_ADS" + } + ], + "noEngineReferences": false +} diff --git a/Assets/Darkmatter/Code/Services/Ads/Services.Ads.asmdef.meta b/Assets/Darkmatter/Code/Services/Ads/Services.Ads.asmdef.meta new file mode 100644 index 0000000..83b20cf --- /dev/null +++ b/Assets/Darkmatter/Code/Services/Ads/Services.Ads.asmdef.meta @@ -0,0 +1,7 @@ +fileFormatVersion: 2 +guid: 662a8e56a1724c84ad8c10211211f0d5 +AssemblyDefinitionImporter: + externalObjects: {} + userData: + assetBundleName: + assetBundleVariant: diff --git a/Assets/Darkmatter/Code/Services/Ads/Systems.meta b/Assets/Darkmatter/Code/Services/Ads/Systems.meta new file mode 100644 index 0000000..69b031f --- /dev/null +++ b/Assets/Darkmatter/Code/Services/Ads/Systems.meta @@ -0,0 +1,8 @@ +fileFormatVersion: 2 +guid: 6fbb9ad075cb419095482b3d74c4311c +folderAsset: yes +DefaultImporter: + externalObjects: {} + userData: + assetBundleName: + assetBundleVariant: diff --git a/Assets/Darkmatter/Code/Services/Ads/Systems/AdMobAdService.cs b/Assets/Darkmatter/Code/Services/Ads/Systems/AdMobAdService.cs new file mode 100644 index 0000000..90f4f44 --- /dev/null +++ b/Assets/Darkmatter/Code/Services/Ads/Systems/AdMobAdService.cs @@ -0,0 +1,535 @@ +using System; +using System.Collections.Generic; +using System.Threading; +using Cysharp.Threading.Tasks; +using Darkmatter.Core.Contracts.Services.Ads; +using Darkmatter.Core.Data.Dynamic.Services.Ads; +using Darkmatter.Core.Data.Static.Services.Ads; +using Darkmatter.Core.Enums.Services.Ads; +using UnityEngine; +using AdFormat = Darkmatter.Core.Enums.Services.Ads.AdFormat; +#if GOOGLE_MOBILE_ADS +using GoogleMobileAds.Api; +#endif + +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; + + public bool IsInitialized => _initialized; + public event Action LoadStateChanged; + + private bool _initialized; + private bool _hasUserConsent = true; + private bool _isChildDirected; + private CancellationTokenSource _lifetimeCts; + + private readonly Dictionary _states = new(); + +#if GOOGLE_MOBILE_ADS + private InterstitialAd _interstitial; + private RewardedAd _rewarded; + private RewardedInterstitialAd _rewardedInterstitial; + private AppOpenAd _appOpen; + private BannerView _banner; +#endif + + private void Awake() + { + _lifetimeCts = new CancellationTokenSource(); + foreach (AdFormat fmt in Enum.GetValues(typeof(AdFormat))) + _states[fmt] = AdLoadState.Idle; + } + + private void OnDestroy() + { + _lifetimeCts?.Cancel(); + _lifetimeCts?.Dispose(); + _lifetimeCts = null; + +#if GOOGLE_MOBILE_ADS + _interstitial?.Destroy(); + _rewarded?.Destroy(); + _rewardedInterstitial?.Destroy(); + _appOpen?.Destroy(); + _banner?.Destroy(); +#endif + } + + public async UniTask InitializeAsync(CancellationToken cancellationToken) + { + if (_initialized) return; + if (catalog == null) + { + Debug.LogError("[AdMobAdService] No AdUnitCatalogSO assigned."); + return; + } + +#if GOOGLE_MOBILE_ADS + ApplyRequestConfiguration(); + + var tcs = new UniTaskCompletionSource(); + MobileAds.Initialize(_ => tcs.TrySetResult(true)); + + using (cancellationToken.Register(() => tcs.TrySetCanceled(cancellationToken))) + { + await tcs.Task; + } + + _initialized = true; + Debug.Log("[AdMobAdService] Initialized."); +#else + await UniTask.CompletedTask; + _initialized = true; + Debug.LogWarning("[AdMobAdService] GOOGLE_MOBILE_ADS not defined. Service will no-op. Add scripting define after importing Google Mobile Ads SDK."); +#endif + } + + public async UniTask LoadAsync(AdFormat format, CancellationToken cancellationToken) + { + if (!_initialized) + { + Debug.LogWarning("[AdMobAdService] Load called before init."); + return false; + } + +#if GOOGLE_MOBILE_ADS + string unitId = catalog.GetUnitId(format, Application.platform); + if (string.IsNullOrEmpty(unitId)) + { + Debug.LogWarning($"[AdMobAdService] No unit ID for {format} on {Application.platform}."); + return false; + } + + SetState(format, AdLoadState.Loading); + var request = new AdRequest(); + + switch (format) + { + case AdFormat.Interstitial: + return await LoadInterstitialAsync(unitId, request, cancellationToken); + case AdFormat.Rewarded: + return await LoadRewardedAsync(unitId, request, cancellationToken); + case AdFormat.RewardedInterstitial: + return await LoadRewardedInterstitialAsync(unitId, request, cancellationToken); + case AdFormat.AppOpen: + return await LoadAppOpenAsync(unitId, request, cancellationToken); + case AdFormat.Banner: + Debug.LogWarning("[AdMobAdService] Use ShowBannerAsync for banners."); + SetState(format, AdLoadState.Failed); + return false; + } + return false; +#else + await UniTask.CompletedTask; + SetState(format, AdLoadState.Failed); + return false; +#endif + } + + public bool IsReady(AdFormat format) + { + if (!_initialized) return false; +#if GOOGLE_MOBILE_ADS + return format switch + { + AdFormat.Interstitial => _interstitial != null && _interstitial.CanShowAd(), + AdFormat.Rewarded => _rewarded != null && _rewarded.CanShowAd(), + AdFormat.RewardedInterstitial => _rewardedInterstitial != null && _rewardedInterstitial.CanShowAd(), + AdFormat.AppOpen => _appOpen != null && _appOpen.CanShowAd(), + _ => false + }; +#else + return false; +#endif + } + + public async UniTask ShowAsync(AdFormat format, CancellationToken cancellationToken) + { + if (!_initialized) return AdShowResult.Failure("Not initialized."); + +#if GOOGLE_MOBILE_ADS + if (!IsReady(format)) + { + bool loaded = await LoadAsync(format, cancellationToken); + if (!loaded) return AdShowResult.Failure($"{format} failed to load."); + } + + switch (format) + { + case AdFormat.Interstitial: return await ShowInterstitialAsync(cancellationToken); + case AdFormat.Rewarded: return await ShowRewardedAsync(cancellationToken); + 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; + return AdShowResult.Failure("GOOGLE_MOBILE_ADS not defined."); +#endif + } + + public async UniTask ShowBannerAsync(BannerSize size, BannerPosition position, CancellationToken cancellationToken) + { + if (!_initialized) return false; + +#if GOOGLE_MOBILE_ADS + string unitId = catalog.GetUnitId(AdFormat.Banner, Application.platform); + if (string.IsNullOrEmpty(unitId)) return false; + + _banner?.Destroy(); + _banner = new BannerView(unitId, MapBannerSize(size), MapBannerPosition(position)); + + var tcs = new UniTaskCompletionSource(); + _banner.OnBannerAdLoaded += () => tcs.TrySetResult(true); + _banner.OnBannerAdLoadFailed += err => + { + Debug.LogWarning($"[AdMobAdService] Banner load failed: {err}"); + tcs.TrySetResult(false); + }; + + SetState(AdFormat.Banner, AdLoadState.Loading); + _banner.LoadAd(new AdRequest()); + + using (cancellationToken.Register(() => tcs.TrySetCanceled(cancellationToken))) + { + bool ok = await tcs.Task; + SetState(AdFormat.Banner, ok ? AdLoadState.Loaded : AdLoadState.Failed); + return ok; + } +#else + await UniTask.CompletedTask; + return false; +#endif + } + + public void HideBanner() + { +#if GOOGLE_MOBILE_ADS + _banner?.Hide(); +#endif + } + + public void DestroyBanner() + { +#if GOOGLE_MOBILE_ADS + _banner?.Destroy(); + _banner = null; + SetState(AdFormat.Banner, AdLoadState.Idle); +#endif + } + + public void SetConsent(bool hasUserConsent, bool isChildDirected) + { + _hasUserConsent = hasUserConsent; + _isChildDirected = isChildDirected; +#if GOOGLE_MOBILE_ADS + if (_initialized) ApplyRequestConfiguration(); +#endif + } + +#if GOOGLE_MOBILE_ADS + private void ApplyRequestConfiguration() + { + var config = new RequestConfiguration + { + TagForChildDirectedTreatment = _isChildDirected + ? TagForChildDirectedTreatment.True + : TagForChildDirectedTreatment.Unspecified, + TagForUnderAgeOfConsent = _hasUserConsent + ? TagForUnderAgeOfConsent.Unspecified + : TagForUnderAgeOfConsent.True + }; + + if (catalog.TestDeviceIds is { Count: > 0 }) + { + config.TestDeviceIds = new List(catalog.TestDeviceIds); + } + + MobileAds.SetRequestConfiguration(config); + } + + private async UniTask LoadInterstitialAsync(string unitId, AdRequest request, CancellationToken cancellationToken) + { + var tcs = new UniTaskCompletionSource(); + InterstitialAd.Load(unitId, request, (ad, error) => + { + if (error != null || ad == null) + { + Debug.LogWarning($"[AdMobAdService] Interstitial load failed: {error}"); + tcs.TrySetResult(false); + return; + } + _interstitial?.Destroy(); + _interstitial = ad; + WireFullScreenEvents(ad, AdFormat.Interstitial); + tcs.TrySetResult(true); + }); + + using (cancellationToken.Register(() => tcs.TrySetCanceled(cancellationToken))) + { + bool ok = await tcs.Task; + SetState(AdFormat.Interstitial, ok ? AdLoadState.Loaded : AdLoadState.Failed); + return ok; + } + } + + private async UniTask LoadRewardedAsync(string unitId, AdRequest request, CancellationToken cancellationToken) + { + var tcs = new UniTaskCompletionSource(); + RewardedAd.Load(unitId, request, (ad, error) => + { + if (error != null || ad == null) + { + Debug.LogWarning($"[AdMobAdService] Rewarded load failed: {error}"); + tcs.TrySetResult(false); + return; + } + _rewarded?.Destroy(); + _rewarded = ad; + WireFullScreenEvents(ad, AdFormat.Rewarded); + tcs.TrySetResult(true); + }); + + using (cancellationToken.Register(() => tcs.TrySetCanceled(cancellationToken))) + { + bool ok = await tcs.Task; + SetState(AdFormat.Rewarded, ok ? AdLoadState.Loaded : AdLoadState.Failed); + return ok; + } + } + + private async UniTask LoadRewardedInterstitialAsync(string unitId, AdRequest request, CancellationToken cancellationToken) + { + var tcs = new UniTaskCompletionSource(); + RewardedInterstitialAd.Load(unitId, request, (ad, error) => + { + if (error != null || ad == null) + { + Debug.LogWarning($"[AdMobAdService] RewardedInterstitial load failed: {error}"); + tcs.TrySetResult(false); + return; + } + _rewardedInterstitial?.Destroy(); + _rewardedInterstitial = ad; + WireFullScreenEvents(ad, AdFormat.RewardedInterstitial); + tcs.TrySetResult(true); + }); + + using (cancellationToken.Register(() => tcs.TrySetCanceled(cancellationToken))) + { + bool ok = await tcs.Task; + SetState(AdFormat.RewardedInterstitial, ok ? AdLoadState.Loaded : AdLoadState.Failed); + return ok; + } + } + + private async UniTask LoadAppOpenAsync(string unitId, AdRequest request, CancellationToken cancellationToken) + { + var tcs = new UniTaskCompletionSource(); + AppOpenAd.Load(unitId, request, (ad, error) => + { + if (error != null || ad == null) + { + Debug.LogWarning($"[AdMobAdService] AppOpen load failed: {error}"); + tcs.TrySetResult(false); + return; + } + _appOpen?.Destroy(); + _appOpen = ad; + WireFullScreenEvents(ad, AdFormat.AppOpen); + tcs.TrySetResult(true); + }); + + using (cancellationToken.Register(() => tcs.TrySetCanceled(cancellationToken))) + { + bool ok = await tcs.Task; + SetState(AdFormat.AppOpen, ok ? AdLoadState.Loaded : AdLoadState.Failed); + return ok; + } + } + + private async UniTask ShowInterstitialAsync(CancellationToken cancellationToken) + { + var tcs = new UniTaskCompletionSource(); + Action onClosed = () => tcs.TrySetResult(AdShowResult.Success()); + Action 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) + { + _interstitial.OnAdFullScreenContentClosed -= onClosed; + _interstitial.OnAdFullScreenContentFailed -= onFailed; + } + ScheduleReload(AdFormat.Interstitial); + } + } + + private async UniTask ShowRewardedAsync(CancellationToken cancellationToken) + { + var tcs = new UniTaskCompletionSource(); + bool earned = false; + AdReward reward = default; + + Action onClosed = () => tcs.TrySetResult(earned ? AdShowResult.WithReward(reward) : AdShowResult.Success()); + Action 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) + { + _rewarded.OnAdFullScreenContentClosed -= onClosed; + _rewarded.OnAdFullScreenContentFailed -= onFailed; + } + ScheduleReload(AdFormat.Rewarded); + } + } + + private async UniTask ShowRewardedInterstitialAsync(CancellationToken cancellationToken) + { + var tcs = new UniTaskCompletionSource(); + bool earned = false; + AdReward reward = default; + + Action onClosed = () => tcs.TrySetResult(earned ? AdShowResult.WithReward(reward) : AdShowResult.Success()); + Action onFailed = err => tcs.TrySetResult(AdShowResult.Failure(err?.GetMessage())); + + _rewardedInterstitial.OnAdFullScreenContentClosed += onClosed; + _rewardedInterstitial.OnAdFullScreenContentFailed += onFailed; + _rewardedInterstitial.Show(r => + { + earned = true; + reward = new AdReward(r.Type, r.Amount); + }); + + try + { + using (cancellationToken.Register(() => tcs.TrySetCanceled(cancellationToken))) + return await tcs.Task; + } + finally + { + if (_rewardedInterstitial != null) + { + _rewardedInterstitial.OnAdFullScreenContentClosed -= onClosed; + _rewardedInterstitial.OnAdFullScreenContentFailed -= onFailed; + } + ScheduleReload(AdFormat.RewardedInterstitial); + } + } + + private async UniTask ShowAppOpenAsync(CancellationToken cancellationToken) + { + var tcs = new UniTaskCompletionSource(); + Action onClosed = () => tcs.TrySetResult(AdShowResult.Success()); + Action onFailed = err => tcs.TrySetResult(AdShowResult.Failure(err?.GetMessage())); + + _appOpen.OnAdFullScreenContentClosed += onClosed; + _appOpen.OnAdFullScreenContentFailed += onFailed; + _appOpen.Show(); + + try + { + using (cancellationToken.Register(() => tcs.TrySetCanceled(cancellationToken))) + return await tcs.Task; + } + finally + { + if (_appOpen != null) + { + _appOpen.OnAdFullScreenContentClosed -= onClosed; + _appOpen.OnAdFullScreenContentFailed -= onFailed; + } + ScheduleReload(AdFormat.AppOpen); + } + } + + private void WireFullScreenEvents(InterstitialAd ad, AdFormat format) => + ad.OnAdFullScreenContentClosed += () => SetState(format, AdLoadState.Idle); + + private void WireFullScreenEvents(RewardedAd ad, AdFormat format) => + ad.OnAdFullScreenContentClosed += () => SetState(format, AdLoadState.Idle); + + private void WireFullScreenEvents(RewardedInterstitialAd ad, AdFormat format) => + ad.OnAdFullScreenContentClosed += () => SetState(format, AdLoadState.Idle); + + private void WireFullScreenEvents(AppOpenAd ad, AdFormat format) => + ad.OnAdFullScreenContentClosed += () => SetState(format, AdLoadState.Idle); + + private void ScheduleReload(AdFormat format) + { + if (!autoReload || _lifetimeCts == null) return; + ReloadAfterDelayAsync(format, _lifetimeCts.Token).Forget(); + } + + private async UniTaskVoid ReloadAfterDelayAsync(AdFormat format, CancellationToken cancellationToken) + { + try + { + await UniTask.Delay(TimeSpan.FromSeconds(reloadDelaySeconds), cancellationToken: cancellationToken); + await LoadAsync(format, cancellationToken); + } + catch (OperationCanceledException) { } + } + + private static AdSize MapBannerSize(BannerSize size) => size switch + { + BannerSize.Banner => AdSize.Banner, + BannerSize.LargeBanner => AdSize.LargeBanner, + BannerSize.MediumRectangle => AdSize.MediumRectangle, + BannerSize.FullBanner => AdSize.IABBanner, + BannerSize.Leaderboard => AdSize.Leaderboard, + BannerSize.SmartBanner => AdSize.SmartBanner, + BannerSize.AnchoredAdaptive => AdSize.GetCurrentOrientationAnchoredAdaptiveBannerAdSizeWithWidth(AdSize.FullWidth), + _ => AdSize.Banner + }; + + private static AdPosition MapBannerPosition(BannerPosition position) => position switch + { + BannerPosition.Top => AdPosition.Top, + BannerPosition.Bottom => AdPosition.Bottom, + BannerPosition.TopLeft => AdPosition.TopLeft, + BannerPosition.TopRight => AdPosition.TopRight, + BannerPosition.BottomLeft => AdPosition.BottomLeft, + BannerPosition.BottomRight => AdPosition.BottomRight, + BannerPosition.Center => AdPosition.Center, + _ => AdPosition.Bottom + }; +#endif + + private void SetState(AdFormat format, AdLoadState state) + { + _states[format] = state; + LoadStateChanged?.Invoke(format, state); + } + } +} diff --git a/Assets/Darkmatter/Code/Services/Ads/Systems/AdMobAdService.cs.meta b/Assets/Darkmatter/Code/Services/Ads/Systems/AdMobAdService.cs.meta new file mode 100644 index 0000000..74f94e2 --- /dev/null +++ b/Assets/Darkmatter/Code/Services/Ads/Systems/AdMobAdService.cs.meta @@ -0,0 +1,11 @@ +fileFormatVersion: 2 +guid: 018ba83ad51c4d9a89a904ba3d21ead2 +MonoImporter: + externalObjects: {} + serializedVersion: 2 + defaultReferences: [] + executionOrder: 0 + icon: {instanceID: 0} + userData: + assetBundleName: + assetBundleVariant: diff --git a/Assets/Darkmatter/Data/Ads.meta b/Assets/Darkmatter/Data/Ads.meta new file mode 100644 index 0000000..2d1e9c6 --- /dev/null +++ b/Assets/Darkmatter/Data/Ads.meta @@ -0,0 +1,8 @@ +fileFormatVersion: 2 +guid: f615a2c0d3b0f44718ef3ac2b3d1a9f6 +folderAsset: yes +DefaultImporter: + externalObjects: {} + userData: + assetBundleName: + assetBundleVariant: diff --git a/Assets/Darkmatter/Data/Ads/AdUnitCatalog.asset b/Assets/Darkmatter/Data/Ads/AdUnitCatalog.asset new file mode 100644 index 0000000..e0a491f --- /dev/null +++ b/Assets/Darkmatter/Data/Ads/AdUnitCatalog.asset @@ -0,0 +1,19 @@ +%YAML 1.1 +%TAG !u! tag:unity3d.com,2011: +--- !u!114 &11400000 +MonoBehaviour: + m_ObjectHideFlags: 0 + m_CorrespondingSourceObject: {fileID: 0} + m_PrefabInstance: {fileID: 0} + m_PrefabAsset: {fileID: 0} + m_GameObject: {fileID: 0} + m_Enabled: 1 + m_EditorHideFlags: 0 + m_Script: {fileID: 11500000, guid: e6352f0162df4adf99a5945e25c6bf40, type: 3} + m_Name: AdUnitCatalog + m_EditorClassIdentifier: Core::Darkmatter.Core.Data.Static.Services.Ads.AdUnitCatalogSO + androidAppId: ca-app-pub-3940256099942544~3347511713 + iosAppId: ca-app-pub-3940256099942544~1458002511 + useTestUnits: 1 + testDeviceIds: [] + entries: [] diff --git a/Assets/Darkmatter/Data/Ads/AdUnitCatalog.asset.meta b/Assets/Darkmatter/Data/Ads/AdUnitCatalog.asset.meta new file mode 100644 index 0000000..55142be --- /dev/null +++ b/Assets/Darkmatter/Data/Ads/AdUnitCatalog.asset.meta @@ -0,0 +1,8 @@ +fileFormatVersion: 2 +guid: 6e2d0c78aa02e4411948adcca14299a5 +NativeFormatImporter: + externalObjects: {} + mainObjectFileID: 11400000 + userData: + assetBundleName: + assetBundleVariant: diff --git a/Assets/Darkmatter/Data/Audio/SfxCatalog.asset b/Assets/Darkmatter/Data/Audio/SfxCatalog.asset index 50a2008..c00ec0c 100644 --- a/Assets/Darkmatter/Data/Audio/SfxCatalog.asset +++ b/Assets/Darkmatter/Data/Audio/SfxCatalog.asset @@ -17,5 +17,11 @@ MonoBehaviour: Clip: {fileID: 8300000, guid: 904c9da8da3f449b997f23438816edba, type: 3} Channel: 0 Volume: 1 - Pitch: 0 + Pitch: 1 + Loop: 0 + - Id: 300 + Clip: {fileID: 8300000, guid: b2d674efc9bbd4fda8e5864b6c91296c, type: 3} + Channel: 0 + Volume: 1 + Pitch: 1 Loop: 0 diff --git a/Assets/Darkmatter/Scenes/Boot.unity b/Assets/Darkmatter/Scenes/Boot.unity index cec09fe..8e816fb 100644 --- a/Assets/Darkmatter/Scenes/Boot.unity +++ b/Assets/Darkmatter/Scenes/Boot.unity @@ -1629,6 +1629,7 @@ MonoBehaviour: m_Script: {fileID: 11500000, guid: 018ba83ad51c4d9a89a904ba3d21ead2, type: 3} m_Name: m_EditorClassIdentifier: Services.Ads::Darkmatter.Services.Ads.AdMobAdService + catalog: {fileID: 11400000, guid: 6e2d0c78aa02e4411948adcca14299a5, type: 2} autoReload: 1 reloadDelaySeconds: 5 --- !u!1 &1239449674