《Cocos 游戏联机实战》1.2 协议选型与序列化

本节讲联机协议怎么选:对比 WebSocket 上的 JSON、MessagePack 二进制与自定义紧凑二进制的取舍,给出用 const 对象加 as const 定义消息类型(禁用 enum)的方式,用 DataView 实现读写定点数、变长整数与字符串的 L1 编解码器并用 vitest 做往返断言,附《方块冲刺》的 INPUT / SNAPSHOT / ROOM_STATE 消息格式表。

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(方向:客户端 → 服务端)——每逻辑帧上报一次按键意图:

字段类型含义
msgIdu8固定 1
seqvarint客户端自增序号,用于服务端去重与客户端和解
tickvarint该输入对应的客户端逻辑帧号
flagsu8按键位:1 左 / 2 右 / 4 跳 / 8 用道具
holdMsu16本帧持续按键毫秒数,用于细粒度手感

SNAPSHOT(方向:服务端 → 客户端)——周期性下发世界状态:

字段类型含义
msgIdu8固定 2
tickvarint服务端权威逻辑帧号
serverTimeMsvarint服务端时钟毫秒,供 1.3 节时钟同步使用
countu8实体数量
entities[count]重复每个实体:id(varint) x(fixed) y(fixed) vx(fixed) vy(fixed) anim(u8)

ROOM_STATE(方向:服务端 → 客户端)——房间生命周期状态:

字段类型含义
msgIdu8固定 3
stateu80 等待 / 1 倒计时 / 2 比赛中 / 3 结算
mySlotu8本客户端在房间里的槽位(0 或 1)
countdownMsu16倒计时剩余毫秒,仅 state=1 有效
levelNamestr关卡名,供客户端加载分包 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 时钟、延迟与抖动 。

继续阅读

探索更多技术文章

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

全部文章 返回首页

「cocos」更多文章

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