diff --git a/src/main/java/me/sashegdev/blackknife/BlackKnifeListener.java b/src/main/java/me/sashegdev/blackknife/BlackKnifeListener.java index a29b910..3bb56fd 100644 --- a/src/main/java/me/sashegdev/blackknife/BlackKnifeListener.java +++ b/src/main/java/me/sashegdev/blackknife/BlackKnifeListener.java @@ -25,11 +25,28 @@ public class BlackKnifeListener implements Listener { private final java.util.Map previewTasks = new java.util.HashMap<>(); private final java.util.Map lungeHighlightTasks = new java.util.HashMap<>(); private final java.util.Map 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"); } + /** + * Текущий режим ножа. + * + *

Режим хранится в 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. + * + *

Конус наведения узкий — около 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; - } - 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; } + 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); } - return best; + 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); + } + + // Единственный луч по блокам на весь поиск. + 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); diff --git a/src/main/java/me/sashegdev/blackknife/LungeAttack.java b/src/main/java/me/sashegdev/blackknife/LungeAttack.java index cd4785d..8203c47 100644 --- a/src/main/java/me/sashegdev/blackknife/LungeAttack.java +++ b/src/main/java/me/sashegdev/blackknife/LungeAttack.java @@ -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); - } } diff --git a/src/main/java/me/sashegdev/blackknife/MagicListener.java b/src/main/java/me/sashegdev/blackknife/MagicListener.java index 8687317..7671c78 100644 --- a/src/main/java/me/sashegdev/blackknife/MagicListener.java +++ b/src/main/java/me/sashegdev/blackknife/MagicListener.java @@ -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; } diff --git a/src/main/java/me/sashegdev/blackknife/Particles.java b/src/main/java/me/sashegdev/blackknife/Particles.java new file mode 100644 index 0000000..ff585b8 --- /dev/null +++ b/src/main/java/me/sashegdev/blackknife/Particles.java @@ -0,0 +1,70 @@ +package me.sashegdev.blackknife; + +import org.bukkit.Color; +import org.bukkit.Location; +import org.bukkit.Particle; +import org.bukkit.World; + +/** + * Батчинг частиц. + * + *

Цена в плагине измеряется не в миллисекундах, а в пакетах. Каждый вызов + * {@code spawnParticle} — это отдельный пакет всем игрокам в радиусе, плюс + * сериализация и аллокация. Кольцо из 12 углов на 3 слоя было 36 вызовами с + * count=1, то есть 36 пакетами на одно попадание; во взрыве — 72. + * + *

Смысл в том, что {@code spawnParticle} с count больше одного раскидывает + * частицы случайно внутри объёма смещения. Поэтому кольцо радиуса r честно + * превращается в заполненный диск того же радиуса. На масштабе половины блока + * разница глазом не видна, а пакетов в разы меньше. + * + *

Числа частиц подобраны так, чтобы плотность осталась прежней: кольцо на + * 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); + } +} \ No newline at end of file diff --git a/src/main/java/me/sashegdev/blackknife/SwordAttack.java b/src/main/java/me/sashegdev/blackknife/SwordAttack.java index 95299d7..0d806fd 100644 --- a/src/main/java/me/sashegdev/blackknife/SwordAttack.java +++ b/src/main/java/me/sashegdev/blackknife/SwordAttack.java @@ -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)); diff --git a/src/main/java/me/sashegdev/blackknife/TensionManager.java b/src/main/java/me/sashegdev/blackknife/TensionManager.java index 9ccff7f..2c0d6e1 100644 --- a/src/main/java/me/sashegdev/blackknife/TensionManager.java +++ b/src/main/java/me/sashegdev/blackknife/TensionManager.java @@ -23,8 +23,11 @@ public class TensionManager implements Listener { private final Map lastDodge = new HashMap<>(); private final Map ownerCache = new HashMap<>(); private final Map ownerCacheTime = new HashMap<>(); + private final Map lastPct = new HashMap<>(); private static final long OWNER_CACHE_TTL = 2000L; public static final float MAX_TP = 250f; + /** Бой считается кончившимся после этого простоя без TP-активности. */ + private static final long BATTLE_EXPIRE_MS = 180_000L; private float passiveRegenPerTick = 0.25f; private long passiveRegenDelayMs = 3000L; @@ -116,25 +119,39 @@ public class TensionManager implements Listener { return mgr != null && mgr.hasAwakened(p.getUniqueId()); } + /** + * Перерисовка полосы. Вызывается каждый тик на каждого игрока, поэтому + * работает по дешёвому пути: пакет прогресса и новый Component уходят только + * когда изменился целый процент. При регене 0.25/тик это 4 перерисовки в + * секунду вместо 20, а на полной полосе — ноль. + * + *

Проценты считаются целочисленно: {@code String.format} на каждом тике + * на каждого игрока это сотни вызовов в секунду ради строки, которую никто + * не может прочитать быстрее, чем за кадр. + */ private void updateBar(Player p, float tpVal) { + final int pct = (int) Math.round(Math.max(0.0D, Math.min(1.0D, (double) tpVal / MAX_TP)) * 100.0D); + if (lastPct.getOrDefault(p.getUniqueId(), -1) == pct) { + return; + } + lastPct.put(p.getUniqueId(), pct); + BossBar bar = bars.get(p.getUniqueId()); if (bar == null) { bar = BossBar.bossBar(Component.text("TP 0%"), 0f, BossBar.Color.WHITE, BossBar.Overlay.NOTCHED_10); bars.put(p.getUniqueId(), bar); } - 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 set = e.getValue(); + final long now = System.currentTimeMillis(); + // Ключи на удаление собираются в список и применяются после обхода. + // Раньше здесь на каждый тик делалась полная копия battleEnemies вместе + // со всеми вложенными сетами — ради того, чтобы итерировать и одновременно + // удалять из той же карты. Это 20 полных аллокаций карты в секунду + // на каждого боя, и копия тем больше, чем длиннее бой. + java.util.List drop = null; + for (var e : battleEnemies.entrySet()) { + final UUID pid = e.getKey(); + final java.util.Set set = e.getValue(); set.removeIf(uuid -> { - 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); } }