《Cocos 游戏联机实战》1.1 网络分层、拓扑与权威边界

本节建立客户端侧联机的心智模型:讲清「权威」为什么默认在服务端、客户端只能决定输入与表现而不能决定胜负与伤害,对比 C/S、P2P、C/S 中继、主机制四种拓扑并说明《方块冲刺》双人竞速的选择,给出传输/会话/同步/表现四层与 assets/scripts/net/ 目录的对应,并写出 Transport 接口与可注入延迟丢包的假传输层完整实现。

1.1 网络分层、拓扑与权威边界

前三卷的世界里只有一个玩家,帧循环、渲染、存档全在一台机器上闭环。从这一节开始,《方块冲刺》里多了一个人——他坐在另一台设备前,隔着网络、时钟和你不一致、还会丢包。联机要回答的第一个问题不是「怎么发数据」,而是「同一件事,谁说了算」。

本节是全书第 1 章的第一节,目标只有一个:把「权威」这个词钉死。讲完你会知道:为什么服务端权威是默认选择、客户端能决定什么又不能决定什么、四种拓扑各自的代价、《方块冲刺》双人竞速为什么选 C/S,以及传输层为什么要抽象成一个可以替换的接口。本节的代码是纯 TypeScript,能在 Node + vitest 里真跑。

与站内 game/server/ 那 315 篇、书稿《游戏服务端编程实践》那 43 篇的分工:它们讲服务端怎么实现(网关、AOI、匹配、裁决),本卷讲客户端怎么参与。凡涉及服务端,本节只给接口约定,不写实现。

1.1.1 联机的第一性问题:谁是权威

先把一个常见误解掰正。很多第一次做联机的人,脑子里想的模型是「我本地算出结果,然后把结果告诉对面」。这个模型在单机里天经地义,在联机里却会立刻崩掉:两台设备各自算,谁的结果才算数?如果双方都说「我赢了」呢?

于是有了**权威(authority)**这个概念:对世界状态的最终解释权归谁。 默认答案是服务端,理由是它站在一个双方都够得着、且玩家改不了的位置上。客户端各自只看到自己那一份世界,服务端握着唯一的真值。

关键在于把「客户端能决定什么」和「不能决定什么」分清楚。这不是技术细节,而是设计约束:

数据类别例子权威在谁客户端的角色
输入意图按了跳跃、向左、用道具客户端采集并上报,这是客户端唯一该「说了算」的
世界真值位置、速度、血量、胜负、掉落服务端预测显示,收到快照后服从
表现细节动画帧、粒子、相机跟随、音效客户端自由决定,不需要同步
过程反馈命中特效、飘字、震动客户端依据服务端确认后播放,可先行播放但可回退

看最后一行和第一行的对比:客户端可以立刻把「我按了跳」表现出来(否则操作会有延迟感),但不能把「我跳到了终点所以赢了」当成事实广播出去。前者是预测,后者是篡权。客户端预测的意义是「让手感先动起来」,不是「让结果先定下来」——这句话是整卷的基调,第 4 章会展开成完整的预测与和解实现。

1.1.2 四种拓扑

权威放在哪,决定了拓扑。四种常见形态的取舍:

拓扑权威位置延迟特征作弊面适合
纯 C/S服务端玩家↔服务端 RTT小(客户端说了不算)竞技、需要一致裁决
纯 P2P无中心 / 对等协商玩家↔玩家 RTT大(每台都能改)熟人房、非竞技
C/S + 中继服务端(消息经中继转发)玩家↔中继↔玩家小需要低延迟直连但又要裁决
主机制(host)一名玩家兼服务端房主近、其他人远中(房主可改)合作 PvE、临时房

《方块冲刺》双人竞速选纯 C/S。理由很直接:竞速是零和的——一个人赢就是另一个人输,胜负必须由第三方裁决,否则「谁先到」永远吵不清。两人直连的 P2P 看似省一个服务端,但把裁决权交到玩家手里,等于把排行榜拱手让人。C/S 的代价是每人多一跳 RTT,对竞速这种以「双方看到同一份赛道」为目标的玩法,这个代价换来的公平是划算的。

服务端是既有设施(见站内《游戏服务端编程实践》),本卷只约定:客户端通过 WebSocket 连到服务端的房间端点,收发二进制消息。 端点形如 wss://game.example.com/room,升级、鉴权、匹配由服务端负责,客户端只管连上去、发消息、收消息。

1.1.3 四层分层与目录对应

联机代码最容易写成一坨「收到消息就到处改状态」的泥球。防泥球的办法是分层,每层只跟相邻层说话:

