Scale round difficulty over time and add claimable powerups
Build / build (push) Successful in 1m17s
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:
co-authored by
Claude Sonnet 5
parent
8049aa04af
commit
4c4f5a1215
@@ -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);
|
||||
|
||||
@@ -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 > 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;
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
|
||||
@@ -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));
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user