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

PaperMC 插件开发完全指南 / 第 14 章:占位符扩展

第 14 章:占位符扩展

学习 PlaceholderAPI 集成,让你的插件数据可被其他插件引用。


14.1 PlaceholderAPI 概述

PlaceholderAPI (PAPI) 是 Bukkit 服务器上最流行的占位符框架。它允许插件注册自定义变量(占位符),其他插件可以通过统一的格式引用这些数据。

占位符格式

%扩展名_参数%

示例:

  • %player_name% — 玩家名称
  • %player_health% — 玩家生命值
  • %server_online% — 在线人数
  • %myplugin_balance% — 你的插件的余额占位符

常用内置扩展

扩展前缀常用占位符
Player%player_name, health, level, gamemode
Server%server_online, max_players, tps
Vault%vault_eco_balance%经济余额
Statistic%statistic_玩家统计

14.2 添加 PlaceholderAPI 依赖

Maven 依赖

<repository>
    <id>placeholderapi</id>
    <url>https://repo.extendedclip.net/content/repositories/placeholderapi/</url>
</repository>

<dependency>
    <groupId>me.clip</groupId>
    <artifactId>placeholderapi</artifactId>
    <version>2.11.6</version>
    <scope>provided</scope>
</dependency>

plugin.yml 声明

softdepend:
  - PlaceholderAPI

14.3 注册自定义占位符

实现 Expansion

package com.example.myplugin.placeholders;

import me.clip.placeholderapi.expansion.PlaceholderExpansion;
import org.bukkit.OfflinePlayer;
import org.bukkit.entity.Player;
import org.jetbrains.annotations.NotNull;
import org.jetbrains.annotations.Nullable;

public class MyPluginExpansion extends PlaceholderExpansion {

    private final MyPlugin plugin;

    public MyPluginExpansion(MyPlugin plugin) {
        this.plugin = plugin;
    }

    /**
     * 占位符标识符(前缀)
     * 返回 "myplugin" → 占位符格式: %myplugin_xxx%
     */
    @Override
    public @NotNull String getIdentifier() {
        return "myplugin";
    }

    /**
     * 作者
     */
    @Override
    public @NotNull String getAuthor() {
        return plugin.getDescription().getAuthors().toString();
    }

    /**
     * 版本
     */
    @Override
    public @NotNull String getVersion() {
        return plugin.getDescription().getVersion();
    }

    /**
     * 是否持久化(插件禁用后是否保留)
     */
    @Override
    public boolean persist() {
        return true;
    }

    /**
     * 是否可以注册
     */
    @Override
    public boolean canRegister() {
        return true;
    }

    /**
     * 处理占位符请求
     * @param player   玩家(可能为 null,表示服务器级占位符)
     * @param params   占位符参数(去掉前缀后的部分)
     * @return 占位符值
     */
    @Override
    public @Nullable String onRequest(OfflinePlayer player,
                                       @NotNull String params) {
        // 处理无玩家的服务器级占位符
        if (player == null) {
            return handleServerPlaceholder(params);
        }

        // 处理玩家级占位符
        return handlePlayerPlaceholder(player, params);
    }

    /**
     * 处理玩家级占位符
     */
    private String handlePlayerPlaceholder(OfflinePlayer player, String params) {
        // %myplugin_balance%
        if (params.equalsIgnoreCase("balance")) {
            double balance = EconomyManager.getBalance(player.getUniqueId());
            return String.format("%.2f", balance);
        }

        // %myplugin_level%
        if (params.equalsIgnoreCase("level")) {
            int level = DataManager.getLevel(player.getUniqueId());
            return String.valueOf(level);
        }

        // %myplugin_playtime%
        if (params.equalsIgnoreCase("playtime")) {
            long playTime = DataManager.getPlayTime(player.getUniqueId());
            return formatTime(playTime);
        }

        // %myplugin_rank%
        if (params.equalsIgnoreCase("rank")) {
            return DataManager.getRank(player.getUniqueId());
        }

        // %myplugin_top_balance_1%
        // 处理带参数的占位符
        if (params.startsWith("top_balance_")) {
            String rankStr = params.substring("top_balance_".length());
            try {
                int rank = Integer.parseInt(rankStr);
                return DataManager.getTopBalance(rank);
            } catch (NumberFormatException e) {
                return "N/A";
            }
        }

        return null; // 未知占位符
    }

