《Cocos 游戏联机实战》6.3 观战与回放

观战与回放都不产生输入,只把同一场比赛在本地重演。本节对比观战与参战的数据流差异,讲观战为何只需要插值、不需要预测,说明回放该录输入流与随机种子而非状态,用纯 TS 写出可单测的回放器(记录加重演、关键帧快照支持拖动进度),并估算一局录像的体积。

6.3 观战与回放

房间里的玩家在跑,房间外的观众在看;比赛打完了,还想复盘。观战与回放是两种「不参与」的形态,但它们在客户端里是同一类东西:本地不产生输入,只把一场比赛重演出来。

这一节只讲客户端。观战的服务端转发、回放文件的存储服务都属服务端话题;客户端要解决的问题是:观战怎么接、回放录什么、怎么播、进度条怎么拖。

6.3.1 观战与参战的数据流差异

参战客户端和观战客户端,接收的数据几乎一样(都是服务端的状态快照),差别只在它自己产不产生输入:

维度参战客户端观战客户端
产生输入是,本地预测自己的位置否,只发一个 spectate.join
需要预测需要(本地立即响应,见 4.1)不需要——没有本地输入,预测无从谈起
需要和解需要(收到快照后回滚重演)不需要
需要插值需要(平滑其他实体)需要——把落后收到的快照插值成平滑画面
延迟敏感度高(输入延迟直接影响手感)低(慢半拍没人察觉)

结论很干脆:观战客户端是「去掉预测与和解、只留插值」的参战客户端。第 4.3 插值与外推 那套插值缓冲可以直接复用,把输入与预测相关代码整段删掉即可。

6.3.2 观战延迟:故意落后一段换平滑

观战不但不需要低延迟,反而要主动落后一段。原因:观战端只接收快照,没有本地模拟兜底,一旦网络抖动就无内容可渲染。做法是维持一个插值缓冲,画面永远渲染在「当前最新快照往前 N 毫秒」的位置——缓冲里始终有数据,抖动被抹平。

  • 缓冲深度给 100–200 ms:太浅抖,太深观众感觉画面更慢。
  • 缓冲耗尽(长时间收不到快照)时降级:先冻结画面、显示「网络不稳」,再尝试重连(5.1)。
  • 观战延迟与参战延迟是两个独立参数,别共用一个「网络延迟」配置。

6.3.3 回放录什么:输入流 + 随机种子,而非状态

最直觉的做法是「每帧存一份完整状态」。它有两个致命缺点:体积大(每帧几十个实体的坐标、速度、动画),且与确定性模拟脱钩(存下来的状态无法再参与逻辑)。

正确做法是只录输入流 + 随机种子:

  • 输入流:每逻辑帧两个玩家的按键(加速 / 刹车 / 道具)。一个 accel 只有三态。
  • 随机种子:开局由服务端下发的那个种子(见 6.2.3)。
  • 元数据:赛道 id、圈数、双方 id、开始时间。

回放时把种子喂进确定性模拟,再按帧重放输入,就能逐帧还原当时的整场比赛。前提是模拟本身确定性——这正是 2.1 确定性模拟与定点数 要保证的:同一份输入 + 同一种子 = 同一份状态,跨设备也一样。

6.3.4 怎么播:确定性重演

回放的播放器就是一个「离线跑模拟」的循环:从初始状态出发,按帧取输入、step 一次、把新状态交给表现层。倍速播放只改「每渲染帧跑几个逻辑帧」,不改逻辑本身。

拖动进度是难点:如果每次都从第 0 帧重跑,拖到一局末尾要跑几万帧,必然卡顿。解法是关键帧快照——重演时每隔 N 帧存一份状态快照;拖动到第 K 帧时,找到不晚于 K 的最近快照,从那里往前补跑剩下的几帧即可。这也是 6.3.6 第二条断言要验的东西。

6.3.5 L1:确定性模拟 + 回放器 + vitest

先写一个最小的确定性竞速模拟,全程整数运算(定点数思路),保证可复现:

// net/replay/sim.ts
export interface FrameInput {
  tick: number;
  accel: -1 | 0 | 1;
}

export interface RaceState {
  tick: number;
  x: number;
  v: number;
}

const ACC = 2;
const MAX_V = 12;

export function step(prev: RaceState, input: FrameInput): RaceState {
  let v = prev.v + input.accel * ACC;
  if (v < 0) v = 0;
  if (v > MAX_V) v = MAX_V;
  return { tick: prev.tick + 1, x: prev.x + v, v };
}

记录器只存输入;回放器按需重演,并维护关键帧快照:

// net/replay/replayer.ts
import { step, type FrameInput, type RaceState } from './sim';

export class Recorder {
  readonly frames: FrameInput[] = [];
  record(tick: number, accel: -1 | 0 | 1): void {
    this.frames.push({ tick, accel });
  }
}

export class Replayer {
  private readonly frames: FrameInput[];
  private readonly snapEvery: number;
  private readonly snapshots = new Map<number, RaceState>();

  constructor(frames: FrameInput[], snapEvery = 30) {
    this.frames = frames;
    this.snapEvery = snapEvery;
    this.build();
  }

  // 先跑一遍全程,每 snapEvery 帧留一张关键帧快照
  private build(): void {
    let s: RaceState = { tick: 0, x: 0, v: 0 };
    this.snapshots.set(0, s);
    for (const f of this.frames) {
      s = step(s, f);
      if (s.tick % this.snapEvery === 0) this.snapshots.set(s.tick, s);
    }
  }

