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

9.7 KiB
Raw Blame History

需求综述

需要一个能录制并回放游戏过程的系统,通过录像唯一还原一段游戏流程。

需求理解

明确需求

  1. 游戏过程可回放。能通过录像唯一地还原出游戏从起点到终点的一段完整流程。
  2. 录像步可自定义。录像步的类型不限于系统内置——移动、使用道具等——也允许作者自定义。

隐含需求

  1. 异步播放。由于播放每个录像步都伴随渲染动画,动画未结束前不能执行下一步——播放必须是异步的。
  2. 渲染端被动通知。架构约束下渲染端不能主动拉取数据——需要钩子将每步信息推送给渲染端。
  3. 录像需跨会话持久化。录像可能录制于这次游戏,在下次游戏中被播放——需要存档。

未确定需求

无。

设计前提

  1. 确定性执行。计入录像的操作在相同输入下产生相同输出。
  2. 单向依赖。录像系统位于 @user/data-common,不依赖 Layer 1/2。渲染端被动接收信息。
  3. 外部主动录制。系统不自动录制——作者在逻辑系统中手动调用录像步写入接口。
  4. 底层存储独立。录像的二进制存储由 IReplayArray 统一管理(详见 replay-array.md),本系统不直接操作 ArrayBuffer

核心概念定义

录像步

一段游戏流程中不可再分的最小操作单元。由一个指令标识和一组数量可变的参数构成:指令标识区分操作类型,参数描述操作的细节。

录像

一组按时间顺序排列的录像步的序列。完整记录从某初始状态到某最终状态的全部操作,是还原一段游戏流程的充要条件。

录像录制

将玩家操作转化为录像步并通过 IReplayArray.add 追加到末尾的过程。由作者在各逻辑系统(商店、移动等)中主动调用写入接口完成。

录像播放

IReplayArray 中通过流式读取器依次取出录像步并驱动数据端状态变化的过程。由于播放时需要从初始状态逐步重建到目标状态——这是整个系统中唯一需要批量修改全局状态的场景——使用沙箱隔离播放过程。

接口设计分析

IReplaySystem

设计思路

由于要还原游戏流程,需要记录玩家操作。不同操作对应不同录像步类型——因此需要 registerCommand 注册命令,使每种命令 ID 映射到对应的执行对象。

注册了命令类型后,运行时需要向 IReplayArray 中追加新步——因此需要 record 方法,接收命令 ID 和可变长度的参数,内部委托给 IReplayArray.add

由于需要回放录像,且回放时原始录像系统仍需录制新步(做交叉验证),播放需要有独立的执行环境。此外播放前需要重置全局状态,但重置逻辑属于数据端不应被 Layer 0 直接依赖——因此需要 createReplaySandbox 创建沙箱,通过 IStateReseter 接口间接访问重置能力。播放结束需要销毁沙箱——因此需要 releaseSandbox

由于播放期间渲染端和数据端需要切换行为,需要一个成员标记当前是否有活跃沙箱——因此需要 replaying。外部可能需要获取当前沙箱实例——因此需要 sandbox 成员。

接口分析

  • 成员 replaying: boolean:预期频率高频
  • 成员 sandbox: IReplaySandbox | null:预期频率中频
  • 方法 registerCommand(id: number, command: IReplayCommand):预期频率低频
  • 方法 record(id: number, ...params: ReplayParamValue[]):预期频率高频
  • 方法 createReplaySandbox(reseter: IStateReseter, save?: Map<string, unknown>): IReplaySandbox:预期频率低频
  • 方法 releaseSandbox():预期频率低频

预期体量

预期代码体量 60-80 行。

  • 命令注册与管理预期 15 行:Map<number, IReplayCommand> 的增删查。
  • 沙箱创建与销毁预期 20 行:深拷贝 IReplayArray、注入命令注册表和 IStateReseter、创建 ReplaySandbox 实例。
  • 存档实现预期 25 行:saveStateIReplayArray 导出 ArrayBufferIReplayMetadataloadState 从存档数据重建 IReplayArray 并恢复标记。

IReplayCommand

设计思路

由于作者可以自定义录像步类型,播放不同步类型时需要执行不同的操作逻辑。录像系统本身无法知晓这些逻辑,需要作者提供。这些逻辑往往需要持有数据端引用,纯函数回调无法满足——因此需要接口对象 IReplayCommand,作者编写实现类通过构造器持有所需引用。沙箱执行时传递当前步信息——因此 execute 直接接收 IReplayStepHandler

接口分析

  • 方法 execute(step: IReplayStepHandler):预期频率低频。每个命令编写一个实现类。

预期体量

预期代码体量 5 行。

IReplaySandbox

设计思路

