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

PaperMC 插件开发完全指南 / 第 10 章:计分板

第 10 章:计分板

掌握计分板系统,实现侧边栏信息面板、浮动标签和 Team 管理。


10.1 计分板系统概述

Minecraft 的计分板(Scoreboard)系统是客户端原生的信息展示机制,可用于:

  • 侧边栏: 屏幕右侧显示信息列表
  • 标签: 玩家/实体头顶显示文字
  • Teams: 分组管理(颜色、PVP 控制、前缀后缀)
  • 计分目标: 跟踪和显示数值

核心概念

概念 说明
Scoreboard 计分板管理器
Objective 计分目标(如"金币"、“击杀数”)
Score 具体的分数条目
Team 队伍,控制颜色和前缀后缀
DisplaySlot 展示位置(侧边栏、玩家列表等)

10.2 侧边栏计分板

创建基础侧边栏

package com.example.myplugin.scoreboard;

import org.bukkit.Bukkit;
import org.bukkit.entity.Player;
import org.bukkit.scoreboard.*;

import net.kyori.adventure.text.Component;
import net.kyori.adventure.text.format.NamedTextColor;

public class SidebarManager {

    private final ScoreboardManager manager;
    private final Scoreboard board;
    private final Objective objective;

    public SidebarManager() {
        this.manager = Bukkit.getScoreboardManager();
        this.board = manager.getNewScoreboard();

        // 创建侧边栏目标
        this.objective = board.registerNewObjective(
            "sidebar",                           // 内部名称
            Criteria.DUMMY,                      // 标准(DUMMY = 手动设置)
            Component.text("§6✦ 服务器名称"),    // 标题
            RenderType.INTEGER                    // 显示为整数
        );

        objective.setDisplaySlot(DisplaySlot.SIDEBAR);
    }

    /**
     * 更新侧边栏内容
     */
    public void update(Player player) {
        // 清除旧分数
        for (String entry : board.getEntries()) {
            board.resetScores(entry);
        }

        // 设置新内容(分数值越大越靠上)
        setLine(15, "§7§m━━━━━━━━━━━━━");
        setLine(14, "§e玩家: §f" + player.getName());
        setLine(13, "§e金币: §a" + getBalance(player));
        setLine(12, "§e等级: §b" + getLevel(player));
        setLine(11, "§7§m━━━━━━━━━━━━━");
        setLine(10, "§e在线: §f" + Bukkit.getOnlinePlayers().size() + " 人");
        setLine(9,  "§eTPS: §a" + getTPS());
        setLine(8,  "§7§m━━━━━━━━━━━━━");
        setLine(7,  "§ewww.example.com");

        // 应用到玩家
        player.setScoreboard(board);
    }

    private void setLine(int score, String text) {
        objective.getScore(text).setScore(score);
    }
}

定时更新侧边栏

public class ScoreboardTask {

    private final SidebarManager sidebar;
    private BukkitTask task;

    public ScoreboardTask(MyPlugin plugin, SidebarManager sidebar) {
        this.sidebar = sidebar;

        // 每 20 tick(1 秒)更新一次
        task = Bukkit.getScheduler().runTaskTimer(plugin, () -> {
            for (Player player : Bukkit.getOnlinePlayers()) {
                sidebar.update(player);
            }
        }, 0L, 20L);
    }

    public void stop() {
        if (task != null) task.cancel();
    }
}

10.3 分页侧边栏

当内容超过 15 行时,需要分页显示。

public class PaginatedSidebar {

    private final Map<UUID, Integer> playerPages = new HashMap<>();
    private final Map<UUID, BukkitTask> playerTasks = new HashMap<>();
    private final List<List<String>> pages = new ArrayList<>();

    /**
     * 添加一页内容
     */
    public void addPage(List<String> lines) {
        pages.add(lines);
    }

