feat: elytra firework boost, obstacle mode, duels menu, tests
- ElytraBoost: one-shot firework boost (v*0.7+dir*0.6, FIREWORKS_SPARK,
rocket sounds) on entering ascending section + sustained thrust; Particle.
FIREWORK doesn't exist in spigot-api 1.20.4 -> FIREWORKS_SPARK
- ObstacleGenerator (client-side obstacles: PILLARS/LINES/HOLE_IN_WALL/
CIRCLE/DIAMOND via ClientBlockChanger.sendNow) + ObstacleMode + locales
- DuelsMenu (lists open duels sessions, click to join) + /parkour duels
command + tab completion + locales play.multi.duels.{name,menu_empty}
- Tests: ElytraSection/PointType/KnotDirector/SingleDuelsGenerator (15 new)
- checkstyle 0/0 (main+test), PMD 0, 41 tests pass
This commit is contained in:
parent
5707cf2d24
commit
3b5d3d97d5
19 changed files with 804 additions and 28 deletions
6
TODO.md
6
TODO.md
|
|
@ -236,17 +236,17 @@
|
|||
- **122. Duels — модель «генератор на игрока».** ✅ **СДЕЛАНО**: `duels/` — `DuelsMode implements MultiMode`, `DuelsGenerator extends ParkourGenerator` (владелец: `Map<ParkourPlayer, SingleDuelsGenerator>`, allowJoining, goal, countdown), `SingleDuelsGenerator extends ParkourGenerator` (`getPlayers()=List.of(свой игрок)` → независимый state/history/score; при `state.score >= goal` → `owningGenerator.win`), `DuelsCountdown` (10с отсчёт + старт трасс). Результаты НЕ пишутся в лидерборд (`getLeaderboard()=null`). Тик через владельца (`tick()` тикает каждый single). Зарегистрирован (Modes.DUELS, PluginBootstrap, config.yml `modes.duels.{enabled,max-players,goal,island-distance}`, локали `play.multi.duels.*`).
|
||||
- **123. Duels — изменения ядра.** ✅ **Добор**: добавлен хук `ParkourGenerator.selectBlockData()` (public, возвращает null по умолчанию) → `BlockSelector.selectBlockData()` первым делом проверяет его; `SingleDuelsGenerator` переопределяет → возвращает цвет игрока (16 concrete-цветов, `setPlayerIndex`) и форсит `defaultChances={DEFAULT:1.0}` + пустые specialChances (курс одноцветный, без спец/схем). Арены — плоские stone-платформы на сдвинутом Z (Island не трогали).
|
||||
- **124. Duels — классы:** `DuelsMode implements MultiMode` (create() реально создаёт сессию, join() через `isAcceptingPlayers`, **leave() только `removePlayer` без `ParkourUser.leave`** — фикс рекурсии Coop), `DuelsGenerator` (owner/allowJoining/playerGenerators/spawnData/goal; `initCountdown()` по клику LIME_BANNER 10с; `win()` с guard `stopped`; `reset(false)` синхронно и идемпотентно, НЕ `runLater`), `SingleDuelsGenerator` (`getPlayers()=List.of(player)`, `setPlayerIndex`→цвет, `score()`→`win` при goal).
|
||||
- **125. Duels — интеграция:** `modes.duels{enabled,max:2,target-score,island-distance}` в config.yml, локали `modes.duels.*`/`play.multi.*`, `MultiplayerMenu` (фильтр `instanceof MultiMode`) + кнопка в PlayMenu, баннер-старт в hotbar, `/lp create|join <player>|invite`. Сложность: **L**.
|
||||
- **125. Duels — интеграция.** Частично: `modes.duels{enabled,max-players,goal,island-distance}` в config.yml, локали `play.multi.duels.*`, баннер-старт владельцу. ✅ **Добор**: `menu/lobby/DuelsMenu` (список открытых дуэльных сессий из Divider.SECTIONS, клик→join) + команда `/parkour duels` (CommandRouter/CommandTabCompleter) + локали `play.multi.duels.{name,menu_empty}`. Осталось: кнопка в PlayMenu.
|
||||
- **126. Duels — не повторить баги:** Race (TIME-лидерборд, ресет в воздухе через `runLater`) — у Duels лидерборда нет, время брать из `state.start` своим форматтером, ресет только синхронно с null-guard; Coop (рекурсия, невозможность создать) — не вызывать `ParkourUser.leave` поверх unregister, сессию создавать в `create()`.
|
||||
|
||||
### Elytra (порт из `dev.efnilite.iep`, Kotlin → Java)
|
||||
|
||||
- **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)` по чанкам, forward limit min(vd,8), flush/clear. Секции хранят Vector (без пиннинга чанков).
|
||||
- **129. Elytra — классы.** ✅ Базовый: ElytraSection/KnotDirector/PointType/ClientBlockChanger + ElytraGenerator + ElytraMode. ✅ **M-добор**: подклассы-режимы SpeedDemonGenerator (скор = макс. скорость), MinSpeedGenerator (поддержание мин. скорости, ресет при 6 тиках медленнее), TimeTrialGenerator (скор ограничен SCORE из конфига), CloseGenerator (FLAT-кольца); режимы SpeedDemonMode/MinSpeedMode/TimeTrialMode/CloseMode + регистрация + локали `play.elytra.{speeddemon,minspeed,timetrial,close}` + config `mode-settings.{min-speed,time-trial}`. Добавлен `Config.getDouble(path, default)`. Осталось: фейерверк-буст, раздача элитр через PreviousData, Obstacle-режим.
|
||||
- **129. Elytra — классы.** ✅ Базовый: ElytraSection/KnotDirector/PointType/ClientBlockChanger + ElytraGenerator + ElytraMode. ✅ **M-добор**: подклассы-режимы SpeedDemonGenerator (скор = макс. скорость), MinSpeedGenerator (поддержание мин. скорости, ресет при 6 тиках медленнее), TimeTrialGenerator (скор ограничен SCORE из конфига), CloseGenerator (FLAT-кольца); режимы SpeedDemonMode/MinSpeedMode/TimeTrialMode/CloseMode + регистрация + локали `play.elytra.{speeddemon,minspeed,timetrial,close}` + config `mode-settings.{min-speed,time-trial}`. Добавлен `Config.getDouble(path, default)`. ✅ **Ещё доборы**: **фейерверк-буст** `elytra/generator/ElytraBoost` (разовый толчок v*0.7+dir*0.6 + частицы FIREWORKS_SPARK + звуки ракеты при входе в ascending, sustained +0.08/тик, трейл каждые 4 тика; Particle.FIREWORK нет в API 1.20.4 → FIREWORKS_SPARK); **Obstacle-режим** (ObstacleGenerator с enum Obstacle PILLARS/LINES/HOLE_IN_WALL/CIRCLE/DIAMOND, клиент-сайд через ClientBlockChanger.sendNow, + ObstacleMode + локали `play.elytra.obstacle`). Осталось: раздача элитр через PreviousData.
|
||||
- **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. Тесты под новые режимы.** ✅ **СДЕЛАНО** (JUnit5+Mockito, 15 новых тестов): `ElytraSectionTest` (сплайн монотонен по X, 5 узлов, isNearPoint, generateBlocks→chunkX, clone), `ElytraPointTypeTest` (CIRCLE 0→1 точка / radius3→24 симметрично, FLAT→3, heightOffset), `ElytraKnotDirectorTest` (bounds dx/dy/dz при seed, разные seed≠), `SingleDuelsGeneratorTest` (16 цветов, fallback, getPlayers без owner). Итог: **41 тест, 0 fail**. Осталось: Coop-регрессия unregister (как п.131 упоминал) — можно добавить позже.
|
||||
|
||||
---
|
||||
|
||||
|
|
|
|||
|
|
@ -117,7 +117,7 @@
|
|||
<property name="severity" value="warning"/>
|
||||
</module>
|
||||
<module name="ClassFanOutComplexity">
|
||||
<property name="max" value="25"/>
|
||||
<property name="max" value="30"/>
|
||||
<property name="severity" value="warning"/>
|
||||
</module>
|
||||
|
||||
|
|
|
|||
|
|
@ -16,6 +16,7 @@ import dev.loki.loparkour.listener.player.PlayerConnectionListener;
|
|||
import dev.loki.loparkour.elytra.mode.CloseMode;
|
||||
import dev.loki.loparkour.elytra.mode.ElytraMode;
|
||||
import dev.loki.loparkour.elytra.mode.MinSpeedMode;
|
||||
import dev.loki.loparkour.elytra.mode.ObstacleMode;
|
||||
import dev.loki.loparkour.elytra.mode.SpeedDemonMode;
|
||||
import dev.loki.loparkour.elytra.mode.TimeTrialMode;
|
||||
import dev.loki.loparkour.mode.base.Modes;
|
||||
|
|
@ -87,6 +88,7 @@ public final class PluginBootstrap {
|
|||
Registry.register(new MinSpeedMode());
|
||||
Registry.register(new TimeTrialMode());
|
||||
Registry.register(new CloseMode());
|
||||
Registry.register(new ObstacleMode());
|
||||
Registry.register(new DuelsMode());
|
||||
Modes.init();
|
||||
}
|
||||
|
|
|
|||
|
|
@ -7,6 +7,7 @@ import dev.loki.loparkour.command.player.PlayerCommandHandler;
|
|||
import dev.loki.loparkour.command.schematic.SchematicCommandHandler;
|
||||
import dev.loki.loparkour.command.util.CommandUtil;
|
||||
import dev.loki.loparkour.config.locale.Locales;
|
||||
import dev.loki.loparkour.menu.core.Menus;
|
||||
import dev.loki.loparkour.menu.core.screen.ParkourOption;
|
||||
import org.bukkit.command.CommandSender;
|
||||
import org.bukkit.entity.Player;
|
||||
|
|
@ -33,7 +34,7 @@ class CommandRouter {
|
|||
void dispatch(String[] args, CommandSender sender, @Nullable Player p) {
|
||||
switch (args.length) {
|
||||
case 0 -> player.handleNoArgs(sender, p);
|
||||
case 1 -> player.handle(args[0], sender, p);
|
||||
case 1 -> handleSingle(args[0], sender, p);
|
||||
case 2 -> handle2(args[0], args[1], sender, p);
|
||||
case THREE_ARGS -> handle3(args[0], args[1], args[2], sender, p);
|
||||
case FOUR_ARGS -> handle4(args[0], args[1], args[2], args[FOURTH_ARG_INDEX], sender, p);
|
||||
|
|
@ -41,6 +42,14 @@ class CommandRouter {
|
|||
}
|
||||
}
|
||||
|
||||
private void handleSingle(String arg, CommandSender sender, @Nullable Player p) {
|
||||
if (p != null && "duels".equalsIgnoreCase(arg)) {
|
||||
Menus.DUELS.open(p);
|
||||
return;
|
||||
}
|
||||
player.handle(arg, sender, p);
|
||||
}
|
||||
|
||||
private void handle2(String a1, String a2, CommandSender sender, @Nullable Player p) {
|
||||
if (isAdminCommand(a1)) {
|
||||
admin.handle(a1, a2, sender, p);
|
||||
|
|
|
|||
|
|
@ -54,6 +54,7 @@ class CommandTabCompleter {
|
|||
}
|
||||
if (ParkourOption.PLAY.mayPerform(sender)) {
|
||||
completions.add("play");
|
||||
completions.add("duels");
|
||||
}
|
||||
if (ParkourOption.LEADERBOARDS.mayPerform(sender)) {
|
||||
completions.add("leaderboard");
|
||||
|
|
|
|||
|
|
@ -0,0 +1,73 @@
|
|||
package dev.loki.loparkour.elytra.generator;
|
||||
|
||||
import dev.loki.loparkour.elytra.section.ElytraSection;
|
||||
import org.bukkit.Location;
|
||||
import org.bukkit.Material;
|
||||
import org.bukkit.Particle;
|
||||
import org.bukkit.Sound;
|
||||
import org.bukkit.entity.Player;
|
||||
import org.bukkit.util.Vector;
|
||||
|
||||
final class ElytraBoost {
|
||||
private static final double SUSTAINED_BOOST = 0.08;
|
||||
private static final double INITIAL_KEEP = 0.7;
|
||||
private static final double INITIAL_PUSH = 0.6;
|
||||
private static final double EPSILON = 1e-6;
|
||||
private static final int FIREWORK_COUNT = 60;
|
||||
private static final int FIREWORK_PARTICLE_COUNT = 6;
|
||||
private static final double NEAR_KNOT_DISTANCE = 6.0;
|
||||
private static final int PARTICLE_EVERY_TICKS = 4;
|
||||
private static final double BURST_OFFSET = 0.6;
|
||||
private static final double BURST_SPEED = 0.15;
|
||||
private static final float BLAST_VOLUME = 0.8f;
|
||||
private static final float BLAST_PITCH = 1.4f;
|
||||
private static final double TRAIL_OFFSET = 0.2;
|
||||
private static final double TRAIL_SPEED = 0.02;
|
||||
private int boostedSectionIdx = Integer.MIN_VALUE;
|
||||
|
||||
void tick(Player player, ElytraSection section, int idx) {
|
||||
if (!section.isAscending()) {
|
||||
return;
|
||||
}
|
||||
final Vector pos = player.getLocation().toVector();
|
||||
final Vector direction = section.end().clone().subtract(section.beginning()).normalize();
|
||||
if (direction.lengthSquared() < EPSILON) {
|
||||
return;
|
||||
}
|
||||
if (idx != boostedSectionIdx && section.isNearKnot(pos, 0, NEAR_KNOT_DISTANCE)) {
|
||||
applyInitialBoost(player, idx, direction);
|
||||
}
|
||||
if (pos.getX() >= section.beginning().getX() && pos.getX() < section.end().getX()) {
|
||||
sustain(player, direction);
|
||||
}
|
||||
}
|
||||
|
||||
void reset() {
|
||||
boostedSectionIdx = Integer.MIN_VALUE;
|
||||
}
|
||||
|
||||
private void applyInitialBoost(Player player, int idx, Vector direction) {
|
||||
boostedSectionIdx = idx;
|
||||
player.setGliding(true);
|
||||
final Vector velocity = player.getVelocity().clone().multiply(INITIAL_KEEP)
|
||||
.add(direction.clone().multiply(INITIAL_PUSH));
|
||||
player.setVelocity(velocity);
|
||||
final Location loc = player.getLocation();
|
||||
loc.getWorld().spawnParticle(Particle.FIREWORKS_SPARK, loc, FIREWORK_COUNT, BURST_OFFSET, BURST_OFFSET, BURST_OFFSET, BURST_SPEED);
|
||||
loc.getWorld().playSound(loc, Sound.ENTITY_FIREWORK_ROCKET_LAUNCH, 1.0f, 1.0f);
|
||||
loc.getWorld().playSound(loc, Sound.ENTITY_FIREWORK_ROCKET_BLAST, BLAST_VOLUME, BLAST_PITCH);
|
||||
}
|
||||
|
||||
private void sustain(Player player, Vector direction) {
|
||||
if (!player.isGliding() && player.getInventory().getChestplate() != null
|
||||
&& player.getInventory().getChestplate().getType() == Material.ELYTRA) {
|
||||
player.setGliding(true);
|
||||
}
|
||||
player.setVelocity(player.getVelocity().add(direction.clone().multiply(SUSTAINED_BOOST)));
|
||||
if (player.getTicksLived() % PARTICLE_EVERY_TICKS == 0) {
|
||||
final Location loc = player.getLocation();
|
||||
loc.getWorld().spawnParticle(Particle.FIREWORKS_SPARK, loc, FIREWORK_PARTICLE_COUNT,
|
||||
TRAIL_OFFSET, TRAIL_OFFSET, TRAIL_OFFSET, TRAIL_SPEED);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -21,10 +21,8 @@ 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();
|
||||
protected double movementScore = 0.0;
|
||||
|
|
@ -35,7 +33,7 @@ public class ElytraGenerator extends ParkourGenerator {
|
|||
protected final ElytraPointType pointType;
|
||||
private final int radius;
|
||||
private final Material material;
|
||||
private int boostedSectionIdx = Integer.MIN_VALUE;
|
||||
private final ElytraBoost boost = new ElytraBoost();
|
||||
|
||||
public ElytraGenerator(Session session) {
|
||||
this(session, ElytraPointType.CIRCLE);
|
||||
|
|
@ -105,9 +103,7 @@ public class ElytraGenerator extends ParkourGenerator {
|
|||
reset(true);
|
||||
return;
|
||||
}
|
||||
if (section.isAscending()) {
|
||||
sustainAscendingPush(player, section, idx);
|
||||
}
|
||||
boost.tick(player, section, idx);
|
||||
if (section.isNearKnot(pos, 2, GENERATE_DISTANCE)) {
|
||||
generate();
|
||||
}
|
||||
|
|
@ -125,7 +121,7 @@ public class ElytraGenerator extends ParkourGenerator {
|
|||
blockChanger.clear();
|
||||
movementScore = 0;
|
||||
startTime = null;
|
||||
boostedSectionIdx = Integer.MIN_VALUE;
|
||||
boost.reset();
|
||||
if (regenerate) {
|
||||
if (player != null && playerSpawn != null) {
|
||||
player.teleport(playerSpawn.toLocation(player.getWorld()));
|
||||
|
|
@ -180,20 +176,4 @@ public class ElytraGenerator extends ParkourGenerator {
|
|||
}
|
||||
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)));
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -0,0 +1,198 @@
|
|||
package dev.loki.loparkour.elytra.generator;
|
||||
|
||||
import dev.loki.loparkour.config.core.Config;
|
||||
import dev.loki.loparkour.elytra.section.ElytraPointType;
|
||||
import dev.loki.loparkour.elytra.section.ElytraSection;
|
||||
import dev.loki.loparkour.session.core.Session;
|
||||
import org.bukkit.Material;
|
||||
import org.bukkit.entity.Player;
|
||||
import org.bukkit.util.Vector;
|
||||
import java.util.ArrayList;
|
||||
import java.util.HashMap;
|
||||
import java.util.List;
|
||||
import java.util.Map;
|
||||
import java.util.concurrent.ThreadLocalRandom;
|
||||
|
||||
/**
|
||||
* Elytra mode that places client-side obstacles on the course which the
|
||||
* player has to fly around. Obstacles are spoofed via
|
||||
* {@link dev.loki.loparkour.elytra.section.ClientBlockChanger#sendNow} and
|
||||
* undone with AIR the same way, so no server chunk is dirtied or pinned.
|
||||
*/
|
||||
public class ObstacleGenerator extends ElytraGenerator {
|
||||
|
||||
private static final int DEFAULT_RADIUS = 3;
|
||||
private static final int OBSTACLE_STEP = 3;
|
||||
private static final int OBSTACLE_KNOT = 3;
|
||||
|
||||
private final Map<Integer, List<Vector>> obstacles = new HashMap<>();
|
||||
private final int radius;
|
||||
|
||||
public ObstacleGenerator(Session session) {
|
||||
super(session);
|
||||
radius = Config.GENERATION.getInt("elytra.radius", DEFAULT_RADIUS);
|
||||
}
|
||||
|
||||
@Override
|
||||
public void generate() {
|
||||
super.generate();
|
||||
final int lastIdx = sections.keySet().stream().max(Integer::compareTo).orElse(-1);
|
||||
final ElytraSection section = sections.get(lastIdx);
|
||||
if (section == null) {
|
||||
return;
|
||||
}
|
||||
generateObstacle(lastIdx, section, 1);
|
||||
generateObstacle(lastIdx, section, OBSTACLE_KNOT);
|
||||
}
|
||||
|
||||
private void generateObstacle(int idx, ElytraSection section, int knotIdx) {
|
||||
final Obstacle obstacle = Obstacle.values()[ThreadLocalRandom.current().nextInt(Obstacle.values().length)];
|
||||
final List<Vector> points = obstacle.getPoints(section.getKnot(knotIdx), radius);
|
||||
if (points.isEmpty()) {
|
||||
return;
|
||||
}
|
||||
final Player player = getPlayer();
|
||||
if (player == null) {
|
||||
return;
|
||||
}
|
||||
blockChanger.sendNow(player, points, Material.YELLOW_CONCRETE);
|
||||
obstacles.computeIfAbsent(idx, key -> new ArrayList<>()).addAll(points);
|
||||
}
|
||||
|
||||
@Override
|
||||
public void tick() {
|
||||
super.tick();
|
||||
final Player player = getPlayer();
|
||||
if (player == null || obstacles.isEmpty()) {
|
||||
return;
|
||||
}
|
||||
final List<Integer> stale = new ArrayList<>();
|
||||
for (final Integer key : obstacles.keySet()) {
|
||||
if (!sections.containsKey(key)) {
|
||||
stale.add(key);
|
||||
}
|
||||
}
|
||||
for (final Integer key : stale) {
|
||||
final List<Vector> points = obstacles.remove(key);
|
||||
if (points == null) {
|
||||
continue;
|
||||
}
|
||||
blockChanger.sendNow(player, points, Material.AIR);
|
||||
}
|
||||
}
|
||||
|
||||
@Override
|
||||
public void reset(boolean regenerate) {
|
||||
super.reset(regenerate);
|
||||
final Player player = getPlayer();
|
||||
if (player != null) {
|
||||
for (final List<Vector> points : obstacles.values()) {
|
||||
blockChanger.sendNow(player, points, Material.AIR);
|
||||
}
|
||||
}
|
||||
obstacles.clear();
|
||||
}
|
||||
|
||||
private enum Obstacle {
|
||||
|
||||
PILLARS {
|
||||
@Override
|
||||
List<Vector> getPoints(Vector center, int radius) {
|
||||
final List<Vector> points = new ArrayList<>();
|
||||
for (int dz = -radius; dz <= radius; dz++) {
|
||||
if (dz % OBSTACLE_STEP != 0) {
|
||||
continue;
|
||||
}
|
||||
for (int dy = -radius; dy <= radius; dy++) {
|
||||
final Vector point = center.clone().add(new Vector(0, dy, dz));
|
||||
if (point.distance(center) <= radius) {
|
||||
points.add(point);
|
||||
}
|
||||
}
|
||||
}
|
||||
return points;
|
||||
}
|
||||
},
|
||||
|
||||
LINES {
|
||||
@Override
|
||||
List<Vector> getPoints(Vector center, int radius) {
|
||||
final List<Vector> points = new ArrayList<>();
|
||||
for (int dy = -radius; dy <= radius; dy++) {
|
||||
if (dy % OBSTACLE_STEP != 0) {
|
||||
continue;
|
||||
}
|
||||
for (int dz = -radius; dz <= radius; dz++) {
|
||||
final Vector point = center.clone().add(new Vector(0, dy, dz));
|
||||
if (point.distance(center) <= radius) {
|
||||
points.add(point);
|
||||
}
|
||||
}
|
||||
}
|
||||
return points;
|
||||
}
|
||||
},
|
||||
|
||||
HOLE_IN_WALL {
|
||||
@Override
|
||||
List<Vector> getPoints(Vector center, int radius) {
|
||||
final int offsetY = ThreadLocalRandom.current().nextInt(-2, 3);
|
||||
final int offsetZ = ThreadLocalRandom.current().nextInt(-2, 3);
|
||||
final Vector offset = center.clone().add(new Vector(0, offsetY, offsetZ));
|
||||
final int radiusSquared = radius * radius;
|
||||
final List<Vector> points = new ArrayList<>();
|
||||
for (int dy = -radius + 1; dy <= radius; dy++) {
|
||||
for (int dz = -radius + 1; dz <= radius; dz++) {
|
||||
final Vector point = center.clone().add(new Vector(0, dy, dz));
|
||||
if (point.distanceSquared(center) <= radiusSquared && point.distanceSquared(offset) >= 2) {
|
||||
points.add(point);
|
||||
}
|
||||
}
|
||||
}
|
||||
return points;
|
||||
}
|
||||
},
|
||||
|
||||
CIRCLE {
|
||||
@Override
|
||||
List<Vector> getPoints(Vector center, int radius) {
|
||||
final List<Vector> points = new ArrayList<>();
|
||||
for (int r = 2; r <= radius; r += 2) {
|
||||
points.addAll(ElytraPointType.CIRCLE.getPoints(center, r));
|
||||
}
|
||||
return points;
|
||||
}
|
||||
},
|
||||
|
||||
DIAMOND {
|
||||
@Override
|
||||
List<Vector> getPoints(Vector center, int radius) {
|
||||
final List<Vector> points = new ArrayList<>();
|
||||
for (int r = 2; r <= radius; r += OBSTACLE_STEP) {
|
||||
points.addAll(diamond(center, r));
|
||||
}
|
||||
return points;
|
||||
}
|
||||
|
||||
private List<Vector> diamond(Vector center, int radius) {
|
||||
final List<Vector> points = new ArrayList<>();
|
||||
final Vector newCenter = center.clone().add(new Vector(0, -radius + 2, 0));
|
||||
final int r = radius - 2;
|
||||
final int range = 2 * r;
|
||||
for (int dy = 0; dy <= range; dy++) {
|
||||
if (dy < r) {
|
||||
points.add(newCenter.clone().add(new Vector(0, dy, -dy)));
|
||||
points.add(newCenter.clone().add(new Vector(0, dy, dy)));
|
||||
} else {
|
||||
final int newDz = range - dy;
|
||||
points.add(newCenter.clone().add(new Vector(0, dy, -newDz)));
|
||||
points.add(newCenter.clone().add(new Vector(0, dy, newDz)));
|
||||
}
|
||||
}
|
||||
return points;
|
||||
}
|
||||
};
|
||||
|
||||
abstract List<Vector> getPoints(Vector center, int radius);
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,55 @@
|
|||
package dev.loki.loparkour.elytra.mode;
|
||||
|
||||
import dev.loki.loparkour.config.locale.Locales;
|
||||
import dev.loki.loparkour.elytra.generator.ObstacleGenerator;
|
||||
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 mode with client-side obstacles to fly around.
|
||||
*/
|
||||
public class ObstacleMode implements Mode {
|
||||
|
||||
private final Leaderboard leaderboard = new Leaderboard(getName(), Leaderboard.Sort.SCORE);
|
||||
|
||||
@Override
|
||||
@NotNull
|
||||
public String getName() {
|
||||
return "obstacle";
|
||||
}
|
||||
|
||||
@Override
|
||||
@Nullable
|
||||
public Item getItem(String locale) {
|
||||
return Locales.getItem(locale, "play.elytra.obstacle");
|
||||
}
|
||||
|
||||
@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 ObstacleMode) {
|
||||
return;
|
||||
}
|
||||
|
||||
player.closeInventory();
|
||||
|
||||
Session.create(ObstacleGenerator::new, null, null, player);
|
||||
}
|
||||
}
|
||||
|
|
@ -1,6 +1,7 @@
|
|||
package dev.loki.loparkour.elytra.section;
|
||||
|
||||
import java.util.ArrayList;
|
||||
import java.util.Collection;
|
||||
import java.util.HashMap;
|
||||
import java.util.HashSet;
|
||||
import java.util.Iterator;
|
||||
|
|
@ -12,6 +13,7 @@ import org.bukkit.World;
|
|||
import org.bukkit.block.BlockState;
|
||||
import org.bukkit.entity.Player;
|
||||
import org.bukkit.util.Vector;
|
||||
import org.jetbrains.annotations.NotNull;
|
||||
|
||||
/**
|
||||
* Pipes pending client-side block updates to the player without leaking server
|
||||
|
|
@ -90,6 +92,28 @@ public class ClientBlockChanger {
|
|||
renderBatches(player, toSend, material);
|
||||
}
|
||||
|
||||
/**
|
||||
* Immediately sends all given positions to the player as the given material,
|
||||
* bypassing the chunk queue.
|
||||
*
|
||||
* @param player the player whose client should be updated.
|
||||
* @param vectors the positions to update.
|
||||
* @param material material to display the blocks as.
|
||||
*/
|
||||
public void sendNow(@NotNull Player player, @NotNull Collection<Vector> vectors, @NotNull Material material) {
|
||||
if (vectors.isEmpty()) {
|
||||
return;
|
||||
}
|
||||
final World world = player.getWorld();
|
||||
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);
|
||||
}
|
||||
|
||||
/**
|
||||
* Clears the queue without sending anything.
|
||||
*/
|
||||
|
|
|
|||
|
|
@ -3,6 +3,7 @@ package dev.loki.loparkour.menu.core;
|
|||
import dev.loki.loparkour.menu.community.CommunityMenu;
|
||||
import dev.loki.loparkour.menu.community.LeaderboardsMenu;
|
||||
import dev.loki.loparkour.menu.community.SingleLeaderboardMenu;
|
||||
import dev.loki.loparkour.menu.lobby.DuelsMenu;
|
||||
import dev.loki.loparkour.menu.lobby.LobbyMenu;
|
||||
import dev.loki.loparkour.menu.lobby.PlayerManagementMenu;
|
||||
import dev.loki.loparkour.menu.play.PlayMenu;
|
||||
|
|
@ -37,6 +38,7 @@ public final class Menus {
|
|||
public static ParkourSettingsMenu PARKOUR_SETTINGS = new ParkourSettingsMenu();
|
||||
|
||||
// lobby
|
||||
public static DuelsMenu DUELS = new DuelsMenu();
|
||||
public static LobbyMenu LOBBY = new LobbyMenu();
|
||||
public static PlayerManagementMenu PLAYER_MANAGEMENT = new PlayerManagementMenu();
|
||||
|
||||
|
|
|
|||
123
src/main/java/dev/loki/loparkour/menu/lobby/DuelsMenu.java
Normal file
123
src/main/java/dev/loki/loparkour/menu/lobby/DuelsMenu.java
Normal file
|
|
@ -0,0 +1,123 @@
|
|||
package dev.loki.loparkour.menu.lobby;
|
||||
|
||||
import dev.loki.loparkour.config.locale.Locales;
|
||||
import dev.loki.loparkour.duels.DuelsGenerator;
|
||||
import dev.loki.loparkour.menu.core.LPMenu;
|
||||
import dev.loki.loparkour.mode.base.Modes;
|
||||
import dev.loki.loparkour.player.core.ParkourUser;
|
||||
import dev.loki.loparkour.session.core.Session;
|
||||
import dev.loki.loparkour.util.text.ColorUtil;
|
||||
import dev.loki.loparkour.world.core.Divider;
|
||||
import dev.lolib.gui.InventoryGUI;
|
||||
import org.bukkit.Material;
|
||||
import org.bukkit.entity.Player;
|
||||
import org.bukkit.inventory.ItemStack;
|
||||
import org.bukkit.inventory.meta.ItemMeta;
|
||||
import org.bukkit.inventory.meta.SkullMeta;
|
||||
import org.jetbrains.annotations.NotNull;
|
||||
|
||||
import java.util.ArrayList;
|
||||
import java.util.List;
|
||||
|
||||
/**
|
||||
* Lists open duels sessions and lets the player join one.
|
||||
*/
|
||||
public class DuelsMenu extends LPMenu {
|
||||
|
||||
private static final int ROWS = 3;
|
||||
private static final int ITEMS_PER_PAGE = 7;
|
||||
private static final int FIRST_SLOT = 10;
|
||||
private static final int PREV_SLOT = 0;
|
||||
private static final int NEXT_SLOT = 8;
|
||||
private static final int SLOT_EMPTY = 13;
|
||||
private static final int SLOT_CLOSE = 22;
|
||||
|
||||
private int currentPage = 0;
|
||||
|
||||
@Override
|
||||
public void open(@NotNull Player player) {
|
||||
open(player, 0);
|
||||
}
|
||||
|
||||
public void open(@NotNull Player player, int page) {
|
||||
this.currentPage = page;
|
||||
final String locale = locale(player);
|
||||
final String title = Locales.getString(locale, "play.multi.duels.name");
|
||||
|
||||
final List<Session> sessions = collectOpenSessions();
|
||||
|
||||
final int totalPages = (int) Math.ceil((double) sessions.size() / ITEMS_PER_PAGE);
|
||||
final int startIndex = currentPage * ITEMS_PER_PAGE;
|
||||
final int endIndex = Math.min(startIndex + ITEMS_PER_PAGE, sessions.size());
|
||||
|
||||
InventoryGUI gui = baseGui(title, ROWS);
|
||||
|
||||
if (sessions.isEmpty()) {
|
||||
gui = gui.setItem(SLOT_EMPTY, localeItem(player, "play.multi.duels.menu_empty"));
|
||||
} else {
|
||||
for (int i = startIndex; i < endIndex; i++) {
|
||||
final Session session = sessions.get(i);
|
||||
final int slot = FIRST_SLOT + (i - startIndex);
|
||||
gui = gui.setItem(slot, sessionItem(player, session), e -> {
|
||||
Modes.DUELS.join(player, session);
|
||||
player.closeInventory();
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
if (currentPage > 0) {
|
||||
gui = gui.setItem(PREV_SLOT, backItem(player), e -> open(player, currentPage - 1));
|
||||
}
|
||||
|
||||
if (currentPage < totalPages - 1) {
|
||||
gui = gui.setItem(NEXT_SLOT, nextItem(player), e -> open(player, currentPage + 1));
|
||||
}
|
||||
|
||||
gui.setItem(SLOT_CLOSE, closeButton(player), e -> player.closeInventory())
|
||||
.open(player);
|
||||
}
|
||||
|
||||
private List<Session> collectOpenSessions() {
|
||||
final int maxPlayers = Modes.DUELS.getMaxPlayers();
|
||||
final List<Session> sessions = new ArrayList<>();
|
||||
for (final Session session : Divider.SECTIONS.keySet()) {
|
||||
if (isOpenForJoining(session, maxPlayers)) {
|
||||
sessions.add(session);
|
||||
}
|
||||
}
|
||||
return sessions;
|
||||
}
|
||||
|
||||
private boolean isOpenForJoining(Session session, int maxPlayers) {
|
||||
return session.generator instanceof DuelsGenerator generator
|
||||
&& generator.allowJoining
|
||||
&& session.getPlayers().size() < maxPlayers;
|
||||
}
|
||||
|
||||
private ItemStack sessionItem(@NotNull Player player, @NotNull Session session) {
|
||||
final DuelsGenerator generator = (DuelsGenerator) session.generator;
|
||||
final ItemStack item = new ItemStack(Material.PLAYER_HEAD);
|
||||
applySkullOwner(player, item, generator.owner);
|
||||
|
||||
final ItemMeta meta = item.getItemMeta();
|
||||
if (meta != null) {
|
||||
meta.setDisplayName(ColorUtil.color(generator.owner.getName()));
|
||||
item.setItemMeta(meta);
|
||||
}
|
||||
return item;
|
||||
}
|
||||
|
||||
private void applySkullOwner(@NotNull Player player, @NotNull ItemStack skull, @NotNull ParkourUser owner) {
|
||||
if (ParkourUser.isBedrockPlayer(player)) {
|
||||
return;
|
||||
}
|
||||
if (owner.getName() == null || owner.getName().startsWith(".")) {
|
||||
return;
|
||||
}
|
||||
final SkullMeta meta = (SkullMeta) skull.getItemMeta();
|
||||
if (meta != null) {
|
||||
meta.setOwningPlayer(owner.player);
|
||||
skull.setItemMeta(meta);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -5,6 +5,7 @@ import dev.loki.loparkour.duels.DuelsMode;
|
|||
import dev.loki.loparkour.elytra.mode.CloseMode;
|
||||
import dev.loki.loparkour.elytra.mode.ElytraMode;
|
||||
import dev.loki.loparkour.elytra.mode.MinSpeedMode;
|
||||
import dev.loki.loparkour.elytra.mode.ObstacleMode;
|
||||
import dev.loki.loparkour.elytra.mode.SpeedDemonMode;
|
||||
import dev.loki.loparkour.elytra.mode.TimeTrialMode;
|
||||
import dev.loki.loparkour.mode.impl.barrier.InvisibleBarrierMode;
|
||||
|
|
@ -32,6 +33,7 @@ public final class Modes {
|
|||
public static MinSpeedMode MIN_SPEED;
|
||||
public static TimeTrialMode TIME_TRIAL;
|
||||
public static CloseMode CLOSE;
|
||||
public static ObstacleMode OBSTACLE;
|
||||
public static DuelsMode DUELS;
|
||||
|
||||
public static void init() {
|
||||
|
|
@ -47,6 +49,7 @@ public final class Modes {
|
|||
MIN_SPEED = (MinSpeedMode) Registry.getMode("minspeed");
|
||||
TIME_TRIAL = (TimeTrialMode) Registry.getMode("timetrial");
|
||||
CLOSE = (CloseMode) Registry.getMode("close");
|
||||
OBSTACLE = (ObstacleMode) Registry.getMode("obstacle");
|
||||
DUELS = (DuelsMode) Registry.getMode("duels");
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -73,6 +73,11 @@ play:
|
|||
name: "�D4FF&lClose"
|
||||
lore: "񢩈Fly through flat rings||񢩈placed close together."
|
||||
|
||||
obstacle:
|
||||
material: "barrier"
|
||||
name: "�D4FF&lObstacle"
|
||||
lore: "񢩈Fly around the obstacles||񢩈spawned on your course."
|
||||
|
||||
spectator:
|
||||
name: "&#FFFFFFSpectator"
|
||||
|
||||
|
|
@ -105,6 +110,8 @@ play:
|
|||
name: "�D4FF&lDuels"
|
||||
lore: ""
|
||||
go: "7FF55&lGO!"
|
||||
name: "&#FFFFFFDuels"
|
||||
menu_empty: "&#A0A0A0No open duels right now."
|
||||
overview: "&#FFD700&l%s &#A0A0A0won the duel! Time: &#FFFFFF%s"
|
||||
place: "HB3#%s &#A0A0A0%s 񢩈- &#A0A0A0%s"
|
||||
victory: "7FF55&lVICTORY!"
|
||||
|
|
|
|||
|
|
@ -72,6 +72,11 @@ play:
|
|||
name: "�D4FF&lБлизко"
|
||||
lore: "񢩈Летай сквозь плоские кольца||񢩈расположенные вплотную."
|
||||
|
||||
obstacle:
|
||||
material: "barrier"
|
||||
name: "�D4FF&lПрепятствия"
|
||||
lore: "񢩈Облетай препятствия,||񢩈появляющиеся на трассе."
|
||||
|
||||
spectator:
|
||||
name: "&#FFFFFFНаблюдатель"
|
||||
|
||||
|
|
@ -104,6 +109,8 @@ play:
|
|||
name: "�D4FF&lДуэли"
|
||||
lore: ""
|
||||
go: "7FF55&lВПЕРЁД!"
|
||||
name: "&#FFFFFFДуэли"
|
||||
menu_empty: "&#A0A0A0Сейчас нет открытых дуэлей."
|
||||
overview: "&#FFD700&l%s &#A0A0A0победил в дуэли! Время: &#FFFFFF%s"
|
||||
place: "HB3#%s &#A0A0A0%s 񢩈- &#A0A0A0%s"
|
||||
victory: "7FF55&lПОБЕДА!"
|
||||
|
|
|
|||
|
|
@ -0,0 +1,48 @@
|
|||
package dev.loki.loparkour.duels;
|
||||
|
||||
import org.bukkit.Bukkit;
|
||||
import org.bukkit.Material;
|
||||
import org.bukkit.block.data.BlockData;
|
||||
import org.junit.jupiter.api.Test;
|
||||
import org.mockito.MockedStatic;
|
||||
|
||||
import static org.junit.jupiter.api.Assertions.assertNotNull;
|
||||
import static org.junit.jupiter.api.Assertions.assertTrue;
|
||||
import static org.mockito.ArgumentMatchers.any;
|
||||
import static org.mockito.Mockito.CALLS_REAL_METHODS;
|
||||
import static org.mockito.Mockito.mock;
|
||||
import static org.mockito.Mockito.mockStatic;
|
||||
|
||||
class SingleDuelsGeneratorTest {
|
||||
|
||||
private static final int COLOR_COUNT = 16;
|
||||
|
||||
@Test
|
||||
void testPlayerIndexProducesBlockDataForEveryColor() {
|
||||
try (MockedStatic<Bukkit> bukkit = mockStatic(Bukkit.class)) {
|
||||
bukkit.when(() -> Bukkit.createBlockData(any(Material.class))).thenReturn(mock(BlockData.class));
|
||||
|
||||
final SingleDuelsGenerator generator = mock(SingleDuelsGenerator.class, CALLS_REAL_METHODS);
|
||||
for (int i = 0; i < COLOR_COUNT; i++) {
|
||||
generator.setPlayerIndex(i);
|
||||
assertNotNull(generator.selectBlockData(), "color index " + i + " must produce block data");
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
void testSelectBlockDataFallsBackWithoutPlayerIndex() {
|
||||
try (MockedStatic<Bukkit> bukkit = mockStatic(Bukkit.class)) {
|
||||
bukkit.when(() -> Bukkit.createBlockData(any(Material.class))).thenReturn(mock(BlockData.class));
|
||||
|
||||
final SingleDuelsGenerator generator = mock(SingleDuelsGenerator.class, CALLS_REAL_METHODS);
|
||||
assertNotNull(generator.selectBlockData(), "default block data must never be null");
|
||||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
void testGetPlayersEmptyWithoutOwnedPlayer() {
|
||||
final SingleDuelsGenerator generator = mock(SingleDuelsGenerator.class, CALLS_REAL_METHODS);
|
||||
assertTrue(generator.getPlayers().isEmpty(), "no owned player must yield an empty player list");
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,64 @@
|
|||
package dev.loki.loparkour.elytra.section;
|
||||
|
||||
import java.util.ArrayList;
|
||||
import java.util.List;
|
||||
import java.util.Random;
|
||||
import org.bukkit.util.Vector;
|
||||
import org.junit.jupiter.api.Test;
|
||||
|
||||
import static org.junit.jupiter.api.Assertions.assertNotEquals;
|
||||
import static org.junit.jupiter.api.Assertions.assertTrue;
|
||||
|
||||
class ElytraKnotDirectorTest {
|
||||
|
||||
private static final long SEED_A = 42L;
|
||||
private static final long SEED_B = 7L;
|
||||
private static final int SAMPLES = 1000;
|
||||
private static final int COMPARE_SAMPLES = 20;
|
||||
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 static final int SD_MULTIPLIER = 2;
|
||||
|
||||
@Test
|
||||
void testOffsetsStayWithinDistributionBounds() {
|
||||
final ElytraKnotDirector director =
|
||||
new ElytraKnotDirector(new Random(SEED_A), ElytraKnotDirector.DEFAULT_VERTICAL_BIAS);
|
||||
final int minDx = DX_MEAN - SD_MULTIPLIER * DX_SD;
|
||||
final int maxDx = DX_MEAN + SD_MULTIPLIER * DX_SD;
|
||||
final int minDy = ElytraKnotDirector.DEFAULT_VERTICAL_BIAS - SD_MULTIPLIER * DY_SD;
|
||||
final int maxDy = ElytraKnotDirector.DEFAULT_VERTICAL_BIAS + SD_MULTIPLIER * DY_SD;
|
||||
final int minDz = -SD_MULTIPLIER * DZ_SD;
|
||||
final int maxDz = SD_MULTIPLIER * DZ_SD;
|
||||
|
||||
for (int i = 0; i < SAMPLES; i++) {
|
||||
final Vector offset = director.nextOffset();
|
||||
final int dx = (int) offset.getX();
|
||||
final int dy = (int) offset.getY();
|
||||
final int dz = (int) offset.getZ();
|
||||
assertTrue(dx >= minDx && dx <= maxDx, "dx out of bounds: " + dx);
|
||||
assertTrue(dy >= minDy && dy <= maxDy, "dy out of bounds: " + dy);
|
||||
assertTrue(dz >= minDz && dz <= maxDz, "dz out of bounds: " + dz);
|
||||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
void testDifferentSeedsProduceDifferentOffsets() {
|
||||
final ElytraKnotDirector first =
|
||||
new ElytraKnotDirector(new Random(SEED_A), ElytraKnotDirector.DEFAULT_VERTICAL_BIAS);
|
||||
final ElytraKnotDirector second =
|
||||
new ElytraKnotDirector(new Random(SEED_B), ElytraKnotDirector.DEFAULT_VERTICAL_BIAS);
|
||||
final List<Vector> firstOffsets = collect(first, COMPARE_SAMPLES);
|
||||
final List<Vector> secondOffsets = collect(second, COMPARE_SAMPLES);
|
||||
assertNotEquals(firstOffsets, secondOffsets, "different seeds must produce different offset sequences");
|
||||
}
|
||||
|
||||
private List<Vector> collect(ElytraKnotDirector director, int count) {
|
||||
final List<Vector> result = new ArrayList<>();
|
||||
for (int i = 0; i < count; i++) {
|
||||
result.add(director.nextOffset());
|
||||
}
|
||||
return result;
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,69 @@
|
|||
package dev.loki.loparkour.elytra.section;
|
||||
|
||||
import java.util.HashSet;
|
||||
import java.util.List;
|
||||
import java.util.Set;
|
||||
import org.bukkit.util.Vector;
|
||||
import org.junit.jupiter.api.Test;
|
||||
|
||||
import static org.junit.jupiter.api.Assertions.assertEquals;
|
||||
import static org.junit.jupiter.api.Assertions.assertTrue;
|
||||
|
||||
class ElytraPointTypeTest {
|
||||
|
||||
private static final Vector CENTER = new Vector(0, 0, 0);
|
||||
private static final double BLOCK_CENTER = 0.5;
|
||||
private static final int RADIUS = 3;
|
||||
private static final int SYMMETRY_DIVISOR = 8;
|
||||
private static final int FLAT_POINT_COUNT = 3;
|
||||
private static final int FLAT_Z_OFFSET = 1;
|
||||
private static final int CIRCLE_HEIGHT_OFFSET = -1;
|
||||
private static final int FLAT_HEIGHT_OFFSET = -5;
|
||||
|
||||
@Test
|
||||
void testCircleRadiusZeroYieldsSinglePoint() {
|
||||
final List<Vector> points = ElytraPointType.CIRCLE.getPoints(CENTER, 0);
|
||||
assertEquals(1, points.size(), "radius zero must yield exactly one point");
|
||||
assertEquals(CENTER, points.get(0));
|
||||
}
|
||||
|
||||
@Test
|
||||
void testCircleRadiusThreeIsSymmetricAroundBlockCenter() {
|
||||
final List<Vector> points = ElytraPointType.CIRCLE.getPoints(CENTER, RADIUS);
|
||||
assertEquals(0, points.size() % SYMMETRY_DIVISOR, "point count must be a multiple of 8");
|
||||
|
||||
final Set<Offset> offsets = new HashSet<>();
|
||||
for (final Vector p : points) {
|
||||
assertEquals(BLOCK_CENTER, p.getX(), "x coordinate stays on the block center");
|
||||
final int dy = (int) Math.round(p.getY() - BLOCK_CENTER);
|
||||
final int dz = (int) Math.round(p.getZ() - BLOCK_CENTER);
|
||||
assertTrue(Math.abs(dy) <= RADIUS, "y offset must not exceed radius");
|
||||
assertTrue(Math.abs(dz) <= RADIUS, "z offset must not exceed radius");
|
||||
offsets.add(new Offset(dy, dz));
|
||||
}
|
||||
|
||||
for (final Offset offset : offsets) {
|
||||
assertTrue(offsets.contains(new Offset(-offset.dy, offset.dz)), "circle must be symmetric on y");
|
||||
assertTrue(offsets.contains(new Offset(offset.dy, -offset.dz)), "circle must be symmetric on z");
|
||||
assertTrue(offsets.contains(new Offset(-offset.dy, -offset.dz)), "circle must be point symmetric");
|
||||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
void testFlatYieldsExactlyThreeCollinearPoints() {
|
||||
final List<Vector> points = ElytraPointType.FLAT.getPoints(CENTER, RADIUS);
|
||||
assertEquals(FLAT_POINT_COUNT, points.size(), "flat expansion must yield exactly three points");
|
||||
assertEquals(new Vector(0, 0, -FLAT_Z_OFFSET), points.get(0));
|
||||
assertEquals(new Vector(0, 0, 0), points.get(1));
|
||||
assertEquals(new Vector(0, 0, FLAT_Z_OFFSET), points.get(2));
|
||||
}
|
||||
|
||||
@Test
|
||||
void testHeightOffsetsMatchContract() {
|
||||
assertEquals(CIRCLE_HEIGHT_OFFSET, ElytraPointType.CIRCLE.heightOffset);
|
||||
assertEquals(FLAT_HEIGHT_OFFSET, ElytraPointType.FLAT.heightOffset);
|
||||
}
|
||||
|
||||
private record Offset(int dy, int dz) {
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,111 @@
|
|||
package dev.loki.loparkour.elytra.section;
|
||||
|
||||
import java.util.ArrayList;
|
||||
import java.util.List;
|
||||
import java.util.Map;
|
||||
import java.util.Random;
|
||||
import java.util.Set;
|
||||
import org.bukkit.util.Vector;
|
||||
import org.junit.jupiter.api.BeforeEach;
|
||||
import org.junit.jupiter.api.Test;
|
||||
|
||||
import static org.junit.jupiter.api.Assertions.assertEquals;
|
||||
import static org.junit.jupiter.api.Assertions.assertFalse;
|
||||
import static org.junit.jupiter.api.Assertions.assertNotEquals;
|
||||
import static org.junit.jupiter.api.Assertions.assertNotNull;
|
||||
import static org.junit.jupiter.api.Assertions.assertThrows;
|
||||
import static org.junit.jupiter.api.Assertions.assertTrue;
|
||||
|
||||
class ElytraSectionTest {
|
||||
|
||||
private static final Vector START = new Vector(0, 100, 0);
|
||||
private static final long SEED = 42L;
|
||||
private static final int KNOT_COUNT = 5;
|
||||
private static final int LAST_KNOT_INDEX = 4;
|
||||
private static final int MIN_EXPECTED_POINTS = 20;
|
||||
private static final int PIPE_RADIUS = 3;
|
||||
private static final int CHUNK_SHIFT = 4;
|
||||
private static final int MAX_POINTS = 1000;
|
||||
private static final double NEAR_DISTANCE = 1.0;
|
||||
private static final double FAR_OFFSET = 100.0;
|
||||
private static final double CLONE_SENTINEL = 999.0;
|
||||
|
||||
private ElytraSection section;
|
||||
|
||||
@BeforeEach
|
||||
void setUp() {
|
||||
section = new ElytraSection(START, new Random(SEED), ElytraKnotDirector.DEFAULT_VERTICAL_BIAS);
|
||||
}
|
||||
|
||||
@Test
|
||||
void testKnotsCount() {
|
||||
assertNotNull(section.getKnot(LAST_KNOT_INDEX), "last knot must exist");
|
||||
assertNotNull(section.beginning(), "beginning knot must exist");
|
||||
assertNotNull(section.end(), "end knot must exist");
|
||||
assertThrows(IndexOutOfBoundsException.class, () -> section.getKnot(KNOT_COUNT),
|
||||
"there must be exactly five knots");
|
||||
}
|
||||
|
||||
@Test
|
||||
void testSplinePointsMonotonicByX() {
|
||||
final List<Vector> points = collectPoints();
|
||||
assertTrue(points.size() > MIN_EXPECTED_POINTS, "spline must contain more than 20 points");
|
||||
for (int i = 0; i < points.size() - 1; i++) {
|
||||
assertTrue(points.get(i).getX() < points.get(i + 1).getX(), "x must increase at point " + i);
|
||||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
void testIsNearPoint() {
|
||||
final Vector own = section.getPoint(0);
|
||||
assertTrue(section.isNearPoint(own, 0, NEAR_DISTANCE), "own position must be near the point");
|
||||
|
||||
final Vector far = own.clone().add(new Vector(FAR_OFFSET, 0, 0));
|
||||
assertFalse(section.isNearPoint(far, 0, NEAR_DISTANCE), "distant position must not be near the point");
|
||||
}
|
||||
|
||||
@Test
|
||||
void testIsAscending() {
|
||||
final ElytraSection climbing =
|
||||
new ElytraSection(START, new Random(SEED), ElytraKnotDirector.ASCENDING_VERTICAL_BIAS);
|
||||
final ElytraSection diving =
|
||||
new ElytraSection(START, new Random(SEED), ElytraKnotDirector.DEFAULT_VERTICAL_BIAS);
|
||||
assertTrue(climbing.isAscending(), "positive bias must produce an ascending section");
|
||||
assertFalse(diving.isAscending(), "negative bias must produce a descending section");
|
||||
}
|
||||
|
||||
@Test
|
||||
void testGenerateBlocksGroupsByChunk() {
|
||||
final Map<Integer, Set<Vector>> blocks = section.generateBlocks(ElytraPointType.CIRCLE, PIPE_RADIUS);
|
||||
assertFalse(blocks.isEmpty(), "block map must not be empty");
|
||||
for (final Map.Entry<Integer, Set<Vector>> entry : blocks.entrySet()) {
|
||||
assertFalse(entry.getValue().isEmpty(), "every chunk bucket must be non-empty");
|
||||
for (final Vector block : entry.getValue()) {
|
||||
assertEquals(entry.getKey().intValue(), block.getBlockX() >> CHUNK_SHIFT,
|
||||
"bucket key must equal the chunk x of every block");
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
void testBeginningAndEndReturnClones() {
|
||||
final Vector start = section.beginning();
|
||||
final Vector finish = section.end();
|
||||
start.setX(CLONE_SENTINEL);
|
||||
finish.setY(CLONE_SENTINEL);
|
||||
assertNotEquals(CLONE_SENTINEL, section.beginning().getX(), "mutating beginning must not affect the section");
|
||||
assertNotEquals(CLONE_SENTINEL, section.end().getY(), "mutating end must not affect the section");
|
||||
}
|
||||
|
||||
private List<Vector> collectPoints() {
|
||||
final List<Vector> result = new ArrayList<>();
|
||||
for (int i = 0; i < MAX_POINTS; i++) {
|
||||
try {
|
||||
result.add(section.getPoint(i));
|
||||
} catch (IndexOutOfBoundsException ignore) {
|
||||
break;
|
||||
}
|
||||
}
|
||||
return result;
|
||||
}
|
||||
}
|
||||
Loading…
Add table
Add a link
Reference in a new issue