feat(blackknife): aim indicators use long-range particles, fix broken SWOON

Частицы прицелов уходили через spawnParticle, который ванильный сервер режет
на 32 блоках. Механика при этом работает на 120: OVERHEAD бьёт по точке
взгляда, DIVE туда же, SNIPE и TRI ищут цель в конусе до 120 блоков. То есть
вне 32 блоков прицела не было видно, а удар приходил — выглядит как поломка.

Теперь прицелы идут через Particles.farRing/farParticle, которые вызывают
World#spawnParticleLongDistance. Дистанция считается от игрока до точки
прицела и подрезается потолком 120. Боевые эффекты остались на обычных
spawnParticle: удар мгновенный, рисовать его за 100 блоков незачем.

Переведены четыре прицела: превью OVERHEAD, превью DIVE, кольцо TRI/SNIPE и
метки на цели. В OVERHEAD заодно кольцо из 8 отдельных SCULK_SOUL свернуто в
один дисковый вызов.

Попутно исправлен SWOON. DamageSource собирался только с withCausingEntity,
без withDirectEntity, и Bukkit такой вызов отвергал:
    IllegalArgumentException: Direct entity must be set if causing entity is set
То есть урон не наносился вообще, а в лог падало исключение на каждый удар
ножом. Причина со времён переписывания SWOON, не сегодняшняя.

