《Cocos 游戏联机实战》10.1 把《方块冲刺》做成双人竞速

综合项目收官:把前九章的协议、会话、同步、预测与网络 UI 拼成可玩的双人竞速。给出逐条对应章节的客户端改造清单、ASCII 数据流总图与从登录到结算的完整时序,用假传输层加两个客户端实例写一局 L1 集成测试,断言在 200 ms 延迟与 5% 丢包下双方仍收敛到同一结果,并列出单帧耗时、内存与消息量的验收预算。

10.1 把《方块冲刺》做成双人竞速

前面九章各自讲清了一件客户端侧的事:怎么定协议、怎么对齐时钟、怎么让两端算出同一个世界、怎么让本地先动、怎么在弱网里活下去。这一节把它们拼起来——把卷二那个单机《方块冲刺》改成双人竞速,同一条跑道上比谁先到终点。

先说分工。站内 game/client/ 讲客户端产品化、game/server/ 讲服务端实现,本卷两者都不重复:客户端工程体系归卷二,服务端实现归服务端专题与《游戏服务端编程实践》书稿。这一节只做一件事——把前九章的客户端模块接成一个能跑、能测的完整联机形态。

10.1.1 客户端改造清单

改造不是重写,是在卷二那套工程上叠一层网络。每项对应本卷一节,落到 assets/scripts/net/ 下一个子目录:

改造项落到哪个目录依据章节在《方块冲刺》里做什么
消息定义与编解码net/protocol/1.2输入帧、快照、结算三类消息与二进制编解码
连接与重连net/session/5.1Transport 抽象、会话状态机、恢复点协商
快照与插值net/sync/3.1 + 4.3收快照、算增量、给远端玩家做插值表现
预测与和解net/predict/4.1 + 4.2本地先动、收快照后比对、超阈值则回滚重演
网络状态 UIui/net/9.2延迟、丢包、重连提示,不把技术指标直接甩给玩家
弱网降级net/session/5.3降帧、关特效、切回放式平滑,保住「能玩」

四条纪律贯穿整张清单:传输层只认 Transport 接口、权威在服务端(客户端预测只是「先动一下」)、确定性逻辑不碰浮点、每个模块都要能脱离网络单测。

10.1.2 数据流总图

一帧数据在客户端里走这样一条路(text 图,非 Mermaid):

本地输入 ─▶ predict ─编码─▶ protocol ─▶ Transport ─▶ WebSocket
              ▲                                  │
              │ 立即本地应用              │ 快照下行
              └───── sync 确定性核心 ◀── session ◀─┘
                            │
                            ▼
                          表现层

读法:输入从左上进、快照从右上进,两者在 sync 的确定性核心里汇合。predict 保证本地不等网络先动起来,session 负责在 Transport 上收发与重连,protocol 只管编解码、不碰业务。表现层永远只读 sync 算出的世界。

10.1.3 从登录到开赛的完整时序

一局联机不是「连上就开跑」,中间要过六步。每步的客户端动作与消息方向:

步客户端动作上行下行
1 登录建连接、发握手(令牌、版本)AuthRequestAuthResponse(会话 ID、服务器时间)
2 匹配入队、显示「匹配中」MatchEnqueueMatchFound(对手、房间 ID)
3 房间加入房间、等配置下发RoomJoinRoomConfig(跑道、随机种子、步长)
4 倒计时用服务器时间对齐倒计时,冻结输入—RaceCountdown(起跑时间戳)
5 开跑解冻输入、本地预测、逐帧上行InputFrameSnapshot(周期下发)
6 结算收结算、展示名次、回房间RaceResultAckRaceResult(名次、用时)

两处最容易出 bug:第 4 步的倒计时必须用服务器时间对齐(否则两端起跑不同步);第 5 步的起跑边界以服务器时间为准(本地倒计时归零只是表现,真正「可以跑」以收到的起跑快照为界)。第 2、3 步的完整状态机见 6.1 与 6.2。

10.1.4 一局的确定性核心(L1)

整局的可测性压在一个纯整数、无 cc、可单测的确定性核心上。它同时是预测与和解的基准——本地预测、服务端和解、回滚重演都调这一个 step:

// assets/scripts/net/sync/race-sim.ts —— 确定性核心(纯整数,不 import 'cc')
export const SPEED = 400;       // 每帧水平位移
export const JUMP_V = -520;     // 起跳初速度(只影响表现高度)
export const FINISH_X = 60_000; // 终点(约 150 帧到达)

export interface Input { readonly move: number; readonly jump: boolean; }
export interface Player { x: number; y: number; onGround: boolean; }
export interface World {
  frame: number;
  players: readonly [Player, Player];
  winner: number; // -1 未决 / 0 / 1
}

const newPlayer = (): Player => ({ x: 0, y: 0, onGround: true });

export function createWorld(): World {
  return { frame: 0, players: [newPlayer(), newPlayer()], winner: -1 };
}

