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
@@ -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];
}
}