feat: personal run replays (ghost system)

- GhostData: worldName field, format v4; GhostManager stores last N runs per player (ghosts/<uuid>/<ts>.ghost)
- GhostService: per-player recording (start on run start, every tick, finalize on fall), gated by ghost-mode.enabled + per-player toggle
- PlayerGhost: packet-based fake-player NPC with viewer skin (NMS via remapNms gradle task, reflection send, graceful degradation)
- GhostReplay: teleport viewer to run start, replay frames via PlayerGhost, restore on finish
- /lp replay [index] command + tab completion; settings menu ghost_recording toggle (persisted disk/SQL)
- config: ghost-mode.{enabled,max-runs}, remove dead show-top/transparency + GHOST_* statics
- tests: GhostData v4 round-trip, GhostManager eviction/ordering, yawToFixed (79 total)
This commit is contained in:
loki5512344 2026-08-06 13:13:59 +02:00
parent 4c255111f0
commit 8c8046a4f5
Signed by: boba
GPG key ID: 253067914055423B
30 changed files with 1027 additions and 227 deletions

View file

@ -33,6 +33,7 @@ public enum ParkourOption {
FALL_MESSAGE("settings.parkour_settings.items.fall_message", "LoParkour.settings.fall_message"),
PARTICLES("settings.parkour_settings.items.particles", "LoParkour.settings.particles"),
SOUND("settings.parkour_settings.items.sound", "LoParkour.settings.sound"),
GHOST_RECORDING("settings.parkour_settings.items.ghost_recording", "LoParkour.settings.ghost_recording"),
SPECIAL_BLOCKS("settings.parkour_settings.items.special_blocks", "LoParkour.settings.special_blocks"),
LANG("settings.lang", "LoParkour.settings.lang"),

View file

@ -27,6 +27,7 @@ public class ParkourSettingsMenu extends LPMenu {
private static final int SLOT_SPECIAL_BLOCKS = 12;
private static final int SLOT_FALL_MESSAGE = 13;
private static final int SLOT_SCOREBOARD = 14;
private static final int SLOT_GHOST_RECORDING = 15;
private static final int SLOT_STYLE = 19;
private static final int SLOT_SCHEM_DIFF = 20;
private static final int SLOT_CLOSE = 31;
@ -55,47 +56,54 @@ public class ParkourSettingsMenu extends LPMenu {
final String title = Locales.getString(locale, "settings.name");
baseGui(title, ROWS_MAIN)
.setItem(SLOT_PARTICLES, toggleItem(locale, "particles", pp.particles), e -> {
pp.particles = !pp.particles;
pp.updateGeneratorSettings(pp.session.generator);
open(pp);
})
.setItem(SLOT_SOUND, toggleItem(locale, "sound", pp.sound), e -> {
pp.sound = !pp.sound;
pp.updateGeneratorSettings(pp.session.generator);
open(pp);
})
.setItem(SLOT_SPECIAL_BLOCKS, toggleItem(locale, "special_blocks", pp.useSpecialBlocks), e -> {
if (pp.session.generator.state.score == 0) {
pp.useSpecialBlocks = !pp.useSpecialBlocks;
pp.updateGeneratorSettings(pp.session.generator);
}
open(pp);
})
.setItem(SLOT_FALL_MESSAGE, toggleItem(locale, "fall_message", pp.showFallMessage), e -> {
pp.showFallMessage = !pp.showFallMessage;
pp.updateGeneratorSettings(pp.session.generator);
open(pp);
})
.setItem(SLOT_SCOREBOARD, toggleItem(locale, "scoreboard", pp.showScoreboard), e -> {
pp.showScoreboard = !pp.showScoreboard;
pp.updateGeneratorSettings(pp.session.generator);
open(pp);
})
.setItem(SLOT_PARTICLES, toggleItem(locale, "particles", pp.particles),
e -> toggleSetting(pp, () -> pp.particles = !pp.particles))
.setItem(SLOT_SOUND, toggleItem(locale, "sound", pp.sound),
e -> toggleSetting(pp, () -> pp.sound = !pp.sound))
.setItem(SLOT_SPECIAL_BLOCKS, toggleItem(locale, "special_blocks", pp.useSpecialBlocks),
e -> toggleSpecialBlocks(pp))
.setItem(SLOT_FALL_MESSAGE, toggleItem(locale, "fall_message", pp.showFallMessage),
e -> toggleSetting(pp, () -> pp.showFallMessage = !pp.showFallMessage))
.setItem(SLOT_SCOREBOARD, toggleItem(locale, "scoreboard", pp.showScoreboard),
e -> toggleSetting(pp, () -> pp.showScoreboard = !pp.showScoreboard))
.setItem(SLOT_GHOST_RECORDING, toggleItem(locale, "ghost_recording",
Boolean.TRUE.equals(pp.ghostRecording)), e -> toggleGhostRecording(pp))
.setItem(SLOT_STYLE, styleItem(pp), e -> openStyleMenu(pp))
.setItem(SLOT_SCHEM_DIFF, schemDiffItem(pp), e -> {
if (pp.session.generator.state.score == 0) {
final List<Double> diffs = List.of(0.0, DIFF_QUARTER, DIFF_HALF, DIFF_THREE_QUARTER, 1.0);
final int idx = diffs.indexOf(pp.schematicDifficulty);
pp.schematicDifficulty = diffs.get((idx + 1) % diffs.size());
pp.updateGeneratorSettings(pp.session.generator);
}
open(pp);
})
.setItem(SLOT_SCHEM_DIFF, schemDiffItem(pp), e -> cycleSchematicDifficulty(pp))
.setItem(SLOT_CLOSE, closeButton(player), e -> Menus.SETTINGS.open(player))
.open(player);
}
private void toggleSetting(ParkourPlayer pp, Runnable update) {
update.run();
pp.updateGeneratorSettings(pp.session.generator);
open(pp);
}
private void toggleSpecialBlocks(ParkourPlayer pp) {
if (pp.session.generator.state.score == 0) {
pp.useSpecialBlocks = !pp.useSpecialBlocks;
pp.updateGeneratorSettings(pp.session.generator);
}
open(pp);
}
private void cycleSchematicDifficulty(ParkourPlayer pp) {
if (pp.session.generator.state.score == 0) {
final List<Double> diffs = List.of(0.0, DIFF_QUARTER, DIFF_HALF, DIFF_THREE_QUARTER, 1.0);
final int idx = diffs.indexOf(pp.schematicDifficulty);
pp.schematicDifficulty = diffs.get((idx + 1) % diffs.size());
pp.updateGeneratorSettings(pp.session.generator);
}
open(pp);
}
private void toggleGhostRecording(ParkourPlayer pp) {
pp.ghostRecording = !Boolean.TRUE.equals(pp.ghostRecording);
pp.save(false);
open(pp);
}
private ItemStack toggleItem(String locale, String key, boolean value) {
final Material mat = value ? Material.LIME_STAINED_GLASS_PANE : Material.RED_STAINED_GLASS_PANE;
final String symbol = Locales.getString(locale, "settings.parkour_settings." + (value ? "enabled" : "disabled"));
@ -106,13 +114,7 @@ public class ParkourSettingsMenu extends LPMenu {
final ItemMeta meta = item.getItemMeta();
if (meta != null) {
meta.setDisplayName(ColorUtil.color(name + " §7[" + symbol + "§7]"));
if (lore != null && !lore.isEmpty()) {
final List<String> loreLines = new ArrayList<>();
for (final String line : lore.split("\\|\\|")) {
loreLines.add(ColorUtil.color(line.replace("%s", symbol)));
}
meta.setLore(loreLines);
}
applyLore(meta, lore, symbol);
item.setItemMeta(meta);
}
return item;
@ -131,13 +133,7 @@ public class ParkourSettingsMenu extends LPMenu {
final String name = Locales.getString(pp.locale, "settings.parkour_settings.items.schematics.name");
final String lore = Locales.getString(pp.locale, "settings.parkour_settings.items.schematics.lore");
meta.setDisplayName(ColorUtil.color(name.replace("%s", label)));
if (lore != null && !lore.isEmpty()) {
final List<String> loreLines = new ArrayList<>();
for (final String line : lore.split("\\|\\|")) {
loreLines.add(ColorUtil.color(line.replace("%s", label)));
}
meta.setLore(loreLines);
}
applyLore(meta, lore, label);
item.setItemMeta(meta);
}
return item;
@ -150,18 +146,23 @@ public class ParkourSettingsMenu extends LPMenu {
final ItemMeta meta = item.getItemMeta();
if (meta != null) {
meta.setDisplayName(ColorUtil.color(name.replace("%s", pp.style)));
if (lore != null && !lore.isEmpty()) {
final List<String> loreLines = new ArrayList<>();
for (final String line : lore.split("\\|\\|")) {
loreLines.add(ColorUtil.color(line.replace("%s", pp.style)));
}
meta.setLore(loreLines);
}
applyLore(meta, lore, pp.style);
item.setItemMeta(meta);
}
return item;
}
private void applyLore(ItemMeta meta, String lore, String replacement) {
if (lore == null || lore.isEmpty()) {
return;
}
final List<String> loreLines = new ArrayList<>();
for (final String line : lore.split("\\|\\|")) {
loreLines.add(ColorUtil.color(line.replace("%s", replacement)));
}
meta.setLore(loreLines);
}
private void openStyleMenu(ParkourPlayer pp) {
final Player player = pp.player;
final String locale = pp.locale;
@ -179,7 +180,6 @@ public class ParkourSettingsMenu extends LPMenu {
if (Config.CONFIG.getBoolean("permissions.per-style") && !player.hasPermission(perm)) {
continue;
}
final ItemStack item = new ItemStack(style.getNext());
final ItemMeta meta = item.getItemMeta();
if (meta != null) {