Theseus 模组文档
教程

自定义场景

一个模组,按快捷键(默认 F7,玩家可在设置里改)打开一块自绘的「练习面板」——标题、提示、一个真正能打字的输入框;按 ESC 或再按一次快捷键,回到打开前的界面。


为什么需要这个

HUD 只能叠在游玩画面上,设置项只能塞进设置页。想在游戏里做一整页自己的界面(图鉴、练习面板、联机房间列表),需要向宿主借一个场景。

场景不是一张贴图,而是一段生命周期:宿主在进入时叫你、每帧叫你更新和绘制、把输入逐条交给你、离开时叫你收尾。画面归你,导航和系统输入法归宿主——分工清楚,所以你不必碰窗口、焦点和候选窗位置。

先认识四个词
  • 场景(Scene):一整屏界面。实现 Scene,只有 id() 是必须写的,其余回调都有默认空实现。
  • 场景栈(scene stack):宿主维护的界面栈。push 把新场景压上去,pop 回到上一个,replace 只换掉栈顶、深度不变。
  • 输入事件(InputEvent):宿主把键盘、文本、鼠标统一成一种事件,交给 Scene.handleInput。
  • 输入法锚点(ImeAnchor):告诉宿主「输入光标在这」。中文候选窗是系统窗口,没有锚点就只会停在画布角落。

运行流程

按下快捷键 GlobalHotkey 压入场景 push + onEnter 每帧更新与绘制 update(dt) / render 分发输入 handleInput 弹出场景 pop + onExit 第 3、4 步每帧重复;输入只送给栈顶场景,被压住的场景收不到任何事件。 ESC 未被你的场景消费时,宿主替你退回上一个场景;再按一次快捷键则走 pop()。
一条快捷键把场景压进栈,之后是「更新 → 输入 → 绘制」的循环,最后弹出

具体到宿主源码,一帧的顺序是:

  1. GlobalHotkey.onHotkey() 在游戏线程触发,你调用 SceneHost.push;
  2. 宿主先把导航动作投递到场景线程,当前场景 onExit,再为你的场景建适配器并调用 onEnter;
  3. 每帧先调用你的 update(dt),紧接着在同一帧里分发本帧按键、文本与鼠标事件;
  4. 然后调用 render(canvas) 画整屏;
  5. 你 pop() 或 ESC 未被消费时宿主 back(),你的 onExit 被调用。

前置条件

不用先读 API 参考

本篇只用到 Scene、SceneHost、SceneInfo、InputEvent、InputType、TextField、ImeAnchor、RenderCanvas、GlobalHotkey。每处出现都会解释。

开始动手

第 1 步:写场景类

Scene 的六个回调各有固定时机,先记住这张表:

回调什么时候调用该做什么
String id()注册 / 导航时读取返回唯一 id,不能返回 null
void onEnter()每次进入场景重置本局状态(文本、光标、计时器)
void update(double dt)每帧,渲染与输入之前推进动画与计时,dt 单位是秒
boolean handleInput(InputEvent)每帧,update 之后分派输入,返回 true 表示已消费
void render(RenderCanvas)每帧,输入之后只负责画,不改业务状态
void onExit()离开场景清掉临时状态与 IME 锚点

场景对象会被反复复用:pop 之后再次 push,回到的还是同一个实例,所以字段必须在 onEnter 里重置。下面是完整的场景类:

java
package com.example.mymod;

import com.seecmd.mod.api.render.RenderCanvas;
import com.seecmd.mod.api.render.TextStyle;
import com.seecmd.mod.api.scene.InputEvent;
import com.seecmd.mod.api.scene.InputType;
import com.seecmd.mod.api.scene.Scene;
import com.seecmd.mod.api.text.ImeAnchor;
import com.seecmd.mod.api.text.TextField;

import java.awt.Color;
import java.awt.event.KeyEvent;

public final class PracticePanelScene implements Scene {

    static final String ID = "mymod:practice_panel";

    private static final double PANEL_X = 240;
    private static final double PANEL_Y = 120;
    private static final double PANEL_W = 800;
    private static final double PANEL_H = 480;

    private static final double FIELD_X = 280;
    private static final double FIELD_Y = 448;
    private static final double FIELD_W = 720;
    private static final double FIELD_H = 36;

    private static final Color BG = new Color(18, 20, 26);
    private static final Color PANEL_BG = new Color(30, 34, 44);
    private static final Color ACCENT = new Color(79, 195, 247);
    private static final Color DIM = new Color(160, 168, 180);

    private final TextField input = new TextField(32, false);
    private boolean focused = true;
    private double elapsed;
    private String lastSubmitted = "";

    @Override
    public String id() {
        return ID;
    }

    @Override
    public void onEnter() {
        input.reset("");
        lastSubmitted = "";
        focused = true;
        elapsed = 0;
    }

