Theseus 模组文档
API 参考 / spi 包:音符与判定 SPI

NoteTypeDefinition


想先跑起来?

这一页是签名与行为参考。从谱面解析到判定、渲染的完整回调面。直接做一遍:教程 · 添加新音符类型。

音符类型定义对象,是模组扩展音符的唯一入口:在 onPreInit 用 ctx.registries().noteTypes().register(key, def) 注册,身份由注册键承载,本接口不自带 id。谱面 JSON 的 type 字段按 serializationKey() 精确匹配(不带命名空间前缀),未命中时该音符回退到宿主的旧式类型比较逻辑。注册期会校验:serializationKey() 为空/空白、或与其它已注册类型(含内置五个)重复,均抛 IllegalArgumentException。相关类型:NoteData、NoteRenderContext、TimeoutStrategy。

方法速览

方法用途
displayName()人类可读显示名
serializationKey()谱面 JSON 的 type 匹配键
deserialize(JsonObject, NoteParseContext)解析期:从音符 JSON 创建运行时音符
onCreateRuntime(NoteData)解析期:补充运行时字段(关联查找/随机值等)
onJudge(JudgeInput, JudgeSink)判定等级修饰钩子(不决定是否命中)
overridesJudgment()是否完全接管本类型判定
onJudgeTick(long, JudgeInputState, JudgeContext, JudgeSink)接管类型的每帧自主判定
timeoutStrategy()接管类型的超时兜底策略
modHandledTimeoutCapMs()MOD_HANDLED 的兜底宽限上限(毫秒)
render(RenderCanvas, NoteRenderContext)每帧渲染回调
defaultImage()皮肤无专属槽位时的模组兜底图(实验性)
onJudged(NoteData, JudgeResult)音符被判定后的生命周期回调
onRemoved(NoteData)音符完全判定(含 MISS)后的收尾回调

displayName()

签名:

java
String displayName();

行为:返回显示名称(如 "长按"、"星尘"),仅用于界面与日志;不参与谱面匹配,也不做非空校验。宿主每次需要时直接调用,实现应恒定返回同一值(不要做重计算)。

示例:

java
@Override
public String displayName() {
    return "星尘";
}

serializationKey()

签名:

java
String serializationKey();

行为:返回谱面 JSON 中该音符的 type 字符串(如 "stardust")。注册期即被调用并建立全局索引:空/空白或与已有类型重复会抛 IllegalArgumentException,因此不要在注册之后才改变返回值。与 NoteData.typeKey() 对应。

示例:

java
@Override
public String serializationKey() {
    return "stardust";
}

deserialize(JsonObject json, NoteParseContext ctx)

签名:

java
NoteData deserialize(JsonObject json, NoteParseContext ctx);

行为:谱面加载时对所有注册类型(内置与模组)统一派发。json 是该音符的完整 JSON 对象(time / lane / endTime 及自定义字段),ctx 提供轨道数、BPM 与内容缩放。返回的 NoteData 实例被宿主作为该音符的运行时对象保存(内置类型返回 null 表示"无运行时补充")。

边界与所有权:

示例:

java
@Override
public NoteData deserialize(JsonObject json, NoteParseContext ctx) {
    StardustNote note = new StardustNote();
    note.setTime(json.get("time").getAsLong());
    note.setLane(json.get("lane").getAsInt());
    if (json.has("endTime") && !json.get("endTime").isJsonNull()) {
        note.setEndTime(json.get("endTime").getAsLong());
    }
    note.setTypeKey(serializationKey());
    return note;
}

onCreateRuntime(NoteData note)

签名:

java
default void onCreateRuntime(NoteData note);

行为:仅在 deserialize 返回非 null 后立即调用一次,用于补全运行时字段(关联查找、默认值、随机角度等)。note 就是 deserialize 返回的同一实例。默认空实现;抛异常由宿主捕获(限频日志)并跳过,音符仍会进入游玩。

示例:

