From 65c0568b91eb8c283897edbe9b6bc7dc9e229049 Mon Sep 17 00:00:00 2001 From: M_Viper Date: Fri, 27 Feb 2026 14:17:36 +0100 Subject: [PATCH] Update from Git Manager GUI --- .../java/de/fussball/plugin/StatsManager.java | 51 ++- .../java/de/fussball/plugin/arena/Arena.java | 96 ++++++ .../plugin/commands/FussballCommand.java | 19 +- .../java/de/fussball/plugin/game/Ball.java | 312 +++++++++++++----- .../java/de/fussball/plugin/game/Game.java | 298 +++++++++++++++-- .../de/fussball/plugin/game/GameManager.java | 22 +- .../plugin/listeners/BallListener.java | 37 ++- src/main/resources/config.yml | 40 +++ 8 files changed, 730 insertions(+), 145 deletions(-) diff --git a/src/main/java/de/fussball/plugin/StatsManager.java b/src/main/java/de/fussball/plugin/StatsManager.java index 34fa4a2..c678352 100644 --- a/src/main/java/de/fussball/plugin/StatsManager.java +++ b/src/main/java/de/fussball/plugin/StatsManager.java @@ -42,12 +42,14 @@ public class StatsManager { String path = "players." + uuidStr; PlayerStats stats = new PlayerStats( statsConfig.getString(path + ".name", "Unbekannt"), - statsConfig.getInt(path + ".goals", 0), - statsConfig.getInt(path + ".kicks", 0), - statsConfig.getInt(path + ".wins", 0), - statsConfig.getInt(path + ".losses", 0), - statsConfig.getInt(path + ".draws", 0), - statsConfig.getInt(path + ".games", 0) + statsConfig.getInt(path + ".goals", 0), + statsConfig.getInt(path + ".kicks", 0), + statsConfig.getInt(path + ".wins", 0), + statsConfig.getInt(path + ".losses", 0), + statsConfig.getInt(path + ".draws", 0), + statsConfig.getInt(path + ".games", 0), + statsConfig.getInt(path + ".assists", 0), + statsConfig.getInt(path + ".ownGoals", 0) ); cache.put(uuid, stats); } catch (IllegalArgumentException ignored) {} @@ -61,13 +63,15 @@ public class StatsManager { for (Map.Entry entry : cache.entrySet()) { String path = "players." + entry.getKey(); PlayerStats s = entry.getValue(); - statsConfig.set(path + ".name", s.name); - statsConfig.set(path + ".goals", s.goals); - statsConfig.set(path + ".kicks", s.kicks); - statsConfig.set(path + ".wins", s.wins); - statsConfig.set(path + ".losses", s.losses); - statsConfig.set(path + ".draws", s.draws); - statsConfig.set(path + ".games", s.games); + statsConfig.set(path + ".name", s.name); + statsConfig.set(path + ".goals", s.goals); + statsConfig.set(path + ".kicks", s.kicks); + statsConfig.set(path + ".wins", s.wins); + statsConfig.set(path + ".losses", s.losses); + statsConfig.set(path + ".draws", s.draws); + statsConfig.set(path + ".games", s.games); + statsConfig.set(path + ".assists", s.assists); + statsConfig.set(path + ".ownGoals", s.ownGoals); } try { statsConfig.save(statsFile); } catch (IOException e) { e.printStackTrace(); } } @@ -75,7 +79,7 @@ public class StatsManager { // ── Datenzugriff ──────────────────────────────────────────────────────── public PlayerStats getStats(UUID uuid) { - return cache.computeIfAbsent(uuid, k -> new PlayerStats("Unbekannt", 0, 0, 0, 0, 0, 0)); + return cache.computeIfAbsent(uuid, k -> new PlayerStats("Unbekannt", 0, 0, 0, 0, 0, 0, 0, 0)); } public void addGoal(UUID uuid, String name) { @@ -85,6 +89,20 @@ public class StatsManager { save(); } + public void addOwnGoal(UUID uuid, String name) { + PlayerStats s = getStats(uuid); + s.name = name; + s.ownGoals++; + save(); + } + + public void addAssist(UUID uuid, String name) { + PlayerStats s = getStats(uuid); + s.name = name; + s.assists++; + save(); + } + public void addKick(UUID uuid, String name) { PlayerStats s = getStats(uuid); s.name = name; @@ -131,11 +149,12 @@ public class StatsManager { public static class PlayerStats { public String name; - public int goals, kicks, wins, losses, draws, games; + public int goals, kicks, wins, losses, draws, games, assists, ownGoals; - public PlayerStats(String name, int goals, int kicks, int wins, int losses, int draws, int games) { + public PlayerStats(String name, int goals, int kicks, int wins, int losses, int draws, int games, int assists, int ownGoals) { this.name = name; this.goals = goals; this.kicks = kicks; this.wins = wins; this.losses = losses; this.draws = draws; this.games = games; + this.assists = assists; this.ownGoals = ownGoals; } public double getWinRate() { diff --git a/src/main/java/de/fussball/plugin/arena/Arena.java b/src/main/java/de/fussball/plugin/arena/Arena.java index eb87fa6..3c2bb1f 100644 --- a/src/main/java/de/fussball/plugin/arena/Arena.java +++ b/src/main/java/de/fussball/plugin/arena/Arena.java @@ -13,6 +13,8 @@ public class Arena implements ConfigurationSerializable { private Location center, redSpawn, blueSpawn, ballSpawn; private Location redGoalMin, redGoalMax, blueGoalMin, blueGoalMax, lobby; private Location fieldMin, fieldMax; + // Strafräume – optional manuell gesetzt; sonst auto-berechnet aus Tor + config + private Location redPenaltyMin, redPenaltyMax, bluePenaltyMin, bluePenaltyMax; private int minPlayers, maxPlayers, gameDuration; /** @@ -43,6 +45,82 @@ public class Arena implements ConfigurationSerializable { public boolean isInRedGoal(Location loc) { return isInRegion(loc, redGoalMin, redGoalMax); } public boolean isInBlueGoal(Location loc) { return isInRegion(loc, blueGoalMin, blueGoalMax); } + // ── Strafraum ──────────────────────────────────────────────────────────── + + /** + * Prüft ob eine Position im roten Strafraum liegt. + * Wenn manuell gesetzte Punkte vorhanden sind, werden diese verwendet. + * Sonst wird der Strafraum automatisch aus den Tor-Koordinaten + config berechnet. + */ + public boolean isInRedPenaltyArea(Location loc) { + if (redPenaltyMin != null && redPenaltyMax != null) { + return isInRegion2D(loc, redPenaltyMin, redPenaltyMax); + } + return isAutoCalculatedPenaltyArea(loc, redGoalMin, redGoalMax, true); + } + + public boolean isInBluePenaltyArea(Location loc) { + if (bluePenaltyMin != null && bluePenaltyMax != null) { + return isInRegion2D(loc, bluePenaltyMin, bluePenaltyMax); + } + return isAutoCalculatedPenaltyArea(loc, blueGoalMin, blueGoalMax, false); + } + + /** + * Automatisch berechneter Strafraum. + * Erweitert das Tor um 'penalty-area-margin' seitwärts und 'penalty-area-depth' + * in Richtung Spielfeldmitte. + */ + private boolean isAutoCalculatedPenaltyArea(Location loc, Location gMin, Location gMax, boolean isRedGoal) { + if (gMin == null || gMax == null || loc == null) return false; + Fussball plugin = Fussball.getInstance(); + double depth = plugin != null ? plugin.getConfig().getDouble("gameplay.penalty-area-depth", 16) : 16; + double margin = plugin != null ? plugin.getConfig().getDouble("gameplay.penalty-area-margin", 6) : 6; + + double minX = Math.min(gMin.getX(), gMax.getX()) - margin; + double maxX = Math.max(gMin.getX(), gMax.getX()) + margin; + double minZ = Math.min(gMin.getZ(), gMax.getZ()) - margin; + double maxZ = Math.max(gMin.getZ(), gMax.getZ()) + margin; + + org.bukkit.util.Vector dir = getFieldDirection(); + if (dir != null) { + double redAxis = getRedGoalAxisValue(); + double blueAxis = getBlueGoalAxisValue(); + // Strafraum erstreckt sich VOM Tor in Richtung Feldmitte + if (Math.abs(dir.getZ()) > Math.abs(dir.getX())) { + // Feld läuft entlang Z-Achse + if (isRedGoal) { + if (redAxis < blueAxis) maxZ = Math.max(maxZ, maxZ + depth); + else minZ = Math.min(minZ, minZ - depth); + } else { + if (blueAxis > redAxis) minZ = Math.min(minZ, minZ - depth); + else maxZ = Math.max(maxZ, maxZ + depth); + } + } else { + // Feld läuft entlang X-Achse + if (isRedGoal) { + if (redAxis < blueAxis) maxX = Math.max(maxX, maxX + depth); + else minX = Math.min(minX, minX - depth); + } else { + if (blueAxis > redAxis) minX = Math.min(minX, minX - depth); + else maxX = Math.max(maxX, maxX + depth); + } + } + } else { + // Keine Richtungsinformation → gleichmäßig ausdehnen + minX -= depth / 2; maxX += depth / 2; + minZ -= depth / 2; maxZ += depth / 2; + } + + return loc.getX() >= minX && loc.getX() <= maxX && loc.getZ() >= minZ && loc.getZ() <= maxZ; + } + + private boolean isInRegion2D(Location loc, Location min, Location max) { + if (min == null || max == null || loc == null) return false; + return loc.getX() >= Math.min(min.getX(), max.getX()) && loc.getX() <= Math.max(min.getX(), max.getX()) + && loc.getZ() >= Math.min(min.getZ(), max.getZ()) && loc.getZ() <= Math.max(min.getZ(), max.getZ()); + } + /** * BUG FIX: Nur X und Z prüfen (Y ignorieren). * Vorher führte die Y-Prüfung dazu, dass der Ball beim Anstoß sofort @@ -173,6 +251,10 @@ public class Arena implements ConfigurationSerializable { if (blueGoalMax != null) map.put("blueGoalMax", serLoc(blueGoalMax)); if (fieldMin != null) map.put("fieldMin", serLoc(fieldMin)); if (fieldMax != null) map.put("fieldMax", serLoc(fieldMax)); + if (redPenaltyMin != null) map.put("redPenaltyMin", serLoc(redPenaltyMin)); + if (redPenaltyMax != null) map.put("redPenaltyMax", serLoc(redPenaltyMax)); + if (bluePenaltyMin != null) map.put("bluePenaltyMin", serLoc(bluePenaltyMin)); + if (bluePenaltyMax != null) map.put("bluePenaltyMax", serLoc(bluePenaltyMax)); return map; } @@ -197,6 +279,10 @@ public class Arena implements ConfigurationSerializable { if (map.containsKey("blueGoalMax")) a.blueGoalMax = desLoc(map.get("blueGoalMax")); if (map.containsKey("fieldMin")) a.fieldMin = desLoc(map.get("fieldMin")); if (map.containsKey("fieldMax")) a.fieldMax = desLoc(map.get("fieldMax")); + if (map.containsKey("redPenaltyMin")) a.redPenaltyMin = desLoc(map.get("redPenaltyMin")); + if (map.containsKey("redPenaltyMax")) a.redPenaltyMax = desLoc(map.get("redPenaltyMax")); + if (map.containsKey("bluePenaltyMin")) a.bluePenaltyMin = desLoc(map.get("bluePenaltyMin")); + if (map.containsKey("bluePenaltyMax")) a.bluePenaltyMax = desLoc(map.get("bluePenaltyMax")); return a; } @@ -244,6 +330,16 @@ public class Arena implements ConfigurationSerializable { public void setFieldMin(Location l) { this.fieldMin = l; } public Location getFieldMax() { return fieldMax; } public void setFieldMax(Location l) { this.fieldMax = l; } + public Location getRedPenaltyMin() { return redPenaltyMin; } + public void setRedPenaltyMin(Location l) { this.redPenaltyMin = l; } + public Location getRedPenaltyMax() { return redPenaltyMax; } + public void setRedPenaltyMax(Location l) { this.redPenaltyMax = l; } + public Location getBluePenaltyMin() { return bluePenaltyMin; } + public void setBluePenaltyMin(Location l) { this.bluePenaltyMin = l; } + public Location getBluePenaltyMax() { return bluePenaltyMax; } + public void setBluePenaltyMax(Location l) { this.bluePenaltyMax = l; } + public boolean hasManualpPenaltyAreas() { return redPenaltyMin != null && redPenaltyMax != null + && bluePenaltyMin != null && bluePenaltyMax != null; } 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 ebb1938..adad6f4 100644 --- a/src/main/java/de/fussball/plugin/commands/FussballCommand.java +++ b/src/main/java/de/fussball/plugin/commands/FussballCommand.java @@ -83,6 +83,8 @@ public class FussballCommand implements CommandExecutor, TabCompleter { StatsManager.PlayerStats s = plugin.getStatsManager().getStats(target.getUniqueId()); player.sendMessage(MessageUtil.header("Statistiken: " + target.getName())); player.sendMessage("§7 ⚽ Tore: §e" + s.goals); + player.sendMessage("§7 🅾 Eigentore: §c" + s.ownGoals); + player.sendMessage("§7 🎯 Vorlagen: §a" + s.assists); player.sendMessage("§7 👟 Schüsse: §e" + s.kicks); player.sendMessage("§7 🏆 Siege: §a" + s.wins); player.sendMessage("§7 ❌ Niederlagen: §c" + s.losses); @@ -205,8 +207,12 @@ public class FussballCommand implements CommandExecutor, TabCompleter { case "redgoalmax" -> { arena.setRedGoalMax(player.getLocation()); player.sendMessage(MessageUtil.success("Rotes Tor Max gesetzt: " + locStr(player.getLocation()))); } case "bluegoalmin" -> { arena.setBlueGoalMin(player.getLocation()); player.sendMessage(MessageUtil.success("Blaues Tor Min gesetzt: " + locStr(player.getLocation()))); } case "bluegoalmax" -> { arena.setBlueGoalMax(player.getLocation()); player.sendMessage(MessageUtil.success("Blaues Tor Max gesetzt: " + locStr(player.getLocation()))); } - case "fieldmin" -> { arena.setFieldMin(player.getLocation()); player.sendMessage(MessageUtil.success("Spielfeld Min gesetzt: " + locStr(player.getLocation()))); } - case "fieldmax" -> { arena.setFieldMax(player.getLocation()); player.sendMessage(MessageUtil.success("Spielfeld Max gesetzt: " + locStr(player.getLocation()))); } + case "fieldmin" -> { arena.setFieldMin(player.getLocation()); player.sendMessage(MessageUtil.success("Spielfeld Min gesetzt: " + locStr(player.getLocation()))); } + case "fieldmax" -> { arena.setFieldMax(player.getLocation()); player.sendMessage(MessageUtil.success("Spielfeld Max gesetzt: " + locStr(player.getLocation()))); } + case "redpenaltymin" -> { arena.setRedPenaltyMin(player.getLocation()); player.sendMessage(MessageUtil.success("Roter Strafraum Min gesetzt: " + locStr(player.getLocation()))); } + case "redpenaltymax" -> { arena.setRedPenaltyMax(player.getLocation()); player.sendMessage(MessageUtil.success("Roter Strafraum Max gesetzt: " + locStr(player.getLocation()))); } + case "bluepenaltymin" -> { arena.setBluePenaltyMin(player.getLocation()); player.sendMessage(MessageUtil.success("Blauer Strafraum Min gesetzt: "+ locStr(player.getLocation()))); } + case "bluepenaltymax" -> { arena.setBluePenaltyMax(player.getLocation()); player.sendMessage(MessageUtil.success("Blauer Strafraum Max 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")); } @@ -220,6 +226,10 @@ public class FussballCommand implements CommandExecutor, TabCompleter { player.sendMessage("§7 Rotes Tor: " + check(arena.getRedGoalMin(), arena.getRedGoalMax())); player.sendMessage("§7 Blaues Tor: " + check(arena.getBlueGoalMin(), arena.getBlueGoalMax())); player.sendMessage("§7 Spielfeld: " + check(arena.getFieldMin(), arena.getFieldMax()) + " §8(optional)"); + player.sendMessage("§7 Roter Strafraum: " + check(arena.getRedPenaltyMin(), arena.getRedPenaltyMax()) + + " §8(optional – sonst auto-berechnet)"); + player.sendMessage("§7 Blauer Strafraum: " + check(arena.getBluePenaltyMin(), arena.getBluePenaltyMax()) + + " §8(optional – sonst auto-berechnet)"); player.sendMessage("§7 Min. Spieler: §e" + arena.getMinPlayers()); player.sendMessage("§7 Max. Spieler: §e" + arena.getMaxPlayers()); player.sendMessage("§7 Spieldauer: §e" + arena.getGameDuration() + "s"); @@ -300,6 +310,7 @@ public class FussballCommand implements CommandExecutor, TabCompleter { p.sendMessage("§e/fb setup lobby|redspawn|bluespawn|ballspawn|center"); p.sendMessage("§e/fb setup redgoalmin|redgoalmax|bluegoalmin|bluegoalmax"); p.sendMessage("§e/fb setup fieldmin|fieldmax §8(optional – Aus-Erkennung)"); + p.sendMessage("§e/fb setup redpenaltymin|redpenaltymax|bluepenaltymin|bluepenaltymax §8(optional – auto-berechnet wenn leer)"); p.sendMessage("§e/fb setup minplayers |maxplayers |duration |info"); } @@ -322,7 +333,9 @@ public class FussballCommand implements CommandExecutor, TabCompleter { } else if (args.length == 3 && args[0].equalsIgnoreCase("setup")) { list.addAll(List.of("lobby","redspawn","bluespawn","ballspawn","center", "redgoalmin","redgoalmax","bluegoalmin","bluegoalmax", - "fieldmin","fieldmax","minplayers","maxplayers","duration","info")); + "fieldmin","fieldmax", + "redpenaltymin","redpenaltymax","bluepenaltymin","bluepenaltymax", + "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/Ball.java b/src/main/java/de/fussball/plugin/game/Ball.java index c6ca3ee..e85f6fe 100644 --- a/src/main/java/de/fussball/plugin/game/Ball.java +++ b/src/main/java/de/fussball/plugin/game/Ball.java @@ -1,7 +1,6 @@ package de.fussball.plugin.game; import de.fussball.plugin.Fussball; -import de.fussball.plugin.utils.MessageUtil; import org.bukkit.*; import org.bukkit.entity.*; import org.bukkit.event.entity.CreatureSpawnEvent; @@ -9,6 +8,7 @@ import org.bukkit.inventory.ItemStack; import org.bukkit.inventory.meta.SkullMeta; import org.bukkit.profile.PlayerProfile; import org.bukkit.profile.PlayerTextures; +import org.bukkit.util.EulerAngle; import org.bukkit.util.Vector; import java.net.MalformedURLException; @@ -20,10 +20,21 @@ public class Ball { private static final String BALL_TEXTURE_URL = "https://textures.minecraft.net/texture/451f8cfcfb85d77945dc6a3618414093e70436b46d2577b28c727f1329b7265e"; - // Reibung: jeder Tick wird horizontale Geschwindigkeit um diesen Faktor reduziert - private static final double FRICTION = 0.82; - // Unter dieser Geschwindigkeit → Ball stoppt komplett - private static final double MIN_VELOCITY = 0.04; + // ── Physik-Konstanten ───────────────────────────────────────────────────── + /** Reibung am Boden (pro Tick) – simuliert Rasen */ + private static final double FRICTION_GROUND = 0.76; + /** Luftwiderstand (pro Tick) – deutlich weniger als Bodenreibung */ + private static final double FRICTION_AIR = 0.985; + /** Abprall-Koeffizient: 0 = kein Abprall, 1 = perfekter Abprall */ + private static final double BOUNCE_RESTITUTION = 0.42; + /** Minimale aufprallY-Geschwindigkeit für sichtbaren Abprall */ + private static final double MIN_BOUNCE_VEL = 0.12; + /** Unter dieser Horizontalgeschwindigkeit stoppt der Ball */ + private static final double MIN_VELOCITY = 0.025; + /** Maximale Ballgeschwindigkeit (verhindert unrealistisch schnelle Bälle) */ + private static final double MAX_VELOCITY = 3.8; + /** Anteil der Blickrichtung beim normalen Schuss (0=nur Ballrichtung, 1=nur Blick) */ + private static final double LOOK_BLEND = 0.45; private final Game game; private final Fussball plugin; @@ -31,30 +42,37 @@ public class Ball { private final Location spawnLocation; private boolean active = false; - // ── Torwart-Halten ─────────────────────────────────────────────────────── + // ── Torwart-Halten ──────────────────────────────────────────────────────── private boolean heldByGoalkeeper = false; private Player holdingPlayer = null; + // ── Physik-State ────────────────────────────────────────────────────────── + /** Y-Geschwindigkeit des letzten Ticks – für Abprall-Erkennung */ + private double prevYVelocity = 0.0; + /** Akkumulierter Rollwinkel für visuelle Rotation (Radianten) */ + private double rollAngle = 0.0; + /** Ticks bis nächstes Rollgeräusch */ + private int rollSoundTick = 0; + public Ball(Game game, Fussball plugin, Location spawnLocation) { - this.game = game; - this.plugin = plugin; + this.game = game; + this.plugin = plugin; this.spawnLocation = spawnLocation.clone(); } - // ── Config-Helfer ──────────────────────────────────────────────────────── + // ── Config-Helfer ───────────────────────────────────────────────────────── private double cfg(String path, double def) { return plugin.getConfig().getDouble(path, def); } - // ── Spawnen ────────────────────────────────────────────────────────────── + // ── Spawnen ─────────────────────────────────────────────────────────────── public void spawn() { if (spawnLocation == null || spawnLocation.getWorld() == null) { plugin.getLogger().severe("[Fussball] Ball-Spawn Location ist null!"); return; } if (entity != null && !entity.isDead()) entity.remove(); - spawnLocation.getWorld().loadChunk(spawnLocation.getBlockX() >> 4, spawnLocation.getBlockZ() >> 4); entity = spawnLocation.getWorld().spawn( @@ -78,16 +96,18 @@ public class Ball { if (entity == null) { plugin.getLogger().severe("[Fussball] ArmorStand konnte nicht gespawnt werden!"); return; } - active = true; + active = true; + prevYVelocity = 0.0; + rollAngle = 0.0; } private ItemStack createBallItem() { ItemStack skull = new ItemStack(Material.PLAYER_HEAD); - SkullMeta meta = (SkullMeta) skull.getItemMeta(); + SkullMeta meta = (SkullMeta) skull.getItemMeta(); if (meta == null) return skull; meta.setDisplayName("§e⚽ Fußball"); try { - PlayerProfile profile = Bukkit.createPlayerProfile( + PlayerProfile profile = Bukkit.createPlayerProfile( UUID.nameUUIDFromBytes("FussballBall".getBytes()), "FussballBall"); PlayerTextures textures = profile.getTextures(); textures.setSkin(new URL(BALL_TEXTURE_URL)); @@ -100,17 +120,34 @@ public class Ball { return skull; } - // ── Schuss ─────────────────────────────────────────────────────────────── + // ── Schüsse ─────────────────────────────────────────────────────────────── - /** Normaler Schuss (Rechtsklick) */ + /** + * Kopfball – flacherer Winkel, stärkere Blickrichtungs-Gewichtung. + */ + public void header(Player player) { + if (entity == null || entity.isDead() || !active) return; + Location ballLoc = entity.getLocation(); + Vector dir = blendDirection(player, 0.65); + double power = cfg("ball.header-power", 1.3); + dir.setY(Math.max(dir.getY(), -0.05)); + dir.multiply(power); + applyKick(dir, ballLoc, 0.85f); + ballLoc.getWorld().spawnParticle(Particle.POOF, ballLoc, 4, 0.1, 0.1, 0.1, 0.02); + } + + /** + * Normaler Schuss (Rechtsklick / Nähe). + * Mischt Ball→Spieler-Richtung mit Blickrichtung. + */ public void kick(Player player) { if (entity == null || entity.isDead() || !active) return; Location ballLoc = entity.getLocation(); - Vector dir = getKickDirection(player); + Vector dir = blendDirection(player, LOOK_BLEND); - double kickVertical = cfg("ball.kick-vertical", 0.3); - double kickPower = cfg("ball.kick-power", 1.1); - double sprintKickPower = cfg("ball.sprint-kick-power", 1.8); + double kickVertical = cfg("ball.kick-vertical", 0.3); + double kickPower = cfg("ball.kick-power", 1.1); + double sprintKickPower = cfg("ball.sprint-kick-power", 1.8); dir.setY(kickVertical); dir.multiply(player.isSprinting() ? sprintKickPower : kickPower); @@ -118,79 +155,204 @@ public class Ball { } /** - * Aufgeladener Schuss (Shift gedrückt halten → loslassen) - * power = 0.0 (kurz gehalten) bis 1.0 (voll aufgeladen, ~1.5s) + * Aufgeladener Schuss (Shift halten → loslassen). + * power = 0.0 bis 1.0 → beeinflusst Kraft und Loft. */ public void chargedKick(Player player, double power) { if (entity == null || entity.isDead() || !active) return; Location ballLoc = entity.getLocation(); - Vector dir = getKickDirection(player); + // Bei vollem Schuss folgt der Ball mehr der Blickrichtung + Vector dir = blendDirection(player, LOOK_BLEND + power * 0.2); double minPower = cfg("ball.charged-min-power", 1.3); double maxPower = cfg("ball.charged-max-power", 3.8); - double actualMultiplier = minPower + (maxPower - minPower) * power; + double mult = minPower + (maxPower - minPower) * power; - dir.setY(0.25 + power * 0.25); // mehr Loft bei vollem Schuss - dir.multiply(actualMultiplier); - float pitch = 1.0f + (float) (power * 0.8f); + dir.setY(0.22 + power * 0.28); + dir.multiply(mult); + float pitch = 0.9f + (float)(power * 0.9f); applyKick(dir, ballLoc, pitch); - // Feuer-Partikel ab 70% Ladung if (power > 0.7) { - ballLoc.getWorld().spawnParticle(Particle.FLAME, ballLoc, 12, 0.2, 0.2, 0.2, 0.06); + ballLoc.getWorld().spawnParticle(Particle.FLAME, ballLoc, 14, 0.2, 0.2, 0.2, 0.07); } } - private Vector getKickDirection(Player player) { - Vector dir = entity.getLocation().toVector().subtract(player.getLocation().toVector()); - if (dir.lengthSquared() < 0.0001) dir = player.getLocation().getDirection(); - dir.normalize(); - return dir; + /** + * Mischt Spieler→Ball-Richtung mit der horizontalen Blickrichtung. + * Ergibt natürlicheres Schussverhalten als nur die Ball-Richtung. + * + * @param lookBlend 0.0 = rein Ball-Richtung, 1.0 = rein Blickrichtung + */ + private Vector blendDirection(Player player, double lookBlend) { + Vector toBall = entity.getLocation().toVector() + .subtract(player.getLocation().toVector()) + .setY(0); + if (toBall.lengthSquared() < 0.0001) toBall = player.getLocation().getDirection().setY(0); + toBall.normalize(); + + Vector look = player.getLocation().getDirection().clone().setY(0); + if (look.lengthSquared() < 0.0001) look = toBall.clone(); + else look.normalize(); + + Vector result = toBall.clone().multiply(1.0 - lookBlend).add(look.multiply(lookBlend)); + if (result.lengthSquared() < 0.0001) result = toBall; + return result.normalize(); } private void applyKick(Vector dir, Location ballLoc, float soundPitch) { + // Geschwindigkeitsbegrenzung + if (dir.length() > MAX_VELOCITY) dir.normalize().multiply(MAX_VELOCITY); entity.setVelocity(dir); + prevYVelocity = dir.getY(); ballLoc.getWorld().playSound(ballLoc, Sound.ENTITY_GENERIC_SMALL_FALL, 1.0f, soundPitch); ballLoc.getWorld().spawnParticle(Particle.POOF, ballLoc, 5, 0.1, 0.1, 0.1, 0.05); } - // ── Physik ─────────────────────────────────────────────────────────────── - - public void applyFriction() { - if (heldByGoalkeeper) return; // Kein Reibung wenn der TW den Ball hält - if (entity == null || entity.isDead() || !active) return; - Vector vel = entity.getVelocity(); - double hx = vel.getX() * FRICTION; - double hz = vel.getZ() * FRICTION; - if (Math.abs(hx) < MIN_VELOCITY) hx = 0; - if (Math.abs(hz) < MIN_VELOCITY) hz = 0; - entity.setVelocity(new Vector(hx, vel.getY(), hz)); - } - - // ── Torwart-Mechanik ───────────────────────────────────────────────────── + // ── Physik (jeden Tick) ─────────────────────────────────────────────────── /** - * Torwart greift den Ball – Ball schwebt vor ihm und folgt ihm. - * Prüft vorher, ob er im erlaubten Bereich ist. + * Haupt-Physik-Update – jeden Tick vom GoalCheckLoop aufgerufen (ersetzt applyFriction). + * Verarbeitet: Reibung (Luft/Boden), Abprall, Wandreflektion, visuelle Rotation. */ - public void holdBall(Player goalkeeper) { + public void applyPhysics() { + if (heldByGoalkeeper) return; if (entity == null || entity.isDead() || !active) return; - // NEUE REGEL: Prüfen, ob Spieler im 2-Block-Radius am Tor ist - if (!game.isAllowedToHoldBall(goalkeeper)) { - goalkeeper.sendMessage(MessageUtil.error("Du kannst den Ball nur innerhalb von 2 Blöcken am Tor halten!")); - return; + Vector vel = entity.getVelocity(); + boolean onGround = entity.isOnGround(); + + // ── 1. Aufprall-Abprall ─────────────────────────────────────────────── + if (onGround && prevYVelocity < -MIN_BOUNCE_VEL) { + double bounceY = -prevYVelocity * BOUNCE_RESTITUTION; + if (bounceY > MIN_BOUNCE_VEL) { + vel.setY(bounceY); + entity.setVelocity(vel); + // Sound proportional zur Aufprallstärke + float vol = (float) Math.min(1.0, Math.abs(prevYVelocity) * 0.65); + float tone = 0.7f + (float)(Math.abs(prevYVelocity) * 0.25); + entity.getLocation().getWorld().playSound( + entity.getLocation(), Sound.BLOCK_GRASS_HIT, vol, tone); + } } + // ── 2. Reibung – Boden vs. Luft ────────────────────────────────────── + double friction = onGround ? FRICTION_GROUND : FRICTION_AIR; + double hx = vel.getX() * friction; + double hz = vel.getZ() * friction; + if (Math.abs(hx) < MIN_VELOCITY) hx = 0; + if (Math.abs(hz) < MIN_VELOCITY) hz = 0; + + // ── 3. Wandabprall ──────────────────────────────────────────────────── + if (!onGround) { + Vector reflected = checkWallCollision(vel); + if (reflected != null) { + hx = reflected.getX() * 0.58; + hz = reflected.getZ() * 0.58; + } + } + + vel.setX(hx); + vel.setZ(hz); + entity.setVelocity(vel); + prevYVelocity = vel.getY(); + + // ── 4. Visuelle Rotation ────────────────────────────────────────────── + updateVisualRotation(hx, hz, onGround); + + // ── 5. Rollgeräusch ─────────────────────────────────────────────────── + if (rollSoundTick > 0) { + rollSoundTick--; + } else if (onGround) { + double speed = Math.sqrt(hx * hx + hz * hz); + if (speed > 0.12) { + float vol = (float) Math.min(0.3, speed * 0.18); + float tone = 0.75f + (float)(speed * 0.18); + entity.getLocation().getWorld().playSound( + entity.getLocation(), Sound.BLOCK_GRASS_STEP, vol, tone); + rollSoundTick = Math.max(2, (int)(9 - speed * 4)); + } + } + } + + /** + * Prüft ob der Ball in Bewegungsrichtung gegen einen festen Block läuft. + * @return Reflektierter Geschwindigkeitsvektor, oder null wenn kein Hindernis. + */ + private Vector checkWallCollision(Vector vel) { + if (vel.lengthSquared() < 0.01) return null; + Location loc = entity.getLocation().add(0, 0.25, 0); + World world = loc.getWorld(); + if (world == null) return null; + + double check = 0.42; + boolean hitX = false, hitZ = false; + + if (Math.abs(vel.getX()) > 0.04) { + if (loc.clone().add(Math.signum(vel.getX()) * check, 0, 0) + .getBlock().getType().isSolid()) hitX = true; + } + if (Math.abs(vel.getZ()) > 0.04) { + if (loc.clone().add(0, 0, Math.signum(vel.getZ()) * check) + .getBlock().getType().isSolid()) hitZ = true; + } + if (!hitX && !hitZ) return null; + + Vector reflected = vel.clone(); + if (hitX) reflected.setX(-vel.getX()); + if (hitZ) reflected.setZ(-vel.getZ()); + + world.playSound(loc, Sound.BLOCK_STONE_HIT, 0.45f, 1.15f); + world.spawnParticle(Particle.POOF, loc, 2, 0.05, 0.05, 0.05, 0.01); + return reflected; + } + + /** + * Dreht den ArmorStand-Kopf in Bewegungsrichtung – simuliert einen rollenden Ball. + * Der Rollwinkel akkumuliert sich mit der Geschwindigkeit. + */ + private void updateVisualRotation(double vx, double vz, boolean onGround) { + if (entity == null || entity.isDead()) return; + double speed = Math.sqrt(vx * vx + vz * vz); + if (speed < 0.015) return; + + // Rollwinkel akkumulieren (ca. 1.4 Radianten pro Block) + rollAngle += speed * 1.4; + + // Yaw = Bewegungsrichtung (Rollachse) + double yaw = Math.atan2(-vx, vz); + + if (onGround) { + // Am Boden: realistisches Rollverhalten + entity.setHeadPose(new EulerAngle( + Math.sin(rollAngle) * 0.9, // Nicken (vorwärts/rückwärts) + yaw, // Rollrichtung + Math.cos(rollAngle) * 0.35 // leichtes Kippen + )); + } else { + // In der Luft: Ball dreht sich frei + entity.setHeadPose(new EulerAngle( + rollAngle % (2 * Math.PI), + yaw, + (rollAngle * 0.3) % (2 * Math.PI) + )); + } + } + + // ── Torwart-Mechanik ────────────────────────────────────────────────────── + + public boolean holdBall(Player goalkeeper) { + if (entity == null || entity.isDead() || !active) return false; + if (!game.isAllowedToHoldBall(goalkeeper)) return false; + this.heldByGoalkeeper = true; this.holdingPlayer = goalkeeper; entity.setGravity(false); entity.setVelocity(new Vector(0, 0, 0)); + prevYVelocity = 0; + return true; } - /** - * Muss jeden Tick aufgerufen werden damit der Ball dem TW folgt. - */ public void updateHeldPosition() { if (!heldByGoalkeeper || holdingPlayer == null || entity == null || entity.isDead()) return; Location hold = holdingPlayer.getLocation().clone(); @@ -198,43 +360,39 @@ public class Ball { hold.setY(hold.getY() + 1.0); entity.teleport(hold); entity.setVelocity(new Vector(0, 0, 0)); + // Sanfte Rotation während der Ball gehalten wird + rollAngle += 0.04; + entity.setHeadPose(new EulerAngle(0, rollAngle, 0)); } - /** - * Torwart lässt den Ball los ohne zu werfen (z.B. fallen lassen). - */ public void releaseBall() { this.heldByGoalkeeper = false; this.holdingPlayer = null; - if (entity != null && !entity.isDead()) { - entity.setGravity(true); - } + if (entity != null && !entity.isDead()) entity.setGravity(true); } - /** - * Torwart wirft den Ball mit gegebener Stärke (0.5 – 2.5). - */ public void throwBall(Player goalkeeper, double power) { releaseBall(); if (entity == null || entity.isDead()) return; - Vector dir = goalkeeper.getLocation().getDirection(); - dir.setY(dir.getY() + 0.25); - dir.multiply(power); + Vector dir = goalkeeper.getLocation().getDirection().clone(); + dir.setY(dir.getY() + 0.3); + dir.multiply(Math.min(power, MAX_VELOCITY * 0.75)); entity.setVelocity(dir); + prevYVelocity = dir.getY(); Location loc = entity.getLocation(); loc.getWorld().playSound(loc, Sound.ENTITY_SNOWBALL_THROW, 1f, 1.1f); loc.getWorld().spawnParticle(Particle.POOF, loc, 5, 0.1, 0.1, 0.1, 0.04); } - public boolean isHeld() { return heldByGoalkeeper; } - public Player getHoldingPlayer() { return holdingPlayer; } - - // ── Util ───────────────────────────────────────────────────────────────── + // ── Util ────────────────────────────────────────────────────────────────── public void returnToCenter() { if (entity != null && !entity.isDead()) { entity.teleport(spawnLocation); entity.setVelocity(new Vector(0, 0, 0)); + entity.setHeadPose(new EulerAngle(0, 0, 0)); + prevYVelocity = 0; + rollAngle = 0; } } @@ -245,8 +403,10 @@ public class Ball { } public ArmorStand getEntity() { return entity; } - public boolean isActive() { return active; } - public Location getSpawnLocation() { return spawnLocation; } + public boolean isActive() { return active; } + public Location getSpawnLocation() { return spawnLocation; } + public boolean isHeld() { return heldByGoalkeeper; } + public Player getHoldingPlayer() { return holdingPlayer; } public double getDistanceTo(Player player) { if (entity == null || entity.isDead()) return Double.MAX_VALUE; diff --git a/src/main/java/de/fussball/plugin/game/Game.java b/src/main/java/de/fussball/plugin/game/Game.java index 0eb4455..d6212f5 100644 --- a/src/main/java/de/fussball/plugin/game/Game.java +++ b/src/main/java/de/fussball/plugin/game/Game.java @@ -53,8 +53,17 @@ public class Game { // ──────────────────────────────────────────────────────────────────────── private UUID lastKicker = null; + private UUID secondLastKicker = null; // für Assist-Erkennung + private boolean lastKickWasHeader = false; // für Rückpass-Regel (Header erlaubt) private Team lastTouchTeam = null; - private Team throwInTeam = null; // null = alle dürfen; gesetzt = nur dieses Team darf treten + private Team throwInTeam = null; + + // ── Nachspielzeit ────────────────────────────────────────────────────── + private int injuryTimeBuffer = 0; // Angesammelte Nachspielzeit in Sekunden + private boolean inInjuryTime = false; // Läuft gerade Nachspielzeit? + + // ── Kopfball-Abklingzeit ─────────────────────────────────────────────── + private final Map headerCooldowns = new HashMap<>(); // ── Ball ─────────────────────────────────────────────────────────────── private Ball ball; @@ -562,15 +571,43 @@ public class Game { private void startGameLoop() { gameTask = new BukkitRunnable() { public void run() { - // Nur bei RUNNING oder OVERTIME zählen if (state != GameState.RUNNING && state != GameState.OVERTIME) return; + // Kopfball-Abklingzeiten herunterzählen + headerCooldowns.entrySet().removeIf(e -> { + int t = e.getValue() - 1; + e.setValue(t); + return t <= 0; + }); + + // Halbzeit-Check VOR dem timeLeft-Decrement (1. Halbzeit) + if (!secondHalf && state == GameState.RUNNING && timeLeft == arena.getGameDuration() / 2) { + // Nachspielzeit abbauen bevor Halbzeit + if (injuryTimeBuffer > 0) { + injuryTimeBuffer--; + int itLeft = injuryTimeBuffer; + if (!inInjuryTime) { + inInjuryTime = true; + int mins = (int) Math.ceil((itLeft + 1) / 60.0); + broadcastAll(Messages.get("injury-time", "n", String.valueOf(mins))); + for (UUID uuid : allPlayers) { + Player p = Bukkit.getPlayer(uuid); + if (p != null) p.sendTitle("§c⏱", "§8+" + (itLeft + 1) + "s Nachspielzeit", 5, 30, 5); + } + } + scoreboard.updateAll(); updateBossBar(); return; + } + inInjuryTime = false; + startHalfTime(); return; + } + timeLeft--; secondsPlayed++; scoreboard.updateAll(); updateBossBar(); checkPlayerBallInteraction(); checkPlayerBoundaries(); + checkHeaderOpportunities(); // Freistoß-Abstandsdurchsetzung if (freekickLocation != null) { @@ -579,7 +616,7 @@ public class Game { if (freekickTicks <= 0) { freekickLocation = null; throwInTeam = null; } } - // Warn-Nachrichten aus config + // Warn-Nachrichten if (timeLeft == 60) broadcastAll(Messages.get("time-1min")); if (timeLeft == 30) broadcastAll(Messages.get("time-30sec")); if (timeLeft <= 10 && timeLeft > 0) { @@ -587,22 +624,31 @@ public class Game { for (UUID uuid : allPlayers) { Player p = Bukkit.getPlayer(uuid); if (p != null) p.playSound(p.getLocation(), Sound.BLOCK_NOTE_BLOCK_PLING, 1f, 2f); } } - // Halbzeit-Check (nur 1. Halbzeit) - if (!secondHalf && state == GameState.RUNNING && timeLeft == arena.getGameDuration() / 2) { - startHalfTime(); return; - } - - // Zeit abgelaufen + // Zeit abgelaufen → Nachspielzeit prüfen if (timeLeft <= 0) { + if (injuryTimeBuffer > 0) { + injuryTimeBuffer--; + if (!inInjuryTime) { + inInjuryTime = true; + int mins = (int) Math.ceil((injuryTimeBuffer + 1) / 60.0); + broadcastAll(Messages.get("injury-time", "n", String.valueOf(mins))); + for (UUID uuid : allPlayers) { + Player p = Bukkit.getPlayer(uuid); + if (p != null) p.sendTitle("§c+"+mins+"'", "§8Nachspielzeit!", 5, 30, 5); + } + } + scoreboard.updateAll(); updateBossBar(); return; + } + inInjuryTime = false; + if (state == GameState.OVERTIME) { Team winner = getWinnerTeam(); - if (winner != null) { endGame(winner); } - else { startPenalty(); } + if (winner != null) { endGame(winner); } else { startPenalty(); } } else { Team winner = getWinnerTeam(); - if (winner != null) { endGame(winner); } + if (winner != null) { endGame(winner); } else if (!overtimeDone) { startOvertime(); } - else { startPenalty(); } + else { startPenalty(); } } cancel(); } @@ -646,7 +692,7 @@ public class Game { return; } - ball.applyFriction(); + ball.applyPhysics(); Location current = ball.getEntity().getLocation(); if (lastBallLocation == null || !lastBallLocation.getWorld().equals(current.getWorld())) { @@ -769,6 +815,7 @@ public class Game { broadcastAll(message); for (UUID uuid : allPlayers) { Player p = Bukkit.getPlayer(uuid); if (p != null) p.playSound(p.getLocation(), Sound.BLOCK_NOTE_BLOCK_HAT, 1f, 1.2f); } + addInjuryTime(plugin.getConfig().getInt("gameplay.injury-time-per-out", 3)); final Location spawnHere = resumeLocation; new BukkitRunnable() { @@ -817,35 +864,80 @@ public class Game { if (ball != null) { ball.remove(); ball = null; } lastBallLocation = null; - // Torschütze ermitteln + // ── Eigentor-Erkennung ────────────────────────────────────────────── + boolean ownGoal = false; + if (lastKicker != null) { + Player possibleScorer = Bukkit.getPlayer(lastKicker); + if (possibleScorer != null) { + Team scorerTeam = getTeam(possibleScorer); + // Scorerteam ist das GEGNER-Team des schießenden Spielers → Eigentor + if (scorerTeam != null && scorerTeam == scoringTeam.getOpponent()) { + ownGoal = true; + } + } + } + + // ── Torschütze / Eigentor ──────────────────────────────────────────── String scorerName = "Unbekannt"; + String assistName = null; if (lastKicker != null) { Player scorer = Bukkit.getPlayer(lastKicker); if (scorer != null) { scorerName = scorer.getName(); - goals.merge(lastKicker, 1, Integer::sum); - // Persistente Statistik - plugin.getStatsManager().addGoal(lastKicker, scorer.getName()); + if (ownGoal) { + goals.merge(lastKicker, 1, Integer::sum); // Eigentor separat in Match-Log + plugin.getStatsManager().addOwnGoal(lastKicker, scorer.getName()); + } else { + goals.merge(lastKicker, 1, Integer::sum); + plugin.getStatsManager().addGoal(lastKicker, scorer.getName()); + + // ── Assist-Erkennung ──────────────────────────────────── + if (secondLastKicker != null && !secondLastKicker.equals(lastKicker)) { + Player assistPlayer = Bukkit.getPlayer(secondLastKicker); + if (assistPlayer != null && getTeam(assistPlayer) == scoringTeam) { + assistName = assistPlayer.getName(); + plugin.getStatsManager().addAssist(secondLastKicker, assistPlayer.getName()); + } + } + } } lastKicker = null; } - lastTouchTeam = null; // Reset nach Tor + secondLastKicker = null; + lastTouchTeam = null; + // ── Nachspielzeit hinzufügen ───────────────────────────────────────── + addInjuryTime(plugin.getConfig().getInt("gameplay.injury-time-per-goal", 30)); + + // ── Broadcast ──────────────────────────────────────────────────────── String color = scoringTeam == Team.RED ? "§c" : "§9"; broadcastAll("§e§l╔══════════════════╗"); - broadcastAll("§e§l║ ⚽ T O R !! ║"); + if (ownGoal) { + broadcastAll("§c§l║ ⚽ EIGENTOR!! ║"); + } else { + broadcastAll("§e§l║ ⚽ T O R !! ║"); + } broadcastAll("§e§l╚══════════════════╝"); broadcastAll(color + "▶ " + scoringTeam.getDisplayName() + " §7hat ein Tor erzielt!"); - broadcastAll("§7Torschütze: §e" + scorerName); + if (ownGoal) { + broadcastAll(Messages.get("own-goal", "player", scorerName)); + } else { + broadcastAll("§7Torschütze: §e" + scorerName); + if (assistName != null) broadcastAll(Messages.get("assist", "player", assistName)); + } broadcastAll("§7Spielstand: §c" + redScore + " §7: §9" + blueScore); - // Tor-Replay in Action-Bar (auch für Zuschauer) - String replayMsg = "§e⚽ TOR von §f" + scorerName + "§e! §8[" + color + scoringTeam.getDisplayName() + "§8] §c" + redScore + " §7: §9" + blueScore; + String replayMsg = ownGoal + ? "§c⚽ EIGENTOR §f" + scorerName + "§e! §c" + redScore + " §7: §9" + blueScore + : "§e⚽ TOR von §f" + scorerName + "§e! §8[" + color + scoringTeam.getDisplayName() + "§8] §c" + redScore + " §7: §9" + blueScore; for (UUID uuid : getAllAndSpectators()) { Player p = Bukkit.getPlayer(uuid); if (p != null) { - p.sendTitle(color + "⚽ TOR!", "§7" + scoringTeam.getDisplayName() + " §8| §c" + redScore + " §7: §9" + blueScore, 10, 60, 10); - p.spigot().sendMessage(ChatMessageType.ACTION_BAR, TextComponent.fromLegacyText(replayMsg)); + String titleColor = ownGoal ? "§c" : color; + p.sendTitle(titleColor + (ownGoal ? "⚽ EIGENTOR!" : "⚽ TOR!"), + "§7" + scoringTeam.getDisplayName() + " §8| §c" + redScore + " §7: §9" + blueScore, 10, 60, 10); + p.spigot().sendMessage(net.md_5.bungee.api.ChatMessageType.ACTION_BAR, + net.md_5.bungee.api.chat.TextComponent.fromLegacyText(replayMsg)); spawnFirework(arena.getBallSpawn(), scoringTeam); p.playSound(p.getLocation(), Sound.ENTITY_FIREWORK_ROCKET_BLAST, 2f, 1f); } @@ -853,15 +945,24 @@ public class Game { scoreboard.updateAll(); updateBossBar(); refreshSigns(); - logMatchEvent("§eTOR: §f" + scorerName + " §8[" + color + scoringTeam.getDisplayName() + "§8] §7" + redScore + ":" + blueScore); + String ownGoalMarker = ownGoal ? " §8(Eigentor)" : ""; + logMatchEvent("§eTOR: §f" + scorerName + ownGoalMarker + + (assistName != null ? " §8(Vorlage: §e" + assistName + "§8)" : "") + + " §8[" + color + scoringTeam.getDisplayName() + "§8] §7" + redScore + ":" + blueScore); + final Team concedingTeam = scoringTeam.getOpponent(); // für Anstoß new BukkitRunnable() { public void run() { if (state == GameState.GOAL) { state = GameState.RUNNING; - outOfBoundsCountdown.clear(); + outOfBoundsCountdown.clear(); spawnBallDelayed(arena.getBallSpawn()); - // BUG FIX: In 2. Halbzeit Seiten getauscht + // ── Anstoß-Team: das Team, das den Treffer kassiert hat ── + throwInTeam = concedingTeam; + broadcastAll(Messages.get("kickoff-team", "team", + concedingTeam == Team.RED ? "§cRotes Team" : "§9Blaues Team")); + + // Teleportation je nach Halbzeit if (!secondHalf) { for (UUID uuid : redTeam) { Player p = Bukkit.getPlayer(uuid); if (p != null) p.teleport(arena.getRedSpawn()); } for (UUID uuid : blueTeam) { Player p = Bukkit.getPlayer(uuid); if (p != null) p.teleport(arena.getBlueSpawn()); } @@ -892,13 +993,19 @@ public class Game { private void updateBossBar() { if (bossBar == null) return; - int m = timeLeft / 60, s = timeLeft % 60; - String timeStr = String.format("%02d:%02d", m, s); + String timeStr; + if (inInjuryTime) { + int injMins = (int) Math.ceil(injuryTimeBuffer / 60.0); + timeStr = "§c+" + injMins + "' "; + } else { + int m = timeLeft / 60, s = timeLeft % 60; + timeStr = String.format("%02d:%02d", m, s); + } String halfLabel = state == GameState.OVERTIME ? "§6VL" : (secondHalf ? "§72.HZ" : "§71.HZ"); bossBar.setTitle("§c" + redScore + " §7: §9" + blueScore + " §8│ §e⏱ " + timeStr + " " + halfLabel); double progress = Math.max(0.0, Math.min(1.0, (double) timeLeft / (state == GameState.OVERTIME ? 600 : arena.getGameDuration()))); - bossBar.setProgress(progress); - bossBar.setColor(timeLeft > 60 ? BarColor.GREEN : timeLeft > 20 ? BarColor.YELLOW : BarColor.RED); + bossBar.setProgress(inInjuryTime ? Math.max(0.01, progress) : progress); + bossBar.setColor(inInjuryTime ? BarColor.RED : (timeLeft > 60 ? BarColor.GREEN : timeLeft > 20 ? BarColor.YELLOW : BarColor.RED)); } // ════════════════════════════════════════════════════════════════════════ @@ -1022,8 +1129,39 @@ public class Game { fouler.playSound(fouler.getLocation(), Sound.ENTITY_VILLAGER_NO, 1f, 1f); if (directRed) giveRedCard(fouler, "Grobes Foulspiel"); else giveYellowCard(fouler, "Foulspiel"); - startFreekick(victimTeam, foulLocation, Messages.get("foul", "player", fouler.getName())); + + addInjuryTime(plugin.getConfig().getInt("gameplay.injury-time-per-foul", 5)); logMatchEvent("§cFoul: §e" + fouler.getName() + " §7→ §e" + victim.getName()); + + // ── Foul im Strafraum → Elfmeter ─────────────────────────────────── + boolean inRedPenalty = arena.isInRedPenaltyArea(foulLocation); + boolean inBluePenalty = arena.isInBluePenaltyArea(foulLocation); + boolean penaltyKick = false; + + if (inRedPenalty && victimTeam == Team.BLUE) { + // Foul an Blau im roten Strafraum → Elfmeter für Blau + broadcastAll(Messages.get("foul-penalty", "team", "§9Blaues Team")); + for (UUID uuid : getAllAndSpectators()) { + Player p = Bukkit.getPlayer(uuid); + if (p != null) p.sendTitle("§c⚠ ELFMETER!", "§9Blaues Team§7 schießt!", 5, 50, 10); + } + penaltyKick = true; + // Elfmeter als Freistoß direkt auf Ballspawn (ggf. später: separater Elfmeter-Punkt) + startFreekick(Team.BLUE, arena.getBallSpawn(), "Elfmeter"); + } else if (inBluePenalty && victimTeam == Team.RED) { + // Foul an Rot im blauen Strafraum → Elfmeter für Rot + broadcastAll(Messages.get("foul-penalty", "team", "§cRotes Team")); + for (UUID uuid : getAllAndSpectators()) { + Player p = Bukkit.getPlayer(uuid); + if (p != null) p.sendTitle("§c⚠ ELFMETER!", "§cRotes Team§7 schießt!", 5, 50, 10); + } + penaltyKick = true; + startFreekick(Team.RED, arena.getBallSpawn(), "Elfmeter"); + } + + if (!penaltyKick) { + startFreekick(victimTeam, foulLocation, Messages.get("foul", "player", fouler.getName())); + } } private void giveYellowCard(Player player, String reason) { @@ -1033,6 +1171,7 @@ public class Game { broadcastAll(Messages.get("yellow-card-2", "player", player.getName())); player.sendTitle("§e🟨→§c🟥", "§7Gelb-Rot Karte!", 5, 60, 10); logMatchEvent("§e🟨§c🟥 §7Gelb-Rot: §e" + player.getName()); + addInjuryTime(plugin.getConfig().getInt("gameplay.injury-time-per-card", 15)); new BukkitRunnable() { public void run() { disqualifyPlayer(player); } }.runTaskLater(plugin, 60L); } else { broadcastAll(Messages.get("yellow-card", "player", player.getName(), "reason", reason)); @@ -1049,6 +1188,7 @@ public class Game { player.sendTitle(Messages.get("red-card-title"), Messages.get("red-card-sub"), 5, 80, 10); player.playSound(player.getLocation(), Sound.ENTITY_LIGHTNING_BOLT_THUNDER, 1f, 1f); logMatchEvent("§c🟥 §7Rot: §e" + player.getName() + " §8(" + reason + ")"); + addInjuryTime(plugin.getConfig().getInt("gameplay.injury-time-per-card", 15)); new BukkitRunnable() { public void run() { disqualifyPlayer(player); } }.runTaskLater(plugin, 80L); } @@ -1123,6 +1263,62 @@ public class Game { } } + // ════════════════════════════════════════════════════════════════════════ + // KOPFBALL + // ════════════════════════════════════════════════════════════════════════ + + /** + * 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, + * kein aktiver Kopfball-Cooldown. + */ + private void checkHeaderOpportunities() { + if (ball == null || !ball.isActive() || ball.isHeld()) return; + if (state != GameState.RUNNING && state != GameState.OVERTIME) return; + Location ballLoc = ball.getEntity() != null ? ball.getEntity().getLocation() : null; + if (ballLoc == null) return; + + double headerRange = plugin.getConfig().getDouble("gameplay.header-range", 1.8); + double minHeight = plugin.getConfig().getDouble("gameplay.header-min-height", 0.8); + double maxHeight = plugin.getConfig().getDouble("gameplay.header-max-height", 2.3); + int cooldownTicks = plugin.getConfig().getInt("gameplay.header-cooldown", 10); + + for (UUID uuid : allPlayers) { + if (headerCooldowns.containsKey(uuid)) continue; // Abklingzeit läuft noch + Player p = Bukkit.getPlayer(uuid); if (p == null) continue; + + // Spieler muss in der Luft sein (nicht auf dem Boden) + if (p.isOnGround()) continue; + + Location pLoc = p.getLocation(); + if (!pLoc.getWorld().equals(ballLoc.getWorld())) continue; + + double dist = pLoc.distance(ballLoc); + if (dist > headerRange) continue; + + // Ball muss sich auf Kopfhöhe befinden + double relHeight = ballLoc.getY() - pLoc.getY(); + if (relHeight < minHeight || relHeight > maxHeight) continue; + + // Einwurf-Prüfung + if (throwInTeam != null && getTeam(p) != throwInTeam) continue; + clearThrowIn(); + + setLastKickerHeader(uuid); + ball.header(p); + headerCooldowns.put(uuid, cooldownTicks); + + // Kurze Broadcast-Meldung + String msg = Messages.get("header", "player", p.getName()); + for (UUID u : getAllAndSpectators()) { + Player pl = Bukkit.getPlayer(u); + if (pl != null) pl.spigot().sendMessage(net.md_5.bungee.api.ChatMessageType.ACTION_BAR, + net.md_5.bungee.api.chat.TextComponent.fromLegacyText(msg)); + } + break; // pro Tick max. 1 Kopfball + } + } + private double getDistanceOutsideField(Location loc) { if (arena.getFieldMin() == null || arena.getFieldMax() == null) return 0; double minX = Math.min(arena.getFieldMin().getX(), arena.getFieldMax().getX()); @@ -1385,12 +1581,17 @@ public class Game { public boolean isInGame(Player player) { return allPlayers.contains(player.getUniqueId()); } public void setLastKicker(UUID uuid) { + // Zweite Berührung tracken (für Assist-Erkennung) + if (lastKicker != null && !lastKicker.equals(uuid)) { + secondLastKicker = lastKicker; + } this.lastKicker = uuid; + lastKickWasHeader = false; // normaler Schuss / Berührung Player p = Bukkit.getPlayer(uuid); if (p != null) lastTouchTeam = getTeam(p); if (p != null) kicks.merge(uuid, 1, Integer::sum); - // Abseits-Check (nur wenn aktiviert und Spiel läuft) + // Abseits-Check if (plugin.getConfig().getBoolean("gameplay.offside-enabled", true) && (state == GameState.RUNNING || state == GameState.OVERTIME) && ball != null && ball.getEntity() != null && !offsideCooldown) { @@ -1398,6 +1599,31 @@ public class Game { } } + /** Wie setLastKicker, aber als Kopfball – setzt header-Flag (Rückpass erlaubt) */ + public void setLastKickerHeader(UUID uuid) { + if (lastKicker != null && !lastKicker.equals(uuid)) { + secondLastKicker = lastKicker; + } + this.lastKicker = uuid; + lastKickWasHeader = true; + Player p = Bukkit.getPlayer(uuid); + if (p != null) lastTouchTeam = getTeam(p); + if (p != null) kicks.merge(uuid, 1, Integer::sum); + + if (plugin.getConfig().getBoolean("gameplay.offside-enabled", true) + && (state == GameState.RUNNING || state == GameState.OVERTIME) + && ball != null && ball.getEntity() != null && !offsideCooldown) { + checkOffside(uuid, ball.getEntity().getLocation()); + } + } + + /** Fügt Nachspielzeit hinzu (aufgerufen bei Toren, Karten, Fouls, Aus-Situationen) */ + public void addInjuryTime(int seconds) { + if (!plugin.getConfig().getBoolean("gameplay.injury-time-enabled", true)) return; + if (state != GameState.RUNNING && state != GameState.OVERTIME) return; + injuryTimeBuffer += seconds; + } + public Arena getArena() { return arena; } public GameState getState() { return state; } public List getRedTeam() { return redTeam; } @@ -1414,6 +1640,10 @@ public class Game { public int getPenaltyBlueGoals() { return penaltyBlueGoals; } /** Gibt zurück welches Team gerade Einwurf/Ecke/Abstoß hat (null = jeder darf) */ public Team getThrowInTeam() { return throwInTeam; } + /** UUID des zuletzt schießenden Spielers */ + public UUID getLastKicker() { return lastKicker; } + /** War die letzte Ballaktion ein Kopfball (Rückpass via Kopf ist erlaubt) */ + public boolean isLastKickWasHeader() { return lastKickWasHeader; } /** Berechtigung aufheben – wird von BallListener nach dem ersten Schuss gerufen */ public void clearThrowIn() { throwInTeam = null; diff --git a/src/main/java/de/fussball/plugin/game/GameManager.java b/src/main/java/de/fussball/plugin/game/GameManager.java index c7b99c2..6a10d40 100644 --- a/src/main/java/de/fussball/plugin/game/GameManager.java +++ b/src/main/java/de/fussball/plugin/game/GameManager.java @@ -118,14 +118,20 @@ public class GameManager { org.bukkit.scheduler.BukkitRunnable task = new org.bukkit.scheduler.BukkitRunnable() { public void run() { Queue q = queues.get(key); - if (q == null || q.isEmpty()) return; - UUID next = q.poll(); - if (next == null) return; - Player p = Bukkit.getPlayer(next); - if (p == null || !p.isOnline()) { run(); return; } // überspringe Offline-Spieler - if (isInAnyGame(p)) return; - p.sendMessage(MessageUtil.success("§e⚽ Dein Platz in §e" + arenaName + " §aist frei! Du wirst hinzugefügt...")); - createGame(arena).addPlayer(p); + if (q == null) return; + // BUG FIX: vorher war hier ein rekursiver run()-Aufruf für Offline-Spieler, + // was bei vielen Offline-Einträgen zu einem StackOverflow führte. + // Jetzt iterativ: alle Offline-Spieler überspringen. + while (!q.isEmpty()) { + UUID next = q.poll(); + if (next == null) return; + Player p = Bukkit.getPlayer(next); + if (p == null || !p.isOnline()) continue; // Offline überspringen + if (isInAnyGame(p)) return; + p.sendMessage(MessageUtil.success("§e⚽ Dein Platz in §e" + arenaName + " §aist frei! Du wirst hinzugefügt...")); + createGame(arena).addPlayer(p); + return; + } } }; task.runTaskLater(plugin, 120L); // 6s nach Spielende diff --git a/src/main/java/de/fussball/plugin/listeners/BallListener.java b/src/main/java/de/fussball/plugin/listeners/BallListener.java index 3f3b220..cd6c12b 100644 --- a/src/main/java/de/fussball/plugin/listeners/BallListener.java +++ b/src/main/java/de/fussball/plugin/listeners/BallListener.java @@ -7,6 +7,7 @@ import de.fussball.plugin.game.GameState; import de.fussball.plugin.game.Team; import net.md_5.bungee.api.ChatMessageType; import net.md_5.bungee.api.chat.TextComponent; +import org.bukkit.Bukkit; import org.bukkit.entity.*; import org.bukkit.event.EventHandler; import org.bukkit.event.EventPriority; @@ -19,6 +20,7 @@ import org.bukkit.scheduler.BukkitRunnable; import java.util.HashMap; import java.util.Map; import java.util.UUID; +import java.util.UUID; /** * Verwaltet Ball-Interaktionen: @@ -62,21 +64,40 @@ public class BallListener implements Listener { // ── Torwart greift Ball (noch nicht haltend) ──────────────────────── if (game.isGoalkeeper(player) && !player.isSneaking()) { if (!plugin.getConfig().getBoolean("gameplay.foul-detection-enabled", true)) { - // Falls Torwart-Mechanik in Config deaktiviert → normaler Schuss + // Torwart-Mechanik deaktiviert → normaler Schuss } else if (ball.getDistanceTo(player) <= plugin.getConfig().getDouble("gameplay.goalkeeper-hold-range", 2.5)) { if (game.isInOwnHalf(player)) { if (game.getThrowInTeam() == null || game.getThrowInTeam() == game.getTeam(player)) { + + // ── RÜCKPASS-REGEL ────────────────────────────────────────────────── + // Torwart darf Ball NICHT mit Händen nehmen wenn ihn ein Mitspieler + // direkt mit dem Fuß zugespielt hat. Kopfball-Rückpässe sind erlaubt. + UUID prevKicker = game.getLastKicker(); + if (prevKicker != null && !prevKicker.equals(player.getUniqueId())) { + Player prev = Bukkit.getPlayer(prevKicker); + if (prev != null + && game.getTeam(prev) == game.getTeam(player) + && !game.isLastKickWasHeader()) { + player.sendTitle(plugin.getConfig().getString("messages.backpass-title", "§c⚠ RÜCKPASS!"), + "§7Direktes Anspielen mit dem Fuß verboten!", 5, 40, 10); + player.sendMessage(plugin.getConfig().getString("messages.backpass", + "§c⚠ §lRÜCKPASS! §7Torwart darf den Ball nicht mit Händen nehmen!")); + return; // Kein Halten, kein Schuss — Ball rollt weiter + } + } + // ──────────────────────────────────────────────────────────────────── + game.clearThrowIn(); game.setLastKicker(player.getUniqueId()); - ball.holdBall(player); - game.broadcastAll(plugin.getConfig().getString("messages.goalkeeper-hold", - "§6TW §f{player} §7hält den Ball!").replace("{player}", player.getName())); - return; + if (ball.holdBall(player)) { + game.broadcastAll(plugin.getConfig().getString("messages.goalkeeper-hold", + "§6TW §f{player} §7hält den Ball!").replace("{player}", player.getName())); + return; + } + // holdBall fehlgeschlagen (zu weit vom Tor) → normaler Schuss } } else { - player.sendMessage(plugin.getConfig().getString("messages.goalkeeper-no-hold", - "§cDu kannst den Ball nur in deiner eigenen Hälfte halten!")); - // Fällt durch zum normalen Schuss + // TW in gegnerischer Hälfte → normaler Schuss ohne Meldung } } } diff --git a/src/main/resources/config.yml b/src/main/resources/config.yml index 654ba3f..4579c5a 100644 --- a/src/main/resources/config.yml +++ b/src/main/resources/config.yml @@ -24,6 +24,24 @@ gameplay: out-of-bounds-tolerance: 2.0 # Toleranz außerhalb Spielfeld (Blöcke) out-of-bounds-countdown: 5 # Sekunden bis Disqualifikation + # ── Strafraum ──────────────────────────────────────────────────────────── + penalty-area-depth: 16 # Tiefe des automatisch berechneten Strafraums (Blöcke Richtung Feld) + penalty-area-margin: 6 # Seitliche Ausdehnung des Strafraums jenseits der Torpfosten + + # ── Kopfball ───────────────────────────────────────────────────────────── + header-range: 1.8 # Max. Abstand Spieler→Ball für Kopfball (Blöcke) + header-min-height: 0.8 # Min. Höhe des Balls über Spielerfüßen für Kopfball + header-max-height: 2.3 # Max. Höhe des Balls über Spielerfüßen für Kopfball + header-power: 1.3 # Schussstärke eines Kopfballs + header-cooldown: 10 # Ticks Abklingzeit zwischen zwei Kopfbällen desselben Spielers + + # ── Nachspielzeit ──────────────────────────────────────────────────────── + injury-time-enabled: true + injury-time-per-goal: 30 # Sekunden Nachspielzeit pro Tor + injury-time-per-card: 15 # Sekunden pro Karte (Gelb-Rot / Rot) + injury-time-per-foul: 5 # Sekunden pro Foul + injury-time-per-out: 3 # Sekunden pro Aus-Situation + # ── Nachrichten (alle editierbar) ────────────────── # Verfügbare Platzhalter je nach Kontext: # {player} = Spielername @@ -102,6 +120,28 @@ messages: boundary-disq: "§c⚠ §e{player} §cwurde disqualifiziert! (Spielfeldgrenze)" boundary-disq-self: "§cDu wurdest disqualifiziert, weil du zu lange außerhalb warst!" + # Eigentore & Assists + own-goal: "§c⚽ EIGENTOR! §7{player} hat ins eigene Tor getroffen!" + own-goal-title: "§c⚽ EIGENTOR!" + assist: "§7Vorlage: §e{player}" + + # Nachspielzeit + injury-time: "§c⏱ §l+{n} Min. Nachspielzeit!" + injury-time-bar: "§c+{n}' §8│ " + + # Anstoß + kickoff-team: "§e⚽ §7Anstoß für {team}§7!" + + # Strafraum / Elfmeter bei Foul + foul-penalty: "§c⚠ §lFOUL IM STRAFRAUM! §7Elfmeter für {team}§7!" + + # Rückpass + backpass: "§c⚠ §lRÜCKPASS! §7Torwart darf den Ball nicht mit Händen nehmen!" + backpass-title: "§c⚠ RÜCKPASS!" + + # Kopfball + header: "§e⚽ §7Kopfball von §e{player}§7!" + # Spieler beitreten / verlassen player-join: "§e{player} §7ist beigetreten! §8({n}/{max})" player-leave: "§e{player} §7hat das Spiel verlassen!"