Theseus 模组文档
开发指南

能力接口实战


能力接口都位于 com.seecmd.mod.api.capability,是模组与宿主之间的原语面:模组不持有宿主内部对象,只按类型取用能力。能力有两类来源:

两条通行约定:

7.1 我要做什么 → 用哪个能力接口 → 怎么拿

我要做什么能力接口怎么拿可用时机
自绘 HUDGameplayHudGameplayMod.createGameplayHud(),或 ctx.capabilities().register(GameplayHud.class, ...)游玩中
接管「按键 → 轨道」映射LaneInputMapperGameplayMod.createLaneInputMapper()游玩中
开自己的场景SceneHostctx.capabilities().get(SceneHost.class)进程级(全局常驻)
播放音效 / 控制 BGMAudioAccessctx.capabilities().get(AudioAccess.class)游玩中
注册自带音效 / BGM 文件ModContext.registerSound / registerMusicctx.registerSound(...) / ctx.registerMusic(...)任意回调(一次即可)
读 / 改游玩状态GameStateAccessctx.capabilities().get(GameStateAccess.class)游玩中
与其它模组联动ModServiceRegistryctx.modServices()进程级
网络功能NetworkAccessctx.network()进程级
在原生设置里加模组设置项SettingsExtension / SettingsItemctx.uiCustomization().settings()进程级
主界面状态栏扩展UiCustomizationctx.uiCustomization()进程级
全局快捷键GlobalHotkeys / GlobalHotkeyctx.hotkeys()进程级
查其它模组状态 / 失败原因ModManagerAccessctx.capabilities().get(ModManagerAccess.class)进程级

7.2 自绘 HUD:GameplayHud

GameplayHud 的核心是三件套:读 HUD 数值、隐藏原版组件开关、覆盖渲染(另有可选的 wantsOverlayWithoutHiding() 与 override(HudElement) 微调)。宿主在每次覆盖渲染前调用 updateValues(HudValues) 注入本帧数值快照,模组在 renderOverlay 中经 values() 读回。

关键语义:setXxxVisible(false) 是模组主动声明的隐藏开关(isProgressVisible() 等 getter 返回的是声明值,不是真实渲染状态);任一组件被声明隐藏、或 wantsOverlayWithoutHiding() 返回 true 时,宿主才调用 renderOverlay。

从零做一遍(隐藏原版连击、自绘大字、加进度条、坐标与缩放说明)见 自定义 HUD。下面只列能力契约。

java
public final class MyHudMod implements GameplayMod {

    private boolean comboHidden = true;

    @Override
    public ModInfo info() {
        return ModInfo.of("my_hud", "My HUD", "0.1.0", List.of("You"), "自绘连击区");
    }

    @Override
    public GameplayHud createGameplayHud() {
        return new MyHud();
    }

    private final class MyHud implements GameplayHud {

        /** 本帧 HUD 数值快照:由宿主经 updateValues 注入。 */
        private HudValues snapshot;

        @Override
        public HudValues values() {
            return snapshot;
        }

        @Override
        public void updateValues(HudValues values) {
            this.snapshot = values;   // 参数永不为 null
        }

        @Override public void setProgressVisible(boolean visible) { }
        @Override public void setComboVisible(boolean visible) { comboHidden = !visible; }
        @Override public void setScoreVisible(boolean visible) { }
        @Override public void setAccuracyVisible(boolean visible) { }

        @Override public boolean isProgressVisible() { return true; }
        @Override public boolean isComboVisible() { return !comboHidden; }
        @Override public boolean isScoreVisible() { return true; }
        @Override public boolean isAccuracyVisible() { return true; }

        @Override
        public void renderOverlay(RenderCanvas canvas, HudContext hudCtx) {
            int combo = values() != null ? values().combo() : 0;
            canvas.setColor(new Color(255, 255, 255));
            canvas.drawText("COMBO " + combo, 40, 120,
                    TextStyle.of(20, new Color(255, 255, 255)));
        }
    }
}

多模组叠加规则:所有经 createGameplayHud() 返回非 null 的实例按启用顺序收集,末尾追加经 capabilities().register(GameplayHud.class, ...) 注册的实例(同类型只保留一个,后注册的生效)。可见性声明取「任一隐藏即隐藏」(最严格生效),覆盖渲染依次调用、可互相叠加。

实时预览实时预览:上面这段 renderOverlay 代码在游戏里绘制出的霓虹进度条
预览加载中…

7.3 接管按键 → 轨道映射:LaneInputMapper

宿主在谱面加载、轨道数已知时调用一次 init(int laneCount);此后把 mapKeyToLane(int) 的返回值作为「该键是否触发轨道」的判定依据。返回 -1 表示不触发任何轨道。一个键触发多条轨道用 mapKeyToLanes(int);triggerKeys() 声明本模组管辖的物理键集合。

