diff --git a/mod/TODO.md b/mod/TODO.md index fcff4b7c..023d09d0 100644 --- a/mod/TODO.md +++ b/mod/TODO.md @@ -94,6 +94,15 @@ - [x] GodRays (2026-10-04, вживую не смотрели): `visuals/godrays/` (модуль `GodRays` как `PostProcessPass` PRE_HAND, чистая математика `GodRaysSun`), шейдер `godrays.frag` (один проход: радиальный блюр неба к солнцу поверх сцены, глубина из `context.mainDepth()`), `GodRaysUniforms`, миксин `render/godrays/GodRaysSkyMixin` забирает `sunAngle` и закатный цвет из `SkyRenderState`. Не работает при шейдерпаке (Iris). +- [x] Косметика в настройках (2026-10-07, вживую не смотрели): категория Settings → «Косметика» (`implement/cosmetics/`: `CosmeticKind` разделы + Крылья/Шапки/Модели/Маски/Оружие, `CosmeticsComponent`, `CosmeticsRenderer`), модули Wings/ChinaHat/TazikHat/CustomModels/TrollfaceMask/Knife + переведены в `ModuleCategory.ACCESSORIES` и скрыты из панелей Modules. Свои превью карточек: кнопка «+» (диалог tinyfd) или перетаскивание файла + на карточку, редактор кадра (`cosmetics/preview/`: `PreviewCrop` с тестами, `PreviewStore` в `config/lovisual/cosmetics/previews`, `PreviewEditor`). +- [x] Быстрые настройки виджета в чате (2026-10-07): ПКМ по виджету при открытом чате открывает попап (`hud/draggable/quick/`: переключатели, ползунки, + режимы; остальные типы настроек только в ClickGUI), изменения сохраняются при закрытии. +- [x] Вкладка Темы получила страницы HUD и ClickGUI (2026-10-07): `implement/look/` (`ThemesPage`, `ThemesPageSwitch`), `main/LookSettingsComponent`; + HUD: стиль поверхностей, анимация переключения, заголовки, сила размытия виджетов (`HudGlobalConfig.getLookSettingDefs`); ClickGUI: новый + `config/style/ClickGuiLookConfig` (сила и плотность размытия панелей, размытие стекла) вместо зашитых констант. - [ ] Дальше P1-P2 — перенос модулей из evo поштучно (референс: `docs/EVO_REPORT.md` §1.2; лицензия evo проприетарная — их ассеты/код в репо не кладём, только идеи): - [ ] **Blizzard** (render, evo ~1290 LOC, M/L) — высотная метель: ветровые снежинки с горизонтальным @@ -102,7 +111,7 @@ - [ ] **ElytraTrails** (~2208, M) — светящиеся шлейфы с концов крыльев элитры, переиспользовать наш `Trails`. - [ ] **CustomHand** (~373, M) — пост-процесс шейдер руки (частично есть `shaders/hand_glass.frag`). - [ ] **DynamicLights** (~553, M) — факелы/лампы в руке светят по-настоящему; сначала проверить наш `Light`. - - [ ] **TotemSound** (~257, S) — свой звук при срабатывании тотема. + - [x] **TotemSound** (2026-10-07, вживую не смотрели): `misc/sound/TotemSound`, звуки из наших hitsounds (5 wav), триггер Все/Только мои/Только чужие, громкость, тон, «Заглушить ванильный» (через `SoundEngineMixin`). Нет из evo: свои .ogg из папки. - [ ] **Sounds** (~195, S) — звук на клиентские действия (открытие инвентаря, прыжок, клик в GUI…). - [ ] **InventorySort** (~390, S/M) — сортировка инвентаря по пресету (клиентские QUICK_MOVE, без чужих пакетов). - [ ] **StorageTracker** (~678, M) — память содержимого контейнеров, поиск предмета, подсветка где лежит. diff --git a/mod/src/main/java/dev/loki/lovisual/config/style/ClickGuiLookConfig.java b/mod/src/main/java/dev/loki/lovisual/config/style/ClickGuiLookConfig.java new file mode 100644 index 00000000..0281d9db --- /dev/null +++ b/mod/src/main/java/dev/loki/lovisual/config/style/ClickGuiLookConfig.java @@ -0,0 +1,68 @@ +package dev.loki.lovisual.config.style; + +import dev.loki.lovisual.config.definition.ConfigNameProvider; +import dev.loki.lovisual.config.definition.SettingDef; +import dev.loki.lovisual.config.definition.SettingOwner; +import dev.loki.lovisual.config.io.ConfigSerializer; +import dev.loki.lovisual.config.io.JsonConfigObject; +import dev.loki.lovisual.config.values.collection.ConfigValue; +import dev.loki.lovisual.config.values.primitive.NumberValue; + +import java.util.List; + +/** + * How the ClickGUI itself is painted: blur sharpness and strength of its panels. + * Defaults match the values that used to be hard-coded; persisted as {@code clickgui_look.json}. + */ +public final class ClickGuiLookConfig implements JsonConfigObject, ConfigNameProvider, SettingOwner { + + public static final ClickGuiLookConfig INSTANCE = new ClickGuiLookConfig(); + + private final NumberValue blurQuality = new NumberValue<>("blurQuality", 27.35f, 8f, 72f); + private final NumberValue blurDensity = new NumberValue<>("blurDensity", 1.16f, 0.3f, 2f); + private final NumberValue glassBlur = new NumberValue<>("glassBlur", 1.28f, 0.3f, 2f); + + private ClickGuiLookConfig() { + ConfigSerializer.load(this); + } + + public static ClickGuiLookConfig get() { + return INSTANCE; + } + + public float blurQuality() { + return blurQuality.get(); + } + + public float blurDensity() { + return blurDensity.get(); + } + + public float glassBlur() { + return glassBlur.get(); + } + + public List getSettingDefs() { + return List.of(SettingDef.number(blurQuality), SettingDef.number(blurDensity), SettingDef.number(glassBlur)); + } + + @Override + public String getConfigName() { + return "clickgui_look"; + } + + @Override + public String name() { + return "clickgui_look"; + } + + @Override + public void saveConfig() { + ConfigSerializer.requestSave(this); + } + + @Override + public List> getConfigValues() { + return List.of(blurQuality, blurDensity, glassBlur); + } +} diff --git a/mod/src/main/java/dev/loki/lovisual/features/gui/clickgui/layout/screen/modules/model/ModulesMenuCategory.java b/mod/src/main/java/dev/loki/lovisual/features/gui/clickgui/layout/screen/modules/model/ModulesMenuCategory.java index e8e4ff54..2d729050 100644 --- a/mod/src/main/java/dev/loki/lovisual/features/gui/clickgui/layout/screen/modules/model/ModulesMenuCategory.java +++ b/mod/src/main/java/dev/loki/lovisual/features/gui/clickgui/layout/screen/modules/model/ModulesMenuCategory.java @@ -32,6 +32,7 @@ public enum ModulesMenuCategory { public boolean matches(ModuleCategory category) { if (category == null) return this == OTHER; + if (category == ModuleCategory.ACCESSORIES) return false; String name = category.name().toLowerCase(Locale.ROOT); for (String alias : aliases) { diff --git a/mod/src/main/java/dev/loki/lovisual/features/gui/clickgui/layout/screen/settings/MenuScreen.java b/mod/src/main/java/dev/loki/lovisual/features/gui/clickgui/layout/screen/settings/MenuScreen.java index c8061a94..f033ed2d 100644 --- a/mod/src/main/java/dev/loki/lovisual/features/gui/clickgui/layout/screen/settings/MenuScreen.java +++ b/mod/src/main/java/dev/loki/lovisual/features/gui/clickgui/layout/screen/settings/MenuScreen.java @@ -79,6 +79,7 @@ public final class MenuScreen { public enum Category { HUD("HUD", "clickgui.settings.tab.hud", "HUD", IconKind.TEXT), UI("UI", "clickgui.settings.tab.ui", "UI", IconKind.TEXT), + COSMETICS("paintbrush", "clickgui.settings.tab.cosmetics", "Cosmetics", IconKind.SVG), THEMES("G", "clickgui.settings.tab.themes", "Themes", IconKind.FONT), MAIN_SETTINGS("columns-3-cog", "clickgui.settings.tab.main_settings", "Main Settings", IconKind.SVG), RELATIONS("user-pen", "clickgui.settings.tab.relations", "Relations", IconKind.SVG), diff --git a/mod/src/main/java/dev/loki/lovisual/features/gui/clickgui/layout/screen/settings/implement/cosmetics/CosmeticKind.java b/mod/src/main/java/dev/loki/lovisual/features/gui/clickgui/layout/screen/settings/implement/cosmetics/CosmeticKind.java new file mode 100644 index 00000000..9f5c2265 --- /dev/null +++ b/mod/src/main/java/dev/loki/lovisual/features/gui/clickgui/layout/screen/settings/implement/cosmetics/CosmeticKind.java @@ -0,0 +1,40 @@ +package dev.loki.lovisual.features.gui.clickgui.layout.screen.settings.implement.cosmetics; + +import dev.loki.lovisual.features.gui.clickgui.util.ClickGuiI18n; + +import java.util.List; + +/** Sub-sections of the Cosmetics settings category; each lists the modules that belong to it. */ +public enum CosmeticKind { + WINGS("wings", "Wings", "wings"), + HATS("hats", "Hats", "chinahat", "tazikhat"), + MODELS("models", "Models", "custommodels"), + MASKS("masks", "Masks", "trollface_mask"), + WEAPONS("weapons", "Weapons", "knife"); + + private final String key; + private final String fallbackTitle; + private final List moduleIds; + + CosmeticKind(String key, String fallbackTitle, String... moduleIds) { + this.key = key; + this.fallbackTitle = fallbackTitle; + this.moduleIds = List.of(moduleIds); + } + + public List moduleIds() { + return moduleIds; + } + + public String title() { + return ClickGuiI18n.tr("clickgui.settings.cosmetics.kind." + key, fallbackTitle); + } + + /** The kind that lists this module, or null when it is not a cosmetic. */ + public static CosmeticKind ofModule(String moduleId) { + for (CosmeticKind kind : values()) { + if (kind.moduleIds.contains(moduleId)) return kind; + } + return null; + } +} diff --git a/mod/src/main/java/dev/loki/lovisual/features/gui/clickgui/layout/screen/settings/implement/cosmetics/CosmeticsComponent.java b/mod/src/main/java/dev/loki/lovisual/features/gui/clickgui/layout/screen/settings/implement/cosmetics/CosmeticsComponent.java new file mode 100644 index 00000000..4b43ea05 --- /dev/null +++ b/mod/src/main/java/dev/loki/lovisual/features/gui/clickgui/layout/screen/settings/implement/cosmetics/CosmeticsComponent.java @@ -0,0 +1,112 @@ +package dev.loki.lovisual.features.gui.clickgui.layout.screen.settings.implement.cosmetics; + +import dev.loki.lovisual.features.gui.clickgui.layout.screen.settings.implement.cosmetics.preview.PreviewEditor; +import dev.loki.lovisual.features.gui.clickgui.layout.screen.settings.implement.cosmetics.preview.PreviewFileDialog; +import dev.loki.lovisual.features.gui.clickgui.layout.screen.settings.implement.cosmetics.preview.PreviewStore; +import dev.loki.lovisual.features.gui.clickgui.settings.core.Setting; +import dev.loki.lovisual.features.gui.clickgui.util.ClickGuiI18n; +import dev.loki.lovisual.features.gui.clickgui.util.ClickGuiMath; +import dev.loki.lovisual.features.module.core.Module; +import dev.loki.lovisual.features.module.core.ModuleManager; +import org.lwjgl.glfw.GLFW; + +import java.util.ArrayList; +import java.nio.file.Path; +import java.util.List; + +/** Cosmetics settings category: kind pills on top, a grid of cosmetic cards below. */ +public final class CosmeticsComponent { + private final PreviewStore previews = new PreviewStore(); + private final PreviewEditor editor = new PreviewEditor(previews); + private final CosmeticsRenderer renderer = new CosmeticsRenderer(previews); + private CosmeticKind kind = CosmeticKind.WINGS; + private float scroll; + + public void resetScroll() { + scroll = 0f; + } + + public void render(float menuX, float menuY, float menuW, float menuH, float mx, float my, float scale) { + scroll = ClickGuiMath.clamp(scroll, -renderer.maxScroll(), 0f); + renderer.render(kind, modules(kind), menuX, menuY, menuW, menuH, scroll, scale); + editor.render(menuX, menuY, menuW, menuH, mx, my, scale); + } + + /** True while the picture editor covers the page and owns the input. */ + public boolean isEditing() { + return editor.isOpen(); + } + + public void mouseReleased() { + editor.mouseReleased(); + } + + public boolean keyPressed(int keyCode) { + return editor.keyPressed(keyCode); + } + + /** A picture dropped on a card opens the editor for it. */ + public boolean onFilesDrop(List paths, float mx, float my) { + if (editor.isOpen()) return true; + String id = renderer.cardAt(mx, my); + if (id == null) return false; + for (Path path : paths) { + if (PreviewFileDialog.isPicture(path)) return editor.open(id, path); + } + return false; + } + + public void scroll(double amount, float scale) { + if (editor.mouseScrolled(amount)) return; + scroll = ClickGuiMath.clamp(scroll + (float) amount * 18f * scale, -renderer.maxScroll(), 0f); + } + + /** Returns true when the click was consumed; {@code onSettings} receives the module to open settings for. */ + public boolean click(float mx, float my, int button, java.util.function.Consumer onSettings) { + if (editor.mousePressed(mx, my, button)) return true; + String pictureCard = button == GLFW.GLFW_MOUSE_BUTTON_LEFT ? renderer.pictureButtonAt(mx, my) : null; + if (pictureCard != null) { + Path chosen = PreviewFileDialog.choose(ClickGuiI18n.tr("clickgui.settings.cosmetics.pick", "Choose a picture")); + if (chosen != null) editor.open(pictureCard, chosen); + return true; + } + String cropCard = button == GLFW.GLFW_MOUSE_BUTTON_LEFT ? renderer.cropButtonAt(mx, my) : null; + if (cropCard != null) { + editor.open(cropCard, null); + return true; + } + CosmeticKind pill = renderer.pillAt(mx, my); + if (pill != null && button == GLFW.GLFW_MOUSE_BUTTON_LEFT) { + if (pill != kind) { + kind = pill; + scroll = 0f; + } + return true; + } + String id = renderer.cardAt(mx, my); + Module module = id == null ? null : ModuleManager.get(id); + if (module == null) return false; + if (button == GLFW.GLFW_MOUSE_BUTTON_LEFT) module.toggle(); + else if (button == GLFW.GLFW_MOUSE_BUTTON_RIGHT) onSettings.accept(module); + return true; + } + + /** Settings of a cosmetic module with their i18n resolved, or an empty list. */ + public static List settingsOf(Module module) { + List settings = module.getSettings(); + if (settings == null) return List.of(); + for (Setting setting : settings) { + if (setting != null) setting.preflightI18n(); + } + return settings; + } + + static List modules(CosmeticKind kind) { + List out = new ArrayList<>(); + for (String id : kind.moduleIds()) { + Module module = ModuleManager.get(id); + if (module != null) out.add(module); + } + return out; + } +} diff --git a/mod/src/main/java/dev/loki/lovisual/features/gui/clickgui/layout/screen/settings/implement/cosmetics/CosmeticsRenderer.java b/mod/src/main/java/dev/loki/lovisual/features/gui/clickgui/layout/screen/settings/implement/cosmetics/CosmeticsRenderer.java new file mode 100644 index 00000000..a9cdc1b2 --- /dev/null +++ b/mod/src/main/java/dev/loki/lovisual/features/gui/clickgui/layout/screen/settings/implement/cosmetics/CosmeticsRenderer.java @@ -0,0 +1,192 @@ +package dev.loki.lovisual.features.gui.clickgui.layout.screen.settings.implement.cosmetics; + +import dev.loki.lovisual.features.gui.clickgui.layout.screen.settings.SettingsGuiPalette; +import dev.loki.lovisual.features.gui.clickgui.layout.screen.settings.implement.cosmetics.preview.PreviewCrop; +import dev.loki.lovisual.features.gui.clickgui.layout.screen.settings.implement.cosmetics.preview.PreviewStore; +import dev.loki.lovisual.features.gui.clickgui.util.ClickGuiI18n; +import dev.loki.lovisual.render.engine.renderer.Renderer2D; +import dev.loki.lovisual.features.gui.clickgui.layout.screen.settings.implement.other.StatusRender; +import dev.loki.lovisual.features.gui.clickgui.layout.screen.settings.render.LayoutRender2D; +import dev.loki.lovisual.features.gui.clickgui.render.ClickGuiRenderer; +import dev.loki.lovisual.features.gui.clickgui.util.ClickGuiMath; +import dev.loki.lovisual.features.module.core.Module; +import dev.loki.lovisual.render.engine.animation.AnimationUtility; +import dev.loki.lovisual.render.helpers.clip.ScissorFunction; + +import java.util.ArrayList; +import java.util.HashMap; +import java.util.List; +import java.util.Map; + +/** Draws the cosmetics pills and card grid, and remembers where things are for hit testing. */ +final class CosmeticsRenderer { + private static final int COLUMNS = 3; + + private record Rect(float x, float y, float w, float h) { + boolean contains(float mx, float my) { + return ClickGuiMath.insideRect(mx, my, x, y, w, h); + } + } + + private final Map pills = new HashMap<>(); + private final List> cards = new ArrayList<>(); + private final Map enabledAnim = new HashMap<>(); + private final Map hoverAnim = new HashMap<>(); + private final Map statuses = new HashMap<>(); + private final Map pillAnim = new HashMap<>(); + private final PreviewStore previews; + private final Map pictureButtons = new HashMap<>(); + private final Map cropButtons = new HashMap<>(); + private float maxScroll; + + CosmeticsRenderer(PreviewStore previews) { + this.previews = previews; + } + + String pictureButtonAt(float mx, float my) { + return buttonAt(pictureButtons, mx, my); + } + + String cropButtonAt(float mx, float my) { + return buttonAt(cropButtons, mx, my); + } + + private static String buttonAt(Map buttons, float mx, float my) { + for (Map.Entry b : buttons.entrySet()) { + if (b.getValue().contains(mx, my)) return b.getKey(); + } + return null; + } + + float maxScroll() { + return maxScroll; + } + + CosmeticKind pillAt(float mx, float my) { + for (Map.Entry pill : pills.entrySet()) { + if (pill.getValue().contains(mx, my)) return pill.getKey(); + } + return null; + } + + String cardAt(float mx, float my) { + for (Map.Entry card : cards) { + if (card.getValue().contains(mx, my)) return card.getKey(); + } + return null; + } + + void render(CosmeticKind kind, List modules, float menuX, float menuY, float menuW, float menuH, + float scroll, float s) { + SettingsGuiPalette palette = SettingsGuiPalette.current(); + float left = menuX + 32f * s; + renderPills(kind, left, menuY + 33f * s, s, palette); + + cards.clear(); + pictureButtons.clear(); + cropButtons.clear(); + float gap = 8f * s; + float areaW = 340f * s; + float cardW = (areaW - gap * (COLUMNS - 1)) / COLUMNS; + float cardH = 92f * s; + float top = menuY + 56f * s; + float bottom = menuY + menuH - 3.5f * s; + boolean clipped = ScissorFunction.pushRaw(left - 4f * s, top, areaW + 8f * s, Math.max(1f, bottom - top)); + for (int i = 0; i < modules.size(); i++) { + float x = left + (i % COLUMNS) * (cardW + gap); + float y = top + (i / COLUMNS) * (cardH + gap) + scroll; + if (y + cardH > top && y < bottom) drawCard(modules.get(i), x, y, cardW, cardH, s, palette); + } + if (clipped) ScissorFunction.pop(); + int rows = (modules.size() + COLUMNS - 1) / COLUMNS; + maxScroll = Math.max(0f, rows * (cardH + gap) - gap - (bottom - top)); + } + + private void renderPills(CosmeticKind selected, float x, float y, float s, SettingsGuiPalette palette) { + float size = 7.5f * s; + float h = 14f * s; + for (CosmeticKind kind : CosmeticKind.values()) { + String label = kind.title(); + float w = ClickGuiRenderer.textWidth(ClickGuiRenderer.getInterMedium(), label, size) + 14f * s; + Rect rect = new Rect(x, y, w, h); + pills.put(kind, rect); + float target = kind == selected ? 1f : 0f; + float anim = pillAnim.merge(kind, target, (old, t) -> AnimationUtility.approach(old, t, 0.2f)); + int base = SettingsGuiPalette.mix(palette.panelPillBase(), palette.panelPillActive(), anim); + LayoutRender2D.roundedQuad(x, y, w, h, h * 0.5f, base, base, base, base); + int text = SettingsGuiPalette.mix(palette.panelMuted(), palette.panelText(), anim); + float th = ClickGuiRenderer.textHeight(ClickGuiRenderer.getInterMedium(), size); + ClickGuiRenderer.drawText(ClickGuiRenderer.getInterMedium(), label, x + 7f * s, y + (h - th) * 0.5f, size, text, false); + x += w + 5f * s; + } + } + + private void drawCard(Module module, float x, float y, float w, float h, float s, SettingsGuiPalette palette) { + String id = module.name(); + boolean hovered = ClickGuiMath.insideRect(ClickGuiRenderer.getMouseX(), ClickGuiRenderer.getMouseY(), x, y, w, h); + float on = enabledAnim.merge(id, module.isEnabled() ? 1f : 0f, (old, t) -> AnimationUtility.approach(old, t, 0.16f)); + float hover = hoverAnim.merge(id, hovered ? 1f : 0f, (old, t) -> AnimationUtility.approach(old, t, 0.18f)); + cards.add(Map.entry(id, new Rect(x, y, w, h))); + + ClickGuiRenderer.drawRoundedRectShadow(x, y + 1.5f * s, w, h, 5f * s, 9f * s, s, + LayoutRender2D.alpha(0xFF000000, 0.22f + 0.10f * hover)); + LayoutRender2D.roundedQuad(x, y, w, h, 5f * s, palette.moduleCardTop(), palette.moduleCardTopStrong(), + palette.moduleCardBottom(), palette.moduleCardBottomStrong()); + + float pad = 5f * s; + float previewH = h - 30f * s; + drawPreview(module, x + pad, y + pad, w - pad * 2f, previewH - pad, s, palette, on); + + float titleSize = 9f * s; + ClickGuiRenderer.drawText(ClickGuiRenderer.getInterRegular(), module.getDisplayName(), x + pad + 1f * s, + y + h - 19f * s, titleSize, palette.moduleTitleText(), false); + StatusRender status = statuses.computeIfAbsent(id, k -> new StatusRender()); + status.render(x + w - 24f * s, y + h - 14f * s, module.isEnabled() ? 1f : 0f, s); + } + + /** The player's own picture when set, otherwise an accent gradient with the cosmetic's initial. */ + private void drawPreview(Module module, float x, float y, float w, float h, float s, SettingsGuiPalette palette, float on) { + PreviewStore.Preview picture = previews.get(module.name()); + if (picture != null) { + PreviewCrop c = picture.crop(); + ClickGuiRenderer.flushRenderer(); + Renderer2D.TEXTURE.roundedTexRect(x, y, w, h, 4f * s, 0f, c.u0(), c.v0(), c.u1(), c.v1(), 0xFFFFFFFF, picture.texture()); + ClickGuiRenderer.flushRenderer(); + } else { + int a = SettingsGuiPalette.mix(palette.moduleCardBottom(), palette.moduleCardTopStrong(), 0.35f + 0.4f * on); + int b = SettingsGuiPalette.mix(palette.moduleCardTop(), palette.panelPillActive(), 0.3f + 0.4f * on); + LayoutRender2D.roundedQuad(x, y, w, h, 4f * s, a, b, a, b); + String initial = module.getDisplayName().isEmpty() ? "?" : module.getDisplayName().substring(0, 1).toUpperCase(); + float size = 26f * s; + float tw = ClickGuiRenderer.textWidth(ClickGuiRenderer.getInterRegular(), initial, size); + float th = ClickGuiRenderer.textHeight(ClickGuiRenderer.getInterRegular(), size); + ClickGuiRenderer.drawText(ClickGuiRenderer.getInterRegular(), initial, x + (w - tw) * 0.5f, y + (h - th) * 0.5f, size, + SettingsGuiPalette.withAlpha(palette.moduleTitleText(), 150 + Math.round(80f * on)), false); + } + float bx = x + w - 13f * s; + drawSmallButton("+", bx, y + 2f * s, s, palette, pictureButtons, module.name()); + if (picture != null) { + String label = ClickGuiI18n.tr("clickgui.settings.cosmetics.crop", "Crop"); + float lw = ClickGuiRenderer.textWidth(ClickGuiRenderer.getInterMedium(), label, 6.5f * s) + 8f * s; + drawSmallButton(label, bx - lw - 3f * s, y + 2f * s, s, palette, cropButtons, module.name(), lw); + } + } + + private void drawSmallButton(String label, float x, float y, float s, SettingsGuiPalette palette, + Map sink, String id) { + drawSmallButton(label, x, y, s, palette, sink, id, 11f * s); + } + + private void drawSmallButton(String label, float x, float y, float s, SettingsGuiPalette palette, + Map sink, String id, float w) { + float h = 11f * s; + boolean hover = ClickGuiMath.insideRect(ClickGuiRenderer.getMouseX(), ClickGuiRenderer.getMouseY(), x, y, w, h); + int fill = SettingsGuiPalette.withAlpha(hover ? palette.panelPillActive() : palette.panelPillBase(), hover ? 235 : 190); + LayoutRender2D.roundedQuad(x, y, w, h, h * 0.5f, fill, fill, fill, fill); + float size = 6.5f * s; + float tw = ClickGuiRenderer.textWidth(ClickGuiRenderer.getInterMedium(), label, size); + float th = ClickGuiRenderer.textHeight(ClickGuiRenderer.getInterMedium(), size); + ClickGuiRenderer.drawText(ClickGuiRenderer.getInterMedium(), label, x + (w - tw) * 0.5f, y + (h - th) * 0.5f, size, palette.panelText(), false); + sink.put(id, new Rect(x, y, w, h)); + } +} diff --git a/mod/src/main/java/dev/loki/lovisual/features/gui/clickgui/layout/screen/settings/implement/cosmetics/preview/PreviewCrop.java b/mod/src/main/java/dev/loki/lovisual/features/gui/clickgui/layout/screen/settings/implement/cosmetics/preview/PreviewCrop.java new file mode 100644 index 00000000..c3725817 --- /dev/null +++ b/mod/src/main/java/dev/loki/lovisual/features/gui/clickgui/layout/screen/settings/implement/cosmetics/preview/PreviewCrop.java @@ -0,0 +1,68 @@ +package dev.loki.lovisual.features.gui.clickgui.layout.screen.settings.implement.cosmetics.preview; + +import java.util.Locale; + +/** + * Which part of a picture a cosmetic card shows, as texture coordinates (0..1). The window always has + * the card's preview aspect ({@link #ASPECT}), so the player only moves it and zooms. + */ +public record PreviewCrop(float u0, float v0, float u1, float v1) { + /** Width over height of the card's preview area. */ + public static final float ASPECT = 98f / 57f; + private static final float MIN_ZOOM = 0.08f; + + /** The largest window of the preview aspect, centred on the picture. */ + public static PreviewCrop centered(int imageW, int imageH) { + float[] size = fullSize(imageW, imageH); + return new PreviewCrop((1f - size[0]) / 2f, (1f - size[1]) / 2f, (1f + size[0]) / 2f, (1f + size[1]) / 2f); + } + + public PreviewCrop moved(float du, float dv) { + float w = u1 - u0; + float h = v1 - v0; + float nu = clamp(u0 + du, 0f, 1f - w); + float nv = clamp(v0 + dv, 0f, 1f - h); + return new PreviewCrop(nu, nv, nu + w, nv + h); + } + + /** Scales the window around its centre; factor above 1 shows more of the picture. */ + public PreviewCrop zoomed(float factor, int imageW, int imageH) { + float[] max = fullSize(imageW, imageH); + float w = clamp((u1 - u0) * factor, max[0] * MIN_ZOOM, max[0]); + float h = w * imageW / (ASPECT * imageH); + float cu = clamp((u0 + u1) / 2f, w / 2f, 1f - w / 2f); + float cv = clamp((v0 + v1) / 2f, h / 2f, 1f - h / 2f); + return new PreviewCrop(cu - w / 2f, cv - h / 2f, cu + w / 2f, cv + h / 2f); + } + + public String serialize() { + return String.format(Locale.ROOT, "%.5f %.5f %.5f %.5f", u0, v0, u1, v1); + } + + /** Parses {@link #serialize()} output; null for anything malformed or out of range. */ + public static PreviewCrop parse(String text) { + if (text == null) return null; + String[] parts = text.trim().split("\\s+"); + if (parts.length != 4) return null; + try { + float[] v = new float[4]; + for (int i = 0; i < 4; i++) v[i] = Float.parseFloat(parts[i]); + boolean valid = v[0] >= 0f && v[1] >= 0f && v[2] <= 1f && v[3] <= 1f && v[0] < v[2] && v[1] < v[3]; + return valid ? new PreviewCrop(v[0], v[1], v[2], v[3]) : null; + } catch (NumberFormatException e) { + return null; + } + } + + /** Width and height (in uv) of the biggest window with the preview aspect that fits the picture. */ + private static float[] fullSize(int imageW, int imageH) { + float imageAspect = (float) imageW / Math.max(1, imageH); + return imageAspect >= ASPECT + ? new float[]{ASPECT / imageAspect, 1f} + : new float[]{1f, imageAspect / ASPECT}; + } + + private static float clamp(float v, float min, float max) { + return Math.max(min, Math.min(max, v)); + } +} diff --git a/mod/src/main/java/dev/loki/lovisual/features/gui/clickgui/layout/screen/settings/implement/cosmetics/preview/PreviewEditor.java b/mod/src/main/java/dev/loki/lovisual/features/gui/clickgui/layout/screen/settings/implement/cosmetics/preview/PreviewEditor.java new file mode 100644 index 00000000..ade97f82 --- /dev/null +++ b/mod/src/main/java/dev/loki/lovisual/features/gui/clickgui/layout/screen/settings/implement/cosmetics/preview/PreviewEditor.java @@ -0,0 +1,194 @@ +package dev.loki.lovisual.features.gui.clickgui.layout.screen.settings.implement.cosmetics.preview; + +import com.mojang.blaze3d.platform.NativeImage; +import dev.loki.lovisual.features.gui.clickgui.layout.screen.settings.SettingsGuiPalette; +import dev.loki.lovisual.features.gui.clickgui.layout.screen.settings.render.LayoutRender2D; +import dev.loki.lovisual.features.gui.clickgui.render.ClickGuiRenderer; +import dev.loki.lovisual.features.gui.clickgui.util.ClickGuiI18n; +import dev.loki.lovisual.features.gui.clickgui.util.ClickGuiMath; +import dev.loki.lovisual.render.engine.renderer.Renderer2D; +import dev.loki.lovisual.util.logging.DebugLog; +import net.minecraft.client.Minecraft; +import net.minecraft.client.renderer.texture.DynamicTexture; +import net.minecraft.resources.Identifier; +import org.lwjgl.glfw.GLFW; + +import java.io.IOException; +import java.io.InputStream; +import java.nio.file.Files; +import java.nio.file.Path; + +/** Modal editor over the cosmetics page: drag the window over the picture, wheel to zoom, then save. */ +public final class PreviewEditor { + private static final Identifier EDIT_TEXTURE = Identifier.fromNamespaceAndPath("lovisual", "cosmetic_preview/editing"); + + private final PreviewStore store; + private String moduleId; + private Path source; + private int imageW; + private int imageH; + private PreviewCrop crop; + private boolean dragging; + private float lastMouseX; + private float lastMouseY; + private float boxX, boxY, boxW, boxH; + private float[] saveBtn = new float[4]; + private float[] cancelBtn = new float[4]; + private float[] removeBtn = new float[4]; + + public PreviewEditor(PreviewStore store) { + this.store = store; + } + + public boolean isOpen() { + return moduleId != null; + } + + public String moduleId() { + return moduleId; + } + + /** Opens the editor for a newly chosen picture, or for the saved one when {@code newSource} is null. */ + public boolean open(String id, Path newSource) { + Path file = newSource != null ? newSource : store.pictureOf(id); + Minecraft mc = Minecraft.getInstance(); + if (mc == null || !Files.isRegularFile(file)) return false; + try (InputStream in = Files.newInputStream(file)) { + NativeImage image = NativeImage.read(in); + imageW = image.getWidth(); + imageH = image.getHeight(); + mc.getTextureManager().release(EDIT_TEXTURE); + mc.getTextureManager().register(EDIT_TEXTURE, new DynamicTexture(() -> "lovisual_cosmetic_editing", image)); + } catch (IOException | RuntimeException e) { + DebugLog.errorOnce("cosmetic-preview-open", "[Cosmetics] Cannot open picture " + file, e); + return false; + } + PreviewStore.Preview saved = newSource == null ? store.get(id) : null; + crop = saved != null ? saved.crop() : PreviewCrop.centered(imageW, imageH); + source = newSource; + moduleId = id; + dragging = false; + return true; + } + + public void close() { + moduleId = null; + source = null; + dragging = false; + Minecraft mc = Minecraft.getInstance(); + if (mc != null) mc.getTextureManager().release(EDIT_TEXTURE); + } + + public void render(float menuX, float menuY, float menuW, float menuH, float mx, float my, float s) { + if (!isOpen()) return; + SettingsGuiPalette palette = SettingsGuiPalette.current(); + LayoutRender2D.rect(menuX, menuY, menuW, menuH, 0xDD000000); + layoutBox(menuX, menuY, menuW, menuH); + if (dragging) { + crop = crop.moved((mx - lastMouseX) / boxW, (my - lastMouseY) / boxH); + } + lastMouseX = mx; + lastMouseY = my; + + ClickGuiRenderer.flushRenderer(); + Renderer2D.TEXTURE.roundedTexRect(boxX, boxY, boxW, boxH, 0f, 0f, 0, 0, 1, 1, 0xFFFFFFFF, EDIT_TEXTURE); + ClickGuiRenderer.flushRenderer(); + drawCropFrame(palette, s); + float btnY = boxY + boxH + 10f * s; + float x = boxX; + saveBtn = button(ClickGuiI18n.tr("clickgui.settings.cosmetics.save", "Save"), x, btnY, true, mx, my, s, palette); + x += saveBtn[2] + 6f * s; + cancelBtn = button(ClickGuiI18n.tr("clickgui.settings.cosmetics.cancel", "Cancel"), x, btnY, false, mx, my, s, palette); + x += cancelBtn[2] + 6f * s; + removeBtn = source == null && store.get(moduleId) != null + ? button(ClickGuiI18n.tr("clickgui.settings.cosmetics.remove", "Remove picture"), x, btnY, false, mx, my, s, palette) + : new float[4]; + String hint = ClickGuiI18n.tr("clickgui.settings.cosmetics.crop_hint", "Drag to move, wheel to zoom"); + ClickGuiRenderer.drawText(ClickGuiRenderer.getInterRegular(), hint, boxX, boxY - 11f * s, 8f * s, palette.panelMuted(), false); + } + + public boolean mousePressed(float mx, float my, int button) { + if (!isOpen()) return false; + if (button != GLFW.GLFW_MOUSE_BUTTON_LEFT) return true; + if (hit(saveBtn, mx, my)) { + boolean ok = source != null ? store.save(moduleId, source, crop) : persistCrop(); + if (ok) close(); + } else if (hit(cancelBtn, mx, my)) { + close(); + } else if (hit(removeBtn, mx, my)) { + store.clear(moduleId); + close(); + } else if (ClickGuiMath.insideRect(mx, my, boxX, boxY, boxW, boxH)) { + dragging = true; + lastMouseX = mx; + lastMouseY = my; + } + return true; + } + + public void mouseReleased() { + dragging = false; + } + + public boolean mouseScrolled(double amount) { + if (!isOpen()) return false; + crop = crop.zoomed(amount > 0 ? 0.9f : 1.1f, imageW, imageH); + return true; + } + + public boolean keyPressed(int keyCode) { + if (!isOpen()) return false; + if (keyCode == GLFW.GLFW_KEY_ESCAPE) close(); + return true; + } + + private boolean persistCrop() { + store.saveCrop(moduleId, crop); + return true; + } + + private void layoutBox(float menuX, float menuY, float menuW, float menuH) { + float maxW = menuW * 0.62f; + float maxH = menuH * 0.58f; + float fit = Math.min(maxW / imageW, maxH / imageH); + boxW = imageW * fit; + boxH = imageH * fit; + boxX = menuX + (menuW - boxW) / 2f; + boxY = menuY + (menuH - boxH) / 2f - menuH * 0.04f; + } + + private void drawCropFrame(SettingsGuiPalette palette, float s) { + float cx0 = boxX + crop.u0() * boxW; + float cy0 = boxY + crop.v0() * boxH; + float cx1 = boxX + crop.u1() * boxW; + float cy1 = boxY + crop.v1() * boxH; + int shade = 0xAA000000; + LayoutRender2D.rect(boxX, boxY, boxW, cy0 - boxY, shade); + LayoutRender2D.rect(boxX, cy1, boxW, boxY + boxH - cy1, shade); + LayoutRender2D.rect(boxX, cy0, cx0 - boxX, cy1 - cy0, shade); + LayoutRender2D.rect(cx1, cy0, boxX + boxW - cx1, cy1 - cy0, shade); + float t = Math.max(1f, 0.8f * s); + int line = palette.panelPillActive() | 0xFF000000; + LayoutRender2D.rect(cx0, cy0, cx1 - cx0, t, line); + LayoutRender2D.rect(cx0, cy1 - t, cx1 - cx0, t, line); + LayoutRender2D.rect(cx0, cy0, t, cy1 - cy0, line); + LayoutRender2D.rect(cx1 - t, cy0, t, cy1 - cy0, line); + } + + private static float[] button(String label, float x, float y, boolean primary, float mx, float my, float s, SettingsGuiPalette palette) { + float size = 8f * s; + float w = ClickGuiRenderer.textWidth(ClickGuiRenderer.getInterMedium(), label, size) + 16f * s; + float h = 15f * s; + boolean hover = ClickGuiMath.insideRect(mx, my, x, y, w, h); + int base = primary ? palette.panelPillActive() : palette.panelPillBase(); + int fill = hover ? SettingsGuiPalette.mix(base, palette.panelText(), 0.15f) : base; + LayoutRender2D.roundedQuad(x, y, w, h, h * 0.5f, fill, fill, fill, fill); + float th = ClickGuiRenderer.textHeight(ClickGuiRenderer.getInterMedium(), size); + ClickGuiRenderer.drawText(ClickGuiRenderer.getInterMedium(), label, x + 8f * s, y + (h - th) / 2f, size, palette.panelText(), false); + return new float[]{x, y, w, h}; + } + + private static boolean hit(float[] r, float mx, float my) { + return r[2] > 0f && ClickGuiMath.insideRect(mx, my, r[0], r[1], r[2], r[3]); + } +} diff --git a/mod/src/main/java/dev/loki/lovisual/features/gui/clickgui/layout/screen/settings/implement/cosmetics/preview/PreviewFileDialog.java b/mod/src/main/java/dev/loki/lovisual/features/gui/clickgui/layout/screen/settings/implement/cosmetics/preview/PreviewFileDialog.java new file mode 100644 index 00000000..8646d55b --- /dev/null +++ b/mod/src/main/java/dev/loki/lovisual/features/gui/clickgui/layout/screen/settings/implement/cosmetics/preview/PreviewFileDialog.java @@ -0,0 +1,38 @@ +package dev.loki.lovisual.features.gui.clickgui.layout.screen.settings.implement.cosmetics.preview; + +import org.lwjgl.PointerBuffer; +import org.lwjgl.system.MemoryStack; +import org.lwjgl.util.tinyfd.TinyFileDialogs; + +import java.nio.file.Files; +import java.nio.file.Path; +import java.util.List; +import java.util.Locale; + +/** Native "pick a picture" dialog plus the extension check shared with drag-and-drop. */ +public final class PreviewFileDialog { + private static final List EXTENSIONS = List.of("png", "jpg", "jpeg", "bmp", "tga", "gif"); + + private PreviewFileDialog() { + } + + public static boolean isPicture(Path path) { + if (path == null || !Files.isRegularFile(path)) return false; + String name = path.getFileName().toString().toLowerCase(Locale.ROOT); + int dot = name.lastIndexOf('.'); + return dot >= 0 && EXTENSIONS.contains(name.substring(dot + 1)); + } + + /** Blocks until the dialog closes; null when cancelled or the choice is not a picture. */ + public static Path choose(String title) { + try (MemoryStack stack = MemoryStack.stackPush()) { + PointerBuffer filters = stack.mallocPointer(EXTENSIONS.size()); + for (String ext : EXTENSIONS) filters.put(stack.UTF8("*." + ext)); + filters.flip(); + String picked = TinyFileDialogs.tinyfd_openFileDialog(title, "", filters, "Pictures", false); + if (picked == null || picked.isBlank()) return null; + Path path = Path.of(picked); + return isPicture(path) ? path : null; + } + } +} diff --git a/mod/src/main/java/dev/loki/lovisual/features/gui/clickgui/layout/screen/settings/implement/cosmetics/preview/PreviewStore.java b/mod/src/main/java/dev/loki/lovisual/features/gui/clickgui/layout/screen/settings/implement/cosmetics/preview/PreviewStore.java new file mode 100644 index 00000000..0ce8a5a1 --- /dev/null +++ b/mod/src/main/java/dev/loki/lovisual/features/gui/clickgui/layout/screen/settings/implement/cosmetics/preview/PreviewStore.java @@ -0,0 +1,126 @@ +package dev.loki.lovisual.features.gui.clickgui.layout.screen.settings.implement.cosmetics.preview; + +import com.mojang.blaze3d.platform.NativeImage; +import dev.loki.lovisual.util.logging.DebugLog; +import net.fabricmc.loader.api.FabricLoader; +import net.minecraft.client.Minecraft; +import net.minecraft.client.renderer.texture.DynamicTexture; +import net.minecraft.resources.Identifier; + +import java.io.IOException; +import java.io.InputStream; +import java.nio.file.Files; +import java.nio.file.Path; +import java.util.HashMap; +import java.util.Map; + +/** The player's own card pictures: one PNG plus one crop per cosmetic, under config/lovisual/cosmetics/previews. */ +public final class PreviewStore { + public record Preview(Identifier texture, int width, int height, PreviewCrop crop) { + } + + private static final Preview NONE = new Preview(null, 0, 0, null); + private static final String NAMESPACE = "lovisual"; + + private final Map cache = new HashMap<>(); + + /** The card picture for a cosmetic, or null when the player has not set one. */ + public Preview get(String id) { + Preview cached = cache.computeIfAbsent(id, this::load); + return cached == NONE ? null : cached; + } + + /** Copies the picture in as PNG, stores the crop and refreshes the texture. */ + public boolean save(String id, Path source, PreviewCrop crop) { + try (InputStream in = Files.newInputStream(source); NativeImage image = NativeImage.read(in)) { + Files.createDirectories(dir()); + image.writeToFile(png(id)); + Files.writeString(cropFile(id), crop.serialize()); + } catch (IOException | RuntimeException e) { + DebugLog.errorOnce("cosmetic-preview-save-" + id, "[Cosmetics] Failed to save preview", e); + return false; + } + reload(id); + return true; + } + + /** Stores a new crop for the picture already saved. */ + public void saveCrop(String id, PreviewCrop crop) { + try { + Files.writeString(cropFile(id), crop.serialize()); + } catch (IOException e) { + DebugLog.errorOnce("cosmetic-preview-crop-" + id, "[Cosmetics] Failed to save crop", e); + return; + } + reload(id); + } + + public void clear(String id) { + try { + Files.deleteIfExists(png(id)); + Files.deleteIfExists(cropFile(id)); + } catch (IOException e) { + DebugLog.errorOnce("cosmetic-preview-clear-" + id, "[Cosmetics] Failed to remove preview", e); + } + release(id); + cache.put(id, NONE); + } + + public Path pictureOf(String id) { + return png(id); + } + + private void reload(String id) { + release(id); + cache.remove(id); + } + + private void release(String id) { + Minecraft mc = Minecraft.getInstance(); + if (mc != null) mc.getTextureManager().release(textureId(id)); + } + + private Preview load(String id) { + Path file = png(id); + if (!Files.isRegularFile(file)) return NONE; + Minecraft mc = Minecraft.getInstance(); + if (mc == null) return NONE; + try (InputStream in = Files.newInputStream(file)) { + NativeImage image = NativeImage.read(in); + int w = image.getWidth(); + int h = image.getHeight(); + Identifier texture = textureId(id); + mc.getTextureManager().register(texture, new DynamicTexture(() -> "lovisual_cosmetic_" + id, image)); + return new Preview(texture, w, h, readCrop(id, w, h)); + } catch (IOException | RuntimeException e) { + DebugLog.errorOnce("cosmetic-preview-load-" + id, "[Cosmetics] Failed to load preview", e); + return NONE; + } + } + + private PreviewCrop readCrop(String id, int w, int h) { + try { + Path file = cropFile(id); + PreviewCrop crop = Files.isRegularFile(file) ? PreviewCrop.parse(Files.readString(file)) : null; + return crop != null ? crop : PreviewCrop.centered(w, h); + } catch (IOException e) { + return PreviewCrop.centered(w, h); + } + } + + private static Identifier textureId(String id) { + return Identifier.fromNamespaceAndPath(NAMESPACE, "cosmetic_preview/" + id); + } + + private static Path dir() { + return FabricLoader.getInstance().getConfigDir().resolve("lovisual").resolve("cosmetics").resolve("previews"); + } + + private static Path png(String id) { + return dir().resolve(id + ".png"); + } + + private static Path cropFile(String id) { + return dir().resolve(id + ".crop"); + } +} diff --git a/mod/src/main/java/dev/loki/lovisual/features/gui/clickgui/layout/screen/settings/implement/look/ThemesPage.java b/mod/src/main/java/dev/loki/lovisual/features/gui/clickgui/layout/screen/settings/implement/look/ThemesPage.java new file mode 100644 index 00000000..b7cfef51 --- /dev/null +++ b/mod/src/main/java/dev/loki/lovisual/features/gui/clickgui/layout/screen/settings/implement/look/ThemesPage.java @@ -0,0 +1,22 @@ +package dev.loki.lovisual.features.gui.clickgui.layout.screen.settings.implement.look; + +import dev.loki.lovisual.features.gui.clickgui.util.ClickGuiI18n; + +/** The pages of the Themes category: colour themes, then how widgets and the ClickGUI are painted. */ +public enum ThemesPage { + THEMES("themes", "Themes"), + HUD("hud", "HUD"), + CLICKGUI("clickgui", "ClickGUI"); + + private final String key; + private final String fallbackTitle; + + ThemesPage(String key, String fallbackTitle) { + this.key = key; + this.fallbackTitle = fallbackTitle; + } + + public String title() { + return ClickGuiI18n.tr("clickgui.settings.themes.page." + key, fallbackTitle); + } +} diff --git a/mod/src/main/java/dev/loki/lovisual/features/gui/clickgui/layout/screen/settings/implement/look/ThemesPageSwitch.java b/mod/src/main/java/dev/loki/lovisual/features/gui/clickgui/layout/screen/settings/implement/look/ThemesPageSwitch.java new file mode 100644 index 00000000..cbb4a36c --- /dev/null +++ b/mod/src/main/java/dev/loki/lovisual/features/gui/clickgui/layout/screen/settings/implement/look/ThemesPageSwitch.java @@ -0,0 +1,59 @@ +package dev.loki.lovisual.features.gui.clickgui.layout.screen.settings.implement.look; + +import dev.loki.lovisual.features.gui.clickgui.layout.screen.settings.SettingsGuiPalette; +import dev.loki.lovisual.features.gui.clickgui.layout.screen.settings.render.LayoutRender2D; +import dev.loki.lovisual.features.gui.clickgui.render.ClickGuiRenderer; +import dev.loki.lovisual.features.gui.clickgui.util.ClickGuiMath; +import dev.loki.lovisual.render.engine.animation.AnimationUtility; + +import java.util.EnumMap; +import java.util.Map; + +/** Pill row in the Themes header that switches between {@link ThemesPage}s. */ +public final class ThemesPageSwitch { + private final Map rects = new EnumMap<>(ThemesPage.class); + private final Map anim = new EnumMap<>(ThemesPage.class); + private ThemesPage page = ThemesPage.THEMES; + + public ThemesPage page() { + return page; + } + + public void render(float menuX, float menuY, float menuW, float s) { + SettingsGuiPalette palette = SettingsGuiPalette.current(); + float size = 7.5f * s; + float h = 14f * s; + float total = 0f; + for (ThemesPage p : ThemesPage.values()) total += width(p, size, s) + 4f * s; + float x = menuX + menuW - total - 6f * s; + float y = menuY + 7f * s; + for (ThemesPage p : ThemesPage.values()) { + float w = width(p, size, s); + rects.put(p, new float[]{x, y, w, h}); + float a = anim.merge(p, p == page ? 1f : 0f, (old, t) -> AnimationUtility.approach(old, t, 0.2f)); + int fill = SettingsGuiPalette.mix(palette.panelPillBase(), palette.panelPillActive(), a); + LayoutRender2D.roundedQuad(x, y, w, h, h * 0.5f, fill, fill, fill, fill); + int text = SettingsGuiPalette.mix(palette.panelMuted(), palette.panelText(), a); + float th = ClickGuiRenderer.textHeight(ClickGuiRenderer.getInterMedium(), size); + ClickGuiRenderer.drawText(ClickGuiRenderer.getInterMedium(), p.title(), x + 7f * s, y + (h - th) / 2f, size, text, false); + x += w + 4f * s; + } + } + + /** Switches page when a pill was hit; returns true if the click was on the row. */ + public boolean click(float mx, float my, int button) { + if (button != 0) return false; + for (Map.Entry e : rects.entrySet()) { + float[] r = e.getValue(); + if (ClickGuiMath.insideRect(mx, my, r[0], r[1], r[2], r[3])) { + page = e.getKey(); + return true; + } + } + return false; + } + + private static float width(ThemesPage p, float size, float s) { + return ClickGuiRenderer.textWidth(ClickGuiRenderer.getInterMedium(), p.title(), size) + 14f * s; + } +} diff --git a/mod/src/main/java/dev/loki/lovisual/features/gui/clickgui/layout/screen/settings/implement/main/LookSettingsComponent.java b/mod/src/main/java/dev/loki/lovisual/features/gui/clickgui/layout/screen/settings/implement/main/LookSettingsComponent.java new file mode 100644 index 00000000..01208bb2 --- /dev/null +++ b/mod/src/main/java/dev/loki/lovisual/features/gui/clickgui/layout/screen/settings/implement/main/LookSettingsComponent.java @@ -0,0 +1,132 @@ +package dev.loki.lovisual.features.gui.clickgui.layout.screen.settings.implement.main; + +import dev.loki.lovisual.config.definition.SettingDef; +import dev.loki.lovisual.config.definition.SettingOwner; +import dev.loki.lovisual.config.style.ClickGuiLookConfig; +import dev.loki.lovisual.config.style.StyleConfig; +import dev.loki.lovisual.features.gui.clickgui.layout.screen.settings.SettingsGuiPalette; +import dev.loki.lovisual.features.gui.clickgui.layout.screen.settings.implement.look.ThemesPage; +import dev.loki.lovisual.features.gui.clickgui.layout.screen.settings.render.LayoutRender2D; +import dev.loki.lovisual.features.gui.clickgui.render.ClickGuiRenderer; +import dev.loki.lovisual.features.gui.clickgui.settings.core.Setting; +import dev.loki.lovisual.features.gui.clickgui.settings.core.SettingFactory; +import dev.loki.lovisual.features.gui.clickgui.settings.render.SettingRenderContext; +import dev.loki.lovisual.features.gui.clickgui.settings.render.SettingRenderSurface; +import dev.loki.lovisual.features.gui.clickgui.util.ClickGuiMath; +import dev.loki.lovisual.features.gui.hud.config.HudGlobalConfig; +import dev.loki.lovisual.render.helpers.clip.ScissorFunction; + +import java.util.ArrayList; +import java.util.EnumMap; +import java.util.List; +import java.util.Map; + +/** Themes tab pages that edit how things look: the HUD (surface style, blur, headers) and the ClickGUI blur. */ +public final class LookSettingsComponent { + private final Map> settings = new EnumMap<>(ThemesPage.class); + private final MainSettingsScroll scroll = new MainSettingsScroll(); + private final List hits = new ArrayList<>(); + private ThemesPage page = ThemesPage.HUD; + private float panelX, panelY, panelW, panelH; + private float contentX, contentY, contentW, contentH; + private float settingScale = 1f; + + public void resetScroll() { + scroll.reset(); + } + + public void render(ThemesPage shown, float menuX, float menuY, float menuW, float menuH, float mx, float my, float s) { + if (shown != page) { + page = shown; + scroll.reset(); + } + SettingsGuiPalette palette = SettingsGuiPalette.current(); + settingScale = Math.max(1f, s * 0.66f); + panelX = menuX + 31f * s; + panelY = menuY + 33f * s; + panelW = menuW - 42f * s; + panelH = menuH - 39f * s; + LayoutRender2D.roundedQuad(panelX, panelY, panelW, panelH, 5f * s, palette.moduleCardTop(), palette.moduleCardTopStrong(), + palette.moduleCardBottom(), palette.moduleCardBottomStrong()); + LayoutRender2D.roundedStroke(panelX, panelY, panelW, panelH, 5f * s, 0.5f * s, palette.menuWindowStroke()); + ClickGuiRenderer.drawText(ClickGuiRenderer.getInterRegular(), page.title(), panelX + 8f * s, panelY + 8f * s, 8f * s, palette.menuHeaderText(), false); + + contentX = panelX + 8f * s; + contentY = panelY + 27f * s; + contentW = Math.max(1f, panelW - 16f * s - (scroll.needsScrollbar() ? 7f * s : 0f)); + contentH = Math.max(1f, panelH - 35f * s); + List list = settings.computeIfAbsent(page, LookSettingsComponent::build); + + float total = 0f; + for (Setting setting : list) total += setting.getHeight() * setting.updateVisibilityAnim() + 6f * setting.getVisibilityAnim(); + scroll.layout(panelX, panelW, contentY, contentH, total, my, s); + + hits.clear(); + boolean clipped = ScissorFunction.pushRaw(contentX, contentY, contentW, contentH); + float y = contentY + scroll.smoothed(); + try (SettingRenderContext.Scope ignored = SettingRenderContext.push(SettingRenderSurface.SETTINGS, settingScale)) { + for (Setting setting : list) { + float anim = setting.getVisibilityAnim(); + if (!setting.isVisibilityTargetVisible() && anim <= 0.01f) continue; + float h = setting.getHeight() * anim; + if (h > 0.5f) { + hits.add(new SettingHit(setting, contentX, y, contentW, h)); + if (y + h >= contentY && y <= contentY + contentH) setting.render(contentX, y, contentW, mx, my); + } + y += h + 6f * anim; + } + } + if (clipped) ScissorFunction.pop(); + scroll.render(s, palette); + } + + public boolean mousePressedScrollbar(float mx, float my, int button) { + return scroll.press(mx, my, button); + } + + public boolean mouseClicked(float mx, float my, int button) { + if (button != 0 && button != 1) return false; + if (scroll.isHovered(mx, my)) return true; + if (!ClickGuiMath.insideRect(mx, my, contentX, contentY, contentW, contentH)) return false; + try (SettingRenderContext.Scope ignored = SettingRenderContext.push(SettingRenderSurface.SETTINGS, settingScale)) { + for (SettingHit hit : hits) { + if (!ClickGuiMath.insideRect(mx, my, hit.x(), hit.y(), hit.w(), hit.h())) continue; + hit.setting().mouseClicked(mx, my, button); + return true; + } + } + return true; + } + + public void mouseReleased(float mx, float my, int button) { + scroll.release(button); + List list = settings.get(page); + if (list == null) return; + try (SettingRenderContext.Scope ignored = SettingRenderContext.push(SettingRenderSurface.SETTINGS, settingScale)) { + for (Setting setting : list) setting.mouseReleased(mx, my, button); + } + } + + public void scroll(float mx, float my, double amount) { + if (ClickGuiMath.insideRect(mx, my, panelX, panelY, panelW, panelH)) scroll.addWheel(amount); + } + + private static List build(ThemesPage page) { + List out = new ArrayList<>(); + if (page == ThemesPage.HUD) { + add(out, StyleConfig.get().getSettingDefs(), StyleConfig.get()); + add(out, HudGlobalConfig.get().getLookSettingDefs(), HudGlobalConfig.get()); + } else { + add(out, ClickGuiLookConfig.get().getSettingDefs(), ClickGuiLookConfig.get()); + } + return out; + } + + private static void add(List out, List defs, SettingOwner owner) { + for (Setting setting : SettingFactory.fromDefs(defs)) { + setting.setParent(owner); + setting.preflightI18n(); + out.add(setting); + } + } +} diff --git a/mod/src/main/java/dev/loki/lovisual/features/gui/clickgui/layout/screen/settings/menu/MenuScreenInput.java b/mod/src/main/java/dev/loki/lovisual/features/gui/clickgui/layout/screen/settings/menu/MenuScreenInput.java index 754d9694..1583b585 100644 --- a/mod/src/main/java/dev/loki/lovisual/features/gui/clickgui/layout/screen/settings/menu/MenuScreenInput.java +++ b/mod/src/main/java/dev/loki/lovisual/features/gui/clickgui/layout/screen/settings/menu/MenuScreenInput.java @@ -1,8 +1,13 @@ package dev.loki.lovisual.features.gui.clickgui.layout.screen.settings; import dev.loki.lovisual.config.i18n.MainConfig; +import dev.loki.lovisual.features.gui.clickgui.layout.screen.settings.implement.cosmetics.CosmeticsComponent; +import dev.loki.lovisual.features.gui.clickgui.layout.screen.settings.implement.look.ThemesPage; import dev.loki.lovisual.features.gui.clickgui.layout.screen.settings.implement.module.ModuleComponent; +import dev.loki.lovisual.features.gui.clickgui.settings.core.Setting; +import dev.loki.lovisual.features.module.core.Module; import dev.loki.lovisual.features.gui.clickgui.layout.screen.settings.implement.theme.ThemeComponent; +import dev.loki.lovisual.features.gui.clickgui.render.ClickGuiRenderer; import dev.loki.lovisual.features.gui.clickgui.tabs.ClickGuiSearch; import dev.loki.lovisual.features.theme.EditableClickGuiTheme; import dev.loki.lovisual.features.theme.Theme; @@ -31,12 +36,22 @@ final class MenuScreenInput { state.category = hit; state.moduleComponent.resetScroll(); state.themeComponent.resetScroll(); + state.lookSettings.resetScroll(); + state.cosmeticsComponent.resetScroll(); state.mainSettingsComponent.resetScroll(); state.relationsComponent.resetScroll(); state.configProfilesComponent.resetScroll(); } return true; } + if (state.category == MenuScreen.Category.COSMETICS) { + return state.cosmeticsComponent.click(mx, my, button, module -> openModuleSettings(state, module)); + } + if (state.category == MenuScreen.Category.THEMES && state.themesPageSwitch.click(mx, my, button)) return true; + if (state.category == MenuScreen.Category.THEMES && state.themesPageSwitch.page() != ThemesPage.THEMES) { + if (state.lookSettings.mousePressedScrollbar(mx, my, button)) return true; + return state.lookSettings.mouseClicked(mx, my, button); + } if (state.category == MenuScreen.Category.THEMES) { if (state.themeComponent.mousePressedScrollbar(mx, my, button)) return true; ThemeComponent.ThemeAction action = state.themeComponent.click(mx, my, button); @@ -66,6 +81,8 @@ final class MenuScreenInput { void mouseReleased(MenuScreenState state, float mx, float my, int button) { if (!state.isInteractive()) return; state.settingsPanel.mouseReleased(mx, my, button); + state.cosmeticsComponent.mouseReleased(); + state.lookSettings.mouseReleased(mx, my, button); state.themeComponent.mouseReleased(button); state.mainSettingsComponent.mouseReleased(mx, my, button); state.relationsComponent.mouseReleased(button); @@ -76,10 +93,15 @@ final class MenuScreenInput { if (!state.isInteractive()) return; if (state.settingsPanel.mouseScrolled(mx, my, delta)) return; if (state.settingsPanel.blocksMenuInput()) return; + if (state.category == MenuScreen.Category.THEMES && state.themesPageSwitch.page() != ThemesPage.THEMES) { + state.lookSettings.scroll(mx, my, delta); + return; + } if (state.category == MenuScreen.Category.THEMES) { state.themeComponent.scroll(mx, my, state.menuX, state.menuY, state.menuW, state.menuH, delta, state.s); return; } + if (state.category == MenuScreen.Category.COSMETICS) { state.cosmeticsComponent.scroll(delta, state.s); return; } if (state.category == MenuScreen.Category.MAIN_SETTINGS) { state.mainSettingsComponent.scroll(mx, my, delta); return; } if (state.category == MenuScreen.Category.RELATIONS) { state.relationsComponent.scroll(mx, my, delta); return; } if (state.category == MenuScreen.Category.CONFIGS) { @@ -90,6 +112,7 @@ final class MenuScreenInput { } boolean keyPressed(MenuScreenState state, int keyCode, int scanCode, int modifiers) { if (!state.isInteractive()) return false; + if (state.category == MenuScreen.Category.COSMETICS && state.cosmeticsComponent.keyPressed(keyCode)) return true; if (state.settingsPanel.isActive() && state.settingsPanel.keyPressed(keyCode, scanCode, modifiers)) return true; if (isHintToggle(keyCode, modifiers)) { MainConfig config = MainConfig.get(); @@ -117,7 +140,11 @@ final class MenuScreenInput { return state.settingsPanel.charTyped(chr, modifiers); } boolean onFilesDrop(MenuScreenState state, List paths) { - if (!state.isInteractive() || state.category != MenuScreen.Category.CONFIGS) return false; + if (!state.isInteractive()) return false; + if (state.category == MenuScreen.Category.COSMETICS) { + return state.cosmeticsComponent.onFilesDrop(paths, ClickGuiRenderer.getMouseX(), ClickGuiRenderer.getMouseY()); + } + if (state.category != MenuScreen.Category.CONFIGS) return false; return state.configProfilesComponent.importDroppedFiles(paths); } private void handleThemeAction(MenuScreenState state, ThemeComponent.ThemeAction action) { @@ -132,6 +159,11 @@ final class MenuScreenInput { if (resolved == null || resolved.settings().isEmpty()) return; state.settingsPanel.open(resolved.getId(), resolved.title(), resolved.settings(), resolved.hudContext()); } + private void openModuleSettings(MenuScreenState state, Module module) { + List settings = CosmeticsComponent.settingsOf(module); + if (settings.isEmpty()) return; + state.settingsPanel.open(module.name(), module.getDisplayName(), settings, false); + } void openThemeEditor(MenuScreenState state, EditableClickGuiTheme editor) { if (editor == null) return; state.activeThemeEditor = editor; diff --git a/mod/src/main/java/dev/loki/lovisual/features/gui/clickgui/layout/screen/settings/menu/MenuScreenRenderer.java b/mod/src/main/java/dev/loki/lovisual/features/gui/clickgui/layout/screen/settings/menu/MenuScreenRenderer.java index 9695229e..1a744e6a 100644 --- a/mod/src/main/java/dev/loki/lovisual/features/gui/clickgui/layout/screen/settings/menu/MenuScreenRenderer.java +++ b/mod/src/main/java/dev/loki/lovisual/features/gui/clickgui/layout/screen/settings/menu/MenuScreenRenderer.java @@ -3,6 +3,7 @@ package dev.loki.lovisual.features.gui.clickgui.layout.screen.settings; import dev.loki.lovisual.config.i18n.MainConfig; import dev.loki.lovisual.features.gui.clickgui.layout.screen.settings.implement.module.ModuleComponent; import dev.loki.lovisual.features.gui.clickgui.layout.screen.settings.render.LayoutRender2D; +import dev.loki.lovisual.features.gui.clickgui.layout.screen.settings.implement.look.ThemesPage; import dev.loki.lovisual.features.gui.clickgui.render.ClickGuiRenderer; import dev.loki.lovisual.features.gui.clickgui.util.ClickGuiI18n; import dev.loki.lovisual.features.gui.clickgui.util.tooltip.ClickGuiHintOverlay; @@ -63,7 +64,13 @@ final class MenuScreenRenderer { if (renderMain && !state.settingsPanel.isActive()) renderContextHints(state, areaX, areaY, areaW, areaH, fadeProgress); } private void renderCategoryContent(MenuScreenState state, float mx, float my) { - if (state.category == MenuScreen.Category.THEMES) { state.themeComponent.render(state.menuX, state.menuY, state.menuW, state.menuH, mx, my, state.s); return; } + if (state.category == MenuScreen.Category.COSMETICS) { state.cosmeticsComponent.render(state.menuX, state.menuY, state.menuW, state.menuH, mx, my, state.s); return; } + if (state.category == MenuScreen.Category.THEMES) { + state.themesPageSwitch.render(state.menuX, state.menuY, state.menuW, state.s); + if (state.themesPageSwitch.page() == ThemesPage.THEMES) state.themeComponent.render(state.menuX, state.menuY, state.menuW, state.menuH, mx, my, state.s); + else state.lookSettings.render(state.themesPageSwitch.page(), state.menuX, state.menuY, state.menuW, state.menuH, mx, my, state.s); + return; + } if (state.category == MenuScreen.Category.MAIN_SETTINGS) { state.mainSettingsComponent.render(state.menuX, state.menuY, state.menuW, state.menuH, mx, my, state.s); return; } if (state.category == MenuScreen.Category.RELATIONS) { state.relationsComponent.render(state.menuX, state.menuY, state.menuW, state.menuH, mx, my, state.s); return; } if (state.category == MenuScreen.Category.CONFIGS) { state.configProfilesComponent.render(state.menuX, state.menuY, state.menuW, state.menuH, mx, my, state.s); return; } @@ -128,6 +135,7 @@ final class MenuScreenRenderer { switch (state.category) { case HUD -> { primary = ClickGuiI18n.tr("clickgui.hints.settings.hud.toggle", "LMB - toggle HUD element"); secondary = ClickGuiI18n.tr("clickgui.hints.settings.hud.settings", "RMB - open element settings"); tertiary = ClickGuiI18n.tr("clickgui.hints.settings.hud.drag", "Open settings - edit widget position"); } case UI -> { primary = ClickGuiI18n.tr("clickgui.hints.settings.ui.toggle", "LMB - toggle UI element"); secondary = ClickGuiI18n.tr("clickgui.hints.settings.ui.settings", "RMB - open element settings"); tertiary = ClickGuiI18n.tr("clickgui.hints.settings.ui.search", "Ctrl+F - search cards"); } + case COSMETICS -> { primary = ClickGuiI18n.tr("clickgui.hints.settings.cosmetics.toggle", "LMB - wear / take off"); secondary = ClickGuiI18n.tr("clickgui.hints.settings.cosmetics.settings", "RMB - open settings"); tertiary = ClickGuiI18n.tr("clickgui.hints.settings.cosmetics.kind", "Top pills - choose kind"); } case THEMES -> { primary = ClickGuiI18n.tr("clickgui.hints.settings.themes.select", "LMB - select theme"); secondary = ClickGuiI18n.tr("clickgui.hints.settings.themes.edit", "Edit - open theme editor"); tertiary = ClickGuiI18n.tr("clickgui.hints.settings.themes.scroll", "Wheel - scroll themes"); } case MAIN_SETTINGS -> { primary = ClickGuiI18n.tr("clickgui.hints.settings.main.select", "LMB - change setting"); secondary = ClickGuiI18n.tr("clickgui.hints.settings.main.category", "Left list - switch setting group"); tertiary = ClickGuiI18n.tr("clickgui.hints.settings.main.scroll", "Wheel - scroll settings"); } case RELATIONS -> { primary = ClickGuiI18n.tr("clickgui.hints.settings.relations.mode", "Top pills - choose relation list"); secondary = ClickGuiI18n.tr("clickgui.hints.settings.relations.add", "User-plus icon - pick online player"); tertiary = ClickGuiI18n.tr("clickgui.hints.settings.relations.search", "Search - filter names"); } diff --git a/mod/src/main/java/dev/loki/lovisual/features/gui/clickgui/layout/screen/settings/menu/MenuScreenState.java b/mod/src/main/java/dev/loki/lovisual/features/gui/clickgui/layout/screen/settings/menu/MenuScreenState.java index eb1ce382..b3c7ab8d 100644 --- a/mod/src/main/java/dev/loki/lovisual/features/gui/clickgui/layout/screen/settings/menu/MenuScreenState.java +++ b/mod/src/main/java/dev/loki/lovisual/features/gui/clickgui/layout/screen/settings/menu/MenuScreenState.java @@ -1,6 +1,9 @@ package dev.loki.lovisual.features.gui.clickgui.layout.screen.settings; import dev.loki.lovisual.features.gui.clickgui.layout.screen.settings.implement.config.ConfigProfilesComponent; +import dev.loki.lovisual.features.gui.clickgui.layout.screen.settings.implement.cosmetics.CosmeticsComponent; +import dev.loki.lovisual.features.gui.clickgui.layout.screen.settings.implement.look.ThemesPageSwitch; +import dev.loki.lovisual.features.gui.clickgui.layout.screen.settings.implement.main.LookSettingsComponent; import dev.loki.lovisual.features.gui.clickgui.layout.screen.settings.implement.main.MainSettingsComponent; import dev.loki.lovisual.features.gui.clickgui.layout.screen.settings.implement.module.ModuleComponent; import dev.loki.lovisual.features.gui.clickgui.layout.screen.settings.implement.module.SettingsPanelComponent; @@ -27,6 +30,9 @@ final class MenuScreenState { final CategoryContainerComponent categoryContainer = new CategoryContainerComponent(); final SearchComponent searchComponent = new SearchComponent(); final ModuleComponent moduleComponent = new ModuleComponent(); + final CosmeticsComponent cosmeticsComponent = new CosmeticsComponent(); + final ThemesPageSwitch themesPageSwitch = new ThemesPageSwitch(); + final LookSettingsComponent lookSettings = new LookSettingsComponent(); final ThemeComponent themeComponent = new ThemeComponent(); final ThemeEditorPreviewComponent themeEditorPreview = new ThemeEditorPreviewComponent(); final MainSettingsComponent mainSettingsComponent = new MainSettingsComponent(); diff --git a/mod/src/main/java/dev/loki/lovisual/features/gui/clickgui/render/ClickGuiDrawHelpers.java b/mod/src/main/java/dev/loki/lovisual/features/gui/clickgui/render/ClickGuiDrawHelpers.java index e80473dc..61da50c5 100644 --- a/mod/src/main/java/dev/loki/lovisual/features/gui/clickgui/render/ClickGuiDrawHelpers.java +++ b/mod/src/main/java/dev/loki/lovisual/features/gui/clickgui/render/ClickGuiDrawHelpers.java @@ -1,5 +1,6 @@ package dev.loki.lovisual.features.gui.clickgui.render; +import dev.loki.lovisual.config.style.ClickGuiLookConfig; import dev.loki.lovisual.render.engine.animation.AnimationUtility; import dev.loki.lovisual.render.engine.renderer.Renderer2D; import dev.loki.lovisual.render.engine.renderer.style.StyleSpec; @@ -175,13 +176,13 @@ public final class ClickGuiDrawHelpers { spec, scale, 0xFFFFFFFF, alpha, blurAlpha, SurfaceStyle.AUTO); } static float clickGuiBlurQuality() { - return 27.35f; + return ClickGuiLookConfig.get().blurQuality(); } static float clickGuiBlurDensityScale() { - return 1.16f; + return ClickGuiLookConfig.get().blurDensity(); } static float clickGuiGlassBlurScale() { - return 1.28f; + return ClickGuiLookConfig.get().glassBlur(); } static int getColorClipboard() { return ClickGuiRenderer.colorClipboard; diff --git a/mod/src/main/java/dev/loki/lovisual/features/gui/hud/config/HudGlobalConfig.java b/mod/src/main/java/dev/loki/lovisual/features/gui/hud/config/HudGlobalConfig.java index 5349e691..d7019429 100644 --- a/mod/src/main/java/dev/loki/lovisual/features/gui/hud/config/HudGlobalConfig.java +++ b/mod/src/main/java/dev/loki/lovisual/features/gui/hud/config/HudGlobalConfig.java @@ -87,6 +87,11 @@ public final class HudGlobalConfig implements JsonConfigObject, ConfigNameProvid return new ArrayList<>(settingDefs); } + /** Look settings shown in the Themes tab: headers and background blur. */ + public List getLookSettingDefs() { + return List.of(SettingDef.mode(headerMode), SettingDef.number(blurRadius)); + } + public List getGlobalPanelSettingDefs() { ensureSettingsInitialized(); return new ArrayList<>(globalPanelDefs); diff --git a/mod/src/main/java/dev/loki/lovisual/features/gui/hud/draggable/interaction/RegistryDragController.java b/mod/src/main/java/dev/loki/lovisual/features/gui/hud/draggable/interaction/RegistryDragController.java index 3e1975e4..e5bdf888 100644 --- a/mod/src/main/java/dev/loki/lovisual/features/gui/hud/draggable/interaction/RegistryDragController.java +++ b/mod/src/main/java/dev/loki/lovisual/features/gui/hud/draggable/interaction/RegistryDragController.java @@ -1,6 +1,7 @@ package dev.loki.lovisual.features.gui.hud.draggable.interaction; import dev.loki.lovisual.features.gui.hud.draggable.DraggableHudElement; +import dev.loki.lovisual.features.gui.hud.draggable.quick.HudQuickSettings; import dev.loki.lovisual.features.gui.hud.anchor.HudAnchorX; import dev.loki.lovisual.features.gui.hud.anchor.HudAnchorY; import dev.loki.lovisual.features.gui.hud.draggable.registry.RegistryState; @@ -32,6 +33,7 @@ public final class RegistryDragController { boolean left = GLFW.glfwGetMouseButton(mc.getWindow().handle(), GLFW.GLFW_MOUSE_BUTTON_1) == GLFW.GLFW_PRESS; boolean middle = GLFW.glfwGetMouseButton(mc.getWindow().handle(), GLFW.GLFW_MOUSE_BUTTON_MIDDLE) == GLFW.GLFW_PRESS; + boolean right = GLFW.glfwGetMouseButton(mc.getWindow().handle(), GLFW.GLFW_MOUSE_BUTTON_RIGHT) == GLFW.GLFW_PRESS; if (!allowDragging) { if (RegistryState.dragging != null) { @@ -45,6 +47,7 @@ public final class RegistryDragController { } RegistryState.dragging = null; RegistryState.draggingWithLeft = false; + HudQuickSettings.close(); RegistryDragSession.clearSnapPreview(); if (RegistryState.hudCursorActive) { SystemCursor.reset(); @@ -52,6 +55,14 @@ public final class RegistryDragController { } RegistryState.prevLeft = left; RegistryState.prevMiddle = middle; + RegistryState.prevRight = right; + return; + } + + if (handleQuickSettings(mx, my, left, right, screenW, screenH)) { + RegistryState.prevLeft = left; + RegistryState.prevMiddle = middle; + RegistryState.prevRight = right; return; } @@ -159,4 +170,39 @@ public final class RegistryDragController { RegistryState.prevLeft = left; RegistryState.prevMiddle = middle; } + + /** Right click on a widget opens its quick settings; true while the popup swallows this frame's input. */ + private static boolean handleQuickSettings(float mx, float my, boolean left, boolean right, int screenW, int screenH) { + if (right && !RegistryState.prevRight) { + DraggableHudElement target = topWidgetAt(mx, my); + if (target != null && RegistryState.dragging == null && HudQuickSettings.open(target, mx, my, screenW, screenH)) return true; + HudQuickSettings.close(); + return false; + } + if (!HudQuickSettings.isOpen()) return false; + if (left && !RegistryState.prevLeft) { + if (!HudQuickSettings.contains(mx, my)) { + HudQuickSettings.close(); + return false; + } + HudQuickSettings.click(mx, my); + return true; + } + if (left) HudQuickSettings.drag(mx); + else HudQuickSettings.release(); + return left && HudQuickSettings.contains(mx, my); + } + + private static DraggableHudElement topWidgetAt(float mx, float my) { + DraggableHudElement best = null; + long bestOrder = Long.MIN_VALUE; + for (DraggableHudElement w : RegistryState.WIDGETS) { + if (!RegistryWidgetStore.isWidgetVisibleForEditor(w) || !w.contains(mx, my)) continue; + if (w.getRuntimeRenderOrder() > bestOrder) { + bestOrder = w.getRuntimeRenderOrder(); + best = w; + } + } + return best; + } } diff --git a/mod/src/main/java/dev/loki/lovisual/features/gui/hud/draggable/quick/HudQuickSettings.java b/mod/src/main/java/dev/loki/lovisual/features/gui/hud/draggable/quick/HudQuickSettings.java new file mode 100644 index 00000000..d7dfa1d6 --- /dev/null +++ b/mod/src/main/java/dev/loki/lovisual/features/gui/hud/draggable/quick/HudQuickSettings.java @@ -0,0 +1,173 @@ +package dev.loki.lovisual.features.gui.hud.draggable.quick; + +import dev.loki.lovisual.config.io.ConfigSerializer; +import dev.loki.lovisual.config.values.collection.ConfigValue; +import dev.loki.lovisual.config.values.mode.EnumValue; +import dev.loki.lovisual.config.values.mode.ModeValue; +import dev.loki.lovisual.config.values.primitive.BooleanValue; +import dev.loki.lovisual.config.values.primitive.NumberValue; +import dev.loki.lovisual.features.gui.clickgui.settings.core.Setting; +import dev.loki.lovisual.features.gui.clickgui.settings.core.SettingFactory; +import dev.loki.lovisual.features.gui.hud.draggable.DraggableHudElement; + +import java.util.ArrayList; +import java.util.List; +import java.util.concurrent.CompletableFuture; + +/** + * Small settings popup shown over a HUD widget while the chat is open (right click on the widget). + * Handles toggles, sliders and mode switches; anything fancier stays in the ClickGUI. + */ +public final class HudQuickSettings { + static final float WIDTH = 150f; + static final float PAD = 6f; + static final float HEADER_H = 16f; + static final float ROW_H = 14f; + static final float SLIDER_ROW_H = 22f; + + private static DraggableHudElement widget; + private static List rows = List.of(); + private static float x; + private static float y; + private static Setting sliding; + private static boolean dirty; + + private HudQuickSettings() { + } + + public static boolean isOpen() { + return widget != null; + } + + static DraggableHudElement widget() { + return widget; + } + + static List rows() { + return rows; + } + + static float x() { + return x; + } + + static float y() { + return y; + } + + static float rowH(Setting setting) { + return setting.getConfigValue() instanceof NumberValue ? SLIDER_ROW_H : ROW_H; + } + + static float height() { + float h = HEADER_H + PAD; + for (Setting row : rows) h += rowH(row); + return h; + } + + /** Opens the popup next to the cursor; false when the widget has nothing it can edit here. */ + public static boolean open(DraggableHudElement target, float mouseX, float mouseY, float screenW, float screenH) { + List supported = new ArrayList<>(); + for (Setting setting : SettingFactory.fromDefs(target.getSettingDefs())) { + if (!supports(setting.getConfigValue())) continue; + setting.setParent(target); + setting.preflightI18n(); + supported.add(setting); + } + if (supported.isEmpty()) return false; + close(); + widget = target; + rows = supported; + x = clamp(mouseX + 6f, 4f, Math.max(4f, screenW - WIDTH - 4f)); + y = clamp(mouseY, 4f, Math.max(4f, screenH - height() - 4f)); + return true; + } + + public static void close() { + sliding = null; + widget = null; + rows = List.of(); + if (dirty) { + dirty = false; + CompletableFuture.runAsync(() -> { + try { + ConfigSerializer.saveAll(); + } catch (Throwable ignored) { + // a failed autosave must not break the chat screen; the next save retries + } + }); + } + } + + public static boolean contains(float mx, float my) { + return widget != null && mx >= x && mx <= x + WIDTH && my >= y && my <= y + height(); + } + + /** Left click inside the popup. */ + public static void click(float mx, float my) { + float rowY = y + HEADER_H; + for (Setting row : rows) { + float h = rowH(row); + if (my >= rowY && my < rowY + h) { + apply(row, mx, rowY); + return; + } + rowY += h; + } + } + + public static void drag(float mx) { + if (sliding != null && sliding.getConfigValue() instanceof NumberValue number) setNumber(number, mx); + } + + public static void release() { + sliding = null; + } + + static float trackX() { + return x + PAD; + } + + static float trackW() { + return WIDTH - PAD * 2f; + } + + private static void apply(Setting row, float mx, float rowY) { + ConfigValue value = row.getConfigValue(); + if (value instanceof BooleanValue flag) { + flag.set(!flag.get()); + } else if (value instanceof ModeValue mode) { + mode.next(); + } else if (value instanceof EnumValue option) { + cycle(option); + } else if (value instanceof NumberValue number) { + sliding = row; + setNumber(number, mx); + return; + } else { + return; + } + dirty = true; + } + + private static > void cycle(EnumValue value) { + E[] all = value.get().getDeclaringClass().getEnumConstants(); + value.set(all[(value.get().ordinal() + 1) % all.length]); + } + + private static void setNumber(NumberValue number, float mx) { + boolean integer = number.get() instanceof Integer || number.get() instanceof Long; + double v = QuickSliderMath.valueAt(mx, trackX(), trackW(), number.getMin().doubleValue(), number.getMax().doubleValue(), integer); + number.set(number.castToType(v)); + dirty = true; + } + + private static boolean supports(ConfigValue value) { + return value instanceof BooleanValue || value instanceof NumberValue + || value instanceof ModeValue || value instanceof EnumValue; + } + + private static float clamp(float v, float min, float max) { + return Math.max(min, Math.min(max, v)); + } +} diff --git a/mod/src/main/java/dev/loki/lovisual/features/gui/hud/draggable/quick/HudQuickSettingsRenderer.java b/mod/src/main/java/dev/loki/lovisual/features/gui/hud/draggable/quick/HudQuickSettingsRenderer.java new file mode 100644 index 00000000..e1042c08 --- /dev/null +++ b/mod/src/main/java/dev/loki/lovisual/features/gui/hud/draggable/quick/HudQuickSettingsRenderer.java @@ -0,0 +1,77 @@ +package dev.loki.lovisual.features.gui.hud.draggable.quick; + +import dev.loki.lovisual.config.values.collection.ConfigValue; +import dev.loki.lovisual.config.values.mode.EnumValue; +import dev.loki.lovisual.config.values.mode.ModeValue; +import dev.loki.lovisual.config.values.primitive.BooleanValue; +import dev.loki.lovisual.config.values.primitive.NumberValue; +import dev.loki.lovisual.features.gui.clickgui.settings.core.Setting; +import dev.loki.lovisual.features.gui.hud.config.HudRenderUtil; +import dev.loki.lovisual.features.theme.Theme; +import dev.loki.lovisual.features.theme.Themes; +import dev.loki.lovisual.render.engine.color.RenderColor; +import dev.loki.lovisual.render.engine.renderer.Renderer2D; +import dev.loki.lovisual.render.engine.text.render.TextRenderer; + +/** Draws {@link HudQuickSettings} in the HUD pipeline. */ +public final class HudQuickSettingsRenderer { + private static final float TEXT_SCALE = 0.8f; + + private HudQuickSettingsRenderer() { + } + + public static void render(Renderer2D renderer, TextRenderer text) { + if (!HudQuickSettings.isOpen() || text == null) return; + Themes.Theme theme = Theme.theme(); + float x = HudQuickSettings.x(); + float y = HudQuickSettings.y(); + float w = HudQuickSettings.WIDTH; + float h = HudQuickSettings.height(); + renderer.roundedRect(x, y, w, h, 5f, 1f, HudRenderUtil.setAlpha(theme.windowBg(), 0xEE)); + renderer.roundedRectStroke(x, y, w, h, 5f, 1f, 0.8f, HudRenderUtil.setAlpha(theme.windowStroke(), 0xC0)); + + text.begin(TEXT_SCALE, false, false); + try { + float th = (float) text.getHeight(false); + text.render(HudQuickSettings.widget().getTitle(), x + HudQuickSettings.PAD, y + (HudQuickSettings.HEADER_H - th) / 2f, + new RenderColor(theme.accent()), false); + float rowY = y + HudQuickSettings.HEADER_H; + for (Setting row : HudQuickSettings.rows()) { + float rh = HudQuickSettings.rowH(row); + drawRow(renderer, text, theme, row, x, rowY, rh, th); + rowY += rh; + } + } finally { + text.end(); + } + } + + private static void drawRow(Renderer2D renderer, TextRenderer text, Themes.Theme theme, Setting row, + float x, float rowY, float rh, float th) { + float pad = HudQuickSettings.PAD; + float right = x + HudQuickSettings.WIDTH - pad; + ConfigValue value = row.getConfigValue(); + float labelY = value instanceof NumberValue ? rowY + 2f : rowY + (rh - th) / 2f; + text.render(row.getDisplayName(), x + pad, labelY, new RenderColor(theme.textPrimary()), false); + if (value instanceof BooleanValue flag) { + float sw = 16f; + float sh = 8f; + float sy = rowY + (rh - sh) / 2f; + renderer.roundedRect(right - sw, sy, sw, sh, sh / 2f, 1f, flag.get() ? theme.accent() : theme.strokeSoft()); + float knob = sh - 2f; + renderer.roundedRect(flag.get() ? right - knob - 1f : right - sw + 1f, sy + 1f, knob, knob, knob / 2f, 1f, theme.textPrimary()); + } else if (value instanceof NumberValue number) { + String shown = number.toDisplay(); + text.render(shown, right - (float) text.getWidth(shown, false), labelY, new RenderColor(theme.textMuted()), false); + float ty = rowY + rh - 6f; + float tw = HudQuickSettings.trackW(); + float ratio = (float) QuickSliderMath.ratio(number.get().doubleValue(), number.getMin().doubleValue(), number.getMax().doubleValue()); + renderer.roundedRect(HudQuickSettings.trackX(), ty, tw, 3f, 1.5f, 1f, theme.strokeSoft()); + renderer.roundedRect(HudQuickSettings.trackX(), ty, Math.max(3f, tw * ratio), 3f, 1.5f, 1f, theme.accent()); + } else { + String shown = value instanceof ModeValue mode ? row.getOptionDisplayName(mode.get()) + : value instanceof EnumValue option ? row.getOptionDisplayName(option.getId()) : ""; + text.render(shown, right - (float) text.getWidth(shown, false), labelY, new RenderColor(theme.accentSoft()), false); + } + } +} diff --git a/mod/src/main/java/dev/loki/lovisual/features/gui/hud/draggable/quick/QuickSliderMath.java b/mod/src/main/java/dev/loki/lovisual/features/gui/hud/draggable/quick/QuickSliderMath.java new file mode 100644 index 00000000..b8d72b74 --- /dev/null +++ b/mod/src/main/java/dev/loki/lovisual/features/gui/hud/draggable/quick/QuickSliderMath.java @@ -0,0 +1,18 @@ +package dev.loki.lovisual.features.gui.hud.draggable.quick; + +/** Maps a mouse position on a slider track to a setting value, and back. */ +final class QuickSliderMath { + private QuickSliderMath() { + } + + static double ratio(double value, double min, double max) { + return max <= min ? 0.0 : Math.max(0.0, Math.min(1.0, (value - min) / (max - min))); + } + + /** Value under the cursor: whole numbers for integer settings, hundredths otherwise. */ + static double valueAt(float mouseX, float trackX, float trackW, double min, double max, boolean integer) { + double t = trackW <= 0f ? 0.0 : Math.max(0.0, Math.min(1.0, (mouseX - trackX) / trackW)); + double raw = min + (max - min) * t; + return integer ? Math.round(raw) : Math.round(raw * 100.0) / 100.0; + } +} diff --git a/mod/src/main/java/dev/loki/lovisual/features/gui/hud/draggable/registry/RegistryState.java b/mod/src/main/java/dev/loki/lovisual/features/gui/hud/draggable/registry/RegistryState.java index e5c9bd3d..0f7b1eb1 100644 --- a/mod/src/main/java/dev/loki/lovisual/features/gui/hud/draggable/registry/RegistryState.java +++ b/mod/src/main/java/dev/loki/lovisual/features/gui/hud/draggable/registry/RegistryState.java @@ -33,6 +33,7 @@ public final class RegistryState { public static boolean draggingWithLeft; public static boolean prevLeft; public static boolean prevMiddle; + public static boolean prevRight; public static long renderOrderSeq; public static boolean forceVisible; public static boolean snapPreviewActive; diff --git a/mod/src/main/java/dev/loki/lovisual/features/gui/hud/draggable/render/RegistryOverlayRenderer.java b/mod/src/main/java/dev/loki/lovisual/features/gui/hud/draggable/render/RegistryOverlayRenderer.java index bdba5805..5215e13c 100644 --- a/mod/src/main/java/dev/loki/lovisual/features/gui/hud/draggable/render/RegistryOverlayRenderer.java +++ b/mod/src/main/java/dev/loki/lovisual/features/gui/hud/draggable/render/RegistryOverlayRenderer.java @@ -1,6 +1,7 @@ package dev.loki.lovisual.features.gui.hud.draggable.render; import dev.loki.lovisual.features.gui.hud.draggable.DraggableHudElement; +import dev.loki.lovisual.features.gui.hud.draggable.quick.HudQuickSettingsRenderer; import dev.loki.lovisual.features.gui.hud.config.HudRenderUtil; import dev.loki.lovisual.features.gui.hud.anchor.WidgetAnchorSide; import dev.loki.lovisual.features.gui.hud.draggable.registry.RegistryAnchorLayout; @@ -27,6 +28,7 @@ public final class RegistryOverlayRenderer { int screenW, int screenH) { renderLinkWires(renderer, screenW, screenH); + HudQuickSettingsRenderer.render(renderer, fallbackTextRenderer != null ? fallbackTextRenderer : TextRenderer.get()); if (RegistryState.snapPreviewActive) { if (RegistryState.snapPreviewHasX) { diff --git a/mod/src/main/java/dev/loki/lovisual/features/module/modules/misc/sound/TotemSound.java b/mod/src/main/java/dev/loki/lovisual/features/module/modules/misc/sound/TotemSound.java new file mode 100644 index 00000000..932470b9 --- /dev/null +++ b/mod/src/main/java/dev/loki/lovisual/features/module/modules/misc/sound/TotemSound.java @@ -0,0 +1,59 @@ +package dev.loki.lovisual.features.module.modules.misc.sound; + +import dev.loki.lovisual.config.values.mode.ModeValue; +import dev.loki.lovisual.config.values.primitive.BooleanValue; +import dev.loki.lovisual.config.values.primitive.NumberValue; +import dev.loki.lovisual.features.module.core.Module; +import dev.loki.lovisual.features.module.core.ModuleCategory; +import dev.loki.lovisual.features.module.core.ModuleInfo; +import dev.loki.lovisual.util.wav.engine.CustomSoundEngine; +import net.minecraft.client.Minecraft; +import net.minecraft.resources.Identifier; +import net.minecraft.world.entity.Entity; + +import java.util.Locale; + +/** Plays our own sound when a totem of undying pops, optionally silencing the vanilla one. */ +@ModuleInfo( + id = "totemsound", + displayName = "Totem Sound", + aliases = {"totemsound"}, + category = ModuleCategory.MISC +) +public class TotemSound extends Module { + private static final String VANILLA_TOTEM_SOUND = "minecraft:item.totem.use"; + private static final String EVERYONE = "Everyone"; + private static final String SELF = "Self"; + private static final String OTHERS = "Others"; + + private final ModeValue sound = modeSetting("totemSoundSound", "sound", "Bell", + "Bell", "Bonk", "Bubble", "Crime", "Critical"); + private final ModeValue trigger = modeSetting("totemSoundTrigger", "trigger", EVERYONE, + EVERYONE, SELF, OTHERS); + private final NumberValue volume = num("totemSoundVolume", "volume", 1.0f, 0.0f, 1.0f); + private final NumberValue pitch = num("totemSoundPitch", "pitch", 1.0f, 0.5f, 2.0f); + private final BooleanValue muteVanilla = bool("totemSoundMuteVanilla", "mute_vanilla", false); + + /** Called on every totem pop packet; {@code entity} is the one that popped. */ + public void onTotemPop(Entity entity) { + if (!isEnabled() || entity == null) return; + if (!matches(trigger.get(), entity == Minecraft.getInstance().player)) return; + Identifier id = Identifier.fromNamespaceAndPath("lovisual", + "sounds/hitsounds/" + sound.get().toLowerCase(Locale.ROOT) + ".wav"); + CustomSoundEngine.get().play(id, volume.get(), pitch.get(), false, true); + } + + /** Volume multiplier for the sound engine: 0 for the vanilla totem sound when muted, else 1. */ + public float vanillaScale(String soundEventId) { + return isEnabled() && muteVanilla.get() && VANILLA_TOTEM_SOUND.equals(soundEventId) ? 0.0f : 1.0f; + } + + /** Pure decision: does this trigger mode cover the popping entity? */ + static boolean matches(String trigger, boolean isSelf) { + return switch (trigger) { + case SELF -> isSelf; + case OTHERS -> !isSelf; + default -> true; + }; + } +} diff --git a/mod/src/main/java/dev/loki/lovisual/features/module/modules/visuals/hats/ChinaHat.java b/mod/src/main/java/dev/loki/lovisual/features/module/modules/visuals/hats/ChinaHat.java index 2bf70701..a5acb41c 100644 --- a/mod/src/main/java/dev/loki/lovisual/features/module/modules/visuals/hats/ChinaHat.java +++ b/mod/src/main/java/dev/loki/lovisual/features/module/modules/visuals/hats/ChinaHat.java @@ -28,7 +28,7 @@ import dev.loki.lovisual.features.module.modules.visuals.hats.ChinaHatPalette; @ModuleInfo( id = "chinahat", displayName = "ChinaHat", - category = ModuleCategory.VISUALS + category = ModuleCategory.ACCESSORIES ) public final class ChinaHat extends Module { diff --git a/mod/src/main/java/dev/loki/lovisual/features/module/modules/visuals/hats/tazik/TazikHat.java b/mod/src/main/java/dev/loki/lovisual/features/module/modules/visuals/hats/tazik/TazikHat.java index 667e4faf..a33f09f5 100644 --- a/mod/src/main/java/dev/loki/lovisual/features/module/modules/visuals/hats/tazik/TazikHat.java +++ b/mod/src/main/java/dev/loki/lovisual/features/module/modules/visuals/hats/tazik/TazikHat.java @@ -25,7 +25,7 @@ import net.minecraft.world.entity.player.Player; @ModuleInfo( id = "tazikhat", displayName = "TazikHat", - category = ModuleCategory.VISUALS + category = ModuleCategory.ACCESSORIES ) public final class TazikHat extends Module { diff --git a/mod/src/main/java/dev/loki/lovisual/features/module/modules/visuals/knife/Knife.java b/mod/src/main/java/dev/loki/lovisual/features/module/modules/visuals/knife/Knife.java index d0365cb5..13bae37c 100644 --- a/mod/src/main/java/dev/loki/lovisual/features/module/modules/visuals/knife/Knife.java +++ b/mod/src/main/java/dev/loki/lovisual/features/module/modules/visuals/knife/Knife.java @@ -32,7 +32,7 @@ import java.util.random.RandomGenerator; id = "knife", displayName = "Knife", aliases = {"knife", "meleeskins"}, - category = ModuleCategory.VISUALS + category = ModuleCategory.ACCESSORIES ) public final class Knife extends Module { private static final String ACTION_INSPECT = "inspect"; diff --git a/mod/src/main/java/dev/loki/lovisual/features/module/modules/visuals/models/CustomModels.java b/mod/src/main/java/dev/loki/lovisual/features/module/modules/visuals/models/CustomModels.java index e1d32c0e..6081e26b 100644 --- a/mod/src/main/java/dev/loki/lovisual/features/module/modules/visuals/models/CustomModels.java +++ b/mod/src/main/java/dev/loki/lovisual/features/module/modules/visuals/models/CustomModels.java @@ -32,7 +32,7 @@ import java.util.Map; id = "custommodels", displayName = "Custom Models", aliases = {"custommodels", "models"}, - category = ModuleCategory.VISUALS + category = ModuleCategory.ACCESSORIES ) public class CustomModels extends Module { public static final String CHICKEN = "Chicken"; diff --git a/mod/src/main/java/dev/loki/lovisual/features/module/modules/visuals/trollface/TrollfaceMask.java b/mod/src/main/java/dev/loki/lovisual/features/module/modules/visuals/trollface/TrollfaceMask.java index 74089645..89f7de74 100644 --- a/mod/src/main/java/dev/loki/lovisual/features/module/modules/visuals/trollface/TrollfaceMask.java +++ b/mod/src/main/java/dev/loki/lovisual/features/module/modules/visuals/trollface/TrollfaceMask.java @@ -20,7 +20,7 @@ import net.minecraft.world.phys.Vec3; @ModuleInfo( id = "trollface_mask", displayName = "Trollface Mask", - category = ModuleCategory.VISUALS + category = ModuleCategory.ACCESSORIES ) public class TrollfaceMask extends Module { diff --git a/mod/src/main/java/dev/loki/lovisual/mixinhandlers/network/ClientPacketListenerPvpHandler.java b/mod/src/main/java/dev/loki/lovisual/mixinhandlers/network/ClientPacketListenerPvpHandler.java index ed1ac7b7..a34cc71a 100644 --- a/mod/src/main/java/dev/loki/lovisual/mixinhandlers/network/ClientPacketListenerPvpHandler.java +++ b/mod/src/main/java/dev/loki/lovisual/mixinhandlers/network/ClientPacketListenerPvpHandler.java @@ -16,6 +16,7 @@ import dev.loki.lovisual.events.impl.pvp.PvpChatEvent; import dev.loki.lovisual.events.impl.pvp.PvpTabEvent; import dev.loki.lovisual.features.module.lifecycle.Modules; import dev.loki.lovisual.features.module.modules.combat.pvp.PvpCooldowns; +import dev.loki.lovisual.features.module.modules.misc.sound.TotemSound; import dev.loki.lovisual.features.module.modules.visuals.fx.TotemFX; import dev.loki.lovisual.util.logging.ServerDumpUtil; import dev.loki.lovisual.util.player.interaction.NetworkStatsUtil; @@ -98,6 +99,8 @@ public final class ClientPacketListenerPvpHandler { Entity entity = packet.getEntity(listener.getLevel()); if (entity == null) return; + TotemSound totemSound = Modules.get(TotemSound.class); + if (totemSound != null) totemSound.onTotemPop(entity); if (entity.equals(client.player)) { PvpCooldowns cooldowns = Modules.get(PvpCooldowns.class); if (cooldowns != null) { diff --git a/mod/src/main/java/dev/loki/lovisual/mixins/render/sound/SoundEngineMixin.java b/mod/src/main/java/dev/loki/lovisual/mixins/render/sound/SoundEngineMixin.java index c6cf11a6..f9bff4bf 100644 --- a/mod/src/main/java/dev/loki/lovisual/mixins/render/sound/SoundEngineMixin.java +++ b/mod/src/main/java/dev/loki/lovisual/mixins/render/sound/SoundEngineMixin.java @@ -2,6 +2,7 @@ package dev.loki.lovisual.mixins.render.sound; import dev.loki.lovisual.features.module.lifecycle.Modules; import dev.loki.lovisual.features.module.modules.misc.sound.NoSound; +import dev.loki.lovisual.features.module.modules.misc.sound.TotemSound; import net.minecraft.client.resources.sounds.Sound; import net.minecraft.client.resources.sounds.SoundInstance; import net.minecraft.client.sounds.SoundEngine; @@ -33,6 +34,9 @@ public abstract class SoundEngineMixin { NoSound module = Modules.get(NoSound.class); if (module == null) return original; - return original * module.volumeScale(resolved.getLocation().toString()); + float scale = module.volumeScale(resolved.getLocation().toString()); + TotemSound totemSound = Modules.get(TotemSound.class); + if (totemSound != null) scale *= totemSound.vanillaScale(instance.getIdentifier().toString()); + return original * scale; } } diff --git a/mod/src/main/resources/assets/lovisual/lang/en_us.json b/mod/src/main/resources/assets/lovisual/lang/en_us.json index 4772dfab..a326e778 100644 --- a/mod/src/main/resources/assets/lovisual/lang/en_us.json +++ b/mod/src/main/resources/assets/lovisual/lang/en_us.json @@ -1,4 +1,23 @@ { + "clickgui.settings.cosmetics.save": "Save", + "clickgui.settings.cosmetics.cancel": "Cancel", + "clickgui.settings.cosmetics.remove": "Remove picture", + "clickgui.settings.cosmetics.crop": "Crop", + "clickgui.settings.cosmetics.pick": "Choose a picture", + "clickgui.settings.cosmetics.crop_hint": "Drag to move, wheel to zoom", + "clickgui.settings.themes.page.themes": "Themes", + "clickgui.settings.themes.page.hud": "HUD", + "clickgui.settings.themes.page.clickgui": "ClickGUI", + "setting.hud.blur_radius": "Widget blur strength", + "setting.clickgui_look.blurQuality": "Panel blur strength", + "setting.clickgui_look.blurDensity": "Panel blur density", + "setting.clickgui_look.glassBlur": "Glass blur strength", + "clickgui.settings.tab.cosmetics": "Cosmetics", + "clickgui.settings.cosmetics.kind.wings": "Wings", + "clickgui.settings.cosmetics.kind.hats": "Hats", + "clickgui.settings.cosmetics.kind.models": "Models", + "clickgui.settings.cosmetics.kind.masks": "Masks", + "clickgui.settings.cosmetics.kind.weapons": "Weapons", "clickgui.settings.tab.hud": "HUD", "clickgui.settings.tab.ui": "UI", "clickgui.settings.tab.themes": "Themes", @@ -3736,6 +3755,20 @@ "setting.elytratrails.color_mode.option.DoubleColor": "Double Color", "setting.elytratrails.color_mode.option.Analogous": "Analogous", "setting.elytratrails.color_mode.option.Theme": "Theme", + "module.totemsound.description": "Plays your own sound when a totem of undying pops, for you or for other players.", + "setting.totemsound.sound": "Sound", + "setting.totemsound.sound.option.Bell": "Bell", + "setting.totemsound.sound.option.Bonk": "Bonk", + "setting.totemsound.sound.option.Bubble": "Bubble", + "setting.totemsound.sound.option.Crime": "Crime", + "setting.totemsound.sound.option.Critical": "Critical", + "setting.totemsound.trigger": "Whose pops", + "setting.totemsound.trigger.option.Everyone": "Everyone", + "setting.totemsound.trigger.option.Self": "Only me", + "setting.totemsound.trigger.option.Others": "Only others", + "setting.totemsound.volume": "Volume", + "setting.totemsound.pitch": "Pitch", + "setting.totemsound.mute_vanilla": "Mute vanilla sound", "module.autosprint.description": "Holds the sprint key for you while you move forward.", "setting.autosprint.all_directions": "All directions", "setting.autosprint.while_using": "While using an item", diff --git a/mod/src/main/resources/assets/lovisual/lang/ru_ru.json b/mod/src/main/resources/assets/lovisual/lang/ru_ru.json index 5aaeed50..d58d6c7a 100644 --- a/mod/src/main/resources/assets/lovisual/lang/ru_ru.json +++ b/mod/src/main/resources/assets/lovisual/lang/ru_ru.json @@ -1,4 +1,23 @@ { + "clickgui.settings.cosmetics.save": "Сохранить", + "clickgui.settings.cosmetics.cancel": "Отмена", + "clickgui.settings.cosmetics.remove": "Убрать картинку", + "clickgui.settings.cosmetics.crop": "Кадр", + "clickgui.settings.cosmetics.pick": "Выберите картинку", + "clickgui.settings.cosmetics.crop_hint": "Тяните, чтобы сдвинуть, колесо меняет масштаб", + "clickgui.settings.themes.page.themes": "Темы", + "clickgui.settings.themes.page.hud": "HUD", + "clickgui.settings.themes.page.clickgui": "ClickGUI", + "setting.hud.blur_radius": "Сила размытия виджетов", + "setting.clickgui_look.blurQuality": "Сила размытия панелей", + "setting.clickgui_look.blurDensity": "Плотность размытия панелей", + "setting.clickgui_look.glassBlur": "Сила размытия стекла", + "clickgui.settings.tab.cosmetics": "Косметика", + "clickgui.settings.cosmetics.kind.wings": "Крылья", + "clickgui.settings.cosmetics.kind.hats": "Шапки", + "clickgui.settings.cosmetics.kind.models": "Модели", + "clickgui.settings.cosmetics.kind.masks": "Маски", + "clickgui.settings.cosmetics.kind.weapons": "Оружие", "clickgui.settings.tab.hud": "HUD", "clickgui.settings.tab.ui": "UI", "clickgui.settings.tab.themes": "Темы", @@ -3527,6 +3546,20 @@ "setting.elytratrails.color_mode.option.DoubleColor": "Два цвета", "setting.elytratrails.color_mode.option.Analogous": "Аналоговый", "setting.elytratrails.color_mode.option.Theme": "Тема", + "module.totemsound.description": "Проигрывает свой звук, когда срабатывает тотем бессмертия: у вас или у других игроков.", + "setting.totemsound.sound": "Звук", + "setting.totemsound.sound.option.Bell": "Колокол", + "setting.totemsound.sound.option.Bonk": "Бонк", + "setting.totemsound.sound.option.Bubble": "Пузырь", + "setting.totemsound.sound.option.Crime": "Крайм", + "setting.totemsound.sound.option.Critical": "Крит", + "setting.totemsound.trigger": "Чьи срабатывания", + "setting.totemsound.trigger.option.Everyone": "Все", + "setting.totemsound.trigger.option.Self": "Только мои", + "setting.totemsound.trigger.option.Others": "Только чужие", + "setting.totemsound.volume": "Громкость", + "setting.totemsound.pitch": "Высота тона", + "setting.totemsound.mute_vanilla": "Заглушить ванильный звук", "module.autosprint.description": "Сам удерживает бег, пока вы двигаетесь вперёд.", "setting.autosprint.all_directions": "Во всех направлениях", "setting.autosprint.while_using": "При использовании предмета", diff --git a/mod/src/test/java/dev/loki/lovisual/features/gui/clickgui/layout/screen/settings/implement/cosmetics/CosmeticKindTest.java b/mod/src/test/java/dev/loki/lovisual/features/gui/clickgui/layout/screen/settings/implement/cosmetics/CosmeticKindTest.java new file mode 100644 index 00000000..e3d78ffc --- /dev/null +++ b/mod/src/test/java/dev/loki/lovisual/features/gui/clickgui/layout/screen/settings/implement/cosmetics/CosmeticKindTest.java @@ -0,0 +1,29 @@ +package dev.loki.lovisual.features.gui.clickgui.layout.screen.settings.implement.cosmetics; + +import org.junit.jupiter.api.Test; + +import java.util.HashSet; +import java.util.Set; + +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertNull; +import static org.junit.jupiter.api.Assertions.assertTrue; + +public final class CosmeticKindTest { + + @Test + void modulesMapToTheirKind() { + assertEquals(CosmeticKind.WINGS, CosmeticKind.ofModule("wings")); + assertEquals(CosmeticKind.HATS, CosmeticKind.ofModule("tazikhat")); + assertEquals(CosmeticKind.WEAPONS, CosmeticKind.ofModule("knife")); + assertNull(CosmeticKind.ofModule("totemsound")); + } + + @Test + void noModuleIsListedTwice() { + Set seen = new HashSet<>(); + for (CosmeticKind kind : CosmeticKind.values()) { + for (String id : kind.moduleIds()) assertTrue(seen.add(id), id); + } + } +} diff --git a/mod/src/test/java/dev/loki/lovisual/features/gui/clickgui/layout/screen/settings/implement/cosmetics/preview/PreviewCropTest.java b/mod/src/test/java/dev/loki/lovisual/features/gui/clickgui/layout/screen/settings/implement/cosmetics/preview/PreviewCropTest.java new file mode 100644 index 00000000..a04b9451 --- /dev/null +++ b/mod/src/test/java/dev/loki/lovisual/features/gui/clickgui/layout/screen/settings/implement/cosmetics/preview/PreviewCropTest.java @@ -0,0 +1,58 @@ +package dev.loki.lovisual.features.gui.clickgui.layout.screen.settings.implement.cosmetics.preview; + +import org.junit.jupiter.api.Test; + +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertNotNull; +import static org.junit.jupiter.api.Assertions.assertNull; +import static org.junit.jupiter.api.Assertions.assertTrue; + +public final class PreviewCropTest { + private static float pixelAspect(PreviewCrop c, int w, int h) { + return (c.u1() - c.u0()) * w / ((c.v1() - c.v0()) * h); + } + + @Test + void centeredWindowHasPreviewAspectAndFitsPicture() { + for (int[] size : new int[][]{{1920, 1080}, {500, 900}, {300, 300}}) { + PreviewCrop c = PreviewCrop.centered(size[0], size[1]); + assertEquals(PreviewCrop.ASPECT, pixelAspect(c, size[0], size[1]), 1e-3f); + assertTrue(c.u0() >= 0f && c.v0() >= 0f && c.u1() <= 1f && c.v1() <= 1f); + } + } + + @Test + void movingStaysInsideThePicture() { + PreviewCrop c = PreviewCrop.centered(300, 300).zoomed(0.5f, 300, 300).moved(5f, -5f); + assertEquals(1f, c.u1(), 1e-5f); + assertEquals(0f, c.v0(), 1e-5f); + } + + @Test + void zoomKeepsAspectAndCannotExceedFullWindow() { + PreviewCrop full = PreviewCrop.centered(1000, 600); + PreviewCrop out = full.zoomed(4f, 1000, 600); + assertEquals(full.u1() - full.u0(), out.u1() - out.u0(), 1e-4f); + PreviewCrop in = full.zoomed(0.5f, 1000, 600); + assertEquals(PreviewCrop.ASPECT, pixelAspect(in, 1000, 600), 1e-3f); + assertTrue(in.u1() - in.u0() < full.u1() - full.u0()); + } + + @Test + void serializeRoundTrips() { + PreviewCrop c = PreviewCrop.centered(640, 480).zoomed(0.7f, 640, 480); + PreviewCrop back = PreviewCrop.parse(c.serialize()); + assertNotNull(back); + assertEquals(c.u0(), back.u0(), 1e-4f); + assertEquals(c.v1(), back.v1(), 1e-4f); + } + + @Test + void parseRejectsGarbage() { + assertNull(PreviewCrop.parse(null)); + assertNull(PreviewCrop.parse("1 2 3")); + assertNull(PreviewCrop.parse("a b c d")); + assertNull(PreviewCrop.parse("0.5 0.5 0.4 0.4")); + assertNull(PreviewCrop.parse("0 0 1.5 1")); + } +} diff --git a/mod/src/test/java/dev/loki/lovisual/features/gui/hud/draggable/quick/QuickSliderMathTest.java b/mod/src/test/java/dev/loki/lovisual/features/gui/hud/draggable/quick/QuickSliderMathTest.java new file mode 100644 index 00000000..ab8ecabc --- /dev/null +++ b/mod/src/test/java/dev/loki/lovisual/features/gui/hud/draggable/quick/QuickSliderMathTest.java @@ -0,0 +1,34 @@ +package dev.loki.lovisual.features.gui.hud.draggable.quick; + +import org.junit.jupiter.api.Test; + +import static org.junit.jupiter.api.Assertions.assertEquals; + +public final class QuickSliderMathTest { + + @Test + void valueFollowsTheCursorAcrossTheTrack() { + assertEquals(0.0, QuickSliderMath.valueAt(10f, 10f, 100f, 0, 10, false), 1e-9); + assertEquals(5.0, QuickSliderMath.valueAt(60f, 10f, 100f, 0, 10, false), 1e-9); + assertEquals(10.0, QuickSliderMath.valueAt(110f, 10f, 100f, 0, 10, false), 1e-9); + } + + @Test + void cursorBeyondTheTrackIsClamped() { + assertEquals(1.0, QuickSliderMath.valueAt(-50f, 10f, 100f, 1, 4, true), 1e-9); + assertEquals(4.0, QuickSliderMath.valueAt(500f, 10f, 100f, 1, 4, true), 1e-9); + } + + @Test + void integerSettingsRoundToWholeNumbersAndFloatsToHundredths() { + assertEquals(3.0, QuickSliderMath.valueAt(60f, 10f, 100f, 1, 4, true), 1e-9); + assertEquals(0.33, QuickSliderMath.valueAt(43f, 10f, 100f, 0, 1, false), 1e-9); + } + + @Test + void ratioIsClampedAndSafeForEmptyRanges() { + assertEquals(0.5, QuickSliderMath.ratio(5, 0, 10), 1e-9); + assertEquals(1.0, QuickSliderMath.ratio(20, 0, 10), 1e-9); + assertEquals(0.0, QuickSliderMath.ratio(3, 5, 5), 1e-9); + } +} diff --git a/mod/src/test/java/dev/loki/lovisual/features/module/modules/misc/sound/TotemSoundTest.java b/mod/src/test/java/dev/loki/lovisual/features/module/modules/misc/sound/TotemSoundTest.java new file mode 100644 index 00000000..e69de29b