diff --git a/README.md b/README.md index de9416e..6535aee 100644 --- a/README.md +++ b/README.md @@ -114,6 +114,13 @@ work fine from console. 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 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 @@ -149,7 +156,10 @@ nothing leaks. 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. - 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, sounds) fire automatically. - **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 are always instantaneous, all blocks at once, by design), `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 - `integrations.placeholderapi`. + (the full 16-color concrete palette by default), `floor.shuffle-each-round`, `music.*` + (see "Music" below), `rewards.winner.commands`, and `integrations.placeholderapi`. - **`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%`, `%time%`, `%color%` are substituted per-message. - **`arenas/.yml`** — one file per arena; a broken file only disables that one arena (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 `/blockparty reload` re-reads `config.yml`, `messages.yml`, and every arena file. Arenas diff --git a/src/main/java/us/tss3/blockparty/arena/Arena.java b/src/main/java/us/tss3/blockparty/arena/Arena.java index 71db54c..5acbcd8 100644 --- a/src/main/java/us/tss3/blockparty/arena/Arena.java +++ b/src/main/java/us/tss3/blockparty/arena/Arena.java @@ -117,6 +117,7 @@ public class Arena { public void disable() { cancelAllTasks(); billboardManager.despawn(); + pauseMusic(); // Force reset players out regardless of state List all = new ArrayList<>(); all.addAll(players); @@ -257,6 +258,9 @@ public class Arena { if (!floorManager.hasLayout()) { floorManager.generate(random); } + if (plugin.getConfigManager().isShuffleFloorEachRound()) { + floorManager.shuffle(random); + } floorManager.restoreFull(plugin.getConfigManager().getFloorBatchBlocksPerTick(), () -> { Location safeSpawn = safeSpawnLocation(); for (UUID uuid : players) { @@ -269,6 +273,7 @@ public class Arena { p.teleport(safeSpawn); } } + startMusic(); startNextRound(); }); } @@ -359,6 +364,7 @@ public class Arena { private void processRoundEnd() { playToParticipants("floor-disappear"); + pauseMusic(); floorManager.removeNonTarget(currentTarget, plugin.getConfigManager().getFloorBatchBlocksPerTick(), () -> { BukkitTask delay = plugin.getServer().getScheduler().runTaskLater(plugin, this::evaluateEliminations, 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() { List> states = new ArrayList<>(); for (UUID uuid : players) { @@ -418,11 +454,16 @@ public class Arena { return; } - BukkitTask restoreDelay = plugin.getServer().getScheduler().runTaskLater(plugin, () -> - floorManager.restoreFull(plugin.getConfigManager().getFloorBatchBlocksPerTick(), () -> { - playToParticipants("round-complete"); - startNextRound(); - }), + BukkitTask restoreDelay = plugin.getServer().getScheduler().runTaskLater(plugin, () -> { + if (plugin.getConfigManager().isShuffleFloorEachRound()) { + floorManager.shuffle(random); + } + floorManager.restoreFull(plugin.getConfigManager().getFloorBatchBlocksPerTick(), () -> { + playToParticipants("round-complete"); + startMusic(); + startNextRound(); + }); + }, config.getFloorRestoreDelaySeconds() * 20L); tasks.add(restoreDelay); } @@ -437,7 +478,11 @@ public class Arena { session.setEliminated(true); } 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) { p.teleport(config.getSpectator()); } @@ -453,6 +498,7 @@ public class Arena { private void endMatch(UUID winner) { stateMachine.transition(ArenaPhase.ENDING); cancelAllTasks(); + pauseMusic(); if (winner != null) { Player winnerPlayer = plugin.getServer().getPlayer(winner); String winnerName = winnerPlayer != null ? winnerPlayer.getName() : "Unknown"; @@ -551,6 +597,7 @@ public class Arena { public void shutdown() { cancelAllTasks(); billboardManager.despawn(); + pauseMusic(); List all = allParticipants(); for (UUID uuid : all) { Player p = plugin.getServer().getPlayer(uuid); diff --git a/src/main/java/us/tss3/blockparty/config/ConfigManager.java b/src/main/java/us/tss3/blockparty/config/ConfigManager.java index 20020e1..2d562ef 100644 --- a/src/main/java/us/tss3/blockparty/config/ConfigManager.java +++ b/src/main/java/us/tss3/blockparty/config/ConfigManager.java @@ -93,6 +93,22 @@ public class ConfigManager { 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() { return config.getBoolean("integrations.placeholderapi", true); } diff --git a/src/main/java/us/tss3/blockparty/floor/FloorManager.java b/src/main/java/us/tss3/blockparty/floor/FloorManager.java index 3332cf1..fac5f97 100644 --- a/src/main/java/us/tss3/blockparty/floor/FloorManager.java +++ b/src/main/java/us/tss3/blockparty/floor/FloorManager.java @@ -9,6 +9,7 @@ import us.tss3.blockparty.config.ArenaConfig; import java.util.ArrayDeque; import java.util.ArrayList; +import java.util.Collections; import java.util.Deque; import java.util.HashMap; import java.util.List; @@ -89,6 +90,26 @@ public class FloorManager { 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 materials = new ArrayList<>(layout.values()); + Collections.shuffle(materials, random); + List 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 * visibly resets instantly at the start of each round instead of filling in gradually. diff --git a/src/main/java/us/tss3/blockparty/util/SoundUtil.java b/src/main/java/us/tss3/blockparty/util/SoundUtil.java index 66dbde0..2b06f45 100644 --- a/src/main/java/us/tss3/blockparty/util/SoundUtil.java +++ b/src/main/java/us/tss3/blockparty/util/SoundUtil.java @@ -2,6 +2,7 @@ package us.tss3.blockparty.util; import org.bukkit.Location; import org.bukkit.Sound; +import org.bukkit.SoundCategory; import org.bukkit.entity.Player; import org.bukkit.plugin.Plugin; @@ -36,6 +37,25 @@ public final class SoundUtil { 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) { if (name == null || name.isBlank()) { return null; diff --git a/src/main/resources/config.yml b/src/main/resources/config.yml index 5530b06..64cbc51 100644 --- a/src/main/resources/config.yml +++ b/src/main/resources/config.yml @@ -21,10 +21,27 @@ sounds: round-complete: BLOCK_NOTE_BLOCK_BELL 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: # blocks processed per server tick when generating/removing/restoring the floor 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 defaults: countdown-duration: 15