Theseus 模组文档
教程

添加新音符类型

一种只在谱面里写 `"type": "burst"` 才出现的双击音符——第一次点击把它点亮,260ms 内再点一次判 PERFECT;只点一次则在自己的二击窗口到期后判 MISS。整个实现只依赖 `mod-api`,不改内核。


为什么加类型,而不是改内核

内置的五种音符语义是固定的:normal 单击、hold 按住到终点、barrier 多段击打、exploit / payload 联动。双击这种「同一个音符要吃两次输入」的规则,内置类型里没有对应物。

改内核意味着 fork 判定引擎、自己维护一份游戏,之后每次升级都要重新合并。加类型则是注册一个定义对象:谱面加载、判定分派、每帧渲染都已经接好了同一套回调管道,你只需要回答四个问题——这个类型叫什么、谱面怎么写、命中怎么算、长什么样。

边界也要说清楚:如果你想改的是「整局判定窗口」「所有音符的计分」,那不是音符类型的职责,应该看判定策略(judgmentPolicies())或游戏模式。

先认识四个词
  • 音符类型(NoteTypeDefinition):你的定义对象。身份由注册键承载,谱面用它的 serializationKey() 选中它;它同时负责解析、判定与渲染回调。
  • 音符实例数据(NoteData 子类):一个音符一份的运行时对象。你实现 getter/setter 并保存解析结果;宿主用内核音符的 time / lane 做定位与渲染,你的副本供自己的判定与渲染逻辑读取。
  • 解析上下文(NoteParseContext):deserialize 时宿主递进来的只读信息,只有 totalLanes() / bpm() / contentScale() 三个方法,且只在该次解析期间有效。
  • 判定接收器(JudgeSink):你把判定结果 accept 进去的收集器。二参版本给普通判定钩子用,三参版本(带 NoteView)才能定位具体音符,接管判定时必须用三参。

一条音符从谱面到判定线

谱面 JSON "type": "burst" 宿主解析器 按序列化 key 匹配 deserialize 产出音符实例 运行期下落 时间推进位置 输入命中 按键 → 轨道 判定 onJudgeTick 渲染 每帧 render 前两步由宿主完成,你只提供「注册键 + serializationKey」;第三步起才进入你的代码。 下落、判定、渲染在同一帧推进:虚线表示下一帧继续,直到音符被判定或超时移除。 判定结果进入宿主的统一计分 / 连击 / 特效路径,音效需要模组自己播。
解析期产出的运行时音符会被宿主保存,并在下落、判定、渲染三处交回给你的定义对象

前置条件

一个音符的字段,和整张谱面的字段不是一回事

每个音符自己的私有字段(本篇的 secondTapWindow)直接写在 notes 数组的那个对象里,由你的 deserialize 读取。

整张谱面共享的配置(比如 BGM 偏移、主题色)才放顶层 extensions 对象,并且必须注册一个 ChartExtensionParser 来解析。两者的键规则不同:音符的 type 用的是不带命名空间的 serializationKey(),而 extensions 的 key 是完整的 namespace:path。

开始动手

定义音符类型

新建 BurstNoteType 实现 NoteTypeDefinition。接口里只有五个方法必须实现:

方法作用
displayName()界面与日志里的名字,不参与谱面匹配
serializationKey()谱面 JSON 的 type 字符串
deserialize(JsonObject, NoteParseContext)解析期:从音符 JSON 造运行时对象
onJudge(JudgeInput, JudgeSink)宿主判定时问「这次用哪个判定等级」
render(RenderCanvas, NoteRenderContext)每帧画这个音符

其余都有默认实现:onCreateRuntime、overridesJudgment、onJudgeTick、timeoutStrategy、modHandledTimeoutCapMs、defaultImage、onJudged、onRemoved。本篇只覆写其中和双击有关的那几个。

serializationKey() 在注册期就会被调用并建立索引:返回空串或与已有类型(含内置的 normal / hold / barrier / exploit / payload)重复,注册直接抛 IllegalArgumentException。所以不要让它依赖任何运行时状态。

