feat(game): 实现出牌选择与计时功能

- 添加 PlayerTurnPayload 接口定义和 PLAYER_TURN 动作类型
- 实现选牌、出牌确认逻辑和相关状态管理
- 添加客户端出牌限制检查和错误提示
- 集成 PLAYER_TURN WebSocket 消息处理
- 添加房间状态面板显示游戏信息
- 优化桌面背景图片和样式布局
- 添加马蹄形动画样式文件
- 配置 Vite 别名和端口设置
This commit is contained in:
2026-03-30 17:23:43 +08:00
parent 43439cb09d
commit 2625baf266
12 changed files with 645 additions and 344 deletions

View File

@@ -1,7 +1,7 @@
<script setup lang="ts">
import {computed, onBeforeUnmount, onMounted, ref} from 'vue'
import {useRoute, useRouter} from 'vue-router'
import deskImage from '../assets/images/desk/desk_01.png'
import deskImage from '../assets/images/desk/desk_01_1920_945.png'
import robotIcon from '../assets/images/icons/robot.svg'
import exitIcon from '../assets/images/icons/exit.svg'
import '../assets/styles/room.css'
@@ -16,7 +16,7 @@ import westWind from '../assets/images/direction/xi.png'
import northWind from '../assets/images/direction/bei.png'
import type {SeatPlayerCardModel} from '../components/game/seat-player-card'
import type {SeatKey} from '../game/seat'
import type {GameAction, RoomPlayerUpdatePayload, RoomTrusteePayload} from '../game/actions'
import type {GameAction, PlayerTurnPayload, RoomPlayerUpdatePayload, RoomTrusteePayload} from '../game/actions'
import {dispatchGameAction} from '../game/dispatcher'
import {refreshAccessToken} from '../api/auth'
import {AuthExpiredError, type AuthSession} from '../api/authed-request'
@@ -96,6 +96,7 @@ const startGamePending = ref(false)
const dingQuePending = ref(false)
const discardPending = ref(false)
const claimActionPending = ref(false)
const selectedDiscardTileId = ref<number | null>(null)
let clockTimer: number | null = null
let discardPendingTimer: number | null = null
let unsubscribe: (() => void) | null = null
@@ -293,8 +294,6 @@ const seatWinds = computed<Record<SeatKey, string>>(() => {
return result
})
const rightMessages = computed(() => wsMessages.value.slice(-16).reverse())
const currentPhaseText = computed(() => {
const map: Record<string, string> = {
waiting: '等待中',
@@ -386,26 +385,91 @@ const showDingQueChooser = computed(() => {
return player.handTiles.length > 0 && !player.missingSuit
})
const canDiscardTiles = computed(() => {
const selectedDiscardTile = computed(() => {
const player = myPlayer.value
if (!player || selectedDiscardTileId.value === null) {
return null
}
return player.handTiles.find((tile) => tile.id === selectedDiscardTileId.value) ?? null
})
const hasMissingSuitTiles = computed(() => {
const player = myPlayer.value
const missingSuit = player?.missingSuit as Tile['suit'] | null | undefined
if (!player || !missingSuit) {
return false
}
return player.handTiles.some((tile) => tile.suit === missingSuit)
})
const discardBlockedReason = computed(() => {
const player = myPlayer.value
if (!player || !gameStore.roomId) {
return false
return '未进入房间'
}
if (wsStatus.value !== 'connected') {
return false
return 'WebSocket 未连接'
}
if (showDingQueChooser.value) {
return '请先完成定缺'
}
if (gameStore.phase !== 'playing') {
return '当前不是出牌阶段'
}
if (player.seatIndex !== gameStore.currentTurn) {
return '未轮到你出牌'
}
if (gameStore.needDraw) {
return '请先摸牌'
}
if (gameStore.pendingClaim) {
return '等待当前操作结算'
}
if (player.handTiles.length === 0) {
return false
return '当前没有可出的手牌'
}
if (discardPending.value) {
return false
return '正在提交出牌'
}
// 交给后端做最终合法性校验,前端只避免明显无效点击。
return true
return ''
})
function discardTileBlockedReason(tile: Tile): string {
if (discardBlockedReason.value) {
return discardBlockedReason.value
}
const player = myPlayer.value
const missingSuit = player?.missingSuit as Tile['suit'] | null | undefined
if (player && missingSuit && hasMissingSuitTiles.value && tile.suit !== missingSuit) {
return `当前必须先打${missingSuitLabel(missingSuit)}`
}
return ''
}
const canConfirmDiscard = computed(() => {
const tile = selectedDiscardTile.value
if (!tile) {
return false
}
return !discardTileBlockedReason(tile)
})
const confirmDiscardLabel = computed(() => {
const tile = selectedDiscardTile.value
return tile ? `出牌 ${formatTile(tile)}` : '出牌'
})
const canDrawTile = computed(() => {
@@ -559,6 +623,14 @@ function readNumber(source: Record<string, unknown>, ...keys: string[]): number
return null
}
function normalizeTimestampMs(value: number | null): number | null {
if (typeof value !== 'number' || !Number.isFinite(value) || value <= 0) {
return null
}
return value >= 1_000_000_000_000 ? value : value * 1000
}
function readStringArray(source: Record<string, unknown>, ...keys: string[]): string[] {
for (const key of keys) {
const value = source[key]
@@ -579,6 +651,15 @@ function readBoolean(source: Record<string, unknown>, ...keys: string[]): boolea
return null
}
function readPlayerTurnPlayerId(payload: PlayerTurnPayload): string {
return (
(typeof payload.player_id === 'string' && payload.player_id) ||
(typeof payload.playerId === 'string' && payload.playerId) ||
(typeof payload.PlayerID === 'string' && payload.PlayerID) ||
''
)
}
function readMissingSuit(source: Record<string, unknown> | null | undefined): string | null {
if (!source) {
return null
@@ -1567,13 +1648,13 @@ function handlePlayerHandResponse(message: unknown): void {
}
}
function handleRoomCountdown(message: unknown): void {
function handleDingQueCountdown(message: unknown): void {
const source = asRecord(message)
if (!source || typeof source.type !== 'string') {
return
}
if (normalizeWsType(source.type) !== 'ROOM_COUNTDOWN') {
if (normalizeWsType(source.type) !== 'DING_QUE_COUNTDOWN') {
return
}
@@ -1612,6 +1693,58 @@ function handleRoomCountdown(message: unknown): void {
}
}
function applyPlayerTurnCountdown(payload: PlayerTurnPayload): void {
const playerId = readPlayerTurnPlayerId(payload)
const timeout =
(typeof payload.timeout === 'number' && Number.isFinite(payload.timeout) ? payload.timeout : null) ??
(typeof payload.Timeout === 'number' && Number.isFinite(payload.Timeout) ? payload.Timeout : null) ??
0
const startAtRaw =
(typeof payload.start_at === 'number' && Number.isFinite(payload.start_at) ? payload.start_at : null) ??
(typeof payload.startAt === 'number' && Number.isFinite(payload.startAt) ? payload.startAt : null) ??
(typeof payload.StartAt === 'number' && Number.isFinite(payload.StartAt) ? payload.StartAt : null)
if (!playerId || timeout <= 0) {
roomCountdown.value = null
return
}
const startAtMs = normalizeTimestampMs(startAtRaw)
const deadlineAtMs = startAtMs !== null ? startAtMs + timeout * 1000 : null
const remaining = deadlineAtMs !== null
? Math.max(0, Math.ceil((deadlineAtMs - now.value) / 1000))
: timeout
roomCountdown.value = {
playerIds: [playerId],
actionDeadlineAt: deadlineAtMs !== null ? new Date(deadlineAtMs).toISOString() : null,
countdownSeconds: timeout,
duration: timeout,
remaining,
}
}
function handlePlayerTurn(message: unknown): void {
const source = asRecord(message)
if (!source || typeof source.type !== 'string') {
return
}
if (normalizeWsType(source.type) !== 'PLAYER_TURN') {
return
}
const payload = asRecord(source.payload) ?? source
const roomId =
readString(payload, 'room_id', 'roomId') ||
readString(source, 'roomId')
if (roomId && gameStore.roomId && roomId !== gameStore.roomId) {
return
}
applyPlayerTurnCountdown(payload as PlayerTurnPayload)
}
function toGameAction(message: unknown): GameAction | null {
if (!message || typeof message !== 'object') {
return null
@@ -1674,6 +1807,14 @@ function toGameAction(message: unknown): GameAction | null {
type: 'ROOM_TRUSTEE',
payload: source as unknown as GameActionPayload<'ROOM_TRUSTEE'>,
}
case 'PLAYER_TURN':
if (payload && typeof payload === 'object') {
return {type: 'PLAYER_TURN', payload: payload as GameActionPayload<'PLAYER_TURN'>}
}
return {
type: 'PLAYER_TURN',
payload: source as unknown as GameActionPayload<'PLAYER_TURN'>,
}
default:
return null
}
@@ -1979,22 +2120,6 @@ async function ensureWsConnected(forceRefresh = false): Promise<void> {
wsClient.connect(buildWsUrl(), token)
}
async function reconnectWsInternal(forceRefresh = false): Promise<boolean> {
const token = await resolveWsToken(forceRefresh, false)
if (!token) {
wsError.value = '未找到登录凭证,无法建立连接'
return false
}
wsError.value = ''
wsClient.reconnect(buildWsUrl(), token)
return true
}
function reconnectWs(): void {
void reconnectWsInternal()
}
function backHall(): void {
leaveRoomPending.value = true
const roomId = gameStore.roomId
@@ -2072,6 +2197,7 @@ function clearDiscardPendingTimer(): void {
function markDiscardCompleted(): void {
clearDiscardPendingTimer()
discardPending.value = false
selectedDiscardTileId.value = null
}
function markDiscardPendingWithFallback(): void {
@@ -2079,15 +2205,40 @@ function markDiscardPendingWithFallback(): void {
discardPending.value = true
discardPendingTimer = window.setTimeout(() => {
discardPending.value = false
selectedDiscardTileId.value = null
discardPendingTimer = null
}, 2000)
}
function discardTile(tile: Tile): void {
if (!canDiscardTiles.value || !gameStore.roomId) {
function selectDiscardTile(tile: Tile): void {
const blockedReason = discardTileBlockedReason(tile)
if (blockedReason) {
wsError.value = blockedReason
wsMessages.value.push(`[client-blocked] select ${formatTile(tile)}: ${blockedReason}`)
selectedDiscardTileId.value = null
return
}
wsError.value = ''
selectedDiscardTileId.value = selectedDiscardTileId.value === tile.id ? null : tile.id
}
function confirmDiscard(): void {
const tile = selectedDiscardTile.value
if (!tile) {
return
}
const blockedReason = discardTileBlockedReason(tile)
if (blockedReason || !gameStore.roomId) {
if (blockedReason) {
wsError.value = blockedReason
wsMessages.value.push(`[client-blocked] discard ${formatTile(tile)}: ${blockedReason}`)
}
return
}
wsError.value = ''
markDiscardPendingWithFallback()
sendWsMessage({
type: 'discard',
@@ -2228,7 +2379,8 @@ onMounted(() => {
handleRoomInfoResponse(msg)
handleRoomStateResponse(msg)
handlePlayerHandResponse(msg)
handleRoomCountdown(msg)
handlePlayerTurn(msg)
handleDingQueCountdown(msg)
handleReadyStateResponse(msg)
handlePlayerDingQueResponse(msg)
const gameAction = toGameAction(msg)
@@ -2236,10 +2388,14 @@ onMounted(() => {
dispatchGameAction(gameAction)
if (gameAction.type === 'GAME_START') {
startGamePending.value = false
roomCountdown.value = null
}
if (gameAction.type === 'PLAY_TILE' && gameAction.payload.playerId === loggedInUserId.value) {
markDiscardCompleted()
}
if (gameAction.type === 'PLAY_TILE' || gameAction.type === 'PENDING_CLAIM' || gameAction.type === 'CLAIM_RESOLVED') {
roomCountdown.value = null
}
if (gameAction.type === 'ROOM_PLAYER_UPDATE') {
syncReadyStatesFromRoomUpdate(gameAction.payload)
readyTogglePending.value = false
@@ -2250,6 +2406,9 @@ onMounted(() => {
if (gameAction.type === 'ROOM_TRUSTEE') {
syncTrusteeState(gameAction.payload)
}
if (gameAction.type === 'PLAYER_TURN' && readPlayerTurnPlayerId(gameAction.payload) !== loggedInUserId.value) {
selectedDiscardTileId.value = null
}
}
})
wsClient.onError((message: string) => {
@@ -2372,6 +2531,28 @@ onBeforeUnmount(() => {
<span>{{ formattedClock }}</span>
</div>
<div class="room-status-panel">
<div class="room-status-grid">
<div class="room-status-item">
<span>房间</span>
<strong>{{ roomState.name || roomName || '未命名' }}</strong>
</div>
<div class="room-status-item">
<span>阶段</span>
<strong>{{ currentPhaseText }}</strong>
</div>
<div class="room-status-item">
<span>人数</span>
<strong>{{ roomState.playerCount }}/{{ roomState.maxPlayers }}</strong>
</div>
<div class="room-status-item">
<span>状态</span>
<strong>{{ roomStatusText }}</strong>
</div>
</div>
<p v-if="wsError" class="room-status-error">{{ wsError }}</p>
</div>
<div v-if="actionCountdown" class="action-countdown" :class="{ 'is-self': actionCountdown.isSelf }">
<div class="action-countdown-head">
<span>{{ actionCountdown.playerLabel }}操作倒计时</span>
@@ -2479,11 +2660,13 @@ onBeforeUnmount(() => {
:class="{
'is-group-start': index > 0 && tile.suit && wallSeats.bottom.tiles[index - 1]?.suit !== tile.suit,
'is-lack-tagged': tile.showLackTag,
'is-selected': selectedDiscardTileId === tile.tile.id,
}"
:data-testid="`hand-tile-${tile.tile.id}`"
type="button"
:disabled="!canDiscardTiles || discardPending"
@click="discardTile(tile.tile)"
:disabled="Boolean(discardBlockedReason)"
:title="discardTileBlockedReason(tile.tile) || formatTile(tile.tile)"
@click="selectDiscardTile(tile.tile)"
>
<span v-if="tile.showLackTag" class="wall-live-tile-lack-tag"></span>
<img
@@ -2539,7 +2722,7 @@ onBeforeUnmount(() => {
<div class="bottom-control-panel">
<div v-if="showDingQueChooser || showReadyToggle || showStartGameButton" class="bottom-action-bar">
<div v-if="showDingQueChooser || showReadyToggle || showStartGameButton || selectedDiscardTile" class="bottom-action-bar">
<div v-if="showDingQueChooser" class="ding-que-bar">
<button
class="ding-que-button"
@@ -2570,6 +2753,17 @@ onBeforeUnmount(() => {
</button>
</div>
<button
v-if="selectedDiscardTile"
class="ready-toggle ready-toggle-inline discard-confirm-button"
data-testid="confirm-discard"
type="button"
:disabled="!canConfirmDiscard || discardPending"
@click="confirmDiscard"
>
<span class="ready-toggle-label">{{ confirmDiscardLabel }}</span>
</button>
<button
v-if="showReadyToggle"
class="ready-toggle ready-toggle-inline"
@@ -2620,41 +2814,6 @@ onBeforeUnmount(() => {
</div>
</section>
<aside class="ws-sidebar">
<div class="sidebar-head">
<div>
<p class="sidebar-title">WebSocket 消息</p>
<small>{{ networkLabel }} · {{ loggedInUserName || '未登录昵称' }}</small>
</div>
<button class="sidebar-btn" type="button" @click="reconnectWs">重连</button>
</div>
<div class="sidebar-stats">
<div class="sidebar-stat">
<span>房间</span>
<strong>{{ roomState.name || roomName || '未命名' }}</strong>
</div>
<div class="sidebar-stat">
<span>阶段</span>
<strong>{{ currentPhaseText }}</strong>
</div>
<div class="sidebar-stat">
<span>人数</span>
<strong>{{ roomState.playerCount }}/{{ roomState.maxPlayers }}</strong>
</div>
<div class="sidebar-stat">
<span>状态</span>
<strong>{{ roomStatusText }}</strong>
</div>
</div>
<p v-if="wsError" class="sidebar-error">{{ wsError }}</p>
<div class="sidebar-log">
<p v-if="rightMessages.length === 0" class="sidebar-empty">等待服务器消息...</p>
<p v-for="(line, index) in rightMessages" :key="index" class="sidebar-line">{{ line }}</p>
</div>
</aside>
</div>
</section>
</template>