first commit
This commit is contained in:
@@ -0,0 +1,447 @@
|
||||
/**
|
||||
* 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;
|
||||
Reference in New Issue
Block a user