feat: add stature potions and tiny-player launchers
Release / release (push) Failing after 15s
CI / build (push) Successful in 3m6s

This commit is contained in:
dmg
2026-09-04 11:28:24 -04:00
commit 7bccc727b1
37 changed files with 1490 additions and 0 deletions
@@ -0,0 +1,40 @@
package games.dmg.spigotheights;
import java.util.function.IntUnaryOperator;
public final class HeightMath {
private HeightMath() {
}
public static double randomScale(HeightSettings settings, IntUnaryOperator randomIndex) {
int outcomes = settings.randomStepCount() + 1;
int index = randomIndex.applyAsInt(outcomes);
if (index < 0 || index >= outcomes) {
throw new IllegalArgumentException("random index is outside the requested bound");
}
return normalize(settings.minimum() + index * settings.adjustmentStep());
}
public static double grow(double current, HeightSettings settings) {
return clamp(current + settings.adjustmentStep(), settings);
}
public static double shrink(double current, HeightSettings settings) {
return clamp(current - settings.adjustmentStep(), settings);
}
public static double safeStoredScale(Double stored, HeightSettings settings) {
if (stored == null || !Double.isFinite(stored)) {
return clamp(1.0, settings);
}
return clamp(stored, settings);
}
public static double clamp(double value, HeightSettings settings) {
return normalize(Math.max(settings.minimum(), Math.min(settings.maximum(), value)));
}
private static double normalize(double value) {
return Math.rint(value * 1_000_000.0) / 1_000_000.0;
}
}
@@ -0,0 +1,40 @@
package games.dmg.spigotheights;
public record HeightSettings(
double minimum,
double maximum,
double adjustmentStep,
double launcherThreshold,
double launcherSpeed,
int launcherCooldownTicks) {
public HeightSettings {
requirePositiveFinite("height.minimum", minimum);
requirePositiveFinite("height.maximum", maximum);
requirePositiveFinite("height.adjustment-step", adjustmentStep);
requirePositiveFinite("launcher.maximum-player-scale-exclusive", launcherThreshold);
requirePositiveFinite("launcher.speed", launcherSpeed);
if (minimum > maximum) {
throw new IllegalArgumentException("height.minimum must not exceed height.maximum");
}
if (minimum < 0.0625 || maximum > 16.0) {
throw new IllegalArgumentException("height range must stay within Minecraft's 0.0625 to 16.0 scale range");
}
if (launcherThreshold < minimum || launcherThreshold > maximum) {
throw new IllegalArgumentException("launcher threshold must be within the height range");
}
if (launcherCooldownTicks < 0) {
throw new IllegalArgumentException("launcher.cooldown-ticks must not be negative");
}
}
public int randomStepCount() {
return (int) Math.floor((maximum - minimum) / adjustmentStep + 1.0e-9);
}
private static void requirePositiveFinite(String key, double value) {
if (!Double.isFinite(value) || value <= 0.0) {
throw new IllegalArgumentException(key + " must be a finite positive number");
}
}
}
@@ -0,0 +1,43 @@
package games.dmg.spigotheights;
import java.io.File;
import java.io.IOException;
import java.nio.charset.StandardCharsets;
import java.nio.file.AtomicMoveNotSupportedException;
import java.nio.file.Files;
import java.nio.file.Path;
import java.nio.file.StandardCopyOption;
import java.util.UUID;
import org.bukkit.configuration.file.YamlConfiguration;
public final class HeightStore {
private final Path statePath;
private final YamlConfiguration state;
public HeightStore(File dataFolder) {
statePath = dataFolder.toPath().resolve("state.yml");
state = YamlConfiguration.loadConfiguration(statePath.toFile());
}
public synchronized Double find(UUID playerId) {
String path = path(playerId);
return state.contains(path) ? state.getDouble(path) : null;
}
public synchronized void save(UUID playerId, double scale) throws IOException {
state.set(path(playerId), scale);
Files.createDirectories(statePath.getParent());
Path temporary = statePath.resolveSibling("state.yml.tmp");
Files.writeString(temporary, state.saveToString(), StandardCharsets.UTF_8);
try {
Files.move(temporary, statePath, StandardCopyOption.ATOMIC_MOVE,
StandardCopyOption.REPLACE_EXISTING);
} catch (AtomicMoveNotSupportedException exception) {
Files.move(temporary, statePath, StandardCopyOption.REPLACE_EXISTING);
}
}
private static String path(UUID playerId) {
return "players." + playerId + ".scale";
}
}
@@ -0,0 +1,11 @@
package games.dmg.spigotheights;
public record LaunchVector(double x, double y, double z) {
public static LaunchVector fromDirection(int x, int y, int z, double speed) {
double length = Math.sqrt((double) x * x + (double) y * y + (double) z * z);
if (length == 0.0) {
throw new IllegalArgumentException("launch direction must not be stationary");
}
return new LaunchVector(x / length * speed, y / length * speed, z / length * speed);
}
}
@@ -0,0 +1,18 @@
package games.dmg.spigotheights;
public final class LauncherPolicy {
private LauncherPolicy() {
}
public static boolean isSmallEnough(double scale, double exclusiveThreshold) {
return scale < exclusiveThreshold;
}
public static boolean cooldownExpired(Long previousTick, long currentTick, int cooldownTicks) {
return previousTick == null || currentTick - previousTick >= cooldownTicks;
}
public static boolean isSafeExit(boolean empty) {
return empty;
}
}
@@ -0,0 +1,106 @@
package games.dmg.spigotheights;
import net.kyori.adventure.text.Component;
import net.kyori.adventure.text.format.NamedTextColor;
import org.bukkit.Color;
import org.bukkit.Material;
import org.bukkit.NamespacedKey;
import org.bukkit.inventory.ItemStack;
import org.bukkit.inventory.RecipeChoice;
import org.bukkit.inventory.ShapedRecipe;
import org.bukkit.inventory.meta.PotionMeta;
import org.bukkit.persistence.PersistentDataType;
import org.bukkit.plugin.java.JavaPlugin;
import org.bukkit.potion.PotionType;
public final class PotionRecipes {
static final String[] SHIFTING_SHAPE = {"ACA", "AWA", "ACA"};
static final String[] GROWTH_SHAPE = {"GAG", "ASA", "GRG"};
static final String[] DIMINUTION_SHAPE = {"GAG", "ASA", "GFG"};
private final JavaPlugin plugin;
private final NamespacedKey potionKindKey;
public PotionRecipes(JavaPlugin plugin) {
this.plugin = plugin;
potionKindKey = new NamespacedKey(plugin, "stature_potion");
}
public void register() {
registerShifting();
registerGrowth();
registerDiminution();
}
public ItemStack create(StaturePotion kind) {
ItemStack item = new ItemStack(Material.POTION);
PotionMeta meta = (PotionMeta) item.getItemMeta();
meta.displayName(Component.text(kind.displayName(), NamedTextColor.LIGHT_PURPLE));
meta.getPersistentDataContainer().set(potionKindKey, PersistentDataType.STRING, kind.name());
meta.setColor(color(kind));
item.setItemMeta(meta);
return item;
}
public StaturePotion identify(ItemStack item) {
if (item == null || item.getType() != Material.POTION || !(item.getItemMeta() instanceof PotionMeta meta)) {
return null;
}
String value = meta.getPersistentDataContainer().get(potionKindKey, PersistentDataType.STRING);
if (value == null) {
return null;
}
try {
return StaturePotion.valueOf(value);
} catch (IllegalArgumentException exception) {
return null;
}
}
public boolean isAwkwardPotion(ItemStack item) {
return item != null && item.getItemMeta() instanceof PotionMeta meta
&& meta.getBasePotionType() == PotionType.AWKWARD;
}
public NamespacedKey shiftingKey() {
return new NamespacedKey(plugin, "shifting_stature");
}
private void registerShifting() {
ShapedRecipe recipe = new ShapedRecipe(shiftingKey(), create(StaturePotion.SHIFTING));
recipe.shape(SHIFTING_SHAPE);
recipe.setIngredient('A', Material.AMETHYST_SHARD);
recipe.setIngredient('C', Material.CHORUS_FRUIT);
recipe.setIngredient('W', Material.POTION);
plugin.getServer().addRecipe(recipe);
}
private void registerGrowth() {
ShapedRecipe recipe = new ShapedRecipe(new NamespacedKey(plugin, "growth"), create(StaturePotion.GROWTH));
recipe.shape(GROWTH_SHAPE);
recipe.setIngredient('G', Material.GOLD_INGOT);
recipe.setIngredient('A', Material.AMETHYST_SHARD);
recipe.setIngredient('S', new RecipeChoice.ExactChoice(create(StaturePotion.SHIFTING)));
recipe.setIngredient('R', Material.RABBIT_FOOT);
plugin.getServer().addRecipe(recipe);
}
private void registerDiminution() {
ShapedRecipe recipe = new ShapedRecipe(new NamespacedKey(plugin, "diminution"),
create(StaturePotion.DIMINUTION));
recipe.shape(DIMINUTION_SHAPE);
recipe.setIngredient('G', Material.GOLD_INGOT);
recipe.setIngredient('A', Material.AMETHYST_SHARD);
recipe.setIngredient('S', new RecipeChoice.ExactChoice(create(StaturePotion.SHIFTING)));
recipe.setIngredient('F', Material.FERMENTED_SPIDER_EYE);
plugin.getServer().addRecipe(recipe);
}
private static Color color(StaturePotion kind) {
return switch (kind) {
case SHIFTING -> Color.PURPLE;
case GROWTH -> Color.LIME;
case DIMINUTION -> Color.FUCHSIA;
};
}
}
@@ -0,0 +1,37 @@
package games.dmg.spigotheights;
import org.bukkit.configuration.file.FileConfiguration;
import org.bukkit.plugin.java.JavaPlugin;
public final class SpigotHeightsPlugin extends JavaPlugin {
@Override
public void onEnable() {
saveDefaultConfig();
HeightSettings settings;
try {
settings = loadSettings(getConfig());
} catch (IllegalArgumentException exception) {
getLogger().severe("Invalid configuration: " + exception.getMessage());
getServer().getPluginManager().disablePlugin(this);
return;
}
HeightStore store = new HeightStore(getDataFolder());
PotionRecipes potions = new PotionRecipes(this);
potions.register();
getServer().getPluginManager().registerEvents(
new StatureListener(this, settings, store, potions), this);
getServer().getPluginManager().registerEvents(new TinyPlayerLauncher(settings), this);
getLogger().info("Spigot Heights enabled.");
}
static HeightSettings loadSettings(FileConfiguration config) {
return new HeightSettings(
config.getDouble("height.minimum"),
config.getDouble("height.maximum"),
config.getDouble("height.adjustment-step"),
config.getDouble("launcher.maximum-player-scale-exclusive"),
config.getDouble("launcher.speed"),
config.getInt("launcher.cooldown-ticks"));
}
}
@@ -0,0 +1,97 @@
package games.dmg.spigotheights;
import java.io.IOException;
import java.util.concurrent.ThreadLocalRandom;
import org.bukkit.attribute.Attribute;
import org.bukkit.attribute.AttributeInstance;
import org.bukkit.entity.Player;
import org.bukkit.event.EventHandler;
import org.bukkit.event.EventPriority;
import org.bukkit.event.Listener;
import org.bukkit.event.inventory.PrepareItemCraftEvent;
import org.bukkit.event.player.PlayerItemConsumeEvent;
import org.bukkit.event.player.PlayerJoinEvent;
import org.bukkit.event.player.PlayerRespawnEvent;
import org.bukkit.inventory.ItemStack;
import org.bukkit.inventory.Recipe;
import org.bukkit.Keyed;
import org.bukkit.plugin.java.JavaPlugin;
public final class StatureListener implements Listener {
private final JavaPlugin plugin;
private final HeightSettings settings;
private final HeightStore store;
private final PotionRecipes potions;
public StatureListener(JavaPlugin plugin, HeightSettings settings, HeightStore store, PotionRecipes potions) {
this.plugin = plugin;
this.settings = settings;
this.store = store;
this.potions = potions;
}
@EventHandler
public void onCraftPrepare(PrepareItemCraftEvent event) {
Recipe recipe = event.getRecipe();
if (!(recipe instanceof Keyed keyed) || !keyed.getKey().equals(potions.shiftingKey())) {
return;
}
ItemStack[] matrix = event.getInventory().getMatrix();
if (matrix.length < 5 || !potions.isAwkwardPotion(matrix[4])) {
event.getInventory().setResult(null);
}
}
@EventHandler(priority = EventPriority.MONITOR, ignoreCancelled = true)
public void onConsume(PlayerItemConsumeEvent event) {
StaturePotion kind = potions.identify(event.getItem());
if (kind == null) {
return;
}
Player player = event.getPlayer();
double current = currentScale(player);
double scale = switch (kind) {
case SHIFTING -> HeightMath.randomScale(settings,
bound -> ThreadLocalRandom.current().nextInt(bound));
case GROWTH -> HeightMath.grow(current, settings);
case DIMINUTION -> HeightMath.shrink(current, settings);
};
applyAndSave(player, scale);
player.sendMessage("Your scale is now " + scale + ".");
}
@EventHandler
public void onJoin(PlayerJoinEvent event) {
apply(event.getPlayer(), HeightMath.safeStoredScale(store.find(event.getPlayer().getUniqueId()), settings));
}
@EventHandler
public void onRespawn(PlayerRespawnEvent event) {
plugin.getServer().getScheduler().runTask(plugin, () -> {
Player player = event.getPlayer();
apply(player, HeightMath.safeStoredScale(store.find(player.getUniqueId()), settings));
});
}
private void applyAndSave(Player player, double scale) {
apply(player, scale);
try {
store.save(player.getUniqueId(), scale);
} catch (IOException exception) {
plugin.getLogger().severe("Could not save scale for " + player.getUniqueId() + ": "
+ exception.getMessage());
}
}
private static void apply(Player player, double scale) {
AttributeInstance attribute = player.getAttribute(Attribute.SCALE);
if (attribute != null) {
attribute.setBaseValue(scale);
}
}
private static double currentScale(Player player) {
AttributeInstance attribute = player.getAttribute(Attribute.SCALE);
return attribute == null ? 1.0 : attribute.getBaseValue();
}
}
@@ -0,0 +1,17 @@
package games.dmg.spigotheights;
public enum StaturePotion {
SHIFTING("Potion of Shifting Stature"),
GROWTH("Potion of Growth"),
DIMINUTION("Potion of Diminution");
private final String displayName;
StaturePotion(String displayName) {
this.displayName = displayName;
}
public String displayName() {
return displayName;
}
}
@@ -0,0 +1,82 @@
package games.dmg.spigotheights;
import java.util.HashMap;
import java.util.Map;
import java.util.UUID;
import org.bukkit.Bukkit;
import org.bukkit.Location;
import org.bukkit.Material;
import org.bukkit.attribute.Attribute;
import org.bukkit.attribute.AttributeInstance;
import org.bukkit.block.Block;
import org.bukkit.block.BlockFace;
import org.bukkit.block.data.Directional;
import org.bukkit.block.data.type.Hopper;
import org.bukkit.entity.Player;
import org.bukkit.event.EventHandler;
import org.bukkit.event.Listener;
import org.bukkit.event.player.PlayerMoveEvent;
import org.bukkit.util.Vector;
public final class TinyPlayerLauncher implements Listener {
private final HeightSettings settings;
private final Map<UUID, Long> lastLaunchTicks = new HashMap<>();
public TinyPlayerLauncher(HeightSettings settings) {
this.settings = settings;
}
@EventHandler(ignoreCancelled = true)
public void onMove(PlayerMoveEvent event) {
Location destination = event.getTo();
if (destination == null || sameBlock(event.getFrom(), destination)) {
return;
}
Player player = event.getPlayer();
if (!LauncherPolicy.isSmallEnough(scale(player), settings.launcherThreshold())) {
return;
}
long tick = Bukkit.getCurrentTick();
Long lastTick = lastLaunchTicks.get(player.getUniqueId());
if (!LauncherPolicy.cooldownExpired(lastTick, tick, settings.launcherCooldownTicks())) {
return;
}
Block hopperBlock = destination.clone().subtract(0.0, 0.1, 0.0).getBlock();
if (!(hopperBlock.getBlockData() instanceof Hopper hopper)) {
return;
}
Block dispenser = hopperBlock.getRelative(hopper.getFacing());
if (dispenser.getType() != Material.DISPENSER
|| !(dispenser.getBlockData() instanceof Directional directional)) {
return;
}
BlockFace facing = directional.getFacing();
Block exit = dispenser.getRelative(facing);
if (!LauncherPolicy.isSafeExit(exit.isEmpty())) {
return;
}
Location exitLocation = exit.getLocation().add(0.5, 0.1, 0.5);
exitLocation.setYaw(player.getLocation().getYaw());
exitLocation.setPitch(player.getLocation().getPitch());
if (!player.teleport(exitLocation)) {
return;
}
Vector direction = facing.getDirection();
LaunchVector launch = LaunchVector.fromDirection(
direction.getBlockX(), direction.getBlockY(), direction.getBlockZ(), settings.launcherSpeed());
player.setVelocity(new Vector(launch.x(), launch.y(), launch.z()));
lastLaunchTicks.put(player.getUniqueId(), tick);
}
private static boolean sameBlock(Location first, Location second) {
return first.getWorld().equals(second.getWorld())
&& first.getBlockX() == second.getBlockX()
&& first.getBlockY() == second.getBlockY()
&& first.getBlockZ() == second.getBlockZ();
}
private static double scale(Player player) {
AttributeInstance attribute = player.getAttribute(Attribute.SCALE);
return attribute == null ? 1.0 : attribute.getValue();
}
}