refactor(game): 重构游戏动作处理和WebSocket连接管理

- 重构sendGameAction函数参数结构,添加上下文支持
- 新增sendStartGame和sendLeaveRoom函数统一处理游戏开始和离开房间逻辑
- 移除路由相关依赖,简化ChengduGamePage组件
- 更新WebSocket客户端实现,添加状态变化订阅功能
- 移除requestId生成函数和相关参数,精简消息结构
- 优化座位玩家卡片数据模型,移除在线状态和金钱字段
- 整理游戏阶段常量定义,添加标签映射
- 移除过期的游戏状态字段如needDraw、lastDiscardTile等
- 添加座位类型定义和改进游戏类型文件组织结构
This commit is contained in:
2026-03-25 13:34:47 +08:00
parent 4f6ef1d0ec
commit 148e21f3b0
13 changed files with 236 additions and 867 deletions

View File

@@ -1,4 +0,0 @@
// 生成 requestId / traceId
export function createRequestId(prefix: string): string {
return `${prefix}-${Date.now()}-${Math.random().toString(36).slice(2, 10)}`
}

View File

@@ -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>

View File

@@ -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 // 定缺花色(万/筒/条)
}

View File

@@ -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
View File

@@ -0,0 +1,2 @@
export type SeatKey = 'top' | 'right' | 'bottom' | 'left'

View File

@@ -1,5 +1,4 @@
// 游戏对象
import type {GameState} from "../../types/state";
export interface Game {

View File

@@ -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,
}
}

View File

@@ -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: '结算',
}

View File

@@ -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
}

View File

@@ -1,5 +1,6 @@
import type {Tile} from "../../models";
import type {ClaimOption} from "./claimOption.ts";
import type {Tile} from "../tile.ts";
export interface PendingClaim {
// 当前被响应的牌

View File

@@ -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>

View File

@@ -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()

View File

@@ -9,7 +9,6 @@ export interface ActionMessage<T = any> {
roomId?: string
seq?: number
status?: string
requestId?: string
trace_id?: string
payload?: T