diff --git a/TODO.md b/TODO.md index eadfad9..eb602eb 100644 --- a/TODO.md +++ b/TODO.md @@ -241,10 +241,10 @@ ### 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`). -- **128. Elytra — клиент-сайд рендер.** `ClientBlockChanger` НЕ ставит блоки в мир: очередь `Map>`, каждый тик шлёт игроку `player.sendBlockChanges(states)` (MultiBlockChange) только для чанков вперёд по X (limit min(viewDistance,8)). Пакет `ElytraBlockChanger` на Java. -- **129. Elytra — классы:** `ElytraSection`+`ElytraKnotDirector`+`ElytraPointType`, `ElytraGenerator extends ParkourGenerator` (СВОЙ `tick()`, переопределённый `getMode()`, свой фолл-детект — не полагаться на `LifecycleTickManager.checkPlayerFall`), режимы `ElytraMode/Default/SpeedDemon/MinSpeed/TimeTrial/Close/Obstacle` (по одному подклассу генератора), раздача элитр в слот нагрудника + `ParkourHotbar`, `PreviousData` переиспользуем. -- **130. Elytra — зависимости/мир.** Добавить Apache Commons Math (`SplineInterpolator`) или заменить на Catmull-Rom. Отдельный мир не нужен: труба клиент-сайд, наш мир void; подобрать высоту/порог ASCEND под наши zone-границы. Скор в double (наши `Score.score` int — масштабировать или добавить float-вариант). Сложность: **S** базовый, **M** режимы+бусты, **L** Obstacle+настройки+стили+награды. +- **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>`, `player.sendBlockChanges(states)` по чанкам, forward limit min(vd,8), flush/clear. Секции хранят Vector (без пиннинга чанков). +- **129. Elytra — классы.** ✅ Базовый: ElytraSection/KnotDirector/PointType/ClientBlockChanger + ElytraGenerator + ElytraMode. Осталось (M): подклассы-режимы SpeedDemon/MinSpeed/TimeTrial/Close/Obstacle, фейерверк-буст, раздача элитр через PreviousData (сейчас элитры выдаются в слот нагрудника при generateFirst, глiding=true). +- **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). diff --git a/build.gradle.kts b/build.gradle.kts index fbb944e..0885b7f 100644 --- a/build.gradle.kts +++ b/build.gradle.kts @@ -93,6 +93,7 @@ dependencies { implementation("org.bstats:bstats-bukkit:3.2.1") implementation("com.zaxxer:HikariCP:5.1.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-bukkit:7.3.6") @@ -122,6 +123,7 @@ tasks.shadowJar { relocate("io.papermc.lib", "dev.loki.loparkour.lib.paperlib") relocate("com.google.gson", "dev.loki.loparkour.lib.gson") 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 // minimize { diff --git a/src/main/java/dev/loki/loparkour/bootstrap/PluginBootstrap.java b/src/main/java/dev/loki/loparkour/bootstrap/PluginBootstrap.java index e7c28e6..1638c64 100644 --- a/src/main/java/dev/loki/loparkour/bootstrap/PluginBootstrap.java +++ b/src/main/java/dev/loki/loparkour/bootstrap/PluginBootstrap.java @@ -12,6 +12,7 @@ import dev.loki.loparkour.hook.papi.PAPIHook; import dev.loki.loparkour.listener.gameplay.ParkourRestrictionListener; import dev.loki.loparkour.listener.schematic.SchematicWandListener; 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.impl.barrier.InvisibleBarrierMode; import dev.loki.loparkour.mode.impl.multi.CoopMode; @@ -76,6 +77,7 @@ public final class PluginBootstrap { Registry.register(new InvisibleBarrierMode()); Registry.register(new CoopMode()); Registry.register(new GravityShiftMode()); + Registry.register(new ElytraMode()); Modes.init(); } diff --git a/src/main/java/dev/loki/loparkour/elytra/generator/ElytraGenerator.java b/src/main/java/dev/loki/loparkour/elytra/generator/ElytraGenerator.java new file mode 100644 index 0000000..28f4c4a --- /dev/null +++ b/src/main/java/dev/loki/loparkour/elytra/generator/ElytraGenerator.java @@ -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 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 target = null; + for (final Map.Entry 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))); + } +} diff --git a/src/main/java/dev/loki/loparkour/elytra/mode/ElytraMode.java b/src/main/java/dev/loki/loparkour/elytra/mode/ElytraMode.java new file mode 100644 index 0000000..03188ad --- /dev/null +++ b/src/main/java/dev/loki/loparkour/elytra/mode/ElytraMode.java @@ -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); + } +} diff --git a/src/main/java/dev/loki/loparkour/elytra/section/ClientBlockChanger.java b/src/main/java/dev/loki/loparkour/elytra/section/ClientBlockChanger.java new file mode 100644 index 0000000..836f592 --- /dev/null +++ b/src/main/java/dev/loki/loparkour/elytra/section/ClientBlockChanger.java @@ -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> toChange = new HashMap<>(); + + /** + * Adds chunked block positions to the queue. + * + * @param blocks chunk X to block positions to add. + */ + public synchronized void queue(Map> blocks) { + for (final Map.Entry> 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>> toSend = new ArrayList<>(); + synchronized (this) { + final Iterator>> iterator = toChange.entrySet().iterator(); + while (iterator.hasNext()) { + final Map.Entry> 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>> toSend = new ArrayList<>(); + synchronized (this) { + for (final Map.Entry> 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>> batches, Material material) { + final World world = player.getWorld(); + for (final Map.Entry> batch : batches) { + final Set vectors = batch.getValue(); + if (vectors.isEmpty()) { + continue; + } + final List 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); + } + } +} diff --git a/src/main/java/dev/loki/loparkour/elytra/section/ElytraKnotDirector.java b/src/main/java/dev/loki/loparkour/elytra/section/ElytraKnotDirector.java new file mode 100644 index 0000000..37fcca8 --- /dev/null +++ b/src/main/java/dev/loki/loparkour/elytra/section/ElytraKnotDirector.java @@ -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. + * + *

