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
+84
View File
@@ -0,0 +1,84 @@
/**
* EventBus.js
* Simple publish/subscribe event system for decoupled communication
* between game systems.
*/
class EventBus {
constructor() {
/** @type {Map<string, Array<{fn: Function, once: boolean}>>} */
this._listeners = new Map();
}
/**
* Subscribe to an event.
* @param {string} event
* @param {Function} fn
* @returns {Function} Unsubscribe function.
*/
on(event, fn) {
if (!this._listeners.has(event)) {
this._listeners.set(event, []);
}
const entry = { fn, once: false };
this._listeners.get(event).push(entry);
return () => this.off(event, fn);
}
/**
* Subscribe to an event, but only fire once.
* @param {string} event
* @param {Function} fn
*/
once(event, fn) {
if (!this._listeners.has(event)) {
this._listeners.set(event, []);
}
this._listeners.get(event).push({ fn, once: true });
}
/**
* Unsubscribe from an event.
* @param {string} event
* @param {Function} fn
*/
off(event, fn) {
const list = this._listeners.get(event);
if (!list) return;
const idx = list.findIndex((entry) => entry.fn === fn);
if (idx !== -1) list.splice(idx, 1);
}
/**
* Emit an event with optional data.
* @param {string} event
* @param {*} [data]
*/
emit(event, data) {
const list = this._listeners.get(event);
if (!list || list.length === 0) return;
// Iterate in reverse so we can safely remove "once" entries
for (let i = list.length - 1; i >= 0; i--) {
const entry = list[i];
entry.fn(data);
if (entry.once) {
list.splice(i, 1);
}
}
}
/**
* Remove all listeners for a specific event, or all events.
* @param {string} [event]
*/
clear(event) {
if (event) {
this._listeners.delete(event);
} else {
this._listeners.clear();
}
}
}
module.exports = EventBus;
+350
View File
@@ -0,0 +1,350 @@
/**
* GameGlobal.js
* Global constants, enums, and configuration for Tank War mini game.
*/
// ============================================================
// Screen & Canvas
// ============================================================
const systemInfo = wx.getSystemInfoSync();
const SCREEN_WIDTH = systemInfo.windowWidth;
const SCREEN_HEIGHT = systemInfo.windowHeight;
const DEVICE_PIXEL_RATIO = systemInfo.pixelRatio || 1;
// ============================================================
// Map Grid
// ============================================================
// TILE_SIZE is determined by screen height so the map fills vertically
const GRID_ROWS = 13;
const GRID_COLS = 21;
const TILE_SIZE = SCREEN_HEIGHT / GRID_ROWS;
const MAP_WIDTH = TILE_SIZE * GRID_COLS;
const MAP_HEIGHT = TILE_SIZE * GRID_ROWS;
// Center map on screen — controls (joystick & fire button) overlay on the map
const MAP_OFFSET_X = Math.floor((SCREEN_WIDTH - MAP_WIDTH) / 2);
const MAP_OFFSET_Y = Math.floor((SCREEN_HEIGHT - MAP_HEIGHT) / 2);
// ============================================================
// Terrain Types
// ============================================================
const TERRAIN = {
EMPTY: 0,
BRICK: 1,
STEEL: 2,
RIVER: 3,
FOREST: 4,
BASE: 5,
BASE_WALL: 6, // brick wall around base
};
// ============================================================
// Direction
// ============================================================
const DIRECTION = {
UP: 0,
DOWN: 1,
LEFT: 2,
RIGHT: 3,
};
// Direction vectors (dx, dy)
const DIR_VECTORS = {
[DIRECTION.UP]: { dx: 0, dy: -1 },
[DIRECTION.DOWN]: { dx: 0, dy: 1 },
[DIRECTION.LEFT]: { dx: -1, dy: 0 },
[DIRECTION.RIGHT]: { dx: 1, dy: 0 },
};
// ============================================================
// Tank Types
// ============================================================
const TANK_TYPE = {
PLAYER: 'player',
ENEMY_NORMAL: 'enemy_normal',
ENEMY_FAST: 'enemy_fast',
ENEMY_ARMOR: 'enemy_armor',
ENEMY_BOSS: 'enemy_boss',
};
// Tank configuration table
const TANK_CONFIG = {
[TANK_TYPE.PLAYER]: {
speed: 2,
hp: 1,
color: '#FFD700', // gold
size: TILE_SIZE * 0.85,
},
[TANK_TYPE.ENEMY_NORMAL]: {
speed: 1.5,
hp: 1,
color: '#AAAAAA', // gray
size: TILE_SIZE * 0.85,
score: 100,
},
[TANK_TYPE.ENEMY_FAST]: {
speed: 3,
hp: 1,
color: '#FF6347', // tomato
size: TILE_SIZE * 0.85,
score: 200,
},
[TANK_TYPE.ENEMY_ARMOR]: {
speed: 1,
hp: 3,
color: '#228B22', // forest green
size: TILE_SIZE * 0.85,
score: 300,
},
[TANK_TYPE.ENEMY_BOSS]: {
speed: 1.2,
hp: 6,
color: '#8B0000', // dark red
size: TILE_SIZE * 1.2,
score: 500,
},
};
// ============================================================
// Bullet
// ============================================================
const BULLET_SPEED = 5;
const BULLET_SIZE = 6;
// ============================================================
// Fire Level
// ============================================================
const FIRE_LEVEL = {
LV1: 1, // single shot, 1 bullet on screen
LV2: 2, // rapid fire, 2 bullets on screen
LV3: 3, // rapid fire + steel break, 2 bullets on screen
};
const MAX_BULLETS_BY_LEVEL = {
[FIRE_LEVEL.LV1]: 1,
[FIRE_LEVEL.LV2]: 2,
[FIRE_LEVEL.LV3]: 2,
};
// ============================================================
// Power-Up Types
// ============================================================
const POWERUP_TYPE = {
STAR: 'star',
CLOCK: 'clock',
BOMB: 'bomb',
HELMET: 'helmet',
SHOVEL: 'shovel',
TANK: 'tank',
};
const POWERUP_DURATION = 15000; // ms, how long a power-up stays on map
// ============================================================
// Game Settings
// ============================================================
const DEFAULT_LIVES = 3;
const ENEMIES_PER_LEVEL = 20;
const MAX_ENEMIES_ON_SCREEN = 4;
const ENEMY_SPAWN_INTERVAL = 3000; // ms
const FREEZE_DURATION = 10000; // ms
const SHIELD_DURATION = 15000; // ms
const SHOVEL_DURATION = 20000; // ms
const INVINCIBLE_BLINK_INTERVAL = 100; // ms
// ============================================================
// Scene Names
// ============================================================
const SCENE = {
LOADING: 'loading',
MENU: 'menu',
GAME: 'game',
RESULT: 'result',
RANKING: 'ranking',
SETTINGS: 'settings',
SHOP: 'shop',
BUFF_SELECT: 'buff_select',
PVP_ROOM: 'pvp_room',
PVP_GAME: 'pvp_game',
PVP_RESULT: 'pvp_result',
TEAM_ROOM: 'team_room',
TEAM_GAME: 'team_game',
TEAM_RESULT: 'team_result',
};
// ============================================================
// Game Modes
// ============================================================
const GAME_MODE = {
CLASSIC: 'classic',
ENDLESS: 'endless',
PVP: 'pvp',
TEAM_3V3: 'team_3v3',
};
// ============================================================
// PVP Settings
// ============================================================
const PVP_ROUND_TIME = 180; // seconds per round (legacy, unused in base-destruction mode)
const PVP_RESPAWN_DELAY = 3000; // ms before respawn
const PVP_MAX_LIVES = 5; // legacy, unused in base-destruction mode
const PVP_WIN_KILLS = 5; // legacy, unused in base-destruction mode
const PVP_BASE_HP = 5; // base hit points for 1v1 PVP mode
// ============================================================
// Server Configuration
// ============================================================
// const SERVER_URL = 'ws://192.168.1.103:3000'; // local testing server URL, replace with actual server URL in production
const SERVER_URL = 'wss://www.igeek.site/games/wx/tankwar';
// ============================================================
// 3v3 Team Settings
// ============================================================
const TEAM_SIZE = 3;
const TEAM_RESPAWN_DELAY = 3000; // ms before respawn
const TEAM_BASE_HP = 10; // base hit points for 3v3 mode
const TEAM_RECONNECT_TIMEOUT = 60000; // ms, 60s to reconnect
const TEAM_MATCH_TIMEOUT = 60000; // ms, 60s matchmaking timeout
// ============================================================
// Battle Configuration (X vs X configurable)
// ============================================================
const BATTLE_CONFIG = {
'1v1': {
teamSize: 1,
baseHp: PVP_BASE_HP,
respawnDelay: PVP_RESPAWN_DELAY,
fillWithBots: false,
mapPool: 'pvp',
},
'3v3': {
teamSize: 3,
baseHp: TEAM_BASE_HP,
respawnDelay: TEAM_RESPAWN_DELAY,
fillWithBots: true,
mapPool: 'team',
},
};
// ============================================================
// Network Message Types
// ============================================================
const NET_MSG = {
// Room
CREATE_ROOM: 'create_room',
JOIN_ROOM: 'join_room',
ROOM_CREATED: 'room_created',
ROOM_JOINED: 'room_joined',
ROOM_ERROR: 'room_error',
OPPONENT_JOINED: 'opponent_joined',
OPPONENT_LEFT: 'opponent_left',
GAME_START: 'game_start',
// Gameplay
PLAYER_INPUT: 'player_input',
PLAYER_STATE: 'player_state',
BULLET_FIRE: 'bullet_fire',
BULLET_HIT: 'bullet_hit',
PLAYER_HIT: 'player_hit',
PLAYER_KILLED: 'player_killed',
GAME_OVER: 'game_over',
// Sync
PING: 'ping',
PONG: 'pong',
SYNC_STATE: 'sync_state',
// Team (3v3)
CREATE_TEAM: 'create_team',
JOIN_TEAM: 'join_team',
LEAVE_TEAM: 'leave_team',
TEAM_READY: 'team_ready',
TEAM_KICK: 'team_kick',
TEAM_DISBAND: 'team_disband',
TEAM_STATE: 'team_state',
MATCH_START: 'match_start',
MATCH_CANCEL: 'match_cancel',
MATCH_FOUND: 'match_found',
MATCH_TIMEOUT: 'match_timeout',
BASE_HIT: 'base_hit',
BASE_DESTROYED: 'base_destroyed',
PLAYER_RESPAWN: 'player_respawn',
TEAM_GAME_START: 'team_game_start',
TEAM_GAME_OVER: 'team_game_over',
RECONNECT: 'reconnect',
RECONNECT_OK: 'reconnect_ok',
PLAYER_DISCONNECT: 'player_disconnect',
BOT_TAKEOVER: 'bot_takeover',
SOLO_MATCH: 'solo_match',
REMATCH: 'rematch',
REMATCH_READY: 'rematch_ready',
};
// ============================================================
// Colors
// ============================================================
const COLORS = {
BG: '#000000',
BRICK: '#B5651D',
STEEL: '#808080',
RIVER: '#4169E1',
FOREST: '#006400',
BASE: '#FFD700',
BASE_WALL: '#B5651D',
HUD_TEXT: '#FFFFFF',
MENU_BG: '#1a1a2e',
MENU_TITLE: '#FFD700',
MENU_BTN: '#16213e',
MENU_BTN_TEXT: '#FFFFFF',
MENU_BTN_BORDER: '#0f3460',
};
// ============================================================
// Export
// ============================================================
module.exports = {
SCREEN_WIDTH,
SCREEN_HEIGHT,
DEVICE_PIXEL_RATIO,
GRID_COLS,
GRID_ROWS,
TILE_SIZE,
MAP_WIDTH,
MAP_HEIGHT,
MAP_OFFSET_X,
MAP_OFFSET_Y,
TERRAIN,
DIRECTION,
DIR_VECTORS,
TANK_TYPE,
TANK_CONFIG,
BULLET_SPEED,
BULLET_SIZE,
FIRE_LEVEL,
MAX_BULLETS_BY_LEVEL,
POWERUP_TYPE,
POWERUP_DURATION,
DEFAULT_LIVES,
ENEMIES_PER_LEVEL,
MAX_ENEMIES_ON_SCREEN,
ENEMY_SPAWN_INTERVAL,
FREEZE_DURATION,
SHIELD_DURATION,
SHOVEL_DURATION,
INVINCIBLE_BLINK_INTERVAL,
SCENE,
GAME_MODE,
PVP_ROUND_TIME,
PVP_RESPAWN_DELAY,
PVP_MAX_LIVES,
PVP_WIN_KILLS,
PVP_BASE_HP,
TEAM_SIZE,
TEAM_RESPAWN_DELAY,
TEAM_BASE_HP,
TEAM_RECONNECT_TIMEOUT,
TEAM_MATCH_TIMEOUT,
BATTLE_CONFIG,
NET_MSG,
COLORS,
SERVER_URL,
};
+83
View File
@@ -0,0 +1,83 @@
/**
* ObjectPool.js
* Generic object pool for reusing frequently created/destroyed objects
* (bullets, explosions, etc.) to avoid GC pressure in WeChat mini game.
*/
class ObjectPool {
/**
* @param {Function} createFn - Factory function that returns a new object instance.
* @param {Function} [resetFn] - Optional function to reset an object before reuse.
* @param {number} [initialSize=0] - Number of objects to pre-allocate.
*/
constructor(createFn, resetFn, initialSize = 0) {
this._createFn = createFn;
this._resetFn = resetFn || null;
this._pool = [];
this._activeCount = 0;
// Pre-allocate
for (let i = 0; i < initialSize; i++) {
this._pool.push(this._createFn());
}
}
/**
* Get an object from the pool. Creates a new one if pool is empty.
* @returns {*} A reusable object instance.
*/
get() {
let obj;
if (this._pool.length > 0) {
obj = this._pool.pop();
} else {
obj = this._createFn();
}
if (this._resetFn) {
this._resetFn(obj);
}
this._activeCount++;
return obj;
}
/**
* Return an object back to the pool for future reuse.
* @param {*} obj - The object to recycle.
*/
put(obj) {
if (obj) {
this._pool.push(obj);
this._activeCount = Math.max(0, this._activeCount - 1);
}
}
/**
* Pre-allocate a number of objects into the pool.
* @param {number} count
*/
preAllocate(count) {
for (let i = 0; i < count; i++) {
this._pool.push(this._createFn());
}
}
/**
* Clear the pool entirely.
*/
clear() {
this._pool.length = 0;
this._activeCount = 0;
}
/** Number of objects currently in the pool (idle). */
get size() {
return this._pool.length;
}
/** Number of objects currently in use. */
get activeCount() {
return this._activeCount;
}
}
module.exports = ObjectPool;
+2
View File
@@ -0,0 +1,2 @@
// BattlePassData - DEPRECATED (removed in monetization-lite)
module.exports = {};
+525
View File
@@ -0,0 +1,525 @@
/**
* LevelData.js
* Predefined level map configurations.
* Each level is a 13×21 grid (rows × cols). Values correspond to TERRAIN enum:
* 0=EMPTY, 1=BRICK, 2=STEEL, 3=RIVER, 4=FOREST, 5=BASE, 6=BASE_WALL
*
* In landscape mode the map is 13 rows × 21 cols.
* The original 13×13 design sits in the center (cols 416),
* with additional terrain on the flanks (cols 03 and cols 1720).
*
* Player spawns at bottom area, enemies spawn from top row.
* Base (5) is at bottom-center, surrounded by BASE_WALL (6).
*/
const LEVELS = [
// ============================================================
// Level 1 - Tutorial: open terrain, few bricks
// ============================================================
{
id: 1,
name: 'Tutorial I',
grid: [
//0 1 2 3 4 5 6 7 8 9 0 1 2 3 4 5 6 7 8 9 0
[0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0],
[0,0,1,0,0,0,1,0,0,1,0,1,0,0,1,0,0,0,1,0,0],
[0,0,0,0,0,0,1,0,0,1,0,1,0,0,1,0,0,0,0,0,0],
[0,1,0,1,0,0,0,0,0,0,0,0,0,0,0,0,0,1,0,1,0],
[0,0,0,0,0,0,1,0,1,1,0,1,1,0,1,0,0,0,0,0,0],
[0,1,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,1,0],
[0,0,0,1,0,0,1,0,0,1,0,1,0,0,1,0,0,1,0,0,0],
[0,0,0,0,0,0,1,0,0,0,0,0,0,0,1,0,0,0,0,0,0],
[0,1,0,0,0,0,0,0,0,1,0,1,0,0,0,0,0,0,0,1,0],
[0,0,0,1,0,0,1,0,0,1,0,1,0,0,1,0,0,1,0,0,0],
[0,0,0,0,0,0,1,0,0,0,0,0,0,0,1,0,0,0,0,0,0],
[0,0,0,0,0,0,0,0,6,6,6,6,6,0,0,0,0,0,0,0,0],
[0,0,0,0,0,0,0,0,6,6,5,6,6,0,0,0,0,0,0,0,0],
],
enemies: {
total: 20,
composition: { normal: 18, fast: 2, armor: 0, boss: 0 },
},
speedMultiplier: 0.6,
spawnPoints: [
{ col: 0, row: 0 },
{ col: 10, row: 0 },
{ col: 20, row: 0 },
],
playerSpawn: { col: 8, row: 10 },
},
// ============================================================
// Level 2 - Tutorial II: more bricks, simple layout
// ============================================================
{
id: 2,
name: 'Tutorial II',
grid: [
[0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0],
[0,1,0,0,0,1,1,0,1,1,0,1,1,0,1,1,0,0,0,1,0],
[0,0,0,1,0,1,1,0,1,1,0,1,1,0,1,1,0,1,0,0,0],
[0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0],
[0,1,0,0,1,0,0,1,0,1,0,1,0,1,0,0,1,0,0,1,0],
[0,0,0,0,0,0,0,1,0,0,0,0,0,1,0,0,0,0,0,0,0],
[0,0,1,0,0,1,0,0,0,1,0,1,0,0,0,1,0,0,1,0,0],
[0,0,1,0,0,1,0,0,0,0,0,0,0,0,0,1,0,0,1,0,0],
[0,0,0,0,0,0,0,1,0,1,0,1,0,1,0,0,0,0,0,0,0],
[0,1,0,1,0,0,0,1,0,1,0,1,0,1,0,0,0,1,0,1,0],
[0,0,0,0,0,1,0,0,0,0,0,0,0,0,0,1,0,0,0,0,0],
[0,0,0,0,0,0,0,0,6,6,6,6,6,0,0,0,0,0,0,0,0],
[0,0,0,0,0,0,0,0,6,6,5,6,6,0,0,0,0,0,0,0,0],
],
enemies: {
total: 20,
composition: { normal: 16, fast: 4, armor: 0, boss: 0 },
},
spawnPoints: [
{ col: 0, row: 0 },
{ col: 10, row: 0 },
{ col: 20, row: 0 },
],
playerSpawn: { col: 8, row: 10 },
},
// ============================================================
// Level 3 - Tutorial III: denser bricks, introduce forest
// ============================================================
{
id: 3,
name: 'Tutorial III',
grid: [
[0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0],
[0,4,0,0,0,1,0,4,0,1,0,1,0,4,0,1,0,0,0,4,0],
[0,4,0,1,0,1,0,4,0,1,0,1,0,4,0,1,0,1,0,4,0],
[0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0],
[0,0,1,0,0,1,1,0,1,0,4,0,1,0,1,1,0,0,1,0,0],
[0,0,0,0,0,0,0,0,1,0,4,0,1,0,0,0,0,0,0,0,0],
[0,1,0,1,1,0,1,0,0,0,0,0,0,0,1,0,1,1,0,1,0],
[0,0,0,0,1,0,1,0,0,1,0,1,0,0,1,0,1,0,0,0,0],
[0,0,0,0,0,0,0,0,0,1,0,1,0,0,0,0,0,0,0,0,0],
[0,1,0,0,0,1,0,1,0,0,0,0,0,1,0,1,0,0,0,1,0],
[0,0,0,1,0,1,0,1,0,0,0,0,0,1,0,1,0,1,0,0,0],
[0,0,0,0,0,0,0,0,6,6,6,6,6,0,0,0,0,0,0,0,0],
[0,0,0,0,0,0,0,0,6,6,5,6,6,0,0,0,0,0,0,0,0],
],
enemies: {
total: 20,
composition: { normal: 14, fast: 6, armor: 0, boss: 0 },
},
spawnPoints: [
{ col: 0, row: 0 },
{ col: 10, row: 0 },
{ col: 20, row: 0 },
],
playerSpawn: { col: 8, row: 10 },
},
// ============================================================
// Level 4 - Steel Fortress: steel walls appear
// ============================================================
{
id: 4,
name: 'Steel Fortress',
grid: [
[0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0],
[0,0,2,0,0,1,2,0,1,1,0,1,1,0,2,1,0,0,2,0,0],
[0,0,0,0,0,1,0,0,0,1,0,1,0,0,0,1,0,0,0,0,0],
[0,1,0,1,0,0,0,1,0,0,0,0,0,1,0,0,0,1,0,1,0],
[0,0,0,0,2,0,0,1,0,2,0,2,0,1,0,0,2,0,0,0,0],
[0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0],
[0,2,0,0,0,1,1,0,2,1,0,1,2,0,1,1,0,0,0,2,0],
[0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0],
[0,0,1,0,0,1,0,1,0,1,0,1,0,1,0,1,0,0,1,0,0],
[0,0,0,0,0,1,0,1,0,0,0,0,0,1,0,1,0,0,0,0,0],
[0,1,0,1,0,0,0,0,0,0,0,0,0,0,0,0,0,1,0,1,0],
[0,0,0,0,0,0,0,0,6,6,6,6,6,0,0,0,0,0,0,0,0],
[0,0,0,0,0,0,0,0,6,6,5,6,6,0,0,0,0,0,0,0,0],
],
enemies: {
total: 20,
composition: { normal: 12, fast: 5, armor: 3, boss: 0 },
},
spawnPoints: [
{ col: 0, row: 0 },
{ col: 10, row: 0 },
{ col: 20, row: 0 },
],
playerSpawn: { col: 8, row: 10 },
},
// ============================================================
// Level 5 - River Crossing: introduces water terrain
// ============================================================
{
id: 5,
name: 'River Crossing',
grid: [
[0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0],
[0,0,1,0,0,1,0,0,1,0,0,0,1,0,0,1,0,0,1,0,0],
[0,0,0,0,0,1,0,0,1,0,0,0,1,0,0,1,0,0,0,0,0],
[0,1,0,0,0,0,0,3,3,0,1,0,3,3,0,0,0,0,0,1,0],
[0,0,0,1,0,1,0,3,3,0,0,0,3,3,0,1,0,1,0,0,0],
[0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0],
[0,1,0,0,1,0,1,0,3,1,0,1,3,0,1,0,1,0,0,1,0],
[0,0,0,0,0,0,0,0,3,0,0,0,3,0,0,0,0,0,0,0,0],
[0,0,1,0,0,1,0,0,0,0,1,0,0,0,0,1,0,0,1,0,0],
[0,1,0,0,0,1,0,1,0,1,1,1,0,1,0,1,0,0,0,1,0],
[0,0,0,1,0,0,0,0,0,0,0,0,0,0,0,0,0,1,0,0,0],
[0,0,0,0,0,0,0,0,6,6,6,6,6,0,0,0,0,0,0,0,0],
[0,0,0,0,0,0,0,0,6,6,5,6,6,0,0,0,0,0,0,0,0],
],
enemies: {
total: 20,
composition: { normal: 10, fast: 6, armor: 4, boss: 0 },
},
spawnPoints: [
{ col: 0, row: 0 },
{ col: 10, row: 0 },
{ col: 20, row: 0 },
],
playerSpawn: { col: 8, row: 10 },
},
// ============================================================
// Level 10 - Iron Wall: lots of armored enemies
// ============================================================
{
id: 10,
name: 'Iron Wall',
grid: [
[0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0],
[0,2,0,0,0,2,1,0,2,1,0,1,2,0,1,2,0,0,0,2,0],
[0,0,0,1,0,0,1,0,0,1,0,1,0,0,1,0,0,1,0,0,0],
[0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0],
[0,1,0,0,1,2,0,1,3,3,0,3,3,1,0,2,1,0,0,1,0],
[0,0,0,0,0,0,0,1,3,3,0,3,3,1,0,0,0,0,0,0,0],
[0,0,2,0,0,1,0,0,0,2,0,2,0,0,0,1,0,0,2,0,0],
[0,0,0,0,0,1,0,0,0,0,0,0,0,0,0,1,0,0,0,0,0],
[0,1,0,1,0,0,0,2,0,1,4,1,0,2,0,0,0,1,0,1,0],
[0,0,0,0,0,1,0,0,0,1,4,1,0,0,0,1,0,0,0,0,0],
[0,0,1,0,0,1,0,0,0,0,0,0,0,0,0,1,0,0,1,0,0],
[0,0,0,0,0,0,0,0,6,6,6,6,6,0,0,0,0,0,0,0,0],
[0,0,0,0,0,0,0,0,6,6,5,6,6,0,0,0,0,0,0,0,0],
],
enemies: {
total: 20,
composition: { normal: 6, fast: 4, armor: 10, boss: 0 },
},
spawnPoints: [
{ col: 0, row: 0 },
{ col: 10, row: 0 },
{ col: 20, row: 0 },
],
playerSpawn: { col: 8, row: 10 },
},
// ============================================================
// Level 20 - Boss Battle: giant tank encounter
// ============================================================
{
id: 20,
name: 'Boss Battle',
grid: [
[0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0],
[0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0],
[0,0,1,0,0,1,2,0,1,2,0,2,1,0,2,1,0,0,1,0,0],
[0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0],
[0,2,0,0,2,0,0,2,3,0,0,0,3,2,0,0,2,0,0,2,0],
[0,0,0,0,0,0,0,0,3,0,4,0,3,0,0,0,0,0,0,0,0],
[0,0,1,0,0,1,2,0,0,0,4,0,0,0,2,1,0,0,1,0,0],
[0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0],
[0,1,0,0,0,2,0,1,0,2,0,2,0,1,0,2,0,0,0,1,0],
[0,0,0,1,0,0,0,1,0,0,0,0,0,1,0,0,0,1,0,0,0],
[0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0],
[0,0,0,0,0,0,0,0,6,6,6,6,6,0,0,0,0,0,0,0,0],
[0,0,0,0,0,0,0,0,6,6,5,6,6,0,0,0,0,0,0,0,0],
],
enemies: {
total: 20,
composition: { normal: 8, fast: 4, armor: 6, boss: 2 },
},
spawnPoints: [
{ col: 0, row: 0 },
{ col: 10, row: 0 },
{ col: 20, row: 0 },
],
playerSpawn: { col: 8, row: 10 },
},
];
/**
* Get level data by level number.
* If the level doesn't exist, generate one based on the closest template
* with increased difficulty.
* @param {number} levelNum
* @returns {object} Level data
*/
function getLevelData(levelNum) {
// Direct match
const exact = LEVELS.find((l) => l.id === levelNum);
if (exact) return JSON.parse(JSON.stringify(exact));
// Find the closest template (highest id <= levelNum)
let template = LEVELS[0];
for (const l of LEVELS) {
if (l.id <= levelNum && l.id > template.id) {
template = l;
}
}
// Clone and adjust difficulty
const data = JSON.parse(JSON.stringify(template));
data.id = levelNum;
data.name = `Level ${levelNum}`;
// Scale enemy composition based on level
const cycle = Math.floor((levelNum - 1) / 20); // difficulty cycle
const extra = cycle * 2;
data.enemies.composition.armor = Math.min(
data.enemies.total,
data.enemies.composition.armor + extra
);
data.enemies.composition.fast = Math.min(
data.enemies.total - data.enemies.composition.armor,
data.enemies.composition.fast + cycle
);
data.enemies.composition.normal = Math.max(
0,
data.enemies.total -
data.enemies.composition.armor -
data.enemies.composition.fast -
data.enemies.composition.boss
);
return data;
}
// ============================================================
// PVP Maps - Symmetric layouts with bases for 1v1 base-destruction mode
// Each player has a base (TERRAIN.BASE=5) surrounded by BASE_WALL (6)
// Player 1 base on left, Player 2 base on right
// ============================================================
const PVP_MAPS = [
{
id: 1,
name: 'Arena I',
grid: [
//0 1 2 3 4 5 6 7 8 9 0 1 2 3 4 5 6 7 8 9 0
[0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0],
[0,0,0,0,0,0,1,0,1,0,0,0,1,0,1,0,0,0,0,0,0],
[0,0,0,0,0,0,1,0,1,0,0,0,1,0,1,0,0,0,0,0,0],
[0,0,0,0,0,0,0,0,0,0,2,0,0,0,0,0,0,0,0,0,0],
[0,0,0,1,0,1,0,1,0,1,0,1,0,1,0,1,0,1,0,0,0],
[0,6,6,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,6,6,0],
[0,6,5,6,1,0,1,0,2,0,0,0,2,0,1,0,1,6,5,6,0],
[0,6,6,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,6,6,0],
[0,0,0,1,0,1,0,1,0,1,0,1,0,1,0,1,0,1,0,0,0],
[0,0,0,0,0,0,0,0,0,0,2,0,0,0,0,0,0,0,0,0,0],
[0,0,0,0,0,0,1,0,1,0,0,0,1,0,1,0,0,0,0,0,0],
[0,0,0,0,0,0,1,0,1,0,0,0,1,0,1,0,0,0,0,0,0],
[0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0],
],
teamABase: [{ col: 2, row: 6 }],
teamBBase: [{ col: 18, row: 6 }],
teamASpawns: [{ col: 1, row: 5 }],
teamBSpawns: [{ col: 19, row: 7 }],
},
{
id: 2,
name: 'Arena II',
grid: [
[0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0],
[0,0,0,0,0,1,0,0,0,1,0,1,0,0,0,1,0,0,0,0,0],
[0,0,0,0,0,0,0,1,0,0,0,0,0,1,0,0,0,0,0,0,0],
[0,0,0,0,0,0,1,2,1,0,0,0,1,2,1,0,0,0,0,0,0],
[0,0,0,0,0,0,0,1,0,0,4,0,0,1,0,0,0,0,0,0,0],
[0,6,6,0,0,1,0,0,0,1,4,1,0,0,0,1,0,0,6,6,0],
[0,6,5,6,0,0,0,0,4,4,0,4,4,0,0,0,0,6,5,6,0],
[0,6,6,0,0,1,0,0,0,1,4,1,0,0,0,1,0,0,6,6,0],
[0,0,0,0,0,0,0,1,0,0,4,0,0,1,0,0,0,0,0,0,0],
[0,0,0,0,0,0,1,2,1,0,0,0,1,2,1,0,0,0,0,0,0],
[0,0,0,0,0,0,0,1,0,0,0,0,0,1,0,0,0,0,0,0,0],
[0,0,0,0,0,1,0,0,0,1,0,1,0,0,0,1,0,0,0,0,0],
[0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0],
],
teamABase: [{ col: 2, row: 6 }],
teamBBase: [{ col: 18, row: 6 }],
teamASpawns: [{ col: 0, row: 6 }],
teamBSpawns: [{ col: 20, row: 6 }],
},
{
id: 3,
name: 'Arena III',
grid: [
[0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0],
[0,0,0,0,1,0,0,2,0,0,0,0,0,2,0,0,1,0,0,0,0],
[0,0,0,0,0,0,0,0,0,1,0,1,0,0,0,0,0,0,0,0,0],
[0,0,0,0,0,0,1,0,3,0,0,0,3,0,1,0,0,0,0,0,0],
[0,0,0,0,0,0,0,0,3,0,2,0,3,0,0,0,0,0,0,0,0],
[0,6,6,1,0,2,0,0,0,0,0,0,0,0,0,2,0,1,6,6,0],
[0,6,5,6,0,0,0,1,0,4,4,4,0,1,0,0,0,6,5,6,0],
[0,6,6,1,0,2,0,0,0,0,0,0,0,0,0,2,0,1,6,6,0],
[0,0,0,0,0,0,0,0,3,0,2,0,3,0,0,0,0,0,0,0,0],
[0,0,0,0,0,0,1,0,3,0,0,0,3,0,1,0,0,0,0,0,0],
[0,0,0,0,0,0,0,0,0,1,0,1,0,0,0,0,0,0,0,0,0],
[0,0,0,0,1,0,0,2,0,0,0,0,0,2,0,0,1,0,0,0,0],
[0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0],
],
teamABase: [{ col: 2, row: 6 }],
teamBBase: [{ col: 18, row: 6 }],
teamASpawns: [{ col: 1, row: 5 }],
teamBSpawns: [{ col: 19, row: 7 }],
},
];
/**
* Get a PVP map by id, or deterministically by roomId seed.
* When no mapId is given, uses roomId to pick the same map on both clients.
* Falls back to random only when neither mapId nor roomId is provided.
* @param {number} [mapId] - Explicit map id
* @param {string} [roomId] - Room id used as seed for deterministic selection
* @returns {object} PVP map data (deep clone).
*/
function getPvpMap(mapId, roomId) {
let map;
if (mapId) {
map = PVP_MAPS.find((m) => m.id === mapId);
}
if (!map && roomId) {
// Deterministic selection based on roomId so both clients pick the same map
let hash = 0;
for (let i = 0; i < roomId.length; i++) {
hash = ((hash << 5) - hash + roomId.charCodeAt(i)) | 0;
}
const index = Math.abs(hash) % PVP_MAPS.length;
map = PVP_MAPS[index];
}
if (!map) {
map = PVP_MAPS[Math.floor(Math.random() * PVP_MAPS.length)];
}
return JSON.parse(JSON.stringify(map));
}
// ============================================================
// 3v3 Team Maps - Symmetric layouts with bases on both ends
// Each team has a base (TERRAIN.BASE=5) surrounded by BASE_WALL (6)
// Left team base at left end, right team base at right end
// Center area is the contested zone
// ============================================================
const TEAM_MAPS = [
{
id: 1,
name: 'Battlefield I',
grid: [
//0 1 2 3 4 5 6 7 8 9 0 1 2 3 4 5 6 7 8 9 0
[0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0],
[0,0,0,0,0,1,0,0,1,0,0,0,1,0,0,1,0,0,0,0,0],
[0,0,0,0,0,1,0,0,0,0,1,0,0,0,0,1,0,0,0,0,0],
[0,0,0,0,0,0,0,1,0,0,0,0,0,1,0,0,0,0,0,0,0],
[0,0,0,0,1,0,0,1,0,2,0,2,0,1,0,0,1,0,0,0,0],
[0,6,6,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,6,6,0],
[0,6,5,6,0,2,0,0,3,3,0,3,3,0,0,2,0,6,5,6,0],
[0,6,6,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,6,6,0],
[0,0,0,0,1,0,0,1,0,2,0,2,0,1,0,0,1,0,0,0,0],
[0,0,0,0,0,0,0,1,0,0,0,0,0,1,0,0,0,0,0,0,0],
[0,0,0,0,0,1,0,0,0,0,1,0,0,0,0,1,0,0,0,0,0],
[0,0,0,0,0,1,0,0,1,0,0,0,1,0,0,1,0,0,0,0,0],
[0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0],
],
// Each team has exactly 1 base, centered vertically on their side
teamABase: [{ col: 2, row: 6 }],
teamBBase: [{ col: 18, row: 6 }],
// Spawn points for Team A (left side, near base)
teamASpawns: [
{ col: 1, row: 5 },
{ col: 0, row: 6 },
{ col: 1, row: 7 },
],
// Spawn points for Team B (right side, near base)
teamBSpawns: [
{ col: 19, row: 5 },
{ col: 20, row: 6 },
{ col: 19, row: 7 },
],
},
{
id: 2,
name: 'Battlefield II',
grid: [
[0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0],
[0,0,0,0,0,0,1,0,0,4,0,4,0,0,1,0,0,0,0,0,0],
[0,0,0,0,0,0,0,0,1,4,0,4,1,0,0,0,0,0,0,0,0],
[0,0,0,0,0,1,0,0,0,0,0,0,0,0,0,1,0,0,0,0,0],
[0,0,0,0,0,0,0,2,0,1,0,1,0,2,0,0,0,0,0,0,0],
[0,6,6,0,0,0,0,0,0,0,3,0,0,0,0,0,0,0,6,6,0],
[0,6,5,6,1,0,2,0,0,0,3,0,0,0,2,0,1,6,5,6,0],
[0,6,6,0,0,0,0,0,0,0,3,0,0,0,0,0,0,0,6,6,0],
[0,0,0,0,0,0,0,2,0,1,0,1,0,2,0,0,0,0,0,0,0],
[0,0,0,0,0,1,0,0,0,0,0,0,0,0,0,1,0,0,0,0,0],
[0,0,0,0,0,0,0,0,1,4,0,4,1,0,0,0,0,0,0,0,0],
[0,0,0,0,0,0,1,0,0,4,0,4,0,0,1,0,0,0,0,0,0],
[0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0],
],
teamABase: [{ col: 2, row: 6 }],
teamBBase: [{ col: 18, row: 6 }],
teamASpawns: [
{ col: 1, row: 5 },
{ col: 0, row: 6 },
{ col: 1, row: 7 },
],
teamBSpawns: [
{ col: 19, row: 5 },
{ col: 20, row: 6 },
{ col: 19, row: 7 },
],
},
{
id: 3,
name: 'Battlefield III',
grid: [
[0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0],
[0,0,0,0,1,0,0,2,0,0,0,0,0,2,0,0,1,0,0,0,0],
[0,0,0,0,0,0,0,0,0,1,0,1,0,0,0,0,0,0,0,0,0],
[0,0,0,0,0,0,1,0,3,0,0,0,3,0,1,0,0,0,0,0,0],
[0,0,0,0,0,0,0,0,3,0,2,0,3,0,0,0,0,0,0,0,0],
[0,6,6,1,0,2,0,0,0,0,0,0,0,0,0,2,0,1,6,6,0],
[0,6,5,6,0,0,0,1,0,4,4,4,0,1,0,0,0,6,5,6,0],
[0,6,6,1,0,2,0,0,0,0,0,0,0,0,0,2,0,1,6,6,0],
[0,0,0,0,0,0,0,0,3,0,2,0,3,0,0,0,0,0,0,0,0],
[0,0,0,0,0,0,1,0,3,0,0,0,3,0,1,0,0,0,0,0,0],
[0,0,0,0,0,0,0,0,0,1,0,1,0,0,0,0,0,0,0,0,0],
[0,0,0,0,1,0,0,2,0,0,0,0,0,2,0,0,1,0,0,0,0],
[0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0],
],
teamABase: [{ col: 2, row: 6 }],
teamBBase: [{ col: 18, row: 6 }],
teamASpawns: [
{ col: 1, row: 5 },
{ col: 0, row: 6 },
{ col: 1, row: 7 },
],
teamBSpawns: [
{ col: 19, row: 5 },
{ col: 20, row: 6 },
{ col: 19, row: 7 },
],
},
];
/**
* Get a 3v3 team map by id or random.
* @param {number} [mapId] - Optional map id
* @returns {object} Team map data (deep clone).
*/
function getTeamMap(mapId) {
let map;
if (mapId) {
map = TEAM_MAPS.find((m) => m.id === mapId);
}
if (!map) {
map = TEAM_MAPS[Math.floor(Math.random() * TEAM_MAPS.length)];
}
return JSON.parse(JSON.stringify(map));
}
module.exports = { LEVELS, getLevelData, PVP_MAPS, getPvpMap, TEAM_MAPS, getTeamMap };
+2
View File
@@ -0,0 +1,2 @@
// SkinData - DEPRECATED (removed in monetization-lite)
module.exports = {};
+275
View File
@@ -0,0 +1,275 @@
/**
* BotTank.js
* AI-controlled bot tank for 3v3 team mode.
* Used to fill empty slots when matchmaking times out,
* or to take over disconnected players.
* Reuses EnemyTank-style AI logic adapted for team play.
*/
const Tank = require('./Tank');
const {
TANK_CONFIG,
TANK_TYPE,
DIRECTION,
DIR_VECTORS,
TILE_SIZE,
MAP_OFFSET_X,
MAP_OFFSET_Y,
} = require('../base/GameGlobal');
/** AI States */
const BOT_STATE = {
PATROL: 'patrol',
ATTACK_BASE: 'attack_base',
DEFEND: 'defend',
};
class BotTank extends Tank {
/**
* @param {object} params
* @param {number} params.col - Spawn grid column.
* @param {number} params.row - Spawn grid row.
* @param {string} params.team - 'A' or 'B'.
* @param {string} params.playerId - Bot player id.
* @param {string} [params.color] - Tank color override.
*/
constructor(params) {
const cfg = TANK_CONFIG[TANK_TYPE.PLAYER];
const spawnX = MAP_OFFSET_X + params.col * TILE_SIZE + TILE_SIZE / 2;
const spawnY = MAP_OFFSET_Y + params.row * TILE_SIZE + TILE_SIZE / 2;
super({
x: spawnX,
y: spawnY,
speed: cfg.speed * 0.9, // Slightly slower than human players
hp: cfg.hp,
color: params.color || (params.team === 'A' ? '#4A90D9' : '#E94560'),
size: cfg.size,
direction: params.team === 'A' ? DIRECTION.RIGHT : DIRECTION.LEFT,
});
this.team = params.team;
this.playerId = params.playerId;
this.lives = 999; // Unlimited lives for 3v3
// AI state
this._botState = BOT_STATE.PATROL;
this._moveTimer = 0;
this._dirChangeInterval = 1.5 + Math.random() * 2;
this._shootTimer = 0;
this._shootInterval = 1.2 + Math.random() * 1.5;
this._stuckTimer = 0;
this._lastX = spawnX;
this._lastY = spawnY;
// Active bullets tracking
this.activeBullets = 0;
this._maxBullets = 1;
// Target (enemy base position)
this._targetBase = null;
// Shield (invincibility) — same as PlayerTank
this._shieldTimer = 0;
this._shieldBlink = false;
this._blinkTimer = 0;
}
/**
* Activate shield (invincibility).
* @param {number} duration - Duration in ms.
*/
activateShield(duration) {
this._shieldTimer = duration;
this._blinkTimer = 0;
this._shieldBlink = false;
}
/**
* Update bot tank state (shield timer etc.).
* @param {number} dt - Delta time in seconds.
*/
update(dt) {
if (!this.alive) return;
// Shield timer (same as PlayerTank.update)
if (this._shieldTimer > 0) {
this._shieldTimer -= dt * 1000;
this._blinkTimer += dt * 1000;
if (this._blinkTimer >= 100) {
this._blinkTimer = 0;
this._shieldBlink = !this._shieldBlink;
}
if (this._shieldTimer <= 0) {
this._shieldTimer = 0;
this._shieldBlink = false;
}
}
}
/**
* Override takeDamage to check shield.
* @param {number} amount
* @returns {boolean} Whether destroyed.
*/
takeDamage(amount = 1) {
if (this._shieldTimer > 0) return false; // invincible
return super.takeDamage(amount);
}
/**
* Set the target base position for the bot to attack.
* @param {{x: number, y: number}} basePos
*/
setTargetBase(basePos) {
this._targetBase = basePos;
}
/**
* Update bot AI and state.
* @param {number} dt - Delta time in seconds.
* @param {MapManager} mapManager
* @param {Function} [onShoot] - Callback to fire a bullet.
*/
updateAI(dt, mapManager, onShoot) {
if (!this.alive) return;
// Movement AI
this._moveTimer += dt;
this._shootTimer += dt;
// Check if stuck
const moved = Math.abs(this.x - this._lastX) + Math.abs(this.y - this._lastY);
if (moved < 0.5) {
this._stuckTimer += dt;
} else {
this._stuckTimer = 0;
}
this._lastX = this.x;
this._lastY = this.y;
// Determine AI behavior
if (Math.random() < 0.5 && this._targetBase) {
this._botState = BOT_STATE.ATTACK_BASE;
} else {
this._botState = BOT_STATE.PATROL;
}
// Direction change
if (this._moveTimer >= this._dirChangeInterval || this._stuckTimer > 0.5) {
this._moveTimer = 0;
this._stuckTimer = 0;
this._chooseDirection(mapManager);
}
// Move
this.move(this.direction, dt, mapManager);
// Shoot
if (this._shootTimer >= this._shootInterval) {
this._shootTimer = 0;
this._shootInterval = 1 + Math.random() * 1.5;
if (this.activeBullets < this._maxBullets && onShoot) {
onShoot(this);
}
}
}
/**
* Choose a new direction based on AI state.
* @private
*/
_chooseDirection(mapManager) {
if (this._botState === BOT_STATE.ATTACK_BASE && this._targetBase) {
this._chaseTarget(this._targetBase, mapManager);
} else {
this._randomDirection();
}
}
/**
* Chase a target position (enemy base).
* @private
*/
_chaseTarget(target, mapManager) {
const dx = target.x - this.x;
const dy = target.y - this.y;
const dirs = [];
if (Math.abs(dx) > Math.abs(dy)) {
dirs.push(dx > 0 ? DIRECTION.RIGHT : DIRECTION.LEFT);
dirs.push(dy > 0 ? DIRECTION.DOWN : DIRECTION.UP);
} else {
dirs.push(dy > 0 ? DIRECTION.DOWN : DIRECTION.UP);
dirs.push(dx > 0 ? DIRECTION.RIGHT : DIRECTION.LEFT);
}
const allDirs = [DIRECTION.UP, DIRECTION.DOWN, DIRECTION.LEFT, DIRECTION.RIGHT];
for (const d of allDirs) {
if (!dirs.includes(d)) dirs.push(d);
}
// Try each direction, pick the first that isn't blocked
for (const dir of dirs) {
const vec = DIR_VECTORS[dir];
const testX = this.x + vec.dx * TILE_SIZE;
const testY = this.y + vec.dy * TILE_SIZE;
const left = testX - this.halfSize;
const top = testY - this.halfSize;
if (mapManager && !mapManager.rectCollidesWithTerrain(left, top, this.size, this.size)) {
this.direction = dir;
return;
}
}
// Fallback: random
this.direction = allDirs[Math.floor(Math.random() * allDirs.length)];
}
/**
* Choose a random direction with bias towards enemy base side.
* @private
*/
_randomDirection() {
const dirs = [DIRECTION.UP, DIRECTION.DOWN, DIRECTION.LEFT, DIRECTION.RIGHT];
// Bias towards enemy base direction
if (Math.random() < 0.4) {
// Team A bots go right, Team B bots go left
this.direction = this.team === 'A' ? DIRECTION.RIGHT : DIRECTION.LEFT;
return;
}
this.direction = dirs[Math.floor(Math.random() * dirs.length)];
}
/** Whether this bot can fire. */
canFire() {
return this.alive && this.activeBullets < this._maxBullets;
}
/** Check if this bot can break steel (always false for bots). */
canBreakSteel() {
return false;
}
/**
* Render with bot indicator.
* @param {CanvasRenderingContext2D} ctx
*/
render(ctx) {
if (!this.alive) return;
super.render(ctx);
// Bot indicator (small robot icon above tank)
ctx.fillStyle = '#AAAAAA';
ctx.font = '8px Arial';
ctx.textAlign = 'center';
ctx.textBaseline = 'middle';
ctx.fillText('🤖', this.x, this.y - this.halfSize - 6);
}
}
module.exports = BotTank;
+119
View File
@@ -0,0 +1,119 @@
/**
* Bullet.js
* Bullet entity that travels in a straight line and interacts with terrain/tanks.
*/
const {
BULLET_SPEED,
BULLET_SIZE,
DIRECTION,
DIR_VECTORS,
MAP_OFFSET_X,
MAP_OFFSET_Y,
MAP_WIDTH,
MAP_HEIGHT,
} = require('../base/GameGlobal');
class Bullet {
constructor() {
this.x = 0;
this.y = 0;
this.direction = DIRECTION.UP;
this.speed = BULLET_SPEED;
this.size = BULLET_SIZE;
this.halfSize = BULLET_SIZE / 2;
this.alive = false;
this.canBreakSteel = false;
/** @type {'player'|'enemy'} */
this.owner = 'player';
/** @type {object|null} Reference to the tank that fired this bullet */
this.ownerTank = null;
}
/**
* Initialize/reset the bullet for reuse from object pool.
* @param {object} config
* @param {number} config.x
* @param {number} config.y
* @param {number} config.direction
* @param {string} config.owner - 'player' or 'enemy'
* @param {boolean} [config.canBreakSteel]
* @param {object} [config.ownerTank]
*/
init(config) {
this.x = config.x;
this.y = config.y;
this.direction = config.direction;
this.owner = config.owner || 'player';
this.canBreakSteel = config.canBreakSteel || false;
this.ownerTank = config.ownerTank || null;
this.alive = true;
}
/**
* Update bullet position.
* @param {number} dt - Delta time in seconds.
*/
update(dt) {
if (!this.alive) return;
const vec = DIR_VECTORS[this.direction];
const moveAmount = this.speed * dt * 60;
this.x += vec.dx * moveAmount;
this.y += vec.dy * moveAmount;
// Check map boundaries
if (
this.x < MAP_OFFSET_X ||
this.y < MAP_OFFSET_Y ||
this.x > MAP_OFFSET_X + MAP_WIDTH ||
this.y > MAP_OFFSET_Y + MAP_HEIGHT
) {
this.destroy();
}
}
/**
* Render the bullet.
* @param {CanvasRenderingContext2D} ctx
*/
render(ctx) {
if (!this.alive) return;
ctx.fillStyle = this.owner === 'player' ? '#FFFF00' : '#FF6600';
ctx.fillRect(
this.x - this.halfSize,
this.y - this.halfSize,
this.size,
this.size
);
}
/**
* Get bounding box.
* @returns {{x: number, y: number, w: number, h: number}}
*/
getBounds() {
return {
x: this.x - this.halfSize,
y: this.y - this.halfSize,
w: this.size,
h: this.size,
};
}
/**
* Destroy the bullet (mark for recycling).
*/
destroy() {
this.alive = false;
// Decrement owner's active bullet count
if (this.ownerTank && typeof this.ownerTank.activeBullets === 'number') {
this.ownerTank.activeBullets = Math.max(0, this.ownerTank.activeBullets - 1);
}
}
}
module.exports = Bullet;
+269
View File
@@ -0,0 +1,269 @@
/**
* EnemyTank.js
* Enemy tank with AI behavior: patrol, chase, and attack states.
*/
const Tank = require('./Tank');
const {
TANK_TYPE,
TANK_CONFIG,
DIRECTION,
DIR_VECTORS,
GRID_COLS,
GRID_ROWS,
TILE_SIZE,
MAP_OFFSET_X,
MAP_OFFSET_Y,
} = require('../base/GameGlobal');
/** AI States */
const AI_STATE = {
PATROL: 'patrol',
CHASE: 'chase',
ATTACK: 'attack',
};
class EnemyTank extends Tank {
/**
* @param {object} params
* @param {string} params.type - TANK_TYPE enum value.
* @param {number} params.col - Spawn grid column.
* @param {number} params.row - Spawn grid row.
* @param {number} [params.levelNum] - Current level number (affects AI).
* @param {boolean} [params.hasPowerUp] - Whether destroying this tank drops a power-up.
*/
constructor(params) {
const cfg = TANK_CONFIG[params.type];
const spawnX = MAP_OFFSET_X + params.col * TILE_SIZE + TILE_SIZE / 2;
const spawnY = MAP_OFFSET_Y + params.row * TILE_SIZE + TILE_SIZE / 2;
const speedMul = params.speedMultiplier || 1;
super({
x: spawnX,
y: spawnY,
speed: cfg.speed * speedMul,
hp: cfg.hp,
color: cfg.color,
size: cfg.size,
direction: DIRECTION.DOWN,
});
this.type = params.type;
this.score = cfg.score || 100;
this.hasPowerUp = params.hasPowerUp || false;
this.levelNum = params.levelNum || 1;
// AI state
this._aiState = AI_STATE.PATROL;
this._moveTimer = 0;
this._dirChangeInterval = 1.5 + Math.random() * 2; // seconds
this._shootTimer = 0;
this._shootInterval = 1 + Math.random() * 1.5; // seconds
this._stuckTimer = 0;
this._lastX = spawnX;
this._lastY = spawnY;
// Frozen state (from clock power-up)
this.frozen = false;
// Active bullets tracking
this.activeBullets = 0;
this._maxBullets = params.type === TANK_TYPE.ENEMY_BOSS ? 2 : 1;
// HP indicator blink for armored tanks
this._hitBlink = 0;
}
/**
* Update enemy tank AI and state.
* @param {number} dt - Delta time in seconds.
* @param {MapManager} mapManager
* @param {{x: number, y: number}} basePos - Base position for targeting.
* @param {Function} onShoot - Callback to fire a bullet.
*/
update(dt, mapManager, basePos, onShoot) {
if (!this.alive || this.frozen) return;
// Hit blink effect
if (this._hitBlink > 0) {
this._hitBlink -= dt;
}
// Movement AI
this._moveTimer += dt;
this._shootTimer += dt;
// Check if stuck
const moved = Math.abs(this.x - this._lastX) + Math.abs(this.y - this._lastY);
if (moved < 0.5) {
this._stuckTimer += dt;
} else {
this._stuckTimer = 0;
}
this._lastX = this.x;
this._lastY = this.y;
// Determine AI behavior based on level
if (this.levelNum >= 10 && this.type !== TANK_TYPE.ENEMY_NORMAL) {
this._aiState = AI_STATE.CHASE;
} else if (Math.random() < 0.3) {
this._aiState = AI_STATE.CHASE;
} else {
this._aiState = AI_STATE.PATROL;
}
// Direction change
if (this._moveTimer >= this._dirChangeInterval || this._stuckTimer > 0.5) {
this._moveTimer = 0;
this._stuckTimer = 0;
this._chooseDirection(mapManager, basePos);
}
// Move
this.move(this.direction, dt, mapManager);
// Shoot
if (this._shootTimer >= this._shootInterval) {
this._shootTimer = 0;
this._shootInterval = 0.8 + Math.random() * 1.5;
if (this.activeBullets < this._maxBullets && onShoot) {
onShoot(this);
}
}
}
/**
* Choose a new direction based on AI state.
* @private
*/
_chooseDirection(mapManager, basePos) {
if (this._aiState === AI_STATE.CHASE && basePos) {
// Move towards base
this._chaseTarget(basePos, mapManager);
} else {
// Random patrol
this._randomDirection(mapManager);
}
}
/**
* Chase a target position (usually the base).
* @private
*/
_chaseTarget(target, mapManager) {
const dx = target.x - this.x;
const dy = target.y - this.y;
// Prefer the axis with greater distance
const dirs = [];
if (Math.abs(dy) > Math.abs(dx)) {
dirs.push(dy > 0 ? DIRECTION.DOWN : DIRECTION.UP);
dirs.push(dx > 0 ? DIRECTION.RIGHT : DIRECTION.LEFT);
} else {
dirs.push(dx > 0 ? DIRECTION.RIGHT : DIRECTION.LEFT);
dirs.push(dy > 0 ? DIRECTION.DOWN : DIRECTION.UP);
}
// Add random alternatives for variety
const allDirs = [DIRECTION.UP, DIRECTION.DOWN, DIRECTION.LEFT, DIRECTION.RIGHT];
for (const d of allDirs) {
if (!dirs.includes(d)) dirs.push(d);
}
// Try each direction, pick the first that isn't immediately blocked
for (const dir of dirs) {
const vec = DIR_VECTORS[dir];
const testX = this.x + vec.dx * TILE_SIZE;
const testY = this.y + vec.dy * TILE_SIZE;
const left = testX - this.halfSize;
const top = testY - this.halfSize;
if (!mapManager.rectCollidesWithTerrain(left, top, this.size, this.size)) {
this.direction = dir;
return;
}
}
// Fallback: random
this.direction = allDirs[Math.floor(Math.random() * allDirs.length)];
}
/**
* Choose a random direction.
* @private
*/
_randomDirection(mapManager) {
const dirs = [DIRECTION.UP, DIRECTION.DOWN, DIRECTION.LEFT, DIRECTION.RIGHT];
// Bias towards down (towards base) 40% of the time
if (Math.random() < 0.4) {
this.direction = DIRECTION.DOWN;
return;
}
this.direction = dirs[Math.floor(Math.random() * dirs.length)];
}
/**
* Override takeDamage to add hit blink.
*/
takeDamage(amount = 1) {
this._hitBlink = 0.15;
return super.takeDamage(amount);
}
/**
* Render with HP indicator for armored tanks.
* @param {CanvasRenderingContext2D} ctx
*/
render(ctx) {
if (!this.alive) return;
// Hit blink effect
if (this._hitBlink > 0) {
ctx.save();
ctx.globalAlpha = 0.5;
super.render(ctx);
ctx.restore();
} else {
super.render(ctx);
}
// Power-up indicator (flashing border)
if (this.hasPowerUp) {
ctx.save();
ctx.strokeStyle = '#FF0000';
ctx.lineWidth = 2;
const blink = Math.sin(Date.now() / 150) > 0;
if (blink) {
ctx.strokeRect(
this.x - this.halfSize - 2,
this.y - this.halfSize - 2,
this.size + 4,
this.size + 4
);
}
ctx.restore();
}
// HP bar for armored/boss tanks
if (this.maxHp > 1) {
const barW = this.size;
const barH = 3;
const barX = this.x - this.halfSize;
const barY = this.y - this.halfSize - 6;
ctx.fillStyle = '#333333';
ctx.fillRect(barX, barY, barW, barH);
ctx.fillStyle = this.hp > this.maxHp * 0.3 ? '#00FF00' : '#FF0000';
ctx.fillRect(barX, barY, barW * (this.hp / this.maxHp), barH);
}
}
/** Whether this enemy can fire. */
canFire() {
return this.alive && !this.frozen && this.activeBullets < this._maxBullets;
}
}
module.exports = EnemyTank;
+104
View File
@@ -0,0 +1,104 @@
/**
* Explosion.js
* Simple explosion effect using frame-based animation.
* Managed via object pool for performance.
*/
class Explosion {
constructor() {
this.x = 0;
this.y = 0;
this.alive = false;
this.size = 0;
this.maxSize = 30;
this._timer = 0;
this._duration = 0.3; // seconds
this._phase = 0; // 0 to 1
this._isBig = false; // big explosion for tank destruction
}
/**
* Initialize the explosion.
* @param {number} x - Center X.
* @param {number} y - Center Y.
* @param {boolean} [isBig=false] - Whether this is a large explosion (tank death).
*/
init(x, y, isBig = false) {
this.x = x;
this.y = y;
this.alive = true;
this._timer = 0;
this._phase = 0;
this._isBig = isBig;
this.maxSize = isBig ? 50 : 25;
this._duration = isBig ? 0.5 : 0.3;
}
/**
* Update explosion animation.
* @param {number} dt - Delta time in seconds.
*/
update(dt) {
if (!this.alive) return;
this._timer += dt;
this._phase = this._timer / this._duration;
if (this._phase >= 1) {
this.alive = false;
return;
}
// Size grows then shrinks
if (this._phase < 0.4) {
this.size = this.maxSize * (this._phase / 0.4);
} else {
this.size = this.maxSize * (1 - (this._phase - 0.4) / 0.6);
}
}
/**
* Render the explosion.
* @param {CanvasRenderingContext2D} ctx
*/
render(ctx) {
if (!this.alive) return;
ctx.save();
const alpha = 1 - this._phase * 0.5;
ctx.globalAlpha = alpha;
// Outer glow
if (this._isBig) {
ctx.fillStyle = '#FF4500';
ctx.beginPath();
ctx.arc(this.x, this.y, this.size * 1.2, 0, Math.PI * 2);
ctx.fill();
}
// Main explosion
ctx.fillStyle = '#FF8C00';
ctx.beginPath();
ctx.arc(this.x, this.y, this.size, 0, Math.PI * 2);
ctx.fill();
// Inner bright core
ctx.fillStyle = '#FFFF00';
ctx.beginPath();
ctx.arc(this.x, this.y, this.size * 0.5, 0, Math.PI * 2);
ctx.fill();
// White center flash (early phase only)
if (this._phase < 0.3) {
ctx.fillStyle = '#FFFFFF';
ctx.beginPath();
ctx.arc(this.x, this.y, this.size * 0.2, 0, Math.PI * 2);
ctx.fill();
}
ctx.restore();
}
}
module.exports = Explosion;
+209
View File
@@ -0,0 +1,209 @@
/**
* PlayerTank.js
* Player-controlled tank with fire level, lives, shield, and respawn logic.
*/
const Tank = require('./Tank');
const {
TANK_TYPE,
TANK_CONFIG,
FIRE_LEVEL,
MAX_BULLETS_BY_LEVEL,
DIRECTION,
DEFAULT_LIVES,
SHIELD_DURATION,
INVINCIBLE_BLINK_INTERVAL,
TILE_SIZE,
MAP_OFFSET_X,
MAP_OFFSET_Y,
} = require('../base/GameGlobal');
class PlayerTank extends Tank {
/**
* @param {object} params
* @param {number} params.col - Spawn grid column.
* @param {number} params.row - Spawn grid row.
*/
constructor(params) {
const cfg = TANK_CONFIG[TANK_TYPE.PLAYER];
const spawnX = MAP_OFFSET_X + params.col * TILE_SIZE + TILE_SIZE / 2;
const spawnY = MAP_OFFSET_Y + params.row * TILE_SIZE + TILE_SIZE / 2;
super({
x: spawnX,
y: spawnY,
speed: cfg.speed,
hp: cfg.hp,
color: cfg.color,
size: cfg.size,
direction: DIRECTION.UP,
});
this.type = TANK_TYPE.PLAYER;
this.spawnCol = params.col;
this.spawnRow = params.row;
// Skin colors (reserved for future use)
this._skinColors = null;
// Fire level system
this.fireLevel = FIRE_LEVEL.LV1;
// Lives
this.lives = DEFAULT_LIVES;
// Shield (invincibility)
this._shieldTimer = 0; // ms remaining
this._shieldBlink = false;
this._blinkTimer = 0;
// Active bullets count (managed externally)
this.activeBullets = 0;
// Respawn invincibility (short shield on spawn)
this._respawnShieldDuration = 3000; // 3 seconds on respawn
}
/**
* Update player tank state.
* @param {number} dt - Delta time in seconds.
*/
update(dt) {
if (!this.alive) return;
// Shield timer
if (this._shieldTimer > 0) {
this._shieldTimer -= dt * 1000;
this._blinkTimer += dt * 1000;
if (this._blinkTimer >= INVINCIBLE_BLINK_INTERVAL) {
this._blinkTimer = 0;
this._shieldBlink = !this._shieldBlink;
}
if (this._shieldTimer <= 0) {
this._shieldTimer = 0;
this._shieldBlink = false;
}
}
}
/**
* Override takeDamage to check shield.
* @param {number} amount
* @returns {boolean} Whether destroyed.
*/
takeDamage(amount = 1) {
if (this._shieldTimer > 0) return false; // invincible
return super.takeDamage(amount);
}
/**
* Handle player death: lose a life and respawn, or game over.
* @returns {boolean} True if player has lives remaining and respawned.
*/
die() {
this.alive = false;
this.lives--;
if (this.lives > 0) {
this.respawn();
return true;
}
return false; // game over
}
/**
* Respawn at the spawn point with temporary invincibility.
*/
respawn() {
this.x = MAP_OFFSET_X + this.spawnCol * TILE_SIZE + TILE_SIZE / 2;
this.y = MAP_OFFSET_Y + this.spawnRow * TILE_SIZE + TILE_SIZE / 2;
this.direction = DIRECTION.UP;
this.hp = 1;
this.alive = true;
this.visible = true;
this.fireLevel = FIRE_LEVEL.LV1;
this.activeBullets = 0;
// Temporary shield on respawn
this.activateShield(this._respawnShieldDuration);
}
/**
* Activate shield (invincibility).
* @param {number} duration - Duration in ms.
*/
activateShield(duration) {
this._shieldTimer = duration;
this._blinkTimer = 0;
this._shieldBlink = false;
}
/**
* Upgrade fire level.
*/
upgradeFireLevel() {
if (this.fireLevel < FIRE_LEVEL.LV3) {
this.fireLevel++;
}
}
/**
* Add a life.
*/
addLife() {
this.lives++;
}
/**
* Check if the player can fire (based on active bullets and fire level).
* @returns {boolean}
*/
canFire() {
return this.alive && this.activeBullets < MAX_BULLETS_BY_LEVEL[this.fireLevel];
}
/**
* Whether the bullet should break steel.
* @returns {boolean}
*/
canBreakSteel() {
return this.fireLevel >= FIRE_LEVEL.LV3;
}
/**
* Render player tank with shield effect.
* @param {CanvasRenderingContext2D} ctx
*/
render(ctx) {
if (!this.alive) return;
// Call base render
super.render(ctx);
// Draw shield effect
if (this._shieldTimer > 0 && this._shieldBlink) {
ctx.save();
ctx.strokeStyle = '#00FFFF';
ctx.lineWidth = 2;
ctx.globalAlpha = 0.6;
ctx.beginPath();
ctx.arc(this.x, this.y, this.halfSize + 4, 0, Math.PI * 2);
ctx.stroke();
ctx.restore();
}
}
/** Whether the player is currently shielded. */
get isShielded() {
return this._shieldTimer > 0;
}
/** Get max bullets allowed on screen. */
get maxBullets() {
return MAX_BULLETS_BY_LEVEL[this.fireLevel];
}
}
module.exports = PlayerTank;
+188
View File
@@ -0,0 +1,188 @@
/**
* PowerUp.js
* Power-up item entity with type, position, timer, and blink animation.
*/
const {
POWERUP_TYPE,
POWERUP_DURATION,
TILE_SIZE,
MAP_OFFSET_X,
MAP_OFFSET_Y,
MAP_WIDTH,
MAP_HEIGHT,
GRID_COLS,
GRID_ROWS,
} = require('../base/GameGlobal');
/** Power-up visual config */
const POWERUP_VISUALS = {
[POWERUP_TYPE.STAR]: { emoji: '⭐', color: '#FFD700', label: 'STAR' },
[POWERUP_TYPE.CLOCK]: { emoji: '🕒', color: '#87CEEB', label: 'CLOCK' },
[POWERUP_TYPE.BOMB]: { emoji: '💣', color: '#FF4500', label: 'BOMB' },
[POWERUP_TYPE.HELMET]: { emoji: '🛡️', color: '#00CED1', label: 'SHIELD' },
[POWERUP_TYPE.SHOVEL]: { emoji: '🏠', color: '#8B4513', label: 'SHOVEL' },
[POWERUP_TYPE.TANK]: { emoji: '+1', color: '#32CD32', label: 'LIFE' },
};
class PowerUp {
/**
* @param {string} type - POWERUP_TYPE value.
* @param {number} x - Pixel X.
* @param {number} y - Pixel Y.
*/
constructor(type, x, y) {
this.type = type;
this.x = x;
this.y = y;
this.alive = true;
this.size = TILE_SIZE * 0.9;
this.halfSize = this.size / 2;
this._timer = 0;
this._duration = POWERUP_DURATION;
this._blinkStart = POWERUP_DURATION * 0.7; // start blinking at 70% of duration
this._visible = true;
this._blinkTimer = 0;
}
/**
* Update power-up timer and blink.
* @param {number} dt - Delta time in seconds.
*/
update(dt) {
if (!this.alive) return;
this._timer += dt * 1000;
// Blink when about to expire
if (this._timer >= this._blinkStart) {
this._blinkTimer += dt * 1000;
if (this._blinkTimer >= 150) {
this._blinkTimer = 0;
this._visible = !this._visible;
}
}
// Expire
if (this._timer >= this._duration) {
this.alive = false;
}
}
/**
* Render the power-up.
* @param {CanvasRenderingContext2D} ctx
*/
render(ctx) {
if (!this.alive || !this._visible) return;
const visual = POWERUP_VISUALS[this.type];
const x = this.x - this.halfSize;
const y = this.y - this.halfSize;
// Background glow
ctx.save();
ctx.globalAlpha = 0.3;
ctx.fillStyle = visual.color;
ctx.beginPath();
ctx.arc(this.x, this.y, this.halfSize + 4, 0, Math.PI * 2);
ctx.fill();
ctx.restore();
// Background box
ctx.fillStyle = '#000000';
ctx.globalAlpha = 0.7;
ctx.fillRect(x, y, this.size, this.size);
ctx.globalAlpha = 1;
// Border
ctx.strokeStyle = visual.color;
ctx.lineWidth = 2;
ctx.strokeRect(x, y, this.size, this.size);
// Icon/text
ctx.fillStyle = visual.color;
ctx.font = `bold ${Math.floor(this.size * 0.5)}px Arial`;
ctx.textAlign = 'center';
ctx.textBaseline = 'middle';
if (this.type === POWERUP_TYPE.TANK) {
ctx.fillText('+1', this.x, this.y);
} else {
ctx.font = `${Math.floor(this.size * 0.6)}px Arial`;
ctx.fillText(visual.emoji, this.x, this.y);
}
}
/**
* Get bounding box for collision.
* @returns {{x: number, y: number, w: number, h: number}}
*/
getBounds() {
return {
x: this.x - this.halfSize,
y: this.y - this.halfSize,
w: this.size,
h: this.size,
};
}
/**
* Generate a random position on the map (avoiding terrain).
* @param {MapManager} mapManager
* @returns {{x: number, y: number}}
*/
static randomPosition(mapManager) {
let attempts = 0;
while (attempts < 50) {
const col = Math.floor(Math.random() * GRID_COLS);
const row = Math.floor(Math.random() * (GRID_ROWS - 2)) + 1; // avoid top/bottom rows
const terrain = mapManager.getTerrain(row, col);
// Only place on empty tiles
if (terrain === 0) { // TERRAIN.EMPTY
return {
x: MAP_OFFSET_X + col * TILE_SIZE + TILE_SIZE / 2,
y: MAP_OFFSET_Y + row * TILE_SIZE + TILE_SIZE / 2,
};
}
attempts++;
}
// Fallback: center of map
return {
x: MAP_OFFSET_X + MAP_WIDTH / 2,
y: MAP_OFFSET_Y + MAP_HEIGHT / 2,
};
}
/**
* Get a random power-up type based on level-adjusted probabilities.
* @param {number} levelNum - Current level number.
* @returns {string} POWERUP_TYPE value.
*/
static randomType(levelNum) {
// Base probabilities (weights)
const weights = {
[POWERUP_TYPE.STAR]: Math.max(10, 30 - levelNum), // decreases with level
[POWERUP_TYPE.CLOCK]: 15,
[POWERUP_TYPE.BOMB]: 10,
[POWERUP_TYPE.HELMET]: 15,
[POWERUP_TYPE.SHOVEL]: 10,
[POWERUP_TYPE.TANK]: 10,
};
const totalWeight = Object.values(weights).reduce((a, b) => a + b, 0);
let rand = Math.random() * totalWeight;
for (const [type, weight] of Object.entries(weights)) {
rand -= weight;
if (rand <= 0) return type;
}
return POWERUP_TYPE.STAR; // fallback
}
}
module.exports = PowerUp;
+375
View File
@@ -0,0 +1,375 @@
/**
* Tank.js
* Base class for all tanks (player and enemy).
* Handles position, direction, movement, rendering, and collision box.
*/
const {
TILE_SIZE,
MAP_OFFSET_X,
MAP_OFFSET_Y,
MAP_WIDTH,
MAP_HEIGHT,
DIRECTION,
DIR_VECTORS,
} = require('../base/GameGlobal');
class Tank {
/**
* @param {object} config
* @param {number} config.x - Pixel X position (center).
* @param {number} config.y - Pixel Y position (center).
* @param {number} config.speed - Movement speed (pixels per frame at 60fps).
* @param {number} config.hp - Hit points.
* @param {string} config.color - Fill color.
* @param {number} config.size - Tank size in pixels.
* @param {number} [config.direction] - Initial direction.
*/
constructor(config) {
this.x = config.x;
this.y = config.y;
this.speed = config.speed || 2;
this.hp = config.hp || 1;
this.maxHp = config.hp || 1;
this.color = config.color || '#FFFFFF';
this.size = config.size || TILE_SIZE * 0.85;
this.direction = config.direction !== undefined ? config.direction : DIRECTION.UP;
this.alive = true;
this.visible = true;
// Half-size for collision calculations
this.halfSize = this.size / 2;
}
/**
* Move the tank in a direction.
* @param {number} dir - DIRECTION enum value.
* @param {number} dt - Delta time in seconds.
* @param {MapManager} mapManager - For collision checking.
* @returns {boolean} Whether the tank actually moved.
*/
move(dir, dt, mapManager) {
if (!this.alive) return false;
const prevDir = this.direction;
this.direction = dir;
// When changing direction, snap to nearest grid alignment first
// and do NOT advance forward this frame — classic Battle City behavior.
if (prevDir !== dir) {
this._snapToGrid(prevDir);
return false;
}
const vec = DIR_VECTORS[dir];
const moveAmount = this.speed * dt * 60; // normalize to 60fps
let newX = this.x + vec.dx * moveAmount;
let newY = this.y + vec.dy * moveAmount;
// Clamp to map boundaries instead of rejecting movement entirely.
// This allows the tank to slide along the edge smoothly.
const minX = MAP_OFFSET_X + this.halfSize;
const minY = MAP_OFFSET_Y + this.halfSize;
const maxX = MAP_OFFSET_X + MAP_WIDTH - this.halfSize;
const maxY = MAP_OFFSET_Y + MAP_HEIGHT - this.halfSize;
newX = Math.max(minX, Math.min(newX, maxX));
newY = Math.max(minY, Math.min(newY, maxY));
// If position didn't change after clamping, we're stuck at the boundary
if (newX === this.x && newY === this.y) {
return false;
}
// Calculate bounding box at clamped position
const left = newX - this.halfSize;
const top = newY - this.halfSize;
// Terrain collision check
if (mapManager && mapManager.rectCollidesWithTerrain(left, top, this.size, this.size)) {
// Try to align to grid for smoother movement along walls
return this._tryAlignedMove(dir, dt, mapManager);
}
this.x = newX;
this.y = newY;
return true;
}
/**
* Snap the tank center to the nearest grid-cell center on the axis
* of the OLD direction. This prevents the tank from "drifting" when
* turning and ensures clean grid-aligned movement.
* @param {number} oldDir - The direction the tank was facing before turning.
* @private
*/
_snapToGrid(oldDir) {
const halfTile = TILE_SIZE / 2;
const minX = MAP_OFFSET_X + this.halfSize;
const minY = MAP_OFFSET_Y + this.halfSize;
const maxX = MAP_OFFSET_X + MAP_WIDTH - this.halfSize;
const maxY = MAP_OFFSET_Y + MAP_HEIGHT - this.halfSize;
if (oldDir === DIRECTION.UP || oldDir === DIRECTION.DOWN) {
// Was moving vertically → snap Y to nearest grid-cell center
const rowExact = (this.y - MAP_OFFSET_Y - halfTile) / TILE_SIZE;
const nearestRow = Math.round(rowExact);
let alignedY = MAP_OFFSET_Y + nearestRow * TILE_SIZE + halfTile;
// Clamp to map bounds so snapping doesn't push tank outside
alignedY = Math.max(minY, Math.min(alignedY, maxY));
if (Math.abs(alignedY - this.y) < TILE_SIZE * 0.5) {
this.y = alignedY;
}
} else {
// Was moving horizontally → snap X to nearest grid-cell center
const colExact = (this.x - MAP_OFFSET_X - halfTile) / TILE_SIZE;
const nearestCol = Math.round(colExact);
let alignedX = MAP_OFFSET_X + nearestCol * TILE_SIZE + halfTile;
// Clamp to map bounds so snapping doesn't push tank outside
alignedX = Math.max(minX, Math.min(alignedX, maxX));
if (Math.abs(alignedX - this.x) < TILE_SIZE * 0.5) {
this.x = alignedX;
}
}
}
/**
* Try to move with grid alignment (helps navigate around corners).
* When blocked, find the nearest gap in the perpendicular axis and slide
* the tank towards it so the player can smoothly pass through openings
* between bricks — classic Battle City "snap-to-gap" behaviour.
* @private
*/
_tryAlignedMove(dir, dt, mapManager) {
const moveAmount = this.speed * dt * 60;
const vec = DIR_VECTORS[dir];
const halfTile = TILE_SIZE / 2;
if (dir === DIRECTION.UP || dir === DIRECTION.DOWN) {
// Moving vertically but blocked — try to slide horizontally into a gap
// Check two candidate column alignments (left-snap and right-snap)
const colExact = (this.x - MAP_OFFSET_X - halfTile) / TILE_SIZE;
const colLeft = Math.floor(colExact);
const colRight = Math.ceil(colExact);
const candidates = [];
for (const col of [colLeft, colRight]) {
const alignedX = MAP_OFFSET_X + col * TILE_SIZE + halfTile;
const diffX = alignedX - this.x;
// Only consider if the offset is within a comfortable snap threshold
if (Math.abs(diffX) < TILE_SIZE * 0.55) {
// Check whether moving in the desired direction would be clear at this aligned X
const testX = alignedX;
const testY = this.y + vec.dy * moveAmount;
const left = testX - this.halfSize;
const top = testY - this.halfSize;
if (
left >= MAP_OFFSET_X &&
top >= MAP_OFFSET_Y &&
left + this.size <= MAP_OFFSET_X + MAP_WIDTH &&
top + this.size <= MAP_OFFSET_Y + MAP_HEIGHT &&
!mapManager.rectCollidesWithTerrain(left, top, this.size, this.size)
) {
candidates.push({ alignedX, diffX: Math.abs(diffX) });
}
}
}
if (candidates.length > 0) {
// Pick the closest gap
candidates.sort((a, b) => a.diffX - b.diffX);
const best = candidates[0];
const diffX = best.alignedX - this.x;
const slideAmount = Math.min(Math.abs(diffX), moveAmount);
this.x += Math.sign(diffX) * slideAmount;
// Clamp to map bounds after sliding
this.x = Math.max(MAP_OFFSET_X + this.halfSize, Math.min(this.x, MAP_OFFSET_X + MAP_WIDTH - this.halfSize));
return false;
}
// No gap found — just do a basic grid-align slide
const gridCol = Math.round(colExact);
const alignedX = MAP_OFFSET_X + gridCol * TILE_SIZE + halfTile;
const diffX = alignedX - this.x;
if (Math.abs(diffX) < TILE_SIZE * 0.4) {
const slideAmount = Math.min(Math.abs(diffX), moveAmount);
this.x += Math.sign(diffX) * slideAmount;
// Clamp to map bounds after sliding
this.x = Math.max(MAP_OFFSET_X + this.halfSize, Math.min(this.x, MAP_OFFSET_X + MAP_WIDTH - this.halfSize));
}
} else {
// Moving horizontally but blocked — try to slide vertically into a gap
const rowExact = (this.y - MAP_OFFSET_Y - halfTile) / TILE_SIZE;
const rowUp = Math.floor(rowExact);
const rowDown = Math.ceil(rowExact);
const candidates = [];
for (const row of [rowUp, rowDown]) {
const alignedY = MAP_OFFSET_Y + row * TILE_SIZE + halfTile;
const diffY = alignedY - this.y;
if (Math.abs(diffY) < TILE_SIZE * 0.55) {
const testX = this.x + vec.dx * moveAmount;
const testY = alignedY;
const left = testX - this.halfSize;
const top = testY - this.halfSize;
if (
left >= MAP_OFFSET_X &&
top >= MAP_OFFSET_Y &&
left + this.size <= MAP_OFFSET_X + MAP_WIDTH &&
top + this.size <= MAP_OFFSET_Y + MAP_HEIGHT &&
!mapManager.rectCollidesWithTerrain(left, top, this.size, this.size)
) {
candidates.push({ alignedY, diffY: Math.abs(diffY) });
}
}
}
if (candidates.length > 0) {
candidates.sort((a, b) => a.diffY - b.diffY);
const best = candidates[0];
const diffY = best.alignedY - this.y;
const slideAmount = Math.min(Math.abs(diffY), moveAmount);
this.y += Math.sign(diffY) * slideAmount;
// Clamp to map bounds after sliding
this.y = Math.max(MAP_OFFSET_Y + this.halfSize, Math.min(this.y, MAP_OFFSET_Y + MAP_HEIGHT - this.halfSize));
return false;
}
// No gap found — basic grid-align slide
const gridRow = Math.round(rowExact);
const alignedY = MAP_OFFSET_Y + gridRow * TILE_SIZE + halfTile;
const diffY = alignedY - this.y;
if (Math.abs(diffY) < TILE_SIZE * 0.4) {
const slideAmount = Math.min(Math.abs(diffY), moveAmount);
this.y += Math.sign(diffY) * slideAmount;
// Clamp to map bounds after sliding
this.y = Math.max(MAP_OFFSET_Y + this.halfSize, Math.min(this.y, MAP_OFFSET_Y + MAP_HEIGHT - this.halfSize));
}
}
return false;
}
/**
* Take damage.
* @param {number} [amount=1]
* @returns {boolean} Whether the tank was destroyed.
*/
takeDamage(amount = 1) {
if (!this.alive) return false;
this.hp -= amount;
if (this.hp <= 0) {
this.hp = 0;
this.alive = false;
return true; // destroyed
}
return false;
}
/**
* Render the tank.
* @param {CanvasRenderingContext2D} ctx
*/
render(ctx) {
if (!this.alive || !this.visible) return;
ctx.save();
ctx.translate(this.x, this.y);
// Rotate based on direction
const angles = {
[DIRECTION.UP]: 0,
[DIRECTION.DOWN]: Math.PI,
[DIRECTION.LEFT]: -Math.PI / 2,
[DIRECTION.RIGHT]: Math.PI / 2,
};
ctx.rotate(angles[this.direction]);
const hs = this.halfSize;
// Determine colors: use skin colors if this is a player tank with a skin
let bodyColor = this.color;
let turretColor = this._darkenColor(this.color, 0.3);
let trackColor = this._darkenColor(this.color, 0.4);
if (this._skinColors) {
bodyColor = this._skinColors.body || bodyColor;
turretColor = this._skinColors.turret || turretColor;
trackColor = this._skinColors.track || trackColor;
}
// Tank body
ctx.fillStyle = bodyColor;
ctx.fillRect(-hs, -hs, this.size, this.size);
// Tank turret (barrel)
const barrelW = this.size * 0.15;
const barrelH = this.size * 0.5;
ctx.fillStyle = turretColor;
ctx.fillRect(-barrelW / 2, -hs - barrelH, barrelW, barrelH);
// Tank body detail (center square)
const innerSize = this.size * 0.4;
ctx.fillStyle = this._darkenColor(bodyColor, 0.2);
ctx.fillRect(-innerSize / 2, -innerSize / 2, innerSize, innerSize);
// Tracks
const trackW = this.size * 0.12;
ctx.fillStyle = trackColor;
ctx.fillRect(-hs, -hs, trackW, this.size);
ctx.fillRect(hs - trackW, -hs, trackW, this.size);
ctx.restore();
}
/**
* Get the axis-aligned bounding box.
* @returns {{x: number, y: number, w: number, h: number}}
*/
getBounds() {
return {
x: this.x - this.halfSize,
y: this.y - this.halfSize,
w: this.size,
h: this.size,
};
}
/**
* Check collision with another tank.
* @param {Tank} other
* @returns {boolean}
*/
collidesWith(other) {
if (!this.alive || !other.alive) return false;
const a = this.getBounds();
const b = other.getBounds();
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
);
}
/**
* Darken a hex color.
* @param {string} hex
* @param {number} factor - 0 to 1
* @returns {string}
*/
_darkenColor(hex, factor) {
const r = parseInt(hex.slice(1, 3), 16);
const g = parseInt(hex.slice(3, 5), 16);
const b = parseInt(hex.slice(5, 7), 16);
const dr = Math.floor(r * (1 - factor));
const dg = Math.floor(g * (1 - factor));
const db = Math.floor(b * (1 - factor));
return `rgb(${dr},${dg},${db})`;
}
}
module.exports = Tank;
+91
View File
@@ -0,0 +1,91 @@
/**
* I18n.js
* Internationalization module for Tank Adventure.
* Auto-detects language from WeChat system info.
* Supports {variable} placeholder interpolation.
*/
const zhLang = require('./zh');
const enLang = require('./en');
// ============================================================
// Language Detection
// ============================================================
let _currentLang = 'en'; // default fallback
try {
const sysInfo = wx.getSystemInfoSync();
const lang = (sysInfo.language || '').toLowerCase();
// zh_CN, zh_TW, zh_HK, etc.
if (lang.startsWith('zh')) {
_currentLang = 'zh';
}
} catch (e) {
// Fallback to English if wx API is unavailable
_currentLang = 'en';
}
const _langPacks = {
zh: zhLang,
en: enLang,
};
// ============================================================
// Translation Function
// ============================================================
/**
* Get translated text by key.
* Supports {variable} placeholder interpolation.
*
* @param {string} key - The translation key, e.g. 'menu.title'
* @param {Object} [params] - Optional parameters for interpolation
* @returns {string} The translated text
*
* @example
* t('menu.title') // => '坦克探险' or 'Tank Adventure'
* t('pvp.hp', { count: 3 }) // => '生命 x3' or 'HP x3'
*/
function t(key, params) {
// Try current language first
let text = _langPacks[_currentLang] && _langPacks[_currentLang][key];
// Fallback to English
if (text === undefined && _currentLang !== 'en') {
text = _langPacks.en[key];
}
// Fallback to key itself
if (text === undefined) {
return key;
}
// Interpolate {variable} placeholders
if (params) {
text = text.replace(/\{(\w+)\}/g, (match, name) => {
return params[name] !== undefined ? String(params[name]) : match;
});
}
return text;
}
/**
* Get the current language code.
* @returns {string} 'zh' or 'en'
*/
function getLang() {
return _currentLang;
}
/**
* Set the language manually (for testing or future settings).
* @param {string} lang - 'zh' or 'en'
*/
function setLang(lang) {
if (_langPacks[lang]) {
_currentLang = lang;
}
}
module.exports = { t, getLang, setLang };
+272
View File
@@ -0,0 +1,272 @@
/**
* en.js
* English language pack for Tank Adventure.
*/
module.exports = {
// ============================================================
// Common
// ============================================================
'common.back': '← Back',
'common.joinBtn': 'Join',
'common.cannotConnect': 'Cannot connect to server',
'common.connectFailed': 'Connection failed',
'common.disconnected': 'Disconnected from server',
'common.paused': 'PAUSED',
'common.tapContinue': 'Tap to continue',
'common.kicked': 'You have been kicked from the team',
// ============================================================
// Menu Scene
// ============================================================
'menu.title': 'Tank Adventure',
'menu.subtitle': 'TANK WAR',
'menu.classic': 'Classic',
'menu.endless': 'Endless',
'menu.pvp': 'PVP',
'menu.team3v3': '3v3 Battle',
'menu.shop': 'Shop',
'menu.ranking': 'Ranking',
'menu.settings': 'Settings',
// ============================================================
// Room Scene (PVP)
// ============================================================
'room.title': 'PVP Battle',
'room.idleHint': 'Create a room or join with a code',
'room.create': 'Create Room',
'room.join': 'Join Room',
'room.connecting': 'Connecting{dots}',
'room.roomCode': 'Room Code:',
'room.waiting': 'Waiting for opponent{dots}',
'room.shareHint': 'Share the room code with your friend',
'room.inputCode': 'Enter Room Code:',
'room.opponentFound': 'Opponent found!',
'room.starting': 'Game starting...',
'room.tapBack': 'Tap anywhere to go back',
// ============================================================
// Team Room Scene (3v3)
// ============================================================
'teamRoom.title': '3v3 Team Battle',
'teamRoom.chooseMode': 'Choose how to play',
'teamRoom.createTeam': '🎮 Create Team',
'teamRoom.soloMatch': '⚡ Quick Match',
'teamRoom.teamId': 'Team: {id}',
'teamRoom.leader': 'Leader',
'teamRoom.ready': '✓ Ready',
'teamRoom.notReady': 'Not Ready',
'teamRoom.emptySlot': 'Empty',
'teamRoom.invite': '📨 Invite',
'teamRoom.startMatch': '🔍 Start Match',
'teamRoom.disband': 'Disband',
'teamRoom.readyBtn': '✓ Ready',
'teamRoom.cancelReady': 'Cancel Ready',
'teamRoom.leaveTeam': 'Leave Team',
'teamRoom.matching': 'Matching{dots}',
'teamRoom.waitTime': 'Waited {seconds}s',
'teamRoom.cancelMatch': 'Cancel Match',
'teamRoom.matchFound': 'Match found!',
'teamRoom.enterBattle': 'Entering battle...',
'teamRoom.tapBack': 'Tap anywhere to go back',
'teamRoom.shareTitle': 'Tank 3v3, join the battle!',
'teamRoom.joining': 'Joining room',
// ============================================================
// PVP Game Scene
// ============================================================
'pvp.playerLabel': 'P{slot} (You)',
'pvp.hp': 'HP x{count}',
'pvp.kills': 'Kills: {count}',
'pvp.killDeath': 'K:{kills} D:{deaths}',
'pvp.respawn': 'Respawning in {seconds}s',
'pvp.youWin': 'YOU WIN!',
'pvp.draw': 'DRAW',
'pvp.youLose': 'YOU LOSE',
'pvp.baseHpSummary': 'P1: {hp1} HP | P2: {hp2} HP',
// ============================================================
// Team Game Scene (3v3)
// ============================================================
'team.teamA': 'Team A',
'team.teamB': 'Team B',
'team.myTeam': 'You: {team} Team',
'team.killDeath': 'K:{kills} D:{deaths}',
'team.respawn': 'Respawning in {seconds}s',
'team.victory': 'VICTORY!',
'team.defeat': 'DEFEAT',
'team.baseHpSummary': 'Team A: {hpA} HP | Team B: {hpB} HP',
'team.disconnectTitle': '⚠ Connection Lost',
'team.reconnecting': 'Reconnecting{dots} ({attempts}/{max})',
'team.reconnectHint': 'Please wait, your tank will be controlled by AI',
// ============================================================
// PVP Result Scene
// ============================================================
'pvpResult.title': 'MATCH RESULT',
'pvpResult.victory': '🏆 VICTORY!',
'pvpResult.draw': '⚔️ DRAW',
'pvpResult.defeat': '😵 DEFEAT',
'pvpResult.kills': 'Kills',
'pvpResult.deaths': 'Deaths',
'pvpResult.lives': 'Lives',
'pvpResult.baseDmg': 'Base DMG',
'pvpResult.p1BaseHp': 'P1: {hp} HP',
'pvpResult.p2BaseHp': 'P2: {hp} HP',
'pvpResult.baseDestroyed': 'Base Destroyed',
'pvpResult.disconnectedReason': 'Disconnected',
'pvpResult.duration': 'Match duration: {time}',
'pvpResult.timeRemaining': 'Time remaining: {time}',
'pvpResult.rematch': 'Rematch',
'pvpResult.backMenu': 'Back to Menu',
// ============================================================
// Team Result Scene (3v3)
// ============================================================
'teamResult.title': '3v3 MATCH RESULT',
'teamResult.victory': '🏆 VICTORY!',
'teamResult.defeat': '😵 DEFEAT',
'teamResult.teamAHp': 'Team A: {hp} HP',
'teamResult.teamBHp': 'Team B: {hp} HP',
'teamResult.baseDestroyed': 'Base Destroyed',
'teamResult.disconnectedReason': 'Disconnected',
'teamResult.teamAHeader': 'Team A',
'teamResult.teamBHeader': 'Team B',
'teamResult.myTeamSuffix': ' (You)',
'teamResult.player': 'Player',
'teamResult.k': 'K',
'teamResult.d': 'D',
'teamResult.a': 'A',
'teamResult.dmg': 'DMG',
'teamResult.bot': '🤖 Bot',
'teamResult.duration': 'Match duration: {time}',
'teamResult.mvp': '⭐ MVP: {name} ({kills} kills)',
'teamResult.rankUp': '📈 Rank +{points}',
'teamResult.mvpBonus': '(MVP bonus +5)',
'teamResult.rankDown': '📉 Rank -{points}',
'teamResult.rematch': 'Rematch',
'teamResult.rematchWaiting': 'Waiting({ready}/{total})',
'teamResult.backMenu': 'Back to Menu',
// ============================================================
// Game Scene (Classic/Endless)
// ============================================================
'game.level': 'Level {level}',
'game.hp': 'HP x{count}',
'game.fireLevel': 'LV{level}',
'game.enemies': 'Enemies: {count}',
'game.score': '{score}pts',
'game.gameOver': 'GAME OVER',
'game.stageClear': 'STAGE CLEAR!',
// ============================================================
// Result Scene
// ============================================================
'result.victory': '🎉 STAGE CLEAR!',
'result.defeat': '😵 GAME OVER',
'result.level': 'Level {level}',
'result.killStats': 'Kill Statistics',
'result.tankNormal': 'Normal',
'result.tankFast': 'Fast',
'result.tankArmor': 'Armor',
'result.tankBoss': 'BOSS',
'result.totalLabel': 'Total',
'result.rowKills': 'Kills',
'result.rowScore': 'Score',
'result.totalScore': 'Total: {score}',
'result.time': 'Time: {minutes}m{seconds}s',
'result.baseAlive': 'Base: ✅ Intact',
'result.baseDestroyed': 'Base: ❌ Destroyed',
'result.newRecord': '🎊 New Record!',
'result.doubled': '2x!',
'result.share': '📤 Share Challenge',
'result.adDouble': '🎬 Watch Ad for 2x Score',
'result.nextLevel': 'Next Level →',
'result.retry': 'Retry',
'result.backMenu': 'Back to Menu',
// ============================================================
// Ranking Scene
// ============================================================
'ranking.title': '🏆 Ranking',
'ranking.personalRecord': '— Personal Records —',
'ranking.classicHigh': 'Classic Mode High Score',
'ranking.endlessHigh': 'Endless Mode High Score',
'ranking.highestLevel': 'Highest Level Cleared',
'ranking.levelSuffix': 'Lv',
'ranking.scoreSuffix': 'pts',
'ranking.friendHint': 'Friend ranking requires WeChat Open Data Domain',
// ============================================================
// Settings Scene
// ============================================================
'settings.title': 'Settings',
'settings.sound': 'Sound',
'settings.music': 'Music',
'settings.vibration': 'Vibration',
// ============================================================
// Shop Scene (Simplified)
// ============================================================
'shop.title': 'Shop',
'shop.goldBalance': 'Gold Balance',
'shop.adFree': 'Remove Ads',
'shop.adFreeDesc': 'Permanently remove interstitial ads',
'shop.adFreeOwned': 'Owned',
'shop.goldPack': 'Gold Pack',
'shop.goldPackDesc': '1000 Gold',
'shop.newcomerPack': 'Newcomer Pack',
'shop.newcomerPackDesc': '500 Gold',
'shop.newcomerExpired': 'Expired',
'shop.buy': 'Buy',
'shop.purchased': 'Purchased',
// ============================================================
// Ad System
// ============================================================
'ad.reviveTitle': 'Revive Chance',
'ad.reviveDesc': 'Choose how to revive and continue',
'ad.watchAd': '📺 Watch Ad (Free)',
'ad.goldRevive': '🪙 Gold Revive (200)',
'ad.giveUp': 'Give Up',
'ad.doubleReward': '🎬 Watch Ad for 2x Reward',
'ad.unavailable': 'Ad temporarily unavailable',
'ad.dailyLimitReached': 'Daily ad recovery limit reached',
// ============================================================
// Currency (Simplified - Gold only)
// ============================================================
'currency.gold': 'Gold',
'currency.insufficient': 'Insufficient Gold',
'currency.full': 'Gold is full',
// ============================================================
// IAP Products (Simplified)
// ============================================================
'iap.adFree': 'Remove Ads (¥18 Permanent)',
'iap.goldPack': 'Gold Pack (¥6)',
'iap.newcomerPack': 'Newcomer Pack (¥1)',
// ============================================================
// Buff System
// ============================================================
'buff.title': 'Pre-Game Buffs',
'buff.shield': '🛡️ Shield',
'buff.shieldDesc': 'Start with a shield layer',
'buff.doubleFire': '🔥 Double Fire',
'buff.doubleFireDesc': '2x bullet power for 10s',
'buff.skip': 'Skip →',
'buff.start': 'Start Game',
'buff.purchased': 'Purchased',
'buff.goldInsufficient': 'Insufficient Gold',
// ============================================================
// Daily Gold
// ============================================================
'dailyGold.btn': '🪙 Get Gold',
'dailyGold.remaining': '{remaining}/3',
'dailyGold.exhausted': 'Come back tomorrow',
'dailyGold.reward': '+100 Gold!',
};
+272
View File
@@ -0,0 +1,272 @@
/**
* zh.js
* Chinese language pack for Tank Adventure.
*/
module.exports = {
// ============================================================
// Common
// ============================================================
'common.back': '← 返回',
'common.joinBtn': '加入',
'common.cannotConnect': '无法连接服务器',
'common.connectFailed': '连接失败',
'common.disconnected': '与服务器断开连接',
'common.paused': '暂停',
'common.tapContinue': '点击继续',
'common.kicked': '你已被踢出队伍',
// ============================================================
// Menu Scene
// ============================================================
'menu.title': '坦克探险',
'menu.subtitle': '经典坦克对战',
'menu.classic': '经典模式',
'menu.endless': '无尽模式',
'menu.pvp': '双人对战',
'menu.team3v3': '3v3 对战',
'menu.shop': '商店',
'menu.ranking': '排行榜',
'menu.settings': '设置',
// ============================================================
// Room Scene (PVP)
// ============================================================
'room.title': '双人对战',
'room.idleHint': '创建房间或输入房间号加入',
'room.create': '创建房间',
'room.join': '加入房间',
'room.connecting': '连接中{dots}',
'room.roomCode': '房间号:',
'room.waiting': '等待对手加入{dots}',
'room.shareHint': '将房间号分享给好友',
'room.inputCode': '输入房间号:',
'room.opponentFound': '对手已找到!',
'room.starting': '即将开始...',
'room.tapBack': '点击任意位置返回',
// ============================================================
// Team Room Scene (3v3)
// ============================================================
'teamRoom.title': '3v3 团队对战',
'teamRoom.chooseMode': '选择游戏方式',
'teamRoom.createTeam': '🎮 组队开黑',
'teamRoom.soloMatch': '⚡ 快速匹配',
'teamRoom.teamId': '队伍:{id}',
'teamRoom.leader': '队长',
'teamRoom.ready': '✓ 已准备',
'teamRoom.notReady': '未准备',
'teamRoom.emptySlot': '空位',
'teamRoom.invite': '📨 邀请好友',
'teamRoom.startMatch': '🔍 开始匹配',
'teamRoom.disband': '解散队伍',
'teamRoom.readyBtn': '✓ 准备',
'teamRoom.cancelReady': '取消准备',
'teamRoom.leaveTeam': '退出队伍',
'teamRoom.matching': '匹配中{dots}',
'teamRoom.waitTime': '已等待 {seconds} 秒',
'teamRoom.cancelMatch': '取消匹配',
'teamRoom.matchFound': '对手已找到!',
'teamRoom.enterBattle': '即将进入战斗...',
'teamRoom.tapBack': '点击任意位置返回',
'teamRoom.shareTitle': '坦克3v3,速来开黑!',
'teamRoom.joining': '正在加入房间',
// ============================================================
// PVP Game Scene
// ============================================================
'pvp.playerLabel': 'P{slot} (我方)',
'pvp.hp': '生命 x{count}',
'pvp.kills': '击杀:{count}',
'pvp.killDeath': '杀:{kills} 亡:{deaths}',
'pvp.respawn': '{seconds}秒后重生',
'pvp.youWin': '你赢了!',
'pvp.draw': '平局',
'pvp.youLose': '你输了',
'pvp.baseHpSummary': 'P1{hp1} 生命 | P2{hp2} 生命',
// ============================================================
// Team Game Scene (3v3)
// ============================================================
'team.teamA': 'A队',
'team.teamB': 'B队',
'team.myTeam': '我方:{team}队',
'team.killDeath': '杀:{kills} 亡:{deaths}',
'team.respawn': '{seconds}秒后重生',
'team.victory': '胜利!',
'team.defeat': '失败',
'team.baseHpSummary': 'A队:{hpA} 生命 | B队:{hpB} 生命',
'team.disconnectTitle': '⚠ 连接断开',
'team.reconnecting': '重连中{dots} ({attempts}/{max})',
'team.reconnectHint': '请稍候,您的坦克将由AI代管',
// ============================================================
// PVP Result Scene
// ============================================================
'pvpResult.title': '对战结果',
'pvpResult.victory': '🏆 胜利!',
'pvpResult.draw': '⚔️ 平局',
'pvpResult.defeat': '😵 失败',
'pvpResult.kills': '击杀',
'pvpResult.deaths': '死亡',
'pvpResult.lives': '生命',
'pvpResult.baseDmg': '阵地伤害',
'pvpResult.p1BaseHp': 'P1{hp} 生命',
'pvpResult.p2BaseHp': 'P2{hp} 生命',
'pvpResult.baseDestroyed': '基地被摧毁',
'pvpResult.disconnectedReason': '断线',
'pvpResult.duration': '对战时长:{time}',
'pvpResult.timeRemaining': '剩余时间:{time}',
'pvpResult.rematch': '再来一局',
'pvpResult.backMenu': '返回菜单',
// ============================================================
// Team Result Scene (3v3)
// ============================================================
'teamResult.title': '3v3 对战结果',
'teamResult.victory': '🏆 胜利!',
'teamResult.defeat': '😵 失败',
'teamResult.teamAHp': 'A队:{hp} 生命',
'teamResult.teamBHp': 'B队:{hp} 生命',
'teamResult.baseDestroyed': '基地被摧毁',
'teamResult.disconnectedReason': '断线',
'teamResult.teamAHeader': 'A队',
'teamResult.teamBHeader': 'B队',
'teamResult.myTeamSuffix': ' (我方)',
'teamResult.player': '玩家',
'teamResult.k': '杀',
'teamResult.d': '亡',
'teamResult.a': '助',
'teamResult.dmg': '伤害',
'teamResult.bot': '🤖 机器人',
'teamResult.duration': '对战时长:{time}',
'teamResult.mvp': '⭐ MVP{name}{kills} 击杀)',
'teamResult.rankUp': '📈 积分 +{points}',
'teamResult.mvpBonus': 'MVP加成 +5',
'teamResult.rankDown': '📉 积分 -{points}',
'teamResult.rematch': '再来一局',
'teamResult.rematchWaiting': '等待中({ready}/{total})',
'teamResult.backMenu': '返回菜单',
// ============================================================
// Game Scene (Classic/Endless)
// ============================================================
'game.level': '第 {level} 关',
'game.hp': '生命 x{count}',
'game.fireLevel': 'LV{level}',
'game.enemies': '敌人: {count}',
'game.score': '{score}分',
'game.gameOver': '游戏结束',
'game.stageClear': '关卡通过!',
// ============================================================
// Result Scene
// ============================================================
'result.victory': '🎉 关卡通过!',
'result.defeat': '😵 游戏结束',
'result.level': '第 {level} 关',
'result.killStats': '击杀统计',
'result.tankNormal': '普通',
'result.tankFast': '快速',
'result.tankArmor': '重甲',
'result.tankBoss': 'BOSS',
'result.totalLabel': '汇总',
'result.rowKills': '击杀',
'result.rowScore': '得分',
'result.totalScore': '总分: {score}',
'result.time': '用时: {minutes}分{seconds}秒',
'result.baseAlive': '基地: ✅ 完好',
'result.baseDestroyed': '基地: ❌ 被毁',
'result.newRecord': '🎊 新纪录!',
'result.doubled': '双倍!',
'result.share': '📤 分享挑战书',
'result.adDouble': '🎬 看广告双倍得分',
'result.nextLevel': '下一关 →',
'result.retry': '重新开始',
'result.backMenu': '返回主菜单',
// ============================================================
// Ranking Scene
// ============================================================
'ranking.title': '🏆 排行榜',
'ranking.personalRecord': '— 个人记录 —',
'ranking.classicHigh': '经典模式最高分',
'ranking.endlessHigh': '无尽模式最高分',
'ranking.highestLevel': '最高通关关卡',
'ranking.levelSuffix': '关',
'ranking.scoreSuffix': '分',
'ranking.friendHint': '好友排行榜需要微信开放数据域支持',
// ============================================================
// Settings Scene
// ============================================================
'settings.title': '设置',
'settings.sound': '音效',
'settings.music': '音乐',
'settings.vibration': '振动',
// ============================================================
// Shop Scene (Simplified)
// ============================================================
'shop.title': '商店',
'shop.goldBalance': '金币余额',
'shop.adFree': '去广告特权',
'shop.adFreeDesc': '永久移除插屏广告',
'shop.adFreeOwned': '已拥有',
'shop.goldPack': '金币包',
'shop.goldPackDesc': '1000 金币',
'shop.newcomerPack': '新手礼包',
'shop.newcomerPackDesc': '500 金币',
'shop.newcomerExpired': '已过期',
'shop.buy': '购买',
'shop.purchased': '已购买',
// ============================================================
// Ad System
// ============================================================
'ad.reviveTitle': '复活机会',
'ad.reviveDesc': '选择复活方式继续游戏',
'ad.watchAd': '📺 观看广告(免费)',
'ad.goldRevive': '🪙 金币复活(200',
'ad.giveUp': '放弃',
'ad.doubleReward': '🎬 看广告双倍奖励',
'ad.unavailable': '广告暂时不可用',
'ad.dailyLimitReached': '今日广告恢复次数已用完',
// ============================================================
// Currency (Simplified - Gold only)
// ============================================================
'currency.gold': '金币',
'currency.insufficient': '金币不足',
'currency.full': '金币已满',
// ============================================================
// IAP Products (Simplified)
// ============================================================
'iap.adFree': '去广告特权(¥18 永久)',
'iap.goldPack': '金币包(¥6',
'iap.newcomerPack': '新手礼包(¥1',
// ============================================================
// Buff System
// ============================================================
'buff.title': '局前增益',
'buff.shield': '🛡️ 护盾',
'buff.shieldDesc': '开局自带一层护盾',
'buff.doubleFire': '🔥 双倍火力',
'buff.doubleFireDesc': '开局10秒子弹威力翻倍',
'buff.skip': '跳过 →',
'buff.start': '开始游戏',
'buff.purchased': '已购买',
'buff.goldInsufficient': '金币不足',
// ============================================================
// Daily Gold
// ============================================================
'dailyGold.btn': '🪙 领金币',
'dailyGold.remaining': '{remaining}/3',
'dailyGold.exhausted': '明日再来',
'dailyGold.reward': '+100 金币!',
};
+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;
+13
View File
@@ -0,0 +1,13 @@
// BattlePassScene - DEPRECATED (removed in monetization-lite)
const { SCENE } = require('../base/GameGlobal');
const BattlePassScene = {
enter() {
// Redirect to menu since battle pass is removed
GameGlobal.sceneManager.switchTo(SCENE.MENU);
},
exit() {},
update() {},
render() {},
handleTouch() {},
};
module.exports = BattlePassScene;
+249
View File
@@ -0,0 +1,249 @@
/**
* BuffSelectScene.js
* Pre-game buff selection screen.
* Allows players to purchase Shield (100g) and/or Double Fire (150g)
* before entering a game level.
*/
const {
SCREEN_WIDTH,
SCREEN_HEIGHT,
COLORS,
SCENE,
} = require('../base/GameGlobal');
const { t } = require('../i18n/I18n');
const BuffManager = require('../managers/BuffManager');
const BUFF_TYPE = BuffManager.BUFF_TYPE;
const BUFF_COST = BuffManager.BUFF_COST;
// Layout constants
const CARD_W = Math.min(SCREEN_WIDTH * 0.38, 160);
const CARD_H = Math.min(SCREEN_HEIGHT * 0.3, 140);
const CARD_GAP = 20;
const CARD_Y = SCREEN_HEIGHT * 0.3;
const BuffSelectScene = {
_gameParams: null, // params to pass to GameScene
_buttons: {},
_buffManager: null,
enter(params) {
this._gameParams = params || {};
this._buffManager = GameGlobal.buffManager;
this._buttons = {};
// Clear any previous buffs
if (this._buffManager) {
this._buffManager.clearBuffs();
}
this._calculateLayout();
},
exit() {},
_calculateLayout() {
const cx = SCREEN_WIDTH / 2;
// Two buff cards side by side
const card1X = cx - CARD_W - CARD_GAP / 2;
const card2X = cx + CARD_GAP / 2;
this._buttons = {
shield: { x: card1X, y: CARD_Y, w: CARD_W, h: CARD_H, type: BUFF_TYPE.SHIELD },
doubleFire: { x: card2X, y: CARD_Y, w: CARD_W, h: CARD_H, type: BUFF_TYPE.DOUBLE_FIRE },
};
// Start/Skip button
const btnW = Math.min(SCREEN_WIDTH * 0.5, 200);
const btnH = 42;
const btnY = CARD_Y + CARD_H + 30;
this._buttons.start = { x: cx - btnW / 2, y: btnY, w: btnW, h: btnH };
this._buttons.skip = { x: cx - btnW / 2, y: btnY + btnH + 12, w: btnW, h: btnH };
},
update(dt) {},
render(ctx) {
// Background
ctx.fillStyle = COLORS.MENU_BG;
ctx.fillRect(0, 0, SCREEN_WIDTH, SCREEN_HEIGHT);
// Title
ctx.fillStyle = COLORS.MENU_TITLE;
ctx.font = 'bold 24px Arial';
ctx.textAlign = 'center';
ctx.textBaseline = 'middle';
ctx.fillText(t('buff.title') || 'Pre-Game Buffs', SCREEN_WIDTH / 2, SCREEN_HEIGHT * 0.12);
// Gold balance
const gold = GameGlobal.currencyManager ? GameGlobal.currencyManager.getGold() : 0;
ctx.fillStyle = '#FFD700';
ctx.font = 'bold 16px Arial';
ctx.fillText(`🪙 ${gold}`, SCREEN_WIDTH / 2, SCREEN_HEIGHT * 0.2);
// Render buff cards
this._renderBuffCard(ctx, this._buttons.shield,
t('buff.shield') || '🛡️ Shield',
t('buff.shieldDesc') || 'Start with a shield layer',
BUFF_COST[BUFF_TYPE.SHIELD],
BUFF_TYPE.SHIELD
);
this._renderBuffCard(ctx, this._buttons.doubleFire,
t('buff.doubleFire') || '🔥 Double Fire',
t('buff.doubleFireDesc') || '2x bullet power for 10s',
BUFF_COST[BUFF_TYPE.DOUBLE_FIRE],
BUFF_TYPE.DOUBLE_FIRE
);
// Start button (if any buff purchased)
const hasBuffs = this._buffManager && this._buffManager.getActiveBuffs().length > 0;
if (hasBuffs) {
this._renderButton(ctx, this._buttons.start, t('buff.start') || 'Start Game', '#4CAF50');
}
// Skip button
this._renderButton(ctx, this._buttons.skip, t('buff.skip') || 'Skip →', '#666666');
},
_renderBuffCard(ctx, rect, title, desc, cost, buffType) {
if (!rect) return;
const purchased = this._buffManager && this._buffManager.hasBuff(buffType);
const canAfford = GameGlobal.currencyManager && GameGlobal.currencyManager.hasGold(cost);
// Card background
ctx.fillStyle = purchased ? 'rgba(76, 175, 80, 0.3)' : 'rgba(255,255,255,0.05)';
ctx.strokeStyle = purchased ? '#4CAF50' : '#444444';
ctx.lineWidth = 2;
// Rounded rect
const r = 10;
ctx.beginPath();
ctx.moveTo(rect.x + r, rect.y);
ctx.lineTo(rect.x + rect.w - r, rect.y);
ctx.arcTo(rect.x + rect.w, rect.y, rect.x + rect.w, rect.y + r, r);
ctx.lineTo(rect.x + rect.w, rect.y + rect.h - r);
ctx.arcTo(rect.x + rect.w, rect.y + rect.h, rect.x + rect.w - r, rect.y + rect.h, r);
ctx.lineTo(rect.x + r, rect.y + rect.h);
ctx.arcTo(rect.x, rect.y + rect.h, rect.x, rect.y + rect.h - r, r);
ctx.lineTo(rect.x, rect.y + r);
ctx.arcTo(rect.x, rect.y, rect.x + r, rect.y, r);
ctx.closePath();
ctx.fill();
ctx.stroke();
const cx = rect.x + rect.w / 2;
// Title
ctx.fillStyle = '#FFFFFF';
ctx.font = 'bold 16px Arial';
ctx.textAlign = 'center';
ctx.fillText(title, cx, rect.y + 30);
// Description
ctx.fillStyle = '#AAAAAA';
ctx.font = '11px Arial';
ctx.fillText(desc, cx, rect.y + 55);
// Cost or status
if (purchased) {
ctx.fillStyle = '#4CAF50';
ctx.font = 'bold 14px Arial';
ctx.fillText(t('buff.purchased') || '✓ Purchased', cx, rect.y + rect.h - 25);
} else {
ctx.fillStyle = canAfford ? '#FFD700' : '#FF4444';
ctx.font = 'bold 14px Arial';
ctx.fillText(`🪙 ${cost}`, cx, rect.y + rect.h - 25);
}
},
_renderButton(ctx, rect, label, color) {
if (!rect) return;
ctx.fillStyle = color;
ctx.strokeStyle = '#333333';
ctx.lineWidth = 1;
const r = 6;
ctx.beginPath();
ctx.moveTo(rect.x + r, rect.y);
ctx.lineTo(rect.x + rect.w - r, rect.y);
ctx.arcTo(rect.x + rect.w, rect.y, rect.x + rect.w, rect.y + r, r);
ctx.lineTo(rect.x + rect.w, rect.y + rect.h - r);
ctx.arcTo(rect.x + rect.w, rect.y + rect.h, rect.x + rect.w - r, rect.y + rect.h, r);
ctx.lineTo(rect.x + r, rect.y + rect.h);
ctx.arcTo(rect.x, rect.y + rect.h, rect.x, rect.y + rect.h - r, r);
ctx.lineTo(rect.x, rect.y + r);
ctx.arcTo(rect.x, rect.y, rect.x + r, rect.y, r);
ctx.closePath();
ctx.fill();
ctx.stroke();
ctx.fillStyle = '#FFFFFF';
ctx.font = 'bold 16px Arial';
ctx.textAlign = 'center';
ctx.textBaseline = 'middle';
ctx.fillText(label, rect.x + rect.w / 2, rect.y + rect.h / 2);
},
_hitTest(tx, ty, rect) {
if (!rect) return false;
return tx >= rect.x && tx <= rect.x + rect.w && ty >= rect.y && ty <= rect.y + rect.h;
},
_startGame() {
const sm = GameGlobal.sceneManager;
if (!sm._scenes.has(SCENE.GAME)) {
const GameScene = require('./GameScene');
sm.register(SCENE.GAME, GameScene);
}
sm.switchTo(SCENE.GAME, this._gameParams);
},
handleTouch(eventType, e) {
if (eventType !== 'touchstart') return;
const touch = e.touches[0];
const tx = touch.clientX;
const ty = touch.clientY;
// Shield card
if (this._hitTest(tx, ty, this._buttons.shield)) {
if (this._buffManager && !this._buffManager.hasBuff(BUFF_TYPE.SHIELD)) {
const result = this._buffManager.purchaseBuff(BUFF_TYPE.SHIELD);
if (!result.success) {
console.log(`[BuffSelectScene] Shield purchase failed: ${result.error}`);
}
}
return;
}
// Double Fire card
if (this._hitTest(tx, ty, this._buttons.doubleFire)) {
if (this._buffManager && !this._buffManager.hasBuff(BUFF_TYPE.DOUBLE_FIRE)) {
const result = this._buffManager.purchaseBuff(BUFF_TYPE.DOUBLE_FIRE);
if (!result.success) {
console.log(`[BuffSelectScene] Double Fire purchase failed: ${result.error}`);
}
}
return;
}
// Start button
if (this._hitTest(tx, ty, this._buttons.start)) {
this._startGame();
return;
}
// Skip button
if (this._hitTest(tx, ty, this._buttons.skip)) {
this._startGame();
return;
}
},
};
module.exports = BuffSelectScene;
+939
View File
@@ -0,0 +1,939 @@
/**
* GameScene.js
* Main battle scene - orchestrates map, tanks, bullets, power-ups, and HUD.
* This is the core gameplay scene that integrates all sub-systems.
*/
const {
SCREEN_WIDTH,
SCREEN_HEIGHT,
COLORS,
SCENE,
GAME_MODE,
MAP_OFFSET_X,
MAP_OFFSET_Y,
MAP_WIDTH,
MAP_HEIGHT,
TILE_SIZE,
GRID_COLS,
GRID_ROWS,
DIRECTION,
DIR_VECTORS,
BULLET_SPEED,
FIRE_LEVEL,
POWERUP_TYPE,
FREEZE_DURATION,
SHIELD_DURATION,
SHOVEL_DURATION,
TANK_TYPE,
TERRAIN,
} = require('../base/GameGlobal');
const ObjectPool = require('../base/ObjectPool');
const MapManager = require('../managers/MapManager');
const CollisionManager = require('../managers/CollisionManager');
const SpawnManager = require('../managers/SpawnManager');
const PlayerTank = require('../entities/PlayerTank');
const Bullet = require('../entities/Bullet');
const Explosion = require('../entities/Explosion');
const PowerUp = require('../entities/PowerUp');
const Joystick = require('../ui/Joystick');
const FireButton = require('../ui/FireButton');
const { getLevelData } = require('../data/LevelData');
const { t } = require('../i18n/I18n');
const GameScene = {
_mode: GAME_MODE.CLASSIC,
_level: 1,
_initialized: false,
_gameOver: false,
_victory: false,
_paused: false,
// Sub-systems
_mapManager: null,
_collisionManager: null,
_spawnManager: null,
_playerTank: null,
_joystick: null,
_fireButton: null,
// Entity lists
_enemies: [],
_bullets: [],
_explosions: [],
_powerUps: [],
// Object pools
_bulletPool: null,
_explosionPool: null,
// Game stats
_stats: null,
_levelStartTime: 0,
_freezeTimer: 0,
// Game over delay
_gameOverDelay: 0,
_gameOverDelayDuration: 2, // seconds
// Revive ad state
_reviveAdUsed: false,
_showingReviveDialog: false,
_reviveDialogButtons: null,
// Buff manager reference
_buffManager: null,
enter(params) {
this._mode = (params && params.mode) || GAME_MODE.CLASSIC;
this._level = (params && params.level) || 1;
this._gameOver = false;
this._victory = false;
this._paused = false;
this._freezeTimer = 0;
this._gameOverDelay = 0;
this._cachedBasePos = null;
this._reviveAdUsed = false;
this._showingReviveDialog = false;
this._reviveDialogButtons = null;
// Initialize buff manager reference
this._buffManager = GameGlobal.buffManager || null;
// Initialize stats
this._stats = {
kills: { normal: 0, fast: 0, armor: 0, boss: 0 },
totalKills: 0,
score: 0,
timeElapsed: 0,
baseAlive: true,
};
// Initialize object pools
this._bulletPool = new ObjectPool(() => new Bullet(), null, 20);
this._explosionPool = new ObjectPool(() => new Explosion(), null, 10);
// Initialize entity lists
this._enemies = [];
this._bullets = [];
this._explosions = [];
this._powerUps = [];
// Initialize map
this._mapManager = new MapManager();
const levelData = getLevelData(this._level);
this._mapManager.loadGrid(levelData.grid);
// Initialize spawn manager
this._spawnManager = new SpawnManager();
this._spawnManager.init(levelData);
// Initialize player
this._playerTank = new PlayerTank({
col: levelData.playerSpawn.col,
row: levelData.playerSpawn.row,
});
this._playerTank.activateShield(3000); // spawn protection
// Safety: ensure player spawn area is clear of blocking terrain
this._clearSpawnArea(levelData.playerSpawn.col, levelData.playerSpawn.row);
// Initialize collision manager
this._collisionManager = new CollisionManager({
mapManager: this._mapManager,
onExplosion: (x, y, isBig) => this._spawnExplosion(x, y, isBig),
eventBus: GameGlobal.eventBus,
});
// Initialize controls
this._joystick = new Joystick();
this._fireButton = new FireButton();
this._fireButton.onFire(() => this._playerFire());
// Event listeners
this._setupEvents();
this._levelStartTime = Date.now();
this._initialized = true;
// Activate pre-game buffs if any were purchased
if (this._buffManager) {
this._buffManager.activateBuffs(this._playerTank);
}
// Preload rewarded video ad for revive/double reward scenes
if (GameGlobal.adManager) {
GameGlobal.adManager.preloadRewardedVideo();
}
console.log(`[GameScene] Level ${this._level} started. Mode: ${this._mode}`);
},
exit() {
this._initialized = false;
this._cleanupEvents();
this._bullets = [];
this._enemies = [];
this._explosions = [];
this._powerUps = [];
// Clear buffs at end of round
if (this._buffManager) {
this._buffManager.clearBuffs();
}
},
_setupEvents() {
const eb = GameGlobal.eventBus;
this._onEnemyDestroyed = (data) => this._handleEnemyDestroyed(data);
this._onPlayerDestroyed = () => this._handlePlayerDestroyed();
this._onBaseDestroyed = () => this._handleBaseDestroyed();
eb.on('enemy:destroyed', this._onEnemyDestroyed);
eb.on('player:destroyed', this._onPlayerDestroyed);
eb.on('base:destroyed', this._onBaseDestroyed);
},
_cleanupEvents() {
const eb = GameGlobal.eventBus;
eb.off('enemy:destroyed', this._onEnemyDestroyed);
eb.off('player:destroyed', this._onPlayerDestroyed);
eb.off('base:destroyed', this._onBaseDestroyed);
},
// ============================================================
// Update
// ============================================================
update(dt) {
if (!this._initialized || this._paused) return;
// Game over delay (show explosion before transitioning)
if (this._gameOver || this._victory) {
this._gameOverDelay += dt;
// Still update explosions during delay
this._updateExplosions(dt);
if (this._gameOverDelay >= this._gameOverDelayDuration) {
this._transitionToResult();
}
return;
}
this._stats.timeElapsed += dt;
// Update map (shovel timer etc.)
this._mapManager.update(dt);
// Update freeze timer
if (this._freezeTimer > 0) {
this._freezeTimer -= dt * 1000;
if (this._freezeTimer < 0) this._freezeTimer = 0;
}
// Player movement
if (this._playerTank.alive && this._joystick.active && this._joystick.direction >= 0) {
this._playerTank.move(this._joystick.direction, dt, this._mapManager);
}
this._playerTank.update(dt);
// Update buff timers
if (this._buffManager) {
this._buffManager.update(dt, this._playerTank);
}
// Spawn enemies (pause spawning while freeze is active)
if (this._freezeTimer <= 0) {
const newEnemy = this._spawnManager.update(dt, this._enemies);
if (newEnemy) {
this._enemies.push(newEnemy);
}
}
// Update enemies — find base position from the grid
const basePos = this._findBasePos();
for (const enemy of this._enemies) {
if (this._freezeTimer > 0) {
enemy.frozen = true;
} else {
enemy.frozen = false;
}
enemy.update(dt, this._mapManager, basePos, (tank) => this._enemyFire(tank));
}
// Update bullets (freeze enemy bullets while freeze is active)
for (const bullet of this._bullets) {
if (this._freezeTimer > 0 && bullet.owner === 'enemy') {
continue; // enemy bullets are frozen
}
bullet.update(dt);
}
// Update power-ups
for (const pu of this._powerUps) {
pu.update(dt);
}
// Collision detection
this._collisionManager.update({
player: this._playerTank,
enemies: this._enemies,
bullets: this._bullets,
});
// Check power-up pickup
this._checkPowerUpPickup();
// Update explosions
this._updateExplosions(dt);
// Cleanup dead entities
this._cleanup();
// Check victory condition
this._checkVictory();
},
// ============================================================
// Render
// ============================================================
render(ctx) {
if (!this._initialized) return;
// Draw game area background
ctx.fillStyle = '#111111';
ctx.fillRect(MAP_OFFSET_X, MAP_OFFSET_Y, MAP_WIDTH, MAP_HEIGHT);
// Render map (terrain layer)
this._mapManager.render(ctx);
// Render power-ups
for (const pu of this._powerUps) {
pu.render(ctx);
}
// Render player
this._playerTank.render(ctx);
// Render enemies
for (const enemy of this._enemies) {
enemy.render(ctx);
}
// Render forest overlay (on top of tanks)
this._mapManager.renderForestOverlay(ctx);
// Render bullets
for (const bullet of this._bullets) {
bullet.render(ctx);
}
// Render explosions
for (const exp of this._explosions) {
exp.render(ctx);
}
// Render HUD
this._renderHUD(ctx);
// Render controls
this._joystick.render(ctx);
this._fireButton.render(ctx);
// Render pause overlay
if (this._paused && !this._showingReviveDialog) {
this._renderPauseOverlay(ctx);
}
// Render revive ad dialog
if (this._showingReviveDialog) {
this._renderReviveDialog(ctx);
}
// Game over text
if (this._gameOver) {
this._renderGameOverText(ctx, t('game.gameOver'));
} else if (this._victory) {
this._renderGameOverText(ctx, t('game.stageClear'));
}
},
// ============================================================
// HUD Rendering
// ============================================================
_renderHUD(ctx) {
// In landscape mode, HUD is rendered on the sides of the map
const leftX = MAP_OFFSET_X - 8; // right edge of left panel
const rightX = MAP_OFFSET_X + MAP_WIDTH + 8; // left edge of right panel
const topY = MAP_OFFSET_Y + 10;
// === Left side panel ===
ctx.fillStyle = COLORS.HUD_TEXT;
ctx.font = 'bold 12px Arial';
ctx.textAlign = 'right';
ctx.textBaseline = 'top';
// Level info
ctx.fillText(t('game.level', { level: this._level }), leftX, topY);
// Player lives
ctx.fillStyle = '#FFD700';
ctx.font = '11px Arial';
ctx.fillText(t('game.hp', { count: this._playerTank.lives }), leftX, topY + 20);
// Fire level
ctx.fillText(t('game.fireLevel', { level: this._playerTank.fireLevel }), leftX, topY + 38);
// === Right side panel ===
ctx.textAlign = 'left';
ctx.textBaseline = 'top';
// Remaining enemies
ctx.fillStyle = COLORS.HUD_TEXT;
ctx.font = 'bold 12px Arial';
const aliveEnemies = this._enemies.filter((e) => e.alive).length;
const remaining = this._spawnManager.remainingToSpawn + aliveEnemies;
ctx.fillText(t('game.enemies', { count: remaining }), rightX, topY);
// Score
ctx.fillStyle = '#FFD700';
ctx.font = '11px Arial';
ctx.fillText(t('game.score', { score: this._stats.score }), rightX, topY + 20);
},
_renderPauseOverlay(ctx) {
ctx.fillStyle = 'rgba(0,0,0,0.6)';
ctx.fillRect(0, 0, SCREEN_WIDTH, SCREEN_HEIGHT);
ctx.fillStyle = '#FFFFFF';
ctx.font = 'bold 28px Arial';
ctx.textAlign = 'center';
ctx.textBaseline = 'middle';
ctx.fillText(t('common.paused'), SCREEN_WIDTH / 2, SCREEN_HEIGHT / 2 - 40);
ctx.font = '16px Arial';
ctx.fillText(t('common.tapContinue'), SCREEN_WIDTH / 2, SCREEN_HEIGHT / 2 + 10);
},
_renderGameOverText(ctx, text) {
ctx.fillStyle = 'rgba(0,0,0,0.5)';
ctx.fillRect(0, 0, SCREEN_WIDTH, SCREEN_HEIGHT);
ctx.fillStyle = text === t('game.gameOver') ? '#FF0000' : '#00FF00';
ctx.font = 'bold 32px Arial';
ctx.textAlign = 'center';
ctx.textBaseline = 'middle';
ctx.fillText(text, SCREEN_WIDTH / 2, SCREEN_HEIGHT / 2);
},
/**
* Render the revive dialog overlay with dual options (ad + gold).
* @private
*/
_renderReviveDialog(ctx) {
const cx = SCREEN_WIDTH / 2;
const cy = SCREEN_HEIGHT / 2;
// Dim background
ctx.fillStyle = 'rgba(0,0,0,0.7)';
ctx.fillRect(0, 0, SCREEN_WIDTH, SCREEN_HEIGHT);
// Dialog box
const boxW = 300;
const boxH = 180;
ctx.fillStyle = 'rgba(30,30,30,0.95)';
ctx.strokeStyle = '#FFD700';
ctx.lineWidth = 2;
ctx.fillRect(cx - boxW / 2, cy - boxH / 2, boxW, boxH);
ctx.strokeRect(cx - boxW / 2, cy - boxH / 2, boxW, boxH);
// Title text
ctx.fillStyle = '#FFFFFF';
ctx.font = 'bold 18px Arial';
ctx.textAlign = 'center';
ctx.textBaseline = 'middle';
ctx.fillText(t('ad.reviveTitle') || 'Revive Chance', cx, cy - 55);
// Subtitle
ctx.fillStyle = '#AAAAAA';
ctx.font = '12px Arial';
ctx.fillText(t('ad.reviveDesc') || 'Choose how to revive and continue', cx, cy - 35);
const btns = this._reviveDialogButtons;
if (btns) {
// Watch Ad button (green) - only show if ad is available
if (btns.watchAd) {
ctx.fillStyle = '#4CAF50';
ctx.fillRect(btns.watchAd.x, btns.watchAd.y, btns.watchAd.w, btns.watchAd.h);
ctx.fillStyle = '#FFFFFF';
ctx.font = 'bold 13px Arial';
ctx.fillText(t('ad.watchAd') || '📺 Watch Ad (Free)', btns.watchAd.x + btns.watchAd.w / 2, btns.watchAd.y + btns.watchAd.h / 2);
}
// Gold Revive button (orange)
if (btns.goldRevive) {
const hasGold = GameGlobal.currencyManager && GameGlobal.currencyManager.hasGold(200);
ctx.fillStyle = hasGold ? '#FF9800' : '#555555';
ctx.fillRect(btns.goldRevive.x, btns.goldRevive.y, btns.goldRevive.w, btns.goldRevive.h);
ctx.fillStyle = '#FFFFFF';
ctx.font = 'bold 13px Arial';
ctx.fillText(t('ad.goldRevive') || '🪙 Gold Revive (200)', btns.goldRevive.x + btns.goldRevive.w / 2, btns.goldRevive.y + btns.goldRevive.h / 2);
}
// Give Up button (gray)
ctx.fillStyle = '#666666';
ctx.fillRect(btns.giveUp.x, btns.giveUp.y, btns.giveUp.w, btns.giveUp.h);
ctx.fillStyle = '#FFFFFF';
ctx.font = 'bold 13px Arial';
ctx.fillText(t('ad.giveUp') || 'Give Up', btns.giveUp.x + btns.giveUp.w / 2, btns.giveUp.y + btns.giveUp.h / 2);
}
},
// ============================================================
// Game Logic
// ============================================================
_playerFire() {
if (!this._playerTank.alive || !this._playerTank.canFire()) return;
if (this._gameOver || this._victory || this._paused) return;
const tank = this._playerTank;
const vec = DIR_VECTORS[tank.direction];
const bullet = this._bulletPool.get();
bullet.init({
x: tank.x + vec.dx * tank.halfSize,
y: tank.y + vec.dy * tank.halfSize,
direction: tank.direction,
owner: 'player',
canBreakSteel: tank.canBreakSteel(),
ownerTank: tank,
});
tank.activeBullets++;
this._bullets.push(bullet);
GameGlobal.audioManager.playSFX('shoot');
},
_enemyFire(enemyTank) {
if (!enemyTank.alive || !enemyTank.canFire()) return;
const vec = DIR_VECTORS[enemyTank.direction];
const bullet = this._bulletPool.get();
bullet.init({
x: enemyTank.x + vec.dx * enemyTank.halfSize,
y: enemyTank.y + vec.dy * enemyTank.halfSize,
direction: enemyTank.direction,
owner: 'enemy',
canBreakSteel: false,
ownerTank: enemyTank,
});
enemyTank.activeBullets++;
this._bullets.push(bullet);
GameGlobal.audioManager.playSFX('shoot');
},
_spawnExplosion(x, y, isBig) {
const exp = this._explosionPool.get();
exp.init(x, y, isBig);
this._explosions.push(exp);
GameGlobal.audioManager.playSFX(isBig ? 'explosion_big' : 'explosion_small');
},
/**
* Clear any blocking terrain at the player spawn area.
* Ensures the tank won't be stuck inside walls on spawn.
* @private
*/
_clearSpawnArea(col, row) {
const terrain = this._mapManager.getTerrain(row, col);
if (terrain !== TERRAIN.EMPTY && terrain !== TERRAIN.FOREST) {
this._mapManager.setTerrain(row, col, TERRAIN.EMPTY);
}
},
/**
* Find the base (eagle) position from the map grid.
* Scans for TERRAIN.BASE and returns its pixel center.
* Result is cached after first call per level.
* @private
* @returns {{x: number, y: number}}
*/
_findBasePos() {
if (this._cachedBasePos) return this._cachedBasePos;
// Scan grid for BASE terrain
for (let r = GRID_ROWS - 1; r >= 0; r--) {
for (let c = 0; c < GRID_COLS; c++) {
if (this._mapManager.getTerrain(r, c) === TERRAIN.BASE) {
this._cachedBasePos = {
x: MAP_OFFSET_X + c * TILE_SIZE + TILE_SIZE / 2,
y: MAP_OFFSET_Y + r * TILE_SIZE + TILE_SIZE / 2,
};
return this._cachedBasePos;
}
}
}
// Fallback: center-bottom
this._cachedBasePos = {
x: MAP_OFFSET_X + Math.floor(GRID_COLS / 2) * TILE_SIZE + TILE_SIZE / 2,
y: MAP_OFFSET_Y + (GRID_ROWS - 1) * TILE_SIZE + TILE_SIZE / 2,
};
return this._cachedBasePos;
},
_updateExplosions(dt) {
for (const exp of this._explosions) {
exp.update(dt);
}
},
_checkPowerUpPickup() {
if (!this._playerTank.alive) return;
const pb = this._playerTank.getBounds();
for (const pu of this._powerUps) {
if (!pu.alive) continue;
const pub = pu.getBounds();
if (
pb.x < pub.x + pub.w &&
pb.x + pb.w > pub.x &&
pb.y < pub.y + pub.h &&
pb.y + pb.h > pub.y
) {
this._applyPowerUp(pu);
pu.alive = false;
GameGlobal.audioManager.playSFX('powerup');
}
}
},
_applyPowerUp(powerUp) {
switch (powerUp.type) {
case POWERUP_TYPE.STAR:
this._playerTank.upgradeFireLevel();
break;
case POWERUP_TYPE.CLOCK:
this._freezeTimer = FREEZE_DURATION;
break;
case POWERUP_TYPE.BOMB:
// Destroy all on-screen enemies
for (const enemy of this._enemies) {
if (enemy.alive) {
enemy.alive = false;
this._spawnExplosion(enemy.x, enemy.y, true);
this._recordKill(enemy);
}
}
break;
case POWERUP_TYPE.HELMET:
this._playerTank.activateShield(SHIELD_DURATION);
break;
case POWERUP_TYPE.SHOVEL:
this._mapManager.activateShovel(SHOVEL_DURATION);
break;
case POWERUP_TYPE.TANK:
this._playerTank.addLife();
break;
}
},
_handleEnemyDestroyed(data) {
const enemy = data.enemy;
this._recordKill(enemy);
// Spawn power-up if this enemy was marked
if (enemy.hasPowerUp) {
const type = PowerUp.randomType(this._level);
const pos = PowerUp.randomPosition(this._mapManager);
const pu = new PowerUp(type, pos.x, pos.y);
this._powerUps.push(pu);
}
},
_recordKill(enemy) {
this._stats.totalKills++;
this._stats.score += enemy.score || 100;
switch (enemy.type) {
case TANK_TYPE.ENEMY_NORMAL:
this._stats.kills.normal++;
break;
case TANK_TYPE.ENEMY_FAST:
this._stats.kills.fast++;
break;
case TANK_TYPE.ENEMY_ARMOR:
this._stats.kills.armor++;
break;
case TANK_TYPE.ENEMY_BOSS:
this._stats.kills.boss++;
break;
}
},
_handlePlayerDestroyed() {
// Check if buff shield can absorb the hit
if (this._buffManager && this._buffManager.consumeShield(this._playerTank)) {
// Shield absorbed the damage, player survives
this._playerTank.hp = 1;
this._playerTank.alive = true;
return;
}
const hasLives = this._playerTank.die();
if (!hasLives) {
// Check if revive ad is available and not yet used this level
if (!this._reviveAdUsed) {
// Always show revive dialog (with ad and/or gold options)
this._showReviveAdDialog();
} else {
this._triggerGameOver();
}
}
},
/**
* Check if revive ad can be shown.
* @returns {boolean}
* @private
*/
_canShowReviveAd() {
if (!GameGlobal.adManager) return false;
const AdManager = require('../managers/AdManager');
return GameGlobal.adManager.canShowScene(AdManager.AD_SCENE.REVIVE);
},
/**
* Show the revive dialog overlay with dual options.
* Pauses the game and presents watch-ad / gold-revive / give-up options.
* @private
*/
_showReviveAdDialog() {
this._showingReviveDialog = true;
this._paused = true;
const cx = SCREEN_WIDTH / 2;
const cy = SCREEN_HEIGHT / 2;
const btnW = 220;
const btnH = 36;
// Check if ad is available
const canShowAd = this._canShowReviveAd();
const buttons = {
giveUp: { x: cx - btnW / 2, y: cy + 50, w: btnW, h: btnH },
};
if (canShowAd) {
buttons.watchAd = { x: cx - btnW / 2, y: cy - 20, w: btnW, h: btnH };
buttons.goldRevive = { x: cx - btnW / 2, y: cy + 15, w: btnW, h: btnH };
buttons.giveUp = { x: cx - btnW / 2, y: cy + 50, w: btnW, h: btnH };
} else {
// No ad available, only show gold revive and give up
buttons.goldRevive = { x: cx - btnW / 2, y: cy - 5, w: btnW, h: btnH };
buttons.giveUp = { x: cx - btnW / 2, y: cy + 35, w: btnW, h: btnH };
}
this._reviveDialogButtons = buttons;
},
/**
* Handle the player choosing to revive with gold (200 gold).
* @private
*/
_onGoldRevive() {
const cm = GameGlobal.currencyManager;
if (cm && cm.spendGold(200)) {
this._showingReviveDialog = false;
this._reviveDialogButtons = null;
this._reviveAdUsed = true;
this._revivePlayer();
console.log('[GameScene] Player revived via gold (200)');
}
},
/**
* Handle the player choosing to watch the revive ad.
* @private
*/
_onReviveAdWatch() {
const AdManager = require('../managers/AdManager');
GameGlobal.adManager.showRewardedVideoForScene(
AdManager.AD_SCENE.REVIVE,
(completed) => {
this._showingReviveDialog = false;
this._reviveDialogButtons = null;
if (completed) {
this._reviveAdUsed = true;
this._revivePlayer();
} else {
this._triggerGameOver();
}
}
);
},
/**
* Handle the player choosing to give up (skip revive ad).
* @private
*/
_onReviveAdGiveUp() {
this._showingReviveDialog = false;
this._reviveDialogButtons = null;
this._triggerGameOver();
},
/**
* Revive the player: restore 1 life, keep fire level, respawn at start.
* @private
*/
_revivePlayer() {
this._paused = false;
this._playerTank.addLife();
// Respawn: reset position, alive=true, hp=1, shield protection
this._playerTank.respawn();
console.log('[GameScene] Player revived');
},
/**
* Trigger the game over state.
* @private
*/
_triggerGameOver() {
this._paused = false;
this._gameOver = true;
this._stats.baseAlive = !this._mapManager.baseDestroyed;
GameGlobal.audioManager.playSFX('gameover');
},
_handleBaseDestroyed() {
this._gameOver = true;
this._stats.baseAlive = false;
GameGlobal.audioManager.playSFX('gameover');
},
_checkVictory() {
if (this._gameOver || this._victory) return;
const allSpawned = this._spawnManager.allSpawned;
const allDead = this._enemies.every((e) => !e.alive);
if (allSpawned && allDead) {
this._victory = true;
this._stats.baseAlive = !this._mapManager.baseDestroyed;
GameGlobal.audioManager.playSFX('victory');
// Time bonus
const timeBonus = Math.max(0, 300 - Math.floor(this._stats.timeElapsed)) * 10;
this._stats.score += timeBonus;
// Base alive bonus
if (this._stats.baseAlive) {
this._stats.score += 1000;
}
}
},
_cleanup() {
// Recycle dead bullets
for (let i = this._bullets.length - 1; i >= 0; i--) {
if (!this._bullets[i].alive) {
this._bulletPool.put(this._bullets[i]);
this._bullets.splice(i, 1);
}
}
// Recycle dead explosions
for (let i = this._explosions.length - 1; i >= 0; i--) {
if (!this._explosions[i].alive) {
this._explosionPool.put(this._explosions[i]);
this._explosions.splice(i, 1);
}
}
// Remove dead power-ups
this._powerUps = this._powerUps.filter((pu) => pu.alive);
// Remove dead enemies (keep for counting)
// Don't remove - they're needed for allDead check
},
_transitionToResult() {
const sm = GameGlobal.sceneManager;
if (!sm._scenes.has(SCENE.RESULT)) {
const ResultScene = require('./ResultScene');
sm.register(SCENE.RESULT, ResultScene);
}
sm.switchTo(SCENE.RESULT, {
level: this._level,
mode: this._mode,
victory: this._victory,
stats: this._stats,
});
},
// ============================================================
// Touch Handling
// ============================================================
handleTouch(eventType, e) {
if (this._gameOver || this._victory) return;
// Handle revive dialog touches
if (this._showingReviveDialog && eventType === 'touchstart') {
const touches = e.touches;
for (let i = 0; i < touches.length; i++) {
const tx = touches[i].clientX;
const ty = touches[i].clientY;
const btns = this._reviveDialogButtons;
if (btns) {
// Watch Ad button
if (btns.watchAd && tx >= btns.watchAd.x && tx <= btns.watchAd.x + btns.watchAd.w &&
ty >= btns.watchAd.y && ty <= btns.watchAd.y + btns.watchAd.h) {
this._onReviveAdWatch();
return;
}
// Gold Revive button
if (btns.goldRevive && tx >= btns.goldRevive.x && tx <= btns.goldRevive.x + btns.goldRevive.w &&
ty >= btns.goldRevive.y && ty <= btns.goldRevive.y + btns.goldRevive.h) {
this._onGoldRevive();
return;
}
// Give Up button
if (tx >= btns.giveUp.x && tx <= btns.giveUp.x + btns.giveUp.w &&
ty >= btns.giveUp.y && ty <= btns.giveUp.y + btns.giveUp.h) {
this._onReviveAdGiveUp();
return;
}
}
}
return;
}
// Handle pause toggle
if (this._paused) {
if (eventType === 'touchstart') {
this._paused = false;
}
return;
}
// Distribute touches to controls
const touches = eventType === 'touchend' ? e.changedTouches : e.touches;
for (let i = 0; i < touches.length; i++) {
const touch = touches[i];
// Try joystick first
if (this._joystick.handleTouch(eventType, touch)) continue;
// Then fire button
if (this._fireButton.handleTouch(eventType, touch)) continue;
// Pause button area (top-right corner)
if (eventType === 'touchstart') {
if (touch.clientX > SCREEN_WIDTH - 50 && touch.clientY < MAP_OFFSET_Y) {
this._paused = true;
}
}
}
},
};
module.exports = GameScene;
+322
View File
@@ -0,0 +1,322 @@
/**
* MenuScene.js
* Main menu scene - displays game title and mode selection buttons.
* Rendered entirely with Canvas API (no DOM).
*/
const {
SCREEN_WIDTH,
SCREEN_HEIGHT,
COLORS,
SCENE,
GAME_MODE,
} = require('../base/GameGlobal');
const { t } = require('../i18n/I18n');
// ============================================================
// Button Layout
// ============================================================
const BTN_WIDTH = Math.min(SCREEN_WIDTH * 0.55, 280);
const BTN_HEIGHT = Math.min(36, SCREEN_HEIGHT * 0.07);
const BTN_GAP = Math.min(8, SCREEN_HEIGHT * 0.015);
const BTN_START_Y = SCREEN_HEIGHT * 0.38;
const BTN_X = (SCREEN_WIDTH - BTN_WIDTH) / 2;
// Half-width buttons for the utility row (shop, battle pass, ranking, settings)
const HALF_BTN_WIDTH = (BTN_WIDTH - BTN_GAP) / 2;
// Main game mode buttons (full width, vertical)
const MAIN_BUTTONS = [
{ labelKey: 'menu.classic', mode: GAME_MODE.CLASSIC, scene: SCENE.GAME },
{ labelKey: 'menu.endless', mode: GAME_MODE.ENDLESS, scene: SCENE.GAME },
{ labelKey: 'menu.pvp', mode: GAME_MODE.PVP, scene: SCENE.PVP_ROOM },
{ labelKey: 'menu.team3v3', mode: GAME_MODE.TEAM_3V3, scene: SCENE.TEAM_ROOM },
];
// Utility buttons: shop, daily gold, ranking, settings (2x2 grid)
const UTIL_BUTTONS = [
{ labelKey: 'menu.shop', mode: null, scene: SCENE.SHOP },
{ labelKey: 'dailyGold.btn', mode: null, scene: 'DAILY_GOLD' },
{ labelKey: 'menu.ranking', mode: null, scene: SCENE.RANKING },
{ labelKey: 'menu.settings', mode: null, scene: SCENE.SETTINGS },
];
// Pre-calculate button rects for main buttons
const mainBtnRects = MAIN_BUTTONS.map((btn, i) => ({
x: BTN_X,
y: BTN_START_Y + i * (BTN_HEIGHT + BTN_GAP),
w: BTN_WIDTH,
h: BTN_HEIGHT,
...btn,
}));
// Pre-calculate button rects for utility buttons (2x2 grid)
const utilStartY = BTN_START_Y + MAIN_BUTTONS.length * (BTN_HEIGHT + BTN_GAP) + BTN_GAP;
const utilBtnRects = UTIL_BUTTONS.map((btn, i) => {
const col = i % 2;
const row = Math.floor(i / 2);
return {
x: BTN_X + col * (HALF_BTN_WIDTH + BTN_GAP),
y: utilStartY + row * (BTN_HEIGHT + BTN_GAP),
w: HALF_BTN_WIDTH,
h: BTN_HEIGHT,
...btn,
};
});
// Combined list for unified iteration
const buttonRects = [...mainBtnRects, ...utilBtnRects];
// ============================================================
// Menu Scene
// ============================================================
const MenuScene = {
_pressedIndex: -1,
_tankAnim: 0, // simple animation timer
enter() {
this._pressedIndex = -1;
this._tankAnim = 0;
// Auto-navigate to team room if there's a pending invite teamId
if (GameGlobal._pendingTeamId) {
const teamId = GameGlobal._pendingTeamId;
GameGlobal._pendingTeamId = null;
console.log(`[MenuScene] Found pendingTeamId: ${teamId}, will auto-navigate to TeamRoomScene`);
// Use setTimeout to allow the scene to fully initialize first
setTimeout(() => {
console.log(`[MenuScene] Auto-navigating to TeamRoomScene with teamId: ${teamId}`);
const sm = GameGlobal.sceneManager;
if (!sm._scenes.has(SCENE.TEAM_ROOM)) {
const TeamRoomScene = require('./TeamRoomScene');
sm.register(SCENE.TEAM_ROOM, TeamRoomScene);
}
sm.switchTo(SCENE.TEAM_ROOM, { teamId });
}, 100);
}
},
exit() {},
update(dt) {
this._tankAnim += dt;
},
render(ctx) {
// Background
ctx.fillStyle = COLORS.MENU_BG;
ctx.fillRect(0, 0, SCREEN_WIDTH, SCREEN_HEIGHT);
// Decorative top bar
const gradient = ctx.createLinearGradient(0, 0, SCREEN_WIDTH, 0);
gradient.addColorStop(0, '#0f3460');
gradient.addColorStop(0.5, '#e94560');
gradient.addColorStop(1, '#0f3460');
ctx.fillStyle = gradient;
ctx.fillRect(0, 0, SCREEN_WIDTH, 4);
// Gold balance display at top
const gold = GameGlobal.currencyManager ? GameGlobal.currencyManager.getGold() : 0;
ctx.fillStyle = '#FFD700';
ctx.font = 'bold 14px Arial';
ctx.textAlign = 'right';
ctx.textBaseline = 'top';
ctx.fillText(`🪙 ${gold}`, SCREEN_WIDTH - 15, 12);
// Title
ctx.fillStyle = COLORS.MENU_TITLE;
ctx.font = 'bold 36px Arial';
ctx.textAlign = 'center';
ctx.textBaseline = 'middle';
ctx.fillText(t('menu.title'), SCREEN_WIDTH / 2, SCREEN_HEIGHT * 0.15);
// Subtitle
ctx.fillStyle = '#AAAAAA';
ctx.font = '14px Arial';
ctx.fillText(t('menu.subtitle'), SCREEN_WIDTH / 2, SCREEN_HEIGHT * 0.22);
// Animated tank icon (simple oscillating triangle)
this._drawTankIcon(ctx, SCREEN_WIDTH / 2, SCREEN_HEIGHT * 0.30);
// Main game mode buttons (full width)
for (let i = 0; i < mainBtnRects.length; i++) {
const btn = mainBtnRects[i];
const isPressed = this._pressedIndex === i;
ctx.fillStyle = isPressed ? '#0f3460' : COLORS.MENU_BTN;
ctx.strokeStyle = COLORS.MENU_BTN_BORDER;
ctx.lineWidth = 2;
this._roundRect(ctx, btn.x, btn.y, btn.w, btn.h, 8);
ctx.fill();
ctx.stroke();
ctx.fillStyle = isPressed ? COLORS.MENU_TITLE : COLORS.MENU_BTN_TEXT;
ctx.font = 'bold 18px Arial';
ctx.textAlign = 'center';
ctx.textBaseline = 'middle';
ctx.fillText(t(btn.labelKey), btn.x + btn.w / 2, btn.y + btn.h / 2);
}
// Utility buttons (2x2 grid, smaller font)
for (let i = 0; i < utilBtnRects.length; i++) {
const btn = utilBtnRects[i];
const globalIdx = mainBtnRects.length + i;
const isPressed = this._pressedIndex === globalIdx;
// Special rendering for daily gold button
const isDailyGold = btn.scene === 'DAILY_GOLD';
let label = t(btn.labelKey);
let btnColor = COLORS.MENU_BTN;
if (isDailyGold) {
const remaining = GameGlobal.adManager ? GameGlobal.adManager.getDailyGoldRemaining() : 0;
if (remaining > 0) {
label = `${t('dailyGold.btn')} ${remaining}/3`;
btnColor = '#2E7D32'; // green tint
} else {
label = t('dailyGold.exhausted') || 'Come back tomorrow';
btnColor = '#555555';
}
}
ctx.fillStyle = isPressed ? '#0f3460' : btnColor;
ctx.strokeStyle = COLORS.MENU_BTN_BORDER;
ctx.lineWidth = 1.5;
this._roundRect(ctx, btn.x, btn.y, btn.w, btn.h, 6);
ctx.fill();
ctx.stroke();
ctx.fillStyle = isPressed ? COLORS.MENU_TITLE : COLORS.MENU_BTN_TEXT;
ctx.font = 'bold 14px Arial';
ctx.textAlign = 'center';
ctx.textBaseline = 'middle';
ctx.fillText(label, btn.x + btn.w / 2, btn.y + btn.h / 2);
}
// Footer
ctx.fillStyle = '#555555';
ctx.font = '11px Arial';
ctx.textAlign = 'center';
ctx.fillText('v1.0.0', SCREEN_WIDTH / 2, SCREEN_HEIGHT - 20);
},
/**
* Draw a simple animated tank icon.
*/
_drawTankIcon(ctx, cx, cy) {
const bounce = Math.sin(this._tankAnim * 3) * 3;
const size = 20;
ctx.save();
ctx.translate(cx, cy + bounce);
// Tank body
ctx.fillStyle = COLORS.MENU_TITLE;
ctx.fillRect(-size, -size / 2, size * 2, size);
// Tank turret
ctx.fillRect(-3, -size / 2 - 14, 6, 14);
// Tank tracks
ctx.fillStyle = '#B8860B';
ctx.fillRect(-size - 4, -size / 2, 4, size);
ctx.fillRect(size, -size / 2, 4, size);
ctx.restore();
},
/**
* Draw a rounded rectangle path.
*/
_roundRect(ctx, x, y, w, h, r) {
ctx.beginPath();
ctx.moveTo(x + r, y);
ctx.lineTo(x + w - r, y);
ctx.arcTo(x + w, y, x + w, y + r, r);
ctx.lineTo(x + w, y + h - r);
ctx.arcTo(x + w, y + h, x + w - r, y + h, r);
ctx.lineTo(x + r, y + h);
ctx.arcTo(x, y + h, x, y + h - r, r);
ctx.lineTo(x, y + r);
ctx.arcTo(x, y, x + r, y, r);
ctx.closePath();
},
handleTouch(eventType, e) {
if (eventType === 'touchstart') {
const touch = e.touches[0];
const tx = touch.clientX;
const ty = touch.clientY;
for (let i = 0; i < buttonRects.length; i++) {
const btn = buttonRects[i];
if (tx >= btn.x && tx <= btn.x + btn.w && ty >= btn.y && ty <= btn.y + btn.h) {
this._pressedIndex = i;
break;
}
}
} else if (eventType === 'touchend') {
if (this._pressedIndex >= 0) {
const btn = buttonRects[this._pressedIndex];
this._pressedIndex = -1;
// Navigate to the target scene
const sm = GameGlobal.sceneManager;
if (btn.scene === SCENE.GAME) {
// Route through BuffSelectScene for PvE modes
if (!sm._scenes.has(SCENE.BUFF_SELECT)) {
const BuffSelectScene = require('./BuffSelectScene');
sm.register(SCENE.BUFF_SELECT, BuffSelectScene);
}
sm.switchTo(SCENE.BUFF_SELECT, { mode: btn.mode });
} else if (btn.scene === 'DAILY_GOLD') {
// Handle daily gold ad
const adm = GameGlobal.adManager;
if (adm && adm.getDailyGoldRemaining() > 0) {
adm.showDailyGoldAd((completed) => {
if (completed) {
try {
wx.showToast({ title: t('dailyGold.reward') || '+100 Gold!', icon: 'none', duration: 1500 });
} catch (e) {}
}
});
}
} else if (btn.scene === SCENE.SHOP) {
if (!sm._scenes.has(SCENE.SHOP)) {
const ShopScene = require('./ShopScene');
sm.register(SCENE.SHOP, ShopScene);
}
sm.switchTo(SCENE.SHOP);
} else if (btn.scene === SCENE.SETTINGS) {
if (!sm._scenes.has(SCENE.SETTINGS)) {
const SettingsScene = require('./SettingsScene');
sm.register(SCENE.SETTINGS, SettingsScene);
}
sm.switchTo(SCENE.SETTINGS);
} else if (btn.scene === SCENE.RANKING) {
if (!sm._scenes.has(SCENE.RANKING)) {
const RankingScene = require('./RankingScene');
sm.register(SCENE.RANKING, RankingScene);
}
sm.switchTo(SCENE.RANKING);
} else if (btn.scene === SCENE.PVP_ROOM) {
if (!sm._scenes.has(SCENE.PVP_ROOM)) {
const RoomScene = require('./RoomScene');
sm.register(SCENE.PVP_ROOM, RoomScene);
}
sm.switchTo(SCENE.PVP_ROOM);
} else if (btn.scene === SCENE.TEAM_ROOM) {
if (!sm._scenes.has(SCENE.TEAM_ROOM)) {
const TeamRoomScene = require('./TeamRoomScene');
sm.register(SCENE.TEAM_ROOM, TeamRoomScene);
}
sm.switchTo(SCENE.TEAM_ROOM);
}
}
}
},
};
module.exports = MenuScene;
+146
View File
@@ -0,0 +1,146 @@
/**
* RankingScene.js
* Ranking/leaderboard scene.
* In production, this would use WeChat Open Data Domain (SharedCanvas).
* For now, displays local high scores with a placeholder for friend rankings.
*/
const {
SCREEN_WIDTH,
SCREEN_HEIGHT,
COLORS,
SCENE,
GAME_MODE,
} = require('../base/GameGlobal');
const { t } = require('../i18n/I18n');
const StorageManager = require('../managers/StorageManager');
const RankingScene = {
_storage: null,
_scores: [],
_buttons: {},
enter() {
this._storage = new StorageManager();
this._buttons = {};
// Load local scores
this._scores = [
{
label: t('ranking.classicHigh'),
score: this._storage.getHighScore(GAME_MODE.CLASSIC),
},
{
label: t('ranking.endlessHigh'),
score: this._storage.getHighScore(GAME_MODE.ENDLESS),
},
{
label: t('ranking.highestLevel'),
score: this._storage.getHighestLevel(),
suffix: t('ranking.levelSuffix'),
},
];
},
exit() {},
update(dt) {},
render(ctx) {
// Background
ctx.fillStyle = COLORS.MENU_BG;
ctx.fillRect(0, 0, SCREEN_WIDTH, SCREEN_HEIGHT);
const cx = SCREEN_WIDTH / 2;
let y = 60;
// Title
ctx.fillStyle = COLORS.MENU_TITLE;
ctx.font = 'bold 28px Arial';
ctx.textAlign = 'center';
ctx.textBaseline = 'middle';
ctx.fillText(t('ranking.title'), cx, y);
y += 60;
// Local scores section
ctx.fillStyle = '#AAAAAA';
ctx.font = '14px Arial';
ctx.fillText(t('ranking.personalRecord'), cx, y);
y += 40;
for (const item of this._scores) {
// Card background
const cardW = SCREEN_WIDTH * 0.75;
const cardH = 55;
const cardX = cx - cardW / 2;
ctx.fillStyle = '#1e1e3a';
ctx.fillRect(cardX, y - cardH / 2, cardW, cardH);
ctx.strokeStyle = '#333366';
ctx.lineWidth = 1;
ctx.strokeRect(cardX, y - cardH / 2, cardW, cardH);
// Label
ctx.fillStyle = COLORS.HUD_TEXT;
ctx.font = '14px Arial';
ctx.textAlign = 'left';
ctx.fillText(item.label, cardX + 15, y - 5);
// Score
ctx.fillStyle = '#FFD700';
ctx.font = 'bold 18px Arial';
ctx.textAlign = 'right';
const suffix = item.suffix || t('ranking.scoreSuffix');
ctx.fillText(`${item.score} ${suffix}`, cardX + cardW - 15, y + 2);
y += 70;
}
y += 20;
// Friend ranking placeholder
ctx.fillStyle = '#666666';
ctx.font = '13px Arial';
ctx.textAlign = 'center';
ctx.fillText(t('ranking.friendHint'), cx, y);
ctx.fillText('(SharedCanvas)', cx, y + 20);
// Back button
y = SCREEN_HEIGHT - 80;
const btnW = SCREEN_WIDTH * 0.4;
const btnH = 42;
const btnX = cx - btnW / 2;
this._buttons['back'] = { x: btnX, y: y - btnH / 2, w: btnW, h: btnH };
ctx.fillStyle = COLORS.MENU_BTN;
ctx.strokeStyle = COLORS.MENU_BTN_BORDER;
ctx.lineWidth = 2;
ctx.fillRect(btnX, y - btnH / 2, btnW, btnH);
ctx.strokeRect(btnX, y - btnH / 2, btnW, btnH);
ctx.fillStyle = COLORS.MENU_BTN_TEXT;
ctx.font = 'bold 16px Arial';
ctx.textAlign = 'center';
ctx.textBaseline = 'middle';
ctx.fillText(t('common.back'), cx, y);
},
handleTouch(eventType, e) {
if (eventType !== 'touchstart') return;
const touch = e.touches[0];
const tx = touch.clientX;
const ty = touch.clientY;
const back = this._buttons['back'];
if (back && tx >= back.x && tx <= back.x + back.w && ty >= back.y && ty <= back.y + back.h) {
GameGlobal.sceneManager.switchTo(SCENE.MENU);
}
},
};
module.exports = RankingScene;
+420
View File
@@ -0,0 +1,420 @@
/**
* ResultScene.js
* Post-game result/settlement screen showing stats, score, and navigation options.
*/
const {
SCREEN_WIDTH,
SCREEN_HEIGHT,
COLORS,
SCENE,
GAME_MODE,
TANK_TYPE,
} = require('../base/GameGlobal');
const { t } = require('../i18n/I18n');
const ResultScene = {
_level: 1,
_mode: GAME_MODE.CLASSIC,
_victory: false,
_stats: null,
_animTimer: 0,
_showButtons: false,
_isNewHighScore: false,
_adWatched: false,
enter(params) {
this._level = params.level || 1;
this._mode = params.mode || GAME_MODE.CLASSIC;
this._victory = params.victory || false;
this._stats = params.stats || {
kills: { normal: 0, fast: 0, armor: 0, boss: 0 },
totalKills: 0,
score: 0,
timeElapsed: 0,
baseAlive: true,
};
this._animTimer = 0;
this._showButtons = false;
this._isNewHighScore = false;
this._adWatched = false;
this._buttons = {};
// Save score and progress
this._saveResults();
// Interstitial ad is shown when player exits (next/retry/menu), not on enter
console.log(`[ResultScene] ${this._victory ? 'Victory' : 'Defeat'} - Score: ${this._stats.score}`);
},
_saveResults() {
const sm = GameGlobal.storageManager;
if (!sm) return;
// Update high score
this._isNewHighScore = sm.updateHighScore(this._mode, this._stats.score);
// Update highest level
if (this._victory) {
sm.updateHighestLevel(this._level);
}
// Update open data for friend ranking
if (GameGlobal.shareManager) {
GameGlobal.shareManager.updateOpenData(this._stats.score, this._level);
}
// Calculate and award gold coins
this._goldReward = this._calculateGoldReward();
if (this._goldReward > 0 && GameGlobal.currencyManager) {
GameGlobal.currencyManager.addGold(this._goldReward);
}
},
/**
* Calculate gold reward based on game performance.
* @returns {number}
* @private
*/
_calculateGoldReward() {
const stats = this._stats;
let gold = 50; // Base reward per requirements
// Bonus per kill type
gold += (stats.kills.normal || 0) * 5;
gold += (stats.kills.fast || 0) * 10;
gold += (stats.kills.armor || 0) * 15;
gold += (stats.kills.boss || 0) * 25;
// Victory bonus
if (this._victory) {
gold += 50;
}
// Time bonus (faster = more gold, max 30 gold for under 60s)
if (this._victory && stats.timeElapsed < 300) {
gold += Math.max(0, Math.floor((300 - stats.timeElapsed) / 10));
}
// Base alive bonus
if (stats.baseAlive) {
gold += 20;
}
return gold;
},
exit() {},
update(dt) {
this._animTimer += dt;
if (this._animTimer > 1.5 && !this._showButtons) {
this._showButtons = true;
}
},
render(ctx) {
// Background
ctx.fillStyle = '#0a0a1a';
ctx.fillRect(0, 0, SCREEN_WIDTH, SCREEN_HEIGHT);
const cx = SCREEN_WIDTH / 2;
let y = 35;
// Title
ctx.fillStyle = this._victory ? '#00FF00' : '#FF4444';
ctx.font = 'bold 24px Arial';
ctx.textAlign = 'center';
ctx.textBaseline = 'middle';
ctx.fillText(
this._victory ? t('result.victory') : t('result.defeat'),
cx,
y
);
y += 30;
// Level info
ctx.fillStyle = '#AAAAAA';
ctx.font = '14px Arial';
ctx.fillText(t('result.level', { level: this._level }), cx, y);
y += 35;
// Kill statistics - horizontal table layout
ctx.fillStyle = COLORS.HUD_TEXT;
ctx.font = 'bold 14px Arial';
ctx.fillText(t('result.killStats'), cx, y);
y += 20;
const killData = [
{ label: t('result.tankNormal'), count: this._stats.kills.normal, score: 100, color: '#AAAAAA' },
{ label: t('result.tankFast'), count: this._stats.kills.fast, score: 200, color: '#FF6347' },
{ label: t('result.tankArmor'), count: this._stats.kills.armor, score: 300, color: '#228B22' },
{ label: t('result.tankBoss'), count: this._stats.kills.boss, score: 500, color: '#8B0000' },
{ label: t('result.totalLabel'), count: this._stats.totalKills, score: null, total: this._stats.score, color: '#FFD700' },
];
// Table layout: first column for row labels, then 5 data columns
const colCount = killData.length;
const rowLabelWidth = 30; // Width for row label column
const tableWidth = SCREEN_WIDTH * 0.55;
const tableLeft = (SCREEN_WIDTH - tableWidth) / 2;
const dataColWidth = (tableWidth - rowLabelWidth) / colCount;
const dataLeft = tableLeft + rowLabelWidth;
// Row 1: Column headers (blank first col + tank type names + total)
ctx.font = 'bold 11px Arial';
ctx.textBaseline = 'middle';
for (let i = 0; i < colCount; i++) {
const showDelay = i * 0.3;
if (this._animTimer < showDelay) continue;
const colCx = dataLeft + dataColWidth * i + dataColWidth / 2;
ctx.textAlign = 'center';
ctx.fillStyle = killData[i].color;
ctx.fillText(killData[i].label, colCx, y);
}
y += 16;
// Row 2: Kill counts (first col = "击杀")
ctx.font = '12px Arial';
ctx.textAlign = 'left';
ctx.fillStyle = '#AAAAAA';
ctx.fillText(t('result.rowKills'), tableLeft, y);
for (let i = 0; i < colCount; i++) {
const showDelay = i * 0.3;
if (this._animTimer < showDelay) continue;
const colCx = dataLeft + dataColWidth * i + dataColWidth / 2;
ctx.textAlign = 'center';
ctx.fillStyle = COLORS.HUD_TEXT;
ctx.fillText(`×${killData[i].count}`, colCx, y);
}
y += 16;
// Row 3: Scores (first col = "得分")
ctx.font = '12px Arial';
ctx.textAlign = 'left';
ctx.fillStyle = '#AAAAAA';
ctx.fillText(t('result.rowScore'), tableLeft, y);
for (let i = 0; i < colCount; i++) {
const showDelay = i * 0.3;
if (this._animTimer < showDelay) continue;
const colCx = dataLeft + dataColWidth * i + dataColWidth / 2;
ctx.textAlign = 'center';
ctx.fillStyle = '#FFD700';
const scoreVal = killData[i].total != null ? killData[i].total : killData[i].count * killData[i].score;
ctx.fillText(`${scoreVal}`, colCx, y);
}
y += 10;
// Divider
ctx.strokeStyle = '#333333';
ctx.lineWidth = 1;
ctx.beginPath();
ctx.moveTo(cx - 120, y);
ctx.lineTo(cx + 120, y);
ctx.stroke();
y += 18;
// Time
ctx.fillStyle = '#AAAAAA';
ctx.font = '12px Arial';
const minutes = Math.floor(this._stats.timeElapsed / 60);
const seconds = Math.floor(this._stats.timeElapsed % 60);
ctx.fillText(
t('result.time', { minutes, seconds: seconds.toString().padStart(2, '0') }),
cx,
y
);
y += 18;
// Base status
ctx.fillText(
this._stats.baseAlive ? t('result.baseAlive') : t('result.baseDestroyed'),
cx,
y
);
// New high score indicator
if (this._isNewHighScore) {
y += 20;
ctx.fillStyle = '#FF69B4';
ctx.font = 'bold 13px Arial';
ctx.fillText(t('result.newRecord'), cx, y);
}
// Gold reward display
if (this._goldReward > 0) {
y += 22;
ctx.fillStyle = '#FFD700';
ctx.font = 'bold 14px Arial';
const goldLabel = this._adWatched
? `🪙 +${this._goldReward} (${t('result.doubled') || '2x!'})`
: `🪙 +${this._goldReward}`;
ctx.fillText(goldLabel, cx, y);
}
// Buttons (shown after animation)
if (this._showButtons) {
// Calculate how many buttons will be shown
let btnCount = 2; // retry + menu always present
if (this._victory) btnCount += 2; // share + next
if (!this._adWatched) btnCount += 1; // ad_double
const btnSpacing = 38;
const totalBtnHeight = btnCount * btnSpacing;
// Start buttons so they end 15px above screen bottom
y = SCREEN_HEIGHT - totalBtnHeight - 15;
// Share challenge button
if (this._victory) {
this._drawButton(ctx, cx, y, t('result.share'), 'share');
y += btnSpacing;
}
// Double score ad button (if not watched)
if (!this._adWatched) {
this._drawButton(ctx, cx, y, t('result.adDouble'), 'ad_double');
y += btnSpacing;
}
if (this._victory) {
this._drawButton(ctx, cx, y, t('result.nextLevel'), 'next');
y += btnSpacing;
}
// Retry button
this._drawButton(ctx, cx, y, t('result.retry'), 'retry');
y += btnSpacing;
// Menu button
this._drawButton(ctx, cx, y, t('result.backMenu'), 'menu');
}
},
_drawButton(ctx, cx, y, label, id) {
const w = SCREEN_WIDTH * 0.55;
const h = 36;
const x = cx - w / 2;
// Store button rect for touch detection
if (!this._buttons) this._buttons = {};
this._buttons[id] = { x, y: y - h / 2, w, h };
ctx.fillStyle = COLORS.MENU_BTN;
ctx.strokeStyle = COLORS.MENU_BTN_BORDER;
ctx.lineWidth = 2;
// Rounded rect
ctx.beginPath();
const r = 6;
ctx.moveTo(x + r, y - h / 2);
ctx.lineTo(x + w - r, y - h / 2);
ctx.arcTo(x + w, y - h / 2, x + w, y - h / 2 + r, r);
ctx.lineTo(x + w, y + h / 2 - r);
ctx.arcTo(x + w, y + h / 2, x + w - r, y + h / 2, r);
ctx.lineTo(x + r, y + h / 2);
ctx.arcTo(x, y + h / 2, x, y + h / 2 - r, r);
ctx.lineTo(x, y - h / 2 + r);
ctx.arcTo(x, y - h / 2, x + r, y - h / 2, r);
ctx.closePath();
ctx.fill();
ctx.stroke();
ctx.fillStyle = COLORS.MENU_BTN_TEXT;
ctx.font = 'bold 14px Arial';
ctx.textAlign = 'center';
ctx.textBaseline = 'middle';
ctx.fillText(label, cx, y);
},
handleTouch(eventType, e) {
if (eventType !== 'touchstart' || !this._showButtons) return;
const touch = e.touches[0];
const tx = touch.clientX;
const ty = touch.clientY;
if (!this._buttons) return;
for (const [id, rect] of Object.entries(this._buttons)) {
if (tx >= rect.x && tx <= rect.x + rect.w && ty >= rect.y && ty <= rect.y + rect.h) {
this._handleButtonPress(id);
break;
}
}
},
_handleButtonPress(id) {
const sm = GameGlobal.sceneManager;
switch (id) {
case 'next':
sm.switchTo(SCENE.GAME, {
mode: this._mode,
level: this._level + 1,
});
break;
case 'retry':
sm.switchTo(SCENE.GAME, {
mode: this._mode,
level: this._level,
});
break;
case 'menu':
// Show interstitial ad when leaving result screen
if (GameGlobal.adManager) {
GameGlobal.adManager.showInterstitial();
}
sm.switchTo(SCENE.MENU);
break;
case 'share':
if (GameGlobal.shareManager) {
GameGlobal.shareManager.shareChallenge(this._level, this._stats.score);
}
break;
case 'ad_double': {
const AdManager = require('../managers/AdManager');
if (GameGlobal.adManager &&
GameGlobal.adManager.canShowScene(AdManager.AD_SCENE.DOUBLE_REWARD)) {
GameGlobal.adManager.showRewardedVideoForScene(
AdManager.AD_SCENE.DOUBLE_REWARD,
(completed) => {
if (completed) {
this._stats.score *= 2;
this._adWatched = true;
// Re-save with doubled score
if (GameGlobal.storageManager) {
GameGlobal.storageManager.updateHighScore(this._mode, this._stats.score);
}
// Award bonus gold (double the original reward)
if (this._goldReward && GameGlobal.currencyManager) {
GameGlobal.currencyManager.addGold(this._goldReward);
this._goldReward *= 2; // Update display
}
}
}
);
} else {
console.warn('[ResultScene] Double reward ad not available');
}
break;
}
}
},
};
module.exports = ResultScene;
+539
View File
@@ -0,0 +1,539 @@
/**
* RoomScene.js
* Room creation/joining UI for PVP online multiplayer mode.
* Allows players to create a room or join an existing one by room code.
*/
const {
SCREEN_WIDTH,
SCREEN_HEIGHT,
COLORS,
SCENE,
NET_MSG,
SERVER_URL,
} = require('../base/GameGlobal');
const { t } = require('../i18n/I18n');
// ============================================================
// Layout Constants
// ============================================================
const BTN_WIDTH = Math.min(SCREEN_WIDTH * 0.5, 240);
const BTN_HEIGHT = Math.min(36, SCREEN_HEIGHT * 0.08);
const BTN_GAP = 14;
const CENTER_X = SCREEN_WIDTH / 2;
// ============================================================
// Room Scene States
// ============================================================
const ROOM_STATE = {
IDLE: 'idle', // Initial state: show create/join buttons
CREATING: 'creating', // Connecting and creating room
WAITING: 'waiting', // Room created, waiting for opponent
JOINING: 'joining', // Joining a room
INPUT_CODE: 'input', // Entering room code
COUNTDOWN: 'countdown', // Both players ready, counting down
ERROR: 'error', // Error state
};
// ============================================================
// Room Scene
// ============================================================
const RoomScene = {
_state: ROOM_STATE.IDLE,
_roomCode: '',
_inputCode: '',
_errorMsg: '',
_countdown: 3,
_countdownTimer: 0,
_animTimer: 0,
_networkManager: null,
_unsubscribers: [],
// Server URL (from global config)
_serverUrl: SERVER_URL,
// Button rects (calculated in enter)
_createBtnRect: null,
_joinBtnRect: null,
_backBtnRect: null,
_confirmBtnRect: null,
_numpadRects: [],
_deleteBtnRect: null,
enter() {
this._state = ROOM_STATE.IDLE;
this._roomCode = '';
this._inputCode = '';
this._errorMsg = '';
this._countdown = 3;
this._countdownTimer = 0;
this._animTimer = 0;
this._pendingStartData = null;
this._networkManager = GameGlobal.networkManager;
// Calculate button positions
const btnY = SCREEN_HEIGHT * 0.4;
this._createBtnRect = {
x: CENTER_X - BTN_WIDTH / 2,
y: btnY,
w: BTN_WIDTH,
h: BTN_HEIGHT,
};
this._joinBtnRect = {
x: CENTER_X - BTN_WIDTH / 2,
y: btnY + BTN_HEIGHT + BTN_GAP,
w: BTN_WIDTH,
h: BTN_HEIGHT,
};
this._backBtnRect = {
x: 10,
y: 10,
w: 60,
h: 30,
};
// Numpad for room code input (3x4 grid: 1-9, 0, delete, confirm)
this._buildNumpad();
// Confirm button for code input
this._confirmBtnRect = {
x: CENTER_X - BTN_WIDTH / 2,
y: SCREEN_HEIGHT * 0.75,
w: BTN_WIDTH,
h: BTN_HEIGHT,
};
// Setup network event listeners
this._setupNetworkEvents();
},
exit() {
this._cleanupNetworkEvents();
},
_buildNumpad() {
const padWidth = Math.min(SCREEN_WIDTH * 0.6, 200);
const padHeight = Math.min(SCREEN_HEIGHT * 0.35, 180);
const startX = CENTER_X - padWidth / 2;
const startY = SCREEN_HEIGHT * 0.42;
const cellW = padWidth / 3;
const cellH = padHeight / 4;
this._numpadRects = [];
const nums = [1, 2, 3, 4, 5, 6, 7, 8, 9, null, 0, 'del'];
for (let i = 0; i < 12; i++) {
const col = i % 3;
const row = Math.floor(i / 3);
if (nums[i] !== null) {
this._numpadRects.push({
x: startX + col * cellW,
y: startY + row * cellH,
w: cellW - 4,
h: cellH - 4,
value: nums[i],
});
}
}
},
_setupNetworkEvents() {
this._cleanupNetworkEvents();
const nm = this._networkManager;
if (!nm) return;
const unsubs = [];
unsubs.push(nm.on(NET_MSG.ROOM_CREATED, (data) => {
this._roomCode = data.roomId || data.roomCode || '';
this._state = ROOM_STATE.WAITING;
}));
unsubs.push(nm.on(NET_MSG.ROOM_JOINED, (data) => {
this._roomCode = data.roomId || data.roomCode || '';
this._state = ROOM_STATE.WAITING;
}));
unsubs.push(nm.on(NET_MSG.OPPONENT_JOINED, () => {
this._state = ROOM_STATE.COUNTDOWN;
this._countdown = 3;
this._countdownTimer = 0;
}));
unsubs.push(nm.on(NET_MSG.GAME_START, (data) => {
// Server authoritative game start — always use server data (contains mapId)
this._pendingStartData = data;
if (this._state !== ROOM_STATE.COUNTDOWN) {
// Guest path: not in countdown state, start game immediately
this._startGame(data);
} else if (this._countdown <= 0) {
// Host path: countdown already finished, start immediately
this._startGame(data);
}
// Host path: countdown still running, will pick up pendingStartData when done
}));
unsubs.push(nm.on(NET_MSG.ROOM_ERROR, (data) => {
this._errorMsg = data.message || 'Unknown error';
this._state = ROOM_STATE.ERROR;
}));
unsubs.push(nm.on('error', () => {
this._errorMsg = t('common.connectFailed');
this._state = ROOM_STATE.ERROR;
}));
unsubs.push(nm.on('disconnected', () => {
if (this._state !== ROOM_STATE.IDLE) {
this._errorMsg = t('common.disconnected');
this._state = ROOM_STATE.ERROR;
}
}));
this._unsubscribers = unsubs;
},
_cleanupNetworkEvents() {
for (const unsub of this._unsubscribers) {
if (typeof unsub === 'function') unsub();
}
this._unsubscribers = [];
},
update(dt) {
this._animTimer += dt;
if (this._state === ROOM_STATE.COUNTDOWN) {
this._countdownTimer += dt;
if (this._countdownTimer >= 1) {
this._countdownTimer -= 1;
this._countdown--;
if (this._countdown <= 0) {
// Countdown finished — only start if we already received server GAME_START
if (this._pendingStartData) {
this._startGame(this._pendingStartData);
}
// Otherwise wait for server GAME_START message
}
}
}
},
_startGame(data) {
const sm = GameGlobal.sceneManager;
if (!sm._scenes.has(SCENE.TEAM_GAME)) {
const TeamGameScene = require('./TeamGameScene');
sm.register(SCENE.TEAM_GAME, TeamGameScene);
}
const myPlayerId = this._networkManager ? this._networkManager.playerId : 'local';
const playerSlot = this._networkManager ? this._networkManager.playerSlot : 1;
// Build teamA/teamB from GAME_START data (sent by server for 1v1 via TeamRoom)
let teamA = data.teamA || [];
let teamB = data.teamB || [];
// Fallback: if server didn't send teamA/teamB (legacy), construct from playerSlot
if (teamA.length === 0 && teamB.length === 0) {
if (playerSlot === 1) {
teamA = [{ playerId: myPlayerId, isBot: false }];
teamB = [{ playerId: 'opponent', isBot: false }];
} else {
teamA = [{ playerId: 'opponent', isBot: false }];
teamB = [{ playerId: myPlayerId, isBot: false }];
}
}
sm.switchTo(SCENE.TEAM_GAME, {
teamId: this._roomCode,
roomId: data.roomId || this._roomCode,
mapId: data.mapId || null,
teamA,
teamB,
teamABaseHp: data.teamABaseHp,
teamBBaseHp: data.teamBBaseHp,
myPlayerId,
battleMode: data.battleMode || '1v1',
});
},
render(ctx) {
// Background
ctx.fillStyle = COLORS.MENU_BG;
ctx.fillRect(0, 0, SCREEN_WIDTH, SCREEN_HEIGHT);
// Top bar
const gradient = ctx.createLinearGradient(0, 0, SCREEN_WIDTH, 0);
gradient.addColorStop(0, '#0f3460');
gradient.addColorStop(0.5, '#e94560');
gradient.addColorStop(1, '#0f3460');
ctx.fillStyle = gradient;
ctx.fillRect(0, 0, SCREEN_WIDTH, 4);
// Back button
this._drawButton(ctx, this._backBtnRect, t('common.back'), false, 12);
// Title
ctx.fillStyle = COLORS.MENU_TITLE;
ctx.font = 'bold 24px Arial';
ctx.textAlign = 'center';
ctx.textBaseline = 'middle';
ctx.fillText(t('room.title'), CENTER_X, SCREEN_HEIGHT * 0.12);
// Render based on state
switch (this._state) {
case ROOM_STATE.IDLE:
this._renderIdle(ctx);
break;
case ROOM_STATE.CREATING:
case ROOM_STATE.JOINING:
this._renderConnecting(ctx);
break;
case ROOM_STATE.WAITING:
this._renderWaiting(ctx);
break;
case ROOM_STATE.INPUT_CODE:
this._renderInputCode(ctx);
break;
case ROOM_STATE.COUNTDOWN:
this._renderCountdown(ctx);
break;
case ROOM_STATE.ERROR:
this._renderError(ctx);
break;
}
},
_renderIdle(ctx) {
ctx.fillStyle = '#AAAAAA';
ctx.font = '14px Arial';
ctx.textAlign = 'center';
ctx.fillText(t('room.idleHint'), CENTER_X, SCREEN_HEIGHT * 0.28);
this._drawButton(ctx, this._createBtnRect, t('room.create'));
this._drawButton(ctx, this._joinBtnRect, t('room.join'));
},
_renderConnecting(ctx) {
const dots = '.'.repeat(Math.floor(this._animTimer * 3) % 4);
ctx.fillStyle = '#FFFFFF';
ctx.font = '16px Arial';
ctx.textAlign = 'center';
ctx.fillText(t('room.connecting', { dots }), CENTER_X, SCREEN_HEIGHT * 0.5);
},
_renderWaiting(ctx) {
// Room code display
ctx.fillStyle = '#FFFFFF';
ctx.font = '14px Arial';
ctx.textAlign = 'center';
ctx.fillText(t('room.roomCode'), CENTER_X, SCREEN_HEIGHT * 0.32);
ctx.fillStyle = COLORS.MENU_TITLE;
ctx.font = 'bold 36px Arial';
ctx.fillText(this._roomCode, CENTER_X, SCREEN_HEIGHT * 0.42);
// Waiting animation
const dots = '.'.repeat(Math.floor(this._animTimer * 2) % 4);
ctx.fillStyle = '#AAAAAA';
ctx.font = '16px Arial';
ctx.fillText(t('room.waiting', { dots }), CENTER_X, SCREEN_HEIGHT * 0.55);
// Hint
ctx.fillStyle = '#666666';
ctx.font = '12px Arial';
ctx.fillText(t('room.shareHint'), CENTER_X, SCREEN_HEIGHT * 0.65);
},
_renderInputCode(ctx) {
ctx.fillStyle = '#FFFFFF';
ctx.font = '14px Arial';
ctx.textAlign = 'center';
ctx.fillText(t('room.inputCode'), CENTER_X, SCREEN_HEIGHT * 0.25);
// Code display box
const boxW = Math.min(SCREEN_WIDTH * 0.5, 180);
const boxH = 40;
const boxX = CENTER_X - boxW / 2;
const boxY = SCREEN_HEIGHT * 0.30;
ctx.fillStyle = '#1a1a2e';
ctx.strokeStyle = COLORS.MENU_TITLE;
ctx.lineWidth = 2;
ctx.fillRect(boxX, boxY, boxW, boxH);
ctx.strokeRect(boxX, boxY, boxW, boxH);
// Input text
ctx.fillStyle = COLORS.MENU_TITLE;
ctx.font = 'bold 24px Arial';
const displayCode = this._inputCode + (Math.floor(this._animTimer * 2) % 2 === 0 ? '|' : '');
ctx.fillText(displayCode, CENTER_X, boxY + boxH / 2);
// Numpad
for (const btn of this._numpadRects) {
const label = btn.value === 'del' ? '⌫' : String(btn.value);
this._drawButton(ctx, btn, label, false, 16);
}
// Confirm button
if (this._inputCode.length >= 4) {
this._drawButton(ctx, this._confirmBtnRect, t('common.joinBtn'), false, 16);
}
},
_renderCountdown(ctx) {
ctx.fillStyle = '#00FF00';
ctx.font = '14px Arial';
ctx.textAlign = 'center';
ctx.fillText(t('room.opponentFound'), CENTER_X, SCREEN_HEIGHT * 0.35);
ctx.fillStyle = COLORS.MENU_TITLE;
ctx.font = 'bold 64px Arial';
ctx.fillText(String(this._countdown), CENTER_X, SCREEN_HEIGHT * 0.52);
ctx.fillStyle = '#AAAAAA';
ctx.font = '14px Arial';
ctx.fillText(t('room.starting'), CENTER_X, SCREEN_HEIGHT * 0.65);
},
_renderError(ctx) {
ctx.fillStyle = '#FF4444';
ctx.font = '16px Arial';
ctx.textAlign = 'center';
ctx.fillText(this._errorMsg, CENTER_X, SCREEN_HEIGHT * 0.45);
ctx.fillStyle = '#AAAAAA';
ctx.font = '14px Arial';
ctx.fillText(t('room.tapBack'), CENTER_X, SCREEN_HEIGHT * 0.55);
},
_drawButton(ctx, rect, label, pressed, fontSize) {
if (!rect) return;
const fs = fontSize || 16;
ctx.fillStyle = pressed ? '#0f3460' : COLORS.MENU_BTN;
ctx.strokeStyle = COLORS.MENU_BTN_BORDER;
ctx.lineWidth = 2;
// Rounded rect
const r = 6;
ctx.beginPath();
ctx.moveTo(rect.x + r, rect.y);
ctx.lineTo(rect.x + rect.w - r, rect.y);
ctx.arcTo(rect.x + rect.w, rect.y, rect.x + rect.w, rect.y + r, r);
ctx.lineTo(rect.x + rect.w, rect.y + rect.h - r);
ctx.arcTo(rect.x + rect.w, rect.y + rect.h, rect.x + rect.w - r, rect.y + rect.h, r);
ctx.lineTo(rect.x + r, rect.y + rect.h);
ctx.arcTo(rect.x, rect.y + rect.h, rect.x, rect.y + rect.h - r, r);
ctx.lineTo(rect.x, rect.y + r);
ctx.arcTo(rect.x, rect.y, rect.x + r, rect.y, r);
ctx.closePath();
ctx.fill();
ctx.stroke();
ctx.fillStyle = pressed ? COLORS.MENU_TITLE : COLORS.MENU_BTN_TEXT;
ctx.font = `bold ${fs}px Arial`;
ctx.textAlign = 'center';
ctx.textBaseline = 'middle';
ctx.fillText(label, rect.x + rect.w / 2, rect.y + rect.h / 2);
},
_hitTest(tx, ty, rect) {
if (!rect) return false;
return tx >= rect.x && tx <= rect.x + rect.w && ty >= rect.y && ty <= rect.y + rect.h;
},
handleTouch(eventType, e) {
if (eventType !== 'touchstart') return;
const touch = e.touches[0];
const tx = touch.clientX;
const ty = touch.clientY;
// Back button (always available)
if (this._hitTest(tx, ty, this._backBtnRect)) {
this._goBack();
return;
}
switch (this._state) {
case ROOM_STATE.IDLE:
if (this._hitTest(tx, ty, this._createBtnRect)) {
this._handleCreateRoom();
} else if (this._hitTest(tx, ty, this._joinBtnRect)) {
this._state = ROOM_STATE.INPUT_CODE;
this._inputCode = '';
}
break;
case ROOM_STATE.INPUT_CODE:
// Check numpad
for (const btn of this._numpadRects) {
if (this._hitTest(tx, ty, btn)) {
if (btn.value === 'del') {
this._inputCode = this._inputCode.slice(0, -1);
} else if (this._inputCode.length < 6) {
this._inputCode += String(btn.value);
}
return;
}
}
// Check confirm
if (this._inputCode.length >= 4 && this._hitTest(tx, ty, this._confirmBtnRect)) {
this._handleJoinRoom();
}
break;
case ROOM_STATE.ERROR:
this._state = ROOM_STATE.IDLE;
this._errorMsg = '';
break;
case ROOM_STATE.WAITING:
// Allow going back while waiting
break;
}
},
async _handleCreateRoom() {
this._state = ROOM_STATE.CREATING;
const nm = this._networkManager;
if (!nm.connected) {
const ok = await nm.connect(this._serverUrl);
if (!ok) {
this._errorMsg = t('common.cannotConnect');
this._state = ROOM_STATE.ERROR;
return;
}
}
nm.createRoom();
},
async _handleJoinRoom() {
this._state = ROOM_STATE.JOINING;
const nm = this._networkManager;
if (!nm.connected) {
const ok = await nm.connect(this._serverUrl);
if (!ok) {
this._errorMsg = t('common.cannotConnect');
this._state = ROOM_STATE.ERROR;
return;
}
}
nm.joinRoom(this._inputCode);
},
_goBack() {
// Disconnect if connected
if (this._networkManager && this._networkManager.connected) {
this._networkManager.disconnect();
}
const sm = GameGlobal.sceneManager;
sm.switchTo(SCENE.MENU);
},
};
module.exports = RoomScene;
+167
View File
@@ -0,0 +1,167 @@
/**
* SettingsScene.js
* Settings screen with sound, music, and vibration toggles.
*/
const {
SCREEN_WIDTH,
SCREEN_HEIGHT,
COLORS,
SCENE,
} = require('../base/GameGlobal');
const { t } = require('../i18n/I18n');
const SettingsScene = {
_settings: {
soundEnabled: true,
musicEnabled: true,
vibrationEnabled: true,
},
_buttons: {},
enter() {
// Load settings from storage
try {
const saved = wx.getStorageSync('game_settings');
if (saved) {
this._settings = { ...this._settings, ...JSON.parse(saved) };
}
} catch (e) {
console.warn('[Settings] Failed to load settings:', e);
}
this._buttons = {};
},
exit() {
// Save settings
try {
wx.setStorageSync('game_settings', JSON.stringify(this._settings));
} catch (e) {
console.warn('[Settings] Failed to save settings:', e);
}
},
update(dt) {},
render(ctx) {
// Background
ctx.fillStyle = COLORS.MENU_BG;
ctx.fillRect(0, 0, SCREEN_WIDTH, SCREEN_HEIGHT);
const cx = SCREEN_WIDTH / 2;
let y = 60;
// Title
ctx.fillStyle = COLORS.MENU_TITLE;
ctx.font = 'bold 28px Arial';
ctx.textAlign = 'center';
ctx.textBaseline = 'middle';
ctx.fillText(t('settings.title'), cx, y);
y += 70;
// Toggle items
const toggles = [
{ key: 'soundEnabled', label: t('settings.sound'), icon: '🔊' },
{ key: 'musicEnabled', label: t('settings.music'), icon: '🎵' },
{ key: 'vibrationEnabled', label: t('settings.vibration'), icon: '📳' },
];
for (const toggle of toggles) {
this._renderToggle(ctx, cx, y, toggle);
y += 70;
}
// Back button
y = SCREEN_HEIGHT - 80;
this._renderBackButton(ctx, cx, y);
},
_renderToggle(ctx, cx, y, toggle) {
const w = SCREEN_WIDTH * 0.7;
const h = 50;
const x = cx - w / 2;
const isOn = this._settings[toggle.key];
// Store button rect
this._buttons[toggle.key] = { x, y: y - h / 2, w, h };
// Background
ctx.fillStyle = '#1e1e3a';
ctx.fillRect(x, y - h / 2, w, h);
ctx.strokeStyle = '#333366';
ctx.lineWidth = 1;
ctx.strokeRect(x, y - h / 2, w, h);
// Icon and label
ctx.fillStyle = COLORS.HUD_TEXT;
ctx.font = '16px Arial';
ctx.textAlign = 'left';
ctx.textBaseline = 'middle';
ctx.fillText(`${toggle.icon} ${toggle.label}`, x + 15, y);
// Toggle switch
const switchW = 50;
const switchH = 26;
const switchX = x + w - switchW - 15;
const switchY = y - switchH / 2;
// Switch track
ctx.fillStyle = isOn ? '#4CAF50' : '#555555';
ctx.beginPath();
ctx.arc(switchX + switchH / 2, y, switchH / 2, Math.PI / 2, Math.PI * 3 / 2);
ctx.arc(switchX + switchW - switchH / 2, y, switchH / 2, -Math.PI / 2, Math.PI / 2);
ctx.closePath();
ctx.fill();
// Switch knob
ctx.fillStyle = '#FFFFFF';
ctx.beginPath();
const knobX = isOn ? switchX + switchW - switchH / 2 : switchX + switchH / 2;
ctx.arc(knobX, y, switchH / 2 - 3, 0, Math.PI * 2);
ctx.fill();
},
_renderBackButton(ctx, cx, y) {
const w = SCREEN_WIDTH * 0.4;
const h = 42;
const x = cx - w / 2;
this._buttons['back'] = { x, y: y - h / 2, w, h };
ctx.fillStyle = COLORS.MENU_BTN;
ctx.strokeStyle = COLORS.MENU_BTN_BORDER;
ctx.lineWidth = 2;
ctx.fillRect(x, y - h / 2, w, h);
ctx.strokeRect(x, y - h / 2, w, h);
ctx.fillStyle = COLORS.MENU_BTN_TEXT;
ctx.font = 'bold 16px Arial';
ctx.textAlign = 'center';
ctx.textBaseline = 'middle';
ctx.fillText(t('common.back'), cx, y);
},
handleTouch(eventType, e) {
if (eventType !== 'touchstart') return;
const touch = e.touches[0];
const tx = touch.clientX;
const ty = touch.clientY;
for (const [key, rect] of Object.entries(this._buttons)) {
if (tx >= rect.x && tx <= rect.x + rect.w && ty >= rect.y && ty <= rect.y + rect.h) {
if (key === 'back') {
GameGlobal.sceneManager.switchTo(SCENE.MENU);
} else if (this._settings.hasOwnProperty(key)) {
this._settings[key] = !this._settings[key];
// Notify audio system
GameGlobal.eventBus.emit('settings:changed', this._settings);
}
break;
}
}
},
};
module.exports = SettingsScene;
+279
View File
@@ -0,0 +1,279 @@
/**
* ShopScene.js
* Simplified shop scene for monetization-lite.
* Shows 3 products: Ad-Free (¥18), Gold Pack (¥6), Newcomer Pack (¥1).
*/
const {
SCREEN_WIDTH,
SCREEN_HEIGHT,
COLORS,
SCENE,
} = require('../base/GameGlobal');
const { t } = require('../i18n/I18n');
// Layout
const CARD_W = Math.min(SCREEN_WIDTH * 0.85, 320);
const CARD_H = 70;
const CARD_GAP = 12;
const CARD_X = (SCREEN_WIDTH - CARD_W) / 2;
const CARDS_START_Y = SCREEN_HEIGHT * 0.25;
const ShopScene = {
_buttons: {},
_message: '',
_messageTimer: 0,
enter() {
this._buttons = {};
this._message = '';
this._messageTimer = 0;
this._calculateLayout();
},
exit() {},
_calculateLayout() {
let y = CARDS_START_Y;
// Ad-Free card
this._buttons.adFree = { x: CARD_X, y, w: CARD_W, h: CARD_H };
y += CARD_H + CARD_GAP;
// Gold Pack card
this._buttons.goldPack = { x: CARD_X, y, w: CARD_W, h: CARD_H };
y += CARD_H + CARD_GAP;
// Newcomer Pack card (only if available)
this._buttons.newcomerPack = { x: CARD_X, y, w: CARD_W, h: CARD_H };
y += CARD_H + CARD_GAP + 10;
// Back button
const backW = 100;
const backH = 36;
this._buttons.back = { x: (SCREEN_WIDTH - backW) / 2, y, w: backW, h: backH };
},
update(dt) {
if (this._messageTimer > 0) {
this._messageTimer -= dt;
if (this._messageTimer <= 0) {
this._message = '';
}
}
},
render(ctx) {
// Background
ctx.fillStyle = COLORS.MENU_BG;
ctx.fillRect(0, 0, SCREEN_WIDTH, SCREEN_HEIGHT);
// Title
ctx.fillStyle = COLORS.MENU_TITLE;
ctx.font = 'bold 24px Arial';
ctx.textAlign = 'center';
ctx.textBaseline = 'middle';
ctx.fillText(t('shop.title') || 'Shop', SCREEN_WIDTH / 2, SCREEN_HEIGHT * 0.08);
// Gold balance
const gold = GameGlobal.currencyManager ? GameGlobal.currencyManager.getGold() : 0;
ctx.fillStyle = '#FFD700';
ctx.font = 'bold 16px Arial';
ctx.fillText(`🪙 ${gold}`, SCREEN_WIDTH / 2, SCREEN_HEIGHT * 0.16);
const pm = GameGlobal.paymentManager;
// Ad-Free card
const adFreePurchased = pm && pm.isAdFreePurchased();
this._renderProductCard(ctx, this._buttons.adFree,
t('shop.adFree') || 'Remove Ads',
t('shop.adFreeDesc') || 'Permanently remove interstitial ads',
adFreePurchased ? (t('shop.adFreeOwned') || 'Owned') : '¥18',
adFreePurchased
);
// Gold Pack card
this._renderProductCard(ctx, this._buttons.goldPack,
t('shop.goldPack') || 'Gold Pack',
t('shop.goldPackDesc') || '1000 Gold',
'¥6',
false
);
// Newcomer Pack card
const newcomerAvailable = pm && pm.isNewcomerPackAvailable();
if (newcomerAvailable) {
const remainingMs = pm.getNewcomerPackRemainingMs();
const hours = Math.floor(remainingMs / (60 * 60 * 1000));
const mins = Math.floor((remainingMs % (60 * 60 * 1000)) / (60 * 1000));
const timeStr = `${hours}h ${mins}m`;
this._renderProductCard(ctx, this._buttons.newcomerPack,
t('shop.newcomerPack') || 'Newcomer Pack',
`${t('shop.newcomerPackDesc') || '500 Gold'} (⏰ ${timeStr})`,
'¥1',
false,
'#FF9800' // highlight color for limited time
);
} else {
// Show expired/purchased state
this._renderProductCard(ctx, this._buttons.newcomerPack,
t('shop.newcomerPack') || 'Newcomer Pack',
t('shop.newcomerExpired') || 'Expired',
'--',
true
);
}
// Back button
this._renderButton(ctx, this._buttons.back, t('common.back') || '← Back', '#666666');
// Toast message
if (this._message) {
ctx.fillStyle = 'rgba(0,0,0,0.7)';
const msgW = 200;
const msgH = 36;
ctx.fillRect(SCREEN_WIDTH / 2 - msgW / 2, SCREEN_HEIGHT * 0.92 - msgH / 2, msgW, msgH);
ctx.fillStyle = '#FFFFFF';
ctx.font = '14px Arial';
ctx.textAlign = 'center';
ctx.fillText(this._message, SCREEN_WIDTH / 2, SCREEN_HEIGHT * 0.92);
}
},
_renderProductCard(ctx, rect, title, desc, priceLabel, disabled, highlightColor) {
if (!rect) return;
// Card background
ctx.fillStyle = disabled ? 'rgba(255,255,255,0.02)' : 'rgba(255,255,255,0.06)';
ctx.strokeStyle = highlightColor || (disabled ? '#333333' : '#555555');
ctx.lineWidth = highlightColor ? 2 : 1;
const r = 8;
ctx.beginPath();
ctx.moveTo(rect.x + r, rect.y);
ctx.lineTo(rect.x + rect.w - r, rect.y);
ctx.arcTo(rect.x + rect.w, rect.y, rect.x + rect.w, rect.y + r, r);
ctx.lineTo(rect.x + rect.w, rect.y + rect.h - r);
ctx.arcTo(rect.x + rect.w, rect.y + rect.h, rect.x + rect.w - r, rect.y + rect.h, r);
ctx.lineTo(rect.x + r, rect.y + rect.h);
ctx.arcTo(rect.x, rect.y + rect.h, rect.x, rect.y + rect.h - r, r);
ctx.lineTo(rect.x, rect.y + r);
ctx.arcTo(rect.x, rect.y, rect.x + r, rect.y, r);
ctx.closePath();
ctx.fill();
ctx.stroke();
// Title (left aligned)
ctx.fillStyle = disabled ? '#666666' : '#FFFFFF';
ctx.font = 'bold 15px Arial';
ctx.textAlign = 'left';
ctx.textBaseline = 'middle';
ctx.fillText(title, rect.x + 15, rect.y + rect.h * 0.35);
// Description
ctx.fillStyle = disabled ? '#444444' : '#AAAAAA';
ctx.font = '11px Arial';
ctx.fillText(desc, rect.x + 15, rect.y + rect.h * 0.65);
// Price (right aligned)
ctx.fillStyle = disabled ? '#444444' : (highlightColor || '#FFD700');
ctx.font = 'bold 16px Arial';
ctx.textAlign = 'right';
ctx.fillText(priceLabel, rect.x + rect.w - 15, rect.y + rect.h / 2);
},
_renderButton(ctx, rect, label, color) {
if (!rect) return;
ctx.fillStyle = color;
const r = 6;
ctx.beginPath();
ctx.moveTo(rect.x + r, rect.y);
ctx.lineTo(rect.x + rect.w - r, rect.y);
ctx.arcTo(rect.x + rect.w, rect.y, rect.x + rect.w, rect.y + r, r);
ctx.lineTo(rect.x + rect.w, rect.y + rect.h - r);
ctx.arcTo(rect.x + rect.w, rect.y + rect.h, rect.x + rect.w - r, rect.y + rect.h, r);
ctx.lineTo(rect.x + r, rect.y + rect.h);
ctx.arcTo(rect.x, rect.y + rect.h, rect.x, rect.y + rect.h - r, r);
ctx.lineTo(rect.x, rect.y + r);
ctx.arcTo(rect.x, rect.y, rect.x + r, rect.y, r);
ctx.closePath();
ctx.fill();
ctx.fillStyle = '#FFFFFF';
ctx.font = 'bold 14px Arial';
ctx.textAlign = 'center';
ctx.textBaseline = 'middle';
ctx.fillText(label, rect.x + rect.w / 2, rect.y + rect.h / 2);
},
_hitTest(tx, ty, rect) {
if (!rect) return false;
return tx >= rect.x && tx <= rect.x + rect.w && ty >= rect.y && ty <= rect.y + rect.h;
},
_showMessage(msg) {
this._message = msg;
this._messageTimer = 2;
},
handleTouch(eventType, e) {
if (eventType !== 'touchstart') return;
const touch = e.touches[0];
const tx = touch.clientX;
const ty = touch.clientY;
const pm = GameGlobal.paymentManager;
// Ad-Free
if (this._hitTest(tx, ty, this._buttons.adFree)) {
if (pm && !pm.isAdFreePurchased()) {
pm.purchaseAdFree((result) => {
if (result.success) {
this._showMessage('✅ Ad-Free activated!');
} else {
this._showMessage(result.error || 'Purchase failed');
}
});
}
return;
}
// Gold Pack
if (this._hitTest(tx, ty, this._buttons.goldPack)) {
if (pm) {
pm.purchaseGoldPack((result) => {
if (result.success) {
this._showMessage('✅ +1000 Gold!');
} else {
this._showMessage(result.error || 'Purchase failed');
}
});
}
return;
}
// Newcomer Pack
if (this._hitTest(tx, ty, this._buttons.newcomerPack)) {
if (pm && pm.isNewcomerPackAvailable()) {
pm.purchaseNewcomerPack((result) => {
if (result.success) {
this._showMessage('✅ +500 Gold!');
} else {
this._showMessage(result.error || 'Purchase failed');
}
});
}
return;
}
// Back
if (this._hitTest(tx, ty, this._buttons.back)) {
GameGlobal.sceneManager.switchTo(SCENE.MENU);
return;
}
},
};
module.exports = ShopScene;
File diff suppressed because it is too large Load Diff
+588
View File
@@ -0,0 +1,588 @@
/**
* TeamResultScene.js
* 3v3 Team match result screen.
* Shows winner, per-player stats (kills/deaths/assists/base damage),
* base HP summary, and options to rematch or return to menu.
*/
const {
SCREEN_WIDTH,
SCREEN_HEIGHT,
COLORS,
SCENE,
NET_MSG,
} = require('../base/GameGlobal');
const { t } = require('../i18n/I18n');
// Layout
const BTN_WIDTH = Math.min(SCREEN_WIDTH * 0.35, 180);
const BTN_HEIGHT = Math.min(36, SCREEN_HEIGHT * 0.07);
const BTN_GAP = 14;
const CENTER_X = SCREEN_WIDTH / 2;
// Team colors
const TEAM_A_COLOR = '#4A90D9';
const TEAM_B_COLOR = '#E94560';
const TeamResultScene = {
_winner: '',
_winReason: '',
_myTeam: '',
_didWin: false,
_teamABaseHp: 0,
_teamBBaseHp: 0,
_stats: {},
_players: [],
_elapsedTime: 0,
_teamId: '',
_animTimer: 0,
_battleMode: '3v3', // '1v1' or '3v3'
// Rematch state
_rematchRequested: false,
_rematchReadyCount: 0,
_rematchTotalCount: 0,
_networkManager: null,
_unsubscribers: [],
// Button rects
_rematchBtnRect: null,
_menuBtnRect: null,
_adDoubleBtnRect: null,
// Ad state
_adWatched: false,
_goldReward: 0,
// Scroll state for player list
_scrollY: 0,
enter(params) {
this._winner = (params && params.winner) || '';
this._winReason = (params && params.winReason) || 'base_destroyed';
this._myTeam = (params && params.myTeam) || 'A';
this._didWin = (params && params.didWin) || false;
this._teamABaseHp = (params && params.teamABaseHp) || 0;
this._teamBBaseHp = (params && params.teamBBaseHp) || 0;
this._stats = (params && params.stats) || {};
this._players = (params && params.players) || [];
this._elapsedTime = (params && params.elapsedTime) || 0;
this._teamId = (params && params.teamId) || '';
this._animTimer = 0;
this._scrollY = 0;
this._battleMode = (params && params.battleMode) || '3v3';
this._rematchRequested = false;
this._rematchReadyCount = 0;
this._rematchTotalCount = 0;
this._networkManager = GameGlobal.networkManager;
this._adWatched = false;
this._goldReward = 0;
// Calculate and award gold
this._calculateAndAwardGold();
this._setupNetworkEvents();
const btnY = SCREEN_HEIGHT * 0.88;
this._rematchBtnRect = {
x: CENTER_X - BTN_WIDTH - BTN_GAP / 2,
y: btnY,
w: BTN_WIDTH,
h: BTN_HEIGHT,
};
this._menuBtnRect = {
x: CENTER_X + BTN_GAP / 2,
y: btnY,
w: BTN_WIDTH,
h: BTN_HEIGHT,
};
// Double reward ad button (above rematch/menu buttons)
const adBtnY = btnY - BTN_HEIGHT - BTN_GAP;
this._adDoubleBtnRect = {
x: CENTER_X - BTN_WIDTH * 0.75,
y: adBtnY,
w: BTN_WIDTH * 1.5,
h: BTN_HEIGHT,
};
},
exit() {
this._cleanupNetworkEvents();
},
_setupNetworkEvents() {
this._cleanupNetworkEvents();
const nm = this._networkManager;
if (!nm) return;
const unsubs = [];
// Listen for rematch ready updates
unsubs.push(nm.on(NET_MSG.REMATCH_READY, (data) => {
console.log('[TeamResultScene] REMATCH_READY received:', JSON.stringify(data));
this._rematchReadyCount = data.readyCount || 0;
this._rematchTotalCount = data.totalCount || 0;
}));
// Listen for game start (rematch accepted, new game starting)
unsubs.push(nm.on(NET_MSG.GAME_START, (data) => {
console.log('[TeamResultScene] GAME_START received for rematch');
this._startRematchGame(data);
}));
unsubs.push(nm.on(NET_MSG.TEAM_GAME_START, (data) => {
console.log('[TeamResultScene] TEAM_GAME_START received for rematch');
this._startRematchGame(data);
}));
this._unsubscribers = unsubs;
},
_cleanupNetworkEvents() {
for (const unsub of this._unsubscribers) {
if (typeof unsub === 'function') unsub();
}
this._unsubscribers = [];
},
/**
* Calculate and award gold for team match.
* @private
*/
_calculateAndAwardGold() {
let gold = 50; // Base reward per requirements
// Find local player stats
const localPlayer = this._players.find(p => p.isLocal);
if (localPlayer) {
const stats = this._stats[localPlayer.playerId] || {};
gold += (stats.kills || 0) * 10;
gold += (stats.assists || 0) * 5;
}
// Victory bonus
if (this._didWin) {
gold += 50;
}
this._goldReward = gold;
// Award gold
if (gold > 0 && GameGlobal.currencyManager) {
GameGlobal.currencyManager.addGold(gold);
}
},
_startRematchGame(data) {
const sm = GameGlobal.sceneManager;
if (!sm._scenes.has(SCENE.TEAM_GAME)) {
const TeamGameScene = require('./TeamGameScene');
sm.register(SCENE.TEAM_GAME, TeamGameScene);
}
const myPlayerId = this._networkManager ? this._networkManager.playerId : 'local';
sm.switchTo(SCENE.TEAM_GAME, {
teamId: data.roomId || this._teamId,
roomId: data.roomId || this._teamId,
mapId: data.mapId || null,
teamA: data.teamA || [],
teamB: data.teamB || [],
teamABaseHp: data.teamABaseHp,
teamBBaseHp: data.teamBBaseHp,
myPlayerId,
battleMode: data.battleMode || this._battleMode,
});
},
update(dt) {
this._animTimer += dt;
},
render(ctx) {
// Background
ctx.fillStyle = COLORS.MENU_BG;
ctx.fillRect(0, 0, SCREEN_WIDTH, SCREEN_HEIGHT);
// Top accent bar
const gradient = ctx.createLinearGradient(0, 0, SCREEN_WIDTH, 0);
gradient.addColorStop(0, '#0f3460');
gradient.addColorStop(0.5, '#e94560');
gradient.addColorStop(1, '#0f3460');
ctx.fillStyle = gradient;
ctx.fillRect(0, 0, SCREEN_WIDTH, 4);
// Title
ctx.font = 'bold 16px Arial';
ctx.textAlign = 'center';
ctx.textBaseline = 'middle';
ctx.fillStyle = '#AAAAAA';
ctx.fillText(t('teamResult.title'), CENTER_X, SCREEN_HEIGHT * 0.05);
// Winner announcement with pulsing effect
let resultText, resultColor;
if (this._didWin) {
resultText = t('teamResult.victory');
resultColor = '#00FF00';
} else {
resultText = t('teamResult.defeat');
resultColor = '#FF4444';
}
const scale = 1 + Math.sin(this._animTimer * 3) * 0.05;
ctx.save();
ctx.translate(CENTER_X, SCREEN_HEIGHT * 0.13);
ctx.scale(scale, scale);
ctx.fillStyle = resultColor;
ctx.font = 'bold 28px Arial';
ctx.fillText(resultText, 0, 0);
ctx.restore();
// Base HP summary
const hpY = SCREEN_HEIGHT * 0.2;
ctx.font = 'bold 12px Arial';
ctx.textAlign = 'center';
ctx.fillStyle = TEAM_A_COLOR;
ctx.fillText(t('teamResult.teamAHp', { hp: this._teamABaseHp }), CENTER_X - 70, hpY);
ctx.fillStyle = '#AAAAAA';
ctx.fillText('vs', CENTER_X, hpY);
ctx.fillStyle = TEAM_B_COLOR;
ctx.fillText(t('teamResult.teamBHp', { hp: this._teamBBaseHp }), CENTER_X + 70, hpY);
// Win reason
let reasonText = t('teamResult.baseDestroyed');
if (this._winReason === 'disconnected') {
reasonText = t('teamResult.disconnectedReason');
}
ctx.fillStyle = '#888888';
ctx.font = '10px Arial';
ctx.fillText(reasonText, CENTER_X, hpY + 16);
// Player stats table
this._renderStatsTable(ctx);
// Double reward ad button
if (!this._adWatched) {
this._drawButton(ctx, this._adDoubleBtnRect, t('result.adDouble') || '📺 Double Rewards');
}
// Gold reward display
if (this._goldReward > 0) {
const goldY = this._adDoubleBtnRect ? this._adDoubleBtnRect.y - 20 : SCREEN_HEIGHT * 0.82;
ctx.fillStyle = '#FFD700';
ctx.font = 'bold 14px Arial';
ctx.textAlign = 'center';
const goldLabel = this._adWatched
? `🪙 +${this._goldReward} (${t('result.doubled') || '2x!'})`
: `🪙 +${this._goldReward}`;
ctx.fillText(goldLabel, CENTER_X, goldY);
}
// Buttons
if (this._rematchRequested) {
// Show waiting state on rematch button
const dots = '.'.repeat(Math.floor(this._animTimer * 3) % 4);
this._drawButton(ctx, this._rematchBtnRect,
t('teamResult.rematchWaiting', { ready: this._rematchReadyCount, total: this._rematchTotalCount }) + dots,
true);
} else {
this._drawButton(ctx, this._rematchBtnRect, t('teamResult.rematch'));
}
this._drawButton(ctx, this._menuBtnRect, t('teamResult.backMenu'));
},
_renderStatsTable(ctx) {
const tableY = SCREEN_HEIGHT * 0.28;
const tableW = Math.min(SCREEN_WIDTH * 0.92, 400);
const tableX = CENTER_X - tableW / 2;
const rowH = 18;
const headerH = 22;
// Sort players: Team A first, then Team B; within team sort by kills desc
const teamAPlayers = this._players.filter(p => p.team === 'A');
const teamBPlayers = this._players.filter(p => p.team === 'B');
const sortByKills = (a, b) => {
const sa = this._stats[a.playerId] || {};
const sb = this._stats[b.playerId] || {};
return (sb.kills || 0) - (sa.kills || 0);
};
teamAPlayers.sort(sortByKills);
teamBPlayers.sort(sortByKills);
// Column positions
const cols = {
name: tableX + tableW * 0.22,
kills: tableX + tableW * 0.48,
deaths: tableX + tableW * 0.60,
assists: tableX + tableW * 0.72,
baseDmg: tableX + tableW * 0.88,
};
// Render Team A section
let y = tableY;
// Team A header
ctx.fillStyle = 'rgba(74, 144, 217, 0.15)';
ctx.fillRect(tableX, y, tableW, headerH);
ctx.fillStyle = TEAM_A_COLOR;
ctx.font = 'bold 11px Arial';
ctx.textAlign = 'left';
ctx.textBaseline = 'middle';
ctx.fillText(t('teamResult.teamAHeader') + (this._myTeam === 'A' ? t('teamResult.myTeamSuffix') : ''), tableX + 6, y + headerH / 2);
// Column headers
ctx.fillStyle = '#AAAAAA';
ctx.font = '9px Arial';
ctx.textAlign = 'center';
ctx.fillText(t('teamResult.player'), cols.name, y + headerH / 2);
ctx.fillText(t('teamResult.k'), cols.kills, y + headerH / 2);
ctx.fillText(t('teamResult.d'), cols.deaths, y + headerH / 2);
ctx.fillText(t('teamResult.a'), cols.assists, y + headerH / 2);
ctx.fillText(t('teamResult.dmg'), cols.baseDmg, y + headerH / 2);
y += headerH;
// Team A players
for (const player of teamAPlayers) {
const stats = this._stats[player.playerId] || {};
const isLocal = player.isLocal;
ctx.fillStyle = isLocal ? 'rgba(255, 215, 0, 0.1)' : 'rgba(255,255,255,0.02)';
ctx.fillRect(tableX, y, tableW, rowH);
// Player name
ctx.fillStyle = isLocal ? '#FFD700' : '#CCCCCC';
ctx.font = isLocal ? 'bold 10px Arial' : '10px Arial';
ctx.textAlign = 'center';
const name = player.isBot ? t('teamResult.bot') : (player.playerId.length > 10 ? player.playerId.substring(0, 10) + '..' : player.playerId);
ctx.fillText(name + (isLocal ? ' ★' : ''), cols.name, y + rowH / 2);
// Stats
ctx.fillStyle = '#FFFFFF';
ctx.font = '10px Arial';
ctx.fillText(String(stats.kills || 0), cols.kills, y + rowH / 2);
ctx.fillText(String(stats.deaths || 0), cols.deaths, y + rowH / 2);
ctx.fillText(String(stats.assists || 0), cols.assists, y + rowH / 2);
ctx.fillText(String(stats.baseDamage || 0), cols.baseDmg, y + rowH / 2);
y += rowH;
}
// Separator
y += 4;
// Team B header
ctx.fillStyle = 'rgba(233, 69, 96, 0.15)';
ctx.fillRect(tableX, y, tableW, headerH);
ctx.fillStyle = TEAM_B_COLOR;
ctx.font = 'bold 11px Arial';
ctx.textAlign = 'left';
ctx.fillText(t('teamResult.teamBHeader') + (this._myTeam === 'B' ? t('teamResult.myTeamSuffix') : ''), tableX + 6, y + headerH / 2);
// Column headers
ctx.fillStyle = '#AAAAAA';
ctx.font = '9px Arial';
ctx.textAlign = 'center';
ctx.fillText(t('teamResult.player'), cols.name, y + headerH / 2);
ctx.fillText(t('teamResult.k'), cols.kills, y + headerH / 2);
ctx.fillText(t('teamResult.d'), cols.deaths, y + headerH / 2);
ctx.fillText(t('teamResult.a'), cols.assists, y + headerH / 2);
ctx.fillText(t('teamResult.dmg'), cols.baseDmg, y + headerH / 2);
y += headerH;
// Team B players
for (const player of teamBPlayers) {
const stats = this._stats[player.playerId] || {};
const isLocal = player.isLocal;
ctx.fillStyle = isLocal ? 'rgba(255, 215, 0, 0.1)' : 'rgba(255,255,255,0.02)';
ctx.fillRect(tableX, y, tableW, rowH);
// Player name
ctx.fillStyle = isLocal ? '#FFD700' : '#CCCCCC';
ctx.font = isLocal ? 'bold 10px Arial' : '10px Arial';
ctx.textAlign = 'center';
const name = player.isBot ? t('teamResult.bot') : (player.playerId.length > 10 ? player.playerId.substring(0, 10) + '..' : player.playerId);
ctx.fillText(name + (isLocal ? ' ★' : ''), cols.name, y + rowH / 2);
// Stats
ctx.fillStyle = '#FFFFFF';
ctx.font = '10px Arial';
ctx.fillText(String(stats.kills || 0), cols.kills, y + rowH / 2);
ctx.fillText(String(stats.deaths || 0), cols.deaths, y + rowH / 2);
ctx.fillText(String(stats.assists || 0), cols.assists, y + rowH / 2);
ctx.fillText(String(stats.baseDamage || 0), cols.baseDmg, y + rowH / 2);
y += rowH;
}
// Elapsed time display
if (this._elapsedTime > 0) {
y += 8;
const minutes = Math.floor(this._elapsedTime / 60);
const seconds = this._elapsedTime % 60;
ctx.fillStyle = '#666666';
ctx.font = '10px Arial';
ctx.textAlign = 'center';
ctx.fillText(
t('teamResult.duration', { time: `${minutes}:${seconds.toString().padStart(2, '0')}` }),
CENTER_X,
y
);
}
// MVP highlight (player with most kills)
const allPlayers = [...teamAPlayers, ...teamBPlayers];
let mvp = null;
let maxKills = 0;
for (const p of allPlayers) {
const s = this._stats[p.playerId] || {};
if ((s.kills || 0) > maxKills) {
maxKills = s.kills || 0;
mvp = p;
}
}
if (mvp && maxKills > 0) {
y += 16;
ctx.fillStyle = '#FFD700';
ctx.font = 'bold 11px Arial';
ctx.textAlign = 'center';
const mvpName = mvp.isBot ? t('teamResult.bot').replace('🤖 ', '') : mvp.playerId;
ctx.fillText(t('teamResult.mvp', { name: mvpName, kills: maxKills }), CENTER_X, y);
}
// Rank points change
y += 18;
const basePoints = 20;
const mvpBonus = 5;
if (this._didWin) {
const isMvp = mvp && mvp.isLocal;
const points = basePoints + (isMvp ? mvpBonus : 0);
ctx.fillStyle = '#00FF00';
ctx.font = 'bold 11px Arial';
ctx.textAlign = 'center';
ctx.fillText(t('teamResult.rankUp', { points }) + (isMvp ? t('teamResult.mvpBonus') : ''), CENTER_X, y);
} else {
ctx.fillStyle = '#FF6347';
ctx.font = 'bold 11px Arial';
ctx.textAlign = 'center';
ctx.fillText(t('teamResult.rankDown', { points: basePoints }), CENTER_X, y);
}
},
_drawButton(ctx, rect, label) {
if (!rect) return;
ctx.fillStyle = COLORS.MENU_BTN;
ctx.strokeStyle = COLORS.MENU_BTN_BORDER;
ctx.lineWidth = 2;
const r = 6;
ctx.beginPath();
ctx.moveTo(rect.x + r, rect.y);
ctx.lineTo(rect.x + rect.w - r, rect.y);
ctx.arcTo(rect.x + rect.w, rect.y, rect.x + rect.w, rect.y + r, r);
ctx.lineTo(rect.x + rect.w, rect.y + rect.h - r);
ctx.arcTo(rect.x + rect.w, rect.y + rect.h, rect.x + rect.w - r, rect.y + rect.h, r);
ctx.lineTo(rect.x + r, rect.y + rect.h);
ctx.arcTo(rect.x, rect.y + rect.h, rect.x, rect.y + rect.h - r, r);
ctx.lineTo(rect.x, rect.y + r);
ctx.arcTo(rect.x, rect.y, rect.x + r, rect.y, r);
ctx.closePath();
ctx.fill();
ctx.stroke();
ctx.fillStyle = COLORS.MENU_BTN_TEXT;
ctx.font = 'bold 14px Arial';
ctx.textAlign = 'center';
ctx.textBaseline = 'middle';
ctx.fillText(label, rect.x + rect.w / 2, rect.y + rect.h / 2);
},
_hitTest(tx, ty, rect) {
if (!rect) return false;
return tx >= rect.x && tx <= rect.x + rect.w && ty >= rect.y && ty <= rect.y + rect.h;
},
handleTouch(eventType, e) {
if (eventType !== 'touchstart') return;
const touch = e.touches[0];
const tx = touch.clientX;
const ty = touch.clientY;
// Double reward ad button
if (!this._adWatched && this._hitTest(tx, ty, this._adDoubleBtnRect)) {
const AdManager = require('../managers/AdManager');
if (GameGlobal.adManager &&
GameGlobal.adManager.canShowScene(AdManager.AD_SCENE.DOUBLE_REWARD)) {
GameGlobal.adManager.showRewardedVideoForScene(
AdManager.AD_SCENE.DOUBLE_REWARD,
(completed) => {
if (completed) {
this._adWatched = true;
// Award bonus gold (double the original reward)
if (this._goldReward > 0 && GameGlobal.currencyManager) {
GameGlobal.currencyManager.addGold(this._goldReward);
this._goldReward *= 2; // Update display
}
console.log('[TeamResultScene] Double reward ad completed');
}
}
);
}
return;
}
// Rematch button -> send rematch request to server (reuse room)
if (this._hitTest(tx, ty, this._rematchBtnRect)) {
if (!this._rematchRequested) {
this._rematchRequested = true;
const nm = this._networkManager;
console.log(`[TeamResultScene] Rematch clicked. nm=${!!nm}, connected=${nm ? nm.connected : 'N/A'}, teamId=${this._teamId}`);
if (nm && nm.connected) {
nm.send(NET_MSG.REMATCH, { teamId: this._teamId });
console.log('[TeamResultScene] Rematch request sent');
} else {
// Not connected, fall back to creating a new room
this._rematchRequested = false;
const sm = GameGlobal.sceneManager;
if (this._battleMode === '1v1') {
if (!sm._scenes.has(SCENE.PVP_ROOM)) {
const RoomScene = require('./RoomScene');
sm.register(SCENE.PVP_ROOM, RoomScene);
}
sm.switchTo(SCENE.PVP_ROOM);
} else {
if (!sm._scenes.has(SCENE.TEAM_ROOM)) {
const TeamRoomScene = require('./TeamRoomScene');
sm.register(SCENE.TEAM_ROOM, TeamRoomScene);
}
sm.switchTo(SCENE.TEAM_ROOM);
}
}
}
return;
}
// Menu button -> disconnect and go to menu
if (this._hitTest(tx, ty, this._menuBtnRect)) {
// Show interstitial ad when leaving
if (GameGlobal.adManager) {
GameGlobal.adManager.showInterstitial();
}
if (GameGlobal.networkManager && GameGlobal.networkManager.connected) {
GameGlobal.networkManager.disconnect();
}
const sm = GameGlobal.sceneManager;
sm.switchTo(SCENE.MENU);
return;
}
},
};
module.exports = TeamResultScene;
+832
View File
@@ -0,0 +1,832 @@
/**
* TeamRoomScene.js
* 3v3 Team room UI scene.
* Supports team creation, joining, ready state, leader controls,
* matchmaking, and WeChat friend invitation.
*/
const {
SCREEN_WIDTH,
SCREEN_HEIGHT,
COLORS,
SCENE,
NET_MSG,
TEAM_SIZE,
SERVER_URL,
} = require('../base/GameGlobal');
const { t } = require('../i18n/I18n');
// ============================================================
// Layout Constants
// ============================================================
const BTN_WIDTH = Math.min(SCREEN_WIDTH * 0.35, 180);
const BTN_HEIGHT = Math.min(36, SCREEN_HEIGHT * 0.07);
const BTN_GAP = 10;
const CENTER_X = SCREEN_WIDTH / 2;
const SLOT_WIDTH = Math.min(SCREEN_WIDTH * 0.15, 80);
const SLOT_HEIGHT = Math.min(SCREEN_HEIGHT * 0.18, 90);
const SLOT_GAP = 8;
// ============================================================
// Team Room States
// ============================================================
const TEAM_STATE = {
MODE_SELECT: 'mode_select', // Choose: create team or solo match
JOINING: 'joining', // Auto-joining a team from invite
FORMING: 'forming', // Team room, waiting for members
MATCHING: 'matching', // In matchmaking queue
COUNTDOWN: 'countdown', // Match found, counting down
ERROR: 'error', // Error state
};
// ============================================================
// Team Room Scene
// ============================================================
const TeamRoomScene = {
_state: TEAM_STATE.MODE_SELECT,
_teamData: null, // { teamId, state, leaderId, teamA, teamB }
_errorMsg: '',
_animTimer: 0,
_matchTimer: 0, // Seconds elapsed in matching
_countdown: 3,
_countdownTimer: 0,
_networkManager: null,
_unsubscribers: [],
_isLeader: false,
_myPlayerId: null,
// Server URL (from global config)
_serverUrl: SERVER_URL,
// Button rects
_createTeamBtnRect: null,
_soloMatchBtnRect: null,
_backBtnRect: null,
_inviteBtnRect: null,
_matchBtnRect: null,
_readyBtnRect: null,
_disbandBtnRect: null,
_leaveBtnRect: null,
_cancelMatchBtnRect: null,
_slotRects: [],
_kickBtnRects: [],
enter(params) {
this._state = TEAM_STATE.MODE_SELECT;
this._teamData = null;
this._errorMsg = '';
this._animTimer = 0;
this._matchTimer = 0;
this._countdown = 3;
this._countdownTimer = 0;
this._networkManager = GameGlobal.networkManager;
this._myPlayerId = this._networkManager ? this._networkManager.playerId : 'player_' + Date.now();
this._isLeader = false;
this._buildLayout();
// Setup network events BEFORE auto-join so listeners are ready
this._setupNetworkEvents();
// If entering with a teamId (from invite card), auto-join
if (params && params.teamId) {
this._autoJoinTeam(params.teamId);
}
},
/**
* Update share content so that any share from the top-right menu
* always carries the current teamId.
* @private
*/
_updateShareContent() {
if (!this._teamData || !this._teamData.teamId) return;
const shareManager = GameGlobal.shareManager;
if (shareManager) {
shareManager.setShareContent({
title: t('teamRoom.shareTitle'),
imageUrl: '',
query: `teamId=${this._teamData.teamId}`,
});
}
},
exit() {
this._cleanupNetworkEvents();
// Reset share content when leaving team room
const shareManager = GameGlobal.shareManager;
if (shareManager) {
shareManager.resetShareContent();
}
},
_buildLayout() {
const modeY = SCREEN_HEIGHT * 0.4;
this._createTeamBtnRect = {
x: CENTER_X - BTN_WIDTH / 2,
y: modeY,
w: BTN_WIDTH,
h: BTN_HEIGHT,
};
this._soloMatchBtnRect = {
x: CENTER_X - BTN_WIDTH / 2,
y: modeY + BTN_HEIGHT + BTN_GAP,
w: BTN_WIDTH,
h: BTN_HEIGHT,
};
this._backBtnRect = {
x: 10,
y: 10,
w: 60,
h: 30,
};
// Team member slots (5 slots in a row)
const totalSlotsWidth = TEAM_SIZE * SLOT_WIDTH + (TEAM_SIZE - 1) * SLOT_GAP;
const slotsStartX = CENTER_X - totalSlotsWidth / 2;
const slotsY = SCREEN_HEIGHT * 0.25;
this._slotRects = [];
this._kickBtnRects = [];
for (let i = 0; i < TEAM_SIZE; i++) {
const x = slotsStartX + i * (SLOT_WIDTH + SLOT_GAP);
this._slotRects.push({
x, y: slotsY, w: SLOT_WIDTH, h: SLOT_HEIGHT,
});
// Kick button (small X at top-right of slot)
this._kickBtnRects.push({
x: x + SLOT_WIDTH - 18, y: slotsY + 2, w: 16, h: 16,
});
}
// Action buttons (below slots)
const actionY = SCREEN_HEIGHT * 0.58;
const smallBtnW = BTN_WIDTH * 0.8;
this._inviteBtnRect = {
x: CENTER_X - smallBtnW - BTN_GAP / 2,
y: actionY,
w: smallBtnW,
h: BTN_HEIGHT,
};
this._matchBtnRect = {
x: CENTER_X + BTN_GAP / 2,
y: actionY,
w: smallBtnW,
h: BTN_HEIGHT,
};
this._readyBtnRect = {
x: CENTER_X - smallBtnW / 2,
y: actionY,
w: smallBtnW,
h: BTN_HEIGHT,
};
this._disbandBtnRect = {
x: CENTER_X - smallBtnW - BTN_GAP / 2,
y: actionY + BTN_HEIGHT + BTN_GAP,
w: smallBtnW,
h: BTN_HEIGHT,
};
this._leaveBtnRect = {
x: CENTER_X - smallBtnW / 2,
y: actionY + BTN_HEIGHT + BTN_GAP,
w: smallBtnW,
h: BTN_HEIGHT,
};
this._cancelMatchBtnRect = {
x: CENTER_X - BTN_WIDTH / 2,
y: SCREEN_HEIGHT * 0.7,
w: BTN_WIDTH,
h: BTN_HEIGHT,
};
},
_setupNetworkEvents() {
this._cleanupNetworkEvents();
const nm = this._networkManager;
if (!nm) return;
const unsubs = [];
unsubs.push(nm.on(NET_MSG.TEAM_STATE, (data) => {
this._teamData = data;
this._isLeader = data.leaderId === this._myPlayerId;
if (data.state === 'forming') {
this._state = TEAM_STATE.FORMING;
} else if (data.state === 'matching') {
this._state = TEAM_STATE.MATCHING;
}
// Keep share content up-to-date with current teamId
this._updateShareContent();
}));
unsubs.push(nm.on(NET_MSG.TEAM_DISBAND, (data) => {
this._teamData = null;
this._state = TEAM_STATE.MODE_SELECT;
if (data.reason === 'kicked') {
this._errorMsg = t('common.kicked');
this._state = TEAM_STATE.ERROR;
}
}));
unsubs.push(nm.on(NET_MSG.MATCH_FOUND, () => {
this._state = TEAM_STATE.COUNTDOWN;
this._countdown = 3;
this._countdownTimer = 0;
}));
unsubs.push(nm.on(NET_MSG.TEAM_GAME_START, (data) => {
this._startTeamGame(data);
}));
unsubs.push(nm.on(NET_MSG.ROOM_ERROR, (data) => {
this._errorMsg = data.message || 'Unknown error';
// Only switch to error state if not already in game transition
if (this._state !== TEAM_STATE.COUNTDOWN) {
this._state = TEAM_STATE.ERROR;
}
}));
unsubs.push(nm.on('error', () => {
this._errorMsg = t('common.connectFailed');
this._state = TEAM_STATE.ERROR;
}));
unsubs.push(nm.on('disconnected', () => {
if (this._state !== TEAM_STATE.MODE_SELECT) {
this._errorMsg = t('common.disconnected');
this._state = TEAM_STATE.ERROR;
}
}));
this._unsubscribers = unsubs;
},
_cleanupNetworkEvents() {
for (const unsub of this._unsubscribers) {
if (typeof unsub === 'function') unsub();
}
this._unsubscribers = [];
},
update(dt) {
this._animTimer += dt;
if (this._state === TEAM_STATE.MATCHING) {
this._matchTimer += dt;
}
if (this._state === TEAM_STATE.COUNTDOWN) {
this._countdownTimer += dt;
if (this._countdownTimer >= 1) {
this._countdownTimer -= 1;
this._countdown--;
}
}
},
_startTeamGame(data) {
const sm = GameGlobal.sceneManager;
if (!sm._scenes.has(SCENE.TEAM_GAME)) {
const TeamGameScene = require('./TeamGameScene');
sm.register(SCENE.TEAM_GAME, TeamGameScene);
}
sm.switchTo(SCENE.TEAM_GAME, {
teamId: this._teamData ? this._teamData.teamId : null,
mapId: data.mapId,
teamA: data.teamA,
teamB: data.teamB,
teamABaseHp: data.teamABaseHp,
teamBBaseHp: data.teamBBaseHp,
myPlayerId: this._myPlayerId,
});
},
render(ctx) {
// Background
ctx.fillStyle = COLORS.MENU_BG;
ctx.fillRect(0, 0, SCREEN_WIDTH, SCREEN_HEIGHT);
// Top accent bar
const gradient = ctx.createLinearGradient(0, 0, SCREEN_WIDTH, 0);
gradient.addColorStop(0, '#0f3460');
gradient.addColorStop(0.5, '#e94560');
gradient.addColorStop(1, '#0f3460');
ctx.fillStyle = gradient;
ctx.fillRect(0, 0, SCREEN_WIDTH, 4);
// Back button
this._drawButton(ctx, this._backBtnRect, t('common.back'), false, 12);
// Title
ctx.fillStyle = COLORS.MENU_TITLE;
ctx.font = 'bold 22px Arial';
ctx.textAlign = 'center';
ctx.textBaseline = 'middle';
ctx.fillText(t('teamRoom.title'), CENTER_X, SCREEN_HEIGHT * 0.08);
switch (this._state) {
case TEAM_STATE.MODE_SELECT:
this._renderModeSelect(ctx);
break;
case TEAM_STATE.JOINING:
this._renderJoining(ctx);
break;
case TEAM_STATE.FORMING:
this._renderForming(ctx);
break;
case TEAM_STATE.MATCHING:
this._renderMatching(ctx);
break;
case TEAM_STATE.COUNTDOWN:
this._renderCountdown(ctx);
break;
case TEAM_STATE.ERROR:
this._renderError(ctx);
break;
}
},
_renderJoining(ctx) {
const dots = '.'.repeat(Math.floor(this._animTimer * 3) % 4);
ctx.fillStyle = '#FFFFFF';
ctx.font = '18px Arial';
ctx.textAlign = 'center';
ctx.textBaseline = 'middle';
ctx.fillText(t('teamRoom.joining') + dots, CENTER_X, SCREEN_HEIGHT * 0.45);
},
_renderModeSelect(ctx) {
ctx.fillStyle = '#AAAAAA';
ctx.font = '14px Arial';
ctx.textAlign = 'center';
ctx.fillText(t('teamRoom.chooseMode'), CENTER_X, SCREEN_HEIGHT * 0.28);
this._drawButton(ctx, this._createTeamBtnRect, t('teamRoom.createTeam'));
this._drawButton(ctx, this._soloMatchBtnRect, t('teamRoom.soloMatch'));
},
_renderForming(ctx) {
if (!this._teamData) return;
// Team ID display
ctx.fillStyle = '#AAAAAA';
ctx.font = '12px Arial';
ctx.textAlign = 'center';
ctx.fillText(t('teamRoom.teamId', { id: this._teamData.teamId }), CENTER_X, SCREEN_HEIGHT * 0.16);
// Render team member slots
const members = this._teamData.teamA || [];
for (let i = 0; i < TEAM_SIZE; i++) {
const rect = this._slotRects[i];
const member = members[i];
// Slot background
ctx.fillStyle = member ? '#1e3a5f' : '#0d1b2a';
ctx.strokeStyle = member ? (member.isLeader ? '#FFD700' : '#0f3460') : '#333333';
ctx.lineWidth = member && member.isLeader ? 3 : 1;
this._drawRoundRect(ctx, rect.x, rect.y, rect.w, rect.h, 8);
ctx.fill();
ctx.stroke();
if (member) {
// Avatar placeholder (circle)
const avatarR = Math.min(rect.w, rect.h) * 0.22;
const avatarCX = rect.x + rect.w / 2;
const avatarCY = rect.y + rect.h * 0.3;
ctx.fillStyle = member.isLeader ? '#FFD700' : '#4a90d9';
ctx.beginPath();
ctx.arc(avatarCX, avatarCY, avatarR, 0, Math.PI * 2);
ctx.fill();
// Player icon
ctx.fillStyle = '#FFFFFF';
ctx.font = `${avatarR}px Arial`;
ctx.textAlign = 'center';
ctx.textBaseline = 'middle';
ctx.fillText('🎖', avatarCX, avatarCY);
// Leader badge
if (member.isLeader) {
ctx.fillStyle = '#FFD700';
ctx.font = 'bold 9px Arial';
ctx.fillText(t('teamRoom.leader'), avatarCX, avatarCY + avatarR + 10);
}
// Player name (truncated)
ctx.fillStyle = '#FFFFFF';
ctx.font = '10px Arial';
const name = member.playerId.length > 8 ? member.playerId.substring(0, 8) + '..' : member.playerId;
ctx.fillText(name, avatarCX, rect.y + rect.h * 0.7);
// Ready state
if (!member.isLeader) {
ctx.fillStyle = member.ready ? '#00FF00' : '#FF6347';
ctx.font = 'bold 10px Arial';
ctx.fillText(member.ready ? t('teamRoom.ready') : t('teamRoom.notReady'), avatarCX, rect.y + rect.h * 0.88);
}
// Kick button (only for leader, not on self)
if (this._isLeader && !member.isLeader && member.playerId !== this._myPlayerId) {
const kickRect = this._kickBtnRects[i];
ctx.fillStyle = '#FF4444';
ctx.font = 'bold 12px Arial';
ctx.fillText('✕', kickRect.x + kickRect.w / 2, kickRect.y + kickRect.h / 2);
}
} else {
// Empty slot
ctx.fillStyle = '#555555';
ctx.font = '24px Arial';
ctx.textAlign = 'center';
ctx.textBaseline = 'middle';
ctx.fillText('+', rect.x + rect.w / 2, rect.y + rect.h / 2);
ctx.fillStyle = '#666666';
ctx.font = '10px Arial';
ctx.fillText(t('teamRoom.emptySlot'), rect.x + rect.w / 2, rect.y + rect.h * 0.78);
}
}
// Action buttons based on role
if (this._isLeader) {
this._drawButton(ctx, this._inviteBtnRect, t('teamRoom.invite'));
// Match button: only enabled if all ready
const allReady = members.length > 0 && members.every(m => m.ready || m.isLeader);
this._drawButton(ctx, this._matchBtnRect, t('teamRoom.startMatch'), false, 14, allReady ? null : '#555555');
this._drawButton(ctx, this._disbandBtnRect, t('teamRoom.disband'), false, 12, '#8B0000');
} else {
// Member: ready/unready button
const myMember = members.find(m => m.playerId === this._myPlayerId);
const readyLabel = myMember && myMember.ready ? t('teamRoom.cancelReady') : t('teamRoom.readyBtn');
this._drawButton(ctx, this._readyBtnRect, readyLabel);
this._drawButton(ctx, this._leaveBtnRect, t('teamRoom.leaveTeam'), false, 12, '#8B0000');
}
},
_renderMatching(ctx) {
if (!this._teamData) return;
// Render team slots (smaller, at top)
const members = this._teamData.teamA || [];
for (let i = 0; i < TEAM_SIZE; i++) {
const rect = this._slotRects[i];
const member = members[i];
ctx.fillStyle = member ? '#1e3a5f' : '#0d1b2a';
ctx.strokeStyle = '#0f3460';
ctx.lineWidth = 1;
this._drawRoundRect(ctx, rect.x, rect.y, rect.w, rect.h, 8);
ctx.fill();
ctx.stroke();
if (member) {
ctx.fillStyle = '#FFFFFF';
ctx.font = '10px Arial';
ctx.textAlign = 'center';
ctx.textBaseline = 'middle';
const name = member.playerId.length > 8 ? member.playerId.substring(0, 8) + '..' : member.playerId;
ctx.fillText(name, rect.x + rect.w / 2, rect.y + rect.h / 2);
}
}
// Matching animation
const elapsed = Math.floor(this._matchTimer);
const dots = '.'.repeat(Math.floor(this._animTimer * 3) % 4);
ctx.fillStyle = '#FFFFFF';
ctx.font = '18px Arial';
ctx.textAlign = 'center';
ctx.fillText(t('teamRoom.matching', { dots }), CENTER_X, SCREEN_HEIGHT * 0.55);
ctx.fillStyle = '#AAAAAA';
ctx.font = '14px Arial';
ctx.fillText(t('teamRoom.waitTime', { seconds: elapsed }), CENTER_X, SCREEN_HEIGHT * 0.62);
// Cancel button (leader only)
if (this._isLeader) {
this._drawButton(ctx, this._cancelMatchBtnRect, t('teamRoom.cancelMatch'));
}
},
_renderCountdown(ctx) {
ctx.fillStyle = '#00FF00';
ctx.font = '16px Arial';
ctx.textAlign = 'center';
ctx.fillText(t('teamRoom.matchFound'), CENTER_X, SCREEN_HEIGHT * 0.35);
ctx.fillStyle = COLORS.MENU_TITLE;
ctx.font = 'bold 64px Arial';
ctx.fillText(String(Math.max(1, this._countdown)), CENTER_X, SCREEN_HEIGHT * 0.52);
ctx.fillStyle = '#AAAAAA';
ctx.font = '14px Arial';
ctx.fillText(t('teamRoom.enterBattle'), CENTER_X, SCREEN_HEIGHT * 0.65);
},
_renderError(ctx) {
ctx.fillStyle = '#FF4444';
ctx.font = '16px Arial';
ctx.textAlign = 'center';
ctx.fillText(this._errorMsg, CENTER_X, SCREEN_HEIGHT * 0.45);
ctx.fillStyle = '#AAAAAA';
ctx.font = '14px Arial';
ctx.fillText(t('teamRoom.tapBack'), CENTER_X, SCREEN_HEIGHT * 0.55);
},
_drawButton(ctx, rect, label, pressed, fontSize, bgColor) {
if (!rect) return;
const fs = fontSize || 14;
ctx.fillStyle = bgColor || (pressed ? '#0f3460' : COLORS.MENU_BTN);
ctx.strokeStyle = COLORS.MENU_BTN_BORDER;
ctx.lineWidth = 2;
this._drawRoundRect(ctx, rect.x, rect.y, rect.w, rect.h, 6);
ctx.fill();
ctx.stroke();
ctx.fillStyle = pressed ? COLORS.MENU_TITLE : COLORS.MENU_BTN_TEXT;
ctx.font = `bold ${fs}px Arial`;
ctx.textAlign = 'center';
ctx.textBaseline = 'middle';
ctx.fillText(label, rect.x + rect.w / 2, rect.y + rect.h / 2);
},
_drawRoundRect(ctx, x, y, w, h, r) {
ctx.beginPath();
ctx.moveTo(x + r, y);
ctx.lineTo(x + w - r, y);
ctx.arcTo(x + w, y, x + w, y + r, r);
ctx.lineTo(x + w, y + h - r);
ctx.arcTo(x + w, y + h, x + w - r, y + h, r);
ctx.lineTo(x + r, y + h);
ctx.arcTo(x, y + h, x, y + h - r, r);
ctx.lineTo(x, y + r);
ctx.arcTo(x, y, x + r, y, r);
ctx.closePath();
},
_hitTest(tx, ty, rect) {
if (!rect) return false;
return tx >= rect.x && tx <= rect.x + rect.w && ty >= rect.y && ty <= rect.y + rect.h;
},
handleTouch(eventType, e) {
if (eventType !== 'touchstart') return;
const touch = e.touches[0];
const tx = touch.clientX;
const ty = touch.clientY;
// Back button
if (this._hitTest(tx, ty, this._backBtnRect)) {
this._goBack();
return;
}
switch (this._state) {
case TEAM_STATE.MODE_SELECT:
if (this._hitTest(tx, ty, this._createTeamBtnRect)) {
this._handleCreateTeam();
} else if (this._hitTest(tx, ty, this._soloMatchBtnRect)) {
this._handleSoloMatch();
}
break;
case TEAM_STATE.FORMING:
this._handleFormingTouch(tx, ty);
break;
case TEAM_STATE.MATCHING:
if (this._isLeader && this._hitTest(tx, ty, this._cancelMatchBtnRect)) {
this._handleCancelMatch();
}
break;
case TEAM_STATE.ERROR:
this._state = TEAM_STATE.MODE_SELECT;
this._errorMsg = '';
break;
}
},
_handleFormingTouch(tx, ty) {
if (!this._teamData) return;
if (this._isLeader) {
// Invite button
if (this._hitTest(tx, ty, this._inviteBtnRect)) {
this._handleInvite();
return;
}
// Match button
if (this._hitTest(tx, ty, this._matchBtnRect)) {
this._handleStartMatch();
return;
}
// Disband button
if (this._hitTest(tx, ty, this._disbandBtnRect)) {
this._handleDisband();
return;
}
// Kick buttons
const members = this._teamData.teamA || [];
for (let i = 0; i < members.length; i++) {
const member = members[i];
if (member && !member.isLeader && member.playerId !== this._myPlayerId) {
if (this._hitTest(tx, ty, this._kickBtnRects[i])) {
this._handleKick(member.playerId);
return;
}
}
}
} else {
// Ready button
if (this._hitTest(tx, ty, this._readyBtnRect)) {
this._handleReady();
return;
}
// Leave button
if (this._hitTest(tx, ty, this._leaveBtnRect)) {
this._handleLeave();
return;
}
}
},
async _handleCreateTeam() {
const nm = this._networkManager;
if (!nm) return;
if (!nm.connected) {
const ok = await nm.connect(this._serverUrl);
if (!ok) {
this._errorMsg = t('common.cannotConnect');
this._state = TEAM_STATE.ERROR;
return;
}
}
this._myPlayerId = nm.playerId;
nm.send(NET_MSG.CREATE_TEAM, {});
},
async _handleSoloMatch() {
const nm = this._networkManager;
if (!nm) return;
if (!nm.connected) {
const ok = await nm.connect(this._serverUrl);
if (!ok) {
this._errorMsg = t('common.cannotConnect');
this._state = TEAM_STATE.ERROR;
return;
}
}
this._myPlayerId = nm.playerId;
this._matchTimer = 0;
// State will be updated by TEAM_STATE event from server
nm.send(NET_MSG.SOLO_MATCH, {});
},
async _autoJoinTeam(teamId) {
const nm = this._networkManager;
if (!nm) {
this._errorMsg = t('common.cannotConnect');
this._state = TEAM_STATE.ERROR;
return;
}
// Show a joining indicator
this._state = TEAM_STATE.JOINING;
this._errorMsg = '';
try {
if (!nm.connected) {
const ok = await nm.connect(this._serverUrl);
if (!ok) {
this._errorMsg = t('common.cannotConnect');
this._state = TEAM_STATE.ERROR;
return;
}
}
this._myPlayerId = nm.playerId;
console.log(`[TeamRoom] Auto-joining team ${teamId} as ${this._myPlayerId}`);
nm.send(NET_MSG.JOIN_TEAM, { teamId });
} catch (e) {
console.error('[TeamRoom] Auto-join failed:', e);
this._errorMsg = t('common.cannotConnect');
this._state = TEAM_STATE.ERROR;
}
},
_handleInvite() {
if (!this._teamData) return;
const teamId = this._teamData.teamId;
const shareData = {
title: t('teamRoom.shareTitle'),
imageUrl: '',
query: `teamId=${teamId}`,
};
console.log(`[TeamRoom] Sharing invite with query: teamId=${teamId}`);
// WeChat mini-game policy: direct wx.shareAppMessage() calls are forbidden.
// Must use passive sharing via onShareAppMessage callback.
const shareManager = GameGlobal.shareManager;
if (shareManager) {
shareManager.triggerShare(shareData);
} else {
try {
wx.showToast({
title: '请点击右上角 ··· 转发给好友',
icon: 'none',
duration: 2500,
});
} catch (e) {
console.log('[TeamRoom] Share not available, teamId:', teamId);
}
}
},
_handleStartMatch() {
const nm = this._networkManager;
if (!nm || !this._teamData) return;
// Check all members are ready before sending
const members = this._teamData.teamA || [];
const allReady = members.length > 0 && members.every(m => m.ready || m.isLeader);
if (!allReady) return;
this._matchTimer = 0;
nm.send(NET_MSG.MATCH_START, {});
},
_handleCancelMatch() {
const nm = this._networkManager;
if (!nm) return;
nm.send(NET_MSG.MATCH_CANCEL, {});
},
_handleReady() {
const nm = this._networkManager;
if (!nm || !this._teamData) return;
const myMember = (this._teamData.teamA || []).find(m => m.playerId === this._myPlayerId);
nm.send(NET_MSG.TEAM_READY, { ready: myMember ? !myMember.ready : true });
},
_handleKick(playerId) {
const nm = this._networkManager;
if (!nm) return;
nm.send(NET_MSG.TEAM_KICK, { playerId });
},
_handleDisband() {
const nm = this._networkManager;
if (!nm) return;
nm.send(NET_MSG.TEAM_DISBAND, {});
},
_handleLeave() {
const nm = this._networkManager;
if (!nm) return;
nm.send(NET_MSG.LEAVE_TEAM, {});
this._teamData = null;
this._state = TEAM_STATE.MODE_SELECT;
},
_goBack() {
// Leave team if in one
if (this._teamData) {
const nm = this._networkManager;
if (nm) {
if (this._state === TEAM_STATE.MATCHING && this._isLeader) {
// Cancel match first, then disband
nm.send(NET_MSG.MATCH_CANCEL, {});
}
if (this._isLeader) {
nm.send(NET_MSG.TEAM_DISBAND, {});
} else {
nm.send(NET_MSG.LEAVE_TEAM, {});
}
}
}
const sm = GameGlobal.sceneManager;
sm.switchTo(SCENE.MENU);
},
};
module.exports = TeamRoomScene;
+134
View File
@@ -0,0 +1,134 @@
/**
* FireButton.js
* Virtual fire button component positioned at the bottom-right of the screen.
* Overlaid as a separate layer above the map when they intersect.
*/
const {
SCREEN_WIDTH,
SCREEN_HEIGHT,
} = require('../base/GameGlobal');
class FireButton {
constructor() {
this.radius = 35;
// Anchored to screen bottom-right, shifted slightly towards upper-left
const padding = this.radius + 40;
this.cx = SCREEN_WIDTH - padding - 15; // right edge + extra leftward offset
this.cy = SCREEN_HEIGHT - padding - 30; // bottom edge + extra upward offset
this._pressed = false;
this._touchId = null;
this._fireCallback = null;
// Visual feedback
this._pressScale = 1;
// Touch area
this._touchAreaRadius = this.radius * 1.5;
}
/**
* Set the callback for when fire is triggered.
* @param {Function} cb
*/
onFire(cb) {
this._fireCallback = cb;
}
/**
* Handle touch events.
* @param {string} eventType
* @param {Touch} touch
* @returns {boolean} Whether this button consumed the touch.
*/
handleTouch(eventType, touch) {
const tx = touch.clientX;
const ty = touch.clientY;
if (eventType === 'touchstart') {
const dist = Math.sqrt((tx - this.cx) ** 2 + (ty - this.cy) ** 2);
if (dist <= this._touchAreaRadius) {
this._pressed = true;
this._touchId = touch.identifier;
this._pressScale = 0.85;
// Fire immediately on press
if (this._fireCallback) {
this._fireCallback();
}
return true;
}
return false;
}
if (eventType === 'touchend') {
if (this._pressed && touch.identifier === this._touchId) {
this._pressed = false;
this._touchId = null;
this._pressScale = 1;
return true;
}
return false;
}
return false;
}
/**
* Render the fire button.
* @param {CanvasRenderingContext2D} ctx
*/
render(ctx) {
ctx.save();
ctx.globalAlpha = 0.5;
const r = this.radius * this._pressScale;
// Outer ring
ctx.strokeStyle = '#FF4444';
ctx.lineWidth = 3;
ctx.beginPath();
ctx.arc(this.cx, this.cy, r, 0, Math.PI * 2);
ctx.stroke();
// Inner fill
ctx.fillStyle = this._pressed ? '#FF6666' : '#CC3333';
ctx.beginPath();
ctx.arc(this.cx, this.cy, r * 0.75, 0, Math.PI * 2);
ctx.fill();
// Fire icon (crosshair)
ctx.globalAlpha = 0.8;
ctx.strokeStyle = '#FFFFFF';
ctx.lineWidth = 2;
const crossSize = r * 0.35;
// Horizontal line
ctx.beginPath();
ctx.moveTo(this.cx - crossSize, this.cy);
ctx.lineTo(this.cx + crossSize, this.cy);
ctx.stroke();
// Vertical line
ctx.beginPath();
ctx.moveTo(this.cx, this.cy - crossSize);
ctx.lineTo(this.cx, this.cy + crossSize);
ctx.stroke();
// Center dot
ctx.fillStyle = '#FFFFFF';
ctx.beginPath();
ctx.arc(this.cx, this.cy, 3, 0, Math.PI * 2);
ctx.fill();
ctx.restore();
}
/** Whether the button is currently pressed. */
get pressed() {
return this._pressed;
}
}
module.exports = FireButton;
+206
View File
@@ -0,0 +1,206 @@
/**
* Joystick.js
* Virtual joystick component for touch-based directional control.
* Positioned at the bottom-left of the screen; overlaid as a separate layer
* above the map when they intersect.
*/
const {
DIRECTION,
SCREEN_WIDTH,
SCREEN_HEIGHT,
} = require('../base/GameGlobal');
class Joystick {
constructor() {
// Position and size — anchored to screen bottom-left, shifted slightly
// towards the upper-right for comfortable thumb reach
this.radius = 50;
this.innerRadius = 20;
const padding = this.radius + 30;
this.cx = padding + 15; // left edge + rightward offset
this.cy = SCREEN_HEIGHT - padding - 30; // bottom edge + upward offset
// State
this._active = false;
this._touchId = null;
this._touchX = 0;
this._touchY = 0;
this._direction = -1; // -1 = no direction
this._dx = 0;
this._dy = 0;
// Touch area (larger than visual for easier use)
this._touchAreaRadius = this.radius * 2;
}
/**
* Handle touch events.
* @param {string} eventType
* @param {Touch} touch - Single touch object.
* @returns {boolean} Whether this joystick consumed the touch.
*/
handleTouch(eventType, touch) {
const tx = touch.clientX;
const ty = touch.clientY;
if (eventType === 'touchstart') {
// Check if touch is within joystick area
const dist = Math.sqrt((tx - this.cx) ** 2 + (ty - this.cy) ** 2);
if (dist <= this._touchAreaRadius) {
this._active = true;
this._touchId = touch.identifier;
this._updateDirection(tx, ty);
return true;
}
return false;
}
if (eventType === 'touchmove') {
if (this._active && touch.identifier === this._touchId) {
this._updateDirection(tx, ty);
return true;
}
return false;
}
if (eventType === 'touchend') {
if (this._active && touch.identifier === this._touchId) {
this._active = false;
this._touchId = null;
this._direction = -1;
this._dx = 0;
this._dy = 0;
return true;
}
return false;
}
return false;
}
/**
* Calculate direction from touch position.
* @private
*/
_updateDirection(tx, ty) {
this._touchX = tx;
this._touchY = ty;
const dx = tx - this.cx;
const dy = ty - this.cy;
const dist = Math.sqrt(dx * dx + dy * dy);
if (dist < 10) {
this._direction = -1;
this._dx = 0;
this._dy = 0;
return;
}
// Clamp to radius for visual
const clampDist = Math.min(dist, this.radius);
this._dx = (dx / dist) * clampDist;
this._dy = (dy / dist) * clampDist;
// Determine 4-direction based on angle
const angle = Math.atan2(dy, dx);
// Right: -45° to 45°, Down: 45° to 135°, Left: 135° to -135°, Up: -135° to -45°
if (angle >= -Math.PI / 4 && angle < Math.PI / 4) {
this._direction = DIRECTION.RIGHT;
} else if (angle >= Math.PI / 4 && angle < Math.PI * 3 / 4) {
this._direction = DIRECTION.DOWN;
} else if (angle >= -Math.PI * 3 / 4 && angle < -Math.PI / 4) {
this._direction = DIRECTION.UP;
} else {
this._direction = DIRECTION.LEFT;
}
}
/**
* Render the joystick.
* @param {CanvasRenderingContext2D} ctx
*/
render(ctx) {
ctx.save();
ctx.globalAlpha = 0.3;
// Outer circle
ctx.fillStyle = '#333333';
ctx.strokeStyle = '#666666';
ctx.lineWidth = 2;
ctx.beginPath();
ctx.arc(this.cx, this.cy, this.radius, 0, Math.PI * 2);
ctx.fill();
ctx.stroke();
// Direction indicators
ctx.globalAlpha = 0.3;
ctx.fillStyle = '#FFFFFF';
const arrowSize = 8;
// Up arrow
this._drawArrow(ctx, this.cx, this.cy - this.radius * 0.6, DIRECTION.UP, arrowSize);
// Down arrow
this._drawArrow(ctx, this.cx, this.cy + this.radius * 0.6, DIRECTION.DOWN, arrowSize);
// Left arrow
this._drawArrow(ctx, this.cx - this.radius * 0.6, this.cy, DIRECTION.LEFT, arrowSize);
// Right arrow
this._drawArrow(ctx, this.cx + this.radius * 0.6, this.cy, DIRECTION.RIGHT, arrowSize);
// Inner knob
ctx.globalAlpha = 0.6;
const knobX = this._active ? this.cx + this._dx : this.cx;
const knobY = this._active ? this.cy + this._dy : this.cy;
ctx.fillStyle = this._active ? '#FFD700' : '#888888';
ctx.beginPath();
ctx.arc(knobX, knobY, this.innerRadius, 0, Math.PI * 2);
ctx.fill();
ctx.restore();
}
/**
* Draw a small directional arrow.
* @private
*/
_drawArrow(ctx, x, y, dir, size) {
ctx.beginPath();
switch (dir) {
case DIRECTION.UP:
ctx.moveTo(x, y - size);
ctx.lineTo(x - size, y + size);
ctx.lineTo(x + size, y + size);
break;
case DIRECTION.DOWN:
ctx.moveTo(x, y + size);
ctx.lineTo(x - size, y - size);
ctx.lineTo(x + size, y - size);
break;
case DIRECTION.LEFT:
ctx.moveTo(x - size, y);
ctx.lineTo(x + size, y - size);
ctx.lineTo(x + size, y + size);
break;
case DIRECTION.RIGHT:
ctx.moveTo(x + size, y);
ctx.lineTo(x - size, y - size);
ctx.lineTo(x - size, y + size);
break;
}
ctx.closePath();
ctx.fill();
}
/** Current direction (-1 if idle). */
get direction() {
return this._direction;
}
/** Whether the joystick is being touched. */
get active() {
return this._active;
}
}
module.exports = Joystick;
+164
View File
@@ -0,0 +1,164 @@
/**
* TutorialOverlay.js
* New player tutorial overlay shown on first play.
* Displays 2-3 step instructions for controls.
*/
const {
SCREEN_WIDTH,
SCREEN_HEIGHT,
MAP_OFFSET_X,
MAP_WIDTH,
} = require('../base/GameGlobal');
class TutorialOverlay {
constructor() {
this._active = false;
this._step = 0;
this._totalSteps = 3;
this._steps = [
{
title: '移动坦克',
desc: '拖动左下角的摇杆\n控制坦克上下左右移动',
highlight: 'joystick',
},
{
title: '发射子弹',
desc: '点击右下角的按钮\n向前方发射子弹',
highlight: 'fire',
},
{
title: '保护基地',
desc: '消灭所有敌人\n不要让基地被摧毁!',
highlight: 'base',
},
];
}
/**
* Show the tutorial.
*/
show() {
this._active = true;
this._step = 0;
}
/**
* Hide the tutorial.
*/
hide() {
this._active = false;
}
/** Whether the tutorial is active. */
get active() {
return this._active;
}
/**
* Handle touch to advance steps.
* @returns {boolean} Whether the tutorial consumed the touch.
*/
handleTouch() {
if (!this._active) return false;
this._step++;
if (this._step >= this._totalSteps) {
this._active = false;
}
return true;
}
/**
* Render the tutorial overlay.
* @param {CanvasRenderingContext2D} ctx
*/
render(ctx) {
if (!this._active) return;
const step = this._steps[this._step];
// Semi-transparent overlay
ctx.fillStyle = 'rgba(0, 0, 0, 0.7)';
ctx.fillRect(0, 0, SCREEN_WIDTH, SCREEN_HEIGHT);
const cx = SCREEN_WIDTH / 2;
const cy = SCREEN_HEIGHT / 2;
// Step indicator
ctx.fillStyle = '#AAAAAA';
ctx.font = '12px Arial';
ctx.textAlign = 'center';
ctx.textBaseline = 'middle';
ctx.fillText(`${this._step + 1} / ${this._totalSteps}`, cx, cy - 80);
// Title
ctx.fillStyle = '#FFD700';
ctx.font = 'bold 24px Arial';
ctx.fillText(step.title, cx, cy - 40);
// Description (multi-line)
ctx.fillStyle = '#FFFFFF';
ctx.font = '16px Arial';
const lines = step.desc.split('\n');
for (let i = 0; i < lines.length; i++) {
ctx.fillText(lines[i], cx, cy + 10 + i * 24);
}
// Highlight area indicator
this._drawHighlight(ctx, step.highlight);
// Tap to continue
ctx.fillStyle = '#888888';
ctx.font = '13px Arial';
ctx.fillText('点击屏幕继续', cx, SCREEN_HEIGHT - 60);
}
/**
* Draw a highlight circle around the relevant UI element.
* @private
*/
_drawHighlight(ctx, type) {
ctx.save();
ctx.strokeStyle = '#FFD700';
ctx.lineWidth = 3;
ctx.setLineDash([8, 4]);
switch (type) {
case 'joystick':
ctx.beginPath();
ctx.arc(Math.floor(MAP_OFFSET_X / 2), SCREEN_HEIGHT - 100, 65, 0, Math.PI * 2);
ctx.stroke();
break;
case 'fire': {
const rightAreaStart = MAP_OFFSET_X + MAP_WIDTH;
ctx.beginPath();
ctx.arc(Math.floor(rightAreaStart + (SCREEN_WIDTH - rightAreaStart) / 2), SCREEN_HEIGHT - 100, 50, 0, Math.PI * 2);
ctx.stroke();
break;
}
case 'base': {
// Arrow pointing to base area
const baseCx = SCREEN_WIDTH / 2;
ctx.beginPath();
ctx.moveTo(baseCx, SCREEN_HEIGHT * 0.7);
ctx.lineTo(baseCx, SCREEN_HEIGHT * 0.8);
ctx.stroke();
ctx.fillStyle = '#FFD700';
ctx.beginPath();
ctx.moveTo(baseCx - 8, SCREEN_HEIGHT * 0.8);
ctx.lineTo(baseCx + 8, SCREEN_HEIGHT * 0.8);
ctx.lineTo(baseCx, SCREEN_HEIGHT * 0.8 + 10);
ctx.closePath();
ctx.fill();
break;
}
}
ctx.setLineDash([]);
ctx.restore();
}
}
module.exports = TutorialOverlay;