docs(10-20): 修订 Store/钩子/声明式(用户第2轮)

- data-stores:五个 Store 改为无序列表并补入 enemyStore;删除登记模板与验证节,改为讲读接口
- hooks-in-depth:统一为「一个对象一个钩子实例」,删除身份来源与共享实例内容
- declarative-vs-imperative:去掉 content 示例、地图伤害重述、验证节与改内置小节
- index:同步 Store 数量、钩子表述与 saveState 返回独立对象
This commit is contained in:
unanmed 2026-10-20 22:02:07 +08:00
parent 8912f5d77d
commit 74116b8d67
5 changed files with 238 additions and 673 deletions

View File

@ -9,15 +9,15 @@
### 逐篇处置
| 篇目 | 处置 |
| ----------------------------- | ---------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
| `index.md` | 本身没有六步块;把「怎么验证你读懂了」改为「读完这一层应该能做什么」,保留分层、阅读路径与 D-55 取舍原则。 |
| `declarative-vs-imperative.md` | 「给怪物加一条光环」改为叙述 + 带注释的 `special` 示例(说明键、范围、矩形/十字与三项百分比),验证改为叙述;保留 `ISpecial23Value`、`aura.ts` 转换器与叠加告警。 |
| `plugin-system.md` | 模板六步改为「示例:一个完整插件」:完整 `MyPlugin` 类(含 `import`、生命周期与注释)→ `list.ts` 追加注册示例;删除 `[模板]` / `[需改]` 标记,验证改为叙述,保留 id 唯一性、依赖串/对象两种形态与错误码。 |
| `saveable-content.md` | 模板六步改为「示例:一个可存档对象」完整类 + 「示例:把可存档对象接到插件里」;删除 `[模板]` / `[需改]` 标记;`structuredClone`、压缩参数省略、注册时机等说明保留,验证改为叙述。 |
| `replay-consistency.md` | 「给一个内置方法补录像标记」的六步改为叙述 + 装饰器片段 + 收集区间示例;保留 `getOrInsert` 练习、错误码 `161`、去标记后告警消失的解释,验证改为叙述。 |
| `data-stores.md` | 「模板:登记一条原始定义」与「把一批自定义内容注册进 Store」两处六步改为带注释的 `addTile` 示例与批量登记示例;保留四个 Store 的字段、错误码与 `fromRaw` / `addLayer` 时机说明,验证改为叙述。 |
| `hooks-in-depth.md` | 「只处理指定图层」「地图级钩子里筛图层」两处六步改为完整钩子类 + 注册片段;保留 `===` 身份比较、`alias` 不是身份、`afterLoad` 可能取不到楼层时的显式提示,验证改为叙述。 |
| 篇目 | 处置 |
| ------------------------------ | --------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
| `index.md` | 本身没有六步块;把「怎么验证你读懂了」改为「读完这一层应该能做什么」,保留分层、阅读路径与 D-55 取舍原则。 |
| `declarative-vs-imperative.md` | 「给怪物加一条光环」改为叙述 + 带注释的 `special` 示例(说明键、范围、矩形/十字与三项百分比),验证改为叙述;保留 `ISpecial23Value`、`aura.ts` 转换器与叠加告警。 |
| `plugin-system.md` | 模板六步改为「示例:一个完整插件」:完整 `MyPlugin` 类(含 `import`、生命周期与注释)→ `list.ts` 追加注册示例;删除 `[模板]` / `[需改]` 标记,验证改为叙述,保留 id 唯一性、依赖串/对象两种形态与错误码。 |
| `saveable-content.md` | 模板六步改为「示例:一个可存档对象」完整类 + 「示例:把可存档对象接到插件里」;删除 `[模板]` / `[需改]` 标记;`structuredClone`、压缩参数省略、注册时机等说明保留,验证改为叙述。 |
| `replay-consistency.md` | 「给一个内置方法补录像标记」的六步改为叙述 + 装饰器片段 + 收集区间示例;保留 `getOrInsert` 练习、错误码 `161`、去标记后告警消失的解释,验证改为叙述。 |
| `data-stores.md` | 「模板:登记一条原始定义」与「把一批自定义内容注册进 Store」两处六步改为带注释的 `addTile` 示例与批量登记示例;保留四个 Store 的字段、错误码与 `fromRaw` / `addLayer` 时机说明,验证改为叙述。 |
| `hooks-in-depth.md` | 「只处理指定图层」「地图级钩子里筛图层」两处六步改为完整钩子类 + 注册片段;保留 `===` 身份比较、`alias` 不是身份、`afterLoad` 可能取不到楼层时的显式提示,验证改为叙述。 |
### 其他
@ -39,42 +39,62 @@
**时间:** 2026-10-20
**用户意见(17 条):**
| # | 意见 | 落点 |
|---|------|------|
| U2-01 | 不要用 `content`(`src/content`)中的内容举例——那是编辑器负责的,没人会直接改文件 | 全部 |
| U2-02 | 地图伤害按「计算后怪物」结算,中级教程已讲过,高级教程不必重提 | 相关篇目 |
| U2-03 | 「声明式 vs 命令式」的验证节可以不加——本篇是概念性描述 | `declarative-vs-imperative.md` |
| U2-04 | 用户已修改插件系统结构:**渲染端可以依赖数据端插件,反之不行**;需重新适配文档 | `plugin-system.md`(及相关) |
| U2-05 | 存档顶层**不会**自动 `structuredClone`;必须在 `saveState` 调用端保证返回对象独立、不与任何其他对象同引用(妥协:大存档 `structuredClone` 很慢) | `saveable-content.md` |
| U2-06 | 存档执行器可举例:读档后勇士需切换至目标楼层 → 用执行器调用 `changeFloor`(实际内部实现不同,仅示例) | `saveable-content.md` |
| U2-07 | 「该不该记录」不是试出来的,通常是分析出来的;若不知如何分析,就把所有「修改了游戏状态且该状态可能导致结果改变」的方法都加上 `@shouldReplay` | `replay-consistency.md` |
| U2-08 | `@shouldReplay` 应明确:方法执行了但没探测到录像记录 → 控制台输出「录像记录缺失」警告;此说明要提前,不要脱节;不讲工作原理(留高级 II/III) | `replay-consistency.md` |
| U2-09 | 不讲 `beginReplaySafetyCollection` / `endReplaySafetyCollection`(太底层;调用后续会补;作者绝大多数情况不需要自己写) | `replay-consistency.md` |
| U2-10 | 示例应讲「在自己的方法上加标记」,而不是给内置方法补录;可用商店插件举例 | `replay-consistency.md` |
| U2-11 | 四个 Store 一览表太挤,改为无序列表 | `data-stores.md` |
| U2-12 | Store 登记是因为数据端未写完;说明「绝大多数情况下不需要自己登记」即可 | `data-stores.md` |
| U2-13 | `EnemyStore` 之前漏了,现已补上;同步文档 | `data-stores.md` |
| U2-14 | 模板允许在初始化期间登记内容,但现阶段不必讲(极少遇到),需要讲也放后面 | `data-stores.md` |
| U2-15 | 不要到处讲「改内置还是自己写」——像 Store 几乎不可能需要自定义,不要为它专门开一节;该要求仅针对中级教程 | 全部(尤其 `data-stores.md`) |
| U2-16 | 验证不必每篇都有;Store 只需讲怎么用接口。文档核心目的是让读者知道怎么用接口;几乎不可能自定义的 Store 用内置接口即可,无需验证 | `data-stores.md` 及相关 |
| U2-17 | 「钩子深入」给人感觉怪:一般不会多个钩子对象共用一个钩子,通常一个钩子对象对应一个钩子实例。不必讲共用;只教「一个对象一个实例」,身份来源、不要用 alias 判断身份等内容可删 | `hooks-in-depth.md` |
| # | 意见 | 落点 |
| ----- | --------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | ------------------------------ |
| U2-01 | 不要用 `content`(`src/content`)中的内容举例——那是编辑器负责的,没人会直接改文件 | 全部 |
| U2-02 | 地图伤害按「计算后怪物」结算,中级教程已讲过,高级教程不必重提 | 相关篇目 |
| U2-03 | 「声明式 vs 命令式」的验证节可以不加——本篇是概念性描述 | `declarative-vs-imperative.md` |
| U2-04 | 用户已修改插件系统结构:**渲染端可以依赖数据端插件,反之不行**;需重新适配文档 | `plugin-system.md`(及相关) |
| U2-05 | 存档顶层**不会**自动 `structuredClone`;必须在 `saveState` 调用端保证返回对象独立、不与任何其他对象同引用(妥协:大存档 `structuredClone` 很慢) | `saveable-content.md` |
| U2-06 | 存档执行器可举例:读档后勇士需切换至目标楼层 → 用执行器调用 `changeFloor`(实际内部实现不同,仅示例) | `saveable-content.md` |
| U2-07 | 「该不该记录」不是试出来的,通常是分析出来的;若不知如何分析,就把所有「修改了游戏状态且该状态可能导致结果改变」的方法都加上 `@shouldReplay` | `replay-consistency.md` |
| U2-08 | `@shouldReplay` 应明确:方法执行了但没探测到录像记录 → 控制台输出「录像记录缺失」警告;此说明要提前,不要脱节;不讲工作原理(留高级 II/III) | `replay-consistency.md` |
| U2-09 | 不讲 `beginReplaySafetyCollection` / `endReplaySafetyCollection`(太底层;调用后续会补;作者绝大多数情况不需要自己写) | `replay-consistency.md` |
| U2-10 | 示例应讲「在自己的方法上加标记」,而不是给内置方法补录;可用商店插件举例 | `replay-consistency.md` |
| U2-11 | 四个 Store 一览表太挤,改为无序列表 | `data-stores.md` |
| U2-12 | Store 登记是因为数据端未写完;说明「绝大多数情况下不需要自己登记」即可 | `data-stores.md` |
| U2-13 | `EnemyStore` 之前漏了,现已补上;同步文档 | `data-stores.md` |
| U2-14 | 模板允许在初始化期间登记内容,但现阶段不必讲(极少遇到),需要讲也放后面 | `data-stores.md` |
| U2-15 | 不要到处讲「改内置还是自己写」——像 Store 几乎不可能需要自定义,不要为它专门开一节;该要求仅针对中级教程 | 全部(尤其 `data-stores.md`) |
| U2-16 | 验证不必每篇都有;Store 只需讲怎么用接口。文档核心目的是让读者知道怎么用接口;几乎不可能自定义的 Store 用内置接口即可,无需验证 | `data-stores.md` 及相关 |
| U2-17 | 「钩子深入」给人感觉怪:一般不会多个钩子对象共用一个钩子,通常一个钩子对象对应一个钩子实例。不必讲共用;只教「一个对象一个实例」,身份来源、不要用 alias 判断身份等内容可删 | `hooks-in-depth.md` |
### 第 2 轮处置(`plugin-system` / `saveable-content` / `replay-consistency`)
**修改方式:** 内联执行,仅修改上述三篇与本职记录。
| # | 处置 |
|---|------|
| U2-04 | 按当前 `IDataPluginDefine` / `IClientPluginDefine` 源码把 `dependencies` 属性统一改为 `dataDeps`;新增「数据端与渲染端」小节,说明渲染端可依赖数据端、数据端不能依赖渲染端,并删去数据端文档中的渲染端细节。 |
| U2-05 | 重写「返回值必须独立」一节:明确 `CoreState.saveState` 只负责收集返回值、顶层不再 `structuredClone`;`saveState` 必须自行返回独立对象,并说明大存档深拷贝的性能取舍。 |
| U2-06 | 在「存档顺序与执行器」中加入 `HeroFloorExecutor` 示例:读档后按 `location.floorId` 取楼层并调用 `hero.changeFloor`;注明仅示意、实际内部实现不同,且执行器接口是同步的。 |
| U2-07 | 把「该不该记」改为分析式判断(能否被玩家触发 / 是否改状态 / 是否可能影响结果);判断不了时,给所有「改状态且可能影响结果」的方法都加 `@shouldReplay`。 |
| U2-08 | 在「两个装饰器」之后提前加入「录像记录缺失」警告说明:带标记的方法执行了却没有录像记录时,控制台会告警;不展开内部机制。 |
| U2-09 | 删除 `beginReplaySafetyCollection` / `endReplaySafetyCollection` 及相关收集区间、错误码、`logReplaySafetyDetail` 内容;验证改为回放对比与告警观察。 |
| U2-10 | 删除给内置 `FlagSystem.getOrInsert` 补标记的示例,改为商店插件 `ShopSystem.buyItem` 上添加 `@shouldReplay` 的示例。 |
| U2-15 | 删除三篇中的「改内置还是自己写 / 改现有插件还是新建插件」专门小节;仅保留插件承载新系统的正向说明。 |
| D-60 / D-61 | 三篇按简洁优先删去重复示例与过度解释;常见错误统一收束为文末「边界与注意」小节,主干不再混入边界情况。 |
| # | 处置 |
| ----------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------ |
| U2-04 | 按当前 `IDataPluginDefine` / `IClientPluginDefine` 源码把 `dependencies` 属性统一改为 `dataDeps`;新增「数据端与渲染端」小节,说明渲染端可依赖数据端、数据端不能依赖渲染端,并删去数据端文档中的渲染端细节。 |
| U2-05 | 重写「返回值必须独立」一节:明确 `CoreState.saveState` 只负责收集返回值、顶层不再 `structuredClone`;`saveState` 必须自行返回独立对象,并说明大存档深拷贝的性能取舍。 |
| U2-06 | 在「存档顺序与执行器」中加入 `HeroFloorExecutor` 示例:读档后按 `location.floorId` 取楼层并调用 `hero.changeFloor`;注明仅示意、实际内部实现不同,且执行器接口是同步的。 |
| U2-07 | 把「该不该记」改为分析式判断(能否被玩家触发 / 是否改状态 / 是否可能影响结果);判断不了时,给所有「改状态且可能影响结果」的方法都加 `@shouldReplay`。 |
| U2-08 | 在「两个装饰器」之后提前加入「录像记录缺失」警告说明:带标记的方法执行了却没有录像记录时,控制台会告警;不展开内部机制。 |
| U2-09 | 删除 `beginReplaySafetyCollection` / `endReplaySafetyCollection` 及相关收集区间、错误码、`logReplaySafetyDetail` 内容;验证改为回放对比与告警观察。 |
| U2-10 | 删除给内置 `FlagSystem.getOrInsert` 补标记的示例,改为商店插件 `ShopSystem.buyItem` 上添加 `@shouldReplay` 的示例。 |
| U2-15 | 删除三篇中的「改内置还是自己写 / 改现有插件还是新建插件」专门小节;仅保留插件承载新系统的正向说明。 |
| D-60 / D-61 | 三篇按简洁优先删去重复示例与过度解释;常见错误统一收束为文末「边界与注意」小节,主干不再混入边界情况。 |
**构建:** `pnpm exec prettier --check` 三篇通过;`pnpm exec tsx docs/.vitepress/init.ts && pnpm docs:build` 输出 `build complete`,无 dead link。
**状态:处理中**
### 第 2 轮处置(Store / 钩子 / 声明式 / 导览)
**修改方式:** 派发子代理执行,仅修改上述四篇与本职记录。
| # | 处置 |
| ----------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
| U2-01 | 删除全部 `src/content` 示例:光环改为 `ISpecial23Value` 类型的参数片段(并注明定义在 `src/data/enemy/special.ts`),道具效果只讲 `IItemEffect` 接口,不再指向内容文件。 |
| U2-02 | 删除「地图伤害按计算后怪物结算」整节;命令式局限里也只保留「无法凭空给怪物加特殊属性」。 |
| U2-03 | 删除「验证」节。 |
| U2-11 | 四个 Store 一览表改为无序列表。 |
| U2-12 | 不再写「你需要自己登记」;明确 `add*` 是数据端自用接口、内容加载写完后由数据端统一登记,绝大多数情况只需读。 |
| U2-13 | 补入 `enemyStore`:`IDataCommon.enemyStore`、`IEnemyStore<IEnemyAttr>`、`getEnemy(token)` / `addEnemy`(重复 `num` 告警 `181`),并入五个 Store 的列表、逐项说明与小结。 |
| U2-14 | 删除「正确注册位置」表与两段批量登记示例;只在文末「边界与注意」用一条说明初始化阶段登记。 |
| U2-15 | 删除 `data-stores`、`hooks-in-depth`、`declarative-vs-imperative` 的「改内置还是自己写」小节;`index.md` 保留 D-55 取舍原则,仅把 Store 行改为「通过 `state.*Store` 的接口读」。 |
| U2-16 | 删除 `data-stores` 的「验证」节,改为讲读接口并给一段读示例。 |
| U2-17 | 重写 `hooks-in-depth`:删掉身份来源表、「不要用 alias 判断身份」、「做法 A / B」与共享实例表,统一为「一个对象一个钩子实例」;保留图层级(靠注册限定)与地图级(用 `===` 筛图层)两个示例。 |
| D-60 / D-61 | 四篇按简洁优先删去重复示例与过度解释;残留的特殊情况收敛到文末「边界与注意」。`index.md` 同步 Store 数量、钩子表述与 `saveState` 返回独立对象的说法。 |
**构建:** `pnpm exec prettier --write` 后 `--check` 四篇通过;`pnpm exec tsx docs/.vitepress/init.ts && pnpm docs:build` 输出 `build complete`,无 dead link。
**状态:已处理**