java
@Override
public void onCreateRuntime(NoteData note) {
    // 每个音符一份运行时状态,render 与 onJudgeTick 经 extensionData 取回同一实例
    note.setExtensionData(new AimState(Math.toRadians(nextAngleDeg())));
}

onJudge(JudgeInput input, JudgeSink sink)

签名:

java
void onJudge(JudgeInput input, JudgeSink sink);

行为:宿主为一次判定构造结果时调用(按键命中、长按终点、超时 MISS、屏障摧毁等所有判定路径),用于声明该次判定使用哪个判定等级。

契约:

示例:

java
@Override
public void onJudge(JudgeInput input, JudgeSink sink) {
    if (Math.abs(input.deltaMs()) <= 30) {
        // 只是把这次判定的等级换成自定义等级;是否命中仍由宿主决定
        sink.accept(myPerfectKind, input.judgeTime());
    }
}

overridesJudgment()

签名:

java
default boolean overridesJudgment();

行为:返回 true 时声明完全接管本类型音符的判定:宿主不再把该类型音符放进按键候选匹配(普通/Hold 与无序路径都会跳过),改为每帧调用 onJudgeTick。默认 false。

边界:

示例:

java
@Override
public boolean overridesJudgment() {
    return true;
}

onJudgeTick(long timeMs, JudgeInputState state, JudgeContext ctx, JudgeSink sink)

签名:

java
default void onJudgeTick(long timeMs, JudgeInputState state, JudgeContext ctx, JudgeSink sink);

行为:接管类型的每帧判定回调。调用时机固定在输入处理之后、超时兜底 MISS 之前(预览会话同样调用,此时输入快照为空)。宿主先做无分配守卫:本类型没有待判定音符时,本帧不构造快照、不回调。

产出与消费:

示例:

java
@Override
public void onJudgeTick(long timeMs, JudgeInputState state, JudgeContext ctx, JudgeSink sink) {
    List<NoteView> notes = ctx.notes();             // 每帧只取一次
    for (Integer key : state.pressedKeys()) {       // 本帧新按下的物理键
        int lane = state.keyToLane(key);
        if (lane < 0) {
            continue;
        }
        for (NoteView note : notes) {
            if (note.lane() == lane && Math.abs(timeMs - note.time()) <= 80) {
                sink.accept(myPerfectKind, timeMs, note);   // 三参版本才对指定音符生效
                break;
            }
        }
    }
}

timeoutStrategy()

签名:

java
default TimeoutStrategy timeoutStrategy();

行为:声明"超时后谁来兜底",默认 TimeoutStrategy.HOST_FALLBACK(超期即按普通期限补 MISS)。返回 MOD_HANDLED 时,宿主把兜底期限改为「普通期限 + modHandledTimeoutCapMs()」,宽限期内不强制 MISS,由接管者自行产出判定或保留音符。

边界:

示例:

java
@Override
public TimeoutStrategy timeoutStrategy() {
    // 需要把音符保留到自定义条件满足时再判,声明由模组处理超时
    return TimeoutStrategy.MOD_HANDLED;
}

modHandledTimeoutCapMs()

签名:

java
default long modHandledTimeoutCapMs();

行为:MOD_HANDLED 的兜底宽限上限(毫秒),默认 30000。宿主兜底期限 = 音符 time + 判定窗口 missWindowMs + 本值。≤ 0 视为不延长(等价 HOST_FALLBACK 期限);读取抛异常按 0 处理。仅当 overridesJudgment() 与 timeoutStrategy() == MOD_HANDLED 同时成立时被消费。

示例:

java
@Override
public long modHandledTimeoutCapMs() {
    return 8_000L;   // 最多多等 8 秒,之后宿主强制兜底 MISS
}

render(RenderCanvas canvas, NoteRenderContext ctx)

签名:

java
void render(RenderCanvas canvas, NoteRenderContext ctx);

行为:每个可见音符每帧调用一次,canvas 是模组唯一的绘制入口。

示例:

