first commit

This commit is contained in:
jakciehan
2026-04-10 22:59:39 +08:00
commit cc2e7b9bb0
89 changed files with 23631 additions and 0 deletions
+447
View File
@@ -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;