强曰为道
与天地相似,故不违。知周乎万物,而道济天下,故不过。旁行而不流,乐天知命,故不忧.
文档目录

PaperMC 插件开发完全指南 / 第 7 章:背包与 GUI

第 7 章:背包与 GUI

用代码构建美观的菜单系统,处理玩家的背包交互操作。


7.1 Bukkit 背包系统概述

Bukkit 的背包(Inventory)系统可以用来创建自定义 GUI 菜单。玩家点击菜单中的物品时,通过事件监听器响应操作。

背包类型

类型大小说明
CHEST9/18/27/36/45/54箱子,最常用的 GUI 容器
DISPENSER9发射器布局(3×3)
DROPPER9投掷器布局(3×3)
HOPPER5漏斗布局(1×5)
ANVIL3铁砧
WORKBENCH10工作台
BARREL27
SHULKER_BOX27潜影盒

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);
        }
    }
}

注意: 同时监听 InventoryClickEventInventoryDragEvent,否则玩家可以通过拖拽方式移动物品。


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
跨页物品重复索引计算错误检查 startend 的计算

7.11 扩展阅读


7.12 本章小结

要点内容
创建 GUIBukkit.createInventory() 创建自定义背包
交互处理InventoryClickEvent + InventoryDragEvent
分页系统通过页码计算偏移量,底栏放导航按钮
确认对话框回调模式,Consumer<Player> 存储后续操作
数据绑定用 PDC 在物品上存储动作标识或业务数据
序列化YamlConfiguration 存储背包数据到文件

下一章: 第 8 章:世界操作 — 学习世界管理、区块加载、实体生成和结构生成。