build.gradle: подключён локальный paper-api из сборки Delta, потому что нового
метода нет в стоковом API. Файл идёт первым, оба jar содержат
org/bukkit/World.class, и на classpath побеждает тот, что раньше.
This commit is contained in:
SashegDev
2026-10-05 17:31:08 +00:00
parent 82bb585f88
commit c68b1d267e
4 changed files with 83 additions and 28 deletions
+7
View File
@@ -18,6 +18,13 @@ repositories {
} }
dependencies { dependencies {
// Локальная сборка Delta нужна из-за World#spawnParticleLongDistance,
// которого нет в стоковом paper-api. Подключается файлом, а не репозиторием:
// в локальной папке сборки нет maven-metadata.xml, поэтому SNAPSHOT оттуда
// не резолвится. Файл идёт ПЕРВЫМ, потому что оба jar содержат
// org/bukkit/World.class, и на classpath побеждает тот, что раньше.
compileOnly files("/root/DeltaZern/paper-api/build/libs/paper-api-1.21.8-R0.1-SNAPSHOT.jar")
// Стоковая версия остаётся ради транзитивных зависимостей (adventure и т.д.).
compileOnly "io.papermc.paper:paper-api:1.21.8-R0.1-SNAPSHOT" compileOnly "io.papermc.paper:paper-api:1.21.8-R0.1-SNAPSHOT"
} }
@@ -100,6 +100,11 @@ public class BlackKnifeListener implements Listener {
/** Порог косинуса конуса наведения: угол примерно 5.7 градуса. */ /** Порог косинуса конуса наведения: угол примерно 5.7 градуса. */
private static final double TARGET_DOT = 0.995D; private static final double TARGET_DOT = 0.995D;
/** Радиус кольца прицела: у SNIPE уже, у TRI шире. */
private static double r1For(String mode) {
return mode.equals("SNIPE") ? 0.6D : 0.8D;
}
/** /**
* Поиск цели для SNIPE и TRI. * Поиск цели для SNIPE и TRI.
* *
@@ -356,24 +361,20 @@ public class BlackKnifeListener implements Listener {
// прицеливания, каждые 5 тиков, на каждого игрока. // прицеливания, каждые 5 тиков, на каждого игрока.
final Particle.DustOptions ringDust = m.equals("TRI") ? Particles.TRI_15 final Particle.DustOptions ringDust = m.equals("TRI") ? Particles.TRI_15
: m.equals("SNIPE") ? Particles.SNIPE_15 : Particles.AIM_15; : m.equals("SNIPE") ? Particles.SNIPE_15 : Particles.AIM_15;
final int ringCount = m.equals("TRI") ? 16 : 12; // SNIPE и TRI ищут цель в конусе до 120 блоков, а ванильные
final double r1 = m.equals("SNIPE") ? 0.6 : 0.8; // частицы режутся на 32: кольцо вокруг цели исчезало, и
for (int i = 0; i < ringCount; i++) { // способность выглядела «никак». Кольцо теперь дальнее.
double angle = (double) i / ringCount * Math.PI * 2; Particles.farRing(p.getWorld(), p, tl, r1For(m), ringDust,
double x = Math.cos(angle) * r1; m.equals("TRI") ? 16 : 12);
double z = Math.sin(angle) * r1;
Location ring = tl.clone().add(x, 0, z);
p.spawnParticle(Particle.DUST, ring, 1, 0, 0, 0, 0, ringDust);
}
if (m.equals("TRI")) { if (m.equals("TRI")) {
for (int k = 0; k < 3; k++) { for (int k = 0; k < 3; k++) {
double a = k * 120 * Math.PI / 180; double a = k * 120 * Math.PI / 180;
Location dot = tl.clone().add(Math.cos(a) * r1, 0, Math.sin(a) * r1); Location dot = tl.clone().add(Math.cos(a) * r1For(m), 0, Math.sin(a) * r1For(m));
p.spawnParticle(Particle.DUST, dot, 1, 0, 0, 0, 0, Particles.RED_13); Particles.farParticle(p.getWorld(), p, dot, Particle.DUST, 1, 0, 0, 0, Particles.RED_13);
} }
} }
p.spawnParticle(Particle.SCULK_SOUL, tl, 3, 0.3, 0.5, 0.3, 0.02); 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, Particles.RED_10); Particles.farParticle(p.getWorld(), p, tl.clone().add(0, 2, 0), Particle.DUST, 1, 0, 0, 0, Particles.RED_10);
} }
}; };
r.runTaskTimer(plugin, 1L, 5L); r.runTaskTimer(plugin, 1L, 5L);
@@ -385,17 +386,13 @@ public class BlackKnifeListener implements Listener {
if (p.hasCooldown(Material.NETHERITE_SWORD)) return; if (p.hasCooldown(Material.NETHERITE_SWORD)) return;
if (!BlackKnifeItem.inHands(p)) return; if (!BlackKnifeItem.inHands(p)) return;
Location gp = getGazePoint(p); Location gp = getGazePoint(p);
// 32 отдельных пакета на тик прицеливания -> 2 пакета. // Прицел идёт через farRing: OVERHEAD бьёт по точке взгляда,
// Кольцо radius 1.8 раскладывается диском, на масштабе // а она бывает на 120 блоках, тогда как ванильные частицы
// полутора блоков глазом неотличимо от точек на окружности. // режутся на 32. Дальше 32 блоков прицела не было видно вообще.
Particles.groundRing(p.getWorld(), gp.clone().add(0, 0.1, 0), 1.8, Particles.farRing(p.getWorld(), p, gp.clone().add(0, 0.1, 0), 1.8,
Particles.dust(20, 20, 20, 1.2f), 16); Particles.GREY_12, 16);
p.spawnParticle(Particle.ASH, gp, 16, 1.8, 0.05, 1.8, 0.0D); Particles.farParticle(p.getWorld(), p, gp, Particle.ASH, 16, 1.8, 0.05, 1.8, null);
for (int i = 0; i < 8; i++) { p.spawnParticle(Particle.SCULK_SOUL, gp.clone().add(0, 0.1, 0), 8, 1, 0.02, 1, 0);
double a = (double) i / 8 * Math.PI * 2;
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, Particles.RED_15); p.spawnParticle(Particle.DUST, gp.clone().add(0, 0.1, 0), 1, 0, 0, 0, 0, Particles.RED_15);
} }
}; };
@@ -429,11 +426,9 @@ public class BlackKnifeListener implements Listener {
if (p.hasCooldown(Material.NETHERITE_SWORD)) return; if (p.hasCooldown(Material.NETHERITE_SWORD)) return;
if (!BlackKnifeItem.inHands(p)) return; if (!BlackKnifeItem.inHands(p)) return;
Location gp = getGazePoint(p); Location gp = getGazePoint(p);
for (int i = 0; i < 16; i++) { // DIVE целится по точке взгляда до 120 блоков — кольцо должно
double a = (double) i / 16 * Math.PI * 2; // быть дальним, иначе после 32 блоков прицела не видно.
Location ring = gp.clone().add(Math.cos(a) * 1.2, 0.1, Math.sin(a) * 1.2); Particles.farRing(p.getWorld(), p, gp, 1.2D, Particles.BLUE_12, 16);
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); p.spawnParticle(Particle.CLOUD, gp.clone().add(0, 0.2, 0), 1, 0.1, 0.1, 0.1, 0);
} }
}; };
@@ -4,6 +4,7 @@ import org.bukkit.Color;
import org.bukkit.Location; import org.bukkit.Location;
import org.bukkit.Particle; import org.bukkit.Particle;
import org.bukkit.World; import org.bukkit.World;
import org.bukkit.entity.Player;
/** /**
* Батчинг частиц. * Батчинг частиц.
@@ -144,4 +145,50 @@ public final class Particles {
public static final Particle.DustOptions DARKGREY_15 = dust(30, 30, 30, 1.5f); public static final Particle.DustOptions DARKGREY_15 = dust(30, 30, 30, 1.5f);
/** Тёмно-серый, размер 0.9 — сфера взрыва SWORD, 36 частиц за вызов. */ /** Тёмно-серый, размер 0.9 — сфера взрыва SWORD, 36 частиц за вызов. */
public static final Particle.DustOptions DARKGREY_09 = dust(30, 30, 30, 0.9f); public static final Particle.DustOptions DARKGREY_09 = dust(30, 30, 30, 0.9f);
// ---------------------------------------------------------------------
// Дальние частицы — прицелы.
//
// Механика способностей бьёт на 120 блоков, а ванильный spawnParticle режет
// на 32: прицел исчезает, а удар приходит. С 32 блоков игрок вообще не
// понимает, куда целится.
//
// World#spawnParticleLongDistance убирает обе отсечки — серверную (список
// получателей фильтруется на заданной дистанции) и клиентскую (alwaysShow
// едет в пакете). Прицелы ходят через неё, боевые эффекты остаются на
// обычных spawnParticle: удар мгновенный, рисовать его за 100 блоков не
// нужно, и лишний трафик на каждый тик не нужен.
//
// Дистанция считается от игрока до точки прицела и подрезается потолком из
// blackknife: дальше 120 блоков прицелы всё равно не рисуются, потому что
// сам взгляд дальше не достаёт.
// ---------------------------------------------------------------------
/** Потолок: взглядом дальше не достать, и клиент всё равно режет. */
public static final double MAX_AIM_DISTANCE = 120.0D;
/** Дистанция от игрока до точки прицела, ограниченная MAX_AIM_DISTANCE. */
public static double aimDistance(Player from, Location to) {
final double d = from.getLocation().distance(to);
return Math.max(1.0D, Math.min(MAX_AIM_DISTANCE, d));
}
/** Диск на земле, видимый издалека. */
public static void farRing(World world, Player viewer, Location center, double radius,
Particle.DustOptions dust, int count) {
world.spawnParticleLongDistance(Particle.DUST,
center.getX(), center.getY(), center.getZ(),
count, radius, 0.0D, radius, 0.0D, dust,
aimDistance(viewer, center));
}
/** Частица с заданным разбросом, видимая издалека. */
public static <T> void farParticle(World world, Player viewer, Location at, Particle particle,
int count, double spreadX, double spreadY, double spreadZ,
T data) {
world.spawnParticleLongDistance(particle,
at.getX(), at.getY(), at.getZ(),
count, spreadX, spreadY, spreadZ, 0.0D, data,
aimDistance(viewer, at));
}
} }
@@ -113,8 +113,14 @@ public class SwoonHandler implements Listener {
tension.setTP(p, tension.getTP(p) - cost); tension.setTP(p, tension.getTP(p) - cost);
final var cfg = plugin.getSwoonConfig(); final var cfg = plugin.getSwoonConfig();
// withCausingEntity без withDirectEntity Bukkit не принимает:
// CraftDamageSourceBuilder.build() бросает
// "Direct entity must be set if causing entity is set".
// То есть урон просто не наносился, а в лог падало исключение на КАЖДЫЙ
// удар ножом — 15 раз за один короткий прогон.
final DamageSource source = DamageSource.builder(DamageType.GENERIC_KILL) final DamageSource source = DamageSource.builder(DamageType.GENERIC_KILL)
.withCausingEntity(p) .withCausingEntity(p)
.withDirectEntity(p)
.build(); .build();
target.getPersistentDataContainer().set(MAGIC_DAMAGE, PersistentDataType.BYTE, (byte) 1); target.getPersistentDataContainer().set(MAGIC_DAMAGE, PersistentDataType.BYTE, (byte) 1);