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:
loki5512344 2026-08-05 21:49:11 +02:00
parent 5707cf2d24
commit 3b5d3d97d5
Signed by: boba
GPG key ID: 253067914055423B
19 changed files with 804 additions and 28 deletions

View file

@ -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();
}

View file

@ -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);

View file

@ -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");

View file

@ -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);
}
}
}

View file

@ -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)));
}
}

View file

@ -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);
}
}

View file

@ -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);
}
}

View file

@ -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.
*/

View file

@ -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();

View 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);
}
}
}

View file

@ -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");
}
}