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

224 lines
15 KiB
Markdown
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
# 录像系统底层
录像要解决的问题很具体:把玩家的一段操作序列记下来,换一台机器、只跑数据端,也能重放出同一局。要做到这一点,录像不能只存「发生了什么」,还要让读取方按同样的顺序、同样的参数重新执行。
中级教程的[录像指令](../../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`;主动步用它决定播放是否继续,被动步则由 `next(status)` 的状态决定。三种取值是:
- `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<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 组装与初始化](./core-state.md#存档挂载)。
## 内部实现:三块缓冲区
此章节讲解一下录像二进制格式的内部实现。一条录像步在存储里由三块缓冲区配合表示:
- `指令区`:每条指令占固定宽度,第一个字节是参数个数,后面是指令码。宽度为 `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`) | 指令码与参数直接写入,不依赖地图编号 |
新格式的优势集中在几点:
- <strong>紧凑</strong>:每条指令固定宽度,参数按实际范围选择最小类型,不需要先拼文本再压缩。
- <strong>类型保真</strong>:类型标记把布尔、各宽度整数、浮点、bigint 与字符串分开,读回不会把 `1` 当成字符串,也不会把 `true` 当成数字。
- <strong>可随机访问</strong>:索引区让按步号定位成为可能;文本录像只能从头解码。
- <strong>可编辑</strong>:插入、删除、改写会同步平移索引;文本录像每次都要改整串并重新编码。
- <strong>不依赖地图编号</strong>:指令码与参数直接存储;旧文本录像的物品等标识要先换算成地图内容编号,换一份内容就可能对不上。
## 边界与注意
- <strong>参数个数上限</strong>:每条指令最多 255 个参数,超出部分会被截断并告警。
- <strong>不支持的参数类型会被丢弃</strong>:只支持 `number`、`string`、`boolean`、`bigint`;对象等无法编码的值在写入前被丢掉,参数个数随之变化。
- <strong>单步随机读取很慢</strong>:`get(index)` 需要单独解码,不适合在播放或批量处理的循环里反复调用。
- <strong>数组编辑是线性成本</strong>:`insert`、`delete`、`set` 会搬移后续指令与参数并平移索引,越界时不修改并告警;只适合录像编辑器之类的低频操作。
- <strong>大整数幅值上限</strong>:bigint 的幅值必须小于 2 的 2040 次方;超出会告警并丢弃该参数。
- <strong>缩窄指令宽度会截断</strong>:从 16 位缩到 8 位时,超过 255 的指令码会被截断并告警。已有指令码应保持在 255 以内,或者继续使用 16 位宽度。
- <strong>存档返回的是内部引用</strong>:不要就地修改 `commandArray` 与 `paramArray`;长期保存要先复制。读档会重建索引并让旧读取流失效,正在播放的沙箱要重建。
- <strong>旧格式不保证可读回</strong>:旧引擎的文本录像不是当前二进制格式;格式只增不改,也不承诺跨版本读回。
返回[高级 III 导览](./index.md)。