Theseus 模组文档
教程

添加判定等级

在 PERFECT 之上新增一个只在 30ms 内触发的 ULTRA 等级,让 HUD 连击、日志、结算统计和判定文字都认这一档。


为什么要自己加一档

默认判定只有 PERFECT / GREAT / MISS(外加内部使用的 NONE)。如果你想做街机风的最严评级、只奖励“几乎零误差”的击打,或者让某一档不涨连击——改打击特效只能换表现,改不了“这一击算哪一档、给多少分、涨不涨连击”。

判定等级就是这个“档位”本身:分数权重、准度权重、颜色、连击行为都挂在同一个注册项上。宿主在计分、连击、HUD 文字、结算统计和判定特效里读的是同一份定义,所以注册一次,整条链路都会认它。只想要不同的文字或颜色、不改变判定与计分时,应该去做 自定义打击特效,不必新增等级。

先认识四个词
  • 判定等级:注册表里的一个 JudgmentKindDefinition,决定一档判定的显示名、分数权重、准度权重、颜色和连击行为。
  • 判定窗口:判定时刻与音符时刻之间允许的偏差,单位是毫秒。内置默认是 PERFECT 70ms、GREAT 150ms。
  • 判定策略:JudgmentPolicy,一次性给出五个窗口值(PERFECT / GREAT / MISS / 长音符结尾 / 迟到截断)。
  • 判定调整器:JudgmentAdjuster,拿到已经产生的判定结果,在计分之前把它改写成另一档。

前置条件

这篇的边界

JudgmentPolicy 只有五个固定的窗口字段,没有“ULTRA 窗口”这种扩展位。所以新增一档不是“往策略里加一个字段”,而是:注册一个等级定义,再用调整器把落在更严阈值里的 PERFECT 改写过去。窗口策略仍然要注册,它决定 PERFECT 本身有多宽。

判定是怎么走到 ULTRA 的

音符到达 判定线 算时间差 判定 − 音符 查窗口 PERFECT ≤ 45ms 得到基础判定 PERFECT / GREAT 调整器改写 ≤ 30ms → ULTRA 结算 连击 · 分数 · 特效 虚线框是你要写的部分:注册等级只是“备好一档”,真正让 ULTRA 出现的是调整器。 窗口策略决定 PERFECT 的上限;调整器在这之上再加一条更严的 30ms 线。
六个环节都在宿主的判定链路里;你在第 5 步插入一段改写规则

开始动手

定义 ULTRA 判定等级

先写等级定义。JudgmentKindDefinition 有 5 个必须实现的方法,另外两个方法(isMissKind()、render(...))有默认实现:

java
private static final class UltraKind implements JudgmentKindDefinition {

    private static final Color COLOR = new Color(255, 215, 0);

    @Override public String displayName() { return "ULTRA"; }
    @Override public int scoreValue() { return 100; }
    @Override public double accuracyWeight() { return 1.0; }
    @Override public Color color() { return COLOR; }
    @Override public ComboBehavior comboBehavior() { return ComboBehavior.INCREMENT; }

    @Override
    public void render(RenderCanvas canvas, JudgedNoteRenderContext ctx) {
        canvas.save();
        canvas.drawText("ULTRA!", ctx.x(), ctx.y(), TextStyle.of(18, COLOR));
        canvas.restore();
    }
}

每个字段的实际作用:

  • displayName():HUD 连击行里的等级文字(实际显示形如 1 ULTRA)、日志行、结算动态行的标签。它只用于显示,不参与判定匹配。
  • scoreValue():相对权重,PERFECT = 100 为基准。这里用 100,让 ULTRA 的单音符基础分与 PERFECT 相同;填 105 会让基础分超过基准,而 ScoreManager 是直接按 Σ scoreValue / 100 累加的,不会替你截断。
  • accuracyWeight():0~1 的准度权重。自定义等级的血量处理读它:>= 1.0 回血 +2,<= 0 或连击行为为 RESET 时扣血 -50。
  • color():HUD 连击文字与判定文字的颜色。
  • comboBehavior():INCREMENT 涨连击;RESET 归零、按 MISS 语义处理;NONE 不变。详见 ComboBehavior。
  • render(...):自定义等级不会自动获得内置的文字特效。不覆写这个方法,ULTRA 命中时就什么都不画。

