Compare commits

...

3 Commits

Author SHA1 Message Date
e495dc6070 feat(chengdu): 更新结算界面添加准备状态和房间管理功能
- 添加玩家准备状态显示和切换功能
- 实现房主控制下一局开始的游戏流程
- 添加退出房间按钮和相关状态管理
- 集成准备状态的计算逻辑和UI展示
- 更新组件props传递准备和房间状态数据
- 重构结算界面按钮布局和交互逻辑
2026-04-07 13:36:28 +08:00
3c876c4c3d feat(ws): 添加WebSocket客户端及房间状态处理功能
- 实现WebSocket客户端类,支持连接、发送消息、状态监听和自动重连
- 添加房间信息和状态的消息处理器,用于同步游戏状态和玩家数据
- 实现房间快照解析功能,处理玩家信息、游戏阶段、计时器等数据结构
- 集成会话状态适配器,管理房间倒计时、结算截止时间等状态变更
- 添加单例WebSocket客户端实例,统一管理WebSocket连接生命周期
2026-04-06 15:13:43 +08:00
cfc65070ea feat(game): 添加成都麻将游戏核心功能实现
- 定义游戏动作类型和载荷接口,包括摸牌、出牌、碰杠胡等操作
- 创建成都麻将游戏页面组件,集成桌面视图和玩家交互界面
- 实现游戏动作消息解析器,处理WebSocket消息转换为游戏动作
- 构建游戏状态管理store,管理玩家信息、回合状态和游戏流程
- 开发房间信息快照解析器,同步房间状态和玩家数据
- 实现房间状态快照解析,处理游戏阶段转换和玩家操作
2026-04-03 22:41:58 +08:00
16 changed files with 435 additions and 118 deletions

View File

@@ -1,5 +1,7 @@
<script setup lang="ts">
defineProps<{
import { computed } from 'vue'
const props = defineProps<{
show: boolean
isLastRound: boolean
currentRound: number
@@ -10,16 +12,27 @@ defineProps<{
score: number
isWinner: boolean
seatIndex: number
isReady: boolean
}>
loggedInUserId: string
nextRoundPending: boolean
isRoomOwner: boolean
selfIsReady: boolean
readyTogglePending: boolean
startNextRoundPending: boolean
leaveRoomPending: boolean
settlementCountdown: number | null
}>()
const emit = defineEmits<{
nextRound: []
ready: []
startNextRound: []
exit: []
backHall: []
}>()
const allPlayersReady = computed(() =>
props.settlementPlayers.length > 0 && props.settlementPlayers.every((p) => p.isReady),
)
</script>
<template>
@@ -44,29 +57,63 @@ const emit = defineEmits<{
<span class="settlement-score" :class="{ 'is-positive': item.score > 0, 'is-negative': item.score < 0 }">
{{ item.score > 0 ? '+' : '' }}{{ item.score }}
</span>
<span class="settlement-ready-badge" :class="{ 'is-ready': item.isReady }">
{{ item.isReady ? '已准备' : '等待...' }}
</span>
</div>
</div>
<div class="settlement-actions">
<button
v-if="!isLastRound"
class="ready-toggle ready-toggle-inline settlement-btn"
type="button"
:disabled="nextRoundPending"
@click="emit('nextRound')"
>
<span class="ready-toggle-label">
{{
nextRoundPending
? '准备中...'
: settlementCountdown != null && settlementCountdown > 0
? `下一局 (${settlementCountdown}s)`
: '下一局'
}}
</span>
</button>
<!-- 非末局准备按钮 + 房主开始游戏按钮 -->
<template v-if="!isLastRound">
<button
class="ready-toggle ready-toggle-inline settlement-btn"
:class="{ 'is-ready': selfIsReady }"
type="button"
:disabled="selfIsReady || readyTogglePending"
@click="emit('ready')"
>
<span class="ready-toggle-label">
{{
readyTogglePending
? '请求中...'
: selfIsReady
? '已准备'
: '准备'
}}
</span>
</button>
<button
v-if="isRoomOwner"
class="ready-toggle ready-toggle-inline settlement-btn"
type="button"
:disabled="!allPlayersReady || startNextRoundPending"
@click="emit('startNextRound')"
>
<span class="ready-toggle-label">
{{
startNextRoundPending
? '开始中...'
: allPlayersReady
? '开始游戏'
: `开始游戏 (${settlementPlayers.filter((p) => p.isReady).length}/${settlementPlayers.length})`
}}
</span>
</button>
<p v-else-if="allPlayersReady" class="settlement-waiting-owner">等待房主开始...</p>
</template>
<!-- 末局返回大厅 -->
<button v-else class="ready-toggle ready-toggle-inline settlement-btn" type="button" @click="emit('backHall')">
<span class="ready-toggle-label">返回大厅</span>
</button>
<!-- 退出按钮始终显示 -->
<button
class="ready-toggle ready-toggle-inline settlement-btn settlement-btn-exit"
type="button"
:disabled="leaveRoomPending"
@click="emit('exit')"
>
<span class="ready-toggle-label">{{ leaveRoomPending ? '退出中...' : '退出' }}</span>
</button>
</div>
</div>
</div>

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,
nextRoundPending: startNextRoundPending,
dingQuePending,
discardPending,
claimActionPending,
turnActionPending,
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,73 @@ 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"
:is-room-owner="isRoomOwner"
:self-is-ready="myReadyState"
:ready-toggle-pending="readyTogglePending"
:start-next-round-pending="startNextRoundPending"
:leave-room-pending="leaveRoomPending"
:settlement-countdown="settlementCountdown"
@ready="toggleReadyState"
@start-next-round="nextRound"
@exit="backHall"
@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

@@ -230,6 +230,7 @@ export function useChengduTableView(deps: TableViewDeps): TableViewResult {
score: deps.gameStore.scores[player.playerId] ?? 0,
isWinner: winnerSet.has(player.playerId),
seatIndex: player.seatIndex,
isReady: Boolean(player.isReady),
}))
.sort((a, b) => b.score - a.score)
})

