fix boss tank cross brick

This commit is contained in:
jakciehan
2026-06-07 22:08:00 +08:00
parent c3a4aa8f15
commit e4140f073f
29 changed files with 2689 additions and 1240 deletions
Vendored
BIN
View File
Binary file not shown.
Binary file not shown.
BIN
View File
Binary file not shown.
+1 -1
View File
@@ -25,7 +25,7 @@ spec:
containers: containers:
- name: tankwar-server - name: tankwar-server
image: tankwar-server:latest image: tankwar-server:latest
imagePullPolicy: IfNotPresent imagePullPolicy: Never
ports: ports:
- name: ws - name: ws
containerPort: 3000 containerPort: 3000
-1
View File
@@ -50,7 +50,6 @@ step_sync() {
ssh_master "mkdir -p ${REMOTE_WORKDIR}/server ${REMOTE_WORKDIR}/deploy/k8s" ssh_master "mkdir -p ${REMOTE_WORKDIR}/server ${REMOTE_WORKDIR}/deploy/k8s"
rsync -az --delete \ rsync -az --delete \
--exclude 'node_modules' \
--exclude '.git' \ --exclude '.git' \
--exclude '.DS_Store' \ --exclude '.DS_Store' \
-e "ssh ${SSH_OPTS}" \ -e "ssh ${SSH_OPTS}" \
+55 -13
View File
@@ -177,9 +177,10 @@ wx.onShow((res) => {
// Check for teamId from invite card (3v3 mode) or roomId from invite card (1v1 mode) // Check for teamId from invite card (3v3 mode) or roomId from invite card (1v1 mode)
const teamId = _extractTeamId(res && res.query); const teamId = _extractTeamId(res && res.query);
const teamMode = _extractTeamMode(res && res.query);
const roomId = _extractRoomId(res && res.query); const roomId = _extractRoomId(res && res.query);
if (teamId) { if (teamId) {
_handleInviteTeamId(teamId); _handleInviteTeamId(teamId, teamMode);
} else if (roomId) { } else if (roomId) {
_handleInviteRoomId(roomId); _handleInviteRoomId(roomId);
} else { } else {
@@ -187,10 +188,11 @@ wx.onShow((res) => {
try { try {
const launchOptions = wx.getLaunchOptionsSync(); const launchOptions = wx.getLaunchOptionsSync();
const fallbackTeamId = _extractTeamId(launchOptions && launchOptions.query); const fallbackTeamId = _extractTeamId(launchOptions && launchOptions.query);
const fallbackTeamMode = _extractTeamMode(launchOptions && launchOptions.query);
const fallbackRoomId = _extractRoomId(launchOptions && launchOptions.query); const fallbackRoomId = _extractRoomId(launchOptions && launchOptions.query);
if (fallbackTeamId) { if (fallbackTeamId) {
console.log(`[game.js] onShow query empty, but found teamId in launchOptions: ${fallbackTeamId}`); console.log(`[game.js] onShow query empty, but found teamId in launchOptions: ${fallbackTeamId}`);
_handleInviteTeamId(fallbackTeamId); _handleInviteTeamId(fallbackTeamId, fallbackTeamMode);
} else if (fallbackRoomId) { } else if (fallbackRoomId) {
console.log(`[game.js] onShow query empty, but found roomId in launchOptions: ${fallbackRoomId}`); console.log(`[game.js] onShow query empty, but found roomId in launchOptions: ${fallbackRoomId}`);
_handleInviteRoomId(fallbackRoomId); _handleInviteRoomId(fallbackRoomId);
@@ -242,6 +244,36 @@ function _extractTeamId(query) {
return null; return null;
} }
/**
* Extract mode parameter from query (e.g. mode=2v2 or mode=3v3).
* Used to route team invites to the correct room scene.
* @param {object|string|undefined} query
* @returns {string|null}
*/
function _extractTeamMode(query) {
if (!query) return null;
if (typeof query === 'object' && query.mode) {
return query.mode;
}
if (typeof query === 'string') {
const match = query.match(/mode=([^&]+)/);
if (match) return match[1];
}
if (typeof query === 'object') {
const keys = Object.keys(query);
for (const key of keys) {
const combined = key + (query[key] ? '=' + query[key] : '');
const match = combined.match(/mode=([^&]+)/);
if (match) return match[1];
}
}
return null;
}
/** /**
* Extract roomId from query parameter (1v1 invite card). * Extract roomId from query parameter (1v1 invite card).
* Similar to _extractTeamId, but looks for roomId key. * Similar to _extractTeamId, but looks for roomId key.
@@ -309,10 +341,11 @@ function _handleInviteRoomId(roomId) {
/** /**
* Handle teamId from invite card (shared between onShow and cold launch). * Handle teamId from invite card (shared between onShow and cold launch).
* Navigates to TeamRoomScene if possible, otherwise stores as pending. * Routes to Team2v2RoomScene or TeamRoomScene based on mode parameter.
* @param {string} teamId * @param {string} teamId
* @param {string|null} mode - '2v2' or '3v3' (default: '3v3')
*/ */
function _handleInviteTeamId(teamId) { function _handleInviteTeamId(teamId, mode) {
if (!teamId) return; if (!teamId) return;
// Avoid duplicate processing if already pending the same teamId // Avoid duplicate processing if already pending the same teamId
@@ -321,21 +354,29 @@ function _handleInviteTeamId(teamId) {
return; return;
} }
console.log(`[game.js] Received teamId from invite: ${teamId}, currentScene: ${sceneManager._currentName}`); const is2v2 = mode === '2v2';
const targetScene = is2v2 ? SCENE.TEAM_2V2_ROOM : SCENE.TEAM_ROOM;
const sceneName = is2v2 ? 'Team2v2RoomScene' : 'TeamRoomScene';
// If already past loading, navigate directly to team room console.log(`[game.js] Received teamId from invite: ${teamId}, mode: ${mode || '3v3'}, targetScene: ${targetScene}, currentScene: ${sceneManager._currentName}`);
// If already past loading, navigate directly to the team room
if (sceneManager._currentScene && sceneManager._currentName !== SCENE.LOADING) { if (sceneManager._currentScene && sceneManager._currentName !== SCENE.LOADING) {
console.log(`[game.js] Navigating directly to TeamRoomScene with teamId: ${teamId}`); console.log(`[game.js] Navigating directly to ${sceneName} with teamId: ${teamId}`);
if (!sceneManager._scenes.has(SCENE.TEAM_ROOM)) { if (!sceneManager._scenes.has(targetScene)) {
const TeamRoomScene = require('./js/scenes/TeamRoomScene'); const SceneModule = is2v2
sceneManager.register(SCENE.TEAM_ROOM, TeamRoomScene); ? require('./js/scenes/Team2v2RoomScene')
: require('./js/scenes/TeamRoomScene');
sceneManager.register(targetScene, SceneModule);
} }
sceneManager.switchTo(SCENE.TEAM_ROOM, { teamId }); sceneManager.switchTo(targetScene, { teamId });
GameGlobal._pendingTeamId = null; GameGlobal._pendingTeamId = null;
GameGlobal._pendingTeamMode = null;
} else { } else {
// Still loading — store pending teamId for auto-navigation after load // Still loading — store pending teamId for auto-navigation after load
console.log(`[game.js] Still loading, storing pendingTeamId: ${teamId}`); console.log(`[game.js] Still loading, storing pendingTeamId: ${teamId}, mode: ${mode || '3v3'}`);
GameGlobal._pendingTeamId = teamId; GameGlobal._pendingTeamId = teamId;
GameGlobal._pendingTeamMode = mode || null;
} }
} }
@@ -344,9 +385,10 @@ try {
const launchOptions = wx.getLaunchOptionsSync(); const launchOptions = wx.getLaunchOptionsSync();
console.log(`[game.js] Cold launch options: scene=${launchOptions.scene}, query=${JSON.stringify(launchOptions.query)}, referrerInfo=${JSON.stringify(launchOptions.referrerInfo)}`); console.log(`[game.js] Cold launch options: scene=${launchOptions.scene}, query=${JSON.stringify(launchOptions.query)}, referrerInfo=${JSON.stringify(launchOptions.referrerInfo)}`);
const launchTeamId = _extractTeamId(launchOptions && launchOptions.query); const launchTeamId = _extractTeamId(launchOptions && launchOptions.query);
const launchTeamMode = _extractTeamMode(launchOptions && launchOptions.query);
const launchRoomId = _extractRoomId(launchOptions && launchOptions.query); const launchRoomId = _extractRoomId(launchOptions && launchOptions.query);
if (launchTeamId) { if (launchTeamId) {
_handleInviteTeamId(launchTeamId); _handleInviteTeamId(launchTeamId, launchTeamMode);
} else if (launchRoomId) { } else if (launchRoomId) {
_handleInviteRoomId(launchRoomId); _handleInviteRoomId(launchRoomId);
} else { } else {
BIN
View File
Binary file not shown.
+10
View File
@@ -102,6 +102,7 @@ const TANK_CONFIG = {
hp: 6, hp: 6,
color: '#8B0000', // dark red color: '#8B0000', // dark red
size: TILE_SIZE * 1.2, size: TILE_SIZE * 1.2,
colliderSize: TILE_SIZE * 0.85,
score: 500, score: 500,
}, },
}; };
@@ -172,6 +173,7 @@ const SCENE = {
TEAM_ROOM: 'team_room', TEAM_ROOM: 'team_room',
TEAM_GAME: 'team_game', TEAM_GAME: 'team_game',
TEAM_RESULT: 'team_result', TEAM_RESULT: 'team_result',
TEAM_2V2_ROOM: 'team_2v2_room',
CHAT_ROOM: 'chat_room', CHAT_ROOM: 'chat_room',
}; };
@@ -182,6 +184,7 @@ const GAME_MODE = {
CLASSIC: 'classic', CLASSIC: 'classic',
ENDLESS: 'endless', ENDLESS: 'endless',
PVP: 'pvp', PVP: 'pvp',
TEAM_2V2: 'team_2v2',
TEAM_3V3: 'team_3v3', TEAM_3V3: 'team_3v3',
}; };
@@ -221,6 +224,13 @@ const BATTLE_CONFIG = {
fillWithBots: false, fillWithBots: false,
mapPool: 'pvp', mapPool: 'pvp',
}, },
'2v2': {
teamSize: 2,
baseHp: 8,
respawnDelay: TEAM_RESPAWN_DELAY,
fillWithBots: true,
mapPool: 'team',
},
'3v3': { '3v3': {
teamSize: 3, teamSize: 3,
baseHp: TEAM_BASE_HP, baseHp: TEAM_BASE_HP,
+4 -3
View File
@@ -45,6 +45,7 @@ class BotTank extends Tank {
hp: cfg.hp, hp: cfg.hp,
color: params.color || (params.team === 'A' ? '#4A90D9' : '#E94560'), color: params.color || (params.team === 'A' ? '#4A90D9' : '#E94560'),
size: cfg.size, size: cfg.size,
colliderSize: cfg.colliderSize || cfg.size,
direction: params.team === 'A' ? DIRECTION.RIGHT : DIRECTION.LEFT, direction: params.team === 'A' ? DIRECTION.RIGHT : DIRECTION.LEFT,
}); });
@@ -215,10 +216,10 @@ class BotTank extends Tank {
const vec = DIR_VECTORS[dir]; const vec = DIR_VECTORS[dir];
const testX = this.x + vec.dx * TILE_SIZE; const testX = this.x + vec.dx * TILE_SIZE;
const testY = this.y + vec.dy * TILE_SIZE; const testY = this.y + vec.dy * TILE_SIZE;
const left = testX - this.halfSize; const left = testX - this.colliderHalfSize;
const top = testY - this.halfSize; const top = testY - this.colliderHalfSize;
if (mapManager && !mapManager.rectCollidesWithTerrain(left, top, this.size, this.size)) { if (mapManager && !mapManager.rectCollidesWithTerrain(left, top, this.colliderSize, this.colliderSize)) {
this.direction = dir; this.direction = dir;
return; return;
} }
+4 -3
View File
@@ -45,6 +45,7 @@ class EnemyTank extends Tank {
hp: cfg.hp, hp: cfg.hp,
color: cfg.color, color: cfg.color,
size: cfg.size, size: cfg.size,
colliderSize: cfg.colliderSize || cfg.size,
direction: DIRECTION.DOWN, direction: DIRECTION.DOWN,
}); });
@@ -176,10 +177,10 @@ class EnemyTank extends Tank {
const vec = DIR_VECTORS[dir]; const vec = DIR_VECTORS[dir];
const testX = this.x + vec.dx * TILE_SIZE; const testX = this.x + vec.dx * TILE_SIZE;
const testY = this.y + vec.dy * TILE_SIZE; const testY = this.y + vec.dy * TILE_SIZE;
const left = testX - this.halfSize; const left = testX - this.colliderHalfSize;
const top = testY - this.halfSize; const top = testY - this.colliderHalfSize;
if (!mapManager.rectCollidesWithTerrain(left, top, this.size, this.size)) { if (!mapManager.rectCollidesWithTerrain(left, top, this.colliderSize, this.colliderSize)) {
this.direction = dir; this.direction = dir;
return; return;
} }
+34 -29
View File
@@ -38,8 +38,11 @@ class Tank {
this.alive = true; this.alive = true;
this.visible = true; this.visible = true;
// Collision size (can differ from visual size for large tanks like Boss)
this.colliderSize = config.colliderSize || this.size;
// Half-size for collision calculations // Half-size for collision calculations
this.halfSize = this.size / 2; this.halfSize = this.size / 2;
this.colliderHalfSize = this.colliderSize / 2;
} }
/** /**
@@ -70,10 +73,10 @@ class Tank {
// Clamp to map boundaries instead of rejecting movement entirely. // Clamp to map boundaries instead of rejecting movement entirely.
// This allows the tank to slide along the edge smoothly. // This allows the tank to slide along the edge smoothly.
const minX = MAP_OFFSET_X + this.halfSize; const minX = MAP_OFFSET_X + this.colliderHalfSize;
const minY = MAP_OFFSET_Y + this.halfSize; const minY = MAP_OFFSET_Y + this.colliderHalfSize;
const maxX = MAP_OFFSET_X + MAP_WIDTH - this.halfSize; const maxX = MAP_OFFSET_X + MAP_WIDTH - this.colliderHalfSize;
const maxY = MAP_OFFSET_Y + MAP_HEIGHT - this.halfSize; const maxY = MAP_OFFSET_Y + MAP_HEIGHT - this.colliderHalfSize;
newX = Math.max(minX, Math.min(newX, maxX)); newX = Math.max(minX, Math.min(newX, maxX));
newY = Math.max(minY, Math.min(newY, maxY)); newY = Math.max(minY, Math.min(newY, maxY));
@@ -84,11 +87,11 @@ class Tank {
} }
// Calculate bounding box at clamped position // Calculate bounding box at clamped position
const left = newX - this.halfSize; const left = newX - this.colliderHalfSize;
const top = newY - this.halfSize; const top = newY - this.colliderHalfSize;
// Terrain collision check // Terrain collision check
if (mapManager && mapManager.rectCollidesWithTerrain(left, top, this.size, this.size)) { if (mapManager && mapManager.rectCollidesWithTerrain(left, top, this.colliderSize, this.colliderSize)) {
// Try to align to grid for smoother movement along walls // Try to align to grid for smoother movement along walls
return this._tryAlignedMove(dir, dt, mapManager); return this._tryAlignedMove(dir, dt, mapManager);
} }
@@ -107,10 +110,10 @@ class Tank {
*/ */
_snapToGrid(oldDir) { _snapToGrid(oldDir) {
const halfTile = TILE_SIZE / 2; const halfTile = TILE_SIZE / 2;
const minX = MAP_OFFSET_X + this.halfSize; const minX = MAP_OFFSET_X + this.colliderHalfSize;
const minY = MAP_OFFSET_Y + this.halfSize; const minY = MAP_OFFSET_Y + this.colliderHalfSize;
const maxX = MAP_OFFSET_X + MAP_WIDTH - this.halfSize; const maxX = MAP_OFFSET_X + MAP_WIDTH - this.colliderHalfSize;
const maxY = MAP_OFFSET_Y + MAP_HEIGHT - this.halfSize; const maxY = MAP_OFFSET_Y + MAP_HEIGHT - this.colliderHalfSize;
if (oldDir === DIRECTION.UP || oldDir === DIRECTION.DOWN) { if (oldDir === DIRECTION.UP || oldDir === DIRECTION.DOWN) {
// Was moving vertically → snap Y to nearest grid-cell center // Was moving vertically → snap Y to nearest grid-cell center
@@ -146,6 +149,8 @@ class Tank {
const moveAmount = this.speed * dt * 60; const moveAmount = this.speed * dt * 60;
const vec = DIR_VECTORS[dir]; const vec = DIR_VECTORS[dir];
const halfTile = TILE_SIZE / 2; const halfTile = TILE_SIZE / 2;
const colliderHS = this.colliderHalfSize;
const colliderS = this.colliderSize;
if (dir === DIRECTION.UP || dir === DIRECTION.DOWN) { if (dir === DIRECTION.UP || dir === DIRECTION.DOWN) {
// Moving vertically but blocked — try to slide horizontally into a gap // Moving vertically but blocked — try to slide horizontally into a gap
@@ -164,14 +169,14 @@ class Tank {
// Check whether moving in the desired direction would be clear at this aligned X // Check whether moving in the desired direction would be clear at this aligned X
const testX = alignedX; const testX = alignedX;
const testY = this.y + vec.dy * moveAmount; const testY = this.y + vec.dy * moveAmount;
const left = testX - this.halfSize; const left = testX - colliderHS;
const top = testY - this.halfSize; const top = testY - colliderHS;
if ( if (
left >= MAP_OFFSET_X && left >= MAP_OFFSET_X &&
top >= MAP_OFFSET_Y && top >= MAP_OFFSET_Y &&
left + this.size <= MAP_OFFSET_X + MAP_WIDTH && left + colliderS <= MAP_OFFSET_X + MAP_WIDTH &&
top + this.size <= MAP_OFFSET_Y + MAP_HEIGHT && top + colliderS <= MAP_OFFSET_Y + MAP_HEIGHT &&
!mapManager.rectCollidesWithTerrain(left, top, this.size, this.size) !mapManager.rectCollidesWithTerrain(left, top, colliderS, colliderS)
) { ) {
candidates.push({ alignedX, diffX: Math.abs(diffX) }); candidates.push({ alignedX, diffX: Math.abs(diffX) });
} }
@@ -186,7 +191,7 @@ class Tank {
const slideAmount = Math.min(Math.abs(diffX), moveAmount); const slideAmount = Math.min(Math.abs(diffX), moveAmount);
this.x += Math.sign(diffX) * slideAmount; this.x += Math.sign(diffX) * slideAmount;
// Clamp to map bounds after sliding // Clamp to map bounds after sliding
this.x = Math.max(MAP_OFFSET_X + this.halfSize, Math.min(this.x, MAP_OFFSET_X + MAP_WIDTH - this.halfSize)); this.x = Math.max(MAP_OFFSET_X + colliderHS, Math.min(this.x, MAP_OFFSET_X + MAP_WIDTH - colliderHS));
return false; return false;
} }
@@ -198,7 +203,7 @@ class Tank {
const slideAmount = Math.min(Math.abs(diffX), moveAmount); const slideAmount = Math.min(Math.abs(diffX), moveAmount);
this.x += Math.sign(diffX) * slideAmount; this.x += Math.sign(diffX) * slideAmount;
// Clamp to map bounds after sliding // Clamp to map bounds after sliding
this.x = Math.max(MAP_OFFSET_X + this.halfSize, Math.min(this.x, MAP_OFFSET_X + MAP_WIDTH - this.halfSize)); this.x = Math.max(MAP_OFFSET_X + colliderHS, Math.min(this.x, MAP_OFFSET_X + MAP_WIDTH - colliderHS));
} }
} else { } else {
// Moving horizontally but blocked — try to slide vertically into a gap // Moving horizontally but blocked — try to slide vertically into a gap
@@ -214,14 +219,14 @@ class Tank {
if (Math.abs(diffY) < TILE_SIZE * 0.55) { if (Math.abs(diffY) < TILE_SIZE * 0.55) {
const testX = this.x + vec.dx * moveAmount; const testX = this.x + vec.dx * moveAmount;
const testY = alignedY; const testY = alignedY;
const left = testX - this.halfSize; const left = testX - colliderHS;
const top = testY - this.halfSize; const top = testY - colliderHS;
if ( if (
left >= MAP_OFFSET_X && left >= MAP_OFFSET_X &&
top >= MAP_OFFSET_Y && top >= MAP_OFFSET_Y &&
left + this.size <= MAP_OFFSET_X + MAP_WIDTH && left + colliderS <= MAP_OFFSET_X + MAP_WIDTH &&
top + this.size <= MAP_OFFSET_Y + MAP_HEIGHT && top + colliderS <= MAP_OFFSET_Y + MAP_HEIGHT &&
!mapManager.rectCollidesWithTerrain(left, top, this.size, this.size) !mapManager.rectCollidesWithTerrain(left, top, colliderS, colliderS)
) { ) {
candidates.push({ alignedY, diffY: Math.abs(diffY) }); candidates.push({ alignedY, diffY: Math.abs(diffY) });
} }
@@ -235,7 +240,7 @@ class Tank {
const slideAmount = Math.min(Math.abs(diffY), moveAmount); const slideAmount = Math.min(Math.abs(diffY), moveAmount);
this.y += Math.sign(diffY) * slideAmount; this.y += Math.sign(diffY) * slideAmount;
// Clamp to map bounds after sliding // Clamp to map bounds after sliding
this.y = Math.max(MAP_OFFSET_Y + this.halfSize, Math.min(this.y, MAP_OFFSET_Y + MAP_HEIGHT - this.halfSize)); this.y = Math.max(MAP_OFFSET_Y + colliderHS, Math.min(this.y, MAP_OFFSET_Y + MAP_HEIGHT - colliderHS));
return false; return false;
} }
@@ -247,7 +252,7 @@ class Tank {
const slideAmount = Math.min(Math.abs(diffY), moveAmount); const slideAmount = Math.min(Math.abs(diffY), moveAmount);
this.y += Math.sign(diffY) * slideAmount; this.y += Math.sign(diffY) * slideAmount;
// Clamp to map bounds after sliding // Clamp to map bounds after sliding
this.y = Math.max(MAP_OFFSET_Y + this.halfSize, Math.min(this.y, MAP_OFFSET_Y + MAP_HEIGHT - this.halfSize)); this.y = Math.max(MAP_OFFSET_Y + colliderHS, Math.min(this.y, MAP_OFFSET_Y + MAP_HEIGHT - colliderHS));
} }
} }
@@ -370,10 +375,10 @@ class Tank {
*/ */
getBounds() { getBounds() {
return { return {
x: this.x - this.halfSize, x: this.x - this.colliderHalfSize,
y: this.y - this.halfSize, y: this.y - this.colliderHalfSize,
w: this.size, w: this.colliderSize,
h: this.size, h: this.colliderSize,
}; };
} }
+42 -5
View File
@@ -21,15 +21,24 @@ module.exports = {
// Menu Scene // Menu Scene
// ============================================================ // ============================================================
'menu.title': 'Tank Adventure', 'menu.title': 'Tank Adventure',
'menu.subtitle': 'TANK WAR', 'menu.subtitle': 'TANK WAR · Battle with Friends',
'menu.classic': 'Classic', 'menu.classic': 'Classic Mode',
'menu.endless': 'Endless', 'menu.classic.sub': 'Classic tank battle',
'menu.pvp': 'PVP', 'menu.endless': 'Endless Mode',
'menu.team3v3': '3v3 Battle', 'menu.endless.sub': 'Push your limits',
'menu.pvp': '1v1 Duel',
'menu.pvp.sub': 'Winner Takes All.',
'menu.team2v2': '2v2 Brawl',
'menu.team2v2.sub': 'Co-op strategy wins',
'menu.team3v3': '3v3 Team Battle',
'menu.team3v3.sub': 'Teamwork dominates the battlefield',
'menu.shop': 'Shop', 'menu.shop': 'Shop',
'menu.skin': 'Skins', 'menu.skin': 'Skins',
'menu.skin.sub': 'Many skins to choose from',
'menu.ranking': 'Ranking', 'menu.ranking': 'Ranking',
'menu.ranking.sub': 'Climb the leaderboard',
'menu.settings': 'Settings', 'menu.settings': 'Settings',
'menu.settings.sub': 'Customize your experience',
'menu.chat': 'Chat', 'menu.chat': 'Chat',
'menu.tapToAuth': 'Tap to authorize', 'menu.tapToAuth': 'Tap to authorize',
@@ -47,6 +56,33 @@ module.exports = {
'room.starting': 'Game starting...', 'room.starting': 'Game starting...',
'room.tapBack': 'Tap anywhere to go back', 'room.tapBack': 'Tap anywhere to go back',
// ============================================================
// Team 2v2 Room Scene
// ============================================================
'team2v2Room.title': '2v2 Brawl',
'team2v2Room.chooseMode': 'Choose how to play',
'team2v2Room.createTeam': '🎮 Create Team',
'team2v2Room.soloMatch': '⚡ Quick Match',
'team2v2Room.teamId': 'Team: {id}',
'team2v2Room.leader': 'Leader',
'team2v2Room.ready': '✓ Ready',
'team2v2Room.notReady': 'Not Ready',
'team2v2Room.emptySlot': 'Empty',
'team2v2Room.invite': '📨 Invite',
'team2v2Room.startMatch': '🔍 Start Match',
'team2v2Room.disband': 'Disband',
'team2v2Room.readyBtn': '✓ Ready',
'team2v2Room.cancelReady': 'Cancel Ready',
'team2v2Room.leaveTeam': 'Leave Team',
'team2v2Room.matching': 'Matching{dots}',
'team2v2Room.waitTime': 'Waited {seconds}s',
'team2v2Room.cancelMatch': 'Cancel Match',
'team2v2Room.matchFound': 'Match found!',
'team2v2Room.enterBattle': 'Entering battle...',
'team2v2Room.tapBack': 'Tap anywhere to go back',
'team2v2Room.shareTitle': 'Tank 2v2, join the brawl!',
'team2v2Room.joining': 'Joining room',
// ============================================================ // ============================================================
// Team Room Scene (3v3) // Team Room Scene (3v3)
// ============================================================ // ============================================================
@@ -295,6 +331,7 @@ module.exports = {
// Daily Gold // Daily Gold
// ============================================================ // ============================================================
'dailyGold.btn': '🪙 Get Gold', 'dailyGold.btn': '🪙 Get Gold',
'dailyGold.desc': 'Daily reward',
'dailyGold.remaining': '{remaining}/3', 'dailyGold.remaining': '{remaining}/3',
'dailyGold.exhausted': 'Come back tomorrow', 'dailyGold.exhausted': 'Come back tomorrow',
'dailyGold.reward': '+100 Gold!', 'dailyGold.reward': '+100 Gold!',
+41 -4
View File
@@ -21,22 +21,31 @@ module.exports = {
// Menu Scene // Menu Scene
// ============================================================ // ============================================================
'menu.title': '坦克探险', 'menu.title': '坦克探险',
'menu.subtitle': '经典坦克对战', 'menu.subtitle': '经典坦克对战 · 兄弟集结开黑',
'menu.classic': '经典模式', 'menu.classic': '经典模式',
'menu.classic.sub': '经典坦克对战',
'menu.endless': '无尽模式', 'menu.endless': '无尽模式',
'menu.pvp': '双人对战', 'menu.endless.sub': '挑战极限,突破自我',
'menu.team3v3': '3v3 对战', 'menu.pvp': '1v1 决斗',
'menu.pvp.sub': '单挑对决,谁与争锋',
'menu.team2v2': '2v2 激斗',
'menu.team2v2.sub': '双人协作,策略制胜',
'menu.team3v3': '3v3 团战',
'menu.team3v3.sub': '团队协作,称霸战场',
'menu.shop': '商店', 'menu.shop': '商店',
'menu.skin': '皮肤', 'menu.skin': '皮肤',
'menu.skin.sub': '多款皮肤任你选',
'menu.ranking': '排行榜', 'menu.ranking': '排行榜',
'menu.ranking.sub': '冲击榜单,赢取荣誉',
'menu.settings': '设置', 'menu.settings': '设置',
'menu.settings.sub': '个性设置,畅快体验',
'menu.chat': '聊天室', 'menu.chat': '聊天室',
'menu.tapToAuth': '点击授权头像', 'menu.tapToAuth': '点击授权头像',
// ============================================================ // ============================================================
// Room Scene (PVP) // Room Scene (PVP)
// ============================================================ // ============================================================
'room.title': '双人对战', 'room.title': '1v1决斗',
'room.connecting': '连接中{dots}', 'room.connecting': '连接中{dots}',
'room.roomCode': '房间号:', 'room.roomCode': '房间号:',
'room.waiting': '等待对手加入{dots}', 'room.waiting': '等待对手加入{dots}',
@@ -47,6 +56,33 @@ module.exports = {
'room.starting': '即将开始...', 'room.starting': '即将开始...',
'room.tapBack': '点击任意位置返回', 'room.tapBack': '点击任意位置返回',
// ============================================================
// Team 2v2 Room Scene
// ============================================================
'team2v2Room.title': '2v2 激斗',
'team2v2Room.chooseMode': '选择游戏方式',
'team2v2Room.createTeam': '🎮 组队开黑',
'team2v2Room.soloMatch': '⚡ 快速匹配',
'team2v2Room.teamId': '队伍:{id}',
'team2v2Room.leader': '队长',
'team2v2Room.ready': '✓ 已准备',
'team2v2Room.notReady': '未准备',
'team2v2Room.emptySlot': '空位',
'team2v2Room.invite': '📨 邀请好友',
'team2v2Room.startMatch': '🔍 开始匹配',
'team2v2Room.disband': '解散队伍',
'team2v2Room.readyBtn': '✓ 准备',
'team2v2Room.cancelReady': '取消准备',
'team2v2Room.leaveTeam': '退出队伍',
'team2v2Room.matching': '匹配中{dots}',
'team2v2Room.waitTime': '已等待 {seconds} 秒',
'team2v2Room.cancelMatch': '取消匹配',
'team2v2Room.matchFound': '对手已找到!',
'team2v2Room.enterBattle': '即将进入战斗...',
'team2v2Room.tapBack': '点击任意位置返回',
'team2v2Room.shareTitle': '坦克2v2,速来开黑!',
'team2v2Room.joining': '正在加入房间',
// ============================================================ // ============================================================
// Team Room Scene (3v3) // Team Room Scene (3v3)
// ============================================================ // ============================================================
@@ -295,6 +331,7 @@ module.exports = {
// Daily Gold // Daily Gold
// ============================================================ // ============================================================
'dailyGold.btn': '🪙 领金币', 'dailyGold.btn': '🪙 领金币',
'dailyGold.desc': '每日领取奖励',
'dailyGold.remaining': '{remaining}/3', 'dailyGold.remaining': '{remaining}/3',
'dailyGold.exhausted': '明日再来', 'dailyGold.exhausted': '明日再来',
'dailyGold.reward': '+100 金币!', 'dailyGold.reward': '+100 金币!',
+2 -2
View File
@@ -262,7 +262,7 @@ class CollisionManager {
* @private * @private
*/ */
_isPositionValid(tank, x, y) { _isPositionValid(tank, x, y) {
const hs = tank.halfSize; const hs = tank.colliderHalfSize;
const left = x - hs; const left = x - hs;
const top = y - hs; const top = y - hs;
const right = x + hs; const right = x + hs;
@@ -279,7 +279,7 @@ class CollisionManager {
} }
// Terrain collision check // Terrain collision check
if (this._map.rectCollidesWithTerrain(left, top, tank.size, tank.size)) { if (this._map.rectCollidesWithTerrain(left, top, tank.colliderSize, tank.colliderSize)) {
return false; return false;
} }
+33 -10
View File
@@ -46,6 +46,10 @@ class NetworkManager {
// Generate a unique player ID // Generate a unique player ID
this._playerId = this._generatePlayerId(); this._playerId = this._generatePlayerId();
// Connection mutex: queue of pending connect() callers
/** @type {Array<{resolve: Function, timeoutMs: number}>} */
this._connectQueue = [];
} }
/** /**
@@ -56,8 +60,15 @@ class NetworkManager {
*/ */
connect(serverUrl, timeoutMs = 10000) { connect(serverUrl, timeoutMs = 10000) {
return new Promise((resolve) => { return new Promise((resolve) => {
if (this._connected || this._connecting) { // If already connected, resolve immediately
resolve(this._connected); if (this._connected) {
resolve(true);
return;
}
// If another connect() is in progress, queue this one and wait
if (this._connecting) {
this._connectQueue.push({ resolve, timeoutMs });
return; return;
} }
@@ -85,6 +96,13 @@ class NetworkManager {
console.warn('[NetworkManager] connect() failed:', reason || 'unknown'); console.warn('[NetworkManager] connect() failed:', reason || 'unknown');
} }
resolve(ok); resolve(ok);
// Resolve all queued connect() calls with the same result
const queue = this._connectQueue;
this._connectQueue = [];
for (const pending of queue) {
pending.resolve(ok);
}
}; };
// Connection timeout guard (e.g. DNS/TLS hang on cellular). // Connection timeout guard (e.g. DNS/TLS hang on cellular).
@@ -96,7 +114,6 @@ class NetworkManager {
this._ws = wx.connectSocket({ this._ws = wx.connectSocket({
url: serverUrl, url: serverUrl,
header: { 'content-type': 'application/json' }, header: { 'content-type': 'application/json' },
// Surface wx.connectSocket API-level failures (invalid url / domain not whitelisted / etc.)
success: (res) => { console.log('[NetworkManager] wx.connectSocket invoked:', res && res.errMsg); }, success: (res) => { console.log('[NetworkManager] wx.connectSocket invoked:', res && res.errMsg); },
fail: (err) => { fail: (err) => {
console.error('[NetworkManager] wx.connectSocket API failed:', JSON.stringify(err)); console.error('[NetworkManager] wx.connectSocket API failed:', JSON.stringify(err));
@@ -119,16 +136,13 @@ class NetworkManager {
}); });
this._ws.onError((err) => { this._ws.onError((err) => {
// Log as much context as possible; wx error objects vary across platforms.
console.error('[NetworkManager] WebSocket error:', console.error('[NetworkManager] WebSocket error:',
(err && (err.errMsg || err.message)) || err, (err && (err.errMsg || err.message)) || err,
'url=', serverUrl); 'url=', serverUrl);
this._emit('error', err); this._emit('error', err);
// If the error arrives before we ever got onOpen, treat it as a connect failure.
if (!this._connected) { if (!this._connected) {
finish(false, `onError before open: ${err && (err.errMsg || err.message)}`); finish(false, `onError before open: ${err && (err.errMsg || err.message)}`);
} else { } else {
// Runtime error on an established connection — let onClose handle reconnection.
this._connecting = false; this._connecting = false;
} }
}); });
@@ -144,13 +158,11 @@ class NetworkManager {
this._stopHeartbeat(); this._stopHeartbeat();
this._emit('disconnected', { code, reason }); this._emit('disconnected', { code, reason });
// If onClose arrives before onOpen, this is a connect failure.
if (!wasConnected) { if (!wasConnected) {
finish(false, `onClose before open: code=${code} reason=${reason}`); finish(false, `onClose before open: code=${code} reason=${reason}`);
return; return;
} }
// Auto-reconnect only for drops on an already-established connection.
if (this._shouldReconnect && this._reconnectAttempts < this._maxReconnectAttempts) { if (this._shouldReconnect && this._reconnectAttempts < this._maxReconnectAttempts) {
this._attemptReconnect(); this._attemptReconnect();
} }
@@ -183,6 +195,13 @@ class NetworkManager {
this._connecting = false; this._connecting = false;
this._roomId = null; this._roomId = null;
this._playerSlot = 0; this._playerSlot = 0;
// Clear connect queue
const queue = this._connectQueue;
this._connectQueue = [];
for (const pending of queue) {
pending.resolve(false);
}
} }
/** /**
@@ -272,10 +291,12 @@ class NetworkManager {
/** /**
* Create a new team for 3v3 mode. * Create a new team for 3v3 mode.
* @param {string} [battleMode='3v3'] - Battle mode ('1v1', '2v2', '3v3').
*/ */
createTeam() { createTeam(battleMode = '3v3') {
this.send(NET_MSG.CREATE_TEAM, { this.send(NET_MSG.CREATE_TEAM, {
playerId: this._playerId, playerId: this._playerId,
battleMode,
}); });
} }
@@ -350,10 +371,12 @@ class NetworkManager {
/** /**
* Start solo matchmaking for 3v3. * Start solo matchmaking for 3v3.
* @param {string} [battleMode='3v3'] - Battle mode ('1v1', '2v2', '3v3').
*/ */
soloMatch() { soloMatch(battleMode = '3v3') {
this.send(NET_MSG.SOLO_MATCH, { this.send(NET_MSG.SOLO_MATCH, {
playerId: this._playerId, playerId: this._playerId,
battleMode,
}); });
} }
+142 -2
View File
@@ -11,6 +11,8 @@ class ShareManager {
imageUrl: '', imageUrl: '',
query: '', query: '',
}; };
// Cached temp file path from last canvas capture
this._cachedImageUrl = '';
// Register share menu and callback ONCE at startup. // Register share menu and callback ONCE at startup.
// The callback reads this._shareContent dynamically so it always // The callback reads this._shareContent dynamically so it always
@@ -28,7 +30,7 @@ class ShareManager {
console.log('[ShareManager] onShareAppMessage callback, query:', this._shareContent.query); console.log('[ShareManager] onShareAppMessage callback, query:', this._shareContent.query);
return { return {
title: this._shareContent.title || '坦克大战 - 一起来战斗吧!', title: this._shareContent.title || '坦克大战 - 一起来战斗吧!',
imageUrl: this._shareContent.imageUrl || '', imageUrl: this._shareContent.imageUrl || this._cachedImageUrl || '',
query: this._shareContent.query || '', query: this._shareContent.query || '',
}; };
}); });
@@ -39,6 +41,137 @@ class ShareManager {
} }
} }
/**
* Generate a share image from the current game canvas.
* Outputs a 5:4 portrait-ratio image so WeChat's share card shows
* the full screen without cropping.
*
* Strategy:
* 1. Try offscreen canvas redraw (ideal — contain-fit into 5:4)
* 2. Fallback: direct capture with portrait dest dimensions
*
* @param {function} [callback] - Called with (tempFilePath) on success.
*/
generateShareImage(callback) {
var self = this;
try {
const srcCanvas = GameGlobal && GameGlobal.canvas;
if (!srcCanvas || typeof wx === 'undefined' || !wx.canvasToTempFilePath) {
if (callback) callback();
return;
}
const DPR = GameGlobal.DEVICE_PIXEL_RATIO || 1;
const SHARE_W = 500;
const SHARE_H = 625; // 5:4 portrait for WeChat share card
const srcW = srcCanvas.width / DPR;
const srcH = srcCanvas.height / DPR;
// --- Try offscreen canvas approach first ---
var offCanvas = null;
try {
offCanvas = wx.createCanvas && wx.createCanvas();
} catch (e2) {
offCanvas = null;
}
if (offCanvas) {
try {
offCanvas.width = SHARE_W;
offCanvas.height = SHARE_H;
const offCtx = offCanvas.getContext('2d');
if (offCtx) {
// Dark background
offCtx.fillStyle = '#0a0e1a';
offCtx.fillRect(0, 0, SHARE_W, SHARE_H);
// Contain-fit source into portrait frame
const srcRatio = srcW / srcH;
const dstRatio = SHARE_W / SHARE_H;
var drawW, drawH, dx, dy;
if (srcRatio > dstRatio) {
drawW = SHARE_W;
drawH = SHARE_W / srcRatio;
dx = 0;
dy = (SHARE_H - drawH) / 2;
} else {
drawH = SHARE_H;
drawW = SHARE_H * srcRatio;
dx = (SHARE_W - drawW) / 2;
dy = 0;
}
offCtx.drawImage(srcCanvas, dx, dy, drawW, drawH);
// Export offscreen canvas
wx.canvasToTempFilePath({
canvas: offCanvas,
width: SHARE_W,
height: SHARE_H,
destWidth: SHARE_W,
destHeight: SHARE_H,
fileType: 'png',
quality: 0.92,
success: function(res) {
if (res.tempFilePath) {
console.log('[ShareManager] Share image (offscreen):', res.tempFilePath);
self._cachedImageUrl = res.tempFilePath;
if (callback) callback(res.tempFilePath);
} else {
self._fallbackDirectCapture(srcCanvas, srcW, srcH, SHARE_W, SHARE_H, callback);
}
},
fail: function() {
self._fallbackDirectCapture(srcCanvas, srcW, srcH, SHARE_W, SHARE_H, callback);
},
});
return; // done via offscreen path
}
} catch (e3) {
console.warn('[ShareManager] Offscreen canvas draw failed:', e3);
}
}
// --- Fallback: direct canvas capture with portrait output ---
this._fallbackDirectCapture(srcCanvas, srcW, srcH, SHARE_W, SHARE_H, callback);
} catch (e) {
console.warn('[ShareManager] generateShareImage error:', e);
if (callback) callback();
}
}
/**
* Fallback: export main canvas directly with portrait dest dimensions.
*/
_fallbackDirectCapture(canvas, srcW, srcH, outW, outH, callback) {
try {
wx.canvasToTempFilePath({
canvas: canvas,
width: srcW,
height: srcH,
destWidth: outW,
destHeight: outH,
fileType: 'png',
quality: 0.92,
success: function(res) {
if (res.tempFilePath) {
console.log('[ShareManager] Share image (direct):', res.tempFilePath);
this._cachedImageUrl = res.tempFilePath;
}
if (callback) callback(res && res.tempFilePath ? res.tempFilePath : undefined);
}.bind(this),
fail: function(err) {
console.warn('[ShareManager] Direct canvasToTempFilePath failed:', err);
if (callback) callback();
},
});
} catch (e) {
console.warn('[ShareManager] _fallbackDirectCapture error:', e);
if (callback) callback();
}
}
/** /**
* Update open data for friend ranking. * Update open data for friend ranking.
* @param {number} score * @param {number} score
@@ -90,6 +223,10 @@ class ShareManager {
setShareContent(opts) { setShareContent(opts) {
this._shareContent = opts || {}; this._shareContent = opts || {};
console.log('[ShareManager] Share content updated:', JSON.stringify(this._shareContent)); console.log('[ShareManager] Share content updated:', JSON.stringify(this._shareContent));
// Auto-generate canvas screenshot as share image if none provided
if (!this._shareContent.imageUrl && !this._cachedImageUrl) {
this.generateShareImage();
}
// Re-register callback to ensure WeChat picks up the new content // Re-register callback to ensure WeChat picks up the new content
this._refreshShareCallback(); this._refreshShareCallback();
} }
@@ -106,6 +243,9 @@ class ShareManager {
// Update passive share callback (right-corner ··· menu fallback) // Update passive share callback (right-corner ··· menu fallback)
this.setShareContent(data); this.setShareContent(data);
// Ensure we have a share image — generate synchronously-style via cache
const imageUrl = data.imageUrl || this._cachedImageUrl || '';
// Directly invoke wx.shareAppMessage() to open the friend-picker panel. // Directly invoke wx.shareAppMessage() to open the friend-picker panel.
// This is permitted because triggerShare is called from a touchstart handler. // This is permitted because triggerShare is called from a touchstart handler.
try { try {
@@ -113,7 +253,7 @@ class ShareManager {
console.log('[ShareManager] Calling wx.shareAppMessage with query:', data.query); console.log('[ShareManager] Calling wx.shareAppMessage with query:', data.query);
wx.shareAppMessage({ wx.shareAppMessage({
title: data.title || '', title: data.title || '',
imageUrl: data.imageUrl || '', imageUrl: imageUrl,
query: data.query || '', query: data.query || '',
}); });
} }
+4 -1
View File
@@ -200,7 +200,10 @@ const BuffSelectScene = {
const GameScene = require('./GameScene'); const GameScene = require('./GameScene');
sm.register(SCENE.GAME, GameScene); sm.register(SCENE.GAME, GameScene);
} }
sm.switchTo(SCENE.GAME, this._gameParams); // ★ DEBUG: Force level 20 (Boss Battle) for quick verification of Boss tank gap-fix
// const params = Object.assign({}, this._gameParams, { level: 20 });
const params = Object.assign({}, this._gameParams);
sm.switchTo(SCENE.GAME, params);
}, },
handleTouch(eventType, e) { handleTouch(eventType, e) {
+780 -123
View File
File diff suppressed because it is too large Load Diff
+7 -7
View File
@@ -38,7 +38,7 @@ const ROOM_STATE = {
// Room Scene // Room Scene
// ============================================================ // ============================================================
const RoomScene = { const RoomScene = {
_state: ROOM_STATE.IDLE, _state: ROOM_STATE.CREATING,
_roomCode: '', _roomCode: '',
_errorMsg: '', _errorMsg: '',
_countdown: 3, _countdown: 3,
@@ -152,7 +152,7 @@ const RoomScene = {
})); }));
unsubs.push(nm.on('disconnected', () => { unsubs.push(nm.on('disconnected', () => {
if (this._state !== ROOM_STATE.IDLE) { if (this._state !== ROOM_STATE.CREATING) {
this._errorMsg = t('common.disconnected'); this._errorMsg = t('common.disconnected');
this._state = ROOM_STATE.ERROR; this._state = ROOM_STATE.ERROR;
} }
@@ -198,7 +198,7 @@ const RoomScene = {
if (shareManager) { if (shareManager) {
shareManager.setShareContent({ shareManager.setShareContent({
title: t('room.shareTitle'), title: t('room.shareTitle'),
imageUrl: '', imageUrl: 'js/ui/images/1v1.png',
query: `roomId=${this._roomCode}`, query: `roomId=${this._roomCode}`,
}); });
} }
@@ -214,7 +214,7 @@ const RoomScene = {
const shareData = { const shareData = {
title: t('room.shareTitle'), title: t('room.shareTitle'),
imageUrl: '', imageUrl: 'js/ui/images/1v1.png',
query: `roomId=${this._roomCode}`, query: `roomId=${this._roomCode}`,
}; };
@@ -240,7 +240,7 @@ const RoomScene = {
/** /**
* One-step invite: auto-create room then immediately share the invite card. * One-step invite: auto-create room then immediately share the invite card.
* This is the primary action on the IDLE screen — user clicks "Invite Friend" * This is the primary action — automatically creates a room and triggers invite share
* and we handle everything in one go. * and we handle everything in one go.
* MUST be called within a touch event for WeChat policy compliance. * MUST be called within a touch event for WeChat policy compliance.
* @private * @private
@@ -280,7 +280,7 @@ const RoomScene = {
const shareData = { const shareData = {
title: t('room.shareTitle'), title: t('room.shareTitle'),
imageUrl: '', imageUrl: 'js/ui/images/1v1.png',
query: `roomId=${roomId}`, query: `roomId=${roomId}`,
}; };
@@ -505,7 +505,7 @@ const RoomScene = {
switch (this._state) { switch (this._state) {
case ROOM_STATE.ERROR: case ROOM_STATE.ERROR:
this._state = ROOM_STATE.IDLE; this._state = ROOM_STATE.CREATING;
this._errorMsg = ''; this._errorMsg = '';
break; break;
+16
View File
@@ -0,0 +1,16 @@
/**
* Team2v2RoomScene.js
* 2v2 Brawl team room — delegates to the shared TeamRoomSceneFactory.
*/
const { createTeamRoomScene } = require('./TeamRoomSceneFactory');
const Team2v2RoomScene = createTeamRoomScene({
teamSize: 2,
battleMode: '2v2',
i18nPrefix: 'team2v2Room',
logTag: 'Team2v2Room',
shareImageUrl: 'js/ui/images/2v2.png',
});
module.exports = Team2v2RoomScene;
+6 -6
View File
@@ -1039,15 +1039,15 @@ const TeamGameScene = {
// Validate pushed positions against terrain; revert if stuck in wall // Validate pushed positions against terrain; revert if stuck in wall
if (this._mapManager) { if (this._mapManager) {
const leftA = tankA.x - tankA.halfSize; const leftA = tankA.x - tankA.colliderHalfSize;
const topA = tankA.y - tankA.halfSize; const topA = tankA.y - tankA.colliderHalfSize;
if (this._mapManager.rectCollidesWithTerrain(leftA, topA, tankA.size, tankA.size)) { if (this._mapManager.rectCollidesWithTerrain(leftA, topA, tankA.colliderSize, tankA.colliderSize)) {
tankA.x = origAX; tankA.x = origAX;
tankA.y = origAY; tankA.y = origAY;
} }
const leftB = tankB.x - tankB.halfSize; const leftB = tankB.x - tankB.colliderHalfSize;
const topB = tankB.y - tankB.halfSize; const topB = tankB.y - tankB.colliderHalfSize;
if (this._mapManager.rectCollidesWithTerrain(leftB, topB, tankB.size, tankB.size)) { if (this._mapManager.rectCollidesWithTerrain(leftB, topB, tankB.colliderSize, tankB.colliderSize)) {
tankB.x = origBX; tankB.x = origBX;
tankB.y = origBY; tankB.y = origBY;
} }
File diff suppressed because it is too large Load Diff
+947
View File
@@ -0,0 +1,947 @@
/**
* TeamRoomSceneFactory.js
* Factory function that creates a team room scene object.
* Shared by 2v2 and 3v3 team rooms eliminates code duplication.
*
* Usage:
* const Team2v2RoomScene = createTeamRoomScene({
* teamSize: 2,
* battleMode: '2v2',
* i18nPrefix: 'team2v2Room',
* logTag: 'Team2v2Room',
* });
*/
const {
SCREEN_WIDTH,
SCREEN_HEIGHT,
COLORS,
SCENE,
NET_MSG,
SERVER_URL,
} = require('../base/GameGlobal');
const { t } = require('../i18n/I18n');
// ============================================================
// Layout Constants (shared across all team room variants)
// ============================================================
const BTN_WIDTH = Math.min(SCREEN_WIDTH * 0.35, 180);
const BTN_HEIGHT = Math.min(36, SCREEN_HEIGHT * 0.07);
const BTN_GAP = 10;
const CENTER_X = SCREEN_WIDTH / 2;
const SLOT_WIDTH = Math.min(SCREEN_WIDTH * 0.15, 80);
const SLOT_HEIGHT = Math.min(SCREEN_HEIGHT * 0.18, 90);
const SLOT_GAP = 8;
// ============================================================
// Team Room States (shared)
// ============================================================
const TEAM_STATE = {
MODE_SELECT: 'mode_select',
JOINING: 'joining',
FORMING: 'forming',
MATCHING: 'matching',
COUNTDOWN: 'countdown',
ERROR: 'error',
};
// ============================================================
// i18n helper
// ============================================================
function tp(prefix, key, params) {
const fullKey = `${prefix}.${key}`;
return params ? t(fullKey, params) : t(fullKey);
}
// ============================================================
// Factory
// ============================================================
function createTeamRoomScene(config) {
const {
teamSize,
battleMode,
i18nPrefix,
logTag,
shareImageUrl = '',
} = config;
return {
_state: TEAM_STATE.MODE_SELECT,
_teamData: null,
_errorMsg: '',
_animTimer: 0,
_matchTimer: 0,
_countdown: 3,
_countdownTimer: 0,
_networkManager: null,
_unsubscribers: [],
_isLeader: false,
_myPlayerId: null,
_serverUrl: SERVER_URL,
// Button rects
_createTeamBtnRect: null,
_soloMatchBtnRect: null,
_backBtnRect: null,
_inviteBtnRect: null,
_matchBtnRect: null,
_readyBtnRect: null,
_disbandBtnRect: null,
_leaveBtnRect: null,
_cancelMatchBtnRect: null,
_slotRects: [],
_kickBtnRects: [],
_avatarImages: {},
// ---- Lifecycle ----
enter(params) {
this._state = TEAM_STATE.MODE_SELECT;
this._teamData = null;
this._errorMsg = '';
this._animTimer = 0;
this._matchTimer = 0;
this._countdown = 3;
this._countdownTimer = 0;
this._networkManager = GameGlobal.networkManager;
this._myPlayerId = this._networkManager ? this._networkManager.playerId : 'player_' + Date.now();
this._isLeader = false;
this._avatarImages = {};
this._buildLayout();
this._setupNetworkEvents();
this._setupProfileListener();
if (params && params.teamId) {
this._autoJoinTeam(params.teamId);
}
},
exit() {
this._cleanupNetworkEvents();
this._cleanupProfileListener();
const shareManager = GameGlobal.shareManager;
if (shareManager) {
shareManager.resetShareContent();
}
},
update(dt) {
this._animTimer += dt;
if (this._state === TEAM_STATE.MATCHING) {
this._matchTimer += dt;
}
if (this._state === TEAM_STATE.COUNTDOWN) {
this._countdownTimer += dt;
if (this._countdownTimer >= 1) {
this._countdownTimer -= 1;
this._countdown--;
}
}
},
// ---- Layout ----
_buildLayout() {
const modeY = SCREEN_HEIGHT * 0.4;
this._createTeamBtnRect = {
x: CENTER_X - BTN_WIDTH / 2,
y: modeY,
w: BTN_WIDTH,
h: BTN_HEIGHT,
};
this._soloMatchBtnRect = {
x: CENTER_X - BTN_WIDTH / 2,
y: modeY + BTN_HEIGHT + BTN_GAP,
w: BTN_WIDTH,
h: BTN_HEIGHT,
};
this._backBtnRect = { x: 10, y: 10, w: 60, h: 30 };
const totalSlotsWidth = teamSize * SLOT_WIDTH + (teamSize - 1) * SLOT_GAP;
const slotsStartX = CENTER_X - totalSlotsWidth / 2;
const slotsY = SCREEN_HEIGHT * 0.25;
this._slotRects = [];
this._kickBtnRects = [];
for (let i = 0; i < teamSize; i++) {
const x = slotsStartX + i * (SLOT_WIDTH + SLOT_GAP);
this._slotRects.push({ x, y: slotsY, w: SLOT_WIDTH, h: SLOT_HEIGHT });
this._kickBtnRects.push({ x: x + SLOT_WIDTH - 18, y: slotsY + 2, w: 16, h: 16 });
}
const actionY = SCREEN_HEIGHT * 0.58;
const smallBtnW = BTN_WIDTH * 0.8;
this._inviteBtnRect = {
x: CENTER_X - smallBtnW - BTN_GAP / 2,
y: actionY,
w: smallBtnW,
h: BTN_HEIGHT,
};
this._matchBtnRect = {
x: CENTER_X + BTN_GAP / 2,
y: actionY,
w: smallBtnW,
h: BTN_HEIGHT,
};
this._readyBtnRect = {
x: CENTER_X - smallBtnW / 2,
y: actionY,
w: smallBtnW,
h: BTN_HEIGHT,
};
this._disbandBtnRect = {
x: CENTER_X - smallBtnW - BTN_GAP / 2,
y: actionY + BTN_HEIGHT + BTN_GAP,
w: smallBtnW,
h: BTN_HEIGHT,
};
this._leaveBtnRect = {
x: CENTER_X - smallBtnW / 2,
y: actionY + BTN_HEIGHT + BTN_GAP,
w: smallBtnW,
h: BTN_HEIGHT,
};
this._cancelMatchBtnRect = {
x: CENTER_X - BTN_WIDTH / 2,
y: SCREEN_HEIGHT * 0.7,
w: BTN_WIDTH,
h: BTN_HEIGHT,
};
},
// ---- Share ----
_updateShareContent() {
if (!this._teamData || !this._teamData.teamId) return;
const shareManager = GameGlobal.shareManager;
if (shareManager) {
shareManager.setShareContent({
title: tp(i18nPrefix, 'shareTitle'),
imageUrl: shareImageUrl,
query: `teamId=${this._teamData.teamId}&mode=${battleMode}`,
});
}
},
// ---- Network Events ----
_setupNetworkEvents() {
this._cleanupNetworkEvents();
const nm = this._networkManager;
if (!nm) return;
const unsubs = [];
unsubs.push(nm.on(NET_MSG.TEAM_STATE, (data) => {
if (data.teamA) {
for (const m of data.teamA) {
if (m.avatarUrl && this._avatarImages[m.playerId] === null) {
delete this._avatarImages[m.playerId];
}
}
}
if (data.teamB) {
for (const m of data.teamB) {
if (m.avatarUrl && this._avatarImages[m.playerId] === null) {
delete this._avatarImages[m.playerId];
}
}
}
this._teamData = data;
this._isLeader = data.leaderId === this._myPlayerId;
if (data.state === 'forming') {
this._state = TEAM_STATE.FORMING;
} else if (data.state === 'matching') {
this._state = TEAM_STATE.MATCHING;
}
this._updateShareContent();
}));
unsubs.push(nm.on(NET_MSG.TEAM_DISBAND, (data) => {
this._teamData = null;
this._state = TEAM_STATE.MODE_SELECT;
if (data.reason === 'kicked') {
this._errorMsg = t('common.kicked');
this._state = TEAM_STATE.ERROR;
}
}));
unsubs.push(nm.on(NET_MSG.MATCH_FOUND, () => {
this._state = TEAM_STATE.COUNTDOWN;
this._countdown = 3;
this._countdownTimer = 0;
}));
unsubs.push(nm.on(NET_MSG.TEAM_GAME_START, (data) => {
this._startTeamGame(data);
}));
unsubs.push(nm.on(NET_MSG.ROOM_ERROR, (data) => {
this._errorMsg = data.message || 'Unknown error';
if (this._state !== TEAM_STATE.COUNTDOWN) {
this._state = TEAM_STATE.ERROR;
}
}));
unsubs.push(nm.on('error', () => {
this._errorMsg = t('common.connectFailed');
this._state = TEAM_STATE.ERROR;
}));
unsubs.push(nm.on('disconnected', () => {
if (this._state !== TEAM_STATE.MODE_SELECT) {
this._errorMsg = t('common.disconnected');
this._state = TEAM_STATE.ERROR;
}
}));
this._unsubscribers = unsubs;
},
_cleanupNetworkEvents() {
for (const unsub of this._unsubscribers) {
if (typeof unsub === 'function') unsub();
}
this._unsubscribers = [];
},
// ---- Profile Listener ----
_setupProfileListener() {
this._cleanupProfileListener();
const bus = (typeof GameGlobal !== 'undefined') ? GameGlobal.eventBus : null;
if (!bus || typeof bus.on !== 'function') return;
this._profileUnsub = bus.on('profile:updated', () => {
if (this._teamData && this._networkManager && this._networkManager.connected) {
this._networkManager.send(NET_MSG.PING);
}
if (this._myPlayerId && this._avatarImages[this._myPlayerId] !== undefined) {
delete this._avatarImages[this._myPlayerId];
}
});
},
_cleanupProfileListener() {
if (this._profileUnsub) {
this._profileUnsub();
this._profileUnsub = null;
}
},
// ---- Game Start ----
_startTeamGame(data) {
const sm = GameGlobal.sceneManager;
if (!sm._scenes.has(SCENE.TEAM_GAME)) {
const TeamGameScene = require('./TeamGameScene');
sm.register(SCENE.TEAM_GAME, TeamGameScene);
}
sm.switchTo(SCENE.TEAM_GAME, {
teamId: this._teamData ? this._teamData.teamId : null,
mapId: data.mapId,
teamA: data.teamA,
teamB: data.teamB,
teamABaseHp: data.teamABaseHp,
teamBBaseHp: data.teamBBaseHp,
battleMode: data.battleMode || battleMode,
roomId: data.roomId || '',
myPlayerId: this._myPlayerId,
});
},
// ---- Render ----
render(ctx) {
ctx.fillStyle = COLORS.MENU_BG;
ctx.fillRect(0, 0, SCREEN_WIDTH, SCREEN_HEIGHT);
const gradient = ctx.createLinearGradient(0, 0, SCREEN_WIDTH, 0);
gradient.addColorStop(0, '#0f3460');
gradient.addColorStop(0.5, '#e94560');
gradient.addColorStop(1, '#0f3460');
ctx.fillStyle = gradient;
ctx.fillRect(0, 0, SCREEN_WIDTH, 4);
this._drawButton(ctx, this._backBtnRect, t('common.back'), false, 12);
ctx.fillStyle = COLORS.MENU_TITLE;
ctx.font = 'bold 22px Arial';
ctx.textAlign = 'center';
ctx.textBaseline = 'middle';
ctx.fillText(tp(i18nPrefix, 'title'), CENTER_X, SCREEN_HEIGHT * 0.08);
switch (this._state) {
case TEAM_STATE.MODE_SELECT:
this._renderModeSelect(ctx);
break;
case TEAM_STATE.JOINING:
this._renderJoining(ctx);
break;
case TEAM_STATE.FORMING:
this._renderForming(ctx);
break;
case TEAM_STATE.MATCHING:
this._renderMatching(ctx);
break;
case TEAM_STATE.COUNTDOWN:
this._renderCountdown(ctx);
break;
case TEAM_STATE.ERROR:
this._renderError(ctx);
break;
}
},
_renderJoining(ctx) {
const dots = '.'.repeat(Math.floor(this._animTimer * 3) % 4);
ctx.fillStyle = '#FFFFFF';
ctx.font = '18px Arial';
ctx.textAlign = 'center';
ctx.textBaseline = 'middle';
ctx.fillText(tp(i18nPrefix, 'joining') + dots, CENTER_X, SCREEN_HEIGHT * 0.45);
},
_renderModeSelect(ctx) {
ctx.fillStyle = '#AAAAAA';
ctx.font = '14px Arial';
ctx.textAlign = 'center';
ctx.fillText(tp(i18nPrefix, 'chooseMode'), CENTER_X, SCREEN_HEIGHT * 0.28);
this._drawButton(ctx, this._createTeamBtnRect, tp(i18nPrefix, 'createTeam'));
this._drawButton(ctx, this._soloMatchBtnRect, tp(i18nPrefix, 'soloMatch'));
},
_renderForming(ctx) {
if (!this._teamData) return;
ctx.fillStyle = '#AAAAAA';
ctx.font = '12px Arial';
ctx.textAlign = 'center';
ctx.fillText(tp(i18nPrefix, 'teamId', { id: this._teamData.teamId }), CENTER_X, SCREEN_HEIGHT * 0.16);
const members = this._teamData.teamA || [];
for (let i = 0; i < teamSize; i++) {
const rect = this._slotRects[i];
const member = members[i];
ctx.fillStyle = member ? '#1e3a5f' : '#0d1b2a';
ctx.strokeStyle = member ? (member.isLeader ? '#FFD700' : '#0f3460') : '#333333';
ctx.lineWidth = member && member.isLeader ? 3 : 1;
this._drawRoundRect(ctx, rect.x, rect.y, rect.w, rect.h, 8);
ctx.fill();
ctx.stroke();
if (member) {
const avatarR = Math.min(rect.w, rect.h) * 0.32;
const avatarCX = rect.x + rect.w / 2;
const avatarCY = rect.y + rect.h * 0.42;
this._drawAvatar(ctx, member, avatarCX, avatarCY, avatarR);
if (!member.isLeader) {
ctx.fillStyle = member.ready ? '#00FF00' : '#FF6347';
ctx.font = 'bold 10px Arial';
ctx.textAlign = 'center';
ctx.textBaseline = 'middle';
ctx.fillText(
member.ready ? tp(i18nPrefix, 'ready') : tp(i18nPrefix, 'notReady'),
avatarCX, rect.y + rect.h * 0.88,
);
}
if (this._isLeader && !member.isLeader && member.playerId !== this._myPlayerId) {
const kickRect = this._kickBtnRects[i];
ctx.fillStyle = '#FF4444';
ctx.font = 'bold 12px Arial';
ctx.fillText('✕', kickRect.x + kickRect.w / 2, kickRect.y + kickRect.h / 2);
}
} else {
ctx.fillStyle = '#555555';
ctx.font = '24px Arial';
ctx.textAlign = 'center';
ctx.textBaseline = 'middle';
ctx.fillText('+', rect.x + rect.w / 2, rect.y + rect.h / 2);
ctx.fillStyle = '#666666';
ctx.font = '10px Arial';
ctx.fillText(tp(i18nPrefix, 'emptySlot'), rect.x + rect.w / 2, rect.y + rect.h * 0.78);
}
}
if (this._isLeader) {
this._drawButton(ctx, this._inviteBtnRect, tp(i18nPrefix, 'invite'));
const allReady = members.length > 0 && members.every(m => m.ready || m.isLeader);
this._drawButton(ctx, this._matchBtnRect, tp(i18nPrefix, 'startMatch'), false, 14, allReady ? null : '#555555');
this._drawButton(ctx, this._disbandBtnRect, tp(i18nPrefix, 'disband'), false, 12, '#8B0000');
} else {
const myMember = members.find(m => m.playerId === this._myPlayerId);
const readyLabel = myMember && myMember.ready ? tp(i18nPrefix, 'cancelReady') : tp(i18nPrefix, 'readyBtn');
this._drawButton(ctx, this._readyBtnRect, readyLabel);
this._drawButton(ctx, this._leaveBtnRect, tp(i18nPrefix, 'leaveTeam'), false, 12, '#8B0000');
}
},
_renderMatching(ctx) {
if (!this._teamData) return;
const members = this._teamData.teamA || [];
for (let i = 0; i < teamSize; i++) {
const rect = this._slotRects[i];
const member = members[i];
ctx.fillStyle = member ? '#1e3a5f' : '#0d1b2a';
ctx.strokeStyle = member ? (member.isLeader ? '#FFD700' : '#0f3460') : '#333333';
ctx.lineWidth = member && member.isLeader ? 3 : 1;
this._drawRoundRect(ctx, rect.x, rect.y, rect.w, rect.h, 8);
ctx.fill();
ctx.stroke();
if (member) {
const avatarR = Math.min(rect.w, rect.h) * 0.32;
const avatarCX = rect.x + rect.w / 2;
const avatarCY = rect.y + rect.h * 0.42;
this._drawAvatar(ctx, member, avatarCX, avatarCY, avatarR);
if (!member.isLeader) {
ctx.fillStyle = member.ready ? '#00FF00' : '#FF6347';
ctx.font = 'bold 10px Arial';
ctx.textAlign = 'center';
ctx.textBaseline = 'middle';
ctx.fillText(
member.ready ? tp(i18nPrefix, 'ready') : tp(i18nPrefix, 'notReady'),
avatarCX, rect.y + rect.h * 0.88,
);
}
} else {
ctx.fillStyle = '#555555';
ctx.font = '24px Arial';
ctx.textAlign = 'center';
ctx.textBaseline = 'middle';
ctx.fillText('+', rect.x + rect.w / 2, rect.y + rect.h / 2);
}
}
const elapsed = Math.floor(this._matchTimer);
const dots = '.'.repeat(Math.floor(this._animTimer * 3) % 4);
ctx.fillStyle = '#FFFFFF';
ctx.font = '18px Arial';
ctx.textAlign = 'center';
ctx.fillText(tp(i18nPrefix, 'matching', { dots }), CENTER_X, SCREEN_HEIGHT * 0.55);
ctx.fillStyle = '#AAAAAA';
ctx.font = '14px Arial';
ctx.fillText(tp(i18nPrefix, 'waitTime', { seconds: elapsed }), CENTER_X, SCREEN_HEIGHT * 0.62);
if (this._isLeader) {
this._drawButton(ctx, this._cancelMatchBtnRect, tp(i18nPrefix, 'cancelMatch'));
}
},
_renderCountdown(ctx) {
ctx.fillStyle = '#00FF00';
ctx.font = '16px Arial';
ctx.textAlign = 'center';
ctx.fillText(tp(i18nPrefix, 'matchFound'), CENTER_X, SCREEN_HEIGHT * 0.35);
ctx.fillStyle = COLORS.MENU_TITLE;
ctx.font = 'bold 64px Arial';
ctx.fillText(String(Math.max(1, this._countdown)), CENTER_X, SCREEN_HEIGHT * 0.52);
ctx.fillStyle = '#AAAAAA';
ctx.font = '14px Arial';
ctx.fillText(tp(i18nPrefix, 'enterBattle'), CENTER_X, SCREEN_HEIGHT * 0.65);
},
_renderError(ctx) {
ctx.fillStyle = '#FF4444';
ctx.font = '16px Arial';
ctx.textAlign = 'center';
ctx.fillText(this._errorMsg, CENTER_X, SCREEN_HEIGHT * 0.45);
ctx.fillStyle = '#AAAAAA';
ctx.font = '14px Arial';
ctx.fillText(tp(i18nPrefix, 'tapBack'), CENTER_X, SCREEN_HEIGHT * 0.55);
},
// ---- Drawing Helpers ----
_drawButton(ctx, rect, label, pressed, fontSize, bgColor) {
if (!rect) return;
const fs = fontSize || 14;
ctx.fillStyle = bgColor || (pressed ? '#0f3460' : COLORS.MENU_BTN);
ctx.strokeStyle = COLORS.MENU_BTN_BORDER;
ctx.lineWidth = 2;
this._drawRoundRect(ctx, rect.x, rect.y, rect.w, rect.h, 6);
ctx.fill();
ctx.stroke();
ctx.fillStyle = pressed ? COLORS.MENU_TITLE : COLORS.MENU_BTN_TEXT;
ctx.font = `bold ${fs}px Arial`;
ctx.textAlign = 'center';
ctx.textBaseline = 'middle';
ctx.fillText(label, rect.x + rect.w / 2, rect.y + rect.h / 2);
},
_drawRoundRect(ctx, x, y, w, h, r) {
ctx.beginPath();
ctx.moveTo(x + r, y);
ctx.lineTo(x + w - r, y);
ctx.arcTo(x + w, y, x + w, y + r, r);
ctx.lineTo(x + w, y + h - r);
ctx.arcTo(x + w, y + h, x + w - r, y + h, r);
ctx.lineTo(x + r, y + h);
ctx.arcTo(x, y + h, x, y + h - r, r);
ctx.lineTo(x, y + r);
ctx.arcTo(x, y, x + r, y, r);
ctx.closePath();
},
_hitTest(tx, ty, rect) {
if (!rect) return false;
return tx >= rect.x && tx <= rect.x + rect.w && ty >= rect.y && ty <= rect.y + rect.h;
},
_drawAvatar(ctx, member, cx, cy, r) {
const img = this._avatarImages[member.playerId];
if (img) {
ctx.save();
ctx.beginPath();
ctx.arc(cx, cy, r, 0, Math.PI * 2);
ctx.clip();
ctx.drawImage(img, cx - r, cy - r, r * 2, r * 2);
ctx.restore();
ctx.strokeStyle = member.isLeader ? '#FFD700' : '#4a90d9';
ctx.lineWidth = 2;
ctx.beginPath();
ctx.arc(cx, cy, r + 1, 0, Math.PI * 2);
ctx.stroke();
} else {
if (member.playerId === this._myPlayerId) {
const profile = (typeof GameGlobal !== 'undefined') ? GameGlobal.playerProfile : null;
if (profile && profile.avatarUrl && !this._avatarImages[member.playerId]) {
this._loadAvatar(member);
}
}
const bgColor = member.isLeader ? '#FFD700' : '#4a90d9';
ctx.fillStyle = bgColor;
ctx.beginPath();
ctx.arc(cx, cy, r, 0, Math.PI * 2);
ctx.fill();
ctx.fillStyle = 'rgba(255,255,255,0.7)';
ctx.beginPath();
ctx.arc(cx, cy - r * 0.2, r * 0.3, 0, Math.PI * 2);
ctx.fill();
ctx.beginPath();
ctx.ellipse(cx, cy + r * 0.55, r * 0.55, r * 0.35, 0, Math.PI, 0);
ctx.fill();
ctx.strokeStyle = bgColor;
ctx.lineWidth = 2;
ctx.beginPath();
ctx.arc(cx, cy, r + 1, 0, Math.PI * 2);
ctx.stroke();
this._loadAvatar(member);
}
},
_loadAvatar(member) {
let avatarUrl = member.avatarUrl;
if (member.playerId === this._myPlayerId) {
const profile = (typeof GameGlobal !== 'undefined') ? GameGlobal.playerProfile : null;
if (profile && profile.avatarUrl) {
avatarUrl = profile.avatarUrl;
}
}
if (!avatarUrl || this._avatarImages[member.playerId] !== undefined) return;
this._avatarImages[member.playerId] = null;
try {
const img = wx.createImage();
img.onload = () => {
this._avatarImages[member.playerId] = img;
};
img.onerror = () => {
// Keep null so we don't retry endlessly
};
img.src = avatarUrl;
} catch (e) {
// wx.createImage not available
}
},
_getDisplayName(member) {
if (!member) return '';
const profile = GameGlobal.playerProfile;
let raw = member.nickname || '';
if (!raw) {
if (profile && typeof profile.getDisplayName === 'function') {
raw = profile.getDisplayName(member.playerId);
} else {
raw = member.playerId || '';
}
}
if (profile && typeof profile.truncate === 'function') {
return profile.truncate(raw, 4);
}
return raw.length > 8 ? raw.substring(0, 8) + '..' : raw;
},
// ---- Touch Handling ----
handleTouch(eventType, e) {
if (eventType !== 'touchstart') return;
const touch = e.touches[0];
const tx = touch.clientX;
const ty = touch.clientY;
if (this._hitTest(tx, ty, this._backBtnRect)) {
this._goBack();
return;
}
switch (this._state) {
case TEAM_STATE.MODE_SELECT:
if (this._hitTest(tx, ty, this._createTeamBtnRect)) {
this._handleCreateTeam();
} else if (this._hitTest(tx, ty, this._soloMatchBtnRect)) {
this._handleSoloMatch();
}
break;
case TEAM_STATE.FORMING:
this._handleFormingTouch(tx, ty);
break;
case TEAM_STATE.MATCHING:
if (this._isLeader && this._hitTest(tx, ty, this._cancelMatchBtnRect)) {
this._handleCancelMatch();
}
break;
case TEAM_STATE.ERROR:
this._state = TEAM_STATE.MODE_SELECT;
this._errorMsg = '';
break;
}
},
_handleFormingTouch(tx, ty) {
if (!this._teamData) return;
if (this._isLeader) {
if (this._hitTest(tx, ty, this._inviteBtnRect)) {
this._handleInvite();
return;
}
if (this._hitTest(tx, ty, this._matchBtnRect)) {
this._handleStartMatch();
return;
}
if (this._hitTest(tx, ty, this._disbandBtnRect)) {
this._handleDisband();
return;
}
const members = this._teamData.teamA || [];
for (let i = 0; i < members.length; i++) {
const member = members[i];
if (member && !member.isLeader && member.playerId !== this._myPlayerId) {
if (this._hitTest(tx, ty, this._kickBtnRects[i])) {
this._handleKick(member.playerId);
return;
}
}
}
} else {
if (this._hitTest(tx, ty, this._readyBtnRect)) {
this._handleReady();
return;
}
if (this._hitTest(tx, ty, this._leaveBtnRect)) {
this._handleLeave();
return;
}
}
},
// ---- Action Handlers ----
async _handleCreateTeam() {
const nm = this._networkManager;
if (!nm) return;
if (!nm.connected) {
const ok = await nm.connect(this._serverUrl);
if (!ok) {
this._errorMsg = t('common.cannotConnect');
this._state = TEAM_STATE.ERROR;
return;
}
}
this._myPlayerId = nm.playerId;
nm.createTeam(battleMode);
},
async _handleSoloMatch() {
const nm = this._networkManager;
if (!nm) return;
if (!nm.connected) {
const ok = await nm.connect(this._serverUrl);
if (!ok) {
this._errorMsg = t('common.cannotConnect');
this._state = TEAM_STATE.ERROR;
return;
}
}
this._myPlayerId = nm.playerId;
this._matchTimer = 0;
nm.soloMatch(battleMode);
},
async _autoJoinTeam(teamId) {
const nm = this._networkManager;
if (!nm) {
this._errorMsg = t('common.cannotConnect');
this._state = TEAM_STATE.ERROR;
return;
}
this._state = TEAM_STATE.JOINING;
this._errorMsg = '';
try {
if (!nm.connected) {
const ok = await nm.connect(this._serverUrl);
if (!ok) {
this._errorMsg = t('common.cannotConnect');
this._state = TEAM_STATE.ERROR;
return;
}
}
this._myPlayerId = nm.playerId;
console.log(`[${logTag}] Auto-joining team ${teamId} as ${this._myPlayerId}`);
nm.send(NET_MSG.JOIN_TEAM, { teamId });
} catch (e) {
console.error(`[${logTag}] Auto-join failed:`, e);
this._errorMsg = t('common.cannotConnect');
this._state = TEAM_STATE.ERROR;
}
},
_handleInvite() {
if (!this._teamData) return;
const teamId = this._teamData.teamId;
const shareData = {
title: tp(i18nPrefix, 'shareTitle'),
imageUrl: shareImageUrl,
query: `teamId=${teamId}&mode=${battleMode}`,
};
console.log(`[${logTag}] Sharing invite with query: teamId=${teamId}&mode=${battleMode}`);
const shareManager = GameGlobal.shareManager;
if (shareManager) {
shareManager.triggerShare(shareData);
} else {
try {
wx.showToast({
title: '请点击右上角 ··· 转发给好友',
icon: 'none',
duration: 2500,
});
} catch (e) {
console.log(`[${logTag}] Share not available, teamId:`, teamId);
}
}
},
_handleStartMatch() {
const nm = this._networkManager;
if (!nm || !this._teamData) return;
const members = this._teamData.teamA || [];
const allReady = members.length > 0 && members.every(m => m.ready || m.isLeader);
if (!allReady) return;
this._matchTimer = 0;
nm.send(NET_MSG.MATCH_START, {});
},
_handleCancelMatch() {
const nm = this._networkManager;
if (!nm) return;
nm.send(NET_MSG.MATCH_CANCEL, {});
},
_handleReady() {
const nm = this._networkManager;
if (!nm || !this._teamData) return;
const myMember = (this._teamData.teamA || []).find(m => m.playerId === this._myPlayerId);
nm.send(NET_MSG.TEAM_READY, { ready: myMember ? !myMember.ready : true });
},
_handleKick(playerId) {
const nm = this._networkManager;
if (!nm) return;
nm.send(NET_MSG.TEAM_KICK, { playerId });
},
_handleDisband() {
const nm = this._networkManager;
if (!nm) return;
nm.send(NET_MSG.TEAM_DISBAND, {});
},
_handleLeave() {
const nm = this._networkManager;
if (!nm) return;
nm.send(NET_MSG.LEAVE_TEAM, {});
this._teamData = null;
this._state = TEAM_STATE.MODE_SELECT;
},
_goBack() {
if (this._teamData) {
const nm = this._networkManager;
if (nm) {
if (this._state === TEAM_STATE.MATCHING && this._isLeader) {
nm.send(NET_MSG.MATCH_CANCEL, {});
}
if (this._isLeader) {
nm.send(NET_MSG.TEAM_DISBAND, {});
} else {
nm.send(NET_MSG.LEAVE_TEAM, {});
}
}
}
const sm = GameGlobal.sceneManager;
sm.switchTo(SCENE.MENU);
},
};
}
module.exports = { createTeamRoomScene, TEAM_STATE };
Binary file not shown.

After

Width:  |  Height:  |  Size: 447 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 443 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 246 KiB

BIN
View File
Binary file not shown.
+20 -4
View File
@@ -169,6 +169,7 @@ const TEAM_RECONNECT_TIMEOUT = 60000; // 60s to reconnect
// ============================================================ // ============================================================
const BATTLE_CONFIG = { const BATTLE_CONFIG = {
'1v1': { teamSize: 1, baseHp: 5, fillWithBots: false }, '1v1': { teamSize: 1, baseHp: 5, fillWithBots: false },
'2v2': { teamSize: 2, baseHp: 8, fillWithBots: true },
'3v3': { teamSize: 3, baseHp: 10, fillWithBots: true }, '3v3': { teamSize: 3, baseHp: 10, fillWithBots: true },
}; };
@@ -321,6 +322,11 @@ class TeamRoom {
return this.teamA.length >= this.teamSize; return this.teamA.length >= this.teamSize;
} }
/** Check if team B is full */
isTeamBFull() {
return this.teamB.length >= this.teamSize;
}
/** Check if both teams are full */ /** Check if both teams are full */
isFull() { isFull() {
return this.teamA.length >= this.teamSize && this.teamB.length >= this.teamSize; return this.teamA.length >= this.teamSize && this.teamB.length >= this.teamSize;
@@ -678,12 +684,15 @@ function handleCreateTeam(ws, data) {
handleLeaveTeam(ws, {}); handleLeaveTeam(ws, {});
} }
// Read battleMode from client data, fall back to '3v3' if not provided
const battleMode = (data && data.battleMode) || '3v3';
const teamId = generateTeamId(); const teamId = generateTeamId();
const teamRoom = new TeamRoom(teamId, ws, playerInfo.playerId, '3v3', playerInfo.nickname || '', playerInfo.avatarUrl || '', playerInfo.skinId || ''); const teamRoom = new TeamRoom(teamId, ws, playerInfo.playerId, battleMode, playerInfo.nickname || '', playerInfo.avatarUrl || '', playerInfo.skinId || '');
teamRooms.set(teamId, teamRoom); teamRooms.set(teamId, teamRoom);
playerInfo.teamId = teamId; playerInfo.teamId = teamId;
console.log(`[Server] Team ${teamId} created by ${playerInfo.playerId}`); console.log(`[Server] Team ${teamId} created by ${playerInfo.playerId} (mode: ${battleMode})`);
sendMessage(ws, NET_MSG.TEAM_STATE, teamRoom.getTeamState()); sendMessage(ws, NET_MSG.TEAM_STATE, teamRoom.getTeamState());
} }
@@ -703,7 +712,7 @@ function handleJoinTeam(ws, data) {
// Team was cleaned up (e.g. leader disconnected during dev-tool reload). // Team was cleaned up (e.g. leader disconnected during dev-tool reload).
// Auto-create a new room with the same ID so the invite link still works. // Auto-create a new room with the same ID so the invite link still works.
console.log(`[Server] Team ${teamId} not found, auto-creating for ${playerInfo.playerId}`); console.log(`[Server] Team ${teamId} not found, auto-creating for ${playerInfo.playerId}`);
teamRoom = new TeamRoom(teamId, ws, playerInfo.playerId, '3v3', playerInfo.nickname || '', playerInfo.avatarUrl || '', playerInfo.skinId || ''); teamRoom = new TeamRoom(teamId, ws, playerInfo.playerId, (data && data.battleMode) || '3v3', playerInfo.nickname || '', playerInfo.avatarUrl || '', playerInfo.skinId || '');
teamRooms.set(teamId, teamRoom); teamRooms.set(teamId, teamRoom);
playerInfo.teamId = teamId; playerInfo.teamId = teamId;
sendMessage(ws, NET_MSG.TEAM_STATE, teamRoom.getTeamState()); sendMessage(ws, NET_MSG.TEAM_STATE, teamRoom.getTeamState());
@@ -926,9 +935,12 @@ function handleSoloMatch(ws, data) {
handleLeaveTeam(ws, {}); handleLeaveTeam(ws, {});
} }
// Read battleMode from client data, fall back to '3v3' if not provided
const battleMode = (data && data.battleMode) || '3v3';
// Create a solo team room for this player // Create a solo team room for this player
const teamId = generateTeamId(); const teamId = generateTeamId();
const teamRoom = new TeamRoom(teamId, ws, playerInfo.playerId, '3v3', playerInfo.nickname || '', playerInfo.avatarUrl || '', playerInfo.skinId || ''); const teamRoom = new TeamRoom(teamId, ws, playerInfo.playerId, battleMode, playerInfo.nickname || '', playerInfo.avatarUrl || '', playerInfo.skinId || '');
teamRoom.state = 'matching'; teamRoom.state = 'matching';
teamRoom.matchStartTime = Date.now(); teamRoom.matchStartTime = Date.now();
teamRooms.set(teamId, teamRoom); teamRooms.set(teamId, teamRoom);
@@ -1093,6 +1105,10 @@ function tryMatchTeams() {
gameRoom.fillWithBots(); gameRoom.fillWithBots();
console.log(`[Server] Solo players matched into team ${gameRoom.id}`); console.log(`[Server] Solo players matched into team ${gameRoom.id}`);
// Send MATCH_FOUND to all players before starting the game
gameRoom.broadcast(NET_MSG.MATCH_FOUND, {});
startTeamGame(gameRoom); startTeamGame(gameRoom);
} }
} }
+516
View File
@@ -0,0 +1,516 @@
/**
* teamRoom.test.js
* Unit tests for TeamRoom class focuses on the isTeamBFull() bug fix
* and the solo match tryMatchTeams() flow that caused the Pod crash.
*
* Bug: TypeError: gameRoom.isTeamBFull is not a function
* at tryMatchTeams (index.js:1088)
* at handleSoloMatch (index.js:961)
* at handleMessage (index.js:1583)
*/
const { describe, it, before, after, beforeEach } = require('node:test');
const assert = require('node:assert/strict');
// ============================================================
// Lightweight WebSocket mock — enough for TeamRoom operations
// ============================================================
function createMockWs(id) {
return {
id,
readyState: 1, // OPEN
send: () => {},
on: () => {},
close: () => {},
};
}
// ============================================================
// We only need the TeamRoom class and its dependencies,
// not the full server (which binds to a port).
// Extract TeamRoom by loading index.js in a controlled way.
// ============================================================
// The server file starts an HTTP server on import, so we intercept
// the listen call and extract TeamRoom via a small trick:
// we require the file, then grab the class from the module's scope.
// Since that's not straightforward, we re-define TeamRoom here
// mirroring the production code, or better: we spawn a child process
// that loads the server and runs our test logic.
// Simpler approach: directly require the server and capture the class.
// The server calls server.listen() but we can override before that.
// Best approach for unit testing: extract TeamRoom into its own module.
// For now, we duplicate the class definition for isolated unit tests
// and also do an integration test that actually starts the server.
// ============================================================
// Unit Tests: TeamRoom class (mirrored from index.js)
// ============================================================
// We read the TeamRoom class directly from the running server by
// starting it on a random port and testing via WebSocket clients.
const http = require('http');
const { WebSocketServer } = require('ws');
const WebSocket = require('ws');
// ============================================================
// Integration Test: Start real server, connect WS clients
// ============================================================
describe('TeamRoom: isTeamBFull() bug fix', () => {
// We'll start the server as a child process to avoid port conflicts
const { spawn } = require('child_process');
const path = require('path');
let serverProcess;
let serverPort;
let wsBase;
before(async () => {
// Find a free port
serverPort = await new Promise((resolve) => {
const srv = http.createServer();
srv.listen(0, () => {
const port = srv.address().port;
srv.close(() => resolve(port));
});
});
wsBase = `ws://127.0.0.1:${serverPort}/tankwar/ws`;
// Start server as child process
serverProcess = spawn('node', ['index.js'], {
cwd: path.resolve(__dirname, '..'),
env: { ...process.env, PORT: String(serverPort), HOST: '127.0.0.1' },
stdio: ['pipe', 'pipe', 'pipe'],
});
// Wait for server to be ready
await new Promise((resolve) => {
serverProcess.stdout.on('data', (data) => {
const msg = data.toString();
if (msg.includes('Running on')) resolve();
});
});
});
after(() => {
if (serverProcess) {
serverProcess.kill('SIGTERM');
}
});
/**
* Helper: create a WebSocket client, send a message, collect responses.
*/
function connectWs(playerId, nickname) {
return new Promise((resolve, reject) => {
const ws = new WebSocket(wsBase);
const messages = [];
ws.on('open', () => {
// Send identification
ws.send(JSON.stringify({
type: 'ping',
playerId,
nickname: nickname || `nick_${playerId}`,
avatarUrl: '',
skinId: '',
}));
});
ws.on('message', (raw) => {
try {
const msg = JSON.parse(raw.toString());
messages.push(msg);
} catch (_) {}
});
ws.on('error', (err) => reject(err));
// Give it a moment to settle
setTimeout(() => {
resolve({ ws, messages, playerId });
}, 300);
});
}
function waitForMessage(client, type, timeoutMs = 5000) {
return new Promise((resolve, reject) => {
const timer = setTimeout(() => {
reject(new Error(`Timeout waiting for message type "${type}"`));
}, timeoutMs);
const handler = (raw) => {
try {
const msg = JSON.parse(raw.toString());
if (msg.type === type) {
clearTimeout(timer);
client.ws.removeListener('message', handler);
resolve(msg);
}
} catch (_) {}
};
client.ws.on('message', handler);
// Also check already-received messages
for (const msg of client.messages) {
if (msg.type === type) {
clearTimeout(timer);
client.ws.removeListener('message', handler);
resolve(msg);
return;
}
}
});
}
// ------------------------------------------------------------
it('should have isTeamBFull method on TeamRoom instances', async () => {
// Create a team room, then verify the method exists via team state
const client = await connectWs('player_test_method', 'TestMethod');
client.ws.send(JSON.stringify({ type: 'create_team', data: { battleMode: '3v3' } }));
const stateMsg = await waitForMessage(client, 'team_state', 3000);
assert.ok(stateMsg, 'Should receive team_state');
assert.strictEqual(stateMsg.data.teamSize, 3);
client.ws.close();
});
it('should match two solo players without crashing (the original bug)', async () => {
// This is the exact scenario that caused the Pod crash:
// Two players enter solo match → tryMatchTeams() is called →
// gameRoom.isTeamBFull() was not a function → TypeError → crash
const client1 = await connectWs('solo_p1', 'SoloPlayer1');
const client2 = await connectWs('solo_p2', 'SoloPlayer2');
// Player 1 enters solo match
client1.ws.send(JSON.stringify({
type: 'solo_match',
data: { battleMode: '3v3' },
}));
// Wait briefly for player 1 to enter pool
await new Promise((r) => setTimeout(r, 200));
// Player 2 enters solo match — this triggers tryMatchTeams()
client2.ws.send(JSON.stringify({
type: 'solo_match',
data: { battleMode: '3v3' },
}));
// Both should receive MATCH_FOUND or team_game_start without server crash
// Wait for match_found on client1
const matchFound1 = await waitForMessage(client1, 'match_found', 5000).catch(() => null);
const matchFound2 = await waitForMessage(client2, 'match_found', 5000).catch(() => null);
// At least one should get match_found (or team_game_start)
const gameStart1 = await waitForMessage(client1, 'team_game_start', 8000).catch(() => null);
const gameStart2 = await waitForMessage(client2, 'team_game_start', 8000).catch(() => null);
// Verify the game started for at least one player
assert.ok(
matchFound1 || matchFound2 || gameStart1 || gameStart2,
'At least one player should receive match_found or team_game_start'
);
// If we got game_start, verify team structure
const gameData = gameStart1 || gameStart2;
if (gameData) {
assert.ok(gameData.data.teamA, 'teamA should exist');
assert.ok(gameData.data.teamB, 'teamB should exist');
assert.ok(Array.isArray(gameData.data.teamA), 'teamA should be array');
assert.ok(Array.isArray(gameData.data.teamB), 'teamB should be array');
// In 3v3, both teams should have 3 members (players + bots)
assert.strictEqual(gameData.data.teamA.length, 3, 'teamA should have 3 members');
assert.strictEqual(gameData.data.teamB.length, 3, 'teamB should have 3 members');
}
client1.ws.close();
client2.ws.close();
});
it('should correctly alternate teams for 2v2 solo match', async () => {
const client1 = await connectWs('solo_2v2_p1', '2v2Player1');
const client2 = await connectWs('solo_2v2_p2', '2v2Player2');
client1.ws.send(JSON.stringify({
type: 'solo_match',
data: { battleMode: '2v2' },
}));
await new Promise((r) => setTimeout(r, 200));
client2.ws.send(JSON.stringify({
type: 'solo_match',
data: { battleMode: '2v2' },
}));
const gameStart1 = await waitForMessage(client1, 'team_game_start', 8000).catch(() => null);
const gameStart2 = await waitForMessage(client2, 'team_game_start', 8000).catch(() => null);
const gameData = gameStart1 || gameStart2;
if (gameData) {
assert.strictEqual(gameData.data.battleMode, '2v2');
assert.strictEqual(gameData.data.teamA.length, 2, 'teamA should have 2 members in 2v2');
assert.strictEqual(gameData.data.teamB.length, 2, 'teamB should have 2 members in 2v2');
// Verify the two real players are on opposite teams
const p1InTeamA = gameData.data.teamA.some(m => m.playerId === 'solo_2v2_p1');
const p1InTeamB = gameData.data.teamB.some(m => m.playerId === 'solo_2v2_p1');
const p2InTeamA = gameData.data.teamA.some(m => m.playerId === 'solo_2v2_p2');
const p2InTeamB = gameData.data.teamB.some(m => m.playerId === 'solo_2v2_p2');
// One should be in teamA, the other in teamB
assert.ok(p1InTeamA || p1InTeamB, 'Player 1 should be in one of the teams');
assert.ok(p2InTeamA || p2InTeamB, 'Player 2 should be in one of the teams');
assert.ok(
(p1InTeamA && p2InTeamB) || (p1InTeamB && p2InTeamA),
'Players should be on opposite teams'
);
}
client1.ws.close();
client2.ws.close();
});
it('should handle isTeamBFull correctly when team B is full', async () => {
// Create a team room and add members until team B is full
const client = await connectWs('full_team_b_test', 'FullTeamBTest');
client.ws.send(JSON.stringify({ type: 'create_team', data: { battleMode: '3v3' } }));
const stateMsg = await waitForMessage(client, 'team_state', 3000);
assert.ok(stateMsg, 'Should receive initial team state');
assert.strictEqual(stateMsg.data.teamB.length, 0, 'Team B should start empty');
// Team B full condition: the isTeamBFull method should work correctly
// We verify indirectly — when match times out, bots fill both teams
// The server won't crash because isTeamBFull is now a proper method
client.ws.close();
});
it('should not crash when 4+ solo players match simultaneously', async () => {
// Stress test: multiple solo players entering the pool at once
const clients = [];
for (let i = 0; i < 4; i++) {
const c = await connectWs(`stress_p${i}`, `StressPlayer${i}`);
clients.push(c);
}
// Send solo_match for all players in quick succession
for (const c of clients) {
c.ws.send(JSON.stringify({
type: 'solo_match',
data: { battleMode: '3v3' },
}));
}
// Wait for match results — server should not crash
await new Promise((r) => setTimeout(r, 3000));
// Verify server is still healthy
const healthResponse = await new Promise((resolve) => {
http.get(`http://127.0.0.1:${serverPort}/health`, (res) => {
let body = '';
res.on('data', (chunk) => { body += chunk; });
res.on('end', () => {
try { resolve(JSON.parse(body)); } catch (_) { resolve(null); }
});
}).on('error', () => resolve(null));
});
assert.ok(healthResponse, 'Server should still respond to health checks');
assert.strictEqual(healthResponse.status, 'healthy', 'Server should be healthy');
for (const c of clients) {
c.ws.close();
}
});
});
// ============================================================
// Unit Tests: TeamRoom class in isolation
// ============================================================
describe('TeamRoom unit tests (isolated)', () => {
// Re-implement the minimal TeamRoom for unit testing
// This mirrors the class in index.js
const BATTLE_CONFIG = {
'1v1': { teamSize: 1, baseHp: 5, fillWithBots: false },
'2v2': { teamSize: 2, baseHp: 8, fillWithBots: true },
'3v3': { teamSize: 3, baseHp: 10, fillWithBots: true },
};
class TeamRoom {
constructor(id, leaderWs, leaderId, battleMode = '3v3', leaderNickname = '', leaderAvatarUrl = '', leaderSkinId = '') {
this.id = id;
this.state = 'forming';
this.battleMode = battleMode;
const config = BATTLE_CONFIG[battleMode] || BATTLE_CONFIG['3v3'];
this.teamSize = config.teamSize;
this.fillWithBotsEnabled = config.fillWithBots;
this.teamA = [{ ws: leaderWs, playerId: leaderId, nickname: leaderNickname || '', avatarUrl: leaderAvatarUrl || '', skinId: leaderSkinId || '', ready: true, isBot: false, disconnectedAt: null }];
this.teamB = [];
this.leaderId = leaderId;
this.matchTimer = null;
this.matchStartTime = null;
this.teamABaseHp = config.baseHp;
this.teamBBaseHp = config.baseHp;
this.gameStartTime = null;
this.createdAt = Date.now();
this.mapId = Math.floor(Math.random() * 3) + 1;
}
isTeamAFull() {
return this.teamA.length >= this.teamSize;
}
isTeamBFull() {
return this.teamB.length >= this.teamSize;
}
isFull() {
return this.teamA.length >= this.teamSize && this.teamB.length >= this.teamSize;
}
addToTeamA(ws, playerId, nickname = '', avatarUrl = '', skinId = '') {
if (this.teamA.length >= this.teamSize) return false;
if (this.teamA.some(m => m.playerId === playerId) || this.teamB.some(m => m.playerId === playerId)) return false;
this.teamA.push({ ws, playerId, nickname, avatarUrl, skinId, ready: false, isBot: false, disconnectedAt: null });
return true;
}
addToTeamB(ws, playerId, nickname = '', avatarUrl = '', skinId = '') {
if (this.teamB.length >= this.teamSize) return false;
if (this.teamA.some(m => m.playerId === playerId) || this.teamB.some(m => m.playerId === playerId)) return false;
this.teamB.push({ ws, playerId, nickname, avatarUrl, skinId, ready: false, isBot: false, disconnectedAt: null });
return true;
}
fillWithBots() {
let botCounter = 0;
while (this.teamA.length < this.teamSize) {
botCounter++;
this.teamA.push({ ws: null, playerId: `bot_a_${botCounter}_${this.id}`, nickname: '', avatarUrl: '', ready: true, isBot: true, disconnectedAt: null });
}
while (this.teamB.length < this.teamSize) {
botCounter++;
this.teamB.push({ ws: null, playerId: `bot_b_${botCounter}_${this.id}`, nickname: '', avatarUrl: '', ready: true, isBot: true, disconnectedAt: null });
}
}
getAllHumanMembers() {
return [...this.teamA, ...this.teamB].filter(m => !m.isBot);
}
removePlayer(playerId) {
this.teamA = this.teamA.filter(m => m.playerId !== playerId);
this.teamB = this.teamB.filter(m => m.playerId !== playerId);
}
broadcast() {}
}
it('isTeamBFull should exist as a function', () => {
const room = new TeamRoom('T1', createMockWs('ws1'), 'leader1', '3v3');
assert.strictEqual(typeof room.isTeamBFull, 'function', 'isTeamBFull must be a function');
});
it('isTeamBFull should return false when team B is empty', () => {
const room = new TeamRoom('T1', createMockWs('ws1'), 'leader1', '3v3');
assert.strictEqual(room.isTeamBFull(), false, 'Team B should not be full when empty');
});
it('isTeamBFull should return false when team B has fewer members than teamSize', () => {
const room = new TeamRoom('T1', createMockWs('ws1'), 'leader1', '3v3');
room.addToTeamB(createMockWs('ws2'), 'p2');
assert.strictEqual(room.isTeamBFull(), false, 'Team B with 1/3 should not be full');
});
it('isTeamBFull should return true when team B reaches teamSize', () => {
const room = new TeamRoom('T1', createMockWs('ws1'), 'leader1', '3v3');
room.addToTeamB(createMockWs('ws2'), 'p2');
room.addToTeamB(createMockWs('ws3'), 'p3');
room.addToTeamB(createMockWs('ws4'), 'p4');
assert.strictEqual(room.isTeamBFull(), true, 'Team B with 3/3 should be full');
});
it('isTeamAFull should return true for 1v1 room after leader joins', () => {
const room = new TeamRoom('T1', createMockWs('ws1'), 'leader1', '1v1');
assert.strictEqual(room.isTeamAFull(), true, 'Team A should be full with just the leader in 1v1');
assert.strictEqual(room.isTeamBFull(), false, 'Team B should not be full yet');
});
it('isFull should return true only when both teams are full', () => {
const room = new TeamRoom('T1', createMockWs('ws1'), 'leader1', '1v1');
assert.strictEqual(room.isFull(), false, 'Room not full without team B');
room.addToTeamB(createMockWs('ws2'), 'p2');
assert.strictEqual(room.isFull(), true, 'Room full with both teams at capacity');
});
it('addToTeamB should respect teamSize limit', () => {
const room = new TeamRoom('T1', createMockWs('ws1'), 'leader1', '2v2');
assert.strictEqual(room.addToTeamB(createMockWs('ws2'), 'p2'), true);
assert.strictEqual(room.addToTeamB(createMockWs('ws3'), 'p3'), true);
assert.strictEqual(room.addToTeamB(createMockWs('ws4'), 'p4'), false, 'Should reject when team B is full');
});
it('fillWithBots should fill both teams to teamSize', () => {
const room = new TeamRoom('T1', createMockWs('ws1'), 'leader1', '3v3');
room.addToTeamB(createMockWs('ws2'), 'p2');
room.fillWithBots();
assert.strictEqual(room.teamA.length, 3, 'Team A should have 3 members');
assert.strictEqual(room.teamB.length, 3, 'Team B should have 3 members');
const teamABots = room.teamA.filter(m => m.isBot).length;
const teamBBots = room.teamB.filter(m => m.isBot).length;
assert.strictEqual(teamABots, 2, 'Team A should have 2 bots');
assert.strictEqual(teamBBots, 2, 'Team B should have 2 bots');
});
it('solo match team alternation logic should place players on opposite teams', () => {
// Simulate the exact code path from tryMatchTeams() that crashed
const room = new TeamRoom('T1', createMockWs('ws1'), 'p1', '3v3', 'Nick1');
const gamePlayers = [
createMockWs('ws1'), // player 0 → already in teamA as room creator
createMockWs('ws2'), // player 1 → should go to teamB
createMockWs('ws3'), // player 2 → should go to teamA
];
// Simulate the tryMatchTeams loop (the code that crashed)
for (let i = 1; i < gamePlayers.length; i++) {
const ws = gamePlayers[i];
const isTeamBSlot = (i % 2 === 1);
// This is the EXACT code path that called isTeamBFull() and crashed
if (isTeamBSlot && !room.isTeamBFull()) {
room.addToTeamB(ws, `p${i + 1}`);
} else if (!isTeamBSlot && !room.isTeamAFull()) {
room.addToTeamA(ws, `p${i + 1}`);
} else if (!room.isTeamBFull()) {
room.addToTeamB(ws, `p${i + 1}`);
} else {
room.addToTeamA(ws, `p${i + 1}`);
}
}
// Verify team distribution
assert.strictEqual(room.teamA.length, 2, 'Team A should have 2 players (p1 + p3)');
assert.strictEqual(room.teamB.length, 1, 'Team B should have 1 player (p2)');
const teamAIds = room.teamA.map(m => m.playerId);
const teamBIds = room.teamB.map(m => m.playerId);
assert.ok(teamAIds.includes('p1'), 'Player 1 should be in team A');
assert.ok(teamBIds.includes('p2'), 'Player 2 should be in team B');
assert.ok(teamAIds.includes('p3'), 'Player 3 should be in team A');
});
});