refactor(ChengduGamePage): replace manual WebSocket logic with composable hook

- Replace manual WebSocket connection and state management with
  useChengduGameRoom composable
- Remove unused imports and authentication related code
- Simplify component by extracting room state logic into separate hook
- Clean up redundant functions and variables that are now handled
 by the composable
- Update component lifecycle to use the new composable's methods
  for connecting WebSocket and managing room state
```
This commit is contained in:
2026-03-24 14:12:04 +08:00
parent f97f1ffdbc
commit 1b15748d0d
2 changed files with 861 additions and 768 deletions

View File

@@ -0,0 +1,830 @@
import { computed, onBeforeUnmount, onMounted, ref, watch, type ComputedRef, type Ref } from 'vue'
import type { RouteLocationNormalizedLoaded, Router } from 'vue-router'
import type { AuthSession } from '../../api/authed-request'
import { refreshAccessToken } from '../../api/auth'
import { getUserInfo } from '../../api/user'
import {
DEFAULT_MAX_PLAYERS,
activeRoomState,
destroyActiveRoomState,
mergeActiveRoomState,
resetActiveRoomState,
type GameState,
type RoomPlayerState,
type RoomState,
} from '../../state/active-room'
import { readStoredAuth, writeStoredAuth } from '../../utils/auth-storage'
import type { StoredAuth } from '../../types/session'
export type SeatKey = 'top' | 'right' | 'bottom' | 'left'
interface ActionEventLike {
type?: unknown
status?: unknown
requestId?: unknown
request_id?: unknown
roomId?: unknown
room_id?: unknown
payload?: unknown
data?: unknown
}
export interface SeatView {
key: SeatKey
player: RoomPlayerState | null
isSelf: boolean
isTurn: boolean
label: string
subLabel: string
}
export interface ChengduGameRoomModel {
auth: Ref<StoredAuth | null>
roomState: typeof activeRoomState
roomId: ComputedRef<string>
roomName: ComputedRef<string>
currentUserId: ComputedRef<string>
loggedInUserName: ComputedRef<string>
wsStatus: Ref<'disconnected' | 'connecting' | 'connected'>
wsError: Ref<string>
wsMessages: Ref<string[]>
startGamePending: Ref<boolean>
leaveRoomPending: Ref<boolean>
canStartGame: ComputedRef<boolean>
seatViews: ComputedRef<SeatView[]>
connectWs: () => Promise<void>
sendStartGame: () => void
backHall: () => void
}
const WS_BASE_URL = import.meta.env.VITE_GAME_WS_URL ?? '/api/v1/ws'
export function useChengduGameRoom(
route: RouteLocationNormalizedLoaded,
router: Router,
): ChengduGameRoomModel {
const auth = ref(readStoredAuth())
const ws = ref<WebSocket | null>(null)
const wsStatus = ref<'disconnected' | 'connecting' | 'connected'>('disconnected')
const wsError = ref('')
const wsMessages = ref<string[]>([])
const startGamePending = ref(false)
const lastStartRequestId = ref('')
const leaveRoomPending = ref(false)
const lastLeaveRoomRequestId = ref('')
const leaveHallAfterAck = ref(false)
const roomId = computed(() => {
return typeof route.params.roomId === 'string' ? route.params.roomId : ''
})
const roomName = computed(() => {
return typeof route.query.roomName === 'string' ? route.query.roomName : ''
})
const currentUserId = computed(() => {
const user = auth.value?.user as Record<string, unknown> | undefined
const candidate = user?.id ?? user?.userID ?? user?.user_id
if (typeof candidate === 'string') {
return candidate
}
if (typeof candidate === 'number' && Number.isFinite(candidate)) {
return String(candidate)
}
return ''
})
const loggedInUserName = computed(() => {
if (!auth.value?.user) {
return ''
}
return auth.value.user.nickname ?? auth.value.user.username ?? ''
})
const roomState = activeRoomState
const isRoomFull = computed(() => {
return (
roomState.value.maxPlayers > 0 &&
roomState.value.playerCount === roomState.value.maxPlayers
)
})
const canStartGame = computed(() => {
return (
Boolean(roomState.value.id) &&
roomState.value.status === 'waiting' &&
isRoomFull.value &&
Boolean(currentUserId.value) &&
roomState.value.ownerId === currentUserId.value
)
})
const seatViews = computed<SeatView[]>(() => {
const seats: Record<SeatKey, RoomPlayerState | null> = {
top: null,
right: null,
bottom: null,
left: null,
}
const players = [...roomState.value.players].sort((a, b) => a.index - b.index)
const hasSelf = players.some((player) => player.playerId === currentUserId.value)
if (currentUserId.value && roomState.value.id && !hasSelf) {
players.unshift({
index: 0,
playerId: currentUserId.value,
ready: false,
})
}
const me = players.find((player) => player.playerId === currentUserId.value) ?? null
const anchorIndex = me?.index ?? players[0]?.index ?? 0
const clockwiseSeatByDelta: SeatKey[] = ['bottom', 'left', 'top', 'right']
for (const player of players) {
const normalizedDelta = ((player.index - anchorIndex) % 4 + 4) % 4
const seat = clockwiseSeatByDelta[normalizedDelta] ?? 'top'
seats[seat] = player
}
const turnSeat =
roomState.value.currentTurnIndex === null
? null
: clockwiseSeatByDelta[
((roomState.value.currentTurnIndex - anchorIndex) % 4 + 4) % 4
] ?? null
const order: SeatKey[] = ['top', 'right', 'bottom', 'left']
return order.map((seat) => {
const player = seats[seat]
const isSelf = Boolean(player) && player?.playerId === currentUserId.value
return {
key: seat,
player,
isSelf,
isTurn: turnSeat === seat,
label: player ? (isSelf ? '你' : player.playerId) : '空位',
subLabel: player ? `座位 ${player.index}` : '',
}
})
})
function backHall(): void {
if (leaveRoomPending.value) {
return
}
leaveHallAfterAck.value = true
const sent = sendLeaveRoom()
if (!sent) {
leaveHallAfterAck.value = false
pushWsMessage('[client] Leave room request was not sent')
}
}
function pushWsMessage(text: string): void {
const line = `[${new Date().toLocaleTimeString()}] ${text}`
wsMessages.value.unshift(line)
if (wsMessages.value.length > 80) {
wsMessages.value.length = 80
}
}
function logWsSend(message: unknown): void {
console.log('[WS][client] 发送:', message)
}
function logWsReceive(kind: string, payload?: unknown): void {
const now = new Date().toLocaleTimeString()
if (payload === undefined) {
console.log(`[WS][${now}] 收到${kind}`)
return
}
console.log(`[WS][${now}] 收到${kind}:`, payload)
}
function disconnectWs(): void {
if (ws.value) {
ws.value.close()
ws.value = null
}
wsStatus.value = 'disconnected'
}
function toRecord(value: unknown): Record<string, unknown> | null {
return typeof value === 'object' && value !== null ? (value as Record<string, unknown>) : null
}
function toStringOrEmpty(value: unknown): string {
if (typeof value === 'string') {
return value
}
if (typeof value === 'number' && Number.isFinite(value)) {
return String(value)
}
return ''
}
function toSession(source: NonNullable<typeof auth.value>): AuthSession {
return {
token: source.token,
tokenType: source.tokenType,
refreshToken: source.refreshToken,
expiresIn: source.expiresIn,
}
}
function syncAuth(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 ensureCurrentUserId(): Promise<void> {
if (currentUserId.value || !auth.value) {
return
}
try {
const userInfo = await getUserInfo(toSession(auth.value), syncAuth)
const payload = userInfo as Record<string, unknown>
const resolvedId = toStringOrEmpty(payload.userID ?? payload.user_id ?? payload.id)
if (!resolvedId) {
return
}
auth.value = {
...auth.value,
user: {
...(auth.value.user ?? {}),
id: resolvedId,
},
}
writeStoredAuth(auth.value)
} catch {
wsError.value = '获取当前用户 ID 失败,部分操作可能不可用'
}
}
async function ensureWsAuth(): Promise<string | null> {
const currentAuth = auth.value
if (!currentAuth?.token) {
return null
}
if (!currentAuth.refreshToken) {
return currentAuth.token
}
try {
const refreshed = await refreshAccessToken({
token: currentAuth.token,
tokenType: currentAuth.tokenType,
refreshToken: currentAuth.refreshToken,
})
const nextAuth = {
...currentAuth,
token: refreshed.token,
tokenType: refreshed.tokenType ?? currentAuth.tokenType,
refreshToken: refreshed.refreshToken ?? currentAuth.refreshToken,
expiresIn: refreshed.expiresIn,
user: refreshed.user ?? currentAuth.user,
}
auth.value = nextAuth
writeStoredAuth(nextAuth)
return nextAuth.token
} catch {
return currentAuth.token
}
}
function toFiniteNumber(value: unknown): number | null {
if (typeof value === 'number' && Number.isFinite(value)) {
return value
}
if (typeof value === 'string' && value.trim()) {
const parsed = Number(value)
return Number.isFinite(parsed) ? parsed : null
}
return null
}
function toBoolean(value: unknown): boolean {
if (typeof value === 'boolean') {
return value
}
if (typeof value === 'number') {
return value !== 0
}
if (typeof value === 'string') {
const normalized = value.trim().toLowerCase()
return normalized === '1' || normalized === 'true' || normalized === 'yes'
}
return false
}
function normalizeScores(value: unknown): Record<string, number> {
const record = toRecord(value)
if (!record) {
return {}
}
const scores: Record<string, number> = {}
for (const [key, score] of Object.entries(record)) {
const parsed = toFiniteNumber(score)
if (parsed !== null) {
scores[key] = parsed
}
}
return scores
}
function normalizePlayer(input: unknown, fallbackIndex: number): RoomPlayerState | null {
const player = toRecord(input)
if (!player) {
return null
}
const playerId = toStringOrEmpty(player.playerId ?? player.player_id ?? player.user_id ?? player.id)
if (!playerId) {
return null
}
const seatIndex = toFiniteNumber(player.index ?? player.seat ?? player.position ?? player.player_index)
return {
index: seatIndex ?? fallbackIndex,
playerId,
ready: Boolean(player.ready),
}
}
function extractCurrentTurnIndex(value: Record<string, unknown>): number | null {
const game = toRecord(value.game)
const gameState = toRecord(game?.state)
const keys = [
gameState?.currentTurn,
gameState?.current_turn,
gameState?.currentTurnIndex,
gameState?.current_turn_index,
value.currentTurnIndex,
value.current_turn_index,
value.currentPlayerIndex,
value.current_player_index,
value.turnIndex,
value.turn_index,
value.activePlayerIndex,
value.active_player_index,
]
for (const key of keys) {
const parsed = toFiniteNumber(key)
if (parsed !== null) {
return parsed
}
}
return null
}
function normalizeGame(input: unknown): GameState | null {
const game = toRecord(input)
if (!game) {
return null
}
const rule = toRecord(game.rule)
const rawState = toRecord(game.state)
const playersRaw =
(Array.isArray(rawState?.players) ? rawState?.players : null) ??
(Array.isArray(rawState?.playerStates) ? rawState?.playerStates : null) ??
[]
const normalizedPlayers = playersRaw
.map((item, index) => normalizePlayer(item, index))
.filter((item): item is RoomPlayerState => Boolean(item))
return {
rule: rule
? {
name: toStringOrEmpty(rule.name),
isBloodFlow: toBoolean(rule.isBloodFlow ?? rule.is_blood_flow),
hasHongZhong: toBoolean(rule.hasHongZhong ?? rule.has_hong_zhong),
}
: null,
state: rawState
? {
phase: toStringOrEmpty(rawState.phase),
dealerIndex: toFiniteNumber(rawState.dealerIndex ?? rawState.dealer_index) ?? 0,
currentTurn: toFiniteNumber(rawState.currentTurn ?? rawState.current_turn) ?? 0,
needDraw: toBoolean(rawState.needDraw ?? rawState.need_draw),
players: normalizedPlayers,
wall: Array.isArray(rawState.wall)
? rawState.wall.map((item) => toStringOrEmpty(item)).filter(Boolean)
: [],
lastDiscardTile: toStringOrEmpty(rawState.lastDiscardTile ?? rawState.last_discard_tile) || null,
lastDiscardBy: toStringOrEmpty(rawState.lastDiscardBy ?? rawState.last_discard_by),
pendingClaim: toRecord(rawState.pendingClaim ?? rawState.pending_claim),
winners: Array.isArray(rawState.winners)
? rawState.winners.map((item) => toStringOrEmpty(item)).filter(Boolean)
: [],
scores: normalizeScores(rawState.scores),
lastDrawPlayerId: toStringOrEmpty(rawState.lastDrawPlayerID ?? rawState.last_draw_player_id),
lastDrawFromGang: toBoolean(rawState.lastDrawFromGang ?? rawState.last_draw_from_gang),
lastDrawIsLastTile: toBoolean(rawState.lastDrawIsLastTile ?? rawState.last_draw_is_last_tile),
huWay: toStringOrEmpty(rawState.huWay ?? rawState.hu_way),
}
: null,
}
}
function normalizeRoom(input: unknown): RoomState | null {
const source = toRecord(input)
if (!source) {
return null
}
let room = source
let id = toStringOrEmpty(room.roomId ?? room.room_id ?? room.id)
if (!id) {
const nestedRoom = toRecord(room.data)
if (nestedRoom) {
room = nestedRoom
id = toStringOrEmpty(room.roomId ?? room.room_id ?? room.id)
}
}
if (!id) {
return null
}
const maxPlayers =
toFiniteNumber(room.maxPlayers ?? room.max_players) ?? roomState.value.maxPlayers ?? DEFAULT_MAX_PLAYERS
const playersRaw =
(Array.isArray(room.players) ? room.players : null) ??
(Array.isArray(room.playerList) ? room.playerList : null) ??
(Array.isArray(room.player_list) ? room.player_list : null) ??
[]
const playerIdsRaw =
(Array.isArray(room.player_ids) ? room.player_ids : null) ??
(Array.isArray(room.playerIds) ? room.playerIds : null) ??
[]
const players = playersRaw
.map((item, index) => normalizePlayer(item, index))
.filter((item): item is RoomPlayerState => Boolean(item))
.sort((a, b) => a.index - b.index)
const playersFromIds = playerIdsRaw
.map((item, index) => ({
index,
playerId: toStringOrEmpty(item),
ready: false,
}))
.filter((item) => Boolean(item.playerId))
const resolvedPlayers = players.length > 0 ? players : playersFromIds
const parsedPlayerCount = toFiniteNumber(room.player_count ?? room.playerCount)
const game = normalizeGame(room.game)
return {
id,
name: toStringOrEmpty(room.name) || roomState.value.name,
gameType: toStringOrEmpty(room.gameType ?? room.game_type) || roomState.value.gameType || 'chengdu',
ownerId: toStringOrEmpty(room.ownerId ?? room.owner_id),
maxPlayers,
playerCount: parsedPlayerCount ?? resolvedPlayers.length,
status: toStringOrEmpty(room.status) || roomState.value.status || 'waiting',
createdAt: toStringOrEmpty(room.createdAt ?? room.created_at) || roomState.value.createdAt,
updatedAt: toStringOrEmpty(room.updatedAt ?? room.updated_at) || roomState.value.updatedAt,
game: game ?? roomState.value.game,
players: resolvedPlayers,
currentTurnIndex: extractCurrentTurnIndex(room),
}
}
function mergeRoomState(next: RoomState): void {
if (roomId.value && next.id !== roomId.value) {
return
}
mergeActiveRoomState(next)
}
function consumeGameEvent(raw: string): void {
let parsed: unknown = null
try {
parsed = JSON.parse(raw)
} catch {
return
}
const event = toRecord(parsed) as ActionEventLike | null
if (!event) {
return
}
const payload = toRecord(event.payload)
const data = toRecord(event.data)
const eventType = toStringOrEmpty(event.type)
const eventStatus = toStringOrEmpty(event.status)
const eventRoomId = toStringOrEmpty(event.roomId ?? event.room_id ?? payload?.roomId ?? payload?.room_id)
const eventRequestId = toStringOrEmpty(
event.requestId ??
event.request_id ??
payload?.requestId ??
payload?.request_id ??
data?.requestId ??
data?.request_id,
)
const payloadPlayerIds = Array.isArray(payload?.player_ids)
? payload.player_ids.map((item) => toStringOrEmpty(item)).filter(Boolean)
: Array.isArray(payload?.playerIds)
? payload.playerIds.map((item) => toStringOrEmpty(item)).filter(Boolean)
: null
const leaveByRequestIdMatched = Boolean(
eventRequestId && eventRequestId === lastLeaveRoomRequestId.value,
)
const leaveByPlayerUpdateMatched =
leaveRoomPending.value &&
eventType === 'room_player_update' &&
eventStatus === 'ok' &&
eventRoomId === (roomState.value.id || roomId.value) &&
Array.isArray(payloadPlayerIds) &&
Boolean(currentUserId.value) &&
!payloadPlayerIds.includes(currentUserId.value)
if (leaveByRequestIdMatched || leaveByPlayerUpdateMatched) {
leaveRoomPending.value = false
lastLeaveRoomRequestId.value = ''
if (event.status === 'error') {
leaveHallAfterAck.value = false
wsError.value = '退出房间失败,请稍后重试'
pushWsMessage(`[client] 退出房间失败 requestId=${eventRequestId}`)
} else {
if (leaveByPlayerUpdateMatched) {
pushWsMessage('[client] 已确认退出房间 player_update')
} else {
pushWsMessage(`[client] 已确认退出房间 requestId=${eventRequestId}`)
}
if (leaveHallAfterAck.value) {
leaveHallAfterAck.value = false
void router.push('/hall')
}
}
}
const candidates: unknown[] = [event.payload, event.data]
if (payload) {
candidates.push(payload.room, payload.state, payload.roomState, payload.data)
}
if (data) {
candidates.push(data.room, data.state, data.roomState, data.data)
}
candidates.push(event)
for (const candidate of candidates) {
const normalized = normalizeRoom(candidate)
if (normalized) {
mergeRoomState(normalized)
break
}
}
if (
event.status === 'error' &&
typeof event.requestId === 'string' &&
event.requestId === lastStartRequestId.value
) {
startGamePending.value = false
}
}
function createRequestId(prefix: string): string {
return `${prefix}-${Date.now()}-${Math.random().toString(36).slice(2, 10)}`
}
function sendStartGame(): void {
if (
!ws.value ||
ws.value.readyState !== WebSocket.OPEN ||
!canStartGame.value ||
startGamePending.value
) {
return
}
const sender = currentUserId.value
if (!sender) {
return
}
const requestId = createRequestId('start-game')
lastStartRequestId.value = requestId
startGamePending.value = true
const message = {
type: 'start_game',
sender,
target: 'room',
roomId: roomState.value.id || roomId.value,
seq: Date.now(),
requestId,
trace_id: createRequestId('trace'),
payload: {},
}
logWsSend(message)
ws.value.send(JSON.stringify(message))
pushWsMessage(`[client] 请求开始游戏 requestId=${requestId}`)
}
function sendLeaveRoom(): boolean {
if (!ws.value || ws.value.readyState !== WebSocket.OPEN) {
wsError.value = 'WebSocket 未连接,无法退出房间'
return false
}
const sender = currentUserId.value
const targetRoomId = roomState.value.id || roomId.value
if (!sender) {
wsError.value = '缺少当前用户 ID无法退出房间'
return false
}
if (!targetRoomId) {
wsError.value = '缺少房间 ID无法退出房间'
return false
}
const requestId = createRequestId('leave-room')
leaveRoomPending.value = true
lastLeaveRoomRequestId.value = requestId
const message = {
type: 'leave_room',
sender,
target: 'room',
roomId: targetRoomId,
seq: Date.now(),
requestId,
trace_id: createRequestId('trace'),
payload: {},
}
logWsSend(message)
ws.value.send(JSON.stringify(message))
pushWsMessage(`[client] 请求退出房间 requestId=${requestId}`)
return true
}
async function connectWs(): Promise<void> {
wsError.value = ''
const token = await ensureWsAuth()
if (!token) {
wsError.value = '缺少 token无法建立 WebSocket 连接'
return
}
disconnectWs()
wsStatus.value = 'connecting'
const url = buildWsUrl(token)
const socket = new WebSocket(url)
ws.value = socket
socket.onopen = () => {
wsStatus.value = 'connected'
pushWsMessage('WebSocket 已连接')
}
socket.onmessage = (event) => {
if (typeof event.data === 'string') {
logWsReceive('文本消息', event.data)
try {
const parsed = JSON.parse(event.data)
logWsReceive('JSON 消息', parsed)
pushWsMessage(`[server] ${JSON.stringify(parsed, null, 2)}`)
} catch {
pushWsMessage(`[server] ${event.data}`)
}
consumeGameEvent(event.data)
return
}
logWsReceive('binary message')
pushWsMessage('[binary] message received')
}
socket.onerror = () => {
wsError.value = 'WebSocket 连接异常'
}
socket.onclose = () => {
wsStatus.value = 'disconnected'
startGamePending.value = false
if (leaveRoomPending.value) {
leaveRoomPending.value = false
lastLeaveRoomRequestId.value = ''
leaveHallAfterAck.value = false
wsError.value = '连接已断开,未收到退出房间确认'
pushWsMessage('[client] 连接断开,退出房间请求未确认')
}
pushWsMessage('WebSocket 已断开')
}
}
function buildWsUrl(token: string): string {
const baseUrl = /^wss?:\/\//.test(WS_BASE_URL)
? new URL(WS_BASE_URL)
: new URL(
WS_BASE_URL.startsWith('/') ? WS_BASE_URL : `/${WS_BASE_URL}`,
`${window.location.protocol === 'https:' ? 'wss:' : 'ws:'}//${window.location.host}`,
)
baseUrl.searchParams.set('token', token)
return baseUrl.toString()
}
watch(
roomId,
(nextRoomId) => {
const currentRoom = roomState.value
if (!nextRoomId) {
destroyActiveRoomState()
} else if (currentRoom.id !== nextRoomId) {
resetActiveRoomState({
id: nextRoomId,
name: roomName.value,
})
} else if (!currentRoom.name && roomName.value) {
roomState.value = { ...currentRoom, name: roomName.value }
}
startGamePending.value = false
lastStartRequestId.value = ''
leaveRoomPending.value = false
lastLeaveRoomRequestId.value = ''
leaveHallAfterAck.value = false
},
{ immediate: true },
)
watch(roomName, (next) => {
roomState.value = { ...roomState.value, name: next || roomState.value.name }
})
watch(
[canStartGame, wsStatus],
([canStart, status]) => {
if (!canStart || status !== 'connected') {
return
}
sendStartGame()
},
{ immediate: true },
)
watch(
() => roomState.value.status,
(status) => {
if (status === 'playing' || status === 'finished') {
startGamePending.value = false
}
},
)
onMounted(async () => {
await ensureCurrentUserId()
void connectWs()
})
onBeforeUnmount(() => {
disconnectWs()
destroyActiveRoomState()
})
return {
auth,
roomState,
roomId,
roomName,
currentUserId,
loggedInUserName,
wsStatus,
wsError,
wsMessages,
startGamePending,
leaveRoomPending,
canStartGame,
seatViews,
connectWs,
sendStartGame,
backHall,
}
}

