未发布版本 v6.3.0-SNAPSHOT。 本页内容来自 alpha 分支,随时可能变更,不属于任何已发布版本。

commit 49619c6 · 注入于 2026-09-13 12:23 UTC

Skip to content

声明式 GUI

实验性功能——渲染接缝已在 v6.3.0 修复

issue #200 跟踪的重绘、点击派发与 GridView 定位三处接缝,已在 v6.3.0 修复:build(BuildContext) 现在会在每次状态变化时重新执行,点击 GUI 自己物品栏之外的位置无法触发处理器,任意 widget 类型都能在 GridView 内正确定位。 @ApiStatus.Experimental 标记至少还会保留一个版本,等待运行这些被重写机制的真实服务器给出反馈。

1. 简介 (Introduction)

传统的 Bukkit GUI 开发通常是命令式的:你需要手动创建 Inventory,手动设置每一个 ItemStack,并监听 InventoryClickEvent 来处理交互。随着界面复杂度的增加,这种方式会导致代码难以维护,状态管理变得异常痛苦。

UltiTools 的声明式 GUI 框架引入了 UI = f(State) 的理念:

  • 声明式 (Declarative): 你只需要描述“当前状态下界面应该长什么样”,框架会自动处理如何从旧界面过渡到新界面。
  • 组件化 (Component-Based): 界面由一个个独立的 Widget 组合而成,易于复用和维护。
  • 响应式 (Reactive): 当数据(状态)发生变化时,界面会自动更新。

核心优势

  • 自动 Diff 更新: 框架内部使用 Diff 算法,只更新发生变化的 Slot,极大降低网络包发送量,提升客户端性能。
  • 状态管理: 内置类似 React/Flutter 的状态管理机制,轻松处理分页、多选、动态刷新等逻辑。
  • 无需手动监听: 点击事件直接绑定在组件上,无需在全局 Listener 中通过 Slot 判断逻辑。

2. 核心概念 (Core Concepts)

2.1 Widget (组件)

Widget 是用户界面的不可变描述。它们是轻量级的配置对象。

  • StatelessWidget: 无状态组件。一旦创建,其表现形式就固定了(除非父组件重建它)。适用于纯展示内容,如标题、背景板。
  • StatefulWidget: 有状态组件。它持有状态(State),当状态改变时,可以触发界面刷新。适用于计数器、分页列表、开关等。

2.2 State (状态)

State 对象包含了 StatefulWidget 在生命周期中可变的数据。

  • setState(() -> { ... }): 当你需要修改数据并刷新界面时,必须setState 中进行。这会标记当前组件为“脏”,并在下一帧触发 build 重建。

2.3 BuildContext (构建上下文)

BuildContext 是组件在 Widget 树中的句柄。它提供了访问树中其他部分(如父级数据、导航器)的能力。

3. 快速开始 (Getting Started)

3.1 创建一个简单的 GUI

所有的声明式 GUI 都继承自 DeclarativeGui 类。

java
package com.ultikits.docs.declarative;

import com.ultikits.ultitools.abstracts.gui.declarative.core.BuildContext;
import com.ultikits.ultitools.abstracts.gui.declarative.core.State;
import com.ultikits.ultitools.abstracts.gui.declarative.core.StatefulWidget;
import com.ultikits.ultitools.abstracts.gui.declarative.core.Widget;
import com.ultikits.ultitools.abstracts.gui.declarative.engine.DeclarativeGui;
import com.ultikits.ultitools.abstracts.gui.declarative.widgets.*;
import org.bukkit.entity.Player;
import org.bukkit.event.inventory.InventoryClickEvent;
import org.bukkit.event.inventory.InventoryCloseEvent;
import org.bukkit.event.inventory.InventoryOpenEvent;
import org.bukkit.inventory.ItemStack;
import org.jetbrains.annotations.NotNull;

import java.util.ArrayList;
import java.util.List;

public class MyFirstGui extends DeclarativeGui {

    public MyFirstGui(Player player) {
        // Parameters: player, GUI id, title, rows
        super(player, "my_first_gui", "Hello GUI", 6);
    }

    @Override
    public Widget build(BuildContext context) {
        // Return the root widget
        return Container.builder()
            .child(
                TextButton.builder()
                    .text("Click Me!")
                    .color("GREEN")
                    .slot(13)
                    .onClick(() -> {
                        player.sendMessage("You clicked the button!");
                    })
                    .build()
            )
            .build();
    }
}
java
// 打开 GUI
new MyFirstGui(player).open();

