feat(game): 添加成都麻将游戏核心功能实现

- 定义游戏动作类型和载荷接口,包括摸牌、出牌、碰杠胡等操作
- 创建成都麻将游戏页面组件,集成桌面视图和玩家交互界面
- 实现游戏动作消息解析器,处理WebSocket消息转换为游戏动作
- 构建游戏状态管理store,管理玩家信息、回合状态和游戏流程
- 开发房间信息快照解析器,同步房间状态和玩家数据
- 实现房间状态快照解析,处理游戏阶段转换和玩家操作
This commit is contained in:
2026-04-03 22:41:58 +08:00
parent 0bf68d4e49
commit cfc65070ea
10 changed files with 311 additions and 98 deletions

View File

@@ -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
}
// 进入操作窗口(碰/杠/胡)

View File

@@ -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)
// 加入出牌<E587BA>?
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', {
},
})

View File

@@ -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<DisplayPlayer[]>(() =>
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<ActionCountdownView | null>(() => {
const countdown = session.roomCountdown.value
const countdown = roomCountdown.value
if (!countdown) {
return null
}
@@ -82,7 +147,7 @@ const actionCountdown = computed<ActionCountdownView | null>(() => {
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<ActionCountdownView | null>(() => {
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<ActionCountdownView | null>(() => {
})
.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<ActionCountdownView | null>(() => {
})
function handleLeaveRoom(): void {
session.menuOpen.value = false
session.backHall()
menuOpen.value = false
backHall()
}
</script>
@@ -140,24 +205,24 @@ function handleLeaveRoom(): void {
<div class="inner-outline mid"></div>
<ChengduTableHeader
:leave-room-pending="session.leaveRoomPending"
:menu-open="session.menuOpen"
:menu-trigger-active="session.menuTriggerActive"
:is-trust-mode="session.isTrustMode"
:wall-count="tableView.roomState.game.state.wall.length || 48"
:network-label="session.networkLabel"
:ws-status="session.wsStatus"
:formatted-clock="session.formattedClock"
:room-name="tableView.roomState.name || tableView.roomName"
:current-phase-text="tableView.currentPhaseText"
:player-count="tableView.roomState.playerCount"
:max-players="tableView.roomState.maxPlayers"
:round-text="tableView.roundText"
:room-status-text="tableView.roomStatusText"
:ws-error="session.wsError"
:leave-room-pending="leaveRoomPending"
:menu-open="menuOpen"
:menu-trigger-active="menuTriggerActive"
:is-trust-mode="isTrustMode"
:wall-count="roomState.game.state.wall.length || 48"
:network-label="networkLabel"
:ws-status="wsStatus"
:formatted-clock="formattedClock"
:room-name="roomState.name || roomName"
:current-phase-text="currentPhaseText"
:player-count="roomState.playerCount"
:max-players="roomState.maxPlayers"
:round-text="roundText"
:room-status-text="roomStatusText"
:ws-error="wsError"
:action-countdown="actionCountdown"
@toggle-menu="session.toggleMenu"
@toggle-trust-mode="session.toggleTrustMode"
@toggle-menu="toggleMenu"
@toggle-trust-mode="toggleTrustMode"
@leave-room="handleLeaveRoom"
>
<template #robot-icon>
@@ -172,67 +237,67 @@ function handleLeaveRoom(): void {
</template>
</ChengduTableHeader>
<TopPlayerCard :player="tableView.seatDecor.top"/>
<RightPlayerCard :player="tableView.seatDecor.right"/>
<BottomPlayerCard :player="tableView.seatDecor.bottom"/>
<LeftPlayerCard :player="tableView.seatDecor.left"/>
<TopPlayerCard :player="seatDecor.top"/>
<RightPlayerCard :player="seatDecor.right"/>
<BottomPlayerCard :player="seatDecor.bottom"/>
<LeftPlayerCard :player="seatDecor.left"/>
<ChengduDeskZones :desk-seats="tableView.deskSeats"/>
<ChengduDeskZones :desk-seats="deskSeats"/>
<ChengduWallSeats
:wall-seats="tableView.wallSeats"
:selected-discard-tile-id="session.selectedDiscardTileId"
:wall-seats="wallSeats"
:selected-discard-tile-id="selectedDiscardTileId"
:discard-blocked-reason="discardBlockedReason"
:discard-tile-blocked-reason="discardTileBlockedReason"
:format-tile="formatTile"
@select-discard-tile="selectDiscardTile"
/>
<WindSquare class="center-wind-square" :seat-winds="tableView.seatWinds"
:active-position="tableView.currentTurnSeat"/>
<WindSquare class="center-wind-square" :seat-winds="seatWinds"
:active-position="currentTurnSeat"/>
<ChengduSettlementOverlay
:show="socket.showSettlementOverlay"
:is-last-round="actions.isLastRound"
:show="showSettlementOverlay"
:is-last-round="isLastRound"
:current-round="gameStore.currentRound"
:total-rounds="gameStore.totalRounds"
:settlement-players="tableView.settlementPlayers"
:logged-in-user-id="session.loggedInUserId"
:next-round-pending="session.nextRoundPending"
:settlement-countdown="socket.settlementCountdown"
@next-round="actions.nextRound"
@back-hall="session.backHall"
:settlement-players="settlementPlayers"
:logged-in-user-id="loggedInUserId"
:next-round-pending="nextRoundPending"
:settlement-countdown="settlementCountdown"
@next-round="nextRound"
@back-hall="backHall"
/>
<ChengduBottomActions
:show-ding-que-chooser="actions.showDingQueChooser"
:show-ready-toggle="actions.showReadyToggle"
:show-start-game-button="actions.showStartGameButton"
:selected-discard-tile="actions.selectedDiscardTile"
:ding-que-pending="session.dingQuePending"
:can-confirm-discard="actions.canConfirmDiscard"
:discard-pending="session.discardPending"
:confirm-discard-label="actions.confirmDiscardLabel"
:ready-toggle-pending="session.readyTogglePending"
:my-ready-state="actions.myReadyState"
:can-draw-tile="actions.canDrawTile"
:can-start-game="actions.canStartGame"
:is-room-owner="actions.isRoomOwner"
:can-self-gang="actions.canSelfGang"
:can-self-hu="actions.canSelfHu"
:show-claim-actions="actions.showClaimActions"
:turn-action-pending="session.turnActionPending"
:visible-claim-options="actions.visibleClaimOptions"
:claim-action-pending="session.claimActionPending"
:show-waiting-owner-tip="actions.showWaitingOwnerTip"
@choose-ding-que="actions.chooseDingQue"
@confirm-discard="actions.confirmDiscard"
@toggle-ready-state="actions.toggleReadyState"
@draw-tile="actions.drawTile"
@start-game="actions.startGame"
@submit-self-gang="actions.submitSelfGang"
@submit-self-hu="actions.submitSelfHu"
@submit-claim="actions.submitClaim"
:show-ding-que-chooser="showDingQueChooser"
:show-ready-toggle="showReadyToggle"
:show-start-game-button="showStartGameButton"
:selected-discard-tile="selectedDiscardTile"
:ding-que-pending="dingQuePending"
:can-confirm-discard="canConfirmDiscard"
:discard-pending="discardPending"
:confirm-discard-label="confirmDiscardLabel"
:ready-toggle-pending="readyTogglePending"
:my-ready-state="myReadyState"
:can-draw-tile="canDrawTile"
:can-start-game="canStartGame"
:is-room-owner="isRoomOwner"
:can-self-gang="canSelfGang"
:can-self-hu="canSelfHu"
:show-claim-actions="showClaimActions"
:turn-action-pending="turnActionPending"
:visible-claim-options="visibleClaimOptions"
:claim-action-pending="claimActionPending"
:show-waiting-owner-tip="showWaitingOwnerTip"
@choose-ding-que="chooseDingQue"
@confirm-discard="confirmDiscard"
@toggle-ready-state="toggleReadyState"
@draw-tile="drawTile"
@start-game="startGame"
@submit-self-gang="submitSelfGang"
@submit-self-hu="submitSelfHu"
@submit-claim="submitClaim"
/>
</div>
</section>

View File

@@ -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,
}
}

View File

@@ -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,
}
}

View File

@@ -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'

View File

@@ -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')

View File

@@ -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') || ''

View File

@@ -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]],

View File

@@ -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
}