feat: add Elytra mode (basic S-level)

- elytra/section/: ElytraSection (5 knots + cubic spline via commons-math3),
  ElytraKnotDirector (dx~N(75,15), dy bias, dz~N(0,35)), ElytraPointType
  (CIRCLE Bresenham / FLAT), ClientBlockChanger (client-side sendBlockChanges,
  forward-limit min(vd,8))
- elytra/generator/ElytraGenerator extends ParkourGenerator: own tick() (score by
  velocity.x while gliding, section lifecycle, fall detection, sustained boost in
  ascending sections), reset, spawn with elytra
- elytra/mode/ElytraMode: registered in Modes/PluginBootstrap, locale items ru/en
- build.gradle: commons-math3 3.6.1 + relocate
- checkstyle 0/0, PMD 0, build green (tests + shadowJar)
This commit is contained in:
loki5512344 2026-08-05 20:45:01 +02:00
parent 6f71a47ab3
commit 8fde1b579b
Signed by: boba
GPG key ID: 253067914055423B
12 changed files with 759 additions and 4 deletions

View file

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

View file

@ -0,0 +1,200 @@
package dev.loki.loparkour.elytra.generator;
import dev.loki.loparkour.config.core.Config;
import dev.loki.loparkour.elytra.section.ClientBlockChanger;
import dev.loki.loparkour.elytra.section.ElytraKnotDirector;
import dev.loki.loparkour.elytra.section.ElytraPointType;
import dev.loki.loparkour.elytra.section.ElytraSection;
import dev.loki.loparkour.generator.core.coordinator.ParkourGenerator;
import dev.loki.loparkour.mode.base.Mode;
import dev.loki.loparkour.mode.base.Modes;
import dev.loki.loparkour.session.core.Session;
import dev.loki.loparkour.util.item.Materials;
import org.bukkit.Location;
import org.bukkit.Material;
import org.bukkit.entity.Player;
import org.bukkit.inventory.ItemStack;
import org.bukkit.util.Vector;
import java.util.LinkedHashMap;
import java.util.Map;
import java.util.Random;
public class ElytraGenerator extends ParkourGenerator {
private static final int ASCEND_THRESHOLD_Y = 50;
private static final double SUSTAINED_BOOST = 0.08;
private static final int DEFAULT_RADIUS = 3;
private static final double GENERATE_DISTANCE = 6.0;
private static final double EPSILON = 1e-6;
public final Map<Integer, ElytraSection> sections = new LinkedHashMap<>();
public final ClientBlockChanger blockChanger = new ClientBlockChanger();
private double movementScore = 0.0;
private Long startTime = null;
private Vector playerSpawn;
private Vector blockSpawn;
private Random random;
private final ElytraPointType pointType = ElytraPointType.CIRCLE;
private final int radius;
private final Material material;
private int boostedSectionIdx = Integer.MIN_VALUE;
public ElytraGenerator(Session session) {
super(session);
this.radius = Config.GENERATION.getInt("elytra.radius", DEFAULT_RADIUS);
this.material = Materials.parseOr(Config.GENERATION.getString("advanced.island.parkour.first-block-material"), Material.WHITE_STAINED_GLASS);
}
@Override
public void generateFirst(Location spawn, Location block) {
this.playerSpawn = spawn.toVector();
this.blockSpawn = block.toVector();
this.random = new Random(System.currentTimeMillis());
final Player player = getPlayer();
if (player != null) {
if (player.getInventory().getChestplate() == null || player.getInventory().getChestplate().getType() != Material.ELYTRA) {
player.getInventory().setChestplate(new ItemStack(Material.ELYTRA));
}
player.setGliding(true);
player.setVelocity(new Vector(1.0, 0.0, 0.0));
}
generate();
}
@Override
public void generate() {
final int idx;
final ElytraSection section;
if (sections.isEmpty()) {
idx = 0;
section = new ElytraSection(blockSpawn.clone().add(new Vector(0, pointType.heightOffset, 0)), random,
ElytraKnotDirector.DEFAULT_VERTICAL_BIAS);
} else {
final int lastIdx = sections.keySet().stream().max(Integer::compareTo).orElse(0);
final ElytraSection previous = sections.get(lastIdx);
final int bias = previous.end().getY() < ASCEND_THRESHOLD_Y ? ElytraKnotDirector.ASCENDING_VERTICAL_BIAS
: ElytraKnotDirector.DEFAULT_VERTICAL_BIAS;
idx = lastIdx + 1;
section = new ElytraSection(previous.end(), random, bias);
}
sections.put(idx, section);
blockChanger.queue(section.generateBlocks(pointType, radius));
}
@Override
public void startTick() {
}
@Override
public void tick() {
final Player player = getPlayer();
if (player == null || sections.isEmpty()) {
return;
}
final int idx = sections.keySet().stream().max(Integer::compareTo).orElse(-1);
final ElytraSection section = sections.get(idx);
if (section == null) {
return;
}
final Vector pos = player.getLocation().toVector();
applyScoring(player, pos);
blockChanger.check(player, material);
removeOldest(player);
if (shouldReset(player, pos)) {
reset(true);
return;
}
if (section.isAscending()) {
sustainAscendingPush(player, section, idx);
}
if (section.isNearKnot(pos, 2, GENERATE_DISTANCE)) {
generate();
}
}
@Override
public void reset(boolean regenerate) {
final Player player = getPlayer();
for (final ElytraSection section : sections.values()) {
if (player != null) {
section.clear(player);
}
}
sections.clear();
blockChanger.clear();
movementScore = 0;
startTime = null;
boostedSectionIdx = Integer.MIN_VALUE;
if (regenerate) {
if (player != null && playerSpawn != null) {
player.teleport(playerSpawn.toLocation(player.getWorld()));
player.setGliding(true);
}
generate();
}
}
@Override
public Mode getMode() {
return Modes.ELYTRA;
}
public int getElytraScore() {
return (int) Math.round(movementScore);
}
private Player getPlayer() {
return session.getPlayers().isEmpty() ? null : session.getPlayers().get(0).player;
}
private void removeOldest(Player player) {
if (sections.size() <= 2) {
return;
}
final ElytraSection old = sections.remove(sections.keySet().stream().min(Integer::compareTo).orElse(-1));
if (old != null) {
old.clear(player);
}
}
private void applyScoring(Player player, Vector pos) {
if (player.isGliding() && pos.getX() - blockSpawn.getX() > 0) {
movementScore += player.getVelocity().getX();
}
if (startTime == null && movementScore > 0) {
startTime = System.currentTimeMillis();
}
}
private boolean shouldReset(Player player, Vector pos) {
Map.Entry<Integer, ElytraSection> target = null;
for (final Map.Entry<Integer, ElytraSection> entry : sections.entrySet()) {
if (pos.getX() < entry.getValue().end().getX() && (target == null || entry.getKey() < target.getKey())) {
target = entry;
}
}
if (target == null) {
return false;
}
final double progress = pos.getX() - target.getValue().beginning().getX();
if (progress < 0) {
return target.getKey() == 0 && pos.getY() < blockSpawn.getY() - radius;
}
return progress > 0 && (!player.isGliding() || !target.getValue().isNearPoint(pos, (int) progress, radius));
}
private void sustainAscendingPush(Player player, ElytraSection section, int idx) {
final Vector pos = player.getLocation().toVector();
if (pos.getX() < section.beginning().getX() || pos.getX() >= section.end().getX()) {
return;
}
if (boostedSectionIdx != idx) {
boostedSectionIdx = idx;
player.setGliding(true);
}
final Vector direction = section.end().clone().subtract(section.beginning()).normalize();
if (direction.lengthSquared() < EPSILON) {
return;
}
player.setVelocity(player.getVelocity().add(direction.multiply(SUSTAINED_BOOST)));
}
}

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

