Files
mahjong-web/src/features/chengdu-game/useChengduGameRoom.ts
wsy182 716bc2b106 style(game): 优化成都麻将游戏页面样式和代码结构
- 将CSS样式提取到独立的room.css文件中
- 移除组件中的内联样式定义
- 调整了顶部工具栏位置参数
- 更新了计数器指示灯的样式
- 精简了导入语句的空格格式
- 移除了调试用的编辑按钮
- 更新游戏标题为"指尖四川麻将"
- 移除了冗余的import类型声明顺序
- 优化了数组创建语法格式
- 统一了图片标签的alt属性格式
- 调整了循环渲染元素的缩进格式
2026-03-24 17:01:02 +08:00

1213 lines
35 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
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<string, string> = {
W: '万',
B: '筒',
T: '条',
}
return suitMap[value] ?? value
}
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[]>
selectedTile: Ref<string | null>
actionButtons: ComputedRef<ActionButtonState[]>
connectWs: () => Promise<void>
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<WebSocket | null>(null)
const wsStatus = ref<'disconnected' | 'connecting' | 'connected'>('disconnected')
const wsError = ref('')
const wsMessages = ref<string[]>([])
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<string | null>(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<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 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<PendingClaimOption[]>(() => {
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<ActionButtonState[]>(() => {
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<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.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<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 normalizeActionName(value: unknown): string {
const raw = toStringOrEmpty(value).trim().toLowerCase()
if (!raw) {
return ''
}
const actionMap: Record<string, string> = {
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<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.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<string, unknown>): 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<string>()
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<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) ?? 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<string, unknown> = {}
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<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
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,
}
}