3 Commits

Author SHA1 Message Date
jakciehan 4b31ac520d add bg.png 2026-06-07 22:08:29 +08:00
jakciehan e4140f073f fix boss tank cross brick 2026-06-07 22:08:00 +08:00
jakciehan c3a4aa8f15 feat: optimize pvp invite 2026-05-18 07:39:03 +08:00
33 changed files with 3045 additions and 1483 deletions
Vendored
BIN
View File
Binary file not shown.
+4 -4
View File
@@ -29,10 +29,10 @@
"js/managers/BuffManager.js", "js/managers/BuffManager.js",
"js/managers/BattlePassManager.js", "js/managers/BattlePassManager.js",
"js/managers/ResourceManager.js", "js/managers/ResourceManager.js",
"js/managers/SkinManager.js",
"js/managers/SpawnManager.js", "js/managers/SpawnManager.js",
"js/managers/StaminaManager.js", "js/managers/StaminaManager.js",
"js/managers/StorageManager.js" "js/managers/StorageManager.js",
"js/managers/SkinManager.js"
] ]
}, },
"obfuscation": { "obfuscation": {
@@ -68,10 +68,10 @@
"js/managers/BuffManager.js", "js/managers/BuffManager.js",
"js/managers/BattlePassManager.js", "js/managers/BattlePassManager.js",
"js/managers/ResourceManager.js", "js/managers/ResourceManager.js",
"js/managers/SkinManager.js",
"js/managers/SpawnManager.js", "js/managers/SpawnManager.js",
"js/managers/StaminaManager.js", "js/managers/StaminaManager.js",
"js/managers/StorageManager.js" "js/managers/StorageManager.js",
"js/managers/SkinManager.js"
] ]
} }
] ]
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}" \
+135 -18
View File
@@ -175,18 +175,27 @@ wx.onShow((res) => {
console.log(`[game.js] onShow fired: scene=${res && res.scene}, query=${JSON.stringify(res && res.query)}, shareTicket=${res && res.shareTicket}`); console.log(`[game.js] onShow fired: scene=${res && res.scene}, query=${JSON.stringify(res && res.query)}, shareTicket=${res && res.shareTicket}`);
// Check for teamId from invite card (3v3 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);
if (teamId) { if (teamId) {
_handleInviteTeamId(teamId); _handleInviteTeamId(teamId, teamMode);
} else if (roomId) {
_handleInviteRoomId(roomId);
} else { } else {
// Fallback: also check launch options in case onShow query is empty on cold start // Fallback: also check launch options in case onShow query is empty on cold start
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);
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) {
console.log(`[game.js] onShow query empty, but found roomId in launchOptions: ${fallbackRoomId}`);
_handleInviteRoomId(fallbackRoomId);
} }
} catch (e) {} } catch (e) {}
} }
@@ -236,11 +245,107 @@ function _extractTeamId(query) {
} }
/** /**
* Handle teamId from invite card (shared between onShow and cold launch). * Extract mode parameter from query (e.g. mode=2v2 or mode=3v3).
* Navigates to TeamRoomScene if possible, otherwise stores as pending. * Used to route team invites to the correct room scene.
* @param {string} teamId * @param {object|string|undefined} query
* @returns {string|null}
*/ */
function _handleInviteTeamId(teamId) { 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).
* Similar to _extractTeamId, but looks for roomId key.
* @param {object|string|undefined} query
* @returns {string|null}
*/
function _extractRoomId(query) {
if (!query) return null;
// Case 1: query is already an object with roomId property
if (typeof query === 'object' && query.roomId) {
return query.roomId;
}
// Case 2: query is a string like 'roomId=R12345' or 'roomId=R12345&foo=bar'
if (typeof query === 'string') {
const match = query.match(/roomId=([^&]+)/);
if (match) return match[1];
}
// Case 3: query is an object but roomId might be nested in a raw string field
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(/roomId=([^&]+)/);
if (match) return match[1];
}
}
return null;
}
/**
* Handle roomId from 1v1 invite card.
* Navigates to RoomScene (auto-join) if possible, otherwise stores as pending.
* @param {string} roomId
*/
function _handleInviteRoomId(roomId) {
if (!roomId) return;
// Avoid duplicate processing if already pending the same roomId
if (GameGlobal._pendingRoomId === roomId) {
console.log(`[game.js] roomId ${roomId} already pending, skipping duplicate`);
return;
}
console.log(`[game.js] Received roomId from 1v1 invite: ${roomId}, currentScene: ${sceneManager._currentName}`);
// If already past loading, navigate directly to PVP room scene
if (sceneManager._currentScene && sceneManager._currentName !== SCENE.LOADING) {
console.log(`[game.js] Navigating directly to RoomScene with roomId: ${roomId}`);
if (!sceneManager._scenes.has(SCENE.PVP_ROOM)) {
const RoomScene = require('./js/scenes/RoomScene');
sceneManager.register(SCENE.PVP_ROOM, RoomScene);
}
sceneManager.switchTo(SCENE.PVP_ROOM, { roomId });
GameGlobal._pendingRoomId = null;
} else {
// Still loading — store pending roomId for auto-navigation after load
console.log(`[game.js] Still loading, storing pendingRoomId: ${roomId}`);
GameGlobal._pendingRoomId = roomId;
}
}
/**
* Handle teamId from invite card (shared between onShow and cold launch).
* Routes to Team2v2RoomScene or TeamRoomScene based on mode parameter.
* @param {string} teamId
* @param {string|null} mode - '2v2' or '3v3' (default: '3v3')
*/
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
@@ -249,33 +354,45 @@ 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;
} }
} }
// Check for teamId from cold launch (user opened game via invite card) // Check for teamId / roomId from cold launch (user opened game via invite card)
try { 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);
if (launchTeamId) { if (launchTeamId) {
_handleInviteTeamId(launchTeamId); _handleInviteTeamId(launchTeamId, launchTeamMode);
} else if (launchRoomId) {
_handleInviteRoomId(launchRoomId);
} else { } else {
console.log('[game.js] No teamId found in cold launch options'); console.log('[game.js] No teamId/roomId found in cold launch options');
} }
} catch (e) { } catch (e) {
console.error('[game.js] getLaunchOptionsSync failed:', e); console.error('[game.js] getLaunchOptionsSync failed:', e);
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,
}; };
} }
+45 -10
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',
@@ -37,18 +46,43 @@ module.exports = {
// Room Scene (PVP) // Room Scene (PVP)
// ============================================================ // ============================================================
'room.title': 'PVP Battle', 'room.title': 'PVP Battle',
'room.idleHint': 'Create a room or join with a code',
'room.create': 'Create Room',
'room.join': 'Join Room',
'room.connecting': 'Connecting{dots}', 'room.connecting': 'Connecting{dots}',
'room.roomCode': 'Room Code:', 'room.roomCode': 'Room Code:',
'room.waiting': 'Waiting for opponent{dots}', 'room.waiting': 'Waiting for opponent{dots}',
'room.shareHint': 'Share the room code with your friend', 'room.inviteFriend': '📨 Invite Friend',
'room.inputCode': 'Enter Room Code:', 'room.shareTitle': 'Come play 1v1 Tank Battle with me!',
'room.shareHint': 'Or share the room code with your friend',
'room.opponentFound': 'Opponent found!', 'room.opponentFound': 'Opponent found!',
'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)
// ============================================================ // ============================================================
@@ -297,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!',
+44 -9
View File
@@ -21,34 +21,68 @@ 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.idleHint': '创建房间或输入房间号加入',
'room.create': '创建房间',
'room.join': '加入房间',
'room.connecting': '连接中{dots}', 'room.connecting': '连接中{dots}',
'room.roomCode': '房间号:', 'room.roomCode': '房间号:',
'room.waiting': '等待对手加入{dots}', 'room.waiting': '等待对手加入{dots}',
'room.shareHint': '将房间号分享给好友', 'room.inviteFriend': '📨 邀请好友',
'room.inputCode': '输入房间号:', 'room.shareTitle': '来和我1v1坦克大战吧!',
'room.shareHint': '或者将房间号分享给好友',
'room.opponentFound': '对手已找到!', 'room.opponentFound': '对手已找到!',
'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)
// ============================================================ // ============================================================
@@ -297,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) {
+810 -138
View File
File diff suppressed because it is too large Load Diff
+201 -174
View File
@@ -1,7 +1,8 @@
/** /**
* RoomScene.js * RoomScene.js
* Room creation/joining UI for PVP online multiplayer mode. * PVP 1v1 online multiplayer scene.
* Allows players to create a room or join an existing one by room code. * Entering this scene automatically creates a room and triggers invite share.
* From an invite card, auto-joins the specified room.
*/ */
const { const {
@@ -26,11 +27,9 @@ const CENTER_X = SCREEN_WIDTH / 2;
// Room Scene States // Room Scene States
// ============================================================ // ============================================================
const ROOM_STATE = { const ROOM_STATE = {
IDLE: 'idle', // Initial state: show create/join buttons CREATING: 'creating', // Connecting and creating room (also initial state)
CREATING: 'creating', // Connecting and creating room
WAITING: 'waiting', // Room created, waiting for opponent WAITING: 'waiting', // Room created, waiting for opponent
JOINING: 'joining', // Joining a room JOINING: 'joining', // Joining a room
INPUT_CODE: 'input', // Entering room code
COUNTDOWN: 'countdown', // Both players ready, counting down COUNTDOWN: 'countdown', // Both players ready, counting down
ERROR: 'error', // Error state ERROR: 'error', // Error state
}; };
@@ -39,9 +38,8 @@ const ROOM_STATE = {
// Room Scene // Room Scene
// ============================================================ // ============================================================
const RoomScene = { const RoomScene = {
_state: ROOM_STATE.IDLE, _state: ROOM_STATE.CREATING,
_roomCode: '', _roomCode: '',
_inputCode: '',
_errorMsg: '', _errorMsg: '',
_countdown: 3, _countdown: 3,
_countdownTimer: 0, _countdownTimer: 0,
@@ -53,17 +51,10 @@ const RoomScene = {
_serverUrl: SERVER_URL, _serverUrl: SERVER_URL,
// Button rects (calculated in enter) // Button rects (calculated in enter)
_createBtnRect: null,
_joinBtnRect: null,
_backBtnRect: null, _backBtnRect: null,
_confirmBtnRect: null, _inviteBtnRect: null,
_numpadRects: [],
_deleteBtnRect: null,
enter() { enter(params) {
this._state = ROOM_STATE.IDLE;
this._roomCode = '';
this._inputCode = '';
this._errorMsg = ''; this._errorMsg = '';
this._countdown = 3; this._countdown = 3;
this._countdownTimer = 0; this._countdownTimer = 0;
@@ -71,20 +62,6 @@ const RoomScene = {
this._pendingStartData = null; this._pendingStartData = null;
this._networkManager = GameGlobal.networkManager; this._networkManager = GameGlobal.networkManager;
// Calculate button positions
const btnY = SCREEN_HEIGHT * 0.4;
this._createBtnRect = {
x: CENTER_X - BTN_WIDTH / 2,
y: btnY,
w: BTN_WIDTH,
h: BTN_HEIGHT,
};
this._joinBtnRect = {
x: CENTER_X - BTN_WIDTH / 2,
y: btnY + BTN_HEIGHT + BTN_GAP,
w: BTN_WIDTH,
h: BTN_HEIGHT,
};
this._backBtnRect = { this._backBtnRect = {
x: 10, x: 10,
y: 10, y: 10,
@@ -92,47 +69,37 @@ const RoomScene = {
h: 30, h: 30,
}; };
// Numpad for room code input (3x4 grid: 1-9, 0, delete, confirm) // Invite friend button (shown in WAITING state)
this._buildNumpad(); const inviteBtnW = Math.min(SCREEN_WIDTH * 0.5, 240);
const inviteBtnH = Math.min(40, SCREEN_HEIGHT * 0.08);
// Confirm button for code input this._inviteBtnRect = {
this._confirmBtnRect = { x: CENTER_X - inviteBtnW / 2,
x: CENTER_X - BTN_WIDTH / 2, y: SCREEN_HEIGHT * 0.73,
y: SCREEN_HEIGHT * 0.75, w: inviteBtnW,
w: BTN_WIDTH, h: inviteBtnH,
h: BTN_HEIGHT,
}; };
// Setup network event listeners // Setup network event listeners
this._setupNetworkEvents(); this._setupNetworkEvents();
// Decide initial flow based on entry params
if (params && params.roomId) {
// From invite card — auto-join the room
this._autoJoinRoom(params.roomId);
} else {
// Direct entry from menu — auto-create room + share (one-step invite)
this._state = ROOM_STATE.CREATING;
this._roomCode = '';
this._handleInviteAndCreate();
}
}, },
exit() { exit() {
this._cleanupNetworkEvents(); this._cleanupNetworkEvents();
}, // Reset share content when leaving room
const shareManager = GameGlobal.shareManager;
_buildNumpad() { if (shareManager) {
const padWidth = Math.min(SCREEN_WIDTH * 0.6, 200); shareManager.resetShareContent();
const padHeight = Math.min(SCREEN_HEIGHT * 0.35, 180);
const startX = CENTER_X - padWidth / 2;
const startY = SCREEN_HEIGHT * 0.42;
const cellW = padWidth / 3;
const cellH = padHeight / 4;
this._numpadRects = [];
const nums = [1, 2, 3, 4, 5, 6, 7, 8, 9, null, 0, 'del'];
for (let i = 0; i < 12; i++) {
const col = i % 3;
const row = Math.floor(i / 3);
if (nums[i] !== null) {
this._numpadRects.push({
x: startX + col * cellW,
y: startY + row * cellH,
w: cellW - 4,
h: cellH - 4,
value: nums[i],
});
}
} }
}, },
@@ -146,6 +113,8 @@ const RoomScene = {
unsubs.push(nm.on(NET_MSG.ROOM_CREATED, (data) => { unsubs.push(nm.on(NET_MSG.ROOM_CREATED, (data) => {
this._roomCode = data.roomId || data.roomCode || ''; this._roomCode = data.roomId || data.roomCode || '';
this._state = ROOM_STATE.WAITING; this._state = ROOM_STATE.WAITING;
// Update share content so right-corner menu also carries the roomId
this._updateShareContent();
})); }));
unsubs.push(nm.on(NET_MSG.ROOM_JOINED, (data) => { unsubs.push(nm.on(NET_MSG.ROOM_JOINED, (data) => {
@@ -183,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;
} }
@@ -218,6 +187,134 @@ const RoomScene = {
} }
}, },
/**
* Update share content so the right-corner ··· menu always carries
* the current roomId for 1v1 invite.
* @private
*/
_updateShareContent() {
if (!this._roomCode) return;
const shareManager = GameGlobal.shareManager;
if (shareManager) {
shareManager.setShareContent({
title: t('room.shareTitle'),
imageUrl: 'js/ui/images/1v1.png',
query: `roomId=${this._roomCode}`,
});
}
},
/**
* Handle "Invite Friend" button tap trigger WeChat share.
* MUST be called within a touch event for WeChat policy compliance.
* @private
*/
_handleInvite() {
if (!this._roomCode) return;
const shareData = {
title: t('room.shareTitle'),
imageUrl: 'js/ui/images/1v1.png',
query: `roomId=${this._roomCode}`,
};
console.log(`[RoomScene] Sharing 1v1 invite with query: roomId=${this._roomCode}`);
const shareManager = GameGlobal.shareManager;
if (shareManager) {
shareManager.triggerShare(shareData);
} else {
try {
if (typeof wx !== 'undefined' && wx.showToast) {
wx.showToast({
title: '请点击右上角 ··· 转发给好友',
icon: 'none',
duration: 2500,
});
}
} catch (e) {
console.log('[RoomScene] Share not available, roomId:', this._roomCode);
}
}
},
/**
* One-step invite: auto-create room then immediately share the invite card.
* This is the primary action automatically creates a room and triggers invite share
* and we handle everything in one go.
* MUST be called within a touch event for WeChat policy compliance.
* @private
*/
async _handleInviteAndCreate() {
const nm = this._networkManager;
if (!nm) {
this._errorMsg = t('common.cannotConnect');
this._state = ROOM_STATE.ERROR;
return;
}
// If already connected and in a room (WAITING state), just re-share
if (this._state === ROOM_STATE.WAITING && this._roomCode) {
this._handleInvite();
return;
}
// Connect if needed
if (!nm.connected) {
this._state = ROOM_STATE.CREATING;
const ok = await nm.connect(this._serverUrl);
if (!ok) {
this._errorMsg = t('common.cannotConnect');
this._state = ROOM_STATE.ERROR;
return;
}
}
// Create room — the ROOM_CREATED event handler will set state to WAITING
// and store the roomCode. We need to share AFTER the room is created,
// so we listen for the event once.
const shareOnCreated = (data) => {
const roomId = data.roomId || data.roomCode || '';
if (!roomId) return;
const shareData = {
title: t('room.shareTitle'),
imageUrl: 'js/ui/images/1v1.png',
query: `roomId=${roomId}`,
};
console.log(`[RoomScene] Auto-sharing 1v1 invite after room created: roomId=${roomId}`);
const shareManager = GameGlobal.shareManager;
if (shareManager) {
shareManager.triggerShare(shareData);
} else {
try {
if (typeof wx !== 'undefined' && wx.showToast) {
wx.showToast({
title: '请点击右上角 ··· 转发给好友',
icon: 'none',
duration: 2500,
});
}
} catch (e) {
console.log('[RoomScene] Share not available after room created');
}
}
};
// Subscribe one-time for auto-share after room creation
const unsub = nm.on(NET_MSG.ROOM_CREATED, (data) => {
// Unsubscribe immediately (one-time listener)
unsub();
shareOnCreated(data);
});
// Now create the room
nm.createRoom();
},
_startGame(data) { _startGame(data) {
const sm = GameGlobal.sceneManager; const sm = GameGlobal.sceneManager;
if (!sm._scenes.has(SCENE.TEAM_GAME)) { if (!sm._scenes.has(SCENE.TEAM_GAME)) {
@@ -281,9 +378,6 @@ const RoomScene = {
// Render based on state // Render based on state
switch (this._state) { switch (this._state) {
case ROOM_STATE.IDLE:
this._renderIdle(ctx);
break;
case ROOM_STATE.CREATING: case ROOM_STATE.CREATING:
case ROOM_STATE.JOINING: case ROOM_STATE.JOINING:
this._renderConnecting(ctx); this._renderConnecting(ctx);
@@ -291,9 +385,6 @@ const RoomScene = {
case ROOM_STATE.WAITING: case ROOM_STATE.WAITING:
this._renderWaiting(ctx); this._renderWaiting(ctx);
break; break;
case ROOM_STATE.INPUT_CODE:
this._renderInputCode(ctx);
break;
case ROOM_STATE.COUNTDOWN: case ROOM_STATE.COUNTDOWN:
this._renderCountdown(ctx); this._renderCountdown(ctx);
break; break;
@@ -303,16 +394,6 @@ const RoomScene = {
} }
}, },
_renderIdle(ctx) {
ctx.fillStyle = '#AAAAAA';
ctx.font = '14px Arial';
ctx.textAlign = 'center';
ctx.fillText(t('room.idleHint'), CENTER_X, SCREEN_HEIGHT * 0.28);
this._drawButton(ctx, this._createBtnRect, t('room.create'));
this._drawButton(ctx, this._joinBtnRect, t('room.join'));
},
_renderConnecting(ctx) { _renderConnecting(ctx) {
const dots = '.'.repeat(Math.floor(this._animTimer * 3) % 4); const dots = '.'.repeat(Math.floor(this._animTimer * 3) % 4);
ctx.fillStyle = '#FFFFFF'; ctx.fillStyle = '#FFFFFF';
@@ -336,48 +417,15 @@ const RoomScene = {
const dots = '.'.repeat(Math.floor(this._animTimer * 2) % 4); const dots = '.'.repeat(Math.floor(this._animTimer * 2) % 4);
ctx.fillStyle = '#AAAAAA'; ctx.fillStyle = '#AAAAAA';
ctx.font = '16px Arial'; ctx.font = '16px Arial';
ctx.fillText(t('room.waiting', { dots }), CENTER_X, SCREEN_HEIGHT * 0.55); ctx.fillText(t('room.waiting', { dots }), CENTER_X, SCREEN_HEIGHT * 0.52);
// Hint // Invite friend button (primary action)
this._drawButton(ctx, this._inviteBtnRect, t('room.inviteFriend'), false, 16, '#e94560');
// Hint text below the button
ctx.fillStyle = '#666666'; ctx.fillStyle = '#666666';
ctx.font = '12px Arial'; ctx.font = '12px Arial';
ctx.fillText(t('room.shareHint'), CENTER_X, SCREEN_HEIGHT * 0.65); ctx.fillText(t('room.shareHint'), CENTER_X, SCREEN_HEIGHT * 0.84);
},
_renderInputCode(ctx) {
ctx.fillStyle = '#FFFFFF';
ctx.font = '14px Arial';
ctx.textAlign = 'center';
ctx.fillText(t('room.inputCode'), CENTER_X, SCREEN_HEIGHT * 0.25);
// Code display box
const boxW = Math.min(SCREEN_WIDTH * 0.5, 180);
const boxH = 40;
const boxX = CENTER_X - boxW / 2;
const boxY = SCREEN_HEIGHT * 0.30;
ctx.fillStyle = '#1a1a2e';
ctx.strokeStyle = COLORS.MENU_TITLE;
ctx.lineWidth = 2;
ctx.fillRect(boxX, boxY, boxW, boxH);
ctx.strokeRect(boxX, boxY, boxW, boxH);
// Input text
ctx.fillStyle = COLORS.MENU_TITLE;
ctx.font = 'bold 24px Arial';
const displayCode = this._inputCode + (Math.floor(this._animTimer * 2) % 2 === 0 ? '|' : '');
ctx.fillText(displayCode, CENTER_X, boxY + boxH / 2);
// Numpad
for (const btn of this._numpadRects) {
const label = btn.value === 'del' ? '⌫' : String(btn.value);
this._drawButton(ctx, btn, label, false, 16);
}
// Confirm button
if (this._inputCode.length >= 4) {
this._drawButton(ctx, this._confirmBtnRect, t('common.joinBtn'), false, 16);
}
}, },
_renderCountdown(ctx) { _renderCountdown(ctx) {
@@ -406,11 +454,11 @@ const RoomScene = {
ctx.fillText(t('room.tapBack'), CENTER_X, SCREEN_HEIGHT * 0.55); ctx.fillText(t('room.tapBack'), CENTER_X, SCREEN_HEIGHT * 0.55);
}, },
_drawButton(ctx, rect, label, pressed, fontSize) { _drawButton(ctx, rect, label, pressed, fontSize, bgColor) {
if (!rect) return; if (!rect) return;
const fs = fontSize || 16; const fs = fontSize || 16;
ctx.fillStyle = pressed ? '#0f3460' : COLORS.MENU_BTN; ctx.fillStyle = bgColor || (pressed ? '#0f3460' : COLORS.MENU_BTN);
ctx.strokeStyle = COLORS.MENU_BTN_BORDER; ctx.strokeStyle = COLORS.MENU_BTN_BORDER;
ctx.lineWidth = 2; ctx.lineWidth = 2;
@@ -456,74 +504,53 @@ const RoomScene = {
} }
switch (this._state) { switch (this._state) {
case ROOM_STATE.IDLE:
if (this._hitTest(tx, ty, this._createBtnRect)) {
this._handleCreateRoom();
} else if (this._hitTest(tx, ty, this._joinBtnRect)) {
this._state = ROOM_STATE.INPUT_CODE;
this._inputCode = '';
}
break;
case ROOM_STATE.INPUT_CODE:
// Check numpad
for (const btn of this._numpadRects) {
if (this._hitTest(tx, ty, btn)) {
if (btn.value === 'del') {
this._inputCode = this._inputCode.slice(0, -1);
} else if (this._inputCode.length < 6) {
this._inputCode += String(btn.value);
}
return;
}
}
// Check confirm
if (this._inputCode.length >= 4 && this._hitTest(tx, ty, this._confirmBtnRect)) {
this._handleJoinRoom();
}
break;
case ROOM_STATE.ERROR: case ROOM_STATE.ERROR:
this._state = ROOM_STATE.IDLE; this._state = ROOM_STATE.CREATING;
this._errorMsg = ''; this._errorMsg = '';
break; break;
case ROOM_STATE.WAITING: case ROOM_STATE.WAITING:
// Invite friend button
if (this._hitTest(tx, ty, this._inviteBtnRect)) {
this._handleInvite();
}
// Allow going back while waiting // Allow going back while waiting
break; break;
} }
}, },
async _handleCreateRoom() { /**
this._state = ROOM_STATE.CREATING; * Auto-join a 1v1 room when entering from an invite card.
const nm = this._networkManager; * @param {string} roomId - Room ID from the invite card query parameter.
*/
if (!nm.connected) { async _autoJoinRoom(roomId) {
const ok = await nm.connect(this._serverUrl);
if (!ok) {
this._errorMsg = t('common.cannotConnect');
this._state = ROOM_STATE.ERROR;
return;
}
}
nm.createRoom();
},
async _handleJoinRoom() {
this._state = ROOM_STATE.JOINING; this._state = ROOM_STATE.JOINING;
this._errorMsg = '';
const nm = this._networkManager; const nm = this._networkManager;
if (!nm.connected) { if (!nm) {
const ok = await nm.connect(this._serverUrl); this._errorMsg = t('common.cannotConnect');
if (!ok) { this._state = ROOM_STATE.ERROR;
this._errorMsg = t('common.cannotConnect'); return;
this._state = ROOM_STATE.ERROR;
return;
}
} }
nm.joinRoom(this._inputCode); try {
if (!nm.connected) {
const ok = await nm.connect(this._serverUrl);
if (!ok) {
this._errorMsg = t('common.cannotConnect');
this._state = ROOM_STATE.ERROR;
return;
}
}
console.log(`[RoomScene] Auto-joining 1v1 room ${roomId}`);
nm.joinRoom(roomId);
} catch (e) {
console.error('[RoomScene] Auto-join failed:', e);
this._errorMsg = t('common.cannotConnect');
this._state = ROOM_STATE.ERROR;
}
}, },
_goBack() { _goBack() {
+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;
} }
+10 -1011
View File
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

Binary file not shown.

After

Width:  |  Height:  |  Size: 640 KiB

+56 -56
View File
@@ -1,59 +1,59 @@
{ {
"description": "Tank War - WeChat Mini Game", "description": "Tank War - WeChat Mini Game",
"packOptions": { "packOptions": {
"ignore": [ "ignore": [
{ {
"value": ".codebuddy", "value": ".codebuddy",
"type": "folder" "type": "folder"
}, },
{ {
"value": ".gitignore", "value": ".gitignore",
"type": "file" "type": "file"
} }
], ],
"include": [] "include": []
},
"setting": {
"urlCheck": false,
"es6": true,
"enhance": true,
"postcss": true,
"preloadBackgroundData": false,
"minified": true,
"newFeature": true,
"coverView": true,
"nodeModules": false,
"autoAudits": false,
"showShadowRootInWxmlPanel": true,
"scopeDataCheck": false,
"uglifyFileName": false,
"checkInvalidKey": true,
"checkSiteMap": true,
"uploadWithSourceMap": true,
"compileHotReLoad": false,
"babelSetting": {
"ignore": [],
"disablePlugins": [],
"outputPath": ""
}, },
"compileWorklet": false, "setting": {
"packNpmManually": false, "urlCheck": false,
"packNpmRelationList": [], "es6": true,
"minifyWXSS": true, "enhance": true,
"minifyWXML": true, "postcss": true,
"localPlugins": false, "preloadBackgroundData": false,
"disableUseStrict": false, "minified": true,
"useCompilerPlugins": false, "newFeature": true,
"condition": false, "coverView": true,
"swc": false, "nodeModules": false,
"disableSWC": true "autoAudits": false,
}, "showShadowRootInWxmlPanel": true,
"compileType": "game", "scopeDataCheck": false,
"libVersion": "2.25.0", "uglifyFileName": false,
"appid": "wx3527fe2fd49db523", "checkInvalidKey": true,
"projectname": "tankwar", "checkSiteMap": true,
"condition": {}, "uploadWithSourceMap": true,
"simulatorPluginLibVersion": {}, "compileHotReLoad": false,
"isGameTourist": false, "babelSetting": {
"editorSetting": {} "ignore": [],
"disablePlugins": [],
"outputPath": ""
},
"compileWorklet": false,
"packNpmManually": false,
"packNpmRelationList": [],
"minifyWXSS": true,
"minifyWXML": true,
"localPlugins": false,
"disableUseStrict": false,
"useCompilerPlugins": false,
"condition": false,
"swc": false,
"disableSWC": true
},
"compileType": "game",
"libVersion": "3.15.1",
"appid": "wx3527fe2fd49db523",
"projectname": "tankwar",
"condition": {},
"simulatorPluginLibVersion": {},
"isGameTourist": false,
"editorSetting": {}
} }
+1 -1
View File
@@ -1,6 +1,6 @@
{ {
"libVersion": "3.15.1", "libVersion": "3.15.1",
"projectname": "tankwar", "projectname": "tankwar_proj",
"condition": { "condition": {
"game": { "game": {
"list": [ "list": [
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');
});
});