567 lines
16 KiB
JavaScript
567 lines
16 KiB
JavaScript
/**
|
|
* 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.CREATING,
|
|
_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.CREATING) {
|
|
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: 'js/ui/images/1v1.png',
|
|
query: `roomId=${this._roomCode}`,
|
|
});
|
|
}
|
|
},
|
|
|
|
/**
|
|
* Handle "Invite Friend" button tap — trigger WeChat share.
|
|
* MUST be called within a touch event for WeChat policy compliance.
|
|
* @private
|
|
*/
|
|
_handleInvite() {
|
|
if (!this._roomCode) return;
|
|
|
|
const shareData = {
|
|
title: t('room.shareTitle'),
|
|
imageUrl: 'js/ui/images/1v1.png',
|
|
query: `roomId=${this._roomCode}`,
|
|
};
|
|
|
|
console.log(`[RoomScene] Sharing 1v1 invite with query: roomId=${this._roomCode}`);
|
|
|
|
const shareManager = GameGlobal.shareManager;
|
|
if (shareManager) {
|
|
shareManager.triggerShare(shareData);
|
|
} else {
|
|
try {
|
|
if (typeof wx !== 'undefined' && wx.showToast) {
|
|
wx.showToast({
|
|
title: '请点击右上角 ··· 转发给好友',
|
|
icon: 'none',
|
|
duration: 2500,
|
|
});
|
|
}
|
|
} catch (e) {
|
|
console.log('[RoomScene] Share not available, roomId:', this._roomCode);
|
|
}
|
|
}
|
|
},
|
|
|
|
/**
|
|
* One-step invite: auto-create room then immediately share the invite card.
|
|
* This is the primary action — automatically creates a room and triggers invite share
|
|
* and we handle everything in one go.
|
|
* MUST be called within a touch event for WeChat policy compliance.
|
|
* @private
|
|
*/
|
|
async _handleInviteAndCreate() {
|
|
const nm = this._networkManager;
|
|
|
|
if (!nm) {
|
|
this._errorMsg = t('common.cannotConnect');
|
|
this._state = ROOM_STATE.ERROR;
|
|
return;
|
|
}
|
|
|
|
// If already connected and in a room (WAITING state), just re-share
|
|
if (this._state === ROOM_STATE.WAITING && this._roomCode) {
|
|
this._handleInvite();
|
|
return;
|
|
}
|
|
|
|
// Connect if needed
|
|
if (!nm.connected) {
|
|
this._state = ROOM_STATE.CREATING;
|
|
const ok = await nm.connect(this._serverUrl);
|
|
if (!ok) {
|
|
this._errorMsg = t('common.cannotConnect');
|
|
this._state = ROOM_STATE.ERROR;
|
|
return;
|
|
}
|
|
}
|
|
|
|
// Create room — the ROOM_CREATED event handler will set state to WAITING
|
|
// and store the roomCode. We need to share AFTER the room is created,
|
|
// so we listen for the event once.
|
|
const shareOnCreated = (data) => {
|
|
const roomId = data.roomId || data.roomCode || '';
|
|
if (!roomId) return;
|
|
|
|
const shareData = {
|
|
title: t('room.shareTitle'),
|
|
imageUrl: 'js/ui/images/1v1.png',
|
|
query: `roomId=${roomId}`,
|
|
};
|
|
|
|
console.log(`[RoomScene] Auto-sharing 1v1 invite after room created: roomId=${roomId}`);
|
|
|
|
const shareManager = GameGlobal.shareManager;
|
|
if (shareManager) {
|
|
shareManager.triggerShare(shareData);
|
|
} else {
|
|
try {
|
|
if (typeof wx !== 'undefined' && wx.showToast) {
|
|
wx.showToast({
|
|
title: '请点击右上角 ··· 转发给好友',
|
|
icon: 'none',
|
|
duration: 2500,
|
|
});
|
|
}
|
|
} catch (e) {
|
|
console.log('[RoomScene] Share not available after room created');
|
|
}
|
|
}
|
|
};
|
|
|
|
// Subscribe one-time for auto-share after room creation
|
|
const unsub = nm.on(NET_MSG.ROOM_CREATED, (data) => {
|
|
// Unsubscribe immediately (one-time listener)
|
|
unsub();
|
|
shareOnCreated(data);
|
|
});
|
|
|
|
// Now create the room
|
|
nm.createRoom();
|
|
},
|
|
|
|
_startGame(data) {
|
|
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.CREATING;
|
|
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;
|