强曰为道

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

第 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 不生效未应用 Scoreboardplayer.setScoreboard(board)
中文字符宽度不一致客户端渲染问题使用等宽字符或空格对齐
多个侧边栏冲突同名 Objective使用不同的内部名称

10.11 扩展阅读


10.12 本章小结

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

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