View File

@ -1,14 +1,14 @@
# data-common 的 Store
`@user/data-common` 是数据端的 Layer 0 公共层,它上面挂着四个 Store:`tileStore`、`itemStore`、`mapStore`、`eventStore`。它们看起来像「数据表」,实际是<strong>内容层原始定义的注册表</strong>——你写的地图、图块、道具、事件定义,在这里登记;游戏运行时从这里查出来,再构造自己的对象。
`@user/data-common` 是数据端的 Layer 0 公共层,上面挂着五个内容注册表:`tileStore`、`itemStore`、`mapStore`、`enemyStore`、`eventStore`。它们看起来像「数据表」,实际是<strong>内容层原始定义的注册表</strong>——图块、道具、地图、怪物、事件的定义登记在这里;游戏运行时从这里查出来,再按定义构造自己的对象。
中级教程的[数据端总览](../intermediate/data-state-overview.md)列过这四个入口,[图块存储](../intermediate/tile-store.md)讲了 `tileStore` 的日常用法。这一篇往下一层:四个 Store 各存什么、键是什么、为什么运行时应当只读。
中级教程的[数据端总览](../intermediate/data-state-overview.md)列过这些入口,[图块存储](../intermediate/tile-store.md)讲了 `tileStore` 的日常用法。这一篇讲每个 Store 存什么、接口怎么用,以及为什么运行期只该读。
阅读前需要会基础语法:`class`、`new`、`Map`、`import`。
## Store 是什么
先把三个容易混淆的区分摆清楚:
先把三个区分摆清楚:
| 区分 | 一边 | 另一边 |
| ------------ | -------------------------------- | ------------------------------------ |
@ -16,24 +16,23 @@
| 注册表与存档 | Store 是查表用的注册表,不进存档 | 勇士、地图、Flag 等游戏状态才进存档 |
| 查表与改表 | 运行时读 Store 是正常操作 | 运行时写 Store 会绕过一整套机制 |
Store 里的东西在<strong>加载 / 初始化阶段</strong>被填一次,之后整个游戏过程都靠它查表。它不是「玩家做了什么」的记录,而是「世界上有什么」的登记。
Store 在<strong>加载 / 初始化阶段</strong>被填一次,之后整个游戏过程都靠它查表。它记录的不是「玩家做了什么」,而是「世界上有什么」。
## 四个 Store 一览
## 五个 Store
| Store | 类型 | 存什么 | 键 | 常用读取 | 写入 |
| ------------ | ----------------------- | ------------ | ------------ | -------------------------------------------------------------- | ---------- |
| `tileStore` | `ITileStore` | 图块原始定义 | 图块数字 | `getData` / `getEvent` / `getType` / `num` / `id` / `identity` | `addTile` |
| `itemStore` | `IItemStore<IHeroAttr>` | 道具原始定义 | 道具图块数字 | `getData` / `getCategory` | `addItem` |
| `mapStore` | `IMapStore` | 地图原始数据 | `floorId` | `getMap` | `addMap` |
| `eventStore` | `IGameEventStore` | 事件实例 | 事件 id | `getEvent` | `addEvent` |
- `tileStore`(`ITileStore`):按<strong>图块数字</strong>存图块原始定义 `ITileRawData`。读 `getData` / `getEvent` / `getType`,以及 `num` / `id` / `identity` 换算;写 `addTile`。
- `itemStore`(`IItemStore<IHeroAttr>`):按<strong>道具图块数字</strong>存道具原始定义 `IItemRawData`。读 `getData(token)` / `getCategory(num)`;写 `addItem`。
- `mapStore`(`IMapStore`):按<strong>楼层 id</strong>(`floorId`)存地图原始数据 `IMapRawData`。读 `getMap(floorId)`;写 `addMap`。
- `enemyStore`(`IEnemyStore<IEnemyAttr>`):按<strong>怪物图块数字</strong>存怪物原始数据 `IEnemyRawData`。读 `getEnemy(token)`;写 `addEnemy`。
- `eventStore`(`IGameEventStore`):按<strong>事件 id</strong>存事件实例。读 `getEvent(id)`;写 `addEvent(id, event)`。
四个 Store 都是 `IDataCommon` 上的只读字段,通过 `state` 直接访问,例如 `state.tileStore`。它们没有各自的「管理器」,也不需要你 `new` 一个出来。
五个都是 `IDataCommon` 上的只读字段,通过 `state` 直接访问,例如 `state.tileStore`。它们没有各自的「管理器」,也不需要你 `new` 一个出来。
## 逐个看
## 逐个看接口
### tileStore:图块定义
以<strong>图块数字</strong>为键,存 `ITileRawData`:
以图块数字为键,存 `ITileRawData`:
| 字段 | 含义 |
| ----------- | -------------------------------------------------------------- |
@ -44,30 +43,23 @@ Store 里的东西在<strong>加载 / 初始化阶段</strong>被填一次,之
| `pass` | 通行性(`ITilePassData`:`onlyEvents` / `outPass` / `inPass`) |
| `eventPass` | 事件可通行性:`false` 撞击触发,`true` 走上去触发 |
除了这张表,它内部还维护两组索引:`id → 数字`、`数字 → id`,以及每个图块数字的默认事件。所以你能用任意一种写法查同一条定义。
读取接口:
- `getData(num)`:完整原始定义,不存在返回 `null`;
- `getEvent(num)`:默认事件,返回 `ReadonlyMap`,无事件返回空 `Map`。它每次都会新建一个 `Map`,改它不会影响 Store;
- `getEvent(num)`:默认事件,返回 `ReadonlyMap`。它每次都会新建一个 `Map`,改它不会影响 Store;
- `getType(num)`:图块类型,不存在返回 `TileType.Unknown`;
- `idToNumber` / `numberToId` / `num` / `id` / `identity`:数字与 id 的换算,细节见[图块存储](../intermediate/tile-store.md)。
写入接口只有一个 `addTile(data)`。`num` 或 `id` 撞上已有定义时会告警(错误码 `133` / `134`)并覆盖旧定义。
- `num` / `id` / `identity`:数字与 id 之间的换算,细节见[图块存储](../intermediate/tile-store.md);
- `addTile(data)`:登记一条定义。`num` 或 `id` 撞上已有定义时会告警(错误码 `133` / `134`)并覆盖。
### itemStore:道具定义
以<strong>道具图块数字</strong>为键,存 `IItemRawData`:`num`、`category`(`ItemCategory`)、`name`、`text`、`hideInToolbox`、`effect`(使用 / 拾取效果)、`equip`(装备数值与事件)。`effect` 与 `equip` <strong>都是必填字段</strong>,不是二选一:非装备类除了 `effect`,也要给出一个 `equip`(通常由内容数据填默认值);装备类除了 `equip`,同样要提供 `effect`(可以是空实现)。只写其中一个过不了类型检查。
以道具图块数字为键,存 `IItemRawData`:`num`、`category`(`ItemCategory`)、`name`、`text`、`hideInToolbox`、`effect`(使用 / 拾取效果)、`equip`(装备数值与事件)。`effect` 与 `equip` <strong>都是必填字段</strong>,不是二选一:非装备类也要给出一个 `equip`,装备类也要提供 `effect`(可以是空实现)。
它在构造时接收 `tileStore`,所以 `getData(token)` 同时接受数字与 id,内部先用 `tileStore.num(token)` 换算。
- `getData(token)`:道具原始定义,查不到返回 `null`;
- `getData(token)`:道具原始定义,同时接受数字与 id,查不到返回 `null`;
- `getCategory(num)`:道具分类,查不到返回 `ItemCategory.Unknown`;
- `addItem(data)`:登记一条定义;同一个 `num` 重复登记会告警(错误码 `181`),然后覆盖。
### mapStore:地图原始数据
以<strong>楼层 id</strong>(`floorId`)为键,存 `IMapRawData`:
以楼层 id 为键,存 `IMapRawData`:
| 字段 | 含义 |
| ------------ | ---------------------------------------------------------- |
@ -80,193 +72,69 @@ Store 里的东西在<strong>加载 / 初始化阶段</strong>被填一次,之
- `getMap(floorId)`:原始地图数据,查不到返回 `null`;
- `addMap(map)`:登记一份原始数据,同一个 `floorId` 直接覆盖,不告警。
这份原始数据不会自己变成可玩的地图。运行时楼层由 `state.maps.fromRaw(raw)` 构造:它按 `width` 与 `map` 算出高度、建图层、铺图块、挂事件。也就是说,从原始数据到运行时对象只有一次转换。
这份原始数据不会自己变成可玩的地图。运行时楼层由 `state.maps.fromRaw(raw)` 构造:它按 `width` 与 `map` 算出高度、建图层、铺图块、挂事件。从原始数据到运行时对象只有这一次转换。
### enemyStore:怪物原始数据
以怪物图块数字为键,存 `IEnemyRawData`:`num`、`attribute`(怪物属性)、`special`(特殊属性,键为特殊属性编号、值为参数)。
- `getEnemy(token)`:怪物原始数据,同时接受数字与 id,查不到返回 `null`;
- `addEnemy(data)`:登记一条定义;同一个 `num` 重复登记会告警(错误码 `181`,与道具共用)并覆盖。
它和另外四个 Store 一样挂在主对象上,直接 `state.enemyStore` 取用。
### eventStore:事件实例
以<strong>事件 id</strong>为键,存事件实例(`IReadonlyGameEvent`)。
以事件 id 为键,存事件实例(`IReadonlyGameEvent`)。
- `getEvent(id)`:返回<strong>存储的那个实例</strong>,不是副本;查不到返回 `null`;
- `addEvent(id, event)`:登记一个事件;重复 id 会告警(错误码 `170`),然后覆盖。
- `addEvent(id, event)`:登记一个事件;重复 id 会告警(错误码 `170`)并覆盖。注意签名与另外几个不同,第一个参数是事件 id,第二个才是事件实例。
这里要特别注意:`getEvent` 返回的是存储的实例,不要修改它。你拿到的就是注册表里的那一份,改它等于绕过 `add*` 改了注册表,风险和运行时调用 `addEvent` 一样(见下一节)。
## 为什么运行期只读
## Store 与运行时状态的区别
在游戏进行中调用 `add*`,问题不止一个:
这是理解「为什么只读」的关键。每一对都是「定义」与「实例」的关系:
| Store 里的 | 运行时的 | 改 Store 之后 |
| ------------------------------------- | ----------------------------------------------------------------- | ------------------------------------------------------------------ |
| `tileStore.getData(num)` 的图块定义 | `mapLayer.getTile(x, y)` 的静态图块 `raw()`;动态图块 `raw()` | 静态图块每次现查 Store,会读到新定义;动态图块创建时缓存,不会更新 |
| `itemStore.getData(num)` 的道具定义 | 背包里的道具:`state.hero.items.getItemState(item)` 的 `raw` 字段 | 进背包时已经缓存定义,之后仍指向旧定义 |
| `mapStore.getMap(floorId)` 的原始地图 | `state.maps.getMap(floorId)` 的运行时楼层 | 楼层由 `fromRaw` 一次性构造,改原始数据不会重建它 |
| `eventStore.getEvent(id)` 的事件实例 | 事件层 `eventLayer` 上的 `LayerEventView`,以及事件派发 | 视图只存事件 id,下次派发会按 id 现查,因此会换成新实例 |
一句话概括:Store 只影响「之后构造」与「每次现查」这两类取用路径。有的运行时对象每次都现查(静态图块的 `raw()`、事件派发),有的在创建时就缓存下来(动态图块、背包道具、运行时楼层)。所以在运行时改定义,会让「谁会看到新值」变得难以预测——这正是要避免它的原因。
## 运行时为什么只读
在游戏进行中调用 `addTile` / `addItem` / `addMap` / `addEvent`,问题不止一个:
1. <strong>它不进存档</strong>。四个 Store 都不是可存档对象——`addSaveableContent` 注册的是 `hero`、`flags`、`maps`、`enemyManager`、`replaySystem`,没有 Store。所以读档不会恢复你的改动,也不会把它退回原样。
2. <strong>它不进录像</strong>。`add*` 没有 `@shouldReplay` 标记,录像里不会有「登记这条定义」这一步。重放时这条定义从什么时候开始生效,与原始游玩并不一致,结果就可能对不上。录像标记的规则见[录像一致性](./replay-consistency.md)。
3. <strong>它不保证更新已经存在的对象</strong>。上一节的四对关系说明:有的对象缓存了定义(动态图块、背包道具),有的每次现查(静态图块、事件派发),改完之后谁变了、谁没变取决于取用方式,不可依赖。
1. <strong>它不进存档</strong>。五个 Store 都不是可存档对象——`addSaveableContent` 注册的是 `hero`、`flags`、`maps`、`enemyManager`、`replaySystem`,没有 Store。读档不会恢复你的改动,也不会把它退回原样。
2. <strong>它不进录像</strong>。`add*` 没有 `@shouldReplay` 标记,录像里不会有「登记这条定义」这一步。重放时这条定义从什么时候开始生效,与原始游玩并不一致。
3. <strong>它不保证更新已经存在的对象</strong>。有的运行时对象每次都现查 Store(静态图块的 `raw()`、事件派发),有的在构造时就把定义缓存下来(动态图块、背包道具、运行时楼层)。改完之后谁变了、谁没变,取决于取用方式。
4. <strong>它绕过了检查</strong>。定义改了,存档、录像、内存里的对象三者可能各是各的;差异不会立刻报错,而是在某个不确定的时机表现出来。
所以规则很简单:<strong>登记只在加载期做一次,运行期只读</strong>。这不是风格建议,而是因为它们本来就是「内容」,不是「状态」。
所以规则很简单:<strong>登记只在加载期做一次,运行期只读</strong>。
## 正确注册位置
登记要发生在「游戏开始跑之前」,具体放哪取决于你的数据什么时候可用:
| 位置 | 时机 | 适合登记什么 |
| ------------------------ | -------------------- | ---------------------------------------- |
| 插件 `awake(state)` | 主对象构造期,同步 | 定义直接写死在代码里,不依赖加载结果 |
| 插件 `initialize(state)` | 加载开始前,会被等待 | 登记前要先完成异步准备 |
| 插件 `afterLoad(state)` | 加载完毕后,会被等待 | 要读到加载出来的内容才知道该登记什么 |
| 专门的初始化函数 | 由上面任一处调用 | 把同一来源的一批登记集中成一段,避免散落 |
三个时机的完整含义见[插件系统](./plugin-system.md)。要点是:它们都只在启动流程里各跑一次,不会随玩家操作反复触发。
不要在这些地方调用 `add*`:玩家动作的处理函数、事件回调、每帧逻辑、战斗结算。它们都会在游戏过程中被反复执行,正好踩中上一节的四个问题。
仓库当前的情况是:这些 `add*` 主要由测试直接调用(`packages-user/data-common/src/store/__test__/` 与 `packages-user/data-base/src/**/__test__/`)。你要登记自己的内容时,就按上表的位置集中调用一次。
## 什么时候可以读
读取随时可以:查表、算数、拼提示文本、给界面提供数据,都是正常用法。查表不会改变任何东西,也不产生录像步。
唯一要注意的是<strong>不要就地修改返回的对象</strong>。`getData` / `getMap` / `getEvent` 返回的多是 Store 里存着的那个实例(`tileStore.getEvent` 除外,它每次新建一个 `Map`)。就地改它等于绕过 `add*` 改了注册表:
至于登记这件事本身,绝大多数情况也不用你动手:`add*` 是给数据端自己用的接口——内容加载还没写完,写完后由数据端统一登记,不在你的代码里。日常你只需要按下面这样读:
```ts
// 不要这样做:把 Store 里的定义当成可变对象
const raw = state.tileStore.getData(num);
// raw 就是注册表里的那一条,改它等于绕过 addTile 改了注册表
```
需要不同的定义,就在加载期登记一条新的,而不是运行时改旧的。
## 还有一个 EnemyStore
`data-common` 里还有一个 `EnemyStore`(`IEnemyStore`,`addEnemy` / `getEnemy`),存怪物原始数据:`num`、`attribute`、`special`。
但它<strong>不在 `IDataCommon` 上</strong>:主对象没有把 `enemyStore` 暴露成字段,`@user/data-common` 的公开入口也没有导出 `EnemyStore` 类;仓库里目前只有测试直接 `new EnemyStore(state.tileStore)` 来构造它。所以不要按前面四个 Store 的习惯去取,也不要假设它一定存在。如果你确实需要一张怪物原始数据表,最稳妥的做法是在自己的插件里维护一个同形状的 `Map` 注册表(`addEnemy` / `getEnemy` 的语义可照 `IEnemyStore`),把改动留在自己的代码里。
## 示例:登记一条原始定义
Store 没有「实现一个接口再填空」那种逐对象骨架:`addTile` / `addItem` / `addMap` / `addEvent` 是<strong>逐条登记一条原始定义</strong>的接口,你不需要实现任何方法,只需要把一条条定义交给它。因此这里要展示的是「一条定义长什么样」。
以图块为例,下面是一条完整的 `addTile` 载荷。新建文件时把 `import` 放在顶部,把调用放进插件的 `awake` / `afterLoad` 里:
```ts
import { TileType } from '@user/data-common';
// 登记一条图块定义;num 与 id 必须各自唯一
state.tileStore.addTile({
num: 10001,
id: 'my-custom-tile',
// 默认事件:优先级 → 事件 id
events: {},
// 图块逻辑类型
type: TileType.Terrain,
pass: {
// 是否只在事件层生效
onlyEvents: false,
// 可离开 / 可进入的方向掩码(上 1、右 2、下 4、左 8)
outPass: 0b1111,
inPass: 0b1111
},
// false 撞击触发,true 走上去触发
eventPass: false
});
```
实际使用时,把写死的 `num` / `id` 换成你内容文件里的条目;内容多时用循环读一遍,而不是手写几十条 `addTile`。`num` 与 `id` 必须唯一,撞了会告警并覆盖,等于悄悄丢掉了另一条定义。`outPass` / `inPass` 是方向位掩码,`0b1111` 表示四向都通。
另外三个 Store 的形状相同,把 `addTile` 换成对应调用即可:`addItem(data)` 收 `IItemRawData`,`addMap(map)` 收 `IMapRawData`,`addEvent(id, event)` 收事件实例。注意 `addEvent` 的签名与另外三个不同,第一个参数是事件 id,第二个参数才是事件实例。
上面这段按新建文件写,`TileType` 从 `@user/data-common` 引入;如果放进已有文件,顶部可能已经引入过,不要重复粘。
## 把一批自定义内容注册进 Store
批量登记时,把这些调用集中在插件的一个时机方法里:定义写死在代码里就用 `awake`;要等加载结果就用 `afterLoad`。例如:
```ts
awake(state: ICoreState): void {
// 图块定义直接写在代码里;也可以改成读数组后循环登记
state.tileStore.addTile({
num: 10001,
id: 'my-custom-tile',
events: {},
type: TileType.Terrain,
pass: { onlyEvents: false, outPass: 0b1111, inPass: 0b1111 },
eventPass: false
});
// 其它 Store 同理,逐条登记各自的原始定义
// rawItem / rawMap / event 换成你实际构造的定义
state.itemStore.addItem(rawItem);
state.mapStore.addMap(rawMap);
state.eventStore.addEvent('my-event', event);
}
```
把 `num`、`id` 与各字段换成你自己的数据。图块数字要与你的内容对得上,道具的 `num` 要与对应图块数字一致,地图的 `floorId` 要与切楼层用的 id 一致。插件的文件顶部通常已经有 `IDataCommon` / `ICoreState`,内容类型通常在 `@user/data-common` 里(`TileType` 也在其中);已经引入过就不要重复粘,编辑器报缺少某个名字时再补 `import`。
启动后读回刚登记的内容即可确认:`getData` / `getMap` / `getEvent` 能查到,说明登记成功;查不到就检查是否真的执行到了这个时机方法、以及键是否拼错。
不要在玩家动作、事件回调或每帧里登记;同一个 `num` 重复登记会告警并覆盖,等于把登记做成了「碰运气」。
在 `afterLoad` 里登记还要多确认一步:`mapStore.addMap` 只是把原始数据放进注册表,<strong>不会自动创建运行时楼层</strong>,需要时自己调用 `state.maps.fromRaw(raw)`。`addTile` 也只影响之后构造的图层;如果目标图层已经由 `fromRaw` 建好,新图块不会自动补进去,必须在构造图层之前完成登记,或自己重新构造楼层 / 图层(`fromRaw` / `addLayer`)。
## 改内置还是自己写
- 只是补几条内容定义——在已有的加载 / 注册位置加条目,不要为一批原始数据新造 Store,也不要扩展 `IDataCommon`;
- 确实需要一张全新的原始数据表(现有四个 Store 都装不下)——那属于系统级扩展,先按[插件系统](./plugin-system.md)组织自己的数据,再考虑是否真的需要挂到主对象上。
扩展 `IDataCommon` 意味着改动内置包,版本升级时要跟着改;而补条目只动你自己的内容,升级时几乎不用管。
## 常见错误
- <strong>在玩家动作里登记</strong>。运行时改定义不会传播到已创建的对象,还会绕过存档与录像。
- <strong>以为读档会恢复 Store</strong>。四个 Store 都不在存档里,读档既不恢复、也不回退你的改动。
- <strong>就地修改 `getData` 返回的对象</strong>。那就是在改注册表,效果与运行时 `add*` 相同。
- <strong>修改 `eventStore.getEvent` 返回的实例</strong>。它返回的是存储的实例,改它等于改注册表。
- <strong>把 `add*` 当成「新增一条游戏状态」</strong>。它们登记的是内容定义,不会自动生成运行时对象;楼层要 `fromRaw`,图块要放进图层。
- <strong>重复登记同一个 `num`</strong>。会告警并覆盖,你以为是新增,实际把旧定义挤掉了。
- <strong>假设主对象上有 `enemyStore`</strong>。`EnemyStore` 存在,但 `IDataCommon` 没有把它暴露成字段。
- <strong>在 `getEvent` 上做缓存却以为拿到了快照</strong>。它给的是实例本身,不是替你复制一份。
## 验证
启动后先读回刚登记的定义:
```ts
// [验证] num 可能为 undefined:id 没登记时先停下来
const num = state.tileStore.num('my-custom-tile');
// 先按图块 id 查数字,再取完整定义;没有登记时 num 为 undefined
const num = state.tileStore.num('my-tile');
if (num !== undefined) {
// raw 为 null 表示这条定义不存在
const raw = state.tileStore.getData(num);
console.log(raw, state.tileStore.getType(num));
}
// 道具、地图、怪物同样按各自的键查
const item = state.itemStore.getData('my-item');
const map = state.mapStore.getMap('my-floor');
const enemy = state.enemyStore.getEnemy('my-enemy');
```
`raw` 应为对象而不是 `null`,`getType(num)` 应等于你登记的类型。道具、地图、事件同理:`itemStore.getData(token)`、`mapStore.getMap(floorId)`、`eventStore.getEvent(id)` 都应查得到。
读 Store 随时可以:查表、算数、拼提示文本、给界面提供数据,都是正常用法。查表不会改变任何东西,也不产生录像步。唯一要注意的是<strong>不要就地修改返回的对象</strong>:`getData` / `getMap` / `getEvent` 返回的多是 Store 里存着的那个实例(`tileStore.getEvent` 例外,它每次新建一个 `Map`),就地改它等于绕过 `add*` 改了注册表。
接着在游戏进行中用同一个 `num` 再调用一次 `addTile` 改掉类型,观察三件事:已经存在的对象里,静态图块 `mapLayer.getTile(x, y)!.raw()` 会读到新定义,而已进背包的道具 `raw` 仍是旧定义——同一个改动在不同对象上表现并不一致;存档再读档,改动不会保留,因为 Store 不在存档里;录一段包含相关操作的录像并回放,结果可能因定义生效的时点变了而对不上。这三个观察合起来,就是「运行时不该改 Store」的理由。
## 边界与注意
把 `add*` 从玩家动作或回调里移到插件 `awake` / `afterLoad`,重启游戏再验证一遍:登记只发生一次,行为不再随操作次数变化。
最后留意启动时的控制台:重复登记会告警(`181` / `170` / `133` / `134`)。没有这类告警,说明每个键只登记了一次。
- <strong>确实要补几条定义</strong>。把调用集中放在插件的一个时机方法里(`awake` / `initialize` / `afterLoad`,见[插件系统](./plugin-system.md)),只在启动流程里执行一次;不要放进玩家动作、事件回调、每帧逻辑或战斗结算。
- <strong>登记不会创建运行时对象</strong>。`addMap` 只是把原始数据放进注册表,要可玩还得自己调用 `state.maps.fromRaw(raw)`;`addTile` 也只影响之后构造的图层,图层已经建好就不会自动补进去。
- <strong>重复登记同一个 `num` 会覆盖</strong>。图块告警 `133` / `134`,道具与怪物告警 `181`,事件告警 `170`;不撞键才是安全的登记。
- <strong>返回值不是副本</strong>。改 `getData` / `getMap` / `getEvent` 的返回值等于改注册表;需要不同的定义就登记新的,不要改旧的。
- <strong>读档不会恢复 Store</strong>。改过的定义不会随存档保存,读档也不会把它退回去。
- <strong>`eventStore.getEvent` 给的是实例本身</strong>,不要在上面做缓存并当成快照。
## 小结
- 四个 Store 是 Layer 0 的内容层注册表,存定义,不存游戏状态,也不进存档。
- `tileStore` 按图块数字存图块定义,`itemStore` 按道具图块数字存道具定义,`mapStore` 按 `floorId` 存地图原始数据,`eventStore` 按事件 id 存事件实例。
- 运行时楼层由 `state.maps.fromRaw(raw)` 一次性构造;改 Store 不会重建已有对象。
- 运行时调用 `add*` 会绕过存档、录像与一致性检查,所以只在加载期登记一次。
- 读 Store 随时可以,但不要就地修改返回的对象;`eventStore.getEvent` 返回的是存储的实例。
- `EnemyStore` 不在 `IDataCommon` 上,不要假设主对象能直接取到。
- Store 注册没有逐对象模板,只有「一条定义长什么样」的骨架。
- 需要新的原始数据表时优先按插件组织;不要急着扩展主对象。
- 五个 Store 是 Layer 0 的内容层注册表,存定义,不存游戏状态,也不进存档。
- 键分别是图块数字、道具图块数字、`floorId`、怪物图块数字、事件 id。
- 读随时可以;返回的通常是注册表里的实例,不要就地修改。
- 登记由数据端负责,绝大多数情况不需要自己调用 `add*`,运行期尤其不要写。
- 运行时楼层由 `state.maps.fromRaw(raw)` 一次性构造;改 Store 不会重建它。
返回[高级 I 导览](./index.md)。

