diff --git a/docs/advanced/hooks-in-depth.md b/docs/advanced/hooks-in-depth.md new file mode 100644 index 0000000..1e53587 --- /dev/null +++ b/docs/advanced/hooks-in-depth.md @@ -0,0 +1,435 @@ +# 钩子深入 + +中级教程的[钩子](../intermediate/hooks.md)篇回答的是「什么需求该用哪个钩子」;这一篇回答另一个问题:当同一个钩子类被多个地图、多个图层、多个移动器触发时,你怎么知道这次回调是不是你关心的那个对象。 + +钩子本身不难,难在身份。一张地图有多个图层,一个楼层集合里又有多张地图;给其中一个图层挂的钩子,不会自动只对那个图层生效,除非你在注册方式或回调里把身份限定住。限定错了,效果就会加到别的楼层或图层上。 + +阅读前需要会基础语法:`class`、`new`、`import`、`async` / `await`。本篇只讲数据端,示例都按当前源码里的接口书写。 + +## 钩子挂在实例上 + +一个钩子类只是模板。真正参与事件的是你用 `new` 创建出来的实例,以及它被注册到的那个主对象实例。 + +`Hookable` 的钩子列表是实例字段:每个地图、每个图层、每个移动器各自持有一份,而不是整个类共享一份。因此: + +- 同一个钩子类可以 `new` 出很多个实例,分别注册到不同对象; +- 给图层 A 注册的钩子,不会出现在图层 B 上; +- 同一个实例也可以注册到多个对象,但这时它必须能在回调里区分身份,否则就会串。 + +以图层为例,`GameMap.addLayer(alias)` 每次调用都会创建一个新的 `MapLayer`。同一张地图的 `ground` 与 `wall` 是两个不同的 `MapLayer` 实例,各自有独立的钩子列表。地图对象本身也是 `Hookable`,它的钩子列表与图层完全分开。 + +这也是为什么「给地图加钩子」和「给图层加钩子」不能混:它们接收的是两套不同的接口,事件也从不同的对象发出。 + +## 多对象场景 + +先看几种容易出错的组合: + +| 场景 | 实际存在的对象 | +| ------------------------------------ | --------------------------------------------------- | +| 一张地图有多个图层 | 每个图层一个 `MapLayer`,各自独立 | +| 楼层集合里有多张地图 | 每张地图一个 `IGameMap`,各自独立 | +| 场上有多个动态图块 | 每个动态图块一个 `IDynamicTile`,各自带一个 `mover` | +| 勇士与动态图块同时存在 | 勇士的移动器与图块的移动器是不同实例 | +| 同一张地图被重复打开或存在于多个沙盒 | 可能是多个 `IGameMap` 实例,名字相同但引用不同 | + +钩子被触发,只能说明「它所属的实例发生了事件」,不能说明那就是你想要的那个实例。比如地图级钩子的 `onResizeLayer(layer, width, height)` 会收到该地图每个图层的尺寸变化;如果你不比较 `layer`,就会对每个图层都执行一次。 + +地图与图层之间还有一层内部转发:`GameMap.addLayer` 会为每个图层注册一个 `StateMapLayerHook`,把图层的 `onResize` 转发到地图的 `onResizeLayer`,并带上具体 `layer`。所以地图级钩子能拿到图层身份;图层级钩子反而拿不到「我是谁」,因为图层钩子的回调参数里没有 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 就是注册这个钩子的图层 +} +``` + +这种做法适合「先写一个通用钩子类,注册到哪一层就服务哪一层」的工厂。无论用哪种方式,核心都是:一个实例只服务一个对象。 + +## 做法 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。 + +### 共享实例不要保存「当前对象」 + +共享实例只适合保存固定的目标,例如 `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。你自己挂在图层上的钩子会随图层对象一起失去作用;如果还持有控制器,也可以显式 `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,另一个没有等待,后者会先兑现;这并不表示引擎把后者排在了前面。 + +确实需要顺序时: + +- 把有先后依赖的步骤写进同一个钩子,在方法体里按 `await` 串行; +- 战斗场景改用带 `priority` 的战斗脚本,脚本会按优先级依次执行(细节见 `高级教程 II · 战斗脚本与优先级`); +- 不要试图在多个独立钩子之间约定顺序,钩子没有跨实例的优先级接口。 + +这里有一处容易忽略的细节:`onDeleteDynamic` 在 `deleteDynamic` 这条异步删除路径上会被 `Promise.all` 等待;但 `toStatic` 是同步转换,那里触发的 `onDeleteDynamic` 不会等待返回值。不要把「声明为异步」误当成「所有调用路径都会等它」。 + +> 钩子在系统级流程中如何被消费、跨系统如何安排优先级,属于 `高级教程 II · 系统级实现`;钩子调度的底层原理属于 `高级教程 III · 架构原理`。本篇只写你能直接使用的那一层。 + + + + +## 模板:一个带身份校验的钩子 + +目标:每层一个钩子实例,只对目标图层做事;如果回调回传了图块,再额外核对一次所属图层。 + +改哪里:`src/data/hook/` 下的现有钩子文件,或你自己的插件文件;类写好后,在能拿到目标图层的时机注册。 + +复制什么:下面是机制本身的通用骨架,复制后按 `[需改]` 修改。它不是某个具体效果的完整实现。 + +```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,可再核对 tile.layer + onCreateDynamic(tile: IDynamicTile): void { + if (tile.layer !== this.layer) return; + // 在这里做事 + } +} +``` + +改什么: + +1. 类名 `MyLayerHooks` 换成你的名字; +2. `readonly layer` 保留,构造时传入目标图层; +3. 方法体换成你的处理; +4. 如果目标钩子不回传身份,不要把这个实例共享给多个图层,改为每层 `new` 一个。 + +怎么验证:见下面的「只处理指定图层」;核心是改目标图层会触发、改别的图层不触发。 + +注意:`readonly layer` 不是为了在 `onUpdateBlock` 里比较一个不存在的参数。身份来自「这个实例注册在哪个图层上」;动态图块方法里的 `tile.layer` 才是额外的运行时校验。 + +## 示例:只处理指定图层 + +目标:只对 `floor-1` 的 `ground` 图层响应图块更新,其他图层的变化不处理。 + +改哪里:在能拿到 `state` 与目标图层的时机注册,例如插件的 `afterLoad` 中;下面的片段省略了外层函数或方法,`return` 需要放在函数体内。 + +复制什么: + +```ts +// [需改] 类名、提示内容 +class GroundBlockHooks implements IMapLayerHooks { + constructor( + readonly state: ICoreState, + readonly layer: IMapLayer + ) {} + + onUpdateBlock(block: number, x: number, y: number): void { + this.state.emitTip(`ground 图层更新:(${x}, ${y}) -> ${block}`); + } +} + +// [模板] 取楼层、取图层、注册 +const map = state.maps.getMap('floor-1'); +if (!map) return; +const layer = [...map.layerList].find(l => l.alias === 'ground'); +if (!layer) return; + +const hook = new GroundBlockHooks(state, layer); +const controller = layer.addHook(hook); +``` + +改什么: + +- `'floor-1'` 换成你的楼层 id; +- `'ground'` 换成目标图层 alias; +- 提示内容换成你的处理; +- `layer.addHook(hook)` 的目标必须是 `layer`,不是 `map`。 + +怎么验证: + +1. 对目标图层调用 `layer.setBlock(1, 0, 0)`,换一个与原来不同的图块数字,提示应出现; +2. 从 `map.layerList` 里取另一个图层,调用它的 `setBlock(1, 0, 0)`,提示不应出现; +3. 调用 `controller.unload()`,再改目标图层,提示不应再出现。 + +注意:`onUpdateBlock` 只在图块数字真的变化时触发;设置成相同数字不会触发。不要用 `alias` 当身份,也不要把同一个钩子实例注册到所有图层。 + +## 示例:地图级钩子里筛图层 + +目标:在地图级 `onResizeLayer` 里只处理目标图层。地图级钩子会收到该地图所有图层的尺寸变化,必须自己筛。 + +改哪里:在能拿到 `map` 与目标图层的时机注册;这次注册到 `map`,不是 `layer`。 + +复制什么: + +```ts +// [需改] 类名与处理逻辑 +class TargetLayerResizeHooks 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}`); + } +} + +// [模板] 取目标图层并注册到地图 +const map = state.maps.getMap('floor-1'); +if (!map) return; +const targetLayer = [...map.layerList].find(l => l.alias === 'ground'); +if (!targetLayer) return; + +map.addHook(new TargetLayerResizeHooks(state, targetLayer)); +``` + +改什么:楼层 id、图层 alias、比较字段与提示内容。比较必须用 `layer !== this.targetLayer`,不要用 `layer.alias`。 + +怎么验证:调用 `map.resizeLayer(30, 30, true)`(换一个与当前不同的尺寸)。该地图的每个图层都会触发一次 `onResizeLayer`,但提示只应出现一次,且宽高是目标图层的新尺寸。把 `targetLayer` 换成另一个图层,提示应对应另一层。 + +注意:`onResizeLayer` 能拿到 layer,是因为 `GameMap.addLayer` 给每个图层装了内部转发器;`onChangeBackground` 没有地图参数,跨地图共享实例时无法判断是谁的背景变化。地图级钩子建议每张地图一个实例,除非你只使用带身份参数的方法。 + +## 改内置还是自己写 + +如果你只是给现有系统加行为,先看 `src/data/hook/*` 里已经注册的类。它们已经在 `src/data/hook/initialize.ts` 的 `initializeHooks` 中 `addHook`,改方法体即可,不需要自己写类,也不需要再注册。 + +地图与图层没有对应的内置 `src/data/hook` 类;这类需求需要自己写一个小类。把类写小:只做身份限定与转发,真正的系统逻辑放进插件或独立系统,不要让钩子类承担一整套功能。 + +如果这是一整套新系统,优先通过插件实现(见[插件系统](./plugin-system.md)),钩子在事件发生时调用它。这样新增逻辑留在 `src` 里,升级内置包时要跟的差异更少。 + +如果你不怎么懂代码,建议直接改内置钩子,而不是自己写新的类。内置类已经注册好,改方法体就能生效;自己写类还要处理身份、注册时机与移除,排查面更大。 + +## 常见错误 + +- 把地图级钩子挂到图层上。`layer.addHook` 接收的是 `IMapLayerHooks`,地图级方法名对不上,永远不会被调用。反过来,把图层钩子挂到地图上也不会收到图层事件。 +- 用 alias 判断身份。alias 只是查找名,不同地图可以重名,重复 `addLayer` 还会覆盖映射。身份用对象引用比较。 +- 共享实例却保存当前对象。异步钩子交错时,后一个对象会覆盖前一个对象的值。共享实例只保存固定的目标字段。 +- 重复注册同一个实例。`addHook` 不去重,同一实例会被多次调用,旧控制器还留在内部列表里。每个实例只注册一次。 +- 依赖异步钩子的顺序。引擎用 `Promise.all` 并发等待,完成顺序不等于注册顺序。需要顺序就把步骤放进同一个钩子串行。 +- 在 awake 里等自己触发的事件。`awake` 调用时钩子还没加入已加载列表,`forEachHook` 不会遍历到它。初始同步直接在 `awake` 里做。 +- 移除时 new 了一个新实例。`removeHook` 按引用查找,新实例不会匹配,也不会报错;保存注册时的实例或控制器。 + +## 怎么验证 + +第一项:确认图层隔离。按「只处理指定图层」注册后,分别修改目标图层与另一个图层。目标图层应有提示,另一个图层不应有。把钩子注册到另一个图层,现象应对调。 + +第二项:确认地图级筛选。按「地图级钩子里筛图层」注册后,调用 `map.resizeLayer` 改一次尺寸。地图级钩子会对每个图层触发,但你的处理只应对目标图层生效。把比较条件删掉,提示会变成每个图层一次——这能反过来证明筛选确实在起作用。 + +第三项:确认移除生效。保存 `addHook` 返回的控制器,调用 `controller.unload()`,再触发同一事件,处理不应再执行。也可以改用 `removeHook(原实例)`,效果应一致;传一个新实例则不应生效。 + +第四项:确认异步完成顺序不等于注册顺序。注册两个都实现 `onStepEnd` 的异步钩子:第一个内部延迟 100ms,第二个不延迟。故意把慢的注册在前面,移动一步后,快的应先提示。这个现象只说明 `Promise.all` 并发等待,不说明引擎按注册顺序执行。 + +## 小结 + +- 钩子挂在**实例**上,不挂在类上;同一个类可以服务多个对象,但每个对象的钩子列表独立。 +- 没有身份参数的钩子(如 `IMapLayerHooks.onUpdateBlock`)必须每对象一个实例;有身份参数的钩子(如 `onResizeLayer`、`IObjectMoverHooks`)才能共享实例并用 `===` 比较。 +- 不要用 alias 判断身份;对象引用才唯一。 +- `addHook` 返回控制器,`removeHook` 要传原实例,控制器可 `unload`;`awake` 在加入已加载列表前调用,`destroy` 在移除时调用。 +- `addHook` 不去重,`forEachHook` 顺序不保证,异步钩子用 `Promise.all` 并发等待。