From 58fe43607af0836d46bcf9600e9df07c491df139 Mon Sep 17 00:00:00 2001 From: wsy182 <2392948297@qq.com> Date: Tue, 24 Mar 2026 15:25:40 +0800 Subject: [PATCH] update --- .gitignore | 3 + package.json | 1 + pnpm-lock.yaml | 29 ++ scripts/open-four-players.mjs | 183 ++++++++ .../chengdu-game/useChengduGameRoom.ts | 398 +++++++++++++++++- src/state/active-room.ts | 18 + src/views/ChengduGamePage.vue | 134 +++++- 7 files changed, 757 insertions(+), 9 deletions(-) create mode 100644 scripts/open-four-players.mjs diff --git a/.gitignore b/.gitignore index a547bf3..3549edd 100644 --- a/.gitignore +++ b/.gitignore @@ -22,3 +22,6 @@ dist-ssr *.njsproj *.sln *.sw? + + +.tmp/ diff --git a/package.json b/package.json index 22e2068..8efb35a 100644 --- a/package.json +++ b/package.json @@ -18,6 +18,7 @@ "@types/node": "^24.10.1", "@vitejs/plugin-vue": "^6.0.2", "@vue/tsconfig": "^0.8.1", + "playwright": "^1.58.2", "typescript": "~5.9.3", "vite": "^7.3.1", "vue-tsc": "^3.1.5" diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 699d22a..116355b 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -24,6 +24,9 @@ importers: '@vue/tsconfig': specifier: ^0.8.1 version: 0.8.1(typescript@5.9.3)(vue@3.5.28(typescript@5.9.3)) + playwright: + specifier: ^1.58.2 + version: 1.58.2 typescript: specifier: ~5.9.3 version: 5.9.3 @@ -448,6 +451,11 @@ packages: picomatch: optional: true + fsevents@2.3.2: + resolution: {integrity: sha512-xiqMQR4xAeHTuB9uWm+fFRcIOgKBMiOBP+eXiyT7jsgVCq1bkVygt00oASowB7EdtpOHaaPgKt812P9ab+DDKA==} + engines: {node: ^8.16.0 || ^10.6.0 || >=11.0.0} + os: [darwin] + fsevents@2.3.3: resolution: {integrity: sha512-5xoDfX+fL7faATnagmWPpbFtwh/R77WmMMqqHGS65C3vvB0YHrgF+B1YmZ3441tMj5n63k0212XNoJwzlhffQw==} engines: {node: ^8.16.0 || ^10.6.0 || >=11.0.0} @@ -474,6 +482,16 @@ packages: resolution: {integrity: sha512-5gTmgEY/sqK6gFXLIsQNH19lWb4ebPDLA4SdLP7dsWkIXHWlG66oPuVvXSGFPppYZz8ZDZq0dYYrbHfBCVUb1Q==} engines: {node: '>=12'} + playwright-core@1.58.2: + resolution: {integrity: sha512-yZkEtftgwS8CsfYo7nm0KE8jsvm6i/PTgVtB8DL726wNf6H2IMsDuxCpJj59KDaxCtSnrWan2AeDqM7JBaultg==} + engines: {node: '>=18'} + hasBin: true + + playwright@1.58.2: + resolution: {integrity: sha512-vA30H8Nvkq/cPBnNw4Q8TWz1EJyqgpuinBcHET0YVJVFldr8JDNiU9LaWAE1KqSkRYazuaBhTpB5ZzShOezQ6A==} + engines: {node: '>=18'} + hasBin: true + postcss@8.5.6: resolution: {integrity: sha512-3Ybi1tAuwAP9s0r1UQ2J4n5Y0G05bJkpUIO0/bI9MhwmD70S5aTWbXGBwxHrelT+XM1k6dM0pk+SwNkpTRN7Pg==} engines: {node: ^10 || ^12 || >=14} @@ -869,6 +887,9 @@ snapshots: optionalDependencies: picomatch: 4.0.3 + fsevents@2.3.2: + optional: true + fsevents@2.3.3: optional: true @@ -886,6 +907,14 @@ snapshots: picomatch@4.0.3: {} + playwright-core@1.58.2: {} + + playwright@1.58.2: + dependencies: + playwright-core: 1.58.2 + optionalDependencies: + fsevents: 2.3.2 + postcss@8.5.6: dependencies: nanoid: 3.3.11 diff --git a/scripts/open-four-players.mjs b/scripts/open-four-players.mjs new file mode 100644 index 0000000..ba3f80b --- /dev/null +++ b/scripts/open-four-players.mjs @@ -0,0 +1,183 @@ +import { chromium } from 'playwright' +import fs from 'node:fs/promises' +import path from 'node:path' +import process from 'node:process' + +const baseUrl = 'http://127.0.0.1:5173' +const chromePath = 'C:\\Program Files\\Google\\Chrome\\Application\\chrome.exe' +const rootDir = process.cwd() +const runtimeDir = path.join(rootDir, '.tmp', 'manual-four-players', String(Date.now())) + +const players = [ + { username: 'pwtest04', loginId: '13812345681', password: 'play123', owner: true }, + { username: 'pwtest01', loginId: '13812345678', password: 'play123', owner: false }, + { username: 'pwtest02', loginId: '13812345679', password: 'play123', owner: false }, + { username: 'pwtest03', loginId: '13812345680', password: 'play123', owner: false }, +] + +function log(message) { + console.log(`[setup] ${message}`) +} + +async function launchPlayer(player) { + const profileDir = path.join(runtimeDir, player.username) + await fs.mkdir(profileDir, { recursive: true }) + log(`launch browser for ${player.username}`) + + const context = await chromium.launchPersistentContext(profileDir, { + headless: false, + executablePath: chromePath, + args: ['--no-first-run', '--no-default-browser-check'], + viewport: { width: 1400, height: 960 }, + }) + + let page = context.pages()[0] + if (!page) { + page = await context.newPage() + } + await page.goto(`${baseUrl}/login`, { waitUntil: 'domcontentloaded' }) + return { ...player, context, page, profileDir } +} + +async function login(page, player) { + log(`login ${player.username}`) + await page.goto(`${baseUrl}/login`, { waitUntil: 'domcontentloaded' }) + await page.getByRole('textbox', { name: '登录ID' }).fill(player.loginId) + await page.getByRole('textbox', { name: '密码' }).fill(player.password) + const submitButton = page.locator('form').getByRole('button', { name: '登录' }) + await Promise.all([ + page.waitForURL('**/hall', { timeout: 15000 }), + submitButton.click(), + ]) + await page.getByText(`用户名:${player.username}`).waitFor({ timeout: 15000 }) + log(`logged in ${player.username}`) +} + +async function createRoom(page) { + const roomName = `manual-${Date.now()}` + log(`create room ${roomName}`) + await page.getByRole('button', { name: '创建房间' }).click() + await page.getByRole('textbox', { name: '房间名' }).fill(roomName) + await page.getByRole('button', { name: '创建', exact: true }).click() + const roomText = await page.getByText(/房间ID:/).textContent() + const roomId = roomText?.replace('房间ID:', '').trim() + if (!roomId) { + throw new Error('Failed to read room id') + } + return { roomId, roomName } +} + +async function ownerEnterRoom(page, roomId) { + log(`owner enter room ${roomId}`) + await Promise.all([ + page.waitForURL(`**/game/chengdu/${roomId}*`, { timeout: 15000 }), + page.getByRole('button', { name: '进入房间' }).click(), + ]) + log(`owner entered room ${roomId}`) +} + +async function ownerStartGame(page) { + log('owner start game') + const startButton = page.getByRole('button', { name: '开始游戏' }) + await startButton.waitFor({ timeout: 15000 }) + await startButton.click() + log('owner clicked start game') +} + +async function joinRoom(page, roomId, username) { + log(`join room ${roomId} as ${username}`) + await page.goto(`${baseUrl}/hall`, { waitUntil: 'domcontentloaded' }) + await page.getByRole('textbox', { name: '输入 room_id' }).fill(roomId) + await Promise.all([ + page.waitForURL(`**/game/chengdu/${roomId}*`, { timeout: 15000 }), + page.getByRole('button', { name: '加入' }).click(), + ]) + log(`joined room ${roomId} as ${username}`) +} + +async function snapshotPage(session) { + const bodyText = ((await session.page.locator('body').textContent()) ?? '').replace(/\s+/g, ' ').trim() + return { + username: session.username, + loginId: session.loginId, + url: session.page.url(), + started: bodyText.includes('对局中') || bodyText.includes('牌局进行中'), + bodyPreview: bodyText.slice(0, 240), + } +} + +async function main() { + await fs.mkdir(runtimeDir, { recursive: true }) + log(`runtimeDir ${runtimeDir}`) + + const sessions = [] + for (const player of players) { + sessions.push(await launchPlayer(player)) + } + + try { + for (const session of sessions) { + await login(session.page, session) + } + + const owner = sessions.find((session) => session.owner) + if (!owner) { + throw new Error('Owner session missing') + } + + const { roomId } = await createRoom(owner.page) + log(`room ready ${roomId}`) + + for (const session of sessions) { + if (!session.owner) { + await joinRoom(session.page, roomId, session.username) + } + } + + await ownerEnterRoom(owner.page, roomId) + await new Promise((resolve) => setTimeout(resolve, 3000)) + await ownerStartGame(owner.page) + + await new Promise((resolve) => setTimeout(resolve, 8000)) + + const playersSnapshot = [] + for (const session of sessions) { + let snapshot = await snapshotPage(session) + if (!snapshot.started) { + log(`reload game page for ${session.username}`) + await session.page.reload({ waitUntil: 'domcontentloaded' }) + await new Promise((resolve) => setTimeout(resolve, 3000)) + snapshot = await snapshotPage(session) + } + playersSnapshot.push(snapshot) + await session.page.bringToFront() + } + + console.log(JSON.stringify({ + roomId, + roomUrl: `${baseUrl}/game/chengdu/${roomId}`, + players: playersSnapshot, + }, null, 2)) + + if (process.env.KEEP_ALIVE === '1') { + log('setup finished; keeping browsers alive for manual testing') + await new Promise(() => {}) + } + + for (const session of sessions) { + await session.context.close() + } + } catch (error) { + console.error(error) + if (process.env.KEEP_ALIVE !== '1') { + for (const session of sessions) { + try { + await session.context.close() + } catch {} + } + } + throw error + } +} + +await main() diff --git a/src/features/chengdu-game/useChengduGameRoom.ts b/src/features/chengdu-game/useChengduGameRoom.ts index db8be98..0ca02ce 100644 --- a/src/features/chengdu-game/useChengduGameRoom.ts +++ b/src/features/chengdu-game/useChengduGameRoom.ts @@ -29,6 +29,17 @@ interface ActionEventLike { data?: unknown } +interface PendingClaimOption { + playerId: string + actions: string[] +} + +export interface ActionButtonState { + type: 'draw' | 'discard' | 'peng' | 'gang' | 'hu' | 'pass' + label: string + disabled: boolean +} + export interface SeatView { key: SeatKey player: RoomPlayerState | null @@ -38,6 +49,19 @@ export interface SeatView { subLabel: string } +function humanizeSuit(value: string): string { + const suitMap: Record = { + W: '万', + B: '筒', + T: '条', + wan: '万', + tong: '筒', + tiao: '条', + } + + return suitMap[value] ?? value +} + export interface ChengduGameRoomModel { auth: Ref roomState: typeof activeRoomState @@ -52,8 +76,12 @@ export interface ChengduGameRoomModel { leaveRoomPending: Ref canStartGame: ComputedRef seatViews: ComputedRef + selectedTile: Ref + actionButtons: ComputedRef connectWs: () => Promise sendStartGame: () => void + selectTile: (tile: string) => void + sendGameAction: (type: ActionButtonState['type']) => void backHall: () => void } @@ -69,10 +97,12 @@ export function useChengduGameRoom( const wsError = ref('') const wsMessages = ref([]) const startGamePending = ref(false) + const actionPending = ref(false) const lastStartRequestId = ref('') const leaveRoomPending = ref(false) const lastLeaveRoomRequestId = ref('') const leaveHallAfterAck = ref(false) + const selectedTile = ref(null) const roomId = computed(() => { return typeof route.params.roomId === 'string' ? route.params.roomId : '' @@ -121,6 +151,64 @@ export function useChengduGameRoom( ) }) + const myPlayer = computed(() => { + return roomState.value.players.find((player) => player.playerId === currentUserId.value) ?? null + }) + + const isMyTurn = computed(() => { + return myPlayer.value?.index === roomState.value.currentTurnIndex + }) + + const pendingClaimOptions = computed(() => { + return normalizePendingClaimOptions(roomState.value.game?.state?.pendingClaim) + }) + + const myClaimActions = computed(() => { + const claim = pendingClaimOptions.value.find((item) => item.playerId === currentUserId.value) + return new Set(claim?.actions ?? []) + }) + + const canDraw = computed(() => { + return roomState.value.status === 'playing' && isMyTurn.value && Boolean(roomState.value.game?.state?.needDraw) + }) + + const canDiscard = computed(() => { + return ( + roomState.value.status === 'playing' && + isMyTurn.value && + !roomState.value.game?.state?.needDraw && + roomState.value.myHand.length > 0 && + Boolean(selectedTile.value) + ) + }) + + const actionButtons = computed(() => { + return [ + { type: 'draw', label: '摸牌', disabled: !canDraw.value || actionPending.value }, + { type: 'discard', label: '出牌', disabled: !canDiscard.value || actionPending.value }, + { + type: 'peng', + label: '碰', + disabled: !myClaimActions.value.has('peng') || actionPending.value, + }, + { + type: 'gang', + label: '杠', + disabled: !myClaimActions.value.has('gang') || actionPending.value, + }, + { + type: 'hu', + label: '胡', + disabled: !myClaimActions.value.has('hu') || actionPending.value, + }, + { + type: 'pass', + label: '过', + disabled: !myClaimActions.value.has('pass') || actionPending.value, + }, + ] + }) + const seatViews = computed(() => { const seats: Record = { top: null, @@ -165,7 +253,7 @@ export function useChengduGameRoom( player, isSelf, isTurn: turnSeat === seat, - label: player ? (isSelf ? '你' : player.playerId) : '空位', + label: player ? (isSelf ? '你' : player.displayName || `玩家${player.index + 1}`) : '空位', subLabel: player ? `座位 ${player.index}` : '', } }) @@ -230,6 +318,29 @@ export function useChengduGameRoom( return '' } + function normalizeActionName(value: unknown): string { + const raw = toStringOrEmpty(value).trim().toLowerCase() + if (!raw) { + return '' + } + const actionMap: Record = { + candraw: 'draw', + draw: 'draw', + candiscard: 'discard', + discard: 'discard', + canpeng: 'peng', + peng: 'peng', + cangang: 'gang', + gang: 'gang', + canhu: 'hu', + hu: 'hu', + canpass: 'pass', + pass: 'pass', + } + + return actionMap[raw.replace(/[_\-\s]/g, '')] ?? raw + } + function toSession(source: NonNullable): AuthSession { return { token: source.token, @@ -361,19 +472,198 @@ export function useChengduGameRoom( return null } - const playerId = toStringOrEmpty(player.playerId ?? player.player_id ?? player.user_id ?? player.id) + const playerId = toStringOrEmpty( + player.playerId ?? + player.player_id ?? + player.PlayerID ?? + player.UserID ?? + player.user_id ?? + player.id, + ) if (!playerId) { return null } - const seatIndex = toFiniteNumber(player.index ?? player.seat ?? player.position ?? player.player_index) + const seatIndex = toFiniteNumber( + player.index ?? + player.Index ?? + player.seat ?? + player.Seat ?? + player.position ?? + player.Position ?? + player.player_index, + ) return { index: seatIndex ?? fallbackIndex, playerId, + displayName: + toStringOrEmpty( + player.playerName ?? + player.player_name ?? + player.PlayerName ?? + player.username ?? + player.nickname, + ) || undefined, ready: Boolean(player.ready), + handCount: + toFiniteNumber(player.handCount ?? player.hand_count ?? player.HandCount) ?? undefined, + melds: normalizeTileList(player.melds ?? player.Melds), + outTiles: normalizeTileList(player.outTiles ?? player.out_tiles ?? player.OutTiles), + hasHu: toBoolean(player.hasHu ?? player.has_hu ?? player.HasHu), + missingSuit: + toStringOrEmpty(player.missingSuit ?? player.missing_suit ?? player.MissingSuit) || null, } } + function normalizeTileList(value: unknown): string[] { + if (!Array.isArray(value)) { + return [] + } + + return value + .flatMap((item) => { + if (Array.isArray(item)) { + return item.map((nested) => toStringOrEmpty(nested)).filter(Boolean) + } + const record = toRecord(item) + if (record) { + const explicit = + toStringOrEmpty( + record.tile ?? record.Tile ?? record.code ?? record.Code ?? record.name ?? record.Name, + ) || '' + if (explicit) { + return [explicit] + } + + const suit = toStringOrEmpty(record.suit ?? record.Suit) + const tileValue = toStringOrEmpty(record.value ?? record.Value) + if (suit && tileValue) { + return [`${humanizeSuit(suit)}${tileValue}`] + } + + return [] + } + return [toStringOrEmpty(item)].filter(Boolean) + }) + .filter(Boolean) + } + + function normalizePublicGameState(source: Record): GameState | null { + const publicPlayers = (Array.isArray(source.players) ? source.players : []) + .map((item, index) => normalizePlayer(item, index)) + .filter((item): item is RoomPlayerState => Boolean(item)) + .sort((a, b) => a.index - b.index) + + const currentTurnPlayerId = toStringOrEmpty( + source.current_turn_player ?? source.currentTurnPlayer ?? source.current_turn_player_id, + ) + const currentTurnIndex = + publicPlayers.find((player) => player.playerId === currentTurnPlayerId)?.index ?? null + + return { + rule: null, + state: { + phase: toStringOrEmpty(source.phase), + dealerIndex: 0, + currentTurn: currentTurnIndex ?? 0, + needDraw: toBoolean(source.need_draw ?? source.needDraw), + players: publicPlayers.map((player) => ({ + playerId: player.playerId, + index: player.index, + ready: player.ready, + })), + wall: Array.from({ + length: toFiniteNumber(source.wall_count ?? source.wallCount) ?? 0, + }).map((_, index) => `wall-${index}`), + lastDiscardTile: + toStringOrEmpty(source.last_discard_tile ?? source.lastDiscardTile) || null, + lastDiscardBy: toStringOrEmpty(source.last_discard_by ?? source.lastDiscardBy), + pendingClaim: toRecord(source.pending_claim ?? source.pendingClaim), + winners: Array.isArray(source.winners) + ? source.winners.map((item) => toStringOrEmpty(item)).filter(Boolean) + : [], + scores: normalizeScores(source.scores), + lastDrawPlayerId: '', + lastDrawFromGang: false, + lastDrawIsLastTile: false, + huWay: '', + }, + } + } + + function normalizePendingClaimOptions(value: unknown): PendingClaimOption[] { + const pendingClaim = toRecord(value) + if (!pendingClaim) { + return [] + } + + const rawOptions = + (Array.isArray(pendingClaim.options) ? pendingClaim.options : null) ?? + (Array.isArray(pendingClaim.Options) ? pendingClaim.Options : null) ?? + [] + + const optionsFromArray = rawOptions + .map((option) => { + const record = toRecord(option) + if (!record) { + return null + } + const playerId = toStringOrEmpty( + record.playerId ?? record.player_id ?? record.PlayerID ?? record.user_id ?? record.UserID, + ) + if (!playerId) { + return null + } + const actions = new Set() + for (const value of Object.values(record)) { + if (typeof value === 'boolean' && value) { + continue + } + } + for (const [key, enabled] of Object.entries(record)) { + if (typeof enabled === 'boolean' && enabled) { + const normalized = normalizeActionName(key) + if (normalized && normalized !== 'playerid' && normalized !== 'userid') { + actions.add(normalized) + } + } + } + if (Array.isArray(record.actions)) { + for (const action of record.actions) { + const normalized = normalizeActionName(action) + if (normalized) { + actions.add(normalized) + } + } + } + + return { playerId, actions: [...actions] } + }) + .filter((item): item is PendingClaimOption => Boolean(item)) + + if (optionsFromArray.length > 0) { + return optionsFromArray + } + + const claimPlayerId = toStringOrEmpty( + pendingClaim.playerId ?? + pendingClaim.player_id ?? + pendingClaim.PlayerID ?? + pendingClaim.user_id ?? + pendingClaim.UserID, + ) + if (!claimPlayerId) { + return [] + } + + const actions = Object.entries(pendingClaim) + .filter(([, enabled]) => typeof enabled === 'boolean' && enabled) + .map(([key]) => normalizeActionName(key)) + .filter(Boolean) + + return actions.length > 0 ? [{ playerId: claimPlayerId, actions }] : [] + } + function extractCurrentTurnIndex(value: Record): number | null { const game = toRecord(value.game) const gameState = toRecord(game?.state) @@ -495,21 +785,44 @@ export function useChengduGameRoom( .filter((item) => Boolean(item.playerId)) const resolvedPlayers = players.length > 0 ? players : playersFromIds const parsedPlayerCount = toFiniteNumber(room.player_count ?? room.playerCount) - const game = normalizeGame(room.game) + const game = normalizeGame(room.game) ?? normalizePublicGameState(room) + const playersFromGame = game?.state?.players + .map((player, index) => + normalizePlayer( + { + player_id: player.playerId, + index: player.index ?? index, + }, + index, + ), + ) + .filter((item): item is RoomPlayerState => Boolean(item)) + const finalPlayers = + resolvedPlayers.length > 0 ? resolvedPlayers : playersFromGame && playersFromGame.length > 0 ? playersFromGame : [] + const derivedTurnIndex = + extractCurrentTurnIndex(room) ?? + (game?.state + ? finalPlayers.find((player) => player.playerId === toStringOrEmpty(room.current_turn_player ?? room.currentTurnPlayer)) + ?.index ?? null + : null) return { id, name: toStringOrEmpty(room.name) || roomState.value.name, gameType: toStringOrEmpty(room.gameType ?? room.game_type) || roomState.value.gameType || 'chengdu', - ownerId: toStringOrEmpty(room.ownerId ?? room.owner_id), + ownerId: toStringOrEmpty(room.ownerId ?? room.owner_id ?? room.OwnerID ?? room.ownerID), maxPlayers, - playerCount: parsedPlayerCount ?? resolvedPlayers.length, + playerCount: + parsedPlayerCount ?? + toFiniteNumber(room.player_count ?? room.playerCount ?? room.playerCount) ?? + finalPlayers.length, status: toStringOrEmpty(room.status) || roomState.value.status || 'waiting', createdAt: toStringOrEmpty(room.createdAt ?? room.created_at) || roomState.value.createdAt, updatedAt: toStringOrEmpty(room.updatedAt ?? room.updated_at) || roomState.value.updatedAt, game: game ?? roomState.value.game, - players: resolvedPlayers, - currentTurnIndex: extractCurrentTurnIndex(room), + players: finalPlayers, + currentTurnIndex: derivedTurnIndex, + myHand: [], } } @@ -607,6 +920,34 @@ export function useChengduGameRoom( ) { startGamePending.value = false } + + if (event.status === 'error') { + actionPending.value = false + } + + if (eventType === 'my_hand') { + const handPayload = payload ?? data + const handRecord = toRecord(handPayload) + const hand = normalizeTileList(handRecord?.hand ?? handRecord?.tiles ?? handRecord?.myHand) + roomState.value = { + ...roomState.value, + myHand: hand, + playerCount: roomState.value.playerCount || roomState.value.players.length, + } + if (!selectedTile.value || !hand.includes(selectedTile.value)) { + selectedTile.value = hand[0] ?? null + } + actionPending.value = false + return + } + + if ( + ['room_state', 'room_player_update', 'room_joined', 'room_member_joined', 'room_member_left'].includes( + eventType, + ) + ) { + actionPending.value = false + } } function createRequestId(prefix: string): string { @@ -648,6 +989,41 @@ export function useChengduGameRoom( pushWsMessage(`[client] 请求开始游戏 requestId=${requestId}`) } + function selectTile(tile: string): void { + selectedTile.value = selectedTile.value === tile ? null : tile + } + + function sendGameAction(type: ActionButtonState['type']): void { + if (!ws.value || ws.value.readyState !== WebSocket.OPEN || !currentUserId.value) { + return + } + + const requestId = createRequestId(type) + const payload: Record = {} + + if (type === 'discard' && selectedTile.value) { + payload.tile = selectedTile.value + payload.discard_tile = selectedTile.value + payload.code = selectedTile.value + } + + actionPending.value = true + const message = { + type, + sender: currentUserId.value, + target: 'room', + roomId: roomState.value.id || roomId.value, + seq: Date.now(), + requestId, + trace_id: createRequestId('trace'), + payload, + } + + logWsSend(message) + ws.value.send(JSON.stringify(message)) + pushWsMessage(`[client] 请求${type} requestId=${requestId}`) + } + function sendLeaveRoom(): boolean { if (!ws.value || ws.value.readyState !== WebSocket.OPEN) { wsError.value = 'WebSocket 未连接,无法退出房间' @@ -774,6 +1150,8 @@ export function useChengduGameRoom( leaveRoomPending.value = false lastLeaveRoomRequestId.value = '' leaveHallAfterAck.value = false + actionPending.value = false + selectedTile.value = null }, { immediate: true }, ) @@ -826,8 +1204,12 @@ export function useChengduGameRoom( leaveRoomPending, canStartGame, seatViews, + selectedTile, + actionButtons, connectWs, sendStartGame, + selectTile, + sendGameAction, backHall, } } diff --git a/src/state/active-room.ts b/src/state/active-room.ts index 67706e0..a12be75 100644 --- a/src/state/active-room.ts +++ b/src/state/active-room.ts @@ -6,7 +6,13 @@ export type RoomStatus = 'waiting' | 'playing' | 'finished' export interface RoomPlayerState { index: number playerId: string + displayName?: string ready: boolean + handCount?: number + melds?: string[] + outTiles?: string[] + hasHu?: boolean + missingSuit?: string | null } export interface RuleState { @@ -57,6 +63,7 @@ export interface RoomState { game: GameState | null players: RoomPlayerState[] currentTurnIndex: number | null + myHand: string[] } function createInitialRoomState(): RoomState { @@ -73,6 +80,7 @@ function createInitialRoomState(): RoomState { game: null, players: [], currentTurnIndex: null, + myHand: [], } } @@ -92,6 +100,7 @@ export function resetActiveRoomState(seed?: Partial): void { ...activeRoomState.value, ...seed, players: seed.players ?? [], + myHand: seed.myHand ?? [], } } @@ -103,10 +112,17 @@ export function mergeActiveRoomState(next: RoomState): void { activeRoomState.value = { ...activeRoomState.value, ...next, + name: next.name || activeRoomState.value.name, + gameType: next.gameType || activeRoomState.value.gameType, + ownerId: next.ownerId || activeRoomState.value.ownerId, + status: next.status || activeRoomState.value.status, + createdAt: next.createdAt || activeRoomState.value.createdAt, + updatedAt: next.updatedAt || activeRoomState.value.updatedAt, game: next.game ?? activeRoomState.value.game, players: next.players.length > 0 ? next.players : activeRoomState.value.players, currentTurnIndex: next.currentTurnIndex !== null ? next.currentTurnIndex : activeRoomState.value.currentTurnIndex, + myHand: next.myHand.length > 0 ? next.myHand : activeRoomState.value.myHand, } } @@ -122,6 +138,7 @@ export function hydrateActiveRoomFromSelection(input: { updatedAt?: string players?: RoomPlayerState[] currentTurnIndex?: number | null + myHand?: string[] }): void { resetActiveRoomState({ id: input.roomId, @@ -135,5 +152,6 @@ export function hydrateActiveRoomFromSelection(input: { updatedAt: input.updatedAt ?? '', players: input.players ?? [], currentTurnIndex: input.currentTurnIndex ?? null, + myHand: input.myHand ?? [], }) } diff --git a/src/views/ChengduGamePage.vue b/src/views/ChengduGamePage.vue index 04dc592..de7ff56 100644 --- a/src/views/ChengduGamePage.vue +++ b/src/views/ChengduGamePage.vue @@ -27,8 +27,12 @@ const { leaveRoomPending, canStartGame, seatViews, + selectedTile, + actionButtons, connectWs, sendStartGame, + selectTile, + sendGameAction, backHall, } = useChengduGameRoom(route, router) @@ -129,7 +133,7 @@ const seatDecor = computed>(() => { const score = scoreMap[playerId] result[seat.key] = { avatar: seat.isSelf ? '我' : String(index + 1), - name: seat.isSelf ? '你自己' : playerId, + name: seat.isSelf ? '你自己' : seat.player.displayName || `玩家${seat.player.index + 1}`, money: typeof score === 'number' ? String(score) : '--', dealer: seat.player.index === dealerIndex, isTurn: seat.isTurn, @@ -170,6 +174,23 @@ const centerTimer = computed(() => { : '等待中' }) +const pendingClaimText = computed(() => { + const claim = roomState.value.game?.state?.pendingClaim + if (!claim) { + return '无' + } + + try { + return JSON.stringify(claim) + } catch { + return '存在响应窗口' + } +}) + +const selectedTileText = computed(() => { + return selectedTile.value ?? '未选择' +}) + function missingSuitLabel(value: string | null | undefined): string { if (!value) { return '未定' @@ -270,6 +291,51 @@ onBeforeUnmount(() => { +
+
+
+

我的手牌

+

当前仅渲染 `my_hand` 事件下发的真实手牌。

+
+ +
+

尚未收到 `my_hand`。

+

已选牌:{{ selectedTileText }}

+
+ +
+

对局动作

+

已接入 `draw / discard / peng / gang / hu / pass` WS 发包。

+
+ +
+

当前响应窗口:{{ pendingClaimText }}

+

+ 最近弃牌:{{ roomState.game?.state?.lastDiscardTile || '无' }} + / {{ roomState.game?.state?.lastDiscardBy || '无' }} +

+
+
+
+
@@ -345,3 +411,69 @@ onBeforeUnmount(() => {
+ +