# 需求综述 录像由数万条录像步组成,每条录像步包含可变数量的参数,参数类型涵盖数字、字符串、布尔值和 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`