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());
}
}