first commit

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