Theseus 模组文档
教程

订阅游玩事件

一个模组,在音符判定、连击中断、每帧和按键时打日志,并能真的吞掉某一条轨道的按键。


为什么需要这个

前面几篇都是宿主要求你「提供」一个东西:HUD、特效、判定等级。这一篇反过来——游戏里发生了什么,宿主主动通知你。

你不需要每帧去猜「刚刚有没有 MISS」,也不用碰判定内部。宿主在关键节点投递一个个带类型的事件,你只订阅自己关心的那几种,其余一概不关心。

订阅是一次性的初始化代码,写完就不用管;之后每局游玩,回调会自己到。这是模组里最省事、也最常用的一种「参与游戏」的方式。

先认识几个词
  • 事件(Event):宿主在某个时刻发生的一件事,对象里带着当时的只读数据。
  • 事件总线(EventBus):分发中心。全进程只有一条,用 ctx.eventBus() 拿到属于你模组的那份视图。
  • 监听器(Consumer<E>):你传给总线的回调,参数就是事件对象。一个事件可以有多个监听器。
  • 订阅令牌(Subscription):subscribe 的返回值。留着它就能随时退订。
  • 优先级(EventPriority):决定同一个事件谁先收到,从 HIGHEST 到 LOWEST。

前置条件

不用先读 API 参考

本篇只用到 EventBus、EventPriority、EventResult、Subscription 和四个事件类。每个出现时都会解释它是什么。

运行流程

宿主投递事件 判定 / 连击 / 每帧 / 按键 EventBus 分发 按优先级降序 你的监听器 读字段 / setResult 宿主继续 按结果决定动作 同步分发:post 在哪个线程,回调就在哪个线程,返回时全部分发已完成。 每个监听器独立 try/catch:你抛异常只会记一条日志,不影响其它监听器。 可取消事件一旦结果不是 DEFAULT,后面的监听器就不再被通知。
一次事件的生命周期:宿主发起 → 总线排序 → 你的回调 → 宿主读结果

开始动手

第 1 步:在 onInit 里拿到事件总线

总线不是自己 new 出来的,从模组上下文里取:

java
EventBus bus = ctx.eventBus();

放在 onInit 里。onPreInit 是写注册表的窗口,订阅事件不需要那么早;而 onInit 时注册表已冻结、总线已就绪,正合适。

返回的 bus 已经绑定了你的模组:通过它建立的订阅都记在你名下,模组停用或卸载时宿主会一次性退掉,不要求你自己记账。

第 2 步:订阅音符判定

先订阅最有用的一种——一个音符刚判完。三个参数依次是:事件类型、优先级、回调。

java
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:这是纯观察,让想干预的监听器先说话。

第 3 步:再加连击中断和每帧

连击被打断时宿主投递 ComboBreakEvent,只有 context() 一个访问器:

java
bus.subscribe(ComboBreakEvent.class, EventPriority.LOW, event -> {
    ctx.getLogger().info("连击中断 combo=" + event.context().combo()
            + " miss=" + event.context().misses());
});

投递时打断已经发生,combo() 就是 0,不要试图回滚。

每帧事件是 GameplayTickEvent 的 Pre / Post。它每帧都来,所以日志必须节流:

java
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 在本帧游玩逻辑更新之后投递,适合做「本帧已经处理完」的统计。

第 4 步:订阅按键,并吞掉一条轨道

按键有两层。先看真正能拦截的那层——LaneInputEvent.Pre,它在按键被映射到轨道之后、判定之前投递:

java
bus.subscribe(LaneInputEvent.Pre.class, EventPriority.HIGHEST, event -> {
    if (event.lane() == 3) {
        event.setResult(EventResult.DENY);   // 4 号轨的这次输入不判定
    }
});

DENY 只跳过这一条轨道的这次输入,不会影响同一个按键映射到的其它轨道;被吞掉的这次也不会再投递该轨道的 Post。这是游玩里真正能「取消按键」的位置。

全局按键则用 KeyEvent,它带 keyCode() 和 type():

java
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)。

第 5 步:优先级与退订

subscribe 的三个参数版允许指定优先级;两个参数版等价于 EventPriority.NORMAL。

常量什么时候收到适合做什么
HIGHEST最先抢先否决或强行放行
HIGH较早压过别人的干预
NORMAL默认普通处理
LOW较晚常规观察
LOWEST最后纯记录,能看到别人处理后的结果

同一个优先级内按注册顺序调用,顺序稳定可复现。

subscribe 返回的 Subscription 就是退订令牌:

java
private Subscription tickSubscription;

tickSubscription = bus.subscribe(GameplayTickEvent.Post.class, EventPriority.LOWEST, ...);

// 不再需要时
tickSubscription.unsubscribe();   // 幂等,重复调用无副作用

总线对监听器持有强引用,所以只为某一段流程建立的临时订阅,用完要立刻退订;至于在 onInit 里建立、活到模组结束的订阅,即使不手动退,宿主也会在模组停用时按归属清理。

第 6 步:两个类放在一起

把逻辑拆成入口类和记录类:入口只负责订阅与退订,记录类只负责读事件、写日志。两个文件都放在 src/main/java/com/example/eventdemo/ 下。

java
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();
    }
}
java
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 退订。
  • 两个完整类都能通过编译。

接下来