← 返回索引

范围:依赖的引入方式、最小 FluentScreen 的组成、Screen 子类的 8 条输入转发与基类职责,以及设置页、表单、模态对话框、动态列表等常见界面的组合方式。

前置:具备 Fabric 与 Gradle 模组开发基础。


最小可用路径

一个基本的 FluentUI 界面需要四样内容或操作:引入依赖、一个 FluentScreen 子类、一个放进根容器的组件、一次 setScreen。

第一步 添加依赖

FluentUI 发布在 GitHub Packages,版本列表见发布页。GitHub Packages 的 Maven 仓库读取时需要凭据:先申请一个勾选了 read:packages 的 personal access token,再把仓库与依赖坐标写进 build.gradle:

repositories {
    maven {
        url 'https://maven.pkg.github.com/xianfish33/FluentUI-MC'
        credentials {
            username = "你的 GitHub 用户名"
            password = "你的 personal access token"
        }
    }
}

dependencies {
    modImplementation "xianfish.fluentui:fluentui:${project.fluentui_version}"
}

fluentui_version 填发布页上的版本号,当前是 26A.14.0。开发期也可以沿用 Fabric Loom 的文件依赖方式,坐标以项目内 fluentui_version 的实际取值为准。

Note

版本号中的第一个小版本号26A表示兼容性分支,指的是分支兼容26.1-26.2

版本好第二个小版本号是基线版本,同步主要改动

版本号中第三个是兼容性分支内的未同步改动版本

所有版本号线性递增,不分改动大小

第二步 第一个 Screen

可直接复制的骨架包含 init() 装配、全部 8 条输入转发,以及渲染与每帧 tick 驱动两类必须覆写的方法:

public class MyScreen extends FluentScreen {
    private LayoutElement<?> root;
    private OverlayManager overlays;
    private InteractionManager interactionManager;
    private final UiTickDriver uiTickDriver = new UiTickDriver();

    public MyScreen() {
        super(Component.literal("My Screen"));
    }

    @Override protected void init() {
        // 1. 元素树的根:整个界面的组件都挂在它下面
        StackPanel root = new StackPanel(Orientation.VERTICAL)
            .size(400, 300)
            .spacing(8)
            .topLeft(width / 2 - 200, height / 2 - 150);  // 屏幕居中
        this.root = root;

        // 2. 放进第一个组件;.font(font) 把 Screen 的字体交给它——文本组件没有字体不渲染
        root.add(new Button("Click Me", 200, 24)
            .font(font)
            .action(() -> System.out.println("Clicked!")));

        // 3. 浮层表与交互管理器:必须在 root 之后创建
        overlays = new OverlayManager();
        interactionManager = new InteractionManager(root, overlays);
        InteractionManager.active = interactionManager;  // 组件靠这个单例查悬停与焦点
    }

