diff --git a/src/main/java/me/sashegdev/blackknife/BlackKnifeListener.java b/src/main/java/me/sashegdev/blackknife/BlackKnifeListener.java index 80016ee..ff8bf1a 100644 --- a/src/main/java/me/sashegdev/blackknife/BlackKnifeListener.java +++ b/src/main/java/me/sashegdev/blackknife/BlackKnifeListener.java @@ -26,7 +26,7 @@ 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", "SNIPE", "TRI", "OVERHEAD", "GRENADE", "DIVE"}; + public static final String[] MODES = {"STARS", "SWORD", "DIAMOND", "DIAMOND_TRI", "BARRAGE", "SNIPE", "TRI", "OVERHEAD", "GRENADE", "DIVE", "MOLEHOLE"}; public BlackKnifeListener(BlackKnifePlugin plugin) { this.plugin = plugin; } @@ -66,6 +66,7 @@ public class BlackKnifeListener implements Listener { swordCombo.remove(p.getUniqueId()); clearPreview(p); clearHighlight(p); + MoleHole.clear(p); } /** @@ -286,6 +287,16 @@ public class BlackKnifeListener implements Listener { p.setCooldown(item.getType(), SkyDiveAttack.ITEM_COOLDOWN_TICKS); clearHighlight(p); } + } else if (m.equals("MOLEHOLE")) { + // ПКМ открывает ВХОДНУЮ дыру и переводит игрока в «жду + // координаты». Кулдаун и TP тут не трогаются: по условию они + // списываются в момент создания ПАРЫ, то есть после ввода + // координат. Поэтому ПКМ можно нажимать многократно. + if (p.hasCooldown(item.getType())) return; + if (MoleHole.openEntrance(plugin, p, tm)) { + p.setCooldown(item.getType(), 5); + clearHighlight(p); + } } e.setCancelled(true); } diff --git a/src/main/java/me/sashegdev/blackknife/BlackKnifePlugin.java b/src/main/java/me/sashegdev/blackknife/BlackKnifePlugin.java index 8bff39d..fbcfe0c 100644 --- a/src/main/java/me/sashegdev/blackknife/BlackKnifePlugin.java +++ b/src/main/java/me/sashegdev/blackknife/BlackKnifePlugin.java @@ -44,6 +44,9 @@ public class BlackKnifePlugin extends JavaPlugin { getServer().getPluginManager().registerEvents(chatHandler, this); getServer().getPluginManager().registerEvents(awakeningManager, this); getServer().getPluginManager().registerEvents(new KillMessageHandler(this), this); + // Разбор координат для «Кротовой ямы». LOWEST внутри обработчика, чтобы + // координаты не утекли в общий чат. + getServer().getPluginManager().registerEvents(new MoleHoleCoords(this), this); BlackKnife.init(this); var bkCmd = new BlackKnifeCommand(this, chatConfig, chatHandler); getCommand("blackknife").setExecutor(bkCmd); @@ -120,6 +123,7 @@ public class BlackKnifePlugin extends JavaPlugin { public void onDisable() { getServer().getScheduler().cancelTasks(this); StarFx.clear(); + MoleHole.clearAll(); if (tension != null) { tension.hideAll(); for (var p : getServer().getOnlinePlayers()) { diff --git a/src/main/java/me/sashegdev/blackknife/MoleHole.java b/src/main/java/me/sashegdev/blackknife/MoleHole.java new file mode 100644 index 0000000..e0ede12 --- /dev/null +++ b/src/main/java/me/sashegdev/blackknife/MoleHole.java @@ -0,0 +1,336 @@ +package me.sashegdev.blackknife; + +import net.kyori.adventure.text.Component; +import net.kyori.adventure.text.format.NamedTextColor; +import org.bukkit.Bukkit; +import org.bukkit.Location; +import org.bukkit.Material; +import org.bukkit.World; +import org.bukkit.block.Block; +import org.bukkit.entity.Player; +import org.bukkit.scheduler.BukkitRunnable; + +import java.util.HashMap; +import java.util.Map; +import java.util.UUID; + +/** + * Способность «Кротовая яма»: телепорт через пары отверстий. + * + *

