添加新音符类型
一种只在谱面里写 `"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)才能定位具体音符,接管判定时必须用三参。
一条音符从谱面到判定线
前置条件
- 已经能跑通一个最小模组(见 第一个模组)。
- 工程依赖
mod-api与 gson,JDK 17 或更高。 - 有一份可以直接改 JSON 的谱面(游戏目录下
data/charts/内),或者用制谱器导出一份再手改。
每个音符自己的私有字段(本篇的 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。所以不要让它依赖任何运行时状态。
宿主会把该音符的完整 JSON 对象交给你,包括它不认识的字段。解析时先取 time 与 lane,再把自己的私有字段挂到 extensionData:
@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 可写,之后立刻冻结;再写会抛 RegistryFrozenException。注册键的命名空间必须等于模组 id,宿主会在写入口先做归属校验:
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() 本身,不加命名空间前缀:
{
"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 给的布局坐标画,默认就会跟随轨道几何:
@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,你在里面只能换判定等级,不能改变「是否命中」。双击需要「同一音符吃两次输入」,所以必须声明接管:
@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(入口与注册):
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(定义、解析、判定、渲染与运行时音符):
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; }
}
}接下来
- 想把定义对象上每个回调的边界读全:NoteTypeDefinition。
- 想在谱面里放整张谱面级别的配置段(主题色、自定义速度):ChartExtensionParser。
- 想让双击命中时爆出自定义演出:自定义打击特效。
- 想确认注册表还能放什么、冻结规则是什么:注册自有内容。