LoParkour/src/main/java/dev/loki/loparkour/ghost/service/GhostService.java
loki5512344 8c8046a4f5
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)
2026-08-06 13:13:59 +02:00

99 lines
3.3 KiB
Java

package dev.loki.loparkour.ghost.service;
import dev.loki.loparkour.config.core.Config;
import dev.loki.loparkour.ghost.core.GhostManager;
import dev.loki.loparkour.ghost.core.GhostRecorder;
import dev.loki.loparkour.ghost.model.GhostData;
import dev.loki.loparkour.generator.core.coordinator.ParkourGenerator;
import dev.loki.loparkour.player.core.ParkourPlayer;
import org.jetbrains.annotations.NotNull;
import java.util.Map;
import java.util.Set;
import java.util.UUID;
import java.util.concurrent.ConcurrentHashMap;
/**
* Plugin-owned singleton that records each player's runs as {@link GhostData}
* while the server's {@code ghost-mode} is enabled and the player's per-player
* {@code ghostRecording} toggle is on.
*/
public class GhostService {
private final GhostManager manager = new GhostManager();
private final Map<UUID, GhostRecorder> recorders = new ConcurrentHashMap<>();
private final Set<UUID> replaying = ConcurrentHashMap.newKeySet();
public GhostManager getManager() {
return manager;
}
private boolean serverEnabled() {
return Config.CONFIG.getBoolean("ghost-mode.enabled");
}
private boolean recordingEnabled(ParkourPlayer pp) {
return serverEnabled()
&& !replaying.contains(pp.getUUID())
&& Boolean.TRUE.equals(pp.ghostRecording);
}
public void startRun(ParkourGenerator generator) {
for (final ParkourPlayer pp : generator.getPlayers()) {
if (recordingEnabled(pp)) {
recorders.computeIfAbsent(pp.getUUID(), k -> new GhostRecorder())
.startRecording(pp.getLocation());
} else {
recorders.remove(pp.getUUID());
}
}
}
public void recordPlayers(ParkourGenerator generator) {
for (final ParkourPlayer pp : generator.getPlayers()) {
final GhostRecorder recorder = recorders.get(pp.getUUID());
if (recorder != null) {
recorder.recordFrame(pp.getLocation());
}
}
}
public void endRun(ParkourGenerator generator, int score) {
for (final ParkourPlayer pp : generator.getPlayers()) {
final GhostRecorder recorder = recorders.remove(pp.getUUID());
if (recorder != null && recorder.isRecording()) {
final String worldName = pp.getLocation().getWorld() == null
? "" : pp.getLocation().getWorld().getName();
final GhostData data = recorder.stopRecording(
pp.getUUID(), pp.player.getName(), score, worldName);
manager.saveRun(pp.getUUID(), data);
}
}
}
public void playerLeft(UUID uuid) {
recorders.remove(uuid);
}
public boolean isReplaying(UUID uuid) {
return replaying.contains(uuid);
}
public void setReplaying(UUID uuid, boolean replaying) {
if (replaying) {
this.replaying.add(uuid);
} else {
this.replaying.remove(uuid);
}
}
public void startReplay(@NotNull org.bukkit.entity.Player viewer, @NotNull GhostData run) {
playerLeft(viewer.getUniqueId());
setReplaying(viewer.getUniqueId(), true);
new GhostReplay(viewer, run, () -> setReplaying(viewer.getUniqueId(), false)).start();
}
public void shutdown() {
recorders.clear();
}
}