|
|
|
@@ -158,6 +158,10 @@ const TeamGameScene = {
|
|
|
|
|
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
|
|
|
|
@@ -165,9 +169,25 @@ const TeamGameScene = {
|
|
|
|
|
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 };
|
|
|
|
@@ -203,14 +223,9 @@ const TeamGameScene = {
|
|
|
|
|
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;
|
|
|
|
|
}
|
|
|
|
|
// 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;
|
|
|
|
@@ -240,15 +255,28 @@ const TeamGameScene = {
|
|
|
|
|
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();
|
|
|
|
|
// 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;
|
|
|
|
@@ -321,9 +349,13 @@ const TeamGameScene = {
|
|
|
|
|
|
|
|
|
|
// 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);
|
|
|
|
|
}
|
|
|
|
|
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
|
|
|
|
@@ -470,6 +502,25 @@ const TeamGameScene = {
|
|
|
|
|
}
|
|
|
|
|
}));
|
|
|
|
|
|
|
|
|
|
// 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;
|
|
|
|
|
},
|
|
|
|
|
|
|
|
|
@@ -558,9 +609,16 @@ const TeamGameScene = {
|
|
|
|
|
} else if (!player.isBot) {
|
|
|
|
|
// Remote player interpolation
|
|
|
|
|
this._interpolateRemoteTank(player, dt);
|
|
|
|
|
} else {
|
|
|
|
|
// Bot AI using BotTank.updateAI
|
|
|
|
|
} 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);
|
|
|
|
@@ -673,6 +731,9 @@ const TeamGameScene = {
|
|
|
|
|
hp: tank.hp,
|
|
|
|
|
alive: tank.alive,
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
// Also sync our team's bot states
|
|
|
|
|
this._sendBotStates();
|
|
|
|
|
},
|
|
|
|
|
|
|
|
|
|
_sendInputIfChanged() {
|
|
|
|
@@ -696,6 +757,32 @@ const TeamGameScene = {
|
|
|
|
|
}
|
|
|
|
|
},
|
|
|
|
|
|
|
|
|
|
_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
|
|
|
|
|
// ============================================================
|
|
|
|
@@ -738,15 +825,21 @@ const TeamGameScene = {
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
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) {
|
|
|
|
|
this._mapManager.setTerrain(row, col, TERRAIN.EMPTY);
|
|
|
|
|
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);
|
|
|
|
|
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) {
|
|
|
|
@@ -754,13 +847,21 @@ const TeamGameScene = {
|
|
|
|
|
return;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
// Base wall has HP — use bulletHitTerrain for proper HP tracking
|
|
|
|
|
this._mapManager.bulletHitTerrain(row, col, bullet.canBreakSteel);
|
|
|
|
|
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) {
|
|
|
|
|
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);
|
|
|
|
@@ -836,8 +937,11 @@ const TeamGameScene = {
|
|
|
|
|
return; // ignore friendly base hit
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
// Local player or local bot reports base hits to server
|
|
|
|
|
if ((bulletOwner.isLocal || bulletOwner.isBot) && this._networkManager) {
|
|
|
|
|
// 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,
|
|
|
|
@@ -856,6 +960,13 @@ const TeamGameScene = {
|
|
|
|
|
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;
|
|
|
|
|
|
|
|
|
@@ -972,6 +1083,7 @@ const TeamGameScene = {
|
|
|
|
|
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');
|
|
|
|
@@ -1003,8 +1115,20 @@ const TeamGameScene = {
|
|
|
|
|
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) {
|
|
|
|
@@ -1031,6 +1155,7 @@ const TeamGameScene = {
|
|
|
|
|
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);
|
|
|
|
|
},
|
|
|
|
@@ -1070,8 +1195,11 @@ const TeamGameScene = {
|
|
|
|
|
// Start respawn timer
|
|
|
|
|
this._startRespawn(victim);
|
|
|
|
|
|
|
|
|
|
// If local player or local bot killed someone, notify server
|
|
|
|
|
if ((killer.isLocal || killer.isBot) && this._networkManager) {
|
|
|
|
|
// 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,
|
|
|
|
@@ -1191,12 +1319,11 @@ const TeamGameScene = {
|
|
|
|
|
|
|
|
|
|
// Per-tank team color:
|
|
|
|
|
// - local player → gold
|
|
|
|
|
// - ally (not me) → blue
|
|
|
|
|
// - enemy → red
|
|
|
|
|
// - Team A (ally or enemy) → blue
|
|
|
|
|
// - Team B (ally or 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;
|
|
|
|
|
else labelColor = player.team === 'A' ? TEAM_A_COLOR : TEAM_B_COLOR;
|
|
|
|
|
|
|
|
|
|
ctx.fillStyle = labelColor;
|
|
|
|
|
ctx.textAlign = 'center';
|
|
|
|
@@ -1289,15 +1416,21 @@ const TeamGameScene = {
|
|
|
|
|
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 (profile && typeof profile.getDisplayName === 'function') {
|
|
|
|
|
raw = profile.getDisplayName(player.playerId);
|
|
|
|
|
} 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 = player.playerId || '';
|
|
|
|
|
raw = '';
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
if (!raw) return '';
|
|
|
|
|