Shuffle floor colors each round, keep eliminated players out of spectator mode, add optional match music
Build / build (push) Successful in 1m17s

This commit is contained in:
Michael Burgess
2026-08-07 11:24:43 -04:00
parent 796f8bd1de
commit a29f359009
6 changed files with 160 additions and 9 deletions
+33 -3
View File
@@ -114,6 +114,13 @@ work fine from console.
configured palette) across the whole region and serializes it into the arena's yml file configured palette) across the whole region and serializes it into the arena's yml file
(`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.
- By default the *positions* from that saved layout are reused every round, but which color
sits at which position is reshuffled (`floor.shuffle-each-round: true` in `config.yml`) at
the start of the match and before every round's restore — so the board isn't a static,
memorizable pattern for the whole game. The shuffle only permutes materials in memory; the
saved `generated-layout` itself is never modified, so a restart or another `/bp generate`
still starts from the original. Set `shuffle-each-round: false` to always restore the exact
saved layout unchanged instead.
### Billboards ### Billboards
@@ -149,7 +156,10 @@ nothing leaks.
target block is placed in your hotbar (slot 9) as a visual reminder. A boss bar (if target block is placed in your hotbar (slot 9) as a visual reminder. A boss bar (if
enabled) shows the remaining time as a shrinking progress bar. enabled) shows the remaining time as a shrinking progress bar.
- Get eliminated by not standing on the target color when the floor clears, or by falling - Get eliminated by not standing on the target color when the floor clears, or by falling
off entirely. Eliminated players become spectators at the configured spectator location. off entirely. Eliminated players are teleported to the configured spectator location and
marked as spectators for gameplay purposes (no damage, can't interfere), but stay in
adventure mode rather than true spectator mode — no noclip/flight, just watching from where
they landed.
- Last player standing wins; rewards, stats and celebration effects (fireworks, titles, - Last player standing wins; rewards, stats and celebration effects (fireworks, titles,
sounds) fire automatically. sounds) fire automatically.
- **Solo play**: if a match starts with only one player (e.g. `min-players: 1` for testing), - **Solo play**: if a match starts with only one player (e.g. `min-players: 1` for testing),
@@ -166,14 +176,34 @@ nothing leaks.
future/optional batched floor operations — round-end removal and each round's floor reset future/optional batched floor operations — round-end removal and each round's floor reset
are always instantaneous, all blocks at once, by design), are always instantaneous, all blocks at once, by design),
`defaults.*` (used only when `/bp create` seeds a new arena), `default-floor-materials` `defaults.*` (used only when `/bp create` seeds a new arena), `default-floor-materials`
(the full 16-color concrete palette by default), `rewards.winner.commands`, and (the full 16-color concrete palette by default), `floor.shuffle-each-round`, `music.*`
`integrations.placeholderapi`. (see "Music" below), `rewards.winner.commands`, and `integrations.placeholderapi`.
- **`messages.yml`** — every user-facing string, in [MiniMessage](https://docs.advntr.dev/minimessage/format.html) - **`messages.yml`** — every user-facing string, in [MiniMessage](https://docs.advntr.dev/minimessage/format.html)
format (Adventure, bundled with Paper). Placeholders like `%player%`, `%arena%`, `%round%`, format (Adventure, bundled with Paper). Placeholders like `%player%`, `%arena%`, `%round%`,
`%time%`, `%color%` are substituted per-message. `%time%`, `%color%` are substituted per-message.
- **`arenas/<name>.yml`** — one file per arena; a broken file only disables that one arena - **`arenas/<name>.yml`** — one file per arena; a broken file only disables that one arena
(the rest still load) and the error is logged. (the rest still load) and the error is logged.
### Music
Optional background music for the duration of a match, off by default:
```yaml
music:
enabled: true
track: MUSIC_DISC_PIGSTEP
volume: 1.0
```
`track` is any Bukkit `Sound` enum name that's actually a track (one of the `MUSIC_DISC_*`
sounds, or a `MUSIC_*` ambient track) — invalid names are logged and skipped, never crash the
plugin. It plays on the client's dedicated **Music** volume slider (via `SoundCategory.MUSIC`),
separately from sound effects, starting when a match begins. It's paused the instant the floor
wipes each round and resumed once the floor has fully reset for the next round. Note this is a
stop/replay-from-the-start rather than a true mid-track pause/resume — vanilla Minecraft's sound
API has no seek/resume-from-position, so each "resume" restarts the track from the beginning.
It's stopped entirely at match end, arena disable, or plugin shutdown.
### Reload behavior ### Reload behavior
`/blockparty reload` re-reads `config.yml`, `messages.yml`, and every arena file. Arenas `/blockparty reload` re-reads `config.yml`, `messages.yml`, and every arena file. Arenas
@@ -117,6 +117,7 @@ public class Arena {
public void disable() { public void disable() {
cancelAllTasks(); cancelAllTasks();
billboardManager.despawn(); billboardManager.despawn();
pauseMusic();
// 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);
@@ -257,6 +258,9 @@ public class Arena {
if (!floorManager.hasLayout()) { if (!floorManager.hasLayout()) {
floorManager.generate(random); floorManager.generate(random);
} }
if (plugin.getConfigManager().isShuffleFloorEachRound()) {
floorManager.shuffle(random);
}
floorManager.restoreFull(plugin.getConfigManager().getFloorBatchBlocksPerTick(), () -> { floorManager.restoreFull(plugin.getConfigManager().getFloorBatchBlocksPerTick(), () -> {
Location safeSpawn = safeSpawnLocation(); Location safeSpawn = safeSpawnLocation();
for (UUID uuid : players) { for (UUID uuid : players) {
@@ -269,6 +273,7 @@ public class Arena {
p.teleport(safeSpawn); p.teleport(safeSpawn);
} }
} }
startMusic();
startNextRound(); startNextRound();
}); });
} }
@@ -359,6 +364,7 @@ public class Arena {
private void processRoundEnd() { private void processRoundEnd() {
playToParticipants("floor-disappear"); playToParticipants("floor-disappear");
pauseMusic();
floorManager.removeNonTarget(currentTarget, plugin.getConfigManager().getFloorBatchBlocksPerTick(), () -> { floorManager.removeNonTarget(currentTarget, plugin.getConfigManager().getFloorBatchBlocksPerTick(), () -> {
BukkitTask delay = plugin.getServer().getScheduler().runTaskLater(plugin, this::evaluateEliminations, BukkitTask delay = plugin.getServer().getScheduler().runTaskLater(plugin, this::evaluateEliminations,
config.getFloorRemoveDelaySeconds() * 20L); config.getFloorRemoveDelaySeconds() * 20L);
@@ -379,6 +385,36 @@ public class Arena {
} }
} }
/** Starts the configured background music (if enabled) for every current player/spectator,
* from the beginning of the track — Bukkit has no true "resume from position" API, so
* pause/resume below is implemented as stop/replay-from-start rather than a real seek. */
private void startMusic() {
if (!plugin.getConfigManager().isMusicEnabled()) {
return;
}
String track = plugin.getConfigManager().getMusicTrack();
float volume = plugin.getConfigManager().getMusicVolume();
for (UUID uuid : allParticipants()) {
Player p = plugin.getServer().getPlayer(uuid);
if (p != null) {
plugin.getSoundUtil().playMusic(p, track, volume);
}
}
}
private void pauseMusic() {
if (!plugin.getConfigManager().isMusicEnabled()) {
return;
}
String track = plugin.getConfigManager().getMusicTrack();
for (UUID uuid : allParticipants()) {
Player p = plugin.getServer().getPlayer(uuid);
if (p != null) {
plugin.getSoundUtil().stopMusic(p, track);
}
}
}
private void evaluateEliminations() { private void evaluateEliminations() {
List<EliminationLogic.PlayerFloorState<UUID, Material>> states = new ArrayList<>(); List<EliminationLogic.PlayerFloorState<UUID, Material>> states = new ArrayList<>();
for (UUID uuid : players) { for (UUID uuid : players) {
@@ -418,11 +454,16 @@ public class Arena {
return; return;
} }
BukkitTask restoreDelay = plugin.getServer().getScheduler().runTaskLater(plugin, () -> BukkitTask restoreDelay = plugin.getServer().getScheduler().runTaskLater(plugin, () -> {
if (plugin.getConfigManager().isShuffleFloorEachRound()) {
floorManager.shuffle(random);
}
floorManager.restoreFull(plugin.getConfigManager().getFloorBatchBlocksPerTick(), () -> { floorManager.restoreFull(plugin.getConfigManager().getFloorBatchBlocksPerTick(), () -> {
playToParticipants("round-complete"); playToParticipants("round-complete");
startMusic();
startNextRound(); startNextRound();
}), });
},
config.getFloorRestoreDelaySeconds() * 20L); config.getFloorRestoreDelaySeconds() * 20L);
tasks.add(restoreDelay); tasks.add(restoreDelay);
} }
@@ -437,7 +478,11 @@ public class Arena {
session.setEliminated(true); session.setEliminated(true);
} }
if (p != null) { if (p != null) {
p.setGameMode(GameMode.SPECTATOR); // Stay in adventure mode rather than true spectator mode (no noclip/flight) -
// being physically placed at the spectator location plus the gameplay
// restriction listener's damage-cancel for the SPECTATOR role is enough for
// them to safely watch without interacting.
p.setGameMode(GameMode.ADVENTURE);
if (config.getSpectator() != null) { if (config.getSpectator() != null) {
p.teleport(config.getSpectator()); p.teleport(config.getSpectator());
} }
@@ -453,6 +498,7 @@ public class Arena {
private void endMatch(UUID winner) { private void endMatch(UUID winner) {
stateMachine.transition(ArenaPhase.ENDING); stateMachine.transition(ArenaPhase.ENDING);
cancelAllTasks(); cancelAllTasks();
pauseMusic();
if (winner != null) { if (winner != null) {
Player winnerPlayer = plugin.getServer().getPlayer(winner); Player winnerPlayer = plugin.getServer().getPlayer(winner);
String winnerName = winnerPlayer != null ? winnerPlayer.getName() : "Unknown"; String winnerName = winnerPlayer != null ? winnerPlayer.getName() : "Unknown";
@@ -551,6 +597,7 @@ public class Arena {
public void shutdown() { public void shutdown() {
cancelAllTasks(); cancelAllTasks();
billboardManager.despawn(); billboardManager.despawn();
pauseMusic();
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);
@@ -93,6 +93,22 @@ public class ConfigManager {
return config.getInt("performance.floor-blocks-per-tick", 200); return config.getInt("performance.floor-blocks-per-tick", 200);
} }
public boolean isShuffleFloorEachRound() {
return config.getBoolean("floor.shuffle-each-round", true);
}
public boolean isMusicEnabled() {
return config.getBoolean("music.enabled", false);
}
public String getMusicTrack() {
return config.getString("music.track", "MUSIC_DISC_PIGSTEP");
}
public float getMusicVolume() {
return (float) config.getDouble("music.volume", 1.0);
}
public boolean isPlaceholderApiEnabled() { public boolean isPlaceholderApiEnabled() {
return config.getBoolean("integrations.placeholderapi", true); return config.getBoolean("integrations.placeholderapi", true);
} }
@@ -9,6 +9,7 @@ import us.tss3.blockparty.config.ArenaConfig;
import java.util.ArrayDeque; import java.util.ArrayDeque;
import java.util.ArrayList; import java.util.ArrayList;
import java.util.Collections;
import java.util.Deque; import java.util.Deque;
import java.util.HashMap; import java.util.HashMap;
import java.util.List; import java.util.List;
@@ -89,6 +90,26 @@ public class FloorManager {
return new ArrayList<>(layout.values()); return new ArrayList<>(layout.values());
} }
/**
* Randomly permutes which material sits at which position, without changing the set of
* positions or how many blocks of each material exist (so target-color counts/fairness
* from {@link #generate} are preserved) — used so the board looks different each round
* instead of always restoring to the exact same static pattern. Purely in-memory: the
* arena's persisted {@code generated-layout} is left untouched, so a restart or
* `/bp generate` still starts from the original saved layout.
*/
public void shuffle(Random random) {
if (layout.size() < 2) {
return;
}
List<Material> materials = new ArrayList<>(layout.values());
Collections.shuffle(materials, random);
List<String> keys = new ArrayList<>(layout.keySet());
for (int i = 0; i < keys.size(); i++) {
layout.put(keys.get(i), materials.get(i));
}
}
/** /**
* Places every block from the stored layout, all within a single tick, so the floor * Places every block from the stored layout, all within a single tick, so the floor
* visibly resets instantly at the start of each round instead of filling in gradually. * visibly resets instantly at the start of each round instead of filling in gradually.
@@ -2,6 +2,7 @@ package us.tss3.blockparty.util;
import org.bukkit.Location; import org.bukkit.Location;
import org.bukkit.Sound; import org.bukkit.Sound;
import org.bukkit.SoundCategory;
import org.bukkit.entity.Player; import org.bukkit.entity.Player;
import org.bukkit.plugin.Plugin; import org.bukkit.plugin.Plugin;
@@ -36,6 +37,25 @@ public final class SoundUtil {
location.getWorld().playSound(location, sound, volume, pitch); location.getWorld().playSound(location, sound, volume, pitch);
} }
/** Plays a configured track to a player on the MUSIC category (respects their music
* volume slider independently of SFX), from the start. */
public void playMusic(Player player, String soundName, float volume) {
Sound sound = resolve(soundName);
if (sound == null) {
return;
}
player.playSound(player.getLocation(), sound, SoundCategory.MUSIC, volume, 1f);
}
/** Stops a configured track for a player on the MUSIC category, if currently playing. */
public void stopMusic(Player player, String soundName) {
Sound sound = resolve(soundName);
if (sound == null) {
return;
}
player.stopSound(sound, SoundCategory.MUSIC);
}
private Sound resolve(String name) { private Sound resolve(String name) {
if (name == null || name.isBlank()) { if (name == null || name.isBlank()) {
return null; return null;
+17
View File
@@ -21,10 +21,27 @@ sounds:
round-complete: BLOCK_NOTE_BLOCK_BELL round-complete: BLOCK_NOTE_BLOCK_BELL
victory: UI_TOAST_CHALLENGE_COMPLETE victory: UI_TOAST_CHALLENGE_COMPLETE
music:
# Optional background music for the duration of a match, played on the client's MUSIC
# volume slider (separate from sound effects). Off by default since it's a matter of taste.
enabled: false
# Any Bukkit Sound enum name that's actually a track, e.g. one of the MUSIC_DISC_* sounds
# or a MUSIC_* ambient track. Invalid names are logged and skipped, never crash the plugin.
track: MUSIC_DISC_PIGSTEP
volume: 1.0
performance: performance:
# blocks processed per server tick when generating/removing/restoring the floor # blocks processed per server tick when generating/removing/restoring the floor
floor-blocks-per-tick: 200 floor-blocks-per-tick: 200
floor:
# If true (default), the floor's material-to-position layout is randomly reshuffled
# (same set of positions and same count of each material, just rearranged) at the start
# of the match and before every round restore, so the board isn't a static, memorizable
# pattern across the whole game. Purely in-memory - the arena's saved generated-layout
# (from /bp generate) is never modified by shuffling.
shuffle-each-round: true
# example/default timings used when creating a brand-new arena via /blockparty create # example/default timings used when creating a brand-new arena via /blockparty create
defaults: defaults:
countdown-duration: 15 countdown-duration: 15