    /**
     * 处理服务器级占位符
     */
    private String handleServerPlaceholder(String params) {
        // %myplugin_total_players%
        if (params.equalsIgnoreCase("total_players")) {
            return String.valueOf(DataManager.getTotalPlayers());
        }

        // %myplugin_total_balance%
        if (params.equalsIgnoreCase("total_balance")) {
            return String.format("%.2f", DataManager.getTotalBalance());
        }

        return null;
    }

    private String formatTime(long ticks) {
        long seconds = ticks / 20;
        long hours = seconds / 3600;
        long minutes = (seconds % 3600) / 60;
        return hours + "h " + minutes + "m";
    }
}

注册 Expansion

// 在 onEnable() 中
@Override
public void onEnable() {
    // 检查 PlaceholderAPI 是否存在
    if (Bukkit.getPluginManager().getPlugin("PlaceholderAPI") != null) {
        new MyPluginExpansion(this).register();
        getLogger().info("PlaceholderAPI 扩展已注册!");
    }
}

14.4 使用其他插件的占位符

读取占位符值

import me.clip.placeholderapi.PlaceholderAPI;

public class PlaceholderHelper {

    /**
     * 替换字符串中的占位符
     */
    public static String setPlaceholders(Player player, String text) {
        return PlaceholderAPI.setPlaceholders(player, text);
    }

    /**
     * 替换字符串中的括号占位符
     */
    public static String setBracketPlaceholders(Player player, String text) {
        return PlaceholderAPI.setBracketPlaceholders(player, text);
    }

    /**
     * 检查占位符是否存在
     */
    public static boolean isRegistered(String identifier) {
        return PlaceholderAPI.isRegistered(identifier);
    }

    /**
     * 获取占位符的值
     */
    public static String getPlaceholder(Player player, String placeholder) {
        return PlaceholderAPI.setPlaceholders(player, "%" + placeholder + "%");
    }
}

使用示例

// 在侧边栏中使用其他插件的占位符
String line = "%player_name% - %vault_eco_balance%";
String parsed = PlaceholderAPI.setPlaceholders(player, line);
// 结果: "Steve - 1000.00"

14.5 动态占位符缓存

对于计算成本较高的占位符,应该使用缓存。

public class CachedExpansion extends PlaceholderExpansion {

    private final Map<UUID, Map<String, CachedValue>> cache = new HashMap<>();
    private static final long CACHE_TTL = 5000; // 5 秒缓存

    @Override
    public String onRequest(OfflinePlayer player, String params) {
        if (player == null || !player.isOnline()) return null;

        UUID uuid = player.getUniqueId();

        // 检查缓存
        Map<String, CachedValue> playerCache = cache.computeIfAbsent(
            uuid, k -> new HashMap<>());
        CachedValue cached = playerCache.get(params);

        if (cached != null && !cached.isExpired()) {
            return cached.value();
        }

        // 计算新值
        String value = computeValue(player, params);

        // 更新缓存
        playerCache.put(params, new CachedValue(value, System.currentTimeMillis()));

        return value;
    }

    private String computeValue(OfflinePlayer player, String params) {
        // 实际计算逻辑
        return "computed_value";
    }

    private record CachedValue(String value, long timestamp) {
        boolean isExpired() {
            return System.currentTimeMillis() - timestamp > CACHE_TTL;
        }
    }
}

14.6 多语言占位符支持