    @Override
    public void update(double dt) {
        elapsed += dt;   // 时间只在 update 推进,render 只按当前值画
    }

    @Override
    public boolean handleInput(InputEvent event) {
        if (event.type() == InputType.MOUSE_PRESSED) {
            boolean inside = inField(event.mouseX(), event.mouseY());
            focused = inside;
            if (!inside) {
                ImeAnchor.clear();   // 失焦就把候选窗锚点交还宿主
            }
            return inside;
        }
        if (event.type() == InputType.TEXT_INPUT) {
            return focused && input.handleInput(event);
        }
        if (event.type() != InputType.KEY_PRESSED) {
            return false;
        }
        if (event.keyCode() == KeyEvent.VK_ESCAPE) {
            return false;   // 不消费:宿主会 back() 回上一个场景
        }
        if (event.keyCode() == KeyEvent.VK_ENTER) {
            lastSubmitted = input.text();
            input.rememberCurrent();
            return true;
        }
        return focused && input.handleInput(event);
    }

    @Override
    public void render(RenderCanvas canvas) {
        canvas.save();
        canvas.setAlpha(1.0);

        // 1) 场景不继承上一场景的画面,自己铺满 1280×720 逻辑空间
        canvas.setColor(BG);
        canvas.fillRect(0, 0, 1280, 720);

        // 2) 面板与边框,全部用逻辑坐标
        canvas.setColor(PANEL_BG);
        canvas.fillRect(PANEL_X, PANEL_Y, PANEL_W, PANEL_H);
        canvas.setColor(ACCENT);
        canvas.drawRect(PANEL_X, PANEL_Y, PANEL_W, PANEL_H);

        // 3) 裁剪:标题与提示只在面板内部可见
        canvas.save();
        canvas.clip(PANEL_X + 1, PANEL_Y + 1, PANEL_W - 2, PANEL_H - 2);

        double pulse = 0.55 + 0.45 * Math.sin(elapsed * 2.5);
        canvas.setAlpha(pulse);
        canvas.setColor(ACCENT);
        canvas.fillRect(PANEL_X + 32, PANEL_Y + 36, 4, 56);

        canvas.setAlpha(1.0);
        canvas.drawText("练习面板", PANEL_X + 56, PANEL_Y + 78,
                TextStyle.of(TextStyle.FontType.BOLD, 30, Color.WHITE,
                        TextStyle.TextAlignment.LEFT));
        canvas.setColor(DIM);
        canvas.drawText("在下面输入练习备注,按 Enter 记录,按 ESC 关闭。",
                PANEL_X + 32, PANEL_Y + 124, TextStyle.of(14, DIM));
        canvas.restore();   // 与上面那次 save 配对,裁剪随之失效

        // 4) 输入框:focused 时自动画光标并上报 IME 锚点
        input.render(canvas, FIELD_X, FIELD_Y, FIELD_W, FIELD_H, focused);

        // 5) 状态行
        String status = lastSubmitted.isEmpty() ? "尚未记录" : "已记录:" + lastSubmitted;
        canvas.setColor(DIM);
        canvas.drawText(status, FIELD_X, FIELD_Y + FIELD_H + 28, TextStyle.of(13, DIM));
        canvas.restore();
    }

    @Override
    public void onExit() {
        ImeAnchor.clear();
    }

    private boolean inField(int x, int y) {
        return x >= FIELD_X && x <= FIELD_X + FIELD_W
                && y >= FIELD_Y && y <= FIELD_Y + FIELD_H;
    }
}

TextField 自己管文本、光标、选区和撤销,不需要注册,也不持有焦点——焦点由场景记在 focused 里,每次 render 时传进去。

input.render(..., true) 在绘制光标时会顺手把「光标 X + 输入框底边 Y」写进 ImeAnchor,宿主据此移动系统的输入法候选窗。如果你完全自绘、不调用 TextField.render,就必须自己调用 input.reportImeAnchor(caretX, boxBottomY);否则中文候选窗会固定在窗口角落,打字时看不见选词。

第 2 步:把输入按类型分派

宿主对模组场景只派发五种事件:KEY_PRESSED、TEXT_INPUT,以及 MOUSE_PRESSED / MOUSE_DRAGGED / MOUSE_RELEASED。不会派发 KEY_RELEASED 与 KEY_HELD,长按要自己用 InputAccess 轮询。

分派顺序建议固定为「全局键 → 字符 → 编辑键」:

java
// ESC 先判:返回 false 就是明确告诉宿主「我没接,你来退」
if (event.type() == InputType.KEY_PRESSED && event.keyCode() == KeyEvent.VK_ESCAPE) {
    return false;
}
// 字符与输入法提交都走 TEXT_INPUT,每个字符一条事件
if (event.type() == InputType.TEXT_INPUT) {
    return focused && input.handleInput(event);
}
if (event.type() != InputType.KEY_PRESSED) {
    return false;   // 鼠标事件在完整类里另有分支
}
// 方向键、Backspace、Ctrl+C/V 等编辑键交给输入框
return focused && input.handleInput(event);

