第一个模组
一个会「说话」的模组——启动时依次打印三段日志、在模组自己的数据目录里存一个计数、并在游戏右下角弹出一次提示。
序章只证明了「jar 能被加载」,你的代码还一个字都没跑。这一篇的目的不是学 API,而是把生命周期摸清楚:宿主在什么时候叫你的哪个方法,哪些事只能在某个时刻做。
后面每一篇教程(加判定等级、加音符、画 HUD)都要往这三个回调里塞代码。先在这里把它跑通一次,后面就不会出现「代码写了但没执行」「注册报 RegistryFrozenException」这类闷头问题。
- 入口类:实现
Mod的那个类。manifest 里的Seecmd-Mod-Class指向它,宿主靠它找到你。 - 模组上下文(
ModContext):宿主递给你的「工具箱」——日志、数据目录、注册表、事件总线、资源,都从这里拿。 - 回调(callback):宿主在特定时刻主动调用你的方法。你不用写
main,也不用手动「启动」。 - 注册表(registry):宿主记录「游戏里有哪些内容」的清单。它只在
onPreInit期间可写。
三个阶段发生了什么
宿主启动时会对每一个模组按顺序走一遍这三个回调。注意粒度:它是「所有模组跑完 onPreInit → 冻结注册表 → 所有模组跑 onInit → 所有模组跑 onPostInit」,不是「一个模组从头跑到尾」。
前置条件
- 序章已完成:工程能构建、jar 能加载、列表里状态是
ACTIVE(见 序章:把环境搭起来)。
本篇只用到 Mod、ModInfo、ModContext 三个类型,出现时都会解释。
开始动手
在 src/main/java/com/example/hellomod/ 下新建 HelloMod.java:
package com.example.hellomod;
import com.seecmd.mod.api.Mod;
import com.seecmd.mod.api.ModContext;
import com.seecmd.mod.api.ModInfo;
import java.util.List;
public class HelloMod implements Mod {
/** 必须与 build.bat 里的 MOD_ID、以及 manifest 的 Seecmd-Mod-Id 一致。 */
private static final String MOD_ID = "hello_mod";
@Override
public ModInfo info() {
return ModInfo.of(
MOD_ID, // 模组 id
"Hello Mod", // 显示名称
"0.1.0", // 你自己的版本号
List.of("YourName"), // 作者
"我的第一个 Theseus 模组"); // 描述
}
@Override
public void onPreInit(ModContext ctx) {
ctx.getLogger().info("阶段 1/3:onPreInit,注册表当前可写");
}
@Override
public void onInit(ModContext ctx) {
ctx.getLogger().info("阶段 2/3:onInit,注册表已冻结");
}
@Override
public void onPostInit(ModContext ctx) {
ctx.getLogger().info("阶段 3/3:onPostInit,全部模组初始化完毕");
}
}三个回调全部来自 Mod 接口的默认空实现,你覆写哪个就执行哪个。真正必须实现的只有 info()。
打开 build.bat,确认入口类写的是全限定名:
set "MOD_ID=hello_mod"
set "MOD_CLASS=com.example.hellomod.HelloMod"MOD_CLASS 少写一段包名、或者类名大小写不对,表现都是「列表里 LOAD_FAILED,原因是找不到入口类」。
构建、把 jar 放进 mods\、重启游戏,然后在日志里搜 hello_mod,应当看到三行按这个顺序出现的记录:
[hello_mod] 阶段 1/3:onPreInit,注册表当前可写
[hello_mod] 阶段 2/3:onInit,注册表已冻结
[hello_mod] 阶段 3/3:onPostInit,全部模组初始化完毕日志的 logger 名是 seecmd.mod.<模组id>,输出前缀是 [<模组id>]。
说明 onPostInit 里抛异常了——它不会让模组变成 LOAD_FAILED,只记录错误并继续(onPostInit 执行时模组已经处于激活状态)。反过来说:如果 onInit 抛异常,模组会直接不被激活,日志里的后两行都不会出现。
宿主在全部模组的 onPreInit 跑完之后,会立刻把注册表冻结。原因很简单:判定和渲染是每帧都在跑的路径,如果注册表随时可能被改,这些热路径就得每帧加锁或检查。
冻结换来的是零开销读取,代价是写入只能发生在那个窗口里。冻结之后任何 register(...) 都会抛 RegistryFrozenException——看到这个异常,不用怀疑别的地方,就是注册写晚了。
ctx.getDataDir() 返回 data/mods/<模组id>/ 的绝对路径,目录已经建好,可以直接往里写文件。这里做一个「启动次数」计数:
private static final String MOD_ID = "hello_mod";
private int launchCount;
@Override
public void onInit(ModContext ctx) {
launchCount = ctx.config().getInt("launch_count", 0) + 1;
ctx.config().set("launch_count", launchCount); // set 会立刻写盘
ctx.getLogger().info("第 " + launchCount + " 次启动,数据目录:" + ctx.getDataDir());
}ctx.config() 读写的是 data/mods/<模组id>/config.json,是一个扁平的 key-value 文件,只支持 String / Integer / Double / Boolean / List。复杂结构自己序列化成字符串存一个 key。
日志只有开发时看得到。想让玩家也看到,用 ctx.notifier():
@Override
public void onPostInit(ModContext ctx) {
ctx.notifier().info("Hello Mod 已就绪,第 " + launchCount + " 次启动");
}通知是右下角滑入的提示面板,约 4 秒自动消失,可在任意线程调用。它只是提示,不能点击;需要可交互的常驻面板时用 ctx.panels()。
- 日志里能看到三行阶段日志,且顺序正确
- 第二次启动时
launchCount变成 2(说明配置真的落盘了) - 剧情外也能看到右下角提示
- 模组列表状态仍是
ACTIVE
完整代码
package com.example.hellomod;
import com.seecmd.mod.api.Mod;
import com.seecmd.mod.api.ModContext;
import com.seecmd.mod.api.ModInfo;
import java.util.List;
public class HelloMod implements Mod {
private static final String MOD_ID = "hello_mod";
private int launchCount;
@Override
public ModInfo info() {
return ModInfo.of(MOD_ID, "Hello Mod", "0.1.0",
List.of("YourName"), "我的第一个 Theseus 模组");
}
@Override
public void onPreInit(ModContext ctx) {
// 唯一允许注册内容的阶段。本模组暂无自有内容。
ctx.getLogger().info("阶段 1/3:onPreInit,注册表当前可写");
}
@Override
public void onInit(ModContext ctx) {
// 注册表已冻结:读配置、建缓存、订阅事件都在这里。
launchCount = ctx.config().getInt("launch_count", 0) + 1;
ctx.config().set("launch_count", launchCount);
ctx.getLogger().info("阶段 2/3:onInit,第 " + launchCount
+ " 次启动,数据目录:" + ctx.getDataDir());
}
@Override
public void onPostInit(ModContext ctx) {
// 全部模组的 onInit 已完成:跨模组交互、读自有译文放这里。
ctx.getLogger().info("阶段 3/3:onPostInit");
ctx.notifier().info("Hello Mod 已就绪,第 " + launchCount + " 次启动");
}
}常见坑
- 在
onInit里注册内容:注册表已冻结,会抛RegistryFrozenException。 - 以为不写
onUnload会泄漏:宿主会在卸载时调用一次,默认空实现;只有持有线程、文件句柄、定时器时才需要覆写它来释放。 - 在
onPreInit里做耗时操作:这个窗口是所有模组共享的,卡住它等于拖慢整个启动,而且它还在冻结注册表之前——大文件读取放onInit。 - 用
System.out.println打日志:能打出来,但不带模组前缀,也进不了游戏的日志面板。统一用ctx.getLogger()。 - 把
MOD_ID写错:它就是资源命名空间和数据目录名。写错不会立刻报错,但之后注册内容、加载资源会全线对不上。
宿主扫描并加载模组 jar 内语言文件(assets/<模组id>/lang/<locale>.json)的时机,是在本模组的 onInit 返回之后。所以 onInit 里读自己的译文会拿到空值。
这不是加载失败,只是时机没到。凡是「依赖宿主在 onInit 之后才完成的装配」,都放到 onPostInit。下一章开始注册内容后,这条会反复用到。