4. 常用组件详解 (Widget Reference)

4.1 Container (容器)

用显式子组件填充背景

自 v6.3.0 起,Container.Builder 不再有 background(...) 方法——它在 v6.2.5 中写入的字段从未被渲染路径读取过,v6.3.0 因此直接删除它,而不是让它继续静默失效。 为每个需要填充的槽位加一个指定 slotItemDisplay 子组件,复用同一个 ItemStack(例如灰色玻璃板)。

最基础的容器组件,用于包裹其他组件。

java
Container.builder()
    .child(widget1) // 添加单个子组件
    .children(listWidgets) // 添加多个子组件
    .build();

4.2 TextButton (文本按钮)

一个带有背景颜色(玻璃板)和文字的按钮,是交互的基础。

java
TextButton.builder()
    .text("Confirm")
    .color("LIME") // 使用 UltiTools Colors 定义的颜色名
    .slot(22)
    .lore("Click to confirm", "Action cannot be undone")
    .onClick(() -> {
        // 处理点击
    })
    .build();

4.3 ItemDisplay (物品展示)

用于展示一个具体的 ItemStack,支持点击事件。

java
ItemDisplay.builder(itemStack)
    .slot(10)
    .name("My Sword") // 覆盖物品原名
    .lore("Damage: 100") // 覆盖物品 Lore
    .onClick(event -> {
        // event 是 InventoryClickEvent
    })
    .build();

4.4 GridView (网格布局)

任意 widget 类型都能正确定位

自 v6.3.0 起,GridView 会在渲染时把每个子组件计算出的槽位作为父数据写入,因此任意 widget 类型——不只是 ItemDisplay——都能自动定位到自己的行列槽位;Widget 自身的 API 未变,下游自定义 widget 不需要任何改动。 子组件在 GridView 内显式声明的 .slot(...) 会被覆盖,并发出一条点名该子组件的 WARNING;没有显式槽位、或合法声明在槽位 0 的子组件,不会产生警告。

非常适合用于展示列表数据(如商店商品、背包内容)。任意 widget 类型都会被自动计算行列位置并写入槽位。

java
GridView.<ShopItem>builder()
    .startSlot(10) // 起始位置
    .columns(7)    // 每行几列
    .items(itemList, item -> {
        // 将数据对象映射为 Widget
        return ItemDisplay.builder(item.getStack())
            .name(item.getName())
            .onClick(() -> buy(item))
            .build();
    })
    .build();

GridView.Builder.rows(int) 已删除

自 v6.3.0 起,.rows(int)/getMaxRows() 不再存在——它们写入的字段从未被任何代码读取过,v6.3.0 因此直接删除,而不是去实现一条没人要求的溢出规则。 需要限制行数时,在传给 .items(...) 之前,用你自己的代码把列表截断到想要的行数。

5. 状态管理与交互 (State Management)

当界面需要根据用户操作发生变化(如翻页、选中物品)时,需要使用 StatefulWidget

示例:简单的计数器

java
package com.ultikits.docs.declarative;

import com.ultikits.ultitools.abstracts.gui.declarative.core.BuildContext;
import com.ultikits.ultitools.abstracts.gui.declarative.core.State;
import com.ultikits.ultitools.abstracts.gui.declarative.core.StatefulWidget;
import com.ultikits.ultitools.abstracts.gui.declarative.core.Widget;
import com.ultikits.ultitools.abstracts.gui.declarative.engine.DeclarativeGui;
import com.ultikits.ultitools.abstracts.gui.declarative.widgets.*;
import org.bukkit.entity.Player;
import org.bukkit.event.inventory.InventoryClickEvent;
import org.bukkit.event.inventory.InventoryCloseEvent;
import org.bukkit.event.inventory.InventoryOpenEvent;
import org.bukkit.inventory.ItemStack;
import org.jetbrains.annotations.NotNull;

import java.util.ArrayList;
import java.util.List;

// 1. Define the Widget
public class CounterWidget extends StatefulWidget {
    @Override
    public State createState() {
        return new CounterState();
    }
}
java
package com.ultikits.docs.declarative;

