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 协议选型与序列化 。
继续阅读
探索更多技术文章
浏览归档,发现更多关于系统设计、工具链和工程实践的内容。