React 19 + TypeScript 实战:把 Ludo 游戏拆成纯引擎、状态层和可替换网络层

如果一个桌游项目后面可能接入联机模式,最容易踩的坑不是 UI,而是规则、随机数、状态和网络逻辑混在一起。这个 Ludo 项目的核心思路是:让规则引擎保持纯函数,把浏览器、副作用、随机数和 WebSocket 都关在边界层里。这样同一套规则今天可以在本地跑,明天也可以搬到服务端做权威校验。

技术栈与目标

项目使用 React 19、TypeScript、Vite、Zustand 和 Three.js。当前只有一个游戏,但目录结构按"多游戏宿主"设计:

txt 复制代码
src/
  App.tsx
  games/
    luddo/
      engine/
      net/
      store/
      components/
      constants/
      styles/

App.tsx 只引入 LudoGame,而 Ludo 自己的规则、组件、样式、网络和常量都收在 src/games/luddo/ 里。这样以后新增棋类、牌类或小游戏时,不需要把宿主应用改成一团全局状态。

第一层:纯规则引擎

Ludo 的引擎只暴露一个核心模型:

ts 复制代码
reduce(state, action) -> { state, events }

这个 engine/ 目录里不导入 React,不读浏览器 API,不开定时器,也不调用 Math.random()。骰子点数通过 ROLL_DICE action 注入:

ts 复制代码
case 'ROLL_DICE':
  return rollDice(state, action.payload.color, action.payload.value)

这样做有三个好处:

  1. 规则容易测试。给定旧状态和 action,就能断言新状态和事件。
  2. 本地和服务端可以复用同一套规则。服务端只需要生成骰子,再调用同一个 reducer。
  3. UI 不会偷偷改变规则。组件只能表达"我要掷骰子"或"我要移动这个棋子"。

引擎还会返回事件,比如 DICE_ROLLEDTOKEN_CAPTUREDEXTRA_TURNGAME_OVER。状态负责真相,事件负责动画、音效和提示,这两个概念分开之后,界面会清爽很多。

第二层:把外部世界收进 transport

项目里有一个 GameTransport 接口,UI 和 store 只跟它说话:

ts 复制代码
interface GameTransport {
  connect(): Promise<void>
  disconnect(): void
  startGame(options: StartGameOptions): void
  rollDice(color: PlayerColor): void
  moveToken(color: PlayerColor, tokenId: string): void
  reset(): void
  onState(listener: StateListener): () => void
  onEvent(listener: EventListener): () => void
}

本地模式下,LocalTransport 在浏览器里持有权威引擎,负责骰子随机数和机器人时机。远程模式下,RemoteTransport 只把意图发给 WebSocket 服务端:

json 复制代码
{ "t": "roll", "color": "red" }
{ "t": "move", "color": "red", "tokenId": "red-0" }

服务端再广播完整 GameState 和单个 GameEvent。这意味着客户端不会因为自己算错一步而和其他玩家不同步;最终画面永远以权威状态为准。

第三层:Zustand 只镜像状态,不改规则

store/ludoStore.ts 是 React 和 transport 之间的桥。它做的事情很克制:

  • 初始化 transport
  • 订阅 onState,把权威 GameState 放进 store
  • 订阅 onEvent,保留最近几个事件
  • startGamerollmovereset 转发给 transport

关键点是:store 不直接改棋子位置,也不自己判断谁能走。它只是一个镜像层。这个约束看起来严格,但对游戏项目很值钱,因为它能阻止状态逻辑在组件、hook、store 和网络回调之间扩散。

第四层:棋盘几何数据化

棋子位置没有直接存坐标,而是一个相对值 rel

rel 含义
-1 在基地里
0-50 公共主路径,按玩家颜色相对计算
51-55 自己的终点通道
56 到家

渲染时再通过常量表把 rel 转成棋盘坐标。安全格、起点、终点通道、15x15 单元格分类也都在 constants/board.tsconstants/cells.ts 中定义。这样规则层和渲染层读的是同一份几何数据,组件里不需要硬编码"第几行第几列是什么格子"。

规则实现细节

当前规则覆盖了常见 Ludo 玩法:

  • 2 到 4 名玩家,每人 4 枚棋子
  • 掷到 6 才能出基地
  • 到家必须精确点数,不能越过终点
  • 非安全格上的敌方棋子会被吃回基地
  • 掷到 6、吃子或棋子到家会获得额外回合
  • 连续三次 6 会失去回合
  • 第一个四枚棋子全部到家的玩家获胜

这些规则都集中在 engine/gameEngine.tsengine/moves.ts,组件层只负责展示可移动棋子、当前回合、骰子和玩家面板。

为什么这种分层适合联机游戏

很多前端小游戏一开始是本地版,后面想加联机时才发现规则和 UI 绑死了。这个项目提前把边界留好:本地和远程只是 transport 的不同实现,React 组件不用知道当前权威来源是谁。

如果要接入真正的多人房间,服务端可以直接按这个协议工作:

  1. 客户端发送 startrollmovereset 这样的意图。
  2. 服务端生成骰子并调用同一个 reduce()
  3. 服务端广播完整状态和事件。
  4. 客户端只渲染服务端推送的状态。

这个模式还有一个额外收益:防作弊。骰子和规则校验都在服务端,客户端不能随便改点数或移动非法棋子。

可复用的经验

这个项目给我的最大启发是:前端游戏并不一定要从"组件怎么写"开始设计。更稳的顺序是:

  1. 先定义可序列化的状态模型。
  2. 再写纯 reducer 和事件。
  3. 然后定义外部边界,比如 transport。
  4. 最后让 React 只消费派生视图和发送玩家意图。

这种写法会让项目在本地、联机、回放、测试甚至服务端校验之间更容易迁移。对于棋类、回合制、卡牌和桌游类项目,纯引擎 + transport 边界 + 状态镜像,是一个很实用的架构组合。

小结

React 负责交互体验,Zustand 负责连接 UI 和权威状态,TypeScript 负责把状态和动作约束清楚,而真正的游戏规则被压缩到一个纯函数引擎里。这个拆法不会让第一版更花哨,但会让第二版、联机版和测试版更好做。

如果你正在写一个前端小游戏,尤其是未来可能加多人联机的项目,可以先问自己一个问题:这套规则能不能脱离 React 独立运行?如果答案是可以,后面的架构通常会轻很多。

相关推荐
kisdiem7 小时前
ReAct:让大模型一边推理,一边行动
前端·react.js·前端框架
尽兴-8 小时前
4.1 智能体核心:Agent、Sub-Agent、ReAct、规划执行
前端·javascript·react.js·agent·react·subagent
kyrie2813 小时前
React中如何模拟vue中的watch,computer,v-model
react.js
放下华子我只抽RuiKe513 小时前
FastAPI 全栈后端(八):部署与运维
运维·数据库·react.js·oracle·数据挖掘·前端框架·fastapi
TheITSea13 小时前
一、React初体验:搭建、解析现代开发环境
前端·react.js·前端框架
光影少年14 小时前
避免不必要渲染:PureComponent、memo、useMemo、useCallback
react.js·面试·掘金·金石计划
aaaa9547266515 小时前
终端与IDE形态Vibe Coding实测:主流AI编程工具迁移与迭代对比
javascript·react.js·ecmascript
放下华子我只抽RuiKe515 小时前
FastAPI 全栈后端(七):测试与自动化
运维·前端·人工智能·react.js·前端框架·自动化·fastapi