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;
|
||||
@@ -0,0 +1,230 @@
|
||||
/**
|
||||
* AudioManager.js
|
||||
* Manages game sound effects using wx.createWebAudioContext for programmatic synthesis.
|
||||
* No external audio files needed — all sounds are generated via PCM buffers.
|
||||
*/
|
||||
|
||||
class AudioManager {
|
||||
constructor() {
|
||||
this._soundEnabled = true;
|
||||
this._musicEnabled = true;
|
||||
|
||||
/** @type {AudioContext|null} WebAudio context */
|
||||
this._audioCtx = null;
|
||||
|
||||
// Cached audio buffers for each sound
|
||||
/** @type {Map<string, AudioBuffer>} */
|
||||
this._buffers = new Map();
|
||||
|
||||
this._initialized = false;
|
||||
|
||||
// Listen for settings changes
|
||||
if (typeof GameGlobal !== 'undefined' && GameGlobal.eventBus) {
|
||||
GameGlobal.eventBus.on('settings:changed', (settings) => {
|
||||
this._soundEnabled = settings.soundEnabled;
|
||||
this._musicEnabled = settings.musicEnabled;
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Initialize WebAudio context and generate all sound buffers.
|
||||
* Must be called after user interaction (touch) on some platforms.
|
||||
*/
|
||||
init() {
|
||||
if (this._initialized) return;
|
||||
|
||||
try {
|
||||
if (typeof wx !== 'undefined' && wx.createWebAudioContext) {
|
||||
this._audioCtx = wx.createWebAudioContext();
|
||||
}
|
||||
} catch (e) {
|
||||
console.warn('[AudioManager] Failed to create WebAudioContext:', e);
|
||||
}
|
||||
|
||||
if (!this._audioCtx) {
|
||||
console.warn('[AudioManager] WebAudioContext not available, audio disabled.');
|
||||
return;
|
||||
}
|
||||
|
||||
// Pre-generate all sound effect buffers
|
||||
this._generateSounds();
|
||||
this._initialized = true;
|
||||
console.log('[AudioManager] Initialized with programmatic audio synthesis.');
|
||||
}
|
||||
|
||||
/**
|
||||
* Generate all game sound effect buffers.
|
||||
* @private
|
||||
*/
|
||||
_generateSounds() {
|
||||
const ctx = this._audioCtx;
|
||||
const sampleRate = ctx.sampleRate;
|
||||
|
||||
// Shoot sound: short high-frequency burst
|
||||
this._buffers.set('shoot', this._generateBuffer(sampleRate, 0.08, (i, len) => {
|
||||
const t = i / len;
|
||||
const freq = 800 - t * 400;
|
||||
const envelope = 1 - t;
|
||||
return Math.sin(2 * Math.PI * freq * i / sampleRate) * envelope * 0.3;
|
||||
}));
|
||||
|
||||
// Explosion (small): noise burst with decay
|
||||
this._buffers.set('explosion_small', this._generateBuffer(sampleRate, 0.2, (i, len) => {
|
||||
const t = i / len;
|
||||
const envelope = (1 - t) * (1 - t);
|
||||
const noise = Math.random() * 2 - 1;
|
||||
const tone = Math.sin(2 * Math.PI * 120 * i / sampleRate);
|
||||
return (noise * 0.6 + tone * 0.4) * envelope * 0.35;
|
||||
}));
|
||||
|
||||
// Explosion (big): longer, deeper noise burst
|
||||
this._buffers.set('explosion_big', this._generateBuffer(sampleRate, 0.4, (i, len) => {
|
||||
const t = i / len;
|
||||
const envelope = (1 - t) * (1 - t);
|
||||
const noise = Math.random() * 2 - 1;
|
||||
const tone = Math.sin(2 * Math.PI * 60 * i / sampleRate);
|
||||
const tone2 = Math.sin(2 * Math.PI * 90 * i / sampleRate);
|
||||
return (noise * 0.5 + tone * 0.3 + tone2 * 0.2) * envelope * 0.4;
|
||||
}));
|
||||
|
||||
// Hit (bullet hits armor but doesn't destroy): metallic ping
|
||||
this._buffers.set('hit', this._generateBuffer(sampleRate, 0.1, (i, len) => {
|
||||
const t = i / len;
|
||||
const envelope = (1 - t);
|
||||
return Math.sin(2 * Math.PI * 1200 * i / sampleRate) * envelope * 0.2 +
|
||||
Math.sin(2 * Math.PI * 2400 * i / sampleRate) * envelope * 0.1;
|
||||
}));
|
||||
|
||||
// Bullet hit wall: short thud
|
||||
this._buffers.set('hit_wall', this._generateBuffer(sampleRate, 0.06, (i, len) => {
|
||||
const t = i / len;
|
||||
const envelope = (1 - t);
|
||||
const noise = Math.random() * 2 - 1;
|
||||
return (noise * 0.4 + Math.sin(2 * Math.PI * 300 * i / sampleRate) * 0.6) * envelope * 0.2;
|
||||
}));
|
||||
|
||||
// Power-up pickup: ascending chime
|
||||
this._buffers.set('powerup', this._generateBuffer(sampleRate, 0.25, (i, len) => {
|
||||
const t = i / len;
|
||||
const freq = 400 + t * 800;
|
||||
const envelope = t < 0.1 ? t / 0.1 : (1 - (t - 0.1) / 0.9);
|
||||
return (Math.sin(2 * Math.PI * freq * i / sampleRate) * 0.5 +
|
||||
Math.sin(2 * Math.PI * freq * 1.5 * i / sampleRate) * 0.2) * envelope * 0.3;
|
||||
}));
|
||||
|
||||
// Game over: descending tone
|
||||
this._buffers.set('gameover', this._generateBuffer(sampleRate, 0.6, (i, len) => {
|
||||
const t = i / len;
|
||||
const freq = 400 - t * 250;
|
||||
const envelope = 1 - t;
|
||||
return Math.sin(2 * Math.PI * freq * i / sampleRate) * envelope * 0.3;
|
||||
}));
|
||||
|
||||
// Victory: ascending fanfare
|
||||
this._buffers.set('victory', this._generateBuffer(sampleRate, 0.5, (i, len) => {
|
||||
const t = i / len;
|
||||
// Three-note ascending pattern
|
||||
let freq;
|
||||
if (t < 0.33) freq = 523; // C5
|
||||
else if (t < 0.66) freq = 659; // E5
|
||||
else freq = 784; // G5
|
||||
const segT = (t % 0.33) / 0.33;
|
||||
const envelope = segT < 0.1 ? segT / 0.1 : Math.max(0, 1 - (segT - 0.1) / 0.9);
|
||||
return (Math.sin(2 * Math.PI * freq * i / sampleRate) * 0.4 +
|
||||
Math.sin(2 * Math.PI * freq * 2 * i / sampleRate) * 0.15) * envelope * 0.3;
|
||||
}));
|
||||
|
||||
// Move: low rumble (very short, for tank movement)
|
||||
this._buffers.set('move', this._generateBuffer(sampleRate, 0.05, (i, len) => {
|
||||
const t = i / len;
|
||||
const envelope = 1 - t;
|
||||
return Math.sin(2 * Math.PI * 80 * i / sampleRate) * envelope * 0.1;
|
||||
}));
|
||||
}
|
||||
|
||||
/**
|
||||
* Generate a PCM audio buffer.
|
||||
* @private
|
||||
* @param {number} sampleRate
|
||||
* @param {number} duration - Duration in seconds.
|
||||
* @param {Function} generator - (sampleIndex, totalSamples) => sampleValue [-1, 1]
|
||||
* @returns {AudioBuffer}
|
||||
*/
|
||||
_generateBuffer(sampleRate, duration, generator) {
|
||||
const ctx = this._audioCtx;
|
||||
const length = Math.floor(sampleRate * duration);
|
||||
const buffer = ctx.createBuffer(1, length, sampleRate);
|
||||
const data = buffer.getChannelData(0);
|
||||
for (let i = 0; i < length; i++) {
|
||||
data[i] = generator(i, length);
|
||||
}
|
||||
return buffer;
|
||||
}
|
||||
|
||||
/**
|
||||
* Play a sound effect by name.
|
||||
* @param {string} name - Sound name (shoot, explosion_small, explosion_big, hit, hit_wall, powerup, gameover, victory, move).
|
||||
*/
|
||||
playSFX(name) {
|
||||
if (!this._soundEnabled || !this._initialized || !this._audioCtx) return;
|
||||
|
||||
const buffer = this._buffers.get(name);
|
||||
if (!buffer) return;
|
||||
|
||||
try {
|
||||
const source = this._audioCtx.createBufferSource();
|
||||
source.buffer = buffer;
|
||||
source.connect(this._audioCtx.destination);
|
||||
source.start(0);
|
||||
} catch (e) {
|
||||
// Silently ignore playback errors
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Register a sound effect (kept for backward compatibility).
|
||||
* @param {string} name
|
||||
* @param {string} path
|
||||
*/
|
||||
register(name, path) {
|
||||
// No-op: sounds are now generated programmatically
|
||||
}
|
||||
|
||||
/**
|
||||
* Play background music (no-op for now, can be implemented later with audio files).
|
||||
* @param {string} path
|
||||
*/
|
||||
playBGM(path) {
|
||||
// BGM requires audio files — not implemented in programmatic mode
|
||||
}
|
||||
|
||||
/** Stop background music. */
|
||||
stopBGM() {}
|
||||
|
||||
/** Pause all audio. */
|
||||
pauseAll() {}
|
||||
|
||||
/** Resume audio. */
|
||||
resumeAll() {}
|
||||
|
||||
/** Destroy audio context. */
|
||||
destroy() {
|
||||
if (this._audioCtx) {
|
||||
try { this._audioCtx.close(); } catch (e) {}
|
||||
this._audioCtx = null;
|
||||
}
|
||||
this._buffers.clear();
|
||||
this._initialized = false;
|
||||
}
|
||||
|
||||
/** Whether sound effects are enabled. */
|
||||
get soundEnabled() { return this._soundEnabled; }
|
||||
set soundEnabled(v) { this._soundEnabled = v; }
|
||||
|
||||
/** Whether music is enabled. */
|
||||
get musicEnabled() { return this._musicEnabled; }
|
||||
set musicEnabled(v) { this._musicEnabled = v; }
|
||||
}
|
||||
|
||||
module.exports = AudioManager;
|
||||
@@ -0,0 +1,7 @@
|
||||
// BattlePassManager - DEPRECATED (removed in monetization-lite)
|
||||
// This file is intentionally empty. The battle pass system has been removed.
|
||||
class BattlePassManager {
|
||||
constructor() {}
|
||||
reportGameStats() {}
|
||||
}
|
||||
module.exports = BattlePassManager;
|
||||
@@ -0,0 +1,217 @@
|
||||
/**
|
||||
* BuffManager.js
|
||||
* Manages pre-game buff purchases and activation.
|
||||
* Buffs are one-time per round: Shield (100g) and Double Fire (150g).
|
||||
*/
|
||||
|
||||
/** Buff type definitions. */
|
||||
const BUFF_TYPE = {
|
||||
SHIELD: 'SHIELD',
|
||||
DOUBLE_FIRE: 'DOUBLE_FIRE',
|
||||
};
|
||||
|
||||
/** Buff cost in gold. */
|
||||
const BUFF_COST = {
|
||||
[BUFF_TYPE.SHIELD]: 100,
|
||||
[BUFF_TYPE.DOUBLE_FIRE]: 150,
|
||||
};
|
||||
|
||||
/** Double fire duration in seconds. */
|
||||
const DOUBLE_FIRE_DURATION = 10;
|
||||
|
||||
class BuffManager {
|
||||
constructor() {
|
||||
/** @type {Set<string>} Active buffs for the current round. */
|
||||
this._activeBuffs = new Set();
|
||||
|
||||
/** @type {number} Remaining double fire time in seconds. */
|
||||
this._doubleFireTimer = 0;
|
||||
|
||||
/** @type {boolean} Whether shield is currently active. */
|
||||
this._shieldActive = false;
|
||||
}
|
||||
|
||||
// ============================================================
|
||||
// Purchase
|
||||
// ============================================================
|
||||
|
||||
/**
|
||||
* Purchase a buff for the upcoming round.
|
||||
* Deducts gold via CurrencyManager.
|
||||
* @param {string} buffType - One of BUFF_TYPE values.
|
||||
* @returns {{ success: boolean, error?: string }}
|
||||
*/
|
||||
purchaseBuff(buffType) {
|
||||
const cost = BUFF_COST[buffType];
|
||||
if (cost === undefined) {
|
||||
return { success: false, error: 'Invalid buff type' };
|
||||
}
|
||||
|
||||
if (this._activeBuffs.has(buffType)) {
|
||||
return { success: false, error: 'Already purchased' };
|
||||
}
|
||||
|
||||
const cm = GameGlobal.currencyManager;
|
||||
if (!cm || !cm.hasGold(cost)) {
|
||||
return { success: false, error: 'Insufficient gold' };
|
||||
}
|
||||
|
||||
const spent = cm.spendGold(cost);
|
||||
if (!spent) {
|
||||
return { success: false, error: 'Insufficient gold' };
|
||||
}
|
||||
|
||||
this._activeBuffs.add(buffType);
|
||||
console.log(`[BuffManager] Purchased buff: ${buffType} for ${cost} gold`);
|
||||
|
||||
// Emit event
|
||||
try {
|
||||
if (GameGlobal.eventBus) {
|
||||
GameGlobal.eventBus.emit('buff:purchased', { type: buffType, cost });
|
||||
}
|
||||
} catch (e) {}
|
||||
|
||||
return { success: true };
|
||||
}
|
||||
|
||||
// ============================================================
|
||||
// Activation & Game Logic
|
||||
// ============================================================
|
||||
|
||||
/**
|
||||
* Check if a buff was purchased for this round.
|
||||
* @param {string} buffType
|
||||
* @returns {boolean}
|
||||
*/
|
||||
hasBuff(buffType) {
|
||||
return this._activeBuffs.has(buffType);
|
||||
}
|
||||
|
||||
/**
|
||||
* Get all active buffs for this round.
|
||||
* @returns {string[]}
|
||||
*/
|
||||
getActiveBuffs() {
|
||||
return Array.from(this._activeBuffs);
|
||||
}
|
||||
|
||||
/**
|
||||
* Activate buffs at the start of a round.
|
||||
* Should be called when the game scene initializes.
|
||||
* @param {object} playerTank - The player tank instance.
|
||||
*/
|
||||
activateBuffs(playerTank) {
|
||||
if (!playerTank) return;
|
||||
|
||||
// Shield buff: add a shield layer to the player tank
|
||||
if (this._activeBuffs.has(BUFF_TYPE.SHIELD)) {
|
||||
this._shieldActive = true;
|
||||
playerTank._buffShield = true;
|
||||
console.log('[BuffManager] Shield buff activated');
|
||||
}
|
||||
|
||||
// Double fire buff: start the timer
|
||||
if (this._activeBuffs.has(BUFF_TYPE.DOUBLE_FIRE)) {
|
||||
this._doubleFireTimer = DOUBLE_FIRE_DURATION;
|
||||
playerTank._buffDoubleFire = true;
|
||||
console.log('[BuffManager] Double fire buff activated');
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Update buff timers. Called every frame from GameScene.
|
||||
* @param {number} dt - Delta time in seconds.
|
||||
* @param {object} playerTank - The player tank instance.
|
||||
*/
|
||||
update(dt, playerTank) {
|
||||
if (!playerTank) return;
|
||||
|
||||
// Double fire timer countdown
|
||||
if (this._doubleFireTimer > 0) {
|
||||
this._doubleFireTimer -= dt;
|
||||
if (this._doubleFireTimer <= 0) {
|
||||
this._doubleFireTimer = 0;
|
||||
playerTank._buffDoubleFire = false;
|
||||
console.log('[BuffManager] Double fire buff expired');
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Consume the shield buff (called when player takes damage).
|
||||
* @param {object} playerTank - The player tank instance.
|
||||
* @returns {boolean} True if shield was consumed (damage absorbed).
|
||||
*/
|
||||
consumeShield(playerTank) {
|
||||
if (this._shieldActive && playerTank && playerTank._buffShield) {
|
||||
this._shieldActive = false;
|
||||
playerTank._buffShield = false;
|
||||
console.log('[BuffManager] Shield buff consumed');
|
||||
|
||||
// Emit event for visual feedback
|
||||
try {
|
||||
if (GameGlobal.eventBus) {
|
||||
GameGlobal.eventBus.emit('buff:shield:consumed');
|
||||
}
|
||||
} catch (e) {}
|
||||
|
||||
return true;
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if double fire is currently active.
|
||||
* @returns {boolean}
|
||||
*/
|
||||
isDoubleFireActive() {
|
||||
return this._doubleFireTimer > 0;
|
||||
}
|
||||
|
||||
/**
|
||||
* Get remaining double fire time in seconds.
|
||||
* @returns {number}
|
||||
*/
|
||||
getDoubleFireRemaining() {
|
||||
return Math.max(0, this._doubleFireTimer);
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if shield buff is still active (not yet consumed).
|
||||
* @returns {boolean}
|
||||
*/
|
||||
isShieldActive() {
|
||||
return this._shieldActive;
|
||||
}
|
||||
|
||||
// ============================================================
|
||||
// Round Lifecycle
|
||||
// ============================================================
|
||||
|
||||
/**
|
||||
* Clear all buffs at the end of a round.
|
||||
* Must be called when the game ends (win or lose).
|
||||
*/
|
||||
clearBuffs() {
|
||||
this._activeBuffs.clear();
|
||||
this._doubleFireTimer = 0;
|
||||
this._shieldActive = false;
|
||||
console.log('[BuffManager] All buffs cleared');
|
||||
}
|
||||
|
||||
/**
|
||||
* Get buff cost.
|
||||
* @param {string} buffType
|
||||
* @returns {number}
|
||||
*/
|
||||
getBuffCost(buffType) {
|
||||
return BUFF_COST[buffType] || 0;
|
||||
}
|
||||
}
|
||||
|
||||
// Export constants
|
||||
BuffManager.BUFF_TYPE = BUFF_TYPE;
|
||||
BuffManager.BUFF_COST = BUFF_COST;
|
||||
BuffManager.DOUBLE_FIRE_DURATION = DOUBLE_FIRE_DURATION;
|
||||
|
||||
module.exports = BuffManager;
|
||||
@@ -0,0 +1,383 @@
|
||||
/**
|
||||
* CollisionManager.js
|
||||
* Handles all collision detection between game entities each frame:
|
||||
* bullet↔terrain, bullet↔tank, bullet↔bullet, bullet↔base, tank↔tank.
|
||||
*/
|
||||
|
||||
const {
|
||||
TILE_SIZE,
|
||||
MAP_OFFSET_X,
|
||||
MAP_OFFSET_Y,
|
||||
MAP_WIDTH,
|
||||
MAP_HEIGHT,
|
||||
TERRAIN,
|
||||
GRID_ROWS,
|
||||
GRID_COLS,
|
||||
} = require('../base/GameGlobal');
|
||||
|
||||
class CollisionManager {
|
||||
/**
|
||||
* @param {object} deps
|
||||
* @param {import('../managers/MapManager')} deps.mapManager
|
||||
* @param {Function} deps.onExplosion - Callback(x, y, isBig) to spawn explosion.
|
||||
* @param {import('../base/EventBus')} deps.eventBus
|
||||
*/
|
||||
constructor(deps) {
|
||||
this._map = deps.mapManager;
|
||||
this._onExplosion = deps.onExplosion;
|
||||
this._eventBus = deps.eventBus;
|
||||
}
|
||||
|
||||
/**
|
||||
* Run all collision checks for one frame.
|
||||
* @param {object} entities
|
||||
* @param {import('../entities/PlayerTank')} entities.player
|
||||
* @param {Array<import('../entities/Tank')>} entities.enemies
|
||||
* @param {Array<import('../entities/Bullet')>} entities.bullets
|
||||
*/
|
||||
update(entities) {
|
||||
const { player, enemies, bullets } = entities;
|
||||
const aliveBullets = bullets.filter((b) => b.alive);
|
||||
const aliveEnemies = enemies.filter((e) => e.alive);
|
||||
|
||||
// 1. Bullet ↔ Terrain / Base
|
||||
this._checkBulletTerrain(aliveBullets);
|
||||
|
||||
// 2. Bullet ↔ Tank
|
||||
this._checkBulletTank(aliveBullets, player, aliveEnemies);
|
||||
|
||||
// 3. Bullet ↔ Bullet (player vs enemy)
|
||||
this._checkBulletBullet(aliveBullets);
|
||||
|
||||
// 4. Tank ↔ Tank (player vs enemies)
|
||||
// Note: In classic tank game, tanks block each other but don't destroy on contact.
|
||||
// Player death on contact is optional - we implement it per requirements.
|
||||
this._checkTankTank(player, aliveEnemies);
|
||||
}
|
||||
|
||||
/**
|
||||
* Check bullets against terrain tiles.
|
||||
* @private
|
||||
*/
|
||||
_checkBulletTerrain(bullets) {
|
||||
for (const bullet of bullets) {
|
||||
if (!bullet.alive) continue;
|
||||
|
||||
const { row, col } = this._map.pixelToGrid(bullet.x, bullet.y);
|
||||
|
||||
// Out of map bounds
|
||||
if (row < 0 || row >= GRID_ROWS || col < 0 || col >= GRID_COLS) {
|
||||
bullet.destroy();
|
||||
this._onExplosion(bullet.x, bullet.y, false);
|
||||
continue;
|
||||
}
|
||||
|
||||
const terrain = this._map.getTerrain(row, col);
|
||||
|
||||
if (terrain === TERRAIN.BRICK) {
|
||||
// Destroy brick
|
||||
this._map.setTerrain(row, col, TERRAIN.EMPTY);
|
||||
|
||||
// Lv3 bullets destroy adjacent bricks too
|
||||
if (bullet.canBreakSteel) {
|
||||
this._destroyAdjacentBricks(row, col, bullet.direction);
|
||||
}
|
||||
|
||||
bullet.destroy();
|
||||
this._onExplosion(bullet.x, bullet.y, false);
|
||||
} else if (terrain === TERRAIN.BASE_WALL) {
|
||||
// Player bullets are immune to base wall
|
||||
if (bullet.owner === 'player') {
|
||||
bullet.destroy();
|
||||
continue;
|
||||
}
|
||||
|
||||
// Base wall has HP - use bulletHitTerrain for HP tracking
|
||||
const result = this._map.bulletHitTerrain(row, col, bullet.canBreakSteel);
|
||||
|
||||
// Lv3 bullets also damage adjacent base walls
|
||||
if (bullet.canBreakSteel) {
|
||||
this._destroyAdjacentBricks(row, col, bullet.direction);
|
||||
}
|
||||
|
||||
bullet.destroy();
|
||||
this._onExplosion(bullet.x, bullet.y, false);
|
||||
} else if (terrain === TERRAIN.STEEL) {
|
||||
if (bullet.canBreakSteel) {
|
||||
this._map.setTerrain(row, col, TERRAIN.EMPTY);
|
||||
bullet.destroy();
|
||||
this._onExplosion(bullet.x, bullet.y, false);
|
||||
} else {
|
||||
// Bullet blocked by steel
|
||||
bullet.destroy();
|
||||
this._onExplosion(bullet.x, bullet.y, false);
|
||||
}
|
||||
} else if (terrain === TERRAIN.BASE) {
|
||||
// Player bullets are immune to base
|
||||
if (bullet.owner === 'player') {
|
||||
bullet.destroy();
|
||||
continue;
|
||||
}
|
||||
|
||||
// Base hit by enemy bullet!
|
||||
this._map._baseDestroyed = true;
|
||||
bullet.destroy();
|
||||
this._onExplosion(bullet.x, bullet.y, true);
|
||||
this._eventBus.emit('base:destroyed');
|
||||
}
|
||||
// RIVER and FOREST: bullets pass through
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Destroy adjacent bricks for Lv3 bullet splash.
|
||||
* @private
|
||||
*/
|
||||
_destroyAdjacentBricks(row, col, direction) {
|
||||
const { DIRECTION } = require('../base/GameGlobal');
|
||||
const offsets =
|
||||
direction === DIRECTION.UP || direction === DIRECTION.DOWN
|
||||
? [[0, -1], [0, 1]] // horizontal neighbors
|
||||
: [[-1, 0], [1, 0]]; // vertical neighbors
|
||||
|
||||
for (const [dr, dc] of offsets) {
|
||||
const nr = row + dr;
|
||||
const nc = col + dc;
|
||||
const t = this._map.getTerrain(nr, nc);
|
||||
if (t === TERRAIN.BRICK) {
|
||||
this._map.setTerrain(nr, nc, TERRAIN.EMPTY);
|
||||
} else if (t === TERRAIN.BASE_WALL) {
|
||||
// Base wall has HP - use bulletHitTerrain for HP tracking
|
||||
this._map.bulletHitTerrain(nr, nc, false);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Check bullets against tanks.
|
||||
* @private
|
||||
*/
|
||||
_checkBulletTank(bullets, player, enemies) {
|
||||
for (const bullet of bullets) {
|
||||
if (!bullet.alive) continue;
|
||||
|
||||
const bb = bullet.getBounds();
|
||||
|
||||
if (bullet.owner === 'player') {
|
||||
// Player bullet hits enemy
|
||||
for (const enemy of enemies) {
|
||||
if (!enemy.alive) continue;
|
||||
const eb = enemy.getBounds();
|
||||
if (this._rectsOverlap(bb, eb)) {
|
||||
const destroyed = enemy.takeDamage(1);
|
||||
bullet.destroy();
|
||||
if (destroyed) {
|
||||
this._onExplosion(enemy.x, enemy.y, true);
|
||||
this._eventBus.emit('enemy:destroyed', { enemy });
|
||||
} else {
|
||||
this._onExplosion(bullet.x, bullet.y, false);
|
||||
this._eventBus.emit('enemy:hit', { enemy });
|
||||
if (GameGlobal.audioManager) GameGlobal.audioManager.playSFX('hit');
|
||||
}
|
||||
break;
|
||||
}
|
||||
}
|
||||
} else {
|
||||
// Enemy bullet hits player
|
||||
if (player && player.alive) {
|
||||
const pb = player.getBounds();
|
||||
if (this._rectsOverlap(bb, pb)) {
|
||||
const destroyed = player.takeDamage(1);
|
||||
bullet.destroy();
|
||||
if (destroyed) {
|
||||
this._onExplosion(player.x, player.y, true);
|
||||
this._eventBus.emit('player:destroyed');
|
||||
} else {
|
||||
this._onExplosion(bullet.x, bullet.y, false);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Check bullet-bullet collisions (player vs enemy bullets cancel out).
|
||||
* @private
|
||||
*/
|
||||
_checkBulletBullet(bullets) {
|
||||
for (let i = 0; i < bullets.length; i++) {
|
||||
if (!bullets[i].alive) continue;
|
||||
for (let j = i + 1; j < bullets.length; j++) {
|
||||
if (!bullets[j].alive) continue;
|
||||
|
||||
// Only cancel if different owners
|
||||
if (bullets[i].owner === bullets[j].owner) continue;
|
||||
|
||||
const a = bullets[i].getBounds();
|
||||
const b = bullets[j].getBounds();
|
||||
|
||||
if (this._rectsOverlap(a, b)) {
|
||||
const mx = (bullets[i].x + bullets[j].x) / 2;
|
||||
const my = (bullets[i].y + bullets[j].y) / 2;
|
||||
bullets[i].destroy();
|
||||
bullets[j].destroy();
|
||||
this._onExplosion(mx, my, false);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Check tank-tank collisions.
|
||||
* Classic Battle City behavior: tanks block each other on contact,
|
||||
* they are pushed apart so they don't overlap. No damage is dealt.
|
||||
* @private
|
||||
*/
|
||||
_checkTankTank(player, enemies) {
|
||||
if (!player || !player.alive) return;
|
||||
|
||||
for (const enemy of enemies) {
|
||||
if (!enemy.alive) continue;
|
||||
if (player.collidesWith(enemy)) {
|
||||
// Push tanks apart — resolve overlap along the axis with smallest penetration
|
||||
this._separateTanks(player, enemy);
|
||||
}
|
||||
}
|
||||
|
||||
// Also prevent enemies from overlapping each other
|
||||
for (let i = 0; i < enemies.length; i++) {
|
||||
if (!enemies[i].alive) continue;
|
||||
for (let j = i + 1; j < enemies.length; j++) {
|
||||
if (!enemies[j].alive) continue;
|
||||
if (enemies[i].collidesWith(enemies[j])) {
|
||||
this._separateTanks(enemies[i], enemies[j]);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if a tank position is valid (within map bounds and not colliding with terrain).
|
||||
* @private
|
||||
*/
|
||||
_isPositionValid(tank, x, y) {
|
||||
const hs = tank.halfSize;
|
||||
const left = x - hs;
|
||||
const top = y - hs;
|
||||
const right = x + hs;
|
||||
const bottom = y + hs;
|
||||
|
||||
// Map boundary check
|
||||
if (
|
||||
left < MAP_OFFSET_X ||
|
||||
top < MAP_OFFSET_Y ||
|
||||
right > MAP_OFFSET_X + MAP_WIDTH ||
|
||||
bottom > MAP_OFFSET_Y + MAP_HEIGHT
|
||||
) {
|
||||
return false;
|
||||
}
|
||||
|
||||
// Terrain collision check
|
||||
if (this._map.rectCollidesWithTerrain(left, top, tank.size, tank.size)) {
|
||||
return false;
|
||||
}
|
||||
|
||||
return true;
|
||||
}
|
||||
|
||||
/**
|
||||
* Push two overlapping tanks apart along the axis of least penetration.
|
||||
* Validates new positions against map bounds and terrain before applying.
|
||||
* @private
|
||||
*/
|
||||
_separateTanks(tankA, tankB) {
|
||||
const a = tankA.getBounds();
|
||||
const b = tankB.getBounds();
|
||||
|
||||
// Calculate overlap on each axis
|
||||
const overlapX = Math.min(a.x + a.w, b.x + b.w) - Math.max(a.x, b.x);
|
||||
const overlapY = Math.min(a.y + a.h, b.y + b.h) - Math.max(a.y, b.y);
|
||||
|
||||
if (overlapX <= 0 || overlapY <= 0) return; // no real overlap
|
||||
|
||||
if (overlapX < overlapY) {
|
||||
// Separate along X axis
|
||||
const sign = tankA.x < tankB.x ? -1 : 1;
|
||||
const halfPush = overlapX / 2;
|
||||
|
||||
const newAx = tankA.x + sign * halfPush;
|
||||
const newBx = tankB.x - sign * halfPush;
|
||||
|
||||
const aValid = this._isPositionValid(tankA, newAx, tankA.y);
|
||||
const bValid = this._isPositionValid(tankB, newBx, tankB.y);
|
||||
|
||||
if (aValid && bValid) {
|
||||
tankA.x = newAx;
|
||||
tankB.x = newBx;
|
||||
} else if (aValid && !bValid) {
|
||||
// B can't move, push A the full overlap
|
||||
const fullAx = tankA.x + sign * overlapX;
|
||||
if (this._isPositionValid(tankA, fullAx, tankA.y)) {
|
||||
tankA.x = fullAx;
|
||||
} else {
|
||||
tankA.x = newAx; // at least push half
|
||||
}
|
||||
} else if (!aValid && bValid) {
|
||||
// A can't move, push B the full overlap
|
||||
const fullBx = tankB.x - sign * overlapX;
|
||||
if (this._isPositionValid(tankB, fullBx, tankB.y)) {
|
||||
tankB.x = fullBx;
|
||||
} else {
|
||||
tankB.x = newBx; // at least push half
|
||||
}
|
||||
}
|
||||
// If neither is valid, don't move either (both stuck)
|
||||
} else {
|
||||
// Separate along Y axis
|
||||
const sign = tankA.y < tankB.y ? -1 : 1;
|
||||
const halfPush = overlapY / 2;
|
||||
|
||||
const newAy = tankA.y + sign * halfPush;
|
||||
const newBy = tankB.y - sign * halfPush;
|
||||
|
||||
const aValid = this._isPositionValid(tankA, tankA.x, newAy);
|
||||
const bValid = this._isPositionValid(tankB, tankB.x, newBy);
|
||||
|
||||
if (aValid && bValid) {
|
||||
tankA.y = newAy;
|
||||
tankB.y = newBy;
|
||||
} else if (aValid && !bValid) {
|
||||
const fullAy = tankA.y + sign * overlapY;
|
||||
if (this._isPositionValid(tankA, tankA.x, fullAy)) {
|
||||
tankA.y = fullAy;
|
||||
} else {
|
||||
tankA.y = newAy;
|
||||
}
|
||||
} else if (!aValid && bValid) {
|
||||
const fullBy = tankB.y - sign * overlapY;
|
||||
if (this._isPositionValid(tankB, tankB.x, fullBy)) {
|
||||
tankB.y = fullBy;
|
||||
} else {
|
||||
tankB.y = newBy;
|
||||
}
|
||||
}
|
||||
// If neither is valid, don't move either (both stuck)
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* AABB overlap test.
|
||||
* @private
|
||||
*/
|
||||
_rectsOverlap(a, b) {
|
||||
return (
|
||||
a.x < b.x + b.w &&
|
||||
a.x + a.w > b.x &&
|
||||
a.y < b.y + b.h &&
|
||||
a.y + a.h > b.y
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
module.exports = CollisionManager;
|
||||
@@ -0,0 +1,227 @@
|
||||
/**
|
||||
* ComplianceManager.js
|
||||
* Manages underage protection, probability disclosure, and anti-cheat measures.
|
||||
* Ensures compliance with Chinese gaming regulations and WeChat platform rules.
|
||||
*/
|
||||
|
||||
class ComplianceManager {
|
||||
constructor() {
|
||||
this._isMinor = false;
|
||||
this._monthlySpending = 0;
|
||||
this._dailyAdCount = 0;
|
||||
this._trackingDate = '';
|
||||
|
||||
this._load();
|
||||
this._checkMinorStatus();
|
||||
}
|
||||
|
||||
// ============================================================
|
||||
// Persistence
|
||||
// ============================================================
|
||||
|
||||
/** @private */
|
||||
_getTodayKey() {
|
||||
const d = new Date();
|
||||
return `${d.getFullYear()}-${String(d.getMonth() + 1).padStart(2, '0')}-${String(d.getDate()).padStart(2, '0')}`;
|
||||
}
|
||||
|
||||
/** @private */
|
||||
_getMonthKey() {
|
||||
const d = new Date();
|
||||
return `${d.getFullYear()}-${String(d.getMonth() + 1).padStart(2, '0')}`;
|
||||
}
|
||||
|
||||
/** @private */
|
||||
_load() {
|
||||
try {
|
||||
if (GameGlobal && GameGlobal.storageManager) {
|
||||
const data = GameGlobal.storageManager.get('compliance', null);
|
||||
if (data) {
|
||||
this._isMinor = data.isMinor || false;
|
||||
|
||||
// Monthly spending
|
||||
const currentMonth = this._getMonthKey();
|
||||
if (data.spendingMonth === currentMonth) {
|
||||
this._monthlySpending = data.monthlySpending || 0;
|
||||
}
|
||||
|
||||
// Daily ad count
|
||||
const today = this._getTodayKey();
|
||||
if (data.adDate === today) {
|
||||
this._dailyAdCount = data.dailyAdCount || 0;
|
||||
}
|
||||
this._trackingDate = today;
|
||||
}
|
||||
}
|
||||
} catch (e) {
|
||||
console.warn('[ComplianceManager] Failed to load:', e);
|
||||
}
|
||||
}
|
||||
|
||||
/** @private */
|
||||
_save() {
|
||||
try {
|
||||
if (GameGlobal && GameGlobal.storageManager) {
|
||||
GameGlobal.storageManager.set('compliance', {
|
||||
isMinor: this._isMinor,
|
||||
spendingMonth: this._getMonthKey(),
|
||||
monthlySpending: this._monthlySpending,
|
||||
adDate: this._getTodayKey(),
|
||||
dailyAdCount: this._dailyAdCount,
|
||||
});
|
||||
}
|
||||
} catch (e) {}
|
||||
}
|
||||
|
||||
// ============================================================
|
||||
// Minor Status Detection
|
||||
// ============================================================
|
||||
|
||||
/**
|
||||
* Check if the user is a minor via WeChat platform API.
|
||||
* @private
|
||||
*/
|
||||
_checkMinorStatus() {
|
||||
// WeChat provides user age info through specific APIs
|
||||
// In production, this would call wx.getUserInfo or a server-side check
|
||||
// For now, default to non-minor
|
||||
try {
|
||||
if (typeof wx !== 'undefined' && typeof wx.getSetting === 'function') {
|
||||
// Placeholder: in production, check real-name verification status
|
||||
console.log('[ComplianceManager] Minor status check: defaulting to adult');
|
||||
}
|
||||
} catch (e) {}
|
||||
}
|
||||
|
||||
// ============================================================
|
||||
// Public API
|
||||
// ============================================================
|
||||
|
||||
/**
|
||||
* Whether the current user is identified as a minor.
|
||||
* @returns {boolean}
|
||||
*/
|
||||
isMinor() {
|
||||
return this._isMinor;
|
||||
}
|
||||
|
||||
/**
|
||||
* Set minor status (called after real-name verification).
|
||||
* @param {boolean} isMinor
|
||||
*/
|
||||
setMinorStatus(isMinor) {
|
||||
this._isMinor = isMinor;
|
||||
this._save();
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if a purchase is allowed for the current user.
|
||||
* Minors: monthly limit ¥400, single purchase > ¥50 requires confirmation.
|
||||
* @param {number} amountFen - Purchase amount in fen (分).
|
||||
* @returns {{ allowed: boolean, needsConfirmation: boolean, reason?: string }}
|
||||
*/
|
||||
checkPurchaseRestriction(amountFen) {
|
||||
if (!this._isMinor) {
|
||||
return { allowed: true, needsConfirmation: false };
|
||||
}
|
||||
|
||||
const amountYuan = amountFen / 100;
|
||||
|
||||
// Monthly limit: ¥400
|
||||
const newTotal = this._monthlySpending + amountFen;
|
||||
if (newTotal > 40000) { // 400 yuan in fen
|
||||
return {
|
||||
allowed: false,
|
||||
needsConfirmation: false,
|
||||
reason: 'monthly_limit',
|
||||
};
|
||||
}
|
||||
|
||||
// Single purchase > ¥50 needs confirmation
|
||||
if (amountYuan > 50) {
|
||||
return {
|
||||
allowed: true,
|
||||
needsConfirmation: true,
|
||||
reason: 'large_purchase',
|
||||
};
|
||||
}
|
||||
|
||||
return { allowed: true, needsConfirmation: false };
|
||||
}
|
||||
|
||||
/**
|
||||
* Record a successful purchase amount.
|
||||
* @param {number} amountFen
|
||||
*/
|
||||
recordPurchase(amountFen) {
|
||||
this._monthlySpending += amountFen;
|
||||
this._save();
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if an ad can be shown to the current user.
|
||||
* Minors: max 5 ads per day.
|
||||
* @returns {boolean}
|
||||
*/
|
||||
canShowAd() {
|
||||
if (!this._isMinor) return true;
|
||||
|
||||
const today = this._getTodayKey();
|
||||
if (this._trackingDate !== today) {
|
||||
this._trackingDate = today;
|
||||
this._dailyAdCount = 0;
|
||||
}
|
||||
|
||||
return this._dailyAdCount < 5;
|
||||
}
|
||||
|
||||
/**
|
||||
* Record an ad shown to the user.
|
||||
*/
|
||||
recordAdShown() {
|
||||
if (!this._isMinor) return;
|
||||
|
||||
const today = this._getTodayKey();
|
||||
if (this._trackingDate !== today) {
|
||||
this._trackingDate = today;
|
||||
this._dailyAdCount = 0;
|
||||
}
|
||||
|
||||
this._dailyAdCount++;
|
||||
this._save();
|
||||
}
|
||||
|
||||
/**
|
||||
* Validate game session data for anti-cheat.
|
||||
* Checks for impossible stats (e.g., too many kills in too short time).
|
||||
* @param {object} stats - { kills, timeElapsed, score }
|
||||
* @returns {{ valid: boolean, flags: string[] }}
|
||||
*/
|
||||
validateGameSession(stats) {
|
||||
const flags = [];
|
||||
|
||||
if (!stats) return { valid: true, flags };
|
||||
|
||||
// Check impossible kill rate (>10 kills per minute)
|
||||
if (stats.kills && stats.timeElapsed) {
|
||||
const killsPerMinute = stats.kills / (stats.timeElapsed / 60);
|
||||
if (killsPerMinute > 10) {
|
||||
flags.push('suspicious_kill_rate');
|
||||
}
|
||||
}
|
||||
|
||||
// Check impossible score
|
||||
if (stats.score && stats.score > 100000) {
|
||||
flags.push('suspicious_score');
|
||||
}
|
||||
|
||||
// Check suspicious ad reward frequency
|
||||
// (anti-cheat for ad reward manipulation)
|
||||
return {
|
||||
valid: flags.length === 0,
|
||||
flags,
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
module.exports = ComplianceManager;
|
||||
@@ -0,0 +1,172 @@
|
||||
/**
|
||||
* CurrencyManager.js
|
||||
* Manages the single in-game currency: Gold.
|
||||
* Simplified from the original multi-currency system (monetization-lite).
|
||||
* Provides add/spend/get operations with EventBus notifications,
|
||||
* StorageManager persistence, and overflow protection.
|
||||
*/
|
||||
|
||||
/** Maximum gold cap to prevent overflow. */
|
||||
const MAX_GOLD = 999999;
|
||||
|
||||
class CurrencyManager {
|
||||
constructor() {
|
||||
this._gold = 0;
|
||||
this._load();
|
||||
}
|
||||
|
||||
// ============================================================
|
||||
// Persistence
|
||||
// ============================================================
|
||||
|
||||
/**
|
||||
* Load currency data from StorageManager.
|
||||
* @private
|
||||
*/
|
||||
_load() {
|
||||
try {
|
||||
if (GameGlobal && GameGlobal.storageManager) {
|
||||
const data = GameGlobal.storageManager.get('currency', null);
|
||||
if (data) {
|
||||
this._gold = data.gold || 0;
|
||||
}
|
||||
}
|
||||
} catch (e) {
|
||||
console.warn('[CurrencyManager] Failed to load currency data:', e);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Save currency data to StorageManager.
|
||||
* @private
|
||||
*/
|
||||
_save() {
|
||||
try {
|
||||
if (GameGlobal && GameGlobal.storageManager) {
|
||||
GameGlobal.storageManager.set('currency', {
|
||||
gold: this._gold,
|
||||
});
|
||||
}
|
||||
} catch (e) {
|
||||
console.warn('[CurrencyManager] Failed to save currency data:', e);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Emit a currency change event via EventBus.
|
||||
* @private
|
||||
*/
|
||||
_emitGoldChanged() {
|
||||
try {
|
||||
if (GameGlobal && GameGlobal.eventBus) {
|
||||
GameGlobal.eventBus.emit('currency:gold:changed', this._gold);
|
||||
}
|
||||
} catch (e) {}
|
||||
}
|
||||
|
||||
// ============================================================
|
||||
// Gold
|
||||
// ============================================================
|
||||
|
||||
/**
|
||||
* Get current gold amount.
|
||||
* @returns {number}
|
||||
*/
|
||||
getGold() {
|
||||
return this._gold;
|
||||
}
|
||||
|
||||
/**
|
||||
* Add gold (capped at MAX_GOLD).
|
||||
* @param {number} amount - Must be positive.
|
||||
* @returns {number} Actual amount added (may be less if capped).
|
||||
*/
|
||||
addGold(amount) {
|
||||
if (amount <= 0) return 0;
|
||||
const before = this._gold;
|
||||
this._gold = Math.min(this._gold + Math.floor(amount), MAX_GOLD);
|
||||
const added = this._gold - before;
|
||||
if (added > 0) {
|
||||
this._save();
|
||||
this._emitGoldChanged();
|
||||
}
|
||||
return added;
|
||||
}
|
||||
|
||||
/**
|
||||
* Spend gold.
|
||||
* @param {number} amount - Must be positive.
|
||||
* @returns {boolean} True if successful, false if insufficient.
|
||||
*/
|
||||
spendGold(amount) {
|
||||
if (amount <= 0) return true;
|
||||
if (this._gold < amount) {
|
||||
// Emit insufficient event for UI to handle
|
||||
try {
|
||||
if (GameGlobal && GameGlobal.eventBus) {
|
||||
GameGlobal.eventBus.emit('currency:gold:insufficient', { required: amount, current: this._gold });
|
||||
}
|
||||
} catch (e) {}
|
||||
return false;
|
||||
}
|
||||
this._gold -= Math.floor(amount);
|
||||
this._save();
|
||||
this._emitGoldChanged();
|
||||
return true;
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if player has enough gold.
|
||||
* @param {number} amount
|
||||
* @returns {boolean}
|
||||
*/
|
||||
hasGold(amount) {
|
||||
return this._gold >= amount;
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if gold is at maximum cap.
|
||||
* @returns {boolean}
|
||||
*/
|
||||
isGoldFull() {
|
||||
return this._gold >= MAX_GOLD;
|
||||
}
|
||||
|
||||
/**
|
||||
* Get the maximum gold cap.
|
||||
* @returns {number}
|
||||
*/
|
||||
getMaxGold() {
|
||||
return MAX_GOLD;
|
||||
}
|
||||
|
||||
// ============================================================
|
||||
// Cloud Sync
|
||||
// ============================================================
|
||||
|
||||
/**
|
||||
* Get currency data for cloud sync.
|
||||
* @returns {object}
|
||||
*/
|
||||
getCloudSyncData() {
|
||||
return {
|
||||
gold: this._gold,
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Restore currency data from cloud (merge: keep higher values).
|
||||
* @param {object} cloudData
|
||||
*/
|
||||
restoreFromCloud(cloudData) {
|
||||
if (!cloudData) return;
|
||||
|
||||
if (cloudData.gold !== undefined && cloudData.gold > this._gold) {
|
||||
this._gold = Math.min(cloudData.gold, MAX_GOLD);
|
||||
this._save();
|
||||
this._emitGoldChanged();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
module.exports = CurrencyManager;
|
||||
@@ -0,0 +1,472 @@
|
||||
/**
|
||||
* MapManager.js
|
||||
* Manages the tile-based game map: loading, rendering, terrain state, and collision queries.
|
||||
*/
|
||||
|
||||
const {
|
||||
GRID_COLS,
|
||||
GRID_ROWS,
|
||||
TILE_SIZE,
|
||||
MAP_OFFSET_X,
|
||||
MAP_OFFSET_Y,
|
||||
TERRAIN,
|
||||
COLORS,
|
||||
} = require('../base/GameGlobal');
|
||||
|
||||
class MapManager {
|
||||
constructor() {
|
||||
/** @type {number[][]} 2D grid of terrain types */
|
||||
this._grid = [];
|
||||
/** @type {boolean} Whether the base has been destroyed */
|
||||
this._baseDestroyed = false;
|
||||
/** @type {boolean} Whether base walls are temporarily steel */
|
||||
this._baseSteelTimer = 0;
|
||||
/** @type {number[][]} Backup of original base wall positions */
|
||||
this._baseWallPositions = [];
|
||||
/** @type {Object} HP map for base wall tiles, keyed by 'row,col' */
|
||||
this._baseWallHP = {};
|
||||
/** Base wall default HP (hits required to destroy) */
|
||||
this.BASE_WALL_MAX_HP = 3;
|
||||
}
|
||||
|
||||
/**
|
||||
* Load a level grid.
|
||||
* @param {number[][]} grid - GRID_ROWS × GRID_COLS array of terrain values.
|
||||
*/
|
||||
loadGrid(grid) {
|
||||
// Deep clone so we don't mutate level data
|
||||
this._grid = grid.map((row) => [...row]);
|
||||
this._baseDestroyed = false;
|
||||
this._baseSteelTimer = 0;
|
||||
|
||||
// Record base wall positions for shovel power-up and initialize HP
|
||||
this._baseWallPositions = [];
|
||||
this._baseWallHP = {};
|
||||
for (let r = 0; r < GRID_ROWS; r++) {
|
||||
for (let c = 0; c < GRID_COLS; c++) {
|
||||
if (this._grid[r][c] === TERRAIN.BASE_WALL) {
|
||||
this._baseWallPositions.push([r, c]);
|
||||
this._baseWallHP[`${r},${c}`] = this.BASE_WALL_MAX_HP;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Update map state (e.g., shovel timer).
|
||||
* @param {number} dt - Delta time in seconds.
|
||||
*/
|
||||
update(dt) {
|
||||
if (this._baseSteelTimer > 0) {
|
||||
this._baseSteelTimer -= dt * 1000;
|
||||
if (this._baseSteelTimer <= 0) {
|
||||
this._baseSteelTimer = 0;
|
||||
// Revert steel walls back to brick and restore HP
|
||||
for (const [r, c] of this._baseWallPositions) {
|
||||
if (this._grid[r][c] === TERRAIN.STEEL) {
|
||||
this._grid[r][c] = TERRAIN.BASE_WALL;
|
||||
this._baseWallHP[`${r},${c}`] = this.BASE_WALL_MAX_HP;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Render the map tiles.
|
||||
* @param {CanvasRenderingContext2D} ctx
|
||||
*/
|
||||
render(ctx) {
|
||||
for (let r = 0; r < GRID_ROWS; r++) {
|
||||
for (let c = 0; c < GRID_COLS; c++) {
|
||||
const terrain = this._grid[r][c];
|
||||
if (terrain === TERRAIN.EMPTY) continue;
|
||||
|
||||
const x = MAP_OFFSET_X + c * TILE_SIZE;
|
||||
const y = MAP_OFFSET_Y + r * TILE_SIZE;
|
||||
|
||||
switch (terrain) {
|
||||
case TERRAIN.BRICK:
|
||||
this._drawBrick(ctx, x, y);
|
||||
break;
|
||||
case TERRAIN.BASE_WALL:
|
||||
this._drawBaseWall(ctx, x, y, r, c);
|
||||
break;
|
||||
case TERRAIN.STEEL:
|
||||
this._drawSteel(ctx, x, y);
|
||||
break;
|
||||
case TERRAIN.RIVER:
|
||||
this._drawRiver(ctx, x, y);
|
||||
break;
|
||||
case TERRAIN.FOREST:
|
||||
// Forest is drawn in a separate pass (on top of tanks)
|
||||
break;
|
||||
case TERRAIN.BASE:
|
||||
this._drawBase(ctx, x, y);
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Render the forest overlay (drawn after tanks so it covers them).
|
||||
* @param {CanvasRenderingContext2D} ctx
|
||||
*/
|
||||
renderForestOverlay(ctx) {
|
||||
for (let r = 0; r < GRID_ROWS; r++) {
|
||||
for (let c = 0; c < GRID_COLS; c++) {
|
||||
if (this._grid[r][c] === TERRAIN.FOREST) {
|
||||
const x = MAP_OFFSET_X + c * TILE_SIZE;
|
||||
const y = MAP_OFFSET_Y + r * TILE_SIZE;
|
||||
this._drawForest(ctx, x, y);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// ============================================================
|
||||
// Tile Drawing Methods
|
||||
// ============================================================
|
||||
|
||||
_drawBrick(ctx, x, y) {
|
||||
const s = TILE_SIZE;
|
||||
ctx.fillStyle = COLORS.BRICK;
|
||||
ctx.fillRect(x, y, s, s);
|
||||
|
||||
// Brick pattern (mortar lines)
|
||||
ctx.strokeStyle = '#8B4513';
|
||||
ctx.lineWidth = 1;
|
||||
// Horizontal line
|
||||
ctx.beginPath();
|
||||
ctx.moveTo(x, y + s / 2);
|
||||
ctx.lineTo(x + s, y + s / 2);
|
||||
ctx.stroke();
|
||||
// Vertical lines (offset pattern)
|
||||
ctx.beginPath();
|
||||
ctx.moveTo(x + s / 2, y);
|
||||
ctx.lineTo(x + s / 2, y + s / 2);
|
||||
ctx.stroke();
|
||||
ctx.beginPath();
|
||||
ctx.moveTo(x + s / 4, y + s / 2);
|
||||
ctx.lineTo(x + s / 4, y + s);
|
||||
ctx.stroke();
|
||||
ctx.beginPath();
|
||||
ctx.moveTo(x + s * 3 / 4, y + s / 2);
|
||||
ctx.lineTo(x + s * 3 / 4, y + s);
|
||||
ctx.stroke();
|
||||
}
|
||||
|
||||
_drawSteel(ctx, x, y) {
|
||||
const s = TILE_SIZE;
|
||||
ctx.fillStyle = COLORS.STEEL;
|
||||
ctx.fillRect(x, y, s, s);
|
||||
|
||||
// Steel shine effect
|
||||
ctx.fillStyle = '#A0A0A0';
|
||||
ctx.fillRect(x + 2, y + 2, s / 2 - 2, s / 2 - 2);
|
||||
ctx.fillRect(x + s / 2 + 1, y + s / 2 + 1, s / 2 - 3, s / 2 - 3);
|
||||
|
||||
// Border
|
||||
ctx.strokeStyle = '#606060';
|
||||
ctx.lineWidth = 1;
|
||||
ctx.strokeRect(x + 0.5, y + 0.5, s - 1, s - 1);
|
||||
}
|
||||
|
||||
_drawRiver(ctx, x, y) {
|
||||
const s = TILE_SIZE;
|
||||
ctx.fillStyle = COLORS.RIVER;
|
||||
ctx.fillRect(x, y, s, s);
|
||||
|
||||
// Wave pattern
|
||||
ctx.strokeStyle = '#5B9BD5';
|
||||
ctx.lineWidth = 1;
|
||||
for (let i = 0; i < 3; i++) {
|
||||
const wy = y + s * (i + 1) / 4;
|
||||
ctx.beginPath();
|
||||
ctx.moveTo(x, wy);
|
||||
ctx.quadraticCurveTo(x + s / 4, wy - 2, x + s / 2, wy);
|
||||
ctx.quadraticCurveTo(x + s * 3 / 4, wy + 2, x + s, wy);
|
||||
ctx.stroke();
|
||||
}
|
||||
}
|
||||
|
||||
_drawForest(ctx, x, y) {
|
||||
const s = TILE_SIZE;
|
||||
ctx.fillStyle = COLORS.FOREST;
|
||||
ctx.globalAlpha = 0.85;
|
||||
ctx.fillRect(x, y, s, s);
|
||||
|
||||
// Tree pattern
|
||||
ctx.fillStyle = '#008000';
|
||||
const r = s / 4;
|
||||
ctx.beginPath();
|
||||
ctx.arc(x + s / 3, y + s / 3, r, 0, Math.PI * 2);
|
||||
ctx.fill();
|
||||
ctx.beginPath();
|
||||
ctx.arc(x + s * 2 / 3, y + s / 2, r, 0, Math.PI * 2);
|
||||
ctx.fill();
|
||||
ctx.beginPath();
|
||||
ctx.arc(x + s / 2, y + s * 2 / 3, r, 0, Math.PI * 2);
|
||||
ctx.fill();
|
||||
|
||||
ctx.globalAlpha = 1;
|
||||
}
|
||||
|
||||
/**
|
||||
* Draw a base wall tile with visual HP indicator.
|
||||
* @private
|
||||
*/
|
||||
_drawBaseWall(ctx, x, y, row, col) {
|
||||
const s = TILE_SIZE;
|
||||
const key = `${row},${col}`;
|
||||
const hp = this._baseWallHP[key] || 0;
|
||||
const maxHP = this.BASE_WALL_MAX_HP;
|
||||
const ratio = hp / maxHP;
|
||||
|
||||
// Base color darkens as HP decreases
|
||||
if (ratio > 0.66) {
|
||||
ctx.fillStyle = '#C47832'; // full HP - bright brick
|
||||
} else if (ratio > 0.33) {
|
||||
ctx.fillStyle = '#A05A20'; // medium HP - darker
|
||||
} else {
|
||||
ctx.fillStyle = '#7A3E10'; // low HP - very dark
|
||||
}
|
||||
ctx.fillRect(x, y, s, s);
|
||||
|
||||
// Brick pattern (mortar lines)
|
||||
ctx.strokeStyle = '#5A2D0C';
|
||||
ctx.lineWidth = 1;
|
||||
ctx.beginPath();
|
||||
ctx.moveTo(x, y + s / 2);
|
||||
ctx.lineTo(x + s, y + s / 2);
|
||||
ctx.stroke();
|
||||
ctx.beginPath();
|
||||
ctx.moveTo(x + s / 2, y);
|
||||
ctx.lineTo(x + s / 2, y + s / 2);
|
||||
ctx.stroke();
|
||||
ctx.beginPath();
|
||||
ctx.moveTo(x + s / 4, y + s / 2);
|
||||
ctx.lineTo(x + s / 4, y + s);
|
||||
ctx.stroke();
|
||||
ctx.beginPath();
|
||||
ctx.moveTo(x + s * 3 / 4, y + s / 2);
|
||||
ctx.lineTo(x + s * 3 / 4, y + s);
|
||||
ctx.stroke();
|
||||
|
||||
// Reinforcement border to distinguish from normal brick
|
||||
ctx.strokeStyle = '#FFD700';
|
||||
ctx.lineWidth = 1.5;
|
||||
ctx.strokeRect(x + 1, y + 1, s - 2, s - 2);
|
||||
|
||||
// HP indicator dots at top
|
||||
const dotR = 2;
|
||||
const dotSpacing = 8;
|
||||
const startX = x + s / 2 - ((maxHP - 1) * dotSpacing) / 2;
|
||||
for (let i = 0; i < maxHP; i++) {
|
||||
ctx.fillStyle = i < hp ? '#FFD700' : '#333333';
|
||||
ctx.beginPath();
|
||||
ctx.arc(startX + i * dotSpacing, y + 5, dotR, 0, Math.PI * 2);
|
||||
ctx.fill();
|
||||
}
|
||||
}
|
||||
|
||||
_drawBase(ctx, x, y) {
|
||||
const s = TILE_SIZE;
|
||||
if (this._baseDestroyed) {
|
||||
// Destroyed base
|
||||
ctx.fillStyle = '#333333';
|
||||
ctx.fillRect(x, y, s, s);
|
||||
ctx.strokeStyle = '#FF0000';
|
||||
ctx.lineWidth = 2;
|
||||
ctx.beginPath();
|
||||
ctx.moveTo(x + 2, y + 2);
|
||||
ctx.lineTo(x + s - 2, y + s - 2);
|
||||
ctx.moveTo(x + s - 2, y + 2);
|
||||
ctx.lineTo(x + 2, y + s - 2);
|
||||
ctx.stroke();
|
||||
} else {
|
||||
// Eagle / base icon
|
||||
ctx.fillStyle = COLORS.BASE;
|
||||
ctx.fillRect(x, y, s, s);
|
||||
|
||||
// Simple eagle shape
|
||||
ctx.fillStyle = '#000000';
|
||||
ctx.font = `${Math.floor(s * 0.7)}px Arial`;
|
||||
ctx.textAlign = 'center';
|
||||
ctx.textBaseline = 'middle';
|
||||
ctx.fillText('🦅', x + s / 2, y + s / 2);
|
||||
}
|
||||
}
|
||||
|
||||
// ============================================================
|
||||
// Collision & Query Methods
|
||||
// ============================================================
|
||||
|
||||
/**
|
||||
* Get terrain type at a grid position.
|
||||
* @param {number} row
|
||||
* @param {number} col
|
||||
* @returns {number} Terrain type, or -1 if out of bounds.
|
||||
*/
|
||||
getTerrain(row, col) {
|
||||
if (row < 0 || row >= GRID_ROWS || col < 0 || col >= GRID_COLS) return -1;
|
||||
return this._grid[row][col];
|
||||
}
|
||||
|
||||
/**
|
||||
* Set terrain at a grid position.
|
||||
* @param {number} row
|
||||
* @param {number} col
|
||||
* @param {number} terrain
|
||||
*/
|
||||
setTerrain(row, col, terrain) {
|
||||
if (row < 0 || row >= GRID_ROWS || col < 0 || col >= GRID_COLS) return;
|
||||
this._grid[row][col] = terrain;
|
||||
}
|
||||
|
||||
/**
|
||||
* Convert pixel coordinates to grid coordinates.
|
||||
* @param {number} px - Pixel X (screen space).
|
||||
* @param {number} py - Pixel Y (screen space).
|
||||
* @returns {{row: number, col: number}}
|
||||
*/
|
||||
pixelToGrid(px, py) {
|
||||
const col = Math.floor((px - MAP_OFFSET_X) / TILE_SIZE);
|
||||
const row = Math.floor((py - MAP_OFFSET_Y) / TILE_SIZE);
|
||||
return { row, col };
|
||||
}
|
||||
|
||||
/**
|
||||
* Convert grid coordinates to pixel coordinates (top-left of tile).
|
||||
* @param {number} row
|
||||
* @param {number} col
|
||||
* @returns {{x: number, y: number}}
|
||||
*/
|
||||
gridToPixel(row, col) {
|
||||
return {
|
||||
x: MAP_OFFSET_X + col * TILE_SIZE,
|
||||
y: MAP_OFFSET_Y + row * TILE_SIZE,
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if a terrain tile blocks tank movement.
|
||||
* @param {number} row
|
||||
* @param {number} col
|
||||
* @returns {boolean}
|
||||
*/
|
||||
isTankBlocking(row, col) {
|
||||
const t = this.getTerrain(row, col);
|
||||
if (t === -1) return true; // out of bounds
|
||||
return (
|
||||
t === TERRAIN.BRICK ||
|
||||
t === TERRAIN.STEEL ||
|
||||
t === TERRAIN.RIVER ||
|
||||
t === TERRAIN.BASE ||
|
||||
t === TERRAIN.BASE_WALL
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if a terrain tile blocks bullets.
|
||||
* @param {number} row
|
||||
* @param {number} col
|
||||
* @param {boolean} canBreakSteel - Whether the bullet can break steel.
|
||||
* @returns {'block'|'destroy'|'pass'} Result of bullet hitting this tile.
|
||||
*/
|
||||
bulletHitTerrain(row, col, canBreakSteel) {
|
||||
const t = this.getTerrain(row, col);
|
||||
if (t === -1) return 'block'; // out of bounds = wall
|
||||
|
||||
switch (t) {
|
||||
case TERRAIN.BRICK:
|
||||
// Destroy the brick
|
||||
this._grid[row][col] = TERRAIN.EMPTY;
|
||||
return 'destroy';
|
||||
|
||||
case TERRAIN.BASE_WALL: {
|
||||
// Base wall has HP, reduce it
|
||||
const key = `${row},${col}`;
|
||||
const currentHP = (this._baseWallHP[key] || 1) - 1;
|
||||
this._baseWallHP[key] = currentHP;
|
||||
if (currentHP <= 0) {
|
||||
this._grid[row][col] = TERRAIN.EMPTY;
|
||||
return 'destroy';
|
||||
}
|
||||
return 'block'; // damaged but not destroyed
|
||||
}
|
||||
|
||||
case TERRAIN.STEEL:
|
||||
if (canBreakSteel) {
|
||||
this._grid[row][col] = TERRAIN.EMPTY;
|
||||
return 'destroy';
|
||||
}
|
||||
return 'block';
|
||||
|
||||
case TERRAIN.BASE:
|
||||
this._baseDestroyed = true;
|
||||
return 'destroy';
|
||||
|
||||
case TERRAIN.RIVER:
|
||||
return 'pass'; // bullets fly over river
|
||||
|
||||
case TERRAIN.FOREST:
|
||||
return 'pass'; // bullets pass through forest
|
||||
|
||||
default:
|
||||
return 'pass';
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if a rectangular area collides with any blocking terrain.
|
||||
* Used for tank movement collision.
|
||||
* @param {number} x - Left edge (pixel).
|
||||
* @param {number} y - Top edge (pixel).
|
||||
* @param {number} w - Width.
|
||||
* @param {number} h - Height.
|
||||
* @returns {boolean} True if any blocking tile overlaps.
|
||||
*/
|
||||
rectCollidesWithTerrain(x, y, w, h) {
|
||||
// Get grid range that the rect covers
|
||||
const startCol = Math.floor((x - MAP_OFFSET_X) / TILE_SIZE);
|
||||
const endCol = Math.floor((x + w - 1 - MAP_OFFSET_X) / TILE_SIZE);
|
||||
const startRow = Math.floor((y - MAP_OFFSET_Y) / TILE_SIZE);
|
||||
const endRow = Math.floor((y + h - 1 - MAP_OFFSET_Y) / TILE_SIZE);
|
||||
|
||||
for (let r = startRow; r <= endRow; r++) {
|
||||
for (let c = startCol; c <= endCol; c++) {
|
||||
if (this.isTankBlocking(r, c)) {
|
||||
return true;
|
||||
}
|
||||
}
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
/**
|
||||
* Activate shovel power-up: convert base walls to steel temporarily.
|
||||
* @param {number} duration - Duration in ms.
|
||||
*/
|
||||
activateShovel(duration) {
|
||||
this._baseSteelTimer = duration;
|
||||
for (const [r, c] of this._baseWallPositions) {
|
||||
if (this._grid[r][c] === TERRAIN.BASE_WALL || this._grid[r][c] === TERRAIN.EMPTY) {
|
||||
this._grid[r][c] = TERRAIN.STEEL;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/** Whether the base has been destroyed. */
|
||||
get baseDestroyed() {
|
||||
return this._baseDestroyed;
|
||||
}
|
||||
|
||||
/** Get the raw grid (read-only reference). */
|
||||
get grid() {
|
||||
return this._grid;
|
||||
}
|
||||
}
|
||||
|
||||
module.exports = MapManager;
|
||||
@@ -0,0 +1,528 @@
|
||||
/**
|
||||
* NetworkManager.js
|
||||
* Manages WebSocket connection for PVP online multiplayer.
|
||||
* Handles connection lifecycle, heartbeat, reconnection, and message routing.
|
||||
*/
|
||||
|
||||
const { NET_MSG } = require('../base/GameGlobal');
|
||||
|
||||
class NetworkManager {
|
||||
constructor() {
|
||||
/** @type {WebSocket|null} */
|
||||
this._ws = null;
|
||||
/** @type {string} Server URL */
|
||||
this._serverUrl = '';
|
||||
/** @type {boolean} */
|
||||
this._connected = false;
|
||||
/** @type {boolean} */
|
||||
this._connecting = false;
|
||||
/** @type {string|null} Current room ID */
|
||||
this._roomId = null;
|
||||
/** @type {number} Player slot (1 or 2) */
|
||||
this._playerSlot = 0;
|
||||
/** @type {string} Unique player ID */
|
||||
this._playerId = '';
|
||||
|
||||
// Heartbeat
|
||||
this._heartbeatInterval = null;
|
||||
this._heartbeatTimeout = null;
|
||||
this._heartbeatMs = 5000;
|
||||
this._heartbeatTimeoutMs = 10000;
|
||||
|
||||
// Reconnection
|
||||
this._reconnectAttempts = 0;
|
||||
this._maxReconnectAttempts = 3;
|
||||
this._reconnectDelay = 2000;
|
||||
this._reconnectTimer = null;
|
||||
this._shouldReconnect = false;
|
||||
|
||||
// Message handlers
|
||||
/** @type {Map<string, Array<Function>>} */
|
||||
this._handlers = new Map();
|
||||
|
||||
// Latency tracking
|
||||
this._lastPingTime = 0;
|
||||
this._latency = 0;
|
||||
|
||||
// Generate a unique player ID
|
||||
this._playerId = this._generatePlayerId();
|
||||
}
|
||||
|
||||
/**
|
||||
* Connect to the WebSocket server.
|
||||
* @param {string} serverUrl - WebSocket server URL (e.g., 'wss://example.com/ws').
|
||||
* @returns {Promise<boolean>} Whether connection succeeded.
|
||||
*/
|
||||
connect(serverUrl) {
|
||||
return new Promise((resolve) => {
|
||||
if (this._connected || this._connecting) {
|
||||
resolve(this._connected);
|
||||
return;
|
||||
}
|
||||
|
||||
this._serverUrl = serverUrl;
|
||||
this._connecting = true;
|
||||
this._shouldReconnect = true;
|
||||
|
||||
try {
|
||||
this._ws = wx.connectSocket({
|
||||
url: serverUrl,
|
||||
header: { 'content-type': 'application/json' },
|
||||
});
|
||||
|
||||
this._ws.onOpen(() => {
|
||||
console.log('[NetworkManager] Connected to server');
|
||||
this._connected = true;
|
||||
this._connecting = false;
|
||||
this._reconnectAttempts = 0;
|
||||
this._startHeartbeat();
|
||||
this._emit('connected');
|
||||
resolve(true);
|
||||
});
|
||||
|
||||
this._ws.onMessage((res) => {
|
||||
this._handleMessage(res.data);
|
||||
});
|
||||
|
||||
this._ws.onError((err) => {
|
||||
console.error('[NetworkManager] WebSocket error:', err);
|
||||
this._connecting = false;
|
||||
this._emit('error', err);
|
||||
resolve(false);
|
||||
});
|
||||
|
||||
this._ws.onClose((res) => {
|
||||
console.log('[NetworkManager] Connection closed:', res.code, res.reason);
|
||||
this._connected = false;
|
||||
this._connecting = false;
|
||||
this._stopHeartbeat();
|
||||
this._emit('disconnected', { code: res.code, reason: res.reason });
|
||||
|
||||
// Auto-reconnect if needed
|
||||
if (this._shouldReconnect && this._reconnectAttempts < this._maxReconnectAttempts) {
|
||||
this._attemptReconnect();
|
||||
}
|
||||
});
|
||||
} catch (e) {
|
||||
console.error('[NetworkManager] Failed to create WebSocket:', e);
|
||||
this._connecting = false;
|
||||
resolve(false);
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Disconnect from the server.
|
||||
*/
|
||||
disconnect() {
|
||||
this._shouldReconnect = false;
|
||||
this._stopHeartbeat();
|
||||
this._clearReconnectTimer();
|
||||
|
||||
if (this._ws) {
|
||||
try {
|
||||
this._ws.close({});
|
||||
} catch (e) {
|
||||
// Ignore close errors
|
||||
}
|
||||
this._ws = null;
|
||||
}
|
||||
|
||||
this._connected = false;
|
||||
this._connecting = false;
|
||||
this._roomId = null;
|
||||
this._playerSlot = 0;
|
||||
}
|
||||
|
||||
/**
|
||||
* Send a message to the server.
|
||||
* @param {string} type - Message type from NET_MSG.
|
||||
* @param {object} [data={}] - Message payload.
|
||||
*/
|
||||
send(type, data = {}) {
|
||||
if (!this._connected || !this._ws) {
|
||||
console.warn('[NetworkManager] Cannot send, not connected');
|
||||
return;
|
||||
}
|
||||
|
||||
const message = JSON.stringify({
|
||||
type,
|
||||
data,
|
||||
playerId: this._playerId,
|
||||
roomId: this._roomId,
|
||||
timestamp: Date.now(),
|
||||
});
|
||||
|
||||
try {
|
||||
this._ws.send({ data: message });
|
||||
} catch (e) {
|
||||
console.error('[NetworkManager] Send error:', e);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Create a new room on the server.
|
||||
*/
|
||||
createRoom() {
|
||||
this.send(NET_MSG.CREATE_ROOM, {
|
||||
playerId: this._playerId,
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Join an existing room.
|
||||
* @param {string} roomId - Room ID to join.
|
||||
*/
|
||||
joinRoom(roomId) {
|
||||
this.send(NET_MSG.JOIN_ROOM, {
|
||||
playerId: this._playerId,
|
||||
roomId: roomId,
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Send player input to the server.
|
||||
* @param {object} input - { direction, firing, x, y }
|
||||
*/
|
||||
sendInput(input) {
|
||||
this.send(NET_MSG.PLAYER_INPUT, input);
|
||||
}
|
||||
|
||||
/**
|
||||
* Send player state for synchronization.
|
||||
* @param {object} state - { x, y, direction, hp, alive }
|
||||
*/
|
||||
sendState(state) {
|
||||
this.send(NET_MSG.PLAYER_STATE, state);
|
||||
}
|
||||
|
||||
/**
|
||||
* Send bullet fire event.
|
||||
* @param {object} bulletData - { x, y, direction }
|
||||
*/
|
||||
sendBulletFire(bulletData) {
|
||||
this.send(NET_MSG.BULLET_FIRE, bulletData);
|
||||
}
|
||||
|
||||
// ============================================================
|
||||
// 3v3 Team Methods
|
||||
// ============================================================
|
||||
|
||||
/**
|
||||
* Create a new team for 3v3 mode.
|
||||
*/
|
||||
createTeam() {
|
||||
this.send(NET_MSG.CREATE_TEAM, {
|
||||
playerId: this._playerId,
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Join an existing team by teamId.
|
||||
* @param {string} teamId - Team ID to join.
|
||||
*/
|
||||
joinTeam(teamId) {
|
||||
this.send(NET_MSG.JOIN_TEAM, {
|
||||
playerId: this._playerId,
|
||||
teamId,
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Leave the current team.
|
||||
*/
|
||||
leaveTeam() {
|
||||
this.send(NET_MSG.LEAVE_TEAM, {
|
||||
playerId: this._playerId,
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Toggle ready state in team room.
|
||||
* @param {boolean} ready - Whether the player is ready.
|
||||
*/
|
||||
teamReady(ready) {
|
||||
this.send(NET_MSG.TEAM_READY, {
|
||||
playerId: this._playerId,
|
||||
ready,
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Start matchmaking (leader only).
|
||||
*/
|
||||
startMatch() {
|
||||
this.send(NET_MSG.MATCH_START, {
|
||||
playerId: this._playerId,
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Cancel matchmaking (leader only).
|
||||
*/
|
||||
cancelMatch() {
|
||||
this.send(NET_MSG.MATCH_CANCEL, {
|
||||
playerId: this._playerId,
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Kick a player from the team (leader only).
|
||||
* @param {string} targetPlayerId - Player ID to kick.
|
||||
*/
|
||||
kickPlayer(targetPlayerId) {
|
||||
this.send(NET_MSG.TEAM_KICK, {
|
||||
playerId: this._playerId,
|
||||
targetPlayerId,
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Disband the team (leader only).
|
||||
*/
|
||||
disbandTeam() {
|
||||
this.send(NET_MSG.TEAM_DISBAND, {
|
||||
playerId: this._playerId,
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Start solo matchmaking for 3v3.
|
||||
*/
|
||||
soloMatch() {
|
||||
this.send(NET_MSG.SOLO_MATCH, {
|
||||
playerId: this._playerId,
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Attempt to reconnect to an ongoing team game.
|
||||
* @param {string} teamId - Team room ID.
|
||||
*/
|
||||
reconnectToTeam(teamId) {
|
||||
this.send(NET_MSG.RECONNECT, {
|
||||
teamId,
|
||||
playerId: this._playerId,
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Register a handler for a message type.
|
||||
* @param {string} type - Message type.
|
||||
* @param {Function} handler - Callback function(data).
|
||||
* @returns {Function} Unsubscribe function.
|
||||
*/
|
||||
on(type, handler) {
|
||||
if (!this._handlers.has(type)) {
|
||||
this._handlers.set(type, []);
|
||||
}
|
||||
this._handlers.get(type).push(handler);
|
||||
|
||||
return () => {
|
||||
const list = this._handlers.get(type);
|
||||
if (list) {
|
||||
const idx = list.indexOf(handler);
|
||||
if (idx !== -1) list.splice(idx, 1);
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Remove a handler for a message type.
|
||||
* @param {string} type
|
||||
* @param {Function} handler
|
||||
*/
|
||||
off(type, handler) {
|
||||
const list = this._handlers.get(type);
|
||||
if (list) {
|
||||
const idx = list.indexOf(handler);
|
||||
if (idx !== -1) list.splice(idx, 1);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Remove all handlers.
|
||||
*/
|
||||
clearHandlers() {
|
||||
this._handlers.clear();
|
||||
}
|
||||
|
||||
// ============================================================
|
||||
// Private Methods
|
||||
// ============================================================
|
||||
|
||||
/**
|
||||
* Handle incoming WebSocket message.
|
||||
* @private
|
||||
*/
|
||||
_handleMessage(rawData) {
|
||||
try {
|
||||
const msg = JSON.parse(rawData);
|
||||
const { type, data } = msg;
|
||||
|
||||
// Handle system messages
|
||||
if (type === NET_MSG.PONG) {
|
||||
this._latency = Date.now() - this._lastPingTime;
|
||||
this._resetHeartbeatTimeout();
|
||||
return;
|
||||
}
|
||||
|
||||
if (type === NET_MSG.ROOM_CREATED) {
|
||||
this._roomId = data.roomId;
|
||||
this._playerSlot = 1; // Creator is player 1
|
||||
} else if (type === NET_MSG.ROOM_JOINED) {
|
||||
this._roomId = data.roomId;
|
||||
this._playerSlot = data.playerSlot || 2;
|
||||
}
|
||||
|
||||
// Emit to registered handlers
|
||||
this._emit(type, data);
|
||||
} catch (e) {
|
||||
console.error('[NetworkManager] Failed to parse message:', e, rawData);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Emit an event to registered handlers.
|
||||
* @private
|
||||
*/
|
||||
_emit(type, data) {
|
||||
const list = this._handlers.get(type);
|
||||
if (list) {
|
||||
for (const handler of list) {
|
||||
try {
|
||||
handler(data);
|
||||
} catch (e) {
|
||||
console.error(`[NetworkManager] Handler error for "${type}":`, e);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Start heartbeat ping/pong.
|
||||
* @private
|
||||
*/
|
||||
_startHeartbeat() {
|
||||
this._stopHeartbeat();
|
||||
this._heartbeatInterval = setInterval(() => {
|
||||
if (this._connected) {
|
||||
this._lastPingTime = Date.now();
|
||||
this.send(NET_MSG.PING);
|
||||
this._startHeartbeatTimeout();
|
||||
}
|
||||
}, this._heartbeatMs);
|
||||
}
|
||||
|
||||
/**
|
||||
* Stop heartbeat.
|
||||
* @private
|
||||
*/
|
||||
_stopHeartbeat() {
|
||||
if (this._heartbeatInterval) {
|
||||
clearInterval(this._heartbeatInterval);
|
||||
this._heartbeatInterval = null;
|
||||
}
|
||||
this._resetHeartbeatTimeout();
|
||||
}
|
||||
|
||||
/**
|
||||
* Start heartbeat timeout (disconnect if no pong received).
|
||||
* @private
|
||||
*/
|
||||
_startHeartbeatTimeout() {
|
||||
this._resetHeartbeatTimeout();
|
||||
this._heartbeatTimeout = setTimeout(() => {
|
||||
console.warn('[NetworkManager] Heartbeat timeout, disconnecting');
|
||||
this.disconnect();
|
||||
}, this._heartbeatTimeoutMs);
|
||||
}
|
||||
|
||||
/**
|
||||
* Reset heartbeat timeout.
|
||||
* @private
|
||||
*/
|
||||
_resetHeartbeatTimeout() {
|
||||
if (this._heartbeatTimeout) {
|
||||
clearTimeout(this._heartbeatTimeout);
|
||||
this._heartbeatTimeout = null;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Attempt to reconnect to the server.
|
||||
* @private
|
||||
*/
|
||||
_attemptReconnect() {
|
||||
this._clearReconnectTimer();
|
||||
this._reconnectAttempts++;
|
||||
console.log(`[NetworkManager] Reconnect attempt ${this._reconnectAttempts}/${this._maxReconnectAttempts}`);
|
||||
|
||||
this._emit('reconnecting', { attempt: this._reconnectAttempts });
|
||||
|
||||
this._reconnectTimer = setTimeout(() => {
|
||||
this.connect(this._serverUrl);
|
||||
}, this._reconnectDelay * this._reconnectAttempts);
|
||||
}
|
||||
|
||||
/**
|
||||
* Clear reconnect timer.
|
||||
* @private
|
||||
*/
|
||||
_clearReconnectTimer() {
|
||||
if (this._reconnectTimer) {
|
||||
clearTimeout(this._reconnectTimer);
|
||||
this._reconnectTimer = null;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Generate a unique player ID.
|
||||
* @private
|
||||
* @returns {string}
|
||||
*/
|
||||
_generatePlayerId() {
|
||||
const chars = 'ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789';
|
||||
let id = 'p_';
|
||||
for (let i = 0; i < 8; i++) {
|
||||
id += chars.charAt(Math.floor(Math.random() * chars.length));
|
||||
}
|
||||
return id + '_' + Date.now().toString(36);
|
||||
}
|
||||
|
||||
// ============================================================
|
||||
// Getters
|
||||
// ============================================================
|
||||
|
||||
/** Whether currently connected. */
|
||||
get connected() {
|
||||
return this._connected;
|
||||
}
|
||||
|
||||
/** Current room ID. */
|
||||
get roomId() {
|
||||
return this._roomId;
|
||||
}
|
||||
|
||||
/** Player slot (1 or 2). */
|
||||
get playerSlot() {
|
||||
return this._playerSlot;
|
||||
}
|
||||
|
||||
/** Player unique ID. */
|
||||
get playerId() {
|
||||
return this._playerId;
|
||||
}
|
||||
|
||||
/** Current latency in ms. */
|
||||
get latency() {
|
||||
return this._latency;
|
||||
}
|
||||
|
||||
/** Whether currently connecting. */
|
||||
get connecting() {
|
||||
return this._connecting;
|
||||
}
|
||||
}
|
||||
|
||||
module.exports = NetworkManager;
|
||||
@@ -0,0 +1,352 @@
|
||||
/**
|
||||
* PaymentManager.js
|
||||
* Simplified payment manager for monetization-lite.
|
||||
* Only 3 products: Ad-Free (¥18), Gold Pack (¥6=1000g), Newcomer Pack (¥1=500g).
|
||||
* Handles WeChat payment, order recovery, and newcomer pack 24h timer.
|
||||
*/
|
||||
|
||||
/** Product definitions. */
|
||||
const PRODUCTS = {
|
||||
AD_FREE: {
|
||||
id: 'ad_free',
|
||||
price: 18, // ¥18
|
||||
name: 'Remove Ads (Permanent)',
|
||||
type: 'permanent',
|
||||
},
|
||||
GOLD_PACK: {
|
||||
id: 'gold_pack',
|
||||
price: 6, // ¥6
|
||||
goldAmount: 1000,
|
||||
name: 'Gold Pack',
|
||||
type: 'consumable',
|
||||
},
|
||||
NEWCOMER_PACK: {
|
||||
id: 'newcomer_pack',
|
||||
price: 1, // ¥1
|
||||
goldAmount: 500,
|
||||
name: 'Newcomer Pack',
|
||||
type: 'one_time',
|
||||
},
|
||||
};
|
||||
|
||||
/** Newcomer pack availability window in milliseconds (24 hours). */
|
||||
const NEWCOMER_WINDOW_MS = 24 * 60 * 60 * 1000;
|
||||
|
||||
class PaymentManager {
|
||||
constructor() {
|
||||
this._adFreePurchased = false;
|
||||
this._newcomerPackPurchased = false;
|
||||
this._newcomerPackStartTime = 0; // timestamp when user first entered game
|
||||
this._pendingOrders = [];
|
||||
|
||||
this._load();
|
||||
}
|
||||
|
||||
// ============================================================
|
||||
// Persistence
|
||||
// ============================================================
|
||||
|
||||
/**
|
||||
* Load payment state from StorageManager.
|
||||
* @private
|
||||
*/
|
||||
_load() {
|
||||
try {
|
||||
if (GameGlobal && GameGlobal.storageManager) {
|
||||
const data = GameGlobal.storageManager.get('payment', null);
|
||||
if (data) {
|
||||
this._adFreePurchased = data.adFreePurchased || false;
|
||||
this._newcomerPackPurchased = data.newcomerPackPurchased || false;
|
||||
this._newcomerPackStartTime = data.newcomerPackStartTime || 0;
|
||||
this._pendingOrders = data.pendingOrders || [];
|
||||
}
|
||||
|
||||
// Initialize newcomer pack timer on first load
|
||||
if (this._newcomerPackStartTime === 0) {
|
||||
this._newcomerPackStartTime = Date.now();
|
||||
this._save();
|
||||
}
|
||||
}
|
||||
} catch (e) {
|
||||
console.warn('[PaymentManager] Failed to load payment data:', e);
|
||||
}
|
||||
|
||||
// Try to recover any pending orders
|
||||
this._recoverPendingOrders();
|
||||
}
|
||||
|
||||
/**
|
||||
* Save payment state to StorageManager.
|
||||
* @private
|
||||
*/
|
||||
_save() {
|
||||
try {
|
||||
if (GameGlobal && GameGlobal.storageManager) {
|
||||
GameGlobal.storageManager.set('payment', {
|
||||
adFreePurchased: this._adFreePurchased,
|
||||
newcomerPackPurchased: this._newcomerPackPurchased,
|
||||
newcomerPackStartTime: this._newcomerPackStartTime,
|
||||
pendingOrders: this._pendingOrders,
|
||||
});
|
||||
}
|
||||
} catch (e) {
|
||||
console.warn('[PaymentManager] Failed to save payment data:', e);
|
||||
}
|
||||
}
|
||||
|
||||
// ============================================================
|
||||
// Purchase API
|
||||
// ============================================================
|
||||
|
||||
/**
|
||||
* Purchase the ad-free privilege (¥18, permanent).
|
||||
* @param {Function} callback - Called with { success: boolean, error?: string }.
|
||||
*/
|
||||
purchaseAdFree(callback) {
|
||||
if (this._adFreePurchased) {
|
||||
if (callback) callback({ success: false, error: 'Already purchased' });
|
||||
return;
|
||||
}
|
||||
|
||||
this._requestPayment(PRODUCTS.AD_FREE, (result) => {
|
||||
if (result.success) {
|
||||
this._adFreePurchased = true;
|
||||
this._save();
|
||||
|
||||
// Enable ad-free in AdManager
|
||||
if (GameGlobal.adManager) {
|
||||
GameGlobal.adManager.enableAdFree();
|
||||
}
|
||||
|
||||
// Emit event
|
||||
this._emitPurchaseEvent('ad_free');
|
||||
}
|
||||
if (callback) callback(result);
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Purchase a gold pack (¥6 = 1000 gold).
|
||||
* @param {Function} callback - Called with { success: boolean, error?: string }.
|
||||
*/
|
||||
purchaseGoldPack(callback) {
|
||||
this._requestPayment(PRODUCTS.GOLD_PACK, (result) => {
|
||||
if (result.success) {
|
||||
// Award gold
|
||||
if (GameGlobal.currencyManager) {
|
||||
GameGlobal.currencyManager.addGold(PRODUCTS.GOLD_PACK.goldAmount);
|
||||
}
|
||||
this._emitPurchaseEvent('gold_pack');
|
||||
}
|
||||
if (callback) callback(result);
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Purchase the newcomer pack (¥1 = 500 gold, one-time only).
|
||||
* @param {Function} callback - Called with { success: boolean, error?: string }.
|
||||
*/
|
||||
purchaseNewcomerPack(callback) {
|
||||
if (this._newcomerPackPurchased) {
|
||||
if (callback) callback({ success: false, error: 'Already purchased' });
|
||||
return;
|
||||
}
|
||||
|
||||
if (!this.isNewcomerPackAvailable()) {
|
||||
if (callback) callback({ success: false, error: 'Newcomer pack expired' });
|
||||
return;
|
||||
}
|
||||
|
||||
this._requestPayment(PRODUCTS.NEWCOMER_PACK, (result) => {
|
||||
if (result.success) {
|
||||
this._newcomerPackPurchased = true;
|
||||
this._save();
|
||||
|
||||
// Award gold
|
||||
if (GameGlobal.currencyManager) {
|
||||
GameGlobal.currencyManager.addGold(PRODUCTS.NEWCOMER_PACK.goldAmount);
|
||||
}
|
||||
this._emitPurchaseEvent('newcomer_pack');
|
||||
}
|
||||
if (callback) callback(result);
|
||||
});
|
||||
}
|
||||
|
||||
// ============================================================
|
||||
// Status Queries
|
||||
// ============================================================
|
||||
|
||||
/**
|
||||
* Whether ad-free has been purchased.
|
||||
* @returns {boolean}
|
||||
*/
|
||||
isAdFreePurchased() {
|
||||
return this._adFreePurchased;
|
||||
}
|
||||
|
||||
/**
|
||||
* Whether the newcomer pack is still available (within 24h and not purchased).
|
||||
* @returns {boolean}
|
||||
*/
|
||||
isNewcomerPackAvailable() {
|
||||
if (this._newcomerPackPurchased) return false;
|
||||
const elapsed = Date.now() - this._newcomerPackStartTime;
|
||||
return elapsed < NEWCOMER_WINDOW_MS;
|
||||
}
|
||||
|
||||
/**
|
||||
* Get remaining time for newcomer pack in milliseconds.
|
||||
* @returns {number} Remaining ms, or 0 if expired.
|
||||
*/
|
||||
getNewcomerPackRemainingMs() {
|
||||
if (this._newcomerPackPurchased) return 0;
|
||||
const remaining = NEWCOMER_WINDOW_MS - (Date.now() - this._newcomerPackStartTime);
|
||||
return Math.max(0, remaining);
|
||||
}
|
||||
|
||||
/**
|
||||
* Get product definitions.
|
||||
* @returns {object}
|
||||
*/
|
||||
getProducts() {
|
||||
return PRODUCTS;
|
||||
}
|
||||
|
||||
// ============================================================
|
||||
// WeChat Payment
|
||||
// ============================================================
|
||||
|
||||
/**
|
||||
* Request payment via WeChat Midas payment.
|
||||
* @param {object} product - Product definition.
|
||||
* @param {Function} callback - Called with { success: boolean, error?: string }.
|
||||
* @private
|
||||
*/
|
||||
_requestPayment(product, callback) {
|
||||
// Add to pending orders for recovery
|
||||
const orderId = `${product.id}_${Date.now()}`;
|
||||
this._pendingOrders.push({ orderId, productId: product.id, timestamp: Date.now() });
|
||||
this._save();
|
||||
|
||||
try {
|
||||
if (typeof wx === 'undefined' || typeof wx.requestMidasPayment !== 'function') {
|
||||
// Dev environment: simulate success
|
||||
console.log(`[PaymentManager] Dev mode: simulating purchase of ${product.id}`);
|
||||
this._removePendingOrder(orderId);
|
||||
if (callback) callback({ success: true });
|
||||
return;
|
||||
}
|
||||
|
||||
wx.requestMidasPayment({
|
||||
mode: 'game',
|
||||
env: 0, // 0 = production, 1 = sandbox
|
||||
offerId: '', // Replace with actual offer ID
|
||||
currencyType: 'CNY',
|
||||
buyQuantity: product.price * 10, // Midas uses 1/10 yuan units
|
||||
success: () => {
|
||||
console.log(`[PaymentManager] Purchase successful: ${product.id}`);
|
||||
this._removePendingOrder(orderId);
|
||||
if (callback) callback({ success: true });
|
||||
},
|
||||
fail: (err) => {
|
||||
console.warn(`[PaymentManager] Purchase failed: ${product.id}`, err);
|
||||
this._removePendingOrder(orderId);
|
||||
if (callback) callback({ success: false, error: err.errMsg || 'Payment failed' });
|
||||
},
|
||||
});
|
||||
} catch (e) {
|
||||
console.warn('[PaymentManager] Payment request error:', e);
|
||||
this._removePendingOrder(orderId);
|
||||
if (callback) callback({ success: false, error: e.message });
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Remove a pending order after completion.
|
||||
* @param {string} orderId
|
||||
* @private
|
||||
*/
|
||||
_removePendingOrder(orderId) {
|
||||
this._pendingOrders = this._pendingOrders.filter(o => o.orderId !== orderId);
|
||||
this._save();
|
||||
}
|
||||
|
||||
/**
|
||||
* Attempt to recover pending orders (e.g., after network interruption).
|
||||
* @private
|
||||
*/
|
||||
_recoverPendingOrders() {
|
||||
if (this._pendingOrders.length === 0) return;
|
||||
|
||||
// Remove orders older than 1 hour (stale)
|
||||
const oneHourAgo = Date.now() - 60 * 60 * 1000;
|
||||
this._pendingOrders = this._pendingOrders.filter(o => o.timestamp > oneHourAgo);
|
||||
this._save();
|
||||
|
||||
// In production, query server for order status and deliver items
|
||||
// For now, just log
|
||||
if (this._pendingOrders.length > 0) {
|
||||
console.log(`[PaymentManager] ${this._pendingOrders.length} pending orders to recover`);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Emit a purchase completed event.
|
||||
* @param {string} productId
|
||||
* @private
|
||||
*/
|
||||
_emitPurchaseEvent(productId) {
|
||||
try {
|
||||
if (GameGlobal && GameGlobal.eventBus) {
|
||||
GameGlobal.eventBus.emit('purchase:completed', { productId });
|
||||
}
|
||||
} catch (e) {}
|
||||
}
|
||||
|
||||
// ============================================================
|
||||
// Cloud Sync
|
||||
// ============================================================
|
||||
|
||||
/**
|
||||
* Get payment data for cloud sync.
|
||||
* @returns {object}
|
||||
*/
|
||||
getCloudSyncData() {
|
||||
return {
|
||||
adFreePurchased: this._adFreePurchased,
|
||||
newcomerPackPurchased: this._newcomerPackPurchased,
|
||||
newcomerPackStartTime: this._newcomerPackStartTime,
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Restore payment data from cloud.
|
||||
* @param {object} cloudData
|
||||
*/
|
||||
restoreFromCloud(cloudData) {
|
||||
if (!cloudData) return;
|
||||
let changed = false;
|
||||
|
||||
// Ad-free is permanent — if cloud says purchased, trust it
|
||||
if (cloudData.adFreePurchased && !this._adFreePurchased) {
|
||||
this._adFreePurchased = true;
|
||||
if (GameGlobal.adManager) {
|
||||
GameGlobal.adManager.enableAdFree();
|
||||
}
|
||||
changed = true;
|
||||
}
|
||||
|
||||
if (cloudData.newcomerPackPurchased && !this._newcomerPackPurchased) {
|
||||
this._newcomerPackPurchased = true;
|
||||
changed = true;
|
||||
}
|
||||
|
||||
if (changed) {
|
||||
this._save();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
PaymentManager.PRODUCTS = PRODUCTS;
|
||||
|
||||
module.exports = PaymentManager;
|
||||
@@ -0,0 +1,6 @@
|
||||
// PromotionManager - DEPRECATED (removed in monetization-lite)
|
||||
// This file is intentionally empty. The promotion system has been removed.
|
||||
class PromotionManager {
|
||||
constructor() {}
|
||||
}
|
||||
module.exports = PromotionManager;
|
||||
@@ -0,0 +1,94 @@
|
||||
/**
|
||||
* ResourceManager.js
|
||||
* Handles preloading of image resources using wx.createImage.
|
||||
* Provides progress callback and cached access to loaded images.
|
||||
*/
|
||||
|
||||
class ResourceManager {
|
||||
constructor() {
|
||||
/** @type {Map<string, Image>} */
|
||||
this._cache = new Map();
|
||||
this._totalAssets = 0;
|
||||
this._loadedAssets = 0;
|
||||
}
|
||||
|
||||
/**
|
||||
* Preload a list of image assets.
|
||||
* @param {Array<{key: string, src: string}>} assetList - Assets to load.
|
||||
* @param {Function} [onProgress] - Called with (loaded, total) on each load.
|
||||
* @returns {Promise<void>} Resolves when all assets are loaded.
|
||||
*/
|
||||
loadImages(assetList, onProgress) {
|
||||
this._totalAssets = assetList.length;
|
||||
this._loadedAssets = 0;
|
||||
|
||||
if (this._totalAssets === 0) {
|
||||
return Promise.resolve();
|
||||
}
|
||||
|
||||
const promises = assetList.map(({ key, src }) => {
|
||||
return new Promise((resolve, reject) => {
|
||||
// If already cached, skip
|
||||
if (this._cache.has(key)) {
|
||||
this._loadedAssets++;
|
||||
if (onProgress) onProgress(this._loadedAssets, this._totalAssets);
|
||||
resolve();
|
||||
return;
|
||||
}
|
||||
|
||||
const img = wx.createImage();
|
||||
img.onload = () => {
|
||||
this._cache.set(key, img);
|
||||
this._loadedAssets++;
|
||||
if (onProgress) onProgress(this._loadedAssets, this._totalAssets);
|
||||
resolve();
|
||||
};
|
||||
img.onerror = (err) => {
|
||||
console.warn(`[ResourceManager] Failed to load: ${src}`, err);
|
||||
// Resolve anyway so other assets continue loading
|
||||
this._loadedAssets++;
|
||||
if (onProgress) onProgress(this._loadedAssets, this._totalAssets);
|
||||
resolve();
|
||||
};
|
||||
img.src = src;
|
||||
});
|
||||
});
|
||||
|
||||
return Promise.all(promises).then(() => {});
|
||||
}
|
||||
|
||||
/**
|
||||
* Get a loaded image by key.
|
||||
* @param {string} key
|
||||
* @returns {Image|null}
|
||||
*/
|
||||
getImage(key) {
|
||||
return this._cache.get(key) || null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if an image is loaded.
|
||||
* @param {string} key
|
||||
* @returns {boolean}
|
||||
*/
|
||||
hasImage(key) {
|
||||
return this._cache.has(key);
|
||||
}
|
||||
|
||||
/**
|
||||
* Clear all cached images.
|
||||
*/
|
||||
clear() {
|
||||
this._cache.clear();
|
||||
this._totalAssets = 0;
|
||||
this._loadedAssets = 0;
|
||||
}
|
||||
|
||||
/** Current loading progress (0 to 1). */
|
||||
get progress() {
|
||||
if (this._totalAssets === 0) return 1;
|
||||
return this._loadedAssets / this._totalAssets;
|
||||
}
|
||||
}
|
||||
|
||||
module.exports = ResourceManager;
|
||||
@@ -0,0 +1,99 @@
|
||||
/**
|
||||
* SceneManager.js
|
||||
* Manages scene registration, switching, and lifecycle (enter/exit/update/render).
|
||||
*/
|
||||
|
||||
class SceneManager {
|
||||
constructor() {
|
||||
/** @type {Map<string, object>} Registered scene instances */
|
||||
this._scenes = new Map();
|
||||
/** @type {object|null} Current active scene */
|
||||
this._currentScene = null;
|
||||
/** @type {string|null} Current scene name */
|
||||
this._currentName = null;
|
||||
/** @type {boolean} Whether a transition is in progress */
|
||||
this._transitioning = false;
|
||||
}
|
||||
|
||||
/**
|
||||
* Register a scene.
|
||||
* A scene object should implement: enter(params), exit(), update(dt), render(ctx).
|
||||
* @param {string} name - Unique scene name.
|
||||
* @param {object} scene - Scene instance.
|
||||
*/
|
||||
register(name, scene) {
|
||||
this._scenes.set(name, scene);
|
||||
}
|
||||
|
||||
/**
|
||||
* Switch to a different scene.
|
||||
* @param {string} name - Target scene name.
|
||||
* @param {object} [params] - Optional parameters passed to the new scene's enter().
|
||||
*/
|
||||
switchTo(name, params) {
|
||||
if (this._transitioning) return;
|
||||
if (!this._scenes.has(name)) {
|
||||
console.error(`[SceneManager] Scene "${name}" not registered.`);
|
||||
return;
|
||||
}
|
||||
|
||||
this._transitioning = true;
|
||||
|
||||
// Exit current scene
|
||||
if (this._currentScene && typeof this._currentScene.exit === 'function') {
|
||||
this._currentScene.exit();
|
||||
}
|
||||
|
||||
// Enter new scene
|
||||
this._currentName = name;
|
||||
this._currentScene = this._scenes.get(name);
|
||||
if (typeof this._currentScene.enter === 'function') {
|
||||
this._currentScene.enter(params || {});
|
||||
}
|
||||
|
||||
this._transitioning = false;
|
||||
}
|
||||
|
||||
/**
|
||||
* Update the current scene.
|
||||
* @param {number} dt - Delta time in seconds.
|
||||
*/
|
||||
update(dt) {
|
||||
if (this._currentScene && typeof this._currentScene.update === 'function') {
|
||||
this._currentScene.update(dt);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Render the current scene.
|
||||
* @param {CanvasRenderingContext2D} ctx
|
||||
*/
|
||||
render(ctx) {
|
||||
if (this._currentScene && typeof this._currentScene.render === 'function') {
|
||||
this._currentScene.render(ctx);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Forward touch events to the current scene.
|
||||
* @param {string} eventType - 'touchstart' | 'touchmove' | 'touchend'
|
||||
* @param {TouchEvent} e
|
||||
*/
|
||||
handleTouch(eventType, e) {
|
||||
if (this._currentScene && typeof this._currentScene.handleTouch === 'function') {
|
||||
this._currentScene.handleTouch(eventType, e);
|
||||
}
|
||||
}
|
||||
|
||||
/** Get the current scene name. */
|
||||
get currentName() {
|
||||
return this._currentName;
|
||||
}
|
||||
|
||||
/** Get the current scene instance. */
|
||||
get currentScene() {
|
||||
return this._currentScene;
|
||||
}
|
||||
}
|
||||
|
||||
module.exports = SceneManager;
|
||||
@@ -0,0 +1,177 @@
|
||||
/**
|
||||
* ShareManager.js
|
||||
* Minimal share manager - only basic share functionality.
|
||||
* Social fission features have been removed in monetization-lite.
|
||||
*/
|
||||
class ShareManager {
|
||||
constructor() {
|
||||
// Default share content
|
||||
this._shareContent = {
|
||||
title: '坦克大战 - 一起来战斗吧!',
|
||||
imageUrl: '',
|
||||
query: '',
|
||||
};
|
||||
|
||||
// Register share menu and callback ONCE at startup.
|
||||
// The callback reads this._shareContent dynamically so it always
|
||||
// returns the latest share data without needing re-registration.
|
||||
try {
|
||||
if (typeof wx !== 'undefined') {
|
||||
if (wx.showShareMenu) {
|
||||
wx.showShareMenu({
|
||||
withShareTicket: true,
|
||||
menus: ['shareAppMessage'],
|
||||
});
|
||||
}
|
||||
if (wx.onShareAppMessage) {
|
||||
wx.onShareAppMessage(() => {
|
||||
console.log('[ShareManager] onShareAppMessage callback, query:', this._shareContent.query);
|
||||
return {
|
||||
title: this._shareContent.title || '坦克大战 - 一起来战斗吧!',
|
||||
imageUrl: this._shareContent.imageUrl || '',
|
||||
query: this._shareContent.query || '',
|
||||
};
|
||||
});
|
||||
}
|
||||
}
|
||||
} catch (e) {
|
||||
console.warn('[ShareManager] constructor share setup failed:', e);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Update open data for friend ranking.
|
||||
* @param {number} score
|
||||
* @param {number} level
|
||||
*/
|
||||
updateOpenData(score, level) {
|
||||
try {
|
||||
if (typeof wx !== 'undefined' && wx.setUserCloudStorage) {
|
||||
wx.setUserCloudStorage({
|
||||
KVDataList: [
|
||||
{ key: 'score', value: String(score) },
|
||||
{ key: 'level', value: String(level) },
|
||||
],
|
||||
});
|
||||
}
|
||||
} catch (e) {
|
||||
console.warn('[ShareManager] updateOpenData failed:', e);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Re-register the onShareAppMessage callback so the latest
|
||||
* this._shareContent is captured. Called internally whenever
|
||||
* share content changes.
|
||||
*/
|
||||
_refreshShareCallback() {
|
||||
try {
|
||||
if (typeof wx !== 'undefined' && wx.onShareAppMessage) {
|
||||
wx.onShareAppMessage(() => {
|
||||
console.log('[ShareManager] onShareAppMessage callback fired, query:', this._shareContent.query);
|
||||
return {
|
||||
title: this._shareContent.title || '坦克大战 - 一起来战斗吧!',
|
||||
imageUrl: this._shareContent.imageUrl || '',
|
||||
query: this._shareContent.query || '',
|
||||
};
|
||||
});
|
||||
}
|
||||
} catch (e) {
|
||||
console.warn('[ShareManager] _refreshShareCallback failed:', e);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Set share content for the passive share callback (right-corner ··· menu).
|
||||
* Also re-registers the onShareAppMessage callback to guarantee the
|
||||
* latest content is used when the user taps the share button.
|
||||
* @param {object} opts - { title, imageUrl, query }
|
||||
*/
|
||||
setShareContent(opts) {
|
||||
this._shareContent = opts || {};
|
||||
console.log('[ShareManager] Share content updated:', JSON.stringify(this._shareContent));
|
||||
// Re-register callback to ensure WeChat picks up the new content
|
||||
this._refreshShareCallback();
|
||||
}
|
||||
|
||||
/**
|
||||
* Trigger a share action (e.g. team invite).
|
||||
* MUST be called within a user-initiated touch event call stack so that
|
||||
* wx.shareAppMessage() is allowed by WeChat policy.
|
||||
* Also updates the passive share callback as a fallback for the ··· menu.
|
||||
* @param {object} opts - { title, imageUrl, query }
|
||||
*/
|
||||
triggerShare(opts) {
|
||||
const data = opts || {};
|
||||
// Update passive share callback (right-corner ··· menu fallback)
|
||||
this.setShareContent(data);
|
||||
|
||||
// Directly invoke wx.shareAppMessage() to open the friend-picker panel.
|
||||
// This is permitted because triggerShare is called from a touchstart handler.
|
||||
try {
|
||||
if (typeof wx !== 'undefined' && wx.shareAppMessage) {
|
||||
console.log('[ShareManager] Calling wx.shareAppMessage with query:', data.query);
|
||||
wx.shareAppMessage({
|
||||
title: data.title || '',
|
||||
imageUrl: data.imageUrl || '',
|
||||
query: data.query || '',
|
||||
});
|
||||
}
|
||||
} catch (e) {
|
||||
console.warn('[ShareManager] wx.shareAppMessage failed, falling back to toast:', e);
|
||||
// Fallback: prompt user to use the ··· menu
|
||||
try {
|
||||
if (typeof wx !== 'undefined' && wx.showToast) {
|
||||
wx.showToast({
|
||||
title: '请点击右上角「···」转发给好友',
|
||||
icon: 'none',
|
||||
duration: 2500,
|
||||
});
|
||||
}
|
||||
} catch (e2) {
|
||||
console.warn('[ShareManager] triggerShare fallback failed:', e2);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Reset share content to default (clear team invite data).
|
||||
* Called when leaving the team room.
|
||||
*/
|
||||
resetShareContent() {
|
||||
this._shareContent = {
|
||||
title: '坦克大战 - 一起来战斗吧!',
|
||||
imageUrl: '',
|
||||
query: '',
|
||||
};
|
||||
console.log('[ShareManager] Share content reset to default');
|
||||
this._refreshShareCallback();
|
||||
}
|
||||
|
||||
/**
|
||||
* Share a challenge to friends.
|
||||
* Sets the share content and prompts the user to share via the menu.
|
||||
* @param {number} level
|
||||
* @param {number} score
|
||||
*/
|
||||
shareChallenge(level, score) {
|
||||
this.setShareContent({
|
||||
title: `我在坦克大战第${level}关拿了${score}分!你能超过我吗?`,
|
||||
imageUrl: '',
|
||||
query: '',
|
||||
});
|
||||
try {
|
||||
if (typeof wx !== 'undefined' && wx.showToast) {
|
||||
wx.showToast({
|
||||
title: '请点击右上角「···」分享给好友',
|
||||
icon: 'none',
|
||||
duration: 2500,
|
||||
});
|
||||
}
|
||||
} catch (e) {
|
||||
console.warn('[ShareManager] shareChallenge failed:', e);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
module.exports = ShareManager;
|
||||
@@ -0,0 +1,275 @@
|
||||
/**
|
||||
* SkinManager.js
|
||||
* Manages tank skin purchases, equipping, and persistence.
|
||||
* Skins are cosmetic-only color schemes purchased with gold.
|
||||
*/
|
||||
|
||||
/** Skin definitions with id, name, cost, and color scheme. */
|
||||
const SKINS = {
|
||||
default: {
|
||||
id: 'default',
|
||||
nameKey: 'skin.default',
|
||||
cost: 0,
|
||||
colors: null, // uses default tank color
|
||||
preview: '#FFD700',
|
||||
},
|
||||
arctic: {
|
||||
id: 'arctic',
|
||||
nameKey: 'skin.arctic',
|
||||
cost: 500,
|
||||
colors: { body: '#B0E0E6', turret: '#5F9EA0', track: '#2F4F4F' },
|
||||
preview: '#B0E0E6',
|
||||
},
|
||||
inferno: {
|
||||
id: 'inferno',
|
||||
nameKey: 'skin.inferno',
|
||||
cost: 800,
|
||||
colors: { body: '#FF4500', turret: '#8B0000', track: '#2F0000' },
|
||||
preview: '#FF4500',
|
||||
},
|
||||
phantom: {
|
||||
id: 'phantom',
|
||||
nameKey: 'skin.phantom',
|
||||
cost: 1200,
|
||||
colors: { body: '#9370DB', turret: '#4B0082', track: '#1C0033' },
|
||||
preview: '#9370DB',
|
||||
},
|
||||
jungle: {
|
||||
id: 'jungle',
|
||||
nameKey: 'skin.jungle',
|
||||
cost: 1000,
|
||||
colors: { body: '#3CB371', turret: '#006400', track: '#002200' },
|
||||
preview: '#3CB371',
|
||||
},
|
||||
neon: {
|
||||
id: 'neon',
|
||||
nameKey: 'skin.neon',
|
||||
cost: 2000,
|
||||
colors: { body: '#00FF7F', turret: '#00CED1', track: '#008B8B' },
|
||||
preview: '#00FF7F',
|
||||
},
|
||||
shadow: {
|
||||
id: 'shadow',
|
||||
nameKey: 'skin.shadow',
|
||||
cost: 3000,
|
||||
colors: { body: '#2C2C2C', turret: '#1A1A1A', track: '#0D0D0D' },
|
||||
preview: '#2C2C2C',
|
||||
},
|
||||
royal: {
|
||||
id: 'royal',
|
||||
nameKey: 'skin.royal',
|
||||
cost: 5000,
|
||||
colors: { body: '#FFD700', turret: '#DAA520', track: '#B8860B' },
|
||||
preview: '#FFD700',
|
||||
},
|
||||
};
|
||||
|
||||
/** Ordered list of skin IDs for display. */
|
||||
const SKIN_ORDER = ['default', 'arctic', 'inferno', 'jungle', 'phantom', 'neon', 'shadow', 'royal'];
|
||||
|
||||
class SkinManager {
|
||||
constructor() {
|
||||
/** @type {Set<string>} Unlocked skin IDs. */
|
||||
this._unlocked = new Set(['default']);
|
||||
|
||||
/** @type {string} Currently equipped skin ID. */
|
||||
this._equipped = 'default';
|
||||
|
||||
this._load();
|
||||
}
|
||||
|
||||
// ============================================================
|
||||
// Persistence
|
||||
// ============================================================
|
||||
|
||||
/** @private */
|
||||
_load() {
|
||||
try {
|
||||
if (GameGlobal && GameGlobal.storageManager) {
|
||||
const data = GameGlobal.storageManager.get('skins', null);
|
||||
if (data) {
|
||||
this._unlocked = new Set(data.unlocked || ['default']);
|
||||
this._equipped = data.equipped || 'default';
|
||||
// Ensure default is always unlocked
|
||||
this._unlocked.add('default');
|
||||
}
|
||||
}
|
||||
} catch (e) {
|
||||
console.warn('[SkinManager] Failed to load skin data:', e);
|
||||
}
|
||||
}
|
||||
|
||||
/** @private */
|
||||
_save() {
|
||||
try {
|
||||
if (GameGlobal && GameGlobal.storageManager) {
|
||||
GameGlobal.storageManager.set('skins', {
|
||||
unlocked: Array.from(this._unlocked),
|
||||
equipped: this._equipped,
|
||||
});
|
||||
}
|
||||
} catch (e) {
|
||||
console.warn('[SkinManager] Failed to save skin data:', e);
|
||||
}
|
||||
}
|
||||
|
||||
// ============================================================
|
||||
// Queries
|
||||
// ============================================================
|
||||
|
||||
/**
|
||||
* Get all skin definitions in display order.
|
||||
* @returns {Array<object>}
|
||||
*/
|
||||
getAllSkins() {
|
||||
return SKIN_ORDER.map(id => SKINS[id]);
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if a skin is unlocked.
|
||||
* @param {string} skinId
|
||||
* @returns {boolean}
|
||||
*/
|
||||
isUnlocked(skinId) {
|
||||
return this._unlocked.has(skinId);
|
||||
}
|
||||
|
||||
/**
|
||||
* Get the currently equipped skin ID.
|
||||
* @returns {string}
|
||||
*/
|
||||
getEquippedSkinId() {
|
||||
return this._equipped;
|
||||
}
|
||||
|
||||
/**
|
||||
* Get the color scheme for the currently equipped skin.
|
||||
* @returns {object|null} { body, turret, track } or null for default.
|
||||
*/
|
||||
getCurrentSkinColors() {
|
||||
const skin = SKINS[this._equipped];
|
||||
if (!skin) return null;
|
||||
return skin.colors; // null for default skin
|
||||
}
|
||||
|
||||
/**
|
||||
* Get skin definition by ID.
|
||||
* @param {string} skinId
|
||||
* @returns {object|null}
|
||||
*/
|
||||
getSkin(skinId) {
|
||||
return SKINS[skinId] || null;
|
||||
}
|
||||
|
||||
// ============================================================
|
||||
// Actions
|
||||
// ============================================================
|
||||
|
||||
/**
|
||||
* Purchase a skin with gold.
|
||||
* @param {string} skinId
|
||||
* @returns {{ success: boolean, error?: string }}
|
||||
*/
|
||||
purchaseSkin(skinId) {
|
||||
const skin = SKINS[skinId];
|
||||
if (!skin) {
|
||||
return { success: false, error: 'Invalid skin' };
|
||||
}
|
||||
|
||||
if (this._unlocked.has(skinId)) {
|
||||
return { success: false, error: 'Already unlocked' };
|
||||
}
|
||||
|
||||
const cm = GameGlobal.currencyManager;
|
||||
if (!cm || !cm.hasGold(skin.cost)) {
|
||||
return { success: false, error: 'Insufficient gold' };
|
||||
}
|
||||
|
||||
const spent = cm.spendGold(skin.cost);
|
||||
if (!spent) {
|
||||
return { success: false, error: 'Insufficient gold' };
|
||||
}
|
||||
|
||||
this._unlocked.add(skinId);
|
||||
this._save();
|
||||
|
||||
console.log(`[SkinManager] Purchased skin: ${skinId} for ${skin.cost} gold`);
|
||||
|
||||
// Emit event
|
||||
try {
|
||||
if (GameGlobal.eventBus) {
|
||||
GameGlobal.eventBus.emit('skin:purchased', { id: skinId, cost: skin.cost });
|
||||
}
|
||||
} catch (e) {}
|
||||
|
||||
return { success: true };
|
||||
}
|
||||
|
||||
/**
|
||||
* Equip an unlocked skin.
|
||||
* @param {string} skinId
|
||||
* @returns {{ success: boolean, error?: string }}
|
||||
*/
|
||||
equipSkin(skinId) {
|
||||
if (!SKINS[skinId]) {
|
||||
return { success: false, error: 'Invalid skin' };
|
||||
}
|
||||
|
||||
if (!this._unlocked.has(skinId)) {
|
||||
return { success: false, error: 'Not unlocked' };
|
||||
}
|
||||
|
||||
this._equipped = skinId;
|
||||
this._save();
|
||||
|
||||
console.log(`[SkinManager] Equipped skin: ${skinId}`);
|
||||
|
||||
// Emit event
|
||||
try {
|
||||
if (GameGlobal.eventBus) {
|
||||
GameGlobal.eventBus.emit('skin:equipped', { id: skinId });
|
||||
}
|
||||
} catch (e) {}
|
||||
|
||||
return { success: true };
|
||||
}
|
||||
|
||||
// ============================================================
|
||||
// Cloud Sync
|
||||
// ============================================================
|
||||
|
||||
/**
|
||||
* Get skin data for cloud sync.
|
||||
* @returns {object}
|
||||
*/
|
||||
getCloudSyncData() {
|
||||
return {
|
||||
unlocked: Array.from(this._unlocked),
|
||||
equipped: this._equipped,
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Restore skin data from cloud (merge: keep all unlocked).
|
||||
* @param {object} cloudData
|
||||
*/
|
||||
restoreFromCloud(cloudData) {
|
||||
if (!cloudData) return;
|
||||
|
||||
if (cloudData.unlocked) {
|
||||
for (const id of cloudData.unlocked) {
|
||||
if (SKINS[id]) {
|
||||
this._unlocked.add(id);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (cloudData.equipped && SKINS[cloudData.equipped] && this._unlocked.has(cloudData.equipped)) {
|
||||
this._equipped = cloudData.equipped;
|
||||
}
|
||||
|
||||
this._save();
|
||||
}
|
||||
}
|
||||
|
||||
module.exports = SkinManager;
|
||||
@@ -0,0 +1,158 @@
|
||||
/**
|
||||
* SpawnManager.js
|
||||
* Manages enemy tank spawning: timing, spawn points, composition, and limits.
|
||||
*/
|
||||
|
||||
const EnemyTank = require('../entities/EnemyTank');
|
||||
const {
|
||||
TANK_TYPE,
|
||||
MAX_ENEMIES_ON_SCREEN,
|
||||
ENEMY_SPAWN_INTERVAL,
|
||||
TILE_SIZE,
|
||||
MAP_OFFSET_X,
|
||||
MAP_OFFSET_Y,
|
||||
GRID_COLS,
|
||||
} = require('../base/GameGlobal');
|
||||
|
||||
class SpawnManager {
|
||||
constructor() {
|
||||
/** @type {Array<{col: number, row: number}>} */
|
||||
this._spawnPoints = [];
|
||||
this._currentSpawnIndex = 0;
|
||||
|
||||
// Spawn queue
|
||||
this._spawnQueue = []; // array of TANK_TYPE values
|
||||
this._spawnTimer = 0;
|
||||
this._spawnInterval = ENEMY_SPAWN_INTERVAL;
|
||||
this._totalSpawned = 0;
|
||||
this._totalEnemies = 0;
|
||||
|
||||
// Level info
|
||||
this._levelNum = 1;
|
||||
|
||||
// Power-up enemy indices (which enemies drop power-ups)
|
||||
this._powerUpIndices = new Set();
|
||||
}
|
||||
|
||||
/**
|
||||
* Initialize for a new level.
|
||||
* @param {object} levelData - Level configuration from LevelData.
|
||||
*/
|
||||
init(levelData) {
|
||||
this._spawnPoints = levelData.spawnPoints || [
|
||||
{ col: 0, row: 0 },
|
||||
{ col: Math.floor(GRID_COLS / 2), row: 0 },
|
||||
{ col: GRID_COLS - 1, row: 0 },
|
||||
];
|
||||
this._currentSpawnIndex = 0;
|
||||
this._spawnTimer = 0;
|
||||
this._totalSpawned = 0;
|
||||
this._levelNum = levelData.id || 1;
|
||||
this._speedMultiplier = levelData.speedMultiplier || 1;
|
||||
|
||||
// Build spawn queue from composition
|
||||
this._spawnQueue = [];
|
||||
const comp = levelData.enemies.composition;
|
||||
this._totalEnemies = levelData.enemies.total;
|
||||
|
||||
// Add enemies by type
|
||||
for (let i = 0; i < (comp.boss || 0); i++) this._spawnQueue.push(TANK_TYPE.ENEMY_BOSS);
|
||||
for (let i = 0; i < (comp.armor || 0); i++) this._spawnQueue.push(TANK_TYPE.ENEMY_ARMOR);
|
||||
for (let i = 0; i < (comp.fast || 0); i++) this._spawnQueue.push(TANK_TYPE.ENEMY_FAST);
|
||||
for (let i = 0; i < (comp.normal || 0); i++) this._spawnQueue.push(TANK_TYPE.ENEMY_NORMAL);
|
||||
|
||||
// Shuffle the queue for variety
|
||||
this._shuffleArray(this._spawnQueue);
|
||||
|
||||
// Determine which enemies drop power-ups (roughly every 4-5 enemies)
|
||||
this._powerUpIndices.clear();
|
||||
const numPowerUps = Math.max(1, Math.floor(this._totalEnemies / 5));
|
||||
const indices = new Set();
|
||||
while (indices.size < numPowerUps) {
|
||||
indices.add(Math.floor(Math.random() * this._totalEnemies));
|
||||
}
|
||||
this._powerUpIndices = indices;
|
||||
|
||||
// Spawn first batch immediately
|
||||
this._spawnTimer = this._spawnInterval;
|
||||
}
|
||||
|
||||
/**
|
||||
* Update spawn timer and spawn enemies as needed.
|
||||
* @param {number} dt - Delta time in seconds.
|
||||
* @param {Array<EnemyTank>} activeEnemies - Currently alive enemies.
|
||||
* @returns {EnemyTank|null} Newly spawned enemy, or null.
|
||||
*/
|
||||
update(dt, activeEnemies) {
|
||||
if (this._spawnQueue.length === 0) return null;
|
||||
|
||||
const aliveCount = activeEnemies.filter((e) => e.alive).length;
|
||||
if (aliveCount >= MAX_ENEMIES_ON_SCREEN) return null;
|
||||
|
||||
this._spawnTimer += dt * 1000;
|
||||
if (this._spawnTimer < this._spawnInterval) return null;
|
||||
|
||||
this._spawnTimer = 0;
|
||||
return this._spawnNext();
|
||||
}
|
||||
|
||||
/**
|
||||
* Spawn the next enemy from the queue.
|
||||
* @private
|
||||
* @returns {EnemyTank|null}
|
||||
*/
|
||||
_spawnNext() {
|
||||
if (this._spawnQueue.length === 0) return null;
|
||||
|
||||
const type = this._spawnQueue.shift();
|
||||
const spawnPoint = this._spawnPoints[this._currentSpawnIndex % this._spawnPoints.length];
|
||||
this._currentSpawnIndex++;
|
||||
|
||||
const hasPowerUp = this._powerUpIndices.has(this._totalSpawned);
|
||||
this._totalSpawned++;
|
||||
|
||||
const enemy = new EnemyTank({
|
||||
type,
|
||||
col: spawnPoint.col,
|
||||
row: spawnPoint.row,
|
||||
levelNum: this._levelNum,
|
||||
hasPowerUp,
|
||||
speedMultiplier: this._speedMultiplier,
|
||||
});
|
||||
|
||||
return enemy;
|
||||
}
|
||||
|
||||
/**
|
||||
* Fisher-Yates shuffle.
|
||||
* @private
|
||||
*/
|
||||
_shuffleArray(arr) {
|
||||
for (let i = arr.length - 1; i > 0; i--) {
|
||||
const j = Math.floor(Math.random() * (i + 1));
|
||||
[arr[i], arr[j]] = [arr[j], arr[i]];
|
||||
}
|
||||
}
|
||||
|
||||
/** Number of enemies remaining to spawn. */
|
||||
get remainingToSpawn() {
|
||||
return this._spawnQueue.length;
|
||||
}
|
||||
|
||||
/** Total enemies for this level. */
|
||||
get totalEnemies() {
|
||||
return this._totalEnemies;
|
||||
}
|
||||
|
||||
/** Total spawned so far. */
|
||||
get totalSpawned() {
|
||||
return this._totalSpawned;
|
||||
}
|
||||
|
||||
/** Whether all enemies have been spawned. */
|
||||
get allSpawned() {
|
||||
return this._spawnQueue.length === 0;
|
||||
}
|
||||
}
|
||||
|
||||
module.exports = SpawnManager;
|
||||
@@ -0,0 +1,6 @@
|
||||
// StaminaManager - DEPRECATED (removed in monetization-lite)
|
||||
// This file is intentionally empty. The stamina system has been removed.
|
||||
class StaminaManager {
|
||||
constructor() {}
|
||||
}
|
||||
module.exports = StaminaManager;
|
||||
@@ -0,0 +1,249 @@
|
||||
/**
|
||||
* StorageManager.js
|
||||
* Handles local data persistence using wx.setStorageSync/getStorageSync.
|
||||
* Manages game save data, settings, and high scores.
|
||||
*/
|
||||
|
||||
class StorageManager {
|
||||
constructor() {
|
||||
this._prefix = 'tankwar_';
|
||||
}
|
||||
|
||||
// ============================================================
|
||||
// Generic Storage
|
||||
// ============================================================
|
||||
|
||||
/**
|
||||
* Save a value to local storage.
|
||||
* @param {string} key
|
||||
* @param {*} value - Will be JSON-serialized.
|
||||
*/
|
||||
set(key, value) {
|
||||
try {
|
||||
wx.setStorageSync(this._prefix + key, JSON.stringify(value));
|
||||
} catch (e) {
|
||||
console.warn(`[StorageManager] Failed to save "${key}":`, e);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Load a value from local storage.
|
||||
* @param {string} key
|
||||
* @param {*} [defaultValue=null]
|
||||
* @returns {*} Parsed value, or defaultValue if not found.
|
||||
*/
|
||||
get(key, defaultValue = null) {
|
||||
try {
|
||||
const raw = wx.getStorageSync(this._prefix + key);
|
||||
if (raw === '' || raw === undefined || raw === null) return defaultValue;
|
||||
return JSON.parse(raw);
|
||||
} catch (e) {
|
||||
console.warn(`[StorageManager] Failed to load "${key}":`, e);
|
||||
return defaultValue;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Remove a value from local storage.
|
||||
* @param {string} key
|
||||
*/
|
||||
remove(key) {
|
||||
try {
|
||||
wx.removeStorageSync(this._prefix + key);
|
||||
} catch (e) {}
|
||||
}
|
||||
|
||||
// ============================================================
|
||||
// Game Progress
|
||||
// ============================================================
|
||||
|
||||
/**
|
||||
* Save game progress.
|
||||
* @param {object} progress
|
||||
* @param {number} progress.currentLevel
|
||||
* @param {number} progress.lives
|
||||
* @param {number} progress.fireLevel
|
||||
* @param {string} progress.mode
|
||||
*/
|
||||
saveProgress(progress) {
|
||||
this.set('progress', progress);
|
||||
}
|
||||
|
||||
/**
|
||||
* Load game progress.
|
||||
* @returns {object|null}
|
||||
*/
|
||||
loadProgress() {
|
||||
return this.get('progress', null);
|
||||
}
|
||||
|
||||
/**
|
||||
* Clear saved progress.
|
||||
*/
|
||||
clearProgress() {
|
||||
this.remove('progress');
|
||||
}
|
||||
|
||||
// ============================================================
|
||||
// High Scores
|
||||
// ============================================================
|
||||
|
||||
/**
|
||||
* Get the high score for a mode.
|
||||
* @param {string} mode - Game mode.
|
||||
* @returns {number}
|
||||
*/
|
||||
getHighScore(mode) {
|
||||
const scores = this.get('highscores', {});
|
||||
return scores[mode] || 0;
|
||||
}
|
||||
|
||||
/**
|
||||
* Update high score if the new score is higher.
|
||||
* @param {string} mode
|
||||
* @param {number} score
|
||||
* @returns {boolean} Whether a new high score was set.
|
||||
*/
|
||||
updateHighScore(mode, score) {
|
||||
const scores = this.get('highscores', {});
|
||||
if (score > (scores[mode] || 0)) {
|
||||
scores[mode] = score;
|
||||
this.set('highscores', scores);
|
||||
return true;
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
/**
|
||||
* Get the highest level reached.
|
||||
* @returns {number}
|
||||
*/
|
||||
getHighestLevel() {
|
||||
return this.get('highest_level', 0);
|
||||
}
|
||||
|
||||
/**
|
||||
* Update highest level if new level is higher.
|
||||
* @param {number} level
|
||||
*/
|
||||
updateHighestLevel(level) {
|
||||
const current = this.getHighestLevel();
|
||||
if (level > current) {
|
||||
this.set('highest_level', level);
|
||||
}
|
||||
}
|
||||
|
||||
// ============================================================
|
||||
// Settings
|
||||
// ============================================================
|
||||
|
||||
/**
|
||||
* Save game settings.
|
||||
* @param {object} settings
|
||||
*/
|
||||
saveSettings(settings) {
|
||||
this.set('settings', settings);
|
||||
}
|
||||
|
||||
/**
|
||||
* Load game settings.
|
||||
* @returns {object}
|
||||
*/
|
||||
loadSettings() {
|
||||
return this.get('settings', {
|
||||
soundEnabled: true,
|
||||
musicEnabled: true,
|
||||
vibrationEnabled: true,
|
||||
});
|
||||
}
|
||||
|
||||
// ============================================================
|
||||
// Purchases & Unlocks
|
||||
// ============================================================
|
||||
|
||||
/**
|
||||
* Record a purchase.
|
||||
* @param {string} itemId
|
||||
*/
|
||||
recordPurchase(itemId) {
|
||||
const purchases = this.get('purchases', []);
|
||||
if (!purchases.includes(itemId)) {
|
||||
purchases.push(itemId);
|
||||
this.set('purchases', purchases);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if an item has been purchased.
|
||||
* @param {string} itemId
|
||||
* @returns {boolean}
|
||||
*/
|
||||
hasPurchased(itemId) {
|
||||
const purchases = this.get('purchases', []);
|
||||
return purchases.includes(itemId);
|
||||
}
|
||||
|
||||
// ============================================================
|
||||
// First-time flags
|
||||
// ============================================================
|
||||
|
||||
/**
|
||||
* Check if this is the first time playing.
|
||||
* @returns {boolean}
|
||||
*/
|
||||
isFirstPlay() {
|
||||
return !this.get('has_played', false);
|
||||
}
|
||||
|
||||
/**
|
||||
* Mark that the player has played.
|
||||
*/
|
||||
markPlayed() {
|
||||
this.set('has_played', true);
|
||||
}
|
||||
|
||||
// ============================================================
|
||||
// Cloud Sync Helpers
|
||||
// ============================================================
|
||||
|
||||
/**
|
||||
* Get data to sync to cloud.
|
||||
* @returns {object}
|
||||
*/
|
||||
getCloudSyncData() {
|
||||
return {
|
||||
highscores: this.get('highscores', {}),
|
||||
highest_level: this.getHighestLevel(),
|
||||
purchases: this.get('purchases', []),
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Restore data from cloud.
|
||||
* @param {object} cloudData
|
||||
*/
|
||||
restoreFromCloud(cloudData) {
|
||||
if (cloudData.highscores) {
|
||||
const local = this.get('highscores', {});
|
||||
// Merge: keep the higher score
|
||||
for (const [mode, score] of Object.entries(cloudData.highscores)) {
|
||||
if (score > (local[mode] || 0)) {
|
||||
local[mode] = score;
|
||||
}
|
||||
}
|
||||
this.set('highscores', local);
|
||||
}
|
||||
|
||||
if (cloudData.highest_level) {
|
||||
this.updateHighestLevel(cloudData.highest_level);
|
||||
}
|
||||
|
||||
if (cloudData.purchases) {
|
||||
const local = this.get('purchases', []);
|
||||
const merged = [...new Set([...local, ...cloudData.purchases])];
|
||||
this.set('purchases', merged);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
module.exports = StorageManager;
|
||||
Reference in New Issue
Block a user