Files
template/docs/advanced/ii/custom-replay-command.md
T
AncTe 7409a47037 docs(10-27): U7 修订批次E(范围/录像/寻路/编辑器占位)
- U7-02:删除范围对象调用处的模块级复用说明注释
- U7-05:指令码位宽收敛为一句,细节指向高级 III
- U7-06:选择框只作概念示例,代码示例恢复为技能示例
- U7-07:NoPath 改为正常结果,无需处理
- U7-08:移除编辑器过渡说法,jsonc 内容改为「需要编辑器」占位
2026-10-22 16:52:38 +08:00

303 lines
17 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.
# 自定义录像指令
高级 I 的[录像一致性](../replay-consistency.md)讲的是「已有行为该不该记」,回答方式是给方法加 `@shouldReplay`。还有一种情况它覆盖不到:行为本身不在引擎里,录像里没有对应的指令码,回放时根本无从执行。这时需要新增一条录像指令。
本篇讲一条自定义指令从指令码、指令对象、注册到记录的完整路径,并用技能给出完整示例。主动步与被动步的配合会先用选择框说明。新增指令码会修改 `packages-user` 下的引擎内容,升级时要预期合并成本;如果只是给已有行为补标记,回到[录像一致性](../replay-consistency.md)即可,不必新增指令。
## 什么时候需要自定义指令
判断标准回到那条核心原则:玩家直接产生、会影响最终成绩的行为必须可复现。
- 行为已经有引擎内置指令覆盖,只缺记录:给入口补 `@shouldReplay` 与 `record`。
- 行为是玩家操作,但引擎没有对应指令(自定义交互、技能、商店交易等),需要一条新指令,让回放时能把这一步重新执行。
中级教程的[录像指令](../../intermediate/replay-commands.md)已经讲过主动与被动、`IReplayCommand` 的主体结构;本篇从指令码、指令对象和注册开始,把一条新指令接进引擎,并讲清被动步在回放时怎么被消费。
## 指令码是格式的一部分
`ReplayCode` 是 `packages-user/data-common/src/replay/types.ts` 里的 `const enum`。它的数值会写进录像的命令数组,属于录像格式的一部分,因此遵循只增不改:新语义必须分配新值,删除或用旧值表示新语义都会让旧录像读错。
```ts
/** 录像指令码,数值属于录像格式的一部分 */
export const enum ReplayCode {
// ...已有录像码省略
/** 使用技能(本篇新增) */
UseSkill,
/** 技能特效的被动步,紧跟主动步(本篇新增) */
SkillEffect
}
```
本篇新增两个码:`UseSkill` 是主动步,`SkillEffect` 是紧跟其后的被动步;两者都要在 `registerCommand` 里注册,否则沙盒按指令码查表时会中止播放。
新增一项意味着修改 `packages-user` 的引擎内容,而不是只改 `src`。`registerCommand` 收的是普通 `number`,跳过枚举直接写数字也能注册;但引擎之后分配新码时可能撞号,所以正式做法仍然是在枚举末尾追加。
指令码默认能容纳 255 个,99.9% 的项目都用不完,本篇不展开位宽细节。若指令码数量确实可能超过 255,参考 `高级 III · 录像格式与指令码位宽`。
## 指令对象
一条指令实现 `IReplayCommand`:
| 成员 | 作用 |
| --------------- | ------------------------------------------------------------------------- |
| `type` | `ReplayCommandType.Active` 或 `Passive` |
| `execute(step)` | 执行这一步,返回 `Promise<ReplayCommandResult>` |
| `finalize?()` | 连续多步同一指令结束时统一收尾,返回 `Promise<ReplayCommandResult>`;可选 |
`step` 提供 `command`(指令码)、`params`(参数列表)与 `index`(读取流给出的步索引)。返回值 `ReplayCommandResult` 有三种:
- `Success`:这一步成功,继续播放;
- `Failed`:这一步失败,播放中止;
- `Ignored`:失败但可以忽略,控制台输出信息后继续。
`finalize` 在两种时机被调用:下一步换了另一条指令,或整条录像已经播完。它没有参数,但**有返回值**:`Promise<ReplayCommandResult>`。返回 `Success` 继续,返回 `Ignored` 记录后继续,返回 `Failed` 会中止播放。需要跨步保存的数据要放在指令对象的成员里。典型用途是「连续多步操作后统一收尾」:每步只更新暂存状态,最后一步结束时再提交。
## 主动录像与被动录像
`ReplayCommandType.Active` 的指令自己执行逻辑,实现 `execute` 即可,是最常见的形式。
被动步不执行自己的逻辑,它的参数要由知道「什么时候该用它」的那一方来取。最典型的例子是选择框:玩家打开选择框并选择一项。录像无法提前知道这里会弹出什么选择框、有哪些选项,只能等选择框出现后,由选择框系统自己读录像里的选择。选择框一般由样板内置,作者通常不需要自己实现;这里只用它说明被动步的场景。
回放时,主动步负责让选择框出现并等它结束;选择框系统在框出现后取被动步,用录下来的选择代替玩家点击,然后调用 `next` 推进。这样,选择框什么时候弹、弹出什么,都由当时运行的界面决定,录像只负责回答「玩家选了什么」。
被动步有两个硬性约束:
- **必须紧跟在一个主动步之后**。`getPassive()` 只在当前主动步执行期间把下一步暴露出来;一个主动步只对应一个紧随其后的被动步。
- **必须在主动步完成前被取走并 `next` 完**。被动步一定源于某个主动操作:主动操作让界面出现,界面再读选择。主动步一旦返回,这一步就不会再作为被动步交给任何人;沙盒会告警并跳过它,等待这一步结果的界面也就永远等不到。所以不要把 `next` 留到主动步返回之后。反过来,主动步预期有被动步、但下一步不是被动步时,`getPassive()` 会返回 `null` 并报错。
取到 handler 后只有一个推进动作:处理完调用 `next`。确实可能需要在多个地方分别取用,但每一处用完都要 `next`;不调用的话,再次 `getPassive()` 拿到的还是同一步。
`getPassive()` 返回的是 `IReplayPassiveHandler | null`,结构为:
```ts
export interface IReplayPassiveHandler {
/** 被动步本身,params 与 command 都在这里 */
readonly step: IReplayStepHandler;
/** 处理完后调用;Success 或 Ignored 会进入下一步,Failed 不推进 */
next(status: ReplayCommandResult): Promise<void>;
}
```
:::info 忘记 next 会怎样
忘记 `next` 不会让整条录像停住,只是这一步没有读到下一个录像。主动步返回后,沙盒发现这条被动录像没被消费,会告警并跳过它,然后继续播放后面的录像。
:::
回放链路可以拆成三步:
1. 玩家入口先 `record` 主动指令,再紧接着 `record` 被动指令——两个录像步必须相邻:
```ts
this.state.replaySystem.record(ReplayCode.UseSkill, skillId, x, y);
// 紧随其后的被动步,给渲染端播放技能特效
this.state.replaySystem.record(ReplayCode.SkillEffect, skillId);
```
2. 回放时,主动指令的 `execute` 在自己的执行期间取被动 handler:
```ts
const passive = this.state.replaySystem.sandbox?.getPassive();
if (passive) {
// 参数在 passive.step.params;先确认它确实是预期的那条被动指令
const [effectSkill] = passive.step.params as [number];
console.log('播放技能特效', effectSkill);
// 渲染端处理完后推进;不调用 next 就不会进入下一步
await passive.next(ReplayCommandResult.Success);
}
```
3. `next` 推进读取流并触发 `onStep`;重复调用 `getPassive()` 在推进前会拿到同一个 handler / 同一步。
主动步的 `execute` 返回值决定播放是否继续;被动步的 `next` 只负责推进读取。通常应在 `await passive.next(...)` 之后返回 `Success`。
被动步的指令对象只用来声明类型和参数,`execute` 不会被沙盒调用:
```ts
import { ReplayCommandResult, ReplayCommandType } from '@user/data-common';
import { BaseReplayCommand } from './commands';
/** 技能特效的被动步:参数由主动步通过 getPassive 取走。 */
export class ReplaySkillEffect extends BaseReplayCommand {
readonly type: ReplayCommandType = ReplayCommandType.Passive;
protected readonly name: string = 'skill-effect';
protected readonly paramTypes: readonly string[] = ['number'];
async wrappedExecute(): Promise<ReplayCommandResult> {
// 被动步不会进入这里;保留实现只是为了满足基类契约
return ReplayCommandResult.Success;
}
}
```
渲染端的播放表现按渲染端教程处理,本篇只写数据端。 <!-- TODO(renderer): 被动录像的渲染端消费 -->
## 用基类减少样板
`src/data/replay/commands.ts` 里的 `BaseReplayCommand` 已经实现了参数校验:它按 `paramTypes` 检查参数个数与 `typeof` 结果,不匹配时直接返回 `Failed`,不会进入子类逻辑。子类只需要提供:
- `name`:写进错误信息的指令名;
- `paramTypes`:参数类型名数组,取值只能是 `'number'`、`'string'`、`'boolean'`、`'bigint'`;
- `wrappedExecute(step)`:真正的执行逻辑,不需要再校验参数类型。
下面是一条 `ReplayUseSkill`:参数为 `[number skillId, number x, number y]`,技能不可用时返回 `Failed`。
```ts
import {
IReplayStepHandler,
ReplayCommandResult,
ReplayCommandType
} from '@user/data-common';
import { IStateSystem } from '@user/data-system';
import { BaseReplayCommand } from './commands';
/** 技能服务:由作者自己的技能系统实现,引擎不内置 */
export interface ISkillService {
/** 判断技能能否在目标点使用 */
canUse(skillId: number, x: number, y: number): boolean;
/** 执行技能效果;回放时也走这里,注意不要在这里写录像 */
apply(skillId: number, x: number, y: number): void;
}
/**
* 使用技能。
* 参数顺序必须与 paramTypes 一致:技能 id、目标横坐标、目标纵坐标。
*/
export class ReplayUseSkill extends BaseReplayCommand {
readonly type: ReplayCommandType = ReplayCommandType.Active;
protected readonly name: string = 'use-skill';
protected readonly paramTypes: readonly string[] = [
'number',
'number',
'number'
];
constructor(
state: IStateSystem,
private readonly skills: ISkillService
) {
super(state);
}
async wrappedExecute(
step: IReplayStepHandler
): Promise<ReplayCommandResult> {
// 参数已由基类校验过,这里可以直接断言类型
const [skillId, x, y] = step.params as [number, number, number];
// 技能不可用时不继续播放,避免后续步骤建立在错误状态上
if (!this.skills.canUse(skillId, x, y)) {
return ReplayCommandResult.Failed;
}
this.skills.apply(skillId, x, y);
return ReplayCommandResult.Success;
}
}
```
`BaseReplayCommand` 的 `execute` 会先校验参数再调用 `wrappedExecute`。因此不要在 `wrappedExecute` 里重复写 `typeof` 判断;参数个数或类型错了,基类已经返回 `Failed`。
如果技能效果是异步的,要在 `wrappedExecute` 里 `await` 完再返回 `Success`,否则后续录像步可能在效果完成前就开始执行。
## 注册
推荐把注册写进插件。插件在 `awake` 里注册自己带来的指令;别人安装这个插件时,指令会随插件一起就位,不需要再改 `initializeReplay`,只需要在 `ReplayCode` 里补上对应的码(如果这个码还不存在)。
```ts
import { ReplayCode } from '@user/data-common';
import { ICoreState, IDataPluginDefine } from '@user/data-state';
import { ISkillService, ReplaySkillEffect, ReplayUseSkill } from './commands';
import { SkillSystem } from './skill';
/**
* 技能插件:技能服务与录像指令一起注册。
* 把插件加进 src/data/plugin/list.ts 的 dataPlugins 后,它会随数据端一起初始化。
*/
export class DataSkillPlugin implements IDataPluginDefine {
readonly id: string = 'data-skill-plugin';
readonly dataDeps: string[] = [];
/** 技能服务:主动步通过它执行技能 */
skill!: ISkillService;
awake(state: ICoreState): void {
this.skill = new SkillSystem(state);
// 注册必须发生在第一次创建沙盒之前;插件的 awake 满足这个时机
state.replaySystem.registerCommand(
ReplayCode.UseSkill,
new ReplayUseSkill(state, this.skill)
);
state.replaySystem.registerCommand(
ReplayCode.SkillEffect,
new ReplaySkillEffect(state)
);
}
async initialize(): Promise<void> {}
async afterLoad(): Promise<void> {}
}
```
同一指令码只能注册一次。重复注册会告警(code 163)并保留先注册的对象,后注册的不会生效;因此不要在插件初始化与主初始化里各注册一遍。只有确实不属于任何插件的引擎内容,才放在 `src/data/replay/initialize.ts` 的 `initializeReplay` 里。
## 记录
注册只让回放认识这条指令,录制仍要在行为入口写入。技能系统里,`use` 负责记录:先 `record` 主动步,再 `record` 紧跟其后的被动步,两个录像步必须相邻;回放时由 `ReplayUseSkill` 执行,不再记录。
`@shouldReplay` 写在 `apply` 上,而不是 `use` 上:真正可能修改状态的是 `apply`,`use` 只是调用它;而且 `use` 里的 `if` 在不可用时会直接返回,不会走到记录。把标记写在 `apply` 上,任何调用 `apply` 的路径都会被检查到。
```ts
import { ReplayCode, shouldReplay } from '@user/data-common';
import { ICoreState } from '@user/data-state';
import { ISkillService } from './commands';
/** 技能系统:玩家入口写录像,回放入口只执行效果 */
export class SkillSystem implements ISkillService {
constructor(private readonly state: ICoreState) {}
canUse(skillId: number, x: number, y: number): boolean {
// ...判断技能是否可用
return true;
}
use(skillId: number, x: number, y: number): void {
if (!this.canUse(skillId, x, y)) return;
// 先记录主动步,再记录紧随其后的被动步;两者必须相邻
this.state.replaySystem.record(ReplayCode.UseSkill, skillId, x, y);
this.state.replaySystem.record(ReplayCode.SkillEffect, skillId);
this.apply(skillId, x, y);
}
/** 真正修改状态的地方,@shouldReplay 写在这里 */
@shouldReplay('Applying a skill should be replayed.')
apply(skillId: number, x: number, y: number): void {
// ...真正的技能效果;回放时由 ReplayUseSkill 调用这里
}
}
```
把「记录 + 执行」的玩家入口与「只执行」的回放入口分开,可以避免同一次技能被记两遍。如果某个被动触发的路径会调用带记录的入口,用 `disable()` 与 `revert()` 把那段包住,并且必须配对。
## 怎么验证
录一段包含技能的录像,再用样板内置的沙盒播放验证:回放时技能会按录像里的参数再次执行,而不是等你操作;结束后检查技能带来的状态变化(例如目标怪物掉血、勇士属性变化)是否与录制时一致。
播放时不要禁用录制。沙盒播放本身也是一遍真实运行;禁用录制的话,这一遍不会留下任何录像,既不能重播,也无法拿它和原录像对比。样板的播放入口已经处理好播放流程,作者一般不需要自己写。
## 边界与注意
- **不要复用已删除的旧指令码**:数值属于录像格式,旧录像会按旧值解码。新语义一律追加新值。
- **参数类型必须与 `paramTypes` 一致**:只支持 `number`、`string`、`boolean`、`bigint`。传对象等无法编码的值会被丢弃,参数个数随之变化,基类会判为失败。
- **被动步必须在主动步期间消费**:`getPassive()` 只在当前主动步执行期间暴露下一步;主动步返回后,未消费的被动步会被沙盒告警并按 code 194 跳过,等待它的代码会一直等下去。取到 handler 后一定要 `next`;忘记 `next` 不会让整条录像停住,只是不会读到下一个录像,沙盒随后会跳过这一步继续播放。
- **`finalize` 返回 `Failed` 会中止播放**:它返回的是 `Promise<ReplayCommandResult>`,和 `execute` 一样会中断后续步骤,不要在里面吞掉错误。它需要跨步状态时,只能把数据保存在指令对象的成员里;指令对象在注册时创建一次,之后复用。
- **指令对象跨回放复用**:成员变量不会自动清空;如果 `execute` 里累积了跨步状态,要在每次 `execute` 开头重置,或在 `finalize` 末尾清理,否则第二次回放会带着上一次的残留。
- **重复注册不会覆盖**:同一指令码重复注册会告警(code 163)并保留先注册的对象。确认注册只发生一次。
- **未知指令码会中止播放**:沙盒查不到指令时告警并停止,不会跳过继续。
返回[高级 II 导览](./index.md)。