层职责目录能不能脱离网络测试
传输层收发字节,屏蔽 WebSocket / 假实现差异assets/scripts/net/transport/能(假传输层)
会话层连接、重连、房间生命周期、时钟assets/scripts/net/session/能(假时钟)
同步层快照、插值、预测、和解assets/scripts/net/sync/、net/predict/能(纯逻辑)
表现层把同步结果画到节点上场景里的 Cocos 组件只能类型检查

这张表后面每一章都会回来认领一行。关键约定:越往下的层越纯。 传输层之上不认识 WebSocket,会话层之上不认识 cc,同步层是纯 TypeScript 函数与数据结构。这样第 2 章往后所有难逻辑都能在 Node 里单测——这正是本卷敢承诺「真跑」的底气。

协议定义与编解码放哪?它跨在传输层与同步层之间,单独开 assets/scripts/net/protocol/,1.2 节整节讲它。

1.1.4 抽象出 Transport 接口

所有网络代码只依赖一个接口,不直接依赖 WebSocket:

// assets/scripts/net/transport/transport.ts
export interface Transport {
  /** 发送一帧字节。实现负责在连接未就绪时自行缓冲或丢弃。 */
  send(payload: Uint8Array): void;
  /** 注册唯一的消息回调;同一时刻只允许一个监听者。 */
  onMessage(listener: (payload: Uint8Array) => void): void;
  /** 主动关闭连接;关闭后 send 应为空操作。 */
  close(): void;
}

三个方法就够了。为什么不是 connect?因为连接管理属于会话层(重连、退避、鉴权都在那层),传输层只负责「一条已经连上的管道」。这样切分之后,测试时把真 WebSocket 换成假实现,其余代码一行不改。

send 的入参是 Uint8Array 而不是 string——1.2 节会说明为什么联机协议走二进制而不是 JSON 字符串。现在只要记住:传输层搬的是字节,不认识消息语义。

1.1.5 假传输层:可注入延迟与丢包

真 WebSocket 在单测里没法用(要真网络、时序不可控)。于是写一个假传输层,它能按注入的参数制造延迟、抖动、丢包,时间来源也由外部注入——这样测试里推进一个假时钟,就能精确断言「50 ms 后包到了」「丢包率 50% 时一半没到」。

// assets/scripts/net/transport/fake-transport.ts
import type { Transport } from './transport';

export interface FakeTransportOptions {
  latencyMs: number;
  jitterMs?: number;
  lossRate?: number;
  /** 单调时间来源,测试里换成假时钟 */
  now: () => number;
  /** 随机源,测试里换成确定性序列 */
  rand?: () => number;
}

interface InFlight {
  deliverAt: number;
  payload: Uint8Array;
}

export class FakeTransport implements Transport {
  readonly name: string;
  private readonly latencyMs: number;
  private readonly jitterMs: number;
  private readonly lossRate: number;
  private readonly now: () => number;
  private readonly rand: () => number;

  private peer: FakeTransport | null = null;
  private listener: ((payload: Uint8Array) => void) | null = null;
  private queue: InFlight[] = [];
  private closed = false;
  private sentCount = 0;
  private droppedCount = 0;

  constructor(name: string, options: FakeTransportOptions) {
    this.name = name;
    this.latencyMs = options.latencyMs;
    this.jitterMs = options.jitterMs ?? 0;
    this.lossRate = options.lossRate ?? 0;
    this.now = options.now;
    this.rand = options.rand ?? Math.random;
  }

  /** 把本端与对端接成一条双向链路 */
  connect(peer: FakeTransport): void {
    this.peer = peer;
  }

  send(payload: Uint8Array): void {
    if (this.closed || this.peer === null) return;
    this.sentCount += 1;
    if (this.rand() < this.lossRate) {
      this.droppedCount += 1;
      return;
    }
    const jitter = (this.rand() * 2 - 1) * this.jitterMs;
    const deliverAt = this.now() + this.latencyMs + jitter;
    this.queue.push({ deliverAt, payload: payload.slice() });
  }

  onMessage(listener: (payload: Uint8Array) => void): void {
    this.listener = listener;
  }

  close(): void {
    this.closed = true;
    this.queue = [];
  }

  /** 测试用:把当前时刻已到期的包投递给对端 */
  pump(): void {
    const t = this.now();
    const remain: InFlight[] = [];
    for (const item of this.queue) {
      if (item.deliverAt <= t) {
        this.peer?.deliver(item.payload);
      } else {
        remain.push(item);
      }
    }
    this.queue = remain;
  }

  private deliver(payload: Uint8Array): void {
    if (this.closed) return;
    this.listener?.(payload);
  }

  get stats(): { sent: number; dropped: number } {
    return { sent: this.sentCount, dropped: this.droppedCount };
  }
}

注意两点:pump() 不是接口的一部分,它是测试专用的推进器——真实传输层由事件循环自己触发投递,假传输层需要测试显式「推一下时间」。deliver 是 private,但同一个类的另一个实例能调它,所以 a.pump() 可以给 b 投递,这是 TypeScript 允许的同类私有访问。

