Place lobby particle entries relative to the body

Entries get a World/Body setting. In body mode offsets, heights and wings
follow the body's facing and lie along the body while gliding or swimming,
so wings stay on the back. Rings circle the body but keep their world
angle, so they do not turn with the player. New wings default to body;
presets and rewards stay in world mode and look exactly as before.
This commit is contained in:
2026-10-26 09:49:11 +01:00
parent c3eda01e20
commit 47f0d9858b
17 changed files with 217 additions and 7 deletions
@@ -142,6 +142,7 @@ PARTICLE_PARAM_OFFSET_X = §fOffset X
PARTICLE_PARAM_OFFSET_Y = §fOffset Y
PARTICLE_PARAM_OFFSET_Z = §fOffset Z
PARTICLE_PARAM_INTERVAL = §fEvery n ticks
PARTICLE_PARAM_PLACEMENT = §fRelative to
PARTICLE_PARAM_TICK_MODE = §fShown
PARTICLE_PARAM_MOVEMENT = §fMovement
PARTICLE_PARAM_GLIDING = §fElytra
@@ -160,6 +161,8 @@ PARTICLE_PARAM_WING_SIZE = §fWing size
PARTICLE_CHOICE_TICKMODE_MOVE = §fWhile moving
PARTICLE_CHOICE_TICKMODE_ALWAYS = §fAlways
PARTICLE_CHOICE_PLACEMENT_WORLD = §fWorld (X east, Y up, Z south)
PARTICLE_CHOICE_PLACEMENT_BODY = §fBody (X right, Y up, Z forward)
PARTICLE_CHOICE_MOVEMENT_ANY = §fAny movement
PARTICLE_CHOICE_MOVEMENT_MOVING = §fOnly while moving
PARTICLE_CHOICE_MOVEMENT_STANDING_STILL = §fOnly while standing still
@@ -142,6 +142,7 @@ PARTICLE_PARAM_OFFSET_X = §fVersatz X
PARTICLE_PARAM_OFFSET_Y = §fVersatz Y
PARTICLE_PARAM_OFFSET_Z = §fVersatz Z
PARTICLE_PARAM_INTERVAL = §fAlle n Ticks
PARTICLE_PARAM_PLACEMENT = §fAusgerichtet an
PARTICLE_PARAM_TICK_MODE = §fAnzeige
PARTICLE_PARAM_MOVEMENT = §fBewegung
PARTICLE_PARAM_GLIDING = §fElytra
@@ -160,6 +161,8 @@ PARTICLE_PARAM_WING_SIZE = §fFlügelgröße
PARTICLE_CHOICE_TICKMODE_MOVE = §fBeim Bewegen
PARTICLE_CHOICE_TICKMODE_ALWAYS = §fImmer
PARTICLE_CHOICE_PLACEMENT_WORLD = §fWelt (X Osten, Y oben, Z Süden)
PARTICLE_CHOICE_PLACEMENT_BODY = §fKörper (X rechts, Y oben, Z vorne)
PARTICLE_CHOICE_MOVEMENT_ANY = §fJede Bewegung
PARTICLE_CHOICE_MOVEMENT_MOVING = §fNur beim Bewegen
PARTICLE_CHOICE_MOVEMENT_STANDING_STILL = §fNur beim Stehen
@@ -0,0 +1,95 @@
/*
* 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;
import lombok.Getter;
import org.bukkit.Location;
import org.bukkit.entity.Player;
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; gliding or swimming the body lies along the look direction.
@Getter
public class BodyFrame {
private static final Vector WORLD_UP = new Vector(0, 1, 0);
private static final double HALF_BODY = 0.9;
private final Location origin;
private final Vector right;
private final Vector up;
private final Vector forward;
// Reference directions of rings around the body; they do not turn with the body
private final Vector ringStart;
private final Vector ringSide;
private BodyFrame(Location origin, Vector right, Vector up, Vector forward) {
this.origin = origin;
this.right = right;
this.up = up;
this.forward = forward;
// World east projected onto the plane around the body, so a standing ring matches the world ring
Vector start = new Vector(1, 0, 0).subtract(up.clone().multiply(up.getX()));
if (start.lengthSquared() < 1e-6) {
start = new Vector(0, 0, 1).subtract(up.clone().multiply(up.getZ()));
}
this.ringStart = start.normalize();
this.ringSide = up.clone().crossProduct(ringStart);
}
public static BodyFrame of(Player player) {
Location location = player.getLocation();
Pose pose = player.getPose();
if (pose == Pose.FALL_FLYING || pose == Pose.SWIMMING) {
Vector up = location.getDirection().normalize();
Vector right = up.clone().crossProduct(WORLD_UP);
if (right.lengthSquared() < 1e-6) {
right = horizontal(player.getBodyYaw()).crossProduct(WORLD_UP);
}
right.normalize();
Vector forward = up.clone().crossProduct(right).normalize();
// The lying body is centred in its short hitbox, its feet lie behind the centre
Location origin = location.clone().add(0, player.getHeight() / 2, 0).subtract(up.clone().multiply(HALF_BODY));
return new BodyFrame(origin, right, up, forward);
}
Vector forward = horizontal(player.getBodyYaw());
Vector right = forward.clone().crossProduct(WORLD_UP).normalize();
return new BodyFrame(location, right, WORLD_UP.clone(), forward);
}
private static Vector horizontal(float yaw) {
double radians = Math.toRadians(yaw);
return new Vector(-Math.sin(radians), 0, Math.cos(radians));
}
public Vector local(double right, double up, double forward) {
return this.right.clone().multiply(right)
.add(this.up.clone().multiply(up))
.add(this.forward.clone().multiply(forward));
}
// A point on a ring around the up axis; angle 0 and the turning direction match the world rings
public Vector ring(double distance, double angle) {
return ringStart.clone().multiply(Math.cos(angle) * distance).add(ringSide.clone().multiply(Math.sin(angle) * distance));
}
}
@@ -41,6 +41,8 @@ public class ParticleTickData {
private final List<Player> viewers;
private boolean onlySelf = false;
private boolean onlyOthers = false;
// Set for entries placed relative to the body, null for world placement
private BodyFrame frame;
public ParticleTickData withLocation(Location location) {
ParticleTickData particleTickData = copy();
@@ -48,6 +50,13 @@ public class ParticleTickData {
return particleTickData;
}
public ParticleTickData withFrame(BodyFrame frame) {
ParticleTickData particleTickData = copy();
particleTickData.frame = frame;
particleTickData.location = frame.getOrigin().clone();
return particleTickData;
}
public ParticleTickData onlySelf() {
ParticleTickData particleTickData = copy();
particleTickData.onlySelf = true;
@@ -68,6 +77,6 @@ public class ParticleTickData {
}
public ParticleTickData copy() {
return new ParticleTickData(world, player, location, deg, isMoving, viewers, onlySelf, onlyOthers);
return new ParticleTickData(world, player, location, deg, isMoving, viewers, onlySelf, onlyOthers, frame);
}
}
@@ -0,0 +1 @@
Relative zur rotation den spielers und co
@@ -94,14 +94,14 @@ public class Designs {
return values.get(WING_IMAGE).getVectors().length;
}
// Wings spawn a particle on every pixel of the image, so they start sparse
// Wings spawn a particle on every pixel of the image, 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.GLIDING, EntryParameters.Gliding.NOT_GLIDING);
.set(EntryParameters.PLACEMENT, EntryParameters.Placement.BODY);
}
};
@@ -37,6 +37,12 @@ public class EntryParameters {
ALWAYS,
}
// Body places offsets, heights, rings and wings around the player's body, even while gliding or swimming
public enum Placement {
WORLD,
BODY,
}
public enum Movement {
ANY(element -> element),
MOVING(element -> data -> {
@@ -183,6 +189,7 @@ public class EntryParameters {
public final DoubleParameter OFFSET_Y = new DoubleParameter("offsetY", "PARTICLE_PARAM_OFFSET_Y", 0, -3, 3, 0.1);
public final DoubleParameter OFFSET_Z = new DoubleParameter("offsetZ", "PARTICLE_PARAM_OFFSET_Z", 0, -3, 3, 0.1);
public final IntParameter INTERVAL = new IntParameter("interval", "PARTICLE_PARAM_INTERVAL", 1, 1, 100);
public final ChoiceParameter<Placement> PLACEMENT = ChoiceParameter.ofEnum("placement", "PARTICLE_PARAM_PLACEMENT", Placement.WORLD);
public final ChoiceParameter<TickMode> TICK_MODE = ChoiceParameter.ofEnum("tickMode", "PARTICLE_PARAM_TICK_MODE", TickMode.MOVE);
public final ChoiceParameter<Movement> MOVEMENT = ChoiceParameter.ofEnum("movement", "PARTICLE_PARAM_MOVEMENT", Movement.ANY);
public final ChoiceParameter<Gliding> GLIDING = ChoiceParameter.ofEnum("gliding", "PARTICLE_PARAM_GLIDING", Gliding.ANY);
@@ -192,7 +199,7 @@ public class EntryParameters {
public final List<Parameter<?>> ALL = List.of(
PARTICLE, COUNT, SPREAD_X, SPREAD_Y, SPREAD_Z, SPEED, COLORS, BLOCK,
OFFSET_X, OFFSET_Y, OFFSET_Z, INTERVAL, TICK_MODE,
OFFSET_X, OFFSET_Y, OFFSET_Z, PLACEMENT, INTERVAL, TICK_MODE,
MOVEMENT, GLIDING, GROUND, SNEAK, VISIBILITY
);
}
@@ -0,0 +1,37 @@
/*
* 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;
import de.steamwar.lobby.particle.BodyFrame;
import de.steamwar.lobby.particle.ParticleElement;
import de.steamwar.lobby.particle.ParticleTickData;
// Places everything inside relative to the player's body instead of the world
public class BodyRelative extends DelegatingParticleElement {
public BodyRelative(ParticleElement particleElement) {
super(particleElement);
}
@Override
public void tick(ParticleTickData particleTickData) {
particleElement.tick(particleTickData.withFrame(BodyFrame.of(particleTickData.getPlayer())));
}
}
@@ -19,6 +19,7 @@
package de.steamwar.lobby.particle.elements;
import de.steamwar.lobby.particle.BodyFrame;
import de.steamwar.lobby.particle.ParticleElement;
import de.steamwar.lobby.particle.ParticleTickData;
import org.bukkit.Location;
@@ -50,6 +51,11 @@ public class Circle extends DelegatingParticleElement {
@Override
public void tick(ParticleTickData particleTickData) {
Location location = particleTickData.getLocation();
BodyFrame frame = particleTickData.getFrame();
if (frame != null) {
particleElement.tick(particleTickData.withLocation(location.clone().add(frame.ring(distance, (particleTickData.getDeg() * speed) % 360 + phase))));
return;
}
Vector vector = new Vector(distance, 0, 0);
vector.rotateAroundY((particleTickData.getDeg() * speed) % 360 + phase);
@@ -19,6 +19,7 @@
package de.steamwar.lobby.particle.elements;
import de.steamwar.lobby.particle.BodyFrame;
import de.steamwar.lobby.particle.ParticleElement;
import de.steamwar.lobby.particle.ParticleTickData;
import de.steamwar.lobby.particle.ParticleTickType;
@@ -57,6 +58,13 @@ public class DoubleCircle extends Circle {
@Override
public void tick(ParticleTickData particleTickData) {
Location location = particleTickData.getLocation();
BodyFrame frame = particleTickData.getFrame();
if (frame != null) {
double angle = (particleTickData.getDeg() * speed) % 360;
particleElement.tick(particleTickData.withLocation(location.clone().add(frame.ring(distance, angle))));
second.tick(particleTickData.withLocation(location.clone().add(frame.ring(distance, angle + Math.PI))));
return;
}
Vector vector = new Vector(distance, 0, 0);
vector.rotateAroundY((particleTickData.getDeg() * speed) % 360);
@@ -19,15 +19,19 @@
package de.steamwar.lobby.particle.elements;
import de.steamwar.lobby.particle.BodyFrame;
import de.steamwar.lobby.particle.ParticleElement;
import de.steamwar.lobby.particle.ParticleTickData;
import org.bukkit.Location;
import org.bukkit.util.Vector;
import java.util.function.UnaryOperator;
public class LocationMutator extends DelegatingParticleElement {
private UnaryOperator<Location> mutator;
// Set for plain offsets, which turn into right/up/forward when placed relative to the body
private Vector offset;
public LocationMutator(ParticleElement particleElement, UnaryOperator<Location> mutator) {
super(particleElement);
@@ -36,10 +40,17 @@ public class LocationMutator extends DelegatingParticleElement {
public LocationMutator(ParticleElement particleElement, double vx, double vy, double vz) {
this(particleElement, location -> location.add(vx, vy, vz));
this.offset = new Vector(vx, vy, vz);
}
@Override
public void tick(ParticleTickData particleTickData) {
BodyFrame frame = particleTickData.getFrame();
if (frame != null && offset != null) {
Location location = particleTickData.getLocation().clone().add(frame.local(offset.getX(), offset.getY(), offset.getZ()));
particleElement.tick(particleTickData.withLocation(location));
return;
}
ParticleTickData nParticleTickData = particleTickData.withLocation(mutator.apply(particleTickData.getLocation()));
particleElement.tick(nParticleTickData);
}
@@ -19,6 +19,7 @@
package de.steamwar.lobby.particle.elements;
import de.steamwar.lobby.particle.BodyFrame;
import de.steamwar.lobby.particle.ParticleElement;
import de.steamwar.lobby.particle.ParticleTickData;
import de.steamwar.lobby.particle.ParticleTickType;
@@ -59,6 +60,14 @@ public class TrippleCircle extends Circle {
@Override
public void tick(ParticleTickData particleTickData) {
Location location = particleTickData.getLocation();
BodyFrame frame = particleTickData.getFrame();
if (frame != null) {
double angle = (particleTickData.getDeg() * speed) % 360;
particleElement.tick(particleTickData.withLocation(location.clone().add(frame.ring(distance, angle))));
second.tick(particleTickData.withLocation(location.clone().add(frame.ring(distance, angle + Math.toRadians(120)))));
third.tick(particleTickData.withLocation(location.clone().add(frame.ring(distance, angle + Math.toRadians(240)))));
return;
}
Vector vector = new Vector(distance, 0, 0);
vector.rotateAroundY((particleTickData.getDeg() * speed) % 360);
@@ -19,6 +19,7 @@
package de.steamwar.lobby.particle.elements;
import de.steamwar.lobby.particle.BodyFrame;
import de.steamwar.lobby.particle.ParticleElement;
import de.steamwar.lobby.particle.ParticleTickData;
import de.steamwar.lobby.particle.WingDesign;
@@ -38,6 +39,17 @@ public class Wing extends DelegatingParticleElement {
@Override
public void tick(ParticleTickData particleTickData) {
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()) {
Vector local = frame.local(dVector.getX() * size, 0.6 + dVector.getY() * size - sneak, -0.5);
particleElement.tick(particleTickData.withLocation(base.clone().add(local)));
}
return;
}
for (Vector dVector : wingDesign.getVectors()) {
Vector vector = new Vector(dVector.getX() * size, 0.6 + dVector.getY() * size - (particleTickData.getPlayer().isSneaking() ? 0.5 : 0), 0.5);
vector.rotateAroundY(Math.toRadians(particleTickData.getPlayer().getLocation().getYaw() * -1));
@@ -19,9 +19,11 @@
package de.steamwar.lobby.particle.elements.custom;
import de.steamwar.lobby.particle.BodyFrame;
import de.steamwar.lobby.particle.ParticleElement;
import de.steamwar.lobby.particle.ParticleTickData;
import de.steamwar.lobby.particle.elements.DelegatingParticleElement;
import org.bukkit.Location;
public class YOffset extends DelegatingParticleElement {
@@ -66,7 +68,9 @@ public class YOffset extends DelegatingParticleElement {
y = minY + (maxY - minY) * value;
}
}
ParticleTickData current = particleTickData.withLocation(particleTickData.getLocation().clone().add(0, y, 0));
BodyFrame frame = particleTickData.getFrame();
Location location = particleTickData.getLocation().clone();
ParticleTickData current = particleTickData.withLocation(frame == null ? location.add(0, y, 0) : location.add(frame.getUp().clone().multiply(y)));
particleElement.tick(current);
}
}
@@ -67,6 +67,7 @@ class EntryEditor {
Map.entry(OFFSET_Z, Material.SPECTRAL_ARROW),
Map.entry(INTERVAL, Material.CLOCK),
Map.entry(TICK_MODE, Material.REPEATER),
Map.entry(PLACEMENT, Material.PLAYER_HEAD),
Map.entry(MOVEMENT, Material.LEATHER_BOOTS),
Map.entry(GLIDING, Material.ELYTRA),
Map.entry(GROUND, Material.GRASS_BLOCK),
@@ -122,7 +123,7 @@ class EntryEditor {
}));
}
row(inventory, context, ROW_SHAPE, design.getParameters());
row(inventory, context, ROW_TIMING, List.of(TICK_MODE, INTERVAL, OFFSET_X, OFFSET_Y, OFFSET_Z));
row(inventory, context, ROW_TIMING, List.of(TICK_MODE, INTERVAL, PLACEMENT, OFFSET_X, OFFSET_Y, OFFSET_Z));
row(inventory, context, ROW_CONDITIONS, List.of(MOVEMENT, GLIDING, GROUND, SNEAK, VISIBILITY));
inventory.setItem(BACK, new SWItem(Material.ARROW, msg("PARTICLE_BACK", player), clickType -> SlotEditor.open(player, index)));
@@ -79,6 +79,9 @@ public class ParticleEntry {
element = withOffset(design.build(values, null));
}
if (values.get(PLACEMENT) == Placement.BODY) {
element = new BodyRelative(element);
}
element = values.get(MOVEMENT).wrap(element);
element = values.get(GLIDING).wrap(element);
element = values.get(GROUND).wrap(element);
@@ -122,7 +122,8 @@ public class Presets {
.with(Designs.WING_SIZE, size)
.with(TICK_MODE, TickMode.ALWAYS)
.with(INTERVAL, interval)
.with(GLIDING, Gliding.NOT_GLIDING);
.with(GLIDING, Gliding.NOT_GLIDING)
.with(PLACEMENT, Placement.WORLD);
}
private ParticleEntry dustWing(Color color, WingDesign image) {