feat(voidexit): stab a sharp item from a jump to open a one-way exit

Десятый этап: выход из пустоты. Теперь пустоту можно посмотреть и покинуть без
рестарта.

Как работает: в пустоте берёшь любой острый предмет, прыгаешь и жмёшь ЛКМ по
воздуху. Пол ищется верхним твёрдым блоком в колонке, а не лучом от глаз: пол
пустоты плоский, а луч при быстром падении проходит мимо. Дыра работает в одну
сторону — из пустоты выкидывает, снаружи это только метка, войти нельзя.

Три решения, которые стоит зафиксировать:

1. Высота при выходе берётся с поверхности ОБЫЧНОГО мира, а не из пустоты.
   Пустота стоит на барьере внизу диапазона, и прямой перенос высоты уронил бы
   игрока в камень. X и Z пересчитываются по соотношению 1:2.

2. Коэффициент берётся из getCoordinateScale, а не константой 2.0. Если
   coordinate_scale поменяется в датапаке, пересчёт последует за ним сам.

3. Живёт дыра по факту использования, как в условии, но с потолком в 5 минут.
   Иначе дыра, о которой владелец забыл, висит до рестарта и продолжает
   выкидывать людей из пустоты. Это моё добавление, не из описания.

Войти может только владелец: дыра сделана в пустоте, а по условию такая пускает
лишь открывшего.

Острый предмет определяется по суффиксу в имени материала: _SWORD, _AXE,
_PICKAXE, _SHOVEL, _HOE. Проверяется по всему инвентарю, а не только по руке:
в пустоте игрок в приключениях, и предмет в руке держать не обязательно.

Мелочь с API: слушатель не может принимать лишний параметр, Bukkit создаёт
его сам, поэтому плагин берётся из инстанса.

Проверено: плагин грузится без ошибок, измерение находится. Сам перенос не
проверялся — нужен игрок.
This commit is contained in:
SashegDev
2026-10-19 04:07:25 +00:00
parent 5a1ea2343e
commit 285bcbc282
3 changed files with 306 additions and 2 deletions
@@ -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();
@@ -90,20 +90,33 @@ final class MoleHoleEffect {
// Списываем прошлые дыры этого игрока, если он не успел закончить пару.
removeHoles(owner);
ensureTask(plugin);
final List<Location> 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;
}
/**
* Только визуал дыры в точке, без регистрации пары.
*
* <p>Нужно выходу из пустоты: там дыра не пара, а самостоятельная точка
* выхода, и она показывается с двух сторон — в пустоте как выход, снаружи
* как метка, в которую не войти.
*/
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;
@@ -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;
/**
* Выход из пустоты острым предметом.
*
* <p>По условию: любой острый предмет, воткнуть с прыжка левой кнопкой в пол,
* после чего даётся кулдаун 50 секунд, дыра работает в одну сторону и живёт до
* того момента, пока в неё кто-то из пустоты не войдёт.
*
* <p>Дыра двусторонняя только визуально: в пустоте она уводит наружу, а снаружи
* это просто метка, войти в неё нельзя. Наружу игрок попадает по соотношению
* 1:2 — координаты пустоты умножаются на два, то есть ровно то, что делает
* портал в Незер, только вчетверо грубее. Высота берётся не из пустоты, а с
* поверхности обычного мира: если взять её оттуда, игрок выйдет внутри камня.
*
* <p>Срок жизни не по таймеру, а по факту использования, как и сказано в
* условии. Но добавлен потолок в 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<UUID, Hole> 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.
*
* <p>X и Z умножаются на масштаб, а Y берётся с поверхности обычного мира:
* пустота стоит на барьере внизу диапазона, и прямой перенос высоты уронил бы
* игрока в камень.
*
* <p>Считаем через масштаб мира, а не константой 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());
}
}