export function step(w: World, inputs: readonly [Input, Input]): World {
  const p0 = advance(w.players[0], inputs[0]);
  const p1 = advance(w.players[1], inputs[1]);
  let winner = w.winner;
  if (winner < 0) {
    if (p0.x >= FINISH_X && p1.x >= FINISH_X) winner = p0.x >= p1.x ? 0 : 1;
    else if (p0.x >= FINISH_X) winner = 0;
    else if (p1.x >= FINISH_X) winner = 1;
  }
  return { frame: w.frame + 1, players: [p0, p1], winner };
}

function advance(p: Player, input: Input): Player {
  const x = p.x + SPEED + input.move * 8;
  const y = input.jump && p.onGround ? JUMP_V : p.y; // 跳跃只影响表现
  return { x, y, onGround: true };
}

全程只有整数加减与比较——没有浮点累积误差,所以两端、跨端、跨版本都能算出逐位相同的 World。这正是 2.1 那条纪律的落点:浮点只允许出现在表现层。

10.1.5 假传输层(L1)

网络代码只依赖一个接口。这样测试才能注入「延迟 + 丢包」,而不必真的连服务器:

// assets/scripts/net/session/transport.ts —— 纯 TS,不 import 'cc'
export interface Transport {
  send(bytes: Uint8Array): void;
  onMessage(handler: (bytes: Uint8Array) => void): void;
  close(): void;
}

假实现用确定性 PRNG,保证同一 seed 下丢包序列可复现:

// test/fake-transport.ts —— 测试用假传输层(纯 TS)
import type { Transport } from '../assets/scripts/net/session/transport';

export interface FakeNetOptions { latencyMs: number; lossRate: number; seed?: number; }

function makeRandom(seed: number): () => number {
  let s = seed >>> 0;
  return () => ((s = (s * 1664525 + 1013904223) >>> 0) / 0x100000000);
}

// 一条广播总线:send 后经延迟(可能丢包)投递给所有订阅者
export class FakeLink {
  private readonly opts: FakeNetOptions;
  private readonly rand: () => number;
  private readonly handlers: Array<(bytes: Uint8Array) => void> = [];
  private pending: Array<{ at: number; bytes: Uint8Array }> = [];
  private clock = 0;

  constructor(opts: FakeNetOptions) {
    this.opts = opts;
    this.rand = makeRandom(opts.seed ?? 1);
  }

  deliver(bytes: Uint8Array): void {
    if (this.rand() < this.opts.lossRate) return; // 丢包
    this.pending.push({ at: this.clock + this.opts.latencyMs, bytes });
  }

  advance(ms: number): void {
    this.clock += ms;
    const due = this.pending.filter((p) => p.at <= this.clock);
    this.pending = this.pending.filter((p) => p.at > this.clock);
    for (const p of due) for (const h of this.handlers) h(p.bytes);
  }

  subscribe(handler: (bytes: Uint8Array) => void): void { this.handlers.push(handler); }
}

export class FakeTransport implements Transport {
  private readonly link: FakeLink;
  private messageHandler: ((bytes: Uint8Array) => void) | null = null;

  constructor(link: FakeLink) {
    this.link = link;
    link.subscribe((bytes) => this.messageHandler?.(bytes));
  }

  send(bytes: Uint8Array): void { this.link.deliver(bytes); }
  onMessage(handler: (bytes: Uint8Array) => void): void { this.messageHandler = handler; }
  close(): void { this.messageHandler = null; }
}

注意构造函数:没有用参数属性写法(constructor(private x))——那是需要生成运行时代码的不可擦除语法,全书禁用;字段一律显式声明再赋值。

10.1.6 一局完整时序的集成测试(L1)

客户端把「本地输入 → 上行 → 收对端输入 → 推进确定性核心」包成一个可测的锁步客户端。它不 import cc,只认 Transport:

// assets/scripts/net/session/lockstep-client.ts —— 纯 TS
import type { Transport } from './transport';
import { createWorld, step, type Input, type World } from '../sync/race-sim';

export function encodeInput(player: number, frame: number, input: Input): Uint8Array {
  const buf = new Uint8Array(5);
  buf[0] = player;
  buf[1] = (frame >> 8) & 0xff;
  buf[2] = frame & 0xff;
  buf[3] = input.move + 1; // -1/0/1 → 0/1/2
  buf[4] = input.jump ? 1 : 0;
  return buf;
}

export function decodeInput(bytes: Uint8Array): { player: number; frame: number; input: Input } {
  return {
    player: bytes[0],
    frame: (bytes[1] << 8) | bytes[2],
    input: { move: bytes[3] - 1, jump: bytes[4] === 1 },
  };
}

export class LockstepClient {
  readonly playerIndex: number;
  private readonly transport: Transport;
  private world: World = createWorld();
  private readonly queue: Input[] = [];
  private readonly peer = new Map<number, Input>();

  constructor(playerIndex: number, transport: Transport) {
    this.playerIndex = playerIndex;
    this.transport = transport;
    transport.onMessage((bytes) => {
      const msg = decodeInput(bytes);
      if (msg.player !== 1 - this.playerIndex) return; // 忽略自己的回环
      this.peer.set(msg.frame, msg.input);
    });
  }