    /**
     * 为玩家创建定时翻页任务
     */
    public void startRotation(MyPlugin plugin, Player player, int intervalTicks) {
        UUID uuid = player.getUniqueId();
        playerPages.put(uuid, 0);

        BukkitTask task = Bukkit.getScheduler().runTaskTimer(plugin, () -> {
            int page = playerPages.getOrDefault(uuid, 0);
            showPage(player, page);
            playerPages.put(uuid, (page + 1) % pages.size());
        }, 0L, intervalTicks);

        playerTasks.put(uuid, task);
    }

    private void showPage(Player player, int pageIndex) {
        if (pages.isEmpty() || pageIndex >= pages.size()) return;

        ScoreboardManager manager = Bukkit.getScoreboardManager();
        Scoreboard board = manager.getNewScoreboard();

        Objective obj = board.registerNewObjective(
            "sidebar_" + pageIndex,
            Criteria.DUMMY,
            Component.text("§6✦ 第 " + (pageIndex + 1) + " 页"),
            RenderType.INTEGER
        );
        obj.setDisplaySlot(DisplaySlot.SIDEBAR);

        List<String> lines = pages.get(pageIndex);
        for (int i = 0; i < lines.size() && i < 15; i++) {
            obj.getScore(lines.get(i)).setScore(15 - i);
        }

        player.setScoreboard(board);
    }

    /**
     * 停止玩家的翻页
     */
    public void stopRotation(UUID uuid) {
        BukkitTask task = playerTasks.remove(uuid);
        if (task != null) task.cancel();
        playerPages.remove(uuid);
    }
}

10.4 Teams 与前缀/后缀

创建 Team

public class TeamManager {

    private final Scoreboard board;

    public TeamManager(Scoreboard board) {
        this.board = board;
    }

    /**
     * 创建带前缀后缀的 Team
     */
    public Team createTeam(String name, String prefix, String suffix,
                           NamedTextColor color) {
        Team team = board.getTeam(name);
        if (team == null) {
            team = board.registerNewTeam(name);
        }

        team.prefix(Component.text(prefix, color));
        team.suffix(Component.text(suffix, color));
        team.color(color);

        // PVP 设置
        team.setAllowFriendlyFire(false);     // 队内不互相伤害
        team.setCanSeeFriendlyInvisibles(true); // 能看到队友隐身

        return team;
    }

    /**
     * 设置玩家的 Team 颜色和前后缀
     */
    public void setPlayerTeam(Player player, String teamName,
                               String prefix, String suffix,
                               NamedTextColor color) {
        // 先移除旧 Team
        removePlayerFromTeams(player);

        Team team = board.getTeam(teamName);
        if (team == null) {
            team = createTeam(teamName, prefix, suffix, color);
        }

        team.addPlayer(player);
    }

    /**
     * 移除玩家的所有 Team
     */
    public void removePlayerFromTeams(Player player) {
        for (Team team : board.getTeams()) {
            team.removePlayer(player);
        }
    }
}

使用示例

// VIP 前缀显示
teamManager.setPlayerTeam(player,
    "vip",           // Team 名称
    "§6[VIP] ",      // 前缀
    "",              // 后缀
    NamedTextColor.GOLD
);

10.5 浮动名称标签

显示自定义名称

// 设置实体头顶名称
entity.customName(Component.text("§c精英僵尸"));
entity.customNameVisible(true); // 始终可见

// 使用 Team 设置玩家名称颜色和前缀
Scoreboard board = Bukkit.getScoreboardManager().getMainScoreboard();

Team team = board.getTeam("admin_team");
if (team == null) {
    team = board.registerNewTeam("admin_team");
}

team.prefix(Component.text("§c[管理] "));
team.color(NamedTextColor.RED);
team.addPlayer(player);

// 应用到所有在线玩家
for (Player online : Bukkit.getOnlinePlayers()) {
    online.setScoreboard(board);
}

10.6 计分目标标准

常用标准

