Theseus 模组文档
教程

设置项与快捷键

一个模组,在原生设置里多出自己的一页,页里有一个开关和一个数值行;再注册一个全局快捷键,按下后切换演示状态。改过的设置和改过的键位,重启后都还在。


为什么需要这个

设置界面是宿主的,不是你的。你不需要画窗口、排版、处理焦点,也不需要自己弹一个配置页。宿主只问你两件事:这一页有哪些设置项,以及玩家改动时通知谁。

设置项本身也不存值。它只是一份描述:叫什么、长什么类型、当前显示什么。真正的值放在你的字段里,跨重启的落盘交给宿主。

快捷键同理。玩家必须能在设置里改键,所以不能自己监听原始按键;正确入口是 GlobalHotkeys。你把「叫什么、默认哪个键、按下做什么」交出去,改键与持久化由宿主完成。

先认识四个词
  • 设置项(SettingsItem):一份无状态描述。宿主按它的 type() 画一行,并在玩家操作时回调你。
  • 设置扩展(SettingsExtension):注册入口。经 ctx.uiCustomization().settings() 取得,用 addItem(...) 把设置项交给宿主。
  • 全局快捷键(GlobalHotkey):任意场景下按下即触发的键。经 ctx.hotkeys().add(...) 注册,会出现在设置的「全局快捷键」集合里。
  • 自动页:只调用 addItem 的模组会各得一页,标题取模组显示名。它不是 addTab 建的自创页。

前置条件

本篇只碰四个 API 面

设置项与设置扩展、全局快捷键、SettingsChangedEvent、ctx.config()。每处出现都会解释,不必先翻参考页。

一次改动经过谁的手

改动设置项 回车 / 左右键 你的回调 onEnter / onAdjust 你的字段 值的唯一来源 persistedValue() 保存设置时收集 settings.json mods 段点号键 按下快捷键 默认键或玩家改键 GlobalHotkeyManager 每帧查按下沿 onHotkey() 游戏线程,一帧一次 ctx.config().set 调用即写盘 config.json data/mods/模组id/ 设置页项:离开设置页(或按应用 / 确定)时,宿主统一收集并落盘。 下次启动:restorePersisted() 在设置页打开前回填第 3 格。 快捷键没有「应用 / 确定」这一步,所以走 config.json 即时落盘。 两条通道都由宿主写文件,你只负责读写值。 玩家改键的结果另存 settings.json 的 globalHotkeys 段。
上半条是设置页的保存链路,下半条是快捷键的即时链路

开始动手

第 1 步:先建状态与入口

设置项是无状态的,值必须有别的地方放。先建入口类,把两个值放在字段里:

java
package com.example.settingsdemo;

import com.seecmd.mod.api.Mod;
import com.seecmd.mod.api.ModConfig;
import com.seecmd.mod.api.ModContext;
import com.seecmd.mod.api.ModInfo;

import java.util.List;

public final class SettingsDemoMod implements Mod {

    private static final String MOD_ID = "settings_demo";
    private static final String KEY_DEMO_ON = "demo_on";
    private static final double MIN_MULTIPLIER = 0.5;
    private static final double MAX_MULTIPLIER = 3.0;

    private ModContext ctx;
    private boolean enabled = true;
    private double multiplier = 1.0;
    private boolean demoOn;

    @Override
    public ModInfo info() {
        return ModInfo.of(MOD_ID, "设置演示", "1.0.0", List.of("me"), "设置项与全局快捷键示例");
    }

    @Override
    public void onInit(ModContext ctx) {
        this.ctx = ctx;
        this.demoOn = ctx.config().getBool(KEY_DEMO_ON, false);
    }

    boolean enabled() {
        return enabled;
    }

    void setEnabled(boolean value) {
        this.enabled = value;
    }

    double multiplier() {
        return multiplier;
    }

    void setMultiplier(double value) {
        double clamped = Math.max(MIN_MULTIPLIER, Math.min(MAX_MULTIPLIER, value));
        this.multiplier = Math.round(clamped * 10.0) / 10.0;
    }

    boolean demoOn() {
        return demoOn;
    }
}

enabled 与 multiplier 的初值就是默认值。API 没有「默认值」「最小值」「步长」这些字段,它们全由你自己维护。

onInit 里读的 demo_on 存在模组自己的 config.json,和设置页无关。为什么快捷键状态走这一边,后面会讲。

第 2 步:加一个开关

SettingsItem.Type 一共六种,没有下拉框:

