设置项与快捷键
一个模组,在原生设置里多出自己的一页,页里有一个开关和一个数值行;再注册一个全局快捷键,按下后切换演示状态。改过的设置和改过的键位,重启后都还在。
设置界面是宿主的,不是你的。你不需要画窗口、排版、处理焦点,也不需要自己弹一个配置页。宿主只问你两件事:这一页有哪些设置项,以及玩家改动时通知谁。
设置项本身也不存值。它只是一份描述:叫什么、长什么类型、当前显示什么。真正的值放在你的字段里,跨重启的落盘交给宿主。
快捷键同理。玩家必须能在设置里改键,所以不能自己监听原始按键;正确入口是 GlobalHotkeys。你把「叫什么、默认哪个键、按下做什么」交出去,改键与持久化由宿主完成。
- 设置项(
SettingsItem):一份无状态描述。宿主按它的type()画一行,并在玩家操作时回调你。 - 设置扩展(
SettingsExtension):注册入口。经ctx.uiCustomization().settings()取得,用addItem(...)把设置项交给宿主。 - 全局快捷键(
GlobalHotkey):任意场景下按下即触发的键。经ctx.hotkeys().add(...)注册,会出现在设置的「全局快捷键」集合里。 - 自动页:只调用
addItem的模组会各得一页,标题取模组显示名。它不是addTab建的自创页。
前置条件
- 已经能构建并加载一个最小模组(见 第一个模组)。
- 工程已引入
mod-api-3.2.jar与 gson。
设置项与设置扩展、全局快捷键、SettingsChangedEvent、ctx.config()。每处出现都会解释,不必先翻参考页。
一次改动经过谁的手
开始动手
设置项是无状态的,值必须有别的地方放。先建入口类,把两个值放在字段里:
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,和设置页无关。为什么快捷键状态走这一边,后面会讲。
SettingsItem.Type 一共六种,没有下拉框:
Type | 宿主画什么 | 你实现什么 |
|---|---|---|
SWITCH | 原生开关 | isOn() 给态,onEnter() 切换 |
NUMBER | 一行数值文本 | toValueText() 显示,onAdjust(dir) 步进 |
INPUT | 文本 | toValueText() 显示,编辑态自己维护 |
JUMP | 跳转引导 | targetCollection() 指向集合,不调 onEnter() |
JUMP_VALUE | 数值 + 跳转 | toValueText()、onAdjust()、targetCollection() |
CUSTOM | value 区自绘 | drawValue(canvas, selected) |
开关类是完整可编译的:
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(默认)就表示这项不参与持久化,重启即丢。
宿主当前的 NUMBER 是「一行数值 + 左右键步进」,没有可拖动的滑条控件。范围、步长、取整都在你这边做:
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() 在渲染线程调用,只做字符串格式化,不要在这里读文件或查数据库。
快捷键定义只有四个方法:id()、labelKey()、defaultKeyCode()、onHotkey()。
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,别和它撞。
在 onInit 里注册设置项与快捷键,并订阅设置变更事件:
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,按「键在集合里才处理」使用。
快捷键按下后切换 demoOn,并立刻写进 config.json。设置页的两个值仍走 settings.json:
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() 在游戏线程执行,只做字段切换、一次配置写入和一次提示,不要在这里做耗时操作。
设置行与快捷键行的显示名都来自 labelKey()。在工程里放一份语言文件:
src/main/resources/assets/settings_demo/lang/zh.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里逐个移除。 - 重启后开关、倍率与键位都能恢复。
接下来
- 想把设置项分组放进二级页:看 SettingsExtension 的
addCollection(...)与JUMP/JUMP_VALUE。 - 想自己画 value 区(颜色块、图标):看 SettingsItem 的
CUSTOM与drawValue(...)。 - 想跟随设置变化刷新缓存:看 SettingsChangedEvent。
- 想读写非界面数据:看 ModConfig。
- 想给快捷键换一套默认键或处理注册顺序:看 GlobalHotkeys 与 GlobalHotkey。