Theseus 模组文档
开发指南

事件订阅


模组与宿主之间的“发生了什么”全部经事件总线获得。当前版本把游玩事件与跨场景事件合并到同一条总线上,模组不再需要在多条总线之间做选择,也不需要任何 per-模组的订阅注册回调——在 onInit 里直接订阅即可。

4.1 一条总线

EventBus 是全进程唯一的事件总线,模组在 onInit 中经 ModContext#eventBus() 取得:

订阅与投递的接口声明:

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 的接口说明,是模组写监听器时必须遵守的前提:

  1. 同步投递:post(Event) 在调用线程内当场完成全部分发并返回事件本身。没有异步队列,也没有延迟投递。
  2. 匹配规则:按 订阅类型.isInstance(事件) 匹配。订阅 Event 可捕获全部事件;订阅父类可收到其子类(例如订阅 TickEvent 会同时收到 TickEvent.Pre 与 TickEvent.Post,订阅 GameplayTickEvent 同理)。
  3. 顺序:先按 EventPriority 降序(HIGHEST 最先收到),同一优先级内按注册顺序(稳定序,可复现)。
  4. 短路条件:仅当事件可取消、且已有结论(结果不是 DEFAULT)时,停止通知后续监听器。不可取消事件永远通知全部监听器。
  5. 异常隔离:每个监听器独立 try/catch(Throwable)。单个监听器抛异常只记日志,不影响其它监听器,也绝不冒泡到宿主循环——一个模组崩不了游戏。
  6. 零订阅零开销:完全无订阅时 post 立即返回(不分配、不遍历)。因此模组可以在需要时随时订阅,不必为“多加一个监听器”担心空闲成本。
  7. 线程约定:subscribe / unsubscribe 任意线程可调用(内部加锁);post 在调用线程同步分发,宿主在游戏线程 / 渲染线程投递,因此监听器回调就在该线程执行。监听器禁止在回调里做阻塞 IO(网络、磁盘、future.get() 等),需要耗时工作请交给自己的线程或宿主提供的线程池。

事件实例的生命周期不属于契约:当前宿主对 TickEvent / GameplayTickEvent 等每帧事件逐帧新建实例,但接口并不保证「每次收到的是新对象」。监听器不得把事件对象存起来跨帧使用——需要的数据请当场读出。

4.3 优先级与结果

EventPriority 的枚举声明顺序即执行顺序,同一优先级内按注册顺序:

优先级相对次序用途
HIGHEST最先收到事件抢先否决或强行放行
HIGH较早收到事件需要压过默认优先级意见的干预
NORMAL默认优先级单参数 subscribe 走此级
LOW较晚收到事件常规观察
LOWEST最后收到事件纯观察 / 记录(看到其它监听器处理后的最终状态)

EventResult 的取值与总线行为:

结果总线是否继续通知后续监听器典型用途
DEFAULT继续只看不改(观察 / 记录)
ALLOW停止高优先级强制放行,覆盖低优先级的否决意图
DENY停止否决本次默认动作(吞输入 / 吞判定 / 否决切场等),仅对可取消事件有意义

干预方式与边界:

注意访问器命名冲突:某些事件的业务结果访问器不叫 result(),例如 NoteJudgedEvent#judgeResult()——基类的 result() 是 final,承载总线结果语义,子类无法覆盖。

4.4 事件总表

事件类包是否可取消宿主投递时机监听器可读的访问器
TickEvent.Pre / TickEvent.Postcom.seecmd.mod.api.event否宿主主循环每帧投递一次;Pre 在本帧逻辑更新之前,Post 在其之后、渲染之前deltaSeconds()
SceneChangeEvent.Precom.seecmd.mod.api.event是场景切换的决议阶段(历史栈尚未改动)from()、to()、kind()、params()、isRedirected()、redirectTarget()、redirectParams()
SceneChangeEvent.Postcom.seecmd.mod.api.event否场景切换完成之后(新场景已进入)from()、to()(均为 SceneInfo)
SoundPlayEventcom.seecmd.mod.api.event否宿主播放了一个音效时(纯通知)soundId()、path()、category()
SettingsChangedEventcom.seecmd.mod.api.event否宿主保存设置后投递(纯通知)changedKeys()
GameStartupEventcom.seecmd.mod.api.event否契约:装配结束、进入主循环之前;当前宿主源码未见投递点无(纯通知)
GameShutdownEventcom.seecmd.mod.api.event否契约:释放资源之前;当前宿主源码未见投递点无(纯通知)
GameStartEventcom.seecmd.mod.api.event.gameplay否一局游玩开始时(谱面加载完成、音乐启动时)投递一次context()
GameEndEventcom.seecmd.mod.api.event.gameplay否一局游玩结束时(正常结束 / 重试 / 退出 / 进入结算)投递一次context()
ChartLoadEventcom.seecmd.mod.api.event.gameplay否谱面解析完成、进入可游玩状态时投递context()
GameplayTickEvent.Pre / GameplayTickEvent.Postcom.seecmd.mod.api.event.gameplay否每个游玩帧各投递一次;Pre 在本帧游玩逻辑更新之前,Post 在其之后(倒带重演窗口内照常投递)musicTimeMs()、rewinding()、deltaSeconds()、stepIndex()
NoteJudgedEventcom.seecmd.mod.api.event.gameplay否一个音符完成判定时(含长音符结尾判定),且判定已经落地judgeResult()、context()
ComboBreakEventcom.seecmd.mod.api.event.gameplay否连击被打断时(本次 MISS 导致 combo 归零),且打断已经发生context()
PauseEventcom.seecmd.mod.api.event.gameplay是暂停请求受理时(宿主改状态之前,可否决)context()
ResumeEventcom.seecmd.mod.api.event.gameplay是恢复请求受理时(宿主改状态之前,可否决)context()
KeyEventcom.seecmd.mod.api.event.input是全局按键分发时(按下 / 抬起 / 长按)keyCode()、type()
MouseEventcom.seecmd.mod.api.event.input是契约:场景级鼠标分发时;当前宿主未投递(鼠标请用 Scene#handleInput)type()、x()、y()、button()
LaneInputEvent.Precom.seecmd.mod.api.event.input是本局游玩期间玩家按键作用于轨道、且按键尚未被映射到轨道 / 判定之前lane()、keyCode()、musicTimeMs()
LaneInputEvent.Postcom.seecmd.mod.api.event.input否本局游玩期间输入已被消费之后(观察点)lane()、keyCode()、musicTimeMs()
RenderEvent.Pre / RenderEvent.Postcom.seecmd.mod.api.event.render否宿主在每个渲染层插槽的前后各投递一次;共 7 层,每帧 14 次layer()、context()
TrackRenderEvent.Pre / TrackRenderEvent.Postcom.seecmd.mod.api.event.render否宿主在轨道渲染器的入口 / 出口各投递一次(只在游玩场景投递)context()、musicTimeMs()、scrollSpeed()、visibleNoteCount()
ResourceReloadEventcom.seecmd.mod.api.event.resource否宿主真实切换语言并完成重载时投递 LANGUAGE(纯通知);RESOURCES 当前无发布点kind()、detail()

补充几点使用要点:

实时预览实时预览:一场游玩中模组订阅到的事件序列与数据
预览加载中…

4.5 订阅与退订

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;
        }
    }
}