Compare commits

...
2 Commits
Author SHA1 Message Date
Michael BurgessandClaude Sonnet 5 61c09a5b4d Announce powerup spawns to arena participants
Build / build (push) Successful in 1m18s
maybeSpawn() now returns the spawned type (or null) so Arena can broadcast
the existing but previously-unused powerup.spawned message plus a new
powerup-spawn sound cue whenever one actually appears on the floor.

Co-Authored-By: Claude Sonnet 5 <[email protected]>
2026-08-08 17:19:38 -04:00
Michael BurgessandClaude Sonnet 5 039cfab92d Make powerups claim-then-use, fix right-click, disable player collision
- Merge missing config.yml keys from the bundled defaults into an existing
  file on load, so upgrading over an old config no longer silently drops
  newer sections (this is what caused powerups to never spawn).
- Claiming a powerup now gives an inventory item instead of applying its
  effect instantly; right-click the item later to activate it.
- Switch the held item's material off placeable blocks (TORCH/END_ROD) to
  non-block items (SUGAR/FEATHER, configurable) - block items only fire a
  right-click event when aimed at a block, which made the powerup unusable
  unless the player was looking at the floor.
- Disable player collision by default (gameplay.disable-player-collision)
  so participants stop shoving each other around on a shrinking floor.

Co-Authored-By: Claude Sonnet 5 <[email protected]>
2026-08-08 17:15:36 -04:00
7 changed files with 215 additions and 15 deletions
@@ -26,6 +26,7 @@ import us.tss3.blockparty.logic.TargetColorSelector;
import us.tss3.blockparty.logic.TargetDensityCalculator; import us.tss3.blockparty.logic.TargetDensityCalculator;
import us.tss3.blockparty.model.PlayerState; import us.tss3.blockparty.model.PlayerState;
import us.tss3.blockparty.powerup.ActivePowerup; import us.tss3.blockparty.powerup.ActivePowerup;
import us.tss3.blockparty.powerup.PowerupItems;
import us.tss3.blockparty.powerup.PowerupManager; import us.tss3.blockparty.powerup.PowerupManager;
import us.tss3.blockparty.powerup.PowerupType; import us.tss3.blockparty.powerup.PowerupType;
import us.tss3.blockparty.session.PlayerSession; import us.tss3.blockparty.session.PlayerSession;
@@ -186,6 +187,7 @@ public class Arena {
player.setHealth(20.0); player.setHealth(20.0);
player.setFoodLevel(20); player.setFoodLevel(20);
player.setFireTicks(0); player.setFireTicks(0);
player.setCollidable(!plugin.getConfigManager().isPlayerCollisionDisabled());
Location target = config.getSpectator() != null ? config.getSpectator() : config.getLobby(); Location target = config.getSpectator() != null ? config.getSpectator() : config.getLobby();
if (target != null) { if (target != null) {
player.teleport(target); player.teleport(target);
@@ -201,6 +203,7 @@ public class Arena {
player.setHealth(20.0); player.setHealth(20.0);
player.setFoodLevel(20); player.setFoodLevel(20);
player.setFireTicks(0); player.setFireTicks(0);
player.setCollidable(!plugin.getConfigManager().isPlayerCollisionDisabled());
if (config.getLobby() != null) { if (config.getLobby() != null) {
player.teleport(config.getLobby()); player.teleport(config.getLobby());
} }
@@ -219,6 +222,7 @@ public class Arena {
} }
if (player != null) { if (player != null) {
plugin.getScoreboardManager().detach(player); plugin.getScoreboardManager().detach(player);
player.setCollidable(true);
} }
checkCountdownCancel(); checkCountdownCancel();
checkForWinner(); checkForWinner();
@@ -344,8 +348,11 @@ public class Arena {
World world = config.getWorld(); World world = config.getWorld();
if (world != null) { if (world != null) {
powerupManager.expireOld(world, round + 1); powerupManager.expireOld(world, round + 1);
powerupManager.maybeSpawn(world, random, round + 1); PowerupType spawned = powerupManager.maybeSpawn(world, random, round + 1);
powerupManager.applyMarkers(world); powerupManager.applyMarkers(world);
if (spawned != null) {
announcePowerupSpawn(spawned);
}
} }
onComplete.run(); onComplete.run();
}); });
@@ -503,8 +510,10 @@ public class Arena {
return; return;
} }
powerupManager.clearMarkerBlock(config.getWorld(), key); powerupManager.clearMarkerBlock(config.getWorld(), key);
applyPowerupEffect(player, powerup.getType()); ItemStack item = PowerupItems.create(plugin, powerup.getType(), powerupManager.itemMaterial(powerup.getType()));
plugin.getStatsManager().recordPowerupUse(player.getUniqueId()); for (ItemStack leftover : player.getInventory().addItem(item).values()) {
player.getWorld().dropItemNaturally(player.getLocation(), leftover);
}
Map<String, String> ph = Map.of("type", powerup.getType().displayName(), "player", player.getName()); Map<String, String> ph = Map.of("type", powerup.getType().displayName(), "player", player.getName());
player.sendMessage(plugin.getMessages().get("powerup.claimed-self", ph)); player.sendMessage(plugin.getMessages().get("powerup.claimed-self", ph));
for (UUID uuid : players) { for (UUID uuid : players) {
@@ -518,6 +527,29 @@ public class Arena {
} }
} }
/** Activates a powerup item's effect on right-click use (see PowerupListener) - the deferred
* half of claim-then-use. Returns false (and consumes nothing) if the player isn't currently
* an active participant, so a stale item from a previous match can't be used out of context. */
public boolean usePowerupItem(Player player, PowerupType type) {
if (!players.contains(player.getUniqueId()) || getPhase() != ArenaPhase.RUNNING) {
return false;
}
applyPowerupEffect(player, type);
plugin.getStatsManager().recordPowerupUse(player.getUniqueId());
Map<String, String> ph = Map.of("type", type.displayName(), "player", player.getName());
player.sendMessage(plugin.getMessages().get("powerup.used-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.used-other", ph));
}
}
return true;
}
private void applyPowerupEffect(Player player, PowerupType type) { private void applyPowerupEffect(Player player, PowerupType type) {
switch (type) { switch (type) {
case SUPER_SPEED -> { case SUPER_SPEED -> {
@@ -556,6 +588,19 @@ public class Arena {
} }
} }
/** Announces a freshly-spawned powerup to everyone currently in the arena, so players know
* to go look for it instead of relying on spotting the marker block by chance. */
private void announcePowerupSpawn(PowerupType type) {
Map<String, String> ph = Map.of("type", type.displayName());
for (UUID uuid : allParticipants()) {
Player p = plugin.getServer().getPlayer(uuid);
if (p != null) {
p.sendMessage(plugin.getMessages().get("powerup.spawned", ph));
}
}
playToParticipants("powerup-spawn");
}
/** Plays a configured sound directly to every current player/spectator (each at their own /** 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 * 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. */ * playSound falls off with distance, so anyone far from that one spot would hear nothing. */
@@ -1,9 +1,14 @@
package us.tss3.blockparty.config; package us.tss3.blockparty.config;
import org.bukkit.configuration.file.FileConfiguration; import org.bukkit.configuration.file.FileConfiguration;
import org.bukkit.configuration.file.YamlConfiguration;
import org.bukkit.plugin.java.JavaPlugin; import org.bukkit.plugin.java.JavaPlugin;
import java.io.InputStream;
import java.io.InputStreamReader;
import java.nio.charset.StandardCharsets;
import java.util.List; import java.util.List;
import java.util.logging.Level;
/** Wraps global config.yml access: UI toggles, sounds, rewards, default floor palette. */ /** Wraps global config.yml access: UI toggles, sounds, rewards, default floor palette. */
public class ConfigManager { public class ConfigManager {
@@ -15,10 +20,27 @@ public class ConfigManager {
this.plugin = plugin; this.plugin = plugin;
} }
/** Loads config.yml, then merges in any keys the bundled jar defines that the on-disk file
* is missing (e.g. a config.yml saved before a feature like powerups was added) and persists
* the merge to disk. Bukkit's saveDefaultConfig() only writes the file when it doesn't exist
* yet, so without this, upgrading the plugin on top of an existing config.yml would silently
* leave newer sections absent - every getter still falls back to its own hardcoded default,
* but the feature looks "on" from the docs while actually running on stale settings. */
public void load() { public void load() {
plugin.saveDefaultConfig(); plugin.saveDefaultConfig();
plugin.reloadConfig(); plugin.reloadConfig();
this.config = plugin.getConfig(); this.config = plugin.getConfig();
try (InputStream defaultStream = plugin.getResource("config.yml")) {
if (defaultStream != null) {
YamlConfiguration defaults = YamlConfiguration.loadConfiguration(
new InputStreamReader(defaultStream, StandardCharsets.UTF_8));
config.setDefaults(defaults);
config.options().copyDefaults(true);
plugin.saveConfig();
}
} catch (Exception ex) {
plugin.getLogger().log(Level.WARNING, "Failed to merge default config.yml keys", ex);
}
} }
public FileConfiguration raw() { public FileConfiguration raw() {
@@ -113,6 +135,12 @@ public class ConfigManager {
return config.getDouble("floor.target-fraction-reduction-per-round", 0.04); return config.getDouble("floor.target-fraction-reduction-per-round", 0.04);
} }
/** If true (default), participants and spectators can't push each other around while in an
* arena - handy since BlockParty rounds tend to bunch everyone onto a shrinking floor. */
public boolean isPlayerCollisionDisabled() {
return config.getBoolean("gameplay.disable-player-collision", true);
}
public boolean isPowerupsEnabled() { public boolean isPowerupsEnabled() {
return config.getBoolean("powerups.enabled", true); return config.getBoolean("powerups.enabled", true);
} }
@@ -143,6 +171,17 @@ public class ConfigManager {
return material != null ? material : org.bukkit.Material.TORCH; return material != null ? material : org.bukkit.Material.TORCH;
} }
/** The item a claimed powerup shows up as in the player's inventory. Deliberately a separate
* setting from the floor marker block: block items (TORCH, END_ROD, etc.) only fire a
* right-click interact event when the player is aiming at a block within reach, so using one
* as the held item made the powerup unusable unless the player happened to be looking at the
* floor. Non-block items fire right-click in any direction. */
public org.bukkit.Material getPowerupItemMaterial(String typeKey, org.bukkit.Material fallback) {
String name = config.getString("powerups.types." + typeKey + ".item-material", fallback.name());
org.bukkit.Material material = org.bukkit.Material.matchMaterial(name);
return material != null ? material : fallback;
}
public int getSuperSpeedDurationSeconds() { public int getSuperSpeedDurationSeconds() {
return config.getInt("powerups.types.super-speed.duration-seconds", 8); return config.getInt("powerups.types.super-speed.duration-seconds", 8);
} }
@@ -6,12 +6,16 @@ import org.bukkit.event.EventPriority;
import org.bukkit.event.Listener; import org.bukkit.event.Listener;
import org.bukkit.event.block.Action; import org.bukkit.event.block.Action;
import org.bukkit.event.player.PlayerInteractEvent; import org.bukkit.event.player.PlayerInteractEvent;
import org.bukkit.inventory.EquipmentSlot;
import org.bukkit.inventory.ItemStack;
import us.tss3.blockparty.BlockPartyPlugin; import us.tss3.blockparty.BlockPartyPlugin;
import us.tss3.blockparty.arena.Arena; import us.tss3.blockparty.powerup.PowerupItems;
import us.tss3.blockparty.powerup.PowerupType;
import us.tss3.blockparty.session.PlayerSession; import us.tss3.blockparty.session.PlayerSession;
/** Lets participants claim a powerup marker by left-clicking it, in addition to just walking /** Lets participants claim a powerup marker off the floor by left-clicking it (in addition to
* over it (see Arena's fall-watcher loop). */ * just walking over it - see Arena's fall-watcher loop), and use a claimed powerup item by
* right-clicking it later, whenever they choose. */
public class PowerupListener implements Listener { public class PowerupListener implements Listener {
private final BlockPartyPlugin plugin; private final BlockPartyPlugin plugin;
@@ -33,4 +37,31 @@ public class PowerupListener implements Listener {
plugin.getArenaManager().get(session.getArenaName()).ifPresent(arena -> plugin.getArenaManager().get(session.getArenaName()).ifPresent(arena ->
arena.claimPowerupAt(player, event.getClickedBlock().getLocation())); arena.claimPowerupAt(player, event.getClickedBlock().getLocation()));
} }
@EventHandler(priority = EventPriority.NORMAL, ignoreCancelled = true)
public void onRightClickUseItem(PlayerInteractEvent event) {
if (event.getHand() != EquipmentSlot.HAND) {
return;
}
if (event.getAction() != Action.RIGHT_CLICK_AIR && event.getAction() != Action.RIGHT_CLICK_BLOCK) {
return;
}
ItemStack item = event.getItem();
PowerupType type = PowerupItems.readType(plugin, item);
if (type == null) {
return;
}
Player player = event.getPlayer();
PlayerSession session = plugin.getSessionManager().get(player.getUniqueId());
if (session == null || session.getRole() != PlayerSession.Role.PLAYER) {
return;
}
boolean used = plugin.getArenaManager().get(session.getArenaName())
.map(arena -> arena.usePowerupItem(player, type))
.orElse(false);
if (used) {
event.setCancelled(true);
item.setAmount(item.getAmount() - 1);
}
}
} }
@@ -0,0 +1,58 @@
package us.tss3.blockparty.powerup;
import org.bukkit.Material;
import org.bukkit.NamespacedKey;
import org.bukkit.inventory.ItemStack;
import org.bukkit.inventory.meta.ItemMeta;
import org.bukkit.persistence.PersistentDataType;
import org.bukkit.plugin.Plugin;
import java.util.List;
/** Builds/reads the inventory item a player receives when they claim a powerup marker off the
* floor. Claiming only hands over this item - the effect itself is applied later, when the
* player right-clicks it (see PowerupListener), rather than instantly on pickup. */
public final class PowerupItems {
private static final String KEY_NAME = "powerup-type";
private PowerupItems() {
}
public static ItemStack create(Plugin plugin, PowerupType type, Material icon) {
ItemStack stack = new ItemStack(icon);
ItemMeta meta = stack.getItemMeta();
if (meta != null) {
meta.setDisplayName("§b" + type.displayName() + " Powerup");
meta.setLore(List.of("§7Right-click to use"));
meta.getPersistentDataContainer().set(key(plugin), PersistentDataType.STRING, type.name());
stack.setItemMeta(meta);
}
return stack;
}
/** The powerup type this item represents, or null if it isn't one of ours (e.g. a plain
* torch the player picked up elsewhere). */
public static PowerupType readType(Plugin plugin, ItemStack stack) {
if (stack == null || stack.getType().isAir()) {
return null;
}
ItemMeta meta = stack.getItemMeta();
if (meta == null) {
return null;
}
String raw = meta.getPersistentDataContainer().get(key(plugin), PersistentDataType.STRING);
if (raw == null) {
return null;
}
try {
return PowerupType.valueOf(raw);
} catch (IllegalArgumentException ex) {
return null;
}
}
private static NamespacedKey key(Plugin plugin) {
return new NamespacedKey(plugin, KEY_NAME);
}
}
@@ -57,25 +57,28 @@ public class PowerupManager {
} }
/** Rolls whether a new powerup should appear this round and, if so, places one above a /** 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. */ * 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) { *
* @return the type that spawned, or null if nothing spawned this call (so the caller can
* decide whether to announce it - see Arena). */
public PowerupType maybeSpawn(World world, Random random, int currentRound) {
if (!configManager.isPowerupsEnabled() || world == null) { if (!configManager.isPowerupsEnabled() || world == null) {
return; return null;
} }
if (active.size() >= configManager.getPowerupMaxActive()) { if (active.size() >= configManager.getPowerupMaxActive()) {
return; return null;
} }
if (random.nextDouble() >= configManager.getPowerupSpawnChance()) { if (random.nextDouble() >= configManager.getPowerupSpawnChance()) {
return; return null;
} }
List<String> floorKeys = floorManager.layoutKeys(); List<String> floorKeys = floorManager.layoutKeys();
if (floorKeys.isEmpty()) { if (floorKeys.isEmpty()) {
return; return null;
} }
String floorKey = floorKeys.get(random.nextInt(floorKeys.size())); String floorKey = floorKeys.get(random.nextInt(floorKeys.size()));
String markerKey = aboveKey(floorKey); String markerKey = aboveKey(floorKey);
if (markerKey == null || active.containsKey(markerKey)) { if (markerKey == null || active.containsKey(markerKey)) {
return; return null;
} }
PowerupType[] types = PowerupType.values(); PowerupType[] types = PowerupType.values();
PowerupType type = types[random.nextInt(types.length)]; PowerupType type = types[random.nextInt(types.length)];
@@ -84,6 +87,7 @@ public class PowerupManager {
int lifetime = min + (max > min ? random.nextInt(max - min + 1) : 0); int lifetime = min + (max > min ? random.nextInt(max - min + 1) : 0);
active.put(markerKey, new ActivePowerup(type, markerKey, currentRound, lifetime)); active.put(markerKey, new ActivePowerup(type, markerKey, currentRound, lifetime));
placeMarkerBlock(world, markerKey, markerMaterial(type)); placeMarkerBlock(world, markerKey, markerMaterial(type));
return type;
} }
/** (Re-)places every currently active powerup's marker block in the world. Only strictly /** (Re-)places every currently active powerup's marker block in the world. Only strictly
@@ -108,6 +112,15 @@ public class PowerupManager {
return configManager.getPowerupMarkerMaterial(type == PowerupType.SUPER_SPEED ? "super-speed" : "air-blast"); return configManager.getPowerupMarkerMaterial(type == PowerupType.SUPER_SPEED ? "super-speed" : "air-blast");
} }
/** The material a claimed powerup uses as its held/inventory item - see
* {@link us.tss3.blockparty.config.ConfigManager#getPowerupItemMaterial} for why this is
* intentionally not the same as the floor marker material. */
public Material itemMaterial(PowerupType type) {
return type == PowerupType.SUPER_SPEED
? configManager.getPowerupItemMaterial("super-speed", Material.SUGAR)
: configManager.getPowerupItemMaterial("air-blast", Material.FEATHER);
}
private void placeMarkerBlock(World world, String key, Material material) { private void placeMarkerBlock(World world, String key, Material material) {
Block block = blockAt(world, key); Block block = blockAt(world, key);
if (block != null && block.getType() != material) { if (block != null && block.getType() != material) {
+12
View File
@@ -20,6 +20,7 @@ sounds:
eliminate: ENTITY_VILLAGER_NO eliminate: ENTITY_VILLAGER_NO
round-complete: BLOCK_NOTE_BLOCK_BELL round-complete: BLOCK_NOTE_BLOCK_BELL
victory: UI_TOAST_CHALLENGE_COMPLETE victory: UI_TOAST_CHALLENGE_COMPLETE
powerup-spawn: ENTITY_PLAYER_LEVELUP
music: music:
# Optional background music for the duration of a match, played on the client's MUSIC # Optional background music for the duration of a match, played on the client's MUSIC
@@ -63,6 +64,11 @@ performance:
# blocks processed per server tick when generating/removing/restoring the floor # blocks processed per server tick when generating/removing/restoring the floor
floor-blocks-per-tick: 200 floor-blocks-per-tick: 200
gameplay:
# If true (default), players/spectators can't push each other around while in an arena -
# rounds tend to bunch everyone onto a shrinking floor, and collision makes that chaotic.
disable-player-collision: true
floor: floor:
# If true (default), the floor's material-to-position layout is randomly reshuffled # If true (default), the floor's material-to-position layout is randomly reshuffled
# (same set of positions and same count of each material, just rearranged) at the start # (same set of positions and same count of each material, just rearranged) at the start
@@ -96,12 +102,18 @@ powerups:
super-speed: super-speed:
# Block used to mark a Super Speed powerup's spot on the floor. # Block used to mark a Super Speed powerup's spot on the floor.
marker-block: TORCH marker-block: TORCH
# Item the player holds after claiming it, used later via right-click. Must NOT be a
# placeable block - block items only fire a right-click event when aimed at a nearby
# block, so a claimed powerup would be unusable except while looking at the floor.
item-material: SUGAR
duration-seconds: 8 duration-seconds: 8
# Potion amplifier: 0 = Speed I, 1 = Speed II, etc. # Potion amplifier: 0 = Speed I, 1 = Speed II, etc.
amplifier: 1 amplifier: 1
air-blast: air-blast:
# Block used to mark an Air Blast powerup's spot on the floor. # Block used to mark an Air Blast powerup's spot on the floor.
marker-block: END_ROD marker-block: END_ROD
# See super-speed.item-material above - must not be a placeable block.
item-material: FEATHER
# Radius, in blocks, that nearby players get pushed within when this is claimed. # Radius, in blocks, that nearby players get pushed within when this is claimed.
radius: 4.0 radius: 4.0
strength: 1.4 strength: 1.4
+4 -2
View File
@@ -55,8 +55,10 @@ game:
powerup: powerup:
spawned: "<aqua>A %type% powerup appeared on the floor!</aqua>" spawned: "<aqua>A %type% powerup appeared on the floor!</aqua>"
claimed-self: "<green>You claimed <bold>%type%</bold>!</green>" claimed-self: "<green>You picked up a <bold>%type%</bold> powerup! Right-click it to use it.</green>"
claimed-other: "<yellow>%player% claimed a %type% powerup!</yellow>" claimed-other: "<yellow>%player% picked up a %type% powerup!</yellow>"
used-self: "<green>You used <bold>%type%</bold>!</green>"
used-other: "<yellow>%player% used a %type% powerup!</yellow>"
scoreboard: scoreboard:
title: "<gold><bold>BlockParty</bold></gold>" title: "<gold><bold>BlockParty</bold></gold>"