3.1 快照与增量同步
第 2 章的帧同步把「确定性」当成唯一地基:所有客户端跑同一份输入流、同一个定点数模拟,于是结果天然一致。代价是每个客户端都得跑完整模拟,一个逻辑分叉就能让两边飘到天上去。本章换一条路——状态同步:服务端跑唯一一份权威模拟,定期把「谁在哪、朝哪走」的结果下发给客户端,客户端不再自己算,只负责把收到的状态画出来。
这条路把确定性难题从「N 个客户端」收拢到「1 个服务端」,代价是带宽:模拟结果每秒要下发几十次。所以本节要解决的核心问题是——怎样用尽量少的字节,把服务端的权威状态送到客户端。站内 game/server/ 那 315 篇与书稿《游戏服务端编程实践》43 篇讲的是服务端怎么算、怎么扩;本节只讲客户端这一侧怎么收、怎么用,服务端只给消息格式。
3.1.1 快照、增量与「基线」
先定义三个词,后面全篇都靠它们对齐:
- 快照(snapshot):某一逻辑帧上,服务端认定的权威状态。
- 全量快照:把范围内所有实体的所有字段都写进去。
- 增量 diff:只写「相对某个基线帧发生了变化的字段」。
- 基线(baseline):增量所参照的那一帧。客户端必须先拥有基线,才能把 diff 叠上去。
关键约束在最后一条:diff 不能脱离基线单独理解。服务端发「实体 7 的 x 变成 320」,前提是客户端手里实体 7 的 x 还是旧值。如果那个旧值客户端根本没收到(丢包),这条 diff 叠上去就是错的。所以增量方案必须配套一套「基线确认」机制,3.1.7 会讲。
3.1.2 全量还是增量:一张取舍表
不要一上来就上增量。增量的每一分带宽都换来一份复杂度,得先看这笔账划不划算:
| 维度 | 全量快照 | 增量 diff |
|---|---|---|
| 每包字节 | 与实体数成正比,恒定 | 与「本帧变化的字段数」成正比,波动大 |
| 实现复杂度 | 低:直接序列化整张表 | 高:要维护基线、比对、ack |
| 丢包容忍 | 高:丢一包,下一包自愈 | 低:丢一包,后续 diff 全部错位 |
| 状态恢复 | 简单:收一包即完整 | 复杂:需先补一次全量 |
| 适合场景 | 实体少、变化大、帧率低 | 实体多、变化稀疏、带宽敏感 |
对《方块冲刺》的双人竞速,实体总数很少(两名玩家 + 若干道具 + 特效),且位置每帧都在变——「变化稀疏」这个增量最大的前提根本不成立。所以卷四的默认策略是:常态走增量,但每隔 N 帧(例如每 2 秒)强制一次全量基线。全量负责「兜底自愈」,增量负责「省下大部分帧」。
3.1.3 实体表:id、字段、脏标记
要让「比对生成 diff」可跑,状态得先有结构。我们用一张实体表表达世界:实体 id 是主键,字段是一组数值。之所以只放数值,是因为数值能直接进二进制打包(3.3 会用到),字符串等复杂结构另走事件通道。
字段的「脏」不需要单独存一个布尔标记——上一帧的实体表本身就是脏标记:把这一帧和上一帧逐字段一比,不相等的就是脏字段。这样少维护一份状态,也就少一处可能不同步的地方。
3.1.4 L1:生成 diff 与应用 patch
下面这份纯 TypeScript 是本节的核心,零 cc 依赖,直接单测可跑:
// mini2d/net/snapshot.ts —— 纯 TS,无 cc 依赖
export type EntityId = number;
export type FieldValue = number;
export interface EntityState {
id: EntityId;
fields: Record<string, FieldValue>;
}
/** 世界状态:实体 id -> 实体状态 */
export type WorldState = Map<EntityId, EntityState>;
export interface EntityDiff {
id: EntityId;
/** 本帧发生变化的字段;全量快照时包含全部字段 */
changed: Record<string, FieldValue>;
}
export interface Snapshot {
/** 服务端逻辑帧号 */
frame: number;
/** true = 全量基线;false = 相对 baseFrame 的增量 */
full: boolean;
/** 增量所基于的基线帧号;全量时为 -1 */
baseFrame: number;
/** 新增或更新的实体 */
changed: EntityDiff[];
/** 本帧被移除的实体 id */
removed: EntityId[];
}
/** 生成一帧全量基线 */
export function captureFull(frame: number, world: WorldState): Snapshot {
const changed: EntityDiff[] = [];
for (const [id, ent] of world) {
changed.push({ id, changed: { ...ent.fields } });
}
return { frame, full: true, baseFrame: -1, changed, removed: [] };
}
/** 相对 prev 生成增量:逐字段比对,只收变化项 */
export function makeDelta(
baseFrame: number,
frame: number,
prev: WorldState,
next: WorldState,
): Snapshot {
const changed: EntityDiff[] = [];
for (const [id, ent] of next) {
const before = prev.get(id);
if (!before) {
changed.push({ id, changed: { ...ent.fields } }); // 新实体:全字段
continue;
}
const fields: Record<string, FieldValue> = {};
for (const key of Object.keys(ent.fields)) {
if (before.fields[key] !== ent.fields[key]) {
fields[key] = ent.fields[key];
}
}
if (Object.keys(fields).length > 0) {
changed.push({ id, changed: fields });
}
}
const removed: EntityId[] = [];
for (const id of prev.keys()) {
if (!next.has(id)) {
removed.push(id);
}
}
return { frame, full: false, baseFrame, changed, removed };
}
/** 把快照(全量或增量)叠加到客户端本地状态上,返回新状态 */
export function applySnapshot(state: WorldState, snap: Snapshot): WorldState {
const out: WorldState = new Map();
for (const [id, ent] of state) {
out.set(id, { id, fields: { ...ent.fields } });
}
for (const id of snap.removed) {
out.delete(id);
}
for (const diff of snap.changed) {
const existing = out.get(diff.id);
const fields = existing ? { ...existing.fields } : {};
for (const key of Object.keys(diff.changed)) {
fields[key] = diff.changed[key];
}
out.set(diff.id, { id: diff.id, fields });
}
return out;
}
三个函数分工清楚:captureFull 产出基线;makeDelta 做「上一帧 vs 这一帧」的逐字段比对;applySnapshot 把结果叠上去。注意 applySnapshot 不改原状态,而是返回一份新的——这不是洁癖,是因为客户端在预测/回滚场景里要保留历史快照(第 4 章要用),原地改会把这些历史毁掉。
3.1.5 vitest:增量必须等于全量
「增量省了带宽」是手段,不是目的;目的是结果和全量一模一样。所以第一条断言就是把这两个口径钉死:
import { describe, expect, it } from 'vitest';
import { applySnapshot, captureFull, makeDelta, type WorldState } from './snapshot';
function world(entries: Array<[number, Record<string, number>]>): WorldState {
const w: WorldState = new Map();
for (const [id, fields] of entries) {
w.set(id, { id, fields });
}
return w;
}
function normalize(w: WorldState): Array<[number, Record<string, number>]> {
return [...w.entries()]
.map(([id, e]) => [id, e.fields] as [number, Record<string, number>])
.sort((p, q) => p[0] - q[0]);
}
describe('snapshot diff/patch', () => {
it('应用增量 == 直接全量覆盖', () => {
const a = world([[1, { x: 0, y: 0 }], [2, { x: 10, y: 10 }]]);
const b = world([[1, { x: 3, y: 0 }], [2, { x: 10, y: 4 }], [3, { x: 0, y: 0 }]]);
const delta = makeDelta(1, 2, a, b);
const patched = applySnapshot(a, delta);
const full = applySnapshot(a, captureFull(2, b));
expect(normalize(patched)).toEqual(normalize(full));
// 且只发了真正变化的字段
expect(delta.changed.find((d) => d.id === 1)?.changed).toEqual({ x: 3 });
});
it('丢一个 diff 包后,下一帧全量基线能恢复', () => {
const a = world([[1, { x: 0, y: 0 }]]);
const b = world([[1, { x: 3, y: 0 }]]);
const lost = makeDelta(1, 2, a, b); // 这个增量包在网络里丢了
void lost;
// 客户端仍停在 a;服务端下一轮下发第 3 帧全量基线
const recovered = applySnapshot(a, captureFull(3, b));
expect(recovered.get(1)?.fields.x).toBe(3);
});
});
第一条断言证明「增量不丢信息」,第二条证明「全量基线能自愈」。第二条尤其重要:增量方案的正确性最终靠的是「定期全量」这条退路,而不是指望网络不丢包。
3.1.6 基线确认:ack 之后 diff 才成立
服务端发出增量后,怎么知道客户端手里的基线是对的?答案是确认(ack):
- 服务端发全量基线,帧号
frame = F。 - 客户端收下、应用、回一条 ack:
{ ackFrame: F }。 - 服务端收到 ack 前,下一帧仍发全量;收到后,从
F+1起改发以F为基线的增量。 - 若连续若干帧收不到 ack,服务端回退,重新发全量。
规则一句话:服务端只对「已被 ack 的帧」发增量。这样任何一条增量在发出时,其基线都已确认在客户端手里,丢包只会丢「变化」,不会丢「基线」。
3.1.7 《方块冲刺》快照消息格式
落到工程里,服务端每帧下发的快照消息约定如下(服务端实现见站内《游戏服务端编程实践》与 game/server/ 专题,本节只给格式):
| 字段 | 类型 | 含义 | 方向 |
|---|---|---|---|
type | string | 固定 "snapshot" | S → C |
frame | uint16 | 本帧逻辑帧号 | S → C |
full | bool | 是否全量基线 | S → C |
baseFrame | uint16 | 增量基线帧号;全量时为 0 | S → C |
entities[] | array | 变化实体列表 | S → C |
entities[].id | uint8 | 实体 id | S → C |
entities[].fields | map | 变化字段名 → 新值 | S → C |
removed[] | uint8[] | 本帧移除的实体 id | S → C |
ackFrame | uint16 | 客户端确认的基线帧号 | C → S |
一条增量消息的 JSON 形态(真实传输走 3.3 的二进制,这里只为可读):
{
"type": "snapshot",
"frame": 1842,
"full": false,
"baseFrame": 1841,
"entities": [
{ "id": 1, "fields": { "x": 320 } },
{ "id": 7, "fields": { "y": 96, "vy": -4 } }
],
"removed": [9]
}
这份格式里有三个值得记的点:frame 与 baseFrame 必须都带,客户端才能判断「我手里是不是这条增量所要求的基线」;full 决定客户端是覆盖还是叠加;removed 单独成列,因为「实体消失」也是一种变化,但它没有字段可写。
注意边界:本节的状态模块只处理结构,不碰 I/O。真正收发的字节由上层 session 层经抽象 Transport 接口(send / onMessage / close)进出——这也是卷四全卷的约定,网络代码一律依赖 Transport 而非直接依赖 WebSocket,这样单测才能注入「延迟 + 丢包 + 乱序」的假传输(第 7 章 7.1 给出完整假实现)。所以本节的 vitest 直接喂构造好的 Snapshot 对象,不需要任何真实连接。
3.1.8 L2:把快照写进 Cocos 节点
收到的状态最终要落到节点上。下面这段 L2 组件只做一件事——维护「实体 id → 节点」的映射,并把字段写进节点位置,只依赖 cc 公开 API,能过 tsc --noEmit:
import { _decorator, Component, Node } from 'cc';
const { ccclass } = _decorator;
@ccclass('RemoteEntityView')
export class RemoteEntityView extends Component {
/** 实体 id -> 场景节点,节点由对象池预先创建 */
private readonly views = new Map<number, Node>();
/** 注册一个可复用的节点 */
bind(id: number, node: Node): void {
this.views.set(id, node);
}
/** 实体离开视野或消失:停用并解绑 */
unbind(id: number): void {
const node = this.views.get(id);
if (node) {
node.active = false;
}
this.views.delete(id);
}
/** 把服务端下发的逻辑坐标写到节点上(x / y 为逻辑像素) */
apply(id: number, x: number, y: number): void {
const node = this.views.get(id);
if (!node) {
return; // 本地还没为这个 id 建节点:交给 3.2 的 AOI 补一次全量
}
node.setPosition(x, y, 0);
}
}
Node.setPosition 有 setPosition(x, y, z?) 的重载,直接传三个数最省事;node.active 是可写的访问器,用来「留节点、不渲染」——这正是 3.2 里 AOI 回收节点的做法。注意 apply 里那个 if (!node) return:收到一个自己没建过节点的实体,说明进入了视野但还没同步过一次全量,这个缺口由下一节的 AOI 补上。
到这里,客户端有了「收快照 → 叠状态 → 写节点」的完整链路。但服务端真的要把全部实体都发过来吗?两名玩家隔着半张地图,互相根本看不见对方,为什么要互相同步位置?下一节讲兴趣管理(AOI),把「只同步看得见的」这件事从概念落到可跑的差集计算。
阅读导航:上一节:目录 · 下一节:3.2 兴趣管理与 AOI 。
继续阅读
探索更多技术文章
浏览归档,发现更多关于系统设计、工具链和工程实践的内容。