perf: batch particles, drop LUNGE, fix target search, stop per-tick map copy

Measured on zern.cc with 20 mineflayer bots on SNIPE, empty world and then
with 625 entities clustered around them.

getLungeTarget searched a 120x120x120 cube to find one entity inside a 5.7
degree cone, and ran TWO raytraces per entity inside that loop. The second one,
rayTraceBlocks(eye, dir, 120), does not depend on the entity at all: eye, dir
and the distance are loop-invariant, so the same answer was recomputed once per
entity, each time walking up to 120 blocks. On a populated world that is up to
2N raytraces per call, and updatePreview calls it every 4 ticks per player
holding a knife.

Now the query is an AABB along the look direction, the block ray is computed
once before the loop, and hasLineOfSight is only called on candidates in
distance order - usually exactly once. The common case of zero or one target
allocates nothing at all; tracking the two nearest inline avoids the lists and
the sort entirely.

Honest note on the measurement: this fix did NOT show up. 10.5 ms median
before vs 10.8 ms after with 625 entities nearby. Working the numbers back,
that path costs roughly 1% of capacity in this scenario, which is below the
noise floor. The code change is correct by inspection, not by measurement, and
it would matter far more in a world with thousands of entities than 625.

LUNGE is removed. Its shockwave drew points = radius*6 and issued THREE
separate spawnParticle calls per point, so a radius-10 ring sent 180 packets
PER TICK, expanding, per cast. That is gone with the ability.

Particle batching, which is the real cost in this plugin. Every spawnParticle
is one packet per viewer. MagicListener's hit ring was 36 separate calls and
its explosion shell was 72; the OVERHEAD aiming ring was 32 packets every
tick. spawnParticle with count>1 spreads particles inside the offset volume, so
a ring of radius r becomes a filled disc of the same radius - indistinguishable
at half-block scale, and the particle count is unchanged. New Particles helper.

TensionManager.updateBar ran every tick per player: String.format, two
Component.text, and a progress packet. Now it repaints only when the whole
percent changes, and the percent is computed with integer math. At 0.25/tick
regen that is four repaints a second instead of twenty, and zero on a full
bar. The percent cache is cleared on quit and resynced on death so the bar
cannot get stuck.

tickBattle copied the entire battleEnemies map, including every nested set,
twenty times a second - purely so it could iterate and delete from the same
map. Keys are now collected into a list and dropped after the loop.

