package me.sashegdev.blackknife; import net.kyori.adventure.text.Component; import net.kyori.adventure.text.format.NamedTextColor; import org.bukkit.GameMode; import org.bukkit.Location; import org.bukkit.Particle; import org.bukkit.Sound; import org.bukkit.entity.LivingEntity; import org.bukkit.entity.Player; import org.bukkit.event.EventHandler; import org.bukkit.event.EventPriority; import org.bukkit.event.Listener; import org.bukkit.event.entity.EntityDamageEvent; import org.bukkit.event.entity.PlayerDeathEvent; import org.bukkit.event.player.PlayerMoveEvent; import org.bukkit.event.player.PlayerQuitEvent; import org.bukkit.event.player.PlayerRespawnEvent; import org.bukkit.event.player.PlayerJoinEvent; import org.bukkit.scheduler.BukkitRunnable; import org.bukkit.util.Vector; import java.util.Map; import java.util.UUID; import java.util.concurrent.ConcurrentHashMap; /** * DIVE — взлёт, зависание и пикирование. * *

Раньше гравитация постоянно воевала с {@code setVelocity(0)} каждый тик, а * апекс искался тремя перекрывающимися эвристиками. Здесь гравитация на время * способности выключена через {@code setGravity(false)} — флаг уходит клиентам, * поэтому и сервер, и клиент перестают её считать, и зависание становится * естественным состоянием, а не борьбой с предсказанием движения. * *

Состояния ведёт один автомат с единственным владельцем: любой выход — * урон, стена, релог, смерть, респавн — проходит через {@link #restore}, * поэтому игрок не может остаться с нулевой гравитацией навсегда. */ public class SkyDiveAttack implements Listener { private static final float COST = 87.5f; private static final double DAMAGE = 25.0; /** Кулдаун ножа на всё пике. Возвращается, если игрок вышел на середине. */ public static final int ITEM_COOLDOWN_TICKS = 120; private static final double LAUNCH_UP = 1.6; private static final double LAUNCH_FORWARD = 0.4; /** Собственная гравитация: 0.08 блока/тик^2, как в ванилле. */ private static final double GRAVITY = 0.08; private static final double TARGET_RISE = 6.0; private static final int ASCENT_TIMEOUT = 30; private static final long HOVER_TICKS = 30L; private static final double DASH_SPEED = 3.0; private static final int DASH_TIMEOUT = 60; private static final int GROUND_ABORT_TICKS = 3; private static final long LAND_GRACE_MS = 2500L; private enum State { IDLE, ASCENT, HOVER, DASH } private static final Map STATE = new ConcurrentHashMap<>(); private static final Map HOVER_ANCHOR = new ConcurrentHashMap<>(); private static final Map GRACE_UNTIL = new ConcurrentHashMap<>(); private static final Map DIVE_START = new ConcurrentHashMap<>(); private static final Map COOLDOWN_ITEM = new ConcurrentHashMap<>(); private final BlackKnifePlugin plugin; public SkyDiveAttack(BlackKnifePlugin plugin) { this.plugin = plugin; } public static boolean isDiving(Player p) { return STATE.get(p.getUniqueId()) != null; } public static boolean tryUse(BlackKnifePlugin plugin, Player p, TensionManager tension) { final SkyDiveAttack dive = plugin.getDiveAttack(); if (dive == null) return false; return dive.start(p, tension); } private boolean start(Player p, TensionManager tension) { if (p.getGameMode() == GameMode.SPECTATOR) return false; if (isDiving(p)) { p.sendActionBar(Component.text("Already diving!").color(NamedTextColor.RED)); return false; } if (tension.getTP(p) < COST) { p.sendActionBar(Component.text("Need 35% TP").color(NamedTextColor.RED)); return false; } tension.setTP(p, tension.getTP(p) - COST); // Гравитация выключена на всю способность: дужение считаем сами, // апекс получается аналитически, без опроса velocity. p.setGravity(false); STATE.put(p.getUniqueId(), State.ASCENT); DIVE_START.put(p.getUniqueId(), System.currentTimeMillis()); COOLDOWN_ITEM.put(p.getUniqueId(), p.getInventory().getItemInMainHand().getType()); final Vector look = p.getEyeLocation().getDirection(); Vector horizontal = new Vector(look.getX(), 0, look.getZ()); if (horizontal.lengthSquared() > 1.0E-6) { horizontal.normalize().multiply(LAUNCH_FORWARD); } else { horizontal = new Vector(0, 0, 0); } p.setVelocity(new Vector(horizontal.getX(), LAUNCH_UP, horizontal.getZ())); p.setFallDistance(0); p.getWorld().playSound(p.getLocation(), Sound.ENTITY_FIREWORK_ROCKET_LAUNCH, 1f, 0.8f); p.getWorld().spawnParticle(Particle.CLOUD, p.getLocation(), 10, 0.3, 0.2, 0.3, 0.05); p.sendActionBar(Component.text("SkyDive!").color(NamedTextColor.AQUA)); new BukkitRunnable() { final Location start = p.getLocation().clone(); int ticks = 0; @Override public void run() { if (done(p)) { cancel(); return; } ticks++; p.setFallDistance(0); final Vector v = p.getVelocity(); // Своё дужение вместо ванильного, чтобы апекс был предсказуем if (v.getY() > 0.0) { p.setVelocity(new Vector(v.getX(), v.getY() - GRAVITY, v.getZ())); } final double risen = p.getLocation().getY() - start.getY(); final boolean apex = v.getY() <= 0.05 || risen >= TARGET_RISE || ticks >= ASCENT_TIMEOUT; if (apex) { cancel(); beginHover(p); } } }.runTaskTimer(plugin, 1L, 1L); return true; } private void beginHover(Player p) { if (done(p)) return; STATE.put(p.getUniqueId(), State.HOVER); p.setVelocity(new Vector(0, 0, 0)); p.setFallDistance(0); final Location top = p.getLocation().clone(); HOVER_ANCHOR.put(p.getUniqueId(), top); p.getWorld().playSound(top, Sound.BLOCK_BEACON_ACTIVATE, 0.8f, 1.4f); p.sendActionBar(Component.text("Aim with your camera!").color(NamedTextColor.AQUA)); new BukkitRunnable() { long left = HOVER_TICKS; @Override public void run() { if (done(p)) { cancel(); return; } p.setVelocity(new Vector(0, 0, 0)); p.setFallDistance(0); final Location anchor = HOVER_ANCHOR.get(p.getUniqueId()); if (anchor == null) { cancel(); return; } for (int k = 0; k < 10; k++) { final double a = (double) k / 10 * Math.PI * 2; final Location ring = anchor.clone().add(Math.cos(a) * 1.2, 0.1, Math.sin(a) * 1.2); p.getWorld().spawnParticle(Particle.DUST, ring, 1, 0, 0, 0, 0, new Particle.DustOptions(org.bukkit.Color.fromRGB(80, 160, 255), 1.0f)); } if (--left <= 0) { cancel(); beginDash(p); } } }.runTaskTimer(plugin, 1L, 1L); } private void beginDash(Player p) { if (done(p)) return; STATE.put(p.getUniqueId(), State.DASH); HOVER_ANCHOR.remove(p.getUniqueId()); final Vector dir = p.getEyeLocation().getDirection().normalize(); p.setVelocity(dir.clone().multiply(DASH_SPEED)); p.setFallDistance(0); p.getWorld().playSound(p.getLocation(), Sound.ENTITY_ENDER_DRAGON_FLAP, 1f, 1.2f); p.sendActionBar(Component.text("DIVE!").color(NamedTextColor.DARK_RED)); new BukkitRunnable() { int ticks = 0; int groundTicks = 0; boolean hit = false; Location prev = p.getLocation().add(0, 1, 0).clone(); @Override public void run() { if (done(p)) { cancel(); return; } p.setFallDistance(0); ticks++; final Location cur = p.getLocation().add(0, 1, 0); cur.getWorld().spawnParticle(Particle.DUST, cur, 2, 0.15, 0.15, 0.15, 0, new Particle.DustOptions(org.bukkit.Color.fromRGB(80, 160, 255), 1.4f)); cur.getWorld().spawnParticle(Particle.CLOUD, cur, 1, 0.1, 0.1, 0.1, 0.02); // Касание земли отменяет урон: иначе можно было промахнуться, // добежать к цели и получить урон на земле. if (p.isOnGround() || p.getLocation().getBlock().isLiquid()) { if (++groundTicks >= GROUND_ABORT_TICKS) { land(p, false); cancel(); return; } } else { groundTicks = 0; } if (!hit) { hit = scanSegment(p, dir, prev, cur); } prev = cur.clone(); if (hit) { land(p, true); cancel(); return; } if (p.getEyeLocation().add(dir.clone().multiply(1.0)).getBlock().isSolid()) { land(p, false); cancel(); return; } if (ticks > DASH_TIMEOUT) { land(p, false); cancel(); return; } } }.runTaskTimer(plugin, 1L, 1L); } private boolean scanSegment(Player p, Vector dir, Location from, Location to) { final Vector seg = to.clone().subtract(from).toVector(); final int steps = Math.max(1, (int) Math.ceil(seg.length() / 0.5)); for (int s = 0; s <= steps; s++) { final Location check = from.clone().add(seg.clone().multiply((double) s / steps)); for (LivingEntity le : check.getWorld().getNearbyLivingEntities(check, 1.2)) { if (le == p) continue; if (!DamageUtil.canHurt(p, le)) continue; DamageUtil.apply(p, le, DAMAGE); final Vector kb = dir.clone().multiply(1.2); kb.setY(kb.getY() + 0.4); le.setVelocity(kb); le.getWorld().spawnParticle(Particle.CRIT, le.getLocation().add(0, 1, 0), 12, 0.4, 0.4, 0.4, 0.1); le.getWorld().playSound(le.getLocation(), Sound.ENTITY_PLAYER_ATTACK_CRIT, 1f, 0.8f); plugin.getTension().startBattle(p, le); return true; } } return false; } private void land(Player p, boolean hit) { if (hit) { p.getWorld().playSound(p.getLocation(), Sound.ENTITY_PLAYER_ATTACK_CRIT, 1f, 0.7f); } restore(p); } /** Единственная точка выхода: снимает состояние и возвращает гравитацию. */ private void restore(Player p) { final UUID id = p.getUniqueId(); final boolean wasDiving = STATE.remove(id) != null; HOVER_ANCHOR.remove(id); DIVE_START.remove(id); COOLDOWN_ITEM.remove(id); if (wasDiving) { try { p.setGravity(true); } catch (Exception ignored) { } p.setFallDistance(0); GRACE_UNTIL.put(id, System.currentTimeMillis() + LAND_GRACE_MS); } } /** * Выход с возвратом кулдауна: игрок закрыл сервер или умер на середине * пике и не получил ни урона, ни управления — сгорать две секунды за * прерванное действие незачем. */ private void abortWithRefund(Player p) { final UUID id = p.getUniqueId(); if (STATE.get(id) == null) { return; } final Long start = DIVE_START.get(id); final org.bukkit.Material item = COOLDOWN_ITEM.get(id); if (start != null && item != null) { final long elapsed = System.currentTimeMillis() - start; final int left = (int) Math.ceil(ITEM_COOLDOWN_TICKS - elapsed / 50L); if (left > 0) { try { p.setCooldown(item, left); } catch (Exception ignored) { } } } restore(p); } /** @return true, если состояние сброшено и таск должен завершиться. */ private boolean done(Player p) { if (p.isOnline() && !p.isDead() && isDiving(p)) { return false; } if (p.isOnline()) { restore(p); } return true; } @EventHandler(priority = EventPriority.HIGHEST, ignoreCancelled = true) public void onFall(EntityDamageEvent e) { if (e.getCause() != EntityDamageEvent.DamageCause.FALL) return; if (!(e.getEntity() instanceof Player p)) return; final UUID id = p.getUniqueId(); final Long grace = GRACE_UNTIL.get(id); if (isDiving(p) || (grace != null && System.currentTimeMillis() < grace)) { e.setCancelled(true); p.setFallDistance(0); } } /** Держим позицию, но камеру не трогаем: меняться может только положение тела. */ @EventHandler(priority = EventPriority.HIGH) public void onMove(PlayerMoveEvent e) { final Player p = e.getPlayer(); final Location anchor = HOVER_ANCHOR.get(p.getUniqueId()); if (anchor == null) return; final Location to = e.getTo(); if (to == null) return; final double dx = to.getX() - anchor.getX(); final double dy = to.getY() - anchor.getY(); final double dz = to.getZ() - anchor.getZ(); if (Math.abs(dx) > 0.05 || Math.abs(dy) > 0.05 || Math.abs(dz) > 0.05) { final Location fixed = anchor.clone(); fixed.setYaw(to.getYaw()); fixed.setPitch(to.getPitch()); e.setTo(fixed); } } @EventHandler public void onQuit(PlayerQuitEvent e) { final UUID id = e.getPlayer().getUniqueId(); abortWithRefund(e.getPlayer()); GRACE_UNTIL.remove(id); } @EventHandler public void onJoin(PlayerJoinEvent e) { // Релог во время пике: состояние не восстанавливаем, но и гравитацию // гарантированно возвращаем, чтобы клиент не остался в подвешенном виде. final Player p = e.getPlayer(); if (STATE.remove(p.getUniqueId()) != null) { try { p.setGravity(true); } catch (Exception ignored) {} } HOVER_ANCHOR.remove(p.getUniqueId()); } @EventHandler public void onDeath(PlayerDeathEvent e) { restore(e.getEntity()); } @EventHandler public void onRespawn(PlayerRespawnEvent e) { final Player p = e.getPlayer(); if (STATE.remove(p.getUniqueId()) != null) { try { p.setGravity(true); } catch (Exception ignored) {} } HOVER_ANCHOR.remove(p.getUniqueId()); } }