java
@Override
public LaneInputMapper createLaneInputMapper() {
    return new LaneInputMapper() {

        private int laneCount;

        @Override
        public void init(int laneCount) {
            this.laneCount = laneCount;   // 可在此缓存分区结果
        }

        @Override
        public int mapKeyToLane(int keyCode) {
            // 把主键区横向均分给各轨道;返回 -1 表示该键不触发轨道
            int[] row = {java.awt.event.KeyEvent.VK_A, java.awt.event.KeyEvent.VK_S,
                         java.awt.event.KeyEvent.VK_D, java.awt.event.KeyEvent.VK_F};
            for (int i = 0; i < row.length && i < laneCount; i++) {
                if (row[i] == keyCode) {
                    return i;
                }
            }
            return -1;
        }

        @Override
        public java.util.Set<Integer> triggerKeys() {
            // 声明管辖键:宿主据此构建 heldLanes 与轨道按压高亮
            return java.util.Set.of(java.awt.event.KeyEvent.VK_A, java.awt.event.KeyEvent.VK_S,
                    java.awt.event.KeyEvent.VK_D, java.awt.event.KeyEvent.VK_F);
        }
    };
}

多模组按键级路由规则:游玩开始时按启用顺序逐个调用 init(laneCount);每个物理键由声明管辖它(triggerKeys())的第一个模组负责映射;未声明管辖键的旧实现按启用顺序回退询问,第一个映射非空的生效(宿主会做重叠检测并记录诊断日志)。列表为空时游戏沿用内置固定键位。

7.4 开自己的场景:SceneHost 与 Scene

SceneHost 控制场景栈:push(Scene) 压入(当前场景挂起)、pop() 弹出(返回 boolean:栈底无历史时返回 false 且不执行导航)、replace(Scene) 替换(不改变栈深度)、current() 查当前场景。

模组场景实现 com.seecmd.mod.api.scene.Scene,只有 id() 是必须实现的方法,其余为 default 空实现;handleInput(InputEvent) 返回 true 表示已消费该输入。

java
/** 模组自定义场景:按 id 路由,render 每帧调用。 */
private static final class MyScene implements Scene {

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

    @Override
    public void render(RenderCanvas canvas) {
        canvas.setColor(new Color(32, 32, 32));
        canvas.fillRect(0, 0, 400, 200);
        canvas.drawText("我的界面", 20, 40, TextStyle.of(16, Color.WHITE));
    }

    @Override
    public boolean handleInput(InputEvent event) {
        return event.type() == InputType.KEY_PRESSED
                && event.keyCode() == java.awt.event.KeyEvent.VK_SPACE;
    }
}
java
// 打开 / 关闭:SceneHost 是进程级能力,onInit 里取一次即可
SceneHost host = ctx.capabilities().get(SceneHost.class);
if (host != null) {
    host.push(new MyScene());   // 打开
    host.pop();                 // 关闭(回到上一个场景)
}

需要「否决 / 改道」场景跳转时不要用本接口,订阅 SceneChangeEvent.Pre 事件(放行 = 不改结果,否决 = DENY,改道 = redirect(...))。

7.5 播音频:AudioAccess 与 ctx.registerSound/registerMusic

两者分工明确:ctx.registerSound / ctx.registerMusic 负责把 jar 内的音频文件注册进引擎(只需要做一次);AudioAccess 负责播放控制。

java
/** jar 内 assets/mymod/sounds/click.wav;其 toString 即引擎内注册 id。 */
private static final ResourceLocation CLICK = ResourceLocation.of("mymod", "sounds/click.wav");

@Override
public void onInit(ModContext ctx) {
    ctx.registerSound(CLICK, 0.8, true);   // 注册一次即可
}

private void playClick(ModContext ctx) {
    AudioAccess audio = ctx.capabilities().get(AudioAccess.class);   // 游玩中才有,可能为 null
    if (audio != null) {
        audio.playSound(CLICK.toString(), 0.8);
    }
}

7.6 读游玩状态:GameStateAccess

GameStateAccess 提供游玩状态机的读取与控制:musicTimeMs() / isPaused() / isFinished() 读状态,play() / pause() / resume() / seek(long) / setSpeed(double) / endGame() 改状态,setAltHudForced(boolean) 强制切换备用 HUD。

其中「轨道几何」区块为实验性入口(签名稳定、语义仍可能微调):setLaneRotation / getLaneRotation、setLaneOffset、setLaneSpeedFactor / getLaneSpeedFactor、flashLane、cursorPosition / setCursorPosition、laneRotationPivot。注意:

java
GameStateAccess state = ctx.capabilities().get(GameStateAccess.class);
if (state != null && !state.isFinished()) {
    long now = state.musicTimeMs();
    state.flashLane(2, 120);   // 在该轨道复用原生按压高亮,持续 120ms
}

3D 轨道倾斜与相机位姿(实验性)

7.7 与其它模组联动:ModServiceRegistry

每个模组 jar 运行在独立类加载器中,无法在编译期互相引用对方的类型。跨模组联动的做法是把「对外读写的状态」注册为命名服务:

