perf: cut per-tick allocation and collapse STARS into one scheduler task

Цель — не средний тик, а разброс: p95 26.9 мс и пики под 170 мс на 60 ботах
бьют по игрокам сильнее, чем медиана 14.8 мс. Почти весь этот разброс шёл
от мусора в горячих путях.

1. Кеш владельца ножа переехал в начало проверки.
   Раньше isOwner() сначала звал BlackKnifeItem.is() на обеих руках и только
   потом смотрел ownerCache, то есть кеш не срабатывал никогда. is() обязан
   взять ItemMeta, а Bukkit возвращает там полную копию метаданных вместе с
   PDC. tickShield() зовёт isOwner каждый тик на каждого игрока: при 60
   игроках это 2400 копий ItemMeta в секунду. Теперь на попадание в кеш —
   ноль аллокаций, пересчёт раз в 2 с. Кеш хранит оба ответа сразу
   (в руках / где-то в инвентаре), потому что hasMagic и превью интересуют
   разные вещи.

2. BlackKnifeItem.inHands() вместо пары is() в горячих путях:
   7 проверок превью прицеливания и SWOON на каждый удар.

3. DustOptions вынесены в константы Particles.
   36 мест создавали new Particle.DustOptions(Color.fromRGB(...)), и почти все
   внутри циклов или задач с периодом в несколько тиков. DustOptions
   иммутабелен, поэтому один объект на цвет. Отдельно вынесен тернарник,
   выбиравший цвет кольца ВНУТРИ цикла в превью SNIPE/TRI: он пересчитывал
   один и тот же цвет на каждой из 12-16 итераций.

4. getGazePoint() больше не зовёт getEyeLocation() три раза и getDirection()
   два. Метод работает каждые 4 тика на каждого игрока с ножом.

5. Визуал STARS вынесен в StarFx под одну задачу.
   Раньше 18 звёзд и до 108 осколков получали по своей задаче с периодом в
   один тик — до 126 задач планировщика на одно нажатие, каждая со своим
   getNearbyEntities и getNearbyPlayers. Теперь один список под одной
   задачей, которая снимает себя сама в простое.

   Попутно исправлен баг: EntityTracker.untrack() вызывался на каждом тике,
   пока сущность жива, поэтому после первого тика она исчезала из множества
   отслеживаемых и EntityTracker.cleanup() её больше не находил. Теперь
   untrack вызывается один раз, при удалении.