定义本身的完整约束见 JudgmentKindDefinition。

在 onPreInit 注册等级

注册只发生在 onPreInit。顺手把内置 PERFECT 的实例取出来,第 4 步做身份比较时要用:

java
private static final String MOD_ID = "ultra_judge";

private static final ResourceLocation PERFECT_ID =
        ResourceLocation.of("seecmd", "perfect");
private static final RegistryKey<JudgmentKindDefinition> ULTRA_KEY =
        RegistryKey.of(MOD_ID, "ultra", JudgmentKindDefinition.class);

private JudgmentKindDefinition builtinPerfect;
private JudgmentKindDefinition ultra;

@Override
public void onPreInit(ModContext ctx) {
    Registry<JudgmentKindDefinition> kinds = ctx.registries().judgmentKinds();
    this.ultra = new UltraKind();
    kinds.register(ULTRA_KEY, this.ultra);
    this.builtinPerfect = kinds.get(PERFECT_ID).orElse(null);
}

三件事必须同时成立:

  • RegistryKey 和 ResourceLocation 的构造器都是私有的,只能走 of(namespace, path, Type.class) / of(namespace, path)。
  • 键的命名空间必须等于模组 id(这里是 ultra_judge)。宿主给每个模组返回的是带归属校验的注册视图,命名空间不匹配会在注册期直接抛 IllegalArgumentException,不会静默接受。
  • 宿主的 seecmd:perfect / great / miss / none 在模组之前就已注册,所以在 onPreInit 里 get(PERFECT_ID) 能拿到实例;onPreInit 全部结束后注册表冻结,之后写入抛 RegistryFrozenException。
注册判定窗口策略

先给 PERFECT 本身定一条更严的线。五个值全部是毫秒:

java
private static final class StrictPolicy implements JudgmentPolicy {
    @Override public long perfectWindowMs() { return 45L; }
    @Override public long greatWindowMs() { return 120L; }
    @Override public long missWindowMs() { return 120L; }
    @Override public long holdEndWindowMs() { return 120L; }
    @Override public long lateCutoffMs() { return 50L; }
}
java
// 仍然在 onPreInit 内
ctx.registries().judgmentPolicies().register(
        RegistryKey.of(MOD_ID, "strict", JudgmentPolicy.class),
        new StrictPolicy());

这五个值在判定引擎里的落点:

  • perfectWindowMs():绝对时间差不超过它 → PERFECT。
  • greatWindowMs():超过 PERFECT 但不超过它 → GREAT。
  • missWindowMs():按键匹配的上限;迟到超过 音符时间 + missWindowMs 仍未判定,宿主自动补 MISS。长音符结尾的宽限也用它。
  • holdEndWindowMs():长音符结尾窗口的声明项。当前引擎的 HOLD 结尾补判走的是 missWindowMs(),不要指望只改这个字段就能改变结尾手感,保持一致即可。
  • lateCutoffMs():只在无序模式里生效,表示越过判定线后还能补判多久。

宿主合并多份策略时是逐字段取最大,并先排除内置默认策略。只装你这一个策略模组时,45/120/120 会直接生效;如果玩家还装了更宽松的策略模组,逐字段最大值会盖掉你的窄值。这是“最宽松生效”,不是你的注册失败。确实需要无条件收紧时,宿主在合并之后还有 judgmentWindowSynthesizers() 链;本篇不需要走到那一步,因为 ULTRA 的 30ms 阈值在调整器里,和窗口竞争无关。

让 ULTRA 真的出现:注册判定调整器

到这一步为止,ULTRA 只是注册表里“备好的一档”,没有任何路径会产出它。把 30ms 内的 PERFECT 改写过去:

java
private static final long ULTRA_WINDOW_MS = 30L;