View File

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

View File

@ -1,283 +1,28 @@
# 钩子深入
中级教程的[钩子](../intermediate/hooks.md)篇回答的是「什么需求该用哪个钩子」;这一篇回答另一个问题:当同一个钩子类被多个地图、多个图层、多个移动器触发时,你怎么知道这次回调是不是你关心的那个对象。
中级教程的[钩子](../intermediate/hooks.md)回答的是「什么需求该用哪个钩子」;这一篇讲另一件事:怎么把钩子限定到你关心的那个对象上。结论只有一句——<strong>一个钩子对象,对应一个钩子实例</strong>。
钩子本身不难,难在<strong>身份</strong>。一张地图有多个图层,一个楼层集合里又有多张地图;给其中一个图层挂的钩子,不会自动只对那个图层生效,除非你在注册方式或回调里把身份限定住。限定错了,效果就会加到别的楼层或图层上。
钩子接口本身不难,容易出错的是「挂上去了,触发的却不只是你想管的那张地图、那个图层」。下面先讲实例与对象的关系,再用两个例子分别处理图层级与地图级的限定,最后是注册、移除与异步的接口事实。
阅读前需要会基础语法:`class`、`new`、`import`、`async` / `await`。本篇只讲数据端,示例都按当前源码里的接口书写。
## 钩子挂在实例上
## 一个对象,一个钩子实例
一个钩子类只是模板。真正参与事件的是你用 `new` 创建出来的<strong>实例</strong>,以及它被注册到的那个主对象实例。
一个钩子类只是模板。真正参与事件的是你用 `new` 创建出来的<strong>实例</strong>,以及它被注册到的那个对象实例。
`Hookable` 的钩子列表是实例字段:每个地图、每个图层、每个移动器各自持有一份,而不是整个类共享一份。因此:
`Hookable` 的钩子列表是实例字段:每个地图、每个图层、每个移动器各自持有一份,而不是整个类共享一份。所以:
- 同一个钩子类可以 `new` 出很多个实例,分别注册到不同对象;
- 同一个钩子类可以 `new` 出多个实例,分别注册到不同对象;
- 给图层 A 注册的钩子,不会出现在图层 B 上;
- 同一个实例也可以注册到多个对象,但这时它必须能在回调里区分身份,否则就会串。
- 一个实例只应注册到一个对象。
以图层为例,`GameMap.addLayer(alias)` 每次调用都会创建一个新的 `MapLayer`。同一张地图的 `ground` 与 `wall` 是两个不同的 `MapLayer` 实例,各自有独立的钩子列表。地图对象本身也是 `Hookable`,它的钩子列表与图层完全分开。
以图层为例,`GameMap.addLayer(alias)` 每次调用都会创建一个新的 `MapLayer`。同一张地图的 `ground` 与 `wall` 是两个不同的 `MapLayer` 实例,各自有独立的钩子列表。地图对象本身也是 `Hookable`,但它的钩子列表与图层完全分开,接收的是另一套接口(`IGameMapHooks` 与 `IMapLayerHooks`),事件也从不同对象发出。
这也是为什么「给地图加钩子」和「给图层加钩子」不能混:它们接收的是两套不同的接口,事件也从不同的对象发出。
「一个对象一个实例」把身份固定在注册处:钩子在哪个对象上被触发,就是因为它注册在哪个对象上。多数钩子方法因此不需要再判断「这次是不是我要的对象」。
## 多对象场景
## 示例:只处理一个图层
先看几种容易出错的组合:
| 场景 | 实际存在的对象 |
| ---------------------- | --------------------------------------------------- |
| 一张地图有多个图层 | 每个图层一个 `MapLayer`,各自独立 |
| 楼层集合里有多张地图 | 每张地图一个 `IGameMap`,各自独立 |
| 场上有多个动态图块 | 每个动态图块一个 `IDynamicTile`,各自带一个 `mover` |
| 勇士与动态图块同时存在 | 勇士的移动器与图块的移动器是不同实例 |
| 同一楼层 id 被重新创建 | 旧引用与新引用是两个 `IGameMap`,名字相同但引用不同 |
钩子被触发,只能说明「它所属的实例发生了事件」,不能说明那就是你想要的那个实例。比如地图级钩子的 `onResizeLayer(layer, width, height)` 会收到该地图每个图层的尺寸变化;如果你不比较 `layer`,就会对每个图层都执行一次。
地图与图层之间还有一层内部转发:`GameMap.addLayer` 会为每个图层注册一个 `StateMapLayerHook`,把图层的 `onResize` 转发到地图的 `onResizeLayer`,并带上具体 `layer`。所以地图级钩子能拿到图层身份;图层级钩子的大多数回调参数里没有 layer,只有动态图块方法能通过 `tile.layer` 间接拿到。
## 身份从哪里来
判断能不能在回调里做身份校验,先看这个钩子族有没有把身份传回来:
| 钩子族 | 回调里能拿到的身份 | 能否直接比较 |
| -------------------------------------------------------------------------------- | ----------------------------------------- | ---------------------------------------------- |
| `IMapLayerHooks.onResize` / `onUpdateArea` / `onUpdateBlock` | 没有 layer 参数,只有尺寸、区域或图块坐标 | 不能 |
| `IMapLayerHooks.onOpenDoor` / `onCloseDoor` | 没有 layer 参数,只有门的位置或图块数字 | 不能 |
| `IMapLayerHooks.onCreateDynamic` / `onDeleteDynamic` / `onUpdateDynamicPosition` | `tile`,而 `tile.layer` 是它所属的图层 | 可以比较 `tile.layer` |
| `IGameMapHooks.onChangeBackground` | 只有背景图块数字 | 不能判断是哪张地图 |
| `IGameMapHooks.onUpdateLayer` | `layerList`,当前地图的图层集合 | 只能看集合内容,不能直接判断是哪张地图触发 |
| `IGameMapHooks.onResizeLayer` | `layer`、宽度、高度 | 可以比较 `layer`;`layer.map` 还能拿到所属地图 |
| `IObjectMoverHooks` 的各方法 | `tile` 与 `mover` | 可以比较 `tile` 或 `mover` |
| `IHeroLocationHooks.onSetFloor` | `map`,即设置后的楼层对象 | 可以比较 `map` |
| `IHeroLocationHooks.onSetPos` | 只有坐标 | 不能;但勇士位置通常只有一个 |
| `IHeroStateHooks.onBeforeChangeFloor` / `onAfterChangeFloor` | `info`,其中 `info.target` 是目标楼层 | 可以比较 `info.target` |
这张表决定了两种做法:没有身份参数的钩子,只能靠「每个对象一个实例」把身份固定在注册处;有身份参数的钩子,才可以共享一个实例,在回调里用 `===` 比较。
### 不要用 alias 判断身份
`alias` 是图层的查找名,不是身份。两张不同的地图可以有同名图层;同一张地图上重复调用 `addLayer('ground')` 也会再建一个图层,并覆盖 `aliasLayerMap` 里的映射。用 `layer.alias === 'ground'` 只能说明「名字叫 ground」,不能说明「就是当初那个图层对象」。
要判断身份,用对象引用:`layer === this.targetLayer`、`map === this.targetMap`、`tile === this.targetTile`。引用比较不受重名影响。
## 做法 A:每个对象一个钩子实例
这是图层钩子的默认做法,也是最不容易出错的做法。
构造钩子时把目标对象保存为字段,注册时只注册到那个对象上。以图层为例,构造时保存 `readonly layer`:
```ts
// 每层一个实例的图层钩子
export class MyLayerHooks implements IMapLayerHooks {
constructor(
readonly state: ICoreState,
readonly layer: IMapLayer
) {}
// 这个方法没有 layer 参数;触发者就是注册时的 this.layer
onUpdateBlock(block: number, x: number, y: number): void {
// 只在这个图层上做事
}
// 动态图块钩子会回传 tile,可以再核对一次
onCreateDynamic(tile: IDynamicTile): void {
if (tile.layer !== this.layer) return;
// 只处理目标图层上的动态图块
}
}
```
注意 `onUpdateBlock` 的签名里没有 `layer`。所以不要在里面写 `if (layer !== this.layer) return`,那个 `layer` 根本不存在。这个实例只注册在 `this.layer` 上,触发者必然是它;身份在注册时就确定了。
`readonly layer` 的作用是让钩子能访问目标图层本身,例如读宽高、调用图层方法,或者在动态图块回调里核对 `tile.layer`。它不是用来比较一个不存在的参数。
如果钩子是通用的、构造时还拿不到图层,可以利用 `awake` 收到的控制器。`MapLayer` 的控制器是 `IMapLayerHookController`,带 `layer` 字段与 `getMapData()`:
```ts
// 在 awake 里取得所属图层
awake(controller: IMapLayerHookController): void {
// controller.layer 就是注册这个钩子的图层
}
```
这种做法适合「先写一个通用钩子类,注册到哪一层就服务哪一层」的工厂。无论用哪种方式,核心都是:<strong>一个实例只服务一个对象</strong>。
## 做法 B:共享实例,用 `===` 比较
如果钩子回调会回传身份,可以只创建一个实例,注册到多个对象,在回调里筛掉不关心的触发者。
例如地图级钩子 `IGameMapHooks.onResizeLayer(layer, width, height)` 会收到该地图所有图层的 resize。共享实例时,构造时保存目标图层 `targetLayer`:
```ts
// 共享实例:在回调里比较目标图层
export class MyMapResizeHooks implements IGameMapHooks {
constructor(
readonly state: ICoreState,
readonly targetLayer: IMapLayer
) {}
onResizeLayer(layer: IMapLayer, width: number, height: number): void {
// 只处理目标图层;其他图层直接返回
if (layer !== this.targetLayer) return;
this.state.emitTip(`目标图层变为 ${width} x ${height}`);
}
}
```
同样的思路可以用于:
- `IHeroLocationHooks.onSetFloor(map)`:比较 `map`;
- `IObjectMoverHooks` 的 `onMoveStart` / `onStepEnd` 等:比较 `tile` 或 `mover`;
- `IHeroStateHooks.onBeforeChangeFloor` / `onAfterChangeFloor`:比较 `info.target`;
- `IMapLayerHooks` 的动态图块方法:比较 `tile.layer`。
但 `IMapLayerHooks` 的 `onResize`、`onUpdateBlock`、`onOpenDoor` 等方法没有 layer 参数,不能用这种做法。它们必须走做法 A。
### 共享实例不要保存「当前对象」
共享实例只适合保存<strong>固定的目标</strong>,例如 `targetLayer`、`targetTile`。不要保存「当前正在处理的对象」:
```ts
// 不要这样:共享实例里保存“当前对象”
onResizeLayer(layer: IMapLayer): void {
this.currentLayer = layer;
// 后续异步逻辑再读 this.currentLayer
}
```
异步钩子可能交错执行:A 图层的事件还没处理完,B 图层的事件已经进来,`currentLayer` 会被覆盖。需要当前对象时,用回调参数或局部变量;需要跨异步步骤保留,就在回调开头把它存进局部常量。
## 同一钩子被多个对象复用
「能共享」不等于「应该共享」。按钩子族分开看:
| 钩子族 | 能否共享一个实例 | 条件 |
| --------------------------------------- | ---------------- | ------------------------------------------------------------ |
| `IMapLayerHooks`(无 layer 参数的方法) | 不能 | 回调无法区分图层,必须每层一个实例 |
| `IMapLayerHooks`(动态图块方法) | 可以 | 回调里比较 `tile.layer` |
| `IGameMapHooks.onResizeLayer` | 可以 | 回调里比较 `layer` |
| `IGameMapHooks.onChangeBackground` | 不建议 | 回调没有地图身份;跨地图共享会分不清是谁的背景变化 |
| `IGameMapHooks.onUpdateLayer` | 不建议 | 只有图层集合,无法可靠判断是哪张地图触发 |
| `IObjectMoverHooks` | 可以 | 回调里比较 `tile` 或 `mover`;若每个对象有独立状态则不要共享 |
| `IHeroLocationHooks.onSetFloor` | 可以 | 回调里比较 `map` |
| `IHeroStateHooks`(切层钩子) | 可以 | 回调里比较 `info.target`;若只服务一个勇士则无需共享 |
如果钩子内部要为每个对象保存不同状态(计数、上次位置、临时标记),即使接口允许共享,也建议每对象一个实例。共享实例的字段只有一份,很难避免互相覆盖。
为多个对象创建实例时,用一个工厂把「创建 + 注册」收在一起,避免漏注册或注册到错误对象:
```ts
// 为每个图层各建一个实例
function addLayerHook(state: ICoreState, layer: IMapLayer) {
return layer.addHook(new MyLayerHooks(state, layer));
}
```
调用方对每个目标图层调用一次即可。不要在事件回调里临时 `new` 钩子再注册;那样每次事件都会多一个钩子,且旧钩子很难清理。
## 注册、移除与生命周期
### addHook 返回控制器
`addHook` 会创建控制器、把钩子写入内部映射与列表、调用 `awake`,最后返回控制器:
```ts
// 注册并保留控制器
const hook = new MyLayerHooks(state, layer);
const controller = layer.addHook(hook);
```
控制器最重要的方法是 `unload()`,它会按控制器移除对应钩子。对图层来说,控制器还带 `layer` 与 `getMapData()`,可以在 `awake` 或后续逻辑里使用。
### awake 在加入已加载列表之前
`addHook` 的调用顺序是:
1. 创建控制器;
2. 写入映射与钩子列表;
3. 调用 `hook.awake(controller)`;
4. 把钩子加入已加载列表;
5. 返回控制器。
`forEachHook` 只遍历已加载列表。因此,在 `awake` 内部由其他代码触发的事件,不会回调到这个钩子——它还没进已加载列表。需要在启动时同步一次状态,就在 `awake` 里直接做,不要指望「在 `awake` 里调用 `setBlock` 会触发自己的 `onUpdateBlock`」。
### 移除需要原实例
`removeHook(hook)` 按钩子对象的引用查找。传入注册时的**同一个实例**才会移除;传一个新建的、字段相同的实例不会匹配,也不会报错:
```ts
// 正确:传回注册时的实例
layer.removeHook(hook);
// 也可以:用注册时返回的控制器卸载
controller.unload();
```
```ts
// 错误:这是一个新实例,removeHook 找不到它
layer.removeHook(new MyLayerHooks(state, layer));
```
移除时会调用钩子的 `destroy(controller)`,适合清理定时器、外部监听器等资源。`destroy` 与 `awake` 都收到控制器,可以在这里读取所属对象。
### forEachHook 的顺序不保证
引擎文档明确写了 `forEachHook` 的执行顺序不固定。不要把「我先注册,所以先执行」当成契约。需要固定顺序时,把步骤写进同一个钩子内部按 `await` 串行,或改用本身带顺序语义的机制;不要依赖多个钩子的注册顺序。
### 同一实例重复注册
`addHook` 不会去重。对同一个实例调用两次,会创建两个控制器、两条内部记录;结果是这个实例被调用两次,而且第一次的控制器仍然留在内部映射里:
```ts
// 不要这样:同一个实例注册两次
layer.addHook(hook);
layer.addHook(hook);
```
第二次注册会覆盖 `hookMap` 里「实例到记录」的映射,但第一次的记录仍在已加载列表里。之后 `removeHook(hook)` 只会移除第二次的记录;如果你没有保存第一次返回的控制器,第一次的记录就很难再按实例清理。这是 `addHook` 的行为事实,不是引擎缺陷;按「每个实例只注册一次」使用即可。
### 图层移除会卸载内部转发器
`GameMap.removeLayer(layer)` 会移除图层,并卸载 `addLayer` 时为它创建的内部 `StateMapLayerHook`。因此图层移除后,地图级钩子不会再收到这个图层的 resize。
但它<strong>不会清理你自己挂在图层上的钩子</strong>:图层自身的钩子列表仍然保留,也不会调用你钩子的 `destroy`。如果你还持有这个图层对象的引用,直接调用它的方法仍会触发那些钩子;要释放定时器、外部监听器等资源,需要自己保存 `addHook` 返回的控制器并显式 `controller.unload()`。
## 异步钩子
钩子分同步与异步两类。同步钩子在触发时立即执行;异步钩子返回 `Promise`。在本篇列出的异步钩子族里,引擎会把本次触发的返回值收集起来,用 `Promise.all` 并发等待。
本篇核对过的异步钩子族:
| 钩子族 | 返回 `Promise` 的方法 | 引擎如何等待 |
| --------------------- | ------------------------------------------------------ | ---------------------------------------------------------------- |
| `IObjectMoverHooks` | `onMoveStart`、`onMoveEnd`、`onStepStart`、`onStepEnd` | 移动流程里用 `Promise.all` 等待 |
| `IMapLayerHooks` | `onOpenDoor`、`onCloseDoor`、`onDeleteDynamic` | `openDoor` / `closeDoor` / `deleteDynamic` 用 `Promise.all` 等待 |
| `IHeroStateHooks` | `onBeforeChangeFloor`、`onAfterChangeFloor` | `changeFloor` 用 `Promise.all` 等待 |
| `ICombatFlowHooks` | `onBeforeCombat`、`onAfterCombat` | 战斗流程用 `Promise.all` 等待 |
| `IReplaySandboxHooks` | `onStep` | 播放与单步流程用 `Promise.all` 等待 |
其余常用钩子,例如 `IMapLayerHooks.onUpdateBlock`、`IHeroLocationHooks.onSetPos`、`IGameEventExecutorHooks` 的事件前后钩子,都是同步的,触发时立即执行。
`Promise.all` 只保证「本次触发的所有异步钩子都完成」,不保证谁先完成。注册顺序、`forEachHook` 的遍历顺序,都不能决定异步完成顺序。两个钩子都实现 `onStepEnd` 时,一个内部 `await` 了 100ms,另一个没有等待,后者会先兑现;这并不表示引擎把后者排在了前面。
确实需要顺序时:
- 把有先后依赖的步骤写进<strong>同一个钩子</strong>,在方法体里按 `await` 串行;
- 战斗场景改用带 `priority` 的战斗脚本,脚本会按优先级依次执行(细节见 `高级教程 II · 战斗脚本与优先级`);
- 不要试图在多个独立钩子之间约定顺序,钩子没有跨实例的优先级接口。
这里有一处容易忽略的细节:`onDeleteDynamic` 在 `deleteDynamic` 这条异步删除路径上会被 `Promise.all` 等待;但 `toStatic` 是同步转换,那里触发的 `onDeleteDynamic` 不会等待返回值。不要把「声明为异步」误当成「所有调用路径都会等它」。
> 钩子在系统级流程中如何被消费、跨系统如何安排优先级,属于 `高级教程 II · 系统级实现`;钩子调度的底层原理属于 `高级教程 III · 架构原理`。本篇只写你能直接使用的那一层。
<!-- TODO(advanced-II): 钩子的系统级消费与优先级 -->
<!-- TODO(advanced-III): 钩子调度原理 -->
## 示例:只处理指定图层
假设要只对 `floor-1` 的 `ground` 图层响应图块更新,其他图层的变化不处理。做法是写一个图层钩子类,把目标图层保存在构造参数里;由于 `onUpdateBlock` 没有 layer 参数,身份由「注册到哪个图层」决定。下面的类放在新文件里,顶部需要 `import { ICoreState } from '@user/data-state';` 与 `import { IMapLayer, IMapLayerHooks } from '@user/data-base';`:
有些图层钩子方法的参数里没有图层身份,例如 `onUpdateBlock(block, x, y)` 只有坐标与图块数字。这类钩子只能靠注册位置限定:注册到哪个图层,就只服务哪个图层。
```ts
export class GroundBlockHooks implements IMapLayerHooks {
@ -288,18 +33,20 @@ export class GroundBlockHooks implements IMapLayerHooks {
) {}
onUpdateBlock(block: number, x: number, y: number): void {
// 只在目标图层上做事;onUpdateBlock 没有 layer 参数,触发者必然是 this.layer
this.state.emitTip(`ground 图层更新:(${x}, ${y}) -> ${block}`);
// 触发者必然是 this.layer;参数里没有 layer 可比较
this.state.emitTip(`图层更新:(${x}, ${y}) -> ${block}`);
}
}
```
然后在能拿到 `state` 与目标图层的时机注册,例如插件的 `afterLoad` 中。这里的 `'floor-1'` 换成你的楼层 id,`'ground'` 换成目标图层 alias:
`readonly layer` 的作用是让钩子能访问目标图层本身(读宽高、调用图层方法),不是用来比较一个不存在的参数。
注册时找到目标图层,把实例挂到它上面:
```ts
const map = state.maps.getMap('floor-1');
// afterLoad 时楼层未必已经构造出来,不要静默跳过
if (!map) {
// afterLoad 时楼层未必已经构造出来,不要静默跳过
state.emitTip('floor-1 尚未构造,钩子未注册');
return;
}
@ -313,15 +60,19 @@ if (!layer) {
const controller = layer.addHook(new GroundBlockHooks(state, layer));
```
`afterLoad` 时 `floor-1` 未必已经由加载流程构造出来(取决于你的内容加载方式与插件顺序),`state.maps.getMap('floor-1')` 可能返回 `null`。上面的提示只是示例,实际使用时至少要报出来,或者把注册推迟到楼层创建之后再执行。
`afterLoad` 时楼层未必已经由加载流程构造出来(取决于内容加载方式与插件顺序),`state.maps.getMap('floor-1')` 可能返回 `null`;这时至少要把原因报出来,或者把注册推迟到楼层创建之后。另外,`onUpdateBlock` 只在图块数字真的变化时触发,设置成相同数字不会触发。
注册后可以这样确认隔离:对目标图层调用 `layer.setBlock(1, 0, 0)`(换一个与原来不同的图块数字),提示应出现;从 `map.layerList` 里取另一个图层,调用它的 `setBlock(1, 0, 0)`,提示不应出现;调用 `controller.unload()` 后再改目标图层,提示不应再出现。
如果钩子要在构造时保持通用、注册时才确定图层,可以在 `awake` 里取控制器。`MapLayer` 的控制器是 `IMapLayerHookController`,带 `layer` 字段与 `getMapData()`:
`onUpdateBlock` 只在图块数字真的变化时触发;设置成相同数字不会触发。不要用 `alias` 当身份,也不要把同一个钩子实例注册到所有图层。
```ts
awake(controller: IMapLayerHookController): void {
// controller.layer 就是注册这个钩子的图层
}
```
## 示例:地图级钩子里筛图层
## 示例:地图级钩子筛图层
换一个层级:地图级钩子 `IGameMapHooks.onResizeLayer` 会收到该地图所有图层的尺寸变化,因此必须在回调里自己筛。下面的类同样把目标图层固定为字段,但注册到 `map`:
地图级钩子挂在 `map` 上,仍然是一个地图一个实例。但 `IGameMapHooks.onResizeLayer(layer, width, height)` 会收到<strong>该地图每个图层</strong>的尺寸变化——事件来自多个图层,对象却只有一个。这种回调里要自己筛掉不关心的图层:
```ts
export class TargetLayerResizeHooks implements IGameMapHooks {
@ -338,7 +89,7 @@ export class TargetLayerResizeHooks implements IGameMapHooks {
}
```
这个新文件顶部需要 `import { ICoreState } from '@user/data-state';` 与 `import { IGameMapHooks, IMapLayer } from '@user/data-base';`。注册代码同样放在能拿到 `state` 的时机方法里,这次注册到 `map`,不是 `layer`:
注册到 `map`,不是 `layer`:
```ts
const map = state.maps.getMap('floor-1');
@ -352,48 +103,70 @@ if (!targetLayer) {
return;
}
// 比较必须用对象引用,不能用 layer.alias
map.addHook(new TargetLayerResizeHooks(state, targetLayer));
```
调用 `map.resizeLayer(30, 30, true)`(换一个与当前不同的尺寸)即可验证:该地图的每个图层都会触发一次 `onResizeLayer`,但提示只应出现一次,且宽高是目标图层的新尺寸;把 `targetLayer` 换成另一个图层,提示应对应另一层。
这里的比较用对象引用(`===`)。`onResizeLayer` 能拿到 layer,是因为 `GameMap.addLayer` 给每个图层装了内部转发器,把图层的 `onResize` 转发到地图级钩子并带上具体图层。调用 `map.resizeLayer(30, 30, true)` 时每个图层都会触发一次,你的处理只应在目标图层上生效。
`onResizeLayer` 能拿到 layer,是因为 `GameMap.addLayer` 给每个图层装了内部转发器;`onChangeBackground` 没有地图参数,跨地图共享实例时无法判断是谁的背景变化。地图级钩子建议每张地图一个实例,除非你只使用带身份参数的方法。
## 注册与移除
## 改内置还是自己写
`addHook` 会创建控制器、把钩子写入内部映射与列表、调用 `awake`,最后返回控制器。控制器最重要的方法是 `unload()`:
如果你只是给现有系统加行为,先看 `src/data/hook/*` 里已经注册的类。它们已经在 `src/data/hook/initialize.ts` 的 `initializeHooks` 中 `addHook`,改方法体即可,不需要自己写类,也不需要再注册。
```ts
const hook = new GroundBlockHooks(state, layer);
const controller = layer.addHook(hook);
地图与图层没有对应的内置 `src/data/hook` 类;这类需求需要自己写一个小类。把类写小:只做身份限定与转发,真正的系统逻辑放进插件或独立系统,不要让钩子类承担一整套功能。
// 之后需要撤掉时
controller.unload();
```
如果这是一整套新系统,优先通过插件实现(见[插件系统](./plugin-system.md)),钩子在事件发生时调用它。这样新增逻辑留在 `src` 里,升级内置包时要跟的差异更少。
几条接口事实:
## 常见错误
- `awake` 在钩子加入「已加载列表」之前调用,而 `forEachHook` 只遍历已加载列表。所以在 `awake` 里由其它代码触发的事件不会回调到自己;需要初始同步就直接在 `awake` 里做。
- `removeHook(hook)` 按对象引用查找,必须传注册时的<strong>同一个实例</strong>。传一个新建的同字段实例不会匹配,也不会报错。
- `addHook` 不去重。同一个实例注册两次会得到两个控制器、两条记录,实例被调用两次;第二次还会覆盖「实例到记录」的映射,第一次的记录之后很难按实例清理。一个实例只注册一次。
- `forEachHook` 的执行顺序不保证。需要顺序时,把步骤写进同一个钩子内部按 `await` 串行。
- 移除钩子时会调用 `destroy(controller)`,适合清理定时器与外部监听器。
- `GameMap.removeLayer(layer)` 会移除图层并卸载它为图层创建的内部转发器,但<strong>不会</strong>卸载你自己挂在图层上的钩子,也不会调用它们的 `destroy`。要释放资源,自己保存控制器并显式 `unload()`。
- <strong>把地图级钩子挂到图层上</strong>。`layer.addHook` 接收的是 `IMapLayerHooks`,地图级方法名对不上,永远不会被调用。反过来,把图层钩子挂到地图上也不会收到图层事件。
- <strong>用 alias 判断身份</strong>。alias 只是查找名,不同地图可以重名,重复 `addLayer` 还会覆盖映射。身份用对象引用比较。
- <strong>共享实例却保存当前对象</strong>。异步钩子交错时,后一个对象会覆盖前一个对象的值。共享实例只保存固定的目标字段。
- <strong>重复注册同一个实例</strong>。`addHook` 不去重,同一实例会被多次调用,旧控制器还留在内部列表里。每个实例只注册一次。
- <strong>依赖异步钩子的顺序</strong>。引擎用 `Promise.all` 并发等待,完成顺序不等于注册顺序。需要顺序就把步骤放进同一个钩子串行。
- <strong>在 awake 里等自己触发的事件</strong>。`awake` 调用时钩子还没加入已加载列表,`forEachHook` 不会遍历到它。初始同步直接在 `awake` 里做。
## 异步钩子
钩子分同步与异步两类。异步钩子返回 `Promise`,引擎会把本次触发的返回值收集起来,用 `Promise.all` 并发等待:
| 钩子族 | 返回 `Promise` 的方法 | 引擎如何等待 |
| --------------------- | ------------------------------------------------------ | ---------------------------------------------------------------- |
| `IObjectMoverHooks` | `onMoveStart`、`onMoveEnd`、`onStepStart`、`onStepEnd` | 移动流程里用 `Promise.all` 等待 |
| `IMapLayerHooks` | `onOpenDoor`、`onCloseDoor`、`onDeleteDynamic` | `openDoor` / `closeDoor` / `deleteDynamic` 用 `Promise.all` 等待 |
| `IHeroStateHooks` | `onBeforeChangeFloor`、`onAfterChangeFloor` | `changeFloor` 用 `Promise.all` 等待 |
| `ICombatFlowHooks` | `onBeforeCombat`、`onAfterCombat` | 战斗流程用 `Promise.all` 等待 |
| `IReplaySandboxHooks` | `onStep` | 播放与单步流程用 `Promise.all` 等待 |
其余常用钩子,例如 `IMapLayerHooks.onUpdateBlock`、`IHeroLocationHooks.onSetPos`、`IGameEventExecutorHooks` 的事件前后钩子,都是同步的。
`Promise.all` 只保证「本次触发的所有异步钩子都完成」,不保证谁先完成。注册顺序与 `forEachHook` 的遍历顺序,都不能决定异步完成顺序。确实需要顺序时,把有依赖的步骤写进<strong>同一个钩子</strong>按 `await` 串行;战斗场景改用带 `priority` 的战斗脚本(细节见 `高级教程 II · 战斗脚本与优先级`)。不要试图在多个独立钩子之间约定顺序,钩子没有跨实例的优先级接口。
`onDeleteDynamic` 只在 `deleteDynamic` 这条异步删除路径上被 `Promise.all` 等待;`toStatic` 是同步转换,那里触发的 `onDeleteDynamic` 不会等待返回值。
> 钩子在系统级流程中如何被消费、跨系统如何安排优先级,属于 `高级教程 II · 系统级实现`;钩子调度的底层原理属于 `高级教程 III · 架构原理`。本篇只写你能直接使用的那一层。
<!-- TODO(advanced-II): 钩子的系统级消费与优先级 -->
<!-- TODO(advanced-III): 钩子调度原理 -->
## 边界与注意
- <strong>把地图级钩子挂到图层上</strong>。`layer.addHook` 接收的是 `IMapLayerHooks`,地图级方法名对不上,永远不会被调用;反过来把图层钩子挂到地图上也不会收到图层事件。
- <strong>把同一个实例注册到多个对象</strong>。接口允许,但回调里就得自己区分触发者;一个对象一个实例更简单。
- <strong>重复注册同一个实例</strong>。`addHook` 不去重,实例会被多次调用,旧控制器还留在内部列表里。
- <strong>移除时 new 了一个新实例</strong>。`removeHook` 按引用查找,新实例不会匹配,也不会报错;保存注册时的实例或控制器。
## 验证
确认图层隔离:按「只处理指定图层」注册后,分别修改目标图层与另一个图层。目标图层应有提示,另一个图层不应有;把钩子注册到另一个图层,现象应对调。
确认地图级筛选:按「地图级钩子里筛图层」注册后,调用 `map.resizeLayer` 改一次尺寸。地图级钩子会对每个图层触发,但你的处理只应对目标图层生效。把比较条件删掉,提示会变成每个图层一次——这能反过来证明筛选确实在起作用。
确认移除生效:保存 `addHook` 返回的控制器,调用 `controller.unload()`,再触发同一事件,处理不应再执行。也可以改用 `removeHook(原实例)`,效果应一致;传一个新实例则不应生效。
确认异步完成顺序不等于注册顺序:注册两个都实现 `onStepEnd` 的异步钩子,第一个内部延迟 100ms,第二个不延迟。故意把慢的注册在前面,移动一步后,快的应先提示。这个现象只说明 `Promise.all` 并发等待,不说明引擎按注册顺序执行。
- <strong>在 awake 里等自己触发的事件</strong>。`awake` 调用时钩子还不在已加载列表里,`forEachHook` 遍历不到它。
- <strong>依赖异步钩子的顺序</strong>。引擎用 `Promise.all` 并发等待,完成顺序不等于注册顺序。
- <strong>以为移除图层会顺带清理自己的钩子</strong>。`removeLayer` 只卸载内部转发器,你自己挂的钩子要显式 `unload()`。
## 小结
- 钩子挂在**实例**上,不挂在类上;同一个类可以服务多个对象,但每个对象的钩子列表独立。
- 没有身份参数的钩子(如 `IMapLayerHooks.onUpdateBlock`)必须每对象一个实例;有身份参数的钩子(如 `onResizeLayer`、`IObjectMoverHooks`)才能共享实例并用 `===` 比较。
- 不要用 alias 判断身份;对象引用才唯一。
- `addHook` 返回控制器,`removeHook` 要传原实例,控制器可 `unload`;`awake` 在加入已加载列表前调用,`destroy` 在移除时调用。
- 一个钩子对象对应一个钩子实例:实例注册到哪个对象,就只服务那个对象。
- 参数里没有图层身份的钩子(如 `onUpdateBlock`)靠注册位置限定;地图级钩子收到多图层事件时,用 `===` 比较回调传来的对象。
- `addHook` 返回控制器,`removeHook` 要传原实例;`awake` 在加入已加载列表前调用,`destroy` 在移除时调用。
- `addHook` 不去重,`forEachHook` 顺序不保证,异步钩子用 `Promise.all` 并发等待。
返回[高级 I 导览](./index.md)。

