From 1b15748d0d4db9eba7607809661557e35e6219f8 Mon Sep 17 00:00:00 2001 From: wsy182 <2392948297@qq.com> Date: Tue, 24 Mar 2026 14:12:04 +0800 Subject: [PATCH] ``` 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 ``` --- .../chengdu-game/useChengduGameRoom.ts | 830 ++++++++++++++++++ src/views/ChengduGamePage.vue | 799 +---------------- 2 files changed, 861 insertions(+), 768 deletions(-) create mode 100644 src/features/chengdu-game/useChengduGameRoom.ts diff --git a/src/features/chengdu-game/useChengduGameRoom.ts b/src/features/chengdu-game/useChengduGameRoom.ts new file mode 100644 index 0000000..cb903e4 --- /dev/null +++ b/src/features/chengdu-game/useChengduGameRoom.ts @@ -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 + 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 + connectWs: () => Promise + 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(null) + const wsStatus = ref<'disconnected' | 'connecting' | 'connected'>('disconnected') + const wsError = ref('') + const wsMessages = ref([]) + 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 | 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 = { + 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 | 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 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.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): 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 { + 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, + } +} diff --git a/src/views/ChengduGamePage.vue b/src/views/ChengduGamePage.vue index 1da2b6d..db65540 100644 --- a/src/views/ChengduGamePage.vue +++ b/src/views/ChengduGamePage.vue @@ -1,5 +1,5 @@ - @@ -978,7 +241,7 @@ onBeforeUnmount(() => { -
+
当前房间 @@ -1006,7 +269,7 @@ onBeforeUnmount(() => { {{ startGamePending ? '开局请求中...' : '开始游戏' }}
-
+