自定义场景
一个模组,按快捷键(默认 F7,玩家可在设置里改)打开一块自绘的「练习面板」——标题、提示、一个真正能打字的输入框;按 ESC 或再按一次快捷键,回到打开前的界面。
HUD 只能叠在游玩画面上,设置项只能塞进设置页。想在游戏里做一整页自己的界面(图鉴、练习面板、联机房间列表),需要向宿主借一个场景。
场景不是一张贴图,而是一段生命周期:宿主在进入时叫你、每帧叫你更新和绘制、把输入逐条交给你、离开时叫你收尾。画面归你,导航和系统输入法归宿主——分工清楚,所以你不必碰窗口、焦点和候选窗位置。
- 场景(
Scene):一整屏界面。实现Scene,只有id()是必须写的,其余回调都有默认空实现。 - 场景栈(scene stack):宿主维护的界面栈。
push把新场景压上去,pop回到上一个,replace只换掉栈顶、深度不变。 - 输入事件(
InputEvent):宿主把键盘、文本、鼠标统一成一种事件,交给Scene.handleInput。 - 输入法锚点(
ImeAnchor):告诉宿主「输入光标在这」。中文候选窗是系统窗口,没有锚点就只会停在画布角落。
运行流程
具体到宿主源码,一帧的顺序是:
GlobalHotkey.onHotkey()在游戏线程触发,你调用SceneHost.push;- 宿主先把导航动作投递到场景线程,当前场景
onExit,再为你的场景建适配器并调用onEnter; - 每帧先调用你的
update(dt),紧接着在同一帧里分发本帧按键、文本与鼠标事件; - 然后调用
render(canvas)画整屏; - 你
pop()或 ESC 未被消费时宿主back(),你的onExit被调用。
前置条件
- 已经能构建并加载一个最小模组(见 第一个模组)。
- 工程已引入
mod-api-3.2.jar与 gson。
本篇只用到 Scene、SceneHost、SceneInfo、InputEvent、InputType、TextField、ImeAnchor、RenderCanvas、GlobalHotkey。每处出现都会解释。
开始动手
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 里重置。下面是完整的场景类:
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);否则中文候选窗会固定在窗口角落,打字时看不见选词。
宿主对模组场景只派发五种事件:KEY_PRESSED、TEXT_INPUT,以及 MOUSE_PRESSED / MOUSE_DRAGGED / MOUSE_RELEASED。不会派发 KEY_RELEASED 与 KEY_HELD,长按要自己用 InputAccess 轮询。
分派顺序建议固定为「全局键 → 字符 → 编辑键」:
// 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 它也不会消费,所以这三个键必须由场景自己决定。
场景画布是一块固定的 1280×720 逻辑空间,左上角为原点、y 向下;窗口缩放只改变显示尺寸,不改这里的坐标。RenderCanvas 本身没有 width() / height(),布局常量就按 1280×720 写。
裁剪要记住一条:clip(x, y, w, h) 用的是当前变换下的局部坐标,并且与已有裁剪区求交。下面两种写法结果不同:
// 正确:先平移到面板左上角,再按局部坐标裁
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 更合适。
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 可以任意线程调用,执行体被投递到场景线程,受理不等于已经执行。入口类如下:
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() 只是默认值,玩家改键后会持久化,模组不用自己保存。
构建安装后,在主菜单或选曲界面按:
- 出现一整屏深色面板,标题、提示、输入框都在;
- 输入框默认聚焦,能输入英文与中文,光标会闪;
- 按 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.*。
接下来
- 想把同一个场景挂到模组菜单:实现
createConfigScene()返回它即可,见 Mod。 - 想在游玩画面上叠一层而不离开游戏:看 RenderLayer 与 RenderEvent。
- 想读 / 改游玩时间、暂停与轨道几何:看 GameStateAccess。
- 想做出多级界面(列表进详情再返回):把
push与pop组合起来,并用SceneHost.find判断自己是否还在栈里。