1.1.6 用 vitest 断言延迟与丢包真的生效

// assets/scripts/net/transport/fake-transport.test.ts
import { describe, expect, it } from 'vitest';
import { FakeTransport } from './fake-transport';

function fakeClock(start = 0) {
  let t = start;
  return { now: () => t, advance: (ms: number) => { t += ms; } };
}

describe('假传输层', () => {
  it('按注入的延迟投递,不早也不晚', () => {
    const clock = fakeClock();
    const a = new FakeTransport('a', { latencyMs: 50, now: clock.now });
    const b = new FakeTransport('b', { latencyMs: 50, now: clock.now });
    a.connect(b);
    b.connect(a);
    const got: Uint8Array[] = [];
    b.onMessage((p) => got.push(p));

    a.send(new Uint8Array([1, 2, 3]));
    clock.advance(49);
    a.pump();
    expect(got.length).toBe(0); // 差 1ms,还没到
    clock.advance(1);
    a.pump();
    expect(got.length).toBe(1);
    expect(Array.from(got[0])).toEqual([1, 2, 3]);
  });

  it('按注入的丢包率丢包', () => {
    const clock = fakeClock();
    const seq = [0.1, 0.9, 0.95, 0.05]; // 确定性随机序列
    let n = 0;
    const a = new FakeTransport('a', {
      latencyMs: 0,
      lossRate: 0.5,
      now: clock.now,
      rand: () => seq[n++],
    });
    const b = new FakeTransport('b', { latencyMs: 0, now: clock.now });
    a.connect(b);
    const got: Uint8Array[] = [];
    b.onMessage((p) => got.push(p));

    a.send(new Uint8Array([1])); // rand=0.1 < 0.5 → 丢
    a.send(new Uint8Array([2])); // rand=0.9 >= 0.5 → 过
    a.pump();
    expect(a.stats.dropped).toBe(1);
    expect(got.length).toBe(1);
    expect(got[0][0]).toBe(2);
  });
});

第一个断言证明延迟是精确可控的(49 ms 不到、50 ms 到),第二个证明丢包是确定可复现的(注入随机序列,同一次运行永远丢同一包)。这两个能力是后面所有弱网测试的地基——第 4 章的「丢包 30% 下预测仍收敛」、第 7 章的弱网模拟,用的都是这个 FakeTransport。

1.1.7 Cocos 侧:把 Transport 装进组件(L2)

L1 的传输层不认识引擎。到了 Cocos 里,需要一个组件来选择并创建具体传输实现。下面这段是 L2,先 tsc --noEmit 通过即可,sys 用来按平台挑端点:

import { _decorator, Component, sys } from 'cc';

const { ccclass } = _decorator;

@ccclass('NetBootstrap')
export class NetBootstrap extends Component {
  private endpoint = '';

  start(): void {
    // 微信小游戏与原生强制走加密通道;桌面浏览器开发期可用 ws
    const mustSecure = sys.isNative || sys.platform === sys.Platform.WECHAT_GAME;
    this.endpoint = `${mustSecure ? 'wss' : 'ws'}://game.example.com/room`;
  }
}

这里用到的 sys、sys.isNative、sys.platform、sys.Platform.WECHAT_GAME、_decorator.ccclass、Component 都已对照 @cocos/creator-types@3.8.8 的 cc.d.ts 确认存在。本书每个 cc 符号落笔前都做过这一步——凭记忆写 API 名是这类书稿最高频的错误来源。

1.1.8 客户端该做什么

这一节讲完,客户端多了一层边界意识,以及一个可测试的传输抽象。落地清单:

  • 认下「权威在服务端」这条线:胜负、伤害、掉落、位置真值都听服务端的
  • 认下「客户端只决定输入与表现」:输入立刻上报、表现立刻播放,但不篡改真值
  • 为《方块冲刺》双人竞速选定纯 C/S 拓扑,端点走 wss://game.example.com/room
  • 建好 assets/scripts/net/{transport,session,sync,predict,protocol}/ 目录骨架
  • 写下 Transport 接口(send / onMessage / close)与 FakeTransport 实现,跑通两个断言

边界定好之后,下一个问题立刻冒出来:这条管道里跑的字节长什么样? 是直接发 JSON 字符串,还是自定义二进制?消息类型怎么定义、字段怎么排布、跨端(H5 / 微信小游戏 / 原生)有什么差异?下一节把协议选型与序列化一次讲透,并给出《方块冲刺》的消息格式表。

阅读导航:上一节:目录 · 下一节:1.2 协议选型与序列化 。

继续阅读

探索更多技术文章

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

全部文章 返回首页

「cocos」更多文章

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