diff --git a/src/main/java/de/fussball/plugin/Fussball.java b/src/main/java/de/fussball/plugin/Fussball.java index 57be7b0..d0b4f32 100644 --- a/src/main/java/de/fussball/plugin/Fussball.java +++ b/src/main/java/de/fussball/plugin/Fussball.java @@ -4,6 +4,7 @@ import de.fussball.plugin.arena.Arena; import de.fussball.plugin.arena.ArenaManager; import de.fussball.plugin.commands.FussballCommand; import de.fussball.plugin.game.GameManager; +import de.fussball.plugin.hologram.HologramManager; import de.fussball.plugin.listeners.*; import de.fussball.plugin.placeholders.FussballPlaceholders; import de.fussball.plugin.stats.StatsManager; @@ -14,10 +15,11 @@ import org.bukkit.plugin.java.JavaPlugin; public class Fussball extends JavaPlugin { private static Fussball instance; - private ArenaManager arenaManager; - private GameManager gameManager; - private StatsManager statsManager; - private SignListener signListener; + private ArenaManager arenaManager; + private GameManager gameManager; + private StatsManager statsManager; + private SignListener signListener; + private HologramManager hologramManager; @Override public void onEnable() { @@ -26,10 +28,11 @@ public class Fussball extends JavaPlugin { saveDefaultConfig(); // Manager initialisieren - arenaManager = new ArenaManager(this); - gameManager = new GameManager(this); - statsManager = new StatsManager(this); - signListener = new SignListener(this); + arenaManager = new ArenaManager(this); + gameManager = new GameManager(this); + statsManager = new StatsManager(this); + signListener = new SignListener(this); + hologramManager = new HologramManager(this); Messages.init(this); registerCommands(); @@ -48,8 +51,9 @@ public class Fussball extends JavaPlugin { @Override public void onDisable() { - if (gameManager != null) gameManager.stopAllGames(); - if (statsManager != null) statsManager.save(); + if (gameManager != null) gameManager.stopAllGames(); + if (statsManager != null) statsManager.save(); + if (hologramManager != null) hologramManager.removeAll(); // Entities sauber entfernen getLogger().info("⚽ Fußball-Plugin gestoppt!"); } @@ -66,9 +70,10 @@ public class Fussball extends JavaPlugin { getServer().getPluginManager().registerEvents(signListener, this); } - public static Fussball getInstance() { return instance; } - public ArenaManager getArenaManager() { return arenaManager; } - public GameManager getGameManager() { return gameManager; } - public StatsManager getStatsManager() { return statsManager; } - public SignListener getSignListener() { return signListener; } + public static Fussball getInstance() { return instance; } + public ArenaManager getArenaManager() { return arenaManager; } + public GameManager getGameManager() { return gameManager; } + public StatsManager getStatsManager() { return statsManager; } + public SignListener getSignListener() { return signListener; } + public HologramManager getHologramManager() { return hologramManager; } } \ No newline at end of file diff --git a/src/main/java/de/fussball/plugin/arena/Arena.java b/src/main/java/de/fussball/plugin/arena/Arena.java index 30c1656..18ad071 100644 --- a/src/main/java/de/fussball/plugin/arena/Arena.java +++ b/src/main/java/de/fussball/plugin/arena/Arena.java @@ -12,6 +12,7 @@ public class Arena implements ConfigurationSerializable { private final String name; private Location center, redSpawn, blueSpawn, ballSpawn; private Location redGoalMin, redGoalMax, blueGoalMin, blueGoalMax, lobby; + private Location spectatorSpawn; // Zuschauer-Spawn (optional – Fallback: Spielfeldrand) private Location fieldMin, fieldMax; // Strafräume – optional manuell gesetzt; sonst auto-berechnet aus Tor + config private Location redPenaltyMin, redPenaltyMax, bluePenaltyMin, bluePenaltyMax; @@ -259,6 +260,7 @@ public class Arena implements ConfigurationSerializable { if (bluePenaltyMax != null) map.put("bluePenaltyMax", serLoc(bluePenaltyMax)); if (redPenaltySpot != null) map.put("redPenaltySpot", serLoc(redPenaltySpot)); if (bluePenaltySpot != null) map.put("bluePenaltySpot", serLoc(bluePenaltySpot)); + if (spectatorSpawn != null) map.put("spectatorSpawn", serLoc(spectatorSpawn)); return map; } @@ -289,6 +291,7 @@ public class Arena implements ConfigurationSerializable { if (map.containsKey("bluePenaltyMax")) a.bluePenaltyMax = desLoc(map.get("bluePenaltyMax")); if (map.containsKey("redPenaltySpot")) a.redPenaltySpot = desLoc(map.get("redPenaltySpot")); if (map.containsKey("bluePenaltySpot")) a.bluePenaltySpot = desLoc(map.get("bluePenaltySpot")); + if (map.containsKey("spectatorSpawn")) a.spectatorSpawn = desLoc(map.get("spectatorSpawn")); return a; } @@ -364,6 +367,8 @@ public class Arena implements ConfigurationSerializable { } public boolean hasManualPenaltyAreas() { return redPenaltyMin != null && redPenaltyMax != null && bluePenaltyMin != null && bluePenaltyMax != null; } + public Location getSpectatorSpawn() { return spectatorSpawn; } + public void setSpectatorSpawn(Location l) { this.spectatorSpawn = l; } public int getMinPlayers() { return minPlayers; } public void setMinPlayers(int n) { this.minPlayers = n; } public int getMaxPlayers() { return maxPlayers; } diff --git a/src/main/java/de/fussball/plugin/commands/FussballCommand.java b/src/main/java/de/fussball/plugin/commands/FussballCommand.java index 1e8b2fd..2daef7d 100644 --- a/src/main/java/de/fussball/plugin/commands/FussballCommand.java +++ b/src/main/java/de/fussball/plugin/commands/FussballCommand.java @@ -5,6 +5,8 @@ import de.fussball.plugin.arena.Arena; import de.fussball.plugin.game.Ball; import de.fussball.plugin.game.Game; import de.fussball.plugin.game.GameState; +import de.fussball.plugin.hologram.FussballHologram; +import de.fussball.plugin.hologram.HologramManager; import de.fussball.plugin.stats.StatsManager; import de.fussball.plugin.utils.MessageUtil; import org.bukkit.Bukkit; @@ -200,6 +202,76 @@ public class FussballCommand implements CommandExecutor, TabCompleter { handleDebug(player, arena); } + // ── Hologramm-Verwaltung ───────────────────────────────────────── + // /fb hologram set goals|wins – Hologramm erstellen + // /fb hologram remove – Nächstes Hologramm (< 5 Blöcke) entfernen + // /fb hologram delete – Hologramm nach ID löschen + // /fb hologram reload – Alle Hologramme neu spawnen + // /fb hologram list – Alle Hologramme anzeigen + case "hologram", "holo" -> { + if (!sender.hasPermission("fussball.admin")) { sender.sendMessage(MessageUtil.error("Keine Berechtigung!")); return true; } + if (!(sender instanceof Player player)) { sender.sendMessage("Nur für Spieler!"); return true; } + if (args.length < 2) { + player.sendMessage(MessageUtil.header("Hologramm-Befehle")); + player.sendMessage("§e/fb hologram set goals|wins §7– Hologramm setzen"); + player.sendMessage("§e/fb hologram remove §7– Nächstes entfernen (< 5 Blöcke)"); + player.sendMessage("§e/fb hologram delete §7– Nach ID löschen"); + player.sendMessage("§e/fb hologram reload §7– Alle neu laden"); + player.sendMessage("§e/fb hologram list §7– Alle anzeigen"); + player.sendMessage("§7Gesamt: §e" + plugin.getHologramManager().getCount() + " §7Hologramme"); + player.sendMessage("§7§oRechtsklick auf Hologramm → Tore ↔ Siege wechseln"); + return true; + } + switch (args[1].toLowerCase()) { + case "set" -> { + if (args.length < 4) { + player.sendMessage(MessageUtil.error("Benutze: /fb hologram set goals|wins")); + return true; + } + String id = args[2]; + FussballHologram.HoloType type = switch (args[3].toLowerCase()) { + case "wins", "siege" -> FussballHologram.HoloType.WINS; + default -> FussballHologram.HoloType.GOALS; + }; + plugin.getHologramManager().createHologram(id, player.getLocation(), type); + String holoLabel = type == FussballHologram.HoloType.WINS ? "Top-10-Siege" : "Top-10-Tore"; + player.sendMessage(MessageUtil.success("§e" + id + " §a(" + holoLabel + ") Hologramm gesetzt!")); + player.sendMessage("§7§oRechtsklick auf das Hologramm wechselt zwischen Tore und Siege."); + } + case "remove" -> { + String removed = plugin.getHologramManager().removeNearest(player.getLocation()); + if (removed != null) { + player.sendMessage(MessageUtil.success("Hologramm §e" + removed + " §aentfernt!")); + } else { + player.sendMessage(MessageUtil.error("Kein Hologramm innerhalb von 5 Blöcken gefunden!")); + } + } + case "delete" -> { + if (args.length < 3) { player.sendMessage(MessageUtil.error("Benutze: /fb hologram delete ")); return true; } + if (plugin.getHologramManager().removeHologram(args[2])) { + player.sendMessage(MessageUtil.success("Hologramm §e" + args[2] + " §agelöscht!")); + } else { + player.sendMessage(MessageUtil.error("Kein Hologramm mit ID §e" + args[2] + "§c gefunden!")); + } + } + case "reload" -> { + plugin.getHologramManager().reload(); + player.sendMessage(MessageUtil.success("Hologramme neu geladen! §7(" + plugin.getHologramManager().getCount() + " gesamt)")); + } + case "list" -> { + player.sendMessage(MessageUtil.header("Hologramme (" + plugin.getHologramManager().getCount() + ")")); + if (plugin.getHologramManager().getCount() == 0) { + player.sendMessage(MessageUtil.warn("Keine Hologramme vorhanden.")); + } else { + for (String id : plugin.getHologramManager().getHologramIds()) { + player.sendMessage("§7 • §e" + id); + } + } + } + default -> player.sendMessage(MessageUtil.error("Gültig: set goals|wins | remove | delete | reload | list")); + } + } + default -> sendHelp(sender); } return true; @@ -229,6 +301,7 @@ public class FussballCommand implements CommandExecutor, TabCompleter { case "bluepenaltymax" -> { arena.setBluePenaltyMax(player.getLocation()); player.sendMessage(MessageUtil.success("Blauer Strafraum Max gesetzt: "+ locStr(player.getLocation()))); } case "redpenaltyspot" -> { arena.setRedPenaltySpot(player.getLocation()); player.sendMessage(MessageUtil.success("Roter Elfmeter-Punkt gesetzt: " + locStr(player.getLocation()))); } case "bluepenaltyspot" -> { arena.setBluePenaltySpot(player.getLocation()); player.sendMessage(MessageUtil.success("Blauer Elfmeter-Punkt gesetzt: " + locStr(player.getLocation()))); } + case "spectatorspawn" -> { arena.setSpectatorSpawn(player.getLocation()); player.sendMessage(MessageUtil.success("Zuschauer-Spawn gesetzt: " + locStr(player.getLocation()))); } case "minplayers" -> { if (args.length < 4) return; arena.setMinPlayers(Integer.parseInt(args[3])); player.sendMessage(MessageUtil.success("Min-Spieler: §e" + args[3])); } case "maxplayers" -> { if (args.length < 4) return; arena.setMaxPlayers(Integer.parseInt(args[3])); player.sendMessage(MessageUtil.success("Max-Spieler: §e" + args[3])); } case "duration" -> { if (args.length < 4) return; arena.setGameDuration(Integer.parseInt(args[3])); player.sendMessage(MessageUtil.success("Spieldauer: §e" + args[3] + "s")); } @@ -246,7 +319,7 @@ public class FussballCommand implements CommandExecutor, TabCompleter { + " §8(optional – sonst auto-berechnet)"); player.sendMessage("§7 Blauer Strafraum: " + check(arena.getBluePenaltyMin(), arena.getBluePenaltyMax()) + " §8(optional – sonst auto-berechnet)"); - player.sendMessage("§7 Roter Elfmeter-Punkt: " + check(arena.getRedPenaltySpot()) + " §8(optional – sonst ball-spawn)"); + player.sendMessage("§7 Zuschauer-Spawn: " + check(arena.getSpectatorSpawn()) + " §8(optional – sonst Spielfeldrand)"); player.sendMessage("§7 Blauer Elfmeter-Punkt: " + check(arena.getBluePenaltySpot()) + " §8(optional – sonst ball-spawn)"); player.sendMessage("§7 Min. Spieler: §e" + arena.getMinPlayers()); player.sendMessage("§7 Max. Spieler: §e" + arena.getMaxPlayers()); @@ -320,6 +393,7 @@ public class FussballCommand implements CommandExecutor, TabCompleter { s.sendMessage("§e/fb top [goals|wins] §7- Bestenliste"); if (s.hasPermission("fussball.admin")) { s.sendMessage("§c§lAdmin: §ccreate / delete / setup / stop / debug"); + s.sendMessage("§c§lAdmin: §chologram set goals|wins / remove / reload"); } } @@ -345,9 +419,17 @@ public class FussballCommand implements CommandExecutor, TabCompleter { List list = new ArrayList<>(); if (args.length == 1) { list.addAll(List.of("join", "leave", "list", "stats", "top", "spectate")); - if (sender.hasPermission("fussball.admin")) list.addAll(List.of("create", "delete", "setup", "stop", "setgk", "debug")); + if (sender.hasPermission("fussball.admin")) list.addAll(List.of("create", "delete", "setup", "stop", "setgk", "debug", "hologram")); } else if (args.length == 2 && List.of("join","delete","setup","stop","setgk","debug","spectate").contains(args[0].toLowerCase())) { list.addAll(plugin.getArenaManager().getArenaNames()); + } else if (args.length == 2 && args[0].equalsIgnoreCase("hologram")) { + list.addAll(List.of("set", "remove", "delete", "reload", "list")); + } else if (args.length == 3 && args[0].equalsIgnoreCase("hologram") && args[1].equalsIgnoreCase("set")) { + list.addAll(plugin.getArenaManager().getArenaNames()); // id-Vorschläge (frei wählbar, aber arena-namen passen) + } else if (args.length == 4 && args[0].equalsIgnoreCase("hologram") && args[1].equalsIgnoreCase("set")) { + list.addAll(List.of("goals", "wins")); + } else if (args.length == 3 && args[0].equalsIgnoreCase("hologram") && args[1].equalsIgnoreCase("delete")) { + list.addAll(plugin.getHologramManager().getHologramIds()); } else if (args.length == 3 && args[0].equalsIgnoreCase("setgk")) { // Spielernamen aus dem aktiven Spiel vorschlagen Game gkGame = plugin.getGameManager().getGame(args[1]); @@ -362,7 +444,7 @@ public class FussballCommand implements CommandExecutor, TabCompleter { "redgoalmin","redgoalmax","bluegoalmin","bluegoalmax", "fieldmin","fieldmax", "redpenaltymin","redpenaltymax","bluepenaltymin","bluepenaltymax", - "redpenaltyspot","bluepenaltyspot", + "redpenaltyspot","bluepenaltyspot","spectatorspawn", "minplayers","maxplayers","duration","info")); } else if (args.length == 2 && args[0].equalsIgnoreCase("top")) { list.addAll(List.of("goals", "wins")); diff --git a/src/main/java/de/fussball/plugin/game/Game.java b/src/main/java/de/fussball/plugin/game/Game.java index 6009daf..5d24abf 100644 --- a/src/main/java/de/fussball/plugin/game/Game.java +++ b/src/main/java/de/fussball/plugin/game/Game.java @@ -52,6 +52,10 @@ public class Game { private final Map outOfBoundsCountdown = new HashMap<>(); // ──────────────────────────────────────────────────────────────────────── + // ── AFK-Erkennung ────────────────────────────────────────────────────── + private final Map lastPosition = new HashMap<>(); // letzte gemessene Position + private final Map afkTicks = new HashMap<>(); // Ticks ohne Bewegung + private UUID lastKicker = null; private UUID secondLastKicker = null; // für Assist-Erkennung private boolean lastKickWasHeader = false; // für Rückpass-Regel (Header erlaubt) @@ -161,15 +165,29 @@ public class Game { public boolean addSpectator(Player player) { if (isInGame(player)) { player.sendMessage(MessageUtil.error("Du bist bereits Spieler!")); return false; } if (isSpectator(player)) { player.sendMessage(MessageUtil.error("Du schaust bereits zu!")); return false; } - if (state == GameState.WAITING || state == GameState.STARTING || state == GameState.ENDING) { - player.sendMessage(MessageUtil.error("Kein laufendes Spiel zum Zuschauen!")); return false; + if (state == GameState.ENDING) { + player.sendMessage(MessageUtil.error("Das Spiel ist gerade dabei zu enden!")); return false; } spectators.add(player.getUniqueId()); - player.setGameMode(GameMode.SPECTATOR); - player.teleport(arena.getBallSpawn() != null ? arena.getBallSpawn() : arena.getLobby()); + player.setGameMode(GameMode.ADVENTURE); + + // Gesetzter Zuschauer-Spawn hat Priorität, danach Fallback über getSpectatorSpawn() + Location spectatorLoc = getSpectatorSpawn(); + if (spectatorLoc != null) player.teleport(spectatorLoc); + + // Zuschauer bleiben sichtbar – die Arena-Grenzen verhindern das Betreten des Feldes. + // Inventar leeren und Hunger/Schaden deaktivieren (bereits durch PlayerListener) + player.getInventory().clear(); + player.setHealth(20.0); + player.setFoodLevel(20); + for (PotionEffect e : player.getActivePotionEffects()) player.removePotionEffect(e.getType()); + scoreboard.give(player); if (bossBar != null) bossBar.addPlayer(player); - player.sendMessage(MessageUtil.success("Du schaust jetzt §e" + arena.getName() + " §azu! §7(/fb leave zum Beenden)")); + String stateHint = (state == GameState.WAITING || state == GameState.STARTING) + ? " §7(Spiel startet gleich)" : ""; + player.sendMessage(MessageUtil.success("Du schaust jetzt §e" + arena.getName() + " §azu!" + stateHint + " §7(/fb leave zum Beenden)")); + player.sendMessage(MessageUtil.info("§7Du kannst die Arena nicht betreten. Viel Spaß beim Zuschauen!")); return true; } @@ -177,6 +195,7 @@ public class Game { spectators.remove(player.getUniqueId()); if (bossBar != null) bossBar.removePlayer(player); scoreboard.remove(player); + resetPlayer(player); } @@ -225,11 +244,13 @@ public class Game { player.getInventory().setLeggings(armor[2]); player.getInventory().setBoots(armor[3]); } - /** BUG FIX: Teleportiert zur Lobby statt Welt-Spawn */ + /** Setzt einen Spieler oder Zuschauer zurück (Lobby, ADVENTURE, Inventar leer) */ private void resetPlayer(Player player) { player.getInventory().clear(); for (PotionEffect e : player.getActivePotionEffects()) player.removePotionEffect(e.getType()); - player.setGameMode(GameMode.SURVIVAL); + player.setGameMode(GameMode.ADVENTURE); // ADVENTURE – kein SURVIVAL (kein PvP / kein Hunger) + player.setHealth(20.0); + player.setFoodLevel(20); Location tp = arena.getLobby() != null ? arena.getLobby() : Bukkit.getWorlds().get(0).getSpawnLocation(); player.teleport(tp); } @@ -578,6 +599,9 @@ public class Game { if (gameTask != null) { gameTask.cancel(); gameTask = null; } gameTask = new BukkitRunnable() { public void run() { + // Zuschauer-Grenzen immer prüfen (unabhängig vom Spielzustand) + checkSpectatorBoundaries(); + if (state != GameState.RUNNING && state != GameState.OVERTIME) return; // Kopfball-Abklingzeiten herunterzählen @@ -615,6 +639,7 @@ public class Game { checkPlayerBallInteraction(); checkPlayerBoundaries(); checkHeaderOpportunities(); + checkAfkPlayers(); // Freistoß-Abstandsdurchsetzung if (freekickLocation != null) { @@ -849,7 +874,7 @@ public class Game { for (UUID uuid : allPlayers) { Player p = Bukkit.getPlayer(uuid); if (p == null) continue; - if (ball.getDistanceTo(p) >= 1.5) continue; + if (ball.getDistanceTo(p) >= 2.2) continue; if (throwInTeam != null && getTeam(p) != throwInTeam) continue; // ── Rückpass-Regel für Torwarte ────────────────────────────────── @@ -1336,9 +1361,199 @@ public class Game { } // ════════════════════════════════════════════════════════════════════════ - // KOPFBALL + // AFK-ERKENNUNG // ════════════════════════════════════════════════════════════════════════ + /** + * Erkennt Spieler die zu lange stillstehen und ermahnt / kickt sie. + * + * Konfiguration (config.yml): + * gameplay.afk-warn-seconds: 20 → erste Warnung nach 20s Stillstand + * gameplay.afk-kick-seconds: 40 → Disqualifikation nach 40s Stillstand + * gameplay.afk-move-threshold: 0.5 → Mindestbewegung in Blöcken pro Sekunde + * + * Läuft sekündlich im Game-Loop (nur bei RUNNING / OVERTIME). + */ + private void checkAfkPlayers() { + if (state != GameState.RUNNING && state != GameState.OVERTIME) return; + int warnSecs = plugin.getConfig().getInt("gameplay.afk-warn-seconds", 20); + int kickSecs = plugin.getConfig().getInt("gameplay.afk-kick-seconds", 40); + double threshold = plugin.getConfig().getDouble("gameplay.afk-move-threshold", 0.5); + + for (UUID uuid : new ArrayList<>(allPlayers)) { + Player p = Bukkit.getPlayer(uuid); + if (p == null) continue; + + Location current = p.getLocation(); + Location last = lastPosition.get(uuid); + + // Erste Messung → Position speichern, kein AFK-Zähler + if (last == null) { + lastPosition.put(uuid, current.clone()); + continue; + } + + double dist = current.distanceSquared(last); // Quadrat reicht zum Vergleich + + if (dist >= threshold * threshold) { + // Spieler hat sich bewegt → AFK-Counter zurücksetzen + afkTicks.remove(uuid); + lastPosition.put(uuid, current.clone()); + } else { + // Spieler steht still → Counter erhöhen + int ticks = afkTicks.merge(uuid, 1, Integer::sum); + lastPosition.put(uuid, current.clone()); + + if (ticks == warnSecs) { + // ── Erste Warnung ──────────────────────────────────────── + p.sendTitle("§e§l⚠ AFK?", "§7Bewege dich – du wirst sonst disqualifiziert!", 5, 50, 10); + p.sendMessage(MessageUtil.warn("§7Du stehst seit §e" + warnSecs + "s §7still! Bewege dich oder du wirst rausgeworfen!")); + p.playSound(p.getLocation(), Sound.BLOCK_NOTE_BLOCK_BASS, 1f, 0.5f); + broadcastAll(MessageUtil.warn("§e" + p.getName() + " §7scheint AFK zu sein!")); + + } else if (ticks > warnSecs && ticks < kickSecs && (ticks - warnSecs) % 5 == 0) { + // ── Erinnerungen alle 5s ───────────────────────────────── + int remaining = kickSecs - ticks; + p.sendTitle("§c§l⚠ NOCH " + remaining + "s!", "§7Bewege dich oder du wirst disqualifiziert!", 5, 25, 5); + p.playSound(p.getLocation(), Sound.BLOCK_NOTE_BLOCK_BASS, 1f, 0.3f); + + } else if (ticks >= kickSecs) { + // ── Disqualifikation ───────────────────────────────────── + afkTicks.remove(uuid); + lastPosition.remove(uuid); + broadcastAll(MessageUtil.warn("§e" + p.getName() + " §7wurde wegen AFK disqualifiziert!")); + p.sendTitle("§c§lDISQUALIFIZIERT!", "§7Du warst zu lange AFK!", 10, 80, 20); + disqualifyPlayer(p); + } + } + } + } + + // ════════════════════════════════════════════════════════════════════════ + // ZUSCHAUER-GRENZEN + // ════════════════════════════════════════════════════════════════════════ + + /** + * Zuschauer müssen sich AUSSERHALB des Spielfeldes aufhalten (mind. 2 Blöcke Abstand), + * dürfen aber einen Außenpuffer von 15 Blöcken um das Spielfeld nicht verlassen. + * + * Zu nah am Feld (< 2 Blöcke) → wird nach außen geschoben + * Zu weit vom Feld (> 15 Blöcke) → wird zum Spielfeldrand zurückteleportiert + * Falsche Welt → sofort zurück + */ + private void checkSpectatorBoundaries() { + if (spectators.isEmpty()) return; + if (arena.getFieldMin() == null || arena.getFieldMax() == null) return; + + final double INNER_BUFFER = 2.0; // Mindestabstand zum Spielfeld (außen) + final double OUTER_BUFFER = 15.0; // Maximaler Abstand vom Spielfeld + + // Spielfeld-Grenzen + double fMinX = Math.min(arena.getFieldMin().getX(), arena.getFieldMax().getX()); + double fMaxX = Math.max(arena.getFieldMin().getX(), arena.getFieldMax().getX()); + double fMinZ = Math.min(arena.getFieldMin().getZ(), arena.getFieldMax().getZ()); + double fMaxZ = Math.max(arena.getFieldMin().getZ(), arena.getFieldMax().getZ()); + double fMinY = Math.min(arena.getFieldMin().getY(), arena.getFieldMax().getY()); + double fMaxY = Math.max(arena.getFieldMin().getY(), arena.getFieldMax().getY()); + + // Innere Grenze (Spielfeld + INNER_BUFFER) – Zuschauer NICHT erlaubt + double innerMinX = fMinX - INNER_BUFFER; double innerMaxX = fMaxX + INNER_BUFFER; + double innerMinZ = fMinZ - INNER_BUFFER; double innerMaxZ = fMaxZ + INNER_BUFFER; + + // Äußere Grenze (Spielfeld + OUTER_BUFFER) – Zuschauer MÜSSEN innerhalb sein + double outerMinX = fMinX - OUTER_BUFFER; double outerMaxX = fMaxX + OUTER_BUFFER; + double outerMinZ = fMinZ - OUTER_BUFFER; double outerMaxZ = fMaxZ + OUTER_BUFFER; + double outerMinY = fMinY - 5; double outerMaxY = fMaxY + 30; + + // Rückteleport-Ziel: Spielfeldrand (+ INNER_BUFFER + 1, damit klar außerhalb) + // Wir teleportieren zur Mitte einer Seitenlinie + double centerX = (fMinX + fMaxX) / 2.0; + double centerZ = (fMinZ + fMaxZ) / 2.0; + double baseY = fMinY; + // Rand-Spawn: an der langen Seite des Feldes, außen + Location spectatorEdge = new Location( + arena.getFieldMin().getWorld(), + centerX, + baseY, + fMaxZ + INNER_BUFFER + 1, + 0f, 0f + ); + // Fallback: Ball-Spawn / Lobby + Location fallback = arena.getBallSpawn() != null ? arena.getBallSpawn() : arena.getLobby(); + + for (UUID uuid : new ArrayList<>(spectators)) { + Player p = Bukkit.getPlayer(uuid); + if (p == null) continue; + + // ── Falsche Welt ────────────────────────────────────────────────── + if (!p.getWorld().equals(arena.getFieldMin().getWorld())) { + Location tp = fallback != null ? fallback : spectatorEdge; + p.teleport(tp); + p.sendTitle("§c⚠ FALSCHE WELT!", "§7Zurück zur Arena teleportiert!", 5, 40, 10); + continue; + } + + Location loc = p.getLocation(); + double px = loc.getX(), pz = loc.getZ(), py = loc.getY(); + + boolean insideInner = px > innerMinX && px < innerMaxX + && pz > innerMinZ && pz < innerMaxZ; + boolean outsideOuter = px < outerMinX || px > outerMaxX + || pz < outerMinZ || pz > outerMaxZ + || py < outerMinY || py > outerMaxY; + + if (insideInner) { + // ── Zu nah / auf dem Spielfeld → raus schieben ─────────────── + // Nächsten Punkt auf dem inneren Rand berechnen und leicht weiter raus + double pushX = Math.max(innerMinX, Math.min(px, innerMaxX)); + double pushZ = Math.max(innerMinZ, Math.min(pz, innerMaxZ)); + // Richtung nach außen bestimmen + double dxMin = Math.abs(px - innerMinX), dxMax = Math.abs(px - innerMaxX); + double dzMin = Math.abs(pz - innerMinZ), dzMax = Math.abs(pz - innerMaxZ); + double minDist = Math.min(Math.min(dxMin, dxMax), Math.min(dzMin, dzMax)); + Location push; + if (minDist == dxMin) push = new Location(loc.getWorld(), innerMinX - 1, loc.getY(), loc.getZ(), loc.getYaw(), loc.getPitch()); + else if (minDist == dxMax) push = new Location(loc.getWorld(), innerMaxX + 1, loc.getY(), loc.getZ(), loc.getYaw(), loc.getPitch()); + else if (minDist == dzMin) push = new Location(loc.getWorld(), loc.getX(), loc.getY(), innerMinZ - 1, loc.getYaw(), loc.getPitch()); + else push = new Location(loc.getWorld(), loc.getX(), loc.getY(), innerMaxZ + 1, loc.getYaw(), loc.getPitch()); + p.teleport(push); + p.sendTitle("§c⚠ SPIELFELD!", "§7Zuschauer müssen außerhalb des Feldes bleiben!", 5, 30, 5); + + } else if (outsideOuter) { + // ── Zu weit draußen → zum Spielfeldrand zurück ─────────────── + p.teleport(spectatorEdge); + p.sendTitle("§c⚠ ARENAGRENZE!", "§7Zuschauer dürfen die Arena nicht verlassen!", 5, 40, 10); + p.sendMessage(MessageUtil.warn("§7Als Zuschauer darfst du die Arena nicht verlassen!")); + } + } + } + + /** Für PlayerListener: prüft ob Zuschauer außerhalb des erlaubten Bereichs ist */ + public boolean isSpectatorOutOfBounds(Player player) { + if (!isSpectator(player)) return false; + if (arena.getFieldMin() == null || arena.getFieldMax() == null) return false; + final double OUTER_BUFFER = 15.0; + Location loc = player.getLocation(); + double minX = Math.min(arena.getFieldMin().getX(), arena.getFieldMax().getX()) - OUTER_BUFFER; + double maxX = Math.max(arena.getFieldMin().getX(), arena.getFieldMax().getX()) + OUTER_BUFFER; + double minZ = Math.min(arena.getFieldMin().getZ(), arena.getFieldMax().getZ()) - OUTER_BUFFER; + double maxZ = Math.max(arena.getFieldMin().getZ(), arena.getFieldMax().getZ()) + OUTER_BUFFER; + return loc.getX() < minX || loc.getX() > maxX || loc.getZ() < minZ || loc.getZ() > maxZ; + } + + public Location getSpectatorSpawn() { + // Manuell gesetzter Zuschauer-Spawn hat Priorität + if (arena.getSpectatorSpawn() != null) return arena.getSpectatorSpawn(); + // Fallback: Spielfeldrand (Mitte der langen Seite, 3 Blöcke außen) + if (arena.getFieldMin() != null && arena.getFieldMax() != null) { + double fMaxZ = Math.max(arena.getFieldMin().getZ(), arena.getFieldMax().getZ()); + double centerX = (arena.getFieldMin().getX() + arena.getFieldMax().getX()) / 2.0; + double baseY = Math.min(arena.getFieldMin().getY(), arena.getFieldMax().getY()); + return new Location(arena.getFieldMin().getWorld(), centerX, baseY, fMaxZ + 3, 0f, 0f); + } + return arena.getBallSpawn() != null ? arena.getBallSpawn() : arena.getLobby(); + } + /** * Prüft jeden Sekunden-Tick ob ein Spieler den Ball köpfen kann. * Bedingungen: Spieler ist in der Luft, Ball befindet sich auf Kopfhöhe, diff --git a/src/main/java/de/fussball/plugin/hologram/FussballHologram.java b/src/main/java/de/fussball/plugin/hologram/FussballHologram.java new file mode 100644 index 0000000..cbee51e --- /dev/null +++ b/src/main/java/de/fussball/plugin/hologram/FussballHologram.java @@ -0,0 +1,220 @@ +package de.fussball.plugin.hologram; + +import de.fussball.plugin.Fussball; +import de.fussball.plugin.stats.StatsManager; +import org.bukkit.Bukkit; +import org.bukkit.Color; +import org.bukkit.Location; +import org.bukkit.entity.Display; +import org.bukkit.entity.Interaction; +import org.bukkit.entity.Player; +import org.bukkit.entity.TextDisplay; + +import java.util.Map; +import java.util.UUID; +import java.util.concurrent.ConcurrentHashMap; + +/** + * Ein einzelnes Fußball-Statistik-Hologramm. + * + * Basiert auf NexusHologram (NexusLobby): + * • Pro Spieler eine eigene TextDisplay-Entity → nur er sieht sie + * • Interaction-Entity als Hitbox → Rechtsklick wechselt zwischen Seiten + * • Seiten: GOALS (Top-10 Torschützen) und WINS (Top-10 Gewinner) + * • Distanz-Check: > 48 Blöcke → Entity entfernen (Bandbreite sparen) + */ +public class FussballHologram { + + /** Render-Radius: 48 Blöcke (2304 = 48²) */ + private static final double RENDER_RADIUS_SQ = 2304.0; + + public enum HoloType { GOALS, WINS } + + private final String id; + private final Location location; + private HoloType type; // mutable – nicht final, damit Admin den Typ ändern kann + + // UUID des Spielers → seine persönliche Entity + private final Map playerEntities = new ConcurrentHashMap<>(); + private final Map playerInteractions = new ConcurrentHashMap<>(); + /** Aktuell angezeigte Seite (0 = GOALS, 1 = WINS) pro Spieler */ + private final Map currentPage = new ConcurrentHashMap<>(); + + private final Fussball plugin; + + public FussballHologram(String id, Location location, HoloType type, Fussball plugin) { + this.id = id; + this.location = location.clone(); + this.type = type; + this.plugin = plugin; + } + + // ── Seiten-Wechsel ─────────────────────────────────────────────────────── + + /** Rechtsklick → zur nächsten Seite (GOALS ↔ WINS) */ + public void nextPage(Player player) { + // Wir haben 2 Seiten: 0 = GOALS, 1 = WINS + int next = (currentPage.getOrDefault(player.getUniqueId(), 0) + 1) % 2; + currentPage.put(player.getUniqueId(), next); + renderForPlayer(player); + } + + // ── Render ─────────────────────────────────────────────────────────────── + + /** + * Rendert das Hologramm für einen Spieler – oder entfernt es wenn er zu weit weg ist. + * Wird alle 5 Ticks vom HologramManager aufgerufen. + */ + public void renderForPlayer(Player player) { + // Falsche Welt oder zu weit → entfernen + if (!player.getWorld().equals(location.getWorld()) + || player.getLocation().distanceSquared(location) > RENDER_RADIUS_SQ) { + removeForPlayer(player); + return; + } + + // Seite bestimmen + int pageIdx = currentPage.getOrDefault(player.getUniqueId(), type == HoloType.WINS ? 1 : 0); + HoloType displayType = pageIdx == 1 ? HoloType.WINS : HoloType.GOALS; + + String text = buildText(displayType); + + TextDisplay display = playerEntities.get(player.getUniqueId()); + + if (display == null || !display.isValid()) { + // ── Neue TextDisplay-Entity spawnen ───────────────────────────── + display = location.getWorld().spawn(location, TextDisplay.class, entity -> { + entity.setCustomName("fb_holo_" + id + "_" + player.getName()); + entity.setCustomNameVisible(false); + entity.setPersistent(false); + entity.setBillboard(Display.Billboard.CENTER); + entity.setBackgroundColor(Color.fromARGB(0, 0, 0, 0)); // komplett transparent + entity.setDefaultBackground(false); // Standard-Grau-Panel entfernen + entity.setText(text); + entity.setInvulnerable(true); + entity.setSeeThrough(false); + }); + + // ── Interaction-Entity spawnen (Hitbox für Rechtsklick) ────────── + Interaction interact = location.getWorld().spawn(location, Interaction.class, entity -> { + entity.setInteractionWidth(2.5f); + entity.setInteractionHeight(2.5f); + entity.setCustomNameVisible(false); + entity.setPersistent(false); + }); + + // ── Nur für diesen Spieler sichtbar machen ─────────────────────── + TextDisplay finalDisplay = display; + Interaction finalInteract = interact; + for (Player other : Bukkit.getOnlinePlayers()) { + if (!other.getUniqueId().equals(player.getUniqueId())) { + other.hideEntity(plugin, finalDisplay); + other.hideEntity(plugin, finalInteract); + } + } + + playerEntities.put(player.getUniqueId(), display); + playerInteractions.put(player.getUniqueId(), interact); + + } else { + // ── Bestehende Entity nur aktualisieren (kein Re-Spawn) ────────── + if (!display.getText().equals(text)) { + display.setText(text); + } + } + } + + // ── Entfernen ──────────────────────────────────────────────────────────── + + /** Entfernt die Entities eines einzelnen Spielers (Disconnect / Worldwechsel / zu weit) */ + public void removeForPlayer(Player player) { + TextDisplay display = playerEntities.remove(player.getUniqueId()); + if (display != null) display.remove(); + + Interaction interact = playerInteractions.remove(player.getUniqueId()); + if (interact != null) interact.remove(); + + currentPage.remove(player.getUniqueId()); + } + + /** Entfernt ALLE Entities (Plugin-Disable / Hologramm gelöscht) */ + public void removeAll() { + playerEntities.values().forEach(TextDisplay::remove); + playerInteractions.values().forEach(Interaction::remove); + playerEntities.clear(); + playerInteractions.clear(); + currentPage.clear(); + } + + // ── Hilfsmethoden ──────────────────────────────────────────────────────── + + /** + * Prüft, ob die gegebene Entity-UUID eine Interaction-Entity dieses Hologramms ist. + * (Wird in HologramManager genutzt um Klick-Events zuzuordnen) + */ + public boolean isInteractionEntity(UUID entityId) { + return playerInteractions.values().stream() + .anyMatch(i -> i.getUniqueId().equals(entityId)); + } + + /** Baut den anzuzeigenden Text aus den aktuellen Top-10-Statistiken */ + private String buildText(HoloType showType) { + StringBuilder sb = new StringBuilder(); + + if (showType == HoloType.GOALS) { + sb.append("§6§l⚽ TOP 10 TORSCHÜTZEN ⚽\n"); + sb.append("§8§m══════════════════════§r\n"); + var list = plugin.getStatsManager().getTopScorers(10); + if (list.isEmpty()) { + sb.append("§8Noch keine Statistiken vorhanden."); + } else { + for (int i = 0; i < list.size(); i++) { + StatsManager.PlayerStats s = list.get(i).getValue(); + sb.append(medal(i + 1)) + .append(" §0").append(s.name) + .append(" §4").append(s.goals).append(" §8Tore"); + if (i < list.size() - 1) sb.append("\n"); + } + } + sb.append("\n§8§m══════════════════════§r"); + sb.append("\n§8§o[Rechtsklick → Siege anzeigen]"); + + } else { + sb.append("§2§l🏆 TOP 10 GEWINNER 🏆\n"); + sb.append("§8§m══════════════════════§r\n"); + var list = plugin.getStatsManager().getTopWins(10); + if (list.isEmpty()) { + sb.append("§8Noch keine Statistiken vorhanden."); + } else { + for (int i = 0; i < list.size(); i++) { + StatsManager.PlayerStats s = list.get(i).getValue(); + sb.append(medal(i + 1)) + .append(" §0").append(s.name) + .append(" §2").append(s.wins).append(" §8Siege") + .append(" §8(").append(String.format("%.0f", s.getWinRate())).append("%)"); + if (i < list.size() - 1) sb.append("\n"); + } + } + sb.append("\n§8§m══════════════════════§r"); + sb.append("\n§8§o[Rechtsklick → Tore anzeigen]"); + } + + return sb.toString(); + } + + private String medal(int rank) { + return switch (rank) { + case 1 -> "§6§l#1"; // Gold bleibt – hebt sich gut ab + case 2 -> "§8§l#2"; // Dunkelgrau + case 3 -> "§4§l#3"; // Dunkelrot + default -> "§8#" + rank; + }; + } + + // ── Getter ─────────────────────────────────────────────────────────────── + + public String getId() { return id; } + public Location getLocation() { return location.clone(); } + public HoloType getType() { return type; } + public void setType(HoloType t) { this.type = t; } +} \ No newline at end of file diff --git a/src/main/java/de/fussball/plugin/hologram/HologramManager.java b/src/main/java/de/fussball/plugin/hologram/HologramManager.java new file mode 100644 index 0000000..1237abb --- /dev/null +++ b/src/main/java/de/fussball/plugin/hologram/HologramManager.java @@ -0,0 +1,293 @@ +package de.fussball.plugin.hologram; + +import de.fussball.plugin.Fussball; +import org.bukkit.Bukkit; +import org.bukkit.Location; +import org.bukkit.World; +import org.bukkit.configuration.file.FileConfiguration; +import org.bukkit.configuration.file.YamlConfiguration; +import org.bukkit.entity.Entity; +import org.bukkit.entity.Interaction; +import org.bukkit.entity.Player; +import org.bukkit.entity.TextDisplay; +import org.bukkit.event.EventHandler; +import org.bukkit.event.HandlerList; +import org.bukkit.event.Listener; +import org.bukkit.event.player.PlayerChangedWorldEvent; +import org.bukkit.event.player.PlayerInteractEntityEvent; +import org.bukkit.event.player.PlayerJoinEvent; +import org.bukkit.event.player.PlayerQuitEvent; +import org.bukkit.scheduler.BukkitTask; + +import java.io.File; +import java.io.IOException; +import java.util.Map; +import java.util.Set; +import java.util.concurrent.ConcurrentHashMap; + +/** + * Verwaltet alle Fußball-Statistik-Hologramme. + * + * Adaption von HologramModule (NexusLobby): + * • Pro Spieler eine eigene TextDisplay-Entity (nur er sieht sie) + * • Interaction-Entity als Klick-Hitbox → Seiten wechseln (Tore ↔ Siege) + * • Render-Task alle 5 Ticks → Distanzprüfung + Text-Update + * • Cleanup bei Join / Quit / Weltenwechsel + * • Persistierung in holograms.yml + * + * Befehle (FussballCommand): + * /fb hologram set goals|wins – Hologramm an Spielerposition erstellen + * /fb hologram remove – Nächstes Hologramm (< 5 Blöcke) entfernen + * /fb hologram reload – Alle Hologramme neu laden (nach Restart) + * /fb hologram list – Alle Hologramme auflisten + */ +public class HologramManager implements Listener { + + private final Fussball plugin; + private final Map holograms = new ConcurrentHashMap<>(); + + private File holoFile; + private FileConfiguration holoConfig; + private BukkitTask renderTask; + + public HologramManager(Fussball plugin) { + this.plugin = plugin; + loadConfig(); + loadHolograms(); + startRenderTask(); + cleanupStrayEntities(); + Bukkit.getPluginManager().registerEvents(this, plugin); + } + + // ── Konfiguration ──────────────────────────────────────────────────────── + + private void loadConfig() { + holoFile = new File(plugin.getDataFolder(), "holograms.yml"); + if (!holoFile.exists()) { + try { holoFile.createNewFile(); } catch (IOException e) { e.printStackTrace(); } + } + holoConfig = YamlConfiguration.loadConfiguration(holoFile); + } + + private void saveConfig() { + try { + holoConfig.save(holoFile); + } catch (IOException e) { + plugin.getLogger().severe("[Hologram] Konnte holograms.yml nicht speichern: " + e.getMessage()); + } + } + + // ── Laden / Erstellen / Entfernen ──────────────────────────────────────── + + private void loadHolograms() { + // Vorherige Instanzen sauber entfernen + holograms.values().forEach(FussballHologram::removeAll); + holograms.clear(); + + if (!holoConfig.contains("holograms")) return; + + for (String id : holoConfig.getConfigurationSection("holograms").getKeys(false)) { + String path = "holograms." + id; + String worldName = holoConfig.getString(path + ".world"); + if (worldName == null) continue; + + World world = Bukkit.getWorld(worldName); + if (world == null) { + plugin.getLogger().warning("[Hologram] Welt '" + worldName + + "' nicht gefunden – Hologramm '" + id + "' übersprungen."); + continue; + } + + Location loc = new Location( + world, + holoConfig.getDouble(path + ".x"), + holoConfig.getDouble(path + ".y"), + holoConfig.getDouble(path + ".z") + ); + + String typeStr = holoConfig.getString(path + ".type", "GOALS"); + FussballHologram.HoloType type = + "WINS".equalsIgnoreCase(typeStr) + ? FussballHologram.HoloType.WINS + : FussballHologram.HoloType.GOALS; + + holograms.put(id, new FussballHologram(id, loc, type, plugin)); + } + + plugin.getLogger().info("[Hologram] " + holograms.size() + " Hologramme geladen."); + } + + /** + * Erstellt ein neues Hologramm und speichert es in holograms.yml. + * Falls die ID bereits existiert, wird das alte sauber entfernt. + */ + public boolean createHologram(String id, Location loc, FussballHologram.HoloType type) { + if (holograms.containsKey(id)) removeHologram(id); + + String path = "holograms." + id; + holoConfig.set(path + ".world", loc.getWorld().getName()); + holoConfig.set(path + ".x", loc.getX()); + holoConfig.set(path + ".y", loc.getY()); + holoConfig.set(path + ".z", loc.getZ()); + holoConfig.set(path + ".type", type.name()); + saveConfig(); + + FussballHologram holo = new FussballHologram(id, loc, type, plugin); + holograms.put(id, holo); + + // Sofort für alle Online-Spieler rendern + for (Player player : Bukkit.getOnlinePlayers()) holo.renderForPlayer(player); + return true; + } + + /** + * Entfernt ein Hologramm anhand seiner ID. + * @return true wenn gefunden und entfernt + */ + public boolean removeHologram(String id) { + FussballHologram holo = holograms.remove(id); + if (holo == null) return false; + + // Erst für alle Online-Spieler visuell entfernen + for (Player player : Bukkit.getOnlinePlayers()) holo.removeForPlayer(player); + // Dann alle Entities serverseitig löschen + holo.removeAll(); + + holoConfig.set("holograms." + id, null); + saveConfig(); + return true; + } + + /** + * Entfernt das nächste Hologramm innerhalb von 5 Blöcken zur gegebenen Location. + * @return ID des entfernten Hologramms oder null wenn keines gefunden + */ + public String removeNearest(Location loc) { + String nearest = null; + double nearestDist = 5.0; + + for (Map.Entry entry : holograms.entrySet()) { + Location holoLoc = entry.getValue().getLocation(); + if (!holoLoc.getWorld().equals(loc.getWorld())) continue; + double dist = holoLoc.distance(loc); + if (dist < nearestDist) { + nearestDist = dist; + nearest = entry.getKey(); + } + } + + if (nearest == null) return null; + removeHologram(nearest); + return nearest; + } + + /** Alle Hologramme neu laden (z.B. nach /fb hologram reload) */ + public void reload() { + loadConfig(); + loadHolograms(); + // Für alle Online-Spieler sofort rendern + for (Player player : Bukkit.getOnlinePlayers()) { + holograms.values().forEach(h -> h.renderForPlayer(player)); + } + } + + // ── Render-Task ────────────────────────────────────────────────────────── + + /** + * Alle 5 Ticks: Distanzprüfung + Text-Update. + * Text wird in FussballHologram nur neu gesetzt wenn er sich geändert hat (==Check). + */ + private void startRenderTask() { + if (renderTask != null) renderTask.cancel(); + renderTask = Bukkit.getScheduler().runTaskTimer(plugin, () -> { + for (Player player : Bukkit.getOnlinePlayers()) { + holograms.values().forEach(h -> h.renderForPlayer(player)); + } + }, 20L, 5L); + } + + // ── Events ─────────────────────────────────────────────────────────────── + + /** Rechtsklick auf Interaction-Entity → Seite wechseln (Tore ↔ Siege) */ + @EventHandler + public void onInteract(PlayerInteractEntityEvent event) { + if (!(event.getRightClicked() instanceof Interaction)) return; + + for (FussballHologram holo : holograms.values()) { + if (holo.isInteractionEntity(event.getRightClicked().getUniqueId())) { + holo.nextPage(event.getPlayer()); + break; + } + } + } + + /** Spieler verlässt den Server → Entities entfernen */ + @EventHandler + public void onQuit(PlayerQuitEvent event) { + holograms.values().forEach(h -> h.removeForPlayer(event.getPlayer())); + } + + /** Weltenwechsel → Entities aus der alten Welt entfernen */ + @EventHandler + public void onWorldChange(PlayerChangedWorldEvent event) { + holograms.values().forEach(h -> h.removeForPlayer(event.getPlayer())); + } + + /** + * Spieler joint → verbleibende verwaiste fb_holo_*-Entities verstecken, + * dann Hologramme für ihn rendern. + */ + @EventHandler + public void onJoin(PlayerJoinEvent event) { + Bukkit.getScheduler().runTaskLater(plugin, () -> { + Player p = event.getPlayer(); + // Crash-Überreste aus der Welt verstecken + for (Entity entity : p.getWorld().getEntities()) { + String name = entity.getCustomName(); + if (name == null || !name.startsWith("fb_holo_")) continue; + if (!name.endsWith("_" + p.getName())) { + p.hideEntity(plugin, entity); + } + } + // Sofort für ihn rendern + holograms.values().forEach(h -> h.renderForPlayer(p)); + }, 10L); + } + + // ── Shutdown ───────────────────────────────────────────────────────────── + + /** Sauber herunterfahren (Plugin-Disable) */ + public void removeAll() { + if (renderTask != null) { renderTask.cancel(); renderTask = null; } + HandlerList.unregisterAll(this); + for (Player player : Bukkit.getOnlinePlayers()) { + holograms.values().forEach(h -> h.removeForPlayer(player)); + } + holograms.values().forEach(FussballHologram::removeAll); + holograms.clear(); + } + + // ── Hilfsmethoden ──────────────────────────────────────────────────────── + + /** + * Entfernt beim Serverstart verbleibende fb_holo_*-Entities aus allen Welten. + * (Überreste nach einem Crash – non-persistent Entities sollten eigentlich weg sein, + * aber besser einmal zu viel prüfen) + */ + private void cleanupStrayEntities() { + for (World world : Bukkit.getWorlds()) { + for (Entity entity : world.getEntities()) { + String name = entity.getCustomName(); + if (name != null && name.startsWith("fb_holo_")) { + entity.remove(); + } + } + } + } + + /** @return Anzahl der registrierten Hologramme */ + public int getCount() { return holograms.size(); } + + /** @return Set aller Hologramm-IDs (für Tab-Completion) */ + public Set getHologramIds() { return holograms.keySet(); } +} \ No newline at end of file diff --git a/src/main/java/de/fussball/plugin/listeners/PlayerListener.java b/src/main/java/de/fussball/plugin/listeners/PlayerListener.java index f69d81b..d8751ce 100644 --- a/src/main/java/de/fussball/plugin/listeners/PlayerListener.java +++ b/src/main/java/de/fussball/plugin/listeners/PlayerListener.java @@ -23,10 +23,8 @@ public class PlayerListener implements Listener { Player player = event.getPlayer(); Game game = plugin.getGameManager().getPlayerGame(player); if (game != null) game.removePlayer(player); - // Auch als Zuschauer entfernen Game spectatorGame = plugin.getGameManager().getSpectatorGame(player); if (spectatorGame != null) spectatorGame.removeSpectator(player); - // Aus Warteschlangen entfernen plugin.getGameManager().removeFromAllQueues(player); } @@ -66,24 +64,81 @@ public class PlayerListener implements Listener { } /** - * Team-Chat: Nachrichten von Spielern im Spiel werden NUR ans eigene Team gesendet. - * Mit "!" am Anfang können Admins global ins Spiel broadcasten. - * Zuschauer sehen alle Team-Chats (mit Label). + * Zuschauer dürfen die Arena nicht verlassen (Weltenwechsel blockieren). + * GM3 erlaubt Portale – das verhindert, dass ein Zuschauer durch ein Portal + * aus der Welt heraus teleportiert wird. */ - @EventHandler(priority = EventPriority.HIGHEST, ignoreCancelled = true) + @EventHandler(priority = EventPriority.HIGH) + public void onPortal(PlayerPortalEvent event) { + Player player = event.getPlayer(); + if (plugin.getGameManager().getSpectatorGame(player) != null) { + event.setCancelled(true); + player.sendMessage("\u00a7cZuschauer d\u00fcrfen keine Portale benutzen!"); + } + } + + /** + * Zuschauer-Grenzen bei Bewegung prüfen. + * Nur bei echtem Block-Wechsel (reduziert Event-Last drastisch). + * Der Game-Loop prüft ebenfalls jede Sekunde – dieser Handler greift + * als sofortiger Schutz bei schnellem Fliegen. + */ + @EventHandler(priority = EventPriority.HIGH, ignoreCancelled = true) + public void onMove(PlayerMoveEvent event) { + // Nur bei echtem Block-Wechsel auswerten (performance) + if (event.getFrom().getBlockX() == event.getTo().getBlockX() + && event.getFrom().getBlockY() == event.getTo().getBlockY() + && event.getFrom().getBlockZ() == event.getTo().getBlockZ()) return; + + Player player = event.getPlayer(); + Game game = plugin.getGameManager().getSpectatorGame(player); + if (game == null) return; + + // Weltenwechsel durch Bewegung abfangen + if (!event.getFrom().getWorld().equals(event.getTo().getWorld())) { + event.setCancelled(true); + return; + } + + // Grenzen prüfen – Game.java berechnet die erlaubte Zone + if (game.isSpectatorOutOfBounds(player)) { + event.setCancelled(true); + // Zum Zuschauer-Spawn zurückteleportieren + player.teleport(game.getSpectatorSpawn()); + player.sendTitle("§c\u26a0 ARENAGRENZE!", "§7Zuschauer d\u00fcrfen die Arena nicht verlassen!", 5, 40, 10); + } + } + + /** + * Team-Chat: Nachrichten von Spielern im Spiel werden NUR ans eigene Team gesendet. + * + * LOWEST-Priorität: Event wird sofort gecancelt, BEVOR Chat-Formatter-Plugins + * (EssentialsChat, CMI, ...) die Nachricht mit ihrem Prefix versenden können. + * Das verhindert Doppelnachrichten wie "[Rot] msg" + "[Owner] msg". + * + * Da AsyncPlayerChatEvent asynchron läuft, wird die eigentliche Team-Nachricht + * per runTask auf den Haupt-Thread verlagert. + */ + @EventHandler(priority = EventPriority.LOWEST) public void onChat(AsyncPlayerChatEvent event) { Player player = event.getPlayer(); Game game = plugin.getGameManager().getPlayerGame(player); if (game == null) return; + // Sofort canceln – noch bevor andere Plugins das Event verarbeiten event.setCancelled(true); + event.getRecipients().clear(); + String message = event.getMessage(); - // Admin-Global-Broadcast - if (message.startsWith("!") && player.hasPermission("fussball.admin")) { - game.broadcastAll("§6[Global] §f" + player.getName() + "§7: " + message.substring(1).trim()); - return; - } - game.sendTeamMessage(player, message); + // Nachricht auf dem Haupt-Thread versenden (Bukkit-API ist nicht thread-safe) + org.bukkit.Bukkit.getScheduler().runTask(plugin, () -> { + // Admin-Global-Broadcast + if (message.startsWith("!") && player.hasPermission("fussball.admin")) { + game.broadcastAll("§6[Global] §f" + player.getName() + "§7: " + message.substring(1).trim()); + return; + } + game.sendTeamMessage(player, message); + }); } } \ No newline at end of file diff --git a/src/main/java/de/fussball/plugin/listeners/SignListener.java b/src/main/java/de/fussball/plugin/listeners/SignListener.java index c5547a5..3233f7a 100644 --- a/src/main/java/de/fussball/plugin/listeners/SignListener.java +++ b/src/main/java/de/fussball/plugin/listeners/SignListener.java @@ -23,24 +23,33 @@ import java.io.IOException; import java.util.*; /** - * Fußball-Join-Schilder + * Fußball-Schilder (Join + Zuschauer) * - * Format beim Beschriften (braucht fussball.admin): + * JOIN-Schild (braucht fussball.admin): * Zeile 1: [Fussball] * Zeile 2: * - * Schilder werden in signs.yml gespeichert und überleben Server-Neustarts. - * Aktualisierung erfolgt automatisch bei Spieler-Join/Leave und Spielstart/-ende. + * ZUSCHAUER-Schild (braucht fussball.admin): + * Zeile 1: [FussballSpec] + * Zeile 2: + * + * Beide Schild-Typen werden in signs.yml gespeichert. + * Der Typ wird als Prefix im Key gespeichert: "join:" oder "spec:". */ public class SignListener implements Listener { - private static final String TAG = "[Fussball]"; - private static final String TAG_FORMATTED = "§8[§e⚽§8]"; + // Join-Schild Tags + private static final String TAG_JOIN = "[Fussball]"; + private static final String TAG_JOIN_FMT = "§8[§e⚽§8]"; + + // Zuschauer-Schild Tags + private static final String TAG_SPEC = "[FussballSpec]"; + private static final String TAG_SPEC_FMT = "§8[§b👁§8]"; private final Fussball plugin; - // Location → ArenaName - private final Map signs = new HashMap<>(); // key = "world;x;y;z" + // locKey → "join:" oder "spec:" + private final Map signs = new HashMap<>(); private final File signFile; private FileConfiguration signConfig; @@ -61,8 +70,12 @@ public class SignListener implements Listener { signs.clear(); if (signConfig.contains("signs")) { for (String key : signConfig.getConfigurationSection("signs").getKeys(false)) { - String arenaName = signConfig.getString("signs." + key); - signs.put(key, arenaName); + String value = signConfig.getString("signs." + key); + // Legacy-Migration: alte Einträge ohne Prefix → "join:" + if (value != null && !value.startsWith("join:") && !value.startsWith("spec:")) { + value = "join:" + value; + } + signs.put(key, value); } } plugin.getLogger().info("[Fussball] " + signs.size() + " Schilder geladen."); @@ -82,13 +95,16 @@ public class SignListener implements Listener { // ── Events ─────────────────────────────────────────────────────────────── - /** Schild beschriften → Fußball-Schild erstellen */ @EventHandler public void onSignChange(SignChangeEvent event) { String line0 = event.getLine(0); - if (line0 == null || !line0.equalsIgnoreCase(TAG)) return; - + if (line0 == null) return; Player player = event.getPlayer(); + + boolean isJoin = line0.equalsIgnoreCase(TAG_JOIN); + boolean isSpec = line0.equalsIgnoreCase(TAG_SPEC); + if (!isJoin && !isSpec) return; + if (!player.hasPermission("fussball.admin")) { player.sendMessage(MessageUtil.error("Keine Berechtigung für Fußball-Schilder!")); event.setCancelled(true); @@ -109,31 +125,40 @@ public class SignListener implements Listener { return; } - event.setLine(0, TAG_FORMATTED); - event.setLine(1, "§e" + arena.getName()); - event.setLine(2, buildStatusLine(arena)); - event.setLine(3, "§7Klick zum Joinen"); - String key = locKey(event.getBlock().getLocation()); - signs.put(key, arena.getName()); - saveSigns(); - player.sendMessage(MessageUtil.success("Fußball-Schild für §e" + arena.getName() + " §aerstellt!")); + if (isJoin) { + event.setLine(0, TAG_JOIN_FMT); + event.setLine(1, "§e" + arena.getName()); + event.setLine(2, buildStatusLine(arena)); + event.setLine(3, "§7Joinen"); + signs.put(key, "join:" + arena.getName()); + player.sendMessage(MessageUtil.success("Join-Schild für §e" + arena.getName() + " §aerstellt!")); + } else { + event.setLine(0, TAG_SPEC_FMT); + event.setLine(1, "§b" + arena.getName()); + event.setLine(2, buildStatusLine(arena)); + event.setLine(3, "§7Zuschauen"); + signs.put(key, "spec:" + arena.getName()); + player.sendMessage(MessageUtil.success("Zuschauer-Schild für §e" + arena.getName() + " §aerstellt!")); + } + saveSigns(); } - /** Rechtsklick → Spieler joinen */ @EventHandler public void onInteract(PlayerInteractEvent event) { if (event.getAction() != Action.RIGHT_CLICK_BLOCK) return; Block block = event.getClickedBlock(); - if (block == null || !(block.getState() instanceof Sign sign)) return; + if (block == null || !(block.getState() instanceof Sign)) return; String key = locKey(block.getLocation()); if (!signs.containsKey(key)) return; event.setCancelled(true); Player player = event.getPlayer(); - String arenaName = signs.get(key); + String value = signs.get(key); + boolean isSpec = value.startsWith("spec:"); + String arenaName = value.substring(value.indexOf(':') + 1); Arena arena = plugin.getArenaManager().getArena(arenaName); if (arena == null) { @@ -142,20 +167,34 @@ public class SignListener implements Listener { saveSigns(); return; } - if (!arena.isSetupComplete()) { - player.sendMessage(MessageUtil.error("Diese Arena ist noch nicht fertig eingerichtet!")); - return; - } - if (plugin.getGameManager().isInGame(player)) { - player.sendMessage(MessageUtil.error("Du bist bereits in einem Spiel!")); - return; - } - plugin.getGameManager().createGame(arena).addPlayer(player); - refreshSignsForArena(arenaName); + if (isSpec) { + // ── ZUSCHAUER-SCHILD ──────────────────────────────────────────── + if (plugin.getGameManager().isInAnyGame(player)) { + player.sendMessage(MessageUtil.error("Verlasse zuerst dein aktuelles Spiel!")); + return; + } + Game game = plugin.getGameManager().getGame(arenaName); + if (game == null) { + player.sendMessage(MessageUtil.error("Kein laufendes Spiel in §e" + arenaName + "§c!")); + return; + } + game.addSpectator(player); + } else { + // ── JOIN-SCHILD ───────────────────────────────────────────────── + if (!arena.isSetupComplete()) { + player.sendMessage(MessageUtil.error("Diese Arena ist noch nicht fertig eingerichtet!")); + return; + } + if (plugin.getGameManager().isInGame(player)) { + player.sendMessage(MessageUtil.error("Du bist bereits in einem Spiel!")); + return; + } + plugin.getGameManager().createGame(arena).addPlayer(player); + refreshSignsForArena(arenaName); + } } - /** Schild abbauen → aus Liste entfernen */ @EventHandler public void onBreak(BlockBreakEvent event) { String key = locKey(event.getBlock().getLocation()); @@ -168,10 +207,13 @@ public class SignListener implements Listener { // ── Öffentliche Methode: von Game.java aufrufen ────────────────────────── - /** Aktualisiert alle Schilder einer Arena. Wird von Game.java aufgerufen. */ public void refreshSignsForArena(String arenaName) { for (Map.Entry entry : signs.entrySet()) { - if (!entry.getValue().equalsIgnoreCase(arenaName)) continue; + String value = entry.getValue(); + String entryArena = value.substring(value.indexOf(':') + 1); + if (!entryArena.equalsIgnoreCase(arenaName)) continue; + + boolean isSpec = value.startsWith("spec:"); Location loc = keyToLocation(entry.getKey()); if (loc == null) continue; Block block = loc.getBlock(); @@ -180,10 +222,17 @@ public class SignListener implements Listener { Arena arena = plugin.getArenaManager().getArena(arenaName); if (arena == null) continue; - sign.setLine(0, TAG_FORMATTED); - sign.setLine(1, "§e" + arena.getName()); - sign.setLine(2, buildStatusLine(arena)); - sign.setLine(3, "§7Klick zum Joinen"); + if (isSpec) { + sign.setLine(0, TAG_SPEC_FMT); + sign.setLine(1, "§b" + arena.getName()); + sign.setLine(2, buildStatusLine(arena)); + sign.setLine(3, "§7Zuschauen"); + } else { + sign.setLine(0, TAG_JOIN_FMT); + sign.setLine(1, "§e" + arena.getName()); + sign.setLine(2, buildStatusLine(arena)); + sign.setLine(3, "§7Joinen"); + } sign.update(); } }