/** * RoomScene.js * PVP 1v1 online multiplayer scene. * Entering this scene automatically creates a room and triggers invite share. * From an invite card, auto-joins the specified room. */ 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 = { CREATING: 'creating', // Connecting and creating room (also initial state) WAITING: 'waiting', // Room created, waiting for opponent JOINING: 'joining', // Joining a room COUNTDOWN: 'countdown', // Both players ready, counting down ERROR: 'error', // Error state }; // ============================================================ // Room Scene // ============================================================ const RoomScene = { _state: ROOM_STATE.IDLE, _roomCode: '', _errorMsg: '', _countdown: 3, _countdownTimer: 0, _animTimer: 0, _networkManager: null, _unsubscribers: [], // Server URL (from global config) _serverUrl: SERVER_URL, // Button rects (calculated in enter) _backBtnRect: null, _inviteBtnRect: null, enter(params) { this._errorMsg = ''; this._countdown = 3; this._countdownTimer = 0; this._animTimer = 0; this._pendingStartData = null; this._networkManager = GameGlobal.networkManager; this._backBtnRect = { x: 10, y: 10, w: 60, h: 30, }; // Invite friend button (shown in WAITING state) const inviteBtnW = Math.min(SCREEN_WIDTH * 0.5, 240); const inviteBtnH = Math.min(40, SCREEN_HEIGHT * 0.08); this._inviteBtnRect = { x: CENTER_X - inviteBtnW / 2, y: SCREEN_HEIGHT * 0.73, w: inviteBtnW, h: inviteBtnH, }; // Setup network event listeners this._setupNetworkEvents(); // Decide initial flow based on entry params if (params && params.roomId) { // From invite card — auto-join the room this._autoJoinRoom(params.roomId); } else { // Direct entry from menu — auto-create room + share (one-step invite) this._state = ROOM_STATE.CREATING; this._roomCode = ''; this._handleInviteAndCreate(); } }, exit() { this._cleanupNetworkEvents(); // Reset share content when leaving room const shareManager = GameGlobal.shareManager; if (shareManager) { shareManager.resetShareContent(); } }, _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; // Update share content so right-corner menu also carries the roomId this._updateShareContent(); })); 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 } } } }, /** * Update share content so the right-corner ··· menu always carries * the current roomId for 1v1 invite. * @private */ _updateShareContent() { if (!this._roomCode) return; const shareManager = GameGlobal.shareManager; if (shareManager) { shareManager.setShareContent({ title: t('room.shareTitle'), imageUrl: '', query: `roomId=${this._roomCode}`, }); } }, /** * Handle "Invite Friend" button tap — trigger WeChat share. * MUST be called within a touch event for WeChat policy compliance. * @private */ _handleInvite() { if (!this._roomCode) return; const shareData = { title: t('room.shareTitle'), imageUrl: '', query: `roomId=${this._roomCode}`, }; console.log(`[RoomScene] Sharing 1v1 invite with query: roomId=${this._roomCode}`); const shareManager = GameGlobal.shareManager; if (shareManager) { shareManager.triggerShare(shareData); } else { try { if (typeof wx !== 'undefined' && wx.showToast) { wx.showToast({ title: '请点击右上角 ··· 转发给好友', icon: 'none', duration: 2500, }); } } catch (e) { console.log('[RoomScene] Share not available, roomId:', this._roomCode); } } }, /** * One-step invite: auto-create room then immediately share the invite card. * This is the primary action on the IDLE screen — user clicks "Invite Friend" * and we handle everything in one go. * MUST be called within a touch event for WeChat policy compliance. * @private */ async _handleInviteAndCreate() { const nm = this._networkManager; if (!nm) { this._errorMsg = t('common.cannotConnect'); this._state = ROOM_STATE.ERROR; return; } // If already connected and in a room (WAITING state), just re-share if (this._state === ROOM_STATE.WAITING && this._roomCode) { this._handleInvite(); return; } // Connect if needed if (!nm.connected) { this._state = ROOM_STATE.CREATING; const ok = await nm.connect(this._serverUrl); if (!ok) { this._errorMsg = t('common.cannotConnect'); this._state = ROOM_STATE.ERROR; return; } } // Create room — the ROOM_CREATED event handler will set state to WAITING // and store the roomCode. We need to share AFTER the room is created, // so we listen for the event once. const shareOnCreated = (data) => { const roomId = data.roomId || data.roomCode || ''; if (!roomId) return; const shareData = { title: t('room.shareTitle'), imageUrl: '', query: `roomId=${roomId}`, }; console.log(`[RoomScene] Auto-sharing 1v1 invite after room created: roomId=${roomId}`); const shareManager = GameGlobal.shareManager; if (shareManager) { shareManager.triggerShare(shareData); } else { try { if (typeof wx !== 'undefined' && wx.showToast) { wx.showToast({ title: '请点击右上角 ··· 转发给好友', icon: 'none', duration: 2500, }); } } catch (e) { console.log('[RoomScene] Share not available after room created'); } } }; // Subscribe one-time for auto-share after room creation const unsub = nm.on(NET_MSG.ROOM_CREATED, (data) => { // Unsubscribe immediately (one-time listener) unsub(); shareOnCreated(data); }); // Now create the room nm.createRoom(); }, _startGame(data) { 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.CREATING: case ROOM_STATE.JOINING: this._renderConnecting(ctx); break; case ROOM_STATE.WAITING: this._renderWaiting(ctx); break; case ROOM_STATE.COUNTDOWN: this._renderCountdown(ctx); break; case ROOM_STATE.ERROR: this._renderError(ctx); break; } }, _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.52); // Invite friend button (primary action) this._drawButton(ctx, this._inviteBtnRect, t('room.inviteFriend'), false, 16, '#e94560'); // Hint text below the button ctx.fillStyle = '#666666'; ctx.font = '12px Arial'; ctx.fillText(t('room.shareHint'), CENTER_X, SCREEN_HEIGHT * 0.84); }, _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, bgColor) { if (!rect) return; const fs = fontSize || 16; ctx.fillStyle = bgColor || (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.ERROR: this._state = ROOM_STATE.IDLE; this._errorMsg = ''; break; case ROOM_STATE.WAITING: // Invite friend button if (this._hitTest(tx, ty, this._inviteBtnRect)) { this._handleInvite(); } // Allow going back while waiting break; } }, /** * Auto-join a 1v1 room when entering from an invite card. * @param {string} roomId - Room ID from the invite card query parameter. */ async _autoJoinRoom(roomId) { this._state = ROOM_STATE.JOINING; this._errorMsg = ''; const nm = this._networkManager; if (!nm) { this._errorMsg = t('common.cannotConnect'); this._state = ROOM_STATE.ERROR; return; } try { if (!nm.connected) { const ok = await nm.connect(this._serverUrl); if (!ok) { this._errorMsg = t('common.cannotConnect'); this._state = ROOM_STATE.ERROR; return; } } console.log(`[RoomScene] Auto-joining 1v1 room ${roomId}`); nm.joinRoom(roomId); } catch (e) { console.error('[RoomScene] Auto-join failed:', e); this._errorMsg = t('common.cannotConnect'); this._state = ROOM_STATE.ERROR; } }, _goBack() { // Disconnect if connected if (this._networkManager && this._networkManager.connected) { this._networkManager.disconnect(); } const sm = GameGlobal.sceneManager; sm.switchTo(SCENE.MENU); }, }; module.exports = RoomScene;