Type宿主画什么你实现什么
SWITCH原生开关isOn() 给态,onEnter() 切换
NUMBER一行数值文本toValueText() 显示,onAdjust(dir) 步进
INPUT文本toValueText() 显示,编辑态自己维护
JUMP跳转引导targetCollection() 指向集合,不调 onEnter()
JUMP_VALUE数值 + 跳转toValueText()、onAdjust()、targetCollection()
CUSTOMvalue 区自绘drawValue(canvas, selected)

开关类是完整可编译的:

java
package com.example.settingsdemo;

import com.seecmd.mod.api.capability.SettingsItem;

final class EnabledSetting implements SettingsItem {

    private final SettingsDemoMod mod;

    EnabledSetting(SettingsDemoMod mod) {
        this.mod = mod;
    }

    @Override
    public String id() {
        return "enabled";
    }

    @Override
    public String labelKey() {
        return "settings_demo.enabled";
    }

    @Override
    public Type type() {
        return Type.SWITCH;
    }

    @Override
    public boolean isOn() {
        return mod.enabled();
    }

    @Override
    public void onEnter() {
        mod.setEnabled(!mod.enabled());
    }

    @Override
    public Object persistedValue() {
        return mod.enabled();
    }

    @Override
    public void restorePersisted(Object value) {
        if (value instanceof Boolean b) {
            mod.setEnabled(b);
        }
    }
}

id() 是模组内唯一 id。宿主拼成 settings_demo:enabled 作为注册全键,落盘时用点号形态 settings_demo.enabled。

persistedValue() 与 restorePersisted() 必须成对覆写。前者返回 null(默认)就表示这项不参与持久化,重启即丢。

第 3 步:加一个数值行

宿主当前的 NUMBER 是「一行数值 + 左右键步进」,没有可拖动的滑条控件。范围、步长、取整都在你这边做:

java
package com.example.settingsdemo;

import com.seecmd.mod.api.capability.SettingsItem;

import java.util.Locale;

final class MultiplierSetting implements SettingsItem {

    private static final double STEP = 0.1;

    private final SettingsDemoMod mod;

    MultiplierSetting(SettingsDemoMod mod) {
        this.mod = mod;
    }

    @Override
    public String id() {
        return "multiplier";
    }

    @Override
    public String labelKey() {
        return "settings_demo.multiplier";
    }

    @Override
    public Type type() {
        return Type.NUMBER;
    }

    @Override
    public String toValueText() {
        return String.format(Locale.ROOT, "%.1fx", mod.multiplier());
    }

    @Override
    public void onAdjust(int dir) {
        mod.setMultiplier(mod.multiplier() + dir * STEP);
    }

    @Override
    public String tooltipKey() {
        return "settings_demo.multiplier.tip";
    }

    @Override
    public Object persistedValue() {
        return mod.multiplier();
    }

    @Override
    public void restorePersisted(Object value) {
        if (value instanceof Number n) {
            mod.setMultiplier(n.doubleValue());
        }
    }
}

onAdjust 的 dir 只有 -1(左)与 +1(右)。范围钳制写在 setMultiplier 里,所以按到边界后不会再越界。

toValueText() 在渲染线程调用,只做字符串格式化,不要在这里读文件或查数据库。

第 4 步:注册全局快捷键

快捷键定义只有四个方法:id()、labelKey()、defaultKeyCode()、onHotkey()。

java
package com.example.settingsdemo;

import com.seecmd.mod.api.capability.GlobalHotkey;

final class DemoToggleHotkey implements GlobalHotkey {

    private final SettingsDemoMod mod;

    DemoToggleHotkey(SettingsDemoMod mod) {
        this.mod = mod;
    }

    @Override
    public String id() {
        return "toggle_demo";
    }

    @Override
    public String labelKey() {
        return "settings_demo.hotkey.toggle_demo";
    }

    @Override
    public int defaultKeyCode() {
        return java.awt.event.KeyEvent.VK_F7;
    }

    @Override
    public void onHotkey() {
        mod.toggleDemo();
    }
}

默认键只是「玩家还没改键时用哪个」。真正生效的绑定优先取玩家改键结果,其次才是这里返回的键码。

选默认键时避开常见修饰键。宿主内置的「显示光标」默认就是 Alt,别和它撞。

第 5 步:把三者接进入口

在 onInit 里注册设置项与快捷键,并订阅设置变更事件:

java
SettingsExtension ext = ctx.uiCustomization().settings();
ext.addItem(new EnabledSetting(this));
ext.addItem(new MultiplierSetting(this));

ctx.hotkeys().add(new DemoToggleHotkey(this));

ctx.eventBus().subscribe(SettingsChangedEvent.class, event -> {
    if (event.changedKeys().contains(MOD_ID + ".enabled")
            || event.changedKeys().contains(MOD_ID + ".multiplier")) {
        ctx.getLogger().info("设置已保存:enabled=" + enabled
                + ",multiplier=" + multiplier);
    }
});