标准 说明 触发方式
Criteria.DUMMY 虚拟标准 手动设置分数
Criteria.DEATH_COUNT 死亡次数 自动更新
Criteria.PLAYER_KILL_COUNT 玩家击杀数 自动更新
Criteria.TOTAL_KILL_COUNT 总击杀数 自动更新
Criteria.HEALTH 生命值 实时更新
Criteria.XP 经验等级 实时更新
Criteria.FOOD 饥饿值 实时更新
Criteria.LEVEL 经验等级 实时更新

创建生命值显示

// 玩家生命值显示在 Tab 列表
Objective healthObj = board.registerNewObjective(
    "health", Criteria.HEALTH,
    Component.text("❤"),
    RenderType.HEARTS  // 以心形显示
);
healthObj.setDisplaySlot(DisplaySlot.PLAYER_LIST);

// 显示在名称下方
Objective nameHealth = board.registerNewObjective(
    "name_health", "health",
    Component.text("❤"),
    RenderType.HEARTS
);
nameHealth.setDisplaySlot(DisplaySlot.BELOW_NAME);

10.7 实时计分更新

分数动画

public class ScoreAnimation {

    private final Objective objective;
    private int frame = 0;

    public ScoreAnimation(Objective objective) {
        this.objective = objective;
    }

    /**
     * 滚动文本动画
     */
    public String scrollText(String text, int width) {
        String padded = " ".repeat(width) + text + " ".repeat(width);
        int index = frame % padded.length();

        if (index + width <= padded.length()) {
            return padded.substring(index, index + width);
        }
        return text.substring(0, Math.min(width, text.length()));
    }

    /**
     * 彩虹色动画
     */
    public String rainbowText(String text) {
        ChatColor[] colors = {
            ChatColor.RED, ChatColor.GOLD, ChatColor.YELLOW,
            ChatColor.GREEN, ChatColor.AQUA, ChatColor.BLUE,
            ChatColor.LIGHT_PURPLE
        };

        StringBuilder result = new StringBuilder();
        for (int i = 0; i < text.length(); i++) {
            result.append(colors[(i + frame) % colors.length]);
            result.append(text.charAt(i));
        }
        frame++;
        return result.toString();
    }

    public void nextFrame() {
        frame++;
    }
}

实时更新类

public class ScoreboardUpdater {

    private final Map<UUID, Scoreboard> playerBoards = new HashMap<>();
    private BukkitTask updateTask;

    public void start(MyPlugin plugin) {
        updateTask = Bukkit.getScheduler().runTaskTimer(plugin, () -> {
            for (Player player : Bukkit.getOnlinePlayers()) {
                updatePlayerScoreboard(player);
            }
        }, 0L, 5L); // 每 0.25 秒更新
    }

    private void updatePlayerScoreboard(Player player) {
        Scoreboard board = playerBoards.computeIfAbsent(
            player.getUniqueId(),
            k -> Bukkit.getScoreboardManager().getNewScoreboard()
        );

        // 获取或创建目标
        Objective obj = board.getObjective("info");
        if (obj == null) {
            obj = board.registerNewObjective(
                "info", Criteria.DUMMY,
                Component.text("§6✦ 信息面板"),
                RenderType.INTEGER
            );
            obj.setDisplaySlot(DisplaySlot.SIDEBAR);
        }

        // 动态更新分数
        updateScores(obj, player);

        player.setScoreboard(board);
    }

    private void updateScores(Objective obj, Player player) {
        // 清除旧分数
        for (String entry : obj.getScoreboard().getEntries()) {
            obj.getScoreboard().resetScores(entry);
        }

        // 设置新分数
        setScore(obj, "§7时间: §f" + getFormattedTime(), 10);
        setScore(obj, "§7在线: §a" + Bukkit.getOnlinePlayers().size(), 9);
        setScore(obj, "§7金币: §6" + EconomyManager.getBalance(player), 8);
        setScore(obj, "§7等级: §b" + player.getLevel(), 7);
    }

