PaperMC 插件开发完全指南 / 第 7 章:背包与 GUI
第 7 章:背包与 GUI
用代码构建美观的菜单系统,处理玩家的背包交互操作。
7.1 Bukkit 背包系统概述
Bukkit 的背包(Inventory)系统可以用来创建自定义 GUI 菜单。玩家点击菜单中的物品时,通过事件监听器响应操作。
背包类型
| 类型 | 大小 | 说明 |
|---|---|---|
CHEST | 9/18/27/36/45/54 | 箱子,最常用的 GUI 容器 |
DISPENSER | 9 | 发射器布局(3×3) |
DROPPER | 9 | 投掷器布局(3×3) |
HOPPER | 5 | 漏斗布局(1×5) |
ANVIL | 3 | 铁砧 |
WORKBENCH | 10 | 工作台 |
BARREL | 27 | 桶 |
SHULKER_BOX | 27 | 潜影盒 |
7.2 创建基础 GUI
简单菜单
import org.bukkit.Bukkit;
import org.bukkit.Material;
import org.bukkit.entity.Player;
import org.bukkit.inventory.Inventory;
import org.bukkit.inventory.ItemStack;
import org.bukkit.inventory.meta.ItemMeta;
import net.kyori.adventure.text.Component;
import net.kyori.adventure.text.format.NamedTextColor;
public class MainMenu {
private static final int MENU_SIZE = 54; // 6 行 × 9 列
public static void open(Player player) {
// 创建背包
Inventory menu = Bukkit.createInventory(
null, // 所有者(null = 无)
MENU_SIZE, // 大小
Component.text("§6✦ 主菜单") // 标题
);
// 填充边框
ItemStack border = createItem(Material.BLACK_STAINED_GLASS_PANE, " ");
for (int i = 0; i < 9; i++) {
menu.setItem(i, border); // 顶部
menu.setItem(i + 45, border); // 底部
}
for (int i = 0; i < 6; i++) {
menu.setItem(i * 9, border); // 左边
menu.setItem(i * 9 + 8, border); // 右边
}
// 功能按钮
menu.setItem(20, createItem(Material.CHEST,
"§6商店", "§7点击打开商店"));
menu.setItem(22, createItem(Material.COMPASS,
"§b传送", "§7点击传送到地标"));
menu.setItem(24, createItem(Material.BOOK,
"§e任务", "§7查看当前任务"));
menu.setItem(40, createItem(Material.BARRIER,
"§c关闭", "§7关闭菜单"));
player.openInventory(menu);
}
private static ItemStack createItem(Material material, String name,
String... lore) {
ItemStack item = new ItemStack(material);
ItemMeta meta = item.getItemMeta();
if (meta != null) {
meta.displayName(Component.text(name));
if (lore.length > 0) {
meta.lore(Arrays.stream(lore)
.map(Component::text)
.collect(Collectors.toList()));
}
item.setItemMeta(meta);
}
return item;
}
}
7.3 GUI 交互事件处理
基本点击处理
public class MenuClickListener implements Listener {
@EventHandler
public void onInventoryClick(InventoryClickEvent event) {
// 检查是否是我们的菜单
if (!(event.getWhoClicked() instanceof Player player)) return;
Component title = event.getView().title();
String titleText = PlainTextComponentSerializer.plainText().serialize(title);
if (!titleText.contains("主菜单")) return;
// 取消事件(防止物品被移动)
event.setCancelled(true);
// 获取点击的槽位
int slot = event.getRawSlot();
// 检查是否点击了有效区域(菜单区域)
if (slot < 0 || slot >= event.getView().getTopInventory().getSize()) return;
// 处理不同按钮
ItemStack clicked = event.getCurrentItem();
if (clicked == null || clicked.getType() == Material.AIR) return;
switch (clicked.getType()) {
case CHEST -> {
player.closeInventory();
ShopMenu.open(player);
}
case COMPASS -> {
player.closeInventory();
player.performCommand("warp list");
}
case BOOK -> {
player.closeInventory();
QuestMenu.open(player);
}
case BARRIER -> {
player.closeInventory();
}
}
}
@EventHandler
public void onInventoryDrag(InventoryDragEvent event) {
// 防止拖拽物品
Component title = event.getView().title();
String titleText = PlainTextComponentSerializer.plainText().serialize(title);
if (titleText.contains("主菜单")) {
event.setCancelled(true);
}
}
}
注意: 同时监听
InventoryClickEvent和InventoryDragEvent,否则玩家可以通过拖拽方式移动物品。
7.4 分页菜单系统
当物品数量超过一页时,需要分页显示。
分页菜单实现
public class PaginatedMenu {
private final List<ItemStack> items;
private final int pageSize = 45; // 每页 45 个物品(留出底栏)
private int currentPage = 0;
public PaginatedMenu(List<ItemStack> items) {
this.items = items;
}
public void open(Player player, int page) {
this.currentPage = page;
int maxPage = (int) Math.ceil((double) items.size() / pageSize) - 1;
if (maxPage < 0) maxPage = 0;
if (page < 0) page = 0;
if (page > maxPage) page = maxPage;
this.currentPage = page;
Inventory menu = Bukkit.createInventory(null, 54,
Component.text("§6物品列表 §7(第 " + (page + 1) + "/" + (maxPage + 1) + " 页)"));
// 填充当前页物品
int start = page * pageSize;
int end = Math.min(start + pageSize, items.size());
for (int i = start; i < end; i++) {
menu.setItem(i - start, items.get(i));
}
// 底部导航栏
ItemStack glass = createItem(Material.GRAY_STAINED_GLASS_PANE, " ");
for (int i = 45; i < 54; i++) {
menu.setItem(i, glass);
}
// 上一页
if (page > 0) {
menu.setItem(45, createItem(Material.ARROW,
"§a上一页", "§7第 " + page + " 页"));
}
// 页码信息
menu.setItem(49, createItem(Material.PAPER,
"§e" + (page + 1) + " / " + (maxPage + 1),
"§7共 " + items.size() + " 项"));
// 下一页
if (page < maxPage) {
menu.setItem(53, createItem(Material.ARROW,
"§a下一页", "§7第 " + (page + 2) + " 页"));
}
player.openInventory(menu);
}
public int getCurrentPage() { return currentPage; }
public int getPageSize() { return pageSize; }
}
分页菜单的事件处理
@EventHandler
public void onPaginatedClick(InventoryClickEvent event) {
if (!(event.getWhoClicked() instanceof Player player)) return;
String title = PlainTextComponentSerializer.plainText()
.serialize(event.getView().title());
if (!title.contains("物品列表")) return;
event.setCancelled(true);
int slot = event.getRawSlot();
// 上一页
if (slot == 45) {
PaginatedMenu menu = getPlayerMenu(player); // 存储的菜单引用
if (menu != null && menu.getCurrentPage() > 0) {
menu.open(player, menu.getCurrentPage() - 1);
}
return;
}
// 下一页
if (slot == 53) {
PaginatedMenu menu = getPlayerMenu(player);
if (menu != null) {
menu.open(player, menu.getCurrentPage() + 1);
}
return;
}
}
7.5 确认对话框
许多操作需要二次确认,如删除物品、转账等。
确认菜单
public class ConfirmMenu {
public static void open(Player player, String message,
Consumer<Player> onConfirm,
Consumer<Player> onCancel) {
Inventory menu = Bukkit.createInventory(null, 27,
Component.text("§c⚠ 确认操作"));
// 填充灰色玻璃
ItemStack glass = createItem(Material.GRAY_STAINED_GLASS_PANE, " ");
for (int i = 0; i < 27; i++) {
menu.setItem(i, glass);
}
// 提示信息
menu.setItem(4, createItem(Material.PAPER, "§e" + message));
// 确认按钮
menu.setItem(11, createItem(Material.LIME_STAINED_GLASS_PANE,
"§a✓ 确认", "§7点击确认操作"));
// 取消按钮
menu.setItem(15, createItem(Material.RED_STAINED_GLASS_PANE,
"§c✗ 取消", "§7点击取消操作"));
// 存储回调
confirmCallbacks.put(player.getUniqueId(), onConfirm);
cancelCallbacks.put(player.getUniqueId(), onCancel);
player.openInventory(menu);
}
}
回调存储和处理
public class ConfirmListener implements Listener {
// 存储回调
private static final Map<UUID, Consumer<Player>> confirmCallbacks = new HashMap<>();
private static final Map<UUID, Consumer<Player>> cancelCallbacks = new HashMap<>();
@EventHandler
public void onClick(InventoryClickEvent event) {
if (!(event.getWhoClicked() instanceof Player player)) return;
String title = PlainTextComponentSerializer.plainText()
.serialize(event.getView().title());
if (!title.contains("确认操作")) return;
event.setCancelled(true);
int slot = event.getRawSlot();
// 清理回调
Consumer<Player> confirm = confirmCallbacks.remove(player.getUniqueId());
Consumer<Player> cancel = cancelCallbacks.remove(player.getUniqueId());
player.closeInventory();
if (slot == 11 && confirm != null) {
confirm.accept(player);
} else if (slot == 15 && cancel != null) {
if (cancel != null) cancel.accept(player);
}
}
// 关闭菜单时也清理回调
@EventHandler
public void onClose(InventoryCloseEvent event) {
if (!(event.getPlayer() instanceof Player player)) return;
String title = PlainTextComponentSerializer.plainText()
.serialize(event.getView().title());
if (title.contains("确认操作")) {
Consumer<Player> cancel = cancelCallbacks.remove(player.getUniqueId());
if (cancel != null) cancel.accept(player);
}
}
}
使用示例
ConfirmMenu.open(player, "确定要删除这个地标吗?",
confirmed -> {
// 确认逻辑
deleteWarp(warpName);
confirmed.sendMessage("§a地标已删除!");
},
cancelled -> {
// 取消逻辑
cancelled.sendMessage("§7操作已取消。");
}
);
7.6 动态 GUI 数据绑定
使用 PersistentDataContainer 存储槽位数据
public class GuiHelper {
private static final NamespacedKey SLOT_ACTION_KEY =
new NamespacedKey(plugin, "gui_slot_action");
/**
* 创建带动作标记的物品
*/
public static ItemStack createActionItem(Material material, String name,
String action, String... lore) {
ItemStack item = new ItemStack(material);
ItemMeta meta = item.getItemMeta();
if (meta != null) {
meta.displayName(Component.text(name));
if (lore.length > 0) {
meta.lore(Arrays.stream(lore)
.map(Component::text)
.collect(Collectors.toList()));
}
// 存储动作标识
meta.getPersistentDataContainer().set(
SLOT_ACTION_KEY, PersistentDataType.STRING, action
);
item.setItemMeta(meta);
}
return item;
}
/**
* 获取物品的动作标识
*/
public static String getAction(ItemStack item) {
if (item == null || !item.hasItemMeta()) return null;
return item.getItemMeta().getPersistentDataContainer()
.get(SLOT_ACTION_KEY, PersistentDataType.STRING);
}
}
事件处理
@EventHandler
public void onMenuClick(InventoryClickEvent event) {
if (!(event.getWhoClicked() instanceof Player player)) return;
event.setCancelled(true);
ItemStack clicked = event.getCurrentItem();
String action = GuiHelper.getAction(clicked);
if (action == null) return;
switch (action) {
case "open_shop" -> ShopMenu.open(player);
case "open_warp" -> WarpMenu.open(player);
case "close" -> player.closeInventory();
case "confirm_delete" -> handleDelete(player);
// ...
}
}
7.7 动画 GUI
定时刷新的 GUI
public class AnimatedMenu {
private BukkitTask animationTask;
public void open(Player player) {
Inventory menu = Bukkit.createInventory(null, 54,
Component.text("§6✦ 动画菜单"));
player.openInventory(menu);
// 启动动画任务
animationTask = Bukkit.getScheduler().runTaskTimer(plugin, () -> {
if (!player.isOnline() || !isValidMenu(player)) {
animationTask.cancel();
return;
}
// 更新动画帧
updateFrame(menu, player);
}, 0L, 10L); // 每 10 tick(0.5 秒)刷新一次
}
private void updateFrame(Inventory menu, Player player) {
// 旋转彩色玻璃边框
long tick = System.currentTimeMillis() / 500;
Material[] colors = {
Material.RED_STAINED_GLASS_PANE,
Material.ORANGE_STAINED_GLASS_PANE,
Material.YELLOW_STAINED_GLASS_PANE,
Material.LIME_STAINED_GLASS_PANE,
Material.CYAN_STAINED_GLASS_PANE,
Material.BLUE_STAINED_GLASS_PANE,
Material.PURPLE_STAINED_GLASS_PANE,
Material.MAGENTA_STAINED_GLASS_PANE,
Material.PINK_STAINED_GLASS_PANE
};
for (int i = 0; i < 9; i++) {
int colorIndex = (int) ((tick + i) % colors.length);
menu.setItem(i, new ItemStack(colors[colorIndex]));
}
}
}
7.8 背包序列化存储
存储玩家背包到文件
public class InventoryManager {
private final JavaPlugin plugin;
private final File dataFolder;
public InventoryManager(JavaPlugin plugin) {
this.plugin = plugin;
this.dataFolder = new File(plugin.getDataFolder(), "inventories");
if (!dataFolder.exists()) {
dataFolder.mkdirs();
}
}
/**
* 保存玩家背包
*/
public void saveInventory(Player player) {
File file = new File(dataFolder, player.getUniqueId() + ".yml");
YamlConfiguration config = new YamlConfiguration();
PlayerInventory inv = player.getInventory();
// 保存主物品栏
for (int i = 0; i < inv.getSize(); i++) {
ItemStack item = inv.getItem(i);
if (item != null && item.getType() != Material.AIR) {
config.set("inventory." + i, item);
}
}
// 保存装备
config.set("armor.helmet", inv.getHelmet());
config.set("armor.chestplate", inv.getChestplate());
config.set("armor.leggings", inv.getLeggings());
config.set("armor.boots", inv.getBoots());
config.set("offhand", inv.getItemInOffHand());
// 保存经验
config.set("exp.level", player.getLevel());
config.set("exp.progress", player.getExp());
try {
config.save(file);
} catch (IOException e) {
plugin.getLogger().severe("保存背包失败: " + e.getMessage());
}
}
/**
* 加载玩家背包
*/
public void loadInventory(Player player) {
File file = new File(dataFolder, player.getUniqueId() + ".yml");
if (!file.exists()) return;
YamlConfiguration config = YamlConfiguration.loadConfiguration(file);
PlayerInventory inv = player.getInventory();
inv.clear();
// 加载物品栏
ConfigurationSection section = config.getConfigurationSection("inventory");
if (section != null) {
for (String key : section.getKeys(false)) {
int slot = Integer.parseInt(key);
ItemStack item = section.getItemStack(key);
if (item != null) {
inv.setItem(slot, item);
}
}
}
// 加载装备
inv.setHelmet(config.getItemStack("armor.helmet"));
inv.setChestplate(config.getItemStack("armor.chestplate"));
inv.setLeggings(config.getItemStack("armor.leggings"));
inv.setBoots(config.getItemStack("armor.boots"));
inv.setItemInOffHand(config.getItemStack("offhand"));
// 加载经验
player.setLevel(config.getInt("exp.level", 0));
player.setExp((float) config.getDouble("exp.progress", 0.0));
}
}
7.9 业务场景:商店系统
public class ShopMenu {
public static void open(Player player) {
Inventory menu = Bukkit.createInventory(null, 54,
Component.text("§6💰 商店"));
// 商店物品
menu.setItem(10, createShopItem(Material.DIAMOND,
"§b钻石", 100.0, "§7稀有的宝石"));
menu.setItem(11, createShopItem(Material.IRON_INGOT,
"§f铁锭", 10.0, "§7常用的金属"));
menu.setItem(12, createShopItem(Material.GOLD_INGOT,
"§6金锭", 25.0, "§7闪亮的金属"));
menu.setItem(13, createShopItem(Material.EMERALD,
"§a绿宝石", 150.0, "§7村民喜爱的宝石"));
menu.setItem(14, createShopItem(Material.COAL,
"§8煤炭", 2.0, "§7基础燃料"));
// 当前余额
double balance = EconomyManager.getBalance(player);
menu.setItem(49, createItem(Material.GOLD_NUGGET,
"§6余额: §e" + String.format("%.2f", balance)));
player.openInventory(menu);
}
private static ItemStack createShopItem(Material material, String name,
double price, String desc) {
ItemStack item = new ItemStack(material);
ItemMeta meta = item.getItemMeta();
if (meta != null) {
meta.displayName(Component.text(name));
meta.lore(List.of(
Component.text(desc, NamedTextColor.GRAY),
Component.empty(),
Component.text("§a左键购买 ×1 §7| §e价格: §6$" + price,
NamedTextColor.WHITE),
Component.text("§c右键出售 ×1 §7| §e价格: §6$" + (price * 0.5),
NamedTextColor.WHITE)
));
// 存储价格信息
PersistentDataContainer pdc = meta.getPersistentDataContainer();
pdc.set(new NamespacedKey(plugin, "buy_price"),
PersistentDataType.DOUBLE, price);
pdc.set(new NamespacedKey(plugin, "sell_price"),
PersistentDataType.DOUBLE, price * 0.5);
item.setItemMeta(meta);
}
return item;
}
}
商店事件处理
@EventHandler
public void onShopClick(InventoryClickEvent event) {
if (!(event.getWhoClicked() instanceof Player player)) return;
String title = PlainTextComponentSerializer.plainText()
.serialize(event.getView().title());
if (!title.contains("商店")) return;
event.setCancelled(true);
ItemStack clicked = event.getCurrentItem();
if (clicked == null || clicked.getType() == Material.AIR) return;
ItemMeta meta = clicked.getItemMeta();
if (meta == null) return;
PersistentDataContainer pdc = meta.getPersistentDataContainer();
NamespacedKey buyKey = new NamespacedKey(plugin, "buy_price");
NamespacedKey sellKey = new NamespacedKey(plugin, "sell_price");
Double buyPrice = pdc.get(buyKey, PersistentDataType.DOUBLE);
Double sellPrice = pdc.get(sellKey, PersistentDataType.DOUBLE);
if (buyPrice == null) return;
if (event.isLeftClick()) {
// 购买
if (EconomyManager.deduct(player, buyPrice)) {
player.getInventory().addItem(new ItemStack(clicked.getType()));
player.sendMessage("§a购买成功!花费 $" + buyPrice);
// 刷新菜单更新余额
ShopMenu.open(player);
} else {
player.sendMessage("§c余额不足!");
}
} else if (event.isRightClick() && sellPrice != null) {
// 出售
ItemStack handItem = new ItemStack(clicked.getType());
if (player.getInventory().containsAtLeast(handItem, 1)) {
player.getInventory().removeItem(handItem);
EconomyManager.add(player, sellPrice);
player.sendMessage("§a出售成功!获得 $" + sellPrice);
ShopMenu.open(player);
} else {
player.sendMessage("§c你没有这个物品!");
}
}
}
7.10 常见问题排查
| 问题 | 原因 | 解决方案 |
|---|---|---|
| 物品可以被拖出来 | 未取消事件 | 在 click 和 drag 事件中 setCancelled(true) |
| 关闭菜单后物品丢失 | 物品在玩家背包中 | 关闭时检查并清理 |
| 菜单标题匹配失败 | 颜色代码不一致 | 用 PlainTextComponentSerializer 去掉格式 |
| 多人同时操作冲突 | 共享 Inventory 实例 | 每个玩家创建独立的 Inventory |
| 跨页物品重复 | 索引计算错误 | 检查 start 和 end 的计算 |
7.11 扩展阅读
7.12 本章小结
| 要点 | 内容 |
|---|---|
| 创建 GUI | Bukkit.createInventory() 创建自定义背包 |
| 交互处理 | InventoryClickEvent + InventoryDragEvent |
| 分页系统 | 通过页码计算偏移量,底栏放导航按钮 |
| 确认对话框 | 回调模式,Consumer<Player> 存储后续操作 |
| 数据绑定 | 用 PDC 在物品上存储动作标识或业务数据 |
| 序列化 | YamlConfiguration 存储背包数据到文件 |
下一章: 第 8 章:世界操作 — 学习世界管理、区块加载、实体生成和结构生成。