getMode now collapses an unknown stored mode onto STARS. Without that a knife
issued before LUNGE was removed could not be mode-switched at all: the index
lookup found nothing, stayed at zero, and the knife was stuck.
This commit is contained in:
SashegDev
2026-10-01 12:50:33 +00:00
parent dd8a4115e0
commit 04bad6f32b
6 changed files with 248 additions and 348 deletions
@@ -25,11 +25,28 @@ public class BlackKnifeListener implements Listener {
private final java.util.Map<java.util.UUID, BukkitRunnable> previewTasks = new java.util.HashMap<>();
private final java.util.Map<java.util.UUID, BukkitRunnable> lungeHighlightTasks = new java.util.HashMap<>();
private final java.util.Map<java.util.UUID, Integer> swordCombo = new java.util.HashMap<>();
public static final String[] MODES = {"STARS", "SWORD", "DIAMOND", "DIAMOND_TRI", "BARRAGE", "LUNGE", "SNIPE", "TRI", "OVERHEAD", "GRENADE", "DIVE"};
public static final String[] MODES = {"STARS", "SWORD", "DIAMOND", "DIAMOND_TRI", "BARRAGE", "SNIPE", "TRI", "OVERHEAD", "GRENADE", "DIVE"};
public BlackKnifeListener(BlackKnifePlugin plugin) { this.plugin = plugin; }
public String getMode(Player p) { return mode.getOrDefault(p.getUniqueId(), "STARS"); }
/**
* Текущий режим ножа.
*
* <p>Режим хранится в PDC на самом ноже, и там может остаться значение,
* которого больше нет в {@link #MODES} — например, "LUNGE" у ножей, выданных
* до его удаления. Без фолбэка такой нож нельзя переключить: цикл перебора
* не находит текущий режим, и индекс остаётся нулём, то есть режим
* застревает. Поэтому неизвестное значение схлопывается на STARS.
*/
public String getMode(Player p) {
final String cur = mode.get(p.getUniqueId());
if (cur == null) return "STARS";
for (String m : MODES) {
if (m.equalsIgnoreCase(cur)) return cur;
}
mode.put(p.getUniqueId(), "STARS");
return "STARS";
}
public boolean setMode(Player p, String m) {
for (String mm : MODES) {
@@ -60,28 +77,104 @@ public class BlackKnifeListener implements Listener {
return p.getEyeLocation().add(p.getEyeLocation().getDirection().normalize().multiply(20));
}
/** Дальность поиска цели, блоков. */
private static final double TARGET_RANGE = 120.0D;
/** Порог косинуса конуса наведения: угол примерно 5.7 градуса. */
private static final double TARGET_DOT = 0.995D;
/**
* Поиск цели для SNIPE и TRI.
*
* <p>Конус наведения узкий — около 5.7 градуса, то есть фактически ищется
* одна цель. Раньше здесь сканировался кубик 120x120x120 вокруг игрока, и
* внутри цикла на КАЖДУЮ сущность заново считались два луча:
* {@code hasLineOfSight} и {@code rayTraceBlocks(eye, dir, 120)}. Второй
* луч не зависит от сущности вообще — eye, dir и дистанция в цикле
* константы, то есть один и тот же результат пересчитывался тысячи раз,
* каждый раз обходя 120 блоков. Теперь запрос идёт по AABB вдоль луча,
* луч по блокам считается один раз до цикла, а видимость проверяется только
* для кандидатов в порядке возрастания дистанции — обычно ровно один раз.
*/
public LivingEntity getLungeTarget(Player p) {
Location eye = p.getEyeLocation();
Vector dir = eye.getDirection().normalize();
LivingEntity best = null;
double bestScore = Double.MAX_VALUE;
for (var ent : p.getWorld().getNearbyEntities(eye, 120, 120, 120, e -> e instanceof LivingEntity && e != p)) {
LivingEntity le = (LivingEntity) ent;
if (!p.hasLineOfSight(le)) continue;
var ray = p.getWorld().rayTraceBlocks(eye, dir, 120);
if (ray != null && ray.getHitBlock() != null) {
double blockDist = ray.getHitBlock().getLocation().distanceSquared(eye);
double entDist = le.getLocation().add(0, 1, 0).distanceSquared(eye);
if (blockDist + 1 < entDist) continue;
final Location eye = p.getEyeLocation();
final Vector dir = eye.getDirection().normalize();
// Бокс, ограничивающий конус: полуось вдоль взгляда и поперёк.
final double halfAlong = TARGET_RANGE * 0.5D;
final double halfSide = TARGET_RANGE * Math.sqrt(1.0D - TARGET_DOT * TARGET_DOT);
final Vector right = new Vector(-dir.getZ(), 0.0D, dir.getX());
if (right.lengthSquared() < 1.0E-6D) {
right.setX(1.0D);
right.setZ(0.0D);
}
Location eloc = le.getLocation().add(0, 1, 0);
Vector toEnt = eloc.clone().subtract(eye).toVector();
double dist = toEnt.length();
double dot = toEnt.normalize().dot(dir);
if (dot < 0.995) continue;
if (best == null || dist < bestScore) { best = le; bestScore = dist; }
right.normalize();
final Vector up = new Vector(
dir.getY() * right.getZ() - dir.getZ() * right.getY(),
dir.getZ() * right.getX() - dir.getX() * right.getZ(),
dir.getX() * right.getY() - dir.getY() * right.getX()).normalize();
final Vector mid = dir.clone().multiply(halfAlong);
double minX = Double.MAX_VALUE, minY = Double.MAX_VALUE, minZ = Double.MAX_VALUE;
double maxX = -Double.MAX_VALUE, maxY = -Double.MAX_VALUE, maxZ = -Double.MAX_VALUE;
for (int i = 0; i < 8; i++) {
final double sx = (i & 1) == 0 ? -halfAlong : halfAlong;
final double sy = (i & 2) == 0 ? -halfSide : halfSide;
final double sz = (i & 4) == 0 ? -halfSide : halfSide;
final double x = eye.getX() + mid.getX() + right.getX() * sx + up.getX() * sy;
final double y = eye.getY() + mid.getY() + right.getY() * sx + up.getY() * sy;
final double z = eye.getZ() + mid.getZ() + right.getZ() * sx + up.getZ() * sy;
minX = Math.min(minX, x); maxX = Math.max(maxX, x);
minY = Math.min(minY, y); maxY = Math.max(maxY, y);
minZ = Math.min(minZ, z); maxZ = Math.max(maxZ, z);
}
return best;
// Единственный луч по блокам на весь поиск.
final var wallRay = p.getWorld().rayTraceBlocks(eye, dir, TARGET_RANGE);
final double wallDistSq = (wallRay != null && wallRay.getHitBlock() != null)
? wallRay.getHitBlock().getLocation().distanceSquared(eye)
: Double.MAX_VALUE;
// Типичный случай — ноль или одна цель. Списки и сортировка ниже
// существуют ради конкуренции, но платить за них на пустом конусе
// незачем, поэтому сначала идём без единой аллокации.
LivingEntity first = null;
double firstSq = Double.MAX_VALUE;
LivingEntity second = null;
double secondSq = Double.MAX_VALUE;
for (var ent : p.getWorld().getNearbyEntities(
new org.bukkit.util.BoundingBox(minX, minY, minZ, maxX, maxY, maxZ),
e -> e instanceof LivingEntity && e != p)) {
final LivingEntity le = (LivingEntity) ent;
final Location eloc = le.getLocation().add(0, 1, 0);
final Vector toEnt = eloc.toVector().subtract(eye.toVector());
final double dSq = toEnt.lengthSquared();
if (dSq > TARGET_RANGE * TARGET_RANGE || dSq < 1.0E-6D) {
continue;
}
if (wallDistSq + 1.0D < dSq) {
continue; // цель за стеной, ближе чем до ближайшего блока
}
if (toEnt.normalize().dot(dir) < TARGET_DOT) {
continue;
}
if (dSq < firstSq) {
second = first; secondSq = firstSq;
first = le; firstSq = dSq;
} else if (dSq < secondSq) {
second = le; secondSq = dSq;
}
}
if (first == null) {
return null;
}
// Ближайшая видимая. hasLineOfSight вызывается по порядку дистанции и
// обычно ровно один раз: за ближайшей целью сразу берётся она.
if (p.hasLineOfSight(first)) {
return first;
}
return (second != null && p.hasLineOfSight(second)) ? second : null;
}
@EventHandler
@@ -146,12 +239,6 @@ public class BlackKnifeListener implements Listener {
if (PhantomBarrageAttack.tryUse(p, tm)) {
p.setCooldown(item.getType(), 50);
}
} else if (m.equals("LUNGE")) {
LivingEntity target = getLungeTarget(p);
if (LungeAttack.tryUse(p, tm, target)) {
p.setCooldown(item.getType(), 280);
clearHighlight(p);
}
} else if (m.equals("SNIPE")) {
LivingEntity target = getLungeTarget(p);
if (LungeAttack.trySnipe(p, tm, target)) {
@@ -235,10 +322,10 @@ public class BlackKnifeListener implements Listener {
};
r.runTaskTimer(plugin, 2L, 4L);
previewTasks.put(p.getUniqueId(), r);
} else if (m.equals("LUNGE") || m.equals("SNIPE") || m.equals("TRI")) {
} else if (m.equals("SNIPE") || m.equals("TRI")) {
BukkitRunnable r = new BukkitRunnable() {
@Override public void run() {
if (!p.isOnline() || !(getMode(p).equals("LUNGE") || getMode(p).equals("SNIPE") || getMode(p).equals("TRI"))) { cancel(); return; }
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;
LivingEntity target = getLungeTarget(p);
@@ -274,12 +361,12 @@ public class BlackKnifeListener implements Listener {
if (p.hasCooldown(Material.NETHERITE_SWORD)) return;
if (!BlackKnifeItem.is(p.getInventory().getItemInMainHand()) && !BlackKnifeItem.is(p.getInventory().getItemInOffHand())) 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.8, 0.1, Math.sin(a) * 1.8);
p.spawnParticle(Particle.DUST, ring, 1, 0, 0, 0, 0, new Particle.DustOptions(Color.fromRGB(20, 20, 20), 1.2f));
p.spawnParticle(Particle.ASH, ring, 1, 0, 0, 0, 0);
}
// 32 отдельных пакета на тик прицеливания -> 2 пакета.
// Кольцо radius 1.8 раскладывается диском, на масштабе
// полутора блоков глазом неотличимо от точек на окружности.
Particles.groundRing(p.getWorld(), gp.clone().add(0, 0.1, 0), 1.8,
Particles.dust(20, 20, 20, 1.2f), 16);
p.spawnParticle(Particle.ASH, gp, 16, 1.8, 0.05, 1.8, 0.0D);
for (int i = 0; i < 8; i++) {
double a = (double) i / 8 * Math.PI * 2;
Location ring = gp.clone().add(Math.cos(a) * 1, 0.1, Math.sin(a) * 1);
@@ -16,69 +16,8 @@ import org.bukkit.scheduler.BukkitRunnable;
import org.bukkit.util.Vector;
public class LungeAttack {
public static boolean tryUse(Player p, TensionManager tension, LivingEntity target) {
if (target == null || !target.isValid() || target.isDead()) {
p.sendActionBar(Component.text("No target! Look at entity within 120 blocks").color(NamedTextColor.RED));
return false;
}
if (!canSeeTarget(p, target)) {
p.sendActionBar(Component.text("Target blocked by wall!").color(NamedTextColor.RED));
return false;
}
if (tension.getTP(p) < 225f) {
p.sendActionBar(Component.text("Need 90% TP").color(NamedTextColor.RED));
return false;
}
tension.setTP(p, tension.getTP(p) - 225f);
tension.startBattle(p, target);
Location start = p.getEyeLocation().clone();
BlackKnifePlugin plugin = BlackKnifePlugin.getPlugin(BlackKnifePlugin.class);
BlockDisplay catalyst = p.getWorld().spawn(start, BlockDisplay.class, d -> {
d.setBlock(Material.SCULK_CATALYST.createBlockData());
d.setBrightness(new Display.Brightness(15, 15));
d.setViewRange(64);
});
EntityTracker.mark(catalyst);
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() {
EntityTracker.untrack(catalyst);
if (!target.isValid() || target.isDead()) { if (catalyst.isValid()) catalyst.remove(); cancel(); return; }
if (!catalyst.isValid()) { cancel(); return; }
Location cur = catalyst.getLocation();
Location tpos = target.getLocation().clone().add(0, 1, 0);
Vector catDir = tpos.clone().subtract(cur).toVector().normalize();
Location nl = cur.clone().add(catDir.clone().multiply(catSpeed));
catalyst.teleport(nl);
nl.getWorld().spawnParticle(Particle.SCULK_SOUL, nl, 2, 0.15, 0.15, 0.15, 0.02);
nl.getWorld().spawnParticle(Particle.DUST, nl, 1, 0, 0, 0, 0, new Particle.DustOptions(Color.fromRGB(20, 60, 80), 1f));
if (nl.distanceSquared(tpos) < 4) {
catalyst.teleport(tpos);
EntityTracker.untrack(catalyst);
catalyst.remove();
cancel();
tpos.getWorld().spawnParticle(Particle.EXPLOSION, tpos, 1);
tpos.getWorld().playSound(tpos, Sound.BLOCK_SCULK_CATALYST_BLOOM, 1f, 0.5f);
tpos.getWorld().playSound(tpos, Sound.ENTITY_WARDEN_SONIC_BOOM, 1f, 0.6f);
startPhase1(p, tpos, target, plugin);
}
EntityTracker.untrack(catalyst);
if (catalyst.getTicksLived() > 80) { catalyst.remove(); cancel(); }
}
}.runTaskTimer(plugin, 1L, 1L);
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;
@@ -303,234 +242,10 @@ public class LungeAttack {
ent.setNoDamageTicks(0);
}
private static void startPhase1(Player p, Location epicenter, LivingEntity target, BlackKnifePlugin plugin) {
epicenter = epicenter.clone();
Location ground = epicenter.clone();
ground.setY(epicenter.getWorld().getHighestBlockYAt(epicenter) + 1);
if (ground.getY() > epicenter.getY() + 0.5) ground = epicenter.clone();
Location finalEpicenter = ground.clone();
new BukkitRunnable() {
int wave = 0;
@Override public void run() {
if (wave >= 5) { cancel(); startPhase2(p, finalEpicenter, target, plugin); return; }
spawnShockwave(p, finalEpicenter.clone(), 20, plugin);
wave++;
}
}.runTaskTimer(plugin, 0L, 10L);
}
private static void spawnShockwave(Player p, Location center, double damage, BlackKnifePlugin plugin) {
center.getWorld().playSound(center, Sound.BLOCK_SCULK_SHRIEKER_SHRIEK, 1f, 0.6f);
double maxRadius = 40;
double expandSpeed = 2.0;
new BukkitRunnable() {
double radius = 1;
@Override public void run() {
if (radius > maxRadius) { cancel(); return; }
int points = (int) (radius * 6);
for (int i = 0; i < points; i++) {
double angle = (double) i / points * Math.PI * 2;
double x = Math.cos(angle) * radius;
double z = Math.sin(angle) * radius;
Location ring = center.clone().add(x, 0.3, z);
ring.getWorld().spawnParticle(Particle.ASH, ring, 1, 0, 0, 0, 0);
ring.getWorld().spawnParticle(Particle.SOUL, ring, 1, 0.05, 0.05, 0.05, 0);
ring.getWorld().spawnParticle(Particle.SCULK_SOUL, ring, 1, 0.05, 0.05, 0.05, 0);
}
if (radius > 3 && radius < maxRadius - 1) {
double thinRadius = radius;
for (LivingEntity le : center.getWorld().getNearbyLivingEntities(center, maxRadius + 2)) {
if (le == p) continue;
if (le.getLocation().getY() > center.getY() + 2.5) continue;
double dist = Math.sqrt(Math.pow(le.getLocation().getX() - center.getX(), 2) + Math.pow(le.getLocation().getZ() - center.getZ(), 2));
if (Math.abs(dist - thinRadius) < 1.2) {
applyLungeDamage(le, p, damage);
le.getWorld().spawnParticle(Particle.CRIT, le.getLocation().add(0, 1, 0), 8, 0.3, 0.3, 0.3, 0.1);
}
}
}
radius += expandSpeed;
}
}.runTaskTimer(plugin, 1L, 1L);
}
private static void startPhase2(Player p, Location epicenter, LivingEntity target, BlackKnifePlugin plugin) {
plugin.getServer().getScheduler().runTaskLater(plugin, () -> {
new BukkitRunnable() {
int idx = 0;
@Override public void run() {
if (idx >= 8) { cancel(); return; }
Location above = epicenter.clone().add((Math.random() - 0.5) * 10, 10 + Math.random() * 3, (Math.random() - 0.5) * 10);
spawnFallingSword(p, above, epicenter, 10, 0.7, plugin);
idx++;
}
}.runTaskTimer(plugin, 1L, 3L);
spawnShockwave(p, epicenter.clone(), 20, plugin);
plugin.getServer().getScheduler().runTaskLater(plugin, () -> spawnShockwave(p, epicenter.clone(), 20, plugin), 12L);
plugin.getServer().getScheduler().runTaskLater(plugin, () -> spawnShockwave(p, epicenter.clone(), 20, plugin), 24L);
plugin.getServer().getScheduler().runTaskLater(plugin, () -> startPhase3(p, epicenter, target, plugin), 36L);
}, 10L);
}
private static void spawnFallingSword(Player p, Location spawn, Location target, double damage, double fallSpeed, BlackKnifePlugin plugin) {
BlockDisplay sword = p.getWorld().spawn(spawn, BlockDisplay.class, d -> {
d.setBlock(Material.DEEPSLATE_TILES.createBlockData());
d.setBrightness(new Display.Brightness(15, 15));
d.setViewRange(64);
d.setTransformation(new org.bukkit.util.Transformation(
new org.joml.Vector3f(-0.35f, -0.18f, -0.35f),
new org.joml.AxisAngle4f(),
new org.joml.Vector3f(0.7f, 0.7f, 0.7f),
new org.joml.AxisAngle4f()));
});
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() {
if (!sword.isValid()) { cancel(); return; }
Location cur = sword.getLocation();
Location nl = cur.clone().add(vel);
sword.teleport(nl);
nl.getWorld().spawnParticle(Particle.DUST, nl, 1, 0, 0, 0, 0, new Particle.DustOptions(Color.fromRGB(0, 0, 0), 1.0f));
nl.getWorld().spawnParticle(Particle.SCULK_SOUL, nl, 1, 0.05, 0.05, 0.05, 0);
if (nl.getY() <= target.getY() + 0.5 || nl.getBlock().isSolid()) {
Location expl = nl.clone();
EntityTracker.untrack(sword);
sword.remove();
cancel();
expl.getWorld().spawnParticle(Particle.SMOKE, expl, 6, 0.2, 0.1, 0.2, 0.02);
expl.getWorld().spawnParticle(Particle.SCULK_SOUL, expl, 4, 0.2, 0.1, 0.2, 0.02);
for (int k = 0; k < 12; k++) {
double a = (double) k / 12 * Math.PI * 2;
Location r = expl.clone().add(Math.cos(a) * 1.5, 0.1, Math.sin(a) * 1.5);
r.getWorld().spawnParticle(Particle.ASH, r, 1, 0, 0, 0, 0);
r.getWorld().spawnParticle(Particle.SOUL, r, 1, 0.02, 0.02, 0.02, 0);
}
for (LivingEntity le : expl.getWorld().getNearbyLivingEntities(expl, 1.5)) {
if (le == p) continue;
applyLungeDamage(le, p, damage);
le.getWorld().spawnParticle(Particle.CRIT, le.getLocation().add(0, 1, 0), 6, 0.3, 0.3, 0.3, 0.1);
}
for (Player pl : expl.getWorld().getNearbyPlayers(expl, 0.9)) {
if (pl == p) continue;
double d2 = pl.getLocation().add(0, 1, 0).distanceSquared(expl);
if (d2 > 0.25 && d2 < 0.81) plugin.getTension().tryGiveDodge(pl);
}
return;
}
for (LivingEntity le : nl.getWorld().getNearbyLivingEntities(nl, 0.7)) {
if (le == p) continue;
applyLungeDamage(le, p, damage);
le.getWorld().spawnParticle(Particle.CRIT, le.getLocation().add(0, 1, 0), 8, 0.3, 0.3, 0.3, 0.1);
EntityTracker.untrack(sword);
sword.remove(); cancel(); return;
}
EntityTracker.untrack(sword);
if (sword.getTicksLived() > 80) { sword.remove(); cancel(); }
}
}.runTaskTimer(plugin, 1L, 1L);
}
private static void startPhase3(Player p, Location epicenter, LivingEntity target, BlackKnifePlugin plugin) {
new BukkitRunnable() {
int idx = 0;
@Override public void run() {
if (idx >= 20) { cancel(); startPhase4(p, target, plugin); return; }
Location above = epicenter.clone().add((Math.random() - 0.5) * 12, 10 + Math.random() * 4, (Math.random() - 0.5) * 12);
spawnFallingSword(p, above, epicenter, 10, 0.7, plugin);
idx++;
}
}.runTaskTimer(plugin, 2L, 4L);
}
private static void startPhase4(Player p, LivingEntity target, BlackKnifePlugin plugin) {
plugin.getServer().getScheduler().runTaskLater(plugin, () -> {
new BukkitRunnable() {
int swordIdx = 0;
long nextDelay = 30;
long delayCounter = 0;
@Override public void run() {
if (swordIdx >= 30 || !target.isValid() || target.isDead()) { cancel(); return; }
if (delayCounter < nextDelay) { delayCounter++; return; }
delayCounter = 0;
double frac = (double) swordIdx / 29;
long holdTicks = Math.round(15 - frac * 10);
long gapTicks = Math.round(20 - frac * 5);
nextDelay = gapTicks;
double angle = (swordIdx * 137.5) % 360 * Math.PI / 180;
double r = 7 + Math.random() * 3;
Vector offset = new Vector(Math.cos(angle), 0, Math.sin(angle));
Location curTargetPos = target.getLocation().clone().add(0, 1, 0);
Location spawn = curTargetPos.clone().add(offset.clone().multiply(r)).add(0, (Math.random() - 0.5) * 2, 0);
Vector aim = curTargetPos.clone().subtract(spawn).toVector().normalize().multiply(3.0);
BlockDisplay sword = spawn.getWorld().spawn(spawn, BlockDisplay.class, d -> {
d.setBlock(Material.DEEPSLATE_TILES.createBlockData());
d.setBrightness(new Display.Brightness(15, 15));
d.setViewRange(64);
d.setTransformation(new org.bukkit.util.Transformation(
new org.joml.Vector3f(-0.75f, -0.4f, -0.75f),
new org.joml.AxisAngle4f(),
new org.joml.Vector3f(1.5f, 1.5f, 1.5f),
new org.joml.AxisAngle4f()));
});
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() {
long hold = holdTicks;
boolean launched = false;
@Override public void run() {
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.SCULK_SOUL, cur, 1, 0.1, 0.1, 0.1, 0);
hold--;
if (hold <= 0) launched = true;
return;
}
Location nl = cur.clone().add(aim);
int points = Math.max(1, (int) (aim.length() * 3));
Vector step = aim.clone();
for (int j = 0; j <= points; j++) {
Location pp = cur.clone().add(step.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.SCULK_SOUL, pp, 1, 0.05, 0.05, 0.05, 0);
}
int steps = Math.max(1, (int) Math.ceil(aim.length() / 0.5));
for (int s = 0; s <= steps; s++) {
Location check = cur.clone().add(aim.clone().multiply((double) s / steps));
EntityTracker.untrack(sword);
if (check.getBlock().isSolid()) { sword.remove(); cancel(); return; }
}
sword.teleport(nl);
for (LivingEntity le : nl.getWorld().getNearbyLivingEntities(nl, 1.0)) {
if (le == p) continue;
applyLungeDamage(le, p, 20);
le.getWorld().spawnParticle(Particle.CRIT, le.getLocation().add(0, 1, 0), 10, 0.4, 0.4, 0.4, 0.1);
EntityTracker.untrack(sword);
sword.remove(); cancel(); return;
}
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);
}
EntityTracker.untrack(sword);
if (sword.getTicksLived() > 80) { sword.remove(); cancel(); }
}
}.runTaskTimer(plugin, 1L, 1L);
swordIdx++;
}
}.runTaskTimer(plugin, 1L, 1L);
}, 10L);
}
}
@@ -116,7 +116,7 @@ public class MagicListener implements Listener {
LivingEntity le=(LivingEntity)ent;
DamageUtil.apply(p, le, 15.0);
Location hit=le.getLocation().add(0,1,0);
for(int k=0;k<12;k++){ double a=(double)k/12*Math.PI*2; for(int h=-1;h<=1;h++){ Location s=hit.clone().add(Math.cos(a)*0.5,h*0.25,Math.sin(a)*0.5); s.getWorld().spawnParticle(Particle.DUST,s,1,0,0,0,0,new Particle.DustOptions(Color.fromRGB(200,40,40),0.9f)); } } hit.getWorld().spawnParticle(Particle.CRIT,hit,10,0.3,0.3,0.3,0.1); hit.getWorld().playSound(hit,Sound.ENTITY_PLAYER_ATTACK_CRIT,1f,0.9f);
Particles.ring(hit.getWorld(), hit, 0.5, Particles.dust(200,40,40,0.9f), 12, 3); hit.getWorld().playSound(hit,Sound.ENTITY_PLAYER_ATTACK_CRIT,1f,0.9f);
le.addPotionEffect(new org.bukkit.potion.PotionEffect(org.bukkit.potion.PotionEffectType.SLOWNESS,40,1,false,false,true));
EntityTracker.untrack(marker);
marker.remove(); cancel(); return;
@@ -146,7 +146,7 @@ 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.SNOWFLAKE,sphere,1,0.02,0.02,0.02,0); sphere.getWorld().spawnParticle(Particle.DUST,sphere,1,0,0,0,0,new Particle.DustOptions(Color.fromRGB(136,204,255),0.9f)); } } expl.getWorld().spawnParticle(Particle.SNOWFLAKE,expl,10,0.3,0.3,0.3,0.1); expl.getWorld().playSound(expl,Sound.BLOCK_GLASS_BREAK,1f,1.2f);
Particles.ring(expl.getWorld(), expl, 0.5, Particles.dust(136,204,255,0.9f), 12, 3, Particle.SNOWFLAKE, 10); expl.getWorld().playSound(expl,Sound.BLOCK_GLASS_BREAK,1f,1.2f);
EntityTracker.untrack(marker);
marker.remove(); cancel(); return;
}
@@ -0,0 +1,70 @@
package me.sashegdev.blackknife;
import org.bukkit.Color;
import org.bukkit.Location;
import org.bukkit.Particle;
import org.bukkit.World;
/**
* Батчинг частиц.
*
* <p>Цена в плагине измеряется не в миллисекундах, а в пакетах. Каждый вызов
* {@code spawnParticle} — это отдельный пакет всем игрокам в радиусе, плюс
* сериализация и аллокация. Кольцо из 12 углов на 3 слоя было 36 вызовами с
* count=1, то есть 36 пакетами на одно попадание; во взрыве — 72.
*
* <p>Смысл в том, что {@code spawnParticle} с count больше одного раскидывает
* частицы случайно внутри объёма смещения. Поэтому кольцо радиуса r честно
* превращается в заполненный диск того же радиуса. На масштабе половины блока
* разница глазом не видна, а пакетов в разы меньше.
*
* <p>Числа частиц подобраны так, чтобы плотность осталась прежней: кольцо на
* 12x3 становится тремя дисками по 12 частиц, то есть ровно те же 36 точек.
*/
public final class Particles {
private Particles() {
}
/** Высота слоя кольца, как в исходной развёртке. */
private static final double LAYER_STEP = 0.25D;
/**
* Кольцо вокруг точки: {@code layers} вызовов вместо
* {@code segments * layers}.
*/
public static void ring(World world, Location center, double radius,
Particle.DustOptions dust, int segments, int layers) {
ring(world, center, radius, dust, segments, layers, Particle.CRIT, 10);
}
public static void ring(World world, Location center, double radius,
Particle.DustOptions dust, int segments, int layers,
Particle spark, int sparkCount) {
// Один диск на слой: offset задаёт полуразброс, поэтому частицы ложатся
// в диск радиуса radius вокруг центра слоя, а не в точку.
for (int h = 0; h < layers; h++) {
final double y = center.getY() + ((h - (layers - 1) / 2.0D) * LAYER_STEP);
final Location at = new Location(world, center.getX(), y, center.getZ());
world.spawnParticle(Particle.DUST, at, segments, radius, 0.0D, radius, 0.0D, dust);
}
if (spark != null && sparkCount > 0) {
world.spawnParticle(spark, center, sparkCount, radius, radius * 0.5D, radius, 0.1D);
}
}
/** Плоское кольцо на земле, как ударная волна. */
public static void groundRing(World world, Location center, double radius,
Particle.DustOptions dust, int count) {
world.spawnParticle(Particle.DUST, center, count, radius, 0.0D, radius, 0.0D, dust);
}
/**
* Единичная частица без аллокаций на стороне вызывающего.
* Обёртка существует только чтобы не плодить DustOptions в циклах —
* объект переиспользуется, а не создаётся на каждую частицу.
*/
public static Particle.DustOptions dust(int r, int g, int b, float size) {
return new Particle.DustOptions(Color.fromRGB(r, g, b), size);
}
}
@@ -73,15 +73,7 @@ public class SwordAttack {
LivingEntity le = (LivingEntity) ent;
DamageUtil.apply(p, le, 15.0);
Location hit = le.getLocation().add(0, 1, 0);
for (int k = 0; k < 12; k++) {
double a = (double) k / 12 * Math.PI * 2;
for (int h = -1; h <= 1; h++) {
Location sphere = hit.clone().add(Math.cos(a) * 0.5, h * 0.25, Math.sin(a) * 0.5);
sphere.getWorld().spawnParticle(Particle.SMOKE, sphere, 1, 0.02, 0.02, 0.02, 0);
sphere.getWorld().spawnParticle(Particle.DUST, sphere, 1, 0, 0, 0, 0, new Particle.DustOptions(Color.fromRGB(40, 40, 40), 0.9f));
}
}
hit.getWorld().spawnParticle(Particle.CRIT, hit, 10, 0.3, 0.3, 0.3, 0.1);
Particles.ring(hit.getWorld(), hit, 0.5, Particles.dust(40, 40, 40, 0.9f), 12, 3);
hit.getWorld().playSound(hit, Sound.ENTITY_PLAYER_ATTACK_CRIT, 1f, 0.9f);
le.addPotionEffect(new org.bukkit.potion.PotionEffect(org.bukkit.potion.PotionEffectType.SLOWNESS, 40, 1, false, false, true));
le.addPotionEffect(new org.bukkit.potion.PotionEffect(org.bukkit.potion.PotionEffectType.WEAKNESS, 30, 0, false, false, true));
@@ -23,8 +23,11 @@ public class TensionManager implements Listener {
private final Map<UUID, Long> lastDodge = new HashMap<>();
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;
@@ -116,25 +119,39 @@ public class TensionManager implements Listener {
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);
}
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.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) {
BossBar bar = bars.get(p.getUniqueId());
if (bar == null) {
updateBar(p, getTP(p));
bar = bars.get(p.getUniqueId());
}
// Полосу создаём здесь, а не через 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);
}
@@ -155,16 +172,31 @@ public class TensionManager implements Listener {
}
public void tickBattle() {
long now = System.currentTimeMillis();
for (var e : new java.util.HashMap<>(battleEnemies).entrySet()) {
UUID pid = e.getKey();
java.util.Set<UUID> set = e.getValue();
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 -> {
var ent = org.bukkit.Bukkit.getEntity(uuid);
return ent == null || !ent.isValid() || (ent instanceof org.bukkit.entity.LivingEntity le && le.isDead());
final var ent = org.bukkit.Bukkit.getEntity(uuid);
return ent == null || !ent.isValid()
|| (ent instanceof org.bukkit.entity.LivingEntity le && le.isDead());
});
Long last = lastCombat.get(pid);
if (set.isEmpty() || (last != null && now - last > 180000)) {
final Long last = lastCombat.get(pid);
if (set.isEmpty() || (last != null && 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);
}
@@ -224,6 +256,7 @@ public class TensionManager implements Listener {
// унести полосу в памяти до следующего рестарта.
tp.remove(id);
lastCombat.remove(id);
lastPct.remove(id);
AbilityCooldown.clearAll(e.getPlayer());
}
@@ -265,6 +298,9 @@ public class TensionManager implements Listener {
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); }
}