rework: SWOON rewrite, tension economy, DIVE physics, predictive displays
SWOON: virtual HP deleted. Damage now goes through DamageType.GENERIC_KILL, which vanilla marks as bypassing armour, enchantments, effects and shields, and which consumes no armour durability. The old mechanic cancelled the damage outright and only regenened virtual HP back to 1, so a swoon could never be escaped by any means. - cost by mob max HP: clamp(mob-base-cost + mob-cost-per-hp * HP, base, max). Player cost is flat. Chicken 14.5 -> Zombie 22.5 -> Enderman 32.5 -> Warden 137. - cooldown inversely proportional to TP spent: trash mob 40t, player 160t. Moved off Material.NETHERITE_SWORD onto a per-ability key: the material cooldown is a shared per-material table, so it blocked every netherite sword in the inventory and any inventory rebuild wiped it. New AbilityCooldown. - death message detected statelessly from the damage type plus who holds the knife, so no marker state has to survive the death. - TP is now checked BEFORE the original hit is cancelled. Cancelling first made every swing without enough TP a free block: no damage, no gain, no progress. - 257 -> ~110 lines; the VHP/SWOONED checks in DamageUtil, ParryHandler, GrenadeAttack and SkyDiveAttack are gone with the mechanic they guarded. Tension: - passive regen 0.25/tick (was 2/tick, i.e. 100 TP/s) with a hard 3s pause after the last combat action. - hit gain 7.5, crit 11.25 (1.5x base instead of 2x). - onQuit clears TP. It only dropped bossbars and caches, so a quitting player left a full bar in memory for the rest of the uptime. - shield gain and delay moved into config; they were the only remaining hardcoded economy numbers. DIVE: - setGravity(false) for the whole ability. Gravity fought setVelocity(0) every tick and the flag reaches clients, so server and client now agree and hover is a real state instead of a fight over movement prediction. - own gravity constant and an analytic apex; the old code probed velocity three different ways. - one state machine with a single exit, so death, wall, logout, respawn and damage can never leave a player with gravity off. The runnables used to return without cancel() and leaked a per-tick task per dive. - ground contact for 3 ticks aborts the dive without damage. Unimplemented before, so you could dive into the ground, walk to the target and hit them. - dash 3.0 blocks/tick over 60 ticks (was 2.8/40). - hover pins X/Z only, camera stays free, no per-tick actionbar spam. - quitting mid-dive refunds the unused knife cooldown. - fall damage cancelled only while diving and for 2.5s after, not for the whole cooldown. Displays: predictive teleport. A visual is spawned N ticks along its own trajectory and told to interpolate the teleport over those same N ticks, so the client never lags behind and the packet count is unchanged. Duration 1 is effectively no smoothing, which is why the visuals snapped on real ping. Applied to DIAMOND, DIAMOND_TRI, LUNGE (catalyst, sword, overhead, volley) and GRENADE. lead-ticks is configurable; 0 restores stock behaviour.
This commit is contained in:
@@ -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;
|
||||
|
||||
/**
|
||||
* Кулдауны, привязанные к способности, а не к материалу.
|
||||
*
|
||||
* <p>Bukkit умеет кулдаун только по {@link org.bukkit.Material}, и это общая
|
||||
* таблица на материал: {@code setCooldown(NETHERITE_SWORD, 160)} глушит любой
|
||||
* незеритовый меч в инвентаре, включая обычный, и заодно обнуляется любой
|
||||
* пересборкой инвентаря. Для способности это неверно — её кулдаун должен
|
||||
* принадлежать ей.
|
||||
*
|
||||
* <p>Хранится простое отображение «игрок + ключ способности → тик готовности».
|
||||
* Время берётся из {@link Bukkit#getCurrentTick()}, а не из wall clock, поэтому
|
||||
* кулдаун идёт ровно в игровых тиках и не растягивается при лагах.
|
||||
*/
|
||||
public final class AbilityCooldown {
|
||||
|
||||
private AbilityCooldown() {
|
||||
}
|
||||
|
||||
private static final Map<UUID, Map<String, Integer>> READY_AT = new ConcurrentHashMap<>();
|
||||
|
||||
/** Осталось тиков до готовности. 0 = можно использовать. */
|
||||
public static int remaining(final Player p, final String ability) {
|
||||
final Map<String, Integer> 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<String, Integer> map = READY_AT.get(p.getUniqueId());
|
||||
if (map != null) {
|
||||
map.remove(ability);
|
||||
}
|
||||
}
|
||||
|
||||
public static void clearAll(final Player p) {
|
||||
READY_AT.remove(p.getUniqueId());
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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(), () -> {
|
||||
|
||||
@@ -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; }
|
||||
|
||||
@@ -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() {
|
||||
|
||||
@@ -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());
|
||||
}
|
||||
|
||||
/**
|
||||
* Плавность визуала через предсказывающую телепортацию.
|
||||
*
|
||||
* <p>Display двигается пакетами позиции, и клиент интерполирует их у себя —
|
||||
* на реальном пинге визуал отстаёт и дёргается. Лечится не частыми пакетами
|
||||
* (это ровно тот трафик, который мы убираем), а сдвигом старта: визуал
|
||||
* рождается сразу на {@code N} тиков вперёд по своей же траектории, а
|
||||
* {@code teleport_duration = N} говорит клиенту растянуть телепорт на эти
|
||||
* же N тиков. Клиент никогда не отстаёт, пакетов столько же.
|
||||
*
|
||||
* <p>Сдвиг именно по траектории, поэтому визуал на 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) {
|
||||
|
||||
@@ -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();
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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() {
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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();
|
||||
|
||||
@@ -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 — взлёт, зависание и пикирование.
|
||||
*
|
||||
* <p>Раньше гравитация постоянно воевала с {@code setVelocity(0)} каждый тик, а
|
||||
* апекс искался тремя перекрывающимися эвристиками. Здесь гравитация на время
|
||||
* способности выключена через {@code setGravity(false)} — флаг уходит клиентам,
|
||||
* поэтому и сервер, и клиент перестают её считать, и зависание становится
|
||||
* естественным состоянием, а не борьбой с предсказанием движения.
|
||||
*
|
||||
* <p>Состояния ведёт один автомат с единственным владельцем: любой выход —
|
||||
* урон, стена, релог, смерть, респавн — проходит через {@link #restore},
|
||||
* поэтому игрок не может остаться с нулевой гравитацией навсегда.
|
||||
*/
|
||||
public class SkyDiveAttack implements Listener {
|
||||
|
||||
private static final float COST = 87.5f;
|
||||
private static final double DAMAGE = 25.0;
|
||||
|
||||
/** Кулдаун ножа на всё пике. Возвращается, если игрок вышел на середине. */
|
||||
public static final int ITEM_COOLDOWN_TICKS = 120;
|
||||
|
||||
private static final double LAUNCH_UP = 1.6;
|
||||
private static final double LAUNCH_FORWARD = 0.4;
|
||||
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<UUID, Location> frozen = new ConcurrentHashMap<>();
|
||||
private static final Map<UUID, Long> immuneUntil = new ConcurrentHashMap<>();
|
||||
private static final Map<UUID, Boolean> 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<UUID, State> STATE = new ConcurrentHashMap<>();
|
||||
private static final Map<UUID, Location> HOVER_ANCHOR = new ConcurrentHashMap<>();
|
||||
private static final Map<UUID, Long> GRACE_UNTIL = new ConcurrentHashMap<>();
|
||||
private static final Map<UUID, Long> DIVE_START = new ConcurrentHashMap<>();
|
||||
private static final Map<UUID, org.bukkit.Material> COOLDOWN_ITEM = new ConcurrentHashMap<>();
|
||||
|
||||
private final BlackKnifePlugin plugin;
|
||||
|
||||
public SkyDiveAttack(BlackKnifePlugin plugin) {
|
||||
this.plugin = plugin;
|
||||
}
|
||||
|
||||
public static boolean isDiving(Player p) {
|
||||
return STATE.get(p.getUniqueId()) != null;
|
||||
}
|
||||
|
||||
public static boolean tryUse(BlackKnifePlugin plugin, Player p, TensionManager tension) {
|
||||
final SkyDiveAttack dive = plugin.getDiveAttack();
|
||||
if (dive == null) return false;
|
||||
return dive.start(p, tension);
|
||||
}
|
||||
|
||||
private boolean start(Player p, TensionManager tension) {
|
||||
if (p.getGameMode() == GameMode.SPECTATOR) return false;
|
||||
if (isDiving(p)) {
|
||||
p.sendActionBar(Component.text("Already diving!").color(NamedTextColor.RED));
|
||||
return false;
|
||||
}
|
||||
@@ -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());
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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 — летальный удар ножом.
|
||||
*
|
||||
* <p>Бьёт 999 урона через {@link DamageType#GENERIC_KILL}: в ванилле этот тип
|
||||
* помечен как обходящий броню, чары, эффекты и щиты. Поэтому урон всегда
|
||||
* смертелен, а прочность брони вообще не расходуется — полный сет переживает
|
||||
* удар целиком. Раньше здесь была механика виртуальных HP (−999 и набор до 1
|
||||
* регенерацией), но она давала пожизненное заключение: урон был полностью
|
||||
* отменён, значит умереть и вырваться было нечем.
|
||||
*
|
||||
* <p>Стоимость и кулдаун зависят от цели, кулдаун обратно пропорционален
|
||||
* потраченному 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<UUID, TextDisplay> 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()));
|
||||
}
|
||||
}
|
||||
|
||||
@@ -24,6 +24,40 @@ public class TensionManager implements Listener {
|
||||
private final Map<UUID, Boolean> ownerCache = new HashMap<>();
|
||||
private final Map<UUID, Long> 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
|
||||
|
||||
@@ -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
|
||||
Reference in New Issue
Block a user