From c78d35d1045432e20de16f37b61097904b8664fa Mon Sep 17 00:00:00 2001 From: Michael Burgess Date: Fri, 7 Aug 2026 07:52:14 -0400 Subject: [PATCH] Add optional round-target billboard display --- README.md | 30 ++++- .../java/us/tss3/blockparty/arena/Arena.java | 11 ++ .../billboard/BillboardManager.java | 109 ++++++++++++++++++ .../blockparty/command/BlockPartyCommand.java | 28 ++++- .../tss3/blockparty/config/ArenaConfig.java | 22 ++++ .../blockparty/config/ArenaConfigLoader.java | 6 + .../tss3/blockparty/config/ConfigManager.java | 4 + src/main/resources/config.yml | 5 + src/main/resources/messages.yml | 2 + 9 files changed, 212 insertions(+), 5 deletions(-) create mode 100644 src/main/java/us/tss3/blockparty/billboard/BillboardManager.java diff --git a/README.md b/README.md index 9884b00..a798364 100644 --- a/README.md +++ b/README.md @@ -25,7 +25,7 @@ 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`, -`pos1`, `pos2`) require a player sender. +`setbillboard`, `pos1`, `pos2`) require a player sender. ### Admin | Command | Description | @@ -38,6 +38,8 @@ 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 pos1 ` | Set floor region corner 1 | | `/blockparty pos2 ` | Set floor region corner 2 | | `/blockparty setfloor ` | Set the allowed floor materials | @@ -83,9 +85,12 @@ Commands that require a physical location (`setlobby`, `setspawn`, `setspectator still required once so BlockParty captures/serializes the exact layout it should restore each round (BlockParty does not scan the world every round — it restores from the saved layout for performance and determinism). -8. `/bp info lobby1` to confirm there are no validation errors. -9. `/bp enable lobby1`. -10. Players run `/bp join lobby1`. Once `min-players` is reached the countdown starts +8. Optionally, stand somewhere visible near/above the floor and run `/bp setbillboard lobby1` + to place a big display block that mirrors the round's target color — purely cosmetic and + entirely optional (see "Billboard" 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 automatically; joining an arena mid-countdown is allowed by default (`allow-join-while-waiting: true`). @@ -99,6 +104,23 @@ 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 + +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: + +- 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 + 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. + ## Player usage - `/bp join ` to enter the waiting lobby. Your inventory, armor, offhand, health, diff --git a/src/main/java/us/tss3/blockparty/arena/Arena.java b/src/main/java/us/tss3/blockparty/arena/Arena.java index 149dd0a..5c2374a 100644 --- a/src/main/java/us/tss3/blockparty/arena/Arena.java +++ b/src/main/java/us/tss3/blockparty/arena/Arena.java @@ -10,6 +10,7 @@ import org.bukkit.entity.Player; import org.bukkit.inventory.ItemStack; import org.bukkit.scheduler.BukkitTask; import us.tss3.blockparty.BlockPartyPlugin; +import us.tss3.blockparty.billboard.BillboardManager; import us.tss3.blockparty.config.ArenaConfig; import us.tss3.blockparty.floor.FloorManager; import us.tss3.blockparty.logic.ArenaPhase; @@ -35,6 +36,7 @@ public class Arena { private final BlockPartyPlugin plugin; private final ArenaConfig config; private final FloorManager floorManager; + private final BillboardManager billboardManager; private final ArenaStateMachine stateMachine = new ArenaStateMachine(ArenaPhase.DISABLED); private final Random random = new Random(); private final TargetColorSelector selector = new TargetColorSelector(random::nextInt); @@ -56,6 +58,7 @@ public class Arena { this.plugin = plugin; this.config = config; this.floorManager = new FloorManager(plugin, config); + this.billboardManager = new BillboardManager(plugin, config); if (config.isEnabled()) { stateMachine.transition(ArenaPhase.WAITING); } @@ -109,6 +112,7 @@ public class Arena { public void disable() { cancelAllTasks(); + billboardManager.despawn(); // Force reset players out regardless of state List all = new ArrayList<>(); all.addAll(players); @@ -318,6 +322,7 @@ public class Arena { p.getInventory().setItem(8, display.clone()); plugin.getSoundUtil().play(p, plugin.getConfigManager().getSound("target-select"), 1f, 1f); } + billboardManager.show(currentTarget); } private void setupBossBar() { @@ -493,6 +498,7 @@ public class Arena { floorManager.cancelActiveTask(); cancelAllTasks(); round = 0; + billboardManager.clear(); stateMachine.transition(ArenaPhase.WAITING); } @@ -516,6 +522,7 @@ public class Arena { /** Called on plugin disable / world unload to safely stop everything and restore players. */ public void shutdown() { cancelAllTasks(); + billboardManager.despawn(); List all = allParticipants(); for (UUID uuid : all) { Player p = plugin.getServer().getPlayer(uuid); @@ -529,6 +536,10 @@ public class Arena { spectators.clear(); } + public BillboardManager getBillboardManager() { + return billboardManager; + } + private String formatMaterial(Material material) { String name = material.name().replace("_CONCRETE", "").replace("_", " ").toLowerCase(); return name.substring(0, 1).toUpperCase() + name.substring(1); diff --git a/src/main/java/us/tss3/blockparty/billboard/BillboardManager.java b/src/main/java/us/tss3/blockparty/billboard/BillboardManager.java new file mode 100644 index 0000000..0c0b570 --- /dev/null +++ b/src/main/java/us/tss3/blockparty/billboard/BillboardManager.java @@ -0,0 +1,109 @@ +package us.tss3.blockparty.billboard; + +import org.bukkit.Location; +import org.bukkit.Material; +import org.bukkit.entity.BlockDisplay; +import org.bukkit.entity.Entity; +import org.bukkit.util.Transformation; +import org.joml.AxisAngle4f; +import org.joml.Vector3f; +import us.tss3.blockparty.BlockPartyPlugin; +import us.tss3.blockparty.config.ArenaConfig; + +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. + */ +public class BillboardManager { + + private static final AxisAngle4f NO_ROTATION = new AxisAngle4f(0f, 0f, 0f, 1f); + + private final BlockPartyPlugin plugin; + private final ArenaConfig config; + private UUID entityId; + + 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; + } + + /** Shows (spawning the display entity if needed) the given material as the current target. */ + public void show(Material material) { + if (!isActive()) { + return; + } + try { + BlockDisplay display = resolve(); + if (display == null) { + display = spawn(); + } + if (display != null) { + display.setBlock(material.createBlockData()); + } + } catch (Exception ex) { + plugin.getLogger().log(Level.WARNING, "Failed to update billboard for arena '" + config.getName() + "'", ex); + } + } + + /** Blanks the display (e.g. between matches) without destroying the entity. */ + public void clear() { + BlockDisplay display = resolve(); + if (display != null) { + display.setBlock(Material.AIR.createBlockData()); + } + } + + /** Fully removes the display entity. Safe to call even if nothing was ever spawned. */ + public void despawn() { + BlockDisplay display = resolve(); + if (display != null) { + display.remove(); + } + entityId = null; + } + + /** Forces the entity to be re-created at its (possibly updated) configured location next time it's shown. */ + public void invalidate() { + despawn(); + } + + private BlockDisplay resolve() { + if (entityId == null) { + return null; + } + Entity entity = plugin.getServer().getEntity(entityId); + if (entity instanceof BlockDisplay display && !display.isDead()) { + return display; + } + entityId = null; + return null; + } + + private BlockDisplay spawn() { + Location loc = config.getBillboard(); + if (loc == null || loc.getWorld() == null) { + return null; + } + BlockDisplay display = loc.getWorld().spawn(loc, BlockDisplay.class, bd -> { + bd.setPersistent(false); + bd.setGlowing(true); + bd.setTransformation(new Transformation( + new Vector3f(-0.75f, 0f, -0.75f), + NO_ROTATION, + new Vector3f(1.5f, 1.5f, 1.5f), + NO_ROTATION)); + }); + entityId = 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 5ca09a8..2907ec5 100644 --- a/src/main/java/us/tss3/blockparty/command/BlockPartyCommand.java +++ b/src/main/java/us/tss3/blockparty/command/BlockPartyCommand.java @@ -45,6 +45,8 @@ public class BlockPartyCommand implements CommandExecutor, TabCompleter { case "setlobby" -> setLobby(sender, args); case "setspawn" -> setSpawn(sender, args); case "setspectator" -> setSpectator(sender, args); + case "setbillboard" -> setBillboard(sender, args); + case "delbillboard" -> delBillboard(sender, args); case "pos1" -> pos1(sender, args); case "pos2" -> pos2(sender, args); case "setfloor" -> setFloor(sender, args); @@ -75,6 +77,8 @@ public class BlockPartyCommand implements CommandExecutor, TabCompleter { "/blockparty setlobby ", "/blockparty setspawn ", "/blockparty setspectator ", + "/blockparty setbillboard ", + "/blockparty delbillboard ", "/blockparty pos1 ", "/blockparty pos2 ", "/blockparty setfloor ", @@ -191,6 +195,27 @@ public class BlockPartyCommand implements CommandExecutor, TabCompleter { }); } + private void setBillboard(CommandSender sender, String[] args) { + if (!requireAdmin(sender, "blockparty.admin.setup") || !requirePlayer(sender)) return; + withArena(sender, args, arena -> { + arena.getConfig().setBillboard(((Player) sender).getLocation()); + arena.getConfig().setBillboardEnabled(true); + arena.getBillboardManager().invalidate(); + plugin.getArenaManager().save(arena); + sender.sendMessage(plugin.getMessages().get("admin.billboard-set", Map.of("arena", arena.getConfig().getName()))); + }); + } + + private void delBillboard(CommandSender sender, String[] args) { + if (!requireAdmin(sender, "blockparty.admin.setup")) return; + withArena(sender, args, arena -> { + arena.getBillboardManager().despawn(); + arena.getConfig().setBillboard(null); + plugin.getArenaManager().save(arena); + sender.sendMessage(plugin.getMessages().get("admin.billboard-removed", Map.of("arena", arena.getConfig().getName()))); + }); + } + private void pos1(CommandSender sender, String[] args) { if (!requireAdmin(sender, "blockparty.admin.setup") || !requirePlayer(sender)) return; withArena(sender, args, arena -> { @@ -277,6 +302,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("§7Floor region set: §f" + cfg.hasFloorRegion()); sender.sendMessage("§7Floor generated: §f" + cfg.hasGeneratedLayout()); sender.sendMessage("§7Floor materials: §f" + cfg.getFloorMaterials().size()); @@ -394,7 +420,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", "pos1", "pos2", "setfloor", "generate", "info", "list", "reload") + "setlobby", "setspawn", "setspectator", "setbillboard", "delbillboard", "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 2693b81..f7991bf 100644 --- a/src/main/java/us/tss3/blockparty/config/ArenaConfig.java +++ b/src/main/java/us/tss3/blockparty/config/ArenaConfig.java @@ -15,6 +15,8 @@ public class ArenaConfig { private Location lobby; private Location spawn; private Location spectator; + private Location billboard; + private boolean billboardEnabled = true; private int[] pos1; // x,y,z block coords private int[] pos2; @@ -84,6 +86,26 @@ public class ArenaConfig { this.spectator = spectator; } + public Location getBillboard() { + return billboard; + } + + public void setBillboard(Location billboard) { + this.billboard = billboard; + } + + public boolean isBillboardEnabled() { + return billboardEnabled; + } + + public void setBillboardEnabled(boolean billboardEnabled) { + this.billboardEnabled = billboardEnabled; + } + + public boolean hasBillboard() { + return billboard != null && billboardEnabled; + } + public int[] getPos1() { return pos1; } diff --git a/src/main/java/us/tss3/blockparty/config/ArenaConfigLoader.java b/src/main/java/us/tss3/blockparty/config/ArenaConfigLoader.java index 00a56de..a33c70e 100644 --- a/src/main/java/us/tss3/blockparty/config/ArenaConfigLoader.java +++ b/src/main/java/us/tss3/blockparty/config/ArenaConfigLoader.java @@ -57,6 +57,8 @@ 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)); if (yml.contains("pos1")) { cfg.setPos1(new int[]{yml.getInt("pos1.x"), yml.getInt("pos1.y"), yml.getInt("pos1.z")}); } @@ -106,6 +108,10 @@ 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()); + } + 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/java/us/tss3/blockparty/config/ConfigManager.java b/src/main/java/us/tss3/blockparty/config/ConfigManager.java index f2fd72f..9250047 100644 --- a/src/main/java/us/tss3/blockparty/config/ConfigManager.java +++ b/src/main/java/us/tss3/blockparty/config/ConfigManager.java @@ -33,6 +33,10 @@ public class ConfigManager { return config.getBoolean("ui.bossbar", true); } + public boolean isBillboardEnabled() { + return config.getBoolean("ui.billboard", true); + } + public boolean isTitlesEnabled() { return config.getBoolean("ui.titles", true); } diff --git a/src/main/resources/config.yml b/src/main/resources/config.yml index da51b36..1290e6d 100644 --- a/src/main/resources/config.yml +++ b/src/main/resources/config.yml @@ -6,6 +6,11 @@ ui: titles: true actionbar: true scoreboard-update-interval-ticks: 10 + # Global switch for the optional billboard: a BlockDisplay entity placed via + # /blockparty setbillboard that visually mirrors the currently selected + # target color each round. Also requires the per-arena billboard to be set and + # enabled (billboard-enabled in the arena's yml, default true). + billboard: true sounds: join: ENTITY_EXPERIENCE_ORB_PICKUP diff --git a/src/main/resources/messages.yml b/src/main/resources/messages.yml index 4b0cf3a..71a0919 100644 --- a/src/main/resources/messages.yml +++ b/src/main/resources/messages.yml @@ -20,6 +20,8 @@ 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%'." pos1-set: "Position 1 set for '%arena%'." pos2-set: "Position 2 set for '%arena%'." floor-set: "Floor palette for '%arena%' set to %count% material(s)."