feat(voidworld): void world rules, adventure mode and basalt ambience

Восьмой этап: поведение мира пустоты. Заход в пустоту пока не сделан, но все
правила поведения готовы и включаются сразу, как только игрок туда попадёт.

Что сделано:
- режим приключений на входе и возврат прежнего на выходе. Слушается
  PlayerChangedWorldEvent, а не проверка каждый тик: состояние меняется только
  при смене мира, и событие об этом сообщает.
- кровать в пустоте взрывается. Тип измерения уже запрещает сон через
  bed_works, но игрок всё равно получает сообщение об успешном сне и ждёт
  пробуждения — доводим до взрыва.
- якорь возвражения взрывается.
- ломать блоки нельзя.
- эмбиент базальтовых дельт: AMBIENT_BASALT_DELTAS_LOOP и ADDITIONS, плюс
  пепельный дым и белые языки пламени. Одна задача на сервер, не на игрока.

Три ошибки поймал компилятор, а не я, и все три были про выдуманные имена:

1. PlayerRespawnAnchorEvent в этом API НЕ существует. Есть только блочные
   данные org.bukkit.block.data.type.RespawnAnchor. Якорь ловится обычным
   PlayerInteractEvent со сверкой типа данных блока.

2. У PlayerChangedWorldEvent в Paper есть только getFrom(), getTo() нет.
   Но к моменту события игрок уже в новом мире, поэтому цель берётся из
   p.getWorld() — это надёжнее, чем отсутствующий геттер.

3. PlayerBedEnterEvent#getBed() возвращает Block, а не Bed. Приводим с
   проверкой: при неудачном входе в кровать блок может оказаться чем угодно.

Имена enum проверял по jar, а не по памяти: звук называется
AMBIENT_BASALT_DELTAS_ADDITIONS, со строчной s в DELTAS. Варианта без неё
в enum нет.

Плюс в лог при старте печатается имя найденного измерения. Оно оказалось
world_delta_void_void_world, а не delta_void:void_world, как я бы угадал:
сервер добавляет префикс папки мира и санитизирует путь. Угадывать имя
измерения в коде было бы верным способом не заработать.
This commit is contained in:
SashegDev
2026-10-17 15:47:30 +00:00
parent 54dcd2078a
commit ecb741140b
2 changed files with 231 additions and 0 deletions
@@ -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);
@@ -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;
/**
* Поведение мира пустоты.
*
* <p>Пустота — измерение из датапака {@code delta_void:void_world}. Тип измерения
* уже выставляет запреты через флаги: {@code bed_works} и
* {@code respawn_anchor_works} равны false, то есть кровать и якорь не работают
* как положено. Здесь они доводятся до конца — не просто не срабатывают, а
* взрываются.
*
* <p>Режим приключений ставится на входе через {@link PlayerChangedWorldEvent},
* а не проверкой каждый тик. Проверка «где игрок» каждый тик на каждого игрока —
* это ровно то, чего в задаче быть не должно: состояние меняется только при
* смене мира, и событие про это сообщает.
*
* <p>Эмбиент пустоты — базальтовые дельты: звуки и белые языки пламени с пеплом.
* Задача одна на сервер, а не на игрока: иначе N игроков дают N задач в
* планировщике.
*/
public final class VoidWorld implements Listener {
public VoidWorld() {
}
/** Помечается один раз при старте, чтобы видеть, какое имя сервер дал измерению. */
private static String detectedWorldName = "не найдено";
/** Режим до входа в пустоту, чтобы вернуть как было, а не угадать. */
private static final Map<UUID, GameMode> 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());
}
/**
* Находит измерение и включает эмбиент. Вызывается один раз при старте.
*
* <p>Возвращает имя найденного мира, чтобы его можно было прочитать в логе:
* имя измерения из датапака сервер выбирает сам, и угадывать его в коде
* опаснее, чем посмотреть.
*/
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());
}
// ------------------------------------------------------------- запреты ----
/**
* Кровать в пустоте взрывается.
*
* <p>Событие отменяем, иначе игрок спокойно уснёт и проспит до утра в мире,
* где нет ни смены дня, ни голода, ни чего-либо. Тип измерения уже запрещает
* сон через {@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");
}
/**
* Якорь возвражения — то же, что кровать, но ловится иначе.
*
* <p>Отдельного события на якорь в этом 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));
}
}
}
// ------------------------------------------------------------ эмбиент ----
/**
* Эмбиент базальтовых дельт: одна задача на сервер.
*
* <p>Имена звуков проверены по 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;
}