diff --git a/LobbySystem/src/de/steamwar/lobby/LobbySystem.properties b/LobbySystem/src/de/steamwar/lobby/LobbySystem.properties
index 5af7324d..40477799 100644
--- a/LobbySystem/src/de/steamwar/lobby/LobbySystem.properties
+++ b/LobbySystem/src/de/steamwar/lobby/LobbySystem.properties
@@ -134,6 +134,7 @@ 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
@@ -168,6 +169,8 @@ 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_CHOICE_TICKMODE_MOVE = §fWhile moving
PARTICLE_CHOICE_TICKMODE_ALWAYS = §fAlways
@@ -191,6 +194,11 @@ 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_ANGEL = §fAngel
+PARTICLE_CHOICE_SHAPE_BAT = §fBat
+PARTICLE_CHOICE_SHAPE_BUTTERFLY = §fButterfly
+PARTICLE_CHOICE_SHAPE_FAIRY = §fFairy
+PARTICLE_CHOICE_SHAPE_PHOENIX = §fPhoenix
# Particle types
PARTICLE_TYPE_DUST = §fDust
diff --git a/LobbySystem/src/de/steamwar/lobby/LobbySystem_de.properties b/LobbySystem/src/de/steamwar/lobby/LobbySystem_de.properties
index 14879b40..f1da7ad6 100644
--- a/LobbySystem/src/de/steamwar/lobby/LobbySystem_de.properties
+++ b/LobbySystem/src/de/steamwar/lobby/LobbySystem_de.properties
@@ -134,6 +134,7 @@ 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
@@ -168,6 +169,8 @@ 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_CHOICE_TICKMODE_MOVE = §fBeim Bewegen
PARTICLE_CHOICE_TICKMODE_ALWAYS = §fImmer
@@ -191,6 +194,11 @@ 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_ANGEL = §fEngel
+PARTICLE_CHOICE_SHAPE_BAT = §fFledermaus
+PARTICLE_CHOICE_SHAPE_BUTTERFLY = §fSchmetterling
+PARTICLE_CHOICE_SHAPE_FAIRY = §fFee
+PARTICLE_CHOICE_SHAPE_PHOENIX = §fPhönix
# Particle types
PARTICLE_TYPE_DUST = §fStaub
diff --git a/LobbySystem/src/de/steamwar/lobby/particle/DrawnWings.java b/LobbySystem/src/de/steamwar/lobby/particle/DrawnWings.java
new file mode 100644
index 00000000..ec31c586
--- /dev/null
+++ b/LobbySystem/src/de/steamwar/lobby/particle/DrawnWings.java
@@ -0,0 +1,186 @@
+/*
+ * 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 org.bukkit.util.Vector;
+
+import java.util.ArrayList;
+import java.util.HashMap;
+import java.util.List;
+import java.util.Map;
+
+// Wing shapes drawn from lines and curves instead of images, so their number of points can be chosen.
+// Coordinates use the same units as the wing images: x is sideways from the spine, y upwards, about 11 by 13.
+public enum DrawnWings {
+
+ // A curved upper edge, a row of covert feathers and long flight feathers ending in a scalloped edge
+ ANGEL {
+ @Override
+ List> rightWing() {
+ List> lines = new ArrayList<>();
+ lines.add(curve(p(0.5, 7), p(3, 10), p(6, 11.8), p(9, 12.4), p(10.8, 11.2), p(11, 8.5)));
+ lines.add(curve(p(1, 6.6), p(4, 8.3), p(7, 9.3), p(10, 9.8)));
+ lines.add(lineThrough(p(0.5, 5.5), p(2.2, 3.8), p(3.4, 4.6), p(4.2, 2.8), p(5.4, 3.9), p(6.3, 2.6), p(7.5, 4),
+ p(8.3, 3.4), p(9.4, 5.2), p(10, 5), p(10.7, 6.6), p(11, 7.5), p(11, 8.5)));
+ return lines;
+ }
+ },
+ // An arm bone along the top, fingers spreading from the wrist and skin hanging between them
+ BAT {
+ @Override
+ List> rightWing() {
+ List> lines = new ArrayList<>();
+ lines.add(curve(p(0.5, 7), p(3, 9.5), p(6, 11), p(11, 12.5)));
+ lines.add(curve(p(6, 11), p(7, 4)));
+ lines.add(curve(p(6, 11), p(9, 6)));
+ lines.add(curve(p(6, 11), p(10.8, 9)));
+ lines.add(curve(p(0.5, 5), p(3.5, 4.6), p(5.5, 3.4), p(7, 4), p(7.6, 5.4), p(9, 6), p(9.6, 7.6), p(10.8, 9), p(11.2, 10.8), p(11, 12.5)));
+ return lines;
+ }
+ },
+ // The butterfly curve r = e^cos(t) - 2 cos(4t), which already holds both wings; its small loops around the body are left out
+ BUTTERFLY {
+ @Override
+ List> rightWing() {
+ List> lines = new ArrayList<>();
+ List current = new ArrayList<>();
+ for (int i = 0; i <= 400; i++) {
+ double t = Math.PI * i / 400;
+ double r = Math.exp(Math.cos(t)) - 2 * Math.cos(4 * t);
+ if (Math.abs(r) < 0.9) {
+ if (current.size() > 1) lines.add(current);
+ current = new ArrayList<>();
+ continue;
+ }
+ current.add(p(Math.sin(t) * r * 3, Math.cos(t) * r * 2 + 6));
+ }
+ if (current.size() > 1) lines.add(current);
+ return lines;
+ }
+ },
+ // Two thin rounded pairs, a long one above and a short one below
+ FAIRY {
+ @Override
+ List> rightWing() {
+ return List.of(ellipse(5.2, 9.3, 5, 1.9, 35), ellipse(3.8, 4.6, 3.6, 1.5, -30));
+ }
+ },
+ // An upswept lower edge with flame tongues rising from it, each bending out and curling back at the tip
+ PHOENIX {
+ @Override
+ List> rightWing() {
+ List> lines = new ArrayList<>();
+ lines.add(curve(p(0.5, 5), p(4, 5.2), p(8, 7), p(11, 10), p(12, 13)));
+ double[][] tongues = {{1.5, 5.2, 2.5, 10}, {4, 5.4, 5.5, 11.5}, {6.5, 6.2, 8.2, 12.8}, {9, 8, 10.5, 14}};
+ for (double[] tongue : tongues) {
+ lines.add(curve(p(tongue[0], tongue[1]), p(tongue[0] + 1.2, tongue[1] + 1.8), p(tongue[2] - 0.8, tongue[3] - 1.5), p(tongue[2], tongue[3])));
+ }
+ return lines;
+ }
+ },
+ ;
+
+ // The right wing as lines; the left wing mirrors it
+ abstract List> rightWing();
+
+ private static final Map CACHE = new HashMap<>();
+
+ // Both wings with the given number of points per wing, spread evenly along the lines
+ public WingDesign withDetail(int pointsPerWing) {
+ return CACHE.computeIfAbsent(name() + "@" + pointsPerWing, key -> {
+ List right = sample(rightWing(), pointsPerWing);
+ List both = new ArrayList<>(right);
+ right.forEach(point -> both.add(new Vector(-point.getX(), point.getY(), 0)));
+ return new WingDesign.WingDesignImpl(both.toArray(new Vector[0]));
+ });
+ }
+
+ private static List sample(List> lines, int count) {
+ double total = lines.stream().mapToDouble(DrawnWings::length).sum();
+ List points = new ArrayList<>();
+ for (int i = 0; i < count; i++) {
+ double distance = total * (i + 0.5) / count;
+ for (List line : lines) {
+ double length = length(line);
+ if (distance <= length) {
+ points.add(at(line, distance));
+ break;
+ }
+ distance -= length;
+ }
+ }
+ return points;
+ }
+
+ private static double length(List line) {
+ double length = 0;
+ for (int i = 1; i < line.size(); i++) length += line.get(i).distance(line.get(i - 1));
+ return length;
+ }
+
+ private static Vector at(List line, double distance) {
+ for (int i = 1; i < line.size(); i++) {
+ double segment = line.get(i).distance(line.get(i - 1));
+ if (distance <= segment) {
+ return line.get(i - 1).clone().add(line.get(i).clone().subtract(line.get(i - 1)).multiply(segment == 0 ? 0 : distance / segment));
+ }
+ distance -= segment;
+ }
+ return line.get(line.size() - 1).clone();
+ }
+
+ private static Vector p(double x, double y) {
+ return new Vector(x, y, 0);
+ }
+
+ // A smooth curve through the given points (Catmull-Rom), as many short segments
+ private static List curve(Vector... points) {
+ List result = new ArrayList<>();
+ for (int i = 0; i < points.length - 1; i++) {
+ Vector p0 = points[Math.max(0, i - 1)], p1 = points[i], p2 = points[i + 1], p3 = points[Math.min(points.length - 1, i + 2)];
+ for (int step = 0; step < 16; step++) {
+ double t = step / 16.0, t2 = t * t, t3 = t2 * t;
+ result.add(p1.clone().multiply(2)
+ .add(p2.clone().subtract(p0).multiply(t))
+ .add(p0.clone().multiply(2).subtract(p1.clone().multiply(5)).add(p2.clone().multiply(4)).subtract(p3).multiply(t2))
+ .add(p1.clone().multiply(3).subtract(p0).subtract(p2.clone().multiply(3)).add(p3).multiply(t3))
+ .multiply(0.5));
+ }
+ }
+ result.add(points[points.length - 1].clone());
+ return result;
+ }
+
+ // Straight segments between the points, for sharp corners like feather tips
+ private static List lineThrough(Vector... points) {
+ return new ArrayList<>(List.of(points));
+ }
+
+ private static List ellipse(double centerX, double centerY, double radiusX, double radiusY, double tiltDegrees) {
+ double tilt = Math.toRadians(tiltDegrees);
+ List result = new ArrayList<>();
+ for (int i = 0; i <= 64; i++) {
+ double a = 2 * Math.PI * i / 64;
+ double x = Math.cos(a) * radiusX, y = Math.sin(a) * radiusY;
+ result.add(p(centerX + x * Math.cos(tilt) - y * Math.sin(tilt), centerY + x * Math.sin(tilt) + y * Math.cos(tilt)));
+ }
+ return result;
+ }
+}
diff --git a/LobbySystem/src/de/steamwar/lobby/particle/design/Designs.java b/LobbySystem/src/de/steamwar/lobby/particle/design/Designs.java
index 4210da24..c6a351bb 100644
--- a/LobbySystem/src/de/steamwar/lobby/particle/design/Designs.java
+++ b/LobbySystem/src/de/steamwar/lobby/particle/design/Designs.java
@@ -20,6 +20,7 @@
package de.steamwar.lobby.particle.design;
import de.steamwar.lobby.particle.ParticleElement;
+import de.steamwar.lobby.particle.DrawnWings;
import de.steamwar.lobby.particle.WingDesign;
import de.steamwar.lobby.particle.elements.Circle;
import de.steamwar.lobby.particle.elements.Cloud;
@@ -105,6 +106,27 @@ public class Designs {
}
};
+ public final ChoiceParameter DRAWN_SHAPE = ChoiceParameter.ofEnum("shape", "PARTICLE_PARAM_DRAWN_SHAPE", DrawnWings.ANGEL);
+ 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) {
+ @Override
+ public ParticleElement build(ParameterValues values, ParticleElement particle) {
+ return new Wing(particle, values.get(WING_SIZE), values.get(DRAWN_SHAPE).withDetail(values.get(WING_DETAIL)));
+ }
+
+ @Override
+ public int points(ParameterValues values) {
+ return 2 * values.get(WING_DETAIL);
+ }
+
+ @Override
+ public void applyDefaults(ParameterValues values) {
+ WING.applyDefaults(values);
+ }
+ };
+
public final ParticleDesign CLOUD = new ParticleDesign("cloud", Material.WHITE_WOOL, "PARTICLE_DESIGN_CLOUD") {
@Override
public ParticleElement build(ParameterValues values, ParticleElement particle) {
@@ -115,7 +137,7 @@ public class Designs {
private Map registry() {
if (registry == null) {
Map designs = new LinkedHashMap<>();
- List.of(SIMPLE, CIRCLE, WING, CLOUD).forEach(design -> register(designs, design));
+ List.of(SIMPLE, CIRCLE, WING, DRAWN_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/gui/EntryEditor.java b/LobbySystem/src/de/steamwar/lobby/particle/gui/EntryEditor.java
index f9e2d135..10e79cca 100644
--- a/LobbySystem/src/de/steamwar/lobby/particle/gui/EntryEditor.java
+++ b/LobbySystem/src/de/steamwar/lobby/particle/gui/EntryEditor.java
@@ -86,7 +86,9 @@ class EntryEditor {
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_SIZE, Material.SCAFFOLDING)
+ Map.entry(Designs.WING_SIZE, Material.SCAFFOLDING),
+ Map.entry(Designs.DRAWN_SHAPE, Material.FEATHER),
+ Map.entry(Designs.WING_DETAIL, Material.AMETHYST_SHARD)
);
private record Context(Player player, SteamwarUser user, int index, int entryIndex, ParticleSlot slot, ParticleEntry entry) {