import com.ultikits.ultitools.abstracts.gui.declarative.core.BuildContext;
import com.ultikits.ultitools.abstracts.gui.declarative.core.State;
import com.ultikits.ultitools.abstracts.gui.declarative.core.StatefulWidget;
import com.ultikits.ultitools.abstracts.gui.declarative.core.Widget;
import com.ultikits.ultitools.abstracts.gui.declarative.engine.DeclarativeGui;
import com.ultikits.ultitools.abstracts.gui.declarative.widgets.*;
import org.bukkit.entity.Player;
import org.bukkit.event.inventory.InventoryClickEvent;
import org.bukkit.event.inventory.InventoryCloseEvent;
import org.bukkit.event.inventory.InventoryOpenEvent;
import org.bukkit.inventory.ItemStack;
import org.jetbrains.annotations.NotNull;

import java.util.ArrayList;
import java.util.List;

// 2. Define the State
public class CounterState extends State<CounterWidget> {
    private int count = 0; // persistent across rebuilds

    @Override
    public Widget build(BuildContext context) {
        return TextButton.builder()
            .slot(13)
            .text("Count: " + count)
            .color("BLUE")
            .onClick(() -> {
                // 3. update state inside setState
                setState(() -> {
                    count++;
                });
            })
            .build();
    }
}

原理解析:

  1. 用户点击按钮。
  2. setState 更新 count 变量。
  3. 框架标记 CounterWidget 需要更新。
  4. 框架重新调用 build() 方法。
  5. TextButton 被重新创建,文本变为 "Count: 1"。
  6. Diff 算法检测到 Slot 13 的物品名称变了,于是发送包更新该位置的物品(而不会刷新整个界面)。

6. 进阶技巧 (Advanced Topics)

6.1 SlotKey 的重要性

在渲染动态列表(如 GridView)时,给每个 Item 设置一个唯一的 Key 是至关重要的。这有助于 Diff 算法正确识别“移动”操作,而不是“删除再创建”。

现在真正能在重排序后保留状态

v6.3.0 之前,ContainerElementGridViewElement 只按列表位置配对子组件,SlotKey 因此不起作用,重排序一个带 key 的列表仍然会丢失每一项的 State。 自 v6.3.0 起,两个类都会优先按 SlotKey 做协调,只有没有 key 的子组件才回退到按位置配对。

java
ItemDisplay.builder(item)
    .key(SlotKey.of("item-" + item.getId())) // 唯一标识
    .build();

6.2 导航与路由 (Navigation)

压入路由现在会立即重绘

v6.3.0 之前,push(String) 通过 setState 生效,只会标脏而从不调度构建,因此路由被压进了 history,已打开的界面却仍停在原页面。 自 v6.3.0 起,每一次由 setState 触发的标脏都会到达一次已调度的重绘,压入路由会立即更新可见页面。

Navigator.of(context) 可能返回 null

Navigator.of(context) 带有 @Nullable,当前 Element 上方没有 Navigator 时会返回 null,链式调用因此会抛出 NullPointerException。 调用 .push(...)/.pop()/.pushReplacement(...) 之前,先判断结果是否为 null

框架提供了 Navigator 组件用于在同一个 GUI 窗口内切换“页面”(实际上是切换 Widget 树)。

java
// 在根 build 方法中
Map<String, RouteBuilder> routes = new HashMap<>();
routes.put("home", (context) -> new HomePageWidget());
routes.put("settings", (context) -> new SettingsPageWidget());
return new Navigator("home", routes);

// 在子组件中跳转
Navigator.of(context).push("settings");

6.3 性能优化

  • 避免在 build 中做耗时操作: build 方法可能会被频繁调用(每秒多次),不要在里面读写数据库或进行复杂计算。
  • 提取常量 Widget: 如果一个组件(如背景板)永远不会变,可以将其定义为 static final 字段,直接复用。
  • 局部刷新: 尽量将状态下沉到叶子节点。例如,只有一个按钮需要变色,就只把那个按钮做成 StatefulWidget,而不是刷新整个页面。

7. 完整示例:商店页面

  1. 布局: 使用 Container + GridView
  2. 分页: 使用 currentPage 状态控制数据切片。
  3. 单选: 使用 selectedSlot 状态控制高亮显示。
  4. 交互: 购买按钮根据选中状态动态显示/隐藏。
java
package com.ultikits.docs.declarative;

import com.ultikits.ultitools.abstracts.gui.declarative.core.BuildContext;
import com.ultikits.ultitools.abstracts.gui.declarative.core.State;
import com.ultikits.ultitools.abstracts.gui.declarative.core.StatefulWidget;
import com.ultikits.ultitools.abstracts.gui.declarative.core.Widget;
import com.ultikits.ultitools.abstracts.gui.declarative.engine.DeclarativeGui;
import com.ultikits.ultitools.abstracts.gui.declarative.widgets.*;
import com.ultikits.ultitools.abstracts.gui.declarative.util.SlotUtils;
import com.ultikits.ultitools.entities.Colors;
import com.ultikits.ultitools.utils.XVersionUtils;
import org.bukkit.entity.Player;
import org.bukkit.event.inventory.InventoryClickEvent;
import org.bukkit.event.inventory.InventoryCloseEvent;
import org.bukkit.event.inventory.InventoryOpenEvent;
import org.bukkit.inventory.ItemStack;
import org.jetbrains.annotations.NotNull;

