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 } interface PendingClaimOption { playerId: string actions: string[] } export interface ActionButtonState { type: 'draw' | 'discard' | 'peng' | 'gang' | 'hu' | 'pass' label: string disabled: boolean } export interface SeatView { key: SeatKey player: RoomPlayerState | null isSelf: boolean isTurn: boolean label: string subLabel: string } function humanizeSuit(value: string): string { const suitMap: Record = { W: '万', B: '筒', T: '条', } return suitMap[value] ?? value } export interface ChengduGameRoomModel { auth: Ref roomState: typeof activeRoomState roomId: ComputedRef roomName: ComputedRef currentUserId: ComputedRef loggedInUserName: ComputedRef wsStatus: Ref<'disconnected' | 'connecting' | 'connected'> wsError: Ref wsMessages: Ref startGamePending: Ref leaveRoomPending: Ref canStartGame: ComputedRef seatViews: ComputedRef selectedTile: Ref actionButtons: ComputedRef connectWs: () => Promise sendStartGame: () => void selectTile: (tile: string) => void sendGameAction: (type: ActionButtonState['type']) => 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(null) const wsStatus = ref<'disconnected' | 'connecting' | 'connected'>('disconnected') const wsError = ref('') const wsMessages = ref([]) const startGamePending = ref(false) const actionPending = ref(false) const lastStartRequestId = ref('') const leaveRoomPending = ref(false) const lastLeaveRoomRequestId = ref('') const leaveHallAfterAck = ref(false) const selectedTile = ref(null) 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 | 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 myPlayer = computed(() => { return roomState.value.players.find((player) => player.playerId === currentUserId.value) ?? null }) const isMyTurn = computed(() => { return myPlayer.value?.index === roomState.value.currentTurnIndex }) const pendingClaimOptions = computed(() => { return normalizePendingClaimOptions(roomState.value.game?.state?.pendingClaim) }) const myClaimActions = computed(() => { const claim = pendingClaimOptions.value.find((item) => item.playerId === currentUserId.value) return new Set(claim?.actions ?? []) }) const canDraw = computed(() => { return roomState.value.status === 'playing' && isMyTurn.value && Boolean(roomState.value.game?.state?.needDraw) }) const canDiscard = computed(() => { return ( roomState.value.status === 'playing' && isMyTurn.value && !roomState.value.game?.state?.needDraw && roomState.value.myHand.length > 0 && Boolean(selectedTile.value) ) }) const actionButtons = computed(() => { return [ { type: 'draw', label: '摸牌', disabled: !canDraw.value || actionPending.value }, { type: 'discard', label: '出牌', disabled: !canDiscard.value || actionPending.value }, { type: 'peng', label: '碰', disabled: !myClaimActions.value.has('peng') || actionPending.value, }, { type: 'gang', label: '杠', disabled: !myClaimActions.value.has('gang') || actionPending.value, }, { type: 'hu', label: '胡', disabled: !myClaimActions.value.has('hu') || actionPending.value, }, { type: 'pass', label: '过', disabled: !myClaimActions.value.has('pass') || actionPending.value, }, ] }) const seatViews = computed(() => { const seats: Record = { 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.displayName || `玩家${player.index + 1}`) : '空位', subLabel: player ? `座位 ${player.index}` : '', } }) }) function backHall(): void { if (leaveRoomPending.value) { return } const sent = sendLeaveRoom() if (!sent) { pushWsMessage('[client] Leave room request was not sent') } leaveHallAfterAck.value = false disconnectWs() destroyActiveRoomState() void router.push('/hall') } 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 | null { return typeof value === 'object' && value !== null ? (value as Record) : null } function toStringOrEmpty(value: unknown): string { if (typeof value === 'string') { return value } if (typeof value === 'number' && Number.isFinite(value)) { return String(value) } return '' } function normalizeActionName(value: unknown): string { const raw = toStringOrEmpty(value).trim().toLowerCase() if (!raw) { return '' } const actionMap: Record = { candraw: 'draw', draw: 'draw', candiscard: 'discard', discard: 'discard', canpeng: 'peng', peng: 'peng', cangang: 'gang', gang: 'gang', canhu: 'hu', hu: 'hu', canpass: 'pass', pass: 'pass', } return actionMap[raw.replace(/[_\-\s]/g, '')] ?? raw } function toSession(source: NonNullable): 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 { if (currentUserId.value || !auth.value) { return } try { const userInfo = await getUserInfo(toSession(auth.value), syncAuth) const payload = userInfo as Record 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 { 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 { const record = toRecord(value) if (!record) { return {} } const scores: Record = {} 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.PlayerID ?? player.UserID ?? player.user_id ?? player.id, ) if (!playerId) { return null } const seatIndex = toFiniteNumber( player.index ?? player.Index ?? player.seat ?? player.Seat ?? player.position ?? player.Position ?? player.player_index, ) return { index: seatIndex ?? fallbackIndex, playerId, displayName: toStringOrEmpty( player.playerName ?? player.player_name ?? player.PlayerName ?? player.username ?? player.nickname, ) || undefined, ready: Boolean(player.ready), handCount: toFiniteNumber(player.handCount ?? player.hand_count ?? player.HandCount) ?? undefined, melds: normalizeTileList(player.melds ?? player.Melds), outTiles: normalizeTileList(player.outTiles ?? player.out_tiles ?? player.OutTiles), hasHu: toBoolean(player.hasHu ?? player.has_hu ?? player.HasHu), missingSuit: toStringOrEmpty(player.missingSuit ?? player.missing_suit ?? player.MissingSuit) || null, } } function normalizeTileList(value: unknown): string[] { if (!Array.isArray(value)) { return [] } return value .flatMap((item) => { if (Array.isArray(item)) { return item.map((nested) => toStringOrEmpty(nested)).filter(Boolean) } const record = toRecord(item) if (record) { const explicit = toStringOrEmpty( record.tile ?? record.Tile ?? record.code ?? record.Code ?? record.name ?? record.Name, ) || '' if (explicit) { return [explicit] } const suit = toStringOrEmpty(record.suit ?? record.Suit) const tileValue = toStringOrEmpty(record.value ?? record.Value) if (suit && tileValue) { return [`${humanizeSuit(suit)}${tileValue}`] } return [] } return [toStringOrEmpty(item)].filter(Boolean) }) .filter(Boolean) } function normalizePublicGameState(source: Record): GameState | null { const publicPlayers = (Array.isArray(source.players) ? source.players : []) .map((item, index) => normalizePlayer(item, index)) .filter((item): item is RoomPlayerState => Boolean(item)) .sort((a, b) => a.index - b.index) const currentTurnPlayerId = toStringOrEmpty( source.current_turn_player ?? source.currentTurnPlayer ?? source.current_turn_player_id, ) const currentTurnIndex = publicPlayers.find((player) => player.playerId === currentTurnPlayerId)?.index ?? null return { rule: null, state: { phase: toStringOrEmpty(source.phase), dealerIndex: 0, currentTurn: currentTurnIndex ?? 0, needDraw: toBoolean(source.need_draw ?? source.needDraw), players: publicPlayers.map((player) => ({ playerId: player.playerId, index: player.index, ready: player.ready, })), wall: Array.from({ length: toFiniteNumber(source.wall_count ?? source.wallCount) ?? 0, }).map((_, index) => `wall-${index}`), lastDiscardTile: toStringOrEmpty(source.last_discard_tile ?? source.lastDiscardTile) || null, lastDiscardBy: toStringOrEmpty(source.last_discard_by ?? source.lastDiscardBy), pendingClaim: toRecord(source.pending_claim ?? source.pendingClaim), winners: Array.isArray(source.winners) ? source.winners.map((item) => toStringOrEmpty(item)).filter(Boolean) : [], scores: normalizeScores(source.scores), lastDrawPlayerId: '', lastDrawFromGang: false, lastDrawIsLastTile: false, huWay: '', }, } } function normalizePendingClaimOptions(value: unknown): PendingClaimOption[] { const pendingClaim = toRecord(value) if (!pendingClaim) { return [] } const rawOptions = (Array.isArray(pendingClaim.options) ? pendingClaim.options : null) ?? (Array.isArray(pendingClaim.Options) ? pendingClaim.Options : null) ?? [] const optionsFromArray = rawOptions .map((option) => { const record = toRecord(option) if (!record) { return null } const playerId = toStringOrEmpty( record.playerId ?? record.player_id ?? record.PlayerID ?? record.user_id ?? record.UserID, ) if (!playerId) { return null } const actions = new Set() for (const value of Object.values(record)) { if (typeof value === 'boolean' && value) { continue } } for (const [key, enabled] of Object.entries(record)) { if (typeof enabled === 'boolean' && enabled) { const normalized = normalizeActionName(key) if (normalized && normalized !== 'playerid' && normalized !== 'userid') { actions.add(normalized) } } } if (Array.isArray(record.actions)) { for (const action of record.actions) { const normalized = normalizeActionName(action) if (normalized) { actions.add(normalized) } } } return { playerId, actions: [...actions] } }) .filter((item): item is PendingClaimOption => Boolean(item)) if (optionsFromArray.length > 0) { return optionsFromArray } const claimPlayerId = toStringOrEmpty( pendingClaim.playerId ?? pendingClaim.player_id ?? pendingClaim.PlayerID ?? pendingClaim.user_id ?? pendingClaim.UserID, ) if (!claimPlayerId) { return [] } const actions = Object.entries(pendingClaim) .filter(([, enabled]) => typeof enabled === 'boolean' && enabled) .map(([key]) => normalizeActionName(key)) .filter(Boolean) return actions.length > 0 ? [{ playerId: claimPlayerId, actions }] : [] } function extractCurrentTurnIndex(value: Record): 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) ?? normalizePublicGameState(room) const playersFromGame = game?.state?.players .map((player, index) => normalizePlayer( { player_id: player.playerId, index: player.index ?? index, }, index, ), ) .filter((item): item is RoomPlayerState => Boolean(item)) const finalPlayers = resolvedPlayers.length > 0 ? resolvedPlayers : playersFromGame && playersFromGame.length > 0 ? playersFromGame : [] const derivedTurnIndex = extractCurrentTurnIndex(room) ?? (game?.state ? finalPlayers.find((player) => player.playerId === toStringOrEmpty(room.current_turn_player ?? room.currentTurnPlayer)) ?.index ?? null : null) 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 ?? room.OwnerID ?? room.ownerID), maxPlayers, playerCount: parsedPlayerCount ?? toFiniteNumber(room.player_count ?? room.playerCount ?? room.playerCount) ?? finalPlayers.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: finalPlayers, currentTurnIndex: derivedTurnIndex, myHand: [], } } 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 } if (event.status === 'error') { actionPending.value = false } if (eventType === 'my_hand') { const handPayload = payload ?? data const handRecord = toRecord(handPayload) const hand = normalizeTileList(handRecord?.hand ?? handRecord?.tiles ?? handRecord?.myHand) roomState.value = { ...roomState.value, myHand: hand, playerCount: roomState.value.playerCount || roomState.value.players.length, } if (!selectedTile.value || !hand.includes(selectedTile.value)) { selectedTile.value = hand[0] ?? null } actionPending.value = false return } if ( ['room_state', 'room_player_update', 'room_joined', 'room_member_joined', 'room_member_left'].includes( eventType, ) ) { actionPending.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 selectTile(tile: string): void { selectedTile.value = selectedTile.value === tile ? null : tile } function sendGameAction(type: ActionButtonState['type']): void { if (!ws.value || ws.value.readyState !== WebSocket.OPEN || !currentUserId.value) { return } const requestId = createRequestId(type) const payload: Record = {} if (type === 'discard' && selectedTile.value) { payload.tile = selectedTile.value payload.discard_tile = selectedTile.value payload.code = selectedTile.value } actionPending.value = true const message = { type, sender: currentUserId.value, 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] 请求${type} 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 { 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 actionPending.value = false selectedTile.value = null }, { 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, selectedTile, actionButtons, connectWs, sendStartGame, selectTile, sendGameAction, backHall, } }