View file

@ -0,0 +1,116 @@
package dev.loki.loparkour.elytra.section;
import java.util.ArrayList;
import java.util.HashMap;
import java.util.HashSet;
import java.util.Iterator;
import java.util.List;
import java.util.Map;
import java.util.Set;
import org.bukkit.Material;
import org.bukkit.World;
import org.bukkit.block.BlockState;
import org.bukkit.entity.Player;
import org.bukkit.util.Vector;
/**
* Pipes pending client-side block updates to the player without leaking server
* memory. Positions are stored as {@link Vector}s rather than Bukkit Blocks to
* avoid pinning chunks. At send time each batch is resolved and shipped as one
* MultiBlockChange per chunk via {@link Player#sendBlockChanges}.
*/
public class ClientBlockChanger {
private static final int DEFAULT_MIN_VIEW_DISTANCE = 4;
private static final int MAX_FORWARD_CHUNKS = 8;
private final Map<Integer, Set<Vector>> toChange = new HashMap<>();
/**
* Adds chunked block positions to the queue.
*
* @param blocks chunk X to block positions to add.
*/
public synchronized void queue(Map<Integer, Set<Vector>> blocks) {
for (final Map.Entry<Integer, Set<Vector>> entry : blocks.entrySet()) {
toChange.computeIfAbsent(entry.getKey(), k -> new HashSet<>()).addAll(entry.getValue());
}
}
/**
* Sends all queued chunks up to the player's forward render limit and removes
* them from the queue. MUST be called on the main thread.
*
* @param player the player whose client should be updated.
* @param material material to display the blocks as.
*/
public void check(Player player, Material material) {
final int playerCx = player.getLocation().getChunk().getX();
final int rawVd = player.getClientViewDistance();
final int vd = rawVd > 0 ? rawVd : Math.max(DEFAULT_MIN_VIEW_DISTANCE, player.getWorld().getViewDistance());
final int forwardLimit = playerCx + Math.min(vd, MAX_FORWARD_CHUNKS);
final List<Map.Entry<Integer, Set<Vector>>> toSend = new ArrayList<>();
synchronized (this) {
final Iterator<Map.Entry<Integer, Set<Vector>>> iterator = toChange.entrySet().iterator();
while (iterator.hasNext()) {
final Map.Entry<Integer, Set<Vector>> entry = iterator.next();
if (entry.getKey() <= forwardLimit) {
toSend.add(Map.entry(entry.getKey(), new HashSet<>(entry.getValue())));
iterator.remove();
}
}
}
if (toSend.isEmpty()) {
return;
}
renderBatches(player, toSend, material);
}
/**
* Drains and sends everything currently queued, then clears the queue.
*
* @param player the player whose client should be updated.
* @param material material to display the blocks as.
*/
public void flush(Player player, Material material) {
final List<Map.Entry<Integer, Set<Vector>>> toSend = new ArrayList<>();
synchronized (this) {
for (final Map.Entry<Integer, Set<Vector>> entry : toChange.entrySet()) {
toSend.add(Map.entry(entry.getKey(), new HashSet<>(entry.getValue())));
}
toChange.clear();
}
if (toSend.isEmpty()) {
return;
}
renderBatches(player, toSend, material);
}
/**
* Clears the queue without sending anything.
*/
public synchronized void clear() {
toChange.clear();
}
private void renderBatches(Player player, List<Map.Entry<Integer, Set<Vector>>> batches, Material material) {
final World world = player.getWorld();
for (final Map.Entry<Integer, Set<Vector>> batch : batches) {
final Set<Vector> vectors = batch.getValue();
if (vectors.isEmpty()) {
continue;
}
final List<BlockState> states = new ArrayList<>(vectors.size());
for (final Vector v : vectors) {
final BlockState state = v.toLocation(world).getBlock().getState();
state.setType(material);
states.add(state);
}
player.sendBlockChanges(states);
}
}
}

