docs(10-13): 编写声明式与命令式接口

This commit is contained in:
unanmed 2026-10-19 22:08:10 +08:00
parent 8f32509199
commit 47777bcb94

View File

@ -0,0 +1,308 @@
# 声明式 vs 命令式接口
如果你已经会用中级教程里的接口做效果,可能注意过一件事:同样是「让某只怪物更强」,有的写法是直接改数值,有的写法只是往数据里加一条 `special`。这不是新旧语法的差别,而是两种完全不同的思路。
高级教程后续的插件、存档、录像与钩子,都建立在第二种思路上。这一层如果没弄明白,后面每篇都会变成死记接口。本篇把两种思路讲透,并告诉你什么时候该用哪一种。
阅读前你只需要会基础语法:变量、函数、`class` 与 `new`、`import`。不要求懂引擎内部结构;涉及内部机制的地方,我们会先说「你能拿它做什么」。
## 一句话区别
- <strong>命令式</strong>:你写一步,代码走到就执行一步。执行时机由你写的位置决定。
- <strong>声明式</strong>:你声明规则、数据或对象,引擎在定义好的时机调用。执行时机由引擎决定。
可以把它想成点菜:
- 命令式像你走进厨房,自己开火、下锅、装盘,每一步都要亲自做,而且只做这一次;
- 声明式像你把菜单交给厨房:你说明「要什么」,厨房在出餐时按流程做好。你不需要知道灶台什么时候点火,也不需要在旁边盯着。
所以,声明式<strong>不是不写代码</strong>。你仍然要写「怎么算、怎么改」;只是把「做什么」与「什么时候做」分开了。分开之后,同一个规则可以被引擎在不同场景反复使用,而不必在每个调用点重写一遍。
## 旧样板:命令式,写到就执行
旧样板里,很多效果是直接写在某段脚本里的:需要强化周围怪物,就在事件脚本里遍历它们、直接改攻击力;需要勇士回血,就直接改勇士当前的血量。
下面只是示意图,用来说明执行方式,<strong>不是可以照抄的新接口</strong>:
```ts
// 旧样板示意(伪代码,不可照抄)
// 事件脚本执行到这里时,直接遍历周围怪物,改写它们“当下”的攻击力
for (const enemy of nearbyEnemies) {
enemy.atk = enemy.atk + 10;
}
// 另一段脚本里,勇士回血也是直接改当前数值
hero.hp = hero.hp + 50;
```
这种写法的执行时机非常直观:代码走到哪,效果就发生到哪。但它会带来几个后果:
- <strong>只在执行到的那一瞬间生效</strong>。脚本没被触发,效果就不存在;触发第二次,效果就再来一次。
- <strong>想重新计算只能重跑</strong>。怪物移动、光环范围变化之后,旧数值不会自动更新;再跑一遍脚本,又容易把加成叠加两次。
- <strong>每条路径都要自己同步</strong>。存档、读档、录像回放、重新进入楼层,这些路径都可能绕开你写的那段脚本,于是状态对不上。
- <strong>没法凭空给怪物加特殊属性</strong>。直接改 `atk` 只是改了一个数字,不能让怪物「获得一条领域属性」,因此也带不出领域对应的地图伤害。
这不是说命令式一无是处。玩家点一下、走一步、用一次道具,本来就是「现在立刻发生」的动作,用命令式接口最直接。问题只出在:把<strong>持续生效的规则</strong>也写成了一次性命令。
## 新样板:声明式,引擎在合适时机执行
新样板把规则与调用时机拆开。你只声明规则长什么样,引擎在它已经定义好的时机调用你。下面五组都是当前源码里真实存在的机制,也是后续高级内容的基础。
### 怪物特殊属性与光环
这是最典型的一组。你不需要在每只怪物身上写「攻击加两成」,只需要在数据里声明它带有哪种特殊属性、参数是多少。
<strong>你声明什么</strong>:`src/content/enemy.jsonc` 里怪物数据对象的 `special` 字段。字段的键是特殊属性编号,值是该属性的参数。给怪物声明一条光环(编号 23)的完整写法见下方示例。光环的参数类型是 `ISpecial23Value`,定义在 `src/data/enemy/special.ts`;这份类型同时被 `aura.ts` 里的转换器与视图使用。
<strong>引擎在哪个时机执行</strong>:引擎构建「计算后怪物」时。`state.enemyContext.buildup()` 会按固定顺序重建每只怪物,把光环、查询、最终效果真正写进属性。`aura.ts` 里的 `CommonAuraConverter` 负责识别编号 23,把它转换成 `CommonAura` 视图;这个转换器在 `src/data/enemy/initialize.ts` 里通过 `ctx.registerAuraConverter(new CommonAuraConverter())` 注册一次即可。
<strong>为什么这比命令式更稳</strong>:怪物模板本身没有被改写,每次重建都从模板的原始值出发,所以反复重建不会让加成越叠越多。你改的是数据里的一条声明,所有读取计算后怪物的场景都会自动看到新结果。存档也只需要保存模板,不需要额外记录「上次光环加了多少」。更细的构建流程见[战斗总览](../intermediate/combat-overview.md),属性本身的写法见[特殊属性](../intermediate/enemy-specials.md)。
#### 示例:给怪物加一条光环
<strong>目标</strong>:让某只怪物给范围内的其他怪物增加 20% 攻击。
<strong>改哪里</strong>:`src/content/enemy.jsonc` 中目标怪物的数据对象,找到它的 `special` 字段。
<strong>复制什么</strong>:把下面的 `special` 字段贴进该怪物对象。JSON 没有 `import`,不需要补引入:
```jsonc
"special": {
"23": {
"range": 1,
"square": false,
"hpBuff": 0,
"atkBuff": 20,
"defBuff": 0
}
}
```
<strong>改什么</strong>:只改参数。`range` 是范围,`square` 决定矩形还是十字,`hpBuff`、`atkBuff`、`defBuff` 是百分比。键 `"23"` 是光环的编号,不要改。
<strong>怎么验证</strong>:重新进入楼层或触发一次重建,查看周围怪物的计算后攻击。数值应当按 `atkBuff` 的百分比提高;改回 0 后应当恢复原值。
<strong>注意</strong>:`special` 的键必须是特殊属性编号,不要把 `"23"` 改成别的名字。也不要在事件脚本里再手动加一遍攻击,否则光环与脚本会叠加。
### 伤害计算器
<strong>你声明什么</strong>:一组属性到伤害结果的换算规则。为了实现「把这组属性换算成勇士掉多少血、要打几回合」,我们需要实现 `IDamageCalculator` 接口,核心方法是 `calculate(handler)`,返回 `{ damage, turn }`。内置实现是 `src/data/enemy/calculator.ts` 里的 `MainDamageCalculator`,它在 `initializeEnemy` 中通过 `useCalculator(new MainDamageCalculator())` 注册。
<strong>引擎在哪个时机执行</strong>:任何需要伤害数值的地方。显示伤害预览、计算临界值、真正结算战斗,都会调用计算器;同一场战斗里,它可能被反复调用很多次。
<strong>为什么这比命令式更稳</strong>:你只描述 `f(enemy, hero)` 这一条规则,所有调用点共用同一份结果。不需要在「显示」和「结算」两处各写一遍公式,也不会出现显示一个数、实际打另一个数。代价是计算器<strong>必须无副作用</strong>,下面会单独解释。
改公式与注册的完整做法见[伤害计算](../intermediate/enemy-calculator.md)。
### 道具效果
<strong>你声明什么</strong>:道具被使用或拾取时要做的事。为了实现这个效果,需要实现 `IItemEffect` 的 `useEffect(item, state)`;如果还要判断能不能用,再实现 `canUse(item, state)`。内容层的 `src/content/item.ts` 就是用对象字面量声明这些效果的。
<strong>引擎在哪个时机执行</strong>:道具系统处理使用与拾取时。`Pick` 类道具在拾取时触发,`Constant` 与 `Consumable` 类道具在使用时触发。这个时机以及道具数量、消耗、存档与录像,都由道具系统统一处理。
<strong>为什么这比命令式更稳</strong>:你只声明「这个道具有什么效果」,不用自己处理扣数量、判定类别、记录录像。只要道具是通过正常入口使用的,效果就会在正确时机发生,也不会漏掉存档同步。
### 可存档对象
<strong>你声明什么</strong>:一份自定义状态该怎么存、怎么读。为了让一份状态跟着存档一起保存,需要实现 `ISaveableContent<T>`:`saveState(compression)` 返回要保存的数据,`loadState(state, compression)` 负责把数据读回来。`saveState` 返回的对象应当经过深拷贝(`structuredClone`),避免和运行中的对象共用引用。
<strong>引擎在哪个时机执行</strong>:存档与读档时。你只需要在初始化阶段注册一次,例如 `state.addSaveableContent('@my/xxx', content)`,之后引擎会在存档、读档时按注册表逐个调用。
<strong>为什么这比命令式更稳</strong>:存与读写在同一个接口里,不会出现「存的时候漏了一个字段、读的时候又忘了恢复」。注册一次之后,压缩级别、调用顺序、跨对象引用都由存档系统统一管理。存档的完整约束会在 `高级教程 I · 存档一致性` 中展开。
### 钩子
<strong>你声明什么</strong>:某个动作发生时要做的事。为了实现这个,需要实现对应的钩子族接口,把处理写进方法体。比如「勇士属性变化时做点什么」,就实现 `IHeroAttributeHooks` 的 `onUpdateAttribute`。
<strong>引擎在哪个时机执行</strong>:对应动作发生时。主对象会通过 `forEachHook` 依次通知挂在自己身上的钩子。你只需要注册一次:
```ts
// [模板] 注册钩子:new 一个钩子实例,挂到关心的主对象上
state.hero.attribute.addHook(new MyHeroAttributeHooks(state));
```
不需要时可以用 `removeHook` 摘掉。内置钩子已经在 `initializeHooks` 里注册好,日常改内置时通常不需要自己调用 `addHook`。
<strong>为什么这比命令式更稳</strong>:你不需要去改每一个调用点。动作发生时引擎统一广播,多个钩子可以同时存在、各自处理;不再需要时也能干净地移除。钩子的完整用法见[钩子](../intermediate/hooks.md)。
这五组的共同点是:你写的不是一串执行步骤,而是「一份规则加一个注册动作」。注册之后,调用时机归引擎。你不需要,也不应该自己再找地方调用它。
## 对照表
| 对比项 | 旧样板:命令式 | 新样板:声明式 |
| ---------- | ------------------------------ | -------------------------------------------- |
| 你写什么 | 一步一步的操作 | 规则、数据或对象 |
| 何时执行 | 代码被执行到的那一刻 | 引擎定义好的时机(构建、使用、存档、事件等) |
| 执行次数 | 你写几次就执行几次 | 由引擎决定,可能反复调用 |
| 副作用 | 每次都要自己保证一致 | 计算类必须无副作用,状态修改走指定时机 |
| 存档与读档 | 自己记录、自己恢复 | 声明 `saveState` / `loadState`,引擎统一调用 |
| 录像 | 容易漏记,回放结果对不上 | 入口动作记入录像,派生结果重算 |
| 组合方式 | 脚本顺序就是执行顺序,容易覆盖 | 注册表与优先级组合,可叠加、可移除 |
| 常见错误 | 忘记在别的路径同步状态 | 把声明式当成不用写逻辑,或在计算器里改状态 |
## 为什么这样设计
把执行时机交给引擎,换来的是下面几件事:
1. <strong>时机集中</strong>。所有光环都在重建计算后怪物时执行,所有存档对象都在存档时执行。调用点只有引擎一处,不会出现「这个效果我在 A 处记得调用、在 B 处忘了」。
2. <strong>从模板重算,避免叠加</strong>。每次重建都从模板的原始值出发,算出来的是「当前应该是什么样」,而不是「上次基础上再加一点」。命令式反复执行同一段脚本,很容易越加越多。
3. <strong>按注册表与优先级组合</strong>。转换器、效果、钩子都注册到对应系统里,多个扩展可以同时存在;需要顺序时由优先级决定,而不是靠你调整脚本摆放位置。
4. <strong>录像只记录玩家入口动作</strong>。玩家的移动、使用道具等动作进入录像,光环、伤害这些派生结果在回放时重新计算。如果派生结果依赖某段命令式脚本执行了几次,回放就会和录制时对不上。
5. <strong>计算会被反复调用</strong>。伤害计算器要同时服务显示、临界与战斗结算,所以它只能读输入、返回结果。把状态修改塞进计算器,第一次和第二次就可能算出不同答案。
## 「副作用」是什么
所谓副作用,就是<strong>除了返回结果之外,还顺手改动了外部状态</strong>。比如写了一个 flag、删了一条记录、改了怪物或勇士的属性,这些都属于副作用。
看一个反例:
```ts
// [反例] 有副作用的计算器:算出结果的同时,还改了外部状态
calculate(handler) {
const damage = 100; // 这里只是示意,真实公式会读 handler
// 副作用:把这次结果写进 flag
this.state.flags.setField('lastDamage', damage);
return { damage, turn: 1 };
}
```
计算器在一次战斗里会被反复调用:显示伤害要算、临界值要二分十几次、真正结算还要算。只要它顺手改了状态,第二次调用的输入就可能已经被第一次改过了。结果是显示、临界与实际战斗互相污染,同一只怪物在不同时刻显示出不同伤害。
需要说明的是,副作用本身不是错。钩子、道具效果、存档对象本来就要改状态;错的是在<strong>只该读输入、且会被反复调用</strong>的位置改状态。判断标准很简单:这个方法会不会被引擎多次调用?如果会,它就应该只读输入、返回结果。
## 地图伤害按「计算后怪物」结算
地图伤害读取的是<strong>计算后怪物</strong>,而不是怪物模板。这一点和光环直接相关。
光环在重建阶段可以给计算后怪物添加特殊属性。如果添加的是领域(13)、夹击(14)、阻击(16)、激光(22)这类属性,那么地图伤害在读取这只计算后怪物时就会看到它们——也就是说,这只怪物确实会获得对应的地图伤害效果。这正是声明式的好处:你只声明「光环给周围怪物加上某条属性」,后续系统会把它当成怪物真实拥有的属性来处理。
旧样板直接改数值,没法凭空给怪物加一条特殊属性,所以做不到「由光环派生出地图伤害」。
如果某个机制既要读取外部环境、又要修改外部环境,应该拆成「查询效果 + 光环效果」两个部分,二者通过新增的一条怪物属性桥接。细节属于更深入的实现,见 `高级教程 II · 光环与查询的优先级`。
<!-- TODO(advanced-II): 查询效果与光环效果通过属性桥接 -->
## 什么时候仍然用命令式
声明式不是万能模板。下面这些场景仍然应该用命令式接口:
- <strong>玩家直接产生的一次性动作</strong>:走一步、使用一个道具、触发一段一次性剧情。它们的语义就是「现在立刻发生」,用命令式最自然。
- <strong>只在该时刻生效、且不需要回放的脚本</strong>:如果一段脚本的效果本来就只应该发生一次,写成命令式没有问题。
- <strong>需要立即得到返回值再决定下一步的流程</strong>:比如先判断能否使用道具,再决定是否扣除数量。
但要记住一条边界:<strong>这些入口动作必须计入录像</strong>。玩家按下的操作进入录像,回放时重新走一遍命令式接口,派生结果再由声明式规则重算。录像一致性的具体要求见 `高级教程 I · 录像一致性`。
可以用一句话概括:<strong>一次性动作用命令式,持续生效的规则用声明式</strong>。
<!-- TODO(advanced-I): 录像一致性 -->
## 模板:声明式扩展的通用骨架
下面给的是机制本身的通用骨架,不对应任何一种具体效果。具体效果只用来帮助理解,不要把它当成模板照抄。
先看各类扩展点分别要写什么、注册到哪里、引擎什么时候调用:
| 扩展点 | 你要实现的东西 | 注册入口 | 引擎调用时机 |
| ---------- | -------------------------------------------- | ---------------------------------------- | ---------------------- |
| 特殊属性 | `createSpecials` 里的一条 `map.set(code, …)` | `initializeEnemy` 统一注册,无需单独调用 | 读取或重建计算后怪物时 |
| 光环 | 一个转换器与一个视图 | `ctx.registerAuraConverter(new …)` | 重建计算后怪物时 |
| 伤害计算器 | `IDamageCalculator.calculate` | `useCalculator(new …)` | 显示、临界、战斗结算时 |
| 道具效果 | `IItemEffect.useEffect` | 写在内容层道具数据里 | 使用或拾取道具时 |
| 可存档对象 | `ISaveableContent.saveState` / `loadState` | `state.addSaveableContent(id, content)` | 存档与读档时 |
| 钩子 | 对应钩子族接口的方法 | `addHook(new …)` | 对应动作发生时 |
通用形状可以写成下面这样。复制后按 `[需改]` 标记修改:
```ts
// [模板] 声明式扩展的通用形状:一个类 + 一次注册 + 一个由引擎调用的方法
export class XxxHandler implements IXxxHandler<IEnemyAttr, IHeroAttr> {
// [模板] 构造时只保存长期引用;不要在这里改游戏状态
constructor(readonly state: ICoreState) {}
// [需改] 方法名与参数照抄你实现的接口(如 calculate、apply、useEffect)
run(handler: IReadonlyXxxHandler<IEnemyAttr, IHeroAttr>): IXxxResult {
// [需改] 只读输入、算出结果;需要改状态时,放到引擎规定的时机里
return /* [需改] 结果 */;
}
}
// [模板] 注册一次,通常写在 initialize.ts 一类初始化函数里
// [需改] 把 someSystem.useXxx 换成对应系统的注册方法
state.someSystem.useXxx(new XxxHandler(state));
```
复制后需要改的地方:
1. <strong>类名与接口名</strong>:`XxxHandler` 与 `IXxxHandler` 换成你实际实现的接口。
2. <strong>方法名与签名</strong>:按接口来写,不同扩展点的方法名不同。
3. <strong>方法体</strong>:只读输入、算出结果;需要改状态时,交给钩子或对应的时机。
4. <strong>注册入口</strong>:按上表找到对应的注册方法,并放进初始化函数里。
5. <strong>是否需要 `#region`</strong>:像 `aura.ts` 这种集中放置多个实现的文件,建议用 `//#region {code}-{name}` 到 `//#endregion` 包起来,结构更清晰,后续编辑器也可能据此识别。
示例一律写成 `class` 再 `new`,不要写成 `const obj = { ... }` 这种 plain object。例外是引擎本身就用对象字面量声明的内容(如 `src/content/item.ts` 的 `ItemEffect`),那里沿用现有写法即可。
## 改内置还是自己写
日常需求优先<strong>改内置</strong>:
- 特殊属性与光环:改 `src/data/enemy/special.ts` 与 `src/data/enemy/aura.ts`;
- 伤害公式:改 `src/data/enemy/calculator.ts` 的 `MainDamageCalculator.calculate`;
- 事件处理:改 `src/data/hook/*` 里已有的钩子类。
这些内置实现已经注册好,你改方法体或加一条分支就会生效,不需要自己写注册代码。不要为每个效果新建一个文件;光环、伤害计算这类实现本来就集中放在对应文件里。
只有在下面这些情况才需要自己写类:
- 你要替换整套策略,例如整条伤害公式都换掉;
- 你要新增一组可独立复用的扩展,例如一个新的光环转换器;
- 你要新增一个系统。此时优先通过插件实现,而不是直接改内置包,见 `高级教程 I · 插件系统`。
<strong>如果你不怎么懂代码,建议直接改内置,而不是自己写新的类</strong>。自己写类要同时处理接口签名、注册时机与上下文刷新,改动面比在内置方法里加一个分支大得多,出错也更难排查。
<!-- TODO(advanced-I): 插件系统 -->
## 常见误区
- <strong>把声明式当成不用写逻辑</strong>。声明式只是把「什么时候执行」交给引擎,「怎么算」仍然要你写清楚。
- <strong>在计算器里改状态</strong>。计算器会被反复调用,任何副作用都会污染显示、临界与结算。要改状态,放到钩子、道具效果或战斗脚本里。
- <strong>在声明式规则里依赖调用顺序</strong>。多个光环、多个钩子之间的顺序不应靠你摆放代码的位置来保证;需要顺序时使用系统提供的优先级。
- <strong>把一次性动作硬做成声明式</strong>。玩家走一步就是现在发生,硬包成「声明规则等引擎调用」只会增加复杂度,还容易漏记录像。
- <strong>只改模板却忘记刷新</strong>。正常流程下引擎会自动重建;只有绕过正常流程直接改怪物对象时,才需要手动标记那只怪物待重算。
## 怎么验证
声明式写对了,最明显的特征是:<strong>只改声明,不改调用代码,结果就会跟着变</strong>。可以按下面三项检查。
<strong>第一项:改一处声明,观察结果是否跟着变</strong>。
在 `src/content/enemy.jsonc` 里把某只怪物的光环参数改大(例如把 `atkBuff` 从 20 改成 50),不要动 `aura.ts`,也不要改任何注册代码。重新进入楼层或触发一次重建,再看这只怪物周围的怪物属性:攻击加成应该按新参数变化。如果没变,先确认你改的是数据里的 `special` 字段,而不是另写了一段命令式脚本。
<strong>第二项:把计算器连续调用两次,确认结果一致</strong>。
用同一份输入调用两次,`damage` 与 `turn` 应该完全相同,并且 flag、怪物属性、勇士属性都不应发生变化。可以用下面的方式取到同一个 handler 后连算两次:
```ts
// [验证] 同一个 handler 连续计算两次,结果应完全一致
const system = state.enemyContext.getDamageSystem();
const info = system?.getDamageInfo(view);
if (system && info) {
const first = system.getDamageInfoByHandler(info.handler);
const second = system.getDamageInfoByHandler(info.handler);
// first 与 second 的 damage、turn 应一致
}
```
如果两次结果不同,或者第二次算完之后状态多出了东西,说明计算器里有副作用,回到 `calculate` 里检查有没有写 flag、删记录或改对象。
<strong>第三项:存档后改状态,再读档,确认恢复</strong>。
先存档,然后修改你声明的状态(例如改一个 flag、移动一次怪物、使用一个道具),再读档。读档后应该回到存档时的状态。如果某个字段没有恢复,检查它所属的对象有没有实现 `ISaveableContent`、有没有通过 `state.addSaveableContent` 注册,以及 `saveState` 返回的对象里有没有漏掉这个字段。
## 小结
- 命令式是「写到就执行」,声明式是「声明规则,引擎在合适时机执行」。
- 声明式不是不写代码,而是把「做什么」与「什么时候做」分开。
- 持续生效的规则用声明式;玩家直接产生的一次性动作用命令式,但要计入录像。
- 计算类扩展必须无副作用;状态修改放到引擎规定的时机里。
- 改内置优先;确实需要独立扩展时再自己写类,新增系统优先考虑插件。