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
209
TODO.md
209
TODO.md
|
|
@ -1,177 +1,82 @@
|
||||||
# LoParkour — TODO
|
# LoParkour — TODO
|
||||||
|
|
||||||
> Обновлено: 2026-03-07
|
> Обновлено: 2026-04-02. Длинный хвост старых аудитов и дублей: **[ARCHIVE_TODO.md](ARCHIVE_TODO.md)**.
|
||||||
|
|
||||||
## Легенда
|
## Легенда
|
||||||
- 🔴 Критично — баг/краш/не компилируется
|
|
||||||
- 🟡 Важно — фича заявлена но не работает
|
- 🔴 Критично — баг/краш/безопасность
|
||||||
- 🟢 Планово — улучшение
|
- 🟡 Важно — заявлено, но не работает / серьёзные проблемы
|
||||||
|
- 🟢 Планово — улучшение / техдолг
|
||||||
- ✅ Готово
|
- ✅ Готово
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
## 🔴 Критично — из code review
|
## Главный план (стабилизация)
|
||||||
|
|
||||||
| # | Баг | Файл | Что сделать |
|
Консолидировано после обхода: меню/util, `LoParkour`, `LoParkourCommand`, join/teleport, `SessionStateManager`, `Island.build()`, Locales, LoLib 3.x.
|
||||||
|---|-----|------|-------------|
|
|
||||||
| 1 | ~~Ghost файлы по имени игрока~~ | `GhostManager.java`, `GhostData.java` | ✅ Используется UUID вместо имени |
|
### P0 — блокер загрузки или геймплей
|
||||||
| 2 | ~~`GhostRecorder.stopRecording()` не передаёт UUID~~ | `GhostRecorder.java`, `GeneratorLifecycle.java` | ✅ Добавлен параметр UUID |
|
|
||||||
| 3 | ~~`new Random()` в горячих методах~~ | `BlockPlacer.java` | ✅ Заменено на `ThreadLocalRandom.current()` |
|
| # | Задача | Статус |
|
||||||
| 4 | ~~`ConcurrentModificationException` в `reset()`~~ | `ParkourGenerator.java` | ✅ Синхронизация через копирование списка |
|
|---|--------|--------|
|
||||||
| 5 | ~~Сохраняется только DEFAULT лидерборд~~ | `LoParkour.java` disable() | ✅ Сохраняются все режимы (Speedrun, Race, Coop, Elytra) |
|
| 1 | LoLib 3.x в classpath | ✅ `build.gradle.kts` → `libs/lolib-3.0.0.jar` |
|
||||||
| 6 | ~~`cleanupDistantBlocks()` вызывается дважды~~ | `GeneratorLifecycle.java` | ✅ Убран дублирующий вызов |
|
| 2 | `GeneratorCleanup` — хвост только **сзади** по индексу в `history` | ✅ |
|
||||||
| 7 | ~~`System.err.println` в JumpOffsetGenerator~~ | `JumpOffsetGenerator.java` | ✅ Используется Logger |
|
| 3 | Потокобезопасность `history` | ✅ `synchronizedList`, снимок при полном `reset()` |
|
||||||
| 8 | ~~`disable()` глотает все исключения~~ | `LoParkour.java` | ✅ Логируются ошибки |
|
|
||||||
| 9 | ~~Первый блок может быть специальным~~ | `BlockPlacer.java` | ✅ Всегда полный блок из конфига |
|
### P1 — важно
|
||||||
|
|
||||||
|
| # | Задача | Статус |
|
||||||
|
|---|--------|--------|
|
||||||
|
| 4 | Хотбар: клик по предмету | ✅ `isSimilar` в restriction listener |
|
||||||
|
| 5 | SQL `readPlayer` на корректном потоке | ✅ |
|
||||||
|
|
||||||
|
### P2 — проверить после P0–P1
|
||||||
|
|
||||||
|
| # | Задача | Статус |
|
||||||
|
|---|--------|--------|
|
||||||
|
| 6 | `LifecycleTickManager` / cleanup / time UI | ✅ |
|
||||||
|
| 7 | Scoring в воздухе | ✅ документировано (стояние на блоке) |
|
||||||
|
|
||||||
|
### Контекст (не в «критичном» списке)
|
||||||
|
|
||||||
|
- Порядок `generateFirst` / телепорт из `Island.build()` — ок при неизменённом порядке вызовов.
|
||||||
|
- `history.contains(Block)` — Bukkit сравнивает по миру и координатам.
|
||||||
|
- `InventoryType.CHEST` в GUI — при странных кейсах проверить тип инвентаря.
|
||||||
|
|
||||||
|
### Порядок работ (регрессия)
|
||||||
|
|
||||||
|
1. `./gradlew build`, старт без `LinkageError`.
|
||||||
|
2. Ручной прогон паркура: счёт, меню, scoreboard, режимы.
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
## 🟡 Важно — улучшения безопасности
|
## Follow-up (сделано в коде)
|
||||||
|
|
||||||
| # | Проблема | Файл | Что сделать |
|
- **Leaderboard**: sync-таймер; итерация/снимки без гонок с `synchronizedMap`.
|
||||||
|---|----------|------|-------------|
|
- **AdminCommandHandler**: cooldown 2.5 с на `forcejoin`, `forceleave`, `reset`, `recoverinventory`; при блокировке — сообщение.
|
||||||
| 9 | ~~Отсутствует rate limiting~~ | `PlayerCommandHandler.java` | ✅ Cooldown уже реализован (2.5 сек на join/leave) |
|
- **SQLConnectionManager**: HikariCP + `mysql-connector-j` в shadow.
|
||||||
| 10 | ~~Нет валидации конфигурации~~ | `Config.java` | ✅ Добавлена валидация критичных значений |
|
- **SQL password**: если задана непустая **`LOPARKOUR_SQL_PASSWORD`**, она перекрывает `config.yml` → `sql.password`.
|
||||||
| 11 | ~~`loadIslandSchematic()` возвращает null~~ | `ParkourGenerator.java` | ✅ Fallback на дефолтный схематик "island" |
|
- **World**: валидация имени; папка через `getWorldContainer()`; безопасное удаление; кик только если мир не null.
|
||||||
| 12 | ~~Hardcoded строки вместо локализации~~ | `MainMenu.java`, `CoopMode.java`, `RaceMode.java`, `ElytraMode.java` | ✅ Используется `Locales.getString()` |
|
- **LoParkourCommand**: `MultiMode` для join к другу; tab-complete; leaderboard без хака; «too many args».
|
||||||
|
- **PlayerCommandHandler**: `/parkour leaderboard` без второго аргумента открывает меню лидербордов.
|
||||||
|
- **RandomStyle** / **ElytraGenerator**: защита от пустых списков.
|
||||||
|
- **InventoryData**: новые сохранения в **YAML**; legacy binary читается один раз при миграции.
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
## 🟢 Планово — оптимизация
|
## DuelMode
|
||||||
|
|
||||||
| # | Улучшение | Файл | Что сделать |
|
**Отдельная крупная фича** (не багфикс): PvP/дуэльный режим из старых заметок — проектировать и оценивать отдельно (сессии, арена, матчмейкинг, награды). Детали и старые идеи см. в **[ARCHIVE_TODO.md](ARCHIVE_TODO.md)** (поиск по «Duel» / режимам).
|
||||||
|---|-----------|------|-------------|
|
|
||||||
| 13 | ~~`getPlayers()` создаёт новый список каждый раз~~ | `ParkourPlayer.java` | ✅ Оптимизирован `getPlayer()` без создания списка |
|
|
||||||
| 14 | ~~Магические числа~~ | `BlockPlacer.java`, `GeneratorLifecycle.java` | ✅ Вынесены в константы (MAX_RETRIES, FALL_DISTANCE, etc) |
|
|
||||||
| 15 | ~~`selectJumpType()` хардкод шансов~~ | `BlockPlacer.java` | ✅ Вынесено в generation.yml |
|
|
||||||
| 16 | ~~SQL Injection в writeScores/writePlayer~~ | `StorageSQL.java` | ✅ Переписано на PreparedStatement с параметрами |
|
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
## 🎉 Итоги рефакторинга
|
## Чек-лист перед релизом
|
||||||
|
|
||||||
### Исправлено критичных багов: 8/8 ✅
|
- [ ] `./gradlew build`
|
||||||
- Ghost UUID параметр
|
- [ ] Старт сервера, `/parkour`, меню, один полный забег
|
||||||
- ThreadLocalRandom вместо new Random()
|
- [ ] SQL: при проде выставить `LOPARKOUR_SQL_PASSWORD` на хосте
|
||||||
- ConcurrentModificationException
|
- [ ] Проверить миграцию старых `.dat` инвентарей при первом заходе игрока
|
||||||
- Сохранение всех лидербордов
|
|
||||||
- Двойной вызов cleanup
|
|
||||||
- System.err → Logger
|
|
||||||
- Логирование ошибок в disable()
|
|
||||||
- **SQL Injection полностью устранён**
|
|
||||||
|
|
||||||
### Улучшения безопасности: 4/4 ✅
|
|
||||||
- Rate limiting (уже был)
|
|
||||||
- Валидация конфигурации
|
|
||||||
- Fallback для схематиков
|
|
||||||
- Локализация всех строк
|
|
||||||
|
|
||||||
### Оптимизация: 4/4 ✅
|
|
||||||
- Оптимизация getPlayer()
|
|
||||||
- Константы вместо магических чисел
|
|
||||||
- Конфигурируемые шансы jump types
|
|
||||||
- PreparedStatement для SQL
|
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
## 🟢 Планово — дуэли
|
|
||||||
|
|
||||||
### DuelMode
|
|
||||||
- [ ] `DuelMode implements Mode` — режим дуэли
|
|
||||||
- [ ] `DuelSession extends Session` — два игрока, отдельные острова
|
|
||||||
- [ ] `/lp duel <игрок>` — вызов на дуэль, запрос истекает через 30 сек
|
|
||||||
- [ ] Форматы: **Race** (кто первый до N очков), **Survival** (3 жизни), **Time Attack** (60 сек кто больше)
|
|
||||||
- [ ] Scoreboard показывает счёт обоих в реальном времени
|
|
||||||
- [ ] ActionBar: `Opponent: 34 pts` во время игры
|
|
||||||
- [ ] При падении противника — уведомление
|
|
||||||
- [ ] Победитель получает награды через систему `Rewards`
|
|
||||||
- [ ] Лидерборд дуэлей (W/L)
|
|
||||||
- [ ] Кнопка реванша после окончания
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## ✅ Уже исправлено
|
|
||||||
|
|
||||||
- ✅ BOM в `ParkourGenerator.java` ломает компиляцию
|
|
||||||
- ✅ `generator.score` → `generator.state.score`
|
|
||||||
- ✅ `ParkourUser.joinCount` удалён
|
|
||||||
- ✅ `PlayerSettingsManager.OptionContainer` не public
|
|
||||||
- ✅ `Option.PARTICLE_DATA.data()` не существует
|
|
||||||
- ✅ `ParticleUtil.circle()` неправильные параметры
|
|
||||||
- ✅ `PotionEffectType.JUMP` и `SLOW` удалены в Paper 1.20.5+
|
|
||||||
- ✅ `ElytraMode` создаёт обычный `ParkourGenerator`
|
|
||||||
- ✅ `SpeedrunMode`: таймеры `runLater` продолжают тикать после `reset()`
|
|
||||||
- ✅ `HardcoreMode`: `collectedRewards` не очищается при падении
|
|
||||||
- ✅ `JumpValidator` создан но нигде не вызывается
|
|
||||||
- ✅ `JumpType` enum создан но не интегрирован в генератор
|
|
||||||
- ✅ `ConfigUpdater` закомментирован
|
|
||||||
- ✅ `GravityShiftMode`: нет визуала при смене эффекта
|
|
||||||
- ✅ Ghost система не интегрирована
|
|
||||||
- ✅ Удалить мёртвые stub-файлы
|
|
||||||
- ✅ ElytraMode: кольца из партиклов + детекция пролёта
|
|
||||||
- ✅ ElytraMode: cooldown на фейерверки
|
|
||||||
- ✅ RaceMode
|
|
||||||
- ✅ CoopMode
|
|
||||||
- ✅ Реорганизация `generator/` по подпакетам
|
|
||||||
- ✅ SQL Injection исправлен (PreparedStatement)
|
|
||||||
- ✅ PreparedStatement leak исправлен (try-with-resources)
|
|
||||||
- ✅ Scheduler Task Leak в SpeedrunMode исправлен
|
|
||||||
- ✅ Удалены GravityShiftMode и HardcoreMode (запутанные режимы)
|
|
||||||
- ✅ Предметы в хотбаре вместо инвентаря
|
|
||||||
- ✅ Схематики отключены из генерации
|
|
||||||
- ✅ Сложность генерации сбалансирована
|
|
||||||
- ✅ Ограничения после специальных блоков (слэбы, заборы, панели)
|
|
||||||
- ✅ Clamp значений height/distance для безопасности
|
|
||||||
- ✅ Fallback в JumpOffsetGenerator
|
|
||||||
- ✅ Локали для race и coop режимов
|
|
||||||
- ✅ Конфиг для race и coop режимов
|
|
||||||
- ✅ Fallback для first-block-material в Island.java
|
|
||||||
- ✅ Ghost файлы по UUID вместо имени (частично - нужно обновить вызовы)
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## 🎮 Чек-лист перед релизом
|
|
||||||
|
|
||||||
- [x] `./gradlew build` без ошибок
|
|
||||||
- [x] Все критичные баги исправлены (пункты 1-8)
|
|
||||||
- [x] Все улучшения безопасности (пункты 9-12)
|
|
||||||
- [x] Все оптимизации (пункты 13-16)
|
|
||||||
- [x] SQL Injection полностью устранён
|
|
||||||
- [x] Версия обновлена до 1.3.1
|
|
||||||
- [x] CHANGELOG.md создан
|
|
||||||
- [x] README.md обновлён
|
|
||||||
- [x] RELEASE_NOTES.md создан
|
|
||||||
- [x] Юнит-тесты добавлены (JumpValidator, GhostData)
|
|
||||||
- [x] Баги найденные тестами исправлены
|
|
||||||
- [x] Все тесты проходят успешно
|
|
||||||
- [ ] Запуск сервера без ошибок в консоли
|
|
||||||
- [ ] Паркур: прыжки, счёт, рекорд работают
|
|
||||||
- [ ] Scoreboard обновляется
|
|
||||||
- [ ] Все меню открываются
|
|
||||||
- [ ] SQL: данные сохраняются и читаются
|
|
||||||
- [ ] Партиклы и звук в каждом режиме
|
|
||||||
- [ ] Все режимы запускаются без ошибок (Default, Speedrun, Elytra, Race, Coop)
|
|
||||||
- [ ] Ghost система работает (запись и воспроизведение)
|
|
||||||
- [ ] Нет невозможных прыжков после специальных блоков
|
|
||||||
- [ ] Предметы в хотбаре (слоты 1,3,4,5,7)
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## 📊 Оценка из code review
|
|
||||||
|
|
||||||
**Общая оценка: 7.5/10**
|
|
||||||
|
|
||||||
**Сильные стороны:**
|
|
||||||
- Отличная структура пакетов
|
|
||||||
- Есть API для разработчиков
|
|
||||||
- Хорошая модульность (режимы, генератор, хранилище)
|
|
||||||
- Много систем (leaderboard, ghost, generator, schematics, menus, rewards)
|
|
||||||
|
|
||||||
|
|
||||||
**Что нужно исправить:**
|
|
||||||
- Критичные баги из таблицы выше
|
|
||||||
- Безопасность (rate limiting, валидация)
|
|
||||||
- Оптимизация (кеширование, константы)
|
|
||||||
|
|
||||||
**Потенциал:**
|
|
||||||
Проект уровня реального Spigot ресурса (15-25k строк кода). При исправлении критичных багов можно выпускать в production.
|
|
||||||
|
|
|
||||||
|
|
@ -77,12 +77,14 @@ dependencies {
|
||||||
compileOnly("net.kyori:adventure-text-minimessage:4.14.0")
|
compileOnly("net.kyori:adventure-text-minimessage:4.14.0")
|
||||||
|
|
||||||
// LoLib Core
|
// LoLib Core
|
||||||
implementation(files("libs/lolib-2.0.1.jar"))
|
implementation(files("libs/lolib-3.0.0.jar"))
|
||||||
|
|
||||||
// Shaded dependencies
|
// Shaded dependencies
|
||||||
implementation("io.papermc:paperlib:1.0.7")
|
implementation("io.papermc:paperlib:1.0.7")
|
||||||
implementation("com.google.code.gson:gson:2.10.1")
|
implementation("com.google.code.gson:gson:2.10.1")
|
||||||
implementation("org.bstats:bstats-bukkit:3.2.1")
|
implementation("org.bstats:bstats-bukkit:3.2.1")
|
||||||
|
implementation("com.zaxxer:HikariCP:5.1.0")
|
||||||
|
implementation("com.mysql:mysql-connector-j:8.3.0")
|
||||||
|
|
||||||
// Provided dependencies (plugins)
|
// Provided dependencies (plugins)
|
||||||
compileOnly("me.clip:placeholderapi:2.11.6")
|
compileOnly("me.clip:placeholderapi:2.11.6")
|
||||||
|
|
@ -111,8 +113,11 @@ tasks.shadowJar {
|
||||||
relocate("com.google.gson", "dev.loki.loparkour.lib.gson")
|
relocate("com.google.gson", "dev.loki.loparkour.lib.gson")
|
||||||
relocate("org.bstats", "dev.loki.loparkour.lib.bstats")
|
relocate("org.bstats", "dev.loki.loparkour.lib.bstats")
|
||||||
|
|
||||||
// Minimize jar size
|
// Minimize jar size (keep JDBC pool + driver intact)
|
||||||
minimize()
|
minimize {
|
||||||
|
exclude(dependency("com.zaxxer:HikariCP:.*"))
|
||||||
|
exclude(dependency("com.mysql:mysql-connector-j:.*"))
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
tasks.named("build") {
|
tasks.named("build") {
|
||||||
|
|
|
||||||
|
|
@ -18,9 +18,9 @@ import dev.loki.loparkour.player.UserRegistry;
|
||||||
import dev.loki.loparkour.reward.Rewards;
|
import dev.loki.loparkour.reward.Rewards;
|
||||||
import dev.loki.loparkour.schematic.lpschem.LPSchematicManager;
|
import dev.loki.loparkour.schematic.lpschem.LPSchematicManager;
|
||||||
import dev.loki.loparkour.storage.Storage;
|
import dev.loki.loparkour.storage.Storage;
|
||||||
|
import dev.loki.loparkour.world.Divider;
|
||||||
import dev.loki.loparkour.world.World;
|
import dev.loki.loparkour.world.World;
|
||||||
import dev.lolib.core.LoPlugin;
|
import dev.lolib.core.LoPlugin;
|
||||||
import dev.lolib.scheduler.Scheduler;
|
|
||||||
import com.google.gson.Gson;
|
import com.google.gson.Gson;
|
||||||
import com.google.gson.GsonBuilder;
|
import com.google.gson.GsonBuilder;
|
||||||
import org.bstats.bukkit.Metrics;
|
import org.bstats.bukkit.Metrics;
|
||||||
|
|
@ -30,6 +30,7 @@ import org.bstats.charts.SingleLineChart;
|
||||||
import org.jetbrains.annotations.Nullable;
|
import org.jetbrains.annotations.Nullable;
|
||||||
|
|
||||||
import java.io.File;
|
import java.io.File;
|
||||||
|
import java.util.ArrayList;
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Main class of LoParkour
|
* Main class of LoParkour
|
||||||
|
|
@ -171,7 +172,7 @@ public final class LoParkour extends LoPlugin {
|
||||||
Metrics metrics = new Metrics(this, 29754);
|
Metrics metrics = new Metrics(this, 29754);
|
||||||
metrics.addCustomChart(new SimplePie("using_sql", () -> Boolean.toString(Option.SQL)));
|
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("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", () -> {
|
metrics.addCustomChart(new SingleLineChart("player_joins", () -> {
|
||||||
int joins = UserRegistry.getJoinCount();
|
int joins = UserRegistry.getJoinCount();
|
||||||
return joins;
|
return joins;
|
||||||
|
|
@ -181,12 +182,30 @@ public final class LoParkour extends LoPlugin {
|
||||||
@Override
|
@Override
|
||||||
public void disable() {
|
public void disable() {
|
||||||
try {
|
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()) {
|
for (ParkourUser user : ParkourUser.getUsers()) {
|
||||||
ParkourUser.leave(user);
|
ParkourUser.leave(user);
|
||||||
}
|
}
|
||||||
|
|
||||||
// write all LoParkour gamemodes
|
// write all LoParkour gamemodes (may be null if enable failed)
|
||||||
|
if (Modes.DEFAULT != null && Modes.DEFAULT.getLeaderboard() != null) {
|
||||||
Modes.DEFAULT.getLeaderboard().write(false);
|
Modes.DEFAULT.getLeaderboard().write(false);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Clear rewards to prevent memory leaks
|
||||||
|
dev.loki.loparkour.reward.Rewards.clear();
|
||||||
|
|
||||||
Storage.close();
|
Storage.close();
|
||||||
World.delete();
|
World.delete();
|
||||||
|
|
|
||||||
|
|
@ -16,6 +16,7 @@ import dev.loki.loparkour.player.ParkourUser;
|
||||||
import dev.loki.loparkour.session.Session;
|
import dev.loki.loparkour.session.Session;
|
||||||
import dev.loki.loparkour.util.ColorUtil;
|
import dev.loki.loparkour.util.ColorUtil;
|
||||||
import org.bukkit.Bukkit;
|
import org.bukkit.Bukkit;
|
||||||
|
import org.bukkit.ChatColor;
|
||||||
import org.bukkit.command.Command;
|
import org.bukkit.command.Command;
|
||||||
import org.bukkit.command.CommandExecutor;
|
import org.bukkit.command.CommandExecutor;
|
||||||
import org.bukkit.command.CommandSender;
|
import org.bukkit.command.CommandSender;
|
||||||
|
|
@ -28,13 +29,13 @@ import java.util.ArrayList;
|
||||||
import java.util.Arrays;
|
import java.util.Arrays;
|
||||||
import java.util.Collections;
|
import java.util.Collections;
|
||||||
import java.util.List;
|
import java.util.List;
|
||||||
|
import java.util.logging.Level;
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Main command router for /LoParkour and /parkour.
|
* Main command router for /LoParkour and /parkour.
|
||||||
* Delegates to {@link PlayerCommandHandler}, {@link AdminCommandHandler},
|
* Delegates to {@link PlayerCommandHandler}, {@link AdminCommandHandler},
|
||||||
* and {@link SchematicCommandHandler}.
|
* and {@link SchematicCommandHandler}.
|
||||||
*/
|
*/
|
||||||
@SuppressWarnings("deprecation")
|
|
||||||
public class LoParkourCommand implements CommandExecutor, TabCompleter {
|
public class LoParkourCommand implements CommandExecutor, TabCompleter {
|
||||||
|
|
||||||
private final PlayerCommandHandler player = new PlayerCommandHandler();
|
private final PlayerCommandHandler player = new PlayerCommandHandler();
|
||||||
|
|
@ -43,6 +44,7 @@ public class LoParkourCommand implements CommandExecutor, TabCompleter {
|
||||||
@Override
|
@Override
|
||||||
public boolean onCommand(@NotNull CommandSender sender, @NotNull Command command,
|
public boolean onCommand(@NotNull CommandSender sender, @NotNull Command command,
|
||||||
@NotNull String label, @NotNull String[] args) {
|
@NotNull String label, @NotNull String[] args) {
|
||||||
|
try {
|
||||||
Player p = sender instanceof Player ? (Player) sender : null;
|
Player p = sender instanceof Player ? (Player) sender : null;
|
||||||
|
|
||||||
switch (args.length) {
|
switch (args.length) {
|
||||||
|
|
@ -50,8 +52,15 @@ public class LoParkourCommand implements CommandExecutor, TabCompleter {
|
||||||
case 1 -> player.handle(args[0], sender, p);
|
case 1 -> player.handle(args[0], sender, p);
|
||||||
case 2 -> handle2(args[0], args[1], sender, p);
|
case 2 -> handle2(args[0], args[1], sender, p);
|
||||||
case 3 -> handle3(args[0], args[1], args[2], 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;
|
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;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// ── 2-arg routing ──────────────────────────────────────────────────────────
|
// ── 2-arg routing ──────────────────────────────────────────────────────────
|
||||||
|
|
@ -108,7 +117,12 @@ public class LoParkourCommand implements CommandExecutor, TabCompleter {
|
||||||
if (user != null && user.session == session) return;
|
if (user != null && user.session == session) return;
|
||||||
|
|
||||||
if (session.isAcceptingPlayers()) {
|
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 {
|
} else {
|
||||||
Modes.SPECTATOR.create(p, session);
|
Modes.SPECTATOR.create(p, session);
|
||||||
}
|
}
|
||||||
|
|
@ -153,7 +167,10 @@ public class LoParkourCommand implements CommandExecutor, TabCompleter {
|
||||||
completions.add("everyone");
|
completions.add("everyone");
|
||||||
ParkourPlayer.getPlayers().forEach(pp -> completions.add(pp.getName()));
|
ParkourPlayer.getPlayers().forEach(pp -> completions.add(pp.getName()));
|
||||||
} else if (a1.equals("join") && ParkourOption.JOIN.mayPerform(sender)) {
|
} 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()));
|
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)) {
|
} else if (a1.equals("schematic") && sender.hasPermission(ParkourOption.ADMIN.permission)) {
|
||||||
completions.addAll(Arrays.asList("wand", "pos1", "pos2", "save", "paste", "list", "reload"));
|
completions.addAll(Arrays.asList("wand", "pos1", "pos2", "save", "paste", "list", "reload"));
|
||||||
} else if ((a1.equals("forcejoin") || a1.equals("forceleave")) && sender.hasPermission(ParkourOption.ADMIN.permission)) {
|
} 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>
|
* <li>For Schematics, please view {@link dev.efnilite.LoParkour.schematic.LPSchematic}</li>
|
||||||
* </ul>
|
* </ul>
|
||||||
*/
|
*/
|
||||||
@SuppressWarnings("unused")
|
|
||||||
public class ParkourAPI {
|
public class ParkourAPI {
|
||||||
|
|
||||||
private ParkourAPI() throws IllegalAccessException {
|
private ParkourAPI() throws IllegalAccessException {
|
||||||
|
|
|
||||||
|
|
@ -1,6 +1,5 @@
|
||||||
package dev.loki.loparkour.api;
|
package dev.loki.loparkour.api;
|
||||||
|
|
||||||
import dev.loki.loparkour.LoParkour;
|
|
||||||
import dev.loki.loparkour.mode.Mode;
|
import dev.loki.loparkour.mode.Mode;
|
||||||
import dev.loki.loparkour.style.Style;
|
import dev.loki.loparkour.style.Style;
|
||||||
import org.jetbrains.annotations.NotNull;
|
import org.jetbrains.annotations.NotNull;
|
||||||
|
|
|
||||||
|
|
@ -17,11 +17,11 @@ public class ParkourSchematicGenerateEvent extends Event {
|
||||||
|
|
||||||
private static final HandlerList HANDLERS = new HandlerList();
|
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 ParkourGenerator generator;
|
||||||
public final ParkourPlayer player;
|
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.schematic = schematic;
|
||||||
this.generator = generator;
|
this.generator = generator;
|
||||||
this.player = player;
|
this.player = player;
|
||||||
|
|
|
||||||
|
|
@ -2,8 +2,6 @@ package dev.loki.loparkour.command;
|
||||||
|
|
||||||
import dev.loki.loparkour.LoParkour;
|
import dev.loki.loparkour.LoParkour;
|
||||||
import dev.loki.loparkour.api.Registry;
|
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.menu.ParkourOption;
|
||||||
import dev.loki.loparkour.mode.Mode;
|
import dev.loki.loparkour.mode.Mode;
|
||||||
import dev.loki.loparkour.mode.Modes;
|
import dev.loki.loparkour.mode.Modes;
|
||||||
|
|
@ -28,6 +26,8 @@ import java.util.UUID;
|
||||||
*/
|
*/
|
||||||
public class AdminCommandHandler {
|
public class AdminCommandHandler {
|
||||||
|
|
||||||
|
private static final String COOLDOWN_HINT = "<gray>Please wait before using this again.";
|
||||||
|
|
||||||
private final PlayerCommandHandler base;
|
private final PlayerCommandHandler base;
|
||||||
|
|
||||||
public AdminCommandHandler(PlayerCommandHandler base) {
|
public AdminCommandHandler(PlayerCommandHandler base) {
|
||||||
|
|
@ -56,6 +56,8 @@ public class AdminCommandHandler {
|
||||||
// ── forcejoin ──────────────────────────────────────────────────────────────
|
// ── forcejoin ──────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
private void handleForceJoin(String target, CommandSender sender) {
|
private void handleForceJoin(String target, CommandSender sender) {
|
||||||
|
if (!base.cooldown(sender, "forcejoin", 2500, COOLDOWN_HINT)) return;
|
||||||
|
|
||||||
if (target.equalsIgnoreCase("everyone")) {
|
if (target.equalsIgnoreCase("everyone")) {
|
||||||
Bukkit.getOnlinePlayers().forEach(p -> Modes.DEFAULT.create(p));
|
Bukkit.getOnlinePlayers().forEach(p -> Modes.DEFAULT.create(p));
|
||||||
send(sender, LoParkour.PREFIX + "Force joined everyone!");
|
send(sender, LoParkour.PREFIX + "Force joined everyone!");
|
||||||
|
|
@ -76,6 +78,8 @@ public class AdminCommandHandler {
|
||||||
// ── forceleave ─────────────────────────────────────────────────────────────
|
// ── forceleave ─────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
private void handleForceLeave(String target, CommandSender sender) {
|
private void handleForceLeave(String target, CommandSender sender) {
|
||||||
|
if (!base.cooldown(sender, "forceleave", 2500, COOLDOWN_HINT)) return;
|
||||||
|
|
||||||
if (target.equalsIgnoreCase("everyone")) {
|
if (target.equalsIgnoreCase("everyone")) {
|
||||||
ParkourPlayer.getPlayers().forEach(ParkourUser::leave);
|
ParkourPlayer.getPlayers().forEach(ParkourUser::leave);
|
||||||
send(sender, LoParkour.PREFIX + "Force kicked everyone!");
|
send(sender, LoParkour.PREFIX + "Force kicked everyone!");
|
||||||
|
|
@ -91,7 +95,7 @@ public class AdminCommandHandler {
|
||||||
// ── reset ──────────────────────────────────────────────────────────────────
|
// ── reset ──────────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
private void handleReset(String target, CommandSender sender) {
|
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")) {
|
if (target.equalsIgnoreCase("everyone")) {
|
||||||
Registry.getModes().stream()
|
Registry.getModes().stream()
|
||||||
|
|
@ -116,7 +120,7 @@ public class AdminCommandHandler {
|
||||||
// ── recoverinventory ───────────────────────────────────────────────────────
|
// ── recoverinventory ───────────────────────────────────────────────────────
|
||||||
|
|
||||||
private void handleRecoverInventory(String target, CommandSender sender) {
|
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);
|
Player other = Bukkit.getPlayer(target);
|
||||||
if (other == null) { send(sender, LoParkour.PREFIX + "Player not online!"); return; }
|
if (other == null) { send(sender, LoParkour.PREFIX + "Player not online!"); return; }
|
||||||
|
|
||||||
|
|
@ -142,10 +146,17 @@ public class AdminCommandHandler {
|
||||||
.orElse(null);
|
.orElse(null);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@SuppressWarnings("deprecation") // Bukkit: name-based OfflinePlayer lookup (admin-only)
|
||||||
private UUID resolveUUID(String input) {
|
private UUID resolveUUID(String input) {
|
||||||
Player online = Bukkit.getPlayerExact(input);
|
Player online = Bukkit.getPlayerExact(input);
|
||||||
if (online != null) return online.getUniqueId();
|
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();
|
return Bukkit.getOfflinePlayer(input).getUniqueId();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -60,7 +60,13 @@ public class PlayerCommandHandler {
|
||||||
case "menu", "main" -> {
|
case "menu", "main" -> {
|
||||||
if (ParkourOption.MAIN.mayPerform(player)) Menus.MAIN.open(player);
|
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" -> {
|
case "schematic" -> {
|
||||||
if (!player.hasPermission(ParkourOption.ADMIN.permission)) {
|
if (!player.hasPermission(ParkourOption.ADMIN.permission)) {
|
||||||
send(sender, Locales.getString(player, "other.no_do"));
|
send(sender, Locales.getString(player, "other.no_do"));
|
||||||
|
|
@ -107,10 +113,22 @@ public class PlayerCommandHandler {
|
||||||
}
|
}
|
||||||
|
|
||||||
public boolean cooldown(CommandSender sender, String key, long millis) {
|
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;
|
String fullKey = sender.getName() + ":" + key;
|
||||||
long now = System.currentTimeMillis();
|
long now = System.currentTimeMillis();
|
||||||
Long last = cooldowns.get(fullKey);
|
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);
|
cooldowns.put(fullKey, now);
|
||||||
return true;
|
return true;
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -1,22 +1,15 @@
|
||||||
package dev.loki.loparkour.config;
|
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.FileConfiguration;
|
||||||
import org.bukkit.configuration.file.YamlConfiguration;
|
|
||||||
import org.jetbrains.annotations.NotNull;
|
import org.jetbrains.annotations.NotNull;
|
||||||
import org.jetbrains.annotations.Nullable;
|
import org.jetbrains.annotations.Nullable;
|
||||||
|
|
||||||
import java.io.File;
|
import java.io.File;
|
||||||
import java.util.ArrayList;
|
|
||||||
import java.util.List;
|
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 {
|
public enum Config {
|
||||||
|
|
||||||
|
|
@ -25,252 +18,102 @@ public enum Config {
|
||||||
REWARDS("rewards-v2.yml", List.of("score-rewards", "interval-rewards", "one-time-rewards")),
|
REWARDS("rewards-v2.yml", List.of("score-rewards", "interval-rewards", "one-time-rewards")),
|
||||||
SCHEMATICS("schematics/schematics.yml", List.of("difficulty"));
|
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;
|
public File path;
|
||||||
/**
|
|
||||||
* The name of this file, e.g. config.yml
|
|
||||||
*/
|
|
||||||
public final String fileName;
|
public final String fileName;
|
||||||
/**
|
|
||||||
* The sections in the file that will be ignored when updating the keys.
|
|
||||||
*/
|
|
||||||
public final List<String> ignoredSections;
|
public final List<String> ignoredSections;
|
||||||
/**
|
|
||||||
* The {@link FileConfiguration} instance associated with this config file.
|
|
||||||
*/
|
|
||||||
public FileConfiguration fileConfiguration;
|
public FileConfiguration fileConfiguration;
|
||||||
|
|
||||||
|
private ConfigAccessor accessor;
|
||||||
|
|
||||||
Config(String fileName, @Nullable List<String> ignoredSections) {
|
Config(String fileName, @Nullable List<String> ignoredSections) {
|
||||||
this.fileName = fileName;
|
this.fileName = fileName;
|
||||||
this.ignoredSections = ignoredSections;
|
this.ignoredSections = ignoredSections;
|
||||||
// path resolved lazily via getPath() — do NOT call LoParkour.getInFolder() here,
|
this.path = null; // Resolved lazily
|
||||||
// because enum constants are initialized before onEnable() sets the instance.
|
|
||||||
this.path = null; // placeholder; real path resolved on first access
|
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Reloads all config files.
|
* Reload all configuration files.
|
||||||
* On first call (initialLoad=true) also resolves file paths and saves defaults.
|
|
||||||
*/
|
*/
|
||||||
public static void reload(boolean initialLoad) {
|
public static void reload(boolean initialLoad) {
|
||||||
|
ConfigLoader.reloadAllConfigs(initialLoad);
|
||||||
|
|
||||||
|
// Update accessors for all configs
|
||||||
for (Config config : values()) {
|
for (Config config : values()) {
|
||||||
if (initialLoad) {
|
if (config.fileConfiguration != null) {
|
||||||
// Resolve path now that instance is guaranteed to be set
|
config.accessor = new ConfigAccessor(config.fileConfiguration, config.fileName);
|
||||||
config.path = LoParkour.getInFolder(config.fileName);
|
|
||||||
if (!config.path.exists()) {
|
|
||||||
LoParkour.getPlugin().saveResource(config.fileName, false);
|
|
||||||
}
|
}
|
||||||
config.update();
|
|
||||||
}
|
|
||||||
config.load();
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// Validate configuration values
|
// Now that accessors exist, initialize systems that depend on Config getters
|
||||||
validateConfigs();
|
ConfigLoader.initializeDependentSystems(initialLoad);
|
||||||
|
|
||||||
// read config stuff
|
|
||||||
Rewards.init();
|
|
||||||
Locales.init();
|
|
||||||
Schematics.init();
|
|
||||||
Option.init(initialLoad);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Validates critical configuration values to prevent crashes and unexpected behavior.
|
* Load this configuration file from disk.
|
||||||
*/
|
|
||||||
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.
|
|
||||||
*/
|
*/
|
||||||
public void load() {
|
public void load() {
|
||||||
if (path == null) return; // not yet initialized, skip
|
if (path == null) return;
|
||||||
this.fileConfiguration = YamlConfiguration.loadConfiguration(path);
|
|
||||||
|
this.fileConfiguration = ConfigLoader.loadConfig(path);
|
||||||
|
this.accessor = new ConfigAccessor(fileConfiguration, fileName);
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Updates the file so all keys are present.
|
* Update this configuration file with new keys.
|
||||||
* Any key that exists in the bundled default but is missing on disk
|
|
||||||
* will be appended to the correct section automatically.
|
|
||||||
*/
|
*/
|
||||||
public void update() {
|
public void update() {
|
||||||
try {
|
if (path != null) {
|
||||||
ConfigUpdater.update(LoParkour.getPlugin(), fileName, path, ignoredSections);
|
ConfigLoader.updateConfig(fileName, path, ignoredSections);
|
||||||
} catch (Exception ex) {
|
|
||||||
LoParkour.getPlugin().getLogger().log(java.util.logging.Level.SEVERE,
|
|
||||||
"Error while trying to update config file: " + fileName, ex);
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
// Delegate all accessor methods to ConfigAccessor
|
||||||
* @param path The path.
|
|
||||||
* @return True when path exists, false if not.
|
|
||||||
*/
|
|
||||||
public boolean isPath(@NotNull String path) {
|
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) {
|
public Object get(@NotNull String path) {
|
||||||
check(path);
|
return accessor.get(path);
|
||||||
|
|
||||||
return fileConfiguration.get(path);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
|
||||||
* @param path The path.
|
|
||||||
* @return The boolean value at path.
|
|
||||||
*/
|
|
||||||
public boolean getBoolean(@NotNull String path) {
|
public boolean getBoolean(@NotNull String path) {
|
||||||
check(path);
|
return accessor.getBoolean(path);
|
||||||
|
}
|
||||||
return fileConfiguration.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) {
|
public int getInt(@NotNull String path) {
|
||||||
check(path);
|
return accessor.getInt(path);
|
||||||
|
}
|
||||||
return fileConfiguration.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) {
|
public double getDouble(@NotNull String path) {
|
||||||
check(path);
|
return accessor.getDouble(path);
|
||||||
|
|
||||||
return fileConfiguration.getDouble(path);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
|
||||||
* @param path The path.
|
|
||||||
* @return The String value at path.
|
|
||||||
*/
|
|
||||||
@NotNull
|
@NotNull
|
||||||
public String getString(@NotNull String path) {
|
public String getString(@NotNull String path) {
|
||||||
check(path);
|
return accessor.getString(path);
|
||||||
|
|
||||||
return fileConfiguration.getString(path, "");
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
|
||||||
* @param path The path.
|
|
||||||
* @return The String list value at path.
|
|
||||||
*/
|
|
||||||
@NotNull
|
@NotNull
|
||||||
public List<String> getStringList(@NotNull String path) {
|
public List<String> getStringList(@NotNull String path) {
|
||||||
check(path);
|
return accessor.getStringList(path);
|
||||||
|
|
||||||
return fileConfiguration.getStringList(path);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
|
||||||
* @param path The path.
|
|
||||||
* @return The int list value at path.
|
|
||||||
*/
|
|
||||||
@NotNull
|
@NotNull
|
||||||
public List<Integer> getIntList(@NotNull String path) {
|
public List<Integer> getIntList(@NotNull String path) {
|
||||||
check(path);
|
return accessor.getIntList(path);
|
||||||
|
|
||||||
return fileConfiguration.getIntegerList(path);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
|
||||||
* @param path The path.
|
|
||||||
* @param deep Whether search should include children of children as well.
|
|
||||||
* @return The children nodes from path.
|
|
||||||
*/
|
|
||||||
@NotNull
|
@NotNull
|
||||||
public List<String> getChildren(@NotNull String path, boolean... deep) {
|
public List<String> getChildren(@NotNull String path, boolean... deep) {
|
||||||
check(path);
|
return accessor.getChildren(path, deep);
|
||||||
|
|
||||||
ConfigurationSection section = fileConfiguration.getConfigurationSection(path);
|
|
||||||
|
|
||||||
if (section == null) {
|
|
||||||
return new ArrayList<>();
|
|
||||||
}
|
|
||||||
|
|
||||||
boolean isDeep = deep.length > 0 && deep[0];
|
|
||||||
return new ArrayList<>(section.getKeys(isDeep));
|
|
||||||
}
|
|
||||||
|
|
||||||
// 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;
|
package dev.loki.loparkour.config;
|
||||||
|
|
||||||
import org.bukkit.plugin.Plugin;
|
|
||||||
import org.jetbrains.annotations.NotNull;
|
import org.jetbrains.annotations.NotNull;
|
||||||
import org.jetbrains.annotations.Nullable;
|
import org.jetbrains.annotations.Nullable;
|
||||||
|
|
||||||
import java.io.*;
|
import java.io.*;
|
||||||
import java.nio.charset.StandardCharsets;
|
import java.nio.file.Files;
|
||||||
|
import java.nio.file.StandardCopyOption;
|
||||||
import java.util.*;
|
import java.util.*;
|
||||||
import java.util.logging.Level;
|
import java.util.logging.Logger;
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Automatically adds missing keys from the default (bundled) config
|
* Merges a resource-template YAML with the user's existing file,
|
||||||
* into the player's config file on disk, preserving all existing values,
|
* preserving user values while adding any new keys from the template.
|
||||||
* comments, and blank lines.
|
* Uses full YAML paths (e.g. "sql.enabled") to avoid key collisions.
|
||||||
*
|
|
||||||
* <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).
|
|
||||||
*/
|
*/
|
||||||
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(
|
public static void update(
|
||||||
@NotNull Plugin plugin,
|
@NotNull File configFile,
|
||||||
@NotNull String resourceName,
|
@NotNull InputStream templateStream,
|
||||||
@NotNull File diskFile,
|
@NotNull String expectedVersion,
|
||||||
@Nullable List<String> ignoredSections
|
@Nullable List<String> ignoredKeys,
|
||||||
) throws IOException {
|
@NotNull Logger logger) throws IOException {
|
||||||
|
|
||||||
List<String> defaultLines = readResource(plugin, resourceName);
|
new ConfigUpdater(logger).performUpdate(configFile, templateStream, ignoredKeys);
|
||||||
if (defaultLines == null) {
|
|
||||||
plugin.getLogger().warning("[ConfigUpdater] Resource not found in jar: " + resourceName);
|
|
||||||
return;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// Flat key set of what already exists on disk (dot-separated paths)
|
// ── core ──────────────────────────────────────────────────────────────────
|
||||||
Set<String> diskKeys = flatKeys(diskFile);
|
|
||||||
|
|
||||||
// Ignored section prefixes (e.g. "styles" → skip any path starting with "styles.")
|
private void performUpdate(
|
||||||
Set<String> ignored = normalizedIgnored(ignoredSections);
|
@NotNull File configFile,
|
||||||
|
@NotNull InputStream templateStream,
|
||||||
|
@Nullable List<String> ignoredKeys) throws IOException {
|
||||||
|
|
||||||
List<String> diskLines = readLines(diskFile);
|
File backup = createBackup(configFile);
|
||||||
List<String> outputLines = merge(defaultLines, diskLines, diskKeys, ignored);
|
try {
|
||||||
|
List<String> merged = merge(templateStream, configFile, ignoredKeys);
|
||||||
// Only write if something actually changed
|
Files.write(configFile.toPath(), merged);
|
||||||
if (!outputLines.equals(diskLines)) {
|
logger.info("Configuration file synced: " + configFile.getName());
|
||||||
writeLines(diskFile, outputLines);
|
} catch (Exception e) {
|
||||||
plugin.getLogger().info("[ConfigUpdater] Updated " + resourceName
|
if (backup != null && backup.exists()) {
|
||||||
+ " — added " + countNew(diskLines, outputLines) + " new key(s).");
|
Files.copy(backup.toPath(), configFile.toPath(), StandardCopyOption.REPLACE_EXISTING);
|
||||||
|
logger.severe("Config sync failed, restored backup: " + e.getMessage());
|
||||||
|
}
|
||||||
|
throw e;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// ── Merge ─────────────────────────────────────────────────────────────────
|
// ── merge ─────────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
/**
|
@NotNull
|
||||||
* Merges default lines into disk lines.
|
private List<String> merge(
|
||||||
*
|
@NotNull InputStream templateStream,
|
||||||
* <p>Strategy: walk disk lines first, emit them as-is. Then walk default
|
@NotNull File existingFile,
|
||||||
* lines; for each key block (comment + key line) whose key is absent on
|
@Nullable List<String> ignoredKeys) throws IOException {
|
||||||
* 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);
|
|
||||||
|
|
||||||
// Collect blocks from the default file: each block = list of comment
|
List<String> templateLines = readLines(templateStream);
|
||||||
// lines + the key line itself. We track the "path stack" to know the
|
List<String> existingLines = existingFile.exists() ? readLines(existingFile) : List.of();
|
||||||
// full dotted path of every key.
|
|
||||||
Deque<String> pathStack = new ArrayDeque<>();
|
|
||||||
List<String> pendingComments = new ArrayList<>();
|
|
||||||
|
|
||||||
for (int i = 0; i < defaultLines.size(); i++) {
|
Set<String> ignored = normalizeIgnored(ignoredKeys);
|
||||||
String raw = defaultLines.get(i);
|
Map<String, String> existingValues = extractFullPathValues(existingLines);
|
||||||
String trimmed = raw.stripLeading();
|
|
||||||
|
|
||||||
// Blank line → flush pending comments, keep as separator
|
List<String> result = new ArrayList<>();
|
||||||
if (trimmed.isBlank()) {
|
Deque<String> stack = new ArrayDeque<>();
|
||||||
pendingComments.add(raw);
|
int prevIndent = -1;
|
||||||
|
|
||||||
|
for (String line : templateLines) {
|
||||||
|
String trimmed = line.trim();
|
||||||
|
|
||||||
|
if (trimmed.isEmpty() || trimmed.startsWith("#") || trimmed.startsWith("-")) {
|
||||||
|
result.add(line);
|
||||||
continue;
|
continue;
|
||||||
}
|
}
|
||||||
|
|
||||||
// Comment line → accumulate
|
int colon = trimmed.indexOf(':');
|
||||||
if (trimmed.startsWith("#")) {
|
if (colon == -1) { result.add(line); continue; }
|
||||||
pendingComments.add(raw);
|
|
||||||
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
|
String fullPath = stack.isEmpty() ? leaf : String.join(".", stack) + "." + leaf;
|
||||||
int indent = raw.length() - trimmed.length();
|
boolean section = after.isEmpty() || after.startsWith("#");
|
||||||
int depth = indent / 2; // assume 2-space indentation
|
|
||||||
|
|
||||||
// Pop stack back to current depth
|
if (section) {
|
||||||
while (pathStack.size() > depth) pathStack.pollLast();
|
stack.push(leaf);
|
||||||
|
prevIndent = indent;
|
||||||
// Parse key name (everything before ':')
|
result.add(line);
|
||||||
String keyPart = trimmed.split(":")[0].trim();
|
} else if (isIgnored(fullPath, ignored)) {
|
||||||
pathStack.addLast(keyPart);
|
result.add(line);
|
||||||
String fullPath = String.join(".", pathStack);
|
prevIndent = indent;
|
||||||
|
} else {
|
||||||
boolean isSection = trimmed.endsWith(":") || trimmed.matches(".*:\\s*$");
|
String existing = existingValues.get(fullPath);
|
||||||
boolean isIgnored = isIgnored(fullPath, ignored);
|
result.add(existing != null
|
||||||
|
? line.substring(0, line.indexOf(':') + 1) + " " + existing
|
||||||
if (!isIgnored && !diskKeys.contains(fullPath) && !isSection) {
|
: line);
|
||||||
// This key is missing on disk — inject it
|
prevIndent = indent;
|
||||||
// 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);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// Clear pending comments regardless
|
|
||||||
pendingComments.clear();
|
|
||||||
}
|
|
||||||
|
|
||||||
return result;
|
return result;
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
@NotNull
|
||||||
* Finds the line index where a missing key should be inserted.
|
private Map<String, String> extractFullPathValues(@NotNull List<String> lines) {
|
||||||
* Inserts after the last line of its parent section.
|
Map<String, String> values = new LinkedHashMap<>();
|
||||||
*/
|
Deque<String> stack = new ArrayDeque<>();
|
||||||
private static int findInsertionPoint(List<String> lines, Deque<String> pathStack, int depth) {
|
int prevIndent = -1;
|
||||||
if (depth == 0) {
|
|
||||||
// Top-level key → append at end
|
for (String line : lines) {
|
||||||
return lines.size();
|
String trimmed = line.trim();
|
||||||
|
if (trimmed.isEmpty() || trimmed.startsWith("#") || trimmed.startsWith("-")) continue;
|
||||||
|
|
||||||
|
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;
|
||||||
}
|
}
|
||||||
|
|
||||||
// Parent path
|
String fullPath = stack.isEmpty() ? leaf : String.join(".", stack) + "." + leaf;
|
||||||
List<String> parts = new ArrayList<>(pathStack);
|
|
||||||
String parentKey = parts.get(depth - 1);
|
|
||||||
int parentIndent = (depth - 1) * 2;
|
|
||||||
|
|
||||||
// Find the parent key line in the result
|
if (after.isEmpty() || after.startsWith("#")) {
|
||||||
int parentLine = -1;
|
stack.push(leaf);
|
||||||
for (int i = 0; i < lines.size(); i++) {
|
} else {
|
||||||
String l = lines.get(i);
|
values.put(fullPath, after);
|
||||||
int ind = l.length() - l.stripLeading().length();
|
|
||||||
if (ind == parentIndent && l.stripLeading().startsWith(parentKey + ":")) {
|
|
||||||
parentLine = i;
|
|
||||||
break;
|
|
||||||
}
|
}
|
||||||
|
prevIndent = indent;
|
||||||
|
}
|
||||||
|
return values;
|
||||||
}
|
}
|
||||||
|
|
||||||
if (parentLine < 0) return lines.size();
|
// ── helpers ───────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
// Find end of parent section: next line with same or lower indent that isn't blank/comment
|
private static int indentOf(@NotNull String line) {
|
||||||
for (int i = parentLine + 1; i < lines.size(); i++) {
|
int i = 0;
|
||||||
String l = lines.get(i);
|
while (i < line.length() && line.charAt(i) == ' ') i++;
|
||||||
if (l.isBlank() || l.stripLeading().startsWith("#")) continue;
|
return i;
|
||||||
int ind = l.length() - l.stripLeading().length();
|
|
||||||
if (ind <= parentIndent) return i;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
return lines.size();
|
|
||||||
}
|
|
||||||
|
|
||||||
// ── IO helpers ─────────────────────────────────────────────────────────────
|
|
||||||
|
|
||||||
/** Reads a resource from the jar as a list of lines. Returns null if not found. */
|
|
||||||
@Nullable
|
@Nullable
|
||||||
private static List<String> readResource(Plugin plugin, String name) {
|
private File createBackup(@NotNull File file) {
|
||||||
try (InputStream in = plugin.getResource(name)) {
|
if (!file.exists()) return null;
|
||||||
if (in == null) return null;
|
try {
|
||||||
return readLines(in);
|
File bak = new File(file.getParent(), file.getName() + ".bak");
|
||||||
} catch (IOException ex) {
|
Files.copy(file.toPath(), bak.toPath(), StandardCopyOption.REPLACE_EXISTING);
|
||||||
plugin.getLogger().log(Level.SEVERE, "Failed to read resource: " + name, ex);
|
return bak;
|
||||||
|
} catch (IOException e) {
|
||||||
|
logger.warning("Failed to create backup: " + e.getMessage());
|
||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
private static List<String> readLines(File file) throws IOException {
|
@NotNull
|
||||||
try (BufferedReader r = new BufferedReader(
|
private static List<String> readLines(@NotNull InputStream is) throws IOException {
|
||||||
new InputStreamReader(new FileInputStream(file), StandardCharsets.UTF_8))) {
|
try (var r = new BufferedReader(new InputStreamReader(is))) {
|
||||||
return readLines(r);
|
return r.lines().toList();
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
private static List<String> readLines(InputStream in) throws IOException {
|
@NotNull
|
||||||
try (BufferedReader r = new BufferedReader(new InputStreamReader(in, StandardCharsets.UTF_8))) {
|
private static List<String> readLines(@NotNull File file) throws IOException {
|
||||||
return readLines(r);
|
return Files.readAllLines(file.toPath());
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
private static List<String> readLines(BufferedReader reader) throws IOException {
|
@NotNull
|
||||||
List<String> lines = new ArrayList<>();
|
private static Set<String> normalizeIgnored(@Nullable List<String> keys) {
|
||||||
String line;
|
if (keys == null) return Set.of();
|
||||||
while ((line = reader.readLine()) != null) lines.add(line);
|
Set<String> s = new HashSet<>();
|
||||||
return lines;
|
for (String k : keys) s.add(k.trim().toLowerCase());
|
||||||
|
return s;
|
||||||
}
|
}
|
||||||
|
|
||||||
private static void writeLines(File file, List<String> lines) throws IOException {
|
private static boolean isIgnored(@NotNull String fullPath, @NotNull Set<String> ignored) {
|
||||||
try (BufferedWriter w = new BufferedWriter(
|
String lower = fullPath.toLowerCase();
|
||||||
new OutputStreamWriter(new FileOutputStream(file), StandardCharsets.UTF_8))) {
|
for (String ign : ignored) {
|
||||||
for (int i = 0; i < lines.size(); i++) {
|
if (lower.equals(ign) || lower.startsWith(ign + ".")) return true;
|
||||||
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;
|
|
||||||
}
|
}
|
||||||
return false;
|
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;
|
package dev.loki.loparkour.config;
|
||||||
|
|
||||||
import java.util.ArrayList;
|
|
||||||
|
|
||||||
import dev.loki.loparkour.util.Item;
|
|
||||||
|
|
||||||
import dev.loki.loparkour.LoParkour;
|
import dev.loki.loparkour.LoParkour;
|
||||||
import dev.loki.loparkour.menu.ParkourOption;
|
import dev.loki.loparkour.menu.ParkourOption;
|
||||||
import dev.loki.loparkour.player.ParkourUser;
|
import dev.loki.loparkour.player.ParkourUser;
|
||||||
|
import dev.loki.loparkour.util.Item;
|
||||||
|
import dev.loki.loparkour.util.Materials;
|
||||||
import dev.lolib.scheduler.Scheduler;
|
import dev.lolib.scheduler.Scheduler;
|
||||||
import org.bukkit.Material;
|
import org.bukkit.Material;
|
||||||
import org.bukkit.configuration.ConfigurationSection;
|
import org.bukkit.configuration.ConfigurationSection;
|
||||||
|
|
@ -30,244 +27,194 @@ import java.util.regex.Pattern;
|
||||||
import java.util.stream.Stream;
|
import java.util.stream.Stream;
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Locale message/item handler.
|
* Locale system: loading, caching, validation, and access.
|
||||||
*/
|
*/
|
||||||
public class Locales {
|
public class Locales {
|
||||||
|
|
||||||
// a list of all nodes, used to check against missing nodes
|
private static final Map<String, FileConfiguration> locales = new HashMap<>();
|
||||||
private static List<String> resourceNodes;
|
private static final Pattern REPLACEMENT_PATTERN = Pattern.compile("%[a-z]");
|
||||||
|
|
||||||
/**
|
// ── public API ────────────────────────────────────────────────────────────
|
||||||
* A map of all locales with their respective yml trees
|
|
||||||
*/
|
|
||||||
public static final Map<String, FileConfiguration> locales = new HashMap<>();
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Initializes this Locale handler.
|
|
||||||
*/
|
|
||||||
public static void init() {
|
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();
|
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(() -> {
|
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
|
Map<String, FileConfiguration> loaded = loadFromDisk(embedded);
|
||||||
resourceNodes = getChildren(embedded, "", true);
|
|
||||||
|
|
||||||
|
Scheduler.get(plugin).run(() -> {
|
||||||
|
synchronized (locales) {
|
||||||
|
locales.clear();
|
||||||
|
locales.putAll(loaded);
|
||||||
|
}
|
||||||
|
LoParkour.log("Locales reloaded successfully (" + loaded.size() + " locales)");
|
||||||
|
});
|
||||||
|
} catch (Exception ex) {
|
||||||
|
plugin.getLogger().severe("Error while loading locale files: " + ex.getMessage());
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
@NotNull
|
||||||
|
private static Map<String, FileConfiguration> loadFromDisk(@NotNull FileConfiguration embedded) throws Exception {
|
||||||
|
Map<String, FileConfiguration> result = new HashMap<>();
|
||||||
File folder = LoParkour.getInFolder("locales");
|
File folder = LoParkour.getInFolder("locales");
|
||||||
|
ensureFolder(folder);
|
||||||
|
|
||||||
// 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())) {
|
try (Stream<Path> stream = Files.list(folder.toPath())) {
|
||||||
stream.forEach(path -> {
|
stream.forEach(path -> {
|
||||||
File file = path.toFile();
|
File file = path.toFile();
|
||||||
|
if (!file.getName().endsWith(".yml")) return;
|
||||||
|
|
||||||
// get locale from file name
|
|
||||||
String locale = file.getName().split("\\.")[0];
|
String locale = file.getName().split("\\.")[0];
|
||||||
|
|
||||||
LoParkour.log("Found locale " + locale);
|
LoParkour.log("Found locale " + locale);
|
||||||
|
|
||||||
FileConfiguration config = YamlConfiguration.loadConfiguration(file);
|
FileConfiguration config = YamlConfiguration.loadConfiguration(file);
|
||||||
validate(embedded, config, file);
|
validateAndFix(embedded, config, file);
|
||||||
|
result.put(locale, config);
|
||||||
newLocales.put(locale, config);
|
|
||||||
});
|
|
||||||
} catch (Exception ex) {
|
|
||||||
LoParkour.getPlugin().getLogger().severe("Error while trying to read locale files - restart/reload your server - " + 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)");
|
|
||||||
});
|
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
return result;
|
||||||
// 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()));
|
private static void ensureFolder(@NotNull File folder) {
|
||||||
|
if (!folder.exists()) folder.mkdirs();
|
||||||
user.set(node, provided.get(node));
|
String[] files = folder.list();
|
||||||
}
|
if (files == null || files.length == 0) {
|
||||||
|
Plugin plugin = LoParkour.getPlugin();
|
||||||
LoParkour.log("Validated locale " + localPath.getName());
|
plugin.saveResource("locales/en.yml", false);
|
||||||
|
plugin.saveResource("locales/ru.yml", false);
|
||||||
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());
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
// ── validation (was LocaleValidator) ──────────────────────────────────────
|
||||||
* 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;
|
private static void validateAndFix(@NotNull FileConfiguration reference, @NotNull FileConfiguration locale, @NotNull File file) {
|
||||||
|
List<String> refNodes = getChildren(reference);
|
||||||
|
List<String> locNodes = getChildren(locale);
|
||||||
|
|
||||||
return getString(locale, path);
|
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());
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
@NotNull
|
||||||
* Gets a coloured String from the provided path in the provided locale file
|
private static List<String> getChildren(@NotNull FileConfiguration config) {
|
||||||
*
|
ConfigurationSection section = config.getConfigurationSection("");
|
||||||
* @param locale The locale
|
return section != null ? new ArrayList<>(section.getKeys(true)) : Collections.emptyList();
|
||||||
* @param path The path
|
|
||||||
* @return a String
|
|
||||||
*/
|
|
||||||
public static String getString(String locale, String path) {
|
|
||||||
return getValue(locale, config -> config.getString(path), "");
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
// ── cache access (was LocaleCache) ────────────────────────────────────────
|
||||||
* 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;
|
|
||||||
}
|
|
||||||
|
|
||||||
|
@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);
|
FileConfiguration config = locales.get(locale);
|
||||||
|
if (config == null) return defaultValue;
|
||||||
return config != null ? f.apply(config) : def;
|
try {
|
||||||
|
T result = extractor.apply(config);
|
||||||
|
return result != null ? result : defaultValue;
|
||||||
|
} catch (Exception e) { return defaultValue; }
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
// ── item builder ──────────────────────────────────────────────────────────
|
||||||
* 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
|
@NotNull
|
||||||
public static Item getItem(@NotNull Player player, String path, String... replace) {
|
private static Item buildItem(@NotNull FileConfiguration config, @NotNull String path, String... replace) {
|
||||||
ParkourUser user = ParkourUser.getUser(player);
|
String material = config.getString("%s.material".formatted(path), "STONE");
|
||||||
String locale = user == null ? Option.OPTIONS_DEFAULTS.get(ParkourOption.LANG) : user.locale;
|
String name = applyReplacements(config.getString("%s.name".formatted(path), ""), replace);
|
||||||
|
String lore = applyReplacements(config.getString("%s.lore".formatted(path), ""), replace);
|
||||||
|
|
||||||
return getItem(locale, path, replace);
|
Material mat = Materials.parse(material);
|
||||||
}
|
|
||||||
|
|
||||||
private static final Pattern pattern = Pattern.compile("%[a-z]");
|
|
||||||
|
|
||||||
/**
|
|
||||||
* 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, "");
|
|
||||||
}
|
|
||||||
|
|
||||||
FileConfiguration base = locales.get(locale);
|
|
||||||
|
|
||||||
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);
|
|
||||||
|
|
||||||
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());
|
|
||||||
if (mat == null) {
|
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;
|
mat = Material.STONE;
|
||||||
}
|
}
|
||||||
|
|
||||||
Item item = new Item(mat, name);
|
Item item = new Item(mat, name);
|
||||||
|
if (!lore.isEmpty()) item.lore(lore.split("\\|\\|"));
|
||||||
if (!lore.isEmpty()) {
|
|
||||||
item.lore(lore.split("\\|\\|"));
|
|
||||||
}
|
|
||||||
|
|
||||||
if (modelId != -1) {
|
|
||||||
// TODO: item.setCustomModelData(modelId);
|
|
||||||
}
|
|
||||||
|
|
||||||
return item;
|
return item;
|
||||||
}
|
}
|
||||||
|
|
||||||
public static List<String> getChildren(FileConfiguration file, String path, boolean deep) {
|
@NotNull
|
||||||
ConfigurationSection section = file.getConfigurationSection(path);
|
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;
|
package dev.loki.loparkour.config;
|
||||||
|
|
||||||
import dev.loki.loparkour.LoParkour;
|
import dev.loki.loparkour.LoParkour;
|
||||||
|
import dev.loki.loparkour.util.Materials;
|
||||||
import dev.loki.loparkour.api.Registry;
|
import dev.loki.loparkour.api.Registry;
|
||||||
import dev.loki.loparkour.menu.ParkourOption;
|
import dev.loki.loparkour.menu.ParkourOption;
|
||||||
import dev.loki.loparkour.style.RandomStyle;
|
import dev.loki.loparkour.style.RandomStyle;
|
||||||
|
|
@ -9,24 +10,12 @@ import dev.loki.loparkour.util.ParticleData;
|
||||||
import org.bukkit.*;
|
import org.bukkit.*;
|
||||||
import org.bukkit.block.BlockFace;
|
import org.bukkit.block.BlockFace;
|
||||||
import org.bukkit.configuration.file.FileConfiguration;
|
import org.bukkit.configuration.file.FileConfiguration;
|
||||||
import org.bukkit.util.Vector;
|
|
||||||
|
|
||||||
import java.util.*;
|
import java.util.*;
|
||||||
import java.util.function.BiFunction;
|
import java.util.function.BiFunction;
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Top-level config constants used throughout the plugin.
|
* All config-derived constants used at runtime.
|
||||||
*
|
|
||||||
* 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.
|
|
||||||
*/
|
*/
|
||||||
public class Option {
|
public class Option {
|
||||||
|
|
||||||
|
|
@ -41,9 +30,8 @@ public class Option {
|
||||||
|
|
||||||
public static Location GO_BACK_LOC;
|
public static Location GO_BACK_LOC;
|
||||||
|
|
||||||
// ── Forwarded from sub-classes (backwards compat) ─────────────────────────
|
// ── SQL ───────────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
/** @see SqlOptions#SQL */
|
|
||||||
public static boolean SQL;
|
public static boolean SQL;
|
||||||
public static String SQL_URL;
|
public static String SQL_URL;
|
||||||
public static int SQL_PORT;
|
public static int SQL_PORT;
|
||||||
|
|
@ -52,38 +40,49 @@ public class Option {
|
||||||
public static String SQL_PASSWORD;
|
public static String SQL_PASSWORD;
|
||||||
public static String SQL_PREFIX;
|
public static String SQL_PREFIX;
|
||||||
|
|
||||||
/** @see ParticleOptions#SHAPE */
|
// ── Particles / Sound ─────────────────────────────────────────────────────
|
||||||
|
|
||||||
public static ParticleShape PARTICLE_SHAPE;
|
public static ParticleShape PARTICLE_SHAPE;
|
||||||
/** @see ParticleOptions#SOUND_TYPE */
|
|
||||||
public static Sound SOUND_TYPE;
|
public static Sound SOUND_TYPE;
|
||||||
/** @see ParticleOptions#SOUND_PITCH */
|
|
||||||
public static int SOUND_PITCH;
|
public static int SOUND_PITCH;
|
||||||
/** @see ParticleOptions#SOUND_VOLUME */
|
|
||||||
public static int SOUND_VOLUME;
|
public static int SOUND_VOLUME;
|
||||||
/** @see ParticleOptions#PARTICLE_TYPE */
|
|
||||||
public static Particle PARTICLE_TYPE;
|
public static Particle PARTICLE_TYPE;
|
||||||
/** @see ParticleOptions#PARTICLE_DATA */
|
|
||||||
public static ParticleData<?> PARTICLE_DATA;
|
public static ParticleData<?> PARTICLE_DATA;
|
||||||
|
|
||||||
/** @see GenerationOptions */
|
// ── Generation ────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
public static double TYPE_NORMAL, TYPE_SPECIAL, TYPE_SCHEMATICS;
|
public static double TYPE_NORMAL, TYPE_SPECIAL, TYPE_SCHEMATICS;
|
||||||
public static double SPECIAL_ICE, SPECIAL_SLAB, SPECIAL_PANE, SPECIAL_FENCE;
|
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_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 double NORMAL_HEIGHT_1, NORMAL_HEIGHT_0, NORMAL_HEIGHT_NEG1, NORMAL_HEIGHT_NEG2;
|
||||||
public static int MAX_Y, MIN_Y;
|
public static int MAX_Y, MIN_Y;
|
||||||
public static int BLOCK_CLEANUP_DISTANCE, CLEANUP_INTERVAL;
|
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 boolean GHOST_MODE_ENABLED;
|
||||||
public static int GHOST_SHOW_TOP;
|
public static int GHOST_SHOW_TOP;
|
||||||
public static double GHOST_TRANSPARENCY;
|
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 ──────────────────────────────────────────────────────────────────
|
// ── init ──────────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
public static void init(boolean firstLoad) {
|
public static void init(boolean firstLoad) {
|
||||||
SqlOptions.init();
|
initSql();
|
||||||
ParticleOptions.init();
|
initParticles();
|
||||||
GenerationOptions.init();
|
initGeneration();
|
||||||
|
|
||||||
syncForwardedFields();
|
|
||||||
initGeneral(firstLoad);
|
initGeneral(firstLoad);
|
||||||
initOptions();
|
initOptions();
|
||||||
|
|
||||||
|
|
@ -91,60 +90,139 @@ public class Option {
|
||||||
.forEach(Registry::register);
|
.forEach(Registry::register);
|
||||||
}
|
}
|
||||||
|
|
||||||
/** Copy sub-class fields to this class so existing code still compiles. */
|
// ── SQL ───────────────────────────────────────────────────────────────────
|
||||||
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;
|
|
||||||
|
|
||||||
PARTICLE_SHAPE = ParticleOptions.SHAPE;
|
private static void initSql() {
|
||||||
SOUND_TYPE = ParticleOptions.SOUND_TYPE;
|
SQL = Config.CONFIG.getBoolean("sql.enabled");
|
||||||
SOUND_PITCH = ParticleOptions.SOUND_PITCH;
|
SQL_PORT = Config.CONFIG.getInt("sql.port");
|
||||||
SOUND_VOLUME = ParticleOptions.SOUND_VOLUME;
|
SQL_DB = Config.CONFIG.getString("sql.database");
|
||||||
PARTICLE_TYPE = ParticleOptions.PARTICLE_TYPE;
|
SQL_URL = Config.CONFIG.getString("sql.url");
|
||||||
PARTICLE_DATA = ParticleOptions.PARTICLE_DATA;
|
SQL_USERNAME = Config.CONFIG.getString("sql.username");
|
||||||
|
String envPassword = System.getenv("LOPARKOUR_SQL_PASSWORD");
|
||||||
TYPE_NORMAL = GenerationOptions.TYPE_NORMAL;
|
SQL_PASSWORD = (envPassword != null && !envPassword.isEmpty())
|
||||||
TYPE_SPECIAL = GenerationOptions.TYPE_SPECIAL;
|
? envPassword
|
||||||
TYPE_SCHEMATICS = GenerationOptions.TYPE_SCHEMATICS;
|
: Config.CONFIG.getString("sql.password");
|
||||||
|
SQL_PREFIX = Config.CONFIG.getString("sql.prefix");
|
||||||
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;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// ── 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) {
|
private static void initGeneral(boolean firstLoad) {
|
||||||
GO_BACK_LOC = parseLocation(Config.CONFIG.getString("bungeecord.go-back"));
|
GO_BACK_LOC = parseLocation(Config.CONFIG.getString("bungeecord.go-back"));
|
||||||
String[] axes = Config.CONFIG.getString("bungeecord.go-back-axes").split(",");
|
try {
|
||||||
GO_BACK_LOC.setPitch(Float.parseFloat(axes[0]));
|
String axesRaw = Config.CONFIG.getString("bungeecord.go-back-axes");
|
||||||
GO_BACK_LOC.setYaw(Float.parseFloat(axes[1]));
|
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");
|
POSSIBLE_LEADS = Config.CONFIG.getIntList("options.leads.amount");
|
||||||
for (int lead : new ArrayList<>(POSSIBLE_LEADS)) {
|
for (int lead : new ArrayList<>(POSSIBLE_LEADS)) {
|
||||||
|
|
@ -193,20 +271,31 @@ public class Option {
|
||||||
// ── Helpers ───────────────────────────────────────────────────────────────
|
// ── Helpers ───────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
private static Location parseLocation(String location) {
|
private static Location parseLocation(String location) {
|
||||||
String[] values = location.replaceAll("[()]", "").replaceAll("[, ]", " ").split(" ");
|
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]);
|
World world = Bukkit.getWorld(values[3]);
|
||||||
if (world == null) world = Bukkit.getWorlds().get(0);
|
if (world == null) world = Bukkit.getWorlds().get(0);
|
||||||
return new Location(world, Double.parseDouble(values[0]),
|
return new Location(world, Double.parseDouble(values[0]),
|
||||||
Double.parseDouble(values[1]), Double.parseDouble(values[2]));
|
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) {
|
public static Set<Style> initStyles(String path, FileConfiguration config, BiFunction<String, List<Material>, Style> fn) {
|
||||||
var styles = new HashSet<Style>();
|
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,
|
styles.add(fn.apply(style,
|
||||||
config.getStringList("%s.%s".formatted(path, style)).stream()
|
config.getStringList("%s.%s".formatted(path, style)).stream()
|
||||||
.map(name -> {
|
.map(name -> {
|
||||||
Material m = Material.getMaterial(name.toUpperCase());
|
Material m = Materials.parse(name);
|
||||||
if (m == null) {
|
if (m == null) {
|
||||||
LoParkour.getPlugin().getLogger().severe("Invalid material %s in style %s".formatted(name, style));
|
LoParkour.getPlugin().getLogger().severe("Invalid material %s in style %s".formatted(name, style));
|
||||||
return Material.STONE;
|
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 playerSpawn;
|
||||||
public Location lastStandingPlayerLocation;
|
public Location lastStandingPlayerLocation;
|
||||||
public Vector heading;
|
public Vector heading;
|
||||||
|
public Location[] zone; // [min, max] bounding box for generation area
|
||||||
|
|
||||||
// Time state
|
// Time state
|
||||||
public Instant start;
|
public Instant start;
|
||||||
|
|
||||||
// Block history
|
// Block history (sync list: all mutations occur on the main thread; avoids accidental CME if extended later)
|
||||||
public List<Block> history = new LinkedList<>();
|
public final List<Block> history = Collections.synchronizedList(new LinkedList<>());
|
||||||
public int lastPositionIndexPlayer = -1;
|
public int lastPositionIndexPlayer = -1;
|
||||||
|
|
||||||
// Schematic state
|
// 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;
|
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.Config;
|
||||||
|
import dev.loki.loparkour.config.Option;
|
||||||
import dev.loki.loparkour.session.Session;
|
import dev.loki.loparkour.session.Session;
|
||||||
|
import dev.loki.loparkour.util.Materials;
|
||||||
|
|
||||||
import org.bukkit.Location;
|
import org.bukkit.Location;
|
||||||
import org.bukkit.Material;
|
import org.bukkit.Material;
|
||||||
|
import org.bukkit.World;
|
||||||
import org.bukkit.block.Block;
|
import org.bukkit.block.Block;
|
||||||
|
import org.bukkit.block.BlockFace;
|
||||||
|
import org.bukkit.util.Vector;
|
||||||
import org.jetbrains.annotations.NotNull;
|
import org.jetbrains.annotations.NotNull;
|
||||||
import org.jetbrains.annotations.Nullable;
|
import org.jetbrains.annotations.Nullable;
|
||||||
|
|
||||||
|
import java.util.ArrayList;
|
||||||
import java.util.List;
|
import java.util.List;
|
||||||
import java.util.NoSuchElementException;
|
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Spawn island handler.
|
* Spawn area: flat platform at the session cell (no island schematic).
|
||||||
*
|
|
||||||
* @author loki
|
|
||||||
* @since 5.0.0
|
|
||||||
*/
|
*/
|
||||||
public final class Island {
|
public final class Island {
|
||||||
|
|
||||||
/**
|
/** Half-size: platform is (2*R+1) x (2*R+1) blocks. */
|
||||||
* The session.
|
private static final int PLATFORM_RADIUS = 4;
|
||||||
*/
|
|
||||||
public final Session session;
|
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;
|
public final dev.loki.loparkour.schematic.lpschem.LPSchematic schematic;
|
||||||
|
|
||||||
/**
|
|
||||||
* The blocks that have been affected by the schematic.
|
|
||||||
*/
|
|
||||||
public List<Block> blocks;
|
public List<Block> blocks;
|
||||||
|
|
||||||
public Island(@NotNull Session session, @Nullable dev.loki.loparkour.schematic.lpschem.LPSchematic schematic) {
|
public Island(@NotNull Session session, @Nullable dev.loki.loparkour.schematic.lpschem.LPSchematic schematic) {
|
||||||
|
|
@ -42,62 +38,64 @@ public final class Island {
|
||||||
this.schematic = schematic;
|
this.schematic = schematic;
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
|
||||||
* Builds the island and teleports the player.
|
|
||||||
*/
|
|
||||||
public void build(Location location) {
|
public void build(Location location) {
|
||||||
if (schematic == null) {
|
World world = location.getWorld();
|
||||||
LoParkour.getPlugin().getLogger().warning("Cannot build island - schematic is null!");
|
if (world == null) {
|
||||||
return;
|
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());
|
BlockFace face = Option.HEADING;
|
||||||
Material parkourMaterial = Material.getMaterial(Config.GENERATION.getString("advanced.island.parkour.begin-block").toUpperCase());
|
int hx = face.getModX();
|
||||||
|
int hz = face.getModZ();
|
||||||
|
|
||||||
// Fallback for first-block-material if not present in config
|
if (session.generator.state.heading == null) {
|
||||||
String firstBlockStr = "stone";
|
session.generator.state.heading = face.getDirection();
|
||||||
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");
|
|
||||||
}
|
}
|
||||||
Material firstBlockMaterial = Material.getMaterial(firstBlockStr.toUpperCase());
|
Vector h = session.generator.state.heading;
|
||||||
|
|
||||||
try {
|
String firstBlockStr = Config.GENERATION.getString("advanced.island.parkour.first-block-material");
|
||||||
Block player = blocks.stream().filter(block -> block.getType() == playerMaterial).findAny().orElseThrow();
|
if (firstBlockStr.isBlank()) {
|
||||||
Block parkour = blocks.stream().filter(block -> block.getType() == parkourMaterial).findAny().orElseThrow();
|
firstBlockStr = "stone";
|
||||||
|
}
|
||||||
|
Material platformMat = Materials.parseOr(firstBlockStr, Material.STONE);
|
||||||
|
|
||||||
player.setType(firstBlockMaterial);
|
List<Block> placed = new ArrayList<>();
|
||||||
parkour.setType(firstBlockMaterial);
|
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);
|
// Player stand 2 blocks "behind" center along heading; course line continues +4 then first jump +4 more (old island logic)
|
||||||
ps.setYaw(Config.GENERATION.getInt("advanced.island.spawn.yaw"));
|
int px = cx - 2 * hx;
|
||||||
ps.setPitch(Config.GENERATION.getInt("advanced.island.spawn.pitch"));
|
int pz = cz - 2 * hz;
|
||||||
|
int qx = px + 4 * hx;
|
||||||
|
int qz = pz + 4 * hz;
|
||||||
|
|
||||||
// First parkour block position - fixed offset from parkour marker
|
Block playerFoot = world.getBlockAt(px, cy, pz);
|
||||||
// Always 4 blocks in the direction of heading from center
|
|
||||||
Location parkourStart = parkour.getLocation().clone();
|
Location ps = playerFoot.getLocation().add(0.5, 1.0, 0.5);
|
||||||
parkourStart.add(
|
ps.setYaw((float) Config.GENERATION.getInt("advanced.island.spawn.yaw"));
|
||||||
session.generator.state.heading.getX() * 4,
|
ps.setPitch((float) Config.GENERATION.getInt("advanced.island.spawn.pitch"));
|
||||||
|
|
||||||
|
Location parkourStart = world.getBlockAt(qx, cy, qz).getLocation().add(
|
||||||
|
h.getX() * 4,
|
||||||
0,
|
0,
|
||||||
session.generator.state.heading.getZ() * 4
|
h.getZ() * 4);
|
||||||
);
|
|
||||||
|
|
||||||
session.generator.generateFirst(ps, parkourStart);
|
session.generator.generateFirst(ps, parkourStart);
|
||||||
session.generator.startTick();
|
session.generator.startTick();
|
||||||
session.getPlayers().forEach(pp -> pp.setup(ps));
|
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());
|
|
||||||
|
|
||||||
blocks.forEach(block -> block.setType(Material.AIR));
|
this.blocks = placed;
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
|
||||||
* Destroys the island.
|
|
||||||
*/
|
|
||||||
public void destroy() {
|
public void destroy() {
|
||||||
if (blocks == null) {
|
if (blocks == null) {
|
||||||
return;
|
return;
|
||||||
|
|
|
||||||
|
|
@ -1,13 +1,7 @@
|
||||||
package dev.loki.loparkour.generator;
|
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.jump.BlockPlacer;
|
||||||
import dev.loki.loparkour.generator.lifecycle.GeneratorLifecycle;
|
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.menu.Menus;
|
||||||
import dev.loki.loparkour.mode.Mode;
|
import dev.loki.loparkour.mode.Mode;
|
||||||
import dev.loki.loparkour.mode.Modes;
|
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.player.ParkourSpectator;
|
||||||
import dev.loki.loparkour.schematic.lpschem.LPSchematic;
|
import dev.loki.loparkour.schematic.lpschem.LPSchematic;
|
||||||
import dev.loki.loparkour.session.Session;
|
import dev.loki.loparkour.session.Session;
|
||||||
import dev.loki.loparkour.world.Divider;
|
|
||||||
import org.bukkit.Location;
|
import org.bukkit.Location;
|
||||||
import org.bukkit.Material;
|
|
||||||
import org.bukkit.block.Block;
|
import org.bukkit.block.Block;
|
||||||
import org.jetbrains.annotations.NotNull;
|
import org.jetbrains.annotations.NotNull;
|
||||||
import org.jetbrains.annotations.Nullable;
|
import org.jetbrains.annotations.Nullable;
|
||||||
|
|
||||||
import java.time.Instant;
|
import java.util.List;
|
||||||
import java.time.ZoneOffset;
|
|
||||||
import java.time.format.DateTimeFormatter;
|
|
||||||
import java.util.*;
|
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Refactored ParkourGenerator using composition pattern.
|
||||||
|
* Coordinates profile management, statistics, and core generation.
|
||||||
|
*/
|
||||||
public class ParkourGenerator {
|
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 Session session;
|
||||||
public final ParkourPlayer player;
|
|
||||||
public final Profile profile = new Profile();
|
|
||||||
public final Island island;
|
public final Island island;
|
||||||
public final List<GeneratorOption> generatorOptions;
|
public final Profile profile;
|
||||||
|
public final GeneratorState state;
|
||||||
|
public final GeneratorOption[] generatorOptions;
|
||||||
|
|
||||||
public Location[] zone;
|
protected final BlockPlacer placer;
|
||||||
public dev.lolib.scheduler.ScheduledTask task;
|
protected final GeneratorLifecycle lifecycle;
|
||||||
|
public final GeneratorProfileManager profileManager;
|
||||||
|
private final GeneratorStatistics statistics;
|
||||||
|
|
||||||
public ParkourGenerator(@NotNull Session session, @Nullable LPSchematic schematic,
|
public ParkourGenerator(@NotNull Session session, @Nullable LPSchematic schematic, GeneratorOption... generatorOptions) {
|
||||||
GeneratorOption... generatorOptions) {
|
|
||||||
this.session = session;
|
this.session = session;
|
||||||
this.generatorOptions = Arrays.asList(generatorOptions);
|
this.generatorOptions = generatorOptions;
|
||||||
this.player = session.getPlayers().get(0);
|
|
||||||
this.island = new Island(session, schematic);
|
this.island = new Island(session, schematic);
|
||||||
this.zone = Divider.toSelection(session);
|
this.profile = new Profile();
|
||||||
this.state.schematicCooldown = Config.GENERATION.getInt("advanced.schematic-cooldown");
|
this.state = new GeneratorState();
|
||||||
this.state.heading = Option.HEADING.getDirection();
|
|
||||||
|
|
||||||
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.placer = new BlockPlacer(this);
|
||||||
this.lifecycle = new GeneratorLifecycle(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) {
|
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() { placer.generate(); }
|
||||||
public void generate(int amount) { placer.generate(amount); }
|
public void generate(int amount) { placer.generate(amount); }
|
||||||
public void generateFirst(Location spawn, Location block) { placer.generateFirst(spawn, block); }
|
public void generateFirst(Location spawn, Location block) { placer.generateFirst(spawn, block); }
|
||||||
|
|
||||||
|
// Lifecycle methods
|
||||||
public void startTick() { lifecycle.startTick(); }
|
public void startTick() { lifecycle.startTick(); }
|
||||||
public void tick() { lifecycle.tick(); }
|
public void tick() { lifecycle.tick(); }
|
||||||
protected void fall() { lifecycle.fall(); }
|
protected void fall() { lifecycle.fall(); }
|
||||||
protected void score() { lifecycle.score(); }
|
protected void score() { lifecycle.score(); }
|
||||||
|
|
||||||
public void reset(boolean regenerate) {
|
public void reset(boolean regenerate) {
|
||||||
state.stopped = !regenerate;
|
lifecycle.reset(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();
|
|
||||||
if (regenerate) {
|
if (regenerate) {
|
||||||
player.teleport(state.playerSpawn);
|
profileManager.overrideProfile();
|
||||||
generateFirst(state.playerSpawn, state.blockSpawn);
|
profileManager.calculateChances();
|
||||||
return;
|
|
||||||
}
|
|
||||||
island.destroy();
|
|
||||||
if (getPlayers().isEmpty()) {
|
|
||||||
getSpectators().forEach(s -> Modes.DEFAULT.create(s.player));
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
private void sendFallMessage(int record) {
|
// Statistics and scoring
|
||||||
String key;
|
public void registerScore(String time, String difficulty, int score) {
|
||||||
int number = 0;
|
statistics.registerScore(time, difficulty, score);
|
||||||
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");
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
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() {
|
public double getDifficultyScore() {
|
||||||
double s = 0;
|
return profileManager.getDifficultyScore();
|
||||||
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;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
public String getFormattedTime() {
|
public String getFormattedTime() {
|
||||||
return getTime(Config.CONFIG.getString("options.time.score-format"));
|
return statistics.getFormattedTime();
|
||||||
}
|
}
|
||||||
|
|
||||||
public String getDetailedTime() {
|
public String getDetailedTime() {
|
||||||
return getTime("mm:ss:SSS");
|
return statistics.getDetailedTime();
|
||||||
}
|
}
|
||||||
|
|
||||||
private String getTime(String format) {
|
// UI and player management
|
||||||
var ms = Instant.now().minusMillis(state.start != null ? state.start.toEpochMilli() : Instant.now().toEpochMilli());
|
public void menu(ParkourPlayer player) {
|
||||||
try {
|
Menus.PARKOUR_SETTINGS.open(player);
|
||||||
return DateTimeFormatter.ofPattern(format).withZone(ZoneOffset.UTC).format(ms);
|
|
||||||
} catch (IllegalArgumentException ex) {
|
|
||||||
LoParkour.getPlugin().getLogger().severe("Invalid time format: " + format);
|
|
||||||
return "";
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
public Mode getMode() { return Modes.DEFAULT; }
|
public Mode getMode() {
|
||||||
@NotNull public List<ParkourPlayer> getPlayers() { return session.getPlayers(); }
|
return Modes.DEFAULT;
|
||||||
@NotNull public List<ParkourSpectator> getSpectators() { return session.getSpectators(); }
|
}
|
||||||
|
|
||||||
public enum BlockGenerationType { DEFAULT, SCHEMATIC, SPECIAL }
|
@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.config.Option;
|
||||||
import dev.loki.loparkour.generator.ParkourGenerator;
|
import dev.loki.loparkour.generator.ParkourGenerator;
|
||||||
import dev.loki.loparkour.player.ParkourPlayer;
|
import dev.loki.loparkour.player.ParkourPlayer;
|
||||||
import dev.loki.loparkour.player.ParkourSpectator;
|
|
||||||
import dev.loki.loparkour.util.ParticleUtil;
|
import dev.loki.loparkour.util.ParticleUtil;
|
||||||
import org.bukkit.Location;
|
import org.bukkit.Location;
|
||||||
import org.bukkit.block.Block;
|
import org.bukkit.block.Block;
|
||||||
|
|
|
||||||
|
|
@ -1,308 +1,176 @@
|
||||||
package dev.loki.loparkour.generator.jump;
|
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.generator.ParkourGenerator;
|
||||||
import dev.loki.loparkour.style.Style;
|
import dev.loki.loparkour.schematic.lpschem.LPSchematic;
|
||||||
import dev.loki.loparkour.util.Probs;
|
import dev.loki.loparkour.util.Probs;
|
||||||
import org.bukkit.Location;
|
import org.bukkit.Location;
|
||||||
import org.bukkit.Material;
|
|
||||||
import org.bukkit.block.Block;
|
import org.bukkit.block.Block;
|
||||||
import org.bukkit.block.data.BlockData;
|
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 org.jetbrains.annotations.NotNull;
|
||||||
|
|
||||||
import java.util.ArrayList;
|
import java.util.ArrayList;
|
||||||
import java.util.HashMap;
|
|
||||||
import java.util.List;
|
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.
|
* Refactored BlockPlacer using composition pattern.
|
||||||
* Extracted from {@link ParkourGenerator}.
|
* Coordinates block selection, jump calculation, and placement.
|
||||||
*/
|
*/
|
||||||
public class BlockPlacer {
|
public class BlockPlacer {
|
||||||
|
|
||||||
private final ParkourGenerator g;
|
private final ParkourGenerator generator;
|
||||||
|
private final BlockSelector blockSelector;
|
||||||
|
private final JumpCalculator jumpCalculator;
|
||||||
|
|
||||||
public BlockPlacer(ParkourGenerator generator) {
|
public BlockPlacer(@NotNull ParkourGenerator generator) {
|
||||||
this.g = generator;
|
this.generator = generator;
|
||||||
}
|
this.blockSelector = new BlockSelector(generator);
|
||||||
|
this.jumpCalculator = new JumpCalculator(generator, blockSelector);
|
||||||
// ── 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;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Check if material is a slab type.
|
* Generate a single block.
|
||||||
*/
|
*/
|
||||||
private boolean isSlabMaterial(Material material) {
|
public void generate() {
|
||||||
return material == Material.SMOOTH_QUARTZ_SLAB ||
|
generate(1);
|
||||||
material == Material.STONE_SLAB ||
|
|
||||||
material.name().endsWith("_SLAB");
|
|
||||||
}
|
}
|
||||||
|
|
||||||
protected BlockData selectBlockData() {
|
/**
|
||||||
Style style = Registry.getStyle(g.profile.get("style").value());
|
* Generate specified amount of blocks.
|
||||||
if (style == null) {
|
*/
|
||||||
g.profile.set("style", Registry.getStyles().stream().findFirst().orElseThrow().getName());
|
public void generate(int amount) {
|
||||||
return selectBlockData();
|
for (int i = 0; i < amount; i++) {
|
||||||
|
generateSingleBlock();
|
||||||
}
|
}
|
||||||
return style.getNext().createBlockData();
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// ── Internal ───────────────────────────────────────────────────────────────
|
/**
|
||||||
|
* Generate the first block at specified locations.
|
||||||
|
*/
|
||||||
|
public void generateFirst(@NotNull Location spawn, @NotNull Location blockSpawn) {
|
||||||
|
Block block = blockSpawn.getBlock();
|
||||||
|
BlockData blockData = blockSelector.selectBlockData();
|
||||||
|
|
||||||
private void placeNormalBlock(ParkourGenerator.BlockGenerationType jump) {
|
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();
|
List<Block> blocks = selectBlocks();
|
||||||
if (blocks.isEmpty()) {
|
if (blocks.isEmpty()) return;
|
||||||
LoParkour.getPlugin().getLogger().severe("No blocks to generate!");
|
|
||||||
|
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;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
List<Block> placed = new ArrayList<>();
|
List<Block> blocks = selectBlocks();
|
||||||
for (Block block : blocks) {
|
if (blocks.isEmpty()) return;
|
||||||
BlockData data;
|
|
||||||
|
|
||||||
// Select JumpType based on chances (70% normal, 30% special types)
|
Block selectedBlock = blocks.get(0);
|
||||||
JumpType jumpType = selectJumpType();
|
placeBlockData(selectedBlock, specialBlockData);
|
||||||
|
generator.state.history.add(selectedBlock);
|
||||||
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();
|
/** Prefer no neighbor physics to avoid fragile vanilla multi-block checks in empty worlds. */
|
||||||
block.setBlockData(data, data instanceof Fence || data instanceof GlassPane);
|
private static void placeBlockData(@NotNull Block block, @NotNull BlockData data) {
|
||||||
placed.add(block);
|
block.setBlockData(data, false);
|
||||||
}
|
}
|
||||||
|
|
||||||
new ParkourBlockGenerateEvent(placed, g, g.player).call();
|
@NotNull
|
||||||
g.effects.particles(placed);
|
private List<Block> selectBlocks() {
|
||||||
g.effects.sound(placed);
|
List<Block> blocks = new ArrayList<>();
|
||||||
g.state.history.addAll(placed);
|
|
||||||
g.state.schematicCooldown--;
|
if (generator.state.history.isEmpty()) {
|
||||||
|
return blocks; // No previous blocks to work with
|
||||||
}
|
}
|
||||||
|
|
||||||
private JumpType selectJumpType() {
|
Block current = generator.getLatest();
|
||||||
// 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);
|
// 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() {
|
private boolean tryGenerateSchematic() {
|
||||||
var manager = LoParkour.getSchematicManager();
|
// Simplified schematic generation
|
||||||
if (manager == null) return false;
|
LPSchematic schematic = generator.island.schematic; // Получаем схематик из island
|
||||||
|
if (schematic == null) return false;
|
||||||
|
|
||||||
var all = manager.getAllSchematics();
|
List<Block> blocks = selectBlocks();
|
||||||
if (all.isEmpty()) return false;
|
if (blocks.isEmpty()) return false;
|
||||||
|
|
||||||
double diff = g.profile.get("schematicDifficulty").asDouble();
|
Block targetBlock = blocks.get(0);
|
||||||
var candidates = all.values().stream()
|
Location pasteLocation = targetBlock.getLocation();
|
||||||
.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));
|
// Paste schematic (simplified)
|
||||||
List<Block> placed = rotatedPaste(schematic, origin);
|
List<Block> schematicBlocks = pasteSchematic(schematic, pasteLocation);
|
||||||
if (placed.isEmpty()) return false;
|
|
||||||
|
|
||||||
g.state.schematicBlocks.addAll(placed);
|
if (!schematicBlocks.isEmpty()) {
|
||||||
g.state.history.addAll(placed);
|
generator.state.history.addAll(schematicBlocks);
|
||||||
g.state.waitForSchematicCompletion = true;
|
generator.state.schematicBlocks = schematicBlocks;
|
||||||
g.state.deleteSchematic = false;
|
|
||||||
|
|
||||||
new ParkourSchematicGenerateEvent(schematic, g, g.player).call();
|
|
||||||
return true;
|
return true;
|
||||||
}
|
}
|
||||||
|
|
||||||
private @NotNull List<Block> rotatedPaste(
|
return false;
|
||||||
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);
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
private double angleInY(Vector a, Vector b) {
|
@NotNull
|
||||||
return Math.atan2(a.getX() * b.getZ() - a.getZ() * b.getX(), a.dot(b));
|
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;
|
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.generator.ParkourGenerator;
|
||||||
import dev.loki.loparkour.ghost.GhostData;
|
import org.jetbrains.annotations.NotNull;
|
||||||
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;
|
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* 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 {
|
public class GeneratorLifecycle {
|
||||||
|
|
||||||
private final ParkourGenerator generator;
|
private final LifecycleTickManager tickManager;
|
||||||
private dev.lolib.scheduler.ScheduledTask cleanupTask;
|
private final PlayerInteractionHandler interactionHandler;
|
||||||
private GhostRecorder ghostRecorder;
|
private final LifecycleVisualUpdater visualUpdater;
|
||||||
private GhostManager ghostManager;
|
private final LifecycleEventManager eventManager;
|
||||||
|
private final GeneratorCleanup cleanup;
|
||||||
|
|
||||||
public GeneratorLifecycle(ParkourGenerator generator) {
|
public GeneratorLifecycle(@NotNull ParkourGenerator generator) {
|
||||||
this.generator = generator;
|
this.eventManager = new LifecycleEventManager(generator);
|
||||||
|
this.cleanup = new GeneratorCleanup(generator);
|
||||||
if (Config.CONFIG.getBoolean("ghost-mode.enabled")) {
|
this.visualUpdater = new LifecycleVisualUpdater(generator);
|
||||||
this.ghostRecorder = new GhostRecorder();
|
this.tickManager = new LifecycleTickManager(generator, cleanup, visualUpdater);
|
||||||
this.ghostManager = new GhostManager();
|
this.interactionHandler = new PlayerInteractionHandler(generator, eventManager, cleanup);
|
||||||
this.ghostManager.loadGhosts(generator.getMode().getName());
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Start the tick cycle.
|
||||||
|
*/
|
||||||
public void startTick() {
|
public void startTick() {
|
||||||
generator.task = Scheduler.get(LoParkour.getPlugin()).runTimer(this::tick, 0, 1);
|
tickManager.startTick();
|
||||||
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()
|
|
||||||
);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Main tick method called every server tick.
|
||||||
|
*/
|
||||||
public void tick() {
|
public void tick() {
|
||||||
if (generator.state.stopped) {
|
tickManager.tick();
|
||||||
generator.task.cancel();
|
interactionHandler.checkAllPlayers();
|
||||||
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();
|
|
||||||
}
|
|
||||||
|
|
||||||
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() {
|
public void score() {
|
||||||
generator.state.score++;
|
interactionHandler.handleScore();
|
||||||
generator.state.totalScore++;
|
|
||||||
checkRewards();
|
|
||||||
new ParkourScoreEvent(generator.player).call();
|
|
||||||
}
|
|
||||||
|
|
||||||
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() {
|
public void fall() {
|
||||||
new ParkourFallEvent(generator.player).call();
|
interactionHandler.handleFall();
|
||||||
|
|
||||||
// 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();
|
* Reset the lifecycle state.
|
||||||
}
|
*/
|
||||||
|
public void reset(boolean regenerate) {
|
||||||
generator.reset(true);
|
tickManager.reset();
|
||||||
}
|
cleanup.reset(regenerate);
|
||||||
|
|
||||||
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);
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -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.entity.Player;
|
||||||
import org.bukkit.plugin.RegisteredServiceProvider;
|
import org.bukkit.plugin.RegisteredServiceProvider;
|
||||||
|
|
||||||
import java.util.NoSuchElementException;
|
|
||||||
|
|
||||||
public class VaultHook {
|
public class VaultHook {
|
||||||
|
|
||||||
private static Economy economy;
|
private static Economy economy;
|
||||||
|
|
|
||||||
|
|
@ -1,9 +1,5 @@
|
||||||
package dev.loki.loparkour.leaderboard;
|
package dev.loki.loparkour.leaderboard;
|
||||||
|
|
||||||
import java.util.ArrayList;
|
|
||||||
|
|
||||||
import dev.lolib.scheduler.Scheduler;
|
|
||||||
|
|
||||||
import dev.loki.loparkour.LoParkour;
|
import dev.loki.loparkour.LoParkour;
|
||||||
import dev.loki.loparkour.config.Config;
|
import dev.loki.loparkour.config.Config;
|
||||||
import dev.loki.loparkour.storage.Storage;
|
import dev.loki.loparkour.storage.Storage;
|
||||||
|
|
@ -29,9 +25,9 @@ public class Leaderboard {
|
||||||
public final Sort sort;
|
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) {
|
public Leaderboard(@NotNull String mode, Sort sort) {
|
||||||
this.mode = mode.toLowerCase();
|
this.mode = mode.toLowerCase();
|
||||||
|
|
@ -45,9 +41,8 @@ public class Leaderboard {
|
||||||
|
|
||||||
var interval = Config.CONFIG.getInt("storage-update-interval");
|
var interval = Config.CONFIG.getInt("storage-update-interval");
|
||||||
|
|
||||||
// TODO: Fix scheduler call - needs proper implementation
|
// Main-thread timer: only schedules I/O; avoids async + Bukkit edge cases
|
||||||
// read/write all data every x seconds after x seconds to allow time for reading/writing
|
Scheduler.get(LoParkour.getPlugin()).runTimer(() -> {
|
||||||
Scheduler.get(LoParkour.getPlugin()).runTimerAsync(() -> {
|
|
||||||
if (Config.CONFIG.getBoolean("joining")) {
|
if (Config.CONFIG.getBoolean("joining")) {
|
||||||
write(true);
|
write(true);
|
||||||
} else {
|
} else {
|
||||||
|
|
@ -68,8 +63,13 @@ public class Leaderboard {
|
||||||
*/
|
*/
|
||||||
public void read(boolean async) {
|
public void read(boolean async) {
|
||||||
run(() -> {
|
run(() -> {
|
||||||
|
Map<UUID, Score> loadedScores = Storage.readScores(mode);
|
||||||
|
|
||||||
|
// Synchronize access to prevent concurrent modification
|
||||||
|
synchronized (scores) {
|
||||||
scores.clear();
|
scores.clear();
|
||||||
scores.putAll(Storage.readScores(mode));
|
scores.putAll(loadedScores);
|
||||||
|
}
|
||||||
|
|
||||||
sort();
|
sort();
|
||||||
}, async);
|
}, async);
|
||||||
|
|
@ -91,7 +91,12 @@ public class Leaderboard {
|
||||||
public Map<UUID, Score> sort(Sort sort) {
|
public Map<UUID, Score> sort(Sort sort) {
|
||||||
LinkedHashMap<UUID, Score> sorted = new LinkedHashMap<>();
|
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) -> {
|
.sorted((one, two) -> {
|
||||||
switch (sort) {
|
switch (sort) {
|
||||||
case SCORE -> {
|
case SCORE -> {
|
||||||
|
|
@ -122,9 +127,12 @@ public class Leaderboard {
|
||||||
private void sort() {
|
private void sort() {
|
||||||
var sorted = sort(sort);
|
var sorted = sort(sort);
|
||||||
|
|
||||||
|
// Synchronize access to prevent concurrent modification
|
||||||
|
synchronized (scores) {
|
||||||
scores.clear();
|
scores.clear();
|
||||||
scores.putAll(sorted);
|
scores.putAll(sorted);
|
||||||
}
|
}
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Registers a new score, overriding the old one
|
* Registers a new score, overriding the old one
|
||||||
|
|
@ -157,7 +165,11 @@ public class Leaderboard {
|
||||||
* Resets all registered scores for this mode
|
* Resets all registered scores for this mode
|
||||||
*/
|
*/
|
||||||
public void resetAll() {
|
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.
|
* @return The rank. Starts from 1. Returns 0 if no ranking is found.
|
||||||
*/
|
*/
|
||||||
public int getRank(@NotNull UUID uuid) {
|
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
|
@Nullable
|
||||||
public Score getScoreAtRank(int rank) {
|
public Score getScoreAtRank(int rank) {
|
||||||
|
List<Score> values;
|
||||||
|
synchronized (scores) {
|
||||||
if (scores.size() < rank) {
|
if (scores.size() < rank) {
|
||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
|
values = new ArrayList<>(scores.values());
|
||||||
return new ArrayList<>(scores.values()).get(rank - 1);
|
}
|
||||||
|
return values.get(rank - 1);
|
||||||
}
|
}
|
||||||
|
|
||||||
public enum Sort {
|
public enum Sort {
|
||||||
|
|
|
||||||
|
|
@ -83,14 +83,14 @@ public class ParkourRestrictionListener implements Listener {
|
||||||
&& event.getHand() == org.bukkit.inventory.EquipmentSlot.HAND;
|
&& event.getHand() == org.bukkit.inventory.EquipmentSlot.HAND;
|
||||||
if (!isRightClick) return;
|
if (!isRightClick) return;
|
||||||
|
|
||||||
org.bukkit.Material held = heldItem(player).getType();
|
ItemStack held = heldItem(player);
|
||||||
event.setCancelled(true);
|
event.setCancelled(true);
|
||||||
|
|
||||||
if (held == Locales.getItem(player, "play.item").build().getType()) Menus.PLAY.open(player);
|
if (matchesLocaleHotbar(player, "play.item", held)) Menus.PLAY.open(player);
|
||||||
else if (held == Locales.getItem(player, "community.item").build().getType()) Menus.COMMUNITY.open(player);
|
else if (matchesLocaleHotbar(player, "community.item", held)) Menus.COMMUNITY.open(player);
|
||||||
else if (held == Locales.getItem(player, "settings.item").build().getType()) Menus.SETTINGS.open(player);
|
else if (matchesLocaleHotbar(player, "settings.item", held)) Menus.SETTINGS.open(player);
|
||||||
else if (held == Locales.getItem(player, "lobby.item").build().getType()) Menus.LOBBY.open(player);
|
else if (matchesLocaleHotbar(player, "lobby.item", held)) Menus.LOBBY.open(player);
|
||||||
else if (held == Locales.getItem(player, "other.quit").build().getType()) ParkourUser.leave(player);
|
else if (matchesLocaleHotbar(player, "other.quit", held)) ParkourUser.leave(player);
|
||||||
else if (!Config.CONFIG.getBoolean("options.disable-inventory-blocks")) event.setCancelled(false);
|
else if (!Config.CONFIG.getBoolean("options.disable-inventory-blocks")) event.setCancelled(false);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -130,4 +130,10 @@ public class ParkourRestrictionListener implements Listener {
|
||||||
? inv.getItemInOffHand()
|
? inv.getItemInOffHand()
|
||||||
: inv.getItemInMainHand();
|
: 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 item The item
|
||||||
* @param shouldDisplay Whether this item should be displayed right now.
|
* @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) {
|
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) {
|
if (id < 0 || row < 0 || row > 4) {
|
||||||
|
|
@ -50,7 +50,7 @@ public abstract class DynamicMenu {
|
||||||
* @param player The player
|
* @param player The player
|
||||||
* @param menu The menu
|
* @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) {
|
public void display(Player player, Menu menu) {
|
||||||
ParkourUser user = ParkourUser.getUser(player);
|
ParkourUser user = ParkourUser.getUser(player);
|
||||||
|
|
|
||||||
|
|
@ -26,7 +26,8 @@ public abstract class LPMenu {
|
||||||
|
|
||||||
protected String locale(@NotNull Player player) {
|
protected String locale(@NotNull Player player) {
|
||||||
var user = dev.loki.loparkour.player.ParkourUser.getUser(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() {
|
protected ItemStack filler() {
|
||||||
|
|
|
||||||
|
|
@ -3,7 +3,6 @@ package dev.loki.loparkour.menu;
|
||||||
import dev.loki.loparkour.config.Config;
|
import dev.loki.loparkour.config.Config;
|
||||||
import dev.loki.loparkour.config.Locales;
|
import dev.loki.loparkour.config.Locales;
|
||||||
import dev.loki.loparkour.player.ParkourUser;
|
import dev.loki.loparkour.player.ParkourUser;
|
||||||
import dev.loki.loparkour.util.ColorUtil;
|
|
||||||
import dev.lolib.gui.InventoryGUI;
|
import dev.lolib.gui.InventoryGUI;
|
||||||
import org.bukkit.entity.Player;
|
import org.bukkit.entity.Player;
|
||||||
import org.bukkit.inventory.ItemStack;
|
import org.bukkit.inventory.ItemStack;
|
||||||
|
|
|
||||||
|
|
@ -1,34 +1,8 @@
|
||||||
package dev.loki.loparkour.menu;
|
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
|
* Placeholder during menu migration; real flows use {@link Menus} / LoLib GUI.
|
||||||
* TODO: Implement proper menu system using LoLib GUI or custom implementation
|
|
||||||
*/
|
|
||||||
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.Menus;
|
||||||
import dev.loki.loparkour.menu.ParkourOption;
|
import dev.loki.loparkour.menu.ParkourOption;
|
||||||
import dev.loki.loparkour.mode.Mode;
|
import dev.loki.loparkour.mode.Mode;
|
||||||
import dev.loki.loparkour.util.ColorUtil;
|
import dev.lolib.gui.InventoryGUI;
|
||||||
import dev.lolib.gui.ScrollableGUI;
|
|
||||||
import org.bukkit.entity.Player;
|
import org.bukkit.entity.Player;
|
||||||
import org.bukkit.inventory.ItemStack;
|
import org.bukkit.inventory.ItemStack;
|
||||||
import org.jetbrains.annotations.NotNull;
|
import org.jetbrains.annotations.NotNull;
|
||||||
|
|
@ -18,8 +17,16 @@ import java.util.List;
|
||||||
|
|
||||||
public class LeaderboardsMenu extends LPMenu {
|
public class LeaderboardsMenu extends LPMenu {
|
||||||
|
|
||||||
|
private static final int ITEMS_PER_PAGE = 7; // Slots 10-16 (middle row)
|
||||||
|
private int currentPage = 0;
|
||||||
|
|
||||||
@Override
|
@Override
|
||||||
public void open(@NotNull Player player) {
|
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 locale = locale(player);
|
||||||
String title = Locales.getString(locale, ParkourOption.LEADERBOARDS.path + ".name");
|
String title = Locales.getString(locale, ParkourOption.LEADERBOARDS.path + ".name");
|
||||||
|
|
||||||
|
|
@ -39,18 +46,32 @@ public class LeaderboardsMenu extends LPMenu {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
ScrollableGUI gui = ScrollableGUI.create()
|
// Calculate pagination
|
||||||
.title(ColorUtil.color(title))
|
int totalPages = (int) Math.ceil((double) items.size() / ITEMS_PER_PAGE);
|
||||||
.rows(3)
|
int startIndex = currentPage * ITEMS_PER_PAGE;
|
||||||
.scrollUpButton(backItem(), 0)
|
int endIndex = Math.min(startIndex + ITEMS_PER_PAGE, items.size());
|
||||||
.scrollDownButton(nextItem(), 8);
|
|
||||||
|
|
||||||
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);
|
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));
|
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);
|
gui.open(player);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -7,8 +7,7 @@ import dev.loki.loparkour.menu.LPMenu;
|
||||||
import dev.loki.loparkour.menu.ParkourOption;
|
import dev.loki.loparkour.menu.ParkourOption;
|
||||||
import dev.loki.loparkour.mode.Mode;
|
import dev.loki.loparkour.mode.Mode;
|
||||||
import dev.loki.loparkour.player.ParkourUser;
|
import dev.loki.loparkour.player.ParkourUser;
|
||||||
import dev.loki.loparkour.util.ColorUtil;
|
import dev.lolib.gui.InventoryGUI;
|
||||||
import dev.lolib.gui.ScrollableGUI;
|
|
||||||
import org.bukkit.Material;
|
import org.bukkit.Material;
|
||||||
import org.bukkit.OfflinePlayer;
|
import org.bukkit.OfflinePlayer;
|
||||||
import org.bukkit.entity.Player;
|
import org.bukkit.entity.Player;
|
||||||
|
|
@ -24,7 +23,15 @@ import java.util.UUID;
|
||||||
|
|
||||||
public class SingleLeaderboardMenu extends LPMenu {
|
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) {
|
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();
|
Leaderboard leaderboard = mode.getLeaderboard();
|
||||||
if (leaderboard == null) return;
|
if (leaderboard == null) return;
|
||||||
|
|
||||||
|
|
@ -66,14 +73,28 @@ public class SingleLeaderboardMenu extends LPMenu {
|
||||||
items.add(skull);
|
items.add(skull);
|
||||||
}
|
}
|
||||||
|
|
||||||
ScrollableGUI gui = ScrollableGUI.create()
|
// Calculate pagination
|
||||||
.title(ColorUtil.color(title))
|
int totalPages = (int) Math.ceil((double) items.size() / ITEMS_PER_PAGE);
|
||||||
.rows(3)
|
int startIndex = currentPage * ITEMS_PER_PAGE;
|
||||||
.scrollUpButton(backItem(), 0)
|
int endIndex = Math.min(startIndex + ITEMS_PER_PAGE, items.size());
|
||||||
.scrollDownButton(nextItem(), 8);
|
|
||||||
|
|
||||||
for (ItemStack item : items) {
|
InventoryGUI gui = baseGui(title, 3);
|
||||||
gui = gui.addItem(item, e -> { /* view only */ });
|
|
||||||
|
// 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);
|
gui.open(player);
|
||||||
|
|
|
||||||
|
|
@ -4,7 +4,6 @@ import dev.loki.loparkour.config.Locales;
|
||||||
import dev.loki.loparkour.menu.LPMenu;
|
import dev.loki.loparkour.menu.LPMenu;
|
||||||
import dev.loki.loparkour.menu.Menus;
|
import dev.loki.loparkour.menu.Menus;
|
||||||
import dev.loki.loparkour.menu.ParkourOption;
|
import dev.loki.loparkour.menu.ParkourOption;
|
||||||
import dev.lolib.gui.InventoryGUI;
|
|
||||||
import org.bukkit.entity.Player;
|
import org.bukkit.entity.Player;
|
||||||
import org.jetbrains.annotations.NotNull;
|
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.config.Locales;
|
||||||
import dev.loki.loparkour.menu.LPMenu;
|
import dev.loki.loparkour.menu.LPMenu;
|
||||||
import dev.loki.loparkour.mode.Mode;
|
import dev.loki.loparkour.mode.Mode;
|
||||||
import dev.loki.loparkour.mode.MultiMode;
|
|
||||||
import dev.loki.loparkour.player.ParkourUser;
|
import dev.loki.loparkour.player.ParkourUser;
|
||||||
import dev.loki.loparkour.util.ColorUtil;
|
import dev.lolib.gui.InventoryGUI;
|
||||||
import dev.lolib.gui.ScrollableGUI;
|
|
||||||
import org.bukkit.entity.Player;
|
import org.bukkit.entity.Player;
|
||||||
import org.bukkit.inventory.ItemStack;
|
import org.bukkit.inventory.ItemStack;
|
||||||
import org.jetbrains.annotations.NotNull;
|
import org.jetbrains.annotations.NotNull;
|
||||||
|
|
||||||
import java.time.Duration;
|
|
||||||
import java.time.Instant;
|
|
||||||
import java.util.ArrayList;
|
import java.util.ArrayList;
|
||||||
import java.util.List;
|
import java.util.List;
|
||||||
|
|
||||||
public class SingleMenu extends LPMenu {
|
public class SingleMenu extends LPMenu {
|
||||||
|
|
||||||
|
private static final int ITEMS_PER_PAGE = 7; // Slots 10-16 (middle row)
|
||||||
|
private int currentPage = 0;
|
||||||
|
|
||||||
@Override
|
@Override
|
||||||
public void open(@NotNull Player player) {
|
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 locale = locale(player);
|
||||||
|
|
||||||
List<Mode> availableModes = new ArrayList<>();
|
List<Mode> availableModes = new ArrayList<>();
|
||||||
|
|
@ -32,7 +36,7 @@ public class SingleMenu extends LPMenu {
|
||||||
if (mode.getName().equals("spectator")) continue;
|
if (mode.getName().equals("spectator")) continue;
|
||||||
|
|
||||||
// Check if mode is enabled in config
|
// 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")
|
boolean blocked = Config.CONFIG.getBoolean("permissions.enabled")
|
||||||
&& !player.hasPermission("LoParkour.gamemode." + mode.getName());
|
&& !player.hasPermission("LoParkour.gamemode." + mode.getName());
|
||||||
|
|
@ -52,15 +56,18 @@ public class SingleMenu extends LPMenu {
|
||||||
|
|
||||||
String title = Locales.getString(locale, "play.single.name");
|
String title = Locales.getString(locale, "play.single.name");
|
||||||
|
|
||||||
ScrollableGUI gui = ScrollableGUI.create()
|
// Calculate pagination
|
||||||
.title(ColorUtil.color(title))
|
int totalPages = (int) Math.ceil((double) items.size() / ITEMS_PER_PAGE);
|
||||||
.rows(3)
|
int startIndex = currentPage * ITEMS_PER_PAGE;
|
||||||
.scrollUpButton(backItem(), 0)
|
int endIndex = Math.min(startIndex + ITEMS_PER_PAGE, items.size());
|
||||||
.scrollDownButton(nextItem(), 8);
|
|
||||||
|
|
||||||
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);
|
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
|
// Prevent multiple clicks - check if player is already in parkour
|
||||||
if (ParkourUser.getUser(player) != null) {
|
if (ParkourUser.getUser(player) != null) {
|
||||||
return; // Already in parkour, ignore click
|
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);
|
gui.open(player);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -5,9 +5,8 @@ import dev.loki.loparkour.menu.LPMenu;
|
||||||
import dev.loki.loparkour.mode.Modes;
|
import dev.loki.loparkour.mode.Modes;
|
||||||
import dev.loki.loparkour.player.ParkourUser;
|
import dev.loki.loparkour.player.ParkourUser;
|
||||||
import dev.loki.loparkour.session.Session;
|
import dev.loki.loparkour.session.Session;
|
||||||
import dev.loki.loparkour.util.ColorUtil;
|
|
||||||
import dev.loki.loparkour.world.Divider;
|
import dev.loki.loparkour.world.Divider;
|
||||||
import dev.lolib.gui.ScrollableGUI;
|
import dev.lolib.gui.InventoryGUI;
|
||||||
import org.bukkit.Material;
|
import org.bukkit.Material;
|
||||||
import org.bukkit.entity.Player;
|
import org.bukkit.entity.Player;
|
||||||
import org.bukkit.inventory.ItemStack;
|
import org.bukkit.inventory.ItemStack;
|
||||||
|
|
@ -19,8 +18,16 @@ import java.util.List;
|
||||||
|
|
||||||
public class SpectatorMenu extends LPMenu {
|
public class SpectatorMenu extends LPMenu {
|
||||||
|
|
||||||
|
private static final int ITEMS_PER_PAGE = 7; // Slots 10-16 (middle row)
|
||||||
|
private int currentPage = 0;
|
||||||
|
|
||||||
@Override
|
@Override
|
||||||
public void open(@NotNull Player player) {
|
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 locale = locale(player);
|
||||||
ParkourUser user = ParkourUser.getUser(player);
|
ParkourUser user = ParkourUser.getUser(player);
|
||||||
String title = Locales.getString(locale, "play.spectator.name");
|
String title = Locales.getString(locale, "play.spectator.name");
|
||||||
|
|
@ -44,15 +51,29 @@ public class SpectatorMenu extends LPMenu {
|
||||||
items.add(skull);
|
items.add(skull);
|
||||||
}
|
}
|
||||||
|
|
||||||
ScrollableGUI gui = ScrollableGUI.create()
|
// Calculate pagination
|
||||||
.title(ColorUtil.color(title))
|
int totalPages = (int) Math.ceil((double) items.size() / ITEMS_PER_PAGE);
|
||||||
.rows(3)
|
int startIndex = currentPage * ITEMS_PER_PAGE;
|
||||||
.scrollUpButton(backItem(), 0)
|
int endIndex = Math.min(startIndex + ITEMS_PER_PAGE, items.size());
|
||||||
.scrollDownButton(nextItem(), 8);
|
|
||||||
|
|
||||||
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);
|
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);
|
gui.open(player);
|
||||||
|
|
|
||||||
|
|
@ -30,7 +30,7 @@ public class LangMenu extends LPMenu {
|
||||||
String locale = pp.locale;
|
String locale = pp.locale;
|
||||||
String title = Locales.getString(locale, "settings.lang.name");
|
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);
|
InventoryGUI gui = baseGui(title, 3);
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -147,7 +147,6 @@ public class ParkourSettingsMenu extends LPMenu {
|
||||||
String locale = pp.locale;
|
String locale = pp.locale;
|
||||||
String title = Locales.getString(locale, "settings.parkour_settings.styles.name");
|
String title = Locales.getString(locale, "settings.parkour_settings.styles.name");
|
||||||
|
|
||||||
List<Style> styles = new ArrayList<>();
|
|
||||||
InventoryGUI gui = baseGui(title, 3);
|
InventoryGUI gui = baseGui(title, 3);
|
||||||
|
|
||||||
int slot = 10;
|
int slot = 10;
|
||||||
|
|
|
||||||
|
|
@ -1,14 +1,12 @@
|
||||||
package dev.loki.loparkour.mode;
|
package dev.loki.loparkour.mode;
|
||||||
|
|
||||||
import dev.loki.loparkour.config.Config;
|
import dev.loki.loparkour.config.Config;
|
||||||
import dev.loki.loparkour.config.Locales;
|
|
||||||
import dev.loki.loparkour.generator.ParkourGenerator;
|
import dev.loki.loparkour.generator.ParkourGenerator;
|
||||||
import dev.loki.loparkour.leaderboard.Leaderboard;
|
import dev.loki.loparkour.leaderboard.Leaderboard;
|
||||||
import dev.loki.loparkour.menu.Menus;
|
import dev.loki.loparkour.menu.Menus;
|
||||||
import dev.loki.loparkour.player.ParkourPlayer;
|
import dev.loki.loparkour.player.ParkourPlayer;
|
||||||
import dev.loki.loparkour.player.ParkourUser;
|
import dev.loki.loparkour.player.ParkourUser;
|
||||||
import dev.loki.loparkour.session.Session;
|
import dev.loki.loparkour.session.Session;
|
||||||
import dev.loki.loparkour.world.Divider;
|
|
||||||
import org.bukkit.Sound;
|
import org.bukkit.Sound;
|
||||||
import org.bukkit.entity.Player;
|
import org.bukkit.entity.Player;
|
||||||
import org.jetbrains.annotations.NotNull;
|
import org.jetbrains.annotations.NotNull;
|
||||||
|
|
@ -139,8 +137,10 @@ public class CoopMode implements MultiMode {
|
||||||
protected void score() {
|
protected void score() {
|
||||||
super.score(); // increments state.score and totalScore
|
super.score(); // increments state.score and totalScore
|
||||||
|
|
||||||
// Track which player was on the block (primary player for single, varies in multi)
|
// Track contributions for all players in session
|
||||||
contributions.merge(player.getUUID(), 1, Integer::sum);
|
for (ParkourPlayer pp : getPlayers()) {
|
||||||
|
contributions.merge(pp.getUUID(), 1, Integer::sum);
|
||||||
|
}
|
||||||
|
|
||||||
// Milestone every 50 points
|
// Milestone every 50 points
|
||||||
if (state.score % 50 == 0) {
|
if (state.score % 50 == 0) {
|
||||||
|
|
|
||||||
|
|
@ -1,7 +1,5 @@
|
||||||
package dev.loki.loparkour.mode;
|
package dev.loki.loparkour.mode;
|
||||||
|
|
||||||
import dev.loki.loparkour.util.Item;
|
|
||||||
|
|
||||||
import dev.loki.loparkour.config.Config;
|
import dev.loki.loparkour.config.Config;
|
||||||
import dev.loki.loparkour.config.Locales;
|
import dev.loki.loparkour.config.Locales;
|
||||||
import dev.loki.loparkour.generator.ParkourGenerator;
|
import dev.loki.loparkour.generator.ParkourGenerator;
|
||||||
|
|
|
||||||
|
|
@ -1,19 +1,12 @@
|
||||||
package dev.loki.loparkour.mode;
|
package dev.loki.loparkour.mode;
|
||||||
|
|
||||||
import dev.lolib.scheduler.Scheduler;
|
|
||||||
import dev.lolib.scheduler.ScheduledTask;
|
|
||||||
import dev.loki.loparkour.LoParkour;
|
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.generator.ParkourGenerator;
|
||||||
import dev.loki.loparkour.leaderboard.Leaderboard;
|
import dev.loki.loparkour.mode.elytra.*;
|
||||||
import dev.loki.loparkour.leaderboard.Score;
|
|
||||||
import dev.loki.loparkour.player.ParkourPlayer;
|
import dev.loki.loparkour.player.ParkourPlayer;
|
||||||
import dev.loki.loparkour.session.Session;
|
import dev.loki.loparkour.session.Session;
|
||||||
import dev.loki.loparkour.world.Divider;
|
import dev.loki.loparkour.util.ColorUtil;
|
||||||
import org.bukkit.*;
|
import org.bukkit.*;
|
||||||
import org.bukkit.block.Block;
|
|
||||||
import org.bukkit.entity.Firework;
|
import org.bukkit.entity.Firework;
|
||||||
import org.bukkit.entity.Player;
|
import org.bukkit.entity.Player;
|
||||||
import org.bukkit.event.EventHandler;
|
import org.bukkit.event.EventHandler;
|
||||||
|
|
@ -23,730 +16,231 @@ import org.bukkit.event.Listener;
|
||||||
import org.bukkit.event.player.PlayerInteractEvent;
|
import org.bukkit.event.player.PlayerInteractEvent;
|
||||||
import org.bukkit.inventory.ItemStack;
|
import org.bukkit.inventory.ItemStack;
|
||||||
import org.bukkit.inventory.meta.FireworkMeta;
|
import org.bukkit.inventory.meta.FireworkMeta;
|
||||||
|
import org.bukkit.scheduler.BukkitTask;
|
||||||
import org.bukkit.util.Vector;
|
import org.bukkit.util.Vector;
|
||||||
import org.jetbrains.annotations.NotNull;
|
import org.jetbrains.annotations.NotNull;
|
||||||
|
|
||||||
import java.time.Instant;
|
import java.util.ArrayList;
|
||||||
import java.time.ZoneOffset;
|
import java.util.HashMap;
|
||||||
import java.time.format.DateTimeFormatter;
|
import java.util.List;
|
||||||
import java.util.*;
|
import java.util.Map;
|
||||||
import java.util.concurrent.ThreadLocalRandom;
|
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Generator for Elytra mode.
|
* Refactored Elytra mode generator using composition pattern.
|
||||||
*
|
* Coordinates ring generation, physics, and rendering components.
|
||||||
* <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>
|
|
||||||
*/
|
*/
|
||||||
public class ElytraGenerator extends ParkourGenerator implements Listener {
|
public class ElytraGenerator extends ParkourGenerator implements Listener {
|
||||||
|
|
||||||
// ── Constants ─────────────────────────────────────────────────────────────
|
private final ElytraConfig config;
|
||||||
|
private final ElytraRingGenerator ringGenerator;
|
||||||
/** How many rings behind the player to keep before deleting. */
|
private final ElytraPhysics physics;
|
||||||
private static final int RING_TRAIL = 3;
|
private final ElytraRenderer renderer;
|
||||||
|
|
||||||
/** 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 List<ElytraRing> rings = new ArrayList<>();
|
private final List<ElytraRing> rings = new ArrayList<>();
|
||||||
private int nextRingIndex = 0; // index of the next ring the player hasn't passed
|
private final Map<ParkourPlayer, Integer> playerRingIndex = new HashMap<>();
|
||||||
private int tickCounter = 0;
|
|
||||||
private Instant startTime = null;
|
|
||||||
private int score = 0;
|
|
||||||
|
|
||||||
private ScheduledTask tickTask;
|
private BukkitTask renderTask;
|
||||||
private ScheduledTask renderTask;
|
private BukkitTask physicsTask;
|
||||||
|
|
||||||
/** Firework cooldown per player UUID in ms. */
|
|
||||||
private final Map<UUID, Long> fireworkCooldowns = new HashMap<>();
|
|
||||||
|
|
||||||
/** Starting Y for fall detection. */
|
|
||||||
private double spawnY;
|
|
||||||
|
|
||||||
// ── Constructor ───────────────────────────────────────────────────────────
|
|
||||||
|
|
||||||
public ElytraGenerator(@NotNull Session session) {
|
public ElytraGenerator(@NotNull Session session) {
|
||||||
super(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
|
@Override
|
||||||
public void generateFirst(Location spawn, Location blockSpawn) {
|
public void generateFirst(Location spawn, Location blockSpawn) {
|
||||||
spawnY = spawn.getY();
|
// Generate initial rings
|
||||||
state.playerSpawn = spawn;
|
Vector initialDirection = new Vector(1, 0, 0); // East direction
|
||||||
state.lastStandingPlayerLocation = spawn;
|
List<ElytraRing> initialRings = ringGenerator.generateRings(spawn, initialDirection, config.getRingLead());
|
||||||
state.blockSpawn = blockSpawn;
|
rings.addAll(initialRings);
|
||||||
|
|
||||||
// Seed the ring path starting just in front of spawn
|
// Initialize player data
|
||||||
Vector initialHeading = state.heading.clone().normalize();
|
for (ParkourPlayer player : getPlayers()) {
|
||||||
Location ringOrigin = spawn.clone().add(initialHeading.clone().multiply(10)).add(0, 2, 0);
|
playerRingIndex.put(player, 0);
|
||||||
|
giveElytraAndFireworks(player);
|
||||||
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");
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// Register firework event listener
|
startTasks();
|
||||||
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);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// ── 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
|
@Override
|
||||||
public void tick() {
|
public void tick() {
|
||||||
if (state.stopped) {
|
super.tick();
|
||||||
stopTasks();
|
checkRingsAndPhysics();
|
||||||
return;
|
generateMoreRings();
|
||||||
}
|
cleanupOldRings();
|
||||||
|
|
||||||
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();
|
|
||||||
}
|
|
||||||
|
|
||||||
// ── 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
|
@Override
|
||||||
public void reset(boolean regenerate) {
|
public void reset(boolean regenerate) {
|
||||||
state.stopped = !regenerate;
|
cleanup(); // Call cleanup BEFORE super.reset to cancel tasks first
|
||||||
|
super.reset(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));
|
|
||||||
}
|
}
|
||||||
|
|
||||||
|
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;
|
return;
|
||||||
}
|
}
|
||||||
|
if (rings.size() < config.getRingLead() * 2) {
|
||||||
|
ElytraRing lastRing = rings.get(rings.size() - 1);
|
||||||
|
Vector direction = calculateNextDirection(lastRing);
|
||||||
|
|
||||||
// Teleport back to spawn and restart
|
List<ElytraRing> newRings = ringGenerator.generateRings(
|
||||||
for (ParkourPlayer pp : getPlayers()) {
|
lastRing.getCenter(), direction, config.getRingLead()
|
||||||
pp.teleport(state.playerSpawn);
|
);
|
||||||
pp.player.setGliding(false);
|
rings.addAll(newRings);
|
||||||
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);
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
private void renderRing(ElytraRing ring, Color color) {
|
private void onRingPassed(@NotNull ParkourPlayer player, @NotNull ElytraRing ring) {
|
||||||
World world = ring.center.getWorld();
|
boolean centered = physics.isPlayerCentered(player.player.getLocation(), ring);
|
||||||
if (world == null) return;
|
int points = centered ? 10 : 5;
|
||||||
|
|
||||||
// Two orthogonal vectors in the ring's plane
|
player.session.generator.state.score += points;
|
||||||
Vector normal = ring.normal.clone().normalize();
|
|
||||||
Vector up = findPerpendicular(normal);
|
|
||||||
Vector right = normal.clone().crossProduct(up).normalize();
|
|
||||||
|
|
||||||
double r = ring.radius;
|
// Play sound and show message
|
||||||
Particle.DustOptions dust = new Particle.DustOptions(color, 1.5f);
|
player.player.playSound(player.player.getLocation(), Sound.ENTITY_EXPERIENCE_ORB_PICKUP, 1.0f, 1.0f);
|
||||||
|
|
||||||
for (int i = 0; i < RING_POINTS; i++) {
|
String message = centered ? "Perfect! +" + points : "Good! +" + points;
|
||||||
double angle = 2 * Math.PI * i / RING_POINTS;
|
player.player.sendMessage(ColorUtil.color("&a" + message));
|
||||||
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
|
private void triggerFall(@NotNull ParkourPlayer player, @NotNull String reason) {
|
||||||
Particle.DustOptions innerDust = new Particle.DustOptions(Color.WHITE, 0.8f);
|
fall();
|
||||||
double innerR = r * 0.4;
|
player.player.sendMessage(ColorUtil.color("&cFell: " + reason));
|
||||||
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);
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// ── Firework event — cancel if on cooldown ────────────────────────────────
|
@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;
|
||||||
|
|
||||||
@EventHandler(priority = EventPriority.HIGH)
|
String message = String.format("&eRing: %d/%d &7| &aScore: %d",
|
||||||
public void onFireworkUse(PlayerInteractEvent event) {
|
currentIndex + 1, totalRings, score);
|
||||||
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
|
player.player.spigot().sendMessage(
|
||||||
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.ChatMessageType.ACTION_BAR,
|
||||||
net.md_5.bungee.api.chat.TextComponent.fromLegacyText(
|
net.md_5.bungee.api.chat.TextComponent.fromLegacyText(ColorUtil.color(message))
|
||||||
"§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);
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* 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(
|
|
||||||
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
|
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
private static ItemStack makeFirework(int amount) {
|
@EventHandler(priority = EventPriority.HIGH)
|
||||||
ItemStack item = new ItemStack(Material.FIREWORK_ROCKET, amount);
|
public void onPlayerInteract(@NotNull PlayerInteractEvent event) {
|
||||||
FireworkMeta meta = (FireworkMeta) item.getItemMeta();
|
Player player = event.getPlayer();
|
||||||
if (meta != null) {
|
ParkourPlayer pp = findParkourPlayer(player);
|
||||||
meta.addEffect(FireworkEffect.builder()
|
if (pp == null) return;
|
||||||
.with(FireworkEffect.Type.BALL)
|
|
||||||
.withColor(Color.AQUA)
|
ItemStack item = event.getItem();
|
||||||
.build());
|
if (item != null && item.getType() == Material.FIREWORK_ROCKET) {
|
||||||
|
if (physics.applyFireworkBoost(pp)) {
|
||||||
|
spawnBoostFirework(player);
|
||||||
|
item.setAmount(item.getAmount() - 1);
|
||||||
|
}
|
||||||
|
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);
|
meta.setPower(1);
|
||||||
item.setItemMeta(meta);
|
firework.setFireworkMeta(meta);
|
||||||
}
|
firework.detonate();
|
||||||
return item;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// ── Inner class: ElytraRing ───────────────────────────────────────────────
|
@NotNull
|
||||||
|
private Vector calculateNextDirection(@NotNull ElytraRing lastRing) {
|
||||||
/**
|
// Simple forward direction for now
|
||||||
* Represents one ring in the course.
|
return new Vector(1, 0, 0);
|
||||||
*
|
|
||||||
* <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;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
private void cleanupOldRings() {
|
||||||
* Returns true if the player crossed the ring's plane since the last call.
|
// Remove rings that are far behind all players
|
||||||
* Uses the dot product of (playerPos - ringCenter) with the ring's normal.
|
// Implementation depends on specific requirements
|
||||||
* 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);
|
|
||||||
}
|
|
||||||
lastSignedDist = signedDist;
|
|
||||||
return crossed;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
@NotNull
|
||||||
* Returns true if the player's position (projected onto the ring's plane)
|
private ItemStack makeFirework(int amount) {
|
||||||
* is within the ring's radius — i.e. they flew through the hole.
|
ItemStack firework = new ItemStack(Material.FIREWORK_ROCKET, amount);
|
||||||
*/
|
FireworkMeta meta = (FireworkMeta) firework.getItemMeta();
|
||||||
public boolean isInside(Location playerLoc) {
|
if (meta != null) {
|
||||||
// Project player position onto ring's plane
|
meta.setPower(1);
|
||||||
Vector toPlayer = playerLoc.toVector().subtract(center.toVector());
|
firework.setItemMeta(meta);
|
||||||
// Remove the component along the normal
|
}
|
||||||
Vector projected = toPlayer.clone().subtract(normal.clone().multiply(toPlayer.dot(normal)));
|
return firework;
|
||||||
return projected.length() <= radius;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/** Remove any barrier blocks this ring placed in the world. */
|
private ParkourPlayer findParkourPlayer(@NotNull Player player) {
|
||||||
public void remove() {
|
return getPlayers().stream()
|
||||||
barrierBlocks.forEach(b -> b.setType(Material.AIR, false));
|
.filter(pp -> pp.player.equals(player))
|
||||||
barrierBlocks.clear();
|
.findFirst()
|
||||||
}
|
.orElse(null);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
@ -97,6 +97,7 @@ public class RaceMode implements Mode {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@SuppressWarnings("deprecation") // Spigot Chat API: action bar via TextComponent
|
||||||
private void updateProgressBar() {
|
private void updateProgressBar() {
|
||||||
int remaining = Math.max(0, targetScore - state.score);
|
int remaining = Math.max(0, targetScore - state.score);
|
||||||
int filled = Math.min(20, state.score * 20 / targetScore);
|
int filled = Math.min(20, state.score * 20 / targetScore);
|
||||||
|
|
|
||||||
|
|
@ -1,7 +1,5 @@
|
||||||
package dev.loki.loparkour.mode;
|
package dev.loki.loparkour.mode;
|
||||||
|
|
||||||
import dev.loki.loparkour.util.Item;
|
|
||||||
|
|
||||||
import dev.loki.loparkour.config.Config;
|
import dev.loki.loparkour.config.Config;
|
||||||
import dev.loki.loparkour.config.Locales;
|
import dev.loki.loparkour.config.Locales;
|
||||||
import dev.loki.loparkour.leaderboard.Leaderboard;
|
import dev.loki.loparkour.leaderboard.Leaderboard;
|
||||||
|
|
|
||||||
|
|
@ -115,11 +115,13 @@ public class SpeedrunMode implements Mode {
|
||||||
|
|
||||||
ScheduledTask warningTask = Scheduler.get(LoParkour.getPlugin()).runLater(() -> {
|
ScheduledTask warningTask = Scheduler.get(LoParkour.getPlugin()).runLater(() -> {
|
||||||
if (block.getType() != Material.AIR) {
|
if (block.getType() != Material.AIR) {
|
||||||
player.player.playSound(block.getLocation(),
|
for (ParkourPlayer pp : getPlayers()) {
|
||||||
|
pp.player.playSound(block.getLocation(),
|
||||||
Sound.BLOCK_NOTE_BLOCK_PLING, 1.0f, 2.0f);
|
Sound.BLOCK_NOTE_BLOCK_PLING, 1.0f, 2.0f);
|
||||||
player.player.spawnParticle(Particle.FLAME,
|
pp.player.spawnParticle(Particle.FLAME,
|
||||||
block.getLocation().add(0.5, 1, 0.5), 8, 0.2, 0.2, 0.2, 0.01);
|
block.getLocation().add(0.5, 1, 0.5), 8, 0.2, 0.2, 0.2, 0.01);
|
||||||
}
|
}
|
||||||
|
}
|
||||||
}, warningTicks);
|
}, warningTicks);
|
||||||
|
|
||||||
ScheduledTask removalTask = Scheduler.get(LoParkour.getPlugin()).runLater(() -> {
|
ScheduledTask removalTask = Scheduler.get(LoParkour.getPlugin()).runLater(() -> {
|
||||||
|
|
|
||||||
|
|
@ -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;
|
package dev.loki.loparkour.player;
|
||||||
|
|
||||||
import java.util.ArrayList;
|
|
||||||
|
|
||||||
import dev.lolib.scheduler.Scheduler;
|
|
||||||
|
|
||||||
import com.google.gson.annotations.Expose;
|
import com.google.gson.annotations.Expose;
|
||||||
import dev.loki.loparkour.LoParkour;
|
import dev.loki.loparkour.LoParkour;
|
||||||
import dev.loki.loparkour.config.Config;
|
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.session.Session;
|
||||||
import dev.loki.loparkour.storage.Storage;
|
import dev.loki.loparkour.storage.Storage;
|
||||||
import dev.loki.loparkour.world.Divider;
|
import dev.loki.loparkour.world.Divider;
|
||||||
|
|
||||||
import dev.lolib.scheduler.Scheduler;
|
import dev.lolib.scheduler.Scheduler;
|
||||||
import org.bukkit.GameMode;
|
import org.bukkit.GameMode;
|
||||||
import org.bukkit.Location;
|
import org.bukkit.Location;
|
||||||
|
import org.bukkit.block.Block;
|
||||||
import org.bukkit.entity.Player;
|
import org.bukkit.entity.Player;
|
||||||
import org.bukkit.potion.PotionEffect;
|
import org.bukkit.potion.PotionEffect;
|
||||||
import org.jetbrains.annotations.NotNull;
|
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();
|
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 Double schematicDifficulty;
|
||||||
public @Expose Integer blockLead;
|
public @Expose Integer blockLead;
|
||||||
public @Expose Boolean particles;
|
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.
|
* @param player The player.
|
||||||
* @return True when this player is a {@link ParkourPlayer}, false if not.
|
* @return True when this player is a {@link ParkourPlayer}, false if not.
|
||||||
|
|
@ -112,9 +107,26 @@ public class ParkourPlayer extends ParkourUser {
|
||||||
|
|
||||||
session.removePlayers(this);
|
session.removePlayers(this);
|
||||||
|
|
||||||
|
// Clear scored blocks to prevent memory leak
|
||||||
|
scoredBlocks.clear();
|
||||||
|
|
||||||
save(LoParkour.getPlugin().isEnabled());
|
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.
|
* 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("selectedTime", selectedTime.toString())
|
||||||
.set("style", style);
|
.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;
|
package dev.loki.loparkour.player;
|
||||||
|
|
||||||
import dev.lolib.scheduler.Scheduler;
|
|
||||||
|
|
||||||
import dev.loki.loparkour.LoParkour;
|
import dev.loki.loparkour.LoParkour;
|
||||||
import dev.loki.loparkour.api.event.ParkourSpectateEvent;
|
import dev.loki.loparkour.api.event.ParkourSpectateEvent;
|
||||||
import dev.loki.loparkour.config.Locales;
|
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.session.Session;
|
||||||
import dev.loki.loparkour.util.ColorUtil;
|
import dev.loki.loparkour.util.ColorUtil;
|
||||||
import dev.lolib.scheduler.Scheduler;
|
import dev.lolib.scheduler.Scheduler;
|
||||||
|
import dev.lolib.scheduler.ScheduledTask;
|
||||||
import net.md_5.bungee.api.ChatMessageType;
|
import net.md_5.bungee.api.ChatMessageType;
|
||||||
import net.md_5.bungee.api.chat.TextComponent;
|
import net.md_5.bungee.api.chat.TextComponent;
|
||||||
import org.bukkit.GameMode;
|
import org.bukkit.GameMode;
|
||||||
import org.bukkit.entity.Player;
|
import org.bukkit.entity.Player;
|
||||||
import dev.lolib.scheduler.ScheduledTask;
|
|
||||||
import org.jetbrains.annotations.NotNull;
|
import org.jetbrains.annotations.NotNull;
|
||||||
import org.jetbrains.annotations.Nullable;
|
import org.jetbrains.annotations.Nullable;
|
||||||
|
|
||||||
|
|
@ -26,7 +24,7 @@ import java.util.Comparator;
|
||||||
*/
|
*/
|
||||||
public class ParkourSpectator extends ParkourUser {
|
public class ParkourSpectator extends ParkourUser {
|
||||||
|
|
||||||
private final dev.lolib.scheduler.ScheduledTask closestChecker;
|
private final ScheduledTask closestChecker;
|
||||||
/**
|
/**
|
||||||
* The closest player.
|
* The closest player.
|
||||||
*/
|
*/
|
||||||
|
|
@ -54,7 +52,8 @@ public class ParkourSpectator extends ParkourUser {
|
||||||
}
|
}
|
||||||
}, 1);
|
}, 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()) {
|
if (session.getPlayers().isEmpty()) {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
@ -68,6 +67,7 @@ public class ParkourSpectator extends ParkourUser {
|
||||||
/**
|
/**
|
||||||
* Updates the spectator's action bar, scoreboard and checks distance.
|
* Updates the spectator's action bar, scoreboard and checks distance.
|
||||||
*/
|
*/
|
||||||
|
@SuppressWarnings("deprecation") // Spigot Chat API: action bar via TextComponent
|
||||||
public void update() {
|
public void update() {
|
||||||
player.spigot().sendMessage(ChatMessageType.ACTION_BAR, TextComponent.fromLegacyText(ColorUtil.color(Locales.getString(player, "play.spectator.action_bar"))));
|
player.spigot().sendMessage(ChatMessageType.ACTION_BAR, TextComponent.fromLegacyText(ColorUtil.color(Locales.getString(player, "play.spectator.action_bar"))));
|
||||||
player.setGameMode(GameMode.SPECTATOR);
|
player.setGameMode(GameMode.SPECTATOR);
|
||||||
|
|
|
||||||
|
|
@ -68,6 +68,7 @@ public abstract class ParkourUser {
|
||||||
public final Instant joined;
|
public final Instant joined;
|
||||||
private ScoreboardManager scoreboardManager;
|
private ScoreboardManager scoreboardManager;
|
||||||
|
|
||||||
|
@SuppressWarnings("deprecation") // Scoreboard: legacy registerNewObjective(String,String,String)
|
||||||
public ParkourUser(@NotNull Player player, @NotNull Session session, @Nullable PreviousData previousData) {
|
public ParkourUser(@NotNull Player player, @NotNull Session session, @Nullable PreviousData previousData) {
|
||||||
this.player = player;
|
this.player = player;
|
||||||
this.session = session;
|
this.session = session;
|
||||||
|
|
|
||||||
|
|
@ -25,18 +25,21 @@ public class PlayerSettingsManager {
|
||||||
private static void initializeColumnMappings() {
|
private static void initializeColumnMappings() {
|
||||||
COLUMN_MAPPINGS.put("uuid", new OptionContainer(null, null));
|
COLUMN_MAPPINGS.put("uuid", new OptionContainer(null, null));
|
||||||
COLUMN_MAPPINGS.put("style", new OptionContainer(ParkourOption.STYLES, (p, v) -> p.style = v));
|
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("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("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("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("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("collectedRewards", new OptionContainer(null, PlayerSettingsManager::applyCollectedRewards));
|
||||||
COLUMN_MAPPINGS.put("locale", new OptionContainer(ParkourOption.LANG, (p, v) -> {
|
COLUMN_MAPPINGS.put("locale", new OptionContainer(ParkourOption.LANG, (p, v) -> {
|
||||||
p._locale = v;
|
p._locale = 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)));
|
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) {
|
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) {
|
private static void applyCollectedRewards(ParkourPlayer player, String value) {
|
||||||
|
|
|
||||||
|
|
@ -68,6 +68,7 @@ class ScoreboardManager {
|
||||||
return new Score("?", "?", "?", 0);
|
return new Score("?", "?", "?", 0);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@SuppressWarnings("deprecation") // Scoreboard: legacy registerNewObjective(String,String,String)
|
||||||
private void updateTitle(Score top, Score high, ParkourGenerator generator) {
|
private void updateTitle(Score top, Score high, ParkourGenerator generator) {
|
||||||
Objective obj = board.getObjective("lp_sidebar");
|
Objective obj = board.getObjective("lp_sidebar");
|
||||||
if (obj == null) {
|
if (obj == null) {
|
||||||
|
|
|
||||||
|
|
@ -1,16 +1,14 @@
|
||||||
package dev.loki.loparkour.player.data;
|
package dev.loki.loparkour.player.data;
|
||||||
|
|
||||||
import dev.lolib.scheduler.Scheduler;
|
|
||||||
|
|
||||||
import dev.loki.loparkour.LoParkour;
|
import dev.loki.loparkour.LoParkour;
|
||||||
import dev.loki.loparkour.config.Config;
|
import dev.loki.loparkour.config.Config;
|
||||||
import dev.lolib.scheduler.Scheduler;
|
import dev.lolib.scheduler.Scheduler;
|
||||||
import org.bukkit.Bukkit;
|
import org.bukkit.Bukkit;
|
||||||
|
import org.bukkit.configuration.file.YamlConfiguration;
|
||||||
import org.bukkit.entity.Player;
|
import org.bukkit.entity.Player;
|
||||||
import org.bukkit.inventory.Inventory;
|
import org.bukkit.inventory.Inventory;
|
||||||
import org.bukkit.inventory.ItemStack;
|
import org.bukkit.inventory.ItemStack;
|
||||||
import org.bukkit.util.io.BukkitObjectInputStream;
|
import org.bukkit.util.io.BukkitObjectInputStream;
|
||||||
import org.bukkit.util.io.BukkitObjectOutputStream;
|
|
||||||
import org.jetbrains.annotations.Nullable;
|
import org.jetbrains.annotations.Nullable;
|
||||||
|
|
||||||
import java.io.*;
|
import java.io.*;
|
||||||
|
|
@ -18,6 +16,10 @@ import java.util.HashMap;
|
||||||
import java.util.Map;
|
import java.util.Map;
|
||||||
import java.util.function.Consumer;
|
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 {
|
public class InventoryData {
|
||||||
|
|
||||||
private final File file;
|
private final File file;
|
||||||
|
|
@ -51,11 +53,46 @@ public class InventoryData {
|
||||||
Scheduler.get(LoParkour.getPlugin()).runAsync(() -> loadFile(onFinish));
|
Scheduler.get(LoParkour.getPlugin()).runAsync(() -> loadFile(onFinish));
|
||||||
}
|
}
|
||||||
|
|
||||||
@SuppressWarnings("unchecked")
|
|
||||||
private void loadFile(Consumer<@Nullable InventoryData> onFinish) {
|
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)))) {
|
try (BukkitObjectInputStream stream = new BukkitObjectInputStream(new BufferedInputStream(new FileInputStream(file)))) {
|
||||||
items = (Map<Integer, ItemStack>) stream.readObject();
|
items = (Map<Integer, ItemStack>) stream.readObject();
|
||||||
|
|
||||||
onFinish.accept(this);
|
onFinish.accept(this);
|
||||||
} catch (IOException | ClassNotFoundException ex) {
|
} catch (IOException | ClassNotFoundException ex) {
|
||||||
LoParkour.getPlugin().getLogger().severe("Error while reading inventory of %s from file %s".formatted(player.getName(), file.getName()) + " - " + ex.getMessage());
|
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());
|
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)))) {
|
try {
|
||||||
stream.writeObject(items);
|
YamlConfiguration yaml = new YamlConfiguration();
|
||||||
stream.flush();
|
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) {
|
} catch (IOException ex) {
|
||||||
LoParkour.getPlugin().getLogger().severe("Error while saving inventory of %s to file %s".formatted(player.getName(), file.getName()) + " - " + ex.getMessage());
|
LoParkour.getPlugin().getLogger().severe("Error while saving inventory of %s to file %s".formatted(player.getName(), file.getName()) + " - " + ex.getMessage());
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -48,6 +48,15 @@ public class Rewards {
|
||||||
ONE_TIME_REWARDS = parseScores("one-time-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) {
|
private static Map<Integer, List<Reward>> parseScores(String path) {
|
||||||
Map<Integer, List<Reward>> rewardMap = new HashMap<>();
|
Map<Integer, List<Reward>> rewardMap = new HashMap<>();
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -1,30 +1,17 @@
|
||||||
package dev.loki.loparkour.schematic;
|
package dev.loki.loparkour.schematic;
|
||||||
|
|
||||||
import dev.lolib.scheduler.Scheduler;
|
|
||||||
|
|
||||||
import dev.loki.loparkour.LoParkour;
|
import dev.loki.loparkour.LoParkour;
|
||||||
import dev.loki.loparkour.config.Config;
|
|
||||||
import dev.lolib.scheduler.Scheduler;
|
import dev.lolib.scheduler.Scheduler;
|
||||||
|
|
||||||
import java.io.File;
|
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 {
|
public class Schematics {
|
||||||
|
|
||||||
private static final String[] SPAWN_SCHEMATICS = new String[]{
|
|
||||||
"spawn-island", "spawn-island-duels"
|
|
||||||
};
|
|
||||||
private static final File FOLDER = LoParkour.getInFolder("schematics");
|
private static final File FOLDER = LoParkour.getInFolder("schematics");
|
||||||
|
|
||||||
/**
|
|
||||||
* Reads all files.
|
|
||||||
*/
|
|
||||||
public static void init() {
|
public static void init() {
|
||||||
Scheduler.get(LoParkour.getPlugin()).runAsync(() -> {
|
Scheduler.get(LoParkour.getPlugin()).runAsync(() -> {
|
||||||
if (!FOLDER.exists()) {
|
if (!FOLDER.exists()) {
|
||||||
|
|
|
||||||
|
|
@ -1,7 +1,5 @@
|
||||||
package dev.loki.loparkour.schematic.lpschem;
|
package dev.loki.loparkour.schematic.lpschem;
|
||||||
|
|
||||||
import org.bukkit.Bukkit;
|
|
||||||
|
|
||||||
import com.google.gson.Gson;
|
import com.google.gson.Gson;
|
||||||
import com.google.gson.GsonBuilder;
|
import com.google.gson.GsonBuilder;
|
||||||
import org.bukkit.Location;
|
import org.bukkit.Location;
|
||||||
|
|
|
||||||
|
|
@ -1,244 +1,154 @@
|
||||||
package dev.loki.loparkour.session;
|
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.generator.ParkourGenerator;
|
||||||
import dev.loki.loparkour.player.ParkourPlayer;
|
import dev.loki.loparkour.player.ParkourPlayer;
|
||||||
import dev.loki.loparkour.player.ParkourSpectator;
|
import dev.loki.loparkour.player.ParkourSpectator;
|
||||||
import dev.loki.loparkour.player.ParkourUser;
|
import dev.loki.loparkour.player.ParkourUser;
|
||||||
import dev.loki.loparkour.world.Divider;
|
|
||||||
import org.bukkit.Location;
|
import org.bukkit.Location;
|
||||||
import org.bukkit.entity.Player;
|
import org.bukkit.entity.Player;
|
||||||
import org.jetbrains.annotations.NotNull;
|
import org.jetbrains.annotations.NotNull;
|
||||||
|
|
||||||
import java.util.*;
|
import java.util.ArrayList;
|
||||||
|
import java.util.List;
|
||||||
import java.util.function.Function;
|
import java.util.function.Function;
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* <p>A session is bound to a {@link Divider} section.
|
* Refactored Session using composition pattern.
|
||||||
* It manages all players, all spectators, visibility, the generator, etc.</p>
|
* Coordinates player management and state management.
|
||||||
* <p>Iteration 2.</p>
|
|
||||||
*
|
|
||||||
* @author loki
|
|
||||||
* @since 5.0.0
|
|
||||||
*/
|
*/
|
||||||
public class Session {
|
public class Session {
|
||||||
|
|
||||||
/**
|
|
||||||
* The spawn location of this session.
|
|
||||||
*/
|
|
||||||
private Location spawnLocation;
|
|
||||||
|
|
||||||
/**
|
|
||||||
* The generator.
|
|
||||||
*/
|
|
||||||
public ParkourGenerator generator;
|
public ParkourGenerator generator;
|
||||||
|
|
||||||
/**
|
private final SessionPlayerManager playerManager;
|
||||||
* The visibility of this session. Default public.
|
private final SessionStateManager stateManager;
|
||||||
*/
|
|
||||||
private Visibility visibility = Visibility.PUBLIC;
|
private Session() {
|
||||||
|
this.playerManager = new SessionPlayerManager(this);
|
||||||
|
this.stateManager = new SessionStateManager(this);
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Manages users (players and spectators) in this session.
|
* Create a new session with specified parameters.
|
||||||
*/
|
*/
|
||||||
private final SessionUserManager userManager = new SessionUserManager(this);
|
@NotNull
|
||||||
|
public static Session create(
|
||||||
/**
|
@NotNull Function<Session, ParkourGenerator> generatorFunction,
|
||||||
* 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> isAcceptingPlayers,
|
||||||
Function<Session, Boolean> isAcceptingSpectators,
|
Function<Session, Boolean> isAcceptingSpectators,
|
||||||
Player... players) {
|
Player... players) {
|
||||||
|
|
||||||
Session session = new Session();
|
Session session = new Session();
|
||||||
|
|
||||||
if (isAcceptingPlayers != null) session.isAcceptingPlayers = isAcceptingPlayers;
|
// Set acceptance functions
|
||||||
if (isAcceptingSpectators != null) session.isAcceptingSpectators = isAcceptingSpectators;
|
if (isAcceptingPlayers != null) {
|
||||||
|
session.playerManager.setAcceptingPlayers(isAcceptingPlayers);
|
||||||
|
}
|
||||||
|
if (isAcceptingSpectators != null) {
|
||||||
|
session.playerManager.setAcceptingSpectators(isAcceptingSpectators);
|
||||||
|
}
|
||||||
|
|
||||||
List<ParkourPlayer> pps = new ArrayList<>();
|
// Add initial players
|
||||||
|
List<ParkourPlayer> parkourPlayers = new ArrayList<>();
|
||||||
if (players != null) {
|
if (players != null) {
|
||||||
for (Player player : players) {
|
for (Player player : players) {
|
||||||
ParkourPlayer pp = ParkourUser.register(player, session);
|
ParkourPlayer pp = ParkourUser.register(player, session);
|
||||||
session.addPlayers(pp);
|
session.addPlayers(pp);
|
||||||
pps.add(pp);
|
parkourPlayers.add(pp);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
session.spawnLocation = Divider.add(session);
|
// Initialize session state
|
||||||
session.generator = generatorFunction.apply(session);
|
session.stateManager.initialize(generatorFunction);
|
||||||
|
|
||||||
if (players != null) {
|
// Update generator settings for players
|
||||||
pps.forEach(p -> p.updateGeneratorSettings(session.generator));
|
if (session.generator != null) {
|
||||||
|
parkourPlayers.forEach(p -> p.updateGeneratorSettings(session.generator));
|
||||||
}
|
}
|
||||||
|
|
||||||
session.generator.island.build(session.spawnLocation);
|
|
||||||
|
|
||||||
return session;
|
return session;
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
// Visibility management
|
||||||
* Sets the visibility of this session.
|
public void setVisibility(@NotNull Visibility visibility) {
|
||||||
* @param visibility The visibility.
|
stateManager.setVisibility(visibility);
|
||||||
*/
|
|
||||||
public void setVisibility(Visibility visibility) {
|
|
||||||
this.visibility = visibility;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
@NotNull
|
||||||
* @return The visibility of this session.
|
|
||||||
*/
|
|
||||||
public Visibility getVisibility() {
|
public Visibility getVisibility() {
|
||||||
return visibility;
|
return stateManager.getVisibility();
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
// Player management
|
||||||
* Adds provided players to this session's player list.
|
public void addPlayers(@NotNull ParkourPlayer... toAdd) {
|
||||||
*
|
playerManager.addPlayers(toAdd);
|
||||||
* @param toAdd The players to add.
|
|
||||||
*/
|
|
||||||
public void addPlayers(ParkourPlayer... toAdd) {
|
|
||||||
userManager.addPlayers(toAdd);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
public void removePlayers(@NotNull ParkourPlayer... toRemove) {
|
||||||
* Removes provided players from this session's player list.
|
playerManager.removePlayers(toRemove);
|
||||||
*
|
|
||||||
* @param toRemove The players to remove.
|
|
||||||
*/
|
|
||||||
public void removePlayers(ParkourPlayer... toRemove) {
|
|
||||||
userManager.removePlayers(toRemove);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
@NotNull
|
||||||
* @return The players.
|
|
||||||
*/
|
|
||||||
public List<ParkourPlayer> getPlayers() {
|
public List<ParkourPlayer> getPlayers() {
|
||||||
return userManager.getPlayers();
|
return playerManager.getPlayers();
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
// Spectator management
|
||||||
* Adds provided spectators to this session's spectator list.
|
public void addSpectators(@NotNull ParkourSpectator... spectators) {
|
||||||
*
|
playerManager.addSpectators(spectators);
|
||||||
* @param spectators The spectators to add.
|
|
||||||
*/
|
|
||||||
public void addSpectators(ParkourSpectator... spectators) {
|
|
||||||
userManager.addSpectators(spectators);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
public void removeSpectators(@NotNull ParkourSpectator... spectators) {
|
||||||
* Removes provided spectators from this session's spectator list.
|
playerManager.removeSpectators(spectators);
|
||||||
*
|
|
||||||
* @param spectators The spectators to remove.
|
|
||||||
*/
|
|
||||||
public void removeSpectators(ParkourSpectator... spectators) {
|
|
||||||
userManager.removeSpectators(spectators);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
@NotNull
|
||||||
* @return The spectators.
|
|
||||||
*/
|
|
||||||
public List<ParkourSpectator> getSpectators() {
|
public List<ParkourSpectator> getSpectators() {
|
||||||
return userManager.getSpectators();
|
return playerManager.getSpectators();
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
@NotNull
|
||||||
* @return The users.
|
|
||||||
*/
|
|
||||||
public List<ParkourUser> getUsers() {
|
public List<ParkourUser> getUsers() {
|
||||||
return userManager.getUsers();
|
return playerManager.getUsers();
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
// State management
|
||||||
* @return the spawn location for this {@link Session}.
|
|
||||||
*/
|
|
||||||
@SuppressWarnings("unused")
|
|
||||||
public Location getSpawnLocation() {
|
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) {
|
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) {
|
public boolean isMuted(@NotNull ParkourUser user) {
|
||||||
return userManager.isMuted(user);
|
return stateManager.isMuted(user);
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
// Acceptance checks
|
||||||
* 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.
|
|
||||||
*/
|
|
||||||
public boolean isAcceptingPlayers() {
|
public boolean isAcceptingPlayers() {
|
||||||
return isAcceptingPlayers.apply(this);
|
return playerManager.isAcceptingPlayers();
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
|
||||||
* @return True when spectators may join this session, false if not.
|
|
||||||
*/
|
|
||||||
public boolean isAcceptingSpectators() {
|
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 {
|
public enum Visibility {
|
||||||
|
PRIVATE, ID_ONLY, PUBLIC
|
||||||
/**
|
|
||||||
* No-one can join.
|
|
||||||
*/
|
|
||||||
PRIVATE,
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Only people with the session id can join.
|
|
||||||
*/
|
|
||||||
ID_ONLY,
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Anyone can join.
|
|
||||||
*/
|
|
||||||
PUBLIC,
|
|
||||||
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
|
||||||
* An enum for all available chat types that a player can select while playing
|
|
||||||
*/
|
|
||||||
public enum ChatType {
|
public enum ChatType {
|
||||||
|
|
||||||
LOBBY_ONLY, PLAYERS_ONLY, PUBLIC
|
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;
|
package dev.loki.loparkour.storage;
|
||||||
|
|
||||||
|
import com.zaxxer.hikari.HikariConfig;
|
||||||
|
import com.zaxxer.hikari.HikariDataSource;
|
||||||
import dev.loki.loparkour.LoParkour;
|
import dev.loki.loparkour.LoParkour;
|
||||||
import dev.loki.loparkour.config.Option;
|
import dev.loki.loparkour.config.Option;
|
||||||
|
|
||||||
import java.sql.Connection;
|
import java.sql.Connection;
|
||||||
import java.sql.DriverManager;
|
|
||||||
import java.sql.PreparedStatement;
|
import java.sql.PreparedStatement;
|
||||||
import java.sql.SQLException;
|
import java.sql.SQLException;
|
||||||
import java.util.List;
|
import java.util.List;
|
||||||
import java.util.concurrent.CopyOnWriteArrayList;
|
import java.util.concurrent.CopyOnWriteArrayList;
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Manages SQL database connection lifecycle.
|
* MySQL access via HikariCP pool.
|
||||||
*
|
|
||||||
* @since 5.0.0
|
|
||||||
*/
|
*/
|
||||||
class SQLConnectionManager {
|
class SQLConnectionManager {
|
||||||
|
|
||||||
private Connection connection;
|
private HikariDataSource dataSource;
|
||||||
private volatile boolean connected = false;
|
private volatile boolean connected = false;
|
||||||
private final List<Runnable> onConnectCallbacks = new CopyOnWriteArrayList<>();
|
private final List<Runnable> onConnectCallbacks = new CopyOnWriteArrayList<>();
|
||||||
|
|
||||||
public boolean isConnected() {
|
public boolean isConnected() {
|
||||||
return connected;
|
return connected && dataSource != null && !dataSource.isClosed();
|
||||||
}
|
}
|
||||||
|
|
||||||
public void runWhenConnected(Runnable callback) {
|
public void runWhenConnected(Runnable callback) {
|
||||||
if (connected) {
|
if (isConnected()) {
|
||||||
callback.run();
|
callback.run();
|
||||||
} else {
|
} else {
|
||||||
onConnectCallbacks.add(callback);
|
onConnectCallbacks.add(callback);
|
||||||
|
|
@ -35,14 +34,19 @@ class SQLConnectionManager {
|
||||||
|
|
||||||
public void connect() {
|
public void connect() {
|
||||||
try {
|
try {
|
||||||
LoParkour.log("Connecting to MySQL...");
|
LoParkour.log("Connecting to MySQL (pool)...");
|
||||||
loadDriver();
|
|
||||||
|
|
||||||
connection = DriverManager.getConnection(
|
HikariConfig config = new HikariConfig();
|
||||||
buildConnectionUrl(),
|
config.setJdbcUrl(buildConnectionUrl());
|
||||||
Option.SQL_USERNAME,
|
config.setUsername(Option.SQL_USERNAME);
|
||||||
Option.SQL_PASSWORD);
|
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;
|
connected = true;
|
||||||
LoParkour.log("Connected to MySQL");
|
LoParkour.log("Connected to MySQL");
|
||||||
|
|
||||||
|
|
@ -53,23 +57,17 @@ class SQLConnectionManager {
|
||||||
}
|
}
|
||||||
|
|
||||||
public void close() {
|
public void close() {
|
||||||
try {
|
connected = false;
|
||||||
if (connection != null && !connection.isClosed()) {
|
if (dataSource != null && !dataSource.isClosed()) {
|
||||||
connection.close();
|
dataSource.close();
|
||||||
LoParkour.log("Closed connection to MySQL");
|
LoParkour.log("Closed MySQL pool");
|
||||||
}
|
|
||||||
} catch (SQLException ex) {
|
|
||||||
LoParkour.getPlugin().getLogger().severe(
|
|
||||||
"Error while trying to close connection to SQL database - " + ex.getMessage());
|
|
||||||
}
|
}
|
||||||
|
dataSource = null;
|
||||||
}
|
}
|
||||||
|
|
||||||
public PreparedStatement prepareStatement(String sql) {
|
public PreparedStatement prepareStatement(String sql) {
|
||||||
validateConnection();
|
|
||||||
if (connection == null) return null;
|
|
||||||
|
|
||||||
try {
|
try {
|
||||||
return connection.prepareStatement(sql);
|
return getConnection().prepareStatement(sql);
|
||||||
} catch (SQLException ex) {
|
} catch (SQLException ex) {
|
||||||
LoParkour.getPlugin().getLogger().severe(
|
LoParkour.getPlugin().getLogger().severe(
|
||||||
"Error preparing statement: %s - %s".formatted(sql, ex.getMessage()));
|
"Error preparing statement: %s - %s".formatted(sql, ex.getMessage()));
|
||||||
|
|
@ -77,30 +75,27 @@ class SQLConnectionManager {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
public void validateConnection() {
|
public Connection getConnection() throws SQLException {
|
||||||
try {
|
if (!isConnected()) {
|
||||||
if (connection == null || !connection.isValid(2)) {
|
throw new SQLException("Database not connected");
|
||||||
LoParkour.getPlugin().getLogger().warning("MySQL connection lost, attempting reconnect...");
|
|
||||||
connect();
|
|
||||||
}
|
}
|
||||||
} catch (Exception ex) {
|
return dataSource.getConnection();
|
||||||
LoParkour.getPlugin().getLogger().severe("Error reconnecting to MySQL - " + ex.getMessage());
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Ensures the pool was created; does not recreate the pool on failure (restart required).
|
||||||
|
*/
|
||||||
|
public void validateConnection() {
|
||||||
|
if (!isConnected()) {
|
||||||
|
LoParkour.getPlugin().getLogger().warning("MySQL pool not available");
|
||||||
Option.SQL = false;
|
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() {
|
private String buildConnectionUrl() {
|
||||||
return ("jdbc:mysql://%s:%d/%s?allowPublicKeyRetrieval=true" +
|
return ("jdbc:mysql://%s:%d/%s?allowPublicKeyRetrieval=true"
|
||||||
"&useSSL=false&useUnicode=true&characterEncoding=utf-8" +
|
+ "&useSSL=false&useUnicode=true&characterEncoding=utf-8"
|
||||||
"&autoReconnect=true&maxReconnects=2&connectTimeout=5000&socketTimeout=5000")
|
+ "&autoReconnect=true&maxReconnects=2&connectTimeout=5000&socketTimeout=5000")
|
||||||
.formatted(Option.SQL_URL, Option.SQL_PORT, Option.SQL_DB);
|
.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());
|
"Could not connect to MySQL - check your SQL settings - " + ex.getMessage());
|
||||||
LoParkour.getPlugin().getLogger().severe("Disabling SQL storage, using local storage instead");
|
LoParkour.getPlugin().getLogger().severe("Disabling SQL storage, using local storage instead");
|
||||||
Option.SQL = false;
|
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;
|
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.leaderboard.Score;
|
||||||
import dev.loki.loparkour.player.ParkourPlayer;
|
import dev.loki.loparkour.player.ParkourPlayer;
|
||||||
|
|
||||||
import org.bukkit.Bukkit;
|
|
||||||
import org.jetbrains.annotations.NotNull;
|
import org.jetbrains.annotations.NotNull;
|
||||||
|
|
||||||
import java.sql.*;
|
import java.sql.Connection;
|
||||||
import java.text.DecimalFormat;
|
import java.sql.PreparedStatement;
|
||||||
import java.text.DecimalFormatSymbols;
|
import java.sql.ResultSet;
|
||||||
import java.util.*;
|
import java.sql.SQLException;
|
||||||
import java.util.concurrent.CopyOnWriteArrayList;
|
import java.util.Map;
|
||||||
|
import java.util.UUID;
|
||||||
|
import java.util.logging.Logger;
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* MySQL storage manager.
|
* Refactored StorageSQL using composition pattern.
|
||||||
*
|
* Coordinates connection management, query building, and data mapping.
|
||||||
* <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
|
|
||||||
*/
|
*/
|
||||||
class StorageSQL {
|
public class StorageSQL {
|
||||||
|
|
||||||
private static final SQLConnectionManager connectionManager = new SQLConnectionManager();
|
private static final Logger logger = Logger.getLogger(StorageSQL.class.getName());
|
||||||
private static final SQLQueryExecutor queryExecutor = new SQLQueryExecutor(connectionManager);
|
private static SQLConnectionManager connectionManager;
|
||||||
private static final SQLMigrationManager migrationManager = new SQLMigrationManager(queryExecutor);
|
|
||||||
|
|
||||||
private static boolean initialized = false;
|
|
||||||
private static final List<String> pendingTableCreations = new CopyOnWriteArrayList<>();
|
|
||||||
|
|
||||||
// ── Connection lifecycle ──────────────────────────────────────────────────
|
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Check if database is connected.
|
||||||
|
*/
|
||||||
public static boolean isConnected() {
|
public static boolean isConnected() {
|
||||||
return connectionManager.isConnected();
|
return connectionManager != null && connectionManager.isConnected();
|
||||||
}
|
}
|
||||||
|
|
||||||
public static void runWhenConnected(Runnable callback) {
|
|
||||||
connectionManager.runWhenConnected(callback);
|
|
||||||
}
|
|
||||||
|
|
||||||
public static void close() {
|
|
||||||
connectionManager.close();
|
|
||||||
}
|
|
||||||
|
|
||||||
// ── Initialisation ────────────────────────────────────────────────────────
|
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Queues creation of the leaderboard table for {@code mode}.
|
* Run callback when database is connected.
|
||||||
* Table name is constructed from a config-defined prefix (admin-controlled,
|
|
||||||
* not user input) so formatting here is safe.
|
|
||||||
*/
|
*/
|
||||||
public static void init(String mode) {
|
public static void runWhenConnected(@NotNull Runnable callback) {
|
||||||
// Table names are derived from admin config, not player input — safe to format.
|
if (isConnected()) {
|
||||||
String createTableSql = """
|
callback.run();
|
||||||
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 {
|
} else {
|
||||||
pendingTableCreations.add(createTableSql);
|
logger.warning("Database not connected, skipping operation");
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// ── 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.
|
* Close database connection.
|
||||||
*
|
*/
|
||||||
* <p>Uses an INSERT … ON DUPLICATE KEY UPDATE with {@code ?} placeholders.
|
public static void close() {
|
||||||
* Player name, time, difficulty come from the leaderboard record — all bound
|
if (connectionManager != null) {
|
||||||
* as parameters, never interpolated.</p>
|
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) {
|
public static void writeScores(@NotNull String mode, @NotNull Map<UUID, Score> scores) {
|
||||||
// Table name is from admin config — safe to format.
|
if (!isConnected() || scores.isEmpty()) {
|
||||||
String sql = """
|
return;
|
||||||
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()));
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// ── Player settings ───────────────────────────────────────────────────────
|
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());
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Reads player settings from the options table.
|
* Read player data from database.
|
||||||
*
|
|
||||||
* <p>UUID is bound as a parameter — never interpolated into the query string.</p>
|
|
||||||
*/
|
*/
|
||||||
public static void readPlayer(@NotNull ParkourPlayer player) {
|
public static void readPlayer(@NotNull ParkourPlayer player) {
|
||||||
// Table name from admin config — safe. UUID bound as parameter below.
|
if (!isConnected()) {
|
||||||
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;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
// Bind UUID as parameter — no injection possible
|
String query = SQLQueryBuilder.selectPlayer();
|
||||||
stmt.setString(1, player.getUUID().toString());
|
|
||||||
|
try (Connection conn = connectionManager.getConnection();
|
||||||
|
PreparedStatement stmt = conn.prepareStatement(query)) {
|
||||||
|
|
||||||
|
stmt.setString(1, player.player.getUniqueId().toString());
|
||||||
|
|
||||||
try (ResultSet rs = stmt.executeQuery()) {
|
try (ResultSet rs = stmt.executeQuery()) {
|
||||||
if (!rs.next()) {
|
SQLDataMapper.PlayerData data = SQLDataMapper.mapPlayerData(rs);
|
||||||
player.setSettings(new HashMap<>());
|
if (data != null) {
|
||||||
|
SQLDataMapper.applyPlayerData(player, data);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
} catch (SQLException e) {
|
||||||
|
logger.severe("Failed to read player data: " + e.getMessage());
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Write player data to database.
|
||||||
|
*/
|
||||||
|
public static void writePlayer(@NotNull ParkourPlayer player) {
|
||||||
|
if (!isConnected()) {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
Map<String, Object> settings = new HashMap<>();
|
String query = SQLQueryBuilder.upsertPlayer();
|
||||||
for (String key : ParkourPlayer.PLAYER_COLUMNS.keySet()) {
|
|
||||||
try {
|
try (Connection conn = connectionManager.getConnection();
|
||||||
settings.put(key, rs.getObject(key));
|
PreparedStatement stmt = conn.prepareStatement(query)) {
|
||||||
} catch (SQLException ex) {
|
|
||||||
LoParkour.getPlugin().getLogger().severe(
|
SQLDataMapper.setPlayerParameters(stmt, player);
|
||||||
"Error reading SQL column %s for %s - %s"
|
stmt.executeUpdate();
|
||||||
.formatted(key, player.getName(), ex.getMessage()));
|
|
||||||
}
|
} catch (SQLException e) {
|
||||||
}
|
logger.severe("Failed to write player data: " + e.getMessage());
|
||||||
player.setSettings(settings);
|
|
||||||
}
|
|
||||||
} catch (SQLException ex) {
|
|
||||||
LoParkour.getPlugin().getLogger().severe(
|
|
||||||
"Error reading player data for %s - %s".formatted(player.getName(), ex.getMessage()));
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
private static void createTables(@NotNull String mode) throws SQLException {
|
||||||
* Writes player settings to the options table.
|
String tableName = SQLQueryBuilder.getTableName(mode);
|
||||||
*
|
|
||||||
* <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>
|
|
||||||
*/
|
|
||||||
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.
|
try (Connection conn = connectionManager.getConnection()) {
|
||||||
String sql = """
|
// Create scores table
|
||||||
INSERT INTO `%soptions`
|
try (PreparedStatement stmt = conn.prepareStatement(SQLQueryBuilder.createScoresTable(tableName))) {
|
||||||
(uuid, style, blockLead, useParticles, useSpecial, showFallMsg,
|
stmt.executeUpdate();
|
||||||
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 ─────────────────────────────────────────────────────────────
|
// Create players table
|
||||||
|
try (PreparedStatement stmt = conn.prepareStatement(SQLQueryBuilder.createPlayersTable())) {
|
||||||
/**
|
stmt.executeUpdate();
|
||||||
* 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);
|
|
||||||
}
|
|
||||||
pendingTableCreations.clear();
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
@ -12,14 +12,15 @@ public final class RandomStyle implements Style {
|
||||||
|
|
||||||
public RandomStyle(String name, List<Material> materials) {
|
public RandomStyle(String name, List<Material> materials) {
|
||||||
this.name = name;
|
this.name = name;
|
||||||
this.materials = materials;
|
this.materials = (materials == null || materials.isEmpty())
|
||||||
|
? List.of(Material.STONE)
|
||||||
|
: List.copyOf(materials);
|
||||||
}
|
}
|
||||||
|
|
||||||
@Override
|
@Override
|
||||||
public Material getNext() {
|
public Material getNext() {
|
||||||
return materials.get(ThreadLocalRandom.current().nextInt(materials.size()));
|
return materials.get(ThreadLocalRandom.current().nextInt(materials.size()));
|
||||||
}
|
}
|
||||||
|
|
||||||
@Override
|
@Override
|
||||||
public String getName() {
|
public String getName() {
|
||||||
return name;
|
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) {
|
public Menu item(int slot, Item item) {
|
||||||
// TODO: Implement with LoLib GUI
|
|
||||||
return this;
|
return this;
|
||||||
}
|
}
|
||||||
|
|
||||||
public Menu distributeRowsEvenly() {
|
public Menu distributeRowsEvenly() {
|
||||||
// TODO: Implement
|
|
||||||
return this;
|
return this;
|
||||||
}
|
}
|
||||||
|
|
||||||
public void open(Player player) {
|
public void open(Player player) {
|
||||||
// TODO: Implement with LoLib GUI
|
player.sendMessage("§c" + title + " (" + rows + " рядов) — меню временно недоступно (миграция GUI).");
|
||||||
player.sendMessage("§cМеню временно недоступно во время миграции");
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -18,36 +18,29 @@ public class PagedMenu {
|
||||||
}
|
}
|
||||||
|
|
||||||
public PagedMenu addToDisplay(List<Item> items) {
|
public PagedMenu addToDisplay(List<Item> items) {
|
||||||
// TODO: Implement with LoLib GUI
|
|
||||||
return this;
|
return this;
|
||||||
}
|
}
|
||||||
|
|
||||||
public PagedMenu item(int slot, Item item) {
|
public PagedMenu item(int slot, Item item) {
|
||||||
// TODO: Implement with LoLib GUI
|
|
||||||
return this;
|
return this;
|
||||||
}
|
}
|
||||||
|
|
||||||
public PagedMenu nextPage(int slot, Item item) {
|
public PagedMenu nextPage(int slot, Item item) {
|
||||||
// TODO: Implement with LoLib GUI
|
|
||||||
return this;
|
return this;
|
||||||
}
|
}
|
||||||
|
|
||||||
public PagedMenu prevPage(int slot, Item item) {
|
public PagedMenu prevPage(int slot, Item item) {
|
||||||
// TODO: Implement with LoLib GUI
|
|
||||||
return this;
|
return this;
|
||||||
}
|
}
|
||||||
|
|
||||||
public void page(int direction) {
|
public void page(int direction) {
|
||||||
// TODO: Implement with LoLib GUI
|
|
||||||
}
|
}
|
||||||
|
|
||||||
public List<Object> getTotalToDisplay() {
|
public List<Object> getTotalToDisplay() {
|
||||||
// TODO: Implement with LoLib GUI
|
|
||||||
return new ArrayList<>();
|
return new ArrayList<>();
|
||||||
}
|
}
|
||||||
|
|
||||||
public void open(Player player) {
|
public void open(Player player) {
|
||||||
// TODO: Implement with LoLib GUI
|
player.sendMessage("§c" + title + " (" + rows + " рядов, " + items.size() + " пунктов) — меню временно недоступно.");
|
||||||
player.sendMessage("§cМеню временно недоступно во время миграции");
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -9,12 +9,10 @@ import java.util.function.Consumer;
|
||||||
public class SliderItem {
|
public class SliderItem {
|
||||||
|
|
||||||
public SliderItem initial(int value) {
|
public SliderItem initial(int value) {
|
||||||
// TODO: Implement with LoLib GUI
|
|
||||||
return this;
|
return this;
|
||||||
}
|
}
|
||||||
|
|
||||||
public SliderItem add(int index, Item item, Consumer<InventoryClickEvent> handler) {
|
public SliderItem add(int index, Item item, Consumer<InventoryClickEvent> handler) {
|
||||||
// TODO: Implement with LoLib GUI
|
|
||||||
return this;
|
return this;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -1,9 +1,7 @@
|
||||||
package dev.loki.loparkour.world;
|
package dev.loki.loparkour.world;
|
||||||
|
|
||||||
import dev.loki.loparkour.LoParkour;
|
|
||||||
import dev.loki.loparkour.config.Option;
|
import dev.loki.loparkour.config.Option;
|
||||||
import dev.loki.loparkour.session.Session;
|
import dev.loki.loparkour.session.Session;
|
||||||
import dev.loki.loparkour.util.Locations;
|
|
||||||
import org.bukkit.Location;
|
import org.bukkit.Location;
|
||||||
|
|
||||||
import java.util.HashMap;
|
import java.util.HashMap;
|
||||||
|
|
|
||||||
|
|
@ -1,15 +1,12 @@
|
||||||
package dev.loki.loparkour.world;
|
package dev.loki.loparkour.world;
|
||||||
|
|
||||||
import dev.lolib.worlds.VoidGenerator;
|
|
||||||
import org.bukkit.Bukkit;
|
|
||||||
|
|
||||||
import dev.loki.loparkour.LoParkour;
|
import dev.loki.loparkour.LoParkour;
|
||||||
import dev.loki.loparkour.config.Config;
|
import dev.loki.loparkour.config.Config;
|
||||||
|
|
||||||
import dev.lolib.worlds.VoidGenerator;
|
import dev.lolib.worlds.VoidGenerator;
|
||||||
import org.bukkit.*;
|
import org.bukkit.*;
|
||||||
|
|
||||||
import java.io.File;
|
import java.io.File;
|
||||||
|
import java.io.IOException;
|
||||||
import java.nio.file.Files;
|
import java.nio.file.Files;
|
||||||
import java.nio.file.Path;
|
import java.nio.file.Path;
|
||||||
import java.util.Comparator;
|
import java.util.Comparator;
|
||||||
|
|
@ -25,6 +22,11 @@ public class World {
|
||||||
*/
|
*/
|
||||||
public static void create() {
|
public static void create() {
|
||||||
name = Config.CONFIG.getString("world.name");
|
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);
|
var world = Bukkit.getWorld(name);
|
||||||
|
|
||||||
|
|
@ -56,8 +58,9 @@ public class World {
|
||||||
.environment(org.bukkit.World.Environment.NORMAL);
|
.environment(org.bukkit.World.Environment.NORMAL);
|
||||||
|
|
||||||
world = Bukkit.createWorld(creator);
|
world = Bukkit.createWorld(creator);
|
||||||
} catch (Exception ex) {
|
} catch (IllegalArgumentException | IllegalStateException ex) {
|
||||||
LoParkour.getPlugin().getLogger().severe("Error while trying to create the parkour world - delete the parkour world folder and restart the server - " + ex.getMessage());
|
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");
|
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();
|
deleteWorld();
|
||||||
}
|
}
|
||||||
|
|
||||||
private static void 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");
|
LoParkour.log("Deleting Spigot world");
|
||||||
|
|
||||||
File file = new File(name);
|
File folder = worldFolder();
|
||||||
|
if (!folder.exists()) {
|
||||||
// world has already been deleted
|
|
||||||
if (!file.exists()) {
|
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
Bukkit.unloadWorld(name, false);
|
Bukkit.unloadWorld(name, false);
|
||||||
|
|
||||||
try (Stream<Path> files = Files.walk(file.toPath())) {
|
try (Stream<Path> files = Files.walk(folder.toPath())) {
|
||||||
files.sorted(Comparator.reverseOrder())
|
files.sorted(Comparator.reverseOrder())
|
||||||
.map(Path::toFile)
|
.map(Path::toFile)
|
||||||
.forEach(File::delete);
|
.forEach(File::delete);
|
||||||
} catch (Exception ex) {
|
} catch (IOException ex) {
|
||||||
LoParkour.getPlugin().getLogger().severe("Error while trying to delete the parkour world" + " - " + ex.getMessage());
|
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.
|
* @return the name of the parkour world.
|
||||||
*/
|
*/
|
||||||
|
|
|
||||||
|
|
@ -79,6 +79,7 @@ sql:
|
||||||
url: 'localhost'
|
url: 'localhost'
|
||||||
port: 3306
|
port: 3306
|
||||||
username: 'efnilite'
|
username: 'efnilite'
|
||||||
|
# If env LOPARKOUR_SQL_PASSWORD is set (non-empty), it overrides this value (recommended for production).
|
||||||
password: 'super secret password'
|
password: 'super secret password'
|
||||||
database: 'ip'
|
database: 'ip'
|
||||||
|
|
||||||
|
|
@ -94,6 +95,10 @@ world:
|
||||||
# The world in which parkour will take place
|
# The world in which parkour will take place
|
||||||
name: 'loparkour'
|
name: 'loparkour'
|
||||||
|
|
||||||
|
# -= Island schematic =-
|
||||||
|
# Name of the spawn island schematic (schematics-new folder). Falls back to generation.yml if empty.
|
||||||
|
island-schematic: island
|
||||||
|
|
||||||
# -= Delete world on reload =-
|
# -= Delete world on reload =-
|
||||||
# Deletes the world on reload/restart of the server.
|
# Deletes the world on reload/restart of the server.
|
||||||
# This makes sure there are no ghost blocks left when a server crash occurs.
|
# This makes sure there are no ghost blocks left when a server crash occurs.
|
||||||
|
|
@ -217,24 +222,24 @@ styles:
|
||||||
# The list of all available styles, which you can add to if you want.
|
# The list of all available styles, which you can add to if you want.
|
||||||
# For changes, you have to restart or reload your server.
|
# For changes, you have to restart or reload your server.
|
||||||
list:
|
list:
|
||||||
red: [red_wool, red_stained_glass, red_concrete, red_terracotta]
|
red: [red_wool, red_stained_glass, red_concrete, red_terracotta, red_concrete_slab, red_terracotta_slab]
|
||||||
blue: [blue_wool, blue_stained_glass, blue_concrete, blue_terracotta]
|
blue: [blue_wool, blue_stained_glass, blue_concrete, blue_terracotta, blue_concrete_slab, blue_terracotta_slab]
|
||||||
light blue: [light_blue_wool, light_blue_stained_glass, light_blue_concrete, light_blue_terracotta]
|
light blue: [light_blue_wool, light_blue_stained_glass, light_blue_concrete, light_blue_terracotta, light_blue_concrete_slab, light_blue_terracotta_slab]
|
||||||
green: [green_wool, green_stained_glass, green_concrete, green_terracotta]
|
green: [green_wool, green_stained_glass, green_concrete, green_terracotta, green_concrete_slab, green_terracotta_slab]
|
||||||
lime: [lime_wool, lime_stained_glass, lime_concrete, lime_terracotta]
|
lime: [lime_wool, lime_stained_glass, lime_concrete, lime_terracotta, lime_concrete_slab, lime_terracotta_slab]
|
||||||
cyan: [cyan_wool, cyan_stained_glass, cyan_concrete, cyan_terracotta]
|
cyan: [cyan_wool, cyan_stained_glass, cyan_concrete, cyan_terracotta, cyan_concrete_slab, cyan_terracotta_slab]
|
||||||
purple: [purple_wool, purple_stained_glass, purple_concrete, purple_terracotta]
|
purple: [purple_wool, purple_stained_glass, purple_concrete, purple_terracotta, purple_concrete_slab, purple_terracotta_slab]
|
||||||
yellow: [yellow_wool, yellow_stained_glass, yellow_concrete, yellow_terracotta]
|
yellow: [yellow_wool, yellow_stained_glass, yellow_concrete, yellow_terracotta, yellow_concrete_slab, yellow_terracotta_slab]
|
||||||
gray: [gray_wool, gray_stained_glass, gray_concrete, gray_terracotta]
|
gray: [gray_wool, gray_stained_glass, gray_concrete, gray_terracotta, gray_concrete_slab, gray_terracotta_slab]
|
||||||
end: [end_stone, end_stone_bricks, purpur_block, purpur_pillar]
|
end: [end_stone, end_stone_bricks, purpur_block, purpur_pillar, end_stone_brick_slab, purpur_slab]
|
||||||
nether: [netherrack, nether_bricks, red_nether_bricks, chiseled_nether_bricks, nether_wart_block, nether_gold_ore, netherite_block, basalt]
|
nether: [netherrack, nether_bricks, red_nether_bricks, chiseled_nether_bricks, nether_wart_block, nether_gold_ore, netherite_block, basalt, nether_brick_slab, red_nether_brick_slab]
|
||||||
quartz: [smooth_quartz, chiseled_quartz_block, quartz_block, quartz_bricks, quartz_pillar]
|
quartz: [smooth_quartz, chiseled_quartz_block, quartz_block, quartz_bricks, quartz_pillar, smooth_quartz_slab, quartz_slab]
|
||||||
ocean: [prismarine, prismarine_bricks, dark_prismarine, sea_lantern]
|
ocean: [prismarine, prismarine_bricks, dark_prismarine, sea_lantern, prismarine_slab, prismarine_brick_slab, dark_prismarine_slab]
|
||||||
redstone: [observer, dispenser, redstone_block, piston, sticky_piston, redstone_lamp, target]
|
redstone: [observer, dispenser, redstone_block, piston, sticky_piston, redstone_lamp, target, smooth_stone_slab]
|
||||||
overworld: [grass_block, stone, cobblestone, oak_log]
|
overworld: [grass_block, stone, cobblestone, oak_log, stone_slab, cobblestone_slab, oak_slab]
|
||||||
ores: [diamond_ore, gold_ore, iron_ore, coal_ore, nether_gold_ore, lapis_ore, redstone_ore, emerald_ore, nether_quartz_ore]
|
ores: [diamond_ore, gold_ore, iron_ore, coal_ore, nether_gold_ore, lapis_ore, redstone_ore, emerald_ore, nether_quartz_ore, stone_slab]
|
||||||
wood: [oak_planks, spruce_planks, jungle_planks, acacia_planks, birch_planks, dark_oak_planks, oak_log, spruce_log, jungle_log, birch_log, acacia_log, dark_oak_log]
|
wood: [oak_planks, spruce_planks, jungle_planks, acacia_planks, birch_planks, dark_oak_planks, oak_log, spruce_log, jungle_log, birch_log, acacia_log, dark_oak_log, oak_slab, spruce_slab, jungle_slab, acacia_slab, birch_slab, dark_oak_slab]
|
||||||
rainbow: [red_wool, blue_wool, orange_wool, magenta_wool, lime_wool, purple_wool, yellow_concrete, orange_concrete, lime_concrete, light_blue_concrete, yellow_stained_glass, white_stained_glass, magenta_stained_glass, light_blue_stained_glass]
|
rainbow: [red_wool, blue_wool, orange_wool, magenta_wool, lime_wool, purple_wool, yellow_concrete, orange_concrete, lime_concrete, light_blue_concrete, yellow_stained_glass, white_stained_glass, magenta_stained_glass, light_blue_stained_glass, red_concrete_slab, blue_concrete_slab, orange_concrete_slab, lime_concrete_slab]
|
||||||
glow: [glowstone, jack_o_lantern, beacon, sea_lantern, crying_obsidian, shroomlight]
|
glow: [glowstone, jack_o_lantern, beacon, sea_lantern, crying_obsidian, shroomlight]
|
||||||
|
|
||||||
# -= Jump validation options =-
|
# -= Jump validation options =-
|
||||||
|
|
@ -291,12 +296,15 @@ jump-types:
|
||||||
memory:
|
memory:
|
||||||
|
|
||||||
# -= Block cleanup distance =-
|
# -= Block cleanup distance =-
|
||||||
# Distance in blocks after which old parkour blocks are removed.
|
# Span (in history indices) for schematic trail removal and similar bulk clears.
|
||||||
# Lower values save memory but might remove blocks too early.
|
|
||||||
block-cleanup-distance: 20
|
block-cleanup-distance: 20
|
||||||
|
|
||||||
|
# -= Trail keep-behind =-
|
||||||
|
# How many course blocks to keep behind the rearmost standing player before removing older trail (index-based; not world distance).
|
||||||
|
trail-keep-behind: 10
|
||||||
|
|
||||||
# -= Cleanup interval =-
|
# -= Cleanup interval =-
|
||||||
# How often (in ticks) to check for blocks to clean up.
|
# How often (in ticks) to run trail cleanup behind players.
|
||||||
# 20 ticks = 1 second
|
# 20 ticks = 1 second
|
||||||
cleanup-interval: 100
|
cleanup-interval: 100
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -62,21 +62,16 @@ generation:
|
||||||
# Don't modify these if you don't know what you're doing!
|
# Don't modify these if you don't know what you're doing!
|
||||||
advanced:
|
advanced:
|
||||||
|
|
||||||
# -= Spawn island options =-
|
# -= Spawn platform =-
|
||||||
# Options for the spawn island
|
# Start area is a flat square platform at the session cell (no .lpschem island).
|
||||||
island:
|
island:
|
||||||
|
|
||||||
# -= Island schematic =-
|
# Legacy key (unused for spawn). Course schematics use schematics.yml / schematics-new.
|
||||||
# The name of the schematic file to use for the spawn island
|
|
||||||
# This schematic should be placed in the schematics-new folder
|
|
||||||
# If not specified or not found, no island will be generated
|
|
||||||
schematic-name: "island"
|
schematic-name: "island"
|
||||||
|
|
||||||
spawn:
|
spawn:
|
||||||
|
|
||||||
# -= Player block =-
|
# Legacy keys (unused). Platform material is parkour.first-block-material below.
|
||||||
# The material of the block that the player will spawn on.
|
|
||||||
# This block will be set to air once the schematic has been pasted.
|
|
||||||
player-block: diamond_block
|
player-block: diamond_block
|
||||||
|
|
||||||
# -= Spawn yaw/pitch =-
|
# -= Spawn yaw/pitch =-
|
||||||
|
|
@ -85,13 +80,11 @@ advanced:
|
||||||
|
|
||||||
parkour:
|
parkour:
|
||||||
|
|
||||||
# -= Begin block =-
|
# Legacy (unused). begin-block was for island schematics.
|
||||||
# The block from which the parkour begins
|
|
||||||
# This block will be set to air once the schematic has been pasted
|
|
||||||
begin-block: emerald_block
|
begin-block: emerald_block
|
||||||
|
|
||||||
# -= First parkour block material =-
|
# -= Platform + first jump pad material =-
|
||||||
# The material of the very first parkour block that players spawn on
|
# The whole spawn platform uses this block type.
|
||||||
first-block-material: stone
|
first-block-material: stone
|
||||||
|
|
||||||
# -= Directional heading =-
|
# -= Directional heading =-
|
||||||
|
|
@ -99,6 +92,47 @@ advanced:
|
||||||
# Possible options: north, south, west and east.
|
# Possible options: north, south, west and east.
|
||||||
heading: east
|
heading: east
|
||||||
|
|
||||||
|
# -= Jump distance weights (advanced.distance.min .. max) =-
|
||||||
|
distance:
|
||||||
|
min: 1
|
||||||
|
max: 4
|
||||||
|
chances:
|
||||||
|
1: 25
|
||||||
|
2: 25
|
||||||
|
3: 25
|
||||||
|
4: 25
|
||||||
|
|
||||||
|
# -= Vertical step weights (advanced.height.min .. max) =-
|
||||||
|
height:
|
||||||
|
min: -1
|
||||||
|
max: 2
|
||||||
|
chances:
|
||||||
|
"-1": 20
|
||||||
|
"0": 30
|
||||||
|
1: 30
|
||||||
|
2: 20
|
||||||
|
|
||||||
|
# -= Block type mix (default vs special vs schematic) =-
|
||||||
|
type:
|
||||||
|
default: 80
|
||||||
|
special: 15
|
||||||
|
schematic: 5
|
||||||
|
|
||||||
|
# -= Special block variants (BlockData strings) =-
|
||||||
|
special:
|
||||||
|
ice:
|
||||||
|
material: minecraft:ice
|
||||||
|
chance: 25
|
||||||
|
slab:
|
||||||
|
material: minecraft:oak_slab
|
||||||
|
chance: 20
|
||||||
|
pane:
|
||||||
|
material: minecraft:glass_pane
|
||||||
|
chance: 15
|
||||||
|
fence:
|
||||||
|
material: minecraft:oak_fence
|
||||||
|
chance: 15
|
||||||
|
|
||||||
# -= Border size =-
|
# -= Border size =-
|
||||||
# The diameter of the playing area of a person in the single mode.
|
# The diameter of the playing area of a person in the single mode.
|
||||||
border-size: 10000
|
border-size: 10000
|
||||||
|
|
|
||||||
|
|
@ -8,7 +8,6 @@ import org.mockito.Mock;
|
||||||
import org.mockito.MockitoAnnotations;
|
import org.mockito.MockitoAnnotations;
|
||||||
|
|
||||||
import static org.junit.jupiter.api.Assertions.*;
|
import static org.junit.jupiter.api.Assertions.*;
|
||||||
import static org.mockito.Mockito.*;
|
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Unit tests for JumpValidator.
|
* Unit tests for JumpValidator.
|
||||||
|
|
@ -105,8 +104,8 @@ class JumpValidatorTest {
|
||||||
Location from = new Location(world, 0, 100, 0);
|
Location from = new Location(world, 0, 100, 0);
|
||||||
Location to = new Location(world, 3, 100, 2);
|
Location to = new Location(world, 3, 100, 2);
|
||||||
|
|
||||||
double distance = Math.sqrt(3*3 + 2*2); // ~3.6 blocks
|
assertTrue(validator.canJump(from, to),
|
||||||
assertTrue(validator.canJump(from, to), "Diagonal jump of ~3.6 blocks should be possible");
|
"Diagonal jump of ~" + Math.sqrt(3 * 3 + 2 * 2) + " blocks should be possible");
|
||||||
}
|
}
|
||||||
|
|
||||||
@Test
|
@Test
|
||||||
|
|
|
||||||
25
src/test/java/dev/loki/loparkour/style/RandomStyleTest.java
Normal file
25
src/test/java/dev/loki/loparkour/style/RandomStyleTest.java
Normal file
|
|
@ -0,0 +1,25 @@
|
||||||
|
package dev.loki.loparkour.style;
|
||||||
|
|
||||||
|
import org.bukkit.Material;
|
||||||
|
import org.junit.jupiter.api.Test;
|
||||||
|
|
||||||
|
import java.util.Collections;
|
||||||
|
import java.util.List;
|
||||||
|
|
||||||
|
import static org.junit.jupiter.api.Assertions.assertEquals;
|
||||||
|
import static org.junit.jupiter.api.Assertions.assertSame;
|
||||||
|
|
||||||
|
class RandomStyleTest {
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void emptyMaterialListFallsBackToStone() {
|
||||||
|
RandomStyle style = new RandomStyle("test", Collections.emptyList());
|
||||||
|
assertEquals(Material.STONE, style.getNext());
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void copiesListAndReturnsOnlyDeclaredMaterials() {
|
||||||
|
RandomStyle style = new RandomStyle("x", List.of(Material.OAK_PLANKS));
|
||||||
|
assertSame(Material.OAK_PLANKS, style.getNext());
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,30 @@
|
||||||
|
package dev.loki.loparkour.world;
|
||||||
|
|
||||||
|
import org.junit.jupiter.api.Test;
|
||||||
|
|
||||||
|
import static org.junit.jupiter.api.Assertions.assertFalse;
|
||||||
|
import static org.junit.jupiter.api.Assertions.assertTrue;
|
||||||
|
|
||||||
|
class WorldNameValidationTest {
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void allowsSafeFolderNames() {
|
||||||
|
assertTrue(World.isValidWorldFolderName("parkour"));
|
||||||
|
assertTrue(World.isValidWorldFolderName("lp_world"));
|
||||||
|
assertTrue(World.isValidWorldFolderName("a1"));
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void rejectsPathTraversalAndSeparators() {
|
||||||
|
assertFalse(World.isValidWorldFolderName("../etc"));
|
||||||
|
assertFalse(World.isValidWorldFolderName("a/b"));
|
||||||
|
assertFalse(World.isValidWorldFolderName("a\\b"));
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void rejectsBlankOrTooLong() {
|
||||||
|
assertFalse(World.isValidWorldFolderName(""));
|
||||||
|
assertFalse(World.isValidWorldFolderName(" "));
|
||||||
|
assertFalse(World.isValidWorldFolderName("x".repeat(65)));
|
||||||
|
}
|
||||||
|
}
|
||||||
Loading…
Add table
Add a link
Reference in a new issue