feat: add Elytra mode (basic S-level)
- elytra/section/: ElytraSection (5 knots + cubic spline via commons-math3), ElytraKnotDirector (dx~N(75,15), dy bias, dz~N(0,35)), ElytraPointType (CIRCLE Bresenham / FLAT), ClientBlockChanger (client-side sendBlockChanges, forward-limit min(vd,8)) - elytra/generator/ElytraGenerator extends ParkourGenerator: own tick() (score by velocity.x while gliding, section lifecycle, fall detection, sustained boost in ascending sections), reset, spawn with elytra - elytra/mode/ElytraMode: registered in Modes/PluginBootstrap, locale items ru/en - build.gradle: commons-math3 3.6.1 + relocate - checkstyle 0/0, PMD 0, build green (tests + shadowJar)
This commit is contained in:
parent
6f71a47ab3
commit
8fde1b579b
12 changed files with 759 additions and 4 deletions
8
TODO.md
8
TODO.md
|
|
@ -241,10 +241,10 @@
|
||||||
|
|
||||||
### Elytra (порт из `dev.efnilite.iep`, Kotlin → Java)
|
### Elytra (порт из `dev.efnilite.iep`, Kotlin → Java)
|
||||||
|
|
||||||
- **127. Elytra — модель.** Один генератор на игрока (не сессия на многих). Трасса = секция из 5 узлов (`KnotDirector`: dx~N(75,15), dy bias −15/+15 (ASCEND при Y<50), dz~N(0,35)), узлы → кубический сплайн → точки → «кольца» (Брезенхэм, `PointType.CIRCLE(-1)` / `FLAT(-5)` для CloseMode). Очки = пройденное расстояние: каждый тик `movementScore += velocity.x` при `isGliding && x > blockSpawn.x`. Фолл: Y<спавн−radius | дальше radius от точек | не глидишь. Подъёмная секция = фейерверк-буст (`velocity = v*0.7+dir*0.6`, насильный `isGliding`).
|
- **127. Elytra — модель.** ~~...~~ ✅ **БАЗОВОЕ СДЕЛАНО**: `elytra/section/` (ElytraSection+KnotDirector+PointType+ClientBlockChanger, сплайн через commons-math3), `elytra/generator/ElytraGenerator extends ParkourGenerator` (свой tick/score/фолл/reset, sustained-буст +0.08 в ascending), `elytra/mode/ElytraMode`. Зарегистрирован (Modes.ELYTRA, PluginBootstrap, локали play.elytra.elytra). Команда commons-math3: `org.apache.commons:commons-math3:3.6.1` + relocate. Осталось по п.127: фейерверк-буст (`v*0.7+dir*0.6`) — сделано упрощённо (только sustained); CloseMode (FLAT-кольца).
|
||||||
- **128. Elytra — клиент-сайд рендер.** `ClientBlockChanger` НЕ ставит блоки в мир: очередь `Map<chunkX, Set<Vector>>`, каждый тик шлёт игроку `player.sendBlockChanges(states)` (MultiBlockChange) только для чанков вперёд по X (limit min(viewDistance,8)). Пакет `ElytraBlockChanger` на Java.
|
- **128. Elytra — клиент-сайд рендер.** ✅ `ClientBlockChanger`: очередь `Map<chunkX, Set<Vector>>`, `player.sendBlockChanges(states)` по чанкам, forward limit min(vd,8), flush/clear. Секции хранят Vector (без пиннинга чанков).
|
||||||
- **129. Elytra — классы:** `ElytraSection`+`ElytraKnotDirector`+`ElytraPointType`, `ElytraGenerator extends ParkourGenerator` (СВОЙ `tick()`, переопределённый `getMode()`, свой фолл-детект — не полагаться на `LifecycleTickManager.checkPlayerFall`), режимы `ElytraMode/Default/SpeedDemon/MinSpeed/TimeTrial/Close/Obstacle` (по одному подклассу генератора), раздача элитр в слот нагрудника + `ParkourHotbar`, `PreviousData` переиспользуем.
|
- **129. Elytra — классы.** ✅ Базовый: ElytraSection/KnotDirector/PointType/ClientBlockChanger + ElytraGenerator + ElytraMode. Осталось (M): подклассы-режимы SpeedDemon/MinSpeed/TimeTrial/Close/Obstacle, фейерверк-буст, раздача элитр через PreviousData (сейчас элитры выдаются в слот нагрудника при generateFirst, глiding=true).
|
||||||
- **130. Elytra — зависимости/мир.** Добавить Apache Commons Math (`SplineInterpolator`) или заменить на Catmull-Rom. Отдельный мир не нужен: труба клиент-сайд, наш мир void; подобрать высоту/порог ASCEND под наши zone-границы. Скор в double (наши `Score.score` int — масштабировать или добавить float-вариант). Сложность: **S** базовый, **M** режимы+бусты, **L** Obstacle+настройки+стили+награды.
|
- **130. Elytra — зависимости/мир.** ✅ commons-math3 подключён+relocated; отдельный мир не нужен (клиент-сайд); ASCEND_THRESHOLD_Y=50; скор в double (movementScore), в лидерборд пока не пишется (TODO: int-масштаб). Сложность: **S ✅**, M (режимы+бусты) и L (Obstacle+настройки+стили+награды) — далее.
|
||||||
|
|
||||||
- **131. Тесты под новые режимы:** сплайн/секции (изоляция, Y/Z интерполяция), `ElytraGenerator.tick` (score по velocity.x, shouldReset-причины), `DuelsGenerator.win` (guard `stopped`, идемпотентность `reset(false)`), `SingleDuelsGenerator.score`→win при goal, рекурсия unregister (регрессия Coop).
|
- **131. Тесты под новые режимы:** сплайн/секции (изоляция, Y/Z интерполяция), `ElytraGenerator.tick` (score по velocity.x, shouldReset-причины), `DuelsGenerator.win` (guard `stopped`, идемпотентность `reset(false)`), `SingleDuelsGenerator.score`→win при goal, рекурсия unregister (регрессия Coop).
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -93,6 +93,7 @@ dependencies {
|
||||||
implementation("org.bstats:bstats-bukkit:3.2.1")
|
implementation("org.bstats:bstats-bukkit:3.2.1")
|
||||||
implementation("com.zaxxer:HikariCP:5.1.0")
|
implementation("com.zaxxer:HikariCP:5.1.0")
|
||||||
implementation("com.mysql:mysql-connector-j:8.3.0")
|
implementation("com.mysql:mysql-connector-j:8.3.0")
|
||||||
|
implementation("org.apache.commons:commons-math3:3.6.1")
|
||||||
compileOnly("com.sk89q.worldedit:worldedit-core:7.3.6")
|
compileOnly("com.sk89q.worldedit:worldedit-core:7.3.6")
|
||||||
compileOnly("com.sk89q.worldedit:worldedit-bukkit:7.3.6")
|
compileOnly("com.sk89q.worldedit:worldedit-bukkit:7.3.6")
|
||||||
|
|
||||||
|
|
@ -122,6 +123,7 @@ tasks.shadowJar {
|
||||||
relocate("io.papermc.lib", "dev.loki.loparkour.lib.paperlib")
|
relocate("io.papermc.lib", "dev.loki.loparkour.lib.paperlib")
|
||||||
relocate("com.google.gson", "dev.loki.loparkour.lib.gson")
|
relocate("com.google.gson", "dev.loki.loparkour.lib.gson")
|
||||||
relocate("org.bstats", "dev.loki.loparkour.lib.bstats")
|
relocate("org.bstats", "dev.loki.loparkour.lib.bstats")
|
||||||
|
relocate("org.apache.commons.math3", "dev.loki.loparkour.lib.commonsmath3")
|
||||||
|
|
||||||
// Don't minimize - causes issues with Caffeine's dynamically generated classes
|
// Don't minimize - causes issues with Caffeine's dynamically generated classes
|
||||||
// minimize {
|
// minimize {
|
||||||
|
|
|
||||||
|
|
@ -12,6 +12,7 @@ import dev.loki.loparkour.hook.papi.PAPIHook;
|
||||||
import dev.loki.loparkour.listener.gameplay.ParkourRestrictionListener;
|
import dev.loki.loparkour.listener.gameplay.ParkourRestrictionListener;
|
||||||
import dev.loki.loparkour.listener.schematic.SchematicWandListener;
|
import dev.loki.loparkour.listener.schematic.SchematicWandListener;
|
||||||
import dev.loki.loparkour.listener.player.PlayerConnectionListener;
|
import dev.loki.loparkour.listener.player.PlayerConnectionListener;
|
||||||
|
import dev.loki.loparkour.elytra.mode.ElytraMode;
|
||||||
import dev.loki.loparkour.mode.base.Modes;
|
import dev.loki.loparkour.mode.base.Modes;
|
||||||
import dev.loki.loparkour.mode.impl.barrier.InvisibleBarrierMode;
|
import dev.loki.loparkour.mode.impl.barrier.InvisibleBarrierMode;
|
||||||
import dev.loki.loparkour.mode.impl.multi.CoopMode;
|
import dev.loki.loparkour.mode.impl.multi.CoopMode;
|
||||||
|
|
@ -76,6 +77,7 @@ public final class PluginBootstrap {
|
||||||
Registry.register(new InvisibleBarrierMode());
|
Registry.register(new InvisibleBarrierMode());
|
||||||
Registry.register(new CoopMode());
|
Registry.register(new CoopMode());
|
||||||
Registry.register(new GravityShiftMode());
|
Registry.register(new GravityShiftMode());
|
||||||
|
Registry.register(new ElytraMode());
|
||||||
Modes.init();
|
Modes.init();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -0,0 +1,200 @@
|
||||||
|
package dev.loki.loparkour.elytra.generator;
|
||||||
|
|
||||||
|
import dev.loki.loparkour.config.core.Config;
|
||||||
|
import dev.loki.loparkour.elytra.section.ClientBlockChanger;
|
||||||
|
import dev.loki.loparkour.elytra.section.ElytraKnotDirector;
|
||||||
|
import dev.loki.loparkour.elytra.section.ElytraPointType;
|
||||||
|
import dev.loki.loparkour.elytra.section.ElytraSection;
|
||||||
|
import dev.loki.loparkour.generator.core.coordinator.ParkourGenerator;
|
||||||
|
import dev.loki.loparkour.mode.base.Mode;
|
||||||
|
import dev.loki.loparkour.mode.base.Modes;
|
||||||
|
import dev.loki.loparkour.session.core.Session;
|
||||||
|
import dev.loki.loparkour.util.item.Materials;
|
||||||
|
import org.bukkit.Location;
|
||||||
|
import org.bukkit.Material;
|
||||||
|
import org.bukkit.entity.Player;
|
||||||
|
import org.bukkit.inventory.ItemStack;
|
||||||
|
import org.bukkit.util.Vector;
|
||||||
|
import java.util.LinkedHashMap;
|
||||||
|
import java.util.Map;
|
||||||
|
import java.util.Random;
|
||||||
|
|
||||||
|
public class ElytraGenerator extends ParkourGenerator {
|
||||||
|
|
||||||
|
private static final int ASCEND_THRESHOLD_Y = 50;
|
||||||
|
private static final double SUSTAINED_BOOST = 0.08;
|
||||||
|
private static final int DEFAULT_RADIUS = 3;
|
||||||
|
private static final double GENERATE_DISTANCE = 6.0;
|
||||||
|
private static final double EPSILON = 1e-6;
|
||||||
|
public final Map<Integer, ElytraSection> sections = new LinkedHashMap<>();
|
||||||
|
public final ClientBlockChanger blockChanger = new ClientBlockChanger();
|
||||||
|
private double movementScore = 0.0;
|
||||||
|
private Long startTime = null;
|
||||||
|
private Vector playerSpawn;
|
||||||
|
private Vector blockSpawn;
|
||||||
|
private Random random;
|
||||||
|
private final ElytraPointType pointType = ElytraPointType.CIRCLE;
|
||||||
|
private final int radius;
|
||||||
|
private final Material material;
|
||||||
|
private int boostedSectionIdx = Integer.MIN_VALUE;
|
||||||
|
|
||||||
|
public ElytraGenerator(Session session) {
|
||||||
|
super(session);
|
||||||
|
this.radius = Config.GENERATION.getInt("elytra.radius", DEFAULT_RADIUS);
|
||||||
|
this.material = Materials.parseOr(Config.GENERATION.getString("advanced.island.parkour.first-block-material"), Material.WHITE_STAINED_GLASS);
|
||||||
|
}
|
||||||
|
|
||||||
|
@Override
|
||||||
|
public void generateFirst(Location spawn, Location block) {
|
||||||
|
this.playerSpawn = spawn.toVector();
|
||||||
|
this.blockSpawn = block.toVector();
|
||||||
|
this.random = new Random(System.currentTimeMillis());
|
||||||
|
final Player player = getPlayer();
|
||||||
|
if (player != null) {
|
||||||
|
if (player.getInventory().getChestplate() == null || player.getInventory().getChestplate().getType() != Material.ELYTRA) {
|
||||||
|
player.getInventory().setChestplate(new ItemStack(Material.ELYTRA));
|
||||||
|
}
|
||||||
|
player.setGliding(true);
|
||||||
|
player.setVelocity(new Vector(1.0, 0.0, 0.0));
|
||||||
|
}
|
||||||
|
generate();
|
||||||
|
}
|
||||||
|
|
||||||
|
@Override
|
||||||
|
public void generate() {
|
||||||
|
final int idx;
|
||||||
|
final ElytraSection section;
|
||||||
|
if (sections.isEmpty()) {
|
||||||
|
idx = 0;
|
||||||
|
section = new ElytraSection(blockSpawn.clone().add(new Vector(0, pointType.heightOffset, 0)), random,
|
||||||
|
ElytraKnotDirector.DEFAULT_VERTICAL_BIAS);
|
||||||
|
} else {
|
||||||
|
final int lastIdx = sections.keySet().stream().max(Integer::compareTo).orElse(0);
|
||||||
|
final ElytraSection previous = sections.get(lastIdx);
|
||||||
|
final int bias = previous.end().getY() < ASCEND_THRESHOLD_Y ? ElytraKnotDirector.ASCENDING_VERTICAL_BIAS
|
||||||
|
: ElytraKnotDirector.DEFAULT_VERTICAL_BIAS;
|
||||||
|
idx = lastIdx + 1;
|
||||||
|
section = new ElytraSection(previous.end(), random, bias);
|
||||||
|
}
|
||||||
|
sections.put(idx, section);
|
||||||
|
blockChanger.queue(section.generateBlocks(pointType, radius));
|
||||||
|
}
|
||||||
|
|
||||||
|
@Override
|
||||||
|
public void startTick() {
|
||||||
|
}
|
||||||
|
|
||||||
|
@Override
|
||||||
|
public void tick() {
|
||||||
|
final Player player = getPlayer();
|
||||||
|
if (player == null || sections.isEmpty()) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
final int idx = sections.keySet().stream().max(Integer::compareTo).orElse(-1);
|
||||||
|
final ElytraSection section = sections.get(idx);
|
||||||
|
if (section == null) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
final Vector pos = player.getLocation().toVector();
|
||||||
|
applyScoring(player, pos);
|
||||||
|
blockChanger.check(player, material);
|
||||||
|
removeOldest(player);
|
||||||
|
if (shouldReset(player, pos)) {
|
||||||
|
reset(true);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (section.isAscending()) {
|
||||||
|
sustainAscendingPush(player, section, idx);
|
||||||
|
}
|
||||||
|
if (section.isNearKnot(pos, 2, GENERATE_DISTANCE)) {
|
||||||
|
generate();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@Override
|
||||||
|
public void reset(boolean regenerate) {
|
||||||
|
final Player player = getPlayer();
|
||||||
|
for (final ElytraSection section : sections.values()) {
|
||||||
|
if (player != null) {
|
||||||
|
section.clear(player);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
sections.clear();
|
||||||
|
blockChanger.clear();
|
||||||
|
movementScore = 0;
|
||||||
|
startTime = null;
|
||||||
|
boostedSectionIdx = Integer.MIN_VALUE;
|
||||||
|
if (regenerate) {
|
||||||
|
if (player != null && playerSpawn != null) {
|
||||||
|
player.teleport(playerSpawn.toLocation(player.getWorld()));
|
||||||
|
player.setGliding(true);
|
||||||
|
}
|
||||||
|
generate();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@Override
|
||||||
|
public Mode getMode() {
|
||||||
|
return Modes.ELYTRA;
|
||||||
|
}
|
||||||
|
|
||||||
|
public int getElytraScore() {
|
||||||
|
return (int) Math.round(movementScore);
|
||||||
|
}
|
||||||
|
|
||||||
|
private Player getPlayer() {
|
||||||
|
return session.getPlayers().isEmpty() ? null : session.getPlayers().get(0).player;
|
||||||
|
}
|
||||||
|
|
||||||
|
private void removeOldest(Player player) {
|
||||||
|
if (sections.size() <= 2) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
final ElytraSection old = sections.remove(sections.keySet().stream().min(Integer::compareTo).orElse(-1));
|
||||||
|
if (old != null) {
|
||||||
|
old.clear(player);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private void applyScoring(Player player, Vector pos) {
|
||||||
|
if (player.isGliding() && pos.getX() - blockSpawn.getX() > 0) {
|
||||||
|
movementScore += player.getVelocity().getX();
|
||||||
|
}
|
||||||
|
if (startTime == null && movementScore > 0) {
|
||||||
|
startTime = System.currentTimeMillis();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private boolean shouldReset(Player player, Vector pos) {
|
||||||
|
Map.Entry<Integer, ElytraSection> target = null;
|
||||||
|
for (final Map.Entry<Integer, ElytraSection> entry : sections.entrySet()) {
|
||||||
|
if (pos.getX() < entry.getValue().end().getX() && (target == null || entry.getKey() < target.getKey())) {
|
||||||
|
target = entry;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if (target == null) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
final double progress = pos.getX() - target.getValue().beginning().getX();
|
||||||
|
if (progress < 0) {
|
||||||
|
return target.getKey() == 0 && pos.getY() < blockSpawn.getY() - radius;
|
||||||
|
}
|
||||||
|
return progress > 0 && (!player.isGliding() || !target.getValue().isNearPoint(pos, (int) progress, radius));
|
||||||
|
}
|
||||||
|
|
||||||
|
private void sustainAscendingPush(Player player, ElytraSection section, int idx) {
|
||||||
|
final Vector pos = player.getLocation().toVector();
|
||||||
|
if (pos.getX() < section.beginning().getX() || pos.getX() >= section.end().getX()) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (boostedSectionIdx != idx) {
|
||||||
|
boostedSectionIdx = idx;
|
||||||
|
player.setGliding(true);
|
||||||
|
}
|
||||||
|
final Vector direction = section.end().clone().subtract(section.beginning()).normalize();
|
||||||
|
if (direction.lengthSquared() < EPSILON) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
player.setVelocity(player.getVelocity().add(direction.multiply(SUSTAINED_BOOST)));
|
||||||
|
}
|
||||||
|
}
|
||||||
55
src/main/java/dev/loki/loparkour/elytra/mode/ElytraMode.java
Normal file
55
src/main/java/dev/loki/loparkour/elytra/mode/ElytraMode.java
Normal file
|
|
@ -0,0 +1,55 @@
|
||||||
|
package dev.loki.loparkour.elytra.mode;
|
||||||
|
|
||||||
|
import dev.loki.loparkour.config.locale.Locales;
|
||||||
|
import dev.loki.loparkour.elytra.generator.ElytraGenerator;
|
||||||
|
import dev.loki.loparkour.leaderboard.core.Leaderboard;
|
||||||
|
import dev.loki.loparkour.mode.base.Mode;
|
||||||
|
import dev.loki.loparkour.mode.base.ModeMessages;
|
||||||
|
import dev.loki.loparkour.player.core.ParkourPlayer;
|
||||||
|
import dev.loki.loparkour.session.core.Session;
|
||||||
|
import dev.loki.loparkour.util.item.Item;
|
||||||
|
import org.bukkit.entity.Player;
|
||||||
|
import org.jetbrains.annotations.NotNull;
|
||||||
|
import org.jetbrains.annotations.Nullable;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Elytra gliding mode.
|
||||||
|
*/
|
||||||
|
public class ElytraMode implements Mode {
|
||||||
|
|
||||||
|
private final Leaderboard leaderboard = new Leaderboard(getName(), Leaderboard.Sort.SCORE);
|
||||||
|
|
||||||
|
@Override
|
||||||
|
@NotNull
|
||||||
|
public String getName() {
|
||||||
|
return "elytra";
|
||||||
|
}
|
||||||
|
|
||||||
|
@Override
|
||||||
|
@Nullable
|
||||||
|
public Item getItem(String locale) {
|
||||||
|
return Locales.getItem(locale, "play.elytra.elytra");
|
||||||
|
}
|
||||||
|
|
||||||
|
@Override
|
||||||
|
@NotNull
|
||||||
|
public Leaderboard getLeaderboard() {
|
||||||
|
return leaderboard;
|
||||||
|
}
|
||||||
|
|
||||||
|
@Override
|
||||||
|
public void create(Player player) {
|
||||||
|
if (!ModeMessages.checkJoiningEnabled(player)) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
final ParkourPlayer pp = ParkourPlayer.getPlayer(player);
|
||||||
|
if (pp != null && pp.session.generator != null && pp.session.generator.getMode() instanceof ElytraMode) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
player.closeInventory();
|
||||||
|
|
||||||
|
Session.create(ElytraGenerator::new, null, null, player);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,116 @@
|
||||||
|
package dev.loki.loparkour.elytra.section;
|
||||||
|
|
||||||
|
import java.util.ArrayList;
|
||||||
|
import java.util.HashMap;
|
||||||
|
import java.util.HashSet;
|
||||||
|
import java.util.Iterator;
|
||||||
|
import java.util.List;
|
||||||
|
import java.util.Map;
|
||||||
|
import java.util.Set;
|
||||||
|
import org.bukkit.Material;
|
||||||
|
import org.bukkit.World;
|
||||||
|
import org.bukkit.block.BlockState;
|
||||||
|
import org.bukkit.entity.Player;
|
||||||
|
import org.bukkit.util.Vector;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Pipes pending client-side block updates to the player without leaking server
|
||||||
|
* memory. Positions are stored as {@link Vector}s rather than Bukkit Blocks to
|
||||||
|
* avoid pinning chunks. At send time each batch is resolved and shipped as one
|
||||||
|
* MultiBlockChange per chunk via {@link Player#sendBlockChanges}.
|
||||||
|
*/
|
||||||
|
public class ClientBlockChanger {
|
||||||
|
|
||||||
|
private static final int DEFAULT_MIN_VIEW_DISTANCE = 4;
|
||||||
|
private static final int MAX_FORWARD_CHUNKS = 8;
|
||||||
|
|
||||||
|
private final Map<Integer, Set<Vector>> toChange = new HashMap<>();
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Adds chunked block positions to the queue.
|
||||||
|
*
|
||||||
|
* @param blocks chunk X to block positions to add.
|
||||||
|
*/
|
||||||
|
public synchronized void queue(Map<Integer, Set<Vector>> blocks) {
|
||||||
|
for (final Map.Entry<Integer, Set<Vector>> entry : blocks.entrySet()) {
|
||||||
|
toChange.computeIfAbsent(entry.getKey(), k -> new HashSet<>()).addAll(entry.getValue());
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Sends all queued chunks up to the player's forward render limit and removes
|
||||||
|
* them from the queue. MUST be called on the main thread.
|
||||||
|
*
|
||||||
|
* @param player the player whose client should be updated.
|
||||||
|
* @param material material to display the blocks as.
|
||||||
|
*/
|
||||||
|
public void check(Player player, Material material) {
|
||||||
|
final int playerCx = player.getLocation().getChunk().getX();
|
||||||
|
|
||||||
|
final int rawVd = player.getClientViewDistance();
|
||||||
|
final int vd = rawVd > 0 ? rawVd : Math.max(DEFAULT_MIN_VIEW_DISTANCE, player.getWorld().getViewDistance());
|
||||||
|
final int forwardLimit = playerCx + Math.min(vd, MAX_FORWARD_CHUNKS);
|
||||||
|
|
||||||
|
final List<Map.Entry<Integer, Set<Vector>>> toSend = new ArrayList<>();
|
||||||
|
synchronized (this) {
|
||||||
|
final Iterator<Map.Entry<Integer, Set<Vector>>> iterator = toChange.entrySet().iterator();
|
||||||
|
while (iterator.hasNext()) {
|
||||||
|
final Map.Entry<Integer, Set<Vector>> entry = iterator.next();
|
||||||
|
if (entry.getKey() <= forwardLimit) {
|
||||||
|
toSend.add(Map.entry(entry.getKey(), new HashSet<>(entry.getValue())));
|
||||||
|
iterator.remove();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (toSend.isEmpty()) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
renderBatches(player, toSend, material);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Drains and sends everything currently queued, then clears the queue.
|
||||||
|
*
|
||||||
|
* @param player the player whose client should be updated.
|
||||||
|
* @param material material to display the blocks as.
|
||||||
|
*/
|
||||||
|
public void flush(Player player, Material material) {
|
||||||
|
final List<Map.Entry<Integer, Set<Vector>>> toSend = new ArrayList<>();
|
||||||
|
synchronized (this) {
|
||||||
|
for (final Map.Entry<Integer, Set<Vector>> entry : toChange.entrySet()) {
|
||||||
|
toSend.add(Map.entry(entry.getKey(), new HashSet<>(entry.getValue())));
|
||||||
|
}
|
||||||
|
toChange.clear();
|
||||||
|
}
|
||||||
|
|
||||||
|
if (toSend.isEmpty()) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
renderBatches(player, toSend, material);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Clears the queue without sending anything.
|
||||||
|
*/
|
||||||
|
public synchronized void clear() {
|
||||||
|
toChange.clear();
|
||||||
|
}
|
||||||
|
|
||||||
|
private void renderBatches(Player player, List<Map.Entry<Integer, Set<Vector>>> batches, Material material) {
|
||||||
|
final World world = player.getWorld();
|
||||||
|
for (final Map.Entry<Integer, Set<Vector>> batch : batches) {
|
||||||
|
final Set<Vector> vectors = batch.getValue();
|
||||||
|
if (vectors.isEmpty()) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
final List<BlockState> states = new ArrayList<>(vectors.size());
|
||||||
|
for (final Vector v : vectors) {
|
||||||
|
final BlockState state = v.toLocation(world).getBlock().getState();
|
||||||
|
state.setType(material);
|
||||||
|
states.add(state);
|
||||||
|
}
|
||||||
|
player.sendBlockChanges(states);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,76 @@
|
||||||
|
package dev.loki.loparkour.elytra.section;
|
||||||
|
|
||||||
|
import java.util.Random;
|
||||||
|
import org.bukkit.util.Vector;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Picks per-knot offsets for the spline-based parkour pipe.
|
||||||
|
*
|
||||||
|
* <p>The Y bias is parameterised: default is -15 (gentle descent). Pass a positive
|
||||||
|
* value (e.g. +15) to make the pipe climb instead.</p>
|
||||||
|
*/
|
||||||
|
public class ElytraKnotDirector {
|
||||||
|
|
||||||
|
public static final int DEFAULT_VERTICAL_BIAS = -15;
|
||||||
|
public static final int ASCENDING_VERTICAL_BIAS = 15;
|
||||||
|
|
||||||
|
private static final int DX_MEAN = 75;
|
||||||
|
private static final int DX_SD = 15;
|
||||||
|
private static final int DY_SD = 3;
|
||||||
|
private static final int DZ_SD = 35;
|
||||||
|
|
||||||
|
private final Random random;
|
||||||
|
private final int verticalBias;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @param random source of randomness for the offsets.
|
||||||
|
* @param verticalBias centre of the normal distribution used for the Y offset.
|
||||||
|
*/
|
||||||
|
public ElytraKnotDirector(Random random, int verticalBias) {
|
||||||
|
this.random = random;
|
||||||
|
this.verticalBias = verticalBias;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Returns a random offset vector. The Y component is centred on the vertical
|
||||||
|
* bias so the caller controls whether the section descends, ascends or hovers.
|
||||||
|
*
|
||||||
|
* @return a random offset vector.
|
||||||
|
*/
|
||||||
|
public Vector nextOffset() {
|
||||||
|
final int dx = nextOffset(DX_MEAN, DX_SD);
|
||||||
|
final int dy = nextOffset(verticalBias, DY_SD);
|
||||||
|
final int dz = nextOffset(0, DZ_SD);
|
||||||
|
|
||||||
|
return new Vector(dx, dy, dz);
|
||||||
|
}
|
||||||
|
|
||||||
|
private int nextOffset(int mean, int sd) {
|
||||||
|
final int start = mean - 2 * sd;
|
||||||
|
final int end = mean + 2 * sd;
|
||||||
|
final double[] weights = new double[end - start + 1];
|
||||||
|
double total = 0.0;
|
||||||
|
|
||||||
|
for (int i = 0; i < weights.length; i++) {
|
||||||
|
final int value = start + i;
|
||||||
|
weights[i] = normalPdf(mean, sd, value);
|
||||||
|
total += weights[i];
|
||||||
|
}
|
||||||
|
|
||||||
|
final double roll = random.nextDouble() * total;
|
||||||
|
double cumulative = 0.0;
|
||||||
|
for (int i = 0; i < weights.length; i++) {
|
||||||
|
cumulative += weights[i];
|
||||||
|
if (roll <= cumulative) {
|
||||||
|
return start + i;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return end;
|
||||||
|
}
|
||||||
|
|
||||||
|
private static double normalPdf(double mean, double sd, double value) {
|
||||||
|
final double variance = sd * sd;
|
||||||
|
final double diff = value - mean;
|
||||||
|
return Math.exp(-(diff * diff) / (2 * variance)) / Math.sqrt(2 * Math.PI * variance);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,85 @@
|
||||||
|
package dev.loki.loparkour.elytra.section;
|
||||||
|
|
||||||
|
import java.util.ArrayList;
|
||||||
|
import java.util.List;
|
||||||
|
import org.bukkit.util.Vector;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Defines how a single spline point expands into a set of display block positions.
|
||||||
|
*/
|
||||||
|
public enum ElytraPointType {
|
||||||
|
|
||||||
|
CIRCLE(-1) {
|
||||||
|
@Override
|
||||||
|
public List<Vector> getPoints(Vector center, int radius) {
|
||||||
|
if (radius == 0) {
|
||||||
|
return List.of(center);
|
||||||
|
}
|
||||||
|
|
||||||
|
final List<Vector> blocks = new ArrayList<>();
|
||||||
|
final double centerX = Math.floor(center.getX()) + HALF_OFFSET;
|
||||||
|
final double centerY = Math.floor(center.getY()) + HALF_OFFSET;
|
||||||
|
final double centerZ = Math.floor(center.getZ()) + HALF_OFFSET;
|
||||||
|
|
||||||
|
int y = radius;
|
||||||
|
int z = 0;
|
||||||
|
int d = CIRCLE_START_D - 2 * radius;
|
||||||
|
|
||||||
|
while (z <= y) {
|
||||||
|
blocks.add(new Vector(centerX, centerY + y, centerZ + z));
|
||||||
|
blocks.add(new Vector(centerX, centerY + y, centerZ - z));
|
||||||
|
blocks.add(new Vector(centerX, centerY - y, centerZ + z));
|
||||||
|
blocks.add(new Vector(centerX, centerY - y, centerZ - z));
|
||||||
|
blocks.add(new Vector(centerX, centerY + z, centerZ + y));
|
||||||
|
blocks.add(new Vector(centerX, centerY + z, centerZ - y));
|
||||||
|
blocks.add(new Vector(centerX, centerY - z, centerZ + y));
|
||||||
|
blocks.add(new Vector(centerX, centerY - z, centerZ - y));
|
||||||
|
|
||||||
|
if (d < 0) {
|
||||||
|
d += CIRCLE_STEP_MULTIPLIER * z + CIRCLE_INNER_INCREMENT;
|
||||||
|
} else {
|
||||||
|
d += CIRCLE_STEP_MULTIPLIER * (z - y) + CIRCLE_OUTER_INCREMENT;
|
||||||
|
y--;
|
||||||
|
}
|
||||||
|
z++;
|
||||||
|
}
|
||||||
|
|
||||||
|
return blocks;
|
||||||
|
}
|
||||||
|
},
|
||||||
|
FLAT(-5) {
|
||||||
|
@Override
|
||||||
|
public List<Vector> getPoints(Vector center, int radius) {
|
||||||
|
final double x = center.getX();
|
||||||
|
final double y = center.getY();
|
||||||
|
final double z = center.getZ();
|
||||||
|
|
||||||
|
return List.of(
|
||||||
|
new Vector(x, y, z - 1),
|
||||||
|
new Vector(x, y, z),
|
||||||
|
new Vector(x, y, z + 1)
|
||||||
|
);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
private static final double HALF_OFFSET = 0.5;
|
||||||
|
private static final int CIRCLE_START_D = 3;
|
||||||
|
private static final int CIRCLE_STEP_MULTIPLIER = 4;
|
||||||
|
private static final int CIRCLE_INNER_INCREMENT = 6;
|
||||||
|
private static final int CIRCLE_OUTER_INCREMENT = 10;
|
||||||
|
|
||||||
|
public final int heightOffset;
|
||||||
|
|
||||||
|
ElytraPointType(int heightOffset) {
|
||||||
|
this.heightOffset = heightOffset;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Expands a spline center point into display block positions.
|
||||||
|
*
|
||||||
|
* @param center the spline point to expand.
|
||||||
|
* @param radius the radius of the pipe.
|
||||||
|
* @return the block positions to display.
|
||||||
|
*/
|
||||||
|
public abstract List<Vector> getPoints(Vector center, int radius);
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,200 @@
|
||||||
|
package dev.loki.loparkour.elytra.section;
|
||||||
|
|
||||||
|
import java.util.ArrayList;
|
||||||
|
import java.util.HashMap;
|
||||||
|
import java.util.HashSet;
|
||||||
|
import java.util.List;
|
||||||
|
import java.util.Map;
|
||||||
|
import java.util.Random;
|
||||||
|
import java.util.Set;
|
||||||
|
import org.apache.commons.math3.analysis.interpolation.SplineInterpolator;
|
||||||
|
import org.apache.commons.math3.analysis.polynomials.PolynomialSplineFunction;
|
||||||
|
import org.bukkit.Material;
|
||||||
|
import org.bukkit.World;
|
||||||
|
import org.bukkit.block.BlockState;
|
||||||
|
import org.bukkit.entity.Player;
|
||||||
|
import org.bukkit.util.Vector;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Represents a section of the total parkour of size KNOTS.
|
||||||
|
*
|
||||||
|
* <p>Stores {@link Vector} coordinates instead of Bukkit Block instances to avoid
|
||||||
|
* pinning chunks in memory. Display blocks are sent client-side via
|
||||||
|
* {@link Player#sendBlockChanges} and undone in {@link #clear(Player)}.</p>
|
||||||
|
*/
|
||||||
|
public class ElytraSection {
|
||||||
|
|
||||||
|
private static final int KNOTS = 5;
|
||||||
|
private static final int EXTRA_POINTS_OFFSET = 1;
|
||||||
|
private static final int CHUNK_SHIFT = 4;
|
||||||
|
|
||||||
|
private final ElytraKnotDirector director;
|
||||||
|
private final SplineInterpolator interpolator;
|
||||||
|
private final List<Vector> knots;
|
||||||
|
private final List<Vector> points;
|
||||||
|
private final boolean ascending;
|
||||||
|
|
||||||
|
private Map<Integer, Set<Vector>> blockMap;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @param start first knot of the section.
|
||||||
|
* @param random source of randomness for the knot offsets.
|
||||||
|
* @param verticalBias vertical bias of the knot director.
|
||||||
|
*/
|
||||||
|
public ElytraSection(Vector start, Random random, int verticalBias) {
|
||||||
|
director = new ElytraKnotDirector(random, verticalBias);
|
||||||
|
interpolator = new SplineInterpolator();
|
||||||
|
knots = generateKnots(start);
|
||||||
|
points = generatePoints();
|
||||||
|
ascending = verticalBias > 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @param idx index of the knot.
|
||||||
|
* @return the knot at the given index.
|
||||||
|
*/
|
||||||
|
public Vector getKnot(int idx) {
|
||||||
|
return knots.get(idx);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @param idx index of the point.
|
||||||
|
* @return the spline point at the given index.
|
||||||
|
*/
|
||||||
|
public Vector getPoint(int idx) {
|
||||||
|
return points.get(idx);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @param pos position to test against.
|
||||||
|
* @param idx index of the knot.
|
||||||
|
* @param distance maximum allowed distance.
|
||||||
|
* @return whether the position is near the given knot.
|
||||||
|
*/
|
||||||
|
public boolean isNearKnot(Vector pos, int idx, double distance) {
|
||||||
|
return knots.get(idx).distanceSquared(pos) < distance * distance;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @param pos position to test against.
|
||||||
|
* @param idx index of the point.
|
||||||
|
* @param distance maximum allowed distance.
|
||||||
|
* @return whether the position is near the given point.
|
||||||
|
*/
|
||||||
|
public boolean isNearPoint(Vector pos, int idx, double distance) {
|
||||||
|
return points.get(idx).distanceSquared(pos) < distance * distance;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @return a copy of the first knot.
|
||||||
|
*/
|
||||||
|
public Vector beginning() {
|
||||||
|
return knots.get(0).clone();
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @return a copy of the last knot.
|
||||||
|
*/
|
||||||
|
public Vector end() {
|
||||||
|
return knots.get(knots.size() - 1).clone();
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @return true when this section was generated with an upward vertical bias.
|
||||||
|
*/
|
||||||
|
public boolean isAscending() {
|
||||||
|
return ascending;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Generates the block positions for this section synchronously, grouped by chunk X.
|
||||||
|
* Results are also stashed in {@link #blockMap} so {@link #clear(Player)} can undo them.
|
||||||
|
*
|
||||||
|
* @param pointType how each spline point expands into display blocks.
|
||||||
|
* @param radius radius of the pipe.
|
||||||
|
* @return the generated block map, chunk X to block positions.
|
||||||
|
*/
|
||||||
|
public Map<Integer, Set<Vector>> generateBlocks(ElytraPointType pointType, int radius) {
|
||||||
|
final Map<Integer, Set<Vector>> currentBlocks = new HashMap<>();
|
||||||
|
|
||||||
|
for (final Vector point : points) {
|
||||||
|
final List<Vector> pts = pointType.getPoints(point, radius);
|
||||||
|
for (final Vector pt : pts) {
|
||||||
|
final int cx = pt.getBlockX() >> CHUNK_SHIFT;
|
||||||
|
currentBlocks.computeIfAbsent(cx, k -> new HashSet<>()).add(pt);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
blockMap = currentBlocks;
|
||||||
|
return currentBlocks;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Sends AIR packets for every position this section had drawn on the client,
|
||||||
|
* then drops the stored map so the GC can reclaim it. MUST be called on the
|
||||||
|
* main thread.
|
||||||
|
*
|
||||||
|
* @param player the player whose client should be updated.
|
||||||
|
*/
|
||||||
|
public void clear(Player player) {
|
||||||
|
final Map<Integer, Set<Vector>> localBlocks = blockMap;
|
||||||
|
blockMap = null;
|
||||||
|
if (localBlocks == null) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
final World world = player.getWorld();
|
||||||
|
for (final Map.Entry<Integer, Set<Vector>> entry : localBlocks.entrySet()) {
|
||||||
|
final Set<Vector> vectors = entry.getValue();
|
||||||
|
if (vectors.isEmpty()) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
final List<BlockState> states = new ArrayList<>(vectors.size());
|
||||||
|
for (final Vector v : vectors) {
|
||||||
|
final BlockState state = v.toLocation(world).getBlock().getState();
|
||||||
|
state.setType(Material.AIR);
|
||||||
|
states.add(state);
|
||||||
|
}
|
||||||
|
player.sendBlockChanges(states);
|
||||||
|
}
|
||||||
|
localBlocks.clear();
|
||||||
|
}
|
||||||
|
|
||||||
|
private List<Vector> generatePoints() {
|
||||||
|
final List<Vector> splineKnots = new ArrayList<>(knots);
|
||||||
|
splineKnots.add(0, knots.get(0).clone().subtract(new Vector(EXTRA_POINTS_OFFSET, 0, 0)));
|
||||||
|
splineKnots.add(knots.get(knots.size() - 1).clone().add(new Vector(EXTRA_POINTS_OFFSET, 0, 0)));
|
||||||
|
|
||||||
|
final double[] xs = new double[splineKnots.size()];
|
||||||
|
final double[] ys = new double[splineKnots.size()];
|
||||||
|
final double[] zs = new double[splineKnots.size()];
|
||||||
|
for (int i = 0; i < splineKnots.size(); i++) {
|
||||||
|
xs[i] = splineKnots.get(i).getX();
|
||||||
|
ys[i] = splineKnots.get(i).getY();
|
||||||
|
zs[i] = splineKnots.get(i).getZ();
|
||||||
|
}
|
||||||
|
|
||||||
|
final PolynomialSplineFunction splineY = interpolator.interpolate(xs, ys);
|
||||||
|
final PolynomialSplineFunction splineZ = interpolator.interpolate(xs, zs);
|
||||||
|
|
||||||
|
final List<Vector> result = new ArrayList<>();
|
||||||
|
final int actualXFirst = (int) xs[EXTRA_POINTS_OFFSET];
|
||||||
|
final int actualXLast = (int) xs[xs.length - 1 - EXTRA_POINTS_OFFSET];
|
||||||
|
for (int x = actualXFirst; x <= actualXLast; x++) {
|
||||||
|
result.add(new Vector(x, (int) splineY.value(x), (int) splineZ.value(x)));
|
||||||
|
}
|
||||||
|
|
||||||
|
return result;
|
||||||
|
}
|
||||||
|
|
||||||
|
private List<Vector> generateKnots(Vector start) {
|
||||||
|
final List<Vector> result = new ArrayList<>(KNOTS);
|
||||||
|
result.add(start);
|
||||||
|
|
||||||
|
for (int i = 0; i < KNOTS - 1; i++) {
|
||||||
|
result.add(result.get(result.size() - 1).clone().add(director.nextOffset()));
|
||||||
|
}
|
||||||
|
|
||||||
|
return result;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -1,6 +1,7 @@
|
||||||
package dev.loki.loparkour.mode.base;
|
package dev.loki.loparkour.mode.base;
|
||||||
|
|
||||||
import dev.loki.loparkour.api.core.Registry;
|
import dev.loki.loparkour.api.core.Registry;
|
||||||
|
import dev.loki.loparkour.elytra.mode.ElytraMode;
|
||||||
import dev.loki.loparkour.mode.impl.barrier.InvisibleBarrierMode;
|
import dev.loki.loparkour.mode.impl.barrier.InvisibleBarrierMode;
|
||||||
import dev.loki.loparkour.mode.impl.multi.CoopMode;
|
import dev.loki.loparkour.mode.impl.multi.CoopMode;
|
||||||
import dev.loki.loparkour.mode.impl.multi.RaceMode;
|
import dev.loki.loparkour.mode.impl.multi.RaceMode;
|
||||||
|
|
@ -21,6 +22,7 @@ public final class Modes {
|
||||||
public static RaceMode RACE;
|
public static RaceMode RACE;
|
||||||
public static CoopMode COOP;
|
public static CoopMode COOP;
|
||||||
public static GravityShiftMode GRAVITY_SHIFT;
|
public static GravityShiftMode GRAVITY_SHIFT;
|
||||||
|
public static ElytraMode ELYTRA;
|
||||||
|
|
||||||
public static void init() {
|
public static void init() {
|
||||||
DEFAULT = (DefaultMode) Registry.getMode("default");
|
DEFAULT = (DefaultMode) Registry.getMode("default");
|
||||||
|
|
@ -30,5 +32,6 @@ public final class Modes {
|
||||||
RACE = (RaceMode) Registry.getMode("race");
|
RACE = (RaceMode) Registry.getMode("race");
|
||||||
COOP = (CoopMode) Registry.getMode("coop");
|
COOP = (CoopMode) Registry.getMode("coop");
|
||||||
GRAVITY_SHIFT = (GravityShiftMode) Registry.getMode("gravity-shift");
|
GRAVITY_SHIFT = (GravityShiftMode) Registry.getMode("gravity-shift");
|
||||||
|
ELYTRA = (ElytraMode) Registry.getMode("elytra");
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -45,6 +45,14 @@ play:
|
||||||
name: "&#FF44FF&lInvisible Barrier"
|
name: "&#FF44FF&lInvisible Barrier"
|
||||||
lore: "񢩈Invisible blocks with||񢩈colorful particle outlines"
|
lore: "񢩈Invisible blocks with||񢩈colorful particle outlines"
|
||||||
|
|
||||||
|
elytra:
|
||||||
|
name: "&#FFFFFFElytra"
|
||||||
|
|
||||||
|
elytra:
|
||||||
|
material: "elytra"
|
||||||
|
name: "�D4FF&lElytra"
|
||||||
|
lore: "񢩈Fly through rings||񢩈on your elytra."
|
||||||
|
|
||||||
spectator:
|
spectator:
|
||||||
name: "&#FFFFFFSpectator"
|
name: "&#FFFFFFSpectator"
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -44,6 +44,14 @@ play:
|
||||||
name: "&#FF44FF&lНевидимый барьер"
|
name: "&#FF44FF&lНевидимый барьер"
|
||||||
lore: "񢩈Невидимые блоки с||񢩈цветными контурами из частиц"
|
lore: "񢩈Невидимые блоки с||񢩈цветными контурами из частиц"
|
||||||
|
|
||||||
|
elytra:
|
||||||
|
name: "&#FFFFFFЭлитры"
|
||||||
|
|
||||||
|
elytra:
|
||||||
|
material: "elytra"
|
||||||
|
name: "�D4FF&lЭлитры"
|
||||||
|
lore: "񢩈Летай сквозь кольца||񢩈на элитрах."
|
||||||
|
|
||||||
spectator:
|
spectator:
|
||||||
name: "&#FFFFFFНаблюдатель"
|
name: "&#FFFFFFНаблюдатель"
|
||||||
|
|
||||||
|
|
|
||||||
Loading…
Add table
Add a link
Reference in a new issue