Files
tankwar_proj/js/scenes/RoomScene.js
2026-04-10 22:59:39 +08:00

540 lines
15 KiB
JavaScript

/**
* RoomScene.js
* Room creation/joining UI for PVP online multiplayer mode.
* Allows players to create a room or join an existing one by room code.
*/
const {
SCREEN_WIDTH,
SCREEN_HEIGHT,
COLORS,
SCENE,
NET_MSG,
SERVER_URL,
} = require('../base/GameGlobal');
const { t } = require('../i18n/I18n');
// ============================================================
// Layout Constants
// ============================================================
const BTN_WIDTH = Math.min(SCREEN_WIDTH * 0.5, 240);
const BTN_HEIGHT = Math.min(36, SCREEN_HEIGHT * 0.08);
const BTN_GAP = 14;
const CENTER_X = SCREEN_WIDTH / 2;
// ============================================================
// Room Scene States
// ============================================================
const ROOM_STATE = {
IDLE: 'idle', // Initial state: show create/join buttons
CREATING: 'creating', // Connecting and creating room
WAITING: 'waiting', // Room created, waiting for opponent
JOINING: 'joining', // Joining a room
INPUT_CODE: 'input', // Entering room code
COUNTDOWN: 'countdown', // Both players ready, counting down
ERROR: 'error', // Error state
};
// ============================================================
// Room Scene
// ============================================================
const RoomScene = {
_state: ROOM_STATE.IDLE,
_roomCode: '',
_inputCode: '',
_errorMsg: '',
_countdown: 3,
_countdownTimer: 0,
_animTimer: 0,
_networkManager: null,
_unsubscribers: [],
// Server URL (from global config)
_serverUrl: SERVER_URL,
// Button rects (calculated in enter)
_createBtnRect: null,
_joinBtnRect: null,
_backBtnRect: null,
_confirmBtnRect: null,
_numpadRects: [],
_deleteBtnRect: null,
enter() {
this._state = ROOM_STATE.IDLE;
this._roomCode = '';
this._inputCode = '';
this._errorMsg = '';
this._countdown = 3;
this._countdownTimer = 0;
this._animTimer = 0;
this._pendingStartData = null;
this._networkManager = GameGlobal.networkManager;
// Calculate button positions
const btnY = SCREEN_HEIGHT * 0.4;
this._createBtnRect = {
x: CENTER_X - BTN_WIDTH / 2,
y: btnY,
w: BTN_WIDTH,
h: BTN_HEIGHT,
};
this._joinBtnRect = {
x: CENTER_X - BTN_WIDTH / 2,
y: btnY + BTN_HEIGHT + BTN_GAP,
w: BTN_WIDTH,
h: BTN_HEIGHT,
};
this._backBtnRect = {
x: 10,
y: 10,
w: 60,
h: 30,
};
// Numpad for room code input (3x4 grid: 1-9, 0, delete, confirm)
this._buildNumpad();
// Confirm button for code input
this._confirmBtnRect = {
x: CENTER_X - BTN_WIDTH / 2,
y: SCREEN_HEIGHT * 0.75,
w: BTN_WIDTH,
h: BTN_HEIGHT,
};
// Setup network event listeners
this._setupNetworkEvents();
},
exit() {
this._cleanupNetworkEvents();
},
_buildNumpad() {
const padWidth = Math.min(SCREEN_WIDTH * 0.6, 200);
const padHeight = Math.min(SCREEN_HEIGHT * 0.35, 180);
const startX = CENTER_X - padWidth / 2;
const startY = SCREEN_HEIGHT * 0.42;
const cellW = padWidth / 3;
const cellH = padHeight / 4;
this._numpadRects = [];
const nums = [1, 2, 3, 4, 5, 6, 7, 8, 9, null, 0, 'del'];
for (let i = 0; i < 12; i++) {
const col = i % 3;
const row = Math.floor(i / 3);
if (nums[i] !== null) {
this._numpadRects.push({
x: startX + col * cellW,
y: startY + row * cellH,
w: cellW - 4,
h: cellH - 4,
value: nums[i],
});
}
}
},
_setupNetworkEvents() {
this._cleanupNetworkEvents();
const nm = this._networkManager;
if (!nm) return;
const unsubs = [];
unsubs.push(nm.on(NET_MSG.ROOM_CREATED, (data) => {
this._roomCode = data.roomId || data.roomCode || '';
this._state = ROOM_STATE.WAITING;
}));
unsubs.push(nm.on(NET_MSG.ROOM_JOINED, (data) => {
this._roomCode = data.roomId || data.roomCode || '';
this._state = ROOM_STATE.WAITING;
}));
unsubs.push(nm.on(NET_MSG.OPPONENT_JOINED, () => {
this._state = ROOM_STATE.COUNTDOWN;
this._countdown = 3;
this._countdownTimer = 0;
}));
unsubs.push(nm.on(NET_MSG.GAME_START, (data) => {
// Server authoritative game start — always use server data (contains mapId)
this._pendingStartData = data;
if (this._state !== ROOM_STATE.COUNTDOWN) {
// Guest path: not in countdown state, start game immediately
this._startGame(data);
} else if (this._countdown <= 0) {
// Host path: countdown already finished, start immediately
this._startGame(data);
}
// Host path: countdown still running, will pick up pendingStartData when done
}));
unsubs.push(nm.on(NET_MSG.ROOM_ERROR, (data) => {
this._errorMsg = data.message || 'Unknown error';
this._state = ROOM_STATE.ERROR;
}));
unsubs.push(nm.on('error', () => {
this._errorMsg = t('common.connectFailed');
this._state = ROOM_STATE.ERROR;
}));
unsubs.push(nm.on('disconnected', () => {
if (this._state !== ROOM_STATE.IDLE) {
this._errorMsg = t('common.disconnected');
this._state = ROOM_STATE.ERROR;
}
}));
this._unsubscribers = unsubs;
},
_cleanupNetworkEvents() {
for (const unsub of this._unsubscribers) {
if (typeof unsub === 'function') unsub();
}
this._unsubscribers = [];
},
update(dt) {
this._animTimer += dt;
if (this._state === ROOM_STATE.COUNTDOWN) {
this._countdownTimer += dt;
if (this._countdownTimer >= 1) {
this._countdownTimer -= 1;
this._countdown--;
if (this._countdown <= 0) {
// Countdown finished — only start if we already received server GAME_START
if (this._pendingStartData) {
this._startGame(this._pendingStartData);
}
// Otherwise wait for server GAME_START message
}
}
}
},
_startGame(data) {
const sm = GameGlobal.sceneManager;
if (!sm._scenes.has(SCENE.TEAM_GAME)) {
const TeamGameScene = require('./TeamGameScene');
sm.register(SCENE.TEAM_GAME, TeamGameScene);
}
const myPlayerId = this._networkManager ? this._networkManager.playerId : 'local';
const playerSlot = this._networkManager ? this._networkManager.playerSlot : 1;
// Build teamA/teamB from GAME_START data (sent by server for 1v1 via TeamRoom)
let teamA = data.teamA || [];
let teamB = data.teamB || [];
// Fallback: if server didn't send teamA/teamB (legacy), construct from playerSlot
if (teamA.length === 0 && teamB.length === 0) {
if (playerSlot === 1) {
teamA = [{ playerId: myPlayerId, isBot: false }];
teamB = [{ playerId: 'opponent', isBot: false }];
} else {
teamA = [{ playerId: 'opponent', isBot: false }];
teamB = [{ playerId: myPlayerId, isBot: false }];
}
}
sm.switchTo(SCENE.TEAM_GAME, {
teamId: this._roomCode,
roomId: data.roomId || this._roomCode,
mapId: data.mapId || null,
teamA,
teamB,
teamABaseHp: data.teamABaseHp,
teamBBaseHp: data.teamBBaseHp,
myPlayerId,
battleMode: data.battleMode || '1v1',
});
},
render(ctx) {
// Background
ctx.fillStyle = COLORS.MENU_BG;
ctx.fillRect(0, 0, SCREEN_WIDTH, SCREEN_HEIGHT);
// Top 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 24px Arial';
ctx.textAlign = 'center';
ctx.textBaseline = 'middle';
ctx.fillText(t('room.title'), CENTER_X, SCREEN_HEIGHT * 0.12);
// Render based on state
switch (this._state) {
case ROOM_STATE.IDLE:
this._renderIdle(ctx);
break;
case ROOM_STATE.CREATING:
case ROOM_STATE.JOINING:
this._renderConnecting(ctx);
break;
case ROOM_STATE.WAITING:
this._renderWaiting(ctx);
break;
case ROOM_STATE.INPUT_CODE:
this._renderInputCode(ctx);
break;
case ROOM_STATE.COUNTDOWN:
this._renderCountdown(ctx);
break;
case ROOM_STATE.ERROR:
this._renderError(ctx);
break;
}
},
_renderIdle(ctx) {
ctx.fillStyle = '#AAAAAA';
ctx.font = '14px Arial';
ctx.textAlign = 'center';
ctx.fillText(t('room.idleHint'), CENTER_X, SCREEN_HEIGHT * 0.28);
this._drawButton(ctx, this._createBtnRect, t('room.create'));
this._drawButton(ctx, this._joinBtnRect, t('room.join'));
},
_renderConnecting(ctx) {
const dots = '.'.repeat(Math.floor(this._animTimer * 3) % 4);
ctx.fillStyle = '#FFFFFF';
ctx.font = '16px Arial';
ctx.textAlign = 'center';
ctx.fillText(t('room.connecting', { dots }), CENTER_X, SCREEN_HEIGHT * 0.5);
},
_renderWaiting(ctx) {
// Room code display
ctx.fillStyle = '#FFFFFF';
ctx.font = '14px Arial';
ctx.textAlign = 'center';
ctx.fillText(t('room.roomCode'), CENTER_X, SCREEN_HEIGHT * 0.32);
ctx.fillStyle = COLORS.MENU_TITLE;
ctx.font = 'bold 36px Arial';
ctx.fillText(this._roomCode, CENTER_X, SCREEN_HEIGHT * 0.42);
// Waiting animation
const dots = '.'.repeat(Math.floor(this._animTimer * 2) % 4);
ctx.fillStyle = '#AAAAAA';
ctx.font = '16px Arial';
ctx.fillText(t('room.waiting', { dots }), CENTER_X, SCREEN_HEIGHT * 0.55);
// Hint
ctx.fillStyle = '#666666';
ctx.font = '12px Arial';
ctx.fillText(t('room.shareHint'), CENTER_X, SCREEN_HEIGHT * 0.65);
},
_renderInputCode(ctx) {
ctx.fillStyle = '#FFFFFF';
ctx.font = '14px Arial';
ctx.textAlign = 'center';
ctx.fillText(t('room.inputCode'), CENTER_X, SCREEN_HEIGHT * 0.25);
// Code display box
const boxW = Math.min(SCREEN_WIDTH * 0.5, 180);
const boxH = 40;
const boxX = CENTER_X - boxW / 2;
const boxY = SCREEN_HEIGHT * 0.30;
ctx.fillStyle = '#1a1a2e';
ctx.strokeStyle = COLORS.MENU_TITLE;
ctx.lineWidth = 2;
ctx.fillRect(boxX, boxY, boxW, boxH);
ctx.strokeRect(boxX, boxY, boxW, boxH);
// Input text
ctx.fillStyle = COLORS.MENU_TITLE;
ctx.font = 'bold 24px Arial';
const displayCode = this._inputCode + (Math.floor(this._animTimer * 2) % 2 === 0 ? '|' : '');
ctx.fillText(displayCode, CENTER_X, boxY + boxH / 2);
// Numpad
for (const btn of this._numpadRects) {
const label = btn.value === 'del' ? '⌫' : String(btn.value);
this._drawButton(ctx, btn, label, false, 16);
}
// Confirm button
if (this._inputCode.length >= 4) {
this._drawButton(ctx, this._confirmBtnRect, t('common.joinBtn'), false, 16);
}
},
_renderCountdown(ctx) {
ctx.fillStyle = '#00FF00';
ctx.font = '14px Arial';
ctx.textAlign = 'center';
ctx.fillText(t('room.opponentFound'), CENTER_X, SCREEN_HEIGHT * 0.35);
ctx.fillStyle = COLORS.MENU_TITLE;
ctx.font = 'bold 64px Arial';
ctx.fillText(String(this._countdown), CENTER_X, SCREEN_HEIGHT * 0.52);
ctx.fillStyle = '#AAAAAA';
ctx.font = '14px Arial';
ctx.fillText(t('room.starting'), 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('room.tapBack'), CENTER_X, SCREEN_HEIGHT * 0.55);
},
_drawButton(ctx, rect, label, pressed, fontSize) {
if (!rect) return;
const fs = fontSize || 16;
ctx.fillStyle = pressed ? '#0f3460' : COLORS.MENU_BTN;
ctx.strokeStyle = COLORS.MENU_BTN_BORDER;
ctx.lineWidth = 2;
// Rounded rect
const r = 6;
ctx.beginPath();
ctx.moveTo(rect.x + r, rect.y);
ctx.lineTo(rect.x + rect.w - r, rect.y);
ctx.arcTo(rect.x + rect.w, rect.y, rect.x + rect.w, rect.y + r, r);
ctx.lineTo(rect.x + rect.w, rect.y + rect.h - r);
ctx.arcTo(rect.x + rect.w, rect.y + rect.h, rect.x + rect.w - r, rect.y + rect.h, r);
ctx.lineTo(rect.x + r, rect.y + rect.h);
ctx.arcTo(rect.x, rect.y + rect.h, rect.x, rect.y + rect.h - r, r);
ctx.lineTo(rect.x, rect.y + r);
ctx.arcTo(rect.x, rect.y, rect.x + r, rect.y, r);
ctx.closePath();
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);
},
_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;
},
handleTouch(eventType, e) {
if (eventType !== 'touchstart') return;
const touch = e.touches[0];
const tx = touch.clientX;
const ty = touch.clientY;
// Back button (always available)
if (this._hitTest(tx, ty, this._backBtnRect)) {
this._goBack();
return;
}
switch (this._state) {
case ROOM_STATE.IDLE:
if (this._hitTest(tx, ty, this._createBtnRect)) {
this._handleCreateRoom();
} else if (this._hitTest(tx, ty, this._joinBtnRect)) {
this._state = ROOM_STATE.INPUT_CODE;
this._inputCode = '';
}
break;
case ROOM_STATE.INPUT_CODE:
// Check numpad
for (const btn of this._numpadRects) {
if (this._hitTest(tx, ty, btn)) {
if (btn.value === 'del') {
this._inputCode = this._inputCode.slice(0, -1);
} else if (this._inputCode.length < 6) {
this._inputCode += String(btn.value);
}
return;
}
}
// Check confirm
if (this._inputCode.length >= 4 && this._hitTest(tx, ty, this._confirmBtnRect)) {
this._handleJoinRoom();
}
break;
case ROOM_STATE.ERROR:
this._state = ROOM_STATE.IDLE;
this._errorMsg = '';
break;
case ROOM_STATE.WAITING:
// Allow going back while waiting
break;
}
},
async _handleCreateRoom() {
this._state = ROOM_STATE.CREATING;
const nm = this._networkManager;
if (!nm.connected) {
const ok = await nm.connect(this._serverUrl);
if (!ok) {
this._errorMsg = t('common.cannotConnect');
this._state = ROOM_STATE.ERROR;
return;
}
}
nm.createRoom();
},
async _handleJoinRoom() {
this._state = ROOM_STATE.JOINING;
const nm = this._networkManager;
if (!nm.connected) {
const ok = await nm.connect(this._serverUrl);
if (!ok) {
this._errorMsg = t('common.cannotConnect');
this._state = ROOM_STATE.ERROR;
return;
}
}
nm.joinRoom(this._inputCode);
},
_goBack() {
// Disconnect if connected
if (this._networkManager && this._networkManager.connected) {
this._networkManager.disconnect();
}
const sm = GameGlobal.sceneManager;
sm.switchTo(SCENE.MENU);
},
};
module.exports = RoomScene;