448 lines
12 KiB
JavaScript
448 lines
12 KiB
JavaScript
/**
|
|
* 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<sceneType, lastShowTimestamp>
|
|
this._sceneCooldowns = new Map();
|
|
|
|
// Daily scene count tracking: Map<sceneType, { date: string, count: number }>
|
|
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;
|