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 网络数据包操作。