实现 deserialize:读出时间、轨道与私有字段

宿主会把该音符的完整 JSON 对象交给你,包括它不认识的字段。解析时先取 time 与 lane,再把自己的私有字段挂到 extensionData:

java
@Override
public NoteData deserialize(JsonObject json, NoteParseContext ctx) {
    BurstNote note = new BurstNote();
    note.setTime(json.get("time").getAsLong());
    note.setLane(json.get("lane").getAsInt());
    note.setEndTime(null);              // 双击音符不是长按,没有终点
    // typeKey 由宿主按 serializationKey 匹配后填充,模组侧不要自己写

    long secondTapMs = json.has("secondTapWindow")
            ? json.get("secondTapWindow").getAsLong()
            : DEFAULT_SECOND_TAP_MS;
    note.setExtensionData(new TapState(Math.max(80L, secondTapMs)));
    return note;
}

几个行为要点:

  • extensionData 不参与序列化,但会在 render 里经 NoteRenderContext.extensionData()、在判定里经 NoteView.extensionData() 原样取回同一个对象,正好用来存「第一次点击发生在什么时候」。
  • ctx 只有 totalLanes()、bpm()、contentScale(),且只在该次解析期间有效,不要保存引用。
  • 返回的 NoteData 里写的 time / lane 只用于你自己的逻辑;宿主的定位、判定、渲染读的是谱面 JSON 解析出的内核值(两者在解析期本来就相同)。运行期改写它们不会移动音符。
  • deserialize 抛异常会被宿主捕获并记限频日志,该音符退回内置解析;但音符仍会进入游玩,你的 render 仍会被调用,extensionData() 却是 null。渲染代码要按「拿不到私有数据」兜底。
  • 需要做关联查找、补默认值时,用 onCreateRuntime(NoteData note),它在 deserialize 返回非 null 后立刻调用一次。
在 onPreInit 注册

注册表只在 onPreInit 可写,之后立刻冻结;再写会抛 RegistryFrozenException。注册键的命名空间必须等于模组 id,宿主会在写入口先做归属校验:

java
private static final String MOD_ID = "burst_note";

private static final RegistryKey<NoteTypeDefinition> BURST_TYPE =
        RegistryKey.of(MOD_ID, "burst", NoteTypeDefinition.class);

@Override
public void onPreInit(ModContext ctx) {
    JudgmentKindDefinition perfect = ctx.registries().judgmentKinds()
            .get(ResourceLocation.of("seecmd", "perfect"))
            .orElseThrow(() -> new IllegalStateException("宿主缺少内置判定等级 seecmd:perfect"));
    JudgmentKindDefinition miss = ctx.registries().judgmentKinds()
            .get(ResourceLocation.of("seecmd", "miss"))
            .orElseThrow(() -> new IllegalStateException("宿主缺少内置判定等级 seecmd:miss"));

    ctx.registries().noteTypes().register(BURST_TYPE, new BurstNoteType(perfect, miss));
    ctx.getLogger().info("已注册音符类型 " + BURST_TYPE.location());
}
  • RegistryKey 与 ResourceLocation 的构造器都是私有的,只能用 of(...) / parse(...)。
  • RegistryKey.of(命名空间, 路径, 类型) 的第三个参数让编译器拦住「往音符类型表里塞游戏模式」这类错误。
  • 路径部分只允许小写字母、数字、_、.、/、-,且整个标识区分大小写。
  • 这里顺手取出 seecmd:perfect 与 seecmd:miss 传给类型对象。接管判定时必须给 sink.accept 一个真实的 JudgmentKindDefinition,用内置等级能让计分、连击与内置判定完全一致。
在谱面里写一个该类型的音符

在任一谱面的 notes 数组里加一条,type 写 serializationKey() 本身,不加命名空间前缀:

json
{
  "lanes": 4,
  "notes": [
    { "type": "normal", "time": 1000, "lane": 0 },
    { "type": "burst",  "time": 1800, "lane": 1 },
    { "type": "burst",  "time": 2600, "lane": 2, "secondTapWindow": 320 }
  ]
}

