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

@@ -11,6 +11,7 @@
"test:e2e": "playwright test" "test:e2e": "playwright test"
}, },
"dependencies": { "dependencies": {
"pinia": "^3.0.4",
"vue": "^3.5.25", "vue": "^3.5.25",
"vue-router": "4" "vue-router": "4"
}, },

99
pnpm-lock.yaml generated
View File

@@ -8,6 +8,9 @@ importers:
.: .:
dependencies: dependencies:
pinia:
specifier: ^3.0.4
version: 3.0.4(typescript@5.9.3)(vue@3.5.28(typescript@5.9.3))
vue: vue:
specifier: ^3.5.25 specifier: ^3.5.25
version: 3.5.28(typescript@5.9.3) version: 3.5.28(typescript@5.9.3)
@@ -393,6 +396,15 @@ packages:
'@vue/devtools-api@6.6.4': '@vue/devtools-api@6.6.4':
resolution: {integrity: sha512-sGhTPMuXqZ1rVOk32RylztWkfXTRhuS7vgAKv0zjqk8gbsHkJ7xfFf+jbySxt7tWObEJwyKaHMikV/WGDiQm8g==} resolution: {integrity: sha512-sGhTPMuXqZ1rVOk32RylztWkfXTRhuS7vgAKv0zjqk8gbsHkJ7xfFf+jbySxt7tWObEJwyKaHMikV/WGDiQm8g==}
'@vue/devtools-api@7.7.9':
resolution: {integrity: sha512-kIE8wvwlcZ6TJTbNeU2HQNtaxLx3a84aotTITUuL/4bzfPxzajGBOoqjMhwZJ8L9qFYDU/lAYMEEm11dnZOD6g==}
'@vue/devtools-kit@7.7.9':
resolution: {integrity: sha512-PyQ6odHSgiDVd4hnTP+aDk2X4gl2HmLDfiyEnn3/oV+ckFDuswRs4IbBT7vacMuGdwY/XemxBoh302ctbsptuA==}
'@vue/devtools-shared@7.7.9':
resolution: {integrity: sha512-iWAb0v2WYf0QWmxCGy0seZNDPdO3Sp5+u78ORnyeonS6MT4PC7VPrryX2BpMJrwlDeaZ6BD4vP4XKjK0SZqaeA==}
'@vue/language-core@3.2.4': '@vue/language-core@3.2.4':
resolution: {integrity: sha512-bqBGuSG4KZM45KKTXzGtoCl9cWju5jsaBKaJJe3h5hRAAWpZUuj5G+L+eI01sPIkm4H6setKRlw7E85wLdDNew==} resolution: {integrity: sha512-bqBGuSG4KZM45KKTXzGtoCl9cWju5jsaBKaJJe3h5hRAAWpZUuj5G+L+eI01sPIkm4H6setKRlw7E85wLdDNew==}
@@ -427,6 +439,13 @@ packages:
alien-signals@3.1.2: alien-signals@3.1.2:
resolution: {integrity: sha512-d9dYqZTS90WLiU0I5c6DHj/HcKkF8ZyGN3G5x8wSbslulz70KOxaqCT0hQCo9KOyhVqzqGojvNdJXoTumZOtcw==} resolution: {integrity: sha512-d9dYqZTS90WLiU0I5c6DHj/HcKkF8ZyGN3G5x8wSbslulz70KOxaqCT0hQCo9KOyhVqzqGojvNdJXoTumZOtcw==}
birpc@2.9.0:
resolution: {integrity: sha512-KrayHS5pBi69Xi9JmvoqrIgYGDkD6mcSe/i6YKi3w5kekCLzrX4+nawcXqrj2tIp50Kw/mT/s3p+GVK0A0sKxw==}
copy-anything@4.0.5:
resolution: {integrity: sha512-7Vv6asjS4gMOuILabD3l739tsaxFQmC+a7pLZm02zyvs8p977bL3zEgq3yDk5rn9B0PbYgIv++jmHcuUab4RhA==}
engines: {node: '>=18'}
csstype@3.2.3: csstype@3.2.3:
resolution: {integrity: sha512-z1HGKcYy2xA8AGQfwrn0PAy+PB7X/GSj3UVJW9qKyn43xWa+gl5nXmU4qqLMRzWVLFC8KusUX8T/0kCiOYpAIQ==} resolution: {integrity: sha512-z1HGKcYy2xA8AGQfwrn0PAy+PB7X/GSj3UVJW9qKyn43xWa+gl5nXmU4qqLMRzWVLFC8KusUX8T/0kCiOYpAIQ==}
@@ -461,9 +480,19 @@ packages:
engines: {node: ^8.16.0 || ^10.6.0 || >=11.0.0} engines: {node: ^8.16.0 || ^10.6.0 || >=11.0.0}
os: [darwin] os: [darwin]
hookable@5.5.3:
resolution: {integrity: sha512-Yc+BQe8SvoXH1643Qez1zqLRmbA5rCL+sSmk6TVos0LWVfNIB7PGncdlId77WzLGSIB5KaWgTaNTs2lNVEI6VQ==}
is-what@5.5.0:
resolution: {integrity: sha512-oG7cgbmg5kLYae2N5IVd3jm2s+vldjxJzK1pcu9LfpGuQ93MQSzo0okvRna+7y5ifrD+20FE8FvjusyGaz14fw==}
engines: {node: '>=18'}
magic-string@0.30.21: magic-string@0.30.21:
resolution: {integrity: sha512-vd2F4YUyEXKGcLHoq+TEyCjxueSeHnFxyyjNp80yg0XV4vUhnDer/lvvlqM/arB5bXQN5K2/3oinyCRyx8T2CQ==} resolution: {integrity: sha512-vd2F4YUyEXKGcLHoq+TEyCjxueSeHnFxyyjNp80yg0XV4vUhnDer/lvvlqM/arB5bXQN5K2/3oinyCRyx8T2CQ==}
mitt@3.0.1:
resolution: {integrity: sha512-vKivATfr97l2/QBCYAkXYDbrIWPM2IIKEl7YPhjCvKlG3kE2gm+uBo6nEXK3M5/Ffh/FLpKExzOQ3JJoJGFKBw==}
muggle-string@0.4.1: muggle-string@0.4.1:
resolution: {integrity: sha512-VNTrAak/KhO2i8dqqnqnAHOa3cYBwXEZe9h+D5h/1ZqFSTEFHdM65lR7RoIqq3tBBYavsOXV84NoHXZ0AkPyqQ==} resolution: {integrity: sha512-VNTrAak/KhO2i8dqqnqnAHOa3cYBwXEZe9h+D5h/1ZqFSTEFHdM65lR7RoIqq3tBBYavsOXV84NoHXZ0AkPyqQ==}
@@ -475,6 +504,9 @@ packages:
path-browserify@1.0.1: path-browserify@1.0.1:
resolution: {integrity: sha512-b7uo2UCUOYZcnF/3ID0lulOJi/bafxa1xPe7ZPsammBSpjSWQkjNxlt635YGS2MiR9GjvuXCtz2emr3jbsz98g==} resolution: {integrity: sha512-b7uo2UCUOYZcnF/3ID0lulOJi/bafxa1xPe7ZPsammBSpjSWQkjNxlt635YGS2MiR9GjvuXCtz2emr3jbsz98g==}
perfect-debounce@1.0.0:
resolution: {integrity: sha512-xCy9V055GLEqoFaHoC1SoLIaLmWctgCUaBaWxDZ7/Zx4CTyX7cJQLJOok/orfjZAh9kEYpjJa4d0KcJmCbctZA==}
picocolors@1.1.1: picocolors@1.1.1:
resolution: {integrity: sha512-xceH2snhtb5M9liqDsmEw56le376mTZkEX/jEb/RxNFyegNul7eNslCXP9FDj/Lcu0X8KEyMceP2ntpaHrDEVA==} resolution: {integrity: sha512-xceH2snhtb5M9liqDsmEw56le376mTZkEX/jEb/RxNFyegNul7eNslCXP9FDj/Lcu0X8KEyMceP2ntpaHrDEVA==}
@@ -482,6 +514,15 @@ packages:
resolution: {integrity: sha512-5gTmgEY/sqK6gFXLIsQNH19lWb4ebPDLA4SdLP7dsWkIXHWlG66oPuVvXSGFPppYZz8ZDZq0dYYrbHfBCVUb1Q==} resolution: {integrity: sha512-5gTmgEY/sqK6gFXLIsQNH19lWb4ebPDLA4SdLP7dsWkIXHWlG66oPuVvXSGFPppYZz8ZDZq0dYYrbHfBCVUb1Q==}
engines: {node: '>=12'} engines: {node: '>=12'}
pinia@3.0.4:
resolution: {integrity: sha512-l7pqLUFTI/+ESXn6k3nu30ZIzW5E2WZF/LaHJEpoq6ElcLD+wduZoB2kBN19du6K/4FDpPMazY2wJr+IndBtQw==}
peerDependencies:
typescript: '>=4.5.0'
vue: ^3.5.11
peerDependenciesMeta:
typescript:
optional: true
playwright-core@1.58.2: playwright-core@1.58.2:
resolution: {integrity: sha512-yZkEtftgwS8CsfYo7nm0KE8jsvm6i/PTgVtB8DL726wNf6H2IMsDuxCpJj59KDaxCtSnrWan2AeDqM7JBaultg==} resolution: {integrity: sha512-yZkEtftgwS8CsfYo7nm0KE8jsvm6i/PTgVtB8DL726wNf6H2IMsDuxCpJj59KDaxCtSnrWan2AeDqM7JBaultg==}
engines: {node: '>=18'} engines: {node: '>=18'}
@@ -496,6 +537,9 @@ packages:
resolution: {integrity: sha512-3Ybi1tAuwAP9s0r1UQ2J4n5Y0G05bJkpUIO0/bI9MhwmD70S5aTWbXGBwxHrelT+XM1k6dM0pk+SwNkpTRN7Pg==} resolution: {integrity: sha512-3Ybi1tAuwAP9s0r1UQ2J4n5Y0G05bJkpUIO0/bI9MhwmD70S5aTWbXGBwxHrelT+XM1k6dM0pk+SwNkpTRN7Pg==}
engines: {node: ^10 || ^12 || >=14} engines: {node: ^10 || ^12 || >=14}
rfdc@1.4.1:
resolution: {integrity: sha512-q1b3N5QkRUWUl7iyylaaj3kOpIT0N2i9MqIEQXP73GVsN9cw3fdx8X63cEmWhJGi2PPCF23Ijp7ktmd39rawIA==}
rollup@4.57.1: rollup@4.57.1:
resolution: {integrity: sha512-oQL6lgK3e2QZeQ7gcgIkS2YZPg5slw37hYufJ3edKlfQSGGm8ICoxswK15ntSzF/a8+h7ekRy7k7oWc3BQ7y8A==} resolution: {integrity: sha512-oQL6lgK3e2QZeQ7gcgIkS2YZPg5slw37hYufJ3edKlfQSGGm8ICoxswK15ntSzF/a8+h7ekRy7k7oWc3BQ7y8A==}
engines: {node: '>=18.0.0', npm: '>=8.0.0'} engines: {node: '>=18.0.0', npm: '>=8.0.0'}
@@ -505,6 +549,14 @@ packages:
resolution: {integrity: sha512-UXWMKhLOwVKb728IUtQPXxfYU+usdybtUrK/8uGE8CQMvrhOpwvzDBwj0QhSL7MQc7vIsISBG8VQ8+IDQxpfQA==} resolution: {integrity: sha512-UXWMKhLOwVKb728IUtQPXxfYU+usdybtUrK/8uGE8CQMvrhOpwvzDBwj0QhSL7MQc7vIsISBG8VQ8+IDQxpfQA==}
engines: {node: '>=0.10.0'} engines: {node: '>=0.10.0'}
speakingurl@14.0.1:
resolution: {integrity: sha512-1POYv7uv2gXoyGFpBCmpDVSNV74IfsWlDW216UPjbWufNf+bSU6GdbDsxdcxtfwb4xlI3yxzOTKClUosxARYrQ==}
engines: {node: '>=0.10.0'}
superjson@2.2.6:
resolution: {integrity: sha512-H+ue8Zo4vJmV2nRjpx86P35lzwDT3nItnIsocgumgr0hHMQ+ZGq5vrERg9kJBo5AWGmxZDhzDo+WVIJqkB0cGA==}
engines: {node: '>=16'}
tinyglobby@0.2.15: tinyglobby@0.2.15:
resolution: {integrity: sha512-j2Zq4NyQYG5XMST4cbs02Ak8iJUdxRM0XI5QyxXuZOzKOINmWurp3smXu3y5wDcJrptwpSjgXHzIQxR0omXljQ==} resolution: {integrity: sha512-j2Zq4NyQYG5XMST4cbs02Ak8iJUdxRM0XI5QyxXuZOzKOINmWurp3smXu3y5wDcJrptwpSjgXHzIQxR0omXljQ==}
engines: {node: '>=12.0.0'} engines: {node: '>=12.0.0'}
@@ -807,6 +859,24 @@ snapshots:
'@vue/devtools-api@6.6.4': {} '@vue/devtools-api@6.6.4': {}
'@vue/devtools-api@7.7.9':
dependencies:
'@vue/devtools-kit': 7.7.9
'@vue/devtools-kit@7.7.9':
dependencies:
'@vue/devtools-shared': 7.7.9
birpc: 2.9.0
hookable: 5.5.3
mitt: 3.0.1
perfect-debounce: 1.0.0
speakingurl: 14.0.1
superjson: 2.2.6
'@vue/devtools-shared@7.7.9':
dependencies:
rfdc: 1.4.1
'@vue/language-core@3.2.4': '@vue/language-core@3.2.4':
dependencies: dependencies:
'@volar/language-core': 2.4.27 '@volar/language-core': 2.4.27
@@ -848,6 +918,12 @@ snapshots:
alien-signals@3.1.2: {} alien-signals@3.1.2: {}
birpc@2.9.0: {}
copy-anything@4.0.5:
dependencies:
is-what: 5.5.0
csstype@3.2.3: {} csstype@3.2.3: {}
entities@7.0.1: {} entities@7.0.1: {}
@@ -893,20 +969,35 @@ snapshots:
fsevents@2.3.3: fsevents@2.3.3:
optional: true optional: true
hookable@5.5.3: {}
is-what@5.5.0: {}
magic-string@0.30.21: magic-string@0.30.21:
dependencies: dependencies:
'@jridgewell/sourcemap-codec': 1.5.5 '@jridgewell/sourcemap-codec': 1.5.5
mitt@3.0.1: {}
muggle-string@0.4.1: {} muggle-string@0.4.1: {}
nanoid@3.3.11: {} nanoid@3.3.11: {}
path-browserify@1.0.1: {} path-browserify@1.0.1: {}
perfect-debounce@1.0.0: {}
picocolors@1.1.1: {} picocolors@1.1.1: {}
picomatch@4.0.3: {} picomatch@4.0.3: {}
pinia@3.0.4(typescript@5.9.3)(vue@3.5.28(typescript@5.9.3)):
dependencies:
'@vue/devtools-api': 7.7.9
vue: 3.5.28(typescript@5.9.3)
optionalDependencies:
typescript: 5.9.3
playwright-core@1.58.2: {} playwright-core@1.58.2: {}
playwright@1.58.2: playwright@1.58.2:
@@ -921,6 +1012,8 @@ snapshots:
picocolors: 1.1.1 picocolors: 1.1.1
source-map-js: 1.2.1 source-map-js: 1.2.1
rfdc@1.4.1: {}
rollup@4.57.1: rollup@4.57.1:
dependencies: dependencies:
'@types/estree': 1.0.8 '@types/estree': 1.0.8
@@ -954,6 +1047,12 @@ snapshots:
source-map-js@1.2.1: {} source-map-js@1.2.1: {}
speakingurl@14.0.1: {}
superjson@2.2.6:
dependencies:
copy-anything: 4.0.5
tinyglobby@0.2.15: tinyglobby@0.2.15:
dependencies: dependencies:
fdir: 6.5.0(picomatch@4.0.3) fdir: 6.5.0(picomatch@4.0.3)

