/** * AdManager.js * Manages WeChat mini game ads: rewarded video and interstitial. * Supports scene-based ad triggering with per-scene cooldowns, * daily limits, preloading, and frequency control. */ /** * Ad scene types for rewarded video ads. * Each scene has independent cooldown and optional daily limits. */ const AD_SCENE = { REVIVE: 'REVIVE', // Revive after death DOUBLE_REWARD: 'DOUBLE_REWARD', // Double settlement rewards DAILY_GOLD: 'DAILY_GOLD', // Daily gold reward from main menu }; /** Cooldown duration per scene in milliseconds (15 minutes). */ const SCENE_COOLDOWN_MS = 15 * 60 * 1000; /** Daily limits for specific scenes. */ const SCENE_DAILY_LIMITS = { [AD_SCENE.DAILY_GOLD]: 3, }; class AdManager { constructor() { /** @type {RewardedVideoAd|null} */ this._rewardedVideo = null; /** @type {InterstitialAd|null} */ this._interstitial = null; // Interstitial frequency control: show every N games since last show this._gamesSinceLastInterstitial = 0; this._interstitialFrequency = 3; // Ad unit IDs (replace with real IDs in production) this._rewardedVideoId = 'adunit-reward-placeholder'; this._interstitialId = 'adunit-interstitial-placeholder'; // State this._rewardedVideoReady = false; this._interstitialReady = false; this._adFreeEnabled = false; // purchased ad-free // Callback for rewarded video completion this._rewardCallback = null; // Scene cooldown tracking: Map this._sceneCooldowns = new Map(); // Daily scene count tracking: Map this._sceneDailyCounts = new Map(); // Skip ad initialization if using placeholder IDs (dev environment) this._isDevMode = this._rewardedVideoId.includes('placeholder') || this._interstitialId.includes('placeholder'); if (!this._isDevMode) { this._init(); } else { console.log('[AdManager] Dev mode: skipping ad initialization (placeholder IDs)'); } // Restore daily counts from storage this._restoreDailyCounts(); } /** * Initialize ad instances. * @private */ _init() { // Check if ad-free was purchased try { if (GameGlobal.storageManager) { this._adFreeEnabled = GameGlobal.storageManager.hasPurchased('ad_free'); } } catch (e) {} this._createRewardedVideo(); this._createInterstitial(); } /** * Create rewarded video ad instance. * @private */ _createRewardedVideo() { try { if (typeof wx === 'undefined' || typeof wx.createRewardedVideoAd !== 'function') return; this._rewardedVideo = wx.createRewardedVideoAd({ adUnitId: this._rewardedVideoId, }); this._rewardedVideo.onLoad(() => { this._rewardedVideoReady = true; console.log('[AdManager] Rewarded video loaded'); }); this._rewardedVideo.onError((err) => { this._rewardedVideoReady = false; console.warn('[AdManager] Rewarded video error:', err); }); this._rewardedVideo.onClose((res) => { // Dispatch reward instantly on ad close if (res && res.isEnded) { if (this._rewardCallback) { this._rewardCallback(true); this._rewardCallback = null; } } else { // User closed early if (this._rewardCallback) { this._rewardCallback(false); this._rewardCallback = null; } } }); } catch (e) { console.warn('[AdManager] Failed to create rewarded video:', e); } } /** * Create interstitial ad instance. * @private */ _createInterstitial() { if (this._adFreeEnabled) return; try { if (typeof wx === 'undefined' || typeof wx.createInterstitialAd !== 'function') return; this._interstitial = wx.createInterstitialAd({ adUnitId: this._interstitialId, }); this._interstitial.onLoad(() => { this._interstitialReady = true; }); this._interstitial.onError((err) => { this._interstitialReady = false; console.warn('[AdManager] Interstitial error:', err); }); this._interstitial.onClose(() => { this._interstitialReady = false; }); } catch (e) { console.warn('[AdManager] Failed to create interstitial:', e); } } // ============================================================ // Scene Cooldown & Daily Limit Helpers // ============================================================ /** * Get today's date string (YYYY-MM-DD) for daily tracking. * @returns {string} * @private */ _getTodayKey() { const d = new Date(); return `${d.getFullYear()}-${String(d.getMonth() + 1).padStart(2, '0')}-${String(d.getDate()).padStart(2, '0')}`; } /** * Restore daily ad counts from StorageManager. * @private */ _restoreDailyCounts() { try { if (GameGlobal && GameGlobal.storageManager) { const saved = GameGlobal.storageManager.get('ad_daily_counts', null); if (saved && typeof saved === 'object') { const today = this._getTodayKey(); for (const [scene, data] of Object.entries(saved)) { if (data.date === today) { this._sceneDailyCounts.set(scene, { date: data.date, count: data.count }); } // Stale dates are discarded } } } } catch (e) {} } /** * Persist daily ad counts to StorageManager. * @private */ _saveDailyCounts() { try { if (GameGlobal && GameGlobal.storageManager) { const obj = {}; for (const [scene, data] of this._sceneDailyCounts.entries()) { obj[scene] = data; } GameGlobal.storageManager.set('ad_daily_counts', obj); } } catch (e) {} } /** * Get the daily count for a scene. * @param {string} sceneType * @returns {number} * @private */ _getDailyCount(sceneType) { const today = this._getTodayKey(); const entry = this._sceneDailyCounts.get(sceneType); if (entry && entry.date === today) { return entry.count; } return 0; } /** * Increment the daily count for a scene. * @param {string} sceneType * @private */ _incrementDailyCount(sceneType) { const today = this._getTodayKey(); const entry = this._sceneDailyCounts.get(sceneType); if (entry && entry.date === today) { entry.count++; } else { this._sceneDailyCounts.set(sceneType, { date: today, count: 1 }); } this._saveDailyCounts(); } /** * Check if a scene ad can be shown (cooldown + daily limit). * @param {string} sceneType - One of AD_SCENE values. * @returns {boolean} */ canShowScene(sceneType) { // Check cooldown const lastShow = this._sceneCooldowns.get(sceneType); if (lastShow && (Date.now() - lastShow < SCENE_COOLDOWN_MS)) { console.log(`[AdManager] Scene ${sceneType} is in cooldown`); return false; } // Check daily limit const limit = SCENE_DAILY_LIMITS[sceneType]; if (limit !== undefined) { const count = this._getDailyCount(sceneType); if (count >= limit) { console.log(`[AdManager] Scene ${sceneType} daily limit reached (${count}/${limit})`); return false; } } return true; } /** * Get remaining daily count for a scene. * @param {string} sceneType * @returns {number} Remaining uses, or Infinity if no limit. */ getRemainingDailyCount(sceneType) { const limit = SCENE_DAILY_LIMITS[sceneType]; if (limit === undefined) return Infinity; return Math.max(0, limit - this._getDailyCount(sceneType)); } // ============================================================ // Public API // ============================================================ /** * Preload the rewarded video ad (call during level loading). * Ensures the ad is ready when needed, reducing wait time. */ preloadRewardedVideo() { if (!this._rewardedVideo) return; if (this._rewardedVideoReady) return; // Already loaded try { this._rewardedVideo.load().then(() => { console.log('[AdManager] Rewarded video preloaded'); }).catch((err) => { console.warn('[AdManager] Rewarded video preload failed:', err); }); } catch (e) {} } /** * Show a rewarded video ad for a specific scene. * Checks cooldown and daily limits before showing. * @param {string} sceneType - One of AD_SCENE values. * @param {Function} callback - Called with (completed: boolean) when ad closes. * @returns {boolean} Whether the ad was shown. */ showRewardedVideoForScene(sceneType, callback) { if (!this.canShowScene(sceneType)) { if (callback) callback(false); return false; } const wrappedCallback = (completed) => { if (completed) { // Record cooldown timestamp this._sceneCooldowns.set(sceneType, Date.now()); // Increment daily count this._incrementDailyCount(sceneType); } if (callback) callback(completed); }; return this.showRewardedVideo(wrappedCallback); } /** * Show a rewarded video ad (low-level, no scene tracking). * @param {Function} callback - Called with (completed: boolean) when ad closes. * @returns {boolean} Whether the ad was shown (false if not ready). */ showRewardedVideo(callback) { this._rewardCallback = callback; if (!this._rewardedVideo) { // Ad not available, give fallback console.warn('[AdManager] Rewarded video not available'); if (callback) callback(false); return false; } this._rewardedVideo.show().catch(() => { // Try to reload and show again this._rewardedVideo.load().then(() => { this._rewardedVideo.show().catch(() => { console.warn('[AdManager] Failed to show rewarded video'); if (callback) callback(false); this._rewardCallback = null; }); }).catch(() => { if (callback) callback(false); this._rewardCallback = null; }); }); return true; } /** * Show an interstitial ad (respects frequency control and ad-free purchase). * Uses "games since last show" logic: shows after every N games. */ showInterstitial() { if (this._adFreeEnabled) return; this._gamesSinceLastInterstitial++; if (this._gamesSinceLastInterstitial < this._interstitialFrequency) return; if (!this._interstitial || !this._interstitialReady) return; try { this._interstitial.show().then(() => { // Reset counter on successful show this._gamesSinceLastInterstitial = 0; }).catch(() => { // Silently skip on failure, don't block player flow console.warn('[AdManager] Failed to show interstitial, skipping'); }); } catch (e) { // Silently skip } } /** * Show a daily gold reward ad. * Convenience method for the DAILY_GOLD scene. * On completion, emits 'daily_gold_reward' event and adds 100 gold. * @param {Function} [callback] - Optional callback with (completed: boolean). * @returns {boolean} Whether the ad was shown. */ showDailyGoldAd(callback) { return this.showRewardedVideoForScene(AD_SCENE.DAILY_GOLD, (completed) => { if (completed) { // Award 100 gold if (GameGlobal && GameGlobal.currencyManager) { GameGlobal.currencyManager.addGold(100); } // Emit event for UI update try { if (GameGlobal && GameGlobal.eventBus) { GameGlobal.eventBus.emit('daily_gold_reward', { amount: 100 }); } } catch (e) {} } if (callback) callback(completed); }); } /** * Get remaining daily gold ad claims for today. * @returns {number} */ getDailyGoldRemaining() { return this.getRemainingDailyCount(AD_SCENE.DAILY_GOLD); } /** * Record that a game was played (for interstitial frequency). */ recordGamePlayed() { // No-op: counting is now done inside showInterstitial() // Kept for backward compatibility } /** * Enable ad-free mode (after purchase). */ enableAdFree() { this._adFreeEnabled = true; if (GameGlobal && GameGlobal.storageManager) { GameGlobal.storageManager.recordPurchase('ad_free'); } } /** Whether rewarded video is ready. */ get rewardedVideoReady() { return this._rewardedVideoReady; } /** Whether ad-free mode is enabled. */ get adFreeEnabled() { return this._adFreeEnabled; } } // Export both the class and the scene enum AdManager.AD_SCENE = AD_SCENE; module.exports = AdManager;