package me.sashegdev.blackknife; import net.kyori.adventure.text.Component; import net.kyori.adventure.text.format.NamedTextColor; import org.bukkit.Color; import org.bukkit.Location; import org.bukkit.Material; import org.bukkit.Particle; import org.bukkit.Sound; import org.bukkit.entity.BlockDisplay; import org.bukkit.entity.Display; import org.bukkit.entity.LivingEntity; import org.bukkit.entity.Player; import org.bukkit.scheduler.BukkitRunnable; import org.bukkit.util.Vector; import java.util.ArrayDeque; import java.util.Deque; public class GrenadeAttack { private static final float COST = 25f; private static final double LETHAL_RADIUS = 1.5; private static final double CHIP_RADIUS = 3.0; private static final double LETHAL_DAMAGE = 35.0; private static final double CHIP_DAMAGE = 5.5; private static final double MAX_RANGE = 15.0; private static final double TICKS_PER_BLOCK = 2.0; private static final int MIN_DURATION = 8; private static final double ARC_MIN = 1.5; private static final double ARC_PER_BLOCK = 0.25; private static final double ARC_MAX = 4.0; public static boolean tryUse(Player p, TensionManager tension) { if (tension.getTP(p) < COST) { p.sendActionBar(Component.text("Need 10% TP").color(NamedTextColor.RED)); return false; } tension.setTP(p, tension.getTP(p) - COST); Location eye = p.getEyeLocation(); Vector dir = eye.getDirection().normalize(); Location target = computeTarget(p, eye, dir); double dist = eye.distance(target); int duration = Math.max(MIN_DURATION, (int) (dist * TICKS_PER_BLOCK)); double arcH = Math.max(ARC_MIN, Math.min(ARC_MAX, dist * ARC_PER_BLOCK)); Location spawn = eye.clone().add(dir.clone().multiply(1)).add(0, 0.2, 0); Vector throwDir = target.clone().subtract(spawn).toVector().normalize(); BlockDisplay nade = p.getWorld().spawn(spawn, BlockDisplay.class, d -> { d.setBlock(Material.TNT.createBlockData()); d.setBrightness(new Display.Brightness(15, 15)); d.setViewRange(64); d.setTransformation(new org.bukkit.util.Transformation( new org.joml.Vector3f(-0.25f, -0.25f, -0.25f), new org.joml.AxisAngle4f(), new org.joml.Vector3f(0.5f, 0.5f, 0.5f), new org.joml.AxisAngle4f())); }); EntityTracker.mark(nade); EntityTracker.smooth(nade); p.getWorld().playSound(spawn, Sound.ENTITY_SNOWBALL_THROW, 1f, 0.7f); BlackKnifePlugin plugin = BlackKnifePlugin.getPlugin(BlackKnifePlugin.class); new BukkitRunnable() { Location flightStart = spawn.clone(); Location flightTarget = target.clone(); Vector flightDir = throwDir.clone(); int flightDuration = duration; // Предсказание: кривая полёта параметризована временем, поэтому просто // начинаем отсчёт чуть позже. Траектория та же, граната летит на lead // тиков короче и клиент не отстаёт. Половина полёта — потолок, // иначе короткий бросок при lead 10 приземлился бы мгновенно. int elapsed = Math.min(EntityTracker.leadTicks(), Math.max(0, flightDuration / 2)); int tick = 0; double arc = arcH; boolean targetWasSolid = flightTarget.getBlock().isSolid(); final Deque trail = new ArrayDeque<>(); @Override public void run() { if (!p.isOnline() || p.isDead() || !nade.isValid()) { EntityTracker.untrack(nade); if (nade.isValid()) nade.remove(); cancel(); return; } tick++; elapsed++; if (tick % 3 == 0 && needsRetarget(nade.getLocation())) { retarget(nade.getLocation()); } double t = Math.min(1.0, (double) elapsed / flightDuration); Location base = flightStart.clone().add(flightTarget.clone().subtract(flightStart).toVector().multiply(t)); base.add(0, Math.sin(Math.PI * t) * arc, 0); nade.teleport(base); base.getWorld().spawnParticle(Particle.SMOKE, base, 1, 0.05, 0.05, 0.05, 0); trail.addLast(base.clone()); while (trail.size() > 12) trail.pollFirst(); renderTrail(); if (tick % 2 == 0) renderRings(flightTarget); boolean landed = t >= 1.0 || base.getBlock().isSolid(); if (landed || nade.getTicksLived() > 100) { Location expl = landed ? flightTarget.clone() : base.clone(); EntityTracker.untrack(nade); nade.remove(); cancel(); explode(p, expl, plugin); } } private boolean needsRetarget(Location cur) { boolean nowSolid = flightTarget.getBlock().isSolid(); if (nowSolid != targetWasSolid) return true; Vector toTarget = flightTarget.clone().subtract(cur).toVector(); double remaining = toTarget.length(); if (remaining < 0.5) return false; var ray = cur.getWorld().rayTraceBlocks(cur, toTarget.normalize(), remaining); if (ray != null && ray.getHitBlock() != null) { double hitDist = ray.getHitBlock().getLocation().distance(cur); return hitDist + 0.5 < remaining; } return false; } private void retarget(Location cur) { var ray = cur.getWorld().rayTraceBlocks(cur, flightDir, MAX_RANGE); Location nt; if (ray != null && ray.getHitPosition() != null) { nt = new Location(cur.getWorld(), ray.getHitPosition().getX(), ray.getHitPosition().getY(), ray.getHitPosition().getZ()); if (ray.getHitBlockFace() != null) nt.add(0, 0.1, 0); } else { nt = cur.clone().add(flightDir.clone().multiply(12)); nt.setY(cur.getWorld().getHighestBlockYAt(nt) + 1); } flightStart = cur.clone(); flightTarget = nt.clone(); double remaining = Math.max(1.0, cur.distance(nt)); flightDuration = Math.max(6, (int) (remaining * TICKS_PER_BLOCK)); elapsed = 0; arc = Math.max(ARC_MIN, Math.min(ARC_MAX, remaining * ARC_PER_BLOCK)); targetWasSolid = flightTarget.getBlock().isSolid(); } private void renderTrail() { int size = trail.size(); int i = 0; for (Location tp : trail) { float frac = size <= 1 ? 1f : (float) i / (size - 1); float dustSize = 0.3f + frac * 1.1f; p.spawnParticle(Particle.DUST, tp, 1, 0.02, 0.02, 0.02, 0, new Particle.DustOptions(Color.fromRGB(180, 120, 40), dustSize)); i++; } } private void renderRings(Location ground) { Location g = ground.clone().add(0, 0.15, 0); for (int k = 0; k < 16; k++) { double a = (double) k / 16 * Math.PI * 2; Location ring = g.clone().add(Math.cos(a) * LETHAL_RADIUS, 0, Math.sin(a) * LETHAL_RADIUS); p.spawnParticle(Particle.DUST, ring, 1, 0, 0, 0, 0, Particles.RED_12); } for (int k = 0; k < 24; k++) { double a = (double) k / 24 * Math.PI * 2; Location ring = g.clone().add(Math.cos(a) * CHIP_RADIUS, 0, Math.sin(a) * CHIP_RADIUS); p.spawnParticle(Particle.DUST, ring, 1, 0, 0, 0, 0, Particles.GOLD_10); } } }.runTaskTimer(plugin, 1L, 1L); return true; } public static Location computeTarget(Player p, Location eye, Vector dir) { var ray = p.getWorld().rayTraceBlocks(eye, dir, MAX_RANGE); if (ray != null && ray.getHitPosition() != null) { Location loc = new Location(p.getWorld(), ray.getHitPosition().getX(), ray.getHitPosition().getY(), ray.getHitPosition().getZ()); if (ray.getHitBlockFace() != null) loc.add(0, 0.1, 0); return loc; } Location fallback = eye.clone().add(dir.clone().multiply(MAX_RANGE)); fallback.setY(p.getWorld().getHighestBlockYAt(fallback) + 1); return fallback; } private static void explode(Player p, Location expl, BlackKnifePlugin plugin) { expl.getWorld().spawnParticle(Particle.EXPLOSION, expl, 2); expl.getWorld().spawnParticle(Particle.SMOKE, expl, 12, 0.3, 0.3, 0.3, 0.05); expl.getWorld().spawnParticle(Particle.DUST, expl, 10, 0.3, 0.2, 0.3, 0, Particles.ORANGE_12); expl.getWorld().playSound(expl, Sound.ENTITY_GENERIC_EXPLODE, 1f, 1.1f); TensionManager tm = plugin.getTension(); for (LivingEntity le : expl.getWorld().getNearbyLivingEntities(expl, CHIP_RADIUS + 0.5)) { if (le == p) continue; if (!DamageUtil.canHurt(p, le)) continue; double d = le.getLocation().add(0, 1, 0).distance(expl); if (d > CHIP_RADIUS) continue; if (d <= LETHAL_RADIUS) { DamageUtil.apply(p, le, LETHAL_DAMAGE); tm.startBattle(p, le); } else { DamageUtil.apply(p, le, CHIP_DAMAGE); tm.startBattle(p, le); } le.getWorld().spawnParticle(Particle.CRIT, le.getLocation().add(0, 1, 0), 8, 0.3, 0.3, 0.3, 0.1); } for (Player pl : expl.getWorld().getNearbyPlayers(expl, 0.9)) { if (pl == p) continue; double d2 = pl.getLocation().add(0, 1, 0).distanceSquared(expl); if (d2 > 0.25 && d2 < 0.81) tm.tryGiveDodge(pl); } } }