    // --- 输入转发:8 条缺一不可(逐条症状见下文)---
    // 注意 MC 侧是原生事件对象,框架侧是自带的事件 record,所以每条都要现造一个:
    // 两边的事件类型不同名、不同包,直接转发是编译不过的。
    // 鼠标按下在 MC 侧叫 mouseClicked,多一个 doubleClick 参数,不是 mousePressed。
    @Override public boolean mouseClicked(MouseButtonEvent e, boolean doubleClick) {
        return interactionManager != null && interactionManager.mousePressed(
            new MousePressedEvent(e.x(), e.y(), e.button(), toMods(e.modifiers())));
    }
    @Override public boolean mouseReleased(MouseButtonEvent e) {
        return interactionManager != null && interactionManager.mouseReleased(
            new MouseReleasedEvent(e.x(), e.y(), e.button(), toMods(e.modifiers())));
    }
    @Override public boolean mouseDragged(MouseButtonEvent e, double dx, double dy) {
        return interactionManager != null && interactionManager.mouseDragged(
            new MouseDraggedEvent(e.x(), e.y(), e.button(), dx, dy, toMods(e.modifiers())));
    }
    @Override public boolean mouseScrolled(double mx, double my, double scrollX, double scrollY) {
        return interactionManager != null && interactionManager.mouseScrolled(
            new MouseScrolledEvent(mx, my, scrollX, scrollY, Modifiers.NONE));
    }
    @Override public boolean keyPressed(KeyEvent e) {
        return (interactionManager != null && interactionManager.keyPressed(
            new KeyPressedEvent(e.key(), e.scancode(), toMods(e.modifiers()))))
            || super.keyPressed(e);
    }
    @Override public boolean keyReleased(KeyEvent e) {
        return (interactionManager != null && interactionManager.keyReleased(
            new KeyReleasedEvent(e.key(), e.scancode(), toMods(e.modifiers()))))
            || super.keyReleased(e);
    }
    @Override public boolean charTyped(CharacterEvent e) {
        return (interactionManager != null && interactionManager.charTyped(
            new CharTypedEvent(e.codepoint()))) || super.charTyped(e);
    }
    @Override public boolean preeditUpdated(PreeditEvent e) {
        return (interactionManager != null && interactionManager.preeditUpdated(
            new PreeditUpdatedEvent(e.fullText(), e.caretPosition(), e.blocks(), e.focusedBlock())))
            || super.preeditUpdated(e);
    }

    // GLFW 修饰键位 → 框架 Modifiers 位。不换算的话,Ctrl+CV 这类组合键在框架侧读不出来。
    private static int toMods(int glfwMods) {
        int m = Modifiers.NONE;
        if ((glfwMods & 0x1) != 0) m |= Modifiers.SHIFT;
        if ((glfwMods & 0x2) != 0) m |= Modifiers.CONTROL;
        if ((glfwMods & 0x4) != 0) m |= Modifiers.ALT;
        if ((glfwMods & 0x8) != 0) m |= Modifiers.SUPER;
        return m;
    }

    // --- 渲染 ---
    @Override public void extractRenderState(GuiGraphicsExtractor e, int mx, int my, float d) {
        Tooltip.cursor(mx, my, width, height);
        uiTickDriver.onRenderFrame(interactionManager);  // 推进滚动弹簧、浮层动画等每帧状态
        root.extractRenderState(e, mx, my, d);
        overlays.renderOverlay(e, mx, my, d);  // 浮层画在根树之上
    }
}
Note

root、overlays、interactionManager 三个字段由子类持有,FluentScreen 基类并不持有它们。

root 不是一个独立元素、容器基类,每个屏幕可以按需用任意元素赋值根树。

装配顺序:先有元素树 root 和浮层表 overlays,才能 new InteractionManager(root, overlays)(它不会自动登记为全局 InteractionManager.active,需要在 init() 里显式赋值 InteractionManager.active = interactionManager,并在屏幕移除时置回 null,组件靠这个单例查悬停与焦点)。渲染中 Tooltip.cursor 负责 tooltip 定位,浮层始终用真实坐标绘制。

需要一行静态文字时写 new Text("...", 120),Text 不是框架内置类,是原版自带绘制方法,其实现配方见 核心概念 的 Text 一节。

第三步 打开屏幕

在模组任何位置调用:

Minecraft.getInstance().setScreen(new MyScreen());

三步完成后最小闭环成立:屏幕显示、按钮响应。

Screen 集成检查清单

创建新的 FluentScreen 子类时,逐项核对:

  • init():创建根 LayoutElement、OverlayManager、InteractionManager 三个字段(FluentScreen 不持有它们)
  • 输入转发:全部 8 条转发给 interactionManager(鼠标按下在 MC 侧叫 mouseClicked,另有一个 doubleClick 参数),完整代码见八个输入转发方法
  • 渲染:extractRenderState 依次调用 Tooltip.cursor → root.extractRenderState → overlays.renderOverlay
  • Tick:extractRenderState 里调用 uiTickDriver.onRenderFrame(interactionManager),漏调的后果是滚轮不响应、动画停止(步长与遍历口径见 生命周期系统)
  • 背景:需要自定义背景时覆盖 extractBackground(FluentScreen 默认已提供全景 + 模糊)

