Scale round difficulty over time and add claimable powerups
Build / build (push) Successful in 1m17s

Reshuffles the floor so the target color's share shrinks each round
(TargetDensityCalculator) instead of always averaging ~1/paletteSize,
making later rounds progressively harder to read.

Adds Super Speed and Air Blast powerups that spawn on the floor each
round, claimable by walking over or left-clicking their marker, expiring
after 2-3 unclaimed rounds. Usage is tracked in player stats.

Co-Authored-By: Claude Sonnet 5 <[email protected]>
This commit is contained in:
Michael Burgess
2026-08-08 11:31:30 -04:00
co-authored by Claude Sonnet 5
parent 8049aa04af
commit 4c4f5a1215
16 changed files with 739 additions and 51 deletions
@@ -15,6 +15,7 @@ import us.tss3.blockparty.jukebox.VoteCodeManager;
import us.tss3.blockparty.jukebox.VoteManager;
import us.tss3.blockparty.listener.GameplayRestrictionListener;
import us.tss3.blockparty.listener.PlayerConnectionListener;
import us.tss3.blockparty.listener.PowerupListener;
import us.tss3.blockparty.persistence.StatsManager;
import us.tss3.blockparty.placeholder.BlockPartyExpansion;
import us.tss3.blockparty.reward.RewardManager;
@@ -65,6 +66,7 @@ public class BlockPartyPlugin extends JavaPlugin implements Listener {
getServer().getPluginManager().registerEvents(new PlayerConnectionListener(this), this);
getServer().getPluginManager().registerEvents(new GameplayRestrictionListener(this), this);
getServer().getPluginManager().registerEvents(new PowerupListener(this), this);
getServer().getPluginManager().registerEvents(this, this);
BlockPartyCommand commandHandler = new BlockPartyCommand(this);
+216 -33
View File
@@ -6,9 +6,13 @@ import net.kyori.adventure.title.Title;
import org.bukkit.GameMode;
import org.bukkit.Location;
import org.bukkit.Material;
import org.bukkit.World;
import org.bukkit.entity.Player;
import org.bukkit.inventory.ItemStack;
import org.bukkit.potion.PotionEffect;
import org.bukkit.potion.PotionEffectType;
import org.bukkit.scheduler.BukkitTask;
import org.bukkit.util.Vector;
import us.tss3.blockparty.BlockPartyPlugin;
import us.tss3.blockparty.billboard.BillboardManager;
import us.tss3.blockparty.config.ArenaConfig;
@@ -19,7 +23,11 @@ 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.logic.TargetDensityCalculator;
import us.tss3.blockparty.model.PlayerState;
import us.tss3.blockparty.powerup.ActivePowerup;
import us.tss3.blockparty.powerup.PowerupManager;
import us.tss3.blockparty.powerup.PowerupType;
import us.tss3.blockparty.session.PlayerSession;
import java.time.Duration;
@@ -37,6 +45,7 @@ public class Arena {
private final ArenaConfig config;
private final FloorManager floorManager;
private final BillboardManager billboardManager;
private final PowerupManager powerupManager;
private final ArenaStateMachine stateMachine = new ArenaStateMachine(ArenaPhase.DISABLED);
private final Random random = new Random();
private final TargetColorSelector selector = new TargetColorSelector(random::nextInt);
@@ -46,6 +55,12 @@ public class Arena {
private final List<BukkitTask> tasks = new ArrayList<>();
private BossBar bossBar;
/** Runs for the whole match (not just the brief post-floor-removal window) and is never
* cleared by {@link #cancelAllTasks()}, which fires every round on the timer/round-end
* transitions — a player who falls through the floor or lands on bedrock must be caught
* no matter what round phase is active at that instant, otherwise they linger until the
* next round's own check happens to catch them. */
private BukkitTask fallWatcherTask;
private int countdownRemaining;
private int round = 0;
@@ -64,6 +79,7 @@ public class Arena {
this.config = config;
this.floorManager = new FloorManager(plugin, config);
this.billboardManager = new BillboardManager(plugin, config);
this.powerupManager = new PowerupManager(plugin.getConfigManager(), floorManager);
if (config.isEnabled()) {
stateMachine.transition(ArenaPhase.WAITING);
}
@@ -117,8 +133,13 @@ public class Arena {
public void disable() {
cancelAllTasks();
stopFallWatcher();
billboardManager.despawn();
pauseMusic();
World disableWorld = config.getWorld();
if (disableWorld != null) {
powerupManager.clearAll(disableWorld);
}
plugin.getVoteManager().clear(config.getName());
// Force reset players out regardless of state
List<UUID> all = new ArrayList<>();
@@ -152,6 +173,28 @@ public class Arena {
maybeStartCountdown();
}
/** Joins the arena purely as a spectator: not counted against max players, never enters
* the round as a participant, and teleported straight to the spectator spawn. */
public void addSpectator(Player player) {
PlayerState state = PlayerState.capture(player);
PlayerSession session = new PlayerSession(player.getUniqueId(), config.getName(), state);
session.setRole(PlayerSession.Role.SPECTATOR);
plugin.getSessionManager().add(session);
spectators.add(player.getUniqueId());
player.getInventory().clear();
player.setGameMode(GameMode.ADVENTURE);
player.setHealth(20.0);
player.setFoodLevel(20);
player.setFireTicks(0);
Location target = config.getSpectator() != null ? config.getSpectator() : config.getLobby();
if (target != null) {
player.teleport(target);
}
if (bossBar != null) {
player.showBossBar(bossBar);
}
}
private void preparePlayerForLobby(Player player) {
player.getInventory().clear();
player.setGameMode(GameMode.ADVENTURE);
@@ -254,16 +297,14 @@ public class Arena {
if (!stateMachine.transition(ArenaPhase.RUNNING)) {
return;
}
startFallWatcher();
round = 0;
soloMode = players.size() == 1;
previousTarget = null;
if (!floorManager.hasLayout()) {
floorManager.generate(random);
}
if (plugin.getConfigManager().isShuffleFloorEachRound()) {
floorManager.shuffle(random);
}
floorManager.restoreFull(plugin.getConfigManager().getFloorBatchBlocksPerTick(), () -> {
prepareRoundFloor(() -> {
Location safeSpawn = safeSpawnLocation();
for (UUID uuid : players) {
Player p = plugin.getServer().getPlayer(uuid);
@@ -280,13 +321,43 @@ public class Arena {
});
}
/** Picks the next target color and reshuffles/restores the floor around it, so the shuffle
* can be biased toward making that specific color rare (see {@link TargetDensityCalculator})
* instead of picking the target only after the floor is already laid out. Shared by
* {@link #beginMatch()} (round 1) and {@link #finishRoundEvaluation()} (every round after). */
private void prepareRoundFloor(Runnable onComplete) {
List<Material> available = floorManager.materialsInLayout().stream().distinct().toList();
if (available.isEmpty()) {
available = config.getFloorMaterials();
}
currentTarget = selector.selectExcluding(available, previousTarget);
previousTarget = currentTarget;
if (plugin.getConfigManager().isShuffleFloorEachRound()) {
double fraction = TargetDensityCalculator.fractionForRound(
plugin.getConfigManager().getStartingTargetBlockFraction(),
plugin.getConfigManager().getMinimumTargetBlockFraction(),
plugin.getConfigManager().getTargetBlockFractionReductionPerRound(),
round + 1);
floorManager.shuffle(random, currentTarget, fraction);
}
floorManager.restoreFull(plugin.getConfigManager().getFloorBatchBlocksPerTick(), () -> {
World world = config.getWorld();
if (world != null) {
powerupManager.expireOld(world, round + 1);
powerupManager.maybeSpawn(world, random, round + 1);
powerupManager.applyMarkers(world);
}
onComplete.run();
});
}
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"
// finishRoundEvaluation) 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.
@@ -295,12 +366,6 @@ public class Arena {
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;
@@ -368,12 +433,129 @@ public class Arena {
playToParticipants("floor-disappear");
pauseMusic();
floorManager.removeNonTarget(currentTarget, plugin.getConfigManager().getFloorBatchBlocksPerTick(), () -> {
BukkitTask delay = plugin.getServer().getScheduler().runTaskLater(plugin, this::evaluateEliminations,
BukkitTask finish = plugin.getServer().getScheduler().runTaskLater(plugin, this::finishRoundEvaluation,
config.getFloorRemoveDelaySeconds() * 20L);
tasks.add(delay);
tasks.add(finish);
});
}
/** Starts the match-long watcher that eliminates a player the instant they actually fall
* through the floor (drop below its surface, or land on bedrock), independent of round
* phase. This is deliberately not judged from a single snapshot after a fixed delay — a
* player who merely jumped while standing on the correct color would otherwise be caught
* mid-air by a one-shot check and wrongly eliminated — and deliberately not confined to
* the brief post-floor-removal window, since a slow fall (e.g. onto distant bedrock) can
* easily outlast that window and would otherwise go undetected until the next round's
* window happened to catch it. */
private void startFallWatcher() {
if (fallWatcherTask != null) {
fallWatcherTask.cancel();
}
fallWatcherTask = plugin.getServer().getScheduler().runTaskTimer(plugin, () -> {
List<UUID> fallen = new ArrayList<>();
for (UUID uuid : players) {
Player p = plugin.getServer().getPlayer(uuid);
if (p == null || p.getWorld() == null) {
continue;
}
Location loc = p.getLocation();
if (floorManager.hasFallenThrough(p.getWorld(), loc.getX(), loc.getY(), loc.getZ())) {
fallen.add(uuid);
continue;
}
checkPowerupClaim(p, loc);
}
for (UUID uuid : fallen) {
eliminatePlayer(uuid);
}
}, 1L, 2L);
}
/** Checks whether a participant is standing on an unclaimed powerup marker and, if so,
* claims it for them. Used both by the walk-over poll above and by left-click claiming
* (see PowerupListener), so a player standing still and clicking still works even between
* poll ticks. */
private void checkPowerupClaim(Player player, Location loc) {
if (getPhase() != ArenaPhase.RUNNING) {
return;
}
// the powerup marker floats in the player's own foot space, one block above the floor
String key = (int) Math.floor(loc.getX()) + "," + (int) Math.floor(loc.getY()) + "," + (int) Math.floor(loc.getZ());
if (!powerupManager.hasActiveAt(key)) {
return;
}
claimPowerup(player, key);
}
/** Attempts to claim a powerup at a world position for a participant; safe to call from a
* listener even if nothing is actually there. */
public void claimPowerupAt(Player player, Location blockLocation) {
if (!players.contains(player.getUniqueId())) {
return;
}
String key = blockLocation.getBlockX() + "," + blockLocation.getBlockY() + "," + blockLocation.getBlockZ();
claimPowerup(player, key);
}
private void claimPowerup(Player player, String key) {
ActivePowerup powerup = powerupManager.claim(key);
if (powerup == null) {
return;
}
powerupManager.clearMarkerBlock(config.getWorld(), key);
applyPowerupEffect(player, powerup.getType());
plugin.getStatsManager().recordPowerupUse(player.getUniqueId());
Map<String, String> ph = Map.of("type", powerup.getType().displayName(), "player", player.getName());
player.sendMessage(plugin.getMessages().get("powerup.claimed-self", ph));
for (UUID uuid : players) {
if (uuid.equals(player.getUniqueId())) {
continue;
}
Player other = plugin.getServer().getPlayer(uuid);
if (other != null) {
other.sendMessage(plugin.getMessages().get("powerup.claimed-other", ph));
}
}
}
private void applyPowerupEffect(Player player, PowerupType type) {
switch (type) {
case SUPER_SPEED -> {
int durationTicks = plugin.getConfigManager().getSuperSpeedDurationSeconds() * 20;
int amplifier = plugin.getConfigManager().getSuperSpeedAmplifier();
player.addPotionEffect(new PotionEffect(PotionEffectType.SPEED, durationTicks, amplifier, false, true));
}
case AIR_BLAST -> {
double radius = plugin.getConfigManager().getAirBlastRadius();
double strength = plugin.getConfigManager().getAirBlastStrength();
for (UUID uuid : players) {
if (uuid.equals(player.getUniqueId())) {
continue;
}
Player other = plugin.getServer().getPlayer(uuid);
if (other == null || !other.getWorld().equals(player.getWorld())) {
continue;
}
double distance = other.getLocation().distance(player.getLocation());
if (distance > radius || distance < 0.001) {
continue;
}
Vector push = other.getLocation().toVector().subtract(player.getLocation().toVector())
.normalize().multiply(strength * (1 - distance / radius));
push.setY(Math.max(push.getY(), 0.35));
other.setVelocity(other.getVelocity().add(push));
}
}
}
}
private void stopFallWatcher() {
if (fallWatcherTask != null) {
fallWatcherTask.cancel();
fallWatcherTask = null;
}
}
/** 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. */
@@ -426,23 +608,14 @@ public class Arena {
}
}
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);
}
/** Called once the post-floor-removal fall window has elapsed. By this point, every
* player who fell through the floor (or hit bedrock) has already been eliminated in
* real time by {@link #monitorFalls}, so whoever remains in {@code players} survived
* the round outright. */
private void finishRoundEvaluation() {
for (UUID uuid : players) {
PlayerSession session = plugin.getSessionManager().get(uuid);
if (session != null && !eliminated.contains(uuid)) {
if (session != null) {
session.incrementRoundsSurvived();
}
}
@@ -466,10 +639,7 @@ public class Arena {
}
BukkitTask restoreDelay = plugin.getServer().getScheduler().runTaskLater(plugin, () -> {
if (plugin.getConfigManager().isShuffleFloorEachRound()) {
floorManager.shuffle(random);
}
floorManager.restoreFull(plugin.getConfigManager().getFloorBatchBlocksPerTick(), () -> {
prepareRoundFloor(() -> {
playToParticipants("round-complete");
startMusic();
startNextRound();
@@ -509,6 +679,7 @@ public class Arena {
private void endMatch(UUID winner) {
stateMachine.transition(ArenaPhase.ENDING);
cancelAllTasks();
stopFallWatcher();
pauseMusic();
if (winner != null) {
Player winnerPlayer = plugin.getServer().getPlayer(winner);
@@ -582,6 +753,10 @@ public class Arena {
}
floorManager.cancelActiveTask();
cancelAllTasks();
World resetWorld = config.getWorld();
if (resetWorld != null) {
powerupManager.clearAll(resetWorld);
}
round = 0;
currentMusicTrack = null;
plugin.getVoteManager().clear(config.getName());
@@ -592,7 +767,7 @@ public class Arena {
private void checkForWinner() {
if (getPhase() == ArenaPhase.RUNNING && EliminationLogic.isMatchOver(players.size())) {
cancelAllTasks();
evaluateEliminations();
finishRoundEvaluation();
}
}
@@ -611,6 +786,10 @@ public class Arena {
cancelAllTasks();
billboardManager.despawn();
pauseMusic();
World shutdownWorld = config.getWorld();
if (shutdownWorld != null) {
powerupManager.clearAll(shutdownWorld);
}
List<UUID> all = allParticipants();
for (UUID uuid : all) {
Player p = plugin.getServer().getPlayer(uuid);
@@ -645,6 +824,10 @@ public class Arena {
return billboardManager;
}
public PowerupManager getPowerupManager() {
return powerupManager;
}
private String formatMaterial(Material material) {
String name = material.name().replace("_CONCRETE", "").replace("_", " ").toLowerCase();
return name.substring(0, 1).toUpperCase() + name.substring(1);
@@ -57,6 +57,7 @@ public class BlockPartyCommand implements CommandExecutor, TabCompleter {
case "list" -> list(sender);
case "reload" -> reload(sender);
case "join" -> join(sender, args);
case "spectate" -> spectate(sender, args);
case "leave" -> leave(sender);
case "arenas" -> list(sender);
case "stats" -> stats(sender, args);
@@ -70,6 +71,7 @@ public class BlockPartyCommand implements CommandExecutor, TabCompleter {
List<String> lines = List.of(
"/blockparty help",
"/blockparty join <arena>",
"/blockparty spectate <arena>",
"/blockparty leave",
"/blockparty arenas",
"/blockparty stats [player]",
@@ -432,6 +434,36 @@ public class BlockPartyCommand implements CommandExecutor, TabCompleter {
sender.sendMessage(plugin.getMessages().get("player.joined", Map.of("arena", name)));
}
private void spectate(CommandSender sender, String[] args) {
if (!requirePlayer(sender)) return;
if (!sender.hasPermission("blockparty.spectate") && !sender.hasPermission("blockparty.play")) {
sender.sendMessage(plugin.getMessages().get("errors.no-permission"));
return;
}
Player player = (Player) sender;
String name = argOrCurrentArenaless(args);
if (name == null) {
sender.sendMessage("Usage: /blockparty spectate <arena>");
return;
}
var opt = plugin.getArenaManager().get(name);
if (opt.isEmpty()) {
sender.sendMessage(plugin.getMessages().get("errors.unknown-arena", Map.of("arena", name)));
return;
}
Arena arena = opt.get();
if (plugin.getSessionManager().isInSession(player.getUniqueId())) {
sender.sendMessage(plugin.getMessages().get("errors.already-in-arena"));
return;
}
if (!arena.isEnabled()) {
sender.sendMessage(plugin.getMessages().get("errors.arena-disabled", Map.of("arena", name)));
return;
}
arena.addSpectator(player);
sender.sendMessage(plugin.getMessages().get("player.spectating", Map.of("arena", name)));
}
private void leave(CommandSender sender) {
if (!requirePlayer(sender)) return;
Player player = (Player) sender;
@@ -481,6 +513,7 @@ public class BlockPartyCommand implements CommandExecutor, TabCompleter {
sender.sendMessage("§7Eliminations: §f" + stats.getEliminations());
sender.sendMessage("§7Best round: §f" + stats.getBestRound());
sender.sendMessage("§7Total rounds survived: §f" + stats.getTotalRoundsSurvived());
sender.sendMessage("§7Powerups used: §f" + stats.getPowerupsUsed());
}
private void withArena(CommandSender sender, String[] args, java.util.function.Consumer<Arena> consumer) {
@@ -500,7 +533,7 @@ public class BlockPartyCommand implements CommandExecutor, TabCompleter {
@Override
public List<String> onTabComplete(CommandSender sender, Command command, String alias, String[] args) {
if (args.length == 1) {
return List.of("help", "join", "leave", "arenas", "stats", "musicvote", "create", "delete", "enable", "disable",
return List.of("help", "join", "spectate", "leave", "arenas", "stats", "musicvote", "create", "delete", "enable", "disable",
"setlobby", "setspawn", "setspectator", "setbillboard", "delbillboard", "billboards", "pos1", "pos2", "setfloor", "generate", "info", "list", "reload")
.stream().filter(s -> s.startsWith(args[0].toLowerCase())).collect(Collectors.toList());
}
@@ -97,6 +97,71 @@ public class ConfigManager {
return config.getBoolean("floor.shuffle-each-round", true);
}
/** Fraction of the floor made of the target color on round 1 (0-1). Below the natural
* 1/paletteSize average so even the first round is harder than a plain uniform shuffle. */
public double getStartingTargetBlockFraction() {
return config.getDouble("floor.starting-target-fraction", 0.5);
}
/** Fraction of the floor made of the target color on the hardest (late-game) rounds. */
public double getMinimumTargetBlockFraction() {
return config.getDouble("floor.minimum-target-fraction", 0.06);
}
/** How much the target color's share of the floor shrinks per round after the first. */
public double getTargetBlockFractionReductionPerRound() {
return config.getDouble("floor.target-fraction-reduction-per-round", 0.04);
}
public boolean isPowerupsEnabled() {
return config.getBoolean("powerups.enabled", true);
}
/** Chance (0-1), rolled once per round, that a new powerup spawns on the floor. */
public double getPowerupSpawnChance() {
return config.getDouble("powerups.spawn-chance-per-round", 0.35);
}
/** Cap on how many unclaimed powerups can sit on the floor at once. */
public int getPowerupMaxActive() {
return config.getInt("powerups.max-active", 1);
}
/** Minimum number of rounds an unclaimed powerup lingers before disappearing. */
public int getPowerupMinLifetimeRounds() {
return config.getInt("powerups.min-lifetime-rounds", 2);
}
/** Maximum number of rounds an unclaimed powerup lingers before disappearing. */
public int getPowerupMaxLifetimeRounds() {
return config.getInt("powerups.max-lifetime-rounds", 3);
}
public org.bukkit.Material getPowerupMarkerMaterial(String typeKey) {
String name = config.getString("powerups.types." + typeKey + ".marker-block", "TORCH");
org.bukkit.Material material = org.bukkit.Material.matchMaterial(name);
return material != null ? material : org.bukkit.Material.TORCH;
}
public int getSuperSpeedDurationSeconds() {
return config.getInt("powerups.types.super-speed.duration-seconds", 8);
}
/** Potion effect amplifier, i.e. Speed (amplifier + 1) - amplifier 1 = Speed II. */
public int getSuperSpeedAmplifier() {
return config.getInt("powerups.types.super-speed.amplifier", 1);
}
/** Radius, in blocks, that Air Blast pushes other nearby players within. */
public double getAirBlastRadius() {
return config.getDouble("powerups.types.air-blast.radius", 4.0);
}
/** Horizontal+vertical knockback strength applied to players caught in an Air Blast. */
public double getAirBlastStrength() {
return config.getDouble("powerups.types.air-blast.strength", 1.4);
}
public boolean isMusicEnabled() {
return config.getBoolean("music.enabled", false);
}
@@ -90,23 +90,40 @@ public class FloorManager {
return new ArrayList<>(layout.values());
}
/** All position keys ("x,y,z") currently in the layout, e.g. so a powerup spawner can pick
* a random floor cell to hover a marker above. */
public List<String> layoutKeys() {
return new ArrayList<>(layout.keySet());
}
/**
* Randomly permutes which material sits at which position, without changing the set of
* positions or how many blocks of each material exist (so target-color counts/fairness
* from {@link #generate} are preserved) — used so the board looks different each round
* instead of always restoring to the exact same static pattern. Purely in-memory: the
* arena's persisted {@code generated-layout} is left untouched, so a restart or
* `/bp generate` still starts from the original saved layout.
* Reassigns every position's material at random, biased so that only {@code targetFraction}
* of the floor ends up as {@code target} — the rest is spread evenly across the other colors
* already present in the layout. Used so the board looks different each round instead of
* always restoring to the exact same static pattern, and so later rounds (a smaller
* {@code targetFraction}) make the target color progressively rarer and harder to spot
* rather than always averaging ~1/(palette size) of the floor. Purely in-memory: the arena's
* persisted {@code generated-layout} is left untouched, so a restart or `/bp generate` still
* starts from the original saved layout.
*
* @param targetFraction desired share (0-1] of the floor that should be {@code target};
* clamped so at least one block of the target and one non-target
* block remain when both are possible
*/
public void shuffle(Random random) {
public void shuffle(Random random, Material target, double targetFraction) {
if (layout.size() < 2) {
return;
}
List<Material> materials = new ArrayList<>(layout.values());
Collections.shuffle(materials, random);
List<Material> otherPalette = layout.values().stream().distinct()
.filter(m -> m != target).toList();
List<String> keys = new ArrayList<>(layout.keySet());
for (int i = 0; i < keys.size(); i++) {
layout.put(keys.get(i), materials.get(i));
Collections.shuffle(keys, random);
int total = keys.size();
int targetCount = otherPalette.isEmpty() ? total
: Math.max(1, Math.min(total - 1, (int) Math.round(total * targetFraction)));
for (int i = 0; i < total; i++) {
Material mat = i < targetCount ? target : otherPalette.get(random.nextInt(otherPalette.size()));
layout.put(keys.get(i), mat);
}
}
@@ -195,6 +212,35 @@ public class FloorManager {
return layout.get(x + "," + y + "," + z);
}
/** Lowest Y coordinate spanned by the floor layout, i.e. the surface players stand on.
* Used as the threshold below which a player is considered to have fallen through. */
public int minLayoutY() {
int min = Integer.MAX_VALUE;
for (String key : layout.keySet()) {
int y = Integer.parseInt(key.split(",")[1]);
if (y < min) {
min = y;
}
}
return min == Integer.MAX_VALUE ? 0 : min;
}
/** True once the player has fallen below the floor surface: either they've dropped past the
* layout's Y level entirely, or the block they're standing in/on is bedrock (the arena's
* hard floor beneath any void/gap). Unlike {@link #materialAtLocation}, this only reports
* a problem once a player has actually fallen, so a jump timed at the moment of the check
* never gets mistaken for falling through. */
public boolean hasFallenThrough(World world, double x, double y, double z) {
if (y < minLayoutY() - 0.5) {
return true;
}
int bx = (int) Math.floor(x);
int bz = (int) Math.floor(z);
int feetY = (int) Math.floor(y);
return world.getBlockAt(bx, feetY, bz).getType() == Material.BEDROCK
|| world.getBlockAt(bx, feetY - 1, bz).getType() == Material.BEDROCK;
}
public Material materialAtLocation(World world, double x, double y, double z) {
// check the block directly below the player's feet
int bx = (int) Math.floor(x);
@@ -0,0 +1,36 @@
package us.tss3.blockparty.listener;
import org.bukkit.entity.Player;
import org.bukkit.event.EventHandler;
import org.bukkit.event.EventPriority;
import org.bukkit.event.Listener;
import org.bukkit.event.block.Action;
import org.bukkit.event.player.PlayerInteractEvent;
import us.tss3.blockparty.BlockPartyPlugin;
import us.tss3.blockparty.arena.Arena;
import us.tss3.blockparty.session.PlayerSession;
/** Lets participants claim a powerup marker by left-clicking it, in addition to just walking
* over it (see Arena's fall-watcher loop). */
public class PowerupListener implements Listener {
private final BlockPartyPlugin plugin;
public PowerupListener(BlockPartyPlugin plugin) {
this.plugin = plugin;
}
@EventHandler(priority = EventPriority.NORMAL, ignoreCancelled = true)
public void onLeftClickBlock(PlayerInteractEvent event) {
if (event.getAction() != Action.LEFT_CLICK_BLOCK || event.getClickedBlock() == null) {
return;
}
Player player = event.getPlayer();
PlayerSession session = plugin.getSessionManager().get(player.getUniqueId());
if (session == null || session.getRole() != PlayerSession.Role.PLAYER) {
return;
}
plugin.getArenaManager().get(session.getArenaName()).ifPresent(arena ->
arena.claimPowerupAt(player, event.getClickedBlock().getLocation()));
}
}
@@ -0,0 +1,35 @@
package us.tss3.blockparty.logic;
/** Pure math for computing how rare the target color's blocks should be on a given round,
* so the floor gets progressively harder to read as a match goes on. */
public final class TargetDensityCalculator {
private TargetDensityCalculator() {
}
/**
* @param startingFraction fraction of the floor made of the target color on round 1 (0-1)
* @param minimumFraction floor value, never go below this (must stay &gt; 0 so a solution exists)
* @param reductionPerRound fraction shaved off per round after the first
* @param roundNumber 1-indexed round number
* @return fraction of the floor's blocks that should be the target color, clamped to (0, 1]
*/
public static double fractionForRound(double startingFraction, double minimumFraction,
double reductionPerRound, int roundNumber) {
if (roundNumber < 1) {
roundNumber = 1;
}
double reduction = reductionPerRound * (roundNumber - 1);
double result = startingFraction - reduction;
if (result < minimumFraction) {
result = minimumFraction;
}
if (result <= 0) {
result = Double.MIN_VALUE;
}
if (result > 1) {
result = 1;
}
return result;
}
}
@@ -10,18 +10,21 @@ public class PlayerStats {
private int eliminations;
private int bestRound;
private int totalRoundsSurvived;
private int powerupsUsed;
public PlayerStats(UUID uuid, int gamesPlayed, int wins, int eliminations, int bestRound, int totalRoundsSurvived) {
public PlayerStats(UUID uuid, int gamesPlayed, int wins, int eliminations, int bestRound, int totalRoundsSurvived,
int powerupsUsed) {
this.uuid = uuid;
this.gamesPlayed = gamesPlayed;
this.wins = wins;
this.eliminations = eliminations;
this.bestRound = bestRound;
this.totalRoundsSurvived = totalRoundsSurvived;
this.powerupsUsed = powerupsUsed;
}
public static PlayerStats empty(UUID uuid) {
return new PlayerStats(uuid, 0, 0, 0, 0, 0);
return new PlayerStats(uuid, 0, 0, 0, 0, 0, 0);
}
public UUID getUuid() {
@@ -47,4 +50,8 @@ public class PlayerStats {
public int getTotalRoundsSurvived() {
return totalRoundsSurvived;
}
public int getPowerupsUsed() {
return powerupsUsed;
}
}
@@ -64,6 +64,7 @@ public class StatsDatabase {
try (Statement st = connection.createStatement()) {
st.execute(createTableStatement());
}
addPowerupsUsedColumnIfMissing();
} catch (Exception ex) {
plugin.getLogger().log(Level.SEVERE, "Failed to initialize " +
(settings.isMysql() ? "MySQL" : "SQLite") + " stats database", ex);
@@ -96,7 +97,8 @@ public class StatsDatabase {
"wins INT NOT NULL DEFAULT 0," +
"eliminations INT NOT NULL DEFAULT 0," +
"best_round INT NOT NULL DEFAULT 0," +
"total_rounds_survived INT NOT NULL DEFAULT 0" +
"total_rounds_survived INT NOT NULL DEFAULT 0," +
"powerups_used INT NOT NULL DEFAULT 0" +
")";
}
return "CREATE TABLE IF NOT EXISTS " + table + " (" +
@@ -105,10 +107,22 @@ public class StatsDatabase {
"wins INTEGER NOT NULL DEFAULT 0," +
"eliminations INTEGER NOT NULL DEFAULT 0," +
"best_round INTEGER NOT NULL DEFAULT 0," +
"total_rounds_survived INTEGER NOT NULL DEFAULT 0" +
"total_rounds_survived INTEGER NOT NULL DEFAULT 0," +
"powerups_used INTEGER NOT NULL DEFAULT 0" +
")";
}
/** Adds the powerups_used column to a pre-existing table from before this stat was tracked.
* Both SQLite and MySQL support plain ADD COLUMN; a failure here just means the column
* already exists (no IF NOT EXISTS support on either dialect for this statement), which is
* safe to ignore. */
private void addPowerupsUsedColumnIfMissing() {
try (Statement st = connection.createStatement()) {
st.execute("ALTER TABLE " + table + " ADD COLUMN powerups_used INT NOT NULL DEFAULT 0");
} catch (SQLException ignored) {
}
}
public synchronized void close() {
try {
if (connection != null && !connection.isClosed()) {
@@ -135,11 +149,11 @@ public class StatsDatabase {
try {
ensureRow(uuid.toString());
try (PreparedStatement ps = connection.prepareStatement(
"SELECT games_played, wins, eliminations, best_round, total_rounds_survived FROM " + table + " WHERE uuid=?")) {
"SELECT games_played, wins, eliminations, best_round, total_rounds_survived, powerups_used FROM " + table + " WHERE uuid=?")) {
ps.setString(1, uuid.toString());
try (ResultSet rs = ps.executeQuery()) {
if (rs.next()) {
return new PlayerStats(uuid, rs.getInt(1), rs.getInt(2), rs.getInt(3), rs.getInt(4), rs.getInt(5));
return new PlayerStats(uuid, rs.getInt(1), rs.getInt(2), rs.getInt(3), rs.getInt(4), rs.getInt(5), rs.getInt(6));
}
}
}
@@ -161,6 +175,10 @@ public class StatsDatabase {
update(uuid, "eliminations = eliminations + 1");
}
public synchronized void incrementPowerupsUsed(UUID uuid) {
update(uuid, "powerups_used = powerups_used + 1");
}
public synchronized void recordRoundReached(UUID uuid, int round) {
if (connection == null) {
return;
@@ -40,6 +40,10 @@ public class StatsManager {
});
}
public void recordPowerupUse(UUID uuid) {
runAsync(() -> database.incrementPowerupsUsed(uuid));
}
public CompletableFuture<PlayerStats> getStats(UUID uuid) {
CompletableFuture<PlayerStats> future = new CompletableFuture<>();
plugin.getServer().getAsyncScheduler().runNow(plugin, task -> future.complete(database.getStats(uuid)));
@@ -0,0 +1,31 @@
package us.tss3.blockparty.powerup;
/** A powerup marker currently sitting on the floor, waiting to be claimed. */
public class ActivePowerup {
private final PowerupType type;
private final String positionKey;
private final int spawnedRound;
private final int lifetimeRounds;
public ActivePowerup(PowerupType type, String positionKey, int spawnedRound, int lifetimeRounds) {
this.type = type;
this.positionKey = positionKey;
this.spawnedRound = spawnedRound;
this.lifetimeRounds = lifetimeRounds;
}
public PowerupType getType() {
return type;
}
public String getPositionKey() {
return positionKey;
}
/** True once this powerup has sat unclaimed since {@code spawnedRound} through and including
* {@code currentRound} for at least {@link #lifetimeRounds} rounds. */
public boolean isExpired(int currentRound) {
return currentRound - spawnedRound >= lifetimeRounds;
}
}
@@ -0,0 +1,141 @@
package us.tss3.blockparty.powerup;
import org.bukkit.Material;
import org.bukkit.World;
import org.bukkit.block.Block;
import us.tss3.blockparty.config.ConfigManager;
import us.tss3.blockparty.floor.FloorManager;
import java.util.ArrayList;
import java.util.LinkedHashMap;
import java.util.List;
import java.util.Map;
import java.util.Random;
/** Owns the powerups currently sitting on a single arena's floor: spawning new ones each round,
* ageing out unclaimed ones, and rendering/clearing their markers in the world. A powerup's
* marker is a non-solid block floating directly above its floor cell (the player's foot space
* when standing there) rather than replacing the floor block itself, so it never removes a
* player's footing and never has to be reconciled with the floor's own color each round.
* Claiming (i.e. what happens when a player actually gets one) is handled by the caller (see
* Arena) since it needs to apply effects and record stats. */
public class PowerupManager {
private final ConfigManager configManager;
private final FloorManager floorManager;
/** marker position key ("x,y,z", one block above the floor cell) -> the powerup there */
private final Map<String, ActivePowerup> active = new LinkedHashMap<>();
public PowerupManager(ConfigManager configManager, FloorManager floorManager) {
this.configManager = configManager;
this.floorManager = floorManager;
}
public boolean hasActiveAt(String markerKey) {
return active.containsKey(markerKey);
}
/** Removes and returns the powerup at a marker position (e.g. a player walked over or
* clicked it), or null if nothing is there. Does not touch the world block - the caller is
* expected to clear it via {@link #clearMarkerBlock}. */
public ActivePowerup claim(String markerKey) {
return active.remove(markerKey);
}
/** Ages out any powerup that's sat unclaimed too long, clearing its marker. */
public void expireOld(World world, int currentRound) {
List<String> expired = new ArrayList<>();
for (Map.Entry<String, ActivePowerup> entry : active.entrySet()) {
if (entry.getValue().isExpired(currentRound)) {
expired.add(entry.getKey());
}
}
for (String key : expired) {
active.remove(key);
clearMarkerBlock(world, key);
}
}
/** Rolls whether a new powerup should appear this round and, if so, places one above a
* random free floor cell. No-op while powerups are disabled or the floor has no free cells. */
public void maybeSpawn(World world, Random random, int currentRound) {
if (!configManager.isPowerupsEnabled() || world == null) {
return;
}
if (active.size() >= configManager.getPowerupMaxActive()) {
return;
}
if (random.nextDouble() >= configManager.getPowerupSpawnChance()) {
return;
}
List<String> floorKeys = floorManager.layoutKeys();
if (floorKeys.isEmpty()) {
return;
}
String floorKey = floorKeys.get(random.nextInt(floorKeys.size()));
String markerKey = aboveKey(floorKey);
if (markerKey == null || active.containsKey(markerKey)) {
return;
}
PowerupType[] types = PowerupType.values();
PowerupType type = types[random.nextInt(types.length)];
int min = Math.min(configManager.getPowerupMinLifetimeRounds(), configManager.getPowerupMaxLifetimeRounds());
int max = Math.max(configManager.getPowerupMinLifetimeRounds(), configManager.getPowerupMaxLifetimeRounds());
int lifetime = min + (max > min ? random.nextInt(max - min + 1) : 0);
active.put(markerKey, new ActivePowerup(type, markerKey, currentRound, lifetime));
placeMarkerBlock(world, markerKey, markerMaterial(type));
}
/** (Re-)places every currently active powerup's marker block in the world. Only strictly
* needed for freshly-spawned ones (the floor restore each round never touches the marker's
* above-floor position), but cheap and safe to call for all of them defensively. */
public void applyMarkers(World world) {
for (ActivePowerup powerup : active.values()) {
placeMarkerBlock(world, powerup.getPositionKey(), markerMaterial(powerup.getType()));
}
}
/** Clears every active powerup's marker - used when a match ends/resets so no stray blocks
* are left floating over the arena. */
public void clearAll(World world) {
for (String key : new ArrayList<>(active.keySet())) {
clearMarkerBlock(world, key);
}
active.clear();
}
public Material markerMaterial(PowerupType type) {
return configManager.getPowerupMarkerMaterial(type == PowerupType.SUPER_SPEED ? "super-speed" : "air-blast");
}
private void placeMarkerBlock(World world, String key, Material material) {
Block block = blockAt(world, key);
if (block != null && block.getType() != material) {
block.setType(material, false);
}
}
public void clearMarkerBlock(World world, String key) {
Block block = blockAt(world, key);
if (block != null) {
block.setType(Material.AIR, false);
}
}
private Block blockAt(World world, String key) {
if (world == null || key == null) {
return null;
}
String[] parts = key.split(",");
return world.getBlockAt(Integer.parseInt(parts[0]), Integer.parseInt(parts[1]), Integer.parseInt(parts[2]));
}
private String aboveKey(String floorKey) {
String[] parts = floorKey.split(",");
if (parts.length != 3) {
return null;
}
int y = Integer.parseInt(parts[1]);
return parts[0] + "," + (y + 1) + "," + parts[2];
}
}
@@ -0,0 +1,17 @@
package us.tss3.blockparty.powerup;
/** The kinds of temporary abilities that can spawn on the floor for players to claim. */
public enum PowerupType {
SUPER_SPEED("Super Speed"),
AIR_BLAST("Air Blast");
private final String displayName;
PowerupType(String displayName) {
this.displayName = displayName;
}
public String displayName() {
return displayName;
}
}
+35
View File
@@ -71,6 +71,41 @@ floor:
# (from /bp generate) is never modified by shuffling.
shuffle-each-round: true
# Controls how rare the target color's blocks are on the floor, so the board gets harder
# to read as a match goes on instead of always averaging ~1/(number of colors) of the floor.
# Only takes effect while shuffle-each-round is true.
# Fraction (0-1) of the floor that is the target color on round 1.
starting-target-fraction: 0.5
# Fraction (0-1) of the floor that is the target color on the hardest rounds - never goes
# below this, so there's always at least a sliver of the floor players can actually stand on.
minimum-target-fraction: 0.06
# How much the target color's share of the floor shrinks per round after the first.
target-fraction-reduction-per-round: 0.04
powerups:
# Temporary abilities that spawn on the floor for players to claim by walking over them or
# left-clicking them. Unclaimed powerups disappear after min/max-lifetime-rounds.
enabled: true
# Chance, rolled once per round, that a new powerup spawns somewhere on the floor.
spawn-chance-per-round: 0.35
# Cap on how many unclaimed powerups can be sitting on the floor at once.
max-active: 1
min-lifetime-rounds: 2
max-lifetime-rounds: 3
types:
super-speed:
# Block used to mark a Super Speed powerup's spot on the floor.
marker-block: TORCH
duration-seconds: 8
# Potion amplifier: 0 = Speed I, 1 = Speed II, etc.
amplifier: 1
air-blast:
# Block used to mark an Air Blast powerup's spot on the floor.
marker-block: END_ROD
# Radius, in blocks, that nearby players get pushed within when this is claimed.
radius: 4.0
strength: 1.4
# example/default timings used when creating a brand-new arena via /blockparty create
defaults:
countdown-duration: 15
+6
View File
@@ -34,6 +34,7 @@ admin:
player:
joined: "<green>You joined arena '%arena%'.</green>"
spectating: "<green>You are now spectating arena '%arena%'.</green>"
left: "<yellow>You left the arena.</yellow>"
musicvote-code: "<green>Your music vote code: <bold>%code%</bold></green> <gray>(valid %minutes% minutes) - enter it on the jukebox voting page to pick the next track.</gray>"
@@ -52,6 +53,11 @@ game:
win-title: "<gold><bold>%player% wins!</bold></gold>"
no-winner: "<gray>The match ended with no winner.</gray>"
powerup:
spawned: "<aqua>A %type% powerup appeared on the floor!</aqua>"
claimed-self: "<green>You claimed <bold>%type%</bold>!</green>"
claimed-other: "<yellow>%player% claimed a %type% powerup!</yellow>"
scoreboard:
title: "<gold><bold>BlockParty</bold></gold>"
lines:
@@ -0,0 +1,29 @@
package us.tss3.blockparty.logic;
import org.junit.jupiter.api.Test;
import static org.junit.jupiter.api.Assertions.assertEquals;
class TargetDensityCalculatorTest {
@Test
void firstRoundUsesStartingFraction() {
assertEquals(0.5, TargetDensityCalculator.fractionForRound(0.5, 0.05, 0.1, 1));
}
@Test
void reducesEachRound() {
assertEquals(0.4, TargetDensityCalculator.fractionForRound(0.5, 0.05, 0.1, 2), 1e-9);
assertEquals(0.3, TargetDensityCalculator.fractionForRound(0.5, 0.05, 0.1, 3), 1e-9);
}
@Test
void clampsAtMinimum() {
assertEquals(0.05, TargetDensityCalculator.fractionForRound(0.5, 0.05, 0.1, 20), 1e-9);
}
@Test
void roundNumberBelowOneTreatedAsOne() {
assertEquals(0.5, TargetDensityCalculator.fractionForRound(0.5, 0.05, 0.1, 0));
}
}