Add optional round-target billboard display
Build / build (push) Successful in 1m14s

This commit is contained in:
Michael Burgess
2026-08-07 07:52:14 -04:00
parent 2a04392942
commit c78d35d104
9 changed files with 212 additions and 5 deletions
+26 -4
View File
@@ -25,7 +25,7 @@ slightly faster round. Last player standing wins.
All admin/config commands live under `/blockparty` (alias `/bp`) and work from console All admin/config commands live under `/blockparty` (alias `/bp`) and work from console
where it makes sense (`create`, `delete`, `enable`, `disable`, `info`, `list`, `reload`). where it makes sense (`create`, `delete`, `enable`, `disable`, `info`, `list`, `reload`).
Commands that require a physical location (`setlobby`, `setspawn`, `setspectator`, Commands that require a physical location (`setlobby`, `setspawn`, `setspectator`,
`pos1`, `pos2`) require a player sender. `setbillboard`, `pos1`, `pos2`) require a player sender.
### Admin ### Admin
| Command | Description | | Command | Description |
@@ -38,6 +38,8 @@ Commands that require a physical location (`setlobby`, `setspawn`, `setspectator
| `/blockparty setlobby <arena>` | Set the waiting-room location | | `/blockparty setlobby <arena>` | Set the waiting-room location |
| `/blockparty setspawn <arena>` | Set the in-game spawn location | | `/blockparty setspawn <arena>` | Set the in-game spawn location |
| `/blockparty setspectator <arena>` | Set the spectator/eliminated location | | `/blockparty setspectator <arena>` | Set the spectator/eliminated location |
| `/blockparty setbillboard <arena>` | (Optional) place a display block here that mirrors the current target color |
| `/blockparty delbillboard <arena>` | Remove the billboard, if one is set |
| `/blockparty pos1 <arena>` | Set floor region corner 1 | | `/blockparty pos1 <arena>` | Set floor region corner 1 |
| `/blockparty pos2 <arena>` | Set floor region corner 2 | | `/blockparty pos2 <arena>` | Set floor region corner 2 |
| `/blockparty setfloor <arena> <mat1,mat2,...>` | Set the allowed floor materials | | `/blockparty setfloor <arena> <mat1,mat2,...>` | 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 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 each round (BlockParty does not scan the world every round — it restores from the saved
layout for performance and determinism). layout for performance and determinism).
8. `/bp info lobby1` to confirm there are no validation errors. 8. Optionally, stand somewhere visible near/above the floor and run `/bp setbillboard lobby1`
9. `/bp enable lobby1`. to place a big display block that mirrors the round's target color — purely cosmetic and
10. Players run `/bp join lobby1`. Once `min-players` is reached the countdown starts 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 automatically; joining an arena mid-countdown is allowed by default
(`allow-join-while-waiting: true`). (`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 (`generated-layout`) so it can be restored exactly, every round, without re-scanning or
re-randomizing the world. 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 <arena>` has been run (stored as `billboard`
in the arena's yml). `/bp delbillboard <arena>` 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 ## Player usage
- `/bp join <arena>` to enter the waiting lobby. Your inventory, armor, offhand, health, - `/bp join <arena>` to enter the waiting lobby. Your inventory, armor, offhand, health,
@@ -10,6 +10,7 @@ import org.bukkit.entity.Player;
import org.bukkit.inventory.ItemStack; import org.bukkit.inventory.ItemStack;
import org.bukkit.scheduler.BukkitTask; import org.bukkit.scheduler.BukkitTask;
import us.tss3.blockparty.BlockPartyPlugin; import us.tss3.blockparty.BlockPartyPlugin;
import us.tss3.blockparty.billboard.BillboardManager;
import us.tss3.blockparty.config.ArenaConfig; import us.tss3.blockparty.config.ArenaConfig;
import us.tss3.blockparty.floor.FloorManager; import us.tss3.blockparty.floor.FloorManager;
import us.tss3.blockparty.logic.ArenaPhase; import us.tss3.blockparty.logic.ArenaPhase;
@@ -35,6 +36,7 @@ public class Arena {
private final BlockPartyPlugin plugin; private final BlockPartyPlugin plugin;
private final ArenaConfig config; private final ArenaConfig config;
private final FloorManager floorManager; private final FloorManager floorManager;
private final BillboardManager billboardManager;
private final ArenaStateMachine stateMachine = new ArenaStateMachine(ArenaPhase.DISABLED); private final ArenaStateMachine stateMachine = new ArenaStateMachine(ArenaPhase.DISABLED);
private final Random random = new Random(); private final Random random = new Random();
private final TargetColorSelector selector = new TargetColorSelector(random::nextInt); private final TargetColorSelector selector = new TargetColorSelector(random::nextInt);
@@ -56,6 +58,7 @@ public class Arena {
this.plugin = plugin; this.plugin = plugin;
this.config = config; this.config = config;
this.floorManager = new FloorManager(plugin, config); this.floorManager = new FloorManager(plugin, config);
this.billboardManager = new BillboardManager(plugin, config);
if (config.isEnabled()) { if (config.isEnabled()) {
stateMachine.transition(ArenaPhase.WAITING); stateMachine.transition(ArenaPhase.WAITING);
} }
@@ -109,6 +112,7 @@ public class Arena {
public void disable() { public void disable() {
cancelAllTasks(); cancelAllTasks();
billboardManager.despawn();
// Force reset players out regardless of state // Force reset players out regardless of state
List<UUID> all = new ArrayList<>(); List<UUID> all = new ArrayList<>();
all.addAll(players); all.addAll(players);
@@ -318,6 +322,7 @@ public class Arena {
p.getInventory().setItem(8, display.clone()); p.getInventory().setItem(8, display.clone());
plugin.getSoundUtil().play(p, plugin.getConfigManager().getSound("target-select"), 1f, 1f); plugin.getSoundUtil().play(p, plugin.getConfigManager().getSound("target-select"), 1f, 1f);
} }
billboardManager.show(currentTarget);
} }
private void setupBossBar() { private void setupBossBar() {
@@ -493,6 +498,7 @@ public class Arena {
floorManager.cancelActiveTask(); floorManager.cancelActiveTask();
cancelAllTasks(); cancelAllTasks();
round = 0; round = 0;
billboardManager.clear();
stateMachine.transition(ArenaPhase.WAITING); stateMachine.transition(ArenaPhase.WAITING);
} }
@@ -516,6 +522,7 @@ public class Arena {
/** Called on plugin disable / world unload to safely stop everything and restore players. */ /** Called on plugin disable / world unload to safely stop everything and restore players. */
public void shutdown() { public void shutdown() {
cancelAllTasks(); cancelAllTasks();
billboardManager.despawn();
List<UUID> all = allParticipants(); List<UUID> all = allParticipants();
for (UUID uuid : all) { for (UUID uuid : all) {
Player p = plugin.getServer().getPlayer(uuid); Player p = plugin.getServer().getPlayer(uuid);
@@ -529,6 +536,10 @@ public class Arena {
spectators.clear(); spectators.clear();
} }
public BillboardManager getBillboardManager() {
return billboardManager;
}
private String formatMaterial(Material material) { private String formatMaterial(Material material) {
String name = material.name().replace("_CONCRETE", "").replace("_", " ").toLowerCase(); String name = material.name().replace("_CONCRETE", "").replace("_", " ").toLowerCase();
return name.substring(0, 1).toUpperCase() + name.substring(1); return name.substring(0, 1).toUpperCase() + name.substring(1);
@@ -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;
}
}
@@ -45,6 +45,8 @@ public class BlockPartyCommand implements CommandExecutor, TabCompleter {
case "setlobby" -> setLobby(sender, args); case "setlobby" -> setLobby(sender, args);
case "setspawn" -> setSpawn(sender, args); case "setspawn" -> setSpawn(sender, args);
case "setspectator" -> setSpectator(sender, args); case "setspectator" -> setSpectator(sender, args);
case "setbillboard" -> setBillboard(sender, args);
case "delbillboard" -> delBillboard(sender, args);
case "pos1" -> pos1(sender, args); case "pos1" -> pos1(sender, args);
case "pos2" -> pos2(sender, args); case "pos2" -> pos2(sender, args);
case "setfloor" -> setFloor(sender, args); case "setfloor" -> setFloor(sender, args);
@@ -75,6 +77,8 @@ public class BlockPartyCommand implements CommandExecutor, TabCompleter {
"/blockparty setlobby <arena>", "/blockparty setlobby <arena>",
"/blockparty setspawn <arena>", "/blockparty setspawn <arena>",
"/blockparty setspectator <arena>", "/blockparty setspectator <arena>",
"/blockparty setbillboard <arena>",
"/blockparty delbillboard <arena>",
"/blockparty pos1 <arena>", "/blockparty pos1 <arena>",
"/blockparty pos2 <arena>", "/blockparty pos2 <arena>",
"/blockparty setfloor <arena>", "/blockparty setfloor <arena>",
@@ -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) { private void pos1(CommandSender sender, String[] args) {
if (!requireAdmin(sender, "blockparty.admin.setup") || !requirePlayer(sender)) return; if (!requireAdmin(sender, "blockparty.admin.setup") || !requirePlayer(sender)) return;
withArena(sender, args, arena -> { withArena(sender, args, arena -> {
@@ -277,6 +302,7 @@ public class BlockPartyCommand implements CommandExecutor, TabCompleter {
sender.sendMessage("§7Lobby set: §f" + (cfg.getLobby() != null)); sender.sendMessage("§7Lobby set: §f" + (cfg.getLobby() != null));
sender.sendMessage("§7Spawn set: §f" + (cfg.getSpawn() != null)); sender.sendMessage("§7Spawn set: §f" + (cfg.getSpawn() != null));
sender.sendMessage("§7Spectator set: §f" + (cfg.getSpectator() != 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 region set: §f" + cfg.hasFloorRegion());
sender.sendMessage("§7Floor generated: §f" + cfg.hasGeneratedLayout()); sender.sendMessage("§7Floor generated: §f" + cfg.hasGeneratedLayout());
sender.sendMessage("§7Floor materials: §f" + cfg.getFloorMaterials().size()); sender.sendMessage("§7Floor materials: §f" + cfg.getFloorMaterials().size());
@@ -394,7 +420,7 @@ public class BlockPartyCommand implements CommandExecutor, TabCompleter {
public List<String> onTabComplete(CommandSender sender, Command command, String alias, String[] args) { public List<String> onTabComplete(CommandSender sender, Command command, String alias, String[] args) {
if (args.length == 1) { if (args.length == 1) {
return List.of("help", "join", "leave", "arenas", "stats", "create", "delete", "enable", "disable", 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()); .stream().filter(s -> s.startsWith(args[0].toLowerCase())).collect(Collectors.toList());
} }
if (args.length == 2) { if (args.length == 2) {
@@ -15,6 +15,8 @@ public class ArenaConfig {
private Location lobby; private Location lobby;
private Location spawn; private Location spawn;
private Location spectator; private Location spectator;
private Location billboard;
private boolean billboardEnabled = true;
private int[] pos1; // x,y,z block coords private int[] pos1; // x,y,z block coords
private int[] pos2; private int[] pos2;
@@ -84,6 +86,26 @@ public class ArenaConfig {
this.spectator = spectator; 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() { public int[] getPos1() {
return pos1; return pos1;
} }
@@ -57,6 +57,8 @@ public class ArenaConfigLoader {
cfg.setLobby(LocationUtil.fromSection(yml.getConfigurationSection("lobby"))); cfg.setLobby(LocationUtil.fromSection(yml.getConfigurationSection("lobby")));
cfg.setSpawn(LocationUtil.fromSection(yml.getConfigurationSection("spawn"))); cfg.setSpawn(LocationUtil.fromSection(yml.getConfigurationSection("spawn")));
cfg.setSpectator(LocationUtil.fromSection(yml.getConfigurationSection("spectator"))); 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")) { if (yml.contains("pos1")) {
cfg.setPos1(new int[]{yml.getInt("pos1.x"), yml.getInt("pos1.y"), yml.getInt("pos1.z")}); 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) { if (cfg.getSpectator() != null) {
LocationUtil.toSection(yml.createSection("spectator"), cfg.getSpectator()); 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) { if (cfg.getPos1() != null) {
yml.set("pos1.x", cfg.getPos1()[0]); yml.set("pos1.x", cfg.getPos1()[0]);
yml.set("pos1.y", cfg.getPos1()[1]); yml.set("pos1.y", cfg.getPos1()[1]);
@@ -33,6 +33,10 @@ public class ConfigManager {
return config.getBoolean("ui.bossbar", true); return config.getBoolean("ui.bossbar", true);
} }
public boolean isBillboardEnabled() {
return config.getBoolean("ui.billboard", true);
}
public boolean isTitlesEnabled() { public boolean isTitlesEnabled() {
return config.getBoolean("ui.titles", true); return config.getBoolean("ui.titles", true);
} }
+5
View File
@@ -6,6 +6,11 @@ ui:
titles: true titles: true
actionbar: true actionbar: true
scoreboard-update-interval-ticks: 10 scoreboard-update-interval-ticks: 10
# Global switch for the optional billboard: a BlockDisplay entity placed via
# /blockparty setbillboard <arena> 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: sounds:
join: ENTITY_EXPERIENCE_ORB_PICKUP join: ENTITY_EXPERIENCE_ORB_PICKUP
+2
View File
@@ -20,6 +20,8 @@ admin:
lobby-set: "<green>Lobby set for '%arena%'.</green>" lobby-set: "<green>Lobby set for '%arena%'.</green>"
spawn-set: "<green>Spawn set for '%arena%'.</green>" spawn-set: "<green>Spawn set for '%arena%'.</green>"
spectator-set: "<green>Spectator location 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>"
pos1-set: "<green>Position 1 set for '%arena%'.</green>" pos1-set: "<green>Position 1 set for '%arena%'.</green>"
pos2-set: "<green>Position 2 set for '%arena%'.</green>" pos2-set: "<green>Position 2 set for '%arena%'.</green>"
floor-set: "<green>Floor palette for '%arena%' set to %count% material(s).</green>" floor-set: "<green>Floor palette for '%arena%' set to %count% material(s).</green>"