Give the rebuildable reward designs unique touches

Rewards whose look the basic designs can build now carry a touch the builder
cannot make: bursts on jumping, landing or sneaking, footprints, a line to
nearby players with the same reward, a December extra, aimed enchanting
glyphs, a heartbeat, a jump-beat wing or an orbiting shape. BodyMotion tracks
the jump, land and sneak events the touches react to.
This commit is contained in:
2026-10-26 14:34:50 +01:00
parent ad9087c038
commit 7d0f5aceae
15 changed files with 521 additions and 22 deletions
@@ -29,7 +29,7 @@ import java.util.WeakHashMap;
// How a player's body moves: the direction it faces, worked out the way the Minecraft client turns the body, because
// the server's own body rotation for players does not follow what other players see, and how fast it moves.
// Walking turns the body towards the walking direction; standing still it only turns once the head looks more than
// 50 degrees to the side.
// 50 degrees to the side. It also notices jumps, landings and starting to sneak, which reward touches react to.
@UtilityClass
public class BodyMotion {
@@ -37,6 +37,8 @@ public class BodyMotion {
private final double MOVING = 0.0025;
private final Map<Player, State> states = new WeakHashMap<>();
// Counts server ticks, so events can be matched to the tick they happened in
private long tick;
private static class State {
private float body;
@@ -45,9 +47,23 @@ public class BodyMotion {
private double z;
// Blocks per tick, smoothed so single steps do not jerk the wings
private double speed;
private boolean onGround = true;
private boolean sneaking;
// Highest point of the current time in the air
private double peakY;
private long jumpTick = Long.MIN_VALUE;
private long landTick = Long.MIN_VALUE;
private double landFall;
private long sneakTick = Long.MIN_VALUE;
}
// Called once per server tick before the players are updated
public void nextTick() {
tick++;
}
// Called once per server tick for every online player
@SuppressWarnings("deprecation")
public void tick(Player player) {
Location location = player.getLocation();
State state = states.get(player);
@@ -70,6 +86,22 @@ public class BodyMotion {
state.z = location.getZ();
state.speed = state.speed * 0.8 + Math.sqrt(dx * dx + dy * dy + dz * dz) * 0.2;
boolean onGround = player.isOnGround();
if (state.onGround && !onGround && dy > 0) {
state.jumpTick = tick;
}
if (!onGround) {
state.peakY = state.onGround ? location.getY() : Math.max(state.peakY, location.getY());
} else if (!state.onGround) {
state.landTick = tick;
state.landFall = state.peakY - location.getY();
}
state.onGround = onGround;
if (player.isSneaking() && !state.sneaking) {
state.sneakTick = tick;
}
state.sneaking = player.isSneaking();
float target = state.body;
if (dx * dx + dz * dz > MOVING) {
target = (float) Math.toDegrees(Math.atan2(dz, dx)) - 90;
@@ -98,6 +130,32 @@ public class BodyMotion {
return state == null ? 0 : state.speed;
}
public long currentTick() {
return tick;
}
public boolean jumped(Player player) {
State state = states.get(player);
return state != null && state.jumpTick == tick;
}
// Ticks since the last jump, or a large number if there was none
public long ticksSinceJump(Player player) {
State state = states.get(player);
return state == null || state.jumpTick == Long.MIN_VALUE ? Long.MAX_VALUE : tick - state.jumpTick;
}
// How far the player fell if they landed this tick, otherwise -1
public double landed(Player player) {
State state = states.get(player);
return state != null && state.landTick == tick ? state.landFall : -1;
}
public boolean startedSneaking(Player player) {
State state = states.get(player);
return state != null && state.sneakTick == tick;
}
private float wrap(float degrees) {
float wrapped = degrees % 360;
if (wrapped >= 180) wrapped -= 360;
@@ -44,6 +44,10 @@ public class CompiledSet {
for (ParticleEntry entry : particleSet.getEntries()) {
if (entry.isUnlocked(context)) {
compiledSet.add(entry.build());
ParticleElement touch = entry.buildTouch();
if (touch != null) {
compiledSet.add(touch);
}
}
}
return compiledSet;
@@ -60,6 +60,7 @@ public class ParticleListener implements Listener {
movingPlayers.entrySet().removeIf(entry -> entry.getValue() <= 0);
deg += 0.1;
if (deg > 360) deg = 0;
BodyMotion.nextTick();
Bukkit.getOnlinePlayers().forEach(BodyMotion::tick);
Bukkit.getOnlinePlayers().forEach(player -> {
if (disabled(player)) return;
@@ -101,7 +101,7 @@ public class Designs {
public final ChoiceParameter<WingColoring> WING_COLORING = ChoiceParameter.ofEnum("coloring", "PARTICLE_PARAM_WING_COLORING", WingColoring.OVER_TIME);
// Percent of the points shown each tick; fewer makes the wing twinkle and costs less
public final IntParameter WING_SPARKLE = new IntParameter("sparkle", "PARTICLE_PARAM_WING_SPARKLE", 100, 10, 100);
public final ChoiceParameter<Wing.Motion> WING_MOTION = ChoiceParameter.ofEnum("motion", "PARTICLE_PARAM_WING_MOTION", Wing.Motion.NONE);
public final ChoiceParameter<Wing.Motion> WING_MOTION = ChoiceParameter.ofEnum("motion", "PARTICLE_PARAM_WING_MOTION", Wing.Motion.NONE, motion -> motion == Wing.Motion.JUMP_BEAT);
// Moving wings need to be redrawn often to look smooth; still ones are fine once a second
private final int MOVING_WING_INTERVAL = 2;
private final int STILL_WING_INTERVAL = 20;
@@ -32,6 +32,8 @@ public class FixedDesign extends ParticleDesign {
private final int points;
private final Consumer<ParameterValues> defaults;
private final Supplier<ParticleElement> shape;
private Supplier<ParticleElement> touch;
private int touchPoints;
public FixedDesign(String id, Material icon, String nameKey, ParticleRequirement requirement, int points, Consumer<ParameterValues> defaults, Supplier<ParticleElement> shape) {
super(id, icon, nameKey, DesignVisibility.REWARD, requirement);
@@ -45,9 +47,21 @@ public class FixedDesign extends ParticleDesign {
return shape.get();
}
// Adds the reward's unique touch; its points count towards the slot budget
public FixedDesign withTouch(int touchPoints, Supplier<ParticleElement> touch) {
this.touchPoints = touchPoints;
this.touch = touch;
return this;
}
@Override
public ParticleElement touch() {
return touch == null ? null : touch.get();
}
@Override
public int points(ParameterValues values) {
return points;
return points + touchPoints;
}
@Override
@@ -72,6 +72,12 @@ public abstract class ParticleDesign {
return false;
}
// An extra the builder cannot make, shown next to the design's look and checked every tick so it never misses
// a jump or landing; null for designs without one
public ParticleElement touch() {
return null;
}
// Called after a design setting changed, to adjust others that depend on it
public void adjust(Parameter<?> changed, ParameterValues values) {
}
@@ -19,22 +19,30 @@
package de.steamwar.lobby.particle.design;
import de.steamwar.lobby.particle.Gradient;
import de.steamwar.lobby.particle.ParticleElement;
import de.steamwar.lobby.particle.ParticleRequirement;
import de.steamwar.lobby.particle.WingDesign;
import de.steamwar.lobby.particle.elements.*;
import de.steamwar.lobby.particle.elements.custom.HearthBeat;
import de.steamwar.lobby.particle.elements.custom.NonMoving;
import de.steamwar.lobby.particle.elements.custom.PulseShimmer;
import de.steamwar.lobby.particle.elements.touch.*;
import lombok.experimental.UtilityClass;
import org.bukkit.Material;
import org.bukkit.Particle;
import org.bukkit.entity.Player;
import org.bukkit.util.Vector;
import java.time.Month;
import java.util.List;
import java.util.function.Consumer;
import static de.steamwar.lobby.particle.design.EntryParameters.*;
// Designs earned in tournaments and the Easter egg hunt
// Designs earned in tournaments, events and the Easter egg hunt. Rewards whose look the basic designs can build get a
// touch the builder cannot make: bursts on jumping, landing or sneaking, footprints, a link to team-mates, a seasonal
// extra, a preset-only shape or aimed glyphs.
@UtilityClass
public class RewardDesigns {
@@ -48,23 +56,29 @@ public class RewardDesigns {
public final ParticleDesign EVENT_PLACEMENT_WARGEAR_SEASON = new FixedDesign("event_placement_wargear_season", Material.ENCHANTING_TABLE, "PARTICLE_EVENT_ENCHANTING",
ParticleRequirement.eventPlacement(22, 12, 285, 54), 5, MOVE,
() -> new Cloud(new Circle(new LocationMutator(new SimpleParticle(Particle.ENCHANT, 0, 0, 0, 0.01), 0, 1.1, 0))));
() -> new Cloud(new Circle(new LocationMutator(new SimpleParticle(Particle.ENCHANT, 0, 0, 0, 0.01), 0, 1.1, 0))))
.withTouch(3, () -> new NonMoving(new Glyphs(Glyphs.Path.INTO_PLAYER, 3, 1.5)));
public final ParticleDesign EVENT_PLACEMENT_AIRSHIP = new FixedDesign("event_placement_airship", Material.SNOWBALL, "PARTICLE_EVENT_CLOUD",
ParticleRequirement.eventPlacement(26, 205, 9, 54, 120, 292), 5, MOVE,
() -> new Circle(new LocationMutator(new SimpleParticle(Particle.CLOUD, 0, 0, 0, 0.01), 0, 2.2, 0)));
() -> new Circle(new LocationMutator(new SimpleParticle(Particle.CLOUD, 0, 0, 0, 0.01), 0, 2.2, 0)))
.withTouch(1, () -> new EventBurst(EventBurst.Trigger.JUMP, new SimpleParticle(Particle.CLOUD, 0.3F, 0.05F, 0.3F, 0.02, 12)));
public final ParticleDesign EVENT_PLACEMENT_HELLS_BELLS = new FixedDesign("event_placement_hells_bells", Material.TNT_MINECART, "PARTICLE_EVENT_SMOKE",
ParticleRequirement.eventPlacement(28, 205, 9, 11), 5, MOVE,
() -> new Cloud(new Circle(new LocationMutator(new SimpleParticle(Particle.CAMPFIRE_COSY_SMOKE, 0, 0, 0, 0.01), 0, 2.2, 0))));
public final ParticleDesign EVENT_PLACEMENT_UNDERWATER = new FixedDesign("event_placement_underwater", Material.PRISMARINE_SHARD, "PARTICLE_EVENT_WATER",
ParticleRequirement.eventPlacement(31, 9, 210, 520), 5, MOVE,
() -> new Cloud(new SimpleParticle(Particle.DRIPPING_WATER)));
() -> new Cloud(new SimpleParticle(Particle.DRIPPING_WATER)))
.withTouch(1, () -> new EventBurst(EventBurst.Trigger.LAND, 1, new Ring(new SimpleParticle(Particle.SPLASH, 0, 0, 0, 0, 3), 1, 16)));
public final ParticleDesign EVENT_PLACEMENT_ADVENT_WAR_SHIP = new FixedDesign("event_placement_advent_war_ship", Material.PRISMARINE_CRYSTALS, "PARTICLE_EVENT_WATER",
ParticleRequirement.eventPlacement(32, 9, 205, 210), 10, ALWAYS,
() -> new LocationMutator(new Circle(new Group(new SimpleParticle(Particle.DRIPPING_WATER), new SimpleParticle(Particle.UNDERWATER, 0.2F, 0.2F, 0.2F, 0.01))), 0, 1.1, 0));
() -> new LocationMutator(new Circle(new Group(new SimpleParticle(Particle.DRIPPING_WATER), new SimpleParticle(Particle.UNDERWATER, 0.2F, 0.2F, 0.2F, 0.01))), 0, 1.1, 0))
.withTouch(4, () -> new Footprints(new SimpleParticle(Particle.FISHING, 0.05F, 0, 0.05F, 0, 1), 40));
public final ParticleDesign EVENT_PLACEMENT_MINI_WARGEAR_LIGA = wings("event_placement_mini_wargear_liga", Material.IRON_SWORD, "PARTICLE_EVENT_WINGS",
ParticleRequirement.eventPlacement(33, 9, 34, 205), Particle.UNDERWATER, 0.15, WingDesign.MWGL);
public final ParticleDesign EVENT_PLACEMENT_ABSTURZ = wings("event_placement_absturz", Material.FEATHER, "PARTICLE_EVENT_WINGS",
ParticleRequirement.eventPlacement(34, 210, 205, 527, 286), Particle.CRIT, 0.15, WingDesign.SIMPLE);
ParticleRequirement.eventPlacement(34, 210, 205, 527, 286), Particle.CRIT, 0.15, WingDesign.SIMPLE)
.withTouch(1, () -> new EventBurst(EventBurst.Trigger.LAND, 3, new Group(new SimpleParticle(Particle.EXPLOSION, 0, 0, 0, 0, 1),
new Ring(new SimpleParticle(Particle.CRIT, 0, 0.1F, 0, 0.1, 2), 1.2, 16))));
public final ParticleDesign EVENT_PLACEMENT_UNDERWATER_MWG = wings("event_placement_underwater_mwg", Material.CYAN_CARPET, "PARTICLE_EVENT_RAIN_CLOUD",
ParticleRequirement.eventPlacement(35, 9, 210), Particle.CRIT, 0.15, WingDesign.SW);
public final ParticleDesign EVENT_PLACEMENT_WARGEAR_SEASON_2022 = wings("event_placement_wargear_season_2022", Material.DIAMOND_HELMET, "PARTICLE_EVENT_WGS",
@@ -75,29 +89,37 @@ public class RewardDesigns {
// For taking part in any event
public final ParticleDesign EVENT_PARTICIPATION_WATER = new FixedDesign("event_participation_water", Material.WATER_BUCKET, "PARTICLE_EVENT_WATER",
ParticleRequirement.EVENT_PARTICIPATION, 5, ALWAYS,
() -> new Circle(new LocationMutator(new SimpleParticle(Particle.DRIPPING_WATER), 0, 1.1, 0)));
() -> new Circle(new LocationMutator(new SimpleParticle(Particle.DRIPPING_WATER), 0, 1.1, 0)))
.withTouch(5, () -> new TeamLink("event_participation_water", new SimpleParticle(Particle.DRIPPING_WATER, 0, 0, 0, 0, 1)));
public final ParticleDesign EVENT_PARTICIPATION_LAVA = new FixedDesign("event_participation_lava", Material.LAVA_BUCKET, "PARTICLE_EVENT_LAVA",
ParticleRequirement.EVENT_PARTICIPATION, 5, ALWAYS,
() -> new Circle(new LocationMutator(new SimpleParticle(Particle.DRIPPING_LAVA), 0, 1.1, 0)));
() -> new Circle(new LocationMutator(new SimpleParticle(Particle.DRIPPING_LAVA), 0, 1.1, 0)))
.withTouch(1, () -> new EventBurst(EventBurst.Trigger.LAND, 1, new Group(new SimpleParticle(Particle.LAVA, 0.2F, 0, 0.2F, 0, 4),
new SimpleParticle(Particle.SMOKE, 0.2F, 0.1F, 0.2F, 0.02, 6))));
public final ParticleDesign EVENT_PARTICIPATION_WARGEAR_SEASON = new FixedDesign("event_participation_wargear_season", Material.BOOK, "PARTICLE_EVENT_ENCHANTING",
ParticleRequirement.eventParticipation(22), 5, MOVE,
() -> new SimpleParticle(Particle.ENCHANT));
() -> new SimpleParticle(Particle.ENCHANT))
.withTouch(1, () -> new EventBurst(EventBurst.Trigger.SNEAK, new Glyphs(Glyphs.Path.OUT_OF_PLAYER, 16, 1.5)));
public final ParticleDesign EVENT_PARTICIPATION_AIRSHIP = new FixedDesign("event_participation_airship", Material.SNOW_BLOCK, "PARTICLE_EVENT_CLOUD",
ParticleRequirement.eventParticipation(26), 5, MOVE,
() -> new SimpleParticle(Particle.CLOUD));
() -> new SimpleParticle(Particle.CLOUD))
.withTouch(4, () -> new Footprints(new SimpleParticle(Particle.CLOUD, 0.05F, 0, 0.05F, 0, 1), 40));
public final ParticleDesign EVENT_PARTICIPATION_HELLS_BELLS = new FixedDesign("event_participation_hells_bells", Material.TNT, "PARTICLE_EVENT_SMOKE",
ParticleRequirement.eventParticipation(28), 5, MOVE,
() -> new Circle(new SimpleParticle(Particle.CAMPFIRE_COSY_SMOKE, 0, 0, 0, 0.01)));
public final ParticleDesign EVENT_PARTICIPATION_UNDERWATER = new FixedDesign("event_participation_underwater", Material.PRISMARINE_BRICKS, "PARTICLE_EVENT_WATER",
ParticleRequirement.eventParticipation(31), 5, MOVE,
() -> new SimpleParticle(Particle.DRIPPING_WATER));
() -> new SimpleParticle(Particle.DRIPPING_WATER))
.withTouch(5, () -> new TeamLink("event_participation_underwater", new SimpleParticle(Particle.DRIPPING_WATER, 0, 0, 0, 0, 1)));
public final ParticleDesign EVENT_PARTICIPATION_ADVENT_WAR_SHIP = new FixedDesign("event_participation_advent_war_ship", Material.PRISMARINE_WALL, "PARTICLE_EVENT_WATER",
ParticleRequirement.eventParticipation(32), 10, MOVE,
() -> new Group(new SimpleParticle(Particle.DRIPPING_WATER), new SimpleParticle(Particle.UNDERWATER, 0.2F, 0.2F, 0.2F, 0.01)));
() -> new Group(new SimpleParticle(Particle.DRIPPING_WATER), new SimpleParticle(Particle.UNDERWATER, 0.2F, 0.2F, 0.2F, 0.01)))
.withTouch(2, () -> new Seasonal(Month.DECEMBER, new LocationMutator(new SimpleParticle(Particle.SNOWFLAKE, 1.2F, 0.3F, 1.2F, 0.01, 2), 0, 2.4, 0)));
public final ParticleDesign EVENT_PARTICIPATION_MINI_WARGEAR_LIGA = new FixedDesign("event_participation_mini_wargear_liga", Material.PRISMARINE_SLAB, "PARTICLE_EVENT_WATER",
ParticleRequirement.eventParticipation(33), 5, MOVE,
() -> new Circle(new SimpleParticle(Particle.UNDERWATER, 0, 0, 0, 0)));
() -> new Circle(new SimpleParticle(Particle.UNDERWATER, 0, 0, 0, 0)))
.withTouch(3, () -> new NonMoving(new PulseShimmer(new SimpleParticle(Particle.UNDERWATER, 0, 0, 0, 0, 1), 80, 2)));
public final ParticleDesign EVENT_PARTICIPATION_UNDERWATER_MWG = new FixedDesign("event_participation_underwater_mwg", Material.BLUE_CARPET, "PARTICLE_EVENT_RAIN_CLOUD",
ParticleRequirement.eventParticipation(35), 15, ALWAYS,
() -> new RandomParticle(
@@ -115,20 +137,24 @@ public class RewardDesigns {
rainCloud(null, Particle.TOTEM_OF_UNDYING, 2)
));
public final ParticleDesign EVENT_PARTICIPATION_WARGEAR_SEASON_2022 = wings("event_participation_wargear_season_2022", Material.DIAMOND_SWORD, "PARTICLE_EVENT_WINGS",
ParticleRequirement.eventParticipation(37), Particle.DRAGON_BREATH, 0.15, WingDesign.COMPLEX);
ParticleRequirement.eventParticipation(37), Particle.DRAGON_BREATH, 0.15, WingDesign.COMPLEX)
.withTouch(2, () -> new Delayed(new Wing(new SimpleParticle(Particle.DRAGON_BREATH, 0, 0, 0, 0, 1), 0.15, WingDesign.COMPLEX, false, 100, Wing.Motion.JUMP_BEAT), 2));
public final ParticleDesign EGG_HUNT_EASY = new FixedDesign("egg_hunt_easy", Material.LIME_CONCRETE_POWDER, "PARTICLE_EGG_HUNT_EASY",
ParticleRequirement.EGG_HUNT_EASY, 1, ALWAYS,
() -> new LocationMutator(new SimpleParticle(Particle.FALLING_SPORE_BLOSSOM, 0.5F, 0.1f, 0.5F, 1, 1), 0, 2.2, 0));
() -> new LocationMutator(new SimpleParticle(Particle.FALLING_SPORE_BLOSSOM, 0.5F, 0.1f, 0.5F, 1, 1), 0, 2.2, 0))
.withTouch(4, () -> new Footprints(new SimpleParticle(Particle.SPORE_BLOSSOM_AIR, 0.05F, 0, 0.05F, 0, 1), 40));
public final ParticleDesign EGG_HUNT_MEDIUM = new FixedDesign("egg_hunt_medium", Material.YELLOW_CONCRETE_POWDER, "PARTICLE_EGG_HUNT_MEDIUM",
ParticleRequirement.EGG_HUNT_MEDIUM, 5, ALWAYS.andThen(values -> values.set(GROUND, Ground.ON_GROUND).set(SNEAK, Sneak.SNEAKING)),
() -> new SimpleParticle(Particle.GLOW_SQUID_INK, 0.4F, 0.9F, 0.4F, 0.01));
public final ParticleDesign EGG_HUNT_HARD = new FixedDesign("egg_hunt_hard", Material.RED_CONCRETE_POWDER, "PARTICLE_EGG_HUNT_HARD",
ParticleRequirement.EGG_HUNT_HARD, 2, ALWAYS,
() -> new LocationMutator(new DoubleCircle(new DustParticle(Particle.DUST, 0, 0, 0, 0.01, 1, null), new DustParticle(Particle.DUST, 0, 0, 0, 0.01, 1, null)), 0, 0.5, 0));
() -> new LocationMutator(new Circle(new DoubleCircle(new DustParticle(Particle.DUST, 0, 0, 0, 0.01, 1, null), new DustParticle(Particle.DUST, 0, 0, 0, 0.01, 1, null)), 0.8, 0.5), 0, 0.5, 0));
public final ParticleDesign EGG_HUNT_EXTREME = new FixedDesign("egg_hunt_extreme", Material.PURPLE_CONCRETE_POWDER, "PARTICLE_EGG_HUNT_EXTREME",
ParticleRequirement.EGG_HUNT_EXTREME, 1, ALWAYS,
() -> new LocationMutator(new SimpleParticle(Particle.FALLING_OBSIDIAN_TEAR, 0.5F, 0.1F, 0.5F, 1, 1), 0, 2.2, 0));
() -> new LocationMutator(new SimpleParticle(Particle.FALLING_OBSIDIAN_TEAR, 0.5F, 0.1F, 0.5F, 1, 1), 0, 2.2, 0))
.withTouch(1, () -> new EventBurst(EventBurst.Trigger.LAND, 1, new Group(new Ring(new SimpleParticle(Particle.WITCH, 0, 0.05F, 0, 0, 2), 1, 16),
new SimpleParticle(Particle.LANDING_OBSIDIAN_TEAR, 0.4F, 0, 0.4F, 0, 6))));
public final ParticleDesign EGG_HUNT_ADVANCED = new FixedDesign("egg_hunt_advanced", Material.BLACK_CONCRETE_POWDER, "PARTICLE_EGG_HUNT_ADVANCED",
ParticleRequirement.EGG_HUNT_ADVANCED, 2, ALWAYS.andThen(values -> values.set(SNEAK, Sneak.SNEAKING)),
() -> new LocationMutator(new Group(
@@ -145,9 +171,10 @@ public class RewardDesigns {
() -> new Group(
new Sneaking(new Floor(new LocationMutator(new SimpleParticle(Particle.HEART, 0, 0, 0, 0.01), 0, 1.7, 0))),
new NonFloor(new NonFlying(new LocationMutator(new Cloud(new Circle(new SimpleParticle(Particle.HEART, 0, 0, 0, 0.01))), 0, 0.5, 0)))
));
))
.withTouch(1, () -> new NonMoving(new HearthBeat(new DustParticle(Particle.DUST, 0, 0, 0, 0, 1, new Gradient(java.awt.Color.RED)), 80)));
private ParticleDesign wings(String id, Material icon, String nameKey, ParticleRequirement requirement, Particle particle, double size, WingDesign wingDesign) {
private FixedDesign wings(String id, Material icon, String nameKey, ParticleRequirement requirement, Particle particle, double size, WingDesign wingDesign) {
return new FixedDesign(id, icon, nameKey, requirement, wingDesign.getVectors().length, WINGS,
() -> new Wing(new SimpleParticle(particle, 0, 0, 0, 0, 1), size, wingDesign));
}
@@ -33,6 +33,8 @@ public class Wing extends DelegatingParticleElement {
NONE,
FLAP_SLOW,
FLAP_FAST,
// One wing beat after each jump and none otherwise; only for a reward
JUMP_BEAT,
}
// Wings stand slightly open when idle and open further the faster the player moves
@@ -40,6 +42,7 @@ public class Wing extends DelegatingParticleElement {
private static final double MOVING_OPEN_DEGREES = 15;
private static final double FULL_OPEN_SPEED = 0.6;
private static final double FLAP_DEGREES = 25;
private static final int JUMP_BEAT_TICKS = 10;
private double size;
private WingDesign wingDesign;
@@ -68,6 +71,9 @@ public class Wing extends DelegatingParticleElement {
@Override
public void tick(ParticleTickData particleTickData) {
if (motion == Motion.JUMP_BEAT && BodyMotion.ticksSinceJump(particleTickData.getPlayer()) > JUMP_BEAT_TICKS) {
return;
}
double ticks = particleTickData.getDeg() * 10;
double open = Math.toRadians(openDegrees(particleTickData, ticks));
double cos = Math.cos(open), sin = Math.sin(open);
@@ -105,6 +111,7 @@ public class Wing extends DelegatingParticleElement {
return switch (motion) {
case FLAP_SLOW -> open + FLAP_DEGREES * Math.sin(2 * Math.PI * ticks / 20);
case FLAP_FAST -> open + FLAP_DEGREES * Math.sin(2 * Math.PI * ticks / 10);
case JUMP_BEAT -> open + FLAP_DEGREES * Math.sin(Math.PI * BodyMotion.ticksSinceJump(particleTickData.getPlayer()) / JUMP_BEAT_TICKS);
default -> open;
};
}
@@ -0,0 +1,63 @@
/*
* This file is a part of the SteamWar software.
*
* Copyright (C) 2026 SteamWar.de-Serverteam
*
* This program is free software: you can redistribute it and/or modify
* it under the terms of the GNU Affero General Public License as published by
* the Free Software Foundation, either version 3 of the License, or
* (at your option) any later version.
*
* This program is distributed in the hope that it will be useful,
* but WITHOUT ANY WARRANTY; without even the implied warranty of
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
* GNU Affero General Public License for more details.
*
* You should have received a copy of the GNU Affero General Public License
* along with this program. If not, see <https://www.gnu.org/licenses/>.
*/
package de.steamwar.lobby.particle.elements.touch;
import de.steamwar.lobby.particle.BodyMotion;
import de.steamwar.lobby.particle.ParticleElement;
import de.steamwar.lobby.particle.ParticleTickData;
import de.steamwar.lobby.particle.elements.DelegatingParticleElement;
import org.bukkit.entity.Player;
// Shows the burst once at the player's feet in the tick the event happens
public class EventBurst extends DelegatingParticleElement {
public enum Trigger {
JUMP,
LAND,
SNEAK,
}
private final Trigger trigger;
// Landings from lower than this many blocks are ignored
private final double minimumFall;
public EventBurst(Trigger trigger, ParticleElement burst) {
this(trigger, 0, burst);
}
public EventBurst(Trigger trigger, double minimumFall, ParticleElement burst) {
super(burst);
this.trigger = trigger;
this.minimumFall = minimumFall;
}
@Override
public void tick(ParticleTickData particleTickData) {
Player player = particleTickData.getPlayer();
boolean happened = switch (trigger) {
case JUMP -> BodyMotion.jumped(player);
case LAND -> BodyMotion.landed(player) >= minimumFall;
case SNEAK -> BodyMotion.startedSneaking(player);
};
if (happened) {
particleElement.tick(particleTickData.withLocation(player.getLocation()));
}
}
}
@@ -0,0 +1,69 @@
/*
* This file is a part of the SteamWar software.
*
* Copyright (C) 2026 SteamWar.de-Serverteam
*
* This program is free software: you can redistribute it and/or modify
* it under the terms of the GNU Affero General Public License as published by
* the Free Software Foundation, either version 3 of the License, or
* (at your option) any later version.
*
* This program is distributed in the hope that it will be useful,
* but WITHOUT ANY WARRANTY; without even the implied warranty of
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
* GNU Affero General Public License for more details.
*
* You should have received a copy of the GNU Affero General Public License
* along with this program. If not, see <https://www.gnu.org/licenses/>.
*/
package de.steamwar.lobby.particle.elements.touch;
import de.steamwar.lobby.particle.ParticleElement;
import de.steamwar.lobby.particle.ParticleTickData;
import de.steamwar.lobby.particle.elements.DelegatingParticleElement;
import org.bukkit.Location;
import org.bukkit.entity.Player;
import java.util.ArrayDeque;
import java.util.Deque;
import java.util.Map;
import java.util.WeakHashMap;
// Leaves a print every half step on the ground and keeps showing it for a while after the player walked on
public class Footprints extends DelegatingParticleElement {
private static final double STEP = 0.6;
private record Print(Location location, int age) {
}
private final int lifetime;
// Weak keys drop players once they quit
private final Map<Player, Deque<Print>> prints = new WeakHashMap<>();
public Footprints(ParticleElement particleElement, int lifetime) {
super(particleElement);
this.lifetime = lifetime;
}
@Override
@SuppressWarnings("deprecation")
public void tick(ParticleTickData particleTickData) {
Player player = particleTickData.getPlayer();
Deque<Print> playerPrints = prints.computeIfAbsent(player, key -> new ArrayDeque<>());
Location feet = player.getLocation();
Print last = playerPrints.peekLast();
if (player.isOnGround() && (last == null || last.location().getWorld() != feet.getWorld() || last.location().distanceSquared(feet) > STEP * STEP)) {
playerPrints.addLast(new Print(feet, 0));
}
int size = playerPrints.size();
for (int i = 0; i < size; i++) {
Print print = playerPrints.pollFirst();
if (print.age() >= lifetime) continue;
particleElement.tick(particleTickData.withLocation(print.location().clone()));
playerPrints.addLast(new Print(print.location(), print.age() + 1));
}
}
}
@@ -0,0 +1,72 @@
/*
* This file is a part of the SteamWar software.
*
* Copyright (C) 2026 SteamWar.de-Serverteam
*
* This program is free software: you can redistribute it and/or modify
* it under the terms of the GNU Affero General Public License as published by
* the Free Software Foundation, either version 3 of the License, or
* (at your option) any later version.
*
* This program is distributed in the hope that it will be useful,
* but WITHOUT ANY WARRANTY; without even the implied warranty of
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
* GNU Affero General Public License for more details.
*
* You should have received a copy of the GNU Affero General Public License
* along with this program. If not, see <https://www.gnu.org/licenses/>.
*/
package de.steamwar.lobby.particle.elements.touch;
import de.steamwar.lobby.particle.ParticleElement;
import de.steamwar.lobby.particle.ParticleTickData;
import org.bukkit.Location;
import org.bukkit.Particle;
import org.bukkit.util.Vector;
// Enchanting glyphs that fly along a path. Spawned with an amount of 0, the offset becomes the glyph's motion: it
// appears at location + offset and flies back to the location, like glyphs flying into an enchanting table.
public class Glyphs implements ParticleElement {
public enum Path {
// From random points on the ground around the player into the chest
INTO_PLAYER,
// From the chest outwards to a ring around the player
OUT_OF_PLAYER,
}
private static final double CHEST = 1.2;
private final Path path;
private final int amount;
private final double radius;
public Glyphs(Path path, int amount, double radius) {
this.path = path;
this.amount = amount;
this.radius = radius;
}
@Override
public void tick(ParticleTickData particleTickData) {
Location chest = particleTickData.getPlayer().getLocation().add(0, CHEST, 0);
for (int i = 0; i < amount; i++) {
Location target;
Vector from;
if (path == Path.INTO_PLAYER) {
double angle = RANDOM.nextDouble() * 2 * Math.PI;
double distance = radius * (0.5 + RANDOM.nextDouble() / 2);
target = chest;
from = new Vector(Math.cos(angle) * distance, -CHEST, Math.sin(angle) * distance);
} else {
double angle = 2 * Math.PI * i / amount;
Vector out = new Vector(Math.cos(angle) * radius, 0, Math.sin(angle) * radius);
target = chest.clone().add(out);
from = out.multiply(-1);
}
Location spawn = target;
display(particleTickData, player -> player.spawnParticle(Particle.ENCHANT, spawn, 0, from.getX(), from.getY(), from.getZ(), 1));
}
}
}
@@ -0,0 +1,47 @@
/*
* This file is a part of the SteamWar software.
*
* Copyright (C) 2026 SteamWar.de-Serverteam
*
* This program is free software: you can redistribute it and/or modify
* it under the terms of the GNU Affero General Public License as published by
* the Free Software Foundation, either version 3 of the License, or
* (at your option) any later version.
*
* This program is distributed in the hope that it will be useful,
* but WITHOUT ANY WARRANTY; without even the implied warranty of
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
* GNU Affero General Public License for more details.
*
* You should have received a copy of the GNU Affero General Public License
* along with this program. If not, see <https://www.gnu.org/licenses/>.
*/
package de.steamwar.lobby.particle.elements.touch;
import de.steamwar.lobby.particle.ParticleElement;
import de.steamwar.lobby.particle.ParticleTickData;
import de.steamwar.lobby.particle.elements.DelegatingParticleElement;
import org.bukkit.Location;
// A flat ring of points around the location, all at once, for bursts
public class Ring extends DelegatingParticleElement {
private final double radius;
private final int points;
public Ring(ParticleElement particleElement, double radius, int points) {
super(particleElement);
this.radius = radius;
this.points = points;
}
@Override
public void tick(ParticleTickData particleTickData) {
Location center = particleTickData.getLocation();
for (int i = 0; i < points; i++) {
double angle = 2 * Math.PI * i / points;
particleElement.tick(particleTickData.withLocation(center.clone().add(Math.cos(angle) * radius, 0, Math.sin(angle) * radius)));
}
}
}
@@ -0,0 +1,45 @@
/*
* This file is a part of the SteamWar software.
*
* Copyright (C) 2026 SteamWar.de-Serverteam
*
* This program is free software: you can redistribute it and/or modify
* it under the terms of the GNU Affero General Public License as published by
* the Free Software Foundation, either version 3 of the License, or
* (at your option) any later version.
*
* This program is distributed in the hope that it will be useful,
* but WITHOUT ANY WARRANTY; without even the implied warranty of
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
* GNU Affero General Public License for more details.
*
* You should have received a copy of the GNU Affero General Public License
* along with this program. If not, see <https://www.gnu.org/licenses/>.
*/
package de.steamwar.lobby.particle.elements.touch;
import de.steamwar.lobby.particle.ParticleElement;
import de.steamwar.lobby.particle.ParticleTickData;
import de.steamwar.lobby.particle.elements.DelegatingParticleElement;
import java.time.LocalDate;
import java.time.Month;
// Only shows during the given month
public class Seasonal extends DelegatingParticleElement {
private final Month month;
public Seasonal(Month month, ParticleElement particleElement) {
super(particleElement);
this.month = month;
}
@Override
public void tick(ParticleTickData particleTickData) {
if (LocalDate.now().getMonth() == month) {
particleElement.tick(particleTickData);
}
}
}
@@ -0,0 +1,74 @@
/*
* This file is a part of the SteamWar software.
*
* Copyright (C) 2026 SteamWar.de-Serverteam
*
* This program is free software: you can redistribute it and/or modify
* it under the terms of the GNU Affero General Public License as published by
* the Free Software Foundation, either version 3 of the License, or
* (at your option) any later version.
*
* This program is distributed in the hope that it will be useful,
* but WITHOUT ANY WARRANTY; without even the implied warranty of
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
* GNU Affero General Public License for more details.
*
* You should have received a copy of the GNU Affero General Public License
* along with this program. If not, see <https://www.gnu.org/licenses/>.
*/
package de.steamwar.lobby.particle.elements.touch;
import de.steamwar.lobby.particle.BodyMotion;
import de.steamwar.lobby.particle.ParticleElement;
import de.steamwar.lobby.particle.ParticleTickData;
import de.steamwar.lobby.particle.elements.DelegatingParticleElement;
import org.bukkit.Location;
import org.bukkit.entity.Player;
import org.bukkit.util.Vector;
import java.util.HashMap;
import java.util.Map;
import java.util.WeakHashMap;
// A line of particles between nearby players who show the same reward. Each pair is drawn by only one of them.
public class TeamLink extends DelegatingParticleElement {
private static final double RANGE = 8;
private static final double SPACING = 0.5;
private static final double CHEST = 1.2;
// Per reward, who showed it in which tick
private static final Map<String, Map<Player, Long>> SHOWING = new HashMap<>();
private final String reward;
public TeamLink(String reward, ParticleElement particleElement) {
super(particleElement);
this.reward = reward;
}
@Override
public void tick(ParticleTickData particleTickData) {
Player player = particleTickData.getPlayer();
long now = BodyMotion.currentTick();
Map<Player, Long> showing = SHOWING.computeIfAbsent(reward, key -> new WeakHashMap<>());
showing.put(player, now);
Location from = player.getLocation().add(0, CHEST, 0);
for (Map.Entry<Player, Long> other : showing.entrySet()) {
Player partner = other.getKey();
// Still showing it, and drawn by the player with the smaller id only
if (partner == player || other.getValue() < now - 1 || player.getUniqueId().compareTo(partner.getUniqueId()) > 0) continue;
Location to = partner.getLocation().add(0, CHEST, 0);
if (to.getWorld() != from.getWorld() || to.distanceSquared(from) > RANGE * RANGE) continue;
Vector step = to.toVector().subtract(from.toVector());
int points = (int) Math.ceil(step.length() / SPACING);
step.multiply(1.0 / points);
for (int i = 1; i < points; i++) {
particleElement.tick(particleTickData.withLocation(from.clone().add(step.clone().multiply(i))));
}
}
}
}
@@ -163,6 +163,18 @@ public class ParticleEntry {
return new LocationMutator(element, offsetX, offsetY, offsetZ);
}
// The design's touch with the entry's placement and visibility, or null; touches ignore conditions and timing
public ParticleElement buildTouch() {
ParticleElement touch = design.touch();
if (touch == null) {
return null;
}
if (values.get(PLACEMENT) == Placement.BODY) {
touch = new BodyRelative(touch);
}
return new Always(values.get(VISIBILITY).wrap(touch));
}
// Shown only when both the design and, for designs that use it, the particle type are unlocked
public boolean isUnlocked(UnlockContext context) {
if (!design.getRequirement().test(context)) {