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 弱网模拟与网络诊断 。
继续阅读
探索更多技术文章
浏览归档,发现更多关于系统设计、工具链和工程实践的内容。