diff --git a/pom.xml b/pom.xml
index 29dcc06..453e61a 100644
--- a/pom.xml
+++ b/pom.xml
@@ -6,7 +6,7 @@
ch.bensuisse
MiningMan
- 1.3
+ 2.0
jar
MiningMan
diff --git a/src/main/java/ch/bensuisse/miningman/MiningMan.java b/src/main/java/ch/bensuisse/miningman/MiningMan.java
index a0243ee..70f7730 100644
--- a/src/main/java/ch/bensuisse/miningman/MiningMan.java
+++ b/src/main/java/ch/bensuisse/miningman/MiningMan.java
@@ -5,6 +5,8 @@ import ch.bensuisse.miningman.features.announce.AnnounceCommand;
import ch.bensuisse.miningman.features.chat.ChatListener;
import ch.bensuisse.miningman.features.chat.ClearChatCommand;
import ch.bensuisse.miningman.features.general.ConnectionListener;
+import ch.bensuisse.miningman.features.scoreboard.ScoreboardCommand;
+import ch.bensuisse.miningman.features.scoreboard.ScoreboardService;
import ch.bensuisse.miningman.features.tablist.TablistService;
import ch.bensuisse.miningman.managers.JoinManager;
import net.luckperms.api.LuckPerms;
@@ -16,6 +18,7 @@ public final class MiningMan extends JavaPlugin {
private LuckPerms luckPerms;
private TablistService tablistService;
+ private ScoreboardService scoreboardService;
@Override
public void onEnable() {
@@ -26,6 +29,7 @@ public final class MiningMan extends JavaPlugin {
}
this.tablistService = new TablistService(this, luckPerms);
+ this.scoreboardService = new ScoreboardService(this, luckPerms);
registerListeners();
registerCommands();
@@ -38,6 +42,8 @@ public final class MiningMan extends JavaPlugin {
public void onDisable() {
if (tablistService != null)
tablistService.onDisable();
+ if (scoreboardService != null)
+ scoreboardService.onDisable();
}
private boolean setupLuckPerms() {
@@ -55,6 +61,7 @@ public final class MiningMan extends JavaPlugin {
pm.registerEvents(new ConnectionListener(luckPerms), this);
pm.registerEvents(new ChatListener(this), this);
pm.registerEvents(this.tablistService, this);
+ pm.registerEvents(this.scoreboardService, this);
}
private void registerCommands() {
@@ -71,9 +78,17 @@ public final class MiningMan extends JavaPlugin {
ClearChatCommand clearChatCommand = new ClearChatCommand();
getCommand("clearchat").setExecutor(clearChatCommand);
getCommand("clearchat").setTabCompleter(clearChatCommand);
+
+ ScoreboardCommand scoreboardCommand = new ScoreboardCommand(scoreboardService);
+ getCommand("sb").setExecutor(scoreboardCommand);
+ getCommand("sb").setTabCompleter(scoreboardCommand);
}
public TablistService getTablistService() {
return tablistService;
}
+
+ public ScoreboardService getScoreboardService() {
+ return scoreboardService;
+ }
}
diff --git a/src/main/java/ch/bensuisse/miningman/features/general/ConnectionListener.java b/src/main/java/ch/bensuisse/miningman/features/general/ConnectionListener.java
index c7f718a..7d15078 100644
--- a/src/main/java/ch/bensuisse/miningman/features/general/ConnectionListener.java
+++ b/src/main/java/ch/bensuisse/miningman/features/general/ConnectionListener.java
@@ -5,6 +5,7 @@ import net.luckperms.api.LuckPerms;
import net.luckperms.api.model.user.User;
import org.bukkit.entity.Player;
import org.bukkit.event.EventHandler;
+import org.bukkit.event.EventPriority;
import org.bukkit.event.Listener;
import org.bukkit.event.player.PlayerJoinEvent;
import org.bukkit.event.player.PlayerQuitEvent;
@@ -17,14 +18,14 @@ public class ConnectionListener implements Listener {
this.luckPerms = luckPerms;
}
- @EventHandler
+ @EventHandler(priority = EventPriority.HIGHEST)
public void onJoin(PlayerJoinEvent event) {
String prefix = getPrefix(event.getPlayer());
String msg = "[+] " + prefix + " " + event.getPlayer().getName();
event.joinMessage(MiniMessage.miniMessage().deserialize(msg));
}
- @EventHandler
+ @EventHandler(priority = EventPriority.HIGHEST)
public void onQuit(PlayerQuitEvent event) {
String prefix = getPrefix(event.getPlayer());
String msg = "[-] " + prefix + " " + event.getPlayer().getName();
diff --git a/src/main/java/ch/bensuisse/miningman/features/scoreboard/ScoreboardCommand.java b/src/main/java/ch/bensuisse/miningman/features/scoreboard/ScoreboardCommand.java
new file mode 100644
index 0000000..857287e
--- /dev/null
+++ b/src/main/java/ch/bensuisse/miningman/features/scoreboard/ScoreboardCommand.java
@@ -0,0 +1,51 @@
+package ch.bensuisse.miningman.features.scoreboard;
+
+import net.kyori.adventure.text.Component;
+import net.kyori.adventure.text.format.NamedTextColor;
+import net.kyori.adventure.text.format.TextColor;
+import org.bukkit.Sound;
+import org.bukkit.command.Command;
+import org.bukkit.command.CommandExecutor;
+import org.bukkit.command.CommandSender;
+import org.bukkit.command.TabCompleter;
+import org.bukkit.entity.Player;
+import org.jetbrains.annotations.NotNull;
+
+import java.util.Collections;
+import java.util.List;
+
+public class ScoreboardCommand implements CommandExecutor, TabCompleter {
+
+ private static final TextColor THEME_COLOR = TextColor.fromHexString("#FFA80F");
+
+ private final ScoreboardService scoreboardService;
+
+ public ScoreboardCommand(ScoreboardService scoreboardService) {
+ this.scoreboardService = scoreboardService;
+ }
+
+ @Override
+ public boolean onCommand(@NotNull CommandSender sender, @NotNull Command command, @NotNull String label,
+ @NotNull String[] args) {
+ if (!(sender instanceof Player player)) {
+ sender.sendMessage(Component.text("ᴏɴʟʏ ᴘʟᴀʏᴇʀs ᴄᴀɴ ᴜsᴇ ᴛʜɪs ᴄᴏᴍᴍᴀɴᴅ.", NamedTextColor.RED));
+ return true;
+ }
+
+ boolean enabled = scoreboardService.toggle(player);
+ String state = enabled ? "ᴇɴᴀʙʟᴇᴅ" : "ᴅɪsᴀʙʟᴇᴅ";
+
+ player.sendMessage(Component.text("➤ ", NamedTextColor.WHITE)
+ .append(Component.text("sᴄᴏʀᴇʙᴏᴀʀᴅ ", THEME_COLOR))
+ .append(Component.text(state, THEME_COLOR)));
+ player.playSound(player.getLocation(), Sound.UI_BUTTON_CLICK, 1.0f, enabled ? 1.2f : 0.8f);
+
+ return true;
+ }
+
+ @Override
+ public List onTabComplete(@NotNull CommandSender sender, @NotNull Command command, @NotNull String alias,
+ @NotNull String[] args) {
+ return Collections.emptyList();
+ }
+}
diff --git a/src/main/java/ch/bensuisse/miningman/features/scoreboard/ScoreboardService.java b/src/main/java/ch/bensuisse/miningman/features/scoreboard/ScoreboardService.java
new file mode 100644
index 0000000..5fccaf2
--- /dev/null
+++ b/src/main/java/ch/bensuisse/miningman/features/scoreboard/ScoreboardService.java
@@ -0,0 +1,342 @@
+package ch.bensuisse.miningman.features.scoreboard;
+
+import ch.bensuisse.miningman.MiningMan;
+import ch.bensuisse.miningman.api.ColorUtil;
+import io.papermc.paper.scoreboard.numbers.NumberFormat;
+import net.kyori.adventure.text.Component;
+import net.kyori.adventure.text.format.NamedTextColor;
+import net.kyori.adventure.text.format.TextColor;
+import net.kyori.adventure.text.format.TextDecoration;
+import net.kyori.adventure.text.minimessage.MiniMessage;
+import net.luckperms.api.LuckPerms;
+import net.luckperms.api.model.user.User;
+import org.bukkit.Bukkit;
+import org.bukkit.ChatColor;
+import org.bukkit.Material;
+import org.bukkit.Statistic;
+import org.bukkit.entity.Player;
+import org.bukkit.event.EventHandler;
+import org.bukkit.event.Listener;
+import org.bukkit.event.player.PlayerJoinEvent;
+import org.bukkit.event.player.PlayerQuitEvent;
+import org.bukkit.scoreboard.Criteria;
+import org.bukkit.scoreboard.DisplaySlot;
+import org.bukkit.scoreboard.Objective;
+import org.bukkit.scoreboard.Scoreboard;
+import org.bukkit.scoreboard.Team;
+
+import java.util.ArrayList;
+import java.util.Arrays;
+import java.util.HashMap;
+import java.util.HashSet;
+import java.util.List;
+import java.util.Map;
+import java.util.Set;
+import java.util.UUID;
+
+/**
+ * Persönliches Sidebar-Scoreboard: Name, Rang, Tode, Spielzeit, Online-Anzahl, platzierte/abgebaute
+ * Blöcke, gelaufene Blöcke sowie Server-IP. Standardmäßig für alle Spieler aktiv, kann pro Spieler
+ * mit /sb ein- oder ausgeblendet werden.
+ */
+public class ScoreboardService implements Listener {
+
+ private static final String OBJECTIVE_NAME = "miningman_sb";
+ private static final TextColor LABEL_COLOR = TextColor.fromHexString("#FE5A1D");
+
+ // Jede Zeile bekommt einen eigenen, satten Farbton für das Icon.
+ private static final TextColor NAME_COLOR = TextColor.fromHexString("#FF5C8A");
+ private static final TextColor RANK_COLOR = TextColor.fromHexString("#FFD400");
+ private static final TextColor DEATHS_COLOR = TextColor.fromHexString("#FF3B3B");
+ private static final TextColor PLAYTIME_COLOR = TextColor.fromHexString("#FFA80F");
+ private static final TextColor ONLINE_COLOR = TextColor.fromHexString("#39FF6A");
+ private static final TextColor PLACED_COLOR = TextColor.fromHexString("#B14EFF");
+ private static final TextColor MINED_COLOR = TextColor.fromHexString("#C68642");
+ private static final TextColor WALKED_COLOR = TextColor.fromHexString("#22D3EE");
+
+ // Die Titel (außer Name, der bleibt pink wie das Icon) bleiben im Marken-Rot-Orange,
+ // nur jede Zeile in einer anderen Stufe (dunkel -> hell).
+ private static final List LABEL_SHADES = ColorUtil.createGradient(
+ TextColor.fromHexString("#C43C0A"), TextColor.fromHexString("#FFC28C"), 7);
+
+ private static final String SEPARATOR = " "; // 25 Zeichen, als Leerzeichen für eine durchgehende Linie
+ private static final String SERVER_DOMAIN = "ᴍɪɴɪɴɢᴍᴀɴ.eu";
+ private static final List MINEABLE_MATERIALS = Arrays.stream(Material.values())
+ .filter(m -> m.isBlock() && !m.isLegacy())
+ .toList();
+ // USE_ITEM verlangt zwingend ein Material, das auch ein Item ist - reine Block-Varianten
+ // wie ACACIA_WALL_HANGING_SIGN (kein eigenes Item) fliegen sonst mit IllegalArgumentException raus.
+ private static final List PLACEABLE_MATERIALS = Arrays.stream(Material.values())
+ .filter(m -> m.isBlock() && m.isItem() && !m.isLegacy())
+ .toList();
+
+ // Reihenfolge der Zeilen in buildLines(). Nur die dynamischen Indizes werden pro Update
+ // tatsächlich neu gesendet - Trennlinien, Leerzeilen und die IP-Zeile ändern sich nie.
+ private static final int LINE_COUNT = 13;
+ private static final int[] DYNAMIC_LINE_INDICES = { 1, 2, 4, 5, 6, 8, 9, 10 };
+
+ // Blöcke platziert/abgebaut erfordern eine Iteration über hunderte Materialien - das lohnt sich
+ // nicht bei jedem 2-Sekunden-Takt, deshalb wird nur alle paar Zyklen neu gerechnet und zwischengespeichert.
+ private static final int BLOCK_STATS_REFRESH_INTERVAL = 5; // ~10s bei 2s-Takt
+
+ private final MiningMan plugin;
+ private final LuckPerms luckPerms;
+ private final List gradientColors;
+ private final Set disabled = new HashSet<>();
+ private final Map boards = new HashMap<>();
+ private final Map blockStatsCache = new HashMap<>();
+ private int tickCounter = 0;
+
+ public ScoreboardService(MiningMan plugin, LuckPerms luckPerms) {
+ this.plugin = plugin;
+ this.luckPerms = luckPerms;
+ this.gradientColors = ColorUtil.createGradient(TextColor.fromHexString("#FE5A1D"),
+ TextColor.fromHexString("#FFA80F"), 40);
+
+ Bukkit.getScheduler().runTaskTimer(plugin, this::updateAll, 20L, 40L);
+ }
+
+ public void onDisable() {
+ Scoreboard main = Bukkit.getScoreboardManager().getMainScoreboard();
+ for (Player player : Bukkit.getOnlinePlayers()) {
+ player.setScoreboard(main);
+ }
+ boards.clear();
+ blockStatsCache.clear();
+ }
+
+ @EventHandler
+ public void onJoin(PlayerJoinEvent event) {
+ Bukkit.getScheduler().runTaskLater(plugin, () -> update(event.getPlayer(), true), 10L);
+ }
+
+ @EventHandler
+ public void onQuit(PlayerQuitEvent event) {
+ UUID id = event.getPlayer().getUniqueId();
+ disabled.remove(id);
+ boards.remove(id);
+ blockStatsCache.remove(id);
+ }
+
+ /**
+ * Schaltet das Scoreboard für den Spieler um.
+ *
+ * @return true, wenn das Scoreboard danach aktiv ist, sonst false.
+ */
+ public boolean toggle(Player player) {
+ UUID id = player.getUniqueId();
+ if (disabled.contains(id)) {
+ disabled.remove(id);
+ update(player, true);
+ return true;
+ }
+
+ disabled.add(id);
+ boards.remove(id);
+ player.setScoreboard(Bukkit.getScoreboardManager().getMainScoreboard());
+ return false;
+ }
+
+ public boolean isEnabled(Player player) {
+ return !disabled.contains(player.getUniqueId());
+ }
+
+ private void updateAll() {
+ boolean refreshBlockStats = (tickCounter++ % BLOCK_STATS_REFRESH_INTERVAL) == 0;
+ for (Player player : Bukkit.getOnlinePlayers()) {
+ if (isEnabled(player))
+ update(player, refreshBlockStats);
+ }
+ }
+
+ private void update(Player player, boolean refreshBlockStats) {
+ if (!isEnabled(player))
+ return;
+
+ boolean isNewBoard = !boards.containsKey(player.getUniqueId());
+ PlayerBoard board = boards.computeIfAbsent(player.getUniqueId(), id -> createBoard(player));
+
+ List lines = buildLines(player, refreshBlockStats || isNewBoard);
+ for (int index : DYNAMIC_LINE_INDICES) {
+ board.teams[index].prefix(lines.get(index));
+ }
+ }
+
+ /**
+ * Registriert Objective, Teams und Scores für einen Spieler genau einmal. Danach werden nur
+ * noch die Team-Prefixe der dynamischen Zeilen aktualisiert, statt das komplette Scoreboard
+ * jedes Mal neu aufzubauen und an den Client zu senden.
+ */
+ private PlayerBoard createBoard(Player player) {
+ Scoreboard scoreboard = Bukkit.getScoreboardManager().getNewScoreboard();
+ Objective objective = scoreboard.registerNewObjective(OBJECTIVE_NAME, Criteria.DUMMY,
+ ColorUtil.applyGradient("MiningMan", gradientColors).decorate(TextDecoration.BOLD));
+ objective.setDisplaySlot(DisplaySlot.SIDEBAR);
+ objective.numberFormat(NumberFormat.blank());
+
+ List lines = buildLines(player, true);
+ Team[] teams = new Team[LINE_COUNT];
+
+ int score = LINE_COUNT;
+ for (int i = 0; i < LINE_COUNT; i++) {
+ String entry = uniqueEntry(score);
+ Team team = scoreboard.registerNewTeam("l" + i);
+ team.addEntry(entry);
+ team.prefix(lines.get(i));
+ objective.getScore(entry).setScore(score);
+ teams[i] = team;
+ score--;
+ }
+
+ player.setScoreboard(scoreboard);
+ return new PlayerBoard(teams);
+ }
+
+ private List buildLines(Player player, boolean refreshBlockStats) {
+ long[] blockStats = getBlockStats(player, refreshBlockStats);
+
+ List lines = new ArrayList<>();
+
+ lines.add(separator());
+ lines.add(nameLine(player));
+ lines.add(rankLine(player, LABEL_SHADES.get(0)));
+ lines.add(Component.empty());
+ lines.add(statLine("☠", DEATHS_COLOR, "ᴅᴇᴀᴛʜs", LABEL_SHADES.get(1),
+ String.valueOf(player.getStatistic(Statistic.DEATHS))));
+ lines.add(statLine("⌛", PLAYTIME_COLOR, "ᴘʟᴀʏᴛɪᴍᴇ", LABEL_SHADES.get(2),
+ formatPlaytime(player.getStatistic(Statistic.PLAY_ONE_MINUTE))));
+ lines.add(statLine("◎", ONLINE_COLOR, "ᴏɴʟɪɴᴇ", LABEL_SHADES.get(3),
+ Bukkit.getOnlinePlayers().size() + "/" + Bukkit.getMaxPlayers()));
+ lines.add(Component.empty());
+ lines.add(statLine("⧉", PLACED_COLOR, "ᴘʟᴀᴄᴇᴅ", LABEL_SHADES.get(4), formatNumber(blockStats[1])));
+ lines.add(statLine("⛏", MINED_COLOR, "ᴍɪɴᴇᴅ", LABEL_SHADES.get(5), formatNumber(blockStats[0])));
+ lines.add(statLine("⇄", WALKED_COLOR, "ᴡᴀʟᴋᴇᴅ", LABEL_SHADES.get(6), formatNumber(getBlocksWalked(player))));
+ lines.add(separator());
+ lines.add(ipLine());
+
+ return lines;
+ }
+
+ private Component separator() {
+ return ColorUtil.applyGradient(SEPARATOR, gradientColors).decorate(TextDecoration.STRIKETHROUGH);
+ }
+
+ private Component nameLine(Player player) {
+ return Component.text(" ✎ ", NAME_COLOR)
+ .append(Component.text("ɴᴀᴍᴇ: ", NAME_COLOR))
+ .append(Component.text(player.getName(), NamedTextColor.WHITE).decoration(TextDecoration.BOLD, false));
+ }
+
+ private Component rankLine(Player player, TextColor labelColor) {
+ User user = luckPerms.getUserManager().getUser(player.getUniqueId());
+ String prefix = user != null ? user.getCachedData().getMetaData().getPrefix() : null;
+
+ Component rankValue;
+ if (prefix != null && !prefix.isEmpty()) {
+ rankValue = MiniMessage.miniMessage().deserialize(prefix);
+ } else {
+ rankValue = Component.text("-", NamedTextColor.WHITE);
+ }
+
+ return Component.text(" ★ ", RANK_COLOR)
+ .append(Component.text("ʀᴀɴᴋ: ", labelColor))
+ .append(rankValue);
+ }
+
+ private Component statLine(String icon, TextColor iconColor, String label, TextColor labelColor, String value) {
+ return Component.text(" " + icon + " ", iconColor)
+ .append(Component.text(label + ": ", labelColor))
+ .append(Component.text(value, NamedTextColor.WHITE));
+ }
+
+ private Component ipLine() {
+ return Component.text(" → ", LABEL_COLOR)
+ .append(Component.text(SERVER_DOMAIN, NamedTextColor.WHITE))
+ .append(Component.text(" ← ", LABEL_COLOR));
+ }
+
+ /**
+ * Liefert {abgebaut, platziert} für einen Spieler. Wird nur bei refresh=true neu über alle
+ * Materialien berechnet, sonst kommt der zwischengespeicherte Wert vom letzten Refresh.
+ */
+ private long[] getBlockStats(Player player, boolean refresh) {
+ UUID id = player.getUniqueId();
+ if (refresh || !blockStatsCache.containsKey(id)) {
+ long[] stats = { getBlocksMined(player), getBlocksPlaced(player) };
+ blockStatsCache.put(id, stats);
+ return stats;
+ }
+ return blockStatsCache.get(id);
+ }
+
+ /**
+ * Summiert die Bukkit-Statistik MINE_BLOCK über alle Blockarten (abgebaute Blöcke).
+ */
+ private long getBlocksMined(Player player) {
+ long total = 0;
+ for (Material material : MINEABLE_MATERIALS) {
+ total += player.getStatistic(Statistic.MINE_BLOCK, material);
+ }
+ return total;
+ }
+
+ /**
+ * Summiert die Bukkit-Statistik USE_ITEM über alle platzierbaren Blockarten. Bei platzierbaren
+ * Blöcken zählt "used" vanilla-seitig genau die Platzierungen (siehe Statistik-Screen im Spiel).
+ */
+ private long getBlocksPlaced(Player player) {
+ long total = 0;
+ for (Material material : PLACEABLE_MATERIALS) {
+ total += player.getStatistic(Statistic.USE_ITEM, material);
+ }
+ return total;
+ }
+
+ private long getBlocksWalked(Player player) {
+ long cm = (long) player.getStatistic(Statistic.WALK_ONE_CM) + player.getStatistic(Statistic.SPRINT_ONE_CM);
+ return cm / 100L;
+ }
+
+ private String formatPlaytime(int ticks) {
+ long minutes = ticks / 20L / 60L;
+ long days = minutes / 1440;
+ long hours = (minutes % 1440) / 60;
+ long mins = minutes % 60;
+
+ if (days > 0)
+ return days + "d " + hours + "h";
+ if (hours > 0)
+ return hours + "h " + mins + "m";
+ return mins + "m";
+ }
+
+ /**
+ * Zeigt Zahlen bis 9999 exakt an (z.B. 9640) und kürzt erst ab 10'000 auf "k" bzw.
+ * ab 1'000'000 auf "m" (z.B. 12k, 100k, 1m).
+ */
+ private String formatNumber(long value) {
+ if (value < 10_000)
+ return String.valueOf(value);
+ if (value < 1_000_000)
+ return (value / 1000) + "k";
+ return (value / 1_000_000) + "m";
+ }
+
+ /**
+ * Erzeugt eine unsichtbare, eindeutige Score-Entry aus Farbcodes (mehrere Zeilen ohne
+ * sichtbaren Text sind sonst nicht möglich, da jede Zeile im Scoreboard einen eigenen
+ * eindeutigen Eintrag benötigt).
+ */
+ private String uniqueEntry(int index) {
+ StringBuilder sb = new StringBuilder();
+ int n = index + 1;
+ while (n > 0) {
+ sb.append(ChatColor.COLOR_CHAR).append(Integer.toHexString(n % 16));
+ n /= 16;
+ }
+ return sb.toString();
+ }
+
+ private record PlayerBoard(Team[] teams) {
+ }
+}
diff --git a/src/main/resources/plugin.yml b/src/main/resources/plugin.yml
index 815f5d4..3b0db1e 100644
--- a/src/main/resources/plugin.yml
+++ b/src/main/resources/plugin.yml
@@ -1,5 +1,5 @@
name: MiningMan
-version: '1.3'
+version: '2.0'
main: ch.bensuisse.miningman.MiningMan
api-version: '1.21'
authors: [BenSuisse]
@@ -18,3 +18,5 @@ commands:
usage: /clearchat
aliases: [cc]
permission: miningman.clearchat
+ sb:
+ usage: /sb