Theseus 模组文档
开发指南

注册自有内容


3.1 统一入口与全部注册表

所有内容注册都发生在 onPreInit,写法统一是「构造 RegistryKey → 调对应注册表的 register(key, value)」,入口是 ctx.registries()(ModRegistries):

方法内容类型说明
noteTypes()Registry<NoteTypeDefinition>音符类型。宿主预注册内置类型 seecmd:normal / seecmd:hold / seecmd:barrier / seecmd:exploit / seecmd:payload
gameModes()Registry<GameModeDefinition>游戏模式。宿主预注册 seecmd:normal / seecmd:autoplay / seecmd:disorder
judgmentKinds()Registry<JudgmentKindDefinition>判定等级。宿主预注册 seecmd:perfect / seecmd:great / seecmd:miss / seecmd:none
chartExtensions()Registry<ChartExtensionParser>谱面 extensions 段的解析器,注册键即 extensions 对象里的 key
hitEffectStyles()Registry<HitEffectStyleProvider>打击特效样式,按注册顺序查询,所有返回非 null 的样式同时生效(依次绘制)
backgroundRenderers()Registry<BackgroundRenderer>背景渲染器,绘制在游玩内容之前,执行顺序按 zOrder() 升序
renderFilters()Registry<RenderFilter>渲染滤镜,叠加在游玩内容之上,执行顺序按 zOrder() 升序
judgmentPolicies()Registry<JudgmentPolicy>判定窗口策略(毫秒),多项合并取「最宽松生效」
judgmentAdjusters()Registry<JudgmentAdjuster>判定结果改写钩子,按注册顺序链式串联(前一个输出作为后一个输入)
collectibles()Registry<CollectibleDefinition>收集品,注册后由宿主转换为内核模型再进入收集品系统;内核侧 id 取注册键的 path
skins()Registry<SkinProvider>音符皮肤,注册键即玩家侧皮肤 id(如 mymod:neon)
layerOverrides()Registry<LayerOverride>渲染层默认绘制替换声明(支持 BACKGROUND / FULLSCREEN / JUDGE_FX / HUD)
customRenderLayers()Registry<CustomRenderLayer>自定义渲染层(带小数层号,参与全局排序)
judgmentWindowSynthesizers()Registry<JudgmentWindowSynthesizer>判定窗口合成器,在「逐字段取最大」之后按注册顺序链式应用
trackPoseAdjusters()Registry<TrackPoseAdjuster>轨道区位姿调整器,链式可变传阅(见 7.6)
cameraPoseAdjusters()Registry<CameraPoseAdjuster>相机位姿调整器,链式语义同上(见 7.6)

Registry<T> 的共同语义(对每张表都成立):

java
<V extends T> RegistryKey<V> register(RegistryKey<V> key, V value);
<V extends T> Optional<V> get(RegistryKey<V> key);
Optional<T> get(ResourceLocation location);
boolean contains(ResourceLocation location);
Set<ResourceLocation> keys();
default Set<ResourceLocation> keysByNamespace(String namespace);
boolean isFrozen();

3.2 RegistryKey 与 ResourceLocation

RegistryKey<T> 由 ResourceLocation + 值类型构成,是注册内容的唯一身份(被注册的值对象不自带 id):

java
public static <T> RegistryKey<T> of(String namespace, String path, Class<T> type);
public static <T> RegistryKey<T> of(ResourceLocation location, Class<T> type);
public static <T> RegistryKey<T> parse(String id, Class<T> type);
public ResourceLocation location();
public Class<T> type();

ResourceLocation 是 namespace:path 形式的不可变资源标识,也是资源路径的统一身份:

java
public static ResourceLocation of(String namespace, String path);
public static ResourceLocation parse(String id);
public String namespace();
public String path();

格式约束(与 Minecraft 同口径,强制小写,因为资源路径会落到大小写不敏感的文件系统上):

构造即校验:非法格式在构造期就抛 IllegalArgumentException 并给出可读原因(「只允许 [...],长度 1~N,且必须小写」),使「注册了但 id 写错」在模组侧立刻可见,而不是静默失效。parse 对空串抛异常,含多个 : 也抛异常,不含 : 时使用默认命名空间 seecmd。of 会先 trim 输入。

3.3 注册自定义音符类型(接口契约)

一个音符类型自带「解析 + 判定 + 渲染」三件套:谱面加载时 deserialize,游玩中 onJudge,每帧 render。最小骨架就是三个覆写(片段,略去 import):

java
private static final RegistryKey<NoteTypeDefinition> TYPE =
        RegistryKey.of("example_mod", "stardust", NoteTypeDefinition.class);

// onPreInit 内:
ctx.registries().noteTypes().register(TYPE, new NoteTypeDefinition() {
    @Override public String displayName() { return "星尘"; }

    /** 谱面 JSON 的 type 字符串,注册期建立索引。 */
    @Override public String serializationKey() { return "stardust"; }

    @Override public NoteData deserialize(JsonObject json, NoteParseContext ctx) {
        StardustNote note = new StardustNote();
        note.setTime(json.get("time").getAsLong());
        note.setLane(json.get("lane").getAsInt());
        note.setEndTime(json.has("endTime") ? json.get("endTime").getAsLong() : null);
        return note;
    }

    /** 不产出判定 = 沿用宿主的按键候选与时间窗判定。 */
    @Override public void onJudge(JudgeInput input, JudgeSink sink) { }

    @Override public void render(RenderCanvas canvas, NoteRenderContext ctx) { /* 自绘 */ }
});

