package us.tss3.blockparty.arena; import net.kyori.adventure.bossbar.BossBar; import net.kyori.adventure.text.Component; import net.kyori.adventure.title.Title; import org.bukkit.GameMode; import org.bukkit.Location; import org.bukkit.Material; import org.bukkit.entity.Player; import org.bukkit.inventory.ItemStack; import org.bukkit.scheduler.BukkitTask; import us.tss3.blockparty.BlockPartyPlugin; import us.tss3.blockparty.billboard.BillboardManager; import us.tss3.blockparty.config.ArenaConfig; import us.tss3.blockparty.floor.FloorManager; import us.tss3.blockparty.logic.ArenaPhase; import us.tss3.blockparty.logic.ArenaStateMachine; import us.tss3.blockparty.logic.CountdownLogic; import us.tss3.blockparty.logic.EliminationLogic; import us.tss3.blockparty.logic.RoundTimeCalculator; import us.tss3.blockparty.logic.TargetColorSelector; import us.tss3.blockparty.model.PlayerState; import us.tss3.blockparty.session.PlayerSession; import java.time.Duration; import java.util.ArrayList; import java.util.HashMap; import java.util.List; import java.util.Map; import java.util.Random; import java.util.UUID; /** A single running arena: owns its state machine, tasks and round lifecycle. */ public class Arena { private final BlockPartyPlugin plugin; private final ArenaConfig config; private final FloorManager floorManager; private final BillboardManager billboardManager; private final ArenaStateMachine stateMachine = new ArenaStateMachine(ArenaPhase.DISABLED); private final Random random = new Random(); private final TargetColorSelector selector = new TargetColorSelector(random::nextInt); private final List players = new ArrayList<>(); private final List spectators = new ArrayList<>(); private final List tasks = new ArrayList<>(); private BossBar bossBar; 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; private int roundTimeRemaining; public Arena(BlockPartyPlugin plugin, ArenaConfig config) { this.plugin = plugin; this.config = config; this.floorManager = new FloorManager(plugin, config); this.billboardManager = new BillboardManager(plugin, config); if (config.isEnabled()) { stateMachine.transition(ArenaPhase.WAITING); } } public ArenaConfig getConfig() { return config; } public FloorManager getFloorManager() { return floorManager; } public ArenaPhase getPhase() { return stateMachine.getState(); } public List getPlayers() { return players; } public List getSpectators() { return spectators; } public int getRound() { return round; } public Material getCurrentTarget() { return currentTarget; } public int getRoundTimeRemaining() { return roundTimeRemaining; } public int getCurrentRoundTime() { return currentRoundTime; } public boolean isEnabled() { return getPhase() != ArenaPhase.DISABLED; } public void enable() { if (stateMachine.transition(ArenaPhase.WAITING)) { config.setEnabled(true); } } public void disable() { cancelAllTasks(); billboardManager.despawn(); // Force reset players out regardless of state List all = new ArrayList<>(); all.addAll(players); all.addAll(spectators); for (UUID uuid : all) { removePlayerForce(uuid); } stateMachine.transition(ArenaPhase.DISABLED); config.setEnabled(false); } // ---------- Join / Leave ---------- public boolean canJoin() { ArenaPhase phase = getPhase(); if (phase != ArenaPhase.WAITING && !(phase == ArenaPhase.STARTING && config.isAllowJoinWhileStarting())) { return false; } return players.size() < config.getMaxPlayers(); } public void addPlayer(Player player) { PlayerState state = PlayerState.capture(player); PlayerSession session = new PlayerSession(player.getUniqueId(), config.getName(), state); plugin.getSessionManager().add(session); players.add(player.getUniqueId()); preparePlayerForLobby(player); plugin.getScoreboardManager().attach(player, this); maybeStartCountdown(); } private void preparePlayerForLobby(Player player) { player.getInventory().clear(); player.setGameMode(GameMode.ADVENTURE); player.setHealth(20.0); player.setFoodLevel(20); player.setFireTicks(0); if (config.getLobby() != null) { player.teleport(config.getLobby()); } } public void removePlayer(UUID uuid, boolean restoreState) { players.remove(uuid); spectators.remove(uuid); PlayerSession session = plugin.getSessionManager().get(uuid); Player player = plugin.getServer().getPlayer(uuid); if (session != null) { if (restoreState && player != null) { session.getSavedState().restore(player); } plugin.getSessionManager().remove(uuid); } if (player != null) { plugin.getScoreboardManager().detach(player); } checkCountdownCancel(); checkForWinner(); } private void removePlayerForce(UUID uuid) { removePlayer(uuid, true); } private void checkCountdownCancel() { if (getPhase() == ArenaPhase.STARTING && CountdownLogic.shouldCancel(players.size(), config.getMinPlayers(), true)) { cancelCountdown(); } } // ---------- Countdown / Starting ---------- private void maybeStartCountdown() { if (getPhase() == ArenaPhase.WAITING && CountdownLogic.shouldStart(players.size(), config.getMinPlayers(), false)) { startCountdown(); } } private void startCountdown() { if (!stateMachine.transition(ArenaPhase.STARTING)) { return; } countdownRemaining = config.getCountdownDuration(); BukkitTask task = plugin.getServer().getScheduler().runTaskTimer(plugin, () -> { if (CountdownLogic.isFinished(countdownRemaining)) { return; } broadcastCountdownTick(); countdownRemaining = CountdownLogic.tick(countdownRemaining); if (CountdownLogic.isFinished(countdownRemaining)) { beginMatch(); } }, 0L, 20L); tasks.add(task); } private void cancelCountdown() { cancelAllTasks(); stateMachine.transition(ArenaPhase.WAITING); for (UUID uuid : players) { Player p = plugin.getServer().getPlayer(uuid); if (p != null) { p.sendMessage(plugin.getMessages().get("countdown.cancelled")); } } } private void broadcastCountdownTick() { Map ph = Map.of("time", String.valueOf(countdownRemaining), "arena", config.getName()); for (UUID uuid : players) { Player p = plugin.getServer().getPlayer(uuid); if (p == null) { continue; } if (plugin.getConfigManager().isActionbarEnabled()) { p.sendActionBar(plugin.getMessages().get("countdown.actionbar", ph)); } if (plugin.getConfigManager().isTitlesEnabled() && (countdownRemaining <= 5 || countdownRemaining == config.getCountdownDuration())) { p.showTitle(Title.title(plugin.getMessages().get("countdown.title", ph), Component.empty())); } plugin.getSoundUtil().play(p, plugin.getConfigManager().getSound("countdown"), 1f, 1f); } } // ---------- Match flow ---------- private void beginMatch() { cancelAllTasks(); if (!stateMachine.transition(ArenaPhase.RUNNING)) { 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 (safeSpawn != null) { p.teleport(safeSpawn); } } startNextRound(); }); } private void startNextRound() { if (players.isEmpty()) { endMatch(null); return; } // 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; } round++; List available = floorManager.materialsInLayout().stream().distinct().toList(); if (available.isEmpty()) { available = config.getFloorMaterials(); } currentTarget = selector.selectExcluding(available, previousTarget); previousTarget = currentTarget; currentRoundTime = RoundTimeCalculator.timeForRound( config.getStartingRoundTime(), config.getMinimumRoundTime(), config.getRoundTimeReduction(), round); roundTimeRemaining = currentRoundTime; announceTarget(); setupBossBar(); BukkitTask task = plugin.getServer().getScheduler().runTaskTimer(plugin, () -> { roundTimeRemaining--; updateBossBar(); if (roundTimeRemaining <= 0) { cancelAllTasks(); processRoundEnd(); } }, 20L, 20L); tasks.add(task); } private void announceTarget() { Map ph = Map.of("color", formatMaterial(currentTarget), "round", String.valueOf(round), "time", String.valueOf(currentRoundTime)); ItemStack display = new ItemStack(currentTarget); for (UUID uuid : players) { Player p = plugin.getServer().getPlayer(uuid); if (p == null) { continue; } if (plugin.getConfigManager().isTitlesEnabled()) { p.showTitle(Title.title(plugin.getMessages().get("round.title", ph), plugin.getMessages().get("round.subtitle", ph))); } if (plugin.getConfigManager().isActionbarEnabled()) { p.sendActionBar(plugin.getMessages().get("round.actionbar", ph)); } p.getInventory().setItem(8, display.clone()); plugin.getSoundUtil().play(p, plugin.getConfigManager().getSound("target-select"), 1f, 1f); } billboardManager.show(currentTarget); } private void setupBossBar() { if (!plugin.getConfigManager().isBossbarEnabled()) { return; } if (bossBar == null) { bossBar = BossBar.bossBar(Component.text("BlockParty"), 1f, BossBar.Color.YELLOW, BossBar.Overlay.PROGRESS); } bossBar.progress(1f); for (UUID uuid : players) { Player p = plugin.getServer().getPlayer(uuid); if (p != null) { p.showBossBar(bossBar); } } } private void updateBossBar() { if (bossBar == null || currentRoundTime <= 0) { return; } float progress = Math.max(0f, Math.min(1f, (float) roundTimeRemaining / (float) currentRoundTime)); bossBar.progress(progress); } private void processRoundEnd() { playToParticipants("floor-disappear"); floorManager.removeNonTarget(currentTarget, plugin.getConfigManager().getFloorBatchBlocksPerTick(), () -> { BukkitTask delay = plugin.getServer().getScheduler().runTaskLater(plugin, this::evaluateEliminations, config.getFloorRemoveDelaySeconds() * 20L); tasks.add(delay); }); } /** Plays a configured sound directly to every current player/spectator (each at their own * location), instead of once at a single world coordinate — Bukkit's location-based * playSound falls off with distance, so anyone far from that one spot would hear nothing. */ private void playToParticipants(String soundKey) { String soundName = plugin.getConfigManager().getSound(soundKey); for (UUID uuid : allParticipants()) { Player p = plugin.getServer().getPlayer(uuid); if (p != null) { plugin.getSoundUtil().play(p, soundName, 1f, 1f); } } } private void evaluateEliminations() { List> states = new ArrayList<>(); for (UUID uuid : players) { Player p = plugin.getServer().getPlayer(uuid); Material standing = null; if (p != null && p.getWorld() != null) { standing = floorManager.materialAtLocation(p.getWorld(), p.getLocation().getX(), p.getLocation().getY(), p.getLocation().getZ()); } states.add(new EliminationLogic.PlayerFloorState<>(uuid, standing)); } List eliminated = EliminationLogic.determineEliminated(states, currentTarget); for (UUID uuid : eliminated) { eliminatePlayer(uuid); } for (UUID uuid : players) { PlayerSession session = plugin.getSessionManager().get(uuid); if (session != null && !eliminated.contains(uuid)) { session.incrementRoundsSurvived(); } } // 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); if (p != null) { p.hideBossBar(bossBar); } } } endMatch(winner); return; } BukkitTask restoreDelay = plugin.getServer().getScheduler().runTaskLater(plugin, () -> floorManager.restoreFull(plugin.getConfigManager().getFloorBatchBlocksPerTick(), () -> { playToParticipants("round-complete"); startNextRound(); }), config.getFloorRestoreDelaySeconds() * 20L); tasks.add(restoreDelay); } private void eliminatePlayer(UUID uuid) { players.remove(uuid); spectators.add(uuid); Player p = plugin.getServer().getPlayer(uuid); PlayerSession session = plugin.getSessionManager().get(uuid); if (session != null) { session.setRole(PlayerSession.Role.SPECTATOR); session.setEliminated(true); } if (p != null) { p.setGameMode(GameMode.SPECTATOR); if (config.getSpectator() != null) { p.teleport(config.getSpectator()); } p.sendMessage(plugin.getMessages().get("game.eliminated", Map.of("arena", config.getName(), "round", String.valueOf(round)))); plugin.getSoundUtil().play(p, plugin.getConfigManager().getSound("eliminate"), 1f, 1f); if (bossBar != null) { p.hideBossBar(bossBar); } } plugin.getStatsManager().recordElimination(uuid, round); } private void endMatch(UUID winner) { stateMachine.transition(ArenaPhase.ENDING); cancelAllTasks(); if (winner != null) { Player winnerPlayer = plugin.getServer().getPlayer(winner); String winnerName = winnerPlayer != null ? winnerPlayer.getName() : "Unknown"; for (UUID uuid : allParticipants()) { Player p = plugin.getServer().getPlayer(uuid); if (p == null) { continue; } p.showTitle(Title.title(plugin.getMessages().get("game.win-title", Map.of("player", winnerName)), Component.empty())); plugin.getSoundUtil().play(p, plugin.getConfigManager().getSound("victory"), 1f, 1f); } if (winnerPlayer != null) { spawnCelebration(winnerPlayer); } plugin.getStatsManager().recordWin(winner); plugin.getRewardManager().dispatchWinnerRewards(winnerName, config.getName(), round); } else { for (UUID uuid : allParticipants()) { Player p = plugin.getServer().getPlayer(uuid); if (p != null) { p.sendMessage(plugin.getMessages().get("game.no-winner")); } } } for (UUID uuid : players) { plugin.getStatsManager().recordGamePlayed(uuid); } for (UUID uuid : spectators) { plugin.getStatsManager().recordGamePlayed(uuid); } BukkitTask endTask = plugin.getServer().getScheduler().runTaskLater(plugin, this::resetToWaiting, config.getWinEndingDelaySeconds() * 20L); tasks.add(endTask); } private void spawnCelebration(Player winner) { try { for (int i = 0; i < 3; i++) { org.bukkit.entity.Firework fw = winner.getWorld().spawn(winner.getLocation(), org.bukkit.entity.Firework.class); org.bukkit.inventory.meta.FireworkMeta meta = fw.getFireworkMeta(); meta.addEffect(org.bukkit.FireworkEffect.builder() .withColor(org.bukkit.Color.YELLOW, org.bukkit.Color.LIME) .with(org.bukkit.FireworkEffect.Type.BALL_LARGE) .trail(true) .build()); meta.setPower(1); fw.setFireworkMeta(meta); } } catch (Exception ignored) { } } private List allParticipants() { List all = new ArrayList<>(players); all.addAll(spectators); return all; } private void resetToWaiting() { List all = allParticipants(); for (UUID uuid : all) { removePlayer(uuid, true); } players.clear(); spectators.clear(); if (bossBar != null) { bossBar.removeViewer(plugin.getServer().getConsoleSender()); } floorManager.cancelActiveTask(); cancelAllTasks(); round = 0; billboardManager.clear(); stateMachine.transition(ArenaPhase.WAITING); } private void checkForWinner() { if (getPhase() == ArenaPhase.RUNNING && EliminationLogic.isMatchOver(players.size())) { cancelAllTasks(); evaluateEliminations(); } } private void cancelAllTasks() { for (BukkitTask task : tasks) { if (task != null) { task.cancel(); } } tasks.clear(); floorManager.cancelActiveTask(); } /** Called on plugin disable / world unload to safely stop everything and restore players. */ public void shutdown() { cancelAllTasks(); billboardManager.despawn(); List all = allParticipants(); for (UUID uuid : all) { Player p = plugin.getServer().getPlayer(uuid); PlayerSession session = plugin.getSessionManager().get(uuid); if (session != null && p != null) { session.getSavedState().restore(p); } plugin.getSessionManager().remove(uuid); } players.clear(); 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; } private String formatMaterial(Material material) { String name = material.name().replace("_CONCRETE", "").replace("_", " ").toLowerCase(); return name.substring(0, 1).toUpperCase() + name.substring(1); } }