由于需要回放录像,播放过程中需要流程控制——连续推进、暂停、单步前进、终止。因此需要 playpauseresumestepstop 五个方法。

由于需求隐含要求异步播放和渲染端被动通知——项目已有 IHookable 通用钩子系统,沙箱扩展 IHookable<IReplaySandboxHook>,每步触发钩子并 await 其完成。

由于播放期间原始录像系统仍在录制,沙箱不能引用正在增长的 IReplayArray——创建时深拷贝,播放时通过 IReplayReadStream 顺序读取每一步,避免每次随机访问的 O(n) 偏移扫描。播放速度需要调节,渲染端据此调整动画时长——因此需要 speed 只读成员和 setSpeed 方法。

接口分析

  • 成员 replaying: boolean:预期频率高频
  • 成员 pausing: boolean:预期频率中频
  • 成员 speed: number:预期频率中频。播放倍率,只读。
  • 方法 setSpeed(speed: number):预期频率低频
  • 方法 play():预期频率低频
  • 方法 pause():预期频率低频
  • 方法 resume():预期频率低频
  • 方法 stop():预期频率低频
  • 方法 step():预期频率中频。返回 Promise<void>

预期体量

预期代码体量 120-160 行。

  • 播放循环预期 80 行。在循环中通过 IReplayReadStream.read() 逐帧解码,传给 IReplayCommand.execute,再触发 onStep 钩子并 await。连续模式下循环自动推进,单步模式执行一步后挂起等待下个 step 调用,暂停冻结循环但不丢失索引,终止跳出循环并清空流式读取器。四种模式的状态切换组合逻辑占主要行数。
  • 状态管理预期 30 行。play/pause/resume/stop/step 之间的互斥与合法性检查——未播放时 pause 无效果,已暂停时 resume 才恢复,stop 后一切操作无效。
  • 钩子集成预期 20 行。扩展 Hookable 后在循环中调用 forEachHook,收集 onStep 的 Promise 并全部 await

IStateReseter

设计思路

由于沙箱播放前需将全局状态恢复到基准状态——从头播放恢复为初始状态,从存档继续播放恢复为存档状态。重置能力属于数据端,不应被 @user/data-common 直接依赖——因此设计 IStateReseter 接口隔离此依赖,reset 接收可选存档数据覆盖两种场景。

接口分析

  • 方法 reset(save?: Map<string, unknown>):预期频率低频

预期体量

预期代码体量 3 行。

IReplaySandboxHook

设计思路

由于架构约束下渲染端被动接收信息,沙箱需要钩子将每步信息推送给渲染端。项目已有 IHookable/IHookBase 体系——因此沙箱钩子扩展 IHookBase,提供 onStep 方法接收 IReplayStepHandler。沙箱 await 其完成后再推进下一步。

接口分析

  • 方法 onStep?(step: IReplayStepHandler): Promise<void>:预期频率低频。渲染端注册时定义一次。

预期体量

预期代码体量 4 行。

IReplayStepHandler

设计思路

由于钩子和命令执行器都需要当前步的完整信息(操作类型、参数、索引),因此设计统一的 handler 风格数据接口 IReplayStepHandler,包含 idparamsindex

接口分析

  • 成员 id: number:预期频率高频
  • 成员 params: readonly ReplayParamValue[]:预期频率高频
  • 成员 index: number:预期频率低频

预期体量

预期代码体量 5 行。

类型别名

ReplayParamValue = number | string | boolean | bigint


实现思路

沙箱播放与交叉验证

  1. 创建沙箱时深拷贝当前 IReplayArray
  2. 调用 reseter.reset(save) 重置全局状态。
  3. 创建 IReplayReadStream,循环:read() 解码一步 → IReplayCommand.execute → 触发 onStepawait
  4. 原始录像系统清空并重新录制——播放中沙箱执行的命令触发 record,若遗漏则安全系统警告。

涉及文件

@user/data-common/src/replay/types.ts

  • 新增 IReplaySystemIReplaySandboxIReplayCommandIStateReseterIReplaySandboxHookIReplayStepHandler 接口与 ReplayParamValue 类型别名

@user/data-common/src/replay/replay.ts

  • 编写 ReplaySystem 类:命令注册、沙箱创建、存档实现

@user/data-common/src/replay/sandbox.ts

  • 编写 ReplaySandbox 类:扩展 Hookable 实现 IReplaySandbox

@user/data-common/src/replay/array.ts

  • 详见 replay-array.md

@user/data-common/src/types.ts

  • 新增 IDataCommon 成员 replay

@user/data-common/src/index.ts

  • 新增 export * from './replay'

@user/data-state/src/core.ts

  • 新增 ReplaySystem 实例化与 IStateReseter 实现
  • 新增存档注册