diff --git a/README.md b/README.md index 26a6696..de9416e 100644 --- a/README.md +++ b/README.md @@ -25,7 +25,8 @@ slightly faster round. Last player standing wins. All admin/config commands live under `/blockparty` (alias `/bp`) and work from console where it makes sense (`create`, `delete`, `enable`, `disable`, `info`, `list`, `reload`). Commands that require a physical location (`setlobby`, `setspawn`, `setspectator`, -`setbillboard`, `pos1`, `pos2`) require a player sender. +`setbillboard`, `pos1`, `pos2`) require a player sender. `delbillboard`, `billboards`, `info` +work fine from console. ### Admin | Command | Description | @@ -38,8 +39,9 @@ Commands that require a physical location (`setlobby`, `setspawn`, `setspectator | `/blockparty setlobby ` | Set the waiting-room location | | `/blockparty setspawn ` | Set the in-game spawn location | | `/blockparty setspectator ` | Set the spectator/eliminated location | -| `/blockparty setbillboard ` | (Optional) place a display block here that mirrors the current target color | -| `/blockparty delbillboard ` | Remove the billboard, if one is set | +| `/blockparty setbillboard [name]` | (Optional) place a named display block here that mirrors the current target color; `name` defaults to `default`, so you can have any number by giving each a unique name | +| `/blockparty delbillboard [name]` | Remove a named billboard (defaults to `default`) | +| `/blockparty billboards ` | List an arena's billboards and whether each is enabled | | `/blockparty pos1 ` | Set floor region corner 1 | | `/blockparty pos2 ` | Set floor region corner 2 | | `/blockparty setfloor ` | Set the allowed floor materials | @@ -86,8 +88,9 @@ Commands that require a physical location (`setlobby`, `setspawn`, `setspectator each round (BlockParty does not scan the world every round — it restores from the saved layout for performance and determinism). 8. Optionally, stand somewhere visible near/above the floor and run `/bp setbillboard lobby1` + (or `/bp setbillboard lobby1 ` to add more than one, e.g. one per side of the arena) to place a big display block that mirrors the round's target color — purely cosmetic and - entirely optional (see "Billboard" below). + entirely optional (see "Billboards" below). 9. `/bp info lobby1` to confirm there are no validation errors. 10. `/bp enable lobby1`. 11. Players run `/bp join lobby1`. Once `min-players` is reached the countdown starts @@ -112,22 +115,29 @@ Commands that require a physical location (`setlobby`, `setspawn`, `setspectator (`generated-layout`) so it can be restored exactly, every round, without re-scanning or re-randomizing the world. -### Billboard +### Billboards An arena billboard is a `BlockDisplay` entity that visually shows the currently selected -target color/block each round, in addition to the title/actionbar/hotbar-item cues. It is -entirely optional: +target color/block each round, in addition to the title/actionbar/hotbar-item cues. An arena +may have any number of them (e.g. one visible from each side of the floor), each independently +named, positioned and toggled. Entirely optional: -- Per-arena: only spawned if `/bp setbillboard ` has been run (stored as `billboard` - in the arena's yml). `/bp delbillboard ` removes it. -- Per-arena toggle: `billboard-enabled` in the arena's yml (default `true`) lets you keep the - location set but temporarily turn it off without deleting it. -- Global toggle: `ui.billboard` in `config.yml` (default `true`) disables billboards across +- Per-billboard: `/bp setbillboard [name]` places/updates one at your current location; + `name` defaults to `default` if omitted, so the simple single-billboard flow needs no name at + all. Give each a distinct `name` to have several at once (e.g. `/bp setbillboard lobby1 north`, + `/bp setbillboard lobby1 south`). `/bp delbillboard [name]` removes one. `/bp billboards + ` lists an arena's billboards and whether each is enabled. +- Per-billboard toggle: stored per name in the arena's yml (`billboards..enabled`, default + `true`) — set to `false` to keep the location saved but temporarily stop displaying it, without + running `delbillboard`. +- Global toggle: `ui.billboard` in `config.yml` (default `true`) disables every billboard on every arena at once, e.g. for performance-constrained servers. -The entity is non-persistent (not saved to the world, so it never survives a server restart on -its own — BlockParty re-creates it on demand) and is despawned automatically when the arena is -disabled, deleted, or the plugin shuts down, so it never leaks entities. +Every billboard shows the same current target color in sync — they're just multiple vantage +points on the same information. Entities are non-persistent (not saved to the world, so they +never survive a server restart on their own — BlockParty re-creates them on demand) and are all +despawned automatically when the arena is disabled, deleted, or the plugin shuts down, so +nothing leaks. ## Player usage diff --git a/src/main/java/us/tss3/blockparty/billboard/BillboardManager.java b/src/main/java/us/tss3/blockparty/billboard/BillboardManager.java index 0c0b570..4d71752 100644 --- a/src/main/java/us/tss3/blockparty/billboard/BillboardManager.java +++ b/src/main/java/us/tss3/blockparty/billboard/BillboardManager.java @@ -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 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; } } diff --git a/src/main/java/us/tss3/blockparty/command/BlockPartyCommand.java b/src/main/java/us/tss3/blockparty/command/BlockPartyCommand.java index 5a4511b..ec07cbf 100644 --- a/src/main/java/us/tss3/blockparty/command/BlockPartyCommand.java +++ b/src/main/java/us/tss3/blockparty/command/BlockPartyCommand.java @@ -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 ", "/blockparty setspawn ", "/blockparty setspectator ", - "/blockparty setbillboard ", - "/blockparty delbillboard ", + "/blockparty setbillboard [name]", + "/blockparty delbillboard [name]", + "/blockparty billboards ", "/blockparty pos1 ", "/blockparty pos2 ", "/blockparty setfloor ", @@ -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 "); + 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 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) { diff --git a/src/main/java/us/tss3/blockparty/config/ArenaConfig.java b/src/main/java/us/tss3/blockparty/config/ArenaConfig.java index f7991bf..42c92e2 100644 --- a/src/main/java/us/tss3/blockparty/config/ArenaConfig.java +++ b/src/main/java/us/tss3/blockparty/config/ArenaConfig.java @@ -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/.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 billboards = new LinkedHashMap<>(); + private final Set 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 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() { diff --git a/src/main/java/us/tss3/blockparty/config/ArenaConfigLoader.java b/src/main/java/us/tss3/blockparty/config/ArenaConfigLoader.java index a33c70e..c76432c 100644 --- a/src/main/java/us/tss3/blockparty/config/ArenaConfigLoader.java +++ b/src/main/java/us/tss3/blockparty/config/ArenaConfigLoader.java @@ -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]); diff --git a/src/main/resources/messages.yml b/src/main/resources/messages.yml index ccb99fe..8f42813 100644 --- a/src/main/resources/messages.yml +++ b/src/main/resources/messages.yml @@ -20,8 +20,9 @@ admin: lobby-set: "Lobby set for '%arena%'." spawn-set: "Spawn set for '%arena%'." spectator-set: "Spectator location set for '%arena%'." - billboard-set: "Billboard set for '%arena%'. It will display the target color each round." - billboard-removed: "Billboard removed for '%arena%'." + billboard-set: "Billboard '%name%' set for '%arena%'. It will display the target color each round." + billboard-removed: "Billboard '%name%' removed for '%arena%'." + billboard-unknown: "No billboard named '%name%' exists for '%arena%'." pos1-set: "Position 1 set for '%arena%'." pos2-set: "Position 2 set for '%arena%'." pos1-set-we: "Position 1 set for '%arena%' from your WorldEdit selection."