diff --git a/docs/advanced/iii/replay-internals.md b/docs/advanced/iii/replay-internals.md new file mode 100644 index 0000000..ff5233c --- /dev/null +++ b/docs/advanced/iii/replay-internals.md @@ -0,0 +1,219 @@ +# 录像系统底层 + +录像要解决的问题很具体:把玩家的一段操作序列记下来,换一台机器、只跑数据端,也能重放出同一局。要做到这一点,录像不能只存「发生了什么」,还要让读取方按同样的顺序、同样的参数重新执行。 + +中级教程的[录像指令](../../intermediate/replay-commands.md)讲过用途与核心原则,高级 I 的[录像一致性](../i/replay-consistency.md)讲过哪些方法该记。本篇讲这套机制底下的东西:录像存成什么形状、录制与播放怎么配合、存档里放了什么。新增一条自定义指令不在本篇,见高级 II 的[自定义录像指令](../ii/custom-replay-command.md)。 + +## 系统模型 + +录像系统由三部分组成: + +| 对象 | 职责 | +| ---------------- | ------------------------------------------------------------ | +| `ReplaySystem` | 对外入口:注册指令、记录一步、创建沙箱、存档与读档 | +| `ReplayArray` | 存储本体:指令区、参数区、索引区,以及顺序读取流 | +| `ReplaySandbox` | 播放器:按读取流推进,处理主动与被动步,提供播放控制 | +| `IReplayCommand` | 单条指令的执行对象,分主动与被动,可选一个连续步骤的收尾方法 | + +`ReplaySystem` 持有 `ReplayArray`、一张「指令码 → 指令对象」的注册表,以及一个可选的当前沙箱。数组才是录像本身;注册表只负责把指令码解释成可执行对象;沙箱只在播放时创建,所以同一份录像可以反复播放,也可以只录制不播放。 + +录制与播放走的是同一份数据:录制时 `record(code, ...params)` 把一步追加到数组末尾;播放时沙箱从数组创建读取流,按步取出、查表、执行。存档只保存数组本身与长度、宽度,既不含指令对象,也不含沙箱状态。 + +```text +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`,三种取值决定播放是否继续: + +- `Success`:这一步成功,继续播放。 +- `Failed`:这一步失败,播放中止。 +- `Ignored`:失败但可以忽略,控制台输出信息后继续。 + +`finalize` 是连续步骤的收尾:**指令切换或录像结束时**,上一条指令会收到一次 `finalize`,同一指令连续多步之间不会逐步触发。典型用途是连续多步操作后统一提交:每步只更新暂存状态,等到收尾时再一次性结算。 + +玩家入口通常把「记录」与「执行」放在一起: + +```ts +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); + // 这里再执行道具效果;回放时读出的参数与录制时一致 + } +} +``` + +玩家入口只负责记录与执行,回放入口只负责执行,这样同一次操作不会在录制时被记两遍。哪些路径必须记,见[录像一致性](../i/replay-consistency.md#什么该记)。 + +## 沙箱播放 + +创建沙箱前先把游戏状态重置到录像起点:`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()` | 取出下一步被动步,不是被动步时返回空 | + +接管被动步的一方通常是选择框之类的界面系统: + +```ts +import { IReplaySandbox, ReplayCommandResult } from '@user/data-common'; + +/** 播放期间的选择框:被动步由这里消费 */ +export class ReplayChoiceConsumer { + constructor(private readonly sandbox: IReplaySandbox) {} + + /** + * 让选择框读取录像里的选择。 + * 主动步负责让选择框出现,本方法负责把被动步的参数交给界面。 + */ + async consume(showChoice: (index: number) => void): Promise { + // 下一步不是被动步时返回 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()`:设置宽度与两个缓冲区、写入长度,然后重建索引,并把此前创建的所有读取流标记为过期。正在播放的沙箱不会自动恢复,需要重新创建。 + +录像的指令码与参数类型码遵循只增不改:新语义必须分配新值,不能复用旧码。格式也不承诺能让新版本读回由旧格式写出的录像。 + +## 内部实现:三块缓冲区 + +此章节讲解一下录像二进制格式的内部实现。一条录像步在存储里由三块缓冲区配合表示: + +- `指令区`:每条指令占固定宽度,第一个字节是参数个数,后面是指令码。宽度为 `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`;长期保存要先复制。读档会重建索引并让旧读取流失效,正在播放的沙箱要重建。 +- 旧格式不保证可读回:旧引擎的文本录像不是当前二进制格式;格式只增不改,也不承诺跨版本读回。