feat(game): 实现出牌选择与计时功能
- 添加 PlayerTurnPayload 接口定义和 PLAYER_TURN 动作类型 - 实现选牌、出牌确认逻辑和相关状态管理 - 添加客户端出牌限制检查和错误提示 - 集成 PLAYER_TURN WebSocket 消息处理 - 添加房间状态面板显示游戏信息 - 优化桌面背景图片和样式布局 - 添加马蹄形动画样式文件 - 配置 Vite 别名和端口设置
This commit is contained in:
@@ -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>
|
||||
|
||||
Reference in New Issue
Block a user