第二条用默认的 260ms 二击窗口,第三条用自己的 320ms。谱面加载时,宿主拿 type 字符串精确匹配已注册类型的 serializationKey();匹配不到时该音符保留原 type 字符串并回退到内置的类型比较逻辑,而内置逻辑只认 hold / barrier / exploit / payload,其余一律当普通音符——所以拼写错误不会报错,只会静默变成普通音符。

构建、运行、逐项核对

把 jar 放进 mods\,重启游戏,选一张改过的谱面:

  • 启动日志里能看到 已注册音符类型 burst_note:burst,模组状态是 ACTIVE。
  • 谱面里 burst 的位置出现两个上下叠放的小条,而不是默认的普通音符块。
  • 在音符到达判定线前后 150ms 内点第一下:颜色变成点亮态并开始脉冲,但不判分。
  • 260ms 内再点同轨一次:出现 PERFECT,连击正常增长。
  • 只点一下或完全不管:音符会在自己的期限(第一击后超过该音符的二击窗口,或始终没点则音符时间 +150ms)被判 MISS。
  • 同一张谱面里的普通音符、长按行为完全不变。

如果 burst 音符长得和普通音符一样、点击后立刻判分,说明宿主没找到你的类型定义:先检查注册是否真的执行、type 是否与 serializationKey() 逐字符相同。

自定义渲染(可选,但示例会做)

render 每帧对每个可见音符调用一次。画布缝已经施加了轨道透明度、旋转、缩放与裁剪,所以你按 ctx 给的布局坐标画,默认就会跟随轨道几何:

java
@Override
public void render(RenderCanvas canvas, NoteRenderContext ctx) {
    double w = Math.min(ctx.width(), 56.0);
    double h = Math.max(8.0, w * 0.34);
    double top = ctx.y() - h / 2;

    TapState tap = ctx.extensionData() instanceof TapState t ? t : null;
    boolean armed = tap != null && tap.firstTapMs >= 0L;

    canvas.save();
    if (armed) {
        double pulse = 0.5 + 0.5 * Math.sin(ctx.musicTimeMs() / 90.0);
        canvas.setColor(COLOR_ARMED);
        canvas.setAlpha(0.18 + 0.22 * pulse);
        canvas.fillRect(ctx.x() - w / 2 - 3, top - 3, w + 6, h + 6);
    }
    canvas.setAlpha(1.0);
    canvas.setColor(armed ? COLOR_ARMED : COLOR_WAITING);
    canvas.fillRect(ctx.x() - w / 2, top, w, h * 0.42);
    canvas.fillRect(ctx.x() - w / 2, top + h * 0.58, w, h * 0.42);
    canvas.restore();
}
  • ctx.x() 是轨道中心,ctx.y() 是音符中心(已含音乐时间偏移),ctx.width() 是轨道绘制宽度。
  • ctx.judgeLineY() 是当前轨道有效判定线,ctx.judgeLineY() - ctx.y() 就是剩余距离;ctx.scrollSpeed() 用来把毫秒换算成像素(位移 = 时间差(ms) * scrollSpeed / 1000)。
  • 宿主在调用前后各做一次 save / restore,但你自己开的 save 仍要自己配平。
  • render 抛异常会被宿主捕获并记限频日志,然后回退到「皮肤专属槽位图 → defaultImage() → 内置分类」绘制,不会崩游戏。
接管判定(可选,双击必须做)

默认情况下,你的类型和普通音符走同一套时间窗口判定:宿主判定该音符时调用 onJudge,你在里面只能换判定等级,不能改变「是否命中」。双击需要「同一音符吃两次输入」,所以必须声明接管:

java
@Override
public boolean overridesJudgment() {
    return true;   // 本类型不参与宿主的按键候选匹配
}

@Override
public TimeoutStrategy timeoutStrategy() {
    // 二击窗口可以长于宿主默认 miss 窗口,申请由本类型处理超时
    return TimeoutStrategy.MOD_HANDLED;
}

@Override
public long modHandledTimeoutCapMs() {
    return 2_000L;   // 宿主兜底期限的宽限上限(防卡死)
}

