feat(game): 添加游戏准备状态功能
- 在 SeatPlayerCard 组件中添加 isReady 属性用于显示准备状态 - 添加准备/取消准备按钮,支持玩家切换准备状态 - 实现 WebSocket 消息处理以同步玩家准备状态 - 添加 CSS 样式显示准备状态标签和准备按钮 - 优化用户 ID 解析逻辑,支持多种字段格式 - 修复座位索引计算逻辑,确保相对位置正确显示 - 添加认证会话管理功能,确保用户信息同步加载 - 实现房间玩家状态更新的消息处理机制
This commit is contained in:
BIN
src/assets/images/icons/cancel.png
Executable file
BIN
src/assets/images/icons/cancel.png
Executable file
Binary file not shown.
|
After Width: | Height: | Size: 11 KiB |
BIN
src/assets/images/icons/read.png
Executable file
BIN
src/assets/images/icons/read.png
Executable file
Binary file not shown.
|
After Width: | Height: | Size: 11 KiB |
@@ -429,6 +429,22 @@
|
|||||||
color: #eef5ff;
|
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-right .player-meta,
|
||||||
.picture-scene .player-badge.seat-left .player-meta {
|
.picture-scene .player-badge.seat-left .player-meta {
|
||||||
display: flex;
|
display: flex;
|
||||||
@@ -575,6 +591,57 @@
|
|||||||
pointer-events: none;
|
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 {
|
.center-desk {
|
||||||
position: absolute;
|
position: absolute;
|
||||||
left: 50%;
|
left: 50%;
|
||||||
|
|||||||
@@ -43,6 +43,7 @@ const resolvedAvatarUrl = computed(() => {
|
|||||||
|
|
||||||
<div class="player-meta">
|
<div class="player-meta">
|
||||||
<p>{{ player.name }}</p>
|
<p>{{ player.name }}</p>
|
||||||
|
<small v-if="player.isReady" class="ready-chip">已准备</small>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div class="missing-mark">
|
<div class="missing-mark">
|
||||||
|
|||||||
@@ -4,5 +4,6 @@ export interface SeatPlayerCardModel {
|
|||||||
name: string // 显示名称
|
name: string // 显示名称
|
||||||
dealer: boolean // 是否庄家
|
dealer: boolean // 是否庄家
|
||||||
isTurn: boolean // 是否当前轮到该玩家
|
isTurn: boolean // 是否当前轮到该玩家
|
||||||
|
isReady: boolean // 是否已准备
|
||||||
missingSuitLabel: string // 定缺花色(万/筒/条)
|
missingSuitLabel: string // 定缺花色(万/筒/条)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -23,9 +23,11 @@ import westWind from '../assets/images/direction/xi.png'
|
|||||||
import northWind from '../assets/images/direction/bei.png'
|
import northWind from '../assets/images/direction/bei.png'
|
||||||
import type {SeatPlayerCardModel} from '../components/game/seat-player-card'
|
import type {SeatPlayerCardModel} from '../components/game/seat-player-card'
|
||||||
import type {SeatKey} from '../game/seat'
|
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 {dispatchGameAction} from '../game/dispatcher'
|
||||||
import {refreshAccessToken} from '../api/auth'
|
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 {clearAuth, readStoredAuth, writeStoredAuth} from '../utils/auth-storage'
|
||||||
import type {WsStatus} from '../ws/client'
|
import type {WsStatus} from '../ws/client'
|
||||||
import {wsClient} from '../ws/client'
|
import {wsClient} from '../ws/client'
|
||||||
@@ -62,6 +64,7 @@ const wsMessages = ref<string[]>([])
|
|||||||
const wsError = ref('')
|
const wsError = ref('')
|
||||||
const selectedTile = ref<string | null>(null)
|
const selectedTile = ref<string | null>(null)
|
||||||
const leaveRoomPending = ref(false)
|
const leaveRoomPending = ref(false)
|
||||||
|
const readyTogglePending = ref(false)
|
||||||
let clockTimer: number | null = null
|
let clockTimer: number | null = null
|
||||||
let unsubscribe: (() => void) | null = null
|
let unsubscribe: (() => void) | null = null
|
||||||
|
|
||||||
@@ -74,8 +77,13 @@ let refreshingWsToken = false
|
|||||||
let lastForcedRefreshAt = 0
|
let lastForcedRefreshAt = 0
|
||||||
|
|
||||||
const loggedInUserId = computed(() => {
|
const loggedInUserId = computed(() => {
|
||||||
const rawId = auth.value?.user?.id
|
const source = auth.value?.user as Record<string, unknown> | undefined
|
||||||
if (typeof rawId === 'string') {
|
const rawId =
|
||||||
|
source?.id ??
|
||||||
|
source?.userID ??
|
||||||
|
source?.user_id
|
||||||
|
|
||||||
|
if (typeof rawId === 'string' && rawId.trim()) {
|
||||||
return rawId
|
return rawId
|
||||||
}
|
}
|
||||||
if (typeof rawId === 'number') {
|
if (typeof rawId === 'number') {
|
||||||
@@ -170,7 +178,7 @@ const seatViews = computed<SeatViewModel[]>(() => {
|
|||||||
const currentTurn = gameStore.currentTurn
|
const currentTurn = gameStore.currentTurn
|
||||||
|
|
||||||
return players.slice(0, 4).map((player) => {
|
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'
|
const seatKey = tableOrder[relativeIndex] ?? 'top'
|
||||||
return {
|
return {
|
||||||
key: seatKey,
|
key: seatKey,
|
||||||
@@ -195,7 +203,7 @@ const seatWinds = computed<Record<SeatKey, string>>(() => {
|
|||||||
}
|
}
|
||||||
|
|
||||||
for (let absoluteSeat = 0; absoluteSeat < 4; absoluteSeat += 1) {
|
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'
|
const seatKey = tableOrder[relativeIndex] ?? 'top'
|
||||||
result[seatKey] = directionBySeatIndex[absoluteSeat] ?? northWind
|
result[seatKey] = directionBySeatIndex[absoluteSeat] ?? northWind
|
||||||
}
|
}
|
||||||
@@ -226,6 +234,56 @@ const roomStatusText = computed(() => {
|
|||||||
return map[status] ?? status ?? '--'
|
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 networkLabel = computed(() => {
|
||||||
const map: Record<WsStatus, string> = {
|
const map: Record<WsStatus, string> = {
|
||||||
connected: '已连接',
|
connected: '已连接',
|
||||||
@@ -268,6 +326,7 @@ const seatDecor = computed<Record<SeatKey, SeatPlayerCardModel>>(() => {
|
|||||||
name: '空位',
|
name: '空位',
|
||||||
dealer: false,
|
dealer: false,
|
||||||
isTurn: false,
|
isTurn: false,
|
||||||
|
isReady: false,
|
||||||
missingSuitLabel: defaultMissingSuitLabel,
|
missingSuitLabel: defaultMissingSuitLabel,
|
||||||
})
|
})
|
||||||
|
|
||||||
@@ -294,6 +353,7 @@ const seatDecor = computed<Record<SeatKey, SeatPlayerCardModel>>(() => {
|
|||||||
name: Array.from(seat.isSelf ? selfDisplayName : displayName).slice(0, 4).join(''),
|
name: Array.from(seat.isSelf ? selfDisplayName : displayName).slice(0, 4).join(''),
|
||||||
dealer: seat.player.seatIndex === dealerIndex,
|
dealer: seat.player.seatIndex === dealerIndex,
|
||||||
isTurn: seat.isTurn,
|
isTurn: seat.isTurn,
|
||||||
|
isReady: Boolean(seat.player.isReady),
|
||||||
missingSuitLabel: missingSuitLabel(seat.player.missingSuit),
|
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 {
|
function logoutToLogin(): void {
|
||||||
clearAuth()
|
clearAuth()
|
||||||
auth.value = null
|
auth.value = null
|
||||||
@@ -446,6 +554,74 @@ function logoutToLogin(): void {
|
|||||||
void router.replace('/login')
|
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 {
|
function decodeJwtExpMs(token: string): number | null {
|
||||||
const parts = token.split('.')
|
const parts = token.split('.')
|
||||||
const payloadPart = parts[1]
|
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 {
|
function handleLeaveRoom(): void {
|
||||||
menuOpen.value = false
|
menuOpen.value = false
|
||||||
backHall()
|
backHall()
|
||||||
@@ -627,10 +824,12 @@ function hydrateFromActiveRoom(routeRoomId: string): void {
|
|||||||
|
|
||||||
onMounted(() => {
|
onMounted(() => {
|
||||||
const routeRoomId = typeof route.params.roomId === 'string' ? route.params.roomId : ''
|
const routeRoomId = typeof route.params.roomId === 'string' ? route.params.roomId : ''
|
||||||
hydrateFromActiveRoom(routeRoomId)
|
void ensureCurrentUserLoaded().finally(() => {
|
||||||
if (routeRoomId) {
|
hydrateFromActiveRoom(routeRoomId)
|
||||||
gameStore.roomId = routeRoomId
|
if (routeRoomId) {
|
||||||
}
|
gameStore.roomId = routeRoomId
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
const handler = (status: WsStatus) => {
|
const handler = (status: WsStatus) => {
|
||||||
wsStatus.value = status
|
wsStatus.value = status
|
||||||
@@ -639,9 +838,14 @@ onMounted(() => {
|
|||||||
wsClient.onMessage((msg: unknown) => {
|
wsClient.onMessage((msg: unknown) => {
|
||||||
const text = typeof msg === 'string' ? msg : JSON.stringify(msg)
|
const text = typeof msg === 'string' ? msg : JSON.stringify(msg)
|
||||||
wsMessages.value.push(`[server] ${text}`)
|
wsMessages.value.push(`[server] ${text}`)
|
||||||
|
handleReadyStateResponse(msg)
|
||||||
const gameAction = toGameAction(msg)
|
const gameAction = toGameAction(msg)
|
||||||
if (gameAction) {
|
if (gameAction) {
|
||||||
dispatchGameAction(gameAction)
|
dispatchGameAction(gameAction)
|
||||||
|
if (gameAction.type === 'ROOM_PLAYER_UPDATE') {
|
||||||
|
syncReadyStatesFromRoomUpdate(gameAction.payload)
|
||||||
|
readyTogglePending.value = false
|
||||||
|
}
|
||||||
}
|
}
|
||||||
})
|
})
|
||||||
wsClient.onError((message: string) => {
|
wsClient.onError((message: string) => {
|
||||||
@@ -796,6 +1000,16 @@ onBeforeUnmount(() => {
|
|||||||
|
|
||||||
<WindSquare class="center-wind-square" :seat-winds="seatWinds"/>
|
<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">
|
<div class="bottom-control-panel">
|
||||||
|
|
||||||
|
|||||||
Reference in New Issue
Block a user