View File

@@ -3,9 +3,11 @@ import { parseRoomInfoSnapshot } from '../parsers/roomInfoSnapshot'
import { clearRoomAndRedirect, syncActiveRoomFromRoomInfo } from '../room/roomSnapshotSync'
import {
clearClaimAndTurnPending,
clearRoomCountdown,
clearDingQuePending,
clearSelfTurnAllowActions,
clearTurnPending,
setRoomCountdown,
setSettlementDeadline,
syncCurrentUserId,
} from '../session/sessionStateAdapter'
@@ -69,6 +71,11 @@ export function createRoomInfoHandlers(context: SocketHandlerContext) {
} else {
clearTurnPending(context.session)
}
if (snapshot.actionTimer) {
setRoomCountdown(context.session, snapshot.actionTimer)
} else {
clearRoomCountdown(context.session)
}
if (typeof snapshot.settlementDeadlineMs === 'number' && snapshot.settlementDeadlineMs > 0) {
setSettlementDeadline(context.session, snapshot.settlementDeadlineMs)
}

View File

@@ -8,11 +8,13 @@ import { parseRoomStateSnapshot } from '../parsers/roomStateSnapshot'
import { syncActiveRoomFromRoomState } from '../room/roomSnapshotSync'
import {
clearClaimAndTurnPending,
clearRoomCountdown,
clearSelfTurnAllowActions,
clearStartGamePending,
clearTurnPending,
completeDiscard,
resetSettlementOverlayState,
setRoomCountdown,
setSettlementDeadline,
} from '../session/sessionStateAdapter'
import { applyRoomSnapshot } from '../store/gameStoreAdapter'
@@ -61,6 +63,11 @@ export function createRoomStateHandlers(context: SocketHandlerContext) {
} else if (snapshot.phase !== 'settlement') {
setSettlementDeadline(context.session, null)
}
if (snapshot.actionTimer) {
setRoomCountdown(context.session, snapshot.actionTimer)
} else {
clearRoomCountdown(context.session)
}
if (!snapshot.pendingClaim) {
clearClaimAndTurnPending(context.session)

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

@@ -0,0 +1,27 @@
import { asRecord, readNumber, readString, readStringArray } from '../../../../game/chengdu/messageNormalizers'
import type { PlayerActionTimer } from '../../types'
export function parseActionTimerSnapshot(source: unknown): PlayerActionTimer | null {
const timer = asRecord(source)
if (!timer) {
return null
}
const playerIds = readStringArray(timer, 'player_ids', 'playerIds', 'PlayerIDs')
const countdownSeconds = readNumber(timer, 'countdown_seconds', 'countdownSeconds', 'CountdownSeconds') ?? 0
const duration = readNumber(timer, 'duration', 'Duration') ?? countdownSeconds
const remaining = readNumber(timer, 'remaining', 'Remaining') ?? countdownSeconds
const actionDeadlineAt = readString(timer, 'action_deadline_at', 'actionDeadlineAt', 'ActionDeadlineAt') || null
if (playerIds.length === 0 && countdownSeconds <= 0 && remaining <= 0 && !actionDeadlineAt) {
return null
}
return {
playerIds,
actionDeadlineAt,
countdownSeconds,
duration,
remaining,
}
}

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

@@ -13,6 +13,8 @@ import {
} from '../../../../game/chengdu/messageNormalizers'
import type { RoomMetaSnapshotState } from '../../../../store/state'
import type { PendingClaimState, PlayerState } from '../../../../types/state'
import type { PlayerActionTimer } from '../../types'
import { parseActionTimerSnapshot } from './actionTimerSnapshot'
interface RoomInfoSnapshotPlayerPair {
roomPlayer: RoomMetaSnapshotState['players'][number]
@@ -39,6 +41,7 @@ export interface ParsedRoomInfoSnapshot {
currentRound: number | null
totalRounds: number | null
settlementDeadlineMs: number | null
actionTimer: PlayerActionTimer | null
}
interface ParseRoomInfoSnapshotOptions {
@@ -237,7 +240,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')
@@ -270,5 +276,6 @@ export function parseRoomInfoSnapshot(
currentRound: readNumber(gameState ?? {}, 'current_round', 'currentRound'),
totalRounds: readNumber(gameState ?? {}, 'total_rounds', 'totalRounds'),
settlementDeadlineMs: readNumber(gameState ?? {}, 'settlement_deadline_ms', 'settlementDeadlineMs'),
actionTimer: parseActionTimerSnapshot(gameState?.action_timer ?? gameState?.actionTimer),
}
}

View File

@@ -10,6 +10,8 @@ import {
readStringArray,
} from '../../../../game/chengdu/messageNormalizers'
import type { PendingClaimState, PlayerState } from '../../../../types/state'
import type { PlayerActionTimer } from '../../types'
import { parseActionTimerSnapshot } from './actionTimerSnapshot'
export interface ParsedRoomStateSnapshot {
roomId: string
@@ -25,6 +27,7 @@ export interface ParsedRoomStateSnapshot {
currentRound: number | null
totalRounds: number | null
settlementDeadlineMs: number | null
actionTimer: PlayerActionTimer | null
}
interface ParseRoomStateSnapshotOptions {
@@ -79,7 +82,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') || ''
@@ -105,5 +111,6 @@ export function parseRoomStateSnapshot(
currentRound: readNumber(payload, 'current_round', 'currentRound'),
totalRounds: readNumber(payload, 'total_rounds', 'totalRounds'),
settlementDeadlineMs: readNumber(payload, 'settlement_deadline_ms', 'settlementDeadlineMs'),
actionTimer: parseActionTimerSnapshot(payload.action_timer ?? payload.actionTimer),
}
}

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
}

View File

@@ -134,6 +134,7 @@ class WsClient {
// 订阅状态变化
onStatusChange(handler: StatusHandler) {
this.statusHandlers.push(handler)
handler(this.status)
return () => {
this.statusHandlers = this.statusHandlers.filter(fn => fn !== handler)
}