template/docs/dev/replay/replay-array.md
2026-07-13 23:19:17 +08:00

152 lines
11 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.

# 需求综述
录像由数万条录像步组成,每条录像步包含可变数量的参数,参数类型涵盖数字、字符串、布尔值和 bigint。直接操作 `ArrayBuffer` 的字节布局会导致系统耦合存储格式细节、修改录像困难。需要一个独立工具封装底层存储的读写,并提供插入、删除、修改等编辑能力。
# 需求理解
## 明确需求
1. 变长指令编码。指令码使用 `uint8``uint16`,宽度取决于已注册命令的最大 ID。
2. 自描述参数格式。参数使用类型标识 + 变长内容的编码,每条录像步的参数数量可变。类型标识同时承担短字符串的长度信息,节省额外字节。
3. 录像可编辑。支持在任意有效位置插入、删除、修改录像步——不是只追加的日志。
4. 顺序流式读取。播放是按顺序逐帧进行的,需要流式读取器避免每次随机访问的 O(n) 偏移扫描。
## 隐含需求
1. 容量管理。`ArrayBuffer` 不可动态扩容,写入前需检查容量,不足时分配新缓冲并复制。
2. 偏移累积。指令数组中的每条录像步长度不固定,随机访问某一条时需从头扫描累积偏移。流式读取器可以利用顺序访问特性只扫描一次。
# 设计前提
1. 双缓冲存储。指令数组和参数数组各自使用独立的 `ArrayBuffer`,通过 `DataView` 读写。
2. 不可变元数据。`IReplayMetadata` 中的格式参数在初始化时确定,读取时不修改。
3. 边解码边播放。不一次性解码全部数据——沙箱通过流式读取器按需解码。
# 核心概念定义
## 指令数组
录像中所有指令码与参数数量的序列化存储。每条录像步在指令数组中占用 1+W 字节W 为指令码宽度):第一个 `uint8` 为参数数量,后续 `uint8``uint16` 为指令码。
## 参数数组
录像中所有参数内容的序列化存储。每个参数由一个 `uint8` 类型标识开头后接对应类型的二进制内容。类型标识同时编码短字符串的长度8-256 → 长度 n-7
## 流式读取器
持有一个内部偏移指针,每次调用 `read` 解码并返回当前指针指向的录像步,然后推进指针。由于指令数组中每条录像步的总字节数不固定,指针以步索引而非字节偏移为单位——读取器内部维护已扫描过的累积字节偏移,每次 `read` 仅解码一步。
## 指令宽度
指令码的存储字节数。初始为 `uint8`1 字节)。命令 ID 上限在 `registerCommand` 注册时即可获知——若注册了 > 255 的 ID直接初始化为 `uint16`,不涉及运行时重编码。若需要变更已存在录像的指令宽度(例如切换为更大的指令码空间以兼容新增命令,或压缩为更小的空间以节省体积),则通过 `setCommandWidth` 方法将全部指令条目按新宽度重编码。
# 接口设计分析
## `IReplayArray`
### 设计思路
由于录像需要支持任意有效位置的插入和删除——比如玩家在已录制的操作序列中间补充了额外操作,或者修复了某段错误的录像——只提供追加写入是不够的。因此需要 `insert``delete` 方法,以及在原位置修改的 `set` 方法。追加仍然是最频繁的操作——因此需要 `add` 方法。
由于沙箱播放是顺序推进的,每次调用 `get(index)` 都需要从头扫描积累偏移——因此设计 `createReadStream` 创建流式读取器。流式读取器内部维护步索引指针和已累积字节偏移,每次 `read` 仅解码一步,将偏移计算从 O(n²) 降为 O(n)。同时保留 `get` 用于跳转播放等非顺序场景。
由于命令 ID 上限在注册阶段已知,指令宽度在 `IReplayArray` 构造时即确定——若最大 ID > 255 则初始化为 `uint16`。但已存在的录像可能需要变更宽度——例如加载旧 `uint8` 录像后注册了 > 255 的新命令需要升级,或者想将 `uint16` 录像压缩回 `uint8` 以节省存储。因此需要 `setCommandWidth` 方法将全部指令条目按新宽度重编码。
### 接口分析
- 成员 `length: number`:预期频率**中频**。安全系统检测步数变化、沙箱判断播放是否结束。
- 成员 `metadata: IReplayMetadata`:预期频率**低频**。存档时读取以持久化格式参数。
- 方法 `add(id: number, params: ReplayParamValue[])`:预期频率**高频**。每次 `record` 调用一次。
- 方法 `insert(index: number, id: number, params: ReplayParamValue[])`:预期频率**低频**。`index` 等于 `length` 时等同于 `add``index` 超出 `length` 时触发警告并忽略。
- 方法 `delete(index: number)`:预期频率**低频**。
- 方法 `set(index: number, id: number, params: ReplayParamValue[])`:预期频率**低频**。
- 方法 `get(index: number): IReplayStepHandler`:预期频率**低频**。跳转播放时使用。
- 方法 `createReadStream(startIndex?: number): IReplayReadStream`:预期频率**中频**。沙箱播放时调用。
- 方法 `setCommandWidth(width: ReplayCommandWidth)`:预期频率**低频**。变更已存在录像的指令码宽度,将全部指令重编码。
### 预期体量
预期代码体量 300-360 行。
- 参数编码预期 100 行。七种参数类型各自需要独立的写入逻辑:布尔值为类型 0 接 1 字节int8/int16/int32/int64/float64 各为一个单字节类型标识1-5接对应宽度的定长写入bigint 需类型 6 接 `uint16` 长度再接 n 个 `int32`短字符串将长度编码进类型字节8-256长度 = n-7`TextEncoder` 编码后直接写入内容;长字符串用类型 7 接 `uint16` 长度再写内容。每种类型还需容量检查与扩容,七种类型的分支代码量相当可观。
- 参数解码预期 80 行。与编码对称的七种读取分支根据类型字节分发。数值类型按宽度读bigint 读长度后拼接 int32短字符串从类型字节反推长度后 `TextDecoder` 解码,长字符串先读长度。每条分支均含边界检查。
- 指令读写预期 60 行。写入时根据当前 `commandWidth` 决定写入 1 还是 2 字节,读取时同理。每条录像步的指令部分 = `uint8` 参数数量 + W 字节指令码。`get` 和 `createReadStream` 均需维护字节偏移——每次读取或跳过一条录像步时,根据上一条的参数数量和参数总字节长度计算下一条的起始偏移。
- 插入与删除预期 60 行。`insert` 需将指定位置及之后的所有指令和参数数据向后移动以腾出空间,再在空位写入新步。`delete` 则反向——将之后的数据向前移动覆盖被删除步。参数数组的移动量取决于被操作步的参数总字节数,需先解码获取。`set` 本质是先 `delete``insert`
- 容量管理与复制预期 30 行。两个 `ArrayBuffer` 各自独立管理容量,写入前检查,不足时分配更大新缓冲(如 2 倍增长),逐字节复制旧数据后再继续写入。
- 流式读取器预期 25 行。内部维护步索引、指令数组字节偏移、参数数组字节偏移。`read` 解码当前步并推进三个变量,返回 `IReplayStepHandler`,超出 `length` 时返回 null。构造时可选 `startIndex`,需扫描至指定位置。
- 指令宽度变更预期 25 行。`setCommandWidth` 遍历全部已写指令条目——解析每条录像步的参数数量和原宽度的指令码——按新宽度重新写入指令数组。`commandWidth` 可能变大也可能变小,编码前需根据新宽度判断每条指令码是否在范围内(例如 `uint8` 模式下指令 ID > 255 应报错)。
## `IReplayReadStream`
### 设计思路
由于沙箱播放是顺序推进的,流式读取器持有当前步索引指针及已累积的字节偏移,每次调用 `read` 仅解码一步且指针自动推进——避免了 `get(n)` 每次 O(n) 扫描的累积开销。读取完毕后返回 null调用方可判断播放结束。
### 接口分析
- 成员 `index: number`:预期频率**低频**。显示当前播放进度。
- 成员 `length: number`:预期频率**低频**。用于判断是否已读取完毕。
- 方法 `read(): IReplayStepHandler | null`:预期频率**高频**。沙箱每步调用一次。
### 预期体量
预期代码体量 25 行。
## `IReplayMetadata`
### 设计思路
指令码宽度在读取时必须知晓才能正确解析每条录像步。`uint8` 和 `uint16` 是不同的编码模式,不是一个可变参数——因此用枚举 `ReplayCommandWidth` 标记,存档时持久化此枚举值。
### 接口分析
- 成员 `commandWidth: ReplayCommandWidth`:预期频率**低频**。初始化时根据最大命令 ID 设定,指令读写时使用。
### 预期体量
预期代码体量 5 行(含枚举定义)。
---
# 实现思路
## 参数编码格式
| 类型标识 | 参数类型 | 内容编码 |
| -------- | --------------------- | ------------------------------------------------- |
| 0 | 布尔值 | 1 字节0 或 1 |
| 1 | int8 | 1 字节 |
| 2 | int16 | 2 字节 |
| 3 | int32 | 4 字节 |
| 4 | int64 | 8 字节 |
| 5 | float64 | 8 字节 |
| 6 | bigint | `uint16` 长度 N后接 N 个 `int32` |
| 7 | 长字符串≥250 字节) | `uint16` 长度 N后接 `TextEncoder` 编码的 N 字节 |
| 8-256 | 短字符串n-7 字节) | `TextEncoder` 编码的 n-7 字节,无额外长度字段 |
短字符串方案类型标识本身编码了长度8 表示 1 字节256 表示 249 字节),`TextEncoder` 编码后直接写内容。大多数录像参数(道具 ID 名、操作描述等)落在此范围内,基本无需额外长度字段。
## 指令宽度确定
1. `IReplayArray` 构造时传入最大命令 ID`IReplaySystem.registerCommand` 的累积结果提供)。若 maxId > 255`commandWidth` 初始化为 `Uint16`;否则为 `Uint8`
2. 注册阶段即确定宽度,后续 `add`/`insert` 不涉及宽度切换。
3. `setCommandWidth` 用于变更已存在录像的指令宽度:遍历全部指令条目,将原宽度的指令码按新宽度重写,更新 `metadata`。若缩小宽度时存在超过新宽度表示范围的指令 ID应报错。
## 流式读取器内部状态
`read()` 每步仅解码当前指针指向的录像步:
1. 从指令数组当前字节偏移读 `uint8` 参数数量,再按 `commandWidth` 读指令码。
2. 根据参数数量,从参数数组当前字节偏移逐参数解码。
3. 将指令数组字节偏移增加 `1 + commandWidth`,参数数组字节偏移增加已解码参数的总字节数。
4. 返回 `IReplayStepHandler``index` 为当前步索引。指针推进到下一步。
# 涉及文件
## `@user/data-common/src/replay/array.ts`
- [ ] 新增 `ReplayCommandWidth` 枚举
- [ ] 编写 `IReplayArray`、`IReplayReadStream`、`IReplayMetadata` 接口
- [ ] 编写 `ReplayArray` 类:实现 `IReplayArray`,封装双 `ArrayBuffer` 操作、指令宽度管理与变更、CRUD 编辑、流式读取
- [ ] 编写 `ReplayReadStream` 类:实现 `IReplayReadStream`