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)}`
|
||||
}
|
||||
@@ -26,8 +26,8 @@ const missingSuitIcon = computed(() => {
|
||||
|
||||
<template>
|
||||
<article
|
||||
class="player-badge"
|
||||
:class="[seatClass, { 'is-turn': player.isTurn, offline: !player.isOnline }]"
|
||||
class="player-badge"
|
||||
:class="[seatClass, { 'is-turn': player.isTurn }]"
|
||||
>
|
||||
<div class="avatar-panel">
|
||||
<div class="avatar-card">{{ player.avatar }}</div>
|
||||
@@ -36,7 +36,6 @@ const missingSuitIcon = computed(() => {
|
||||
|
||||
<div class="player-meta">
|
||||
<p>{{ player.name }}</p>
|
||||
<strong>{{ player.money }}</strong>
|
||||
</div>
|
||||
|
||||
<div class="missing-mark">
|
||||
@@ -44,4 +43,4 @@ const missingSuitIcon = computed(() => {
|
||||
<span v-else>{{ player.missingSuitLabel }}</span>
|
||||
</div>
|
||||
</article>
|
||||
</template>
|
||||
</template>
|
||||
@@ -1,9 +1,8 @@
|
||||
// 玩家卡片展示模型(用于座位UI渲染)
|
||||
export interface SeatPlayerCardModel {
|
||||
avatar: string
|
||||
name: string
|
||||
money: string
|
||||
dealer: boolean
|
||||
isTurn: boolean
|
||||
isOnline: boolean
|
||||
missingSuitLabel: string
|
||||
}
|
||||
avatar: string // 头像
|
||||
name: string // 显示名称
|
||||
dealer: boolean // 是否庄家
|
||||
isTurn: boolean // 是否当前轮到该玩家
|
||||
missingSuitLabel: string // 定缺花色(万/筒/条)
|
||||
}
|
||||
@@ -1,37 +1,145 @@
|
||||
import type { ActionButtonState } from "./types.ts"
|
||||
import { wsClient } from "@/ws/client" // 新增
|
||||
import type {ActionButtonState} from "./types.ts"
|
||||
import {wsClient} from "../ws/client.ts"
|
||||
|
||||
export function sendGameAction(type: ActionButtonState['type']): void {
|
||||
// 原来是判断 ws,这里改成用 wsClient 的状态(简单处理)
|
||||
if (!currentUserId.value) {
|
||||
|
||||
export function sendGameAction(
|
||||
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
|
||||
}
|
||||
|
||||
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
|
||||
// 出牌
|
||||
if (type === 'discard' && selectedTile) {
|
||||
payload.tile = selectedTile
|
||||
payload.discard_tile = selectedTile
|
||||
payload.code = selectedTile
|
||||
}
|
||||
|
||||
actionPending.value = true
|
||||
|
||||
const message = {
|
||||
type,
|
||||
sender: currentUserId.value,
|
||||
sender: userID,
|
||||
target: 'room',
|
||||
roomId: roomState.value.id || roomId.value,
|
||||
roomId,
|
||||
seq: Date.now(),
|
||||
requestId,
|
||||
trace_id: createRequestId('trace'),
|
||||
payload,
|
||||
}
|
||||
|
||||
logWsSend(message)
|
||||
wsClient.send(message)
|
||||
pushWsMessage(`[client] 请求${type}`)
|
||||
}
|
||||
|
||||
wsClient.send(message) // ✅ 改这里
|
||||
|
||||
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";
|
||||
|
||||
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 = {
|
||||
WAITING: 'waiting',
|
||||
DEALING: 'dealing',
|
||||
PLAYING: 'playing',
|
||||
SETTLEMENT: 'settlement',
|
||||
WAITING: 'waiting', // 等待玩家准备 / 开始
|
||||
DEALING: 'dealing', // 发牌阶段
|
||||
PLAYING: 'playing', // 对局进行中
|
||||
SETTLEMENT: 'settlement', // 结算阶段
|
||||
} as const
|
||||
|
||||
// 游戏阶段类型(取自 GAME_PHASE 的值)
|
||||
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 {Tile} from "../../models";
|
||||
import type {PendingClaim} from "./pendingClaim.ts";
|
||||
import type {HuWay} from "./huWay.ts";
|
||||
import type {GamePhase} from "./gamePhase.ts";
|
||||
|
||||
export interface GameState {
|
||||
@@ -14,21 +12,12 @@ export interface GameState {
|
||||
// 当前操作玩家(座位)
|
||||
currentTurn: number
|
||||
|
||||
// 是否必须先摸牌
|
||||
needDraw: boolean
|
||||
|
||||
// 玩家列表
|
||||
players: Player[]
|
||||
|
||||
// ⚠️ 建议:只保留剩余数量(不要完整牌墙)
|
||||
// 剩余数量
|
||||
remainingTiles: number
|
||||
|
||||
// 最近弃牌
|
||||
lastDiscardTile?: Tile
|
||||
|
||||
// 最近弃牌玩家
|
||||
lastDiscardBy?: string
|
||||
|
||||
// 操作响应窗口(碰/杠/胡)
|
||||
pendingClaim?: PendingClaim
|
||||
|
||||
@@ -37,16 +26,4 @@ export interface GameState {
|
||||
|
||||
// 分数(playerId -> score)
|
||||
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 {Tile} from "../tile.ts";
|
||||
|
||||
export interface PendingClaim {
|
||||
// 当前被响应的牌
|
||||
|
||||
@@ -1,6 +1,5 @@
|
||||
<script setup lang="ts">
|
||||
import {computed, onBeforeUnmount, onMounted, ref} from 'vue'
|
||||
import {useRoute, useRouter} from 'vue-router'
|
||||
import deskImage from '../assets/images/desk/desk_01.png'
|
||||
import wanIcon from '../assets/images/flowerClolor/wan.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 LeftPlayerCard from '../components/game/LeftPlayerCard.vue'
|
||||
import type {SeatPlayerCardModel} from '../components/game/seat-player-card'
|
||||
|
||||
const route = useRoute()
|
||||
const router = useRouter()
|
||||
const {
|
||||
roomState,
|
||||
roomName,
|
||||
loggedInUserName,
|
||||
wsStatus,
|
||||
wsError,
|
||||
wsMessages,
|
||||
leaveRoomPending,
|
||||
seatViews,
|
||||
selectedTile,
|
||||
connectWs,
|
||||
selectTile,
|
||||
backHall,
|
||||
} = useChengduGameRoom(route, router)
|
||||
import type {WsStatus} from "../ws/client.ts";
|
||||
import {wsClient} from "../ws/client.ts";
|
||||
|
||||
const now = ref(Date.now())
|
||||
let clockTimer: number | null = null
|
||||
let unsubscribe: (() => void) | null = null
|
||||
|
||||
const menuOpen = ref(false)
|
||||
const isTrustMode = ref(false)
|
||||
const menuTriggerActive = ref(false)
|
||||
let menuTriggerTimer: 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(() => {
|
||||
if (wsStatus.value === 'connected') {
|
||||
return '已连接'
|
||||
const map: Record<string, string> = {
|
||||
connected: '已连接',
|
||||
connecting: '连接中',
|
||||
error: '连接异常',
|
||||
idle: '未连接',
|
||||
closed: '未连接',
|
||||
}
|
||||
if (wsStatus.value === 'connecting') {
|
||||
return '连接中'
|
||||
}
|
||||
return '未连接'
|
||||
|
||||
return map[wsStatus.value] ?? '未连接'
|
||||
})
|
||||
|
||||
const formattedClock = computed(() => {
|
||||
@@ -108,10 +71,8 @@ const seatDecor = computed<Record<SeatKey, SeatPlayerCardModel>>(() => {
|
||||
const emptySeat = (avatar: string): SeatPlayerCardModel => ({
|
||||
avatar,
|
||||
name: '空位',
|
||||
money: '--',
|
||||
dealer: false,
|
||||
isTurn: false,
|
||||
isOnline: false,
|
||||
missingSuitLabel: defaultMissingSuitLabel,
|
||||
})
|
||||
|
||||
@@ -233,25 +194,43 @@ function handleGlobalEsc(event: KeyboardEvent): void {
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
onMounted(() => {
|
||||
const handler = (status: any) => {
|
||||
WsStatus.value = status
|
||||
}
|
||||
|
||||
// 保存取消订阅函数
|
||||
unsubscribe = wsClient.onStatusChange(handler)
|
||||
|
||||
clockTimer = window.setInterval(() => {
|
||||
now.value = Date.now()
|
||||
}, 1000)
|
||||
|
||||
window.addEventListener('click', handleGlobalClick)
|
||||
window.addEventListener('keydown', handleGlobalEsc)
|
||||
})
|
||||
|
||||
onBeforeUnmount(() => {
|
||||
// 取消 ws 订阅
|
||||
if (unsubscribe) {
|
||||
unsubscribe()
|
||||
unsubscribe = null
|
||||
}
|
||||
|
||||
if (clockTimer !== null) {
|
||||
window.clearInterval(clockTimer)
|
||||
clockTimer = null
|
||||
}
|
||||
|
||||
window.removeEventListener('click', handleGlobalClick)
|
||||
window.removeEventListener('keydown', handleGlobalEsc)
|
||||
|
||||
if (menuTriggerTimer !== null) {
|
||||
window.clearTimeout(menuTriggerTimer)
|
||||
menuTriggerTimer = null
|
||||
}
|
||||
|
||||
if (menuOpenTimer !== null) {
|
||||
window.clearTimeout(menuOpenTimer)
|
||||
menuOpenTimer = null
|
||||
@@ -273,11 +252,11 @@ onBeforeUnmount(() => {
|
||||
<div class="top-left-tools">
|
||||
<div class="menu-trigger-wrap">
|
||||
<button
|
||||
class="metal-circle menu-trigger"
|
||||
:class="{ 'is-feedback': menuTriggerActive }"
|
||||
type="button"
|
||||
:disabled="leaveRoomPending"
|
||||
@click.stop="toggleMenu"
|
||||
class="metal-circle menu-trigger"
|
||||
:class="{ 'is-feedback': menuTriggerActive }"
|
||||
type="button"
|
||||
:disabled="leaveRoomPending"
|
||||
@click.stop="toggleMenu"
|
||||
>
|
||||
<span class="menu-trigger-icon">☰</span>
|
||||
</button>
|
||||
@@ -286,18 +265,18 @@ onBeforeUnmount(() => {
|
||||
<div class="menu-list">
|
||||
<button class="menu-item menu-item-delay-1" type="button" @click="toggleTrustMode">
|
||||
<span class="menu-item-icon">
|
||||
<img :src="robotIcon" alt="" />
|
||||
<img :src="robotIcon" alt=""/>
|
||||
</span>
|
||||
<span>{{ isTrustMode ? '取消托管' : '托管' }}</span>
|
||||
</button>
|
||||
<button
|
||||
class="menu-item menu-item-danger menu-item-delay-2"
|
||||
type="button"
|
||||
:disabled="leaveRoomPending"
|
||||
@click="handleLeaveRoom"
|
||||
class="menu-item menu-item-danger menu-item-delay-2"
|
||||
type="button"
|
||||
:disabled="leaveRoomPending"
|
||||
@click="handleLeaveRoom"
|
||||
>
|
||||
<span class="menu-item-icon">
|
||||
<img :src="exitIcon" alt="" />
|
||||
<img :src="exitIcon" alt=""/>
|
||||
</span>
|
||||
<span>{{ leaveRoomPending ? '退出中...' : '退出' }}</span>
|
||||
</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 StatusHandler = (status: WsStatus) => void
|
||||
// 错误回调
|
||||
type ErrorHandler = (err: string) => void
|
||||
|
||||
class WsClient {
|
||||
private ws: WebSocket | null = null
|
||||
private url: string = ''
|
||||
private token: string = '' // 保存token
|
||||
private token: string = '' // 保存 token(用于重连)
|
||||
|
||||
private status: WsStatus = 'idle'
|
||||
|
||||
@@ -16,10 +30,10 @@ class WsClient {
|
||||
private errorHandlers: ErrorHandler[] = []
|
||||
|
||||
private reconnectTimer: number | null = null
|
||||
private reconnectDelay = 2000
|
||||
private reconnectDelay = 2000 // 重连间隔(毫秒)
|
||||
|
||||
|
||||
// 构造带token的url
|
||||
// 构造带 token 的 URL
|
||||
private buildUrl(): string {
|
||||
if (!this.token) return this.url
|
||||
|
||||
@@ -30,40 +44,45 @@ class WsClient {
|
||||
}
|
||||
|
||||
|
||||
// 连接
|
||||
// 建立连接
|
||||
connect(url: string, token?: string) {
|
||||
// 已连接则不重复连接
|
||||
if (this.ws && this.status === 'connected') {
|
||||
return
|
||||
}
|
||||
|
||||
this.url = url
|
||||
if (token !== undefined) {
|
||||
this.token = token // 保存token(用于重连)
|
||||
this.token = token // 保存 token(用于重连)
|
||||
}
|
||||
|
||||
this.setStatus('connecting')
|
||||
|
||||
const finalUrl = this.buildUrl() // 使用带token的url
|
||||
const finalUrl = this.buildUrl()
|
||||
this.ws = new WebSocket(finalUrl)
|
||||
|
||||
// 连接成功
|
||||
this.ws.onopen = () => {
|
||||
this.setStatus('connected')
|
||||
}
|
||||
|
||||
// 收到消息
|
||||
this.ws.onmessage = (event) => {
|
||||
try {
|
||||
const data = JSON.parse(event.data)
|
||||
this.messageHandlers.forEach(fn => fn(data))
|
||||
} catch (e) {
|
||||
} catch {
|
||||
this.messageHandlers.forEach(fn => fn(event.data))
|
||||
}
|
||||
}
|
||||
|
||||
// 出错
|
||||
this.ws.onerror = () => {
|
||||
this.setStatus('error')
|
||||
this.emitError('WebSocket error')
|
||||
}
|
||||
|
||||
// 连接关闭
|
||||
this.ws.onclose = () => {
|
||||
this.setStatus('closed')
|
||||
this.tryReconnect()
|
||||
@@ -71,7 +90,7 @@ class WsClient {
|
||||
}
|
||||
|
||||
|
||||
// 发送
|
||||
// 发送消息
|
||||
send(data: any) {
|
||||
if (this.ws && this.status === 'connected') {
|
||||
this.ws.send(JSON.stringify(data))
|
||||
@@ -79,7 +98,7 @@ class WsClient {
|
||||
}
|
||||
|
||||
|
||||
// 关闭
|
||||
// 手动关闭
|
||||
close() {
|
||||
if (this.ws) {
|
||||
this.ws.close()
|
||||
@@ -89,40 +108,48 @@ class WsClient {
|
||||
}
|
||||
|
||||
|
||||
// 订阅
|
||||
// 订阅消息
|
||||
onMessage(handler: MessageHandler) {
|
||||
this.messageHandlers.push(handler)
|
||||
}
|
||||
|
||||
// 订阅状态变化
|
||||
onStatusChange(handler: StatusHandler) {
|
||||
this.statusHandlers.push(handler)
|
||||
return () => {
|
||||
this.statusHandlers = this.statusHandlers.filter(fn => fn !== handler)
|
||||
}
|
||||
}
|
||||
|
||||
// 订阅错误
|
||||
onError(handler: ErrorHandler) {
|
||||
this.errorHandlers.push(handler)
|
||||
}
|
||||
|
||||
|
||||
// 内部方法
|
||||
// 设置状态并通知所有订阅者
|
||||
private setStatus(status: WsStatus) {
|
||||
this.status = status
|
||||
this.statusHandlers.forEach(fn => fn(status))
|
||||
}
|
||||
|
||||
// 触发错误回调
|
||||
private emitError(msg: string) {
|
||||
this.errorHandlers.forEach(fn => fn(msg))
|
||||
}
|
||||
|
||||
// 尝试重连
|
||||
private tryReconnect() {
|
||||
this.clearReconnect()
|
||||
|
||||
this.reconnectTimer = window.setTimeout(() => {
|
||||
if (this.url) {
|
||||
this.connect(this.url) // 重连自动带token
|
||||
this.connect(this.url) // 自动带 token
|
||||
}
|
||||
}, this.reconnectDelay)
|
||||
}
|
||||
|
||||
// 清除重连定时器
|
||||
private clearReconnect() {
|
||||
if (this.reconnectTimer !== null) {
|
||||
window.clearTimeout(this.reconnectTimer)
|
||||
@@ -131,6 +158,5 @@ class WsClient {
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
// 单例导出
|
||||
// 单例
|
||||
export const wsClient = new WsClient()
|
||||
@@ -9,7 +9,6 @@ export interface ActionMessage<T = any> {
|
||||
roomId?: string
|
||||
seq?: number
|
||||
status?: string
|
||||
requestId?: string
|
||||
trace_id?: string
|
||||
|
||||
payload?: T
|
||||
|
||||
Reference in New Issue
Block a user