The Y bias is parameterised: default is -15 (gentle descent). Pass a positive + * value (e.g. +15) to make the pipe climb instead.

+ */ +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); + } +} diff --git a/src/main/java/dev/loki/loparkour/elytra/section/ElytraPointType.java b/src/main/java/dev/loki/loparkour/elytra/section/ElytraPointType.java new file mode 100644 index 0000000..f4ec168 --- /dev/null +++ b/src/main/java/dev/loki/loparkour/elytra/section/ElytraPointType.java @@ -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 getPoints(Vector center, int radius) { + if (radius == 0) { + return List.of(center); + } + + final List 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 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 getPoints(Vector center, int radius); +} diff --git a/src/main/java/dev/loki/loparkour/elytra/section/ElytraSection.java b/src/main/java/dev/loki/loparkour/elytra/section/ElytraSection.java new file mode 100644 index 0000000..9234d5a --- /dev/null +++ b/src/main/java/dev/loki/loparkour/elytra/section/ElytraSection.java @@ -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. + * + *

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)}.

+ */ +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 knots; + private final List points; + private final boolean ascending; + + private Map> 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> generateBlocks(ElytraPointType pointType, int radius) { + final Map> currentBlocks = new HashMap<>(); + + for (final Vector point : points) { + final List 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> localBlocks = blockMap; + blockMap = null; + if (localBlocks == null) { + return; + } + + final World world = player.getWorld(); + for (final Map.Entry> entry : localBlocks.entrySet()) { + final Set vectors = entry.getValue(); + if (vectors.isEmpty()) { + continue; + } + final List 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 generatePoints() { + final List 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 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 generateKnots(Vector start) { + final List 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; + } +} diff --git a/src/main/java/dev/loki/loparkour/mode/base/Modes.java b/src/main/java/dev/loki/loparkour/mode/base/Modes.java index 56088a3..681d820 100644 --- a/src/main/java/dev/loki/loparkour/mode/base/Modes.java +++ b/src/main/java/dev/loki/loparkour/mode/base/Modes.java @@ -1,6 +1,7 @@ package dev.loki.loparkour.mode.base; 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.multi.CoopMode; import dev.loki.loparkour.mode.impl.multi.RaceMode; @@ -21,6 +22,7 @@ public final class Modes { public static RaceMode RACE; public static CoopMode COOP; public static GravityShiftMode GRAVITY_SHIFT; + public static ElytraMode ELYTRA; public static void init() { DEFAULT = (DefaultMode) Registry.getMode("default"); @@ -30,5 +32,6 @@ public final class Modes { RACE = (RaceMode) Registry.getMode("race"); COOP = (CoopMode) Registry.getMode("coop"); GRAVITY_SHIFT = (GravityShiftMode) Registry.getMode("gravity-shift"); + ELYTRA = (ElytraMode) Registry.getMode("elytra"); } } diff --git a/src/main/resources/locales/en.yml b/src/main/resources/locales/en.yml index e9b16d6..da29cee 100644 --- a/src/main/resources/locales/en.yml +++ b/src/main/resources/locales/en.yml @@ -45,6 +45,14 @@ play: name: "&#FF44FF&lInvisible Barrier" lore: "񢩈Invisible blocks with||񢩈colorful particle outlines" + elytra: + name: "&#FFFFFFElytra" + + elytra: + material: "elytra" + name: "�D4FF&lElytra" + lore: "񢩈Fly through rings||񢩈on your elytra." + spectator: name: "&#FFFFFFSpectator" diff --git a/src/main/resources/locales/ru.yml b/src/main/resources/locales/ru.yml index 8e4fafa..ca31c83 100644 --- a/src/main/resources/locales/ru.yml +++ b/src/main/resources/locales/ru.yml @@ -44,6 +44,14 @@ play: name: "&#FF44FF&lНевидимый барьер" lore: "񢩈Невидимые блоки с||񢩈цветными контурами из частиц" + elytra: + name: "&#FFFFFFЭлитры" + + elytra: + material: "elytra" + name: "�D4FF&lЭлитры" + lore: "񢩈Летай сквозь кольца||񢩈на элитрах." + spectator: name: "&#FFFFFFНаблюдатель"