java
// 提供方(服务实现需自行保证 get*/set* 线程安全)
ctx.modServices().register("background", myController);

// 消费方
ModService svc = ctx.modServices().find("other_mod:background");
if (svc != null && svc.has("visible")) {
    boolean visible = svc.boolValue("visible", true);
    svc.setBoolean("visible", !visible);
}

7.8 网络:NetworkAccess

宿主只负责线程生命周期托管,协议由模组用标准 java.net / java.nio 自行实现:

java
private ScheduledExecutorService netPool;

@Override
public void onInit(ModContext ctx) {
    netPool = ctx.network().executor();                     // 懒创建;不要缓存到静态字段
    netPool.execute(() -> {                                  // 阻塞 IO 都在这个池上做
        try (java.net.Socket socket = new java.net.Socket("127.0.0.1", 12345)) {
            // ... 收发 ...
        } catch (Exception e) {
            ctx.getLogger().warning("网络失败:" + e.getMessage());
        }
    });
}

网络任务若要影响画面,经线程安全入口回交游戏线程渲染(ctx.panels() 与 ctx.notifier() 均支持任意线程调用)。

7.9 原生设置里加模组设置项:SettingsExtension / SettingsItem

SettingsExtension 经 ctx.uiCustomization().settings() 获取(宿主未实现该扩展点时抛 UnsupportedOperationException,视为可选扩展点)。它把模组设置项直接嵌入原生设置场景(有设置项的模组在设置页各占一个独立标签页,见下)—— 这与第 8 节的 Mod.createConfigScene()「独立配置场景」是两条不同的路。

java
SettingsExtension settings = ctx.uiCustomization().settings();

SettingsItem hintSwitch = new SettingsItem() {

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

    @Override
    public String labelKey() {
        return "mymod.settings.hint";   // 译键;缺失时宿主回退显示原文
    }

    @Override
    public Type type() {
        return Type.SWITCH;             // 宿主画原生开关,态取自 isOn()
    }

    @Override
    public boolean isOn() {
        return cfg.hintEnabled();
    }

    @Override
    public void onEnter() {
        cfg.setHintEnabled(!cfg.hintEnabled());   // 自持久化:set 即同步写盘
    }
};

settings.addItem(hintSwitch);

Type 取值:SWITCH(宿主画原生开关)、NUMBER / JUMP_VALUE(宿主显示 toValueText(),左右键触发 onAdjust)、INPUT(显示 toValueText(),回车触发 onEnter,不响应左右键)、JUMP(回车打开 targetCollection() 指定的集合,格式 模组id:collectionId,不调用 onEnter)、CUSTOM(宿主把 value 区矩形包装成 RenderCanvas 调 drawValue 自绘)。

7.10 主界面状态栏扩展:UiCustomization

UiCustomization 提供的是嵌入原版固定 UI 区域的扩展点,与 ctx.panels() 的独立悬浮弹窗不同:panels() 是屏幕右下角的独立滑入弹窗(任意线程可调用,游戏线程统一渲染),不受原版 UI 布局约束;UiCustomization 的块嵌入主界面底部状态栏,随原版布局与空间分配。

java
UiCustomization.StatusItemHandle item =
        ctx.uiCustomization().addStatusBarItem("mymod", "示例模组");
item.setOnClick(() -> {
    // 点击回调在游戏线程执行,可直接跳转场景
    SceneHost host = ctx.capabilities().get(SceneHost.class);
    if (host != null) {
        host.push(new MyScene());
    }
});
item.setText("示例模组·已启用");   // 动态更新,下次渲染帧生效

7.11 全局快捷键:GlobalHotkeys

这是模组全局快捷键的唯一合法注册入口。 禁止自行监听按键事件来实现玩家无法改键的隐性快捷键 —— event.input.KeyEvent 是只读的原始按键事件流(适合输入分析),不能充当快捷键通道。注册的快捷键进入原生设置「全局快捷键」集合面板(与内置快捷键同列),玩家统一改键,绑定持久化于游戏设置,模组无需自行保存。

java
ctx.hotkeys().add(new GlobalHotkey() {

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

    @Override
    public String labelKey() {
        return "mymod.hotkey.toggle";   // 建议带模组前缀,避免与其它模组撞名
    }

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

    @Override
    public void onHotkey() {
        hudVisible = !hudVisible;   // 只做轻量状态切换
    }
});

7.12 查其它模组状态 / 失败原因:ModManagerAccess

进程级可用,用于实现自己的模组管理 UI(内置模组菜单功能与此接口等价):

java
ModManagerAccess mgr = ctx.capabilities().get(ModManagerAccess.class);
if (mgr != null) {
    for (ModInfo info : mgr.registeredMods()) {
        ModLoadStatus status = mgr.getLoadStatus(info.id());
        mgr.getLoadError(info.id()).ifPresent(reason ->
                ctx.getLogger().warning(info.id() + " 加载失败:" + reason));
        if (status == ModLoadStatus.ACTIVE) {
            // 该模组可用
        }
    }
}