feat(mod): cosmetics settings category with custom card previews, in-chat widget quick settings, HUD and ClickGUI look pages, TotemSound

- Cosmetics: new Settings category with kind pills (Wings, Hats, Models, Masks, Weapons) and a card grid; Wings, ChinaHat, TazikHat,
  CustomModels, TrollfaceMask and Knife moved to ModuleCategory.ACCESSORIES and hidden from the Modules panels
- Card previews: pick a picture (native dialog or drag and drop) and crop it in an editor; PreviewCrop, PreviewStore, PreviewEditor
  stored under config/lovisual/cosmetics/previews
- HUD: right click a widget while the chat is open to get a popup with its toggles, sliders and modes (quick settings)
- Themes tab: HUD and ClickGUI pages; new ClickGuiLookConfig replaces hard-coded panel blur constants, HudGlobalConfig look defs
- TotemSound: own sound on totem pop (everyone/self/others, volume, pitch, mute vanilla through SoundEngineMixin)
- tests: CosmeticKind, PreviewCrop, QuickSliderMath, TotemSound; ./gradlew test green
This commit is contained in:
loki5512344 2026-10-07 17:54:25 +02:00
parent 8bc884c297
commit edd6a3dc0b
Signed by: boba
GPG key ID: 253067914055423B
39 changed files with 1696 additions and 12 deletions

View file

@ -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) — память содержимого контейнеров, поиск предмета, подсветка где лежит.

View file

@ -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<Float> blurQuality = new NumberValue<>("blurQuality", 27.35f, 8f, 72f);
private final NumberValue<Float> blurDensity = new NumberValue<>("blurDensity", 1.16f, 0.3f, 2f);
private final NumberValue<Float> 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<SettingDef> 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<ConfigValue<?>> getConfigValues() {
return List.of(blurQuality, blurDensity, glassBlur);
}
}

View file

@ -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) {

View file

@ -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),

View file

@ -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<String> moduleIds;
CosmeticKind(String key, String fallbackTitle, String... moduleIds) {
this.key = key;
this.fallbackTitle = fallbackTitle;
this.moduleIds = List.of(moduleIds);
}
public List<String> 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;
}
}

View file

@ -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<Path> 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<Module> 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<Setting> settingsOf(Module module) {
List<Setting> settings = module.getSettings();
if (settings == null) return List.of();
for (Setting setting : settings) {
if (setting != null) setting.preflightI18n();
}
return settings;
}
static List<Module> modules(CosmeticKind kind) {
List<Module> out = new ArrayList<>();
for (String id : kind.moduleIds()) {
Module module = ModuleManager.get(id);
if (module != null) out.add(module);
}
return out;
}
}

View file

@ -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<CosmeticKind, Rect> pills = new HashMap<>();
private final List<Map.Entry<String, Rect>> cards = new ArrayList<>();
private final Map<String, Float> enabledAnim = new HashMap<>();
private final Map<String, Float> hoverAnim = new HashMap<>();
private final Map<String, StatusRender> statuses = new HashMap<>();
private final Map<CosmeticKind, Float> pillAnim = new HashMap<>();
private final PreviewStore previews;
private final Map<String, Rect> pictureButtons = new HashMap<>();
private final Map<String, Rect> 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<String, Rect> buttons, float mx, float my) {
for (Map.Entry<String, Rect> 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<CosmeticKind, Rect> pill : pills.entrySet()) {
if (pill.getValue().contains(mx, my)) return pill.getKey();
}
return null;
}
String cardAt(float mx, float my) {
for (Map.Entry<String, Rect> card : cards) {
if (card.getValue().contains(mx, my)) return card.getKey();
}
return null;
}
void render(CosmeticKind kind, List<Module> 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<String, Rect> 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<String, Rect> 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));
}
}

View file

@ -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));
}
}

View file

@ -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]);
}
}

View file

@ -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<String> 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;
}
}
}

View file

@ -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<String, Preview> 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");
}
}

View file

@ -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);
}
}

View file

@ -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<ThemesPage, float[]> rects = new EnumMap<>(ThemesPage.class);
private final Map<ThemesPage, Float> 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<ThemesPage, float[]> 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;
}
}

View file

@ -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<ThemesPage, List<Setting>> settings = new EnumMap<>(ThemesPage.class);
private final MainSettingsScroll scroll = new MainSettingsScroll();
private final List<SettingHit> 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<Setting> 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<Setting> 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<Setting> build(ThemesPage page) {
List<Setting> 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<Setting> out, List<SettingDef> defs, SettingOwner owner) {
for (Setting setting : SettingFactory.fromDefs(defs)) {
setting.setParent(owner);
setting.preflightI18n();
out.add(setting);
}
}
}

View file

@ -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<Path> 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<Setting> 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;

View file

@ -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"); }

View file

@ -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();

View file

@ -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;

View file

@ -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<SettingDef> getLookSettingDefs() {
return List.of(SettingDef.mode(headerMode), SettingDef.number(blurRadius));
}
public List<SettingDef> getGlobalPanelSettingDefs() {
ensureSettingsInitialized();
return new ArrayList<>(globalPanelDefs);

View file

@ -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;
}
}

View file

@ -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<Setting> 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<Setting> 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<Setting> 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 <E extends Enum<E>> void cycle(EnumValue<E> value) {
E[] all = value.get().getDeclaringClass().getEnumConstants();
value.set(all[(value.get().ordinal() + 1) % all.length]);
}
private static <N extends Number> void setNumber(NumberValue<N> 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));
}
}

View file

@ -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);
}
}
}

View file

@ -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;
}
}

View file

@ -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;

View file

@ -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) {

View file

@ -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<Float> volume = num("totemSoundVolume", "volume", 1.0f, 0.0f, 1.0f);
private final NumberValue<Float> 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;
};
}
}

View file

@ -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 {

View file

@ -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 {

View file

@ -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";

View file

@ -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";

View file

@ -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 {

View file

@ -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) {

View file

@ -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;
}
}

View file

@ -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",

View file

@ -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": "При использовании предмета",

View file

@ -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<String> seen = new HashSet<>();
for (CosmeticKind kind : CosmeticKind.values()) {
for (String id : kind.moduleIds()) assertTrue(seen.add(id), id);
}
}
}

View file

@ -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"));
}
}

View file

@ -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);
}
}