java
@Override
public void render(RenderCanvas canvas, NoteRenderContext ctx) {
    double size = Math.min(ctx.width(), 48);
    canvas.save();
    canvas.setColor(new Color(160, 120, 255));
    canvas.fillRect(ctx.x() - size / 2, ctx.y() - size / 2, size, size);
    canvas.restore();
}

defaultImage()

签名:

java
default RenderImage defaultImage();

行为(源码标注为实验性):当当前皮肤没有为本类型提供专属槽位(图片皮肤目录内的 <类型key>.png)时,宿主用本图绘制音符(宽度铺满轨道、高度按图片比例),优先级高于行为分类固有槽位回退。玩家皮肤提供专属槽位时玩家皮肤优先;返回 null(默认)表示不提供。

注意实现事实:宿主在每次渲染自定义音符前都会解析该图(皮肤槽位缺失时即调用本方法),并把它同时作为 ctx.skinImage() 传入 render——即使你的 render 随后自绘成功,本方法也已经被调用过。因此实现必须缓存(在 onInit 加载一次),不要在这里读文件/生成像素。

示例:

java
private RenderImage defaultImg;   // onInit 中经 ctx.resources().loadImage(...) 预加载并缓存

@Override
public RenderImage defaultImage() {
    return defaultImg;            // 无图时返回 null,宿主落到内置分类渲染
}

onJudged(NoteData note, JudgeResult result)

签名:

java
default void onJudged(NoteData note, JudgeResult result);

行为:一次判定被宿主消费后调用,note 为该音符的运行时对象(内置类型为只读适配器),result 为最终判定结果(含判定等级、判定时间与 isHoldEnd())。长按起点与终点各触发一次;MISS 同样触发。默认空实现;异常被捕获并记限频日志。预览会话的静默事件模式下不投递本回调。

示例:

java
@Override
public void onJudged(NoteData note, JudgeResult result) {
    if (result.isHoldEnd()) {
        lastHoldEndMs = result.judgeTime();
    }
}

onRemoved(NoteData note)

签名:

java
default void onRemoved(NoteData note);

行为:音符完全判定(命中、超时 MISS 都算)并经计分路径消费后调用一次。长按起点的判定(含起点超时 MISS)不触发——宿主按"非长按终点且为长按音符"识别起点;长按终点、普通音符的任何判定结局才触发。宿主按音符记录"已通知"标志,保证每音符至多一次。默认空实现;异常被捕获并记限频日志。

示例:

java
@Override
public void onRemoved(NoteData note) {
    counts.remove(note.time() + ":" + note.lane());   // 清理本音符的连按计数
}

完整签名

java
public interface NoteTypeDefinition {

    String displayName();

    String serializationKey();

    NoteData deserialize(JsonObject json, NoteParseContext ctx);

    default void onCreateRuntime(NoteData note);

    void onJudge(JudgeInput input, JudgeSink sink);

    default boolean overridesJudgment();

    default void onJudgeTick(long timeMs, JudgeInputState state, JudgeContext ctx, JudgeSink sink);

    default TimeoutStrategy timeoutStrategy();

    default long modHandledTimeoutCapMs();

    void render(RenderCanvas canvas, NoteRenderContext ctx);

    default RenderImage defaultImage();

    default void onJudged(NoteData note, JudgeResult result);

    default void onRemoved(NoteData note);
}

线程:源码未声明额外线程约束。宿主在谱面解析线程(deserialize / onCreateRuntime)与游玩更新/渲染线程(判定与渲染回调)同步调用,回调内不得阻塞。

生命周期阶段:onPreInit 注册;解析期回调 deserialize / onCreateRuntime,游玩期回调判定、渲染与生命周期钩子。

稳定性:1.x 内冻结承诺自 2.0 起对本接口兑现一次破坏:isHold() / isBarrier() / isExploit() / isPayload() 四个布尔旗标已删除,改为语义标记子接口(见 语义标记子接口);2.0 内恢复只增不破。timeoutStrategy() / modHandledTimeoutCapMs() 为 新增(只增不破);defaultImage() 在源码中标注为实验性。