完整可运行示例(NoteData 子类、双击判定接管、谱面 JSON 写法、逐项检查点与常见坑)见 添加新音符类型。本节只保留接口契约与易错点,不重复那篇的完整工程。

几个必须注意的点:

实时预览实时预览:谱面 JSON 如何被音符类型注册表解析为运行时音符
预览加载中…
实时预览实时预览:连击行为 INCREMENT / RESET / NONE(点击徽章可编辑)
预览加载中…

3.4 谱面 JSON 里怎么写这个类型

音符数组里用 type 选中已注册的类型,其余字段交给你的 deserialize 自行解释:

json
{
  "notes": [
    { "type": "stardust", "time": 1200, "lane": 2 },
    { "type": "stardust", "time": 2400, "lane": 3, "endTime": 3000 }
  ]
}

两条约定:

3.5 可选能力:自主判定接管

默认情况下,你自己的音符类型与普通音符走同一套宿主时间窗口判定:音符到达判定线时宿主调用 onJudge(JudgeInput, JudgeSink),你在里面调用 sink.accept(kind, judgeTime) 产出结果;什么都不产出就等于「交给宿主按内置窗口判定」。

当你需要完全自定义匹配规则(连按 N 次、按住时长、按顺序连锁等)时,覆写 overridesJudgment() 返回 true,判定就交给你(以下为片段,略去 import):

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

@Override
public void onJudgeTick(long timeMs, JudgeInputState state, JudgeContext ctx, JudgeSink sink) {
    // myKind:模组持有的判定等级定义(经 judgmentKinds().get(key) 取出)
    for (Integer key : state.pressedKeys()) {          // 本帧新按下的物理键
        int lane = state.keyToLane(key);               // 未绑定轨道的键返回 -1
        if (lane < 0) {
            continue;
        }
        for (NoteView note : ctx.notes()) {            // 本类型待判定音符(按时间升序的快照)
            if (note.lane() == lane && Math.abs(timeMs - note.time()) <= 80) {
                sink.accept(myKind, timeMs, note);     // 三参数版本:对指定音符产出判定
                break;
            }
        }
    }
}

语义要点(来自 NoteTypeDefinition 的接口说明):

3.6 自定义判定等级

判定等级定义决定分数权重、准度权重、combo 行为与判定特效的渲染颜色。一个自定义等级就是一次注册(判定等级同样在 onPreInit 注册,以下为片段,略去 import):

java
private static final RegistryKey<JudgmentKindDefinition> GOOD_KIND =
        RegistryKey.of("example_mod", "good", JudgmentKindDefinition.class);

// onPreInit 内:
ctx.registries().judgmentKinds().register(GOOD_KIND, new JudgmentKindDefinition() {
    @Override public String displayName() { return "GOOD"; }
    @Override public int scoreValue() { return 50; }          // 相对权重,PERFECT = 100 基准
    @Override public double accuracyWeight() { return 0.5; }  // 准度权重 0~1
    @Override public Color color() { return new Color(120, 220, 120); }
    @Override public ComboBehavior comboBehavior() { return ComboBehavior.INCREMENT; }
});

ComboBehavior 三个取值:INCREMENT(combo 递增)、RESET(combo 归零)、NONE(combo 不变)。还有可选的 render(RenderCanvas canvas, JudgedNoteRenderContext ctx),默认空实现(不绘制),覆写后在判定特效层自绘该等级的特效文字。

注册好之后,在你的音符类型 onJudge 里经判定等级注册表 get(key) 取出定义,再 sink.accept(定义, 判定时间)(二参)即可让该次判定走你的等级。注意 onJudgeTick 路径必须用三参 sink.accept(定义, 判定时间, note):二参产出无法定位音符,会被宿主丢弃(见 3.5)。

只注册等级并不会让它自动出现:还需要判定窗口策略与调整器把结果改写过去。完整做法见 添加判定等级。

实时预览实时预览:判定窗口可视化(默认 vs 宽松 ×1.5,可拖拽指针)
预览加载中…

3.7 自定义游戏模式

游戏模式把核心循环的控制权交给当前激活模式的定义对象,所有回调默认是安全空操作(以下为片段,略去 import):

java
private static final RegistryKey<GameModeDefinition> MIRROR_MODE =
        RegistryKey.of("example_mod", "mirror", GameModeDefinition.class);

// onPreInit 内:
ctx.registries().gameModes().register(MIRROR_MODE, new GameModeDefinition() {
    @Override public String displayName() { return "镜像"; }

    @Override public int mapLane(int lane, int laneCount) { return laneCount - 1 - lane; }

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

    @Override public void onEnter(GameModeContext ctx) { /* 进入模式 */ }

    @Override public void onUpdate(long musicTimeMs, GameModeContext ctx) { /* 每帧更新 */ }

    @Override public void renderOverlay(RenderCanvas canvas, GameModeContext ctx) { /* 全屏覆盖 */ }
});

完整走查(模式如何出现在选曲界面、镜像渲染与水印、与按键映射的分工)见 添加游戏模式。

3.8 重复注册与冻结后注册

这两种错误都是明确失败,不会被静默忽略:

由此得到的直接后果是「玩家启用 / 停用模组需要重启才生效」:注册在进程生命周期内一次性完成、不再撤销。