Files
tankwar_proj/js/scenes/TeamGameScene.js
2026-05-02 13:50:52 +08:00

1568 lines
48 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) || [];
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);
// Find local player
this._localPlayer = this._players.find(p => 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: local=gold, ally=blue, enemy=red
if (isLocal) {
tankColor = LOCAL_PLAYER_COLOR;
} else if (isMyTeam) {
tankColor = TEAM_A_COLOR;
} else {
tankColor = 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 for the LOCAL player — other players keep team color)
if (GameGlobal.skinManager && isLocal) {
tank._skinColors = GameGlobal.skinManager.getCurrentSkinColors();
tank._skinId = GameGlobal.skinManager.getEquippedSkinId();
}
}
tank.activateShield(3000);
// 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 && data.playerId !== this._myPlayerId) {
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.');
}
}));
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 {
// Bot AI using BotTank.updateAI
this._updateBotAI(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,
});
},
_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,
});
}
},
// ============================================================
// 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);
if (terrain === TERRAIN.BRICK) {
this._mapManager.setTerrain(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);
const bulletOwner = this._players.find(p => p.playerId === bullet.ownerPlayerId);
// Friendly-fire immunity: own team's bullets don't damage own base walls
if (bulletOwner && wallTeam && bulletOwner.team === wallTeam) {
bullet.destroy();
return;
}
// Base wall has HP — use bulletHitTerrain for proper HP tracking
this._mapManager.bulletHitTerrain(row, col, bullet.canBreakSteel);
bullet.destroy();
this._spawnExplosion(bullet.x, bullet.y, false);
} else if (terrain === TERRAIN.STEEL) {
if (bullet.canBreakSteel) {
this._mapManager.setTerrain(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 local bot reports base hits to server
if ((bulletOwner.isLocal || bulletOwner.isBot) && 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;
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;
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;
tank.activeBullets++;
this._bullets.push(bullet);
},
_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;
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 local bot killed someone, notify server
if ((killer.isLocal || killer.isBot) && 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
// - ally (not me) → blue
// - enemy → red
let labelColor;
if (player.isLocal) labelColor = LOCAL_PLAYER_COLOR;
else if (player.team === this._myTeam) labelColor = TEAM_A_COLOR;
else labelColor = 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 {
raw = player.nickname || '';
}
if (!raw) {
if (player.isBot) {
raw = ''; // bot — we already draw the 🤖 marker, skip name
} else if (profile && typeof profile.getDisplayName === 'function') {
raw = profile.getDisplayName(player.playerId);
} else {
raw = player.playerId || '';
}
}
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;