// 仍然在 onPreInit 内
ctx.registries().judgmentAdjusters().register(
        RegistryKey.of(MOD_ID, "promote", JudgmentAdjuster.class),
        this::promoteToUltra);

private JudgeResult promoteToUltra(JudgeResult input, GameplayContext ctx) {
    if (input == null || ultra == null || builtinPerfect == null) {
        return input;
    }
    if (input.isHoldEnd()) {
        return input;
    }
    if (input.judgment() != builtinPerfect) {
        return input;
    }
    NoteView note = input.note();
    if (note == null) {
        return input;
    }
    long offsetMs = Math.abs(input.judgeTime() - note.time());
    if (offsetMs > ULTRA_WINDOW_MS) {
        return input;
    }
    return JudgeResults.withJudgment(input, ultra);
}

为什么这么写:

  • 链在计分之前。 调整器按注册顺序链式执行,前一个的输出是后一个的输入;链跑完才计分、涨连击、生成特效、投递判定事件。
  • 用实例身份比较,不用 displayName()。 input.judgment() 是注册表里的内置 PERFECT 实例,builtinPerfect 是同一个实例,所以 != 是可靠的。displayName() 只是给人看的文本,宿主从不拿它做匹配。
  • 跳过 HOLD 终点。 input.note().time() 是长音符的起点时间,而终点判定的 judgeTime() 在结尾附近,算出来的差值没有意义。显式跳过比依赖“差值必然超标”更清楚。
  • 不适用时原样返回 input,不要返回 null。 返回 null 会被宿主跳过并限频告警。
  • 保持纯函数。 只用 input 里的判定时刻、音符时刻和 ctx,不读系统时钟、不用随机数。倒带重演会逐帧重跑这条链,任何跨帧可变状态都会让两次结果不一致。
  • 这里不改 judgeTime(),所以不会触发准度分布重录;只换判定等级。
构建、进游戏、对照检查点

在 build.bat 里确认两个值:

bat
set "MOD_ID=ultra_judge"
set "MOD_CLASS=com.example.ultrajudge.UltraJudgeMod"

构建后把 jar 放进 mods\,重启游戏。先看日志里有没有这一行:

text
[ultra_judge] ultra=true builtinPerfect=true

两个都是 true 才说明等级注册成功、并且取到了内置 PERFECT 实例。只装本篇模组时,进一局对照检查:

  • 击打误差在 30ms 以内(提前或延后):HUD 连击行显示 1 ULTRA,日志出现 ULTRA,判定线上方出现金色的 ULTRA!。
  • 误差 31~45ms:仍然是 PERFECT。
  • 误差 46~120ms:GREAT。
  • 偏差超过 120ms:按键不再命中该音符;迟到超过 120ms 由宿主自动补 MISS。
  • 长音符结尾:不会变成 ULTRA。
  • 结算界面会多出一条 ULTRA 的模组判定动态行。
可选:只在特定条件下提升

想让 ULTRA 成为“高连击后的奖励档”,把条件加在阈值判断之后即可:

java
private JudgeResult promoteToUltra(JudgeResult input, GameplayContext ctx) {
    // ... 前面的空值与 HOLD 终点判断保持不变
    long offsetMs = Math.abs(input.judgeTime() - input.note().time());
    if (offsetMs > ULTRA_WINDOW_MS) {
        return input;
    }
    if (ctx.combo() < 50) {
        return input;
    }
    return JudgeResults.withJudgment(input, ultra);
}

ctx 是计分前的只读快照,所以 combo() 是这一击结算前的连击数。它仍然属于“入参”,倒带重演时会被宿主重建成同一状态;不要在调整器里维护自己的计数器,也不要读墙钟或随机数。

完整代码

java
package com.example.ultrajudge;

