Files
template/docs/advanced/iii/replay-internals.md
T
AncTe 93ac3efec8 docs(10-39): 新增高级 III 导览并补齐互链
- 新建 III 导览:读者画像、与 I/II 的区别、前置、八篇地图、阅读路径、边界与自检

- 七篇正文补返回导览,并把已有的兄弟页提及改为真实相对链接(含 event-system 收集章节锚点)
2026-10-27 14:13:10 +08:00

15 KiB
Raw Blame History

录像系统底层

录像要解决的问题很具体:把玩家的一段操作序列记下来,换一台机器、只跑数据端,也能重放出同一局。要做到这一点,录像不能只存「发生了什么」,还要让读取方按同样的顺序、同样的参数重新执行。

中级教程的录像指令讲过用途与核心原则,高级 I 的录像一致性讲过哪些方法该记。本篇讲这套机制底下的东西:录像存成什么形状、录制与播放怎么配合、存档里放了什么。新增一条自定义指令不在本篇,见高级 II 的自定义录像指令。

系统模型

录像系统由三部分组成:

对象 职责
ReplaySystem 对外入口:注册指令、记录一步、创建沙箱、存档与读档
ReplayArray 存储本体:指令区、参数区、索引区,以及顺序读取流
ReplaySandbox 播放器:按读取流推进,处理主动与被动步,提供播放控制
IReplayCommand 单条指令的执行对象,分主动与被动,可选一个连续步骤的收尾方法

ReplaySystem 持有 ReplayArray、一张「指令码 → 指令对象」的注册表,以及一个可选的当前沙箱。数组才是录像本身;注册表只负责把指令码解释成可执行对象;沙箱只在播放时创建,所以同一份录像可以反复播放,也可以只录制不播放。

录制与播放走的是同一份数据:录制时 record(code, ...params) 把一步追加到数组末尾;播放时沙箱从数组创建读取流,按步取出、查表、执行。存档只保存数组本身与长度、宽度,既不含指令对象,也不含沙箱状态。

record(code, ...params)
  └─ ReplayArray
       指令区:追加一条指令
       参数区:追加载荷
       索引区:记录参数起点

createReplaySandbox(...)
  └─ ReplaySandbox
       读取流按步取出 → 查指令注册表 → 主动执行 / 被动交给外部

saveState()
  └─ { length, commandWidth, commandArray, paramArray }

录制一步

record(code, ...params) 只做一件事:把一步追加到录像末尾。code 是 ReplayCode 里的指令码或你自己注册的码;...params 是这一步带的参数,取值只能是 number、string、boolean、bigint。

指令对象分两类:

类型 行为
ReplayCommandType.Active 自己执行这一步,并返回执行结果
ReplayCommandType.Passive 不执行自己的逻辑,只把参数交出去;外部执行完再回报状态

两类指令的 execute 都返回 ReplayCommandResult;主动步用它决定播放是否继续,被动步则由 next(status) 的状态决定。三种取值是:

  • Success:这一步成功,继续播放。
  • Failed:这一步失败,播放中止。
  • Ignored:失败但可以忽略,控制台输出信息后继续。

finalize 是连续步骤的收尾:指令切换或录像结束时,上一条主动指令会收到一次 finalize,同一指令连续多步之间不会逐步触发。典型用途是连续多步操作后统一提交:每步只更新暂存状态,等到收尾时再一次性结算。

玩家入口通常把「记录」与「执行」放在一起:

import { ReplayCode } from '@user/data-common';
import { ICoreState } from '@user/data-state';

/** 玩家输入入口:一步操作先写进录像,再交给游戏逻辑执行 */
export class PlayerInputRecorder {
    constructor(private readonly state: ICoreState) {}

    /** 玩家向上走一步 */
    stepUp(): void {
        // 向上一步没有额外参数,方向由指令码本身表达
        this.state.replaySystem.record(ReplayCode.Up);
        // 这里再调用移动器执行真正的移动;回放时由对应指令重放同一段逻辑
    }

    /** 玩家使用一个道具 */
    useItem(itemId: number): void {
        // 道具 id 是这一步的参数,会按类型写进参数区
        this.state.replaySystem.record(ReplayCode.UseItem, itemId);
        // 这里再执行道具效果;回放时读出的参数与录制时一致
    }
}

