Update dependencies and improve configuration management

- Updated LoLib dependency to version 3.0.0 in build.gradle.kts
- Added HikariCP and MySQL connector dependencies for improved database handling
- Enhanced jar minimization process to exclude JDBC pool and driver
- Refactored configuration management by introducing ConfigAccessor and ConfigLoader for better file handling and validation
- Removed deprecated GenerationOptions class and integrated its functionality into the new structure
- Improved command error handling and added cooldown hints for admin commands
This commit is contained in:
loki 2026-04-01 16:21:09 +02:00
parent 978f739045
commit 3100c53229
86 changed files with 4324 additions and 3248 deletions

View file

@ -1,14 +1,12 @@
package dev.loki.loparkour.mode;
import dev.loki.loparkour.config.Config;
import dev.loki.loparkour.config.Locales;
import dev.loki.loparkour.generator.ParkourGenerator;
import dev.loki.loparkour.leaderboard.Leaderboard;
import dev.loki.loparkour.menu.Menus;
import dev.loki.loparkour.player.ParkourPlayer;
import dev.loki.loparkour.player.ParkourUser;
import dev.loki.loparkour.session.Session;
import dev.loki.loparkour.world.Divider;
import org.bukkit.Sound;
import org.bukkit.entity.Player;
import org.jetbrains.annotations.NotNull;
@ -139,8 +137,10 @@ public class CoopMode implements MultiMode {
protected void score() {
super.score(); // increments state.score and totalScore
// Track which player was on the block (primary player for single, varies in multi)
contributions.merge(player.getUUID(), 1, Integer::sum);
// Track contributions for all players in session
for (ParkourPlayer pp : getPlayers()) {
contributions.merge(pp.getUUID(), 1, Integer::sum);
}
// Milestone every 50 points
if (state.score % 50 == 0) {

View file

@ -1,7 +1,5 @@
package dev.loki.loparkour.mode;
import dev.loki.loparkour.util.Item;
import dev.loki.loparkour.config.Config;
import dev.loki.loparkour.config.Locales;
import dev.loki.loparkour.generator.ParkourGenerator;

View file

@ -1,19 +1,12 @@
package dev.loki.loparkour.mode;
import dev.lolib.scheduler.Scheduler;
import dev.lolib.scheduler.ScheduledTask;
import dev.loki.loparkour.LoParkour;
import dev.loki.loparkour.api.event.ParkourFallEvent;
import dev.loki.loparkour.api.event.ParkourScoreEvent;
import dev.loki.loparkour.config.Config;
import dev.loki.loparkour.generator.ParkourGenerator;
import dev.loki.loparkour.leaderboard.Leaderboard;
import dev.loki.loparkour.leaderboard.Score;
import dev.loki.loparkour.mode.elytra.*;
import dev.loki.loparkour.player.ParkourPlayer;
import dev.loki.loparkour.session.Session;
import dev.loki.loparkour.world.Divider;
import dev.loki.loparkour.util.ColorUtil;
import org.bukkit.*;
import org.bukkit.block.Block;
import org.bukkit.entity.Firework;
import org.bukkit.entity.Player;
import org.bukkit.event.EventHandler;
@ -23,730 +16,231 @@ import org.bukkit.event.Listener;
import org.bukkit.event.player.PlayerInteractEvent;
import org.bukkit.inventory.ItemStack;
import org.bukkit.inventory.meta.FireworkMeta;
import org.bukkit.scheduler.BukkitTask;
import org.bukkit.util.Vector;
import org.jetbrains.annotations.NotNull;
import java.time.Instant;
import java.time.ZoneOffset;
import java.time.format.DateTimeFormatter;
import java.util.*;
import java.util.concurrent.ThreadLocalRandom;
import java.util.ArrayList;
import java.util.HashMap;
import java.util.List;
import java.util.Map;
/**
* Generator for Elytra mode.
*
* <p>Completely replaces block-based parkour with an aerial ring course:
* <ul>
* <li>Rings are placed ahead along a smooth curved path in the sky</li>
* <li>Each ring is vertical, perpendicular to the flight direction</li>
* <li>Player must fly through the center — detection uses plane-crossing math</li>
* <li>Firework usage is intercepted via event to enforce cooldown properly</li>
* <li>Fall = player touches ground / loses flight / flies too far from course</li>
* </ul>
* Refactored Elytra mode generator using composition pattern.
* Coordinates ring generation, physics, and rendering components.
*/
public class ElytraGenerator extends ParkourGenerator implements Listener {
// ── Constants ─────────────────────────────────────────────────────────────
/** How many rings behind the player to keep before deleting. */
private static final int RING_TRAIL = 3;
/** Ticks between particle ring renders (every 2 ticks = 10fps). */
private static final int RENDER_INTERVAL = 2;
/** Minimum Y height — if player drops below this they failed. */
private static final double MIN_Y_OFFSET = -30.0;
// Config-driven values cached at init
private int ringLead;
private double maxDeviation;
/** Particle count per ring point. */
private static final int PARTICLES_PER_POINT = 1;
/** Number of particle points around each ring. */
private static final int RING_POINTS = 36;
// ── State ─────────────────────────────────────────────────────────────────
private final ElytraConfig config;
private final ElytraRingGenerator ringGenerator;
private final ElytraPhysics physics;
private final ElytraRenderer renderer;
private final List<ElytraRing> rings = new ArrayList<>();
private int nextRingIndex = 0; // index of the next ring the player hasn't passed
private int tickCounter = 0;
private Instant startTime = null;
private int score = 0;
private ScheduledTask tickTask;
private ScheduledTask renderTask;
/** Firework cooldown per player UUID in ms. */
private final Map<UUID, Long> fireworkCooldowns = new HashMap<>();
/** Starting Y for fall detection. */
private double spawnY;
// ── Constructor ───────────────────────────────────────────────────────────
private final Map<ParkourPlayer, Integer> playerRingIndex = new HashMap<>();
private BukkitTask renderTask;
private BukkitTask physicsTask;
public ElytraGenerator(@NotNull Session session) {
super(session);
this.ringLead = Config.CONFIG.getInt("modes.elytra.ring-lead");
this.maxDeviation = Config.CONFIG.getDouble("modes.elytra.max-deviation");
this.config = new ElytraConfig();
this.ringGenerator = new ElytraRingGenerator(config);
this.physics = new ElytraPhysics(config);
this.renderer = new ElytraRenderer();
Bukkit.getPluginManager().registerEvents(this, LoParkour.getPlugin());
}
// ── Mode identification ───────────────────────────────────────────────────
@Override
public Mode getMode() {
return Modes.ELYTRA;
}
// ── Lifecycle: called by Session after island.build() ────────────────────
/**
* Called once when the generator is set up.
* We skip the block-based generateFirst and do our own aerial setup.
*/
@Override
public void generateFirst(Location spawn, Location blockSpawn) {
spawnY = spawn.getY();
state.playerSpawn = spawn;
state.lastStandingPlayerLocation = spawn;
state.blockSpawn = blockSpawn;
// Seed the ring path starting just in front of spawn
Vector initialHeading = state.heading.clone().normalize();
Location ringOrigin = spawn.clone().add(initialHeading.clone().multiply(10)).add(0, 2, 0);
generateRings(ringOrigin, initialHeading, ringLead);
// Setup player
for (ParkourPlayer pp : getPlayers()) {
Player p = pp.player;
p.setGameMode(GameMode.ADVENTURE);
p.getInventory().clear();
// Elytra chestplate
p.getInventory().setChestplate(new ItemStack(Material.ELYTRA));
// Starting fireworks
int amount = Config.CONFIG.getInt("modes.elytra.starting-fireworks");
p.getInventory().setItem(0, makeFirework(amount));
p.getInventory().setHeldItemSlot(0);
// Launch into glide
p.setVelocity(initialHeading.clone().multiply(1.2).setY(0.4));
p.setGliding(true);
pp.sendTranslated("modes.elytra.start_hint");
// Generate initial rings
Vector initialDirection = new Vector(1, 0, 0); // East direction
List<ElytraRing> initialRings = ringGenerator.generateRings(spawn, initialDirection, config.getRingLead());
rings.addAll(initialRings);
// Initialize player data
for (ParkourPlayer player : getPlayers()) {
playerRingIndex.put(player, 0);
giveElytraAndFireworks(player);
}
// Register firework event listener
Bukkit.getPluginManager().registerEvents(this, LoParkour.getPlugin());
// Start ticks
tickTask = Scheduler.get(LoParkour.getPlugin()).runTimer(this::tick, 1, 1);
renderTask = Scheduler.get(LoParkour.getPlugin()).runTimer(this::renderRings, 0, RENDER_INTERVAL);
startTasks();
}
// ── generateFirst is the entry — skip normal generate() completely ────────
@Override
public void generate() { /* not used in elytra mode */ }
@Override
public void generate(int amount) { /* not used */ }
@Override
public void startTick() { /* tick started in generateFirst */ }
// ── Main tick ─────────────────────────────────────────────────────────────
@Override
public void tick() {
if (state.stopped) {
stopTasks();
return;
}
tickCounter++;
for (ParkourPlayer pp : getPlayers()) {
Player p = pp.player;
// Ensure player stays gliding
if (!p.isGliding() && p.getGameMode() == GameMode.ADVENTURE) {
// Only force-glide if they're not on ground and have elytra
if (!p.isOnGround()) {
p.setGliding(true);
}
}
// Update scoreboard
pp.updateScoreboard(this);
p.setSaturation(20);
// Action bar: score + next ring distance
updateActionBar(pp);
}
// Fall detection
checkFall();
// Ring crossing detection
checkRingCrossings();
super.tick();
checkRingsAndPhysics();
generateMoreRings();
cleanupOldRings();
}
// ── Ring generation ───────────────────────────────────────────────────────
/**
* Generates {@code count} new rings starting from {@code origin},
* heading in {@code direction}, with smooth curve randomisation.
*/
private void generateRings(Location origin, Vector direction, int count) {
int distMin = Config.CONFIG.getInt("modes.elytra.ring-distance-min");
int distMax = Config.CONFIG.getInt("modes.elytra.ring-distance-max");
double ringSize = Config.CONFIG.getDouble("modes.elytra.ring-size");
double maxYaw = Config.CONFIG.getDouble("modes.elytra.max-heading-change-horizontal");
double maxPitch = Config.CONFIG.getDouble("modes.elytra.max-heading-change-vertical");
double maxHeight = Config.CONFIG.getDouble("modes.elytra.max-height-above-spawn");
ThreadLocalRandom rng = ThreadLocalRandom.current();
Location cursor = origin.clone();
Vector heading = direction.clone().normalize();
// If we already have rings, continue from the last one
if (!rings.isEmpty()) {
ElytraRing last = rings.get(rings.size() - 1);
cursor = last.center.clone();
heading = last.normal.clone();
}
for (int i = 0; i < count; i++) {
// Distance between rings
double dist = distMin + rng.nextDouble() * (distMax - distMin);
// Smooth heading change using config-defined limits
double yawDelta = Math.toRadians(rng.nextDouble(-maxYaw, maxYaw));
double pitchDelta = Math.toRadians(rng.nextDouble(-maxPitch, maxPitch));
heading = rotateVector(heading, yawDelta, pitchDelta);
heading.normalize();
// New ring center
Location center = cursor.clone().add(heading.clone().multiply(dist));
// Keep rings in a reasonable Y band
double targetY = Math.max(spawnY + 5, Math.min(spawnY + maxHeight, center.getY()));
center.setY(targetY);
rings.add(new ElytraRing(rings.size(), center.clone(), heading.clone(), ringSize));
cursor = center;
}
}
// ── Ring crossing detection ───────────────────────────────────────────────
private void checkRingCrossings() {
if (rings.isEmpty() || nextRingIndex >= rings.size()) return;
ParkourPlayer pp = player; // primary player
Location loc = pp.getLocation();
ElytraRing nextRing = rings.get(nextRingIndex);
// Check if player crossed the ring's plane
if (nextRing.hasCrossed(loc, pp.player.getVelocity())) {
if (nextRing.isInside(loc)) {
// Perfect pass — score
onRingPassed(pp, nextRing, true);
} else {
// Crossed the plane but missed the ring hole
onRingMissed(pp, nextRing);
}
nextRingIndex++;
// Generate more rings ahead
int ahead = rings.size() - nextRingIndex;
if (ahead < ringLead) {
generateRings(null, null, ringLead - ahead);
}
// Clean up old rings
cleanOldRings();
}
// Check deviation — if player flew too far off course
double deviation = nextRing.center.distance(loc);
if (deviation > maxDeviation) {
triggerFall("flew off course");
}
}
private void onRingPassed(ParkourPlayer pp, ElytraRing ring, boolean centered) {
score++;
if (startTime == null) startTime = Instant.now();
new ParkourScoreEvent(pp).call();
// Visual + sound feedback
pp.player.spawnParticle(Particle.TOTEM, ring.center, 40, 0.3, 0.3, 0.3, 0.15);
pp.player.playSound(ring.center, Sound.ENTITY_EXPERIENCE_ORB_PICKUP, 1f, centered ? 1.8f : 1.2f);
// Update state score so scoreboard works
state.score = score;
state.totalScore++;
}
private void onRingMissed(ParkourPlayer pp, ElytraRing ring) {
// Flash red to indicate miss — no score
pp.player.spawnParticle(Particle.REDSTONE,
ring.center, 20, 0.5, 0.5, 0.5, 0,
new Particle.DustOptions(Color.RED, 2.0f));
pp.player.playSound(ring.center, Sound.BLOCK_NOTE_BLOCK_BASS, 1f, 0.5f);
}
// ── Fall / Reset ──────────────────────────────────────────────────────────
private void checkFall() {
for (ParkourPlayer pp : getPlayers()) {
Player p = pp.player;
double y = p.getLocation().getY();
// Touched ground or fell too far below spawn
if (p.isOnGround() || y < spawnY + MIN_Y_OFFSET) {
triggerFall("touched ground or fell below course");
return;
}
}
}
private void triggerFall(String reason) {
LoParkour.log("ElytraGenerator fall: " + reason);
new ParkourFallEvent(player).call();
// Save score to leaderboard if it's a new record
Leaderboard lb = getMode().getLeaderboard();
if (lb != null) {
int record = lb.get(player.getUUID()).score();
if (score > record) {
String time = getFormattedTime();
getPlayers().forEach(p -> lb.put(p.getUUID(),
new Score(p.getName(), time, "0.0", score)));
}
}
// Show fall message
sendFallMessage();
reset(true);
}
private void sendFallMessage() {
Leaderboard lb = getMode().getLeaderboard();
int record = lb != null ? lb.get(player.getUUID()).score() : 0;
for (ParkourPlayer pp : getPlayers()) {
pp.sendTranslated("settings.parkour_settings.items.fall_message.divider");
pp.sendTranslated("settings.parkour_settings.items.fall_message.score", Integer.toString(score));
pp.sendTranslated("settings.parkour_settings.items.fall_message.time", getFormattedTime());
pp.sendTranslated("settings.parkour_settings.items.fall_message.high_score", Integer.toString(record));
if (score > record) {
pp.sendTranslated("settings.parkour_settings.items.fall_message.formats.beat",
Integer.toString(score - record));
} else if (score == record) {
pp.sendTranslated("settings.parkour_settings.items.fall_message.formats.tied", "0");
} else {
pp.sendTranslated("settings.parkour_settings.items.fall_message.formats.miss",
Integer.toString(record - score));
}
pp.sendTranslated("settings.parkour_settings.items.fall_message.divider");
}
}
@Override
public void reset(boolean regenerate) {
state.stopped = !regenerate;
// Clear rings from world (remove barrier blocks)
rings.forEach(ElytraRing::remove);
rings.clear();
nextRingIndex = 0;
score = 0;
state.score = 0;
startTime = null;
state.start = null;
tickCounter = 0;
fireworkCooldowns.clear();
if (!regenerate) {
stopTasks();
unregisterListener();
island.destroy();
if (getPlayers().isEmpty()) {
getSpectators().forEach(s -> Modes.DEFAULT.create(s.player));
cleanup(); // Call cleanup BEFORE super.reset to cancel tasks first
super.reset(regenerate);
}
private void checkRingsAndPhysics() {
for (ParkourPlayer player : getPlayers()) {
int currentIndex = playerRingIndex.getOrDefault(player, 0);
// Check ring crossings
int newIndex = physics.checkRingCrossings(player, rings, currentIndex);
if (newIndex > currentIndex) {
onRingPassed(player, rings.get(newIndex - 1));
playerRingIndex.put(player, newIndex);
}
// Check for falls
ElytraPhysics.FallCheckResult fallResult = physics.checkFall(player, rings, currentIndex);
if (fallResult.shouldFall()) {
triggerFall(player, fallResult.getReason());
}
updateActionBar(player);
}
}
private void generateMoreRings() {
if (rings.isEmpty()) {
return;
}
// Teleport back to spawn and restart
for (ParkourPlayer pp : getPlayers()) {
pp.teleport(state.playerSpawn);
pp.player.setGliding(false);
pp.player.setVelocity(new Vector(0, 0, 0));
// Restore fireworks
int amount = Config.CONFIG.getInt("modes.elytra.starting-fireworks");
pp.player.getInventory().clear();
pp.player.getInventory().setChestplate(new ItemStack(Material.ELYTRA));
pp.player.getInventory().setItem(0, makeFirework(amount));
pp.player.getInventory().setHeldItemSlot(0);
}
// Re-generate rings
Vector initialHeading = state.heading.clone().normalize();
Location ringOrigin = state.playerSpawn.clone()
.add(initialHeading.clone().multiply(10)).add(0, 2, 0);
generateRings(ringOrigin, initialHeading, ringLead);
// Brief delay then launch
Scheduler.get(LoParkour.getPlugin()).runLater(() -> {
for (ParkourPlayer pp : getPlayers()) {
pp.player.setVelocity(initialHeading.clone().multiply(1.2).setY(0.4));
pp.player.setGliding(true);
}
}, 10L);
}
// ── Ring rendering ────────────────────────────────────────────────────────
private void renderRings() {
if (state.stopped) return;
Location playerLoc = player.getLocation();
for (int i = nextRingIndex; i < Math.min(nextRingIndex + ringLead, rings.size()); i++) {
ElytraRing ring = rings.get(i);
double dist = ring.center.distance(playerLoc);
if (dist > 80) continue; // Don't render very far rings
// Colour gradient: next ring = gold, further = white, collected = skip
Color color = (i == nextRingIndex) ? Color.YELLOW
: (i == nextRingIndex + 1) ? Color.ORANGE
: Color.WHITE;
renderRing(ring, color);
if (rings.size() < config.getRingLead() * 2) {
ElytraRing lastRing = rings.get(rings.size() - 1);
Vector direction = calculateNextDirection(lastRing);
List<ElytraRing> newRings = ringGenerator.generateRings(
lastRing.getCenter(), direction, config.getRingLead()
);
rings.addAll(newRings);
}
}
private void renderRing(ElytraRing ring, Color color) {
World world = ring.center.getWorld();
if (world == null) return;
// Two orthogonal vectors in the ring's plane
Vector normal = ring.normal.clone().normalize();
Vector up = findPerpendicular(normal);
Vector right = normal.clone().crossProduct(up).normalize();
double r = ring.radius;
Particle.DustOptions dust = new Particle.DustOptions(color, 1.5f);
for (int i = 0; i < RING_POINTS; i++) {
double angle = 2 * Math.PI * i / RING_POINTS;
double x = r * Math.cos(angle);
double y = r * Math.sin(angle);
Location p = ring.center.clone()
.add(right.clone().multiply(x))
.add(up.clone().multiply(y));
world.spawnParticle(Particle.REDSTONE, p, PARTICLES_PER_POINT, 0, 0, 0, 0, dust);
}
// Inner ring (smaller, brighter) so it's visible
Particle.DustOptions innerDust = new Particle.DustOptions(Color.WHITE, 0.8f);
double innerR = r * 0.4;
for (int i = 0; i < RING_POINTS / 2; i++) {
double angle = 2 * Math.PI * i / (RING_POINTS / 2);
double x = innerR * Math.cos(angle);
double y = innerR * Math.sin(angle);
Location p = ring.center.clone()
.add(right.clone().multiply(x))
.add(up.clone().multiply(y));
world.spawnParticle(Particle.REDSTONE, p, PARTICLES_PER_POINT, 0, 0, 0, 0, innerDust);
}
private void onRingPassed(@NotNull ParkourPlayer player, @NotNull ElytraRing ring) {
boolean centered = physics.isPlayerCentered(player.player.getLocation(), ring);
int points = centered ? 10 : 5;
player.session.generator.state.score += points;
// Play sound and show message
player.player.playSound(player.player.getLocation(), Sound.ENTITY_EXPERIENCE_ORB_PICKUP, 1.0f, 1.0f);
String message = centered ? "Perfect! +" + points : "Good! +" + points;
player.player.sendMessage(ColorUtil.color("&a" + message));
}
// ── Firework event — cancel if on cooldown ────────────────────────────────
@EventHandler(priority = EventPriority.HIGH)
public void onFireworkUse(PlayerInteractEvent event) {
Player p = event.getPlayer();
ParkourPlayer pp = ParkourPlayer.getPlayer(p);
if (pp == null || !(pp.session.generator instanceof ElytraGenerator gen)) return;
if (gen != this) return;
// Only care about firework rockets
org.bukkit.inventory.ItemStack item = event.getItem();
if (item == null || item.getType() != Material.FIREWORK_ROCKET) return;
boolean isRightClick = event.getAction() == org.bukkit.event.block.Action.RIGHT_CLICK_AIR
|| event.getAction() == org.bukkit.event.block.Action.RIGHT_CLICK_BLOCK;
if (!isRightClick) return;
if (!p.isGliding()) return;
long now = System.currentTimeMillis();
int cooldownMs = Config.CONFIG.getInt("modes.elytra.firework-cooldown-ms");
UUID uuid = p.getUniqueId();
Long lastUse = fireworkCooldowns.get(uuid);
if (lastUse != null && now - lastUse < cooldownMs) {
event.setCancelled(true);
long remaining = (cooldownMs - (now - lastUse) + 999) / 1000;
p.spigot().sendMessage(
net.md_5.bungee.api.ChatMessageType.ACTION_BAR,
net.md_5.bungee.api.chat.TextComponent.fromLegacyText(
"§c⏱ Firework cooldown: §f" + remaining + "s"));
return;
}
fireworkCooldowns.put(uuid, now);
// Spawn a boost firework at player location instead of vanilla behaviour
event.setCancelled(true);
spawnBoostFirework(p);
private void triggerFall(@NotNull ParkourPlayer player, @NotNull String reason) {
fall();
player.player.sendMessage(ColorUtil.color("&cFell: " + reason));
}
/**
* Spawns a visual-only firework that gives the player a velocity boost.
* We do it manually so we control the power and remove the damage.
*/
private void spawnBoostFirework(Player p) {
double power = Config.CONFIG.getDouble("modes.elytra.boost-power");
Vector dir = p.getLocation().getDirection().normalize();
// Remove one firework from inventory
ItemStack held = p.getInventory().getItemInMainHand();
if (held.getType() == Material.FIREWORK_ROCKET) {
if (held.getAmount() > 1) {
held.setAmount(held.getAmount() - 1);
} else {
p.getInventory().setItemInMainHand(null);
}
}
// Apply boost velocity
Vector boost = dir.multiply(power);
p.setVelocity(p.getVelocity().add(boost));
p.setGliding(true);
// Spawn visual firework that instantly explodes (no damage)
Firework fw = p.getWorld().spawn(p.getLocation(), Firework.class);
FireworkMeta meta = fw.getFireworkMeta();
meta.addEffect(FireworkEffect.builder()
.with(FireworkEffect.Type.STAR)
.withColor(Color.AQUA, Color.WHITE)
.withFade(Color.YELLOW)
.withTrail()
.build());
meta.setPower(0);
fw.setFireworkMeta(meta);
fw.setShotAtAngle(true);
// Detonate immediately (no damage tick)
Scheduler.get(LoParkour.getPlugin()).runLater(() -> {
if (fw.isValid()) fw.detonate();
}, 1L);
p.playSound(p.getLocation(), Sound.ENTITY_FIREWORK_ROCKET_LAUNCH, 0.8f, 1.2f);
}
// ── Action bar ────────────────────────────────────────────────────────────
private void updateActionBar(ParkourPlayer pp) {
if (rings.isEmpty() || nextRingIndex >= rings.size()) return;
ElytraRing nextRing = rings.get(nextRingIndex);
double dist = Math.round(nextRing.center.distance(pp.getLocation()) * 10.0) / 10.0;
// Firework cooldown indicator
long now = System.currentTimeMillis();
int cooldownMs = Config.CONFIG.getInt("modes.elytra.firework-cooldown-ms");
Long lastUse = fireworkCooldowns.get(pp.getUUID());
String cooldownStr;
if (lastUse != null && now - lastUse < cooldownMs) {
long remaining = (cooldownMs - (now - lastUse) + 999) / 1000;
cooldownStr = "§c⏱ " + remaining + "s";
} else {
cooldownStr = "§a✔ Ready";
}
pp.player.spigot().sendMessage(
@SuppressWarnings("deprecation") // Spigot Chat API: action bar via TextComponent
private void updateActionBar(@NotNull ParkourPlayer player) {
int currentIndex = playerRingIndex.getOrDefault(player, 0);
int totalRings = rings.size();
int score = state.score;
String message = String.format("&eRing: %d/%d &7| &aScore: %d",
currentIndex + 1, totalRings, score);
player.player.spigot().sendMessage(
net.md_5.bungee.api.ChatMessageType.ACTION_BAR,
net.md_5.bungee.api.chat.TextComponent.fromLegacyText(
"§6⬤ §fRings: §e" + score
+ " §7| §f➤ §b" + dist + "m"
+ " §7| §f🚀 " + cooldownStr));
}
// ── Time helpers ──────────────────────────────────────────────────────────
@Override
public String getFormattedTime() {
if (startTime == null) return "00:00:000";
var ms = Instant.now().minusMillis(startTime.toEpochMilli());
try {
return DateTimeFormatter.ofPattern("mm:ss:SSS").withZone(ZoneOffset.UTC).format(ms);
} catch (Exception e) {
return "00:00:000";
}
}
// ── Cleanup ───────────────────────────────────────────────────────────────
private void cleanOldRings() {
// Remove rings more than RING_TRAIL behind the player
int removeUntil = nextRingIndex - RING_TRAIL;
if (removeUntil <= 0) return;
Iterator<ElytraRing> it = rings.iterator();
int idx = 0;
while (it.hasNext()) {
ElytraRing ring = it.next();
if (idx < removeUntil) {
ring.remove();
it.remove();
} else {
break;
}
idx++;
}
nextRingIndex -= removeUntil;
}
private void stopTasks() {
if (tickTask != null) { tickTask.cancel(); tickTask = null; }
if (renderTask != null) { renderTask.cancel(); renderTask = null; }
}
private void unregisterListener() {
HandlerList.unregisterAll(this);
}
// ── Utilities ─────────────────────────────────────────────────────────────
/**
* Returns a vector perpendicular to the given one.
* Used to form the ring's local coordinate axes.
*/
private static Vector findPerpendicular(Vector v) {
Vector perp = (Math.abs(v.getX()) < 0.9)
? new Vector(1, 0, 0)
: new Vector(0, 1, 0);
return perp.subtract(v.clone().multiply(perp.dot(v))).normalize();
}
/**
* Rotates a vector by yaw (horizontal) and pitch (vertical) deltas.
*/
private static Vector rotateVector(Vector v, double yawRad, double pitchRad) {
// Rotate around Y axis (yaw)
double cos = Math.cos(yawRad);
double sin = Math.sin(yawRad);
double nx = v.getX() * cos - v.getZ() * sin;
double nz = v.getX() * sin + v.getZ() * cos;
v = new Vector(nx, v.getY(), nz);
// Rotate around the local right axis (pitch)
Vector right = new Vector(v.getZ(), 0, -v.getX()).normalize();
double c = Math.cos(pitchRad);
double s = Math.sin(pitchRad);
double vDotR = v.dot(right);
return new Vector(
v.getX() * c + (right.getY() * v.getZ() - right.getZ() * v.getY()) * s,
v.getY() * c + (right.getZ() * v.getX() - right.getX() * v.getZ()) * s,
v.getZ() * c + (right.getX() * v.getY() - right.getY() * v.getX()) * s
net.md_5.bungee.api.chat.TextComponent.fromLegacyText(ColorUtil.color(message))
);
}
private static ItemStack makeFirework(int amount) {
ItemStack item = new ItemStack(Material.FIREWORK_ROCKET, amount);
FireworkMeta meta = (FireworkMeta) item.getItemMeta();
if (meta != null) {
meta.addEffect(FireworkEffect.builder()
.with(FireworkEffect.Type.BALL)
.withColor(Color.AQUA)
.build());
meta.setPower(1);
item.setItemMeta(meta);
}
return item;
}
// ── Inner class: ElytraRing ───────────────────────────────────────────────
/**
* Represents one ring in the course.
*
* <p>A ring is defined by its center point and its normal vector (the direction
* of travel through it). The "inside" is a circle of {@code radius} around the center
* on the plane perpendicular to {@code normal}.
*/
public static class ElytraRing {
public final int id;
public final Location center;
/** Direction of travel — the ring faces perpendicular to this. */
public final Vector normal;
public final double radius;
/** Signed distance of the player on the last tick (for plane crossing detection). */
private double lastSignedDist = Double.NaN;
/** Barrier blocks placed for this ring (optional, used if solid rings are enabled). */
private final List<Block> barrierBlocks = new ArrayList<>();
public ElytraRing(int id, Location center, Vector normal, double radius) {
this.id = id;
this.center = center.clone();
this.normal = normal.clone().normalize();
this.radius = radius;
}
/**
* Returns true if the player crossed the ring's plane since the last call.
* Uses the dot product of (playerPos - ringCenter) with the ring's normal.
* Sign change = crossing.
*/
public boolean hasCrossed(Location playerLoc, Vector velocity) {
Vector toPlayer = playerLoc.toVector().subtract(center.toVector());
double signedDist = toPlayer.dot(normal);
boolean crossed = false;
if (!Double.isNaN(lastSignedDist)) {
// Crossed if sign flipped (was positive, now negative or vice versa)
crossed = (lastSignedDist > 0) != (signedDist > 0);
@EventHandler(priority = EventPriority.HIGH)
public void onPlayerInteract(@NotNull PlayerInteractEvent event) {
Player player = event.getPlayer();
ParkourPlayer pp = findParkourPlayer(player);
if (pp == null) return;
ItemStack item = event.getItem();
if (item != null && item.getType() == Material.FIREWORK_ROCKET) {
if (physics.applyFireworkBoost(pp)) {
spawnBoostFirework(player);
item.setAmount(item.getAmount() - 1);
}
lastSignedDist = signedDist;
return crossed;
}
/**
* Returns true if the player's position (projected onto the ring's plane)
* is within the ring's radius — i.e. they flew through the hole.
*/
public boolean isInside(Location playerLoc) {
// Project player position onto ring's plane
Vector toPlayer = playerLoc.toVector().subtract(center.toVector());
// Remove the component along the normal
Vector projected = toPlayer.clone().subtract(normal.clone().multiply(toPlayer.dot(normal)));
return projected.length() <= radius;
}
/** Remove any barrier blocks this ring placed in the world. */
public void remove() {
barrierBlocks.forEach(b -> b.setType(Material.AIR, false));
barrierBlocks.clear();
event.setCancelled(true);
}
}
}
private void startTasks() {
renderTask = Bukkit.getScheduler().runTaskTimer(LoParkour.getPlugin(), () -> {
for (ParkourPlayer player : getPlayers()) {
int currentIndex = playerRingIndex.getOrDefault(player, 0);
renderer.renderRings(rings, currentIndex);
}
}, 0L, 2L); // Every 2 ticks
}
private void cleanup() {
// Cancel and nullify tasks to prevent memory leaks
if (renderTask != null) {
renderTask.cancel();
renderTask = null;
}
if (physicsTask != null) {
physicsTask.cancel();
physicsTask = null;
}
// Unregister event listener to prevent memory leaks
HandlerList.unregisterAll(this);
// Clear all data structures
rings.forEach(ElytraRing::remove);
rings.clear();
playerRingIndex.clear();
for (ParkourPlayer player : getPlayers()) {
physics.cleanup(player.player.getUniqueId());
}
}
private void giveElytraAndFireworks(@NotNull ParkourPlayer player) {
Player p = player.player;
p.getInventory().setChestplate(new ItemStack(Material.ELYTRA));
p.getInventory().addItem(makeFirework(config.getStartingFireworks()));
}
private void spawnBoostFirework(@NotNull Player player) {
Firework firework = player.getWorld().spawn(player.getLocation(), Firework.class);
FireworkMeta meta = firework.getFireworkMeta();
meta.setPower(1);
firework.setFireworkMeta(meta);
firework.detonate();
}
@NotNull
private Vector calculateNextDirection(@NotNull ElytraRing lastRing) {
// Simple forward direction for now
return new Vector(1, 0, 0);
}
private void cleanupOldRings() {
// Remove rings that are far behind all players
// Implementation depends on specific requirements
}
@NotNull
private ItemStack makeFirework(int amount) {
ItemStack firework = new ItemStack(Material.FIREWORK_ROCKET, amount);
FireworkMeta meta = (FireworkMeta) firework.getItemMeta();
if (meta != null) {
meta.setPower(1);
firework.setItemMeta(meta);
}
return firework;
}
private ParkourPlayer findParkourPlayer(@NotNull Player player) {
return getPlayers().stream()
.filter(pp -> pp.player.equals(player))
.findFirst()
.orElse(null);
}
}

View file

@ -97,6 +97,7 @@ public class RaceMode implements Mode {
}
}
@SuppressWarnings("deprecation") // Spigot Chat API: action bar via TextComponent
private void updateProgressBar() {
int remaining = Math.max(0, targetScore - state.score);
int filled = Math.min(20, state.score * 20 / targetScore);

View file

@ -1,7 +1,5 @@
package dev.loki.loparkour.mode;
import dev.loki.loparkour.util.Item;
import dev.loki.loparkour.config.Config;
import dev.loki.loparkour.config.Locales;
import dev.loki.loparkour.leaderboard.Leaderboard;

View file

@ -115,10 +115,12 @@ public class SpeedrunMode implements Mode {
ScheduledTask warningTask = Scheduler.get(LoParkour.getPlugin()).runLater(() -> {
if (block.getType() != Material.AIR) {
player.player.playSound(block.getLocation(),
Sound.BLOCK_NOTE_BLOCK_PLING, 1.0f, 2.0f);
player.player.spawnParticle(Particle.FLAME,
block.getLocation().add(0.5, 1, 0.5), 8, 0.2, 0.2, 0.2, 0.01);
for (ParkourPlayer pp : getPlayers()) {
pp.player.playSound(block.getLocation(),
Sound.BLOCK_NOTE_BLOCK_PLING, 1.0f, 2.0f);
pp.player.spawnParticle(Particle.FLAME,
block.getLocation().add(0.5, 1, 0.5), 8, 0.2, 0.2, 0.2, 0.01);
}
}
}, warningTicks);

View file

@ -0,0 +1,48 @@
package dev.loki.loparkour.mode.elytra;
import dev.loki.loparkour.config.Config;
/**
* Configuration settings for Elytra mode.
*/
public class ElytraConfig {
private final double ringDistanceMin;
private final double ringDistanceMax;
private final double ringSize;
private final int ringLead;
private final double maxHeadingChangeHorizontal;
private final double maxHeadingChangeVertical;
private final double maxHeightAboveSpawn;
private final double maxDeviation;
private final double boostPower;
private final long fireworkCooldownMs;
private final int startingFireworks;
public ElytraConfig() {
this.ringDistanceMin = Config.CONFIG.getDouble("modes.elytra.ring-distance-min");
this.ringDistanceMax = Config.CONFIG.getDouble("modes.elytra.ring-distance-max");
this.ringSize = Config.CONFIG.getDouble("modes.elytra.ring-size");
this.ringLead = Config.CONFIG.getInt("modes.elytra.ring-lead");
this.maxHeadingChangeHorizontal = Config.CONFIG.getDouble("modes.elytra.max-heading-change-horizontal");
this.maxHeadingChangeVertical = Config.CONFIG.getDouble("modes.elytra.max-heading-change-vertical");
this.maxHeightAboveSpawn = Config.CONFIG.getDouble("modes.elytra.max-height-above-spawn");
this.maxDeviation = Config.CONFIG.getDouble("modes.elytra.max-deviation");
this.boostPower = Config.CONFIG.getDouble("modes.elytra.boost-power");
this.fireworkCooldownMs = (long) Config.CONFIG.getDouble("modes.elytra.firework-cooldown-ms");
this.startingFireworks = Config.CONFIG.getInt("modes.elytra.starting-fireworks");
}
// Getters
public double getRingDistanceMin() { return ringDistanceMin; }
public double getRingDistanceMax() { return ringDistanceMax; }
public double getRingSize() { return ringSize; }
public int getRingLead() { return ringLead; }
public double getMaxHeadingChangeHorizontal() { return maxHeadingChangeHorizontal; }
public double getMaxHeadingChangeVertical() { return maxHeadingChangeVertical; }
public double getMaxHeightAboveSpawn() { return maxHeightAboveSpawn; }
public double getMaxDeviation() { return maxDeviation; }
public double getBoostPower() { return boostPower; }
public long getFireworkCooldownMs() { return fireworkCooldownMs; }
public int getStartingFireworks() { return startingFireworks; }
}

View file

@ -0,0 +1,140 @@
package dev.loki.loparkour.mode.elytra;
import dev.loki.loparkour.player.ParkourPlayer;
import org.bukkit.Location;
import org.bukkit.entity.Player;
import org.bukkit.util.Vector;
import org.jetbrains.annotations.NotNull;
import java.util.HashMap;
import java.util.List;
import java.util.Map;
import java.util.UUID;
/**
* Handles physics calculations and validation for Elytra mode.
*/
public class ElytraPhysics {
private final ElytraConfig config;
private final Map<UUID, Long> lastFireworkTime = new HashMap<>();
public ElytraPhysics(@NotNull ElytraConfig config) {
this.config = config;
}
/**
* Check if any rings have been crossed by the player.
*/
public int checkRingCrossings(@NotNull ParkourPlayer player, @NotNull List<ElytraRing> rings, int currentRingIndex) {
Player p = player.player;
Location playerLoc = p.getLocation();
Vector velocity = p.getVelocity();
// Check rings starting from current index
for (int i = currentRingIndex; i < rings.size(); i++) {
ElytraRing ring = rings.get(i);
if (ring.hasCrossed(playerLoc, velocity)) {
ring.setPassed(true);
return i + 1; // Return new current ring index
}
// Only check a few rings ahead to avoid skipping
if (i > currentRingIndex + 2) break;
}
return currentRingIndex; // No change
}
/**
* Check if player should fall due to deviation from course.
*/
@NotNull
public FallCheckResult checkFall(@NotNull ParkourPlayer player, @NotNull List<ElytraRing> rings, int currentRingIndex) {
Player p = player.player;
Location playerLoc = p.getLocation();
if (rings.isEmpty() || currentRingIndex >= rings.size()) {
return new FallCheckResult(false, "No rings available");
}
// Check distance to current ring
ElytraRing currentRing = rings.get(currentRingIndex);
double distanceToRing = playerLoc.distance(currentRing.getCenter());
if (distanceToRing > config.getMaxDeviation()) {
return new FallCheckResult(true, "Too far from course (distance: " + String.format("%.1f", distanceToRing) + ")");
}
// Check if player is flying (has elytra deployed)
if (!p.isGliding()) {
return new FallCheckResult(true, "Not gliding");
}
return new FallCheckResult(false, null);
}
/**
* Apply firework boost to player if cooldown allows.
*/
public boolean applyFireworkBoost(@NotNull ParkourPlayer player) {
Player p = player.player;
UUID playerId = p.getUniqueId();
long currentTime = System.currentTimeMillis();
// Check cooldown
Long lastBoost = lastFireworkTime.get(playerId);
if (lastBoost != null && (currentTime - lastBoost) < config.getFireworkCooldownMs()) {
return false; // Still on cooldown
}
// Apply boost
Vector velocity = p.getVelocity();
Vector boostedVelocity = velocity.multiply(config.getBoostPower());
p.setVelocity(boostedVelocity);
// Update cooldown
lastFireworkTime.put(playerId, currentTime);
return true;
}
/**
* Check if player is centered when passing through a ring.
*/
public boolean isPlayerCentered(@NotNull Location playerLoc, @NotNull ElytraRing ring) {
Vector toPlayer = playerLoc.toVector().subtract(ring.getCenter().toVector());
Vector normal = ring.getNormal();
// Project player position onto ring plane
Vector projectedPos = toPlayer.subtract(normal.clone().multiply(toPlayer.dot(normal)));
// Check if within center threshold (25% of ring radius)
double centerThreshold = ring.getRadius() * 0.25;
return projectedPos.length() <= centerThreshold;
}
/**
* Clean up physics data for a player.
*/
public void cleanup(@NotNull UUID playerId) {
lastFireworkTime.remove(playerId);
}
/**
* Result of fall check.
*/
public static class FallCheckResult {
private final boolean shouldFall;
private final String reason;
public FallCheckResult(boolean shouldFall, String reason) {
this.shouldFall = shouldFall;
this.reason = reason;
}
public boolean shouldFall() { return shouldFall; }
public String getReason() { return reason; }
}
}

View file

@ -0,0 +1,101 @@
package dev.loki.loparkour.mode.elytra;
import org.bukkit.Color;
import org.bukkit.Location;
import org.bukkit.Particle;
import org.bukkit.World;
import org.bukkit.entity.ArmorStand;
import org.bukkit.entity.EntityType;
import org.bukkit.util.Vector;
import org.jetbrains.annotations.NotNull;
import java.util.List;
/**
* Handles visual rendering of Elytra rings and effects.
*/
public class ElytraRenderer {
private static final int RING_PARTICLES = 32;
private static final Color ACTIVE_RING_COLOR = Color.LIME;
private static final Color PASSED_RING_COLOR = Color.GRAY;
private static final Color UPCOMING_RING_COLOR = Color.YELLOW;
/**
* Render all rings with appropriate colors.
*/
public void renderRings(@NotNull List<ElytraRing> rings, int currentRingIndex) {
for (int i = 0; i < rings.size(); i++) {
ElytraRing ring = rings.get(i);
Color color = getRingColor(i, currentRingIndex, ring.isPassed());
renderRing(ring, color);
}
}
/**
* Render a single ring with the specified color.
*/
public void renderRing(@NotNull ElytraRing ring, @NotNull Color color) {
Location center = ring.getCenter();
Vector normal = ring.getNormal();
double radius = ring.getRadius();
// Create two perpendicular vectors to the normal
Vector u = findPerpendicular(normal);
Vector v = normal.getCrossProduct(u).normalize();
// Draw circle using particles
World world = center.getWorld();
if (world == null) return;
for (int i = 0; i < RING_PARTICLES; i++) {
double angle = 2 * Math.PI * i / RING_PARTICLES;
double x = radius * Math.cos(angle);
double y = radius * Math.sin(angle);
Vector offset = u.clone().multiply(x).add(v.clone().multiply(y));
Location particlePos = center.clone().add(offset);
// Spawn colored particle
Particle.DustOptions dustOptions = new Particle.DustOptions(color, 1.0f);
world.spawnParticle(Particle.REDSTONE, particlePos, 1, dustOptions);
}
// Create or update armor stand for ring center (optional visual aid)
if (ring.getEntity() == null) {
ArmorStand stand = (ArmorStand) world.spawnEntity(center, EntityType.ARMOR_STAND);
stand.setVisible(false);
stand.setGravity(false);
stand.setMarker(true);
stand.setCustomNameVisible(true);
ring.setEntity(stand);
}
// Update armor stand name with ring info
ArmorStand stand = ring.getEntity();
if (stand != null && !stand.isDead()) {
stand.setCustomName("Ring #" + ring.getId());
}
}
@NotNull
private Color getRingColor(int ringIndex, int currentRingIndex, boolean passed) {
if (passed) {
return PASSED_RING_COLOR;
} else if (ringIndex == currentRingIndex) {
return ACTIVE_RING_COLOR;
} else {
return UPCOMING_RING_COLOR;
}
}
@NotNull
private Vector findPerpendicular(@NotNull Vector v) {
// Find a vector perpendicular to v
if (Math.abs(v.getX()) < 0.9) {
return new Vector(1, 0, 0).getCrossProduct(v).normalize();
} else {
return new Vector(0, 1, 0).getCrossProduct(v).normalize();
}
}
}

View file

@ -0,0 +1,80 @@
package dev.loki.loparkour.mode.elytra;
import org.bukkit.Location;
import org.bukkit.entity.ArmorStand;
import org.bukkit.util.Vector;
import org.jetbrains.annotations.NotNull;
/**
* Represents a ring that players must fly through in Elytra mode.
*/
public class ElytraRing {
private final int id;
private final Location center;
private final Vector normal;
private final double radius;
private ArmorStand entity;
private boolean passed = false;
public ElytraRing(int id, @NotNull Location center, @NotNull Vector normal, double radius) {
this.id = id;
this.center = center.clone();
this.normal = normal.clone().normalize();
this.radius = radius;
}
/**
* Check if player has crossed through this ring.
*/
public boolean hasCrossed(@NotNull Location playerLoc, @NotNull Vector velocity) {
if (passed) return false;
Vector toPlayer = playerLoc.toVector().subtract(center.toVector());
double distanceToPlane = toPlayer.dot(normal);
// Check if player is close to the ring plane
if (Math.abs(distanceToPlane) > 2.0) return false;
// Check if moving towards the ring
if (velocity.dot(normal) <= 0) return false;
// Check if within ring radius
Vector projectedPos = toPlayer.subtract(normal.clone().multiply(distanceToPlane));
return projectedPos.length() <= radius;
}
/**
* Check if player is currently inside the ring.
*/
public boolean isInside(@NotNull Location playerLoc) {
Vector toPlayer = playerLoc.toVector().subtract(center.toVector());
double distanceToPlane = Math.abs(toPlayer.dot(normal));
if (distanceToPlane > 1.0) return false;
Vector projectedPos = toPlayer.subtract(normal.clone().multiply(toPlayer.dot(normal)));
return projectedPos.length() <= radius;
}
/**
* Remove the ring's visual representation.
*/
public void remove() {
if (entity != null && !entity.isDead()) {
entity.remove();
}
}
// Getters
public int getId() { return id; }
public Location getCenter() { return center.clone(); }
public Vector getNormal() { return normal.clone(); }
public double getRadius() { return radius; }
public ArmorStand getEntity() { return entity; }
public boolean isPassed() { return passed; }
// Setters
public void setEntity(ArmorStand entity) { this.entity = entity; }
public void setPassed(boolean passed) { this.passed = passed; }
}

View file

@ -0,0 +1,128 @@
package dev.loki.loparkour.mode.elytra;
import org.bukkit.Location;
import org.bukkit.util.Vector;
import org.jetbrains.annotations.NotNull;
import java.util.ArrayList;
import java.util.List;
import java.util.concurrent.ThreadLocalRandom;
/**
* Generates rings for Elytra mode parkour.
*/
public class ElytraRingGenerator {
private final ElytraConfig config;
private int nextRingId = 0;
public ElytraRingGenerator(@NotNull ElytraConfig config) {
this.config = config;
}
/**
* Generate a series of rings starting from origin in the given direction.
*/
@NotNull
public List<ElytraRing> generateRings(@NotNull Location origin, @NotNull Vector direction, int count) {
List<ElytraRing> rings = new ArrayList<>();
Location currentPos = origin.clone();
Vector currentDir = direction.clone().normalize();
for (int i = 0; i < count; i++) {
// Calculate distance to next ring
double distance = calculateRingDistance();
// Move to next ring position
currentPos.add(currentDir.clone().multiply(distance));
// Apply course variation
currentDir = applyDirectionVariation(currentDir);
// Ensure we don't go too high or too low
currentPos = constrainHeight(currentPos, origin);
// Create ring with perpendicular normal
Vector normal = findPerpendicularVector(currentDir);
ElytraRing ring = new ElytraRing(nextRingId++, currentPos.clone(), normal, config.getRingSize());
rings.add(ring);
}
return rings;
}
private double calculateRingDistance() {
return ThreadLocalRandom.current().nextDouble(
config.getRingDistanceMin(),
config.getRingDistanceMax()
);
}
@NotNull
private Vector applyDirectionVariation(@NotNull Vector currentDir) {
double maxHorizontalChange = Math.toRadians(config.getMaxHeadingChangeHorizontal());
double maxVerticalChange = Math.toRadians(config.getMaxHeadingChangeVertical());
// Random horizontal rotation
double yawChange = ThreadLocalRandom.current().nextDouble(-maxHorizontalChange, maxHorizontalChange);
// Random vertical rotation
double pitchChange = ThreadLocalRandom.current().nextDouble(-maxVerticalChange, maxVerticalChange);
return rotateVector(currentDir, yawChange, pitchChange);
}
@NotNull
private Location constrainHeight(@NotNull Location pos, @NotNull Location origin) {
double maxHeight = origin.getY() + config.getMaxHeightAboveSpawn();
double minHeight = origin.getY() - 20; // Don't go too low
if (pos.getY() > maxHeight) {
pos.setY(maxHeight);
} else if (pos.getY() < minHeight) {
pos.setY(minHeight);
}
return pos;
}
@NotNull
private Vector findPerpendicularVector(@NotNull Vector v) {
// Find a vector perpendicular to v
Vector perp;
if (Math.abs(v.getX()) < 0.9) {
perp = new Vector(1, 0, 0);
} else {
perp = new Vector(0, 1, 0);
}
// Make it truly perpendicular using cross product
return v.getCrossProduct(perp).normalize();
}
@NotNull
private Vector rotateVector(@NotNull Vector v, double yawRad, double pitchRad) {
// Rotate around Y axis (yaw)
double cosYaw = Math.cos(yawRad);
double sinYaw = Math.sin(yawRad);
double newX = v.getX() * cosYaw - v.getZ() * sinYaw;
double newZ = v.getX() * sinYaw + v.getZ() * cosYaw;
Vector rotated = new Vector(newX, v.getY(), newZ);
// Rotate around perpendicular axis (pitch)
Vector axis = findPerpendicularVector(rotated);
double cosPitch = Math.cos(pitchRad);
double sinPitch = Math.sin(pitchRad);
// Rodrigues' rotation formula
Vector result = rotated.clone().multiply(cosPitch);
result.add(axis.getCrossProduct(rotated).multiply(sinPitch));
result.add(axis.clone().multiply(axis.dot(rotated) * (1 - cosPitch)));
return result.normalize();
}
}