@Override
public void onJudgeTick(long timeMs, JudgeInputState state, JudgeContext ctx, JudgeSink sink) {
    List<NoteView> pending = ctx.notes();
    if (pending.isEmpty()) {
        return;
    }

    Set<NoteView> handled = new HashSet<>();
    Set<Integer> lanesThisFrame = new HashSet<>();

    for (Integer keyCode : state.pressedKeys()) {
        int lane = state.keyToLane(keyCode);
        if (lane < 0 || !lanesThisFrame.add(lane)) {
            continue;
        }
        for (NoteView note : pending) {
            if (handled.contains(note) || note.lane() != lane) continue;
            if (!(note.extensionData() instanceof TapState tap)) continue;

            if (tap.firstTapMs < 0L) {
                if (Math.abs(timeMs - note.time()) > HIT_WINDOW_MS) continue;
                tap.firstTapMs = timeMs;
            } else if (timeMs - tap.firstTapMs <= tap.secondTapMs) {
                sink.accept(perfectKind, timeMs, note);
                handled.add(note);
            } else {
                continue;   // 已过期,交给下面的超时扫描
            }
            break;
        }
    }

    for (NoteView note : pending) {
        if (handled.contains(note)) continue;
        if (!(note.extensionData() instanceof TapState tap)) continue;
        boolean expired = tap.firstTapMs < 0L
                ? timeMs - note.time() > HIT_WINDOW_MS
                : timeMs - tap.firstTapMs > tap.secondTapMs;
        if (expired) {
            sink.accept(missKind, timeMs, note);
        }
    }
}
  • 接管后宿主不再把该类型的音符放进按键候选匹配;只要该类型还有待判定音符,就在输入处理之后、超时兜底 MISS 之前每帧调用 onJudgeTick(没有待判定音符时整帧跳过)。
  • state.pressedKeys() 是本帧新按下的物理键,键码与 java.awt.event.KeyEvent 一致;state.keyToLane(int) 返回逻辑轨道,未绑定为 -1。
  • ctx.notes() 是本类型尚未判定的音符快照,宿主每帧只构造一次,在回调开头取一次即可。
  • 产出判定必须用三参 sink.accept(kind, judgeTime, note),且 note 必须是 ctx.notes() 里的同一实例;二参版本在接管路径下无法定位音符,会被丢弃。
  • 每条产出由宿主逐条消费:标记该音符已判定,再走统一的计分 / 连击 / 特效路径。示例用 handled 保证同一帧对同一音符只产出一条。
  • 这里必须声明 MOD_HANDLED:宿主内置默认的 missWindowMs 是 150ms,而二击窗口默认 260ms。若保持默认的 HOST_FALLBACK,音符会在 time + 150ms 先被宿主判 MISS,第二次点击永远来不及。声明 MOD_HANDLED 后宿主把兜底期限延后 modHandledTimeoutCapMs(),宽限期内不强制 MISS。
  • 宽限期不是免死金牌:接管者要自己产出判定。上面的超时扫描会在「第一击后超过二击窗口」或「始终没有第一击且超出首击窗口」时,用 seecmd:miss 产出 MISS,所以音符不会悬挂到宽限上限。
  • 打击音效不会自动播放,需要模组经 AudioAccess 自己播;onJudge 也会在宿主兜底 MISS 时被调用,不要在那里做计分或播音效,否则会和 onJudgeTick 的产出重复。
为什么音符实例只存「时间 + 轨道 + 类型私有数据」

NoteData 的读接口只有 time()、lane()、typeKey()、endTime()、isJudged()、extensionData()。这不是接口偷懒,而是所有权边界:时间与轨道是谱面的内容,宿主拿它定位、判定、渲染;判定状态由判定引擎独占;只有 extensionData 是留给类型的自由槽。

所以双击的「第一次点击时刻」不放在宿主字段里,而是塞进自己的 extensionData。宿主不会读它,但会在渲染和判定回调里原样递回来,同一份对象两边都能改。这也解释了为什么运行期改 NoteData 的 time / lane 不会移动音符——宿主管线根本不看你的副本。

