自定义打击特效
把默认的判定文字换成自己的一套命中特效(先文字、再图片动画),并能按判定等级区分、在运行时改时长或取消。
游戏自带的打击反馈只有一行小字。想做「PERFECT 爆出金色火花」「MISS 屏幕震一下」这类演出,不需要改渲染管线,也不用重画整个判定层——你只要回答宿主一个问题:这次打击该长什么样。
每个音符被判定的那一刻,宿主会挨个问已注册的「特效提供者」。你把答案包成 HitEffectStyle 返回,位置、缩放、淡出、层级都由宿主负责。
- 打击特效(hit effect):音符命中或漏掉时,在判定线上闪一下的视觉反馈。
- 样式(
HitEffectStyle):一份「怎么画」的描述——文字还是图片序列、持续多久、多大、是否随轨道旋转。 - 提供者(
HitEffectStyleProvider):你的回调对象。宿主每次打击调用一次getStyle(ctx),你返回样式或null。
前置条件
- 已经能跑通一个最小模组(见 第一个模组)。
- 工程已引入
mod-api-3.2.jar与 gson。
本篇只用到 3 个类型:HitEffectStyle、HitEffectStyles、HitEffectStyleProvider。每处出现都会解释,不必提前翻参考页。
运行流程
开始动手
先做文字版可以跳过。要做图片动画,就在工程里放一张图:
src/main/resources/assets/mymod/textures/spark.png资源路径规则是 assets/<模组id>/<path>,因此加载时用的资源标识是 mymod:textures/spark.png。
新建一个类实现 HitEffectStyleProvider。它只有一个方法 getStyle(HitEffectContext ctx):
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——这是宿主唯一开放注册表的时机:
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 里加载图片(此时注册表已冻结,但资源随时可读),构造图片序列样式:
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());
}
}参数依次是:帧列表、总时长(毫秒)、是否循环、缩放倍率。绘制尺寸 = 图片像素尺寸 × 缩放倍率,居中锚定在轨道与判定线上。
再让提供者优先返回它,缺失时退回文字版:
private HitEffectStyle styleFor(HitEffectContext c) {
if (!"PERFECT".equals(c.grade().displayName())) {
return null;
}
return imageFx != null ? imageFx : fallbackText;
}完整代码
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 的样式会同时生效并按注册顺序依次绘制。你加火花、别人加拖尾,两者都会显示——不需要协商优先级。
进阶:运行时改时长或取消
需要精细控制时,可以从游玩上下文拿到「活动特效句柄」:
@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。 - 图片缺失时有回退,日志能定位到资源标识。
接下来
- 想让特效随倾斜的轨道一起转:看 HitEffectStyle 的
followLaneRotation()。 - 想用一张精灵图切帧:看 HitEffectStyles 的
imageSheet(...)。 - 想在特效出现/消失时做别的事(计数、联动 BGM):看 HitEffectLifecycleEvent。
- 想整层自己画而不是只换打击反馈:看 RenderCanvas。