三个判断依据分别是:

  • 键:event.keyCode(),取值与 java.awt.event.KeyEvent.VK_* 一致;文本事件的键码恒为 0。
  • 字符:event.type() == InputType.TEXT_INPUT,内容在 event.text(),中文输入法上屏也走这里。
  • 鼠标:event.mouseX() / event.mouseY(),宿主已经把窗口像素换算成 1280×720 的场景逻辑坐标;键盘事件的这两个值是 -1。

TextField.handleInput 只处理 TEXT_INPUT 与 KEY_PRESSED,其余类型直接返回 false;Enter、TAB、ESC 它也不会消费,所以这三个键必须由场景自己决定。

第 3 步:画布坐标、裁剪与层

场景画布是一块固定的 1280×720 逻辑空间,左上角为原点、y 向下;窗口缩放只改变显示尺寸,不改这里的坐标。RenderCanvas 本身没有 width() / height(),布局常量就按 1280×720 写。

裁剪要记住一条:clip(x, y, w, h) 用的是当前变换下的局部坐标,并且与已有裁剪区求交。下面两种写法结果不同:

java
// 正确:先平移到面板左上角,再按局部坐标裁
canvas.save();
canvas.translate(PANEL_X, PANEL_Y);
canvas.clip(0, 0, PANEL_W, PANEL_H);
canvas.drawText("标题", 32, 48, style);
canvas.restore();

// 错误:平移之后仍按世界坐标传参,裁到的位置会整体偏移
canvas.translate(PANEL_X, PANEL_Y);
canvas.clip(PANEL_X, PANEL_Y, PANEL_W, PANEL_H);

save() / restore() 必须成对,否则颜色、透明度和裁剪会泄漏给同一帧里后续绘制的内容——输入框、状态行都会跟着变样。

关于层:Scene 没有 RenderLayer 参数。压入一个场景就是整屏接管,所有层都属于你,不需要也不能选层。RenderLayer 只在游玩中叠加绘制时使用,经 RenderEvent.Post 过滤:HUD 在原版 HUD 之后,OVERLAY 在 altHud 与暂停对话框一带,FULLSCREEN 是最后的后处理。只想在游玩画面上加一块小面板,不要用 SceneHost,用 RenderEvent 更合适。

第 4 步:写入口类,注册快捷键并压栈

SceneHost 是进程级能力,在 onInit 里取一次即可;嵌入方可能不提供,所以必须判空。导航与查询方法的真实签名是:

方法返回值说明
void push(Scene scene)无压入并挂起当前场景;scene 或 id() 为 null 时静默忽略
boolean pop()有历史返回 true,栈底返回 false栈底不执行任何导航
void replace(Scene scene)无只换栈顶,深度不变
SceneInfo current()恒非 null当前场景信息
SceneInfo top()栈空时为 null栈顶场景,与 current() 同源
SceneInfo find(String sceneId)不在栈时为 null按声明 id 查是否在导航栈中

push / pop / replace 可以任意线程调用,执行体被投递到场景线程,受理不等于已经执行。入口类如下:

java
package com.example.mymod;

import com.seecmd.mod.api.GameplayMod;
import com.seecmd.mod.api.ModContext;
import com.seecmd.mod.api.ModInfo;
import com.seecmd.mod.api.capability.GlobalHotkey;
import com.seecmd.mod.api.capability.SceneHost;
import com.seecmd.mod.api.scene.SceneInfo;

import java.awt.event.KeyEvent;
import java.util.List;

public final class PracticePanelMod implements GameplayMod {

    private final PracticePanelScene panel = new PracticePanelScene();

    private SceneHost host;

    @Override
    public ModInfo info() {
        return ModInfo.of("mymod", "练习面板", "1.0.0", List.of("me"), "按快捷键打开自绘场景");
    }

    @Override
    public void onInit(ModContext ctx) {
        host = ctx.capabilities().get(SceneHost.class);
        if (host == null) {
            ctx.getLogger().warning("宿主没有提供 SceneHost,练习面板不可用");
            return;
        }
        ctx.hotkeys().add(new GlobalHotkey() {
            @Override
            public String id() {
                return "open_practice_panel";
            }

            @Override
            public String labelKey() {
                return "mymod.hotkey.practice";
            }

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

            @Override
            public void onHotkey() {
                toggle();
            }
        });
    }

    @Override
    public void onUnload() {
        host = null;
    }

