添加游戏模式
一个叫「镜像」的游戏模式——谱面左右翻转,按键判定跟着翻转;它会出现在选曲界面的模式列表里,可以和内置模式一样被选中,并在游玩画面上打一个水印。
游戏已经用同一套流程跑着三个模式:普通(seecmd:normal)、自动播放(seecmd:autoplay)、无序(seecmd:disorder)。宿主不再为每种规则写 if/else,而是把当前局的控制权交给当前激活的模式对象。
所以如果你想要「左右翻转」这种规则变化,正确做法不是去改判定引擎,而是回答宿主一个问题:这一局该按哪套规则解释按键和画面。
这样做有三个直接好处:
- 三种规则可以共存,玩家在选曲时逐曲切换,不用装两套游戏;
- 规则只在本局、本谱面生效,不影响别人的成绩,也不改变宿主统一的算分公式;
- 想关掉时把模式切走即可,不需要卸载模组,更不会在判定逻辑里留下永久分支。
- 游戏模式(
GameModeDefinition):一组回调。宿主在进入模式、每帧更新、输入、渲染、退出时调用它。 - 模式上下文(
GameModeContext):回调里拿到的只读快照,只有musicTimeMs()、isPaused()和通常无需关心的services()。 - 轨道映射(
mapLane):把玩家按下的物理轨道换算成要参与判定的谱面轨道。 - 场地镜像(
mirrorsPlayfield):告诉宿主「这一帧把谱面元素左右翻转着画」。它只负责画面,不参与判定。
一局游戏里,模式在哪些时刻被调用
具体到宿主源码,顺序是这样的:
- 选曲界面的信息卡把所选模式拼进
modeId参数,进入谱面时宿主用它在gameModes()注册表里查找模式实例,找不到就回退到设置里的普通 / 自动播放; - 查到后先调用旧模式的
onExit,再调用新模式的onEnter; - 每个常规游玩更新帧,宿主先调用
onUpdate,再处理输入;输入里凡是「按键 → 轨道」的换算,都会经过mapLane; - 每帧渲染前读取一次
mirrorsPlayfield,把镜像开关同步给轨道渲染器; - 原版 HUD 画完后调用
renderOverlay,最后才是全屏特效与覆盖层; - 离开玩法或切换模式时调用
onExit。
注册表在启动期冻结后不再变化,注册进去的就是运行中的那个实例,跨局复用。所以任何「这一局才有效」的字段都必须在 onEnter 里重置,不能指望构造器每局跑一次。
前置条件
- 已经能构建并加载一个最小模组(见 第一个模组)。
- 工程已引入
mod-api的 jar,build.bat里的MOD_ID与入口类全限定名可用。
开始动手
在 src/main/java/com/example/mirrormod/ 下新建 MirrorModeMod.java,先只写模式本身。
GameModeDefinition 只有 displayName() 是抽象方法,其余都有默认空实现。本篇会用到的真实签名如下:
| 方法 | 什么时候被调用 |
|---|---|
String displayName() | 界面展示模式名,实现必须恒定返回同一字符串 |
default void onEnter(GameModeContext ctx) | 模式成为当前激活模式时调用一次 |
default int mapLane(int lane, int laneCount) | 按键转轨道时调用;lane 从 0 起,laneCount 是本谱面轨道数 |
default boolean mirrorsPlayfield() | 每帧渲染前读取;返回 true 表示左右镜像画谱面 |
default void onExit(GameModeContext ctx) | 模式不再激活时调用 |
default void renderOverlay(RenderCanvas canvas, GameModeContext ctx) | 模式激活时每帧调用,层序在原版 HUD 之上、全屏覆盖之下 |
先写一个只会翻转轨道的版本:
private static final class MirrorMode implements GameModeDefinition {
private final Logger logger;
MirrorMode(Logger logger) {
this.logger = logger;
}
@Override
public String displayName() {
return "镜像";
}
@Override
public int mapLane(int lane, int laneCount) {
return laneCount <= 1 ? lane : laneCount - 1 - lane;
}
@Override
public boolean mirrorsPlayfield() {
return true;
}
@Override
public void onEnter(GameModeContext ctx) {
logger.info("镜像模式进入 @" + ctx.musicTimeMs() + "ms");
}
@Override
public void onExit(GameModeContext ctx) {
logger.info("镜像模式退出 @" + ctx.musicTimeMs() + "ms");
}
}laneCount <= 1 的守卫不是防御性代码,而是单轨谱面的正确结果:laneCount - 1 - lane 在只有一条轨道时仍然是 0,但写清楚可以避免以后误改。
注册必须写在 onPreInit,这是注册表唯一可写的窗口。命名空间必须等于模组 id:
private static final String MOD_ID = "mirror_mode";
private static final RegistryKey<GameModeDefinition> MIRROR_MODE =
RegistryKey.of(MOD_ID, "mirror", GameModeDefinition.class);
@Override
public void onPreInit(ModContext ctx) {
ctx.registries().gameModes().register(MIRROR_MODE, new MirrorMode(ctx.getLogger()));
ctx.getLogger().info("已注册游戏模式 " + MIRROR_MODE);
}注册键 mirror_mode:mirror 就是这个模式的 id,之后选曲界面、requestMode 都用它。RegistryKey 的构造器是私有的,只能用 RegistryKey.of(...);写成 seecmd:mirror 会被宿主拒绝,因为模组注册视图会校验命名空间。
重新构建并启动,日志里应当出现一行 已注册游戏模式 mirror_mode:mirror。如果看到 IllegalArgumentException,先检查命名空间;如果看到 RegistryFrozenException,说明注册写晚了。
不需要额外写 UI。选曲列表里打开一张谱面的详情信息卡,右侧有三行:模式 / 继续 / 退出。第一行显示当前模式,激活它就会循环切换。
这个循环列表直接来自 gameModes() 注册表:内置的 seecmd:normal、seecmd:autoplay、seecmd:disorder 在前,模组注册的模式按注册顺序排在后面,所以你的「镜像」会自动出现在其中。选到「镜像」后按「继续」进入谱面,modeId 会随场景参数传进去。
如果已经打开了谱面详情卡,切到「镜像」再继续,就是本教程要验证的路径。
运行时也可以切换。模组级上下文可以在 onInit 里存下来,游玩中从能力注册表取 GameStateAccess,调用 requestMode:
import com.seecmd.mod.api.capability.GameStateAccess;
// 入口类里保存 onInit 的上下文(模组级上下文可以长期持有)
private ModContext modContext;
@Override
public void onInit(ModContext ctx) {
this.modContext = ctx;
}
// 需要切换时(例如你自己的全局快捷键回调里)
GameStateAccess state = modContext.capabilities().get(GameStateAccess.class);
if (state != null && !state.requestMode("mirror_mode:mirror")) {
modContext.getLogger().warn("没有找到模式 mirror_mode:mirror");
}两个注意点:get(...) 只在游玩场景里才有值,必须判空;requestMode 接受完整 id,写 "mirror" 会被解析成 seecmd:mirror 而找不到。切换只影响当前这一局,重试谱面仍按选曲面板当初选的模式。
- 模组列表里的状态是
ACTIVE,日志有注册成功那一行。 - 谱面详情信息卡的模式行能循环到「镜像」。
- 进入谱面后,谱面元素相对原谱左右对调,按键指示器也跟着对调。
- 按原本最左轨的键,命中的是谱面最右轨的音符;反方向同理(说明
mapLane真的在起作用)。 - 切回「按设置默认」再进一次,谱面恢复正常,没有残留。
因为「模式」不是谱面的一部分。宿主在输入层拿物理轨道问模式要判定轨道,在渲染层另问一次要不要镜像;音符自己的轨道号从头到尾没变。
这样做换来三个性质:
- 可以中途切换:
requestMode切换后下一帧就按新规则解释,不需要重新布局音符、不需要重载谱面; - 不破坏时间轴:HOLD 的起点终点、Barrier 的跨轨范围、Payload 的自动判定都靠稳定的轨道号关联,搬动坐标会让这些跨时间的对象失去锚点;
- 可逆:模式切走,映射恢复恒等,画面和判定同时回到原样,没有需要回滚的数据。
反过来,如果模式去改谱面数据,它就得处理已判定音符、倒带重演、连击时间线等一大堆状态,这是宿主判定引擎的职责,不该由规则模组承担。
renderOverlay 拿到的 RenderCanvas 是模组唯一的绘制入口,坐标系与 HUD 覆盖层一致:逻辑画布 1280 × 720,原点在左上角。
加一行居中的文字水印:
@Override
public void renderOverlay(RenderCanvas canvas, GameModeContext ctx) {
if (ctx.isPaused()) {
return;
}
canvas.save();
canvas.setAlpha(0.6);
canvas.drawText("MIRROR", 640, 24,
TextStyle.of(TextStyle.FontType.BOLD, 14, new Color(200, 220, 255),
TextStyle.TextAlignment.CENTER));
canvas.restore();
}TextStyle.of(字体, 字号, 颜色, 对齐) 里第四个参数决定 x 是左缘、中心还是右缘,所以这里的 640 是画布水平中心。
重新构建进游戏,水印只会在「镜像」模式激活时出现;切回普通模式立刻消失。save() / restore() 必须成对,否则你设置的透明度会泄漏给同一帧后面的绘制。
只有当你想改变「哪个键对应哪条轨道」时才需要这一步;镜像本身不需要。
把入口类从 Mod 改成 GameplayMod(它继承 Mod,已有代码不用动),并覆写 createLaneInputMapper():
@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) {
int lane = switch (keyCode) {
case KeyEvent.VK_D -> 0;
case KeyEvent.VK_F -> 1;
case KeyEvent.VK_J -> 2;
case KeyEvent.VK_K -> 3;
default -> -1;
};
return lane < laneCount ? lane : -1;
}
@Override
public Set<Integer> triggerKeys() {
return Set.of(KeyEvent.VK_D, KeyEvent.VK_F, KeyEvent.VK_J, KeyEvent.VK_K);
}
};
}要点:init(int laneCount) 在谱面加载、轨道数已知时调用一次;triggerKeys() 必须列出你管辖的物理键,否则宿主不会轮询它们;返回 -1 表示这个键不触发任何轨道。别忘了补上 GameplayMod、LaneInputMapper、java.awt.event.KeyEvent 和 java.util.Set 的 import。
不要在映射器里再做一次镜像。宿主会把你返回的每条逻辑轨道逐条交给当前模式的 mapLane,映射器负责「键 → 逻辑轨」,模式负责「逻辑轨 → 判定轨」,两层职责不重叠。
GameModeDefinition 里没有任何判定或计分入口。2.0 已经删掉了旧的 overridesJudgment(),判定与计分由宿主的判定引擎和分数管理统一完成;模式能做的只有两件事:消费输入(onHandleInput 返回 true),或者改变按键落到哪条轨道(mapLane)。
这意味着一局游戏的分数仍然按同一套公式结算。镜像模式让你「按反过来的位置打」,打得好不好还是由判定窗口和连击决定,不会因为装了模组就换成另一套算分规则。
如果你真的想改判定结果,那是另一个扩展点的事:判定等级、判定策略、判定调整器各有自己的注册表,见 添加判定等级。
- 解引用
onHandleInput的event:宿主当前是轮询式输入,这个参数恒为null。更麻烦的是该调用点没有 try/catch,异常会直接中断本帧输入处理。需要按键状态请用InputAccess能力。 - 让
mirrorsPlayfield()抛异常:它在渲染路径每帧被直接读取,同样没有 try/catch,异常会中断本帧渲染。这个方法应当只返回一个布尔值。 - 只实现
mapLane或只实现mirrorsPlayfield:只映射会让「画面上没翻、判定翻了」,只镜像会让「画面翻了、按键没翻」。镜像类模式必须两个一起实现。 - 在模式对象里存每局状态却不重置:注册进去的实例跨局复用。计数、开始时间、缓存都应在
onEnter清零或重建,在onExit释放。 - 注册写在
onInit:注册表已冻结,必抛RegistryFrozenException;命名空间写成别的模组 id 则抛IllegalArgumentException,而且归属校验发生在冻结检查之前。 - 想用自动播放验证镜像:
autoplay也是一个模式,和你的模式互斥。选中「镜像」后自动播放不会生效,必须自己打;另外运行时requestMode切过去的模式不会改变「重试」指向,重试仍用面板当初的选择。
- 注册发生在
onPreInit,注册键命名空间等于模组 id(mirror_mode:mirror)。 displayName()返回稳定字符串,选曲界面能看到它。mapLane与mirrorsPlayfield同时实现,且laneCount <= 1时不出错。renderOverlay里的save()/restore()成对,暂停时不会画水印。- 模式切换后没有残留状态,重开一局行为一致。
- 日志里
onEnter与onExit成对出现,没有异常堆栈。
完整代码
package com.example.mirrormod;
import com.seecmd.mod.api.Mod;
import com.seecmd.mod.api.ModContext;
import com.seecmd.mod.api.ModInfo;
import com.seecmd.mod.api.registry.RegistryKey;
import com.seecmd.mod.api.render.RenderCanvas;
import com.seecmd.mod.api.render.TextStyle;
import com.seecmd.mod.api.spi.GameModeContext;
import com.seecmd.mod.api.spi.GameModeDefinition;
import java.awt.Color;
import java.util.List;
import java.util.logging.Logger;
public final class MirrorModeMod implements Mod {
/** 必须与 build.bat 的 MOD_ID、manifest 的 Seecmd-Mod-Id 一致。 */
private static final String MOD_ID = "mirror_mode";
/** 注册键的路径部分即模式 id 的后半段:mirror_mode:mirror。 */
private static final RegistryKey<GameModeDefinition> MIRROR_MODE =
RegistryKey.of(MOD_ID, "mirror", GameModeDefinition.class);
@Override
public ModInfo info() {
return ModInfo.of(
MOD_ID,
"镜像模式",
"1.0.0",
List.of("YourName"),
"把谱面左右镜像的游戏模式");
}
@Override
public void onPreInit(ModContext ctx) {
ctx.registries().gameModes().register(MIRROR_MODE, new MirrorMode(ctx.getLogger()));
ctx.getLogger().info("已注册游戏模式 " + MIRROR_MODE);
}
/** 模式实例进程级复用:若加了每局状态,记得在 onEnter 重置。 */
private static final class MirrorMode implements GameModeDefinition {
private final Logger logger;
MirrorMode(Logger logger) {
this.logger = logger;
}
@Override
public String displayName() {
return "镜像";
}
@Override
public void onEnter(GameModeContext ctx) {
logger.info("镜像模式进入 @" + ctx.musicTimeMs() + "ms");
}
@Override
public int mapLane(int lane, int laneCount) {
if (laneCount <= 1) {
return lane;
}
return laneCount - 1 - lane;
}
@Override
public boolean mirrorsPlayfield() {
return true;
}
@Override
public void onExit(GameModeContext ctx) {
logger.info("镜像模式退出 @" + ctx.musicTimeMs() + "ms");
}
@Override
public void renderOverlay(RenderCanvas canvas, GameModeContext ctx) {
if (ctx.isPaused()) {
return;
}
canvas.save();
canvas.setAlpha(0.6);
canvas.drawText("MIRROR", 640, 24,
TextStyle.of(TextStyle.FontType.BOLD, 14, new Color(200, 220, 255),
TextStyle.TextAlignment.CENTER));
canvas.restore();
}
}
}本模组只注册一个模式,没有接管 HUD,也没有接管按键映射,所以实现 Mod 就够了。GameplayMod 是 Mod 的子接口,只在需要覆写 createGameplayHud() 或 createLaneInputMapper() 时才需要。之后想加自定义键位,把 implements Mod 改成 implements GameplayMod 即可,info() 和 onPreInit 都不用动。
接下来
- 想知道每个钩子在宿主里的精确时机与异常隔离:GameModeDefinition 与 GameModeContext。
- 想改键位而不改模式规则:LaneInputMapper。
- 想让模式自带一套界面或判定反馈:自定义 HUD。
- 想从游玩事件里做统计或联动:事件订阅。