Merge wing designs, turn wings with the body, limit leaves

Wing and Drawn wings become one Wings design whose shapes include the
images and the drawn bat, butterfly and fairy; for drawn shapes the points
per wing replace the amount. Body rotation is tracked per player the way
the client turns the body, and wings, the heart beat and body placement
follow it instead of the head. Leaves particles are for the server team
only, and typed hex colours show the closest dye.
This commit is contained in:
2026-10-26 13:39:28 +01:00
parent b4661ea345
commit e830d087c9
18 changed files with 283 additions and 86 deletions
@@ -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
@@ -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
@@ -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);
}
@@ -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 <https://www.gnu.org/licenses/>.
*/
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<Player, State> 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;
}
}
@@ -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());
}
}
@@ -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;
@@ -66,22 +66,29 @@ public class Designs {
}
};
public final ChoiceParameter<WingDesign> 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<ChoiceParameter.Choice<WingShape>> 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<WingShape> 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<DrawnWings> 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<String, ParticleDesign> registry() {
if (registry == null) {
Map<String, ParticleDesign> 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;
@@ -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) {
}
@@ -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;
}
@@ -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 <https://www.gnu.org/licenses/>.
*/
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;
}
}
@@ -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);
@@ -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);
@@ -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<Parameter<?>> 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 <E> void pick(Context context, ChoiceParameter<E> 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;
}
@@ -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;
@@ -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
<E> void choice(Player player, ChoiceParameter<E> parameter, E current, Function<E, Material> icon, Consumer<E> onPick, Runnable onBack) {
// Lists every option; the current one glows, locked ones show their requirement and cannot be picked
<E> void choice(Player player, ChoiceParameter<E> parameter, E current, Function<E, Material> icon, Function<E, ParticleRequirement> requirement,
Consumer<E> onPick, Runnable onBack) {
UnlockContext context = new UnlockContext(SteamwarUser.get(player.getUniqueId()));
List<SWListInv.SWListEntry<E>> list = new ArrayList<>();
for (E value : parameter.getBuildableValues()) {
List<String> lore = List.of(msg(value == current ? "PARTICLE_VALUE_SELECTED" : "PARTICLE_SELECT", player));
boolean unlocked = requirement.apply(value).test(context);
List<String> 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);
}
@@ -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) {
@@ -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<Color> 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);
}
@@ -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)