八个输入转发方法

Minecraft 将输入回调分发给 Screen 子类,而框架真正的事件入口是 InteractionManager。

命中链、焦点转移、浮层分发都发生在其内部(分发算法见 交互引擎)。子类必须覆写并转发 8 个方法。

1. 鼠标按下 mouseClicked

签名 boolean mouseClicked(MouseButtonEvent e, boolean doubleClick),方法体构造 new MousePressedEvent(e.x(), e.y(), e.button(), toMods(e.modifiers())),转发给 interactionManager.mousePressed(...)。

2. 鼠标松开 mouseReleased

签名 boolean mouseReleased(MouseButtonEvent e),转换为 new MouseReleasedEvent(e.x(), e.y(), e.button(), toMods(e.modifiers())),转发给 interactionManager.mouseReleased(...)。

3. 鼠标拖拽 mouseDragged

签名 boolean mouseDragged(MouseButtonEvent e, double dx, double dy),转换为 new MouseDraggedEvent(e.x(), e.y(), e.button(), dx, dy, toMods(e.modifiers())),转发给 interactionManager.mouseDragged(...)。

4. 滚轮 mouseScrolled

签名 boolean mouseScrolled(double mx, double my, double scrollX, double scrollY),转换为 new MouseScrolledEvent(mx, my, scrollX, scrollY, Modifiers.NONE),转发给 interactionManager.mouseScrolled(...)。

5. 按键 keyPressed

签名 boolean keyPressed(KeyEvent e),转换为 new KeyPressedEvent(e.key(), e.scancode(), toMods(e.modifiers())),转发给 interactionManager.keyPressed(...),且必须保留 || super.keyPressed(e) 兜底。

6. 按键松开 keyReleased

签名 boolean keyReleased(KeyEvent e),转换为 new KeyReleasedEvent(e.key(), e.scancode(), toMods(e.modifiers())),转发给 interactionManager.keyReleased(...),同样保留 || super.keyReleased(e)。

7. 字符输入 charTyped

签名 boolean charTyped(CharacterEvent e),转换为 new CharTypedEvent(e.codepoint()),转发给 interactionManager.charTyped(...) 并保留 || super.charTyped(e)。

8. 输入法组字 preeditUpdated

签名 boolean preeditUpdated(PreeditEvent e),转换为 new PreeditUpdatedEvent(e.fullText(), e.caretPosition(), e.blocks(), e.focusedBlock()),转发给 interactionManager.preeditUpdated(...) 并保留 || super.preeditUpdated(e)。


基类已完成的处理

  • mouseMoved(double, double):基类将其转给 InteractionManager.updateHover。悬停的 enter/exit/move 由这一条一级驱动,鼠标一动就刷新悬停归属。无需转发它;若因特殊需求覆写,必须调用 super.mouseMoved(...),否则悬停高亮与 tooltip 会整体失效。
  • extractBackground(...):非游戏内画全景加模糊、游戏内模糊世界。默认背景已经处理好,除非替换为自定义背景,否则不必覆写。

不变量与注意

  • 键盘三个方法(keyPressed / charTyped / preeditUpdated)必须保留 || super... 兜底;省略 super 会让 ESC 退出等原版默认按键行为一起失效。
  • 8 条转发都要判 interactionManager != null:init() 执行完成之前事件就可能到达,直接调用 interactionManager.xxx 会触发空指针崩溃。
  • mouseMoved 不转发(基类已将其转给 updateHover);覆写又不调用 super 会使悬停体系整体失效。
  • root、overlays、interactionManager 三个字段是子类的责任:少建一个,或先建 interactionManager 后建 root,交互从第一帧起即失效。
  • 示例里的 new Text(...) 不是框架内置组件。实现配方见 核心概念,其他文档中的 Text 均指该实现。

