clear
This commit is contained in:
parent
e4a476e3be
commit
7dd26408f9
20 changed files with 0 additions and 4469 deletions
|
|
@ -1,441 +0,0 @@
|
|||
# GravityShift и Hardcore Modes Implementation Plan
|
||||
|
||||
> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking.
|
||||
|
||||
**Goal:** Реализовать GravityShiftMode (случайные эффекты зелий) и HardcoreMode (сброс наград при падении)
|
||||
|
||||
**Architecture:** Оба режима следуют паттерну Mode + внутренний Generator класс. GravityShiftGenerator переопределяет score() для применения эффектов, HardcoreGenerator переопределяет fall() для сброса наград.
|
||||
|
||||
**Tech Stack:** Bukkit/Spigot API, Java 21, существующая архитектура Mode/ParkourGenerator
|
||||
|
||||
---
|
||||
|
||||
## Task 1: Implement GravityShiftMode
|
||||
|
||||
**Files:**
|
||||
- Create: `src/main/java/dev/loki/loparkour/mode/GravityShiftMode.java`
|
||||
|
||||
- [ ] **Step 1: Create GravityShiftMode with inner Generator**
|
||||
|
||||
```java
|
||||
package dev.loki.loparkour.mode;
|
||||
|
||||
import dev.loki.loparkour.config.Config;
|
||||
import dev.loki.loparkour.config.Locales;
|
||||
import dev.loki.loparkour.generator.ParkourGenerator;
|
||||
import dev.loki.loparkour.leaderboard.Leaderboard;
|
||||
import dev.loki.loparkour.player.ParkourPlayer;
|
||||
import dev.loki.loparkour.session.Session;
|
||||
import dev.loki.loparkour.util.Item;
|
||||
import org.bukkit.entity.Player;
|
||||
import org.bukkit.potion.PotionEffect;
|
||||
import org.bukkit.potion.PotionEffectType;
|
||||
import org.jetbrains.annotations.NotNull;
|
||||
import org.jetbrains.annotations.Nullable;
|
||||
|
||||
import java.util.*;
|
||||
|
||||
/**
|
||||
* Gravity Shift Mode — random potion effects applied every N jumps.
|
||||
*/
|
||||
public class GravityShiftMode implements Mode {
|
||||
|
||||
private final Leaderboard leaderboard = new Leaderboard(getName(), Leaderboard.Sort.SCORE);
|
||||
|
||||
@Override
|
||||
@NotNull
|
||||
public String getName() {
|
||||
return "gravity-shift";
|
||||
}
|
||||
|
||||
@Override
|
||||
@Nullable
|
||||
public Item getItem(String locale) {
|
||||
return Locales.getItem(locale, "modes.gravity-shift");
|
||||
}
|
||||
|
||||
@Override
|
||||
@NotNull
|
||||
public Leaderboard getLeaderboard() {
|
||||
return leaderboard;
|
||||
}
|
||||
|
||||
@Override
|
||||
public void create(Player player) {
|
||||
if (!Config.CONFIG.getBoolean("joining")) {
|
||||
player.sendMessage("§cJoining is currently disabled.");
|
||||
return;
|
||||
}
|
||||
|
||||
ParkourPlayer pp = ParkourPlayer.getPlayer(player);
|
||||
if (pp != null && pp.session.generator instanceof GravityShiftGenerator) {
|
||||
return;
|
||||
}
|
||||
|
||||
player.closeInventory();
|
||||
Session.create(session -> new GravityShiftGenerator(session), null, null, player);
|
||||
}
|
||||
|
||||
// ── GravityShiftGenerator ─────────────────────────────────────────────────
|
||||
|
||||
private static class GravityShiftGenerator extends ParkourGenerator {
|
||||
|
||||
private final int interval;
|
||||
private final Map<PotionEffectType, EffectConfig> effects;
|
||||
private final Random random;
|
||||
private final Map<UUID, Integer> jumpCounts;
|
||||
|
||||
public GravityShiftGenerator(@NotNull Session session) {
|
||||
super(session);
|
||||
this.random = new Random();
|
||||
this.jumpCounts = new HashMap<>();
|
||||
|
||||
// Load config
|
||||
this.interval = Config.CONFIG.isPath("modes.gravity-shift.interval")
|
||||
? Config.CONFIG.getInt("modes.gravity-shift.interval") : 10;
|
||||
|
||||
this.effects = new HashMap<>();
|
||||
loadEffect("jump-boost", PotionEffectType.JUMP);
|
||||
loadEffect("speed", PotionEffectType.SPEED);
|
||||
loadEffect("slowness", PotionEffectType.SLOW);
|
||||
loadEffect("levitation", PotionEffectType.LEVITATION);
|
||||
|
||||
// Initialize jump counts for all players
|
||||
for (ParkourPlayer pp : getPlayers()) {
|
||||
jumpCounts.put(pp.getUUID(), 0);
|
||||
}
|
||||
}
|
||||
|
||||
private void loadEffect(String key, PotionEffectType type) {
|
||||
String basePath = "modes.gravity-shift.effects." + key;
|
||||
boolean enabled = Config.CONFIG.isPath(basePath + ".enabled")
|
||||
? Config.CONFIG.getBoolean(basePath + ".enabled") : false;
|
||||
|
||||
if (!enabled) {
|
||||
return;
|
||||
}
|
||||
|
||||
int amplifier = Config.CONFIG.isPath(basePath + ".amplifier")
|
||||
? Config.CONFIG.getInt(basePath + ".amplifier") : 1;
|
||||
int duration = Config.CONFIG.isPath(basePath + ".duration")
|
||||
? Config.CONFIG.getInt(basePath + ".duration") : 30;
|
||||
|
||||
effects.put(type, new EffectConfig(amplifier, duration * 20));
|
||||
}
|
||||
|
||||
@Override
|
||||
protected void score() {
|
||||
super.score();
|
||||
|
||||
// Apply effects to all players
|
||||
for (ParkourPlayer pp : getPlayers()) {
|
||||
UUID uuid = pp.getUUID();
|
||||
int count = jumpCounts.getOrDefault(uuid, 0) + 1;
|
||||
jumpCounts.put(uuid, count);
|
||||
|
||||
if (count % interval == 0 && !effects.isEmpty()) {
|
||||
applyRandomEffect(pp.getPlayer());
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private void applyRandomEffect(Player player) {
|
||||
if (player == null || effects.isEmpty()) {
|
||||
return;
|
||||
}
|
||||
|
||||
List<PotionEffectType> types = new ArrayList<>(effects.keySet());
|
||||
PotionEffectType selected = types.get(random.nextInt(types.size()));
|
||||
EffectConfig config = effects.get(selected);
|
||||
|
||||
player.removePotionEffect(selected);
|
||||
player.addPotionEffect(new PotionEffect(
|
||||
selected,
|
||||
config.durationTicks,
|
||||
config.amplifier,
|
||||
false,
|
||||
true,
|
||||
true
|
||||
));
|
||||
}
|
||||
|
||||
@Override
|
||||
public void reset(boolean regenerate) {
|
||||
// Clear effects and reset counts
|
||||
for (ParkourPlayer pp : getPlayers()) {
|
||||
Player player = pp.getPlayer();
|
||||
if (player != null) {
|
||||
for (PotionEffectType type : effects.keySet()) {
|
||||
player.removePotionEffect(type);
|
||||
}
|
||||
}
|
||||
jumpCounts.put(pp.getUUID(), 0);
|
||||
}
|
||||
|
||||
super.reset(regenerate);
|
||||
}
|
||||
|
||||
@Override
|
||||
public Mode getMode() {
|
||||
return Modes.GRAVITY_SHIFT;
|
||||
}
|
||||
|
||||
private record EffectConfig(int amplifier, int durationTicks) {}
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
- [ ] **Step 2: Commit GravityShiftMode**
|
||||
|
||||
```bash
|
||||
git add src/main/java/dev/loki/loparkour/mode/GravityShiftMode.java
|
||||
git commit -m "feat: implement GravityShiftMode with random potion effects"
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Task 2: Implement HardcoreMode
|
||||
|
||||
**Files:**
|
||||
- Create: `src/main/java/dev/loki/loparkour/mode/HardcoreMode.java`
|
||||
|
||||
- [ ] **Step 1: Create HardcoreMode with inner Generator**
|
||||
|
||||
```java
|
||||
package dev.loki.loparkour.mode;
|
||||
|
||||
import dev.loki.loparkour.config.Config;
|
||||
import dev.loki.loparkour.config.Locales;
|
||||
import dev.loki.loparkour.generator.ParkourGenerator;
|
||||
import dev.loki.loparkour.leaderboard.Leaderboard;
|
||||
import dev.loki.loparkour.player.ParkourPlayer;
|
||||
import dev.loki.loparkour.session.Session;
|
||||
import dev.loki.loparkour.util.Item;
|
||||
import org.bukkit.entity.Player;
|
||||
import org.jetbrains.annotations.NotNull;
|
||||
import org.jetbrains.annotations.Nullable;
|
||||
|
||||
/**
|
||||
* Hardcore Mode — lose all collected rewards on fall.
|
||||
*/
|
||||
public class HardcoreMode implements Mode {
|
||||
|
||||
private final Leaderboard leaderboard = new Leaderboard(getName(), Leaderboard.Sort.SCORE);
|
||||
|
||||
@Override
|
||||
@NotNull
|
||||
public String getName() {
|
||||
return "hardcore";
|
||||
}
|
||||
|
||||
@Override
|
||||
@Nullable
|
||||
public Item getItem(String locale) {
|
||||
return Locales.getItem(locale, "modes.hardcore");
|
||||
}
|
||||
|
||||
@Override
|
||||
@NotNull
|
||||
public Leaderboard getLeaderboard() {
|
||||
return leaderboard;
|
||||
}
|
||||
|
||||
@Override
|
||||
public void create(Player player) {
|
||||
if (!Config.CONFIG.getBoolean("joining")) {
|
||||
player.sendMessage("§cJoining is currently disabled.");
|
||||
return;
|
||||
}
|
||||
|
||||
ParkourPlayer pp = ParkourPlayer.getPlayer(player);
|
||||
if (pp != null && pp.session.generator instanceof HardcoreGenerator) {
|
||||
return;
|
||||
}
|
||||
|
||||
player.closeInventory();
|
||||
Session.create(session -> new HardcoreGenerator(session), null, null, player);
|
||||
}
|
||||
|
||||
// ── HardcoreGenerator ─────────────────────────────────────────────────────
|
||||
|
||||
private static class HardcoreGenerator extends ParkourGenerator {
|
||||
|
||||
private final boolean resetRewards;
|
||||
|
||||
public HardcoreGenerator(@NotNull Session session) {
|
||||
super(session);
|
||||
this.resetRewards = Config.CONFIG.isPath("modes.hardcore.reset-rewards")
|
||||
? Config.CONFIG.getBoolean("modes.hardcore.reset-rewards") : true;
|
||||
}
|
||||
|
||||
@Override
|
||||
public void fall() {
|
||||
if (resetRewards) {
|
||||
for (ParkourPlayer pp : getPlayers()) {
|
||||
pp.collectedRewards.clear();
|
||||
}
|
||||
}
|
||||
|
||||
super.fall();
|
||||
}
|
||||
|
||||
@Override
|
||||
public Mode getMode() {
|
||||
return Modes.HARDCORE;
|
||||
}
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
- [ ] **Step 2: Commit HardcoreMode**
|
||||
|
||||
```bash
|
||||
git add src/main/java/dev/loki/loparkour/mode/HardcoreMode.java
|
||||
git commit -m "feat: implement HardcoreMode with reward reset on fall"
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Task 3: Register modes in Modes.java
|
||||
|
||||
**Files:**
|
||||
- Modify: `src/main/java/dev/loki/loparkour/mode/Modes.java`
|
||||
|
||||
- [ ] **Step 1: Read Modes.java to find registration pattern**
|
||||
|
||||
```bash
|
||||
cat src/main/java/dev/loki/loparkour/mode/Modes.java
|
||||
```
|
||||
|
||||
- [ ] **Step 2: Add static fields for new modes**
|
||||
|
||||
Add after existing mode declarations (e.g., after `SPEEDRUN`, `ELYTRA`):
|
||||
|
||||
```java
|
||||
public static GravityShiftMode GRAVITY_SHIFT;
|
||||
public static HardcoreMode HARDCORE;
|
||||
```
|
||||
|
||||
- [ ] **Step 3: Register modes in init() method**
|
||||
|
||||
Add in `init()` method after existing registrations:
|
||||
|
||||
```java
|
||||
GRAVITY_SHIFT = register(new GravityShiftMode());
|
||||
HARDCORE = register(new HardcoreMode());
|
||||
```
|
||||
|
||||
- [ ] **Step 4: Commit registration**
|
||||
|
||||
```bash
|
||||
git add src/main/java/dev/loki/loparkour/mode/Modes.java
|
||||
git commit -m "feat: register GravityShift and Hardcore modes"
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Task 4: Add locale entries
|
||||
|
||||
**Files:**
|
||||
- Modify: `src/main/resources/lang/en.yml`
|
||||
- Modify: `src/main/resources/lang/ru.yml`
|
||||
|
||||
- [ ] **Step 1: Add English locale entries**
|
||||
|
||||
Add to `en.yml` under `modes:` section:
|
||||
|
||||
```yaml
|
||||
gravity-shift:
|
||||
item: POTION
|
||||
name: 'Gravity Shift'
|
||||
description: 'Random potion effects every few jumps'
|
||||
hardcore:
|
||||
item: SKELETON_SKULL
|
||||
name: 'Hardcore'
|
||||
description: 'Lose all rewards on fall'
|
||||
```
|
||||
|
||||
- [ ] **Step 2: Add Russian locale entries**
|
||||
|
||||
Add to `ru.yml` under `modes:` section:
|
||||
|
||||
```yaml
|
||||
gravity-shift:
|
||||
item: POTION
|
||||
name: 'Гравитационный сдвиг'
|
||||
description: 'Случайные эффекты зелий каждые несколько прыжков'
|
||||
hardcore:
|
||||
item: SKELETON_SKULL
|
||||
name: 'Хардкор'
|
||||
description: 'Потеря всех наград при падении'
|
||||
```
|
||||
|
||||
- [ ] **Step 3: Commit locale entries**
|
||||
|
||||
```bash
|
||||
git add src/main/resources/lang/en.yml src/main/resources/lang/ru.yml
|
||||
git commit -m "feat: add locale entries for GravityShift and Hardcore modes"
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Task 5: Test both modes
|
||||
|
||||
**Files:**
|
||||
- None (manual testing)
|
||||
|
||||
- [ ] **Step 1: Build plugin**
|
||||
|
||||
```bash
|
||||
./gradlew clean build
|
||||
```
|
||||
|
||||
Expected: BUILD SUCCESSFUL
|
||||
|
||||
- [ ] **Step 2: Copy to server**
|
||||
|
||||
```bash
|
||||
cp build/libs/LoParkour-*.jar /path/to/server/plugins/
|
||||
```
|
||||
|
||||
- [ ] **Step 3: Start server and test GravityShift**
|
||||
|
||||
1. Start server
|
||||
2. Join parkour with `/lp`
|
||||
3. Switch to gravity-shift mode
|
||||
4. Make 10+ jumps
|
||||
5. Verify random potion effects appear
|
||||
|
||||
Expected: Effects like Jump Boost, Speed, Slowness, Levitation apply randomly every 10 jumps
|
||||
|
||||
- [ ] **Step 4: Test Hardcore mode**
|
||||
|
||||
1. Switch to hardcore mode
|
||||
2. Collect some rewards (if available)
|
||||
3. Fall off parkour
|
||||
4. Check if rewards were cleared
|
||||
|
||||
Expected: `player.collectedRewards` should be empty after fall
|
||||
|
||||
- [ ] **Step 5: Verify config loading**
|
||||
|
||||
Check server logs for any config errors related to:
|
||||
- `modes.gravity-shift.interval`
|
||||
- `modes.gravity-shift.effects.*`
|
||||
- `modes.hardcore.reset-rewards`
|
||||
|
||||
Expected: No errors, default values used if config missing
|
||||
|
||||
---
|
||||
|
||||
## Execution Handoff
|
||||
|
||||
Plan complete and saved to `docs/superpowers/plans/2026-04-09-gravity-shift-hardcore-modes.md`.
|
||||
|
||||
**Two execution options:**
|
||||
|
||||
**1. Subagent-Driven (recommended)** - Fresh subagent per task, review between tasks, fast iteration
|
||||
|
||||
**2. Inline Execution** - Execute tasks in this session using executing-plans, batch execution with checkpoints
|
||||
|
||||
**Which approach?**
|
||||
230
release/GUIDE.md
230
release/GUIDE.md
|
|
@ -1,230 +0,0 @@
|
|||
# LoParkour — Гайд по патчам
|
||||
|
||||
Все файлы в этом архиве заменяют оригинальные файлы проекта **1 к 1 по пути**.
|
||||
Просто скопируй их поверх своего src/ и пересобери проект.
|
||||
|
||||
---
|
||||
|
||||
## Что уже исправлено (этот архив)
|
||||
|
||||
### 🔴 Патч 1 — SQL Injection (КРИТИЧНО)
|
||||
**Файлы:** `storage/StorageSQL.java`, `storage/SQLQueryExecutor.java`, `storage/SQLMigrationManager.java`
|
||||
|
||||
Оригинальный код вставлял имена игроков, стили, локали напрямую в SQL строки через `.formatted()`.
|
||||
Игрок с ником `'; DROP TABLE loparkour-leaderboard-default; --` мог уничтожить таблицу лидерборда.
|
||||
|
||||
**Что сделано:** Все запросы с пользовательскими данными переписаны на `?` плейсхолдеры.
|
||||
JDBC-драйвер сам экранирует значения — инъекция физически невозможна.
|
||||
|
||||
---
|
||||
|
||||
### 🔴 Патч 2 — ElytraMode полная переработка
|
||||
**Файлы:** `mode/ElytraMode.java`, `mode/ElytraGenerator.java`, `resources/config.yml`, `resources/locales/en.yml`
|
||||
|
||||
Оригинальный ElytraMode был просто обычным паркуром с элитрой в инвентаре.
|
||||
Кольца лежали горизонтально (как блины), хитбокс был кубом 5×5×5,
|
||||
cooldown фейерверка не работал (показывал сообщение но не блокировал).
|
||||
|
||||
**Что сделано:**
|
||||
- Генерируются кольца в воздухе по плавной кривой (настраивается в config.yml)
|
||||
- Кольца стоят вертикально, перпендикулярно направлению полёта
|
||||
- Детект пролёта через плоскость кольца (dot product, а не куб)
|
||||
- Firework перехватывается через `PlayerInteractEvent` — реально блокируется до использования
|
||||
- Boost выдаётся вручную через `setVelocity()` + визуальный фейерверк без урона
|
||||
- Action bar: счёт + дистанция до следующего кольца + cooldown
|
||||
|
||||
---
|
||||
|
||||
### 🟠 Патч 3 — SpeedrunMode не работал вообще
|
||||
**Файл:** `mode/SpeedrunMode.java`
|
||||
|
||||
`onBlockTouch()` был объявлен но **нигде не вызывался** — блоки никогда не исчезали.
|
||||
Speedrun был идентичен обычному режиму.
|
||||
|
||||
**Что сделано:** Подключился через `score()` override — вызывается в момент когда
|
||||
игрок встаёт на новый блок. Исправлено деление на 50 которое давало 0 тиков при
|
||||
малых значениях. Warning task теперь отменяется при reset().
|
||||
|
||||
---
|
||||
|
||||
### 🟠 Патч 4 — CoopMode показывался в одиночном меню
|
||||
**Файл:** `mode/CoopMode.java`
|
||||
|
||||
`CoopMode implements Mode` вместо `MultiMode` — поэтому висел в меню рядом с Default/Speedrun.
|
||||
`SingleMenu` фильтрует `instanceof MultiMode`, но кооп им не был.
|
||||
|
||||
**Что сделано:** `CoopMode implements MultiMode`. `getItem()` возвращает `null`.
|
||||
`create()` открывает лобби вместо создания сессии соло.
|
||||
Исправлен двойной счётчик (`sharedScore` дублировал `state.score`).
|
||||
`fall()` больше не чистит стейт который уже сброшен внутри `super.fall()`.
|
||||
|
||||
---
|
||||
|
||||
### 🟠 Патч 5 — RaceMode off-by-one + ActionBar конфликт
|
||||
**Файл:** `mode/RaceMode.java`
|
||||
|
||||
Проверка финиша была в `tick()` — срабатывала на тик позже нужного.
|
||||
ActionBar обновлялся каждый тик конфликтуя с parent.
|
||||
`reset(false)` вызывался с задержкой без null-check — NPE если игрок вышел.
|
||||
|
||||
**Что сделано:** Финиш проверяется в `score()` override — точно в момент достижения цели.
|
||||
Прогресс-бар обновляется только при новом очке. Добавлен guard `!state.stopped`.
|
||||
|
||||
---
|
||||
|
||||
## Что ещё нужно исправить (следующие шаги)
|
||||
|
||||
### 🔴 Приоритет 1 — Ghost файлы по имени игрока (path traversal + коллизии)
|
||||
**Файл:** `ghost/GhostManager.java`
|
||||
|
||||
```java
|
||||
// ПРОБЛЕМА:
|
||||
File file = new File(modeFolder, data.getPlayerName() + ".ghost");
|
||||
```
|
||||
|
||||
Два игрока с одинаковым именем затирают друг друга.
|
||||
Игрок с именем `../../config` может писать за пределы папки плагина.
|
||||
|
||||
**Как чинить:** Называть файлы по UUID игрока, не по имени.
|
||||
Хранить имя внутри файла (оно уже там есть в `GhostData`).
|
||||
|
||||
---
|
||||
|
||||
### 🟡 Приоритет 2 — Ghost не пишет всех игроков в мультиплеере
|
||||
**Файл:** `generator/lifecycle/GeneratorLifecycle.java`
|
||||
|
||||
`GhostRecorder` записывает только `generator.player` (первый игрок сессии).
|
||||
В CoopMode и RaceMode остальные игроки не записываются.
|
||||
|
||||
**Как чинить:** Recorder на каждого игрока отдельно, или писать только в single-player режимах.
|
||||
|
||||
---
|
||||
|
||||
### 🟡 Приоритет 3 — `disable()` глотает все исключения
|
||||
**Файл:** `LoParkour.java`
|
||||
|
||||
```java
|
||||
} catch (Throwable ignored) { }
|
||||
```
|
||||
|
||||
Если при выключении сервера не сохранятся данные — никто не узнает.
|
||||
|
||||
**Как чинить:** Минимум логировать: `catch (Throwable t) { getLogger().severe(...) }`.
|
||||
|
||||
---
|
||||
|
||||
### 🟡 Приоритет 4 — `cleanupDistantBlocks()` вызывается дважды за тик
|
||||
**Файл:** `generator/lifecycle/GeneratorLifecycle.java`
|
||||
|
||||
Вызывается явно в `tick()` И через отдельный `cleanupTask` таймер одновременно.
|
||||
|
||||
**Как чинить:** Убрать явный вызов из `tick()`, оставить только таймер.
|
||||
|
||||
---
|
||||
|
||||
### 🟡 Приоритет 5 — `new Random()` в горячих методах
|
||||
**Файл:** `generator/jump/BlockPlacer.java`
|
||||
|
||||
```java
|
||||
pool.get(new java.util.Random().nextInt(pool.size())) // внутри tryGenerateSchematic()
|
||||
new java.util.Random().nextInt(rotations.length) // внутри rotatedPaste()
|
||||
```
|
||||
|
||||
Каждый вызов создаёт новый объект Random. Дорого и неправильно.
|
||||
|
||||
**Как чинить:** Один `private static final ThreadLocalRandom RNG = ThreadLocalRandom.current()`
|
||||
на уровне класса.
|
||||
|
||||
---
|
||||
|
||||
### 🟡 Приоритет 6 — `System.err.println` в JumpOffsetGenerator
|
||||
**Файл:** `generator/jump/JumpOffsetGenerator.java`
|
||||
|
||||
```java
|
||||
System.err.println("Warning: Unexpected jump height ...");
|
||||
```
|
||||
|
||||
В Bukkit плагинах нельзя писать в System.err напрямую.
|
||||
|
||||
**Как чинить:** `LoParkour.getPlugin().getLogger().warning(...)`.
|
||||
|
||||
---
|
||||
|
||||
### 🟢 Приоритет 7 — Пустой javadoc в LoParkour.java
|
||||
**Файл:** `LoParkour.java`
|
||||
|
||||
Висит осиротевший javadoc от удалённого метода `getLogging()`. Не баг, просто мусор.
|
||||
|
||||
---
|
||||
|
||||
## Порядок следующих патчей (рекомендация)
|
||||
|
||||
1. Ghost path traversal (безопасность)
|
||||
2. `disable()` silent catch (надёжность)
|
||||
3. `cleanupDistantBlocks` двойной вызов (производительность)
|
||||
4. `new Random()` в горячих путях (производительность)
|
||||
5. Ghost мультиплеер (функциональность)
|
||||
|
||||
---
|
||||
|
||||
## Патчи добавленные в эту версию архива
|
||||
|
||||
### 🔴 Патч 6 — Ghost path traversal + коллизии по имени (БЕЗОПАСНОСТЬ)
|
||||
**Файлы:** `ghost/GhostData.java`, `ghost/GhostManager.java`, `ghost/GhostRecorder.java`, `generator/lifecycle/GeneratorLifecycle.java`
|
||||
|
||||
Файлы назывались `playerName.ghost`. Два игрока с одинаковым ником затирали друг друга.
|
||||
Игрок с именем `../../config` мог писать файлы за пределы папки плагина (path traversal).
|
||||
|
||||
**Что сделано:**
|
||||
- Файлы теперь называются `uuid.ghost` — уникально и filesystem-safe
|
||||
- `GhostData` хранит UUID (добавлен в конструктор и в бинарный формат)
|
||||
- `GhostRecorder.stopRecording()` принимает UUID + имя
|
||||
- `GhostManager.sanitizeMode()` чистит имя режима от слэшей
|
||||
- Старые файлы v1 (без UUID) автоматически удаляются при загрузке с логом
|
||||
- Формат файла версионирован (int VERSION=2 в начале)
|
||||
|
||||
---
|
||||
|
||||
### 🟡 Патч 7 — `cleanupDistantBlocks()` вызывался дважды за тик
|
||||
**Файл:** `generator/lifecycle/GeneratorLifecycle.java`
|
||||
|
||||
Был явный вызов в `tick()` И таймер `cleanupTask` — двойная работа каждый тик.
|
||||
|
||||
**Что сделано:** Убран явный вызов из `tick()`. Только таймер.
|
||||
|
||||
---
|
||||
|
||||
### 🟡 Патч 8 — `new Random()` в горячих методах BlockPlacer
|
||||
**Файл:** `generator/jump/BlockPlacer.java`
|
||||
|
||||
`new java.util.Random()` создавался внутри `tryGenerateSchematic()` и `rotatedPaste()` при каждом вызове.
|
||||
|
||||
**Что сделано:** Заменено на `ThreadLocalRandom.current().nextInt()`.
|
||||
|
||||
---
|
||||
|
||||
### 🟡 Патч 9 — `System.err.println` в JumpOffsetGenerator
|
||||
**Файл:** `generator/jump/JumpOffsetGenerator.java`
|
||||
|
||||
**Что сделано:** Заменено на `LoParkour.getPlugin().getLogger().warning(...)`.
|
||||
|
||||
---
|
||||
|
||||
### 🟡 Патч 10 — `disable()` глотал все исключения молча
|
||||
**Файл:** `LoParkour.java`
|
||||
|
||||
`catch (Throwable ignored) {}` — если данные не сохранялись при выключении сервера, никто не узнавал.
|
||||
|
||||
**Что сделано:** `catch (Throwable t)` с логированием и `t.printStackTrace()`.
|
||||
|
||||
---
|
||||
|
||||
## Оставшиеся задачи
|
||||
|
||||
### 🟡 Ghost мультиплеер
|
||||
`GhostRecorder` пишет только `generator.player` (первый игрок сессии).
|
||||
В CoopMode/RaceMode остальные игроки не записываются.
|
||||
**Решение:** Либо отдельный рекордер на каждого, либо запись только в single-player режимах.
|
||||
|
||||
### 🟢 Осиротевший javadoc в LoParkour.java
|
||||
Комментарий от удалённого метода `getLogging()` — просто мусор, не баг.
|
||||
|
|
@ -1,269 +0,0 @@
|
|||
package dev.loki.loparkour;
|
||||
|
||||
import dev.loki.loparkour.api.Registry;
|
||||
import dev.loki.loparkour.config.Config;
|
||||
import dev.loki.loparkour.config.Locales;
|
||||
import dev.loki.loparkour.config.Option;
|
||||
import dev.loki.loparkour.hook.HoloHook;
|
||||
import dev.loki.loparkour.hook.PAPIHook;
|
||||
import dev.loki.loparkour.mode.DefaultMode;
|
||||
import dev.loki.loparkour.mode.ElytraMode;
|
||||
import dev.loki.loparkour.mode.Modes;
|
||||
import dev.loki.loparkour.mode.SpectatorMode;
|
||||
import dev.loki.loparkour.mode.SpeedrunMode;
|
||||
import dev.loki.loparkour.mode.RaceMode;
|
||||
import dev.loki.loparkour.mode.CoopMode;
|
||||
import dev.loki.loparkour.player.ParkourUser;
|
||||
import dev.loki.loparkour.player.UserRegistry;
|
||||
import dev.loki.loparkour.reward.Rewards;
|
||||
import dev.loki.loparkour.schematic.lpschem.LPSchematicManager;
|
||||
import dev.loki.loparkour.storage.Storage;
|
||||
import dev.loki.loparkour.world.World;
|
||||
import dev.lolib.core.LoPlugin;
|
||||
import dev.lolib.scheduler.Scheduler;
|
||||
import com.google.gson.Gson;
|
||||
import com.google.gson.GsonBuilder;
|
||||
import org.bstats.bukkit.Metrics;
|
||||
import org.bstats.charts.SimplePie;
|
||||
import org.bstats.charts.SingleLineChart;
|
||||
|
||||
import org.jetbrains.annotations.Nullable;
|
||||
|
||||
import java.io.File;
|
||||
import java.util.ArrayList;
|
||||
import java.util.List;
|
||||
|
||||
/**
|
||||
* Main class of LoParkour
|
||||
*
|
||||
* @author loki
|
||||
*/
|
||||
public final class LoParkour extends LoPlugin {
|
||||
|
||||
public static final String NAME = "&#FF6464<bold>LoParkour<reset>";
|
||||
public static final String PREFIX = NAME + " 񢩈» &#A0A0A0";
|
||||
|
||||
private static final Gson gson = new GsonBuilder()
|
||||
.setPrettyPrinting()
|
||||
.excludeFieldsWithoutExposeAnnotation()
|
||||
.create();
|
||||
|
||||
public static Gson getGson() {
|
||||
return gson;
|
||||
}
|
||||
private static LoParkour instance;
|
||||
private static LPSchematicManager schematicManager;
|
||||
|
||||
@Nullable
|
||||
private static PAPIHook placeholderHook;
|
||||
|
||||
public static void log(String message) {
|
||||
if (Config.CONFIG.getBoolean("debug")) {
|
||||
LoParkour.getPlugin().getLogger().info("[Debug] " + message);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* @param child The file name.
|
||||
* @return A file from within the plugin folder.
|
||||
*/
|
||||
public static File getInFolder(String child) {
|
||||
return new File(instance.getDataFolder(), child);
|
||||
}
|
||||
|
||||
/**
|
||||
* @return This plugin's {@link Logging} instance.
|
||||
*/
|
||||
|
||||
|
||||
/**
|
||||
* @return The plugin instance.
|
||||
*/
|
||||
public static LoParkour getPlugin() {
|
||||
return instance;
|
||||
}
|
||||
|
||||
@Nullable
|
||||
public static PAPIHook getPlaceholderHook() {
|
||||
return placeholderHook;
|
||||
}
|
||||
|
||||
public static LPSchematicManager getSchematicManager() {
|
||||
return schematicManager;
|
||||
}
|
||||
|
||||
|
||||
|
||||
@Override
|
||||
public void enable() {
|
||||
instance = this; // must be first — Config enum accesses instance inside reload()
|
||||
loadConfigs();
|
||||
loadSchematics();
|
||||
registerModes();
|
||||
registerHooks();
|
||||
setupWorld();
|
||||
registerEventsAndCommands();
|
||||
setupMetrics();
|
||||
}
|
||||
|
||||
private void loadConfigs() {
|
||||
Config.reload(true);
|
||||
}
|
||||
|
||||
private void loadSchematics() {
|
||||
schematicManager = new LPSchematicManager();
|
||||
|
||||
// Create default island schematic if it doesn't exist
|
||||
File schematicsFolder = getInFolder("schematics-new");
|
||||
File islandFile = new File(schematicsFolder, "island.lpschem");
|
||||
|
||||
if (!islandFile.exists()) {
|
||||
try {
|
||||
createDefaultIslandSchematic(islandFile);
|
||||
getLogger().info("Created default island schematic");
|
||||
} catch (Exception ex) {
|
||||
getLogger().severe("Failed to create default island schematic: " + ex.getMessage());
|
||||
ex.printStackTrace();
|
||||
}
|
||||
}
|
||||
|
||||
schematicManager.loadAll();
|
||||
}
|
||||
|
||||
private void createDefaultIslandSchematic(File file) throws Exception {
|
||||
// Create a larger 9x5x9 island for better visibility and safety
|
||||
List<String> palette = new ArrayList<>();
|
||||
palette.add("minecraft:air");
|
||||
palette.add("minecraft:stone");
|
||||
palette.add("minecraft:diamond_block");
|
||||
palette.add("minecraft:emerald_block");
|
||||
|
||||
int width = 9, height = 5, length = 9;
|
||||
int[] blocks = new int[width * height * length];
|
||||
|
||||
// Fill everything with air first
|
||||
for (int i = 0; i < blocks.length; i++) {
|
||||
blocks[i] = 0;
|
||||
}
|
||||
|
||||
// Create a solid stone platform at y=1 (7x7 platform)
|
||||
for (int z = 1; z < 8; z++) {
|
||||
for (int x = 1; x < 8; x++) {
|
||||
int index = x + (z * width) + (1 * width * length);
|
||||
blocks[index] = 1; // stone
|
||||
}
|
||||
}
|
||||
|
||||
// Place diamond block (player spawn) at center
|
||||
int centerX = 4, centerZ = 4;
|
||||
int diamondIndex = centerX + (centerZ * width) + (1 * width * length);
|
||||
blocks[diamondIndex] = 2; // diamond_block - player spawns here
|
||||
|
||||
// Place emerald block (parkour start) to the right of spawn
|
||||
int emeraldIndex = (centerX + 1) + (centerZ * width) + (1 * width * length);
|
||||
blocks[emeraldIndex] = 3; // emerald_block - parkour starts here
|
||||
|
||||
dev.loki.loparkour.schematic.lpschem.SchematicMetadata metadata =
|
||||
new dev.loki.loparkour.schematic.lpschem.SchematicMetadata("island", "LoParkour", 0.0);
|
||||
metadata.addTag("spawn");
|
||||
metadata.addTag("island");
|
||||
|
||||
dev.loki.loparkour.schematic.lpschem.SchematicDimensions dimensions =
|
||||
new dev.loki.loparkour.schematic.lpschem.SchematicDimensions(width, height, length);
|
||||
|
||||
// Markers point to the blocks where player spawns and parkour begins
|
||||
dev.loki.loparkour.schematic.lpschem.SchematicMarkers markers =
|
||||
new dev.loki.loparkour.schematic.lpschem.SchematicMarkers(
|
||||
new dev.loki.loparkour.schematic.lpschem.SchematicMarkers.Vector3i(centerX, 1, centerZ),
|
||||
new dev.loki.loparkour.schematic.lpschem.SchematicMarkers.Vector3i(centerX + 1, 1, centerZ)
|
||||
);
|
||||
|
||||
dev.loki.loparkour.schematic.lpschem.LPSchematic schematic =
|
||||
new dev.loki.loparkour.schematic.lpschem.LPSchematic(metadata, dimensions, palette, blocks, markers);
|
||||
|
||||
schematic.save(file);
|
||||
}
|
||||
|
||||
private void registerModes() {
|
||||
Registry.register(new DefaultMode());
|
||||
Registry.register(new SpectatorMode());
|
||||
Registry.register(new SpeedrunMode());
|
||||
Registry.register(new ElytraMode());
|
||||
Registry.register(new RaceMode());
|
||||
Registry.register(new CoopMode());
|
||||
Modes.init();
|
||||
}
|
||||
|
||||
private void registerHooks() {
|
||||
if (getServer().getPluginManager().isPluginEnabled("HolographicDisplays")) {
|
||||
getLogger().info("Registered Holographic Displays hook");
|
||||
HoloHook.init();
|
||||
}
|
||||
if (getServer().getPluginManager().isPluginEnabled("PlaceholderAPI")) {
|
||||
getLogger().info("Registered PlaceholderAPI hook");
|
||||
placeholderHook = new PAPIHook();
|
||||
placeholderHook.register();
|
||||
}
|
||||
if (Config.CONFIG.getBoolean("bungeecord.enabled")) {
|
||||
getServer().getMessenger().registerOutgoingPluginChannel(this, "BungeeCord");
|
||||
getLogger().info("Registered BungeeCord hook");
|
||||
}
|
||||
}
|
||||
|
||||
private void setupWorld() {
|
||||
if (!Config.CONFIG.getBoolean("joining")) {
|
||||
return;
|
||||
}
|
||||
|
||||
// World creation MUST happen on the main thread (Bukkit API requirement)
|
||||
try {
|
||||
loLogger().info("Creating parkour world...");
|
||||
World.create();
|
||||
loLogger().info("Parkour world created successfully!");
|
||||
} catch (Exception ex) {
|
||||
loLogger().error("Failed to create parkour world", ex);
|
||||
}
|
||||
}
|
||||
|
||||
private void registerEventsAndCommands() {
|
||||
dev.lolib.gui.GUIManager.init(this); // required for LoLib GUI click handling
|
||||
|
||||
var pm = getServer().getPluginManager();
|
||||
pm.registerEvents(new dev.loki.loparkour.listener.PlayerConnectionListener(), this);
|
||||
pm.registerEvents(new dev.loki.loparkour.listener.ParkourRestrictionListener(), this);
|
||||
pm.registerEvents(new dev.loki.loparkour.listener.SchematicWandListener(), this);
|
||||
|
||||
LoParkourCommand cmd = new LoParkourCommand();
|
||||
getCommand("LoParkour").setExecutor(cmd);
|
||||
getCommand("LoParkour").setTabCompleter(cmd);
|
||||
}
|
||||
|
||||
private void setupMetrics() {
|
||||
Metrics metrics = new Metrics(this, 29754);
|
||||
metrics.addCustomChart(new SimplePie("using_sql", () -> Boolean.toString(Option.SQL)));
|
||||
metrics.addCustomChart(new SimplePie("using_rewards", () -> Boolean.toString(Rewards.REWARDS_ENABLED)));
|
||||
metrics.addCustomChart(new SimplePie("locale_count", () -> Integer.toString(Locales.locales.size())));
|
||||
metrics.addCustomChart(new SingleLineChart("player_joins", () -> {
|
||||
int joins = UserRegistry.getJoinCount();
|
||||
return joins;
|
||||
}));
|
||||
}
|
||||
|
||||
@Override
|
||||
public void disable() {
|
||||
try {
|
||||
for (ParkourUser user : ParkourUser.getUsers()) {
|
||||
ParkourUser.leave(user);
|
||||
}
|
||||
|
||||
// write all LoParkour gamemodes
|
||||
Modes.DEFAULT.getLeaderboard().write(false);
|
||||
|
||||
Storage.close();
|
||||
World.delete();
|
||||
} catch (Throwable t) {
|
||||
getLogger().severe("Error during plugin shutdown: " + t.getMessage());
|
||||
t.printStackTrace();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -1,281 +0,0 @@
|
|||
package dev.loki.loparkour.generator.jump;
|
||||
|
||||
import dev.loki.loparkour.LoParkour;
|
||||
import dev.loki.loparkour.api.event.ParkourBlockGenerateEvent;
|
||||
import dev.loki.loparkour.api.event.ParkourSchematicGenerateEvent;
|
||||
import dev.loki.loparkour.api.Registry;
|
||||
import dev.loki.loparkour.config.Config;
|
||||
import dev.loki.loparkour.config.Option;
|
||||
import dev.loki.loparkour.generator.GeneratorOption;
|
||||
import dev.loki.loparkour.generator.ParkourGenerator;
|
||||
import dev.loki.loparkour.style.Style;
|
||||
import dev.loki.loparkour.util.Probs;
|
||||
import org.bukkit.Location;
|
||||
import org.bukkit.Material;
|
||||
import org.bukkit.block.Block;
|
||||
import org.bukkit.block.data.BlockData;
|
||||
import org.bukkit.block.data.type.Fence;
|
||||
import org.bukkit.block.data.type.GlassPane;
|
||||
import org.bukkit.util.BoundingBox;
|
||||
import org.bukkit.util.Vector;
|
||||
import org.jetbrains.annotations.NotNull;
|
||||
|
||||
import java.util.ArrayList;
|
||||
import java.util.HashMap;
|
||||
import java.util.List;
|
||||
import java.util.Map;
|
||||
import java.util.concurrent.ThreadLocalRandom;
|
||||
import java.util.logging.Level;
|
||||
|
||||
/**
|
||||
* Handles block selection, placement, and schematic generation.
|
||||
* Extracted from {@link ParkourGenerator}.
|
||||
*/
|
||||
public class BlockPlacer {
|
||||
|
||||
private final ParkourGenerator g;
|
||||
|
||||
public BlockPlacer(ParkourGenerator generator) {
|
||||
this.g = generator;
|
||||
}
|
||||
|
||||
// ── Public entry points ────────────────────────────────────────────────────
|
||||
|
||||
/** Generates one block or schematic ahead. */
|
||||
public void generate() {
|
||||
if (g.state.waitForSchematicCompletion) return;
|
||||
|
||||
Map<ParkourGenerator.BlockGenerationType, Double> chances = new HashMap<>(g.state.defaultChances);
|
||||
if (g.state.schematicCooldown > 0
|
||||
|| g.generatorOptions.contains(GeneratorOption.DISABLE_SCHEMATICS)
|
||||
|| g.profile.get("schematicDifficulty").asDouble() == 0.0
|
||||
|| !g.state.schematicBlocks.isEmpty()) {
|
||||
chances.remove(ParkourGenerator.BlockGenerationType.SCHEMATIC);
|
||||
}
|
||||
if (!g.profile.get("useSpecialBlocks").asBoolean()) {
|
||||
chances.remove(ParkourGenerator.BlockGenerationType.SPECIAL);
|
||||
}
|
||||
if (chances.isEmpty()) chances.put(ParkourGenerator.BlockGenerationType.DEFAULT, 1.0);
|
||||
|
||||
ParkourGenerator.BlockGenerationType jump = Probs.random(chances);
|
||||
if (jump == ParkourGenerator.BlockGenerationType.SCHEMATIC && tryGenerateSchematic()) return;
|
||||
|
||||
placeNormalBlock(jump);
|
||||
}
|
||||
|
||||
/** Generates {@code amount+1} blocks. */
|
||||
public void generate(int amount) {
|
||||
for (int i = 0; i <= amount; i++) generate();
|
||||
}
|
||||
|
||||
/** Seeds the history and generates the initial lead. */
|
||||
public void generateFirst(Location spawn, Location blockSpawn) {
|
||||
g.state.playerSpawn = spawn;
|
||||
g.state.lastStandingPlayerLocation = spawn;
|
||||
g.state.blockSpawn = blockSpawn;
|
||||
g.state.history.add(blockSpawn.getBlock());
|
||||
generate(g.profile.get("blockLead").asInt());
|
||||
}
|
||||
|
||||
/** Clears schematic blocks and resets cooldown. */
|
||||
public void deleteSchematic() {
|
||||
if (!g.state.deleteSchematic) return;
|
||||
g.state.schematicBlocks.forEach(b -> b.setType(Material.AIR));
|
||||
g.state.schematicBlocks.clear();
|
||||
g.state.deleteSchematic = false;
|
||||
g.state.schematicCooldown = Config.GENERATION.getInt("advanced.schematic-cooldown");
|
||||
}
|
||||
|
||||
// ── Block selection ────────────────────────────────────────────────────────
|
||||
|
||||
protected List<Block> selectBlocks() {
|
||||
int height = Probs.random(g.state.heightChances);
|
||||
int distance = Probs.random(g.state.distanceChances);
|
||||
return List.of(selectNext(g.getLatest(), distance, height));
|
||||
}
|
||||
|
||||
protected Block selectNext(Block current, int distance, int height) {
|
||||
JumpDirector director = new JumpDirector(
|
||||
BoundingBox.of(g.zone[0], g.zone[1]), g.getLatest().getLocation().toVector());
|
||||
|
||||
g.state.heading = director.getRecommendedHeading(g.state.heading);
|
||||
height = director.getRecommendedHeight(height);
|
||||
|
||||
// Restrict jumps after special blocks to prevent impossible jumps
|
||||
Material lastType = g.getLatest().getType();
|
||||
switch (lastType) {
|
||||
case SMOOTH_QUARTZ_SLAB -> {
|
||||
height = Math.min(height, 0); // Can't jump up from slab
|
||||
distance = Math.min(distance, 3); // Limit distance
|
||||
}
|
||||
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;
|
||||
}
|
||||
|
||||
protected BlockData selectBlockData() {
|
||||
Style style = Registry.getStyle(g.profile.get("style").value());
|
||||
if (style == null) {
|
||||
g.profile.set("style", Registry.getStyles().stream().findFirst().orElseThrow().getName());
|
||||
return selectBlockData();
|
||||
}
|
||||
return style.getNext().createBlockData();
|
||||
}
|
||||
|
||||
// ── Internal ───────────────────────────────────────────────────────────────
|
||||
|
||||
private void placeNormalBlock(ParkourGenerator.BlockGenerationType jump) {
|
||||
List<Block> blocks = selectBlocks();
|
||||
if (blocks.isEmpty()) {
|
||||
LoParkour.getPlugin().getLogger().severe("No blocks to generate!");
|
||||
return;
|
||||
}
|
||||
|
||||
List<Block> placed = new ArrayList<>();
|
||||
for (Block block : blocks) {
|
||||
BlockData data;
|
||||
|
||||
// Select JumpType based on chances (70% normal, 30% special types)
|
||||
JumpType jumpType = selectJumpType();
|
||||
|
||||
if (jumpType != JumpType.NORMAL && !g.generatorOptions.contains(GeneratorOption.DISABLE_SPECIAL)) {
|
||||
// Use JumpType material
|
||||
data = jumpType.getRandomMaterial().createBlockData();
|
||||
} else if (jump == ParkourGenerator.BlockGenerationType.SPECIAL
|
||||
&& !g.generatorOptions.contains(GeneratorOption.DISABLE_SPECIAL)) {
|
||||
data = Probs.random(g.state.specialChances);
|
||||
} else {
|
||||
data = selectBlockData();
|
||||
}
|
||||
|
||||
if (data instanceof Fence) block = block.getLocation().subtract(0, 1, 0).getBlock();
|
||||
block.setBlockData(data, data instanceof Fence || data instanceof GlassPane);
|
||||
placed.add(block);
|
||||
}
|
||||
|
||||
new ParkourBlockGenerateEvent(placed, g, g.player).call();
|
||||
g.effects.particles(placed);
|
||||
g.effects.sound(placed);
|
||||
g.state.history.addAll(placed);
|
||||
g.state.schematicCooldown--;
|
||||
}
|
||||
|
||||
private JumpType selectJumpType() {
|
||||
// Simple chances: 70% normal, 5% each for special types
|
||||
Map<JumpType, Double> chances = new HashMap<>();
|
||||
chances.put(JumpType.NORMAL, 70.0);
|
||||
chances.put(JumpType.NEO_JUMP, 5.0);
|
||||
chances.put(JumpType.HEAD_HITTER, 5.0);
|
||||
chances.put(JumpType.FENCE_JUMP, 10.0);
|
||||
chances.put(JumpType.TRAPDOOR_JUMP, 5.0);
|
||||
chances.put(JumpType.LADDER_JUMP, 5.0);
|
||||
|
||||
return Probs.random(chances);
|
||||
}
|
||||
|
||||
private boolean tryGenerateSchematic() {
|
||||
var manager = LoParkour.getSchematicManager();
|
||||
if (manager == null) return false;
|
||||
|
||||
var all = manager.getAllSchematics();
|
||||
if (all.isEmpty()) return false;
|
||||
|
||||
double diff = g.profile.get("schematicDifficulty").asDouble();
|
||||
var candidates = all.values().stream()
|
||||
.filter(s -> s.getMetadata() != null && Math.abs(s.getMetadata().getDifficulty() - diff) <= 0.26)
|
||||
.toList();
|
||||
var pool = candidates.isEmpty() ? new ArrayList<>(all.values()) : candidates;
|
||||
var schematic = pool.get(ThreadLocalRandom.current().nextInt(pool.size()));
|
||||
|
||||
Location origin = g.getLatest().getLocation().add(g.state.heading.clone().multiply(2));
|
||||
List<Block> placed = rotatedPaste(schematic, origin);
|
||||
if (placed.isEmpty()) return false;
|
||||
|
||||
g.state.schematicBlocks.addAll(placed);
|
||||
g.state.history.addAll(placed);
|
||||
g.state.waitForSchematicCompletion = true;
|
||||
g.state.deleteSchematic = false;
|
||||
|
||||
new ParkourSchematicGenerateEvent(schematic, g, g.player).call();
|
||||
return true;
|
||||
}
|
||||
|
||||
private @NotNull List<Block> rotatedPaste(
|
||||
dev.loki.loparkour.schematic.lpschem.LPSchematic schematic, Location location) {
|
||||
int[] rotations = {0, 90, 180, 270};
|
||||
int rotation = rotations[ThreadLocalRandom.current().nextInt(rotations.length)];
|
||||
org.bukkit.World world = location.getWorld();
|
||||
if (world == null) return List.of();
|
||||
|
||||
if (rotation == 0) return schematic.paste(location, world);
|
||||
try {
|
||||
var t = new dev.loki.loparkour.schematic.lpschem.SchematicTransformer(schematic);
|
||||
return t.rotate(rotation).paste(location, world);
|
||||
} catch (Exception ex) {
|
||||
LoParkour.getPlugin().getLogger().log(Level.WARNING, "Could not rotate schematic", ex);
|
||||
return schematic.paste(location, world);
|
||||
}
|
||||
}
|
||||
|
||||
private double angleInY(Vector a, Vector b) {
|
||||
return Math.atan2(a.getX() * b.getZ() - a.getZ() * b.getX(), a.dot(b));
|
||||
}
|
||||
}
|
||||
|
|
@ -1,78 +0,0 @@
|
|||
package dev.loki.loparkour.generator.jump;
|
||||
|
||||
import dev.loki.loparkour.util.Probs;
|
||||
|
||||
import java.util.Map;
|
||||
import java.util.Random;
|
||||
import java.util.concurrent.ThreadLocalRandom;
|
||||
import java.util.stream.Collectors;
|
||||
|
||||
/**
|
||||
* Generates a random offset (the sidestep) for a parkour block.
|
||||
* Source: <a href="https://i.imgur.com/RPevMfX.png">here</a>.
|
||||
*/
|
||||
public class JumpOffsetGenerator {
|
||||
|
||||
private final int maxOffset;
|
||||
|
||||
/**
|
||||
* Constructor.
|
||||
*
|
||||
* @param y The height difference.
|
||||
*/
|
||||
public JumpOffsetGenerator(int y, int distance) {
|
||||
this.maxOffset = switch (y) {
|
||||
case 1 -> switch (distance) {
|
||||
case 1 -> 4;
|
||||
case 2 -> 3;
|
||||
default -> 2;
|
||||
};
|
||||
case 0 -> switch (distance) {
|
||||
case 1, 2 -> 4;
|
||||
case 3 -> 3;
|
||||
default -> 2;
|
||||
};
|
||||
case -1 -> switch (distance) {
|
||||
case 1, 2 -> 5;
|
||||
case 3 -> 4;
|
||||
default -> 3;
|
||||
};
|
||||
case -2 -> switch (distance) {
|
||||
case 1, 2 -> 5;
|
||||
default -> 4;
|
||||
};
|
||||
default -> {
|
||||
// Fallback for unexpected values
|
||||
dev.loki.loparkour.LoParkour.getPlugin().getLogger()
|
||||
.warning("Unexpected jump height " + y + ", using default maxOffset=2");
|
||||
yield 2;
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* @param mean The mean (average) value for the offset. Usually 0 to avoid parkour going only left or right.
|
||||
* @param standardDeviation The standard deviation.
|
||||
* @param random The random instance.
|
||||
* @return A random jump-able offset.
|
||||
*/
|
||||
public int getRandomOffset(double mean, double standardDeviation, Random random) {
|
||||
Map<Double, Double> distribution = java.util.stream.IntStream.range(-maxOffset, maxOffset + 1)
|
||||
.boxed()
|
||||
.collect(Collectors.toMap(
|
||||
offset -> (double) offset,
|
||||
offset -> Probs.normalpdf(mean, standardDeviation, (double) offset)
|
||||
));
|
||||
|
||||
return (int) (double) Probs.random(distribution, random);
|
||||
}
|
||||
|
||||
/**
|
||||
* @param mean The mean (average) value for the offset. Usually 0 to avoid parkour going only left or right.
|
||||
* @param standardDeviation The standard deviation.
|
||||
* @return A random jump-able offset.
|
||||
*/
|
||||
public int getRandomOffset(double mean, double standardDeviation) {
|
||||
return getRandomOffset(mean, standardDeviation, ThreadLocalRandom.current());
|
||||
}
|
||||
}
|
||||
|
|
@ -1,265 +0,0 @@
|
|||
package dev.loki.loparkour.generator.lifecycle;
|
||||
|
||||
import dev.lolib.scheduler.Scheduler;
|
||||
import dev.loki.loparkour.LoParkour;
|
||||
import dev.loki.loparkour.api.event.ParkourFallEvent;
|
||||
import dev.loki.loparkour.api.event.ParkourScoreEvent;
|
||||
import dev.loki.loparkour.config.Config;
|
||||
import dev.loki.loparkour.config.Option;
|
||||
import dev.loki.loparkour.generator.ParkourGenerator;
|
||||
import dev.loki.loparkour.ghost.GhostData;
|
||||
import dev.loki.loparkour.ghost.GhostManager;
|
||||
import dev.loki.loparkour.ghost.GhostRecorder;
|
||||
import dev.loki.loparkour.leaderboard.Leaderboard;
|
||||
import dev.loki.loparkour.leaderboard.Score;
|
||||
import dev.loki.loparkour.mode.Modes;
|
||||
import dev.loki.loparkour.player.ParkourPlayer;
|
||||
import dev.loki.loparkour.player.ParkourSpectator;
|
||||
import dev.loki.loparkour.reward.Rewards;
|
||||
import org.bukkit.Location;
|
||||
import org.bukkit.Material;
|
||||
import org.bukkit.block.Block;
|
||||
import org.jetbrains.annotations.Nullable;
|
||||
|
||||
import java.time.Instant;
|
||||
import java.util.Iterator;
|
||||
|
||||
/**
|
||||
* Handles parkour lifecycle: tick, fall, reset, cleanup.
|
||||
*/
|
||||
public class GeneratorLifecycle {
|
||||
|
||||
private final ParkourGenerator generator;
|
||||
private dev.lolib.scheduler.ScheduledTask cleanupTask;
|
||||
private GhostRecorder ghostRecorder;
|
||||
private GhostManager ghostManager;
|
||||
|
||||
public GeneratorLifecycle(ParkourGenerator generator) {
|
||||
this.generator = generator;
|
||||
|
||||
if (Config.CONFIG.getBoolean("ghost-mode.enabled")) {
|
||||
this.ghostRecorder = new GhostRecorder();
|
||||
this.ghostManager = new GhostManager();
|
||||
this.ghostManager.loadGhosts(generator.getMode().getName());
|
||||
}
|
||||
}
|
||||
|
||||
public void startTick() {
|
||||
generator.task = Scheduler.get(LoParkour.getPlugin()).runTimer(this::tick, 0, 1);
|
||||
cleanupTask = Scheduler.get(LoParkour.getPlugin()).runTimer(this::cleanupDistantBlocks, 0, Option.CLEANUP_INTERVAL);
|
||||
|
||||
// Start ghost recording if enabled
|
||||
if (ghostRecorder != null && ghostManager != null) {
|
||||
ghostRecorder.startRecording(generator.player.getLocation());
|
||||
|
||||
// Spawn top ghosts
|
||||
if (generator.state.playerSpawn != null) {
|
||||
ghostManager.spawnGhosts(
|
||||
generator.getMode().getName(),
|
||||
generator.state.playerSpawn,
|
||||
generator.player.player.getWorld()
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
public void tick() {
|
||||
if (generator.state.stopped) {
|
||||
generator.task.cancel();
|
||||
if (cleanupTask != null) cleanupTask.cancel();
|
||||
if (ghostManager != null) ghostManager.stopAllGhosts();
|
||||
return;
|
||||
}
|
||||
|
||||
generator.getPlayers().forEach(p -> {
|
||||
updateVisualTime(p, p.selectedTime);
|
||||
p.updateScoreboard(generator);
|
||||
p.player.setSaturation(20);
|
||||
|
||||
// Record ghost frame
|
||||
if (ghostRecorder != null && ghostRecorder.isRecording()) {
|
||||
ghostRecorder.recordFrame(p.getLocation());
|
||||
}
|
||||
});
|
||||
generator.getSpectators().forEach(ParkourSpectator::update);
|
||||
|
||||
if (generator.player.getLocation().getWorld() != generator.state.lastStandingPlayerLocation.getWorld()) return;
|
||||
if (generator.player.getLocation().subtract(generator.state.lastStandingPlayerLocation).getY() < -10) {
|
||||
fall();
|
||||
return;
|
||||
}
|
||||
|
||||
Block below = blockBelow();
|
||||
if (below == null) return;
|
||||
|
||||
handleSchematicEndBlock(below);
|
||||
|
||||
// Clean up schematic blocks that are behind the player
|
||||
if (!generator.state.schematicBlocks.isEmpty()) {
|
||||
int currentIdx = generator.state.history.indexOf(below);
|
||||
if (currentIdx > 0) {
|
||||
generator.state.schematicBlocks.removeIf(b -> {
|
||||
int blockIdx = generator.state.history.indexOf(b);
|
||||
if (blockIdx >= 0 && blockIdx < currentIdx - 5) {
|
||||
b.setType(Material.AIR);
|
||||
return true;
|
||||
}
|
||||
return false;
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
// Reset schematic wait if player is far from schematic blocks
|
||||
if (generator.state.waitForSchematicCompletion && !generator.state.schematicBlocks.isEmpty()) {
|
||||
boolean nearSchematic = generator.state.schematicBlocks.stream()
|
||||
.anyMatch(b -> b.getLocation().distance(generator.player.getLocation()) < 15);
|
||||
if (!nearSchematic) {
|
||||
generator.state.waitForSchematicCompletion = false;
|
||||
// Force delete schematic blocks
|
||||
generator.state.schematicBlocks.forEach(b -> b.setType(Material.AIR));
|
||||
generator.state.schematicBlocks.clear();
|
||||
generator.state.deleteSchematic = false;
|
||||
generator.state.schematicCooldown = Config.GENERATION.getInt("advanced.schematic-cooldown");
|
||||
}
|
||||
}
|
||||
|
||||
if (!generator.state.history.contains(below)) return;
|
||||
|
||||
int idx = generator.state.history.indexOf(below);
|
||||
int delta = idx - generator.state.lastPositionIndexPlayer;
|
||||
if (delta <= 0) return;
|
||||
|
||||
generator.state.lastStandingPlayerLocation = generator.player.getLocation();
|
||||
|
||||
int lead = generator.profile.get("blockLead").asInt();
|
||||
if (generator.state.history.size() - idx <= lead) {
|
||||
generator.generate(lead - (generator.state.history.size() - idx));
|
||||
}
|
||||
generator.state.lastPositionIndexPlayer = idx;
|
||||
|
||||
removeTrailBlocks(idx);
|
||||
generator.placer.deleteSchematic();
|
||||
|
||||
int pts = Config.CONFIG.getBoolean("scoring.all-points") ? delta : 1;
|
||||
for (int i = 0; i < pts; i++) score();
|
||||
|
||||
if (generator.state.start == null) generator.state.start = Instant.now();
|
||||
}
|
||||
|
||||
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);
|
||||
}
|
||||
|
||||
public void score() {
|
||||
generator.state.score++;
|
||||
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);
|
||||
}
|
||||
}
|
||||
|
||||
public void fall() {
|
||||
new ParkourFallEvent(generator.player).call();
|
||||
|
||||
// Save ghost if score is good enough
|
||||
if (ghostRecorder != null && ghostManager != null && ghostRecorder.isRecording()) {
|
||||
String modeName = generator.getMode().getName();
|
||||
int score = generator.state.score;
|
||||
|
||||
if (ghostManager.shouldRecordGhost(modeName, score)) {
|
||||
// Pass UUID as well as name — GhostManager names files by UUID
|
||||
GhostData ghostData = ghostRecorder.stopRecording(
|
||||
generator.player.getUUID(),
|
||||
generator.player.getName(),
|
||||
score
|
||||
);
|
||||
ghostManager.saveGhost(modeName, ghostData);
|
||||
generator.player.sendTranslated("ghost.saved", Integer.toString(score));
|
||||
}
|
||||
}
|
||||
|
||||
if (ghostManager != null) {
|
||||
ghostManager.stopAllGhosts();
|
||||
}
|
||||
|
||||
generator.reset(true);
|
||||
}
|
||||
|
||||
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);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -1,102 +0,0 @@
|
|||
package dev.loki.loparkour.ghost;
|
||||
|
||||
import org.jetbrains.annotations.NotNull;
|
||||
|
||||
import java.io.*;
|
||||
import java.util.ArrayList;
|
||||
import java.util.List;
|
||||
import java.util.UUID;
|
||||
|
||||
/**
|
||||
* Immutable snapshot of a player's run: who ran it, their score, and every recorded frame.
|
||||
*
|
||||
* <p>Bugs fixed vs original:
|
||||
* <ul>
|
||||
* <li>Files were named {@code playerName.ghost} — two players with the same name
|
||||
* overwrote each other, and a name like {@code ../../config} caused path traversal.
|
||||
* UUID is now stored and used as the filename instead.</li>
|
||||
* <li>File format is backwards-compatible: UUID is written first (new field),
|
||||
* then the existing layout. Old files without a UUID are handled in
|
||||
* {@link #loadFromFile} by catching the format mismatch and returning null.</li>
|
||||
* </ul>
|
||||
*/
|
||||
public class GhostData {
|
||||
|
||||
/** Binary format version — bump when changing the serialised layout. */
|
||||
private static final int FORMAT_VERSION = 2;
|
||||
|
||||
private final UUID playerUuid;
|
||||
private final String playerName;
|
||||
private final int score;
|
||||
private final List<GhostFrame> frames;
|
||||
|
||||
public GhostData(@NotNull UUID playerUuid, @NotNull String playerName,
|
||||
int score, @NotNull List<GhostFrame> frames) {
|
||||
this.playerUuid = playerUuid;
|
||||
this.playerName = playerName;
|
||||
this.score = score;
|
||||
this.frames = new ArrayList<>(frames);
|
||||
}
|
||||
|
||||
// ── Getters ───────────────────────────────────────────────────────────────
|
||||
|
||||
public UUID getPlayerUuid() { return playerUuid; }
|
||||
public String getPlayerName() { return playerName; }
|
||||
public int getScore() { return score; }
|
||||
public List<GhostFrame> getFrames() { return frames; }
|
||||
|
||||
// ── Serialisation ─────────────────────────────────────────────────────────
|
||||
|
||||
public void saveToFile(@NotNull File file) throws IOException {
|
||||
try (DataOutputStream out = new DataOutputStream(
|
||||
new BufferedOutputStream(new FileOutputStream(file)))) {
|
||||
out.writeInt(FORMAT_VERSION); // version header
|
||||
out.writeUTF(playerUuid.toString()); // UUID (filename-safe, collision-free)
|
||||
out.writeUTF(playerName); // display name (for the ghost label)
|
||||
out.writeInt(score);
|
||||
out.writeInt(frames.size());
|
||||
for (GhostFrame frame : frames) {
|
||||
out.writeLong(frame.getTimestamp());
|
||||
out.writeDouble(frame.getX());
|
||||
out.writeDouble(frame.getY());
|
||||
out.writeDouble(frame.getZ());
|
||||
out.writeFloat(frame.getYaw());
|
||||
out.writeFloat(frame.getPitch());
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Loads a ghost from file.
|
||||
*
|
||||
* @return The loaded data, or {@code null} if the file is an old v1 format
|
||||
* (no version header / UUID) — caller should delete and ignore it.
|
||||
*/
|
||||
public static GhostData loadFromFile(@NotNull File file) throws IOException {
|
||||
try (DataInputStream in = new DataInputStream(
|
||||
new BufferedInputStream(new FileInputStream(file)))) {
|
||||
|
||||
int version = in.readInt();
|
||||
if (version != FORMAT_VERSION) {
|
||||
// Old format (v1) started with readUTF for playerName, not an int version.
|
||||
// Those files are stale — return null so the manager can discard them.
|
||||
return null;
|
||||
}
|
||||
|
||||
UUID uuid = UUID.fromString(in.readUTF());
|
||||
String name = in.readUTF();
|
||||
int score = in.readInt();
|
||||
int frameCount = in.readInt();
|
||||
|
||||
List<GhostFrame> frames = new ArrayList<>(frameCount);
|
||||
for (int i = 0; i < frameCount; i++) {
|
||||
frames.add(new GhostFrame(
|
||||
in.readLong(),
|
||||
in.readDouble(), in.readDouble(), in.readDouble(),
|
||||
in.readFloat(), in.readFloat()));
|
||||
}
|
||||
|
||||
return new GhostData(uuid, name, score, frames);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -1,162 +0,0 @@
|
|||
package dev.loki.loparkour.ghost;
|
||||
|
||||
import dev.loki.loparkour.LoParkour;
|
||||
import dev.loki.loparkour.config.Config;
|
||||
import org.bukkit.Location;
|
||||
import org.bukkit.World;
|
||||
import org.jetbrains.annotations.NotNull;
|
||||
import org.jetbrains.annotations.Nullable;
|
||||
|
||||
import java.io.File;
|
||||
import java.io.IOException;
|
||||
import java.util.*;
|
||||
|
||||
/**
|
||||
* Loads, saves, and spawns ghost runs per mode.
|
||||
*
|
||||
* <p>Bugs fixed vs original:
|
||||
* <ul>
|
||||
* <li>Files were named {@code playerName.ghost} — collisions between players sharing
|
||||
* a name, and path traversal via names like {@code ../../config}.
|
||||
* Now named {@code uuid.ghost} — guaranteed unique and filesystem-safe.</li>
|
||||
* <li>When evicting the lowest ghost, the old file was looked up by player name
|
||||
* which failed if the name had changed since the run was saved.</li>
|
||||
* <li>Old v1 files (no UUID) are detected via {@link GhostData#loadFromFile}
|
||||
* returning null — they are deleted automatically on first load.</li>
|
||||
* </ul>
|
||||
*/
|
||||
public class GhostManager {
|
||||
|
||||
private static final int MAX_GHOSTS_PER_MODE = 3;
|
||||
|
||||
private final File ghostFolder;
|
||||
private final Map<String, List<GhostData>> ghostsByMode = new HashMap<>();
|
||||
private final List<GhostPlayer> activeGhosts = new ArrayList<>();
|
||||
|
||||
public GhostManager() {
|
||||
this.ghostFolder = LoParkour.getInFolder("ghosts");
|
||||
if (!ghostFolder.exists()) {
|
||||
ghostFolder.mkdirs();
|
||||
}
|
||||
}
|
||||
|
||||
// ── Loading ───────────────────────────────────────────────────────────────
|
||||
|
||||
public void loadGhosts(@NotNull String mode) {
|
||||
File modeFolder = new File(ghostFolder, sanitizeMode(mode));
|
||||
if (!modeFolder.exists()) return;
|
||||
|
||||
File[] files = modeFolder.listFiles((dir, name) -> name.endsWith(".ghost"));
|
||||
if (files == null) return;
|
||||
|
||||
List<GhostData> ghosts = new ArrayList<>();
|
||||
for (File file : files) {
|
||||
try {
|
||||
GhostData data = GhostData.loadFromFile(file);
|
||||
if (data == null) {
|
||||
// Old v1 format — delete and skip
|
||||
LoParkour.getPlugin().getLogger().info(
|
||||
"Deleting outdated ghost file (old format): " + file.getName());
|
||||
file.delete();
|
||||
continue;
|
||||
}
|
||||
ghosts.add(data);
|
||||
} catch (IOException e) {
|
||||
LoParkour.getPlugin().getLogger().severe(
|
||||
"Failed to load ghost file " + file.getName() + ": " + e.getMessage());
|
||||
}
|
||||
}
|
||||
|
||||
ghosts.sort(Comparator.comparingInt(GhostData::getScore).reversed());
|
||||
ghostsByMode.put(mode, ghosts);
|
||||
}
|
||||
|
||||
// ── Saving ────────────────────────────────────────────────────────────────
|
||||
|
||||
public void saveGhost(@NotNull String mode, @NotNull GhostData data) {
|
||||
File modeFolder = new File(ghostFolder, sanitizeMode(mode));
|
||||
if (!modeFolder.exists()) modeFolder.mkdirs();
|
||||
|
||||
List<GhostData> ghosts = new ArrayList<>(ghostsByMode.getOrDefault(mode, new ArrayList<>()));
|
||||
ghosts.add(data);
|
||||
ghosts.sort(Comparator.comparingInt(GhostData::getScore).reversed());
|
||||
|
||||
// Evict lowest-score ghosts beyond the cap
|
||||
while (ghosts.size() > MAX_GHOSTS_PER_MODE) {
|
||||
GhostData evicted = ghosts.remove(ghosts.size() - 1);
|
||||
// File is named by UUID — always finds the right file regardless of name changes
|
||||
File evictedFile = ghostFile(modeFolder, evicted);
|
||||
if (evictedFile.exists()) evictedFile.delete();
|
||||
}
|
||||
|
||||
ghostsByMode.put(mode, ghosts);
|
||||
|
||||
// Save new ghost — named by UUID, not by player name
|
||||
File file = ghostFile(modeFolder, data);
|
||||
try {
|
||||
data.saveToFile(file);
|
||||
} catch (IOException e) {
|
||||
LoParkour.getPlugin().getLogger().severe(
|
||||
"Failed to save ghost for " + data.getPlayerName() + ": " + e.getMessage());
|
||||
}
|
||||
}
|
||||
|
||||
// ── Spawning ──────────────────────────────────────────────────────────────
|
||||
|
||||
public void spawnGhosts(@NotNull String mode, @NotNull Location startLocation,
|
||||
@NotNull World world) {
|
||||
if (!Config.CONFIG.getBoolean("ghost-mode.enabled")) return;
|
||||
|
||||
stopAllGhosts();
|
||||
|
||||
List<GhostData> ghosts = ghostsByMode.get(mode);
|
||||
if (ghosts == null || ghosts.isEmpty()) return;
|
||||
|
||||
int showTop = Math.min(Config.CONFIG.getInt("ghost-mode.show-top"), ghosts.size());
|
||||
for (int i = 0; i < showTop; i++) {
|
||||
GhostPlayer ghost = new GhostPlayer(ghosts.get(i), world);
|
||||
ghost.spawn(startLocation);
|
||||
activeGhosts.add(ghost);
|
||||
}
|
||||
}
|
||||
|
||||
public void stopAllGhosts() {
|
||||
activeGhosts.forEach(GhostPlayer::stop);
|
||||
activeGhosts.clear();
|
||||
}
|
||||
|
||||
// ── Queries ───────────────────────────────────────────────────────────────
|
||||
|
||||
@Nullable
|
||||
public GhostData getTopGhost(@NotNull String mode) {
|
||||
List<GhostData> ghosts = ghostsByMode.get(mode);
|
||||
return (ghosts == null || ghosts.isEmpty()) ? null : ghosts.get(0);
|
||||
}
|
||||
|
||||
public List<GhostData> getTopGhosts(@NotNull String mode, int count) {
|
||||
List<GhostData> ghosts = ghostsByMode.get(mode);
|
||||
if (ghosts == null || ghosts.isEmpty()) return Collections.emptyList();
|
||||
return Collections.unmodifiableList(ghosts.subList(0, Math.min(count, ghosts.size())));
|
||||
}
|
||||
|
||||
public boolean shouldRecordGhost(@NotNull String mode, int score) {
|
||||
List<GhostData> ghosts = ghostsByMode.get(mode);
|
||||
if (ghosts == null || ghosts.size() < MAX_GHOSTS_PER_MODE) return true;
|
||||
return score > ghosts.get(ghosts.size() - 1).getScore();
|
||||
}
|
||||
|
||||
// ── Internals ─────────────────────────────────────────────────────────────
|
||||
|
||||
/** Returns the ghost file for a run — always UUID-based, never name-based. */
|
||||
private File ghostFile(File modeFolder, GhostData data) {
|
||||
return new File(modeFolder, data.getPlayerUuid().toString() + ".ghost");
|
||||
}
|
||||
|
||||
/**
|
||||
* Strips any path separators from mode names so they can't be used to
|
||||
* escape the ghosts directory (e.g. mode name "../../etc").
|
||||
*/
|
||||
private static String sanitizeMode(String mode) {
|
||||
return mode.replaceAll("[/\\\\.]", "_");
|
||||
}
|
||||
}
|
||||
|
|
@ -1,78 +0,0 @@
|
|||
package dev.loki.loparkour.ghost;
|
||||
|
||||
import dev.lolib.scheduler.Scheduler;
|
||||
import dev.lolib.scheduler.ScheduledTask;
|
||||
import dev.loki.loparkour.LoParkour;
|
||||
import org.bukkit.Location;
|
||||
import org.bukkit.World;
|
||||
import org.bukkit.entity.ArmorStand;
|
||||
import org.bukkit.entity.EntityType;
|
||||
import org.jetbrains.annotations.NotNull;
|
||||
|
||||
/**
|
||||
* Spawns and animates an ArmorStand that replays a {@link GhostData} run.
|
||||
*
|
||||
* <p>Bugs fixed vs original:
|
||||
* <ul>
|
||||
* <li>Duplicate import of {@code dev.lolib.scheduler.Scheduler} — removed.</li>
|
||||
* <li>Duplicate field declaration {@code dev.lolib.scheduler.ScheduledTask task}
|
||||
* shadowed the already-imported type — removed the fully-qualified duplicate.</li>
|
||||
* </ul>
|
||||
*/
|
||||
public class GhostPlayer {
|
||||
|
||||
private final GhostData data;
|
||||
private final World world;
|
||||
private ArmorStand entity;
|
||||
private ScheduledTask task;
|
||||
private int currentFrame = 0;
|
||||
private long startTime;
|
||||
|
||||
public GhostPlayer(@NotNull GhostData data, @NotNull World world) {
|
||||
this.data = data;
|
||||
this.world = world;
|
||||
}
|
||||
|
||||
public void spawn(@NotNull Location startLocation) {
|
||||
if (data.getFrames().isEmpty()) return;
|
||||
|
||||
entity = (ArmorStand) world.spawnEntity(startLocation, EntityType.ARMOR_STAND);
|
||||
entity.setVisible(false);
|
||||
entity.setGravity(false);
|
||||
entity.setInvulnerable(true);
|
||||
entity.setCustomName("§7" + data.getPlayerName() + " §8(Ghost)");
|
||||
entity.setCustomNameVisible(true);
|
||||
|
||||
startTime = System.currentTimeMillis();
|
||||
currentFrame = 0;
|
||||
|
||||
task = Scheduler.get(LoParkour.getPlugin()).runTimer(this::update, 0, 1);
|
||||
}
|
||||
|
||||
private void update() {
|
||||
if (entity == null || !entity.isValid()) {
|
||||
stop();
|
||||
return;
|
||||
}
|
||||
|
||||
long elapsed = System.currentTimeMillis() - startTime;
|
||||
|
||||
while (currentFrame < data.getFrames().size()) {
|
||||
GhostFrame frame = data.getFrames().get(currentFrame);
|
||||
if (frame.getTimestamp() > elapsed) break;
|
||||
entity.teleport(frame.toLocation(world));
|
||||
currentFrame++;
|
||||
}
|
||||
|
||||
if (currentFrame >= data.getFrames().size()) stop();
|
||||
}
|
||||
|
||||
public void stop() {
|
||||
if (task != null) { task.cancel(); task = null; }
|
||||
if (entity != null) { entity.remove(); entity = null; }
|
||||
}
|
||||
|
||||
public boolean isActive() { return entity != null && entity.isValid(); }
|
||||
public String getPlayerName() { return data.getPlayerName(); }
|
||||
public int getScore() { return data.getScore(); }
|
||||
}
|
||||
|
|
@ -1,61 +0,0 @@
|
|||
package dev.loki.loparkour.ghost;
|
||||
|
||||
import org.bukkit.Location;
|
||||
import org.jetbrains.annotations.NotNull;
|
||||
|
||||
import java.util.ArrayList;
|
||||
import java.util.List;
|
||||
import java.util.UUID;
|
||||
|
||||
/**
|
||||
* Records a player's movement as a sequence of {@link GhostFrame}s.
|
||||
*
|
||||
* <p>Bugs fixed vs original:
|
||||
* <ul>
|
||||
* <li>{@code stopRecording} took only {@code playerName} — now also takes {@code playerUuid}
|
||||
* so {@link GhostData} can store it and {@link GhostManager} can name files by UUID.</li>
|
||||
* </ul>
|
||||
*/
|
||||
public class GhostRecorder {
|
||||
|
||||
private static final double MIN_MOVEMENT = 0.1;
|
||||
|
||||
private final List<GhostFrame> frames = new ArrayList<>();
|
||||
private Location lastLocation;
|
||||
private long startTime;
|
||||
private boolean recording = false;
|
||||
|
||||
public void startRecording(@NotNull Location startLocation) {
|
||||
frames.clear();
|
||||
lastLocation = startLocation.clone();
|
||||
startTime = System.currentTimeMillis();
|
||||
recording = true;
|
||||
frames.add(new GhostFrame(0, startLocation.clone()));
|
||||
}
|
||||
|
||||
public void recordFrame(@NotNull Location currentLocation) {
|
||||
if (!recording) return;
|
||||
if (lastLocation.distance(currentLocation) < MIN_MOVEMENT) return;
|
||||
|
||||
long timestamp = System.currentTimeMillis() - startTime;
|
||||
frames.add(new GhostFrame(timestamp, currentLocation.clone()));
|
||||
lastLocation = currentLocation.clone();
|
||||
}
|
||||
|
||||
/**
|
||||
* Stops recording and returns the completed ghost data.
|
||||
*
|
||||
* @param playerUuid UUID of the player — used as the filename in {@link GhostManager}.
|
||||
* @param playerName Display name shown on the ghost label in-game.
|
||||
* @param score The score achieved this run.
|
||||
*/
|
||||
public GhostData stopRecording(@NotNull UUID playerUuid,
|
||||
@NotNull String playerName,
|
||||
int score) {
|
||||
recording = false;
|
||||
return new GhostData(playerUuid, playerName, score, frames);
|
||||
}
|
||||
|
||||
public boolean isRecording() { return recording; }
|
||||
public int getFrameCount() { return frames.size(); }
|
||||
}
|
||||
|
|
@ -1,192 +0,0 @@
|
|||
package dev.loki.loparkour.mode;
|
||||
|
||||
import dev.loki.loparkour.config.Config;
|
||||
import dev.loki.loparkour.config.Locales;
|
||||
import dev.loki.loparkour.generator.ParkourGenerator;
|
||||
import dev.loki.loparkour.leaderboard.Leaderboard;
|
||||
import dev.loki.loparkour.menu.Menus;
|
||||
import dev.loki.loparkour.player.ParkourPlayer;
|
||||
import dev.loki.loparkour.player.ParkourUser;
|
||||
import dev.loki.loparkour.session.Session;
|
||||
import dev.loki.loparkour.world.Divider;
|
||||
import org.bukkit.Sound;
|
||||
import org.bukkit.entity.Player;
|
||||
import org.jetbrains.annotations.NotNull;
|
||||
import org.jetbrains.annotations.Nullable;
|
||||
|
||||
import java.util.HashMap;
|
||||
import java.util.Map;
|
||||
import java.util.UUID;
|
||||
|
||||
/**
|
||||
* Co-op Mode — multiple players share one session and one score.
|
||||
*
|
||||
* <p>Implements {@link MultiMode} so it is excluded from the single-player
|
||||
* mode selection menu in {@link dev.loki.loparkour.menu.play.SingleMenu}.
|
||||
* Players join via /parkour coop or through a lobby invite flow.</p>
|
||||
*
|
||||
* <p>Problems fixed vs previous version:
|
||||
* <ul>
|
||||
* <li>Did NOT implement MultiMode → showed up in single-player menu</li>
|
||||
* <li>CoopGenerator was a private inner class — inaccessible from join/leave</li>
|
||||
* <li>sharedScore was a separate field that duplicated state.score</li>
|
||||
* <li>fall() called super.fall() THEN tried to clear state that was already reset</li>
|
||||
* <li>playerContributions never cleared between falls correctly</li>
|
||||
* <li>create(Player) silently created a solo session instead of rejecting</li>
|
||||
* </ul>
|
||||
*/
|
||||
public class CoopMode implements MultiMode {
|
||||
|
||||
private final Leaderboard leaderboard = new Leaderboard(getName(), Leaderboard.Sort.SCORE);
|
||||
|
||||
// Active coop sessions — used so join() can add to an existing one
|
||||
// Key = session, tracked via Divider automatically; this is just for lookup
|
||||
// We look up by existing player's session instead.
|
||||
|
||||
@Override
|
||||
@NotNull
|
||||
public String getName() {
|
||||
return "coop";
|
||||
}
|
||||
|
||||
/**
|
||||
* Returns null so this mode never appears in any menu item list.
|
||||
* Players join via lobby invite or command only.
|
||||
*/
|
||||
@Override
|
||||
@Nullable
|
||||
public dev.loki.loparkour.util.Item getItem(String locale) {
|
||||
return null;
|
||||
}
|
||||
|
||||
@Override
|
||||
@NotNull
|
||||
public Leaderboard getLeaderboard() {
|
||||
return leaderboard;
|
||||
}
|
||||
|
||||
/**
|
||||
* Called when a player tries to start coop solo from a command/menu.
|
||||
* Opens the lobby menu so they can invite others — does NOT create a session here.
|
||||
* A session is created only once a second player joins via {@link #join}.
|
||||
*/
|
||||
@Override
|
||||
public void create(Player player) {
|
||||
if (!Config.CONFIG.getBoolean("joining")) {
|
||||
player.sendMessage("§cJoining is currently disabled.");
|
||||
return;
|
||||
}
|
||||
// Opening lobby menu lets player invite others — actual session created in join()
|
||||
Menus.LOBBY.open(player);
|
||||
}
|
||||
|
||||
/**
|
||||
* Adds a player to an existing coop session, or creates a new session if
|
||||
* the target player is not yet in one.
|
||||
*/
|
||||
@Override
|
||||
public void join(Player joiningPlayer, Session session) {
|
||||
if (!Config.CONFIG.getBoolean("joining")) {
|
||||
joiningPlayer.sendMessage("§cJoining is currently disabled.");
|
||||
return;
|
||||
}
|
||||
|
||||
if (session.generator == null) return;
|
||||
|
||||
// Register the joining player into the existing session
|
||||
ParkourPlayer pp = ParkourUser.register(joiningPlayer, session);
|
||||
session.addPlayers(pp);
|
||||
pp.updateGeneratorSettings(session.generator);
|
||||
|
||||
// Inform everyone
|
||||
session.getPlayers().forEach(p ->
|
||||
p.sendTranslated("modes.coop.player_joined", joiningPlayer.getName()));
|
||||
}
|
||||
|
||||
/**
|
||||
* Removes a player from a coop session.
|
||||
* If the last player leaves, the session ends normally.
|
||||
*/
|
||||
@Override
|
||||
public void leave(Player player, Session session) {
|
||||
ParkourPlayer pp = ParkourPlayer.getPlayer(player);
|
||||
if (pp == null) return;
|
||||
ParkourUser.leave(pp);
|
||||
}
|
||||
|
||||
@Override
|
||||
public int getMaxPlayers() {
|
||||
return Config.CONFIG.getInt("modes.coop.max-players");
|
||||
}
|
||||
|
||||
// ── CoopGenerator ─────────────────────────────────────────────────────────
|
||||
|
||||
/**
|
||||
* Package-accessible so Session.create() lambda can reference it,
|
||||
* and so join() can cast generator when needed.
|
||||
*/
|
||||
static class CoopGenerator extends ParkourGenerator {
|
||||
|
||||
/** Individual block contributions per player UUID. */
|
||||
private final Map<UUID, Integer> contributions = new HashMap<>();
|
||||
|
||||
public CoopGenerator(@NotNull Session session) {
|
||||
super(session);
|
||||
session.getPlayers().forEach(pp -> contributions.put(pp.getUUID(), 0));
|
||||
}
|
||||
|
||||
@Override
|
||||
protected void score() {
|
||||
super.score(); // increments state.score and totalScore
|
||||
|
||||
// Track which player was on the block (primary player for single, varies in multi)
|
||||
contributions.merge(player.getUUID(), 1, Integer::sum);
|
||||
|
||||
// Milestone every 50 points
|
||||
if (state.score % 50 == 0) {
|
||||
getPlayers().forEach(pp -> {
|
||||
pp.player.sendTitle(
|
||||
"§6Team: §e" + state.score,
|
||||
"§aKeep it up!",
|
||||
5, 40, 10);
|
||||
pp.player.playSound(pp.getLocation(),
|
||||
Sound.ENTITY_PLAYER_LEVELUP, 0.6f, 1.5f);
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* When a new player joins mid-game, add them to contributions map.
|
||||
*/
|
||||
public void onPlayerJoin(ParkourPlayer pp) {
|
||||
contributions.putIfAbsent(pp.getUUID(), 0);
|
||||
}
|
||||
|
||||
@Override
|
||||
protected void fall() {
|
||||
// Show each player their individual contribution before reset
|
||||
getPlayers().forEach(pp -> {
|
||||
int contrib = contributions.getOrDefault(pp.getUUID(), 0);
|
||||
int pct = state.score > 0 ? (contrib * 100 / state.score) : 0;
|
||||
pp.sendTranslated("modes.coop.stats",
|
||||
Integer.toString(state.score),
|
||||
Integer.toString(contrib),
|
||||
Integer.toString(pct));
|
||||
});
|
||||
|
||||
super.fall(); // handles reset, leaderboard, etc.
|
||||
}
|
||||
|
||||
@Override
|
||||
public void reset(boolean regenerate) {
|
||||
super.reset(regenerate);
|
||||
contributions.clear();
|
||||
getPlayers().forEach(pp -> contributions.put(pp.getUUID(), 0));
|
||||
}
|
||||
|
||||
@Override
|
||||
public Mode getMode() {
|
||||
return Modes.COOP;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -1,750 +0,0 @@
|
|||
package dev.loki.loparkour.mode;
|
||||
|
||||
import dev.lolib.scheduler.Scheduler;
|
||||
import dev.lolib.scheduler.ScheduledTask;
|
||||
import dev.loki.loparkour.LoParkour;
|
||||
import dev.loki.loparkour.api.event.ParkourFallEvent;
|
||||
import dev.loki.loparkour.api.event.ParkourScoreEvent;
|
||||
import dev.loki.loparkour.config.Config;
|
||||
import dev.loki.loparkour.generator.ParkourGenerator;
|
||||
import dev.loki.loparkour.leaderboard.Leaderboard;
|
||||
import dev.loki.loparkour.leaderboard.Score;
|
||||
import dev.loki.loparkour.player.ParkourPlayer;
|
||||
import dev.loki.loparkour.session.Session;
|
||||
import dev.loki.loparkour.world.Divider;
|
||||
import org.bukkit.*;
|
||||
import org.bukkit.block.Block;
|
||||
import org.bukkit.entity.Firework;
|
||||
import org.bukkit.entity.Player;
|
||||
import org.bukkit.event.EventHandler;
|
||||
import org.bukkit.event.EventPriority;
|
||||
import org.bukkit.event.HandlerList;
|
||||
import org.bukkit.event.Listener;
|
||||
import org.bukkit.event.player.PlayerInteractEvent;
|
||||
import org.bukkit.inventory.ItemStack;
|
||||
import org.bukkit.inventory.meta.FireworkMeta;
|
||||
import org.bukkit.util.Vector;
|
||||
import org.jetbrains.annotations.NotNull;
|
||||
|
||||
import java.time.Instant;
|
||||
import java.time.ZoneOffset;
|
||||
import java.time.format.DateTimeFormatter;
|
||||
import java.util.*;
|
||||
import java.util.concurrent.ThreadLocalRandom;
|
||||
|
||||
/**
|
||||
* Generator for Elytra mode.
|
||||
*
|
||||
* <p>Completely replaces block-based parkour with an aerial ring course:
|
||||
* <ul>
|
||||
* <li>Rings are placed ahead along a smooth curved path in the sky</li>
|
||||
* <li>Each ring is vertical, perpendicular to the flight direction</li>
|
||||
* <li>Player must fly through the center — detection uses plane-crossing math</li>
|
||||
* <li>Firework usage is intercepted via event to enforce cooldown properly</li>
|
||||
* <li>Fall = player touches ground / loses flight / flies too far from course</li>
|
||||
* </ul>
|
||||
*/
|
||||
public class ElytraGenerator extends ParkourGenerator implements Listener {
|
||||
|
||||
// ── Constants ─────────────────────────────────────────────────────────────
|
||||
|
||||
/** How many rings behind the player to keep before deleting. */
|
||||
private static final int RING_TRAIL = 3;
|
||||
|
||||
/** Ticks between particle ring renders (every 2 ticks = 10fps). */
|
||||
private static final int RENDER_INTERVAL = 2;
|
||||
|
||||
/** Minimum Y height — if player drops below this they failed. */
|
||||
private static final double MIN_Y_OFFSET = -30.0;
|
||||
|
||||
// Config-driven values cached at init
|
||||
private int ringLead;
|
||||
private double maxDeviation;
|
||||
|
||||
/** Particle count per ring point. */
|
||||
private static final int PARTICLES_PER_POINT = 1;
|
||||
|
||||
/** Number of particle points around each ring. */
|
||||
private static final int RING_POINTS = 36;
|
||||
|
||||
// ── State ─────────────────────────────────────────────────────────────────
|
||||
|
||||
private final List<ElytraRing> rings = new ArrayList<>();
|
||||
private int nextRingIndex = 0; // index of the next ring the player hasn't passed
|
||||
private int tickCounter = 0;
|
||||
private Instant startTime = null;
|
||||
private int score = 0;
|
||||
|
||||
private ScheduledTask tickTask;
|
||||
private ScheduledTask renderTask;
|
||||
|
||||
/** Firework cooldown per player UUID in ms. */
|
||||
private final Map<UUID, Long> fireworkCooldowns = new HashMap<>();
|
||||
|
||||
/** Starting Y for fall detection. */
|
||||
private double spawnY;
|
||||
|
||||
// ── Constructor ───────────────────────────────────────────────────────────
|
||||
|
||||
public ElytraGenerator(@NotNull Session session) {
|
||||
super(session);
|
||||
this.ringLead = Config.CONFIG.getInt("modes.elytra.ring-lead");
|
||||
this.maxDeviation = Config.CONFIG.getDouble("modes.elytra.max-deviation");
|
||||
}
|
||||
|
||||
// ── Mode identification ───────────────────────────────────────────────────
|
||||
|
||||
@Override
|
||||
public Mode getMode() {
|
||||
return Modes.ELYTRA;
|
||||
}
|
||||
|
||||
// ── Lifecycle: called by Session after island.build() ────────────────────
|
||||
|
||||
/**
|
||||
* Called once when the generator is set up.
|
||||
* We skip the block-based generateFirst and do our own aerial setup.
|
||||
*/
|
||||
@Override
|
||||
public void generateFirst(Location spawn, Location blockSpawn) {
|
||||
spawnY = spawn.getY();
|
||||
state.playerSpawn = spawn;
|
||||
state.lastStandingPlayerLocation = spawn;
|
||||
state.blockSpawn = blockSpawn;
|
||||
|
||||
// Seed the ring path starting just in front of spawn
|
||||
Vector initialHeading = state.heading.clone().normalize();
|
||||
Location ringOrigin = spawn.clone().add(initialHeading.clone().multiply(10)).add(0, 2, 0);
|
||||
|
||||
generateRings(ringOrigin, initialHeading, ringLead);
|
||||
|
||||
// Setup player
|
||||
for (ParkourPlayer pp : getPlayers()) {
|
||||
Player p = pp.player;
|
||||
p.setGameMode(GameMode.ADVENTURE);
|
||||
p.getInventory().clear();
|
||||
|
||||
// Elytra chestplate
|
||||
p.getInventory().setChestplate(new ItemStack(Material.ELYTRA));
|
||||
|
||||
// Starting fireworks
|
||||
int amount = Config.CONFIG.getInt("modes.elytra.starting-fireworks");
|
||||
p.getInventory().setItem(0, makeFirework(amount));
|
||||
p.getInventory().setHeldItemSlot(0);
|
||||
|
||||
// Launch into glide
|
||||
p.setVelocity(initialHeading.clone().multiply(1.2).setY(0.4));
|
||||
p.setGliding(true);
|
||||
|
||||
pp.sendTranslated("modes.elytra.start_hint");
|
||||
}
|
||||
|
||||
// Register firework event listener
|
||||
Bukkit.getPluginManager().registerEvents(this, LoParkour.getPlugin());
|
||||
|
||||
// Start ticks
|
||||
tickTask = Scheduler.get(LoParkour.getPlugin()).runTimer(this::tick, 1, 1);
|
||||
renderTask = Scheduler.get(LoParkour.getPlugin()).runTimer(this::renderRings, 0, RENDER_INTERVAL);
|
||||
}
|
||||
|
||||
// ── 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 ─────────────────────────────────────────────────────────────
|
||||
|
||||
private void tick() {
|
||||
if (state.stopped) {
|
||||
stopTasks();
|
||||
return;
|
||||
}
|
||||
|
||||
tickCounter++;
|
||||
|
||||
for (ParkourPlayer pp : getPlayers()) {
|
||||
Player p = pp.player;
|
||||
|
||||
// Ensure player stays gliding
|
||||
if (!p.isGliding() && p.getGameMode() == GameMode.ADVENTURE) {
|
||||
// Only force-glide if they're not on ground and have elytra
|
||||
if (!p.isOnGround()) {
|
||||
p.setGliding(true);
|
||||
}
|
||||
}
|
||||
|
||||
// Update scoreboard
|
||||
pp.updateScoreboard(this);
|
||||
p.setSaturation(20);
|
||||
|
||||
// Action bar: score + next ring distance
|
||||
updateActionBar(pp);
|
||||
}
|
||||
|
||||
// Fall detection
|
||||
checkFall();
|
||||
|
||||
// Ring crossing detection
|
||||
checkRingCrossings();
|
||||
}
|
||||
|
||||
// ── Ring generation ───────────────────────────────────────────────────────
|
||||
|
||||
/**
|
||||
* Generates {@code count} new rings starting from {@code origin},
|
||||
* heading in {@code direction}, with smooth curve randomisation.
|
||||
*/
|
||||
private void generateRings(Location origin, Vector direction, int count) {
|
||||
int distMin = Config.CONFIG.getInt("modes.elytra.ring-distance-min");
|
||||
int distMax = Config.CONFIG.getInt("modes.elytra.ring-distance-max");
|
||||
double ringSize = Config.CONFIG.getDouble("modes.elytra.ring-size");
|
||||
double maxYaw = Config.CONFIG.getDouble("modes.elytra.max-heading-change-horizontal");
|
||||
double maxPitch = Config.CONFIG.getDouble("modes.elytra.max-heading-change-vertical");
|
||||
double maxHeight = Config.CONFIG.getDouble("modes.elytra.max-height-above-spawn");
|
||||
|
||||
ThreadLocalRandom rng = ThreadLocalRandom.current();
|
||||
Location cursor = origin.clone();
|
||||
Vector heading = direction.clone().normalize();
|
||||
|
||||
// If we already have rings, continue from the last one
|
||||
if (!rings.isEmpty()) {
|
||||
ElytraRing last = rings.get(rings.size() - 1);
|
||||
cursor = last.center.clone();
|
||||
heading = last.normal.clone();
|
||||
}
|
||||
|
||||
for (int i = 0; i < count; i++) {
|
||||
// Distance between rings
|
||||
double dist = distMin + rng.nextDouble() * (distMax - distMin);
|
||||
|
||||
// Smooth heading change using config-defined limits
|
||||
double yawDelta = Math.toRadians(rng.nextDouble(-maxYaw, maxYaw));
|
||||
double pitchDelta = Math.toRadians(rng.nextDouble(-maxPitch, maxPitch));
|
||||
|
||||
heading = rotateVector(heading, yawDelta, pitchDelta);
|
||||
heading.normalize();
|
||||
|
||||
// New ring center
|
||||
Location center = cursor.clone().add(heading.clone().multiply(dist));
|
||||
|
||||
// Keep rings in a reasonable Y band
|
||||
double targetY = Math.max(spawnY + 5, Math.min(spawnY + maxHeight, center.getY()));
|
||||
center.setY(targetY);
|
||||
|
||||
rings.add(new ElytraRing(rings.size(), center.clone(), heading.clone(), ringSize));
|
||||
cursor = center;
|
||||
}
|
||||
}
|
||||
|
||||
// ── Ring crossing detection ───────────────────────────────────────────────
|
||||
|
||||
private void checkRingCrossings() {
|
||||
if (rings.isEmpty() || nextRingIndex >= rings.size()) return;
|
||||
|
||||
ParkourPlayer pp = player; // primary player
|
||||
Location loc = pp.getLocation();
|
||||
|
||||
ElytraRing nextRing = rings.get(nextRingIndex);
|
||||
|
||||
// Check if player crossed the ring's plane
|
||||
if (nextRing.hasCrossed(loc, pp.player.getVelocity())) {
|
||||
if (nextRing.isInside(loc)) {
|
||||
// Perfect pass — score
|
||||
onRingPassed(pp, nextRing, true);
|
||||
} else {
|
||||
// Crossed the plane but missed the ring hole
|
||||
onRingMissed(pp, nextRing);
|
||||
}
|
||||
nextRingIndex++;
|
||||
|
||||
// Generate more rings ahead
|
||||
int ahead = rings.size() - nextRingIndex;
|
||||
if (ahead < ringLead) {
|
||||
generateRings(null, null, ringLead - ahead);
|
||||
}
|
||||
|
||||
// Clean up old rings
|
||||
cleanOldRings();
|
||||
}
|
||||
|
||||
// Check deviation — if player flew too far off course
|
||||
double deviation = nextRing.center.distance(loc);
|
||||
if (deviation > maxDeviation) {
|
||||
triggerFall("flew off course");
|
||||
}
|
||||
}
|
||||
|
||||
private void onRingPassed(ParkourPlayer pp, ElytraRing ring, boolean centered) {
|
||||
score++;
|
||||
if (startTime == null) startTime = Instant.now();
|
||||
|
||||
new ParkourScoreEvent(pp).call();
|
||||
|
||||
// Visual + sound feedback
|
||||
pp.player.spawnParticle(Particle.TOTEM, ring.center, 40, 0.3, 0.3, 0.3, 0.15);
|
||||
pp.player.playSound(ring.center, Sound.ENTITY_EXPERIENCE_ORB_PICKUP, 1f, centered ? 1.8f : 1.2f);
|
||||
|
||||
// Update state score so scoreboard works
|
||||
state.score = score;
|
||||
state.totalScore++;
|
||||
}
|
||||
|
||||
private void onRingMissed(ParkourPlayer pp, ElytraRing ring) {
|
||||
// Flash red to indicate miss — no score
|
||||
pp.player.spawnParticle(Particle.REDSTONE,
|
||||
ring.center, 20, 0.5, 0.5, 0.5, 0,
|
||||
new Particle.DustOptions(Color.RED, 2.0f));
|
||||
pp.player.playSound(ring.center, Sound.BLOCK_NOTE_BLOCK_BASS, 1f, 0.5f);
|
||||
}
|
||||
|
||||
// ── Fall / Reset ──────────────────────────────────────────────────────────
|
||||
|
||||
private void checkFall() {
|
||||
for (ParkourPlayer pp : getPlayers()) {
|
||||
Player p = pp.player;
|
||||
double y = p.getLocation().getY();
|
||||
|
||||
// Touched ground or fell too far below spawn
|
||||
if (p.isOnGround() || y < spawnY + MIN_Y_OFFSET) {
|
||||
triggerFall("touched ground or fell below course");
|
||||
return;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private void triggerFall(String reason) {
|
||||
LoParkour.log("ElytraGenerator fall: " + reason);
|
||||
new ParkourFallEvent(player).call();
|
||||
|
||||
// Save score to leaderboard if it's a new record
|
||||
Leaderboard lb = getMode().getLeaderboard();
|
||||
if (lb != null) {
|
||||
int record = lb.get(player.getUUID()).score();
|
||||
if (score > record) {
|
||||
String time = getFormattedTime();
|
||||
getPlayers().forEach(p -> lb.put(p.getUUID(),
|
||||
new Score(p.getName(), time, "0.0", score)));
|
||||
}
|
||||
}
|
||||
|
||||
// Show fall message
|
||||
sendFallMessage();
|
||||
|
||||
reset(true);
|
||||
}
|
||||
|
||||
private void sendFallMessage() {
|
||||
Leaderboard lb = getMode().getLeaderboard();
|
||||
int record = lb != null ? lb.get(player.getUUID()).score() : 0;
|
||||
|
||||
for (ParkourPlayer pp : getPlayers()) {
|
||||
pp.sendTranslated("settings.parkour_settings.items.fall_message.divider");
|
||||
pp.sendTranslated("settings.parkour_settings.items.fall_message.score", Integer.toString(score));
|
||||
pp.sendTranslated("settings.parkour_settings.items.fall_message.time", getFormattedTime());
|
||||
pp.sendTranslated("settings.parkour_settings.items.fall_message.high_score", Integer.toString(record));
|
||||
if (score > record) {
|
||||
pp.sendTranslated("settings.parkour_settings.items.fall_message.formats.beat",
|
||||
Integer.toString(score - record));
|
||||
} else if (score == record) {
|
||||
pp.sendTranslated("settings.parkour_settings.items.fall_message.formats.tied", "0");
|
||||
} else {
|
||||
pp.sendTranslated("settings.parkour_settings.items.fall_message.formats.miss",
|
||||
Integer.toString(record - score));
|
||||
}
|
||||
pp.sendTranslated("settings.parkour_settings.items.fall_message.divider");
|
||||
}
|
||||
}
|
||||
|
||||
@Override
|
||||
public void reset(boolean regenerate) {
|
||||
state.stopped = !regenerate;
|
||||
|
||||
// Clear rings from world (remove barrier blocks)
|
||||
rings.forEach(ElytraRing::remove);
|
||||
rings.clear();
|
||||
nextRingIndex = 0;
|
||||
score = 0;
|
||||
state.score = 0;
|
||||
startTime = null;
|
||||
state.start = null;
|
||||
tickCounter = 0;
|
||||
fireworkCooldowns.clear();
|
||||
|
||||
if (!regenerate) {
|
||||
stopTasks();
|
||||
unregisterListener();
|
||||
island.destroy();
|
||||
if (getPlayers().isEmpty()) {
|
||||
getSpectators().forEach(s -> Modes.DEFAULT.create(s.player));
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
// Teleport back to spawn and restart
|
||||
for (ParkourPlayer pp : getPlayers()) {
|
||||
pp.teleport(state.playerSpawn);
|
||||
pp.player.setGliding(false);
|
||||
pp.player.setVelocity(new Vector(0, 0, 0));
|
||||
|
||||
// Restore fireworks
|
||||
int amount = Config.CONFIG.getInt("modes.elytra.starting-fireworks");
|
||||
pp.player.getInventory().clear();
|
||||
pp.player.getInventory().setChestplate(new ItemStack(Material.ELYTRA));
|
||||
pp.player.getInventory().setItem(0, makeFirework(amount));
|
||||
pp.player.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) {
|
||||
World world = ring.center.getWorld();
|
||||
if (world == null) return;
|
||||
|
||||
// Two orthogonal vectors in the ring's plane
|
||||
Vector normal = ring.normal.clone().normalize();
|
||||
Vector up = findPerpendicular(normal);
|
||||
Vector right = normal.clone().crossProduct(up).normalize();
|
||||
|
||||
double r = ring.radius;
|
||||
Particle.DustOptions dust = new Particle.DustOptions(color, 1.5f);
|
||||
|
||||
for (int i = 0; i < RING_POINTS; i++) {
|
||||
double angle = 2 * Math.PI * i / RING_POINTS;
|
||||
double x = r * Math.cos(angle);
|
||||
double y = r * Math.sin(angle);
|
||||
|
||||
Location p = ring.center.clone()
|
||||
.add(right.clone().multiply(x))
|
||||
.add(up.clone().multiply(y));
|
||||
|
||||
world.spawnParticle(Particle.REDSTONE, p, PARTICLES_PER_POINT, 0, 0, 0, 0, dust);
|
||||
}
|
||||
|
||||
// Inner ring (smaller, brighter) so it's visible
|
||||
Particle.DustOptions innerDust = new Particle.DustOptions(Color.WHITE, 0.8f);
|
||||
double innerR = r * 0.4;
|
||||
for (int i = 0; i < RING_POINTS / 2; i++) {
|
||||
double angle = 2 * Math.PI * i / (RING_POINTS / 2);
|
||||
double x = innerR * Math.cos(angle);
|
||||
double y = innerR * Math.sin(angle);
|
||||
|
||||
Location p = ring.center.clone()
|
||||
.add(right.clone().multiply(x))
|
||||
.add(up.clone().multiply(y));
|
||||
|
||||
world.spawnParticle(Particle.REDSTONE, p, PARTICLES_PER_POINT, 0, 0, 0, 0, innerDust);
|
||||
}
|
||||
}
|
||||
|
||||
// ── Firework event — cancel if on cooldown ────────────────────────────────
|
||||
|
||||
@EventHandler(priority = EventPriority.HIGH)
|
||||
public void onFireworkUse(PlayerInteractEvent event) {
|
||||
Player p = event.getPlayer();
|
||||
ParkourPlayer pp = ParkourPlayer.getPlayer(p);
|
||||
if (pp == null || !(pp.session.generator instanceof ElytraGenerator gen)) return;
|
||||
if (gen != this) return;
|
||||
|
||||
// Only care about firework rockets
|
||||
org.bukkit.inventory.ItemStack item = event.getItem();
|
||||
if (item == null || item.getType() != Material.FIREWORK_ROCKET) return;
|
||||
|
||||
boolean isRightClick = event.getAction() == org.bukkit.event.block.Action.RIGHT_CLICK_AIR
|
||||
|| event.getAction() == org.bukkit.event.block.Action.RIGHT_CLICK_BLOCK;
|
||||
if (!isRightClick) return;
|
||||
if (!p.isGliding()) return;
|
||||
|
||||
long now = System.currentTimeMillis();
|
||||
int cooldownMs = Config.CONFIG.getInt("modes.elytra.firework-cooldown-ms");
|
||||
UUID uuid = p.getUniqueId();
|
||||
|
||||
Long lastUse = fireworkCooldowns.get(uuid);
|
||||
if (lastUse != null && now - lastUse < cooldownMs) {
|
||||
event.setCancelled(true);
|
||||
long remaining = (cooldownMs - (now - lastUse) + 999) / 1000;
|
||||
p.spigot().sendMessage(
|
||||
net.md_5.bungee.api.ChatMessageType.ACTION_BAR,
|
||||
net.md_5.bungee.api.chat.TextComponent.fromLegacyText(
|
||||
"§c⏱ Firework cooldown: §f" + remaining + "s"));
|
||||
return;
|
||||
}
|
||||
|
||||
fireworkCooldowns.put(uuid, now);
|
||||
|
||||
// Spawn a boost firework at player location instead of vanilla behaviour
|
||||
event.setCancelled(true);
|
||||
spawnBoostFirework(p);
|
||||
}
|
||||
|
||||
/**
|
||||
* 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 ──────────────────────────────────────────────────────────
|
||||
|
||||
private 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) {
|
||||
ItemStack item = new ItemStack(Material.FIREWORK_ROCKET, amount);
|
||||
FireworkMeta meta = (FireworkMeta) item.getItemMeta();
|
||||
if (meta != null) {
|
||||
meta.addEffect(FireworkEffect.builder()
|
||||
.with(FireworkEffect.Type.BALL)
|
||||
.withColor(Color.AQUA)
|
||||
.build());
|
||||
meta.setPower(1);
|
||||
item.setItemMeta(meta);
|
||||
}
|
||||
return item;
|
||||
}
|
||||
|
||||
// ── Inner class: ElytraRing ───────────────────────────────────────────────
|
||||
|
||||
/**
|
||||
* Represents one ring in the course.
|
||||
*
|
||||
* <p>A ring is defined by its center point and its normal vector (the direction
|
||||
* of travel through it). The "inside" is a circle of {@code radius} around the center
|
||||
* on the plane perpendicular to {@code normal}.
|
||||
*/
|
||||
public static class ElytraRing {
|
||||
|
||||
public final int id;
|
||||
public final Location center;
|
||||
/** Direction of travel — the ring faces perpendicular to this. */
|
||||
public final Vector normal;
|
||||
public final double radius;
|
||||
|
||||
/** Signed distance of the player on the last tick (for plane crossing detection). */
|
||||
private double lastSignedDist = Double.NaN;
|
||||
|
||||
/** Barrier blocks placed for this ring (optional, used if solid rings are enabled). */
|
||||
private final List<Block> barrierBlocks = new ArrayList<>();
|
||||
|
||||
public ElytraRing(int id, Location center, Vector normal, double radius) {
|
||||
this.id = id;
|
||||
this.center = center.clone();
|
||||
this.normal = normal.clone().normalize();
|
||||
this.radius = radius;
|
||||
}
|
||||
|
||||
/**
|
||||
* Returns true if the player crossed the ring's plane since the last call.
|
||||
* Uses the dot product of (playerPos - ringCenter) with the ring's normal.
|
||||
* Sign change = crossing.
|
||||
*/
|
||||
public boolean hasCrossed(Location playerLoc, Vector velocity) {
|
||||
Vector toPlayer = playerLoc.toVector().subtract(center.toVector());
|
||||
double signedDist = toPlayer.dot(normal);
|
||||
|
||||
boolean crossed = false;
|
||||
if (!Double.isNaN(lastSignedDist)) {
|
||||
// Crossed if sign flipped (was positive, now negative or vice versa)
|
||||
crossed = (lastSignedDist > 0) != (signedDist > 0);
|
||||
}
|
||||
lastSignedDist = signedDist;
|
||||
return crossed;
|
||||
}
|
||||
|
||||
/**
|
||||
* Returns true if the player's position (projected onto the ring's plane)
|
||||
* is within the ring's radius — i.e. they flew through the hole.
|
||||
*/
|
||||
public boolean isInside(Location playerLoc) {
|
||||
// Project player position onto ring's plane
|
||||
Vector toPlayer = playerLoc.toVector().subtract(center.toVector());
|
||||
// Remove the component along the normal
|
||||
Vector projected = toPlayer.clone().subtract(normal.clone().multiply(toPlayer.dot(normal)));
|
||||
return projected.length() <= radius;
|
||||
}
|
||||
|
||||
/** Remove any barrier blocks this ring placed in the world. */
|
||||
public void remove() {
|
||||
barrierBlocks.forEach(b -> b.setType(Material.AIR, false));
|
||||
barrierBlocks.clear();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -1,55 +0,0 @@
|
|||
package dev.loki.loparkour.mode;
|
||||
|
||||
import dev.loki.loparkour.config.Config;
|
||||
import dev.loki.loparkour.config.Locales;
|
||||
import dev.loki.loparkour.leaderboard.Leaderboard;
|
||||
import dev.loki.loparkour.player.ParkourPlayer;
|
||||
import dev.loki.loparkour.session.Session;
|
||||
import dev.loki.loparkour.util.Item;
|
||||
import org.bukkit.entity.Player;
|
||||
import org.jetbrains.annotations.NotNull;
|
||||
import org.jetbrains.annotations.Nullable;
|
||||
|
||||
/**
|
||||
* Elytra Parkour Mode.
|
||||
* Players fly through vertical rings generated ahead along a curved aerial path.
|
||||
* No blocks on the ground — pure flight mechanics.
|
||||
*/
|
||||
public class ElytraMode implements Mode {
|
||||
|
||||
private final Leaderboard leaderboard = new Leaderboard(getName(), Leaderboard.Sort.SCORE);
|
||||
|
||||
@Override
|
||||
@NotNull
|
||||
public String getName() {
|
||||
return "elytra";
|
||||
}
|
||||
|
||||
@Override
|
||||
@Nullable
|
||||
public Item getItem(String locale) {
|
||||
return Locales.getItem(locale, "modes.elytra");
|
||||
}
|
||||
|
||||
@Override
|
||||
@NotNull
|
||||
public Leaderboard getLeaderboard() {
|
||||
return leaderboard;
|
||||
}
|
||||
|
||||
@Override
|
||||
public void create(Player player) {
|
||||
if (!Config.CONFIG.getBoolean("joining")) {
|
||||
player.sendMessage("§cJoining is currently disabled.");
|
||||
return;
|
||||
}
|
||||
|
||||
ParkourPlayer pp = ParkourPlayer.getPlayer(player);
|
||||
if (pp != null && pp.session.generator instanceof ElytraGenerator) {
|
||||
return;
|
||||
}
|
||||
|
||||
player.closeInventory();
|
||||
Session.create(session -> new ElytraGenerator(session), null, null, player);
|
||||
}
|
||||
}
|
||||
|
|
@ -1,150 +0,0 @@
|
|||
package dev.loki.loparkour.mode;
|
||||
|
||||
import dev.loki.loparkour.LoParkour;
|
||||
import dev.loki.loparkour.config.Config;
|
||||
import dev.loki.loparkour.config.Locales;
|
||||
import dev.loki.loparkour.generator.ParkourGenerator;
|
||||
import dev.loki.loparkour.leaderboard.Leaderboard;
|
||||
import dev.loki.loparkour.player.ParkourPlayer;
|
||||
import dev.loki.loparkour.session.Session;
|
||||
import org.bukkit.Sound;
|
||||
import org.bukkit.entity.Player;
|
||||
import org.jetbrains.annotations.NotNull;
|
||||
import org.jetbrains.annotations.Nullable;
|
||||
|
||||
/**
|
||||
* Race Mode — reach the target score as fast as possible.
|
||||
* Leaderboard sorts by time (fastest wins).
|
||||
*
|
||||
* <p>Bugs fixed vs previous version:
|
||||
* <ul>
|
||||
* <li>{@code tick()} called {@code super.tick()} AND checked score every tick —
|
||||
* but super.tick() already calls {@code score()} internally, so the finish
|
||||
* check ran one tick late causing an off-by-one where state.score could
|
||||
* briefly exceed targetScore before finishing. Fixed by overriding
|
||||
* {@link #score()} instead — finish check fires the exact moment target is hit.</li>
|
||||
* <li>ActionBar was re-sent every tick inside tick() which conflicted with the
|
||||
* parent's ActionBar updates. Moved to score() so it updates only on change.</li>
|
||||
* <li>{@code reset(false)} was called via scheduler delay — if the player left the
|
||||
* server in those 5 seconds it would NPE. Now guarded with null-check.</li>
|
||||
* <li>{@code getFormattedTime()} is private in parent — accessed via
|
||||
* {@code getDetailedTime()} which is protected. Fixed method call.</li>
|
||||
* </ul>
|
||||
*/
|
||||
public class RaceMode implements Mode {
|
||||
|
||||
private final Leaderboard leaderboard = new Leaderboard(getName(), Leaderboard.Sort.TIME);
|
||||
|
||||
@Override
|
||||
@NotNull
|
||||
public String getName() {
|
||||
return "race";
|
||||
}
|
||||
|
||||
@Override
|
||||
@Nullable
|
||||
public dev.loki.loparkour.util.Item getItem(String locale) {
|
||||
return Locales.getItem(locale, "modes.race");
|
||||
}
|
||||
|
||||
@Override
|
||||
@NotNull
|
||||
public Leaderboard getLeaderboard() {
|
||||
return leaderboard;
|
||||
}
|
||||
|
||||
@Override
|
||||
public void create(Player player) {
|
||||
if (!Config.CONFIG.getBoolean("joining")) {
|
||||
player.sendMessage("§cJoining is currently disabled.");
|
||||
return;
|
||||
}
|
||||
|
||||
ParkourPlayer pp = ParkourPlayer.getPlayer(player);
|
||||
if (pp != null && pp.session.generator instanceof RaceGenerator) {
|
||||
return;
|
||||
}
|
||||
|
||||
player.closeInventory();
|
||||
Session.create(session -> new RaceGenerator(session), null, null, player);
|
||||
}
|
||||
|
||||
// ── RaceGenerator ─────────────────────────────────────────────────────────
|
||||
|
||||
private static class RaceGenerator extends ParkourGenerator {
|
||||
|
||||
private final int targetScore;
|
||||
private boolean raceFinished = false;
|
||||
|
||||
public RaceGenerator(@NotNull Session session) {
|
||||
super(session);
|
||||
this.targetScore = Config.CONFIG.getInt("modes.race.target-score");
|
||||
}
|
||||
|
||||
/**
|
||||
* Hook into score() — the exact moment a new point is added.
|
||||
* This ensures finish detection is frame-perfect with no off-by-one.
|
||||
*/
|
||||
@Override
|
||||
protected void score() {
|
||||
super.score();
|
||||
|
||||
// Update action bar on every new block
|
||||
updateProgressBar();
|
||||
|
||||
if (!raceFinished && state.score >= targetScore) {
|
||||
finishRace();
|
||||
}
|
||||
}
|
||||
|
||||
private void updateProgressBar() {
|
||||
int remaining = Math.max(0, targetScore - state.score);
|
||||
int filled = Math.min(20, state.score * 20 / targetScore);
|
||||
String bar = "§a" + "█".repeat(filled) + "§7" + "█".repeat(20 - filled);
|
||||
|
||||
getPlayers().forEach(pp ->
|
||||
pp.player.spigot().sendMessage(
|
||||
net.md_5.bungee.api.ChatMessageType.ACTION_BAR,
|
||||
net.md_5.bungee.api.chat.TextComponent.fromLegacyText(
|
||||
bar + " §e" + state.score + " §7/ §a" + targetScore
|
||||
+ (remaining > 0 ? " §7(§c" + remaining + " §7left)" : " §6§lDONE!")
|
||||
)));
|
||||
}
|
||||
|
||||
private void finishRace() {
|
||||
raceFinished = true;
|
||||
String time = getDetailedTime();
|
||||
|
||||
getPlayers().forEach(pp -> {
|
||||
pp.player.sendTitle(
|
||||
"§6§lRACE COMPLETE!",
|
||||
"§aFinished in §f" + time,
|
||||
10, 80, 20);
|
||||
pp.player.playSound(pp.getLocation(),
|
||||
Sound.UI_TOAST_CHALLENGE_COMPLETE, 1f, 1f);
|
||||
pp.sendTranslated("modes.race.completed",
|
||||
Integer.toString(targetScore), time);
|
||||
});
|
||||
|
||||
// Save to leaderboard
|
||||
registerScore(time, "1.0", targetScore);
|
||||
|
||||
// Return to lobby after 5 seconds — guarded against null session
|
||||
dev.lolib.scheduler.Scheduler.get(LoParkour.getPlugin())
|
||||
.runLater(() -> {
|
||||
if (!state.stopped) reset(false);
|
||||
}, 100L);
|
||||
}
|
||||
|
||||
@Override
|
||||
public void reset(boolean regenerate) {
|
||||
raceFinished = false;
|
||||
super.reset(regenerate);
|
||||
}
|
||||
|
||||
@Override
|
||||
public Mode getMode() {
|
||||
return Modes.RACE;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -1,154 +0,0 @@
|
|||
package dev.loki.loparkour.mode;
|
||||
|
||||
import dev.loki.loparkour.util.Item;
|
||||
import dev.lolib.scheduler.Scheduler;
|
||||
import dev.lolib.scheduler.ScheduledTask;
|
||||
import dev.loki.loparkour.LoParkour;
|
||||
import dev.loki.loparkour.config.Config;
|
||||
import dev.loki.loparkour.config.Locales;
|
||||
import dev.loki.loparkour.generator.ParkourGenerator;
|
||||
import dev.loki.loparkour.leaderboard.Leaderboard;
|
||||
import dev.loki.loparkour.player.ParkourPlayer;
|
||||
import dev.loki.loparkour.session.Session;
|
||||
import org.bukkit.Material;
|
||||
import org.bukkit.Particle;
|
||||
import org.bukkit.Sound;
|
||||
import org.bukkit.block.Block;
|
||||
import org.bukkit.entity.Player;
|
||||
import org.jetbrains.annotations.NotNull;
|
||||
import org.jetbrains.annotations.Nullable;
|
||||
|
||||
import java.util.HashMap;
|
||||
import java.util.Map;
|
||||
|
||||
/**
|
||||
* Speedrun Mode — blocks disappear a short time after the player lands on them.
|
||||
*
|
||||
* <p>Bugs fixed vs previous version:
|
||||
* <ul>
|
||||
* <li>{@code onBlockTouch()} was never called from anywhere — blocks never disappeared.
|
||||
* Fixed by hooking into {@link #score()} which fires exactly when the player
|
||||
* steps onto a new block for the first time.</li>
|
||||
* <li>Warning task reference was stored in a throwaway local, not in scheduledTasks,
|
||||
* so it could never be cancelled on reset.</li>
|
||||
* <li>{@code removalTime / 50} used integer division that truncated sub-second values
|
||||
* to 0 ticks when blockLifetime < 0.05s. Now uses Math.max(1, ...).</li>
|
||||
* </ul>
|
||||
*/
|
||||
public class SpeedrunMode implements Mode {
|
||||
|
||||
private final Leaderboard leaderboard = new Leaderboard(getName(), Leaderboard.Sort.SCORE);
|
||||
|
||||
@Override
|
||||
@NotNull
|
||||
public String getName() {
|
||||
return "speedrun";
|
||||
}
|
||||
|
||||
@Override
|
||||
@Nullable
|
||||
public Item getItem(String locale) {
|
||||
return Locales.getItem(locale, "play.single.speedrun");
|
||||
}
|
||||
|
||||
@Override
|
||||
@NotNull
|
||||
public Leaderboard getLeaderboard() {
|
||||
return leaderboard;
|
||||
}
|
||||
|
||||
@Override
|
||||
public void create(Player player) {
|
||||
if (!Config.CONFIG.getBoolean("joining")) {
|
||||
player.sendMessage("Joining is currently disabled.");
|
||||
return;
|
||||
}
|
||||
|
||||
ParkourPlayer pp = ParkourPlayer.getPlayer(player);
|
||||
if (pp != null && pp.session.generator instanceof SpeedrunGenerator) {
|
||||
return;
|
||||
}
|
||||
|
||||
player.closeInventory();
|
||||
Session.create(session -> new SpeedrunGenerator(session), null, null, player);
|
||||
}
|
||||
|
||||
// ── SpeedrunGenerator ─────────────────────────────────────────────────────
|
||||
|
||||
private static class SpeedrunGenerator extends ParkourGenerator {
|
||||
|
||||
/** Tracks scheduled tasks per block. Key = block, value = [warningTask, removalTask]. */
|
||||
private final Map<Block, ScheduledTask[]> scheduledTasks = new HashMap<>();
|
||||
|
||||
public SpeedrunGenerator(@NotNull Session session) {
|
||||
super(session);
|
||||
}
|
||||
|
||||
/**
|
||||
* Called every time the player scores a point (= lands on a new block).
|
||||
* We hook here instead of a separate onBlockTouch() because score() is the
|
||||
* canonical place where "player stepped on block N" is detected.
|
||||
*/
|
||||
@Override
|
||||
protected void score() {
|
||||
super.score();
|
||||
|
||||
// The block the player just landed on is the previous-to-last in history
|
||||
// (history.size()-1 is the leading block, size()-2 is where player stands)
|
||||
int histSize = state.history.size();
|
||||
if (histSize < 2) return;
|
||||
|
||||
Block stepped = state.history.get(histSize - 2);
|
||||
if (stepped.getType() == Material.AIR) return;
|
||||
if (scheduledTasks.containsKey(stepped)) return; // already scheduled
|
||||
|
||||
scheduleBlockRemoval(stepped);
|
||||
}
|
||||
|
||||
private void scheduleBlockRemoval(@NotNull Block block) {
|
||||
double blockLifetime = Config.CONFIG.getDouble("modes.speedrun.block-lifetime");
|
||||
double warningTime = Config.CONFIG.getDouble("modes.speedrun.warning-time");
|
||||
|
||||
// Convert seconds → ticks (20 ticks/s), minimum 1 tick
|
||||
long removalTicks = Math.max(1, Math.round(blockLifetime * 20));
|
||||
long warningTicks = Math.max(1, Math.round(warningTime * 20));
|
||||
|
||||
ScheduledTask warningTask = Scheduler.get(LoParkour.getPlugin()).runLater(() -> {
|
||||
if (block.getType() != Material.AIR) {
|
||||
player.player.playSound(block.getLocation(),
|
||||
Sound.BLOCK_NOTE_BLOCK_PLING, 1.0f, 2.0f);
|
||||
player.player.spawnParticle(Particle.FLAME,
|
||||
block.getLocation().add(0.5, 1, 0.5), 8, 0.2, 0.2, 0.2, 0.01);
|
||||
}
|
||||
}, warningTicks);
|
||||
|
||||
ScheduledTask removalTask = Scheduler.get(LoParkour.getPlugin()).runLater(() -> {
|
||||
if (block.getType() != Material.AIR) {
|
||||
block.setType(Material.AIR, false);
|
||||
}
|
||||
scheduledTasks.remove(block);
|
||||
}, removalTicks);
|
||||
|
||||
// Store both so we can cancel them on reset
|
||||
scheduledTasks.put(block, new ScheduledTask[]{warningTask, removalTask});
|
||||
}
|
||||
|
||||
@Override
|
||||
public void reset(boolean regenerate) {
|
||||
// Cancel every pending warning and removal task
|
||||
scheduledTasks.values().forEach(tasks -> {
|
||||
for (ScheduledTask t : tasks) {
|
||||
if (t != null) t.cancel();
|
||||
}
|
||||
});
|
||||
scheduledTasks.clear();
|
||||
|
||||
super.reset(regenerate);
|
||||
}
|
||||
|
||||
@Override
|
||||
public Mode getMode() {
|
||||
return Modes.SPEEDRUN;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -1,50 +0,0 @@
|
|||
package dev.loki.loparkour.storage;
|
||||
|
||||
import dev.loki.loparkour.config.Option;
|
||||
|
||||
/**
|
||||
* Manages database schema migrations.
|
||||
*
|
||||
* @since 5.0.0
|
||||
*/
|
||||
class SQLMigrationManager {
|
||||
|
||||
private final SQLQueryExecutor queryExecutor;
|
||||
|
||||
public SQLMigrationManager(SQLQueryExecutor queryExecutor) {
|
||||
this.queryExecutor = queryExecutor;
|
||||
}
|
||||
|
||||
public void initializeDatabase() {
|
||||
createDatabase();
|
||||
createOptionsTable();
|
||||
runMigrations();
|
||||
}
|
||||
|
||||
private void createDatabase() {
|
||||
queryExecutor.executeStaticUpdate("CREATE DATABASE IF NOT EXISTS `%s`;".formatted(Option.SQL_DB));
|
||||
queryExecutor.executeStaticUpdate("USE `%s`;".formatted(Option.SQL_DB));
|
||||
}
|
||||
|
||||
private void createOptionsTable() {
|
||||
queryExecutor.executeStaticUpdate(
|
||||
("CREATE TABLE IF NOT EXISTS `%soptions` " +
|
||||
"(`uuid` CHAR(36) NOT NULL, `time` VARCHAR(8), `style` VARCHAR(32), `blockLead` INT, " +
|
||||
"`useParticles` BOOLEAN, `useDifficulty` BOOLEAN, `useSpecial` BOOLEAN, " +
|
||||
"`showFallMsg` BOOLEAN, `showScoreboard` BOOLEAN, PRIMARY KEY (`uuid`)) " +
|
||||
"ENGINE = InnoDB CHARSET = utf8;").formatted(Option.SQL_PREFIX));
|
||||
}
|
||||
|
||||
private void runMigrations() {
|
||||
String prefix = Option.SQL_PREFIX;
|
||||
|
||||
queryExecutor.executeStaticUpdateSuppressed("ALTER TABLE `%soptions` DROP COLUMN `time`;".formatted(prefix));
|
||||
queryExecutor.executeStaticUpdateSuppressed("ALTER TABLE `%soptions` ADD `selectedTime` INT NOT NULL;".formatted(prefix));
|
||||
queryExecutor.executeStaticUpdateSuppressed("ALTER TABLE `%soptions` ADD `collectedRewards` MEDIUMTEXT;".formatted(prefix));
|
||||
queryExecutor.executeStaticUpdateSuppressed("ALTER TABLE `%soptions` ADD `locale` VARCHAR(8);".formatted(prefix));
|
||||
queryExecutor.executeStaticUpdateSuppressed("ALTER TABLE `%soptions` ADD `schematicDifficulty` DOUBLE;".formatted(prefix));
|
||||
queryExecutor.executeStaticUpdateSuppressed("ALTER TABLE `%soptions` ADD `sound` BOOLEAN;".formatted(prefix));
|
||||
queryExecutor.executeStaticUpdateSuppressed("ALTER TABLE `%soptions` DROP COLUMN `useDifficulty`;".formatted(prefix));
|
||||
queryExecutor.executeStaticUpdateSuppressed("ALTER TABLE `%soptions` DROP COLUMN `useStructure`;".formatted(prefix));
|
||||
}
|
||||
}
|
||||
|
|
@ -1,86 +0,0 @@
|
|||
package dev.loki.loparkour.storage;
|
||||
|
||||
import dev.loki.loparkour.LoParkour;
|
||||
|
||||
import java.sql.PreparedStatement;
|
||||
import java.sql.SQLException;
|
||||
|
||||
/**
|
||||
* Executes SQL queries with error handling.
|
||||
*
|
||||
* <p>All public methods accept parameters separately so callers never
|
||||
* interpolate user data into SQL strings — preventing SQL injection.</p>
|
||||
*
|
||||
* @since 5.0.0
|
||||
*/
|
||||
class SQLQueryExecutor {
|
||||
|
||||
private final SQLConnectionManager connectionManager;
|
||||
|
||||
public SQLQueryExecutor(SQLConnectionManager connectionManager) {
|
||||
this.connectionManager = connectionManager;
|
||||
}
|
||||
|
||||
/**
|
||||
* Executes a DDL/static update with no user-supplied parameters.
|
||||
* Only used for CREATE TABLE statements where all values are hardcoded.
|
||||
*/
|
||||
public void executeStaticUpdate(String sql) {
|
||||
executeStaticUpdate(sql, false);
|
||||
}
|
||||
|
||||
/**
|
||||
* Same as {@link #executeStaticUpdate(String)} but optionally suppresses errors.
|
||||
* Used for ALTER TABLE migrations that may already have been applied.
|
||||
*/
|
||||
public void executeStaticUpdateSuppressed(String sql) {
|
||||
executeStaticUpdate(sql, true);
|
||||
}
|
||||
|
||||
private void executeStaticUpdate(String sql, boolean suppressErrors) {
|
||||
connectionManager.validateConnection();
|
||||
try (PreparedStatement stmt = connectionManager.prepareStatement(sql)) {
|
||||
if (stmt != null) stmt.executeUpdate();
|
||||
} catch (SQLException ex) {
|
||||
if (!suppressErrors) {
|
||||
LoParkour.getPlugin().getLogger().severe(
|
||||
"Error while executing static update - " + ex.getMessage());
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Executes a parameterised update. Values are bound via {@link PreparedStatement#setObject}
|
||||
* — the JDBC driver handles escaping, no interpolation ever touches the SQL string.
|
||||
*
|
||||
* @param sql SQL with {@code ?} placeholders
|
||||
* @param params Values to bind in order
|
||||
*/
|
||||
public void executeUpdate(String sql, Object... params) {
|
||||
connectionManager.validateConnection();
|
||||
try (PreparedStatement stmt = connectionManager.prepareStatement(sql)) {
|
||||
if (stmt == null) return;
|
||||
bindParams(stmt, params);
|
||||
stmt.executeUpdate();
|
||||
} catch (SQLException ex) {
|
||||
LoParkour.getPlugin().getLogger().severe(
|
||||
"Error while executing update - " + ex.getMessage());
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Prepares a statement for callers that need to read a {@link java.sql.ResultSet}.
|
||||
* Parameters must be bound by the caller before executing.
|
||||
*/
|
||||
public PreparedStatement prepareStatement(String sql) {
|
||||
return connectionManager.prepareStatement(sql);
|
||||
}
|
||||
|
||||
// ── Helpers ───────────────────────────────────────────────────────────────
|
||||
|
||||
private static void bindParams(PreparedStatement stmt, Object[] params) throws SQLException {
|
||||
for (int i = 0; i < params.length; i++) {
|
||||
stmt.setObject(i + 1, params[i]);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -1,246 +0,0 @@
|
|||
package dev.loki.loparkour.storage;
|
||||
|
||||
import dev.loki.loparkour.LoParkour;
|
||||
import dev.loki.loparkour.config.Option;
|
||||
import dev.loki.loparkour.leaderboard.Score;
|
||||
import dev.loki.loparkour.player.ParkourPlayer;
|
||||
|
||||
import org.bukkit.Bukkit;
|
||||
import org.jetbrains.annotations.NotNull;
|
||||
|
||||
import java.sql.*;
|
||||
import java.text.DecimalFormat;
|
||||
import java.text.DecimalFormatSymbols;
|
||||
import java.util.*;
|
||||
import java.util.concurrent.CopyOnWriteArrayList;
|
||||
|
||||
/**
|
||||
* MySQL storage manager.
|
||||
*
|
||||
* <p><b>Security:</b> Every query that touches user-supplied data uses
|
||||
* {@code ?} placeholders bound via {@link PreparedStatement#setObject}.
|
||||
* No string interpolation of player names, UUIDs, scores, or any other
|
||||
* external value ever reaches the SQL string itself.</p>
|
||||
*
|
||||
* @since 5.0.0
|
||||
*/
|
||||
class StorageSQL {
|
||||
|
||||
private static final SQLConnectionManager connectionManager = new SQLConnectionManager();
|
||||
private static final SQLQueryExecutor queryExecutor = new SQLQueryExecutor(connectionManager);
|
||||
private static final SQLMigrationManager migrationManager = new SQLMigrationManager(queryExecutor);
|
||||
|
||||
private static boolean initialized = false;
|
||||
private static final List<String> pendingTableCreations = new CopyOnWriteArrayList<>();
|
||||
|
||||
// ── Connection lifecycle ──────────────────────────────────────────────────
|
||||
|
||||
public static boolean isConnected() {
|
||||
return connectionManager.isConnected();
|
||||
}
|
||||
|
||||
public static void runWhenConnected(Runnable callback) {
|
||||
connectionManager.runWhenConnected(callback);
|
||||
}
|
||||
|
||||
public static void close() {
|
||||
connectionManager.close();
|
||||
}
|
||||
|
||||
// ── Initialisation ────────────────────────────────────────────────────────
|
||||
|
||||
/**
|
||||
* Queues creation of the leaderboard table for {@code mode}.
|
||||
* Table name is constructed from a config-defined prefix (admin-controlled,
|
||||
* not user input) so formatting here is safe.
|
||||
*/
|
||||
public static void init(String mode) {
|
||||
// Table names are derived from admin config, not player input — safe to format.
|
||||
String createTableSql = """
|
||||
CREATE TABLE IF NOT EXISTS `%s`
|
||||
(
|
||||
uuid CHAR(36) NOT NULL PRIMARY KEY,
|
||||
name VARCHAR(16),
|
||||
time VARCHAR(16),
|
||||
difficulty VARCHAR(3),
|
||||
score INT
|
||||
) CHARSET = utf8 ENGINE = InnoDB;
|
||||
""".formatted(getTableName(mode));
|
||||
|
||||
if (!initialized) {
|
||||
initialized = true;
|
||||
pendingTableCreations.add(createTableSql);
|
||||
Bukkit.getScheduler().runTaskAsynchronously(LoParkour.getPlugin(), StorageSQL::connect);
|
||||
} else {
|
||||
pendingTableCreations.add(createTableSql);
|
||||
}
|
||||
}
|
||||
|
||||
// ── Leaderboard ───────────────────────────────────────────────────────────
|
||||
|
||||
public static @NotNull Map<UUID, Score> readScores(@NotNull String mode) {
|
||||
// Table name from admin config — safe to format; no user data here.
|
||||
String sql = "SELECT * FROM `%s`;".formatted(getTableName(mode));
|
||||
try (PreparedStatement stmt = queryExecutor.prepareStatement(sql)) {
|
||||
if (stmt == null) return new HashMap<>();
|
||||
try (ResultSet rs = stmt.executeQuery()) {
|
||||
Map<UUID, Score> scores = new HashMap<>();
|
||||
while (rs.next()) {
|
||||
scores.put(
|
||||
UUID.fromString(rs.getString("uuid")),
|
||||
new Score(
|
||||
rs.getString("name"),
|
||||
rs.getString("time"),
|
||||
rs.getString("difficulty"),
|
||||
rs.getInt("score")));
|
||||
}
|
||||
return scores;
|
||||
}
|
||||
} catch (SQLException ex) {
|
||||
LoParkour.getPlugin().getLogger().severe(
|
||||
"Error reading leaderboard for mode %s - %s".formatted(mode, ex.getMessage()));
|
||||
return new HashMap<>();
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Writes all scores for a mode.
|
||||
*
|
||||
* <p>Uses an INSERT … ON DUPLICATE KEY UPDATE with {@code ?} placeholders.
|
||||
* Player name, time, difficulty come from the leaderboard record — all bound
|
||||
* as parameters, never interpolated.</p>
|
||||
*/
|
||||
public static void writeScores(@NotNull String mode, @NotNull Map<UUID, Score> scores) {
|
||||
// Table name is from admin config — safe to format.
|
||||
String sql = """
|
||||
INSERT INTO `%s` (uuid, name, time, difficulty, score)
|
||||
VALUES (?, ?, ?, ?, ?)
|
||||
ON DUPLICATE KEY UPDATE
|
||||
name = VALUES(name),
|
||||
time = VALUES(time),
|
||||
difficulty = VALUES(difficulty),
|
||||
score = VALUES(score);
|
||||
""".formatted(getTableName(mode));
|
||||
|
||||
new HashMap<>(scores).forEach((uuid, score) ->
|
||||
queryExecutor.executeUpdate(sql,
|
||||
uuid.toString(),
|
||||
score.name(),
|
||||
score.time(),
|
||||
score.difficulty(),
|
||||
score.score()));
|
||||
}
|
||||
|
||||
// ── Player settings ───────────────────────────────────────────────────────
|
||||
|
||||
/**
|
||||
* Reads player settings from the options table.
|
||||
*
|
||||
* <p>UUID is bound as a parameter — never interpolated into the query string.</p>
|
||||
*/
|
||||
public static void readPlayer(@NotNull ParkourPlayer player) {
|
||||
// Table name from admin config — safe. UUID bound as parameter below.
|
||||
String sql = "SELECT * FROM `%soptions` WHERE uuid = ?;".formatted(Option.SQL_PREFIX);
|
||||
|
||||
try (PreparedStatement stmt = queryExecutor.prepareStatement(sql)) {
|
||||
if (stmt == null) {
|
||||
player.setSettings(new HashMap<>());
|
||||
return;
|
||||
}
|
||||
|
||||
// Bind UUID as parameter — no injection possible
|
||||
stmt.setString(1, player.getUUID().toString());
|
||||
|
||||
try (ResultSet rs = stmt.executeQuery()) {
|
||||
if (!rs.next()) {
|
||||
player.setSettings(new HashMap<>());
|
||||
return;
|
||||
}
|
||||
|
||||
Map<String, Object> settings = new HashMap<>();
|
||||
for (String key : ParkourPlayer.PLAYER_COLUMNS.keySet()) {
|
||||
try {
|
||||
settings.put(key, rs.getObject(key));
|
||||
} catch (SQLException ex) {
|
||||
LoParkour.getPlugin().getLogger().severe(
|
||||
"Error reading SQL column %s for %s - %s"
|
||||
.formatted(key, player.getName(), ex.getMessage()));
|
||||
}
|
||||
}
|
||||
player.setSettings(settings);
|
||||
}
|
||||
} catch (SQLException ex) {
|
||||
LoParkour.getPlugin().getLogger().severe(
|
||||
"Error reading player data for %s - %s".formatted(player.getName(), ex.getMessage()));
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Writes player settings to the options table.
|
||||
*
|
||||
* <p>All 12 player-controlled values (style, locale, rewards, etc.) are bound
|
||||
* as {@code ?} parameters. None of them are interpolated into the SQL string.</p>
|
||||
*/
|
||||
public static void writePlayer(@NotNull ParkourPlayer player) {
|
||||
DecimalFormat df = new DecimalFormat("#.######",
|
||||
DecimalFormatSymbols.getInstance(Locale.ENGLISH));
|
||||
double schemDiff = df.format(player.schematicDifficulty) != null
|
||||
? player.schematicDifficulty : 0.0;
|
||||
|
||||
// Table name from admin config — safe to format.
|
||||
String sql = """
|
||||
INSERT INTO `%soptions`
|
||||
(uuid, style, blockLead, useParticles, useSpecial, showFallMsg,
|
||||
showScoreboard, selectedTime, collectedRewards, locale,
|
||||
schematicDifficulty, sound)
|
||||
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
|
||||
ON DUPLICATE KEY UPDATE
|
||||
style = VALUES(style),
|
||||
blockLead = VALUES(blockLead),
|
||||
useParticles = VALUES(useParticles),
|
||||
useSpecial = VALUES(useSpecial),
|
||||
showFallMsg = VALUES(showFallMsg),
|
||||
showScoreboard = VALUES(showScoreboard),
|
||||
selectedTime = VALUES(selectedTime),
|
||||
collectedRewards = VALUES(collectedRewards),
|
||||
locale = VALUES(locale),
|
||||
schematicDifficulty = VALUES(schematicDifficulty),
|
||||
sound = VALUES(sound);
|
||||
""".formatted(Option.SQL_PREFIX);
|
||||
|
||||
queryExecutor.executeUpdate(sql,
|
||||
player.getUUID().toString(), // 1 uuid
|
||||
player.style, // 2 style
|
||||
player.blockLead, // 3 blockLead
|
||||
player.particles, // 4 useParticles
|
||||
player.useSpecialBlocks, // 5 useSpecial
|
||||
player.showFallMessage, // 6 showFallMsg
|
||||
player.showScoreboard, // 7 showScoreboard
|
||||
player.selectedTime, // 8 selectedTime
|
||||
String.join(",", player.collectedRewards), // 9 collectedRewards
|
||||
player.locale, // 10 locale
|
||||
schemDiff, // 11 schematicDifficulty
|
||||
player.sound // 12 sound
|
||||
);
|
||||
}
|
||||
|
||||
// ── Internals ─────────────────────────────────────────────────────────────
|
||||
|
||||
/**
|
||||
* Table name is built from the admin-configured SQL prefix and the internal
|
||||
* mode name (e.g. "default", "speedrun"). Neither is user-supplied.
|
||||
*/
|
||||
private static String getTableName(String mode) {
|
||||
return "%sleaderboard-%s".formatted(Option.SQL_PREFIX, mode);
|
||||
}
|
||||
|
||||
private static void connect() {
|
||||
connectionManager.connect();
|
||||
migrationManager.initializeDatabase();
|
||||
|
||||
for (String sql : pendingTableCreations) {
|
||||
queryExecutor.executeStaticUpdate(sql);
|
||||
}
|
||||
pendingTableCreations.clear();
|
||||
}
|
||||
}
|
||||
|
|
@ -1,524 +0,0 @@
|
|||
#===============================================================================#
|
||||
# #
|
||||
# 88 88888888ba #
|
||||
# 88 88 "8b #
|
||||
# 88 88 ,8P #
|
||||
# 88 88aaaaaa8P' #
|
||||
# 88 88""""""' #
|
||||
# 88 88 #
|
||||
# 88 88 #
|
||||
# 88 88 #
|
||||
# #
|
||||
#===============================================================================#
|
||||
|
||||
#===============================================================================#
|
||||
# #
|
||||
# (c) loki 2026 #
|
||||
# #
|
||||
#===============================================================================#
|
||||
|
||||
# -= Debugging =-
|
||||
debug: false
|
||||
|
||||
# -= Enable joining =-
|
||||
# Whether players can join the parkour.
|
||||
# When joining is disabled, the IP instance on this server will only read data from leaderboard databases.
|
||||
# Set joining to false if you want to create a cross-server leaderboard on this server.
|
||||
# true = players can join, false = players can't join.
|
||||
joining: true
|
||||
|
||||
# -= Proxy options =-
|
||||
# Change the proxy settings. Applies to Velocity as well.
|
||||
# This also applies to servers who only want to run this plugin.
|
||||
bungeecord:
|
||||
|
||||
# -= Proxy enabled =-
|
||||
# Enabling this will make players who join the server automatically join the parkour.
|
||||
# You can enable this even without BungeeCord to make this a parkour-only server.
|
||||
# True = players will automatically join the parkour on join, false = players have to use the menu/parkour command.
|
||||
enabled: false
|
||||
|
||||
# -= Proxy return server =-
|
||||
# The server the players will be returned to after they leave.
|
||||
return_server: 'Lobby'
|
||||
|
||||
# -= Go back enabled =-
|
||||
# Whether players should be teleported to a specific location after leaving.
|
||||
# True = players get teleported to a specific location when they leave, false = they go to their location when they joined.
|
||||
go-back-enabled: false
|
||||
|
||||
# -= Go back location =-
|
||||
# The place players will go back to when they leave
|
||||
# Only applies if proxy mode is disabled and the go-back-enabled option is true.
|
||||
# Syntax: '(x,y,z,worldName)'
|
||||
go-back: '(0,100,0,world)'
|
||||
|
||||
# -= Go back axes =-
|
||||
# Sets the pitch and yaw of the go-back location.
|
||||
# The first argument is the pitch, second yaw.
|
||||
go-back-axes: '0,0'
|
||||
|
||||
# -= Storage update interval =-
|
||||
# The amount of seconds between every disk/database storage update.
|
||||
# In this update, the in-memory scores will be written to storage.
|
||||
# A lower number might reduce the chance of losing data on server crashes,
|
||||
# but will increase the amount of requests to the disk/database.
|
||||
# (requires a restart, default = 300)
|
||||
storage-update-interval: 300
|
||||
|
||||
# -= MySQL =-
|
||||
# The options for using MySQL as storage.
|
||||
sql:
|
||||
|
||||
# -= MySQL enabled =-
|
||||
# Using databases makes you able to sync player data across multiple servers.
|
||||
# True = data will be stored in a database, false = data will be stored locally.
|
||||
enabled: false
|
||||
|
||||
# -= MySQL settings =-
|
||||
url: 'localhost'
|
||||
port: 3306
|
||||
username: 'efnilite'
|
||||
password: 'super secret password'
|
||||
database: 'ip'
|
||||
|
||||
# -= MySQL prefix =-
|
||||
# The prefix given to any table names
|
||||
# The default is ''. Example: 'ip-' -> this would make the table 'ip-players'
|
||||
prefix: ''
|
||||
|
||||
# -= World options =-
|
||||
world:
|
||||
|
||||
# -= World name =-
|
||||
# The world in which parkour will take place
|
||||
name: 'loparkour'
|
||||
|
||||
# -= Delete world on reload =-
|
||||
# Deletes the world on reload/restart of the server.
|
||||
# This makes sure there are no ghost blocks left when a server crash occurs.
|
||||
# Disable this if you want to have a custom map.
|
||||
delete-on-reload: true
|
||||
|
||||
# -= World fall back =-
|
||||
# The world players will be teleported to if they accidentally join the Parkour world without joining parkour
|
||||
fall-back: 'world'
|
||||
|
||||
# -= General options =-
|
||||
options:
|
||||
|
||||
# -= Leads options =-
|
||||
leads:
|
||||
|
||||
# -= Leads amounts =-
|
||||
# The lead amounts a player can select in their options menu.
|
||||
# Values also need to be above 1 and below 64.
|
||||
amount:
|
||||
- 1
|
||||
- 2
|
||||
- 3
|
||||
- 4
|
||||
- 5
|
||||
- 6
|
||||
- 7
|
||||
- 8
|
||||
- 9
|
||||
- 10
|
||||
|
||||
# -= Time options =-
|
||||
time:
|
||||
|
||||
# -= Time format =-
|
||||
# Choose between:
|
||||
# 24 - 24 hour clock. (00:00, 12:00, 23:59)
|
||||
# 12 - 12 hour clock. (12:00 AM, 12:00 PM, 11:59 PM)
|
||||
format: 24
|
||||
|
||||
# -= Score time format =-
|
||||
# H = hours, m = minutes, s = seconds, S = milliseconds
|
||||
score-format: "mm:ss:SSS"
|
||||
|
||||
# -= Inventory handling =-
|
||||
# Toggles the inventory handling system.
|
||||
# True = players' inventories get saved and cleared when they join the parkour and when they leave they get their items back
|
||||
# False = players keep their inventories when they join the parkour
|
||||
inventory-handling: true
|
||||
|
||||
# -= Inventory saving =-
|
||||
# Logs inventories of the players that join to the inventories/ folder.
|
||||
# It is highly recommended to keep this enabled. Server crashes or errors while leaving might cause
|
||||
# inventories of players to be lost if this is set to false!
|
||||
# Use /ip recoverinventory to recover inventories of players who joined the parkour and who lost their inventories in a crash, etc.
|
||||
inventory-saving: true
|
||||
|
||||
# -= Alternate inventory saving command =-
|
||||
# If you don't trust this plugin to save inventories, you can select another plugin to execute a command
|
||||
# to save players' inventories.
|
||||
# Example: /inventory save %player%
|
||||
# This command will be executed by console.
|
||||
alt-inventory-saving-command: ''
|
||||
|
||||
# -= Disables inventory blocks =-
|
||||
# Disables the ability for a player to open blocks with an inventory.
|
||||
disable-inventory-blocks: false
|
||||
|
||||
# -= Hotbar slots =-
|
||||
# Configure which hotbar slots (0-8) to use for parkour items
|
||||
# Items will be placed in these slots in order: play, community, settings, lobby, quit
|
||||
hotbar-slots:
|
||||
play: 1
|
||||
community: 3
|
||||
settings: 4
|
||||
lobby: 5
|
||||
quit: 7
|
||||
|
||||
# -= Permissions options =-
|
||||
# These permissions are used to determine which things the player can change.
|
||||
# Find them at https://efnilite.dev/projects/ip/wiki
|
||||
permissions:
|
||||
|
||||
# -= Permissions enabled =-
|
||||
# If permission handling is enabled.
|
||||
# True = players need a permission to join, view the leaderboard, etc., false = players can do and view anything
|
||||
# from this plugin besides admin commands.
|
||||
enabled: false
|
||||
|
||||
# -= Per style permissions =-
|
||||
# Gives every style a permission. The permission is the name of the style.
|
||||
# Syntax: ip.settings.styles.<style> - example: ip.settings.styles.red
|
||||
# Useful for servers with ranks.
|
||||
per-style: false
|
||||
|
||||
# -= Focus mode options =-
|
||||
focus-mode:
|
||||
|
||||
# -= Focus mode enabled =-
|
||||
# If focus mode is enabled players can't use commands while in parkour.
|
||||
# Note: this might not work on all commands because Minecraft.
|
||||
enabled: false
|
||||
|
||||
# -=
|
||||
# A whitelist for commands which ARE allowed during focus mode (if focus mode is enabled).
|
||||
# If you want to disable the default whitelisted commands you can remove every item in the list below.
|
||||
whitelist:
|
||||
- 'ip'
|
||||
- 'parkour'
|
||||
- 'msg'
|
||||
- 'r'
|
||||
- 'w'
|
||||
- 'whisper'
|
||||
- 'reply'
|
||||
- 'message'
|
||||
|
||||
# -= Styles options =-
|
||||
styles:
|
||||
|
||||
# -= Styles list =-
|
||||
# The list of all available styles, which you can add to if you want.
|
||||
# For changes, you have to restart or reload your server.
|
||||
list:
|
||||
red: [red_wool, red_stained_glass, red_concrete, red_terracotta]
|
||||
blue: [blue_wool, blue_stained_glass, blue_concrete, blue_terracotta]
|
||||
light blue: [light_blue_wool, light_blue_stained_glass, light_blue_concrete, light_blue_terracotta]
|
||||
green: [green_wool, green_stained_glass, green_concrete, green_terracotta]
|
||||
lime: [lime_wool, lime_stained_glass, lime_concrete, lime_terracotta]
|
||||
cyan: [cyan_wool, cyan_stained_glass, cyan_concrete, cyan_terracotta]
|
||||
purple: [purple_wool, purple_stained_glass, purple_concrete, purple_terracotta]
|
||||
yellow: [yellow_wool, yellow_stained_glass, yellow_concrete, yellow_terracotta]
|
||||
gray: [gray_wool, gray_stained_glass, gray_concrete, gray_terracotta]
|
||||
end: [end_stone, end_stone_bricks, purpur_block, purpur_pillar]
|
||||
nether: [netherrack, nether_bricks, red_nether_bricks, chiseled_nether_bricks, nether_wart_block, nether_gold_ore, netherite_block, basalt]
|
||||
quartz: [smooth_quartz, chiseled_quartz_block, quartz_block, quartz_bricks, quartz_pillar]
|
||||
ocean: [prismarine, prismarine_bricks, dark_prismarine, sea_lantern]
|
||||
redstone: [observer, dispenser, redstone_block, piston, sticky_piston, redstone_lamp, target]
|
||||
overworld: [grass_block, stone, cobblestone, oak_log]
|
||||
ores: [diamond_ore, gold_ore, iron_ore, coal_ore, nether_gold_ore, lapis_ore, redstone_ore, emerald_ore, nether_quartz_ore]
|
||||
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]
|
||||
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]
|
||||
glow: [glowstone, jack_o_lantern, beacon, sea_lantern, crying_obsidian, shroomlight]
|
||||
|
||||
# -= Jump validation options =-
|
||||
jump-validation:
|
||||
|
||||
# -= Jump validation enabled =-
|
||||
# Validates generated jumps using Minecraft physics.
|
||||
# Ensures all jumps are physically possible.
|
||||
enabled: true
|
||||
|
||||
# -= Maximum jump distance =-
|
||||
# Maximum 3D distance for a jump (Pythagorean theorem).
|
||||
max-distance: 4.5
|
||||
|
||||
# -= Maximum horizontal distance =-
|
||||
# Maximum horizontal distance (X and Z axes only).
|
||||
max-horizontal: 4.1
|
||||
|
||||
# -= Maximum vertical up =-
|
||||
# Maximum height a player can jump up.
|
||||
max-vertical-up: 1.25
|
||||
|
||||
# -= Maximum vertical down =-
|
||||
# Maximum height a player can jump down.
|
||||
max-vertical-down: 3.0
|
||||
|
||||
# -= Jump types options =-
|
||||
jump-types:
|
||||
|
||||
# -= Jump types enabled =-
|
||||
# Enables special jump types (neo-jumps, fence jumps, etc.).
|
||||
enabled: false
|
||||
|
||||
# -= Jump types list =-
|
||||
# Configure which jump types are enabled and their spawn chances.
|
||||
types:
|
||||
neo-jump:
|
||||
enabled: true
|
||||
chance: 0.05
|
||||
head-hitter:
|
||||
enabled: true
|
||||
chance: 0.08
|
||||
fence-jump:
|
||||
enabled: true
|
||||
chance: 0.10
|
||||
trapdoor-jump:
|
||||
enabled: true
|
||||
chance: 0.12
|
||||
ladder-jump:
|
||||
enabled: true
|
||||
chance: 0.07
|
||||
|
||||
# -= Memory optimization =-
|
||||
memory:
|
||||
|
||||
# -= Block cleanup distance =-
|
||||
# Distance in blocks after which old parkour blocks are removed.
|
||||
# Lower values save memory but might remove blocks too early.
|
||||
block-cleanup-distance: 20
|
||||
|
||||
# -= Cleanup interval =-
|
||||
# How often (in ticks) to check for blocks to clean up.
|
||||
# 20 ticks = 1 second
|
||||
cleanup-interval: 100
|
||||
|
||||
# -= Ghost mode options =-
|
||||
ghost-mode:
|
||||
|
||||
# -= Ghost mode enabled =-
|
||||
# Shows ghosts of top players running alongside you.
|
||||
enabled: false
|
||||
|
||||
# -= Show top =-
|
||||
# How many top ghosts to show (1-3).
|
||||
show-top: 3
|
||||
|
||||
# -= Ghost transparency =-
|
||||
# Transparency of ghost entities (0.0 = invisible, 1.0 = fully visible).
|
||||
transparency: 0.5
|
||||
|
||||
# -= Game modes options =-
|
||||
modes:
|
||||
|
||||
# -= Speedrun mode =-
|
||||
speedrun:
|
||||
# Block lifetime in seconds before disappearing
|
||||
block-lifetime: 1.5
|
||||
# Warning time in seconds before block disappears
|
||||
warning-time: 0.5
|
||||
|
||||
# -= Gravity shift mode =-
|
||||
gravity-shift:
|
||||
# Interval in jumps before effect changes
|
||||
interval: 10
|
||||
|
||||
effects:
|
||||
jump-boost:
|
||||
enabled: true
|
||||
amplifier: 2
|
||||
duration: 30
|
||||
speed:
|
||||
enabled: true
|
||||
amplifier: 1
|
||||
duration: 30
|
||||
slowness:
|
||||
enabled: true
|
||||
amplifier: 1
|
||||
duration: 15
|
||||
levitation:
|
||||
enabled: true
|
||||
amplifier: 1
|
||||
duration: 5
|
||||
|
||||
# -= Hardcore mode =-
|
||||
hardcore:
|
||||
# Reset all rewards on fall
|
||||
reset-rewards: true
|
||||
|
||||
# -= Elytra mode =-
|
||||
elytra:
|
||||
# Enable elytra mode
|
||||
enabled: true
|
||||
|
||||
# --- Ring course settings ---
|
||||
# Minimum distance (blocks) between consecutive rings
|
||||
ring-distance-min: 20
|
||||
# Maximum distance (blocks) between consecutive rings
|
||||
ring-distance-max: 40
|
||||
# Ring radius in blocks. Player must fly through the center hole.
|
||||
ring-size: 5
|
||||
# How many rings to generate ahead of the player
|
||||
ring-lead: 8
|
||||
# Maximum horizontal heading change per ring in degrees (course curvature)
|
||||
max-heading-change-horizontal: 30
|
||||
# Maximum vertical heading change per ring in degrees
|
||||
max-heading-change-vertical: 15
|
||||
# Maximum Y blocks above spawn point that the course can reach
|
||||
max-height-above-spawn: 60
|
||||
# How far from the course (blocks) before a fall is triggered
|
||||
max-deviation: 25
|
||||
|
||||
# --- Firework / boost settings ---
|
||||
# Firework velocity boost multiplier
|
||||
boost-power: 1.5
|
||||
# Cooldown between firework boosts in milliseconds
|
||||
firework-cooldown-ms: 3000
|
||||
# Number of fireworks given at session start
|
||||
starting-fireworks: 64
|
||||
|
||||
# -= Race mode =-
|
||||
race:
|
||||
# Enable race mode
|
||||
enabled: true
|
||||
# Target score to win
|
||||
target-score: 100
|
||||
|
||||
# -= Co-op mode =-
|
||||
coop:
|
||||
# Enable co-op mode
|
||||
enabled: true
|
||||
# Maximum players in co-op session
|
||||
max-players: 4
|
||||
|
||||
|
||||
# -= Scoring options =-
|
||||
scoring:
|
||||
|
||||
# -= All points =-
|
||||
# This will give the player all possible points.
|
||||
# If a player skips a block jump, it will still give the points for that block.
|
||||
all-points: false
|
||||
|
||||
# -= Rewards use total score =-
|
||||
# Whether interval rewards should use the total score of a session or not.
|
||||
# The total score doesn't reset when a player falls.
|
||||
rewards-use-total-score: false
|
||||
|
||||
# -= Particle options =-
|
||||
particles:
|
||||
|
||||
# -= Particle shape =-
|
||||
# The shape of the particle that will appear when a new block generates.
|
||||
# Possible options: dot, box, circle
|
||||
particle-shape: 'box'
|
||||
|
||||
# -= Particle type =-
|
||||
# The type of particle that will appear when a new block generates.
|
||||
# You can find all types at https://hub.spigotmc.org/javadocs/spigot/org/bukkit/Particle.html
|
||||
particle-type: 'SPELL_INSTANT'
|
||||
|
||||
# -= Sound type =-
|
||||
# The sound that will play when a new block generates.
|
||||
# You can find all sounds at https://hub.spigotmc.org/javadocs/spigot/org/bukkit/Sound.html
|
||||
sound-type: 'BLOCK_NOTE_BLOCK_GUITAR'
|
||||
|
||||
# -= Sound pitch =-
|
||||
# The pitch of the sound that will play when a new block generates.
|
||||
sound-pitch: 3
|
||||
|
||||
# -= Sound volume =-
|
||||
sound-volume: 2
|
||||
|
||||
# -= Default values =-
|
||||
# This is a list of all options and defaults.
|
||||
default-values:
|
||||
main:
|
||||
enabled: true
|
||||
|
||||
quit:
|
||||
enabled: true
|
||||
|
||||
play:
|
||||
enabled: true
|
||||
|
||||
single:
|
||||
enabled: true
|
||||
|
||||
spectator:
|
||||
enabled: true
|
||||
|
||||
community:
|
||||
enabled: true
|
||||
|
||||
leaderboards:
|
||||
enabled: true
|
||||
|
||||
settings:
|
||||
enabled: true
|
||||
|
||||
lang:
|
||||
enabled: true
|
||||
default: "en"
|
||||
|
||||
chat:
|
||||
enabled: true
|
||||
|
||||
parkour_settings:
|
||||
|
||||
item:
|
||||
enabled: true
|
||||
|
||||
items:
|
||||
|
||||
styles:
|
||||
enabled: true
|
||||
default: "red"
|
||||
|
||||
leads:
|
||||
enabled: true
|
||||
default: 4
|
||||
|
||||
time:
|
||||
enabled: true
|
||||
default: 6000
|
||||
|
||||
schematics:
|
||||
enabled: true
|
||||
default: 0.6
|
||||
|
||||
scoreboard:
|
||||
enabled: true
|
||||
default: true
|
||||
|
||||
fall_message:
|
||||
enabled: true
|
||||
default: true
|
||||
|
||||
particles:
|
||||
enabled: true
|
||||
default: true
|
||||
|
||||
sound:
|
||||
enabled: true
|
||||
default: true
|
||||
|
||||
special_blocks:
|
||||
enabled: true
|
||||
default: true
|
||||
|
||||
lobby:
|
||||
enabled: true
|
||||
|
||||
visibility:
|
||||
enabled: true
|
||||
|
||||
player_management:
|
||||
enabled: true
|
||||
|
|
@ -1,295 +0,0 @@
|
|||
# English (English, en)
|
||||
# By loki
|
||||
# Copy this file and rename it to add your own language!
|
||||
# Materials have to be from: https://hub.spigotmc.org/javadocs/spigot/org/bukkit/Material.html
|
||||
# Learn more about colouring: https://docs.adventure.kyori.net/minimessage/format.html#format
|
||||
|
||||
name: "English"
|
||||
|
||||
main:
|
||||
name: "&#FFFFFFParkour"
|
||||
|
||||
play:
|
||||
name: "&#FFFFFFPlay"
|
||||
|
||||
item:
|
||||
material: "sugar_cane"
|
||||
name: "�D4FF&lPlay"
|
||||
lore: ""
|
||||
|
||||
single:
|
||||
name: "&#FFFFFFSingle"
|
||||
|
||||
item:
|
||||
material: "ender_pearl"
|
||||
name: "�D4FF&lSingle-player"
|
||||
lore: ""
|
||||
|
||||
default:
|
||||
material: "barrel"
|
||||
name: "�D4FF&lDefault"
|
||||
lore: "񢩈The default gamemode."
|
||||
|
||||
speedrun:
|
||||
material: "clock"
|
||||
name: "�D4FF&lSpeedrun"
|
||||
lore: "񢩈Race against time! Blocks||񢩈disappear after you step on them."
|
||||
|
||||
gravity-shift:
|
||||
material: "feather"
|
||||
name: "�D4FF&lGravity Shift"
|
||||
lore: "񢩈Gravity changes direction||񢩈as you parkour!"
|
||||
|
||||
hardcore:
|
||||
material: "skeleton_skull"
|
||||
name: "�D4FF&lHardcore"
|
||||
lore: "񢩈One mistake and you're out!||񢩈No second chances."
|
||||
|
||||
spectator:
|
||||
name: "&#FFFFFFSpectator"
|
||||
|
||||
join: "�D4FFYou are now a spectator. You can use \"/parkour\" to leave/change your gamemode."
|
||||
action_bar: "�D4FFYou are a spectator &#A0A0A0• �D4FFUse \"/parkour\" to leave."
|
||||
|
||||
other_join: "ᤀ&l+ &r&#A0A0A0%s started spectating you"
|
||||
other_leave: "&#FF4444&l- &r&#A0A0A0%s stopped spectating you"
|
||||
|
||||
item:
|
||||
material: "glass"
|
||||
name: "�D4FF&lSpectator"
|
||||
lore: ""
|
||||
|
||||
head:
|
||||
material: ""
|
||||
name: "�D4FF&l%s"
|
||||
lore: ""
|
||||
|
||||
community:
|
||||
name: "&#FFFFFFCommunity"
|
||||
|
||||
item:
|
||||
material: "oak_boat"
|
||||
name: "�D4FF&lCommunity"
|
||||
lore: ""
|
||||
|
||||
leaderboards:
|
||||
name: "&#FFFFFFLeaderboards"
|
||||
|
||||
item:
|
||||
material: "gold_nugget"
|
||||
name: "�D4FF&lLeaderboards"
|
||||
lore: ""
|
||||
|
||||
sort:
|
||||
material: "bookshelf"
|
||||
name: "�D4FF&lSorted by %s"
|
||||
lore: ""
|
||||
values: ["score", "time", "difficulty"]
|
||||
|
||||
head:
|
||||
material: ""
|
||||
name: "�D4FF&l#%r - %p"
|
||||
lore: "�D4FFScore: &#A0A0A0%s||�D4FFTime: &#A0A0A0%t||�D4FFDifficulty: &#A0A0A0%d񢩈/1.0"
|
||||
|
||||
settings:
|
||||
name: "&#FFFFFFSettings"
|
||||
|
||||
item:
|
||||
material: "comparator"
|
||||
name: "�D4FF&lSettings"
|
||||
lore: ""
|
||||
|
||||
lang:
|
||||
name: "&#FFFFFFLanguage"
|
||||
|
||||
item:
|
||||
material: "writable_book"
|
||||
name: "�D4FF&lLanguage"
|
||||
lore: "񢩈Currently: �D4FF&l%s"
|
||||
|
||||
chat:
|
||||
material: "crossbow"
|
||||
name: "�D4FF&lChat"
|
||||
lore: "񢩈Currently: �D4FF&l%s||񢩈Select who you can chat with"
|
||||
values: ["lobby only", "players only", "public"]
|
||||
|
||||
formats:
|
||||
lobby: "�D4FF(Lobby) �D4FF%s 񢩈> &#A0A0A0%s"
|
||||
players: "�D4FF(Players) �D4FF%s 񢩈> &#A0A0A0%s"
|
||||
|
||||
parkour_settings:
|
||||
|
||||
enabled: "�D4FF&l✔"
|
||||
disabled: "&#FF4444&l✘"
|
||||
|
||||
item:
|
||||
material: "redstone"
|
||||
name: "�D4FF&lParkour Settings"
|
||||
lore: "񢩈Change the way your parkour appears."
|
||||
|
||||
items:
|
||||
|
||||
no_change:
|
||||
material: "barrier"
|
||||
name: "B0000&lYou cant change this right now!"
|
||||
lore: "&#A0A0A0You have a score above 0,||&#A0A0A0which means you cant change this setting."
|
||||
|
||||
styles:
|
||||
name: "&#FFFFFFStyles"
|
||||
|
||||
style_item:
|
||||
material: ""
|
||||
name: "&#FFFFFF&l%s"
|
||||
lore: "�D4FFCategory: &#A0A0A0%s"
|
||||
|
||||
item:
|
||||
material: "crafting_table"
|
||||
name: "�D4FF&lStyle"
|
||||
lore: "�D4FFCurrently: &#A0A0A0%s||&#A0A0A0Choose which blocks will be used."
|
||||
|
||||
leads:
|
||||
material: "feather"
|
||||
name: "�D4FF&lLead"
|
||||
lore: "�D4FFCurrently: &#A0A0A0%s blocks||&#A0A0A0How many blocks will||&#A0A0A0be generated in front of you."
|
||||
|
||||
time:
|
||||
material: "clock"
|
||||
name: "�D4FF&lTime"
|
||||
lore: "�D4FFCurrently: &#A0A0A0%s||&#A0A0A0Your current visual time."
|
||||
|
||||
schematics:
|
||||
material: ""
|
||||
name: "�D4FF&lSchematic difficulty"
|
||||
lore: "�D4FFCurrently: &#A0A0A0%s||&#A0A0A0The difficulty of schematics"
|
||||
values: ["&#FF4444off", "�D4FFeasy", "&#FFFF00medium", "�D4FFhard", "B0000very hard"]
|
||||
|
||||
scoreboard:
|
||||
material: ""
|
||||
name: "Show scoreboard"
|
||||
lore: "&#A0A0A0Currently: %s||&#A0A0A0If enabled shows the scoreboard."
|
||||
|
||||
fall_message:
|
||||
item:
|
||||
material: ""
|
||||
name: "Show fall message"
|
||||
lore: "&#A0A0A0Currently: %s||&#A0A0A0If enabled shows a message when you fall||&#A0A0A0with extra info."
|
||||
|
||||
divider: "񢩈&m-----------------------------------"
|
||||
score: "�D4FF&lScore &r&#FFFFFF%s"
|
||||
time: "�D4FF&lTime &r&#FFFFFF%s"
|
||||
high_score: "�D4FF&lHigh-score &r&#FFFFFF%s"
|
||||
|
||||
formats:
|
||||
miss: "&#A0A0A0You missed your high score by %s points!"
|
||||
beat: "&#A0A0A0You beat your high score by %s points!"
|
||||
tied: "&#A0A0A0You tied your high score!"
|
||||
|
||||
particles:
|
||||
material: ""
|
||||
name: "Show particles"
|
||||
lore: "&#A0A0A0Currently: %s||&#A0A0A0If enabled, every generated block||&#A0A0A0will show particles."
|
||||
|
||||
sound:
|
||||
material: ""
|
||||
name: "Play sounds"
|
||||
lore: "&#A0A0A0Currently: %s||&#A0A0A0If enabled, every generated block||&#A0A0A0will play a sound."
|
||||
|
||||
special_blocks:
|
||||
material: ""
|
||||
name: "Use special blocks"
|
||||
lore: "&#A0A0A0Currently: %s||&#A0A0A0If enabled, uses special blocks like ice and slabs."
|
||||
|
||||
score_difficulty:
|
||||
material: ""
|
||||
name: "Score difficulty"
|
||||
lore: "&#A0A0A0Currently: %s||&#A0A0A0When enabled, the higher score you have,||&#A0A0A0the more difficult it gets."
|
||||
|
||||
lobby:
|
||||
name: "&#FFFFFFLobby"
|
||||
|
||||
other_join: 'ᤀ&l+ &r&#A0A0A0%s joined your lobby'
|
||||
other_leave: '&#FF4444&l- &r&#A0A0A0%s left your lobby'
|
||||
|
||||
item:
|
||||
material: "iron_ingot"
|
||||
name: "�D4FF&lYour Lobby"
|
||||
lore: ""
|
||||
|
||||
visibility:
|
||||
material: "firework_rocket"
|
||||
name: "�D4FF&lVisibility"
|
||||
lore: "񢩈Currently: �D4FF&l%s||񢩈Select who can join your lobby."
|
||||
values: ["private", "invite-only", "public"]
|
||||
|
||||
player_management:
|
||||
name: "&#FFFFFFManage players"
|
||||
|
||||
kicked: "&#A0A0A0You've been kicked from your lobby by the lobby owner."
|
||||
advice: "&#A0A0A0Set your lobby visibility to invite-only or private to avoid people randomly joining."
|
||||
muted: "&#A0A0A0You've been muted by the lobby owner."
|
||||
unmuted: "&#A0A0A0You've been unmuted by the lobby owner."
|
||||
|
||||
item:
|
||||
material: "cake"
|
||||
name: "�D4FF&lManage Players"
|
||||
lore: "񢩈Kick or mute players here."
|
||||
|
||||
head:
|
||||
material: ""
|
||||
name: "�D4FF&l%s"
|
||||
lore: ""
|
||||
top: "񢩈Muted in lobby||"
|
||||
bottom: "�D4FFLeft click񢩈 to kick from lobby||�D4FFRight click񢩈 to (un)mute in lobby"
|
||||
|
||||
scoreboard:
|
||||
|
||||
title: "�D4FF&lLoParkour"
|
||||
|
||||
lines:
|
||||
- "�FF"
|
||||
- "�D4FFScore &#FFFFFF%score%"
|
||||
- "�D4FFTime &#FFFFFF%time%"
|
||||
- "�D4FFHighscore &#FFFFFF%high_score%"
|
||||
- "&#FF4444"
|
||||
- "�D4FF&lserver.ip"
|
||||
|
||||
other:
|
||||
|
||||
no_do: "&#A0A0A0You can't do this right now."
|
||||
no_teleporting: "&#FFFFFFYou can't enter the parkour world by teleporting!"
|
||||
customize: "&#A0A0A0Customize your parkour experience by using \"/parkour\""
|
||||
|
||||
quit:
|
||||
material: "barrier"
|
||||
name: "�D4FF&lQuit"
|
||||
lore: ""
|
||||
|
||||
close:
|
||||
material: "arrow"
|
||||
name: "�D4FF&lClose"
|
||||
lore: ""
|
||||
|
||||
iep:
|
||||
material: "elytra"
|
||||
name: "�D4FF&lFlying"
|
||||
lore: "&#A0A0A0Parkour with elytra!"
|
||||
|
||||
modes:
|
||||
elytra:
|
||||
material: "elytra"
|
||||
name: "�D4FF&lElytra"
|
||||
lore: "&#A0A0A0Fly through rings!||&#A0A0A0Use fireworks to boost"
|
||||
start_hint: "&#A0A0A0Fly through the rings! Use fireworks to boost."
|
||||
|
||||
race:
|
||||
material: "golden_boots"
|
||||
name: "&#FFD700&lRace"
|
||||
lore: "&#A0A0A0First to reach target score!||&#A0A0A0Compete for fastest time"
|
||||
completed: "&#FFD700Finished! Score: &#FFFFFF%s &#A0A0Ain &#FFFFFF%s"
|
||||
|
||||
coop:
|
||||
material: "player_head"
|
||||
name: "�FF00&lCo-op"
|
||||
lore: "&#A0A0A0Team up with friends!||&#A0A0A0Shared score and progress"
|
||||
player_joined: "�FF00%s &#A0A0Ajoined the co-op session!"
|
||||
stats: "&#A0A0ATeam score: &#FFFFFF%s &#A0A0A| Your blocks: &#FFFFFF%s &#A0A0A(&#FFFFFF%s%%&#A0A0A)"
|
||||
Loading…
Add table
Add a link
Reference in a new issue