Add drawn wing shapes to the slot builder

A new Drawn wings design draws angel, bat, butterfly, fairy and phoenix
wings from lines and curves instead of images. The points per wing can be
set from 6 to 60, so sparse wings stay cheap enough to show often.
This commit is contained in:
2026-10-26 10:48:32 +01:00
parent 8e23490b04
commit a5b8f00c58
5 changed files with 228 additions and 2 deletions
@@ -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
@@ -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
@@ -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 <https://www.gnu.org/licenses/>.
*/
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<List<Vector>> rightWing() {
List<List<Vector>> 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<List<Vector>> rightWing() {
List<List<Vector>> 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<List<Vector>> rightWing() {
List<List<Vector>> lines = new ArrayList<>();
List<Vector> 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<List<Vector>> 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<List<Vector>> rightWing() {
List<List<Vector>> 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<List<Vector>> rightWing();
private static final Map<String, WingDesign> 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<Vector> right = sample(rightWing(), pointsPerWing);
List<Vector> 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<Vector> sample(List<List<Vector>> lines, int count) {
double total = lines.stream().mapToDouble(DrawnWings::length).sum();
List<Vector> points = new ArrayList<>();
for (int i = 0; i < count; i++) {
double distance = total * (i + 0.5) / count;
for (List<Vector> line : lines) {
double length = length(line);
if (distance <= length) {
points.add(at(line, distance));
break;
}
distance -= length;
}
}
return points;
}
private static double length(List<Vector> 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<Vector> 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<Vector> curve(Vector... points) {
List<Vector> 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<Vector> lineThrough(Vector... points) {
return new ArrayList<>(List.of(points));
}
private static List<Vector> ellipse(double centerX, double centerY, double radiusX, double radiusY, double tiltDegrees) {
double tilt = Math.toRadians(tiltDegrees);
List<Vector> 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;
}
}
@@ -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<DrawnWings> 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<String, ParticleDesign> registry() {
if (registry == null) {
Map<String, ParticleDesign> 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;
@@ -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) {