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

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

注意: 同时监听 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 本章小结

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

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