《卫戍协议:盟约》这个名字第一次出现在项目组白板上时,旁边只写了三个技术栈词:Node.js、PixiJS、three.js。当时争论最久的问题不是玩法,而是"一个浏览器里跑的自走棋,凭什么敢同时挂两套渲染器"。这篇文章就把这套架构摊开来讲:一个联机自走棋从房间匹配、服务端权威战斗模拟,到 2D UI 与 3D 棋盘混合渲染的完整链路,中间哪些地方是踩过坑才定下来的,哪些参数是反复压测才敢写死的。适合已经会写业务代码、但对"实时联机 + 浏览器图形"这个组合还没有完整落地经验的同学,也适合正在纠结自走棋类项目该用帧同步还是状态同步的策划和技术负责人对照着看。
1. 先把"算"和"看"切开:自走棋架构的第一性判断
1.1 一局对战的时间线长什么样
自走棋这个品类有个很容易被忽视的特点:它的操作是回合制的,但它的表现是实时的。玩家在备战阶段慢慢摆棋子、算经济、看对手阵容,这时候对延迟几乎无感;可一旦进入战斗,双方棋子开始自动寻敌、放技能、掉血,屏幕上会同时滚动几十个单位的位移和几十条伤害数字,这时候任何一次卡顿都会被玩家立刻察觉。
把这两段体验拆开看,结论就很清楚了:备战阶段需要的是"可靠",战斗阶段需要的是"一致"。可靠指的是买卖棋子、刷新商店、升级人口这些操作不能丢、不能重、不能被客户端自己说了算;一致指的是双方看到的那场自动战斗必须是同一场——同一个棋子在第 3 秒打在同一个目标身上,掉同样的血,而不是各算各的。
我见过一些早期版本图省事,让客户端本地跑战斗逻辑,服务端只做结果校验。这种做法在单机或者好友房里能活,一旦上了排位就立刻出问题:客户端改个内存就能让己方棋子攻击力翻倍,而服务端因为看不到中间过程,只能事后比对最终血量,误判率极高。所以《卫戍协议:盟约》在立项第二周就定了一条铁律——战斗的一切计算在服务端完成,客户端只负责播放。
1.2 服务端权威与客户端渲染的边界划定
边界划在哪儿,直接决定了后面所有代码的组织方式。我们把整个系统的职责切成三层:
| 层级 | 归属 | 负责内容 | 不允许做的事 |
|---|---|---|---|
| 规则层 | Node.js 服务端 | 商店随机、经济结算、人口上限、战斗模拟、胜负判定 | 依赖任何客户端上报的战斗中间数据 |
| 表现层 | PixiJS + three.js | 单位位移、技能特效、血条、伤害飘字、UI 交互 | 修改任何影响胜负的状态 |
| 输入层 | 浏览器 | 拖拽、点击、快捷键 | 直接写入规则层状态 |
这条边界听起来像是教科书里的分层,实际落地时最难守的是"表现层不反向影响规则层"。举个具体例子:棋子从 A 格移动到 B 格,客户端动画需要 400 毫秒,但服务端在战斗开始的第 0 帧就已经把它算到了 B 格。如果客户端在动画播完之前收到"该单位已死亡"的消息,就会出现在半路上突然消失的观感问题。这不是架构错误,而是表现层需要自己做动画队列与状态快照的对账,这一点我们放在第 6 节细讲。
真正需要强调的是:规则层输出的不是"一帧一帧的画面指令",而是带时间戳的事件流。服务端算完一整场战斗可能只花 20 到 40 毫秒,输出的却是一份包含几百条事件的数组,每条事件形如"第 17 tick,单位 u_23 对 u_41 造成 86 点物理伤害"。客户端拿到这份事件流,按 tick 节奏回放,就能还原出完整的战斗过程。这个设计带来的好处是连锁的:
- 断线重连时不用重新算,把事件流从断点续播即可;
- 战斗回放功能天然存在,一份事件流就是一个回放文件;
- 观战模式只需要把同一份事件流分发给额外观众;
- 战斗逻辑可以被单元测试直接覆盖,不需要跑起浏览器。
1.3 为什么最后选了状态同步而不是帧同步
自走棋圈子里关于帧同步和状态同步的争论从来没停过。帧同步的好处是带宽省、回放天然、战斗一致性强,但它有一个致命前提:所有参与计算的客户端必须跑出完全相同的结果。这意味着浮点运算要严格一致、随机数种子要严格一致、遍历顺序要严格一致。只要有一个玩家的浏览器是 ARM 架构的平板,浮点舍入差了一丁点,几秒后整场战斗就会完全跑偏。
《卫戍协议:盟约》的目标平台是"任何能开浏览器的地方",这条就否掉了纯帧同步。我们选的是服务端权威模拟 + 事件流广播,本质上是"服务端算一遍,客户端放一遍"。代价是服务端要多扛算力,收益是客户端不需要为了一致性做任何妥协,甚至可以给低端设备降帧、跳帧、关特效,而不影响任何人的战斗结果。
提示:如果你的自走棋项目规模很小、只做熟人房、且能接受偶尔不同步,帧同步仍然是可行路线。但只要涉及排位、涉及跨设备、涉及反作弊,服务端权威模拟几乎没有第二条路。
2. Node.js 撑起联机房间:进程模型与状态机怎么落
2.1 单房间对象与事件循环的相处方式
用 Node.js 写游戏服务端,被问得最多的一句话是"单线程扛得住吗"。答案是:扛不扛得住跟线程数没关系,跟你有没有在事件循环里塞同步阻塞的操作有关系。
自走棋的计算密度其实很低。一局八人对战,每个回合每方棋盘上最多十几二十个单位,一场战斗的模拟步长如果是 100 毫秒一跳、单场战斗最多 60 跳,那么一场战斗的纯计算量在毫秒级。真正吃时间的是 I/O:数据库读写、Redis 操作、日志落盘。只要这几件事都走异步,单进程开几百个房间是没问题的。
我们的房间模型是一个房间对应一个纯内存对象,里面挂着八名玩家的会话引用、当前回合阶段、所有棋盘的权威快照、以及一个轻量的定时器句柄。房间对象自己不持有 socket,只持有会话 ID,所有消息出口统一走一个消息分发器。这么设计有两个好处:一是房间可以脱离连接独立存活(断线重连的基础),二是房间逻辑可以被单元测试直接 new 出来跑。
// 房间状态机的骨架,省略错误处理 class Room { constructor(id, config) { this.id = id; this.seats = new Map(); // seatIndex -> SessionRef this.phase = 'WAITING'; // WAITING / PREPARE / COMBAT / SETTLE this.round = 0; this.deadline = 0; // 当前阶段截止时间戳 this.timer = null; } enterPrepare() { this.phase = 'PREPARE'; this.round += 1; this.deadline = Date.now() + this.prepareMs(this.round); this.timer = setTimeout(() => this.enterCombat(), this.prepareMs(this.round)); this.broadcast({ t: 'phase', p: 'PREPARE', r: this.round, d: this.deadline }); } enterCombat() { this.phase = 'COMBAT'; // 关键:战斗在服务端同步算完,只输出事件流 const results = this.seats.size ? simulateAllBoards(this.snapshotBoards()) : []; this.broadcast({ t: 'combat', r: this.round, logs: results }); this.enterSettle(); } }注意enterCombat里那个同步执行的simulateAllBoards。这是整个服务端唯一允许长时间占用事件循环的地方。为了避免它拖慢其他房间,我们给它加了一个硬性预算:单房间单次模拟如果超过 30 毫秒,就打点告警;超过 80 毫秒,说明配置表或者战斗规则里出现了复杂度失控(通常是某个技能触发了链式反应),必须立刻回查。
2.2 房间生命周期状态机
房间阶段看着简单,实际坑都在"边界"上:最后一个玩家离开准备阶段、战斗中途有人掉线、结算阶段所有人都在看结果但没人点继续。我们最后定下来的状态迁移是这样的:
WAITING到PREPARE:满员或超时开局,超时开局会把空位标记为机器人接管;PREPARE到COMBAT:所有玩家点准备,或者准备计时器到期;COMBAT到SETTLE:服务端模拟完成后立刻进入,客户端这时才开始播动画;SETTLE到PREPARE:结算计时器到期,或者全员点继续。
这里有个非常反直觉的点:服务端的COMBAT阶段可能只存在 40 毫秒,而客户端的战斗动画要播 8 秒。所以服务端的阶段推进和客户端的画面进度天然不同步,必须靠"阶段提前量"来对齐。我们的做法是服务端进入SETTLE后立刻广播下一回合的备战开始时间,客户端自己决定在动画播到第几秒时解除操作锁。这样一来,动画慢的玩家也不会被系统判定为"操作超时"。
2.3 WebSocket 消息设计:少即是多
浏览器联机绕不开传输层选择。早期考虑过长连接轮询和 SSE,最后都是因为双向、低开销这两个硬需求回到了 WebSocket。
消息格式上我们纠结过 JSON 和二进制。JSON 的好处是抓包即读、调试成本极低,坏处是一条战斗事件流动辄几十 KB 的文本。最终的方案是分层:控制类消息(匹配、准备、聊天、错误)走 JSON,战斗事件流走二进制。事件流里每个字段都是固定宽度,单位 ID 用 16 位整数,伤害值用 16 位整数,事件类型用 8 位,一条事件的体积从 JSON 的 60 多字节压到 8 字节左右。
// 战斗事件:type(8) + tick(8) + srcId(16) + dstId(16) + value(16) function packEvent(buf, off, ev) { buf.writeUInt8(ev.type, off); off += 1; buf.writeUInt8(ev.tick, off); off += 1; buf.writeUInt16LE(ev.srcId, off); off += 2; buf.writeUInt16LE(ev.dstId, off); off += 2; buf.writeUInt16LE(ev.value, off); off += 2; return off; }注意:二进制协议一定要在立项早期就定好字段宽度并写进协议文档。我们中途想把单位 ID 从 16 位扩到 32 位,结果客户端和服务端各改一半,线上出现了整整两小时的错位事故。
3. 确定性战斗模拟:一场自动战斗怎么被算干净
3.1 战斗模拟的输入到底是什么
很多人以为战斗模拟的输入是"棋盘上的棋子位置",其实不够。完整输入至少包含四类东西:
- 双方棋盘上的单位完整属性快照(血量、攻击、护甲、攻速、法力、技能等级、装备);
- 双方阵容的站位坐标(格子索引);
- 全局战斗参数(回合数、是否有特殊地形或羁绊加成);
- 一个随机数种子。
第四点最容易被忽略。自走棋里大量机制依赖随机:暴击判定、闪避、目标选择时的同优先级打散、某些技能的随机目标。如果每次模拟都用真随机,那回放就对不上,断线重连也没法续算。所以我们的做法是——每场战斗生成一个种子,写进战斗日志头部,模拟过程中的所有随机都从这条种子派生的伪随机序列里取。
// 线性同余 + 每帧重播种,保证不同调用顺序下结果一致 function makeRng(seed) { let s = seed >>> 0; return { next() { s = (Math.imul(s, 1664525) + 1013904223) >>> 0; return s; }, int(n) { return this.next() % n; }, float() { return this.next() / 4294967296; } }; }这里有个细节必须强调:伪随机序列的消耗次数也必须是确定的。如果某次模拟里因为一个单位提前死亡,导致它后面的暴击判定被跳过了,那后面的随机序列就会整体错位。我们的处理是"该消耗的必须消耗"——即使目标已死,也照样调用一次next(),只是丢弃结果。这一条如果不在早期定死,后期做回放时会发现同一份日志播出来的画面完全不一样,且极难定位。
3.2 时间切片:把 60 秒战斗拆成 60 个 tick
自走棋的战斗表现看起来是流畅的,但服务端完全不需要按帧算。我们把一场战斗切成固定步长的 tick,每个 tick 是 100 毫秒,60 秒的战斗就是 600 个 tick。每个 tick 内的执行顺序是强固定的:
- 检查所有单位的持续伤害、增益、减益的剩余时间并结算;
- 每个单位按固定顺序(座位号升序)判断是否能行动,能行动就执行一次行动;
- 处理行动产生的位移、伤害、治疗、召唤、死亡;
- 检查胜负条件。
这个顺序不能改。我见过有团队为了让"死亡单位本回合仍能出手"这种细节更自然,把死亡结算挪到了帧末,结果就是不同 tick 下单位出手顺序不一致,回放直接崩。规则的表现力可以通过数值和技能设计来补,但执行顺序必须像数学公理一样钉死。
3.3 寻路和位移:用格子图而不是连续坐标
自走棋的棋盘是格子,这一点救了我们。如果用连续坐标加物理引擎,单位之间会互相挤、会卡墙、会出现浮点误差导致的穿模。用格子图之后,寻路就是标准的 BFS,格子数最多几十个,性能完全不是问题,而且结果百分之百确定。
位移的表现交给客户端做插值:服务端只告诉客户端"A 从 (3,4) 走到了 (3,5)",客户端自己决定用 300 毫秒还是 500 毫秒走过去。这样低端设备也不影响战斗结果。
3.4 战斗日志就是回放文件
一份完整战斗日志的结构大概是这样:
{ "roomId": "r_8f21", "round": 12, "seed": 3735928559, "tickMs": 100, "init": [{ "id": 23, "hp": 980, "atk": 76, "pos": [3, 4], "team": 1 }], "events": [ { "type": 1, "tick": 3, "srcId": 23, "dstId": 41, "value": 86 }, { "type": 2, "tick": 3, "srcId": 41, "dstId": 0, "value": 12 }, { "type": 7, "tick": 9, "srcId": 41, "dstId": 0, "value": 0 } ], "result": { "winner": 1, "remainHp": 62 } }一份 60 秒、双方各 20 个单位的战斗,事件数量通常在 300 到 800 条之间,二进制打包后大概 6 到 8 KB。这个体积决定了后面很多设计:观战可以实时分发、结算页可以直接把日志塞进前端缓存、断线重连只需要补发日志而不需要重算。
提示:日志里一定要记
tickMs和规则版本号。我们上线后改过一次技能伤害系数,老回放用新规则去播就会出现"伤害数字对不上血量变化"的问题,最后靠版本号做了分支回放才解决。
4. PixiJS 与 three.js 共存的渲染分层
4.1 双画布叠放与 z 轴秩序
这是整套架构里被问得最多的部分:为什么不用一套渲染器,非要同时挂 PixiJS 和 three.js。
答案藏在自走棋的 UI 复杂度里。自走棋的备战阶段本质上是一个信息密度极高的桌面应用:商店卡片、经济数字、装备拖拽、羁绊面板、对手列表、血条、倒计时、聊天框……这些元素需要精细的文本排版、大量的圆角矩形和图标、以及跟 DOM 交互习惯一致的事件处理。PixiJS 在这件事上是碾压级的:它的 2D 精灵批处理很成熟,文本渲染可控,事件系统直接支持命中测试,做复杂 UI 比在 3D 场景里贴平面要舒服太多。
而战斗阶段的棋盘和单位,我们想要透视、想要阴影、想要技能特效有纵深。three.js 在这块的能力是 PixiJS 给不了的。
所以最终的方案是:下层一张 WebGL 画布给 three.js 画 3D 棋盘和单位,上层一张 Canvas 给 PixiJS 画 UI 和 2D 覆盖层。两张画布用绝对定位叠在一起,靠 CSSz-index控制层级,PixiJS 画布默认pointer-events: none,只有需要交互的 UI 区域才把事件打开。
<div id="stage"> <canvas id="gl" style="position:absolute;inset:0;z-index:1"></canvas> <canvas id="ui" style="position:absolute;inset:0;z-index:2;pointer-events:none"></canvas> </div>这样做的好处是两套渲染器各自独立,互不干扰。坏处也很明显:它们分属两个 WebGL 上下文(如果 PixiJS 也走 WebGL 的话),显存翻倍,且无法共享纹理。关于显存的具体处理,第 6 节会展开。
4.2 three.js 侧的克制:场景里只放该放的东西
用 three.js 做游戏最容易犯的错是"什么都往场景里塞"。我们给 3D 侧定了几条硬规则:
- 场景里只存在棋盘、单位模型、地面阴影、特效粒子这四类对象;
- 所有文字(伤害数字、单位名称、血量数值)一律不在 3D 里做,交给 PixiJS;
- 光照只用一盏平行光加环境光,不开实时阴影贴图,用烘焙的假阴影贴片代替;
- 单位模型使用 InstancedMesh 的思路做批处理,同兵种共用一份几何体和材质。
第三点值得展开。实时阴影贴图在中低端笔记本上是明显的性能杀手,而自走棋的镜头视角是固定的俯视斜角,阴影几乎不会变化。所以我们直接在建模阶段把阴影烘进一张贴图,运行时只做一次简单的平面投影。视觉上的差异小到玩家根本注意不到,帧率却能提升 20% 以上。
// 单位模型池:同兵种复用几何体和材质,只换位置和动画状态 const pool = new Map(); // unitType -> { geo, mat, meshes: [] } function spawnUnit(type, pos) { const entry = pool.get(type); const mesh = entry.meshes.pop() || new THREE.Mesh(entry.geo, entry.mat); mesh.position.set(pos.x, 0, pos.z); mesh.visible = true; scene.add(mesh); return mesh; }4.3 PixiJS 侧的取舍:什么时候该降级成 DOM
PixiJS 虽然好用,但也不是所有 UI 都适合它。我们踩过一次坑:把聊天框和设置面板也做在 PixiJS 里,结果输入框的光标、文本选中、输入法候选词全都得自己实现,工作量直接失控。后来一刀切——所有需要文本输入的界面一律用 DOM 覆盖层,PixiJS 只做纯展示和简单点击。
这条规则定下来之后,开发效率肉眼可见地提升。商店卡片、羁绊图标、血条这些纯展示的东西留在 PixiJS 里享受批处理性能;聊天、改名、设置、结算详情这些带表单的东西用 DOM,样式直接用 CSS 写,改起来快得多。
4.4 两个渲染循环怎么合流
three.js 和 PixiJS 各有各的requestAnimationFrame驱动,如果放任它们各跑各的,就会出现一个问题:某一帧 three.js 已经更新完了,PixiJS 还没更新,UI 上的血条和 3D 里的单位就会差一帧,快速移动时看得出来。
解决方法是只保留一个主循环,在里面按固定顺序调用两边的更新和渲染:
let last = performance.now(); function frame(now) { const dt = Math.min(now - last, 50); // 夹住,防止切标签页回来时爆帧 last = now; updateCamera(dt); updateUnits(dt); // 3D 侧逻辑与动画混合 renderer3d.render(scene, camera); updateUi(dt); // 2D 侧 UI 动画 renderer2d.render(uiStage); requestAnimationFrame(frame); } requestAnimationFrame(frame);dt那个 50 毫秒的夹取非常关键。浏览器切到后台标签页时requestAnimationFrame会暂停,切回来的一瞬间dt可能是好几秒,如果不夹住,所有插值和缓动都会瞬间跳到底,视觉上就是"画面猛地一闪"。
注意:帧预算要设红线。我们的目标是 16.7 毫秒一帧,其中 3D 渲染不超过 8 毫秒,2D 渲染不超过 3 毫秒,逻辑不超过 2 毫秒。一旦某一项长期超标,就要开始砍特效而不是加特效。
5. 数据驱动与规则复用:配置表是这套架构的地基
5.1 配置表的组织形态
自走棋的平衡性调整频率极高,一周改几十个数值是常态。如果数值写死在代码里,每次改动都要走完整发布流程,这条路走不通。所以《卫戍协议:盟约》里几乎所有玩法数值都在配置表里:
| 表名 | 内容 | 更新频率 |
|---|---|---|
| units | 单位基础属性、技能 ID、费用、羁绊标签 | 高 |
| skills | 技能描述、数值曲线、触发条件 | 高 |
| traits | 羁绊效果、层级阈值 | 中 |
| equipments | 装备属性与合成关系 | 中 |
| rounds | 每回合的经济、人口、商店概率 | 低 |
| combat | 全局战斗参数(tick 长度、暴击公式系数) | 极低 |
配置表用表格工具维护,导出成 JSON,服务端在启动时加载进内存,客户端在登录时拉取一份版本号匹配的快照。这里有个必须提的点:服务端和客户端的配置版本必须严格一致。我们用一个configVersion字段做校验,登录时比对,不一致就直接让客户端重新拉取,绝不允许多拉。
5.2 规则代码共用:同一份逻辑两端跑的代价
最有价值也最容易出问题的设计,是把"纯函数式"的规则代码抽成一个共享模块,服务端和客户端都引用。比如"某个单位在当前羁绊下应该有多少攻击力"这个计算,服务端算战斗时要用,客户端显示面板时也要用。如果两边各写一份,迟早会不一致。
但共享模块有个隐形陷阱:它必须是无副作用、无平台依赖的纯函数。任何一次window引用、任何一次Date.now()调用、任何一次Math.random(),都会让这份代码在 Node.js 里跑出不同结果。我们的做法是给共享模块加一层静态检查,禁止在里面出现这些标识符,一旦出现直接构建失败。
// shared/rules/stats.js —— 纯函数,两端共用 export function computeAttack(unit, traitLevels) { let atk = unit.baseAtk + (unit.star - 1) * unit.atkPerStar; for (const t of unit.traits) { const lv = traitLevels[t] || 0; if (lv > 0) atk *= 1 + TRAIT_ATK_BONUS[t][lv - 1]; } return Math.round(atk); }Math.round这个细节也别小看。如果服务端用Math.round、客户端用toFixed(0),在 0.5 这种边界值上结果会不一样,显示 87 实际算 86,玩家一眼就看出来了。统一用一个封装好的取整函数,比统一"用不用 round"更重要。
5.3 配置热更新的边界在哪
配置热更新听起来很美,实际非常危险。我们的原则是:只在WAITING和SETTLE阶段允许热更,PREPARE和COMBAT阶段一律拒绝。原因很简单,一场正在进行中的战斗如果中途被换了配置,服务端的模拟结果和客户端的显示就会互相矛盾,而这种矛盾比"配置晚生效一分钟"严重得多。
具体实现上,服务端维护一个"当前房间使用的配置版本",新配置加载后不会立刻切换,而是标记为"待生效",等所有房间都退出战斗阶段后再统一切。
6. 那些文档里不会写的坑:延迟、重连与资源泄漏
6.1 服务端算得比客户端快,这本身是个问题
前面提过,服务端 40 毫秒算完的战斗,客户端要播 8 秒。这个时间差会带来一系列工程问题,其中最典型的是"操作锁提前解锁"。
具体场景:玩家在结算页面点了继续,服务端立刻进入下一回合的PREPARE并开始计时 30 秒。但客户端的战斗动画还在播,玩家实际能操作时只剩 22 秒。如果他正好在这一回合要做复杂操作,就会觉得"怎么时间这么短"。
我们的解法是给每个客户端单独维护一个表现进度偏移量。服务端广播阶段切换时,附带一个"建议解除锁定的 tick 序号",客户端根据自己动画队列的进度决定什么时候真正解锁,并把剩余时间按动画剩余时长顺延。这个顺延是客户端本地行为,不影响服务端的权威计时——因为真正的操作截止时间,服务端会按每个玩家上报的"已解锁"时间做一次宽容补偿。
6.2 弱网下的三层降级策略
移动端玩家在地铁里玩是很现实的需求。我们做了三层降级:
- 网络抖动但连接还在:客户端降低插值精度,把 3D 特效降为 2D 贴图特效,保证逻辑帧不断;
- 连接断开但能快速恢复:进入"重连窗口",服务端保留该玩家的房间席位和棋盘状态,客户端重连后拉取最新快照和缺失的事件流;
- 断开超过阈值:标记为托管,机器人按预设策略接管,玩家回来后可以重新接管。
重连窗口的长度是个需要调的参数。太短,玩家坐电梯下来就掉线了;太长,其他七个玩家要等。我们最终定的是准备阶段 45 秒,战斗阶段不设上限但战斗中掉线不影响结果——因为战斗是服务端算的,掉线玩家的战斗结果照样出,重连后直接看回放就行。
6.3 断线重连的完整链路
这套流程在线上被触发过无数次,最后固化下来的步骤是:
- 客户端重连后发送
resume(roomId, seatIndex, lastEventSeq); - 服务端校验房间还有效、席位还在、
lastEventSeq在可续播范围内(我们保留最近 5 分钟的完整事件序列); - 如果在范围内,只补发缺失部分;如果超出范围,直接下发一份完整快照;
- 客户端清空本地动画队列,用快照重建场景,再按补发的事件流继续播。
第三步的分支处理很关键。早期我们只做了"补发缺失部分",结果一次网络抖动把lastEventSeq跳了几百条,客户端硬着头皮接着播,画面直接错乱。后来加了快照兜底,问题再没出现过。
提示:重连日志一定要记三个时间点——断开时间、重连时间、快照重建耗时。这三个数字是排查"重连后画面不对"类问题的唯一线索。
6.4 显存和内存泄漏:双渲染器的代价
同时挂两套渲染器,最容易出的问题就是资源泄漏。典型症状是玩家连续玩五六局之后,浏览器变得很卡,打开任务管理器发现内存一路涨。
我们排查下来,泄漏点主要有三个:
| 泄漏点 | 表现 | 修法 |
|---|---|---|
| three.js 几何体未释放 | 每局结束后显存不降 | 复用对象池,不新建几何体 |
| PixiJS 纹理未销毁 | UI 切换次数越多越卡 | 战斗结束时统一texture.destroy(true) |
| 事件监听未解绑 | 重连次数越多越卡 | 房间对象销毁时统一清理监听器 |
第二条最隐蔽。PixiJS 的纹理默认会被缓存,同一个图标加载多次也只会占一份显存,但如果图标是动态生成的(比如带数字的羁绊徽章),每次生成都是新纹理,不销毁就一直累积。我们的做法是给所有动态生成的纹理打一个temp标记,战斗阶段结束时批量销毁。
7. 上线前值得反复过的几项压测与自查
7.1 房间并发压测要压"卡点"而不是压平均值
做压测时最容易犯的错是测"100 个房间同时开打,CPU 占用 40%",然后就觉得问题不大。这种平均值意义非常有限,因为真实场景里的负载是尖峰的:每到整点匹配高峰,大量房间会在同一个 20 秒窗口内同时进入战斗阶段。
所以压测要专门构造"同步爆发"场景:让 200 个房间在同一毫秒触发enterCombat,观察事件循环的延迟分布。我们当时测出来,200 个房间同步爆发时,第 95 百分位的模拟耗时是 18 毫秒,第 99 百分位到了 65 毫秒,超过了告警线。最后靠两件事解决:一是把模拟里的对象创建改成预分配池,二是给房间进入战斗加了一个 0 到 300 毫秒的随机抖动,把尖峰摊平。
7.2 客户端帧率下限的实验
我们把目标设备分成三档做实际测试,测出来的结果是:
- 高性能独显笔记本:稳定 60 帧,3D 渲染耗时约 4 毫秒;
- 集成显卡轻薄本:稳定 60 帧,需要关掉粒子特效,3D 渲染耗时约 9 毫秒;
- 中端安卓平板浏览器:帧率在 40 到 55 之间波动,关掉实时阴影后能稳定在 50 帧以上。
第三档是最需要照顾的。我们的策略是按设备能力自动降级:启动时用一帧的实际渲染耗时做一个简单探测,超过 12 毫秒就自动关闭阴影和高级特效,同时把 PixiJS 的resolution从devicePixelRatio降到 1。这个降级对操作体验几乎没有影响,但帧率能提升将近三成。
7.3 一份可以照着过的检查清单
把整个项目从开发到上线要过的检查项整理了一下,基本上每一轮大版本都会照着过一遍:
| 检查项 | 通过标准 |
|---|---|
| 战斗日志确定性 | 同一份日志连播 10 次,画面与血量变化完全一致 |
| 配置版本一致性 | 客户端与服务端版本不符时必定拒绝进入房间 |
| 重连成功率 | 模拟断网 3 秒后重连,成功率 100%,快照重建耗时小于 800 毫秒 |
| 内存稳定性 | 连续 10 局后堆内存增长不超过基线的 15% |
| 渲染帧率 | 目标设备上 95 分位帧率不低于 45 帧 |
| 服务端尖峰 | 200 房间同步开战时 p99 模拟耗时低于 80 毫秒 |
| 事件流体积 | 单场战斗二进制包不超过 16 KB |
7.4 关于这套架构还能怎么长
这套架构目前最大的技术债在 3D 侧的资源管理上:单位模型现在还是每局加载,如果能做到常驻缓存并按兵种分块加载,切场景的等待时间能再压掉一半。另一个想改的地方是战斗事件流的压缩,现在只是简单定长打包,如果引入按 tick 分块的差分编码,观战模式的带宽开销应该还能再降一截。
不过说到底,自走棋这类游戏的技术难点从来不在于"能不能渲染出好看的画面",而在于能不能让八个玩家在八个不同性能的设备上,看到完全一致、且都觉得自己被公平对待的一场战斗。Node.js 负责把这场战斗算得又准又快,PixiJS 负责把信息塞得又满又清楚,three.js 负责让它看起来不像个表格——三者各管一段,边界划清楚了,剩下的就是修修补补的耐心活。