Compare commits
2 Commits
c3a4aa8f15
...
master
| Author | SHA1 | Date | |
|---|---|---|---|
| 4b31ac520d | |||
| e4140f073f |
Vendored
BIN
Binary file not shown.
Vendored
BIN
Binary file not shown.
@@ -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
|
||||||
|
|||||||
@@ -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}" \
|
||||||
|
|||||||
@@ -177,9 +177,10 @@ wx.onShow((res) => {
|
|||||||
|
|
||||||
// Check for teamId from invite card (3v3 mode) or roomId from invite card (1v1 mode)
|
// Check for teamId from invite card (3v3 mode) or roomId from invite card (1v1 mode)
|
||||||
const teamId = _extractTeamId(res && res.query);
|
const teamId = _extractTeamId(res && res.query);
|
||||||
|
const teamMode = _extractTeamMode(res && res.query);
|
||||||
const roomId = _extractRoomId(res && res.query);
|
const roomId = _extractRoomId(res && res.query);
|
||||||
if (teamId) {
|
if (teamId) {
|
||||||
_handleInviteTeamId(teamId);
|
_handleInviteTeamId(teamId, teamMode);
|
||||||
} else if (roomId) {
|
} else if (roomId) {
|
||||||
_handleInviteRoomId(roomId);
|
_handleInviteRoomId(roomId);
|
||||||
} else {
|
} else {
|
||||||
@@ -187,10 +188,11 @@ wx.onShow((res) => {
|
|||||||
try {
|
try {
|
||||||
const launchOptions = wx.getLaunchOptionsSync();
|
const launchOptions = wx.getLaunchOptionsSync();
|
||||||
const fallbackTeamId = _extractTeamId(launchOptions && launchOptions.query);
|
const fallbackTeamId = _extractTeamId(launchOptions && launchOptions.query);
|
||||||
|
const fallbackTeamMode = _extractTeamMode(launchOptions && launchOptions.query);
|
||||||
const fallbackRoomId = _extractRoomId(launchOptions && launchOptions.query);
|
const fallbackRoomId = _extractRoomId(launchOptions && launchOptions.query);
|
||||||
if (fallbackTeamId) {
|
if (fallbackTeamId) {
|
||||||
console.log(`[game.js] onShow query empty, but found teamId in launchOptions: ${fallbackTeamId}`);
|
console.log(`[game.js] onShow query empty, but found teamId in launchOptions: ${fallbackTeamId}`);
|
||||||
_handleInviteTeamId(fallbackTeamId);
|
_handleInviteTeamId(fallbackTeamId, fallbackTeamMode);
|
||||||
} else if (fallbackRoomId) {
|
} else if (fallbackRoomId) {
|
||||||
console.log(`[game.js] onShow query empty, but found roomId in launchOptions: ${fallbackRoomId}`);
|
console.log(`[game.js] onShow query empty, but found roomId in launchOptions: ${fallbackRoomId}`);
|
||||||
_handleInviteRoomId(fallbackRoomId);
|
_handleInviteRoomId(fallbackRoomId);
|
||||||
@@ -242,6 +244,36 @@ function _extractTeamId(query) {
|
|||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Extract mode parameter from query (e.g. mode=2v2 or mode=3v3).
|
||||||
|
* Used to route team invites to the correct room scene.
|
||||||
|
* @param {object|string|undefined} query
|
||||||
|
* @returns {string|null}
|
||||||
|
*/
|
||||||
|
function _extractTeamMode(query) {
|
||||||
|
if (!query) return null;
|
||||||
|
|
||||||
|
if (typeof query === 'object' && query.mode) {
|
||||||
|
return query.mode;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (typeof query === 'string') {
|
||||||
|
const match = query.match(/mode=([^&]+)/);
|
||||||
|
if (match) return match[1];
|
||||||
|
}
|
||||||
|
|
||||||
|
if (typeof query === 'object') {
|
||||||
|
const keys = Object.keys(query);
|
||||||
|
for (const key of keys) {
|
||||||
|
const combined = key + (query[key] ? '=' + query[key] : '');
|
||||||
|
const match = combined.match(/mode=([^&]+)/);
|
||||||
|
if (match) return match[1];
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Extract roomId from query parameter (1v1 invite card).
|
* Extract roomId from query parameter (1v1 invite card).
|
||||||
* Similar to _extractTeamId, but looks for roomId key.
|
* Similar to _extractTeamId, but looks for roomId key.
|
||||||
@@ -309,10 +341,11 @@ function _handleInviteRoomId(roomId) {
|
|||||||
|
|
||||||
/**
|
/**
|
||||||
* Handle teamId from invite card (shared between onShow and cold launch).
|
* Handle teamId from invite card (shared between onShow and cold launch).
|
||||||
* Navigates to TeamRoomScene if possible, otherwise stores as pending.
|
* Routes to Team2v2RoomScene or TeamRoomScene based on mode parameter.
|
||||||
* @param {string} teamId
|
* @param {string} teamId
|
||||||
|
* @param {string|null} mode - '2v2' or '3v3' (default: '3v3')
|
||||||
*/
|
*/
|
||||||
function _handleInviteTeamId(teamId) {
|
function _handleInviteTeamId(teamId, mode) {
|
||||||
if (!teamId) return;
|
if (!teamId) return;
|
||||||
|
|
||||||
// Avoid duplicate processing if already pending the same teamId
|
// Avoid duplicate processing if already pending the same teamId
|
||||||
@@ -321,21 +354,29 @@ function _handleInviteTeamId(teamId) {
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
console.log(`[game.js] Received teamId from invite: ${teamId}, currentScene: ${sceneManager._currentName}`);
|
const is2v2 = mode === '2v2';
|
||||||
|
const targetScene = is2v2 ? SCENE.TEAM_2V2_ROOM : SCENE.TEAM_ROOM;
|
||||||
|
const sceneName = is2v2 ? 'Team2v2RoomScene' : 'TeamRoomScene';
|
||||||
|
|
||||||
// If already past loading, navigate directly to team room
|
console.log(`[game.js] Received teamId from invite: ${teamId}, mode: ${mode || '3v3'}, targetScene: ${targetScene}, currentScene: ${sceneManager._currentName}`);
|
||||||
|
|
||||||
|
// If already past loading, navigate directly to the team room
|
||||||
if (sceneManager._currentScene && sceneManager._currentName !== SCENE.LOADING) {
|
if (sceneManager._currentScene && sceneManager._currentName !== SCENE.LOADING) {
|
||||||
console.log(`[game.js] Navigating directly to TeamRoomScene with teamId: ${teamId}`);
|
console.log(`[game.js] Navigating directly to ${sceneName} with teamId: ${teamId}`);
|
||||||
if (!sceneManager._scenes.has(SCENE.TEAM_ROOM)) {
|
if (!sceneManager._scenes.has(targetScene)) {
|
||||||
const TeamRoomScene = require('./js/scenes/TeamRoomScene');
|
const SceneModule = is2v2
|
||||||
sceneManager.register(SCENE.TEAM_ROOM, TeamRoomScene);
|
? require('./js/scenes/Team2v2RoomScene')
|
||||||
|
: require('./js/scenes/TeamRoomScene');
|
||||||
|
sceneManager.register(targetScene, SceneModule);
|
||||||
}
|
}
|
||||||
sceneManager.switchTo(SCENE.TEAM_ROOM, { teamId });
|
sceneManager.switchTo(targetScene, { teamId });
|
||||||
GameGlobal._pendingTeamId = null;
|
GameGlobal._pendingTeamId = null;
|
||||||
|
GameGlobal._pendingTeamMode = null;
|
||||||
} else {
|
} else {
|
||||||
// Still loading — store pending teamId for auto-navigation after load
|
// Still loading — store pending teamId for auto-navigation after load
|
||||||
console.log(`[game.js] Still loading, storing pendingTeamId: ${teamId}`);
|
console.log(`[game.js] Still loading, storing pendingTeamId: ${teamId}, mode: ${mode || '3v3'}`);
|
||||||
GameGlobal._pendingTeamId = teamId;
|
GameGlobal._pendingTeamId = teamId;
|
||||||
|
GameGlobal._pendingTeamMode = mode || null;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -344,9 +385,10 @@ try {
|
|||||||
const launchOptions = wx.getLaunchOptionsSync();
|
const launchOptions = wx.getLaunchOptionsSync();
|
||||||
console.log(`[game.js] Cold launch options: scene=${launchOptions.scene}, query=${JSON.stringify(launchOptions.query)}, referrerInfo=${JSON.stringify(launchOptions.referrerInfo)}`);
|
console.log(`[game.js] Cold launch options: scene=${launchOptions.scene}, query=${JSON.stringify(launchOptions.query)}, referrerInfo=${JSON.stringify(launchOptions.referrerInfo)}`);
|
||||||
const launchTeamId = _extractTeamId(launchOptions && launchOptions.query);
|
const launchTeamId = _extractTeamId(launchOptions && launchOptions.query);
|
||||||
|
const launchTeamMode = _extractTeamMode(launchOptions && launchOptions.query);
|
||||||
const launchRoomId = _extractRoomId(launchOptions && launchOptions.query);
|
const launchRoomId = _extractRoomId(launchOptions && launchOptions.query);
|
||||||
if (launchTeamId) {
|
if (launchTeamId) {
|
||||||
_handleInviteTeamId(launchTeamId);
|
_handleInviteTeamId(launchTeamId, launchTeamMode);
|
||||||
} else if (launchRoomId) {
|
} else if (launchRoomId) {
|
||||||
_handleInviteRoomId(launchRoomId);
|
_handleInviteRoomId(launchRoomId);
|
||||||
} else {
|
} else {
|
||||||
|
|||||||
Vendored
BIN
Binary file not shown.
@@ -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,
|
||||||
|
|||||||
@@ -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;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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
@@ -38,8 +38,11 @@ class Tank {
|
|||||||
this.alive = true;
|
this.alive = true;
|
||||||
this.visible = true;
|
this.visible = true;
|
||||||
|
|
||||||
|
// Collision size (can differ from visual size for large tanks like Boss)
|
||||||
|
this.colliderSize = config.colliderSize || this.size;
|
||||||
// Half-size for collision calculations
|
// Half-size for collision calculations
|
||||||
this.halfSize = this.size / 2;
|
this.halfSize = this.size / 2;
|
||||||
|
this.colliderHalfSize = this.colliderSize / 2;
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -70,10 +73,10 @@ class Tank {
|
|||||||
|
|
||||||
// Clamp to map boundaries instead of rejecting movement entirely.
|
// Clamp to map boundaries instead of rejecting movement entirely.
|
||||||
// This allows the tank to slide along the edge smoothly.
|
// This allows the tank to slide along the edge smoothly.
|
||||||
const minX = MAP_OFFSET_X + this.halfSize;
|
const minX = MAP_OFFSET_X + this.colliderHalfSize;
|
||||||
const minY = MAP_OFFSET_Y + this.halfSize;
|
const minY = MAP_OFFSET_Y + this.colliderHalfSize;
|
||||||
const maxX = MAP_OFFSET_X + MAP_WIDTH - this.halfSize;
|
const maxX = MAP_OFFSET_X + MAP_WIDTH - this.colliderHalfSize;
|
||||||
const maxY = MAP_OFFSET_Y + MAP_HEIGHT - this.halfSize;
|
const maxY = MAP_OFFSET_Y + MAP_HEIGHT - this.colliderHalfSize;
|
||||||
|
|
||||||
newX = Math.max(minX, Math.min(newX, maxX));
|
newX = Math.max(minX, Math.min(newX, maxX));
|
||||||
newY = Math.max(minY, Math.min(newY, maxY));
|
newY = Math.max(minY, Math.min(newY, maxY));
|
||||||
@@ -84,11 +87,11 @@ class Tank {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// Calculate bounding box at clamped position
|
// Calculate bounding box at clamped position
|
||||||
const left = newX - this.halfSize;
|
const left = newX - this.colliderHalfSize;
|
||||||
const top = newY - this.halfSize;
|
const top = newY - this.colliderHalfSize;
|
||||||
|
|
||||||
// Terrain collision check
|
// Terrain collision check
|
||||||
if (mapManager && mapManager.rectCollidesWithTerrain(left, top, this.size, this.size)) {
|
if (mapManager && mapManager.rectCollidesWithTerrain(left, top, this.colliderSize, this.colliderSize)) {
|
||||||
// Try to align to grid for smoother movement along walls
|
// Try to align to grid for smoother movement along walls
|
||||||
return this._tryAlignedMove(dir, dt, mapManager);
|
return this._tryAlignedMove(dir, dt, mapManager);
|
||||||
}
|
}
|
||||||
@@ -107,10 +110,10 @@ class Tank {
|
|||||||
*/
|
*/
|
||||||
_snapToGrid(oldDir) {
|
_snapToGrid(oldDir) {
|
||||||
const halfTile = TILE_SIZE / 2;
|
const halfTile = TILE_SIZE / 2;
|
||||||
const minX = MAP_OFFSET_X + this.halfSize;
|
const minX = MAP_OFFSET_X + this.colliderHalfSize;
|
||||||
const minY = MAP_OFFSET_Y + this.halfSize;
|
const minY = MAP_OFFSET_Y + this.colliderHalfSize;
|
||||||
const maxX = MAP_OFFSET_X + MAP_WIDTH - this.halfSize;
|
const maxX = MAP_OFFSET_X + MAP_WIDTH - this.colliderHalfSize;
|
||||||
const maxY = MAP_OFFSET_Y + MAP_HEIGHT - this.halfSize;
|
const maxY = MAP_OFFSET_Y + MAP_HEIGHT - this.colliderHalfSize;
|
||||||
|
|
||||||
if (oldDir === DIRECTION.UP || oldDir === DIRECTION.DOWN) {
|
if (oldDir === DIRECTION.UP || oldDir === DIRECTION.DOWN) {
|
||||||
// Was moving vertically → snap Y to nearest grid-cell center
|
// Was moving vertically → snap Y to nearest grid-cell center
|
||||||
@@ -146,6 +149,8 @@ class Tank {
|
|||||||
const moveAmount = this.speed * dt * 60;
|
const moveAmount = this.speed * dt * 60;
|
||||||
const vec = DIR_VECTORS[dir];
|
const vec = DIR_VECTORS[dir];
|
||||||
const halfTile = TILE_SIZE / 2;
|
const halfTile = TILE_SIZE / 2;
|
||||||
|
const colliderHS = this.colliderHalfSize;
|
||||||
|
const colliderS = this.colliderSize;
|
||||||
|
|
||||||
if (dir === DIRECTION.UP || dir === DIRECTION.DOWN) {
|
if (dir === DIRECTION.UP || dir === DIRECTION.DOWN) {
|
||||||
// Moving vertically but blocked — try to slide horizontally into a gap
|
// Moving vertically but blocked — try to slide horizontally into a gap
|
||||||
@@ -164,14 +169,14 @@ class Tank {
|
|||||||
// Check whether moving in the desired direction would be clear at this aligned X
|
// Check whether moving in the desired direction would be clear at this aligned X
|
||||||
const testX = alignedX;
|
const testX = alignedX;
|
||||||
const testY = this.y + vec.dy * moveAmount;
|
const testY = this.y + vec.dy * moveAmount;
|
||||||
const left = testX - this.halfSize;
|
const left = testX - colliderHS;
|
||||||
const top = testY - this.halfSize;
|
const top = testY - colliderHS;
|
||||||
if (
|
if (
|
||||||
left >= MAP_OFFSET_X &&
|
left >= MAP_OFFSET_X &&
|
||||||
top >= MAP_OFFSET_Y &&
|
top >= MAP_OFFSET_Y &&
|
||||||
left + this.size <= MAP_OFFSET_X + MAP_WIDTH &&
|
left + colliderS <= MAP_OFFSET_X + MAP_WIDTH &&
|
||||||
top + this.size <= MAP_OFFSET_Y + MAP_HEIGHT &&
|
top + colliderS <= MAP_OFFSET_Y + MAP_HEIGHT &&
|
||||||
!mapManager.rectCollidesWithTerrain(left, top, this.size, this.size)
|
!mapManager.rectCollidesWithTerrain(left, top, colliderS, colliderS)
|
||||||
) {
|
) {
|
||||||
candidates.push({ alignedX, diffX: Math.abs(diffX) });
|
candidates.push({ alignedX, diffX: Math.abs(diffX) });
|
||||||
}
|
}
|
||||||
@@ -186,7 +191,7 @@ class Tank {
|
|||||||
const slideAmount = Math.min(Math.abs(diffX), moveAmount);
|
const slideAmount = Math.min(Math.abs(diffX), moveAmount);
|
||||||
this.x += Math.sign(diffX) * slideAmount;
|
this.x += Math.sign(diffX) * slideAmount;
|
||||||
// Clamp to map bounds after sliding
|
// Clamp to map bounds after sliding
|
||||||
this.x = Math.max(MAP_OFFSET_X + this.halfSize, Math.min(this.x, MAP_OFFSET_X + MAP_WIDTH - this.halfSize));
|
this.x = Math.max(MAP_OFFSET_X + colliderHS, Math.min(this.x, MAP_OFFSET_X + MAP_WIDTH - colliderHS));
|
||||||
return false;
|
return false;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -198,7 +203,7 @@ class Tank {
|
|||||||
const slideAmount = Math.min(Math.abs(diffX), moveAmount);
|
const slideAmount = Math.min(Math.abs(diffX), moveAmount);
|
||||||
this.x += Math.sign(diffX) * slideAmount;
|
this.x += Math.sign(diffX) * slideAmount;
|
||||||
// Clamp to map bounds after sliding
|
// Clamp to map bounds after sliding
|
||||||
this.x = Math.max(MAP_OFFSET_X + this.halfSize, Math.min(this.x, MAP_OFFSET_X + MAP_WIDTH - this.halfSize));
|
this.x = Math.max(MAP_OFFSET_X + colliderHS, Math.min(this.x, MAP_OFFSET_X + MAP_WIDTH - colliderHS));
|
||||||
}
|
}
|
||||||
} else {
|
} else {
|
||||||
// Moving horizontally but blocked — try to slide vertically into a gap
|
// Moving horizontally but blocked — try to slide vertically into a gap
|
||||||
@@ -214,14 +219,14 @@ class Tank {
|
|||||||
if (Math.abs(diffY) < TILE_SIZE * 0.55) {
|
if (Math.abs(diffY) < TILE_SIZE * 0.55) {
|
||||||
const testX = this.x + vec.dx * moveAmount;
|
const testX = this.x + vec.dx * moveAmount;
|
||||||
const testY = alignedY;
|
const testY = alignedY;
|
||||||
const left = testX - this.halfSize;
|
const left = testX - colliderHS;
|
||||||
const top = testY - this.halfSize;
|
const top = testY - colliderHS;
|
||||||
if (
|
if (
|
||||||
left >= MAP_OFFSET_X &&
|
left >= MAP_OFFSET_X &&
|
||||||
top >= MAP_OFFSET_Y &&
|
top >= MAP_OFFSET_Y &&
|
||||||
left + this.size <= MAP_OFFSET_X + MAP_WIDTH &&
|
left + colliderS <= MAP_OFFSET_X + MAP_WIDTH &&
|
||||||
top + this.size <= MAP_OFFSET_Y + MAP_HEIGHT &&
|
top + colliderS <= MAP_OFFSET_Y + MAP_HEIGHT &&
|
||||||
!mapManager.rectCollidesWithTerrain(left, top, this.size, this.size)
|
!mapManager.rectCollidesWithTerrain(left, top, colliderS, colliderS)
|
||||||
) {
|
) {
|
||||||
candidates.push({ alignedY, diffY: Math.abs(diffY) });
|
candidates.push({ alignedY, diffY: Math.abs(diffY) });
|
||||||
}
|
}
|
||||||
@@ -235,7 +240,7 @@ class Tank {
|
|||||||
const slideAmount = Math.min(Math.abs(diffY), moveAmount);
|
const slideAmount = Math.min(Math.abs(diffY), moveAmount);
|
||||||
this.y += Math.sign(diffY) * slideAmount;
|
this.y += Math.sign(diffY) * slideAmount;
|
||||||
// Clamp to map bounds after sliding
|
// Clamp to map bounds after sliding
|
||||||
this.y = Math.max(MAP_OFFSET_Y + this.halfSize, Math.min(this.y, MAP_OFFSET_Y + MAP_HEIGHT - this.halfSize));
|
this.y = Math.max(MAP_OFFSET_Y + colliderHS, Math.min(this.y, MAP_OFFSET_Y + MAP_HEIGHT - colliderHS));
|
||||||
return false;
|
return false;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -247,7 +252,7 @@ class Tank {
|
|||||||
const slideAmount = Math.min(Math.abs(diffY), moveAmount);
|
const slideAmount = Math.min(Math.abs(diffY), moveAmount);
|
||||||
this.y += Math.sign(diffY) * slideAmount;
|
this.y += Math.sign(diffY) * slideAmount;
|
||||||
// Clamp to map bounds after sliding
|
// Clamp to map bounds after sliding
|
||||||
this.y = Math.max(MAP_OFFSET_Y + this.halfSize, Math.min(this.y, MAP_OFFSET_Y + MAP_HEIGHT - this.halfSize));
|
this.y = Math.max(MAP_OFFSET_Y + colliderHS, Math.min(this.y, MAP_OFFSET_Y + MAP_HEIGHT - colliderHS));
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -370,10 +375,10 @@ class Tank {
|
|||||||
*/
|
*/
|
||||||
getBounds() {
|
getBounds() {
|
||||||
return {
|
return {
|
||||||
x: this.x - this.halfSize,
|
x: this.x - this.colliderHalfSize,
|
||||||
y: this.y - this.halfSize,
|
y: this.y - this.colliderHalfSize,
|
||||||
w: this.size,
|
w: this.colliderSize,
|
||||||
h: this.size,
|
h: this.colliderSize,
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
+42
-5
@@ -21,15 +21,24 @@ module.exports = {
|
|||||||
// Menu Scene
|
// Menu Scene
|
||||||
// ============================================================
|
// ============================================================
|
||||||
'menu.title': 'Tank Adventure',
|
'menu.title': 'Tank Adventure',
|
||||||
'menu.subtitle': 'TANK WAR',
|
'menu.subtitle': 'TANK WAR · Battle with Friends',
|
||||||
'menu.classic': 'Classic',
|
'menu.classic': 'Classic Mode',
|
||||||
'menu.endless': 'Endless',
|
'menu.classic.sub': 'Classic tank battle',
|
||||||
'menu.pvp': 'PVP',
|
'menu.endless': 'Endless Mode',
|
||||||
'menu.team3v3': '3v3 Battle',
|
'menu.endless.sub': 'Push your limits',
|
||||||
|
'menu.pvp': '1v1 Duel',
|
||||||
|
'menu.pvp.sub': 'Winner Takes All.',
|
||||||
|
'menu.team2v2': '2v2 Brawl',
|
||||||
|
'menu.team2v2.sub': 'Co-op strategy wins',
|
||||||
|
'menu.team3v3': '3v3 Team Battle',
|
||||||
|
'menu.team3v3.sub': 'Teamwork dominates the battlefield',
|
||||||
'menu.shop': 'Shop',
|
'menu.shop': 'Shop',
|
||||||
'menu.skin': 'Skins',
|
'menu.skin': 'Skins',
|
||||||
|
'menu.skin.sub': 'Many skins to choose from',
|
||||||
'menu.ranking': 'Ranking',
|
'menu.ranking': 'Ranking',
|
||||||
|
'menu.ranking.sub': 'Climb the leaderboard',
|
||||||
'menu.settings': 'Settings',
|
'menu.settings': 'Settings',
|
||||||
|
'menu.settings.sub': 'Customize your experience',
|
||||||
'menu.chat': 'Chat',
|
'menu.chat': 'Chat',
|
||||||
'menu.tapToAuth': 'Tap to authorize',
|
'menu.tapToAuth': 'Tap to authorize',
|
||||||
|
|
||||||
@@ -47,6 +56,33 @@ module.exports = {
|
|||||||
'room.starting': 'Game starting...',
|
'room.starting': 'Game starting...',
|
||||||
'room.tapBack': 'Tap anywhere to go back',
|
'room.tapBack': 'Tap anywhere to go back',
|
||||||
|
|
||||||
|
// ============================================================
|
||||||
|
// Team 2v2 Room Scene
|
||||||
|
// ============================================================
|
||||||
|
'team2v2Room.title': '2v2 Brawl',
|
||||||
|
'team2v2Room.chooseMode': 'Choose how to play',
|
||||||
|
'team2v2Room.createTeam': '🎮 Create Team',
|
||||||
|
'team2v2Room.soloMatch': '⚡ Quick Match',
|
||||||
|
'team2v2Room.teamId': 'Team: {id}',
|
||||||
|
'team2v2Room.leader': 'Leader',
|
||||||
|
'team2v2Room.ready': '✓ Ready',
|
||||||
|
'team2v2Room.notReady': 'Not Ready',
|
||||||
|
'team2v2Room.emptySlot': 'Empty',
|
||||||
|
'team2v2Room.invite': '📨 Invite',
|
||||||
|
'team2v2Room.startMatch': '🔍 Start Match',
|
||||||
|
'team2v2Room.disband': 'Disband',
|
||||||
|
'team2v2Room.readyBtn': '✓ Ready',
|
||||||
|
'team2v2Room.cancelReady': 'Cancel Ready',
|
||||||
|
'team2v2Room.leaveTeam': 'Leave Team',
|
||||||
|
'team2v2Room.matching': 'Matching{dots}',
|
||||||
|
'team2v2Room.waitTime': 'Waited {seconds}s',
|
||||||
|
'team2v2Room.cancelMatch': 'Cancel Match',
|
||||||
|
'team2v2Room.matchFound': 'Match found!',
|
||||||
|
'team2v2Room.enterBattle': 'Entering battle...',
|
||||||
|
'team2v2Room.tapBack': 'Tap anywhere to go back',
|
||||||
|
'team2v2Room.shareTitle': 'Tank 2v2, join the brawl!',
|
||||||
|
'team2v2Room.joining': 'Joining room',
|
||||||
|
|
||||||
// ============================================================
|
// ============================================================
|
||||||
// Team Room Scene (3v3)
|
// Team Room Scene (3v3)
|
||||||
// ============================================================
|
// ============================================================
|
||||||
@@ -295,6 +331,7 @@ module.exports = {
|
|||||||
// Daily Gold
|
// Daily Gold
|
||||||
// ============================================================
|
// ============================================================
|
||||||
'dailyGold.btn': '🪙 Get Gold',
|
'dailyGold.btn': '🪙 Get Gold',
|
||||||
|
'dailyGold.desc': 'Daily reward',
|
||||||
'dailyGold.remaining': '{remaining}/3',
|
'dailyGold.remaining': '{remaining}/3',
|
||||||
'dailyGold.exhausted': 'Come back tomorrow',
|
'dailyGold.exhausted': 'Come back tomorrow',
|
||||||
'dailyGold.reward': '+100 Gold!',
|
'dailyGold.reward': '+100 Gold!',
|
||||||
|
|||||||
+41
-4
@@ -21,22 +21,31 @@ module.exports = {
|
|||||||
// Menu Scene
|
// Menu Scene
|
||||||
// ============================================================
|
// ============================================================
|
||||||
'menu.title': '坦克探险',
|
'menu.title': '坦克探险',
|
||||||
'menu.subtitle': '经典坦克对战',
|
'menu.subtitle': '经典坦克对战 · 兄弟集结开黑',
|
||||||
'menu.classic': '经典模式',
|
'menu.classic': '经典模式',
|
||||||
|
'menu.classic.sub': '经典坦克对战',
|
||||||
'menu.endless': '无尽模式',
|
'menu.endless': '无尽模式',
|
||||||
'menu.pvp': '双人对战',
|
'menu.endless.sub': '挑战极限,突破自我',
|
||||||
'menu.team3v3': '3v3 对战',
|
'menu.pvp': '1v1 决斗',
|
||||||
|
'menu.pvp.sub': '单挑对决,谁与争锋',
|
||||||
|
'menu.team2v2': '2v2 激斗',
|
||||||
|
'menu.team2v2.sub': '双人协作,策略制胜',
|
||||||
|
'menu.team3v3': '3v3 团战',
|
||||||
|
'menu.team3v3.sub': '团队协作,称霸战场',
|
||||||
'menu.shop': '商店',
|
'menu.shop': '商店',
|
||||||
'menu.skin': '皮肤',
|
'menu.skin': '皮肤',
|
||||||
|
'menu.skin.sub': '多款皮肤任你选',
|
||||||
'menu.ranking': '排行榜',
|
'menu.ranking': '排行榜',
|
||||||
|
'menu.ranking.sub': '冲击榜单,赢取荣誉',
|
||||||
'menu.settings': '设置',
|
'menu.settings': '设置',
|
||||||
|
'menu.settings.sub': '个性设置,畅快体验',
|
||||||
'menu.chat': '聊天室',
|
'menu.chat': '聊天室',
|
||||||
'menu.tapToAuth': '点击授权头像',
|
'menu.tapToAuth': '点击授权头像',
|
||||||
|
|
||||||
// ============================================================
|
// ============================================================
|
||||||
// Room Scene (PVP)
|
// Room Scene (PVP)
|
||||||
// ============================================================
|
// ============================================================
|
||||||
'room.title': '双人对战',
|
'room.title': '1v1决斗',
|
||||||
'room.connecting': '连接中{dots}',
|
'room.connecting': '连接中{dots}',
|
||||||
'room.roomCode': '房间号:',
|
'room.roomCode': '房间号:',
|
||||||
'room.waiting': '等待对手加入{dots}',
|
'room.waiting': '等待对手加入{dots}',
|
||||||
@@ -47,6 +56,33 @@ module.exports = {
|
|||||||
'room.starting': '即将开始...',
|
'room.starting': '即将开始...',
|
||||||
'room.tapBack': '点击任意位置返回',
|
'room.tapBack': '点击任意位置返回',
|
||||||
|
|
||||||
|
// ============================================================
|
||||||
|
// Team 2v2 Room Scene
|
||||||
|
// ============================================================
|
||||||
|
'team2v2Room.title': '2v2 激斗',
|
||||||
|
'team2v2Room.chooseMode': '选择游戏方式',
|
||||||
|
'team2v2Room.createTeam': '🎮 组队开黑',
|
||||||
|
'team2v2Room.soloMatch': '⚡ 快速匹配',
|
||||||
|
'team2v2Room.teamId': '队伍:{id}',
|
||||||
|
'team2v2Room.leader': '队长',
|
||||||
|
'team2v2Room.ready': '✓ 已准备',
|
||||||
|
'team2v2Room.notReady': '未准备',
|
||||||
|
'team2v2Room.emptySlot': '空位',
|
||||||
|
'team2v2Room.invite': '📨 邀请好友',
|
||||||
|
'team2v2Room.startMatch': '🔍 开始匹配',
|
||||||
|
'team2v2Room.disband': '解散队伍',
|
||||||
|
'team2v2Room.readyBtn': '✓ 准备',
|
||||||
|
'team2v2Room.cancelReady': '取消准备',
|
||||||
|
'team2v2Room.leaveTeam': '退出队伍',
|
||||||
|
'team2v2Room.matching': '匹配中{dots}',
|
||||||
|
'team2v2Room.waitTime': '已等待 {seconds} 秒',
|
||||||
|
'team2v2Room.cancelMatch': '取消匹配',
|
||||||
|
'team2v2Room.matchFound': '对手已找到!',
|
||||||
|
'team2v2Room.enterBattle': '即将进入战斗...',
|
||||||
|
'team2v2Room.tapBack': '点击任意位置返回',
|
||||||
|
'team2v2Room.shareTitle': '坦克2v2,速来开黑!',
|
||||||
|
'team2v2Room.joining': '正在加入房间',
|
||||||
|
|
||||||
// ============================================================
|
// ============================================================
|
||||||
// Team Room Scene (3v3)
|
// Team Room Scene (3v3)
|
||||||
// ============================================================
|
// ============================================================
|
||||||
@@ -295,6 +331,7 @@ module.exports = {
|
|||||||
// Daily Gold
|
// Daily Gold
|
||||||
// ============================================================
|
// ============================================================
|
||||||
'dailyGold.btn': '🪙 领金币',
|
'dailyGold.btn': '🪙 领金币',
|
||||||
|
'dailyGold.desc': '每日领取奖励',
|
||||||
'dailyGold.remaining': '{remaining}/3',
|
'dailyGold.remaining': '{remaining}/3',
|
||||||
'dailyGold.exhausted': '明日再来',
|
'dailyGold.exhausted': '明日再来',
|
||||||
'dailyGold.reward': '+100 金币!',
|
'dailyGold.reward': '+100 金币!',
|
||||||
|
|||||||
@@ -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;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -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
@@ -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 || '',
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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) {
|
||||||
|
|||||||
+795
-138
File diff suppressed because it is too large
Load Diff
@@ -38,7 +38,7 @@ const ROOM_STATE = {
|
|||||||
// Room Scene
|
// Room Scene
|
||||||
// ============================================================
|
// ============================================================
|
||||||
const RoomScene = {
|
const RoomScene = {
|
||||||
_state: ROOM_STATE.IDLE,
|
_state: ROOM_STATE.CREATING,
|
||||||
_roomCode: '',
|
_roomCode: '',
|
||||||
_errorMsg: '',
|
_errorMsg: '',
|
||||||
_countdown: 3,
|
_countdown: 3,
|
||||||
@@ -152,7 +152,7 @@ const RoomScene = {
|
|||||||
}));
|
}));
|
||||||
|
|
||||||
unsubs.push(nm.on('disconnected', () => {
|
unsubs.push(nm.on('disconnected', () => {
|
||||||
if (this._state !== ROOM_STATE.IDLE) {
|
if (this._state !== ROOM_STATE.CREATING) {
|
||||||
this._errorMsg = t('common.disconnected');
|
this._errorMsg = t('common.disconnected');
|
||||||
this._state = ROOM_STATE.ERROR;
|
this._state = ROOM_STATE.ERROR;
|
||||||
}
|
}
|
||||||
@@ -198,7 +198,7 @@ const RoomScene = {
|
|||||||
if (shareManager) {
|
if (shareManager) {
|
||||||
shareManager.setShareContent({
|
shareManager.setShareContent({
|
||||||
title: t('room.shareTitle'),
|
title: t('room.shareTitle'),
|
||||||
imageUrl: '',
|
imageUrl: 'js/ui/images/1v1.png',
|
||||||
query: `roomId=${this._roomCode}`,
|
query: `roomId=${this._roomCode}`,
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
@@ -214,7 +214,7 @@ const RoomScene = {
|
|||||||
|
|
||||||
const shareData = {
|
const shareData = {
|
||||||
title: t('room.shareTitle'),
|
title: t('room.shareTitle'),
|
||||||
imageUrl: '',
|
imageUrl: 'js/ui/images/1v1.png',
|
||||||
query: `roomId=${this._roomCode}`,
|
query: `roomId=${this._roomCode}`,
|
||||||
};
|
};
|
||||||
|
|
||||||
@@ -240,7 +240,7 @@ const RoomScene = {
|
|||||||
|
|
||||||
/**
|
/**
|
||||||
* One-step invite: auto-create room then immediately share the invite card.
|
* One-step invite: auto-create room then immediately share the invite card.
|
||||||
* This is the primary action on the IDLE screen — user clicks "Invite Friend"
|
* This is the primary action — automatically creates a room and triggers invite share
|
||||||
* and we handle everything in one go.
|
* and we handle everything in one go.
|
||||||
* MUST be called within a touch event for WeChat policy compliance.
|
* MUST be called within a touch event for WeChat policy compliance.
|
||||||
* @private
|
* @private
|
||||||
@@ -280,7 +280,7 @@ const RoomScene = {
|
|||||||
|
|
||||||
const shareData = {
|
const shareData = {
|
||||||
title: t('room.shareTitle'),
|
title: t('room.shareTitle'),
|
||||||
imageUrl: '',
|
imageUrl: 'js/ui/images/1v1.png',
|
||||||
query: `roomId=${roomId}`,
|
query: `roomId=${roomId}`,
|
||||||
};
|
};
|
||||||
|
|
||||||
@@ -505,7 +505,7 @@ const RoomScene = {
|
|||||||
|
|
||||||
switch (this._state) {
|
switch (this._state) {
|
||||||
case ROOM_STATE.ERROR:
|
case ROOM_STATE.ERROR:
|
||||||
this._state = ROOM_STATE.IDLE;
|
this._state = ROOM_STATE.CREATING;
|
||||||
this._errorMsg = '';
|
this._errorMsg = '';
|
||||||
break;
|
break;
|
||||||
|
|
||||||
|
|||||||
@@ -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;
|
||||||
@@ -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
File diff suppressed because it is too large
Load Diff
@@ -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 |
Vendored
BIN
Binary file not shown.
+20
-4
@@ -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);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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');
|
||||||
|
});
|
||||||
|
});
|
||||||
Reference in New Issue
Block a user