订阅游玩事件
一个模组,在音符判定、连击中断、每帧和按键时打日志,并能真的吞掉某一条轨道的按键。
前面几篇都是宿主要求你「提供」一个东西:HUD、特效、判定等级。这一篇反过来——游戏里发生了什么,宿主主动通知你。
你不需要每帧去猜「刚刚有没有 MISS」,也不用碰判定内部。宿主在关键节点投递一个个带类型的事件,你只订阅自己关心的那几种,其余一概不关心。
订阅是一次性的初始化代码,写完就不用管;之后每局游玩,回调会自己到。这是模组里最省事、也最常用的一种「参与游戏」的方式。
- 事件(
Event):宿主在某个时刻发生的一件事,对象里带着当时的只读数据。 - 事件总线(
EventBus):分发中心。全进程只有一条,用ctx.eventBus()拿到属于你模组的那份视图。 - 监听器(
Consumer<E>):你传给总线的回调,参数就是事件对象。一个事件可以有多个监听器。 - 订阅令牌(
Subscription):subscribe的返回值。留着它就能随时退订。 - 优先级(
EventPriority):决定同一个事件谁先收到,从HIGHEST到LOWEST。
前置条件
- 已经能构建并加载一个最小模组(见 第一个模组)。
- 工程已引入
mod-api-3.2.jar与 gson。
本篇只用到 EventBus、EventPriority、EventResult、Subscription 和四个事件类。每个出现时都会解释它是什么。
运行流程
开始动手
总线不是自己 new 出来的,从模组上下文里取:
EventBus bus = ctx.eventBus();放在 onInit 里。onPreInit 是写注册表的窗口,订阅事件不需要那么早;而 onInit 时注册表已冻结、总线已就绪,正合适。
返回的 bus 已经绑定了你的模组:通过它建立的订阅都记在你名下,模组停用或卸载时宿主会一次性退掉,不要求你自己记账。
先订阅最有用的一种——一个音符刚判完。三个参数依次是:事件类型、优先级、回调。
bus.subscribe(NoteJudgedEvent.class, EventPriority.LOW, event -> {
ctx.getLogger().info("判定 " + event.judgeResult().judgment().displayName()
+ " 时间=" + event.judgeResult().judgeTime() + "ms"
+ " 长尾=" + event.judgeResult().isHoldEnd()
+ " combo=" + event.context().combo());
});NoteJudgedEvent 有两个访问器:
| 方法 | 含义 |
|---|---|
judgeResult() | 本次判定结果,可读 judgment()(判定等级定义)、judgeTime()(毫秒)、isHoldEnd()(是否长音符结尾) |
context() | 判定之后的游玩状态快照,combo() / score() 已经包含这一次 |
注意方法名是 judgeResult() 而不是 result():基类的 result() 是总线的处理结论,NoteJudgedEvent 没法覆盖它。
优先级先用 LOW:这是纯观察,让想干预的监听器先说话。
连击被打断时宿主投递 ComboBreakEvent,只有 context() 一个访问器:
bus.subscribe(ComboBreakEvent.class, EventPriority.LOW, event -> {
ctx.getLogger().info("连击中断 combo=" + event.context().combo()
+ " miss=" + event.context().misses());
});投递时打断已经发生,combo() 就是 0,不要试图回滚。
每帧事件是 GameplayTickEvent 的 Pre / Post。它每帧都来,所以日志必须节流:
private long nextTickLogMs;
bus.subscribe(GameplayTickEvent.Post.class, EventPriority.LOWEST, event -> {
if (event.rewinding()) {
return; // 倒带重演窗口内时间会倒退,跳过
}
if (event.musicTimeMs() < nextTickLogMs) {
return;
}
nextTickLogMs = event.musicTimeMs() + 1000L;
ctx.getLogger().info("tick t=" + event.musicTimeMs()
+ "ms step=" + event.stepIndex());
});GameplayTickEvent 可读 musicTimeMs()、rewinding()、deltaSeconds()、stepIndex()。Post 在本帧游玩逻辑更新之后投递,适合做「本帧已经处理完」的统计。
按键有两层。先看真正能拦截的那层——LaneInputEvent.Pre,它在按键被映射到轨道之后、判定之前投递:
bus.subscribe(LaneInputEvent.Pre.class, EventPriority.HIGHEST, event -> {
if (event.lane() == 3) {
event.setResult(EventResult.DENY); // 4 号轨的这次输入不判定
}
});DENY 只跳过这一条轨道的这次输入,不会影响同一个按键映射到的其它轨道;被吞掉的这次也不会再投递该轨道的 Post。这是游玩里真正能「取消按键」的位置。
全局按键则用 KeyEvent,它带 keyCode() 和 type():
bus.subscribe(KeyEvent.class, EventPriority.HIGHEST, event -> {
if (event.type() == InputType.KEY_PRESSED && event.keyCode() == 0x70) {
event.setResult(EventResult.DENY); // 语义上表示吞掉,但当前宿主不读这个结果
}
});KeyEvent 在类型上继承 CancellableEvent,契约是「DENY = 宿主吞掉这次按键」。但当前宿主的转播桥只把按键转译并投递,不读取结果,所以这里的 DENY 实际上不会改变分发。要真拦截,用上面的 LaneInputEvent.Pre;要做玩家可改键的快捷键,用 ctx.hotkeys()。
另外,别去找 cancel() 方法:CancellableEvent 没有 cancel(),唯一的取消写法就是 setResult(EventResult.DENY)。
subscribe 的三个参数版允许指定优先级;两个参数版等价于 EventPriority.NORMAL。
| 常量 | 什么时候收到 | 适合做什么 |
|---|---|---|
HIGHEST | 最先 | 抢先否决或强行放行 |
HIGH | 较早 | 压过别人的干预 |
NORMAL | 默认 | 普通处理 |
LOW | 较晚 | 常规观察 |
LOWEST | 最后 | 纯记录,能看到别人处理后的结果 |
同一个优先级内按注册顺序调用,顺序稳定可复现。
subscribe 返回的 Subscription 就是退订令牌:
private Subscription tickSubscription;
tickSubscription = bus.subscribe(GameplayTickEvent.Post.class, EventPriority.LOWEST, ...);
// 不再需要时
tickSubscription.unsubscribe(); // 幂等,重复调用无副作用总线对监听器持有强引用,所以只为某一段流程建立的临时订阅,用完要立刻退订;至于在 onInit 里建立、活到模组结束的订阅,即使不手动退,宿主也会在模组停用时按归属清理。
把逻辑拆成入口类和记录类:入口只负责订阅与退订,记录类只负责读事件、写日志。两个文件都放在 src/main/java/com/example/eventdemo/ 下。
package com.example.eventdemo;
import com.seecmd.mod.api.Mod;
import com.seecmd.mod.api.ModContext;
import com.seecmd.mod.api.ModInfo;
import com.seecmd.mod.api.event.EventBus;
import com.seecmd.mod.api.event.EventPriority;
import com.seecmd.mod.api.event.EventResult;
import com.seecmd.mod.api.event.Subscription;
import com.seecmd.mod.api.event.gameplay.ComboBreakEvent;
import com.seecmd.mod.api.event.gameplay.GameplayTickEvent;
import com.seecmd.mod.api.event.gameplay.NoteJudgedEvent;
import com.seecmd.mod.api.event.input.KeyEvent;
import com.seecmd.mod.api.event.input.LaneInputEvent;
import java.util.ArrayList;
import java.util.List;
public final class EventDemoMod implements Mod {
private final List<Subscription> subscriptions = new ArrayList<>();
@Override
public ModInfo info() {
return ModInfo.of("event_demo", "事件演示", "1.0.0",
List.of("you"), "订阅游玩事件并在日志里反馈");
}
@Override
public void onInit(ModContext ctx) {
EventBus bus = ctx.eventBus();
EventLogger recorder = new EventLogger(ctx.getLogger());
subscriptions.add(bus.subscribe(NoteJudgedEvent.class, EventPriority.LOW,
recorder::onNoteJudged));
subscriptions.add(bus.subscribe(ComboBreakEvent.class, EventPriority.LOW,
recorder::onComboBreak));
subscriptions.add(bus.subscribe(GameplayTickEvent.Post.class, EventPriority.LOWEST,
recorder::onTick));
subscriptions.add(bus.subscribe(KeyEvent.class, EventPriority.HIGHEST,
recorder::onKey));
subscriptions.add(bus.subscribe(LaneInputEvent.Pre.class, EventPriority.HIGHEST,
event -> {
// 吞掉 4 号轨道的输入:判定不会发生,也不会投递该轨道的 Post
if (event.lane() == 3) {
event.setResult(EventResult.DENY);
}
}));
}
@Override
public void onUnload() {
for (Subscription sub : subscriptions) {
sub.unsubscribe();
}
subscriptions.clear();
}
}package com.example.eventdemo;
import com.seecmd.mod.api.event.gameplay.ComboBreakEvent;
import com.seecmd.mod.api.event.gameplay.GameplayTickEvent;
import com.seecmd.mod.api.event.gameplay.NoteJudgedEvent;
import com.seecmd.mod.api.event.input.KeyEvent;
import com.seecmd.mod.api.scene.InputType;
import java.util.logging.Logger;
public final class EventLogger {
private static final long TICK_LOG_INTERVAL_MS = 1000L;
private final Logger log;
private long nextTickLogMs;
EventLogger(Logger log) {
this.log = log;
}
void onNoteJudged(NoteJudgedEvent event) {
log.info("判定 " + event.judgeResult().judgment().displayName()
+ " 时间=" + event.judgeResult().judgeTime() + "ms"
+ " 长尾=" + event.judgeResult().isHoldEnd()
+ " combo=" + event.context().combo());
}
void onComboBreak(ComboBreakEvent event) {
log.info("连击中断 combo=" + event.context().combo()
+ " miss=" + event.context().misses());
}
void onTick(GameplayTickEvent.Post event) {
if (event.rewinding()) {
return;
}
if (event.musicTimeMs() < nextTickLogMs) {
return;
}
nextTickLogMs = event.musicTimeMs() + TICK_LOG_INTERVAL_MS;
log.info("tick t=" + event.musicTimeMs() + "ms step=" + event.stepIndex());
}
void onKey(KeyEvent event) {
if (event.type() != InputType.KEY_PRESSED || event.keyCode() != 0x70) {
return;
}
log.info(" 被按下");
}
}EventLogger 不是入口类,不需要实现 Mod;实例被总线强引用的那些方法引用持有,所以 onInit 结束后它不会被回收。
- 每打中一个音符,日志多一行「判定 PERFECT / GREAT ...」,
combo随之增长 - 打出一个 MISS 后,出现一行「连击中断 combo=0」
- 游玩时每秒最多一行
tick日志,暂停恢复的倒带段没有 tick 日志 - 按键盘上的,出现一行「 被按下」
- 按 4 号轨道对应的键,音符不再被判定
- 模组列表里状态仍是 ACTIVE
宿主在游玩更新、输入分发等节点上调用 post(event),总线当场把事件按优先级降序发给你,发完才返回。没有异步队列,也没有延迟投递——所以回调在哪个线程,取决于宿主在哪投递:游玩事件和输入事件都在游戏线程。
这条链路有三条固定规则:
- 按类型匹配:订阅
NoteJudgedEvent只收这一种;订阅父类会收到子类,订阅Event会收到全部。 - 短路:只有可取消事件、且结果已经不是
DEFAULT时才停止通知后续监听器;不可取消事件永远通知所有人。 - 异常隔离:每个监听器单独
try/catch(Throwable),异常只记一条限频日志,既不冒泡到宿主循环,也不影响其它监听器。所以你的 bug 不会让游戏崩,但也不会有人替你报错——日志要自己看。
- 把
event.result()当判定结果:result()是总线的处理结论(DEFAULT/ALLOW/DENY),判定等级要读judgeResult().judgment()。 - 找
CancellableEvent#cancel():没有这个方法,取消就是setResult(EventResult.DENY)。 - 对不可取消事件
setResult:NoteJudgedEvent、ComboBreakEvent、GameplayTickEvent都是纯通知,写了也没有效果;想改判定请用判定调整器。 - 在每帧回调里做重活:
GameplayTickEvent每帧都来,打印日志、读文件、发网络请求都会拖慢判定帧。先节流,再考虑做别的事。 - 以为
KeyEvent的DENY能拦按键:当前宿主不读这个结果。真正拦游玩输入用LaneInputEvent.Pre。 - 临时订阅忘了退订:总线强引用监听器,不会因为没人用就自动失效;只在某段流程里用的订阅,用完立即
unsubscribe()。
- 订阅写在
onInit,不是onPreInit。 - 音符判定读的是
judgeResult(),不是result()。 - 每帧回调有节流,且没有阻塞 IO。
- 取消按键用的是
LaneInputEvent.Pre加EventResult.DENY。 - 需要释放的订阅都保存了
Subscription,并在onUnload退订。 - 两个完整类都能通过编译。