Fix solo-play win condition, floor-spawn clipping, and make round-end block removal instantaneous
Build / build (push) Successful in 1m17s
Build / build (push) Successful in 1m17s
This commit is contained in:
@@ -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`.
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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<String> queue = new ArrayDeque<>();
|
||||
for (Map.Entry<String, Material> 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. */
|
||||
|
||||
Reference in New Issue
Block a user