diff --git a/src/main/java/me/sashegdev/blackknife/BlackKnifePlugin.java b/src/main/java/me/sashegdev/blackknife/BlackKnifePlugin.java index fbcfe0c..06b838a 100644 --- a/src/main/java/me/sashegdev/blackknife/BlackKnifePlugin.java +++ b/src/main/java/me/sashegdev/blackknife/BlackKnifePlugin.java @@ -47,7 +47,11 @@ public class BlackKnifePlugin extends JavaPlugin { // Разбор координат для «Кротовой ямы». LOWEST внутри обработчика, чтобы // координаты не утекли в общий чат. getServer().getPluginManager().registerEvents(new MoleHoleCoords(this), this); + getServer().getPluginManager().registerEvents(new VoidWorld(), this); BlackKnife.init(this); + // Имя измерения сервер выбирает сам, поэтому угадывать его в коде нельзя. + // Находим и печатаем: по этой строке видно, какой мир считается пустотой. + getLogger().info("Void world: " + VoidWorld.discover(this)); var bkCmd = new BlackKnifeCommand(this, chatConfig, chatHandler); getCommand("blackknife").setExecutor(bkCmd); getCommand("blackknife").setTabCompleter(bkCmd); diff --git a/src/main/java/me/sashegdev/blackknife/VoidWorld.java b/src/main/java/me/sashegdev/blackknife/VoidWorld.java new file mode 100644 index 0000000..a5521b7 --- /dev/null +++ b/src/main/java/me/sashegdev/blackknife/VoidWorld.java @@ -0,0 +1,227 @@ +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.block.Block; +import org.bukkit.block.data.type.RespawnAnchor; +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.block.BlockBreakEvent; +import org.bukkit.event.player.PlayerBedEnterEvent; +import org.bukkit.event.player.PlayerChangedWorldEvent; +import org.bukkit.event.player.PlayerInteractEvent; +import org.bukkit.event.player.PlayerQuitEvent; +import org.bukkit.scheduler.BukkitRunnable; + +import java.util.HashMap; +import java.util.Locale; +import java.util.Map; +import java.util.UUID; + +/** + * Поведение мира пустоты. + * + *

Пустота — измерение из датапака {@code delta_void:void_world}. Тип измерения + * уже выставляет запреты через флаги: {@code bed_works} и + * {@code respawn_anchor_works} равны false, то есть кровать и якорь не работают + * как положено. Здесь они доводятся до конца — не просто не срабатывают, а + * взрываются. + * + *

Режим приключений ставится на входе через {@link PlayerChangedWorldEvent}, + * а не проверкой каждый тик. Проверка «где игрок» каждый тик на каждого игрока — + * это ровно то, чего в задаче быть не должно: состояние меняется только при + * смене мира, и событие про это сообщает. + * + *

Эмбиент пустоты — базальтовые дельты: звуки и белые языки пламени с пеплом. + * Задача одна на сервер, а не на игрока: иначе N игроков дают N задач в + * планировщике. + */ +public final class VoidWorld implements Listener { + + public VoidWorld() { + } + + /** Помечается один раз при старте, чтобы видеть, какое имя сервер дал измерению. */ + private static String detectedWorldName = "не найдено"; + + /** Режим до входа в пустоту, чтобы вернуть как было, а не угадать. */ + private static final Map PREVIOUS_MODE = new HashMap<>(); + + private static final long AMBIENCE_PERIOD = 60L; + + /** Это мир пустоты? Смотрим на имя измерения, а не на флаги уровня. */ + public static boolean isVoid(World w) { + return w != null && w.getName().toLowerCase(Locale.ROOT).contains("void"); + } + + public static boolean inVoid(Player p) { + return p != null && isVoid(p.getWorld()); + } + + /** + * Находит измерение и включает эмбиент. Вызывается один раз при старте. + * + *

Возвращает имя найденного мира, чтобы его можно было прочитать в логе: + * имя измерения из датапака сервер выбирает сам, и угадывать его в коде + * опаснее, чем посмотреть. + */ + public static String discover(BlackKnifePlugin plugin) { + for (World w : plugin.getServer().getWorlds()) { + if (isVoid(w)) { + detectedWorldName = w.getName(); + startAmbience(plugin); + return detectedWorldName; + } + } + return detectedWorldName; + } + + // -------------------------------------------------------------- переходы ---- + + @EventHandler + public void onChangedWorld(PlayerChangedWorldEvent e) { + final Player p = e.getPlayer(); + + // У PlayerChangedWorldEvent в этом API есть только getFrom(), getTo() нет. + // Но к моменту события игрок УЖЕ в новом мире, так что цель и есть + // p.getWorld() — читать её оттуда надёжнее, чем по отсутствующему геттеру. + if (isVoid(p.getWorld())) { + PREVIOUS_MODE.put(p.getUniqueId(), p.getGameMode()); + if (p.getGameMode() != GameMode.ADVENTURE) { + p.setGameMode(GameMode.ADVENTURE); + } + p.sendActionBar(Component.text("The void does not forgive mistakes") + .color(NamedTextColor.DARK_GRAY)); + return; + } + + // Выход из пустоты: возвращаем тот режим, который был до входа. + final GameMode prev = PREVIOUS_MODE.remove(p.getUniqueId()); + if (prev != null && prev != p.getGameMode()) { + p.setGameMode(prev); + } + } + + @EventHandler + public void onQuit(PlayerQuitEvent e) { + PREVIOUS_MODE.remove(e.getPlayer().getUniqueId()); + } + + // ------------------------------------------------------------- запреты ---- + + /** + * Кровать в пустоте взрывается. + * + *

Событие отменяем, иначе игрок спокойно уснёт и проспит до утра в мире, + * где нет ни смены дня, ни голода, ни чего-либо. Тип измерения уже запрещает + * сон через {@code bed_works}, но игрок всё равно получает сообщение и + * ожидает пробуждения — доводим до взрыва. + */ + @EventHandler(priority = EventPriority.HIGHEST, ignoreCancelled = true) + public void onBed(PlayerBedEnterEvent e) { + if (!inVoid(e.getPlayer())) return; + e.setCancelled(true); + // getBed() возвращает Block, а не Bed: приводим и проверяем, потому что + // при неудачном входе блок может оказаться чем угодно. + final Block bed = e.getBed(); + final Location at = bed != null ? bed.getLocation() : e.getPlayer().getLocation(); + explode(at, "A bed is not welcome here"); + } + + /** + * Якорь возвражения — то же, что кровать, но ловится иначе. + * + *

Отдельного события на якорь в этом API нет: класс + * {@code PlayerRespawnAnchorEvent} отсутствует, есть только блочные данные + * {@link RespawnAnchor}. Поэтому ловим обычное взаимодействие с блоком и + * сверяем его тип данных. + */ + @EventHandler(priority = EventPriority.HIGHEST, ignoreCancelled = true) + public void onAnchor(PlayerInteractEvent e) { + if (e.getAction() != Action.RIGHT_CLICK_BLOCK && e.getAction() != Action.PHYSICAL) return; + if (!inVoid(e.getPlayer())) return; + final Block clicked = e.getClickedBlock(); + if (clicked == null || !(clicked.getBlockData() instanceof RespawnAnchor)) return; + + e.setCancelled(true); + explode(clicked.getLocation(), "An anchor is not welcome here"); + } + + /** Ломать нельзя: пустота не держит вещей. Режим приключений закрывает не всё. */ + @EventHandler(priority = EventPriority.HIGHEST, ignoreCancelled = true) + public void onBreak(BlockBreakEvent e) { + if (inVoid(e.getPlayer())) { + e.setCancelled(true); + } + } + + private static void explode(Location at, String message) { + final World w = at.getWorld(); + if (w == null) return; + w.createExplosion(at, 3.0F, false, true); + w.spawnParticle(Particle.EXPLOSION, at, 3, 0.4D, 0.4D, 0.4D, 0.0D); + for (Player p : w.getPlayers()) { + if (p.getLocation().distanceSquared(at) < 36.0D) { + p.sendActionBar(Component.text(message).color(NamedTextColor.DARK_RED)); + } + } + } + + // ------------------------------------------------------------ эмбиент ---- + + /** + * Эмбиент базальтовых дельт: одна задача на сервер. + * + *

Имена звуков проверены по enum: {@code AMBIENT_BASALT_DELTAS_ADDITIONS} + * и {@code AMBIENT_BASALT_DELTAS_LOOP} — со строчной «s» в DELTAS, варианта + * без неё в enum нет. + */ + private static void startAmbience(BlackKnifePlugin plugin) { + new BukkitRunnable() { + int flip = 0; + + @Override public void run() { + for (Player p : plugin.getServer().getOnlinePlayers()) { + if (!isVoid(p.getWorld())) continue; + final Location eye = p.getEyeLocation(); + + // Звук не каждый тик, иначе это не эмбиент, а шум. + flip++; + if (flip % 6 == 0) { + p.playSound(eye, Sound.AMBIENT_BASALT_DELTAS_LOOP, 0.5F, 1.0F); + } else if (flip % 3 == 0) { + p.playSound(eye, Sound.AMBIENT_BASALT_DELTAS_ADDITIONS, 0.6F, 1.0F); + } + + // Пепельный дым и белые языки пламени, как в базальтовых дельтах. + if (flip % 2 == 0) { + p.spawnParticle(Particle.SMOKE, eye, 2, 1.2D, 1.0D, 1.2D, 0.0D); + } else { + p.spawnParticle(Particle.SMALL_FLAME, eye, 1, 0.6D, 0.8D, 0.6D, 0.0D); + } + if (flip % 12 == 0) { + p.spawnParticle(Particle.SOUL, eye.clone().add(0, -0.5D, 0), 1, + 0.3D, 0.5D, 0.3D, 0.0D); + } + } + } + }.runTaskTimer(plugin, 40L, AMBIENCE_PERIOD); + } + + /** Имя измерения, найденное при старте. Только для чтения в лог. */ + public static String detectedWorld() { + return detectedWorldName; + } + + /** Не используется, но оставлено явным: пол пустоты держим барьером. */ + static final Material VOID_FLOOR = Material.BARRIER; +} \ No newline at end of file