View File

@@ -1,145 +1,55 @@
import type {ActionButtonState} from "./types.ts" import type {GameState, PendingClaimState} from "../types/state";
import {wsClient} from "../ws/client.ts" import type {Tile} from "../types/tile.ts";
export function sendGameAction( /**
params: { * 游戏动作定义(只描述“发生了什么”)
type: ActionButtonState['type'] */
userID: string export type GameAction =
roomId: string // 初始化整局(进入房间 / 断线重连)
selectedTile?: string | null | {
}, type: 'GAME_INIT'
ctx: { payload: GameState
actionPending: { value: boolean } }
logWsSend: (msg: any) => void
pushWsMessage: (msg: string) => void // 开始游戏(发牌完成)
| {
type: 'GAME_START'
payload: {
dealerIndex: number
} }
): void { }
const {type, userID, roomId, selectedTile} = params // 摸牌
const {actionPending, logWsSend, pushWsMessage} = ctx | {
type: 'DRAW_TILE'
// 简单登录判断 payload: {
if (!userID) { playerId: string
console.log('当前用户未登录') tile: Tile
return
} }
}
const payload: Record<string, unknown> = {}
// 出牌 // 出牌
if (type === 'discard' && selectedTile) { | {
payload.tile = selectedTile type: 'PLAY_TILE'
payload.discard_tile = selectedTile payload: {
payload.code = selectedTile 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 type: 'PENDING_CLAIM'
roomId: string payload: PendingClaimState
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 type: 'CLAIM_RESOLVED'
roomId: string payload: {
wsError: { value: string } playerId: string
leaveRoomPending: { value: boolean } action: 'peng' | 'gang' | 'hu' | 'pass'
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
} }

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 {createApp} from 'vue'
import './assets/styles/style.css' import {createPinia} from 'pinia'
import App from './App.vue' import App from './App.vue'
import router from './router' 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', PASS: 'pass',
} as const } as const
export type ClaimOption = export type ClaimOptionState =
typeof CLAIM_OPTIONS[keyof typeof CLAIM_OPTIONS] typeof CLAIM_OPTIONS[keyof typeof CLAIM_OPTIONS]

View File

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

View File

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

View File

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

View File

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

View File

@@ -1,6 +1,6 @@
import type { Tile } from '../tile' import type { Tile } from '../tile'
export type Meld = export type MeldState =
| { | {
type: 'peng' type: 'peng'
tiles: Tile[] 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"; import type {Tile} from "../tile.ts";
export interface PendingClaim { export interface PendingClaimState {
// 当前被响应的牌 // 当前被响应的牌
tile: Tile tile: Tile
@@ -10,5 +10,5 @@ export interface PendingClaim {
fromPlayerId: string fromPlayerId: string
// 当前玩家可执行操作 // 当前玩家可执行操作
options: ClaimOption[] options: ClaimOptionState[]
} }

View File

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

View File

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