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;
|
||||
}
|
||||
|
||||
.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%;
|
||||
|
||||
@@ -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">
|
||||
|
||||
@@ -4,5 +4,6 @@ export interface SeatPlayerCardModel {
|
||||
name: string // 显示名称
|
||||
dealer: boolean // 是否庄家
|
||||
isTurn: boolean // 是否当前轮到该玩家
|
||||
isReady: boolean // 是否已准备
|
||||
missingSuitLabel: string // 定缺花色(万/筒/条)
|
||||
}
|
||||
|
||||
@@ -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">
|
||||
|
||||
|
||||
Reference in New Issue
Block a user