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