This commit is contained in:
SashegDev
2026-10-04 13:11:14 +00:00
parent 04bad6f32b
commit 47046ee273
15 changed files with 448 additions and 149 deletions
@@ -5,6 +5,7 @@ import net.kyori.adventure.text.format.NamedTextColor;
import net.kyori.adventure.text.format.TextDecoration;
import org.bukkit.Material;
import org.bukkit.NamespacedKey;
import org.bukkit.entity.Player;
import org.bukkit.inventory.ItemStack;
import org.bukkit.inventory.meta.ItemMeta;
import org.bukkit.persistence.PersistentDataType;
@@ -34,4 +35,18 @@ public class BlackKnifeItem {
var meta = item.getItemMeta();
return meta != null && meta.getPersistentDataContainer().has(KEY, PersistentDataType.BYTE);
}
/**
* Нож в руках игрока.
*
* <p>Отдельный метод вместо пары вызовов {@link #is} по инвентарю, потому что
* {@code getItemMeta()} возвращает полную копию метаданных вместе с PDC, то
* есть на каждый вызов это аллокация и копирование NBT. Здесь та же проверка,
* но её результат берётся из кеша владельца — ноль аллокаций на попадание.
*/
public static boolean inHands(Player p) {
var plugin = BlackKnifePlugin.getInstance();
return plugin != null && plugin.getTension() != null
&& plugin.getTension().hasKnifeInHands(p);
}
}
@@ -7,6 +7,7 @@ import org.bukkit.Location;
import org.bukkit.Material;
import org.bukkit.Particle;
import org.bukkit.Sound;
import org.bukkit.World;
import org.bukkit.entity.Display;
import org.bukkit.entity.LivingEntity;
import org.bukkit.entity.Player;
@@ -67,14 +68,31 @@ public class BlackKnifeListener implements Listener {
clearHighlight(p);
}
/**
* Точка, в которую смотрит игрок.
*
* <p>Раньше здесь вызывалось getEyeLocation() три раза и getDirection() два.
* Каждый вызов — новая аллокация: Location и Vector у Bukkit копируются, а не
* отдаются ссылкой. Метод зовётся из превью прицеливания каждые 4 тика на
* каждого игрока с ножом, то есть на 60 игроках это 90 лишних Location и
* 60 лишних Vector в секунду только на вычисление одного и того же вектора.
*
* <p>Сам rayTraceBlocks на 120 блоков остался: он определяет, во что попал
* взгляд, и его результат меняется вместе с обзором игрока.
*/
public Location getGazePoint(Player p) {
var ray = p.getWorld().rayTraceBlocks(p.getEyeLocation(), p.getEyeLocation().getDirection(), 120);
final Location eye = p.getEyeLocation();
final Vector dir = eye.getDirection();
final World world = eye.getWorld();
final var ray = world.rayTraceBlocks(eye, dir, 120);
if (ray != null && ray.getHitPosition() != null) {
Location loc = new Location(p.getWorld(), ray.getHitPosition().getX(), ray.getHitPosition().getY(), ray.getHitPosition().getZ());
final Location loc = new Location(world,
ray.getHitPosition().getX(), ray.getHitPosition().getY(), ray.getHitPosition().getZ());
if (ray.getHitBlockFace() != null) loc.add(0, 0.1, 0);
return loc;
}
return p.getEyeLocation().add(p.getEyeLocation().getDirection().normalize().multiply(20));
return eye.add(dir.normalize().multiply(20));
}
/** Дальность поиска цели, блоков. */
@@ -296,7 +314,7 @@ public class BlackKnifeListener implements Listener {
@Override public void run() {
if (!p.isOnline() || !getMode(p).equals("SWORD")) { previewTasks.remove(p.getUniqueId()); cancel(); return; }
if (p.hasCooldown(Material.NETHERITE_SWORD)) return;
if (!BlackKnifeItem.is(p.getInventory().getItemInMainHand()) && !BlackKnifeItem.is(p.getInventory().getItemInOffHand())) return;
if (!BlackKnifeItem.inHands(p)) return;
Vector dir = p.getEyeLocation().getDirection().normalize();
Vector right = dir.clone().crossProduct(new Vector(0, 1, 0));
if (right.lengthSquared() < 0.01) right = new Vector(1, 0, 0);
@@ -306,15 +324,15 @@ public class BlackKnifeListener implements Listener {
if (combo < 2) {
for (int i = -2; i <= 2; i++) {
Location pp = base.clone().add(right.clone().multiply(i * 0.3)).add(dir.clone().multiply(Math.abs(i) * 0.15));
p.spawnParticle(Particle.DUST, pp, 1, 0, 0, 0, 0, new Particle.DustOptions(Color.fromRGB(0, 0, 0), 1.3f));
p.spawnParticle(Particle.DUST, pp, 1, 0, 0, 0, 0, Particles.BLACK_13);
}
p.spawnParticle(Particle.DUST, base.clone().add(dir.clone().multiply(1.2)), 1, 0, 0, 0, 0, new Particle.DustOptions(Color.fromRGB(0, 0, 0), 0.8f));
p.spawnParticle(Particle.DUST, base.clone().add(dir.clone().multiply(1.2)), 1, 0, 0, 0, 0, Particles.BLACK_08);
} else {
for (int side = -1; side <= 1; side++) {
Location c = base.clone().add(right.clone().multiply(side * 1.3));
for (int i = -2; i <= 2; i++) {
Location pp = c.clone().add(right.clone().multiply(i * 0.22)).add(dir.clone().multiply(Math.abs(i) * 0.1));
p.spawnParticle(Particle.DUST, pp, 1, 0, 0, 0, 0, new Particle.DustOptions(Color.fromRGB(50, 0, 0), 1.3f));
p.spawnParticle(Particle.DUST, pp, 1, 0, 0, 0, 0, Particles.DARKRED_13);
}
}
}
@@ -327,29 +345,35 @@ public class BlackKnifeListener implements Listener {
@Override public void run() {
if (!p.isOnline() || !(getMode(p).equals("SNIPE") || getMode(p).equals("TRI"))) { cancel(); return; }
if (p.hasCooldown(Material.NETHERITE_SWORD)) return;
if (!BlackKnifeItem.is(p.getInventory().getItemInMainHand()) && !BlackKnifeItem.is(p.getInventory().getItemInOffHand())) return;
if (!BlackKnifeItem.inHands(p)) return;
LivingEntity target = getLungeTarget(p);
if (target == null) return;
Location tl = target.getLocation().add(0, 1, 0);
int ringCount = m.equals("TRI") ? 16 : 12;
double r1 = m.equals("SNIPE") ? 0.6 : 0.8;
// Цвет кольца выбирается ОДИН раз до цикла. Раньше тернарник с
// тремя Color.fromRGB стоял внутри цикла, то есть на каждой из
// 12-16 итераций вычислялся заново цвет, который за цикл не
// меняется: это до 16 лишних Color и DustOptions на каждый тик
// прицеливания, каждые 5 тиков, на каждого игрока.
final Particle.DustOptions ringDust = m.equals("TRI") ? Particles.TRI_15
: m.equals("SNIPE") ? Particles.SNIPE_15 : Particles.AIM_15;
final int ringCount = m.equals("TRI") ? 16 : 12;
final double r1 = m.equals("SNIPE") ? 0.6 : 0.8;
for (int i = 0; i < ringCount; i++) {
double angle = (double) i / ringCount * Math.PI * 2;
double x = Math.cos(angle) * r1;
double z = Math.sin(angle) * r1;
Location ring = tl.clone().add(x, 0, z);
Color col = m.equals("TRI") ? Color.fromRGB(80, 20, 80) : m.equals("SNIPE") ? Color.fromRGB(20, 100, 160) : Color.fromRGB(30, 80, 120);
p.spawnParticle(Particle.DUST, ring, 1, 0, 0, 0, 0, new Particle.DustOptions(col, 1.5f));
p.spawnParticle(Particle.DUST, ring, 1, 0, 0, 0, 0, ringDust);
}
if (m.equals("TRI")) {
for (int k = 0; k < 3; k++) {
double a = k * 120 * Math.PI / 180;
Location dot = tl.clone().add(Math.cos(a) * r1, 0, Math.sin(a) * r1);
p.spawnParticle(Particle.DUST, dot, 1, 0, 0, 0, 0, new Particle.DustOptions(Color.fromRGB(255, 50, 50), 1.3f));
p.spawnParticle(Particle.DUST, dot, 1, 0, 0, 0, 0, Particles.RED_13);
}
}
p.spawnParticle(Particle.SCULK_SOUL, tl, 3, 0.3, 0.5, 0.3, 0.02);
p.spawnParticle(Particle.DUST, tl.clone().add(0, 2, 0), 1, 0, 0, 0, 0, new Particle.DustOptions(Color.fromRGB(255, 50, 50), 1.0f));
p.spawnParticle(Particle.DUST, tl.clone().add(0, 2, 0), 1, 0, 0, 0, 0, Particles.RED_10);
}
};
r.runTaskTimer(plugin, 1L, 5L);
@@ -359,7 +383,7 @@ public class BlackKnifeListener implements Listener {
@Override public void run() {
if (!p.isOnline() || !getMode(p).equals("OVERHEAD")) { cancel(); return; }
if (p.hasCooldown(Material.NETHERITE_SWORD)) return;
if (!BlackKnifeItem.is(p.getInventory().getItemInMainHand()) && !BlackKnifeItem.is(p.getInventory().getItemInOffHand())) return;
if (!BlackKnifeItem.inHands(p)) return;
Location gp = getGazePoint(p);
// 32 отдельных пакета на тик прицеливания -> 2 пакета.
// Кольцо radius 1.8 раскладывается диском, на масштабе
@@ -372,7 +396,7 @@ public class BlackKnifeListener implements Listener {
Location ring = gp.clone().add(Math.cos(a) * 1, 0.1, Math.sin(a) * 1);
p.spawnParticle(Particle.SCULK_SOUL, ring, 1, 0.02, 0.02, 0.02, 0);
}
p.spawnParticle(Particle.DUST, gp.clone().add(0, 0.1, 0), 1, 0, 0, 0, 0, new Particle.DustOptions(Color.fromRGB(255, 40, 40), 1.5f));
p.spawnParticle(Particle.DUST, gp.clone().add(0, 0.1, 0), 1, 0, 0, 0, 0, Particles.RED_15);
}
};
r.runTaskTimer(plugin, 1L, 4L);
@@ -382,17 +406,17 @@ public class BlackKnifeListener implements Listener {
@Override public void run() {
if (!p.isOnline() || !getMode(p).equals("GRENADE")) { cancel(); return; }
if (p.hasCooldown(Material.NETHERITE_SWORD)) return;
if (!BlackKnifeItem.is(p.getInventory().getItemInMainHand()) && !BlackKnifeItem.is(p.getInventory().getItemInOffHand())) return;
if (!BlackKnifeItem.inHands(p)) return;
Location gp = GrenadeAttack.computeTarget(p, p.getEyeLocation(), p.getEyeLocation().getDirection().normalize());
for (int i = 0; i < 16; i++) {
double a = (double) i / 16 * Math.PI * 2;
Location ring = gp.clone().add(Math.cos(a) * 1.5, 0.1, Math.sin(a) * 1.5);
p.spawnParticle(Particle.DUST, ring, 1, 0, 0, 0, 0, new Particle.DustOptions(Color.fromRGB(255, 40, 40), 1.2f));
p.spawnParticle(Particle.DUST, ring, 1, 0, 0, 0, 0, Particles.RED_12);
}
for (int i = 0; i < 24; i++) {
double a = (double) i / 24 * Math.PI * 2;
Location ring = gp.clone().add(Math.cos(a) * 3.0, 0.1, Math.sin(a) * 3.0);
p.spawnParticle(Particle.DUST, ring, 1, 0, 0, 0, 0, new Particle.DustOptions(Color.fromRGB(255, 200, 60), 1.0f));
p.spawnParticle(Particle.DUST, ring, 1, 0, 0, 0, 0, Particles.GOLD_10);
}
}
};
@@ -403,12 +427,12 @@ public class BlackKnifeListener implements Listener {
@Override public void run() {
if (!p.isOnline() || !getMode(p).equals("DIVE")) { cancel(); return; }
if (p.hasCooldown(Material.NETHERITE_SWORD)) return;
if (!BlackKnifeItem.is(p.getInventory().getItemInMainHand()) && !BlackKnifeItem.is(p.getInventory().getItemInOffHand())) return;
if (!BlackKnifeItem.inHands(p)) return;
Location gp = getGazePoint(p);
for (int i = 0; i < 16; i++) {
double a = (double) i / 16 * Math.PI * 2;
Location ring = gp.clone().add(Math.cos(a) * 1.2, 0.1, Math.sin(a) * 1.2);
p.spawnParticle(Particle.DUST, ring, 1, 0, 0, 0, 0, new Particle.DustOptions(Color.fromRGB(80, 160, 255), 1.2f));
p.spawnParticle(Particle.DUST, ring, 1, 0, 0, 0, 0, Particles.BLUE_12);
}
p.spawnParticle(Particle.CLOUD, gp.clone().add(0, 0.2, 0), 1, 0.1, 0.1, 0.1, 0);
}
@@ -420,9 +444,9 @@ public class BlackKnifeListener implements Listener {
@Override public void run() {
if (!p.isOnline() || !getMode(p).equals("DIAMOND_TRI")) { cancel(); return; }
if (p.hasCooldown(Material.NETHERITE_SWORD)) return;
if (!BlackKnifeItem.is(p.getInventory().getItemInMainHand()) && !BlackKnifeItem.is(p.getInventory().getItemInOffHand())) return;
if (!BlackKnifeItem.inHands(p)) return;
Location base = p.getEyeLocation().add(p.getEyeLocation().getDirection().normalize().multiply(1.5));
for (int i=0;i<3;i++){ double a=(double)i/3*Math.PI*2; Location pp=base.clone().add(Math.cos(a)*0.5, Math.sin(a)*0.3, Math.sin(a)*0.5); p.spawnParticle(Particle.DUST,pp,1,0,0,0,0,new Particle.DustOptions(Color.fromRGB(180,0,255),1f)); p.spawnParticle(Particle.END_ROD,pp,1,0.02,0.02,0.02,0); }
for (int i=0;i<3;i++){ double a=(double)i/3*Math.PI*2; Location pp=base.clone().add(Math.cos(a)*0.5, Math.sin(a)*0.3, Math.sin(a)*0.5); p.spawnParticle(Particle.DUST,pp,1,0,0,0,0,Particles.PURPLE_10); p.spawnParticle(Particle.END_ROD,pp,1,0.02,0.02,0.02,0); }
}
};
r.runTaskTimer(plugin, 1L, 5L);
@@ -432,7 +456,7 @@ public class BlackKnifeListener implements Listener {
@Override public void run() {
if (!p.isOnline() || !getMode(p).equals("BARRAGE")) { cancel(); return; }
if (p.hasCooldown(Material.NETHERITE_SWORD)) return;
if (!BlackKnifeItem.is(p.getInventory().getItemInMainHand()) && !BlackKnifeItem.is(p.getInventory().getItemInOffHand())) return;
if (!BlackKnifeItem.inHands(p)) return;
Location c = p.getLocation().add(0,1,0);
for(int i=0;i<5;i++){ double a=(double)i/5*Math.PI*2 + System.currentTimeMillis()*0.002; Location orb=c.clone().add(Math.cos(a)*1.5, Math.sin(a*2)*0.3, Math.sin(a)*1.5); p.spawnParticle(Particle.SCULK_SOUL,orb,1,0.02,0.02,0.02,0); p.spawnParticle(Particle.ASH,orb,1,0,0,0,0); }
}
@@ -442,100 +466,13 @@ public class BlackKnifeListener implements Listener {
}
}
/**
* Визуал STARS вынесен в {@link StarFx}: раньше каждая из 18 звёзд и каждый
* из 108 осколков получал собственную задачу с периодом в один тик, то
* есть одно нажатие порождало до 126 задач планировщика. Теперь они все
* едут под одной общей задачей, а в простое она снимает себя сама.
*/
private void spawnStars(Player p, Location center) {
p.getWorld().playSound(center, Sound.BLOCK_AMETHYST_BLOCK_PLACE, 1f, 1.4f);
p.getWorld().playSound(center, Sound.BLOCK_BEACON_POWER_SELECT, 0.8f, 1.5f);
for (int i = 0; i < 18; i++) {
double angle = Math.random() * Math.PI * 2;
double r = 6 + Math.random() * 6;
Location loc = center.clone().add(Math.cos(angle) * r, 8 + Math.random() * 4, Math.sin(angle) * r);
org.bukkit.entity.BlockDisplay disp = p.getWorld().spawn(loc, org.bukkit.entity.BlockDisplay.class, d -> {
d.setBlock(Material.AMETHYST_BLOCK.createBlockData());
d.setBrightness(new Display.Brightness(15, 15));
d.setViewRange(64);
});
EntityTracker.mark(disp);
EntityTracker.smooth(disp);
Vector vel = new Vector(0, -0.35 - Math.random() * 0.1, 0);
vel.add(new Vector((Math.random() - 0.5) * 0.08, 0, (Math.random() - 0.5) * 0.08));
new BukkitRunnable() {
@Override public void run() {
if (!disp.isValid() || disp.isDead()) { cancel(); return; }
Location cur = disp.getLocation();
Location nl = cur.clone().add(vel);
Location next = nl.clone().add(vel);
if (next.getBlock().isSolid() || nl.getY() <= p.getWorld().getHighestBlockYAt(nl) + 0.5) {
Location expl = cur.clone().add(vel.clone().normalize().multiply(-0.1));
disp.teleport(expl);
EntityTracker.untrack(disp);
disp.remove();
cancel();
p.getWorld().spawnParticle(Particle.EXPLOSION, expl, 1);
p.getWorld().playSound(expl, Sound.ENTITY_GENERIC_EXPLODE, 0.8f, 1.2f);
for (int s = 0; s < 6; s++) {
double a = s * Math.PI * 2 / 6;
Vector sv = new Vector(Math.cos(a) * 0.35, 0, Math.sin(a) * 0.35);
org.bukkit.entity.BlockDisplay shard = p.getWorld().spawn(expl, org.bukkit.entity.BlockDisplay.class, d -> {
d.setBlock(Material.MEDIUM_AMETHYST_BUD.createBlockData());
d.setBrightness(new Display.Brightness(15, 15));
d.setViewRange(64);
});
EntityTracker.mark(shard);
EntityTracker.smooth(shard);
new BukkitRunnable() {
@Override public void run() {
if (!shard.isValid()) { cancel(); return; }
Location cur2 = shard.getLocation();
Location n2 = cur2.clone().add(sv);
if (n2.getBlock().isSolid()) {
Location expl2 = cur2.clone().add(sv.clone().normalize().multiply(-0.1));
shard.teleport(expl2);
EntityTracker.untrack(shard);
shard.remove();
cancel();
return;
}
shard.teleport(n2);
n2.getWorld().spawnParticle(Particle.CRIT, n2, 1, 0.05, 0.05, 0.05, 0);
for (var ent : n2.getWorld().getNearbyEntities(n2, 0.7, 0.7, 0.7)) {
if (ent instanceof Player pl && pl != p) continue;
if (ent instanceof org.bukkit.entity.LivingEntity le && le != p) {
DamageUtil.apply(p, le, 3.0);
EntityTracker.untrack(shard);
shard.remove();
cancel();
break;
}
}
for (Player pl : n2.getWorld().getNearbyPlayers(n2, 0.9)) {
if (pl == p) continue;
double d2 = pl.getLocation().add(0, 1, 0).distanceSquared(n2);
if (d2 > 0.25 && d2 < 0.81) {
plugin.getTension().tryGiveDodge(pl);
pl.getWorld().spawnParticle(Particle.DUST, pl.getLocation().add(0, 0.25, 0), 25, 0.5, 0.05, 0.5, 0, new Particle.DustOptions(Color.fromRGB(255, 255, 255), 0.15f));
}
}
EntityTracker.untrack(shard);
if (shard.getTicksLived() > 40) { shard.remove(); cancel(); }
}
}.runTaskTimer(plugin, 1L, 1L);
}
return;
}
disp.teleport(nl);
disp.getWorld().spawnParticle(Particle.END_ROD, nl, 1, 0.05, 0.05, 0.05, 0);
for (Player pl : nl.getWorld().getNearbyPlayers(nl, 0.9)) {
if (pl == p) continue;
double d2 = pl.getLocation().add(0, 1, 0).distanceSquared(nl);
if (d2 > 0.25 && d2 < 0.81) {
plugin.getTension().tryGiveDodge(pl);
pl.getWorld().spawnParticle(Particle.DUST, pl.getLocation().add(0, 0.25, 0), 25, 0.5, 0.05, 0.5, 0, new Particle.DustOptions(Color.fromRGB(255, 255, 255), 0.15f));
}
}
EntityTracker.untrack(disp);
if (disp.getTicksLived() > 120) { disp.remove(); cancel(); }
}
}.runTaskTimer(plugin, 1L, 1L);
}
StarFx.burst(plugin, p, center);
}
}
@@ -119,6 +119,7 @@ public class BlackKnifePlugin extends JavaPlugin {
@Override
public void onDisable() {
getServer().getScheduler().cancelTasks(this);
StarFx.clear();
if (tension != null) {
tension.hideAll();
for (var p : getServer().getOnlinePlayers()) {
@@ -88,7 +88,7 @@ public class DiamondAttack {
double d2 = pl.getLocation().add(0, 1, 0).distanceSquared(n2);
if (d2 > 0.25 && d2 < 0.81) {
BlackKnifePlugin.getPlugin(BlackKnifePlugin.class).getTension().tryGiveDodge(pl);
pl.getWorld().spawnParticle(Particle.DUST, pl.getLocation().add(0, 0.25, 0), 25, 0.5, 0.05, 0.5, 0, new Particle.DustOptions(Color.fromRGB(255, 255, 255), 0.15f));
pl.getWorld().spawnParticle(Particle.DUST, pl.getLocation().add(0, 0.25, 0), 25, 0.5, 0.05, 0.5, 0, Particles.WHITE_015);
}
}
EntityTracker.untrack(shard);
@@ -100,7 +100,7 @@ public class DiamondAttack {
}
crystal.teleport(nl);
crystal.getWorld().spawnParticle(Particle.END_ROD, nl, 1, 0.05, 0.05, 0.05, 0);
crystal.getWorld().spawnParticle(Particle.DUST, nl, 1, 0, 0, 0, 0, new Particle.DustOptions(Color.fromRGB(180, 0, 255), 1f));
crystal.getWorld().spawnParticle(Particle.DUST, nl, 1, 0, 0, 0, 0, Particles.PURPLE_10);
boolean hit = false;
for (var ent : nl.getWorld().getNearbyEntities(nl, 0.7, 0.7, 0.7, e -> e instanceof LivingEntity && e != p)) {
LivingEntity le = (LivingEntity) ent;
@@ -113,7 +113,7 @@ public class DiamondAttack {
double d = pl.getLocation().add(0, 1, 0).distanceSquared(nl);
if (d > 0.25 && d < 0.81) {
BlackKnifePlugin.getPlugin(BlackKnifePlugin.class).getTension().tryGiveDodge(pl);
pl.getWorld().spawnParticle(Particle.DUST, pl.getLocation().add(0, 0.25, 0), 25, 0.5, 0.05, 0.5, 0, new Particle.DustOptions(Color.fromRGB(255, 255, 255), 0.15f));
pl.getWorld().spawnParticle(Particle.DUST, pl.getLocation().add(0, 0.25, 0), 25, 0.5, 0.05, 0.5, 0, Particles.WHITE_015);
}
}
if (hit) {
@@ -159,7 +159,7 @@ public class DiamondAttack {
double d2 = pl.getLocation().add(0, 1, 0).distanceSquared(n2);
if (d2 > 0.25 && d2 < 0.81) {
BlackKnifePlugin.getPlugin(BlackKnifePlugin.class).getTension().tryGiveDodge(pl);
pl.getWorld().spawnParticle(Particle.DUST, pl.getLocation().add(0, 0.25, 0), 25, 0.5, 0.05, 0.5, 0, new Particle.DustOptions(Color.fromRGB(255, 255, 255), 0.15f));
pl.getWorld().spawnParticle(Particle.DUST, pl.getLocation().add(0, 0.25, 0), 25, 0.5, 0.05, 0.5, 0, Particles.WHITE_015);
}
}
EntityTracker.untrack(shard);
@@ -68,7 +68,7 @@ public class DiamondTripleAttack {
if (check.getBlock().isSolid()) { shard.remove(); cancel(); return; }
}
shard.teleport(nl);
nl.getWorld().spawnParticle(Particle.DUST, nl,1,0,0,0,0,new Particle.DustOptions(Color.fromRGB(180,0,255),1f));
nl.getWorld().spawnParticle(Particle.DUST, nl,1,0,0,0,0,Particles.PURPLE_10);
nl.getWorld().spawnParticle(Particle.END_ROD, nl,1,0.05,0.05,0.05,0);
for (LivingEntity le : nl.getWorld().getNearbyLivingEntities(nl,0.8)) {
if (le == p) continue;
@@ -162,13 +162,13 @@ public class GrenadeAttack {
double a = (double) k / 16 * Math.PI * 2;
Location ring = g.clone().add(Math.cos(a) * LETHAL_RADIUS, 0, Math.sin(a) * LETHAL_RADIUS);
p.spawnParticle(Particle.DUST, ring, 1, 0, 0, 0, 0,
new Particle.DustOptions(Color.fromRGB(255, 40, 40), 1.2f));
Particles.RED_12);
}
for (int k = 0; k < 24; k++) {
double a = (double) k / 24 * Math.PI * 2;
Location ring = g.clone().add(Math.cos(a) * CHIP_RADIUS, 0, Math.sin(a) * CHIP_RADIUS);
p.spawnParticle(Particle.DUST, ring, 1, 0, 0, 0, 0,
new Particle.DustOptions(Color.fromRGB(255, 200, 60), 1.0f));
Particles.GOLD_10);
}
}
}.runTaskTimer(plugin, 1L, 1L);
@@ -191,7 +191,7 @@ public class GrenadeAttack {
expl.getWorld().spawnParticle(Particle.EXPLOSION, expl, 2);
expl.getWorld().spawnParticle(Particle.SMOKE, expl, 12, 0.3, 0.3, 0.3, 0.05);
expl.getWorld().spawnParticle(Particle.DUST, expl, 10, 0.3, 0.2, 0.3, 0,
new Particle.DustOptions(Color.fromRGB(255, 120, 30), 1.2f));
Particles.ORANGE_12);
expl.getWorld().playSound(expl, Sound.ENTITY_GENERIC_EXPLODE, 1f, 1.1f);
TensionManager tm = plugin.getTension();
for (LivingEntity le : expl.getWorld().getNearbyLivingEntities(expl, CHIP_RADIUS + 0.5)) {
@@ -111,7 +111,7 @@ public class LungeAttack {
if (!sword.isValid()) { cancel(); return; }
Location cur = sword.getLocation();
if (!launched) {
cur.getWorld().spawnParticle(Particle.DUST, cur.clone().add(0, 0.5, 0), 3, 0.15, 0.15, 0.15, 0, new Particle.DustOptions(Color.fromRGB(10, 30, 50), 1.5f));
cur.getWorld().spawnParticle(Particle.DUST, cur.clone().add(0, 0.5, 0), 3, 0.15, 0.15, 0.15, 0, Particles.NAVY_15);
cur.getWorld().spawnParticle(Particle.SCULK_SOUL, cur, 1, 0.1, 0.1, 0.1, 0);
hold--;
if (hold <= 0) launched = true;
@@ -121,7 +121,7 @@ public class LungeAttack {
int points = Math.max(1, (int) (aim.length() * 3));
for (int j = 0; j <= points; j++) {
Location pp = cur.clone().add(aim.clone().multiply((double) j / points));
pp.getWorld().spawnParticle(Particle.DUST, pp, 1, 0, 0, 0, 0, new Particle.DustOptions(Color.fromRGB(0, 0, 0), 2.0f));
pp.getWorld().spawnParticle(Particle.DUST, pp, 1, 0, 0, 0, 0, Particles.BLACK_20);
pp.getWorld().spawnParticle(Particle.SCULK_SOUL, pp, 1, 0.05, 0.05, 0.05, 0);
}
int steps = Math.max(1, (int) Math.ceil(aim.length() / 0.5));
@@ -192,7 +192,7 @@ public class LungeAttack {
}
Location nl = cur.clone().add(vel);
sword.teleport(nl);
nl.getWorld().spawnParticle(Particle.DUST, nl, 2, 0.15, 0.15, 0.15, 0, new Particle.DustOptions(Color.fromRGB(0, 0, 0), 1.5f));
nl.getWorld().spawnParticle(Particle.DUST, nl, 2, 0.15, 0.15, 0.15, 0, Particles.BLACK_15);
nl.getWorld().spawnParticle(Particle.SCULK_SOUL, nl, 1, 0.05, 0.05, 0.05, 0);
if (nl.getY() <= gazePoint.getY() + 0.5 || nl.getBlock().isSolid()) {
Location expl = gazePoint.clone();
@@ -105,12 +105,12 @@ public class MagicListener implements Listener {
Location check = cur.clone().add(vel.clone().multiply((double)s/steps));
if (check.getBlock().isSolid()) {
Location expl = cur.clone().add(vel.clone().normalize().multiply(Math.max(0, (double)(s-1)/steps)));
for (int k=0;k<12;k++){ double a=(double)k/12*Math.PI*2; for(int h=-1;h<=1;h++){ Location sphere=expl.clone().add(Math.cos(a)*0.5, h*0.25, Math.sin(a)*0.5); sphere.getWorld().spawnParticle(Particle.DUST, sphere,1,0,0,0,0,new Particle.DustOptions(Color.fromRGB(180,40,40),1f)); sphere.getWorld().spawnParticle(Particle.SMOKE,sphere,1,0.02,0.02,0.02,0);} } expl.getWorld().spawnParticle(Particle.CRIT,expl,6,0.2,0.2,0.2,0.05); expl.getWorld().playSound(expl,Sound.BLOCK_STONE_BREAK,1f,0.8f);
for (int k=0;k<12;k++){ double a=(double)k/12*Math.PI*2; for(int h=-1;h<=1;h++){ Location sphere=expl.clone().add(Math.cos(a)*0.5, h*0.25, Math.sin(a)*0.5); sphere.getWorld().spawnParticle(Particle.DUST, sphere,1,0,0,0,0,Particles.CRIMSON_10); sphere.getWorld().spawnParticle(Particle.SMOKE,sphere,1,0.02,0.02,0.02,0);} } expl.getWorld().spawnParticle(Particle.CRIT,expl,6,0.2,0.2,0.2,0.05); expl.getWorld().playSound(expl,Sound.BLOCK_STONE_BREAK,1f,0.8f);
EntityTracker.untrack(marker);
marker.remove(); cancel(); return;
}
}
for(int i=0;i<=4;i++){ Location pp=cur.clone().add(vel.clone().multiply((double)i/4)); pp.getWorld().spawnParticle(Particle.DUST,pp,1,0,0,0,0,new Particle.DustOptions(Color.fromRGB(200,40,40),1.4f)); pp.getWorld().spawnParticle(Particle.CRIT,pp,1,0.02,0.02,0.02,0); }
for(int i=0;i<=4;i++){ Location pp=cur.clone().add(vel.clone().multiply((double)i/4)); pp.getWorld().spawnParticle(Particle.DUST,pp,1,0,0,0,0,Particles.CRIMSON_14); pp.getWorld().spawnParticle(Particle.CRIT,pp,1,0.02,0.02,0.02,0); }
marker.teleport(nl);
for(var ent: nl.getWorld().getNearbyEntities(nl,0.7,0.7,0.7, en->en instanceof LivingEntity && en!=p)) {
LivingEntity le=(LivingEntity)ent;
@@ -151,7 +151,7 @@ public class MagicListener implements Listener {
marker.remove(); cancel(); return;
}
}
for(int i=0;i<=4;i++){ Location pp=cur.clone().add(vel.clone().multiply((double)i/4)); pp.getWorld().spawnParticle(Particle.DUST,pp,1,0,0,0,0,new Particle.DustOptions(Color.fromRGB(136,204,255),1.3f)); pp.getWorld().spawnParticle(Particle.SNOWFLAKE,pp,1,0.02,0.02,0.02,0); }
for(int i=0;i<=4;i++){ Location pp=cur.clone().add(vel.clone().multiply((double)i/4)); pp.getWorld().spawnParticle(Particle.DUST,pp,1,0,0,0,0,Particles.ICE_13); pp.getWorld().spawnParticle(Particle.SNOWFLAKE,pp,1,0.02,0.02,0.02,0); }
marker.teleport(nl);
for(var ent: nl.getWorld().getNearbyEntities(nl,0.7,0.7,0.7, en->en instanceof LivingEntity && en!=p)) {
LivingEntity le=(LivingEntity)ent;
@@ -67,4 +67,79 @@ public final class Particles {
public static Particle.DustOptions dust(int r, int g, int b, float size) {
return new Particle.DustOptions(Color.fromRGB(r, g, b), size);
}
// ---------------------------------------------------------------------
// Готовые DustOptions.
//
// DustOptions иммутабелен, поэтому один объект на цвет безопасно делить
// между всеми вызовами. Раньше в коде было 36 мест вида
// new Particle.DustOptions(Color.fromRGB(...))
// и почти все — внутри циклов или внутри задач с периодом в несколько
// тиков. Каждый такой вызов это две аллокации (Color и сам DustOptions) на
// КАЖДУЮ частицу: на превью GRENADE это 40 частиц каждые 4 тика на игрока,
// то есть 20 аллокаций на тик только на одного целящегося. Именно такого
// типа мусор и даёт короткие GC-пики в p95.
// ---------------------------------------------------------------------
/** Чёрный, размер 1.3 — превью комбо SWORD и одиночная метка. */
public static final Particle.DustOptions BLACK_13 = dust(0, 0, 0, 1.3f);
/** Чёрный, размер 0.8 — верхняя метка превью SWORD. */
public static final Particle.DustOptions BLACK_08 = dust(0, 0, 0, 0.8f);
/** Тёмно-красный, размер 1.3 — превью комбо на третьем ударе. */
public static final Particle.DustOptions DARKRED_13 = dust(50, 0, 0, 1.3f);
/** Ярко-красный, размер 1.5 — метка прицеливания OVERHEAD. */
public static final Particle.DustOptions RED_15 = dust(255, 40, 40, 1.5f);
/** Красный, размер 1.2 — внутреннее кольцо GRENADE и метка SNIPE. */
public static final Particle.DustOptions RED_12 = dust(255, 40, 40, 1.2f);
/** Золотой, размер 1.0 — внешнее кольцо GRENADE. */
public static final Particle.DustOptions GOLD_10 = dust(255, 200, 60, 1.0f);
/** Сине-голубой, размер 1.2 — кольцо DIVE. */
public static final Particle.DustOptions BLUE_12 = dust(80, 160, 255, 1.2f);
/** Сине-голубой, размер 1.4 — след DIVE. */
public static final Particle.DustOptions BLUE_14 = dust(80, 160, 255, 1.4f);
/** Сине-голубой, размер 1.0 — метка DIVE. */
public static final Particle.DustOptions BLUE_10 = dust(80, 160, 255, 1.0f);
/** Фиолетовый, размер 1.0 — превью DIAMOND_TRI. */
public static final Particle.DustOptions PURPLE_10 = dust(180, 0, 255, 1.0f);
/** Тёмно-синий, размер 1.5 — след SNIPE. */
public static final Particle.DustOptions NAVY_15 = dust(10, 30, 50, 1.5f);
/** Чёрный, размер 2.0 — ядро снаряда SNIPE. */
public static final Particle.DustOptions BLACK_20 = dust(0, 0, 0, 2.0f);
/** Чёрный, размер 1.5 — хвост снаряда SNIPE. */
public static final Particle.DustOptions BLACK_15 = dust(0, 0, 0, 1.5f);
/** Тёмно-серый, размер 1.2 — подсветка точки удара OVERHEAD. */
public static final Particle.DustOptions GREY_12 = dust(20, 20, 20, 1.2f);
/** Оранжевый, размер 1.2 — снаряд GRENADE. */
public static final Particle.DustOptions ORANGE_12 = dust(255, 120, 30, 1.2f);
/** Оранжево-золотой, размер по размеру частицы — пыль GRENADE. */
public static Particle.DustOptions grenadeDust(float size) {
return new Particle.DustOptions(Color.fromRGB(180, 120, 40), size);
}
/** Белый, размер 0.15 — вспышка dodg-эффекта. Шесть мест, одно на цвет. */
public static final Particle.DustOptions WHITE_015 = dust(255, 255, 255, 0.15f);
/** Пурпурный, размер 1.5 — кольцо TRI. */
public static final Particle.DustOptions TRI_15 = dust(80, 20, 80, 1.5f);
/** Синий, размер 1.5 — кольцо SNIPE. */
public static final Particle.DustOptions SNIPE_15 = dust(20, 100, 160, 1.5f);
/** Тёмно-синий, размер 1.5 — запасной кольцевой цвет прицеливания. */
public static final Particle.DustOptions AIM_15 = dust(30, 80, 120, 1.5f);
/** Красный, размер 1.3 — метка TRI. */
public static final Particle.DustOptions RED_13 = dust(255, 50, 50, 1.3f);
/** Красный, размер 1.0 — верхняя метка прицеливания. */
public static final Particle.DustOptions RED_10 = dust(255, 50, 50, 1.0f);
/** Тёмно-красный, размер 1.2 — снаряд PhantomBarrage. */
public static final Particle.DustOptions BARRAGE_12 = dust(30, 10, 60, 1.2f);
/** Пурпурный, размер 1.2 — след SWORD. */
public static final Particle.DustOptions SWORD_12 = dust(120, 40, 160, 1.2f);
/** Бледно-голубой, размер 1.0 — превью BARRAGE. */
public static final Particle.DustOptions PALE_10 = dust(200, 200, 255, 1.0f);
/** Алый, размер 1.4 — след снаряда MagicListener. */
public static final Particle.DustOptions CRIMSON_14 = dust(200, 40, 40, 1.4f);
/** Алый, размер 1.0 — взрыв MagicListener. */
public static final Particle.DustOptions CRIMSON_10 = dust(180, 40, 40, 1.0f);
/** Ледяной, размер 1.3 — след второго снаряда MagicListener. */
public static final Particle.DustOptions ICE_13 = dust(136, 204, 255, 1.3f);
/** Тёмно-серый, размер 1.5 — сфера SWORD. */
public static final Particle.DustOptions DARKGREY_15 = dust(30, 30, 30, 1.5f);
}
@@ -65,7 +65,7 @@ public class PhantomBarrageAttack {
for(int s=0;s<=steps;s++){ Location check=cur.clone().add(toTarget.clone().multiply((double)s/steps)); if(check.getBlock().isSolid()){orb.remove();cancel();return;} }
orb.teleport(nl);
cur = nl;
nl.getWorld().spawnParticle(Particle.DUST, nl,1,0,0,0,0,new Particle.DustOptions(Color.fromRGB(30,10,50),1.2f));
nl.getWorld().spawnParticle(Particle.DUST, nl,1,0,0,0,0,Particles.BARRAGE_12);
nl.getWorld().spawnParticle(Particle.SCULK_SOUL, nl,1,0.05,0.05,0.05,0);
EntityTracker.untrack(orb);
for(LivingEntity le: nl.getWorld().getNearbyLivingEntities(nl,0.9)){ if(le==p) continue; DamageUtil.apply(p, le, 6.0); le.getWorld().spawnParticle(Particle.CRIT, le.getLocation().add(0,1,0),8,0.3,0.3,0.3,0.1); orb.remove(); cancel(); return; }
@@ -167,7 +167,7 @@ public class SkyDiveAttack implements Listener {
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));
Particles.BLUE_10);
}
if (--left <= 0) {
cancel();
@@ -200,7 +200,7 @@ public class SkyDiveAttack implements Listener {
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(org.bukkit.Color.fromRGB(80, 160, 255), 1.4f));
Particles.BLUE_14);
cur.getWorld().spawnParticle(Particle.CLOUD, cur, 1, 0.1, 0.1, 0.1, 0.02);
// Касание земли отменяет урон: иначе можно было промахнуться,
@@ -0,0 +1,244 @@
package me.sashegdev.blackknife;
import org.bukkit.Location;
import org.bukkit.Material;
import org.bukkit.Particle;
import org.bukkit.Sound;
import org.bukkit.entity.BlockDisplay;
import org.bukkit.entity.Display;
import org.bukkit.entity.LivingEntity;
import org.bukkit.entity.Player;
import org.bukkit.scheduler.BukkitRunnable;
import org.bukkit.util.Vector;
import java.util.ArrayList;
import java.util.List;
/**
* Визуал способности STARS: падающие звёзды и их осколки.
*
* <p>Раньше каждая сущность получала СВОЮ задачу с периодом в один тик: 18 звёзд
* на один вызов, и каждая при ударе о землю порождала ещё 6 осколков, у которых
* тоже была своя задача. Итого до 126 одновременных записей в планировщике и
* 126 обходов тика на одно нажатие, каждый со своим {@code getNearbyEntities} и
* {@code getNearbyPlayers}.
*
* <p>Здесь всё это живёт в одном списке под одной задачей. Стоимость на тик
* та же по сути — те же сущности двигаются, — но вместо 126 записей в
* планировщике одна, и обход линейный по одному массиву.
*
* <p>Задача снимается с себя сама, когда список пуст, то есть в простое
* планировщик не трогает вообще ничего.
*
* <p>Попутно исправлена ошибка: {@code EntityTracker.untrack()} вызывался на
* каждом тике, пока сущность ещё жива. После первого тика она исчезала из
* множества отслеживаемых, и {@link EntityTracker#cleanup} больше не мог её
* найти — визуал переживал перезагрузку плагина. Теперь untrack вызывается
* ровно один раз, в момент удаления.
*/
final class StarFx {
private StarFx() {
}
/** Одна движущаяся сущность. */
private static final class Node {
final BlockDisplay ent;
final Vector vel;
final Player owner;
final boolean shard;
int age;
Node(BlockDisplay ent, Vector vel, Player owner, boolean shard) {
this.ent = ent;
this.vel = vel;
this.owner = owner;
this.shard = shard;
}
}
private static final List<Node> NODES = new ArrayList<>(32);
private static BukkitRunnable task;
private static final int STAR_COUNT = 18;
private static final int SHARD_COUNT = 6;
private static final int STAR_MAX_AGE = 120;
private static final int SHARD_MAX_AGE = 40;
static void burst(BlackKnifePlugin plugin, Player p, Location center) {
p.getWorld().playSound(center, Sound.BLOCK_AMETHYST_BLOCK_PLACE, 1f, 1.4f);
p.getWorld().playSound(center, Sound.BLOCK_BEACON_POWER_SELECT, 0.8f, 1.5f);
for (int i = 0; i < STAR_COUNT; i++) {
double angle = Math.random() * Math.PI * 2;
double r = 6 + Math.random() * 6;
Location loc = center.clone().add(Math.cos(angle) * r, 8 + Math.random() * 4, Math.sin(angle) * r);
Vector vel = new Vector((Math.random() - 0.5) * 0.08, -0.35 - Math.random() * 0.1, (Math.random() - 0.5) * 0.08);
NODES.add(new Node(spawnStar(p.getWorld(), loc), vel, p, false));
}
ensureTask(plugin);
}
private static BlockDisplay spawnStar(org.bukkit.World world, Location loc) {
final BlockDisplay disp = world.spawn(loc, BlockDisplay.class, d -> {
d.setBlock(Material.AMETHYST_BLOCK.createBlockData());
d.setBrightness(new Display.Brightness(15, 15));
d.setViewRange(64);
});
EntityTracker.mark(disp);
EntityTracker.smooth(disp);
return disp;
}
private static BlockDisplay spawnShard(BlackKnifePlugin plugin, Player owner, Location loc, Vector vel) {
final BlockDisplay shard = loc.getWorld().spawn(loc, BlockDisplay.class, d -> {
d.setBlock(Material.MEDIUM_AMETHYST_BUD.createBlockData());
d.setBrightness(new Display.Brightness(15, 15));
d.setViewRange(64);
});
EntityTracker.mark(shard);
EntityTracker.smooth(shard);
NODES.add(new Node(shard, vel, owner, true));
return shard;
}
private static void ensureTask(BlackKnifePlugin plugin) {
if (task != null) {
return;
}
task = new BukkitRunnable() {
@Override
public void run() {
tick(plugin);
}
};
task.runTaskTimer(plugin, 1L, 1L);
}
private static void tick(BlackKnifePlugin plugin) {
// Обратный проход: удаляем по индексу, не сдвигая список в середине.
for (int i = NODES.size() - 1; i >= 0; i--) {
final Node n = NODES.get(i);
if (!n.ent.isValid() || n.ent.isDead()) {
NODES.remove(i);
continue;
}
n.age++;
final Location cur = n.ent.getLocation();
final Location next = cur.clone().add(n.vel);
if (n.shard) {
if (!tickShard(plugin, n, cur, next)) {
NODES.remove(i);
}
} else {
if (!tickStar(plugin, n, cur, next)) {
NODES.remove(i);
}
}
}
if (NODES.isEmpty() && task != null) {
task.cancel();
task = null;
}
}
/** @return false, если сущность обработана и её надо убрать из списка. */
private static boolean tickStar(BlackKnifePlugin plugin, Node n, Location cur, Location next) {
final Location nl = next;
final Location nn = nl.clone().add(n.vel);
final org.bukkit.World world = nl.getWorld();
if (nn.getBlock().isSolid() || nl.getY() <= world.getHighestBlockYAt(nl) + 0.5) {
final Location expl = cur.clone().add(n.vel.clone().normalize().multiply(-0.1));
n.ent.teleport(expl);
EntityTracker.untrack(n.ent);
n.ent.remove();
world.spawnParticle(Particle.EXPLOSION, expl, 1);
world.playSound(expl, Sound.ENTITY_GENERIC_EXPLODE, 0.8f, 1.2f);
for (int s = 0; s < SHARD_COUNT; s++) {
double a = s * Math.PI * 2 / SHARD_COUNT;
spawnShard(plugin, n.owner, expl,
new Vector(Math.cos(a) * 0.35, 0, Math.sin(a) * 0.35));
}
return false;
}
n.ent.teleport(nl);
world.spawnParticle(Particle.END_ROD, nl, 1, 0.05, 0.05, 0.05, 0);
dodgeNear(plugin, n.owner, nl);
if (n.age > STAR_MAX_AGE) {
EntityTracker.untrack(n.ent);
n.ent.remove();
return false;
}
return true;
}
/** @return false, если осколок обработан и его надо убрать из списка. */
private static boolean tickShard(BlackKnifePlugin plugin, Node n, Location cur, Location next) {
final Location n2 = next;
final org.bukkit.World world = n2.getWorld();
if (n2.getBlock().isSolid()) {
final Location stop = cur.clone().add(n.vel.clone().normalize().multiply(-0.1));
n.ent.teleport(stop);
EntityTracker.untrack(n.ent);
n.ent.remove();
return false;
}
n.ent.teleport(n2);
world.spawnParticle(Particle.CRIT, n2, 1, 0.05, 0.05, 0.05, 0);
for (var ent : world.getNearbyEntities(n2, 0.7, 0.7, 0.7)) {
// Свой хостер не получает урон от собственного осколка.
if (ent instanceof Player pl && pl != n.owner) {
continue;
}
if (ent instanceof LivingEntity le && le != n.owner) {
DamageUtil.apply(n.owner, le, 3.0);
EntityTracker.untrack(n.ent);
n.ent.remove();
return false;
}
}
dodgeNear(plugin, n.owner, n2);
if (n.age > SHARD_MAX_AGE) {
EntityTracker.untrack(n.ent);
n.ent.remove();
return false;
}
return true;
}
/** Вспышка dodg-эффекта для игроков, прошедших рядом с визуалом. */
private static void dodgeNear(BlackKnifePlugin plugin, Player owner, Location at) {
for (Player pl : at.getWorld().getNearbyPlayers(at, 0.9)) {
if (pl == owner) {
continue;
}
double d2 = pl.getLocation().add(0, 1, 0).distanceSquared(at);
if (d2 > 0.25 && d2 < 0.81) {
plugin.getTension().tryGiveDodge(pl);
pl.getWorld().spawnParticle(Particle.DUST, pl.getLocation().add(0, 0.25, 0),
25, 0.5, 0.05, 0.5, 0, Particles.WHITE_015);
}
}
}
/** Сброс при выключении плагина. */
static void clear() {
for (Node n : NODES) {
if (n.ent != null && n.ent.isValid()) {
EntityTracker.untrack(n.ent);
n.ent.remove();
}
}
NODES.clear();
if (task != null) {
task.cancel();
task = null;
}
}
}
@@ -74,7 +74,10 @@ public class SwoonHandler implements Listener {
if (p.getGameMode() == org.bukkit.GameMode.SPECTATOR) {
return;
}
if (!BlackKnifeItem.is(p.getInventory().getItemInMainHand()) && !BlackKnifeItem.is(p.getInventory().getItemInOffHand())) {
// Кешированная проверка: событие срабатывает на КАЖДЫЙ удар, а is()
// копирует ItemMeta целиком. В бою на 20 игроков это десятки копий NBT
// в секунду ради проверки «есть ли нож в руках».
if (!BlackKnifeItem.inHands(p)) {
return;
}
if (!(e.getEntity() instanceof LivingEntity target)) {
@@ -66,7 +66,7 @@ public class SwordAttack {
int points = Math.max(1, (int) (len * 4));
for (int i = 0; i <= points; i++) {
Location pp = cur.clone().add(step.clone().multiply((double) i / points));
pp.getWorld().spawnParticle(Particle.DUST, pp, 1, 0, 0, 0, 0, new Particle.DustOptions(Color.fromRGB(0, 0, 0), 1.5f));
pp.getWorld().spawnParticle(Particle.DUST, pp, 1, 0, 0, 0, 0, Particles.BLACK_15);
}
marker.teleport(nl);
for (var ent : nl.getWorld().getNearbyEntities(nl, 0.7, 0.7, 0.7, e -> e instanceof LivingEntity && e != p)) {
@@ -87,7 +87,7 @@ public class SwordAttack {
double d = pl.getLocation().add(0, 1, 0).distanceSquared(nl);
if (d > 0.25 && d < 0.81) {
BlackKnifePlugin.getPlugin(BlackKnifePlugin.class).getTension().tryGiveDodge(pl);
pl.getWorld().spawnParticle(Particle.DUST, pl.getLocation().add(0, 0.25, 0), 25, 0.5, 0.05, 0.5, 0, new Particle.DustOptions(Color.fromRGB(255, 255, 255), 0.15f));
pl.getWorld().spawnParticle(Particle.DUST, pl.getLocation().add(0, 0.25, 0), 25, 0.5, 0.05, 0.5, 0, Particles.WHITE_015);
}
}
EntityTracker.untrack(marker);
@@ -21,7 +21,19 @@ public class TensionManager implements Listener {
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<>();
private final Map<UUID, Boolean> ownerCache = 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;
@@ -90,16 +102,28 @@ public class TensionManager implements Listener {
}
public boolean isOwner(Player p) {
if (BlackKnifeItem.is(p.getInventory().getItemInMainHand()) || BlackKnifeItem.is(p.getInventory().getItemInOffHand())) return true;
long now = System.currentTimeMillis();
UUID id = p.getUniqueId();
Long t = ownerCacheTime.get(id);
Boolean cached = ownerCache.get(id);
if (cached != null && t != null && now - t < OWNER_CACHE_TTL) return cached;
boolean has = hasAnyBlackKnife(p);
ownerCache.put(id, has);
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 has;
return anywhere ? has : inHands;
}
public void invalidateOwnerCache(Player p) {
@@ -113,7 +137,7 @@ public class TensionManager implements Listener {
}
public boolean hasMagic(Player p) {
if (BlackKnifeItem.is(p.getInventory().getItemInMainHand()) || BlackKnifeItem.is(p.getInventory().getItemInOffHand())) return true;
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());