diff --git a/README.md b/README.md index a798364..438c9df 100644 --- a/README.md +++ b/README.md @@ -134,12 +134,18 @@ disabled, deleted, or the plugin shuts down, so it never leaks entities. off entirely. Eliminated players become spectators at the configured spectator location. - 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), + "one player remaining" is the expected steady state, not a win — the match keeps running + rounds, each shorter than the last, until that player is actually eliminated or leaves. + With two or more players, the match ends the moment only one remains. ## Configuration overview - **`config.yml`** — global settings: `ui.*` toggles for scoreboard/bossbar/titles/actionbar, `sounds.*` (Bukkit `Sound` enum names; invalid names are logged and skipped, never crash - the plugin), `performance.floor-blocks-per-tick` (batch size for floor mutations), + the plugin), `performance.floor-blocks-per-tick` (batch size for floor *generation* and + *restoration*, spread across ticks — the round-end removal of non-target blocks is always + instantaneous, all 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`. diff --git a/src/main/java/us/tss3/blockparty/arena/Arena.java b/src/main/java/us/tss3/blockparty/arena/Arena.java index 5c2374a..cca0a83 100644 --- a/src/main/java/us/tss3/blockparty/arena/Arena.java +++ b/src/main/java/us/tss3/blockparty/arena/Arena.java @@ -49,6 +49,10 @@ public class Arena { private int countdownRemaining; private int round = 0; + /** True when the match started with exactly one player (solo/testing play): in that case + * the match doesn't end just because "one player remains" — it keeps running rounds until + * that player is actually eliminated or leaves. */ + private boolean soloMode; private Material currentTarget; private Material previousTarget; private int currentRoundTime; @@ -248,19 +252,21 @@ public class Arena { return; } round = 0; + soloMode = players.size() == 1; previousTarget = null; if (!floorManager.hasLayout()) { floorManager.generate(random); } floorManager.restoreFull(plugin.getConfigManager().getFloorBatchBlocksPerTick(), () -> { + Location safeSpawn = safeSpawnLocation(); for (UUID uuid : players) { Player p = plugin.getServer().getPlayer(uuid); if (p == null) { continue; } p.setGameMode(GameMode.ADVENTURE); - if (config.getSpawn() != null) { - p.teleport(config.getSpawn()); + if (safeSpawn != null) { + p.teleport(safeSpawn); } } startNextRound(); @@ -272,10 +278,12 @@ public class Arena { endMatch(null); return; } - // Only treat "down to one player" as a win once at least one round has actually been - // played (round > 0). At round 0 (the very first round after the countdown), a single - // player is expected whenever min-players is configured as 1 for solo play/testing. - if (round > 0 && EliminationLogic.isMatchOver(players.size())) { + // In solo mode the match only ends when the lone player is actually eliminated (see + // evaluateEliminations) or leaves (players.isEmpty() above) — "one player remaining" + // is the expected steady state, not a win condition, so round count keeps climbing. + // In normal multiplayer, only treat "down to one player" as a win once at least one + // round has actually been played (round > 0); at round 0 that's just the initial join. + if (!soloMode && round > 0 && EliminationLogic.isMatchOver(players.size())) { endMatch(players.get(0)); return; } @@ -379,8 +387,12 @@ public class Arena { } } - if (EliminationLogic.isMatchOver(players.size())) { - UUID winner = players.isEmpty() ? null : players.get(0); + // In solo mode, "1 player left" is the normal steady state — only end when that + // player is actually eliminated (players empty). In multiplayer, end as soon as at + // most one player remains. + boolean over = soloMode ? players.isEmpty() : EliminationLogic.isMatchOver(players.size()); + if (over) { + UUID winner = (soloMode || players.isEmpty()) ? null : players.get(0); if (bossBar != null) { for (UUID uuid : spectators) { Player p = plugin.getServer().getPlayer(uuid); @@ -536,6 +548,23 @@ public class Arena { spectators.clear(); } + /** The configured spawn, lifted above the floor's top block layer if it would otherwise + * place the player inside/underneath the freshly (re)generated floor. */ + private Location safeSpawnLocation() { + Location spawn = config.getSpawn(); + if (spawn == null) { + return null; + } + if (config.hasFloorRegion()) { + int floorTopY = Math.max(config.getPos1()[1], config.getPos2()[1]); + if (spawn.getBlockY() <= floorTopY) { + spawn = spawn.clone(); + spawn.setY(floorTopY + 1); + } + } + return spawn; + } + public BillboardManager getBillboardManager() { return billboardManager; } diff --git a/src/main/java/us/tss3/blockparty/floor/FloorManager.java b/src/main/java/us/tss3/blockparty/floor/FloorManager.java index 78c2b15..6587c10 100644 --- a/src/main/java/us/tss3/blockparty/floor/FloorManager.java +++ b/src/main/java/us/tss3/blockparty/floor/FloorManager.java @@ -121,7 +121,15 @@ public class FloorManager { activeTask = runnable.runTaskTimer(plugin, 0L, 1L); } - /** Removes every block that is not the target material, batched across ticks. */ + /** + * Removes every block that is not the target material, all within a single tick so the + * non-target floor visibly disappears all at once rather than block-by-block. Unlike + * {@link #restoreFull} / {@link #generate}, this is intentionally not spread across ticks: + * the "reveal" moment is meant to be instantaneous. Arena floors are expected to stay in + * the tens-to-low-thousands of blocks; for a given target color roughly (palette-size - 1) + * / palette-size of the floor is removed, which stays comfortably within a single-tick + * synchronous block-mutation budget. + */ public void removeNonTarget(Material target, int blocksPerTick, Runnable onComplete) { World world = config.getWorld(); if (world == null) { @@ -131,31 +139,14 @@ public class FloorManager { return; } cancelActiveTask(); - Deque queue = new ArrayDeque<>(); for (Map.Entry entry : layout.entrySet()) { if (entry.getValue() != target) { - queue.add(entry.getKey()); + setBlock(world, entry.getKey(), Material.AIR); } } - org.bukkit.scheduler.BukkitRunnable runnable = new org.bukkit.scheduler.BukkitRunnable() { - @Override - public void run() { - int processed = 0; - while (processed < blocksPerTick && !queue.isEmpty()) { - String key = queue.poll(); - setBlock(world, key, Material.AIR); - processed++; - } - if (queue.isEmpty()) { - cancel(); - activeTask = null; - if (onComplete != null) { - onComplete.run(); - } - } - } - }; - activeTask = runnable.runTaskTimer(plugin, 0L, 1L); + if (onComplete != null) { + onComplete.run(); + } } /** Clears the entire floor region to air, batched. */