/* * 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.gui; 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.Material; import org.bukkit.entity.Player; import java.util.ArrayList; import java.util.List; import java.util.Map; import static de.steamwar.lobby.particle.design.EntryParameters.*; import static de.steamwar.lobby.particle.gui.ParticleGuiText.*; // Settings of one entry in labelled rows; a click on a setting toggles it or opens its own screen @UtilityClass class EntryEditor { private final int ROW_DESIGN = 0; private final int ROW_PARTICLE = 1; private final int ROW_SHAPE = 2; private final int ROW_TIMING = 3; private final int ROW_CONDITIONS = 4; private final int SIZE = 54; private final int BACK = BottomBar.position(SIZE, BottomBar.BACK); private final int COST = BottomBar.position(SIZE, BottomBar.COST); private final int DELETE = BottomBar.position(SIZE, BottomBar.DELETE); private final int MAX_CYCLED_OPTIONS = 5; private final Map, Material> ICONS = Map.ofEntries( Map.entry(COUNT, Material.GLOWSTONE_DUST), Map.entry(SPEED, Material.FEATHER), Map.entry(SPREAD_X, Material.GUNPOWDER), Map.entry(SPREAD_Y, Material.GUNPOWDER), Map.entry(SPREAD_Z, Material.GUNPOWDER), Map.entry(OFFSET_X, Material.SPECTRAL_ARROW), Map.entry(OFFSET_Y, Material.SPECTRAL_ARROW), Map.entry(OFFSET_Z, Material.SPECTRAL_ARROW), Map.entry(INTERVAL, Material.CLOCK), Map.entry(TICK_MODE, Material.REPEATER), Map.entry(MOVEMENT, Material.LEATHER_BOOTS), Map.entry(GLIDING, Material.ELYTRA), Map.entry(GROUND, Material.GRASS_BLOCK), Map.entry(SNEAK, Material.LEATHER_LEGGINGS), Map.entry(VISIBILITY, Material.SPYGLASS), Map.entry(Designs.CIRCLE_RADIUS, Material.COMPASS), Map.entry(Designs.CIRCLE_SPEED, Material.RECOVERY_COMPASS), 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_SIZE, Material.SCAFFOLDING) ); 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(); ParticleDesign design = entry.getDesign(); SWInventory inventory = new SWInventory(player, SIZE, msg("PARTICLE_ENTRY_EDITOR", player, designName(design, player))); header(inventory, ROW_DESIGN, Material.PAINTING, "PARTICLE_SECTION_DESIGN", player); header(inventory, ROW_PARTICLE, Material.FIREWORK_STAR, "PARTICLE_SECTION_PARTICLE", player); header(inventory, ROW_SHAPE, Material.ARMOR_STAND, "PARTICLE_SECTION_SHAPE", player); header(inventory, ROW_TIMING, Material.DAYLIGHT_DETECTOR, "PARTICLE_SECTION_TIMING", player); header(inventory, ROW_CONDITIONS, Material.OBSERVER, "PARTICLE_SECTION_CONDITIONS", player); inventory.setItem(ROW_DESIGN * 9 + 1, new SWItem(design.getIcon(), designName(design, player), List.of(msg("PARTICLE_VALUE_CLICK", player)), false, clickType -> Pickers.design(player, design, picked -> changeDesign(context, picked), context::reopen))); if (design.usesEntryParticle()) { inventory.setItem(ROW_DESIGN * 9 + 2, parameterItem(context, PARTICLE)); ParticleType type = entry.getValues().get(PARTICLE); if (type.usesColors()) { inventory.setItem(ROW_DESIGN * 9 + 3, parameterItem(context, COLORS)); } else if (type.usesBlock()) { inventory.setItem(ROW_DESIGN * 9 + 3, parameterItem(context, BLOCK)); } row(inventory, context, ROW_PARTICLE, List.of(COUNT, SPEED, SPREAD_X, SPREAD_Y, SPREAD_Z)); } else { inventory.setItem(ROW_PARTICLE * 9 + 1, new SWItem(Material.STRUCTURE_VOID, msg("PARTICLE_FIXED_LOOK", player), clickType -> { })); } row(inventory, context, ROW_SHAPE, design.getParameters()); row(inventory, context, ROW_TIMING, List.of(TICK_MODE, INTERVAL, OFFSET_X, OFFSET_Y, OFFSET_Z)); row(inventory, context, ROW_CONDITIONS, List.of(MOVEMENT, GLIDING, GROUND, SNEAK, VISIBILITY)); 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", player), List.of(msg("PARTICLE_SHIFT_TO_DELETE", player)), false, clickType -> { if (!clickType.isShiftClick()) return; slot.getEntries().remove(entryIndex); SlotEditor.save(player, index, slot); SlotEditor.open(player, index); })); BottomBar.fillUnused(inventory, SIZE, false); SlotEditor.fill(inventory, 0, SIZE); inventory.open(); } // Glowing so the row labels stand apart from the settings next to them private void header(SWInventory inventory, int row, Material icon, String key, Player player) { inventory.setItem(row * 9, new SWItem(icon, msg(key, player), List.of(), true, clickType -> { })); } private void row(SWInventory inventory, Context context, int row, List> parameters) { for (int i = 0; i < parameters.size() && i < 8; i++) { inventory.setItem(row * 9 + 1 + i, parameterItem(context, parameters.get(i))); } } @SuppressWarnings("unchecked") private SWItem parameterItem(Context context, Parameter parameter) { Player player = context.player(); Object value = context.entry().getValues().get(parameter); List lore = new ArrayList<>(); if (parameter instanceof ChoiceParameter choice && isCycled(choice)) { lore.addAll(optionLines((ChoiceParameter) choice, value, player)); } else { lore.add(msg("PARTICLE_VALUE", player, valueText(parameter, value, player))); } lore.add(""); lore.add(msg(isToggle(parameter) ? "PARTICLE_VALUE_TOGGLE" : isCycled(parameter) ? "PARTICLE_VALUE_CYCLE" : "PARTICLE_VALUE_CLICK", player)); Material icon = ICONS.getOrDefault(parameter, Material.PAPER); if (parameter instanceof BooleanParameter) icon = (Boolean) value ? Material.LIME_DYE : Material.GRAY_DYE; if (parameter == PARTICLE) icon = ((ParticleType) value).getIcon(); if (parameter == COLORS) icon = Material.RED_DYE; if (parameter == BLOCK) icon = (Material) value; return new SWItem(icon, name(parameter.getNameKey(), player), lore, false, clickType -> { if (parameter instanceof BooleanParameter toggle) { apply(context, toggle, !(Boolean) value); context.reopen(); } else if (parameter instanceof DoubleParameter number) { ValueEditors.editNumber(player, name(number.getNameKey(), player), () -> context.entry().getValues().get(number), number.getStep(), number.getMin(), number.getMax(), number.getDefaultValue(), false, next -> apply(context, number, next), context::reopen); } else if (parameter instanceof IntParameter number) { ValueEditors.editNumber(player, name(number.getNameKey(), player), () -> (double) context.entry().getValues().get(number), 1, number.getMin(), number.getMax(), number.getDefaultValue(), true, next -> apply(context, number, (int) Math.round(next)), context::reopen); } else if (parameter instanceof GradientParameter gradient) { ValueEditors.editColors(player, () -> context.entry().getValues().get(gradient), gradient.getMaxColors(), colors -> apply(context, gradient, colors), context::reopen); } else if (parameter instanceof ChoiceParameter choice) { if (isCycled(choice)) { cycle(context, (ChoiceParameter) choice, value, clickType.isRightClick() ? -1 : 1); } else { pick(context, (ChoiceParameter) choice, value); } } }); } // Choices with a few options cycle through them on click instead of opening a list private boolean isCycled(Parameter parameter) { return parameter instanceof ChoiceParameter choice && choice.getBuildableValues().size() <= MAX_CYCLED_OPTIONS; } // With two options both click directions lead to the same value, so it reads as a plain toggle private boolean isToggle(Parameter parameter) { return parameter instanceof BooleanParameter || (isCycled(parameter) && ((ChoiceParameter) parameter).getBuildableValues().size() == 2); } // All options of a cycled choice, the current one highlighted private List optionLines(ChoiceParameter parameter, E current, Player player) { List lines = new ArrayList<>(); for (E option : parameter.getBuildableValues()) { String name = choiceName(parameter, option, player).replaceAll("§.", ""); lines.add(option == current ? "§a» " + name : "§8» §7" + name); } return lines; } // Left click moves forward, right click backward private void cycle(Context context, ChoiceParameter parameter, E current, int direction) { List values = parameter.getBuildableValues(); apply(context, parameter, values.get(Math.floorMod(values.indexOf(current) + direction, values.size()))); context.reopen(); } private void pick(Context context, ChoiceParameter parameter, E current) { Pickers.choice(context.player(), parameter, current, EntryEditor::choiceIcon, picked -> { apply(context, parameter, picked); context.reopen(); }, context::reopen); } private Material choiceIcon(Object value) { if (value instanceof ParticleType type) return type.getIcon(); if (value instanceof WingDesign) return Material.PHANTOM_MEMBRANE; if (value instanceof Material material) return material; return Material.LEVER; } // Saves the change, or reverts it and tells the player when the slot would go over its budget private boolean apply(Context context, Parameter 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()); return false; } SlotEditor.save(context.player(), context.index(), context.slot()); return true; } // 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 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 void copy(Parameter parameter, ParameterValues from, ParameterValues to) { to.set(parameter, from.get(parameter)); } }