diff --git a/.DS_Store b/.DS_Store index 9946d37..120c2ea 100644 Binary files a/.DS_Store and b/.DS_Store differ diff --git a/content-security-service/.DS_Store b/content-security-service/.DS_Store new file mode 100644 index 0000000..526f649 Binary files /dev/null and b/content-security-service/.DS_Store differ diff --git a/deploy/.DS_Store b/deploy/.DS_Store new file mode 100644 index 0000000..bafd9bd Binary files /dev/null and b/deploy/.DS_Store differ diff --git a/deploy/k8s/deployment.yaml b/deploy/k8s/deployment.yaml index bc8acf0..4b81ee2 100644 --- a/deploy/k8s/deployment.yaml +++ b/deploy/k8s/deployment.yaml @@ -25,7 +25,7 @@ spec: containers: - name: tankwar-server image: tankwar-server:latest - imagePullPolicy: IfNotPresent + imagePullPolicy: Never ports: - name: ws containerPort: 3000 diff --git a/deploy/k8s/scripts/run-deploy.sh b/deploy/k8s/scripts/run-deploy.sh index f30ae75..cfcfdb5 100755 --- a/deploy/k8s/scripts/run-deploy.sh +++ b/deploy/k8s/scripts/run-deploy.sh @@ -50,7 +50,6 @@ step_sync() { ssh_master "mkdir -p ${REMOTE_WORKDIR}/server ${REMOTE_WORKDIR}/deploy/k8s" rsync -az --delete \ - --exclude 'node_modules' \ --exclude '.git' \ --exclude '.DS_Store' \ -e "ssh ${SSH_OPTS}" \ diff --git a/game.js b/game.js index 6a0b7ed..a88178f 100644 --- a/game.js +++ b/game.js @@ -177,9 +177,10 @@ wx.onShow((res) => { // Check for teamId from invite card (3v3 mode) or roomId from invite card (1v1 mode) const teamId = _extractTeamId(res && res.query); + const teamMode = _extractTeamMode(res && res.query); const roomId = _extractRoomId(res && res.query); if (teamId) { - _handleInviteTeamId(teamId); + _handleInviteTeamId(teamId, teamMode); } else if (roomId) { _handleInviteRoomId(roomId); } else { @@ -187,10 +188,11 @@ wx.onShow((res) => { try { const launchOptions = wx.getLaunchOptionsSync(); const fallbackTeamId = _extractTeamId(launchOptions && launchOptions.query); + const fallbackTeamMode = _extractTeamMode(launchOptions && launchOptions.query); const fallbackRoomId = _extractRoomId(launchOptions && launchOptions.query); if (fallbackTeamId) { console.log(`[game.js] onShow query empty, but found teamId in launchOptions: ${fallbackTeamId}`); - _handleInviteTeamId(fallbackTeamId); + _handleInviteTeamId(fallbackTeamId, fallbackTeamMode); } else if (fallbackRoomId) { console.log(`[game.js] onShow query empty, but found roomId in launchOptions: ${fallbackRoomId}`); _handleInviteRoomId(fallbackRoomId); @@ -242,6 +244,36 @@ function _extractTeamId(query) { 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). * 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). - * Navigates to TeamRoomScene if possible, otherwise stores as pending. + * Routes to Team2v2RoomScene or TeamRoomScene based on mode parameter. * @param {string} teamId + * @param {string|null} mode - '2v2' or '3v3' (default: '3v3') */ -function _handleInviteTeamId(teamId) { +function _handleInviteTeamId(teamId, mode) { if (!teamId) return; // Avoid duplicate processing if already pending the same teamId @@ -321,21 +354,29 @@ function _handleInviteTeamId(teamId) { 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) { - console.log(`[game.js] Navigating directly to TeamRoomScene with teamId: ${teamId}`); - if (!sceneManager._scenes.has(SCENE.TEAM_ROOM)) { - const TeamRoomScene = require('./js/scenes/TeamRoomScene'); - sceneManager.register(SCENE.TEAM_ROOM, TeamRoomScene); + console.log(`[game.js] Navigating directly to ${sceneName} with teamId: ${teamId}`); + if (!sceneManager._scenes.has(targetScene)) { + const SceneModule = is2v2 + ? 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._pendingTeamMode = null; } else { // 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._pendingTeamMode = mode || null; } } @@ -344,9 +385,10 @@ try { const launchOptions = wx.getLaunchOptionsSync(); 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 launchTeamMode = _extractTeamMode(launchOptions && launchOptions.query); const launchRoomId = _extractRoomId(launchOptions && launchOptions.query); if (launchTeamId) { - _handleInviteTeamId(launchTeamId); + _handleInviteTeamId(launchTeamId, launchTeamMode); } else if (launchRoomId) { _handleInviteRoomId(launchRoomId); } else { diff --git a/js/.DS_Store b/js/.DS_Store new file mode 100644 index 0000000..01cd01a Binary files /dev/null and b/js/.DS_Store differ diff --git a/js/base/GameGlobal.js b/js/base/GameGlobal.js index e714fd3..16bf55a 100644 --- a/js/base/GameGlobal.js +++ b/js/base/GameGlobal.js @@ -102,6 +102,7 @@ const TANK_CONFIG = { hp: 6, color: '#8B0000', // dark red size: TILE_SIZE * 1.2, + colliderSize: TILE_SIZE * 0.85, score: 500, }, }; @@ -172,6 +173,7 @@ const SCENE = { TEAM_ROOM: 'team_room', TEAM_GAME: 'team_game', TEAM_RESULT: 'team_result', + TEAM_2V2_ROOM: 'team_2v2_room', CHAT_ROOM: 'chat_room', }; @@ -182,6 +184,7 @@ const GAME_MODE = { CLASSIC: 'classic', ENDLESS: 'endless', PVP: 'pvp', + TEAM_2V2: 'team_2v2', TEAM_3V3: 'team_3v3', }; @@ -221,6 +224,13 @@ const BATTLE_CONFIG = { fillWithBots: false, mapPool: 'pvp', }, + '2v2': { + teamSize: 2, + baseHp: 8, + respawnDelay: TEAM_RESPAWN_DELAY, + fillWithBots: true, + mapPool: 'team', + }, '3v3': { teamSize: 3, baseHp: TEAM_BASE_HP, diff --git a/js/entities/BotTank.js b/js/entities/BotTank.js index 4897081..22df7de 100644 --- a/js/entities/BotTank.js +++ b/js/entities/BotTank.js @@ -45,6 +45,7 @@ class BotTank extends Tank { hp: cfg.hp, color: params.color || (params.team === 'A' ? '#4A90D9' : '#E94560'), size: cfg.size, + colliderSize: cfg.colliderSize || cfg.size, direction: params.team === 'A' ? DIRECTION.RIGHT : DIRECTION.LEFT, }); @@ -215,10 +216,10 @@ class BotTank extends Tank { const vec = DIR_VECTORS[dir]; const testX = this.x + vec.dx * TILE_SIZE; const testY = this.y + vec.dy * TILE_SIZE; - const left = testX - this.halfSize; - const top = testY - this.halfSize; + const left = testX - this.colliderHalfSize; + 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; return; } diff --git a/js/entities/EnemyTank.js b/js/entities/EnemyTank.js index 30e709c..fe75a60 100644 --- a/js/entities/EnemyTank.js +++ b/js/entities/EnemyTank.js @@ -45,6 +45,7 @@ class EnemyTank extends Tank { hp: cfg.hp, color: cfg.color, size: cfg.size, + colliderSize: cfg.colliderSize || cfg.size, direction: DIRECTION.DOWN, }); @@ -176,10 +177,10 @@ class EnemyTank extends Tank { const vec = DIR_VECTORS[dir]; const testX = this.x + vec.dx * TILE_SIZE; const testY = this.y + vec.dy * TILE_SIZE; - const left = testX - this.halfSize; - const top = testY - this.halfSize; + const left = testX - this.colliderHalfSize; + 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; return; } diff --git a/js/entities/Tank.js b/js/entities/Tank.js index 88775e8..2824461 100644 --- a/js/entities/Tank.js +++ b/js/entities/Tank.js @@ -38,8 +38,11 @@ class Tank { this.alive = 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 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. // This allows the tank to slide along the edge smoothly. - const minX = MAP_OFFSET_X + this.halfSize; - const minY = MAP_OFFSET_Y + this.halfSize; - const maxX = MAP_OFFSET_X + MAP_WIDTH - this.halfSize; - const maxY = MAP_OFFSET_Y + MAP_HEIGHT - this.halfSize; + const minX = MAP_OFFSET_X + this.colliderHalfSize; + const minY = MAP_OFFSET_Y + this.colliderHalfSize; + const maxX = MAP_OFFSET_X + MAP_WIDTH - this.colliderHalfSize; + const maxY = MAP_OFFSET_Y + MAP_HEIGHT - this.colliderHalfSize; newX = Math.max(minX, Math.min(newX, maxX)); newY = Math.max(minY, Math.min(newY, maxY)); @@ -84,11 +87,11 @@ class Tank { } // Calculate bounding box at clamped position - const left = newX - this.halfSize; - const top = newY - this.halfSize; + const left = newX - this.colliderHalfSize; + const top = newY - this.colliderHalfSize; // 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 return this._tryAlignedMove(dir, dt, mapManager); } @@ -107,10 +110,10 @@ class Tank { */ _snapToGrid(oldDir) { const halfTile = TILE_SIZE / 2; - const minX = MAP_OFFSET_X + this.halfSize; - const minY = MAP_OFFSET_Y + this.halfSize; - const maxX = MAP_OFFSET_X + MAP_WIDTH - this.halfSize; - const maxY = MAP_OFFSET_Y + MAP_HEIGHT - this.halfSize; + const minX = MAP_OFFSET_X + this.colliderHalfSize; + const minY = MAP_OFFSET_Y + this.colliderHalfSize; + const maxX = MAP_OFFSET_X + MAP_WIDTH - this.colliderHalfSize; + const maxY = MAP_OFFSET_Y + MAP_HEIGHT - this.colliderHalfSize; if (oldDir === DIRECTION.UP || oldDir === DIRECTION.DOWN) { // Was moving vertically → snap Y to nearest grid-cell center @@ -146,6 +149,8 @@ class Tank { const moveAmount = this.speed * dt * 60; const vec = DIR_VECTORS[dir]; const halfTile = TILE_SIZE / 2; + const colliderHS = this.colliderHalfSize; + const colliderS = this.colliderSize; if (dir === DIRECTION.UP || dir === DIRECTION.DOWN) { // 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 const testX = alignedX; const testY = this.y + vec.dy * moveAmount; - const left = testX - this.halfSize; - const top = testY - this.halfSize; + const left = testX - colliderHS; + const top = testY - colliderHS; if ( left >= MAP_OFFSET_X && top >= MAP_OFFSET_Y && - left + this.size <= MAP_OFFSET_X + MAP_WIDTH && - top + this.size <= MAP_OFFSET_Y + MAP_HEIGHT && - !mapManager.rectCollidesWithTerrain(left, top, this.size, this.size) + left + colliderS <= MAP_OFFSET_X + MAP_WIDTH && + top + colliderS <= MAP_OFFSET_Y + MAP_HEIGHT && + !mapManager.rectCollidesWithTerrain(left, top, colliderS, colliderS) ) { candidates.push({ alignedX, diffX: Math.abs(diffX) }); } @@ -186,7 +191,7 @@ class Tank { const slideAmount = Math.min(Math.abs(diffX), moveAmount); this.x += Math.sign(diffX) * slideAmount; // 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; } @@ -198,7 +203,7 @@ class Tank { const slideAmount = Math.min(Math.abs(diffX), moveAmount); this.x += Math.sign(diffX) * slideAmount; // 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 { // 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) { const testX = this.x + vec.dx * moveAmount; const testY = alignedY; - const left = testX - this.halfSize; - const top = testY - this.halfSize; + const left = testX - colliderHS; + const top = testY - colliderHS; if ( left >= MAP_OFFSET_X && top >= MAP_OFFSET_Y && - left + this.size <= MAP_OFFSET_X + MAP_WIDTH && - top + this.size <= MAP_OFFSET_Y + MAP_HEIGHT && - !mapManager.rectCollidesWithTerrain(left, top, this.size, this.size) + left + colliderS <= MAP_OFFSET_X + MAP_WIDTH && + top + colliderS <= MAP_OFFSET_Y + MAP_HEIGHT && + !mapManager.rectCollidesWithTerrain(left, top, colliderS, colliderS) ) { candidates.push({ alignedY, diffY: Math.abs(diffY) }); } @@ -235,7 +240,7 @@ class Tank { const slideAmount = Math.min(Math.abs(diffY), moveAmount); this.y += Math.sign(diffY) * slideAmount; // 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; } @@ -247,7 +252,7 @@ class Tank { const slideAmount = Math.min(Math.abs(diffY), moveAmount); this.y += Math.sign(diffY) * slideAmount; // 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() { return { - x: this.x - this.halfSize, - y: this.y - this.halfSize, - w: this.size, - h: this.size, + x: this.x - this.colliderHalfSize, + y: this.y - this.colliderHalfSize, + w: this.colliderSize, + h: this.colliderSize, }; } diff --git a/js/i18n/en.js b/js/i18n/en.js index 75af6e1..7b2fb4e 100644 --- a/js/i18n/en.js +++ b/js/i18n/en.js @@ -21,15 +21,24 @@ module.exports = { // Menu Scene // ============================================================ 'menu.title': 'Tank Adventure', - 'menu.subtitle': 'TANK WAR', - 'menu.classic': 'Classic', - 'menu.endless': 'Endless', - 'menu.pvp': 'PVP', - 'menu.team3v3': '3v3 Battle', + 'menu.subtitle': 'TANK WAR · Battle with Friends', + 'menu.classic': 'Classic Mode', + 'menu.classic.sub': 'Classic tank battle', + 'menu.endless': 'Endless Mode', + '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.skin': 'Skins', + 'menu.skin.sub': 'Many skins to choose from', 'menu.ranking': 'Ranking', + 'menu.ranking.sub': 'Climb the leaderboard', 'menu.settings': 'Settings', + 'menu.settings.sub': 'Customize your experience', 'menu.chat': 'Chat', 'menu.tapToAuth': 'Tap to authorize', @@ -47,6 +56,33 @@ module.exports = { 'room.starting': 'Game starting...', '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) // ============================================================ @@ -295,6 +331,7 @@ module.exports = { // Daily Gold // ============================================================ 'dailyGold.btn': '🪙 Get Gold', + 'dailyGold.desc': 'Daily reward', 'dailyGold.remaining': '{remaining}/3', 'dailyGold.exhausted': 'Come back tomorrow', 'dailyGold.reward': '+100 Gold!', diff --git a/js/i18n/zh.js b/js/i18n/zh.js index fa05e48..038bf2a 100644 --- a/js/i18n/zh.js +++ b/js/i18n/zh.js @@ -21,22 +21,31 @@ module.exports = { // Menu Scene // ============================================================ 'menu.title': '坦克探险', - 'menu.subtitle': '经典坦克对战', + 'menu.subtitle': '经典坦克对战 · 兄弟集结开黑', 'menu.classic': '经典模式', + 'menu.classic.sub': '经典坦克对战', 'menu.endless': '无尽模式', - 'menu.pvp': '双人对战', - 'menu.team3v3': '3v3 对战', + 'menu.endless.sub': '挑战极限,突破自我', + 'menu.pvp': '1v1 决斗', + 'menu.pvp.sub': '单挑对决,谁与争锋', + 'menu.team2v2': '2v2 激斗', + 'menu.team2v2.sub': '双人协作,策略制胜', + 'menu.team3v3': '3v3 团战', + 'menu.team3v3.sub': '团队协作,称霸战场', 'menu.shop': '商店', 'menu.skin': '皮肤', + 'menu.skin.sub': '多款皮肤任你选', 'menu.ranking': '排行榜', + 'menu.ranking.sub': '冲击榜单,赢取荣誉', 'menu.settings': '设置', + 'menu.settings.sub': '个性设置,畅快体验', 'menu.chat': '聊天室', 'menu.tapToAuth': '点击授权头像', // ============================================================ // Room Scene (PVP) // ============================================================ - 'room.title': '双人对战', + 'room.title': '1v1决斗', 'room.connecting': '连接中{dots}', 'room.roomCode': '房间号:', 'room.waiting': '等待对手加入{dots}', @@ -47,6 +56,33 @@ module.exports = { 'room.starting': '即将开始...', '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) // ============================================================ @@ -295,6 +331,7 @@ module.exports = { // Daily Gold // ============================================================ 'dailyGold.btn': '🪙 领金币', + 'dailyGold.desc': '每日领取奖励', 'dailyGold.remaining': '{remaining}/3', 'dailyGold.exhausted': '明日再来', 'dailyGold.reward': '+100 金币!', diff --git a/js/managers/CollisionManager.js b/js/managers/CollisionManager.js index b9f1a14..bc3e4b0 100644 --- a/js/managers/CollisionManager.js +++ b/js/managers/CollisionManager.js @@ -262,7 +262,7 @@ class CollisionManager { * @private */ _isPositionValid(tank, x, y) { - const hs = tank.halfSize; + const hs = tank.colliderHalfSize; const left = x - hs; const top = y - hs; const right = x + hs; @@ -279,7 +279,7 @@ class CollisionManager { } // 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; } diff --git a/js/managers/NetworkManager.js b/js/managers/NetworkManager.js index 4ad50fd..b0ed1e1 100644 --- a/js/managers/NetworkManager.js +++ b/js/managers/NetworkManager.js @@ -46,6 +46,10 @@ class NetworkManager { // Generate a unique player ID 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) { return new Promise((resolve) => { - if (this._connected || this._connecting) { - resolve(this._connected); + // If already connected, resolve immediately + 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; } @@ -85,6 +96,13 @@ class NetworkManager { console.warn('[NetworkManager] connect() failed:', reason || 'unknown'); } 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). @@ -96,7 +114,6 @@ class NetworkManager { this._ws = wx.connectSocket({ url: serverUrl, 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); }, fail: (err) => { console.error('[NetworkManager] wx.connectSocket API failed:', JSON.stringify(err)); @@ -119,16 +136,13 @@ class NetworkManager { }); this._ws.onError((err) => { - // Log as much context as possible; wx error objects vary across platforms. console.error('[NetworkManager] WebSocket error:', (err && (err.errMsg || err.message)) || err, 'url=', serverUrl); this._emit('error', err); - // If the error arrives before we ever got onOpen, treat it as a connect failure. if (!this._connected) { finish(false, `onError before open: ${err && (err.errMsg || err.message)}`); } else { - // Runtime error on an established connection — let onClose handle reconnection. this._connecting = false; } }); @@ -144,13 +158,11 @@ class NetworkManager { this._stopHeartbeat(); this._emit('disconnected', { code, reason }); - // If onClose arrives before onOpen, this is a connect failure. if (!wasConnected) { finish(false, `onClose before open: code=${code} reason=${reason}`); return; } - // Auto-reconnect only for drops on an already-established connection. if (this._shouldReconnect && this._reconnectAttempts < this._maxReconnectAttempts) { this._attemptReconnect(); } @@ -183,6 +195,13 @@ class NetworkManager { this._connecting = false; this._roomId = null; 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. + * @param {string} [battleMode='3v3'] - Battle mode ('1v1', '2v2', '3v3'). */ - createTeam() { + createTeam(battleMode = '3v3') { this.send(NET_MSG.CREATE_TEAM, { playerId: this._playerId, + battleMode, }); } @@ -350,10 +371,12 @@ class NetworkManager { /** * Start solo matchmaking for 3v3. + * @param {string} [battleMode='3v3'] - Battle mode ('1v1', '2v2', '3v3'). */ - soloMatch() { + soloMatch(battleMode = '3v3') { this.send(NET_MSG.SOLO_MATCH, { playerId: this._playerId, + battleMode, }); } diff --git a/js/managers/ShareManager.js b/js/managers/ShareManager.js index a3f1666..08b64cd 100644 --- a/js/managers/ShareManager.js +++ b/js/managers/ShareManager.js @@ -11,6 +11,8 @@ class ShareManager { imageUrl: '', query: '', }; + // Cached temp file path from last canvas capture + this._cachedImageUrl = ''; // Register share menu and callback ONCE at startup. // The callback reads this._shareContent dynamically so it always @@ -28,7 +30,7 @@ class ShareManager { console.log('[ShareManager] onShareAppMessage callback, query:', this._shareContent.query); return { title: this._shareContent.title || '坦克大战 - 一起来战斗吧!', - imageUrl: this._shareContent.imageUrl || '', + imageUrl: this._shareContent.imageUrl || this._cachedImageUrl || '', 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. * @param {number} score @@ -90,6 +223,10 @@ class ShareManager { setShareContent(opts) { this._shareContent = opts || {}; 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 this._refreshShareCallback(); } @@ -106,6 +243,9 @@ class ShareManager { // Update passive share callback (right-corner ··· menu fallback) 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. // This is permitted because triggerShare is called from a touchstart handler. try { @@ -113,7 +253,7 @@ class ShareManager { console.log('[ShareManager] Calling wx.shareAppMessage with query:', data.query); wx.shareAppMessage({ title: data.title || '', - imageUrl: data.imageUrl || '', + imageUrl: imageUrl, query: data.query || '', }); } diff --git a/js/scenes/BuffSelectScene.js b/js/scenes/BuffSelectScene.js index b5608d2..c36898e 100644 --- a/js/scenes/BuffSelectScene.js +++ b/js/scenes/BuffSelectScene.js @@ -200,7 +200,10 @@ const BuffSelectScene = { const GameScene = require('./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) { diff --git a/js/scenes/MenuScene.js b/js/scenes/MenuScene.js index fbfa0aa..7eb444e 100644 --- a/js/scenes/MenuScene.js +++ b/js/scenes/MenuScene.js @@ -14,76 +14,102 @@ const { const { t } = require('../i18n/I18n'); // ============================================================ -// Style +// Style — Military-tech theme matching reference design // ============================================================ const MC = { - BG_TOP: '#0b0e17', - BG_BOT: '#141b2d', + BG_TOP: '#0a0e1a', + BG_BOT: '#111827', ACCENT: '#e94560', GOLD: '#FFD700', + GOLD_LIGHT: '#FFF3A8', GOLD_DIM: '#B8860B', - BTN_BG: '#16213e', - BTN_BORDER: '#1e3054', - BTN_HOVER: '#0f3460', - BTN_TEXT: '#E8E8E8', + BTN_BG: '#162844', + BTN_BG_HIGHLIGHT: '#1a3555', + BG_GRAD_START: '#142236', + BG_GRAD_END: '#0c1320', + BTN_BORDER_GLOW: 'rgba(70,140,220,0.4)', + BTN_BORDER: 'rgba(50,100,170,0.5)', + BTN_HOVER: '#244a75', + BTN_TEXT_PRIMARY: '#FFFFFF', + BTN_TEXT_SECONDARY: '#88AACC', TITLE: '#FFD700', - SUBTITLE: '#8899AA', + SUBTITLE_BG: 'rgba(25,55,95,0.85)', + SUBTITLE_BORDER: '#3a68a0', + SUBTITLE_TEXT: '#7CB9E8', FOOTER: '#445566', - TANK_BODY: '#FFD700', - TANK_TRACK: '#B8860B', + ICON_GOLD: '#FFD700', + ICON_BLUE: '#4A90D9', + HOT_BADGE: '#e94560', + UTIL_GREEN: '#1a4a2e', }; // ============================================================ -// Button Layout +// Layout — Left-Right Split (Reference Design) +// +// ┌─ Left Panel (~54%) ─┬─ Right Panel (~43%) ─┐ +// │ Tank visual area │ 5 mode buttons │ +// │ + Title + Subtitle │ (vertical stack) │ +// │ + Promo text │ │ +// ├──────────────────────┴───────────────────────┤ +// │ Bottom Utility Bar (4 columns) │ +// └───────────────────────────────────────────────┘ // ============================================================ -const BTN_WIDTH = Math.min(SCREEN_WIDTH * 0.55, 280); -const BTN_HEIGHT = Math.min(36, SCREEN_HEIGHT * 0.07); -const BTN_GAP = Math.min(8, SCREEN_HEIGHT * 0.015); -const BTN_START_Y = SCREEN_HEIGHT * 0.38; -const BTN_X = (SCREEN_WIDTH - BTN_WIDTH) / 2; -const HALF_BTN_WIDTH = (BTN_WIDTH - BTN_GAP) / 2; -const THIRD_BTN_WIDTH = (BTN_WIDTH - BTN_GAP * 2) / 3; +const PAD = Math.max(12, SCREEN_WIDTH * 0.03); +const TOP_BAR_H = 48; +const SPLIT_X = SCREEN_WIDTH * 0.55; +const RIGHT_PAD = Math.max(14, PAD * 2); + +// --- Right Panel: Mode Buttons (vertical) --- +const MODE_INNER_PAD = PAD; // inner left padding inside right panel +const MODE_BTN_W = SCREEN_WIDTH - SPLIT_X - RIGHT_PAD - MODE_INNER_PAD; +const MODE_BTN_H = Math.min(66, SCREEN_HEIGHT * 0.095); +const MODE_GAP = Math.max(10, SCREEN_HEIGHT * 0.015); +const MODE_START_Y = TOP_BAR_H + SCREEN_HEIGHT * 0.035; + +// --- Bottom Utility Bar --- +const UTIL_BAR_Y = SCREEN_HEIGHT * 0.805; +const UTIL_BAR_H = Math.min(58, SCREEN_HEIGHT * 0.088); +const UTIL_EDGE_PAD = Math.max(24, SCREEN_WIDTH * 0.058); +const UTIL_GAP = Math.max(14, SCREEN_WIDTH * 0.025); +const UTIL_COL_W = (SCREEN_WIDTH - UTIL_EDGE_PAD * 2 - UTIL_GAP * 3) / 4; + +// ============================================================ +// Button Definitions +// ============================================================ -// Main game mode buttons (full width, vertical) const MAIN_BUTTONS = [ - { labelKey: 'menu.classic', mode: GAME_MODE.CLASSIC, scene: SCENE.GAME }, - { labelKey: 'menu.endless', mode: GAME_MODE.ENDLESS, scene: SCENE.GAME }, - { labelKey: 'menu.pvp', mode: GAME_MODE.PVP, scene: SCENE.PVP_ROOM }, - { labelKey: 'menu.team3v3', mode: GAME_MODE.TEAM_3V3, scene: SCENE.TEAM_ROOM }, + { labelKey: 'menu.classic', subKey: 'menu.classic.sub', icon: 'trophy', mode: GAME_MODE.CLASSIC, scene: SCENE.GAME }, + { labelKey: 'menu.endless', subKey: 'menu.endless.sub', icon: 'target', mode: GAME_MODE.ENDLESS, scene: SCENE.GAME }, + { labelKey: 'menu.pvp', subKey: 'menu.pvp.sub', icon: 'swords', mode: GAME_MODE.PVP, scene: SCENE.PVP_ROOM }, + { labelKey: 'menu.team2v2', subKey: 'menu.team2v2.sub', icon: 'swords', mode: GAME_MODE.TEAM_2V2, scene: SCENE.TEAM_2V2_ROOM }, + { labelKey: 'menu.team3v3', subKey: 'menu.team3v3.sub', icon: 'shield', mode: GAME_MODE.TEAM_3V3, scene: SCENE.TEAM_ROOM, hot: true }, ]; -// Utility buttons: daily gold, skin, ranking, settings (grid) -// NOTE: Shop button is temporarily disabled const UTIL_BUTTONS = [ - { labelKey: 'dailyGold.btn', mode: null, scene: 'DAILY_GOLD' }, - { labelKey: 'menu.skin', mode: null, scene: SCENE.SKIN }, - { labelKey: 'menu.ranking', mode: null, scene: SCENE.RANKING }, - { labelKey: 'menu.settings', mode: null, scene: SCENE.SETTINGS }, + { labelKey: 'dailyGold.btn', subKey: 'dailyGold.desc', icon: 'coins', mode: null, scene: 'DAILY_GOLD' }, + { labelKey: 'menu.skin', subKey: 'menu.skin.sub', icon: 'skin', mode: null, scene: SCENE.SKIN }, + { labelKey: 'menu.ranking', subKey: 'menu.ranking.sub', icon: 'ranking', mode: null, scene: SCENE.RANKING }, + { labelKey: 'menu.settings', subKey: 'menu.settings.sub', icon: 'settings', mode: null, scene: SCENE.SETTINGS }, ]; -// Pre-calculate button rects for main buttons +// Pre-calculate mode button rects (right panel, vertical stack) const mainBtnRects = MAIN_BUTTONS.map((btn, i) => ({ - x: BTN_X, - y: BTN_START_Y + i * (BTN_HEIGHT + BTN_GAP), - w: BTN_WIDTH, - h: BTN_HEIGHT, + x: SPLIT_X + MODE_INNER_PAD, + y: MODE_START_Y + i * (MODE_BTN_H + MODE_GAP), + w: MODE_BTN_W, + h: MODE_BTN_H, ...btn, })); -// Pre-calculate button rects for utility buttons (2 rows x 2 cols) -const utilStartY = BTN_START_Y + MAIN_BUTTONS.length * (BTN_HEIGHT + BTN_GAP) + BTN_GAP; -const utilBtnRects = UTIL_BUTTONS.map((btn, i) => { - const row = Math.floor(i / 2); - const col = i % 2; - return { - x: BTN_X + col * (HALF_BTN_WIDTH + BTN_GAP), - y: utilStartY + row * (BTN_HEIGHT + BTN_GAP), - w: HALF_BTN_WIDTH, - h: BTN_HEIGHT, - ...btn, - }; -}); +// Pre-calculate utility button rects (bottom row) +const utilBtnRects = UTIL_BUTTONS.map((btn, i) => ({ + x: UTIL_EDGE_PAD + i * (UTIL_COL_W + UTIL_GAP), + y: UTIL_BAR_Y, + w: UTIL_COL_W, + h: UTIL_BAR_H, + ...btn, +})); const buttonRects = [...mainBtnRects, ...utilBtnRects]; @@ -94,10 +120,17 @@ const MenuScene = { _pressedIndex: -1, _tankAnim: 0, + // Background image + _bgImage: null, + enter() { this._pressedIndex = -1; this._tankAnim = 0; this._avatarImg = null; + this._bgImage = null; + + // Load background image + this._loadBgImage(); // Load avatar image if profile has one const profile = GameGlobal.playerProfile; @@ -122,16 +155,23 @@ const MenuScene = { if (GameGlobal._pendingTeamId) { const teamId = GameGlobal._pendingTeamId; + const teamMode = GameGlobal._pendingTeamMode || null; GameGlobal._pendingTeamId = null; - console.log(`[MenuScene] Found pendingTeamId: ${teamId}, will auto-navigate to TeamRoomScene`); + GameGlobal._pendingTeamMode = null; + const is2v2 = teamMode === '2v2'; + const targetScene = is2v2 ? SCENE.TEAM_2V2_ROOM : SCENE.TEAM_ROOM; + const sceneName = is2v2 ? 'Team2v2RoomScene' : 'TeamRoomScene'; + console.log(`[MenuScene] Found pendingTeamId: ${teamId}, mode: ${teamMode || '3v3'}, will auto-navigate to ${sceneName}`); setTimeout(() => { - console.log(`[MenuScene] Auto-navigating to TeamRoomScene with teamId: ${teamId}`); + console.log(`[MenuScene] Auto-navigating to ${sceneName} with teamId: ${teamId}`); const sm = GameGlobal.sceneManager; - if (!sm._scenes.has(SCENE.TEAM_ROOM)) { - const TeamRoomScene = require('./TeamRoomScene'); - sm.register(SCENE.TEAM_ROOM, TeamRoomScene); + if (!sm._scenes.has(targetScene)) { + const SceneModule = is2v2 + ? require('./Team2v2RoomScene') + : require('./TeamRoomScene'); + sm.register(targetScene, SceneModule); } - sm.switchTo(SCENE.TEAM_ROOM, { teamId }); + sm.switchTo(targetScene, { teamId }); }, 100); } @@ -172,20 +212,41 @@ const MenuScene = { }, render(ctx) { - // ---- Background ---- - const bgGrad = ctx.createLinearGradient(0, 0, 0, SCREEN_HEIGHT); - bgGrad.addColorStop(0, MC.BG_TOP); - bgGrad.addColorStop(1, MC.BG_BOT); - ctx.fillStyle = bgGrad; - ctx.fillRect(0, 0, SCREEN_WIDTH, SCREEN_HEIGHT); + // ---- Background (image or fallback gradient) ---- + if (this._bgImage) { + // Cover-fit: scale image to fill entire screen, center-crop if aspect differs + const imgW = this._bgImage.width; + const imgH = this._bgImage.height; + const imgRatio = imgW / imgH; + const screenRatio = SCREEN_WIDTH / SCREEN_HEIGHT; + var drawW, drawH, dx, dy; + if (imgRatio > screenRatio) { + drawH = SCREEN_HEIGHT; + drawW = SCREEN_HEIGHT * imgRatio; + dx = (SCREEN_WIDTH - drawW) / 2; + dy = 0; + } else { + drawW = SCREEN_WIDTH; + drawH = SCREEN_WIDTH / imgRatio; + dx = 0; + dy = (SCREEN_HEIGHT - drawH) / 2; + } + ctx.drawImage(this._bgImage, dx, dy, drawW, drawH); + } else { + const bgGrad = ctx.createLinearGradient(0, 0, 0, SCREEN_HEIGHT); + bgGrad.addColorStop(0, MC.BG_TOP); + bgGrad.addColorStop(1, MC.BG_BOT); + ctx.fillStyle = bgGrad; + ctx.fillRect(0, 0, SCREEN_WIDTH, SCREEN_HEIGHT); - // Scan-lines - ctx.globalAlpha = 0.025; - ctx.fillStyle = '#FFFFFF'; - for (let sy = 0; sy < SCREEN_HEIGHT; sy += 4) { - ctx.fillRect(0, sy, SCREEN_WIDTH, 1); + // Scan-lines + ctx.globalAlpha = 0.02; + ctx.fillStyle = '#FFFFFF'; + for (let sy = 0; sy < SCREEN_HEIGHT; sy += 4) { + ctx.fillRect(0, sy, SCREEN_WIDTH, 1); + } + ctx.globalAlpha = 1; } - ctx.globalAlpha = 1; // Top accent bar const accentGrad = ctx.createLinearGradient(0, 0, SCREEN_WIDTH, 0); @@ -196,30 +257,127 @@ const MenuScene = { ctx.fillStyle = accentGrad; ctx.fillRect(0, 0, SCREEN_WIDTH, 3); - // ---- Player Avatar & Nickname (top-left) ---- + // ============================================================ + // LEFT PANEL — Branding area (~54% width) + // - Tank icon (animated) + // - Title + subtitle tag + // - Decorative treasure icon + // - Promotional text + // ============================================================ + const leftCenterX = SPLIT_X * 0.5; + const leftCenterY = SCREEN_HEIGHT * 0.48; + + // ---- Animated Tank Icon (larger, as visual anchor) ---- + ctx.save(); + ctx.translate(leftCenterX * 0.65, leftCenterY - 10); + const tankScale = 1.6; + ctx.scale(tankScale, tankScale); + ctx.translate(-leftCenterX * 0.65 / tankScale, (-leftCenterY + 10) / tankScale); + this._drawTankIcon(ctx, leftCenterX * 0.65, (leftCenterY - 10) / tankScale); + ctx.restore(); + + // ---- Title (positioned in upper-right of left panel) ---- + const titleX = SPLIT_X * 0.52; + const titleY = TOP_BAR_H + SCREEN_HEIGHT * 0.04; + ctx.save(); + ctx.shadowColor = MC.GOLD; + ctx.shadowBlur = 22; + ctx.fillStyle = MC.TITLE; + const titleSize = Math.max(Math.min(SPLIT_X * 0.13, 38), 26); + ctx.font = `bold ${titleSize}px Arial`; + ctx.textAlign = 'center'; + ctx.textBaseline = 'middle'; + ctx.fillText(t('menu.title'), titleX, titleY); + + // Subtitle tag below title + ctx.shadowBlur = 0; + const subText = t('menu.subtitle'); + const sFont = '11px Arial'; + ctx.font = sFont; + const stw = ctx.measureText(subText).width; + const stagW = Math.max(stw + 20, 120); + const stagH = 24; + const stagX = titleX - stagW / 2; + const stagY = titleY + titleSize * 0.58; + + ctx.fillStyle = MC.SUBTITLE_BG; + ctx.strokeStyle = MC.SUBTITLE_BORDER; + ctx.lineWidth = 1.2; + this._roundRect(ctx, stagX, stagY, stagW, stagH, stagH / 2); + ctx.fill(); + ctx.stroke(); + + ctx.fillStyle = MC.SUBTITLE_TEXT; + ctx.font = sFont; + ctx.fillText(subText, titleX, stagY + stagH / 2); + ctx.restore(); + + // ---- Promotional Text (bottom-left) ---- + ctx.save(); + const promoY = UTIL_BAR_Y - 38; + const promoX = PAD * 3.2; + + // "3v3 团队竞技" style text + ctx.fillStyle = '#4A9EFF'; + ctx.font = `bold ${Math.max(16, SCREEN_WIDTH * 0.042)}px Arial`; + ctx.textAlign = 'left'; + ctx.textBaseline = 'bottom'; + + // Add subtle glow + ctx.shadowColor = '#4A9EFF'; + ctx.shadowBlur = 8; + const promoLine1 = '3v3 团队竞技'; + ctx.fillText(promoLine1, promoX, promoY); + + // "随时随地开一局!" style text + ctx.shadowBlur = 0; + ctx.fillStyle = '#FFD54F'; + ctx.font = `bold ${Math.max(14, SCREEN_WIDTH * 0.037)}px Arial`; + const promoLine2 = '随时随地开一局!'; + ctx.fillText(promoLine2, promoX, promoY + 32); + ctx.restore(); + + // ============================================================ + // RIGHT PANEL — Mode buttons (vertical stack) + // ============================================================ + for (let i = 0; i < mainBtnRects.length; i++) { + const btn = mainBtnRects[i]; + const isPressed = this._pressedIndex === i; + this._drawModeButton( + ctx, btn, + t(btn.labelKey), + isPressed, + null, + t(btn.subKey || ''), + btn.icon, + !!btn.hot + ); + } + + // ============================================================ + // TOP BAR — Avatar + Gold pill + // ============================================================ + // Player Avatar & Nickname (top-left) const profile = GameGlobal.playerProfile; const avatarSize = 28; - const avatarX = 10; - const avatarY = 10; + const avatarX = PAD; + const avatarY = 8; const avatarR = avatarSize / 2; - // Avatar circle background ctx.save(); ctx.beginPath(); ctx.arc(avatarX + avatarR, avatarY + avatarR, avatarR, 0, Math.PI * 2); ctx.closePath(); - ctx.fillStyle = 'rgba(30,48,84,0.7)'; + ctx.fillStyle = 'rgba(30,48,84,0.75)'; ctx.fill(); - ctx.strokeStyle = 'rgba(255,215,0,0.4)'; - ctx.lineWidth = 1; + ctx.strokeStyle = 'rgba(255,215,0,0.45)'; + ctx.lineWidth = 1.2; ctx.stroke(); - // Avatar image or default icon if (profile && profile.avatarUrl && this._avatarImg && this._avatarImg.complete) { ctx.clip(); ctx.drawImage(this._avatarImg, avatarX, avatarY, avatarSize, avatarSize); } else { - // Default user icon (simple silhouette) ctx.fillStyle = 'rgba(255,215,0,0.5)'; ctx.beginPath(); ctx.arc(avatarX + avatarR, avatarY + avatarR - 2, avatarR * 0.35, 0, Math.PI * 2); @@ -230,131 +388,603 @@ const MenuScene = { } ctx.restore(); - // Nickname + // Nickname next to avatar const displayName = profile ? profile.getDisplayName() : 'Tanker'; ctx.font = 'bold 11px Arial'; ctx.fillStyle = MC.GOLD; ctx.textAlign = 'left'; ctx.textBaseline = 'middle'; ctx.fillText(displayName, avatarX + avatarSize + 6, avatarY + avatarR - 5); - - // Hint text (only if not yet granted) if (profile && !profile.granted) { ctx.font = '9px Arial'; - ctx.fillStyle = MC.SUBTITLE; + ctx.fillStyle = MC.FOOTER; ctx.fillText(t('menu.tapToAuth') || 'Tap to authorize', avatarX + avatarSize + 6, avatarY + avatarR + 8); } - // ---- Gold Balance (top-right pill) ---- + // Gold Balance pill (to the right of nickname) const gold = GameGlobal.currencyManager ? GameGlobal.currencyManager.getGold() : 0; const goldText = `🪙 ${gold}`; ctx.font = 'bold 12px Arial'; const gtw = ctx.measureText(goldText).width; - const pillW = gtw + 16; + const pillW = gtw + 14; const pillH = 22; - const pillX = SCREEN_WIDTH - pillW - 12; - const pillY = 10; + const pillX = avatarX + avatarSize + 6; // right under nickname + const pillY = avatarY + avatarR + 12; - ctx.fillStyle = 'rgba(255, 215, 0, 0.08)'; - ctx.strokeStyle = 'rgba(255, 215, 0, 0.3)'; + ctx.fillStyle = 'rgba(255,215,0,0.08)'; + ctx.strokeStyle = 'rgba(255,215,0,0.35)'; ctx.lineWidth = 1; this._roundRect(ctx, pillX, pillY, pillW, pillH, pillH / 2); ctx.fill(); ctx.stroke(); - ctx.fillStyle = MC.GOLD; ctx.textAlign = 'center'; ctx.textBaseline = 'middle'; ctx.fillText(goldText, pillX + pillW / 2, pillY + pillH / 2); - // ---- Title with glow ---- - ctx.save(); - ctx.shadowColor = MC.GOLD; - ctx.shadowBlur = 16; - ctx.fillStyle = MC.TITLE; - ctx.font = 'bold 34px Arial'; - ctx.textAlign = 'center'; - ctx.textBaseline = 'middle'; - ctx.fillText(t('menu.title'), SCREEN_WIDTH / 2, SCREEN_HEIGHT * 0.15); - ctx.restore(); - - // ---- Subtitle ---- - ctx.fillStyle = MC.SUBTITLE; - ctx.font = '13px Arial'; - ctx.textAlign = 'center'; - ctx.fillText(t('menu.subtitle'), SCREEN_WIDTH / 2, SCREEN_HEIGHT * 0.22); - - // ---- Animated Tank Icon ---- - this._drawTankIcon(ctx, SCREEN_WIDTH / 2, SCREEN_HEIGHT * 0.30); - - // ---- Main Buttons ---- - for (let i = 0; i < mainBtnRects.length; i++) { - const btn = mainBtnRects[i]; - const isPressed = this._pressedIndex === i; - this._drawMenuButton(ctx, btn, t(btn.labelKey), isPressed, 'bold 16px Arial', 8); - } - - // ---- Utility Buttons ---- + // ============================================================ + // BOTTOM UTILITY BAR — 4 columns + // ============================================================ for (let i = 0; i < utilBtnRects.length; i++) { const btn = utilBtnRects[i]; const globalIdx = mainBtnRects.length + i; const isPressed = this._pressedIndex === globalIdx; - const isDailyGold = btn.scene === 'DAILY_GOLD'; let label = t(btn.labelKey); + let subtitle = t(btn.subKey || ''); let customBg = null; + let iconOverride = btn.icon; - if (isDailyGold) { + if (btn.scene === 'DAILY_GOLD') { const remaining = GameGlobal.adManager ? GameGlobal.adManager.getDailyGoldRemaining() : 0; if (remaining > 0) { label = `${t('dailyGold.btn')} ${remaining}/3`; - customBg = '#1a3a2a'; + subtitle = t('dailyGold.desc') || ''; + customBg = MC.UTIL_GREEN; + iconOverride = 'coins'; } else { - label = t('dailyGold.exhausted') || 'Come back tomorrow'; - customBg = '#2a2a2a'; + label = t('dailyGold.exhausted') || '...'; + subtitle = ''; + customBg = '#222'; + iconOverride = 'coins'; } } - this._drawMenuButton(ctx, btn, label, isPressed, 'bold 13px Arial', 6, customBg); + this._drawUtilButton(ctx, btn, label, subtitle, isPressed, customBg, iconOverride); } - // ---- Footer ---- + // ---- Footer version ---- ctx.fillStyle = MC.FOOTER; - ctx.font = '10px Arial'; + ctx.font = `${Math.max(9, SCREEN_WIDTH * 0.022)}px Arial`; ctx.textAlign = 'center'; - ctx.fillText('v1.0.0', SCREEN_WIDTH / 2, SCREEN_HEIGHT - 18); + ctx.fillText('v1.0.0', SCREEN_WIDTH / 2, SCREEN_HEIGHT - 10); }, - // ---- Menu Button ---- - _drawMenuButton(ctx, btn, label, isPressed, font, radius, customBg) { - const r = radius || 8; + // ---- Mode Button (Right panel: icon + two-line text + optional hot badge) ---- + _drawModeButton(ctx, btn, label, isPressed, font, subtitle, iconType, isHot) { + const r = 10; + ctx.save(); + ctx.globalAlpha = 0.85; + + const iconSz = Math.max(26, Math.min(btn.h * 0.48, 34)); + const pad = Math.max(10, iconSz * 0.38); + const txtOff = iconSz + pad + 6; + const iconX = btn.x + pad; + const iconY = btn.y + (btn.h - iconSz) / 2; + const textX = btn.x + txtOff; // Shadow - ctx.fillStyle = 'rgba(0,0,0,0.2)'; - this._roundRect(ctx, btn.x + 1, btn.y + 2, btn.w, btn.h, r); + ctx.fillStyle = 'rgba(0,0,0,0.35)'; + this._roundRect(ctx, btn.x + 2, btn.y + 3, btn.w, btn.h, r); ctx.fill(); - // Body - ctx.fillStyle = isPressed ? MC.BTN_HOVER : (customBg || MC.BTN_BG); - ctx.strokeStyle = MC.BTN_BORDER; - ctx.lineWidth = 1.5; + // Body gradient — slightly lighter for mode buttons + const bodyGrad = ctx.createLinearGradient(btn.x, btn.y, btn.x, btn.y + btn.h); + if (isPressed) { + bodyGrad.addColorStop(0, '#284a70'); + bodyGrad.addColorStop(1, '#1c3055'); + } else if (isHot) { + bodyGrad.addColorStop(0, '#1a4080'); + bodyGrad.addColorStop(1, '#142d55'); + } else { + bodyGrad.addColorStop(0, MC.BTN_BG_HIGHLIGHT); + bodyGrad.addColorStop(1, MC.BTN_BG); + } + ctx.fillStyle = bodyGrad; + + // Border glow + if (isHot) { + ctx.strokeStyle = 'rgba(100,180,255,0.6)'; + ctx.lineWidth = 1.5; + } else { + ctx.strokeStyle = isPressed ? MC.GOLD : MC.BTN_BORDER_GLOW; + ctx.lineWidth = isPressed ? 2 : 1.2; + } this._roundRect(ctx, btn.x, btn.y, btn.w, btn.h, r); ctx.fill(); ctx.stroke(); - // Label - ctx.fillStyle = isPressed ? MC.TITLE : MC.BTN_TEXT; - ctx.font = font || 'bold 16px Arial'; - ctx.textAlign = 'center'; + // Inner top highlight + if (!isPressed) { + ctx.save(); + ctx.clip(); + const hl = ctx.createLinearGradient(btn.x, btn.y, btn.x, btn.y + btn.h * 0.32); + hl.addColorStop(0, isHot ? 'rgba(100,180,255,0.12)' : 'rgba(255,255,255,0.08)'); + hl.addColorStop(1, 'rgba(255,255,255,0)'); + ctx.fillStyle = hl; + ctx.fillRect(btn.x, btn.y, btn.w, btn.h * 0.32); + ctx.restore(); + } + + // Icon + if (iconType) { + this._drawButtonIcon(ctx, iconX, iconY, iconSz, iconType, isPressed); + } + + // Two-line text — larger fonts with clear vertical separation + const pFont = font || `bold ${Math.max(15, btn.h * 0.32)}px Arial`; + const sFont = `${Math.max(11, btn.h * 0.24)}px Arial`; + + if (subtitle) { + ctx.fillStyle = isPressed ? MC.GOLD_LIGHT : MC.BTN_TEXT_PRIMARY; + ctx.font = pFont; + ctx.textAlign = 'left'; + ctx.textBaseline = 'middle'; + ctx.fillText(label, textX, btn.y + btn.h * 0.33); + + ctx.fillStyle = isPressed ? 'rgba(200,220,255,0.85)' : MC.BTN_TEXT_SECONDARY; + ctx.font = sFont; + ctx.fillText(subtitle, textX, btn.y + btn.h * 0.73); + } else { + ctx.fillStyle = isPressed ? MC.TITLE : MC.BTN_TEXT_PRIMARY; + ctx.font = pFont; + ctx.textAlign = 'center'; + ctx.textBaseline = 'middle'; + ctx.fillText(label, btn.x + btn.w / 2, btn.y + btn.h / 2); + } + + // Hot badge ("火爆" style corner badge) + if (isHot && !isPressed) { + const badgeW = 34; + const badgeH = 18; + const bx = btn.x + btn.w - badgeW - 2; + const by = btn.y + 2; + + ctx.save(); + ctx.fillStyle = MC.HOT_BADGE; + this._roundRect(ctx, bx, by, badgeW, badgeH, 4); + ctx.fill(); + + ctx.fillStyle = '#FFFFFF'; + ctx.font = 'bold 10px Arial'; + ctx.textAlign = 'center'; + ctx.textBaseline = 'middle'; + ctx.fillText('HOT', bx + badgeW / 2, by + badgeH / 2); + ctx.restore(); + } + + ctx.restore(); + }, + + /** + * Draw decorative treasure/chest icon in left panel. + */ + _drawTreasureIcon(ctx, cx, cy) { + const pulse = 0.5 + 0.5 * Math.sin(this._tankAnim * 1.8); + const size = 36; + ctx.save(); + + // Glow halo + ctx.globalAlpha = 0.12 + pulse * 0.1; + ctx.fillStyle = MC.GOLD; + ctx.beginPath(); + ctx.arc(cx, cy, size * 0.7, 0, Math.PI * 2); + ctx.fill(); + ctx.globalAlpha = 1; + + // Chest body (rounded rectangle with metallic look) + const bw = size * 0.85; + const bh = size * 0.65; + const br = 5; + + // Shadow + ctx.fillStyle = 'rgba(0,0,0,0.25)'; + this._roundRect(ctx, cx - bw / 2 + 2, cy - bh / 2 + 3, bw, bh, br); + ctx.fill(); + + // Body gradient + const chestGrad = ctx.createLinearGradient(cx - bw / 2, cy - bh / 2, cx - bw / 2, cy + bh / 2); + chestGrad.addColorStop(0, '#FFD54F'); + chestGrad.addColorStop(0.3, MC.GOLD); + chestGrad.addColorStop(0.7, '#DAA520'); + chestGrad.addColorStop(1, '#B8860B'); + + ctx.save(); + ctx.shadowColor = MC.GOLD; + ctx.shadowBlur = 12 + pulse * 5; + ctx.fillStyle = chestGrad; + this._roundRect(ctx, cx - bw / 2, cy - bh / 2, bw, bh, br); + ctx.fill(); + ctx.restore(); + + // Edge outline + ctx.strokeStyle = 'rgba(255,235,150,0.6)'; + ctx.lineWidth = 1; + this._roundRect(ctx, cx - bw / 2, cy - bh / 2, bw, bh, br); + ctx.stroke(); + + // Center lock/keyhole area + const lockR = size * 0.14; + ctx.fillStyle = '#7A5A0A'; + ctx.beginPath(); + ctx.arc(cx, cy, lockR, 0, Math.PI * 2); + ctx.fill(); + + // Keyhole inner + ctx.fillStyle = '#FFF3A8'; + ctx.beginPath(); + ctx.arc(cx, cy - 1, lockR * 0.45, 0, Math.PI * 2); + ctx.fill(); + ctx.fillRect(cx - 1.5, cy, 3, lockR * 0.6); + + // Horizontal lid line + ctx.strokeStyle = 'rgba(120,80,0,0.5)'; + ctx.lineWidth = 2; + ctx.beginPath(); + ctx.moveTo(cx - bw * 0.38, cy - 2); + ctx.lineTo(cx - lockR * 1.3, cy - 2); + ctx.moveTo(cx + lockR * 1.3, cy - 2); + ctx.lineTo(cx + bw * 0.38, cy - 2); + ctx.stroke(); + + // Decorative rivets + ctx.fillStyle = '#B8860B'; + for (const [rx, ry] of [[-bw * 0.32, -bh * 0.25], [bw * 0.32, -bh * 0.25], + [-bw * 0.32, bh * 0.25], [bw * 0.32, bh * 0.25]]) { + ctx.beginPath(); + ctx.arc(cx + rx, cy + ry, 2.2, 0, Math.PI * 2); + ctx.fill(); + } + + ctx.restore(); + }, + + /** + * Draw button icons — simplified vector-style icons for each button type. + * @param {CanvasRenderingContext2D} ctx + * @param {number} x - left position + * @param {number} y - top position + * @param {number} size - icon size (square) + * @param {string} type - icon identifier + * @param {boolean} isActive - pressed/highlighted state + */ + _drawButtonIcon(ctx, x, y, size, type, isActive) { + const cx = x + size / 2; + const cy = y + size / 2; + const s = size / 2.2; // drawing scale + + ctx.save(); + + // Icon glow when active + if (isActive) { + ctx.shadowColor = MC.GOLD; + ctx.shadowBlur = 8; + } + + const baseColor = isActive ? MC.GOLD_LIGHT : MC.ICON_GOLD; + const dimColor = isActive ? '#E0B020' : MC.GOLD_DIM; + + switch (type) { + case 'trophy': { + // Trophy cup + ctx.fillStyle = baseColor; + ctx.beginPath(); + ctx.moveTo(cx - s * 0.5, cy - s * 0.7); + ctx.lineTo(cx + s * 0.5, cy - s * 0.7); + ctx.lineTo(cx + s * 0.35, cy - s * 0.1); + ctx.lineTo(cx - s * 0.35, cy - s * 0.1); + ctx.closePath(); + ctx.fill(); + // Cup base + ctx.fillRect(cx - s * 0.25, cy - s * 0.1, s * 0.5, s * 0.35); + ctx.fillRect(cx - s * 0.45, cy + s * 0.2, s * 0.9, s * 0.15); + // Handles + ctx.strokeStyle = baseColor; + ctx.lineWidth = 2; + ctx.beginPath(); + ctx.arc(cx - s * 0.5, cy - s * 0.3, s * 0.25, Math.PI * 0.6, Math.PI * 1.4); + ctx.stroke(); + ctx.beginPath(); + ctx.arc(cx + s * 0.5, cy - s * 0.3, s * 0.25, -Math.PI * 0.4, Math.PI * 0.4); + ctx.stroke(); + break; + } + + case 'target': { + // Crosshair target + ctx.strokeStyle = baseColor; + ctx.lineWidth = 2.5; + // Outer circle + ctx.beginPath(); + ctx.arc(cx, cy, s * 0.7, 0, Math.PI * 2); + ctx.stroke(); + // Inner circle + ctx.beginPath(); + ctx.arc(cx, cy, s * 0.28, 0, Math.PI * 2); + ctx.stroke(); + // Crosshairs + ctx.lineWidth = 2; + ctx.beginPath(); + ctx.moveTo(cx - s * 0.95, cy); ctx.lineTo(cx - s * 0.45, cy); + ctx.moveTo(cx + s * 0.45, cy); ctx.lineTo(cx + s * 0.95, cy); + ctx.moveTo(cx, cy - s * 0.95); ctx.lineTo(cx, cy - s * 0.45); + ctx.moveTo(cx, cy + s * 0.45); ctx.lineTo(cx, cy + s * 0.95); + ctx.stroke(); + // Center dot + ctx.fillStyle = dimColor; + ctx.beginPath(); + ctx.arc(cx, cy, s * 0.12, 0, Math.PI * 2); + ctx.fill(); + break; + } + + case 'users': { + // Two user figures + ctx.fillStyle = baseColor; + // Left person (head + body) + ctx.beginPath(); ctx.arc(cx - s * 0.32, cy - s * 0.2, s * 0.28, 0, Math.PI * 2); ctx.fill(); + ctx.beginPath(); + ctx.ellipse(cx - s * 0.32, cy + s * 0.42, s * 0.34, s * 0.22, 0, Math.PI, 0); ctx.fill(); + // Right person (slightly behind) + ctx.fillStyle = dimColor; + ctx.globalAlpha = 0.85; + ctx.beginPath(); ctx.arc(cx + s * 0.32, cy - s * 0.12, s * 0.26, 0, Math.PI * 2); ctx.fill(); + ctx.beginPath(); + ctx.ellipse(cx + s * 0.32, cy + s * 0.48, s * 0.30, s * 0.20, 0, Math.PI, 0); ctx.fill(); + break; + } + + case 'swords': { + // Two crossed swords + ctx.save(); + ctx.translate(cx, cy); + ctx.rotate(Math.PI / 4); + // Sword 1 + ctx.fillStyle = baseColor; + ctx.fillRect(-s * 0.08, -s * 0.85, s * 0.16, s * 1.4); + // Guard + ctx.fillRect(-s * 0.38, -s * 0.05, s * 0.76, s * 0.14); + // Sword 2 (rotated) + ctx.rotate(Math.PI / 2); + ctx.fillStyle = dimColor; + ctx.fillRect(-s * 0.08, -s * 0.85, s * 0.16, s * 1.4); + ctx.fillRect(-s * 0.38, -s * 0.05, s * 0.76, s * 0.14); + ctx.restore(); + break; + } + + case 'coins': { + // Stack of coins + ctx.fillStyle = baseColor; + // Bottom coin (offset) + ctx.beginPath(); + ctx.ellipse(cx + s * 0.15, cy + s * 0.18, s * 0.55, s * 0.22, 0, 0, Math.PI * 2); + ctx.fill(); + // Top coin + ctx.fillStyle = MC.GOLD_LIGHT; + ctx.beginPath(); + ctx.ellipse(cx, cy, s * 0.55, s * 0.22, 0, 0, Math.PI * 2); + ctx.fill(); + // Coin detail ($ symbol hint) + ctx.fillStyle = dimColor; + ctx.font = `bold ${Math.floor(s)}px Arial`; + ctx.textAlign = 'center'; + ctx.textBaseline = 'middle'; + ctx.fillText('$', cx, cy + 1); + break; + } + + case 'skin': { + // Tank/paintbrush icon + ctx.fillStyle = baseColor; + // Tank body + this._roundRect(ctx, cx - s * 0.55, cy - s * 0.15, s * 1.1, s * 0.55, 3); + ctx.fill(); + // Barrel + ctx.fillRect(cx + s * 0.2, cy - s * 0.65, s * 0.18, s * 0.52); + // Wheels hint + ctx.fillStyle = dimColor; + ctx.beginPath(); + ctx.arc(cx - s * 0.3, cy + s * 0.4, s * 0.15, 0, Math.PI * 2); + ctx.fill(); + ctx.beginPath(); + ctx.arc(cx + s * 0.3, cy + s * 0.4, s * 0.15, 0, Math.PI * 2); + ctx.fill(); + // Star sparkle (skin customization hint) + ctx.fillStyle = MC.GOLD_LIGHT; + for (let i = 0; i < 3; i++) { + const angle = (i / 3) * Math.PI * 2 - Math.PI / 2; + const sx = cx + Math.cos(angle) * s * 0.7; + const sy = cy + Math.sin(angle) * s * 0.7 - s * 0.2; + ctx.beginPath(); + ctx.arc(sx, sy, s * 0.08, 0, Math.PI * 2); + ctx.fill(); + } + break; + } + + case 'ranking': { + // Podium/ranking icon + ctx.fillStyle = baseColor; + // 2nd place (left, shorter) + ctx.fillRect(cx - s * 0.55, cy, s * 0.4, s * 0.6); + ctx.fillStyle = '#C0C0C0'; + ctx.fillRect(cx - s * 0.15, cy - s * 0.3, s * 0.4, s * 0.9); + // 1st place (center, tallest) + ctx.fillStyle = baseColor; + ctx.fillRect(cx + s * 0.25, cy + s * 0.15, s * 0.4, s * 0.45); + // Numbers + ctx.fillStyle = '#333'; + ctx.font = `bold ${Math.floor(s * 0.3)}px Arial`; + ctx.textAlign = 'center'; + ctx.textBaseline = 'middle'; + ctx.fillText('2', cx - s * 0.35, cy + s * 0.3); + ctx.fillText('1', cx + s * 0.05, cy); + ctx.fillText('3', cx + s * 0.45, cy + s * 0.4); + break; + } + + case 'settings': { + // Gear/cog icon + ctx.strokeStyle = baseColor; + ctx.lineWidth = 2.5; + ctx.beginPath(); + ctx.arc(cx, cy, s * 0.35, 0, Math.PI * 2); + ctx.stroke(); + // Gear teeth + ctx.fillStyle = baseColor; + const teeth = 8; + for (let i = 0; i < teeth; i++) { + const angle = (i / teeth) * Math.PI * 2; + const tx = cx + Math.cos(angle) * s * 0.5; + const ty = cy + Math.sin(angle) * s * 0.5; + ctx.beginPath(); + ctx.arc(tx, ty, s * 0.1, 0, Math.PI * 2); + ctx.fill(); + } + // Center hole + ctx.fillStyle = dimColor; + ctx.beginPath(); + ctx.arc(cx, cy, s * 0.15, 0, Math.PI * 2); + ctx.fill(); + break; + } + + case 'shield': { + // Shield icon (for 3v3 team mode) + ctx.fillStyle = baseColor; + ctx.beginPath(); + ctx.moveTo(cx, cy - s * 0.65); + ctx.lineTo(cx + s * 0.55, cy - s * 0.35); + ctx.lineTo(cx + s * 0.5, cy + s * 0.45); + ctx.lineTo(cx, cy + s * 0.6); + ctx.lineTo(cx - s * 0.5, cy + s * 0.45); + ctx.lineTo(cx - s * 0.55, cy - s * 0.35); + ctx.closePath(); + ctx.fill(); + + // Inner detail + ctx.strokeStyle = dimColor; + ctx.lineWidth = 1; + const shieldInner = 0.7; + ctx.beginPath(); + ctx.moveTo(cx, cy - s * 0.65 * shieldInner); + ctx.lineTo(cx + s * 0.55 * shieldInner, cy - s * 0.35 * shieldInner); + ctx.lineTo(cx + s * 0.5 * shieldInner, cy + s * 0.45 * shieldInner); + ctx.lineTo(cx, cy + s * 0.6 * shieldInner); + ctx.lineTo(cx - s * 0.5 * shieldInner, cy + s * 0.45 * shieldInner); + ctx.lineTo(cx - s * 0.55 * shieldInner, cy - s * 0.35 * shieldInner); + ctx.closePath(); + ctx.stroke(); + + // "3V3" text inside + ctx.fillStyle = dimColor; + ctx.font = `bold ${Math.max(6, s * 0.32)}px Arial`; + ctx.textAlign = 'center'; + ctx.textBaseline = 'middle'; + ctx.fillText('3v3', cx, cy + s * 0.08); + break; + } + + default: + // Fallback circle icon + ctx.fillStyle = baseColor; + ctx.beginPath(); + ctx.arc(cx, cy, s * 0.5, 0, Math.PI * 2); + ctx.fill(); + } + + ctx.restore(); + }, + + /** + * Utility bar button — horizontal layout (icon on left, text on right). + * Used for the 4-column bottom utility bar. + */ + _drawUtilButton(ctx, btn, label, subtitle, isPressed, customBg, iconType) { + const r = 8; + ctx.save(); + ctx.globalAlpha = 0.85; + + const iconSz = Math.min(24, btn.h * 0.48); + const innerPad = Math.max(8, btn.w * 0.06); + + // Shadow + ctx.fillStyle = 'rgba(0,0,0,0.25)'; + this._roundRect(ctx, btn.x + 1, btn.y + 2, btn.w, btn.h, r); + ctx.fill(); + + // Body gradient + const bgGrad = ctx.createLinearGradient(btn.x, btn.y, btn.x, btn.y + btn.h); + if (isPressed) { + bgGrad.addColorStop(0, '#243a5c'); + bgGrad.addColorStop(1, '#1a2d4a'); + } else if (customBg) { + bgGrad.addColorStop(0, customBg); + bgGrad.addColorStop(1, this._darkenColor(customBg, 12)); + } else { + bgGrad.addColorStop(0, '#182844'); + bgGrad.addColorStop(1, '#121d33'); + } + ctx.fillStyle = bgGrad; + ctx.strokeStyle = isPressed ? MC.GOLD : 'rgba(50,90,140,0.5)'; + ctx.lineWidth = 1; + this._roundRect(ctx, btn.x, btn.y, btn.w, btn.h, r); + ctx.fill(); + ctx.stroke(); + + // Icon on the left side + if (iconType) { + const ix = btn.x + innerPad; + const iy = btn.y + (btn.h - iconSz) / 2; + this._drawButtonIcon(ctx, ix, iy, iconSz, iconType, isPressed); + } + + // Text to the right of icon — match right-panel mode button font size + const txtX = btn.x + innerPad + iconSz + 6; + const maxTxtW = btn.x + btn.w - innerPad - txtX; + // Match mode button primary text size: bold max(15, h*0.32) + const txtFont = `bold ${Math.max(14, btn.h * 0.30)}px Arial`; + ctx.fillStyle = isPressed ? MC.GOLD_LIGHT : MC.BTN_TEXT_PRIMARY; + ctx.font = txtFont; + ctx.textAlign = 'left'; ctx.textBaseline = 'middle'; - ctx.fillText(label, btn.x + btn.w / 2, btn.y + btn.h / 2); + + // Truncate long text to fit remaining width + let displayLabel = label; + while (ctx.measureText(displayLabel).width > maxTxtW && displayLabel.length > 3) { + displayLabel = displayLabel.slice(0, -2) + '…'; + } + ctx.fillText(displayLabel, txtX, btn.y + btn.h / 2); + + ctx.restore(); + }, + + /** Darken a hex color by a percentage */ + _darkenColor(hex, percent) { + const num = parseInt(hex.replace('#', ''), 16); + const amt = Math.round(2.55 * percent); + const R = Math.max((num >> 16) - amt, 0); + const G = Math.max(((num >> 8) & 0x00FF) - amt, 0); + const B = Math.max((num & 0x0000FF) - amt, 0); + return '#' + (0x1000000 + R * 0x10000 + G * 0x100 + B).toString(16).slice(1); }, // ---- Tank Icon ---- _drawTankIcon(ctx, cx, cy) { - const bounce = Math.sin(this._tankAnim * 3) * 2; + const bounce = Math.sin(this._tankAnim * 3) * 1.5; const pulse = 0.5 + 0.5 * Math.sin(this._tankAnim * 2); - const s = 15; // body half-size (square tank: 2s × 2s) + const s = 13; // body half-size (compact: 26×26) ctx.save(); ctx.translate(cx, cy + bounce); @@ -567,6 +1197,27 @@ const MenuScene = { * @param {string} url * @private */ + /** + * Load background image from local path. + * @private + */ + _loadBgImage() { + if (this._bgImage) return; + try { + const img = wx.createImage(); + img.onload = () => { + this._bgImage = img; + console.log('[MenuScene] Background image loaded:', img.width, 'x', img.height); + }; + img.onerror = (e) => { + console.warn('[MenuScene] Failed to load background image, using gradient fallback'); + }; + img.src = 'js/ui/images/bg.png'; + } catch (e) { + console.warn('[MenuScene] _loadBgImage error:', e && e.message); + } + }, + _loadAvatarImage(url) { if (!url || this._avatarImg) return; try { @@ -692,6 +1343,12 @@ const MenuScene = { sm.register(SCENE.PVP_ROOM, RoomScene); } sm.switchTo(SCENE.PVP_ROOM); + } else if (btn.scene === SCENE.TEAM_2V2_ROOM) { + if (!sm._scenes.has(SCENE.TEAM_2V2_ROOM)) { + const Team2v2RoomScene = require('./Team2v2RoomScene'); + sm.register(SCENE.TEAM_2V2_ROOM, Team2v2RoomScene); + } + sm.switchTo(SCENE.TEAM_2V2_ROOM); } else if (btn.scene === SCENE.TEAM_ROOM) { if (!sm._scenes.has(SCENE.TEAM_ROOM)) { const TeamRoomScene = require('./TeamRoomScene'); diff --git a/js/scenes/RoomScene.js b/js/scenes/RoomScene.js index 65d7e92..f27a3bd 100644 --- a/js/scenes/RoomScene.js +++ b/js/scenes/RoomScene.js @@ -38,7 +38,7 @@ const ROOM_STATE = { // Room Scene // ============================================================ const RoomScene = { - _state: ROOM_STATE.IDLE, + _state: ROOM_STATE.CREATING, _roomCode: '', _errorMsg: '', _countdown: 3, @@ -152,7 +152,7 @@ const RoomScene = { })); unsubs.push(nm.on('disconnected', () => { - if (this._state !== ROOM_STATE.IDLE) { + if (this._state !== ROOM_STATE.CREATING) { this._errorMsg = t('common.disconnected'); this._state = ROOM_STATE.ERROR; } @@ -198,7 +198,7 @@ const RoomScene = { if (shareManager) { shareManager.setShareContent({ title: t('room.shareTitle'), - imageUrl: '', + imageUrl: 'js/ui/images/1v1.png', query: `roomId=${this._roomCode}`, }); } @@ -214,7 +214,7 @@ const RoomScene = { const shareData = { title: t('room.shareTitle'), - imageUrl: '', + imageUrl: 'js/ui/images/1v1.png', query: `roomId=${this._roomCode}`, }; @@ -240,7 +240,7 @@ const RoomScene = { /** * 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. * MUST be called within a touch event for WeChat policy compliance. * @private @@ -280,7 +280,7 @@ const RoomScene = { const shareData = { title: t('room.shareTitle'), - imageUrl: '', + imageUrl: 'js/ui/images/1v1.png', query: `roomId=${roomId}`, }; @@ -505,7 +505,7 @@ const RoomScene = { switch (this._state) { case ROOM_STATE.ERROR: - this._state = ROOM_STATE.IDLE; + this._state = ROOM_STATE.CREATING; this._errorMsg = ''; break; diff --git a/js/scenes/Team2v2RoomScene.js b/js/scenes/Team2v2RoomScene.js new file mode 100644 index 0000000..aaed45e --- /dev/null +++ b/js/scenes/Team2v2RoomScene.js @@ -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; diff --git a/js/scenes/TeamGameScene.js b/js/scenes/TeamGameScene.js index 4422683..da74363 100644 --- a/js/scenes/TeamGameScene.js +++ b/js/scenes/TeamGameScene.js @@ -1039,15 +1039,15 @@ const TeamGameScene = { // Validate pushed positions against terrain; revert if stuck in wall if (this._mapManager) { - const leftA = tankA.x - tankA.halfSize; - const topA = tankA.y - tankA.halfSize; - if (this._mapManager.rectCollidesWithTerrain(leftA, topA, tankA.size, tankA.size)) { + const leftA = tankA.x - tankA.colliderHalfSize; + const topA = tankA.y - tankA.colliderHalfSize; + if (this._mapManager.rectCollidesWithTerrain(leftA, topA, tankA.colliderSize, tankA.colliderSize)) { tankA.x = origAX; tankA.y = origAY; } - const leftB = tankB.x - tankB.halfSize; - const topB = tankB.y - tankB.halfSize; - if (this._mapManager.rectCollidesWithTerrain(leftB, topB, tankB.size, tankB.size)) { + const leftB = tankB.x - tankB.colliderHalfSize; + const topB = tankB.y - tankB.colliderHalfSize; + if (this._mapManager.rectCollidesWithTerrain(leftB, topB, tankB.colliderSize, tankB.colliderSize)) { tankB.x = origBX; tankB.y = origBY; } diff --git a/js/scenes/TeamRoomScene.js b/js/scenes/TeamRoomScene.js index 167e7a8..a408a24 100644 --- a/js/scenes/TeamRoomScene.js +++ b/js/scenes/TeamRoomScene.js @@ -1,1017 +1,16 @@ /** * TeamRoomScene.js - * 3v3 Team room UI scene. - * Supports team creation, joining, ready state, leader controls, - * matchmaking, and WeChat friend invitation. + * 3v3 Team room — delegates to the shared TeamRoomSceneFactory. */ -const { - SCREEN_WIDTH, - SCREEN_HEIGHT, - COLORS, - SCENE, - NET_MSG, - TEAM_SIZE, - SERVER_URL, -} = require('../base/GameGlobal'); -const { t } = require('../i18n/I18n'); - -// ============================================================ -// Layout Constants -// ============================================================ -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 -// ============================================================ -const TEAM_STATE = { - MODE_SELECT: 'mode_select', // Choose: create team or solo match - JOINING: 'joining', // Auto-joining a team from invite - FORMING: 'forming', // Team room, waiting for members - MATCHING: 'matching', // In matchmaking queue - COUNTDOWN: 'countdown', // Match found, counting down - ERROR: 'error', // Error state -}; - -// ============================================================ -// Team Room Scene -// ============================================================ -const TeamRoomScene = { - _state: TEAM_STATE.MODE_SELECT, - _teamData: null, // { teamId, state, leaderId, teamA, teamB } - _errorMsg: '', - _animTimer: 0, - _matchTimer: 0, // Seconds elapsed in matching - _countdown: 3, - _countdownTimer: 0, - _networkManager: null, - _unsubscribers: [], - _isLeader: false, - _myPlayerId: null, - - // Server URL (from global config) - _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: {}, - - 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(); - // Setup network events BEFORE auto-join so listeners are ready - this._setupNetworkEvents(); - // Listen for profile updates (avatar/nickname granted mid-session) - this._setupProfileListener(); - - // If entering with a teamId (from invite card), auto-join - if (params && params.teamId) { - this._autoJoinTeam(params.teamId); - } - }, - - /** - * Update share content so that any share from the top-right menu - * always carries the current teamId. - * @private - */ - _updateShareContent() { - if (!this._teamData || !this._teamData.teamId) return; - const shareManager = GameGlobal.shareManager; - if (shareManager) { - shareManager.setShareContent({ - title: t('teamRoom.shareTitle'), - imageUrl: '', - query: `teamId=${this._teamData.teamId}`, - }); - } - }, - - exit() { - this._cleanupNetworkEvents(); - this._cleanupProfileListener(); - // Reset share content when leaving team room - const shareManager = GameGlobal.shareManager; - if (shareManager) { - shareManager.resetShareContent(); - } - }, - - _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, - }; - - // Team member slots (5 slots in a row) - const totalSlotsWidth = TEAM_SIZE * SLOT_WIDTH + (TEAM_SIZE - 1) * SLOT_GAP; - const slotsStartX = CENTER_X - totalSlotsWidth / 2; - const slotsY = SCREEN_HEIGHT * 0.25; - - this._slotRects = []; - this._kickBtnRects = []; - for (let i = 0; i < TEAM_SIZE; i++) { - const x = slotsStartX + i * (SLOT_WIDTH + SLOT_GAP); - this._slotRects.push({ - x, y: slotsY, w: SLOT_WIDTH, h: SLOT_HEIGHT, - }); - // Kick button (small X at top-right of slot) - this._kickBtnRects.push({ - x: x + SLOT_WIDTH - 18, y: slotsY + 2, w: 16, h: 16, - }); - } - - // Action buttons (below slots) - 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, - }; - }, - - _setupNetworkEvents() { - this._cleanupNetworkEvents(); - const nm = this._networkManager; - if (!nm) return; - - const unsubs = []; - - unsubs.push(nm.on(NET_MSG.TEAM_STATE, (data) => { - // Invalidate avatar cache for members whose avatarUrl changed - // so that _loadAvatar will reload the new image. - if (data.teamA) { - for (const m of data.teamA) { - if (m.avatarUrl && this._avatarImages[m.playerId] === null) { - // null = previously failed to load; the new URL may work - 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; - } - - // Keep share content up-to-date with current teamId - 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'; - // Only switch to error state if not already in game transition - 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 = []; - }, - - /** - * Listen for profile:updated events. When the avatar URL is granted - * (e.g. after fetchSilent completes or user taps UserInfoButton), - * we need to trigger a network message so the server propagates - * the new avatarUrl to other team members. - * @private - */ - _setupProfileListener() { - this._cleanupProfileListener(); - const bus = (typeof GameGlobal !== 'undefined') ? GameGlobal.eventBus : null; - if (!bus || typeof bus.on !== 'function') return; - - this._profileUnsub = bus.on('profile:updated', (data) => { - console.log('[TeamRoom] profile:updated received, avatarUrl:', data && data.avatarUrl ? 'present' : 'empty'); - - // If we are in a team room and have a network connection, send a - // lightweight ping so the server picks up the updated avatarUrl - // from the next message's top-level field. - if (this._teamData && this._networkManager && this._networkManager.connected) { - this._networkManager.send(NET_MSG.PING); - console.log('[TeamRoom] Sent PING to sync updated profile to server'); - } - - // Also invalidate cached avatar for self so it reloads - if (this._myPlayerId && this._avatarImages[this._myPlayerId] !== undefined) { - delete this._avatarImages[this._myPlayerId]; - } - }); - }, - - _cleanupProfileListener() { - if (this._profileUnsub) { - this._profileUnsub(); - this._profileUnsub = null; - } - }, - - 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--; - } - } - }, - - _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 || '3v3', - roomId: data.roomId || '', - myPlayerId: this._myPlayerId, - }); - }, - - render(ctx) { - // Background - ctx.fillStyle = COLORS.MENU_BG; - ctx.fillRect(0, 0, SCREEN_WIDTH, SCREEN_HEIGHT); - - // Top accent bar - 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); - - // Back button - this._drawButton(ctx, this._backBtnRect, t('common.back'), false, 12); - - // Title - ctx.fillStyle = COLORS.MENU_TITLE; - ctx.font = 'bold 22px Arial'; - ctx.textAlign = 'center'; - ctx.textBaseline = 'middle'; - ctx.fillText(t('teamRoom.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(t('teamRoom.joining') + dots, CENTER_X, SCREEN_HEIGHT * 0.45); - }, - - _renderModeSelect(ctx) { - ctx.fillStyle = '#AAAAAA'; - ctx.font = '14px Arial'; - ctx.textAlign = 'center'; - ctx.fillText(t('teamRoom.chooseMode'), CENTER_X, SCREEN_HEIGHT * 0.28); - - this._drawButton(ctx, this._createTeamBtnRect, t('teamRoom.createTeam')); - this._drawButton(ctx, this._soloMatchBtnRect, t('teamRoom.soloMatch')); - }, - - _renderForming(ctx) { - if (!this._teamData) return; - - // Team ID display - ctx.fillStyle = '#AAAAAA'; - ctx.font = '12px Arial'; - ctx.textAlign = 'center'; - ctx.fillText(t('teamRoom.teamId', { id: this._teamData.teamId }), CENTER_X, SCREEN_HEIGHT * 0.16); - - // Render team member slots - const members = this._teamData.teamA || []; - for (let i = 0; i < TEAM_SIZE; i++) { - const rect = this._slotRects[i]; - const member = members[i]; - - // Slot background - 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) { - // Avatar centered in the slot - 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); - - // Ready state (below avatar) - if (!member.isLeader) { - ctx.fillStyle = member.ready ? '#00FF00' : '#FF6347'; - ctx.font = 'bold 10px Arial'; - ctx.textAlign = 'center'; - ctx.textBaseline = 'middle'; - ctx.fillText(member.ready ? t('teamRoom.ready') : t('teamRoom.notReady'), avatarCX, rect.y + rect.h * 0.88); - } - - // Kick button (only for leader, not on self) - 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 { - // Empty slot - 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(t('teamRoom.emptySlot'), rect.x + rect.w / 2, rect.y + rect.h * 0.78); - } - } - - // Action buttons based on role - if (this._isLeader) { - this._drawButton(ctx, this._inviteBtnRect, t('teamRoom.invite')); - - // Match button: only enabled if all ready - const allReady = members.length > 0 && members.every(m => m.ready || m.isLeader); - this._drawButton(ctx, this._matchBtnRect, t('teamRoom.startMatch'), false, 14, allReady ? null : '#555555'); - this._drawButton(ctx, this._disbandBtnRect, t('teamRoom.disband'), false, 12, '#8B0000'); - } else { - // Member: ready/unready button - const myMember = members.find(m => m.playerId === this._myPlayerId); - const readyLabel = myMember && myMember.ready ? t('teamRoom.cancelReady') : t('teamRoom.readyBtn'); - this._drawButton(ctx, this._readyBtnRect, readyLabel); - this._drawButton(ctx, this._leaveBtnRect, t('teamRoom.leaveTeam'), false, 12, '#8B0000'); - } - }, - - _renderMatching(ctx) { - if (!this._teamData) return; - - // Render team slots (smaller, at top) - const members = this._teamData.teamA || []; - for (let i = 0; i < TEAM_SIZE; 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) { - // Avatar centered in the slot - 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); - - // Ready state (below avatar) - if (!member.isLeader) { - ctx.fillStyle = member.ready ? '#00FF00' : '#FF6347'; - ctx.font = 'bold 10px Arial'; - ctx.textAlign = 'center'; - ctx.textBaseline = 'middle'; - ctx.fillText(member.ready ? t('teamRoom.ready') : t('teamRoom.notReady'), avatarCX, rect.y + rect.h * 0.88); - } - } else { - // Empty slot - 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); - } - } - - // Matching animation - 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(t('teamRoom.matching', { dots }), CENTER_X, SCREEN_HEIGHT * 0.55); - - ctx.fillStyle = '#AAAAAA'; - ctx.font = '14px Arial'; - ctx.fillText(t('teamRoom.waitTime', { seconds: elapsed }), CENTER_X, SCREEN_HEIGHT * 0.62); - - // Cancel button (leader only) - if (this._isLeader) { - this._drawButton(ctx, this._cancelMatchBtnRect, t('teamRoom.cancelMatch')); - } - }, - - _renderCountdown(ctx) { - ctx.fillStyle = '#00FF00'; - ctx.font = '16px Arial'; - ctx.textAlign = 'center'; - ctx.fillText(t('teamRoom.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(t('teamRoom.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(t('teamRoom.tapBack'), CENTER_X, SCREEN_HEIGHT * 0.55); - }, - - _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; - }, - - /** - * Draw an avatar for a team member: WeChat image if loaded, else colored placeholder. - * For the local player, also checks PlayerProfile.avatarUrl as a fallback - * source (it may be set before the server broadcasts the update). - * @private - */ - _drawAvatar(ctx, member, cx, cy, r) { - const img = this._avatarImages[member.playerId]; - if (img) { - // Circular clip for the avatar image - 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(); - - // Border ring - ctx.strokeStyle = member.isLeader ? '#FFD700' : '#4a90d9'; - ctx.lineWidth = 2; - ctx.beginPath(); - ctx.arc(cx, cy, r + 1, 0, Math.PI * 2); - ctx.stroke(); - } else { - // For the local player, check if PlayerProfile has an avatarUrl that - // hasn't been loaded into _avatarImages yet (async timing gap). - if (member.playerId === this._myPlayerId) { - const profile = (typeof GameGlobal !== 'undefined') ? GameGlobal.playerProfile : null; - if (profile && profile.avatarUrl && !this._avatarImages[member.playerId]) { - // Profile has an avatarUrl but we haven't loaded it yet — trigger load - this._loadAvatar(member); - // Don't draw the placeholder; next frame will render the image. - // However, to avoid a flash, still draw placeholder this frame. - } - } - - // Placeholder: colored circle with a simple person silhouette - const bgColor = member.isLeader ? '#FFD700' : '#4a90d9'; - ctx.fillStyle = bgColor; - ctx.beginPath(); - ctx.arc(cx, cy, r, 0, Math.PI * 2); - ctx.fill(); - - // Draw a simple person silhouette (head + shoulders) - ctx.fillStyle = 'rgba(255,255,255,0.7)'; - // Head - ctx.beginPath(); - ctx.arc(cx, cy - r * 0.2, r * 0.3, 0, Math.PI * 2); - ctx.fill(); - // Shoulders - ctx.beginPath(); - ctx.ellipse(cx, cy + r * 0.55, r * 0.55, r * 0.35, 0, Math.PI, 0); - ctx.fill(); - - // Border ring for placeholder - ctx.strokeStyle = bgColor; - ctx.lineWidth = 2; - ctx.beginPath(); - ctx.arc(cx, cy, r + 1, 0, Math.PI * 2); - ctx.stroke(); - - // Trigger async load - this._loadAvatar(member); - } - }, - - /** - * Asynchronously load a WeChat avatar image for the given member. - * Caches the Image object so subsequent frames render the real avatar. - * For the local player, also checks PlayerProfile as a fallback source - * for the avatarUrl (it may have been granted after the team state was - * last broadcast from the server). - * @private - */ - _loadAvatar(member) { - // For self, prefer the latest PlayerProfile avatarUrl (it updates - // asynchronously via fetchSilent / UserInfoButton, possibly after - // the server last broadcast the team state). - 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; - - // Mark as loading (null) to prevent duplicate loads - this._avatarImages[member.playerId] = null; - - try { - const img = wx.createImage(); - img.onload = () => { - this._avatarImages[member.playerId] = img; - console.log(`[TeamRoom] Avatar loaded for ${member.playerId}, url=${avatarUrl.substring(0, 60)}...`); - }; - img.onerror = (err) => { - // Keep null so we don't retry endlessly - console.warn(`[TeamRoom] Failed to load avatar for ${member.playerId}, url=${avatarUrl.substring(0, 60)}..., err:`, err); - }; - img.src = avatarUrl; - } catch (e) { - console.warn('[TeamRoom] wx.createImage not available:', e); - } - }, - - /** - * Compute a display name for a team member entry. - * Uses real WeChat nickname when available, otherwise a stable fallback. - * Truncated to 4 Chinese-equivalent chars to fit the slot UI. - * @private - */ - _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; - }, - - handleTouch(eventType, e) { - if (eventType !== 'touchstart') return; - - const touch = e.touches[0]; - const tx = touch.clientX; - const ty = touch.clientY; - - // Back button - 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) { - // Invite button - if (this._hitTest(tx, ty, this._inviteBtnRect)) { - this._handleInvite(); - return; - } - - // Match button - if (this._hitTest(tx, ty, this._matchBtnRect)) { - this._handleStartMatch(); - return; - } - - // Disband button - if (this._hitTest(tx, ty, this._disbandBtnRect)) { - this._handleDisband(); - return; - } - - // Kick buttons - 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 { - // Ready button - if (this._hitTest(tx, ty, this._readyBtnRect)) { - this._handleReady(); - return; - } - - // Leave button - if (this._hitTest(tx, ty, this._leaveBtnRect)) { - this._handleLeave(); - return; - } - } - }, - - 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.send(NET_MSG.CREATE_TEAM, {}); - }, - - 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; - // State will be updated by TEAM_STATE event from server - nm.send(NET_MSG.SOLO_MATCH, {}); - }, - - async _autoJoinTeam(teamId) { - const nm = this._networkManager; - if (!nm) { - this._errorMsg = t('common.cannotConnect'); - this._state = TEAM_STATE.ERROR; - return; - } - - // Show a joining indicator - 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(`[TeamRoom] Auto-joining team ${teamId} as ${this._myPlayerId}`); - nm.send(NET_MSG.JOIN_TEAM, { teamId }); - } catch (e) { - console.error('[TeamRoom] 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: t('teamRoom.shareTitle'), - imageUrl: '', - query: `teamId=${teamId}`, - }; - - console.log(`[TeamRoom] Sharing invite with query: teamId=${teamId}`); - - // WeChat mini-game policy: direct wx.shareAppMessage() calls are forbidden. - // Must use passive sharing via onShareAppMessage callback. - const shareManager = GameGlobal.shareManager; - if (shareManager) { - shareManager.triggerShare(shareData); - } else { - try { - wx.showToast({ - title: '请点击右上角 ··· 转发给好友', - icon: 'none', - duration: 2500, - }); - } catch (e) { - console.log('[TeamRoom] Share not available, teamId:', teamId); - } - } - }, - - _handleStartMatch() { - const nm = this._networkManager; - if (!nm || !this._teamData) return; - - // Check all members are ready before sending - 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() { - // Leave team if in one - if (this._teamData) { - const nm = this._networkManager; - if (nm) { - if (this._state === TEAM_STATE.MATCHING && this._isLeader) { - // Cancel match first, then disband - 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); - }, -}; +const { createTeamRoomScene } = require('./TeamRoomSceneFactory'); + +const TeamRoomScene = createTeamRoomScene({ + teamSize: 3, + battleMode: '3v3', + i18nPrefix: 'teamRoom', + logTag: 'TeamRoom', + shareImageUrl: 'js/ui/images/3v3.png', +}); module.exports = TeamRoomScene; diff --git a/js/scenes/TeamRoomSceneFactory.js b/js/scenes/TeamRoomSceneFactory.js new file mode 100644 index 0000000..34330ea --- /dev/null +++ b/js/scenes/TeamRoomSceneFactory.js @@ -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 }; diff --git a/js/ui/images/1v1.png b/js/ui/images/1v1.png new file mode 100644 index 0000000..f31e748 Binary files /dev/null and b/js/ui/images/1v1.png differ diff --git a/js/ui/images/2v2.png b/js/ui/images/2v2.png new file mode 100644 index 0000000..06af78d Binary files /dev/null and b/js/ui/images/2v2.png differ diff --git a/js/ui/images/3v3.png b/js/ui/images/3v3.png new file mode 100644 index 0000000..c7acd2f Binary files /dev/null and b/js/ui/images/3v3.png differ diff --git a/server/.DS_Store b/server/.DS_Store new file mode 100644 index 0000000..dbde391 Binary files /dev/null and b/server/.DS_Store differ diff --git a/server/index.js b/server/index.js index 4d29621..2de67ad 100644 --- a/server/index.js +++ b/server/index.js @@ -169,6 +169,7 @@ const TEAM_RECONNECT_TIMEOUT = 60000; // 60s to reconnect // ============================================================ const BATTLE_CONFIG = { '1v1': { teamSize: 1, baseHp: 5, fillWithBots: false }, + '2v2': { teamSize: 2, baseHp: 8, fillWithBots: true }, '3v3': { teamSize: 3, baseHp: 10, fillWithBots: true }, }; @@ -321,6 +322,11 @@ class TeamRoom { 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 */ isFull() { return this.teamA.length >= this.teamSize && this.teamB.length >= this.teamSize; @@ -678,12 +684,15 @@ function handleCreateTeam(ws, data) { handleLeaveTeam(ws, {}); } + // Read battleMode from client data, fall back to '3v3' if not provided + const battleMode = (data && data.battleMode) || '3v3'; + 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); 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()); } @@ -703,7 +712,7 @@ function handleJoinTeam(ws, data) { // 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. 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); playerInfo.teamId = teamId; sendMessage(ws, NET_MSG.TEAM_STATE, teamRoom.getTeamState()); @@ -926,9 +935,12 @@ function handleSoloMatch(ws, data) { 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 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.matchStartTime = Date.now(); teamRooms.set(teamId, teamRoom); @@ -1093,6 +1105,10 @@ function tryMatchTeams() { gameRoom.fillWithBots(); 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); } } diff --git a/server/test/teamRoom.test.js b/server/test/teamRoom.test.js new file mode 100644 index 0000000..16ad87a --- /dev/null +++ b/server/test/teamRoom.test.js @@ -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'); + }); +});