《Cocos 游戏联机实战》3.1 快照与增量同步

上一章用帧同步把确定性锁死,但它要求所有客户端跑同一份输入流。本章换到状态同步:服务端定期下发权威状态,客户端只负责表现。本节拆开全量快照与增量 diff 的取舍,用纯 TS 写一套实体表加字段比对生成 diff、接收端 patch 应用的算法,配 vitest 断言,并给出《方块冲刺》的快照消息格式与基线确认机制。

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):

  1. 服务端发全量基线,帧号 frame = F。
  2. 客户端收下、应用、回一条 ack:{ ackFrame: F }。
  3. 服务端收到 ack 前,下一帧仍发全量;收到后,从 F+1 起改发以 F 为基线的增量。
  4. 若连续若干帧收不到 ack,服务端回退,重新发全量。

规则一句话:服务端只对「已被 ack 的帧」发增量。这样任何一条增量在发出时,其基线都已确认在客户端手里,丢包只会丢「变化」,不会丢「基线」。

3.1.7 《方块冲刺》快照消息格式

落到工程里,服务端每帧下发的快照消息约定如下(服务端实现见站内《游戏服务端编程实践》与 game/server/ 专题,本节只给格式):

字段类型含义方向
typestring固定 "snapshot"S → C
frameuint16本帧逻辑帧号S → C
fullbool是否全量基线S → C
baseFrameuint16增量基线帧号;全量时为 0S → C
entities[]array变化实体列表S → C
entities[].iduint8实体 idS → C
entities[].fieldsmap变化字段名 → 新值S → C
removed[]uint8[]本帧移除的实体 idS → C
ackFrameuint16客户端确认的基线帧号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 。

继续阅读

探索更多技术文章

浏览归档,发现更多关于系统设计、工具链和工程实践的内容。

全部文章 返回首页

「cocos」更多文章

  1. 《Cocos 游戏联机实战》9.3 延迟补偿与体验权衡
  2. 《Cocos 游戏联机实战》9.2 网络状态 UI 与提示
  3. 《Cocos 游戏联机实战》9.1 同步表现与插值动画