玩家入口只负责记录与执行,回放入口只负责执行,这样同一次操作不会在录制时被记两遍。哪些路径必须记,见录像一致性。

沙箱播放

创建沙箱前先把游戏状态重置到录像起点:createReplaySandbox 接收 route、reseter 与可选的 startIndex、save,先调用 reseter.reset(save),再从 startIndex(默认 0)创建读取流。

播放循环只处理两种步:

  • 主动步:沙箱查表后直接调用 execute,用它的返回值决定是否继续。
  • 被动步:沙箱不执行。它把 { step, next } 交给外部,外部拿到参数、完成自己的操作,再调用 next(status);只有 next 之后读取流才推进。

被动步必须紧跟在一个主动步之后,并在主动步执行期间被取走。主动步返回后仍未消费的被动步会被告警并跳过,等待它的代码也就等不到结果。getPassive() 只在下一步确实是被动步时返回 handler,否则返回 null;在 next 之前重复调用,拿到的是同一步。

播放控制都挂在沙箱上:

成员 作用
play() 开始连续播放
pause() 暂停播放
resume() 从暂停恢复
stop() 终止播放,恢复为正常游戏状态
step() 单步播放一步
setSpeed(speed) 设置播放倍率,1 为正常速度
getReplayed() 已播放步数,不包含当前正在播放的步
getPassive() 取出下一步被动步,不是被动步时返回空

接管被动步的一方通常是选择框之类的界面系统:

import { IReplaySandbox, ReplayCommandResult } from '@user/data-common';

/** 播放期间的选择框:被动步由这里消费 */
export class ReplayChoiceConsumer {
    constructor(private readonly sandbox: IReplaySandbox) {}

    /**
     * 让选择框读取录像里的选择。
     * 主动步负责让选择框出现,本方法负责把被动步的参数交给界面。
     */
    async consume(showChoice: (index: number) => void): Promise<void> {
        // 下一步不是被动步时返回 null,直接走正常的交互逻辑
        const passive = this.sandbox.getPassive();
        if (!passive) return;

        // 参数按录制时的类型读回;这里约定第一个参数是选项下标
        const index = passive.step.params[0] as number;
        // 用录像里的选择代替玩家点击,让选择框走同一段后续逻辑
        showChoice(index);

        // 回报成功,沙箱才会读取下一步;不调用 next 会一直停在这一步
        await passive.next(ReplayCommandResult.Success);
    }
}

存档形状

saveState() 返回四个字段:

字段 含义
length 录像总步数
commandWidth 指令码宽度,8 位或 16 位
commandArray 指令区缓冲区
paramArray 参数区缓冲区

commandArray 与 paramArray 是内部缓冲区的直接引用,不是拷贝。需要长期保存或交给别的存储时,先复制一份;直接就地修改会改到正在使用的录像。

读档走 loadState():设置宽度与两个缓冲区、写入长度,然后重建索引,并把此前创建的所有读取流标记为过期。正在播放的沙箱不会自动恢复,需要重新创建。

录像的指令码与参数类型码遵循只增不改:新语义必须分配新值,不能复用旧码。格式只增不改,也不承诺旧文本格式能被新版本读回。

录像作为可存档对象由主插件注册,装配与挂载顺序见CoreState 组装与初始化。

内部实现:三块缓冲区

此章节讲解一下录像二进制格式的内部实现。一条录像步在存储里由三块缓冲区配合表示:

  • 指令区:每条指令占固定宽度,第一个字节是参数个数,后面是指令码。宽度为 Uint8 时指令码占 1 字节,为 Uint16 时占 2 字节。固定宽度让第 n 步的指令位置可以直接算出来。
  • 参数区:所有步骤的参数首尾相接,每个参数先是 1 字节类型标记,再是载荷。参数长度可以不同,读取时靠类型标记判断该往后走多少。
  • 索引区:一个与步数等长的整数数组,记录每一步的参数在参数区里的起始位置。参数区是变长的,没有索引就无法从步号直接跳到参数。

参数类型标记决定载荷怎么读:

