06 回调模型¶
前面几章写的代码已经涉及了多个回调:OnLoad 做初始化、OnProcess 每帧画窗口、命令回调响应输入。但我们一直在用它们,还没有停下来解释:为什么脚本被分成零散的函数,由 BML 在特定时机调用?
本章回答这个问题,给出完整的回调清单、调用时机、以及常见错误。
脚本由 BML 调用¶
很多小程序是从第一行一直执行到最后一行,跑完就结束。BML 脚本不是这种模型。它更像一组挂在游戏里的响应函数:游戏加载脚本后,脚本对象一直存在;游戏运行到某个时机,BML 调用对应函数。
例如加载脚本时调用 OnLoad,每帧调用 OnProcess,退出时调用 OnUnload。命令和 Timer 也是同一个思路:你先把函数交给 BML,等玩家输入命令或时间到了,BML 再调用它。
这些由宿主程序在特定时机调用的函数叫回调(callback)。你的脚本声明好函数,BML 在合适的时机来调。
两类回调¶
固定回调¶
BML 靠函数名匹配。函数名、返回值、参数类型全部对上,BML 才会认。拼错名字或者参数类型不对,回调不会被调用,日志里通常会出现签名不匹配的提示。
本章覆盖五个固定回调:OnLoad、OnProcess、OnUnload、OnGameEvent、OnRender。
注册回调¶
函数名可以自由取,注册时把函数交给 BML。前面的命令回调就是注册回调,RegisterCommand 注册,玩家输入命令时 BML 才调用。
下一章还会见到 Timer 回调(SetTimeout / SetInterval 注册,时间到了 BML 调用)。
时序总览¶
先给出一帧循环的全貌,后面逐个展开:
游戏启动
└─ BML 加载脚本
└─ OnLoad <- 执行一次
游戏运行(每帧循环)
┌─ OnProcess <- 逻辑更新
│ 检查按键、更新数据、提交 ImGui
├─ OnRender <- Virtools 渲染回调
│ 读取 RenderEvent 中的渲染标志
└─ 回到下一帧
游戏事件发生(关卡开始、重生等)
└─ OnGameEvent <- 事件触发时
游戏退出
└─ OnUnload <- 执行一次
OnLoad¶
触发时机:脚本被 BML 加载后,执行且仅执行一次。
适合做的事:
- 注册命令
- 读取配置
- 启动 Timer
- 写一条启动日志
void OnLoad(const BML::ModContext &in ctx) {
LoadConfig(ctx);
RegisterCommands(ctx);
ctx.LogInfo("MyMod loaded");
}
不要做的事:
- 画 ImGui 窗口(此时渲染循环还没开始)
- 访问关卡对象(玩家可能还在主菜单,关卡没有加载)
- 把
ctx存到成员变量里长期持有(BML 不保证跨回调有效性)
OnProcess¶
触发时机:游戏运行时,每一帧调用一次。如果游戏跑 60 fps,这个函数每秒被调 60 次。
适合做的事:
- 检查输入
- 更新数据
- 提交 ImGui 窗口
void OnProcess(const BML::ModContext &in ctx) {
HandleWindowToggle(ctx);
UpdateFps();
DrawWindow();
}
不要做的事:
- 每帧写日志(日志文件会迅速膨胀到几百 MB)
- 在里面注册命令或读配置(那是一次性动作,放
OnLoad) - 做耗时运算(搜索文件、解析大数据)。每帧有 16ms 预算(60fps),超了就会卡顿
为什么 OnProcess 和 ImGui 的关系如此紧密¶
第 04 章提到:ImGui 是即时模式 UI,每帧提交才能持续显示。而 OnProcess 恰好就是每帧调用的函数。所以你在 OnProcess 里调用 ImGui::Begin/End,窗口就每帧都在;这一帧不调用,窗口就消失。
成员变量在帧之间活着¶
OnProcess 结束后,脚本类的成员变量不会销毁。下一帧进来时,showWindow、fps 这些值和上一帧结束时一样。这就是成员变量能充当「状态」的原因,在第 04 章里 showWindow 记住窗口开关,fps 记住帧率。
OnUnload¶
触发时机:脚本被 BML 卸载时,执行且仅执行一次。通常发生在游戏退出时。
适合做的事:
- 取消还在运行的 Timer
- 写一条卸载日志
void OnUnload(const BML::ModContext &in ctx) {
if (statusTimer !is null && statusTimer.IsValid) {
statusTimer.Cancel();
}
ctx.LogInfo("MyMod unloaded");
}
不要做的事:
- 画 ImGui 窗口(渲染循环已经结束或即将结束)
- 注册命令(太晚了,命令不会被使用)
- 访问关卡对象(可能已经被销毁)
OnGameEvent¶
触发时机:游戏状态发生变化时,关卡开始、关卡结束、玩家重生等。
注意参数 event 是按值传递的,没有 const &in。这和其他回调不同。
适合做的事:
- 关卡开始时初始化关卡数据
- 重生时重置计时器或统计
- 关卡结束时汇总信息
void OnGameEvent(const BML::ModContext &in ctx, BML::GameEvent event) {
// 根据 event 判断发生了什么,再执行对应逻辑
ctx.LogInfo("Game event received");
}
不要做的事:
- 不判断事件类型就执行逻辑(你会在关卡结束时也执行关卡开始的代码)
- 假设此时一定有关卡对象(某些事件类型在关卡卸载后触发)
OnRender¶
触发时机:每帧的 Virtools 渲染阶段,在 OnProcess 之后。
和 OnProcess 的区别:OnProcess 位于 BML+ 的活动 ImGui 帧中,前面教程里的
ImGui 窗口和 BML::UI 控件都应在这里提交。OnRender 用于接收 Virtools 渲染
回调及其 RenderEvent.Flags,不用于提交 BML/ImGui 绘制命令。
不要做的事:
- 在这里调用 ImGui 或
BML::UI绘制函数 - 在这里做逻辑计算(逻辑放
OnProcess,职责分离)
ctx:服务入口¶
每个回调都收到 const BML::ModContext &in ctx。它是当前脚本的服务入口,通过它借日志、借输入管理器、注册命令。
几个要点:
- 不要存起来。BML 每次调用回调时传入
ctx,保证这一次调用期间有效。不要把它保存到成员变量留着下次用。 - 每个回调都有。无论
OnLoad还是OnProcess,拿到的ctx指向同一个 mod 的服务。 - 往下传就行。需要使用 Mod 服务的 helper 函数,把
ctx当参数传进去即可; 简单日志直接调用ctx.LogInfo(...)。
已经用过的服务¶
| 写法 | 用途 |
|---|---|
ctx.LogInfo/LogWarn/LogError() |
日志 |
ctx.BorrowInputManager() |
输入 |
BML::UI::AddMessage(...) |
游戏内消息 |
ctx.RegisterCommand(...) |
注册命令 |
下一章将用到的服务¶
| 写法 | 用途 |
|---|---|
ctx.BorrowConfig() |
配置 |
ctx.SetTimeout(...) |
一次性 Timer |
ctx.SetInterval(...) |
循环 Timer |
回调签名必须精确匹配¶
BML 通过函数名和完整签名来匹配固定回调。如果你的函数签名写错了,BML 会跳过这个函数;排查时先看 ModLoader/ModLoader.log 里的签名提示。
常见的签名错误:
| 错误写法 | 问题 |
|---|---|
void onLoad(...) |
小写 o,BML 不认识 |
void OnLoad(BML::ModContext &in ctx) |
少了 const |
int OnProcess(...) |
返回值不是 void |
void OnGameEvent(const BML::ModContext &in ctx, const BML::GameEvent &in event) |
event 不是 const &in,是按值传 |
如果一个回调看起来永远不被调用,首先检查签名是否和本章列出的完全一致。
故障诊断¶
OnLoad 没有执行¶
- 脚本加载失败。检查日志里有没有语法错误
- 函数签名不对。和上面的签名表逐字对照
- 脚本文件没有放在
ModLoader/Mods/目录下
OnProcess 没有执行¶
OnLoad里有异常导致脚本被卸载。先确认OnLoad的日志出现- 脚本在加载阶段就失败了。看 BML 日志里有没有报错
OnGameEvent 没有收到事件¶
- 签名错误(最常见:给
event加了const &in) - 当前没有进入关卡(主菜单里不会有关卡事件)
ImGui 窗口不显示¶
OnProcess没有被调用(先解决上面的问题)Begin/End不配对(即使窗口折叠也必须调End)showWindow初始值设成了false
本章如何连接前后¶
回顾前面的章节:
- 第 02 章的
OnLoad里写启动日志,现在知道这是「脚本加载后执行一次」 - 第 04 章在
OnProcess里画窗口,现在知道这是「每帧调用」,所以窗口每帧存在 - 第 05 章在
OnLoad里注册命令,现在知道注册只做一次,执行靠另一个注册回调
下一章会引入 Config 和 Timer。Config 在 OnLoad 读取一次,Timer 回调是又一种注册回调,BML 在时间到了之后才调用你给它的函数。
完成状态¶
理解了回调模型后,前面写过的窗口、输入、命令都能串起来:OnLoad 做一次初始化,OnProcess 每帧更新,OnUnload 清理退出,OnGameEvent 响应关卡变化,OnRender 对接 Virtools 渲染阶段。固定回调靠名字匹配,注册回调靠 RegisterCommand / SetTimeout / SetInterval 交出函数引用。
下一步:07 配置与 Timer