Поток ровно такой, как задумано: + *

    + *
  1. ПКМ открывает ВХОДНУЮ дыру и переводит игрока в {@link State#WAITING_COORDS}. + * Таймера на этом шаге нет вообще.
  2. + *
  3. Игрок пишет координаты в чат. Формат {@code x, y, z} либо {@code x, z}. + * Ошибся — просто вводит ещё раз, ответная дыра переезжает, ничего не теряется.
  4. + *
  5. Когда ответная дыра открылась, списывается 100% TP, стартует кулдаун + * 30 секунд и пятисекундный таймер жизни пары. Состояние {@link State#PAIRED}, + * и ввод координат в нём ЗАБЛОКИРОВАН.
  6. + *
+ * + *

Почему блокировка ввода в {@code PAIRED}: иначе игрок переставил бы + * ответную дыру и продлил себе окно. По условию таймер начинается только один раз, + * в момент сборки пары, и живёт ровно 5 секунд. + * + *

Погружение: вставший в дыру плавно уходит вниз на 1 блок (как в паутине), + * а на −0.5 блока срабатывает телепорт. На выходе игрок появляется с тем же + * смещением по Y, то есть тоже на −0.5, чтобы «утонувший» приземлялся в дыру, + * а не падал с высоты. + */ +public final class MoleHole { + + private MoleHole() { + } + + /** Стоимость: полная полоса TP. */ + public static final float TP_COST = 100.0f; + /** Кулдаун способности, тиков. 30 секунд при 20 тиках в секунду. */ + public static final long COOLDOWN_TICKS = 600L; + /** Сколько живёт собранная пара. */ + public static final long PAIR_TICKS = 100L; + /** На сколько блоков вниз уходит вставший в дыру. */ + public static final double SINK_DEPTH = 1.0D; + /** На этой глубине срабатывает телепорт. */ + public static final double TRIGGER_DEPTH = 0.5D; + /** Ключ способности в AbilityCooldown. */ + public static final String ABILITY = "MOLEHOLE"; + /** Радиус дыры для попадания, блоков. Чуть шире визуала. */ + public static final double HOLE_RADIUS = 1.5D; + + /** Состояние игрока по отношению к дырам. */ + public enum State { IDLE, WAITING_COORDS, PAIRED } + + /** + * Всё состояние способности на игрока. + * + *

Держим одну запись на игрока, а не разбросанные карты по трём классам: + * связать «дыра открыта» и «можно вводить координаты» иначе невозможно. + */ + public static final class Session { + public State state = State.IDLE; + /** Входная дыра, там где стоит игрок. */ + public Location entrance; + /** Ответная дыра, там куда указали координаты. */ + public Location exit; + /** Тик, на который пара рассыплется. 0 = не задана. */ + public long expireTick; + /** Куда отправлять вставших в дыру. */ + public Location entranceTo; + public Location exitTo; + /** Дыра сделана из пустоты: вход только для открывшего. */ + public boolean fromVoid; + public UUID owner; + /** Кто уже воспользовался, чтобы не телепортировать одного и того же дважды за тик. */ + public final Map inside = new HashMap<>(); + } + + private static final Map SESSIONS = new HashMap<>(); + private static BukkitRunnable task; + + // ------------------------------------------------------------- состояние ---- + + public static State state(Player p) { + final Session s = SESSIONS.get(p.getUniqueId()); + return s == null ? State.IDLE : s.state; + } + + /** Ждёт ли игрок координаты. Только в этом состоянии чат разбирается. */ + public static boolean awaitingCoords(Player p) { + return state(p) == State.WAITING_COORDS; + } + + private static Session session(Player p) { + return SESSIONS.computeIfAbsent(p.getUniqueId(), k -> new Session()); + } + + // ----------------------------------------------------------------- запуск ---- + + /** + * ПКМ: открыть входную дыру и начать ждать координаты. + * + * @return true, если дыра открылась + */ + public static boolean openEntrance(BlackKnifePlugin plugin, Player p, TensionManager tm) { + final Session s = session(p); + + // Дыра должна стоять на опоре: иначе она висит в воздухе и в неё + // невозможно войти, потому что «встать в яму» не выходит. + final Location ground = groundUnder(p.getLocation()); + if (ground == null) { + p.sendActionBar(Component.text("Need solid ground under you").color(NamedTextColor.RED)); + return false; + } + if (!isFree(ground)) { + p.sendActionBar(Component.text("Blocked here").color(NamedTextColor.RED)); + return false; + } + + if (s.state == State.PAIRED) { + // Повторный ПКМ в окне пары: игнорируем, иначе можно было бы + // продлевать себе окно многократными нажатиями. + p.sendActionBar(Component.text("Hole already open").color(NamedTextColor.YELLOW)); + return false; + } + + s.entrance = ground; + s.entranceTo = null; + s.exit = null; + s.exitTo = null; + s.inside.clear(); + s.state = State.WAITING_COORDS; + ensureTask(plugin); + p.sendActionBar(Component.text("Hole open. Type coordinates in chat: x, y, z").color(NamedTextColor.LIGHT_PURPLE)); + return true; + } + + /** + * Ответная дыра по координатам из чата. + * + *

Здесь и только здесь списывается TP: условие говорит, что стоимость + * уходит в момент создания пары, а не в момент прыжка. Значит пару можно + * создать и уйти, не воспользовавшись — потрачено. + */ + public static boolean openExit(BlackKnifePlugin plugin, Player p, TensionManager tm, Location target) { + final Session s = SESSIONS.get(p.getUniqueId()); + if (s == null || s.state != State.WAITING_COORDS) { + return false; + } + if (target == null || !isFree(target)) { + p.sendActionBar(Component.text("Blocked at those coordinates").color(NamedTextColor.RED)); + return false; + } + if (tm.getTP(p) < TP_COST) { + p.sendActionBar(Component.text("Need 100% TP").color(NamedTextColor.RED)); + return false; + } + + // Проверяем до списания, чтобы не списать TP за неудачную попытку. + if (!MoleHoleEffect.place(plugin, p, s.entrance, s.exit, target)) { + return false; + } + + tm.setTP(p, tm.getTP(p) - TP_COST); + AbilityCooldown.set(p, ABILITY, (int) COOLDOWN_TICKS); + s.exit = target; + s.expireTick = Bukkit.getCurrentTick() + PAIR_TICKS; + s.state = State.PAIRED; + p.sendActionBar(Component.text("Pair ready - 5s").color(NamedTextColor.GREEN)); + return true; + } + + // -------------------------------------------------------------- геометрия ---- + + /** + * Точка дыры под игроком: верхний блок минус один, то есть пол на уровне ног. + * + *

Возвращает null, если опоры нет — висящий в воздухе игрок дыру не открывает. + */ + public static Location groundUnder(Location from) { + final World w = from.getWorld(); + if (w == null) return null; + final int x = from.getBlockX(); + final int z = from.getBlockZ(); + for (int y = Math.min(w.getMaxHeight() - 2, from.getBlockY() + 2); y >= w.getMinHeight() + 1; y--) { + if (w.getBlockAt(x, y, z).getType().isSolid()) { + return new Location(w, x + 0.5D, y, z + 0.5D); + } + } + return null; + } + + /** Верхний блок в колонке — используется для формата «x, z» без Y. */ + public static Location topAt(World w, int x, int z) { + for (int y = w.getMaxHeight() - 1; y >= w.getMinHeight(); y--) { + if (w.getBlockAt(x, y, z).getType().isSolid()) { + return new Location(w, x + 0.5D, y, z + 0.5D); + } + } + return null; + } + + /** + * Дыра не может стоять в блоке и не должна быть на потолке. + * + *

Проверяем три блока: пол-опора под точкой, сам блок точки и блок над ним. + * Точка дыры — это поверхность блока снизу, то есть её блок обязан быть воздухом, + * а над ней должно быть свободно, иначе в дыру не встанешь. + */ + public static boolean isFree(Location at) { + final World w = at.getWorld(); + if (w == null) return false; + if (w.getBlockAt(at).getType().isSolid()) return false; + final Block above = w.getBlockAt(at.getBlockX(), at.getBlockY() + 1, at.getBlockZ()); + if (above.getType().isSolid()) return false; + final Block below = w.getBlockAt(at.getBlockX(), at.getBlockY() - 1, at.getBlockZ()); + return below.getType().isSolid(); + } + + /** Стоит ли игрок внутри дыры. */ + public static boolean standingIn(Player p, Location hole) { + if (hole == null || hole.getWorld() == null) return false; + if (!hole.getWorld().equals(p.getWorld())) return false; + return hole.distanceSquared(p.getLocation()) < HOLE_RADIUS * HOLE_RADIUS; + } + + // ------------------------------------------------------------- уборка ---- + + public static void clear(Player p) { + final Session s = SESSIONS.remove(p.getUniqueId()); + if (s != null && s.owner != null && s.owner.equals(p.getUniqueId())) { + MoleHoleEffect.removeAll(p); + } else if (s != null) { + MoleHoleEffect.removeHoles(s); + } + if (SESSIONS.isEmpty()) stopTask(); + } + + public static void clearAll() { + SESSIONS.clear(); + MoleHoleEffect.removeEverything(); + stopTask(); + } + + // -------------------------------------------------------------- тик ---- + + 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 stopTask() { + if (task != null) { + task.cancel(); + task = null; + } + } + + private static void tick(BlackKnifePlugin plugin) { + final long now = Bukkit.getCurrentTick(); + + // Истёкшие пары и игроки, вышедшие с сервера, снимаем отдельным проходом + // по копии ключей: удалять из карты прямо во время обхода нельзя. + for (var id : new java.util.ArrayList<>(SESSIONS.keySet())) { + final Session s = SESSIONS.get(id); + + if (s.state == State.PAIRED && s.expireTick > 0 && now >= s.expireTick) { + MoleHoleEffect.removeHoles(s); + reset(s); + continue; + } + + if (!isOnline(id)) { + MoleHoleEffect.removeHoles(s); + reset(s); + } + } + + if (SESSIONS.isEmpty()) { + stopTask(); + return; + } + + // Погружение тех, кто встал в дыру. + for (Session s : SESSIONS.values()) { + if (s.state != State.PAIRED || s.entrance == null || s.exit == null) continue; + + for (Player target : Bukkit.getOnlinePlayers()) { + final UUID uid = target.getUniqueId(); + final boolean inEntrance = standingIn(target, s.entrance); + final boolean inExit = standingIn(target, s.exit); + if (!inEntrance && !inExit) continue; + + // Правила доступа: дыра из пустоты — только для открывшего, + // обычная дыра — для всех. + if (s.fromVoid && !uid.equals(s.owner)) continue; + if (s.inside.getOrDefault(uid, Boolean.FALSE)) continue; + s.inside.put(uid, Boolean.TRUE); + + final Location dest = inEntrance ? s.exit : s.entrance; + MoleHoleEffect.sink(plugin, target, dest, new java.util.function.Consumer() { + @Override public void accept(Player who) { + who.teleport(dest.clone().add(0, -TRIGGER_DEPTH, 0)); + who.setFallDistance(0f); + who.setVelocity(new org.bukkit.util.Vector(0, 0, 0)); + } + }); + } + } + } + + private static void reset(Session s) { + s.state = State.IDLE; + s.entrance = null; + s.exit = null; + s.expireTick = 0; + s.inside.clear(); + } + + private static boolean isOnline(UUID id) { + return Bukkit.getPlayer(id) != null; + } +} \ No newline at end of file diff --git a/src/main/java/me/sashegdev/blackknife/MoleHoleCoords.java b/src/main/java/me/sashegdev/blackknife/MoleHoleCoords.java new file mode 100644 index 0000000..c610efa --- /dev/null +++ b/src/main/java/me/sashegdev/blackknife/MoleHoleCoords.java @@ -0,0 +1,112 @@ +package me.sashegdev.blackknife; + +import io.papermc.paper.event.player.AsyncChatEvent; +import net.kyori.adventure.text.Component; +import net.kyori.adventure.text.format.NamedTextColor; +import net.kyori.adventure.text.serializer.plain.PlainTextComponentSerializer; +import org.bukkit.Location; +import org.bukkit.World; +import org.bukkit.entity.Player; +import org.bukkit.event.EventHandler; +import org.bukkit.event.EventPriority; +import org.bukkit.event.Listener; + +import java.util.regex.Matcher; +import java.util.regex.Pattern; + +/** + * Приём координат для «Кротовой ямы». + * + *

Разбираем только то, что игрок реально ввёл как координаты, и только когда + * он ждёт их. Всё остальное уходит в чат нетронутым. Это принципиально: если + * перехватчик глотает переписку, ломается чат целиком, и заметят это все, а не + * только игрок с ножом. + * + *

Форматов ровно два: три числа — это {@code x, y, z}, два числа — это + * {@code x, z} и Y берётся верхним блоком колонки. Формата «x, y» без Z не + * существует: двусмысленно, и трактовать его как «две координаты» безопаснее, + * потому что это единственное прочтение, при котором игрок не улетает в + * пустоту вместо нужного места. + */ +public class MoleHoleCoords implements Listener { + + /** До трёх чисел через запятую или пробел. Числа дробные, дыра в центре блока. */ + private static final Pattern COORDS = Pattern.compile( + "^\\s*(-?\\d+(?:\\.\\d+)?)\\s*(?:[,;]\\s*|\\s+)(-?\\d+(?:\\.\\d+)?)?" + + "(?:\\s*[,;]?\\s*(-?\\d+(?:\\.\\d+)?))?\\s*$"); + + private final BlackKnifePlugin plugin; + + public MoleHoleCoords(BlackKnifePlugin plugin) { + this.plugin = plugin; + } + + /** + * LOWEST, чтобы другие плагины чата не успели отправить сообщение в чат. + * Но НЕ MONITOR: там уже поздно отменять. + */ + @EventHandler(priority = EventPriority.LOWEST, ignoreCancelled = true) + public void onChat(AsyncChatEvent e) { + final Player p = e.getPlayer(); + + // Ввод координат принимается только в этом состоянии. В PAIRED ввод + // заблокирован, и сообщение уходит в чат обычным текстом. + if (!MoleHole.awaitingCoords(p)) { + return; + } + + final String text = PlainTextComponentSerializer.plainText().serialize(e.message()).trim(); + if (text.isEmpty() || !looksLikeCoords(text)) { + return; // не координаты — не наше дело, пусть идёт в чат + } + + // Разобрали как координаты: в чат это сообщение уже не пойдёт. + e.setCancelled(true); + + final Location target = resolve(p, text); + if (target == null) { + p.sendActionBar(Component.text("Bad coordinates, use x, y, z or x, z") + .color(NamedTextColor.RED)); + return; + } + + // Работа с миром и телепортами — только на главном потоке. + plugin.getServer().getScheduler().runTask(plugin, () -> + MoleHole.openExit(plugin, p, plugin.getTension(), target)); + } + + /** Строка разбирается как координаты: ровно два или три числа. */ + public static boolean looksLikeCoords(String text) { + if (text == null) return false; + final Matcher m = COORDS.matcher(text.trim()); + if (!m.matches()) return false; + final int count = numbers(m); + return count == 2 || count == 3; + } + + private static int numbers(Matcher m) { + int n = 0; + for (int g = 1; g <= 3; g++) { + if (m.group(g) != null && !m.group(g).isEmpty()) n++; + } + return n; + } + + /** Строит точку. Два числа — это x и z, три — x, y, z. */ + private static Location resolve(Player p, String text) { + final Matcher m = COORDS.matcher(text.trim()); + if (!m.matches()) return null; + + final int count = numbers(m); + final double x = Double.parseDouble(m.group(1)); + final double second = m.group(2) == null ? 0 : Double.parseDouble(m.group(2)); + final double third = m.group(3) == null ? 0 : Double.parseDouble(m.group(3)); + + final World w = p.getWorld(); + + if (count == 2) { + return MoleHole.topAt(w, (int) Math.floor(x), (int) Math.floor(second)); + } + return new Location(w, x, second, third); + } +} \ No newline at end of file diff --git a/src/main/java/me/sashegdev/blackknife/MoleHoleEffect.java b/src/main/java/me/sashegdev/blackknife/MoleHoleEffect.java new file mode 100644 index 0000000..70506e5 --- /dev/null +++ b/src/main/java/me/sashegdev/blackknife/MoleHoleEffect.java @@ -0,0 +1,267 @@ +package me.sashegdev.blackknife; + +import org.bukkit.Location; +import org.bukkit.Material; +import org.bukkit.Particle; +import org.bukkit.World; +import org.bukkit.entity.BlockDisplay; +import org.bukkit.entity.Display; +import org.bukkit.entity.Player; +import org.bukkit.potion.PotionEffect; +import org.bukkit.potion.PotionEffectType; +import org.bukkit.scheduler.BukkitRunnable; + +import java.util.ArrayList; +import java.util.HashMap; +import java.util.List; +import java.util.Map; +import java.util.UUID; +import java.util.function.Consumer; + +/** + * Визуал и погружение «Кротовой ямы». + * + *

Всё крутится в одной задаче, а не по задаче на сущность. Тот же приём, что + * в {@link StarFx}: раньше на каждую звезду и каждый осколок была своя запись + * в планировщике, то есть одно нажатие порождало до 126 задач. Здесь дыр может + * быть много одновременно у разных игроков, и умножать задачи на их количество + * нельзя. + * + *

Отверстие выглядит как заполненный чёрный круг радиусом 1.5 блока, из + * которого вверх идут и одновременно уменьшаются BlockDisplay-очки: чёрное + * стекло, чёрный бетон и чёрный гладкий бетон. На каждом осколке идёт своя + * частица. Всё это живёт около секунды и само исчезает. + * + *

Погружение: игрок плавно уходит вниз, буквально опускаясь всё глубже в + * свой же блок, пока не достигнет {@link MoleHole#TRIGGER_DEPTH}. Движение не + * через setVelocity, а телепортацией на доли блока: velocity в этом мире с + * другими сущностями всё равно смешается, а телепорт даёт ровный контроль. + */ +final class MoleHoleEffect { + + private MoleHoleEffect() { + } + + /** Радиус чёрного круга. */ + private static final double RING_RADIUS = 1.5D; + /** Сколько осколков поднимается из дыры. */ + private static final int SHARDS = 14; + /** Жизнь осколка, тиков. */ + private static final long SHARD_LIFE = 20L; + /** Тиков на погружение. */ + private static final int SINK_TICKS = 10; + + private static final Material[] SHARD_BLOCKS = { + Material.BLACK_STAINED_GLASS, + Material.BLACK_CONCRETE, + Material.BLACK_CONCRETE_POWDER, + }; + + /** Осколок, летящий вверх и уменьшающийся. */ + private static final class Shard { + final BlockDisplay ent; + final double y0; + final double rise; + final int age; + + Shard(BlockDisplay ent, double y0, double rise, int age) { + this.ent = ent; + this.y0 = y0; + this.rise = rise; + this.age = age; + } + } + + /** Все живые дыры: кто открыл → где. */ + private static final Map> HOLES = new HashMap<>(); + /** Все осколки под одной задачей. */ + private static final List SHARD_LIST = new ArrayList<>(64); + + private static BukkitRunnable task; + + /** + * Ставит визуал обеих дыр. + * + *

Возвращает false, если игрок вышел — тогда координаты можно не + * принимать, а TP не списывать. + */ + static boolean place(BlackKnifePlugin plugin, Player owner, Location entrance, Location ignored, Location exit) { + if (owner == null || !owner.isOnline()) return false; + + // Списываем прошлые дыры этого игрока, если он не успел закончить пару. + removeHoles(owner); + final List both = new ArrayList<>(2); + if (entrance != null) both.add(entrance.clone()); + if (exit != null) both.add(exit.clone()); + HOLES.put(owner.getUniqueId(), both); + + for (Location at : both) { + spawnRing(plugin, at); + } + ensureTask(plugin); + return true; + } + + /** Чёрный заполненный круг и летящие вверх осколки в одной точке. */ + private static void spawnRing(BlackKnifePlugin plugin, Location at) { + final World w = at.getWorld(); + if (w == null) return; + + // Заполненный круг: плотный диск, а не кольцо, иначе середина выглядит + // дырой в буквальном смысле. + w.spawnParticle(Particle.DUST, at, 60, RING_RADIUS, 0.12D, RING_RADIUS, 0.0D, Particles.voidDust()); + + for (int i = 0; i < SHARDS; i++) { + final double a = (double) i / SHARDS * Math.PI * 2.0D; + final double r = RING_RADIUS * (0.35D + Math.random() * 0.65D); + final Location spawn = at.clone().add(Math.cos(a) * r, 0.05D, Math.sin(a) * r); + // Материал в финальную переменную: индекс цикла не effectively final, + // а лямбда его не увидит. + final Material block = SHARD_BLOCKS[i % SHARD_BLOCKS.length]; + final BlockDisplay d = w.spawn(spawn, BlockDisplay.class, ent -> { + ent.setBlock(block.createBlockData()); + ent.setBrightness(new Display.Brightness(0, 0)); + ent.setViewRange(0.75F); + }); + EntityTracker.mark(d); + SHARD_LIST.add(new Shard(d, at.getY(), 1.2D + Math.random() * 1.6D, 0)); + } + } + + // -------------------------------------------------------------- погружение ---- + + /** + * Плавно уводит игрока вниз, потом вызывает onDone. + * + *

Уводим телепортацией на доли блока, а не скоростью: скорость смешалась бы + * с другими сущностями в дыре, а телепорт даёт предсказуемое плавное + * движение и не зависит от того, что игрок делает в этот момент. + */ + static void sink(BlackKnifePlugin plugin, Player p, Location hole, Consumer onDone) { + if (p == null || !p.isOnline() || hole == null) return; + final Location from = p.getLocation().clone(); + // Копии для лямбды: step меняется внутри, значит сам по себе он не + // effectively final и в анонимном классе его не поймать. + final int[] counter = {0}; + + new BukkitRunnable() { + @Override public void run() { + if (!p.isOnline()) { cancel(); return; } + counter[0]++; + final double t = Math.min(1.0D, (double) counter[0] / SINK_TICKS); + // Плавно: доля пройденного пути растёт нелинейно, по smoothstep. + final double eased = t * t * (3.0D - 2.0D * t); + final Location next = from.clone(); + next.setY(from.getY() - MoleHole.SINK_DEPTH * eased); + p.teleport(next); + p.setFallDistance(0f); + p.setVelocity(new org.bukkit.util.Vector(0, 0, 0)); + p.addPotionEffect(new PotionEffect(PotionEffectType.SLOWNESS, 4, 4, true, false, false)); + + if (counter[0] >= SINK_TICKS) { + cancel(); + onDone.accept(p); + } + } + }.runTaskTimer(plugin, 1L, 1L); + } + + // ------------------------------------------------------------------ тик ---- + + private static void ensureTask(BlackKnifePlugin plugin) { + if (task != null) return; + task = new BukkitRunnable() { + @Override public void run() { + tick(); + } + }; + task.runTaskTimer(plugin, 1L, 1L); + } + + private static void tick() { + for (int i = SHARD_LIST.size() - 1; i >= 0; i--) { + final Shard s = SHARD_LIST.get(i); + if (!s.ent.isValid() || s.ent.isDead()) { + SHARD_LIST.remove(i); + continue; + } + final int age = s.age + 1; + + // Поднимается и одновременно уменьшается: к концу жизни исчезает. + final double t = (double) age / SHARD_LIFE; + final double y = s.y0 + s.rise * t; + final double scale = Math.max(0.02D, 0.42D * (1.0D - t)); + + final Location loc = s.ent.getLocation(); + s.ent.teleport(new Location(loc.getWorld(), loc.getX(), y, loc.getZ())); + s.ent.setTransformation(new org.bukkit.util.Transformation( + new org.joml.Vector3f((float) -scale, (float) -scale, (float) -scale), + new org.joml.AxisAngle4f(), + new org.joml.Vector3f((float) scale, (float) scale, (float) scale), + new org.joml.AxisAngle4f())); + + // Частица на самом осколке. + final World w = s.ent.getWorld(); + if (w != null && age % 2 == 0) { + w.spawnParticle(Particle.SQUID_INK, s.ent.getLocation().add(0, 0.3D, 0), 1, + 0.02D, 0.02D, 0.02D, 0); + } + + if (age >= SHARD_LIFE) { + EntityTracker.untrack(s.ent); + s.ent.remove(); + SHARD_LIST.remove(i); + } else { + SHARD_LIST.set(i, new Shard(s.ent, s.y0, s.rise, age)); + } + } + + if (SHARD_LIST.isEmpty() && HOLES.isEmpty() && task != null) { + task.cancel(); + task = null; + } + } + + // ---------------------------------------------------------------- уборка ---- + + static void removeHoles(Player owner) { + final List removed = HOLES.remove(owner.getUniqueId()); + if (removed == null) return; + for (Location at : removed) { + fillRing(at); + } + } + + static void removeHoles(MoleHole.Session s) { + if (s.entrance != null) fillRing(s.entrance); + if (s.exit != null) fillRing(s.exit); + } + + static void removeAll(Player owner) { + removeHoles(owner); + } + + static void removeEverything() { + for (List list : HOLES.values()) { + for (Location at : list) fillRing(at); + } + HOLES.clear(); + for (Shard s : SHARD_LIST) { + if (s.ent.isValid()) { + EntityTracker.untrack(s.ent); + s.ent.remove(); + } + } + SHARD_LIST.clear(); + if (task != null) { + task.cancel(); + task = null; + } + } + + /** Гасит чёрный круг: дыра схлопывается, остаётся только слой пыли. */ + private static void fillRing(Location at) { + if (at == null || at.getWorld() == null) return; + at.getWorld().spawnParticle(Particle.DUST, at, 24, RING_RADIUS, 0.1D, RING_RADIUS, 0.0D, Particles.voidDust()); + } +} \ No newline at end of file diff --git a/src/main/java/me/sashegdev/blackknife/Particles.java b/src/main/java/me/sashegdev/blackknife/Particles.java index 5b6d876..82cec2d 100644 --- a/src/main/java/me/sashegdev/blackknife/Particles.java +++ b/src/main/java/me/sashegdev/blackknife/Particles.java @@ -145,6 +145,13 @@ public final class Particles { public static final Particle.DustOptions DARKGREY_15 = dust(30, 30, 30, 1.5f); /** Тёмно-серый, размер 0.9 — сфера взрыва SWORD, 36 частиц за вызов. */ public static final Particle.DustOptions DARKGREY_09 = dust(30, 30, 30, 0.9f); + /** Почти чёрный, крупный — заполнение кротовой ямы. */ + public static final Particle.DustOptions VOID_DUST = dust(2, 2, 4, 1.6f); + + /** Заполнение дыры вообще: один объект на все вызовы. */ + public static Particle.DustOptions voidDust() { + return VOID_DUST; + } // --------------------------------------------------------------------- // Дальние частицы — прицелы.