  // 第 tick 帧之后的状态:从最近快照补跑,而非从 0 重来
  stateAt(tick: number): RaceState {
    let base = 0;
    for (const k of this.snapshots.keys()) if (k <= tick && k > base) base = k;
    let s = this.snapshots.get(base) as RaceState;
    for (const f of this.frames) {
      if (f.tick >= base && f.tick < tick) s = step(s, f);
    }
    return s;
  }

  finalState(): RaceState {
    const last = this.frames.length ? this.frames[this.frames.length - 1].tick + 1 : 0;
    return this.stateAt(last);
  }
}

两条断言:回放终态与当时的实时模拟一致;拖动到任意进度后状态正确。

// net/replay/replayer.test.ts
import { describe, expect, it } from 'vitest';
import { Recorder, Replayer } from './replayer';
import { step, type RaceState } from './sim';

function recordFrames(n: number): { rec: Recorder; live: RaceState } {
  const rec = new Recorder();
  let s: RaceState = { tick: 0, x: 0, v: 0 };
  for (let i = 0; i < n; i++) {
    const accel = ((Math.floor(i / 7) % 3) - 1) as -1 | 0 | 1;
    rec.record(i, accel);
    s = step(s, { tick: i, accel });
  }
  return { rec, live: s };
}

describe('Replayer', () => {
  it('回放结果与当时一致', () => {
    const { rec, live } = recordFrames(120);
    const rp = new Replayer(rec.frames, 30);
    expect(rp.finalState()).toEqual(live);
  });

  it('拖动进度后状态正确', () => {
    const { rec } = recordFrames(100);
    const truth: RaceState[] = [{ tick: 0, x: 0, v: 0 }];
    for (const f of rec.frames) truth.push(step(truth[truth.length - 1], f));
    const rp = new Replayer(rec.frames, 30);
    expect(rp.stateAt(45)).toEqual(truth[45]);
    expect(rp.stateAt(97)).toEqual(truth[97]);
  });
});

6.3.6 存储与体积估算

一局双人竞速录像的体积(参考值,需按你的逻辑帧率与实体数复测):

内容单帧/单条一局 3 分钟(60 Hz)
输入流(2 人 × accel)~2 字节约 21.6 KB
关键帧快照(每 30 帧)~12 字节约 4.3 KB
元数据(赛道、种子、玩家)—几十字节

合计约 26 KB/局量级。把两个 accel 打包成 2 bit 再差分编码,输入流可再压到约四分之一。结论:录像小到可以随对局结果一起存,无需单独的对象存储服务——这也是「录输入而非录状态」的又一个好处。

6.3.7 L2:回放控制条

回放 UI 是一个 Slider(进度)+ Label(时间)+ Button(播放/暂停)。Slider.progress、Button.EventType.CLICK 已核对 cc.d.ts 存在。滑块拖动只改 progress,由 update 读回并 seek:

import { _decorator, Button, Component, Label, Slider } from 'cc';
import { Replayer } from './net/replay/replayer';

const { ccclass, property } = _decorator;

@ccclass('ReplayControls')
export class ReplayControls extends Component {
  @property(Slider)
  progress: Slider | null = null;
  @property(Label)
  timeLabel: Label | null = null;
  @property(Button)
  playButton: Button | null = null;

  private replayer: Replayer | null = null;
  private total = 0;
  private cursor = 0;
  private playing = false;
  private speed = 1;

  onLoad(): void {
    this.playButton?.node.on(Button.EventType.CLICK, this.toggle, this);
  }
  bind(replayer: Replayer, total: number): void {
    this.replayer = replayer;
    this.total = total;
    this.cursor = 0;
    this.render();
  }
  setSpeed(speed: number): void { this.speed = speed; }

  update(dt: number): void {
    if (this.progress) {
      const target = Math.round(this.progress.progress * this.total);
      if (target !== this.cursor) this.seek(target); // 用户拖动
    }
    if (this.playing) {
      this.cursor += Math.max(1, Math.round(dt * 60 * this.speed));
      if (this.cursor >= this.total) { this.cursor = this.total; this.playing = false; }
      this.render();
    }
  }
  seek(tick: number): void {
    this.cursor = Math.max(0, Math.min(this.total, tick));
    this.render();
  }
  private toggle(): void { this.playing = !this.playing; }
  private render(): void {
    const st = this.replayer?.stateAt(this.cursor);
    if (this.timeLabel) this.timeLabel.string = `tick ${this.cursor}${st ? ` · x=${st.x}` : ''}`;
    if (this.progress) this.progress.progress = this.total ? this.cursor / this.total : 0;
  }
}

6.3.8 工程决策

  • 观战复用插值、删掉预测。同一套同步代码,观战模式关掉本地输入与和解即可。
  • 观战主动落后 100–200 ms。换来抖动被抹平;参战与观战的延迟参数分开配。
  • 回放录输入不录状态。体积小、可参与逻辑、跨端一致,前提是模拟确定性。
  • 拖动必须靠关键帧快照。从 0 重跑在长局里必卡;每 30 帧一张是体积与速度的折中。
  • 倍速只改播放步进,绝不改逻辑帧的 step,否则回放结果与当时不一致。

下一章换到调试视角:联机做完了,怎么在本地复现弱网、怎么诊断一次卡顿到底卡在哪一环。

阅读导航:上一节:6.2 房间生命周期 · 下一节:7.1 弱网模拟与网络诊断 。

继续阅读

探索更多技术文章

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

全部文章 返回首页

「cocos」更多文章

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