- 将CSS样式提取到独立的room.css文件中 - 移除组件中的内联样式定义 - 调整了顶部工具栏位置参数 - 更新了计数器指示灯的样式 - 精简了导入语句的空格格式 - 移除了调试用的编辑按钮 - 更新游戏标题为"指尖四川麻将" - 移除了冗余的import类型声明顺序 - 优化了数组创建语法格式 - 统一了图片标签的alt属性格式 - 调整了循环渲染元素的缩进格式
1213 lines
35 KiB
TypeScript
1213 lines
35 KiB
TypeScript
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,
|
||
}
|
||
}
|