Support multiple named billboards per arena
Build / build (push) Successful in 1m16s

This commit is contained in:
Michael Burgess
2026-08-07 11:13:50 -04:00
parent 412c9411b8
commit 796f8bd1de
6 changed files with 189 additions and 78 deletions
@@ -10,14 +10,16 @@ import org.joml.Vector3f;
import us.tss3.blockparty.BlockPartyPlugin;
import us.tss3.blockparty.config.ArenaConfig;
import java.util.HashMap;
import java.util.Map;
import java.util.UUID;
import java.util.logging.Level;
/**
* Owns an optional {@link BlockDisplay} entity that visually mirrors an arena's currently
* selected target color. Purely cosmetic: fully optional (global toggle + per-arena
* toggle/location), and every method safely no-ops when disabled, unset, or the arena's
* world isn't loaded.
* Owns any number of named, optional {@link BlockDisplay} entities that visually mirror an
* arena's currently selected target color. Purely cosmetic: fully optional (global toggle +
* per-billboard toggle/location), and every method safely no-ops when disabled, unset, or the
* arena's world isn't loaded.
*/
public class BillboardManager {
@@ -25,75 +27,96 @@ public class BillboardManager {
private final BlockPartyPlugin plugin;
private final ArenaConfig config;
private UUID entityId;
/** billboard name -> live display entity id, only present while spawned. */
private final Map<String, UUID> entityIds = new HashMap<>();
public BillboardManager(BlockPartyPlugin plugin, ArenaConfig config) {
this.plugin = plugin;
this.config = config;
}
private boolean isActive() {
return plugin.getConfigManager().isBillboardEnabled() && config.hasBillboard()
&& config.getBillboard().getWorld() != null;
private boolean globallyActive() {
return plugin.getConfigManager().isBillboardEnabled() && config.hasAnyBillboards();
}
/** Shows (spawning the display entity if needed) the given material as the current target. */
/** Shows the given material as the current target on every enabled, valid billboard. */
public void show(Material material) {
if (!isActive()) {
if (!globallyActive()) {
return;
}
for (String name : config.getBillboards().keySet()) {
showOne(name, material);
}
}
private void showOne(String name, Material material) {
Location loc = config.getBillboard(name);
if (loc == null || loc.getWorld() == null || !config.isBillboardEnabled(name)) {
return;
}
try {
BlockDisplay display = resolve();
BlockDisplay display = resolve(name);
if (display == null) {
display = spawn();
display = spawn(name, loc);
}
if (display != null) {
display.setBlock(material.createBlockData());
}
} catch (Exception ex) {
plugin.getLogger().log(Level.WARNING, "Failed to update billboard for arena '" + config.getName() + "'", ex);
plugin.getLogger().log(Level.WARNING, "Failed to update billboard '" + name
+ "' for arena '" + config.getName() + "'", ex);
}
}
/** Blanks the display (e.g. between matches) without destroying the entity. */
/** Blanks every spawned display (e.g. between matches) without destroying the entities. */
public void clear() {
BlockDisplay display = resolve();
if (display != null) {
display.setBlock(Material.AIR.createBlockData());
for (String name : entityIds.keySet().toArray(new String[0])) {
BlockDisplay display = resolve(name);
if (display != null) {
display.setBlock(Material.AIR.createBlockData());
}
}
}
/** Fully removes the display entity. Safe to call even if nothing was ever spawned. */
/** Fully removes every display entity. Safe to call even if nothing was ever spawned. */
public void despawn() {
BlockDisplay display = resolve();
for (String name : entityIds.keySet().toArray(new String[0])) {
BlockDisplay display = resolve(name);
if (display != null) {
display.remove();
}
entityIds.remove(name);
}
}
/** Removes a single named billboard's live entity (e.g. after deletion or relocation). */
public void despawnOne(String name) {
BlockDisplay display = resolve(name);
if (display != null) {
display.remove();
}
entityId = null;
entityIds.remove(name);
}
/** Forces the entity to be re-created at its (possibly updated) configured location next time it's shown. */
public void invalidate() {
despawn();
/** Forces a specific billboard to be re-created at its (possibly updated) location next show(). */
public void invalidate(String name) {
despawnOne(name);
}
private BlockDisplay resolve() {
if (entityId == null) {
private BlockDisplay resolve(String name) {
UUID id = entityIds.get(name);
if (id == null) {
return null;
}
Entity entity = plugin.getServer().getEntity(entityId);
Entity entity = plugin.getServer().getEntity(id);
if (entity instanceof BlockDisplay display && !display.isDead()) {
return display;
}
entityId = null;
entityIds.remove(name);
return null;
}
private BlockDisplay spawn() {
Location loc = config.getBillboard();
if (loc == null || loc.getWorld() == null) {
return null;
}
private BlockDisplay spawn(String name, Location loc) {
BlockDisplay display = loc.getWorld().spawn(loc, BlockDisplay.class, bd -> {
bd.setPersistent(false);
bd.setGlowing(true);
@@ -103,7 +126,7 @@ public class BillboardManager {
new Vector3f(1.5f, 1.5f, 1.5f),
NO_ROTATION));
});
entityId = display.getUniqueId();
entityIds.put(name, display.getUniqueId());
return display;
}
}
@@ -48,6 +48,7 @@ public class BlockPartyCommand implements CommandExecutor, TabCompleter {
case "setspectator" -> setSpectator(sender, args);
case "setbillboard" -> setBillboard(sender, args);
case "delbillboard" -> delBillboard(sender, args);
case "billboards" -> listBillboards(sender, args);
case "pos1" -> pos1(sender, args);
case "pos2" -> pos2(sender, args);
case "setfloor" -> setFloor(sender, args);
@@ -78,8 +79,9 @@ public class BlockPartyCommand implements CommandExecutor, TabCompleter {
"/blockparty setlobby <arena>",
"/blockparty setspawn <arena>",
"/blockparty setspectator <arena>",
"/blockparty setbillboard <arena>",
"/blockparty delbillboard <arena>",
"/blockparty setbillboard <arena> [name]",
"/blockparty delbillboard <arena> [name]",
"/blockparty billboards <arena>",
"/blockparty pos1 <arena>",
"/blockparty pos2 <arena>",
"/blockparty setfloor <arena>",
@@ -198,25 +200,56 @@ public class BlockPartyCommand implements CommandExecutor, TabCompleter {
private void setBillboard(CommandSender sender, String[] args) {
if (!requireAdmin(sender, "blockparty.admin.setup") || !requirePlayer(sender)) return;
String billboardName = args.length > 2 ? args[2] : "default";
withArena(sender, args, arena -> {
arena.getConfig().setBillboard(((Player) sender).getLocation());
arena.getConfig().setBillboardEnabled(true);
arena.getBillboardManager().invalidate();
arena.getConfig().setBillboard(billboardName, ((Player) sender).getLocation());
arena.getConfig().setBillboardEnabled(billboardName, true);
arena.getBillboardManager().invalidate(billboardName);
plugin.getArenaManager().save(arena);
sender.sendMessage(plugin.getMessages().get("admin.billboard-set", Map.of("arena", arena.getConfig().getName())));
sender.sendMessage(plugin.getMessages().get("admin.billboard-set",
Map.of("arena", arena.getConfig().getName(), "name", billboardName)));
});
}
private void delBillboard(CommandSender sender, String[] args) {
if (!requireAdmin(sender, "blockparty.admin.setup")) return;
String billboardName = args.length > 2 ? args[2] : "default";
withArena(sender, args, arena -> {
arena.getBillboardManager().despawn();
arena.getConfig().setBillboard(null);
if (!arena.getConfig().hasBillboard(billboardName)) {
sender.sendMessage(plugin.getMessages().get("admin.billboard-unknown",
Map.of("arena", arena.getConfig().getName(), "name", billboardName)));
return;
}
arena.getBillboardManager().despawnOne(billboardName);
arena.getConfig().removeBillboard(billboardName);
plugin.getArenaManager().save(arena);
sender.sendMessage(plugin.getMessages().get("admin.billboard-removed", Map.of("arena", arena.getConfig().getName())));
sender.sendMessage(plugin.getMessages().get("admin.billboard-removed",
Map.of("arena", arena.getConfig().getName(), "name", billboardName)));
});
}
private void listBillboards(CommandSender sender, String[] args) {
String name = argOrCurrentArenaless(args);
if (name == null) {
sender.sendMessage("Usage: /blockparty billboards <arena>");
return;
}
var opt = plugin.getArenaManager().get(name);
if (opt.isEmpty()) {
sender.sendMessage(plugin.getMessages().get("errors.unknown-arena", Map.of("arena", name)));
return;
}
ArenaConfig cfg = opt.get().getConfig();
sender.sendMessage("§6=== Billboards: " + cfg.getName() + " ===");
if (cfg.getBillboards().isEmpty()) {
sender.sendMessage("§7(none set)");
return;
}
for (String bbName : cfg.getBillboards().keySet()) {
sender.sendMessage("§7- §f" + bbName + " §7(enabled: " + cfg.isBillboardEnabled(bbName) + ")");
}
}
private void pos1(CommandSender sender, String[] args) {
if (!requireAdmin(sender, "blockparty.admin.setup") || !requirePlayer(sender)) return;
withArena(sender, args, arena -> {
@@ -335,7 +368,7 @@ public class BlockPartyCommand implements CommandExecutor, TabCompleter {
sender.sendMessage("§7Lobby set: §f" + (cfg.getLobby() != null));
sender.sendMessage("§7Spawn set: §f" + (cfg.getSpawn() != null));
sender.sendMessage("§7Spectator set: §f" + (cfg.getSpectator() != null));
sender.sendMessage("§7Billboard set: §f" + (cfg.getBillboard() != null) + (cfg.getBillboard() != null ? " (enabled: " + cfg.isBillboardEnabled() + ")" : ""));
sender.sendMessage("§7Billboards: §f" + cfg.getBillboards().size() + " (see /bp billboards " + cfg.getName() + ")");
sender.sendMessage("§7Floor region set: §f" + cfg.hasFloorRegion());
sender.sendMessage("§7Floor generated: §f" + cfg.hasGeneratedLayout());
sender.sendMessage("§7Floor materials: §f" + cfg.getFloorMaterials().size());
@@ -453,7 +486,7 @@ public class BlockPartyCommand implements CommandExecutor, TabCompleter {
public List<String> onTabComplete(CommandSender sender, Command command, String alias, String[] args) {
if (args.length == 1) {
return List.of("help", "join", "leave", "arenas", "stats", "create", "delete", "enable", "disable",
"setlobby", "setspawn", "setspectator", "setbillboard", "delbillboard", "pos1", "pos2", "setfloor", "generate", "info", "list", "reload")
"setlobby", "setspawn", "setspectator", "setbillboard", "delbillboard", "billboards", "pos1", "pos2", "setfloor", "generate", "info", "list", "reload")
.stream().filter(s -> s.startsWith(args[0].toLowerCase())).collect(Collectors.toList());
}
if (args.length == 2) {
@@ -5,7 +5,12 @@ import org.bukkit.Material;
import org.bukkit.World;
import java.util.ArrayList;
import java.util.Collections;
import java.util.HashSet;
import java.util.LinkedHashMap;
import java.util.List;
import java.util.Map;
import java.util.Set;
/** Mutable POJO describing a single arena's configuration, mirrors arenas/<name>.yml. */
public class ArenaConfig {
@@ -15,8 +20,10 @@ public class ArenaConfig {
private Location lobby;
private Location spawn;
private Location spectator;
private Location billboard;
private boolean billboardEnabled = true;
/** Named billboards: displays that mirror the current round's target color. Any number
* may be set (e.g. one per side of the arena), each independently enabled/disabled. */
private final Map<String, Location> billboards = new LinkedHashMap<>();
private final Set<String> disabledBillboards = new HashSet<>();
private int[] pos1; // x,y,z block coords
private int[] pos2;
@@ -86,24 +93,42 @@ public class ArenaConfig {
this.spectator = spectator;
}
public Location getBillboard() {
return billboard;
/** All configured billboards by name, in insertion order. */
public Map<String, Location> getBillboards() {
return Collections.unmodifiableMap(billboards);
}
public void setBillboard(Location billboard) {
this.billboard = billboard;
public Location getBillboard(String name) {
return billboards.get(name);
}
public boolean isBillboardEnabled() {
return billboardEnabled;
public void setBillboard(String name, Location location) {
billboards.put(name, location);
}
public void setBillboardEnabled(boolean billboardEnabled) {
this.billboardEnabled = billboardEnabled;
public void removeBillboard(String name) {
billboards.remove(name);
disabledBillboards.remove(name);
}
public boolean hasBillboard() {
return billboard != null && billboardEnabled;
public boolean hasBillboard(String name) {
return billboards.containsKey(name);
}
public boolean hasAnyBillboards() {
return !billboards.isEmpty();
}
public boolean isBillboardEnabled(String name) {
return !disabledBillboards.contains(name);
}
public void setBillboardEnabled(String name, boolean enabled) {
if (enabled) {
disabledBillboards.remove(name);
} else {
disabledBillboards.add(name);
}
}
public int[] getPos1() {
@@ -1,7 +1,9 @@
package us.tss3.blockparty.config;
import org.bukkit.Bukkit;
import org.bukkit.Location;
import org.bukkit.Material;
import org.bukkit.configuration.ConfigurationSection;
import org.bukkit.configuration.file.YamlConfiguration;
import org.bukkit.plugin.Plugin;
import us.tss3.blockparty.logic.ArenaConfigValidator;
@@ -57,8 +59,21 @@ public class ArenaConfigLoader {
cfg.setLobby(LocationUtil.fromSection(yml.getConfigurationSection("lobby")));
cfg.setSpawn(LocationUtil.fromSection(yml.getConfigurationSection("spawn")));
cfg.setSpectator(LocationUtil.fromSection(yml.getConfigurationSection("spectator")));
cfg.setBillboard(LocationUtil.fromSection(yml.getConfigurationSection("billboard")));
cfg.setBillboardEnabled(yml.getBoolean("billboard-enabled", true));
ConfigurationSection billboardsSection = yml.getConfigurationSection("billboards");
if (billboardsSection != null) {
for (String bbName : billboardsSection.getKeys(false)) {
ConfigurationSection bbSection = billboardsSection.getConfigurationSection(bbName);
if (bbSection == null) {
continue;
}
Location loc = LocationUtil.fromSection(bbSection.getConfigurationSection("location"));
if (loc == null) {
continue;
}
cfg.setBillboard(bbName, loc);
cfg.setBillboardEnabled(bbName, bbSection.getBoolean("enabled", true));
}
}
if (yml.contains("pos1")) {
cfg.setPos1(new int[]{yml.getInt("pos1.x"), yml.getInt("pos1.y"), yml.getInt("pos1.z")});
}
@@ -108,10 +123,14 @@ public class ArenaConfigLoader {
if (cfg.getSpectator() != null) {
LocationUtil.toSection(yml.createSection("spectator"), cfg.getSpectator());
}
if (cfg.getBillboard() != null) {
LocationUtil.toSection(yml.createSection("billboard"), cfg.getBillboard());
if (!cfg.getBillboards().isEmpty()) {
ConfigurationSection billboardsSection = yml.createSection("billboards");
for (var entry : cfg.getBillboards().entrySet()) {
ConfigurationSection bbSection = billboardsSection.createSection(entry.getKey());
LocationUtil.toSection(bbSection.createSection("location"), entry.getValue());
bbSection.set("enabled", cfg.isBillboardEnabled(entry.getKey()));
}
}
yml.set("billboard-enabled", cfg.isBillboardEnabled());
if (cfg.getPos1() != null) {
yml.set("pos1.x", cfg.getPos1()[0]);
yml.set("pos1.y", cfg.getPos1()[1]);
+3 -2
View File
@@ -20,8 +20,9 @@ admin:
lobby-set: "<green>Lobby set for '%arena%'.</green>"
spawn-set: "<green>Spawn set for '%arena%'.</green>"
spectator-set: "<green>Spectator location set for '%arena%'.</green>"
billboard-set: "<green>Billboard set for '%arena%'. It will display the target color each round.</green>"
billboard-removed: "<green>Billboard removed for '%arena%'.</green>"
billboard-set: "<green>Billboard '%name%' set for '%arena%'. It will display the target color each round.</green>"
billboard-removed: "<green>Billboard '%name%' removed for '%arena%'.</green>"
billboard-unknown: "<red>No billboard named '%name%' exists for '%arena%'.</red>"
pos1-set: "<green>Position 1 set for '%arena%'.</green>"
pos2-set: "<green>Position 2 set for '%arena%'.</green>"
pos1-set-we: "<green>Position 1 set for '%arena%' from your WorldEdit selection.</green>"