Open wings with speed and let them flap or breathe

All wings stand 5 degrees open when idle and open up to 15 degrees the
faster the player moves. The wings design gets a motion setting to flap
slowly, flap quickly or breathe; picking a motion redraws the wing every
2 ticks so it looks smooth, and going back to none restores every 20.
Body rotation tracking now also tracks speed, as BodyMotion.
This commit is contained in:
2026-10-26 13:55:58 +01:00
parent e830d087c9
commit 872da5f62a
11 changed files with 108 additions and 22 deletions
@@ -171,6 +171,7 @@ PARTICLE_PARAM_WING_SIZE = §fWing size
PARTICLE_PARAM_WING_DETAIL = §fPoints per wing
PARTICLE_PARAM_WING_COLORING = §fColour mode
PARTICLE_PARAM_WING_SPARKLE = §fSparkle (points shown, %)
PARTICLE_PARAM_WING_MOTION = §fMotion
PARTICLE_CHOICE_TICKMODE_MOVE = §fWhile moving
PARTICLE_CHOICE_TICKMODE_ALWAYS = §fAlways
@@ -199,6 +200,10 @@ PARTICLE_CHOICE_IMAGE_BUTTERFLY = §fButterfly
PARTICLE_CHOICE_IMAGE_FAIRY = §fFairy
PARTICLE_CHOICE_COLORING_OVER_TIME = §fChange over time
PARTICLE_CHOICE_COLORING_ALONG_WING = §fFrom the back to the tips
PARTICLE_CHOICE_MOTION_NONE = §fNone
PARTICLE_CHOICE_MOTION_FLAP_SLOW = §fFlap slowly
PARTICLE_CHOICE_MOTION_FLAP_FAST = §fFlap quickly
PARTICLE_CHOICE_MOTION_BREATHE = §fBreathe
# Particle types
PARTICLE_TYPE_DUST = §fDust
@@ -171,6 +171,7 @@ PARTICLE_PARAM_WING_SIZE = §fFlügelgröße
PARTICLE_PARAM_WING_DETAIL = §fPunkte pro Flügel
PARTICLE_PARAM_WING_COLORING = §fFarbmodus
PARTICLE_PARAM_WING_SPARKLE = §fFunkeln (sichtbare Punkte, %)
PARTICLE_PARAM_WING_MOTION = §fBewegung
PARTICLE_CHOICE_TICKMODE_MOVE = §fBeim Bewegen
PARTICLE_CHOICE_TICKMODE_ALWAYS = §fImmer
@@ -199,6 +200,10 @@ PARTICLE_CHOICE_IMAGE_BUTTERFLY = §fSchmetterling
PARTICLE_CHOICE_IMAGE_FAIRY = §fFee
PARTICLE_CHOICE_COLORING_OVER_TIME = §fWechseln mit der Zeit
PARTICLE_CHOICE_COLORING_ALONG_WING = §fVom Rücken zu den Spitzen
PARTICLE_CHOICE_MOTION_NONE = §fKeine
PARTICLE_CHOICE_MOTION_FLAP_SLOW = §fLangsam schlagen
PARTICLE_CHOICE_MOTION_FLAP_FAST = §fSchnell schlagen
PARTICLE_CHOICE_MOTION_BREATHE = §fAtmen
# Particle types
PARTICLE_TYPE_DUST = §fStaub
@@ -26,7 +26,7 @@ import org.bukkit.entity.Pose;
import org.bukkit.util.Vector;
// The player's body as a coordinate system: origin at the feet, up towards the head, forward out of the chest.
// Standing it turns with the body (as BodyYaw tracks it); gliding or swimming the body lies along the look direction.
// Standing it turns with the body (as BodyMotion tracks it); gliding or swimming the body lies along the look direction.
@Getter
public class BodyFrame {
@@ -63,7 +63,7 @@ public class BodyFrame {
Vector up = location.getDirection().normalize();
Vector right = up.clone().crossProduct(WORLD_UP);
if (right.lengthSquared() < 1e-6) {
right = horizontal(BodyYaw.of(player)).crossProduct(WORLD_UP);
right = horizontal(BodyMotion.yaw(player)).crossProduct(WORLD_UP);
}
right.normalize();
Vector forward = up.clone().crossProduct(right).normalize();
@@ -72,7 +72,7 @@ public class BodyFrame {
return new BodyFrame(origin, right, up, forward);
}
Vector forward = horizontal(BodyYaw.of(player));
Vector forward = horizontal(BodyMotion.yaw(player));
Vector right = forward.clone().crossProduct(WORLD_UP).normalize();
return new BodyFrame(location, right, WORLD_UP.clone(), forward);
}
@@ -26,11 +26,12 @@ import org.bukkit.entity.Player;
import java.util.Map;
import java.util.WeakHashMap;
// The direction a player's body 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. Walking turns the body towards the walking
// direction, and standing still it only turns once the head looks more than 50 degrees to the side.
// 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.
@UtilityClass
public class BodyYaw {
public class BodyMotion {
private final float MAX_HEAD_TURN = 50;
private final double MOVING = 0.0025;
@@ -40,7 +41,10 @@ public class BodyYaw {
private static class State {
private float body;
private double x;
private double y;
private double z;
// Blocks per tick, smoothed so single steps do not jerk the wings
private double speed;
}
// Called once per server tick for every online player
@@ -51,6 +55,7 @@ public class BodyYaw {
state = new State();
state.body = location.getYaw();
state.x = location.getX();
state.y = location.getY();
state.z = location.getZ();
states.put(player, state);
return;
@@ -58,9 +63,12 @@ public class BodyYaw {
float head = location.getYaw();
double dx = location.getX() - state.x;
double dy = location.getY() - state.y;
double dz = location.getZ() - state.z;
state.x = location.getX();
state.y = location.getY();
state.z = location.getZ();
state.speed = state.speed * 0.8 + Math.sqrt(dx * dx + dy * dy + dz * dz) * 0.2;
float target = state.body;
if (dx * dx + dz * dz > MOVING) {
@@ -79,11 +87,17 @@ public class BodyYaw {
}
// The tracked body direction in degrees, or the head direction for players not tracked yet
public float of(Player player) {
public float yaw(Player player) {
State state = states.get(player);
return state == null ? player.getLocation().getYaw() : state.body;
}
// Smoothed speed in blocks per tick; sprinting is about 0.28, fast elytra flight well above 1
public double speed(Player player) {
State state = states.get(player);
return state == null ? 0 : state.speed;
}
private float wrap(float degrees) {
float wrapped = degrees % 360;
if (wrapped >= 180) wrapped -= 360;
@@ -60,7 +60,7 @@ public class ParticleListener implements Listener {
movingPlayers.entrySet().removeIf(entry -> entry.getValue() <= 0);
deg += 0.1;
if (deg > 360) deg = 0;
Bukkit.getOnlinePlayers().forEach(BodyYaw::tick);
Bukkit.getOnlinePlayers().forEach(BodyMotion::tick);
Bukkit.getOnlinePlayers().forEach(player -> {
if (disabled(player)) return;
if (JumpAndRun.isPlayerInJumpAndRun(player)) return;
@@ -101,13 +101,17 @@ 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);
// 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;
public final ParticleDesign WING = new ParticleDesign("wing", Material.ELYTRA, "PARTICLE_DESIGN_WING", WING_SHAPE, WING_DETAIL, WING_SIZE, WING_COLORING, WING_SPARKLE) {
public final ParticleDesign WING = new ParticleDesign("wing", Material.ELYTRA, "PARTICLE_DESIGN_WING", WING_SHAPE, WING_DETAIL, WING_SIZE, WING_COLORING, WING_SPARKLE, WING_MOTION) {
@Override
public ParticleElement build(ParameterValues values, ParticleElement particle) {
WingShape shape = values.get(WING_SHAPE);
return new Wing(particle, values.get(WING_SIZE), shape.design(values.get(WING_DETAIL)),
values.get(WING_COLORING) == WingColoring.ALONG_WING, values.get(WING_SPARKLE));
values.get(WING_COLORING) == WingColoring.ALONG_WING, values.get(WING_SPARKLE), values.get(WING_MOTION));
}
@Override
@@ -126,13 +130,24 @@ public class Designs {
return values.get(WING_SHAPE).isDrawn();
}
@Override
public void adjust(Parameter<?> changed, ParameterValues values) {
if (changed != WING_MOTION) return;
int interval = values.get(EntryParameters.INTERVAL);
if (values.get(WING_MOTION) != Wing.Motion.NONE && interval > MOVING_WING_INTERVAL) {
values.set(EntryParameters.INTERVAL, MOVING_WING_INTERVAL);
} else if (values.get(WING_MOTION) == Wing.Motion.NONE && interval == MOVING_WING_INTERVAL) {
values.set(EntryParameters.INTERVAL, STILL_WING_INTERVAL);
}
}
// Image wings spawn a particle on every pixel, so they start sparse, and sit on the back even while gliding
@Override
public void applyDefaults(ParameterValues values) {
values.set(EntryParameters.COUNT, 1)
.set(EntryParameters.SPEED, 0.0)
.set(EntryParameters.TICK_MODE, EntryParameters.TickMode.ALWAYS)
.set(EntryParameters.INTERVAL, 20)
.set(EntryParameters.INTERVAL, STILL_WING_INTERVAL)
.set(EntryParameters.PLACEMENT, EntryParameters.Placement.BODY);
}
};
@@ -40,6 +40,12 @@ public class ParameterValues {
return this;
}
// Puts back values saved with copy()
public void restore(ParameterValues saved) {
values.clear();
values.putAll(saved.values);
}
public ParameterValues copy() {
ParameterValues copy = new ParameterValues();
copy.values.putAll(values);
@@ -72,6 +72,10 @@ public abstract class ParticleDesign {
return false;
}
// Called after a design setting changed, to adjust others that depend on it
public void adjust(Parameter<?> changed, ParameterValues values) {
}
// Sets the conditions and timing a new entry of this design starts with
public void applyDefaults(ParameterValues values) {
}
@@ -20,7 +20,7 @@
package de.steamwar.lobby.particle.elements;
import de.steamwar.lobby.particle.BodyFrame;
import de.steamwar.lobby.particle.BodyYaw;
import de.steamwar.lobby.particle.BodyMotion;
import de.steamwar.lobby.particle.ParticleElement;
import de.steamwar.lobby.particle.ParticleTickData;
import de.steamwar.lobby.particle.WingDesign;
@@ -29,12 +29,27 @@ import org.bukkit.util.Vector;
public class Wing extends DelegatingParticleElement {
public enum Motion {
NONE,
FLAP_SLOW,
FLAP_FAST,
BREATHE,
}
// Wings stand slightly open when idle and open further the faster the player moves
private static final double IDLE_OPEN_DEGREES = 5;
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 double BREATHE_SCALE = 0.08;
private double size;
private WingDesign wingDesign;
// Colour each point by its distance from the spine instead of by time
private boolean colorAlongWing;
// Share of the points shown each tick, in percent; below 100 the wing twinkles
private int density = 100;
private Motion motion = Motion.NONE;
private double widest;
public Wing(ParticleElement particleElement, double size, WingDesign wingDesign) {
@@ -43,10 +58,11 @@ public class Wing extends DelegatingParticleElement {
this.wingDesign = wingDesign;
}
public Wing(ParticleElement particleElement, double size, WingDesign wingDesign, boolean colorAlongWing, int density) {
public Wing(ParticleElement particleElement, double size, WingDesign wingDesign, boolean colorAlongWing, int density, Motion motion) {
this(particleElement, size, wingDesign);
this.colorAlongWing = colorAlongWing;
this.density = density;
this.motion = motion;
for (Vector vector : wingDesign.getVectors()) {
widest = Math.max(widest, Math.abs(vector.getX()));
}
@@ -54,22 +70,29 @@ public class Wing extends DelegatingParticleElement {
@Override
public void tick(ParticleTickData particleTickData) {
double ticks = particleTickData.getDeg() * 10;
double open = Math.toRadians(openDegrees(particleTickData, ticks));
double cos = Math.cos(open), sin = Math.sin(open);
double scale = size * (motion == Motion.BREATHE ? 1 + BREATHE_SCALE * Math.sin(2 * Math.PI * ticks / 40) : 1);
double sneak = particleTickData.getPlayer().isSneaking() ? 0.5 : 0;
BodyFrame frame = particleTickData.getFrame();
if (frame != null) {
// Half a block behind the back, so the wings stay on the back even while gliding
double sneak = particleTickData.getPlayer().isSneaking() ? 0.5 : 0;
Location base = particleTickData.getLocation();
for (Vector dVector : wingDesign.getVectors()) {
if (hidden()) continue;
Vector local = frame.local(dVector.getX() * size, 0.6 + dVector.getY() * size - sneak, -0.5);
double out = dVector.getX() * scale;
Vector local = frame.local(out * cos, 0.6 + dVector.getY() * scale - sneak, -0.5 - Math.abs(out) * sin);
particleElement.tick(shaded(particleTickData, dVector).withLocation(base.clone().add(local)));
}
return;
}
for (Vector dVector : wingDesign.getVectors()) {
if (hidden()) continue;
Vector vector = new Vector(dVector.getX() * size, 0.6 + dVector.getY() * size - (particleTickData.getPlayer().isSneaking() ? 0.5 : 0), 0.5);
vector.rotateAroundY(Math.toRadians(BodyYaw.of(particleTickData.getPlayer()) * -1));
double out = dVector.getX() * scale;
Vector vector = new Vector(out * cos, 0.6 + dVector.getY() * scale - sneak, 0.5 + Math.abs(out) * sin);
vector.rotateAroundY(Math.toRadians(BodyMotion.yaw(particleTickData.getPlayer()) * -1));
vector.setX(-vector.getX());
vector.setZ(-vector.getZ());
Location location = particleTickData.getPlayer().getLocation().clone().add(vector);
@@ -78,6 +101,17 @@ public class Wing extends DelegatingParticleElement {
}
}
// How far each half is turned back around the spine; flapping swings it back and forth
private double openDegrees(ParticleTickData particleTickData, double ticks) {
double speed = Math.min(1, BodyMotion.speed(particleTickData.getPlayer()) / FULL_OPEN_SPEED);
double open = IDLE_OPEN_DEGREES + (MOVING_OPEN_DEGREES - IDLE_OPEN_DEGREES) * speed;
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);
default -> open;
};
}
// Full wings never draw a random number, so presets keep their exact look
private boolean hidden() {
return density < 100 && RANDOM.nextInt(100) >= density;
@@ -19,7 +19,7 @@
package de.steamwar.lobby.particle.elements.custom;
import de.steamwar.lobby.particle.BodyYaw;
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;
@@ -49,7 +49,7 @@ public class HearthBeat extends DelegatingParticleElement {
double x = (time - 0.5) * 2;
Vector vector = new Vector(x, 1.1 + y - (particleTickData.getPlayer().isSneaking() ? 0.5 : 0), 0.7);
vector.rotateAroundY(Math.toRadians(BodyYaw.of(particleTickData.getPlayer()) * -1));
vector.rotateAroundY(Math.toRadians(BodyMotion.yaw(particleTickData.getPlayer()) * -1));
vector.setX(-vector.getX());
vector.setZ(-vector.getZ());
Location location = particleTickData.getPlayer().getLocation().clone().add(vector);
@@ -89,7 +89,8 @@ class EntryEditor {
Map.entry(Designs.WING_SIZE, Material.SCAFFOLDING),
Map.entry(Designs.WING_DETAIL, Material.AMETHYST_SHARD),
Map.entry(Designs.WING_COLORING, Material.CYAN_DYE),
Map.entry(Designs.WING_SPARKLE, Material.AMETHYST_CLUSTER)
Map.entry(Designs.WING_SPARKLE, Material.AMETHYST_CLUSTER),
Map.entry(Designs.WING_MOTION, Material.FEATHER)
);
private record Context(Player player, SteamwarUser user, int index, int entryIndex, ParticleSlot slot, ParticleEntry entry) {
@@ -261,9 +262,11 @@ class EntryEditor {
return true;
}
double previousCost = context.slot().cost();
ParameterValues saved = context.entry().getValues().copy();
context.entry().getValues().set(parameter, value);
context.entry().getDesign().adjust(parameter, context.entry().getValues());
if (!SlotEditor.allows(context.user(), context.slot(), previousCost)) {
context.entry().getValues().set(parameter, previous);
context.entry().getValues().restore(saved);
LobbySystem.getMessage().send("PARTICLE_SLOT_OVER_BUDGET", context.player());
return false;
}