From fab579d5af7ad75f074abe36ff0e980821f4a383 Mon Sep 17 00:00:00 2001 From: bensuisse Date: Wed, 5 Aug 2026 22:21:52 +0200 Subject: [PATCH] added scoreboard --- pom.xml | 2 +- .../ch/bensuisse/miningman/MiningMan.java | 15 + .../features/general/ConnectionListener.java | 5 +- .../scoreboard/ScoreboardCommand.java | 51 +++ .../scoreboard/ScoreboardService.java | 342 ++++++++++++++++++ src/main/resources/plugin.yml | 4 +- 6 files changed, 415 insertions(+), 4 deletions(-) create mode 100644 src/main/java/ch/bensuisse/miningman/features/scoreboard/ScoreboardCommand.java create mode 100644 src/main/java/ch/bensuisse/miningman/features/scoreboard/ScoreboardService.java 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