Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
5 changes: 5 additions & 0 deletions src/main/java/net/tfminecraft/trialrooms/TrialRooms.java
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down Expand Up @@ -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
Expand All @@ -54,6 +57,8 @@ public void onEnable() {
List<Database.ChestRecord> 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!");
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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());
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down Expand Up @@ -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);

Expand Down Expand Up @@ -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) {}
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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);

Expand Down Expand Up @@ -190,7 +192,8 @@ public void destroy() {
}
killActiveEnemiesSilently(); // << add this
destroyed = true;
removeHolograms(TrialRooms.getInstance());
levelHologram.dispose(TrialRooms.getInstance());
statusHologram.dispose(TrialRooms.getInstance());
}

// ---------- Ticking ----------
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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.
*
* <p>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<Hologram> REGISTRY = Collections.newSetFromMap(new WeakHashMap<>());

private final Supplier<Location> locationSupplier; // where to place it
private final Supplier<String> nameSupplier; // what to display
// recognises this label's plain text on displays saved by older versions
private final Predicate<String> 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<String> legacyText) {}

/** A label whose old saved copies show exactly the text it shows now. */
public Hologram(Supplier<Location> locationSupplier, Supplier<String> 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<Location> locationSupplier, Supplier<String> nameSupplier,
Predicate<String> legacyText) {
this.locationSupplier = locationSupplier;
this.nameSupplier = nameSupplier;
this.legacyText = legacyText != null ? legacyText : this::showsCurrentText;
REGISTRY.add(this);
Comment thread
coderabbitai[bot] marked this conversation as resolved.
Resolved
}

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);
Comment thread
coderabbitai[bot] marked this conversation as resolved.
Resolved
}
} 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<Label> labels() {
List<Label> out = new ArrayList<>();
for (Hologram h : new ArrayList<>(REGISTRY)) {
Location pos = h.position();
if (pos != null) out.add(new Label(pos, h.legacyText));
}
return out;
}

/** {@code text} without legacy colour and format codes. */
static String plain(String text) {
return text == null ? "" : COLOR_CODES.matcher(text).replaceAll("");
}

TextDisplay existing = findDisplay();
if (existing != null && !existing.isDead()) return existing;
/** UUIDs of the displays the live holograms currently own. */
static Set<UUID> ownedDisplayIds() {
Set<UUID> out = new HashSet<>();
for (Hologram h : new ArrayList<>(REGISTRY)) {
if (h.entityId != null) out.add(h.entityId);
}
return out;
}

// ===== Internals =====

private void update() {
if (disposed) {
removeCurrent(); // e.g. an editor still open on a deleted spawner
return;
}
Location pos = position();
if (pos == null) return;
if (!isChunkLoaded(pos)) {
removeCurrent();
return;
}

TextDisplay td = findDisplay();
if (td == null) {
if (!anyPlayerNear(pos)) return;
create(pos);
return;
}
td.teleport(pos);
applyText(td);
}

private void removeCurrent() {
TextDisplay td = findDisplay();
if (td != null) td.remove();
entityId = null;
}

private TextDisplay create(Location pos) {
World world = pos.getWorld();
// A display we lost track of (for example after a plugin reload) must not stay next to the new one.
LabelCleanup.removeLeftovers(world, pos.getBlockX() >> 4, pos.getBlockZ() >> 4,
world.getNearbyEntitiesByType(TextDisplay.class, pos, LabelCleanup.SEARCH_RADIUS));

TextDisplay spawned = world.spawn(pos, TextDisplay.class, td -> {
td.setInvulnerable(true);
td.setPersistent(true);
// Runtime-only: tick() recreates it, so it must never be saved with the chunk.
td.setPersistent(false);
td.setGravity(false);
td.getPersistentDataContainer().set(LABEL_KEY, PersistentDataType.BYTE, (byte) 1);
// Nice defaults (safe across recent Paper/Bukkit versions):
try { td.setBillboard(org.bukkit.entity.Display.Billboard.CENTER); } catch (Throwable ignored) {}
try { td.setShadowed(true); } catch (Throwable ignored) {}
try { td.setSeeThrough(false); } catch (Throwable ignored) {}
// optional: remove the dark background if your server build supports it
// try { td.setBackgroundColor(org.joml.Vector4f...); } catch (Throwable ignored) {}
applyText(td);
});
entityId = spawned.getUniqueId();
Expand All @@ -117,15 +197,37 @@ private TextDisplay getOrCreateDisplay() {
private TextDisplay findDisplay() {
if (entityId == null) return null;
Entity e = Bukkit.getEntity(entityId);
return (e instanceof TextDisplay td) ? td : null;
return (e instanceof TextDisplay td && !td.isDead()) ? td : null;
}

private Location position() {
Location base = locationSupplier.get();
if (base == null) return null;
try {
if (base.getWorld() == null) return null;
} catch (IllegalArgumentException worldUnloaded) {
return null; // Location#getWorld throws once its world was unloaded
}
return base.clone().add(0.5, yOffset, 0.5);
}

private boolean showsCurrentText(String plainText) {
return plainText.equals(plain(nameSupplier.get()));
}

private static boolean isChunkLoaded(Location pos) {
return pos.getWorld().isChunkLoaded(pos.getBlockX() >> 4, pos.getBlockZ() >> 4);
}

private static boolean anyPlayerNear(Location pos) {
for (var p : Bukkit.getOnlinePlayers()) {
if (p.getWorld() == pos.getWorld() && p.getLocation().distanceSquared(pos) <= VIEW_RADIUS_SQ) {
return true;
}
}
return false;
}

private void applyText(TextDisplay td) {
String raw = nameSupplier.get();
if (raw == null) raw = "";
Expand All @@ -136,9 +238,4 @@ private void runSync(JavaPlugin plugin, Runnable r) {
if (Bukkit.isPrimaryThread()) r.run();
else Bukkit.getScheduler().runTask(plugin, r);
}

private JavaPlugin plugin() {
// Replace with your plugin singleton if you like; this avoids passing it around for tick()
return net.tfminecraft.trialrooms.TrialRooms.getInstance();
}
}
Loading
Loading