类型码 类型 载荷形态
0 boolean 1 字节的 0 或 1
1 int8 单字节有符号整数
2 int16 16 位有符号整数
3 int32 32 位有符号整数
4 非负 int64 幅值拆成高低两段 32 位整数
5 负 int64 幅值拆成高低两段 32 位整数,读回时取负
6 float 双精度浮点数
7 非负 bigint 1 字节长度,后跟小端排列的幅值字节
8 负 bigint 1 字节长度,后跟小端排列的幅值字节,读回时取负
9 string 32 位字节长度,后跟 UTF-8 编码的字节
32 ~ 255 短字符串 类型码减去 32 就是字节长度,后跟 UTF-8 字节

两个缓冲区都按需扩容:创建时预留初始条数与字节数,不够时按固定乘数扩大,各自有上限。指令区最多容纳一百万步,参数区最多一千万字节;达到上限后不再扩容。索引区随指令区一起扩大。

内部实现:索引与读流

索引让「按步号直接定位参数」成为可能:读取第 n 步时,先从指令区拿到参数个数与指令码,再从索引区拿到参数起点,最后按类型标记逐个解码参数。

随机读单步并不快:get(index) 是独立的随机读取,单步调用比顺序流更重。它是给「确实要跳到某一步」的场景准备的,不是播放路径。

播放走读取流:createReadStream(startIndex) 返回一个带 index、length、expired 的流,read() 每调用一次前进一步,读完返回 null。任何写操作都会让已有读取流失效:追加、插入、删除、改写、切换宽度、整块读档都会把流标记为 expired,过期后继续 read() 会告警。需要修改录像时,先停止播放并重建沙箱。

旧引擎基线对比

以下为旧引擎的文本录像,仅作对比基线,不是当前接口。

维度 旧引擎文本录像 当前二进制录像
容器 core.status.route 字符串数组,存盘时序列化成一段文本 ReplayArray 的指令区、参数区、索引区三块缓冲区
单步表示 U / D / L / R 加数量、I<编号>: 等文本记号 固定宽度指令(参数个数 + 指令码)加类型化参数
序列化 文本拼串,移动做游程压缩,再用 LZString 转 Base64;!! 表示不压缩 直接读写二进制缓冲区分段,不经过文本
随机访问 必须从头顺序解码到目标步 索引区记录参数起点,可按步号定位
参数类型 全部是文本,数字与字符串靠位置和分隔符解析 每个参数带类型标记,保留布尔、整数、浮点、bigint 与字符串
编辑 改完字符串数组后整体重新编码 insert / delete / set 搬移缓冲区并平移索引,仍是线性成本
物品标识 物品 id 与地图内容编号互转(getNumberById / blocksInfo) 指令码与参数直接写入,不依赖地图编号

新格式的优势集中在几点:

  • 紧凑:每条指令固定宽度,参数按实际范围选择最小类型,不需要先拼文本再压缩。
  • 类型保真:类型标记把布尔、各宽度整数、浮点、bigint 与字符串分开,读回不会把 1 当成字符串,也不会把 true 当成数字。
  • 可随机访问:索引区让按步号定位成为可能;文本录像只能从头解码。
  • 可编辑:插入、删除、改写会同步平移索引;文本录像每次都要改整串并重新编码。
  • 不依赖地图编号:指令码与参数直接存储;旧文本录像的物品等标识要先换算成地图内容编号,换一份内容就可能对不上。

边界与注意

  • 参数个数上限:每条指令最多 255 个参数,超出部分会被截断并告警。
  • 不支持的参数类型会被丢弃:只支持 number、string、boolean、bigint;对象等无法编码的值在写入前被丢掉,参数个数随之变化。
  • 单步随机读取很慢:get(index) 需要单独解码,不适合在播放或批量处理的循环里反复调用。
  • 数组编辑是线性成本:insert、delete、set 会搬移后续指令与参数并平移索引,越界时不修改并告警;只适合录像编辑器之类的低频操作。
  • 大整数幅值上限:bigint 的幅值必须小于 2 的 2040 次方;超出会告警并丢弃该参数。
  • 缩窄指令宽度会截断:从 16 位缩到 8 位时,超过 255 的指令码会被截断并告警。已有指令码应保持在 255 以内,或者继续使用 16 位宽度。
  • 存档返回的是内部引用:不要就地修改 commandArray 与 paramArray;长期保存要先复制。读档会重建索引并让旧读取流失效,正在播放的沙箱要重建。
  • 旧格式不保证可读回:旧引擎的文本录像不是当前二进制格式;格式只增不改,也不承诺跨版本读回。

返回高级 III 导览。