常见使用模式

构建标准设置页面

设置页由标题、若干「标签 + 控件」行和一枚保存按钮组成。

public class SettingsScreen extends FluentScreen {
    private StackPanel root;
    private OverlayManager overlays;
    private InteractionManager interactionManager;

    public SettingsScreen() { super(Component.literal("Settings")); }

    @Override protected void init() {
        root = new StackPanel(Orientation.VERTICAL)
            .size(400, 300)
            .topLeft(width / 2 - 200, height / 2 - 150)
            .overflow(OverflowMode.SCROLL)
            .scrollbar(true);

        // 标题(纯文本)
        root.add(new Text(Component.literal("Settings")));

        // 音量滑块
        root.add(new EntryRow(380, 120)
            .left(new Text("Volume"))
            .right(new Slider(240).value(0.8f).onChanged(v -> saveVolume(v))));

        // 启用开关
        root.add(new EntryRow(380, 120)
            .left(new Text("Dark Mode"))
            .right(new Toggle(0, 0).on(isDarkMode).onChange(() -> toggleDark())));

        // 保存按钮
        root.add(new Button("Save", 200, 24)
            .action(() -> { saveAll(); close(); }));

        overlays = new OverlayManager();
        interactionManager = new InteractionManager(root, overlays);
    }

    private void close() { Minecraft.getInstance().setScreen(null); }

    // 转发共 8 条,此处示范鼠标按下;其余照上文清单
    @Override public boolean mouseClicked(MouseButtonEvent e, boolean doubleClick) {
        if (interactionManager == null) return false;
        return interactionManager.mousePressed(
            new MousePressedEvent(e.x(), e.y(), e.button(), toMods(e.modifiers())));
    }
}

表单验证

带验证逻辑的表单把「输入 → 校验 → 启用提交」串在 onChanged 上:

StackPanel form = new StackPanel(Orientation.VERTICAL)
    .size(300, 200)
    .spacing(8);

TextBox nameInput = new TextBox(200, 20)
    .placeholder("Enter name...")
    .onChanged(v -> validateForm());

TextBox emailInput = new TextBox(200, 20)
    .placeholder("Enter email...")
    .onChanged(v -> validateForm());

Button submitBtn = new Button("Submit", 100, 24)
    .action(() -> submitForm());

form.add(new EntryRow(280, 80).left(new Text("Name")).right(nameInput));
form.add(new EntryRow(280, 80).left(new Text("Email")).right(emailInput));
form.add(submitBtn);

// 验证逻辑
private void validateForm() {
    boolean valid = !nameInput.text().isEmpty() && emailInput.text().contains("@");
    submitBtn.setEnabled(valid);
}

模态对话框

对话框是一个普通面板,经 InteractionManager.active.overlays() 的全参 push 推成模态,即点击外部不关闭:

StackPanel dialog = new StackPanel(Orientation.VERTICAL)
    .size(300, 150)
    .bgColor(0xFF1E1E1E);

dialog.add(new Text("Delete this item?"));
dialog.add(new StackPanel(Orientation.HORIZONTAL)
    .spacing(8)
    .add(new Button("Yes", 100, 24).action(() -> { deleteItem(); closeDialog(); }))
    .add(new Button("No", 100, 24).action(() -> closeDialog())));

// dismissOnOutsideClick=false 不自动关闭
InteractionManager.active.overlays().push(dialog, false);

确认框使用全参 push,仅由框内按钮关闭;开关的取值与全参语义见覆盖层。

提示类浮层则相反:单参 overlays().push(el) 推入即带 dismissOnOutsideClick=true,外部的明确输入路由到浮层的 onOutsideInteraction() 钩子,由该钩子关闭浮层;外部关闭在点击分发之前执行(分发过程中新建的浮层不参与本次关闭),顺序细节见交互引擎。


动态列表

运行时增删条目,每次改动后触发重新布局:

