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