feat(generator): improve jump generation and block placement logic

Refactor the parkour generator to improve jump accuracy, block placement, and lifecycle management.

- Improve jump calculation by normalizing heading vectors and adjusting height/distance constraints.
- Implement height overrides for tall materials (fences, trapdoors, glass panes) during block placement.
- Add difficulty-based special block generation (packed/blue ice).
- Fix block identity issues by using coordinate-based comparison instead of reference equality for scoring and cleanup.
- Optimize generation lead to reduce unnecessary block generation.
- Update leaderboard logic to only replace scores if the new score is strictly better (higher score or faster time).
- Add error handling for generator ticks to prevent session crashes.

build: update build configuration and dependencies

- Add `run-paper` plugin for easier local server testing.
- Update Gradle wrapper to version 8.14.3.
- Change WorldEdit dependencies to `compileOnly`.
- Configure `runServer` task with Minecraft 1.21.4 and PlaceholderAPI.

fix(ui): improve scoreboard placeholder handling

- Add check for PlaceholderAPI plugin enablement before attempting to use it.
This commit is contained in:
loki5512344 2026-06-07 23:02:01 +02:00
parent fd0ba2cc0e
commit 0b6409397b
15 changed files with 168 additions and 55 deletions

2
.gitignore vendored
View file

@ -52,7 +52,7 @@ build/
gradle-app.setting
!**/src/main/**/build/
!**/src/test/**/build/
run/
# OS files
.DS_Store
Thumbs.db

View file