@Override
public String onRequest(OfflinePlayer player, String params) {
    // %myplugin_balance_formatted%
    // %myplugin_balance_short%

    String[] parts = params.split("_");
    if (parts.length == 0) return null;

    String type = parts[0];

    if ("balance".equals(type)) {
        double balance = EconomyManager.getBalance(player.getUniqueId());

        if (parts.length > 1) {
            return switch (parts[1]) {
                case "formatted" -> String.format("§6$%,.2f", balance);
                case "short" -> formatShort(balance);
                case "integer" -> String.valueOf((int) balance);
                default -> String.valueOf(balance);
            };
        }

        return String.valueOf(balance);
    }

    return null;
}

private String formatShort(double amount) {
    if (amount >= 1_000_000_000) return String.format("%.1fB", amount / 1_000_000_000);
    if (amount >= 1_000_000) return String.format("%.1fM", amount / 1_000_000);
    if (amount >= 1_000) return String.format("%.1fK", amount / 1_000);
    return String.format("%.2f", amount);
}

14.7 业务场景:聊天格式插件

public class ChatFormatter implements Listener {

    @EventHandler(priority = EventPriority.HIGH)
    public void onChat(AsyncChatEvent event) {
        Player player = event.getPlayer();

        // 聊天格式模板(从配置读取)
        String format = config.getString("chat-format",
            "%myplugin_prefix% &7%player_name%&8: &f%message%");

        // 替换自定义占位符
        format = format.replace("%myplugin_prefix%", getPrefix(player));

        // 使用 PlaceholderAPI 替换其他占位符
        if (isPAPIEnabled()) {
            format = PlaceholderAPI.setPlaceholders(player, format);
        }

        // 设置渲染器
        event.renderer((source, name, msg, viewer) -> {
            String rendered = format
                .replace("%player_name%", source.getName())
                .replace("%message%", PlainTextComponentSerializer.plainText()
                    .serialize(msg));

            return LegacyComponentSerializer.legacySection().deserialize(rendered);
        });
    }
}

14.8 占位符测试

测试命令

public class PlaceholderTestCommand implements CommandExecutor {

    @Override
    public boolean onCommand(CommandSender sender, Command command,
                             String label, String[] args) {
        if (!(sender instanceof Player player)) {
            sender.sendMessage("§c只能由玩家执行!");
            return true;
        }

        if (args.length == 0) {
            sender.sendMessage("§c用法: /papi <占位符>");
            return true;
        }

        String placeholder = args[0];

        // 确保格式正确
        if (!placeholder.startsWith("%")) placeholder = "%" + placeholder;
        if (!placeholder.endsWith("%")) placeholder = placeholder + "%";

        // 解析占位符
        String result = PlaceholderAPI.setPlaceholders(player, placeholder);

        player.sendMessage("§a占位符: §e" + placeholder);
        player.sendMessage("§a结果: §f" + result);

        return true;
    }
}

14.9 PlaceholderAPI 内置占位符列表

常用内置扩展

扩展名安装方式占位符
player内置%player_name%, %player_uuid%
server内置%server_online%, %server_max_players%
vault需安装%vault_eco_balance%
statistic需安装%statistic_deaths%
time需安装%time_current%
rel_需安装关系占位符(队友、好友等)

14.10 常见问题排查

问题原因解决方案
占位符返回原文Expansion 未注册检查 register() 调用
占位符返回 nullonRequest 返回了 null未知占位符返回空字符串
性能问题占位符计算成本高使用缓存
PlaceholderAPI 未安装依赖缺失softdepend + 运行时检查

14.11 扩展阅读


14.12 本章小结

要点内容
PlaceholderAPI统一的占位符框架,插件间数据共享
注册 Expansion继承 PlaceholderExpansion,实现 onRequest
使用占位符PlaceholderAPI.setPlaceholders()
缓存计算成本高的占位符应缓存结果
软依赖softdepend + 运行时检查

下一章: 第 15 章:Docker 环境 — 学习使用 Docker 搭建开发和测试环境。