View file

@ -0,0 +1,76 @@
package dev.loki.loparkour.elytra.section;
import java.util.Random;
import org.bukkit.util.Vector;
/**
* Picks per-knot offsets for the spline-based parkour pipe.
*
* <p>The Y bias is parameterised: default is -15 (gentle descent). Pass a positive
* value (e.g. +15) to make the pipe climb instead.</p>
*/
public class ElytraKnotDirector {
public static final int DEFAULT_VERTICAL_BIAS = -15;
public static final int ASCENDING_VERTICAL_BIAS = 15;
private static final int DX_MEAN = 75;
private static final int DX_SD = 15;
private static final int DY_SD = 3;
private static final int DZ_SD = 35;
private final Random random;
private final int verticalBias;
/**
* @param random source of randomness for the offsets.
* @param verticalBias centre of the normal distribution used for the Y offset.
*/
public ElytraKnotDirector(Random random, int verticalBias) {
this.random = random;
this.verticalBias = verticalBias;
}
/**
* Returns a random offset vector. The Y component is centred on the vertical
* bias so the caller controls whether the section descends, ascends or hovers.
*
* @return a random offset vector.
*/
public Vector nextOffset() {
final int dx = nextOffset(DX_MEAN, DX_SD);
final int dy = nextOffset(verticalBias, DY_SD);
final int dz = nextOffset(0, DZ_SD);
return new Vector(dx, dy, dz);
}
private int nextOffset(int mean, int sd) {
final int start = mean - 2 * sd;
final int end = mean + 2 * sd;
final double[] weights = new double[end - start + 1];
double total = 0.0;
for (int i = 0; i < weights.length; i++) {
final int value = start + i;
weights[i] = normalPdf(mean, sd, value);
total += weights[i];
}
final double roll = random.nextDouble() * total;
double cumulative = 0.0;
for (int i = 0; i < weights.length; i++) {
cumulative += weights[i];
if (roll <= cumulative) {
return start + i;
}
}
return end;
}
private static double normalPdf(double mean, double sd, double value) {
final double variance = sd * sd;
final double diff = value - mean;
return Math.exp(-(diff * diff) / (2 * variance)) / Math.sqrt(2 * Math.PI * variance);
}
}

