15 KiB
事件系统
事件最容易出现的一种误判是「脚本写了,但没跑」。原因通常不在脚本,而在两段完全不同的逻辑:先按坐标与图层收集候选事件,再在执行时按触发器与仓库内容筛选。收集阶段不知道脚本语义,执行阶段才拿到事件对象;把这两步混在一起,就会以为收集器会按 trigger 过滤,或者以为点事件与图块事件可以按优先级跨组比较。
本篇讲事件仓库、事件对象、收集器、执行器与解释器这五个角色,以及一个事件从注册到执行的过程。事件内容由编辑器的事件编辑器产出,本篇不讨论编辑器本身;脚本如何编译成程序属于实现细节,不单独展开。
系统模型
| 角色 | 负责什么 |
|---|---|
GameEventStore |
按 id 保存事件对象,按 id 取回 |
GameEvent |
持有触发器、原始语句与缓存的编译产物 |
GameEventSystem |
持有仓库、解释器与执行器,按坐标收集候选调用项 |
EventExecutor |
逐条查仓库、过滤触发器、执行并合并返回值 |
AnonTokyoInterpreter |
把语句编译成可执行程序,或直接执行语句 |
flowchart TD
S[GameEventStore<br/>id → GameEvent] --> C[GameEventSystem.collectEvent]
C --> I[调用列表<br/>id + env]
I --> E[EventExecutor.execute]
E --> F{仓库中有该 id 且 trigger 匹配?}
F -->|否| K[告警或静默跳过]
F -->|是| G[GameEvent.execute]
G --> X[AnonTokyoInterpreter]
收集结果不是事件实例,而是 { id, env } 形式的调用项。这样收集阶段只关心坐标、图层与顺序,事件对象被替换或仓库被换掉时,候选列表仍然成立。真正的触发器过滤发生在执行器。
事件对象与触发器
GameEvent 保存触发器、原始语句数组与编译产物。第一次执行时编译,之后复用缓存;setRaw 换掉原始语句并清空缓存;setTrigger 只改触发器,不影响已经编译好的程序。解释器由构造时传入,通常来自 state.eventSystem.executor.interpreter。
EventTrigger 共 11 个值。数据端目前只有三个值接到了派发点,其余是枚举保留,挂上去不会自动执行:
| 值 | 含义 | 数据端派发状态 |
|---|---|---|
None |
无触发器,事件不由触发器启动 | 保留值 |
OnTouch |
玩家撞上目标格时触发;直接走入不触发 | 勇士移动的撞击 |
OnEnter |
玩家进入目标格时触发 | 勇士移动的进入 |
OnLeave |
玩家离开目标格时触发 | 勇士移动的离开 |
OnBeforeBattle |
与指定怪物战斗前触发,返回值表示是否进入战斗 | 未派发,待引擎实现 |
OnAfterBattle |
与指定怪物战斗后触发 | 未派发,待引擎实现 |
OnBeforeOpenDoor |
开启指定门之前触发,返回值表示是否成功开门 | 未派发,待引擎实现 |
OnAfterOpenDoor |
开启指定门之后触发 | 未派发,待引擎实现 |
OnAfterGetItem |
成功拾取指定道具时触发 | 未派发,待引擎实现 |
OnBeforeChangeFloor |
使用指定位置或图块切换楼层前触发,返回值表示是否切换 | 未派发,待引擎实现 |
OnAfterChangeFloor |
使用指定位置或图块切换楼层后触发 | 未派发,待引擎实现 |
触发器过滤不放在收集器里,是因为同一坐标可能因 OnEnter、OnLeave、OnTouch 被反复收集。收集器只把本次时机写进 env.trigger,执行器再决定这个事件是否属于本次时机。把过滤提前到收集器,会让「收集」依赖事件脚本的语义,仓库换一个实现就可能漏掉候选。
事件层
IGameMap.eventLayer 指向当前楼层的默认事件层,类型是 IMapLayer | null。内容加载时,alias === 'event' 的图层会被默认指定为事件层;也可以用 setEventLayer 显式换一个图层。事件层不是特殊类型,仍然是普通图层,具备图块、点事件、图块事件、存档与钩子。
切换楼层后,使用的是目标楼层自己的事件层。事件对象按 id 存在 state.eventStore;点事件与图块事件只保存 id,不保存脚本本身。事件 id 的登记接口是 eventStore.addEvent;谁在加载期登记属于内容加载,不在本篇展开。
把事件层做成普通图层,而不是一个专门的事件容器,是为了复用图层的全部能力:它仍然有静态图块、动态图块、点事件、图块事件、存档与钩子。事件系统只需要一个「当前楼层的事件层」指针,不需要为事件单独维护坐标表;换楼层时换指针即可,图层本身的存档与事件视图继续工作。
收集:这一次该执行谁
触发器只回答「这次是什么时机」;要把它变成一次执行,还需要收集候选与执行过滤。collectEvent(layer, trigger, x, y) 先判断坐标是否在地图内;越界直接返回空数组。它不按事件自身的触发器过滤,而是把该坐标上的候选事件都放进列表,并把本次触发时机写进每个调用项的环境。
候选分两组:
- 点事件:
layer.getPointEvent(x, y)返回「优先级 → 事件 id」的只读映射。 - 图块事件:当前格的静态图块与每个动态图块各有一份
tileEvent()映射。
两组各自按优先级降序排序,再按「点事件组在前、图块事件组在后」拼接。组间顺序不受优先级数值影响:即使某个图块事件的优先级更高,整组点事件仍然先进入调用列表。这样「这个坐标发生什么」始终先于「这个物体发生什么」,跨组比较不会让物体事件压过坐标语义。排序只发生在收集结果上,不会修改图层的事件视图;同一份视图可以被多个坐标、多次触发反复读取。
同一份事件视图内,一个优先级只保存一个事件 id。对已存在的优先级再次 set 会用新 id 覆盖旧 id,并触发重复优先级告警 136;作者看到的结果是后写入的 id 生效。不同来源出现相同优先级时,不再重新排序,而是保留收集顺序:点事件组仍然整体在前;图块事件组内部静态图块在前,多个动态图块按图层顺序排列。调用项只有 id 与 env,不带优先级,优先级只体现在列表顺序里。
env 记录本次触发的完整上下文:
| 字段 | 含义 |
|---|---|
state |
数据端主对象,静态类型是 IStateBase |
type |
来源类型:点事件或图块事件 |
trigger |
本次触发时机,来自 collectEvent 的参数 |
system |
事件系统对象,可用来再次收集或执行 |
heroLocator |
触发时勇士的位置 |
heroFloor |
触发时勇士所在楼层 id |
triggerLocator |
触发坐标 |
tile |
触发事件的图块;点事件为 null |
layer |
触发事件的图层;可能为 null |
map |
触发事件的地图;可能为 null |
env.state 的静态类型是 IStateBase,拿不到 L3 的 loader 等成员。内建函数需要更高层能力时,应从事件系统的持有者或插件依赖取得,而不是假设 env.state 具备全部成员;这不是类型疏漏,而是为了让事件环境只暴露数据层能力,避免脚本反向依赖启动层。
env 是收集时创建的调用上下文,不是会随游戏状态实时更新的视图。heroLocator、triggerLocator 与 tile 记录的是触发那一刻的值;事件脚本执行期间勇士再移动,这些字段不会跟着变。需要当前状态时读 env.state.hero,不要把 env.heroLocator 当成实时坐标。
执行:候选到结果
EventExecutor.execute(invocations, param) 按列表顺序逐条处理:
- 仓库为空或
getEvent(id)找不到事件:告警 171 并跳过。 - 事件自身的
trigger与invocation.env.trigger不一致:静默跳过。 - 否则
await event.execute(param, env),把结果加入结果列表。
执行模式决定是否提前结束:
| 模式 | 行为 |
|---|---|
EventExecuteMode.Normal |
全部执行 |
EventExecuteMode.CutIfFalsy |
遇到 falsy 结果立即结束 |
EventExecuteMode.CutIfTruthy |
遇到 truthy 结果立即结束 |
折叠模式决定返回值:
| 模式 | 返回值 |
|---|---|
EventReduceMode.NoReduce |
按执行顺序组成数组 |
EventReduceMode.OrReduce |
从 false 起做或运算 |
EventReduceMode.AndReduce |
从 true 起做与运算 |
非布尔值在折叠时告警 172,但按正常短路规则继续。mode 与 reduce 是执行器上的共享状态,不是每次调用的参数;改完要复位,否则会影响后续调用。
把执行策略放在执行器上,而不是每次调用传参数,是为了让「同一批事件按什么策略执行」成为系统级配置:战斗、对话、踩踏可以各自在调用前设置模式,调用后恢复。
param 是 IBlockEventParam,形状为 { custom },执行器把它原样交给事件。脚本里它成为顶层 scope.args。内建函数的 func(param, env) 第一个参数不是它,而是 Call.parameters 求值后的对象;两者不会自动传递。
EventExecutor.execute 带 @shouldReplay,含义是「执行事件这条路径必须产生录像记录」,不是自动写录像。onBeforeExecuteEvent 与 onAfterExecuteEvent 是同步钩子;任意事件执行拒绝会让 execute 拒绝,后续调用项与 onAfterExecuteEvent 不再执行。
钩子可以修改传入的调用列表与参数对象,所以它们适合做「执行前裁剪候选、执行后记录结果」。但钩子不是事件脚本的一部分:它们看不到 scope,也不参与折叠;需要修改事件结果时,仍然要在脚本或事件对象里做。
从注册到执行
事件由编辑器的 Blockly 风格事件编辑器产出,作者拖拽语句块,而不是手写脚本。编辑器输出的是一份事件数据:触发器、语句块与参数。内容加载时,运行时按 id 把这份数据登记到 state.eventStore;点事件与图块事件只保存这个 id,不保存脚本本身。
一次触发按四步走:
- 编辑器产出事件数据,运行时在加载期把它注册到事件仓库,得到一个 id。
- 触发发生时,
collectEvent(layer, trigger, x, y)按坐标与图层收集候选,把本次触发时机写进每个调用项的env,返回{ id, env }列表。 EventExecutor.execute按列表顺序逐条处理:先按 id 从仓库取事件,找不到就告警并跳过;再比较事件自身的trigger与env.trigger,不一致就静默跳过。- 触发器匹配的事件按顺序执行,结果按当前执行模式与折叠模式合并。
执行器只按调用列表顺序逐条执行。列表顺序来自收集阶段:点事件组整体在前,图块事件组在后,组内按优先级降序;同一优先级重复设置时后写入的 id 生效并告警。
编辑器里的 Expression 只计算值,不产生副作用;要修改 Flag、勇士或地图,必须调用内建函数。Expression 的结果不会自动写回,需要被后续语句读取时由事件数据自行组织。脚本第一次执行时编译,之后复用编译产物;setRaw 换掉语句块并清空缓存,setTrigger 只改触发器,不影响已经编译好的程序。
移动器在撞击分支用目标格收集 OnTouch,在结算阶段用旧格收集 OnLeave、新格收集 OnEnter。触发器不匹配的事件会在执行器被静默跳过,所以同一个坐标可以参与多次不同时机的收集。
自定义一个内建函数
在数据端的标准装配里,事件脚本能调用的动作来自内建函数表。新增一个动作分三步:写类、声明参数接口、加入注册列表。
内建函数类实现 BuiltInFunction<P, IBlockEventEnv>:name 是编辑器里显示并调用的名字,func(param, env) 是执行逻辑。param 来自事件里这次调用的参数,不是事件参数;env 是本次触发的上下文。
写好类后,把实例放进 createEventRegistrations() 返回的数组,由数据端入口放进 ICoreStateConfig.events,GameEventSystem 构造解释器时把它作为 builtInFunctions 传入。注册一次后,编辑器里的调用就能按 name 找到它。
func 的第二个参数是本次调用的环境。env.state 是入口,Flag、勇士、地图、事件仓库与录像系统都从它取;env.layer 与 env.map 是触发事件的图层与地图,env.trigger 是本次触发时机,env.tile 是触发图块(点事件为空),env.triggerLocator 是触发坐标,env.heroLocator 与 env.heroFloor 是勇士的位置与楼层。
env.layer 与 env.map 都可能为空。需要操作地图时,先取 env.layer,再取 env.map,最后用 env.heroFloor 从 state.maps 找当前地图,再取它的 eventLayer;不要假设事件一定由某个具体图层触发。这条兜底顺序能覆盖「事件由脚本临时执行、没有真实触发坐标」的情况。
分步的内建函数写法与更多示例见自定义事件;事件仓库本身的用法见 data-common 的 Store。
边界与注意
- 只有三个触发器有派发点:
OnTouch、OnEnter、OnLeave由勇士移动派发;其余枚举值已定义,派发点仍待引擎实现。 - 点事件组整体优先:无论数值高低,点事件组都先于图块事件组;优先级只在组内比较。
- 同优先级会覆盖并告警:同一事件视图内再次设置同一优先级,旧 id 被新 id 覆盖并告警 136;跨来源同优先级保留收集顺序。
- 执行前有两道检查:id 找不到会告警 171 并跳过;触发器与本次时机不一致则静默跳过。
- 两种 param 不是一回事:事件参数从
scope.args读取,内建函数的param是脚本Call.parameters求值后的对象。 - 执行器状态是共享的:
mode与reduce改完要复位,否则会影响后续调用。 - 事件层不是特殊图层:它只是被指定为事件层的普通图层;
alias === 'event'是内容加载的默认约定,setEventLayer可以改。
返回高级 III 导览。