diff --git a/LobbySystem/src/de/steamwar/lobby/LobbySystem.properties b/LobbySystem/src/de/steamwar/lobby/LobbySystem.properties
index 15f50cb3..cc61a9ff 100644
--- a/LobbySystem/src/de/steamwar/lobby/LobbySystem.properties
+++ b/LobbySystem/src/de/steamwar/lobby/LobbySystem.properties
@@ -134,7 +134,6 @@ PARTICLE_VALUE_NO = §cNo
PARTICLE_DESIGN_SIMPLE = §fSimple
PARTICLE_DESIGN_CIRCLE = §fCircle
PARTICLE_DESIGN_WING = §fWings
-PARTICLE_DESIGN_DRAWN_WING = §fDrawn wings
PARTICLE_DESIGN_CLOUD = §fCloud
PARTICLE_PARAM_PARTICLE = §fParticle
@@ -169,7 +168,6 @@ PARTICLE_PARAM_BOUNCE = §fBounce back
PARTICLE_PARAM_INVERTED = §fStart at the top
PARTICLE_PARAM_WING_IMAGE = §fWing shape
PARTICLE_PARAM_WING_SIZE = §fWing size
-PARTICLE_PARAM_DRAWN_SHAPE = §fShape
PARTICLE_PARAM_WING_DETAIL = §fPoints per wing
PARTICLE_PARAM_WING_COLORING = §fColour mode
PARTICLE_PARAM_WING_SPARKLE = §fSparkle (points shown, %)
@@ -196,9 +194,9 @@ PARTICLE_CHOICE_VISIBILITY_ONLY_OTHERS = §fOnly others
PARTICLE_CHOICE_IMAGE_SIMPLE = §fSimple
PARTICLE_CHOICE_IMAGE_COMPLEX = §fComplex
PARTICLE_CHOICE_IMAGE_SWORD = §fSword
-PARTICLE_CHOICE_SHAPE_BAT = §fBat
-PARTICLE_CHOICE_SHAPE_BUTTERFLY = §fButterfly
-PARTICLE_CHOICE_SHAPE_FAIRY = §fFairy
+PARTICLE_CHOICE_IMAGE_BAT = §fBat
+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
diff --git a/LobbySystem/src/de/steamwar/lobby/LobbySystem_de.properties b/LobbySystem/src/de/steamwar/lobby/LobbySystem_de.properties
index 54bd05ec..ac11769d 100644
--- a/LobbySystem/src/de/steamwar/lobby/LobbySystem_de.properties
+++ b/LobbySystem/src/de/steamwar/lobby/LobbySystem_de.properties
@@ -134,7 +134,6 @@ PARTICLE_VALUE_NO = §cNein
PARTICLE_DESIGN_SIMPLE = §fEinfach
PARTICLE_DESIGN_CIRCLE = §fKreis
PARTICLE_DESIGN_WING = §fFlügel
-PARTICLE_DESIGN_DRAWN_WING = §fGezeichnete Flügel
PARTICLE_DESIGN_CLOUD = §fWolke
PARTICLE_PARAM_PARTICLE = §fPartikel
@@ -169,7 +168,6 @@ PARTICLE_PARAM_BOUNCE = §fZurückspringen
PARTICLE_PARAM_INVERTED = §fOben beginnen
PARTICLE_PARAM_WING_IMAGE = §fFlügelform
PARTICLE_PARAM_WING_SIZE = §fFlügelgröße
-PARTICLE_PARAM_DRAWN_SHAPE = §fForm
PARTICLE_PARAM_WING_DETAIL = §fPunkte pro Flügel
PARTICLE_PARAM_WING_COLORING = §fFarbmodus
PARTICLE_PARAM_WING_SPARKLE = §fFunkeln (sichtbare Punkte, %)
@@ -196,9 +194,9 @@ PARTICLE_CHOICE_VISIBILITY_ONLY_OTHERS = §fNur andere
PARTICLE_CHOICE_IMAGE_SIMPLE = §fEinfach
PARTICLE_CHOICE_IMAGE_COMPLEX = §fKomplex
PARTICLE_CHOICE_IMAGE_SWORD = §fSchwert
-PARTICLE_CHOICE_SHAPE_BAT = §fFledermaus
-PARTICLE_CHOICE_SHAPE_BUTTERFLY = §fSchmetterling
-PARTICLE_CHOICE_SHAPE_FAIRY = §fFee
+PARTICLE_CHOICE_IMAGE_BAT = §fFledermaus
+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
diff --git a/LobbySystem/src/de/steamwar/lobby/particle/BodyFrame.java b/LobbySystem/src/de/steamwar/lobby/particle/BodyFrame.java
index 68c5de0d..05ee64d3 100644
--- a/LobbySystem/src/de/steamwar/lobby/particle/BodyFrame.java
+++ b/LobbySystem/src/de/steamwar/lobby/particle/BodyFrame.java
@@ -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; gliding or swimming the body lies along the look direction.
+// Standing it turns with the body (as BodyYaw 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(player.getBodyYaw()).crossProduct(WORLD_UP);
+ right = horizontal(BodyYaw.of(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(player.getBodyYaw());
+ Vector forward = horizontal(BodyYaw.of(player));
Vector right = forward.clone().crossProduct(WORLD_UP).normalize();
return new BodyFrame(location, right, WORLD_UP.clone(), forward);
}
diff --git a/LobbySystem/src/de/steamwar/lobby/particle/BodyYaw.java b/LobbySystem/src/de/steamwar/lobby/particle/BodyYaw.java
new file mode 100644
index 00000000..f757c4c5
--- /dev/null
+++ b/LobbySystem/src/de/steamwar/lobby/particle/BodyYaw.java
@@ -0,0 +1,93 @@
+/*
+ * 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 .
+ */
+
+package de.steamwar.lobby.particle;
+
+import lombok.experimental.UtilityClass;
+import org.bukkit.Location;
+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.
+@UtilityClass
+public class BodyYaw {
+
+ private final float MAX_HEAD_TURN = 50;
+ private final double MOVING = 0.0025;
+
+ private final Map states = new WeakHashMap<>();
+
+ private static class State {
+ private float body;
+ private double x;
+ private double z;
+ }
+
+ // Called once per server tick for every online player
+ public void tick(Player player) {
+ Location location = player.getLocation();
+ State state = states.get(player);
+ if (state == null) {
+ state = new State();
+ state.body = location.getYaw();
+ state.x = location.getX();
+ state.z = location.getZ();
+ states.put(player, state);
+ return;
+ }
+
+ float head = location.getYaw();
+ double dx = location.getX() - state.x;
+ double dz = location.getZ() - state.z;
+ state.x = location.getX();
+ state.z = location.getZ();
+
+ float target = state.body;
+ if (dx * dx + dz * dz > MOVING) {
+ target = (float) Math.toDegrees(Math.atan2(dz, dx)) - 90;
+ // Walking backwards keeps the body facing forwards
+ float away = Math.abs(wrap(head) - target);
+ if (away > 95 && away < 265) {
+ target -= 180;
+ }
+ }
+ state.body += wrap(target - state.body) * 0.3f;
+ float headTurn = wrap(head - state.body);
+ if (Math.abs(headTurn) > MAX_HEAD_TURN) {
+ state.body += headTurn - Math.signum(headTurn) * MAX_HEAD_TURN;
+ }
+ }
+
+ // The tracked body direction in degrees, or the head direction for players not tracked yet
+ public float of(Player player) {
+ State state = states.get(player);
+ return state == null ? player.getLocation().getYaw() : state.body;
+ }
+
+ private float wrap(float degrees) {
+ float wrapped = degrees % 360;
+ if (wrapped >= 180) wrapped -= 360;
+ if (wrapped < -180) wrapped += 360;
+ return wrapped;
+ }
+}
diff --git a/LobbySystem/src/de/steamwar/lobby/particle/CompiledSet.java b/LobbySystem/src/de/steamwar/lobby/particle/CompiledSet.java
index 5a668e2b..3a3b707b 100644
--- a/LobbySystem/src/de/steamwar/lobby/particle/CompiledSet.java
+++ b/LobbySystem/src/de/steamwar/lobby/particle/CompiledSet.java
@@ -38,11 +38,11 @@ public class CompiledSet {
return compiledSet;
}
- // Entries whose design is not unlocked are skipped, not removed
+ // Entries whose design or particle type is not unlocked are skipped, not removed
public static CompiledSet of(ParticleSet particleSet, UnlockContext context) {
CompiledSet compiledSet = new CompiledSet();
for (ParticleEntry entry : particleSet.getEntries()) {
- if (entry.getDesign().getRequirement().test(context)) {
+ if (entry.isUnlocked(context)) {
compiledSet.add(entry.build());
}
}
diff --git a/LobbySystem/src/de/steamwar/lobby/particle/ParticleListener.java b/LobbySystem/src/de/steamwar/lobby/particle/ParticleListener.java
index 9d569ebb..e6aba7a6 100644
--- a/LobbySystem/src/de/steamwar/lobby/particle/ParticleListener.java
+++ b/LobbySystem/src/de/steamwar/lobby/particle/ParticleListener.java
@@ -60,6 +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(player -> {
if (disabled(player)) return;
if (JumpAndRun.isPlayerInJumpAndRun(player)) return;
diff --git a/LobbySystem/src/de/steamwar/lobby/particle/design/Designs.java b/LobbySystem/src/de/steamwar/lobby/particle/design/Designs.java
index c23fe63f..2875ee8b 100644
--- a/LobbySystem/src/de/steamwar/lobby/particle/design/Designs.java
+++ b/LobbySystem/src/de/steamwar/lobby/particle/design/Designs.java
@@ -66,22 +66,29 @@ public class Designs {
}
};
- public final ChoiceParameter WING_IMAGE = new ChoiceParameter<>("image", "PARTICLE_PARAM_WING_IMAGE", WingDesign.SIMPLE, List.of(
- new ChoiceParameter.Choice<>("simple", WingDesign.SIMPLE, false),
- new ChoiceParameter.Choice<>("complex", WingDesign.COMPLEX, false),
- new ChoiceParameter.Choice<>("sword", WingDesign.SWORD, false),
- new ChoiceParameter.Choice<>("sw", WingDesign.SW, true),
- new ChoiceParameter.Choice<>("wgs", WingDesign.WGS, true),
- new ChoiceParameter.Choice<>("sword_crossed", WingDesign.SWORD_CROSSED, true),
- new ChoiceParameter.Choice<>("mwgl", WingDesign.MWGL, true),
- new ChoiceParameter.Choice<>("eclipse", WingDesign.ECLIPSE, true),
- new ChoiceParameter.Choice<>("pl", WingDesign.PL, true),
- new ChoiceParameter.Choice<>("plompa_easter", WingDesign.PlompaEasterWings, true),
- new ChoiceParameter.Choice<>("ely_e", WingDesign.ELY_E, true),
- new ChoiceParameter.Choice<>("ely_l", WingDesign.ELY_L, true),
- new ChoiceParameter.Choice<>("ely_y", WingDesign.ELY_Y, true),
- new ChoiceParameter.Choice<>("ev", WingDesign.EV, true)
- ));
+ // Images keep their old ids, so saved wings still load; preset and tournament images stay out of the builder
+ private final List> WING_SHAPES = List.of(
+ new ChoiceParameter.Choice<>("simple", WingShape.image(WingDesign.SIMPLE), false),
+ new ChoiceParameter.Choice<>("complex", WingShape.image(WingDesign.COMPLEX), false),
+ new ChoiceParameter.Choice<>("sword", WingShape.image(WingDesign.SWORD), false),
+ new ChoiceParameter.Choice<>("bat", WingShape.drawn(DrawnWings.BAT), false),
+ new ChoiceParameter.Choice<>("butterfly", WingShape.drawn(DrawnWings.BUTTERFLY), false),
+ new ChoiceParameter.Choice<>("fairy", WingShape.drawn(DrawnWings.FAIRY), false),
+ new ChoiceParameter.Choice<>("sw", WingShape.image(WingDesign.SW), true),
+ new ChoiceParameter.Choice<>("wgs", WingShape.image(WingDesign.WGS), true),
+ new ChoiceParameter.Choice<>("sword_crossed", WingShape.image(WingDesign.SWORD_CROSSED), true),
+ new ChoiceParameter.Choice<>("mwgl", WingShape.image(WingDesign.MWGL), true),
+ new ChoiceParameter.Choice<>("eclipse", WingShape.image(WingDesign.ECLIPSE), true),
+ new ChoiceParameter.Choice<>("pl", WingShape.image(WingDesign.PL), true),
+ new ChoiceParameter.Choice<>("plompa_easter", WingShape.image(WingDesign.PlompaEasterWings), true),
+ new ChoiceParameter.Choice<>("ely_e", WingShape.image(WingDesign.ELY_E), true),
+ new ChoiceParameter.Choice<>("ely_l", WingShape.image(WingDesign.ELY_L), true),
+ new ChoiceParameter.Choice<>("ely_y", WingShape.image(WingDesign.ELY_Y), true),
+ new ChoiceParameter.Choice<>("ev", WingShape.image(WingDesign.EV), true)
+ );
+ public final ChoiceParameter WING_SHAPE = new ChoiceParameter<>("image", "PARTICLE_PARAM_WING_IMAGE", WING_SHAPES.get(0).value(), WING_SHAPES);
+ // Points per wing of drawn shapes; it replaces the amount, as every point spawns one particle
+ public final IntParameter WING_DETAIL = new IntParameter("detail", "PARTICLE_PARAM_WING_DETAIL", 20, 6, 60);
public final DoubleParameter WING_SIZE = new DoubleParameter("size", "PARTICLE_PARAM_WING_SIZE", 0.15, 0.05, 0.3, 0.01);
public enum WingColoring {
@@ -95,18 +102,31 @@ public class Designs {
// 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 ParticleDesign WING = new ParticleDesign("wing", Material.ELYTRA, "PARTICLE_DESIGN_WING", WING_IMAGE, 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) {
@Override
public ParticleElement build(ParameterValues values, ParticleElement particle) {
- return wing(values, particle, values.get(WING_IMAGE));
+ 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));
}
@Override
public int points(ParameterValues values) {
- return shown(values, values.get(WING_IMAGE).getVectors().length);
+ int points = values.get(WING_SHAPE).points(values.get(WING_DETAIL));
+ return (int) Math.ceil(points * values.get(WING_SPARKLE) / 100.0);
}
- // Wings spawn a particle on every pixel of the image, so they start sparse, and sit on the back even while gliding
+ @Override
+ public boolean showsSetting(Parameter> parameter, ParameterValues values) {
+ return parameter != WING_DETAIL || values.get(WING_SHAPE).isDrawn();
+ }
+
+ @Override
+ public boolean replacesAmount(ParameterValues values) {
+ return values.get(WING_SHAPE).isDrawn();
+ }
+
+ // 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)
@@ -117,27 +137,10 @@ public class Designs {
}
};
- public final ChoiceParameter DRAWN_SHAPE = ChoiceParameter.ofEnum("shape", "PARTICLE_PARAM_DRAWN_SHAPE", DrawnWings.BAT);
- public final IntParameter WING_DETAIL = new IntParameter("detail", "PARTICLE_PARAM_WING_DETAIL", 20, 6, 60);
-
- // Wings drawn from lines, with a chosen number of points per wing; sparse ones stay cheap enough to show often
- public final ParticleDesign DRAWN_WING = new ParticleDesign("drawn_wing", Material.FEATHER, "PARTICLE_DESIGN_DRAWN_WING",
- DRAWN_SHAPE, WING_DETAIL, WING_SIZE, WING_COLORING, WING_SPARKLE) {
- @Override
- public ParticleElement build(ParameterValues values, ParticleElement particle) {
- return wing(values, particle, values.get(DRAWN_SHAPE).withDetail(values.get(WING_DETAIL)));
- }
-
- @Override
- public int points(ParameterValues values) {
- return shown(values, 2 * values.get(WING_DETAIL));
- }
-
- @Override
- public void applyDefaults(ParameterValues values) {
- WING.applyDefaults(values);
- }
- };
+ // The wing shape that shows the given image, for presets
+ public WingShape wingShape(WingDesign image) {
+ return WING_SHAPE.getChoices().stream().map(ChoiceParameter.Choice::value).filter(shape -> shape.shows(image)).findFirst().orElseThrow();
+ }
public final ParticleDesign CLOUD = new ParticleDesign("cloud", Material.WHITE_WOOL, "PARTICLE_DESIGN_CLOUD") {
@Override
@@ -146,19 +149,10 @@ public class Designs {
}
};
- private ParticleElement wing(ParameterValues values, ParticleElement particle, WingDesign shape) {
- return new Wing(particle, values.get(WING_SIZE), shape, values.get(WING_COLORING) == WingColoring.ALONG_WING, values.get(WING_SPARKLE));
- }
-
- // Points shown per tick on average with the sparkle setting
- private int shown(ParameterValues values, int points) {
- return (int) Math.ceil(points * values.get(WING_SPARKLE) / 100.0);
- }
-
private Map registry() {
if (registry == null) {
Map designs = new LinkedHashMap<>();
- List.of(SIMPLE, CIRCLE, WING, DRAWN_WING, CLOUD).forEach(design -> register(designs, design));
+ List.of(SIMPLE, CIRCLE, WING, CLOUD).forEach(design -> register(designs, design));
RewardDesigns.all().forEach(design -> register(designs, design));
InternalDesigns.all().forEach(design -> register(designs, design));
registry = designs;
diff --git a/LobbySystem/src/de/steamwar/lobby/particle/design/ParticleDesign.java b/LobbySystem/src/de/steamwar/lobby/particle/design/ParticleDesign.java
index ce59f23b..65f8143b 100644
--- a/LobbySystem/src/de/steamwar/lobby/particle/design/ParticleDesign.java
+++ b/LobbySystem/src/de/steamwar/lobby/particle/design/ParticleDesign.java
@@ -62,6 +62,16 @@ public abstract class ParticleDesign {
return true;
}
+ // Whether the entry editor shows this design setting; some only matter for some values of others
+ public boolean showsSetting(Parameter> parameter, ParameterValues values) {
+ return true;
+ }
+
+ // True when the design decides how many particles each point spawns, so the amount setting is hidden and counts as 1
+ public boolean replacesAmount(ParameterValues values) {
+ return false;
+ }
+
// Sets the conditions and timing a new entry of this design starts with
public void applyDefaults(ParameterValues values) {
}
diff --git a/LobbySystem/src/de/steamwar/lobby/particle/design/ParticleType.java b/LobbySystem/src/de/steamwar/lobby/particle/design/ParticleType.java
index f80d76cf..4a4945e1 100644
--- a/LobbySystem/src/de/steamwar/lobby/particle/design/ParticleType.java
+++ b/LobbySystem/src/de/steamwar/lobby/particle/design/ParticleType.java
@@ -19,6 +19,7 @@
package de.steamwar.lobby.particle.design;
+import de.steamwar.lobby.particle.ParticleRequirement;
import lombok.AllArgsConstructor;
import lombok.Getter;
import org.bukkit.Material;
@@ -179,6 +180,14 @@ public enum ParticleType {
return data == Data.DUST || data == Data.COLOR || data == Data.COLOR_TRANSITION || data == Data.TRAIL;
}
+ // Leaves drift down slowly and pile up around a player, so only the server team may build with them
+ public ParticleRequirement getRequirement() {
+ return switch (this) {
+ case CHERRY_LEAVES, PALE_OAK_LEAVES, TINTED_LEAVES -> ParticleRequirement.SERVER_TEAM;
+ default -> ParticleRequirement.NO_REQUIRMENT;
+ };
+ }
+
public boolean isTrail() {
return data == Data.TRAIL;
}
diff --git a/LobbySystem/src/de/steamwar/lobby/particle/design/WingShape.java b/LobbySystem/src/de/steamwar/lobby/particle/design/WingShape.java
new file mode 100644
index 00000000..80970c42
--- /dev/null
+++ b/LobbySystem/src/de/steamwar/lobby/particle/design/WingShape.java
@@ -0,0 +1,59 @@
+/*
+ * 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 .
+ */
+
+package de.steamwar.lobby.particle.design;
+
+import de.steamwar.lobby.particle.DrawnWings;
+import de.steamwar.lobby.particle.WingDesign;
+
+// A wing shape of the wings design: either a fixed image or a shape drawn with a chosen number of points
+public class WingShape {
+
+ private final WingDesign image;
+ private final DrawnWings drawn;
+
+ private WingShape(WingDesign image, DrawnWings drawn) {
+ this.image = image;
+ this.drawn = drawn;
+ }
+
+ public static WingShape image(WingDesign image) {
+ return new WingShape(image, null);
+ }
+
+ public static WingShape drawn(DrawnWings drawn) {
+ return new WingShape(null, drawn);
+ }
+
+ public boolean isDrawn() {
+ return drawn != null;
+ }
+
+ public boolean shows(WingDesign image) {
+ return this.image == image;
+ }
+
+ public WingDesign design(int pointsPerWing) {
+ return isDrawn() ? drawn.withDetail(pointsPerWing) : image;
+ }
+
+ public int points(int pointsPerWing) {
+ return isDrawn() ? 2 * pointsPerWing : image.getVectors().length;
+ }
+}
diff --git a/LobbySystem/src/de/steamwar/lobby/particle/elements/Wing.java b/LobbySystem/src/de/steamwar/lobby/particle/elements/Wing.java
index f0778880..009bc753 100644
--- a/LobbySystem/src/de/steamwar/lobby/particle/elements/Wing.java
+++ b/LobbySystem/src/de/steamwar/lobby/particle/elements/Wing.java
@@ -20,6 +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.ParticleElement;
import de.steamwar.lobby.particle.ParticleTickData;
import de.steamwar.lobby.particle.WingDesign;
@@ -68,7 +69,7 @@ public class Wing extends DelegatingParticleElement {
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(particleTickData.getPlayer().getLocation().getYaw() * -1));
+ vector.rotateAroundY(Math.toRadians(BodyYaw.of(particleTickData.getPlayer()) * -1));
vector.setX(-vector.getX());
vector.setZ(-vector.getZ());
Location location = particleTickData.getPlayer().getLocation().clone().add(vector);
diff --git a/LobbySystem/src/de/steamwar/lobby/particle/elements/custom/HearthBeat.java b/LobbySystem/src/de/steamwar/lobby/particle/elements/custom/HearthBeat.java
index b6ffd443..b8071b0d 100644
--- a/LobbySystem/src/de/steamwar/lobby/particle/elements/custom/HearthBeat.java
+++ b/LobbySystem/src/de/steamwar/lobby/particle/elements/custom/HearthBeat.java
@@ -19,6 +19,7 @@
package de.steamwar.lobby.particle.elements.custom;
+import de.steamwar.lobby.particle.BodyYaw;
import de.steamwar.lobby.particle.ParticleElement;
import de.steamwar.lobby.particle.ParticleTickData;
import de.steamwar.lobby.particle.elements.DelegatingParticleElement;
@@ -48,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(particleTickData.getPlayer().getLocation().getYaw() * -1));
+ vector.rotateAroundY(Math.toRadians(BodyYaw.of(particleTickData.getPlayer()) * -1));
vector.setX(-vector.getX());
vector.setZ(-vector.getZ());
Location location = particleTickData.getPlayer().getLocation().clone().add(vector);
diff --git a/LobbySystem/src/de/steamwar/lobby/particle/gui/EntryEditor.java b/LobbySystem/src/de/steamwar/lobby/particle/gui/EntryEditor.java
index b2d55a44..af539ed3 100644
--- a/LobbySystem/src/de/steamwar/lobby/particle/gui/EntryEditor.java
+++ b/LobbySystem/src/de/steamwar/lobby/particle/gui/EntryEditor.java
@@ -22,8 +22,8 @@ package de.steamwar.lobby.particle.gui;
import de.steamwar.inventory.SWInventory;
import de.steamwar.inventory.SWItem;
import de.steamwar.lobby.LobbySystem;
+import de.steamwar.lobby.particle.ParticleRequirement;
import de.steamwar.lobby.particle.UnlockContext;
-import de.steamwar.lobby.particle.WingDesign;
import de.steamwar.lobby.particle.design.*;
import de.steamwar.lobby.particle.slot.ParticleEntry;
import de.steamwar.lobby.particle.slot.ParticleSlot;
@@ -85,9 +85,8 @@ class EntryEditor {
Map.entry(Designs.CIRCLE_PHASE, Material.TARGET),
Map.entry(Designs.CIRCLE_RISE, Material.LADDER),
Map.entry(Designs.CIRCLE_RISE_SPEED, Material.RABBIT_FOOT),
- Map.entry(Designs.WING_IMAGE, Material.PHANTOM_MEMBRANE),
+ Map.entry(Designs.WING_SHAPE, Material.PHANTOM_MEMBRANE),
Map.entry(Designs.WING_SIZE, Material.SCAFFOLDING),
- Map.entry(Designs.DRAWN_SHAPE, Material.FEATHER),
Map.entry(Designs.WING_DETAIL, Material.AMETHYST_SHARD),
Map.entry(Designs.WING_COLORING, Material.CYAN_DYE),
Map.entry(Designs.WING_SPARKLE, Material.AMETHYST_CLUSTER)
@@ -134,12 +133,13 @@ class EntryEditor {
inventory.setItem(ROW_DESIGN * 9 + FIRST_SETTING + 3 + i, parameterItem(context, trail.get(i)));
}
}
- row(inventory, context, ROW_PARTICLE, List.of(COUNT, SPEED, SPREAD_X, SPREAD_Y, SPREAD_Z));
+ List> particleRow = design.replacesAmount(entry.getValues()) ? List.of(SPEED, SPREAD_X, SPREAD_Y, SPREAD_Z) : List.of(COUNT, SPEED, SPREAD_X, SPREAD_Y, SPREAD_Z);
+ row(inventory, context, ROW_PARTICLE, particleRow);
} else {
inventory.setItem(ROW_PARTICLE * 9 + FIRST_SETTING, new SWItem(Material.STRUCTURE_VOID, msg("PARTICLE_FIXED_LOOK", player), clickType -> {
}));
}
- row(inventory, context, ROW_SHAPE, design.getParameters());
+ row(inventory, context, ROW_SHAPE, design.getParameters().stream().filter(parameter -> design.showsSetting(parameter, entry.getValues())).toList());
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));
@@ -240,7 +240,8 @@ class EntryEditor {
}
private void pick(Context context, ChoiceParameter parameter, E current) {
- Pickers.choice(context.player(), parameter, current, EntryEditor::choiceIcon, picked -> {
+ Pickers.choice(context.player(), parameter, current, EntryEditor::choiceIcon,
+ value -> value instanceof ParticleType type ? type.getRequirement() : ParticleRequirement.NO_REQUIRMENT, picked -> {
apply(context, parameter, picked);
context.reopen();
}, context::reopen);
@@ -248,7 +249,7 @@ class EntryEditor {
private Material choiceIcon(Object value) {
if (value instanceof ParticleType type) return type.getIcon();
- if (value instanceof WingDesign) return Material.PHANTOM_MEMBRANE;
+ if (value instanceof WingShape) return Material.PHANTOM_MEMBRANE;
if (value instanceof Material material) return material;
return Material.LEVER;
}
diff --git a/LobbySystem/src/de/steamwar/lobby/particle/gui/ParticleGuiText.java b/LobbySystem/src/de/steamwar/lobby/particle/gui/ParticleGuiText.java
index ebe9f0de..f1365f77 100644
--- a/LobbySystem/src/de/steamwar/lobby/particle/gui/ParticleGuiText.java
+++ b/LobbySystem/src/de/steamwar/lobby/particle/gui/ParticleGuiText.java
@@ -127,7 +127,7 @@ class ParticleGuiText {
addCondition(lore, SNEAK, values.get(SNEAK), Sneak.ANY, player);
addCondition(lore, VISIBILITY, values.get(VISIBILITY), Visibility.EVERYONE, player);
lore.add(msg("PARTICLE_ENTRY_COST", player, number(entry.cost())));
- if (!entry.getDesign().getRequirement().test(context)) {
+ if (!entry.isUnlocked(context)) {
lore.add(msg("PARTICLE_ENTRY_LOCKED", player));
}
return lore;
diff --git a/LobbySystem/src/de/steamwar/lobby/particle/gui/Pickers.java b/LobbySystem/src/de/steamwar/lobby/particle/gui/Pickers.java
index 88ca01f0..e7f92456 100644
--- a/LobbySystem/src/de/steamwar/lobby/particle/gui/Pickers.java
+++ b/LobbySystem/src/de/steamwar/lobby/particle/gui/Pickers.java
@@ -22,6 +22,7 @@ package de.steamwar.lobby.particle.gui;
import de.steamwar.inventory.SWInventory;
import de.steamwar.inventory.SWItem;
import de.steamwar.inventory.SWListInv;
+import de.steamwar.lobby.particle.ParticleRequirement;
import de.steamwar.lobby.particle.UnlockContext;
import de.steamwar.lobby.particle.design.*;
import de.steamwar.sql.SteamwarUser;
@@ -62,13 +63,18 @@ class Pickers {
open(player, msg("PARTICLE_DESIGN_PICKER", player), list, onPick, onBack);
}
- // Lists every option; the current one glows
- void choice(Player player, ChoiceParameter parameter, E current, Function icon, Consumer onPick, Runnable onBack) {
+ // Lists every option; the current one glows, locked ones show their requirement and cannot be picked
+ void choice(Player player, ChoiceParameter parameter, E current, Function icon, Function requirement,
+ Consumer onPick, Runnable onBack) {
+ UnlockContext context = new UnlockContext(SteamwarUser.get(player.getUniqueId()));
List> list = new ArrayList<>();
for (E value : parameter.getBuildableValues()) {
- List lore = List.of(msg(value == current ? "PARTICLE_VALUE_SELECTED" : "PARTICLE_SELECT", player));
+ boolean unlocked = requirement.apply(value).test(context);
+ List lore = new ArrayList<>(requirement(requirement.apply(value), player));
+ if (!lore.isEmpty()) lore.add("");
+ lore.add(msg(value == current ? "PARTICLE_VALUE_SELECTED" : unlocked ? "PARTICLE_SELECT" : "PARTICLE_LOCKED", player));
list.add(new SWListInv.SWListEntry<>(new SWItem(icon.apply(value), choiceName(parameter, value, player), lore, value == current, clickType -> {
- }), value));
+ }), unlocked ? value : null));
}
open(player, msg("PARTICLE_CHOICE_PICKER", player, name(parameter.getNameKey(), player)), list, onPick, onBack);
}
diff --git a/LobbySystem/src/de/steamwar/lobby/particle/gui/ValueEditors.java b/LobbySystem/src/de/steamwar/lobby/particle/gui/ValueEditors.java
index 156dff9d..e1d60ed3 100644
--- a/LobbySystem/src/de/steamwar/lobby/particle/gui/ValueEditors.java
+++ b/LobbySystem/src/de/steamwar/lobby/particle/gui/ValueEditors.java
@@ -168,13 +168,25 @@ class ValueEditors {
return new Color(Integer.parseInt(hex, 16));
}
+ // The dye whose colour is closest to the given one, so typed hex colours get a matching icon
private Material dye(Color color) {
+ DyeColor closest = DyeColor.WHITE;
+ double best = Double.MAX_VALUE;
for (DyeColor dye : DyeColor.values()) {
- if ((dye.getColor().asRGB() & 0xFFFFFF) == (color.getRGB() & 0xFFFFFF)) {
- return Material.valueOf(dye.name() + "_DYE");
+ double distance = distance(color, new Color(dye.getColor().asRGB()));
+ if (distance < best) {
+ best = distance;
+ closest = dye;
}
}
- return Material.WHITE_DYE;
+ return Material.valueOf(closest.name() + "_DYE");
+ }
+
+ // Colour distance weighted by how sensitive the eye is to red, green and blue ("redmean")
+ private double distance(Color a, Color b) {
+ double redMean = (a.getRed() + b.getRed()) / 2.0;
+ int dr = a.getRed() - b.getRed(), dg = a.getGreen() - b.getGreen(), db = a.getBlue() - b.getBlue();
+ return (2 + redMean / 256) * dr * dr + 4 * dg * dg + (2 + (255 - redMean) / 256) * db * db;
}
private String strip(String text) {
diff --git a/LobbySystem/src/de/steamwar/lobby/particle/slot/ParticleEntry.java b/LobbySystem/src/de/steamwar/lobby/particle/slot/ParticleEntry.java
index fd240eae..fcb79a11 100644
--- a/LobbySystem/src/de/steamwar/lobby/particle/slot/ParticleEntry.java
+++ b/LobbySystem/src/de/steamwar/lobby/particle/slot/ParticleEntry.java
@@ -24,6 +24,7 @@ import com.google.gson.JsonObject;
import de.steamwar.lobby.particle.Gradient;
import de.steamwar.lobby.particle.ParticleElement;
import de.steamwar.lobby.particle.ParticleTickData;
+import de.steamwar.lobby.particle.UnlockContext;
import de.steamwar.lobby.particle.design.*;
import de.steamwar.lobby.particle.elements.*;
import lombok.Getter;
@@ -106,7 +107,7 @@ public class ParticleEntry {
float spreadY = values.get(SPREAD_Y).floatValue();
float spreadZ = values.get(SPREAD_Z).floatValue();
double speed = values.get(SPEED);
- int count = values.get(COUNT);
+ int count = amount();
List colors = values.get(COLORS);
Gradient gradient = colors.isEmpty() ? null : new Gradient(colors.toArray(new Color[0]));
@@ -162,9 +163,22 @@ public class ParticleEntry {
return new LocationMutator(element, offsetX, offsetY, offsetZ);
}
+ // 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)) {
+ return false;
+ }
+ return !design.usesEntryParticle() || values.get(PARTICLE).getRequirement().test(context);
+ }
+
+ // Particles each point spawns; designs that set their own point count replace the amount with 1
+ private int amount() {
+ return design.replacesAmount(values) ? 1 : values.get(COUNT);
+ }
+
// Particle positions spawned per tick, used for the slot budget
public double cost() {
- double perTick = design.usesEntryParticle() ? (double) design.points(values) * values.get(COUNT) : design.points(values);
+ double perTick = design.usesEntryParticle() ? (double) design.points(values) * amount() : design.points(values);
return perTick / values.get(INTERVAL);
}
diff --git a/LobbySystem/src/de/steamwar/lobby/particle/slot/Presets.java b/LobbySystem/src/de/steamwar/lobby/particle/slot/Presets.java
index 413b9103..930bb3a5 100644
--- a/LobbySystem/src/de/steamwar/lobby/particle/slot/Presets.java
+++ b/LobbySystem/src/de/steamwar/lobby/particle/slot/Presets.java
@@ -118,7 +118,7 @@ public class Presets {
private ParticleEntry wing(ParticleType type, double size, WingDesign image, int interval) {
return particle(Designs.WING, type, 0, 0, 1)
- .with(Designs.WING_IMAGE, image)
+ .with(Designs.WING_SHAPE, Designs.wingShape(image))
.with(Designs.WING_SIZE, size)
.with(TICK_MODE, TickMode.ALWAYS)
.with(INTERVAL, interval)