为什么解析与渲染要分开

解析只发生一次,而且发生在谱面加载线程;渲染每帧发生一次,在游玩渲染线程。两件事的生命周期差了几个数量级:

  • 解析期适合做校验、补默认值、预计算(deserialize / onCreateRuntime)。
  • 渲染期必须足够便宜,不能读文件、不能解码图片、不能建大对象。

接口把它们拆成两个回调,就是为了让你没法在渲染里偷偷做解析。defaultImage() 也是同一个道理:宿主每次渲染自定义音符前都会解析皮肤图,所以它必须返回缓存好的 RenderImage,不能在里面加载。

为什么渲染回调不给你绝对屏幕坐标

ctx.x() / ctx.y() 是布局坐标,不是最终屏幕像素。宿主在调用你的回调之前,已经把轨道透明度、旋转、缩放、位置式裁剪和音符级变换施加到画布上了;你再拿绝对屏幕坐标去画,就会被这些变换叠加两次。

这层设计的收益是:轨道倾斜、缩放、镜像、位置式裁剪全部自动生效,你写一次绘制逻辑就能适配所有轨道形态。真要偏离轨道几何(比如无视旋转、始终朝向屏幕),才需要经 GameStateAccess 查圆心与变换后自行反向叠加——那是例外,不是默认路径。

这几个地方最容易踩
  • 注册写晚了:写在 onInit / onPostInit / 运行期都会抛 RegistryFrozenException。注册表在全部模组的 onPreInit 结束后立即冻结,没有第二次机会。
  • 注册键的命名空间不是模组 id:宿主在写入口做归属校验,直接抛 IllegalArgumentException。命名空间写 seecmd 或别的模组 id 都不行,必须等于 info() 里的模组 id。
  • serializationKey() 与谱面 type 对不上:不要加 burst_note: 前缀,不要改大小写。匹配失败不报错,音符会静默按普通音符渲染和判定,表现为「我的代码完全没跑」。
  • extensions 的 key 与 ChartExtensionParser 注册键不一致:extensions 的 key 是完整的 namespace:path(如 burst_note:config),不是 serializationKey 那种裸字符串;写错时该扩展段会被静默跳过,不报错。
  • 在 render 或 defaultImage() 里做 IO:两者都会每帧被调用(defaultImage 即使你随后自绘成功也已经被调用过)。图片在 onInit 用 ctx.resources().loadImage(...) 加载一次并缓存。
  • 没处理资源缺失异常:loadImage / readText 抛的是受检异常 ResourceNotFoundException,不处理就编译不过;处理了也要有几何兜底,缺图时仍要能画出音符。
交付前自查
  • 注册发生在 onPreInit,注册键命名空间等于模组 id。
  • serializationKey() 与谱面里的 type 完全一致,且没有与内置类型重复。
  • deserialize 读到了 time / lane,私有字段缺失时有默认值。
  • render 里没有 IO、没有每帧新建大对象,save / restore 配平。
  • 接管判定只用三参 sink.accept,note 来自 ctx.notes()。
  • 单击不补第二下时,音符会被本类型按二击窗口判 MISS,不会悬挂到宿主宽限上限。
  • 启动日志与游玩日志中没有 [NoteType] 开头的限频异常。

完整代码

BurstNoteMod.java(入口与注册):

java
package com.example.burst;

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.registry.ResourceLocation;
import com.seecmd.mod.api.spi.JudgmentKindDefinition;
import com.seecmd.mod.api.spi.NoteTypeDefinition;

import java.util.List;

public final class BurstNoteMod implements Mod {

    private static final String MOD_ID = "burst_note";

    /** 注册键:命名空间必须等于模组 id,path 自取。 */
    private static final RegistryKey<NoteTypeDefinition> BURST_TYPE =
            RegistryKey.of(MOD_ID, "burst", NoteTypeDefinition.class);

    @Override
    public ModInfo info() {
        return ModInfo.of(MOD_ID, "Burst Note", "0.1.0",
                List.of("YourName"), "双击音符(burst)示例");
    }