View file

@ -0,0 +1,85 @@
package dev.loki.loparkour.elytra.section;
import java.util.ArrayList;
import java.util.List;
import org.bukkit.util.Vector;
/**
* Defines how a single spline point expands into a set of display block positions.
*/
public enum ElytraPointType {
CIRCLE(-1) {
@Override
public List<Vector> getPoints(Vector center, int radius) {
if (radius == 0) {
return List.of(center);
}
final List<Vector> blocks = new ArrayList<>();
final double centerX = Math.floor(center.getX()) + HALF_OFFSET;
final double centerY = Math.floor(center.getY()) + HALF_OFFSET;
final double centerZ = Math.floor(center.getZ()) + HALF_OFFSET;
int y = radius;
int z = 0;
int d = CIRCLE_START_D - 2 * radius;
while (z <= y) {
blocks.add(new Vector(centerX, centerY + y, centerZ + z));
blocks.add(new Vector(centerX, centerY + y, centerZ - z));
blocks.add(new Vector(centerX, centerY - y, centerZ + z));
blocks.add(new Vector(centerX, centerY - y, centerZ - z));
blocks.add(new Vector(centerX, centerY + z, centerZ + y));
blocks.add(new Vector(centerX, centerY + z, centerZ - y));
blocks.add(new Vector(centerX, centerY - z, centerZ + y));
blocks.add(new Vector(centerX, centerY - z, centerZ - y));
if (d < 0) {
d += CIRCLE_STEP_MULTIPLIER * z + CIRCLE_INNER_INCREMENT;
} else {
d += CIRCLE_STEP_MULTIPLIER * (z - y) + CIRCLE_OUTER_INCREMENT;
y--;
}
z++;
}
return blocks;
}
},
FLAT(-5) {
@Override
public List<Vector> getPoints(Vector center, int radius) {
final double x = center.getX();
final double y = center.getY();
final double z = center.getZ();
return List.of(
new Vector(x, y, z - 1),
new Vector(x, y, z),
new Vector(x, y, z + 1)
);
}
};
private static final double HALF_OFFSET = 0.5;
private static final int CIRCLE_START_D = 3;
private static final int CIRCLE_STEP_MULTIPLIER = 4;
private static final int CIRCLE_INNER_INCREMENT = 6;
private static final int CIRCLE_OUTER_INCREMENT = 10;
public final int heightOffset;
ElytraPointType(int heightOffset) {
this.heightOffset = heightOffset;
}
/**
* Expands a spline center point into display block positions.
*
* @param center the spline point to expand.
* @param radius the radius of the pipe.
* @return the block positions to display.
*/
public abstract List<Vector> getPoints(Vector center, int radius);
}

View file

