diff --git a/src/game/actions.ts b/src/game/actions.ts index df17dca..41839f8 100644 --- a/src/game/actions.ts +++ b/src/game/actions.ts @@ -30,6 +30,22 @@ export interface RoomTrusteePayload { reason?: string } +export interface DiscardActionPayload { + player_id?: string + playerId?: string + PlayerID?: string + tile?: Tile + next_seat?: number + nextSeat?: number +} + +export interface DrawActionPayload { + player_id?: string + playerId?: string + PlayerID?: string + tile?: Tile +} + export interface PlayerTurnPayload { player_id?: string playerId?: string @@ -69,20 +85,13 @@ export type GameAction = // 摸牌 | { type: 'DRAW_TILE' - payload: { - playerId: string - tile: Tile - } + payload: DrawActionPayload } // 出牌 | { type: 'PLAY_TILE' - payload: { - playerId: string - tile: Tile - nextSeat: number - } + payload: DiscardActionPayload } // 进入操作窗口(碰/杠/胡) diff --git a/src/store/gameStore.ts b/src/store/gameStore.ts index 5c4c483..45f6913 100644 --- a/src/store/gameStore.ts +++ b/src/store/gameStore.ts @@ -70,13 +70,21 @@ export const useGameStore = defineStore('game', { }, // 摸牌 - onDrawTile(data: { playerId: string; tile: Tile }) { - const player = this.players[data.playerId] + onDrawTile(data: { playerId?: string; player_id?: string; PlayerID?: string; tile?: Tile }) { + const playerId = + (typeof data.playerId === 'string' && data.playerId) || + (typeof data.player_id === 'string' && data.player_id) || + (typeof data.PlayerID === 'string' && data.PlayerID) || + '' + const tile = data.tile + if (!playerId || !tile) return + + const player = this.players[playerId] if (!player) return // 只更新自己的手牌 if (player.playerId === this.getMyPlayerId()) { - player.handTiles.push(data.tile) + player.handTiles.push(tile) } player.handCount += 1 @@ -97,17 +105,28 @@ export const useGameStore = defineStore('game', { // 出牌 onPlayTile(data: { - playerId: string - tile: Tile - nextSeat: number + playerId?: string + player_id?: string + PlayerID?: string + tile?: Tile + nextSeat?: number + next_seat?: number }) { - const player = this.players[data.playerId] + const playerId = + (typeof data.playerId === 'string' && data.playerId) || + (typeof data.player_id === 'string' && data.player_id) || + (typeof data.PlayerID === 'string' && data.PlayerID) || + '' + const tile = data.tile + if (!playerId || !tile) return + + const player = this.players[playerId] if (!player) return // 如果是自己,移除手牌 if (player.playerId === this.getMyPlayerId()) { const index = player.handTiles.findIndex( - (t) => t.id === data.tile.id + (t) => t.id === tile.id ) if (index !== -1) { player.handTiles.splice(index, 1) @@ -116,10 +135,16 @@ export const useGameStore = defineStore('game', { player.handCount = Math.max(0, player.handCount - 1) // 加入出牌�? - player.discardTiles.push(data.tile) + player.discardTiles.push(tile) // 更新回合 - this.currentTurn = data.nextSeat + const nextSeat = + typeof data.nextSeat === 'number' + ? data.nextSeat + : typeof data.next_seat === 'number' + ? data.next_seat + : this.currentTurn + this.currentTurn = nextSeat this.needDraw = true // 等待其他玩家响应 @@ -297,4 +322,3 @@ export const useGameStore = defineStore('game', { }, }) - diff --git a/src/views/ChengduGamePage.vue b/src/views/ChengduGamePage.vue index 2b13222..8437ef2 100644 --- a/src/views/ChengduGamePage.vue +++ b/src/views/ChengduGamePage.vue @@ -34,20 +34,55 @@ const session = useChengduGameSession({ gameStore, roomMeta, }) +const { + now, + wsStatus, + wsError, + roomCountdown, + leaveRoomPending, + readyTogglePending, + dingQuePending, + discardPending, + claimActionPending, + turnActionPending, + nextRoundPending, + selectedDiscardTileId, + menuOpen, + isTrustMode, + menuTriggerActive, + loggedInUserId, + networkLabel, + formattedClock, + toggleMenu, + toggleTrustMode, + backHall, +} = session const routeRoomName = computed(() => (typeof route.query.roomName === 'string' ? route.query.roomName : '')) -const myPlayer = computed(() => gameStore.players[session.loggedInUserId.value] as DisplayPlayer | undefined) +const myPlayer = computed(() => gameStore.players[loggedInUserId.value] as DisplayPlayer | undefined) const myHandTiles = computed(() => myPlayer.value?.handTiles ?? []) const gamePlayers = computed(() => Object.values(gameStore.players).sort((a, b) => a.seatIndex - b.seatIndex) as DisplayPlayer[], ) -const tableView = useChengduTableView({ +const { + roomName, + roomState, + seatWinds, + currentTurnSeat, + currentPhaseText, + roomStatusText, + roundText, + wallSeats, + deskSeats, + seatDecor, + settlementPlayers, +} = useChengduTableView({ roomMeta, gamePlayers, gameStore, localCachedAvatarUrl: session.localCachedAvatarUrl, - loggedInUserId: session.loggedInUserId, + loggedInUserId, loggedInUserName: session.loggedInUserName, myHandTiles, myPlayer, @@ -59,13 +94,43 @@ const socket = useChengduGameSocket({ router, gameStore, roomMeta, - roomName: tableView.roomName, + roomName, myHandTiles, myPlayer, session, }) +const { showSettlementOverlay, settlementCountdown } = socket -const actions = useChengduGameActions({ +const { + isLastRound, + myReadyState, + isRoomOwner, + showStartGameButton, + showWaitingOwnerTip, + canStartGame, + showReadyToggle, + showDingQueChooser, + selectedDiscardTile, + discardBlockedReason, + discardTileBlockedReason, + canConfirmDiscard, + confirmDiscardLabel, + canDrawTile, + visibleClaimOptions, + showClaimActions, + canSelfHu, + canSelfGang, + toggleReadyState, + startGame, + nextRound, + chooseDingQue, + selectDiscardTile, + confirmDiscard, + drawTile, + submitSelfGang, + submitSelfHu, + submitClaim, +} = useChengduGameActions({ gameStore, roomMeta, gamePlayers, @@ -74,7 +139,7 @@ const actions = useChengduGameActions({ }) const actionCountdown = computed(() => { - const countdown = session.roomCountdown.value + const countdown = roomCountdown.value if (!countdown) { return null } @@ -82,7 +147,7 @@ const actionCountdown = computed(() => { const deadlineAt = countdown.actionDeadlineAt ? Date.parse(countdown.actionDeadlineAt) : Number.NaN const fallbackRemaining = countdown.remaining > 0 ? countdown.remaining : countdown.countdownSeconds const derivedRemaining = Number.isFinite(deadlineAt) - ? Math.ceil((deadlineAt - session.now.value) / 1000) + ? Math.ceil((deadlineAt - now.value) / 1000) : fallbackRemaining const remaining = Math.max(0, derivedRemaining) @@ -97,7 +162,7 @@ const actionCountdown = computed(() => { const playerLabel = targetPlayerIds .map((playerId) => { - if (playerId === session.loggedInUserId.value) { + if (playerId === loggedInUserId.value) { return '你' } const targetPlayer = gameStore.players[playerId] @@ -111,7 +176,7 @@ const actionCountdown = computed(() => { }) .join('、') const duration = countdown.duration > 0 ? countdown.duration : Math.max(remaining, fallbackRemaining, 1) - const includesSelf = targetPlayerIds.includes(session.loggedInUserId.value) + const includesSelf = targetPlayerIds.includes(loggedInUserId.value) return { playerLabel, @@ -123,8 +188,8 @@ const actionCountdown = computed(() => { }) function handleLeaveRoom(): void { - session.menuOpen.value = false - session.backHall() + menuOpen.value = false + backHall() } @@ -140,24 +205,24 @@ function handleLeaveRoom(): void {
- - - - + + + + - + - + diff --git a/src/views/chengdu/socket/handlers/statusHandlers.ts b/src/views/chengdu/socket/handlers/statusHandlers.ts index 47ebeba..4098e25 100644 --- a/src/views/chengdu/socket/handlers/statusHandlers.ts +++ b/src/views/chengdu/socket/handlers/statusHandlers.ts @@ -3,10 +3,36 @@ import { normalizeWsType, readString, } from '../../../../game/chengdu/messageNormalizers' -import { clearClaimAndTurnPending, pushWsMessage, setWsError } from '../session/sessionStateAdapter' +import { clearClaimAndTurnPending, clearRoomCountdown, pushWsMessage, setWsError } from '../session/sessionStateAdapter' import type { SocketHandlerContext, StatusHandlerApi } from '../types' export function createStatusHandlers(context: SocketHandlerContext): StatusHandlerApi { + function handleActionAck(message: unknown): void { + const source = asRecord(message) + if (!source || typeof source.type !== 'string' || normalizeWsType(source.type) !== 'ACTION_ACK') { + return + } + + const payload = asRecord(source.payload) + const roomId = readString(payload ?? {}, 'room_id', 'roomId') || readString(source, 'roomId') + if (roomId && context.gameStore.roomId && roomId !== context.gameStore.roomId) { + return + } + + const action = normalizeWsType(readString(payload ?? {}, 'action') || readString(source, 'action')) + if ( + action === 'DISCARD' || + action === 'DRAW' || + action === 'PENG' || + action === 'GANG' || + action === 'HU' || + action === 'PASS' || + action === 'DING_QUE' + ) { + clearRoomCountdown(context.session) + } + } + function handleActionError(message: unknown): void { const source = asRecord(message) if (!source || typeof source.type !== 'string' || normalizeWsType(source.type) !== 'ACTION_ERROR') { @@ -27,6 +53,7 @@ export function createStatusHandlers(context: SocketHandlerContext): StatusHandl } return { + handleActionAck, handleActionError, } } diff --git a/src/views/chengdu/socket/handlers/turnHandlers.ts b/src/views/chengdu/socket/handlers/turnHandlers.ts index defc630..8b6a4db 100644 --- a/src/views/chengdu/socket/handlers/turnHandlers.ts +++ b/src/views/chengdu/socket/handlers/turnHandlers.ts @@ -24,6 +24,46 @@ import { resetRoundResolutionState } from '../store/gameStoreAdapter' import type { SocketHandlerContext, TurnHandlerApi, TurnPayloadRecord } from '../types' export function createTurnHandlers(context: SocketHandlerContext): TurnHandlerApi { + function handlePlayerAllowAction(message: unknown): void { + const source = asRecord(message) + if (!source || typeof source.type !== 'string' || normalizeWsType(source.type) !== 'PLAYER_ALLOW_ACTION') { + return + } + + const payload = asRecord(source.payload) ?? source + const roomId = readString(payload, 'room_id', 'roomId') || readString(source, 'roomId', 'room_id') + if (roomId && context.gameStore.roomId && roomId !== context.gameStore.roomId) { + return + } + + const playerId = readPlayerTurnPlayerId(payload) + const timeout = + readNumber(payload, 'timeout', 'Timeout') ?? + readNumber(source, 'timeout', 'Timeout') ?? + 0 + const startAtRaw = + readNumber(payload, 'start_at', 'startAt', 'StartAt') ?? + readNumber(source, 'start_at', 'startAt', 'StartAt') + + if (!playerId || timeout <= 0) { + clearRoomCountdown(context.session) + return + } + + const startAtMs = normalizeTimestampMs(startAtRaw) + const deadlineAtMs = startAtMs !== null ? startAtMs + timeout * 1000 : null + const remaining = + deadlineAtMs !== null ? Math.max(0, Math.ceil((deadlineAtMs - context.session.now.value) / 1000)) : timeout + + setRoomCountdown(context.session, { + playerIds: [playerId], + actionDeadlineAt: deadlineAtMs !== null ? new Date(deadlineAtMs).toISOString() : null, + countdownSeconds: timeout, + duration: timeout, + remaining, + }) + } + function handleDingQueCountdown(message: unknown): void { const source = asRecord(message) if (!source || typeof source.type !== 'string' || normalizeWsType(source.type) !== 'DING_QUE_COUNTDOWN') { @@ -151,6 +191,7 @@ export function createTurnHandlers(context: SocketHandlerContext): TurnHandlerAp return { handleDingQueCountdown, + handlePlayerAllowAction, handlePlayerTurn, } } diff --git a/src/views/chengdu/socket/parsers/gameActionMessage.ts b/src/views/chengdu/socket/parsers/gameActionMessage.ts index 00b0b99..839c0c6 100644 --- a/src/views/chengdu/socket/parsers/gameActionMessage.ts +++ b/src/views/chengdu/socket/parsers/gameActionMessage.ts @@ -1,6 +1,6 @@ -import type { GameAction, PlayerTurnPayload, RoomTrusteePayload } from '../../../../game/actions' +import type { DiscardActionPayload, DrawActionPayload, GameAction, PlayerTurnPayload, RoomTrusteePayload } from '../../../../game/actions' import type { ClaimOptionState } from '../../../../types/state' -import { asRecord, normalizeWsType, readString } from '../../../../game/chengdu/messageNormalizers' +import { asRecord, normalizeTile, normalizeWsType, readNumber, readString } from '../../../../game/chengdu/messageNormalizers' export function parseGameActionMessage(message: unknown): GameAction | null { if (!message || typeof message !== 'object') { @@ -29,9 +29,46 @@ export function parseGameActionMessage(message: unknown): GameAction | null { ? ({ type: 'ROOM_PLAYER_UPDATE', payload } as GameAction) : null case 'ROOM_TRUSTEE': + case 'PLAYER_TRUSTEE': return payload && typeof payload === 'object' ? ({ type: 'ROOM_TRUSTEE', payload } as GameAction) : ({ type: 'ROOM_TRUSTEE', payload: source as unknown as RoomTrusteePayload } as GameAction) + case 'DRAW': { + const resolvedPayload = asRecord(payload) + const playerId = + readString(resolvedPayload ?? {}, 'player_id', 'playerId', 'PlayerID') || + readString(source, 'target') + if (!playerId) { + return null + } + return { + type: 'DRAW_TILE', + payload: { + ...(resolvedPayload as DrawActionPayload | null), + player_id: playerId, + }, + } + } + case 'DISCARD': { + const resolvedPayload = asRecord(payload) + const playerId = + readString(resolvedPayload ?? {}, 'player_id', 'playerId', 'PlayerID') || + readString(source, 'target') + const tile = normalizeTile(resolvedPayload?.tile) + if (!playerId || !tile) { + return null + } + const nextSeat = readNumber(resolvedPayload ?? {}, 'next_seat', 'nextSeat') + return { + type: 'PLAY_TILE', + payload: { + ...(resolvedPayload as DiscardActionPayload | null), + player_id: playerId, + tile, + ...(typeof nextSeat === 'number' ? { next_seat: nextSeat } : {}), + }, + } + } case 'PLAYER_TURN': case 'NEXT_TURN': return payload && typeof payload === 'object' diff --git a/src/views/chengdu/socket/parsers/roomInfoSnapshot.ts b/src/views/chengdu/socket/parsers/roomInfoSnapshot.ts index ff35cd1..b335cf0 100644 --- a/src/views/chengdu/socket/parsers/roomInfoSnapshot.ts +++ b/src/views/chengdu/socket/parsers/roomInfoSnapshot.ts @@ -237,7 +237,10 @@ export function parseRoomInfoSnapshot( readString(room ?? {}, 'status') || readString(gameState ?? {}, 'phase') || 'waiting' - const phase = readString(gameState ?? {}, 'phase') || readString(room ?? {}, 'status') || 'waiting' + const rawPendingClaim = asRecord(gameState?.pending_claim ?? gameState?.pendingClaim) + const hasPendingClaimWindow = Boolean(rawPendingClaim && Object.keys(rawPendingClaim).length > 0) + const phase = + hasPendingClaimWindow ? 'action' : readString(gameState ?? {}, 'phase') || readString(room ?? {}, 'status') || 'waiting' const wallCount = readNumber(gameState ?? {}, 'wall_count', 'wallCount') const dealerIndex = readNumber(gameState ?? {}, 'dealer_index', 'dealerIndex') const currentTurnSeat = readNumber(gameState ?? {}, 'current_turn', 'currentTurn') diff --git a/src/views/chengdu/socket/parsers/roomStateSnapshot.ts b/src/views/chengdu/socket/parsers/roomStateSnapshot.ts index 1045373..df178a8 100644 --- a/src/views/chengdu/socket/parsers/roomStateSnapshot.ts +++ b/src/views/chengdu/socket/parsers/roomStateSnapshot.ts @@ -79,7 +79,10 @@ export function parseRoomStateSnapshot( } }) - const phase = readString(payload, 'phase') || readString(payload, 'status') || 'waiting' + const rawPendingClaim = asRecord(payload.pending_claim ?? payload.pendingClaim) + const hasPendingClaimWindow = Boolean(rawPendingClaim && Object.keys(rawPendingClaim).length > 0) + const phase = + hasPendingClaimWindow ? 'action' : readString(payload, 'phase') || readString(payload, 'status') || 'waiting' const wallCount = readNumber(payload, 'wall_count', 'wallCount') const currentTurnSeat = readNumber(payload, 'current_turn', 'currentTurn') const currentTurnPlayerId = readString(payload, 'current_turn_player', 'currentTurnPlayer') || '' diff --git a/src/views/chengdu/socket/router/socketMessageRouter.ts b/src/views/chengdu/socket/router/socketMessageRouter.ts index e64360d..9fbe242 100644 --- a/src/views/chengdu/socket/router/socketMessageRouter.ts +++ b/src/views/chengdu/socket/router/socketMessageRouter.ts @@ -15,8 +15,10 @@ export function createSocketMessageRouter(deps: SocketMessageRouterDeps) { ['ROOM_INFO', [deps.roomHandlers.handleRoomInfoResponse]], ['ROOM_STATE', [deps.roomHandlers.handleRoomStateResponse]], ['PLAYER_HAND', [deps.playerHandlers.handlePlayerHandResponse]], + ['PLAYER_ALLOW_ACTION', [deps.turnHandlers.handlePlayerAllowAction]], ['PLAYER_TURN', [deps.turnHandlers.handlePlayerTurn]], ['NEXT_TURN', [deps.turnHandlers.handlePlayerTurn]], + ['ACTION_ACK', [deps.statusHandlers.handleActionAck]], ['ACTION_ERROR', [deps.statusHandlers.handleActionError]], ['DING_QUE_COUNTDOWN', [deps.turnHandlers.handleDingQueCountdown]], ['PLAYER_READY', [deps.playerHandlers.handleReadyStateResponse]], diff --git a/src/views/chengdu/socket/types.ts b/src/views/chengdu/socket/types.ts index 54d1d13..7b9b16e 100644 --- a/src/views/chengdu/socket/types.ts +++ b/src/views/chengdu/socket/types.ts @@ -51,10 +51,12 @@ export interface PlayerHandlerApi extends ReadyStateApi { export interface TurnHandlerApi { handleDingQueCountdown: (message: unknown) => void + handlePlayerAllowAction: (message: unknown) => void handlePlayerTurn: (message: unknown) => void } export interface StatusHandlerApi { + handleActionAck: (message: unknown) => void handleActionError: (message: unknown) => void }