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:
@@ -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;
|
||||
}
|
||||
Reference in New Issue
Block a user