@ -1,6 +1,7 @@
plugins {
id("java")
id("io.github.goooler.shadow") version("8.1.8")
id("xyz.jpenilla.run-paper") version("3.0.2")
}
group = "dev.loki"
@ -90,8 +91,8 @@ dependencies {
implementation("org.bstats:bstats-bukkit:3.2.1")
implementation("com.zaxxer:HikariCP:5.1.0")
implementation("com.mysql:mysql-connector-j:8.3.0")
implementation("com.sk89q.worldedit:worldedit-core:7.3.6")
implementation("com.sk89q.worldedit:worldedit-bukkit:7.3.6")
compileOnly("com.sk89q.worldedit:worldedit-core:7.3.6")
compileOnly("com.sk89q.worldedit:worldedit-bukkit:7.3.6")
// Provided dependencies (plugins)
compileOnly("me.clip:placeholderapi:2.11.6")
@ -119,8 +120,6 @@ tasks.shadowJar {
relocate("io.papermc.lib", "dev.loki.loparkour.lib.paperlib")
relocate("com.google.gson", "dev.loki.loparkour.lib.gson")
relocate("org.bstats", "dev.loki.loparkour.lib.bstats")
relocate("com.sk89q.worldedit", "dev.loki.loparkour.lib.worldedit")
relocate("org.enginehub", "dev.loki.loparkour.lib.enginehub")
// Don't minimize - causes issues with Caffeine's dynamically generated classes
// minimize {
@ -156,5 +155,18 @@ tasks.withType<Javadoc> {
options.encoding = "UTF-8"
}
// Run paper server via gradlew runServer
tasks {
runServer {
minecraftVersion("1.21.4")
jvmArgs("-Xms2G", "-Xmx2G")
runDirectory.set(layout.projectDirectory.dir("run"))
downloadPlugins {
modrinth("placeholderapi", "2.11.6")
}
}
}
// Default task
defaultTasks("clean", "build")

View file

@ -1,6 +1,6 @@
distributionBase=GRADLE_USER_HOME
distributionPath=wrapper/dists
distributionUrl=https\://services.gradle.org/distributions/gradle-8.10-bin.zip
distributionUrl=https\://services.gradle.org/distributions/gradle-8.14.3-bin.zip
networkTimeout=10000
validateDistributionUrl=true
zipStoreBase=GRADLE_USER_HOME

View file

@ -121,6 +121,8 @@ public class GeneratorProfileManager {
generator.state.specialChances.clear();
try {
double difficulty = getDifficultyScore();
for (String specialType : Config.GENERATION.getChildren("advanced.special")) {
String materialPath = "advanced.special." + specialType + ".material";
String chancePath = "advanced.special." + specialType + ".chance";
@ -132,6 +134,17 @@ public class GeneratorProfileManager {
generator.state.specialChances.put(blockData, (double) chance);
}
// Add ice variants on higher difficulties
// difficulty >= ~1.0 = hard: packed ice, >= ~2.0 = expert: blue ice
if (difficulty >= 1.0) {
generator.state.specialChances.put(
org.bukkit.Bukkit.createBlockData("minecraft:packed_ice"), 15.0);
}
if (difficulty >= 2.0) {
generator.state.specialChances.put(
org.bukkit.Bukkit.createBlockData("minecraft:blue_ice"), 10.0);
}
normalizeMap(generator.state.specialChances);
} catch (Exception ignored) {
// special blocks config optional

View file

@ -48,9 +48,9 @@ public class JumpCalculator {
distance = Math.min(distance, constraints.maxDistance);
// Clamp values to valid ranges
height = Math.max(-2, Math.min(1, height));
height = Math.max(-2, Math.min(2, height));
distance = Math.max(1, Math.min(4, distance));
if (height > 0) distance = Math.max(distance - height, 1);
// Calculate offset
@ -102,7 +102,7 @@ public class JumpCalculator {
double sd = java.util.Arrays.asList(generator.generatorOptions).contains(GeneratorOption.REDUCE_RANDOM_BLOCK_SELECTION_ANGLE) ? 0.5 : 1;
int randomOffset = new JumpOffsetGenerator(height, distance).getRandomOffset(0, sd);
Vector offset = generator.state.heading.clone().multiply(distance + 1).setY(height);
Vector offset = generator.state.heading.clone().multiply(distance).setY(height);
if (offset.getX() == 0) {
offset.setX(randomOffset);
} else {
@ -133,17 +133,21 @@ public class JumpCalculator {
private int getSpecialMaterialMaxHeight(@NotNull Material material) {
return switch (material) {
case PACKED_ICE -> 1; // Ice allows normal height
case LADDER -> 1; // Ladder allows some height
default -> 0; // Most special materials restrict height
case PACKED_ICE -> 1; // Ice allows normal height
case BLUE_ICE -> 1; // Blue ice same
case ICE -> 0; // Regular ice melts, slippery
case LADDER -> 1; // Ladder allows some height
default -> 0; // Most special materials restrict height
};
}
private int getSpecialMaterialMaxDistance(@NotNull Material material) {
return switch (material) {
case PACKED_ICE -> 3; // Ice is slippery
case LADDER -> 2; // Ladder is very restrictive
default -> 3; // Default restriction
case PACKED_ICE -> 4; // Ice — full distance, slippery
case BLUE_ICE -> 4; // Blue ice — full distance
case ICE -> 3; // Regular ice — reduced
case LADDER -> 2; // Ladder is very restrictive
default -> 3; // Default restriction
};
}

View file

@ -101,7 +101,8 @@ public class JumpDirector {
if (recommendedHeading.lengthSquared() == 0) {
return current;
} else {
return recommendedHeading;
// Normalize so diagonal vectors don't multiply distance by √2
return recommendedHeading.normalize();
}
}

View file

@ -9,6 +9,7 @@ import dev.loki.loparkour.schematic.core.ParkourSchematic;
import dev.loki.loparkour.schematic.core.SchematicManager;
import dev.loki.loparkour.util.misc.Probs;
import org.bukkit.Location;
import org.bukkit.Material;
import org.bukkit.Particle;
import org.bukkit.Sound;
import org.bukkit.block.Block;
@ -40,7 +41,7 @@ public class BlockPlacer {
public void generate() {
generate(1);
}
/**
* Generate specified amount of blocks.
*/
@ -49,6 +50,27 @@ public class BlockPlacer {
generateSingleBlock();
}
}
private boolean isTallMaterial(@NotNull Material mat) {
return blockSelector.isFenceMaterial(mat) || blockSelector.isTrapdoorMaterial(mat) || mat == Material.GLASS_PANE;
}
/**
* Select blocks with a forced height override for tall materials (fences, trapdoors).
*/
@NotNull
private List<Block> selectBlocks(int forcedHeight) {
List<Block> blocks = new ArrayList<>();
if (generator.state.history.isEmpty()) {
return blocks;
}
Block current = generator.getLatest();
int distance = Probs.random(generator.state.distanceChances);
int height = forcedHeight != Integer.MIN_VALUE ? forcedHeight : Probs.random(generator.state.heightChances);
Block nextBlock = jumpCalculator.calculateNextBlock(current, distance, height);
blocks.add(nextBlock);
return blocks;
}
/**
* Generate the first block at specified locations.
@ -92,30 +114,33 @@ public class BlockPlacer {
}
private void placeNormalBlock() {
List<Block> blocks = selectBlocks();
BlockData blockData = blockSelector.selectBlockData();
if (blockData == null) return;
Material mat = blockData.getMaterial();
boolean tall = isTallMaterial(mat);
List<Block> blocks = tall ? selectBlocks(-1) : selectBlocks();
if (blocks.isEmpty()) return;
Block selectedBlock = blocks.get(0); // Use first block for simplicity
BlockData blockData = blockSelector.selectBlockData();
if (blockData == null) {
return; // Skip if no valid block data available
}
Block selectedBlock = blocks.get(0);
placeBlockData(selectedBlock, blockData);
generator.state.history.add(selectedBlock);
}
private void placeSpecialBlock() {
// Select special block type based on probabilities
BlockData specialBlockData = Probs.random(generator.state.specialChances);
if (specialBlockData == null) {
placeNormalBlock();
return;
}
List<Block> blocks = selectBlocks();
Material mat = specialBlockData.getMaterial();
boolean tall = isTallMaterial(mat);
List<Block> blocks = tall ? selectBlocks(-1) : selectBlocks();
if (blocks.isEmpty()) return;
Block selectedBlock = blocks.get(0);
placeBlockData(selectedBlock, specialBlockData);
generator.state.history.add(selectedBlock);

View file

@ -107,12 +107,14 @@ public class BlockSelector {
return isSlabMaterial(material) ||
material == Material.GLASS_PANE ||
material == Material.PACKED_ICE ||
material == Material.BLUE_ICE ||
material == Material.ICE ||
isFenceMaterial(material) ||
isTrapdoorMaterial(material) ||
material == Material.LADDER;
}
private boolean isFenceMaterial(@NotNull Material material) {
public boolean isFenceMaterial(@NotNull Material material) {
return material == Material.OAK_FENCE ||
material == Material.BIRCH_FENCE ||
material == Material.SPRUCE_FENCE ||
@ -126,7 +128,7 @@ public class BlockSelector {
material == Material.NETHER_BRICK_FENCE;
}
private boolean isTrapdoorMaterial(@NotNull Material material) {
public boolean isTrapdoorMaterial(@NotNull Material material) {
return material == Material.OAK_TRAPDOOR ||
material == Material.BIRCH_TRAPDOOR ||
material == Material.SPRUCE_TRAPDOOR ||

View file

@ -11,7 +11,7 @@ import org.jetbrains.annotations.NotNull;
public class LifecycleTickManager {
private static final int TIME_UI_INTERVAL = 20; // 1 second — time display tick
private static final int GENERATION_LEAD = 5;
private static final int GENERATION_LEAD = 2; // keep only 2 blocks ahead, generate more when player approaches end
private final ParkourGenerator generator;
private final GeneratorCleanup cleanup;
@ -32,12 +32,10 @@ public class LifecycleTickManager {
* Start the tick cycle.
*/
public void startTick() {
if (generator.state.start == null) {
generator.state.start = java.time.Instant.now();
}
// Timer starts on first block jump, not here — see PlayerInteractionHandler.handleScore()
// Generate initial blocks (reduced from GENERATION_LEAD to avoid too far generation)
generator.generate(3);
// Generate initial blocks ahead of the player
generator.generate(2);
}
/**

View file

@ -34,7 +34,7 @@ public class GeneratorCleanup {
for (ParkourPlayer pp : generator.getPlayers()) {
Block below = blockBelowPlayer(pp);
if (below == null) continue;
int idx = history.indexOf(below);
int idx = indexOfBlock(history, below);
if (idx >= 0) {
minStandingIndex = Math.min(minStandingIndex, idx);
}
@ -56,6 +56,17 @@ public class GeneratorCleanup {
history.subList(0, removeCount).clear();
}
/** Find index of a block in history by coordinates, not reference. */
private static int indexOfBlock(@NotNull List<Block> list, @NotNull Block target) {
for (int i = 0; i < list.size(); i++) {
Block b = list.get(i);
if (b != null && b.getX() == target.getX() && b.getY() == target.getY() && b.getZ() == target.getZ()) {
return i;
}
}
return -1;
}
private static Block blockBelowPlayer(@NotNull ParkourPlayer pp) {
Location loc = pp.player.getLocation().clone();
Block below = loc.subtract(0, 1, 0).getBlock();

View file

@ -11,6 +11,9 @@ import org.bukkit.block.Block;
import org.jetbrains.annotations.NotNull;
import org.jetbrains.annotations.Nullable;
import java.time.Instant;
import java.util.List;
/**
* Handles player interactions with parkour blocks.
*/
@ -42,11 +45,29 @@ public class PlayerInteractionHandler {
}
}
}
/** Compare two blocks by their world coordinates (Bukkit Block uses reference equality). */
private static boolean isSameBlock(@NotNull Block a, @NotNull Block b) {
return a.getX() == b.getX() && a.getY() == b.getY() && a.getZ() == b.getZ();
}
/** Check if history contains a block by coordinates, not reference. */
private static boolean historyContains(@NotNull List<Block> history, @NotNull Block block) {
for (Block b : history) {
if (isSameBlock(b, block)) return true;
}
return false;
}
/**
* Handle player scoring on a block.
*/
public void handleScore() {
// Start the timer on the very first scored block
if (generator.state.start == null) {
generator.state.start = Instant.now();
}
generator.state.score++;
eventManager.handleScore();
@ -91,8 +112,8 @@ public class PlayerInteractionHandler {
handleSchematicEndBlock(block);
}
// Check for scoring
if (generator.state.history.contains(block) && !player.hasScored(block)) {
// Check for scoring (compare by coordinates, not reference — Bukkit Block uses identity)
if (historyContains(generator.state.history, block) && !player.hasScored(block)) {
player.markScored(block);
handleScore();
}
@ -111,7 +132,7 @@ public class PlayerInteractionHandler {
private boolean isSchematicEndBlock(@NotNull Block block) {
return generator.state.schematicBlocks != null
&& generator.state.schematicBlocks.contains(block);
&& historyContains(generator.state.schematicBlocks, block);
}
/** Block directly under feet; {@code null} in air — scoring runs when landed. */

View file

@ -67,17 +67,22 @@ public class Leaderboard {
// ── CRUD operations ───────────────────────────────────────────────────────
/**
* Registers a new score, overriding the old one
* Registers a new score — only replaces the old one if it's strictly better
* (higher score, or same score with faster time).
*
* @param uuid The player's uuid
* @param score The {@link Score} instance associated with a player's run
* @return the previous score, if there was one
* @return the previous best score, if there was one
*/
@Nullable
public Score put(@NotNull UUID uuid, @NotNull Score score) {
Score previous;
synchronized (scores) {
previous = scores.put(uuid, score);
previous = scores.get(uuid);
if (previous != null && !isBetterThan(score, previous)) {
return previous; // keep the better score
}
scores.put(uuid, score);
}
sorter.sortInPlace(scores);
@ -85,6 +90,15 @@ public class Leaderboard {
return previous;
}
/** Returns true if {@code candidate} is strictly better than {@code existing}. */
private static boolean isBetterThan(@NotNull Score candidate, @NotNull Score existing) {
if (candidate.score() != existing.score()) {
return candidate.score() > existing.score();
}
// same score — faster time wins
return candidate.getTimeMillis() < existing.getTimeMillis();
}
/**
* Resets the score of a player by deleting it from the internal map
*

View file

@ -36,8 +36,8 @@ public class ParkourPlayer extends ParkourUser {
public static final Map<String, PlayerSettingsManager.OptionContainer> PLAYER_COLUMNS = PlayerSettingsManager.getColumnMappings();
// Track scored blocks to prevent duplicate scoring
private final Set<Block> scoredBlocks = new HashSet<>();
// Track scored blocks to prevent duplicate scoring (String key = "x,y,z" to avoid Bukkit Block reference equality)
private final Set<String> scoredBlocks = new HashSet<>();
public @Expose Double schematicDifficulty;
public @Expose Integer blockLead;
@ -115,18 +115,22 @@ public class ParkourPlayer extends ParkourUser {
save(LoParkour.getPlugin().isEnabled());
}
private static String blockKey(Block block) {
return block.getX() + "," + block.getY() + "," + block.getZ();
}
/**
* Check if player has already scored on this block.
*/
public boolean hasScored(Block block) {
return scoredBlocks.contains(block);
return scoredBlocks.contains(blockKey(block));
}
/**
* Mark block as scored to prevent duplicate scoring.
*/
public void markScored(Block block) {
scoredBlocks.add(block);
scoredBlocks.add(blockKey(block));
}
/**

View file

@ -5,7 +5,7 @@ import dev.loki.loparkour.generator.core.coordinator.ParkourGenerator;
import dev.loki.loparkour.leaderboard.core.Leaderboard;
import dev.loki.loparkour.leaderboard.model.Score;
import dev.loki.loparkour.util.text.ColorUtil;
import me.clip.placeholderapi.PlaceholderAPI;
import org.bukkit.Bukkit;
import org.bukkit.entity.Player;
import org.bukkit.scoreboard.DisplaySlot;
import org.bukkit.scoreboard.Objective;
@ -124,10 +124,13 @@ public class ScoreboardManager {
}
private String applyPlaceholderAPI(String text) {
try {
return PlaceholderAPI.setPlaceholders(player, text);
} catch (Exception e) {
return text;
if (Bukkit.getPluginManager().isPluginEnabled("PlaceholderAPI")) {
try {
return me.clip.placeholderapi.PlaceholderAPI.setPlaceholders(player, text);
} catch (Exception e) {
return text;
}
}
return text;
}
}

View file

@ -75,7 +75,12 @@ public class SessionStateManager {
tickTask = Scheduler.get(LoParkour.getPlugin()).runTimer(() -> {
if (session.generator != null) {
session.generator.tick();
try {
session.generator.tick();
} catch (Throwable t) {
session.generator.session.getPlayers().forEach(p ->
p.player.sendMessage("§c[LoParkour] Tick error: " + t.getMessage()));
}
}
}, 0, 1); // Run every tick (1 = 50ms)
}