feat(game): 添加游戏准备状态功能

- 在 SeatPlayerCard 组件中添加 isReady 属性用于显示准备状态
- 添加准备/取消准备按钮,支持玩家切换准备状态
- 实现 WebSocket 消息处理以同步玩家准备状态
- 添加 CSS 样式显示准备状态标签和准备按钮
- 优化用户 ID 解析逻辑,支持多种字段格式
- 修复座位索引计算逻辑,确保相对位置正确显示
- 添加认证会话管理功能,确保用户信息同步加载
- 实现房间玩家状态更新的消息处理机制
This commit is contained in:
2026-03-26 17:18:29 +08:00
parent 603f910e8b
commit 0fa3c4f1df
6 changed files with 292 additions and 9 deletions

Binary file not shown.

After

Width:  |  Height:  |  Size: 11 KiB

BIN
src/assets/images/icons/read.png Executable file

Binary file not shown.

After

Width:  |  Height:  |  Size: 11 KiB

View File

@@ -429,6 +429,22 @@
color: #eef5ff;
}
.picture-scene .player-meta .ready-chip {
display: inline-flex;
align-items: center;
justify-content: center;
margin-top: 4px;
padding: 1px 8px;
border-radius: 999px;
color: #f7f2dd;
font-size: 10px;
font-weight: 700;
background: linear-gradient(180deg, rgba(214, 173, 58, 0.82), rgba(125, 91, 20, 0.92));
box-shadow:
inset 0 1px 0 rgba(255, 255, 255, 0.22),
0 2px 6px rgba(0, 0, 0, 0.2);
}
.picture-scene .player-badge.seat-right .player-meta,
.picture-scene .player-badge.seat-left .player-meta {
display: flex;
@@ -575,6 +591,57 @@
pointer-events: none;
}
.ready-toggle {
position: absolute;
right: 120px;
bottom: 70px;
display: inline-flex;
align-items: center;
justify-content: center;
gap: 8px;
min-width: 138px;
height: 44px;
padding: 0 16px 0 14px;
border: 1px solid rgba(220, 191, 118, 0.24);
border-radius: 999px;
background:
linear-gradient(180deg, rgba(14, 55, 40, 0.92), rgba(8, 36, 27, 0.96)),
radial-gradient(circle at 20% 24%, rgba(237, 214, 157, 0.08), transparent 34%);
box-shadow:
inset 0 1px 0 rgba(255, 244, 214, 0.1),
inset 0 -1px 0 rgba(0, 0, 0, 0.22),
0 8px 18px rgba(0, 0, 0, 0.2);
z-index: 4;
animation: ready-toggle-pop 180ms ease-out;
}
.ready-toggle-label {
color: #e5c472;
font-size: 14px;
font-weight: 800;
letter-spacing: 0.5px;
text-shadow:
-1px 0 rgba(0, 0, 0, 0.38),
0 1px rgba(0, 0, 0, 0.38),
1px 0 rgba(0, 0, 0, 0.38),
0 -1px rgba(0, 0, 0, 0.38);
}
.ready-toggle:active {
transform: translateY(1px) scale(0.96);
}
@keyframes ready-toggle-pop {
from {
opacity: 0;
transform: translateY(10px) scale(0.94);
}
to {
opacity: 1;
transform: translateY(0) scale(1);
}
}
.center-desk {
position: absolute;
left: 50%;

View File

@@ -43,6 +43,7 @@ const resolvedAvatarUrl = computed(() => {
<div class="player-meta">
<p>{{ player.name }}</p>
<small v-if="player.isReady" class="ready-chip">已准备</small>
</div>
<div class="missing-mark">

View File

@@ -4,5 +4,6 @@ export interface SeatPlayerCardModel {
name: string // 显示名称
dealer: boolean // 是否庄家
isTurn: boolean // 是否当前轮到该玩家
isReady: boolean // 是否已准备
missingSuitLabel: string // 定缺花色(万/筒/条)
}

View File

@@ -23,9 +23,11 @@ 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} from '../game/actions'
import type {GameAction, RoomPlayerUpdatePayload} from '../game/actions'
import {dispatchGameAction} from '../game/dispatcher'
import {refreshAccessToken} from '../api/auth'
import {AuthExpiredError, type AuthSession} from '../api/authed-request'
import {getUserInfo} from '../api/user'
import {clearAuth, readStoredAuth, writeStoredAuth} from '../utils/auth-storage'
import type {WsStatus} from '../ws/client'
import {wsClient} from '../ws/client'
@@ -62,6 +64,7 @@ const wsMessages = ref<string[]>([])
const wsError = ref('')
const selectedTile = ref<string | null>(null)
const leaveRoomPending = ref(false)
const readyTogglePending = ref(false)
let clockTimer: number | null = null
let unsubscribe: (() => void) | null = null
@@ -74,8 +77,13 @@ let refreshingWsToken = false
let lastForcedRefreshAt = 0
const loggedInUserId = computed(() => {
const rawId = auth.value?.user?.id
if (typeof rawId === 'string') {
const source = auth.value?.user as Record<string, unknown> | undefined
const rawId =
source?.id ??
source?.userID ??
source?.user_id
if (typeof rawId === 'string' && rawId.trim()) {
return rawId
}
if (typeof rawId === 'number') {
@@ -170,7 +178,7 @@ const seatViews = computed<SeatViewModel[]>(() => {
const currentTurn = gameStore.currentTurn
return players.slice(0, 4).map((player) => {
const relativeIndex = (player.seatIndex - selfSeatIndex + 4) % 4
const relativeIndex = (selfSeatIndex - player.seatIndex + 4) % 4
const seatKey = tableOrder[relativeIndex] ?? 'top'
return {
key: seatKey,
@@ -195,7 +203,7 @@ const seatWinds = computed<Record<SeatKey, string>>(() => {
}
for (let absoluteSeat = 0; absoluteSeat < 4; absoluteSeat += 1) {
const relativeIndex = (absoluteSeat - selfSeatIndex + 4) % 4
const relativeIndex = (selfSeatIndex - absoluteSeat + 4) % 4
const seatKey = tableOrder[relativeIndex] ?? 'top'
result[seatKey] = directionBySeatIndex[absoluteSeat] ?? northWind
}
@@ -226,6 +234,56 @@ const roomStatusText = computed(() => {
return map[status] ?? status ?? '--'
})
const myReadyState = computed(() => {
return Boolean(myPlayer.value?.isReady)
})
const showReadyToggle = computed(() => {
return gameStore.phase === 'waiting' && Boolean(gameStore.roomId)
})
function applyPlayerReadyState(playerId: string, ready: boolean): void {
const player = gameStore.players[playerId]
if (player) {
player.isReady = ready
}
const room = activeRoom.value
if (!room || room.roomId !== gameStore.roomId) {
return
}
const roomPlayer = room.players.find((item) => item.playerId === playerId)
if (roomPlayer) {
roomPlayer.ready = ready
}
}
function syncReadyStatesFromRoomUpdate(payload: RoomPlayerUpdatePayload): void {
if (!Array.isArray(payload.players)) {
return
}
for (const item of payload.players) {
const playerId =
(typeof item.PlayerID === 'string' && item.PlayerID) ||
(typeof item.player_id === 'string' && item.player_id) ||
''
const ready =
typeof item.Ready === 'boolean'
? item.Ready
: typeof item.ready === 'boolean'
? item.ready
: undefined
if (!playerId || typeof ready !== 'boolean') {
continue
}
applyPlayerReadyState(playerId, ready)
}
}
const networkLabel = computed(() => {
const map: Record<WsStatus, string> = {
connected: '已连接',
@@ -268,6 +326,7 @@ const seatDecor = computed<Record<SeatKey, SeatPlayerCardModel>>(() => {
name: '空位',
dealer: false,
isTurn: false,
isReady: false,
missingSuitLabel: defaultMissingSuitLabel,
})
@@ -294,6 +353,7 @@ const seatDecor = computed<Record<SeatKey, SeatPlayerCardModel>>(() => {
name: Array.from(seat.isSelf ? selfDisplayName : displayName).slice(0, 4).join(''),
dealer: seat.player.seatIndex === dealerIndex,
isTurn: seat.isTurn,
isReady: Boolean(seat.player.isReady),
missingSuitLabel: missingSuitLabel(seat.player.missingSuit),
}
}
@@ -439,6 +499,54 @@ function toGameAction(message: unknown): GameAction | null {
}
}
function handleReadyStateResponse(message: unknown): void {
if (!message || typeof message !== 'object') {
return
}
const source = message as Record<string, unknown>
if (typeof source.type !== 'string') {
return
}
const type = source.type.replace(/[-\s]/g, '_').toUpperCase()
if (type !== 'SET_READY') {
return
}
const payload = source.payload
if (!payload || typeof payload !== 'object') {
return
}
const readyPayload = payload as Record<string, unknown>
const roomId =
typeof readyPayload.room_id === 'string'
? readyPayload.room_id
: typeof source.roomId === 'string'
? source.roomId
: ''
const userId =
typeof readyPayload.user_id === 'string'
? readyPayload.user_id
: typeof source.target === 'string'
? source.target
: ''
const ready = readyPayload.ready
if (roomId && roomId !== gameStore.roomId) {
return
}
if (typeof ready === 'boolean' && userId) {
applyPlayerReadyState(userId, ready)
}
if (userId && userId === loggedInUserId.value) {
readyTogglePending.value = false
}
}
function logoutToLogin(): void {
clearAuth()
auth.value = null
@@ -446,6 +554,74 @@ function logoutToLogin(): void {
void router.replace('/login')
}
function currentSession(): AuthSession | null {
const current = auth.value
if (!current?.token) {
return null
}
return {
token: current.token,
tokenType: current.tokenType,
refreshToken: current.refreshToken,
expiresIn: current.expiresIn,
}
}
function syncAuthSession(next: AuthSession): void {
if (!auth.value) {
return
}
auth.value = {
...auth.value,
token: next.token,
tokenType: next.tokenType ?? auth.value.tokenType,
refreshToken: next.refreshToken ?? auth.value.refreshToken,
expiresIn: next.expiresIn,
}
writeStoredAuth(auth.value)
}
async function ensureCurrentUserLoaded(): Promise<void> {
if (loggedInUserId.value) {
return
}
const currentAuth = auth.value
const session = currentSession()
if (!session) {
return
}
try {
const userInfo = await getUserInfo(session, syncAuthSession)
const resolvedId = userInfo.userID ?? userInfo.user_id ?? userInfo.id ?? currentAuth?.user?.id
const nextUser = {
...(currentAuth?.user ?? {}),
...userInfo,
id:
typeof resolvedId === 'string' || typeof resolvedId === 'number'
? resolvedId
: undefined,
}
if (!currentAuth) {
return
}
auth.value = {
...currentAuth,
user: nextUser,
}
writeStoredAuth(auth.value)
} catch (error) {
if (error instanceof AuthExpiredError) {
logoutToLogin()
}
}
}
function decodeJwtExpMs(token: string): number | null {
const parts = token.split('.')
const payloadPart = parts[1]
@@ -557,6 +733,27 @@ function backHall(): void {
})
}
function toggleReadyState(): void {
if (readyTogglePending.value) {
return
}
const nextReady = !myReadyState.value
readyTogglePending.value = true
console.log('[ready-toggle]', {
loggedInUserId: loggedInUserId.value,
myReadyState: myReadyState.value,
nextReady,
})
sendWsMessage({
type: 'set_ready',
roomId: gameStore.roomId,
payload: {
ready: nextReady,
},
})
}
function handleLeaveRoom(): void {
menuOpen.value = false
backHall()
@@ -627,10 +824,12 @@ function hydrateFromActiveRoom(routeRoomId: string): void {
onMounted(() => {
const routeRoomId = typeof route.params.roomId === 'string' ? route.params.roomId : ''
void ensureCurrentUserLoaded().finally(() => {
hydrateFromActiveRoom(routeRoomId)
if (routeRoomId) {
gameStore.roomId = routeRoomId
}
})
const handler = (status: WsStatus) => {
wsStatus.value = status
@@ -639,9 +838,14 @@ onMounted(() => {
wsClient.onMessage((msg: unknown) => {
const text = typeof msg === 'string' ? msg : JSON.stringify(msg)
wsMessages.value.push(`[server] ${text}`)
handleReadyStateResponse(msg)
const gameAction = toGameAction(msg)
if (gameAction) {
dispatchGameAction(gameAction)
if (gameAction.type === 'ROOM_PLAYER_UPDATE') {
syncReadyStatesFromRoomUpdate(gameAction.payload)
readyTogglePending.value = false
}
}
})
wsClient.onError((message: string) => {
@@ -796,6 +1000,16 @@ onBeforeUnmount(() => {
<WindSquare class="center-wind-square" :seat-winds="seatWinds"/>
<button
v-if="showReadyToggle"
class="ready-toggle"
type="button"
:disabled="readyTogglePending"
@click="toggleReadyState"
>
<span class="ready-toggle-label">{{ myReadyState ? '取 消' : '准 备' }}</span>
</button>
<div class="bottom-control-panel">