import java.util.ArrayList;
import java.util.List;

public class ExampleShopPage extends DeclarativeGui {

    private final List<ShopItem> items;
    private int currentPage = 0;
    private int selectedSlot = -1;
    
    private static final int ITEMS_PER_PAGE = 28; // 4 rows x 7 columns
    private static final int START_SLOT = 10;     // start at row 2, column 2

    /**
     * Shop item data class.
     */
    public static class ShopItem {
        private final ItemStack display;
        private final double price;
        private final String name;

        public ShopItem(ItemStack display, double price, String name) {
            this.display = display;
            this.price = price;
            this.name = name;
        }

        public ItemStack getDisplay() {
            return display;
        }

        public double getPrice() {
            return price;
        }

        public String getName() {
            return name;
        }
    }

    /**
     * Create the shop page.
     *
     * @param player viewer
     * @param items  item list
     */
    public ExampleShopPage(@NotNull Player player, @NotNull List<ShopItem> items) {
        super(player, "example_shop", "§6§lItem Shop", 6);
        this.items = new ArrayList<>(items);
    }

    @Override
    @NotNull
    public Widget build(@NotNull BuildContext context) {
        List<Widget> children = new ArrayList<>();

        // 1. title
        children.add(createTitle());

        // 2. decorative borders
        children.addAll(createBorders());

        // 3. item grid
        children.add(createItemGrid());

        // 4. pagination controls
        children.add(createPaginationControls());

        // 5. selected item info (if any)
        if (selectedSlot >= 0) {
            children.add(createSelectedInfo());
        }

        // 6. close button
        children.add(createCloseButton());

        return Container.builder()
                .children(children)
                .build();
    }

    /** Create title button. */
    @NotNull
    private Widget createTitle() {
        return TextButton.builder()
                .text("§6§lItem Shop")
                .color("YELLOW")
                .slot(4)
                .build();
    }

    /** Create decorative borders. */
    @NotNull
    private List<Widget> createBorders() {
        List<Widget> borders = new ArrayList<>();
        ItemStack borderGlass = XVersionUtils.getColoredPlaneGlass(Colors.GRAY);

        // top and bottom borders
        for (int col = 0; col < 9; col++) {
            borders.add(ItemDisplay.builder(borderGlass)
                    .slot(col)
                    .build());
            borders.add(ItemDisplay.builder(borderGlass)
                    .slot(45 + col)
                    .build());
        }

        // left and right borders
        for (int row = 1; row < 5; row++) {
            borders.add(ItemDisplay.builder(borderGlass)
                    .slot(row * 9)
                    .build());
            borders.add(ItemDisplay.builder(borderGlass)
                    .slot(row * 9 + 8)
                    .build());
        }

        return borders;
    }

    /** Create the item grid widget. */
    @NotNull
    private Widget createItemGrid() {
        List<ShopItem> pageItems = getPageItems();
        
        List<Widget> itemWidgets = new ArrayList<>();
        for (int i = 0; i < pageItems.size(); i++) {
            ShopItem item = pageItems.get(i);
            int slot = calculateItemSlot(i);
            boolean isSelected = (slot == selectedSlot);

            itemWidgets.add(createItemWidget(item, slot, isSelected));
        }

        return Container.builder()
                .children(itemWidgets)
                .build();
    }

    /** Create a single item widget. */
    @NotNull
    private Widget createItemWidget(@NotNull ShopItem item, int slot, boolean isSelected) {
        ItemStack display = item.getDisplay().clone();
        
        // optionally add visual effect when selected
        if (isSelected) {
            // add selection effect here
        }

        return ItemDisplay.builder(display)
                .slot(slot)
                .name("§e" + item.getName())
                .lore(
                        "§7Price: §6$" + item.getPrice(),
                        "",
                        isSelected ? "§a§lSELECTED" : "§eClick to select"
                )
                .onClick(() -> selectItem(slot))
                .key("item-" + slot)
                .build();
    }

