1701 lines
54 KiB
JavaScript
1701 lines
54 KiB
JavaScript
/**
|
|
* 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;
|