    @Override
    public void onPreInit(ModContext ctx) {
        JudgmentKindDefinition perfect = ctx.registries().judgmentKinds()
                .get(ResourceLocation.of("seecmd", "perfect"))
                .orElseThrow(() -> new IllegalStateException("宿主缺少内置判定等级 seecmd:perfect"));
        JudgmentKindDefinition miss = ctx.registries().judgmentKinds()
                .get(ResourceLocation.of("seecmd", "miss"))
                .orElseThrow(() -> new IllegalStateException("宿主缺少内置判定等级 seecmd:miss"));

        ctx.registries().noteTypes().register(BURST_TYPE, new BurstNoteType(perfect, miss));
        ctx.getLogger().info("已注册音符类型 " + BURST_TYPE.location());
    }
}

BurstNoteType.java(定义、解析、判定、渲染与运行时音符):

java
package com.example.burst;

import com.google.gson.JsonObject;
import com.seecmd.mod.api.context.NoteView;
import com.seecmd.mod.api.render.RenderCanvas;
import com.seecmd.mod.api.spi.JudgeContext;
import com.seecmd.mod.api.spi.JudgeInput;
import com.seecmd.mod.api.spi.JudgeInputState;
import com.seecmd.mod.api.spi.JudgeSink;
import com.seecmd.mod.api.spi.JudgmentKindDefinition;
import com.seecmd.mod.api.spi.NoteData;
import com.seecmd.mod.api.spi.NoteParseContext;
import com.seecmd.mod.api.spi.NoteRenderContext;
import com.seecmd.mod.api.spi.NoteTypeDefinition;
import com.seecmd.mod.api.spi.TimeoutStrategy;

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

public final class BurstNoteType implements NoteTypeDefinition {

    /** 谱面 JSON 的 "type" 就是它,不加命名空间前缀。 */
    private static final String TYPE_KEY = "burst";

    /** 第一击与音符时间的最大允许偏差。 */
    private static final long HIT_WINDOW_MS = 150L;

    /** 第二次点击相对第一次的默认窗口。 */
    private static final long DEFAULT_SECOND_TAP_MS = 260L;

    /** MOD_HANDLED 的兜底宽限上限。 */
    private static final long TIMEOUT_GRACE_MS = 2_000L;

    private static final Color COLOR_WAITING = new Color(255, 196, 64);
    private static final Color COLOR_ARMED = new Color(120, 230, 255);

    private final JudgmentKindDefinition perfectKind;
    private final JudgmentKindDefinition missKind;

    public BurstNoteType(JudgmentKindDefinition perfectKind, JudgmentKindDefinition missKind) {
        this.perfectKind = perfectKind;
        this.missKind = missKind;
    }

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

    @Override
    public String serializationKey() {
        return TYPE_KEY;
    }

    @Override
    public NoteData deserialize(JsonObject json, NoteParseContext ctx) {
        BurstNote note = new BurstNote();
        note.setTime(json.get("time").getAsLong());
        note.setLane(json.get("lane").getAsInt());
        note.setEndTime(null);
        // typeKey 由宿主按 serializationKey 匹配后填充,模组侧不要自己写

        long secondTapMs = json.has("secondTapWindow")
                ? json.get("secondTapWindow").getAsLong()
                : DEFAULT_SECOND_TAP_MS;
        note.setExtensionData(new TapState(Math.max(80L, secondTapMs)));
        return note;
    }

    @Override
    public void onJudge(JudgeInput input, JudgeSink sink) {
        // 宿主兜底 MISS 也会经过这里。不产出等级 = 沿用内置判定等级。
    }

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

    @Override
    public TimeoutStrategy timeoutStrategy() {
        return TimeoutStrategy.MOD_HANDLED;
    }

    @Override
    public long modHandledTimeoutCapMs() {
        return TIMEOUT_GRACE_MS;
    }