    /** Create pagination controls. */
    @NotNull
    private Widget createPaginationControls() {
        List<Widget> controls = new ArrayList<>();

        // previous page
        if (currentPage > 0) {
            controls.add(TextButton.builder()
                    .text("§a← Previous")
                    .color("GREEN")
                    .slot(45)
                    .onClick(this::goToPreviousPage)
                    .build());
        }

        // page indicator
        int totalPages = (int) Math.ceil((double) items.size() / ITEMS_PER_PAGE);
        controls.add(TextButton.builder()
                .text("§7Page §f" + (currentPage + 1) + "§7/§f" + totalPages)
                .color("GRAY")
                .slot(49)
                .build());

        // next page
        if (currentPage < totalPages - 1) {
            controls.add(TextButton.builder()
                    .text("§aNext →")
                    .color("GREEN")
                    .slot(53)
                    .onClick(this::goToNextPage)
                    .build());
        }

        return Container.builder()
                .children(controls)
                .build();
    }

    /** Create selected item info display. */
    @NotNull
    private Widget createSelectedInfo() {
        ShopItem selected = getSelectedItem();
        if (selected == null) {
            return Container.builder().build();
        }

        return TextButton.builder()
                .text("§aBuy: §f" + selected.getName())
                .color("LIME")
                .slot(47)
                .lore("§7Price: §6$" + selected.getPrice())
                .onClick(this::buySelectedItem)
                .build();
    }

    /** Create close button. */
    @NotNull
    private Widget createCloseButton() {
        return TextButton.builder()
                .text("§c§lClose")
                .color("RED")
                .slot(51)
                .onClick(() -> player.closeInventory())
                .build();
    }

    // ========== business logic ==========

    /** Get items for the current page. */
    @NotNull
    private List<ShopItem> getPageItems() {
        int start = currentPage * ITEMS_PER_PAGE;
        int end = Math.min(start + ITEMS_PER_PAGE, items.size());
        
        if (start >= items.size()) {
            return new ArrayList<>();
        }
        return items.subList(start, end);
    }

    /** Calculate the slot index for the item at `index`. */
    private int calculateItemSlot(int index) {
        int row = index / 7;  // 7 items per row
        int col = index % 7;
        return SlotUtils.toSlotIndex(START_SLOT, row, col);
    }

    /** Select an item. */
    private void selectItem(int slot) {
        setState(() -> {
            selectedSlot = slot;
        });
    }

    /** Get the currently selected ShopItem, or null. */
    private ShopItem getSelectedItem() {
        if (selectedSlot < 0) {
            return null;
        }
        
        // compute index within current page
        int indexInPage = -1;
        List<ShopItem> pageItems = getPageItems();
        for (int i = 0; i < pageItems.size(); i++) {
            if (calculateItemSlot(i) == selectedSlot) {
                indexInPage = i;
                break;
            }
        }
        
        if (indexInPage < 0 || indexInPage >= pageItems.size()) {
            return null;
        }
        
        return pageItems.get(indexInPage);
    }

    /** Buy the selected item. */
    private void buySelectedItem() {
        ShopItem selected = getSelectedItem();
        if (selected == null) {
            return;
        }

        // add actual purchase logic here (balance check, give item, etc.)
        player.sendMessage("§aYou bought §f" + selected.getName() + " §afor §6$" + selected.getPrice());
        
        // clear selection after purchase
        setState(() -> {
            selectedSlot = -1;
        });
    }

    /** Go to previous page. */
    private void goToPreviousPage() {
        if (currentPage > 0) {
            setState(() -> {
                currentPage--;
                selectedSlot = -1;  // clear selection when page changes
            });
        }
    }

    /** Go to next page. */
    private void goToNextPage() {
        int totalPages = (int) Math.ceil((double) items.size() / ITEMS_PER_PAGE);
        if (currentPage < totalPages - 1) {
            setState(() -> {
                currentPage++;
                selectedSlot = -1;  // clear selection when page changes
            });
        }
    }

    // ========== lifecycle hooks ==========

    @Override
    protected void onGuiOpen(@NotNull InventoryOpenEvent event) {
        player.sendMessage("§aWelcome to the shop!");
    }

    @Override
    protected void onGuiClose(@NotNull InventoryCloseEvent event) {
        // cleanup
    }

    @Override
    protected boolean onGuiClick(@NotNull InventoryClickEvent event) {
        // extra click handling (if needed)
        return false;  // keep the event cancelled so the item cannot be picked up
    }
}

贡献者

暂无相关贡献者

基于 MIT 许可发布