import com.seecmd.mod.api.GameplayMod;
import com.seecmd.mod.api.ModContext;
import com.seecmd.mod.api.ModInfo;
import com.seecmd.mod.api.context.GameplayContext;
import com.seecmd.mod.api.context.NoteView;
import com.seecmd.mod.api.judge.JudgeResult;
import com.seecmd.mod.api.judge.JudgeResults;
import com.seecmd.mod.api.judge.JudgmentAdjuster;
import com.seecmd.mod.api.judge.JudgmentPolicy;
import com.seecmd.mod.api.registry.Registry;
import com.seecmd.mod.api.registry.RegistryKey;
import com.seecmd.mod.api.registry.ResourceLocation;
import com.seecmd.mod.api.render.RenderCanvas;
import com.seecmd.mod.api.render.TextStyle;
import com.seecmd.mod.api.spi.ComboBehavior;
import com.seecmd.mod.api.spi.JudgedNoteRenderContext;
import com.seecmd.mod.api.spi.JudgmentKindDefinition;

import java.awt.Color;
import java.util.List;

public final class UltraJudgeMod implements GameplayMod {

    private static final String MOD_ID = "ultra_judge";

    /** 内置 PERFECT 的资源标识(命名空间固定为 seecmd)。 */
    private static final ResourceLocation PERFECT_ID =
            ResourceLocation.of("seecmd", "perfect");

    /** ULTRA 的注册键:命名空间必须等于模组 id。 */
    private static final RegistryKey<JudgmentKindDefinition> ULTRA_KEY =
            RegistryKey.of(MOD_ID, "ultra", JudgmentKindDefinition.class);

    /** ULTRA 的判定阈值(毫秒):|判定时刻 - 音符时刻| <= 30。 */
    private static final long ULTRA_WINDOW_MS = 30L;

    private JudgmentKindDefinition builtinPerfect;
    private JudgmentKindDefinition ultra;

    @Override
    public ModInfo info() {
        return ModInfo.of(MOD_ID, "Ultra Judge", "1.0.0",
                List.of("you"), "在 PERFECT 之上新增 ULTRA 判定等级");
    }

    @Override
    public void onPreInit(ModContext ctx) {
        Registry<JudgmentKindDefinition> kinds = ctx.registries().judgmentKinds();
        this.ultra = new UltraKind();
        kinds.register(ULTRA_KEY, this.ultra);
        this.builtinPerfect = kinds.get(PERFECT_ID).orElse(null);

        ctx.registries().judgmentPolicies().register(
                RegistryKey.of(MOD_ID, "strict", JudgmentPolicy.class),
                new StrictPolicy());

        ctx.registries().judgmentAdjusters().register(
                RegistryKey.of(MOD_ID, "promote", JudgmentAdjuster.class),
                this::promoteToUltra);
    }

    @Override
    public void onInit(ModContext ctx) {
        boolean registered = ctx.registries().judgmentKinds()
                .get(ULTRA_KEY).isPresent();
        ctx.getLogger().info("ultra=" + registered
                + " builtinPerfect=" + (builtinPerfect != null));
    }

    /** 把 30ms 内的 PERFECT 改写成 ULTRA;其余情况原样返回。 */
    private JudgeResult promoteToUltra(JudgeResult input, GameplayContext ctx) {
        if (input == null || ultra == null || builtinPerfect == null) {
            return input;
        }
        if (input.isHoldEnd()) {
            return input;
        }
        if (input.judgment() != builtinPerfect) {
            return input;
        }
        NoteView note = input.note();
        if (note == null) {
            return input;
        }
        long offsetMs = Math.abs(input.judgeTime() - note.time());
        if (offsetMs > ULTRA_WINDOW_MS) {
            return input;
        }
        return JudgeResults.withJudgment(input, ultra);
    }

    /** ULTRA 判定等级定义。 */
    private static final class UltraKind implements JudgmentKindDefinition {

        private static final Color COLOR = new Color(255, 215, 0);

        @Override
        public String displayName() {
            return "ULTRA";
        }

        @Override
        public int scoreValue() {
            return 100;
        }

        @Override
        public double accuracyWeight() {
            return 1.0;
        }

        @Override
        public Color color() {
            return COLOR;
        }

        @Override
        public ComboBehavior comboBehavior() {
            return ComboBehavior.INCREMENT;
        }

