82bb585f88
Обе карты росли без границы и никогда не освобождались. 1. MagicListener.lastCast / lastCastTime Писались на каждом применении магии (recordCast) и не удалялись НИКОГДА — ни на выходе, ни на смерти. Каждый игрок, хоть раз применивший магию, навсегда оставлял две записи. Данные копеечные, но карты растут без границы весь срок жизни сервера. Очистка вынесена в общий forget(UUID), который зовут и onQuit, и onDeath. 2. TensionManager.battleEnemies На выходе не удалялся. Расчёт был на то, что запись исчезнет сама через BATTLE_EXPIRE_MS, но эта проверка требует непустого lastCombat — а он удалялся в той же строке выше. В итоге запись жила вечно, если в бою был противник, который не умирает: босс или запертый моб на ферме. Множество не пустеет, lastCombat == null, условие истечения не проверяется, и tickBattle обходит эту запись каждый тик до конца жизни сервера. Дополнительно tickBattle теперь снимает боевую запись и при lastCombat == null, а не только по таймауту. Это закрывает не только выход, но и любую другую рассинхронизацию: без боя и без отметки времени бой не бой.
344 lines
16 KiB
Java
344 lines
16 KiB
Java
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<UUID, Float> tp = new HashMap<>();
|
||
final Map<UUID, BossBar> bars = new HashMap<>();
|
||
private final Map<UUID, Long> blockStart = new HashMap<>();
|
||
private final Map<UUID, Long> lastCombat = new HashMap<>();
|
||
private final Map<UUID, java.util.Set<UUID>> battleEnemies = new HashMap<>();
|
||
private final Map<UUID, Long> lastDodge = new HashMap<>();
|
||
/**
|
||
* Кеш владельца ножа: [в руках, где-то в инвентаре].
|
||
*
|
||
* <p>Ключевой момент — кеш читается ПЕРВЫМ, а не после быстрого пути.
|
||
* Раньше {@link #isOwner} сначала звал {@code BlackKnifeItem.is()} на обеих
|
||
* руках и только потом смотрел кеш, то есть кеш не срабатывал никогда.
|
||
* {@code ItemStack#getItemMeta()} возвращает полную копию ItemMeta вместе с
|
||
* PDC — это аллокация и копирование NBT на каждый вызов. А {@link #tickShield}
|
||
* зовёт {@code isOwner} каждый тик на каждого игрока, то есть при 60 игроках
|
||
* это было 2400 копий ItemMeta в секунду. Теперь на попадание в кеш — ноль
|
||
* аллокаций, а пересчёт раз в OWNER_CACHE_TTL.
|
||
*/
|
||
private final Map<UUID, boolean[]> ownerCache = new HashMap<>();
|
||
private final Map<UUID, Long> ownerCacheTime = new HashMap<>();
|
||
private final Map<UUID, Integer> 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, а на полной полосе — ноль.
|
||
*
|
||
* <p>Проценты считаются целочисленно: {@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<UUID> 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<UUID> drop = null;
|
||
for (var e : battleEnemies.entrySet()) {
|
||
final UUID pid = e.getKey();
|
||
final java.util.Set<UUID> 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); }
|
||
}
|
||
}
|