Fix solo-play win condition, floor-spawn clipping, and make round-end block removal instantaneous
Build / build (push) Successful in 1m17s

This commit is contained in:
Michael Burgess
2026-08-07 08:09:45 -04:00
parent c78d35d104
commit 07d210d8db
3 changed files with 57 additions and 31 deletions
@@ -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. */