package me.sashegdev.blackknife; import net.kyori.adventure.bossbar.BossBar; import net.kyori.adventure.text.Component; import net.kyori.adventure.text.format.NamedTextColor; import org.bukkit.entity.Player; import org.bukkit.event.EventHandler; import org.bukkit.event.Listener; import org.bukkit.event.entity.PlayerDeathEvent; import org.bukkit.event.player.PlayerJoinEvent; import org.bukkit.event.player.PlayerQuitEvent; import java.util.HashMap; import java.util.Map; import java.util.UUID; public class TensionManager implements Listener { private final Map tp = new HashMap<>(); final Map bars = new HashMap<>(); private final Map blockStart = new HashMap<>(); private final Map lastCombat = new HashMap<>(); private final Map> battleEnemies = new HashMap<>(); private final Map lastDodge = new HashMap<>(); /** * Кеш владельца ножа: [в руках, где-то в инвентаре]. * *

Ключевой момент — кеш читается ПЕРВЫМ, а не после быстрого пути. * Раньше {@link #isOwner} сначала звал {@code BlackKnifeItem.is()} на обеих * руках и только потом смотрел кеш, то есть кеш не срабатывал никогда. * {@code ItemStack#getItemMeta()} возвращает полную копию ItemMeta вместе с * PDC — это аллокация и копирование NBT на каждый вызов. А {@link #tickShield} * зовёт {@code isOwner} каждый тик на каждого игрока, то есть при 60 игроках * это было 2400 копий ItemMeta в секунду. Теперь на попадание в кеш — ноль * аллокаций, а пересчёт раз в OWNER_CACHE_TTL. */ private final Map ownerCache = new HashMap<>(); private final Map ownerCacheTime = new HashMap<>(); private final Map lastPct = new HashMap<>(); private static final long OWNER_CACHE_TTL = 2000L; public static final float MAX_TP = 250f; /** Бой считается кончившимся после этого простоя без TP-активности. */ private static final long BATTLE_EXPIRE_MS = 180_000L; 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; long now = System.currentTimeMillis(); Long last = lastDodge.get(pl.getUniqueId()); if (last != null && now - last < 1000) return false; lastDodge.put(pl.getUniqueId(), now); addTP(pl, 2.5f, "DODGE +1%"); return true; } 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(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(MAX_TP, getTP(p) + amount); tp.put(p.getUniqueId(), cur); lastCombat.put(p.getUniqueId(), System.currentTimeMillis()); updateBar(p, cur); if (amount > 0) p.sendActionBar(Component.text(String.format("+%.0f%% TP %s", amount / 2.5f, reason)).color(NamedTextColor.YELLOW)); } public boolean isOwner(Player p) { return knifeState(p, true); } /** Нож в руках — дешевле, нужно превью прицеливания и {@link #hasMagic}. */ public boolean hasKnifeInHands(Player p) { return knifeState(p, false); } private boolean knifeState(Player p, boolean anywhere) { final UUID id = p.getUniqueId(); final long now = System.currentTimeMillis(); boolean[] cached = ownerCache.get(id); final Long t = ownerCacheTime.get(id); if (cached != null && t != null && now - t < OWNER_CACHE_TTL) { return cached[anywhere ? 1 : 0]; } final boolean inHands = BlackKnifeItem.is(p.getInventory().getItemInMainHand()) || BlackKnifeItem.is(p.getInventory().getItemInOffHand()); final boolean has = inHands || hasAnyBlackKnife(p); ownerCache.put(id, new boolean[]{inHands, has}); ownerCacheTime.put(id, now); return anywhere ? has : inHands; } public void invalidateOwnerCache(Player p) { ownerCache.remove(p.getUniqueId()); ownerCacheTime.remove(p.getUniqueId()); } private boolean hasAnyBlackKnife(Player p) { for (var it : p.getInventory().getContents()) if (BlackKnifeItem.is(it)) return true; return false; } public boolean hasMagic(Player p) { if (hasKnifeInHands(p)) return true; if (isOwner(p)) return true; var mgr = BlackKnifePlugin.getInstance() != null ? BlackKnifePlugin.getInstance().getMagicManager() : null; return mgr != null && mgr.hasAwakened(p.getUniqueId()); } /** * Перерисовка полосы. Вызывается каждый тик на каждого игрока, поэтому * работает по дешёвому пути: пакет прогресса и новый Component уходят только * когда изменился целый процент. При регене 0.25/тик это 4 перерисовки в * секунду вместо 20, а на полной полосе — ноль. * *

Проценты считаются целочисленно: {@code String.format} на каждом тике * на каждого игрока это сотни вызовов в секунду ради строки, которую никто * не может прочитать быстрее, чем за кадр. */ private void updateBar(Player p, float tpVal) { final int pct = (int) Math.round(Math.max(0.0D, Math.min(1.0D, (double) tpVal / MAX_TP)) * 100.0D); if (lastPct.getOrDefault(p.getUniqueId(), -1) == pct) { return; } lastPct.put(p.getUniqueId(), pct); BossBar bar = bars.get(p.getUniqueId()); if (bar == null) { bar = BossBar.bossBar(Component.text("TP 0%"), 0f, BossBar.Color.WHITE, BossBar.Overlay.NOTCHED_10); bars.put(p.getUniqueId(), bar); } bar.progress(pct / 100.0F); bar.name(Component.text("TP " + pct + "%").color(NamedTextColor.WHITE)); bar.color(BossBar.Color.WHITE); if (tpVal > 0) bar.addViewer(p); else bar.removeViewer(p); } public void showFor(Player p) { // Полосу создаём здесь, а не через updateBar: updateBar выходит раньше, // если процент не изменился, и полоса тогда осталась бы null. BossBar bar = bars.computeIfAbsent(p.getUniqueId(), id -> BossBar.bossBar(Component.text("TP 0%"), 0f, BossBar.Color.WHITE, BossBar.Overlay.NOTCHED_10)); if (getTP(p) > 0) bar.addViewer(p); } public boolean isInBattle(Player p) { java.util.Set set = battleEnemies.get(p.getUniqueId()); return set != null && !set.isEmpty(); } public void startBattle(Player attacker, org.bukkit.entity.LivingEntity defender) { battleEnemies.computeIfAbsent(attacker.getUniqueId(), k -> new java.util.HashSet<>()).add(defender.getUniqueId()); lastCombat.put(attacker.getUniqueId(), System.currentTimeMillis()); if (getTP(attacker) > 0) showFor(attacker); if (defender instanceof Player dp) { battleEnemies.computeIfAbsent(dp.getUniqueId(), k -> new java.util.HashSet<>()).add(attacker.getUniqueId()); lastCombat.put(dp.getUniqueId(), System.currentTimeMillis()); if (getTP(dp) > 0) showFor(dp); } } public void tickBattle() { final long now = System.currentTimeMillis(); // Ключи на удаление собираются в список и применяются после обхода. // Раньше здесь на каждый тик делалась полная копия battleEnemies вместе // со всеми вложенными сетами — ради того, чтобы итерировать и одновременно // удалять из той же карты. Это 20 полных аллокаций карты в секунду // на каждого боя, и копия тем больше, чем длиннее бой. java.util.List drop = null; for (var e : battleEnemies.entrySet()) { final UUID pid = e.getKey(); final java.util.Set set = e.getValue(); set.removeIf(uuid -> { final var ent = org.bukkit.Bukkit.getEntity(uuid); return ent == null || !ent.isValid() || (ent instanceof org.bukkit.entity.LivingEntity le && le.isDead()); }); final Long last = lastCombat.get(pid); // lastCombat == null означает, что бой не начинался или уже вышел из // состояния. Множество при этом может быть непустым (противник жив), // и тогда условие истечения по времени не проверяется вовсе — запись // остаётся навсегда. Без боя и без отметки времени бой не бой. if (last == null || set.isEmpty() || (now - last > BATTLE_EXPIRE_MS)) { if (drop == null) { drop = new java.util.ArrayList<>(4); } drop.add(pid); } } if (drop != null) { for (final UUID pid : drop) { battleEnemies.remove(pid); lastCombat.remove(pid); } } } public boolean hasHeldShield(Player p) { return p.isBlocking() && (p.getInventory().getItemInMainHand().getType() == org.bukkit.Material.SHIELD || p.getInventory().getItemInOffHand().getType() == org.bukkit.Material.SHIELD); } public void tickShield() { long now = System.currentTimeMillis(); for (Player p : org.bukkit.Bukkit.getOnlinePlayers()) { boolean blocking = hasHeldShield(p); if (blocking) { long start = blockStart.getOrDefault(p.getUniqueId(), now); if (!blockStart.containsKey(p.getUniqueId())) blockStart.put(p.getUniqueId(), now); if (now - start >= shieldDelayMs) { addTP(p, shieldGain, "SHIELD"); blockStart.put(p.getUniqueId(), now); lastCombat.put(p.getUniqueId(), now); } } else { blockStart.remove(p.getUniqueId()); } if (isOwner(p)) { 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()); } @EventHandler public void onQuit(PlayerQuitEvent e) { UUID id = e.getPlayer().getUniqueId(); BossBar bar = bars.remove(id); if (bar != null) bar.removeViewer(e.getPlayer()); ownerCache.remove(id); ownerCacheTime.remove(id); blockStart.remove(id); lastDodge.remove(id); // TP не персистится: рестарт обнуляет всех, и ливнувший тоже не должен // унести полосу в памяти до следующего рестарта. tp.remove(id); lastCombat.remove(id); lastPct.remove(id); // Утечка: battleEnemies на выходе не чистился. Расчёт на то, что запись // исчезнет сама через BATTLE_EXPIRE_MS, но этот путь требует непустого // lastCombat — а его мы только что удалили строкой выше. В итоге запись // могла жить вечно: пока в бою есть противник, который не умирает // (босс на ферме, запертый моб), множество не пустеет, lastCombat равен // null, и условие истечения не проверяется. Дальше tickBattle обходит // эту запись каждый тик до конца жизни сервера. battleEnemies.remove(id); AbilityCooldown.clearAll(e.getPlayer()); } @EventHandler public void onInvChange(org.bukkit.event.inventory.InventoryClickEvent e) { if (e.getWhoClicked() instanceof Player p) invalidateOwnerCache(p); } @EventHandler public void onPickup(org.bukkit.event.entity.EntityPickupItemEvent e) { if (e.getEntity() instanceof Player p) invalidateOwnerCache(p); } @EventHandler public void onDrop(org.bukkit.event.player.PlayerDropItemEvent e) { invalidateOwnerCache(e.getPlayer()); } @EventHandler public void onHeld(org.bukkit.event.player.PlayerItemHeldEvent e) { invalidateOwnerCache(e.getPlayer()); } public void hideAll() { for (var e : new HashMap<>(bars).entrySet()) { var bar = e.getValue(); var p = org.bukkit.Bukkit.getPlayer(e.getKey()); if (p != null) bar.removeViewer(p); else { for (var pl : org.bukkit.Bukkit.getOnlinePlayers()) bar.removeViewer(pl); } } bars.clear(); blockStart.clear(); lastDodge.clear(); } @EventHandler public void onDeath(PlayerDeathEvent e) { Player p = e.getEntity(); tp.put(p.getUniqueId(), 0f); // Синхронизируем кеш: иначе после смерти updateBar увидит прежний // процент, решит что ничего не изменилось, и не перерисует полосу. lastPct.put(p.getUniqueId(), 0); BossBar bar = bars.get(p.getUniqueId()); if (bar != null) { bar.progress(0); bar.name(Component.text("TP 0%")); bar.removeViewer(p); } } }