  submit(input: Input): void {
    const frame = this.world.frame + this.queue.length;
    this.queue.push(input);
    this.transport.send(encodeInput(this.playerIndex, frame, input));
  }

  // 丢包时把未推进的输入再发一遍:对端按帧去重,天然幂等(5.2)
  retransmit(): void {
    let frame = this.world.frame;
    for (const input of this.queue) {
      this.transport.send(encodeInput(this.playerIndex, frame, input));
      frame++;
    }
  }

  // 本地队列非空且对端该帧已到才推进——这就是帧对齐(2.2)
  tryAdvance(): number {
    let advanced = 0;
    while (this.queue.length > 0 && this.peer.has(this.world.frame)) {
      const local = this.queue.shift() as Input;
      const remote = this.peer.get(this.world.frame) as Input;
      this.peer.delete(this.world.frame);
      const inputs: [Input, Input] =
        this.playerIndex === 0 ? [local, remote] : [remote, local];
      this.world = step(this.world, inputs);
      advanced++;
    }
    return advanced;
  }

  get state(): World { return this.world; }
}

有了它,一局的完整时序就是一段纯逻辑,直接 vitest:

// test/race-session.test.ts
import { describe, expect, it } from 'vitest';
import { FakeLink, FakeTransport } from './fake-transport';
import { LockstepClient } from '../assets/scripts/net/session/lockstep-client';
import { FINISH_X } from '../assets/scripts/net/sync/race-sim';

const FRAME_MS = 16; // 60 Hz

function runRace(latencyMs: number, lossRate: number, ticks: number) {
  const link = new FakeLink({ latencyMs, lossRate, seed: 20261010 });
  const a = new LockstepClient(0, new FakeTransport(link));
  const b = new LockstepClient(1, new FakeTransport(link));
  for (let f = 0; f < ticks; f++) {
    a.submit({ move: 1, jump: f % 7 === 0 });
    b.submit({ move: f % 4 === 0 ? 0 : 1, jump: f % 5 === 0 });
    link.advance(FRAME_MS);
    a.tryAdvance(); b.tryAdvance();
    a.retransmit(); b.retransmit();
    link.advance(FRAME_MS);
    a.tryAdvance(); b.tryAdvance();
  }
  return { a: a.state, b: b.state };
}

describe('双人竞速 · 一局完整时序', () => {
  it('理想网络下两个客户端收敛到同一结果', () => {
    const { a, b } = runRace(0, 0, 200);
    expect(a.frame).toBe(b.frame);
    expect(a.players).toEqual(b.players);
    expect(a.winner).toBe(b.winner);
    expect(a.winner).toBe(0); // a 不犹豫,先到终点
  });

  it('200 ms 延迟 + 5% 丢包下仍能完赛且结果一致', () => {
    const { a, b } = runRace(200, 0.05, 600);
    expect(a.frame).toBe(b.frame);
    expect(a.players).toEqual(b.players);
    expect(a.players[0].x).toBeGreaterThanOrEqual(FINISH_X);
    expect(a.winner).toBe(0);
  });
});

两条断言守住两件事:「双方最终到达同一结果」(a.players 与 b.players 逐字段相等,说明确定性成立、帧对齐正确),「200 ms 延迟 + 5% 丢包下仍能完赛」(靠 retransmit 补齐丢包,winner 最终落定)。真实网络下这些数字会变,但测试的性质不变:把联机逻辑从网络里剥出来,用假传输层把边界条件打满。

10.1.7 性能预算与验收标准

联机版比单机多了一层开销。给一组参考预算(需在你的机型与网络下复测):

指标目标说明
客户端单帧逻辑耗时< 2 ms确定性 step + 快照应用 + 插值,占 60 Hz 的 16.6 ms 预算一小部分
客户端内存增量< 8 MB输入缓冲、快照环形队列、远端插值状态
上行消息量约 2 KB/s60 Hz 输入帧、每帧约 5 字节 + 包头
下行消息量约 6 ~ 12 KB/s状态同步快照 + 增量,随实体数变化
起跑时间对齐误差< 50 ms以服务器起跑时间为准

验收标准同样具体:① 确定性核心在 Node 里跑 1 万帧,两端结果逐位相同;② 假传输层注入 200 ms + 5% 丢包,一局仍能完赛;③ 断网 3 秒后重连,能恢复到同一局(5.1);④ 弱网下降级生效,画面不冻结(5.3)。 四条都能自动跑,才是「可验收」。

10.1.8 交接:客户端该做什么

这一节把全卷收成一个工程:六个 net/ 子模块 + 一个确定性核心 + 一套假传输层测试。客户端侧到此完整——只剩一件事没交代:它跟服务端到底怎么对接。消息长什么样、端点什么形态、哪些参数要可配置,都留给下一节。

下一节是10.2 与既有服务端书稿的对接约定 ,它给出一份完整的接口清单与联调检查表,也是全卷的收尾。

阅读导航:上一节:目录 · 下一节:10.2 与既有服务端书稿的对接约定 。

继续阅读

探索更多技术文章

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

全部文章 返回首页

「cocos」更多文章

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