多人回合制游戏需要处理规则、输入、动画和网络消息。MineMonopoly 将核心规则放在 Web Worker 的 GameProcess 中运行,Vue 界面负责展示,房主侧 Room 负责连接 Worker 与房间通信。这样,规则推进不依赖组件生命周期,也便于集中处理暂停、超时、断线和调试。
Worker 的 WorkerState 管理进程生命周期,GameProcess 管理游戏规则,客户端 Pinia 的 TurnState 管理界面可操作性。
Worker 作为规则执行边界
Worker 启动后发送 WorkerReady。主线程通过 WorkerCommType.LoadGameInfo 发送地图、游戏设置、用户列表、房主 ID、AI 配置和初始化会话 ID。入口使用类型化消息:
self.addEventListener("message", async (ev) => {
try {
await handleMessage(ev.data as WorkerCommMsg);
} catch (error) {
reportWorkerError(error, "Message Handler Error");
}
});
WorkerCommMsg 由 WorkerCommDataTypeMap 生成联合类型,游戏对象和函数留在 Worker 内部。
收到初始化消息后,Worker 创建 GameProcess 并调用 start()。构造阶段会规范化地图和阶段数据,建立路径关系,编译动态脚本所需类型声明,并创建玩家、地产和机会卡容器。初始化顺序是:初始化玩家和地产,运行游戏初始化阶段;有存档时恢复快照并运行 postRestore;广播 GameInit,等待客户端回传 GameInitFinished;发送 GameProcessReady,再进入游戏循环。初始化屏障避免客户端尚未完成资源加载时推进回合。
命令总线与阶段系统
玩家和地产都有 CommandBus。initPlayers 为移动、传送、掷骰子和回合命令注册处理器,处理器负责动作本身并返回结果。
CommandBus.execute 的执行顺序固定为:
- 运行
before修饰器; - 调用命令处理器;
- 运行
after修饰器; - 按
skip、consume等上下文规则衰减修饰器。
上下文还提供 cancel 和 setResult。没有处理器时抛出明确错误,修饰器可以在处理前取消命令或覆盖结果。因此折扣、额外移动等规则可以独立叠加。回合命令处理器通过 eventBus 发出回合事件。
地图阶段由 GamePhase 表示。阶段构造时编译 initEventCode 并建立事件队列,use("before", fn) 插入队首,use("after", fn) 追加到队尾。运行阶段时,GameProcess.runGamePhase 将阶段事件和游戏结束检查事件压入 GameRuntimeStack:
this.gameRuntimeStack.push(
...[checkGameOverEvent, ...phase.getEventQueue().reverse()]
);
await this.gameRuntimeStack.run(context, this);
运行时栈采用后进先出;阶段代码可用 nextTick 或 pushEventToStack 追加事件。当前回合玩家破产时会清空剩余事件。
顺序游戏循环与异步输入
gameLoop 是一个由 Worker 独占的异步协调器。它不使用多个定时器分别推进回合,而是通过 await 保持规则的先后关系;等待玩家输入时,当前阶段暂停,Worker 仍可接收主线程发来的操作消息。输入被 OperateListener 消费后,挂起的 Promise 恢复,阶段继续执行。
每轮循环的顺序是固定的:先执行回合开始阶段,再按玩家集合逐一处理玩家回合,最后执行回合结束阶段。玩家集合通过 Array.from(this.players.values()) 在本轮开始时形成遍历序列。单个玩家的处理还要经过暂停、跳过回合和破产检查:暂停计数大于零时,Worker 执行 player.round.skip 并减少计数;破产玩家直接跳过;可行动玩家才会触发 player.round.start,设置 currentRoundPlayer,广播 RoundTurn 和最新的 GameData,然后执行该玩家的阶段列表。
玩家阶段执行完成后,Worker 清空 currentRoundPlayer。如果玩家在阶段中破产,循环会先调用 checkGameOver(),确认游戏是否结束;如果游戏尚未结束,则继续处理下一位玩家,但不会再发送该玩家的 player.round.end。只有正常完成阶段的玩家才会执行回合结束命令。所有玩家处理完毕后,Worker 执行 gameRoundEnd 阶段并发出 game.round.end 事件。
可以用下面的伪代码概括这条流程:
async function gameLoop() {
broadcastGameData();
while (!isGameOver) {
await waitIfPaused();
emit("game.round.start");
await runPhases(roundStartPhases);
for (const player of playersInCurrentRound) {
await waitIfPaused();
if (shouldSkip(player)) {
await player.commandBus.execute({ type: "player.round.skip" });
continue;
}
if (player.isBankrupted) continue;
await player.commandBus.execute({ type: "player.round.start" });
currentRoundPlayer = player;
broadcastRoundTurn(player.id);
for (const phase of player.roundPhases) {
await runPhase(phase, { currentRoundPlayer: player });
if (player.isBankrupted) break;
}
currentRoundPlayer = null;
if (player.isBankrupted) {
await checkGameOver();
if (isGameOver) break;
continue;
}
await player.commandBus.execute({ type: "player.round.end" });
}
await runPhases(roundEndPhases);
emit("game.round.end");
}
}
对应的控制流可以简化为:
开始一轮
↓
等待恢复
↓
回合开始阶段
↓
还有未处理玩家?
├─ 否 → 回合结束阶段 → game.round.end → 下一轮
└─ 是
↓
需要跳过或已破产?
├─ 是 → 下一位玩家
└─ 否
↓
round.start
↓
广播当前玩家
↓
执行玩家阶段
↓
玩家破产?
├─ 是 → 检查游戏结束
│ ├─ 是 → 结束循环
│ └─ 否 → 下一位玩家
└─ 否 → round.end → 下一位玩家
输入如何回到顺序流程
一个需要玩家决策的阶段通常按以下顺序工作:阶段代码先向目标玩家发送对话框、选择器或按钮请求,再调用 oncePlayerOperationAsync 等待结果。真实玩家的等待最终落到 OperateListener.onceAsyncWithTimeout;AI 玩家则在相同的接口下转入 AI 决策或预处理代理。因此阶段代码不需要区分“输入来自 Vue”还是“输入来自 AI”,只处理一个异步结果。
等待器创建监听器和计时器,并可接收调用方提供的 timeoutId 与 match 函数。主线程把用户操作封装为 WorkerCommType.EmitOperation 消息,Worker 收到后调用 emitPlayerOperation,监听器检查玩家、操作类型和匹配条件。匹配成功后,监听器移除自身、清理计时器并解析 Promise;未匹配的同类操作不会结束当前等待。这样可以避免多个选择器同时打开时,旧响应被错误消费。
超时路径与成功路径使用同一套清理逻辑。计时器到期后,监听器被移除,超时回调收到 timeoutId、玩家、操作类型和总时长,Promise 返回调用方指定的默认值。阶段代码可以据此执行跳过回合、取消操作或使用兜底选择。游戏暂停时,waitIfPaused 挂起规则循环,操作计时器也会暂停;恢复后按照剩余时间继续计时。
长时间操作由 runLongOperation 包装。它设置 isProcessingLongOperation 并更新当前事件名称,阶段完成或抛出异常后通过 finally 恢复标记。房主侧的心跳和调试信息可以据此判断 Worker 当前是否正在等待输入、执行动画或运行地图脚本。
机会卡动画遵循同样的顺序边界:Worker 先广播动画 ID,等待客户端发送 AnimationComplete,再调用机会卡的效果函数。动画期间不会提前修改后续规则状态,效果执行完成后才回到当前阶段。顺序循环因此保留了清晰的因果关系,同时允许输入、动画和网络消息以事件形式异步返回。
客户端回合状态机
apps/client/src/store/index.ts 的 TurnState 包含五种状态:
WAITING_TURN:等待其他玩家;MY_TURN:当前玩家可以操作;ANIMATING:动画处理中;TIMEOUT:操作超时;BANKRUPTED:玩家破产。
收到 RoundTurn 或初始化数据后,Store 根据玩家 ID 调用 changeTurn。开始动画时先保存原状态,再切到 ANIMATING;失败可恢复,成功则等待服务器的新回合或游戏数据消息。破产状态优先级更高,即使仍是自己的回合也会变为 BANKRUPTED。canRoll 和 canUseCard 只有在 MY_TURN 时为真。
实际回合顺序由 Worker 决定。
生命周期与可复用经验
Worker 的 setGamePaused 同时暂停操作计时器,并让游戏循环在 waitIfPaused 处等待恢复。主线程 Room 维护 WorkerState,处理初始化超时、心跳异常和资源清理。
Worker 捕获未处理异常和 Promise 拒绝,错误报告可以附带调试快照;心跳报告当前回合和忙状态。游戏结束时广播 GameOver 并销毁资源。
这套实现的经验可以概括为四点:规则状态与 UI 解耦;命令、修饰器、阶段和事件栈各司其职;每个异步等待都有唯一 ID、超时和清理路径;进程状态、规则状态和界面操作状态分别维护。这样新增一个命令或回合阶段时,可以在对应层扩展,而无需改动渲染组件或重写整个回合循环。