Theseus 模组文档
教程

第一个模组

一个会「说话」的模组——启动时依次打印三段日志、在模组自己的数据目录里存一个计数、并在游戏右下角弹出一次提示。


为什么第二篇要写这个

序章只证明了「jar 能被加载」,你的代码还一个字都没跑。这一篇的目的不是学 API,而是把生命周期摸清楚:宿主在什么时候叫你的哪个方法,哪些事只能在某个时刻做。

后面每一篇教程(加判定等级、加音符、画 HUD)都要往这三个回调里塞代码。先在这里把它跑通一次,后面就不会出现「代码写了但没执行」「注册报 RegistryFrozenException」这类闷头问题。

先认识四个词
  • 入口类:实现 Mod 的那个类。manifest 里的 Seecmd-Mod-Class 指向它,宿主靠它找到你。
  • 模组上下文(ModContext):宿主递给你的「工具箱」——日志、数据目录、注册表、事件总线、资源,都从这里拿。
  • 回调(callback):宿主在特定时刻主动调用你的方法。你不用写 main,也不用手动「启动」。
  • 注册表(registry):宿主记录「游戏里有哪些内容」的清单。它只在 onPreInit 期间可写。

三个阶段发生了什么

宿主启动时会对每一个模组按顺序走一遍这三个回调。注意粒度:它是「所有模组跑完 onPreInit → 冻结注册表 → 所有模组跑 onInit → 所有模组跑 onPostInit」,不是「一个模组从头跑到尾」。

onPreInit 唯一可写注册表 冻结 宿主执行,不可逆 onInit 读资源 / 订阅事件 onPostInit 跨模组 / 读自己的译文 本篇要观察的就是这条线:同一个模组,三段日志会在启动时按顺序出现。 之后进游戏、打球、退出,都不再触发它们——那时是「运行期」,靠事件回调工作。
三个阶段的顺序与职责。虚线框是宿主自己的动作,模组不参与

前置条件

不需要提前读 API 参考

本篇只用到 Mod、ModInfo、ModContext 三个类型,出现时都会解释。

开始动手

写入口类

在 src/main/java/com/example/hellomod/ 下新建 HelloMod.java:

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,确认入口类写的是全限定名:

bat
set "MOD_ID=hello_mod"
set "MOD_CLASS=com.example.hellomod.HelloMod"

MOD_CLASS 少写一段包名、或者类名大小写不对,表现都是「列表里 LOAD_FAILED,原因是找不到入口类」。

构建并观察三段日志

构建、把 jar 放进 mods\、重启游戏,然后在日志里搜 hello_mod,应当看到三行按这个顺序出现的记录:

text
[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

宿主在全部模组的 onPreInit 跑完之后,会立刻把注册表冻结。原因很简单:判定和渲染是每帧都在跑的路径,如果注册表随时可能被改,这些热路径就得每帧加锁或检查。

冻结换来的是零开销读取,代价是写入只能发生在那个窗口里。冻结之后任何 register(...) 都会抛 RegistryFrozenException——看到这个异常,不用怀疑别的地方,就是注册写晚了。

用模组自己的数据目录

ctx.getDataDir() 返回 data/mods/<模组id>/ 的绝对路径,目录已经建好,可以直接往里写文件。这里做一个「启动次数」计数:

java
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():

java
@Override
public void onPostInit(ModContext ctx) {
    ctx.notifier().info("Hello Mod 已就绪,第 " + launchCount + " 次启动");
}

通知是右下角滑入的提示面板,约 4 秒自动消失,可在任意线程调用。它只是提示,不能点击;需要可交互的常驻面板时用 ctx.panels()。

交付前自查
  • 日志里能看到三行阶段日志,且顺序正确
  • 第二次启动时 launchCount 变成 2(说明配置真的落盘了)
  • 剧情外也能看到右下角提示
  • 模组列表状态仍是 ACTIVE

完整代码

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 {

    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 写错:它就是资源命名空间和数据目录名。写错不会立刻报错,但之后注册内容、加载资源会全线对不上。
为什么在 onInit 里读自己的译文是空的

宿主扫描并加载模组 jar 内语言文件(assets/<模组id>/lang/<locale>.json)的时机,是在本模组的 onInit 返回之后。所以 onInit 里读自己的译文会拿到空值。

这不是加载失败,只是时机没到。凡是「依赖宿主在 onInit 之后才完成的装配」,都放到 onPostInit。下一章开始注册内容后,这条会反复用到。

接下来