1.2 协议选型与序列化
1.1 节把传输层抽象成了 Transport,它只搬 Uint8Array。但「字节长什么样」还没定——是直接发一段 JSON 字符串,还是自己排布二进制?这个决定影响带宽、调试难度、跨端一致性和后面每一章写同步代码时的顺手程度。
本节给出选型取舍表、用
const对象定义消息类型的方式、一个基于DataView的 L1 编解码器(真跑 vitest 往返断言),以及《方块冲刺》三条核心消息的格式表。读完你会知道为什么联机走二进制而不是 JSON,以及怎么把一个字段精确地摆进字节里再读回来。
与站内 game/server/ 那 315 篇的分工照旧:它们讲服务端协议栈的实现,本节只讲客户端侧怎么把消息编成字节、又把字节解回消息,并给出与既有权威服务端对接的字段约定。
1.2.1 三种候选的取舍
在 WebSocket 之上(WebSocket 本身就是二进制帧通道),消息体有三种常见做法:
| 方案 | 每消息体积(估) | 可读性 | 跨端支持 | 调试成本 | 适合 |
|---|---|---|---|---|---|
| JSON 字符串 | 大(键名重复 + UTF-8) | 极好,抓包即读 | 全部支持 | 低 | 原型期、低频消息、配置下发 |
| MessagePack 风格二进制 | 中(省键名,仍带类型标签) | 差,需工具 | 需库,H5/微信/原生都有 | 中 | 结构频繁变动、字段多 |
| 自定义紧凑二进制 | 小(字段顺序固定,按需位宽) | 最差,需 schema | 全部支持(纯 DataView) | 高(要自己写工具) | 高频消息、带宽敏感 |
《方块冲刺》双人竞速选自定义紧凑二进制。原因是联机里最热的消息是 SNAPSHOT(快照,每秒几十次),它字段固定、结构简单、每个字节都要省。JSON 在弱网下的额外开销很实在:{"tick":123,"x":1024} 里键名 "tick"、"x" 占了大头,UTF-8 编码后一个 SNAPSHOT 可能从几十字节膨胀到两三百字节,还多一次字符串解析。低频消息(如匹配请求、房间配置)可以继续用 JSON,本节只把高频路径做成二进制。
1.2.2 消息定义:const 对象 + as const
Node 26 只做类型剥离,enum 这类需要生成运行时代码的语法不能用。用 const 对象配 as const 表达常量表,用 typeof 取联合类型:
// assets/scripts/net/protocol/messages.ts
/** 消息类型编号,写入每条消息的第一个字节 */
export const MessageId = {
INPUT: 1,
SNAPSHOT: 2,
ROOM_STATE: 3,
} as const;
export type MessageId = (typeof MessageId)[keyof typeof MessageId];
/** INPUT 的按键位标志,一个字节塞多个键 */
export const InputFlag = {
LEFT: 1,
RIGHT: 2,
JUMP: 4,
ITEM: 8,
} as const;
export type InputFlag = (typeof InputFlag)[keyof typeof InputFlag];
这样 MessageId.INPUT 的类型是字面量 1 而不是 number,MessageId 这个类型是 1 | 2 | 3 的联合。好处是 switch 能穷尽检查、Record<MessageId, ...> 能强制覆盖全部消息,而运行时代码里它就是一个普通对象——Node 直接跑,零转换。全卷所有常量表都走这个模式,一个 enum 都不会出现。
1.2.3 一个够用的编解码器(L1)
二进制协议的手感全在编解码器上。我们只需要三种基本能力:定点数(位置、速度,跨端一致)、变长整数(tick、id、长度,小值省字节)、字符串(房间名、玩家名,长度前缀 + UTF-8)。
// assets/scripts/net/protocol/codec.ts
const encoder = new TextEncoder();
const decoder = new TextDecoder();
export class ByteWriter {
private buf: Uint8Array;
private view: DataView;
private pos = 0;
constructor(capacity = 256) {
this.buf = new Uint8Array(capacity);
this.view = new DataView(this.buf.buffer);
}
private ensure(extra: number): void {
if (this.pos + extra <= this.buf.length) return;
let cap = this.buf.length;
while (cap < this.pos + extra) cap *= 2;
const next = new Uint8Array(cap);
next.set(this.buf.subarray(0, this.pos));
this.buf = next;
this.view = new DataView(next.buffer);
}
u8(v: number): this {
this.ensure(1);
this.view.setUint8(this.pos, v & 0xff);
this.pos += 1;
return this;
}
i16(v: number): this {
this.ensure(2);
this.view.setInt16(this.pos, v, true);
this.pos += 2;
return this;
}
u16(v: number): this {
this.ensure(2);
this.view.setUint16(this.pos, v, true);
this.pos += 2;
return this;
}
/** 16.16 定点数:低 16 位是小数,精度 1/65536,值域约 ±32767 */
fixed(v: number): this {
const raw = Math.round(v * 65536);
this.ensure(4);
this.view.setInt32(this.pos, raw, true);
this.pos += 4;
return this;
}
/** 变长无符号整数:每字节 7 位有效位,最高位为续位标志 */
varint(v: number): this {
let n = v >>> 0;
for (;;) {
const byte = n & 0x7f;
n >>>= 7;
if (n === 0) {
this.u8(byte);
break;
}
this.u8(byte | 0x80);
}
return this;
}
/** 长度前缀(varint)+ UTF-8 字节 */
str(s: string): this {
const bytes = encoder.encode(s);
this.varint(bytes.length);
this.ensure(bytes.length);
this.buf.set(bytes, this.pos);
this.pos += bytes.length;
return this;
}
toBytes(): Uint8Array {
return this.buf.slice(0, this.pos);
}
}
读写对称,ByteReader 一一对应:
// assets/scripts/net/protocol/codec.ts(续)
export class ByteReader {
private view: DataView;
private pos = 0;
private readonly len: number;
constructor(bytes: Uint8Array) {
this.view = new DataView(bytes.buffer, bytes.byteOffset, bytes.byteLength);
this.len = bytes.byteLength;
}
u8(): number {
const v = this.view.getUint8(this.pos);
this.pos += 1;
return v;
}
i16(): number {
const v = this.view.getInt16(this.pos, true);
this.pos += 2;
return v;
}
u16(): number {
const v = this.view.getUint16(this.pos, true);
this.pos += 2;
return v;
}
fixed(): number {
const raw = this.view.getInt32(this.pos, true);
this.pos += 4;
return raw / 65536;
}
varint(): number {
let shift = 0;
let result = 0;
for (;;) {
const byte = this.view.getUint8(this.pos);
this.pos += 1;
result += (byte & 0x7f) * 2 ** shift;
if ((byte & 0x80) === 0) break;
shift += 7;
}
return result;
}
str(): string {
const n = this.varint();
const slice = new Uint8Array(this.view.buffer, this.view.byteOffset + this.pos, n);
this.pos += n;
return decoder.decode(slice);
}
get eof(): boolean {
return this.pos >= this.len;
}
}
两处要点:fixed() 用 Math.round 而不是 Math.floor,保证 -2.25 这类负值四舍五入对称;varint() 的 2 ** shift 在 shift 到 28 时仍精确(JavaScript 安全整数上限 2^53),足够覆盖 tick 与 id。
1.2.4 往返断言
编解码器最怕「写进去读不回来」。写两个测试,一个测往返一致,一个测变长整数真的按值大小省字节:
// assets/scripts/net/protocol/codec.test.ts
import { describe, expect, it } from 'vitest';
import { ByteReader, ByteWriter } from './codec';
describe('编解码器', () => {
it('基本类型往返一致', () => {
const w = new ByteWriter(4); // 故意给小容量,触发扩容
w.u8(255).i16(-300).u16(65535).fixed(1.5).fixed(-2.25).varint(300000).str('方块冲刺');
const r = new ByteReader(w.toBytes());
expect(r.u8()).toBe(255);
expect(r.i16()).toBe(-300);
expect(r.u16()).toBe(65535);
expect(r.fixed()).toBeCloseTo(1.5, 5);
expect(r.fixed()).toBeCloseTo(-2.25, 5);
expect(r.varint()).toBe(300000);
expect(r.str()).toBe('方块冲刺');
expect(r.eof).toBe(true);
});
it('变长整数按值大小占字节', () => {
const w = new ByteWriter();
w.varint(1).varint(127).varint(128).varint(16383).varint(16384);
// 1、127 各 1 字节;128、16383 各 2 字节;16384 要 3 字节
expect(w.toBytes().length).toBe(1 + 1 + 2 + 2 + 3);
});
});
第一个测试特意把初始容量设成 4,逼 ensure() 扩容,证明扩容后 DataView 跟着新缓冲区重建(这是新手最容易漏的一处:只换 buf 不换 view,写越界却静默不报)。第二个测试证明小值只占一字节——tick 在开局阶段几乎全是 1~2 字节,省下来的带宽是实打实的。
1.2.5 《方块冲刺》消息格式表
以下三条是双人竞速的核心消息。表里所有多字节整数均为小端,字符串为 varint 长度 + UTF-8。
INPUT(方向:客户端 → 服务端)——每逻辑帧上报一次按键意图:
| 字段 | 类型 | 含义 |
|---|---|---|
msgId | u8 | 固定 1 |
seq | varint | 客户端自增序号,用于服务端去重与客户端和解 |
tick | varint | 该输入对应的客户端逻辑帧号 |
flags | u8 | 按键位:1 左 / 2 右 / 4 跳 / 8 用道具 |
holdMs | u16 | 本帧持续按键毫秒数,用于细粒度手感 |
SNAPSHOT(方向:服务端 → 客户端)——周期性下发世界状态:
| 字段 | 类型 | 含义 |
|---|---|---|
msgId | u8 | 固定 2 |
tick | varint | 服务端权威逻辑帧号 |
serverTimeMs | varint | 服务端时钟毫秒,供 1.3 节时钟同步使用 |
count | u8 | 实体数量 |
entities[count] | 重复 | 每个实体:id(varint) x(fixed) y(fixed) vx(fixed) vy(fixed) anim(u8) |
ROOM_STATE(方向:服务端 → 客户端)——房间生命周期状态:
| 字段 | 类型 | 含义 |
|---|---|---|
msgId | u8 | 固定 3 |
state | u8 | 0 等待 / 1 倒计时 / 2 比赛中 / 3 结算 |
mySlot | u8 | 本客户端在房间里的槽位(0 或 1) |
countdownMs | u16 | 倒计时剩余毫秒,仅 state=1 有效 |
levelName | str | 关卡名,供客户端加载分包 bundle-levels |
服务端实现不写。 这三张表是客户端与服务端之间的契约:客户端按此编,服务端按此解。服务端怎么接收、怎么广播、怎么裁决,见站内《游戏服务端编程实践》与 game/server/ 专题。
1.2.6 跨端注意
H5、微信小游戏、原生三端都基于同一个 JS 引擎,对 ArrayBuffer / DataView / TypedArray 的支持是一致的——这意味着上面这套 DataView 编解码代码三端通用,不需要平台分支。
差异在连接层 API:微信小游戏用 wx.connectSocket,原生走引擎封装的 WebSocket,H5 用标准 WebSocket。但因为我们把它藏在 Transport 后面,上层协议与编解码代码完全无感。这就是 1.1 节坚持抽象传输层的回报。
另一个差异是弱网下的 JSON 开销:微信小游戏环境里字符串编解码与 GC 压力比 H5 更敏感,JSON 的键名重复在弱网重传时会被放大。这也是高频消息一律走二进制的原因之一。
1.2.7 Cocos 侧:把协议版本暴露到检查器(L2)
协议版本要能改、要跟消息头一起发,用一个 Cocos 组件把它暴露成可配置项。下面这段是 L2,先过 tsc --noEmit:
import { _decorator, Component } from 'cc';
const { ccclass, property } = _decorator;
@ccclass('NetConfig')
export class NetConfig extends Component {
@property({ tooltip: '协议版本,随每条消息头一起发送,服务端据此拒绝不兼容客户端' })
protocolVersion = 1;
@property({ tooltip: '逻辑帧率,客户端与服务端必须一致,否则快照对不上' })
tickRate = 60;
}
_decorator.ccclass、_decorator.property、Component 均已对照 cc.d.ts 确认。@property({ tooltip }) 是 Cocos 把字段暴露到属性检查器的标准写法,tooltip 会在编辑器里悬浮提示。
1.2.8 客户端该做什么
本节交付了协议的骨架:消息类型表、编解码器、往返测试、三张消息格式表。落地清单:
- 定下高频路径走自定义紧凑二进制,低频消息(匹配、配置)可继续用 JSON
- 用
const对象 +as const定义MessageId/InputFlag,全程无enum - 落地
ByteWriter/ByteReader,跑通往返与变长整数两个断言 - 按消息格式表实现
INPUT/SNAPSHOT/ROOM_STATE的编解码函数 - 在
NetConfig组件里暴露protocolVersion与tickRate
协议定了,但消息里所有时间相关字段(tick、serverTimeMs、holdMs)都建立在一个前提上:两端对「现在几点」有一致的理解。而现实是客户端和服务端的时钟根本不同源——Date.now() 两台机器差几秒都正常。下一节把时钟同步、RTT 与抖动讲清楚,并写出一个后面 4.2、5.2、9.3 都要复用的估计模块。
阅读导航:上一节:1.1 网络分层、拓扑与权威边界 · 下一节:1.3 时钟、延迟与抖动 。
继续阅读
探索更多技术文章
浏览归档,发现更多关于系统设计、工具链和工程实践的内容。