这里没有「注册表冻结」的限制。设置项与快捷键可以随时注册,宿主在打开设置页时取快照。放在 onInit 只是为了让注册时机确定。

SettingsChangedEvent 在宿主保存设置后投递,不可取消。changedKeys() 不是精确 diff,按「键在集合里才处理」使用。

第 6 步:补上快捷键动作与最终入口

快捷键按下后切换 demoOn,并立刻写进 config.json。设置页的两个值仍走 settings.json:

java
package com.example.settingsdemo;

import com.seecmd.mod.api.Mod;
import com.seecmd.mod.api.ModContext;
import com.seecmd.mod.api.ModInfo;
import com.seecmd.mod.api.capability.SettingsExtension;
import com.seecmd.mod.api.event.SettingsChangedEvent;

import java.util.List;
import java.util.Locale;

public final class SettingsDemoMod implements Mod {

    private static final String MOD_ID = "settings_demo";
    private static final String KEY_DEMO_ON = "demo_on";
    private static final double MIN_MULTIPLIER = 0.5;
    private static final double MAX_MULTIPLIER = 3.0;

    private ModContext ctx;
    private boolean enabled = true;
    private double multiplier = 1.0;
    private boolean demoOn;

    @Override
    public ModInfo info() {
        return ModInfo.of(MOD_ID, "设置演示", "1.0.0", List.of("me"), "设置项与全局快捷键示例");
    }

    @Override
    public void onInit(ModContext ctx) {
        this.ctx = ctx;
        this.demoOn = ctx.config().getBool(KEY_DEMO_ON, false);

        SettingsExtension ext = ctx.uiCustomization().settings();
        ext.addItem(new EnabledSetting(this));
        ext.addItem(new MultiplierSetting(this));

        ctx.hotkeys().add(new DemoToggleHotkey(this));

        ctx.eventBus().subscribe(SettingsChangedEvent.class, event -> {
            if (event.changedKeys().contains(MOD_ID + ".enabled")
                    || event.changedKeys().contains(MOD_ID + ".multiplier")) {
                ctx.getLogger().info("设置已保存:enabled=" + enabled
                        + ",multiplier=" + multiplier);
            }
        });
    }

    void toggleDemo() {
        demoOn = !demoOn;
        ctx.config().set(KEY_DEMO_ON, demoOn);

        if (!enabled) {
            ctx.notifier().info("快捷键已触发,但设置里的提示开关是关的");
            return;
        }
        ctx.notifier().info("演示模式:" + (demoOn ? "开" : "关")
                + ",倍率 " + String.format(Locale.ROOT, "%.1fx", multiplier));
    }

    boolean enabled() {
        return enabled;
    }

    void setEnabled(boolean value) {
        this.enabled = value;
    }

    double multiplier() {
        return multiplier;
    }

    void setMultiplier(double value) {
        double clamped = Math.max(MIN_MULTIPLIER, Math.min(MAX_MULTIPLIER, value));
        this.multiplier = Math.round(clamped * 10.0) / 10.0;
    }

    boolean demoOn() {
        return demoOn;
    }
}

toggleDemo() 在游戏线程执行,只做字段切换、一次配置写入和一次提示,不要在这里做耗时操作。

第 7 步:补上译文再打包

设置行与快捷键行的显示名都来自 labelKey()。在工程里放一份语言文件:

text
src/main/resources/assets/settings_demo/lang/zh.json
json
{
  "settings_demo.enabled": "启用快捷键提示",
  "settings_demo.multiplier": "提示倍率",
  "settings_demo.multiplier.tip": "左右键以 0.1 步进,范围 0.5 到 3.0",
  "settings_demo.hotkey.toggle_demo": "切换演示模式"
}

键名建议带模组 id 前缀,避免与其它模组撞名。译键缺失时宿主会回退显示键名原文,看起来就是一串点号标识。

打包进游戏,进入设置,翻到模组页区。页标题是 ModInfo.name,也就是「设置演示」。

到这里应该看到
  • 设置页的模组页区里有一页叫「设置演示」,里面两行:启用快捷键提示、提示倍率 1.0x。
  • 回车切换开关;左右键让倍率以 0.1 变化,到 0.5 或 3.0 就停住。
  • 设置的「全局快捷键」集合里多出一行 切换演示模式,右侧显示默认键 。
  • 回到主界面按,右下角弹出提示;再按一次,提示里的开关状态反过来。
  • 离开设置页再重启,开关、倍率、改过的键位都保持原样。
