官方实现解剖
五个桥接、五种问题。每个都按同一顺序讲:为什么 JSON 不够 → 具体怎么做 → 版本要求与坑 → 源码入口。后半页把它们的共同手法抽成五段可复用的代码。
本节: 概览 · 接入与生命周期 · 接口速查 · 官方实现 · 谁在用这套 API
五个官方桥接
兼容实现分三类,源码都在 GitHub 的 ControlFlexMC 组织下:本体(内置 JSON + 派发通道)、配置源(社区 JSON 的分发)、桥接(真的写 Java 的那 5 个)。
| 仓库 | 适配对象 | 分支 · 加载器 | 手法 |
|---|---|---|---|
| control-flex | 所有标准 KeyMapping 模组 | 本体,全版本线 | 内置 3 份 JSON + 五个派发通道 + 三层覆盖 |
| cfx-compat-configs | 纯 JSON 能覆盖的模组 | 按 ControlFlex 版本打 tag | 社区配置源,见兼容配置 |
| cfx-compat-epicfight | Epic Fight | 1.20.1 Forge · 1.21.1 NeoForge | 注册为 EF 的手柄输入 mod + mixin 改绑定查找 + 状态推送 + 相机接管 |
| cfx-compat-create | Create 列车控制台 | 1.20.1 Forge / Fabric · 1.21.1 NeoForge | 单个注入点,把移动脉冲并进按键判定 |
| cfx-compat-exposure | Exposure 取景器 | 1.20.1 Forge / Fabric · 1.21.1 Fabric / NeoForge | 声明交互式叠层的前台 / 后台 |
| cfx-compat-emi | EMI | 1.18.2 / 1.19.2 / 1.20.1 / 1.21.1 | 注册 22 个伪 KeyMapping,再转发回 EMI 自己的函数 |
| cfx-compat-vehicles | 沉浸车辆(MTS)等轮询 GLFW 的载具模组 | 1.12.2 → 1.21.1、26.1.2(Forge 线) | mixin 进载具模组自己的按键读取点 |
cfx-compat-epicfight:把 Control Flex 变成 EF 的手柄后端
Epic Fight 不读 KeyMapping,它有一套自己的输入 SPI(IEpicFightControllerMod):没人注册实现时,它会退回去查原始 GLFW 键盘状态 —— 手柄绑定永远满足不了。同时它按动作查 ControllerBinding,这个对象只能由实现方提供,JSON 给不出来。
所以这个桥接做三件事:实现 IEpicFightControllerMod(摇杆 → PlayerInputState,动作 → ControllerBinding);用 mixin 覆写 EF 的 controllerBinding(),把绑定查找重定向到 Control Flex 的动作状态;监听 ChangePlayerModeEvent 推送 epicfight:battle_mode。此外 EpicFightCameraBridge 实现了相机接管 —— 把转向增量交给 EpicFightCameraAPI.turnCamera(),EF 接管视角时(TPS 战斗视角、锁敌)画面才跟手。
要求:ControlFlex ≥ 0.8.8;Epic Fight ≥ 20.14(1.20.1 Forge)/ ≥ 21.15(1.21.1 NeoForge)。与 Controlify 互斥 —— 两边都要当 EF 的手柄 mod,只能留一个。源码入口:CfxEpicFightControllerMod、EpicFightStateBridge、EpicFightCameraBridge、mixin/EpicFightInputActionMixin。
cfx-compat-create:一个注入点解决列车油门
Create 的列车控制台不读 KeyMapping.isDown(),而是一路问到 InputConstants.isKeyDown(window, code)。而 Control Flex 的模拟移动会接管 MOVE 组:动作不再派发,摇杆值直接写进 player.input.forwardImpulse —— 于是玩家在走,但「有键按下」这件事从来没被发布过,列车油门收不到。
做法克制到极简:只在 ControlsUtil.isActuallyPressed(KeyMapping) 的 RETURN 上注入一次,把 forwardImpulse / leftImpulse 并进「前进 / 后退 / 左右」的判定;sneak → 刹车、jump → 鸣笛。真实按键已经按下时原样返回,键盘优先级不丢。
注意它不写任何全局状态:不合成 GLFW 轮询、不写 KeyMapping、不发 Forge 按键事件,所以不会污染别的模组;也不需要判断「玩家是不是在开车」,因为 isActuallyPressed 只有列车控制台会调。源码入口:CreateTrainControl、mixin/ControlsUtilMixin。
cfx-compat-exposure:只做上下文声明
Exposure 的取景器是一个会夺走镜头、隐藏 HUD 的交互式叠层。Control Flex 只为它知道的上下文切换摇杆行为,所以桥接只负责通报:CameraClient.setupViewfinder / removeViewfinder 这两个所有开关路径的必经点上挂 mixin,调用 notifyOverlayForeground / notifyOverlayBackground。
细节在配对:桥接自己维护前台标志,没有活动取景器时 removeViewfinder 不会发出孤立的 background;切换相机会按 background → foreground 的顺序成对发出;退出世界时重置并补一次 background。要求 ControlFlex ≥ 0.8.7(0.8.7 重命名了这两个方法)、Exposure ≥ 1.9.20。
cfx-compat-emi:22 个伪 KeyMapping
EMI 把键位存在自己的配置(binds.*)里、在自己的界面里匹配,一个 Minecraft KeyMapping 都不注册 —— 所以它的动作既不出现在原版控制里,也不会被控制器的自动发现看到。桥接注册 22 个伪 KeyMapping 让 Control Flex 发现,按下时转发回 EMI 自己的交互函数:不伪造输入,也不改写 EMI 的键位设置。
| 分组 | 动作 |
|---|---|
| 界面 | 打开 / 关闭 EMI、聚焦搜索、清空搜索 |
| 浏览 | 查看配方 / 用途、加入收藏、配方树、物品树、前进 / 后退 |
| 合成 | 合成一份 / 一组、送到背包(单份 / 整组)、送到光标(单份) |
| 创造 | 作弊给一份 / 一组到背包或光标 |
| 管理 | 隐藏物品、按 ID 隐藏、复制配方 ID |
全部落在 GUI 层,绑定列表里显示为「Control Flex x EMI」。多数动作作用于光标悬停的物品,所以要先指过去再按;输入框获得焦点、EMI 关闭或重载时只有「开关 EMI」有效。未支持:defaultStack、deleteCursorStack、showCraft(前两个是配方界面深处的内联逻辑,第三个是「按住状态」而不是动作)、displayAllRecipes(未进发布 jar)、配方界面上的「复制配方 ID」(上下文在私有字段里)。它也不会改写 EMI 自己的 binds.*。
cfx-compat-vehicles:载具只认 GLFW
沉浸车辆(MTS)用 GLFW.glfwGetKey(window, keyCode) 读操作,既不看 KeyMapping 也不看 InputConstants —— keyMapping / virtualKbm / glfwPoll 三个通道都够不到它。GLFW 层本身在 Forge 上不可注入(库类由系统类加载器加载,不经过 ModLauncher),所以 mixin 只能落在载具模组自己的读取路径里:InterfaceInputMixin 挂 mcinterface1201.InterfaceInput#isKeyPressed,四个移动键返回 true。
判断信号仍然是玩家自己的 forwardImpulse / leftImpulse(含模拟移动的曲线),为零时回落到动作状态,所以关掉模拟移动也能用;不轮询、不写全局键态、物理按键优先。不随包发 JSON:它直接读本体的信号,没有需要跟配置格式同步的规则集。已知限制:IV 自己的键位(car.gas 等)被忽略,改过就复位成 WASD;不要给 IV 绑内置手柄轴,那会置 kbOverride 并让桥接失效;mixin 目标是带版本号的包名(mcinterface1201),换 MC 版本必须重新构建。
桥接与 JSON 不是二选一:多数桥接自己也会在 onInstallCompatConfigs 里装一份 JSON 到 mods/ 层 —— 把「键位怎么摆」交给 JSON,「运行时怎么算」留给 Java。社区配置库里 create / emi 的配置还反过来用 dependencies 要求桥接已安装。
五种手法,五个真实例子
1)键位根本没注册:补一组伪 KeyMapping(EMI)
注册一组普通 KeyMapping 让自动发现看到,再把「按下」转发回模组自己的交互入口 —— 不伪造输入,也不改模组的键位设置。
// Register a pseudo key mapping (registration API differs per MC version / loader)
new KeyMapping("key.cfx_target.open", GLFW.GLFW_KEY_K, "key.categories.cfx_target");
// Each tick, forward Control Flex's action state into the mod's own entry point
if (ControlFlexApi.getActionStateProvider()
.isGameActionActive("cfx_target:key.cfx_target.open")) {
TargetMod.openPanel(); // call the mod's function, do not synthesize a key press
}
2)模组直接读原始键态:mixin 到它的读取点(载具 / Create)
glfwPoll 通道能覆盖 InputConstants.isKeyDown 这类轮询;但直接调 GLFW.glfwGetKey 的模组读不到 —— GLFW 由系统类加载器加载,不经过 ModLauncher 的转换。办法是把 mixin 落到目标模组自己的读取入口上,回答「现在按着没有」。
// Only answer for the four movement keys; everything else keeps the original logic
@Mixin(TargetInput.class)
public abstract class TargetInputMixin {
@Inject(method = "isKeyPressed", at = @At("HEAD"), cancellable = true)
private void cfx$steering(int keyCode, CallbackInfoReturnable<Boolean> cir) {
if (ControlFlexApi.isAvailable()
&& ControlFlexApi.getActionStateProvider().isGameActionActive("move_forward")) {
cir.setReturnValue(true);
}
}
}
把这种 mixin 关在独立的桥接模组里,核心就不必背第三方依赖,出问题也只影响这一个兼容包。
3)模组有运行时状态:推 state,让 JSON 消费(Epic Fight)
Epic Fight 的「战斗模式」决定了哪些按键该被模组接管。Java 侧只负责把状态推出去,具体压制哪些动作写进 JSON —— 玩家和包作者都能改。
// Java: push once whenever the state flips
ControlFlexApi.getPlayerStateRegistry()
.setState("epicfight:battle_mode", inBattle);
// JSON: consume it as a condition (holding a katana in battle mode: suppress generic use)
"itemSuppressKeys": [
{ "item": "efn:yamato_dmc*",
"playerState": ["epicfight:battle_mode"],
"suppressActions": ["use"] }
]
4)自绘叠层:声明前台 / 后台(Exposure)
自绘 HUD 不是 Screen,Control Flex 看不见它。打开、关闭时各通报一次,本体就会在 Overlay 相位里按 JSON 的 overlay 表切换摇杆与光标;类名要和 overlay 的键对齐。
public final class OverlayNotifier {
public static void onOpened(Object overlay) {
IInteractiveContextRegistrar registrar = ControlFlexApi.getInteractiveContextRegistrar();
if (registrar != null) {
registrar.notifyOverlayForeground(overlay.getClass().getName());
}
}
public static void onClosed(Object overlay) {
IInteractiveContextRegistrar registrar = ControlFlexApi.getInteractiveContextRegistrar();
if (registrar != null) {
registrar.notifyOverlayBackground(overlay.getClass().getName());
}
}
}
成对通报是硬要求:只发 foreground 不发 background,摇杆行为会卡在叠层模式;退出世界时用 clearAll() 兜底。
5)模组抢了相机:接管视角增量
相机被接管后,写 yRot/xRot 不会改变画面。实现 ICameraLookConsumer,把增量交给接管方;返回 true 表示这一帧已经被消费,本体不再写玩家旋转。
@Override
public ICameraLookConsumer cameraLookConsumer() {
return (yaw, pitch) -> {
if (!ControlFlexApi.isAvailable()) return false;
return TargetCameraApi.getInstance().turnCamera(yaw, pitch);
};
}