        @Override
        public void render(RenderCanvas canvas, JudgedNoteRenderContext ctx) {
            canvas.save();
            canvas.drawText("ULTRA!", ctx.x(), ctx.y(), TextStyle.of(18, COLOR));
            canvas.restore();
        }
    }

    /** 收紧 PERFECT / GREAT 的窗口策略。 */
    private static final class StrictPolicy implements JudgmentPolicy {
        @Override public long perfectWindowMs() { return 45L; }
        @Override public long greatWindowMs() { return 120L; }
        @Override public long missWindowMs() { return 120L; }
        @Override public long holdEndWindowMs() { return 120L; }
        @Override public long lateCutoffMs() { return 50L; }
    }
}
三个设计决定

为什么窗口用毫秒而不是帧。 判定链路里的 judgeTime()、note.time()、musicTimeMs() 都在同一条毫秒时间轴上;暂停恢复会按时间窗逐帧重演过去的一段,帧率只是“多久采样一次”,不是判定单位。用帧数当窗口,60Hz 和 144Hz 的玩家会得到不同的判定结果,倒带重演也无法复现。

为什么等级身份靠注册表键。 JudgmentKindDefinition 没有 id(),注册键就是唯一身份:同一个 ResourceLocation 第二次注册会抛 IllegalArgumentException,宿主按实例反查到的键就是“这一档是谁”。键还带类型参数,把“往判定等级表里塞别的定义”拦在编译期。等级之间没有优先级字段,注册顺序只决定遍历顺序,不代表哪一档更高;ULTRA 能压过 PERFECT,完全是因为调整器先做了替换。

为什么多个窗口策略取最宽松。 逐字段取最大值让“放宽判定”的模组可以任意叠加,而且与注册顺序无关;代价是注册策略只能放宽、不能收紧。这是接口的明确取舍:要改结果就用调整器,要改窗口尺度就在合并之后挂窗口合成器。理解这一点,就不会把“我注册了 45ms 但实际还是 70ms”当成注册失败——那通常是另一份更宽的策略在起作用。

常见坑

六个真实易错点
  • 把注册写进 onInit:注册表在全部模组的 onPreInit 结束后立刻冻结,再写抛 RegistryFrozenException。看到它就是写晚了,不是权限问题。
  • 用 new RegistryKey<>(...) 或 new ResourceLocation(...):两者构造器都是私有的,编译就过不去;必须用 of(...) / parse(...)。
  • 键的命名空间填成 seecmd 或别的模组 id:每个模组拿到的是带归属校验的注册视图,命名空间必须等于本模组 id,否则注册期直接抛 IllegalArgumentException。
  • 用 displayName() 判断等级:它只用于 HUD、日志和结算标签,宿主不拿它做匹配。判断“是不是内置 PERFECT”要比较注册表里的实例,判断“是不是自己那一档”要比较键或实例。
  • 只注册等级就期待它出现:注册只是“备好一档”,没有任何路径会主动产出它。必须像本篇这样用 JudgmentAdjuster 改写,或者在你自己的音符类型 onJudge 里 sink.accept(...) 产出。
  • 忘了自定义等级不进内置统计行:perfects 只统计内置 PERFECT。如果整局每一击都落在 30ms 内、全部变成 ULTRA,misses 仍为 0,但 perfects 也是 0——评级会按 B 计算,满分奖励从 2500 降为 1000。这是宿主的统计口径,不是你的注册写错了;接受不了就不要把 PERFECT 全部替换掉。
交付前自查
  • 等级、窗口策略、调整器三个注册都写在 onPreInit。
  • RegistryKey 与 ResourceLocation 都用 of(...) 构造,没有 new。
  • 注册键命名空间是 ultra_judge,与 ModInfo.id 一致。
  • onInit 日志里 ultra=true 且 builtinPerfect=true。
  • 调整器不适用时返回原 input,任何分支都没有返回 null。
  • 判定分支比较的是注册表实例,不是 displayName()。
  • 长音符终点被显式跳过,没有被误算成 ULTRA。
  • 清楚全 ULTRA 时评级与满分奖励的变化。

接下来