Theseus 模组文档
教程

自定义打击特效

把默认的判定文字换成自己的一套命中特效(先文字、再图片动画),并能按判定等级区分、在运行时改时长或取消。


为什么需要这个

游戏自带的打击反馈只有一行小字。想做「PERFECT 爆出金色火花」「MISS 屏幕震一下」这类演出,不需要改渲染管线,也不用重画整个判定层——你只要回答宿主一个问题:这次打击该长什么样。

每个音符被判定的那一刻,宿主会挨个问已注册的「特效提供者」。你把答案包成 HitEffectStyle 返回,位置、缩放、淡出、层级都由宿主负责。

先认识三个词
  • 打击特效(hit effect):音符命中或漏掉时,在判定线上闪一下的视觉反馈。
  • 样式(HitEffectStyle):一份「怎么画」的描述——文字还是图片序列、持续多久、多大、是否随轨道旋转。
  • 提供者(HitEffectStyleProvider):你的回调对象。宿主每次打击调用一次 getStyle(ctx),你返回样式或 null。

前置条件

不用先读 API 参考

本篇只用到 3 个类型:HitEffectStyle、HitEffectStyles、HitEffectStyleProvider。每处出现都会解释,不必提前翻参考页。

运行流程

音符被判定 PERFECT / GREAT … 宿主遍历提供者 按注册顺序调用 收集非 null 样式 可多个同时生效 每帧绘制并衰减 按强度淡出 你的代码只负责「第 3 步」:实现提供者并注册。 位置锚定在轨道中心与判定线上,无需自己算坐标。
一次打击的处理顺序:宿主发起 → 问你的提供者 → 合并样式 → 逐帧绘制

开始动手

准备一张图(可选)

先做文字版可以跳过。要做图片动画,就在工程里放一张图:

text
src/main/resources/assets/mymod/textures/spark.png

资源路径规则是 assets/<模组id>/<path>,因此加载时用的资源标识是 mymod:textures/spark.png。

写提供者

新建一个类实现 HitEffectStyleProvider。它只有一个方法 getStyle(HitEffectContext ctx):

java
package com.example.mymod;

import com.seecmd.mod.api.render.HitEffectContext;
import com.seecmd.mod.api.render.HitEffectStyle;
import com.seecmd.mod.api.render.HitEffectStyleProvider;
import com.seecmd.mod.api.render.HitEffectStyles;

public final class MyHitEffects implements HitEffectStyleProvider {

    @Override
    public HitEffectStyle getStyle(HitEffectContext ctx) {
        // 只接管 PERFECT,其它判定交回宿主默认表现
        if (!"PERFECT".equals(ctx.grade().displayName())) {
            return null;
        }
        return HitEffectStyles.textByIntensity(
                new String[] { "PERFECT!", "Perfect", "" },
                450L);   // 持续 450ms
    }
}

textByIntensity 的字符串数组按强度取词:强度 1.0 用第 0 个,强度 0 用最后一个,中间线性插值。

在注册窗口里登记

注册必须发生在 onPreInit——这是宿主唯一开放注册表的时机:

java
package com.example.mymod;

import com.seecmd.mod.api.GameplayMod;
import com.seecmd.mod.api.ModContext;
import com.seecmd.mod.api.ModInfo;
import com.seecmd.mod.api.registry.RegistryKey;
import com.seecmd.mod.api.render.HitEffectStyleProvider;

import java.util.List;

public final class MyMod implements GameplayMod {

    @Override
    public ModInfo info() {
        return ModInfo.of("mymod", "我的特效", "1.0.0", List.of("me"), "自定义打击特效");
    }

    @Override
    public void onPreInit(ModContext ctx) {
        ctx.registries().hitEffectStyles().register(
                RegistryKey.of("mymod", "hit_effects", HitEffectStyleProvider.class),
                new MyHitEffects());
    }
}

RegistryKey.of(命名空间, 路径, 类型) 里,命名空间必须等于模组 id,否则注册会被拒绝。

打包进游戏,对照检查点

打入游戏后连打几个 PERFECT,确认:

  • 命中 PERFECT 时出现自己的文字,不再是默认小字;
  • 文字在轨道中心、判定线略上方——你不需要写坐标;
  • 打 GREAT / MISS 时仍是宿主默认表现;
  • 游戏其它表现完全不变。
为什么你不需要写坐标

你的 HitEffectStyle 里只有一个「持续多久、多大、长什么样」,没有 x、y。

因为位置不是样式的一部分,是宿主的事:特效的锚点固定在轨道中心与判定线上,宿主在每次打击时按当时生效的轨道数量、轨道宽度、轨道位姿换算成屏幕坐标。你换首歌、改轨道数、甚至轨道倾斜,锚点都跟着走,不需要你重新算。

这条分工贯穿整个渲染面:你描述「是什么」,宿主决定「在哪、什么时候」。这也是为什么 HitEffectStyle.scale() 放大的是图片而不是坐标。

如果这一步没效果,按顺序排查
  • 模组列表里状态是否 ACTIVE(不是 ACTIVE 就是加载失败,先去看加载原因);
  • 判定等级名是否真的是 PERFECT(自定义判定等级会取自己的 displayName);
  • 注册是否写在 onPreInit 而不是 onInit。
升级成图片动画

在 onInit 里加载图片(此时注册表已冻结,但资源随时可读),构造图片序列样式:

