声明式 GUI
实验性功能
该框架目前处于实验性状态,可能存在未知的问题,欢迎通过 GitHub Issue 提交问题反馈。
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 (容器)
最基础的容器组件,用于包裹其他组件,并可以设置背景。
java
Container.builder()
.background(IconWrapper.of(Material.GRAY_STAINED_GLASS_PANE)) // 设置背景
.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 (网格布局)
非常适合用于展示列表数据(如商店商品、背包内容)。它可以自动计算行列位置。
java
GridView.<ShopItem>builder()
.startSlot(10) // 起始位置
.columns(7) // 每行几列
.rows(4) // 最多几行
.items(itemList, item -> {
// 将数据对象映射为 Widget
return ItemDisplay.builder(item.getStack())
.name(item.getName())
.onClick(() -> buy(item))
.build();
})
.build();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();
}
}原理解析:
- 用户点击按钮。
setState更新count变量。- 框架标记
CounterWidget需要更新。 - 框架重新调用
build()方法。 TextButton被重新创建,文本变为 "Count: 1"。- Diff 算法检测到 Slot 13 的物品名称变了,于是发送包更新该位置的物品(而不会刷新整个界面)。
6. 进阶技巧 (Advanced Topics)
6.1 SlotKey 的重要性
在渲染动态列表(如 GridView)时,给每个 Item 设置一个唯一的 Key 是至关重要的。这有助于 Diff 算法正确识别“移动”操作,而不是“删除再创建”。
java
ItemDisplay.builder(item)
.key(SlotKey.of("item-" + item.getId())) // 唯一标识
.build();6.2 导航与路由 (Navigation)
框架提供了 Navigator 组件用于在同一个 GUI 窗口内切换“页面”(实际上是切换 Widget 树)。
java
// 在根 build 方法中
return new Navigator("home", Map.of(
"home", (context) -> new HomePageWidget(),
"settings", (context) -> new SettingsPageWidget()
));
// 在子组件中跳转
Navigator.of(context).push("settings");6.3 性能优化
- 避免在 build 中做耗时操作:
build方法可能会被频繁调用(每秒多次),不要在里面读写数据库或进行复杂计算。 - 提取常量 Widget: 如果一个组件(如背景板)永远不会变,可以将其定义为
static final字段,直接复用。 - 局部刷新: 尽量将状态下沉到叶子节点。例如,只有一个按钮需要变色,就只把那个按钮做成
StatefulWidget,而不是刷新整个页面。
7. 完整示例:商店页面
- 布局: 使用
Container+GridView。 - 分页: 使用
currentPage状态控制数据切片。 - 单选: 使用
selectedSlot状态控制高亮显示。 - 交互: 购买按钮根据选中状态动态显示/隐藏。
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
}
}