feat(molehole): teleporting hole pairs, state machine and visuals

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

Поток ровно как задумано:
  ПКМ            -> входная дыра, состояние WAITING_COORDS, таймера нет
  ввод "x, y, z" -> ответная дыра, PAIRED, -100% TP, кулдаун 30с, таймер 5с
  истёк 5с      -> обе дыры исчезают

Ключевые решения, которые стоит знать при доработке:

1. Повторный ввод координат в состоянии PAIRED ЗАБЛОКИРОВЕН. Иначе можно было
   бы переставлять ответную дыру и продлевать себе окно, то есть 5 секунд
   перестали бы быть 5 секундами. В WAITING_COORDS повторять можно сколько
   угодно: ошибся — передвинул.

2. TP и кулдаун списываются в момент создания ПАРЫ, а не прыжка. Значит пару
   можно создать и уйти не воспользовавшись — потрачено.

3. Порядок действий фиксированный: ПКМ, потом координаты. Раньше в описании
   было «порядок не важен», но раз координаты вводятся только после ПКМ,
   обратный порядок физически невозможен.

4. Парсер координат: три числа это x,y,z, два числа это x,z и Y берётся
   верхним блоком колонки. Формата «x, y» без Z не существует: двусмысленно,
   и читать его как две координаты безопаснее. Разбор ведётся только в
   WAITING_COORDS; всё остальное уходит в чат нетронутым — перехватчик не
   имеет права глотать переписку.

5. Проверка места дыры: блок точки обязан быть воздухом, над ним свободно,
   под ним опора. Иначе в дыру невозможно встать, а висящая в воздухе дыра
   бессмысленна.

6. Визуал идёт под одной задачей, как StarFx. Дыр может быть много одновременно
   у разных игроков, и задача на каждую сущность дала бы десятки записей в
   планировщике. Осколки: чёрное стекло, чёрный бетон и чёрный бетонный порошок,
   поднимаются и одновременно уменьшаются.

7. Погружение телепортацией на доли блока, а не скоростью. Скорость смешалась бы
   с сущностями в дыре, телепорт даёт ровное smoothstep-движение. По достижении
   -0.5 телепорт в ответную дыру с тем же смещением по Y.

