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:
parent
978f739045
commit
3100c53229
86 changed files with 4324 additions and 3248 deletions
|
|
@ -18,9 +18,9 @@ import dev.loki.loparkour.player.UserRegistry;
|
|||
import dev.loki.loparkour.reward.Rewards;
|
||||
import dev.loki.loparkour.schematic.lpschem.LPSchematicManager;
|
||||
import dev.loki.loparkour.storage.Storage;
|
||||
import dev.loki.loparkour.world.Divider;
|
||||
import dev.loki.loparkour.world.World;
|
||||
import dev.lolib.core.LoPlugin;
|
||||
import dev.lolib.scheduler.Scheduler;
|
||||
import com.google.gson.Gson;
|
||||
import com.google.gson.GsonBuilder;
|
||||
import org.bstats.bukkit.Metrics;
|
||||
|
|
@ -30,6 +30,7 @@ import org.bstats.charts.SingleLineChart;
|
|||
import org.jetbrains.annotations.Nullable;
|
||||
|
||||
import java.io.File;
|
||||
import java.util.ArrayList;
|
||||
|
||||
/**
|
||||
* Main class of LoParkour
|
||||
|
|
@ -171,7 +172,7 @@ public final class LoParkour extends LoPlugin {
|
|||
Metrics metrics = new Metrics(this, 29754);
|
||||
metrics.addCustomChart(new SimplePie("using_sql", () -> Boolean.toString(Option.SQL)));
|
||||
metrics.addCustomChart(new SimplePie("using_rewards", () -> Boolean.toString(Rewards.REWARDS_ENABLED)));
|
||||
metrics.addCustomChart(new SimplePie("locale_count", () -> Integer.toString(Locales.locales.size())));
|
||||
metrics.addCustomChart(new SimplePie("locale_count", () -> Integer.toString(Locales.getLocaleCount())));
|
||||
metrics.addCustomChart(new SingleLineChart("player_joins", () -> {
|
||||
int joins = UserRegistry.getJoinCount();
|
||||
return joins;
|
||||
|
|
@ -181,12 +182,30 @@ public final class LoParkour extends LoPlugin {
|
|||
@Override
|
||||
public void disable() {
|
||||
try {
|
||||
// Clear all sessions BEFORE leaving players to prevent memory leaks
|
||||
new ArrayList<>(Divider.sections.keySet()).forEach(session -> {
|
||||
try {
|
||||
session.onAllPlayersLeft();
|
||||
} catch (Exception e) {
|
||||
getLogger().warning("Error cleaning up session: " + e.getMessage());
|
||||
}
|
||||
});
|
||||
|
||||
// Remove all sessions from Divider
|
||||
new ArrayList<>(Divider.sections.keySet()).forEach(Divider::remove);
|
||||
|
||||
// Now leave all players
|
||||
for (ParkourUser user : ParkourUser.getUsers()) {
|
||||
ParkourUser.leave(user);
|
||||
}
|
||||
|
||||
// write all LoParkour gamemodes
|
||||
Modes.DEFAULT.getLeaderboard().write(false);
|
||||
// write all LoParkour gamemodes (may be null if enable failed)
|
||||
if (Modes.DEFAULT != null && Modes.DEFAULT.getLeaderboard() != null) {
|
||||
Modes.DEFAULT.getLeaderboard().write(false);
|
||||
}
|
||||
|
||||
// Clear rewards to prevent memory leaks
|
||||
dev.loki.loparkour.reward.Rewards.clear();
|
||||
|
||||
Storage.close();
|
||||
World.delete();
|
||||
|
|
|
|||
|
|
@ -16,6 +16,7 @@ import dev.loki.loparkour.player.ParkourUser;
|
|||
import dev.loki.loparkour.session.Session;
|
||||
import dev.loki.loparkour.util.ColorUtil;
|
||||
import org.bukkit.Bukkit;
|
||||
import org.bukkit.ChatColor;
|
||||
import org.bukkit.command.Command;
|
||||
import org.bukkit.command.CommandExecutor;
|
||||
import org.bukkit.command.CommandSender;
|
||||
|
|
@ -28,13 +29,13 @@ import java.util.ArrayList;
|
|||
import java.util.Arrays;
|
||||
import java.util.Collections;
|
||||
import java.util.List;
|
||||
import java.util.logging.Level;
|
||||
|
||||
/**
|
||||
* Main command router for /LoParkour and /parkour.
|
||||
* Delegates to {@link PlayerCommandHandler}, {@link AdminCommandHandler},
|
||||
* and {@link SchematicCommandHandler}.
|
||||
*/
|
||||
@SuppressWarnings("deprecation")
|
||||
public class LoParkourCommand implements CommandExecutor, TabCompleter {
|
||||
|
||||
private final PlayerCommandHandler player = new PlayerCommandHandler();
|
||||
|
|
@ -43,15 +44,23 @@ public class LoParkourCommand implements CommandExecutor, TabCompleter {
|
|||
@Override
|
||||
public boolean onCommand(@NotNull CommandSender sender, @NotNull Command command,
|
||||
@NotNull String label, @NotNull String[] args) {
|
||||
Player p = sender instanceof Player ? (Player) sender : null;
|
||||
try {
|
||||
Player p = sender instanceof Player ? (Player) sender : null;
|
||||
|
||||
switch (args.length) {
|
||||
case 0 -> player.handleNoArgs(sender, p);
|
||||
case 1 -> player.handle(args[0], sender, p);
|
||||
case 2 -> handle2(args[0], args[1], sender, p);
|
||||
case 3 -> handle3(args[0], args[1], args[2], sender, p);
|
||||
switch (args.length) {
|
||||
case 0 -> player.handleNoArgs(sender, p);
|
||||
case 1 -> player.handle(args[0], sender, p);
|
||||
case 2 -> handle2(args[0], args[1], sender, p);
|
||||
case 3 -> handle3(args[0], args[1], args[2], sender, p);
|
||||
default -> sender.sendMessage(ChatColor.GRAY + "Too many arguments. Try /parkour help");
|
||||
}
|
||||
return true;
|
||||
} catch (Throwable t) {
|
||||
LoParkour.getPlugin().getLogger().log(Level.SEVERE,
|
||||
"LoParkour command failed (" + label + " " + String.join(" ", args) + ")", t);
|
||||
sender.sendMessage(ChatColor.RED + "LoParkour: command error — see server console.");
|
||||
return true;
|
||||
}
|
||||
return true;
|
||||
}
|
||||
|
||||
// ── 2-arg routing ──────────────────────────────────────────────────────────
|
||||
|
|
@ -108,7 +117,12 @@ public class LoParkourCommand implements CommandExecutor, TabCompleter {
|
|||
if (user != null && user.session == session) return;
|
||||
|
||||
if (session.isAcceptingPlayers()) {
|
||||
((MultiMode) session.generator.getMode()).join(p, session);
|
||||
Mode sessionMode = session.generator.getMode();
|
||||
if (sessionMode instanceof MultiMode mm) {
|
||||
mm.join(p, session);
|
||||
} else {
|
||||
Modes.SPECTATOR.create(p, session);
|
||||
}
|
||||
} else {
|
||||
Modes.SPECTATOR.create(p, session);
|
||||
}
|
||||
|
|
@ -153,7 +167,10 @@ public class LoParkourCommand implements CommandExecutor, TabCompleter {
|
|||
completions.add("everyone");
|
||||
ParkourPlayer.getPlayers().forEach(pp -> completions.add(pp.getName()));
|
||||
} else if (a1.equals("join") && ParkourOption.JOIN.mayPerform(sender)) {
|
||||
Registry.getModes().forEach(m -> completions.add(m.getName()));
|
||||
ParkourPlayer.getPlayers().forEach(pp -> completions.add(pp.getName()));
|
||||
} else if (a1.equals("leaderboard") && ParkourOption.LEADERBOARDS.mayPerform(sender)) {
|
||||
Registry.getModes().forEach(m -> completions.add(m.getName()));
|
||||
} else if (a1.equals("schematic") && sender.hasPermission(ParkourOption.ADMIN.permission)) {
|
||||
completions.addAll(Arrays.asList("wand", "pos1", "pos2", "save", "paste", "list", "reload"));
|
||||
} else if ((a1.equals("forcejoin") || a1.equals("forceleave")) && sender.hasPermission(ParkourOption.ADMIN.permission)) {
|
||||
|
|
|
|||
|
|
@ -17,7 +17,6 @@ import org.jetbrains.annotations.Nullable;
|
|||
* <li>For Schematics, please view {@link dev.efnilite.LoParkour.schematic.LPSchematic}</li>
|
||||
* </ul>
|
||||
*/
|
||||
@SuppressWarnings("unused")
|
||||
public class ParkourAPI {
|
||||
|
||||
private ParkourAPI() throws IllegalAccessException {
|
||||
|
|
|
|||
|
|
@ -1,6 +1,5 @@
|
|||
package dev.loki.loparkour.api;
|
||||
|
||||
import dev.loki.loparkour.LoParkour;
|
||||
import dev.loki.loparkour.mode.Mode;
|
||||
import dev.loki.loparkour.style.Style;
|
||||
import org.jetbrains.annotations.NotNull;
|
||||
|
|
|
|||
|
|
@ -17,11 +17,11 @@ public class ParkourSchematicGenerateEvent extends Event {
|
|||
|
||||
private static final HandlerList HANDLERS = new HandlerList();
|
||||
|
||||
public final dev.loki.loparkour.schematic.lpschem.LPSchematic schematic;
|
||||
public final LPSchematic schematic;
|
||||
public final ParkourGenerator generator;
|
||||
public final ParkourPlayer player;
|
||||
|
||||
public ParkourSchematicGenerateEvent(dev.loki.loparkour.schematic.lpschem.LPSchematic schematic, ParkourGenerator generator, ParkourPlayer player) {
|
||||
public ParkourSchematicGenerateEvent(LPSchematic schematic, ParkourGenerator generator, ParkourPlayer player) {
|
||||
this.schematic = schematic;
|
||||
this.generator = generator;
|
||||
this.player = player;
|
||||
|
|
|
|||
|
|
@ -2,8 +2,6 @@ package dev.loki.loparkour.command;
|
|||
|
||||
import dev.loki.loparkour.LoParkour;
|
||||
import dev.loki.loparkour.api.Registry;
|
||||
import dev.loki.loparkour.config.Locales;
|
||||
import dev.loki.loparkour.leaderboard.Leaderboard;
|
||||
import dev.loki.loparkour.menu.ParkourOption;
|
||||
import dev.loki.loparkour.mode.Mode;
|
||||
import dev.loki.loparkour.mode.Modes;
|
||||
|
|
@ -28,6 +26,8 @@ import java.util.UUID;
|
|||
*/
|
||||
public class AdminCommandHandler {
|
||||
|
||||
private static final String COOLDOWN_HINT = "<gray>Please wait before using this again.";
|
||||
|
||||
private final PlayerCommandHandler base;
|
||||
|
||||
public AdminCommandHandler(PlayerCommandHandler base) {
|
||||
|
|
@ -56,6 +56,8 @@ public class AdminCommandHandler {
|
|||
// ── forcejoin ──────────────────────────────────────────────────────────────
|
||||
|
||||
private void handleForceJoin(String target, CommandSender sender) {
|
||||
if (!base.cooldown(sender, "forcejoin", 2500, COOLDOWN_HINT)) return;
|
||||
|
||||
if (target.equalsIgnoreCase("everyone")) {
|
||||
Bukkit.getOnlinePlayers().forEach(p -> Modes.DEFAULT.create(p));
|
||||
send(sender, LoParkour.PREFIX + "Force joined everyone!");
|
||||
|
|
@ -76,6 +78,8 @@ public class AdminCommandHandler {
|
|||
// ── forceleave ─────────────────────────────────────────────────────────────
|
||||
|
||||
private void handleForceLeave(String target, CommandSender sender) {
|
||||
if (!base.cooldown(sender, "forceleave", 2500, COOLDOWN_HINT)) return;
|
||||
|
||||
if (target.equalsIgnoreCase("everyone")) {
|
||||
ParkourPlayer.getPlayers().forEach(ParkourUser::leave);
|
||||
send(sender, LoParkour.PREFIX + "Force kicked everyone!");
|
||||
|
|
@ -91,7 +95,7 @@ public class AdminCommandHandler {
|
|||
// ── reset ──────────────────────────────────────────────────────────────────
|
||||
|
||||
private void handleReset(String target, CommandSender sender) {
|
||||
if (!base.cooldown(sender, "reset", 2500)) return;
|
||||
if (!base.cooldown(sender, "reset", 2500, COOLDOWN_HINT)) return;
|
||||
|
||||
if (target.equalsIgnoreCase("everyone")) {
|
||||
Registry.getModes().stream()
|
||||
|
|
@ -116,7 +120,7 @@ public class AdminCommandHandler {
|
|||
// ── recoverinventory ───────────────────────────────────────────────────────
|
||||
|
||||
private void handleRecoverInventory(String target, CommandSender sender) {
|
||||
if (!base.cooldown(sender, "recoverinventory", 2500)) return;
|
||||
if (!base.cooldown(sender, "recoverinventory", 2500, COOLDOWN_HINT)) return;
|
||||
Player other = Bukkit.getPlayer(target);
|
||||
if (other == null) { send(sender, LoParkour.PREFIX + "Player not online!"); return; }
|
||||
|
||||
|
|
@ -142,10 +146,17 @@ public class AdminCommandHandler {
|
|||
.orElse(null);
|
||||
}
|
||||
|
||||
@SuppressWarnings("deprecation") // Bukkit: name-based OfflinePlayer lookup (admin-only)
|
||||
private UUID resolveUUID(String input) {
|
||||
Player online = Bukkit.getPlayerExact(input);
|
||||
if (online != null) return online.getUniqueId();
|
||||
if (input.contains("-")) return UUID.fromString(input);
|
||||
if (input.contains("-")) {
|
||||
try {
|
||||
return UUID.fromString(input);
|
||||
} catch (IllegalArgumentException ignored) {
|
||||
// fall through to offline lookup by name
|
||||
}
|
||||
}
|
||||
return Bukkit.getOfflinePlayer(input).getUniqueId();
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -60,7 +60,13 @@ public class PlayerCommandHandler {
|
|||
case "menu", "main" -> {
|
||||
if (ParkourOption.MAIN.mayPerform(player)) Menus.MAIN.open(player);
|
||||
}
|
||||
case "leaderboard" -> player.performCommand("LoParkour leaderboard invalid");
|
||||
case "leaderboard" -> {
|
||||
if (!ParkourOption.LEADERBOARDS.mayPerform(player)) {
|
||||
send(sender, Locales.getString(player, "other.no_do"));
|
||||
return;
|
||||
}
|
||||
Menus.LEADERBOARDS.open(player);
|
||||
}
|
||||
case "schematic" -> {
|
||||
if (!player.hasPermission(ParkourOption.ADMIN.permission)) {
|
||||
send(sender, Locales.getString(player, "other.no_do"));
|
||||
|
|
@ -107,10 +113,22 @@ public class PlayerCommandHandler {
|
|||
}
|
||||
|
||||
public boolean cooldown(CommandSender sender, String key, long millis) {
|
||||
return cooldown(sender, key, millis, null);
|
||||
}
|
||||
|
||||
/**
|
||||
* @param whenBlocked message sent (MiniMessage color) if still on cooldown; may be null for silent block
|
||||
*/
|
||||
public boolean cooldown(CommandSender sender, String key, long millis, @Nullable String whenBlocked) {
|
||||
String fullKey = sender.getName() + ":" + key;
|
||||
long now = System.currentTimeMillis();
|
||||
Long last = cooldowns.get(fullKey);
|
||||
if (last != null && now - last < millis) return false;
|
||||
if (last != null && now - last < millis) {
|
||||
if (whenBlocked != null) {
|
||||
send(sender, whenBlocked);
|
||||
}
|
||||
return false;
|
||||
}
|
||||
cooldowns.put(fullKey, now);
|
||||
return true;
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1,22 +1,15 @@
|
|||
package dev.loki.loparkour.config;
|
||||
|
||||
import dev.loki.loparkour.LoParkour;
|
||||
import dev.loki.loparkour.reward.Rewards;
|
||||
import dev.loki.loparkour.schematic.Schematics;
|
||||
|
||||
import org.bukkit.configuration.ConfigurationSection;
|
||||
import org.bukkit.configuration.file.FileConfiguration;
|
||||
import org.bukkit.configuration.file.YamlConfiguration;
|
||||
import org.jetbrains.annotations.NotNull;
|
||||
import org.jetbrains.annotations.Nullable;
|
||||
|
||||
import java.io.File;
|
||||
import java.util.ArrayList;
|
||||
import java.util.List;
|
||||
import java.util.NoSuchElementException;
|
||||
|
||||
/**
|
||||
* Config management class.
|
||||
* Refactored Config enum using composition pattern.
|
||||
* Delegates functionality to specialized components.
|
||||
*/
|
||||
public enum Config {
|
||||
|
||||
|
|
@ -25,252 +18,102 @@ public enum Config {
|
|||
REWARDS("rewards-v2.yml", List.of("score-rewards", "interval-rewards", "one-time-rewards")),
|
||||
SCHEMATICS("schematics/schematics.yml", List.of("difficulty"));
|
||||
|
||||
/**
|
||||
* The path to this file, incl. plugin folder.
|
||||
* Not final — resolved lazily on first reload() call after plugin instance is set.
|
||||
*/
|
||||
public File path;
|
||||
/**
|
||||
* The name of this file, e.g. config.yml
|
||||
*/
|
||||
public final String fileName;
|
||||
/**
|
||||
* The sections in the file that will be ignored when updating the keys.
|
||||
*/
|
||||
public final List<String> ignoredSections;
|
||||
/**
|
||||
* The {@link FileConfiguration} instance associated with this config file.
|
||||
*/
|
||||
public FileConfiguration fileConfiguration;
|
||||
|
||||
private ConfigAccessor accessor;
|
||||
|
||||
Config(String fileName, @Nullable List<String> ignoredSections) {
|
||||
this.fileName = fileName;
|
||||
this.ignoredSections = ignoredSections;
|
||||
// path resolved lazily via getPath() — do NOT call LoParkour.getInFolder() here,
|
||||
// because enum constants are initialized before onEnable() sets the instance.
|
||||
this.path = null; // placeholder; real path resolved on first access
|
||||
this.path = null; // Resolved lazily
|
||||
}
|
||||
|
||||
|
||||
|
||||
/**
|
||||
* Reloads all config files.
|
||||
* On first call (initialLoad=true) also resolves file paths and saves defaults.
|
||||
* Reload all configuration files.
|
||||
*/
|
||||
public static void reload(boolean initialLoad) {
|
||||
ConfigLoader.reloadAllConfigs(initialLoad);
|
||||
|
||||
// Update accessors for all configs
|
||||
for (Config config : values()) {
|
||||
if (initialLoad) {
|
||||
// Resolve path now that instance is guaranteed to be set
|
||||
config.path = LoParkour.getInFolder(config.fileName);
|
||||
if (!config.path.exists()) {
|
||||
LoParkour.getPlugin().saveResource(config.fileName, false);
|
||||
}
|
||||
config.update();
|
||||
if (config.fileConfiguration != null) {
|
||||
config.accessor = new ConfigAccessor(config.fileConfiguration, config.fileName);
|
||||
}
|
||||
config.load();
|
||||
}
|
||||
|
||||
// Validate configuration values
|
||||
validateConfigs();
|
||||
|
||||
// read config stuff
|
||||
Rewards.init();
|
||||
Locales.init();
|
||||
Schematics.init();
|
||||
Option.init(initialLoad);
|
||||
// Now that accessors exist, initialize systems that depend on Config getters
|
||||
ConfigLoader.initializeDependentSystems(initialLoad);
|
||||
}
|
||||
|
||||
/**
|
||||
* Validates critical configuration values to prevent crashes and unexpected behavior.
|
||||
*/
|
||||
private static void validateConfigs() {
|
||||
var logger = LoParkour.getPlugin().getLogger();
|
||||
|
||||
// Validate border-size
|
||||
if (CONFIG.isPath("world.border-size")) {
|
||||
int borderSize = CONFIG.getInt("world.border-size");
|
||||
if (borderSize <= 0) {
|
||||
logger.severe("Invalid config: world.border-size must be > 0, found: " + borderSize);
|
||||
logger.severe("Using default value: 1000");
|
||||
}
|
||||
}
|
||||
|
||||
// Validate world dimensions
|
||||
if (CONFIG.isPath("world.max-y") && CONFIG.isPath("world.min-y")) {
|
||||
int maxY = CONFIG.getInt("world.max-y");
|
||||
int minY = CONFIG.getInt("world.min-y");
|
||||
if (maxY <= minY) {
|
||||
logger.severe("Invalid config: world.max-y (" + maxY + ") must be > world.min-y (" + minY + ")");
|
||||
logger.severe("Using default values: max-y=320, min-y=-64");
|
||||
}
|
||||
}
|
||||
|
||||
// Validate section dimensions
|
||||
if (CONFIG.isPath("world.section.width") && CONFIG.isPath("world.section.length")) {
|
||||
int width = CONFIG.getInt("world.section.width");
|
||||
int length = CONFIG.getInt("world.section.length");
|
||||
if (width <= 0 || length <= 0) {
|
||||
logger.severe("Invalid config: world.section dimensions must be > 0, found: width=" + width + ", length=" + length);
|
||||
logger.severe("Using default values: width=50, length=50");
|
||||
}
|
||||
}
|
||||
|
||||
// Validate generation chances (must sum to reasonable values)
|
||||
if (GENERATION.isPath("chances.height")) {
|
||||
double sum = GENERATION.getDouble("chances.height.1") +
|
||||
GENERATION.getDouble("chances.height.0") +
|
||||
GENERATION.getDouble("chances.height.-1") +
|
||||
GENERATION.getDouble("chances.height.-2");
|
||||
if (sum <= 0) {
|
||||
logger.severe("Invalid config: generation height chances sum to " + sum + ", must be > 0");
|
||||
logger.severe("Check generation.yml chances.height section");
|
||||
}
|
||||
}
|
||||
|
||||
// Validate ghost mode settings
|
||||
if (CONFIG.isPath("ghost-mode.show-top")) {
|
||||
int showTop = CONFIG.getInt("ghost-mode.show-top");
|
||||
if (showTop < 0 || showTop > 10) {
|
||||
logger.warning("Config: ghost-mode.show-top should be between 0-10, found: " + showTop);
|
||||
}
|
||||
}
|
||||
|
||||
// Validate cleanup settings
|
||||
if (GENERATION.isPath("advanced.cleanup-distance")) {
|
||||
int cleanupDistance = GENERATION.getInt("advanced.cleanup-distance");
|
||||
if (cleanupDistance <= 0) {
|
||||
logger.severe("Invalid config: advanced.cleanup-distance must be > 0, found: " + cleanupDistance);
|
||||
logger.severe("Using default value: 100");
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Loads the file from disk.
|
||||
* Load this configuration file from disk.
|
||||
*/
|
||||
public void load() {
|
||||
if (path == null) return; // not yet initialized, skip
|
||||
this.fileConfiguration = YamlConfiguration.loadConfiguration(path);
|
||||
if (path == null) return;
|
||||
|
||||
this.fileConfiguration = ConfigLoader.loadConfig(path);
|
||||
this.accessor = new ConfigAccessor(fileConfiguration, fileName);
|
||||
}
|
||||
|
||||
/**
|
||||
* Updates the file so all keys are present.
|
||||
* Any key that exists in the bundled default but is missing on disk
|
||||
* will be appended to the correct section automatically.
|
||||
* Update this configuration file with new keys.
|
||||
*/
|
||||
public void update() {
|
||||
try {
|
||||
ConfigUpdater.update(LoParkour.getPlugin(), fileName, path, ignoredSections);
|
||||
} catch (Exception ex) {
|
||||
LoParkour.getPlugin().getLogger().log(java.util.logging.Level.SEVERE,
|
||||
"Error while trying to update config file: " + fileName, ex);
|
||||
if (path != null) {
|
||||
ConfigLoader.updateConfig(fileName, path, ignoredSections);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* @param path The path.
|
||||
* @return True when path exists, false if not.
|
||||
*/
|
||||
// Delegate all accessor methods to ConfigAccessor
|
||||
|
||||
public boolean isPath(@NotNull String path) {
|
||||
return fileConfiguration.isSet(path);
|
||||
return accessor != null && accessor.isPath(path);
|
||||
}
|
||||
|
||||
/**
|
||||
* @param path The path.
|
||||
* @return The value at path.
|
||||
*/
|
||||
public Object get(@NotNull String path) {
|
||||
check(path);
|
||||
|
||||
return fileConfiguration.get(path);
|
||||
return accessor.get(path);
|
||||
}
|
||||
|
||||
/**
|
||||
* @param path The path.
|
||||
* @return The boolean value at path.
|
||||
*/
|
||||
public boolean getBoolean(@NotNull String path) {
|
||||
check(path);
|
||||
|
||||
return fileConfiguration.getBoolean(path);
|
||||
return accessor.getBoolean(path);
|
||||
}
|
||||
|
||||
public boolean getBoolean(@NotNull String path, boolean defaultValue) {
|
||||
return accessor != null ? accessor.getBoolean(path, defaultValue) : defaultValue;
|
||||
}
|
||||
|
||||
/**
|
||||
* @param path The path.
|
||||
* @return The int value at path.
|
||||
*/
|
||||
public int getInt(@NotNull String path) {
|
||||
check(path);
|
||||
|
||||
return fileConfiguration.getInt(path);
|
||||
return accessor.getInt(path);
|
||||
}
|
||||
|
||||
public int getInt(@NotNull String path, int defaultValue) {
|
||||
return accessor != null ? accessor.getInt(path, defaultValue) : defaultValue;
|
||||
}
|
||||
|
||||
/**
|
||||
* @param path The path.
|
||||
* @return The double value at path.
|
||||
*/
|
||||
public double getDouble(@NotNull String path) {
|
||||
check(path);
|
||||
|
||||
return fileConfiguration.getDouble(path);
|
||||
return accessor.getDouble(path);
|
||||
}
|
||||
|
||||
/**
|
||||
* @param path The path.
|
||||
* @return The String value at path.
|
||||
*/
|
||||
@NotNull
|
||||
public String getString(@NotNull String path) {
|
||||
check(path);
|
||||
|
||||
return fileConfiguration.getString(path, "");
|
||||
return accessor.getString(path);
|
||||
}
|
||||
|
||||
/**
|
||||
* @param path The path.
|
||||
* @return The String list value at path.
|
||||
*/
|
||||
@NotNull
|
||||
public List<String> getStringList(@NotNull String path) {
|
||||
check(path);
|
||||
|
||||
return fileConfiguration.getStringList(path);
|
||||
return accessor.getStringList(path);
|
||||
}
|
||||
|
||||
/**
|
||||
* @param path The path.
|
||||
* @return The int list value at path.
|
||||
*/
|
||||
@NotNull
|
||||
public List<Integer> getIntList(@NotNull String path) {
|
||||
check(path);
|
||||
|
||||
return fileConfiguration.getIntegerList(path);
|
||||
return accessor.getIntList(path);
|
||||
}
|
||||
|
||||
/**
|
||||
* @param path The path.
|
||||
* @param deep Whether search should include children of children as well.
|
||||
* @return The children nodes from path.
|
||||
*/
|
||||
@NotNull
|
||||
public List<String> getChildren(@NotNull String path, boolean... deep) {
|
||||
check(path);
|
||||
|
||||
ConfigurationSection section = fileConfiguration.getConfigurationSection(path);
|
||||
|
||||
if (section == null) {
|
||||
return new ArrayList<>();
|
||||
}
|
||||
|
||||
boolean isDeep = deep.length > 0 && deep[0];
|
||||
return new ArrayList<>(section.getKeys(isDeep));
|
||||
return accessor.getChildren(path, deep);
|
||||
}
|
||||
|
||||
// checks if the specified path exists to avoid developer error
|
||||
private void check(@NotNull String path) {
|
||||
if (!isPath(path)) {
|
||||
throw new NoSuchElementException("Unknown path %s in %s".formatted(path, fileName));
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
141
src/main/java/dev/loki/loparkour/config/ConfigAccessor.java
Normal file
141
src/main/java/dev/loki/loparkour/config/ConfigAccessor.java
Normal file
|
|
@ -0,0 +1,141 @@
|
|||
package dev.loki.loparkour.config;
|
||||
|
||||
import dev.loki.loparkour.LoParkour;
|
||||
import org.bukkit.configuration.ConfigurationSection;
|
||||
import org.bukkit.configuration.file.FileConfiguration;
|
||||
import org.jetbrains.annotations.NotNull;
|
||||
|
||||
import java.util.ArrayList;
|
||||
import java.util.List;
|
||||
import java.util.Set;
|
||||
import java.util.concurrent.ConcurrentHashMap;
|
||||
|
||||
/**
|
||||
* Provides safe access to configuration values with validation.
|
||||
*/
|
||||
public class ConfigAccessor {
|
||||
|
||||
private final FileConfiguration config;
|
||||
private final String fileName;
|
||||
private final Set<String> warnedMissingPaths = ConcurrentHashMap.newKeySet();
|
||||
|
||||
public ConfigAccessor(@NotNull FileConfiguration config, @NotNull String fileName) {
|
||||
this.config = config;
|
||||
this.fileName = fileName;
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if path exists in configuration.
|
||||
*/
|
||||
public boolean isPath(@NotNull String path) {
|
||||
return config.isSet(path);
|
||||
}
|
||||
|
||||
/**
|
||||
* Get raw object value at path.
|
||||
*/
|
||||
public Object get(@NotNull String path) {
|
||||
warnIfMissing(path);
|
||||
return config.get(path);
|
||||
}
|
||||
|
||||
/**
|
||||
* Get boolean value at path.
|
||||
*/
|
||||
public boolean getBoolean(@NotNull String path) {
|
||||
warnIfMissing(path);
|
||||
return config.getBoolean(path, false);
|
||||
}
|
||||
|
||||
/**
|
||||
* Boolean at path, or default when the path is absent (no missing-path warning).
|
||||
*/
|
||||
public boolean getBoolean(@NotNull String path, boolean defaultValue) {
|
||||
if (!isPath(path)) {
|
||||
return defaultValue;
|
||||
}
|
||||
return config.getBoolean(path, defaultValue);
|
||||
}
|
||||
|
||||
/**
|
||||
* Get integer value at path.
|
||||
*/
|
||||
public int getInt(@NotNull String path) {
|
||||
warnIfMissing(path);
|
||||
return config.getInt(path, 0);
|
||||
}
|
||||
|
||||
/**
|
||||
* Get integer value at path with default.
|
||||
*/
|
||||
public int getInt(@NotNull String path, int defaultValue) {
|
||||
if (!isPath(path)) {
|
||||
return defaultValue;
|
||||
}
|
||||
return config.getInt(path, defaultValue);
|
||||
}
|
||||
|
||||
/**
|
||||
* Get double value at path.
|
||||
*/
|
||||
public double getDouble(@NotNull String path) {
|
||||
warnIfMissing(path);
|
||||
return config.getDouble(path, 0.0);
|
||||
}
|
||||
|
||||
/**
|
||||
* Get string value at path.
|
||||
*/
|
||||
@NotNull
|
||||
public String getString(@NotNull String path) {
|
||||
warnIfMissing(path);
|
||||
return config.getString(path, "");
|
||||
}
|
||||
|
||||
/**
|
||||
* Get string list at path.
|
||||
*/
|
||||
@NotNull
|
||||
public List<String> getStringList(@NotNull String path) {
|
||||
warnIfMissing(path);
|
||||
return config.getStringList(path);
|
||||
}
|
||||
|
||||
/**
|
||||
* Get integer list at path.
|
||||
*/
|
||||
@NotNull
|
||||
public List<Integer> getIntList(@NotNull String path) {
|
||||
warnIfMissing(path);
|
||||
return config.getIntegerList(path);
|
||||
}
|
||||
|
||||
/**
|
||||
* Get child keys from configuration section.
|
||||
*/
|
||||
@NotNull
|
||||
public List<String> getChildren(@NotNull String path, boolean... deep) {
|
||||
if (!isPath(path)) {
|
||||
warnIfMissing(path);
|
||||
return new ArrayList<>();
|
||||
}
|
||||
|
||||
ConfigurationSection section = config.getConfigurationSection(path);
|
||||
if (section == null) {
|
||||
return new ArrayList<>();
|
||||
}
|
||||
|
||||
boolean isDeep = deep.length > 0 && deep[0];
|
||||
return new ArrayList<>(section.getKeys(isDeep));
|
||||
}
|
||||
|
||||
private void warnIfMissing(@NotNull String path) {
|
||||
if (isPath(path)) {
|
||||
return;
|
||||
}
|
||||
if (warnedMissingPaths.add(path)) {
|
||||
LoParkour.getPlugin().getLogger().warning(
|
||||
"Missing config path '" + path + "' in " + fileName + ". Using a safe default value.");
|
||||
}
|
||||
}
|
||||
}
|
||||
87
src/main/java/dev/loki/loparkour/config/ConfigLoader.java
Normal file
87
src/main/java/dev/loki/loparkour/config/ConfigLoader.java
Normal file
|
|
@ -0,0 +1,87 @@
|
|||
package dev.loki.loparkour.config;
|
||||
|
||||
import dev.loki.loparkour.LoParkour;
|
||||
import org.bukkit.configuration.file.FileConfiguration;
|
||||
import org.bukkit.configuration.file.YamlConfiguration;
|
||||
import org.jetbrains.annotations.NotNull;
|
||||
import org.jetbrains.annotations.Nullable;
|
||||
|
||||
import java.io.File;
|
||||
import java.util.List;
|
||||
import java.util.logging.Logger;
|
||||
|
||||
/**
|
||||
* Handles loading, updating, and basic validation of configuration files.
|
||||
*/
|
||||
public class ConfigLoader {
|
||||
|
||||
@NotNull
|
||||
public static FileConfiguration loadConfig(@NotNull File configFile) {
|
||||
return YamlConfiguration.loadConfiguration(configFile);
|
||||
}
|
||||
|
||||
@NotNull
|
||||
public static File initializeConfigFile(@NotNull String fileName) {
|
||||
File configFile = LoParkour.getInFolder(fileName);
|
||||
if (!configFile.exists()) {
|
||||
LoParkour.getPlugin().saveResource(fileName, false);
|
||||
}
|
||||
return configFile;
|
||||
}
|
||||
|
||||
public static void updateConfig(@NotNull String fileName, @NotNull File configFile, @Nullable List<String> ignoredSections) {
|
||||
try {
|
||||
var plugin = LoParkour.getPlugin();
|
||||
var resourceStream = plugin.getResource(fileName);
|
||||
if (resourceStream != null) {
|
||||
ConfigUpdater.update(configFile, resourceStream, "1.0", ignoredSections, plugin.getLogger());
|
||||
}
|
||||
} catch (Exception ex) {
|
||||
LoParkour.getPlugin().getLogger().log(java.util.logging.Level.SEVERE,
|
||||
"Error while trying to update config file: " + fileName, ex);
|
||||
}
|
||||
}
|
||||
|
||||
public static void reloadAllConfigs(boolean initialLoad) {
|
||||
for (Config config : Config.values()) {
|
||||
if (initialLoad) {
|
||||
config.path = initializeConfigFile(config.fileName);
|
||||
updateConfig(config.fileName, config.path, config.ignoredSections);
|
||||
}
|
||||
config.fileConfiguration = loadConfig(config.path);
|
||||
}
|
||||
|
||||
validateConfigs();
|
||||
}
|
||||
|
||||
public static void initializeDependentSystems(boolean firstLoad) {
|
||||
dev.loki.loparkour.reward.Rewards.init();
|
||||
Locales.init();
|
||||
dev.loki.loparkour.schematic.Schematics.init();
|
||||
Option.init(firstLoad);
|
||||
}
|
||||
|
||||
// ── inline validation (was ConfigValidatorRefactored) ─────────────────────
|
||||
|
||||
private static void validateConfigs() {
|
||||
Logger log = LoParkour.getPlugin().getLogger();
|
||||
|
||||
if (Config.CONFIG.isPath("world.max-y") && Config.CONFIG.isPath("world.min-y")) {
|
||||
int maxY = Config.CONFIG.getInt("world.max-y");
|
||||
int minY = Config.CONFIG.getInt("world.min-y");
|
||||
if (maxY <= minY) {
|
||||
log.severe("Invalid config: world.max-y (%d) must be > world.min-y (%d)".formatted(maxY, minY));
|
||||
}
|
||||
}
|
||||
|
||||
if (Config.GENERATION.isPath("generation.normal.height")) {
|
||||
double sum = Config.GENERATION.getDouble("generation.normal.height.1")
|
||||
+ Config.GENERATION.getDouble("generation.normal.height.0")
|
||||
+ Config.GENERATION.getDouble("generation.normal.height.-1")
|
||||
+ Config.GENERATION.getDouble("generation.normal.height.-2");
|
||||
if (sum <= 0) {
|
||||
log.severe("Invalid config: generation height chances sum to %.1f, must be > 0".formatted(sum));
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -1,287 +1,197 @@
|
|||
package dev.loki.loparkour.config;
|
||||
|
||||
import org.bukkit.plugin.Plugin;
|
||||
import org.jetbrains.annotations.NotNull;
|
||||
import org.jetbrains.annotations.Nullable;
|
||||
|
||||
import java.io.*;
|
||||
import java.nio.charset.StandardCharsets;
|
||||
import java.nio.file.Files;
|
||||
import java.nio.file.StandardCopyOption;
|
||||
import java.util.*;
|
||||
import java.util.logging.Level;
|
||||
import java.util.logging.Logger;
|
||||
|
||||
/**
|
||||
* Automatically adds missing keys from the default (bundled) config
|
||||
* into the player's config file on disk, preserving all existing values,
|
||||
* comments, and blank lines.
|
||||
*
|
||||
* <p>Algorithm:
|
||||
* <ol>
|
||||
* <li>Load the default config from the jar as a list of raw lines.</li>
|
||||
* <li>Load the on-disk config as a flat key→value map.</li>
|
||||
* <li>Walk the default lines; for every key that is missing on disk,
|
||||
* emit the default line (with its comment header) into the output.</li>
|
||||
* <li>Rewrite the on-disk file with the merged result.</li>
|
||||
* </ol>
|
||||
*
|
||||
* Sections listed in {@code ignoredSections} are never touched — their
|
||||
* default content is never injected (user manages them freely).
|
||||
* Merges a resource-template YAML with the user's existing file,
|
||||
* preserving user values while adding any new keys from the template.
|
||||
* Uses full YAML paths (e.g. "sql.enabled") to avoid key collisions.
|
||||
*/
|
||||
public final class ConfigUpdater {
|
||||
public class ConfigUpdater {
|
||||
|
||||
private ConfigUpdater() {}
|
||||
private final Logger logger;
|
||||
|
||||
private ConfigUpdater(@NotNull Logger logger) {
|
||||
this.logger = logger;
|
||||
}
|
||||
|
||||
/**
|
||||
* Updates {@code diskFile} by injecting any keys that exist in the
|
||||
* bundled default but are absent on disk.
|
||||
*
|
||||
* @param plugin the plugin (used to read the bundled resource)
|
||||
* @param resourceName path inside the jar, e.g. {@code "config.yml"}
|
||||
* @param diskFile the on-disk file to update
|
||||
* @param ignoredSections top-level sections whose keys should never be added
|
||||
* (pass {@code null} or empty list to ignore nothing)
|
||||
*/
|
||||
public static void update(
|
||||
@NotNull Plugin plugin,
|
||||
@NotNull String resourceName,
|
||||
@NotNull File diskFile,
|
||||
@Nullable List<String> ignoredSections
|
||||
) throws IOException {
|
||||
@NotNull File configFile,
|
||||
@NotNull InputStream templateStream,
|
||||
@NotNull String expectedVersion,
|
||||
@Nullable List<String> ignoredKeys,
|
||||
@NotNull Logger logger) throws IOException {
|
||||
|
||||
List<String> defaultLines = readResource(plugin, resourceName);
|
||||
if (defaultLines == null) {
|
||||
plugin.getLogger().warning("[ConfigUpdater] Resource not found in jar: " + resourceName);
|
||||
return;
|
||||
}
|
||||
new ConfigUpdater(logger).performUpdate(configFile, templateStream, ignoredKeys);
|
||||
}
|
||||
|
||||
// Flat key set of what already exists on disk (dot-separated paths)
|
||||
Set<String> diskKeys = flatKeys(diskFile);
|
||||
// ── core ──────────────────────────────────────────────────────────────────
|
||||
|
||||
// Ignored section prefixes (e.g. "styles" → skip any path starting with "styles.")
|
||||
Set<String> ignored = normalizedIgnored(ignoredSections);
|
||||
private void performUpdate(
|
||||
@NotNull File configFile,
|
||||
@NotNull InputStream templateStream,
|
||||
@Nullable List<String> ignoredKeys) throws IOException {
|
||||
|
||||
List<String> diskLines = readLines(diskFile);
|
||||
List<String> outputLines = merge(defaultLines, diskLines, diskKeys, ignored);
|
||||
|
||||
// Only write if something actually changed
|
||||
if (!outputLines.equals(diskLines)) {
|
||||
writeLines(diskFile, outputLines);
|
||||
plugin.getLogger().info("[ConfigUpdater] Updated " + resourceName
|
||||
+ " — added " + countNew(diskLines, outputLines) + " new key(s).");
|
||||
File backup = createBackup(configFile);
|
||||
try {
|
||||
List<String> merged = merge(templateStream, configFile, ignoredKeys);
|
||||
Files.write(configFile.toPath(), merged);
|
||||
logger.info("Configuration file synced: " + configFile.getName());
|
||||
} catch (Exception e) {
|
||||
if (backup != null && backup.exists()) {
|
||||
Files.copy(backup.toPath(), configFile.toPath(), StandardCopyOption.REPLACE_EXISTING);
|
||||
logger.severe("Config sync failed, restored backup: " + e.getMessage());
|
||||
}
|
||||
throw e;
|
||||
}
|
||||
}
|
||||
|
||||
// ── Merge ─────────────────────────────────────────────────────────────────
|
||||
// ── merge ─────────────────────────────────────────────────────────────────
|
||||
|
||||
/**
|
||||
* Merges default lines into disk lines.
|
||||
*
|
||||
* <p>Strategy: walk disk lines first, emit them as-is. Then walk default
|
||||
* lines; for each key block (comment + key line) whose key is absent on
|
||||
* disk and not ignored, append it at the correct indentation level.
|
||||
*/
|
||||
private static List<String> merge(
|
||||
List<String> defaultLines,
|
||||
List<String> diskLines,
|
||||
Set<String> diskKeys,
|
||||
Set<String> ignored
|
||||
) {
|
||||
// We need to insert missing keys at the right position.
|
||||
// Build a copy of disk lines we will extend.
|
||||
List<String> result = new ArrayList<>(diskLines);
|
||||
@NotNull
|
||||
private List<String> merge(
|
||||
@NotNull InputStream templateStream,
|
||||
@NotNull File existingFile,
|
||||
@Nullable List<String> ignoredKeys) throws IOException {
|
||||
|
||||
// Collect blocks from the default file: each block = list of comment
|
||||
// lines + the key line itself. We track the "path stack" to know the
|
||||
// full dotted path of every key.
|
||||
Deque<String> pathStack = new ArrayDeque<>();
|
||||
List<String> pendingComments = new ArrayList<>();
|
||||
List<String> templateLines = readLines(templateStream);
|
||||
List<String> existingLines = existingFile.exists() ? readLines(existingFile) : List.of();
|
||||
|
||||
for (int i = 0; i < defaultLines.size(); i++) {
|
||||
String raw = defaultLines.get(i);
|
||||
String trimmed = raw.stripLeading();
|
||||
Set<String> ignored = normalizeIgnored(ignoredKeys);
|
||||
Map<String, String> existingValues = extractFullPathValues(existingLines);
|
||||
|
||||
// Blank line → flush pending comments, keep as separator
|
||||
if (trimmed.isBlank()) {
|
||||
pendingComments.add(raw);
|
||||
List<String> result = new ArrayList<>();
|
||||
Deque<String> stack = new ArrayDeque<>();
|
||||
int prevIndent = -1;
|
||||
|
||||
for (String line : templateLines) {
|
||||
String trimmed = line.trim();
|
||||
|
||||
if (trimmed.isEmpty() || trimmed.startsWith("#") || trimmed.startsWith("-")) {
|
||||
result.add(line);
|
||||
continue;
|
||||
}
|
||||
|
||||
// Comment line → accumulate
|
||||
if (trimmed.startsWith("#")) {
|
||||
pendingComments.add(raw);
|
||||
continue;
|
||||
int colon = trimmed.indexOf(':');
|
||||
if (colon == -1) { result.add(line); continue; }
|
||||
|
||||
String leaf = trimmed.substring(0, colon).trim();
|
||||
String after = trimmed.substring(colon + 1).trim();
|
||||
int indent = indentOf(line);
|
||||
|
||||
while (prevIndent >= 0 && indent <= prevIndent) {
|
||||
if (!stack.isEmpty()) stack.pop();
|
||||
prevIndent -= 2;
|
||||
}
|
||||
|
||||
// Key line
|
||||
int indent = raw.length() - trimmed.length();
|
||||
int depth = indent / 2; // assume 2-space indentation
|
||||
String fullPath = stack.isEmpty() ? leaf : String.join(".", stack) + "." + leaf;
|
||||
boolean section = after.isEmpty() || after.startsWith("#");
|
||||
|
||||
// Pop stack back to current depth
|
||||
while (pathStack.size() > depth) pathStack.pollLast();
|
||||
|
||||
// Parse key name (everything before ':')
|
||||
String keyPart = trimmed.split(":")[0].trim();
|
||||
pathStack.addLast(keyPart);
|
||||
String fullPath = String.join(".", pathStack);
|
||||
|
||||
boolean isSection = trimmed.endsWith(":") || trimmed.matches(".*:\\s*$");
|
||||
boolean isIgnored = isIgnored(fullPath, ignored);
|
||||
|
||||
if (!isIgnored && !diskKeys.contains(fullPath) && !isSection) {
|
||||
// This key is missing on disk — inject it
|
||||
// Find the best insertion point: after the last line that
|
||||
// belongs to the parent section on disk
|
||||
int insertAt = findInsertionPoint(result, pathStack, depth);
|
||||
|
||||
// Insert blank separator + comments + key line
|
||||
List<String> block = new ArrayList<>();
|
||||
if (insertAt == result.size() || !result.get(Math.max(0, insertAt - 1)).isBlank()) {
|
||||
block.add("");
|
||||
}
|
||||
block.addAll(pendingComments);
|
||||
block.add(raw);
|
||||
result.addAll(insertAt, block);
|
||||
if (section) {
|
||||
stack.push(leaf);
|
||||
prevIndent = indent;
|
||||
result.add(line);
|
||||
} else if (isIgnored(fullPath, ignored)) {
|
||||
result.add(line);
|
||||
prevIndent = indent;
|
||||
} else {
|
||||
String existing = existingValues.get(fullPath);
|
||||
result.add(existing != null
|
||||
? line.substring(0, line.indexOf(':') + 1) + " " + existing
|
||||
: line);
|
||||
prevIndent = indent;
|
||||
}
|
||||
|
||||
// Clear pending comments regardless
|
||||
pendingComments.clear();
|
||||
}
|
||||
|
||||
return result;
|
||||
}
|
||||
|
||||
/**
|
||||
* Finds the line index where a missing key should be inserted.
|
||||
* Inserts after the last line of its parent section.
|
||||
*/
|
||||
private static int findInsertionPoint(List<String> lines, Deque<String> pathStack, int depth) {
|
||||
if (depth == 0) {
|
||||
// Top-level key → append at end
|
||||
return lines.size();
|
||||
}
|
||||
@NotNull
|
||||
private Map<String, String> extractFullPathValues(@NotNull List<String> lines) {
|
||||
Map<String, String> values = new LinkedHashMap<>();
|
||||
Deque<String> stack = new ArrayDeque<>();
|
||||
int prevIndent = -1;
|
||||
|
||||
// Parent path
|
||||
List<String> parts = new ArrayList<>(pathStack);
|
||||
String parentKey = parts.get(depth - 1);
|
||||
int parentIndent = (depth - 1) * 2;
|
||||
for (String line : lines) {
|
||||
String trimmed = line.trim();
|
||||
if (trimmed.isEmpty() || trimmed.startsWith("#") || trimmed.startsWith("-")) continue;
|
||||
|
||||
// Find the parent key line in the result
|
||||
int parentLine = -1;
|
||||
for (int i = 0; i < lines.size(); i++) {
|
||||
String l = lines.get(i);
|
||||
int ind = l.length() - l.stripLeading().length();
|
||||
if (ind == parentIndent && l.stripLeading().startsWith(parentKey + ":")) {
|
||||
parentLine = i;
|
||||
break;
|
||||
int colon = trimmed.indexOf(':');
|
||||
if (colon == -1) continue;
|
||||
|
||||
String leaf = trimmed.substring(0, colon).trim();
|
||||
String after = trimmed.substring(colon + 1).trim();
|
||||
int indent = indentOf(line);
|
||||
|
||||
while (prevIndent >= 0 && indent <= prevIndent) {
|
||||
if (!stack.isEmpty()) stack.pop();
|
||||
prevIndent -= 2;
|
||||
}
|
||||
|
||||
String fullPath = stack.isEmpty() ? leaf : String.join(".", stack) + "." + leaf;
|
||||
|
||||
if (after.isEmpty() || after.startsWith("#")) {
|
||||
stack.push(leaf);
|
||||
} else {
|
||||
values.put(fullPath, after);
|
||||
}
|
||||
prevIndent = indent;
|
||||
}
|
||||
|
||||
if (parentLine < 0) return lines.size();
|
||||
|
||||
// Find end of parent section: next line with same or lower indent that isn't blank/comment
|
||||
for (int i = parentLine + 1; i < lines.size(); i++) {
|
||||
String l = lines.get(i);
|
||||
if (l.isBlank() || l.stripLeading().startsWith("#")) continue;
|
||||
int ind = l.length() - l.stripLeading().length();
|
||||
if (ind <= parentIndent) return i;
|
||||
}
|
||||
|
||||
return lines.size();
|
||||
return values;
|
||||
}
|
||||
|
||||
// ── IO helpers ─────────────────────────────────────────────────────────────
|
||||
// ── helpers ───────────────────────────────────────────────────────────────
|
||||
|
||||
private static int indentOf(@NotNull String line) {
|
||||
int i = 0;
|
||||
while (i < line.length() && line.charAt(i) == ' ') i++;
|
||||
return i;
|
||||
}
|
||||
|
||||
/** Reads a resource from the jar as a list of lines. Returns null if not found. */
|
||||
@Nullable
|
||||
private static List<String> readResource(Plugin plugin, String name) {
|
||||
try (InputStream in = plugin.getResource(name)) {
|
||||
if (in == null) return null;
|
||||
return readLines(in);
|
||||
} catch (IOException ex) {
|
||||
plugin.getLogger().log(Level.SEVERE, "Failed to read resource: " + name, ex);
|
||||
private File createBackup(@NotNull File file) {
|
||||
if (!file.exists()) return null;
|
||||
try {
|
||||
File bak = new File(file.getParent(), file.getName() + ".bak");
|
||||
Files.copy(file.toPath(), bak.toPath(), StandardCopyOption.REPLACE_EXISTING);
|
||||
return bak;
|
||||
} catch (IOException e) {
|
||||
logger.warning("Failed to create backup: " + e.getMessage());
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
private static List<String> readLines(File file) throws IOException {
|
||||
try (BufferedReader r = new BufferedReader(
|
||||
new InputStreamReader(new FileInputStream(file), StandardCharsets.UTF_8))) {
|
||||
return readLines(r);
|
||||
@NotNull
|
||||
private static List<String> readLines(@NotNull InputStream is) throws IOException {
|
||||
try (var r = new BufferedReader(new InputStreamReader(is))) {
|
||||
return r.lines().toList();
|
||||
}
|
||||
}
|
||||
|
||||
private static List<String> readLines(InputStream in) throws IOException {
|
||||
try (BufferedReader r = new BufferedReader(new InputStreamReader(in, StandardCharsets.UTF_8))) {
|
||||
return readLines(r);
|
||||
}
|
||||
@NotNull
|
||||
private static List<String> readLines(@NotNull File file) throws IOException {
|
||||
return Files.readAllLines(file.toPath());
|
||||
}
|
||||
|
||||
private static List<String> readLines(BufferedReader reader) throws IOException {
|
||||
List<String> lines = new ArrayList<>();
|
||||
String line;
|
||||
while ((line = reader.readLine()) != null) lines.add(line);
|
||||
return lines;
|
||||
@NotNull
|
||||
private static Set<String> normalizeIgnored(@Nullable List<String> keys) {
|
||||
if (keys == null) return Set.of();
|
||||
Set<String> s = new HashSet<>();
|
||||
for (String k : keys) s.add(k.trim().toLowerCase());
|
||||
return s;
|
||||
}
|
||||
|
||||
private static void writeLines(File file, List<String> lines) throws IOException {
|
||||
try (BufferedWriter w = new BufferedWriter(
|
||||
new OutputStreamWriter(new FileOutputStream(file), StandardCharsets.UTF_8))) {
|
||||
for (int i = 0; i < lines.size(); i++) {
|
||||
w.write(lines.get(i));
|
||||
if (i < lines.size() - 1) w.newLine();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// ── Key extraction ─────────────────────────────────────────────────────────
|
||||
|
||||
/**
|
||||
* Returns the flat set of dotted-path keys present in the file.
|
||||
* Only leaf keys (not sections) are returned.
|
||||
*/
|
||||
private static Set<String> flatKeys(File file) throws IOException {
|
||||
Set<String> keys = new LinkedHashSet<>();
|
||||
Deque<String> stack = new ArrayDeque<>();
|
||||
|
||||
List<String> lines = readLines(file);
|
||||
for (String raw : lines) {
|
||||
String trimmed = raw.stripLeading();
|
||||
if (trimmed.isBlank() || trimmed.startsWith("#")) continue;
|
||||
|
||||
int indent = raw.length() - trimmed.length();
|
||||
int depth = indent / 2;
|
||||
|
||||
while (stack.size() > depth) stack.pollLast();
|
||||
|
||||
if (!trimmed.contains(":")) continue;
|
||||
String keyPart = trimmed.split(":")[0].trim();
|
||||
stack.addLast(keyPart);
|
||||
|
||||
boolean isSection = trimmed.matches("[^:]+:\\s*$");
|
||||
if (!isSection) {
|
||||
keys.add(String.join(".", stack));
|
||||
}
|
||||
}
|
||||
return keys;
|
||||
}
|
||||
|
||||
// ── Helpers ────────────────────────────────────────────────────────────────
|
||||
|
||||
private static Set<String> normalizedIgnored(@Nullable List<String> list) {
|
||||
if (list == null || list.isEmpty()) return Collections.emptySet();
|
||||
Set<String> result = new LinkedHashSet<>();
|
||||
for (String s : list) result.add(s.toLowerCase(Locale.ROOT).trim());
|
||||
return result;
|
||||
}
|
||||
|
||||
private static boolean isIgnored(String fullPath, Set<String> ignored) {
|
||||
String lower = fullPath.toLowerCase(Locale.ROOT);
|
||||
for (String prefix : ignored) {
|
||||
if (lower.equals(prefix) || lower.startsWith(prefix + ".")) return true;
|
||||
private static boolean isIgnored(@NotNull String fullPath, @NotNull Set<String> ignored) {
|
||||
String lower = fullPath.toLowerCase();
|
||||
for (String ign : ignored) {
|
||||
if (lower.equals(ign) || lower.startsWith(ign + ".")) return true;
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
private static int countNew(List<String> before, List<String> after) {
|
||||
return Math.max(0, after.size() - before.size());
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1,117 +0,0 @@
|
|||
package dev.loki.loparkour.config;
|
||||
|
||||
import dev.loki.loparkour.LoParkour;
|
||||
|
||||
/**
|
||||
* Generation and jump-validation settings. Populated by {@link Option#init}.
|
||||
*/
|
||||
public final class GenerationOptions {
|
||||
|
||||
// Block type chances
|
||||
public static double TYPE_NORMAL;
|
||||
public static double TYPE_SPECIAL;
|
||||
public static double TYPE_SCHEMATICS;
|
||||
|
||||
// Special block chances
|
||||
public static double SPECIAL_ICE;
|
||||
public static double SPECIAL_SLAB;
|
||||
public static double SPECIAL_PANE;
|
||||
public static double SPECIAL_FENCE;
|
||||
|
||||
// Distance weights
|
||||
public static double NORMAL_DISTANCE_1;
|
||||
public static double NORMAL_DISTANCE_2;
|
||||
public static double NORMAL_DISTANCE_3;
|
||||
public static double NORMAL_DISTANCE_4;
|
||||
|
||||
// Height weights
|
||||
public static double NORMAL_HEIGHT_1;
|
||||
public static double NORMAL_HEIGHT_0;
|
||||
public static double NORMAL_HEIGHT_NEG1;
|
||||
public static double NORMAL_HEIGHT_NEG2;
|
||||
|
||||
// World bounds
|
||||
public static int MAX_Y;
|
||||
public static int MIN_Y;
|
||||
|
||||
// Jump validation
|
||||
public static boolean JUMP_VALIDATION_ENABLED;
|
||||
public static double MAX_JUMP_DISTANCE;
|
||||
public static double MAX_HORIZONTAL_DISTANCE;
|
||||
public static double MAX_VERTICAL_UP;
|
||||
public static double MAX_VERTICAL_DOWN;
|
||||
|
||||
// Jump types
|
||||
public static boolean JUMP_TYPES_ENABLED;
|
||||
public static java.util.Map<String, Boolean> JUMP_TYPE_ENABLED;
|
||||
public static java.util.Map<String, Double> JUMP_TYPE_CHANCE;
|
||||
|
||||
// Memory optimisation
|
||||
public static int BLOCK_CLEANUP_DISTANCE;
|
||||
public static int CLEANUP_INTERVAL;
|
||||
|
||||
// Ghost mode
|
||||
public static boolean GHOST_MODE_ENABLED;
|
||||
public static int GHOST_SHOW_TOP;
|
||||
public static double GHOST_TRANSPARENCY;
|
||||
|
||||
static void init() {
|
||||
// Jump validation
|
||||
JUMP_VALIDATION_ENABLED = Config.CONFIG.isPath("jump-validation.enabled") && Config.CONFIG.getBoolean("jump-validation.enabled");
|
||||
MAX_JUMP_DISTANCE = Config.CONFIG.isPath("jump-validation.max-distance") ? Config.CONFIG.getDouble("jump-validation.max-distance") : 5.0;
|
||||
MAX_HORIZONTAL_DISTANCE = Config.CONFIG.isPath("jump-validation.max-horizontal") ? Config.CONFIG.getDouble("jump-validation.max-horizontal") : 4.0;
|
||||
MAX_VERTICAL_UP = Config.CONFIG.isPath("jump-validation.max-vertical-up") ? Config.CONFIG.getDouble("jump-validation.max-vertical-up") : 1.0;
|
||||
MAX_VERTICAL_DOWN = Config.CONFIG.isPath("jump-validation.max-vertical-down") ? Config.CONFIG.getDouble("jump-validation.max-vertical-down") : 3.0;
|
||||
|
||||
// Jump types
|
||||
JUMP_TYPES_ENABLED = Config.CONFIG.isPath("jump-types.enabled") && Config.CONFIG.getBoolean("jump-types.enabled");
|
||||
JUMP_TYPE_ENABLED = new java.util.HashMap<>();
|
||||
JUMP_TYPE_CHANCE = new java.util.HashMap<>();
|
||||
if (Config.CONFIG.isPath("jump-types.types")) {
|
||||
for (String type : Config.CONFIG.getChildren("jump-types.types")) {
|
||||
String path = "jump-types.types." + type;
|
||||
JUMP_TYPE_ENABLED.put(type, Config.CONFIG.isPath(path + ".enabled") && Config.CONFIG.getBoolean(path + ".enabled"));
|
||||
JUMP_TYPE_CHANCE.put(type, Config.CONFIG.isPath(path + ".chance") ? Config.CONFIG.getDouble(path + ".chance") : 0.0);
|
||||
}
|
||||
}
|
||||
|
||||
// Memory
|
||||
BLOCK_CLEANUP_DISTANCE = Config.CONFIG.isPath("memory.block-cleanup-distance") ? Config.CONFIG.getInt("memory.block-cleanup-distance") : 100;
|
||||
CLEANUP_INTERVAL = Config.CONFIG.isPath("memory.cleanup-interval") ? Config.CONFIG.getInt("memory.cleanup-interval") : 100;
|
||||
|
||||
// Ghost
|
||||
GHOST_MODE_ENABLED = Config.CONFIG.isPath("ghost-mode.enabled") && Config.CONFIG.getBoolean("ghost-mode.enabled");
|
||||
GHOST_SHOW_TOP = Config.CONFIG.isPath("ghost-mode.show-top") ? Config.CONFIG.getInt("ghost-mode.show-top") : 3;
|
||||
GHOST_TRANSPARENCY = Config.CONFIG.isPath("ghost-mode.transparency") ? Config.CONFIG.getDouble("ghost-mode.transparency") : 0.5;
|
||||
|
||||
// Generation chances
|
||||
TYPE_NORMAL = Config.GENERATION.getInt("generation.type.normal") / 100.0;
|
||||
TYPE_SPECIAL = Config.GENERATION.getInt("generation.type.special") / 100.0;
|
||||
TYPE_SCHEMATICS = Config.GENERATION.getInt("generation.type.schematic") / 100.0;
|
||||
|
||||
SPECIAL_ICE = Config.GENERATION.getInt("generation.special.ice") / 100.0;
|
||||
SPECIAL_SLAB = Config.GENERATION.getInt("generation.special.slab") / 100.0;
|
||||
SPECIAL_PANE = Config.GENERATION.getInt("generation.special.pane") / 100.0;
|
||||
SPECIAL_FENCE = Config.GENERATION.getInt("generation.special.fence") / 100.0;
|
||||
|
||||
NORMAL_DISTANCE_1 = Config.GENERATION.getInt("generation.normal.distance.1") / 100.0;
|
||||
NORMAL_DISTANCE_2 = Config.GENERATION.getInt("generation.normal.distance.2") / 100.0;
|
||||
NORMAL_DISTANCE_3 = Config.GENERATION.getInt("generation.normal.distance.3") / 100.0;
|
||||
NORMAL_DISTANCE_4 = Config.GENERATION.getInt("generation.normal.distance.4") / 100.0;
|
||||
|
||||
NORMAL_HEIGHT_1 = Config.GENERATION.getInt("generation.normal.height.1") / 100.0;
|
||||
NORMAL_HEIGHT_0 = Config.GENERATION.getInt("generation.normal.height.0") / 100.0;
|
||||
NORMAL_HEIGHT_NEG1 = Config.GENERATION.getInt("generation.normal.height.-1") / 100.0;
|
||||
NORMAL_HEIGHT_NEG2 = Config.GENERATION.getInt("generation.normal.height.-2") / 100.0;
|
||||
|
||||
MAX_Y = Config.GENERATION.getInt("generation.settings.max-y");
|
||||
MIN_Y = Config.GENERATION.getInt("generation.settings.min-y");
|
||||
|
||||
if (MIN_Y >= MAX_Y) {
|
||||
MIN_Y = 100; MAX_Y = 200;
|
||||
LoParkour.getPlugin().getLogger().severe("min-y >= max-y in generation.yml — using defaults 100/200.");
|
||||
}
|
||||
}
|
||||
|
||||
private GenerationOptions() {}
|
||||
}
|
||||
|
|
@ -1,13 +1,10 @@
|
|||
package dev.loki.loparkour.config;
|
||||
|
||||
import java.util.ArrayList;
|
||||
|
||||
import dev.loki.loparkour.util.Item;
|
||||
|
||||
import dev.loki.loparkour.LoParkour;
|
||||
import dev.loki.loparkour.menu.ParkourOption;
|
||||
import dev.loki.loparkour.player.ParkourUser;
|
||||
|
||||
import dev.loki.loparkour.util.Item;
|
||||
import dev.loki.loparkour.util.Materials;
|
||||
import dev.lolib.scheduler.Scheduler;
|
||||
import org.bukkit.Material;
|
||||
import org.bukkit.configuration.ConfigurationSection;
|
||||
|
|
@ -30,244 +27,194 @@ import java.util.regex.Pattern;
|
|||
import java.util.stream.Stream;
|
||||
|
||||
/**
|
||||
* Locale message/item handler.
|
||||
* Locale system: loading, caching, validation, and access.
|
||||
*/
|
||||
public class Locales {
|
||||
|
||||
// a list of all nodes, used to check against missing nodes
|
||||
private static List<String> resourceNodes;
|
||||
private static final Map<String, FileConfiguration> locales = new HashMap<>();
|
||||
private static final Pattern REPLACEMENT_PATTERN = Pattern.compile("%[a-z]");
|
||||
|
||||
/**
|
||||
* A map of all locales with their respective yml trees
|
||||
*/
|
||||
public static final Map<String, FileConfiguration> locales = new HashMap<>();
|
||||
// ── public API ────────────────────────────────────────────────────────────
|
||||
|
||||
/**
|
||||
* Initializes this Locale handler.
|
||||
*/
|
||||
public static void init() {
|
||||
loadLocalesAsync();
|
||||
}
|
||||
|
||||
@NotNull
|
||||
public static String getString(@NotNull Player player, @NotNull String path) {
|
||||
return getString(getPlayerLocale(player), path);
|
||||
}
|
||||
|
||||
@NotNull
|
||||
public static String getString(@NotNull String locale, @NotNull String path) {
|
||||
return cachedValue(locale, c -> c.getString(path), "");
|
||||
}
|
||||
|
||||
@NotNull
|
||||
public static List<String> getStringList(@NotNull String locale, @NotNull String path) {
|
||||
return cachedValue(locale, c -> c.getStringList(path), Collections.emptyList());
|
||||
}
|
||||
|
||||
public static int getLocaleCount() {
|
||||
synchronized (locales) { return locales.size(); }
|
||||
}
|
||||
|
||||
@NotNull
|
||||
public static Set<String> getLocaleKeys() {
|
||||
synchronized (locales) { return new HashSet<>(locales.keySet()); }
|
||||
}
|
||||
|
||||
@NotNull
|
||||
public static Item getItem(@NotNull Player player, @NotNull String path, String... replace) {
|
||||
return getItem(getPlayerLocale(player), path, replace);
|
||||
}
|
||||
|
||||
@NotNull
|
||||
public static Item getItem(@NotNull String locale, @NotNull String path, String... replace) {
|
||||
synchronized (locales) {
|
||||
if (locales.isEmpty()) return new Item(Material.STONE, "");
|
||||
FileConfiguration config = locales.get(locale);
|
||||
if (config == null) return new Item(Material.STONE, "");
|
||||
return buildItem(config, path, replace);
|
||||
}
|
||||
}
|
||||
|
||||
// ── loading (was LocaleLoader) ────────────────────────────────────────────
|
||||
|
||||
private static void loadLocalesAsync() {
|
||||
Plugin plugin = LoParkour.getPlugin();
|
||||
|
||||
// Create a temporary map to avoid clearing locales before new ones are loaded
|
||||
Map<String, FileConfiguration> newLocales = new HashMap<>();
|
||||
|
||||
Scheduler.get(plugin).runAsync(() -> {
|
||||
FileConfiguration embedded = YamlConfiguration.loadConfiguration(new InputStreamReader(plugin.getResource("locales/en.yml"), StandardCharsets.UTF_8));
|
||||
try {
|
||||
FileConfiguration embedded = YamlConfiguration.loadConfiguration(
|
||||
new InputStreamReader(plugin.getResource("locales/en.yml"), StandardCharsets.UTF_8));
|
||||
|
||||
// get all nodes from the plugin's english resource, aka the most updated version
|
||||
resourceNodes = getChildren(embedded, "", true);
|
||||
Map<String, FileConfiguration> loaded = loadFromDisk(embedded);
|
||||
|
||||
File folder = LoParkour.getInFolder("locales");
|
||||
|
||||
// download files to locales folder
|
||||
if (!folder.exists()) {
|
||||
folder.mkdirs();
|
||||
}
|
||||
|
||||
String[] files = folder.list();
|
||||
|
||||
// create non-existent files
|
||||
if (files != null && files.length == 0) {
|
||||
plugin.saveResource("locales/en.yml", false);
|
||||
plugin.saveResource("locales/ru.yml", false);
|
||||
}
|
||||
|
||||
// get all files in locales folder
|
||||
try (Stream<Path> stream = Files.list(folder.toPath())) {
|
||||
stream.forEach(path -> {
|
||||
File file = path.toFile();
|
||||
|
||||
// get locale from file name
|
||||
String locale = file.getName().split("\\.")[0];
|
||||
|
||||
LoParkour.log("Found locale " + locale);
|
||||
|
||||
FileConfiguration config = YamlConfiguration.loadConfiguration(file);
|
||||
validate(embedded, config, file);
|
||||
|
||||
newLocales.put(locale, config);
|
||||
Scheduler.get(plugin).run(() -> {
|
||||
synchronized (locales) {
|
||||
locales.clear();
|
||||
locales.putAll(loaded);
|
||||
}
|
||||
LoParkour.log("Locales reloaded successfully (" + loaded.size() + " locales)");
|
||||
});
|
||||
} catch (Exception ex) {
|
||||
LoParkour.getPlugin().getLogger().severe("Error while trying to read locale files - restart/reload your server - " + ex.getMessage());
|
||||
plugin.getLogger().severe("Error while loading locale files: " + ex.getMessage());
|
||||
}
|
||||
|
||||
// Only clear and replace locales after all new ones are loaded
|
||||
Scheduler.get(plugin).run(() -> {
|
||||
locales.clear();
|
||||
locales.putAll(newLocales);
|
||||
LoParkour.log("Locales reloaded successfully (" + locales.size() + " locales)");
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
// validates whether a lang file contains all required keys.
|
||||
// if it doesn't, automatically add them
|
||||
private static void validate(FileConfiguration provided, FileConfiguration user, File localPath) {
|
||||
List<String> userNodes = getChildren(user, "", true);
|
||||
|
||||
for (String node : resourceNodes) {
|
||||
if (userNodes.contains(node)) {
|
||||
continue;
|
||||
}
|
||||
|
||||
LoParkour.log("Fixing missing config node %s in %s".formatted(node, localPath.getName()));
|
||||
|
||||
user.set(node, provided.get(node));
|
||||
}
|
||||
|
||||
LoParkour.log("Validated locale " + localPath.getName());
|
||||
|
||||
try {
|
||||
user.save(localPath);
|
||||
} catch (IOException ex) {
|
||||
LoParkour.getPlugin().getLogger().severe("Error while trying to save fixed config file %s - delete this file and restart your server - ".formatted(localPath) + ex.getMessage());
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Gets a String from the provided path in the provided player's locale.
|
||||
* If the player is a {@link ParkourUser}, their locale value will be used.
|
||||
* If not, the default locale will be used.
|
||||
*
|
||||
* @param player The player
|
||||
* @param path The path
|
||||
* @return a String
|
||||
*/
|
||||
public static String getString(Player player, String path) {
|
||||
ParkourUser user = ParkourUser.getUser(player);
|
||||
|
||||
String locale = user == null ? Option.OPTIONS_DEFAULTS.get(ParkourOption.LANG) : user.locale;
|
||||
|
||||
return getString(locale, path);
|
||||
}
|
||||
|
||||
/**
|
||||
* Gets a coloured String from the provided path in the provided locale file
|
||||
*
|
||||
* @param locale The locale
|
||||
* @param path The path
|
||||
* @return a String
|
||||
*/
|
||||
public static String getString(String locale, String path) {
|
||||
return getValue(locale, config -> config.getString(path), "");
|
||||
}
|
||||
|
||||
/**
|
||||
* Gets an uncoloured String list from the provided path in the provided locale file
|
||||
*
|
||||
* @param locale The locale
|
||||
* @param path The path
|
||||
* @return a String list
|
||||
*/
|
||||
public static List<String> getStringList(String locale, String path) {
|
||||
return getValue(locale, config -> config.getStringList(path), Collections.emptyList());
|
||||
}
|
||||
|
||||
private static <T> T getValue(String locale, Function<FileConfiguration, T> f, T def) {
|
||||
if (locales.isEmpty()) {
|
||||
return def;
|
||||
}
|
||||
|
||||
FileConfiguration config = locales.get(locale);
|
||||
|
||||
return config != null ? f.apply(config) : def;
|
||||
}
|
||||
|
||||
/**
|
||||
* Returns an item from a json locale file.
|
||||
* The locale is derived from the player.
|
||||
* If the player is a {@link ParkourUser}, their locale value will be used.
|
||||
* If not, the default locale will be used.
|
||||
*
|
||||
* @param player The player
|
||||
* @param path The full path of the item in the locale file
|
||||
* @return a non-null {@link Item} instance built from the description in the locale file
|
||||
*/
|
||||
@NotNull
|
||||
public static Item getItem(@NotNull Player player, String path, String... replace) {
|
||||
ParkourUser user = ParkourUser.getUser(player);
|
||||
String locale = user == null ? Option.OPTIONS_DEFAULTS.get(ParkourOption.LANG) : user.locale;
|
||||
private static Map<String, FileConfiguration> loadFromDisk(@NotNull FileConfiguration embedded) throws Exception {
|
||||
Map<String, FileConfiguration> result = new HashMap<>();
|
||||
File folder = LoParkour.getInFolder("locales");
|
||||
ensureFolder(folder);
|
||||
|
||||
return getItem(locale, path, replace);
|
||||
try (Stream<Path> stream = Files.list(folder.toPath())) {
|
||||
stream.forEach(path -> {
|
||||
File file = path.toFile();
|
||||
if (!file.getName().endsWith(".yml")) return;
|
||||
|
||||
String locale = file.getName().split("\\.")[0];
|
||||
LoParkour.log("Found locale " + locale);
|
||||
|
||||
FileConfiguration config = YamlConfiguration.loadConfiguration(file);
|
||||
validateAndFix(embedded, config, file);
|
||||
result.put(locale, config);
|
||||
});
|
||||
}
|
||||
return result;
|
||||
}
|
||||
|
||||
private static final Pattern pattern = Pattern.compile("%[a-z]");
|
||||
private static void ensureFolder(@NotNull File folder) {
|
||||
if (!folder.exists()) folder.mkdirs();
|
||||
String[] files = folder.list();
|
||||
if (files == null || files.length == 0) {
|
||||
Plugin plugin = LoParkour.getPlugin();
|
||||
plugin.saveResource("locales/en.yml", false);
|
||||
plugin.saveResource("locales/ru.yml", false);
|
||||
}
|
||||
}
|
||||
|
||||
// ── validation (was LocaleValidator) ──────────────────────────────────────
|
||||
|
||||
private static void validateAndFix(@NotNull FileConfiguration reference, @NotNull FileConfiguration locale, @NotNull File file) {
|
||||
List<String> refNodes = getChildren(reference);
|
||||
List<String> locNodes = getChildren(locale);
|
||||
|
||||
boolean modified = false;
|
||||
for (String node : refNodes) {
|
||||
if (!locNodes.contains(node)) {
|
||||
LoParkour.log("Fixing missing config node %s in %s".formatted(node, file.getName()));
|
||||
locale.set(node, reference.get(node));
|
||||
modified = true;
|
||||
}
|
||||
}
|
||||
if (modified) {
|
||||
try { locale.save(file); }
|
||||
catch (IOException ex) {
|
||||
LoParkour.getPlugin().getLogger().severe(
|
||||
"Error saving fixed locale %s — delete and restart — %s".formatted(file.getName(), ex.getMessage()));
|
||||
}
|
||||
}
|
||||
LoParkour.log("Validated locale " + file.getName());
|
||||
}
|
||||
|
||||
/**
|
||||
* Returns an item from a provided json locale file with possible replacements.
|
||||
*
|
||||
* @param locale The locale
|
||||
* @param path The path in the json file
|
||||
* @param replace The Strings that will replace any appearances of a String following the regex "%[a-z]"
|
||||
* @return a non-null {@link Item} instance built from the description in the locale file
|
||||
*/
|
||||
@NotNull
|
||||
public static Item getItem(String locale, String path, String... replace) {
|
||||
if (locales.isEmpty()) { // during reloading
|
||||
return new Item(Material.STONE, "");
|
||||
private static List<String> getChildren(@NotNull FileConfiguration config) {
|
||||
ConfigurationSection section = config.getConfigurationSection("");
|
||||
return section != null ? new ArrayList<>(section.getKeys(true)) : Collections.emptyList();
|
||||
}
|
||||
|
||||
// ── cache access (was LocaleCache) ────────────────────────────────────────
|
||||
|
||||
@NotNull
|
||||
private static <T> T cachedValue(@NotNull String locale, @NotNull Function<FileConfiguration, T> extractor, @NotNull T defaultValue) {
|
||||
synchronized (locales) {
|
||||
if (locales.isEmpty()) return defaultValue;
|
||||
FileConfiguration config = locales.get(locale);
|
||||
if (config == null) return defaultValue;
|
||||
try {
|
||||
T result = extractor.apply(config);
|
||||
return result != null ? result : defaultValue;
|
||||
} catch (Exception e) { return defaultValue; }
|
||||
}
|
||||
}
|
||||
|
||||
FileConfiguration base = locales.get(locale);
|
||||
// ── item builder ──────────────────────────────────────────────────────────
|
||||
|
||||
String material = base.getString("%s.material".formatted(path));
|
||||
String name = base.getString("%s.name".formatted(path));
|
||||
String lore = base.getString("%s.lore".formatted(path));
|
||||
int modelId = base.getInt("%s.model_id".formatted(path), -1);
|
||||
@NotNull
|
||||
private static Item buildItem(@NotNull FileConfiguration config, @NotNull String path, String... replace) {
|
||||
String material = config.getString("%s.material".formatted(path), "STONE");
|
||||
String name = applyReplacements(config.getString("%s.name".formatted(path), ""), replace);
|
||||
String lore = applyReplacements(config.getString("%s.lore".formatted(path), ""), replace);
|
||||
|
||||
if (material == null) {
|
||||
material = "";
|
||||
}
|
||||
if (name == null) {
|
||||
name = "";
|
||||
}
|
||||
if (lore == null) {
|
||||
lore = "";
|
||||
}
|
||||
|
||||
int idx = 0;
|
||||
Matcher matcher = pattern.matcher(name);
|
||||
while (matcher.find()) {
|
||||
if (idx == replace.length) {
|
||||
break;
|
||||
}
|
||||
|
||||
name = name.replaceFirst(matcher.group(), replace[idx]);
|
||||
idx++;
|
||||
}
|
||||
|
||||
matcher = pattern.matcher(lore);
|
||||
|
||||
while (matcher.find()) {
|
||||
if (idx == replace.length) {
|
||||
break;
|
||||
}
|
||||
|
||||
lore = lore.replaceFirst(matcher.group(), replace[idx]);
|
||||
idx++;
|
||||
}
|
||||
|
||||
Material mat = Material.getMaterial(material.toUpperCase());
|
||||
Material mat = Materials.parse(material);
|
||||
if (mat == null) {
|
||||
LoParkour.getPlugin().getLogger().warning("Invalid material '%s' for locale path '%s', using STONE as fallback".formatted(material, path));
|
||||
LoParkour.getPlugin().getLogger().warning("Invalid material '%s' for locale path '%s', using STONE".formatted(material, path));
|
||||
mat = Material.STONE;
|
||||
}
|
||||
|
||||
Item item = new Item(mat, name);
|
||||
|
||||
if (!lore.isEmpty()) {
|
||||
item.lore(lore.split("\\|\\|"));
|
||||
}
|
||||
|
||||
if (modelId != -1) {
|
||||
// TODO: item.setCustomModelData(modelId);
|
||||
}
|
||||
|
||||
if (!lore.isEmpty()) item.lore(lore.split("\\|\\|"));
|
||||
return item;
|
||||
}
|
||||
|
||||
public static List<String> getChildren(FileConfiguration file, String path, boolean deep) {
|
||||
ConfigurationSection section = file.getConfigurationSection(path);
|
||||
@NotNull
|
||||
private static String applyReplacements(@NotNull String text, String... replacements) {
|
||||
if (replacements.length == 0) return text;
|
||||
String result = text;
|
||||
int idx = 0;
|
||||
Matcher matcher = REPLACEMENT_PATTERN.matcher(result);
|
||||
while (matcher.find() && idx < replacements.length) {
|
||||
result = result.replaceFirst(matcher.group(), replacements[idx++]);
|
||||
}
|
||||
return result;
|
||||
}
|
||||
|
||||
return section != null ? new ArrayList<>(section.getKeys(deep)) : Collections.emptyList();
|
||||
@NotNull
|
||||
private static String getPlayerLocale(@NotNull Player player) {
|
||||
ParkourUser user = ParkourUser.getUser(player);
|
||||
String loc = user == null ? Option.OPTIONS_DEFAULTS.get(ParkourOption.LANG) : user.locale;
|
||||
return (loc != null && !loc.isBlank()) ? loc : "en";
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1,6 +1,7 @@
|
|||
package dev.loki.loparkour.config;
|
||||
|
||||
import dev.loki.loparkour.LoParkour;
|
||||
import dev.loki.loparkour.util.Materials;
|
||||
import dev.loki.loparkour.api.Registry;
|
||||
import dev.loki.loparkour.menu.ParkourOption;
|
||||
import dev.loki.loparkour.style.RandomStyle;
|
||||
|
|
@ -9,24 +10,12 @@ import dev.loki.loparkour.util.ParticleData;
|
|||
import org.bukkit.*;
|
||||
import org.bukkit.block.BlockFace;
|
||||
import org.bukkit.configuration.file.FileConfiguration;
|
||||
import org.bukkit.util.Vector;
|
||||
|
||||
import java.util.*;
|
||||
import java.util.function.BiFunction;
|
||||
|
||||
/**
|
||||
* Top-level config constants used throughout the plugin.
|
||||
*
|
||||
* Heavy sub-sections are delegated to focused classes:
|
||||
* <ul>
|
||||
* <li>{@link SqlOptions} – SQL connection settings</li>
|
||||
* <li>{@link ParticleOptions} – particle & sound settings</li>
|
||||
* <li>{@link GenerationOptions} – generation & jump-validation settings</li>
|
||||
* </ul>
|
||||
*
|
||||
* Those classes expose their own static fields. Option re-exports the most
|
||||
* commonly accessed ones via forwarding fields so existing call-sites compile
|
||||
* without changes.
|
||||
* All config-derived constants used at runtime.
|
||||
*/
|
||||
public class Option {
|
||||
|
||||
|
|
@ -41,9 +30,8 @@ public class Option {
|
|||
|
||||
public static Location GO_BACK_LOC;
|
||||
|
||||
// ── Forwarded from sub-classes (backwards compat) ─────────────────────────
|
||||
// ── SQL ───────────────────────────────────────────────────────────────────
|
||||
|
||||
/** @see SqlOptions#SQL */
|
||||
public static boolean SQL;
|
||||
public static String SQL_URL;
|
||||
public static int SQL_PORT;
|
||||
|
|
@ -52,38 +40,49 @@ public class Option {
|
|||
public static String SQL_PASSWORD;
|
||||
public static String SQL_PREFIX;
|
||||
|
||||
/** @see ParticleOptions#SHAPE */
|
||||
public static ParticleShape PARTICLE_SHAPE;
|
||||
/** @see ParticleOptions#SOUND_TYPE */
|
||||
public static Sound SOUND_TYPE;
|
||||
/** @see ParticleOptions#SOUND_PITCH */
|
||||
public static int SOUND_PITCH;
|
||||
/** @see ParticleOptions#SOUND_VOLUME */
|
||||
public static int SOUND_VOLUME;
|
||||
/** @see ParticleOptions#PARTICLE_TYPE */
|
||||
public static Particle PARTICLE_TYPE;
|
||||
/** @see ParticleOptions#PARTICLE_DATA */
|
||||
// ── Particles / Sound ─────────────────────────────────────────────────────
|
||||
|
||||
public static ParticleShape PARTICLE_SHAPE;
|
||||
public static Sound SOUND_TYPE;
|
||||
public static int SOUND_PITCH;
|
||||
public static int SOUND_VOLUME;
|
||||
public static Particle PARTICLE_TYPE;
|
||||
public static ParticleData<?> PARTICLE_DATA;
|
||||
|
||||
/** @see GenerationOptions */
|
||||
// ── Generation ────────────────────────────────────────────────────────────
|
||||
|
||||
public static double TYPE_NORMAL, TYPE_SPECIAL, TYPE_SCHEMATICS;
|
||||
public static double SPECIAL_ICE, SPECIAL_SLAB, SPECIAL_PANE, SPECIAL_FENCE;
|
||||
public static double NORMAL_DISTANCE_1, NORMAL_DISTANCE_2, NORMAL_DISTANCE_3, NORMAL_DISTANCE_4;
|
||||
public static double NORMAL_HEIGHT_1, NORMAL_HEIGHT_0, NORMAL_HEIGHT_NEG1, NORMAL_HEIGHT_NEG2;
|
||||
public static int MAX_Y, MIN_Y;
|
||||
public static int BLOCK_CLEANUP_DISTANCE, CLEANUP_INTERVAL;
|
||||
/** Block indices to keep behind the rearmost player before trail air-cleanup. */
|
||||
public static int TRAIL_KEEP_BEHIND;
|
||||
public static boolean GHOST_MODE_ENABLED;
|
||||
public static int GHOST_SHOW_TOP;
|
||||
public static double GHOST_TRANSPARENCY;
|
||||
|
||||
// ── Jump validation ───────────────────────────────────────────────────────
|
||||
|
||||
public static boolean JUMP_VALIDATION_ENABLED;
|
||||
public static double MAX_JUMP_DISTANCE;
|
||||
public static double MAX_HORIZONTAL_DISTANCE;
|
||||
public static double MAX_VERTICAL_UP;
|
||||
public static double MAX_VERTICAL_DOWN;
|
||||
|
||||
// ── Jump types ────────────────────────────────────────────────────────────
|
||||
|
||||
public static boolean JUMP_TYPES_ENABLED;
|
||||
public static Map<String, Boolean> JUMP_TYPE_ENABLED;
|
||||
public static Map<String, Double> JUMP_TYPE_CHANCE;
|
||||
|
||||
// ── init ──────────────────────────────────────────────────────────────────
|
||||
|
||||
public static void init(boolean firstLoad) {
|
||||
SqlOptions.init();
|
||||
ParticleOptions.init();
|
||||
GenerationOptions.init();
|
||||
|
||||
syncForwardedFields();
|
||||
initSql();
|
||||
initParticles();
|
||||
initGeneration();
|
||||
initGeneral(firstLoad);
|
||||
initOptions();
|
||||
|
||||
|
|
@ -91,60 +90,139 @@ public class Option {
|
|||
.forEach(Registry::register);
|
||||
}
|
||||
|
||||
/** Copy sub-class fields to this class so existing code still compiles. */
|
||||
private static void syncForwardedFields() {
|
||||
SQL = SqlOptions.SQL;
|
||||
SQL_URL = SqlOptions.URL;
|
||||
SQL_PORT = SqlOptions.PORT;
|
||||
SQL_DB = SqlOptions.DB;
|
||||
SQL_USERNAME = SqlOptions.USERNAME;
|
||||
SQL_PASSWORD = SqlOptions.PASSWORD;
|
||||
SQL_PREFIX = SqlOptions.PREFIX;
|
||||
// ── SQL ───────────────────────────────────────────────────────────────────
|
||||
|
||||
PARTICLE_SHAPE = ParticleOptions.SHAPE;
|
||||
SOUND_TYPE = ParticleOptions.SOUND_TYPE;
|
||||
SOUND_PITCH = ParticleOptions.SOUND_PITCH;
|
||||
SOUND_VOLUME = ParticleOptions.SOUND_VOLUME;
|
||||
PARTICLE_TYPE = ParticleOptions.PARTICLE_TYPE;
|
||||
PARTICLE_DATA = ParticleOptions.PARTICLE_DATA;
|
||||
|
||||
TYPE_NORMAL = GenerationOptions.TYPE_NORMAL;
|
||||
TYPE_SPECIAL = GenerationOptions.TYPE_SPECIAL;
|
||||
TYPE_SCHEMATICS = GenerationOptions.TYPE_SCHEMATICS;
|
||||
|
||||
SPECIAL_ICE = GenerationOptions.SPECIAL_ICE;
|
||||
SPECIAL_SLAB = GenerationOptions.SPECIAL_SLAB;
|
||||
SPECIAL_PANE = GenerationOptions.SPECIAL_PANE;
|
||||
SPECIAL_FENCE = GenerationOptions.SPECIAL_FENCE;
|
||||
|
||||
NORMAL_DISTANCE_1 = GenerationOptions.NORMAL_DISTANCE_1;
|
||||
NORMAL_DISTANCE_2 = GenerationOptions.NORMAL_DISTANCE_2;
|
||||
NORMAL_DISTANCE_3 = GenerationOptions.NORMAL_DISTANCE_3;
|
||||
NORMAL_DISTANCE_4 = GenerationOptions.NORMAL_DISTANCE_4;
|
||||
|
||||
NORMAL_HEIGHT_1 = GenerationOptions.NORMAL_HEIGHT_1;
|
||||
NORMAL_HEIGHT_0 = GenerationOptions.NORMAL_HEIGHT_0;
|
||||
NORMAL_HEIGHT_NEG1 = GenerationOptions.NORMAL_HEIGHT_NEG1;
|
||||
NORMAL_HEIGHT_NEG2 = GenerationOptions.NORMAL_HEIGHT_NEG2;
|
||||
|
||||
MAX_Y = GenerationOptions.MAX_Y;
|
||||
MIN_Y = GenerationOptions.MIN_Y;
|
||||
|
||||
BLOCK_CLEANUP_DISTANCE = GenerationOptions.BLOCK_CLEANUP_DISTANCE;
|
||||
CLEANUP_INTERVAL = GenerationOptions.CLEANUP_INTERVAL;
|
||||
|
||||
GHOST_MODE_ENABLED = GenerationOptions.GHOST_MODE_ENABLED;
|
||||
GHOST_SHOW_TOP = GenerationOptions.GHOST_SHOW_TOP;
|
||||
GHOST_TRANSPARENCY = GenerationOptions.GHOST_TRANSPARENCY;
|
||||
private static void initSql() {
|
||||
SQL = Config.CONFIG.getBoolean("sql.enabled");
|
||||
SQL_PORT = Config.CONFIG.getInt("sql.port");
|
||||
SQL_DB = Config.CONFIG.getString("sql.database");
|
||||
SQL_URL = Config.CONFIG.getString("sql.url");
|
||||
SQL_USERNAME = Config.CONFIG.getString("sql.username");
|
||||
String envPassword = System.getenv("LOPARKOUR_SQL_PASSWORD");
|
||||
SQL_PASSWORD = (envPassword != null && !envPassword.isEmpty())
|
||||
? envPassword
|
||||
: Config.CONFIG.getString("sql.password");
|
||||
SQL_PREFIX = Config.CONFIG.getString("sql.prefix");
|
||||
}
|
||||
|
||||
// ── General init ──────────────────────────────────────────────────────────
|
||||
// ── Particles / Sound ─────────────────────────────────────────────────────
|
||||
|
||||
private static void initParticles() {
|
||||
SOUND_TYPE = resolveSound(Config.CONFIG.getString("particles.sound-type"));
|
||||
PARTICLE_TYPE = resolveParticle(Config.CONFIG.getString("particles.particle-type"));
|
||||
PARTICLE_SHAPE = resolveShape(Config.CONFIG.getString("particles.particle-shape"));
|
||||
|
||||
SOUND_PITCH = Config.CONFIG.getInt("particles.sound-pitch");
|
||||
SOUND_VOLUME = Config.CONFIG.getInt("particles.sound-volume");
|
||||
PARTICLE_DATA = new ParticleData<>(PARTICLE_TYPE, null, 10);
|
||||
}
|
||||
|
||||
private static Sound resolveSound(String name) {
|
||||
if (name != null && !name.isEmpty()) {
|
||||
try { return Sound.valueOf(name.toUpperCase()); }
|
||||
catch (IllegalArgumentException ignored) {}
|
||||
}
|
||||
for (String fb : new String[]{"BLOCK_NOTE_BLOCK_GUITAR", "BLOCK_NOTE_BLOCK_PLING", "BLOCK_NOTE_PLING"}) {
|
||||
try { return Sound.valueOf(fb); }
|
||||
catch (IllegalArgumentException ignored) {}
|
||||
}
|
||||
LoParkour.getPlugin().getLogger().warning("Could not resolve any sound, using first available.");
|
||||
return Sound.values()[0];
|
||||
}
|
||||
|
||||
private static Particle resolveParticle(String name) {
|
||||
if (name != null && !name.isEmpty()) {
|
||||
try { return Particle.valueOf(name.toUpperCase()); }
|
||||
catch (IllegalArgumentException ignored) {}
|
||||
}
|
||||
for (String fb : new String[]{"INSTANT_EFFECT", "SPELL_INSTANT", "CRIT"}) {
|
||||
try { return Particle.valueOf(fb); }
|
||||
catch (IllegalArgumentException ignored) {}
|
||||
}
|
||||
LoParkour.getPlugin().getLogger().warning("Could not resolve any particle, using first available.");
|
||||
return Particle.values()[0];
|
||||
}
|
||||
|
||||
private static ParticleShape resolveShape(String name) {
|
||||
if (name != null && !name.isEmpty()) {
|
||||
try { return ParticleShape.valueOf(name.toUpperCase()); }
|
||||
catch (IllegalArgumentException ignored) {}
|
||||
}
|
||||
return ParticleShape.BOX;
|
||||
}
|
||||
|
||||
// ── Generation ────────────────────────────────────────────────────────────
|
||||
|
||||
private static void initGeneration() {
|
||||
JUMP_VALIDATION_ENABLED = Config.CONFIG.isPath("jump-validation.enabled") && Config.CONFIG.getBoolean("jump-validation.enabled");
|
||||
MAX_JUMP_DISTANCE = Config.CONFIG.isPath("jump-validation.max-distance") ? Config.CONFIG.getDouble("jump-validation.max-distance") : 5.0;
|
||||
MAX_HORIZONTAL_DISTANCE = Config.CONFIG.isPath("jump-validation.max-horizontal") ? Config.CONFIG.getDouble("jump-validation.max-horizontal") : 4.0;
|
||||
MAX_VERTICAL_UP = Config.CONFIG.isPath("jump-validation.max-vertical-up") ? Config.CONFIG.getDouble("jump-validation.max-vertical-up") : 1.0;
|
||||
MAX_VERTICAL_DOWN = Config.CONFIG.isPath("jump-validation.max-vertical-down") ? Config.CONFIG.getDouble("jump-validation.max-vertical-down") : 3.0;
|
||||
|
||||
JUMP_TYPES_ENABLED = Config.CONFIG.isPath("jump-types.enabled") && Config.CONFIG.getBoolean("jump-types.enabled");
|
||||
JUMP_TYPE_ENABLED = new HashMap<>();
|
||||
JUMP_TYPE_CHANCE = new HashMap<>();
|
||||
if (Config.CONFIG.isPath("jump-types.types")) {
|
||||
for (String type : Config.CONFIG.getChildren("jump-types.types")) {
|
||||
String p = "jump-types.types." + type;
|
||||
JUMP_TYPE_ENABLED.put(type, Config.CONFIG.isPath(p + ".enabled") && Config.CONFIG.getBoolean(p + ".enabled"));
|
||||
JUMP_TYPE_CHANCE.put(type, Config.CONFIG.isPath(p + ".chance") ? Config.CONFIG.getDouble(p + ".chance") : 0.0);
|
||||
}
|
||||
}
|
||||
|
||||
BLOCK_CLEANUP_DISTANCE = Config.CONFIG.isPath("memory.block-cleanup-distance") ? Config.CONFIG.getInt("memory.block-cleanup-distance") : 100;
|
||||
CLEANUP_INTERVAL = Config.CONFIG.isPath("memory.cleanup-interval") ? Config.CONFIG.getInt("memory.cleanup-interval") : 100;
|
||||
TRAIL_KEEP_BEHIND = Config.CONFIG.isPath("memory.trail-keep-behind") ? Config.CONFIG.getInt("memory.trail-keep-behind") : 10;
|
||||
|
||||
GHOST_MODE_ENABLED = Config.CONFIG.isPath("ghost-mode.enabled") && Config.CONFIG.getBoolean("ghost-mode.enabled");
|
||||
GHOST_SHOW_TOP = Config.CONFIG.isPath("ghost-mode.show-top") ? Config.CONFIG.getInt("ghost-mode.show-top") : 3;
|
||||
GHOST_TRANSPARENCY = Config.CONFIG.isPath("ghost-mode.transparency") ? Config.CONFIG.getDouble("ghost-mode.transparency") : 0.5;
|
||||
|
||||
TYPE_NORMAL = Config.GENERATION.getInt("generation.type.normal") / 100.0;
|
||||
TYPE_SPECIAL = Config.GENERATION.getInt("generation.type.special") / 100.0;
|
||||
TYPE_SCHEMATICS = Config.GENERATION.getInt("generation.type.schematic") / 100.0;
|
||||
|
||||
SPECIAL_ICE = Config.GENERATION.getInt("generation.special.ice") / 100.0;
|
||||
SPECIAL_SLAB = Config.GENERATION.getInt("generation.special.slab") / 100.0;
|
||||
SPECIAL_PANE = Config.GENERATION.getInt("generation.special.pane") / 100.0;
|
||||
SPECIAL_FENCE = Config.GENERATION.getInt("generation.special.fence") / 100.0;
|
||||
|
||||
NORMAL_DISTANCE_1 = Config.GENERATION.getInt("generation.normal.distance.1") / 100.0;
|
||||
NORMAL_DISTANCE_2 = Config.GENERATION.getInt("generation.normal.distance.2") / 100.0;
|
||||
NORMAL_DISTANCE_3 = Config.GENERATION.getInt("generation.normal.distance.3") / 100.0;
|
||||
NORMAL_DISTANCE_4 = Config.GENERATION.getInt("generation.normal.distance.4") / 100.0;
|
||||
|
||||
NORMAL_HEIGHT_1 = Config.GENERATION.getInt("generation.normal.height.1") / 100.0;
|
||||
NORMAL_HEIGHT_0 = Config.GENERATION.getInt("generation.normal.height.0") / 100.0;
|
||||
NORMAL_HEIGHT_NEG1 = Config.GENERATION.getInt("generation.normal.height.-1") / 100.0;
|
||||
NORMAL_HEIGHT_NEG2 = Config.GENERATION.getInt("generation.normal.height.-2") / 100.0;
|
||||
|
||||
MAX_Y = Config.GENERATION.getInt("generation.settings.max-y");
|
||||
MIN_Y = Config.GENERATION.getInt("generation.settings.min-y");
|
||||
|
||||
if (MIN_Y >= MAX_Y) {
|
||||
MIN_Y = 100; MAX_Y = 200;
|
||||
LoParkour.getPlugin().getLogger().severe("min-y >= max-y in generation.yml — using defaults 100/200.");
|
||||
}
|
||||
}
|
||||
|
||||
// ── General ───────────────────────────────────────────────────────────────
|
||||
|
||||
private static void initGeneral(boolean firstLoad) {
|
||||
GO_BACK_LOC = parseLocation(Config.CONFIG.getString("bungeecord.go-back"));
|
||||
String[] axes = Config.CONFIG.getString("bungeecord.go-back-axes").split(",");
|
||||
GO_BACK_LOC.setPitch(Float.parseFloat(axes[0]));
|
||||
GO_BACK_LOC.setYaw(Float.parseFloat(axes[1]));
|
||||
try {
|
||||
String axesRaw = Config.CONFIG.getString("bungeecord.go-back-axes");
|
||||
if (!axesRaw.isEmpty()) {
|
||||
String[] axes = axesRaw.split(",");
|
||||
if (axes.length >= 2) {
|
||||
GO_BACK_LOC.setPitch(Float.parseFloat(axes[0].trim()));
|
||||
GO_BACK_LOC.setYaw(Float.parseFloat(axes[1].trim()));
|
||||
}
|
||||
}
|
||||
} catch (Exception ex) {
|
||||
LoParkour.getPlugin().getLogger().warning("Invalid bungeecord.go-back-axes, using 0,0");
|
||||
}
|
||||
|
||||
POSSIBLE_LEADS = Config.CONFIG.getIntList("options.leads.amount");
|
||||
for (int lead : new ArrayList<>(POSSIBLE_LEADS)) {
|
||||
|
|
@ -193,20 +271,31 @@ public class Option {
|
|||
// ── Helpers ───────────────────────────────────────────────────────────────
|
||||
|
||||
private static Location parseLocation(String location) {
|
||||
String[] values = location.replaceAll("[()]", "").replaceAll("[, ]", " ").split(" ");
|
||||
World world = Bukkit.getWorld(values[3]);
|
||||
if (world == null) world = Bukkit.getWorlds().get(0);
|
||||
return new Location(world, Double.parseDouble(values[0]),
|
||||
Double.parseDouble(values[1]), Double.parseDouble(values[2]));
|
||||
try {
|
||||
String[] values = location.replaceAll("[()]", "").replaceAll("[, ]", " ").trim().split("\\s+");
|
||||
if (values.length < 4) {
|
||||
throw new IllegalArgumentException("Expected 4 values (x,y,z,world), got " + values.length);
|
||||
}
|
||||
World world = Bukkit.getWorld(values[3]);
|
||||
if (world == null) world = Bukkit.getWorlds().get(0);
|
||||
return new Location(world, Double.parseDouble(values[0]),
|
||||
Double.parseDouble(values[1]), Double.parseDouble(values[2]));
|
||||
} catch (Exception ex) {
|
||||
LoParkour.getPlugin().getLogger().warning(
|
||||
"Invalid bungeecord.go-back value '" + location + "'. Falling back to spawn world origin.");
|
||||
World fallback = Bukkit.getWorlds().isEmpty() ? null : Bukkit.getWorlds().get(0);
|
||||
return new Location(fallback, 0, 100, 0);
|
||||
}
|
||||
}
|
||||
|
||||
public static Set<Style> initStyles(String path, FileConfiguration config, BiFunction<String, List<Material>, Style> fn) {
|
||||
var styles = new HashSet<Style>();
|
||||
for (String style : Locales.getChildren(config, path, false)) {
|
||||
ConfigAccessor accessor = new ConfigAccessor(config, "config.yml");
|
||||
for (String style : accessor.getChildren(path, false)) {
|
||||
styles.add(fn.apply(style,
|
||||
config.getStringList("%s.%s".formatted(path, style)).stream()
|
||||
.map(name -> {
|
||||
Material m = Material.getMaterial(name.toUpperCase());
|
||||
Material m = Materials.parse(name);
|
||||
if (m == null) {
|
||||
LoParkour.getPlugin().getLogger().severe("Invalid material %s in style %s".formatted(name, style));
|
||||
return Material.STONE;
|
||||
|
|
|
|||
|
|
@ -1,44 +0,0 @@
|
|||
package dev.loki.loparkour.config;
|
||||
|
||||
import dev.loki.loparkour.LoParkour;
|
||||
import dev.loki.loparkour.util.ParticleData;
|
||||
import org.bukkit.Particle;
|
||||
import org.bukkit.Sound;
|
||||
|
||||
/**
|
||||
* Particle and sound settings. Populated by {@link Option#init}.
|
||||
*/
|
||||
public final class ParticleOptions {
|
||||
|
||||
public static Option.ParticleShape SHAPE;
|
||||
public static Particle PARTICLE_TYPE;
|
||||
public static ParticleData<?> PARTICLE_DATA;
|
||||
public static Sound SOUND_TYPE;
|
||||
public static int SOUND_PITCH;
|
||||
public static int SOUND_VOLUME;
|
||||
|
||||
static void init() {
|
||||
String sound = Config.CONFIG.getString("particles.sound-type").toUpperCase();
|
||||
try {
|
||||
SOUND_TYPE = Sound.valueOf(sound);
|
||||
} catch (IllegalArgumentException ex) {
|
||||
SOUND_TYPE = Sound.valueOf("BLOCK_NOTE_PLING");
|
||||
LoParkour.getPlugin().getLogger().severe("Invalid sound: " + sound);
|
||||
}
|
||||
|
||||
String particle = Config.CONFIG.getString("particles.particle-type");
|
||||
try {
|
||||
PARTICLE_TYPE = Particle.valueOf(particle);
|
||||
} catch (IllegalArgumentException ex) {
|
||||
PARTICLE_TYPE = Particle.valueOf("SPELL_INSTANT");
|
||||
LoParkour.getPlugin().getLogger().severe("Invalid particle: " + particle);
|
||||
}
|
||||
|
||||
SOUND_PITCH = Config.CONFIG.getInt("particles.sound-pitch");
|
||||
SOUND_VOLUME = Config.CONFIG.getInt("particles.sound-volume");
|
||||
SHAPE = Option.ParticleShape.valueOf(Config.CONFIG.getString("particles.particle-shape").toUpperCase());
|
||||
PARTICLE_DATA = new ParticleData<>(PARTICLE_TYPE, null, 10);
|
||||
}
|
||||
|
||||
private ParticleOptions() {}
|
||||
}
|
||||
|
|
@ -1,27 +0,0 @@
|
|||
package dev.loki.loparkour.config;
|
||||
|
||||
/**
|
||||
* SQL connection settings. Populated by {@link Option#init}.
|
||||
*/
|
||||
public final class SqlOptions {
|
||||
|
||||
public static boolean SQL;
|
||||
public static int PORT;
|
||||
public static String URL;
|
||||
public static String DB;
|
||||
public static String USERNAME;
|
||||
public static String PASSWORD;
|
||||
public static String PREFIX;
|
||||
|
||||
static void init() {
|
||||
SQL = Config.CONFIG.getBoolean("sql.enabled");
|
||||
PORT = Config.CONFIG.getInt("sql.port");
|
||||
DB = Config.CONFIG.getString("sql.database");
|
||||
URL = Config.CONFIG.getString("sql.url");
|
||||
USERNAME = Config.CONFIG.getString("sql.username");
|
||||
PASSWORD = Config.CONFIG.getString("sql.password");
|
||||
PREFIX = Config.CONFIG.getString("sql.prefix");
|
||||
}
|
||||
|
||||
private SqlOptions() {}
|
||||
}
|
||||
|
|
@ -0,0 +1,143 @@
|
|||
package dev.loki.loparkour.generator;
|
||||
|
||||
import dev.loki.loparkour.config.Config;
|
||||
import org.bukkit.block.data.BlockData;
|
||||
import org.jetbrains.annotations.NotNull;
|
||||
|
||||
import java.util.Map;
|
||||
|
||||
/**
|
||||
* Manages generator profiles and difficulty calculations.
|
||||
*/
|
||||
public class GeneratorProfileManager {
|
||||
|
||||
private final ParkourGenerator generator;
|
||||
|
||||
public GeneratorProfileManager(@NotNull ParkourGenerator generator) {
|
||||
this.generator = generator;
|
||||
}
|
||||
|
||||
/**
|
||||
* Override profile settings based on generator options.
|
||||
*/
|
||||
public void overrideProfile() {
|
||||
// Generator options are for disabling features, not difficulty
|
||||
// Difficulty is controlled by profile settings
|
||||
// This method can be used to apply custom profile overrides if needed
|
||||
}
|
||||
|
||||
/**
|
||||
* Calculate generation chances based on current profile.
|
||||
*/
|
||||
public void calculateChances() {
|
||||
calculateDistanceChances();
|
||||
calculateHeightChances();
|
||||
calculateDefaultChances();
|
||||
calculateSpecialChances();
|
||||
}
|
||||
|
||||
/**
|
||||
* Calculate difficulty score based on current settings.
|
||||
*/
|
||||
public double getDifficultyScore() {
|
||||
double score = 0.0;
|
||||
|
||||
// Distance difficulty
|
||||
for (Map.Entry<Integer, Double> entry : generator.state.distanceChances.entrySet()) {
|
||||
score += entry.getKey() * entry.getValue() * 0.3;
|
||||
}
|
||||
|
||||
// Height difficulty
|
||||
for (Map.Entry<Integer, Double> entry : generator.state.heightChances.entrySet()) {
|
||||
score += Math.abs(entry.getKey()) * entry.getValue() * 0.2;
|
||||
}
|
||||
|
||||
// Special blocks difficulty
|
||||
for (Map.Entry<BlockData, Double> entry : generator.state.specialChances.entrySet()) {
|
||||
score += entry.getValue() * 0.5;
|
||||
}
|
||||
|
||||
return Math.round(score * 100.0) / 100.0;
|
||||
}
|
||||
|
||||
private void calculateDistanceChances() {
|
||||
generator.state.distanceChances.clear();
|
||||
|
||||
// Get distance range from config
|
||||
int minDistance = Config.GENERATION.getInt("advanced.distance.min", 1);
|
||||
int maxDistance = Config.GENERATION.getInt("advanced.distance.max", 4);
|
||||
|
||||
for (int distance = minDistance; distance <= maxDistance; distance++) {
|
||||
int chance = Config.GENERATION.getInt("advanced.distance.chances." + distance, 1);
|
||||
generator.state.distanceChances.put(distance, (double) chance);
|
||||
}
|
||||
|
||||
// Normalize chances to sum to 1.0
|
||||
normalizeMap(generator.state.distanceChances);
|
||||
}
|
||||
|
||||
private void calculateHeightChances() {
|
||||
generator.state.heightChances.clear();
|
||||
|
||||
// Get height range from config
|
||||
int minHeight = Config.GENERATION.getInt("advanced.height.min", -1);
|
||||
int maxHeight = Config.GENERATION.getInt("advanced.height.max", 2);
|
||||
|
||||
for (int height = minHeight; height <= maxHeight; height++) {
|
||||
int chance = Config.GENERATION.getInt("advanced.height.chances." + height, 1);
|
||||
generator.state.heightChances.put(height, (double) chance);
|
||||
}
|
||||
|
||||
// Normalize chances to sum to 1.0
|
||||
normalizeMap(generator.state.heightChances);
|
||||
}
|
||||
|
||||
private void calculateDefaultChances() {
|
||||
generator.state.defaultChances.clear();
|
||||
|
||||
int defaultChance = Config.GENERATION.getInt("advanced.type.default", 80);
|
||||
int specialChance = Config.GENERATION.getInt("advanced.type.special", 15);
|
||||
int schematicChance = Config.GENERATION.getInt("advanced.type.schematic", 5);
|
||||
|
||||
generator.state.defaultChances.put(ParkourGenerator.BlockGenerationType.DEFAULT, (double) defaultChance);
|
||||
generator.state.defaultChances.put(ParkourGenerator.BlockGenerationType.SPECIAL, (double) specialChance);
|
||||
generator.state.defaultChances.put(ParkourGenerator.BlockGenerationType.SCHEMATIC, (double) schematicChance);
|
||||
|
||||
// Normalize chances to sum to 1.0
|
||||
normalizeMap(generator.state.defaultChances);
|
||||
}
|
||||
|
||||
private void calculateSpecialChances() {
|
||||
generator.state.specialChances.clear();
|
||||
|
||||
try {
|
||||
for (String specialType : Config.GENERATION.getChildren("advanced.special")) {
|
||||
String materialPath = "advanced.special." + specialType + ".material";
|
||||
String chancePath = "advanced.special." + specialType + ".chance";
|
||||
|
||||
String materialName = Config.GENERATION.getString(materialPath);
|
||||
int chance = Config.GENERATION.getInt(chancePath, 1);
|
||||
|
||||
BlockData blockData = org.bukkit.Bukkit.createBlockData(materialName);
|
||||
generator.state.specialChances.put(blockData, (double) chance);
|
||||
}
|
||||
|
||||
// Normalize chances to sum to 1.0
|
||||
normalizeMap(generator.state.specialChances);
|
||||
} catch (Exception e) {
|
||||
// If special blocks config is missing, just skip
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Normalize a map so all values sum to 1.0
|
||||
*/
|
||||
private <K> void normalizeMap(Map<K, Double> map) {
|
||||
if (map.isEmpty()) return;
|
||||
|
||||
double sum = map.values().stream().mapToDouble(Double::doubleValue).sum();
|
||||
if (sum > 0) {
|
||||
map.replaceAll((k, v) -> v / sum);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -25,12 +25,13 @@ public class GeneratorState {
|
|||
public Location playerSpawn;
|
||||
public Location lastStandingPlayerLocation;
|
||||
public Vector heading;
|
||||
public Location[] zone; // [min, max] bounding box for generation area
|
||||
|
||||
// Time state
|
||||
public Instant start;
|
||||
|
||||
// Block history
|
||||
public List<Block> history = new LinkedList<>();
|
||||
// Block history (sync list: all mutations occur on the main thread; avoids accidental CME if extended later)
|
||||
public final List<Block> history = Collections.synchronizedList(new LinkedList<>());
|
||||
public int lastPositionIndexPlayer = -1;
|
||||
|
||||
// Schematic state
|
||||
|
|
|
|||
|
|
@ -0,0 +1,97 @@
|
|||
package dev.loki.loparkour.generator;
|
||||
|
||||
import dev.loki.loparkour.config.Locales;
|
||||
import dev.loki.loparkour.leaderboard.Leaderboard;
|
||||
import dev.loki.loparkour.leaderboard.Score;
|
||||
import dev.loki.loparkour.mode.Mode;
|
||||
import dev.loki.loparkour.player.ParkourPlayer;
|
||||
import org.jetbrains.annotations.NotNull;
|
||||
|
||||
import java.text.SimpleDateFormat;
|
||||
import java.util.Date;
|
||||
|
||||
/**
|
||||
* Manages generator statistics and time formatting.
|
||||
*/
|
||||
public class GeneratorStatistics {
|
||||
|
||||
private final ParkourGenerator generator;
|
||||
|
||||
public GeneratorStatistics(@NotNull ParkourGenerator generator) {
|
||||
this.generator = generator;
|
||||
}
|
||||
|
||||
/**
|
||||
* Get formatted time string.
|
||||
*/
|
||||
@NotNull
|
||||
public String getFormattedTime() {
|
||||
return getTime("mm:ss.SSS");
|
||||
}
|
||||
|
||||
/**
|
||||
* Get detailed time string.
|
||||
*/
|
||||
@NotNull
|
||||
public String getDetailedTime() {
|
||||
return getTime("HH:mm:ss.SSS");
|
||||
}
|
||||
|
||||
/**
|
||||
* Register score in leaderboard.
|
||||
*/
|
||||
public void registerScore(@NotNull String time, @NotNull String difficulty, int score) {
|
||||
Mode mode = generator.getMode();
|
||||
Leaderboard leaderboard = mode.getLeaderboard();
|
||||
|
||||
for (ParkourPlayer player : generator.getPlayers()) {
|
||||
Score playerScore = new Score(
|
||||
player.player.getName(),
|
||||
time,
|
||||
difficulty,
|
||||
score
|
||||
);
|
||||
|
||||
leaderboard.put(player.getUUID(), playerScore);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Send fall message to players.
|
||||
*/
|
||||
public void sendFallMessage(int record) {
|
||||
for (ParkourPlayer player : generator.getPlayers()) {
|
||||
String message;
|
||||
|
||||
if (generator.state.score > record) {
|
||||
// New record
|
||||
message = Locales.getString(player.player, "parkour.fall.new-record")
|
||||
.replace("%score%", String.valueOf(generator.state.score))
|
||||
.replace("%time%", getFormattedTime());
|
||||
} else {
|
||||
// Regular fall
|
||||
message = Locales.getString(player.player, "parkour.fall.regular")
|
||||
.replace("%score%", String.valueOf(generator.state.score))
|
||||
.replace("%record%", String.valueOf(record))
|
||||
.replace("%time%", getFormattedTime());
|
||||
}
|
||||
|
||||
player.player.sendMessage(message);
|
||||
}
|
||||
}
|
||||
|
||||
@NotNull
|
||||
private String getTime(@NotNull String format) {
|
||||
if (generator.state.start == null) {
|
||||
return "00:00.000";
|
||||
}
|
||||
|
||||
long elapsed = java.time.Duration.between(generator.state.start, java.time.Instant.now()).toMillis();
|
||||
Date date = new Date(elapsed);
|
||||
|
||||
SimpleDateFormat formatter = new SimpleDateFormat(format);
|
||||
formatter.setTimeZone(java.util.TimeZone.getTimeZone("UTC"));
|
||||
|
||||
return formatter.format(date);
|
||||
}
|
||||
}
|
||||
|
|
@ -1,40 +1,36 @@
|
|||
package dev.loki.loparkour.generator;
|
||||
|
||||
import java.util.ArrayList;
|
||||
|
||||
import dev.loki.loparkour.LoParkour;
|
||||
import dev.loki.loparkour.config.Config;
|
||||
import dev.loki.loparkour.config.Option;
|
||||
import dev.loki.loparkour.session.Session;
|
||||
import dev.loki.loparkour.util.Materials;
|
||||
|
||||
import org.bukkit.Location;
|
||||
import org.bukkit.Material;
|
||||
import org.bukkit.World;
|
||||
import org.bukkit.block.Block;
|
||||
import org.bukkit.block.BlockFace;
|
||||
import org.bukkit.util.Vector;
|
||||
import org.jetbrains.annotations.NotNull;
|
||||
import org.jetbrains.annotations.Nullable;
|
||||
|
||||
import java.util.ArrayList;
|
||||
import java.util.List;
|
||||
import java.util.NoSuchElementException;
|
||||
|
||||
/**
|
||||
* Spawn island handler.
|
||||
*
|
||||
* @author loki
|
||||
* @since 5.0.0
|
||||
* Spawn area: flat platform at the session cell (no island schematic).
|
||||
*/
|
||||
public final class Island {
|
||||
|
||||
/**
|
||||
* The session.
|
||||
*/
|
||||
/** Half-size: platform is (2*R+1) x (2*R+1) blocks. */
|
||||
private static final int PLATFORM_RADIUS = 4;
|
||||
|
||||
public final Session session;
|
||||
/**
|
||||
* The schematic.
|
||||
* Unused for spawn; kept for API / {@link dev.loki.loparkour.generator.jump.BlockPlacer} schematic branch.
|
||||
*/
|
||||
public final dev.loki.loparkour.schematic.lpschem.LPSchematic schematic;
|
||||
|
||||
/**
|
||||
* The blocks that have been affected by the schematic.
|
||||
*/
|
||||
public List<Block> blocks;
|
||||
|
||||
public Island(@NotNull Session session, @Nullable dev.loki.loparkour.schematic.lpschem.LPSchematic schematic) {
|
||||
|
|
@ -42,62 +38,64 @@ public final class Island {
|
|||
this.schematic = schematic;
|
||||
}
|
||||
|
||||
/**
|
||||
* Builds the island and teleports the player.
|
||||
*/
|
||||
public void build(Location location) {
|
||||
if (schematic == null) {
|
||||
LoParkour.getPlugin().getLogger().warning("Cannot build island - schematic is null!");
|
||||
World world = location.getWorld();
|
||||
if (world == null) {
|
||||
return;
|
||||
}
|
||||
|
||||
blocks = schematic.paste(location, location.getWorld());
|
||||
int cx = location.getBlockX();
|
||||
int cy = location.getBlockY();
|
||||
int cz = location.getBlockZ();
|
||||
|
||||
Material playerMaterial = Material.getMaterial(Config.GENERATION.getString("advanced.island.spawn.player-block").toUpperCase());
|
||||
Material parkourMaterial = Material.getMaterial(Config.GENERATION.getString("advanced.island.parkour.begin-block").toUpperCase());
|
||||
|
||||
// Fallback for first-block-material if not present in config
|
||||
String firstBlockStr = "stone";
|
||||
try {
|
||||
firstBlockStr = Config.GENERATION.getString("advanced.island.parkour.first-block-material");
|
||||
} catch (NoSuchElementException ex) {
|
||||
LoParkour.getPlugin().getLogger().warning("Config key 'advanced.island.parkour.first-block-material' not found, using 'stone' as default");
|
||||
BlockFace face = Option.HEADING;
|
||||
int hx = face.getModX();
|
||||
int hz = face.getModZ();
|
||||
|
||||
if (session.generator.state.heading == null) {
|
||||
session.generator.state.heading = face.getDirection();
|
||||
}
|
||||
Material firstBlockMaterial = Material.getMaterial(firstBlockStr.toUpperCase());
|
||||
Vector h = session.generator.state.heading;
|
||||
|
||||
try {
|
||||
Block player = blocks.stream().filter(block -> block.getType() == playerMaterial).findAny().orElseThrow();
|
||||
Block parkour = blocks.stream().filter(block -> block.getType() == parkourMaterial).findAny().orElseThrow();
|
||||
String firstBlockStr = Config.GENERATION.getString("advanced.island.parkour.first-block-material");
|
||||
if (firstBlockStr.isBlank()) {
|
||||
firstBlockStr = "stone";
|
||||
}
|
||||
Material platformMat = Materials.parseOr(firstBlockStr, Material.STONE);
|
||||
|
||||
player.setType(firstBlockMaterial);
|
||||
parkour.setType(firstBlockMaterial);
|
||||
List<Block> placed = new ArrayList<>();
|
||||
for (int dx = -PLATFORM_RADIUS; dx <= PLATFORM_RADIUS; dx++) {
|
||||
for (int dz = -PLATFORM_RADIUS; dz <= PLATFORM_RADIUS; dz++) {
|
||||
Block b = world.getBlockAt(cx + dx, cy, cz + dz);
|
||||
b.setType(platformMat);
|
||||
placed.add(b);
|
||||
}
|
||||
}
|
||||
|
||||
Location ps = player.getLocation().add(0.5, 1.0, 0.5);
|
||||
ps.setYaw(Config.GENERATION.getInt("advanced.island.spawn.yaw"));
|
||||
ps.setPitch(Config.GENERATION.getInt("advanced.island.spawn.pitch"));
|
||||
// Player stand 2 blocks "behind" center along heading; course line continues +4 then first jump +4 more (old island logic)
|
||||
int px = cx - 2 * hx;
|
||||
int pz = cz - 2 * hz;
|
||||
int qx = px + 4 * hx;
|
||||
int qz = pz + 4 * hz;
|
||||
|
||||
// First parkour block position - fixed offset from parkour marker
|
||||
// Always 4 blocks in the direction of heading from center
|
||||
Location parkourStart = parkour.getLocation().clone();
|
||||
parkourStart.add(
|
||||
session.generator.state.heading.getX() * 4,
|
||||
Block playerFoot = world.getBlockAt(px, cy, pz);
|
||||
|
||||
Location ps = playerFoot.getLocation().add(0.5, 1.0, 0.5);
|
||||
ps.setYaw((float) Config.GENERATION.getInt("advanced.island.spawn.yaw"));
|
||||
ps.setPitch((float) Config.GENERATION.getInt("advanced.island.spawn.pitch"));
|
||||
|
||||
Location parkourStart = world.getBlockAt(qx, cy, qz).getLocation().add(
|
||||
h.getX() * 4,
|
||||
0,
|
||||
session.generator.state.heading.getZ() * 4
|
||||
);
|
||||
|
||||
session.generator.generateFirst(ps, parkourStart);
|
||||
session.generator.startTick();
|
||||
session.getPlayers().forEach(pp -> pp.setup(ps));
|
||||
} catch (NoSuchElementException ex) {
|
||||
LoParkour.getPlugin().getLogger().severe("Error while trying to find parkour or player spawn in schematic %s - check if you used the same material as the one in generation.yml - ".formatted("schematic") + ex.getMessage());
|
||||
h.getZ() * 4);
|
||||
|
||||
blocks.forEach(block -> block.setType(Material.AIR));
|
||||
}
|
||||
session.generator.generateFirst(ps, parkourStart);
|
||||
session.generator.startTick();
|
||||
session.getPlayers().forEach(pp -> pp.setup(ps));
|
||||
|
||||
this.blocks = placed;
|
||||
}
|
||||
|
||||
/**
|
||||
* Destroys the island.
|
||||
*/
|
||||
public void destroy() {
|
||||
if (blocks == null) {
|
||||
return;
|
||||
|
|
|
|||
|
|
@ -1,13 +1,7 @@
|
|||
package dev.loki.loparkour.generator;
|
||||
|
||||
import dev.loki.loparkour.LoParkour;
|
||||
import dev.loki.loparkour.config.Config;
|
||||
import dev.loki.loparkour.config.Option;
|
||||
import dev.loki.loparkour.generator.effect.EffectManager;
|
||||
import dev.loki.loparkour.generator.jump.BlockPlacer;
|
||||
import dev.loki.loparkour.generator.lifecycle.GeneratorLifecycle;
|
||||
import dev.loki.loparkour.leaderboard.Leaderboard;
|
||||
import dev.loki.loparkour.leaderboard.Score;
|
||||
import dev.loki.loparkour.menu.Menus;
|
||||
import dev.loki.loparkour.mode.Mode;
|
||||
import dev.loki.loparkour.mode.Modes;
|
||||
|
|
@ -15,219 +9,121 @@ import dev.loki.loparkour.player.ParkourPlayer;
|
|||
import dev.loki.loparkour.player.ParkourSpectator;
|
||||
import dev.loki.loparkour.schematic.lpschem.LPSchematic;
|
||||
import dev.loki.loparkour.session.Session;
|
||||
import dev.loki.loparkour.world.Divider;
|
||||
import org.bukkit.Location;
|
||||
import org.bukkit.Material;
|
||||
import org.bukkit.block.Block;
|
||||
import org.jetbrains.annotations.NotNull;
|
||||
import org.jetbrains.annotations.Nullable;
|
||||
|
||||
import java.time.Instant;
|
||||
import java.time.ZoneOffset;
|
||||
import java.time.format.DateTimeFormatter;
|
||||
import java.util.*;
|
||||
import java.util.List;
|
||||
|
||||
/**
|
||||
* Refactored ParkourGenerator using composition pattern.
|
||||
* Coordinates profile management, statistics, and core generation.
|
||||
*/
|
||||
public class ParkourGenerator {
|
||||
|
||||
public static final int BLOCK_TRAIL = 2;
|
||||
|
||||
public final GeneratorState state = new GeneratorState();
|
||||
public final GeneratorLifecycle lifecycle;
|
||||
public final EffectManager effects;
|
||||
public final BlockPlacer placer;
|
||||
|
||||
|
||||
public final Session session;
|
||||
public final ParkourPlayer player;
|
||||
public final Profile profile = new Profile();
|
||||
public final Island island;
|
||||
public final List<GeneratorOption> generatorOptions;
|
||||
|
||||
public Location[] zone;
|
||||
public dev.lolib.scheduler.ScheduledTask task;
|
||||
|
||||
public ParkourGenerator(@NotNull Session session, @Nullable LPSchematic schematic,
|
||||
GeneratorOption... generatorOptions) {
|
||||
public final Profile profile;
|
||||
public final GeneratorState state;
|
||||
public final GeneratorOption[] generatorOptions;
|
||||
|
||||
protected final BlockPlacer placer;
|
||||
protected final GeneratorLifecycle lifecycle;
|
||||
public final GeneratorProfileManager profileManager;
|
||||
private final GeneratorStatistics statistics;
|
||||
|
||||
public ParkourGenerator(@NotNull Session session, @Nullable LPSchematic schematic, GeneratorOption... generatorOptions) {
|
||||
this.session = session;
|
||||
this.generatorOptions = Arrays.asList(generatorOptions);
|
||||
this.player = session.getPlayers().get(0);
|
||||
this.generatorOptions = generatorOptions;
|
||||
this.island = new Island(session, schematic);
|
||||
this.zone = Divider.toSelection(session);
|
||||
this.state.schematicCooldown = Config.GENERATION.getInt("advanced.schematic-cooldown");
|
||||
this.state.heading = Option.HEADING.getDirection();
|
||||
this.profile = new Profile();
|
||||
this.state = new GeneratorState();
|
||||
|
||||
this.effects = new EffectManager(this);
|
||||
// Initialize zone from session
|
||||
if (session != null) {
|
||||
Location[] selection = dev.loki.loparkour.world.Divider.toSelection(session);
|
||||
this.state.zone = selection;
|
||||
}
|
||||
|
||||
// Initialize components
|
||||
this.placer = new BlockPlacer(this);
|
||||
this.lifecycle = new GeneratorLifecycle(this);
|
||||
this.profileManager = new GeneratorProfileManager(this);
|
||||
this.statistics = new GeneratorStatistics(this);
|
||||
|
||||
calculateChances();
|
||||
// Setup initial state
|
||||
profileManager.overrideProfile();
|
||||
profileManager.calculateChances();
|
||||
}
|
||||
|
||||
|
||||
public ParkourGenerator(@NotNull Session session, GeneratorOption... generatorOptions) {
|
||||
this(session, loadIslandSchematic(), generatorOptions);
|
||||
this(session, null, generatorOptions);
|
||||
}
|
||||
|
||||
private static LPSchematic loadIslandSchematic() {
|
||||
String name = Config.GENERATION.getString("advanced.island.schematic-name");
|
||||
if (name == null || name.isEmpty()) {
|
||||
name = "island"; // Default fallback
|
||||
}
|
||||
|
||||
var manager = LoParkour.getSchematicManager();
|
||||
if (manager == null) {
|
||||
LoParkour.getPlugin().getLogger().warning("Schematic manager not initialized, using null island");
|
||||
return null;
|
||||
}
|
||||
|
||||
LPSchematic s = manager.getSchematic(name);
|
||||
if (s == null) {
|
||||
LoParkour.getPlugin().getLogger().warning("Island schematic '%s' not found!".formatted(name));
|
||||
|
||||
// Try fallback to default "island" schematic
|
||||
if (!"island".equals(name)) {
|
||||
LoParkour.getPlugin().getLogger().info("Attempting to load default 'island' schematic as fallback...");
|
||||
s = manager.getSchematic("island");
|
||||
if (s != null) {
|
||||
LoParkour.getPlugin().getLogger().info("Successfully loaded fallback 'island' schematic");
|
||||
} else {
|
||||
LoParkour.getPlugin().getLogger().severe("Fallback 'island' schematic also not found! Island generation will be disabled.");
|
||||
}
|
||||
}
|
||||
}
|
||||
return s;
|
||||
}
|
||||
|
||||
public void overrideProfile() {}
|
||||
|
||||
protected void calculateChances() {
|
||||
state.defaultChances.clear();
|
||||
state.defaultChances.put(BlockGenerationType.DEFAULT, Option.TYPE_NORMAL);
|
||||
// Schematics disabled - only normal and special blocks
|
||||
state.defaultChances.put(BlockGenerationType.SPECIAL, Option.TYPE_SPECIAL);
|
||||
|
||||
state.heightChances.clear();
|
||||
state.heightChances.put(1, Option.NORMAL_HEIGHT_1);
|
||||
state.heightChances.put(0, Option.NORMAL_HEIGHT_0);
|
||||
state.heightChances.put(-1, Option.NORMAL_HEIGHT_NEG1);
|
||||
state.heightChances.put(-2, Option.NORMAL_HEIGHT_NEG2);
|
||||
|
||||
state.distanceChances.clear();
|
||||
state.distanceChances.put(1, Option.NORMAL_DISTANCE_1);
|
||||
state.distanceChances.put(2, Option.NORMAL_DISTANCE_2);
|
||||
state.distanceChances.put(3, Option.NORMAL_DISTANCE_3);
|
||||
state.distanceChances.put(4, Option.NORMAL_DISTANCE_4);
|
||||
|
||||
state.specialChances.clear();
|
||||
state.specialChances.put(Material.PACKED_ICE.createBlockData(), Option.SPECIAL_ICE);
|
||||
state.specialChances.put(Material.SMOOTH_QUARTZ_SLAB.createBlockData("[type=bottom]"), Option.SPECIAL_SLAB);
|
||||
state.specialChances.put(Material.GLASS_PANE.createBlockData(), Option.SPECIAL_PANE);
|
||||
state.specialChances.put(Material.OAK_FENCE.createBlockData(), Option.SPECIAL_FENCE);
|
||||
}
|
||||
|
||||
|
||||
// Core generation methods
|
||||
public void generate() { placer.generate(); }
|
||||
public void generate(int amount) { placer.generate(amount); }
|
||||
public void generateFirst(Location spawn, Location block) { placer.generateFirst(spawn, block); }
|
||||
|
||||
|
||||
// Lifecycle methods
|
||||
public void startTick() { lifecycle.startTick(); }
|
||||
public void tick() { lifecycle.tick(); }
|
||||
protected void fall() { lifecycle.fall(); }
|
||||
protected void score() { lifecycle.score(); }
|
||||
|
||||
|
||||
public void reset(boolean regenerate) {
|
||||
state.stopped = !regenerate;
|
||||
if (!regenerate && task == null) {
|
||||
LoParkour.getPlugin().getLogger().warning("## Incomplete joining setup — report this!");
|
||||
}
|
||||
state.lastPositionIndexPlayer = 0;
|
||||
if (!state.history.isEmpty()) {
|
||||
Block first = state.history.remove(0);
|
||||
// Create a copy to avoid ConcurrentModificationException
|
||||
List<Block> historySnapshot = new ArrayList<>(state.history);
|
||||
historySnapshot.forEach(b -> b.setType(Material.AIR, false));
|
||||
state.history.clear();
|
||||
}
|
||||
state.resetSchematicState();
|
||||
placer.deleteSchematic();
|
||||
Leaderboard lb = getMode().getLeaderboard();
|
||||
int record = lb != null ? lb.get(player.getUUID()).score() : 0;
|
||||
if (profile.get("showFallMessage").asBoolean()) {
|
||||
sendFallMessage(record);
|
||||
}
|
||||
if (lb != null && state.score > record) {
|
||||
registerScore(getDetailedTime(), Double.toString(getDifficultyScore()).substring(0, 3), state.score);
|
||||
}
|
||||
state.resetScore();
|
||||
state.heading = Option.HEADING.getDirection();
|
||||
lifecycle.reset(regenerate);
|
||||
|
||||
if (regenerate) {
|
||||
player.teleport(state.playerSpawn);
|
||||
generateFirst(state.playerSpawn, state.blockSpawn);
|
||||
return;
|
||||
}
|
||||
island.destroy();
|
||||
if (getPlayers().isEmpty()) {
|
||||
getSpectators().forEach(s -> Modes.DEFAULT.create(s.player));
|
||||
profileManager.overrideProfile();
|
||||
profileManager.calculateChances();
|
||||
}
|
||||
}
|
||||
|
||||
private void sendFallMessage(int record) {
|
||||
String key;
|
||||
int number = 0;
|
||||
if (state.score == record) {
|
||||
key = "settings.parkour_settings.items.fall_message.formats.tied";
|
||||
} else if (state.score > record) {
|
||||
key = "settings.parkour_settings.items.fall_message.formats.beat";
|
||||
number = state.score - record;
|
||||
} else {
|
||||
key = "settings.parkour_settings.items.fall_message.formats.miss";
|
||||
number = record - state.score;
|
||||
}
|
||||
for (ParkourPlayer p : getPlayers()) {
|
||||
p.sendTranslated("settings.parkour_settings.items.fall_message.divider");
|
||||
p.sendTranslated("settings.parkour_settings.items.fall_message.score", Integer.toString(state.score));
|
||||
p.sendTranslated("settings.parkour_settings.items.fall_message.time", getFormattedTime());
|
||||
p.sendTranslated("settings.parkour_settings.items.fall_message.high_score", Integer.toString(record));
|
||||
p.sendTranslated(key, Integer.toString(number));
|
||||
p.sendTranslated("settings.parkour_settings.items.fall_message.divider");
|
||||
}
|
||||
|
||||
// Statistics and scoring
|
||||
public void registerScore(String time, String difficulty, int score) {
|
||||
statistics.registerScore(time, difficulty, score);
|
||||
}
|
||||
|
||||
protected void registerScore(String time, String difficulty, int score) {
|
||||
Leaderboard lb = getMode().getLeaderboard();
|
||||
if (lb == null) return;
|
||||
getPlayers().forEach(p -> lb.put(p.getUUID(), new Score(p.getName(), time, difficulty, score)));
|
||||
}
|
||||
|
||||
public void menu(ParkourPlayer player) { Menus.PARKOUR_SETTINGS.open(player); }
|
||||
public Block getLatest() { return state.history.get(state.history.size() - 1); }
|
||||
|
||||
|
||||
public double getDifficultyScore() {
|
||||
double s = 0;
|
||||
if (profile.get("useSpecialBlocks").asBoolean()) s += 0.5;
|
||||
double d = profile.get("schematicDifficulty").asDouble();
|
||||
if (d > 0) s += d <= 0.25 ? 0.2 : d <= 0.5 ? 0.3 : d <= 0.75 ? 0.4 : 0.5;
|
||||
return s;
|
||||
return profileManager.getDifficultyScore();
|
||||
}
|
||||
|
||||
|
||||
public String getFormattedTime() {
|
||||
return getTime(Config.CONFIG.getString("options.time.score-format"));
|
||||
return statistics.getFormattedTime();
|
||||
}
|
||||
|
||||
|
||||
public String getDetailedTime() {
|
||||
return getTime("mm:ss:SSS");
|
||||
return statistics.getDetailedTime();
|
||||
}
|
||||
|
||||
private String getTime(String format) {
|
||||
var ms = Instant.now().minusMillis(state.start != null ? state.start.toEpochMilli() : Instant.now().toEpochMilli());
|
||||
try {
|
||||
return DateTimeFormatter.ofPattern(format).withZone(ZoneOffset.UTC).format(ms);
|
||||
} catch (IllegalArgumentException ex) {
|
||||
LoParkour.getPlugin().getLogger().severe("Invalid time format: " + format);
|
||||
return "";
|
||||
}
|
||||
|
||||
// UI and player management
|
||||
public void menu(ParkourPlayer player) {
|
||||
Menus.PARKOUR_SETTINGS.open(player);
|
||||
}
|
||||
|
||||
public Mode getMode() { return Modes.DEFAULT; }
|
||||
@NotNull public List<ParkourPlayer> getPlayers() { return session.getPlayers(); }
|
||||
@NotNull public List<ParkourSpectator> getSpectators() { return session.getSpectators(); }
|
||||
|
||||
public enum BlockGenerationType { DEFAULT, SCHEMATIC, SPECIAL }
|
||||
}
|
||||
|
||||
public Mode getMode() {
|
||||
return Modes.DEFAULT;
|
||||
}
|
||||
|
||||
@NotNull
|
||||
public List<ParkourPlayer> getPlayers() {
|
||||
return session.getPlayers();
|
||||
}
|
||||
|
||||
@NotNull
|
||||
public List<ParkourSpectator> getSpectators() {
|
||||
return session.getSpectators();
|
||||
}
|
||||
|
||||
/** Last generated block, or {@code null} if history is empty. */
|
||||
public @Nullable Block getLatest() {
|
||||
if (state.history.isEmpty()) return null;
|
||||
return state.history.get(state.history.size() - 1);
|
||||
}
|
||||
|
||||
public enum BlockGenerationType {
|
||||
DEFAULT, SPECIAL, SCHEMATIC
|
||||
}
|
||||
}
|
||||
|
|
@ -3,7 +3,6 @@ package dev.loki.loparkour.generator.effect;
|
|||
import dev.loki.loparkour.config.Option;
|
||||
import dev.loki.loparkour.generator.ParkourGenerator;
|
||||
import dev.loki.loparkour.player.ParkourPlayer;
|
||||
import dev.loki.loparkour.player.ParkourSpectator;
|
||||
import dev.loki.loparkour.util.ParticleUtil;
|
||||
import org.bukkit.Location;
|
||||
import org.bukkit.block.Block;
|
||||
|
|
|
|||
|
|
@ -1,308 +1,176 @@
|
|||
package dev.loki.loparkour.generator.jump;
|
||||
|
||||
import dev.loki.loparkour.LoParkour;
|
||||
import dev.loki.loparkour.api.event.ParkourBlockGenerateEvent;
|
||||
import dev.loki.loparkour.api.event.ParkourSchematicGenerateEvent;
|
||||
import dev.loki.loparkour.api.Registry;
|
||||
import dev.loki.loparkour.config.Config;
|
||||
import dev.loki.loparkour.config.Option;
|
||||
import dev.loki.loparkour.generator.GeneratorOption;
|
||||
import dev.loki.loparkour.generator.ParkourGenerator;
|
||||
import dev.loki.loparkour.style.Style;
|
||||
import dev.loki.loparkour.schematic.lpschem.LPSchematic;
|
||||
import dev.loki.loparkour.util.Probs;
|
||||
import org.bukkit.Location;
|
||||
import org.bukkit.Material;
|
||||
import org.bukkit.block.Block;
|
||||
import org.bukkit.block.data.BlockData;
|
||||
import org.bukkit.block.data.type.Fence;
|
||||
import org.bukkit.block.data.type.GlassPane;
|
||||
import org.bukkit.util.BoundingBox;
|
||||
import org.bukkit.util.Vector;
|
||||
import org.jetbrains.annotations.NotNull;
|
||||
|
||||
import java.util.ArrayList;
|
||||
import java.util.HashMap;
|
||||
import java.util.List;
|
||||
import java.util.Map;
|
||||
import java.util.concurrent.ThreadLocalRandom;
|
||||
import java.util.logging.Level;
|
||||
|
||||
/**
|
||||
* Handles block selection, placement, and schematic generation.
|
||||
* Extracted from {@link ParkourGenerator}.
|
||||
* Refactored BlockPlacer using composition pattern.
|
||||
* Coordinates block selection, jump calculation, and placement.
|
||||
*/
|
||||
public class BlockPlacer {
|
||||
|
||||
private final ParkourGenerator g;
|
||||
|
||||
public BlockPlacer(ParkourGenerator generator) {
|
||||
this.g = generator;
|
||||
}
|
||||
|
||||
// ── Public entry points ────────────────────────────────────────────────────
|
||||
|
||||
/** Generates one block or schematic ahead. */
|
||||
public void generate() {
|
||||
if (g.state.waitForSchematicCompletion) return;
|
||||
|
||||
Map<ParkourGenerator.BlockGenerationType, Double> chances = new HashMap<>(g.state.defaultChances);
|
||||
if (g.state.schematicCooldown > 0
|
||||
|| g.generatorOptions.contains(GeneratorOption.DISABLE_SCHEMATICS)
|
||||
|| g.profile.get("schematicDifficulty").asDouble() == 0.0
|
||||
|| !g.state.schematicBlocks.isEmpty()) {
|
||||
chances.remove(ParkourGenerator.BlockGenerationType.SCHEMATIC);
|
||||
}
|
||||
if (!g.profile.get("useSpecialBlocks").asBoolean()) {
|
||||
chances.remove(ParkourGenerator.BlockGenerationType.SPECIAL);
|
||||
}
|
||||
if (chances.isEmpty()) chances.put(ParkourGenerator.BlockGenerationType.DEFAULT, 1.0);
|
||||
|
||||
ParkourGenerator.BlockGenerationType jump = Probs.random(chances);
|
||||
if (jump == ParkourGenerator.BlockGenerationType.SCHEMATIC && tryGenerateSchematic()) return;
|
||||
|
||||
placeNormalBlock(jump);
|
||||
}
|
||||
|
||||
/** Generates {@code amount+1} blocks. */
|
||||
public void generate(int amount) {
|
||||
for (int i = 0; i <= amount; i++) generate();
|
||||
}
|
||||
|
||||
/** Seeds the history and generates the initial lead. */
|
||||
public void generateFirst(Location spawn, Location blockSpawn) {
|
||||
g.state.playerSpawn = spawn;
|
||||
g.state.lastStandingPlayerLocation = spawn;
|
||||
g.state.blockSpawn = blockSpawn;
|
||||
g.state.history.add(blockSpawn.getBlock());
|
||||
generate(g.profile.get("blockLead").asInt());
|
||||
}
|
||||
|
||||
/** Clears schematic blocks and resets cooldown. */
|
||||
public void deleteSchematic() {
|
||||
if (!g.state.deleteSchematic) return;
|
||||
g.state.schematicBlocks.forEach(b -> b.setType(Material.AIR));
|
||||
g.state.schematicBlocks.clear();
|
||||
g.state.deleteSchematic = false;
|
||||
g.state.schematicCooldown = Config.GENERATION.getInt("advanced.schematic-cooldown");
|
||||
}
|
||||
|
||||
// ── Block selection ────────────────────────────────────────────────────────
|
||||
|
||||
protected List<Block> selectBlocks() {
|
||||
int height = Probs.random(g.state.heightChances);
|
||||
int distance = Probs.random(g.state.distanceChances);
|
||||
return List.of(selectNext(g.getLatest(), distance, height));
|
||||
}
|
||||
|
||||
protected Block selectNext(Block current, int distance, int height) {
|
||||
JumpDirector director = new JumpDirector(
|
||||
BoundingBox.of(g.zone[0], g.zone[1]), g.getLatest().getLocation().toVector());
|
||||
|
||||
g.state.heading = director.getRecommendedHeading(g.state.heading);
|
||||
height = director.getRecommendedHeight(height);
|
||||
|
||||
// Restrict jumps after special blocks to prevent impossible jumps
|
||||
Material lastType = g.getLatest().getType();
|
||||
BlockData lastBlockData = g.getLatest().getBlockData();
|
||||
|
||||
// Check if it's a slab material
|
||||
if (isSlabMaterial(lastType)) {
|
||||
// Check if it's a bottom slab (height 0.5) or top slab (height 1.0)
|
||||
if (lastBlockData instanceof org.bukkit.block.data.type.Slab slab) {
|
||||
if (slab.getType() == org.bukkit.block.data.type.Slab.Type.BOTTOM) {
|
||||
// Bottom slab: player is 0.5 blocks lower, can't jump as high
|
||||
height = Math.min(height, -1); // Reduce max height due to lower starting position
|
||||
distance = Math.min(distance, 3); // Limit distance
|
||||
} else if (slab.getType() == org.bukkit.block.data.type.Slab.Type.TOP) {
|
||||
// Top slab: normal restrictions
|
||||
height = Math.min(height, 0);
|
||||
distance = Math.min(distance, 3);
|
||||
}
|
||||
} else {
|
||||
// Fallback for unknown slab type
|
||||
height = Math.min(height, 0);
|
||||
distance = Math.min(distance, 3);
|
||||
}
|
||||
} else {
|
||||
switch (lastType) {
|
||||
case GLASS_PANE -> {
|
||||
height = Math.min(height, 0); // Can't jump up from pane
|
||||
distance = Math.min(distance, 3); // Limit distance
|
||||
}
|
||||
case OAK_FENCE, BIRCH_FENCE, SPRUCE_FENCE, DARK_OAK_FENCE,
|
||||
JUNGLE_FENCE, ACACIA_FENCE, MANGROVE_FENCE, CHERRY_FENCE,
|
||||
CRIMSON_FENCE, WARPED_FENCE, NETHER_BRICK_FENCE -> {
|
||||
height = Math.min(height, 0); // Can't jump up from fence
|
||||
distance = Math.min(distance, 3); // Limit distance
|
||||
}
|
||||
case PACKED_ICE -> {
|
||||
distance = Math.min(distance, 3); // Ice is slippery, limit distance
|
||||
}
|
||||
case OAK_TRAPDOOR, BIRCH_TRAPDOOR, SPRUCE_TRAPDOOR, DARK_OAK_TRAPDOOR,
|
||||
JUNGLE_TRAPDOOR, ACACIA_TRAPDOOR, MANGROVE_TRAPDOOR, CHERRY_TRAPDOOR,
|
||||
CRIMSON_TRAPDOOR, WARPED_TRAPDOOR, IRON_TRAPDOOR -> {
|
||||
height = Math.min(height, 0); // Can't jump up from trapdoor
|
||||
distance = Math.min(distance, 2); // Very limited distance
|
||||
}
|
||||
case LADDER -> {
|
||||
height = Math.min(height, 1); // Limited height from ladder
|
||||
distance = Math.min(distance, 2); // Very limited distance
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Clamp values to valid ranges for JumpOffsetGenerator
|
||||
height = Math.max(-2, Math.min(1, height));
|
||||
distance = Math.max(1, Math.min(4, distance));
|
||||
|
||||
if (height > 0) distance = Math.max(distance - height, 1);
|
||||
|
||||
double sd = g.generatorOptions.contains(GeneratorOption.REDUCE_RANDOM_BLOCK_SELECTION_ANGLE) ? 0.5 : 1;
|
||||
int randomOffset = new JumpOffsetGenerator(height, distance).getRandomOffset(0, sd);
|
||||
|
||||
Vector offset = g.state.heading.clone().multiply(distance + 1).setY(height);
|
||||
if (offset.getX() == 0) offset.setX(randomOffset);
|
||||
else offset.setZ(randomOffset);
|
||||
|
||||
offset.rotateAroundY(angleInY(g.state.heading, Option.HEADING.getDirection()));
|
||||
|
||||
Block candidate = current.getLocation().add(offset).getBlock();
|
||||
|
||||
// Retry with JumpValidator if jump is impossible (up to 10 attempts)
|
||||
JumpValidator validator = new JumpValidator();
|
||||
int attempts = 0;
|
||||
while (!validator.canJump(current.getLocation(), candidate.getLocation()) && attempts < 10) {
|
||||
// Reduce distance and height to make jump easier
|
||||
distance = Math.max(1, distance - 1);
|
||||
height = Math.max(-1, height - 1);
|
||||
|
||||
randomOffset = new JumpOffsetGenerator(height, distance).getRandomOffset(0, sd);
|
||||
offset = g.state.heading.clone().multiply(distance + 1).setY(height);
|
||||
if (offset.getX() == 0) offset.setX(randomOffset);
|
||||
else offset.setZ(randomOffset);
|
||||
offset.rotateAroundY(angleInY(g.state.heading, Option.HEADING.getDirection()));
|
||||
|
||||
candidate = current.getLocation().add(offset).getBlock();
|
||||
attempts++;
|
||||
}
|
||||
|
||||
return candidate;
|
||||
|
||||
private final ParkourGenerator generator;
|
||||
private final BlockSelector blockSelector;
|
||||
private final JumpCalculator jumpCalculator;
|
||||
|
||||
public BlockPlacer(@NotNull ParkourGenerator generator) {
|
||||
this.generator = generator;
|
||||
this.blockSelector = new BlockSelector(generator);
|
||||
this.jumpCalculator = new JumpCalculator(generator, blockSelector);
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if material is a slab type.
|
||||
* Generate a single block.
|
||||
*/
|
||||
private boolean isSlabMaterial(Material material) {
|
||||
return material == Material.SMOOTH_QUARTZ_SLAB ||
|
||||
material == Material.STONE_SLAB ||
|
||||
material.name().endsWith("_SLAB");
|
||||
}
|
||||
|
||||
protected BlockData selectBlockData() {
|
||||
Style style = Registry.getStyle(g.profile.get("style").value());
|
||||
if (style == null) {
|
||||
g.profile.set("style", Registry.getStyles().stream().findFirst().orElseThrow().getName());
|
||||
return selectBlockData();
|
||||
}
|
||||
return style.getNext().createBlockData();
|
||||
}
|
||||
|
||||
// ── Internal ───────────────────────────────────────────────────────────────
|
||||
|
||||
private void placeNormalBlock(ParkourGenerator.BlockGenerationType jump) {
|
||||
List<Block> blocks = selectBlocks();
|
||||
if (blocks.isEmpty()) {
|
||||
LoParkour.getPlugin().getLogger().severe("No blocks to generate!");
|
||||
return;
|
||||
}
|
||||
|
||||
List<Block> placed = new ArrayList<>();
|
||||
for (Block block : blocks) {
|
||||
BlockData data;
|
||||
|
||||
// Select JumpType based on chances (70% normal, 30% special types)
|
||||
JumpType jumpType = selectJumpType();
|
||||
|
||||
if (jumpType != JumpType.NORMAL && !g.generatorOptions.contains(GeneratorOption.DISABLE_SPECIAL)) {
|
||||
// Use JumpType material
|
||||
data = jumpType.getRandomMaterial().createBlockData();
|
||||
} else if (jump == ParkourGenerator.BlockGenerationType.SPECIAL
|
||||
&& !g.generatorOptions.contains(GeneratorOption.DISABLE_SPECIAL)) {
|
||||
data = Probs.random(g.state.specialChances);
|
||||
} else {
|
||||
data = selectBlockData();
|
||||
}
|
||||
|
||||
if (data instanceof Fence) block = block.getLocation().subtract(0, 1, 0).getBlock();
|
||||
block.setBlockData(data, data instanceof Fence || data instanceof GlassPane);
|
||||
placed.add(block);
|
||||
}
|
||||
|
||||
new ParkourBlockGenerateEvent(placed, g, g.player).call();
|
||||
g.effects.particles(placed);
|
||||
g.effects.sound(placed);
|
||||
g.state.history.addAll(placed);
|
||||
g.state.schematicCooldown--;
|
||||
public void generate() {
|
||||
generate(1);
|
||||
}
|
||||
|
||||
private JumpType selectJumpType() {
|
||||
// Simple chances: 70% normal, 5% each for special types
|
||||
Map<JumpType, Double> chances = new HashMap<>();
|
||||
chances.put(JumpType.NORMAL, 70.0);
|
||||
chances.put(JumpType.NEO_JUMP, 5.0);
|
||||
chances.put(JumpType.HEAD_HITTER, 5.0);
|
||||
chances.put(JumpType.FENCE_JUMP, 10.0);
|
||||
chances.put(JumpType.TRAPDOOR_JUMP, 5.0);
|
||||
chances.put(JumpType.LADDER_JUMP, 5.0);
|
||||
|
||||
return Probs.random(chances);
|
||||
}
|
||||
|
||||
private boolean tryGenerateSchematic() {
|
||||
var manager = LoParkour.getSchematicManager();
|
||||
if (manager == null) return false;
|
||||
|
||||
var all = manager.getAllSchematics();
|
||||
if (all.isEmpty()) return false;
|
||||
|
||||
double diff = g.profile.get("schematicDifficulty").asDouble();
|
||||
var candidates = all.values().stream()
|
||||
.filter(s -> s.getMetadata() != null && Math.abs(s.getMetadata().getDifficulty() - diff) <= 0.26)
|
||||
.toList();
|
||||
var pool = candidates.isEmpty() ? new ArrayList<>(all.values()) : candidates;
|
||||
var schematic = pool.get(ThreadLocalRandom.current().nextInt(pool.size()));
|
||||
|
||||
Location origin = g.getLatest().getLocation().add(g.state.heading.clone().multiply(2));
|
||||
List<Block> placed = rotatedPaste(schematic, origin);
|
||||
if (placed.isEmpty()) return false;
|
||||
|
||||
g.state.schematicBlocks.addAll(placed);
|
||||
g.state.history.addAll(placed);
|
||||
g.state.waitForSchematicCompletion = true;
|
||||
g.state.deleteSchematic = false;
|
||||
|
||||
new ParkourSchematicGenerateEvent(schematic, g, g.player).call();
|
||||
return true;
|
||||
}
|
||||
|
||||
private @NotNull List<Block> rotatedPaste(
|
||||
dev.loki.loparkour.schematic.lpschem.LPSchematic schematic, Location location) {
|
||||
int[] rotations = {0, 90, 180, 270};
|
||||
int rotation = rotations[ThreadLocalRandom.current().nextInt(rotations.length)];
|
||||
org.bukkit.World world = location.getWorld();
|
||||
if (world == null) return List.of();
|
||||
|
||||
if (rotation == 0) return schematic.paste(location, world);
|
||||
try {
|
||||
var t = new dev.loki.loparkour.schematic.lpschem.SchematicTransformer(schematic);
|
||||
return t.rotate(rotation).paste(location, world);
|
||||
} catch (Exception ex) {
|
||||
LoParkour.getPlugin().getLogger().log(Level.WARNING, "Could not rotate schematic", ex);
|
||||
return schematic.paste(location, world);
|
||||
/**
|
||||
* Generate specified amount of blocks.
|
||||
*/
|
||||
public void generate(int amount) {
|
||||
for (int i = 0; i < amount; i++) {
|
||||
generateSingleBlock();
|
||||
}
|
||||
}
|
||||
|
||||
private double angleInY(Vector a, Vector b) {
|
||||
return Math.atan2(a.getX() * b.getZ() - a.getZ() * b.getX(), a.dot(b));
|
||||
|
||||
/**
|
||||
* Generate the first block at specified locations.
|
||||
*/
|
||||
public void generateFirst(@NotNull Location spawn, @NotNull Location blockSpawn) {
|
||||
Block block = blockSpawn.getBlock();
|
||||
BlockData blockData = blockSelector.selectBlockData();
|
||||
|
||||
placeBlockData(block, blockData);
|
||||
generator.state.history.add(block);
|
||||
generator.state.playerSpawn = spawn;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Delete schematic blocks (if any).
|
||||
*/
|
||||
public void deleteSchematic() {
|
||||
if (generator.state.schematicBlocks != null) {
|
||||
generator.state.schematicBlocks.forEach(block -> block.setType(org.bukkit.Material.AIR));
|
||||
generator.state.schematicBlocks.clear();
|
||||
}
|
||||
}
|
||||
|
||||
private void generateSingleBlock() {
|
||||
ParkourGenerator.BlockGenerationType jumpType = selectGenerationType();
|
||||
|
||||
switch (jumpType) {
|
||||
case DEFAULT -> placeNormalBlock();
|
||||
case SPECIAL -> placeSpecialBlock();
|
||||
case SCHEMATIC -> {
|
||||
if (!tryGenerateSchematic()) {
|
||||
placeNormalBlock(); // Fallback to normal block
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@NotNull
|
||||
private ParkourGenerator.BlockGenerationType selectGenerationType() {
|
||||
return Probs.random(generator.state.defaultChances);
|
||||
}
|
||||
|
||||
private void placeNormalBlock() {
|
||||
List<Block> blocks = selectBlocks();
|
||||
if (blocks.isEmpty()) return;
|
||||
|
||||
Block selectedBlock = blocks.get(0); // Use first block for simplicity
|
||||
BlockData blockData = blockSelector.selectBlockData();
|
||||
|
||||
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();
|
||||
if (blocks.isEmpty()) return;
|
||||
|
||||
Block selectedBlock = blocks.get(0);
|
||||
placeBlockData(selectedBlock, specialBlockData);
|
||||
generator.state.history.add(selectedBlock);
|
||||
}
|
||||
|
||||
/** Prefer no neighbor physics to avoid fragile vanilla multi-block checks in empty worlds. */
|
||||
private static void placeBlockData(@NotNull Block block, @NotNull BlockData data) {
|
||||
block.setBlockData(data, false);
|
||||
}
|
||||
|
||||
@NotNull
|
||||
private List<Block> selectBlocks() {
|
||||
List<Block> blocks = new ArrayList<>();
|
||||
|
||||
if (generator.state.history.isEmpty()) {
|
||||
return blocks; // No previous blocks to work with
|
||||
}
|
||||
|
||||
Block current = generator.getLatest();
|
||||
|
||||
// Select jump parameters
|
||||
int distance = Probs.random(generator.state.distanceChances);
|
||||
int height = Probs.random(generator.state.heightChances);
|
||||
|
||||
// Calculate next block position
|
||||
Block nextBlock = jumpCalculator.calculateNextBlock(current, distance, height);
|
||||
blocks.add(nextBlock);
|
||||
|
||||
return blocks;
|
||||
}
|
||||
|
||||
private boolean tryGenerateSchematic() {
|
||||
// Simplified schematic generation
|
||||
LPSchematic schematic = generator.island.schematic; // Получаем схематик из island
|
||||
if (schematic == null) return false;
|
||||
|
||||
List<Block> blocks = selectBlocks();
|
||||
if (blocks.isEmpty()) return false;
|
||||
|
||||
Block targetBlock = blocks.get(0);
|
||||
Location pasteLocation = targetBlock.getLocation();
|
||||
|
||||
// Paste schematic (simplified)
|
||||
List<Block> schematicBlocks = pasteSchematic(schematic, pasteLocation);
|
||||
|
||||
if (!schematicBlocks.isEmpty()) {
|
||||
generator.state.history.addAll(schematicBlocks);
|
||||
generator.state.schematicBlocks = schematicBlocks;
|
||||
return true;
|
||||
}
|
||||
|
||||
return false;
|
||||
}
|
||||
|
||||
@NotNull
|
||||
private List<Block> pasteSchematic(@NotNull LPSchematic schematic, @NotNull Location location) {
|
||||
List<Block> blocks = new ArrayList<>();
|
||||
|
||||
// Simplified schematic pasting - would need full implementation
|
||||
// For now, just place a single block
|
||||
Block block = location.getBlock();
|
||||
BlockData blockData = blockSelector.selectBlockData();
|
||||
placeBlockData(block, blockData);
|
||||
blocks.add(block);
|
||||
|
||||
return blocks;
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,141 @@
|
|||
package dev.loki.loparkour.generator.jump;
|
||||
|
||||
import dev.loki.loparkour.api.Registry;
|
||||
import dev.loki.loparkour.generator.ParkourGenerator;
|
||||
import dev.loki.loparkour.style.Style;
|
||||
import org.bukkit.Material;
|
||||
import org.bukkit.block.data.BlockData;
|
||||
import org.jetbrains.annotations.NotNull;
|
||||
import org.jetbrains.annotations.Nullable;
|
||||
|
||||
/**
|
||||
* Handles selection of block types and materials for parkour generation.
|
||||
*/
|
||||
public class BlockSelector {
|
||||
|
||||
private final ParkourGenerator generator;
|
||||
|
||||
public BlockSelector(@NotNull ParkourGenerator generator) {
|
||||
this.generator = generator;
|
||||
}
|
||||
|
||||
private static final int MAX_STYLE_PICKS = 64;
|
||||
|
||||
/**
|
||||
* Select block data for the next block based on current style.
|
||||
*/
|
||||
@NotNull
|
||||
public BlockData selectBlockData() {
|
||||
Style style = Registry.getStyle(generator.profile.get("style").value());
|
||||
if (style == null) {
|
||||
generator.profile.set("style", Registry.getStyles().stream().findFirst().orElseThrow().getName());
|
||||
return selectBlockData();
|
||||
}
|
||||
for (int i = 0; i < MAX_STYLE_PICKS; i++) {
|
||||
Material mat = style.getNext();
|
||||
if (!isUnsafeParkourMaterial(mat)) {
|
||||
return mat.createBlockData();
|
||||
}
|
||||
}
|
||||
return Material.STONE.createBlockData();
|
||||
}
|
||||
|
||||
/**
|
||||
* Blocks whose vanilla {@code onPlace} scans neighbors (e.g. iron golem pattern for carved pumpkin)
|
||||
* can NPE on high/void parkour worlds when adjacent chunks have no loaded state.
|
||||
*/
|
||||
static boolean isUnsafeParkourMaterial(@NotNull Material m) {
|
||||
if (m == Material.CARVED_PUMPKIN || m == Material.JACK_O_LANTERN || m == Material.PUMPKIN) {
|
||||
return true;
|
||||
}
|
||||
if (m == Material.DRAGON_EGG) {
|
||||
return true;
|
||||
}
|
||||
String n = m.name();
|
||||
if (n.endsWith("_BED")) {
|
||||
return true;
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
/**
|
||||
* Select jump type based on current configuration and probabilities.
|
||||
*/
|
||||
@NotNull
|
||||
public JumpType selectJumpType() {
|
||||
// Simple random selection for now
|
||||
JumpType[] types = JumpType.values();
|
||||
return types[(int) (Math.random() * types.length)];
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if material is a slab type.
|
||||
*/
|
||||
public boolean isSlabMaterial(@NotNull Material material) {
|
||||
return material == Material.SMOOTH_QUARTZ_SLAB ||
|
||||
material == Material.STONE_SLAB ||
|
||||
material.name().endsWith("_SLAB");
|
||||
}
|
||||
|
||||
/**
|
||||
* Get a slab material from the current player's style.
|
||||
*/
|
||||
@Nullable
|
||||
public Material getSlabMaterialFromCurrentStyle() {
|
||||
try {
|
||||
Style style = Registry.getStyle(generator.profile.get("style").value());
|
||||
if (style == null) return null;
|
||||
|
||||
// Try to get a slab material from the style multiple times
|
||||
for (int i = 0; i < 20; i++) {
|
||||
Material material = style.getNext();
|
||||
if (isSlabMaterial(material)) {
|
||||
return material;
|
||||
}
|
||||
}
|
||||
} catch (Exception e) {
|
||||
// Ignore errors and return null
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if material is a special block type that affects jump mechanics.
|
||||
*/
|
||||
public boolean isSpecialMaterial(@NotNull Material material) {
|
||||
return isSlabMaterial(material) ||
|
||||
material == Material.GLASS_PANE ||
|
||||
material == Material.PACKED_ICE ||
|
||||
isFenceMaterial(material) ||
|
||||
isTrapdoorMaterial(material) ||
|
||||
material == Material.LADDER;
|
||||
}
|
||||
|
||||
private boolean isFenceMaterial(@NotNull Material material) {
|
||||
return material == Material.OAK_FENCE ||
|
||||
material == Material.BIRCH_FENCE ||
|
||||
material == Material.SPRUCE_FENCE ||
|
||||
material == Material.DARK_OAK_FENCE ||
|
||||
material == Material.JUNGLE_FENCE ||
|
||||
material == Material.ACACIA_FENCE ||
|
||||
material == Material.MANGROVE_FENCE ||
|
||||
material == Material.CHERRY_FENCE ||
|
||||
material == Material.CRIMSON_FENCE ||
|
||||
material == Material.WARPED_FENCE ||
|
||||
material == Material.NETHER_BRICK_FENCE;
|
||||
}
|
||||
|
||||
private boolean isTrapdoorMaterial(@NotNull Material material) {
|
||||
return material == Material.OAK_TRAPDOOR ||
|
||||
material == Material.BIRCH_TRAPDOOR ||
|
||||
material == Material.SPRUCE_TRAPDOOR ||
|
||||
material == Material.DARK_OAK_TRAPDOOR ||
|
||||
material == Material.JUNGLE_TRAPDOOR ||
|
||||
material == Material.ACACIA_TRAPDOOR ||
|
||||
material == Material.MANGROVE_TRAPDOOR ||
|
||||
material == Material.CHERRY_TRAPDOOR ||
|
||||
material == Material.CRIMSON_TRAPDOOR ||
|
||||
material == Material.WARPED_TRAPDOOR ||
|
||||
material == Material.IRON_TRAPDOOR;
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,165 @@
|
|||
package dev.loki.loparkour.generator.jump;
|
||||
|
||||
import dev.loki.loparkour.config.Option;
|
||||
import dev.loki.loparkour.generator.GeneratorOption;
|
||||
import dev.loki.loparkour.generator.ParkourGenerator;
|
||||
import org.bukkit.Material;
|
||||
import org.bukkit.block.Block;
|
||||
import org.bukkit.block.data.BlockData;
|
||||
import org.bukkit.util.BoundingBox;
|
||||
import org.bukkit.util.Vector;
|
||||
import org.jetbrains.annotations.NotNull;
|
||||
|
||||
/**
|
||||
* Calculates jump positions and validates jump constraints.
|
||||
*/
|
||||
public class JumpCalculator {
|
||||
|
||||
private final ParkourGenerator generator;
|
||||
private final BlockSelector blockSelector;
|
||||
|
||||
public JumpCalculator(@NotNull ParkourGenerator generator, @NotNull BlockSelector blockSelector) {
|
||||
this.generator = generator;
|
||||
this.blockSelector = blockSelector;
|
||||
}
|
||||
|
||||
/**
|
||||
* Calculate the next block position based on current block and jump parameters.
|
||||
*/
|
||||
@NotNull
|
||||
public Block calculateNextBlock(@NotNull Block current, int distance, int height) {
|
||||
// Ensure zone is initialized
|
||||
if (generator.state.zone == null || generator.state.zone.length < 2) {
|
||||
throw new IllegalStateException("Generator zone not initialized");
|
||||
}
|
||||
|
||||
JumpDirector director = new JumpDirector(
|
||||
BoundingBox.of(generator.state.zone[0], generator.state.zone[1]),
|
||||
current.getLocation().toVector()
|
||||
);
|
||||
|
||||
generator.state.heading = director.getRecommendedHeading(generator.state.heading);
|
||||
height = director.getRecommendedHeight(height);
|
||||
|
||||
// Apply material-specific restrictions
|
||||
JumpConstraints constraints = calculateJumpConstraints(current);
|
||||
height = Math.min(height, constraints.maxHeight);
|
||||
distance = Math.min(distance, constraints.maxDistance);
|
||||
|
||||
// Clamp values to valid ranges
|
||||
height = Math.max(-2, Math.min(1, height));
|
||||
distance = Math.max(1, Math.min(4, distance));
|
||||
|
||||
if (height > 0) distance = Math.max(distance - height, 1);
|
||||
|
||||
// Calculate offset
|
||||
Vector offset = calculateJumpOffset(distance, height);
|
||||
Block candidate = current.getLocation().add(offset).getBlock();
|
||||
|
||||
// Validate jump with retry mechanism
|
||||
return validateAndRetryJump(current, candidate, distance, height);
|
||||
}
|
||||
|
||||
/**
|
||||
* Calculate jump constraints based on the material of the current block.
|
||||
*/
|
||||
@NotNull
|
||||
public JumpConstraints calculateJumpConstraints(@NotNull Block currentBlock) {
|
||||
Material material = currentBlock.getType();
|
||||
BlockData blockData = currentBlock.getBlockData();
|
||||
|
||||
// Default constraints
|
||||
int maxHeight = 1;
|
||||
int maxDistance = 4;
|
||||
|
||||
if (blockSelector.isSlabMaterial(material)) {
|
||||
if (blockData instanceof org.bukkit.block.data.type.Slab slab) {
|
||||
if (slab.getType() == org.bukkit.block.data.type.Slab.Type.BOTTOM) {
|
||||
maxHeight = -1; // Bottom slab: reduced height capability
|
||||
maxDistance = 3;
|
||||
} else {
|
||||
maxHeight = 0; // Top slab: normal restrictions
|
||||
maxDistance = 3;
|
||||
}
|
||||
} else {
|
||||
maxHeight = 0;
|
||||
maxDistance = 3;
|
||||
}
|
||||
} else if (material == Material.GLASS_PANE) {
|
||||
maxHeight = 0;
|
||||
maxDistance = 3;
|
||||
} else if (blockSelector.isSpecialMaterial(material)) {
|
||||
maxHeight = getSpecialMaterialMaxHeight(material);
|
||||
maxDistance = getSpecialMaterialMaxDistance(material);
|
||||
}
|
||||
|
||||
return new JumpConstraints(maxHeight, maxDistance);
|
||||
}
|
||||
|
||||
@NotNull
|
||||
private Vector calculateJumpOffset(int distance, int height) {
|
||||
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);
|
||||
if (offset.getX() == 0) {
|
||||
offset.setX(randomOffset);
|
||||
} else {
|
||||
offset.setZ(randomOffset);
|
||||
}
|
||||
|
||||
offset.rotateAroundY(angleInY(generator.state.heading, Option.HEADING.getDirection()));
|
||||
return offset;
|
||||
}
|
||||
|
||||
@NotNull
|
||||
private Block validateAndRetryJump(@NotNull Block current, @NotNull Block candidate, int distance, int height) {
|
||||
JumpValidator validator = new JumpValidator();
|
||||
int attempts = 0;
|
||||
|
||||
while (!validator.canJump(current.getLocation(), candidate.getLocation()) && attempts < 10) {
|
||||
// Reduce distance and height to make jump easier
|
||||
distance = Math.max(1, distance - 1);
|
||||
height = Math.max(-1, height - 1);
|
||||
|
||||
Vector offset = calculateJumpOffset(distance, height);
|
||||
candidate = current.getLocation().add(offset).getBlock();
|
||||
attempts++;
|
||||
}
|
||||
|
||||
return candidate;
|
||||
}
|
||||
|
||||
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
|
||||
};
|
||||
}
|
||||
|
||||
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
|
||||
};
|
||||
}
|
||||
|
||||
private double angleInY(@NotNull Vector a, @NotNull Vector b) {
|
||||
return Math.atan2(b.getZ(), b.getX()) - Math.atan2(a.getZ(), a.getX());
|
||||
}
|
||||
|
||||
/**
|
||||
* Represents jump constraints for a specific block type.
|
||||
*/
|
||||
public static class JumpConstraints {
|
||||
public final int maxHeight;
|
||||
public final int maxDistance;
|
||||
|
||||
public JumpConstraints(int maxHeight, int maxDistance) {
|
||||
this.maxHeight = maxHeight;
|
||||
this.maxDistance = maxDistance;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,120 @@
|
|||
package dev.loki.loparkour.generator.lifecycle;
|
||||
|
||||
import dev.loki.loparkour.config.Option;
|
||||
import dev.loki.loparkour.generator.ParkourGenerator;
|
||||
import dev.loki.loparkour.player.ParkourPlayer;
|
||||
import org.bukkit.Location;
|
||||
import org.bukkit.Material;
|
||||
import org.bukkit.block.Block;
|
||||
import org.jetbrains.annotations.NotNull;
|
||||
|
||||
import java.util.ArrayList;
|
||||
import java.util.List;
|
||||
|
||||
/**
|
||||
* Handles cleanup operations for parkour generator.
|
||||
*/
|
||||
public class GeneratorCleanup {
|
||||
|
||||
private final ParkourGenerator generator;
|
||||
|
||||
public GeneratorCleanup(@NotNull ParkourGenerator generator) {
|
||||
this.generator = generator;
|
||||
}
|
||||
|
||||
/**
|
||||
* Remove only trail blocks <strong>behind</strong> players (oldest indices).
|
||||
* Distance-based cleanup was removing blocks ahead of the player and breaking scoring.
|
||||
*/
|
||||
public void cleanupDistantBlocks() {
|
||||
List<Block> history = generator.state.history;
|
||||
if (history.isEmpty()) return;
|
||||
|
||||
int minStandingIndex = Integer.MAX_VALUE;
|
||||
for (ParkourPlayer pp : generator.getPlayers()) {
|
||||
Block below = blockBelowPlayer(pp);
|
||||
if (below == null) continue;
|
||||
int idx = history.indexOf(below);
|
||||
if (idx >= 0) {
|
||||
minStandingIndex = Math.min(minStandingIndex, idx);
|
||||
}
|
||||
}
|
||||
if (minStandingIndex == Integer.MAX_VALUE) {
|
||||
return;
|
||||
}
|
||||
|
||||
int keepBehind = Math.max(0, Option.TRAIL_KEEP_BEHIND);
|
||||
int removeCount = minStandingIndex - keepBehind;
|
||||
if (removeCount <= 0) return;
|
||||
|
||||
List<Block> head = new ArrayList<>(history.subList(0, removeCount));
|
||||
for (Block block : head) {
|
||||
if (block != null && block.getType() != Material.AIR) {
|
||||
block.setType(Material.AIR);
|
||||
}
|
||||
}
|
||||
history.subList(0, removeCount).clear();
|
||||
}
|
||||
|
||||
private static Block blockBelowPlayer(@NotNull ParkourPlayer pp) {
|
||||
Location loc = pp.player.getLocation().clone();
|
||||
Block below = loc.subtract(0, 1, 0).getBlock();
|
||||
return below.getType() != Material.AIR ? below : null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Remove trail blocks behind player at specified index.
|
||||
*/
|
||||
public void removeTrailBlocks(int startIndex) {
|
||||
if (startIndex < 0 || startIndex >= generator.state.history.size()) return;
|
||||
|
||||
int endIndex = Math.min(startIndex + Option.BLOCK_CLEANUP_DISTANCE, generator.state.history.size());
|
||||
|
||||
for (int i = startIndex; i < endIndex; i++) {
|
||||
Block block = generator.state.history.get(i);
|
||||
if (block != null && block.getType() != Material.AIR) {
|
||||
block.setType(Material.AIR);
|
||||
}
|
||||
}
|
||||
|
||||
// Remove from history
|
||||
generator.state.history.subList(startIndex, endIndex).clear();
|
||||
}
|
||||
|
||||
/**
|
||||
* Reset generator state and clean up all blocks.
|
||||
*/
|
||||
public void reset(boolean regenerate) {
|
||||
List<Block> snapshot = new ArrayList<>(generator.state.history);
|
||||
for (Block block : snapshot) {
|
||||
if (block != null) {
|
||||
block.setType(Material.AIR);
|
||||
}
|
||||
}
|
||||
|
||||
// Clear schematic blocks
|
||||
if (generator.state.schematicBlocks != null) {
|
||||
for (Block block : generator.state.schematicBlocks) {
|
||||
if (block != null) {
|
||||
block.setType(Material.AIR);
|
||||
}
|
||||
}
|
||||
generator.state.schematicBlocks.clear();
|
||||
}
|
||||
|
||||
// Reset state
|
||||
generator.state.history.clear();
|
||||
generator.state.score = 0;
|
||||
generator.state.start = null;
|
||||
|
||||
if (regenerate) {
|
||||
// Regenerate first block
|
||||
Location spawn = generator.state.playerSpawn;
|
||||
if (spawn != null) {
|
||||
Location blockSpawn = spawn.clone().add(0, -1, 0);
|
||||
generator.generateFirst(spawn, blockSpawn);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
}
|
||||
|
|
@ -1,265 +1,69 @@
|
|||
package dev.loki.loparkour.generator.lifecycle;
|
||||
|
||||
import dev.lolib.scheduler.Scheduler;
|
||||
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.config.Option;
|
||||
import dev.loki.loparkour.generator.ParkourGenerator;
|
||||
import dev.loki.loparkour.ghost.GhostData;
|
||||
import dev.loki.loparkour.ghost.GhostManager;
|
||||
import dev.loki.loparkour.ghost.GhostRecorder;
|
||||
import dev.loki.loparkour.leaderboard.Leaderboard;
|
||||
import dev.loki.loparkour.leaderboard.Score;
|
||||
import dev.loki.loparkour.mode.Modes;
|
||||
import dev.loki.loparkour.player.ParkourPlayer;
|
||||
import dev.loki.loparkour.player.ParkourSpectator;
|
||||
import dev.loki.loparkour.reward.Rewards;
|
||||
import org.bukkit.Location;
|
||||
import org.bukkit.Material;
|
||||
import org.bukkit.block.Block;
|
||||
import org.jetbrains.annotations.Nullable;
|
||||
|
||||
import java.time.Instant;
|
||||
import java.util.Iterator;
|
||||
import org.jetbrains.annotations.NotNull;
|
||||
|
||||
/**
|
||||
* Handles parkour lifecycle: tick, fall, reset, cleanup.
|
||||
* Refactored GeneratorLifecycle using composition pattern.
|
||||
* Coordinates tick management, player interactions, and visual updates.
|
||||
*
|
||||
* Responsibilities delegated to:
|
||||
* - LifecycleTickManager: Tick cycle and periodic operations
|
||||
* - PlayerInteractionHandler: Block interactions and scoring
|
||||
* - LifecycleVisualUpdater: Visual updates for players
|
||||
* - LifecycleEventManager: Event firing and rewards
|
||||
* - GeneratorCleanup: Block cleanup operations
|
||||
*/
|
||||
public class GeneratorLifecycle {
|
||||
|
||||
private final ParkourGenerator generator;
|
||||
private dev.lolib.scheduler.ScheduledTask cleanupTask;
|
||||
private GhostRecorder ghostRecorder;
|
||||
private GhostManager ghostManager;
|
||||
|
||||
public GeneratorLifecycle(ParkourGenerator generator) {
|
||||
this.generator = generator;
|
||||
|
||||
if (Config.CONFIG.getBoolean("ghost-mode.enabled")) {
|
||||
this.ghostRecorder = new GhostRecorder();
|
||||
this.ghostManager = new GhostManager();
|
||||
this.ghostManager.loadGhosts(generator.getMode().getName());
|
||||
}
|
||||
private final LifecycleTickManager tickManager;
|
||||
private final PlayerInteractionHandler interactionHandler;
|
||||
private final LifecycleVisualUpdater visualUpdater;
|
||||
private final LifecycleEventManager eventManager;
|
||||
private final GeneratorCleanup cleanup;
|
||||
|
||||
public GeneratorLifecycle(@NotNull ParkourGenerator generator) {
|
||||
this.eventManager = new LifecycleEventManager(generator);
|
||||
this.cleanup = new GeneratorCleanup(generator);
|
||||
this.visualUpdater = new LifecycleVisualUpdater(generator);
|
||||
this.tickManager = new LifecycleTickManager(generator, cleanup, visualUpdater);
|
||||
this.interactionHandler = new PlayerInteractionHandler(generator, eventManager, cleanup);
|
||||
}
|
||||
|
||||
|
||||
/**
|
||||
* Start the tick cycle.
|
||||
*/
|
||||
public void startTick() {
|
||||
generator.task = Scheduler.get(LoParkour.getPlugin()).runTimer(this::tick, 0, 1);
|
||||
cleanupTask = Scheduler.get(LoParkour.getPlugin()).runTimer(this::cleanupDistantBlocks, 0, Option.CLEANUP_INTERVAL);
|
||||
|
||||
// Start ghost recording if enabled
|
||||
if (ghostRecorder != null && ghostManager != null) {
|
||||
ghostRecorder.startRecording(generator.player.getLocation());
|
||||
|
||||
// Spawn top ghosts
|
||||
if (generator.state.playerSpawn != null) {
|
||||
ghostManager.spawnGhosts(
|
||||
generator.getMode().getName(),
|
||||
generator.state.playerSpawn,
|
||||
generator.player.player.getWorld()
|
||||
);
|
||||
}
|
||||
}
|
||||
tickManager.startTick();
|
||||
}
|
||||
|
||||
|
||||
/**
|
||||
* Main tick method called every server tick.
|
||||
*/
|
||||
public void tick() {
|
||||
if (generator.state.stopped) {
|
||||
generator.task.cancel();
|
||||
if (cleanupTask != null) cleanupTask.cancel();
|
||||
if (ghostManager != null) ghostManager.stopAllGhosts();
|
||||
return;
|
||||
}
|
||||
|
||||
generator.getPlayers().forEach(p -> {
|
||||
updateVisualTime(p, p.selectedTime);
|
||||
p.updateScoreboard(generator);
|
||||
p.player.setSaturation(20);
|
||||
|
||||
// Record ghost frame
|
||||
if (ghostRecorder != null && ghostRecorder.isRecording()) {
|
||||
ghostRecorder.recordFrame(p.getLocation());
|
||||
}
|
||||
});
|
||||
generator.getSpectators().forEach(ParkourSpectator::update);
|
||||
|
||||
if (generator.player.getLocation().getWorld() != generator.state.lastStandingPlayerLocation.getWorld()) return;
|
||||
if (generator.player.getLocation().subtract(generator.state.lastStandingPlayerLocation).getY() < -10) {
|
||||
fall();
|
||||
return;
|
||||
}
|
||||
|
||||
Block below = blockBelow();
|
||||
if (below == null) return;
|
||||
|
||||
handleSchematicEndBlock(below);
|
||||
|
||||
// Clean up schematic blocks that are behind the player
|
||||
if (!generator.state.schematicBlocks.isEmpty()) {
|
||||
int currentIdx = generator.state.history.indexOf(below);
|
||||
if (currentIdx > 0) {
|
||||
generator.state.schematicBlocks.removeIf(b -> {
|
||||
int blockIdx = generator.state.history.indexOf(b);
|
||||
if (blockIdx >= 0 && blockIdx < currentIdx - 5) {
|
||||
b.setType(Material.AIR);
|
||||
return true;
|
||||
}
|
||||
return false;
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
// Reset schematic wait if player is far from schematic blocks
|
||||
if (generator.state.waitForSchematicCompletion && !generator.state.schematicBlocks.isEmpty()) {
|
||||
boolean nearSchematic = generator.state.schematicBlocks.stream()
|
||||
.anyMatch(b -> b.getLocation().distance(generator.player.getLocation()) < 15);
|
||||
if (!nearSchematic) {
|
||||
generator.state.waitForSchematicCompletion = false;
|
||||
// Force delete schematic blocks
|
||||
generator.state.schematicBlocks.forEach(b -> b.setType(Material.AIR));
|
||||
generator.state.schematicBlocks.clear();
|
||||
generator.state.deleteSchematic = false;
|
||||
generator.state.schematicCooldown = Config.GENERATION.getInt("advanced.schematic-cooldown");
|
||||
}
|
||||
}
|
||||
|
||||
if (!generator.state.history.contains(below)) return;
|
||||
|
||||
int idx = generator.state.history.indexOf(below);
|
||||
int delta = idx - generator.state.lastPositionIndexPlayer;
|
||||
if (delta <= 0) return;
|
||||
|
||||
generator.state.lastStandingPlayerLocation = generator.player.getLocation();
|
||||
|
||||
int lead = generator.profile.get("blockLead").asInt();
|
||||
if (generator.state.history.size() - idx <= lead) {
|
||||
generator.generate(lead - (generator.state.history.size() - idx));
|
||||
}
|
||||
generator.state.lastPositionIndexPlayer = idx;
|
||||
|
||||
removeTrailBlocks(idx);
|
||||
generator.placer.deleteSchematic();
|
||||
|
||||
int pts = Config.CONFIG.getBoolean("scoring.all-points") ? delta : 1;
|
||||
for (int i = 0; i < pts; i++) score();
|
||||
|
||||
if (generator.state.start == null) generator.state.start = Instant.now();
|
||||
tickManager.tick();
|
||||
interactionHandler.checkAllPlayers();
|
||||
}
|
||||
|
||||
private void handleSchematicEndBlock(Block below) {
|
||||
if (generator.state.schematicBlocks.contains(below)
|
||||
&& below.getType() == Material.RED_WOOL
|
||||
&& !generator.state.deleteSchematic) {
|
||||
|
||||
for (int i = 0; i < generator.profile.get("schematicDifficulty").asDouble() * 15; i++) {
|
||||
score();
|
||||
}
|
||||
generator.state.waitForSchematicCompletion = false;
|
||||
generator.state.schematicCooldown = Config.GENERATION.getInt("advanced.schematic-cooldown");
|
||||
generator.generate(generator.profile.get("blockLead").asInt());
|
||||
generator.state.deleteSchematic = true;
|
||||
}
|
||||
}
|
||||
|
||||
private @Nullable Block blockBelow() {
|
||||
Location loc = generator.player.getLocation().subtract(0, 1, 0);
|
||||
Block b = loc.getBlock();
|
||||
if (b.getType() == Material.AIR) {
|
||||
if (loc.subtract(0, 0.5, 0).getBlock().getType() == Material.AIR) return null;
|
||||
b = loc.getBlock();
|
||||
}
|
||||
return b;
|
||||
}
|
||||
|
||||
private void removeTrailBlocks(int idx) {
|
||||
for (int i = idx - ParkourGenerator.BLOCK_TRAIL - 1; i >= idx - 4 * ParkourGenerator.BLOCK_TRAIL; i--) {
|
||||
if (i > 0) generator.state.history.get(i).setType(Material.AIR);
|
||||
}
|
||||
}
|
||||
|
||||
private void updateVisualTime(ParkourPlayer p, int selectedTime) {
|
||||
int t = 18000 + selectedTime;
|
||||
if (t >= 24000) t -= 24000;
|
||||
p.player.setPlayerTime(t, false);
|
||||
}
|
||||
|
||||
|
||||
/**
|
||||
* Handle player scoring.
|
||||
*/
|
||||
public void score() {
|
||||
generator.state.score++;
|
||||
generator.state.totalScore++;
|
||||
checkRewards();
|
||||
new ParkourScoreEvent(generator.player).call();
|
||||
interactionHandler.handleScore();
|
||||
}
|
||||
|
||||
private void checkRewards() {
|
||||
if (!Rewards.REWARDS_ENABLED || generator.state.score == 0) return;
|
||||
|
||||
if (Rewards.SCORE_REWARDS.containsKey(generator.state.score)) {
|
||||
Rewards.SCORE_REWARDS.get(generator.state.score).forEach(r -> r.execute(generator.player, generator.getMode()));
|
||||
}
|
||||
|
||||
int intervalScore = Config.CONFIG.getBoolean("scoring.rewards-use-total-score")
|
||||
? generator.state.totalScore
|
||||
: generator.state.score;
|
||||
|
||||
for (int interval : Rewards.INTERVAL_REWARDS.keySet()) {
|
||||
if (intervalScore % interval == 0) {
|
||||
Rewards.INTERVAL_REWARDS.get(interval).forEach(r -> r.execute(generator.player, generator.getMode()));
|
||||
}
|
||||
}
|
||||
|
||||
String key = Integer.toString(generator.state.score);
|
||||
if (Rewards.ONE_TIME_REWARDS.containsKey(generator.state.score)
|
||||
&& !generator.player.collectedRewards.contains(key)) {
|
||||
Rewards.ONE_TIME_REWARDS.get(generator.state.score).forEach(r -> r.execute(generator.player, generator.getMode()));
|
||||
generator.player.collectedRewards.add(key);
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
/**
|
||||
* Handle player falling.
|
||||
*/
|
||||
public void fall() {
|
||||
new ParkourFallEvent(generator.player).call();
|
||||
|
||||
// Save ghost if score is good enough
|
||||
if (ghostRecorder != null && ghostManager != null && ghostRecorder.isRecording()) {
|
||||
String modeName = generator.getMode().getName();
|
||||
int score = generator.state.score;
|
||||
|
||||
if (ghostManager.shouldRecordGhost(modeName, score)) {
|
||||
// Pass UUID as well as name — GhostManager names files by UUID
|
||||
GhostData ghostData = ghostRecorder.stopRecording(
|
||||
generator.player.getUUID(),
|
||||
generator.player.getName(),
|
||||
score
|
||||
);
|
||||
ghostManager.saveGhost(modeName, ghostData);
|
||||
generator.player.sendTranslated("ghost.saved", Integer.toString(score));
|
||||
}
|
||||
}
|
||||
|
||||
if (ghostManager != null) {
|
||||
ghostManager.stopAllGhosts();
|
||||
}
|
||||
|
||||
generator.reset(true);
|
||||
interactionHandler.handleFall();
|
||||
}
|
||||
|
||||
protected void cleanupDistantBlocks() {
|
||||
if (generator.state.history.size() < Option.BLOCK_CLEANUP_DISTANCE * 2) return;
|
||||
|
||||
Location loc = generator.player.getLocation();
|
||||
int removed = 0;
|
||||
Iterator<Block> it = generator.state.history.iterator();
|
||||
|
||||
while (it.hasNext()) {
|
||||
Block b = it.next();
|
||||
if (b.getLocation().distance(loc) > Option.BLOCK_CLEANUP_DISTANCE) {
|
||||
b.setType(Material.AIR, false);
|
||||
it.remove();
|
||||
removed++;
|
||||
} else break;
|
||||
}
|
||||
|
||||
if (removed > 0) {
|
||||
generator.state.lastPositionIndexPlayer = Math.max(0, generator.state.lastPositionIndexPlayer - removed);
|
||||
}
|
||||
|
||||
/**
|
||||
* Reset the lifecycle state.
|
||||
*/
|
||||
public void reset(boolean regenerate) {
|
||||
tickManager.reset();
|
||||
cleanup.reset(regenerate);
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -0,0 +1,100 @@
|
|||
package dev.loki.loparkour.generator.lifecycle;
|
||||
|
||||
import dev.loki.loparkour.api.event.ParkourFallEvent;
|
||||
import dev.loki.loparkour.api.event.ParkourScoreEvent;
|
||||
import dev.loki.loparkour.generator.ParkourGenerator;
|
||||
import dev.loki.loparkour.mode.Mode;
|
||||
import dev.loki.loparkour.player.ParkourPlayer;
|
||||
import dev.loki.loparkour.reward.Reward;
|
||||
import dev.loki.loparkour.reward.Rewards;
|
||||
import org.bukkit.Bukkit;
|
||||
import org.jetbrains.annotations.NotNull;
|
||||
|
||||
import java.util.List;
|
||||
import java.util.Map;
|
||||
|
||||
/**
|
||||
* Manages lifecycle events for parkour generator.
|
||||
*/
|
||||
public class LifecycleEventManager {
|
||||
|
||||
private final ParkourGenerator generator;
|
||||
|
||||
public LifecycleEventManager(@NotNull ParkourGenerator generator) {
|
||||
this.generator = generator;
|
||||
}
|
||||
|
||||
/**
|
||||
* Handle score event and check rewards.
|
||||
*/
|
||||
public void handleScore() {
|
||||
// Fire score event for each player
|
||||
for (ParkourPlayer player : generator.getPlayers()) {
|
||||
ParkourScoreEvent scoreEvent = new ParkourScoreEvent(player);
|
||||
Bukkit.getPluginManager().callEvent(scoreEvent);
|
||||
}
|
||||
|
||||
// Check for rewards
|
||||
checkRewards();
|
||||
}
|
||||
|
||||
/**
|
||||
* Handle fall event.
|
||||
*/
|
||||
public void handleFall() {
|
||||
// Fire fall event for each player
|
||||
for (ParkourPlayer player : generator.getPlayers()) {
|
||||
ParkourFallEvent fallEvent = new ParkourFallEvent(player);
|
||||
Bukkit.getPluginManager().callEvent(fallEvent);
|
||||
}
|
||||
|
||||
// Process fall logic
|
||||
processFall();
|
||||
}
|
||||
|
||||
private void checkRewards() {
|
||||
if (!Rewards.REWARDS_ENABLED) {
|
||||
return;
|
||||
}
|
||||
|
||||
Mode mode = generator.getMode();
|
||||
|
||||
for (ParkourPlayer player : generator.getPlayers()) {
|
||||
int score = generator.state.score;
|
||||
|
||||
// Score-based rewards
|
||||
if (Rewards.SCORE_REWARDS.containsKey(score)) {
|
||||
for (Reward reward : Rewards.SCORE_REWARDS.get(score)) {
|
||||
reward.execute(player, mode);
|
||||
}
|
||||
}
|
||||
|
||||
// Interval rewards (every N blocks)
|
||||
for (Map.Entry<Integer, List<Reward>> entry : Rewards.INTERVAL_REWARDS.entrySet()) {
|
||||
if (score > 0 && score % entry.getKey() == 0) {
|
||||
for (Reward reward : entry.getValue()) {
|
||||
reward.execute(player, mode);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// One-time milestone rewards
|
||||
if (Rewards.ONE_TIME_REWARDS.containsKey(score)) {
|
||||
for (Reward reward : Rewards.ONE_TIME_REWARDS.get(score)) {
|
||||
reward.execute(player, mode);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private void processFall() {
|
||||
// Record statistics
|
||||
String time = generator.getFormattedTime();
|
||||
String difficulty = String.valueOf(generator.getDifficultyScore());
|
||||
|
||||
generator.registerScore(time, difficulty, generator.state.score);
|
||||
|
||||
// Reset generator state
|
||||
generator.reset(true);
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,78 @@
|
|||
package dev.loki.loparkour.generator.lifecycle;
|
||||
|
||||
import dev.loki.loparkour.config.Option;
|
||||
import dev.loki.loparkour.generator.ParkourGenerator;
|
||||
import org.jetbrains.annotations.NotNull;
|
||||
|
||||
/**
|
||||
* Manages tick cycle and periodic operations for generator lifecycle.
|
||||
*/
|
||||
public class LifecycleTickManager {
|
||||
|
||||
private static final int TIME_UI_INTERVAL = 20; // 1 second — time display tick
|
||||
private static final int GENERATION_LEAD = 5;
|
||||
|
||||
private final ParkourGenerator generator;
|
||||
private final GeneratorCleanup cleanup;
|
||||
private final LifecycleVisualUpdater visualUpdater;
|
||||
|
||||
private int tickCounter = 0;
|
||||
|
||||
public LifecycleTickManager(
|
||||
@NotNull ParkourGenerator generator,
|
||||
@NotNull GeneratorCleanup cleanup,
|
||||
@NotNull LifecycleVisualUpdater visualUpdater) {
|
||||
this.generator = generator;
|
||||
this.cleanup = cleanup;
|
||||
this.visualUpdater = visualUpdater;
|
||||
}
|
||||
|
||||
/**
|
||||
* Start the tick cycle.
|
||||
*/
|
||||
public void startTick() {
|
||||
if (generator.state.start == null) {
|
||||
generator.state.start = java.time.Instant.now();
|
||||
}
|
||||
|
||||
// Generate initial blocks
|
||||
generator.generate(GENERATION_LEAD);
|
||||
}
|
||||
|
||||
/**
|
||||
* Main tick method called every server tick.
|
||||
*/
|
||||
public void tick() {
|
||||
tickCounter++;
|
||||
|
||||
if (tickCounter % Option.CLEANUP_INTERVAL == 0) {
|
||||
cleanup.cleanupDistantBlocks();
|
||||
}
|
||||
if (tickCounter % TIME_UI_INTERVAL == 0) {
|
||||
visualUpdater.updateTimeDisplays();
|
||||
}
|
||||
|
||||
// Generate more blocks if needed
|
||||
maintainBlockLead();
|
||||
|
||||
// Update player visuals
|
||||
visualUpdater.updateAllPlayers();
|
||||
}
|
||||
|
||||
/**
|
||||
* Reset tick counter.
|
||||
*/
|
||||
public void reset() {
|
||||
tickCounter = 0;
|
||||
}
|
||||
|
||||
private void maintainBlockLead() {
|
||||
int currentBlocks = generator.state.history.size();
|
||||
int targetBlocks = generator.state.score + GENERATION_LEAD;
|
||||
|
||||
if (currentBlocks < targetBlocks) {
|
||||
int needed = targetBlocks - currentBlocks;
|
||||
generator.generate(needed);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,50 @@
|
|||
package dev.loki.loparkour.generator.lifecycle;
|
||||
|
||||
import dev.loki.loparkour.generator.ParkourGenerator;
|
||||
import dev.loki.loparkour.player.ParkourPlayer;
|
||||
import org.jetbrains.annotations.NotNull;
|
||||
|
||||
/**
|
||||
* Manages visual updates for players during parkour lifecycle.
|
||||
*/
|
||||
public class LifecycleVisualUpdater {
|
||||
|
||||
private final ParkourGenerator generator;
|
||||
|
||||
public LifecycleVisualUpdater(@NotNull ParkourGenerator generator) {
|
||||
this.generator = generator;
|
||||
}
|
||||
|
||||
/**
|
||||
* Update visuals for all players.
|
||||
*/
|
||||
public void updateAllPlayers() {
|
||||
for (ParkourPlayer player : generator.getPlayers()) {
|
||||
updatePlayerVisuals(player);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Update time displays for all players.
|
||||
*/
|
||||
public void updateTimeDisplays() {
|
||||
for (ParkourPlayer player : generator.getPlayers()) {
|
||||
updatePlayerTimeDisplay(player);
|
||||
}
|
||||
}
|
||||
|
||||
private void updatePlayerVisuals(@NotNull ParkourPlayer player) {
|
||||
// Update scoreboard if enabled
|
||||
if (player.showScoreboard != null && player.showScoreboard) {
|
||||
player.updateScoreboard(generator);
|
||||
}
|
||||
|
||||
// Update action bar with time and score
|
||||
updatePlayerTimeDisplay(player);
|
||||
}
|
||||
|
||||
@SuppressWarnings("unused") // reserved for action bar
|
||||
private void updatePlayerTimeDisplay(@NotNull ParkourPlayer player) {
|
||||
// Placeholder for action bar (Paper adventure); scoreboard already shows time/score.
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,103 @@
|
|||
package dev.loki.loparkour.generator.lifecycle;
|
||||
|
||||
import dev.loki.loparkour.generator.ParkourGenerator;
|
||||
import dev.loki.loparkour.player.ParkourPlayer;
|
||||
import dev.loki.loparkour.schematic.lpschem.LPSchematic;
|
||||
import org.bukkit.Location;
|
||||
import org.bukkit.Material;
|
||||
import org.bukkit.block.Block;
|
||||
import org.jetbrains.annotations.NotNull;
|
||||
import org.jetbrains.annotations.Nullable;
|
||||
|
||||
/**
|
||||
* Handles player interactions with parkour blocks.
|
||||
*/
|
||||
public class PlayerInteractionHandler {
|
||||
|
||||
private static final int GENERATION_LEAD = 5;
|
||||
|
||||
private final ParkourGenerator generator;
|
||||
private final LifecycleEventManager eventManager;
|
||||
private final GeneratorCleanup cleanup;
|
||||
|
||||
public PlayerInteractionHandler(
|
||||
@NotNull ParkourGenerator generator,
|
||||
@NotNull LifecycleEventManager eventManager,
|
||||
@NotNull GeneratorCleanup cleanup) {
|
||||
this.generator = generator;
|
||||
this.eventManager = eventManager;
|
||||
this.cleanup = cleanup;
|
||||
}
|
||||
|
||||
/**
|
||||
* Check all players for block interactions.
|
||||
*/
|
||||
public void checkAllPlayers() {
|
||||
for (ParkourPlayer player : generator.getPlayers()) {
|
||||
Block below = getBlockBelow(player);
|
||||
if (below != null) {
|
||||
handleBlockInteraction(player, below);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Handle player scoring on a block.
|
||||
*/
|
||||
public void handleScore() {
|
||||
generator.state.score++;
|
||||
eventManager.handleScore();
|
||||
|
||||
// Generate more blocks after scoring
|
||||
generator.generate(1);
|
||||
}
|
||||
|
||||
/**
|
||||
* Handle player falling.
|
||||
*/
|
||||
public void handleFall() {
|
||||
eventManager.handleFall();
|
||||
}
|
||||
|
||||
private void handleBlockInteraction(@NotNull ParkourPlayer player, @NotNull Block block) {
|
||||
// Check if it's a schematic end block
|
||||
if (isSchematicEndBlock(block)) {
|
||||
handleSchematicEndBlock(block);
|
||||
}
|
||||
|
||||
// Check for scoring
|
||||
if (generator.state.history.contains(block) && !player.hasScored(block)) {
|
||||
player.markScored(block);
|
||||
handleScore();
|
||||
}
|
||||
}
|
||||
|
||||
private void handleSchematicEndBlock(@NotNull Block block) {
|
||||
// Remove schematic blocks and continue with normal generation
|
||||
if (generator.state.schematicBlocks != null) {
|
||||
cleanup.removeTrailBlocks(0); // Remove schematic trail
|
||||
generator.state.schematicBlocks = null;
|
||||
}
|
||||
|
||||
// Continue normal generation
|
||||
generator.generate(GENERATION_LEAD);
|
||||
}
|
||||
|
||||
private boolean isSchematicEndBlock(@NotNull Block block) {
|
||||
LPSchematic schematic = generator.island.schematic;
|
||||
if (schematic == null) return false;
|
||||
|
||||
// Simplified check - would need full schematic logic
|
||||
return generator.state.schematicBlocks != null &&
|
||||
generator.state.schematicBlocks.contains(block);
|
||||
}
|
||||
|
||||
/** Block directly under feet; {@code null} in air — scoring runs when landed. */
|
||||
@Nullable
|
||||
private Block getBlockBelow(@NotNull ParkourPlayer player) {
|
||||
Location loc = player.player.getLocation().clone();
|
||||
Block below = loc.subtract(0, 1, 0).getBlock();
|
||||
|
||||
return below.getType() != Material.AIR ? below : null;
|
||||
}
|
||||
}
|
||||
|
|
@ -6,8 +6,6 @@ import org.bukkit.Bukkit;
|
|||
import org.bukkit.entity.Player;
|
||||
import org.bukkit.plugin.RegisteredServiceProvider;
|
||||
|
||||
import java.util.NoSuchElementException;
|
||||
|
||||
public class VaultHook {
|
||||
|
||||
private static Economy economy;
|
||||
|
|
|
|||
|
|
@ -1,9 +1,5 @@
|
|||
package dev.loki.loparkour.leaderboard;
|
||||
|
||||
import java.util.ArrayList;
|
||||
|
||||
import dev.lolib.scheduler.Scheduler;
|
||||
|
||||
import dev.loki.loparkour.LoParkour;
|
||||
import dev.loki.loparkour.config.Config;
|
||||
import dev.loki.loparkour.storage.Storage;
|
||||
|
|
@ -29,9 +25,9 @@ public class Leaderboard {
|
|||
public final Sort sort;
|
||||
|
||||
/**
|
||||
* A map of all scores for this mode
|
||||
* A map of all scores for this mode (thread-safe)
|
||||
*/
|
||||
public final Map<UUID, Score> scores = new LinkedHashMap<>();
|
||||
public final Map<UUID, Score> scores = Collections.synchronizedMap(new LinkedHashMap<>());
|
||||
|
||||
public Leaderboard(@NotNull String mode, Sort sort) {
|
||||
this.mode = mode.toLowerCase();
|
||||
|
|
@ -45,9 +41,8 @@ public class Leaderboard {
|
|||
|
||||
var interval = Config.CONFIG.getInt("storage-update-interval");
|
||||
|
||||
// TODO: Fix scheduler call - needs proper implementation
|
||||
// read/write all data every x seconds after x seconds to allow time for reading/writing
|
||||
Scheduler.get(LoParkour.getPlugin()).runTimerAsync(() -> {
|
||||
// Main-thread timer: only schedules I/O; avoids async + Bukkit edge cases
|
||||
Scheduler.get(LoParkour.getPlugin()).runTimer(() -> {
|
||||
if (Config.CONFIG.getBoolean("joining")) {
|
||||
write(true);
|
||||
} else {
|
||||
|
|
@ -68,8 +63,13 @@ public class Leaderboard {
|
|||
*/
|
||||
public void read(boolean async) {
|
||||
run(() -> {
|
||||
scores.clear();
|
||||
scores.putAll(Storage.readScores(mode));
|
||||
Map<UUID, Score> loadedScores = Storage.readScores(mode);
|
||||
|
||||
// Synchronize access to prevent concurrent modification
|
||||
synchronized (scores) {
|
||||
scores.clear();
|
||||
scores.putAll(loadedScores);
|
||||
}
|
||||
|
||||
sort();
|
||||
}, async);
|
||||
|
|
@ -91,7 +91,12 @@ public class Leaderboard {
|
|||
public Map<UUID, Score> sort(Sort sort) {
|
||||
LinkedHashMap<UUID, Score> sorted = new LinkedHashMap<>();
|
||||
|
||||
scores.entrySet().stream()
|
||||
List<Map.Entry<UUID, Score>> snapshot;
|
||||
synchronized (scores) {
|
||||
snapshot = new ArrayList<>(scores.entrySet());
|
||||
}
|
||||
|
||||
snapshot.stream()
|
||||
.sorted((one, two) -> {
|
||||
switch (sort) {
|
||||
case SCORE -> {
|
||||
|
|
@ -122,8 +127,11 @@ public class Leaderboard {
|
|||
private void sort() {
|
||||
var sorted = sort(sort);
|
||||
|
||||
scores.clear();
|
||||
scores.putAll(sorted);
|
||||
// Synchronize access to prevent concurrent modification
|
||||
synchronized (scores) {
|
||||
scores.clear();
|
||||
scores.putAll(sorted);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
|
|
@ -157,7 +165,11 @@ public class Leaderboard {
|
|||
* Resets all registered scores for this mode
|
||||
*/
|
||||
public void resetAll() {
|
||||
new HashSet<>(scores.keySet()).forEach(this::remove);
|
||||
List<UUID> keys;
|
||||
synchronized (scores) {
|
||||
keys = new ArrayList<>(scores.keySet());
|
||||
}
|
||||
keys.forEach(this::remove);
|
||||
}
|
||||
|
||||
/**
|
||||
|
|
@ -174,7 +186,11 @@ public class Leaderboard {
|
|||
* @return The rank. Starts from 1. Returns 0 if no ranking is found.
|
||||
*/
|
||||
public int getRank(@NotNull UUID uuid) {
|
||||
return new ArrayList<>(scores.keySet()).indexOf(uuid) + 1;
|
||||
List<UUID> keys;
|
||||
synchronized (scores) {
|
||||
keys = new ArrayList<>(scores.keySet());
|
||||
}
|
||||
return keys.indexOf(uuid) + 1;
|
||||
}
|
||||
|
||||
/**
|
||||
|
|
@ -186,11 +202,14 @@ public class Leaderboard {
|
|||
*/
|
||||
@Nullable
|
||||
public Score getScoreAtRank(int rank) {
|
||||
if (scores.size() < rank) {
|
||||
return null;
|
||||
List<Score> values;
|
||||
synchronized (scores) {
|
||||
if (scores.size() < rank) {
|
||||
return null;
|
||||
}
|
||||
values = new ArrayList<>(scores.values());
|
||||
}
|
||||
|
||||
return new ArrayList<>(scores.values()).get(rank - 1);
|
||||
return values.get(rank - 1);
|
||||
}
|
||||
|
||||
public enum Sort {
|
||||
|
|
|
|||
|
|
@ -83,14 +83,14 @@ public class ParkourRestrictionListener implements Listener {
|
|||
&& event.getHand() == org.bukkit.inventory.EquipmentSlot.HAND;
|
||||
if (!isRightClick) return;
|
||||
|
||||
org.bukkit.Material held = heldItem(player).getType();
|
||||
ItemStack held = heldItem(player);
|
||||
event.setCancelled(true);
|
||||
|
||||
if (held == Locales.getItem(player, "play.item").build().getType()) Menus.PLAY.open(player);
|
||||
else if (held == Locales.getItem(player, "community.item").build().getType()) Menus.COMMUNITY.open(player);
|
||||
else if (held == Locales.getItem(player, "settings.item").build().getType()) Menus.SETTINGS.open(player);
|
||||
else if (held == Locales.getItem(player, "lobby.item").build().getType()) Menus.LOBBY.open(player);
|
||||
else if (held == Locales.getItem(player, "other.quit").build().getType()) ParkourUser.leave(player);
|
||||
if (matchesLocaleHotbar(player, "play.item", held)) Menus.PLAY.open(player);
|
||||
else if (matchesLocaleHotbar(player, "community.item", held)) Menus.COMMUNITY.open(player);
|
||||
else if (matchesLocaleHotbar(player, "settings.item", held)) Menus.SETTINGS.open(player);
|
||||
else if (matchesLocaleHotbar(player, "lobby.item", held)) Menus.LOBBY.open(player);
|
||||
else if (matchesLocaleHotbar(player, "other.quit", held)) ParkourUser.leave(player);
|
||||
else if (!Config.CONFIG.getBoolean("options.disable-inventory-blocks")) event.setCancelled(false);
|
||||
}
|
||||
|
||||
|
|
@ -130,4 +130,10 @@ public class ParkourRestrictionListener implements Listener {
|
|||
? inv.getItemInOffHand()
|
||||
: inv.getItemInMainHand();
|
||||
}
|
||||
|
||||
/** Matches configured hotbar item (material + meta), not material alone. */
|
||||
private boolean matchesLocaleHotbar(Player player, String localeKey, ItemStack held) {
|
||||
ItemStack ref = Locales.getItem(player, localeKey).build();
|
||||
return held != null && held.getType() != org.bukkit.Material.AIR && held.isSimilar(ref);
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -27,7 +27,7 @@ public abstract class DynamicMenu {
|
|||
* @param item The item
|
||||
* @param shouldDisplay Whether this item should be displayed right now.
|
||||
*/
|
||||
// TODO: Migrate to LoLib GUI system
|
||||
// Stub: migrate to LoLib GUI when dynamic rows are wired up.
|
||||
/*
|
||||
public void registerMainItem(int row, int id, BiFunction<@NotNull Player, @Nullable ParkourUser, dev.loki.loparkour.util.MenuItem> item, Predicate<Player> shouldDisplay) {
|
||||
if (id < 0 || row < 0 || row > 4) {
|
||||
|
|
@ -50,7 +50,7 @@ public abstract class DynamicMenu {
|
|||
* @param player The player
|
||||
* @param menu The menu
|
||||
*/
|
||||
// TODO: Migrate to LoLib GUI system
|
||||
// Stub: migrate to LoLib GUI when dynamic rows are wired up.
|
||||
/*
|
||||
public void display(Player player, Menu menu) {
|
||||
ParkourUser user = ParkourUser.getUser(player);
|
||||
|
|
|
|||
|
|
@ -26,7 +26,8 @@ public abstract class LPMenu {
|
|||
|
||||
protected String locale(@NotNull Player player) {
|
||||
var user = dev.loki.loparkour.player.ParkourUser.getUser(player);
|
||||
return user != null ? user.locale : Option.OPTIONS_DEFAULTS.get(ParkourOption.LANG);
|
||||
String loc = user != null ? user.locale : Option.OPTIONS_DEFAULTS.get(ParkourOption.LANG);
|
||||
return (loc != null && !loc.isBlank()) ? loc : "en";
|
||||
}
|
||||
|
||||
protected ItemStack filler() {
|
||||
|
|
|
|||
|
|
@ -3,7 +3,6 @@ package dev.loki.loparkour.menu;
|
|||
import dev.loki.loparkour.config.Config;
|
||||
import dev.loki.loparkour.config.Locales;
|
||||
import dev.loki.loparkour.player.ParkourUser;
|
||||
import dev.loki.loparkour.util.ColorUtil;
|
||||
import dev.lolib.gui.InventoryGUI;
|
||||
import org.bukkit.entity.Player;
|
||||
import org.bukkit.inventory.ItemStack;
|
||||
|
|
|
|||
|
|
@ -1,34 +1,8 @@
|
|||
package dev.loki.loparkour.menu;
|
||||
|
||||
import dev.loki.loparkour.leaderboard.Leaderboard;
|
||||
import dev.loki.loparkour.mode.Mode;
|
||||
import dev.loki.loparkour.player.ParkourPlayer;
|
||||
import org.bukkit.entity.Player;
|
||||
|
||||
/**
|
||||
* Temporary stub for menu system during migration
|
||||
* TODO: Implement proper menu system using LoLib GUI or custom implementation
|
||||
* Placeholder during menu migration; real flows use {@link Menus} / LoLib GUI.
|
||||
*/
|
||||
public class MenuStub {
|
||||
|
||||
// TODO: Migrate to LoLib GUI system
|
||||
/*
|
||||
public void open(Player player) {
|
||||
player.sendMessage("§cМеню временно недоступно во время миграции");
|
||||
}
|
||||
*/
|
||||
|
||||
// TODO: Migrate to LoLib GUI system
|
||||
/*
|
||||
public void open(Player player, Mode mode, Leaderboard.Sort sort) {
|
||||
player.sendMessage("§cМеню временно недоступно во время миграции");
|
||||
}
|
||||
*/
|
||||
|
||||
// TODO: Migrate to LoLib GUI system
|
||||
/*
|
||||
public void open(ParkourPlayer player) {
|
||||
player.player.sendMessage("§cМеню временно недоступно во время миграции");
|
||||
}
|
||||
*/
|
||||
public final class MenuStub {
|
||||
private MenuStub() {}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -7,8 +7,7 @@ import dev.loki.loparkour.menu.LPMenu;
|
|||
import dev.loki.loparkour.menu.Menus;
|
||||
import dev.loki.loparkour.menu.ParkourOption;
|
||||
import dev.loki.loparkour.mode.Mode;
|
||||
import dev.loki.loparkour.util.ColorUtil;
|
||||
import dev.lolib.gui.ScrollableGUI;
|
||||
import dev.lolib.gui.InventoryGUI;
|
||||
import org.bukkit.entity.Player;
|
||||
import org.bukkit.inventory.ItemStack;
|
||||
import org.jetbrains.annotations.NotNull;
|
||||
|
|
@ -18,8 +17,16 @@ import java.util.List;
|
|||
|
||||
public class LeaderboardsMenu extends LPMenu {
|
||||
|
||||
private static final int ITEMS_PER_PAGE = 7; // Slots 10-16 (middle row)
|
||||
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;
|
||||
String locale = locale(player);
|
||||
String title = Locales.getString(locale, ParkourOption.LEADERBOARDS.path + ".name");
|
||||
|
||||
|
|
@ -39,18 +46,32 @@ public class LeaderboardsMenu extends LPMenu {
|
|||
return;
|
||||
}
|
||||
|
||||
ScrollableGUI gui = ScrollableGUI.create()
|
||||
.title(ColorUtil.color(title))
|
||||
.rows(3)
|
||||
.scrollUpButton(backItem(), 0)
|
||||
.scrollDownButton(nextItem(), 8);
|
||||
// Calculate pagination
|
||||
int totalPages = (int) Math.ceil((double) items.size() / ITEMS_PER_PAGE);
|
||||
int startIndex = currentPage * ITEMS_PER_PAGE;
|
||||
int endIndex = Math.min(startIndex + ITEMS_PER_PAGE, items.size());
|
||||
|
||||
for (int i = 0; i < items.size(); i++) {
|
||||
InventoryGUI gui = baseGui(title, 3);
|
||||
|
||||
// Add items for current page
|
||||
for (int i = startIndex; i < endIndex; i++) {
|
||||
final Mode mode = modes.get(i);
|
||||
gui = gui.addItem(items.get(i),
|
||||
int slot = 10 + (i - startIndex); // Slots 10-16
|
||||
gui = gui.setItem(slot, items.get(i),
|
||||
e -> Menus.SINGLE_LEADERBOARD.open(player, mode, Leaderboard.Sort.SCORE));
|
||||
}
|
||||
|
||||
// Add navigation buttons
|
||||
if (currentPage > 0) {
|
||||
// Previous page button
|
||||
gui = gui.setItem(0, backItem(), e -> open(player, currentPage - 1));
|
||||
}
|
||||
|
||||
if (currentPage < totalPages - 1) {
|
||||
// Next page button
|
||||
gui = gui.setItem(8, nextItem(), e -> open(player, currentPage + 1));
|
||||
}
|
||||
|
||||
gui.open(player);
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -7,8 +7,7 @@ import dev.loki.loparkour.menu.LPMenu;
|
|||
import dev.loki.loparkour.menu.ParkourOption;
|
||||
import dev.loki.loparkour.mode.Mode;
|
||||
import dev.loki.loparkour.player.ParkourUser;
|
||||
import dev.loki.loparkour.util.ColorUtil;
|
||||
import dev.lolib.gui.ScrollableGUI;
|
||||
import dev.lolib.gui.InventoryGUI;
|
||||
import org.bukkit.Material;
|
||||
import org.bukkit.OfflinePlayer;
|
||||
import org.bukkit.entity.Player;
|
||||
|
|
@ -24,7 +23,15 @@ import java.util.UUID;
|
|||
|
||||
public class SingleLeaderboardMenu extends LPMenu {
|
||||
|
||||
private static final int ITEMS_PER_PAGE = 7; // Slots 10-16 (middle row)
|
||||
private int currentPage = 0;
|
||||
|
||||
public void open(@NotNull Player player, @NotNull Mode mode, @NotNull Leaderboard.Sort sort) {
|
||||
open(player, mode, sort, 0);
|
||||
}
|
||||
|
||||
public void open(@NotNull Player player, @NotNull Mode mode, @NotNull Leaderboard.Sort sort, int page) {
|
||||
this.currentPage = page;
|
||||
Leaderboard leaderboard = mode.getLeaderboard();
|
||||
if (leaderboard == null) return;
|
||||
|
||||
|
|
@ -66,14 +73,28 @@ public class SingleLeaderboardMenu extends LPMenu {
|
|||
items.add(skull);
|
||||
}
|
||||
|
||||
ScrollableGUI gui = ScrollableGUI.create()
|
||||
.title(ColorUtil.color(title))
|
||||
.rows(3)
|
||||
.scrollUpButton(backItem(), 0)
|
||||
.scrollDownButton(nextItem(), 8);
|
||||
// Calculate pagination
|
||||
int totalPages = (int) Math.ceil((double) items.size() / ITEMS_PER_PAGE);
|
||||
int startIndex = currentPage * ITEMS_PER_PAGE;
|
||||
int endIndex = Math.min(startIndex + ITEMS_PER_PAGE, items.size());
|
||||
|
||||
for (ItemStack item : items) {
|
||||
gui = gui.addItem(item, e -> { /* view only */ });
|
||||
InventoryGUI gui = baseGui(title, 3);
|
||||
|
||||
// Add items for current page
|
||||
for (int i = startIndex; i < endIndex; i++) {
|
||||
int slot = 10 + (i - startIndex); // Slots 10-16
|
||||
gui = gui.setItem(slot, items.get(i), e -> { /* view only */ });
|
||||
}
|
||||
|
||||
// Add navigation buttons
|
||||
if (currentPage > 0) {
|
||||
// Previous page button
|
||||
gui = gui.setItem(0, backItem(), e -> open(player, mode, sort, currentPage - 1));
|
||||
}
|
||||
|
||||
if (currentPage < totalPages - 1) {
|
||||
// Next page button
|
||||
gui = gui.setItem(8, nextItem(), e -> open(player, mode, sort, currentPage + 1));
|
||||
}
|
||||
|
||||
gui.open(player);
|
||||
|
|
|
|||
|
|
@ -4,7 +4,6 @@ import dev.loki.loparkour.config.Locales;
|
|||
import dev.loki.loparkour.menu.LPMenu;
|
||||
import dev.loki.loparkour.menu.Menus;
|
||||
import dev.loki.loparkour.menu.ParkourOption;
|
||||
import dev.lolib.gui.InventoryGUI;
|
||||
import org.bukkit.entity.Player;
|
||||
import org.jetbrains.annotations.NotNull;
|
||||
|
||||
|
|
|
|||
|
|
@ -5,23 +5,27 @@ import dev.loki.loparkour.config.Config;
|
|||
import dev.loki.loparkour.config.Locales;
|
||||
import dev.loki.loparkour.menu.LPMenu;
|
||||
import dev.loki.loparkour.mode.Mode;
|
||||
import dev.loki.loparkour.mode.MultiMode;
|
||||
import dev.loki.loparkour.player.ParkourUser;
|
||||
import dev.loki.loparkour.util.ColorUtil;
|
||||
import dev.lolib.gui.ScrollableGUI;
|
||||
import dev.lolib.gui.InventoryGUI;
|
||||
import org.bukkit.entity.Player;
|
||||
import org.bukkit.inventory.ItemStack;
|
||||
import org.jetbrains.annotations.NotNull;
|
||||
|
||||
import java.time.Duration;
|
||||
import java.time.Instant;
|
||||
import java.util.ArrayList;
|
||||
import java.util.List;
|
||||
|
||||
public class SingleMenu extends LPMenu {
|
||||
|
||||
private static final int ITEMS_PER_PAGE = 7; // Slots 10-16 (middle row)
|
||||
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;
|
||||
String locale = locale(player);
|
||||
|
||||
List<Mode> availableModes = new ArrayList<>();
|
||||
|
|
@ -32,7 +36,7 @@ public class SingleMenu extends LPMenu {
|
|||
if (mode.getName().equals("spectator")) continue;
|
||||
|
||||
// Check if mode is enabled in config
|
||||
if (!Config.CONFIG.getBoolean("modes." + mode.getName() + ".enabled")) continue;
|
||||
if (!Config.CONFIG.getBoolean("modes." + mode.getName() + ".enabled", true)) continue;
|
||||
|
||||
boolean blocked = Config.CONFIG.getBoolean("permissions.enabled")
|
||||
&& !player.hasPermission("LoParkour.gamemode." + mode.getName());
|
||||
|
|
@ -52,15 +56,18 @@ public class SingleMenu extends LPMenu {
|
|||
|
||||
String title = Locales.getString(locale, "play.single.name");
|
||||
|
||||
ScrollableGUI gui = ScrollableGUI.create()
|
||||
.title(ColorUtil.color(title))
|
||||
.rows(3)
|
||||
.scrollUpButton(backItem(), 0)
|
||||
.scrollDownButton(nextItem(), 8);
|
||||
// Calculate pagination
|
||||
int totalPages = (int) Math.ceil((double) items.size() / ITEMS_PER_PAGE);
|
||||
int startIndex = currentPage * ITEMS_PER_PAGE;
|
||||
int endIndex = Math.min(startIndex + ITEMS_PER_PAGE, items.size());
|
||||
|
||||
for (int i = 0; i < items.size(); i++) {
|
||||
InventoryGUI gui = baseGui(title, 3);
|
||||
|
||||
// Add items for current page
|
||||
for (int i = startIndex; i < endIndex; i++) {
|
||||
final Mode mode = availableModes.get(i);
|
||||
gui = gui.addItem(items.get(i), e -> {
|
||||
int slot = 10 + (i - startIndex); // Slots 10-16
|
||||
gui = gui.setItem(slot, items.get(i), e -> {
|
||||
// Prevent multiple clicks - check if player is already in parkour
|
||||
if (ParkourUser.getUser(player) != null) {
|
||||
return; // Already in parkour, ignore click
|
||||
|
|
@ -71,6 +78,17 @@ public class SingleMenu extends LPMenu {
|
|||
});
|
||||
}
|
||||
|
||||
// Add navigation buttons
|
||||
if (currentPage > 0) {
|
||||
// Previous page button
|
||||
gui = gui.setItem(0, backItem(), e -> open(player, currentPage - 1));
|
||||
}
|
||||
|
||||
if (currentPage < totalPages - 1) {
|
||||
// Next page button
|
||||
gui = gui.setItem(8, nextItem(), e -> open(player, currentPage + 1));
|
||||
}
|
||||
|
||||
gui.open(player);
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -5,9 +5,8 @@ import dev.loki.loparkour.menu.LPMenu;
|
|||
import dev.loki.loparkour.mode.Modes;
|
||||
import dev.loki.loparkour.player.ParkourUser;
|
||||
import dev.loki.loparkour.session.Session;
|
||||
import dev.loki.loparkour.util.ColorUtil;
|
||||
import dev.loki.loparkour.world.Divider;
|
||||
import dev.lolib.gui.ScrollableGUI;
|
||||
import dev.lolib.gui.InventoryGUI;
|
||||
import org.bukkit.Material;
|
||||
import org.bukkit.entity.Player;
|
||||
import org.bukkit.inventory.ItemStack;
|
||||
|
|
@ -19,8 +18,16 @@ import java.util.List;
|
|||
|
||||
public class SpectatorMenu extends LPMenu {
|
||||
|
||||
private static final int ITEMS_PER_PAGE = 7; // Slots 10-16 (middle row)
|
||||
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;
|
||||
String locale = locale(player);
|
||||
ParkourUser user = ParkourUser.getUser(player);
|
||||
String title = Locales.getString(locale, "play.spectator.name");
|
||||
|
|
@ -44,15 +51,29 @@ public class SpectatorMenu extends LPMenu {
|
|||
items.add(skull);
|
||||
}
|
||||
|
||||
ScrollableGUI gui = ScrollableGUI.create()
|
||||
.title(ColorUtil.color(title))
|
||||
.rows(3)
|
||||
.scrollUpButton(backItem(), 0)
|
||||
.scrollDownButton(nextItem(), 8);
|
||||
// Calculate pagination
|
||||
int totalPages = (int) Math.ceil((double) items.size() / ITEMS_PER_PAGE);
|
||||
int startIndex = currentPage * ITEMS_PER_PAGE;
|
||||
int endIndex = Math.min(startIndex + ITEMS_PER_PAGE, items.size());
|
||||
|
||||
for (int i = 0; i < items.size(); i++) {
|
||||
InventoryGUI gui = baseGui(title, 3);
|
||||
|
||||
// Add items for current page
|
||||
for (int i = startIndex; i < endIndex; i++) {
|
||||
final Session session = sessions.get(i);
|
||||
gui = gui.addItem(items.get(i), e -> Modes.SPECTATOR.create(player, session));
|
||||
int slot = 10 + (i - startIndex); // Slots 10-16
|
||||
gui = gui.setItem(slot, items.get(i), e -> Modes.SPECTATOR.create(player, session));
|
||||
}
|
||||
|
||||
// Add navigation buttons
|
||||
if (currentPage > 0) {
|
||||
// Previous page button
|
||||
gui = gui.setItem(0, backItem(), e -> open(player, currentPage - 1));
|
||||
}
|
||||
|
||||
if (currentPage < totalPages - 1) {
|
||||
// Next page button
|
||||
gui = gui.setItem(8, nextItem(), e -> open(player, currentPage + 1));
|
||||
}
|
||||
|
||||
gui.open(player);
|
||||
|
|
|
|||
|
|
@ -30,7 +30,7 @@ public class LangMenu extends LPMenu {
|
|||
String locale = pp.locale;
|
||||
String title = Locales.getString(locale, "settings.lang.name");
|
||||
|
||||
List<String> langs = new ArrayList<>(Locales.locales.keySet());
|
||||
List<String> langs = new ArrayList<>(Locales.getLocaleKeys());
|
||||
|
||||
InventoryGUI gui = baseGui(title, 3);
|
||||
|
||||
|
|
|
|||
|
|
@ -147,7 +147,6 @@ public class ParkourSettingsMenu extends LPMenu {
|
|||
String locale = pp.locale;
|
||||
String title = Locales.getString(locale, "settings.parkour_settings.styles.name");
|
||||
|
||||
List<Style> styles = new ArrayList<>();
|
||||
InventoryGUI gui = baseGui(title, 3);
|
||||
|
||||
int slot = 10;
|
||||
|
|
|
|||
|
|
@ -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) {
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
}
|
||||
}
|
||||
|
|
@ -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);
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
|
||||
|
|
|
|||
|
|
@ -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; }
|
||||
}
|
||||
140
src/main/java/dev/loki/loparkour/mode/elytra/ElytraPhysics.java
Normal file
140
src/main/java/dev/loki/loparkour/mode/elytra/ElytraPhysics.java
Normal 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; }
|
||||
}
|
||||
}
|
||||
101
src/main/java/dev/loki/loparkour/mode/elytra/ElytraRenderer.java
Normal file
101
src/main/java/dev/loki/loparkour/mode/elytra/ElytraRenderer.java
Normal 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();
|
||||
}
|
||||
}
|
||||
}
|
||||
80
src/main/java/dev/loki/loparkour/mode/elytra/ElytraRing.java
Normal file
80
src/main/java/dev/loki/loparkour/mode/elytra/ElytraRing.java
Normal 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; }
|
||||
}
|
||||
|
|
@ -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();
|
||||
}
|
||||
}
|
||||
|
|
@ -1,9 +1,5 @@
|
|||
package dev.loki.loparkour.player;
|
||||
|
||||
import java.util.ArrayList;
|
||||
|
||||
import dev.lolib.scheduler.Scheduler;
|
||||
|
||||
import com.google.gson.annotations.Expose;
|
||||
import dev.loki.loparkour.LoParkour;
|
||||
import dev.loki.loparkour.config.Config;
|
||||
|
|
@ -17,10 +13,10 @@ import dev.loki.loparkour.player.data.PreviousData;
|
|||
import dev.loki.loparkour.session.Session;
|
||||
import dev.loki.loparkour.storage.Storage;
|
||||
import dev.loki.loparkour.world.Divider;
|
||||
|
||||
import dev.lolib.scheduler.Scheduler;
|
||||
import org.bukkit.GameMode;
|
||||
import org.bukkit.Location;
|
||||
import org.bukkit.block.Block;
|
||||
import org.bukkit.entity.Player;
|
||||
import org.bukkit.potion.PotionEffect;
|
||||
import org.jetbrains.annotations.NotNull;
|
||||
|
|
@ -38,6 +34,9 @@ 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<>();
|
||||
|
||||
public @Expose Double schematicDifficulty;
|
||||
public @Expose Integer blockLead;
|
||||
public @Expose Boolean particles;
|
||||
|
|
@ -68,10 +67,6 @@ public class ParkourPlayer extends ParkourUser {
|
|||
}
|
||||
}
|
||||
|
||||
private static boolean parseBoolean(String string) {
|
||||
return string == null || string.equals("1") || string.equals("true");
|
||||
}
|
||||
|
||||
/**
|
||||
* @param player The player.
|
||||
* @return True when this player is a {@link ParkourPlayer}, false if not.
|
||||
|
|
@ -111,10 +106,27 @@ public class ParkourPlayer extends ParkourUser {
|
|||
}
|
||||
|
||||
session.removePlayers(this);
|
||||
|
||||
// Clear scored blocks to prevent memory leak
|
||||
scoredBlocks.clear();
|
||||
|
||||
save(LoParkour.getPlugin().isEnabled());
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if player has already scored on this block.
|
||||
*/
|
||||
public boolean hasScored(Block block) {
|
||||
return scoredBlocks.contains(block);
|
||||
}
|
||||
|
||||
/**
|
||||
* Mark block as scored to prevent duplicate scoring.
|
||||
*/
|
||||
public void markScored(Block block) {
|
||||
scoredBlocks.add(block);
|
||||
}
|
||||
|
||||
/**
|
||||
* Sets the user's settings. If an item is not included, the setting gets reset.
|
||||
*
|
||||
|
|
@ -140,7 +152,8 @@ public class ParkourPlayer extends ParkourUser {
|
|||
.set("selectedTime", selectedTime.toString())
|
||||
.set("style", style);
|
||||
|
||||
generator.overrideProfile();
|
||||
// Use profileManager instead of calling overrideProfile directly on generator
|
||||
generator.profileManager.overrideProfile();
|
||||
}
|
||||
|
||||
/**
|
||||
|
|
|
|||
|
|
@ -1,7 +1,5 @@
|
|||
package dev.loki.loparkour.player;
|
||||
|
||||
import dev.lolib.scheduler.Scheduler;
|
||||
|
||||
import dev.loki.loparkour.LoParkour;
|
||||
import dev.loki.loparkour.api.event.ParkourSpectateEvent;
|
||||
import dev.loki.loparkour.config.Locales;
|
||||
|
|
@ -9,11 +7,11 @@ import dev.loki.loparkour.player.data.PreviousData;
|
|||
import dev.loki.loparkour.session.Session;
|
||||
import dev.loki.loparkour.util.ColorUtil;
|
||||
import dev.lolib.scheduler.Scheduler;
|
||||
import dev.lolib.scheduler.ScheduledTask;
|
||||
import net.md_5.bungee.api.ChatMessageType;
|
||||
import net.md_5.bungee.api.chat.TextComponent;
|
||||
import org.bukkit.GameMode;
|
||||
import org.bukkit.entity.Player;
|
||||
import dev.lolib.scheduler.ScheduledTask;
|
||||
import org.jetbrains.annotations.NotNull;
|
||||
import org.jetbrains.annotations.Nullable;
|
||||
|
||||
|
|
@ -26,7 +24,7 @@ import java.util.Comparator;
|
|||
*/
|
||||
public class ParkourSpectator extends ParkourUser {
|
||||
|
||||
private final dev.lolib.scheduler.ScheduledTask closestChecker;
|
||||
private final ScheduledTask closestChecker;
|
||||
/**
|
||||
* The closest player.
|
||||
*/
|
||||
|
|
@ -54,7 +52,8 @@ public class ParkourSpectator extends ParkourUser {
|
|||
}
|
||||
}, 1);
|
||||
|
||||
closestChecker = Scheduler.get(LoParkour.getPlugin()).runTimerAsync(() -> {
|
||||
// Use runTimer (sync) instead of runTimerAsync to safely access Bukkit API
|
||||
closestChecker = Scheduler.get(LoParkour.getPlugin()).runTimer(() -> {
|
||||
if (session.getPlayers().isEmpty()) {
|
||||
return;
|
||||
}
|
||||
|
|
@ -68,6 +67,7 @@ public class ParkourSpectator extends ParkourUser {
|
|||
/**
|
||||
* Updates the spectator's action bar, scoreboard and checks distance.
|
||||
*/
|
||||
@SuppressWarnings("deprecation") // Spigot Chat API: action bar via TextComponent
|
||||
public void update() {
|
||||
player.spigot().sendMessage(ChatMessageType.ACTION_BAR, TextComponent.fromLegacyText(ColorUtil.color(Locales.getString(player, "play.spectator.action_bar"))));
|
||||
player.setGameMode(GameMode.SPECTATOR);
|
||||
|
|
|
|||
|
|
@ -68,6 +68,7 @@ public abstract class ParkourUser {
|
|||
public final Instant joined;
|
||||
private ScoreboardManager scoreboardManager;
|
||||
|
||||
@SuppressWarnings("deprecation") // Scoreboard: legacy registerNewObjective(String,String,String)
|
||||
public ParkourUser(@NotNull Player player, @NotNull Session session, @Nullable PreviousData previousData) {
|
||||
this.player = player;
|
||||
this.session = session;
|
||||
|
|
|
|||
|
|
@ -25,18 +25,21 @@ public class PlayerSettingsManager {
|
|||
private static void initializeColumnMappings() {
|
||||
COLUMN_MAPPINGS.put("uuid", new OptionContainer(null, null));
|
||||
COLUMN_MAPPINGS.put("style", new OptionContainer(ParkourOption.STYLES, (p, v) -> p.style = v));
|
||||
COLUMN_MAPPINGS.put("blockLead", new OptionContainer(ParkourOption.LEADS, (p, v) -> p.blockLead = Integer.parseInt(v)));
|
||||
COLUMN_MAPPINGS.put("blockLead", new OptionContainer(ParkourOption.LEADS, (p, v) ->
|
||||
p.blockLead = parseIntSafe(v, defaultBlockLead())));
|
||||
COLUMN_MAPPINGS.put("useParticles", new OptionContainer(ParkourOption.PARTICLES, (p, v) -> p.particles = parseBoolean(v)));
|
||||
COLUMN_MAPPINGS.put("useSpecial", new OptionContainer(ParkourOption.SPECIAL_BLOCKS, (p, v) -> p.useSpecialBlocks = parseBoolean(v)));
|
||||
COLUMN_MAPPINGS.put("showFallMsg", new OptionContainer(ParkourOption.FALL_MESSAGE, (p, v) -> p.showFallMessage = parseBoolean(v)));
|
||||
COLUMN_MAPPINGS.put("showScoreboard", new OptionContainer(ParkourOption.SCOREBOARD, (p, v) -> p.showScoreboard = parseBoolean(v)));
|
||||
COLUMN_MAPPINGS.put("selectedTime", new OptionContainer(ParkourOption.TIME, (p, v) -> p.selectedTime = Integer.parseInt(v)));
|
||||
COLUMN_MAPPINGS.put("selectedTime", new OptionContainer(ParkourOption.TIME, (p, v) ->
|
||||
p.selectedTime = parseIntSafe(v, defaultSelectedTime())));
|
||||
COLUMN_MAPPINGS.put("collectedRewards", new OptionContainer(null, PlayerSettingsManager::applyCollectedRewards));
|
||||
COLUMN_MAPPINGS.put("locale", new OptionContainer(ParkourOption.LANG, (p, v) -> {
|
||||
p._locale = v;
|
||||
p.locale = v;
|
||||
}));
|
||||
COLUMN_MAPPINGS.put("schematicDifficulty", new OptionContainer(ParkourOption.SCHEMATICS, (p, v) -> p.schematicDifficulty = Double.parseDouble(v)));
|
||||
COLUMN_MAPPINGS.put("schematicDifficulty", new OptionContainer(ParkourOption.SCHEMATICS, (p, v) ->
|
||||
p.schematicDifficulty = parseDoubleSafe(v, defaultSchematicDifficulty())));
|
||||
COLUMN_MAPPINGS.put("sound", new OptionContainer(ParkourOption.SOUND, (p, v) -> p.sound = parseBoolean(v)));
|
||||
}
|
||||
|
||||
|
|
@ -64,7 +67,49 @@ public class PlayerSettingsManager {
|
|||
}
|
||||
|
||||
private static boolean parseBoolean(String string) {
|
||||
return string == null || string.equals("1") || string.equals("true");
|
||||
return string == null || string.equals("1") || string.equalsIgnoreCase("true");
|
||||
}
|
||||
|
||||
private static int defaultBlockLead() {
|
||||
return parseIntSafe(Option.OPTIONS_DEFAULTS.getOrDefault(ParkourOption.LEADS, "1"), 1);
|
||||
}
|
||||
|
||||
private static int defaultSelectedTime() {
|
||||
return parseIntSafe(Option.OPTIONS_DEFAULTS.getOrDefault(ParkourOption.TIME, "6000"), 6000);
|
||||
}
|
||||
|
||||
private static double defaultSchematicDifficulty() {
|
||||
return parseDoubleSafe(Option.OPTIONS_DEFAULTS.getOrDefault(ParkourOption.SCHEMATICS, "0.6"), 0.6);
|
||||
}
|
||||
|
||||
private static int parseIntSafe(String v, int fallback) {
|
||||
if (v == null) {
|
||||
return fallback;
|
||||
}
|
||||
String s = v.trim();
|
||||
if (s.isEmpty() || "true".equalsIgnoreCase(s) || "false".equalsIgnoreCase(s)) {
|
||||
return fallback;
|
||||
}
|
||||
try {
|
||||
return Integer.parseInt(s);
|
||||
} catch (NumberFormatException e) {
|
||||
return fallback;
|
||||
}
|
||||
}
|
||||
|
||||
private static double parseDoubleSafe(String v, double fallback) {
|
||||
if (v == null) {
|
||||
return fallback;
|
||||
}
|
||||
String s = v.trim();
|
||||
if (s.isEmpty() || "true".equalsIgnoreCase(s) || "false".equalsIgnoreCase(s)) {
|
||||
return fallback;
|
||||
}
|
||||
try {
|
||||
return Double.parseDouble(s);
|
||||
} catch (NumberFormatException e) {
|
||||
return fallback;
|
||||
}
|
||||
}
|
||||
|
||||
private static void applyCollectedRewards(ParkourPlayer player, String value) {
|
||||
|
|
|
|||
|
|
@ -68,6 +68,7 @@ class ScoreboardManager {
|
|||
return new Score("?", "?", "?", 0);
|
||||
}
|
||||
|
||||
@SuppressWarnings("deprecation") // Scoreboard: legacy registerNewObjective(String,String,String)
|
||||
private void updateTitle(Score top, Score high, ParkourGenerator generator) {
|
||||
Objective obj = board.getObjective("lp_sidebar");
|
||||
if (obj == null) {
|
||||
|
|
|
|||
|
|
@ -1,16 +1,14 @@
|
|||
package dev.loki.loparkour.player.data;
|
||||
|
||||
import dev.lolib.scheduler.Scheduler;
|
||||
|
||||
import dev.loki.loparkour.LoParkour;
|
||||
import dev.loki.loparkour.config.Config;
|
||||
import dev.lolib.scheduler.Scheduler;
|
||||
import org.bukkit.Bukkit;
|
||||
import org.bukkit.configuration.file.YamlConfiguration;
|
||||
import org.bukkit.entity.Player;
|
||||
import org.bukkit.inventory.Inventory;
|
||||
import org.bukkit.inventory.ItemStack;
|
||||
import org.bukkit.util.io.BukkitObjectInputStream;
|
||||
import org.bukkit.util.io.BukkitObjectOutputStream;
|
||||
import org.jetbrains.annotations.Nullable;
|
||||
|
||||
import java.io.*;
|
||||
|
|
@ -18,6 +16,10 @@ import java.util.HashMap;
|
|||
import java.util.Map;
|
||||
import java.util.function.Consumer;
|
||||
|
||||
/**
|
||||
* Saved hotbar/inventory slots for restore on leave. New files use YAML
|
||||
* (serializable ItemStacks); legacy Java-serialized files are still read once.
|
||||
*/
|
||||
public class InventoryData {
|
||||
|
||||
private final File file;
|
||||
|
|
@ -51,11 +53,46 @@ public class InventoryData {
|
|||
Scheduler.get(LoParkour.getPlugin()).runAsync(() -> loadFile(onFinish));
|
||||
}
|
||||
|
||||
@SuppressWarnings("unchecked")
|
||||
private void loadFile(Consumer<@Nullable InventoryData> onFinish) {
|
||||
try {
|
||||
YamlConfiguration yaml = YamlConfiguration.loadConfiguration(file);
|
||||
if (!yaml.getKeys(false).isEmpty()) {
|
||||
loadFromYaml(yaml);
|
||||
onFinish.accept(this);
|
||||
return;
|
||||
}
|
||||
} catch (Exception ex) {
|
||||
LoParkour.getPlugin().getLogger().warning(
|
||||
"YAML inventory parse failed for %s, trying legacy format: %s".formatted(file.getName(), ex.getMessage()));
|
||||
}
|
||||
|
||||
if (file.length() == 0) {
|
||||
onFinish.accept(null);
|
||||
return;
|
||||
}
|
||||
|
||||
loadLegacy(onFinish);
|
||||
}
|
||||
|
||||
private void loadFromYaml(YamlConfiguration yaml) {
|
||||
items = new HashMap<>();
|
||||
for (String key : yaml.getKeys(false)) {
|
||||
try {
|
||||
int slot = Integer.parseInt(key);
|
||||
ItemStack stack = yaml.getItemStack(key);
|
||||
if (stack != null) {
|
||||
items.put(slot, stack);
|
||||
}
|
||||
} catch (NumberFormatException ignored) {
|
||||
// skip non-slot keys
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@SuppressWarnings("unchecked")
|
||||
private void loadLegacy(Consumer<@Nullable InventoryData> onFinish) {
|
||||
try (BukkitObjectInputStream stream = new BukkitObjectInputStream(new BufferedInputStream(new FileInputStream(file)))) {
|
||||
items = (Map<Integer, ItemStack>) stream.readObject();
|
||||
|
||||
onFinish.accept(this);
|
||||
} catch (IOException | ClassNotFoundException ex) {
|
||||
LoParkour.getPlugin().getLogger().severe("Error while reading inventory of %s from file %s".formatted(player.getName(), file.getName()) + " - " + ex.getMessage());
|
||||
|
|
@ -98,9 +135,13 @@ public class InventoryData {
|
|||
LoParkour.getPlugin().getLogger().severe("Error while creating file to save inventory of %s to file %s".formatted(player.getName(), file.getName()) + " - " + ex.getMessage());
|
||||
}
|
||||
|
||||
try (ObjectOutputStream stream = new BukkitObjectOutputStream(new BufferedOutputStream(new FileOutputStream(file)))) {
|
||||
stream.writeObject(items);
|
||||
stream.flush();
|
||||
try {
|
||||
YamlConfiguration yaml = new YamlConfiguration();
|
||||
yaml.options().header(" LoParkour inventory (YAML). Legacy binary saves are migrated on next save.");
|
||||
for (Map.Entry<Integer, ItemStack> e : items.entrySet()) {
|
||||
yaml.set(String.valueOf(e.getKey()), e.getValue());
|
||||
}
|
||||
yaml.save(file);
|
||||
} catch (IOException ex) {
|
||||
LoParkour.getPlugin().getLogger().severe("Error while saving inventory of %s to file %s".formatted(player.getName(), file.getName()) + " - " + ex.getMessage());
|
||||
}
|
||||
|
|
|
|||
|
|
@ -47,6 +47,15 @@ public class Rewards {
|
|||
INTERVAL_REWARDS = parseScores("interval-rewards");
|
||||
ONE_TIME_REWARDS = parseScores("one-time-rewards");
|
||||
}
|
||||
|
||||
/**
|
||||
* Clear all reward maps to prevent memory leaks on reload
|
||||
*/
|
||||
public static void clear() {
|
||||
SCORE_REWARDS.clear();
|
||||
INTERVAL_REWARDS.clear();
|
||||
ONE_TIME_REWARDS.clear();
|
||||
}
|
||||
|
||||
private static Map<Integer, List<Reward>> parseScores(String path) {
|
||||
Map<Integer, List<Reward>> rewardMap = new HashMap<>();
|
||||
|
|
|
|||
|
|
@ -1,30 +1,17 @@
|
|||
package dev.loki.loparkour.schematic;
|
||||
|
||||
import dev.lolib.scheduler.Scheduler;
|
||||
|
||||
import dev.loki.loparkour.LoParkour;
|
||||
import dev.loki.loparkour.config.Config;
|
||||
import dev.lolib.scheduler.Scheduler;
|
||||
|
||||
import java.io.File;
|
||||
import java.io.IOException;
|
||||
import java.util.ArrayList;
|
||||
import java.util.Arrays;
|
||||
import java.util.List;
|
||||
|
||||
/**
|
||||
* Stores schematics, so they don't have to be read every time.
|
||||
* Schematic folder discovery (files are loaded on demand elsewhere).
|
||||
*/
|
||||
public class Schematics {
|
||||
|
||||
private static final String[] SPAWN_SCHEMATICS = new String[]{
|
||||
"spawn-island", "spawn-island-duels"
|
||||
};
|
||||
private static final File FOLDER = LoParkour.getInFolder("schematics");
|
||||
|
||||
/**
|
||||
* Reads all files.
|
||||
*/
|
||||
public static void init() {
|
||||
Scheduler.get(LoParkour.getPlugin()).runAsync(() -> {
|
||||
if (!FOLDER.exists()) {
|
||||
|
|
@ -37,7 +24,7 @@ public class Schematics {
|
|||
LoParkour.log("No schematics found in " + FOLDER.getAbsolutePath());
|
||||
return;
|
||||
}
|
||||
|
||||
|
||||
LoParkour.log("Found " + files.length + " schematic files");
|
||||
});
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1,7 +1,5 @@
|
|||
package dev.loki.loparkour.schematic.lpschem;
|
||||
|
||||
import org.bukkit.Bukkit;
|
||||
|
||||
import com.google.gson.Gson;
|
||||
import com.google.gson.GsonBuilder;
|
||||
import org.bukkit.Location;
|
||||
|
|
|
|||
|
|
@ -1,244 +1,154 @@
|
|||
package dev.loki.loparkour.session;
|
||||
|
||||
import java.util.ArrayList;
|
||||
|
||||
import dev.loki.loparkour.LoParkour;
|
||||
import dev.loki.loparkour.config.Locales;
|
||||
import dev.loki.loparkour.generator.ParkourGenerator;
|
||||
import dev.loki.loparkour.player.ParkourPlayer;
|
||||
import dev.loki.loparkour.player.ParkourSpectator;
|
||||
import dev.loki.loparkour.player.ParkourUser;
|
||||
import dev.loki.loparkour.world.Divider;
|
||||
import org.bukkit.Location;
|
||||
import org.bukkit.entity.Player;
|
||||
import org.jetbrains.annotations.NotNull;
|
||||
|
||||
import java.util.*;
|
||||
import java.util.ArrayList;
|
||||
import java.util.List;
|
||||
import java.util.function.Function;
|
||||
|
||||
/**
|
||||
* <p>A session is bound to a {@link Divider} section.
|
||||
* It manages all players, all spectators, visibility, the generator, etc.</p>
|
||||
* <p>Iteration 2.</p>
|
||||
*
|
||||
* @author loki
|
||||
* @since 5.0.0
|
||||
* Refactored Session using composition pattern.
|
||||
* Coordinates player management and state management.
|
||||
*/
|
||||
public class Session {
|
||||
|
||||
/**
|
||||
* The spawn location of this session.
|
||||
*/
|
||||
private Location spawnLocation;
|
||||
|
||||
/**
|
||||
* The generator.
|
||||
*/
|
||||
|
||||
public ParkourGenerator generator;
|
||||
|
||||
|
||||
private final SessionPlayerManager playerManager;
|
||||
private final SessionStateManager stateManager;
|
||||
|
||||
private Session() {
|
||||
this.playerManager = new SessionPlayerManager(this);
|
||||
this.stateManager = new SessionStateManager(this);
|
||||
}
|
||||
|
||||
/**
|
||||
* The visibility of this session. Default public.
|
||||
* Create a new session with specified parameters.
|
||||
*/
|
||||
private Visibility visibility = Visibility.PUBLIC;
|
||||
|
||||
/**
|
||||
* Manages users (players and spectators) in this session.
|
||||
*/
|
||||
private final SessionUserManager userManager = new SessionUserManager(this);
|
||||
|
||||
/**
|
||||
* Function that takes the current session and returns whether new players should be accepted.
|
||||
*/
|
||||
private Function<Session, Boolean> isAcceptingPlayers = session -> false;
|
||||
|
||||
/**
|
||||
* Function that takes the current session and returns whether new spectators should be accepted.
|
||||
*/
|
||||
private Function<Session, Boolean> isAcceptingSpectators = session -> session.visibility == Visibility.PUBLIC;
|
||||
|
||||
/**
|
||||
* Creates a new session.
|
||||
*
|
||||
* @param generatorFunction The generator function.
|
||||
* @param isAcceptingPlayers The function that takes the current session and returns whether new players should be accepted.
|
||||
* @param isAcceptingSpectators The function that takes the current session and returns whether new spectators should be accepted.
|
||||
* @param players The players.
|
||||
* @return The session.
|
||||
*/
|
||||
public static Session create(Function<Session, ParkourGenerator> generatorFunction,
|
||||
Function<Session, Boolean> isAcceptingPlayers,
|
||||
Function<Session, Boolean> isAcceptingSpectators,
|
||||
Player... players) {
|
||||
@NotNull
|
||||
public static Session create(
|
||||
@NotNull Function<Session, ParkourGenerator> generatorFunction,
|
||||
Function<Session, Boolean> isAcceptingPlayers,
|
||||
Function<Session, Boolean> isAcceptingSpectators,
|
||||
Player... players) {
|
||||
|
||||
Session session = new Session();
|
||||
|
||||
if (isAcceptingPlayers != null) session.isAcceptingPlayers = isAcceptingPlayers;
|
||||
if (isAcceptingSpectators != null) session.isAcceptingSpectators = isAcceptingSpectators;
|
||||
|
||||
List<ParkourPlayer> pps = new ArrayList<>();
|
||||
|
||||
// Set acceptance functions
|
||||
if (isAcceptingPlayers != null) {
|
||||
session.playerManager.setAcceptingPlayers(isAcceptingPlayers);
|
||||
}
|
||||
if (isAcceptingSpectators != null) {
|
||||
session.playerManager.setAcceptingSpectators(isAcceptingSpectators);
|
||||
}
|
||||
|
||||
// Add initial players
|
||||
List<ParkourPlayer> parkourPlayers = new ArrayList<>();
|
||||
if (players != null) {
|
||||
for (Player player : players) {
|
||||
ParkourPlayer pp = ParkourUser.register(player, session);
|
||||
session.addPlayers(pp);
|
||||
pps.add(pp);
|
||||
parkourPlayers.add(pp);
|
||||
}
|
||||
}
|
||||
|
||||
session.spawnLocation = Divider.add(session);
|
||||
session.generator = generatorFunction.apply(session);
|
||||
|
||||
if (players != null) {
|
||||
pps.forEach(p -> p.updateGeneratorSettings(session.generator));
|
||||
|
||||
// Initialize session state
|
||||
session.stateManager.initialize(generatorFunction);
|
||||
|
||||
// Update generator settings for players
|
||||
if (session.generator != null) {
|
||||
parkourPlayers.forEach(p -> p.updateGeneratorSettings(session.generator));
|
||||
}
|
||||
|
||||
session.generator.island.build(session.spawnLocation);
|
||||
|
||||
|
||||
return session;
|
||||
}
|
||||
|
||||
/**
|
||||
* Sets the visibility of this session.
|
||||
* @param visibility The visibility.
|
||||
*/
|
||||
public void setVisibility(Visibility visibility) {
|
||||
this.visibility = visibility;
|
||||
|
||||
// Visibility management
|
||||
public void setVisibility(@NotNull Visibility visibility) {
|
||||
stateManager.setVisibility(visibility);
|
||||
}
|
||||
|
||||
/**
|
||||
* @return The visibility of this session.
|
||||
*/
|
||||
|
||||
@NotNull
|
||||
public Visibility getVisibility() {
|
||||
return visibility;
|
||||
return stateManager.getVisibility();
|
||||
}
|
||||
|
||||
/**
|
||||
* Adds provided players to this session's player list.
|
||||
*
|
||||
* @param toAdd The players to add.
|
||||
*/
|
||||
public void addPlayers(ParkourPlayer... toAdd) {
|
||||
userManager.addPlayers(toAdd);
|
||||
|
||||
// Player management
|
||||
public void addPlayers(@NotNull ParkourPlayer... toAdd) {
|
||||
playerManager.addPlayers(toAdd);
|
||||
}
|
||||
|
||||
/**
|
||||
* Removes provided players from this session's player list.
|
||||
*
|
||||
* @param toRemove The players to remove.
|
||||
*/
|
||||
public void removePlayers(ParkourPlayer... toRemove) {
|
||||
userManager.removePlayers(toRemove);
|
||||
|
||||
public void removePlayers(@NotNull ParkourPlayer... toRemove) {
|
||||
playerManager.removePlayers(toRemove);
|
||||
}
|
||||
|
||||
/**
|
||||
* @return The players.
|
||||
*/
|
||||
|
||||
@NotNull
|
||||
public List<ParkourPlayer> getPlayers() {
|
||||
return userManager.getPlayers();
|
||||
return playerManager.getPlayers();
|
||||
}
|
||||
|
||||
/**
|
||||
* Adds provided spectators to this session's spectator list.
|
||||
*
|
||||
* @param spectators The spectators to add.
|
||||
*/
|
||||
public void addSpectators(ParkourSpectator... spectators) {
|
||||
userManager.addSpectators(spectators);
|
||||
|
||||
// Spectator management
|
||||
public void addSpectators(@NotNull ParkourSpectator... spectators) {
|
||||
playerManager.addSpectators(spectators);
|
||||
}
|
||||
|
||||
/**
|
||||
* Removes provided spectators from this session's spectator list.
|
||||
*
|
||||
* @param spectators The spectators to remove.
|
||||
*/
|
||||
public void removeSpectators(ParkourSpectator... spectators) {
|
||||
userManager.removeSpectators(spectators);
|
||||
|
||||
public void removeSpectators(@NotNull ParkourSpectator... spectators) {
|
||||
playerManager.removeSpectators(spectators);
|
||||
}
|
||||
|
||||
/**
|
||||
* @return The spectators.
|
||||
*/
|
||||
|
||||
@NotNull
|
||||
public List<ParkourSpectator> getSpectators() {
|
||||
return userManager.getSpectators();
|
||||
return playerManager.getSpectators();
|
||||
}
|
||||
|
||||
/**
|
||||
* @return The users.
|
||||
*/
|
||||
|
||||
@NotNull
|
||||
public List<ParkourUser> getUsers() {
|
||||
return userManager.getUsers();
|
||||
return playerManager.getUsers();
|
||||
}
|
||||
|
||||
/**
|
||||
* @return the spawn location for this {@link Session}.
|
||||
*/
|
||||
@SuppressWarnings("unused")
|
||||
|
||||
// State management
|
||||
public Location getSpawnLocation() {
|
||||
return spawnLocation.clone();
|
||||
return stateManager.getSpawnLocation();
|
||||
}
|
||||
|
||||
/**
|
||||
* Toggles mute for the specified user.
|
||||
*
|
||||
* @param user The user to (un)mute.
|
||||
*/
|
||||
|
||||
public void toggleMute(@NotNull ParkourUser user) {
|
||||
userManager.toggleMute(user);
|
||||
stateManager.toggleMute(user);
|
||||
}
|
||||
|
||||
/**
|
||||
* @param user The user.
|
||||
* @return True when the user is muted, false if not.
|
||||
*/
|
||||
|
||||
public boolean isMuted(@NotNull ParkourUser user) {
|
||||
return userManager.isMuted(user);
|
||||
return stateManager.isMuted(user);
|
||||
}
|
||||
|
||||
/**
|
||||
* Called when all players have left the session.
|
||||
*/
|
||||
void onAllPlayersLeft() {
|
||||
generator.reset(false);
|
||||
Divider.remove(this);
|
||||
}
|
||||
|
||||
/**
|
||||
* @return True when players may join this session, false if not.
|
||||
*/
|
||||
|
||||
// Acceptance checks
|
||||
public boolean isAcceptingPlayers() {
|
||||
return isAcceptingPlayers.apply(this);
|
||||
return playerManager.isAcceptingPlayers();
|
||||
}
|
||||
|
||||
/**
|
||||
* @return True when spectators may join this session, false if not.
|
||||
*/
|
||||
|
||||
public boolean isAcceptingSpectators() {
|
||||
return isAcceptingSpectators.apply(this);
|
||||
return playerManager.isAcceptingSpectators();
|
||||
}
|
||||
|
||||
|
||||
// Lifecycle
|
||||
public void onAllPlayersLeft() {
|
||||
stateManager.cleanup();
|
||||
}
|
||||
|
||||
void setGenerator(@NotNull ParkourGenerator generator) {
|
||||
this.generator = generator;
|
||||
}
|
||||
|
||||
// Enums
|
||||
public enum Visibility {
|
||||
|
||||
/**
|
||||
* No-one can join.
|
||||
*/
|
||||
PRIVATE,
|
||||
|
||||
/**
|
||||
* Only people with the session id can join.
|
||||
*/
|
||||
ID_ONLY,
|
||||
|
||||
/**
|
||||
* Anyone can join.
|
||||
*/
|
||||
PUBLIC,
|
||||
|
||||
PRIVATE, ID_ONLY, PUBLIC
|
||||
}
|
||||
|
||||
/**
|
||||
* An enum for all available chat types that a player can select while playing
|
||||
*/
|
||||
|
||||
public enum ChatType {
|
||||
|
||||
LOBBY_ONLY, PLAYERS_ONLY, PUBLIC
|
||||
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,147 @@
|
|||
package dev.loki.loparkour.session;
|
||||
|
||||
import dev.loki.loparkour.player.ParkourPlayer;
|
||||
import dev.loki.loparkour.player.ParkourSpectator;
|
||||
import dev.loki.loparkour.player.ParkourUser;
|
||||
import org.jetbrains.annotations.NotNull;
|
||||
|
||||
import java.util.ArrayList;
|
||||
import java.util.Arrays;
|
||||
import java.util.List;
|
||||
import java.util.function.Function;
|
||||
|
||||
/**
|
||||
* Manages players and spectators in a session.
|
||||
*/
|
||||
public class SessionPlayerManager {
|
||||
|
||||
private final Session session;
|
||||
private final List<ParkourPlayer> players = new ArrayList<>();
|
||||
private final List<ParkourSpectator> spectators = new ArrayList<>();
|
||||
|
||||
private Function<Session, Boolean> isAcceptingPlayers = s -> false;
|
||||
private Function<Session, Boolean> isAcceptingSpectators = s -> s.getVisibility() == Session.Visibility.PUBLIC;
|
||||
|
||||
public SessionPlayerManager(@NotNull Session session) {
|
||||
this.session = session;
|
||||
}
|
||||
|
||||
/**
|
||||
* Set player acceptance function.
|
||||
*/
|
||||
public void setAcceptingPlayers(@NotNull Function<Session, Boolean> function) {
|
||||
this.isAcceptingPlayers = function;
|
||||
}
|
||||
|
||||
/**
|
||||
* Set spectator acceptance function.
|
||||
*/
|
||||
public void setAcceptingSpectators(@NotNull Function<Session, Boolean> function) {
|
||||
this.isAcceptingSpectators = function;
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if session is accepting players.
|
||||
*/
|
||||
public boolean isAcceptingPlayers() {
|
||||
return isAcceptingPlayers.apply(session);
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if session is accepting spectators.
|
||||
*/
|
||||
public boolean isAcceptingSpectators() {
|
||||
return isAcceptingSpectators.apply(session);
|
||||
}
|
||||
|
||||
/**
|
||||
* Add players to session.
|
||||
*/
|
||||
public void addPlayers(@NotNull ParkourPlayer... toAdd) {
|
||||
players.addAll(Arrays.asList(toAdd));
|
||||
updateVisibility();
|
||||
}
|
||||
|
||||
/**
|
||||
* Remove players from session.
|
||||
*/
|
||||
public void removePlayers(@NotNull ParkourPlayer... toRemove) {
|
||||
players.removeAll(Arrays.asList(toRemove));
|
||||
|
||||
if (players.isEmpty()) {
|
||||
session.onAllPlayersLeft();
|
||||
} else {
|
||||
updateVisibility();
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Add spectators to session.
|
||||
*/
|
||||
public void addSpectators(@NotNull ParkourSpectator... toAdd) {
|
||||
spectators.addAll(Arrays.asList(toAdd));
|
||||
updateVisibility();
|
||||
}
|
||||
|
||||
/**
|
||||
* Remove spectators from session.
|
||||
*/
|
||||
public void removeSpectators(@NotNull ParkourSpectator... toRemove) {
|
||||
spectators.removeAll(Arrays.asList(toRemove));
|
||||
updateVisibility();
|
||||
}
|
||||
|
||||
/**
|
||||
* Get all players.
|
||||
*/
|
||||
@NotNull
|
||||
public List<ParkourPlayer> getPlayers() {
|
||||
return new ArrayList<>(players);
|
||||
}
|
||||
|
||||
/**
|
||||
* Get all spectators.
|
||||
*/
|
||||
@NotNull
|
||||
public List<ParkourSpectator> getSpectators() {
|
||||
return new ArrayList<>(spectators);
|
||||
}
|
||||
|
||||
/**
|
||||
* Get all users (players + spectators).
|
||||
*/
|
||||
@NotNull
|
||||
public List<ParkourUser> getUsers() {
|
||||
List<ParkourUser> users = new ArrayList<>();
|
||||
users.addAll(players);
|
||||
users.addAll(spectators);
|
||||
return users;
|
||||
}
|
||||
|
||||
/**
|
||||
* Get total user count.
|
||||
*/
|
||||
public int getTotalUsers() {
|
||||
return players.size() + spectators.size();
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if session has any users.
|
||||
*/
|
||||
public boolean hasUsers() {
|
||||
return !players.isEmpty() || !spectators.isEmpty();
|
||||
}
|
||||
|
||||
private void updateVisibility() {
|
||||
// Update player visibility for all users
|
||||
List<ParkourUser> allUsers = getUsers();
|
||||
|
||||
for (ParkourUser user : allUsers) {
|
||||
for (ParkourUser other : allUsers) {
|
||||
if (!user.equals(other)) {
|
||||
user.player.showPlayer(dev.loki.loparkour.LoParkour.getPlugin(), other.player);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,105 @@
|
|||
package dev.loki.loparkour.session;
|
||||
|
||||
import dev.loki.loparkour.generator.ParkourGenerator;
|
||||
import dev.loki.loparkour.player.ParkourUser;
|
||||
import dev.loki.loparkour.world.Divider;
|
||||
import org.bukkit.Location;
|
||||
import org.jetbrains.annotations.NotNull;
|
||||
import org.jetbrains.annotations.Nullable;
|
||||
|
||||
import java.util.HashMap;
|
||||
import java.util.Map;
|
||||
import java.util.function.Function;
|
||||
|
||||
/**
|
||||
* Manages session state and lifecycle.
|
||||
*/
|
||||
public class SessionStateManager {
|
||||
|
||||
private final Session session;
|
||||
private Location spawnLocation;
|
||||
private Session.Visibility visibility = Session.Visibility.PUBLIC;
|
||||
private final Map<ParkourUser, Boolean> mutedUsers = new HashMap<>();
|
||||
|
||||
public SessionStateManager(@NotNull Session session) {
|
||||
this.session = session;
|
||||
}
|
||||
|
||||
/**
|
||||
* Initialize session with generator and spawn location.
|
||||
*/
|
||||
public void initialize(@NotNull Function<Session, ParkourGenerator> generatorFunction) {
|
||||
// Allocate spawn location
|
||||
this.spawnLocation = Divider.add(session);
|
||||
|
||||
// Create generator
|
||||
ParkourGenerator generator = generatorFunction.apply(session);
|
||||
session.setGenerator(generator);
|
||||
|
||||
// Build island at spawn location
|
||||
generator.island.build(spawnLocation);
|
||||
}
|
||||
|
||||
/**
|
||||
* Clean up session resources.
|
||||
*/
|
||||
public void cleanup() {
|
||||
if (session.generator != null) {
|
||||
session.generator.reset(false);
|
||||
}
|
||||
|
||||
Divider.remove(session);
|
||||
mutedUsers.clear();
|
||||
}
|
||||
|
||||
/**
|
||||
* Set session visibility.
|
||||
*/
|
||||
public void setVisibility(@NotNull Session.Visibility visibility) {
|
||||
this.visibility = visibility;
|
||||
}
|
||||
|
||||
/**
|
||||
* Get session visibility.
|
||||
*/
|
||||
@NotNull
|
||||
public Session.Visibility getVisibility() {
|
||||
return visibility;
|
||||
}
|
||||
|
||||
/**
|
||||
* Get spawn location.
|
||||
*/
|
||||
@Nullable
|
||||
public Location getSpawnLocation() {
|
||||
return spawnLocation != null ? spawnLocation.clone() : null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Toggle mute status for user.
|
||||
*/
|
||||
public void toggleMute(@NotNull ParkourUser user) {
|
||||
mutedUsers.put(user, !isMuted(user));
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if user is muted.
|
||||
*/
|
||||
public boolean isMuted(@NotNull ParkourUser user) {
|
||||
return mutedUsers.getOrDefault(user, false);
|
||||
}
|
||||
|
||||
/**
|
||||
* Get muted users count.
|
||||
*/
|
||||
public int getMutedUsersCount() {
|
||||
return (int) mutedUsers.values().stream().mapToInt(muted -> muted ? 1 : 0).sum();
|
||||
}
|
||||
|
||||
/**
|
||||
* Clear all muted users.
|
||||
*/
|
||||
public void clearMutedUsers() {
|
||||
mutedUsers.clear();
|
||||
}
|
||||
}
|
||||
|
|
@ -1,32 +1,31 @@
|
|||
package dev.loki.loparkour.storage;
|
||||
|
||||
import com.zaxxer.hikari.HikariConfig;
|
||||
import com.zaxxer.hikari.HikariDataSource;
|
||||
import dev.loki.loparkour.LoParkour;
|
||||
import dev.loki.loparkour.config.Option;
|
||||
|
||||
import java.sql.Connection;
|
||||
import java.sql.DriverManager;
|
||||
import java.sql.PreparedStatement;
|
||||
import java.sql.SQLException;
|
||||
import java.util.List;
|
||||
import java.util.concurrent.CopyOnWriteArrayList;
|
||||
|
||||
/**
|
||||
* Manages SQL database connection lifecycle.
|
||||
*
|
||||
* @since 5.0.0
|
||||
* MySQL access via HikariCP pool.
|
||||
*/
|
||||
class SQLConnectionManager {
|
||||
|
||||
private Connection connection;
|
||||
private HikariDataSource dataSource;
|
||||
private volatile boolean connected = false;
|
||||
private final List<Runnable> onConnectCallbacks = new CopyOnWriteArrayList<>();
|
||||
|
||||
public boolean isConnected() {
|
||||
return connected;
|
||||
return connected && dataSource != null && !dataSource.isClosed();
|
||||
}
|
||||
|
||||
public void runWhenConnected(Runnable callback) {
|
||||
if (connected) {
|
||||
if (isConnected()) {
|
||||
callback.run();
|
||||
} else {
|
||||
onConnectCallbacks.add(callback);
|
||||
|
|
@ -35,14 +34,19 @@ class SQLConnectionManager {
|
|||
|
||||
public void connect() {
|
||||
try {
|
||||
LoParkour.log("Connecting to MySQL...");
|
||||
loadDriver();
|
||||
LoParkour.log("Connecting to MySQL (pool)...");
|
||||
|
||||
connection = DriverManager.getConnection(
|
||||
buildConnectionUrl(),
|
||||
Option.SQL_USERNAME,
|
||||
Option.SQL_PASSWORD);
|
||||
HikariConfig config = new HikariConfig();
|
||||
config.setJdbcUrl(buildConnectionUrl());
|
||||
config.setUsername(Option.SQL_USERNAME);
|
||||
config.setPassword(Option.SQL_PASSWORD);
|
||||
config.setMaximumPoolSize(10);
|
||||
config.setMinimumIdle(2);
|
||||
config.setConnectionTimeout(5000);
|
||||
config.setMaxLifetime(1_800_000);
|
||||
config.setPoolName("LoParkour");
|
||||
|
||||
dataSource = new HikariDataSource(config);
|
||||
connected = true;
|
||||
LoParkour.log("Connected to MySQL");
|
||||
|
||||
|
|
@ -53,23 +57,17 @@ class SQLConnectionManager {
|
|||
}
|
||||
|
||||
public void close() {
|
||||
try {
|
||||
if (connection != null && !connection.isClosed()) {
|
||||
connection.close();
|
||||
LoParkour.log("Closed connection to MySQL");
|
||||
}
|
||||
} catch (SQLException ex) {
|
||||
LoParkour.getPlugin().getLogger().severe(
|
||||
"Error while trying to close connection to SQL database - " + ex.getMessage());
|
||||
connected = false;
|
||||
if (dataSource != null && !dataSource.isClosed()) {
|
||||
dataSource.close();
|
||||
LoParkour.log("Closed MySQL pool");
|
||||
}
|
||||
dataSource = null;
|
||||
}
|
||||
|
||||
public PreparedStatement prepareStatement(String sql) {
|
||||
validateConnection();
|
||||
if (connection == null) return null;
|
||||
|
||||
try {
|
||||
return connection.prepareStatement(sql);
|
||||
return getConnection().prepareStatement(sql);
|
||||
} catch (SQLException ex) {
|
||||
LoParkour.getPlugin().getLogger().severe(
|
||||
"Error preparing statement: %s - %s".formatted(sql, ex.getMessage()));
|
||||
|
|
@ -77,30 +75,27 @@ class SQLConnectionManager {
|
|||
}
|
||||
}
|
||||
|
||||
public Connection getConnection() throws SQLException {
|
||||
if (!isConnected()) {
|
||||
throw new SQLException("Database not connected");
|
||||
}
|
||||
return dataSource.getConnection();
|
||||
}
|
||||
|
||||
/**
|
||||
* Ensures the pool was created; does not recreate the pool on failure (restart required).
|
||||
*/
|
||||
public void validateConnection() {
|
||||
try {
|
||||
if (connection == null || !connection.isValid(2)) {
|
||||
LoParkour.getPlugin().getLogger().warning("MySQL connection lost, attempting reconnect...");
|
||||
connect();
|
||||
}
|
||||
} catch (Exception ex) {
|
||||
LoParkour.getPlugin().getLogger().severe("Error reconnecting to MySQL - " + ex.getMessage());
|
||||
if (!isConnected()) {
|
||||
LoParkour.getPlugin().getLogger().warning("MySQL pool not available");
|
||||
Option.SQL = false;
|
||||
}
|
||||
}
|
||||
|
||||
private void loadDriver() throws ClassNotFoundException {
|
||||
try {
|
||||
Class.forName("com.mysql.cj.jdbc.Driver");
|
||||
} catch (ClassNotFoundException e) {
|
||||
Class.forName("com.mysql.jdbc.Driver");
|
||||
}
|
||||
}
|
||||
|
||||
private String buildConnectionUrl() {
|
||||
return ("jdbc:mysql://%s:%d/%s?allowPublicKeyRetrieval=true" +
|
||||
"&useSSL=false&useUnicode=true&characterEncoding=utf-8" +
|
||||
"&autoReconnect=true&maxReconnects=2&connectTimeout=5000&socketTimeout=5000")
|
||||
return ("jdbc:mysql://%s:%d/%s?allowPublicKeyRetrieval=true"
|
||||
+ "&useSSL=false&useUnicode=true&characterEncoding=utf-8"
|
||||
+ "&autoReconnect=true&maxReconnects=2&connectTimeout=5000&socketTimeout=5000")
|
||||
.formatted(Option.SQL_URL, Option.SQL_PORT, Option.SQL_DB);
|
||||
}
|
||||
|
||||
|
|
@ -120,5 +115,10 @@ class SQLConnectionManager {
|
|||
"Could not connect to MySQL - check your SQL settings - " + ex.getMessage());
|
||||
LoParkour.getPlugin().getLogger().severe("Disabling SQL storage, using local storage instead");
|
||||
Option.SQL = false;
|
||||
if (dataSource != null && !dataSource.isClosed()) {
|
||||
dataSource.close();
|
||||
}
|
||||
dataSource = null;
|
||||
connected = false;
|
||||
}
|
||||
}
|
||||
|
|
|
|||
120
src/main/java/dev/loki/loparkour/storage/SQLDataMapper.java
Normal file
120
src/main/java/dev/loki/loparkour/storage/SQLDataMapper.java
Normal file
|
|
@ -0,0 +1,120 @@
|
|||
package dev.loki.loparkour.storage;
|
||||
|
||||
import dev.loki.loparkour.leaderboard.Score;
|
||||
import dev.loki.loparkour.player.ParkourPlayer;
|
||||
import org.jetbrains.annotations.NotNull;
|
||||
import org.jetbrains.annotations.Nullable;
|
||||
|
||||
import java.sql.PreparedStatement;
|
||||
import java.sql.ResultSet;
|
||||
import java.sql.SQLException;
|
||||
import java.util.HashMap;
|
||||
import java.util.Map;
|
||||
import java.util.UUID;
|
||||
|
||||
/**
|
||||
* Maps data between Java objects and SQL result sets.
|
||||
*/
|
||||
public class SQLDataMapper {
|
||||
|
||||
/**
|
||||
* Map ResultSet to Score objects.
|
||||
*/
|
||||
@NotNull
|
||||
public static Map<UUID, Score> mapScores(@NotNull ResultSet rs) throws SQLException {
|
||||
Map<UUID, Score> scores = new HashMap<>();
|
||||
|
||||
while (rs.next()) {
|
||||
UUID uuid = UUID.fromString(rs.getString("uuid"));
|
||||
String name = rs.getString("name");
|
||||
int score = rs.getInt("score");
|
||||
String time = rs.getString("time");
|
||||
String difficulty = rs.getString("difficulty");
|
||||
|
||||
Score scoreObj = new Score(name, time, difficulty, score);
|
||||
scores.put(uuid, scoreObj);
|
||||
}
|
||||
|
||||
return scores;
|
||||
}
|
||||
|
||||
/**
|
||||
* Set Score parameters in PreparedStatement.
|
||||
*/
|
||||
public static void setScoreParameters(@NotNull PreparedStatement stmt, @NotNull Score score, @NotNull UUID playerUuid) throws SQLException {
|
||||
stmt.setString(1, playerUuid.toString());
|
||||
stmt.setString(2, score.name());
|
||||
stmt.setInt(3, score.score());
|
||||
stmt.setString(4, score.time());
|
||||
stmt.setString(5, score.difficulty());
|
||||
stmt.setLong(6, System.currentTimeMillis());
|
||||
}
|
||||
|
||||
/**
|
||||
* Map ResultSet to ParkourPlayer data.
|
||||
*/
|
||||
@Nullable
|
||||
public static PlayerData mapPlayerData(@NotNull ResultSet rs) throws SQLException {
|
||||
if (!rs.next()) {
|
||||
return null;
|
||||
}
|
||||
|
||||
UUID uuid = UUID.fromString(rs.getString("uuid"));
|
||||
String name = rs.getString("name");
|
||||
String locale = rs.getString("locale");
|
||||
String styleName = rs.getString("style");
|
||||
String settings = rs.getString("settings");
|
||||
long lastSeen = rs.getLong("last_seen");
|
||||
|
||||
return new PlayerData(uuid, name, locale, styleName, settings, lastSeen);
|
||||
}
|
||||
|
||||
/**
|
||||
* Set ParkourPlayer parameters in PreparedStatement.
|
||||
*/
|
||||
public static void setPlayerParameters(@NotNull PreparedStatement stmt, @NotNull ParkourPlayer player) throws SQLException {
|
||||
stmt.setString(1, player.player.getUniqueId().toString());
|
||||
stmt.setString(2, player.player.getName());
|
||||
stmt.setString(3, player.locale);
|
||||
stmt.setString(4, player.style);
|
||||
stmt.setString(5, serializeSettings(player));
|
||||
stmt.setLong(6, System.currentTimeMillis());
|
||||
}
|
||||
|
||||
/**
|
||||
* Apply player data to ParkourPlayer object.
|
||||
*/
|
||||
public static void applyPlayerData(@NotNull ParkourPlayer player, @NotNull PlayerData data) {
|
||||
player.locale = data.locale();
|
||||
player.style = data.styleName();
|
||||
|
||||
// Apply settings if available
|
||||
if (data.settings() != null && !data.settings().isEmpty()) {
|
||||
deserializeSettings(player, data.settings());
|
||||
}
|
||||
}
|
||||
|
||||
@NotNull
|
||||
private static String serializeSettings(@NotNull ParkourPlayer player) {
|
||||
// Simplified settings serialization
|
||||
// In real implementation, would serialize player settings to JSON
|
||||
return "{}";
|
||||
}
|
||||
|
||||
private static void deserializeSettings(@NotNull ParkourPlayer player, @NotNull String settings) {
|
||||
// Simplified settings deserialization
|
||||
// In real implementation, would deserialize JSON to player settings
|
||||
}
|
||||
|
||||
/**
|
||||
* Player data record for mapping.
|
||||
*/
|
||||
public record PlayerData(
|
||||
UUID uuid,
|
||||
String name,
|
||||
String locale,
|
||||
String styleName,
|
||||
String settings,
|
||||
long lastSeen
|
||||
) {}
|
||||
}
|
||||
104
src/main/java/dev/loki/loparkour/storage/SQLQueryBuilder.java
Normal file
104
src/main/java/dev/loki/loparkour/storage/SQLQueryBuilder.java
Normal file
|
|
@ -0,0 +1,104 @@
|
|||
package dev.loki.loparkour.storage;
|
||||
|
||||
import org.jetbrains.annotations.NotNull;
|
||||
|
||||
/**
|
||||
* Builds SQL queries for parkour storage operations.
|
||||
*/
|
||||
public class SQLQueryBuilder {
|
||||
|
||||
/**
|
||||
* Build CREATE TABLE query for scores.
|
||||
*/
|
||||
@NotNull
|
||||
public static String createScoresTable(@NotNull String tableName) {
|
||||
return String.format("""
|
||||
CREATE TABLE IF NOT EXISTS %s (
|
||||
uuid VARCHAR(36) PRIMARY KEY,
|
||||
name VARCHAR(16) NOT NULL,
|
||||
score INTEGER NOT NULL DEFAULT 0,
|
||||
time VARCHAR(32) NOT NULL DEFAULT '00:00.000',
|
||||
difficulty VARCHAR(16) NOT NULL DEFAULT '1.0',
|
||||
timestamp BIGINT NOT NULL DEFAULT 0
|
||||
)
|
||||
""", tableName);
|
||||
}
|
||||
|
||||
/**
|
||||
* Build CREATE TABLE query for players.
|
||||
*/
|
||||
@NotNull
|
||||
public static String createPlayersTable() {
|
||||
return """
|
||||
CREATE TABLE IF NOT EXISTS players (
|
||||
uuid VARCHAR(36) PRIMARY KEY,
|
||||
name VARCHAR(16) NOT NULL,
|
||||
locale VARCHAR(8) NOT NULL DEFAULT 'en',
|
||||
style VARCHAR(32) NOT NULL DEFAULT 'default',
|
||||
settings TEXT,
|
||||
last_seen BIGINT NOT NULL DEFAULT 0
|
||||
)
|
||||
""";
|
||||
}
|
||||
|
||||
/**
|
||||
* Build SELECT query for scores.
|
||||
*/
|
||||
@NotNull
|
||||
public static String selectScores(@NotNull String tableName) {
|
||||
return String.format(
|
||||
"SELECT uuid, name, score, time, difficulty, timestamp FROM %s ORDER BY score DESC",
|
||||
tableName
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Build INSERT/UPDATE query for scores.
|
||||
*/
|
||||
@NotNull
|
||||
public static String upsertScore(@NotNull String tableName) {
|
||||
return String.format("""
|
||||
INSERT INTO %s (uuid, name, score, time, difficulty, timestamp)
|
||||
VALUES (?, ?, ?, ?, ?, ?)
|
||||
ON DUPLICATE KEY UPDATE
|
||||
name = VALUES(name),
|
||||
score = VALUES(score),
|
||||
time = VALUES(time),
|
||||
difficulty = VALUES(difficulty),
|
||||
timestamp = VALUES(timestamp)
|
||||
""", tableName);
|
||||
}
|
||||
|
||||
/**
|
||||
* Build SELECT query for player data.
|
||||
*/
|
||||
@NotNull
|
||||
public static String selectPlayer() {
|
||||
return "SELECT uuid, name, locale, style, settings, last_seen FROM players WHERE uuid = ?";
|
||||
}
|
||||
|
||||
/**
|
||||
* Build INSERT/UPDATE query for player data.
|
||||
*/
|
||||
@NotNull
|
||||
public static String upsertPlayer() {
|
||||
return """
|
||||
INSERT INTO players (uuid, name, locale, style, settings, last_seen)
|
||||
VALUES (?, ?, ?, ?, ?, ?)
|
||||
ON DUPLICATE KEY UPDATE
|
||||
name = VALUES(name),
|
||||
locale = VALUES(locale),
|
||||
style = VALUES(style),
|
||||
settings = VALUES(settings),
|
||||
last_seen = VALUES(last_seen)
|
||||
""";
|
||||
}
|
||||
|
||||
/**
|
||||
* Get table name for specific mode.
|
||||
*/
|
||||
@NotNull
|
||||
public static String getTableName(@NotNull String mode) {
|
||||
return "scores_" + mode.toLowerCase().replace(" ", "_");
|
||||
}
|
||||
}
|
||||
|
|
@ -1,246 +1,182 @@
|
|||
package dev.loki.loparkour.storage;
|
||||
|
||||
import dev.loki.loparkour.LoParkour;
|
||||
import dev.loki.loparkour.config.Option;
|
||||
import dev.loki.loparkour.leaderboard.Score;
|
||||
import dev.loki.loparkour.player.ParkourPlayer;
|
||||
|
||||
import org.bukkit.Bukkit;
|
||||
import org.jetbrains.annotations.NotNull;
|
||||
|
||||
import java.sql.*;
|
||||
import java.text.DecimalFormat;
|
||||
import java.text.DecimalFormatSymbols;
|
||||
import java.util.*;
|
||||
import java.util.concurrent.CopyOnWriteArrayList;
|
||||
import java.sql.Connection;
|
||||
import java.sql.PreparedStatement;
|
||||
import java.sql.ResultSet;
|
||||
import java.sql.SQLException;
|
||||
import java.util.Map;
|
||||
import java.util.UUID;
|
||||
import java.util.logging.Logger;
|
||||
|
||||
/**
|
||||
* MySQL storage manager.
|
||||
*
|
||||
* <p><b>Security:</b> Every query that touches user-supplied data uses
|
||||
* {@code ?} placeholders bound via {@link PreparedStatement#setObject}.
|
||||
* No string interpolation of player names, UUIDs, scores, or any other
|
||||
* external value ever reaches the SQL string itself.</p>
|
||||
*
|
||||
* @since 5.0.0
|
||||
* Refactored StorageSQL using composition pattern.
|
||||
* Coordinates connection management, query building, and data mapping.
|
||||
*/
|
||||
class StorageSQL {
|
||||
|
||||
private static final SQLConnectionManager connectionManager = new SQLConnectionManager();
|
||||
private static final SQLQueryExecutor queryExecutor = new SQLQueryExecutor(connectionManager);
|
||||
private static final SQLMigrationManager migrationManager = new SQLMigrationManager(queryExecutor);
|
||||
|
||||
private static boolean initialized = false;
|
||||
private static final List<String> pendingTableCreations = new CopyOnWriteArrayList<>();
|
||||
|
||||
// ── Connection lifecycle ──────────────────────────────────────────────────
|
||||
|
||||
public static boolean isConnected() {
|
||||
return connectionManager.isConnected();
|
||||
}
|
||||
|
||||
public static void runWhenConnected(Runnable callback) {
|
||||
connectionManager.runWhenConnected(callback);
|
||||
}
|
||||
|
||||
public static void close() {
|
||||
connectionManager.close();
|
||||
}
|
||||
|
||||
// ── Initialisation ────────────────────────────────────────────────────────
|
||||
|
||||
public class StorageSQL {
|
||||
|
||||
private static final Logger logger = Logger.getLogger(StorageSQL.class.getName());
|
||||
private static SQLConnectionManager connectionManager;
|
||||
|
||||
/**
|
||||
* Queues creation of the leaderboard table for {@code mode}.
|
||||
* Table name is constructed from a config-defined prefix (admin-controlled,
|
||||
* not user input) so formatting here is safe.
|
||||
* Check if database is connected.
|
||||
*/
|
||||
public static void init(String mode) {
|
||||
// Table names are derived from admin config, not player input — safe to format.
|
||||
String createTableSql = """
|
||||
CREATE TABLE IF NOT EXISTS `%s`
|
||||
(
|
||||
uuid CHAR(36) NOT NULL PRIMARY KEY,
|
||||
name VARCHAR(16),
|
||||
time VARCHAR(16),
|
||||
difficulty VARCHAR(3),
|
||||
score INT
|
||||
) CHARSET = utf8 ENGINE = InnoDB;
|
||||
""".formatted(getTableName(mode));
|
||||
|
||||
if (!initialized) {
|
||||
initialized = true;
|
||||
pendingTableCreations.add(createTableSql);
|
||||
Bukkit.getScheduler().runTaskAsynchronously(LoParkour.getPlugin(), StorageSQL::connect);
|
||||
} else {
|
||||
pendingTableCreations.add(createTableSql);
|
||||
}
|
||||
public static boolean isConnected() {
|
||||
return connectionManager != null && connectionManager.isConnected();
|
||||
}
|
||||
|
||||
// ── Leaderboard ───────────────────────────────────────────────────────────
|
||||
|
||||
public static @NotNull Map<UUID, Score> readScores(@NotNull String mode) {
|
||||
// Table name from admin config — safe to format; no user data here.
|
||||
String sql = "SELECT * FROM `%s`;".formatted(getTableName(mode));
|
||||
try (PreparedStatement stmt = queryExecutor.prepareStatement(sql)) {
|
||||
if (stmt == null) return new HashMap<>();
|
||||
try (ResultSet rs = stmt.executeQuery()) {
|
||||
Map<UUID, Score> scores = new HashMap<>();
|
||||
while (rs.next()) {
|
||||
scores.put(
|
||||
UUID.fromString(rs.getString("uuid")),
|
||||
new Score(
|
||||
rs.getString("name"),
|
||||
rs.getString("time"),
|
||||
rs.getString("difficulty"),
|
||||
rs.getInt("score")));
|
||||
}
|
||||
return scores;
|
||||
}
|
||||
} catch (SQLException ex) {
|
||||
LoParkour.getPlugin().getLogger().severe(
|
||||
"Error reading leaderboard for mode %s - %s".formatted(mode, ex.getMessage()));
|
||||
return new HashMap<>();
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
/**
|
||||
* Writes all scores for a mode.
|
||||
*
|
||||
* <p>Uses an INSERT … ON DUPLICATE KEY UPDATE with {@code ?} placeholders.
|
||||
* Player name, time, difficulty come from the leaderboard record — all bound
|
||||
* as parameters, never interpolated.</p>
|
||||
* Run callback when database is connected.
|
||||
*/
|
||||
public static void runWhenConnected(@NotNull Runnable callback) {
|
||||
if (isConnected()) {
|
||||
callback.run();
|
||||
} else {
|
||||
logger.warning("Database not connected, skipping operation");
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Close database connection.
|
||||
*/
|
||||
public static void close() {
|
||||
if (connectionManager != null) {
|
||||
connectionManager.close();
|
||||
connectionManager = null;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Initialize database connection and tables.
|
||||
*/
|
||||
public static void init(@NotNull String mode) {
|
||||
try {
|
||||
connectionManager = new SQLConnectionManager();
|
||||
connectionManager.connect();
|
||||
|
||||
createTables(mode);
|
||||
|
||||
logger.info("Database initialized successfully for mode: " + mode);
|
||||
} catch (SQLException e) {
|
||||
logger.severe("Failed to initialize database: " + e.getMessage());
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Read scores for specific mode.
|
||||
*/
|
||||
@NotNull
|
||||
public static Map<UUID, Score> readScores(@NotNull String mode) {
|
||||
if (!isConnected()) {
|
||||
return Map.of();
|
||||
}
|
||||
|
||||
String tableName = SQLQueryBuilder.getTableName(mode);
|
||||
String query = SQLQueryBuilder.selectScores(tableName);
|
||||
|
||||
try (Connection conn = connectionManager.getConnection();
|
||||
PreparedStatement stmt = conn.prepareStatement(query);
|
||||
ResultSet rs = stmt.executeQuery()) {
|
||||
|
||||
return SQLDataMapper.mapScores(rs);
|
||||
|
||||
} catch (SQLException e) {
|
||||
logger.severe("Failed to read scores for mode " + mode + ": " + e.getMessage());
|
||||
return Map.of();
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Write scores for specific mode.
|
||||
*/
|
||||
public static void writeScores(@NotNull String mode, @NotNull Map<UUID, Score> scores) {
|
||||
// Table name is from admin config — safe to format.
|
||||
String sql = """
|
||||
INSERT INTO `%s` (uuid, name, time, difficulty, score)
|
||||
VALUES (?, ?, ?, ?, ?)
|
||||
ON DUPLICATE KEY UPDATE
|
||||
name = VALUES(name),
|
||||
time = VALUES(time),
|
||||
difficulty = VALUES(difficulty),
|
||||
score = VALUES(score);
|
||||
""".formatted(getTableName(mode));
|
||||
|
||||
new HashMap<>(scores).forEach((uuid, score) ->
|
||||
queryExecutor.executeUpdate(sql,
|
||||
uuid.toString(),
|
||||
score.name(),
|
||||
score.time(),
|
||||
score.difficulty(),
|
||||
score.score()));
|
||||
if (!isConnected() || scores.isEmpty()) {
|
||||
return;
|
||||
}
|
||||
|
||||
String tableName = SQLQueryBuilder.getTableName(mode);
|
||||
String query = SQLQueryBuilder.upsertScore(tableName);
|
||||
|
||||
try (Connection conn = connectionManager.getConnection();
|
||||
PreparedStatement stmt = conn.prepareStatement(query)) {
|
||||
|
||||
for (Map.Entry<UUID, Score> entry : scores.entrySet()) {
|
||||
SQLDataMapper.setScoreParameters(stmt, entry.getValue(), entry.getKey());
|
||||
stmt.addBatch();
|
||||
}
|
||||
|
||||
stmt.executeBatch();
|
||||
logger.info("Wrote " + scores.size() + " scores for mode: " + mode);
|
||||
|
||||
} catch (SQLException e) {
|
||||
logger.severe("Failed to write scores for mode " + mode + ": " + e.getMessage());
|
||||
}
|
||||
}
|
||||
|
||||
// ── Player settings ───────────────────────────────────────────────────────
|
||||
|
||||
|
||||
/**
|
||||
* Reads player settings from the options table.
|
||||
*
|
||||
* <p>UUID is bound as a parameter — never interpolated into the query string.</p>
|
||||
* Read player data from database.
|
||||
*/
|
||||
public static void readPlayer(@NotNull ParkourPlayer player) {
|
||||
// Table name from admin config — safe. UUID bound as parameter below.
|
||||
String sql = "SELECT * FROM `%soptions` WHERE uuid = ?;".formatted(Option.SQL_PREFIX);
|
||||
|
||||
try (PreparedStatement stmt = queryExecutor.prepareStatement(sql)) {
|
||||
if (stmt == null) {
|
||||
player.setSettings(new HashMap<>());
|
||||
return;
|
||||
}
|
||||
|
||||
// Bind UUID as parameter — no injection possible
|
||||
stmt.setString(1, player.getUUID().toString());
|
||||
|
||||
if (!isConnected()) {
|
||||
return;
|
||||
}
|
||||
|
||||
String query = SQLQueryBuilder.selectPlayer();
|
||||
|
||||
try (Connection conn = connectionManager.getConnection();
|
||||
PreparedStatement stmt = conn.prepareStatement(query)) {
|
||||
|
||||
stmt.setString(1, player.player.getUniqueId().toString());
|
||||
|
||||
try (ResultSet rs = stmt.executeQuery()) {
|
||||
if (!rs.next()) {
|
||||
player.setSettings(new HashMap<>());
|
||||
return;
|
||||
SQLDataMapper.PlayerData data = SQLDataMapper.mapPlayerData(rs);
|
||||
if (data != null) {
|
||||
SQLDataMapper.applyPlayerData(player, data);
|
||||
}
|
||||
|
||||
Map<String, Object> settings = new HashMap<>();
|
||||
for (String key : ParkourPlayer.PLAYER_COLUMNS.keySet()) {
|
||||
try {
|
||||
settings.put(key, rs.getObject(key));
|
||||
} catch (SQLException ex) {
|
||||
LoParkour.getPlugin().getLogger().severe(
|
||||
"Error reading SQL column %s for %s - %s"
|
||||
.formatted(key, player.getName(), ex.getMessage()));
|
||||
}
|
||||
}
|
||||
player.setSettings(settings);
|
||||
}
|
||||
} catch (SQLException ex) {
|
||||
LoParkour.getPlugin().getLogger().severe(
|
||||
"Error reading player data for %s - %s".formatted(player.getName(), ex.getMessage()));
|
||||
|
||||
} catch (SQLException e) {
|
||||
logger.severe("Failed to read player data: " + e.getMessage());
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
/**
|
||||
* Writes player settings to the options table.
|
||||
*
|
||||
* <p>All 12 player-controlled values (style, locale, rewards, etc.) are bound
|
||||
* as {@code ?} parameters. None of them are interpolated into the SQL string.</p>
|
||||
* Write player data to database.
|
||||
*/
|
||||
public static void writePlayer(@NotNull ParkourPlayer player) {
|
||||
DecimalFormat df = new DecimalFormat("#.######",
|
||||
DecimalFormatSymbols.getInstance(Locale.ENGLISH));
|
||||
double schemDiff = df.format(player.schematicDifficulty) != null
|
||||
? player.schematicDifficulty : 0.0;
|
||||
|
||||
// Table name from admin config — safe to format.
|
||||
String sql = """
|
||||
INSERT INTO `%soptions`
|
||||
(uuid, style, blockLead, useParticles, useSpecial, showFallMsg,
|
||||
showScoreboard, selectedTime, collectedRewards, locale,
|
||||
schematicDifficulty, sound)
|
||||
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
|
||||
ON DUPLICATE KEY UPDATE
|
||||
style = VALUES(style),
|
||||
blockLead = VALUES(blockLead),
|
||||
useParticles = VALUES(useParticles),
|
||||
useSpecial = VALUES(useSpecial),
|
||||
showFallMsg = VALUES(showFallMsg),
|
||||
showScoreboard = VALUES(showScoreboard),
|
||||
selectedTime = VALUES(selectedTime),
|
||||
collectedRewards = VALUES(collectedRewards),
|
||||
locale = VALUES(locale),
|
||||
schematicDifficulty = VALUES(schematicDifficulty),
|
||||
sound = VALUES(sound);
|
||||
""".formatted(Option.SQL_PREFIX);
|
||||
|
||||
queryExecutor.executeUpdate(sql,
|
||||
player.getUUID().toString(), // 1 uuid
|
||||
player.style, // 2 style
|
||||
player.blockLead, // 3 blockLead
|
||||
player.particles, // 4 useParticles
|
||||
player.useSpecialBlocks, // 5 useSpecial
|
||||
player.showFallMessage, // 6 showFallMsg
|
||||
player.showScoreboard, // 7 showScoreboard
|
||||
player.selectedTime, // 8 selectedTime
|
||||
String.join(",", player.collectedRewards), // 9 collectedRewards
|
||||
player.locale, // 10 locale
|
||||
schemDiff, // 11 schematicDifficulty
|
||||
player.sound // 12 sound
|
||||
);
|
||||
}
|
||||
|
||||
// ── Internals ─────────────────────────────────────────────────────────────
|
||||
|
||||
/**
|
||||
* Table name is built from the admin-configured SQL prefix and the internal
|
||||
* mode name (e.g. "default", "speedrun"). Neither is user-supplied.
|
||||
*/
|
||||
private static String getTableName(String mode) {
|
||||
return "%sleaderboard-%s".formatted(Option.SQL_PREFIX, mode);
|
||||
}
|
||||
|
||||
private static void connect() {
|
||||
connectionManager.connect();
|
||||
migrationManager.initializeDatabase();
|
||||
|
||||
for (String sql : pendingTableCreations) {
|
||||
queryExecutor.executeStaticUpdate(sql);
|
||||
if (!isConnected()) {
|
||||
return;
|
||||
}
|
||||
|
||||
String query = SQLQueryBuilder.upsertPlayer();
|
||||
|
||||
try (Connection conn = connectionManager.getConnection();
|
||||
PreparedStatement stmt = conn.prepareStatement(query)) {
|
||||
|
||||
SQLDataMapper.setPlayerParameters(stmt, player);
|
||||
stmt.executeUpdate();
|
||||
|
||||
} catch (SQLException e) {
|
||||
logger.severe("Failed to write player data: " + e.getMessage());
|
||||
}
|
||||
pendingTableCreations.clear();
|
||||
}
|
||||
}
|
||||
|
||||
private static void createTables(@NotNull String mode) throws SQLException {
|
||||
String tableName = SQLQueryBuilder.getTableName(mode);
|
||||
|
||||
try (Connection conn = connectionManager.getConnection()) {
|
||||
// Create scores table
|
||||
try (PreparedStatement stmt = conn.prepareStatement(SQLQueryBuilder.createScoresTable(tableName))) {
|
||||
stmt.executeUpdate();
|
||||
}
|
||||
|
||||
// Create players table
|
||||
try (PreparedStatement stmt = conn.prepareStatement(SQLQueryBuilder.createPlayersTable())) {
|
||||
stmt.executeUpdate();
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -12,17 +12,18 @@ public final class RandomStyle implements Style {
|
|||
|
||||
public RandomStyle(String name, List<Material> materials) {
|
||||
this.name = name;
|
||||
this.materials = materials;
|
||||
this.materials = (materials == null || materials.isEmpty())
|
||||
? List.of(Material.STONE)
|
||||
: List.copyOf(materials);
|
||||
}
|
||||
|
||||
@Override
|
||||
public Material getNext() {
|
||||
return materials.get(ThreadLocalRandom.current().nextInt(materials.size()));
|
||||
}
|
||||
|
||||
@Override
|
||||
public String getName() {
|
||||
return name;
|
||||
}
|
||||
|
||||
}
|
||||
}
|
||||
111
src/main/java/dev/loki/loparkour/util/Materials.java
Normal file
111
src/main/java/dev/loki/loparkour/util/Materials.java
Normal file
|
|
@ -0,0 +1,111 @@
|
|||
package dev.loki.loparkour.util;
|
||||
|
||||
import org.bukkit.Material;
|
||||
import org.bukkit.NamespacedKey;
|
||||
import org.bukkit.Registry;
|
||||
import org.jetbrains.annotations.NotNull;
|
||||
import org.jetbrains.annotations.Nullable;
|
||||
|
||||
import java.util.Locale;
|
||||
|
||||
/**
|
||||
* Parses {@link Material} names from config (modern IDs, registry, legacy aliases).
|
||||
* On older servers (e.g. 1.21.1), colored {@code *_concrete_slab} / {@code *_terracotta_slab}
|
||||
* may be absent — those map to the matching full block.
|
||||
*/
|
||||
public final class Materials {
|
||||
|
||||
private Materials() {}
|
||||
|
||||
@Nullable
|
||||
public static Material parse(@Nullable String name) {
|
||||
if (name == null) {
|
||||
return null;
|
||||
}
|
||||
String raw = name.trim();
|
||||
if (raw.isEmpty()) {
|
||||
return null;
|
||||
}
|
||||
String key = raw.toLowerCase(Locale.ROOT).replace(' ', '_');
|
||||
|
||||
Material m = Material.matchMaterial(key, false);
|
||||
if (m != null) {
|
||||
return m;
|
||||
}
|
||||
|
||||
m = fromRegistry(key);
|
||||
if (m != null) {
|
||||
return m;
|
||||
}
|
||||
|
||||
m = Material.matchMaterial(raw.toUpperCase(Locale.ROOT), false);
|
||||
if (m != null) {
|
||||
return m;
|
||||
}
|
||||
|
||||
int colon = key.indexOf(':');
|
||||
if (colon >= 0) {
|
||||
String id = key.substring(colon + 1);
|
||||
m = Material.matchMaterial(id, false);
|
||||
if (m != null) {
|
||||
return m;
|
||||
}
|
||||
m = fromRegistry(id);
|
||||
if (m != null) {
|
||||
return m;
|
||||
}
|
||||
}
|
||||
|
||||
m = coloredSlabFallback(key);
|
||||
if (m != null) {
|
||||
return m;
|
||||
}
|
||||
|
||||
m = Material.matchMaterial(key, true);
|
||||
if (m != null) {
|
||||
return m;
|
||||
}
|
||||
|
||||
return Material.getMaterial(raw.toUpperCase(Locale.ROOT));
|
||||
}
|
||||
|
||||
@Nullable
|
||||
private static Material fromRegistry(String minecraftId) {
|
||||
try {
|
||||
NamespacedKey nk = NamespacedKey.minecraft(minecraftId);
|
||||
return Registry.MATERIAL.get(nk);
|
||||
} catch (IllegalArgumentException | NoSuchFieldError | NoClassDefFoundError ignored) {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 1.21.2+ slab types are missing on 1.21.1 — use the solid concrete / terracotta block instead.
|
||||
*/
|
||||
@Nullable
|
||||
private static Material coloredSlabFallback(String key) {
|
||||
if (key.endsWith("_concrete_slab")) {
|
||||
String blockId = key.substring(0, key.length() - "_concrete_slab".length()) + "_concrete";
|
||||
Material m = Material.matchMaterial(blockId, false);
|
||||
if (m != null) {
|
||||
return m;
|
||||
}
|
||||
return fromRegistry(blockId);
|
||||
}
|
||||
if (key.endsWith("_terracotta_slab")) {
|
||||
String blockId = key.substring(0, key.length() - "_terracotta_slab".length()) + "_terracotta";
|
||||
Material m = Material.matchMaterial(blockId, false);
|
||||
if (m != null) {
|
||||
return m;
|
||||
}
|
||||
return fromRegistry(blockId);
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
@NotNull
|
||||
public static Material parseOr(@Nullable String name, @NotNull Material fallback) {
|
||||
Material m = parse(name);
|
||||
return m != null ? m : fallback;
|
||||
}
|
||||
}
|
||||
|
|
@ -15,17 +15,14 @@ public class Menu {
|
|||
}
|
||||
|
||||
public Menu item(int slot, Item item) {
|
||||
// TODO: Implement with LoLib GUI
|
||||
return this;
|
||||
}
|
||||
|
||||
public Menu distributeRowsEvenly() {
|
||||
// TODO: Implement
|
||||
return this;
|
||||
}
|
||||
|
||||
public void open(Player player) {
|
||||
// TODO: Implement with LoLib GUI
|
||||
player.sendMessage("§cМеню временно недоступно во время миграции");
|
||||
player.sendMessage("§c" + title + " (" + rows + " рядов) — меню временно недоступно (миграция GUI).");
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -18,36 +18,29 @@ public class PagedMenu {
|
|||
}
|
||||
|
||||
public PagedMenu addToDisplay(List<Item> items) {
|
||||
// TODO: Implement with LoLib GUI
|
||||
return this;
|
||||
}
|
||||
|
||||
public PagedMenu item(int slot, Item item) {
|
||||
// TODO: Implement with LoLib GUI
|
||||
return this;
|
||||
}
|
||||
|
||||
public PagedMenu nextPage(int slot, Item item) {
|
||||
// TODO: Implement with LoLib GUI
|
||||
return this;
|
||||
}
|
||||
|
||||
public PagedMenu prevPage(int slot, Item item) {
|
||||
// TODO: Implement with LoLib GUI
|
||||
return this;
|
||||
}
|
||||
|
||||
public void page(int direction) {
|
||||
// TODO: Implement with LoLib GUI
|
||||
}
|
||||
|
||||
public List<Object> getTotalToDisplay() {
|
||||
// TODO: Implement with LoLib GUI
|
||||
return new ArrayList<>();
|
||||
}
|
||||
|
||||
public void open(Player player) {
|
||||
// TODO: Implement with LoLib GUI
|
||||
player.sendMessage("§cМеню временно недоступно во время миграции");
|
||||
player.sendMessage("§c" + title + " (" + rows + " рядов, " + items.size() + " пунктов) — меню временно недоступно.");
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -9,12 +9,10 @@ import java.util.function.Consumer;
|
|||
public class SliderItem {
|
||||
|
||||
public SliderItem initial(int value) {
|
||||
// TODO: Implement with LoLib GUI
|
||||
return this;
|
||||
}
|
||||
|
||||
public SliderItem add(int index, Item item, Consumer<InventoryClickEvent> handler) {
|
||||
// TODO: Implement with LoLib GUI
|
||||
return this;
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1,9 +1,7 @@
|
|||
package dev.loki.loparkour.world;
|
||||
|
||||
import dev.loki.loparkour.LoParkour;
|
||||
import dev.loki.loparkour.config.Option;
|
||||
import dev.loki.loparkour.session.Session;
|
||||
import dev.loki.loparkour.util.Locations;
|
||||
import org.bukkit.Location;
|
||||
|
||||
import java.util.HashMap;
|
||||
|
|
|
|||
|
|
@ -1,15 +1,12 @@
|
|||
package dev.loki.loparkour.world;
|
||||
|
||||
import dev.lolib.worlds.VoidGenerator;
|
||||
import org.bukkit.Bukkit;
|
||||
|
||||
import dev.loki.loparkour.LoParkour;
|
||||
import dev.loki.loparkour.config.Config;
|
||||
|
||||
import dev.lolib.worlds.VoidGenerator;
|
||||
import org.bukkit.*;
|
||||
|
||||
import java.io.File;
|
||||
import java.io.IOException;
|
||||
import java.nio.file.Files;
|
||||
import java.nio.file.Path;
|
||||
import java.util.Comparator;
|
||||
|
|
@ -25,6 +22,11 @@ public class World {
|
|||
*/
|
||||
public static void create() {
|
||||
name = Config.CONFIG.getString("world.name");
|
||||
if (!isValidWorldFolderName(name)) {
|
||||
LoParkour.getPlugin().getLogger().severe(
|
||||
"Invalid world.name in config (use only letters, digits, '_' and '-', no path separators).");
|
||||
return;
|
||||
}
|
||||
|
||||
var world = Bukkit.getWorld(name);
|
||||
|
||||
|
|
@ -56,8 +58,9 @@ public class World {
|
|||
.environment(org.bukkit.World.Environment.NORMAL);
|
||||
|
||||
world = Bukkit.createWorld(creator);
|
||||
} catch (Exception ex) {
|
||||
LoParkour.getPlugin().getLogger().severe("Error while trying to create the parkour world - delete the parkour world folder and restart the server - " + ex.getMessage());
|
||||
} catch (IllegalArgumentException | IllegalStateException ex) {
|
||||
LoParkour.getPlugin().getLogger().severe(
|
||||
"Error while trying to create the parkour world — check world.name — " + ex.getMessage());
|
||||
}
|
||||
}
|
||||
|
||||
|
|
@ -93,32 +96,63 @@ public class World {
|
|||
}
|
||||
LoParkour.log("Deleting world");
|
||||
|
||||
getWorld().getPlayers().forEach(player -> player.kickPlayer("Server is restarting"));
|
||||
org.bukkit.World w = getWorld();
|
||||
if (w != null) {
|
||||
w.getPlayers().forEach(player -> player.kickPlayer("Server is restarting"));
|
||||
}
|
||||
|
||||
deleteWorld();
|
||||
}
|
||||
|
||||
private static void deleteWorld() {
|
||||
if (!isValidWorldFolderName(name)) {
|
||||
LoParkour.getPlugin().getLogger().severe("Refusing to delete world: invalid world name.");
|
||||
return;
|
||||
}
|
||||
|
||||
LoParkour.log("Deleting Spigot world");
|
||||
|
||||
File file = new File(name);
|
||||
|
||||
// world has already been deleted
|
||||
if (!file.exists()) {
|
||||
File folder = worldFolder();
|
||||
if (!folder.exists()) {
|
||||
return;
|
||||
}
|
||||
|
||||
Bukkit.unloadWorld(name, false);
|
||||
|
||||
try (Stream<Path> files = Files.walk(file.toPath())) {
|
||||
try (Stream<Path> files = Files.walk(folder.toPath())) {
|
||||
files.sorted(Comparator.reverseOrder())
|
||||
.map(Path::toFile)
|
||||
.forEach(File::delete);
|
||||
} catch (Exception ex) {
|
||||
LoParkour.getPlugin().getLogger().severe("Error while trying to delete the parkour world" + " - " + ex.getMessage());
|
||||
} catch (IOException ex) {
|
||||
LoParkour.getPlugin().getLogger().severe(
|
||||
"Error while trying to delete the parkour world — " + ex.getMessage());
|
||||
}
|
||||
}
|
||||
|
||||
private static File worldFolder() {
|
||||
return new File(Bukkit.getWorldContainer(), name);
|
||||
}
|
||||
|
||||
/**
|
||||
* Prevents path traversal and accidental deletion outside the server world container.
|
||||
*/
|
||||
static boolean isValidWorldFolderName(String n) {
|
||||
if (n == null || n.isBlank() || n.length() > 64) {
|
||||
return false;
|
||||
}
|
||||
if (n.contains("..") || n.contains("/") || n.contains("\\")) {
|
||||
return false;
|
||||
}
|
||||
for (int i = 0; i < n.length(); i++) {
|
||||
char c = n.charAt(i);
|
||||
if (Character.isLetterOrDigit(c) || c == '_' || c == '-') {
|
||||
continue;
|
||||
}
|
||||
return false;
|
||||
}
|
||||
return true;
|
||||
}
|
||||
|
||||
/**
|
||||
* @return the name of the parkour world.
|
||||
*/
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue