Vibe Coding 多人游戏(八)—— 大重构:Monorepo + 双服务 + Logic 共享层 + 开闭原则
1. Logic 共享层:ReScript 纯函数
重构前最大的痛点:碰撞检测、移动速度计算、伤害公式,在 room-service 里有一份,在 new_basic2 的客户端里也有一份。两边代码早晚不一致。
方案: 把纯逻辑抽出来,做成独立的 packages/logic/ 包。
logic 包(ReScript 纯函数)
/ \
room-service frontend
(服务器加载) (浏览器加载)
关键约束:
- 纯函数:输入 → 输出,无副作用
- 无 IO:不碰网络、不碰文件、不碰 DOM
- 无状态:全局状态外层管理,logic 只看当前帧数据
为什么用 ReScript?
ReScript(前身 BuckleScript/ReasonML)默认不可变,模式匹配优雅,编译产物极小(49KB,gzip ~15KB)。@genType 注解自动生成 .gen.tsx 类型定义,TypeScript 可以直接 import。
/* Movement.res — 纯 ReScript 写的移动逻辑 */ let computeSpeed = (characterType: string): float => { characterType === "giantess" ? 3.0 : 1.0 }
let executeCommand = (state: gameState, command: command): gameState => {
switch command.cmdType {
| Move => { ...state, position: computeNewPosition(state, command) }
| Attack => { ...state, hp: state.hp - computeDamage(state, command) }
}
}
logic 包包含的核心函数:
| 函数 | 用途 |
|---|---|
executeCommand |
执行移动/攻击等命令 |
isCollision |
碰撞检测 |
computeSpeed |
不同角色类型速度计算 |
computeCollisionDamage |
碰撞伤害计算 |
getCollisionBox |
模型碰撞盒配置 |
getMaxHp |
角色最大血量 |
这些函数在服务端是权威计算,在前端是本地预测的参考——两端用完全同一份代码,不存在"服务端和客户端算法不一致"的问题。
插曲:函数式编程在多人联网中的应用
logic/ 包用 ReScript(函数式语言)写的,这不是偶然——多人共享逻辑天然适合函数式范式,面向对象是反模式的。
多人联网的核心问题是:同一份代码在两个不同的运行时(Node + 浏览器)里跑,结果必须一致。
纯函数
/* ✅ 纯函数:输入 → 输出,无副作用 */
let isCollision = (a: player, b: player): bool => {
let dx = a.x -. b.x
let dz = a.z -. b.z
sqrt(dx *. dx +. dz *. dz) < 1.0
}
/* ❌ 面向对象:方法依赖对象内部状态,外部不可控 */
class CollisionSystem {
private players: Player[]
checkCollision(a: Player, b: Player): boolean {
// this.players 可能在调用间被修改!
}
}
纯函数的好处:给定同样的输入(两个玩家坐标),永远返回同样的输出(是否碰撞)。不依赖 this、不依赖全局变量、不依赖时间——这些正是帧同步出问题的根源(同一个函数在不同客户端跑出不同结果)。
接受和返回 State
面向对象传递状态的方式是 this.state = newState——方法不返回值,只改变内部状态。多人联网里这条路走不通:服务端改了状态,客户端怎么同步?
/* ✅ FP 风格:接受 state,返回新 state */ let executeCommand = (state: gameState, cmd: command): gameState => { switch cmd.cmdType { | Move => { ...state, position: computeNewPosition(state, cmd) } | Attack => { ...state, hp: state.hp -. computeDamage(state, cmd) } } }
// 调用方管理状态流转
let newState = executeCommand(oldState, moveCommand)
broadcastMsgGameState(newState)
/* ❌ OOP 风格:方法不返回值,直接改内部状态 */
class Game {
private state: GameState
executeCommand(cmd: Command) {
if (cmd.type === 'Move') {
this.state.position = computeNewPosition(cmd) // 改了内部
}
}
}
FP 模式的关键优势:state 在每次调用后被显式返回,调用方可以自由决定是否广播、是否持久化、是否做快照。 服务端可以在每帧打包 state 广播给所有客户端——因为在纯函数式模型里,state 就是数据,不是一个对象的私有财产。
不可变数据
多人状态经过网络传输后是“拍平”的 JSON 对象。如果某个玩家改了状态后原地 mutate,其他端收到的就是脏数据。
FP 用不可变数据天然解决了这个问题——每次变更都产生新对象,旧状态作为历史快照自动保留。这在回滚(rollback)、重放(replay)、断线重连场景下极为省心。
/* ✅ 不可变:... 扩展语法创建新状态 */ { ...state, position: newPos }
/* ❌ 可变:直接改,其他引用全崩 */
state.position = newPos // 别的地方还在用 oldState!
函数组合
多人联网的逻辑是一个流水线:接收入站命令 → 执行 → 碰撞检测 → 伤害计算 → 广播。FP 用函数组合天然表达这个流程:
/* ✅ 函数组合:流水线清晰可见 */
let tick = (state: gameState, commands: list<command>): gameState => {
state
|> executeAllCommands(commands)
|> detectCollisions
|> applyDamage
|> cleanupDeadPlayers
}
如果用 OOP,同样的流程会散落在多个 manager 类的方法里,调用关系靠事件监听和回调串联,代码逻辑的流转路径肉眼难以追踪。
柯里化
柯里化(Currying)在配置函数时特别有用:
/* 柯里化:先把固定参数绑定好 */ let computeSpeed = (characterType: string): (float) => float => { let baseSpeed = characterType === "giantess" ? 3.0 : 1.0 // 返回新函数,只需要移动距离这一个参数 (distance) => baseSpeed *. distance }let giantessSpeed = computeSpeed("giantess")
let littlemanSpeed = computeSpeed("littleman")
// 使用:只需要传入当前帧的距离
giantessSpeed(0.5) // 1.5
littlemanSpeed(0.5) // 0.5
在 OOP 里这通常要用策略模式或者工厂模式来实现——类+接口+多态,复杂度指数级上升。
为什么 OOP 在多人共享逻辑里不好用
不是说 OOP 不好,而是多人共享逻辑的核心需求恰好是 OOP 的弱点:
| 维度 | FP 做法 | OOP 做法 | 多人场景的影响 |
|---|---|---|---|
| 状态管理 | 输入 state → 输出 state | 方法修改内部状态 | FP 模式下 state 可以直接序列化广播 |
| 无副作用 | 纯函数,不碰外部 | 方法依赖 this/成员变量 | FP 保证两端执行结果一致 |
| 可测试性 | 给输入测输出,零 mock | 需要 mock 对象/模拟 DI | FP 测试代码量少 3-5 倍 |
| 并发安全 | 不可变数据天然安全 | 锁 + 互斥 | 多人服务端高并发天然利好 FP |
| 跨运行时 | 数据是普通 JSON/Record | 对象序列化麻烦(循环引用) | FP 状态直接 JSON.stringify 就能发 |
最实际的差异体现在测试上。测试一个 FP 的 executeCommand:
// FP 测试:造输入 → 调函数 → 断言输出
test('move command updates position', () => {
const state = createInitialState()
const cmd = { type: 'Move', x: 10, z: 10 }
const newState = executeCommand(state, cmd)
expect(newState.position.x).toBe(10)
})
如果要测试同功能的 OOP 版本,需要:实例化 Game 对象 → 可能 mock 数据库 → 可能 mock 网络层 → 调用方法 → 再读出内部 state 验证。
引用一句社区经验:"FP 让不可能的事变得困难,让困难的事变得可能。" 在多人共享逻辑这个领域,函数式范式不只是一个风格选择,而是直接解决了面向对象解决不了的问题——让同一份代码在两个不同的运行时产生完全相同的结果。
FP 的连锁效应:为多线程、WebGPU 和 SoA 铺路
函数式设计的影响不止于共享逻辑。后面几期会详细讲到的三个方向,它们的可实行性都跟这一天的 FP 决策直接相关:
多线程(Worker)
FP 加不可变数据最被低估的价值是天然线程安全。每个 worker 拿到自己的 state 快照,处理后返回新 state,主线程合并。零锁、零竞态、零数据竞争。
换成 OOP:每个线程里都有一个 Game 对象,方法改了内部 this.state。不加锁数据坏掉,加了锁性能回到单线程。FP 零锁的原因是:数据是值(value),不是可变的共享引用。
WebGPU Compute Shader
WebGPU 的 compute shader 本质上是纯函数:输入一个 buffer,输出一个 buffer。读取输入数组,写入输出数组——跟 FP 的 state -> newState 一模一样。
如果逻辑层已经是 FP 风格,迁移到 GPU 并行计算的难度大幅降低:players.map(updateOnePlayer) → dispatchWorkgroups(positionsBuffer)。对象继承树和虚函数表无法映射到 GPU。
FP 风格: state |> computePhysics |> computeCollisions |> broadcast
GPU 映射: buffer |> computeShader0 |> computeShader1 |> copyToCPU
SoA(Structure of Arrays)
FP 的不可变数据天然符合 SoA 内存布局:
// AoS(Array of Structures)— OOP 风格 interface Player { position: { x: number, y: number, z: number } velocity: { x: number, y: number, z: number } hp: number } let players: Player[] = [...]
// SoA(Structure of Arrays)— FP 风格,纯数据
let positions = new Float32Array(playerCount * 3) // 连续内存
let velocities = new Float32Array(playerCount * 3) // 连续内存
let hp = new Float32Array(playerCount) // 连续内存
SoA 的好处:
- Cache locality:处理 position 时只遍历 position 数组,不踩到 hp/velocity 的缓存行
- 零 GC:TypedArray 不在堆上分配,不触发 GC
- GPU 就绪:Float32Array 可以直接上传到 GPU StorageBuffer,无需转换格式
- 分支预测:计算 hp 时不混入 position 的读取
FP 让 SoA 变得自然,因为 FP 里数据是纯值(value),不是封装了方法的高耦合对象(object)。把一个 Player 对象的字段拆成几个 TypedArray,在 OOP 里需要大改——方法怎么调?封装在哪?FP 里本来就是 executeCommand(state, cmd),state 是 {positions, velocities, hp} 几个数组,SoA 跟 FP 风格天然相容。
这三件事——多线程、WebGPU、SoA——在 P8 阶段一个都没做,但这一天的 FP 决策让它们全部变成了「只要想做就能做」。相反,如果在 06-09 选了 OOP + 可变状态,后面任何一个方向的迁移都会需要「全部重写」。
2. 双服务架构:basic1 时期就有的设计
早在 basic1 阶段,多人联机就已经是双服务架构——不是说到了重构才拆出来的。
basic1 的时期服务端是 demos/backend/,里面包含了:
- room-service 角色:处理游戏循环、状态同步、碰撞检测(对应
packages/room-service/) - match-service 角色:处理房间创建、匹配、列表查询(对应
packages/match-service/)
为什么这么早就是双服务?因为这两个服务的生命周期和协议不同:
- 游戏服务需要 WebSocket 长连接,实时推送状态
- 匹配服务只需要 HTTP 请求-响应,不需要保持连接
混在一起会让两个职责互相干扰——改匹配逻辑要重启游戏服务、游戏服务的 WebSocket 连接数影响匹配查询性能。所以从一开始就分开了。
这次重构做的事情,是把 demos/backend/ 中已有的职责拆成两个独立包:
# 之前 demos/backend/(room 逻辑 + match 逻辑混在 Main.ts 里)之后
packages/room-service/(WebSocket, 4003)
packages/match-service/(HTTP, 3000)
通信链路:
浏览器
├── WS → packages/room-service(实时游戏状态)
└── HTTP → packages/match-service(创建/查找房间)
│
WS 监听 room-service 状态
全链路类型安全靠 TSRPC 的 serviceProto.ts 维持——改一个协议文件,所有引用端 TypeScript 编译全崩,问题在部署前就暴露了。
3. Monorepo:本来就是 monorepo
GTS-Play 在单机时期就已经是 Lerna monorepo 结构了——多个包(frontend、mods、defaults、asset-lib……)放在同一个仓库里,用 yarn workspaces 统一管理依赖。这不是为多人联机新引入的架构,而是给已有的 monorepo 新加了几个包。
之前 demos/basic1/ 和 demos/new_basic2/ 靠 ../../../ 跨目录引用类型,改了目录结构就断一片。搬进 packages/ 之后:
// 以前 import { MsgGameState } from "../../../packages/room-service/src/..."
// 以后
import { MsgGameState } from "room-service/src/shared/protocols/MsgGameState"
借助 yarn workspaces,packages/room-service/ 自动链接到 node_modules/room-service/。
packages/
├── frontend/ ← 单机版前端(零改动)
├── room-service/ ← 游戏服务端(WebSocket, 4003)
├── match-service/ ← 匹配服务端(HTTP, 3000)
└── logic/ ← 共享逻辑包(ReScript 纯函数)
坑:循环依赖。 初始化时几个包互相引用形成了环。解决方式是画依赖图,强制单向链:
logic(最底层,零依赖)
↑
room-service(依赖 logic 纯函数)
↑
frontend(依赖 room-service 协议类型 + logic 类型)
↑
match-service(最上层,依赖 room-service 房间状态)
4. 开闭原则:单机零改动
架构定型了,但还有一个约束没解决:单机版「巨大娘的玩耍」已经是一个成熟的商业产品,不能因为多人功能去改它。
开闭原则——对扩展开放,对修改关闭。
frontend/src/
├── scene3d_layer/ ← 单机代码(零改动!)
├── business_layer/
│ └── multiplayer/ ← 多人代码(只新增)
├── render_layer/
│ ├── Render.ts ← 单机渲染
│ └── MultiplayerRender.ts ← 多人渲染(新增)
├── render_interface/ ← IRenderer 抽象层
└── logic_layer/ ← 共享逻辑
核心规则:单机代码文件一行不改。 所有多人功能放在 business_layer/multiplayer/ 目录下。多人状态全部放在 state.multiplayer 子字段——单机代码不看这个字段,不会冲突。
多人退房时,模块级 dispose 负责清理所有资源:clearInterval、WS 连接、多人渲染器。
这个约束在 AI 协作时代尤其重要。传统开发中开闭原则是"好代码"的加分项,在 AI 协作中它是必需品——因为 AI 不确定什么不能改,给它一个 5000 行的大文件,它很可能把不相干的地方改出 bug。目录隔离之后,AI 只能在 business_layer/multiplayer/ 里写代码,单机代码它碰不到。
开闭原则 = AI 协作时代的第一架构约束。
总结
| 决策 | 解决什么问题 | 关键要点 |
|---|---|---|
| Logic 共享层 | 前后端逻辑不一致 | ReScript 纯函数 49KB |
| 双服务拆分 | 职责清晰化 | WS + HTTP 双模通信,从 demos/backend 拆出 |
| Monorepo 扩展 | 已有结构加新包 | Lerna + yarn workspaces |
| 开闭原则 | 单机多人共存 | 隔离目录 + dispose |
2026-06-09 这一天奠定了整个项目接下来所有迭代的架构基础。后面几周干的全是"在这个架子上加功能",没有再动过架构本身。
接下来是漫长的 迭代开发阶段——Tick Loop、凸包碰撞、状态管理演进、BDD 测试体系,持续了整整两周。
浙公网安备 33010602011771号