View File

@@ -1,5 +1,5 @@
<script setup lang="ts">
import { computed, onBeforeUnmount, onMounted, ref, watch } from 'vue'
<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 topBackImage from '../assets/images/tiles/top/tbgs_2.png'
@@ -10,151 +10,31 @@ import TopPlayerCard from '../components/game/TopPlayerCard.vue'
import RightPlayerCard from '../components/game/RightPlayerCard.vue'
import BottomPlayerCard from '../components/game/BottomPlayerCard.vue'
import LeftPlayerCard from '../components/game/LeftPlayerCard.vue'
import type { AuthSession } from '../api/authed-request'
import { refreshAccessToken } from '../api/auth'
import { getUserInfo } from '../api/user'
import type { SeatPlayerCardModel } from '../components/game/seat-player-card'
import {
DEFAULT_MAX_PLAYERS,
activeRoomState,
destroyActiveRoomState,
mergeActiveRoomState,
resetActiveRoomState,
type GameState,
type RoomPlayerState,
type RoomState,
} from '../state/active-room'
import { readStoredAuth, writeStoredAuth } from '../utils/auth-storage'
import { useChengduGameRoom, type SeatKey } from '../features/chengdu-game/useChengduGameRoom'
const router = useRouter()
const route = useRoute()
const router = useRouter()
const {
roomState,
roomId,
roomName,
loggedInUserName,
wsStatus,
wsError,
wsMessages,
startGamePending,
leaveRoomPending,
canStartGame,
seatViews,
connectWs,
sendStartGame,
backHall,
} = useChengduGameRoom(route, router)
const auth = ref(readStoredAuth())
const ws = ref<WebSocket | null>(null)
const wsStatus = ref<'disconnected' | 'connecting' | 'connected'>('disconnected')
const wsError = ref('')
const wsMessages = ref<string[]>([])
const startGamePending = ref(false)
const lastStartRequestId = ref('')
const leaveRoomPending = ref(false)
const lastLeaveRoomRequestId = ref('')
const leaveHallAfterAck = ref(false)
const now = ref(Date.now())
let clockTimer: number | null = null
const WS_BASE_URL = import.meta.env.VITE_GAME_WS_URL ?? '/api/v1/ws'
type SeatKey = 'top' | 'right' | 'bottom' | 'left'
interface ActionEventLike {
type?: unknown
status?: unknown
requestId?: unknown
request_id?: unknown
roomId?: unknown
room_id?: unknown
payload?: unknown
data?: unknown
}
const roomId = computed(() => {
return typeof route.params.roomId === 'string' ? route.params.roomId : ''
})
const roomName = computed(() => {
return typeof route.query.roomName === 'string' ? route.query.roomName : ''
})
const currentUserId = computed(() => {
const user = auth.value?.user as Record<string, unknown> | undefined
const candidate = user?.id ?? user?.userID ?? user?.user_id
if (typeof candidate === 'string') {
return candidate
}
if (typeof candidate === 'number' && Number.isFinite(candidate)) {
return String(candidate)
}
return ''
})
const loggedInUserName = computed(() => {
if (!auth.value?.user) {
return ''
}
return auth.value.user.nickname ?? auth.value.user.username ?? ''
})
const roomState = activeRoomState
const isRoomFull = computed(() => {
return (
roomState.value.maxPlayers > 0 &&
roomState.value.playerCount === roomState.value.maxPlayers
)
})
const canStartGame = computed(() => {
return (
Boolean(roomState.value.id) &&
roomState.value.status === 'waiting' &&
isRoomFull.value &&
Boolean(currentUserId.value) &&
roomState.value.ownerId === currentUserId.value
)
})
const seatViews = computed(() => {
const seats: Record<SeatKey, RoomPlayerState | null> = {
top: null,
right: null,
bottom: null,
left: null,
}
const players = [...roomState.value.players].sort((a, b) => a.index - b.index)
const hasSelf = players.some((player) => player.playerId === currentUserId.value)
if (currentUserId.value && roomState.value.id && !hasSelf) {
// Fallback before WS full player list arrives: keep current player at bottom.
players.unshift({
index: 0,
playerId: currentUserId.value,
ready: false,
})
}
const me = players.find((player) => player.playerId === currentUserId.value) ?? null
const anchorIndex = me?.index ?? players[0]?.index ?? 0
const clockwiseSeatByDelta: SeatKey[] = ['bottom', 'left', 'top', 'right']
for (const player of players) {
const normalizedDelta = ((player.index - anchorIndex) % 4 + 4) % 4
const seat = clockwiseSeatByDelta[normalizedDelta] ?? 'top'
seats[seat] = player
}
const turnSeat =
roomState.value.currentTurnIndex === null
? null
: clockwiseSeatByDelta[
((roomState.value.currentTurnIndex - anchorIndex) % 4 + 4) % 4
] ?? null
const order: SeatKey[] = ['top', 'right', 'bottom', 'left']
return order.map((seat) => {
const player = seats[seat]
const isSelf = Boolean(player) && player?.playerId === currentUserId.value
return {
key: seat,
player,
isSelf,
isTurn: turnSeat === seat,
label: player ? (isSelf ? '你' : player.playerId) : '空位',
subLabel: player ? `座位 ${player.index}` : '',
}
})
})
const roomStatusText = computed(() => {
if (roomState.value.status === 'playing') {
return '对局中'
@@ -221,11 +101,12 @@ const wallBacks = computed<Record<SeatKey, string[]>>(() => {
const seatDecor = computed<Record<SeatKey, SeatPlayerCardModel>>(() => {
const scoreMap = roomState.value.game?.state?.scores ?? {}
const dealerIndex = roomState.value.game?.state?.dealerIndex ?? -1
const emptyLabel = missingSuitLabel(null)
return seatViews.value.reduce(
(acc, seat, index) => {
const playerId = seat.player?.playerId ?? ''
const score = playerId ? scoreMap[playerId] : undefined
const playerId = seat.player?.playerId ?? ''
const score = playerId ? scoreMap[playerId] : undefined
acc[seat.key] = {
avatar: seat.isSelf ? '我' : String(index + 1),
@@ -234,7 +115,7 @@ const seatDecor = computed<Record<SeatKey, SeatPlayerCardModel>>(() => {
dealer: seat.player?.index === dealerIndex,
isTurn: seat.isTurn,
isOnline: Boolean(seat.player),
missingSuitLabel: missingSuitLabel(null),
missingSuitLabel: emptyLabel,
}
return acc
@@ -247,7 +128,7 @@ const seatDecor = computed<Record<SeatKey, SeatPlayerCardModel>>(() => {
dealer: false,
isTurn: false,
isOnline: false,
missingSuitLabel: missingSuitLabel(null),
missingSuitLabel: emptyLabel,
},
right: {
avatar: '2',
@@ -256,7 +137,7 @@ const seatDecor = computed<Record<SeatKey, SeatPlayerCardModel>>(() => {
dealer: false,
isTurn: false,
isOnline: false,
missingSuitLabel: missingSuitLabel(null),
missingSuitLabel: emptyLabel,
},
bottom: {
avatar: '我',
@@ -265,7 +146,7 @@ const seatDecor = computed<Record<SeatKey, SeatPlayerCardModel>>(() => {
dealer: false,
isTurn: false,
isOnline: false,
missingSuitLabel: missingSuitLabel(null),
missingSuitLabel: emptyLabel,
},
left: {
avatar: '4',
@@ -274,7 +155,7 @@ const seatDecor = computed<Record<SeatKey, SeatPlayerCardModel>>(() => {
dealer: false,
isTurn: false,
isOnline: false,
missingSuitLabel: missingSuitLabel(null),
missingSuitLabel: emptyLabel,
},
},
)
@@ -316,626 +197,10 @@ function getBackImage(seat: SeatKey): string {
return imageMap[seat]
}
function backHall(): void {
if (leaveRoomPending.value) {
return
}
leaveHallAfterAck.value = true
const sent = sendLeaveRoom()
if (!sent) {
leaveHallAfterAck.value = false
pushWsMessage('[client] Leave room request was not sent')
}
}
function pushWsMessage(text: string): void {
const line = `[${new Date().toLocaleTimeString()}] ${text}`
wsMessages.value.unshift(line)
if (wsMessages.value.length > 80) {
wsMessages.value.length = 80
}
}
function logWsSend(message: unknown): void {
console.log('[WS][client] 发送:', message)
}
function logWsReceive(kind: string, payload?: unknown): void {
const now = new Date().toLocaleTimeString()
if (payload === undefined) {
console.log(`[WS][${now}] 收到${kind}`)
return
}
console.log(`[WS][${now}] 收到${kind}:`, payload)
}
function disconnectWs(): void {
if (ws.value) {
ws.value.close()
ws.value = null
}
wsStatus.value = 'disconnected'
}
function toRecord(value: unknown): Record<string, unknown> | null {
return typeof value === 'object' && value !== null ? (value as Record<string, unknown>) : null
}
function toStringOrEmpty(value: unknown): string {
if (typeof value === 'string') {
return value
}
if (typeof value === 'number' && Number.isFinite(value)) {
return String(value)
}
return ''
}
function toSession(source: NonNullable<typeof auth.value>): AuthSession {
return {
token: source.token,
tokenType: source.tokenType,
refreshToken: source.refreshToken,
expiresIn: source.expiresIn,
}
}
function syncAuth(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 ensureCurrentUserId(): Promise<void> {
if (currentUserId.value || !auth.value) {
return
}
try {
const userInfo = await getUserInfo(toSession(auth.value), syncAuth)
const payload = userInfo as Record<string, unknown>
const resolvedId = toStringOrEmpty(payload.userID ?? payload.user_id ?? payload.id)
if (!resolvedId) {
return
}
auth.value = {
...auth.value,
user: {
...(auth.value.user ?? {}),
id: resolvedId,
},
}
writeStoredAuth(auth.value)
} catch {
wsError.value = '获取当前用户 ID 失败,部分操作可能不可用'
}
}
async function ensureWsAuth(): Promise<string | null> {
const currentAuth = auth.value
if (!currentAuth?.token) {
return null
}
if (!currentAuth.refreshToken) {
return currentAuth.token
}
try {
const refreshed = await refreshAccessToken({
token: currentAuth.token,
tokenType: currentAuth.tokenType,
refreshToken: currentAuth.refreshToken,
})
const nextAuth = {
...currentAuth,
token: refreshed.token,
tokenType: refreshed.tokenType ?? currentAuth.tokenType,
refreshToken: refreshed.refreshToken ?? currentAuth.refreshToken,
expiresIn: refreshed.expiresIn,
user: refreshed.user ?? currentAuth.user,
}
auth.value = nextAuth
writeStoredAuth(nextAuth)
return nextAuth.token
} catch {
return currentAuth.token
}
}
function toFiniteNumber(value: unknown): number | null {
if (typeof value === 'number' && Number.isFinite(value)) {
return value
}
if (typeof value === 'string' && value.trim()) {
const parsed = Number(value)
return Number.isFinite(parsed) ? parsed : null
}
return null
}
function toBoolean(value: unknown): boolean {
if (typeof value === 'boolean') {
return value
}
if (typeof value === 'number') {
return value !== 0
}
if (typeof value === 'string') {
const normalized = value.trim().toLowerCase()
return normalized === '1' || normalized === 'true' || normalized === 'yes'
}
return false
}
function normalizeScores(value: unknown): Record<string, number> {
const record = toRecord(value)
if (!record) {
return {}
}
const scores: Record<string, number> = {}
for (const [key, score] of Object.entries(record)) {
const parsed = toFiniteNumber(score)
if (parsed !== null) {
scores[key] = parsed
}
}
return scores
}
function normalizePlayer(input: unknown, fallbackIndex: number): RoomPlayerState | null {
const player = toRecord(input)
if (!player) {
return null
}
const playerId = toStringOrEmpty(player.playerId ?? player.player_id ?? player.user_id ?? player.id)
if (!playerId) {
return null
}
const seatIndex = toFiniteNumber(player.index ?? player.seat ?? player.position ?? player.player_index)
return {
index: seatIndex ?? fallbackIndex,
playerId,
ready: Boolean(player.ready),
}
}
function extractCurrentTurnIndex(value: Record<string, unknown>): number | null {
const game = toRecord(value.game)
const gameState = toRecord(game?.state)
const keys = [
gameState?.currentTurn,
gameState?.current_turn,
gameState?.currentTurnIndex,
gameState?.current_turn_index,
value.currentTurnIndex,
value.current_turn_index,
value.currentPlayerIndex,
value.current_player_index,
value.turnIndex,
value.turn_index,
value.activePlayerIndex,
value.active_player_index,
]
for (const key of keys) {
const parsed = toFiniteNumber(key)
if (parsed !== null) {
return parsed
}
}
return null
}
function normalizeGame(input: unknown): GameState | null {
const game = toRecord(input)
if (!game) {
return null
}
const rule = toRecord(game.rule)
const rawState = toRecord(game.state)
const playersRaw =
(Array.isArray(rawState?.players) ? rawState?.players : null) ??
(Array.isArray(rawState?.playerStates) ? rawState?.playerStates : null) ??
[]
const normalizedPlayers = playersRaw
.map((item, index) => normalizePlayer(item, index))
.filter((item): item is RoomPlayerState => Boolean(item))
return {
rule: rule
? {
name: toStringOrEmpty(rule.name),
isBloodFlow: toBoolean(rule.isBloodFlow ?? rule.is_blood_flow),
hasHongZhong: toBoolean(rule.hasHongZhong ?? rule.has_hong_zhong),
}
: null,
state: rawState
? {
phase: toStringOrEmpty(rawState.phase),
dealerIndex: toFiniteNumber(rawState.dealerIndex ?? rawState.dealer_index) ?? 0,
currentTurn: toFiniteNumber(rawState.currentTurn ?? rawState.current_turn) ?? 0,
needDraw: toBoolean(rawState.needDraw ?? rawState.need_draw),
players: normalizedPlayers,
wall: Array.isArray(rawState.wall) ? rawState.wall.map((item) => toStringOrEmpty(item)).filter(Boolean) : [],
lastDiscardTile: toStringOrEmpty(rawState.lastDiscardTile ?? rawState.last_discard_tile) || null,
lastDiscardBy: toStringOrEmpty(rawState.lastDiscardBy ?? rawState.last_discard_by),
pendingClaim: toRecord(rawState.pendingClaim ?? rawState.pending_claim),
winners: Array.isArray(rawState.winners)
? rawState.winners.map((item) => toStringOrEmpty(item)).filter(Boolean)
: [],
scores: normalizeScores(rawState.scores),
lastDrawPlayerId: toStringOrEmpty(rawState.lastDrawPlayerID ?? rawState.last_draw_player_id),
lastDrawFromGang: toBoolean(rawState.lastDrawFromGang ?? rawState.last_draw_from_gang),
lastDrawIsLastTile: toBoolean(rawState.lastDrawIsLastTile ?? rawState.last_draw_is_last_tile),
huWay: toStringOrEmpty(rawState.huWay ?? rawState.hu_way),
}
: null,
}
}
function normalizeRoom(input: unknown): RoomState | null {
const source = toRecord(input)
if (!source) {
return null
}
let room = source
let id = toStringOrEmpty(room.roomId ?? room.room_id ?? room.id)
if (!id) {
const nestedRoom = toRecord(room.data)
if (nestedRoom) {
room = nestedRoom
id = toStringOrEmpty(room.roomId ?? room.room_id ?? room.id)
}
}
if (!id) {
return null
}
const maxPlayers =
toFiniteNumber(room.maxPlayers ?? room.max_players) ?? roomState.value.maxPlayers ?? DEFAULT_MAX_PLAYERS
const playersRaw =
(Array.isArray(room.players) ? room.players : null) ??
(Array.isArray(room.playerList) ? room.playerList : null) ??
(Array.isArray(room.player_list) ? room.player_list : null) ??
[]
const playerIdsRaw =
(Array.isArray(room.player_ids) ? room.player_ids : null) ??
(Array.isArray(room.playerIds) ? room.playerIds : null) ??
[]
const players = playersRaw
.map((item, index) => normalizePlayer(item, index))
.filter((item): item is RoomPlayerState => Boolean(item))
.sort((a, b) => a.index - b.index)
const playersFromIds = playerIdsRaw
.map((item, index) => ({
index,
playerId: toStringOrEmpty(item),
ready: false,
}))
.filter((item) => Boolean(item.playerId))
const resolvedPlayers = players.length > 0 ? players : playersFromIds
const parsedPlayerCount = toFiniteNumber(room.player_count ?? room.playerCount)
const game = normalizeGame(room.game)
return {
id,
name: toStringOrEmpty(room.name) || roomState.value.name,
gameType: toStringOrEmpty(room.gameType ?? room.game_type) || roomState.value.gameType || 'chengdu',
ownerId: toStringOrEmpty(room.ownerId ?? room.owner_id),
maxPlayers,
playerCount: parsedPlayerCount ?? resolvedPlayers.length,
status: toStringOrEmpty(room.status) || roomState.value.status || 'waiting',
createdAt: toStringOrEmpty(room.createdAt ?? room.created_at) || roomState.value.createdAt,
updatedAt: toStringOrEmpty(room.updatedAt ?? room.updated_at) || roomState.value.updatedAt,
game: game ?? roomState.value.game,
players: resolvedPlayers,
currentTurnIndex: extractCurrentTurnIndex(room),
}
}
function mergeRoomState(next: RoomState): void {
if (roomId.value && next.id !== roomId.value) {
return
}
mergeActiveRoomState(next)
}
function consumeGameEvent(raw: string): void {
let parsed: unknown = null
try {
parsed = JSON.parse(raw)
} catch {
return
}
const event = toRecord(parsed) as ActionEventLike | null
if (!event) {
return
}
const payload = toRecord(event.payload)
const data = toRecord(event.data)
const eventType = toStringOrEmpty(event.type)
const eventStatus = toStringOrEmpty(event.status)
const eventRoomId = toStringOrEmpty(event.roomId ?? event.room_id ?? payload?.roomId ?? payload?.room_id)
const eventRequestId = toStringOrEmpty(
event.requestId ?? event.request_id ?? payload?.requestId ?? payload?.request_id ?? data?.requestId ?? data?.request_id,
)
const payloadPlayerIds = Array.isArray(payload?.player_ids)
? payload.player_ids.map((item) => toStringOrEmpty(item)).filter(Boolean)
: Array.isArray(payload?.playerIds)
? payload.playerIds.map((item) => toStringOrEmpty(item)).filter(Boolean)
: null
const leaveByRequestIdMatched = Boolean(eventRequestId && eventRequestId === lastLeaveRoomRequestId.value)
const leaveByPlayerUpdateMatched =
leaveRoomPending.value &&
eventType === 'room_player_update' &&
eventStatus === 'ok' &&
eventRoomId === (roomState.value.id || roomId.value) &&
Array.isArray(payloadPlayerIds) &&
Boolean(currentUserId.value) &&
!payloadPlayerIds.includes(currentUserId.value)
if (leaveByRequestIdMatched || leaveByPlayerUpdateMatched) {
leaveRoomPending.value = false
lastLeaveRoomRequestId.value = ''
if (event.status === 'error') {
leaveHallAfterAck.value = false
wsError.value = '退出房间失败,请稍后重试'
pushWsMessage(`[client] 退出房间失败 requestId=${eventRequestId}`)
} else {
if (leaveByPlayerUpdateMatched) {
pushWsMessage('[client] 已确认退出房间 player_update')
} else {
pushWsMessage(`[client] 已确认退出房间 requestId=${eventRequestId}`)
}
if (leaveHallAfterAck.value) {
leaveHallAfterAck.value = false
void router.push('/hall')
}
}
}
const candidates: unknown[] = [event.payload, event.data]
if (payload) {
candidates.push(payload.room, payload.state, payload.roomState, payload.data)
}
if (data) {
candidates.push(data.room, data.state, data.roomState, data.data)
}
candidates.push(event)
for (const candidate of candidates) {
const normalized = normalizeRoom(candidate)
if (normalized) {
mergeRoomState(normalized)
break
}
}
if (
event.status === 'error' &&
typeof event.requestId === 'string' &&
event.requestId === lastStartRequestId.value
) {
startGamePending.value = false
}
}
function createRequestId(prefix: string): string {
return `${prefix}-${Date.now()}-${Math.random().toString(36).slice(2, 10)}`
}
function sendStartGame(): void {
if (!ws.value || ws.value.readyState !== WebSocket.OPEN || !canStartGame.value || startGamePending.value) {
return
}
const sender = currentUserId.value
if (!sender) {
return
}
const requestId = createRequestId('start-game')
lastStartRequestId.value = requestId
startGamePending.value = true
const message = {
type: 'start_game',
sender,
target: 'room',
roomId: roomState.value.id || roomId.value,
seq: Date.now(),
requestId,
trace_id: createRequestId('trace'),
payload: {},
}
logWsSend(message)
ws.value.send(JSON.stringify(message))
pushWsMessage(`[client] 请求开始游戏 requestId=${requestId}`)
}
function sendLeaveRoom(): boolean {
if (!ws.value || ws.value.readyState !== WebSocket.OPEN) {
wsError.value = 'WebSocket 未连接,无法退出房间'
return false
}
const sender = currentUserId.value
const targetRoomId = roomState.value.id || roomId.value
if (!sender) {
wsError.value = '缺少当前用户 ID无法退出房间'
return false
}
if (!targetRoomId) {
wsError.value = '缺少房间 ID无法退出房间'
return false
}
const requestId = createRequestId('leave-room')
leaveRoomPending.value = true
lastLeaveRoomRequestId.value = requestId
const message = {
type: 'leave_room',
sender,
target: 'room',
roomId: targetRoomId,
seq: Date.now(),
requestId,
trace_id: createRequestId('trace'),
payload: {},
}
logWsSend(message)
ws.value.send(JSON.stringify(message))
pushWsMessage(`[client] 请求退出房间 requestId=${requestId}`)
return true
}
async function connectWs(): Promise<void> {
wsError.value = ''
const token = await ensureWsAuth()
if (!token) {
wsError.value = '缺少 token无法建立 WebSocket 连接'
return
}
disconnectWs()
wsStatus.value = 'connecting'
const url = buildWsUrl(token)
const socket = new WebSocket(url)
ws.value = socket
socket.onopen = () => {
wsStatus.value = 'connected'
pushWsMessage('WebSocket 已连接')
}
socket.onmessage = (event) => {
if (typeof event.data === 'string') {
logWsReceive('文本消息', event.data)
try {
const parsed = JSON.parse(event.data)
logWsReceive('JSON 消息', parsed)
pushWsMessage(`[server] ${JSON.stringify(parsed, null, 2)}`)
} catch {
pushWsMessage(`[server] ${event.data}`)
}
consumeGameEvent(event.data)
return
}
logWsReceive('binary message')
pushWsMessage('[binary] message received')
}
socket.onerror = () => {
wsError.value = 'WebSocket 连接异常'
}
socket.onclose = () => {
wsStatus.value = 'disconnected'
startGamePending.value = false
if (leaveRoomPending.value) {
leaveRoomPending.value = false
lastLeaveRoomRequestId.value = ''
leaveHallAfterAck.value = false
wsError.value = '连接已断开,未收到退出房间确认'
pushWsMessage('[client] 连接断开,退出房间请求未确认')
}
pushWsMessage('WebSocket 已断开')
}
}
function buildWsUrl(token: string): string {
const baseUrl = /^wss?:\/\//.test(WS_BASE_URL)
? new URL(WS_BASE_URL)
: new URL(
WS_BASE_URL.startsWith('/') ? WS_BASE_URL : `/${WS_BASE_URL}`,
`${window.location.protocol === 'https:' ? 'wss:' : 'ws:'}//${window.location.host}`,
)
baseUrl.searchParams.set('token', token)
return baseUrl.toString()
}
watch(
roomId,
(nextRoomId) => {
const currentRoom = roomState.value
if (!nextRoomId) {
destroyActiveRoomState()
} else if (currentRoom.id !== nextRoomId) {
resetActiveRoomState({
id: nextRoomId,
name: roomName.value,
})
} else if (!currentRoom.name && roomName.value) {
roomState.value = { ...currentRoom, name: roomName.value }
}
startGamePending.value = false
lastStartRequestId.value = ''
leaveRoomPending.value = false
lastLeaveRoomRequestId.value = ''
leaveHallAfterAck.value = false
},
{ immediate: true },
)
watch(roomName, (next) => {
roomState.value = { ...roomState.value, name: next || roomState.value.name }
})
watch(
[canStartGame, wsStatus],
([canStart, status]) => {
if (!canStart || status !== 'connected') {
return
}
sendStartGame()
},
{ immediate: true },
)
watch(
() => roomState.value.status,
(status) => {
if (status === 'playing' || status === 'finished') {
startGamePending.value = false
}
},
)
onMounted(async () => {
onMounted(() => {
clockTimer = window.setInterval(() => {
now.value = Date.now()
}, 1000)
await ensureCurrentUserId()
connectWs()
})
onBeforeUnmount(() => {
@@ -943,8 +208,6 @@ onBeforeUnmount(() => {
window.clearInterval(clockTimer)
clockTimer = null
}
disconnectWs()
destroyActiveRoomState()
})
</script>
@@ -978,7 +241,7 @@ onBeforeUnmount(() => {
</div>
</header>
<section class="table-panel game-table-panel">
<section class="table-panel game-table-panel">
<div class="room-brief">
<span class="room-brief-title">当前房间</span>
<span class="room-brief-item">
@@ -1006,7 +269,7 @@ onBeforeUnmount(() => {
{{ startGamePending ? '开局请求中...' : '开始游戏' }}
</button>
</div>
</section>
</section>
<section class="table-shell">
<img class="table-desk" :src="deskImage" alt="" />