Add lobby particle slot menus

Replace the particle inventory with a main menu showing the 7 slots and
unlocked presets, a slot editor with a read-only view for locked slots,
and an entry editor with pickers for designs, choices, colours and icons.
Slots are limited to 10 entries and a particles-per-tick budget.
This commit is contained in:
2026-10-26 09:48:00 +01:00
parent 211e2693cf
commit c95901bc42
12 changed files with 1041 additions and 77 deletions
@@ -0,0 +1,257 @@
/*
* 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.gui;
import de.steamwar.inventory.SWAnvilInv;
import de.steamwar.inventory.SWInventory;
import de.steamwar.inventory.SWItem;
import de.steamwar.lobby.LobbySystem;
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;
import de.steamwar.lobby.particle.slot.ParticleSlots;
import de.steamwar.lobby.util.LobbyPlayer;
import de.steamwar.sql.SteamwarUser;
import lombok.experimental.UtilityClass;
import org.bukkit.Bukkit;
import org.bukkit.Material;
import org.bukkit.entity.Player;
import org.bukkit.event.inventory.ClickType;
import java.awt.Color;
import java.util.ArrayList;
import java.util.List;
import java.util.Set;
import static de.steamwar.lobby.particle.design.EntryParameters.*;
import static de.steamwar.lobby.particle.gui.ParticleGuiText.*;
// One item per parameter of an entry; every change is saved and shown right away
@UtilityClass
class EntryEditor {
private final int DESIGN = 4;
private final int FIRST_PARAMETER = 9;
private final int BACK = 45;
private final int COST = 49;
private final int DELETE = 53;
// Choices with more options than this open a list instead of cycling on click
private final int MAX_CYCLE_CHOICES = 6;
private final Set<Parameter<?>> PARTICLE_PARAMETERS = Set.of(PARTICLE, COUNT, SPREAD_X, SPREAD_Y, SPREAD_Z, SPEED, COLORS);
private record Context(Player player, SteamwarUser user, int index, int entryIndex, ParticleSlot slot, ParticleEntry entry) {
void reopen() {
open(player, index, entryIndex);
}
}
void open(Player player, int index, int entryIndex) {
LobbyPlayer lobbyPlayer = LobbyPlayer.getLobbyPlayer(player);
ParticleSlot slot = lobbyPlayer.getSlot(index);
SteamwarUser user = SteamwarUser.get(player.getUniqueId());
if (slot == null || entryIndex >= slot.getEntries().size() || !ParticleSlots.requirement(index).test(new UnlockContext(user))) {
SlotEditor.open(player, index);
return;
}
Context context = new Context(player, user, index, entryIndex, slot, slot.getEntries().get(entryIndex));
ParticleEntry entry = context.entry();
SWInventory inventory = new SWInventory(player, 54, msg("PARTICLE_ENTRY_EDITOR", player));
inventory.setItem(DESIGN, new SWItem(entry.getDesign().getIcon(), designName(entry.getDesign(), player), List.of(msg("PARTICLE_ENTRY_CHANGE_DESIGN", player)), false,
clickType -> Pickers.design(player, design -> changeDesign(context, design), context::reopen)));
int position = FIRST_PARAMETER;
for (Parameter<?> parameter : visibleParameters(entry)) {
if (position >= BACK) break;
inventory.setItem(position++, parameterItem(context, parameter));
}
inventory.setItem(BACK, new SWItem(Material.ARROW, msg("PARTICLE_BACK", player), clickType -> SlotEditor.open(player, index)));
inventory.setItem(COST, SlotEditor.costItem(player, user, slot));
inventory.setItem(DELETE, new SWItem(Material.LAVA_BUCKET, msg("PARTICLE_ENTRY_DELETE_ITEM", player), List.of(msg("PARTICLE_SLOT_DELETE_CONFIRM", player)), false, clickType -> {
if (!clickType.isShiftClick()) return;
slot.getEntries().remove(entryIndex);
SlotEditor.save(player, index, slot);
SlotEditor.open(player, index);
}));
inventory.open();
}
private List<Parameter<?>> visibleParameters(ParticleEntry entry) {
List<Parameter<?>> parameters = new ArrayList<>();
boolean dust = entry.getValues().get(PARTICLE).isDust();
for (Parameter<?> parameter : entry.getParameters()) {
if (!entry.getDesign().usesEntryParticle() && PARTICLE_PARAMETERS.contains(parameter)) continue;
if (parameter == COLORS && !dust) continue;
parameters.add(parameter);
}
return parameters;
}
@SuppressWarnings("unchecked")
private SWItem parameterItem(Context context, Parameter<?> parameter) {
if (parameter instanceof DoubleParameter number) return doubleItem(context, number);
if (parameter instanceof IntParameter number) return intItem(context, number);
if (parameter instanceof BooleanParameter toggle) return booleanItem(context, toggle);
if (parameter instanceof GradientParameter gradient) return gradientItem(context, gradient);
if (parameter instanceof ChoiceParameter<?> choice) return choiceItem(context, (ChoiceParameter<Object>) choice);
throw new IllegalArgumentException("No editor for parameter " + parameter.getKey());
}
private List<String> lore(Context context, Parameter<?> parameter, String... help) {
List<String> lore = new ArrayList<>();
lore.add(msg("PARTICLE_VALUE", context.player(), valueText(parameter, context.entry().getValues().get(parameter), context.player())));
lore.add("");
for (String line : help) lore.add(line);
return lore;
}
private SWItem doubleItem(Context context, DoubleParameter parameter) {
Player player = context.player();
double value = context.entry().getValues().get(parameter);
List<String> lore = lore(context, parameter, msg("PARTICLE_VALUE_NUMBER_HELP", player, number(parameter.getStep())), msg("PARTICLE_VALUE_EXACT_HELP", player));
return new SWItem(Material.REPEATER, name(parameter.getNameKey(), player), lore, false, clickType -> {
if (clickType == ClickType.DROP || clickType == ClickType.CONTROL_DROP) {
askNumber(context, parameter, ParticleGuiText.number(value), input -> change(context, parameter, input));
return;
}
double step = parameter.getStep() * (clickType.isShiftClick() ? 10 : 1) * (clickType.isRightClick() ? -1 : 1);
double next = Math.round((value + step) / parameter.getStep()) * parameter.getStep();
change(context, parameter, next);
});
}
private SWItem intItem(Context context, IntParameter parameter) {
Player player = context.player();
int value = context.entry().getValues().get(parameter);
List<String> lore = lore(context, parameter, msg("PARTICLE_VALUE_NUMBER_HELP", player, 1), msg("PARTICLE_VALUE_EXACT_HELP", player));
return new SWItem(Material.COMPARATOR, name(parameter.getNameKey(), player), lore, false, clickType -> {
if (clickType == ClickType.DROP || clickType == ClickType.CONTROL_DROP) {
askNumber(context, parameter, String.valueOf(value), input -> change(context, parameter, (int) Math.round(input)));
return;
}
int step = (clickType.isShiftClick() ? 10 : 1) * (clickType.isRightClick() ? -1 : 1);
change(context, parameter, value + step);
});
}
private SWItem booleanItem(Context context, BooleanParameter parameter) {
boolean value = context.entry().getValues().get(parameter);
List<String> lore = lore(context, parameter, msg("PARTICLE_VALUE_TOGGLE_HELP", context.player()));
return new SWItem(value ? Material.LIME_DYE : Material.GRAY_DYE, name(parameter.getNameKey(), context.player()), lore, false,
clickType -> change(context, parameter, !value));
}
private SWItem gradientItem(Context context, GradientParameter parameter) {
Player player = context.player();
List<Color> colors = context.entry().getValues().get(parameter);
List<String> lore = lore(context, parameter, msg("PARTICLE_VALUE_COLORS_HELP", player));
return new SWItem(Material.WHITE_DYE, name(parameter.getNameKey(), player), lore, false, clickType -> {
if (clickType.isRightClick()) {
if (colors.isEmpty()) return;
change(context, parameter, colors.subList(0, colors.size() - 1));
} else if (colors.size() < parameter.getMaxColors()) {
Pickers.color(player, color -> {
List<Color> next = new ArrayList<>(colors);
next.add(color);
change(context, parameter, next);
}, context::reopen);
}
});
}
private <E> SWItem choiceItem(Context context, ChoiceParameter<E> parameter) {
Player player = context.player();
E value = context.entry().getValues().get(parameter);
List<E> values = parameter.getBuildableValues();
boolean list = values.size() > MAX_CYCLE_CHOICES;
List<String> lore = lore(context, parameter, msg(list ? "PARTICLE_VALUE_LIST_HELP" : "PARTICLE_VALUE_CHOICE_HELP", player));
return new SWItem(choiceIcon(value), name(parameter.getNameKey(), player), lore, false, clickType -> {
if (list) {
Pickers.choice(player, parameter, EntryEditor::choiceIcon, picked -> change(context, parameter, picked), context::reopen);
return;
}
int current = values.indexOf(value);
int next = Math.floorMod(current + (clickType.isRightClick() ? -1 : 1), values.size());
change(context, parameter, values.get(next));
});
}
private Material choiceIcon(Object value) {
if (value instanceof ParticleType type) return type.getIcon();
if (value instanceof WingDesign) return Material.ELYTRA;
return Material.LEVER;
}
private void askNumber(Context context, Parameter<?> parameter, String current, java.util.function.DoubleConsumer onInput) {
Player player = context.player();
SWAnvilInv anvil = new SWAnvilInv(player, name(parameter.getNameKey(), player), current);
anvil.setItem(Material.PAPER);
anvil.setCallback(input -> Bukkit.getScheduler().runTask(LobbySystem.getInstance(), () -> {
try {
onInput.accept(Double.parseDouble(input.trim().replace(',', '.')));
} catch (NumberFormatException e) {
context.reopen();
}
}));
anvil.open();
}
// Reverts the change when it would push the slot over its budget
private <T> void change(Context context, Parameter<T> parameter, T value) {
T previous = context.entry().getValues().get(parameter);
context.entry().getValues().set(parameter, value);
if (!SlotEditor.fits(context.user(), context.slot())) {
context.entry().getValues().set(parameter, previous);
LobbySystem.getMessage().send("PARTICLE_SLOT_OVER_BUDGET", context.player());
} else {
SlotEditor.save(context.player(), context.index(), context.slot());
}
context.reopen();
}
// Keeps the particle, offset and conditions; a fixed design brings its own conditions and timing
private void changeDesign(Context context, ParticleDesign design) {
ParticleEntry previous = context.entry();
ParticleEntry next = new ParticleEntry(design);
for (Parameter<?> parameter : EntryParameters.ALL) {
copy(parameter, previous.getValues(), next.getValues());
}
if (!design.usesEntryParticle()) {
design.applyDefaults(next.getValues());
}
List<ParticleEntry> entries = context.slot().getEntries();
entries.set(context.entryIndex(), next);
if (!SlotEditor.fits(context.user(), context.slot())) {
entries.set(context.entryIndex(), previous);
LobbySystem.getMessage().send("PARTICLE_SLOT_OVER_BUDGET", context.player());
} else {
SlotEditor.save(context.player(), context.index(), context.slot());
}
context.reopen();
}
private <T> void copy(Parameter<T> parameter, ParameterValues from, ParameterValues to) {
to.set(parameter, from.get(parameter));
}
}