/** * TeamGameScene.js * Unified battle scene for online multiplayer (supports 1v1 PVP and 3v3 Team modes). * Configurable via battleMode parameter ('1v1' or '3v3'). * Base HP system, unlimited respawns, network-synced input, team-based win/lose logic. */ const { SCREEN_WIDTH, SCREEN_HEIGHT, COLORS, SCENE, MAP_OFFSET_X, MAP_OFFSET_Y, MAP_WIDTH, MAP_HEIGHT, TILE_SIZE, DIRECTION, DIR_VECTORS, TERRAIN, NET_MSG, TEAM_RESPAWN_DELAY, TEAM_BASE_HP, TEAM_SIZE, TANK_CONFIG, TANK_TYPE, PVP_BASE_HP, PVP_RESPAWN_DELAY, BATTLE_CONFIG, } = require('../base/GameGlobal'); const { t } = require('../i18n/I18n'); const ObjectPool = require('../base/ObjectPool'); const MapManager = require('../managers/MapManager'); const PlayerTank = require('../entities/PlayerTank'); const BotTank = require('../entities/BotTank'); const Bullet = require('../entities/Bullet'); const Explosion = require('../entities/Explosion'); const Joystick = require('../ui/Joystick'); const FireButton = require('../ui/FireButton'); const { getTeamMap, getPvpMap } = require('../data/LevelData'); // Team colors const TEAM_A_COLOR = '#4A90D9'; // blue const TEAM_B_COLOR = '#E94560'; // red const LOCAL_PLAYER_COLOR = '#FFD700'; // gold highlight // 1v1 PVP player colors const PLAYER1_COLOR = '#FFD700'; // gold const PLAYER2_COLOR = '#00BFFF'; // deep sky blue const TeamGameScene = { _initialized: false, _gameOver: false, _paused: false, _winner: '', // '' | 'A' | 'B' _winReason: '', // Reconnection state _isDisconnected: false, _reconnectTimer: 0, _reconnectAttempts: 0, _maxReconnectAttempts: 5, _reconnectInterval: 3, // seconds between attempts // Network _networkManager: null, _myPlayerId: '', _myTeam: '', // 'A' or 'B' _teamId: '', _unsubscribers: [], // Map _mapManager: null, _mapData: null, // Controls _joystick: null, _fireButton: null, // Players: { playerId, tank, isBot, team, isLocal, spawnPoint, respawnTimer } _players: [], _localPlayer: null, // Entity lists _bullets: [], _explosions: [], // Object pools _bulletPool: null, _explosionPool: null, // Battle config (1v1 or 3v3) _battleMode: '3v3', // '1v1' or '3v3' _battleConfig: null, // Game state _elapsedTime: 0, // seconds since game started (count up) _teamABaseHp: TEAM_BASE_HP, _teamBBaseHp: TEAM_BASE_HP, _gameOverDelay: 0, _gameOverDelayDuration: 3, // Stats _stats: {}, // { playerId: { kills, deaths, assists, baseDamage } } // Sync _syncTimer: 0, _syncInterval: 0.05, _lastSentInput: null, // Remote state targets for interpolation _remoteTargets: {}, // { playerId: { x, y, direction } } enter(params) { this._teamId = (params && params.teamId) || ''; this._myPlayerId = (params && params.myPlayerId) || ''; this._gameOver = false; this._paused = false; this._winner = ''; this._winReason = ''; this._gameOverDelay = 0; this._elapsedTime = 0; this._syncTimer = 0; this._lastSentInput = null; this._stats = {}; this._remoteTargets = {}; this._isDisconnected = false; this._reconnectTimer = 0; this._reconnectAttempts = 0; // Determine battle mode from params this._battleMode = (params && params.battleMode) || '3v3'; this._battleConfig = BATTLE_CONFIG[this._battleMode] || BATTLE_CONFIG['3v3']; const baseHp = (params && params.teamABaseHp) || this._battleConfig.baseHp; this._teamABaseHp = baseHp; this._teamBBaseHp = (params && params.teamBBaseHp) || baseHp; this._networkManager = GameGlobal.networkManager; // Initialize object pools this._bulletPool = new ObjectPool(() => new Bullet(), null, 40); this._explosionPool = new ObjectPool(() => new Explosion(), null, 20); this._bullets = []; this._explosions = []; // Load map based on battle mode if (this._battleConfig.mapPool === 'pvp') { this._mapData = getPvpMap(params && params.mapId, params && params.roomId); } else { this._mapData = getTeamMap(params && params.mapId); } this._mapManager = new MapManager(); this._mapManager.loadGrid(this._mapData.grid); // Determine which team the local player is on const teamAMembers = (params && params.teamA) || []; const teamBMembers = (params && params.teamB) || []; console.log(`[TeamGameScene] teamA: ${JSON.stringify(teamAMembers.map(m => ({ playerId: m.playerId, isBot: m.isBot })))}`); console.log(`[TeamGameScene] teamB: ${JSON.stringify(teamBMembers.map(m => ({ playerId: m.playerId, isBot: m.isBot })))}`); console.log(`[TeamGameScene] myPlayerId: ${this._myPlayerId}`); this._myTeam = teamAMembers.find(m => m.playerId === this._myPlayerId) ? 'A' : 'B'; // Create all player tanks this._players = []; this._createTeamPlayers(teamAMembers, 'A', this._mapData.teamASpawns); this._createTeamPlayers(teamBMembers, 'B', this._mapData.teamBSpawns); // Log _players for duplicate check const playerIds = this._players.map(p => p.playerId); const duplicates = playerIds.filter((id, idx) => playerIds.indexOf(id) !== idx); if (duplicates.length > 0) { console.error(`[TeamGameScene] DUPLICATE playerIds: ${JSON.stringify(duplicates)}`); } console.log(`[TeamGameScene] All players: ${JSON.stringify(this._players.map(p => ({ playerId: p.playerId, team: p.team, isLocal: p.isLocal, isBot: p.isBot })))}`); // Find local player this._localPlayer = this._players.find(p => p.isLocal); // Check if enemy team has no human players (all bots) — // if so, this client must run enemy bot AI locally since no remote // client exists to act as authority const enemyTeam = this._myTeam === 'A' ? 'B' : 'A'; this._enemyTeamAllBots = !this._players.some( p => p.team === enemyTeam && !p.isBot && !p.isLocal ); // Initialize stats for (const p of this._players) { this._stats[p.playerId] = { kills: 0, deaths: 0, assists: 0, baseDamage: 0 }; } // Initialize controls this._joystick = new Joystick(); this._fireButton = new FireButton(); this._fireButton.onFire(() => this._localFire()); // Setup network events this._setupNetworkEvents(); this._initialized = true; console.log(`[TeamGameScene] Started (${this._battleMode}). Team: ${this._teamId}, Player: ${this._myPlayerId}, MyTeam: ${this._myTeam}`); }, _createTeamPlayers(members, team, spawnPoints) { for (let i = 0; i < members.length; i++) { const member = members[i]; const spawn = spawnPoints[i % spawnPoints.length]; const isLocal = member.playerId === this._myPlayerId; const isMyTeam = team === this._myTeam; const isBot = member.isBot || false; // Determine color based on battle mode let tankColor; if (this._battleMode === '1v1') { // 1v1: use P1/P2 colors based on team if (isLocal) { tankColor = team === 'A' ? PLAYER1_COLOR : PLAYER2_COLOR; } else { tankColor = team === 'A' ? PLAYER1_COLOR : PLAYER2_COLOR; } } else { // 3v3: Team A = blue, Team B = red // Local player uses team color too (gold border drawn separately for identification) tankColor = team === 'A' ? TEAM_A_COLOR : TEAM_B_COLOR; } let tank; if (isBot) { // Use BotTank for AI-controlled players tank = new BotTank({ col: spawn.col, row: spawn.row, team, playerId: member.playerId, color: tankColor, }); // Set target base: bots attack the enemy base const enemyBase = team === 'A' ? this._mapData.teamBBase : this._mapData.teamABase; if (enemyBase && enemyBase.length > 0) { tank.setTargetBase({ x: MAP_OFFSET_X + enemyBase[0].col * TILE_SIZE + TILE_SIZE / 2, y: MAP_OFFSET_Y + enemyBase[0].row * TILE_SIZE + TILE_SIZE / 2, }); } } else { // Use PlayerTank for human players tank = new PlayerTank({ col: spawn.col, row: spawn.row, }); tank.color = tankColor; // Unlimited lives for 3v3 tank.lives = 999; // Apply equipped skin — only non-default skins override team color if (GameGlobal.skinManager) { const skinId = isLocal ? GameGlobal.skinManager.getEquippedSkinId() : (member.skinId || ''); if (skinId && skinId !== 'default') { const skinDef = GameGlobal.skinManager.getSkin(skinId); if (skinDef && skinDef.colors) { tank._skinColors = skinDef.colors; tank._skinId = skinId; } } } } tank.activateShield(3000); // Mark local player's tank for gold-border rendering if (isLocal) { tank._isLocal = true; } // Set initial direction based on team if (team === 'A') { tank.direction = DIRECTION.RIGHT; } else { tank.direction = DIRECTION.LEFT; } // Clear spawn area this._clearSpawnArea(spawn.col, spawn.row); const playerData = { playerId: member.playerId, nickname: member.nickname || '', tank, isBot, team, isLocal, spawnPoint: spawn, respawnTimer: 0, isRespawning: false, }; this._players.push(playerData); // Initialize remote target if (!isLocal) { this._remoteTargets[member.playerId] = { x: tank.x, y: tank.y, direction: tank.direction, }; } } }, exit() { this._initialized = false; this._cleanupNetworkEvents(); this._bullets = []; this._explosions = []; this._players = []; }, _setupNetworkEvents() { this._cleanupNetworkEvents(); const nm = this._networkManager; if (!nm) return; const unsubs = []; // Receive player state sync from other players unsubs.push(nm.on(NET_MSG.PLAYER_STATE, (data) => { if (data.playerId && data.playerId !== this._myPlayerId) { this._updateRemotePlayerState(data); } })); // Receive player input from other players unsubs.push(nm.on(NET_MSG.PLAYER_INPUT, (data) => { if (data.playerId && data.playerId !== this._myPlayerId) { const player = this._players.find(p => p.playerId === data.playerId); if (player) { player._remoteInput = { direction: data.direction !== undefined ? data.direction : -1, moving: data.moving || false, }; } } })); // Receive bullet fire from other players unsubs.push(nm.on(NET_MSG.BULLET_FIRE, (data) => { if (!data.playerId) return; // Ignore our own bullets (local player or our team's bots) — we already // created them locally. Only spawn remote bullets for enemy players/bots. const shooter = this._players.find(p => p.playerId === data.playerId); if (shooter && shooter.team === this._myTeam) return; if (data.playerId === this._myPlayerId) return; this._spawnRemoteBullet(data); })); // Receive player killed notification unsubs.push(nm.on(NET_MSG.PLAYER_KILLED, (data) => { if (data.victimId) { const victim = this._players.find(p => p.playerId === data.victimId); if (victim && victim.tank.alive) { victim.tank.alive = false; this._spawnExplosion(victim.tank.x, victim.tank.y, true); this._startRespawn(victim); } // Update stats if (data.killerId && this._stats[data.killerId]) { this._stats[data.killerId].kills++; } if (this._stats[data.victimId]) { this._stats[data.victimId].deaths++; } } })); // Receive player respawn unsubs.push(nm.on(NET_MSG.PLAYER_RESPAWN, (data) => { if (data.playerId && data.playerId !== this._myPlayerId) { const player = this._players.find(p => p.playerId === data.playerId); if (player) { this._respawnPlayer(player); } } })); // Receive base hit unsubs.push(nm.on(NET_MSG.BASE_HIT, (data) => { if (data.teamABaseHp !== undefined) this._teamABaseHp = data.teamABaseHp; if (data.teamBBaseHp !== undefined) this._teamBBaseHp = data.teamBBaseHp; // Check if base destroyed (immediate client-side feedback) if (!this._gameOver) { if (this._teamABaseHp <= 0) { this._winner = 'B'; this._winReason = 'base_destroyed'; this._gameOver = true; GameGlobal.audioManager.playSFX(this._myTeam === 'B' ? 'victory' : 'gameover'); } else if (this._teamBBaseHp <= 0) { this._winner = 'A'; this._winReason = 'base_destroyed'; this._gameOver = true; GameGlobal.audioManager.playSFX(this._myTeam === 'A' ? 'victory' : 'gameover'); } } })); // Receive base destroyed / game over unsubs.push(nm.on(NET_MSG.BASE_DESTROYED, (data) => { if (data.team === 'A') { this._teamABaseHp = 0; this._winner = 'B'; } else { this._teamBBaseHp = 0; this._winner = 'A'; } this._winReason = 'base_destroyed'; this._gameOver = true; })); unsubs.push(nm.on(NET_MSG.TEAM_GAME_OVER, (data) => { this._winner = data.winner || ''; this._winReason = data.reason || 'base_destroyed'; this._teamABaseHp = data.teamABaseHp; this._teamBBaseHp = data.teamBBaseHp; this._gameOver = true; })); // Receive game over (1v1 mode uses GAME_OVER instead of TEAM_GAME_OVER) unsubs.push(nm.on(NET_MSG.GAME_OVER, (data) => { this._winner = data.winner || ''; this._winReason = data.reason || 'base_destroyed'; if (data.teamABaseHp !== undefined) this._teamABaseHp = data.teamABaseHp; if (data.teamBBaseHp !== undefined) this._teamBBaseHp = data.teamBBaseHp; this._gameOver = true; })); // Opponent left (1v1 mode) unsubs.push(nm.on(NET_MSG.OPPONENT_LEFT, () => { if (!this._gameOver) { this._winner = this._myTeam; this._winReason = 'disconnected'; this._gameOver = true; } })); // Player disconnect unsubs.push(nm.on(NET_MSG.PLAYER_DISCONNECT, (data) => { console.log(`[TeamGameScene] Player disconnected: ${data.playerId}`); })); // Bot takeover unsubs.push(nm.on(NET_MSG.BOT_TAKEOVER, (data) => { const player = this._players.find(p => p.playerId === data.playerId); if (player) { player.isBot = true; console.log(`[TeamGameScene] Bot takeover: ${data.playerId}`); } })); unsubs.push(nm.on(NET_MSG.RECONNECT_OK, (data) => { console.log('[TeamGameScene] Reconnected successfully'); this._isDisconnected = false; this._reconnectAttempts = 0; // Restore game state from server if (data.teamABaseHp !== undefined) this._teamABaseHp = data.teamABaseHp; if (data.teamBBaseHp !== undefined) this._teamBBaseHp = data.teamBBaseHp; })); unsubs.push(nm.on('disconnected', () => { if (!this._gameOver) { this._isDisconnected = true; this._reconnectTimer = 0; this._reconnectAttempts = 0; console.log('[TeamGameScene] Disconnected, attempting reconnect...'); } })); // Receive live team roster updates — keeps every tank's overhead label in // sync with the real WeChat nickname, which may be granted AFTER the match // has already started (via MenuScene's UserInfoButton). unsubs.push(nm.on(NET_MSG.TEAM_STATE, (data) => { if (!data) return; const rosterA = Array.isArray(data.teamA) ? data.teamA : []; const rosterB = Array.isArray(data.teamB) ? data.teamB : []; const byId = Object.create(null); for (const m of rosterA) if (m && m.playerId) byId[m.playerId] = m.nickname || ''; for (const m of rosterB) if (m && m.playerId) byId[m.playerId] = m.nickname || ''; let changed = false; for (const p of this._players) { const nn = byId[p.playerId]; if (nn && p.nickname !== nn) { p.nickname = nn; changed = true; } } if (changed) { console.log('[TeamGameScene] Roster nicknames refreshed.'); } })); // Receive terrain changes from remote client (brick/steel/base_wall destruction) unsubs.push(nm.on(NET_MSG.TERRAIN_CHANGE, (data) => { if (data.row !== undefined && data.col !== undefined && data.terrain !== undefined) { const currentTerrain = this._mapManager.getTerrain(data.row, data.col); // Only apply if the terrain still matches the original type (avoid double-apply) // Accept EMPTY→EMPTY as no-op, but apply any real change if (currentTerrain !== data.terrain) { this._mapManager.setTerrain(data.row, data.col, data.terrain); } } })); // Receive bot state from remote client (enemy team bots) unsubs.push(nm.on(NET_MSG.BOT_STATE, (data) => { if (data.playerId) { this._updateRemotePlayerState(data); } })); this._unsubscribers = unsubs; }, _cleanupNetworkEvents() { for (const unsub of this._unsubscribers) { if (typeof unsub === 'function') unsub(); } this._unsubscribers = []; }, _updateRemotePlayerState(data) { const target = this._remoteTargets[data.playerId]; if (target) { // Convert normalized grid coords back to local pixel coords if (data.col !== undefined && data.row !== undefined) { target.x = MAP_OFFSET_X + data.col * TILE_SIZE; target.y = MAP_OFFSET_Y + data.row * TILE_SIZE; } else { target.x = data.x; target.y = data.y; } target.direction = data.direction; } const player = this._players.find(p => p.playerId === data.playerId); if (player) { if (data.hp !== undefined) player.tank.hp = data.hp; if (data.alive !== undefined) player.tank.alive = data.alive; } }, // ============================================================ // Update // ============================================================ update(dt) { if (!this._initialized || this._paused) return; // Handle reconnection if (this._isDisconnected) { this._reconnectTimer += dt; if (this._reconnectTimer >= this._reconnectInterval) { this._reconnectTimer = 0; this._attemptReconnect(); } return; // Pause game updates while disconnected } // Game over delay if (this._gameOver) { this._gameOverDelay += dt; this._updateExplosions(dt); if (this._gameOverDelay >= this._gameOverDelayDuration) { this._transitionToResult(); } return; } // Elapsed time (count up, for display only) this._elapsedTime += dt; // Update map this._mapManager.update(dt); // Update all players for (const player of this._players) { if (player.isRespawning) { player.respawnTimer -= dt * 1000; if (player.respawnTimer <= 0 && (player.isLocal || player.isBot)) { this._respawnPlayer(player); // Notify server if (this._networkManager) { this._networkManager.send(NET_MSG.PLAYER_RESPAWN, { playerId: player.playerId, }); } } continue; } if (!player.tank.alive) continue; if (player.isLocal) { // Local player movement if (this._joystick.active && this._joystick.direction >= 0) { player.tank.move(this._joystick.direction, dt, this._mapManager); } } else if (!player.isBot) { // Remote player interpolation this._interpolateRemoteTank(player, dt); } else if (player.team === this._myTeam) { // Our team's bot — we are the authority, run AI locally this._updateBotAI(player, dt); } else if (this._enemyTeamAllBots) { // Enemy team's bot but enemy has no human players — // run AI locally since no remote client exists to drive them this._updateBotAI(player, dt); } else { // Enemy team's bot — interpolated from remote state (authority is on their side) this._interpolateRemoteTank(player, dt); } player.tank.update(dt); } // Update bullets for (const bullet of this._bullets) { bullet.update(dt); } // Collision detection this._checkCollisions(); // Update explosions this._updateExplosions(dt); // Cleanup this._cleanup(); // Send local state periodically this._syncTimer += dt; if (this._syncTimer >= this._syncInterval) { this._syncTimer = 0; this._sendLocalState(); } // Send input changes this._sendInputIfChanged(); }, _interpolateRemoteTank(player, dt) { const target = this._remoteTargets[player.playerId]; if (!target) return; const tank = player.tank; tank.direction = target.direction; const dx = target.x - tank.x; const dy = target.y - tank.y; const dist = Math.abs(dx) + Math.abs(dy); if (dist > TILE_SIZE * 3) { // Too far away (e.g. respawn / teleport) — snap directly tank.x = target.x; tank.y = target.y; } else if (dist > 0.5) { // Interpolate towards target position. // No local terrain collision check — the remote position is authoritative // from the opponent's client. Local terrain may differ (bullets destroy // bricks independently on each client), so blocking here would cause // the remote tank to get stuck at the wrong position. // No input prediction — it causes accumulated drift because Tank.move() // has grid-snapping and terrain-sliding that differ between clients. const lerpSpeed = Math.min(20 * dt, 1); // cap at 1 to avoid overshoot tank.x += dx * lerpSpeed; tank.y += dy * lerpSpeed; } else { // Close enough — snap to target tank.x = target.x; tank.y = target.y; } }, _updateBotAI(player, dt) { const tank = player.tank; // Use BotTank's built-in AI if available if (typeof tank.updateAI === 'function') { tank.updateAI(dt, this._mapManager, (bot) => { this._botFire(player); }); return; } // Fallback: simple bot AI for non-BotTank instances if (!player._botTimer) player._botTimer = 0; if (!player._botShootTimer) player._botShootTimer = 0; if (!player._botDirection) player._botDirection = Math.floor(Math.random() * 4); player._botTimer += dt; player._botShootTimer += dt; // Change direction every 1-3 seconds if (player._botTimer > 1 + Math.random() * 2) { player._botTimer = 0; player._botDirection = Math.floor(Math.random() * 4); } // Move tank.move(player._botDirection, dt, this._mapManager); // Shoot every 1-2 seconds if (player._botShootTimer > 1 + Math.random()) { player._botShootTimer = 0; this._botFire(player); } }, _sendLocalState() { if (!this._networkManager || !this._localPlayer) return; const tank = this._localPlayer.tank; // Send normalized grid coordinates instead of pixel coordinates // so that different screen sizes produce the same grid position. this._networkManager.send(NET_MSG.PLAYER_STATE, { playerId: this._myPlayerId, col: (tank.x - MAP_OFFSET_X) / TILE_SIZE, row: (tank.y - MAP_OFFSET_Y) / TILE_SIZE, direction: tank.direction, hp: tank.hp, alive: tank.alive, }); // Also sync our team's bot states this._sendBotStates(); }, _sendInputIfChanged() { if (!this._networkManager) return; const currentInput = { direction: this._joystick.direction, moving: this._joystick.active && this._joystick.direction >= 0, }; if ( !this._lastSentInput || this._lastSentInput.direction !== currentInput.direction || this._lastSentInput.moving !== currentInput.moving ) { this._lastSentInput = { ...currentInput }; this._networkManager.send(NET_MSG.PLAYER_INPUT, { playerId: this._myPlayerId, ...currentInput, }); } }, _sendTerrainChange(row, col, newTerrain) { if (!this._networkManager) return; this._networkManager.send(NET_MSG.TERRAIN_CHANGE, { row, col, terrain: newTerrain, }); }, _sendBotStates() { if (!this._networkManager) return; // Only sync bots on OUR team (we are the authority for our team's bots) const myBots = this._players.filter(p => p.isBot && p.team === this._myTeam); for (const bot of myBots) { const tank = bot.tank; this._networkManager.send(NET_MSG.BOT_STATE, { playerId: bot.playerId, col: (tank.x - MAP_OFFSET_X) / TILE_SIZE, row: (tank.y - MAP_OFFSET_Y) / TILE_SIZE, direction: tank.direction, alive: tank.alive, hp: tank.hp, }); } }, // ============================================================ // Collision Detection // ============================================================ _checkCollisions() { const aliveBullets = this._bullets.filter(b => b.alive); // Bullet vs terrain (including bases) for (const bullet of aliveBullets) { if (!bullet.alive) continue; this._checkBulletTerrain(bullet); } // Bullet vs tanks (team-aware) for (const bullet of aliveBullets) { if (!bullet.alive) continue; this._checkBulletVsTanks(bullet); } // Bullet vs bullet this._checkBulletVsBullet(aliveBullets); // Tank vs tank (push apart) const alivePlayers = this._players.filter(p => p.tank.alive && !p.isRespawning); for (let i = 0; i < alivePlayers.length; i++) { for (let j = i + 1; j < alivePlayers.length; j++) { if (alivePlayers[i].tank.collidesWith(alivePlayers[j].tank)) { this._separateTanks(alivePlayers[i].tank, alivePlayers[j].tank); } } } }, _checkBulletTerrain(bullet) { const { row, col } = this._mapManager.pixelToGrid(bullet.x, bullet.y); if (row < 0 || row >= this._mapData.grid.length || col < 0 || col >= this._mapData.grid[0].length) { bullet.destroy(); this._spawnExplosion(bullet.x, bullet.y, false); return; } const terrain = this._mapManager.getTerrain(row, col); const bulletOwner = this._players.find(p => p.playerId === bullet.ownerPlayerId); // Only our team's bullets can modify terrain locally; enemy bullets are // visual-only and their terrain changes come via TERRAIN_CHANGE messages. const isAuthority = bulletOwner && bulletOwner.team === this._myTeam; if (terrain === TERRAIN.BRICK) { if (isAuthority) { this._mapManager.setTerrain(row, col, TERRAIN.EMPTY); this._sendTerrainChange(row, col, TERRAIN.EMPTY); } bullet.destroy(); this._spawnExplosion(bullet.x, bullet.y, false); } else if (terrain === TERRAIN.BASE_WALL) { // Determine which team this base wall belongs to const wallTeam = this._getBaseWallTeam(row, col); // Friendly-fire immunity: own team's bullets don't damage own base walls if (bulletOwner && wallTeam && bulletOwner.team === wallTeam) { bullet.destroy(); return; } if (isAuthority) { // Base wall has HP — use bulletHitTerrain for proper HP tracking const prevTerrain = this._mapManager.getTerrain(row, col); this._mapManager.bulletHitTerrain(row, col, bullet.canBreakSteel); const newTerrain = this._mapManager.getTerrain(row, col); if (prevTerrain !== newTerrain) { this._sendTerrainChange(row, col, newTerrain); } } bullet.destroy(); this._spawnExplosion(bullet.x, bullet.y, false); } else if (terrain === TERRAIN.STEEL) { if (bullet.canBreakSteel && isAuthority) { this._mapManager.setTerrain(row, col, TERRAIN.EMPTY); this._sendTerrainChange(row, col, TERRAIN.EMPTY); } bullet.destroy(); this._spawnExplosion(bullet.x, bullet.y, false); } else if (terrain === TERRAIN.BASE) { // Base hit! Determine which team's base bullet.destroy(); this._spawnExplosion(bullet.x, bullet.y, true); this._handleBaseHit(bullet, row, col); } }, /** * Determine which team a BASE_WALL tile belongs to based on proximity to team bases. * @param {number} row * @param {number} col * @returns {string} 'A', 'B', or '' if unknown */ _getBaseWallTeam(row, col) { // Check proximity to each team's base positions let minDistA = Infinity; let minDistB = Infinity; if (this._mapData.teamABase) { for (const base of this._mapData.teamABase) { const dist = Math.abs(row - base.row) + Math.abs(col - base.col); if (dist < minDistA) minDistA = dist; } } if (this._mapData.teamBBase) { for (const base of this._mapData.teamBBase) { const dist = Math.abs(row - base.row) + Math.abs(col - base.col); if (dist < minDistB) minDistB = dist; } } if (minDistA < minDistB) return 'A'; if (minDistB < minDistA) return 'B'; // Fallback: use column position (left = A, right = B) return col < this._mapData.grid[0].length / 2 ? 'A' : 'B'; }, _handleBaseHit(bullet, row, col) { // Determine which team's base was hit based on position const bulletOwner = this._players.find(p => p.playerId === bullet.ownerPlayerId); if (!bulletOwner) return; // Check if the base belongs to team A or team B let targetTeam = ''; if (this._mapData.teamABase) { for (const base of this._mapData.teamABase) { if (base.row === row && base.col === col) { targetTeam = 'A'; break; } } } if (!targetTeam && this._mapData.teamBBase) { for (const base of this._mapData.teamBBase) { if (base.row === row && base.col === col) { targetTeam = 'B'; break; } } } // If we can't determine by exact position, use column position if (!targetTeam) { targetTeam = col < this._mapData.grid[0].length / 2 ? 'A' : 'B'; } // Friendly-fire immunity: bullets do not damage their own team's base if (bulletOwner.team === targetTeam) { return; // ignore friendly base hit } // Local player or our team's bot reports base hits to server. // Also report when enemy team is all bots (we are the authority for them). const isLocalAuthority = bulletOwner.isLocal || (bulletOwner.isBot && bulletOwner.team === this._myTeam); const isEnemyBotAuthority = this._enemyTeamAllBots && bulletOwner.isBot && bulletOwner.team !== this._myTeam; if ((isLocalAuthority || isEnemyBotAuthority) && this._networkManager) { this._networkManager.send(NET_MSG.BASE_HIT, { targetTeam, damage: 1, attackerId: bulletOwner.playerId, }); // Update local stats if (this._stats[bulletOwner.playerId]) { this._stats[bulletOwner.playerId].baseDamage++; } } }, _checkBulletVsTanks(bullet) { const bb = bullet.getBounds(); const bulletOwner = this._players.find(p => p.playerId === bullet.ownerPlayerId); if (!bulletOwner) return; // Only perform tank hit detection for bullets from our team. // Enemy bullets are visual-only on our side; the enemy client is the // authority for those hits and will send PLAYER_KILLED. // Exception: when the enemy team has no human players (all bots), // we run enemy bot AI locally and must also resolve their bullet hits. if (bulletOwner.team !== this._myTeam && !this._enemyTeamAllBots) return; for (const player of this._players) { if (!player.tank.alive || player.isRespawning) continue; // Friendly fire protection: don't hit teammates if (player.team === bulletOwner.team) continue; const tb = player.tank.getBounds(); if (this._rectsOverlap(bb, tb)) { const destroyed = player.tank.takeDamage(1); bullet.destroy(); if (destroyed) { this._spawnExplosion(player.tank.x, player.tank.y, true); this._handlePlayerDeath(player, bulletOwner); } else { this._spawnExplosion(bullet.x, bullet.y, false); } return; } } }, _checkBulletVsBullet(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 bullets from different teams const ownerI = this._players.find(p => p.playerId === bullets[i].ownerPlayerId); const ownerJ = this._players.find(p => p.playerId === bullets[j].ownerPlayerId); if (ownerI && ownerJ && ownerI.team === ownerJ.team) 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._spawnExplosion(mx, my, false); } } } }, _separateTanks(tankA, tankB) { const a = tankA.getBounds(); const b = tankB.getBounds(); 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; // Save original positions in case push causes terrain collision const origAX = tankA.x, origAY = tankA.y; const origBX = tankB.x, origBY = tankB.y; if (overlapX < overlapY) { const sign = tankA.x < tankB.x ? -1 : 1; const push = overlapX / 2; tankA.x += sign * push; tankB.x -= sign * push; } else { const sign = tankA.y < tankB.y ? -1 : 1; const push = overlapY / 2; tankA.y += sign * push; tankB.y -= sign * push; } // Validate pushed positions against terrain; revert if stuck in wall if (this._mapManager) { const leftA = tankA.x - tankA.halfSize; const topA = tankA.y - tankA.halfSize; if (this._mapManager.rectCollidesWithTerrain(leftA, topA, tankA.size, tankA.size)) { tankA.x = origAX; tankA.y = origAY; } const leftB = tankB.x - tankB.halfSize; const topB = tankB.y - tankB.halfSize; if (this._mapManager.rectCollidesWithTerrain(leftB, topB, tankB.size, tankB.size)) { tankB.x = origBX; tankB.y = origBY; } } }, _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 ); }, // ============================================================ // Game Logic // ============================================================ _localFire() { if (!this._localPlayer || !this._localPlayer.tank.alive) return; if (!this._localPlayer.tank.canFire()) return; if (this._gameOver || this._paused) return; const tank = this._localPlayer.tank; 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: 'local', canBreakSteel: tank.canBreakSteel(), ownerTank: tank, }); bullet.ownerPlayerId = this._myPlayerId; bullet._isAlly = true; // local player's bullets are always ally tank.activeBullets++; this._bullets.push(bullet); GameGlobal.audioManager.playSFX('shoot'); // Send to network — use normalized grid coordinates if (this._networkManager) { this._networkManager.send(NET_MSG.BULLET_FIRE, { playerId: this._myPlayerId, col: (bullet.x - MAP_OFFSET_X) / TILE_SIZE, row: (bullet.y - MAP_OFFSET_Y) / TILE_SIZE, direction: bullet.direction, canBreakSteel: bullet.canBreakSteel, }); } }, _botFire(player) { if (!player.tank.alive || !player.tank.canFire()) return; const tank = player.tank; 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: 'bot', canBreakSteel: false, ownerTank: tank, }); bullet.ownerPlayerId = player.playerId; bullet._isAlly = player.team === this._myTeam; // bot on my team = ally tank.activeBullets++; this._bullets.push(bullet); // Sync our team's bot bullets to the remote client if (player.team === this._myTeam && this._networkManager) { this._networkManager.send(NET_MSG.BULLET_FIRE, { playerId: player.playerId, col: (bullet.x - MAP_OFFSET_X) / TILE_SIZE, row: (bullet.y - MAP_OFFSET_Y) / TILE_SIZE, direction: bullet.direction, canBreakSteel: false, }); } }, _spawnRemoteBullet(data) { const player = this._players.find(p => p.playerId === data.playerId); if (!player) return; // Convert normalized grid coords back to local pixel coords let bx, by; if (data.col !== undefined && data.row !== undefined) { bx = MAP_OFFSET_X + data.col * TILE_SIZE; by = MAP_OFFSET_Y + data.row * TILE_SIZE; } else { bx = data.x; by = data.y; } const bullet = this._bulletPool.get(); bullet.init({ x: bx, y: by, direction: data.direction, owner: 'remote', canBreakSteel: data.canBreakSteel || false, ownerTank: player.tank, }); bullet.ownerPlayerId = data.playerId; bullet._isAlly = player.team === this._myTeam; // remote on my team = ally player.tank.activeBullets++; this._bullets.push(bullet); }, _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'); }, _clearSpawnArea(col, row) { // Clear a 3x3 area around the spawn point to ensure the tank // (whose size is ~0.85 tiles) won't overlap adjacent blocking tiles for (let dr = -1; dr <= 1; dr++) { for (let dc = -1; dc <= 1; dc++) { const r = row + dr; const c = col + dc; const terrain = this._mapManager.getTerrain(r, c); // Clear any tank-blocking terrain except BASE and RIVER if (terrain === TERRAIN.BRICK || terrain === TERRAIN.STEEL || terrain === TERRAIN.BASE_WALL) { this._mapManager.setTerrain(r, c, TERRAIN.EMPTY); } } } }, _handlePlayerDeath(victim, killer) { // Update stats if (this._stats[killer.playerId]) { this._stats[killer.playerId].kills++; } if (this._stats[victim.playerId]) { this._stats[victim.playerId].deaths++; } // Start respawn timer this._startRespawn(victim); // If local player or our team's bot killed someone, notify server. // Also notify when enemy team is all bots (we are the authority for them). const isLocalAuthority = killer.isLocal || (killer.isBot && killer.team === this._myTeam); const isEnemyBotAuthority = this._enemyTeamAllBots && killer.isBot && killer.team !== this._myTeam; if ((isLocalAuthority || isEnemyBotAuthority) && this._networkManager) { this._networkManager.send(NET_MSG.PLAYER_KILLED, { killerId: killer.playerId, victimId: victim.playerId, }); } // Play sound if (victim.isLocal) { GameGlobal.audioManager.playSFX('gameover'); } }, _startRespawn(player) { player.isRespawning = true; player.respawnTimer = this._battleConfig ? this._battleConfig.respawnDelay : TEAM_RESPAWN_DELAY; player.tank.alive = false; }, _respawnPlayer(player) { player.isRespawning = false; player.respawnTimer = 0; player.tank.alive = true; player.tank.hp = 1; // Respawn at team spawn point const spawn = player.spawnPoint; // Clear spawn area to prevent getting stuck in rebuilt terrain this._clearSpawnArea(spawn.col, spawn.row); player.tank.x = MAP_OFFSET_X + spawn.col * TILE_SIZE + TILE_SIZE / 2; player.tank.y = MAP_OFFSET_Y + spawn.row * TILE_SIZE + TILE_SIZE / 2; player.tank.activateShield(3000); // Update remote target if (!player.isLocal) { this._remoteTargets[player.playerId] = { x: player.tank.x, y: player.tank.y, direction: player.tank.direction, }; } }, _updateExplosions(dt) { for (const exp of this._explosions) { exp.update(dt); } }, _cleanup() { 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); } } 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); } } }, _transitionToResult() { const sm = GameGlobal.sceneManager; if (!sm._scenes.has(SCENE.TEAM_RESULT)) { const TeamResultScene = require('./TeamResultScene'); sm.register(SCENE.TEAM_RESULT, TeamResultScene); } const didWin = this._winner === this._myTeam; sm.switchTo(SCENE.TEAM_RESULT, { winner: this._winner, winReason: this._winReason, myTeam: this._myTeam, didWin, isDraw: false, // No draw in base-destruction mode teamABaseHp: this._teamABaseHp, teamBBaseHp: this._teamBBaseHp, stats: this._stats, players: this._players.map(p => ({ playerId: p.playerId, nickname: p.nickname || '', team: p.team, isBot: p.isBot, isLocal: p.isLocal, })), elapsedTime: Math.floor(this._elapsedTime), teamId: this._teamId, battleMode: this._battleMode, }); }, // ============================================================ // Render // ============================================================ render(ctx) { if (!this._initialized) return; // Game area background ctx.fillStyle = '#111111'; ctx.fillRect(MAP_OFFSET_X, MAP_OFFSET_Y, MAP_WIDTH, MAP_HEIGHT); // Render map this._mapManager.render(ctx); // Render all tanks for (const player of this._players) { if (player.tank.alive && !player.isRespawning) { player.tank.render(ctx); // Name & team indicator above the tank const tx = player.tank.x; const labelY = player.tank.y - player.tank.halfSize - 4; const nameY = labelY - 10; // Per-tank team color: // - local player → gold // - Team A (ally or enemy) → blue // - Team B (ally or enemy) → red let labelColor; if (player.isLocal) labelColor = LOCAL_PLAYER_COLOR; else labelColor = player.team === 'A' ? TEAM_A_COLOR : TEAM_B_COLOR; ctx.fillStyle = labelColor; ctx.textAlign = 'center'; ctx.textBaseline = 'middle'; // Arrow / bot tag ctx.font = 'bold 8px Arial'; let marker; if (player.isLocal) marker = '★'; else if (player.isBot) marker = '🤖'; else marker = (player.team === this._myTeam) ? '▲' : '▼'; ctx.fillText(marker, tx, labelY); // Nickname (truncated to 4 Chinese-equivalent chars) const name = this._getTankLabel(player); if (name) { ctx.font = 'bold 9px Arial'; // Outline for readability on busy backgrounds ctx.lineWidth = 3; ctx.strokeStyle = 'rgba(0,0,0,0.7)'; ctx.strokeText(name, tx, nameY); ctx.fillText(name, tx, nameY); } } } // Render forest overlay 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); // Respawn overlay if (this._localPlayer && this._localPlayer.isRespawning) { this._renderRespawnOverlay(ctx); } // Pause overlay if (this._paused) { this._renderPauseOverlay(ctx); } // Game over overlay if (this._gameOver) { this._renderGameOverOverlay(ctx); } // Disconnection overlay if (this._isDisconnected) { this._renderDisconnectOverlay(ctx); } }, _getTeamStats(team) { let kills = 0, deaths = 0; for (const p of this._players) { if (p.team === team && this._stats[p.playerId]) { kills += this._stats[p.playerId].kills; deaths += this._stats[p.playerId].deaths; } } return { kills, deaths }; }, /** * Compute a short label (≤ 4 Chinese-equivalent chars) to draw above a tank. * Uses real WeChat nickname if available, otherwise a stable fallback. * @private */ _getTankLabel(player) { if (!player) return ''; const profile = GameGlobal.playerProfile; let raw = ''; if (player.isLocal) { // For local player prefer the freshest profile nickname if granted. if (profile && profile.nickname) raw = profile.nickname; else raw = player.nickname || ''; } else { // For remote players, use the server-provided nickname. // Do NOT fall back to profile.getDisplayName() because that returns // the LOCAL player's nickname when set, which would show the wrong // name for every remote player. raw = player.nickname || ''; } if (!raw) { if (player.isBot) { raw = ''; // bot — we already draw the 🤖 marker, skip name } else if (player.playerId && typeof player.playerId === 'string') { // Derive a stable anonymous tag from the remote player's own ID. const tail = player.playerId.slice(-4).toUpperCase(); raw = `Tanker_${tail}`; } else { raw = ''; } } if (!raw) return ''; if (profile && typeof profile.truncate === 'function') { return profile.truncate(raw, 4); } return raw.length > 8 ? raw.substring(0, 8) + '..' : raw; }, _renderHUD(ctx) { const hudY = 4; // Team A base HP (left) const barWidth = 80; const barHeight = 12; const barY = hudY + 2; const timeGap = 30; // half-width reserved for the timer in the center // Team A label + HP bar const teamALabel = this._battleMode === '1v1' ? 'P1' : t('team.teamA'); const teamBLabel = this._battleMode === '1v1' ? 'P2' : t('team.teamB'); ctx.fillStyle = this._battleMode === '1v1' ? PLAYER1_COLOR : TEAM_A_COLOR; ctx.font = 'bold 10px Arial'; ctx.textAlign = 'right'; ctx.textBaseline = 'top'; ctx.fillText(teamALabel, SCREEN_WIDTH / 2 - timeGap - barWidth - 5, barY); // Team A HP bar background const barAX = SCREEN_WIDTH / 2 - timeGap; ctx.fillStyle = '#333333'; ctx.fillRect(barAX, barY, -barWidth, barHeight); // Team A HP bar fill const maxBaseHp = this._battleConfig ? this._battleConfig.baseHp : TEAM_BASE_HP; const hpRatioA = this._teamABaseHp / maxBaseHp; const teamADisplayColor = this._battleMode === '1v1' ? PLAYER1_COLOR : TEAM_A_COLOR; const teamBDisplayColor = this._battleMode === '1v1' ? PLAYER2_COLOR : TEAM_B_COLOR; ctx.fillStyle = hpRatioA > 0.3 ? teamADisplayColor : '#FF4444'; ctx.fillRect(barAX, barY, -barWidth * hpRatioA, barHeight); // Team A HP text ctx.fillStyle = '#FFFFFF'; ctx.font = 'bold 9px Arial'; ctx.textAlign = 'center'; ctx.fillText(`${this._teamABaseHp}`, barAX - barWidth / 2, barY + barHeight / 2 + 1); // Elapsed time (center, count up) const minutes = Math.floor(this._elapsedTime / 60); const seconds = Math.floor(this._elapsedTime % 60); const timeStr = `${minutes}:${seconds.toString().padStart(2, '0')}`; ctx.fillStyle = '#FFFFFF'; ctx.font = 'bold 16px Arial'; ctx.textAlign = 'center'; ctx.fillText(timeStr, SCREEN_WIDTH / 2, hudY); // Team B label + HP bar const barBStart = SCREEN_WIDTH / 2 + timeGap; ctx.fillStyle = this._battleMode === '1v1' ? PLAYER2_COLOR : TEAM_B_COLOR; ctx.font = 'bold 10px Arial'; ctx.textAlign = 'left'; ctx.fillText(teamBLabel, barBStart + barWidth + 5, barY); // Team B HP bar background ctx.fillStyle = '#333333'; ctx.fillRect(barBStart, barY, barWidth, barHeight); // Team B HP bar fill const hpRatioB = this._teamBBaseHp / maxBaseHp; ctx.fillStyle = hpRatioB > 0.3 ? teamBDisplayColor : '#FF4444'; ctx.fillRect(barBStart, barY, barWidth * hpRatioB, barHeight); // Team B HP text ctx.fillStyle = '#FFFFFF'; ctx.font = 'bold 9px Arial'; ctx.textAlign = 'center'; ctx.fillText(`${this._teamBBaseHp}`, barBStart + barWidth / 2, barY + barHeight / 2 + 1); // My team indicator if (this._battleMode === '1v1') { const mySlot = this._myTeam === 'A' ? 1 : 2; ctx.fillStyle = this._myTeam === 'A' ? PLAYER1_COLOR : PLAYER2_COLOR; ctx.font = '9px Arial'; ctx.textAlign = 'center'; ctx.fillText(t('pvp.playerLabel', { slot: mySlot }), SCREEN_WIDTH / 2, hudY + 20); } else { ctx.fillStyle = this._myTeam === 'A' ? TEAM_A_COLOR : TEAM_B_COLOR; ctx.font = '9px Arial'; ctx.textAlign = 'center'; ctx.fillText(t('team.myTeam', { team: this._myTeam }), SCREEN_WIDTH / 2, hudY + 20); } // Team kill/death totals const myTeamStats = this._getTeamStats(this._myTeam); const enemyTeam = this._myTeam === 'A' ? 'B' : 'A'; const enemyTeamStats = this._getTeamStats(enemyTeam); ctx.fillStyle = '#AAAAAA'; ctx.font = '10px Arial'; ctx.textAlign = 'left'; ctx.fillText(t('team.killDeath', { kills: myTeamStats.kills, deaths: myTeamStats.deaths }), 10, hudY); ctx.textAlign = 'right'; ctx.fillText(t('team.killDeath', { kills: enemyTeamStats.kills, deaths: enemyTeamStats.deaths }), SCREEN_WIDTH - 10, hudY); // Latency if (this._networkManager) { ctx.fillStyle = '#666666'; ctx.font = '9px Arial'; ctx.textAlign = 'right'; ctx.fillText(`${this._networkManager.latency || 0}ms`, SCREEN_WIDTH - 10, hudY + 14); } }, _renderRespawnOverlay(ctx) { const remaining = Math.ceil(this._localPlayer.respawnTimer / 1000); ctx.fillStyle = 'rgba(0,0,0,0.3)'; ctx.fillRect(SCREEN_WIDTH / 2 - 60, SCREEN_HEIGHT / 2 - 25, 120, 50); ctx.fillStyle = '#FFFFFF'; ctx.font = 'bold 14px Arial'; ctx.textAlign = 'center'; ctx.textBaseline = 'middle'; ctx.fillText(t('team.respawn', { seconds: remaining }), SCREEN_WIDTH / 2, SCREEN_HEIGHT / 2); }, _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); }, _renderGameOverOverlay(ctx) { ctx.fillStyle = 'rgba(0,0,0,0.5)'; ctx.fillRect(0, 0, SCREEN_WIDTH, SCREEN_HEIGHT); let text, color; if (this._winner === this._myTeam) { text = t('team.victory'); color = '#00FF00'; } else { text = t('team.defeat'); color = '#FF0000'; } ctx.fillStyle = color; ctx.font = 'bold 36px Arial'; ctx.textAlign = 'center'; ctx.textBaseline = 'middle'; ctx.fillText(text, SCREEN_WIDTH / 2, SCREEN_HEIGHT / 2 - 10); // Base HP summary ctx.fillStyle = '#FFFFFF'; ctx.font = '14px Arial'; if (this._battleMode === '1v1') { ctx.fillText( t('pvp.baseHpSummary', { hp1: this._teamABaseHp, hp2: this._teamBBaseHp }), SCREEN_WIDTH / 2, SCREEN_HEIGHT / 2 + 25 ); } else { ctx.fillText( t('team.baseHpSummary', { hpA: this._teamABaseHp, hpB: this._teamBBaseHp }), SCREEN_WIDTH / 2, SCREEN_HEIGHT / 2 + 25 ); } }, _renderDisconnectOverlay(ctx) { ctx.fillStyle = 'rgba(0,0,0,0.7)'; ctx.fillRect(0, 0, SCREEN_WIDTH, SCREEN_HEIGHT); ctx.fillStyle = '#FF6347'; ctx.font = 'bold 20px Arial'; ctx.textAlign = 'center'; ctx.textBaseline = 'middle'; ctx.fillText(t('team.disconnectTitle'), SCREEN_WIDTH / 2, SCREEN_HEIGHT / 2 - 30); const dots = '.'.repeat(Math.floor(this._reconnectTimer * 3) % 4); ctx.fillStyle = '#FFFFFF'; ctx.font = '14px Arial'; ctx.fillText( t('team.reconnecting', { dots, attempts: this._reconnectAttempts, max: this._maxReconnectAttempts }), SCREEN_WIDTH / 2, SCREEN_HEIGHT / 2 + 5 ); ctx.fillStyle = '#AAAAAA'; ctx.font = '12px Arial'; ctx.fillText( t('team.reconnectHint'), SCREEN_WIDTH / 2, SCREEN_HEIGHT / 2 + 30 ); }, async _attemptReconnect() { this._reconnectAttempts++; console.log(`[TeamGameScene] Reconnect attempt ${this._reconnectAttempts}/${this._maxReconnectAttempts}`); if (this._reconnectAttempts > this._maxReconnectAttempts) { // Give up reconnecting — treat as defeat this._isDisconnected = false; this._winner = this._myTeam === 'A' ? 'B' : 'A'; this._winReason = 'disconnected'; this._gameOver = true; return; } const nm = this._networkManager; if (!nm) return; try { // Try to reconnect to server const { SERVER_URL } = require('../base/GameGlobal'); const ok = await nm.connect(SERVER_URL); if (ok) { // Send reconnect message nm.send(NET_MSG.RECONNECT, { teamId: this._teamId, playerId: this._myPlayerId, }); } } catch (e) { console.error('[TeamGameScene] Reconnect failed:', e); } }, // ============================================================ // Touch Handling // ============================================================ handleTouch(eventType, e) { if (this._gameOver) return; if (this._paused) { if (eventType === 'touchstart') { this._paused = false; } return; } const touches = eventType === 'touchend' ? e.changedTouches : e.touches; for (let i = 0; i < touches.length; i++) { const touch = touches[i]; if (this._joystick.handleTouch(eventType, touch)) continue; if (this._fireButton.handleTouch(eventType, touch)) continue; // Pause button area if (eventType === 'touchstart') { if (touch.clientX > SCREEN_WIDTH - 50 && touch.clientY < MAP_OFFSET_Y) { this._paused = true; } } } }, }; module.exports = TeamGameScene;