diff --git a/src/assets/images/icons/cancel.png b/src/assets/images/icons/cancel.png new file mode 100755 index 0000000..f4bfb58 Binary files /dev/null and b/src/assets/images/icons/cancel.png differ diff --git a/src/assets/images/icons/read.png b/src/assets/images/icons/read.png new file mode 100755 index 0000000..95a0b92 Binary files /dev/null and b/src/assets/images/icons/read.png differ diff --git a/src/assets/styles/room.css b/src/assets/styles/room.css index d86828c..5f512f3 100644 --- a/src/assets/styles/room.css +++ b/src/assets/styles/room.css @@ -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%; diff --git a/src/components/game/SeatPlayerCard.vue b/src/components/game/SeatPlayerCard.vue index 3d4e552..627fc4f 100644 --- a/src/components/game/SeatPlayerCard.vue +++ b/src/components/game/SeatPlayerCard.vue @@ -43,6 +43,7 @@ const resolvedAvatarUrl = computed(() => {

{{ player.name }}

+ 已准备
diff --git a/src/components/game/seat-player-card.ts b/src/components/game/seat-player-card.ts index a77f893..6be38c6 100644 --- a/src/components/game/seat-player-card.ts +++ b/src/components/game/seat-player-card.ts @@ -4,5 +4,6 @@ export interface SeatPlayerCardModel { name: string // 显示名称 dealer: boolean // 是否庄家 isTurn: boolean // 是否当前轮到该玩家 + isReady: boolean // 是否已准备 missingSuitLabel: string // 定缺花色(万/筒/条) } diff --git a/src/views/ChengduGamePage.vue b/src/views/ChengduGamePage.vue index adf2bf8..9c07271 100644 --- a/src/views/ChengduGamePage.vue +++ b/src/views/ChengduGamePage.vue @@ -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([]) const wsError = ref('') const selectedTile = ref(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 | 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(() => { 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>(() => { } 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 = { connected: '已连接', @@ -268,6 +326,7 @@ const seatDecor = computed>(() => { name: '空位', dealer: false, isTurn: false, + isReady: false, missingSuitLabel: defaultMissingSuitLabel, }) @@ -294,6 +353,7 @@ const seatDecor = computed>(() => { 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 + 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 + 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 { + 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 : '' - hydrateFromActiveRoom(routeRoomId) - if (routeRoomId) { - gameStore.roomId = routeRoomId - } + 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(() => { + +