refactor(game): 重构游戏动作处理和WebSocket连接管理
- 重构sendGameAction函数参数结构,添加上下文支持 - 新增sendStartGame和sendLeaveRoom函数统一处理游戏开始和离开房间逻辑 - 移除路由相关依赖,简化ChengduGamePage组件 - 更新WebSocket客户端实现,添加状态变化订阅功能 - 移除requestId生成函数和相关参数,精简消息结构 - 优化座位玩家卡片数据模型,移除在线状态和金钱字段 - 整理游戏阶段常量定义,添加标签映射 - 移除过期的游戏状态字段如needDraw、lastDiscardTile等 - 添加座位类型定义和改进游戏类型文件组织结构
This commit is contained in:
@@ -1,4 +0,0 @@
|
|||||||
// 生成 requestId / traceId
|
|
||||||
export function createRequestId(prefix: string): string {
|
|
||||||
return `${prefix}-${Date.now()}-${Math.random().toString(36).slice(2, 10)}`
|
|
||||||
}
|
|
||||||
@@ -27,7 +27,7 @@ const missingSuitIcon = computed(() => {
|
|||||||
<template>
|
<template>
|
||||||
<article
|
<article
|
||||||
class="player-badge"
|
class="player-badge"
|
||||||
:class="[seatClass, { 'is-turn': player.isTurn, offline: !player.isOnline }]"
|
:class="[seatClass, { 'is-turn': player.isTurn }]"
|
||||||
>
|
>
|
||||||
<div class="avatar-panel">
|
<div class="avatar-panel">
|
||||||
<div class="avatar-card">{{ player.avatar }}</div>
|
<div class="avatar-card">{{ player.avatar }}</div>
|
||||||
@@ -36,7 +36,6 @@ const missingSuitIcon = computed(() => {
|
|||||||
|
|
||||||
<div class="player-meta">
|
<div class="player-meta">
|
||||||
<p>{{ player.name }}</p>
|
<p>{{ player.name }}</p>
|
||||||
<strong>{{ player.money }}</strong>
|
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div class="missing-mark">
|
<div class="missing-mark">
|
||||||
|
|||||||
@@ -1,9 +1,8 @@
|
|||||||
|
// 玩家卡片展示模型(用于座位UI渲染)
|
||||||
export interface SeatPlayerCardModel {
|
export interface SeatPlayerCardModel {
|
||||||
avatar: string
|
avatar: string // 头像
|
||||||
name: string
|
name: string // 显示名称
|
||||||
money: string
|
dealer: boolean // 是否庄家
|
||||||
dealer: boolean
|
isTurn: boolean // 是否当前轮到该玩家
|
||||||
isTurn: boolean
|
missingSuitLabel: string // 定缺花色(万/筒/条)
|
||||||
isOnline: boolean
|
|
||||||
missingSuitLabel: string
|
|
||||||
}
|
}
|
||||||
@@ -1,37 +1,145 @@
|
|||||||
import type { ActionButtonState } from "./types.ts"
|
import type {ActionButtonState} from "./types.ts"
|
||||||
import { wsClient } from "@/ws/client" // 新增
|
import {wsClient} from "../ws/client.ts"
|
||||||
|
|
||||||
export function sendGameAction(type: ActionButtonState['type']): void {
|
|
||||||
// 原来是判断 ws,这里改成用 wsClient 的状态(简单处理)
|
export function sendGameAction(
|
||||||
if (!currentUserId.value) {
|
params: {
|
||||||
|
type: ActionButtonState['type']
|
||||||
|
userID: string
|
||||||
|
roomId: string
|
||||||
|
selectedTile?: string | null
|
||||||
|
},
|
||||||
|
ctx: {
|
||||||
|
actionPending: { value: boolean }
|
||||||
|
logWsSend: (msg: any) => void
|
||||||
|
pushWsMessage: (msg: string) => void
|
||||||
|
}
|
||||||
|
): void {
|
||||||
|
|
||||||
|
const {type, userID, roomId, selectedTile} = params
|
||||||
|
const {actionPending, logWsSend, pushWsMessage} = ctx
|
||||||
|
|
||||||
|
// 简单登录判断
|
||||||
|
if (!userID) {
|
||||||
|
console.log('当前用户未登录')
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
const requestId = createRequestId(type)
|
|
||||||
const payload: Record<string, unknown> = {}
|
const payload: Record<string, unknown> = {}
|
||||||
|
|
||||||
if (type === 'discard' && selectedTile.value) {
|
// 出牌
|
||||||
payload.tile = selectedTile.value
|
if (type === 'discard' && selectedTile) {
|
||||||
payload.discard_tile = selectedTile.value
|
payload.tile = selectedTile
|
||||||
payload.code = selectedTile.value
|
payload.discard_tile = selectedTile
|
||||||
|
payload.code = selectedTile
|
||||||
}
|
}
|
||||||
|
|
||||||
actionPending.value = true
|
actionPending.value = true
|
||||||
|
|
||||||
const message = {
|
const message = {
|
||||||
type,
|
type,
|
||||||
sender: currentUserId.value,
|
sender: userID,
|
||||||
target: 'room',
|
target: 'room',
|
||||||
roomId: roomState.value.id || roomId.value,
|
roomId,
|
||||||
seq: Date.now(),
|
seq: Date.now(),
|
||||||
requestId,
|
|
||||||
trace_id: createRequestId('trace'),
|
|
||||||
payload,
|
payload,
|
||||||
}
|
}
|
||||||
|
|
||||||
logWsSend(message)
|
logWsSend(message)
|
||||||
|
wsClient.send(message)
|
||||||
wsClient.send(message) // ✅ 改这里
|
pushWsMessage(`[client] 请求${type}`)
|
||||||
|
}
|
||||||
pushWsMessage(`[client] 请求${type} requestId=${requestId}`)
|
|
||||||
|
|
||||||
|
export function sendStartGame(params: {
|
||||||
|
userID: string
|
||||||
|
roomId: string
|
||||||
|
canStartGame: boolean
|
||||||
|
startGamePending: { value: boolean }
|
||||||
|
logWsSend: (msg: any) => void
|
||||||
|
pushWsMessage: (msg: string) => void
|
||||||
|
}): void {
|
||||||
|
|
||||||
|
const {
|
||||||
|
userID,
|
||||||
|
roomId,
|
||||||
|
canStartGame,
|
||||||
|
startGamePending,
|
||||||
|
logWsSend,
|
||||||
|
pushWsMessage
|
||||||
|
} = params
|
||||||
|
|
||||||
|
if (!canStartGame || startGamePending.value) {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!userID) {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
startGamePending.value = true
|
||||||
|
|
||||||
|
const message = {
|
||||||
|
type: 'start_game',
|
||||||
|
sender: userID,
|
||||||
|
target: 'room',
|
||||||
|
roomId,
|
||||||
|
seq: Date.now(),
|
||||||
|
|
||||||
|
payload: {},
|
||||||
|
}
|
||||||
|
|
||||||
|
logWsSend(message)
|
||||||
|
wsClient.send(message)
|
||||||
|
|
||||||
|
pushWsMessage(`[client] 请求开始游戏`)
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
export function sendLeaveRoom(params: {
|
||||||
|
userID: string
|
||||||
|
roomId: string
|
||||||
|
wsError: { value: string }
|
||||||
|
leaveRoomPending: { value: boolean }
|
||||||
|
logWsSend: (msg: any) => void
|
||||||
|
pushWsMessage: (msg: string) => void
|
||||||
|
}): boolean {
|
||||||
|
|
||||||
|
const {
|
||||||
|
userID,
|
||||||
|
roomId,
|
||||||
|
wsError,
|
||||||
|
leaveRoomPending,
|
||||||
|
logWsSend,
|
||||||
|
pushWsMessage
|
||||||
|
} = params
|
||||||
|
|
||||||
|
if (!userID) {
|
||||||
|
wsError.value = '缺少当前用户 ID,无法退出房间'
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!roomId) {
|
||||||
|
wsError.value = '缺少房间 ID,无法退出房间'
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
|
||||||
|
leaveRoomPending.value = true
|
||||||
|
|
||||||
|
const message = {
|
||||||
|
type: 'leave_room',
|
||||||
|
sender: userID,
|
||||||
|
target: 'room',
|
||||||
|
roomId: roomId,
|
||||||
|
seq: Date.now(),
|
||||||
|
payload: {},
|
||||||
|
}
|
||||||
|
|
||||||
|
logWsSend(message)
|
||||||
|
|
||||||
|
wsClient.send(message)
|
||||||
|
|
||||||
|
pushWsMessage(`[client] 请求退出房间`)
|
||||||
|
|
||||||
|
return true
|
||||||
}
|
}
|
||||||
2
src/game/seat.ts
Normal file
2
src/game/seat.ts
Normal file
@@ -0,0 +1,2 @@
|
|||||||
|
export type SeatKey = 'top' | 'right' | 'bottom' | 'left'
|
||||||
|
|
||||||
@@ -1,5 +1,4 @@
|
|||||||
// 游戏对象
|
// 游戏对象
|
||||||
|
|
||||||
import type {GameState} from "../../types/state";
|
import type {GameState} from "../../types/state";
|
||||||
|
|
||||||
export interface Game {
|
export interface Game {
|
||||||
@@ -1,726 +0,0 @@
|
|||||||
import { computed, onBeforeUnmount, onMounted, ref, watch } from 'vue'
|
|
||||||
import type { RouteLocationNormalizedLoaded, Router } from 'vue-router'
|
|
||||||
import type { AuthSession } from '../api/authed-request.ts'
|
|
||||||
import { refreshAccessToken } from '../api/auth.ts'
|
|
||||||
import { getUserInfo } from '../api/user.ts'
|
|
||||||
import {
|
|
||||||
activeRoomState,
|
|
||||||
destroyActiveRoomState,
|
|
||||||
mergeActiveRoomState,
|
|
||||||
resetActiveRoomState,
|
|
||||||
type RoomPlayerState,
|
|
||||||
type RoomState,
|
|
||||||
} from '../../store/active-room-store'
|
|
||||||
import { readStoredAuth, writeStoredAuth } from '../utils/auth-storage.ts'
|
|
||||||
import type {
|
|
||||||
ActionButtonState,
|
|
||||||
ActionEventLike,
|
|
||||||
ChengduGameRoomModel,
|
|
||||||
PendingClaimOption,
|
|
||||||
SeatKey,
|
|
||||||
SeatView,
|
|
||||||
} from './types'
|
|
||||||
import { toRecord, toStringOrEmpty } from './parser-utils'
|
|
||||||
import { normalizePendingClaimOptions, normalizeRoom, normalizeTileList } from './room-normalizers'
|
|
||||||
|
|
||||||
export type {
|
|
||||||
ActionButtonState,
|
|
||||||
ChengduGameRoomModel,
|
|
||||||
SeatKey,
|
|
||||||
SeatView,
|
|
||||||
} from './types'
|
|
||||||
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,
|
|
||||||
hand: [],
|
|
||||||
melds: [],
|
|
||||||
outTiles: [],
|
|
||||||
hasHu: 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 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 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, roomState.value)
|
|
||||||
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,
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
@@ -1,9 +1,19 @@
|
|||||||
|
// 游戏阶段常量定义(用于标识当前对局所处阶段)
|
||||||
export const GAME_PHASE = {
|
export const GAME_PHASE = {
|
||||||
WAITING: 'waiting',
|
WAITING: 'waiting', // 等待玩家准备 / 开始
|
||||||
DEALING: 'dealing',
|
DEALING: 'dealing', // 发牌阶段
|
||||||
PLAYING: 'playing',
|
PLAYING: 'playing', // 对局进行中
|
||||||
SETTLEMENT: 'settlement',
|
SETTLEMENT: 'settlement', // 结算阶段
|
||||||
} as const
|
} as const
|
||||||
|
|
||||||
|
// 游戏阶段类型(取自 GAME_PHASE 的值)
|
||||||
export type GamePhase =
|
export type GamePhase =
|
||||||
typeof GAME_PHASE[keyof typeof GAME_PHASE]
|
typeof GAME_PHASE[keyof typeof GAME_PHASE]
|
||||||
|
|
||||||
|
|
||||||
|
export const GAME_PHASE_LABEL: Record<GamePhase, string> = {
|
||||||
|
waiting: '等待中',
|
||||||
|
dealing: '发牌',
|
||||||
|
playing: '对局中',
|
||||||
|
settlement: '结算',
|
||||||
|
}
|
||||||
@@ -1,7 +1,5 @@
|
|||||||
import type {Player} from "./player.ts";
|
import type {Player} from "./player.ts";
|
||||||
import type {Tile} from "../../models";
|
|
||||||
import type {PendingClaim} from "./pendingClaim.ts";
|
import type {PendingClaim} from "./pendingClaim.ts";
|
||||||
import type {HuWay} from "./huWay.ts";
|
|
||||||
import type {GamePhase} from "./gamePhase.ts";
|
import type {GamePhase} from "./gamePhase.ts";
|
||||||
|
|
||||||
export interface GameState {
|
export interface GameState {
|
||||||
@@ -14,21 +12,12 @@ export interface GameState {
|
|||||||
// 当前操作玩家(座位)
|
// 当前操作玩家(座位)
|
||||||
currentTurn: number
|
currentTurn: number
|
||||||
|
|
||||||
// 是否必须先摸牌
|
|
||||||
needDraw: boolean
|
|
||||||
|
|
||||||
// 玩家列表
|
// 玩家列表
|
||||||
players: Player[]
|
players: Player[]
|
||||||
|
|
||||||
// ⚠️ 建议:只保留剩余数量(不要完整牌墙)
|
// 剩余数量
|
||||||
remainingTiles: number
|
remainingTiles: number
|
||||||
|
|
||||||
// 最近弃牌
|
|
||||||
lastDiscardTile?: Tile
|
|
||||||
|
|
||||||
// 最近弃牌玩家
|
|
||||||
lastDiscardBy?: string
|
|
||||||
|
|
||||||
// 操作响应窗口(碰/杠/胡)
|
// 操作响应窗口(碰/杠/胡)
|
||||||
pendingClaim?: PendingClaim
|
pendingClaim?: PendingClaim
|
||||||
|
|
||||||
@@ -37,16 +26,4 @@ export interface GameState {
|
|||||||
|
|
||||||
// 分数(playerId -> score)
|
// 分数(playerId -> score)
|
||||||
scores: Record<string, number>
|
scores: Record<string, number>
|
||||||
|
|
||||||
// 最近摸牌玩家
|
|
||||||
lastDrawPlayerId: string
|
|
||||||
|
|
||||||
// 是否杠后补牌
|
|
||||||
lastDrawFromGang: boolean
|
|
||||||
|
|
||||||
// 是否最后一张牌
|
|
||||||
lastDrawIsLastTile: boolean
|
|
||||||
|
|
||||||
// 胡牌方式
|
|
||||||
huWay: HuWay
|
|
||||||
}
|
}
|
||||||
@@ -1,5 +1,6 @@
|
|||||||
import type {Tile} from "../../models";
|
|
||||||
import type {ClaimOption} from "./claimOption.ts";
|
import type {ClaimOption} from "./claimOption.ts";
|
||||||
|
import type {Tile} from "../tile.ts";
|
||||||
|
|
||||||
export interface PendingClaim {
|
export interface PendingClaim {
|
||||||
// 当前被响应的牌
|
// 当前被响应的牌
|
||||||
|
|||||||
@@ -1,6 +1,5 @@
|
|||||||
<script setup lang="ts">
|
<script setup lang="ts">
|
||||||
import {computed, onBeforeUnmount, onMounted, ref} from 'vue'
|
import {computed, onBeforeUnmount, onMounted, ref} from 'vue'
|
||||||
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 wanIcon from '../assets/images/flowerClolor/wan.png'
|
import wanIcon from '../assets/images/flowerClolor/wan.png'
|
||||||
import tongIcon from '../assets/images/flowerClolor/tong.png'
|
import tongIcon from '../assets/images/flowerClolor/tong.png'
|
||||||
@@ -17,66 +16,30 @@ 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 {SeatPlayerCardModel} from '../components/game/seat-player-card'
|
import type {SeatPlayerCardModel} from '../components/game/seat-player-card'
|
||||||
|
import type {WsStatus} from "../ws/client.ts";
|
||||||
const route = useRoute()
|
import {wsClient} from "../ws/client.ts";
|
||||||
const router = useRouter()
|
|
||||||
const {
|
|
||||||
roomState,
|
|
||||||
roomName,
|
|
||||||
loggedInUserName,
|
|
||||||
wsStatus,
|
|
||||||
wsError,
|
|
||||||
wsMessages,
|
|
||||||
leaveRoomPending,
|
|
||||||
seatViews,
|
|
||||||
selectedTile,
|
|
||||||
connectWs,
|
|
||||||
selectTile,
|
|
||||||
backHall,
|
|
||||||
} = useChengduGameRoom(route, router)
|
|
||||||
|
|
||||||
const now = ref(Date.now())
|
const now = ref(Date.now())
|
||||||
let clockTimer: number | null = null
|
let clockTimer: number | null = null
|
||||||
|
let unsubscribe: (() => void) | null = null
|
||||||
|
|
||||||
const menuOpen = ref(false)
|
const menuOpen = ref(false)
|
||||||
const isTrustMode = ref(false)
|
const isTrustMode = ref(false)
|
||||||
const menuTriggerActive = ref(false)
|
const menuTriggerActive = ref(false)
|
||||||
let menuTriggerTimer: number | null = null
|
let menuTriggerTimer: number | null = null
|
||||||
let menuOpenTimer: number | null = null
|
let menuOpenTimer: number | null = null
|
||||||
|
|
||||||
const roomStatusText = computed(() => {
|
|
||||||
if (roomState.value.status === 'playing') {
|
|
||||||
return '对局中'
|
|
||||||
}
|
|
||||||
if (roomState.value.status === 'finished') {
|
|
||||||
return '已结束'
|
|
||||||
}
|
|
||||||
})
|
|
||||||
|
|
||||||
const currentPhaseText = computed(() => {
|
|
||||||
const phase = roomState.value.game?.state?.phase?.trim()
|
|
||||||
if (!phase) {
|
|
||||||
return roomState.value.status === 'playing' ? '牌局进行中' : '未开局'
|
|
||||||
}
|
|
||||||
|
|
||||||
const phaseLabelMap: Record<string, string> = {
|
|
||||||
dealing: '发牌',
|
|
||||||
discard: '出牌',
|
|
||||||
action: '响应',
|
|
||||||
settle: '结算',
|
|
||||||
finished: '已结束',
|
|
||||||
}
|
|
||||||
|
|
||||||
return phaseLabelMap[phase] ?? phase
|
|
||||||
})
|
|
||||||
|
|
||||||
const networkLabel = computed(() => {
|
const networkLabel = computed(() => {
|
||||||
if (wsStatus.value === 'connected') {
|
const map: Record<string, string> = {
|
||||||
return '已连接'
|
connected: '已连接',
|
||||||
|
connecting: '连接中',
|
||||||
|
error: '连接异常',
|
||||||
|
idle: '未连接',
|
||||||
|
closed: '未连接',
|
||||||
}
|
}
|
||||||
if (wsStatus.value === 'connecting') {
|
|
||||||
return '连接中'
|
return map[wsStatus.value] ?? '未连接'
|
||||||
}
|
|
||||||
return '未连接'
|
|
||||||
})
|
})
|
||||||
|
|
||||||
const formattedClock = computed(() => {
|
const formattedClock = computed(() => {
|
||||||
@@ -108,10 +71,8 @@ const seatDecor = computed<Record<SeatKey, SeatPlayerCardModel>>(() => {
|
|||||||
const emptySeat = (avatar: string): SeatPlayerCardModel => ({
|
const emptySeat = (avatar: string): SeatPlayerCardModel => ({
|
||||||
avatar,
|
avatar,
|
||||||
name: '空位',
|
name: '空位',
|
||||||
money: '--',
|
|
||||||
dealer: false,
|
dealer: false,
|
||||||
isTurn: false,
|
isTurn: false,
|
||||||
isOnline: false,
|
|
||||||
missingSuitLabel: defaultMissingSuitLabel,
|
missingSuitLabel: defaultMissingSuitLabel,
|
||||||
})
|
})
|
||||||
|
|
||||||
@@ -233,25 +194,43 @@ function handleGlobalEsc(event: KeyboardEvent): void {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
onMounted(() => {
|
onMounted(() => {
|
||||||
|
const handler = (status: any) => {
|
||||||
|
WsStatus.value = status
|
||||||
|
}
|
||||||
|
|
||||||
|
// 保存取消订阅函数
|
||||||
|
unsubscribe = wsClient.onStatusChange(handler)
|
||||||
|
|
||||||
clockTimer = window.setInterval(() => {
|
clockTimer = window.setInterval(() => {
|
||||||
now.value = Date.now()
|
now.value = Date.now()
|
||||||
}, 1000)
|
}, 1000)
|
||||||
|
|
||||||
window.addEventListener('click', handleGlobalClick)
|
window.addEventListener('click', handleGlobalClick)
|
||||||
window.addEventListener('keydown', handleGlobalEsc)
|
window.addEventListener('keydown', handleGlobalEsc)
|
||||||
})
|
})
|
||||||
|
|
||||||
onBeforeUnmount(() => {
|
onBeforeUnmount(() => {
|
||||||
|
// 取消 ws 订阅
|
||||||
|
if (unsubscribe) {
|
||||||
|
unsubscribe()
|
||||||
|
unsubscribe = null
|
||||||
|
}
|
||||||
|
|
||||||
if (clockTimer !== null) {
|
if (clockTimer !== null) {
|
||||||
window.clearInterval(clockTimer)
|
window.clearInterval(clockTimer)
|
||||||
clockTimer = null
|
clockTimer = null
|
||||||
}
|
}
|
||||||
|
|
||||||
window.removeEventListener('click', handleGlobalClick)
|
window.removeEventListener('click', handleGlobalClick)
|
||||||
window.removeEventListener('keydown', handleGlobalEsc)
|
window.removeEventListener('keydown', handleGlobalEsc)
|
||||||
|
|
||||||
if (menuTriggerTimer !== null) {
|
if (menuTriggerTimer !== null) {
|
||||||
window.clearTimeout(menuTriggerTimer)
|
window.clearTimeout(menuTriggerTimer)
|
||||||
menuTriggerTimer = null
|
menuTriggerTimer = null
|
||||||
}
|
}
|
||||||
|
|
||||||
if (menuOpenTimer !== null) {
|
if (menuOpenTimer !== null) {
|
||||||
window.clearTimeout(menuOpenTimer)
|
window.clearTimeout(menuOpenTimer)
|
||||||
menuOpenTimer = null
|
menuOpenTimer = null
|
||||||
@@ -286,7 +265,7 @@ onBeforeUnmount(() => {
|
|||||||
<div class="menu-list">
|
<div class="menu-list">
|
||||||
<button class="menu-item menu-item-delay-1" type="button" @click="toggleTrustMode">
|
<button class="menu-item menu-item-delay-1" type="button" @click="toggleTrustMode">
|
||||||
<span class="menu-item-icon">
|
<span class="menu-item-icon">
|
||||||
<img :src="robotIcon" alt="" />
|
<img :src="robotIcon" alt=""/>
|
||||||
</span>
|
</span>
|
||||||
<span>{{ isTrustMode ? '取消托管' : '托管' }}</span>
|
<span>{{ isTrustMode ? '取消托管' : '托管' }}</span>
|
||||||
</button>
|
</button>
|
||||||
@@ -297,7 +276,7 @@ onBeforeUnmount(() => {
|
|||||||
@click="handleLeaveRoom"
|
@click="handleLeaveRoom"
|
||||||
>
|
>
|
||||||
<span class="menu-item-icon">
|
<span class="menu-item-icon">
|
||||||
<img :src="exitIcon" alt="" />
|
<img :src="exitIcon" alt=""/>
|
||||||
</span>
|
</span>
|
||||||
<span>{{ leaveRoomPending ? '退出中...' : '退出' }}</span>
|
<span>{{ leaveRoomPending ? '退出中...' : '退出' }}</span>
|
||||||
</button>
|
</button>
|
||||||
|
|||||||
@@ -1,13 +1,27 @@
|
|||||||
type WsStatus = 'idle' | 'connecting' | 'connected' | 'closed' | 'error'
|
// WebSocket 连接状态
|
||||||
|
export type WsStatus =
|
||||||
|
// 空闲未连接
|
||||||
|
| 'idle'
|
||||||
|
// 正在连接
|
||||||
|
| 'connecting'
|
||||||
|
// 连接成功
|
||||||
|
| 'connected'
|
||||||
|
// 连接已关闭
|
||||||
|
| 'closed'
|
||||||
|
// 连接异常
|
||||||
|
| 'error'
|
||||||
|
|
||||||
|
// 消息回调
|
||||||
type MessageHandler = (data: any) => void
|
type MessageHandler = (data: any) => void
|
||||||
|
// 状态变化回调
|
||||||
type StatusHandler = (status: WsStatus) => void
|
type StatusHandler = (status: WsStatus) => void
|
||||||
|
// 错误回调
|
||||||
type ErrorHandler = (err: string) => void
|
type ErrorHandler = (err: string) => void
|
||||||
|
|
||||||
class WsClient {
|
class WsClient {
|
||||||
private ws: WebSocket | null = null
|
private ws: WebSocket | null = null
|
||||||
private url: string = ''
|
private url: string = ''
|
||||||
private token: string = '' // 保存token
|
private token: string = '' // 保存 token(用于重连)
|
||||||
|
|
||||||
private status: WsStatus = 'idle'
|
private status: WsStatus = 'idle'
|
||||||
|
|
||||||
@@ -16,10 +30,10 @@ class WsClient {
|
|||||||
private errorHandlers: ErrorHandler[] = []
|
private errorHandlers: ErrorHandler[] = []
|
||||||
|
|
||||||
private reconnectTimer: number | null = null
|
private reconnectTimer: number | null = null
|
||||||
private reconnectDelay = 2000
|
private reconnectDelay = 2000 // 重连间隔(毫秒)
|
||||||
|
|
||||||
|
|
||||||
// 构造带token的url
|
// 构造带 token 的 URL
|
||||||
private buildUrl(): string {
|
private buildUrl(): string {
|
||||||
if (!this.token) return this.url
|
if (!this.token) return this.url
|
||||||
|
|
||||||
@@ -30,40 +44,45 @@ class WsClient {
|
|||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
// 连接
|
// 建立连接
|
||||||
connect(url: string, token?: string) {
|
connect(url: string, token?: string) {
|
||||||
|
// 已连接则不重复连接
|
||||||
if (this.ws && this.status === 'connected') {
|
if (this.ws && this.status === 'connected') {
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
this.url = url
|
this.url = url
|
||||||
if (token !== undefined) {
|
if (token !== undefined) {
|
||||||
this.token = token // 保存token(用于重连)
|
this.token = token // 保存 token(用于重连)
|
||||||
}
|
}
|
||||||
|
|
||||||
this.setStatus('connecting')
|
this.setStatus('connecting')
|
||||||
|
|
||||||
const finalUrl = this.buildUrl() // 使用带token的url
|
const finalUrl = this.buildUrl()
|
||||||
this.ws = new WebSocket(finalUrl)
|
this.ws = new WebSocket(finalUrl)
|
||||||
|
|
||||||
|
// 连接成功
|
||||||
this.ws.onopen = () => {
|
this.ws.onopen = () => {
|
||||||
this.setStatus('connected')
|
this.setStatus('connected')
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// 收到消息
|
||||||
this.ws.onmessage = (event) => {
|
this.ws.onmessage = (event) => {
|
||||||
try {
|
try {
|
||||||
const data = JSON.parse(event.data)
|
const data = JSON.parse(event.data)
|
||||||
this.messageHandlers.forEach(fn => fn(data))
|
this.messageHandlers.forEach(fn => fn(data))
|
||||||
} catch (e) {
|
} catch {
|
||||||
this.messageHandlers.forEach(fn => fn(event.data))
|
this.messageHandlers.forEach(fn => fn(event.data))
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// 出错
|
||||||
this.ws.onerror = () => {
|
this.ws.onerror = () => {
|
||||||
this.setStatus('error')
|
this.setStatus('error')
|
||||||
this.emitError('WebSocket error')
|
this.emitError('WebSocket error')
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// 连接关闭
|
||||||
this.ws.onclose = () => {
|
this.ws.onclose = () => {
|
||||||
this.setStatus('closed')
|
this.setStatus('closed')
|
||||||
this.tryReconnect()
|
this.tryReconnect()
|
||||||
@@ -71,7 +90,7 @@ class WsClient {
|
|||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
// 发送
|
// 发送消息
|
||||||
send(data: any) {
|
send(data: any) {
|
||||||
if (this.ws && this.status === 'connected') {
|
if (this.ws && this.status === 'connected') {
|
||||||
this.ws.send(JSON.stringify(data))
|
this.ws.send(JSON.stringify(data))
|
||||||
@@ -79,7 +98,7 @@ class WsClient {
|
|||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
// 关闭
|
// 手动关闭
|
||||||
close() {
|
close() {
|
||||||
if (this.ws) {
|
if (this.ws) {
|
||||||
this.ws.close()
|
this.ws.close()
|
||||||
@@ -89,40 +108,48 @@ class WsClient {
|
|||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
// 订阅
|
// 订阅消息
|
||||||
onMessage(handler: MessageHandler) {
|
onMessage(handler: MessageHandler) {
|
||||||
this.messageHandlers.push(handler)
|
this.messageHandlers.push(handler)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// 订阅状态变化
|
||||||
onStatusChange(handler: StatusHandler) {
|
onStatusChange(handler: StatusHandler) {
|
||||||
this.statusHandlers.push(handler)
|
this.statusHandlers.push(handler)
|
||||||
|
return () => {
|
||||||
|
this.statusHandlers = this.statusHandlers.filter(fn => fn !== handler)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// 订阅错误
|
||||||
onError(handler: ErrorHandler) {
|
onError(handler: ErrorHandler) {
|
||||||
this.errorHandlers.push(handler)
|
this.errorHandlers.push(handler)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
// 内部方法
|
// 设置状态并通知所有订阅者
|
||||||
private setStatus(status: WsStatus) {
|
private setStatus(status: WsStatus) {
|
||||||
this.status = status
|
this.status = status
|
||||||
this.statusHandlers.forEach(fn => fn(status))
|
this.statusHandlers.forEach(fn => fn(status))
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// 触发错误回调
|
||||||
private emitError(msg: string) {
|
private emitError(msg: string) {
|
||||||
this.errorHandlers.forEach(fn => fn(msg))
|
this.errorHandlers.forEach(fn => fn(msg))
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// 尝试重连
|
||||||
private tryReconnect() {
|
private tryReconnect() {
|
||||||
this.clearReconnect()
|
this.clearReconnect()
|
||||||
|
|
||||||
this.reconnectTimer = window.setTimeout(() => {
|
this.reconnectTimer = window.setTimeout(() => {
|
||||||
if (this.url) {
|
if (this.url) {
|
||||||
this.connect(this.url) // 重连自动带token
|
this.connect(this.url) // 自动带 token
|
||||||
}
|
}
|
||||||
}, this.reconnectDelay)
|
}, this.reconnectDelay)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// 清除重连定时器
|
||||||
private clearReconnect() {
|
private clearReconnect() {
|
||||||
if (this.reconnectTimer !== null) {
|
if (this.reconnectTimer !== null) {
|
||||||
window.clearTimeout(this.reconnectTimer)
|
window.clearTimeout(this.reconnectTimer)
|
||||||
@@ -131,6 +158,5 @@ class WsClient {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// 单例
|
||||||
// 单例导出
|
|
||||||
export const wsClient = new WsClient()
|
export const wsClient = new WsClient()
|
||||||
@@ -9,7 +9,6 @@ export interface ActionMessage<T = any> {
|
|||||||
roomId?: string
|
roomId?: string
|
||||||
seq?: number
|
seq?: number
|
||||||
status?: string
|
status?: string
|
||||||
requestId?: string
|
|
||||||
trace_id?: string
|
trace_id?: string
|
||||||
|
|
||||||
payload?: T
|
payload?: T
|
||||||
|
|||||||
Reference in New Issue
Block a user