添加判定等级
在 PERFECT 之上新增一个只在 30ms 内触发的 ULTRA 等级,让 HUD 连击、日志、结算统计和判定文字都认这一档。
默认判定只有 PERFECT / GREAT / MISS(外加内部使用的 NONE)。如果你想做街机风的最严评级、只奖励“几乎零误差”的击打,或者让某一档不涨连击——改打击特效只能换表现,改不了“这一击算哪一档、给多少分、涨不涨连击”。
判定等级就是这个“档位”本身:分数权重、准度权重、颜色、连击行为都挂在同一个注册项上。宿主在计分、连击、HUD 文字、结算统计和判定特效里读的是同一份定义,所以注册一次,整条链路都会认它。只想要不同的文字或颜色、不改变判定与计分时,应该去做 自定义打击特效,不必新增等级。
- 判定等级:注册表里的一个
JudgmentKindDefinition,决定一档判定的显示名、分数权重、准度权重、颜色和连击行为。 - 判定窗口:判定时刻与音符时刻之间允许的偏差,单位是毫秒。内置默认是 PERFECT 70ms、GREAT 150ms。
- 判定策略:
JudgmentPolicy,一次性给出五个窗口值(PERFECT / GREAT / MISS / 长音符结尾 / 迟到截断)。 - 判定调整器:
JudgmentAdjuster,拿到已经产生的判定结果,在计分之前把它改写成另一档。
前置条件
- 已经能构建并加载一个最小模组(见 第一个模组)。
- 工程已引入
mod-api的 jar,build.bat能打出可加载的模组 jar。
JudgmentPolicy 只有五个固定的窗口字段,没有“ULTRA 窗口”这种扩展位。所以新增一档不是“往策略里加一个字段”,而是:注册一个等级定义,再用调整器把落在更严阈值里的 PERFECT 改写过去。窗口策略仍然要注册,它决定 PERFECT 本身有多宽。
判定是怎么走到 ULTRA 的
开始动手
先写等级定义。JudgmentKindDefinition 有 5 个必须实现的方法,另外两个方法(isMissKind()、render(...))有默认实现:
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。顺手把内置 PERFECT 的实例取出来,第 4 步做身份比较时要用:
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 本身定一条更严的线。五个值全部是毫秒:
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; }
}// 仍然在 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 只是注册表里“备好的一档”,没有任何路径会产出它。把 30ms 内的 PERFECT 改写过去:
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 里确认两个值:
set "MOD_ID=ultra_judge"
set "MOD_CLASS=com.example.ultrajudge.UltraJudgeMod"构建后把 jar 放进 mods\,重启游戏。先看日志里有没有这一行:
[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 成为“高连击后的奖励档”,把条件加在阈值判断之后即可:
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() 是这一击结算前的连击数。它仍然属于“入参”,倒带重演时会被宿主重建成同一状态;不要在调整器里维护自己的计数器,也不要读墙钟或随机数。
完整代码
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 时评级与满分奖励的变化。
接下来
- 想把窗口语义、五个字段的边界看全:读 JudgmentPolicy。
- 想确认调整器链的执行顺序、异常隔离与确定性契约:读 JudgmentAdjuster。
- 想在改写时读取或替换判定时刻、音符信息:读 JudgeResult。
- 想让 ULTRA 有独立的命中演出,而不是一行文字:看 自定义打击特效。