把大富翁游戏逻辑放进 Web Worker:命令总线、阶段系统与回合状态机

多人回合制游戏需要处理规则、输入、动画和网络消息。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 的执行顺序固定为:

  1. 运行 before 修饰器;
  2. 调用命令处理器;
  3. 运行 after 修饰器;
  4. 按 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、超时和清理路径;进程状态、规则状态和界面操作状态分别维护。这样新增一个命令或回合阶段时,可以在对应层扩展,而无需改动渲染组件或重写整个回合循环。