- 新建 III 导览:读者画像、与 I/II 的区别、前置、八篇地图、阅读路径、边界与自检 - 七篇正文补返回导览,并把已有的兄弟页提及改为真实相对链接(含 event-system 收集章节锚点)
15 KiB
录像系统底层
录像要解决的问题很具体:把玩家的一段操作序列记下来,换一台机器、只跑数据端,也能重放出同一局。要做到这一点,录像不能只存「发生了什么」,还要让读取方按同样的顺序、同样的参数重新执行。
中级教程的录像指令讲过用途与核心原则,高级 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 导览。