28 KiB
钩子深入
中级教程的钩子篇回答的是「什么需求该用哪个钩子」;这一篇回答另一个问题:当同一个钩子类被多个地图、多个图层、多个移动器触发时,你怎么知道这次回调是不是你关心的那个对象。
钩子本身不难,难在身份。一张地图有多个图层,一个楼层集合里又有多张地图;给其中一个图层挂的钩子,不会自动只对那个图层生效,除非你在注册方式或回调里把身份限定住。限定错了,效果就会加到别的楼层或图层上。
阅读前需要会基础语法:class、new、import、async / await。本篇只讲数据端,示例都按当前源码里的接口书写。
钩子挂在实例上
一个钩子类只是模板。真正参与事件的是你用 new 创建出来的实例,以及它被注册到的那个主对象实例。
Hookable 的钩子列表是实例字段:每个地图、每个图层、每个移动器各自持有一份,而不是整个类共享一份。因此:
- 同一个钩子类可以
new出很多个实例,分别注册到不同对象; - 给图层 A 注册的钩子,不会出现在图层 B 上;
- 同一个实例也可以注册到多个对象,但这时它必须能在回调里区分身份,否则就会串。
以图层为例,GameMap.addLayer(alias) 每次调用都会创建一个新的 MapLayer。同一张地图的 ground 与 wall 是两个不同的 MapLayer 实例,各自有独立的钩子列表。地图对象本身也是 Hookable,它的钩子列表与图层完全分开。
这也是为什么「给地图加钩子」和「给图层加钩子」不能混:它们接收的是两套不同的接口,事件也从不同的对象发出。
多对象场景
先看几种容易出错的组合:
| 场景 | 实际存在的对象 |
|---|---|
| 一张地图有多个图层 | 每个图层一个 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:
// [模板] 每层一个实例的图层钩子
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():
// [模板] 在 awake 里取得所属图层
awake(controller: IMapLayerHookController): void {
// controller.layer 就是注册这个钩子的图层
}
这种做法适合「先写一个通用钩子类,注册到哪一层就服务哪一层」的工厂。无论用哪种方式,核心都是:一个实例只服务一个对象。
做法 B:共享实例,用 === 比较
如果钩子回调会回传身份,可以只创建一个实例,注册到多个对象,在回调里筛掉不关心的触发者。
例如地图级钩子 IGameMapHooks.onResizeLayer(layer, width, height) 会收到该地图所有图层的 resize。共享实例时,构造时保存目标图层 targetLayer:
// [模板] 共享实例:在回调里比较目标图层
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。不要保存「当前正在处理的对象」:
// 不要这样:共享实例里保存“当前对象”
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;若只服务一个勇士则无需共享 |
如果钩子内部要为每个对象保存不同状态(计数、上次位置、临时标记),即使接口允许共享,也建议每对象一个实例。共享实例的字段只有一份,很难避免互相覆盖。
为多个对象创建实例时,用一个工厂把「创建 + 注册」收在一起,避免漏注册或注册到错误对象:
// [模板] 为每个图层各建一个实例
function addLayerHook(state: ICoreState, layer: IMapLayer) {
return layer.addHook(new MyLayerHooks(state, layer));
}
调用方对每个目标图层调用一次即可。不要在事件回调里临时 new 钩子再注册;那样每次事件都会多一个钩子,且旧钩子很难清理。
注册、移除与生命周期
addHook 返回控制器
addHook 会创建控制器、把钩子写入内部映射与列表、调用 awake,最后返回控制器:
// [模板] 注册并保留控制器
const hook = new MyLayerHooks(state, layer);
const controller = layer.addHook(hook);
控制器最重要的方法是 unload(),它会按控制器移除对应钩子。对图层来说,控制器还带 layer 与 getMapData(),可以在 awake 或后续逻辑里使用。
awake 在加入已加载列表之前
addHook 的调用顺序是:
- 创建控制器;
- 写入映射与钩子列表;
- 调用
hook.awake(controller); - 把钩子加入已加载列表;
- 返回控制器。
forEachHook 只遍历已加载列表。因此,在 awake 内部由其他代码触发的事件,不会回调到这个钩子——它还没进已加载列表。需要在启动时同步一次状态,就在 awake 里直接做,不要指望「在 awake 里调用 setBlock 会触发自己的 onUpdateBlock」。
移除需要原实例
removeHook(hook) 按钩子对象的引用查找。传入注册时的同一个实例才会移除;传一个新建的、字段相同的实例不会匹配,也不会报错:
// 正确:传回注册时的实例
layer.removeHook(hook);
// 也可以:用注册时返回的控制器卸载
controller.unload();
// 错误:这是一个新实例,removeHook 找不到它
layer.removeHook(new MyLayerHooks(state, layer));
移除时会调用钩子的 destroy(controller),适合清理定时器、外部监听器等资源。destroy 与 awake 都收到控制器,可以在这里读取所属对象。
forEachHook 的顺序不保证
引擎文档明确写了 forEachHook 的执行顺序不固定。不要把「我先注册,所以先执行」当成契约。需要固定顺序时,把步骤写进同一个钩子内部按 await 串行,或改用本身带顺序语义的机制;不要依赖多个钩子的注册顺序。
同一实例重复注册
addHook 不会去重。对同一个实例调用两次,会创建两个控制器、两条内部记录;结果是这个实例被调用两次,而且第一次的控制器仍然留在内部映射里:
// 不要这样:同一个实例注册两次
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/ 下的现有钩子文件,或你自己的插件文件;类写好后,在能拿到目标图层的时机注册。
复制什么:下面是机制本身的通用骨架,复制后按 [需改] 修改。它不是某个具体效果的完整实现。
// [模板] 每层一个实例的图层钩子
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;
// 在这里做事
}
}
改什么:
- 类名
MyLayerHooks换成你的名字; readonly layer保留,构造时传入目标图层;- 方法体换成你的处理;
- 如果目标钩子不回传身份,不要把这个实例共享给多个图层,改为每层
new一个。
怎么验证:见下面的「只处理指定图层」;核心是改目标图层会触发、改别的图层不触发。
注意:readonly layer 不是为了在 onUpdateBlock 里比较一个不存在的参数。身份来自「这个实例注册在哪个图层上」;动态图块方法里的 tile.layer 才是额外的运行时校验。
示例:只处理指定图层
目标:只对 floor-1 的 ground 图层响应图块更新,其他图层的变化不处理。
改哪里:在能拿到 state 与目标图层的时机注册,例如插件的 afterLoad 中;下面的片段省略了外层函数或方法,return 需要放在函数体内。
复制什么:
// [需改] 类名、提示内容
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。
怎么验证:
- 对目标图层调用
layer.setBlock(1, 0, 0),换一个与原来不同的图块数字,提示应出现; - 从
map.layerList里取另一个图层,调用它的setBlock(1, 0, 0),提示不应出现; - 调用
controller.unload(),再改目标图层,提示不应再出现。
注意:onUpdateBlock 只在图块数字真的变化时触发;设置成相同数字不会触发。不要用 alias 当身份,也不要把同一个钩子实例注册到所有图层。
示例:地图级钩子里筛图层
目标:在地图级 onResizeLayer 里只处理目标图层。地图级钩子会收到该地图所有图层的尺寸变化,必须自己筛。
改哪里:在能拿到 map 与目标图层的时机注册;这次注册到 map,不是 layer。
复制什么:
// [需改] 类名与处理逻辑
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 类;这类需求需要自己写一个小类。把类写小:只做身份限定与转发,真正的系统逻辑放进插件或独立系统,不要让钩子类承担一整套功能。
如果这是一整套新系统,优先通过插件实现(见插件系统),钩子在事件发生时调用它。这样新增逻辑留在 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并发等待。