StackPanel list = new StackPanel(Orientation.VERTICAL)
    .size(300, 400)
    .spacing(4)
    .overflow(OverflowMode.SCROLL)
    .scrollbar(true);

// 动态添加项目
void addItem(String item) {
    list.add(new Button(item, 280, 24)
        .action(() -> { /* 处理项目点击 */ }));
    list.markDirty();  // 触发重新布局
}

// 删除项目
void removeItem(int index) {
    if (index < list.children().size()) {
        list.children().remove(index);
        list.markDirty();
    }
}
  • 条目数量超过可视区域时,overflow(OverflowMode.SCROLL) 配合 scrollbar(true) 由容器管理滚动偏移,无需手动裁剪;滚动条不出现时,确认两个开关均已设置,语义详见布局容器。

可拖拽面板

面板在标题栏按下后捕获鼠标,拖动期间跟随光标:

public class DraggablePanel extends StackPanel {
    private boolean dragging;
    private double dragOffsetX, dragOffsetY;

    public DraggablePanel(int w, int h) {
        super(Orientation.VERTICAL);
        size(w, h);
    }

    @Override public boolean mousePressed(MousePressedEvent e) {
        if (e.button() == 0 && inTitleBar(e.x(), e.y())) {
            InteractionManager.active.requestMouseCapture(this);
            dragOffsetX = e.x() - getAbsoluteX();
            dragOffsetY = e.y() - getAbsoluteY();
            dragging = true;
            return true;
        }
        return super.mousePressed(e);
    }

    @Override public boolean mouseDragged(MouseDraggedEvent e) {
        if (dragging) {
            setPos((int) (e.x() - dragOffsetX), (int) (e.y() - dragOffsetY));
            return true;
        }
        return super.mouseDragged(e);
    }

    @Override public boolean mouseReleased(MouseReleasedEvent e) {
        if (dragging) { dragging = false; return true; }
        return super.mouseReleased(e);
    }

    private boolean inTitleBar(double mx, double my) {
        int ax = getAbsoluteX(), ay = getAbsoluteY();
        return mx >= ax && mx <= ax + width && my >= ay && my <= ay + 30;  // 30px 标题栏
    }
}
  • 光标拖出面板边界后仍需继续拖动时,requestMouseCapture(this) 将后续拖动/抬起事件定向到该组件,不再走常规命中分发。若拖动中途松开而面板仍跟随光标,检查 mouseReleased 是否清除了 dragging(捕获的释放时机,例如属主不可见时自动清除,见交互引擎)。
  • 标题栏区域随内容变化时,将 inTitleBar 替换为自定义几何判断,命中形状的职责边界见碰撞检测覆盖。修改判断后仍无法命中时,临时打印 getAbsoluteX/Y 与宽高,核对坐标系。

自定义组件

继承 Element 实现一个色块组件,固定尺寸不需要测量:

public class ColorSwatch extends Element<ColorSwatch> {
    private int color;
    private Animator hoverAnim = new Animator();
    private Runnable onClick;

    public ColorSwatch(int color, int size) {
        super(size, size);
        this.color = color;
    }

    public ColorSwatch onClick(Runnable r) { this.onClick = r; return this; }

    @Override protected void measure() {}  // 固定尺寸,无需测量

    @Override
    public void extractRenderState(GuiGraphicsExtractor e, int mx, int my, float d) {
        if (!visible || font == null) return;
        int ax = getAbsoluteX(), ay = getAbsoluteY();
        float hov = hoverAnim.get();
        int borderColor = Colors.lerp(0xFF555555, 0xFF3B82F6, hov);
        e.fill(ax - 1, ay - 1, ax + width + 1, ay + height + 1, borderColor);
        e.fill(ax, ay, ax + width, ay + height, color);
    }

    @Override public boolean mousePressed(MousePressedEvent e) {
        if (e.button() == 0 && Element.isHit(this, e.x(), e.y())) {
            if (onClick != null) onClick.run();
            return true;
        }
        return false;
    }

