```
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:
830
src/features/chengdu-game/useChengduGameRoom.ts
Normal file
830
src/features/chengdu-game/useChengduGameRoom.ts
Normal 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,
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -1,5 +1,5 @@
|
|||||||
<script setup lang="ts">
|
<script setup lang="ts">
|
||||||
import { computed, onBeforeUnmount, onMounted, ref, watch } from 'vue'
|
import { computed, onBeforeUnmount, onMounted, ref } from 'vue'
|
||||||
import { useRoute, useRouter } from 'vue-router'
|
import { useRoute, useRouter } from 'vue-router'
|
||||||
import deskImage from '../assets/images/desk/desk_01.png'
|
import deskImage from '../assets/images/desk/desk_01.png'
|
||||||
import topBackImage from '../assets/images/tiles/top/tbgs_2.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 RightPlayerCard from '../components/game/RightPlayerCard.vue'
|
||||||
import BottomPlayerCard from '../components/game/BottomPlayerCard.vue'
|
import BottomPlayerCard from '../components/game/BottomPlayerCard.vue'
|
||||||
import LeftPlayerCard from '../components/game/LeftPlayerCard.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 type { SeatPlayerCardModel } from '../components/game/seat-player-card'
|
||||||
import {
|
import { useChengduGameRoom, type SeatKey } from '../features/chengdu-game/useChengduGameRoom'
|
||||||
DEFAULT_MAX_PLAYERS,
|
|
||||||
activeRoomState,
|
|
||||||
destroyActiveRoomState,
|
|
||||||
mergeActiveRoomState,
|
|
||||||
resetActiveRoomState,
|
|
||||||
type GameState,
|
|
||||||
type RoomPlayerState,
|
|
||||||
type RoomState,
|
|
||||||
} from '../state/active-room'
|
|
||||||
import { readStoredAuth, writeStoredAuth } from '../utils/auth-storage'
|
|
||||||
|
|
||||||
const router = useRouter()
|
|
||||||
const route = useRoute()
|
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())
|
const now = ref(Date.now())
|
||||||
let clockTimer: number | null = null
|
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(() => {
|
const roomStatusText = computed(() => {
|
||||||
if (roomState.value.status === 'playing') {
|
if (roomState.value.status === 'playing') {
|
||||||
return '对局中'
|
return '对局中'
|
||||||
@@ -221,6 +101,7 @@ const wallBacks = computed<Record<SeatKey, string[]>>(() => {
|
|||||||
const seatDecor = computed<Record<SeatKey, SeatPlayerCardModel>>(() => {
|
const seatDecor = computed<Record<SeatKey, SeatPlayerCardModel>>(() => {
|
||||||
const scoreMap = roomState.value.game?.state?.scores ?? {}
|
const scoreMap = roomState.value.game?.state?.scores ?? {}
|
||||||
const dealerIndex = roomState.value.game?.state?.dealerIndex ?? -1
|
const dealerIndex = roomState.value.game?.state?.dealerIndex ?? -1
|
||||||
|
const emptyLabel = missingSuitLabel(null)
|
||||||
|
|
||||||
return seatViews.value.reduce(
|
return seatViews.value.reduce(
|
||||||
(acc, seat, index) => {
|
(acc, seat, index) => {
|
||||||
@@ -234,7 +115,7 @@ const seatDecor = computed<Record<SeatKey, SeatPlayerCardModel>>(() => {
|
|||||||
dealer: seat.player?.index === dealerIndex,
|
dealer: seat.player?.index === dealerIndex,
|
||||||
isTurn: seat.isTurn,
|
isTurn: seat.isTurn,
|
||||||
isOnline: Boolean(seat.player),
|
isOnline: Boolean(seat.player),
|
||||||
missingSuitLabel: missingSuitLabel(null),
|
missingSuitLabel: emptyLabel,
|
||||||
}
|
}
|
||||||
|
|
||||||
return acc
|
return acc
|
||||||
@@ -247,7 +128,7 @@ const seatDecor = computed<Record<SeatKey, SeatPlayerCardModel>>(() => {
|
|||||||
dealer: false,
|
dealer: false,
|
||||||
isTurn: false,
|
isTurn: false,
|
||||||
isOnline: false,
|
isOnline: false,
|
||||||
missingSuitLabel: missingSuitLabel(null),
|
missingSuitLabel: emptyLabel,
|
||||||
},
|
},
|
||||||
right: {
|
right: {
|
||||||
avatar: '2',
|
avatar: '2',
|
||||||
@@ -256,7 +137,7 @@ const seatDecor = computed<Record<SeatKey, SeatPlayerCardModel>>(() => {
|
|||||||
dealer: false,
|
dealer: false,
|
||||||
isTurn: false,
|
isTurn: false,
|
||||||
isOnline: false,
|
isOnline: false,
|
||||||
missingSuitLabel: missingSuitLabel(null),
|
missingSuitLabel: emptyLabel,
|
||||||
},
|
},
|
||||||
bottom: {
|
bottom: {
|
||||||
avatar: '我',
|
avatar: '我',
|
||||||
@@ -265,7 +146,7 @@ const seatDecor = computed<Record<SeatKey, SeatPlayerCardModel>>(() => {
|
|||||||
dealer: false,
|
dealer: false,
|
||||||
isTurn: false,
|
isTurn: false,
|
||||||
isOnline: false,
|
isOnline: false,
|
||||||
missingSuitLabel: missingSuitLabel(null),
|
missingSuitLabel: emptyLabel,
|
||||||
},
|
},
|
||||||
left: {
|
left: {
|
||||||
avatar: '4',
|
avatar: '4',
|
||||||
@@ -274,7 +155,7 @@ const seatDecor = computed<Record<SeatKey, SeatPlayerCardModel>>(() => {
|
|||||||
dealer: false,
|
dealer: false,
|
||||||
isTurn: false,
|
isTurn: false,
|
||||||
isOnline: false,
|
isOnline: false,
|
||||||
missingSuitLabel: missingSuitLabel(null),
|
missingSuitLabel: emptyLabel,
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
)
|
)
|
||||||
@@ -316,626 +197,10 @@ function getBackImage(seat: SeatKey): string {
|
|||||||
return imageMap[seat]
|
return imageMap[seat]
|
||||||
}
|
}
|
||||||
|
|
||||||
function backHall(): void {
|
onMounted(() => {
|
||||||
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 () => {
|
|
||||||
clockTimer = window.setInterval(() => {
|
clockTimer = window.setInterval(() => {
|
||||||
now.value = Date.now()
|
now.value = Date.now()
|
||||||
}, 1000)
|
}, 1000)
|
||||||
await ensureCurrentUserId()
|
|
||||||
connectWs()
|
|
||||||
})
|
})
|
||||||
|
|
||||||
onBeforeUnmount(() => {
|
onBeforeUnmount(() => {
|
||||||
@@ -943,8 +208,6 @@ onBeforeUnmount(() => {
|
|||||||
window.clearInterval(clockTimer)
|
window.clearInterval(clockTimer)
|
||||||
clockTimer = null
|
clockTimer = null
|
||||||
}
|
}
|
||||||
disconnectWs()
|
|
||||||
destroyActiveRoomState()
|
|
||||||
})
|
})
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
|
|||||||
Reference in New Issue
Block a user