@ -0,0 +1,200 @@
package dev.loki.loparkour.elytra.section;
import java.util.ArrayList;
import java.util.HashMap;
import java.util.HashSet;
import java.util.List;
import java.util.Map;
import java.util.Random;
import java.util.Set;
import org.apache.commons.math3.analysis.interpolation.SplineInterpolator;
import org.apache.commons.math3.analysis.polynomials.PolynomialSplineFunction;
import org.bukkit.Material;
import org.bukkit.World;
import org.bukkit.block.BlockState;
import org.bukkit.entity.Player;
import org.bukkit.util.Vector;
/**
* Represents a section of the total parkour of size KNOTS.
*
* <p>Stores {@link Vector} coordinates instead of Bukkit Block instances to avoid
* pinning chunks in memory. Display blocks are sent client-side via
* {@link Player#sendBlockChanges} and undone in {@link #clear(Player)}.</p>
*/
public class ElytraSection {
private static final int KNOTS = 5;
private static final int EXTRA_POINTS_OFFSET = 1;
private static final int CHUNK_SHIFT = 4;
private final ElytraKnotDirector director;
private final SplineInterpolator interpolator;
private final List<Vector> knots;
private final List<Vector> points;
private final boolean ascending;
private Map<Integer, Set<Vector>> blockMap;
/**
* @param start first knot of the section.
* @param random source of randomness for the knot offsets.
* @param verticalBias vertical bias of the knot director.
*/
public ElytraSection(Vector start, Random random, int verticalBias) {
director = new ElytraKnotDirector(random, verticalBias);
interpolator = new SplineInterpolator();
knots = generateKnots(start);
points = generatePoints();
ascending = verticalBias > 0;
}
/**
* @param idx index of the knot.
* @return the knot at the given index.
*/
public Vector getKnot(int idx) {
return knots.get(idx);
}
/**
* @param idx index of the point.
* @return the spline point at the given index.
*/
public Vector getPoint(int idx) {
return points.get(idx);
}
/**
* @param pos position to test against.
* @param idx index of the knot.
* @param distance maximum allowed distance.
* @return whether the position is near the given knot.
*/
public boolean isNearKnot(Vector pos, int idx, double distance) {
return knots.get(idx).distanceSquared(pos) < distance * distance;
}
/**
* @param pos position to test against.
* @param idx index of the point.
* @param distance maximum allowed distance.
* @return whether the position is near the given point.
*/
public boolean isNearPoint(Vector pos, int idx, double distance) {
return points.get(idx).distanceSquared(pos) < distance * distance;
}
/**
* @return a copy of the first knot.
*/
public Vector beginning() {
return knots.get(0).clone();
}
/**
* @return a copy of the last knot.
*/
public Vector end() {
return knots.get(knots.size() - 1).clone();
}
/**
* @return true when this section was generated with an upward vertical bias.
*/
public boolean isAscending() {
return ascending;
}
/**
* Generates the block positions for this section synchronously, grouped by chunk X.
* Results are also stashed in {@link #blockMap} so {@link #clear(Player)} can undo them.
*
* @param pointType how each spline point expands into display blocks.
* @param radius radius of the pipe.
* @return the generated block map, chunk X to block positions.
*/
public Map<Integer, Set<Vector>> generateBlocks(ElytraPointType pointType, int radius) {
final Map<Integer, Set<Vector>> currentBlocks = new HashMap<>();
for (final Vector point : points) {
final List<Vector> pts = pointType.getPoints(point, radius);
for (final Vector pt : pts) {
final int cx = pt.getBlockX() >> CHUNK_SHIFT;
currentBlocks.computeIfAbsent(cx, k -> new HashSet<>()).add(pt);
}
}
blockMap = currentBlocks;
return currentBlocks;
}
/**
* Sends AIR packets for every position this section had drawn on the client,
* then drops the stored map so the GC can reclaim it. MUST be called on the
* main thread.
*
* @param player the player whose client should be updated.
*/
public void clear(Player player) {
final Map<Integer, Set<Vector>> localBlocks = blockMap;
blockMap = null;
if (localBlocks == null) {
return;
}
final World world = player.getWorld();
for (final Map.Entry<Integer, Set<Vector>> entry : localBlocks.entrySet()) {
final Set<Vector> vectors = entry.getValue();
if (vectors.isEmpty()) {
continue;
}
final List<BlockState> states = new ArrayList<>(vectors.size());
for (final Vector v : vectors) {
final BlockState state = v.toLocation(world).getBlock().getState();
state.setType(Material.AIR);
states.add(state);
}
player.sendBlockChanges(states);
}
localBlocks.clear();
}
private List<Vector> generatePoints() {
final List<Vector> splineKnots = new ArrayList<>(knots);
splineKnots.add(0, knots.get(0).clone().subtract(new Vector(EXTRA_POINTS_OFFSET, 0, 0)));
splineKnots.add(knots.get(knots.size() - 1).clone().add(new Vector(EXTRA_POINTS_OFFSET, 0, 0)));
final double[] xs = new double[splineKnots.size()];
final double[] ys = new double[splineKnots.size()];
final double[] zs = new double[splineKnots.size()];
for (int i = 0; i < splineKnots.size(); i++) {
xs[i] = splineKnots.get(i).getX();
ys[i] = splineKnots.get(i).getY();
zs[i] = splineKnots.get(i).getZ();
}
final PolynomialSplineFunction splineY = interpolator.interpolate(xs, ys);
final PolynomialSplineFunction splineZ = interpolator.interpolate(xs, zs);
final List<Vector> result = new ArrayList<>();
final int actualXFirst = (int) xs[EXTRA_POINTS_OFFSET];
final int actualXLast = (int) xs[xs.length - 1 - EXTRA_POINTS_OFFSET];
for (int x = actualXFirst; x <= actualXLast; x++) {
result.add(new Vector(x, (int) splineY.value(x), (int) splineZ.value(x)));
}
return result;
}
private List<Vector> generateKnots(Vector start) {
final List<Vector> result = new ArrayList<>(KNOTS);
result.add(start);
for (int i = 0; i < KNOTS - 1; i++) {
result.add(result.get(result.size() - 1).clone().add(director.nextOffset()));
}
return result;
}
}

View file

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