    @Override public void mouseEntered(HoverEvent e) {
        hoverAnim.to(1f, 120, Easing.EASE_OUT_CUBIC);
    }

    @Override public void mouseExited(HoverEvent e) {
        hoverAnim.to(0f, 120, Easing.EASE_OUT_CUBIC);
    }
}
  • 组件尺寸固定、不参与父级重算时,measure() 留空即可,父容器按构造时给的宽高排列它;父级重排后色块位置不对,确认尺寸变更通过 size(...) 等链式 setter(会标脏)完成,而不是裸字段赋值。裸赋值跳过标脏的后果与排查方式见布局系统。
  • 悬停/点击需要动画与回调时,mouseEntered/mouseExited 驱动 Animator,mousePressed 用 Element.isHit 复核形状后消费事件并返回 true;未命中却触发回调时,isHit 内部先做形状命中再做裁剪边界检查,组成细节见交互引擎。

自定义布局容器

线性排列不满足需求时,继承 LayoutElement 实现测量与排列:

public class MyLayout extends LayoutElement<MyLayout> {
    private boolean autoWidth, autoHeight;

    @Override protected void measure() {
        // 计算子元素所需的总尺寸
        int totalWidth = 0;
        int maxHeight = 0;
        for (Element<?> child : children) {
            if (!child.isVisible()) continue;
            child.layout();
            totalWidth += child.getWidth() + child.getMargin().horizontal();
            maxHeight = Math.max(maxHeight, child.getHeight() + child.getMargin().vertical());
        }
        if (autoWidth) width = totalWidth;
        if (autoHeight) height = maxHeight;
    }

    @Override protected void arrange() {
        // 定位子元素
        int cursor = 0;
        for (Element<?> child : children) {
            if (!child.isVisible()) continue;
            Margin m = child.getMargin();
            child.setPos(cursor + m.left(), m.top());
            cursor += child.getWidth() + m.horizontal();
        }
    }
}
  • 需要实现 StackPanel 未提供的排列算法时,惰性布局把工作分成两步:
    measure() 计算元素自身所需尺寸,arrange() 确定每个子元素的摆放位置,两者都由 layout() 在标脏后调用。
    两个阶段都要跳过 !child.isVisible() 的子元素,否则隐藏的子元素仍占用空间;应检查 measure()/arrange() 是否都包含可见性跳过。
  • 子元素增删后容器尺寸没变时,容器的 dirty 沿父链传播后才会重新执行测量与排列,直接修改 children 不标脏;调用了 child.layout() 仍无效时,打印容器 isDirty(),标脏传播见布局系统。

碰撞检测覆盖

覆盖 shapeHit 实现自定义命中形状(例如圆角矩形):

@Override public boolean shapeHit(double mx, double my) {
    int ax = getAbsoluteX(), ay = getAbsoluteY();
    // 检查点是否在圆角矩形内
    return isInsideRoundedRect(mx, my, ax, ay, width, height, cornerRadius);
}
  • 不变量:只覆盖 shapeHit(H1 层),绝不覆盖 isHit(L1 层)。isHit 把「形状命中」与「祖先裁剪边界检查」组合成统一谓词;覆写它会丢失裁剪语义。
  • 组件为圆形、圆角或带缺口的形状时,分发、悬停、外部关闭都经由 isHit → shapeHit 这条链,自定义形状只需判定该点是否落在组件自身形状内,裁剪与树搜索由框架补上。滚动区域外仍算命中;确认未覆写 isHit,Element.isHit 的实现显示,形状命中之后还执行裁剪边界检查(各层(H0/H1/L1/H3/L3/H5)的完整定义在交互引擎)。
  • 修改 shapeHit 后无效果时,命中查询每次实时调用 shapeHit、不缓存,也不需要标脏;此时先核对坐标(getAbsoluteX/Y),再核对几何公式。

相关文档