Compare commits
3 Commits
0bf68d4e49
...
dev
| Author | SHA1 | Date | |
|---|---|---|---|
| e495dc6070 | |||
| 3c876c4c3d | |||
| cfc65070ea |
@@ -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>
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
// 进入操作窗口(碰/杠/胡)
|
||||
|
||||
@@ -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', {
|
||||
},
|
||||
})
|
||||
|
||||
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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)
|
||||
})
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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,
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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,
|
||||
}
|
||||
}
|
||||
|
||||
27
src/views/chengdu/socket/parsers/actionTimerSnapshot.ts
Normal file
27
src/views/chengdu/socket/parsers/actionTimerSnapshot.ts
Normal 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,
|
||||
}
|
||||
}
|
||||
@@ -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'
|
||||
|
||||
@@ -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),
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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),
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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]],
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user