Theseus 模组文档
教程

添加游戏模式

一个叫「镜像」的游戏模式——谱面左右翻转,按键判定跟着翻转;它会出现在选曲界面的模式列表里,可以和内置模式一样被选中,并在游玩画面上打一个水印。


为什么「改规则」要做成模式

游戏已经用同一套流程跑着三个模式:普通(seecmd:normal)、自动播放(seecmd:autoplay)、无序(seecmd:disorder)。宿主不再为每种规则写 if/else,而是把当前局的控制权交给当前激活的模式对象。

所以如果你想要「左右翻转」这种规则变化,正确做法不是去改判定引擎,而是回答宿主一个问题:这一局该按哪套规则解释按键和画面。

这样做有三个直接好处:

  • 三种规则可以共存,玩家在选曲时逐曲切换,不用装两套游戏;
  • 规则只在本局、本谱面生效,不影响别人的成绩,也不改变宿主统一的算分公式;
  • 想关掉时把模式切走即可,不需要卸载模组,更不会在判定逻辑里留下永久分支。
先认识四个词
  • 游戏模式(GameModeDefinition):一组回调。宿主在进入模式、每帧更新、输入、渲染、退出时调用它。
  • 模式上下文(GameModeContext):回调里拿到的只读快照,只有 musicTimeMs()、isPaused() 和通常无需关心的 services()。
  • 轨道映射(mapLane):把玩家按下的物理轨道换算成要参与判定的谱面轨道。
  • 场地镜像(mirrorsPlayfield):告诉宿主「这一帧把谱面元素左右翻转着画」。它只负责画面,不参与判定。

一局游戏里,模式在哪些时刻被调用

选模式 信息卡写入 modeId 加载谱面 得到轨道数 模式声明规则 mapLane / mirrorsPlayfield 游玩中逐帧解释 输入判定 + 镜像渲染 结算 宿主统一算分 模式不复制、也不搬动谱面:它只提供两个纯函数——「这个物理轨道算哪条轨」和「要不要镜像画」。 谱面数据与判定结果始终归宿主所有,所以中途切换模式不需要重载谱面,也不会破坏已经判过的音符。
模式是一组「解释规则」,不是一份被改写过的谱面

具体到宿主源码,顺序是这样的:

  1. 选曲界面的信息卡把所选模式拼进 modeId 参数,进入谱面时宿主用它在 gameModes() 注册表里查找模式实例,找不到就回退到设置里的普通 / 自动播放;
  2. 查到后先调用旧模式的 onExit,再调用新模式的 onEnter;
  3. 每个常规游玩更新帧,宿主先调用 onUpdate,再处理输入;输入里凡是「按键 → 轨道」的换算,都会经过 mapLane;
  4. 每帧渲染前读取一次 mirrorsPlayfield,把镜像开关同步给轨道渲染器;
  5. 原版 HUD 画完后调用 renderOverlay,最后才是全屏特效与覆盖层;
  6. 离开玩法或切换模式时调用 onExit。
模式对象是进程级的,不是每局新建的

注册表在启动期冻结后不再变化,注册进去的就是运行中的那个实例,跨局复用。所以任何「这一局才有效」的字段都必须在 onEnter 里重置,不能指望构造器每局跑一次。

前置条件

开始动手

写模式类:实现 GameModeDefinition

在 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 之上、全屏覆盖之下

先写一个只会翻转轨道的版本:

java
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 里注册

注册必须写在 onPreInit,这是注册表唯一可写的窗口。命名空间必须等于模组 id:

java
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:

java
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 真的在起作用)。
  • 切回「按设置默认」再进一次,谱面恢复正常,没有残留。
为什么 mapLane 只做映射,不搬动音符坐标

因为「模式」不是谱面的一部分。宿主在输入层拿物理轨道问模式要判定轨道,在渲染层另问一次要不要镜像;音符自己的轨道号从头到尾没变。

这样做换来三个性质:

  • 可以中途切换:requestMode 切换后下一帧就按新规则解释,不需要重新布局音符、不需要重载谱面;
  • 不破坏时间轴:HOLD 的起点终点、Barrier 的跨轨范围、Payload 的自动判定都靠稳定的轨道号关联,搬动坐标会让这些跨时间的对象失去锚点;
  • 可逆:模式切走,映射恢复恒等,画面和判定同时回到原样,没有需要回滚的数据。

反过来,如果模式去改谱面数据,它就得处理已判定音符、倒带重演、连击时间线等一大堆状态,这是宿主判定引擎的职责,不该由规则模组承担。

给模式加一个水印

renderOverlay 拿到的 RenderCanvas 是模组唯一的绘制入口,坐标系与 HUD 覆盖层一致:逻辑画布 1280 × 720,原点在左上角。

加一行居中的文字水印:

java
@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() 必须成对,否则你设置的透明度会泄漏给同一帧后面的绘制。

(可选)配合 LaneInputMapper 自定义键位

只有当你想改变「哪个键对应哪条轨道」时才需要这一步;镜像本身不需要。

把入口类从 Mod 改成 GameplayMod(它继承 Mod,已有代码不用动),并覆写 createLaneInputMapper():

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) {
            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 成对出现,没有异常堆栈。

完整代码

java
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();
        }
    }
}
为什么入口类用 Mod 而不是 GameplayMod

本模组只注册一个模式,没有接管 HUD,也没有接管按键映射,所以实现 Mod 就够了。GameplayMod 是 Mod 的子接口,只在需要覆写 createGameplayHud() 或 createLaneInputMapper() 时才需要。之后想加自定义键位,把 implements Mod 改成 implements GameplayMod 即可,info() 和 onPreInit 都不用动。

接下来