Задел на будущее: Session уже несёт флаги fromVoid и owner под правило
«дыра из пустоты — только для открывшего», и поля entranceTo/exitTo под
механику острого предмета. Логика их пока не читает.
This commit is contained in:
SashegDev
2026-10-15 21:06:48 +00:00
parent f6d850b5a7
commit 54dcd2078a
6 changed files with 738 additions and 1 deletions
@@ -26,7 +26,7 @@ public class BlackKnifeListener implements Listener {
private final java.util.Map<java.util.UUID, BukkitRunnable> previewTasks = new java.util.HashMap<>();
private final java.util.Map<java.util.UUID, BukkitRunnable> lungeHighlightTasks = new java.util.HashMap<>();
private final java.util.Map<java.util.UUID, Integer> swordCombo = new java.util.HashMap<>();
public static final String[] MODES = {"STARS", "SWORD", "DIAMOND", "DIAMOND_TRI", "BARRAGE", "SNIPE", "TRI", "OVERHEAD", "GRENADE", "DIVE"};
public static final String[] MODES = {"STARS", "SWORD", "DIAMOND", "DIAMOND_TRI", "BARRAGE", "SNIPE", "TRI", "OVERHEAD", "GRENADE", "DIVE", "MOLEHOLE"};
public BlackKnifeListener(BlackKnifePlugin plugin) { this.plugin = plugin; }
@@ -66,6 +66,7 @@ public class BlackKnifeListener implements Listener {
swordCombo.remove(p.getUniqueId());
clearPreview(p);
clearHighlight(p);
MoleHole.clear(p);
}
/**
@@ -286,6 +287,16 @@ public class BlackKnifeListener implements Listener {
p.setCooldown(item.getType(), SkyDiveAttack.ITEM_COOLDOWN_TICKS);
clearHighlight(p);
}
} else if (m.equals("MOLEHOLE")) {
// ПКМ открывает ВХОДНУЮ дыру и переводит игрока в «жду
// координаты». Кулдаун и TP тут не трогаются: по условию они
// списываются в момент создания ПАРЫ, то есть после ввода
// координат. Поэтому ПКМ можно нажимать многократно.
if (p.hasCooldown(item.getType())) return;
if (MoleHole.openEntrance(plugin, p, tm)) {
p.setCooldown(item.getType(), 5);
clearHighlight(p);
}
}
e.setCancelled(true);
}
@@ -44,6 +44,9 @@ public class BlackKnifePlugin extends JavaPlugin {
getServer().getPluginManager().registerEvents(chatHandler, this);
getServer().getPluginManager().registerEvents(awakeningManager, this);
getServer().getPluginManager().registerEvents(new KillMessageHandler(this), this);
// Разбор координат для «Кротовой ямы». LOWEST внутри обработчика, чтобы
// координаты не утекли в общий чат.
getServer().getPluginManager().registerEvents(new MoleHoleCoords(this), this);
BlackKnife.init(this);
var bkCmd = new BlackKnifeCommand(this, chatConfig, chatHandler);
getCommand("blackknife").setExecutor(bkCmd);
@@ -120,6 +123,7 @@ public class BlackKnifePlugin extends JavaPlugin {
public void onDisable() {
getServer().getScheduler().cancelTasks(this);
StarFx.clear();
MoleHole.clearAll();
if (tension != null) {
tension.hideAll();
for (var p : getServer().getOnlinePlayers()) {
@@ -0,0 +1,336 @@
package me.sashegdev.blackknife;
import net.kyori.adventure.text.Component;
import net.kyori.adventure.text.format.NamedTextColor;
import org.bukkit.Bukkit;
import org.bukkit.Location;
import org.bukkit.Material;
import org.bukkit.World;
import org.bukkit.block.Block;
import org.bukkit.entity.Player;
import org.bukkit.scheduler.BukkitRunnable;
import java.util.HashMap;
import java.util.Map;
import java.util.UUID;
/**
* Способность «Кротовая яма»: телепорт через пары отверстий.
*
* <p>Поток ровно такой, как задумано:
* <ol>
* <li>ПКМ открывает ВХОДНУЮ дыру и переводит игрока в {@link State#WAITING_COORDS}.
* Таймера на этом шаге нет вообще.</li>
* <li>Игрок пишет координаты в чат. Формат {@code x, y, z} либо {@code x, z}.
* Ошибся — просто вводит ещё раз, ответная дыра переезжает, ничего не теряется.</li>
* <li>Когда ответная дыра открылась, списывается 100% TP, стартует кулдаун
* 30 секунд и пятисекундный таймер жизни пары. Состояние {@link State#PAIRED},
* и ввод координат в нём ЗАБЛОКИРОВАН.</li>
* </ol>
*
* <p>Почему блокировка ввода в {@code PAIRED}: иначе игрок переставил бы
* ответную дыру и продлил себе окно. По условию таймер начинается только один раз,
* в момент сборки пары, и живёт ровно 5 секунд.
*
* <p>Погружение: вставший в дыру плавно уходит вниз на 1 блок (как в паутине),
* а на −0.5 блока срабатывает телепорт. На выходе игрок появляется с тем же
* смещением по Y, то есть тоже на −0.5, чтобы «утонувший» приземлялся в дыру,
* а не падал с высоты.
*/
public final class MoleHole {
private MoleHole() {
}
/** Стоимость: полная полоса TP. */
public static final float TP_COST = 100.0f;
/** Кулдаун способности, тиков. 30 секунд при 20 тиках в секунду. */
public static final long COOLDOWN_TICKS = 600L;
/** Сколько живёт собранная пара. */
public static final long PAIR_TICKS = 100L;
/** На сколько блоков вниз уходит вставший в дыру. */
public static final double SINK_DEPTH = 1.0D;
/** На этой глубине срабатывает телепорт. */
public static final double TRIGGER_DEPTH = 0.5D;
/** Ключ способности в AbilityCooldown. */
public static final String ABILITY = "MOLEHOLE";
/** Радиус дыры для попадания, блоков. Чуть шире визуала. */
public static final double HOLE_RADIUS = 1.5D;
/** Состояние игрока по отношению к дырам. */
public enum State { IDLE, WAITING_COORDS, PAIRED }
/**
* Всё состояние способности на игрока.
*
* <p>Держим одну запись на игрока, а не разбросанные карты по трём классам:
* связать «дыра открыта» и «можно вводить координаты» иначе невозможно.
*/
public static final class Session {
public State state = State.IDLE;
/** Входная дыра, там где стоит игрок. */
public Location entrance;
/** Ответная дыра, там куда указали координаты. */
public Location exit;
/** Тик, на который пара рассыплется. 0 = не задана. */
public long expireTick;
/** Куда отправлять вставших в дыру. */
public Location entranceTo;
public Location exitTo;
/** Дыра сделана из пустоты: вход только для открывшего. */
public boolean fromVoid;
public UUID owner;
/** Кто уже воспользовался, чтобы не телепортировать одного и того же дважды за тик. */
public final Map<UUID, Boolean> inside = new HashMap<>();
}
private static final Map<UUID, Session> SESSIONS = new HashMap<>();
private static BukkitRunnable task;
// ------------------------------------------------------------- состояние ----
public static State state(Player p) {
final Session s = SESSIONS.get(p.getUniqueId());
return s == null ? State.IDLE : s.state;
}
/** Ждёт ли игрок координаты. Только в этом состоянии чат разбирается. */
public static boolean awaitingCoords(Player p) {
return state(p) == State.WAITING_COORDS;
}
private static Session session(Player p) {
return SESSIONS.computeIfAbsent(p.getUniqueId(), k -> new Session());
}
// ----------------------------------------------------------------- запуск ----
/**
* ПКМ: открыть входную дыру и начать ждать координаты.
*
* @return true, если дыра открылась
*/
public static boolean openEntrance(BlackKnifePlugin plugin, Player p, TensionManager tm) {
final Session s = session(p);
// Дыра должна стоять на опоре: иначе она висит в воздухе и в неё
// невозможно войти, потому что «встать в яму» не выходит.
final Location ground = groundUnder(p.getLocation());
if (ground == null) {
p.sendActionBar(Component.text("Need solid ground under you").color(NamedTextColor.RED));
return false;
}
if (!isFree(ground)) {
p.sendActionBar(Component.text("Blocked here").color(NamedTextColor.RED));
return false;
}
if (s.state == State.PAIRED) {
// Повторный ПКМ в окне пары: игнорируем, иначе можно было бы
// продлевать себе окно многократными нажатиями.
p.sendActionBar(Component.text("Hole already open").color(NamedTextColor.YELLOW));
return false;
}
s.entrance = ground;
s.entranceTo = null;
s.exit = null;
s.exitTo = null;
s.inside.clear();
s.state = State.WAITING_COORDS;
ensureTask(plugin);
p.sendActionBar(Component.text("Hole open. Type coordinates in chat: x, y, z").color(NamedTextColor.LIGHT_PURPLE));
return true;
}
/**
* Ответная дыра по координатам из чата.
*
* <p>Здесь и только здесь списывается TP: условие говорит, что стоимость
* уходит в момент создания пары, а не в момент прыжка. Значит пару можно
* создать и уйти, не воспользовавшись — потрачено.
*/
public static boolean openExit(BlackKnifePlugin plugin, Player p, TensionManager tm, Location target) {
final Session s = SESSIONS.get(p.getUniqueId());
if (s == null || s.state != State.WAITING_COORDS) {
return false;
}
if (target == null || !isFree(target)) {
p.sendActionBar(Component.text("Blocked at those coordinates").color(NamedTextColor.RED));
return false;
}
if (tm.getTP(p) < TP_COST) {
p.sendActionBar(Component.text("Need 100% TP").color(NamedTextColor.RED));
return false;
}
// Проверяем до списания, чтобы не списать TP за неудачную попытку.
if (!MoleHoleEffect.place(plugin, p, s.entrance, s.exit, target)) {
return false;
}
tm.setTP(p, tm.getTP(p) - TP_COST);
AbilityCooldown.set(p, ABILITY, (int) COOLDOWN_TICKS);
s.exit = target;
s.expireTick = Bukkit.getCurrentTick() + PAIR_TICKS;
s.state = State.PAIRED;
p.sendActionBar(Component.text("Pair ready - 5s").color(NamedTextColor.GREEN));
return true;
}
// -------------------------------------------------------------- геометрия ----
/**
* Точка дыры под игроком: верхний блок минус один, то есть пол на уровне ног.
*
* <p>Возвращает null, если опоры нет — висящий в воздухе игрок дыру не открывает.
*/
public static Location groundUnder(Location from) {
final World w = from.getWorld();
if (w == null) return null;
final int x = from.getBlockX();
final int z = from.getBlockZ();
for (int y = Math.min(w.getMaxHeight() - 2, from.getBlockY() + 2); y >= w.getMinHeight() + 1; y--) {
if (w.getBlockAt(x, y, z).getType().isSolid()) {
return new Location(w, x + 0.5D, y, z + 0.5D);
}
}
return null;
}
/** Верхний блок в колонке — используется для формата «x, z» без Y. */
public static Location topAt(World w, int x, int z) {
for (int y = w.getMaxHeight() - 1; y >= w.getMinHeight(); y--) {
if (w.getBlockAt(x, y, z).getType().isSolid()) {
return new Location(w, x + 0.5D, y, z + 0.5D);
}
}
return null;
}
/**
* Дыра не может стоять в блоке и не должна быть на потолке.
*
* <p>Проверяем три блока: пол-опора под точкой, сам блок точки и блок над ним.
* Точка дыры — это поверхность блока снизу, то есть её блок обязан быть воздухом,
* а над ней должно быть свободно, иначе в дыру не встанешь.
*/
public static boolean isFree(Location at) {
final World w = at.getWorld();
if (w == null) return false;
if (w.getBlockAt(at).getType().isSolid()) return false;
final Block above = w.getBlockAt(at.getBlockX(), at.getBlockY() + 1, at.getBlockZ());
if (above.getType().isSolid()) return false;
final Block below = w.getBlockAt(at.getBlockX(), at.getBlockY() - 1, at.getBlockZ());
return below.getType().isSolid();
}
/** Стоит ли игрок внутри дыры. */
public static boolean standingIn(Player p, Location hole) {
if (hole == null || hole.getWorld() == null) return false;
if (!hole.getWorld().equals(p.getWorld())) return false;
return hole.distanceSquared(p.getLocation()) < HOLE_RADIUS * HOLE_RADIUS;
}
// ------------------------------------------------------------- уборка ----
public static void clear(Player p) {
final Session s = SESSIONS.remove(p.getUniqueId());
if (s != null && s.owner != null && s.owner.equals(p.getUniqueId())) {
MoleHoleEffect.removeAll(p);
} else if (s != null) {
MoleHoleEffect.removeHoles(s);
}
if (SESSIONS.isEmpty()) stopTask();
}
public static void clearAll() {
SESSIONS.clear();
MoleHoleEffect.removeEverything();
stopTask();
}
// -------------------------------------------------------------- тик ----
private 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 stopTask() {
if (task != null) {
task.cancel();
task = null;
}
}
private static void tick(BlackKnifePlugin plugin) {
final long now = Bukkit.getCurrentTick();
// Истёкшие пары и игроки, вышедшие с сервера, снимаем отдельным проходом
// по копии ключей: удалять из карты прямо во время обхода нельзя.
for (var id : new java.util.ArrayList<>(SESSIONS.keySet())) {
final Session s = SESSIONS.get(id);
if (s.state == State.PAIRED && s.expireTick > 0 && now >= s.expireTick) {
MoleHoleEffect.removeHoles(s);
reset(s);
continue;
}
if (!isOnline(id)) {
MoleHoleEffect.removeHoles(s);
reset(s);
}
}
if (SESSIONS.isEmpty()) {
stopTask();
return;
}
// Погружение тех, кто встал в дыру.
for (Session s : SESSIONS.values()) {
if (s.state != State.PAIRED || s.entrance == null || s.exit == null) continue;
for (Player target : Bukkit.getOnlinePlayers()) {
final UUID uid = target.getUniqueId();
final boolean inEntrance = standingIn(target, s.entrance);
final boolean inExit = standingIn(target, s.exit);
if (!inEntrance && !inExit) continue;
// Правила доступа: дыра из пустоты — только для открывшего,
// обычная дыра — для всех.
if (s.fromVoid && !uid.equals(s.owner)) continue;
if (s.inside.getOrDefault(uid, Boolean.FALSE)) continue;
s.inside.put(uid, Boolean.TRUE);
final Location dest = inEntrance ? s.exit : s.entrance;
MoleHoleEffect.sink(plugin, target, dest, new java.util.function.Consumer<Player>() {
@Override public void accept(Player who) {
who.teleport(dest.clone().add(0, -TRIGGER_DEPTH, 0));
who.setFallDistance(0f);
who.setVelocity(new org.bukkit.util.Vector(0, 0, 0));
}
});
}
}
}
private static void reset(Session s) {
s.state = State.IDLE;
s.entrance = null;
s.exit = null;
s.expireTick = 0;
s.inside.clear();
}
private static boolean isOnline(UUID id) {
return Bukkit.getPlayer(id) != null;
}
}
@@ -0,0 +1,112 @@
package me.sashegdev.blackknife;
import io.papermc.paper.event.player.AsyncChatEvent;
import net.kyori.adventure.text.Component;
import net.kyori.adventure.text.format.NamedTextColor;
import net.kyori.adventure.text.serializer.plain.PlainTextComponentSerializer;
import org.bukkit.Location;
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 java.util.regex.Matcher;
import java.util.regex.Pattern;
/**
* Приём координат для «Кротовой ямы».
*
* <p>Разбираем только то, что игрок реально ввёл как координаты, и только когда
* он ждёт их. Всё остальное уходит в чат нетронутым. Это принципиально: если
* перехватчик глотает переписку, ломается чат целиком, и заметят это все, а не
* только игрок с ножом.
*
* <p>Форматов ровно два: три числа — это {@code x, y, z}, два числа — это
* {@code x, z} и Y берётся верхним блоком колонки. Формата «x, y» без Z не
* существует: двусмысленно, и трактовать его как «две координаты» безопаснее,
* потому что это единственное прочтение, при котором игрок не улетает в
* пустоту вместо нужного места.
*/
public class MoleHoleCoords implements Listener {
/** До трёх чисел через запятую или пробел. Числа дробные, дыра в центре блока. */
private static final Pattern COORDS = Pattern.compile(
"^\\s*(-?\\d+(?:\\.\\d+)?)\\s*(?:[,;]\\s*|\\s+)(-?\\d+(?:\\.\\d+)?)?"
+ "(?:\\s*[,;]?\\s*(-?\\d+(?:\\.\\d+)?))?\\s*$");
private final BlackKnifePlugin plugin;
public MoleHoleCoords(BlackKnifePlugin plugin) {
this.plugin = plugin;
}
/**
* LOWEST, чтобы другие плагины чата не успели отправить сообщение в чат.
* Но НЕ MONITOR: там уже поздно отменять.
*/
@EventHandler(priority = EventPriority.LOWEST, ignoreCancelled = true)
public void onChat(AsyncChatEvent e) {
final Player p = e.getPlayer();
// Ввод координат принимается только в этом состоянии. В PAIRED ввод
// заблокирован, и сообщение уходит в чат обычным текстом.
if (!MoleHole.awaitingCoords(p)) {
return;
}
final String text = PlainTextComponentSerializer.plainText().serialize(e.message()).trim();
if (text.isEmpty() || !looksLikeCoords(text)) {
return; // не координаты — не наше дело, пусть идёт в чат
}
// Разобрали как координаты: в чат это сообщение уже не пойдёт.
e.setCancelled(true);
final Location target = resolve(p, text);
if (target == null) {
p.sendActionBar(Component.text("Bad coordinates, use x, y, z or x, z")
.color(NamedTextColor.RED));
return;
}
// Работа с миром и телепортами — только на главном потоке.
plugin.getServer().getScheduler().runTask(plugin, () ->
MoleHole.openExit(plugin, p, plugin.getTension(), target));
}
/** Строка разбирается как координаты: ровно два или три числа. */
public static boolean looksLikeCoords(String text) {
if (text == null) return false;
final Matcher m = COORDS.matcher(text.trim());
if (!m.matches()) return false;
final int count = numbers(m);
return count == 2 || count == 3;
}
private static int numbers(Matcher m) {
int n = 0;
for (int g = 1; g <= 3; g++) {
if (m.group(g) != null && !m.group(g).isEmpty()) n++;
}
return n;
}
/** Строит точку. Два числа — это x и z, три — x, y, z. */
private static Location resolve(Player p, String text) {
final Matcher m = COORDS.matcher(text.trim());
if (!m.matches()) return null;
final int count = numbers(m);
final double x = Double.parseDouble(m.group(1));
final double second = m.group(2) == null ? 0 : Double.parseDouble(m.group(2));
final double third = m.group(3) == null ? 0 : Double.parseDouble(m.group(3));
final World w = p.getWorld();
if (count == 2) {
return MoleHole.topAt(w, (int) Math.floor(x), (int) Math.floor(second));
}
return new Location(w, x, second, third);
}
}
@@ -0,0 +1,267 @@
package me.sashegdev.blackknife;
import org.bukkit.Location;
import org.bukkit.Material;
import org.bukkit.Particle;
import org.bukkit.World;
import org.bukkit.entity.BlockDisplay;
import org.bukkit.entity.Display;
import org.bukkit.entity.Player;
import org.bukkit.potion.PotionEffect;
import org.bukkit.potion.PotionEffectType;
import org.bukkit.scheduler.BukkitRunnable;
import java.util.ArrayList;
import java.util.HashMap;
import java.util.List;
import java.util.Map;
import java.util.UUID;
import java.util.function.Consumer;
/**
* Визуал и погружение «Кротовой ямы».
*
* <p>Всё крутится в одной задаче, а не по задаче на сущность. Тот же приём, что
* в {@link StarFx}: раньше на каждую звезду и каждый осколок была своя запись
* в планировщике, то есть одно нажатие порождало до 126 задач. Здесь дыр может
* быть много одновременно у разных игроков, и умножать задачи на их количество
* нельзя.
*
* <p>Отверстие выглядит как заполненный чёрный круг радиусом 1.5 блока, из
* которого вверх идут и одновременно уменьшаются BlockDisplay-очки: чёрное
* стекло, чёрный бетон и чёрный гладкий бетон. На каждом осколке идёт своя
* частица. Всё это живёт около секунды и само исчезает.
*
* <p>Погружение: игрок плавно уходит вниз, буквально опускаясь всё глубже в
* свой же блок, пока не достигнет {@link MoleHole#TRIGGER_DEPTH}. Движение не
* через setVelocity, а телепортацией на доли блока: velocity в этом мире с
* другими сущностями всё равно смешается, а телепорт даёт ровный контроль.
*/
final class MoleHoleEffect {
private MoleHoleEffect() {
}
/** Радиус чёрного круга. */
private static final double RING_RADIUS = 1.5D;
/** Сколько осколков поднимается из дыры. */
private static final int SHARDS = 14;
/** Жизнь осколка, тиков. */
private static final long SHARD_LIFE = 20L;
/** Тиков на погружение. */
private static final int SINK_TICKS = 10;
private static final Material[] SHARD_BLOCKS = {
Material.BLACK_STAINED_GLASS,
Material.BLACK_CONCRETE,
Material.BLACK_CONCRETE_POWDER,
};
/** Осколок, летящий вверх и уменьшающийся. */
private static final class Shard {
final BlockDisplay ent;
final double y0;
final double rise;
final int age;
Shard(BlockDisplay ent, double y0, double rise, int age) {
this.ent = ent;
this.y0 = y0;
this.rise = rise;
this.age = age;
}
}
/** Все живые дыры: кто открыл → где. */
private static final Map<UUID, List<Location>> HOLES = new HashMap<>();
/** Все осколки под одной задачей. */
private static final List<Shard> SHARD_LIST = new ArrayList<>(64);
private static BukkitRunnable task;
/**
* Ставит визуал обеих дыр.
*
* <p>Возвращает false, если игрок вышел — тогда координаты можно не
* принимать, а TP не списывать.
*/
static boolean place(BlackKnifePlugin plugin, Player owner, Location entrance, Location ignored, Location exit) {
if (owner == null || !owner.isOnline()) return false;
// Списываем прошлые дыры этого игрока, если он не успел закончить пару.
removeHoles(owner);
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);
}
ensureTask(plugin);
return true;
}
/** Чёрный заполненный круг и летящие вверх осколки в одной точке. */
private static void spawnRing(BlackKnifePlugin plugin, Location at) {
final World w = at.getWorld();
if (w == null) return;
// Заполненный круг: плотный диск, а не кольцо, иначе середина выглядит
// дырой в буквальном смысле.
w.spawnParticle(Particle.DUST, at, 60, RING_RADIUS, 0.12D, RING_RADIUS, 0.0D, Particles.voidDust());
for (int i = 0; i < SHARDS; i++) {
final double a = (double) i / SHARDS * Math.PI * 2.0D;
final double r = RING_RADIUS * (0.35D + Math.random() * 0.65D);
final Location spawn = at.clone().add(Math.cos(a) * r, 0.05D, Math.sin(a) * r);
// Материал в финальную переменную: индекс цикла не effectively final,
// а лямбда его не увидит.
final Material block = SHARD_BLOCKS[i % SHARD_BLOCKS.length];
final BlockDisplay d = w.spawn(spawn, BlockDisplay.class, ent -> {
ent.setBlock(block.createBlockData());
ent.setBrightness(new Display.Brightness(0, 0));
ent.setViewRange(0.75F);
});
EntityTracker.mark(d);
SHARD_LIST.add(new Shard(d, at.getY(), 1.2D + Math.random() * 1.6D, 0));
}
}
// -------------------------------------------------------------- погружение ----
/**
* Плавно уводит игрока вниз, потом вызывает onDone.
*
* <p>Уводим телепортацией на доли блока, а не скоростью: скорость смешалась бы
* с другими сущностями в дыре, а телепорт даёт предсказуемое плавное
* движение и не зависит от того, что игрок делает в этот момент.
*/
static void sink(BlackKnifePlugin plugin, Player p, Location hole, Consumer<Player> onDone) {
if (p == null || !p.isOnline() || hole == null) return;
final Location from = p.getLocation().clone();
// Копии для лямбды: step меняется внутри, значит сам по себе он не
// effectively final и в анонимном классе его не поймать.
final int[] counter = {0};
new BukkitRunnable() {
@Override public void run() {
if (!p.isOnline()) { cancel(); return; }
counter[0]++;
final double t = Math.min(1.0D, (double) counter[0] / SINK_TICKS);
// Плавно: доля пройденного пути растёт нелинейно, по smoothstep.
final double eased = t * t * (3.0D - 2.0D * t);
final Location next = from.clone();
next.setY(from.getY() - MoleHole.SINK_DEPTH * eased);
p.teleport(next);
p.setFallDistance(0f);
p.setVelocity(new org.bukkit.util.Vector(0, 0, 0));
p.addPotionEffect(new PotionEffect(PotionEffectType.SLOWNESS, 4, 4, true, false, false));
if (counter[0] >= SINK_TICKS) {
cancel();
onDone.accept(p);
}
}
}.runTaskTimer(plugin, 1L, 1L);
}
// ------------------------------------------------------------------ тик ----
private static void ensureTask(BlackKnifePlugin plugin) {
if (task != null) return;
task = new BukkitRunnable() {
@Override public void run() {
tick();
}
};
task.runTaskTimer(plugin, 1L, 1L);
}
private static void tick() {
for (int i = SHARD_LIST.size() - 1; i >= 0; i--) {
final Shard s = SHARD_LIST.get(i);
if (!s.ent.isValid() || s.ent.isDead()) {
SHARD_LIST.remove(i);
continue;
}
final int age = s.age + 1;
// Поднимается и одновременно уменьшается: к концу жизни исчезает.
final double t = (double) age / SHARD_LIFE;
final double y = s.y0 + s.rise * t;
final double scale = Math.max(0.02D, 0.42D * (1.0D - t));
final Location loc = s.ent.getLocation();
s.ent.teleport(new Location(loc.getWorld(), loc.getX(), y, loc.getZ()));
s.ent.setTransformation(new org.bukkit.util.Transformation(
new org.joml.Vector3f((float) -scale, (float) -scale, (float) -scale),
new org.joml.AxisAngle4f(),
new org.joml.Vector3f((float) scale, (float) scale, (float) scale),
new org.joml.AxisAngle4f()));
// Частица на самом осколке.
final World w = s.ent.getWorld();
if (w != null && age % 2 == 0) {
w.spawnParticle(Particle.SQUID_INK, s.ent.getLocation().add(0, 0.3D, 0), 1,
0.02D, 0.02D, 0.02D, 0);
}
if (age >= SHARD_LIFE) {
EntityTracker.untrack(s.ent);
s.ent.remove();
SHARD_LIST.remove(i);
} else {
SHARD_LIST.set(i, new Shard(s.ent, s.y0, s.rise, age));
}
}
if (SHARD_LIST.isEmpty() && HOLES.isEmpty() && task != null) {
task.cancel();
task = null;
}
}
// ---------------------------------------------------------------- уборка ----
static void removeHoles(Player owner) {
final List<Location> removed = HOLES.remove(owner.getUniqueId());
if (removed == null) return;
for (Location at : removed) {
fillRing(at);
}
}
static void removeHoles(MoleHole.Session s) {
if (s.entrance != null) fillRing(s.entrance);
if (s.exit != null) fillRing(s.exit);
}
static void removeAll(Player owner) {
removeHoles(owner);
}
static void removeEverything() {
for (List<Location> list : HOLES.values()) {
for (Location at : list) fillRing(at);
}
HOLES.clear();
for (Shard s : SHARD_LIST) {
if (s.ent.isValid()) {
EntityTracker.untrack(s.ent);
s.ent.remove();
}
}
SHARD_LIST.clear();
if (task != null) {
task.cancel();
task = null;
}
}
/** Гасит чёрный круг: дыра схлопывается, остаётся только слой пыли. */
private static void fillRing(Location at) {
if (at == null || at.getWorld() == null) return;
at.getWorld().spawnParticle(Particle.DUST, at, 24, RING_RADIUS, 0.1D, RING_RADIUS, 0.0D, Particles.voidDust());
}
}
@@ -145,6 +145,13 @@ public final class Particles {
public static final Particle.DustOptions DARKGREY_15 = dust(30, 30, 30, 1.5f);
/** Тёмно-серый, размер 0.9 — сфера взрыва SWORD, 36 частиц за вызов. */
public static final Particle.DustOptions DARKGREY_09 = dust(30, 30, 30, 0.9f);
/** Почти чёрный, крупный — заполнение кротовой ямы. */
public static final Particle.DustOptions VOID_DUST = dust(2, 2, 4, 1.6f);
/** Заполнение дыры вообще: один объект на все вызовы. */
public static Particle.DustOptions voidDust() {
return VOID_DUST;
}
// ---------------------------------------------------------------------
// Дальние частицы — прицелы.