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 Возвращает имя найденного мира, чтобы его можно было прочитать в логе:
+ * имя измерения из датапака сервер выбирает сам, и угадывать его в коде
+ * опаснее, чем посмотреть.
+ */
+ 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