    private void setScore(Objective obj, String entry, int score) {
        obj.getScore(entry).setScore(score);
    }

    public void stop() {
        if (updateTask != null) updateTask.cancel();
    }
}

10.8 生命条(BossBar)

BossBar 也是一种重要的信息展示方式:

import org.bukkit.boss.BarColor;
import org.bukkit.boss.BarFlag;
import org.bukkit.boss.BarStyle;
import org.bukkit.boss.BossBar;
import org.bukkit.boss.KeyedBossBar;

public class BossBarManager {

    /**
     * 创建 BossBar
     */
    public static BossBar createBossBar(String title, BarColor color,
                                         BarStyle style) {
        return Bukkit.createBossBar(
            NamespacedKey.fromString("myplugin:info_bar"),
            title,
            color,
            style,
            BarFlag.CREATE_FOG
        );
    }

    /**
     * 向所有玩家显示
     */
    public static void showToAll(BossBar bossBar) {
        for (Player player : Bukkit.getOnlinePlayers()) {
            bossBar.addPlayer(player);
        }
    }

    /**
     * 进度条动画
     */
    public static void animateProgress(BossBar bossBar, double from,
                                        double to, int steps, MyPlugin plugin) {
        BukkitTask task = Bukkit.getScheduler().runTaskTimer(plugin, () -> {
            double current = bossBar.getProgress();
            double diff = to - current;

            if (Math.abs(diff) < 0.01) {
                bossBar.setProgress(to);
                return;
            }

            bossBar.setProgress(current + diff / steps);
        }, 0L, 2L);
    }
}

10.9 业务场景:排行榜系统

public class LeaderboardSidebar {

    private final SidebarManager sidebar;

    public void showLeaderboard(Player player, String category) {
        List<LeaderboardEntry> entries = getLeaderboardData(category);

        ScoreboardManager manager = Bukkit.getScoreboardManager();
        Scoreboard board = manager.getNewScoreboard();

        Objective obj = board.registerNewObjective(
            "leaderboard", Criteria.DUMMY,
            Component.text("§6" + category + " 排行榜"),
            RenderType.INTEGER
        );
        obj.setDisplaySlot(DisplaySlot.SIDEBAR);

        // 显示前 10 名
        int displayCount = Math.min(entries.size(), 10);
        for (int i = 0; i < displayCount; i++) {
            LeaderboardEntry entry = entries.get(i);
            String medal = switch (i) {
                case 0 -> "§6🥇 ";
                case 1 -> "§f🥈 ";
                case 2 -> "§c🥉 ";
                default -> "§7" + (i + 1) + ". ";
            };

            String line = medal + entry.name() + ": §a" + entry.value();
            obj.getScore(line).setScore(100 - i); // 高分在上
        }

        player.setScoreboard(board);
    }
}

10.10 常见问题排查

问题 原因 解决方案
侧边栏不显示 DisplaySlot 未设置 调用 setDisplaySlot(SIDEBAR)
分数行不更新 旧分数未清除 resetScores()
Team 不生效 未应用 Scoreboard player.setScoreboard(board)
中文字符宽度不一致 客户端渲染问题 使用等宽字符或空格对齐
多个侧边栏冲突 同名 Objective 使用不同的内部名称

10.11 扩展阅读


10.12 本章小结

要点 内容
侧边栏 使用 Objective + DisplaySlot.SIDEBAR
Teams 控制玩家颜色、前缀后缀、PVP 设置
计分标准 Criteria.DUMMY(手动)vs 生命值等(自动)
更新频率 避免过于频繁,推荐 1 秒以上间隔
BossBar 大型进度条,适合 BOSS 血量或通知
排行榜 结合数据库数据,分页显示

下一章: 第 11 章:数据包 — 学习 ProtocolLib 和 Minecraft 网络数据包操作。