java
private RenderImage spark;
private HitEffectStyle imageFx;

@Override
public void onInit(ModContext ctx) {
    try {
        spark = ctx.resources().loadImage(
                ResourceLocation.of("mymod", "textures/spark.png"));
        imageFx = HitEffectStyles.imageSequenceByDuration(
                List.of(spark), 400L, false, 1.5f);
    } catch (ResourceNotFoundException e) {
        ctx.getLogger().warning("缺少 spark.png,回退文字特效: " + e.location());
    }
}

参数依次是:帧列表、总时长(毫秒)、是否循环、缩放倍率。绘制尺寸 = 图片像素尺寸 × 缩放倍率,居中锚定在轨道与判定线上。

再让提供者优先返回它,缺失时退回文字版:

java
private HitEffectStyle styleFor(HitEffectContext c) {
    if (!"PERFECT".equals(c.grade().displayName())) {
        return null;
    }
    return imageFx != null ? imageFx : fallbackText;
}

完整代码

你的模组 onPreInit → 注册提供者 onInit → 加载图片 / 建样式 getStyle → 按判定等级返回 样式来源 文字:text / textByIntensity 图片:imageSequence / imageSheet 宿主 合并全部非 null 样式 各自按 durationMs 衰减 运行时(可选) activeHitEffects() 取句柄 setDurationMs / setLane / cancel HitEffectLifecycleEvent 监听消亡 玩家看到 判定线上方的特效 按强度淡出 可选:随轨道旋转 (followLaneRotation)
样式由你描述,生命周期由宿主管理;需要精细控制时再取运行时句柄
java
package com.example.mymod;

import com.seecmd.mod.api.GameplayMod;
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.render.HitEffectContext;
import com.seecmd.mod.api.render.HitEffectStyle;
import com.seecmd.mod.api.render.HitEffectStyleProvider;
import com.seecmd.mod.api.render.HitEffectStyles;
import com.seecmd.mod.api.render.RenderImage;
import com.seecmd.mod.api.resource.ResourceNotFoundException;

import java.util.List;

public final class MyMod implements GameplayMod {

    private final HitEffectStyle fallbackText =
            HitEffectStyles.textByIntensity(new String[] { "PERFECT!", "Perfect", "" }, 450L);

    private HitEffectStyle imageFx;

    @Override
    public ModInfo info() {
        return ModInfo.of("mymod", "我的特效", "1.0.0", List.of("me"), "自定义打击特效");
    }

    @Override
    public void onPreInit(ModContext ctx) {
        ctx.registries().hitEffectStyles().register(
                RegistryKey.of("mymod", "hit_effects", HitEffectStyleProvider.class),
                this::styleFor);
    }

    @Override
    public void onInit(ModContext ctx) {
        try {
            RenderImage spark = ctx.resources().loadImage(
                    ResourceLocation.of("mymod", "textures/spark.png"));
            imageFx = HitEffectStyles.imageSequenceByDuration(
                    List.of(spark), 400L, false, 1.5f);
        } catch (ResourceNotFoundException e) {
            ctx.getLogger().warning("缺少 spark.png,回退文字特效: " + e.location());
        }
    }

    private HitEffectStyle styleFor(HitEffectContext c) {
        if (!"PERFECT".equals(c.grade().displayName())) {
            return null;
        }
        return imageFx != null ? imageFx : fallbackText;
    }
}
多个模组不会互相顶掉

同一打击点上,所有返回非 null 的样式会同时生效并按注册顺序依次绘制。你加火花、别人加拖尾,两者都会显示——不需要协商优先级。

进阶:运行时改时长或取消

需要精细控制时,可以从游玩上下文拿到「活动特效句柄」:

java
@Override
public void onInit(ModContext ctx) {
    ctx.eventBus().subscribe(GameplayTickEvent.Post.class, event -> {
        for (ActiveHitEffect fx : event.context().activeHitEffects()) {
            if (fx.durationMs() < 80L) {
                fx.setDurationMs(80L);   // 太短的特效拉长一点
            }
        }
    });
}
运行时控制的两个代价
  • activeHitEffects() 每次调用都会收集并新建句柄,不要每帧轮询;上面的写法只是演示 API,实际建议加节流或只在特定事件里查。
  • 句柄只在本次游玩有效,不要跨局缓存;场景退出后全部失效。

常见坑

这几个地方最容易踩
  • 在 onInit 里注册:注册表在 onPreInit 之后立即冻结,再写会抛 RegistryFrozenException。看到它就是注册写晚了。
  • RegistryKey 用 new 构造:它和 ResourceLocation 的构造器都是私有的,必须用 of(...) / parse(...)。
  • 以为 scale 能放大文字:文字样式的 scale 当前不生效,字号由宿主固定;要更大就用图片序列或自己绘制。
  • 在 getStyle 里读文件或解码图片:它每命中一次调用一次,IO 会直接卡判定帧。图片在 onInit 加载好,这里只返回缓存对象。
  • 不接管时返回了空样式:应该返回 null。返回空样式会顶掉宿主默认表现,看起来像「打击反馈消失了」。
交付前自查
  • 注册发生在 onPreInit。
  • RegistryKey 的命名空间等于模组 id。
  • getStyle 内没有 IO、没有大对象分配。
  • 不接管的情况返回 null。
  • 图片缺失时有回退,日志能定位到资源标识。

接下来