refactor(game): 重构游戏状态管理和WebSocket通信

- 定义统一的游戏动作类型GameAction替代原有发送函数
- 创建游戏状态管理store使用Pinia进行状态管理
- 实现游戏状态分发器处理各种游戏事件
- 重构WebSocket处理器支持多处理器注册
- 重命名状态类型文件统一使用State后缀
- 添加ACTION游戏阶段处理操作窗口逻辑
- 集成Pinia依赖管理应用状态
This commit is contained in:
2026-03-25 15:19:28 +08:00
parent 4a9b2f2db2
commit 2737971608
17 changed files with 334 additions and 178 deletions

View File

@@ -1,145 +1,55 @@
import type {ActionButtonState} from "./types.ts"
import {wsClient} from "../ws/client.ts"
import type {GameState, PendingClaimState} from "../types/state";
import type {Tile} from "../types/tile.ts";
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
/**
* 游戏动作定义(只描述“发生了什么”)
*/
export type GameAction =
// 初始化整局(进入房间 / 断线重连)
| {
type: 'GAME_INIT'
payload: GameState
}
// 开始游戏(发牌完成)
| {
type: 'GAME_START'
payload: {
dealerIndex: number
}
): void {
}
const {type, userID, roomId, selectedTile} = params
const {actionPending, logWsSend, pushWsMessage} = ctx
// 简单登录判断
if (!userID) {
console.log('当前用户未登录')
return
// 摸牌
| {
type: 'DRAW_TILE'
payload: {
playerId: string
tile: Tile
}
const payload: Record<string, unknown> = {}
}
// 出牌
if (type === 'discard' && selectedTile) {
payload.tile = selectedTile
payload.discard_tile = selectedTile
payload.code = selectedTile
| {
type: 'PLAY_TILE'
payload: {
playerId: string
tile: Tile
nextSeat: number
}
actionPending.value = true
const message = {
type,
sender: userID,
target: 'room',
roomId,
seq: Date.now(),
payload,
}
logWsSend(message)
wsClient.send(message)
pushWsMessage(`[client] 请求${type}`)
}
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] 请求开始游戏`)
// 进入操作窗口(碰/杠/胡)
| {
type: 'PENDING_CLAIM'
payload: PendingClaimState
}
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
// 操作结束(碰/杠/胡/过)
| {
type: 'CLAIM_RESOLVED'
payload: {
playerId: string
action: 'peng' | 'gang' | 'hu' | 'pass'
}
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
}

33
src/game/dispatcher.ts Normal file
View File

@@ -0,0 +1,33 @@
import type {GameAction} from './actions'
import {useGameStore} from "../store/gameStore.ts";
export function dispatchGameAction(action: GameAction) {
const store = useGameStore()
switch (action.type) {
case 'GAME_INIT':
store.initGame(action.payload)
break
case 'GAME_START':
store.phase = 'playing'
store.dealerIndex = action.payload.dealerIndex
break
case 'DRAW_TILE':
store.onDrawTile(action.payload)
break
case 'PLAY_TILE':
store.onPlayTile(action.payload)
break
case 'PENDING_CLAIM':
store.onPendingClaim(action.payload)
break
case 'CLAIM_RESOLVED':
store.clearPendingClaim()
break
}
}

View File

@@ -1,14 +0,0 @@
// 游戏对象
import type {GameState} from "../../types/state";
export interface Game {
rule: Rule
state: GameState
}
// 规则配置(仅描述,不包含判定逻辑)
export interface Rule {
name: string // 玩法名称
isBloodFlow: boolean // 是否血流玩法
hasHongZhong: boolean // 是否包含红中
}

View File

@@ -1,6 +1,16 @@
import { createApp } from 'vue'
import './assets/styles/style.css'
import {createApp} from 'vue'
import {createPinia} from 'pinia'
import App from './App.vue'
import router from './router'
createApp(App).use(router).mount('#app')
import './assets/styles/style.css'
const app = createApp(App)
const pinia = createPinia()
app.use(router)
app.use(pinia)
app.mount('#app')

105
src/store/gameStore.ts Normal file
View File

@@ -0,0 +1,105 @@
import { defineStore } from 'pinia'
import {
GAME_PHASE,
type GameState,
type PendingClaimState,
} from '../types/state'
import type { Tile } from '../types/tile'
export const useGameStore = defineStore('game', {
state: (): GameState => ({
roomId: '',
phase: GAME_PHASE.WAITING,
dealerIndex: 0,
currentTurn: 0,
players: {},
remainingTiles: 0,
pendingClaim: undefined,
winners: [],
scores: {},
}),
actions: {
// 初始化
initGame(data: GameState) {
Object.assign(this, data)
},
// 摸牌
onDrawTile(data: { playerId: string; tile: Tile }) {
const player = this.players[data.playerId]
if (!player) return
// 只更新自己的手牌
if (player.playerId === this.getMyPlayerId()) {
player.handTiles.push(data.tile)
}
// 剩余牌数减少
this.remainingTiles = Math.max(0, this.remainingTiles - 1)
// 更新回合seatIndex
this.currentTurn = player.seatIndex
// 清除操作窗口
this.pendingClaim = undefined
// 进入出牌阶段
this.phase = GAME_PHASE.PLAYING
},
// 出牌
onPlayTile(data: {
playerId: string
tile: Tile
nextSeat: number
}) {
const player = this.players[data.playerId]
if (!player) return
// 如果是自己,移除手牌
if (player.playerId === this.getMyPlayerId()) {
const index = player.handTiles.findIndex(
(t) => t.id === data.tile.id
)
if (index !== -1) {
player.handTiles.splice(index, 1)
}
}
// 加入出牌区
player.discardTiles.push(data.tile)
// 更新回合
this.currentTurn = data.nextSeat
// 等待其他玩家响应
this.phase = GAME_PHASE.ACTION
},
// 触发操作窗口(碰/杠/胡)
onPendingClaim(data: PendingClaimState) {
this.pendingClaim = data
this.phase = GAME_PHASE.ACTION
},
// 清理操作窗口
clearPendingClaim() {
this.pendingClaim = undefined
this.phase = GAME_PHASE.PLAYING
},
// 获取当前玩家ID后续建议放到 userStore
getMyPlayerId(): string {
return Object.keys(this.players)[0] || ''
},
},
})

View File

@@ -5,5 +5,5 @@ export const CLAIM_OPTIONS = {
PASS: 'pass',
} as const
export type ClaimOption =
export type ClaimOptionState =
typeof CLAIM_OPTIONS[keyof typeof CLAIM_OPTIONS]

View File

@@ -3,17 +3,19 @@ export const GAME_PHASE = {
WAITING: 'waiting', // 等待玩家准备 / 开始
DEALING: 'dealing', // 发牌阶段
PLAYING: 'playing', // 对局进行中
ACTION: 'action',
SETTLEMENT: 'settlement', // 结算阶段
} as const
// 游戏阶段类型(取自 GAME_PHASE 的值)
export type GamePhase =
export type GamePhaseState =
typeof GAME_PHASE[keyof typeof GAME_PHASE]
export const GAME_PHASE_LABEL: Record<GamePhase, string> = {
export const GAME_PHASE_LABEL: Record<GamePhaseState, string> = {
waiting: '等待中',
dealing: '发牌',
playing: '对局中',
action: '操作中',
settlement: '结算',
}

View File

@@ -1,10 +1,12 @@
import type {Player} from "./player.ts";
import type {PendingClaim} from "./pendingClaim.ts";
import type {GamePhase} from "./gamePhase.ts";
import type {PlayerState} from "./playerState.ts";
import type {PendingClaimState} from "./pendingClaimState.ts";
import type {GamePhaseState} from "./gamePhaseState.ts";
export interface GameState {
// 房间ID
roomId: string
// 当前阶段
phase: GamePhase
phase: GamePhaseState
// 庄家位置
dealerIndex: number
@@ -13,13 +15,13 @@ export interface GameState {
currentTurn: number
// 玩家列表
players: Player[]
players: Record<string, PlayerState>
// 剩余数量
remainingTiles: number
// 操作响应窗口(碰/杠/胡)
pendingClaim?: PendingClaim
pendingClaim?: PendingClaimState
// 胡牌玩家
winners: string[]

View File

@@ -8,5 +8,5 @@ export const HU_WAY = {
} as const
// 胡牌类型(从常量中推导)
export type HuWay =
export type HuWayState =
typeof HU_WAY[keyof typeof HU_WAY]

View File

@@ -1,7 +1,7 @@
export * from './gamestate'
export * from './player'
export * from './meld'
export * from './pendingClaim'
export * from './claimOption'
export * from './gamePhase'
export * from './huWay'
export * from './gameState.ts'
export * from './playerState.ts'
export * from './meldState.ts'
export * from './pendingClaimState.ts'
export * from './claimOptionState.ts'
export * from './gamePhaseState.ts'
export * from './huWayState.ts'

View File

@@ -1,6 +1,6 @@
import type { Tile } from '../tile'
export type Meld =
export type MeldState =
| {
type: 'peng'
tiles: Tile[]

View File

@@ -1,8 +1,8 @@
import type {ClaimOption} from "./claimOption.ts";
import type {ClaimOptionState} from "./claimOptionState.ts";
import type {Tile} from "../tile.ts";
export interface PendingClaim {
export interface PendingClaimState {
// 当前被响应的牌
tile: Tile
@@ -10,5 +10,5 @@ export interface PendingClaim {
fromPlayerId: string
// 当前玩家可执行操作
options: ClaimOption[]
options: ClaimOptionState[]
}

View File

@@ -1,7 +1,7 @@
import type { Tile } from '../tile'
import type { Meld } from './meld'
import type { MeldState } from './meldState.ts'
export interface Player{
export interface PlayerState {
playerId: string
seatIndex: number
@@ -9,7 +9,7 @@ export interface Player{
handTiles: Tile[]
// 副露(碰/杠)
melds: Meld[]
melds: MeldState[]
// 出牌区
discardTiles: Tile[]

View File

@@ -2,22 +2,24 @@ import {wsClient} from './client'
type Handler = (msg: any) => void
const handlerMap: Record<string, Handler> = {}
const handlerMap: Record<string, Handler[]> = {}
// 注册 handler
export function registerHandler(type: string, handler: Handler) {
handlerMap[type] = handler
if (!handlerMap[type]) {
handlerMap[type] = []
}
handlerMap[type].push(handler)
}
// 初始化监听
export function initWsHandler() {
wsClient.onMessage((msg) => {
const handler = handlerMap[msg.type]
const handlers = handlerMap[msg.type]
if (handler) {
handler(msg)
if (handlers && handlers.length > 0) {
handlers.forEach(h => h(msg))
} else {
console.warn('[WS] 未处理消息:', msg.type, msg)
}

6
src/ws/sender.ts Normal file
View File

@@ -0,0 +1,6 @@
import { wsClient } from './client'
export function sendWsMessage(message: any) {
console.log('[WS SEND]', message)
wsClient.send(message)
}