View File

@ -37,8 +37,8 @@
| 3 | 插件系统 | 独立写出插件,处理依赖、生命周期、id 稳定性与版本迁移 |
| 4 | 可存档内容 | 让自定义状态正确进入存档,并在读档后恢复到存档时的样子 |
| 5 | 录像一致性 | 判断哪些方法该登记、哪些路径不该记,以及检查能保证什么 |
| 6 | data-common 的 Store | 理解四个内容注册表各存什么,以及为什么运行时只读 |
| 7 | 钩子深入 | 在多地图、多图层、多移动器的场景里限定钩子的身份 |
| 6 | data-common 的 Store | 知道内容注册表存什么、接口怎么用,以及为什么运行时只读 |
| 7 | 钩子深入 | 把钩子限定到一个对象上,一个对象一个钩子实例 |
推荐阅读顺序:声明式 vs 命令式 → 插件系统 → 可存档内容 → 录像一致性 → data-common 的 Store → 钩子深入。
@ -51,7 +51,7 @@
按目标不同,可以走两条路径:
- <strong>做扩展系统</strong>:声明式 → 插件 → 可存档内容 → 录像一致性。适合新增任务、商店、计数器这类有自己状态与生命周期的功能。读完能独立装配一个插件,并让它的数据跟着存档走。
- <strong>改内容或机制</strong>:声明式 → data-common 的 Store → 钩子深入。适合补图块、改事件、在现有系统上加行为。读完能判断一条定义该在加载期登记还是运行期只读,也能把钩子限定到目标对象上。
- <strong>改内容或机制</strong>:声明式 → data-common 的 Store → 钩子深入。适合在现有机制上加行为、按接口读取内容定义。读完能分清哪些接口只读、哪些需要登记,也能把钩子限定到目标对象上。
两条路径都建议先读声明式篇。它决定你后面是在「加一条规则」,还是「写一段一次性脚本」;这个判断错了,插件、存档与录像都会跟着别扭。
@ -59,12 +59,12 @@
新增系统优先通过插件系统实现;只有不得不修改内置系统时,才去改内部。理由很现实:版本迁移时 `src` 基本不会改,而内置包(`packages` / `packages-user`)有可能会改。直接改内置,升级时就要跟着差异走,维护成本更高;把改动留在自己的 `src` 里,升级时要对照的地方少得多。
| 需求类型 | 优先做法 | 什么情况下才改内置 |
| -------------------------------------------- | ------------------------------ | --------------------------------------------------------------------- |
| 新增一套有自己状态、生命周期与存档内容的系统 | 写成插件,在插件里装配对象 | 现有插件已覆盖大部分能力,只差一个接口或默认值时 |
| 给已有系统补一点行为 | 改内置类的方法体,或加一条分支 | 需要替换整套策略、或改动会破坏内置行为时,才独立成类 |
| 注册一组内容定义(图块、道具、地图、事件) | 在加载期集中登记,不新建 Store | 现有四个 Store 装不下,且确实需要新的系统级数据表时 |
| 给会改状态的方法补录像标记 | 在内置方法上加一行装饰器 | 需要新的录像入口或自定义录像行为时(属于 `高级 II · 自定义录像行为`) |
| 需求类型 | 优先做法 | 什么情况下才改内置 |
| -------------------------------------------- | ------------------------------------------ | --------------------------------------------------------------------- |
| 新增一套有自己状态、生命周期与存档内容的系统 | 写成插件,在插件里装配对象 | 现有插件已覆盖大部分能力,只差一个接口或默认值时 |
| 给已有系统补一点行为 | 改内置类的方法体,或加一条分支 | 需要替换整套策略、或改动会破坏内置行为时,才独立成类 |
| 读取内容定义(图块、道具、地图、怪物、事件) | 通过 `state.*Store` 的接口读,不新建 Store | 现有 Store 装不下,且确实需要新的系统级数据表时 |
| 给会改状态的方法补录像标记 | 在内置方法上加一行装饰器 | 需要新的录像入口或自定义录像行为时(属于 `高级 II · 自定义录像行为`) |
判断标准不是改动多少行,而是改动落在谁的代码里:能由插件或内容定义完成的,就留在 `src`;必须动内置包时,尽量把差异收敛在一个方法或一条分支上,方便升级时对照。
@ -78,9 +78,9 @@
## 读完这一层应该能做什么
- 能用自己的话解释声明式与命令式的区别,并说出一个「仍然该用命令式」的场景。
- 能拿到一个需求,判断它该做成插件、改内置,还是只需补一条内容定义。
- 能说出一个可存档对象必须满足的三个约束:只存重建必需的数据、`saveState` 返回深拷贝、`loadState` 先清空再重建。
- 能判断一个钩子会不会被非目标对象触发,并说出该用「每对象一个实例」还是「共享实例 + `===`」。
- 能拿到一个需求,判断它该做成插件,还是按现有接口读取即可。
- 能说出一个可存档对象必须满足的三个约束:只存重建必需的数据、`saveState` 返回独立对象(引擎不替你深拷贝)、`loadState` 先清空再重建。
- 能说明一个钩子实例只服务哪个对象,并在回调拿到多个对象时用对象引用筛出目标。
## 继续阅读