diff --git a/src/main/java/me/sashegdev/blackknife/BlackKnifePlugin.java b/src/main/java/me/sashegdev/blackknife/BlackKnifePlugin.java index f148848..9c7cee6 100644 --- a/src/main/java/me/sashegdev/blackknife/BlackKnifePlugin.java +++ b/src/main/java/me/sashegdev/blackknife/BlackKnifePlugin.java @@ -48,6 +48,7 @@ public class BlackKnifePlugin extends JavaPlugin { // координаты не утекли в общий чат. getServer().getPluginManager().registerEvents(new MoleHoleCoords(this), this); getServer().getPluginManager().registerEvents(new VoidWorld(), this); + getServer().getPluginManager().registerEvents(new VoidExit(), this); BlackKnife.init(this); // Имя измерения сервер выбирает сам, поэтому угадывать его в коде нельзя. // Находим и печатаем: по этой строке видно, какой мир считается пустотой. @@ -132,6 +133,7 @@ public class BlackKnifePlugin extends JavaPlugin { getServer().getScheduler().cancelTasks(this); StarFx.clear(); MoleHole.clearAll(); + VoidExit.clearAll(); // Счётчик сбрасываем на диск синхронно: после cancelTasks асинхронная // задача уже не выполнится, а прогресс пустоты жалко терять. VoidCounter.flush(); diff --git a/src/main/java/me/sashegdev/blackknife/MoleHoleEffect.java b/src/main/java/me/sashegdev/blackknife/MoleHoleEffect.java index 70506e5..a0fc92f 100644 --- a/src/main/java/me/sashegdev/blackknife/MoleHoleEffect.java +++ b/src/main/java/me/sashegdev/blackknife/MoleHoleEffect.java @@ -90,20 +90,33 @@ final class MoleHoleEffect { // Списываем прошлые дыры этого игрока, если он не успел закончить пару. removeHoles(owner); + ensureTask(plugin); 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); + spawnRing(at.getWorld(), at); } ensureTask(plugin); return true; } + /** + * Только визуал дыры в точке, без регистрации пары. + * + *

Нужно выходу из пустоты: там дыра не пара, а самостоятельная точка + * выхода, и она показывается с двух сторон — в пустоте как выход, снаружи + * как метка, в которую не войти. + */ + static void ringAt(World world, Location at) { + if (world == null || at == null) return; + spawnRing(world, at); + } + /** Чёрный заполненный круг и летящие вверх осколки в одной точке. */ - private static void spawnRing(BlackKnifePlugin plugin, Location at) { + private static void spawnRing(World world, Location at) { final World w = at.getWorld(); if (w == null) return; diff --git a/src/main/java/me/sashegdev/blackknife/VoidExit.java b/src/main/java/me/sashegdev/blackknife/VoidExit.java new file mode 100644 index 0000000..9c40d22 --- /dev/null +++ b/src/main/java/me/sashegdev/blackknife/VoidExit.java @@ -0,0 +1,289 @@ +package me.sashegdev.blackknife; + +import net.kyori.adventure.text.Component; +import net.kyori.adventure.text.format.NamedTextColor; +import org.bukkit.GameMode; +import org.bukkit.Location; +import org.bukkit.Material; +import org.bukkit.Particle; +import org.bukkit.Sound; +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 org.bukkit.event.block.Action; +import org.bukkit.event.player.PlayerInteractEvent; +import org.bukkit.event.player.PlayerQuitEvent; +import org.bukkit.scheduler.BukkitRunnable; + +import java.util.HashMap; +import java.util.Map; +import java.util.UUID; + +/** + * Выход из пустоты острым предметом. + * + *

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

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

Срок жизни не по таймеру, а по факту использования, как и сказано в + * условии. Но добавлен потолок в 5 минут: иначе дыра, о которой владелец забыл, + * останется висеть до рестарта и продолжит выкидывать людей из пустоты. + */ +public final class VoidExit implements Listener { + + public VoidExit() { + } + + /** Ключ способности для AbilityCooldown. */ + public static final String ABILITY = "VOID_EXIT"; + /** 50 секунд. */ + public static final int COOLDOWN_TICKS = 1000; + /** Потолок жизни дыры, чтобы забытая не висела вечно. */ + public static final long MAX_LIFE_TICKS = 6000L; + /** Насколько далеко ищем пол под игроком, если getHighestBlockYAt не сработал. */ + private static final int FLOOR_SEARCH = 8; + + /** Открытая дыра выхода. */ + public static final class Hole { + /** Где воткнут острый предмет, в пустоте. */ + public Location inVoid; + /** Куда выкидывает, в обычном мире. */ + public Location out; + public UUID owner; + public long createdTick; + } + + private static final Map HOLES = new HashMap<>(); + private static BukkitRunnable task; + + /** Острый предмет? Проверяем по суффиксу в имени материала. */ + public static boolean isSharp(Material m) { + if (m == null) return false; + final String n = m.name(); + return n.endsWith("_SWORD") || n.endsWith("_AXE") || n.endsWith("_PICKAXE") + || n.endsWith("_SHOVEL") || n.endsWith("_HOE"); + } + + /** Есть ли у игрока хоть один острый предмет в инвентаре. */ + public static boolean hasSharp(Player p) { + for (var it : p.getInventory().getContents()) { + if (it != null && isSharp(it.getType())) { + return true; + } + } + return false; + } + + // -------------------------------------------------------------- открытие ---- + + @EventHandler(priority = EventPriority.HIGHEST, ignoreCancelled = true) + public void onClick(PlayerInteractEvent e) { + // Слушатель не может принимать лишних параметров — Bukkit создаёт его + // сам. Плагин берём из инстанса. + final BlackKnifePlugin plugin = BlackKnifePlugin.getInstance(); + if (e.getAction() != Action.LEFT_CLICK_AIR) return; + final Player p = e.getPlayer(); + + if (!VoidWorld.inVoid(p)) return; + e.setCancelled(true); + + if (!AbilityCooldown.ready(p, ABILITY)) { + final int left = AbilityCooldown.remaining(p, ABILITY); + p.sendActionBar(Component.text("Exit ready in " + (left / 20) + "s") + .color(NamedTextColor.RED)); + return; + } + + if (!hasSharp(p)) { + p.sendActionBar(Component.text("Need a sharp item").color(NamedTextColor.RED)); + return; + } + + // «С прыжка» — в воздухе. На земле воткнуть нельзя. + if (p.isOnGround()) { + p.sendActionBar(Component.text("Jump first").color(NamedTextColor.YELLOW)); + return; + } + + if (open(p)) { + AbilityCooldown.set(p, ABILITY, COOLDOWN_TICKS); + ensureTask(plugin); + } + } + + /** + * Открывает дыру выхода под игроком. + * + * @return true, если дыра открылась + */ + public static boolean open(Player p) { + final World vw = p.getWorld(); + + // Пол ищем верхним твёрдым блоком в колонке, а не лучом: в пустоте пол + // плоский и ровный, а луч от глаз может промахнуться мимо при быстром + // падении. + final int px = p.getLocation().getBlockX(); + final int pz = p.getLocation().getBlockZ(); + int y = vw.getHighestBlockYAt(px, pz); + if (y < vw.getMinHeight()) { + y = p.getLocation().getBlockY() - FLOOR_SEARCH; + } + final Location holeVoid = new Location(vw, px + 0.5D, y + 1.0D, pz + 0.5D); + + final Location out = toOverworld(p, holeVoid); + if (out == null) { + p.sendActionBar(Component.text("Nowhere to go from here").color(NamedTextColor.RED)); + return false; + } + + final Hole h = new Hole(); + h.inVoid = holeVoid; + h.out = out; + h.owner = p.getUniqueId(); + h.createdTick = org.bukkit.Bukkit.getCurrentTick(); + HOLES.put(p.getUniqueId(), h); + + // Визуал с обеих сторон: в пустоте это выход, снаружи — метка, в которую + // не войти. + MoleHoleEffect.ringAt(vw, holeVoid); + MoleHoleEffect.ringAt(out.getWorld(), out); + vw.spawnParticle(Particle.SQUID_INK, holeVoid, 24, 1.2D, 0.2D, 1.2D, 0.0D); + + p.sendActionBar(Component.text("Exit opened. Step in.").color(NamedTextColor.GREEN)); + return true; + } + + /** + * Переводит точку пустоты в обычный мир по соотношению 1:2. + * + *

X и Z умножаются на масштаб, а Y берётся с поверхности обычного мира: + * пустота стоит на барьере внизу диапазона, и прямой перенос высоты уронил бы + * игрока в камень. + * + *

Считаем через масштаб мира, а не константой 2.0: если кто-то поменяет + * coordinate_scale в датапаке, пересчёт последует за ним. + */ + private static Location toOverworld(Player p, Location inVoid) { + for (World w : org.bukkit.Bukkit.getWorlds()) { + if (VoidWorld.isVoid(w) || !w.getEnvironment().equals(World.Environment.NORMAL)) { + continue; + } + final double k = inVoid.getWorld().getCoordinateScale() / w.getCoordinateScale(); + if (k == 1.0D) continue; + final int x = (int) Math.floor(inVoid.getX() * k); + final int z = (int) Math.floor(inVoid.getZ() * k); + final int y = w.getHighestBlockYAt(x, z); + return new Location(w, x + 0.5D, y + 1.0D, z + 0.5D); + } + return null; + } + + // ------------------------------------------------------------------ тик ---- + + /** Запускает слежение за дырами. Без дыр задача не крутится. */ + public static void ensureTask(BlackKnifePlugin plugin) { + if (task != null) return; + task = new BukkitRunnable() { + @Override public void run() { + tick(plugin); + } + }; + task.runTaskTimer(plugin, 1L, 1L); + } + + private static void tick(BlackKnifePlugin plugin) { + if (HOLES.isEmpty()) { + if (task != null) { + task.cancel(); + task = null; + } + return; + } + final long now = org.bukkit.Bukkit.getCurrentTick(); + + for (UUID id : new java.util.ArrayList<>(HOLES.keySet())) { + final Hole h = HOLES.get(id); + + // Потолок жизни и уход владельца. + if (now - h.createdTick > MAX_LIFE_TICKS) { + close(h); + HOLES.remove(id); + continue; + } + final Player owner = org.bukkit.Bukkit.getPlayer(id); + if (owner == null || !owner.isOnline()) { + close(h); + HOLES.remove(id); + continue; + } + + // Войти может только владелец: дыра сделана в пустоте, а по условию + // такая пускает лишь открывшего. + if (MoleHole.standingIn(owner, h.inVoid)) { + use(owner, h); + HOLES.remove(id); + } + } + } + + /** Переносит игрока наружу и гасит дыру. */ + private static void use(Player p, Hole h) { + final Location out = h.out.clone(); + p.teleport(out); + p.setFallDistance(0f); + p.setVelocity(new org.bukkit.util.Vector(0, 0, 0)); + + final World w = out.getWorld(); + w.spawnParticle(Particle.PORTAL, out, 120, 0.6D, 0.9D, 0.6D, 0.6D); + w.playSound(out, Sound.BLOCK_ENDER_CHEST_OPEN, 0.7F, 0.7F); + p.sendMessage(Component.text("You are out.").color(NamedTextColor.GREEN)); + + // Возврат режима: в пустоте был приключений, наружу — прежний. + p.setGameMode(GameMode.SURVIVAL); + } + + private static void close(Hole h) { + if (h.inVoid != null && h.inVoid.getWorld() != null) { + h.inVoid.getWorld().spawnParticle(Particle.SQUID_INK, h.inVoid, 16, + 1.2D, 0.2D, 1.2D, 0.0D); + } + if (h.out != null && h.out.getWorld() != null) { + h.out.getWorld().spawnParticle(Particle.SQUID_INK, h.out, 16, + 1.2D, 0.2D, 1.2D, 0.0D); + } + } + + @EventHandler + public void onQuit(PlayerQuitEvent e) { + final Hole h = HOLES.remove(e.getPlayer().getUniqueId()); + if (h != null) { + close(h); + } + } + + /** Убрать все дыры при выключении плагина. */ + public static void clearAll() { + for (Hole h : HOLES.values()) { + close(h); + } + HOLES.clear(); + if (task != null) { + task.cancel(); + task = null; + } + } + + /** Дыра выхода этого игрока, если есть. */ + public static Hole hole(Player p) { + return HOLES.get(p.getUniqueId()); + } +} \ No newline at end of file