diff --git a/src/main/java/me/sashegdev/blackknife/AbilityCooldown.java b/src/main/java/me/sashegdev/blackknife/AbilityCooldown.java new file mode 100644 index 0000000..b8c5abc --- /dev/null +++ b/src/main/java/me/sashegdev/blackknife/AbilityCooldown.java @@ -0,0 +1,67 @@ +package me.sashegdev.blackknife; + +import org.bukkit.Bukkit; +import org.bukkit.entity.Player; + +import java.util.Map; +import java.util.UUID; +import java.util.concurrent.ConcurrentHashMap; + +/** + * Кулдауны, привязанные к способности, а не к материалу. + * + *

Bukkit умеет кулдаун только по {@link org.bukkit.Material}, и это общая + * таблица на материал: {@code setCooldown(NETHERITE_SWORD, 160)} глушит любой + * незеритовый меч в инвентаре, включая обычный, и заодно обнуляется любой + * пересборкой инвентаря. Для способности это неверно — её кулдаун должен + * принадлежать ей. + * + *

Хранится простое отображение «игрок + ключ способности → тик готовности». + * Время берётся из {@link Bukkit#getCurrentTick()}, а не из wall clock, поэтому + * кулдаун идёт ровно в игровых тиках и не растягивается при лагах. + */ +public final class AbilityCooldown { + + private AbilityCooldown() { + } + + private static final Map> READY_AT = new ConcurrentHashMap<>(); + + /** Осталось тиков до готовности. 0 = можно использовать. */ + public static int remaining(final Player p, final String ability) { + final Map map = READY_AT.get(p.getUniqueId()); + if (map == null) { + return 0; + } + final Integer at = map.get(ability); + if (at == null) { + return 0; + } + return Math.max(0, at - Bukkit.getCurrentTick()); + } + + public static boolean ready(final Player p, final String ability) { + return remaining(p, ability) <= 0; + } + + public static void set(final Player p, final String ability, final int ticks) { + if (ticks <= 0) { + clear(p, ability); + return; + } + READY_AT.computeIfAbsent(p.getUniqueId(), k -> new ConcurrentHashMap<>()) + .put(ability, Bukkit.getCurrentTick() + ticks); + } + + /** Снять кулдаун досрочно — например, вернуть его при прерванном действии. */ + public static void clear(final Player p, final String ability) { + final Map map = READY_AT.get(p.getUniqueId()); + if (map != null) { + map.remove(ability); + } + } + + public static void clearAll(final Player p) { + READY_AT.remove(p.getUniqueId()); + } +} diff --git a/src/main/java/me/sashegdev/blackknife/AttackTpListener.java b/src/main/java/me/sashegdev/blackknife/AttackTpListener.java index 0664115..e490bdf 100644 --- a/src/main/java/me/sashegdev/blackknife/AttackTpListener.java +++ b/src/main/java/me/sashegdev/blackknife/AttackTpListener.java @@ -31,8 +31,8 @@ public class AttackTpListener implements Listener { boolean crit = false; if (e.getDamager() instanceof org.bukkit.entity.AbstractArrow arrow) crit = arrow.isCritical(); else crit = p.getFallDistance() > 0 && !p.isOnGround() && !p.isSprinting(); - float gain = crit ? 25f : 12.5f; - tension.addTP(p, gain, crit ? "CRIT +10%" : "+5%"); + final float gain = tension.getHitGain(crit); + tension.addTP(p, gain, crit ? "CRIT" : "HIT"); } @EventHandler diff --git a/src/main/java/me/sashegdev/blackknife/BlackKnifeListener.java b/src/main/java/me/sashegdev/blackknife/BlackKnifeListener.java index 9f71c6c..a29b910 100644 --- a/src/main/java/me/sashegdev/blackknife/BlackKnifeListener.java +++ b/src/main/java/me/sashegdev/blackknife/BlackKnifeListener.java @@ -172,8 +172,8 @@ public class BlackKnifeListener implements Listener { p.setCooldown(item.getType(), 10); } } else if (m.equals("DIVE")) { - if (SkyDiveAttack.tryUse(p, tm)) { - p.setCooldown(item.getType(), 120); + if (SkyDiveAttack.tryUse(plugin, p, tm)) { + p.setCooldown(item.getType(), SkyDiveAttack.ITEM_COOLDOWN_TICKS); clearHighlight(p); } } diff --git a/src/main/java/me/sashegdev/blackknife/BlackKnifePlugin.java b/src/main/java/me/sashegdev/blackknife/BlackKnifePlugin.java index 65234e4..d16c3da 100644 --- a/src/main/java/me/sashegdev/blackknife/BlackKnifePlugin.java +++ b/src/main/java/me/sashegdev/blackknife/BlackKnifePlugin.java @@ -12,6 +12,9 @@ public class BlackKnifePlugin extends JavaPlugin { private AwakeningManager awakeningManager; private BlackKnifeListener knifeListener; private SwoonHandler swoonHandler; + private SkyDiveAttack diveAttack; + private final SwoonConfig swoonConfig = new SwoonConfig(); + private int displayLeadTicks = 1; private String lang = "ru"; @Override @@ -19,7 +22,10 @@ public class BlackKnifePlugin extends JavaPlugin { instance = this; saveDefaultConfig(); lang = getConfig().getString("lang", "ru"); + loadSwoonConfig(); + loadDisplayConfig(); tension = new TensionManager(); + tension.reloadEconomy(); magicManager = new MagicManager(this); magicListener = new MagicListener(this, magicManager); chatConfig = new ChatConfig(this); @@ -31,7 +37,8 @@ public class BlackKnifePlugin extends JavaPlugin { getServer().getPluginManager().registerEvents(knifeListener, this); getServer().getPluginManager().registerEvents(swoonHandler, this); getServer().getPluginManager().registerEvents(new AttackTpListener(tension), this); - getServer().getPluginManager().registerEvents(new SkyDiveAttack(), this); + diveAttack = new SkyDiveAttack(this); + getServer().getPluginManager().registerEvents(diveAttack, this); getServer().getPluginManager().registerEvents(new ParryHandler(this), this); getServer().getPluginManager().registerEvents(magicListener, this); getServer().getPluginManager().registerEvents(chatHandler, this); @@ -55,11 +62,40 @@ public class BlackKnifePlugin extends JavaPlugin { public MagicListener getMagicListener() { return magicListener; } public BlackKnifeListener getKnifeListener() { return knifeListener; } public SwoonHandler getSwoonHandler() { return swoonHandler; } + public SkyDiveAttack getDiveAttack() { return diveAttack; } public ChatConfig getChatConfig() { return chatConfig; } public ChatHandler getChatHandler() { return chatHandler; } public AwakeningManager getAwakeningManager() { return awakeningManager; } public String getLang() { return lang; } - public void reloadLang() { lang = getConfig().getString("lang", "ru"); if (awakeningManager != null) awakeningManager.reloadChances(); } + public SwoonConfig getSwoonConfig() { return swoonConfig; } + + /** На сколько тиков визуал уходит вперёд, чтобы клиент не отставал. */ + public int getDisplayLeadTicks() { return displayLeadTicks; } + + private void loadDisplayConfig() { + int lead = getConfig().getInt("display.lead-ticks", 1); + displayLeadTicks = Math.max(0, Math.min(10, lead)); + } + + private void loadSwoonConfig() { + var s = getConfig().getConfigurationSection("swoon"); + if (s == null) return; + swoonConfig.damage = s.getDouble("damage", swoonConfig.damage); + swoonConfig.playerCost = s.getDouble("player-cost", swoonConfig.playerCost); + swoonConfig.mobBaseCost = s.getDouble("mob-base-cost", swoonConfig.mobBaseCost); + swoonConfig.mobCostPerHp = s.getDouble("mob-cost-per-hp", swoonConfig.mobCostPerHp); + swoonConfig.mobMaxCost = s.getDouble("mob-max-cost", swoonConfig.mobMaxCost); + swoonConfig.cooldownMinTicks = s.getInt("cooldown-min-ticks", swoonConfig.cooldownMinTicks); + swoonConfig.cooldownMaxTicks = s.getInt("cooldown-max-ticks", swoonConfig.cooldownMaxTicks); + } + + public void reloadLang() { + lang = getConfig().getString("lang", "ru"); + loadSwoonConfig(); + loadDisplayConfig(); + if (tension != null) tension.reloadEconomy(); + if (awakeningManager != null) awakeningManager.reloadChances(); + } private void cleanupLegacyKnocked() { try { diff --git a/src/main/java/me/sashegdev/blackknife/DamageUtil.java b/src/main/java/me/sashegdev/blackknife/DamageUtil.java index 9fb3798..9b38aa3 100644 --- a/src/main/java/me/sashegdev/blackknife/DamageUtil.java +++ b/src/main/java/me/sashegdev/blackknife/DamageUtil.java @@ -5,22 +5,23 @@ import org.bukkit.entity.Player; import org.bukkit.persistence.PersistentDataType; public class DamageUtil { + public static final org.bukkit.NamespacedKey MAGIC_DAMAGE = new org.bukkit.NamespacedKey("blackknife", "magic_damage"); + public static boolean canHurt(Player attacker, LivingEntity victim) { if (victim == null || !victim.isValid() || victim.isDead()) return false; if (attacker != null && attacker.getGameMode() == org.bukkit.GameMode.SPECTATOR) return false; if (victim instanceof Player pl) { if (pl.getGameMode() == org.bukkit.GameMode.CREATIVE || pl.getGameMode() == org.bukkit.GameMode.SPECTATOR) return false; if (pl.isInvulnerable()) return false; - if (pl.getPersistentDataContainer().has(SwoonHandler.SWOONED, PersistentDataType.BYTE)) return false; } return true; } public static void apply(Player attacker, LivingEntity victim, double amount) { if (!canHurt(attacker, victim)) return; - victim.getPersistentDataContainer().set(SwoonHandler.MAGIC_DAMAGE, PersistentDataType.BYTE, (byte) 1); + victim.getPersistentDataContainer().set(DamageUtil.MAGIC_DAMAGE, PersistentDataType.BYTE, (byte) 1); victim.damage(amount, attacker); - victim.getPersistentDataContainer().remove(SwoonHandler.MAGIC_DAMAGE); + victim.getPersistentDataContainer().remove(DamageUtil.MAGIC_DAMAGE); victim.setNoDamageTicks(0); victim.setMaximumNoDamageTicks(10); BlackKnifePlugin.getInstance().getServer().getScheduler().runTaskLater(BlackKnifePlugin.getInstance(), () -> { diff --git a/src/main/java/me/sashegdev/blackknife/DiamondAttack.java b/src/main/java/me/sashegdev/blackknife/DiamondAttack.java index b411e09..80fbb71 100644 --- a/src/main/java/me/sashegdev/blackknife/DiamondAttack.java +++ b/src/main/java/me/sashegdev/blackknife/DiamondAttack.java @@ -19,7 +19,11 @@ public class DiamondAttack { tension.setTP(p, tension.getTP(p) - 25f); Location eye = p.getEyeLocation(); Vector dir = eye.getDirection().normalize(); + Vector vel = dir.clone().multiply(1.5); Location spawn = eye.clone().add(dir.clone().multiply(1.5)); + // Предсказывающая телепортация: рождаемся на lead тиков вперёд по своей + // же траектории, клиент растягивает телепорт на столько же — без рывков. + spawn = EntityTracker.lead(spawn, vel); BlockDisplay crystal = p.getWorld().spawn(spawn, BlockDisplay.class, d -> { d.setBlock(Material.AMETHYST_BLOCK.createBlockData()); d.setBrightness(new Display.Brightness(15, 15)); @@ -29,7 +33,6 @@ public class DiamondAttack { EntityTracker.smooth(crystal); p.getWorld().playSound(spawn, Sound.BLOCK_AMETHYST_BLOCK_PLACE, 1f, 0.9f); p.getWorld().playSound(spawn, Sound.BLOCK_AMETHYST_BLOCK_CHIME, 1f, 1.5f); - Vector vel = dir.clone().multiply(1.5); BlackKnifePlugin plugin = BlackKnifePlugin.getPlugin(BlackKnifePlugin.class); new BukkitRunnable() { @Override public void run() { @@ -56,6 +59,7 @@ public class DiamondAttack { EntityTracker.mark(shard); EntityTracker.smooth(shard); Vector sv = d.clone().multiply(0.4); + shard.teleport(EntityTracker.lead(shard.getLocation(), sv)); new BukkitRunnable() { @Override public void run() { if (!shard.isValid()) { cancel(); return; } @@ -129,6 +133,7 @@ public class DiamondAttack { EntityTracker.mark(shard); EntityTracker.smooth(shard); Vector sv = d.clone().multiply(0.4); + shard.teleport(EntityTracker.lead(shard.getLocation(), sv)); new BukkitRunnable() { @Override public void run() { if (!shard.isValid()) { cancel(); return; } diff --git a/src/main/java/me/sashegdev/blackknife/DiamondTripleAttack.java b/src/main/java/me/sashegdev/blackknife/DiamondTripleAttack.java index 989a1eb..38c5408 100644 --- a/src/main/java/me/sashegdev/blackknife/DiamondTripleAttack.java +++ b/src/main/java/me/sashegdev/blackknife/DiamondTripleAttack.java @@ -51,6 +51,8 @@ public class DiamondTripleAttack { EntityTracker.smooth(shard); BlackKnifePlugin plugin = BlackKnifePlugin.getPlugin(BlackKnifePlugin.class); Vector initDir = target.clone().subtract(spawn).toVector().normalize().multiply(1.4); + // Ускоряемся к цели, вектор почти постоянный — сдвигаем старт по нему. + shard.teleport(EntityTracker.lead(shard.getLocation(), initDir)); new BukkitRunnable() { Vector vel = initDir.clone(); @Override public void run() { diff --git a/src/main/java/me/sashegdev/blackknife/EntityTracker.java b/src/main/java/me/sashegdev/blackknife/EntityTracker.java index 48af02f..46b4b60 100644 --- a/src/main/java/me/sashegdev/blackknife/EntityTracker.java +++ b/src/main/java/me/sashegdev/blackknife/EntityTracker.java @@ -1,9 +1,11 @@ package me.sashegdev.blackknife; +import org.bukkit.Location; import org.bukkit.NamespacedKey; import org.bukkit.entity.Display; import org.bukkit.entity.Entity; import org.bukkit.persistence.PersistentDataType; +import org.bukkit.util.Vector; public class EntityTracker { public static final NamespacedKey BK_MARK = new NamespacedKey("blackknife", "bk_entity"); @@ -18,8 +20,43 @@ public class EntityTracker { tracked.remove(e.getUniqueId()); } + /** + * Плавность визуала через предсказывающую телепортацию. + * + *

Display двигается пакетами позиции, и клиент интерполирует их у себя — + * на реальном пинге визуал отстаёт и дёргается. Лечится не частыми пакетами + * (это ровно тот трафик, который мы убираем), а сдвигом старта: визуал + * рождается сразу на {@code N} тиков вперёд по своей же траектории, а + * {@code teleport_duration = N} говорит клиенту растянуть телепорт на эти + * же N тиков. Клиент никогда не отстаёт, пакетов столько же. + * + *

Сдвиг именно по траектории, поэтому визуал на N тиков короче — при + * lead 3 это 0.15с на всю длительность, и попадание по-прежнему считается + * на настоящей серверной позиции. + */ + public static int leadTicks() { + final var p = BlackKnifePlugin.getInstance(); + return p == null ? 1 : p.getDisplayLeadTicks(); + } + + /** Сдвинуть точку появления на {@code N} тиков вперёд по вектору скорости. */ + public static Location lead(final Location spawn, final Vector perTick) { + final int lead = leadTicks(); + if (lead <= 0 || perTick == null) { + return spawn; + } + return spawn.clone().add(perTick.clone().multiply(lead)); + } + public static void smooth(Display e) { - try { e.setTeleportDuration(1); } catch (Exception ignored) {} + smooth(e, leadTicks()); + } + + public static void smooth(Display e, int leadTicks) { + if (leadTicks <= 0) { + return; + } + try { e.setTeleportDuration(leadTicks); } catch (Exception ignored) {} } public static int cleanup(BlackKnifePlugin plugin) { diff --git a/src/main/java/me/sashegdev/blackknife/GrenadeAttack.java b/src/main/java/me/sashegdev/blackknife/GrenadeAttack.java index 67e3faa..7c88c31 100644 --- a/src/main/java/me/sashegdev/blackknife/GrenadeAttack.java +++ b/src/main/java/me/sashegdev/blackknife/GrenadeAttack.java @@ -31,7 +31,6 @@ public class GrenadeAttack { private static final double ARC_MAX = 4.0; public static boolean tryUse(Player p, TensionManager tension) { - if (SwoonHandler.isSwooned(p)) return false; if (tension.getTP(p) < COST) { p.sendActionBar(Component.text("Need 10% TP").color(NamedTextColor.RED)); return false; @@ -66,7 +65,11 @@ public class GrenadeAttack { Location flightTarget = target.clone(); Vector flightDir = throwDir.clone(); int flightDuration = duration; - int elapsed = 0; + // Предсказание: кривая полёта параметризована временем, поэтому просто + // начинаем отсчёт чуть позже. Траектория та же, граната летит на 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(); diff --git a/src/main/java/me/sashegdev/blackknife/KillMessageHandler.java b/src/main/java/me/sashegdev/blackknife/KillMessageHandler.java index e68d7ee..5986401 100644 --- a/src/main/java/me/sashegdev/blackknife/KillMessageHandler.java +++ b/src/main/java/me/sashegdev/blackknife/KillMessageHandler.java @@ -48,7 +48,7 @@ public class KillMessageHandler implements Listener { } private String detectMagic(Player killer, Player victim) { - if (SwoonHandler.isSwooned(victim)) return "SWOON"; + if (SwoonHandler.isSwoonDeath(victim)) return "SWOON"; var ml = plugin.getMagicListener(); if (ml != null) { MagicType last = ml.getLastCast(killer, 10000L); diff --git a/src/main/java/me/sashegdev/blackknife/LungeAttack.java b/src/main/java/me/sashegdev/blackknife/LungeAttack.java index af0b427..cd4785d 100644 --- a/src/main/java/me/sashegdev/blackknife/LungeAttack.java +++ b/src/main/java/me/sashegdev/blackknife/LungeAttack.java @@ -43,6 +43,7 @@ public class LungeAttack { EntityTracker.smooth(catalyst); p.getWorld().playSound(start, Sound.BLOCK_SCULK_CATALYST_BLOOM, 1f, 0.7f); double catSpeed = 3.0; + catalyst.teleport(EntityTracker.lead(catalyst.getLocation(), toTarget(start, target, catSpeed))); new BukkitRunnable() { @Override public void run() { @@ -73,6 +74,12 @@ public class LungeAttack { return true; } + /** Вектор скорости к цели в один тик — им же сдвигаем старт катализатора. */ + private static Vector toTarget(Location from, LivingEntity target, double speed) { + final Location tpos = target.getLocation().add(0, 1, 0); + return tpos.toVector().subtract(from.toVector()).normalize().multiply(speed); + } + private static boolean canSeeTarget(Player p, LivingEntity target) { if (!p.hasLineOfSight(target)) return false; var ray = p.getWorld().rayTraceBlocks(p.getEyeLocation(), p.getEyeLocation().getDirection().normalize(), 120); @@ -156,6 +163,8 @@ public class LungeAttack { }); EntityTracker.mark(sword); EntityTracker.smooth(sword); + // Клинок летит по постоянному вектору aim — сдвигаем старт вперёд. + sword.teleport(EntityTracker.lead(sword.getLocation(), aim)); new BukkitRunnable() { long hold = 5; boolean launched = false; @@ -228,6 +237,8 @@ public class LungeAttack { p.getWorld().playSound(gazePoint, Sound.ENTITY_PLAYER_ATTACK_SWEEP, 1f, 0.5f); p.getWorld().playSound(gazePoint, Sound.BLOCK_ANVIL_LAND, 0.6f, 1.4f); Vector vel = new Vector(0, -1.2, 0); + // Падение строго вниз с постоянной скоростью — предсказываем по нему же. + sword.teleport(EntityTracker.lead(sword.getLocation(), vel)); new BukkitRunnable() { int hold = 8; @Override public void run() { @@ -287,7 +298,7 @@ public class LungeAttack { private static void applyLungeDamage(LivingEntity ent, Player attacker, double amount) { if (!DamageUtil.canHurt(attacker, ent)) return; if (ent instanceof Player pl && pl.isBlocking()) amount /= 2.5; - ent.getPersistentDataContainer().set(SwoonHandler.MAGIC_DAMAGE, PersistentDataType.BYTE, (byte) 1); + ent.getPersistentDataContainer().set(DamageUtil.MAGIC_DAMAGE, PersistentDataType.BYTE, (byte) 1); ent.damage(amount, attacker); ent.setNoDamageTicks(0); } @@ -375,6 +386,7 @@ public class LungeAttack { EntityTracker.mark(sword); EntityTracker.smooth(sword); Vector vel = new Vector(0, -fallSpeed, 0); + sword.teleport(EntityTracker.lead(sword.getLocation(), vel)); p.getWorld().playSound(spawn, Sound.ENTITY_PLAYER_ATTACK_SWEEP, 0.6f, 0.7f); new BukkitRunnable() { @Override public void run() { @@ -468,6 +480,7 @@ public class LungeAttack { }); EntityTracker.mark(sword); EntityTracker.smooth(sword); + sword.teleport(EntityTracker.lead(sword.getLocation(), aim)); spawn.getWorld().playSound(spawn, Sound.ENTITY_PLAYER_ATTACK_SWEEP, 0.8f, 0.5f); new BukkitRunnable() { diff --git a/src/main/java/me/sashegdev/blackknife/ParryHandler.java b/src/main/java/me/sashegdev/blackknife/ParryHandler.java index 5f34e32..67e8392 100644 --- a/src/main/java/me/sashegdev/blackknife/ParryHandler.java +++ b/src/main/java/me/sashegdev/blackknife/ParryHandler.java @@ -9,7 +9,6 @@ import org.bukkit.event.EventHandler; import org.bukkit.event.EventPriority; import org.bukkit.event.Listener; import org.bukkit.event.entity.EntityDamageByEntityEvent; -import org.bukkit.persistence.PersistentDataType; public class ParryHandler implements Listener { private final BlackKnifePlugin plugin; @@ -20,7 +19,6 @@ public class ParryHandler implements Listener { if (!(e.getEntity() instanceof Player victim)) return; if (victim.getGameMode() == org.bukkit.GameMode.CREATIVE || victim.getGameMode() == org.bukkit.GameMode.SPECTATOR) return; if (victim.isInvulnerable()) return; - if (victim.getPersistentDataContainer().has(SwoonHandler.SWOONED, PersistentDataType.BYTE)) return; if (BlackKnifeItem.is(victim.getInventory().getItemInMainHand()) || BlackKnifeItem.is(victim.getInventory().getItemInOffHand())) { TensionManager tm = plugin.getTension(); if (tm.getTP(victim) < 37.5f) return; diff --git a/src/main/java/me/sashegdev/blackknife/PhantomBarrageAttack.java b/src/main/java/me/sashegdev/blackknife/PhantomBarrageAttack.java index 9706348..cf60969 100644 --- a/src/main/java/me/sashegdev/blackknife/PhantomBarrageAttack.java +++ b/src/main/java/me/sashegdev/blackknife/PhantomBarrageAttack.java @@ -35,6 +35,9 @@ public class PhantomBarrageAttack { }); EntityTracker.mark(orb); EntityTracker.smooth(orb); + // Орбита: скорость постоянная, сдвигаем старт по касательной. + Vector tangent = new Vector(-Math.sin(fa), 0, Math.cos(fa)).normalize().multiply(0.35); + orb.teleport(EntityTracker.lead(orb.getLocation(), tangent)); new BukkitRunnable() { int ticks = 0; Location cur = spawn.clone(); diff --git a/src/main/java/me/sashegdev/blackknife/SkyDiveAttack.java b/src/main/java/me/sashegdev/blackknife/SkyDiveAttack.java index 3ee47ca..fb3adc5 100644 --- a/src/main/java/me/sashegdev/blackknife/SkyDiveAttack.java +++ b/src/main/java/me/sashegdev/blackknife/SkyDiveAttack.java @@ -2,7 +2,6 @@ package me.sashegdev.blackknife; import net.kyori.adventure.text.Component; import net.kyori.adventure.text.format.NamedTextColor; -import org.bukkit.Color; import org.bukkit.GameMode; import org.bukkit.Location; import org.bukkit.Particle; @@ -16,6 +15,8 @@ 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; @@ -23,31 +24,67 @@ 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; - private static final double APEX_HEIGHT = 7.5; - private static final int LAUNCH_TIMEOUT = 18; - private static final double DASH_SPEED = 2.8; - private static final long FREEZE_TICKS = 30L; - private static final long FALL_IMMUNE_MS = 8000L; + /** Собственная гравитация: 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 Map frozen = new ConcurrentHashMap<>(); - private static final Map immuneUntil = new ConcurrentHashMap<>(); - private static final Map diving = new ConcurrentHashMap<>(); + 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; - public static boolean tryUse(Player p, TensionManager tension) { - if (SwoonHandler.isSwooned(p)) { - p.sendActionBar(Component.text("SWOONED - can't use DIVE").color(NamedTextColor.RED)); - return false; - } - if (p.getGameMode() == GameMode.SPECTATOR) { - p.sendActionBar(Component.text("Spectators can't use DIVE").color(NamedTextColor.RED)); - return false; - } - if (Boolean.TRUE.equals(diving.get(p.getUniqueId()))) { + 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; } @@ -56,183 +93,293 @@ public class SkyDiveAttack implements Listener { return false; } tension.setTP(p, tension.getTP(p) - COST); - BlackKnifePlugin plugin = BlackKnifePlugin.getPlugin(BlackKnifePlugin.class); - Vector look = p.getEyeLocation().getDirection(); + // Гравитация выключена на всю способность: дужение считаем сами, + // апекс получается аналитически, без опроса 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() > 0.0001) horizontal.normalize().multiply(LAUNCH_FORWARD); - else horizontal = new Vector(0, 0, 0); + 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); - immuneUntil.put(p.getUniqueId(), System.currentTimeMillis() + FALL_IMMUNE_MS); - diving.put(p.getUniqueId(), true); + 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 double startY = p.getLocation().getY(); + final Location start = p.getLocation().clone(); int ticks = 0; @Override public void run() { - if (!p.isOnline() || p.isDead()) { cleanup(p); cancel(); return; } - p.setFallDistance(0); + if (done(p)) { cancel(); return; } ticks++; - double gained = p.getLocation().getY() - startY; - Vector v = p.getVelocity(); - if (v.getY() <= 0.25 || gained >= APEX_HEIGHT || ticks >= LAUNCH_TIMEOUT) { - cancel(); - freeze(p, plugin); + 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); } - if (ticks > 40) { cancel(); freeze(p, plugin); } } }.runTaskTimer(plugin, 1L, 1L); return true; } - private static void freeze(Player p, BlackKnifePlugin plugin) { - if (!p.isOnline() || p.isDead()) { cleanup(p); return; } - Location top = p.getLocation().clone(); + 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); - frozen.put(p.getUniqueId(), top); + 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 = FREEZE_TICKS; + long left = HOVER_TICKS; @Override public void run() { - if (!p.isOnline() || p.isDead()) { cleanup(p); cancel(); return; } - Location anchor = frozen.get(p.getUniqueId()); - if (anchor == null) { cancel(); return; } - // Без телепортов: позицию держит onMove, камера свободна. - // Телепорт каждый тик слал клиенту зафиксированный поворот и давил движения мыши. + if (done(p)) { cancel(); return; } p.setVelocity(new Vector(0, 0, 0)); p.setFallDistance(0); - for (int k = 0; k < 10; k++) { - double a = (double) k / 10 * Math.PI * 2; - 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(Color.fromRGB(80, 160, 255), 1.0f)); - } - left--; - if (left <= 0) { - frozen.remove(p.getUniqueId()); + final Location anchor = HOVER_ANCHOR.get(p.getUniqueId()); + if (anchor == null) { cancel(); - dash(p, plugin); + 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 static void dash(Player p, BlackKnifePlugin plugin) { - if (!p.isOnline() || p.isDead()) { cleanup(p); return; } - Vector dir = p.getEyeLocation().getDirection().normalize(); + 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); - immuneUntil.put(p.getUniqueId(), System.currentTimeMillis() + FALL_IMMUNE_MS); 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 (!p.isOnline() || p.isDead()) { cleanup(p); cancel(); return; } + if (done(p)) { cancel(); return; } p.setFallDistance(0); ticks++; - Location cur = p.getLocation().add(0, 1, 0); + + 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(Color.fromRGB(80, 160, 255), 1.4f)); + 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); - // Семплируем весь пройденный за тик отрезок, иначе на скорости 2.8 цель проскакивает между тиками - Vector seg = cur.clone().subtract(prev).toVector(); - double len = seg.length(); - int steps = Math.max(1, (int) Math.ceil(len / 0.5)); - for (int s = 0; s <= steps && !hit; s++) { - Location check = prev.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); - 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); - hit = true; - break; + // Касание земли отменяет урон: иначе можно было промахнуться, + // добежать к цели и получить урон на земле. + 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) { finish(); return; } - Location ahead = p.getEyeLocation().add(dir.clone().multiply(1.0)); - if (ahead.getBlock().isSolid()) { finish(); return; } - if (ticks > 40) { finish(); return; } - } - private void finish() { - immuneUntil.put(p.getUniqueId(), System.currentTimeMillis() + 3000L); - cleanup(p); - cancel(); + 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 static void cleanup(Player p) { - frozen.remove(p.getUniqueId()); - diving.remove(p.getUniqueId()); + 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; } - public static boolean isImmune(Player p) { - Long until = immuneUntil.get(p.getUniqueId()); - return until != null && System.currentTimeMillis() < until; + 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; - if (isImmune(p) || frozen.containsKey(p.getUniqueId()) || Boolean.TRUE.equals(diving.get(p.getUniqueId()))) { + 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) { - Player p = e.getPlayer(); - Location anchor = frozen.get(p.getUniqueId()); + final Player p = e.getPlayer(); + final Location anchor = HOVER_ANCHOR.get(p.getUniqueId()); if (anchor == null) return; - if (e.getTo() == null) return; - double dx = e.getTo().getX() - anchor.getX(); - double dy = e.getTo().getY() - anchor.getY(); - double dz = e.getTo().getZ() - anchor.getZ(); + 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) { - Location fixed = anchor.clone(); - fixed.setYaw(e.getTo().getYaw()); - fixed.setPitch(e.getTo().getPitch()); + final Location fixed = anchor.clone(); + fixed.setYaw(to.getYaw()); + fixed.setPitch(to.getPitch()); e.setTo(fixed); - p.sendActionBar(Component.text("Aiming...").color(NamedTextColor.AQUA)); } } @EventHandler public void onQuit(PlayerQuitEvent e) { - frozen.remove(e.getPlayer().getUniqueId()); - diving.remove(e.getPlayer().getUniqueId()); - immuneUntil.remove(e.getPlayer().getUniqueId()); + 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) { - frozen.remove(e.getEntity().getUniqueId()); - diving.remove(e.getEntity().getUniqueId()); - immuneUntil.remove(e.getEntity().getUniqueId()); + 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()); + } +} \ No newline at end of file diff --git a/src/main/java/me/sashegdev/blackknife/SwoonConfig.java b/src/main/java/me/sashegdev/blackknife/SwoonConfig.java new file mode 100644 index 0000000..ae03b50 --- /dev/null +++ b/src/main/java/me/sashegdev/blackknife/SwoonConfig.java @@ -0,0 +1,14 @@ +package me.sashegdev.blackknife; + +/** Баланс SWOON — читается из config.yml в секции {@code swoon}. */ +public class SwoonConfig { + public double damage = 999.0D; + public double playerCost = 225.0D; + + public double mobBaseCost = 12.5D; + public double mobCostPerHp = 0.5D; + public double mobMaxCost = 225.0D; + + public int cooldownMinTicks = 40; + public int cooldownMaxTicks = 160; +} \ No newline at end of file diff --git a/src/main/java/me/sashegdev/blackknife/SwoonHandler.java b/src/main/java/me/sashegdev/blackknife/SwoonHandler.java index 528dadb..978301b 100644 --- a/src/main/java/me/sashegdev/blackknife/SwoonHandler.java +++ b/src/main/java/me/sashegdev/blackknife/SwoonHandler.java @@ -2,256 +2,156 @@ package me.sashegdev.blackknife; import net.kyori.adventure.text.Component; import net.kyori.adventure.text.format.NamedTextColor; -import org.bukkit.Material; import org.bukkit.NamespacedKey; +import org.bukkit.Particle; import org.bukkit.Sound; -import org.bukkit.entity.Display; +import org.bukkit.attribute.Attribute; +import org.bukkit.damage.DamageSource; +import org.bukkit.damage.DamageType; import org.bukkit.entity.LivingEntity; import org.bukkit.entity.Player; -import org.bukkit.entity.TextDisplay; import org.bukkit.event.EventHandler; import org.bukkit.event.EventPriority; import org.bukkit.event.Listener; -import org.bukkit.event.block.BlockBreakEvent; -import org.bukkit.event.block.BlockPlaceEvent; -import org.bukkit.event.entity.EntityDamageEvent; -import org.bukkit.event.entity.PlayerDeathEvent; -import org.bukkit.event.player.PlayerInteractEvent; -import org.bukkit.event.player.PlayerMoveEvent; +import org.bukkit.event.entity.EntityDamageByEntityEvent; import org.bukkit.persistence.PersistentDataType; -import org.bukkit.potion.PotionEffect; -import org.bukkit.potion.PotionEffectType; -import java.util.HashMap; -import java.util.Map; -import java.util.UUID; +/** + * SWOON — летальный удар ножом. + * + *

Бьёт 999 урона через {@link DamageType#GENERIC_KILL}: в ванилле этот тип + * помечен как обходящий броню, чары, эффекты и щиты. Поэтому урон всегда + * смертелен, а прочность брони вообще не расходуется — полный сет переживает + * удар целиком. Раньше здесь была механика виртуальных HP (−999 и набор до 1 + * регенерацией), но она давала пожизненное заключение: урон был полностью + * отменён, значит умереть и вырваться было нечем. + * + *

Стоимость и кулдаун зависят от цели, кулдаун обратно пропорционален + * потраченному TP: дешёвый PvE бьётся часто, удар по игроку — на восемь + * секунд. Кулдаун живёт в {@link AbilityCooldown}, а не в + * {@code setCooldown(NETHERITE_SWORD, ...)}: материальный кулдаун — это общая + * таблица на материал, он глушил бы любой незеритовый меч и обнулялся пересборкой + * инвентаря. + */ public class SwoonHandler implements Listener { - private final BlackKnifePlugin plugin; - public static final NamespacedKey SWOONED = new NamespacedKey("blackknife", "swooned"); - public static final NamespacedKey MAGIC_DAMAGE = new NamespacedKey("blackknife", "magic_damage"); - public static final NamespacedKey VHP = new NamespacedKey("blackknife", "vhp"); - private final Map displays = new HashMap<>(); - public SwoonHandler(BlackKnifePlugin plugin) { this.plugin = plugin; } + private static final NamespacedKey MAGIC_DAMAGE = new NamespacedKey("blackknife", "magic_damage"); + private static final String ABILITY = "swoon"; + + private final BlackKnifePlugin plugin; + + public SwoonHandler(BlackKnifePlugin plugin) { + this.plugin = plugin; + } + + /** Сколько TP стоит удар по этой цели. */ + public static float costFor(final BlackKnifePlugin plugin, final LivingEntity target) { + final var cfg = plugin.getSwoonConfig(); + if (target instanceof Player) { + return (float) cfg.playerCost; + } + final var attr = target.getAttribute(Attribute.MAX_HEALTH); + final double maxHp = attr != null ? attr.getValue() : 20.0D; + final double raw = cfg.mobBaseCost + (cfg.mobCostPerHp * maxHp); + final double clamped = Math.max(cfg.mobBaseCost, Math.min(cfg.mobMaxCost, raw)); + return (float) clamped; + } + + /** Кулдаун обратно пропорционален потраченному TP. */ + public static int cooldownFor(final BlackKnifePlugin plugin, final float cost) { + final var cfg = plugin.getSwoonConfig(); + final double playerCost = Math.max(1.0D, cfg.playerCost); + final double fraction = Math.max(0.0D, Math.min(1.0D, cost / playerCost)); + final double ticks = cfg.cooldownMinTicks + (fraction * (cfg.cooldownMaxTicks - cfg.cooldownMinTicks)); + return (int) Math.round(ticks); + } @EventHandler(priority = EventPriority.HIGHEST, ignoreCancelled = true) - public void onHit(org.bukkit.event.entity.EntityDamageByEntityEvent e) { - if (!(e.getDamager() instanceof Player p)) return; - if (p.getGameMode() == org.bukkit.GameMode.SPECTATOR) return; - if (!BlackKnifeItem.is(p.getInventory().getItemInMainHand()) && !BlackKnifeItem.is(p.getInventory().getItemInOffHand())) return; - if (!(e.getEntity() instanceof LivingEntity target)) return; - if (target.getPersistentDataContainer().has(MAGIC_DAMAGE, PersistentDataType.BYTE)) { - target.getPersistentDataContainer().remove(MAGIC_DAMAGE); + public void onHit(final EntityDamageByEntityEvent e) { + if (!(e.getDamager() instanceof Player p)) { + return; + } + if (p.getGameMode() == org.bukkit.GameMode.SPECTATOR) { + return; + } + if (!BlackKnifeItem.is(p.getInventory().getItemInMainHand()) && !BlackKnifeItem.is(p.getInventory().getItemInOffHand())) { + return; + } + if (!(e.getEntity() instanceof LivingEntity target)) { + return; + } + // Рекурсия: собственный урон способностей DamageUtil помечает тем же ключом + // и входит сюда повторно. Таким уроном SWOON не срабатывает. + if (target.getPersistentDataContainer().has(MAGIC_DAMAGE, PersistentDataType.BYTE)) { + return; + } + if (!AbilityCooldown.ready(p, ABILITY)) { return; } - if (target.getPersistentDataContainer().has(SWOONED, PersistentDataType.BYTE)) return; if (target instanceof Player vt) { - if (vt.getGameMode() == org.bukkit.GameMode.CREATIVE || vt.getGameMode() == org.bukkit.GameMode.SPECTATOR) return; - if (vt.isInvulnerable()) return; - } - if (p.hasCooldown(Material.NETHERITE_SWORD)) return; - e.setCancelled(true); - plugin.getTension().setTP(p, Math.max(0, plugin.getTension().getTP(p) - 125f)); - if (target instanceof Player tp) { - var attr = tp.getAttribute(org.bukkit.attribute.Attribute.MAX_HEALTH); - double maxHp = attr != null ? attr.getValue() : 20.0; - double vhpTmp = tp.getHealth() - 999; - double vhp = Math.max(-2147483648.0, Math.min(maxHp, vhpTmp)); - final double finalVhp = vhp; - applySwoon(tp, vhp); - tp.getWorld().spawnParticle(org.bukkit.Particle.SOUL, tp.getLocation().add(0,1,0), 40, 0.5,0.5,0.5,0.05); - tp.getWorld().spawnParticle(org.bukkit.Particle.SCULK_SOUL, tp.getLocation().add(0,1,0), 15, 0.5,0.5,0.5,0.02); - tp.getWorld().spawnParticle(org.bukkit.Particle.ASH, tp.getLocation().add(0,1,0), 20, 0.5,0.5,0.5,0); - for (int k=0;k<12;k++){ double a=(double)k/12*Math.PI*2; org.bukkit.Location r=tp.getLocation().add(Math.cos(a)*1.5,0.1,Math.sin(a)*1.5); r.getWorld().spawnParticle(org.bukkit.Particle.SMOKE,r,2,0.05,0.05,0.05,0); r.getWorld().spawnParticle(org.bukkit.Particle.SCULK_SOUL,r,1,0.02,0.02,0.02,0); } - p.sendActionBar(Component.text("SWOON! " + tp.getName() + " -999 HP").color(NamedTextColor.DARK_RED)); - tp.sendActionBar(Component.text("SWOONED! -999 HP regen to revive").color(NamedTextColor.DARK_RED)); - tp.getWorld().playSound(tp.getLocation(), Sound.ENTITY_WARDEN_SONIC_BOOM, 1f, 0.5f); - tp.getWorld().playSound(tp.getLocation(), Sound.ENTITY_ELDER_GUARDIAN_CURSE, 1f, 0.7f); - startDisplayTask(tp); - } else { - target.getPersistentDataContainer().set(MAGIC_DAMAGE, PersistentDataType.BYTE, (byte) 1); - target.damage(999.0, p); - target.getPersistentDataContainer().remove(MAGIC_DAMAGE); - target.getWorld().spawnParticle(org.bukkit.Particle.EXPLOSION, target.getLocation().add(0, 1, 0), 1); - target.getWorld().spawnParticle(org.bukkit.Particle.SOUL, target.getLocation().add(0,1,0), 30, 0.5,0.5,0.5,0.05); - target.getWorld().playSound(target.getLocation(), Sound.ENTITY_WARDEN_SONIC_BOOM, 1f, 0.5f); - p.sendActionBar(Component.text("SWOON! " + target.getName() + " -999").color(NamedTextColor.DARK_RED)); - } - target.getWorld().spawnParticle(org.bukkit.Particle.SWEEP_ATTACK, target.getLocation().add(0, 1, 0), 1); - target.getWorld().spawnParticle(org.bukkit.Particle.SOUL, target.getLocation().add(0, 1, 0), 20, 0.5, 0.5, 0.5, 0.02); - p.setCooldown(Material.NETHERITE_SWORD, 160); - p.playSound(p.getLocation(), Sound.ITEM_TRIDENT_THUNDER, 1f, 0.7f); - } - - @EventHandler(priority = EventPriority.HIGHEST) - public void onDamage(EntityDamageEvent e) { - if (!(e.getEntity() instanceof Player p)) return; - if (!p.getPersistentDataContainer().has(SWOONED, PersistentDataType.BYTE)) return; - e.setCancelled(true); - } - - @EventHandler - public void onMove(PlayerMoveEvent e) { - Player p = e.getPlayer(); - if (!p.getPersistentDataContainer().has(SWOONED, PersistentDataType.BYTE)) return; - if (e.getFrom().distanceSquared(e.getTo()) < 0.0001) return; - double dx = e.getTo().getX() - e.getFrom().getX(); - double dz = e.getTo().getZ() - e.getFrom().getZ(); - if (Math.abs(dx) > 0.01 || Math.abs(dz) > 0.01 || Math.abs(e.getTo().getY() - e.getFrom().getY()) > 0.01) { - e.setCancelled(true); - p.sendActionBar(Component.text("SWOONED - can't move, regen to revive").color(NamedTextColor.DARK_RED)); - } - } - - @EventHandler - public void onInteract(PlayerInteractEvent e) { - Player p = e.getPlayer(); - if (!p.getPersistentDataContainer().has(SWOONED, PersistentDataType.BYTE)) return; - e.setCancelled(true); - p.sendActionBar(Component.text("SWOONED - can't interact").color(NamedTextColor.DARK_RED)); - } - - @EventHandler - public void onBreak(BlockBreakEvent e) { - if (!e.getPlayer().getPersistentDataContainer().has(SWOONED, PersistentDataType.BYTE)) return; - e.setCancelled(true); - } - - @EventHandler - public void onPlace(BlockPlaceEvent e) { - if (!e.getPlayer().getPersistentDataContainer().has(SWOONED, PersistentDataType.BYTE)) return; - e.setCancelled(true); - } - - @EventHandler - public void onPotionSplash(org.bukkit.event.entity.PotionSplashEvent e) { - if (e.getPotion().getEffects().stream().noneMatch(ef -> ef.getType().equals(PotionEffectType.REGENERATION))) return; - for (LivingEntity ent : e.getAffectedEntities()) { - if (!(ent instanceof Player p)) continue; - if (!p.getPersistentDataContainer().has(SWOONED, PersistentDataType.BYTE)) continue; - double intensity = e.getIntensity(p); - double heal = 4 * intensity; - Double vhp = p.getPersistentDataContainer().get(VHP, PersistentDataType.DOUBLE); - if (vhp == null) vhp = -10.0; - double newVhp = vhp + heal; - var attr = p.getAttribute(org.bukkit.attribute.Attribute.MAX_HEALTH); - double maxHp = attr != null ? attr.getValue() : 20.0; - newVhp = Math.min(maxHp, newVhp); - p.getPersistentDataContainer().set(VHP, PersistentDataType.DOUBLE, newVhp); - TextDisplay td = displays.get(p.getUniqueId()); - if (td != null && td.isValid()) td.text(Component.text(String.format("%.0f HP", newVhp)).color(newVhp < 0 ? NamedTextColor.DARK_RED : newVhp < 1 ? NamedTextColor.YELLOW : NamedTextColor.GREEN)); - if (newVhp >= 1) { - revive(p); - } - } - } - - private void applySwoon(Player tp, double vhp) { - tp.setHealth(1.0); - try { tp.setWalkSpeed(0f); } catch (Exception ignored) {} - tp.getPersistentDataContainer().set(SWOONED, PersistentDataType.BYTE, (byte) 1); - tp.getPersistentDataContainer().set(VHP, PersistentDataType.DOUBLE, vhp); - tp.addPotionEffect(new PotionEffect(PotionEffectType.SLOWNESS, 100000, 10, false, false, true)); - tp.addPotionEffect(new PotionEffect(PotionEffectType.JUMP_BOOST, 100000, 128, false, false, true)); - tp.addPotionEffect(new PotionEffect(PotionEffectType.WEAKNESS, 100000, 10, false, false, true)); - tp.addPotionEffect(new PotionEffect(PotionEffectType.DARKNESS, 100000, 0, false, false, true)); - tp.addPotionEffect(new PotionEffect(PotionEffectType.BLINDNESS, 100, 0, false, false, true)); - TextDisplay old = displays.remove(tp.getUniqueId()); - if (old != null && old.isValid()) { EntityTracker.untrack(old); old.remove(); } - TextDisplay td = tp.getWorld().spawn(tp.getLocation().add(0, 2.4, 0), TextDisplay.class, d -> { - d.text(Component.text(String.format("%.0f HP", vhp)).color(NamedTextColor.DARK_RED)); - d.setBillboard(Display.Billboard.CENTER); - d.setSeeThrough(true); - d.setShadowed(true); - d.setBackgroundColor(org.bukkit.Color.fromARGB(64, 40, 0, 0)); - d.setAlignment(TextDisplay.TextAlignment.CENTER); - d.getPersistentDataContainer().set(SWOONED, PersistentDataType.BYTE, (byte) 1); - }); - EntityTracker.mark(td); - EntityTracker.smooth(td); - displays.put(tp.getUniqueId(), td); - } - - private void startDisplayTask(Player tp) { - plugin.getServer().getScheduler().runTaskTimer(plugin, task -> { - TextDisplay d = displays.get(tp.getUniqueId()); - if (d == null || !d.isValid() || !tp.isOnline() || !tp.getPersistentDataContainer().has(SWOONED, PersistentDataType.BYTE)) { - if (d != null && !d.isValid()) displays.remove(tp.getUniqueId()); - task.cancel(); + if (vt.getGameMode() == org.bukkit.GameMode.CREATIVE || vt.getGameMode() == org.bukkit.GameMode.SPECTATOR) { return; } - d.teleport(tp.getLocation().add(0, 2.4, 0)); - Double cur = tp.getPersistentDataContainer().get(VHP, PersistentDataType.DOUBLE); - if (cur != null) d.text(Component.text(String.format("%.0f HP", cur)).color(cur < 0 ? NamedTextColor.DARK_RED : NamedTextColor.YELLOW)); - if (tp.isDead()) { EntityTracker.untrack(d); d.remove(); displays.remove(tp.getUniqueId()); task.cancel(); } - }, 2L, 2L); + if (vt.isInvulnerable()) { + return; + } + } + + final var tension = plugin.getTension(); + final float cost = costFor(plugin, target); + if (tension.getTP(p) < cost) { + // Отменять обычный удар нельзя: иначе удары без TP станут бесплатным + // «ничьим» — игрок вообще ничего не наносит и никогда не копит. + p.sendActionBar(Component.text("Need " + percent(cost) + "% TP").color(NamedTextColor.RED)); + return; + } + + e.setCancelled(true); + tension.setTP(p, tension.getTP(p) - cost); + + final var cfg = plugin.getSwoonConfig(); + final DamageSource source = DamageSource.builder(DamageType.GENERIC_KILL) + .withCausingEntity(p) + .build(); + + target.getPersistentDataContainer().set(MAGIC_DAMAGE, PersistentDataType.BYTE, (byte) 1); + try { + target.damage(cfg.damage, source); + } finally { + target.getPersistentDataContainer().remove(MAGIC_DAMAGE); + } + + target.getWorld().spawnParticle(Particle.SWEEP_ATTACK, target.getLocation().add(0, 1, 0), 1); + target.getWorld().spawnParticle(Particle.SOUL, target.getLocation().add(0, 1, 0), 30, 0.5, 0.5, 0.5, 0.02); + target.getWorld().playSound(target.getLocation(), Sound.ENTITY_WARDEN_SONIC_BOOM, 1f, 0.5f); + + p.sendActionBar(Component.text("SWOON! " + target.getName() + " -" + percent(cost) + "% TP") + .color(NamedTextColor.DARK_RED)); + p.playSound(p.getLocation(), Sound.ITEM_TRIDENT_THUNDER, 1f, 0.7f); + + tension.startBattle(p, target); + AbilityCooldown.set(p, ABILITY, cooldownFor(plugin, cost)); } - public void revive(Player p) { - Double stored = p.getPersistentDataContainer().get(VHP, PersistentDataType.DOUBLE); - p.getPersistentDataContainer().remove(SWOONED); - p.getPersistentDataContainer().remove(VHP); - var attr = p.getAttribute(org.bukkit.attribute.Attribute.MAX_HEALTH); - double maxHp = attr != null ? attr.getValue() : 20.0; - double hp = stored != null ? Math.min(Math.max(stored, 1.0), maxHp) : 1.0; - try { p.setHealth(hp); } catch (Exception ignored) {} - try { p.setWalkSpeed(0.2f); } catch (Exception ignored) {} - p.removePotionEffect(PotionEffectType.SLOWNESS); - p.removePotionEffect(PotionEffectType.JUMP_BOOST); - p.removePotionEffect(PotionEffectType.WEAKNESS); - p.removePotionEffect(PotionEffectType.DARKNESS); - p.removePotionEffect(PotionEffectType.BLINDNESS); - TextDisplay td = displays.remove(p.getUniqueId()); - if (td != null && td.isValid()) { EntityTracker.untrack(td); td.remove(); } - p.sendActionBar(Component.text("Revived from SWOON!").color(NamedTextColor.GREEN)); - p.getWorld().playSound(p.getLocation(), Sound.BLOCK_BEACON_ACTIVATE, 1f, 1.2f); + /** Полоса TP: 250 = 100%, то есть один процент равен 2.5. */ + private static int percent(final float tp) { + return (int) Math.ceil(tp / 2.5f); } - public void cleanupOffline(Player p) { - TextDisplay td = displays.remove(p.getUniqueId()); - if (td != null && td.isValid()) { EntityTracker.untrack(td); td.remove(); } - } - - @EventHandler - public void onQuit(org.bukkit.event.player.PlayerQuitEvent e) { - cleanupOffline(e.getPlayer()); - } - - @EventHandler - public void onJoin(org.bukkit.event.player.PlayerJoinEvent e) { - Player p = e.getPlayer(); - if (!p.getPersistentDataContainer().has(SWOONED, PersistentDataType.BYTE)) return; - Double vhp = p.getPersistentDataContainer().get(VHP, PersistentDataType.DOUBLE); - if (vhp == null) { p.getPersistentDataContainer().remove(SWOONED); try { p.setWalkSpeed(0.2f); } catch (Exception ignored) {} return; } - applySwoon(p, vhp); - startDisplayTask(p); - } - - @EventHandler - public void onRespawn(org.bukkit.event.player.PlayerRespawnEvent e) { - Player p = e.getPlayer(); - if (!p.getPersistentDataContainer().has(SWOONED, PersistentDataType.BYTE)) return; - p.getPersistentDataContainer().remove(SWOONED); - p.getPersistentDataContainer().remove(VHP); - try { p.setWalkSpeed(0.2f); } catch (Exception ignored) {} - p.removePotionEffect(PotionEffectType.SLOWNESS); - p.removePotionEffect(PotionEffectType.JUMP_BOOST); - p.removePotionEffect(PotionEffectType.WEAKNESS); - p.removePotionEffect(PotionEffectType.DARKNESS); - p.removePotionEffect(PotionEffectType.BLINDNESS); - cleanupOffline(p); - } - - @EventHandler - public void onDeath(PlayerDeathEvent e) { - Player p = e.getEntity(); - TextDisplay td = displays.remove(p.getUniqueId()); - if (td != null && td.isValid()) { EntityTracker.untrack(td); td.remove(); } - p.getPersistentDataContainer().remove(SWOONED); - p.getPersistentDataContainer().remove(VHP); - } - - public static boolean isSwooned(LivingEntity e) { - return e.getPersistentDataContainer().has(SWOONED, PersistentDataType.BYTE); + /** + * Определяет, что жертву убил именно SWOON. Полностью без состояния: + * смотрим тип урона и то, что в руках у нападавшего. + */ + public static boolean isSwoonDeath(final Player victim) { + final var last = victim.getLastDamageCause(); + if (!(last instanceof EntityDamageByEntityEvent ev)) { + return false; + } + if (ev.getDamageSource().getDamageType() != DamageType.GENERIC_KILL) { + return false; + } + return ev.getDamager() instanceof Player killer + && (BlackKnifeItem.is(killer.getInventory().getItemInMainHand()) + || BlackKnifeItem.is(killer.getInventory().getItemInOffHand())); } } diff --git a/src/main/java/me/sashegdev/blackknife/TensionManager.java b/src/main/java/me/sashegdev/blackknife/TensionManager.java index ca34bc6..9ccff7f 100644 --- a/src/main/java/me/sashegdev/blackknife/TensionManager.java +++ b/src/main/java/me/sashegdev/blackknife/TensionManager.java @@ -24,6 +24,40 @@ public class TensionManager implements Listener { private final Map ownerCache = new HashMap<>(); private final Map ownerCacheTime = new HashMap<>(); private static final long OWNER_CACHE_TTL = 2000L; + public static final float MAX_TP = 250f; + + private float passiveRegenPerTick = 0.25f; + private long passiveRegenDelayMs = 3000L; + private float hitGain = 7.5f; + private float critGain = 11.25f; + private float shieldGain = 25f; + private long shieldDelayMs = 5000L; + + private static final BlackKnifePlugin PLUGIN = BlackKnifePlugin.getInstance(); + + private float cfgFloat(String key, float def) { + if (PLUGIN == null) return def; + return (float) PLUGIN.getConfig().getDouble("tension." + key, def); + } + + private long cfgLong(String key, long def) { + if (PLUGIN == null) return def; + return PLUGIN.getConfig().getLong("tension." + key, def); + } + + /** Перечитывает экономику из конфига (на старте и при /blackknife reload). */ + public void reloadEconomy() { + passiveRegenPerTick = cfgFloat("passive-regen-per-tick", 0.25f); + passiveRegenDelayMs = cfgLong("passive-regen-delay-ms", 3000L); + hitGain = cfgFloat("hit-gain", 7.5f); + critGain = cfgFloat("crit-gain", 11.25f); + shieldGain = cfgFloat("shield-gain", 25f); + shieldDelayMs = cfgLong("shield-delay-ms", 5000L); + } + + public float getHitGain(boolean crit) { + return crit ? critGain : hitGain; + } public boolean tryGiveDodge(Player pl) { if (!isInBattle(pl)) return false; @@ -38,14 +72,14 @@ public class TensionManager implements Listener { public float getTP(Player p) { return tp.getOrDefault(p.getUniqueId(), 0f); } public void setTP(Player p, float v) { - float clamped = Math.max(0, Math.min(250, v)); + float clamped = Math.max(0, Math.min(MAX_TP, v)); tp.put(p.getUniqueId(), clamped); updateBar(p, clamped); } public void addTP(Player p, float amount, String reason) { if (!hasMagic(p)) return; - float cur = Math.min(250, getTP(p) + amount); + float cur = Math.min(MAX_TP, getTP(p) + amount); tp.put(p.getUniqueId(), cur); lastCombat.put(p.getUniqueId(), System.currentTimeMillis()); updateBar(p, cur); @@ -88,7 +122,7 @@ public class TensionManager implements Listener { bar = BossBar.bossBar(Component.text("TP 0%"), 0f, BossBar.Color.WHITE, BossBar.Overlay.NOTCHED_10); bars.put(p.getUniqueId(), bar); } - float pct = tpVal / 250f; + float pct = tpVal / MAX_TP; bar.progress(Math.max(0, Math.min(1, pct))); bar.name(Component.text(String.format("TP %.0f%%", pct * 100)).color(NamedTextColor.WHITE)); bar.color(BossBar.Color.WHITE); @@ -148,8 +182,8 @@ public class TensionManager implements Listener { if (blocking) { long start = blockStart.getOrDefault(p.getUniqueId(), now); if (!blockStart.containsKey(p.getUniqueId())) blockStart.put(p.getUniqueId(), now); - if (now - start >= 5000) { - addTP(p, 25f, "SHIELD"); + if (now - start >= shieldDelayMs) { + addTP(p, shieldGain, "SHIELD"); blockStart.put(p.getUniqueId(), now); lastCombat.put(p.getUniqueId(), now); } @@ -157,17 +191,23 @@ public class TensionManager implements Listener { blockStart.remove(p.getUniqueId()); } if (isOwner(p)) { - float cur = getTP(p); - if (cur < 250) { - float next = Math.min(250, cur + 2f); - tp.put(p.getUniqueId(), next); - updateBar(p, next); - } + tickPassiveRegen(p, now); } } tickBattle(); } + /** Пассивный реген — только вне боя, иначе TP становится вечной зарплатой. */ + private void tickPassiveRegen(Player p, long now) { + final float cur = getTP(p); + if (cur >= MAX_TP) return; + final Long lastCombatAt = lastCombat.get(p.getUniqueId()); + if (lastCombatAt != null && now - lastCombatAt < passiveRegenDelayMs) return; + final float next = Math.min(MAX_TP, cur + passiveRegenPerTick); + tp.put(p.getUniqueId(), next); + updateBar(p, next); + } + @EventHandler public void onJoin(PlayerJoinEvent e) { updateBar(e.getPlayer(), getTP(e.getPlayer())); invalidateOwnerCache(e.getPlayer()); } @@ -180,6 +220,11 @@ public class TensionManager implements Listener { ownerCacheTime.remove(id); blockStart.remove(id); lastDodge.remove(id); + // TP не персистится: рестарт обнуляет всех, и ливнувший тоже не должен + // унести полосу в памяти до следующего рестарта. + tp.remove(id); + lastCombat.remove(id); + AbilityCooldown.clearAll(e.getPlayer()); } @EventHandler diff --git a/src/main/resources/config.yml b/src/main/resources/config.yml index 4097bbe..af2153e 100644 --- a/src/main/resources/config.yml +++ b/src/main/resources/config.yml @@ -1,4 +1,11 @@ lang: ru + +display: + # На сколько тиков визуал (Display-сущности) уходит вперёд, чтобы клиент не + # отставал на пинге. 1 — как раньше, 0 — стоковое поведение (дёргается). + # Значение свыше 10 превращается не в предсказание, а в телепорт рывком. + lead-ticks: 1 + awakening: enabled: true tp_requires_magic: true @@ -10,3 +17,31 @@ awakening: lava: 0.04 void: 0.10 generic: 0.01 + +tension: + # Пассивный реген идёт только когда игрок не в бою дольше passive-regen-delay-ms. + # Боевые гейны (попадания, криты, додж, блок щитом) при этом не меняются. + passive-regen-per-tick: 0.25 + passive-regen-delay-ms: 3000 + hit-gain: 7.5 + crit-gain: 11.25 + # Щит держит TP: 25 = 10% полосы. При регене 0.25/тик это заметно больше, + # чем даёт бой, поэтому если щит станет «бесплатной зарплатой» — снижай. + shield-gain: 25 + shield-delay-ms: 5000 + +swoon: + # Урон наносится через DamageType.GENERIC_KILL: броня, чары, эффекты и + # щиты игнорируются, прочность брони не расходуется. + damage: 999.0 + + # Стоимость удара. По игроку фиксированная, по мобу растёт с HP. + player-cost: 225.0 + mob-base-cost: 12.5 + mob-cost-per-hp: 0.5 + mob-max-cost: 225.0 + + # Кулдаун обратно пропорционален потраченному TP: + # дешёвый PvE -> cooldown-min, дорогой PvP -> cooldown-max. + cooldown-min-ticks: 40 + cooldown-max-ticks: 160 \ No newline at end of file