能力接口实战
能力接口都位于 com.seecmd.mod.api.capability,是模组与宿主之间的原语面:模组不持有宿主内部对象,只按类型取用能力。能力有两类来源:
- 宿主回填(只读取用):
GameStateAccess、AudioAccess、InputAccess、SceneHost、StoryAccess、ModManagerAccess等;宿主在对应时机以同类型注册,退出场景时清除。 - 模组提供:
GameplayHud经GameplayMod.createGameplayHud()、LaneInputMapper经GameplayMod.createLaneInputMapper();GameplayHud也可改经ctx.capabilities().register(GameplayHud.class, ...)自行注册。
两条通行约定:
capabilities().get(X.class)可能返回null(宿主只在对应时机注册对应能力,GameStateAccess/AudioAccess等游玩中才有),取用前一律判空;- 不要在静态字段长期缓存能力实例,也不要缓存
NetworkAccess.executor()的返回值(模组停用后旧引用已失效)。
7.1 我要做什么 → 用哪个能力接口 → 怎么拿
| 我要做什么 | 能力接口 | 怎么拿 | 可用时机 |
|---|---|---|---|
| 自绘 HUD | GameplayHud | GameplayMod.createGameplayHud(),或 ctx.capabilities().register(GameplayHud.class, ...) | 游玩中 |
| 接管「按键 → 轨道」映射 | LaneInputMapper | GameplayMod.createLaneInputMapper() | 游玩中 |
| 开自己的场景 | SceneHost | ctx.capabilities().get(SceneHost.class) | 进程级(全局常驻) |
| 播放音效 / 控制 BGM | AudioAccess | ctx.capabilities().get(AudioAccess.class) | 游玩中 |
| 注册自带音效 / BGM 文件 | ModContext.registerSound / registerMusic | ctx.registerSound(...) / ctx.registerMusic(...) | 任意回调(一次即可) |
| 读 / 改游玩状态 | GameStateAccess | ctx.capabilities().get(GameStateAccess.class) | 游玩中 |
| 与其它模组联动 | ModServiceRegistry | ctx.modServices() | 进程级 |
| 网络功能 | NetworkAccess | ctx.network() | 进程级 |
| 在原生设置里加模组设置项 | SettingsExtension / SettingsItem | ctx.uiCustomization().settings() | 进程级 |
| 主界面状态栏扩展 | UiCustomization | ctx.uiCustomization() | 进程级 |
| 全局快捷键 | GlobalHotkeys / GlobalHotkey | ctx.hotkeys() | 进程级 |
| 查其它模组状态 / 失败原因 | ModManagerAccess | ctx.capabilities().get(ModManagerAccess.class) | 进程级 |
7.2 自绘 HUD:GameplayHud
GameplayHud 的核心是三件套:读 HUD 数值、隐藏原版组件开关、覆盖渲染(另有可选的 wantsOverlayWithoutHiding() 与 override(HudElement) 微调)。宿主在每次覆盖渲染前调用 updateValues(HudValues) 注入本帧数值快照,模组在 renderOverlay 中经 values() 读回。
关键语义:setXxxVisible(false) 是模组主动声明的隐藏开关(isProgressVisible() 等 getter 返回的是声明值,不是真实渲染状态);任一组件被声明隐藏、或 wantsOverlayWithoutHiding() 返回 true 时,宿主才调用 renderOverlay。
从零做一遍(隐藏原版连击、自绘大字、加进度条、坐标与缩放说明)见 自定义 HUD。下面只列能力契约。
public final class MyHudMod implements GameplayMod {
private boolean comboHidden = true;
@Override
public ModInfo info() {
return ModInfo.of("my_hud", "My HUD", "0.1.0", List.of("You"), "自绘连击区");
}
@Override
public GameplayHud createGameplayHud() {
return new MyHud();
}
private final class MyHud implements GameplayHud {
/** 本帧 HUD 数值快照:由宿主经 updateValues 注入。 */
private HudValues snapshot;
@Override
public HudValues values() {
return snapshot;
}
@Override
public void updateValues(HudValues values) {
this.snapshot = values; // 参数永不为 null
}
@Override public void setProgressVisible(boolean visible) { }
@Override public void setComboVisible(boolean visible) { comboHidden = !visible; }
@Override public void setScoreVisible(boolean visible) { }
@Override public void setAccuracyVisible(boolean visible) { }
@Override public boolean isProgressVisible() { return true; }
@Override public boolean isComboVisible() { return !comboHidden; }
@Override public boolean isScoreVisible() { return true; }
@Override public boolean isAccuracyVisible() { return true; }
@Override
public void renderOverlay(RenderCanvas canvas, HudContext hudCtx) {
int combo = values() != null ? values().combo() : 0;
canvas.setColor(new Color(255, 255, 255));
canvas.drawText("COMBO " + combo, 40, 120,
TextStyle.of(20, new Color(255, 255, 255)));
}
}
}多模组叠加规则:所有经 createGameplayHud() 返回非 null 的实例按启用顺序收集,末尾追加经 capabilities().register(GameplayHud.class, ...) 注册的实例(同类型只保留一个,后注册的生效)。可见性声明取「任一隐藏即隐藏」(最严格生效),覆盖渲染依次调用、可互相叠加。
7.3 接管按键 → 轨道映射:LaneInputMapper
宿主在谱面加载、轨道数已知时调用一次 init(int laneCount);此后把 mapKeyToLane(int) 的返回值作为「该键是否触发轨道」的判定依据。返回 -1 表示不触发任何轨道。一个键触发多条轨道用 mapKeyToLanes(int);triggerKeys() 声明本模组管辖的物理键集合。
@Override
public LaneInputMapper createLaneInputMapper() {
return new LaneInputMapper() {
private int laneCount;
@Override
public void init(int laneCount) {
this.laneCount = laneCount; // 可在此缓存分区结果
}
@Override
public int mapKeyToLane(int keyCode) {
// 把主键区横向均分给各轨道;返回 -1 表示该键不触发轨道
int[] row = {java.awt.event.KeyEvent.VK_A, java.awt.event.KeyEvent.VK_S,
java.awt.event.KeyEvent.VK_D, java.awt.event.KeyEvent.VK_F};
for (int i = 0; i < row.length && i < laneCount; i++) {
if (row[i] == keyCode) {
return i;
}
}
return -1;
}
@Override
public java.util.Set<Integer> triggerKeys() {
// 声明管辖键:宿主据此构建 heldLanes 与轨道按压高亮
return java.util.Set.of(java.awt.event.KeyEvent.VK_A, java.awt.event.KeyEvent.VK_S,
java.awt.event.KeyEvent.VK_D, java.awt.event.KeyEvent.VK_F);
}
};
}多模组按键级路由规则:游玩开始时按启用顺序逐个调用 init(laneCount);每个物理键由声明管辖它(triggerKeys())的第一个模组负责映射;未声明管辖键的旧实现按启用顺序回退询问,第一个映射非空的生效(宿主会做重叠检测并记录诊断日志)。列表为空时游戏沿用内置固定键位。
7.4 开自己的场景:SceneHost 与 Scene
SceneHost 控制场景栈:push(Scene) 压入(当前场景挂起)、pop() 弹出(返回 boolean:栈底无历史时返回 false 且不执行导航)、replace(Scene) 替换(不改变栈深度)、current() 查当前场景。
模组场景实现 com.seecmd.mod.api.scene.Scene,只有 id() 是必须实现的方法,其余为 default 空实现;handleInput(InputEvent) 返回 true 表示已消费该输入。
/** 模组自定义场景:按 id 路由,render 每帧调用。 */
private static final class MyScene implements Scene {
@Override
public String id() {
return "mymod_panel";
}
@Override
public void render(RenderCanvas canvas) {
canvas.setColor(new Color(32, 32, 32));
canvas.fillRect(0, 0, 400, 200);
canvas.drawText("我的界面", 20, 40, TextStyle.of(16, Color.WHITE));
}
@Override
public boolean handleInput(InputEvent event) {
return event.type() == InputType.KEY_PRESSED
&& event.keyCode() == java.awt.event.KeyEvent.VK_SPACE;
}
}// 打开 / 关闭:SceneHost 是进程级能力,onInit 里取一次即可
SceneHost host = ctx.capabilities().get(SceneHost.class);
if (host != null) {
host.push(new MyScene()); // 打开
host.pop(); // 关闭(回到上一个场景)
}需要「否决 / 改道」场景跳转时不要用本接口,订阅 SceneChangeEvent.Pre 事件(放行 = 不改结果,否决 = DENY,改道 = redirect(...))。
7.5 播音频:AudioAccess 与 ctx.registerSound/registerMusic
两者分工明确:ctx.registerSound / ctx.registerMusic 负责把 jar 内的音频文件注册进引擎(只需要做一次);AudioAccess 负责播放控制。
ctx.registerSound(ResourceLocation location, double volume, boolean preload):音频资源路径与resources()同口径(assets/<命名空间>/<路径>);location.toString()即音频在引擎内的注册 id,天然带命名空间,不会与内置音频或其它模组冲突;preload为是否立即预加载到内存(短音效建议true)。ctx.registerMusic(ResourceLocation location, double volume, boolean preload):类别为 BGM(受bgmVolume音量控制)。AudioAccess.playSound(String soundId, double volume):同一soundId宿主维护 3 个 Clip 的轮转池,最多 3 个实例并发叠加;第 4 次播放复用并从头重播最早那个(不是「打断前一个」)。stopSound(soundId)停掉该 id 的全部实例;playMusic(String musicId)若同一musicId正在播放则跳过;playingSounds()返回正在播放的音效快照。
/** jar 内 assets/mymod/sounds/click.wav;其 toString 即引擎内注册 id。 */
private static final ResourceLocation CLICK = ResourceLocation.of("mymod", "sounds/click.wav");
@Override
public void onInit(ModContext ctx) {
ctx.registerSound(CLICK, 0.8, true); // 注册一次即可
}
private void playClick(ModContext ctx) {
AudioAccess audio = ctx.capabilities().get(AudioAccess.class); // 游玩中才有,可能为 null
if (audio != null) {
audio.playSound(CLICK.toString(), 0.8);
}
}7.6 读游玩状态:GameStateAccess
GameStateAccess 提供游玩状态机的读取与控制:musicTimeMs() / isPaused() / isFinished() 读状态,play() / pause() / resume() / seek(long) / setSpeed(double) / endGame() 改状态,setAltHudForced(boolean) 强制切换备用 HUD。
其中「轨道几何」区块为实验性入口(签名稳定、语义仍可能微调):setLaneRotation / getLaneRotation、setLaneOffset、setLaneSpeedFactor / getLaneSpeedFactor、flashLane、cursorPosition / setCursorPosition、laneRotationPivot。注意:
setLaneRotation/setLaneOffset与谱面额外内容特效lane_rotate/lane_move写同一份每轨状态,同时使用会互相覆盖;二者均为纯视觉,不影响判定与输入。- 旋转圆心在 3.0 起固定为该轨判定线中点:
LANE_ROTATE_PIVOT_*常量与setLaneRotation的pivot参数均已删除;需要自定义圆心请改用EntityPose.rotateZ(deg, pivotX, pivotY)自绘。 laneRotationPivot(int lane)在轨道索引无效时返回null。flashLane(int lane, long durationMs)复用原生按压高亮(与玩家按键点亮同源),durationMs须大于 0。cursorPosition()/setCursorPosition(int, int)读写的是画布逻辑坐标,不影响系统真实光标。
GameStateAccess state = ctx.capabilities().get(GameStateAccess.class);
if (state != null && !state.isFinished()) {
long now = state.musicTimeMs();
state.flashLane(2, 120); // 在该轨道复用原生按压高亮,持续 120ms
}3D 轨道倾斜与相机位姿(实验性)
setTrackTilt3D(double tiltDeg)/setTrackTilt3DAuto():整个轨道区绕锚点 X 轴的 3D 透视倾斜(单位度,正值 = 顶部远离相机向后倒,Arcaea 式,宿主钳 ±89°)。它驱动 WORLD/BACKGROUND 层的相机矩阵,改的是轨道实体的姿态;tilt=0完全等价默认正交画面。与谱面特效通道track_tilt写同一份执行体状态(后写生效)。setCameraPose(double x, double y, double z, double yawDeg, double pitchDeg, double fovDeg)/setCameraPoseAuto():直接设置视点(相机位姿)。x/y/z 为内容坐标(画布逻辑像素,z 单位 = 像素、+z向屏幕内、有效范围|z| ≤ 2000);yawDeg/pitchDeg/fovDeg单位均为度(注意区别于MatrixStack3D 原语的弧度口径;yaw 正值向右转,pitch 正值视线上抬);fovDeg传哨兵CameraPose.FOV_AUTO= 用宿主现行推导(相机距离 d = 内容高 × 0.5,z=0 平面映射与正交恒等)。写入是每帧可写、后写生效的绝对写——接管后宿主默认位姿不再作用于视图,setTrackTilt3D的倾斜分量不再改相机 pitch 而是转为内容旋转参与合成(同帧可共存),直至倒带 / 新谱面复位;setCameraPoseAuto()是唯一干净的还原入口。裁剪注意:近裁剪面 = d/3(720p 内容约 120px),把几何挪到距相机极近处会被近面裁掉。纯视觉:判定引擎零消费。- 位姿链(多模组叠加的正确做法):
TrackPoseAdjuster/CameraPoseAdjuster经ModRegistries.trackPoseAdjusters()/cameraPoseAdjusters()在onPreInit注册,按注册顺序链式可变传阅——宿主每帧先算出默认位姿(既有 setter 与setCameraPose的取值构成链的输入),每个调整器原地修改传入的位姿对象,修改结果即下一个调整器的输入,链尾交给渲染。后写者能看到前者的结果,多个模组因此可自然叠加(如一个大幅移动、另一个叠加小颤抖)。单个调整器抛异常会被跳过并限频告警,不拖垮其它模组;位姿对象禁跨帧持有引用;位姿链位于表现层可重演路径,实现须满足确定性契约(禁随机 / 禁墙钟,时间一律取ctx.musicTimeMs())。 - tilt 与相机正交可组合:
setTrackTilt3D改的是轨道实体的姿态(进入相机视角的形状),setCameraPose挪的是观察视角本身;二者作用于渲染的不同环节,可自由叠加。 - 谱面侧等价通道
camera_pose:谱师也能在谱面额外内容里直接声明相机位姿(camera_pose/cameraPoseConfig,字段与CameraPose同口径,见制谱器开发文档)。它与模组setCameraPose写同一相机通道、后写生效;宿主帧序为ExtraContentRenderer.update(谱面写,帧逻辑阶段)→GameplayTickEvent.Post(模组写),故模组在Post每帧重写即最高优先级。位姿接管期内track_tilt(谱面原语 / 模组setTrackTilt3D)不再改相机 pitch,而是转为内容旋转合成——两者同帧可共存、互不覆盖;恢复到宿主默认位姿推导走setCameraPoseAuto()(倒带重建 / 新谱面亦复位)。 - 谱面侧 per-lane 3D(
lane_3d,无会话侧等价原语):谱师可给单条真轨道叠加独立 3D 朝向(lane_3d/lane3DConfig:pitchDeg/yawDeg/rollDeg/zOffset,角度单位度且不钳制;圆心复用该轨lane_rotate的圆心口径,pivotZ=0)。它是轨道实体的本地姿态,与lane_rotate(2D rotateZ)/lane_scale在同一变换栈内复合,且与全局相机(track_tilt/camera_pose)正交可叠加——在透视相机(camera_pose/track_tilt生效时)下呈现真实三点透视。GameStateAccess无对应会话原语:模组要 3D 效果请用MatrixStack.translate3D/rotateX/rotateY自绘,或经全局setTrackTilt3D/setCameraPose。纯视觉,判定引擎零消费。
7.7 与其它模组联动:ModServiceRegistry
每个模组 jar 运行在独立类加载器中,无法在编译期互相引用对方的类型。跨模组联动的做法是把「对外读写的状态」注册为命名服务:
- 提供方:
ctx.modServices().register(name, service),全局 id 自动拼接为"<本模组id>:<name>",无需手写前缀; - 消费方:
ctx.modServices().find(globalId),按同一格式查询,无则返回null; ModService只提供布尔 / 整数 / 浮点 / 字符串四种原语的读写(has/boolValue/intValue/doubleValue/stringValue与setBoolean/setInt/setDouble/setString),键的类型与语义由提供方约定;- 模组停用 / 注销时宿主自动移除该模组的全部服务,无需在
onUnload手动清理; - 消费者若需保证查询时目标服务已注册,应在
Mod.loadOrder()中声明loadAfter目标模组 id(见第 9 节)。
// 提供方(服务实现需自行保证 get*/set* 线程安全)
ctx.modServices().register("background", myController);
// 消费方
ModService svc = ctx.modServices().find("other_mod:background");
if (svc != null && svc.has("visible")) {
boolean visible = svc.boolValue("visible", true);
svc.setBoolean("visible", !visible);
}7.8 网络:NetworkAccess
宿主只负责线程生命周期托管,协议由模组用标准 java.net / java.nio 自行实现:
- 线程池按模组懒创建(首次调用
executor()才建线程,核心数 4、守护线程),同一模组多次调用返回同一实例; - 不要把它缓存到静态字段:模组停用后宿主对该池执行
shutdownNow()并把能力实例置为终止态,此后再调用executor()抛IllegalStateException(不会重建线程池); - 模组停用(含游玩中热停用)与游戏退出时,宿主会对该线程池执行
shutdownNow();模组不要对其调用shutdown(); - 阻塞在普通
java.netSocket 上的线程不响应中断,应在onUnload中自行关闭自己打开的 socket / 连接,否则残留线程会存活到进程退出(属于资源泄漏); - 严禁在游戏线程做任何阻塞 IO,否则整个游戏冻结。
private ScheduledExecutorService netPool;
@Override
public void onInit(ModContext ctx) {
netPool = ctx.network().executor(); // 懒创建;不要缓存到静态字段
netPool.execute(() -> { // 阻塞 IO 都在这个池上做
try (java.net.Socket socket = new java.net.Socket("127.0.0.1", 12345)) {
// ... 收发 ...
} catch (Exception e) {
ctx.getLogger().warning("网络失败:" + e.getMessage());
}
});
}网络任务若要影响画面,经线程安全入口回交游戏线程渲染(ctx.panels() 与 ctx.notifier() 均支持任意线程调用)。
7.9 原生设置里加模组设置项:SettingsExtension / SettingsItem
SettingsExtension 经 ctx.uiCustomization().settings() 获取(宿主未实现该扩展点时抛 UnsupportedOperationException,视为可选扩展点)。它把模组设置项直接嵌入原生设置场景(有设置项的模组在设置页各占一个独立标签页,见下)—— 这与第 8 节的 Mod.createConfigScene()「独立配置场景」是两条不同的路。
addItem(SettingsItem item):宿主把每个有设置项的模组自动归入一个独立标签页(标签名 = 模组显示名,未注册模组回退模组 id),该模组的全部设置项都排在这一页内——不再按锚点散落进宿主页。自动页排在宿主原生页之后、显式自创页之前;没有设置项的模组不产生页。SettingsItem.anchorBefore()/anchorAfter()接口仍在,但设置场景已不再消费(遗留方法,见下);addCollection(String id, String titleKey)建集合,addCollectionItem(String collectionId, SettingsItem item)往集合里加项(此处collectionId是集合全键模组id:collectionId);SettingsItem是无状态描述器:宿主只按type()渲染行并回调,值由模组自己经ModContext.config()持久化;id()为模组内唯一 id,宿主拼模组id:id为全键;labelKey()/tooltipKey()为译键(缺失时回退原文);onEnter()/onAdjust(int dir)在游戏线程触发(dir = -1左 /+1右);isOn()/toValueText()/drawValue在渲染线程调用,应轻量;- 模组停用或游戏退出时宿主自动清理注册项,不要在
onUnload中逐个remove。
SettingsExtension settings = ctx.uiCustomization().settings();
SettingsItem hintSwitch = new SettingsItem() {
@Override
public String id() {
return "hintEnabled";
}
@Override
public String labelKey() {
return "mymod.settings.hint"; // 译键;缺失时宿主回退显示原文
}
@Override
public Type type() {
return Type.SWITCH; // 宿主画原生开关,态取自 isOn()
}
@Override
public boolean isOn() {
return cfg.hintEnabled();
}
@Override
public void onEnter() {
cfg.setHintEnabled(!cfg.hintEnabled()); // 自持久化:set 即同步写盘
}
};
settings.addItem(hintSwitch);Type 取值:SWITCH(宿主画原生开关)、NUMBER / JUMP_VALUE(宿主显示 toValueText(),左右键触发 onAdjust)、INPUT(显示 toValueText(),回车触发 onEnter,不响应左右键)、JUMP(回车打开 targetCollection() 指定的集合,格式 模组id:collectionId,不调用 onEnter)、CUSTOM(宿主把 value 区矩形包装成 RenderCanvas 调 drawValue 自绘)。
7.10 主界面状态栏扩展:UiCustomization
UiCustomization 提供的是嵌入原版固定 UI 区域的扩展点,与 ctx.panels() 的独立悬浮弹窗不同:panels() 是屏幕右下角的独立滑入弹窗(任意线程可调用,游戏线程统一渲染),不受原版 UI 布局约束;UiCustomization 的块嵌入主界面底部状态栏,随原版布局与空间分配。
addStatusBarItem(String id, String text):块按注册顺序从左到右排列;状态栏空间不足时靠后的块跳过绘制(不显示、不响应点击),窗口变宽后自动恢复;同一模组重复注册相同id属幂等(更新文本并复用既有块);完整键为模组id:本id,建议文本 12 字符以内。StatusItemHandle:setText下次渲染帧生效;setOnClick(null)表示无回调(纯展示块);remove()后其余方法无效。点击回调在游戏线程触发,可直接调用场景跳转。- 该能力下还有
addResultOverlay(结算覆盖渲染)与addTrackStyleProvider(轨道样式覆盖)两个default方法,当前版本可能抛UnsupportedOperationException。 - 生命周期:模组停用或游戏退出时宿主自动移除该模组注册的全部定制项。
UiCustomization.StatusItemHandle item =
ctx.uiCustomization().addStatusBarItem("mymod", "示例模组");
item.setOnClick(() -> {
// 点击回调在游戏线程执行,可直接跳转场景
SceneHost host = ctx.capabilities().get(SceneHost.class);
if (host != null) {
host.push(new MyScene());
}
});
item.setText("示例模组·已启用"); // 动态更新,下次渲染帧生效7.11 全局快捷键:GlobalHotkeys
这是模组全局快捷键的唯一合法注册入口。 禁止自行监听按键事件来实现玩家无法改键的隐性快捷键 —— event.input.KeyEvent 是只读的原始按键事件流(适合输入分析),不能充当快捷键通道。注册的快捷键进入原生设置「全局快捷键」集合面板(与内置快捷键同列),玩家统一改键,绑定持久化于游戏设置,模组无需自行保存。
ctx.hotkeys().add(GlobalHotkey hotkey)幂等:同 id 重复注册替换旧定义;返回句柄用于移除;GlobalHotkey.id()为模组内唯一 id(全键模组id:id);labelKey()为标签译键,建议带模组前缀;defaultKeyCode()为默认键码(与java.awt.event.KeyEvent.VK_*一致的字面量);选择默认键时避开常见组合键的修饰键单独触发场景;onHotkey()是按下沿语义,在游戏线程触发、每帧至多一次,实现应轻量;长时间操作须自行起线程(如经ctx.network())。
ctx.hotkeys().add(new GlobalHotkey() {
@Override
public String id() {
return "toggle_hud";
}
@Override
public String labelKey() {
return "mymod.hotkey.toggle"; // 建议带模组前缀,避免与其它模组撞名
}
@Override
public int defaultKeyCode() {
return java.awt.event.KeyEvent.VK_F6;
}
@Override
public void onHotkey() {
hudVisible = !hudVisible; // 只做轻量状态切换
}
});7.12 查其它模组状态 / 失败原因:ModManagerAccess
进程级可用,用于实现自己的模组管理 UI(内置模组菜单功能与此接口等价):
registeredMods()返回全部已注册模组(含未启用与加载失败的),保持注册顺序,是不可变视图;getModInfo(String)未注册返回null;isEnabled(String)只看 id 是否在启用列表(无论加载是否成功);isActive(String)才是「加载成功且仍在启用」,两者语义不同;getLoadStatus(String)未注册返回NOT_REGISTERED;getLoadError(String)返回失败原因(依赖缺失、循环依赖、加载时抛异常等),无失败或未注册返回empty;setEnabled(String, boolean)修改启用列表并持久化,但注册表冻结后只记录、下次启动才生效(需重启);传入未注册的 id 会进入启用列表并告警,但不会被激活。
ModManagerAccess mgr = ctx.capabilities().get(ModManagerAccess.class);
if (mgr != null) {
for (ModInfo info : mgr.registeredMods()) {
ModLoadStatus status = mgr.getLoadStatus(info.id());
mgr.getLoadError(info.id()).ifPresent(reason ->
ctx.getLogger().warning(info.id() + " 加载失败:" + reason));
if (status == ModLoadStatus.ACTIVE) {
// 该模组可用
}
}
}