开发指南
事件订阅
模组与宿主之间的“发生了什么”全部经事件总线获得。当前版本把游玩事件与跨场景事件合并到同一条总线上,模组不再需要在多条总线之间做选择,也不需要任何 per-模组的订阅注册回调——在 onInit 里直接订阅即可。
4.1 一条总线
EventBus 是全进程唯一的事件总线,模组在 onInit 中经 ModContext#eventBus() 取得:
- 游玩事件(判定、连击中断、暂停/恢复、游玩帧、游玩输入、轨道渲染等)与跨场景事件(场景切换、启动/关闭、音效播放、设置变更、每帧 tick、资源重载等)都在这一条总线上,模组用事件类型区分自己关心哪一类。
ctx.eventBus()返回的实例带模组归属:模组经它建立的全部订阅,在模组停用/卸载时由宿主一次性自动退订,模组不需要自己记账。subscribe返回Subscription令牌,需要提前退订时调用unsubscribe()(幂等,重复调用无副作用)。
订阅与投递的接口声明:
java
<E extends Event> Subscription subscribe(Class<E> type, EventPriority priority, Consumer<E> listener);
<E extends Event> Subscription subscribe(Class<E> type, Consumer<E> listener);
<E extends Event> E post(E event);单参数重载等价于使用 EventPriority.NORMAL。同一个监听器实例可以重复注册,每次注册各产生一个独立订阅,各自用返回的 Subscription 退订。
4.2 分发语义
以下契约来自 EventBus 的接口说明,是模组写监听器时必须遵守的前提:
- 同步投递:
post(Event)在调用线程内当场完成全部分发并返回事件本身。没有异步队列,也没有延迟投递。 - 匹配规则:按
订阅类型.isInstance(事件)匹配。订阅Event可捕获全部事件;订阅父类可收到其子类(例如订阅TickEvent会同时收到TickEvent.Pre与TickEvent.Post,订阅GameplayTickEvent同理)。 - 顺序:先按
EventPriority降序(HIGHEST最先收到),同一优先级内按注册顺序(稳定序,可复现)。 - 短路条件:仅当事件可取消、且已有结论(结果不是
DEFAULT)时,停止通知后续监听器。不可取消事件永远通知全部监听器。 - 异常隔离:每个监听器独立
try/catch(Throwable)。单个监听器抛异常只记日志,不影响其它监听器,也绝不冒泡到宿主循环——一个模组崩不了游戏。 - 零订阅零开销:完全无订阅时
post立即返回(不分配、不遍历)。因此模组可以在需要时随时订阅,不必为“多加一个监听器”担心空闲成本。 - 线程约定:
subscribe/unsubscribe任意线程可调用(内部加锁);post在调用线程同步分发,宿主在游戏线程 / 渲染线程投递,因此监听器回调就在该线程执行。监听器禁止在回调里做阻塞 IO(网络、磁盘、future.get()等),需要耗时工作请交给自己的线程或宿主提供的线程池。
事件实例的生命周期不属于契约:当前宿主对 TickEvent / GameplayTickEvent 等每帧事件逐帧新建实例,但接口并不保证「每次收到的是新对象」。监听器不得把事件对象存起来跨帧使用——需要的数据请当场读出。
4.3 优先级与结果
EventPriority 的枚举声明顺序即执行顺序,同一优先级内按注册顺序:
| 优先级 | 相对次序 | 用途 |
|---|---|---|
HIGHEST | 最先收到事件 | 抢先否决或强行放行 |
HIGH | 较早收到事件 | 需要压过默认优先级意见的干预 |
NORMAL | 默认优先级 | 单参数 subscribe 走此级 |
LOW | 较晚收到事件 | 常规观察 |
LOWEST | 最后收到事件 | 纯观察 / 记录(看到其它监听器处理后的最终状态) |
EventResult 的取值与总线行为:
| 结果 | 总线是否继续通知后续监听器 | 典型用途 |
|---|---|---|
DEFAULT | 继续 | 只看不改(观察 / 记录) |
ALLOW | 停止 | 高优先级强制放行,覆盖低优先级的否决意图 |
DENY | 停止 | 否决本次默认动作(吞输入 / 吞判定 / 否决切场等),仅对可取消事件有意义 |
干预方式与边界:
- 可取消性由事件在构造期声明,之后不可变。事件基类为
Event,其子类经protected Event(boolean cancellable)声明;isCancellable()的返回值构造时定死。CancellableEvent是“等价于extends Event+ 构造传true”的可取消事件基类,见到某个事件继承它,即知setResult有效且总线会按结果短路。 - 干预用
setResult(EventResult.DENY)或setResult(EventResult.ALLOW);参数为null时视为DEFAULT。 - 对不可取消事件调用
setResult是无操作(结果保持DEFAULT)——纯通知事件不可能靠它让宿主跳过出帧或跳过默认动作。 - 读回结果:
result()返回当前结果;isDenied()/isAllowed()/isDecided()(结果非DEFAULT,即已有监听器干预)为只读判断。宿主实际按isDenied()(部分投递点还看改道)决定是否走非默认分支,总线则以isDecided()作为短路判据。
注意访问器命名冲突:某些事件的业务结果访问器不叫 result(),例如 NoteJudgedEvent#judgeResult()——基类的 result() 是 final,承载总线结果语义,子类无法覆盖。
4.4 事件总表
| 事件类 | 包 | 是否可取消 | 宿主投递时机 | 监听器可读的访问器 |
|---|---|---|---|---|
TickEvent.Pre / TickEvent.Post | com.seecmd.mod.api.event | 否 | 宿主主循环每帧投递一次;Pre 在本帧逻辑更新之前,Post 在其之后、渲染之前 | deltaSeconds() |
SceneChangeEvent.Pre | com.seecmd.mod.api.event | 是 | 场景切换的决议阶段(历史栈尚未改动) | from()、to()、kind()、params()、isRedirected()、redirectTarget()、redirectParams() |
SceneChangeEvent.Post | com.seecmd.mod.api.event | 否 | 场景切换完成之后(新场景已进入) | from()、to()(均为 SceneInfo) |
SoundPlayEvent | com.seecmd.mod.api.event | 否 | 宿主播放了一个音效时(纯通知) | soundId()、path()、category() |
SettingsChangedEvent | com.seecmd.mod.api.event | 否 | 宿主保存设置后投递(纯通知) | changedKeys() |
GameStartupEvent | com.seecmd.mod.api.event | 否 | 契约:装配结束、进入主循环之前;当前宿主源码未见投递点 | 无(纯通知) |
GameShutdownEvent | com.seecmd.mod.api.event | 否 | 契约:释放资源之前;当前宿主源码未见投递点 | 无(纯通知) |
GameStartEvent | com.seecmd.mod.api.event.gameplay | 否 | 一局游玩开始时(谱面加载完成、音乐启动时)投递一次 | context() |
GameEndEvent | com.seecmd.mod.api.event.gameplay | 否 | 一局游玩结束时(正常结束 / 重试 / 退出 / 进入结算)投递一次 | context() |
ChartLoadEvent | com.seecmd.mod.api.event.gameplay | 否 | 谱面解析完成、进入可游玩状态时投递 | context() |
GameplayTickEvent.Pre / GameplayTickEvent.Post | com.seecmd.mod.api.event.gameplay | 否 | 每个游玩帧各投递一次;Pre 在本帧游玩逻辑更新之前,Post 在其之后(倒带重演窗口内照常投递) | musicTimeMs()、rewinding()、deltaSeconds()、stepIndex() |
NoteJudgedEvent | com.seecmd.mod.api.event.gameplay | 否 | 一个音符完成判定时(含长音符结尾判定),且判定已经落地 | judgeResult()、context() |
ComboBreakEvent | com.seecmd.mod.api.event.gameplay | 否 | 连击被打断时(本次 MISS 导致 combo 归零),且打断已经发生 | context() |
PauseEvent | com.seecmd.mod.api.event.gameplay | 是 | 暂停请求受理时(宿主改状态之前,可否决) | context() |
ResumeEvent | com.seecmd.mod.api.event.gameplay | 是 | 恢复请求受理时(宿主改状态之前,可否决) | context() |
KeyEvent | com.seecmd.mod.api.event.input | 是 | 全局按键分发时(按下 / 抬起 / 长按) | keyCode()、type() |
MouseEvent | com.seecmd.mod.api.event.input | 是 | 契约:场景级鼠标分发时;当前宿主未投递(鼠标请用 Scene#handleInput) | type()、x()、y()、button() |
LaneInputEvent.Pre | com.seecmd.mod.api.event.input | 是 | 本局游玩期间玩家按键作用于轨道、且按键尚未被映射到轨道 / 判定之前 | lane()、keyCode()、musicTimeMs() |
LaneInputEvent.Post | com.seecmd.mod.api.event.input | 否 | 本局游玩期间输入已被消费之后(观察点) | lane()、keyCode()、musicTimeMs() |
RenderEvent.Pre / RenderEvent.Post | com.seecmd.mod.api.event.render | 否 | 宿主在每个渲染层插槽的前后各投递一次;共 7 层,每帧 14 次 | layer()、context() |
TrackRenderEvent.Pre / TrackRenderEvent.Post | com.seecmd.mod.api.event.render | 否 | 宿主在轨道渲染器的入口 / 出口各投递一次(只在游玩场景投递) | context()、musicTimeMs()、scrollSpeed()、visibleNoteCount() |
ResourceReloadEvent | com.seecmd.mod.api.event.resource | 否 | 宿主真实切换语言并完成重载时投递 LANGUAGE(纯通知);RESOURCES 当前无发布点 | kind()、detail() |
补充几点使用要点:
- 可取消事件上干预的“默认动作”具体包含什么由宿主实现定义:例如
PauseEvent置DENY表示跳过本次暂停的默认动作,LaneInputEvent.Pre置DENY表示宿主吞掉该轨道上的这次输入(同一按键映射到的其它轨道不受影响)。注意KeyEvent的转播桥只转译、不读取结果,DENY目前不会改变按键分发;MouseEvent当前根本未投递——需要拦截按键请用LaneInputEvent.Pre或全局快捷键能力。SceneChangeEvent.Pre还提供redirect(target)改道能力(改道后的目标不再二次征询);只观察请订阅SceneChangeEvent.Post。 - 想干预判定结果,不要指望
NoteJudgedEvent(它投递时判定已经落地),应改用判定策略 / 判定调整器或自定义判定等级。 - 玩家可见、需要改键的全局快捷键应经
ctx.hotkeys()注册,不要用KeyEvent实现玩家无法改键的隐性快捷键。 TickEvent/GameplayTickEvent/RenderEvent都是高频事件,监听器必须保持极轻:禁止分配大对象、禁止阻塞 IO、禁止在渲染回调里做文件或网络访问。- 上表只列常用事件; / 3.x 另有
BeatEvent、RewindEvent、GhostHitEvent、HitEffectLifecycleEvent、PreviewSeekEvent、SceneUnregisteredEvent(根包)与host包的HostCommandEvent/HostGameResultEvent/HostAudioSpeedEvent,完整时机与访问器见 API 参考。
实时预览实时预览:一场游玩中模组订阅到的事件序列与数据
预览加载中…
4.5 订阅与退订
- 幂等:
unsubscribe()重复调用无副作用;调用后该监听器不再收到事件。 - 强引用语义:总线对监听器持有强引用(不依赖 GC 回收)。因此模组必须在
onUnload释放自己显式建立的长生命周期订阅,否则监听器会持续被调用。 - 模组停用由宿主统一退订:经
ModContext#eventBus()建立的订阅带模组归属,模组停用 / 卸载时宿主会一次性清理该模组的全部订阅,模组无需自己记账。 - 模组自建的临时订阅应自己退订:只为某一段流程建立的订阅(例如“只在下一局开始前收集一次信息”),应在不再需要时立即
unsubscribe()。
4.6 完整示例:订阅开局与判定事件
下面的模组订阅 2 个游玩事件,在回调里读取 GameplayContext 只读快照,并在 onUnload 显式退订自建订阅。
java
package com.example.eventmod;
import com.seecmd.mod.api.Mod;
import com.seecmd.mod.api.ModContext;
import com.seecmd.mod.api.ModInfo;
import com.seecmd.mod.api.context.GameplayContext;
import com.seecmd.mod.api.event.EventBus;
import com.seecmd.mod.api.event.EventPriority;
import com.seecmd.mod.api.event.Subscription;
import com.seecmd.mod.api.event.gameplay.GameStartEvent;
import com.seecmd.mod.api.event.gameplay.NoteJudgedEvent;
import java.util.List;
/**
* 事件订阅示例:一条总线,按事件类型区分。
*
* <p>注意所有监听器都在游戏线程被同步调用(事件由宿主投递),
* 因此回调里只做轻量累加与读取,不做阻塞 IO。
*/
public final class EventExampleMod implements Mod {
private static final String MOD_ID = "example_events";
/** 自建的长生命周期订阅:onUnload 时显式退订。 */
private Subscription judgedSubscription;
/** 本局已判定音符数。 */
private int judgedInThisRun;
/** 本局峰值连击。 */
private int peakCombo;
@Override
public ModInfo info() {
return ModInfo.of(MOD_ID, "Event Example", "1.0.0", List.of("YourName"),
"演示在同一总线上订阅游玩事件、读取 GameplayContext 快照与退订");
}
@Override
public void onInit(ModContext ctx) {
EventBus bus = ctx.eventBus();
// 订阅 1:开局(记下本局基准信息)。
// 单参数重载 = EventPriority.NORMAL;返回的 Subscription 不需要时可暂存。
bus.subscribe(GameStartEvent.class, event -> {
judgedInThisRun = 0;
peakCombo = 0;
// 事件负载是只读快照,读它不会改变游戏状态。
GameplayContext snapshot = event.context();
ctx.getLogger().info("本局开始:" + snapshot.chartTitle()
+ "(难度 " + snapshot.chartDifficulty()
+ ",总音符 " + snapshot.totalNoteCount() + ")");
});
// 订阅 2:音符判定完成(观察型,放 LOW,让干预型监听器先表态)。
judgedSubscription = bus.subscribe(NoteJudgedEvent.class, EventPriority.LOW, event -> {
judgedInThisRun++;
GameplayContext snapshot = event.context();
peakCombo = Math.max(peakCombo, snapshot.maxCombo());
if (judgedInThisRun % 25 == 0) {
ctx.getLogger().info("已判定 " + judgedInThisRun
+ ",combo=" + snapshot.combo()
+ ",峰值=" + peakCombo
+ ",accuracy=" + snapshot.accuracy());
}
});
}
@Override
public void onUnload() {
// 显式释放自建的长生命周期订阅(幂等,重复调用无副作用)。
// 模组经 ctx.eventBus() 建立的订阅也会由宿主在停用时统一清理,
// 这里主动退订是为了让释放时机可控、不依赖停用流程。
if (judgedSubscription != null) {
judgedSubscription.unsubscribe();
judgedSubscription = null;
}
}
}