Files
DeltaMine/src/main/java/me/sashegdev/blackknife/SkyDiveAttack.java
T
SashegDev 81e86fdc9f rework: SWOON rewrite, tension economy, DIVE physics, predictive displays
SWOON: virtual HP deleted. Damage now goes through DamageType.GENERIC_KILL,
which vanilla marks as bypassing armour, enchantments, effects and shields,
and which consumes no armour durability. The old mechanic cancelled the damage
outright and only regenened virtual HP back to 1, so a swoon could never be
escaped by any means.

- cost by mob max HP: clamp(mob-base-cost + mob-cost-per-hp * HP, base, max).
  Player cost is flat. Chicken 14.5 -> Zombie 22.5 -> Enderman 32.5 -> Warden 137.
- cooldown inversely proportional to TP spent: trash mob 40t, player 160t.
  Moved off Material.NETHERITE_SWORD onto a per-ability key: the material
  cooldown is a shared per-material table, so it blocked every netherite sword
  in the inventory and any inventory rebuild wiped it. New AbilityCooldown.
- death message detected statelessly from the damage type plus who holds the
  knife, so no marker state has to survive the death.
- TP is now checked BEFORE the original hit is cancelled. Cancelling first made
  every swing without enough TP a free block: no damage, no gain, no progress.
- 257 -> ~110 lines; the VHP/SWOONED checks in DamageUtil, ParryHandler,
  GrenadeAttack and SkyDiveAttack are gone with the mechanic they guarded.

Tension:
- passive regen 0.25/tick (was 2/tick, i.e. 100 TP/s) with a hard 3s pause
  after the last combat action.
- hit gain 7.5, crit 11.25 (1.5x base instead of 2x).
- onQuit clears TP. It only dropped bossbars and caches, so a quitting player
  left a full bar in memory for the rest of the uptime.
- shield gain and delay moved into config; they were the only remaining
  hardcoded economy numbers.

DIVE:
- setGravity(false) for the whole ability. Gravity fought setVelocity(0) every
  tick and the flag reaches clients, so server and client now agree and hover
  is a real state instead of a fight over movement prediction.
- own gravity constant and an analytic apex; the old code probed velocity three
  different ways.
- one state machine with a single exit, so death, wall, logout, respawn and
  damage can never leave a player with gravity off. The runnables used to
  return without cancel() and leaked a per-tick task per dive.
- ground contact for 3 ticks aborts the dive without damage. Unimplemented
  before, so you could dive into the ground, walk to the target and hit them.
- dash 3.0 blocks/tick over 60 ticks (was 2.8/40).
- hover pins X/Z only, camera stays free, no per-tick actionbar spam.
- quitting mid-dive refunds the unused knife cooldown.
- fall damage cancelled only while diving and for 2.5s after, not for the whole
  cooldown.

Displays: predictive teleport. A visual is spawned N ticks along its own
trajectory and told to interpolate the teleport over those same N ticks, so the
client never lags behind and the packet count is unchanged. Duration 1 is
effectively no smoothing, which is why the visuals snapped on real ping.
Applied to DIAMOND, DIAMOND_TRI, LUNGE (catalyst, sword, overhead, volley) and
GRENADE. lead-ticks is configurable; 0 restores stock behaviour.
2026-09-30 12:41:28 +00:00

385 lines
16 KiB
Java

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 — взлёт, зависание и пикирование.
*
* <p>Раньше гравитация постоянно воевала с {@code setVelocity(0)} каждый тик, а
* апекс искался тремя перекрывающимися эвристиками. Здесь гравитация на время
* способности выключена через {@code setGravity(false)} — флаг уходит клиентам,
* поэтому и сервер, и клиент перестают её считать, и зависание становится
* естественным состоянием, а не борьбой с предсказанием движения.
*
* <p>Состояния ведёт один автомат с единственным владельцем: любой выход —
* урон, стена, релог, смерть, респавн — проходит через {@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<UUID, State> STATE = new ConcurrentHashMap<>();
private static final Map<UUID, Location> HOVER_ANCHOR = new ConcurrentHashMap<>();
private static final Map<UUID, Long> GRACE_UNTIL = new ConcurrentHashMap<>();
private static final Map<UUID, Long> DIVE_START = new ConcurrentHashMap<>();
private static final Map<UUID, org.bukkit.Material> 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());
}
}