原理:值存在哪、谁保存、快捷键冲突谁优先

设置项不存值。 SettingsItem 只是描述器,宿主每次渲染时问 isOn() / toValueText(),玩家操作时调 onEnter() / onAdjust()。值的唯一来源是你的字段。

跨重启有两条宿主通道,选一条用,不要手写文件。

  • settings.json 的 mods 段:覆写 persistedValue() 与 restorePersisted(),键是点号形态 <模组id>.<设置项id>,例如 settings_demo.multiplier。宿主在保存设置时(应用 / 确定 / 离开设置页)收集非 null 的值,下次启动在设置页打开前回填。值只能是 JSON 基元:Boolean、Number、String。回填时整数可能是 Long、小数是 Double,所以要用 Number 取值,不要硬转 Integer。
  • data/mods/<模组id>/config.json:经 ctx.config() 读写,扁平 key-value。set(...) 调用即同步写盘,没有「应用 / 确定」这一步。适合非界面数据,也适合像本篇这样由快捷键即时改变的运行状态。

两条通道的文件都归宿主管:settings.json 由宿主统一序列化,config.json 只该经 ctx.config() 访问。自己打开文件写,会和宿主的写入抢同一份内容,也会重复实现序列化与默认值逻辑。

为什么本篇把快捷键状态放 config.json。 设置页项的保存时机是「离开设置页」,而快捷键可能在任何场景按下。如果按一次 只改内存,玩家直接关游戏就丢了。ctx.config().set(...) 没有这个时机问题。

快捷键冲突没有优先级。 宿主把所有注册项放在同一张表里,每帧遍历,凡是当前键码匹配的都会触发,顺序就是注册顺序;它不会因为某个是内置项就跳过另一个,也不会在改键时拒绝重复键。所以默认键要选得克制,真正的解法是让玩家改键。

快捷键的绑定也不用你保存。 你只给默认键码;玩家改键写在 settings.json 的 globalHotkeys 段,解析时玩家绑定优先于默认值。注册返回的 SettingHandle.remove() 可以注销单个快捷键;模组停用或游戏退出时宿主也会自动清理,不需要在 onUnload 里逐个移除。

设置项落在哪。 只调 addItem(...) 的模组各得一个自动页:标题取模组显示名,排在全部宿主页之后、显式 addTab 建的自创页之前;多个模组的自动页按首次注册顺序排。页内是一个分节,行序就是 addItem 的调用顺序。anchorBefore() / anchorAfter() 是遗留字段,设置场景已经不再消费,不要靠它们定位。想把选项分组,用 addCollection(...) 建集合,再用 JUMP / JUMP_VALUE 项钻取进入。

常见坑
  • 以为 NUMBER 是拖动滑条:当前宿主画的是数值文本,左右键步进;没有范围与步长元数据,越界钳制必须自己写。
  • 找不到下拉类型:SettingsItem.Type 只有六种,没有下拉框。多选项可以用 NUMBER 循环,或建集合用 JUMP 进入。
  • 只覆写 persistedValue() 不覆写 restorePersisted():值会写进 settings.json,但下次启动没人接住,表现为「存档里有值,界面还是默认」。
  • 把回填值硬转 Integer / Double:JSON 往返后整数是 Long、小数是 Double,硬转会抛异常或拿错类型。统一按 Number 读。
  • 在 onInit 里缓存「已恢复的值」:回填发生在全部模组初始化之后、设置页打开之前。这里读到的是默认值,后面回填不会自动同步到你复制出来的副本。需要时直接读字段。
  • 以为「取消」会回滚模组项:默认是即时生效,staged() 未申报时「取消」不动模组项。要配合应用 / 取消,需成组覆写 staged()、isDirty()、onCommit()、onRevert()。
  • 在 isOn() / toValueText() 里做 IO:它们在渲染线程逐帧调用,慢一次就掉一帧。
  • 用原始按键事件做「隐性快捷键」:玩家无法改键。面向用户的快捷键必须走 GlobalHotkeys。
  • 默认键选 Alt:会与内置「显示光标」同键,两个动作都会触发。
交付自查
  • 设置项 id 在模组内唯一,标签有对应译键。
  • 开关与数值行的默认值写在字段初值里,范围由自己钳制。
  • persistedValue() 与 restorePersisted() 成对覆写,数值按 Number 读取。
  • 快捷键用 GlobalHotkeys 注册,onHotkey() 只做轻量操作。
  • 默认键避开了内置快捷键与常见修饰键。
  • 设置项与快捷键没有在 onUnload 里逐个移除。
  • 重启后开关、倍率与键位都能恢复。

接下来