    private void toggle() {
        if (host == null) {
            return;
        }
        SceneInfo top = host.top();
        if (top != null && PracticePanelScene.ID.equals(top.id())) {
            host.pop();
        } else {
            host.push(panel);
        }
    }
}

GlobalHotkey 的 id() 只需模组内唯一,宿主会拼成 mymod:open_practice_panel;labelKey() 是设置面板的译键;defaultKeyCode() 只是默认值,玩家改键后会持久化,模组不用自己保存。

第 5 步:打包进游戏,对照检查点

构建安装后,在主菜单或选曲界面按:

  • 出现一整屏深色面板,标题、提示、输入框都在;
  • 输入框默认聚焦,能输入英文与中文,光标会闪;
  • 按 Enter,下面出现「已记录:…」;
  • 按 ESC 回到打开前的界面;
  • 再按 打开、再按 关闭,不会叠出第二层同样的场景。

不要在对局中按它:整屏场景会把游玩场景挤出栈,回退时宿主按注册表新建的是另一个游玩场景,本局状态不会保留。游玩中要叠界面,用第 3 步说的 RenderEvent。

到这里应该看到
  • 面板内容不被裁到面板外:把标题挪到面板边缘外,会被 clip 切掉。
  • 点输入框外部,光标消失;重新点输入框,光标回来。
  • 中文输入法的候选窗贴着输入框底边,而不是窗口左下角。
  • 打开与关闭各触发一次 onEnter / onExit,重复开关不会留下上一轮的文字。
原理:场景栈、输入分发与相对坐标

场景栈:压入是挂起,不是销毁。 push 把当前场景名写进导航历史,再切换到你的场景;pop 从历史取回上一个场景。你的 Scene 对象本身留在宿主的注册表里,下次打开还是同一个实例,所以 onEnter 必须把文本、焦点、计时器全部重置。replace 不写历史,只换栈顶,适合「列表 → 详情」这种同级替换;pop 在历史为空时返回 false 且什么都不做,不会把你弹回主菜单。

输入先给栈顶场景,再由场景决定给谁。 宿主只把事件送进当前场景的 handleInput,被压住的场景完全收不到输入。进入你的场景后,优先级由你定:先把 ESC 这类「退出语义」判掉,再把字符与编辑键交给聚焦的输入框。返回 true 只表示「这条事件我处理了」;宿主唯一在意的是 ESC——返回 false 它才执行 back(),返回 true 你就得自己 pop()。两条路径都走会退两层。

渲染用逻辑空间的相对坐标。 场景画布固定 1280×720,由宿主缩放到窗口;RenderCanvas 不暴露宽高,所以布局应写成「面板原点 + 偏移」的常量,而不是去读窗口像素。clip 又是当前变换下的局部坐标:先 translate 再 clip(0, 0, w, h) 裁的是面板自己的矩形;若仍传世界坐标,裁剪区就会整体偏移。这也是为什么整段绘制要用 save() / restore() 包住——裁剪、透明度和颜色都挂在同一份状态栈上。

常见坑
  • id() 返回 null:push 会静默忽略,表现为「按了快捷键什么都没发生」,且没有异常。
  • ESC 返回 false(交给宿主)又自己 pop():宿主会 back() 一次,你再弹一次,结果退了两层。二选一。
  • 每次打开都 new 一个新场景:同 id 会替换注册实例,旧实例上的 TextField 内容全部丢失。复用同一个实例,在 onEnter 里重置。
  • 在 render 里改状态:render 每帧都会被调用,且可能在异常时被跳过;计时与动画放 update,否则帧率一变速度就变。
  • save() 没有配对的 restore():裁剪会一直生效,同一帧里后续的输入框和状态行被一起切掉。
  • clip 传世界坐标:在 translate 之后裁剪区会偏移,看起来像「内容凭空消失」。
  • 忘记 ImeAnchor.clear():关闭场景后候选窗仍停在原来的位置,影响下一个界面。
  • 无条件把 TEXT_INPUT 交给输入框:没聚焦时也会吃字符,表现为「点击别处后打字仍然进了输入框」。
  • 在 onUnload 里逐个移除快捷键:宿主停用模组时会自动清理,手动移除是多余的;需要清的是自己持有的引用。
交付自查
  • 场景 id() 非空,且与 SceneHost.top().id() 的比较口径一致。
  • SceneHost 取自 ctx.capabilities().get(SceneHost.class),并做了判空。
  • ESC 只走一条退出路径:要么返回 false 交给宿主,要么自己 pop()。
  • 场景实例复用,onEnter 重置全部本局状态。
  • 输入框只在聚焦时接收字符与编辑键。
  • 聚焦的输入框调用了 render(..., true),或在自绘时手动 reportImeAnchor。
  • 每对 save() / restore() 与 clip 都配平。
  • 只引用了 com.seecmd.mod.api.*,没有出现 com.seecmd.core.*。

接下来