    @Override
    public void onJudgeTick(long timeMs, JudgeInputState state, JudgeContext ctx, JudgeSink sink) {
        List<NoteView> pending = ctx.notes();
        if (pending.isEmpty()) {
            return;
        }

        Set<NoteView> handled = new HashSet<>();
        // 同一帧里多个键映射到同一轨时,只算一次点击。
        Set<Integer> lanesThisFrame = new HashSet<>();
        for (Integer keyCode : state.pressedKeys()) {
            int lane = state.keyToLane(keyCode);
            if (lane < 0 || !lanesThisFrame.add(lane)) {
                continue;
            }

            for (NoteView note : pending) {
                if (handled.contains(note) || note.lane() != lane) continue;
                if (!(note.extensionData() instanceof TapState tap)) continue;

                if (tap.firstTapMs < 0L) {
                    if (Math.abs(timeMs - note.time()) > HIT_WINDOW_MS) continue;
                    tap.firstTapMs = timeMs;
                } else if (timeMs - tap.firstTapMs <= tap.secondTapMs) {
                    sink.accept(perfectKind, timeMs, note);
                    handled.add(note);
                } else {
                    continue;   // 已过期,交给下面的超时扫描
                }
                break;
            }
        }

        // 自己处理超时:宿主只负责兜底,宽限期内不会替我们判。
        for (NoteView note : pending) {
            if (handled.contains(note)) continue;
            if (!(note.extensionData() instanceof TapState tap)) continue;
            boolean expired = tap.firstTapMs < 0L
                    ? timeMs - note.time() > HIT_WINDOW_MS
                    : timeMs - tap.firstTapMs > tap.secondTapMs;
            if (expired) {
                sink.accept(missKind, timeMs, note);
            }
        }
    }

    @Override
    public void render(RenderCanvas canvas, NoteRenderContext ctx) {
        double w = Math.min(ctx.width(), 56.0);
        double h = Math.max(8.0, w * 0.34);
        double top = ctx.y() - h / 2;

        TapState tap = ctx.extensionData() instanceof TapState t ? t : null;
        boolean armed = tap != null && tap.firstTapMs >= 0L;

        canvas.save();
        if (armed) {
            double pulse = 0.5 + 0.5 * Math.sin(ctx.musicTimeMs() / 90.0);
            canvas.setColor(COLOR_ARMED);
            canvas.setAlpha(0.18 + 0.22 * pulse);
            canvas.fillRect(ctx.x() - w / 2 - 3, top - 3, w + 6, h + 6);
        }
        canvas.setAlpha(1.0);
        canvas.setColor(armed ? COLOR_ARMED : COLOR_WAITING);
        canvas.fillRect(ctx.x() - w / 2, top, w, h * 0.42);
        canvas.fillRect(ctx.x() - w / 2, top + h * 0.58, w, h * 0.42);
        canvas.restore();
    }

    /** 每个音符一份的运行时状态,存在 NoteData.extensionData 里。 */
    private static final class TapState {
        private final long secondTapMs;
        private long firstTapMs = -1L;

        private TapState(long secondTapMs) {
            this.secondTapMs = secondTapMs;
        }
    }

    /** 自定义运行时音符:保存宿主定位用的时间/轨道,以及自己的状态。 */
    private static final class BurstNote extends NoteData {

        private long time;
        private int lane;
        /** 与 serializationKey 一致;宿主侧还会按谱面 type 匹配回填。 */
        private String typeKey = TYPE_KEY;
        private Long endTime;
        private boolean judged;
        private Object extensionData;

        @Override public long time() { return time; }
        @Override public int lane() { return lane; }
        @Override public String typeKey() { return typeKey; }
        @Override public Long endTime() { return endTime; }
        @Override public boolean isJudged() { return judged; }
        @Override public Object extensionData() { return extensionData; }

        @Override public void setTime(long time) { this.time = time; }
        @Override public void setLane(int lane) { this.lane = lane; }
        @Override public void setEndTime(Long endTime) { this.endTime = endTime; }
        @Override public void setJudged(boolean judged) { this.judged = judged; }
        @Override public void setExtensionData(Object data) { this.extensionData = data; }
    }
}

接下来