范围:在不改动主构建的前提下手工核对组件行为,以及向 DevGallery 添加组件。涉及 testmod 单独成源集的理由、
build.gradle三段式接线的分工、DevGallery 的打开方式、新组件需要改动的位置。分包理由与目录结构见 概览,Screen 层的输入接入见 根树与层级系统。
源集构成
src/testmod/ 是一个自建的测试源集,内容只有一个模组:登记一个按键绑定(默认 G,按键设置里可改)、打开展示全部内置组件的页面,用于手工核对。
- 源码:
src/testmod/java/xianfish/fluentui/testmod/(命令入口、展示页,以及component/下的自建组件) - 资源:
src/testmod/resources/fabric.mod.json(idfluentui-testmod,depends 主模组fluentui) - 编译命令:
.\gradlew.bat compileTestmodJava --console=plain(对应源集名自动生成)
它在运行时是一个独立的 mod 标签,但依赖主模组与 fabric-api,功能上只是主模组的独立核对页,不随主 jar 发布。
独立源集的理由
三个候选方案均不采用,理由如下:
| 方案 | 排除理由 |
|---|---|
src/gametest | gametest 源集服务于 CI/CD 自动化冒烟测试:fabricApi.configureTests 会建 runGameTest / runClientGameTest 自动化运行配置,语义与手工核对页不符 |
src/test | 那是单元测试(JUnit)源集,DevGallery 需要真实游戏线程与渲染 |
直接放 src/client/ 下 | 混入正式发布内容,需依赖代码审查拦截,不如用源集边界隔离 |
独立成源集的好处:依赖边界清楚(承载编译期所需的 classpath)、随构建集成(编译命令自动生成)、并且不参与主 jar 的装配。
build.gradle 三段接线
接线分三步,顺序不可颠倒;三步均曾出现配置错误,见「常见配置错误」。
1. 声明源集
sourceSets {
testmod // 空体;目录走默认约定 src/testmod/{java,resources},不必写 srcDir
}
必须是空体且在 loom {} 之前。 块内不能写 compileClasspath += sourceSets.client.output 之类:splitEnvironmentSourceSets() 是在 loom {} 块里执行的,此时 client 源集尚不存在,因此写在前面会在配置期直接报“unknown property ‘client’“。
2. 类路径分组声明
loom {
mods {
"fluentui" {
sourceSet sourceSets.main
sourceSet sourceSets.client
}
"fluentui-testmod" {
sourceSet sourceSets.testmod
}
}
}
这是 Loom 类路径分组系统在开发时的语义声明,作用是 dev 运行时让同属本区的资源(这里关键是 testmod 源集的 fabric.mod.json)能被正确加载;最终 jar(main+client 合体)不受影响。官方文档:https://docs.fabricmc.net/zh_cn/26.1.2/develop/loom/classpath-groups
漏写该声明时,dev 运行时按 G 打不开页面(按键设置里也找不到「打开 DevGallery 展示页」这一项),因为 testmod 的 fabric.mod.json 未被加载,整个测试模组不参与 dev 运行时;该症状不易定位,因此单独列一条检查项。分组里的两个字符串本身只用于声明(组名与 modId),不必与目录或包名一致;但 sourceSet 引用的对象必须是已声明的源集名。
3. 类路径补齐
configurations {
// MC 与 mappings 都挂在 CompileClasspath 系配置上,从 implementation 继承接不到
testmodCompileClasspath.extendsFrom(compileClasspath, clientCompileClasspath)
testmodRuntimeClasspath.extendsFrom(runtimeClasspath, clientRuntimeClasspath)
}
sourceSets.testmod {
compileClasspath += sourceSets.main.output
compileClasspath += sourceSets.client.output
runtimeClasspath += sourceSets.main.output
runtimeClasspath += sourceSets.client.output
}
这组配置对应 Loom 内部 configureSourceSet 的 dependsOn 行为:为源集间添加 output(编译产物)并 extendsFrom 对应的 CompileClasspath/RuntimeClasspath 配置。extendsFrom 的对象容易出错:写 testmodImplementation.extendsFrom(implementation, clientImplementation) 看似合理,实际无法生效,因为 Minecraft 类位于 compileClasspath 配置中,不经过 implementation。症状是 testmod 自身代码编译报“程序包 net.minecraft.* 不存在“。
常见配置错误
| 现象 | 原因 |
|---|---|
| line XX:unknown property ‘testmod’ | sourceSets { testmod {...} } 放在 loom {} 之后,引用它的 loom.mods 先一步求值 |
| line XX:unknown property ‘client’ | 源集声明块内部直接引用了 sourceSets.client.output,那时 client 尚未由 loom 生成 |
| 程序包 net.minecraft.* 不存在 | extendsFrom 指向 implementation,实际注册 Minecraft 依赖类的是 CompileClasspath 系配置 |
DevGallery 页
入口在 FluentUiDevGalleryClient:登记一个按键绑定(默认 G,按键设置里可改),游戏每帧末尾消费按下的次数并打开页面:
DEV_GALLERY_KEY = KeyMappingHelper.registerKeyMapping(new KeyMapping(
"key.fluentui.devgallery", InputConstants.Type.KEYSYM, GLFW.GLFW_KEY_G, KeyMapping.Category.MISC));
ClientTickEvents.END_CLIENT_TICK.register(client -> {
while (DEV_GALLERY_KEY.consumeClick()) {
if (client.screen == null) client.setScreen(new DevGalleryScreen());
}
});
MC 26.2 已把旧 KeyBinding 改名 KeyMapping,Fabric 的工具类随之是 keymapping.v1.KeyMappingHelper(不再有 keybinding.v1 / KeyBindingHelper);consumeClick() 就是旧版的 wasPressed()。Category.MISC 自带译文,category 不需要 lang 键;只有 "key.fluentui.devgallery" 这个键要译文,落在 testmod 自己的资源里(src/testmod/resources/assets/fluentui-testmod/lang/zh_cn.json / en_us.json)。while 与 client.screen == null 两层守卫的含义是:积压的按下次数一次性消费完,且已打开其他界面时不叠加打开新页面。
页面由 DevGalleryScreen 实现。各行通过 EntryRow(380, 100).left(标签).right(组件) 逐行堆叠,容器为 StackPanel(VERTICAL) + overflow(OverflowMode.SCROLL);框架无内置 Label 组件,改用文档示例中的极简实现 GalleryLabel extends Element<GalleryLabel>。
页面各行的构成:17 行,覆盖 15 个内置组件与 1 个测试源集自建组件。
| 行(左列标签) | 内容要点 |
|---|---|
| Button | `“点我” + action 触发一条 MessageFlyout |
| Toggle / SelectBox | 状态回读(isOn() / isSelected()) |
| Slider | steps(11) + .tooltip(...)(自动跟随鼠标的链路) |
| Stepper(两行) | FREE + allowInput + suffix;另一行 PERCENT 模式 |
| TextBox / AutoCompleteBox | .maxLength(64) / dataset(List.of(...)) |
| SelectDropdown / ProgressBar | options(List) 提供全部四个颜色;固定 66% |
| Gradient | 测试源集自建的 GradientBox,四角色深蓝到亮蓝,验证自绘渐变的插值口径 |
| ListView / ItemList / ItemView | ItemList.renderer 设置标签;ItemView(ItemStack.EMPTY) 关闭装饰 |
| Tooltip / ContextMenu / MessageFlyout | 各留一个按钮触发(Tooltip 采用绝对坐标唤起) |
Screen 层输入转发的原生签名
页面里 8 条转发方法必须使用 Minecraft 原生事件类型,并与 Screen 的真实签名一一对齐,即 mouseClicked(MouseButtonEvent e, boolean doubleClick)、mouseReleased(MouseButtonEvent e)、mouseDragged(MouseButtonEvent e, double dx, double dy)、mouseScrolled(double mx, double my, double scrollX, double scrollY)、keyPressed(KeyEvent e)、keyReleased(KeyEvent e)、charTyped(CharacterEvent e)、preeditUpdated(PreeditEvent e)。注意鼠标按下在 MC 侧名为 mouseClicked,而非 mousePressed。方法体内再构造框架自己的事件 record(MousePressedEvent(x, y, button, mods) / MouseReleasedEvent / MouseDraggedEvent / MouseScrolledEvent / KeyPressedEvent / KeyReleasedEvent / CharTypedEvent(codepoint) / PreeditUpdatedEvent(fullText, caretPosition, blocks, focusedBlock))并下发给 InteractionManager;MC 类型只出现在覆写签名一侧,框架 record 只在方法体一侧出现,两处不得混用。修饰键经 toMods(int) 转换:GLFW 位 0x1/0x2/0x4/0x8 分别映射 Modifiers.SHIFT/CONTROL/ALT/SUPER,直接传 MC 的 e.modifiers() 会出错。
渲染循环遵循 根树与层级系统 的流水线:Tooltip.cursor → uiTickDriver.onRenderFrame(interactionManager) → root.extractRenderState → overlays.renderOverlay;removed() 里把 InteractionManager.active 置回 null。
维护注意
- 新增组件后:在
DevGalleryScreen.buildRows()里添加一行row(...),EntryRow 行宽 380/左列宽 100 是通用校对的配置;行高由左右子元素中的较大者决定(22 高的 Stepper 与 9×9 的 SelectBox 能共处同列),无需手动调整。 - 回调中读取自身状态的组件(Toggle、SelectBox、Slider、Stepper、SelectDropdown、ItemList、三个触发按钮)均保留字段;新增组件若需在回调中读取自身状态,应同样保留。
- 源码链接:DevGalleryScreen.java / FluentUiDevGalleryClient.java / GradientBox.java / fabric.mod.json / lang/zh_cn.json / lang/en_us.json / build.gradle