Theseus 模组文档
开发指南

生命周期与阶段职责


2.1 四阶段总表

宿主按 onPreInit → 冻结注册表 → onInit → onPostInit → …(运行期)… → onUnload 推进,全部回调在 Mod 中都有默认空实现。四个阶段的职责不能混:

阶段何时允许做什么禁止 / 注意抛异常的后果
onPreInit(ModContext)模组加载时最先调用,注册表开放经 ctx.registries() 注册全部自有内容(音符类型 / 游戏模式 / 判定等级 / 谱面扩展段 / 渲染滤镜 / 背景渲染器 / 打击特效样式 / 判定策略 / 判定调整器 / 收集品 / 皮肤 / 层覆盖 / 位姿调整器等)不能把注册写到别的回调或运行期;窗口内不要做 IO / 耗时操作;回调顺序按依赖拓扑该模组不被激活,记入加载失败(LOAD_FAILED),其余模组继续;注册类错误(重复注册 IllegalArgumentException、冻结后写入 RegistryFrozenException)同样表现为该模组加载失败,不要捕获吞掉
宿主冻结注册表全部模组的 onPreInit 结束后立即执行宿主内部动作模组不得依赖该动作作为自己的回调不适用(宿主动作)
onInit(ModContext)注册表已冻结、全部模组的 onPreInit 已完成之后读配置、建缓存、订阅事件、创建运行期资源、读取其它模组已注册的内容(此时注册表内容已定型,可安全遍历)任何注册写入都会抛 RegistryFrozenException该模组不被激活,记入加载失败(不加入激活列表)
onPostInit(ModContext)全部模组的 onInit 已完成之后发布初始事件、自检、建立跨模组交互(此时可确信依赖模组已完成初始化)、读取自有译文任何注册写入保持激活,仅记录错误(此时运行期订阅已经建立,判成失败会让订阅变成孤儿)
运行期onPostInit 之后直至卸载事件回调、渲染、快捷键、网络等任何注册写入事件总线对每个监听器独立 try/catch(Throwable):单个监听器抛异常只记日志,不影响其它监听器,也不冒泡到宿主循环
onUnload()模组卸载时调用一次释放资源—异常被宿主隔离,仅记录日志,不阻塞其它模组卸载

要点:

2.2 阶段推进粒度

宿主在每一个阶段内部都按依赖拓扑顺序遍历全部模组,即:

text
全部模组的 onPreInit(按依赖序) → 冻结注册表 → 全部模组的 onInit(按依赖序) → 全部模组的 onPostInit(按依赖序)

而不是「逐个模组把三段跑完」。这条粒度直接决定了两件事:

阶段的遍历顺序由依赖关系决定(ModInfo.dependsOn() 声明硬依赖,缺失即加载失败;loadOrder() 声明加载阶段与软约束,目标不存在时自动失效)。

2.3 常见坑:在 onInit 里读自己的译文是空的

宿主在本模组的 onInit 返回之后才自动扫描并加载其 jar 内的语言文件(assets/<模组id>/lang/<locale>.json)。因此在 onInit 里读自己的译文会读到空值——这不是加载失败,只是时机没到。

正确做法是把「读自有译文」放到 onPostInit:

java
@Override
public void onInit(ModContext ctx) {
    // 这里读 ctx.language().get("hello_mod.greeting") 会得到空 Optional
}

@Override
public void onPostInit(ModContext ctx) {
    // 语言文件已在 onInit 返回后由宿主扫描加载,这里才是读自有译文的时机
    String greeting = ctx.language().get("hello_mod.greeting").orElse("(no lang entry)");
    ctx.getLogger().info("译文就绪:" + greeting);
    ctx.notifier().info(greeting);
}

同类道理:任何「依赖宿主在 onInit 之后才完成的装配动作」都要放到 onPostInit。

2.4 onInit 抛异常与 onPostInit 抛异常的区别

两者都做了异常隔离(不让一个模组崩掉游戏),但后果完全不同: