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:
@@ -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;
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------
|
||||
// Дальние частицы — прицелы.
|
||||
|
||||
Reference in New Issue
Block a user