diff --git a/src/main/java/net/tfminecraft/trialrooms/TrialRooms.java b/src/main/java/net/tfminecraft/trialrooms/TrialRooms.java index fbd699b..2c43b78 100644 --- a/src/main/java/net/tfminecraft/trialrooms/TrialRooms.java +++ b/src/main/java/net/tfminecraft/trialrooms/TrialRooms.java @@ -7,6 +7,7 @@ import org.bukkit.plugin.java.JavaPlugin; import net.tfminecraft.trialrooms.environment.entrance.ui.EntranceEditor; +import net.tfminecraft.trialrooms.environment.spawner.LabelCleanup; import net.tfminecraft.trialrooms.environment.spawner.ui.ActiveSpawnerEditor; import net.tfminecraft.trialrooms.loader.ConfigLoader; import net.tfminecraft.trialrooms.loader.SpawnerLoader; @@ -46,6 +47,8 @@ public void onEnable() { Bukkit.getPluginManager().registerEvents(EntranceManager.get(), this); Bukkit.getPluginManager().registerEvents(new ConversionDeathGuard(), this); Bukkit.getPluginManager().registerEvents(new PlayerManager(TrialRooms.getInstance()), this); + // Removes label displays left behind by older versions when their chunk loads. + Bukkit.getPluginManager().registerEvents(new LabelCleanup(), this); // Load DTOs @@ -54,6 +57,8 @@ public void onEnable() { List chests = Database.loadChests(); spawnerManager.hydrateFrom(spawners); ChestManager.get().hydrateFrom(chests); + // Chunks already loaded before the listener existed (spawn chunks, /reload). + LabelCleanup.sweepLoadedChunks(); persistenceReady = true; spawnerManager.start(); getLogger().info("TrialRooms Plugin Enabled!"); diff --git a/src/main/java/net/tfminecraft/trialrooms/environment/chest/LootChest.java b/src/main/java/net/tfminecraft/trialrooms/environment/chest/LootChest.java index 5925565..4ab2e0a 100644 --- a/src/main/java/net/tfminecraft/trialrooms/environment/chest/LootChest.java +++ b/src/main/java/net/tfminecraft/trialrooms/environment/chest/LootChest.java @@ -148,6 +148,13 @@ public void removeHologram() { } catch (Exception ignored) {} } + /** The chest was deleted: remove its label for good. */ + public void disposeHologram() { + try { + hologram.dispose(TrialRooms.getInstance()); + } catch (Exception ignored) {} + } + private Location normalize(Location in) { if (in == null) return null; return new Location(in.getWorld(), in.getBlockX(), in.getBlockY(), in.getBlockZ()); diff --git a/src/main/java/net/tfminecraft/trialrooms/environment/entrance/Entrance.java b/src/main/java/net/tfminecraft/trialrooms/environment/entrance/Entrance.java index e5adacc..0e5d169 100644 --- a/src/main/java/net/tfminecraft/trialrooms/environment/entrance/Entrance.java +++ b/src/main/java/net/tfminecraft/trialrooms/environment/entrance/Entrance.java @@ -3,6 +3,7 @@ import net.tfminecraft.tlibs.TLibs; import net.tfminecraft.trialrooms.TrialRooms; import net.tfminecraft.trialrooms.environment.spawner.Hologram; +import net.tfminecraft.trialrooms.environment.spawner.LegacyLabelTexts; import net.tfminecraft.trialrooms.persist.Database; import org.bukkit.ChatColor; @@ -39,7 +40,8 @@ public Entrance(Location entranceLodestone) { this.entranceHolo = new Hologram( () -> this.entranceLoc, - this::buildEntranceText + this::buildEntranceText, + LegacyLabelTexts::entrance ); this.entranceHolo.setYOffset(1.55); @@ -89,6 +91,12 @@ public void removeHolograms(JavaPlugin plugin) { try { exitHolo.remove(plugin); } catch (Exception ignored) {} } + /** The entrance was deleted: remove its labels for good. */ + public void disposeHolograms(JavaPlugin plugin) { + try { entranceHolo.dispose(plugin); } catch (Exception ignored) {} + try { exitHolo.dispose(plugin); } catch (Exception ignored) {} + } + public void refreshHolograms(JavaPlugin plugin) { // Cheap refresh (safe even if not spawned) try { entranceHolo.refresh(plugin); } catch (Exception ignored) {} diff --git a/src/main/java/net/tfminecraft/trialrooms/environment/spawner/ActiveSpawner.java b/src/main/java/net/tfminecraft/trialrooms/environment/spawner/ActiveSpawner.java index f30cf09..792acd1 100644 --- a/src/main/java/net/tfminecraft/trialrooms/environment/spawner/ActiveSpawner.java +++ b/src/main/java/net/tfminecraft/trialrooms/environment/spawner/ActiveSpawner.java @@ -108,11 +108,13 @@ public ActiveSpawner(Location loc, Spawner spawner) { this.levelHologram = new Hologram( () -> this.loc, - this::getLevelFormatted + this::getLevelFormatted, + LegacyLabelTexts::spawnerLevel ); this.statusHologram = new Hologram( () -> this.loc, - this::getStatusText + this::getStatusText, + LegacyLabelTexts::spawnerStatus ); this.statusHologram.setYOffset(1.5); @@ -190,7 +192,8 @@ public void destroy() { } killActiveEnemiesSilently(); // << add this destroyed = true; - removeHolograms(TrialRooms.getInstance()); + levelHologram.dispose(TrialRooms.getInstance()); + statusHologram.dispose(TrialRooms.getInstance()); } // ---------- Ticking ---------- diff --git a/src/main/java/net/tfminecraft/trialrooms/environment/spawner/Hologram.java b/src/main/java/net/tfminecraft/trialrooms/environment/spawner/Hologram.java index 8a28994..5f254d9 100644 --- a/src/main/java/net/tfminecraft/trialrooms/environment/spawner/Hologram.java +++ b/src/main/java/net/tfminecraft/trialrooms/environment/spawner/Hologram.java @@ -2,112 +2,192 @@ import org.bukkit.Bukkit; import org.bukkit.Location; +import org.bukkit.NamespacedKey; import org.bukkit.World; import org.bukkit.entity.Entity; import org.bukkit.entity.TextDisplay; +import org.bukkit.persistence.PersistentDataType; import org.bukkit.plugin.java.JavaPlugin; +import java.util.ArrayList; +import java.util.Collections; +import java.util.HashSet; +import java.util.List; +import java.util.Set; import java.util.UUID; +import java.util.WeakHashMap; +import java.util.function.Predicate; import java.util.function.Supplier; - +import java.util.regex.Pattern; + +/** + * A floating TextDisplay label above a TrialRooms block. + * + *

The display is a runtime-only entity: it is never saved with the chunk and is + * recreated by {@link #tick()} while a player is within view range. Every display is + * tagged with {@link #LABEL_KEY} so leftovers can be recognised by {@link LabelCleanup}. + */ public class Hologram { + /** PDC marker on every label display spawned by TrialRooms. */ + public static final NamespacedKey LABEL_KEY = new NamespacedKey("trialrooms", "label"); + + /** Every live hologram, so the cleanup knows label positions and current displays. */ + private static final Set REGISTRY = Collections.newSetFromMap(new WeakHashMap<>()); + private final Supplier locationSupplier; // where to place it private final Supplier nameSupplier; // what to display + // recognises this label's plain text on displays saved by older versions + private final Predicate legacyText; private UUID entityId; + private boolean disposed; // owner deleted: never show this label again private double yOffset = 1.0; // height above block center // view radius squared (occlusion is handled by the engine; we keep radius for perf) private static final double VIEW_RADIUS_SQ = 96 * 96; + private static final Pattern COLOR_CODES = Pattern.compile("(?i)§[0-9A-FK-ORX]"); + + /** A live label: where it sits and which plain texts an old saved copy of it may show. */ + record Label(Location position, Predicate legacyText) {} + + /** A label whose old saved copies show exactly the text it shows now. */ public Hologram(Supplier locationSupplier, Supplier nameSupplier) { + this(locationSupplier, nameSupplier, null); + } + + /** + * @param legacyText matches the plain (uncoloured) text an old saved copy of this label + * may show; null means "exactly the current text" + */ + public Hologram(Supplier locationSupplier, Supplier nameSupplier, + Predicate legacyText) { this.locationSupplier = locationSupplier; this.nameSupplier = nameSupplier; + this.legacyText = legacyText != null ? legacyText : this::showsCurrentText; + REGISTRY.add(this); } public void setYOffset(double yOffset) { this.yOffset = yOffset; } + /** Shows or updates the label now if a player can see it; otherwise {@link #tick()} shows it later. */ public void spawn(JavaPlugin plugin) { - runSync(plugin, () -> { - TextDisplay td = getOrCreateDisplay(); - if (td == null) return; - Location pos = position(); - if (pos != null) td.teleport(pos); - applyText(td); - }); + runSync(plugin, this::update); } + /** Updates text and position of the label, creating it only if a player can see it. */ public void refresh(JavaPlugin plugin) { - runSync(plugin, () -> { - TextDisplay td = findDisplay(); - if (td == null || td.isDead()) { - td = getOrCreateDisplay(); - if (td == null) return; - } - Location pos = position(); - if (pos != null) td.teleport(pos); - applyText(td); - }); + runSync(plugin, this::update); } public void remove(JavaPlugin plugin) { - runSync(plugin, () -> { - TextDisplay td = findDisplay(); - if (td != null && !td.isDead()) td.remove(); - entityId = null; - }); + runSync(plugin, this::removeCurrent); + } + + /** Permanently retires the label: removes its display and stops LabelCleanup treating its spot as live. */ + public void dispose(JavaPlugin plugin) { + disposed = true; + REGISTRY.remove(this); + remove(plugin); } /** Distance-based lifecycle (spawn near players, despawn when nobody nearby). */ public void tick() { + if (disposed) { + removeCurrent(); + return; + } Location pos = position(); if (pos == null) return; TextDisplay td = findDisplay(); - - boolean anyNearby = false; - for (var p : Bukkit.getOnlinePlayers()) { - if (p.getWorld() == pos.getWorld() && p.getLocation().distanceSquared(pos) <= VIEW_RADIUS_SQ) { - anyNearby = true; break; - } - } - - if (anyNearby) { - if (td == null || td.isDead()) { - spawn(plugin()); + if (anyPlayerNear(pos) && isChunkLoaded(pos)) { + if (td == null) { + create(pos); } else { - // keep text up to date in case suppliers change frequently + // follow a moved label, and keep text up to date in case suppliers change frequently + if (!LabelCleanup.samePosition(td.getLocation(), pos)) td.teleport(pos); applyText(td); } } else if (td != null) { - remove(plugin()); + removeCurrent(); } } - // ===== Internals ===== + // ===== Registry (used by LabelCleanup) ===== - private TextDisplay getOrCreateDisplay() { - Location base = locationSupplier.get(); - if (base == null) return null; - World world = base.getWorld(); - if (world == null) return null; + /** All live labels with a position (labels without one, or in an unloaded world, are skipped). */ + static List

Older versions saved every label display with the chunk and spawned a new one after + * each restart or chunk reload, so thousands of identical copies piled up. A display is + * only removed when all of these hold: + *

+ */ +public final class LabelCleanup implements Listener { + + private record ChunkKey(World world, int x, int z) {} + + private static final PlainTextComponentSerializer PLAIN = PlainTextComponentSerializer.plainText(); + + /** Largest coordinate difference still counted as "the label position". */ + static final double POSITION_EPSILON = 1.0e-3; + /** Radius of the entity lookup around a label before it spawns its display. */ + static final double SEARCH_RADIUS = 0.25; + + @EventHandler + public void onEntitiesLoad(EntitiesLoadEvent e) { + Chunk chunk = e.getChunk(); + removeLeftovers(chunk.getWorld(), chunk.getX(), chunk.getZ(), e.getEntities()); + } + + /** + * Cleans the chunks of all live labels whose entities are already loaded. + * Chunks that load later are handled by {@link #onEntitiesLoad(EntitiesLoadEvent)}. + * + * @return the number of displays removed + */ + public static int sweepLoadedChunks() { + // Read every label once and group it by chunk. + Map> chunks = new LinkedHashMap<>(); + for (Label label : Hologram.labels()) { + Location pos = label.position(); + chunks.computeIfAbsent(new ChunkKey(pos.getWorld(), pos.getBlockX() >> 4, pos.getBlockZ() >> 4), + k -> new ArrayList<>()).add(label); + } + int removed = 0; + for (Map.Entry> entry : chunks.entrySet()) { + ChunkKey key = entry.getKey(); + if (!key.world().isChunkLoaded(key.x(), key.z())) continue; + Chunk chunk = key.world().getChunkAt(key.x(), key.z()); + if (!chunk.isEntitiesLoaded()) continue; + removed += removeLeftovers(key.world(), key.x(), key.z(), List.of(chunk.getEntities()), entry.getValue()); + } + return removed; + } + + /** + * Removes leftover label displays among {@code entities} for the labels in chunk + * ({@code cx}, {@code cz}) of {@code world}, logging one line when any are removed. + * + * @return the number of displays removed + */ + static int removeLeftovers(World world, int cx, int cz, Collection entities) { + List displays = textDisplays(entities); + if (displays.isEmpty()) return 0; + + List