593 lines
22 KiB
Java
593 lines
22 KiB
Java
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<UUID> players = new ArrayList<>();
|
|
private final List<UUID> spectators = new ArrayList<>();
|
|
|
|
private final List<BukkitTask> 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<UUID> getPlayers() {
|
|
return players;
|
|
}
|
|
|
|
public List<UUID> 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<UUID> 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<String, String> 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<Material> 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<String, String> 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<EliminationLogic.PlayerFloorState<UUID, Material>> 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<UUID> 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<UUID> allParticipants() {
|
|
List<UUID> all = new ArrayList<>(players);
|
|
all.addAll(spectators);
|
|
return all;
|
|
}
|
|
|
|
private void resetToWaiting() {
|
|
List<UUID> 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<UUID> 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);
|
|
}
|
|
}
|