package de.fussball.plugin.game; import de.fussball.plugin.Fussball; import org.bukkit.*; import org.bukkit.entity.*; import org.bukkit.event.entity.CreatureSpawnEvent; 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; import java.net.URL; import java.util.UUID; public class Ball { private static final String BALL_TEXTURE_URL = "https://textures.minecraft.net/texture/451f8cfcfb85d77945dc6a3618414093e70436b46d2577b28c727f1329b7265e"; // ── 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; private ArmorStand entity; private final Location spawnLocation; private boolean active = false; // ── 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.spawnLocation = spawnLocation.clone(); } // ── Config-Helfer ───────────────────────────────────────────────────────── private double cfg(String path, double def) { return plugin.getConfig().getDouble(path, def); } // ── 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( spawnLocation, ArmorStand.class, CreatureSpawnEvent.SpawnReason.CUSTOM, false, stand -> { stand.setVisible(false); stand.setGravity(true); stand.setCollidable(false); stand.setInvulnerable(true); stand.setPersistent(false); stand.setSilent(true); stand.setSmall(true); stand.setArms(false); stand.setBasePlate(false); stand.setCustomName("§e⚽ Fußball"); stand.setCustomNameVisible(true); stand.getEquipment().setHelmet(createBallItem()); } ); if (entity == null) { plugin.getLogger().severe("[Fussball] ArmorStand konnte nicht gespawnt werden!"); return; } active = true; prevYVelocity = 0.0; rollAngle = 0.0; } private ItemStack createBallItem() { ItemStack skull = new ItemStack(Material.PLAYER_HEAD); SkullMeta meta = (SkullMeta) skull.getItemMeta(); if (meta == null) return skull; meta.setDisplayName("§e⚽ Fußball"); try { PlayerProfile profile = Bukkit.createPlayerProfile( UUID.nameUUIDFromBytes("FussballBall".getBytes()), "FussballBall"); PlayerTextures textures = profile.getTextures(); textures.setSkin(new URL(BALL_TEXTURE_URL)); profile.setTextures(textures); meta.setOwnerProfile(profile); } catch (MalformedURLException e) { plugin.getLogger().warning("[Fussball] Ball-Textur URL ungültig: " + e.getMessage()); } skull.setItemMeta(meta); return skull; } // ── Schüsse ─────────────────────────────────────────────────────────────── /** * 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("gameplay.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 = 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); dir.setY(kickVertical); dir.multiply(player.isSprinting() ? sprintKickPower : kickPower); applyKick(dir, ballLoc, 1.8f); } /** * 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(); // 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 mult = minPower + (maxPower - minPower) * power; dir.setY(0.22 + power * 0.28); dir.multiply(mult); float pitch = 0.9f + (float)(power * 0.9f); applyKick(dir, ballLoc, pitch); if (power > 0.7) { ballLoc.getWorld().spawnParticle(Particle.FLAME, ballLoc, 14, 0.2, 0.2, 0.2, 0.07); } } /** * 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 (jeden Tick) ─────────────────────────────────────────────────── /** * Haupt-Physik-Update – jeden Tick vom GoalCheckLoop aufgerufen (ersetzt applyFriction). * Verarbeitet: Reibung (Luft/Boden), Abprall, Wandreflektion, visuelle Rotation. */ public void applyPhysics() { if (heldByGoalkeeper) return; if (entity == null || entity.isDead() || !active) 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; } public void updateHeldPosition() { if (!heldByGoalkeeper || holdingPlayer == null || entity == null || entity.isDead()) return; Location hold = holdingPlayer.getLocation().clone(); hold.add(holdingPlayer.getLocation().getDirection().normalize().multiply(0.8)); 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)); } public void releaseBall() { this.heldByGoalkeeper = false; this.holdingPlayer = null; if (entity != null && !entity.isDead()) entity.setGravity(true); } public void throwBall(Player goalkeeper, double power) { releaseBall(); if (entity == null || entity.isDead()) return; 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); } // ── 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; } } public void remove() { if (entity != null && !entity.isDead()) entity.remove(); entity = null; active = false; } public ArmorStand getEntity() { return entity; } 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; if (!entity.getWorld().equals(player.getWorld())) return Double.MAX_VALUE; return entity.getLocation().distance(player.getLocation()); } }