chore(history): squash 3 commit(s) from 2026-09-05

- feat: батч QoL-модулей из Delta + фиксы рендера руки
- refactor: Фаза 8 — разбивка топ-гигантов по правилу ≤200 строк
- refactor: Фаза 8, проход 2 — Predictions, TargetESP, HudNotifier, Module
This commit is contained in:
loki5512344 2026-09-05 23:07:43 +02:00
parent e504859777
commit d83f9a29ff
105 changed files with 10287 additions and 5785 deletions

View file

@ -13,7 +13,7 @@
## Правила (для мода, продублированы в его TODO) ## Правила (для мода, продублированы в его TODO)
- KISS / DRY / SOLID - KISS / DRY / SOLID
- Макс. 200 строк на файл (фасады-делегаты исключение) - Макс. 200 строк на файл (фасады-делегаты исключение)
- Макс. 3 файла на папку - Макс. 4 файла на папку
- Никакого статика, никаких this-escape, никаких мёртвых миксинов - Никакого статика, никаких this-escape, никаких мёртвых миксинов
- Без пустышек; каждый этап компилируется (`./gradlew build`) - Без пустышек; каждый этап компилируется (`./gradlew build`)
- Тесты обязательны для новых модулей (JUnit) - Тесты обязательны для новых модулей (JUnit)

View file

@ -5,7 +5,7 @@
## Правила (ОБЯЗАТЕЛЬНЫЕ, нарушение = откат) ## Правила (ОБЯЗАТЕЛЬНЫЕ, нарушение = откат)
- **KISS / DRY / SOLID** — простая структура, без оверинжиниринга - **KISS / DRY / SOLID** — простая структура, без оверинжиниринга
- **Макс. 200 строк на файл** (было >1500) — превышение = разбить на модули - **Макс. 200 строк на файл** (было >1500) — превышение = разбить на модули
- **Макс. 3 файла на папку** — больше = новая подпапка по смыслу - **Макс. 4 файла на папку** — больше = новая подпапка по смыслу
- **Никакого статика** — `Minecraft.getInstance()` только лениво, никогда в поле - **Никакого статика** — `Minecraft.getInstance()` только лениво, никогда в поле
- **Никаких this-escape** — конструктор не публикует `this` - **Никаких this-escape** — конструктор не публикует `this`
- **Никаких мёртвых миксинов** — миксин только если реально нужен; `cancellable=true` только при реальном cancel - **Никаких мёртвых миксинов** — миксин только если реально нужен; `cancellable=true` только при реальном cancel
@ -148,4 +148,68 @@
- [ ] Юнит-тесты: EventBus, конфиг, утилиты (игровые классы — mock) - [ ] Юнит-тесты: EventBus, конфиг, утилиты (игровые классы — mock)
- [ ] По мере переписи модулей — тест на каждый - [ ] По мере переписи модулей — тест на каждый
## Фаза 7: QoL-модули из Delta (легит) — ПЛАН, обсуждаем
> Референс: `ref/delta-26.2-main` (чит-клиент, код обфусцирован — берём только идеи,
> переписываем под наши правила; у Delta 114 модулей, у нас 63).
> Уже перенесено: SeeInvisibles→InvisESP, HandsShader→Chams/Noise, AtmoDawnFog,
> ShulkerPreview, Animations, ItemPhysic (из WorldTweaks), HoldMyItems (фикс ordinal-бага,
> математика в EaseCurves). Реф-исходники: ref/hmi-src, ref/soupvisuals-src.
### Решения по обсуждению (2026-09-05)
- [x] Wasted — НЕ делаем
- [x] TapeMouse — делаем (пользователь: AFK-фермы, не чит-софт)
- [x] DeathCoords — делаем
- [x] ItemScroller / Arrows — НЕ делаем
- [x] ColorPlus ≈ наш PostFX — PostFX восстановлен и починен (дефолтный пресет был near-identity → добавлен Cinematic + тесты)
- [x] Правило папок: максимум 4 файла (было 3)
### Перенос (список пользователя)
- [ ] **StreamerMode** (misc, ~80 у них) — при записи/стриме скрывает: ники игроков,
имена друзей, названия серверов («Анархия-N») в чате/таб-листе/скорборде; скрытие скинов.
Интеграция: наши BetterChat + CustomTabList + Scoreboard-виджет + рендер скинов.
- [x] **LockSlot** (player, ~36) — запрет выброса из выбранных слотов 1–9 (анти-случайный Q),
опция «только в PVP», предупреждение в чат. Точка: перехват drop-пакета.
- [x] **AutoRespawn** (player, ~28) — авто-возрождение после смерти (tick на death screen).
- [ ] **AutoLeave** (player, ~61) — выход в хаб по триггерам: малое ХП, игроки рядом (N, радиус), задержка.
- [ ] **AutoAccept** (player, ~67) — авто-принятие запросов: клан/ТП; опция «только от друзей»
через наш PlayerRelations. Точка: перехват chat-пакетов.
- [ ] **SoundReducer** (player, ~42) — приглушение выбранных звуков/категорий (громкость 0..N),
в отличие от нашего NoSound (он только вкл/выкл всего). Вопрос: новый модуль или развитие NoSound.
- [ ] **Wasted** (misc, ~100) — ОТМЕНЕНО (решение пользователя)
- [ ] **ThirdPerson** (player, ~77) — свободный обзор от 3-го лица без поворота движения
(по нажатию/зажатию бинда). Не дублирует Freecam (он про отдельную камеру-сущность).
Точка: наш CameraMixin.
- [ ] **UseTracker** (player, ~86) — уведомления об использованиях: тотем, зелья, предметы.
Вывод через наш HudNotifier. Точка: пакеты + инвентарь.
- [x] **NoServerPack** (misc) — у Delta ПУСТЫШКА (пустой класс). Сделаем по-настоящему:
авто-отклонение принудительного серверного ресурспака (спрашивать/отклонять всегда).
Точка: наш ClientCommonPacketListenerImplMixin.
- [x] **NoFriendDamage** (combat, ~19) — отмена атаки, если цель — друг (PlayerRelations).
Точка: перехват атаки (AttackEvent у них; у нас миксин на attack/interact-пакет).
- [x] **TapeMouse** (combat, ~66) — автоклик по таймеру. ⚠️ ПРОТИВОРЕЧИТ легит-политике Фазы 3.5
(тот же класс, что удалённые AutoAttack/TriggerBot). По умолчанию НЕ делаем / делаем
легит-вариант «напоминание о клике» без автоматического клика — РЕШИТЬ.
### Кандидаты из отчёта (не в списке пользователя — обсудить)
- [x] **DeathCoords** (~27) — координаты последней смерти в чат. Дёшево, полезно.
- [ ] **ItemScroller** — перекладывание предметов прокруткой в инвентарях. Крупный QoL (~200+).
- [ ] **ColorPlus** — ⚠️ это по сути наш удалённый PostFX (экспозиция/гамма/контраст/насыщенность/
виньетка/резкость/LUT). Восстанавливать под новым именем или забыть — РЕШИТЬ.
- [ ] **Arrows / off-screen индикаторы** — дополнение к Pointers (у нас их нет вообще).
### Проверка после каждого модуля: `./gradlew build` ✓, тесты на чистую логику ✓
## Фаза 8: Структура «как у Delta» (маленькие файлы) — ПЛАН
> Правило ≤200 уже есть (Фаза 1–3), но долг: ~240 файлов >200 строк.
> У Delta файлы компактные и слои разделены: api / client(features, ui, utils, processors) /
> inject(accessors, invokers, mixin). Наша пакетная структура сопоставима — проблема в размере файлов.
- [ ] Топ-гигантов к разбивке (после Фазы 3): LoVisualRenderPipelines 1527,
DraggableHudElementRegistry 1449, Predictions 1363, DynamicIsland 1254, Module 1181,
HudNotifier 1116, AltManager 1115, Triangulator 1102, TargetESP 1061, UiPrimitiveRenderer 1042
- [ ] Стратегия: как в Фазе 3 — фасад + package-private хелперы ≤200, 0 изменений вызывающих,
`./gradlew build` после каждого; по мере касания файлов в Фазе 7 — разбивать на ходу
- [ ] Facades >200 (Renderer2D 2150 и т.п.) — оставить как есть (исключение по правилам)
- [ ] Не смешивать с Фазой 7: разбивка структуры — отдельными проходами, иначе diff-каша
### Проверка: `./gradlew test` зелёный ### Проверка: `./gradlew test` зелёный

View file

@ -195,7 +195,8 @@ public abstract class DraggableHudElement extends BaseHudElement {
); );
} }
void markRendered(long order) { /** public: вызывается хелперами реестра в подпакетах registry/render (JLS: package-private недоступен из подпакета). */
public void markRendered(long order) {
this.renderOrder = order; this.renderOrder = order;
} }

View file

@ -7,24 +7,27 @@
package dev.loki.lovisual.features.gui.hud.draggable.impl; package dev.loki.lovisual.features.gui.hud.draggable.impl;
import dev.loki.lovisual.features.theme.Theme;
import com.mojang.blaze3d.platform.Window; import com.mojang.blaze3d.platform.Window;
import dev.loki.lovisual.util.screen.ClientScreen;
import net.minecraft.client.Minecraft;
import net.minecraft.client.gui.GuiGraphicsExtractor;
import net.minecraft.client.gui.screens.ChatScreen;
import net.minecraft.resources.Identifier;
import dev.loki.lovisual.config.SettingDef; import dev.loki.lovisual.config.SettingDef;
import dev.loki.lovisual.events.impl.RenderPrewarmCollectEvent;
import dev.loki.lovisual.config.values.BooleanValue; import dev.loki.lovisual.config.values.BooleanValue;
import dev.loki.lovisual.config.values.ModeValue; import dev.loki.lovisual.config.values.ModeValue;
import dev.loki.lovisual.config.values.NumberValue; import dev.loki.lovisual.config.values.NumberValue;
import dev.loki.lovisual.events.impl.RenderPrewarmCollectEvent;
import dev.loki.lovisual.features.gui.hud.HudElementRegister; import dev.loki.lovisual.features.gui.hud.HudElementRegister;
import dev.loki.lovisual.features.gui.hud.HudGlobalConfig; import dev.loki.lovisual.features.gui.hud.HudGlobalConfig;
import dev.loki.lovisual.features.gui.hud.HudRenderUtil;
import dev.loki.lovisual.features.gui.hud.draggable.DraggableHudElement; import dev.loki.lovisual.features.gui.hud.draggable.DraggableHudElement;
import dev.loki.lovisual.features.gui.hud.draggable.DraggableHudElementRegistry; import dev.loki.lovisual.features.gui.hud.draggable.DraggableHudElementRegistry;
import dev.loki.lovisual.features.theme.Themes; import dev.loki.lovisual.features.gui.hud.draggable.impl.hudnotifier.ToastCardRenderer;
import dev.loki.lovisual.features.gui.hud.draggable.impl.hudnotifier.ToastGeometry;
import dev.loki.lovisual.features.gui.hud.draggable.impl.hudnotifier.ToastGeometry.MessengerSide;
import dev.loki.lovisual.features.gui.hud.draggable.impl.hudnotifier.ToastGeometry.ToastMetrics;
import dev.loki.lovisual.features.gui.hud.draggable.impl.hudnotifier.ToastGeometry.ToastPosition;
import dev.loki.lovisual.features.gui.hud.draggable.impl.hudnotifier.ToastModel;
import dev.loki.lovisual.features.gui.hud.draggable.impl.hudnotifier.ToastModel.PreviewToast;
import dev.loki.lovisual.features.gui.hud.draggable.impl.hudnotifier.ToastModel.Toast;
import dev.loki.lovisual.features.gui.hud.draggable.impl.hudnotifier.ToastModel.ToastDraw;
import dev.loki.lovisual.features.gui.hud.draggable.impl.hudnotifier.ToastModel.ToastVisual;
import dev.loki.lovisual.features.gui.hud.draggable.impl.hudnotifier.ToastStyle;
import dev.loki.lovisual.features.module.HudPhase; import dev.loki.lovisual.features.module.HudPhase;
import dev.loki.lovisual.features.module.Module; import dev.loki.lovisual.features.module.Module;
import dev.loki.lovisual.features.module.ModuleManager; import dev.loki.lovisual.features.module.ModuleManager;
@ -33,11 +36,15 @@ import dev.loki.lovisual.render.engine.animation.AnimationUtility;
import dev.loki.lovisual.render.engine.color.RenderColor; import dev.loki.lovisual.render.engine.color.RenderColor;
import dev.loki.lovisual.render.engine.math.HudScale; import dev.loki.lovisual.render.engine.math.HudScale;
import dev.loki.lovisual.render.engine.renderer.Renderer2D; import dev.loki.lovisual.render.engine.renderer.Renderer2D;
import dev.loki.lovisual.render.engine.svg.SvgRenderOptions;
import dev.loki.lovisual.render.engine.text.FontInfo; import dev.loki.lovisual.render.engine.text.FontInfo;
import dev.loki.lovisual.render.engine.text.Fonts; import dev.loki.lovisual.render.engine.text.Fonts;
import dev.loki.lovisual.render.engine.text.TextRenderer; import dev.loki.lovisual.render.engine.text.TextRenderer;
import dev.loki.lovisual.util.screen.ClientScreen;
import dev.loki.lovisual.util.wav.CustomSoundEngine; import dev.loki.lovisual.util.wav.CustomSoundEngine;
import net.minecraft.client.Minecraft;
import net.minecraft.client.gui.GuiGraphicsExtractor;
import net.minecraft.client.gui.screens.ChatScreen;
import net.minecraft.resources.Identifier;
import java.util.ArrayList; import java.util.ArrayList;
import java.util.Iterator; import java.util.Iterator;
@ -52,43 +59,9 @@ public final class HudNotifier extends DraggableHudElement implements ModuleStat
private static final float BASE_MARGIN = 9f; private static final float BASE_MARGIN = 9f;
private static final float BASE_GAP = 3f; private static final float BASE_GAP = 3f;
private static final float BASE_BOTTOM_OFFSET = 160f; private static final float BASE_BOTTOM_OFFSET = 160f;
private static final float BASE_RADIUS = 4f;
private static final float BASE_BOX_HEIGHT = 16f;
private static final float BASE_LEFT_PAD = 7f;
private static final float BASE_RIGHT_PAD = 9f;
private static final float BASE_ICON_PIPE_GAP = 5f;
private static final float BASE_TOGGLE_PIPE_GAP = 4.5f;
private static final float BASE_PIPE_TEXT_GAP = 6f;
private static final float BASE_TOGGLE_W = 20f;
private static final float BASE_TOGGLE_H = 12f;
private static final float BASE_PROGRESS_H = 1.35f;
private static final float GLASS_BLUR_ALPHA = 255f;
private static final float GLASS_PASS_ALPHA = 255f;
private static final float TEXT_ALPHA_FACTOR = 0.88f;
private static final float PIPE_ALPHA_FACTOR = 0.72f;
private static final float TEXT_DARKEN_MIN = 0.78f;
private static final float TEXT_DARKEN_MAX = 1.00f;
private static final float TEXT_ANIM_SPEED = 2.2f;
private static final float LAYOUT_SMOOTH_SPEED = 14.0f; private static final float LAYOUT_SMOOTH_SPEED = 14.0f;
private static final float BASE_TOAST_SLIDE = 7.0f;
private static final float LAYOUT_SNAP_EPS = 0.12f; private static final float LAYOUT_SNAP_EPS = 0.12f;
private static final String ICON_NO = "I";
private static final String ICON_INFO = "J";
private static final String ICON_YES = "K";
private static final String ICON_WARN = "L";
private static final int ENTER_MS = 400;
private static final int DEFAULT_EXIT_MS = 260;
private static final long PREVIEW_ROTATE_MS = 1000L;
private static final int TOGGLE_TRANSITION_MS = 420;
private static final String PREVIEW_KEY = "preview";
private static final PreviewToast[] PREVIEW_EXAMPLES = new PreviewToast[]{
new PreviewToast("Module - enabled!", NotifyType.YES, ToastVisual.MODULE_TOGGLE, true),
new PreviewToast("Module - disabled!", NotifyType.NO, ToastVisual.MODULE_TOGGLE, false)
};
private final NumberValue<Integer> durationMs = new NumberValue<>("duration_ms", 2000, 500, 6000); private final NumberValue<Integer> durationMs = new NumberValue<>("duration_ms", 2000, 500, 6000);
private final NumberValue<Integer> fadeMs = new NumberValue<>("fade_ms", 250, 0, 1500); private final NumberValue<Integer> fadeMs = new NumberValue<>("fade_ms", 250, 0, 1500);
private final NumberValue<Double> scaleValue = new NumberValue<>("scale", 1.35, 0.5, 3.0); private final NumberValue<Double> scaleValue = new NumberValue<>("scale", 1.35, 0.5, 3.0);
@ -149,196 +122,6 @@ public final class HudNotifier extends DraggableHudElement implements ModuleStat
INSTANCE.renderPreviewInternal(renderer, textRenderer, x, y, w, h); INSTANCE.renderPreviewInternal(renderer, textRenderer, x, y, w, h);
} }
private static ToastPosition toastSlideOffset(MessengerSide side, float anim, float baseScale) {
float offset = (1.0f - clamp01(anim)) * BASE_TOAST_SLIDE * baseScale;
return switch (side) {
case LEFT -> new ToastPosition(-offset, 0f);
case TOP -> new ToastPosition(0f, -offset);
case BOTTOM -> new ToastPosition(0f, offset);
default -> new ToastPosition(offset, 0f);
};
}
private static PreviewToast previewExample(long now) {
int index = (int) ((Math.max(0L, now) / PREVIEW_ROTATE_MS) % PREVIEW_EXAMPLES.length);
return PREVIEW_EXAMPLES[index];
}
private static long previewStart(long now) {
long safeNow = Math.max(0L, now);
return safeNow - safeNow % PREVIEW_ROTATE_MS;
}
private static ToastMetrics measureToast(Toast toast,
TextRenderer lineRenderer,
TextRenderer iconRenderer,
float lineScale,
float iconScale,
float baseScale) {
boolean moduleToggle = toast.visual == ToastVisual.MODULE_TOGGLE;
String icon = iconFor(toast.type);
float iconW = moduleToggle ? BASE_TOGGLE_W * baseScale : measureWidth(iconRenderer, icon, iconScale);
float iconH = moduleToggle ? BASE_TOGGLE_H * baseScale : measureHeight(iconRenderer, iconScale);
float pipeW = measureWidth(lineRenderer, "|", lineScale);
float textW = measureWidth(lineRenderer, toast.line, lineScale);
if (PREVIEW_KEY.equals(toast.key)) {
textW = Math.max(textW, previewMaxTextWidth(lineRenderer, lineScale));
}
float textH = measureHeight(lineRenderer, lineScale);
float leftPad = BASE_LEFT_PAD * baseScale;
float rightPad = BASE_RIGHT_PAD * baseScale;
float iconPipeGap = (moduleToggle ? BASE_TOGGLE_PIPE_GAP : BASE_ICON_PIPE_GAP) * baseScale;
float pipeTextGap = BASE_PIPE_TEXT_GAP * baseScale;
float boxW = leftPad + iconW + iconPipeGap + pipeW + pipeTextGap + textW + rightPad;
float boxH = Math.max(BASE_BOX_HEIGHT * baseScale, Math.max(textH, iconH) + (6.0f * baseScale));
float radius = boxH * 0.5f;
float iconX = leftPad;
float iconY = (boxH - iconH) * 0.5f;
float pipeX = iconX + iconW + iconPipeGap;
float textX = pipeX + pipeW + pipeTextGap;
float textY = (boxH - textH) * 0.5f + (0.8f * baseScale);
return new ToastMetrics(boxW, boxH, radius, textScale(lineScale), iconScale, iconX, iconY, iconW, iconH, pipeX, textX, textY);
}
private static float previewMaxTextWidth(TextRenderer renderer, float scale) {
float max = 0f;
for (PreviewToast preview : PREVIEW_EXAMPLES) {
max = Math.max(max, measureWidth(renderer, preview.line(), scale));
}
return max;
}
private static float textScale(float value) {
return value;
}
private static ToastPalette paletteFor() {
int progressTrack = 0x33FFFFFF;
int progressFill = 0xC4FFFFFF;
int bg = 0x52000000;
return new ToastPalette(
bg,
bg,
0xFFF7FAFF,
progressTrack,
progressFill,
0xFFFFFFFF
);
}
private static NotifyType resolveType(String text, Boolean enabled) {
if (enabled != null) return enabled ? NotifyType.YES : NotifyType.NO;
String lower = text.toLowerCase();
if (lower.contains("warn") || lower.contains("warning") || lower.contains("cooldown")) {
return NotifyType.WARN;
}
if (lower.contains("error") || lower.contains("failed")
|| lower.contains("fail") || lower.contains("denied")
|| lower.contains("blocked") || lower.contains("disabled")) {
return NotifyType.NO;
}
return NotifyType.INFO;
}
private static String iconFor(NotifyType type) {
return switch (type) {
case YES -> ICON_YES;
case NO -> ICON_NO;
case WARN -> ICON_WARN;
case INFO -> ICON_INFO;
};
}
private static int colorFor(NotifyType type) {
return switch (type) {
case YES -> 0xFF6DFF56;
case NO -> 0xFFFF5656;
case WARN -> 0xFFF5FF5E;
case INFO -> 0xFF4AA3FF;
};
}
private static int moduleToggleColor(float progress, float anim) {
Themes.Theme theme = Theme.theme();
int off = theme != null ? HudRenderUtil.mixColor(theme.textMuted(), theme.textPrimary(), 0.18f) : 0xFFC6D2CD;
int on = theme != null ? theme.accent() : 0xFF5CC8E7;
int color = HudRenderUtil.mixColor(off, on, AnimationUtility.clamp01(progress));
return applyAlpha(color, anim);
}
private static String resolveLine(String text, Boolean enabled) {
if (enabled != null) {
return text + " - " + (enabled ? "enabled!" : "disabled!");
}
return text;
}
private static float easeOutBack(float t) {
float c1 = 1.70158f;
float c3 = c1 + 1f;
float p = t - 1f;
return 1f + c3 * p * p * p + c1 * p * p;
}
private static float clamp01(float v) {
return Math.max(0f, Math.min(1f, v));
}
private static int applyAlpha(int argb, float alpha) {
int a = (int) (((argb >>> 24) & 0xFF) * alpha);
return (argb & 0x00FFFFFF) | ((a & 0xFF) << 24);
}
private static float measureWidth(TextRenderer renderer, String text, float scale) {
if (renderer == null || text == null || text.isEmpty()) return 0f;
renderer.begin(scale, true, false);
float width = (float) renderer.getWidth(text, false);
renderer.end();
return width;
}
private static float measureHeight(TextRenderer renderer, float scale) {
if (renderer == null) return 0f;
renderer.begin(scale, true, false);
float height = (float) renderer.getHeight(false);
renderer.end();
return height;
}
private static int scaleAlpha(int argb, float factor) {
factor = AnimationUtility.clamp01(factor);
int a = (argb >>> 24) & 0xFF;
int scaledA = Math.round(a * factor);
return (argb & 0x00FFFFFF) | ((scaledA & 0xFF) << 24);
}
private static int darken(int argb, float factor) {
factor = AnimationUtility.clamp(factor, 0f, 1f);
int a = (argb >>> 24) & 0xFF;
int r = (argb >>> 16) & 0xFF;
int g = (argb >>> 8) & 0xFF;
int b = argb & 0xFF;
r = Math.round(r * factor);
g = Math.round(g * factor);
b = Math.round(b * factor);
return ((a & 0xFF) << 24)
| ((r & 0xFF) << 16)
| ((g & 0xFF) << 8)
| (b & 0xFF);
}
private static void applyColor(RenderColor out, int argb) {
out.a = (argb >>> 24) & 0xFF;
out.r = (argb >>> 16) & 0xFF;
out.g = (argb >>> 8) & 0xFF;
out.b = argb & 0xFF;
}
@Override @Override
protected void defineSettings(List<SettingDef> defs) { protected void defineSettings(List<SettingDef> defs) {
defs.add(SettingDef.number(durationMs)); defs.add(SettingDef.number(durationMs));
@ -360,7 +143,7 @@ public final class HudNotifier extends DraggableHudElement implements ModuleStat
float margin = BASE_MARGIN * scale; float margin = BASE_MARGIN * scale;
float width = 170f * scale; float width = 170f * scale;
float height = BASE_BOX_HEIGHT * scale; float height = ToastGeometry.BASE_BOX_HEIGHT * scale;
// Position side is intentionally automatic now; the draggable position itself defines slide direction. // Position side is intentionally automatic now; the draggable position itself defines slide direction.
this.x = Math.max(margin, screenW - margin - width); this.x = Math.max(margin, screenW - margin - width);
this.y = Math.max(margin, screenH - margin - BASE_BOTTOM_OFFSET * scale - height); this.y = Math.max(margin, screenH - margin - BASE_BOTTOM_OFFSET * scale - height);
@ -440,7 +223,7 @@ public final class HudNotifier extends DraggableHudElement implements ModuleStat
float anim = sample ? 1.0f : toast.anim(now, duration, fade); float anim = sample ? 1.0f : toast.anim(now, duration, fade);
if (anim <= 0.001f) continue; if (anim <= 0.001f) continue;
ToastMetrics metrics = measureToast(toast, lineRenderer, iconRenderer, lineScale, iconScale, baseScale); ToastMetrics metrics = ToastGeometry.measureToast(toast, lineRenderer, iconRenderer, lineScale, iconScale, baseScale);
float remaining = sample ? 0.45f : toast.remaining(now, duration); float remaining = sample ? 0.45f : toast.remaining(now, duration);
draws.add(new ToastDraw(toast, metrics, remaining, anim, 0f, 0f)); draws.add(new ToastDraw(toast, metrics, remaining, anim, 0f, 0f));
maxW = Math.max(maxW, metrics.boxW); maxW = Math.max(maxW, metrics.boxW);
@ -472,17 +255,18 @@ public final class HudNotifier extends DraggableHudElement implements ModuleStat
}; };
float targetY = cursorY; float targetY = cursorY;
ToastPosition pos = resolveToastPosition(draw.toast, targetX, targetY, sample, baseScale); ToastPosition pos = resolveToastPosition(draw.toast, targetX, targetY, sample, baseScale);
ToastPosition slide = toastSlideOffset(side, draw.anim, baseScale); ToastPosition slide = ToastGeometry.toastSlideOffset(side, draw.anim, baseScale);
draws.set(i, new ToastDraw(draw.toast, metrics, draw.remaining, draw.anim, pos.x() + slide.x(), pos.y() + slide.y())); draws.set(i, new ToastDraw(draw.toast, metrics, draw.remaining, draw.anim, pos.x() + slide.x(), pos.y() + slide.y()));
cursorY += (metrics.boxH + gap) * draw.anim; cursorY += (metrics.boxH + gap) * draw.anim;
} }
setBounds(x, y, layoutW, Math.max(1f, layoutH)); setBounds(x, y, layoutW, Math.max(1f, layoutH));
drawGlassBlurBatch(draws, baseScale); ToastGeometry.drawGlassBlurBatch(draws, baseScale);
for (ToastDraw draw : draws) { for (ToastDraw draw : draws) {
drawToastCard( ToastCardRenderer.drawToastCard(
renderer, renderer,
colorTmp,
draw.toast, draw.toast,
lineRenderer, lineRenderer,
iconRenderer, iconRenderer,
@ -559,9 +343,9 @@ public final class HudNotifier extends DraggableHudElement implements ModuleStat
} }
private Toast previewToast(long now) { private Toast previewToast(long now) {
PreviewToast preview = previewExample(now); PreviewToast preview = ToastModel.previewExample(now);
long start = previewStart(now); long start = ToastModel.previewStart(now);
return new Toast(PREVIEW_KEY, preview.line(), preview.type(), start, preview.visual(), preview.enabled()); return new Toast(ToastModel.PREVIEW_KEY, preview.line(), preview.type(), start, preview.visual(), preview.enabled());
} }
private void renderPreviewInternal(Renderer2D renderer, private void renderPreviewInternal(Renderer2D renderer,
@ -608,7 +392,7 @@ public final class HudNotifier extends DraggableHudElement implements ModuleStat
float anim = sampleMode ? 1.0f : toast.anim(now, duration, fade); float anim = sampleMode ? 1.0f : toast.anim(now, duration, fade);
if (anim <= 0.001f) continue; if (anim <= 0.001f) continue;
ToastMetrics metrics = measureToast(toast, lineRenderer, iconRenderer, lineScale, iconScale, baseScale); ToastMetrics metrics = ToastGeometry.measureToast(toast, lineRenderer, iconRenderer, lineScale, iconScale, baseScale);
float remaining = sampleMode ? 0.45f : toast.remaining(now, duration); float remaining = sampleMode ? 0.45f : toast.remaining(now, duration);
maxW = Math.max(maxW, metrics.boxW); maxW = Math.max(maxW, metrics.boxW);
draws.add(new ToastDraw(toast, metrics, remaining, anim, 0f, 0f)); draws.add(new ToastDraw(toast, metrics, remaining, anim, 0f, 0f));
@ -623,15 +407,16 @@ public final class HudNotifier extends DraggableHudElement implements ModuleStat
default -> originX + screenW - margin - metrics.boxW; default -> originX + screenW - margin - metrics.boxW;
}; };
float targetY = originY + cursorY; float targetY = originY + cursorY;
ToastPosition slide = toastSlideOffset(side, draw.anim, baseScale); ToastPosition slide = ToastGeometry.toastSlideOffset(side, draw.anim, baseScale);
draws.set(i, new ToastDraw(draw.toast, metrics, draw.remaining, draw.anim, targetX + slide.x(), targetY + slide.y())); draws.set(i, new ToastDraw(draw.toast, metrics, draw.remaining, draw.anim, targetX + slide.x(), targetY + slide.y()));
cursorY += (metrics.boxH + gap) * draw.anim; cursorY += (metrics.boxH + gap) * draw.anim;
} }
drawGlassBlurBatch(draws, baseScale); ToastGeometry.drawGlassBlurBatch(draws, baseScale);
for (ToastDraw draw : draws) { for (ToastDraw draw : draws) {
drawToastCard( ToastCardRenderer.drawToastCard(
renderer, renderer,
colorTmp,
draw.toast, draw.toast,
lineRenderer, lineRenderer,
iconRenderer, iconRenderer,
@ -665,7 +450,7 @@ public final class HudNotifier extends DraggableHudElement implements ModuleStat
} }
private void enqueue(String key, String text, Boolean enabled, NotifyType forcedType) { private void enqueue(String key, String text, Boolean enabled, NotifyType forcedType) {
NotifyType type = forcedType != null ? forcedType : resolveType(text, enabled); NotifyType type = forcedType != null ? forcedType : ToastStyle.resolveType(text, enabled);
enqueueInternal(key, text, enabled, type, ToastVisual.STANDARD); enqueueInternal(key, text, enabled, type, ToastVisual.STANDARD);
} }
@ -677,7 +462,7 @@ public final class HudNotifier extends DraggableHudElement implements ModuleStat
if (!enabledValue().get()) return; if (!enabledValue().get()) return;
if (hud.getHeaderMode() == HudGlobalConfig.HeaderMode.NEVER) return; if (hud.getHeaderMode() == HudGlobalConfig.HeaderMode.NEVER) return;
String line = resolveLine(text, enabled); String line = ToastStyle.resolveLine(text, enabled);
long now = System.currentTimeMillis(); long now = System.currentTimeMillis();
if (key != null) { if (key != null) {
@ -703,225 +488,6 @@ public final class HudNotifier extends DraggableHudElement implements ModuleStat
} }
} }
private void drawToastCard(Renderer2D renderer,
Toast toast,
TextRenderer lineRenderer,
TextRenderer iconRenderer,
float x,
float y,
ToastMetrics metrics,
float remaining,
float anim,
float baseScale) {
ToastPalette palette = paletteFor();
int glassTint = applyAlpha(palette.glassTint, anim);
int bgLeft = applyAlpha(palette.bgLeft, anim);
int bgRight = applyAlpha(palette.bgRight, anim);
long now = System.currentTimeMillis();
float wave = 0.5f + 0.5f * (float) Math.sin(now / 1000.0f * TEXT_ANIM_SPEED);
float darkenFactor = AnimationUtility.lerp(TEXT_DARKEN_MIN, TEXT_DARKEN_MAX, wave);
int text = scaleAlpha(applyAlpha(palette.text, anim), TEXT_ALPHA_FACTOR);
text = darken(text, darkenFactor);
int progressTrack = applyAlpha(palette.progressTrack, anim);
int progress = applyAlpha(palette.progressFill, anim);
int accent = applyAlpha(colorFor(toast.type), anim);
int pipe = scaleAlpha(applyAlpha(HudRenderUtil.mixColor(palette.text, palette.progressTrack, 0.55f), anim), PIPE_ALPHA_FACTOR);
pipe = darken(pipe, darkenFactor * 0.96f);
float sideBottomR = Math.max(1f, metrics.boxH * 0.5f);
float sideTopR = 0f;
drawGlassPill(
renderer,
x,
y,
metrics.boxW,
metrics.boxH,
sideTopR,
sideTopR,
sideBottomR,
sideBottomR,
glassTint,
anim
);
int bgMixed = HudRenderUtil.mixColor(bgLeft, bgRight, 0.5f);
int bgA = (bgMixed >>> 24) & 0xFF;
int bgSolid = HudRenderUtil.glassBackground(anim);
renderer.roundedRectCornersQuad(
x,
y,
metrics.boxW,
metrics.boxH,
sideTopR,
sideTopR,
sideBottomR,
sideBottomR,
1.0f,
bgSolid,
bgSolid,
bgSolid,
bgSolid
);
float progressInsetX = 0.45f * baseScale;
float progressInsetY = 0.08f * baseScale;
float progressH = Math.max(1f, BASE_PROGRESS_H * baseScale);
float progressY = y + progressInsetY;
float progressX = x + progressInsetX;
float progressAreaW = Math.max(0f, metrics.boxW - progressInsetX * 2f);
float progressW = Math.max(0f, progressAreaW * clamp01(remaining));
float progressTopR = 0f;
float progressBottomR = 0.02f * baseScale;
renderer.roundedRectCornersQuad(
progressX,
progressY,
progressAreaW,
progressH,
progressTopR,
progressTopR,
progressBottomR,
progressBottomR,
1.0f,
progressTrack,
progressTrack,
progressTrack,
progressTrack
);
if (progressW > 0f) {
renderer.roundedRectCornersQuad(
progressX,
progressY,
progressW,
progressH,
progressTopR,
progressTopR,
progressBottomR,
progressBottomR,
1.0f,
progress,
progress,
progress,
progress
);
}
if (toast.visual == ToastVisual.MODULE_TOGGLE) {
drawModuleToggle(renderer, toast, x + metrics.iconX, y + metrics.iconY, metrics.iconW, metrics.iconH, accent, anim, now);
} else {
iconRenderer.begin(metrics.iconScale, false, false);
applyColor(colorTmp, accent);
iconRenderer.render(iconFor(toast.type), x + metrics.iconX, y + metrics.iconY, colorTmp, false);
iconRenderer.end();
}
lineRenderer.begin(metrics.textScale, false, false);
applyColor(colorTmp, pipe);
lineRenderer.render("|", x + metrics.pipeX, y + metrics.textY, colorTmp, false);
applyColor(colorTmp, text);
lineRenderer.render(toast.line, x + metrics.textX, y + metrics.textY, colorTmp, false);
lineRenderer.end();
}
private void drawModuleToggle(Renderer2D renderer,
Toast toast,
float x,
float y,
float w,
float h,
int fallbackAccent,
float anim,
long now) {
if (renderer == null || w <= 0.1f || h <= 0.1f) return;
float progress = toast.toggleProgress(now);
int tint = moduleToggleColor(progress, anim);
if (((tint >>> 24) & 0xFF) <= 0) {
tint = fallbackAccent;
}
float svgY = y + (h - w) * 0.5f;
SvgRenderOptions options = SvgRenderOptions.overrideColor(tint);
if (progress <= 0.001f) {
renderer.svg("toggle-left", x, svgY, w, w, options);
return;
}
if (progress >= 0.999f) {
renderer.svg("toggle-right", x, svgY, w, w, options);
return;
}
renderer.svg("toggle-track", x, svgY, w, w, options);
float thumbTravel = w * (3f / 24f);
float thumbX = x + AnimationUtility.lerp(-thumbTravel, thumbTravel, progress);
renderer.svg("toggle-thumb", thumbX, svgY, w, w, options);
}
private void drawGlassBlurBatch(List<ToastDraw> draws, float baseScale) {
if (draws == null || draws.isEmpty()) return;
HudGlobalConfig hud = HudGlobalConfig.get();
if (hud == null) return;
Renderer2D.COLOR.blurComposite(composite -> {
for (ToastDraw draw : draws) {
if (draw == null || draw.metrics == null || draw.anim <= 0.001f) continue;
float a = clamp01(draw.anim);
float sideBottomR = Math.max(1f, draw.metrics.boxH * 0.5f);
composite.roundedRectCorners(
draw.x,
draw.y,
draw.metrics.boxW,
draw.metrics.boxH,
0f,
0f,
sideBottomR,
sideBottomR,
hud.getBlurRadius(),
1.0f,
(GLASS_BLUR_ALPHA / 255.0f) * a,
0xFFFFFF
);
}
});
}
private void drawGlassPill(Renderer2D renderer,
float x,
float y,
float w,
float h,
float radiusTL,
float radiusTR,
float radiusBR,
float radiusBL,
int tintArgb,
float anim) {
if (renderer == null) return;
float a = clamp01(anim);
if (a <= 0.001f) return;
float glassAlpha = (GLASS_PASS_ALPHA / 255.0f) * a;
float glassScale = Math.max(0.50f, Math.min(1.25f, h / 24.0f));
HudRenderUtil.drawLiquidGlassCorners(
x,
y,
w,
h,
radiusTL,
radiusTR,
radiusBR,
radiusBL,
glassScale,
false,
glassAlpha,
tintArgb
);
}
private int getDurationMs() { private int getDurationMs() {
return durationMs.get(); return durationMs.get();
} }
@ -978,23 +544,6 @@ public final class HudNotifier extends DraggableHudElement implements ModuleStat
return Identifier.fromNamespaceAndPath("lovisual", "sounds/" + base + ".wav"); return Identifier.fromNamespaceAndPath("lovisual", "sounds/" + base + ".wav");
} }
private enum MessengerSide {
RIGHT,
LEFT,
TOP,
BOTTOM;
static MessengerSide from(String name) {
if (name == null) return RIGHT;
return switch (name.toLowerCase()) {
case "left" -> LEFT;
case "top" -> TOP;
case "bottom" -> BOTTOM;
default -> RIGHT;
};
}
}
public enum NotifyType { public enum NotifyType {
YES, YES,
NO, NO,
@ -1002,107 +551,9 @@ public final class HudNotifier extends DraggableHudElement implements ModuleStat
INFO INFO
} }
private enum ToastVisual {
STANDARD,
MODULE_TOGGLE
}
private static final class Toast {
private final String key;
private String line;
private NotifyType type;
private ToastVisual visual;
private Boolean stateEnabled;
private long startMs;
private boolean layoutReady;
private float layoutX;
private float layoutY;
private Toast(String key, String line, NotifyType type, long startMs) {
this(key, line, type, startMs, ToastVisual.STANDARD, null);
}
private Toast(String key, String line, NotifyType type, long startMs, ToastVisual visual, Boolean stateEnabled) {
this.key = key;
this.line = line;
this.type = type;
this.visual = visual == null ? ToastVisual.STANDARD : visual;
this.stateEnabled = stateEnabled;
this.startMs = startMs;
}
boolean isExpired(long now, int duration) {
return now - startMs > duration;
}
float remaining(long now, int duration) {
if (duration <= 0) return 0f;
long age = Math.max(0L, now - startMs);
return 1f - clamp01((float) age / (float) duration);
}
float anim(long now, int duration, int fade) {
long age = Math.max(0L, now - startMs);
if (age >= duration) return 0f;
float in = clamp01((float) age / (float) ENTER_MS);
float enter = easeOutBack(in);
int outMs = Math.max(1, fade > 0 ? fade : DEFAULT_EXIT_MS);
float out = 1f;
if (age > duration - outMs) {
out = clamp01((float) (duration - age) / (float) outMs);
}
return clamp01(enter * out);
}
float toggleProgress(long now) {
boolean enabled = stateEnabled != null ? stateEnabled : type == NotifyType.YES;
long age = Math.max(0L, now - startMs);
float t = AnimationUtility.easeInOutCubic((float) age / (float) TOGGLE_TRANSITION_MS);
return enabled ? t : 1f - t;
}
}
private record PreviewToast(String line, NotifyType type, ToastVisual visual, Boolean enabled) {
}
private record LayoutSize(float width, float height) { private record LayoutSize(float width, float height) {
} }
private record ToastPosition(float x, float y) {
}
private record ToastDraw(Toast toast,
ToastMetrics metrics,
float remaining,
float anim,
float x,
float y) {
}
private record ToastMetrics(float boxW,
float boxH,
float radius,
float textScale,
float iconScale,
float iconX,
float iconY,
float iconW,
float iconH,
float pipeX,
float textX,
float textY) {
}
private record ToastPalette(int bgLeft,
int bgRight,
int text,
int progressTrack,
int progressFill,
int glassTint) {
}
@Override @Override
public void collectRenderPrewarm(RenderPrewarmCollectEvent event) { public void collectRenderPrewarm(RenderPrewarmCollectEvent event) {
super.collectRenderPrewarm(event); super.collectRenderPrewarm(event);

View file

@ -0,0 +1,193 @@
/*
* This file is part of the LoVisual Client distribution.
* Copyright (c) 2026 loki5512344.
*
* Licensed under the GNU General Public License v3.0.
*/
package dev.loki.lovisual.features.gui.hud.draggable.impl.hudnotifier;
import dev.loki.lovisual.features.gui.hud.HudRenderUtil;
import dev.loki.lovisual.features.gui.hud.draggable.impl.hudnotifier.ToastGeometry.ToastMetrics;
import dev.loki.lovisual.features.gui.hud.draggable.impl.hudnotifier.ToastModel.Toast;
import dev.loki.lovisual.features.gui.hud.draggable.impl.hudnotifier.ToastModel.ToastVisual;
import dev.loki.lovisual.features.gui.hud.draggable.impl.hudnotifier.ToastStyle.ToastPalette;
import dev.loki.lovisual.render.engine.animation.AnimationUtility;
import dev.loki.lovisual.render.engine.color.RenderColor;
import dev.loki.lovisual.render.engine.renderer.Renderer2D;
import dev.loki.lovisual.render.engine.text.TextRenderer;
/**
* Card drawing routines for HudNotifier toasts: glass pill background, progress bar
* and the icon/pipe/text layout of a single toast card.
*/
public final class ToastCardRenderer {
public static final float BASE_PROGRESS_H = 1.35f;
public static final float GLASS_PASS_ALPHA = 255f;
public static final float TEXT_ALPHA_FACTOR = 0.88f;
public static final float PIPE_ALPHA_FACTOR = 0.72f;
public static final float TEXT_DARKEN_MIN = 0.78f;
public static final float TEXT_DARKEN_MAX = 1.00f;
public static final float TEXT_ANIM_SPEED = 2.2f;
private ToastCardRenderer() {
}
public static void drawToastCard(Renderer2D renderer,
RenderColor colorTmp,
Toast toast,
TextRenderer lineRenderer,
TextRenderer iconRenderer,
float x,
float y,
ToastMetrics metrics,
float remaining,
float anim,
float baseScale) {
ToastPalette palette = ToastStyle.paletteFor();
int glassTint = ToastStyle.applyAlpha(palette.glassTint, anim);
int bgLeft = ToastStyle.applyAlpha(palette.bgLeft, anim);
int bgRight = ToastStyle.applyAlpha(palette.bgRight, anim);
long now = System.currentTimeMillis();
float wave = 0.5f + 0.5f * (float) Math.sin(now / 1000.0f * TEXT_ANIM_SPEED);
float darkenFactor = AnimationUtility.lerp(TEXT_DARKEN_MIN, TEXT_DARKEN_MAX, wave);
int text = ToastStyle.scaleAlpha(ToastStyle.applyAlpha(palette.text, anim), TEXT_ALPHA_FACTOR);
text = ToastStyle.darken(text, darkenFactor);
int progressTrack = ToastStyle.applyAlpha(palette.progressTrack, anim);
int progress = ToastStyle.applyAlpha(palette.progressFill, anim);
int accent = ToastStyle.applyAlpha(ToastStyle.colorFor(toast.type), anim);
int pipe = ToastStyle.scaleAlpha(ToastStyle.applyAlpha(HudRenderUtil.mixColor(palette.text, palette.progressTrack, 0.55f), anim), PIPE_ALPHA_FACTOR);
pipe = ToastStyle.darken(pipe, darkenFactor * 0.96f);
float sideBottomR = Math.max(1f, metrics.boxH * 0.5f);
float sideTopR = 0f;
drawGlassPill(
renderer,
x,
y,
metrics.boxW,
metrics.boxH,
sideTopR,
sideTopR,
sideBottomR,
sideBottomR,
glassTint,
anim
);
int bgMixed = HudRenderUtil.mixColor(bgLeft, bgRight, 0.5f);
int bgA = (bgMixed >>> 24) & 0xFF;
int bgSolid = HudRenderUtil.glassBackground(anim);
renderer.roundedRectCornersQuad(
x,
y,
metrics.boxW,
metrics.boxH,
sideTopR,
sideTopR,
sideBottomR,
sideBottomR,
1.0f,
bgSolid,
bgSolid,
bgSolid,
bgSolid
);
float progressInsetX = 0.45f * baseScale;
float progressInsetY = 0.08f * baseScale;
float progressH = Math.max(1f, BASE_PROGRESS_H * baseScale);
float progressY = y + progressInsetY;
float progressX = x + progressInsetX;
float progressAreaW = Math.max(0f, metrics.boxW - progressInsetX * 2f);
float progressW = Math.max(0f, progressAreaW * ToastModel.clamp01(remaining));
float progressTopR = 0f;
float progressBottomR = 0.02f * baseScale;
renderer.roundedRectCornersQuad(
progressX,
progressY,
progressAreaW,
progressH,
progressTopR,
progressTopR,
progressBottomR,
progressBottomR,
1.0f,
progressTrack,
progressTrack,
progressTrack,
progressTrack
);
if (progressW > 0f) {
renderer.roundedRectCornersQuad(
progressX,
progressY,
progressW,
progressH,
progressTopR,
progressTopR,
progressBottomR,
progressBottomR,
1.0f,
progress,
progress,
progress,
progress
);
}
if (toast.visual == ToastVisual.MODULE_TOGGLE) {
ToastStyle.drawModuleToggle(renderer, toast, x + metrics.iconX, y + metrics.iconY, metrics.iconW, metrics.iconH, accent, anim, now);
} else {
iconRenderer.begin(metrics.iconScale, false, false);
ToastStyle.applyColor(colorTmp, accent);
iconRenderer.render(ToastStyle.iconFor(toast.type), x + metrics.iconX, y + metrics.iconY, colorTmp, false);
iconRenderer.end();
}
lineRenderer.begin(metrics.textScale, false, false);
ToastStyle.applyColor(colorTmp, pipe);
lineRenderer.render("|", x + metrics.pipeX, y + metrics.textY, colorTmp, false);
ToastStyle.applyColor(colorTmp, text);
lineRenderer.render(toast.line, x + metrics.textX, y + metrics.textY, colorTmp, false);
lineRenderer.end();
}
public static void drawGlassPill(Renderer2D renderer,
float x,
float y,
float w,
float h,
float radiusTL,
float radiusTR,
float radiusBR,
float radiusBL,
int tintArgb,
float anim) {
if (renderer == null) return;
float a = ToastModel.clamp01(anim);
if (a <= 0.001f) return;
float glassAlpha = (GLASS_PASS_ALPHA / 255.0f) * a;
float glassScale = Math.max(0.50f, Math.min(1.25f, h / 24.0f));
HudRenderUtil.drawLiquidGlassCorners(
x,
y,
w,
h,
radiusTL,
radiusTR,
radiusBR,
radiusBL,
glassScale,
false,
glassAlpha,
tintArgb
);
}
}

View file

@ -0,0 +1,200 @@
/*
* This file is part of the LoVisual Client distribution.
* Copyright (c) 2026 loki5512344.
*
* Licensed under the GNU General Public License v3.0.
*/
package dev.loki.lovisual.features.gui.hud.draggable.impl.hudnotifier;
import dev.loki.lovisual.features.gui.hud.HudGlobalConfig;
import dev.loki.lovisual.features.gui.hud.draggable.impl.hudnotifier.ToastModel.PreviewToast;
import dev.loki.lovisual.features.gui.hud.draggable.impl.hudnotifier.ToastModel.Toast;
import dev.loki.lovisual.features.gui.hud.draggable.impl.hudnotifier.ToastModel.ToastDraw;
import dev.loki.lovisual.features.gui.hud.draggable.impl.hudnotifier.ToastModel.ToastVisual;
import dev.loki.lovisual.render.engine.renderer.Renderer2D;
import dev.loki.lovisual.render.engine.text.TextRenderer;
import java.util.List;
/**
* Layout and measurement math for HudNotifier toasts: box metrics, text measurement,
* slide-in offsets and the per-frame blur backdrop pass over the toast draw list.
*/
public final class ToastGeometry {
public static final float BASE_RADIUS = 4f;
public static final float BASE_BOX_HEIGHT = 16f;
public static final float BASE_LEFT_PAD = 7f;
public static final float BASE_RIGHT_PAD = 9f;
public static final float BASE_ICON_PIPE_GAP = 5f;
public static final float BASE_TOGGLE_PIPE_GAP = 4.5f;
public static final float BASE_PIPE_TEXT_GAP = 6f;
public static final float BASE_TOGGLE_W = 20f;
public static final float BASE_TOGGLE_H = 12f;
public static final float BASE_TOAST_SLIDE = 7.0f;
public static final float GLASS_BLUR_ALPHA = 255f;
private ToastGeometry() {
}
public static ToastPosition toastSlideOffset(MessengerSide side, float anim, float baseScale) {
float offset = (1.0f - ToastModel.clamp01(anim)) * BASE_TOAST_SLIDE * baseScale;
return switch (side) {
case LEFT -> new ToastPosition(-offset, 0f);
case TOP -> new ToastPosition(0f, -offset);
case BOTTOM -> new ToastPosition(0f, offset);
default -> new ToastPosition(offset, 0f);
};
}
public static ToastMetrics measureToast(Toast toast,
TextRenderer lineRenderer,
TextRenderer iconRenderer,
float lineScale,
float iconScale,
float baseScale) {
boolean moduleToggle = toast.visual == ToastVisual.MODULE_TOGGLE;
String icon = ToastStyle.iconFor(toast.type);
float iconW = moduleToggle ? BASE_TOGGLE_W * baseScale : measureWidth(iconRenderer, icon, iconScale);
float iconH = moduleToggle ? BASE_TOGGLE_H * baseScale : measureHeight(iconRenderer, iconScale);
float pipeW = measureWidth(lineRenderer, "|", lineScale);
float textW = measureWidth(lineRenderer, toast.line, lineScale);
if (ToastModel.PREVIEW_KEY.equals(toast.key)) {
textW = Math.max(textW, previewMaxTextWidth(lineRenderer, lineScale));
}
float textH = measureHeight(lineRenderer, lineScale);
float leftPad = BASE_LEFT_PAD * baseScale;
float rightPad = BASE_RIGHT_PAD * baseScale;
float iconPipeGap = (moduleToggle ? BASE_TOGGLE_PIPE_GAP : BASE_ICON_PIPE_GAP) * baseScale;
float pipeTextGap = BASE_PIPE_TEXT_GAP * baseScale;
float boxW = leftPad + iconW + iconPipeGap + pipeW + pipeTextGap + textW + rightPad;
float boxH = Math.max(BASE_BOX_HEIGHT * baseScale, Math.max(textH, iconH) + (6.0f * baseScale));
float radius = boxH * 0.5f;
float iconX = leftPad;
float iconY = (boxH - iconH) * 0.5f;
float pipeX = iconX + iconW + iconPipeGap;
float textX = pipeX + pipeW + pipeTextGap;
float textY = (boxH - textH) * 0.5f + (0.8f * baseScale);
return new ToastMetrics(boxW, boxH, radius, textScale(lineScale), iconScale, iconX, iconY, iconW, iconH, pipeX, textX, textY);
}
public static float previewMaxTextWidth(TextRenderer renderer, float scale) {
float max = 0f;
for (PreviewToast preview : ToastModel.PREVIEW_EXAMPLES) {
max = Math.max(max, measureWidth(renderer, preview.line(), scale));
}
return max;
}
public static float textScale(float value) {
return value;
}
public static float measureWidth(TextRenderer renderer, String text, float scale) {
if (renderer == null || text == null || text.isEmpty()) return 0f;
renderer.begin(scale, true, false);
float width = (float) renderer.getWidth(text, false);
renderer.end();
return width;
}
public static float measureHeight(TextRenderer renderer, float scale) {
if (renderer == null) return 0f;
renderer.begin(scale, true, false);
float height = (float) renderer.getHeight(false);
renderer.end();
return height;
}
public static void drawGlassBlurBatch(List<ToastDraw> draws, float baseScale) {
if (draws == null || draws.isEmpty()) return;
HudGlobalConfig hud = HudGlobalConfig.get();
if (hud == null) return;
Renderer2D.COLOR.blurComposite(composite -> {
for (ToastDraw draw : draws) {
if (draw == null || draw.metrics == null || draw.anim <= 0.001f) continue;
float a = ToastModel.clamp01(draw.anim);
float sideBottomR = Math.max(1f, draw.metrics.boxH * 0.5f);
composite.roundedRectCorners(
draw.x,
draw.y,
draw.metrics.boxW,
draw.metrics.boxH,
0f,
0f,
sideBottomR,
sideBottomR,
hud.getBlurRadius(),
1.0f,
(GLASS_BLUR_ALPHA / 255.0f) * a,
0xFFFFFF
);
}
});
}
public enum MessengerSide {
RIGHT,
LEFT,
TOP,
BOTTOM;
public static MessengerSide from(String name) {
if (name == null) return RIGHT;
return switch (name.toLowerCase()) {
case "left" -> LEFT;
case "top" -> TOP;
case "bottom" -> BOTTOM;
default -> RIGHT;
};
}
}
public record ToastPosition(float x, float y) {
}
public static final class ToastMetrics {
public final float boxW;
public final float boxH;
public final float radius;
public final float textScale;
public final float iconScale;
public final float iconX;
public final float iconY;
public final float iconW;
public final float iconH;
public final float pipeX;
public final float textX;
public final float textY;
public ToastMetrics(float boxW,
float boxH,
float radius,
float textScale,
float iconScale,
float iconX,
float iconY,
float iconW,
float iconH,
float pipeX,
float textX,
float textY) {
this.boxW = boxW;
this.boxH = boxH;
this.radius = radius;
this.textScale = textScale;
this.iconScale = iconScale;
this.iconX = iconX;
this.iconY = iconY;
this.iconW = iconW;
this.iconH = iconH;
this.pipeX = pipeX;
this.textX = textX;
this.textY = textY;
}
}
}

View file

@ -0,0 +1,142 @@
/*
* This file is part of the LoVisual Client distribution.
* Copyright (c) 2026 loki5512344.
*
* Licensed under the GNU General Public License v3.0.
*/
package dev.loki.lovisual.features.gui.hud.draggable.impl.hudnotifier;
import dev.loki.lovisual.features.gui.hud.draggable.impl.HudNotifier;
import dev.loki.lovisual.features.gui.hud.draggable.impl.hudnotifier.ToastGeometry.ToastMetrics;
import dev.loki.lovisual.render.engine.animation.AnimationUtility;
/**
* Toast state model for HudNotifier: lifecycle timing, preview rotation data, the
* mutable toast entries rendered by the notifier and the per-frame draw items.
*/
public final class ToastModel {
public static final int ENTER_MS = 400;
public static final int DEFAULT_EXIT_MS = 260;
public static final long PREVIEW_ROTATE_MS = 1000L;
public static final int TOGGLE_TRANSITION_MS = 420;
public static final String PREVIEW_KEY = "preview";
public static final PreviewToast[] PREVIEW_EXAMPLES = new PreviewToast[]{
new PreviewToast("Module - enabled!", HudNotifier.NotifyType.YES, ToastVisual.MODULE_TOGGLE, true),
new PreviewToast("Module - disabled!", HudNotifier.NotifyType.NO, ToastVisual.MODULE_TOGGLE, false)
};
private ToastModel() {
}
public static PreviewToast previewExample(long now) {
int index = (int) ((Math.max(0L, now) / PREVIEW_ROTATE_MS) % PREVIEW_EXAMPLES.length);
return PREVIEW_EXAMPLES[index];
}
public static long previewStart(long now) {
long safeNow = Math.max(0L, now);
return safeNow - safeNow % PREVIEW_ROTATE_MS;
}
public static float easeOutBack(float t) {
float c1 = 1.70158f;
float c3 = c1 + 1f;
float p = t - 1f;
return 1f + c3 * p * p * p + c1 * p * p;
}
public static float clamp01(float v) {
return Math.max(0f, Math.min(1f, v));
}
public enum ToastVisual {
STANDARD,
MODULE_TOGGLE
}
public record PreviewToast(String line, HudNotifier.NotifyType type, ToastVisual visual, Boolean enabled) {
}
public static final class Toast {
public final String key;
public String line;
public HudNotifier.NotifyType type;
public ToastVisual visual;
public Boolean stateEnabled;
public long startMs;
public boolean layoutReady;
public float layoutX;
public float layoutY;
public Toast(String key, String line, HudNotifier.NotifyType type, long startMs) {
this(key, line, type, startMs, ToastVisual.STANDARD, null);
}
public Toast(String key, String line, HudNotifier.NotifyType type, long startMs, ToastVisual visual, Boolean stateEnabled) {
this.key = key;
this.line = line;
this.type = type;
this.visual = visual == null ? ToastVisual.STANDARD : visual;
this.stateEnabled = stateEnabled;
this.startMs = startMs;
}
public boolean isExpired(long now, int duration) {
return now - startMs > duration;
}
public float remaining(long now, int duration) {
if (duration <= 0) return 0f;
long age = Math.max(0L, now - startMs);
return 1f - clamp01((float) age / (float) duration);
}
public float anim(long now, int duration, int fade) {
long age = Math.max(0L, now - startMs);
if (age >= duration) return 0f;
float in = clamp01((float) age / (float) ENTER_MS);
float enter = easeOutBack(in);
int outMs = Math.max(1, fade > 0 ? fade : DEFAULT_EXIT_MS);
float out = 1f;
if (age > duration - outMs) {
out = clamp01((float) (duration - age) / (float) outMs);
}
return clamp01(enter * out);
}
public float toggleProgress(long now) {
boolean enabled = stateEnabled != null ? stateEnabled : type == HudNotifier.NotifyType.YES;
long age = Math.max(0L, now - startMs);
float t = AnimationUtility.easeInOutCubic((float) age / (float) TOGGLE_TRANSITION_MS);
return enabled ? t : 1f - t;
}
}
public static final class ToastDraw {
public final Toast toast;
public final ToastMetrics metrics;
public final float remaining;
public final float anim;
public final float x;
public final float y;
public ToastDraw(Toast toast,
ToastMetrics metrics,
float remaining,
float anim,
float x,
float y) {
this.toast = toast;
this.metrics = metrics;
this.remaining = remaining;
this.anim = anim;
this.x = x;
this.y = y;
}
}
}

View file

@ -0,0 +1,190 @@
/*
* This file is part of the LoVisual Client distribution.
* Copyright (c) 2026 loki5512344.
*
* Licensed under the GNU General Public License v3.0.
*/
package dev.loki.lovisual.features.gui.hud.draggable.impl.hudnotifier;
import dev.loki.lovisual.features.gui.hud.HudRenderUtil;
import dev.loki.lovisual.features.gui.hud.draggable.impl.HudNotifier;
import dev.loki.lovisual.features.gui.hud.draggable.impl.HudNotifier.NotifyType;
import dev.loki.lovisual.features.gui.hud.draggable.impl.hudnotifier.ToastModel.Toast;
import dev.loki.lovisual.features.theme.Theme;
import dev.loki.lovisual.features.theme.Themes;
import dev.loki.lovisual.render.engine.animation.AnimationUtility;
import dev.loki.lovisual.render.engine.color.RenderColor;
import dev.loki.lovisual.render.engine.renderer.Renderer2D;
import dev.loki.lovisual.render.engine.svg.SvgRenderOptions;
/**
* Color and icon styling for HudNotifier toasts: palettes, notification type colors,
* the small ARGB helpers shared by the toast drawing routines and the module-toggle
* switch artwork.
*/
public final class ToastStyle {
public static final String ICON_NO = "I";
public static final String ICON_INFO = "J";
public static final String ICON_YES = "K";
public static final String ICON_WARN = "L";
private ToastStyle() {
}
public static ToastPalette paletteFor() {
int progressTrack = 0x33FFFFFF;
int progressFill = 0xC4FFFFFF;
int bg = 0x52000000;
return new ToastPalette(
bg,
bg,
0xFFF7FAFF,
progressTrack,
progressFill,
0xFFFFFFFF
);
}
public static NotifyType resolveType(String text, Boolean enabled) {
if (enabled != null) return enabled ? NotifyType.YES : NotifyType.NO;
String lower = text.toLowerCase();
if (lower.contains("warn") || lower.contains("warning") || lower.contains("cooldown")) {
return NotifyType.WARN;
}
if (lower.contains("error") || lower.contains("failed")
|| lower.contains("fail") || lower.contains("denied")
|| lower.contains("blocked") || lower.contains("disabled")) {
return NotifyType.NO;
}
return NotifyType.INFO;
}
public static String iconFor(NotifyType type) {
return switch (type) {
case YES -> ICON_YES;
case NO -> ICON_NO;
case WARN -> ICON_WARN;
case INFO -> ICON_INFO;
};
}
public static int colorFor(NotifyType type) {
return switch (type) {
case YES -> 0xFF6DFF56;
case NO -> 0xFFFF5656;
case WARN -> 0xFFF5FF5E;
case INFO -> 0xFF4AA3FF;
};
}
public static int moduleToggleColor(float progress, float anim) {
Themes.Theme theme = Theme.theme();
int off = theme != null ? HudRenderUtil.mixColor(theme.textMuted(), theme.textPrimary(), 0.18f) : 0xFFC6D2CD;
int on = theme != null ? theme.accent() : 0xFF5CC8E7;
int color = HudRenderUtil.mixColor(off, on, AnimationUtility.clamp01(progress));
return applyAlpha(color, anim);
}
public static String resolveLine(String text, Boolean enabled) {
if (enabled != null) {
return text + " - " + (enabled ? "enabled!" : "disabled!");
}
return text;
}
public static int applyAlpha(int argb, float alpha) {
int a = (int) (((argb >>> 24) & 0xFF) * alpha);
return (argb & 0x00FFFFFF) | ((a & 0xFF) << 24);
}
public static int scaleAlpha(int argb, float factor) {
factor = AnimationUtility.clamp01(factor);
int a = (argb >>> 24) & 0xFF;
int scaledA = Math.round(a * factor);
return (argb & 0x00FFFFFF) | ((scaledA & 0xFF) << 24);
}
public static int darken(int argb, float factor) {
factor = AnimationUtility.clamp(factor, 0f, 1f);
int a = (argb >>> 24) & 0xFF;
int r = (argb >>> 16) & 0xFF;
int g = (argb >>> 8) & 0xFF;
int b = argb & 0xFF;
r = Math.round(r * factor);
g = Math.round(g * factor);
b = Math.round(b * factor);
return ((a & 0xFF) << 24)
| ((r & 0xFF) << 16)
| ((g & 0xFF) << 8)
| (b & 0xFF);
}
public static void applyColor(RenderColor out, int argb) {
out.a = (argb >>> 24) & 0xFF;
out.r = (argb >>> 16) & 0xFF;
out.g = (argb >>> 8) & 0xFF;
out.b = argb & 0xFF;
}
public static void drawModuleToggle(Renderer2D renderer,
Toast toast,
float x,
float y,
float w,
float h,
int fallbackAccent,
float anim,
long now) {
if (renderer == null || w <= 0.1f || h <= 0.1f) return;
float progress = toast.toggleProgress(now);
int tint = moduleToggleColor(progress, anim);
if (((tint >>> 24) & 0xFF) <= 0) {
tint = fallbackAccent;
}
float svgY = y + (h - w) * 0.5f;
SvgRenderOptions options = SvgRenderOptions.overrideColor(tint);
if (progress <= 0.001f) {
renderer.svg("toggle-left", x, svgY, w, w, options);
return;
}
if (progress >= 0.999f) {
renderer.svg("toggle-right", x, svgY, w, w, options);
return;
}
renderer.svg("toggle-track", x, svgY, w, w, options);
float thumbTravel = w * (3f / 24f);
float thumbX = x + AnimationUtility.lerp(-thumbTravel, thumbTravel, progress);
renderer.svg("toggle-thumb", thumbX, svgY, w, w, options);
}
public static final class ToastPalette {
public final int bgLeft;
public final int bgRight;
public final int text;
public final int progressTrack;
public final int progressFill;
public final int glassTint;
public ToastPalette(int bgLeft,
int bgRight,
int text,
int progressTrack,
int progressFill,
int glassTint) {
this.bgLeft = bgLeft;
this.bgRight = bgRight;
this.text = text;
this.progressTrack = progressTrack;
this.progressFill = progressFill;
this.glassTint = glassTint;
}
}
}

View file

@ -0,0 +1,168 @@
/*
* This file is part of the LoVisual Client distribution.
* Copyright (c) 2026 loki5512344.
*
* Licensed under the GNU General Public License v3.0.
*/
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.HudAnchorX;
import dev.loki.lovisual.features.gui.hud.HudAnchorY;
import dev.loki.lovisual.features.gui.hud.draggable.registry.RegistryState;
import dev.loki.lovisual.features.gui.hud.draggable.registry.RegistryWidgetStore;
import dev.loki.lovisual.render.engine.math.HudScale;
import dev.loki.lovisual.render.helpers.SystemCursor;
import dev.loki.lovisual.util.screen.ClientScreen;
import net.minecraft.client.Minecraft;
import net.minecraft.client.gui.screens.ChatScreen;
import org.lwjgl.glfw.GLFW;
/**
* Обработка ввода drag-and-drop виджетов при открытом чате / drag-редакторе.
*/
public final class RegistryDragController {
private RegistryDragController() {
}
public static void handleDragging(Minecraft mc, int screenW, int screenH, float uiScale) {
boolean chatOpen = ClientScreen.current() instanceof ChatScreen;
boolean dragEditorOpen = RegistryState.forceVisible && ClientScreen.current() != null;
boolean allowDragging = chatOpen || dragEditorOpen;
float fbX = (float) mc.mouseHandler.xpos();
float fbY = (float) mc.mouseHandler.ypos();
float mx = HudScale.toVirtual(fbX, uiScale);
float my = HudScale.toVirtual(fbY, uiScale);
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;
if (!allowDragging) {
if (RegistryState.dragging != null) {
//DebugLog.config("[HUD] Drag cancelled outside chat for %s", RegistryState.dragging.getId());
}
if (left && !RegistryState.prevLeft) {
// DebugLog.config("[HUD] Mouse down ignored (chat not open) at (%.1f, %.1f)", mx, my);
}
if (!left && RegistryState.prevLeft) {
// DebugLog.config("[HUD] Mouse up ignored (chat not open) at (%.1f, %.1f)", mx, my);
}
RegistryState.dragging = null;
RegistryState.draggingWithLeft = false;
RegistryDragSession.clearSnapPreview();
if (RegistryState.hudCursorActive) {
SystemCursor.reset();
RegistryState.hudCursorActive = false;
}
RegistryState.prevLeft = left;
RegistryState.prevMiddle = middle;
return;
}
if (RegistryState.dragging != null && !RegistryWidgetStore.isWidgetVisibleForEditor(RegistryState.dragging)) {
RegistryState.dragging = null;
RegistryState.draggingWithLeft = false;
RegistryDragSession.clearSnapPreview();
}
if (left && !RegistryState.prevLeft) {
//DebugLog.config("[HUD] Mouse down in chat at (%.1f, %.1f)", mx, my);
for (DraggableHudElement w : RegistryState.WIDGETS) {
/*DebugLog.config("[HUD] Widget %s bounds x=%.1f y=%.1f w=%.1f h=%.1f contains=%s",
w.getId(), w.getX(), w.getY(), w.getWidth(), w.getHeight(), w.contains(mx, my));*/
}
DraggableHudElement clickTarget = null;
long bestClickOrder = Long.MIN_VALUE;
for (DraggableHudElement w : RegistryState.WIDGETS) {
if (!RegistryWidgetStore.isWidgetVisibleForEditor(w)) continue;
if (!w.contains(mx, my)) continue;
if (!w.isMouseOverInteractive(mx, my)) continue;
long order = w.getRuntimeRenderOrder();
if (order > bestClickOrder) {
bestClickOrder = order;
clickTarget = w;
}
}
if (clickTarget != null) {
if (clickTarget.onMouseClicked(mx, my, GLFW.GLFW_MOUSE_BUTTON_1)) {
RegistryState.dragging = null;
RegistryState.draggingWithLeft = false;
RegistryDragSession.clearSnapPreview();
RegistryState.prevLeft = left;
RegistryState.prevMiddle = middle;
return;
}
// interactive hitbox: do not start drag on left click
RegistryState.prevLeft = left;
RegistryState.prevMiddle = middle;
return;
} else {
// start drag with left click if no interactive hitbox
DraggableHudElement candidate = RegistryWidgetStore.pickDragCandidate(mx, my);
if (candidate != null) {
RegistryState.dragging = candidate;
RegistryState.draggingWithLeft = true;
RegistryState.dragOffsetX = mx - candidate.getX();
RegistryState.dragOffsetY = my - candidate.getY();
if (RegistryWidgetStore.isWidgetAnchorModifierDown()) {
RegistryWidgetStore.detachChildren(candidate);
}
RegistryState.dragging.clearParentAnchor();
RegistryState.dragging.setAnchors(HudAnchorX.FREE, HudAnchorY.FREE, RegistryState.dragging.getX(), RegistryState.dragging.getY());
SystemCursor.set(SystemCursor.CursorType.MOVE);
RegistryState.hudCursorActive = true;
}
}
}
if (middle && !RegistryState.prevMiddle) {
// start drag
DraggableHudElement candidate = RegistryWidgetStore.pickDragCandidate(mx, my);
if (candidate != null) {
RegistryState.dragging = candidate;
RegistryState.draggingWithLeft = false;
RegistryState.dragOffsetX = mx - candidate.getX();
RegistryState.dragOffsetY = my - candidate.getY();
if (RegistryWidgetStore.isWidgetAnchorModifierDown()) {
RegistryWidgetStore.detachChildren(candidate);
}
RegistryState.dragging.clearParentAnchor();
RegistryState.dragging.setAnchors(HudAnchorX.FREE, HudAnchorY.FREE, RegistryState.dragging.getX(), RegistryState.dragging.getY());
//DebugLog.config("[HUD] Drag start for %s at (%.1f, %.1f)", RegistryState.dragging.getId(), RegistryState.dragging.getX(), RegistryState.dragging.getY());
SystemCursor.set(SystemCursor.CursorType.MOVE);
RegistryState.hudCursorActive = true;
}
if (RegistryState.dragging == null) {
//DebugLog.config("[HUD] No widget found under cursor at (%.1f, %.1f); widgets=%d", mx, my, RegistryState.WIDGETS.size());
}
} else if (!middle && RegistryState.prevMiddle && RegistryState.dragging != null && !RegistryState.draggingWithLeft) {
RegistryDragSession.stopDragging(screenW, screenH);
} else if (!middle && RegistryState.prevMiddle) {
//DebugLog.config("[HUD] Mouse up with no active drag at (%.1f, %.1f)", mx, my);
}
if (RegistryState.draggingWithLeft && !left && RegistryState.prevLeft && RegistryState.dragging != null) {
RegistryDragSession.stopDragging(screenW, screenH);
}
if (((RegistryState.draggingWithLeft && left) || (!RegistryState.draggingWithLeft && middle)) && RegistryState.dragging != null) {
RegistryDragSession.moveDraggedWidget(RegistryState.dragging, mx, my, screenW, screenH);
if (!RegistryWidgetStore.isWidgetAnchorModifierDown()) {
RegistryDragSession.updateSnapPreview(RegistryState.dragging, screenW, screenH);
} else {
RegistryDragSession.clearSnapPreview();
}
//DebugLog.config("[HUD] Dragging %s -> (%.1f, %.1f)", RegistryState.dragging.getId(), RegistryState.dragging.getX(), RegistryState.dragging.getY());
SystemCursor.set(SystemCursor.CursorType.MOVE);
RegistryState.hudCursorActive = true;
} else if (RegistryState.dragging == null) {
RegistryDragSession.clearSnapPreview();
RegistryDragSession.requestHoverCursor(mx, my);
}
RegistryState.prevLeft = left;
RegistryState.prevMiddle = middle;
}
}

View file

@ -0,0 +1,200 @@
/*
* This file is part of the LoVisual Client distribution.
* Copyright (c) 2026 loki5512344.
*
* Licensed under the GNU General Public License v3.0.
*/
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.WidgetAnchorSide;
import dev.loki.lovisual.features.gui.hud.draggable.registry.RegistrySnapSupport;
import dev.loki.lovisual.features.gui.hud.draggable.registry.RegistryState;
import dev.loki.lovisual.features.gui.hud.draggable.registry.RegistryWidgetStore;
import dev.loki.lovisual.features.gui.hud.draggable.registry.RegistryAnchorLayout;
import dev.loki.lovisual.render.helpers.SystemCursor;
import net.minecraft.client.Minecraft;
import java.util.HashSet;
/** Жизненный цикл перетаскивания: завершение, перемещение, snap-превью, курсор. */
public final class RegistryDragSession {
private RegistryDragSession() {
}
public static void stopDragging(int screenW, int screenH) {
if (RegistryState.dragging == null) return;
if (!RegistryWidgetStore.isWidgetAnchorModifierDown()) {
applyStickySnap(RegistryState.dragging, screenW, screenH);
}
RegistryState.dragging.clearParentAnchor();
Minecraft mc = Minecraft.getInstance();
boolean allowWidgetAnchoring = mc == null || !RegistryWidgetStore.isWidgetAnchorModifierDown();
RegistrySnapSupport.updateDragAnchors(RegistryState.dragging, screenW, screenH, allowWidgetAnchoring);
RegistryState.dragging.layoutValue().set(RegistryState.dragging.snapshotLayout());
RegistryState.dragging.saveConfig();
RegistryState.dragging = null;
RegistryState.draggingWithLeft = false;
clearSnapPreview();
if (RegistryState.hudCursorActive) {
SystemCursor.reset();
RegistryState.hudCursorActive = false;
}
}
public static void cancelDragging() {
RegistryState.dragging = null;
RegistryState.draggingWithLeft = false;
clearSnapPreview();
if (RegistryState.hudCursorActive) {
SystemCursor.reset();
RegistryState.hudCursorActive = false;
}
RegistryState.prevLeft = false;
RegistryState.prevMiddle = false;
}
public static void moveDraggedWidget(DraggableHudElement w, float mx, float my, int screenW, int screenH) {
float newX = mx - RegistryState.dragOffsetX;
float newY = my - RegistryState.dragOffsetY;
RegistryState.Bounds groupBounds = collectLinkedBounds(w, screenW, screenH);
float dx = newX - w.getX();
float dy = newY - w.getY();
if (groupBounds != null && groupBounds.width() <= screenW && groupBounds.height() <= screenH) {
dx = RegistryAnchorLayout.clamp(dx, -groupBounds.minX(), screenW - groupBounds.maxX());
dy = RegistryAnchorLayout.clamp(dy, -groupBounds.minY(), screenH - groupBounds.maxY());
w.moveTo(w.getX() + dx, w.getY() + dy);
RegistryAnchorLayout.applyDescendantAnchors(w, screenW, screenH, new HashSet<>());
return;
}
w.moveTo(RegistryAnchorLayout.clampX(newX, w, screenW), RegistryAnchorLayout.clampY(newY, w, screenH));
RegistryAnchorLayout.applyDescendantAnchors(w, screenW, screenH, new HashSet<>());
}
public static void updateSnapPreview(DraggableHudElement w, int screenW, int screenH) {
if (w == null || w.getWidth() <= 0 || w.getHeight() <= 0) {
clearSnapPreview();
return;
}
RegistryState.StickySnap snap = RegistrySnapSupport.findStickySnap(w, screenW, screenH);
if (!snap.hasX() && !snap.hasY()) {
clearSnapPreview();
return;
}
RegistryState.snapPreviewActive = true;
RegistryState.snapPreviewHasX = snap.hasX();
RegistryState.snapPreviewHasY = snap.hasY();
RegistryState.snapPreviewLineX = snap.x();
RegistryState.snapPreviewLineY = snap.y();
}
public static void applyStickySnap(DraggableHudElement w, int screenW, int screenH) {
if (w == null || w.getWidth() <= 0 || w.getHeight() <= 0) return;
RegistryState.StickySnap snap = RegistrySnapSupport.findStickySnap(w, screenW, screenH);
float newX = w.getX();
float newY = w.getY();
if (snap.hasX()) newX = snap.x();
if (snap.hasY()) newY = snap.y();
w.moveTo(RegistryAnchorLayout.clampX(newX, w, screenW), RegistryAnchorLayout.clampY(newY, w, screenH));
}
public static void clearSnapPreview() {
RegistryState.snapPreviewActive = false;
RegistryState.snapPreviewHasX = false;
RegistryState.snapPreviewHasY = false;
}
public static void requestHoverCursor(float mx, float my) {
DraggableHudElement interactive = findTopVisibleWidget(mx, my, true);
if (interactive != null) {
SystemCursor.set(SystemCursor.CursorType.HAND);
RegistryState.hudCursorActive = true;
return;
}
DraggableHudElement draggable = findTopVisibleWidget(mx, my, false);
if (draggable != null) {
SystemCursor.set(SystemCursor.CursorType.MOVE);
RegistryState.hudCursorActive = true;
}
}
public static DraggableHudElement findTopVisibleWidget(float mx, float my, boolean interactiveOnly) {
DraggableHudElement best = null;
long bestOrder = Long.MIN_VALUE;
for (DraggableHudElement w : RegistryState.WIDGETS) {
if (!RegistryWidgetStore.isWidgetVisibleForEditor(w)) continue;
if (!w.contains(mx, my)) continue;
if (interactiveOnly) {
if (!w.isMouseOverInteractive(mx, my)) continue;
} else if (!w.isDraggable()) {
continue;
}
long order = w.getRuntimeRenderOrder();
if (order > bestOrder) {
bestOrder = order;
best = w;
}
}
if (best != null) return best;
for (int i = RegistryState.WIDGETS.size() - 1; i >= 0; i--) {
DraggableHudElement w = RegistryState.WIDGETS.get(i);
if (!RegistryWidgetStore.isWidgetVisibleForEditor(w)) continue;
if (!w.contains(mx, my)) continue;
if (interactiveOnly) {
if (!w.isMouseOverInteractive(mx, my)) continue;
} else if (!w.isDraggable()) {
continue;
}
return w;
}
return null;
}
public static RegistryState.Bounds collectLinkedBounds(DraggableHudElement root, int screenW, int screenH) {
if (root == null) return null;
return collectLinkedBounds(root, screenW, screenH, new HashSet<>());
}
public static RegistryState.Bounds collectLinkedBounds(DraggableHudElement root, int screenW, int screenH,
java.util.Set<String> visited) {
if (root == null || !visited.add(root.getId())) return null;
RegistryState.Bounds bounds = RegistryState.Bounds.of(root);
String parentId = root.getId();
for (DraggableHudElement child : RegistryState.WIDGETS) {
if (child == root) continue;
if (!parentId.equals(child.getParentId()) || child.getParentSide() == WidgetAnchorSide.NONE) continue;
RegistryAnchorLayout.applyWidgetAnchor(child, root, screenW, screenH);
bounds = RegistryState.Bounds.union(bounds, collectLinkedBounds(child, screenW, screenH, visited));
}
return bounds;
}
public static boolean fitsLinkedBoundsAt(DraggableHudElement root,
float desiredRootX,
float desiredRootY,
int screenW,
int screenH) {
RegistryState.Bounds bounds = collectLinkedBounds(root, screenW, screenH);
if (bounds == null) return false;
float dx = desiredRootX - root.getX();
float dy = desiredRootY - root.getY();
return bounds.minX() + dx >= 0f
&& bounds.minY() + dy >= 0f
&& bounds.maxX() + dx <= screenW
&& bounds.maxY() + dy <= screenH;
}
}

View file

@ -0,0 +1,200 @@
/*
* This file is part of the LoVisual Client distribution.
* Copyright (c) 2026 loki5512344.
*
* Licensed under the GNU General Public License v3.0.
*/
package dev.loki.lovisual.features.gui.hud.draggable.registry;
import dev.loki.lovisual.features.gui.hud.*;
import dev.loki.lovisual.features.gui.hud.draggable.DraggableHudElement;
import dev.loki.lovisual.features.gui.hud.draggable.DraggableLayoutValue;
import dev.loki.lovisual.render.engine.math.HudScale;
import dev.loki.lovisual.util.logging.DebugLog;
import net.minecraft.client.Minecraft;
import java.util.*;
/** Применение якорей и инициализация сохранённых позиций виджетов. */
public final class RegistryAnchorLayout {
private RegistryAnchorLayout() {
}
public static void ensurePositionInitialized(DraggableHudElement w, int screenW, int screenH) {
if (w.isPositionInitialized()) return;
DraggableLayoutValue.State saved = w.layoutValue().get();
if (saved != null) {
DraggableLayoutValue.State resolved = saved;
if (!saved.logical()) {
Minecraft mc = Minecraft.getInstance();
if (mc != null && mc.getWindow() != null) {
int fbw = mc.getWindow().getWidth();
int fbh = mc.getWindow().getHeight();
float uiScale = HudScale.scale(fbw, fbh);
float nx = HudScale.toVirtual(saved.x(), uiScale);
float ny = HudScale.toVirtual(saved.y(), uiScale);
float npx = HudScale.toVirtual(saved.parentOffsetX(), uiScale);
float npy = HudScale.toVirtual(saved.parentOffsetY(), uiScale);
resolved = new DraggableLayoutValue.State(
nx,
ny,
saved.anchorX(),
saved.anchorY(),
saved.parentId(),
saved.parentSide(),
npx,
npy,
true
);
w.layoutValue().set(resolved);
w.saveConfig();
}
}
float safeW = w.getWidth() > 0f ? w.getWidth() : 20f;
float safeH = w.getHeight() > 0f ? w.getHeight() : 20f;
float clampedX = saved.anchorX() == HudAnchorX.FREE
? Math.max(0, Math.min(resolved.x(), screenW - safeW))
: resolved.x();
float clampedY = saved.anchorY() == HudAnchorY.FREE
? Math.max(0, Math.min(resolved.y(), screenH - safeH))
: resolved.y();
if (saved.anchorX() == HudAnchorX.FREE && saved.anchorY() == HudAnchorY.FREE
&& (clampedX != resolved.x() || clampedY != resolved.y())) {
DebugLog.config("[HUD] Saved position clamped for %s: (%.1f, %.1f) -> (%.1f, %.1f)",
w.getId(), resolved.x(), resolved.y(), clampedX, clampedY);
} else {
DebugLog.config("[HUD] Loaded saved position for %s: (%.1f, %.1f)", w.getId(), resolved.x(), resolved.y());
}
w.moveTo(clampedX, clampedY);
w.setAnchors(resolved.anchorX(), resolved.anchorY(), clampedX, clampedY);
if (resolved.parentId() != null && resolved.parentSide() != WidgetAnchorSide.NONE) {
w.setParentAnchor(resolved.parentId(), resolved.parentSide(), resolved.parentOffsetX(), resolved.parentOffsetY());
} else {
w.clearParentAnchor();
}
} else {
w.applyDefaultPosition(screenW, screenH);
w.setAnchors(HudAnchorX.FREE, HudAnchorY.FREE, w.getX(), w.getY());
w.clearParentAnchor();
DebugLog.config("[HUD] Applied default position for %s: (%.1f, %.1f)", w.getId(), w.getX(), w.getY());
}
w.markPositionInitialized();
}
public static void applyAnchors(DraggableHudElement w, int screenW, int screenH) {
applyAnchors(w, screenW, screenH, new HashSet<>());
}
public static void applyAnchors(DraggableHudElement w, int screenW, int screenH, Set<String> visiting) {
if (w.getParentId() == null || w.getParentSide() == WidgetAnchorSide.NONE) {
applyScreenAnchor(w, screenW, screenH);
return;
}
DraggableHudElement parent = RegistryWidgetStore.findById(w.getParentId());
if (parent == null) {
w.clearParentAnchor();
applyScreenAnchor(w, screenW, screenH);
return;
}
if (!visiting.add(w.getId())) {
w.clearParentAnchor();
applyScreenAnchor(w, screenW, screenH);
return;
}
if (parent != RegistryState.dragging) {
applyAnchors(parent, screenW, screenH, visiting);
}
if (RegistryWidgetStore.isDescendantOf(parent, w)) {
w.clearParentAnchor();
applyScreenAnchor(w, screenW, screenH);
} else {
applyWidgetAnchor(w, parent, screenW, screenH);
}
visiting.remove(w.getId());
}
public static void applyScreenAnchor(DraggableHudElement w, int screenW, int screenH) {
float x = w.getAnchorOffsetX();
float y = w.getAnchorOffsetY();
switch (w.getAnchorX()) {
case LEFT -> x = w.getAnchorOffsetX();
case CENTER -> x = screenW * 0.5f - w.getWidth() * 0.5f + w.getAnchorOffsetX();
case RIGHT -> x = screenW - w.getWidth() - w.getAnchorOffsetX();
case FREE -> x = w.getAnchorOffsetX();
}
switch (w.getAnchorY()) {
case TOP -> y = w.getAnchorOffsetY();
case CENTER -> y = screenH * 0.5f - w.getHeight() * 0.5f + w.getAnchorOffsetY();
case BOTTOM -> y = screenH - w.getHeight() - w.getAnchorOffsetY();
case FREE -> y = w.getAnchorOffsetY();
}
w.moveTo(clampX(x, w, screenW), clampY(y, w, screenH));
}
public static void applyWidgetAnchor(DraggableHudElement w, DraggableHudElement parent, int screenW, int screenH) {
float x = w.getX();
float y = w.getY();
switch (w.getParentSide()) {
case TOP -> {
x = parent.getX() + w.getParentOffsetX();
y = parent.getY() - w.getHeight() - RegistryState.PARENT_GAP + w.getParentOffsetY();
}
case BOTTOM -> {
x = parent.getX() + w.getParentOffsetX();
y = parent.getY() + parent.getHeight() + RegistryState.PARENT_GAP + w.getParentOffsetY();
}
case LEFT -> {
x = parent.getX() - w.getWidth() - RegistryState.PARENT_GAP + w.getParentOffsetX();
y = parent.getY() + w.getParentOffsetY();
}
case RIGHT -> {
x = parent.getX() + parent.getWidth() + RegistryState.PARENT_GAP + w.getParentOffsetX();
y = parent.getY() + w.getParentOffsetY();
}
case NONE -> {
}
}
w.moveTo(clampX(x, w, screenW), clampY(y, w, screenH));
}
public static void applyDragAnchorLock(DraggableHudElement w, int screenW, int screenH) {
if (w.getParentId() != null && w.getParentSide() != WidgetAnchorSide.NONE) {
DraggableHudElement parent = RegistryWidgetStore.findById(w.getParentId());
if (parent != null) {
applyWidgetAnchor(w, parent, screenW, screenH);
}
return;
}
if (w.getAnchorX() != HudAnchorX.FREE || w.getAnchorY() != HudAnchorY.FREE) {
applyScreenAnchor(w, screenW, screenH);
}
}
public static void applyDescendantAnchors(DraggableHudElement parent, int screenW, int screenH, Set<String> visited) {
if (parent == null || !visited.add(parent.getId())) return;
String parentId = parent.getId();
for (DraggableHudElement child : RegistryState.WIDGETS) {
if (child == parent) continue;
if (!parentId.equals(child.getParentId()) || child.getParentSide() == WidgetAnchorSide.NONE) continue;
applyWidgetAnchor(child, parent, screenW, screenH);
applyDescendantAnchors(child, screenW, screenH, visited);
}
}
public static float clampX(float x, DraggableHudElement w, int screenW) {
float maxX = Math.max(0f, screenW - Math.max(0f, w.getWidth()));
return clamp(x, 0f, maxX);
}
public static float clampY(float y, DraggableHudElement w, int screenH) {
float maxY = Math.max(0f, screenH - Math.max(0f, w.getHeight()));
return clamp(y, 0f, maxY);
}
public static float clamp(float value, float min, float max) {
if (max < min) return min;
return Math.max(min, Math.min(value, max));
}
}

View file

@ -0,0 +1,199 @@
/*
* This file is part of the LoVisual Client distribution.
* Copyright (c) 2026 loki5512344.
*
* Licensed under the GNU General Public License v3.0.
*/
package dev.loki.lovisual.features.gui.hud.draggable.registry;
import dev.loki.lovisual.features.gui.hud.*;
import dev.loki.lovisual.features.gui.hud.draggable.DraggableHudElement;
import dev.loki.lovisual.features.gui.hud.draggable.interaction.RegistryDragSession;
/** Математика прилипания: якоря-кандидаты, sticky snap, якоря после drag. */
public final class RegistrySnapSupport {
private RegistrySnapSupport() {
}
public static RegistryState.WidgetAnchorCandidate findWidgetAnchorCandidate(DraggableHudElement dragging, int screenW, int screenH) {
float bestDist = Float.MAX_VALUE;
RegistryState.WidgetAnchorCandidate best = null;
for (DraggableHudElement target : RegistryState.WIDGETS) {
if (target == dragging) continue;
if (!RegistryWidgetStore.isWidgetVisibleForLinkOverlay(target)) continue;
if (RegistryWidgetStore.isDescendantOf(target, dragging)) continue;
if (target.getWidth() <= 0 || target.getHeight() <= 0) continue;
float dxOverlap = overlap(dragging.getX(), dragging.getX() + dragging.getWidth(),
target.getX(), target.getX() + target.getWidth());
float dyOverlap = overlap(dragging.getY(), dragging.getY() + dragging.getHeight(),
target.getY(), target.getY() + target.getHeight());
float distTop = Math.abs((dragging.getY() + dragging.getHeight()) - (target.getY() - RegistryState.PARENT_GAP));
if (dxOverlap > 0 && distTop <= RegistryState.PARENT_SNAP_DIST && distTop < bestDist) {
float offsetX = parentAxisOffset(dragging.getX() - target.getX());
float desiredY = target.getY() - RegistryState.PARENT_GAP - dragging.getHeight();
float desiredX = target.getX() + offsetX;
if (!RegistryDragSession.fitsLinkedBoundsAt(dragging, desiredX, desiredY, screenW, screenH)) continue;
float offsetY = 0f;
bestDist = distTop;
best = new RegistryState.WidgetAnchorCandidate(target, WidgetAnchorSide.TOP, offsetX, offsetY);
}
float distBottom = Math.abs(dragging.getY() - (target.getY() + target.getHeight() + RegistryState.PARENT_GAP));
if (dxOverlap > 0 && distBottom <= RegistryState.PARENT_SNAP_DIST && distBottom < bestDist) {
float offsetX = parentAxisOffset(dragging.getX() - target.getX());
float desiredY = target.getY() + target.getHeight() + RegistryState.PARENT_GAP;
float desiredX = target.getX() + offsetX;
if (!RegistryDragSession.fitsLinkedBoundsAt(dragging, desiredX, desiredY, screenW, screenH)) continue;
float offsetY = 0f;
bestDist = distBottom;
best = new RegistryState.WidgetAnchorCandidate(target, WidgetAnchorSide.BOTTOM, offsetX, offsetY);
}
float distLeft = Math.abs((dragging.getX() + dragging.getWidth()) - (target.getX() - RegistryState.PARENT_GAP));
if (dyOverlap > 0 && distLeft <= RegistryState.PARENT_SNAP_DIST && distLeft < bestDist) {
float offsetY = parentAxisOffset(dragging.getY() - target.getY());
float desiredX = target.getX() - RegistryState.PARENT_GAP - dragging.getWidth();
float desiredY = target.getY() + offsetY;
if (!RegistryDragSession.fitsLinkedBoundsAt(dragging, desiredX, desiredY, screenW, screenH)) continue;
float offsetX = 0f;
bestDist = distLeft;
best = new RegistryState.WidgetAnchorCandidate(target, WidgetAnchorSide.LEFT, offsetX, offsetY);
}
float distRight = Math.abs(dragging.getX() - (target.getX() + target.getWidth() + RegistryState.PARENT_GAP));
if (dyOverlap > 0 && distRight <= RegistryState.PARENT_SNAP_DIST && distRight < bestDist) {
float offsetY = parentAxisOffset(dragging.getY() - target.getY());
float desiredX = target.getX() + target.getWidth() + RegistryState.PARENT_GAP;
float desiredY = target.getY() + offsetY;
if (!RegistryDragSession.fitsLinkedBoundsAt(dragging, desiredX, desiredY, screenW, screenH)) continue;
float offsetX = 0f;
bestDist = distRight;
best = new RegistryState.WidgetAnchorCandidate(target, WidgetAnchorSide.RIGHT, offsetX, offsetY);
}
}
return best;
}
public static float parentAxisOffset(float offset) {
return Math.abs(offset) <= RegistryState.PARENT_ALIGN_SNAP_DIST ? 0f : offset;
}
public static float overlap(float a0, float a1, float b0, float b1) {
float left = Math.max(a0, b0);
float right = Math.min(a1, b1);
return right - left;
}
public static RegistryState.StickySnap findStickySnap(DraggableHudElement dragging, int screenW, int screenH) {
float bestX = 0f;
float bestY = 0f;
float bestXDist = Float.MAX_VALUE;
float bestYDist = Float.MAX_VALUE;
boolean hasX = false;
boolean hasY = false;
boolean rightSide = dragging.getX() > screenW * 0.5f;
for (DraggableHudElement target : RegistryState.WIDGETS) {
if (target == dragging) continue;
if (!target.isEnabled()) continue;
if (target.getWidth() <= 0 || target.getHeight() <= 0) continue;
if (rightSide) {
float targetEdge = target.getX() + target.getWidth();
float dragEdge = dragging.getX() + dragging.getWidth();
float dist = Math.abs(targetEdge - dragEdge);
if (dist <= RegistryState.PARENT_SNAP_DIST && dist < bestXDist) {
bestXDist = dist;
bestX = targetEdge - dragging.getWidth();
hasX = true;
}
} else {
float dist = Math.abs(target.getX() - dragging.getX());
if (dist <= RegistryState.PARENT_SNAP_DIST && dist < bestXDist) {
bestXDist = dist;
bestX = target.getX();
hasX = true;
}
}
float distY = Math.abs(target.getY() - dragging.getY());
if (distY <= RegistryState.PARENT_SNAP_DIST && distY < bestYDist) {
bestYDist = distY;
bestY = target.getY();
hasY = true;
}
}
float distTop = Math.abs(dragging.getY());
if (distTop <= RegistryState.PARENT_SNAP_DIST && distTop < bestYDist) {
bestY = 0f;
hasY = true;
}
return new RegistryState.StickySnap(hasX, hasY, bestX, bestY);
}
public static void updateDragAnchors(DraggableHudElement w, int screenW, int screenH, boolean allowWidgetAnchoring) {
if (w.getWidth() <= 0 || w.getHeight() <= 0) {
w.clearParentAnchor();
w.setAnchors(HudAnchorX.FREE, HudAnchorY.FREE, w.getX(), w.getY());
return;
}
if (!allowWidgetAnchoring) {
w.clearParentAnchor();
w.setAnchors(HudAnchorX.FREE, HudAnchorY.FREE, w.getX(), w.getY());
return;
}
if (w.supportsWidgetAnchoring()) {
RegistryState.WidgetAnchorCandidate candidate = findWidgetAnchorCandidate(w, screenW, screenH);
if (candidate != null) {
w.setParentAnchor(candidate.parent().getId(), candidate.side(), candidate.offsetX(), candidate.offsetY());
return;
}
}
w.clearParentAnchor();
float x = w.getX();
float y = w.getY();
HudAnchorX ax = HudAnchorX.FREE;
float offX = x;
float leftDist = Math.abs(x);
float rightDist = Math.abs((x + w.getWidth()) - screenW);
float centerDistX = Math.abs((x + w.getWidth() * 0.5f) - screenW * 0.5f);
if (leftDist <= RegistryState.SNAP_DIST) {
ax = HudAnchorX.LEFT;
offX = x;
} else if (rightDist <= RegistryState.SNAP_DIST) {
ax = HudAnchorX.RIGHT;
offX = rightDist;
} else if (centerDistX <= RegistryState.SNAP_DIST) {
ax = HudAnchorX.CENTER;
offX = (x + w.getWidth() * 0.5f) - screenW * 0.5f;
}
HudAnchorY ay = HudAnchorY.FREE;
float offY = y;
float topDist = Math.abs(y);
float bottomDist = Math.abs((y + w.getHeight()) - screenH);
float centerDistY = Math.abs((y + w.getHeight() * 0.5f) - screenH * 0.5f);
if (topDist <= RegistryState.SNAP_DIST) {
ay = HudAnchorY.TOP;
offY = y;
} else if (bottomDist <= RegistryState.SNAP_DIST) {
ay = HudAnchorY.BOTTOM;
offY = bottomDist;
} else if (centerDistY <= RegistryState.SNAP_DIST) {
ay = HudAnchorY.CENTER;
offY = (y + w.getHeight() * 0.5f) - screenH * 0.5f;
}
w.setAnchors(ax, ay, offX, offY);
}
}

View file

@ -0,0 +1,92 @@
/*
* This file is part of the LoVisual Client distribution.
* Copyright (c) 2026 loki5512344.
*
* Licensed under the GNU General Public License v3.0.
*/
package dev.loki.lovisual.features.gui.hud.draggable.registry;
import dev.loki.lovisual.features.gui.hud.draggable.DraggableHudElement;
import dev.loki.lovisual.features.gui.hud.WidgetAnchorSide;
import dev.loki.lovisual.features.theme.Themes;
import java.util.ArrayList;
import java.util.List;
/**
* Общее состояние реестра HUD-виджетов: константы, поля drag-and-drop
* и структуры данных раскладки.
*/
public final class RegistryState {
private RegistryState() {
}
public static final List<DraggableHudElement> WIDGETS = new ArrayList<>();
public static final float SNAP_DIST = 8f;
public static final float PARENT_SNAP_DIST = 10f;
public static final float PARENT_ALIGN_SNAP_DIST = 5f;
public static final float PARENT_GAP = 6f;
public static final int SNAP_PREVIEW_LINE = 0xFFFFFFFF;
public static final float DRAG_COORD_TEXT_SCALE = 0.85f;
public static final float DRAG_COORD_MARGIN = 4f;
public static final float LINK_WIRE_THICKNESS = 1.05f;
public static final float LINK_PREVIEW_THICKNESS = 1.55f;
public static DraggableHudElement dragging;
public static float dragOffsetX;
public static float dragOffsetY;
public static boolean draggingWithLeft;
public static boolean prevLeft;
public static boolean prevMiddle;
public static long renderOrderSeq;
public static boolean forceVisible;
public static boolean snapPreviewActive;
public static boolean snapPreviewHasX;
public static boolean snapPreviewHasY;
public static float snapPreviewLineX;
public static float snapPreviewLineY;
public static String editorWidgetId;
public static boolean hudCursorActive;
public static int lastScreenW = -1;
public static int lastScreenH = -1;
public static Themes.Theme cachedLinkTheme;
public static LinkWireColors cachedLinkColors;
public record StickySnap(boolean hasX, boolean hasY, float x, float y) {
}
public record WidgetAnchorCandidate(DraggableHudElement parent, WidgetAnchorSide side, float offsetX,
float offsetY) {
}
public record LinkWireColors(int wireStart, int wireEnd, int previewStart, int previewEnd) {
}
public record Bounds(float minX, float minY, float maxX, float maxY) {
public static Bounds of(DraggableHudElement w) {
return new Bounds(w.getX(), w.getY(), w.getX() + w.getWidth(), w.getY() + w.getHeight());
}
public static Bounds union(Bounds a, Bounds b) {
if (a == null) return b;
if (b == null) return a;
return new Bounds(
Math.min(a.minX, b.minX),
Math.min(a.minY, b.minY),
Math.max(a.maxX, b.maxX),
Math.max(a.maxY, b.maxY)
);
}
public float width() {
return maxX - minX;
}
public float height() {
return maxY - minY;
}
}
}

View file

@ -0,0 +1,196 @@
/*
* This file is part of the LoVisual Client distribution.
* Copyright (c) 2026 loki5512344.
*
* Licensed under the GNU General Public License v3.0.
*/
package dev.loki.lovisual.features.gui.hud.draggable.registry;
import dev.loki.lovisual.Lifecycle;
import dev.loki.lovisual.features.gui.hud.*;
import dev.loki.lovisual.features.gui.hud.draggable.DraggableHudElement;
import dev.loki.lovisual.features.module.HudPhase;
import dev.loki.lovisual.util.input.KeyManager;
import dev.loki.lovisual.util.logging.DebugLog;
import java.util.Collections;
import java.util.HashSet;
import java.util.List;
import java.util.Set;
/**
* Хранение и поиск виджетов реестра + предикаты видимости для редактора.
*/
public final class RegistryWidgetStore {
private RegistryWidgetStore() {
}
public static void register(DraggableHudElement widget) {
if (!RegistryState.WIDGETS.contains(widget)) {
widget.postInit();
RegistryState.WIDGETS.add(widget);
DebugLog.config("[HUD] Registered widget: %s", widget.getId());
}
}
public static List<DraggableHudElement> getWidgets() {
HudElements.ensureLoaded();
return Collections.unmodifiableList(RegistryState.WIDGETS);
}
public static DraggableHudElement getById(String id) {
HudElements.ensureLoaded();
return findById(id);
}
public static boolean isEnabled(String id) {
if (!Lifecycle.canRunHud()) return false;
DraggableHudElement w = findById(id);
return w != null && w.isEnabled();
}
public static <T extends DraggableHudElement> T get(Class<T> type) {
if (type == null) return null;
for (DraggableHudElement w : RegistryState.WIDGETS) {
if (type.isInstance(w)) {
return type.cast(w);
}
}
return null;
}
public static boolean isEnabled(Class<? extends DraggableHudElement> type) {
if (!Lifecycle.canRunHud()) return false;
DraggableHudElement w = get(type);
return w != null && w.isEnabled();
}
public static void tickAll() {
for (DraggableHudElement w : RegistryState.WIDGETS) {
w.onTick();
}
}
public static DraggableHudElement findById(String id) {
if (id == null) return null;
for (DraggableHudElement w : RegistryState.WIDGETS) {
if (id.equals(w.getId())) return w;
}
return null;
}
public static void noteRendered(DraggableHudElement w) {
if (w == null) return;
w.markRendered(++RegistryState.renderOrderSeq);
}
public static boolean isWidgetVisibleForEditor(DraggableHudElement w) {
if (w == null) return false;
boolean singleEditorPreview = RegistryState.editorWidgetId != null;
if (singleEditorPreview && (RegistryState.editorWidgetId.isEmpty() || !RegistryState.editorWidgetId.equals(w.getId()))) {
return false;
}
boolean bypassEnabled = singleEditorPreview && RegistryState.forceVisible;
if (!bypassEnabled && !w.isEnabled()) {
return false;
}
if (!w.usesEngineRenderer()) return false;
if (w.getRenderSpace() != HudRenderSpace.UNSCALED_LOGICAL) return false;
return w.getRuntimeRenderOrder() > 0L;
}
public static boolean isRenderableCandidate(DraggableHudElement w, HudPhase phase) {
if (w == null) return false;
boolean singleEditorPreview = RegistryState.editorWidgetId != null;
if (singleEditorPreview && (RegistryState.editorWidgetId.isEmpty() || !RegistryState.editorWidgetId.equals(w.getId()))) {
return false;
}
boolean bypassEnabled = singleEditorPreview && RegistryState.forceVisible;
if (!bypassEnabled && !w.isEnabled()) {
return false;
}
if (!w.usesEngineRenderer()) return false;
if (w.getHudPhase() != phase) return false;
return w.getRenderSpace() == HudRenderSpace.UNSCALED_LOGICAL;
}
public static boolean isWidgetVisibleForLinkOverlay(DraggableHudElement w) {
if (w == null) return false;
boolean singleEditorPreview = RegistryState.editorWidgetId != null;
if (singleEditorPreview && (RegistryState.editorWidgetId.isEmpty() || !RegistryState.editorWidgetId.equals(w.getId()))) {
return false;
}
boolean bypassEnabled = singleEditorPreview && RegistryState.forceVisible;
if (!bypassEnabled && !w.isEnabled()) {
return false;
}
return w.usesEngineRenderer() && w.getRenderSpace() == HudRenderSpace.UNSCALED_LOGICAL;
}
public static boolean isDescendantOf(DraggableHudElement possibleDescendant, DraggableHudElement ancestor) {
if (possibleDescendant == null || ancestor == null) return false;
String ancestorId = ancestor.getId();
Set<String> visited = new HashSet<>();
DraggableHudElement current = possibleDescendant;
while (current != null && current.getParentId() != null && current.getParentSide() != WidgetAnchorSide.NONE) {
if (!visited.add(current.getId())) return false;
if (ancestorId.equals(current.getParentId())) return true;
current = findById(current.getParentId());
}
return false;
}
public static void detachChildren(DraggableHudElement parent) {
String parentId = parent.getId();
for (DraggableHudElement w : RegistryState.WIDGETS) {
if (w == parent) continue;
if (parentId.equals(w.getParentId()) && w.getParentSide() != WidgetAnchorSide.NONE) {
w.clearParentAnchor();
w.setAnchors(HudAnchorX.FREE, HudAnchorY.FREE, w.getX(), w.getY());
w.layoutValue().set(w.snapshotLayout());
w.saveConfig();
}
}
}
public static boolean isWidgetAnchorModifierDown() {
return KeyManager.isComboHeldAllowScreen("LEFT_ALT")
|| KeyManager.isComboHeldAllowScreen("RIGHT_ALT");
}
public static DraggableHudElement pickDragCandidate(float mx, float my) {
DraggableHudElement candidate = null;
long bestOrder = Long.MIN_VALUE;
for (DraggableHudElement w : RegistryState.WIDGETS) {
if (!isWidgetVisibleForEditor(w)) continue;
if (!w.isDraggable() || !w.contains(mx, my)) continue;
long order = w.getRuntimeRenderOrder();
if (order > bestOrder) {
bestOrder = order;
candidate = w;
}
}
if (candidate == null) {
for (int i = RegistryState.WIDGETS.size() - 1; i >= 0; i--) {
DraggableHudElement w = RegistryState.WIDGETS.get(i);
if (!isWidgetVisibleForEditor(w)) continue;
if (w.contains(mx, my) && w.isDraggable()) {
candidate = w;
break;
}
}
}
return candidate;
}
}

View file

@ -0,0 +1,200 @@
/*
* This file is part of the LoVisual Client distribution.
* Copyright (c) 2026 loki5512344.
*
* Licensed under the GNU General Public License v3.0.
*/
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.HudRenderUtil;
import dev.loki.lovisual.features.gui.hud.WidgetAnchorSide;
import dev.loki.lovisual.features.gui.hud.draggable.registry.RegistryAnchorLayout;
import dev.loki.lovisual.features.gui.hud.draggable.registry.RegistrySnapSupport;
import dev.loki.lovisual.features.gui.hud.draggable.registry.RegistryState;
import dev.loki.lovisual.features.gui.hud.draggable.registry.RegistryWidgetStore;
import dev.loki.lovisual.features.theme.Theme;
import dev.loki.lovisual.features.theme.Themes;
import dev.loki.lovisual.render.engine.math.HudScale;
import dev.loki.lovisual.render.engine.renderer.Renderer2D;
import dev.loki.lovisual.render.engine.text.Fonts;
import dev.loki.lovisual.render.engine.text.TextRenderer;
import dev.loki.lovisual.util.screen.ClientScreen;
import net.minecraft.client.Minecraft;
/** Оверлей перетаскивания: координаты, snap-линии и провода родительских связей. */
public final class RegistryOverlayRenderer {
private RegistryOverlayRenderer() {
}
public static void renderDragOverlay(Renderer2D renderer,
TextRenderer fallbackTextRenderer,
int screenW,
int screenH) {
renderLinkWires(renderer, screenW, screenH);
if (RegistryState.snapPreviewActive) {
if (RegistryState.snapPreviewHasX) {
renderer.line(RegistryState.snapPreviewLineX, 0f, RegistryState.snapPreviewLineX, screenH, RegistryState.SNAP_PREVIEW_LINE);
}
if (RegistryState.snapPreviewHasY) {
renderer.line(0f, RegistryState.snapPreviewLineY, screenW, RegistryState.snapPreviewLineY, RegistryState.SNAP_PREVIEW_LINE);
}
}
if (RegistryState.dragging == null) return;
TextRenderer coordRenderer = Fonts.renderer("Comfortaa", dev.loki.lovisual.render.engine.text.FontInfo.Type.Regular,
fallbackTextRenderer != null ? fallbackTextRenderer : TextRenderer.get());
if (coordRenderer == null) return;
String coords = "x: " + Math.round(RegistryState.dragging.getX()) + " y: " + Math.round(RegistryState.dragging.getY());
coordRenderer.begin(RegistryState.DRAG_COORD_TEXT_SCALE, false, false);
try {
float textW = (float) coordRenderer.getWidth(coords, true);
float textH = (float) coordRenderer.getHeight(true);
float textX = RegistryState.dragging.getX();
float textY = RegistryState.dragging.getY() - textH - RegistryState.DRAG_COORD_MARGIN;
if (textY < RegistryState.DRAG_COORD_MARGIN) {
textY = RegistryState.dragging.getY() + RegistryState.dragging.getHeight() + RegistryState.DRAG_COORD_MARGIN;
}
textX = RegistryAnchorLayout.clamp(textX, RegistryState.DRAG_COORD_MARGIN,
Math.max(RegistryState.DRAG_COORD_MARGIN, screenW - textW - RegistryState.DRAG_COORD_MARGIN));
textY = RegistryAnchorLayout.clamp(textY, RegistryState.DRAG_COORD_MARGIN,
Math.max(RegistryState.DRAG_COORD_MARGIN, screenH - textH - RegistryState.DRAG_COORD_MARGIN));
coordRenderer.render(coords, textX, textY, new dev.loki.lovisual.render.engine.color.RenderColor(0xFFFFFFFF), true);
} finally {
coordRenderer.end();
}
}
private static void renderLinkWires(Renderer2D renderer,
int screenW,
int screenH) {
Minecraft mc = Minecraft.getInstance();
if (mc == null || mc.getWindow() == null || !(ClientScreen.current() instanceof net.minecraft.client.gui.screens.ChatScreen))
return;
int fbw = mc.getWindow().getWidth();
int fbh = mc.getWindow().getHeight();
float uiScale = HudScale.scale(fbw, fbh);
float mx = HudScale.toVirtual((float) mc.mouseHandler.xpos(), uiScale);
float my = HudScale.toVirtual((float) mc.mouseHandler.ypos(), uiScale);
RegistryState.LinkWireColors colors = linkWireColors();
for (DraggableHudElement child : RegistryState.WIDGETS) {
if (!RegistryWidgetStore.isWidgetVisibleForLinkOverlay(child)) continue;
if (child == RegistryState.dragging) continue;
if (child.getParentId() == null || child.getParentSide() == WidgetAnchorSide.NONE) continue;
DraggableHudElement parent = RegistryWidgetStore.findById(child.getParentId());
if (!RegistryWidgetStore.isWidgetVisibleForLinkOverlay(parent)) continue;
if (!parent.contains(mx, my) && !child.contains(mx, my)) continue;
renderLinkWire(renderer, parent, child, child.getParentSide(),
RegistryState.LINK_WIRE_THICKNESS, colors.wireStart(), colors.wireEnd());
}
if (RegistryState.dragging == null || RegistryWidgetStore.isWidgetAnchorModifierDown()) return;
RegistryState.WidgetAnchorCandidate candidate =
RegistrySnapSupport.findWidgetAnchorCandidate(RegistryState.dragging, screenW, screenH);
if (candidate == null) return;
renderLinkWire(renderer, candidate.parent(), RegistryState.dragging, candidate.side(),
RegistryState.LINK_PREVIEW_THICKNESS, colors.previewStart(), colors.previewEnd());
}
private static RegistryState.LinkWireColors linkWireColors() {
Themes.Theme theme = Theme.theme();
if (RegistryState.cachedLinkTheme == theme && RegistryState.cachedLinkColors != null) {
return RegistryState.cachedLinkColors;
}
int wireStart = HudRenderUtil.setAlpha(
HudRenderUtil.mixColor(theme.textMuted(), theme.strokeSoft(), 0.45f), 0x86);
int wireEnd = HudRenderUtil.setAlpha(
HudRenderUtil.mixColor(theme.accentSoft(), theme.windowStroke(), 0.24f), 0x78);
int previewStart = HudRenderUtil.setAlpha(
HudRenderUtil.mixColor(theme.textPrimary(), theme.accentSoft(), 0.38f), 0xC8);
int previewEnd = HudRenderUtil.setAlpha(
HudRenderUtil.mixColor(theme.accent(), theme.accentSoft(), 0.28f), 0xD8);
RegistryState.cachedLinkTheme = theme;
RegistryState.cachedLinkColors = new RegistryState.LinkWireColors(wireStart, wireEnd, previewStart, previewEnd);
return RegistryState.cachedLinkColors;
}
private static void renderLinkWire(Renderer2D renderer,
DraggableHudElement parent,
DraggableHudElement child,
WidgetAnchorSide side,
double thickness,
int startArgb,
int endArgb) {
if (parent == null || child == null || side == null || side == WidgetAnchorSide.NONE) return;
if (parent.getWidth() <= 0f || parent.getHeight() <= 0f || child.getWidth() <= 0f || child.getHeight() <= 0f) {
return;
}
double parentX = 0.0;
double parentY = 0.0;
double childX = 0.0;
double childY = 0.0;
double parentCx = 0.0;
double parentCy = 0.0;
double childCx = 0.0;
double childCy = 0.0;
switch (side) {
case TOP -> {
parentX = parent.getX() + parent.getWidth() * 0.5;
parentY = parent.getY();
childX = child.getX() + child.getWidth() * 0.5;
childY = child.getY() + child.getHeight();
double c = Math.max(14.0, Math.abs(childY - parentY) * 0.45);
parentCx = parentX;
parentCy = parentY - c;
childCx = childX;
childCy = childY + c;
}
case BOTTOM -> {
parentX = parent.getX() + parent.getWidth() * 0.5;
parentY = parent.getY() + parent.getHeight();
childX = child.getX() + child.getWidth() * 0.5;
childY = child.getY();
double c = Math.max(14.0, Math.abs(childY - parentY) * 0.45);
parentCx = parentX;
parentCy = parentY + c;
childCx = childX;
childCy = childY - c;
}
case LEFT -> {
parentX = parent.getX();
parentY = parent.getY() + parent.getHeight() * 0.5;
childX = child.getX() + child.getWidth();
childY = child.getY() + child.getHeight() * 0.5;
double c = Math.max(14.0, Math.abs(childX - parentX) * 0.45);
parentCx = parentX - c;
parentCy = parentY;
childCx = childX + c;
childCy = childY;
}
case RIGHT -> {
parentX = parent.getX() + parent.getWidth();
parentY = parent.getY() + parent.getHeight() * 0.5;
childX = child.getX();
childY = child.getY() + child.getHeight() * 0.5;
double c = Math.max(14.0, Math.abs(childX - parentX) * 0.45);
parentCx = parentX + c;
parentCy = parentY;
childCx = childX - c;
childCy = childY;
}
case NONE -> {
return;
}
}
renderer.bezierConnectorGradient(childX, childY, childCx, childCy, parentCx, parentCy, parentX, parentY,
18, thickness, startArgb, endArgb);
}
}

View file

@ -0,0 +1,171 @@
/*
* This file is part of the LoVisual Client distribution.
* Copyright (c) 2026 loki5512344.
*
* Licensed under the GNU General Public License v3.0.
*/
package dev.loki.lovisual.features.gui.hud.draggable.render;
import dev.loki.lovisual.Lifecycle;
import dev.loki.lovisual.features.gui.chat.BetterChatRenderer;
import dev.loki.lovisual.features.gui.hud.*;
import dev.loki.lovisual.features.gui.hud.draggable.DraggableHudElement;
import dev.loki.lovisual.features.gui.hud.draggable.interaction.RegistryDragController;
import dev.loki.lovisual.features.gui.hud.draggable.interaction.RegistryDragSession;
import dev.loki.lovisual.features.gui.hud.draggable.registry.RegistryAnchorLayout;
import dev.loki.lovisual.features.gui.hud.draggable.registry.RegistryState;
import dev.loki.lovisual.features.gui.hud.draggable.registry.RegistryWidgetStore;
import dev.loki.lovisual.features.module.HudPhase;
import dev.loki.lovisual.render.engine.math.HudScale;
import dev.loki.lovisual.render.engine.profiler.ProfilerPhase;
import dev.loki.lovisual.render.engine.profiler.RenderProfiler2D;
import dev.loki.lovisual.render.engine.renderer.Renderer2D;
import dev.loki.lovisual.render.engine.text.TextRenderer;
import net.minecraft.client.Minecraft;
import java.util.ArrayList;
import java.util.HashSet;
import java.util.List;
/**
* Пакетный проход движка рендера: отбор и отрисовка всех виджетов фазы + drag-оверлей.
*/
public final class RegistryRenderPipeline {
private RegistryRenderPipeline() {
}
public static boolean hasEngineWork(HudPhase phase, boolean foreground) {
if (!Lifecycle.canRunHud()) return false;
if (foreground && RegistryState.dragging != null) return true;
for (DraggableHudElement w : RegistryState.WIDGETS) {
if (RegistryWidgetStore.isRenderableCandidate(w, phase)) return true;
}
return false;
}
public static void renderAllEngine(HudPhase phase,
Renderer2D renderer,
TextRenderer textRenderer,
net.minecraft.client.gui.GuiGraphicsExtractor ctx,
float tickDelta) {
if (!Lifecycle.canRunHud()) {
RegistryDragSession.cancelDragging();
return;
}
Minecraft mc = Minecraft.getInstance();
if (mc == null || mc.getWindow() == null) return;
boolean allowOnlyModuleList = false;
int fbw = mc.getWindow().getWidth();
int fbh = mc.getWindow().getHeight();
int screenW = Math.max(1, Math.round(HudScale.virtualWidth(fbw, fbh)));
int screenH = Math.max(1, Math.round(HudScale.virtualHeight(fbw, fbh)));
RegistryResizeHandler.handleScreenResize(screenW, screenH);
List<DraggableHudElement> renderList = new ArrayList<>();
for (DraggableHudElement w : RegistryState.WIDGETS) {
boolean singleEditorPreview = RegistryState.editorWidgetId != null;
if (singleEditorPreview && (RegistryState.editorWidgetId.isEmpty() || !RegistryState.editorWidgetId.equals(w.getId()))) {
continue;
}
boolean bypassEnabled = singleEditorPreview && RegistryState.forceVisible;
if (!bypassEnabled && !w.isEnabled()) {
continue;
}
if (!w.usesEngineRenderer()) {
continue;
}
if (w.getHudPhase() != phase) {
continue;
}
if (w.getRenderSpace() != HudRenderSpace.UNSCALED_LOGICAL) {
continue;
}
RegistryAnchorLayout.ensurePositionInitialized(w, screenW, screenH);
if (allowOnlyModuleList && !"module_list".equals(w.getId())) {
continue;
}
renderList.add(w);
}
for (DraggableHudElement w : renderList) {
if (w == RegistryState.dragging) continue;
if (w.getParentId() == null || w.getParentSide() == WidgetAnchorSide.NONE) {
RegistryAnchorLayout.applyScreenAnchor(w, screenW, screenH);
}
}
for (DraggableHudElement w : renderList) {
if (w == RegistryState.dragging) continue;
if (w.getParentId() != null && w.getParentSide() != WidgetAnchorSide.NONE) {
RegistryAnchorLayout.applyAnchors(w, screenW, screenH, new HashSet<>());
}
}
for (DraggableHudElement w : renderList) {
RegistryWidgetStore.noteRendered(w);
try (ProfilerPhase.Scope widgetScope =
ProfilerPhase.scope("2d:widget:" + w.getId());
RenderProfiler2D.Section ignored = RenderProfiler2D.section("widget:" + w.getId())) {
w.renderEngine(renderer, textRenderer, ctx, tickDelta, screenW, screenH);
}
}
}
public static void renderAllEngineForeground(HudPhase phase,
Renderer2D renderer,
TextRenderer textRenderer,
net.minecraft.client.gui.GuiGraphicsExtractor ctx,
float tickDelta) {
if (!Lifecycle.canRunHud()) {
RegistryDragSession.cancelDragging();
return;
}
Minecraft mc = Minecraft.getInstance();
if (mc == null || mc.getWindow() == null) return;
boolean allowOnlyModuleList = false;
int fbw = mc.getWindow().getWidth();
int fbh = mc.getWindow().getHeight();
int screenW = Math.max(1, Math.round(HudScale.virtualWidth(fbw, fbh)));
int screenH = Math.max(1, Math.round(HudScale.virtualHeight(fbw, fbh)));
RegistryResizeHandler.handleScreenResize(screenW, screenH);
for (DraggableHudElement w : RegistryState.WIDGETS) {
boolean singleEditorPreview = RegistryState.editorWidgetId != null;
if (singleEditorPreview && (RegistryState.editorWidgetId.isEmpty() || !RegistryState.editorWidgetId.equals(w.getId()))) {
continue;
}
boolean bypassEnabled = singleEditorPreview && RegistryState.forceVisible;
if (!bypassEnabled && !w.isEnabled()) {
continue;
}
if (!w.usesEngineRenderer()) {
continue;
}
if (w.getHudPhase() != phase) {
continue;
}
if (w.getRenderSpace() != HudRenderSpace.UNSCALED_LOGICAL) {
continue;
}
if (allowOnlyModuleList && !"module_list".equals(w.getId())) {
continue;
}
try (ProfilerPhase.Scope widgetScope =
ProfilerPhase.scope("2d:widget_fg:" + w.getId());
RenderProfiler2D.Section ignored = RenderProfiler2D.section("widget_fg:" + w.getId())) {
w.renderEngineForeground(renderer, textRenderer, ctx, tickDelta, screenW, screenH);
}
}
RegistryOverlayRenderer.renderDragOverlay(renderer, textRenderer, screenW, screenH);
BetterChatRenderer.renderCapturedTooltip(ctx);
if (phase == HudPhase.AFTER_SUBTITLES) {
float uiScale = HudScale.scale(fbw, fbh);
RegistryDragController.handleDragging(mc, screenW, screenH, uiScale);
}
}
}

View file

@ -0,0 +1,130 @@
/*
* This file is part of the LoVisual Client distribution.
* Copyright (c) 2026 loki5512344.
*
* Licensed under the GNU General Public License v3.0.
*/
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.HudAnchorX;
import dev.loki.lovisual.features.gui.hud.HudAnchorY;
import dev.loki.lovisual.features.gui.hud.WidgetAnchorSide;
import dev.loki.lovisual.features.gui.hud.draggable.registry.RegistryAnchorLayout;
import dev.loki.lovisual.features.gui.hud.draggable.registry.RegistryState;
/**
* Реакция раскладки на изменение логического размера экрана.
*/
public final class RegistryResizeHandler {
private RegistryResizeHandler() {
}
public static void handleScreenResize(int screenW, int screenH) {
if (screenW <= 0 || screenH <= 0) return;
if (RegistryState.lastScreenW <= 0 || RegistryState.lastScreenH <= 0) {
RegistryState.lastScreenW = screenW;
RegistryState.lastScreenH = screenH;
return;
}
if (RegistryState.lastScreenW == screenW && RegistryState.lastScreenH == screenH) return;
int oldW = RegistryState.lastScreenW;
int oldH = RegistryState.lastScreenH;
RegistryState.lastScreenW = screenW;
RegistryState.lastScreenH = screenH;
for (DraggableHudElement w : RegistryState.WIDGETS) {
if (!w.isPositionInitialized()) continue;
if (w.getParentId() != null && w.getParentSide() != WidgetAnchorSide.NONE) {
// Parent-anchored widgets will be handled when anchors are applied.
continue;
}
if (w.getWidth() <= 0 || w.getHeight() <= 0) continue;
boolean changed = false;
if (w.getAnchorX() == HudAnchorX.FREE && w.getAnchorY() == HudAnchorY.FREE) {
changed |= inferScreenAnchors(w, oldW, oldH);
}
RegistryAnchorLayout.applyScreenAnchor(w, screenW, screenH);
changed |= clampToScreenIfFree(w, screenW, screenH);
if (changed) {
w.layoutValue().set(w.snapshotLayout());
w.saveConfig();
}
}
}
private static boolean inferScreenAnchors(DraggableHudElement w, int screenW, int screenH) {
float x = w.getX();
float y = w.getY();
boolean changed = false;
HudAnchorX ax = w.getAnchorX();
float offX = w.getAnchorOffsetX();
float leftDist = Math.abs(x);
float rightDist = Math.abs((x + w.getWidth()) - screenW);
float centerDistX = Math.abs((x + w.getWidth() * 0.5f) - screenW * 0.5f);
if (leftDist <= RegistryState.SNAP_DIST) {
ax = HudAnchorX.LEFT;
offX = x;
} else if (rightDist <= RegistryState.SNAP_DIST) {
ax = HudAnchorX.RIGHT;
offX = rightDist;
} else if (centerDistX <= RegistryState.SNAP_DIST) {
ax = HudAnchorX.CENTER;
offX = (x + w.getWidth() * 0.5f) - screenW * 0.5f;
}
HudAnchorY ay = w.getAnchorY();
float offY = w.getAnchorOffsetY();
float topDist = Math.abs(y);
float bottomDist = Math.abs((y + w.getHeight()) - screenH);
float centerDistY = Math.abs((y + w.getHeight() * 0.5f) - screenH * 0.5f);
if (topDist <= RegistryState.SNAP_DIST) {
ay = HudAnchorY.TOP;
offY = y;
} else if (bottomDist <= RegistryState.SNAP_DIST) {
ay = HudAnchorY.BOTTOM;
offY = bottomDist;
} else if (centerDistY <= RegistryState.SNAP_DIST) {
ay = HudAnchorY.CENTER;
offY = (y + w.getHeight() * 0.5f) - screenH * 0.5f;
}
if (ax != w.getAnchorX() || ay != w.getAnchorY()
|| offX != w.getAnchorOffsetX() || offY != w.getAnchorOffsetY()) {
w.setAnchors(ax, ay, offX, offY);
changed = true;
}
return changed;
}
private static boolean clampToScreenIfFree(DraggableHudElement w, int screenW, int screenH) {
boolean changed = false;
float newX = w.getX();
float newY = w.getY();
if (w.getAnchorX() == HudAnchorX.FREE) {
float maxX = Math.max(0f, screenW - w.getWidth());
newX = Math.max(0f, Math.min(newX, maxX));
}
if (w.getAnchorY() == HudAnchorY.FREE) {
float maxY = Math.max(0f, screenH - w.getHeight());
newY = Math.max(0f, Math.min(newY, maxY));
}
if (newX != w.getX() || newY != w.getY()) {
w.moveTo(newX, newY);
if (w.getAnchorX() == HudAnchorX.FREE || w.getAnchorY() == HudAnchorY.FREE) {
w.setAnchors(w.getAnchorX(), w.getAnchorY(),
w.getAnchorX() == HudAnchorX.FREE ? newX : w.getAnchorOffsetX(),
w.getAnchorY() == HudAnchorY.FREE ? newY : w.getAnchorOffsetY());
}
changed = true;
}
return changed;
}
}

View file

@ -0,0 +1,110 @@
/*
* This file is part of the LoVisual Client distribution.
* Copyright (c) 2026 loki5512344.
*
* Licensed under the GNU General Public License v3.0.
*/
package dev.loki.lovisual.features.gui.hud.draggable.render;
import dev.loki.lovisual.Lifecycle;
import dev.loki.lovisual.features.gui.hud.draggable.DraggableHudElement;
import dev.loki.lovisual.features.gui.hud.HudRenderSpace;
import dev.loki.lovisual.features.gui.hud.WidgetAnchorSide;
import dev.loki.lovisual.features.gui.hud.draggable.interaction.RegistryDragSession;
import dev.loki.lovisual.features.gui.hud.draggable.registry.RegistryAnchorLayout;
import dev.loki.lovisual.features.gui.hud.draggable.registry.RegistryState;
import dev.loki.lovisual.features.gui.hud.draggable.registry.RegistryWidgetStore;
import dev.loki.lovisual.features.module.HudPhase;
import dev.loki.lovisual.render.engine.math.HudScale;
import dev.loki.lovisual.render.engine.profiler.ProfilerPhase;
import dev.loki.lovisual.render.engine.profiler.RenderProfiler2D;
import dev.loki.lovisual.render.engine.renderer.Renderer2D;
import dev.loki.lovisual.render.engine.text.TextRenderer;
import dev.loki.lovisual.util.screen.ClientScreen;
import net.minecraft.client.Minecraft;
import java.util.HashSet;
/**
* Путь отрисовки одиночного виджета по id (превью в редакторе и запросы движка).
*/
public final class RegistryWidgetRenderPath {
private RegistryWidgetRenderPath() {
}
public static boolean hasEngineWidgetWork(String id) {
if (!Lifecycle.canRunHud()) return false;
if (id == null || id.isBlank()) return false;
if (RegistryState.editorWidgetId != null && !RegistryState.editorWidgetId.equals(id)) return false;
DraggableHudElement w = RegistryWidgetStore.findById(id);
if (w == null) return false;
if (!w.usesEngineRenderer()) return false;
boolean bypassEnabled = RegistryState.forceVisible && RegistryState.editorWidgetId != null && RegistryState.editorWidgetId.equals(id);
return bypassEnabled || w.isEnabled();
}
public static void renderEngineWidget(String id,
Renderer2D renderer,
TextRenderer textRenderer,
net.minecraft.client.gui.GuiGraphicsExtractor ctx,
float tickDelta) {
if (!Lifecycle.canRunHud()) {
RegistryDragSession.cancelDragging();
return;
}
if (RegistryState.editorWidgetId != null && (!RegistryState.editorWidgetId.equals(id))) return;
Minecraft mc = Minecraft.getInstance();
if (mc == null || mc.getWindow() == null) return;
boolean isChat = ClientScreen.current() instanceof net.minecraft.client.gui.screens.ChatScreen;
boolean isInventory = ClientScreen.current() instanceof net.minecraft.client.gui.screens.inventory.AbstractContainerScreen<?>;
boolean allowOnlyModuleList = isInventory && ClientScreen.current() != null;
if (RegistryState.forceVisible) {
allowOnlyModuleList = false;
}
boolean shouldHideHud = !RegistryState.forceVisible && ClientScreen.current() != null && !isChat && !allowOnlyModuleList;
if (shouldHideHud) {
return;
}
DraggableHudElement w = RegistryWidgetStore.findById(id);
if (w == null) return;
if (!w.usesEngineRenderer()) return;
boolean bypassEnabled = RegistryState.forceVisible && RegistryState.editorWidgetId != null && RegistryState.editorWidgetId.equals(id);
if (!bypassEnabled && !w.isEnabled()) {
return;
}
if (allowOnlyModuleList && !"module_list".equals(w.getId())) {
return;
}
int fbw = mc.getWindow().getWidth();
int fbh = mc.getWindow().getHeight();
int screenW = Math.max(1, Math.round(HudScale.virtualWidth(fbw, fbh)));
int screenH = Math.max(1, Math.round(HudScale.virtualHeight(fbw, fbh)));
RegistryResizeHandler.handleScreenResize(screenW, screenH);
RegistryAnchorLayout.ensurePositionInitialized(w, screenW, screenH);
if (w != RegistryState.dragging) {
if (w.getParentId() == null || w.getParentSide() == WidgetAnchorSide.NONE) {
RegistryAnchorLayout.applyScreenAnchor(w, screenW, screenH);
} else {
RegistryAnchorLayout.applyAnchors(w, screenW, screenH, new HashSet<>());
}
}
RegistryWidgetStore.noteRendered(w);
try (ProfilerPhase.Scope widgetScope =
ProfilerPhase.scope("2d:widget:" + w.getId());
RenderProfiler2D.Section ignored = RenderProfiler2D.section("widget:" + w.getId())) {
w.renderEngine(renderer, textRenderer, ctx, tickDelta, screenW, screenH);
}
try (ProfilerPhase.Scope widgetScope =
ProfilerPhase.scope("2d:widget_fg:" + w.getId());
RenderProfiler2D.Section ignored = RenderProfiler2D.section("widget_fg:" + w.getId())) {
w.renderEngineForeground(renderer, textRenderer, ctx, tickDelta, screenW, screenH);
}
}
}

View file

@ -17,6 +17,8 @@ import net.minecraft.util.Util;
import net.minecraft.world.item.ItemStack; import net.minecraft.world.item.ItemStack;
import dev.loki.lovisual.config.SettingDef; import dev.loki.lovisual.config.SettingDef;
import dev.loki.lovisual.features.gui.hud.AbstractHudElement; import dev.loki.lovisual.features.gui.hud.AbstractHudElement;
import dev.loki.lovisual.features.module.Modules;
import dev.loki.lovisual.features.module.modules.visuals.Animations;
import dev.loki.lovisual.features.gui.hud.HudElementRegister; import dev.loki.lovisual.features.gui.hud.HudElementRegister;
import dev.loki.lovisual.features.gui.hud.HudRenderUtil; import dev.loki.lovisual.features.gui.hud.HudRenderUtil;
import dev.loki.lovisual.features.theme.Themes; import dev.loki.lovisual.features.theme.Themes;
@ -104,7 +106,7 @@ public final class CustomHotbar extends AbstractHudElement {
ItemStack offhand = player.getOffhandItem(); ItemStack offhand = player.getOffhandItem();
boolean hasOffhand = !offhand.isEmpty(); boolean hasOffhand = !offhand.isEmpty();
float baseY = h - 22f; float baseY = h - 22f + hotbarRaiseOffset();
boolean blur = shouldBlur(bm); boolean blur = shouldBlur(bm);
boolean glass = shouldGlass(bm); boolean glass = shouldGlass(bm);
@ -219,7 +221,7 @@ public final class CustomHotbar extends AbstractHudElement {
if (bm == null || !bm.isHudHotbarEnabled()) return; if (bm == null || !bm.isHudHotbarEnabled()) return;
int i = mc.getWindow().getGuiScaledWidth() / 2; int i = mc.getWindow().getGuiScaledWidth() / 2;
int baseY = mc.getWindow().getGuiScaledHeight() - 22; float baseY = mc.getWindow().getGuiScaledHeight() - 22 + hotbarRaiseOffset();
int selected = getSelectedSlot(player); int selected = getSelectedSlot(player);
HotbarSelection selection = updateSelectionAnimation(selected); HotbarSelection selection = updateSelectionAnimation(selected);
@ -327,6 +329,16 @@ public final class CustomHotbar extends AbstractHudElement {
return ((PlayerInventoryAccessor) player.getInventory()).lovisual$getSelectedSlot(); return ((PlayerInventoryAccessor) player.getInventory()).lovisual$getSelectedSlot();
} }
/**
* Vertical GUI-pixel offset applied to the whole hotbar while the Animations
* module slides it into place. The animated value itself is advanced once per
* frame inside the module; this only reads the settled value.
*/
private static float hotbarRaiseOffset() {
Animations animations = Modules.get(Animations.class);
return animations != null ? animations.getHotbarRaiseOffset() : 0.0f;
}
private static OffHandMode getOffHandMode() { private static OffHandMode getOffHandMode() {
CustomHotbar bm = CustomHotbar.get(); CustomHotbar bm = CustomHotbar.get();
if (bm == null) return OffHandMode.MERGED; if (bm == null) return OffHandMode.MERGED;

View file

@ -10,8 +10,6 @@ package dev.loki.lovisual.features.gui.hud.nondraggable.impl;
import dev.loki.lovisual.util.screen.ClientScreen; import dev.loki.lovisual.util.screen.ClientScreen;
import net.minecraft.client.Minecraft; import net.minecraft.client.Minecraft;
import net.minecraft.client.gui.GuiGraphicsExtractor; import net.minecraft.client.gui.GuiGraphicsExtractor;
import net.minecraft.client.gui.components.BossHealthOverlay;
import net.minecraft.client.gui.components.LerpingBossEvent;
import net.minecraft.client.gui.screens.ChatScreen; import net.minecraft.client.gui.screens.ChatScreen;
import net.minecraft.client.gui.screens.Screen; import net.minecraft.client.gui.screens.Screen;
import net.minecraft.resources.Identifier; import net.minecraft.resources.Identifier;
@ -28,11 +26,12 @@ import dev.loki.lovisual.features.gui.hud.AbstractHudElement;
import dev.loki.lovisual.features.gui.hud.HudElementRegister; import dev.loki.lovisual.features.gui.hud.HudElementRegister;
import dev.loki.lovisual.features.gui.hud.HudRenderSpace; import dev.loki.lovisual.features.gui.hud.HudRenderSpace;
import dev.loki.lovisual.features.gui.hud.HudRenderUtil; import dev.loki.lovisual.features.gui.hud.HudRenderUtil;
import dev.loki.lovisual.features.gui.hud.nondraggable.impl.dynamicisland.IslandBoundsPatches;
import dev.loki.lovisual.features.gui.hud.nondraggable.impl.dynamicisland.IslandRuntimePatches;
import dev.loki.lovisual.features.gui.hud.nondraggable.impl.dynamicisland.IslandUiUtil;
import dev.loki.lovisual.features.gui.hud.nondraggable.impl.dynamicisland.controls.IslandControls;
import dev.loki.lovisual.features.gui.hud.script.HudScriptLayouts; import dev.loki.lovisual.features.gui.hud.script.HudScriptLayouts;
import dev.loki.lovisual.features.module.HudPhase; import dev.loki.lovisual.features.module.HudPhase;
import dev.loki.lovisual.features.module.Modules;
import dev.loki.lovisual.features.module.modules.misc.ClickGui;
import dev.loki.lovisual.mixins.accessors.BossHealthOverlayAccessor;
import dev.loki.lovisual.render.engine.animation.AnimationUtility; import dev.loki.lovisual.render.engine.animation.AnimationUtility;
import dev.loki.lovisual.render.engine.core.LoVisualRenderSystem; import dev.loki.lovisual.render.engine.core.LoVisualRenderSystem;
import dev.loki.lovisual.render.engine.core.RenderPhase; import dev.loki.lovisual.render.engine.core.RenderPhase;
@ -41,32 +40,30 @@ import dev.loki.lovisual.render.engine.core.ViewportContext;
import dev.loki.lovisual.render.engine.math.HudScale; import dev.loki.lovisual.render.engine.math.HudScale;
import dev.loki.lovisual.render.engine.renderer.FullScreenRenderer; import dev.loki.lovisual.render.engine.renderer.FullScreenRenderer;
import dev.loki.lovisual.render.engine.renderer.Renderer2D; import dev.loki.lovisual.render.engine.renderer.Renderer2D;
import dev.loki.lovisual.render.engine.renderer.ui.runtime.core.UiBounds;
import dev.loki.lovisual.render.engine.renderer.ui.runtime.core.UiRuntime; import dev.loki.lovisual.render.engine.renderer.ui.runtime.core.UiRuntime;
import dev.loki.lovisual.render.engine.renderer.ui.runtime.render.UiProjectionMode; import dev.loki.lovisual.render.engine.renderer.ui.runtime.render.UiProjectionMode;
import dev.loki.lovisual.render.engine.renderer.ui.runtime.render.UiRenderContext; import dev.loki.lovisual.render.engine.renderer.ui.runtime.render.UiRenderContext;
import dev.loki.lovisual.render.engine.renderer.ui.runtime.script.CachedUiScriptRuntime; import dev.loki.lovisual.render.engine.renderer.ui.runtime.script.CachedUiScriptRuntime;
import dev.loki.lovisual.render.engine.renderer.ui.runtime.script.UiBoundsPatchSet;
import dev.loki.lovisual.render.engine.renderer.ui.runtime.script.UiScriptColor;
import dev.loki.lovisual.render.engine.renderer.ui.runtime.script.UiScriptPatchSet;
import dev.loki.lovisual.render.engine.renderer.ui.runtime.script.UiScriptProps;
import dev.loki.lovisual.render.engine.renderer.ui.runtime.script.UiScriptModule; import dev.loki.lovisual.render.engine.renderer.ui.runtime.script.UiScriptModule;
import dev.loki.lovisual.render.engine.renderer.ui.runtime.script.UiScriptModuleHandle; import dev.loki.lovisual.render.engine.renderer.ui.runtime.script.UiScriptModuleHandle;
import dev.loki.lovisual.render.engine.renderer.ui.runtime.script.UiScriptProps;
import dev.loki.lovisual.render.engine.text.FontInfo; import dev.loki.lovisual.render.engine.text.FontInfo;
import dev.loki.lovisual.render.engine.text.Fonts; import dev.loki.lovisual.render.engine.text.Fonts;
import dev.loki.lovisual.render.engine.text.TextRenderer; import dev.loki.lovisual.render.engine.text.TextRenderer;
import dev.loki.lovisual.Lifecycle; import dev.loki.lovisual.Lifecycle;
import dev.loki.lovisual.util.media.MediaSessionService; import dev.loki.lovisual.util.media.MediaSessionService;
import dev.loki.lovisual.util.media.RepeatMode;
import dev.loki.lovisual.util.pvp.PvpState; import dev.loki.lovisual.util.pvp.PvpState;
import java.time.LocalTime; import java.time.LocalTime;
import java.util.List; import java.util.List;
import java.util.Map;
import java.util.UUID;
import static dev.loki.lovisual.features.theme.Theme.theme; import static dev.loki.lovisual.features.theme.Theme.theme;
/**
* Dynamic Island: оркестрация режимов (time/pvp/music/clickgui) и анимаций.
* Патчинг UI-скрипта, bounds-патчи, утилиты и геометрия медиа-контролов
* делегированы в {@code impl/dynamicisland/}.
*/
@HudElementRegister(order = 60) @HudElementRegister(order = 60)
public final class DynamicIsland extends AbstractHudElement { public final class DynamicIsland extends AbstractHudElement {
private static final String LAYOUT_ID = "lovisual:api/hud/static/dynamic_island"; private static final String LAYOUT_ID = "lovisual:api/hud/static/dynamic_island";
@ -83,25 +80,16 @@ public final class DynamicIsland extends AbstractHudElement {
private static final float PVP_MAX_WIDTH = 96f; private static final float PVP_MAX_WIDTH = 96f;
private static final float MUSIC_EXPANDED_WIDTH = 338f; private static final float MUSIC_EXPANDED_WIDTH = 338f;
private static final float CONTEXT_GAP = 7f;
private static final float CONTEXT_CHIP_H = 20f;
private static final float CONTEXT_CHIP_Y = 7.5f;
private static final float TIME_CONTEXT_WIDTH = 57f; private static final float TIME_CONTEXT_WIDTH = 57f;
private static final float PVP_CONTEXT_WIDTH = 46f;
private static final float COMPACT_PAD_X = 11f; private static final float COMPACT_PAD_X = 11f;
private static final float COMPACT_GAP = 9f; private static final float COMPACT_GAP = 9f;
private static final float COMPACT_ART = 22f; private static final float COMPACT_ART = 22f;
private static final float COMPACT_WAVE_W = 15f; private static final float COMPACT_WAVE_W = 15f;
private static final float COMPACT_FADE = 15f; private static final float COMPACT_FADE = 15f;
private static final float EXPANDED_PAD_X = 13f;
private static final float EXPANDED_PAD_Y = 11f; private static final float EXPANDED_PAD_Y = 11f;
private static final float EXPANDED_ART = 36f;
private static final float PROGRESS_H = 5f; private static final float PROGRESS_H = 5f;
private static final float CONTROL_SIZE = 27f;
private static final float CONTROL_GAP = 7f;
private static final float CONTROL_TIME_GAP = 10f; private static final float CONTROL_TIME_GAP = 10f;
private static final float CONTROL_ROW_GAP_Y = -5f;
private static final int ICON_PAUSE = 0xEA02; private static final int ICON_PAUSE = 0xEA02;
private static final int ICON_PLAY = 0xEA03; private static final int ICON_PLAY = 0xEA03;
@ -109,8 +97,6 @@ public final class DynamicIsland extends AbstractHudElement {
private static final int ICON_NEXT = 0xEA05; private static final int ICON_NEXT = 0xEA05;
private static final int ICON_SHUFFLE = 0xEA06; private static final int ICON_SHUFFLE = 0xEA06;
private static final int BASE_SHADOW = 0xFF000000;
private static final int BASE_STROKE = 0xFFFFFFFF;
private static final int STATUS_PVP = 0xFFFF6368; private static final int STATUS_PVP = 0xFFFF6368;
private static final int PROGRESS_BG = 0x2AFFFFFF; private static final int PROGRESS_BG = 0x2AFFFFFF;
@ -127,6 +113,7 @@ public final class DynamicIsland extends AbstractHudElement {
private final UiScriptModuleHandle moduleHandle = HudScriptLayouts.handle(LAYOUT_ID); private final UiScriptModuleHandle moduleHandle = HudScriptLayouts.handle(LAYOUT_ID);
private final CachedUiScriptRuntime scriptRuntime = new CachedUiScriptRuntime(HudScriptLayouts.runtimeReporter()); private final CachedUiScriptRuntime scriptRuntime = new CachedUiScriptRuntime(HudScriptLayouts.runtimeReporter());
private final IslandControls controls = new IslandControls();
private float visibilityAnim; private float visibilityAnim;
private float expandAnim; private float expandAnim;
@ -143,23 +130,6 @@ public final class DynamicIsland extends AbstractHudElement {
private IslandMode currentMode = IslandMode.TIME; private IslandMode currentMode = IslandMode.TIME;
private MediaSessionService.Snapshot currentSnapshot = MediaSessionService.Snapshot.empty(MediaSessionService.isMediaAvailable()); private MediaSessionService.Snapshot currentSnapshot = MediaSessionService.Snapshot.empty(MediaSessionService.isMediaAvailable());
private float shuffleX;
private float shuffleY;
private float prevX;
private float prevY;
private float playX;
private float playY;
private float nextX;
private float nextY;
private float repeatX;
private float repeatY;
private float controlSize;
private float shuffleHover;
private float prevHover;
private float playHover;
private float nextHover;
private float repeatHover;
private int uiFillTop; private int uiFillTop;
private int uiFillBottom; private int uiFillBottom;
private int uiTextPrimary; private int uiTextPrimary;
@ -185,206 +155,6 @@ public final class DynamicIsland extends AbstractHudElement {
return INSTANCE.clickGuiShellVisible; return INSTANCE.clickGuiShellVisible;
} }
private static void putControlBounds(UiBoundsPatchSet patches,
String key,
float x,
float y,
float size) {
putBounds(patches, key + ":hover", x, y, size, size);
putBounds(patches, key, x, y + 4.0f, size, Math.max(0.0f, size - 4.0f));
}
private static void putSvgControlBounds(UiBoundsPatchSet patches,
String key,
float x,
float y,
float size) {
float iconSize = Math.max(0.0f, size * 0.74f);
putBounds(patches, key + ":hover", x, y, size, size);
putBounds(patches, key, x + (size - iconSize) * 0.5f, y + (size - iconSize) * 0.5f, iconSize, iconSize);
}
private static void putBounds(UiBoundsPatchSet patches,
String key,
float x,
float y,
float width,
float height) {
patches.put(key, x, y, width, height);
}
private static void patchControl(UiScriptPatchSet patches,
String key,
String icon,
String iconColor,
String accentSoft,
float expandedAlpha,
float hover) {
patchText(patches, key, icon, scaleHexAlpha(iconColor, expandedAlpha));
patchShape(patches, key + ":hover", "fill", scaleHexAlpha(accentSoft, Math.max(0.0f, hover) * 0.3f * expandedAlpha));
}
private static void patchSvgControl(UiScriptPatchSet patches,
String key,
String asset,
String iconColor,
String accentSoft,
float expandedAlpha,
float hover) {
patchImage(patches, key, asset, scaleHexAlpha(iconColor, expandedAlpha));
patchShape(patches, key + ":hover", "fill", scaleHexAlpha(accentSoft, Math.max(0.0f, hover) * 0.3f * expandedAlpha));
}
private static void patchText(UiScriptPatchSet patches,
String key,
String text,
String color) {
patches.text(key, text, color);
}
private static void patchClippedText(UiScriptPatchSet patches,
String key,
float measuredWidth,
float boxWidth,
float scrollTime,
float delay,
float speed,
float fadeWidth) {
patches.clippedText(key, measuredWidth, boxWidth, scrollTime, delay, speed, fadeWidth);
}
private static void patchImage(UiScriptPatchSet patches,
String key,
String asset,
String tint) {
patches.image(key, asset, tint);
}
private static void patchShape(UiScriptPatchSet patches,
String key,
Object... pairs) {
patches.props(key, pairs);
}
private static void patchShape(UiScriptPatchSet patches,
String key,
String prop,
Object value) {
patches.put(key, prop, value);
}
private static void patchShape(UiScriptPatchSet patches,
String key,
String propA,
Object valueA,
String propB,
Object valueB) {
patches.put(key, propA, valueA, propB, valueB);
}
private static String stringProp(UiScriptProps props, String key, String fallback) {
return props.string(key, fallback);
}
private static float numberProp(UiScriptProps props, String key, float fallback) {
return props.number(key, fallback);
}
private static boolean boolProp(UiScriptProps props, String key, boolean fallback) {
return props.bool(key, fallback);
}
private static float clamp01(float value) {
return Math.max(0.0f, Math.min(1.0f, value));
}
private static String scaleHexAlpha(String hex, float amount) {
return UiScriptColor.alpha(hex, amount);
}
private static int shuffleColor(MediaSessionService.Snapshot snapshot) {
if (snapshot == null || !snapshot.supportsShuffle()) {
return 0x99000000;
}
return snapshot.isShuffleActive() ? 0xFFFFFFFF : 0x7AB7BEC8;
}
private static int repeatColor(MediaSessionService.Snapshot snapshot) {
if (snapshot == null || !snapshot.supportsRepeat()) {
return 0x99000000;
}
RepeatMode mode = snapshot.repeatMode();
return mode == RepeatMode.ALL || mode == RepeatMode.ONE ? 0xFFFFFFFF : 0x7AB7BEC8;
}
private static String repeatSvg(MediaSessionService.Snapshot snapshot) {
if (snapshot == null || !snapshot.supportsRepeat()) {
return "repeat-off";
}
return snapshot.repeatMode() == RepeatMode.ONE ? "repeat-1" : "repeat";
}
private static boolean clickGuiEnabled() {
ClickGui clickGui = Modules.get(ClickGui.class);
return clickGui != null && clickGui.isEnabled();
}
private static boolean isSettingsLikeScreen(Screen screen) {
if (screen == null) return false;
String name = screen.getClass().getName();
String simple = screen.getClass().getSimpleName();
if (name.contains(".gui.screen.option.") || name.contains(".gui.screen.pack.")) return true;
return simple.contains("Options")
|| simple.contains("Option")
|| simple.contains("Controls")
|| simple.contains("Keybind")
|| simple.contains("Video")
|| simple.contains("Sound")
|| simple.contains("Language")
|| simple.contains("Accessibility")
|| simple.contains("Skin")
|| simple.contains("Pack")
|| simple.contains("Datapack")
|| simple.contains("Customize")
|| simple.contains("Telemetry")
|| simple.contains("Credits");
}
private static String formatClock(LocalTime time) {
return String.format("%02d:%02d", time.getHour(), time.getMinute());
}
private static String formatSecondsOnly(float seconds) {
return Math.max(0L, (long) Math.ceil(Math.max(0f, seconds))) + "s";
}
private static String formatShortTime(float seconds) {
return formatShortTime((long) Math.ceil(Math.max(0f, seconds)));
}
private static String formatShortTime(long seconds) {
long sec = Math.max(0L, seconds);
long m = sec / 60L;
long s = sec % 60L;
return String.format("%d:%02d", m, s);
}
private static String iconString(int codepoint) {
return new String(Character.toChars(codepoint));
}
private static String hex(int argb) {
return String.format("#%08X", argb);
}
private static float clamp(float value, float min, float max) {
return Math.max(min, Math.min(max, value));
}
private static boolean hit(float mx, float my, float x, float y, float w, float h) {
return mx >= x && mx <= x + w && my >= y && my <= y + h;
}
@Override @Override
protected void defineSettings(List<SettingDef> defs) { protected void defineSettings(List<SettingDef> defs) {
defs.add(SettingDef.bool(syncTheme)); defs.add(SettingDef.bool(syncTheme));
@ -429,24 +199,24 @@ public final class DynamicIsland extends AbstractHudElement {
if (currentMode == IslandMode.MUSIC && expandAnim > 0.01f && button == GLFW.GLFW_MOUSE_BUTTON_LEFT) { if (currentMode == IslandMode.MUSIC && expandAnim > 0.01f && button == GLFW.GLFW_MOUSE_BUTTON_LEFT) {
if (currentSnapshot != null && currentSnapshot.supportsShuffle() if (currentSnapshot != null && currentSnapshot.supportsShuffle()
&& hit(mx, my, shuffleX, shuffleY, controlSize, controlSize)) { && IslandUiUtil.hit(mx, my, controls.shuffleX, controls.shuffleY, controls.controlSize, controls.controlSize)) {
mediaService.toggleShuffle(); mediaService.toggleShuffle();
return true; return true;
} }
if (hit(mx, my, prevX, prevY, controlSize, controlSize)) { if (IslandUiUtil.hit(mx, my, controls.prevX, controls.prevY, controls.controlSize, controls.controlSize)) {
mediaService.previous(); mediaService.previous();
return true; return true;
} }
if (hit(mx, my, playX, playY, controlSize, controlSize)) { if (IslandUiUtil.hit(mx, my, controls.playX, controls.playY, controls.controlSize, controls.controlSize)) {
mediaService.playPause(); mediaService.playPause();
return true; return true;
} }
if (hit(mx, my, nextX, nextY, controlSize, controlSize)) { if (IslandUiUtil.hit(mx, my, controls.nextX, controls.nextY, controls.controlSize, controls.controlSize)) {
mediaService.next(); mediaService.next();
return true; return true;
} }
if (currentSnapshot != null && currentSnapshot.supportsRepeat() if (currentSnapshot != null && currentSnapshot.supportsRepeat()
&& hit(mx, my, repeatX, repeatY, controlSize, controlSize)) { && IslandUiUtil.hit(mx, my, controls.repeatX, controls.repeatY, controls.controlSize, controls.controlSize)) {
mediaService.cycleRepeatMode(); mediaService.cycleRepeatMode();
return true; return true;
} }
@ -474,7 +244,7 @@ public final class DynamicIsland extends AbstractHudElement {
if (!Lifecycle.canRunHud()) return; if (!Lifecycle.canRunHud()) return;
if (mc == null || mc.getWindow() == null || ctx == null) return; if (mc == null || mc.getWindow() == null || ctx == null) return;
Screen currentScreen = ClientScreen.current(); Screen currentScreen = ClientScreen.current();
boolean clickGuiOverlay = isEnabled() && clickGuiEnabled(); boolean clickGuiOverlay = isEnabled() && IslandUiUtil.clickGuiEnabled();
if (!clickGuiOverlay && !isScreenOverlayAllowed(currentScreen)) return; if (!clickGuiOverlay && !isScreenOverlayAllowed(currentScreen)) return;
FullScreenRenderer.ensureInit(); FullScreenRenderer.ensureInit();
@ -513,7 +283,7 @@ public final class DynamicIsland extends AbstractHudElement {
Screen currentScreen = ClientScreen.current(); Screen currentScreen = ClientScreen.current();
boolean chatOpen = currentScreen instanceof ChatScreen; boolean chatOpen = currentScreen instanceof ChatScreen;
boolean clickGuiVisible = clickGuiEnabled(); boolean clickGuiVisible = IslandUiUtil.clickGuiEnabled();
boolean clickGuiTabShellScreen = currentScreen instanceof ClickGuiScreen; boolean clickGuiTabShellScreen = currentScreen instanceof ClickGuiScreen;
ClickGuiRenderer.ClickGuiIslandState clickGuiState = ClickGuiRenderer.islandState(); ClickGuiRenderer.ClickGuiIslandState clickGuiState = ClickGuiRenderer.islandState();
boolean clickGuiBridge = isEnabled() boolean clickGuiBridge = isEnabled()
@ -525,7 +295,7 @@ public final class DynamicIsland extends AbstractHudElement {
if (clickGuiVisible && clickGuiTabShellScreen && !clickGuiBridge) { if (clickGuiVisible && clickGuiTabShellScreen && !clickGuiBridge) {
clickGuiShellVisible = false; clickGuiShellVisible = false;
setBounds(0f, 0f, 0f, 0f); setBounds(0f, 0f, 0f, 0f);
resetControls(); controls.reset();
return; return;
} }
boolean shouldShow = clickGuiBridge boolean shouldShow = clickGuiBridge
@ -547,7 +317,7 @@ public final class DynamicIsland extends AbstractHudElement {
if (!shouldShow && visibilityAnim <= 0.01f) { if (!shouldShow && visibilityAnim <= 0.01f) {
clickGuiShellVisible = false; clickGuiShellVisible = false;
setBounds(0f, 0f, 0f, 0f); setBounds(0f, 0f, 0f, 0f);
resetControls(); controls.reset();
return; return;
} }
@ -560,7 +330,7 @@ public final class DynamicIsland extends AbstractHudElement {
IslandMetrics metrics = measureMetrics(titleRenderer, valueRenderer, metaRenderer); IslandMetrics metrics = measureMetrics(titleRenderer, valueRenderer, metaRenderer);
float targetMainWidth = switch (currentMode) { float targetMainWidth = switch (currentMode) {
case TIME -> TIME_WIDTH; case TIME -> TIME_WIDTH;
case PVP -> clamp(metrics.pvpWidth, PVP_MIN_WIDTH, PVP_MAX_WIDTH); case PVP -> IslandUiUtil.clamp(metrics.pvpWidth, PVP_MIN_WIDTH, PVP_MAX_WIDTH);
case MUSIC -> AnimationUtility.lerp(metrics.musicCompactWidth, MUSIC_EXPANDED_WIDTH, expandAnim); case MUSIC -> AnimationUtility.lerp(metrics.musicCompactWidth, MUSIC_EXPANDED_WIDTH, expandAnim);
case CLICKGUI -> clickGuiState.tabBarW(); case CLICKGUI -> clickGuiState.tabBarW();
}; };
@ -572,7 +342,7 @@ public final class DynamicIsland extends AbstractHudElement {
boolean clickGuiContext = currentMode == IslandMode.CLICKGUI; boolean clickGuiContext = currentMode == IslandMode.CLICKGUI;
float contextPresence = musicContext ? 1.0f - AnimationUtility.easeOutCubic(expandAnim) : (clickGuiContext ? 1.0f : 0.0f); float contextPresence = musicContext ? 1.0f - AnimationUtility.easeOutCubic(expandAnim) : (clickGuiContext ? 1.0f : 0.0f);
float targetLeftContextWidth = TIME_CONTEXT_WIDTH * contextPresence; float targetLeftContextWidth = TIME_CONTEXT_WIDTH * contextPresence;
float targetRightContextWidth = PvpState.isActive() ? PVP_CONTEXT_WIDTH * contextPresence : 0.0f; float targetRightContextWidth = PvpState.isActive() ? IslandBoundsPatches.PVP_CONTEXT_WIDTH * contextPresence : 0.0f;
mainWidthAnim = mainWidthAnim < 0f ? targetMainWidth : AnimationUtility.approach(mainWidthAnim, targetMainWidth, dt, 12f); mainWidthAnim = mainWidthAnim < 0f ? targetMainWidth : AnimationUtility.approach(mainWidthAnim, targetMainWidth, dt, 12f);
heightAnim = heightAnim < 0f ? targetHeight : AnimationUtility.approach(heightAnim, targetHeight, dt, 12f); heightAnim = heightAnim < 0f ? targetHeight : AnimationUtility.approach(heightAnim, targetHeight, dt, 12f);
@ -585,8 +355,8 @@ public final class DynamicIsland extends AbstractHudElement {
float drawMainWidth = mainWidthAnim; float drawMainWidth = mainWidthAnim;
float drawHeight = heightAnim; float drawHeight = heightAnim;
float leftOccupied = leftContextWidthAnim > 0.5f ? leftContextWidthAnim + CONTEXT_GAP : 0.0f; float leftOccupied = leftContextWidthAnim > 0.5f ? leftContextWidthAnim + IslandBoundsPatches.CONTEXT_GAP : 0.0f;
float rightOccupied = rightContextWidthAnim > 0.5f ? CONTEXT_GAP + rightContextWidthAnim : 0.0f; float rightOccupied = rightContextWidthAnim > 0.5f ? IslandBoundsPatches.CONTEXT_GAP + rightContextWidthAnim : 0.0f;
float drawWidth = leftOccupied + drawMainWidth + rightOccupied; float drawWidth = leftOccupied + drawMainWidth + rightOccupied;
widthAnim = drawWidth; widthAnim = drawWidth;
float drawX = currentMode == IslandMode.CLICKGUI float drawX = currentMode == IslandMode.CLICKGUI
@ -594,7 +364,7 @@ public final class DynamicIsland extends AbstractHudElement {
: (screenW - drawMainWidth) * 0.5f - leftOccupied; : (screenW - drawMainWidth) * 0.5f - leftOccupied;
float drawY = currentMode == IslandMode.CLICKGUI float drawY = currentMode == IslandMode.CLICKGUI
? clickGuiState.tabBarY() - (1.0f - AnimationUtility.easeOutCubic(clickGuiState.lifecycle())) * 18.0f ? clickGuiState.tabBarY() - (1.0f - AnimationUtility.easeOutCubic(clickGuiState.lifecycle())) * 18.0f
: TOP_Y + bossBarOffset(screenH); : TOP_Y + IslandUiUtil.bossBarOffset(mc, screenH, TOP_Y);
float mainX = leftOccupied; float mainX = leftOccupied;
float alpha = AnimationUtility.easeOutCubic(visibilityAnim); float alpha = AnimationUtility.easeOutCubic(visibilityAnim);
float radius = currentMode == IslandMode.CLICKGUI ? drawHeight * 0.5f : AnimationUtility.lerp(CLOSED_RADIUS, EXPANDED_RADIUS, expandAnim); float radius = currentMode == IslandMode.CLICKGUI ? drawHeight * 0.5f : AnimationUtility.lerp(CLOSED_RADIUS, EXPANDED_RADIUS, expandAnim);
@ -605,16 +375,16 @@ public final class DynamicIsland extends AbstractHudElement {
float expandedContentAlpha = currentMode == IslandMode.MUSIC ? AnimationUtility.easeOutCubic(expandAnim) * alpha : 0f; float expandedContentAlpha = currentMode == IslandMode.MUSIC ? AnimationUtility.easeOutCubic(expandAnim) * alpha : 0f;
float compactContentAlpha = currentMode == IslandMode.MUSIC ? (1f - AnimationUtility.easeOutCubic(expandAnim)) * alpha : 0f; float compactContentAlpha = currentMode == IslandMode.MUSIC ? (1f - AnimationUtility.easeOutCubic(expandAnim)) * alpha : 0f;
if (expandedContentAlpha > 0.01f) { if (expandedContentAlpha > 0.01f) {
updateMusicControls(drawX + mainX, drawY, drawMainWidth, valueRenderer); controls.updateLayout(drawX + mainX, drawY, drawMainWidth, valueRenderer, currentSnapshot);
updateControlHover(isInteractiveScreen(currentScreen)); controls.updateHover(isInteractiveScreen(currentScreen), mc, currentSnapshot);
} else { } else {
resetControls(); controls.reset();
} }
if (!renderScripted(renderer, fallback, titleRenderer, metaRenderer, ctx, tickDelta, screenW, screenH, drawWidth, drawHeight, if (!renderScripted(renderer, fallback, titleRenderer, metaRenderer, ctx, tickDelta, screenW, screenH, drawWidth, drawHeight,
drawMainWidth, mainX, leftContextWidthAnim, rightContextWidthAnim, drawMainWidth, mainX, leftContextWidthAnim, rightContextWidthAnim,
alpha, compactContentAlpha, expandedContentAlpha, radius, accent)) { alpha, compactContentAlpha, expandedContentAlpha, radius, accent)) {
drawFallback(renderer, drawX + mainX, drawY, drawMainWidth, drawHeight, radius, alpha, accent); IslandUiUtil.drawFallback(renderer, drawX + mainX, drawY, drawMainWidth, drawHeight, radius, alpha, accent, uiFillTop);
} }
} }
@ -647,7 +417,7 @@ public final class DynamicIsland extends AbstractHudElement {
alpha, compactAlpha, expandedAlpha, radius, accent, alpha, compactAlpha, expandedAlpha, radius, accent,
titleRenderer, metaRenderer titleRenderer, metaRenderer
); );
long treeSignature = islandStructuralSignature(props); long treeSignature = IslandRuntimePatches.islandStructuralSignature(props);
long dataSignature = CachedUiScriptRuntime.signature(props.asMap()); long dataSignature = CachedUiScriptRuntime.signature(props.asMap());
long layoutSignature = treeSignature; long layoutSignature = treeSignature;
UiRuntime baked = scriptRuntime.bake( UiRuntime baked = scriptRuntime.bake(
@ -665,223 +435,14 @@ public final class DynamicIsland extends AbstractHudElement {
width, width,
height, height,
props::asMap, props::asMap,
() -> islandRuntimePatches(props), () -> IslandRuntimePatches.islandRuntimePatches(props),
() -> islandBoundsPatches(props) () -> IslandBoundsPatches.islandBoundsPatches(props, x, y, controls)
); );
if (baked == null) return false; if (baked == null) return false;
baked.render(new UiRenderContext(renderer, textRenderer, ctx, tickDelta, UiProjectionMode.UNSCALED_LOGICAL)); baked.render(new UiRenderContext(renderer, textRenderer, ctx, tickDelta, UiProjectionMode.UNSCALED_LOGICAL));
return true; return true;
} }
private long islandStructuralSignature(UiScriptProps props) {
long h = 0xcbf29ce484222325L;
h = CachedUiScriptRuntime.mix(h, stringProp(props, "mode", ""));
h = CachedUiScriptRuntime.mix(h, boolProp(props, "blur", false));
h = CachedUiScriptRuntime.mix(h, numberProp(props, "blurAlpha", 0.0f));
h = CachedUiScriptRuntime.mix(h, !stringProp(props, "artworkTexture", "").isEmpty());
h = CachedUiScriptRuntime.mix(h, "music".equals(stringProp(props, "mode", "")));
h = CachedUiScriptRuntime.mix(h, boolProp(props, "showShuffle", false));
h = CachedUiScriptRuntime.mix(h, boolProp(props, "showRepeat", false));
return h;
}
private Map<String, ? extends Map<String, ?>> islandRuntimePatches(UiScriptProps props) {
UiScriptPatchSet patches = UiScriptPatchSet.create(48);
float compactAlpha = numberProp(props, "compactAlpha", 0.0f);
float expandedAlpha = numberProp(props, "expandedAlpha", 0.0f);
float contextAlpha = numberProp(props, "contextAlpha", compactAlpha);
float width = numberProp(props, "width", 0.0f);
float mainWidth = numberProp(props, "mainWidth", width);
float progress = clamp01(numberProp(props, "progress", 0.0f));
String mode = stringProp(props, "mode", "");
boolean musicMode = "music".equals(mode);
boolean clickGuiMode = "clickgui".equals(mode);
boolean shellMode = musicMode || clickGuiMode;
boolean pvpMode = "pvp".equals(mode);
boolean timeMode = "time".equals(mode);
String primary = stringProp(props, "textPrimary", "#FFFFFFFF");
String secondary = stringProp(props, "textSecondary", "#99FFFFFF");
String muted = stringProp(props, "textMuted", "#66FFFFFF");
String accent = stringProp(props, "accent", "#FFFFFFFF");
String accentSoft = stringProp(props, "accentSoft", "#33FFFFFF");
if (shellMode) {
float shellBlurAlpha = numberProp(props, "blurAlpha", 0.0f) * numberProp(props, "alpha", 0.0f);
patchShape(patches, "dynamic-island", "renderRadius", props.get("radius"), "renderBlurAlpha", shellBlurAlpha);
patchShape(patches, "shell:blur", "blurAlpha", shellBlurAlpha, "radius", props.get("radius"));
patchShape(patches, "shell:shadow", "fill", props.get("shadow"), "radius", props.get("radius"));
patchShape(patches, "shell:fill", "startColor", props.get("fillTop"), "endColor", props.get("fillBottom"), "radius", props.get("radius"));
patchShape(patches, "shell:tint", "startColor", props.get("tintTop"), "endColor", props.get("tintBottom"), "radius", props.get("radius"));
patchShape(patches, "shell:stroke", "stroke", props.get("stroke"), "radius", props.get("radius"));
}
if (timeMode) {
patchShape(patches, "time:box", "fill", props.get("timeFill"), "stroke", props.get("timeStroke"));
patchText(patches, "time", stringProp(props, "time", ""), primary);
}
if (pvpMode) {
patchShape(patches, "pvp:chip", "fill", props.get("pvpFill"), "stroke", props.get("pvpStroke"));
patchText(patches, "pvp:timer", stringProp(props, "pvpTimer", "0s"), primary);
}
if (musicMode || clickGuiMode) {
float contextBlurAlpha = numberProp(props, "blurAlpha", 0.0f) * contextAlpha;
patchShape(patches, "context:time:blur", "blurAlpha", contextBlurAlpha, "radius", 10.0f);
patchShape(patches, "context:time:box", "fill", scaleHexAlpha(stringProp(props, "contextFill", "#00000000"), contextAlpha), "stroke", scaleHexAlpha(stringProp(props, "contextStroke", "#00000000"), contextAlpha));
patchText(patches, "context:time", stringProp(props, "time", ""), scaleHexAlpha(primary, contextAlpha));
float pvpContextAlpha = boolProp(props, "pvpContextVisible", false) ? contextAlpha : 0.0f;
patchShape(patches, "context:pvp:blur", "blurAlpha", numberProp(props, "blurAlpha", 0.0f) * pvpContextAlpha, "radius", 10.0f);
patchShape(patches, "context:pvp:box", "fill", scaleHexAlpha(stringProp(props, "pvpFill", "#00000000"), pvpContextAlpha), "stroke", scaleHexAlpha(stringProp(props, "pvpStroke", "#00000000"), pvpContextAlpha));
patchText(patches, "context:pvp", stringProp(props, "pvpTimer", ""), scaleHexAlpha(primary, pvpContextAlpha));
}
if (musicMode) {
patchText(patches, "music:elapsed:compact", stringProp(props, "elapsed", "0:00"), scaleHexAlpha(secondary, compactAlpha));
patchText(patches, "music:title:compact", stringProp(props, "title", ""), scaleHexAlpha(primary, compactAlpha));
patchClippedText(patches, "music:title:compact", numberProp(props, "titleWidthCompact", 0.0f), Math.max(0.0f, mainWidth - 124.0f), numberProp(props, "titleScrollTime", 0.0f), 1.0f, 18.0f, 15.0f);
patchShape(patches, "music:divider", "fill", scaleHexAlpha(muted, 0.4f * compactAlpha));
patchShape(patches, "wave:0", "fill", scaleHexAlpha(accent, 0.75f * compactAlpha));
patchShape(patches, "wave:1", "fill", scaleHexAlpha(accent, 0.83f * compactAlpha));
patchShape(patches, "wave:2", "fill", scaleHexAlpha(accent, 0.91f * compactAlpha));
patchImage(patches, "artwork:compact", stringProp(props, "artworkTexture", ""), scaleHexAlpha("#FFFFFFFF", compactAlpha));
patchShape(patches, "artwork:compact:fallback", "fill", scaleHexAlpha(accent, 0.92f * compactAlpha), "stroke", scaleHexAlpha("#33FFFFFF", compactAlpha));
}
if (musicMode) {
patchText(patches, "music:title:expanded", stringProp(props, "title", ""), scaleHexAlpha(primary, expandedAlpha));
patchClippedText(patches, "music:title:expanded", numberProp(props, "titleWidthExpanded", 0.0f), Math.max(0.0f, mainWidth - 72.0f), numberProp(props, "titleScrollTime", 0.0f), 1.0f, 18.0f, 16.0f);
patchText(patches, "music:artist:expanded", stringProp(props, "artist", ""), scaleHexAlpha(secondary, expandedAlpha));
patchClippedText(patches, "music:artist:expanded", numberProp(props, "artistWidthExpanded", 0.0f), Math.max(0.0f, mainWidth - 72.0f), numberProp(props, "titleScrollTime", 0.0f), 1.4f, 14.0f, 16.0f);
patchShape(patches, "music:progress:bg", "fill", scaleHexAlpha(stringProp(props, "progressBg", "#00FFFFFF"), expandedAlpha));
patchShape(patches, "music:progress:fill",
"fill", scaleHexAlpha(accent, expandedAlpha),
"renderWidth", Math.max(0.0f, mainWidth - 26.0f) * progress);
patchText(patches, "music:elapsed:expanded", stringProp(props, "elapsed", "0:00"), scaleHexAlpha(secondary, expandedAlpha));
patchText(patches, "music:total:expanded", stringProp(props, "total", "0:00"), scaleHexAlpha(secondary, expandedAlpha));
patchImage(patches, "artwork:expanded", stringProp(props, "artworkTexture", ""), scaleHexAlpha("#FFFFFFFF", expandedAlpha));
patchShape(patches, "artwork:expanded:fallback", "fill", scaleHexAlpha(accent, 0.92f * expandedAlpha), "stroke", scaleHexAlpha("#33FFFFFF", expandedAlpha));
if (boolProp(props, "showShuffle", false)) {
patchControl(patches, "music:shuffle", stringProp(props, "iconShuffle", ""), stringProp(props, "shuffleColor", primary), accentSoft, expandedAlpha, numberProp(props, "shuffleHover", 0.0f));
}
patchControl(patches, "music:prev", stringProp(props, "iconPrev", ""), primary, accentSoft, expandedAlpha, numberProp(props, "prevHover", 0.0f));
patchControl(patches, "music:play", stringProp(props, "iconPlay", ""), primary, accentSoft, expandedAlpha, numberProp(props, "playHover", 0.0f));
patchControl(patches, "music:next", stringProp(props, "iconNext", ""), primary, accentSoft, expandedAlpha, numberProp(props, "nextHover", 0.0f));
if (boolProp(props, "showRepeat", false)) {
patchSvgControl(patches, "music:repeat", stringProp(props, "repeatAsset", "repeat-off"), stringProp(props, "repeatColor", "#99000000"), accentSoft, expandedAlpha, numberProp(props, "repeatHover", 0.0f));
}
}
return patches.asMap();
}
private Map<String, UiBounds> islandBoundsPatches(UiScriptProps props) {
UiBoundsPatchSet patches = UiBoundsPatchSet.create(48);
float rootX = x;
float rootY = y;
float width = numberProp(props, "width", 0.0f);
float height = numberProp(props, "height", 0.0f);
float mainX = numberProp(props, "mainX", 0.0f);
float mainWidth = numberProp(props, "mainWidth", width);
float leftContextWidth = numberProp(props, "leftContextWidth", 0.0f);
float rightContextWidth = numberProp(props, "rightContextWidth", 0.0f);
String mode = stringProp(props, "mode", "");
boolean musicMode = "music".equals(mode);
boolean clickGuiMode = "clickgui".equals(mode);
putBounds(patches, "dynamic-island", rootX, rootY, width, height);
putBounds(patches, "content", rootX, rootY, width, height);
if (musicMode || clickGuiMode) {
putBounds(patches, "shell:blur", rootX + mainX, rootY, mainWidth, height);
putBounds(patches, "shell:shadow", rootX + mainX, rootY, mainWidth, height);
putBounds(patches, "shell:fill", rootX + mainX, rootY, mainWidth, height);
putBounds(patches, "shell:tint", rootX + mainX, rootY, mainWidth, height);
putBounds(patches, "shell:stroke", rootX + mainX, rootY, mainWidth, height);
}
if (musicMode || clickGuiMode) {
putBounds(patches, "context:time:blur", rootX, rootY + CONTEXT_CHIP_Y, leftContextWidth, CONTEXT_CHIP_H);
putBounds(patches, "context:time:box", rootX, rootY + CONTEXT_CHIP_Y, leftContextWidth, CONTEXT_CHIP_H);
putBounds(patches, "context:time", rootX, rootY + 10.1f, leftContextWidth, 14.0f);
float rightX = mainX + mainWidth + CONTEXT_GAP;
putBounds(patches, "context:pvp:blur", rootX + rightX, rootY + CONTEXT_CHIP_Y, rightContextWidth, CONTEXT_CHIP_H);
putBounds(patches, "context:pvp:box", rootX + rightX, rootY + CONTEXT_CHIP_Y, rightContextWidth, CONTEXT_CHIP_H);
putBounds(patches, "context:pvp", rootX + rightX, rootY + 10.1f, rightContextWidth, 14.0f);
}
if (musicMode) {
putMusicCompactBounds(patches, rootX, rootY, props, mainX, mainWidth);
putMusicExpandedBounds(patches, rootX, rootY, props, mainWidth);
} else if ("pvp".equals(mode)) {
float chipW = Math.min(mainWidth - 16.0f, Math.max(42.0f, PVP_CONTEXT_WIDTH + 8.0f));
float chipX = mainX + (mainWidth - chipW) * 0.5f;
putBounds(patches, "pvp:chip", rootX + chipX, rootY + CONTEXT_CHIP_Y, chipW, CONTEXT_CHIP_H);
putBounds(patches, "pvp:timer", rootX + chipX, rootY + 10.1f, chipW, 14.0f);
} else {
putBounds(patches, "time:box", rootX + mainX, rootY + CONTEXT_CHIP_Y, mainWidth, CONTEXT_CHIP_H);
putBounds(patches, "time", rootX + mainX, rootY + 7.8f, mainWidth, 20.0f);
}
return patches.asMap();
}
private void putMusicCompactBounds(UiBoundsPatchSet patches,
float rootX,
float rootY,
UiScriptProps props,
float mainX,
float width) {
float waveX = mainX + width - 26.0f;
putBounds(patches, "music:elapsed:compact", rootX + mainX + 11.0f, rootY + 10.5f, 38.0f, 13.0f);
putBounds(patches, "music:divider", rootX + mainX + 49.0f, rootY + 7.0f, 1.0f, 18.0f);
putBounds(patches, "artwork:compact", rootX + mainX + 58.0f, rootY + 6.5f, 22.0f, 22.0f);
putBounds(patches, "artwork:compact:fallback", rootX + mainX + 58.0f, rootY + 6.5f, 22.0f, 22.0f);
float titleW = Math.max(0.0f, width - 124.0f);
putBounds(patches, "music:title:compact:clip", rootX + mainX + 89.0f, rootY + 8.5f, titleW, 18.0f);
putBounds(patches, "music:title:compact", rootX + mainX + 89.0f, rootY + 8.5f, titleW, 18.0f);
float centerY = 17.5f;
float t = Util.getMillis() / 1000.0f;
boolean playing = boolProp(props, "playing", false);
for (int i = 0; i < 3; i++) {
float barH = playing
? (float) (3.0f + Math.abs(Math.sin(t * 5.2f + i * 0.85f)) * 8.0f)
: 4.0f + i;
putBounds(patches, "wave:" + i, rootX + waveX + i * 5.0f, rootY + centerY - barH * 0.5f, 2.5f, barH);
}
}
private void putMusicExpandedBounds(UiBoundsPatchSet patches,
float rootX,
float rootY,
UiScriptProps props,
float width) {
float mainX = numberProp(props, "mainX", 0.0f);
float progressW = Math.max(0.0f, width - 26.0f);
putBounds(patches, "artwork:expanded", rootX + mainX + 13.0f, rootY + 11.0f, 36.0f, 36.0f);
putBounds(patches, "artwork:expanded:fallback", rootX + mainX + 13.0f, rootY + 11.0f, 36.0f, 36.0f);
float titleW = Math.max(0.0f, width - 72.0f);
putBounds(patches, "music:title:expanded:clip", rootX + mainX + 59.0f, rootY + 11.0f, titleW, 16.0f);
putBounds(patches, "music:title:expanded", rootX + mainX + 59.0f, rootY + 11.0f, titleW, 16.0f);
putBounds(patches, "music:artist:expanded:clip", rootX + mainX + 59.0f, rootY + 28.0f, titleW, 14.0f);
putBounds(patches, "music:artist:expanded", rootX + mainX + 59.0f, rootY + 28.0f, titleW, 14.0f);
putBounds(patches, "music:progress:bg", rootX + mainX + 13.0f, rootY + 52.0f, progressW, 5.0f);
putBounds(patches, "music:progress:fill", rootX + mainX + 13.0f, rootY + 52.0f, progressW, 5.0f);
putBounds(patches, "music:elapsed:expanded", rootX + mainX + 13.0f, rootY + 65.0f, 44.0f, 14.0f);
putBounds(patches, "music:total:expanded", rootX + mainX + width - 57.0f, rootY + 65.0f, 44.0f, 14.0f);
if (boolProp(props, "showShuffle", false)) {
putControlBounds(patches, "music:shuffle", shuffleX, shuffleY, controlSize);
}
putControlBounds(patches, "music:prev", prevX, prevY, controlSize);
putControlBounds(patches, "music:play", playX, playY, controlSize);
putControlBounds(patches, "music:next", nextX, nextY, controlSize);
if (boolProp(props, "showRepeat", false)) {
putSvgControlBounds(patches, "music:repeat", repeatX, repeatY, controlSize);
}
}
private UiScriptProps buildProps(int screenW, private UiScriptProps buildProps(int screenW,
int screenH, int screenH,
float width, float width,
@ -909,7 +470,7 @@ public final class DynamicIsland extends AbstractHudElement {
props.put("mainX", mainX); props.put("mainX", mainX);
props.put("leftContextWidth", leftContextWidth); props.put("leftContextWidth", leftContextWidth);
props.put("rightContextWidth", rightContextWidth); props.put("rightContextWidth", rightContextWidth);
props.put("contextGap", CONTEXT_GAP); props.put("contextGap", IslandBoundsPatches.CONTEXT_GAP);
boolean pvpContextVisible = (currentMode == IslandMode.MUSIC || currentMode == IslandMode.CLICKGUI) && PvpState.isActive() && rightContextWidth > 0.5f; boolean pvpContextVisible = (currentMode == IslandMode.MUSIC || currentMode == IslandMode.CLICKGUI) && PvpState.isActive() && rightContextWidth > 0.5f;
props.put("pvpContextVisible", pvpContextVisible); props.put("pvpContextVisible", pvpContextVisible);
props.put("contextAlpha", currentMode == IslandMode.MUSIC ? compactAlpha : (currentMode == IslandMode.CLICKGUI ? alpha : 0.0f)); props.put("contextAlpha", currentMode == IslandMode.MUSIC ? compactAlpha : (currentMode == IslandMode.CLICKGUI ? alpha : 0.0f));
@ -922,70 +483,70 @@ public final class DynamicIsland extends AbstractHudElement {
props.put("blur", blur.get()); props.put("blur", blur.get());
props.put("blurAlpha", blurAlpha.get() / 255.0f); props.put("blurAlpha", blurAlpha.get() / 255.0f);
props.put("radius", radius); props.put("radius", radius);
props.put("time", formatClock(LocalTime.now())); props.put("time", IslandUiUtil.formatClock(LocalTime.now()));
props.put("pvpTimer", pvpContextVisible || currentMode == IslandMode.PVP ? formatSecondsOnly(predictedPvpSeconds()) : ""); props.put("pvpTimer", pvpContextVisible || currentMode == IslandMode.PVP ? IslandUiUtil.formatSecondsOnly(predictedPvpSeconds()) : "");
ClickGuiRenderer.ClickGuiIslandState clickGuiState = ClickGuiRenderer.islandState(); ClickGuiRenderer.ClickGuiIslandState clickGuiState = ClickGuiRenderer.islandState();
props.put("clickGuiLabel", clickGuiState.activeLabel()); props.put("clickGuiLabel", clickGuiState.activeLabel());
props.put("clickGuiTabCount", clickGuiState.tabCount()); props.put("clickGuiTabCount", clickGuiState.tabCount());
props.put("clickGuiPicker", clickGuiState.pickerActive()); props.put("clickGuiPicker", clickGuiState.pickerActive());
props.put("title", title); props.put("title", title);
props.put("artist", artist); props.put("artist", artist);
props.put("titleWidthCompact", measureWidth(titleRenderer, title, 0.82f)); props.put("titleWidthCompact", IslandUiUtil.measureWidth(titleRenderer, title, 0.82f));
props.put("titleWidthExpanded", measureWidth(titleRenderer, title, 0.86f)); props.put("titleWidthExpanded", IslandUiUtil.measureWidth(titleRenderer, title, 0.86f));
props.put("artistWidthExpanded", measureWidth(metaRenderer, artist, 0.72f)); props.put("artistWidthExpanded", IslandUiUtil.measureWidth(metaRenderer, artist, 0.72f));
props.put("titleScrollTime", titleScrollSeconds(title, artist, duration)); props.put("titleScrollTime", titleScrollSeconds(title, artist, duration));
props.put("elapsed", formatShortTime(currentSnapshot != null ? currentSnapshot.predictedPositionSeconds() : 0L)); props.put("elapsed", IslandUiUtil.formatShortTime(currentSnapshot != null ? currentSnapshot.predictedPositionSeconds() : 0L));
props.put("total", formatShortTime(currentSnapshot != null ? currentSnapshot.durationSeconds() : 0L)); props.put("total", IslandUiUtil.formatShortTime(currentSnapshot != null ? currentSnapshot.durationSeconds() : 0L));
props.put("progress", currentSnapshot != null && currentSnapshot.durationSeconds() > 0 props.put("progress", currentSnapshot != null && currentSnapshot.durationSeconds() > 0
? AnimationUtility.clamp((float) currentSnapshot.predictedPositionSeconds() / (float) currentSnapshot.durationSeconds(), 0f, 1f) ? AnimationUtility.clamp((float) currentSnapshot.predictedPositionSeconds() / (float) currentSnapshot.durationSeconds(), 0f, 1f)
: 0f); : 0f);
props.put("playing", currentSnapshot != null && currentSnapshot.isPlaying()); props.put("playing", currentSnapshot != null && currentSnapshot.isPlaying());
Identifier artwork = currentSnapshot != null ? currentSnapshot.artworkTexture() : null; Identifier artwork = currentSnapshot != null ? currentSnapshot.artworkTexture() : null;
props.put("artworkTexture", artwork != null ? artwork.toString() : ""); props.put("artworkTexture", artwork != null ? artwork.toString() : "");
props.put("fillTop", hex(HudRenderUtil.scaleAlpha(uiFillTop, 0.88f * alpha))); props.put("fillTop", IslandUiUtil.hex(HudRenderUtil.scaleAlpha(uiFillTop, 0.88f * alpha)));
props.put("fillBottom", hex(HudRenderUtil.scaleAlpha(uiFillBottom, 0.88f * alpha))); props.put("fillBottom", IslandUiUtil.hex(HudRenderUtil.scaleAlpha(uiFillBottom, 0.88f * alpha)));
props.put("tintTop", hex(HudRenderUtil.scaleAlpha(HudRenderUtil.mixColor(accent, 0xFFFFFFFF, 0.10f), 0.16f * alpha))); props.put("tintTop", IslandUiUtil.hex(HudRenderUtil.scaleAlpha(HudRenderUtil.mixColor(accent, 0xFFFFFFFF, 0.10f), 0.16f * alpha)));
props.put("tintBottom", hex(HudRenderUtil.scaleAlpha(HudRenderUtil.mixColor(accent, 0xFF000000, 0.25f), 0.112f * alpha))); props.put("tintBottom", IslandUiUtil.hex(HudRenderUtil.scaleAlpha(HudRenderUtil.mixColor(accent, 0xFF000000, 0.25f), 0.112f * alpha)));
props.put("shadow", hex(HudRenderUtil.scaleAlpha(BASE_SHADOW, 0.28f * alpha))); props.put("shadow", IslandUiUtil.hex(HudRenderUtil.scaleAlpha(IslandUiUtil.BASE_SHADOW, 0.28f * alpha)));
props.put("stroke", hex(HudRenderUtil.scaleAlpha(HudRenderUtil.mixColor(syncTheme.get() ? theme().windowStroke() : BASE_STROKE, accent, 0.18f), 0.18f * alpha))); props.put("stroke", IslandUiUtil.hex(HudRenderUtil.scaleAlpha(HudRenderUtil.mixColor(syncTheme.get() ? theme().windowStroke() : IslandUiUtil.BASE_STROKE, accent, 0.18f), 0.18f * alpha)));
props.put("highlight", hex(HudRenderUtil.scaleAlpha(0xFFFFFFFF, 0.08f * alpha))); props.put("highlight", IslandUiUtil.hex(HudRenderUtil.scaleAlpha(0xFFFFFFFF, 0.08f * alpha)));
props.put("contextFill", hex(HudRenderUtil.scaleAlpha(0xFF000000, 0.36f * alpha))); props.put("contextFill", IslandUiUtil.hex(HudRenderUtil.scaleAlpha(0xFF000000, 0.36f * alpha)));
props.put("contextStroke", hex(HudRenderUtil.scaleAlpha(0xFFFFFFFF, 0.10f * alpha))); props.put("contextStroke", IslandUiUtil.hex(HudRenderUtil.scaleAlpha(0xFFFFFFFF, 0.10f * alpha)));
props.put("timeFill", hex(HudRenderUtil.scaleAlpha(0xFF000000, 0.48f * alpha))); props.put("timeFill", IslandUiUtil.hex(HudRenderUtil.scaleAlpha(0xFF000000, 0.48f * alpha)));
props.put("timeStroke", hex(HudRenderUtil.scaleAlpha(0xFFFFFFFF, 0.10f * alpha))); props.put("timeStroke", IslandUiUtil.hex(HudRenderUtil.scaleAlpha(0xFFFFFFFF, 0.10f * alpha)));
props.put("pvpFill", hex(HudRenderUtil.scaleAlpha(STATUS_PVP, 0.86f * alpha))); props.put("pvpFill", IslandUiUtil.hex(HudRenderUtil.scaleAlpha(STATUS_PVP, 0.86f * alpha)));
props.put("pvpStroke", hex(HudRenderUtil.scaleAlpha(HudRenderUtil.mixColor(STATUS_PVP, 0xFFFFFFFF, 0.32f), 0.34f * alpha))); props.put("pvpStroke", IslandUiUtil.hex(HudRenderUtil.scaleAlpha(HudRenderUtil.mixColor(STATUS_PVP, 0xFFFFFFFF, 0.32f), 0.34f * alpha)));
props.put("accent", hex(HudRenderUtil.scaleAlpha(accent, alpha))); props.put("accent", IslandUiUtil.hex(HudRenderUtil.scaleAlpha(accent, alpha)));
props.put("accentSoft", hex(HudRenderUtil.scaleAlpha(uiAccentSoft, alpha))); props.put("accentSoft", IslandUiUtil.hex(HudRenderUtil.scaleAlpha(uiAccentSoft, alpha)));
props.put("textPrimary", hex(HudRenderUtil.scaleAlpha(uiTextPrimary, alpha))); props.put("textPrimary", IslandUiUtil.hex(HudRenderUtil.scaleAlpha(uiTextPrimary, alpha)));
props.put("textSecondary", hex(HudRenderUtil.scaleAlpha(uiTextSecondary, alpha))); props.put("textSecondary", IslandUiUtil.hex(HudRenderUtil.scaleAlpha(uiTextSecondary, alpha)));
props.put("textMuted", hex(HudRenderUtil.scaleAlpha(uiTextMuted, alpha))); props.put("textMuted", IslandUiUtil.hex(HudRenderUtil.scaleAlpha(uiTextMuted, alpha)));
props.put("progressBg", hex(HudRenderUtil.scaleAlpha(PROGRESS_BG, expandedAlpha))); props.put("progressBg", IslandUiUtil.hex(HudRenderUtil.scaleAlpha(PROGRESS_BG, expandedAlpha)));
props.put("shuffleX", shuffleX - x); props.put("shuffleX", controls.shuffleX - x);
props.put("shuffleY", shuffleY - y); props.put("shuffleY", controls.shuffleY - y);
props.put("prevX", prevX - x); props.put("prevX", controls.prevX - x);
props.put("prevY", prevY - y); props.put("prevY", controls.prevY - y);
props.put("playX", playX - x); props.put("playX", controls.playX - x);
props.put("playY", playY - y); props.put("playY", controls.playY - y);
props.put("nextX", nextX - x); props.put("nextX", controls.nextX - x);
props.put("nextY", nextY - y); props.put("nextY", controls.nextY - y);
props.put("repeatX", repeatX - x); props.put("repeatX", controls.repeatX - x);
props.put("repeatY", repeatY - y); props.put("repeatY", controls.repeatY - y);
props.put("controlSize", controlSize); props.put("controlSize", controls.controlSize);
props.put("showShuffle", currentSnapshot != null && currentSnapshot.supportsShuffle()); props.put("showShuffle", currentSnapshot != null && currentSnapshot.supportsShuffle());
props.put("showRepeat", currentSnapshot != null && currentSnapshot.supportsRepeat()); props.put("showRepeat", currentSnapshot != null && currentSnapshot.supportsRepeat());
props.put("shuffleHover", shuffleHover); props.put("shuffleHover", controls.shuffleHover);
props.put("prevHover", prevHover); props.put("prevHover", controls.prevHover);
props.put("playHover", playHover); props.put("playHover", controls.playHover);
props.put("nextHover", nextHover); props.put("nextHover", controls.nextHover);
props.put("repeatHover", repeatHover); props.put("repeatHover", controls.repeatHover);
props.put("iconShuffle", iconString(ICON_SHUFFLE)); props.put("iconShuffle", IslandUiUtil.iconString(ICON_SHUFFLE));
props.put("iconPrev", iconString(ICON_PREV)); props.put("iconPrev", IslandUiUtil.iconString(ICON_PREV));
props.put("iconPlay", iconString(currentSnapshot != null && currentSnapshot.isPlaying() ? ICON_PAUSE : ICON_PLAY)); props.put("iconPlay", IslandUiUtil.iconString(currentSnapshot != null && currentSnapshot.isPlaying() ? ICON_PAUSE : ICON_PLAY));
props.put("iconNext", iconString(ICON_NEXT)); props.put("iconNext", IslandUiUtil.iconString(ICON_NEXT));
props.put("shuffleColor", hex(shuffleColor(currentSnapshot))); props.put("shuffleColor", IslandUiUtil.hex(IslandUiUtil.shuffleColor(currentSnapshot)));
props.put("repeatColor", hex(repeatColor(currentSnapshot))); props.put("repeatColor", IslandUiUtil.hex(IslandUiUtil.repeatColor(currentSnapshot)));
props.put("repeatAsset", repeatSvg(currentSnapshot)); props.put("repeatAsset", IslandUiUtil.repeatSvg(currentSnapshot));
return props; return props;
} }
@ -1023,55 +584,15 @@ public final class DynamicIsland extends AbstractHudElement {
private IslandMetrics measureMetrics(TextRenderer titleRenderer, private IslandMetrics measureMetrics(TextRenderer titleRenderer,
TextRenderer valueRenderer, TextRenderer valueRenderer,
TextRenderer metaRenderer) { TextRenderer metaRenderer) {
float pvpTimerW = reservedTimeWidth(valueRenderer, formatSecondsOnly(predictedPvpSeconds()), 0.84f); float pvpTimerW = IslandUiUtil.reservedTimeWidth(valueRenderer, IslandUiUtil.formatSecondsOnly(predictedPvpSeconds()), 0.84f);
float pvpWidth = 24f + pvpTimerW; float pvpWidth = 24f + pvpTimerW;
String title = currentSnapshot != null ? currentSnapshot.title() : ""; String title = currentSnapshot != null ? currentSnapshot.title() : "";
float titleW = measureWidth(titleRenderer, title, 0.82f); float titleW = IslandUiUtil.measureWidth(titleRenderer, title, 0.82f);
String compactElapsed = formatShortTime(currentSnapshot != null ? currentSnapshot.predictedPositionSeconds() : 0L); String compactElapsed = IslandUiUtil.formatShortTime(currentSnapshot != null ? currentSnapshot.predictedPositionSeconds() : 0L);
float elapsedW = reservedTimeWidth(valueRenderer, compactElapsed, 0.72f); float elapsedW = IslandUiUtil.reservedTimeWidth(valueRenderer, compactElapsed, 0.72f);
float musicWidth = 10f + elapsedW + 18f + COMPACT_ART + COMPACT_GAP + titleW + COMPACT_GAP + COMPACT_WAVE_W + 10f; float musicWidth = 10f + elapsedW + 18f + COMPACT_ART + COMPACT_GAP + titleW + COMPACT_GAP + COMPACT_WAVE_W + 10f;
return new IslandMetrics(pvpWidth, clamp(musicWidth, 226f, 308f)); return new IslandMetrics(pvpWidth, IslandUiUtil.clamp(musicWidth, 226f, 308f));
}
private void updateMusicControls(float drawX, float drawY, float width, TextRenderer valueRenderer) {
float progressX = drawX + EXPANDED_PAD_X;
float progressW = width - EXPANDED_PAD_X * 2f;
float progressY = drawY + 52f;
float timeH = measureHeight(valueRenderer, 0.80f);
controlSize = CONTROL_SIZE;
float controlGap = CONTROL_GAP;
boolean showShuffle = currentSnapshot != null && currentSnapshot.supportsShuffle();
boolean showRepeat = currentSnapshot != null && currentSnapshot.supportsRepeat();
int activeControls = 3 + (showShuffle ? 1 : 0) + (showRepeat ? 1 : 0);
float totalControlsW = controlSize * activeControls + controlGap * Math.max(0, activeControls - 1);
float controlsX = drawX + (width - totalControlsW) * 0.5f;
float controlsY = progressY + 5f + timeH + CONTROL_ROW_GAP_Y;
float cursorX = controlsX;
if (showShuffle) {
shuffleX = cursorX;
shuffleY = controlsY;
cursorX += controlSize + controlGap;
} else {
shuffleX = shuffleY = 0f;
}
prevX = cursorX;
prevY = controlsY;
cursorX += controlSize + controlGap;
playX = cursorX;
playY = controlsY;
cursorX += controlSize + controlGap;
nextX = cursorX;
nextY = controlsY;
cursorX += controlSize + controlGap;
if (showRepeat) {
repeatX = cursorX;
repeatY = controlsY;
} else {
repeatX = repeatY = 0f;
}
} }
private boolean isInteractiveScreen(Screen screen) { private boolean isInteractiveScreen(Screen screen) {
@ -1081,91 +602,7 @@ public final class DynamicIsland extends AbstractHudElement {
private boolean isScreenOverlayAllowed(Screen screen) { private boolean isScreenOverlayAllowed(Screen screen) {
if (screen == null || screen instanceof ChatScreen) return false; if (screen == null || screen instanceof ChatScreen) return false;
if (screen instanceof ClickGuiScreen) return false; if (screen instanceof ClickGuiScreen) return false;
return !isSettingsLikeScreen(screen); return !IslandUiUtil.isSettingsLikeScreen(screen);
}
private float bossBarOffset(int logicalScreenH) {
if (mc == null || mc.gui == null || mc.getWindow() == null) return 0f;
BossHealthOverlay bossBarHud = mc.gui.hud.getBossOverlay();
if (bossBarHud == null) return 0f;
Map<UUID, LerpingBossEvent> bars;
try {
bars = ((BossHealthOverlayAccessor) bossBarHud).getBars();
} catch (Throwable ignored) {
return 0f;
}
if (bars == null || bars.isEmpty()) return 0f;
int scaledH = Math.max(1, mc.getWindow().getGuiScaledHeight());
int y = 12;
int rows = 0;
for (LerpingBossEvent ignored : bars.values()) {
rows++;
y += 19;
if (y >= scaledH / 3) break;
}
if (rows <= 0) return 0f;
float bossBottomScaled = 17f + 19f * (rows - 1);
float hudScale = HudScale.scale(mc.getWindow().getWidth(), mc.getWindow().getHeight());
float windowScale = (float) mc.getWindow().getGuiScale();
float bossBottomLogical = (bossBottomScaled + 8f) * windowScale / Math.max(0.001f, hudScale);
return Math.max(0f, Math.min(logicalScreenH * 0.30f, bossBottomLogical) - TOP_Y);
}
private void drawFallback(Renderer2D renderer,
float drawX,
float drawY,
float drawWidth,
float drawHeight,
float radius,
float alpha,
int accent) {
renderer.roundedRectShadow(drawX, drawY, drawWidth, drawHeight, radius, 8f, 14f,
HudRenderUtil.scaleAlpha(BASE_SHADOW, 0.28f * alpha));
renderer.roundedRect(drawX, drawY, drawWidth, drawHeight, radius, 1.1f,
HudRenderUtil.scaleAlpha(uiFillTop, 0.88f * alpha));
renderer.roundedRectStroke(drawX, drawY, drawWidth, drawHeight, radius, 1.1f, 1f,
HudRenderUtil.scaleAlpha(HudRenderUtil.mixColor(BASE_STROKE, accent, 0.18f), 0.18f * alpha));
}
private void updateControlHover(boolean chatOpen) {
if (!chatOpen || controlSize <= 0f || mc == null || mc.getWindow() == null) {
shuffleHover = AnimationUtility.approach(shuffleHover, 0f, 0.25f);
prevHover = AnimationUtility.approach(prevHover, 0f, 0.25f);
playHover = AnimationUtility.approach(playHover, 0f, 0.25f);
nextHover = AnimationUtility.approach(nextHover, 0f, 0.25f);
repeatHover = AnimationUtility.approach(repeatHover, 0f, 0.25f);
return;
}
int fbw = mc.getWindow().getWidth();
int fbh = mc.getWindow().getHeight();
float uiScale = HudScale.scale(fbw, fbh);
float mx = HudScale.toVirtual((float) mc.mouseHandler.xpos(), uiScale);
float my = HudScale.toVirtual((float) mc.mouseHandler.ypos(), uiScale);
boolean showShuffle = currentSnapshot != null && currentSnapshot.supportsShuffle();
boolean showRepeat = currentSnapshot != null && currentSnapshot.supportsRepeat();
shuffleHover = AnimationUtility.approach(shuffleHover, showShuffle && hit(mx, my, shuffleX, shuffleY, controlSize, controlSize) ? 1f : 0f, 0.25f);
prevHover = AnimationUtility.approach(prevHover, hit(mx, my, prevX, prevY, controlSize, controlSize) ? 1f : 0f, 0.25f);
playHover = AnimationUtility.approach(playHover, hit(mx, my, playX, playY, controlSize, controlSize) ? 1f : 0f, 0.25f);
nextHover = AnimationUtility.approach(nextHover, hit(mx, my, nextX, nextY, controlSize, controlSize) ? 1f : 0f, 0.25f);
repeatHover = AnimationUtility.approach(repeatHover, showRepeat && hit(mx, my, repeatX, repeatY, controlSize, controlSize) ? 1f : 0f, 0.25f);
}
private void resetControls() {
shuffleX = shuffleY = 0f;
prevX = prevY = 0f;
playX = playY = 0f;
nextX = nextY = 0f;
repeatX = repeatY = 0f;
controlSize = 0f;
shuffleHover = 0f;
prevHover = 0f;
playHover = 0f;
nextHover = 0f;
repeatHover = 0f;
} }
private int accentFor(IslandMode mode) { private int accentFor(IslandMode mode) {
@ -1208,40 +645,6 @@ public final class DynamicIsland extends AbstractHudElement {
return Math.max(0f, secondsLeft - sinceUpdate / 1000f); return Math.max(0f, secondsLeft - sinceUpdate / 1000f);
} }
private float measureWidth(TextRenderer renderer, String text, float scale) {
if (renderer == null || text == null || text.isEmpty()) return 0f;
renderer.begin(scale, true, false);
try {
return (float) renderer.getWidth(text, false);
} finally {
renderer.end();
}
}
private float measureHeight(TextRenderer renderer, float scale) {
if (renderer == null) return 0f;
renderer.begin(scale, true, false);
try {
return (float) renderer.getHeight(false);
} finally {
renderer.end();
}
}
private float reservedTimeWidth(TextRenderer renderer, String text, float scale) {
return measureWidth(renderer, reserveTimeTemplate(text), scale);
}
private String reserveTimeTemplate(String text) {
if (text == null || text.isEmpty()) return "0:00";
StringBuilder out = new StringBuilder(text.length());
for (int i = 0; i < text.length(); i++) {
char c = text.charAt(i);
out.append(Character.isDigit(c) ? '8' : c);
}
return out.toString();
}
private enum IslandMode { private enum IslandMode {
TIME, TIME,
MUSIC, MUSIC,

View file

@ -0,0 +1,141 @@
/*
* This file is part of the LoVisual Client distribution.
* Copyright (c) 2026 loki5512344.
*
* Licensed under the GNU General Public License v3.0.
*/
package dev.loki.lovisual.features.gui.hud.nondraggable.impl.dynamicisland;
import dev.loki.lovisual.features.gui.hud.nondraggable.impl.dynamicisland.controls.IslandControls;
import dev.loki.lovisual.render.engine.renderer.ui.runtime.core.UiBounds;
import dev.loki.lovisual.render.engine.renderer.ui.runtime.script.UiBoundsPatchSet;
import dev.loki.lovisual.render.engine.renderer.ui.runtime.script.UiScriptProps;
import net.minecraft.util.Util;
import java.util.Map;
import static dev.loki.lovisual.features.gui.hud.nondraggable.impl.dynamicisland.IslandPatchOps.*;
/**
* Bounds-патчи узлов UI-скрипта Dynamic Island по режимам (time/pvp/music/clickgui).
*/
public final class IslandBoundsPatches {
public static final float CONTEXT_GAP = 7f;
public static final float CONTEXT_CHIP_H = 20f;
public static final float CONTEXT_CHIP_Y = 7.5f;
public static final float PVP_CONTEXT_WIDTH = 46f;
private IslandBoundsPatches() {
}
public static Map<String, UiBounds> islandBoundsPatches(UiScriptProps props,
float rootX,
float rootY,
IslandControls controls) {
UiBoundsPatchSet patches = UiBoundsPatchSet.create(48);
float width = numberProp(props, "width", 0.0f);
float height = numberProp(props, "height", 0.0f);
float mainX = numberProp(props, "mainX", 0.0f);
float mainWidth = numberProp(props, "mainWidth", width);
float leftContextWidth = numberProp(props, "leftContextWidth", 0.0f);
float rightContextWidth = numberProp(props, "rightContextWidth", 0.0f);
String mode = stringProp(props, "mode", "");
boolean musicMode = "music".equals(mode);
boolean clickGuiMode = "clickgui".equals(mode);
putBounds(patches, "dynamic-island", rootX, rootY, width, height);
putBounds(patches, "content", rootX, rootY, width, height);
if (musicMode || clickGuiMode) {
putBounds(patches, "shell:blur", rootX + mainX, rootY, mainWidth, height);
putBounds(patches, "shell:shadow", rootX + mainX, rootY, mainWidth, height);
putBounds(patches, "shell:fill", rootX + mainX, rootY, mainWidth, height);
putBounds(patches, "shell:tint", rootX + mainX, rootY, mainWidth, height);
putBounds(patches, "shell:stroke", rootX + mainX, rootY, mainWidth, height);
}
if (musicMode || clickGuiMode) {
putBounds(patches, "context:time:blur", rootX, rootY + CONTEXT_CHIP_Y, leftContextWidth, CONTEXT_CHIP_H);
putBounds(patches, "context:time:box", rootX, rootY + CONTEXT_CHIP_Y, leftContextWidth, CONTEXT_CHIP_H);
putBounds(patches, "context:time", rootX, rootY + 10.1f, leftContextWidth, 14.0f);
float rightX = mainX + mainWidth + CONTEXT_GAP;
putBounds(patches, "context:pvp:blur", rootX + rightX, rootY + CONTEXT_CHIP_Y, rightContextWidth, CONTEXT_CHIP_H);
putBounds(patches, "context:pvp:box", rootX + rightX, rootY + CONTEXT_CHIP_Y, rightContextWidth, CONTEXT_CHIP_H);
putBounds(patches, "context:pvp", rootX + rightX, rootY + 10.1f, rightContextWidth, 14.0f);
}
if (musicMode) {
putMusicCompactBounds(patches, rootX, rootY, props, mainX, mainWidth);
putMusicExpandedBounds(patches, rootX, rootY, props, mainWidth, controls);
} else if ("pvp".equals(mode)) {
float chipW = Math.min(mainWidth - 16.0f, Math.max(42.0f, PVP_CONTEXT_WIDTH + 8.0f));
float chipX = mainX + (mainWidth - chipW) * 0.5f;
putBounds(patches, "pvp:chip", rootX + chipX, rootY + CONTEXT_CHIP_Y, chipW, CONTEXT_CHIP_H);
putBounds(patches, "pvp:timer", rootX + chipX, rootY + 10.1f, chipW, 14.0f);
} else {
putBounds(patches, "time:box", rootX + mainX, rootY + CONTEXT_CHIP_Y, mainWidth, CONTEXT_CHIP_H);
putBounds(patches, "time", rootX + mainX, rootY + 7.8f, mainWidth, 20.0f);
}
return patches.asMap();
}
private static void putMusicCompactBounds(UiBoundsPatchSet patches,
float rootX,
float rootY,
UiScriptProps props,
float mainX,
float width) {
float waveX = mainX + width - 26.0f;
putBounds(patches, "music:elapsed:compact", rootX + mainX + 11.0f, rootY + 10.5f, 38.0f, 13.0f);
putBounds(patches, "music:divider", rootX + mainX + 49.0f, rootY + 7.0f, 1.0f, 18.0f);
putBounds(patches, "artwork:compact", rootX + mainX + 58.0f, rootY + 6.5f, 22.0f, 22.0f);
putBounds(patches, "artwork:compact:fallback", rootX + mainX + 58.0f, rootY + 6.5f, 22.0f, 22.0f);
float titleW = Math.max(0.0f, width - 124.0f);
putBounds(patches, "music:title:compact:clip", rootX + mainX + 89.0f, rootY + 8.5f, titleW, 18.0f);
putBounds(patches, "music:title:compact", rootX + mainX + 89.0f, rootY + 8.5f, titleW, 18.0f);
float centerY = 17.5f;
float t = Util.getMillis() / 1000.0f;
boolean playing = boolProp(props, "playing", false);
for (int i = 0; i < 3; i++) {
float barH = playing
? (float) (3.0f + Math.abs(Math.sin(t * 5.2f + i * 0.85f)) * 8.0f)
: 4.0f + i;
putBounds(patches, "wave:" + i, rootX + waveX + i * 5.0f, rootY + centerY - barH * 0.5f, 2.5f, barH);
}
}
private static void putMusicExpandedBounds(UiBoundsPatchSet patches,
float rootX,
float rootY,
UiScriptProps props,
float width,
IslandControls controls) {
float mainX = numberProp(props, "mainX", 0.0f);
float progressW = Math.max(0.0f, width - 26.0f);
putBounds(patches, "artwork:expanded", rootX + mainX + 13.0f, rootY + 11.0f, 36.0f, 36.0f);
putBounds(patches, "artwork:expanded:fallback", rootX + mainX + 13.0f, rootY + 11.0f, 36.0f, 36.0f);
float titleW = Math.max(0.0f, width - 72.0f);
putBounds(patches, "music:title:expanded:clip", rootX + mainX + 59.0f, rootY + 11.0f, titleW, 16.0f);
putBounds(patches, "music:title:expanded", rootX + mainX + 59.0f, rootY + 11.0f, titleW, 16.0f);
putBounds(patches, "music:artist:expanded:clip", rootX + mainX + 59.0f, rootY + 28.0f, titleW, 14.0f);
putBounds(patches, "music:artist:expanded", rootX + mainX + 59.0f, rootY + 28.0f, titleW, 14.0f);
putBounds(patches, "music:progress:bg", rootX + mainX + 13.0f, rootY + 52.0f, progressW, 5.0f);
putBounds(patches, "music:progress:fill", rootX + mainX + 13.0f, rootY + 52.0f, progressW, 5.0f);
putBounds(patches, "music:elapsed:expanded", rootX + mainX + 13.0f, rootY + 65.0f, 44.0f, 14.0f);
putBounds(patches, "music:total:expanded", rootX + mainX + width - 57.0f, rootY + 65.0f, 44.0f, 14.0f);
if (boolProp(props, "showShuffle", false)) {
IslandPatchOps.putControlBounds(patches, "music:shuffle", controls.shuffleX, controls.shuffleY, controls.controlSize);
}
IslandPatchOps.putControlBounds(patches, "music:prev", controls.prevX, controls.prevY, controls.controlSize);
IslandPatchOps.putControlBounds(patches, "music:play", controls.playX, controls.playY, controls.controlSize);
IslandPatchOps.putControlBounds(patches, "music:next", controls.nextX, controls.nextY, controls.controlSize);
if (boolProp(props, "showRepeat", false)) {
IslandPatchOps.putSvgControlBounds(patches, "music:repeat", controls.repeatX, controls.repeatY, controls.controlSize);
}
}
}

View file

@ -0,0 +1,139 @@
/*
* This file is part of the LoVisual Client distribution.
* Copyright (c) 2026 loki5512344.
*
* Licensed under the GNU General Public License v3.0.
*/
package dev.loki.lovisual.features.gui.hud.nondraggable.impl.dynamicisland;
import dev.loki.lovisual.render.engine.renderer.ui.runtime.script.UiBoundsPatchSet;
import dev.loki.lovisual.render.engine.renderer.ui.runtime.script.UiScriptColor;
import dev.loki.lovisual.render.engine.renderer.ui.runtime.script.UiScriptPatchSet;
import dev.loki.lovisual.render.engine.renderer.ui.runtime.script.UiScriptProps;
/**
* Примитивы патчинга UI-скрипта: текст, изображения, формы и bounds-патчи.
*/
public final class IslandPatchOps {
private IslandPatchOps() {
}
public static void putControlBounds(UiBoundsPatchSet patches,
String key,
float x,
float y,
float size) {
putBounds(patches, key + ":hover", x, y, size, size);
putBounds(patches, key, x, y + 4.0f, size, Math.max(0.0f, size - 4.0f));
}
public static void putSvgControlBounds(UiBoundsPatchSet patches,
String key,
float x,
float y,
float size) {
float iconSize = Math.max(0.0f, size * 0.74f);
putBounds(patches, key + ":hover", x, y, size, size);
putBounds(patches, key, x + (size - iconSize) * 0.5f, y + (size - iconSize) * 0.5f, iconSize, iconSize);
}
public static void putBounds(UiBoundsPatchSet patches,
String key,
float x,
float y,
float width,
float height) {
patches.put(key, x, y, width, height);
}
public static void patchControl(UiScriptPatchSet patches,
String key,
String icon,
String iconColor,
String accentSoft,
float expandedAlpha,
float hover) {
patchText(patches, key, icon, scaleHexAlpha(iconColor, expandedAlpha));
patchShape(patches, key + ":hover", "fill", scaleHexAlpha(accentSoft, Math.max(0.0f, hover) * 0.3f * expandedAlpha));
}
public static void patchSvgControl(UiScriptPatchSet patches,
String key,
String asset,
String iconColor,
String accentSoft,
float expandedAlpha,
float hover) {
patchImage(patches, key, asset, scaleHexAlpha(iconColor, expandedAlpha));
patchShape(patches, key + ":hover", "fill", scaleHexAlpha(accentSoft, Math.max(0.0f, hover) * 0.3f * expandedAlpha));
}
public static void patchText(UiScriptPatchSet patches,
String key,
String text,
String color) {
patches.text(key, text, color);
}
public static void patchClippedText(UiScriptPatchSet patches,
String key,
float measuredWidth,
float boxWidth,
float scrollTime,
float delay,
float speed,
float fadeWidth) {
patches.clippedText(key, measuredWidth, boxWidth, scrollTime, delay, speed, fadeWidth);
}
public static void patchImage(UiScriptPatchSet patches,
String key,
String asset,
String tint) {
patches.image(key, asset, tint);
}
public static void patchShape(UiScriptPatchSet patches,
String key,
Object... pairs) {
patches.props(key, pairs);
}
public static void patchShape(UiScriptPatchSet patches,
String key,
String prop,
Object value) {
patches.put(key, prop, value);
}
public static void patchShape(UiScriptPatchSet patches,
String key,
String propA,
Object valueA,
String propB,
Object valueB) {
patches.put(key, propA, valueA, propB, valueB);
}
public static String stringProp(UiScriptProps props, String key, String fallback) {
return props.string(key, fallback);
}
public static float numberProp(UiScriptProps props, String key, float fallback) {
return props.number(key, fallback);
}
public static boolean boolProp(UiScriptProps props, String key, boolean fallback) {
return props.bool(key, fallback);
}
public static float clamp01(float value) {
return Math.max(0.0f, Math.min(1.0f, value));
}
public static String scaleHexAlpha(String hex, float amount) {
return UiScriptColor.alpha(hex, amount);
}
}

View file

@ -0,0 +1,127 @@
/*
* This file is part of the LoVisual Client distribution.
* Copyright (c) 2026 loki5512344.
*
* Licensed under the GNU General Public License v3.0.
*/
package dev.loki.lovisual.features.gui.hud.nondraggable.impl.dynamicisland;
import dev.loki.lovisual.render.engine.renderer.ui.runtime.script.CachedUiScriptRuntime;
import dev.loki.lovisual.render.engine.renderer.ui.runtime.script.UiScriptPatchSet;
import dev.loki.lovisual.render.engine.renderer.ui.runtime.script.UiScriptProps;
import java.util.Map;
import static dev.loki.lovisual.features.gui.hud.nondraggable.impl.dynamicisland.IslandPatchOps.*;
/**
* Рантайм-патчи UI-скрипта Dynamic Island (свойства узлов по режимам) и структурная сигнатура.
*/
public final class IslandRuntimePatches {
private IslandRuntimePatches() {
}
public static long islandStructuralSignature(UiScriptProps props) {
long h = 0xcbf29ce484222325L;
h = CachedUiScriptRuntime.mix(h, stringProp(props, "mode", ""));
h = CachedUiScriptRuntime.mix(h, boolProp(props, "blur", false));
h = CachedUiScriptRuntime.mix(h, numberProp(props, "blurAlpha", 0.0f));
h = CachedUiScriptRuntime.mix(h, !stringProp(props, "artworkTexture", "").isEmpty());
h = CachedUiScriptRuntime.mix(h, "music".equals(stringProp(props, "mode", "")));
h = CachedUiScriptRuntime.mix(h, boolProp(props, "showShuffle", false));
h = CachedUiScriptRuntime.mix(h, boolProp(props, "showRepeat", false));
return h;
}
public static Map<String, ? extends Map<String, ?>> islandRuntimePatches(UiScriptProps props) {
UiScriptPatchSet patches = UiScriptPatchSet.create(48);
float compactAlpha = numberProp(props, "compactAlpha", 0.0f);
float expandedAlpha = numberProp(props, "expandedAlpha", 0.0f);
float contextAlpha = numberProp(props, "contextAlpha", compactAlpha);
float width = numberProp(props, "width", 0.0f);
float mainWidth = numberProp(props, "mainWidth", width);
float progress = clamp01(numberProp(props, "progress", 0.0f));
String mode = stringProp(props, "mode", "");
boolean musicMode = "music".equals(mode);
boolean clickGuiMode = "clickgui".equals(mode);
boolean shellMode = musicMode || clickGuiMode;
boolean pvpMode = "pvp".equals(mode);
boolean timeMode = "time".equals(mode);
String primary = stringProp(props, "textPrimary", "#FFFFFFFF");
String secondary = stringProp(props, "textSecondary", "#99FFFFFF");
String muted = stringProp(props, "textMuted", "#66FFFFFF");
String accent = stringProp(props, "accent", "#FFFFFFFF");
String accentSoft = stringProp(props, "accentSoft", "#33FFFFFF");
if (shellMode) {
float shellBlurAlpha = numberProp(props, "blurAlpha", 0.0f) * numberProp(props, "alpha", 0.0f);
patchShape(patches, "dynamic-island", "renderRadius", props.get("radius"), "renderBlurAlpha", shellBlurAlpha);
patchShape(patches, "shell:blur", "blurAlpha", shellBlurAlpha, "radius", props.get("radius"));
patchShape(patches, "shell:shadow", "fill", props.get("shadow"), "radius", props.get("radius"));
patchShape(patches, "shell:fill", "startColor", props.get("fillTop"), "endColor", props.get("fillBottom"), "radius", props.get("radius"));
patchShape(patches, "shell:tint", "startColor", props.get("tintTop"), "endColor", props.get("tintBottom"), "radius", props.get("radius"));
patchShape(patches, "shell:stroke", "stroke", props.get("stroke"), "radius", props.get("radius"));
}
if (timeMode) {
patchShape(patches, "time:box", "fill", props.get("timeFill"), "stroke", props.get("timeStroke"));
patchText(patches, "time", stringProp(props, "time", ""), primary);
}
if (pvpMode) {
patchShape(patches, "pvp:chip", "fill", props.get("pvpFill"), "stroke", props.get("pvpStroke"));
patchText(patches, "pvp:timer", stringProp(props, "pvpTimer", "0s"), primary);
}
if (musicMode || clickGuiMode) {
float contextBlurAlpha = numberProp(props, "blurAlpha", 0.0f) * contextAlpha;
patchShape(patches, "context:time:blur", "blurAlpha", contextBlurAlpha, "radius", 10.0f);
patchShape(patches, "context:time:box", "fill", scaleHexAlpha(stringProp(props, "contextFill", "#00000000"), contextAlpha), "stroke", scaleHexAlpha(stringProp(props, "contextStroke", "#00000000"), contextAlpha));
patchText(patches, "context:time", stringProp(props, "time", ""), scaleHexAlpha(primary, contextAlpha));
float pvpContextAlpha = boolProp(props, "pvpContextVisible", false) ? contextAlpha : 0.0f;
patchShape(patches, "context:pvp:blur", "blurAlpha", numberProp(props, "blurAlpha", 0.0f) * pvpContextAlpha, "radius", 10.0f);
patchShape(patches, "context:pvp:box", "fill", scaleHexAlpha(stringProp(props, "pvpFill", "#00000000"), pvpContextAlpha), "stroke", scaleHexAlpha(stringProp(props, "pvpStroke", "#00000000"), pvpContextAlpha));
patchText(patches, "context:pvp", stringProp(props, "pvpTimer", ""), scaleHexAlpha(primary, pvpContextAlpha));
}
if (musicMode) {
patchText(patches, "music:elapsed:compact", stringProp(props, "elapsed", "0:00"), scaleHexAlpha(secondary, compactAlpha));
patchText(patches, "music:title:compact", stringProp(props, "title", ""), scaleHexAlpha(primary, compactAlpha));
patchClippedText(patches, "music:title:compact", numberProp(props, "titleWidthCompact", 0.0f), Math.max(0.0f, mainWidth - 124.0f), numberProp(props, "titleScrollTime", 0.0f), 1.0f, 18.0f, 15.0f);
patchShape(patches, "music:divider", "fill", scaleHexAlpha(muted, 0.4f * compactAlpha));
patchShape(patches, "wave:0", "fill", scaleHexAlpha(accent, 0.75f * compactAlpha));
patchShape(patches, "wave:1", "fill", scaleHexAlpha(accent, 0.83f * compactAlpha));
patchShape(patches, "wave:2", "fill", scaleHexAlpha(accent, 0.91f * compactAlpha));
patchImage(patches, "artwork:compact", stringProp(props, "artworkTexture", ""), scaleHexAlpha("#FFFFFFFF", compactAlpha));
patchShape(patches, "artwork:compact:fallback", "fill", scaleHexAlpha(accent, 0.92f * compactAlpha), "stroke", scaleHexAlpha("#33FFFFFF", compactAlpha));
}
if (musicMode) {
patchText(patches, "music:title:expanded", stringProp(props, "title", ""), scaleHexAlpha(primary, expandedAlpha));
patchClippedText(patches, "music:title:expanded", numberProp(props, "titleWidthExpanded", 0.0f), Math.max(0.0f, mainWidth - 72.0f), numberProp(props, "titleScrollTime", 0.0f), 1.0f, 18.0f, 16.0f);
patchText(patches, "music:artist:expanded", stringProp(props, "artist", ""), scaleHexAlpha(secondary, expandedAlpha));
patchClippedText(patches, "music:artist:expanded", numberProp(props, "artistWidthExpanded", 0.0f), Math.max(0.0f, mainWidth - 72.0f), numberProp(props, "titleScrollTime", 0.0f), 1.4f, 14.0f, 16.0f);
patchShape(patches, "music:progress:bg", "fill", scaleHexAlpha(stringProp(props, "progressBg", "#00FFFFFF"), expandedAlpha));
patchShape(patches, "music:progress:fill",
"fill", scaleHexAlpha(accent, expandedAlpha),
"renderWidth", Math.max(0.0f, mainWidth - 26.0f) * progress);
patchText(patches, "music:elapsed:expanded", stringProp(props, "elapsed", "0:00"), scaleHexAlpha(secondary, expandedAlpha));
patchText(patches, "music:total:expanded", stringProp(props, "total", "0:00"), scaleHexAlpha(secondary, expandedAlpha));
patchImage(patches, "artwork:expanded", stringProp(props, "artworkTexture", ""), scaleHexAlpha("#FFFFFFFF", expandedAlpha));
patchShape(patches, "artwork:expanded:fallback", "fill", scaleHexAlpha(accent, 0.92f * expandedAlpha), "stroke", scaleHexAlpha("#33FFFFFF", expandedAlpha));
if (boolProp(props, "showShuffle", false)) {
patchControl(patches, "music:shuffle", stringProp(props, "iconShuffle", ""), stringProp(props, "shuffleColor", primary), accentSoft, expandedAlpha, numberProp(props, "shuffleHover", 0.0f));
}
patchControl(patches, "music:prev", stringProp(props, "iconPrev", ""), primary, accentSoft, expandedAlpha, numberProp(props, "prevHover", 0.0f));
patchControl(patches, "music:play", stringProp(props, "iconPlay", ""), primary, accentSoft, expandedAlpha, numberProp(props, "playHover", 0.0f));
patchControl(patches, "music:next", stringProp(props, "iconNext", ""), primary, accentSoft, expandedAlpha, numberProp(props, "nextHover", 0.0f));
if (boolProp(props, "showRepeat", false)) {
patchSvgControl(patches, "music:repeat", stringProp(props, "repeatAsset", "repeat-off"), stringProp(props, "repeatColor", "#99000000"), accentSoft, expandedAlpha, numberProp(props, "repeatHover", 0.0f));
}
}
return patches.asMap();
}
}

View file

@ -0,0 +1,199 @@
/*
* This file is part of the LoVisual Client distribution.
* Copyright (c) 2026 loki5512344.
*
* Licensed under the GNU General Public License v3.0.
*/
package dev.loki.lovisual.features.gui.hud.nondraggable.impl.dynamicisland;
import dev.loki.lovisual.features.gui.hud.HudRenderUtil;
import dev.loki.lovisual.features.module.Modules;
import dev.loki.lovisual.features.module.modules.misc.ClickGui;
import dev.loki.lovisual.mixins.accessors.BossHealthOverlayAccessor;
import dev.loki.lovisual.render.engine.math.HudScale;
import dev.loki.lovisual.render.engine.renderer.Renderer2D;
import dev.loki.lovisual.render.engine.text.TextRenderer;
import dev.loki.lovisual.util.media.MediaSessionService;
import dev.loki.lovisual.util.media.RepeatMode;
import net.minecraft.client.Minecraft;
import net.minecraft.client.gui.components.BossHealthOverlay;
import net.minecraft.client.gui.components.LerpingBossEvent;
import net.minecraft.client.gui.screens.Screen;
import java.time.LocalTime;
import java.util.Map;
import java.util.UUID;
/** Чистые утилиты Dynamic Island: форматирование, иконки, цвета медиа, замеры, экраны, fallback-рендер. */
public final class IslandUiUtil {
public static final int BASE_SHADOW = 0xFF000000;
public static final int BASE_STROKE = 0xFFFFFFFF;
private IslandUiUtil() {
}
public static int shuffleColor(MediaSessionService.Snapshot snapshot) {
if (snapshot == null || !snapshot.supportsShuffle()) {
return 0x99000000;
}
return snapshot.isShuffleActive() ? 0xFFFFFFFF : 0x7AB7BEC8;
}
public static int repeatColor(MediaSessionService.Snapshot snapshot) {
if (snapshot == null || !snapshot.supportsRepeat()) {
return 0x99000000;
}
RepeatMode mode = snapshot.repeatMode();
return mode == RepeatMode.ALL || mode == RepeatMode.ONE ? 0xFFFFFFFF : 0x7AB7BEC8;
}
public static String repeatSvg(MediaSessionService.Snapshot snapshot) {
if (snapshot == null || !snapshot.supportsRepeat()) {
return "repeat-off";
}
return snapshot.repeatMode() == RepeatMode.ONE ? "repeat-1" : "repeat";
}
public static boolean clickGuiEnabled() {
ClickGui clickGui = Modules.get(ClickGui.class);
return clickGui != null && clickGui.isEnabled();
}
public static boolean isSettingsLikeScreen(Screen screen) {
if (screen == null) return false;
String name = screen.getClass().getName();
String simple = screen.getClass().getSimpleName();
if (name.contains(".gui.screen.option.") || name.contains(".gui.screen.pack.")) return true;
return simple.contains("Options")
|| simple.contains("Option")
|| simple.contains("Controls")
|| simple.contains("Keybind")
|| simple.contains("Video")
|| simple.contains("Sound")
|| simple.contains("Language")
|| simple.contains("Accessibility")
|| simple.contains("Skin")
|| simple.contains("Pack")
|| simple.contains("Datapack")
|| simple.contains("Customize")
|| simple.contains("Telemetry")
|| simple.contains("Credits");
}
public static String formatClock(LocalTime time) {
return String.format("%02d:%02d", time.getHour(), time.getMinute());
}
public static String formatSecondsOnly(float seconds) {
return Math.max(0L, (long) Math.ceil(Math.max(0f, seconds))) + "s";
}
public static String formatShortTime(float seconds) {
return formatShortTime((long) Math.ceil(Math.max(0f, seconds)));
}
public static String formatShortTime(long seconds) {
long sec = Math.max(0L, seconds);
long m = sec / 60L;
long s = sec % 60L;
return String.format("%d:%02d", m, s);
}
public static String iconString(int codepoint) {
return new String(Character.toChars(codepoint));
}
public static String hex(int argb) {
return String.format("#%08X", argb);
}
public static float clamp(float value, float min, float max) {
return Math.max(min, Math.min(max, value));
}
public static boolean hit(float mx, float my, float x, float y, float w, float h) {
return mx >= x && mx <= x + w && my >= y && my <= y + h;
}
public static float measureWidth(TextRenderer renderer, String text, float scale) {
if (renderer == null || text == null || text.isEmpty()) return 0f;
renderer.begin(scale, true, false);
try {
return (float) renderer.getWidth(text, false);
} finally {
renderer.end();
}
}
public static float measureHeight(TextRenderer renderer, float scale) {
if (renderer == null) return 0f;
renderer.begin(scale, true, false);
try {
return (float) renderer.getHeight(false);
} finally {
renderer.end();
}
}
public static float reservedTimeWidth(TextRenderer renderer, String text, float scale) {
return measureWidth(renderer, reserveTimeTemplate(text), scale);
}
public static String reserveTimeTemplate(String text) {
if (text == null || text.isEmpty()) return "0:00";
StringBuilder out = new StringBuilder(text.length());
for (int i = 0; i < text.length(); i++) {
char c = text.charAt(i);
out.append(Character.isDigit(c) ? '8' : c);
}
return out.toString();
}
public static void drawFallback(Renderer2D renderer,
float drawX,
float drawY,
float drawWidth,
float drawHeight,
float radius,
float alpha,
int accent,
int fillTop) {
renderer.roundedRectShadow(drawX, drawY, drawWidth, drawHeight, radius, 8f, 14f,
HudRenderUtil.scaleAlpha(BASE_SHADOW, 0.28f * alpha));
renderer.roundedRect(drawX, drawY, drawWidth, drawHeight, radius, 1.1f,
HudRenderUtil.scaleAlpha(fillTop, 0.88f * alpha));
renderer.roundedRectStroke(drawX, drawY, drawWidth, drawHeight, radius, 1.1f, 1f,
HudRenderUtil.scaleAlpha(HudRenderUtil.mixColor(BASE_STROKE, accent, 0.18f), 0.18f * alpha));
}
public static float bossBarOffset(Minecraft mc, int logicalScreenH, float topY) {
if (mc == null || mc.gui == null || mc.getWindow() == null) return 0f;
BossHealthOverlay bossBarHud = mc.gui.hud.getBossOverlay();
if (bossBarHud == null) return 0f;
Map<UUID, LerpingBossEvent> bars;
try {
bars = ((BossHealthOverlayAccessor) bossBarHud).getBars();
} catch (Throwable ignored) {
return 0f;
}
if (bars == null || bars.isEmpty()) return 0f;
int scaledH = Math.max(1, mc.getWindow().getGuiScaledHeight());
int y = 12;
int rows = 0;
for (LerpingBossEvent ignored : bars.values()) {
rows++;
y += 19;
if (y >= scaledH / 3) break;
}
if (rows <= 0) return 0f;
float bossBottomScaled = 17f + 19f * (rows - 1);
float hudScale = HudScale.scale(mc.getWindow().getWidth(), mc.getWindow().getHeight());
float windowScale = (float) mc.getWindow().getGuiScale();
float bossBottomLogical = (bossBottomScaled + 8f) * windowScale / Math.max(0.001f, hudScale);
return Math.max(0f, Math.min(logicalScreenH * 0.30f, bossBottomLogical) - topY);
}
}

View file

@ -0,0 +1,123 @@
/*
* This file is part of the LoVisual Client distribution.
* Copyright (c) 2026 loki5512344.
*
* Licensed under the GNU General Public License v3.0.
*/
package dev.loki.lovisual.features.gui.hud.nondraggable.impl.dynamicisland.controls;
import dev.loki.lovisual.features.gui.hud.nondraggable.impl.dynamicisland.IslandUiUtil;
import dev.loki.lovisual.render.engine.animation.AnimationUtility;
import dev.loki.lovisual.render.engine.math.HudScale;
import dev.loki.lovisual.render.engine.text.TextRenderer;
import dev.loki.lovisual.util.media.MediaSessionService;
import net.minecraft.client.Minecraft;
/**
* Геометрия и hover-состояния ряда медиа-контролов Dynamic Island.
*/
public final class IslandControls {
public static final float EXPANDED_PAD_X = 13f;
public static final float CONTROL_SIZE = 27f;
public static final float CONTROL_GAP = 7f;
public static final float CONTROL_ROW_GAP_Y = -5f;
public float shuffleX;
public float shuffleY;
public float prevX;
public float prevY;
public float playX;
public float playY;
public float nextX;
public float nextY;
public float repeatX;
public float repeatY;
public float controlSize;
public float shuffleHover;
public float prevHover;
public float playHover;
public float nextHover;
public float repeatHover;
public void updateLayout(float drawX, float drawY, float width, TextRenderer valueRenderer,
MediaSessionService.Snapshot snapshot) {
float progressX = drawX + EXPANDED_PAD_X;
float progressW = width - EXPANDED_PAD_X * 2f;
float progressY = drawY + 52f;
float timeH = IslandUiUtil.measureHeight(valueRenderer, 0.80f);
controlSize = CONTROL_SIZE;
float controlGap = CONTROL_GAP;
boolean showShuffle = snapshot != null && snapshot.supportsShuffle();
boolean showRepeat = snapshot != null && snapshot.supportsRepeat();
int activeControls = 3 + (showShuffle ? 1 : 0) + (showRepeat ? 1 : 0);
float totalControlsW = controlSize * activeControls + controlGap * Math.max(0, activeControls - 1);
float controlsX = drawX + (width - totalControlsW) * 0.5f;
float controlsY = progressY + 5f + timeH + CONTROL_ROW_GAP_Y;
float cursorX = controlsX;
if (showShuffle) {
shuffleX = cursorX;
shuffleY = controlsY;
cursorX += controlSize + controlGap;
} else {
shuffleX = shuffleY = 0f;
}
prevX = cursorX;
prevY = controlsY;
cursorX += controlSize + controlGap;
playX = cursorX;
playY = controlsY;
cursorX += controlSize + controlGap;
nextX = cursorX;
nextY = controlsY;
cursorX += controlSize + controlGap;
if (showRepeat) {
repeatX = cursorX;
repeatY = controlsY;
} else {
repeatX = repeatY = 0f;
}
}
public void updateHover(boolean chatOpen, Minecraft mc, MediaSessionService.Snapshot snapshot) {
if (!chatOpen || controlSize <= 0f || mc == null || mc.getWindow() == null) {
shuffleHover = AnimationUtility.approach(shuffleHover, 0f, 0.25f);
prevHover = AnimationUtility.approach(prevHover, 0f, 0.25f);
playHover = AnimationUtility.approach(playHover, 0f, 0.25f);
nextHover = AnimationUtility.approach(nextHover, 0f, 0.25f);
repeatHover = AnimationUtility.approach(repeatHover, 0f, 0.25f);
return;
}
int fbw = mc.getWindow().getWidth();
int fbh = mc.getWindow().getHeight();
float uiScale = HudScale.scale(fbw, fbh);
float mx = HudScale.toVirtual((float) mc.mouseHandler.xpos(), uiScale);
float my = HudScale.toVirtual((float) mc.mouseHandler.ypos(), uiScale);
boolean showShuffle = snapshot != null && snapshot.supportsShuffle();
boolean showRepeat = snapshot != null && snapshot.supportsRepeat();
shuffleHover = AnimationUtility.approach(shuffleHover, showShuffle && IslandUiUtil.hit(mx, my, shuffleX, shuffleY, controlSize, controlSize) ? 1f : 0f, 0.25f);
prevHover = AnimationUtility.approach(prevHover, IslandUiUtil.hit(mx, my, prevX, prevY, controlSize, controlSize) ? 1f : 0f, 0.25f);
playHover = AnimationUtility.approach(playHover, IslandUiUtil.hit(mx, my, playX, playY, controlSize, controlSize) ? 1f : 0f, 0.25f);
nextHover = AnimationUtility.approach(nextHover, IslandUiUtil.hit(mx, my, nextX, nextY, controlSize, controlSize) ? 1f : 0f, 0.25f);
repeatHover = AnimationUtility.approach(repeatHover, showRepeat && IslandUiUtil.hit(mx, my, repeatX, repeatY, controlSize, controlSize) ? 1f : 0f, 0.25f);
}
public void reset() {
shuffleX = shuffleY = 0f;
prevX = prevY = 0f;
playX = playY = 0f;
nextX = nextY = 0f;
repeatX = repeatY = 0f;
controlSize = 0f;
shuffleHover = 0f;
prevHover = 0f;
playHover = 0f;
nextHover = 0f;
repeatHover = 0f;
}
}

View file

@ -20,22 +20,21 @@ import dev.loki.lovisual.config.common.CommonBooleanGroupSchema;
import dev.loki.lovisual.config.common.CommonSettingSchema; import dev.loki.lovisual.config.common.CommonSettingSchema;
import dev.loki.lovisual.config.values.*; import dev.loki.lovisual.config.values.*;
import dev.loki.lovisual.features.gui.clickgui.settings.*; import dev.loki.lovisual.features.gui.clickgui.settings.*;
import dev.loki.lovisual.features.module.module.ModuleBinds;
import dev.loki.lovisual.features.module.module.ModuleConfigValues;
import dev.loki.lovisual.features.module.module.ModuleNames;
import dev.loki.lovisual.features.module.module.ModuleSettingValues;
import dev.loki.lovisual.render.engine.renderer.Renderer2D; import dev.loki.lovisual.render.engine.renderer.Renderer2D;
import dev.loki.lovisual.render.engine.renderer.Renderer3D; import dev.loki.lovisual.render.engine.renderer.Renderer3D;
import dev.loki.lovisual.render.engine.text.FontInfo; import dev.loki.lovisual.render.engine.text.FontInfo;
import dev.loki.lovisual.render.engine.text.TextRenderer; import dev.loki.lovisual.render.engine.text.TextRenderer;
import dev.loki.lovisual.Lifecycle; import dev.loki.lovisual.Lifecycle;
import dev.loki.lovisual.util.input.KeyManager;
import dev.loki.lovisual.util.logging.DebugLog; import dev.loki.lovisual.util.logging.DebugLog;
import net.minecraft.client.resources.language.I18n;
import java.util.*; import java.util.*;
import java.util.function.Supplier; import java.util.function.Supplier;
public abstract class Module implements ConfigObject, ConfigNameProvider, SettingOwner, ConfigValueAliasProvider { public abstract class Module implements ConfigObject, ConfigNameProvider, SettingOwner, ConfigValueAliasProvider {
private static final String SETTING_SHOW_IN_MODULE_LIST = "show_in_module_list";
private static final String LEGACY_SETTING_SHOW_IN_ACTIVE_MODULES = "show_in_active_modules";
protected final List<Setting> settings = new ArrayList<>(); protected final List<Setting> settings = new ArrayList<>();
private final String name; private final String name;
private final String displayName; private final String displayName;
@ -45,7 +44,7 @@ public abstract class Module implements ConfigObject, ConfigNameProvider, Settin
private final BooleanValue enabledValue; private final BooleanValue enabledValue;
private final EnumValue<ModuleActivationSource> activationSourceValue = private final EnumValue<ModuleActivationSource> activationSourceValue =
new EnumValue<>("activation_source", ModuleActivationSource.NONE, ModuleActivationSource.class); new EnumValue<>("activation_source", ModuleActivationSource.NONE, ModuleActivationSource.class);
private final BooleanValue moduleListVisibleValue = new BooleanValue(SETTING_SHOW_IN_MODULE_LIST, true); private final BooleanValue moduleListVisibleValue = new BooleanValue(ModuleConfigValues.SETTING_SHOW_IN_MODULE_LIST, true);
private final List<SettingDef> declaredSettingDefs = new ArrayList<>(); private final List<SettingDef> declaredSettingDefs = new ArrayList<>();
private final Map<ConfigValue<?>, SettingDef> declaredSettingDefsByValue = new IdentityHashMap<>(); private final Map<ConfigValue<?>, SettingDef> declaredSettingDefsByValue = new IdentityHashMap<>();
private final KeyBindSetting keyBindSetting; private final KeyBindSetting keyBindSetting;
@ -61,7 +60,7 @@ public abstract class Module implements ConfigObject, ConfigNameProvider, Settin
this.name = info.id().toLowerCase(Locale.ROOT); this.name = info.id().toLowerCase(Locale.ROOT);
this.displayName = info.displayName(); this.displayName = info.displayName();
this.aliases = normalizeAliases(info.aliases()); this.aliases = ModuleNames.normalizeAliases(info.aliases());
this.description = info.description(); this.description = info.description();
this.category = info.category(); this.category = info.category();
this.enabledValue = new BooleanValue("enabled", info.enabledByDefault()); this.enabledValue = new BooleanValue("enabled", info.enabledByDefault());
@ -89,75 +88,7 @@ public abstract class Module implements ConfigObject, ConfigNameProvider, Settin
} }
private static KeyBindSetting createKeyBindSetting() { private static KeyBindSetting createKeyBindSetting() {
return new KeyBindSetting(new KeyBindValue("bind", "NONE")); return ModuleBinds.createKeyBindSetting();
}
private static List<String> normalizeAliases(String[] aliases) {
if (aliases == null || aliases.length == 0) return List.of();
List<String> out = new ArrayList<>(aliases.length);
for (String alias : aliases) {
if (alias == null || alias.isBlank()) continue;
out.add(alias.trim());
}
return List.copyOf(out);
}
private static String stripOwnerPrefix(String value, String owner) {
if (value == null || value.isBlank() || owner == null || owner.isBlank()) return value;
String normalizedOwner = normalizePrefix(owner);
String lowerValue = value.toLowerCase(java.util.Locale.ROOT);
if (!lowerValue.startsWith(normalizedOwner)) return value;
String tail = value.substring(normalizedOwner.length());
if (tail.isEmpty()) return value;
char first = tail.charAt(0);
if (first == '_' || first == '-' || first == '.') {
tail = tail.substring(1);
} else if (!Character.isUpperCase(first)) {
return value;
}
return camelToSnake(tail);
}
private static String normalizePrefix(String value) {
StringBuilder out = new StringBuilder(value.length());
for (int i = 0; i < value.length(); i++) {
char c = Character.toLowerCase(value.charAt(i));
if ((c >= 'a' && c <= 'z') || (c >= '0' && c <= '9')) {
out.append(c);
}
}
return out.toString();
}
private static String camelToSnake(String value) {
if (value == null || value.isEmpty()) return value;
StringBuilder out = new StringBuilder(value.length() + 4);
char previous = 0;
for (int i = 0; i < value.length(); i++) {
char c = value.charAt(i);
if (c == '-' || c == '.' || c == ' ') {
c = '_';
}
if (c == '_') {
if (!out.isEmpty() && out.charAt(out.length() - 1) != '_') {
out.append('_');
}
previous = '_';
continue;
}
if (Character.isUpperCase(c) && i > 0 && previous != '_' && !Character.isUpperCase(previous)) {
out.append('_');
}
out.append(Character.toLowerCase(c));
previous = c;
}
return out.toString();
} }
private static ModuleActivationSource normalizeActivationSource(boolean state, ModuleActivationSource source) { private static ModuleActivationSource normalizeActivationSource(boolean state, ModuleActivationSource source) {
@ -192,9 +123,7 @@ public abstract class Module implements ConfigObject, ConfigNameProvider, Settin
} }
protected final BooleanValue bool(String configName, String settingId, boolean def) { protected final BooleanValue bool(String configName, String settingId, boolean def) {
BooleanValue value = new BooleanValue(configName, def); return ModuleSettingValues.bool(configName, settingId, def, this::declare);
declare(value, SettingDef.bool(settingId, value));
return value;
} }
protected final NumberValue<Integer> num(String configName, int def, int min, int max) { protected final NumberValue<Integer> num(String configName, int def, int min, int max) {
@ -202,9 +131,7 @@ public abstract class Module implements ConfigObject, ConfigNameProvider, Settin
} }
protected final NumberValue<Integer> num(String configName, String settingId, int def, int min, int max) { protected final NumberValue<Integer> num(String configName, String settingId, int def, int min, int max) {
NumberValue<Integer> value = new NumberValue<>(configName, def, min, max); return ModuleSettingValues.num(configName, settingId, def, min, max, this::declare);
declare(value, SettingDef.number(settingId, value));
return value;
} }
protected final NumberValue<Long> num(String configName, long def, long min, long max) { protected final NumberValue<Long> num(String configName, long def, long min, long max) {
@ -212,9 +139,7 @@ public abstract class Module implements ConfigObject, ConfigNameProvider, Settin
} }
protected final NumberValue<Long> num(String configName, String settingId, long def, long min, long max) { protected final NumberValue<Long> num(String configName, String settingId, long def, long min, long max) {
NumberValue<Long> value = new NumberValue<>(configName, def, min, max); return ModuleSettingValues.num(configName, settingId, def, min, max, this::declare);
declare(value, SettingDef.number(settingId, value));
return value;
} }
protected final NumberValue<Float> num(String configName, float def, float min, float max) { protected final NumberValue<Float> num(String configName, float def, float min, float max) {
@ -222,9 +147,7 @@ public abstract class Module implements ConfigObject, ConfigNameProvider, Settin
} }
protected final NumberValue<Float> num(String configName, String settingId, float def, float min, float max) { protected final NumberValue<Float> num(String configName, String settingId, float def, float min, float max) {
NumberValue<Float> value = new NumberValue<>(configName, def, min, max); return ModuleSettingValues.num(configName, settingId, def, min, max, this::declare);
declare(value, SettingDef.number(settingId, value));
return value;
} }
protected final NumberValue<Double> num(String configName, double def, double min, double max) { protected final NumberValue<Double> num(String configName, double def, double min, double max) {
@ -232,9 +155,7 @@ public abstract class Module implements ConfigObject, ConfigNameProvider, Settin
} }
protected final NumberValue<Double> num(String configName, String settingId, double def, double min, double max) { protected final NumberValue<Double> num(String configName, String settingId, double def, double min, double max) {
NumberValue<Double> value = new NumberValue<>(configName, def, min, max); return ModuleSettingValues.num(configName, settingId, def, min, max, this::declare);
declare(value, SettingDef.number(settingId, value));
return value;
} }
protected final ModeValue mode(String configName, String def, String... options) { protected final ModeValue mode(String configName, String def, String... options) {
@ -242,9 +163,7 @@ public abstract class Module implements ConfigObject, ConfigNameProvider, Settin
} }
protected final ModeValue modeSetting(String configName, String settingId, String def, String... options) { protected final ModeValue modeSetting(String configName, String settingId, String def, String... options) {
ModeValue value = new ModeValue(configName, def, options); return ModuleSettingValues.modeSetting(configName, settingId, def, this::declare, options);
declare(value, SettingDef.mode(settingId, value));
return value;
} }
protected final <E extends Enum<E>> EnumValue<E> enumMode(String configName, E def) { protected final <E extends Enum<E>> EnumValue<E> enumMode(String configName, E def) {
@ -252,10 +171,7 @@ public abstract class Module implements ConfigObject, ConfigNameProvider, Settin
} }
protected final <E extends Enum<E>> EnumValue<E> enumSetting(String configName, String settingId, E def) { protected final <E extends Enum<E>> EnumValue<E> enumSetting(String configName, String settingId, E def) {
Class<E> enumClass = def.getDeclaringClass(); return ModuleSettingValues.enumSetting(configName, settingId, def, this::declare);
EnumValue<E> value = new EnumValue<>(configName, def, enumClass);
declare(value, SettingDef.mode(settingId, value));
return value;
} }
@Deprecated(forRemoval = false) @Deprecated(forRemoval = false)
@ -265,9 +181,7 @@ public abstract class Module implements ConfigObject, ConfigNameProvider, Settin
@Deprecated(forRemoval = false) @Deprecated(forRemoval = false)
protected final <E extends Enum<E>> EnumValue<E> enumSetting(String configName, String settingId, E def, Class<E> enumClass) { protected final <E extends Enum<E>> EnumValue<E> enumSetting(String configName, String settingId, E def, Class<E> enumClass) {
EnumValue<E> value = new EnumValue<>(configName, def, enumClass); return ModuleSettingValues.enumSetting(configName, settingId, def, enumClass, this::declare);
declare(value, SettingDef.mode(settingId, value));
return value;
} }
@SafeVarargs @SafeVarargs
@ -277,9 +191,7 @@ public abstract class Module implements ConfigObject, ConfigNameProvider, Settin
@SafeVarargs @SafeVarargs
protected final <E extends Enum<E>> EnumValue<E> enumSetting(String configName, String settingId, E def, E... options) { protected final <E extends Enum<E>> EnumValue<E> enumSetting(String configName, String settingId, E def, E... options) {
EnumValue<E> value = new EnumValue<>(configName, def, options); return ModuleSettingValues.enumSetting(configName, settingId, def, this::declare, options);
declare(value, SettingDef.mode(settingId, value));
return value;
} }
protected final RGBAColorValue color(String configName, String defHex) { protected final RGBAColorValue color(String configName, String defHex) {
@ -287,9 +199,7 @@ public abstract class Module implements ConfigObject, ConfigNameProvider, Settin
} }
protected final RGBAColorValue color(String configName, String settingId, String defHex) { protected final RGBAColorValue color(String configName, String settingId, String defHex) {
RGBAColorValue value = new RGBAColorValue(configName, defHex); return ModuleSettingValues.color(configName, settingId, defHex, this::declare);
declare(value, SettingDef.color(settingId, value));
return value;
} }
protected final RGBColorValue colorNoAlpha(String configName, String defHex) { protected final RGBColorValue colorNoAlpha(String configName, String defHex) {
@ -297,9 +207,7 @@ public abstract class Module implements ConfigObject, ConfigNameProvider, Settin
} }
protected final RGBColorValue colorNoAlpha(String configName, String settingId, String defHex) { protected final RGBColorValue colorNoAlpha(String configName, String settingId, String defHex) {
RGBColorValue value = new RGBColorValue(configName, defHex); return ModuleSettingValues.colorNoAlpha(configName, settingId, defHex, this::declare);
declare(value, SettingDef.colorNoAlpha(settingId, value));
return value;
} }
protected final StringValue text(String configName, String def) { protected final StringValue text(String configName, String def) {
@ -307,9 +215,7 @@ public abstract class Module implements ConfigObject, ConfigNameProvider, Settin
} }
protected final StringValue text(String configName, String settingId, String def) { protected final StringValue text(String configName, String settingId, String def) {
StringValue value = new StringValue(configName, def); return ModuleSettingValues.text(configName, settingId, def, this::declare);
declare(value, SettingDef.text(settingId, value));
return value;
} }
protected final SetValue textList(String configName) { protected final SetValue textList(String configName) {
@ -321,15 +227,11 @@ public abstract class Module implements ConfigObject, ConfigNameProvider, Settin
} }
protected final SetValue textList(String configName, String settingId, TextListSetting.PickerMode pickerMode) { protected final SetValue textList(String configName, String settingId, TextListSetting.PickerMode pickerMode) {
SetValue value = new SetValue(configName); return ModuleSettingValues.textList(configName, settingId, pickerMode, this::declare);
declare(value, SettingDef.textList(settingId, value, pickerMode));
return value;
} }
protected final SetValue textList(String configName, String settingId, TextListSetting.PickerMode pickerMode, Set<String> defaults) { protected final SetValue textList(String configName, String settingId, TextListSetting.PickerMode pickerMode, Set<String> defaults) {
SetValue value = new SetValue(configName, defaults); return ModuleSettingValues.textList(configName, settingId, pickerMode, defaults, this::declare);
declare(value, SettingDef.textList(settingId, value, pickerMode));
return value;
} }
protected final ItemIdSetValue itemList(String configName) { protected final ItemIdSetValue itemList(String configName) {
@ -341,9 +243,7 @@ public abstract class Module implements ConfigObject, ConfigNameProvider, Settin
} }
protected final ItemIdSetValue itemList(String configName, String settingId, TextListSetting.PickerMode pickerMode) { protected final ItemIdSetValue itemList(String configName, String settingId, TextListSetting.PickerMode pickerMode) {
ItemIdSetValue value = new ItemIdSetValue(configName); return ModuleSettingValues.itemList(configName, settingId, pickerMode, this::declare);
declare(value, SettingDef.textList(settingId, value, pickerMode));
return value;
} }
protected final BooleanMapValue group(String configName, Map<String, Boolean> defaults) { protected final BooleanMapValue group(String configName, Map<String, Boolean> defaults) {
@ -351,9 +251,7 @@ public abstract class Module implements ConfigObject, ConfigNameProvider, Settin
} }
protected final BooleanMapValue group(String configName, String settingId, Map<String, Boolean> defaults) { protected final BooleanMapValue group(String configName, String settingId, Map<String, Boolean> defaults) {
BooleanMapValue value = new BooleanMapValue(configName, defaults); return ModuleSettingValues.group(configName, settingId, defaults, this::declare);
declare(value, SettingDef.group(settingId, value));
return value;
} }
protected final KeyBindValue bind(String configName, String def, BindMode mode) { protected final KeyBindValue bind(String configName, String def, BindMode mode) {
@ -361,9 +259,7 @@ public abstract class Module implements ConfigObject, ConfigNameProvider, Settin
} }
protected final KeyBindValue bind(String configName, String settingId, String def, BindMode mode) { protected final KeyBindValue bind(String configName, String settingId, String def, BindMode mode) {
KeyBindValue value = new KeyBindValue(configName, def); return ModuleSettingValues.bind(configName, settingId, def, mode, this::declare);
declare(value, SettingDef.bind(settingId, value, mode));
return value;
} }
protected final BooleanMapValue groupCommon(String configName, protected final BooleanMapValue groupCommon(String configName,
@ -520,12 +416,7 @@ public abstract class Module implements ConfigObject, ConfigNameProvider, Settin
protected final BooleanMapValue protocolHeuristics(String configName, protected final BooleanMapValue protocolHeuristics(String configName,
String settingId, String settingId,
CommonBooleanGroupSchema schema) { CommonBooleanGroupSchema schema) {
Map<String, Boolean> defaults = schema == null ? Map.of() : schema.defaults(); return ModuleSettingValues.protocolHeuristics(configName, settingId, schema, this::declare);
BooleanMapValue value = new BooleanMapValue(configName, defaults);
SettingDef def = SettingDef.protocolHeuristics(settingId, value);
if (schema != null) def.common(schema);
declare(value, def);
return value;
} }
protected final <E extends Enum<E>> EnumValue<E> enumCommon(String configName, protected final <E extends Enum<E>> EnumValue<E> enumCommon(String configName,
@ -744,17 +635,13 @@ public abstract class Module implements ConfigObject, ConfigNameProvider, Settin
} }
private String settingIdFromConfigName(String configName) { private String settingIdFromConfigName(String configName) {
if (configName == null || configName.isBlank()) return configName; return ModuleNames.settingId(configName, name(), getClass().getSimpleName());
String tail = stripOwnerPrefix(configName, name());
tail = stripOwnerPrefix(tail, getClass().getSimpleName());
return tail;
} }
@Override @Override
public List<String> getLegacyValueNames(String currentValueName) { public List<String> getLegacyValueNames(String currentValueName) {
if (SETTING_SHOW_IN_MODULE_LIST.equals(currentValueName)) { if (ModuleConfigValues.SETTING_SHOW_IN_MODULE_LIST.equals(currentValueName)) {
return List.of(LEGACY_SETTING_SHOW_IN_ACTIVE_MODULES); return List.of(ModuleConfigValues.LEGACY_SETTING_SHOW_IN_ACTIVE_MODULES);
} }
return List.of(); return List.of();
} }
@ -786,10 +673,7 @@ public abstract class Module implements ConfigObject, ConfigNameProvider, Settin
} }
protected final FunctionBindSetting action(String name, String defaultKey, BindMode mode) { protected final FunctionBindSetting action(String name, String defaultKey, BindMode mode) {
FunctionBindSetting setting = new FunctionBindSetting(name, defaultKey, mode); return ModuleBinds.registerAction(actions, settings, name, defaultKey, mode);
actions.put(name, setting);
settings.add(setting);
return setting;
} }
protected final FunctionBindSetting action(String name, String defaultKey) { protected final FunctionBindSetting action(String name, String defaultKey) {
@ -805,22 +689,15 @@ public abstract class Module implements ConfigObject, ConfigNameProvider, Settin
} }
protected final boolean isActionPressedOnce(String name) { protected final boolean isActionPressedOnce(String name) {
if (!isEnabled()) return false; return ModuleBinds.isPressedOnce(isEnabled(), actions.get(name));
FunctionBindSetting setting = actions.get(name);
if (setting == null) return false;
return KeyManager.wasPressed(setting.getBindingName());
} }
protected final boolean isActionHeld(String name) { protected final boolean isActionHeld(String name) {
if (!isEnabled()) return false; return ModuleBinds.isHeld(isEnabled(), actions.get(name));
FunctionBindSetting setting = actions.get(name);
if (setting == null) return false;
return KeyManager.isHeld(setting.getBindingName());
} }
protected final String getActionKey(String name) { protected final String getActionKey(String name) {
FunctionBindSetting setting = actions.get(name); return ModuleBinds.actionKey(actions.get(name));
return setting == null ? "NONE" : setting.get();
} }
public boolean isShownInModuleList() { public boolean isShownInModuleList() {
@ -830,10 +707,7 @@ public abstract class Module implements ConfigObject, ConfigNameProvider, Settin
public void setShownInModuleList(boolean value) { public void setShownInModuleList(boolean value) {
if (moduleListVisibleValue.get() == value) return; if (moduleListVisibleValue.get() == value) return;
moduleListVisibleValue.set(value); moduleListVisibleValue.set(value);
try { ModuleConfigValues.requestSaveQuietly(this);
ConfigSerializer.requestSave(this);
} catch (Throwable ignored) {
}
} }
public void toggleShownInModuleList() { public void toggleShownInModuleList() {
@ -872,10 +746,7 @@ public abstract class Module implements ConfigObject, ConfigNameProvider, Settin
public final void setActivationSource(ModuleActivationSource source) { public final void setActivationSource(ModuleActivationSource source) {
activationSource = normalizeActivationSource(enabled, source); activationSource = normalizeActivationSource(enabled, source);
activationSourceValue.set(activationSource); activationSourceValue.set(activationSource);
try { ModuleConfigValues.requestSaveQuietly(this);
ConfigSerializer.requestSave(this);
} catch (Throwable ignored) {
}
} }
public boolean isEnabledFromKeybind() { public boolean isEnabledFromKeybind() {
@ -896,7 +767,7 @@ public abstract class Module implements ConfigObject, ConfigNameProvider, Settin
} }
public String getDisplayName() { public String getDisplayName() {
return translateFirst(displayName, "module." + name(), "module." + name() + ".name"); return ModuleNames.translateFirst(displayName, "module." + name(), "module." + name() + ".name");
} }
public List<String> getAliases() { public List<String> getAliases() {
@ -904,24 +775,13 @@ public abstract class Module implements ConfigObject, ConfigNameProvider, Settin
} }
public String getDescription() { public String getDescription() {
return translateFirst(description, "module." + name() + ".description"); return ModuleNames.translateFirst(description, "module." + name() + ".description");
} }
public ModuleCategory getCategory() { public ModuleCategory getCategory() {
return category; return category;
} }
private static String translateFirst(String fallback, String... keys) {
for (String key : keys) {
if (key == null || key.isBlank()) continue;
String translated = I18n.get(key);
if (!translated.equals(key)) {
return translated;
}
}
return fallback == null ? "" : fallback;
}
public final void toggle() { public final void toggle() {
setEnabled(!enabled, ModuleActivationSource.MANUAL); setEnabled(!enabled, ModuleActivationSource.MANUAL);
} }
@ -944,10 +804,7 @@ public abstract class Module implements ConfigObject, ConfigNameProvider, Settin
activationSource = normalizedSource; activationSource = normalizedSource;
enabledValue.set(enabled); enabledValue.set(enabled);
activationSourceValue.set(activationSource); activationSourceValue.set(activationSource);
try { ModuleConfigValues.requestSaveQuietly(this);
ConfigSerializer.requestSave(this);
} catch (Throwable ignored) {
}
return; return;
} }
@ -975,10 +832,7 @@ public abstract class Module implements ConfigObject, ConfigNameProvider, Settin
// Persist change // Persist change
enabledValue.set(enabled); enabledValue.set(enabled);
activationSourceValue.set(activationSource); activationSourceValue.set(activationSource);
try { ModuleConfigValues.requestSaveQuietly(this);
ConfigSerializer.requestSave(this);
} catch (Throwable ignored) {
}
} }
public final void setEnabledFromConfig(boolean state) { public final void setEnabledFromConfig(boolean state) {
@ -1084,64 +938,21 @@ public abstract class Module implements ConfigObject, ConfigNameProvider, Settin
@Override @Override
public List<ConfigValue<?>> getConfigValues() { public List<ConfigValue<?>> getConfigValues() {
Map<String, ConfigValue<?>> map = new LinkedHashMap<>(); return ModuleConfigValues.orderedValues(enabledValue, activationSourceValue, moduleListVisibleValue, settings);
map.put(enabledValue.getName(), enabledValue);
map.put(activationSourceValue.getName(), activationSourceValue);
map.put(moduleListVisibleValue.getName(), moduleListVisibleValue);
for (Setting s : settings) {
ConfigValue<?> v = s.getConfigValue();
if (v != null) {
map.put(v.getName(), v);
}
}
return new ArrayList<>(map.values());
} }
/** /**
* Legacy support: apply values from the old "settings" map saved by previous versions. * Legacy support: apply values from the old "settings" map saved by previous versions.
*/ */
public void applyLegacySettings(Object json) { public void applyLegacySettings(Object json) {
if (json == null) return; ModuleConfigValues.applyLegacy(json, moduleListVisibleValue, settings);
if (json instanceof de.marhali.json5.Json5Object obj) {
for (var e : obj.asMap().entrySet()) {
applySetting(e.getKey(), e.getValue());
}
} else if (json instanceof Map<?, ?> map) {
for (var e : map.entrySet()) {
if (e.getKey() instanceof String k) {
applySetting(k, e.getValue());
}
}
}
}
private void applySetting(String id, Object data) {
if (SETTING_SHOW_IN_MODULE_LIST.equals(id) || LEGACY_SETTING_SHOW_IN_ACTIVE_MODULES.equals(id)) {
moduleListVisibleValue.fromJson(data);
return;
}
for (Setting s : settings) {
if (s.getId().equals(id)) {
s.load(data);
break;
}
}
} }
/** /**
* Re-applies values to settings to trigger any side effects in Setting.load(). * Re-applies values to settings to trigger any side effects in Setting.load().
*/ */
public void syncSettingsAfterLoad() { public void syncSettingsAfterLoad() {
for (Setting s : settings) { ModuleConfigValues.syncAfterLoad(settings);
ConfigValue<?> v = s.getConfigValue();
if (v != null) {
s.load(v.toJson());
}
}
} }
public void applyConfiguredEnabledState() { public void applyConfiguredEnabledState() {
@ -1173,9 +984,6 @@ public abstract class Module implements ConfigObject, ConfigNameProvider, Settin
} }
public void saveConfig() { public void saveConfig() {
try { ModuleConfigValues.requestSaveQuietly(this);
ConfigSerializer.requestSave(this);
} catch (Throwable ignored) {
}
} }
} }

View file

@ -0,0 +1,57 @@
/*
* This file is part of the LoVisual Client distribution.
* Copyright (c) 2026 loki5512344.
*
* Licensed under the GNU General Public License v3.0.
*/
package dev.loki.lovisual.features.module.module;
import dev.loki.lovisual.config.values.BindMode;
import dev.loki.lovisual.config.values.KeyBindValue;
import dev.loki.lovisual.features.gui.clickgui.settings.FunctionBindSetting;
import dev.loki.lovisual.features.gui.clickgui.settings.KeyBindSetting;
import dev.loki.lovisual.features.gui.clickgui.settings.Setting;
import dev.loki.lovisual.util.input.KeyManager;
import java.util.List;
import java.util.Map;
/**
* Keybind and action helpers for modules: the shared key bind setting creation, action
* registration into the module's settings and action query helpers.
*/
public final class ModuleBinds {
private ModuleBinds() {
}
public static KeyBindSetting createKeyBindSetting() {
return new KeyBindSetting(new KeyBindValue("bind", "NONE"));
}
public static FunctionBindSetting registerAction(Map<String, FunctionBindSetting> actions,
List<Setting> settings,
String name,
String defaultKey,
BindMode mode) {
FunctionBindSetting setting = new FunctionBindSetting(name, defaultKey, mode);
actions.put(name, setting);
settings.add(setting);
return setting;
}
public static boolean isPressedOnce(boolean enabled, FunctionBindSetting setting) {
if (!enabled || setting == null) return false;
return KeyManager.wasPressed(setting.getBindingName());
}
public static boolean isHeld(boolean enabled, FunctionBindSetting setting) {
if (!enabled || setting == null) return false;
return KeyManager.isHeld(setting.getBindingName());
}
public static String actionKey(FunctionBindSetting setting) {
return setting == null ? "NONE" : setting.get();
}
}

View file

@ -0,0 +1,106 @@
/*
* This file is part of the LoVisual Client distribution.
* Copyright (c) 2026 loki5512344.
*
* Licensed under the GNU General Public License v3.0.
*/
package dev.loki.lovisual.features.module.module;
import de.marhali.json5.Json5Object;
import dev.loki.lovisual.config.ConfigObject;
import dev.loki.lovisual.config.ConfigSerializer;
import dev.loki.lovisual.config.values.BooleanValue;
import dev.loki.lovisual.config.values.ConfigValue;
import dev.loki.lovisual.config.values.EnumValue;
import dev.loki.lovisual.features.gui.clickgui.settings.Setting;
import dev.loki.lovisual.features.module.ModuleActivationSource;
import java.util.ArrayList;
import java.util.LinkedHashMap;
import java.util.List;
import java.util.Map;
/**
* Config plumbing for modules: the ordered config value collection, legacy settings
* application, post-load value sync and the quiet save request used across Module.
*/
public final class ModuleConfigValues {
public static final String SETTING_SHOW_IN_MODULE_LIST = "show_in_module_list";
public static final String LEGACY_SETTING_SHOW_IN_ACTIVE_MODULES = "show_in_active_modules";
private ModuleConfigValues() {
}
public static List<ConfigValue<?>> orderedValues(BooleanValue enabledValue,
EnumValue<ModuleActivationSource> activationSourceValue,
BooleanValue moduleListVisibleValue,
List<Setting> settings) {
Map<String, ConfigValue<?>> map = new LinkedHashMap<>();
map.put(enabledValue.getName(), enabledValue);
map.put(activationSourceValue.getName(), activationSourceValue);
map.put(moduleListVisibleValue.getName(), moduleListVisibleValue);
for (Setting s : settings) {
ConfigValue<?> v = s.getConfigValue();
if (v != null) {
map.put(v.getName(), v);
}
}
return new ArrayList<>(map.values());
}
/**
* Legacy support: apply values from the old "settings" map saved by previous versions.
*/
public static void applyLegacy(Object json, BooleanValue moduleListVisibleValue, List<Setting> settings) {
if (json == null) return;
if (json instanceof Json5Object obj) {
for (var e : obj.asMap().entrySet()) {
applySetting(e.getKey(), e.getValue(), moduleListVisibleValue, settings);
}
} else if (json instanceof Map<?, ?> map) {
for (var e : map.entrySet()) {
if (e.getKey() instanceof String k) {
applySetting(k, e.getValue(), moduleListVisibleValue, settings);
}
}
}
}
private static void applySetting(String id, Object data, BooleanValue moduleListVisibleValue, List<Setting> settings) {
if (SETTING_SHOW_IN_MODULE_LIST.equals(id) || LEGACY_SETTING_SHOW_IN_ACTIVE_MODULES.equals(id)) {
moduleListVisibleValue.fromJson(data);
return;
}
for (Setting s : settings) {
if (s.getId().equals(id)) {
s.load(data);
break;
}
}
}
/**
* Re-applies values to settings to trigger any side effects in Setting.load().
*/
public static void syncAfterLoad(List<Setting> settings) {
for (Setting s : settings) {
ConfigValue<?> v = s.getConfigValue();
if (v != null) {
s.load(v.toJson());
}
}
}
public static void requestSaveQuietly(ConfigObject module) {
try {
ConfigSerializer.requestSave(module);
} catch (Throwable ignored) {
}
}
}

View file

@ -0,0 +1,111 @@
/*
* This file is part of the LoVisual Client distribution.
* Copyright (c) 2026 loki5512344.
*
* Licensed under the GNU General Public License v3.0.
*/
package dev.loki.lovisual.features.module.module;
import net.minecraft.client.resources.language.I18n;
import java.util.ArrayList;
import java.util.List;
import java.util.Locale;
/**
* Name and identity helpers for modules: alias normalization, setting id derivation
* from config names and the i18n-first display/description translation lookup.
*/
public final class ModuleNames {
private ModuleNames() {
}
public static List<String> normalizeAliases(String[] aliases) {
if (aliases == null || aliases.length == 0) return List.of();
List<String> out = new ArrayList<>(aliases.length);
for (String alias : aliases) {
if (alias == null || alias.isBlank()) continue;
out.add(alias.trim());
}
return List.copyOf(out);
}
public static String stripOwnerPrefix(String value, String owner) {
if (value == null || value.isBlank() || owner == null || owner.isBlank()) return value;
String normalizedOwner = normalizePrefix(owner);
String lowerValue = value.toLowerCase(Locale.ROOT);
if (!lowerValue.startsWith(normalizedOwner)) return value;
String tail = value.substring(normalizedOwner.length());
if (tail.isEmpty()) return value;
char first = tail.charAt(0);
if (first == '_' || first == '-' || first == '.') {
tail = tail.substring(1);
} else if (!Character.isUpperCase(first)) {
return value;
}
return camelToSnake(tail);
}
private static String normalizePrefix(String value) {
StringBuilder out = new StringBuilder(value.length());
for (int i = 0; i < value.length(); i++) {
char c = Character.toLowerCase(value.charAt(i));
if ((c >= 'a' && c <= 'z') || (c >= '0' && c <= '9')) {
out.append(c);
}
}
return out.toString();
}
private static String camelToSnake(String value) {
if (value == null || value.isEmpty()) return value;
StringBuilder out = new StringBuilder(value.length() + 4);
char previous = 0;
for (int i = 0; i < value.length(); i++) {
char c = value.charAt(i);
if (c == '-' || c == '.' || c == ' ') {
c = '_';
}
if (c == '_') {
if (!out.isEmpty() && out.charAt(out.length() - 1) != '_') {
out.append('_');
}
previous = '_';
continue;
}
if (Character.isUpperCase(c) && i > 0 && previous != '_' && !Character.isUpperCase(previous)) {
out.append('_');
}
out.append(Character.toLowerCase(c));
previous = c;
}
return out.toString();
}
public static String settingId(String configName, String ownerId, String ownerSimpleName) {
if (configName == null || configName.isBlank()) return configName;
String tail = stripOwnerPrefix(configName, ownerId);
tail = stripOwnerPrefix(tail, ownerSimpleName);
return tail;
}
public static String translateFirst(String fallback, String... keys) {
for (String key : keys) {
if (key == null || key.isBlank()) continue;
String translated = I18n.get(key);
if (!translated.equals(key)) {
return translated;
}
}
return fallback == null ? "" : fallback;
}
}

View file

@ -0,0 +1,156 @@
/*
* This file is part of the LoVisual Client distribution.
* Copyright (c) 2026 loki5512344.
*
* Licensed under the GNU General Public License v3.0.
*/
package dev.loki.lovisual.features.module.module;
import dev.loki.lovisual.config.SettingDef;
import dev.loki.lovisual.config.common.CommonBooleanGroupSchema;
import dev.loki.lovisual.config.values.BindMode;
import dev.loki.lovisual.config.values.BooleanMapValue;
import dev.loki.lovisual.config.values.BooleanValue;
import dev.loki.lovisual.config.values.ConfigValue;
import dev.loki.lovisual.config.values.EnumValue;
import dev.loki.lovisual.config.values.ItemIdSetValue;
import dev.loki.lovisual.config.values.KeyBindValue;
import dev.loki.lovisual.config.values.ModeValue;
import dev.loki.lovisual.config.values.NumberValue;
import dev.loki.lovisual.config.values.RGBAColorValue;
import dev.loki.lovisual.config.values.RGBColorValue;
import dev.loki.lovisual.config.values.SetValue;
import dev.loki.lovisual.config.values.StringValue;
import dev.loki.lovisual.features.gui.clickgui.settings.TextListSetting;
import java.util.Map;
import java.util.Set;
import java.util.function.BiConsumer;
/**
* Factory helpers for module setting values. Every factory creates the config value,
* builds its {@link SettingDef} and hands both to the caller's declare callback so the
* owning module can register them exactly as before.
*/
public final class ModuleSettingValues {
private ModuleSettingValues() {
}
public static BooleanValue bool(String configName, String settingId, boolean def, BiConsumer<ConfigValue<?>, SettingDef> declare) {
BooleanValue value = new BooleanValue(configName, def);
declare.accept(value, SettingDef.bool(settingId, value));
return value;
}
public static NumberValue<Integer> num(String configName, String settingId, int def, int min, int max, BiConsumer<ConfigValue<?>, SettingDef> declare) {
NumberValue<Integer> value = new NumberValue<>(configName, def, min, max);
declare.accept(value, SettingDef.number(settingId, value));
return value;
}
public static NumberValue<Long> num(String configName, String settingId, long def, long min, long max, BiConsumer<ConfigValue<?>, SettingDef> declare) {
NumberValue<Long> value = new NumberValue<>(configName, def, min, max);
declare.accept(value, SettingDef.number(settingId, value));
return value;
}
public static NumberValue<Float> num(String configName, String settingId, float def, float min, float max, BiConsumer<ConfigValue<?>, SettingDef> declare) {
NumberValue<Float> value = new NumberValue<>(configName, def, min, max);
declare.accept(value, SettingDef.number(settingId, value));
return value;
}
public static NumberValue<Double> num(String configName, String settingId, double def, double min, double max, BiConsumer<ConfigValue<?>, SettingDef> declare) {
NumberValue<Double> value = new NumberValue<>(configName, def, min, max);
declare.accept(value, SettingDef.number(settingId, value));
return value;
}
public static ModeValue modeSetting(String configName, String settingId, String def, BiConsumer<ConfigValue<?>, SettingDef> declare, String... options) {
ModeValue value = new ModeValue(configName, def, options);
declare.accept(value, SettingDef.mode(settingId, value));
return value;
}
public static <E extends Enum<E>> EnumValue<E> enumSetting(String configName, String settingId, E def, BiConsumer<ConfigValue<?>, SettingDef> declare) {
Class<E> enumClass = def.getDeclaringClass();
EnumValue<E> value = new EnumValue<>(configName, def, enumClass);
declare.accept(value, SettingDef.mode(settingId, value));
return value;
}
public static <E extends Enum<E>> EnumValue<E> enumSetting(String configName, String settingId, E def, Class<E> enumClass, BiConsumer<ConfigValue<?>, SettingDef> declare) {
EnumValue<E> value = new EnumValue<>(configName, def, enumClass);
declare.accept(value, SettingDef.mode(settingId, value));
return value;
}
@SafeVarargs
public static <E extends Enum<E>> EnumValue<E> enumSetting(String configName, String settingId, E def, BiConsumer<ConfigValue<?>, SettingDef> declare, E... options) {
EnumValue<E> value = new EnumValue<>(configName, def, options);
declare.accept(value, SettingDef.mode(settingId, value));
return value;
}
public static RGBAColorValue color(String configName, String settingId, String defHex, BiConsumer<ConfigValue<?>, SettingDef> declare) {
RGBAColorValue value = new RGBAColorValue(configName, defHex);
declare.accept(value, SettingDef.color(settingId, value));
return value;
}
public static RGBColorValue colorNoAlpha(String configName, String settingId, String defHex, BiConsumer<ConfigValue<?>, SettingDef> declare) {
RGBColorValue value = new RGBColorValue(configName, defHex);
declare.accept(value, SettingDef.colorNoAlpha(settingId, value));
return value;
}
public static StringValue text(String configName, String settingId, String def, BiConsumer<ConfigValue<?>, SettingDef> declare) {
StringValue value = new StringValue(configName, def);
declare.accept(value, SettingDef.text(settingId, value));
return value;
}
public static SetValue textList(String configName, String settingId, TextListSetting.PickerMode pickerMode, BiConsumer<ConfigValue<?>, SettingDef> declare) {
SetValue value = new SetValue(configName);
declare.accept(value, SettingDef.textList(settingId, value, pickerMode));
return value;
}
public static SetValue textList(String configName, String settingId, TextListSetting.PickerMode pickerMode, Set<String> defaults, BiConsumer<ConfigValue<?>, SettingDef> declare) {
SetValue value = new SetValue(configName, defaults);
declare.accept(value, SettingDef.textList(settingId, value, pickerMode));
return value;
}
public static ItemIdSetValue itemList(String configName, String settingId, TextListSetting.PickerMode pickerMode, BiConsumer<ConfigValue<?>, SettingDef> declare) {
ItemIdSetValue value = new ItemIdSetValue(configName);
declare.accept(value, SettingDef.textList(settingId, value, pickerMode));
return value;
}
public static BooleanMapValue group(String configName, String settingId, Map<String, Boolean> defaults, BiConsumer<ConfigValue<?>, SettingDef> declare) {
BooleanMapValue value = new BooleanMapValue(configName, defaults);
declare.accept(value, SettingDef.group(settingId, value));
return value;
}
public static KeyBindValue bind(String configName, String settingId, String def, BindMode mode, BiConsumer<ConfigValue<?>, SettingDef> declare) {
KeyBindValue value = new KeyBindValue(configName, def);
declare.accept(value, SettingDef.bind(settingId, value, mode));
return value;
}
public static BooleanMapValue protocolHeuristics(String configName,
String settingId,
CommonBooleanGroupSchema schema,
BiConsumer<ConfigValue<?>, SettingDef> declare) {
Map<String, Boolean> defaults = schema == null ? Map.of() : schema.defaults();
BooleanMapValue value = new BooleanMapValue(configName, defaults);
SettingDef def = SettingDef.protocolHeuristics(settingId, value);
if (schema != null) def.common(schema);
declare.accept(value, def);
return value;
}
}

View file

@ -0,0 +1,53 @@
/*
* This file is part of the LoVisual Client distribution.
* Copyright (c) 2026 loki5512344.
*
* Licensed under the GNU General Public License v3.0.
*/
package dev.loki.lovisual.features.module.modules.combat;
import dev.loki.lovisual.features.module.Module;
import dev.loki.lovisual.features.module.ModuleCategory;
import dev.loki.lovisual.features.module.ModuleInfo;
import dev.loki.lovisual.features.module.Notifier;
import dev.loki.lovisual.features.relations.CategoryService;
import dev.loki.lovisual.util.protection.NotifyThrottle;
import net.minecraft.util.Util;
import net.minecraft.world.entity.Entity;
import net.minecraft.world.entity.player.Player;
/**
* Cancels attacks against players marked as friends (including the
* Bedwars self team) and shows a throttled warning instead.
*/
@ModuleInfo(
id = "nofrienddamage",
displayName = "No Friend Damage",
aliases = {"nofrienddamage", "nfd"},
category = ModuleCategory.COMBAT,
description = "module.nofrienddamage.description"
)
public final class NoFriendDamage extends Module {
private static final long WARN_INTERVAL_MS = 2000L;
private final NotifyThrottle warnThrottle = new NotifyThrottle(WARN_INTERVAL_MS);
/**
* Called from the client attack hook (MultiPlayerGameMode.attack).
*
* @return true when the attack must be cancelled
*/
public boolean shouldBlockAttack(Entity target) {
if (!isEnabled() || !(target instanceof Player targetPlayer)) return false;
if (!CategoryService.isFriend(targetPlayer)) return false;
notifyBlocked(targetPlayer);
return true;
}
private void notifyBlocked(Player targetPlayer) {
if (!warnThrottle.tryAcquire(Util.getMillis())) return;
Notifier.warning("Blocked attack on friend: " + targetPlayer.getGameProfile().name());
}
}

View file

@ -16,38 +16,34 @@ import net.minecraft.client.multiplayer.ClientLevel;
import net.minecraft.client.renderer.SubmitNodeCollector; import net.minecraft.client.renderer.SubmitNodeCollector;
import net.minecraft.resources.Identifier; import net.minecraft.resources.Identifier;
import net.minecraft.util.Mth; import net.minecraft.util.Mth;
import net.minecraft.world.InteractionHand;
import net.minecraft.world.entity.Entity; import net.minecraft.world.entity.Entity;
import net.minecraft.world.entity.ExperienceOrb;
import net.minecraft.world.entity.LivingEntity; import net.minecraft.world.entity.LivingEntity;
import net.minecraft.world.entity.item.ItemEntity;
import net.minecraft.world.entity.item.PrimedTnt; import net.minecraft.world.entity.item.PrimedTnt;
import net.minecraft.world.entity.player.Player; import net.minecraft.world.entity.player.Player;
import net.minecraft.world.entity.projectile.FireworkRocketEntity; import net.minecraft.world.entity.projectile.FireworkRocketEntity;
import net.minecraft.world.entity.projectile.Projectile; import net.minecraft.world.entity.projectile.hurtingprojectile.Fireball;
import net.minecraft.world.entity.projectile.ProjectileUtil; import net.minecraft.world.entity.projectile.hurtingprojectile.windcharge.WindCharge;
import net.minecraft.world.entity.projectile.arrow.AbstractArrow; import net.minecraft.world.entity.projectile.arrow.AbstractArrow;
import net.minecraft.world.entity.projectile.arrow.ThrownTrident; import net.minecraft.world.entity.projectile.arrow.ThrownTrident;
import net.minecraft.world.entity.projectile.hurtingprojectile.Fireball; import net.minecraft.world.entity.projectile.throwableitemprojectile.AbstractThrownPotion;
import net.minecraft.world.entity.projectile.hurtingprojectile.SmallFireball; import net.minecraft.world.entity.projectile.throwableitemprojectile.Snowball;
import net.minecraft.world.entity.projectile.hurtingprojectile.windcharge.WindCharge; import net.minecraft.world.entity.projectile.throwableitemprojectile.ThrownEgg;
import net.minecraft.world.entity.projectile.throwableitemprojectile.*; import net.minecraft.world.entity.projectile.throwableitemprojectile.ThrownEnderpearl;
import net.minecraft.world.item.BowItem;
import net.minecraft.world.item.CrossbowItem;
import net.minecraft.world.item.ItemStack; import net.minecraft.world.item.ItemStack;
import net.minecraft.world.item.Items; import net.minecraft.world.item.Items;
import net.minecraft.world.level.ClipContext;
import net.minecraft.world.phys.AABB; import net.minecraft.world.phys.AABB;
import net.minecraft.world.phys.EntityHitResult;
import net.minecraft.world.phys.HitResult;
import net.minecraft.world.phys.Vec3; import net.minecraft.world.phys.Vec3;
import org.joml.Quaternionf; import org.joml.Quaternionf;
import org.joml.Vector3f;
import dev.loki.lovisual.config.values.BooleanMapValue; import dev.loki.lovisual.config.values.BooleanMapValue;
import dev.loki.lovisual.config.values.BooleanValue; import dev.loki.lovisual.config.values.BooleanValue;
import dev.loki.lovisual.config.values.NumberValue; import dev.loki.lovisual.config.values.NumberValue;
import dev.loki.lovisual.config.values.RGBColorValue; import dev.loki.lovisual.config.values.RGBColorValue;
import dev.loki.lovisual.features.module.*; import dev.loki.lovisual.features.module.modules.combat.predictions.PredictionsAim;
import dev.loki.lovisual.features.module.modules.combat.predictions.PredictionsSimulation;
import dev.loki.lovisual.features.module.modules.combat.predictions.PredictionsModel.AimPrediction;
import dev.loki.lovisual.features.module.modules.combat.predictions.PredictionsModel.Kind;
import dev.loki.lovisual.features.module.modules.combat.predictions.PredictionsModel.TimerPlate;
import dev.loki.lovisual.features.module.modules.combat.predictions.PredictionsModel.TrajectoryResult;
import dev.loki.lovisual.mixins.accessors.PersistentProjectileEntityAccessor; import dev.loki.lovisual.mixins.accessors.PersistentProjectileEntityAccessor;
import dev.loki.lovisual.render.engine.RenderState; import dev.loki.lovisual.render.engine.RenderState;
import dev.loki.lovisual.render.engine.TextureStorage; import dev.loki.lovisual.render.engine.TextureStorage;
@ -68,6 +64,15 @@ import java.util.Collections;
import java.util.List; import java.util.List;
import java.util.Locale; import java.util.Locale;
import static dev.loki.lovisual.features.module.modules.combat.predictions.PredictionsGeometry.addBillboardQuad;
import static dev.loki.lovisual.features.module.modules.combat.predictions.PredictionsGeometry.addGradientLine;
import static dev.loki.lovisual.features.module.modules.combat.predictions.PredictionsGeometry.addImpactDecal;
import static dev.loki.lovisual.features.module.modules.combat.predictions.PredictionsGeometry.addImpactRing;
import static dev.loki.lovisual.features.module.modules.combat.predictions.PredictionsGeometry.multiplyAlpha;
import static dev.loki.lovisual.features.module.modules.combat.predictions.PredictionsGeometry.withAlpha;
import static dev.loki.lovisual.features.module.modules.combat.predictions.PredictionsSimulation.HIT_ENTITY_COLOR;
import static dev.loki.lovisual.features.module.modules.combat.predictions.PredictionsSimulation.HIT_SELF_COLOR;
//todo Description //todo Description
@ModuleInfo(id = "predictions", displayName = "Predictions", category = ModuleCategory.COMBAT) @ModuleInfo(id = "predictions", displayName = "Predictions", category = ModuleCategory.COMBAT)
public class Predictions extends Module { public class Predictions extends Module {
@ -87,8 +92,6 @@ public class Predictions extends Module {
private static final String SETTING_TNT_COLOR = "tnt_color"; private static final String SETTING_TNT_COLOR = "tnt_color";
private static final String SETTING_PROJECTILES = "projectiles"; private static final String SETTING_PROJECTILES = "projectiles";
private static final int HIT_SELF_COLOR = 0xFFFF4444;
private static final int HIT_ENTITY_COLOR = 0xFFFFCC44;
private static final int GLOW_SUBSTEPS = 10; private static final int GLOW_SUBSTEPS = 10;
private static final float GLOW_CORE_SIZE = 0.085f; private static final float GLOW_CORE_SIZE = 0.085f;
private static final float GLOW_SOFT_SIZE = 0.16f; private static final float GLOW_SOFT_SIZE = 0.16f;
@ -125,20 +128,10 @@ public class Predictions extends Module {
private static final float TRAJECTORY_LINE_WIDTH = 1.35f; private static final float TRAJECTORY_LINE_WIDTH = 1.35f;
private static final float TRAJECTORY_LINE_ALPHA_START = 0.16f; private static final float TRAJECTORY_LINE_ALPHA_START = 0.16f;
private static final float TRAJECTORY_LINE_ALPHA_END = 0.78f; private static final float TRAJECTORY_LINE_ALPHA_END = 0.78f;
private static final int IMPACT_RING_SEGMENTS = 64; private static final float IMPACT_DECAL_RADIUS = 0.34f;
private static final float IMPACT_RING_RADIUS = 0.30f; private static final float IMPACT_RING_RADIUS = 0.30f;
private static final float IMPACT_RING_CROSS_RADIUS = 0.18f;
private static final float IMPACT_RING_LINE_WIDTH = 1.2f; private static final float IMPACT_RING_LINE_WIDTH = 1.2f;
private static final float IMPACT_RING_ALPHA = 0.88f; private static final float IMPACT_RING_ALPHA = 0.88f;
private static final float IMPACT_DECAL_RADIUS = 0.34f;
private static final float IMPACT_DECAL_EDGE_SOFTNESS = 0.085f;
private static final float IMPACT_DECAL_STROKE_WIDTH = 0.10f;
private static final float IMPACT_DECAL_PATTERN_SCALE = 4.25f;
private static final float IMPACT_DECAL_FILL_ALPHA = 0.48f;
private static final float IMPACT_DECAL_STROKE_ALPHA = 0.92f;
private static final float IMPACT_DECAL_PATTERN_STRENGTH = 0.10f;
private static final float IMPACT_DECAL_QUAD_PADDING = 1.18f;
private static final double IMPACT_DECAL_OFFSET = 0.004;
private final Minecraft mc = Minecraft.getInstance(); private final Minecraft mc = Minecraft.getInstance();
@ -177,301 +170,6 @@ public class Predictions extends Module {
private ItemStack tntTimerIcon = ItemStack.EMPTY; private ItemStack tntTimerIcon = ItemStack.EMPTY;
private final List<TimerPlate> timerPlates = new ArrayList<>(); private final List<TimerPlate> timerPlates = new ArrayList<>();
private static boolean canHitPredictedEntity(Entity projectile, Entity entity, AABB collisionBox) {
if (projectile == null || entity == null || !entity.isAlive() || !entity.canBeHitByProjectile()) {
return false;
}
if (projectile instanceof Projectile projectileEntity) {
Entity owner = projectileEntity.getOwner();
if (owner != null && !hasPredictedLeftOwner(owner, entity, collisionBox)) {
return !owner.isPassengerOfSameVehicle(entity);
}
}
return true;
}
private static boolean hasPredictedLeftOwner(Entity owner, Entity entity, AABB collisionBox) {
if (owner == null || collisionBox == null) {
return true;
}
return owner.getRootVehicle()
.getSelfAndPassengers()
.filter(Entity::isPickable)
.noneMatch(passenger -> collisionBox.intersects(passenger.getBoundingBox()));
}
private static void addGradientLine(MeshBuilder mesh, Vec3 from, Vec3 to, int startColor, int endColor) {
if (mesh == null || from == null || to == null) return;
mesh.ensureLineCapacity();
int i1 = mesh.vec3(from.x, from.y, from.z).color(new RenderColor(startColor)).next();
int i2 = mesh.vec3(to.x, to.y, to.z).color(new RenderColor(endColor)).next();
mesh.line(i1, i2);
}
private static void addImpactRing(MeshBuilder mesh, Vec3 center, Vec3 normal, float radius, int argb) {
if (mesh == null || center == null) return;
Vec3 useNormal = normal == null || normal.lengthSqr() < 1.0E-6
? new Vec3(0.0, 1.0, 0.0)
: normal.normalize();
Vec3 axis = Math.abs(useNormal.y) < 0.95 ? new Vec3(0.0, 1.0, 0.0) : new Vec3(1.0, 0.0, 0.0);
Vec3 tangent = useNormal.cross(axis);
if (tangent.lengthSqr() < 1.0E-6) {
tangent = new Vec3(1.0, 0.0, 0.0);
} else {
tangent = tangent.normalize();
}
Vec3 bitangent = useNormal.cross(tangent).normalize();
for (int i = 0; i < IMPACT_RING_SEGMENTS; i++) {
double angle0 = (Math.PI * 2.0 * i) / IMPACT_RING_SEGMENTS;
double angle1 = (Math.PI * 2.0 * (i + 1)) / IMPACT_RING_SEGMENTS;
Vec3 p0 = center
.add(tangent.scale(Math.cos(angle0) * radius))
.add(bitangent.scale(Math.sin(angle0) * radius));
Vec3 p1 = center
.add(tangent.scale(Math.cos(angle1) * radius))
.add(bitangent.scale(Math.sin(angle1) * radius));
addGradientLine(mesh, p0, p1, argb, argb);
}
Vec3 crossTangent = tangent.scale(IMPACT_RING_CROSS_RADIUS);
Vec3 crossBitangent = bitangent.scale(IMPACT_RING_CROSS_RADIUS);
addGradientLine(mesh, center.subtract(crossTangent), center.add(crossTangent), argb, argb);
addGradientLine(mesh, center.subtract(crossBitangent), center.add(crossBitangent), argb, argb);
}
private static void addImpactDecal(MeshBuilder mesh, Vec3 center, Vec3 normal, float radius, int argb) {
if (mesh == null || center == null || radius <= 0.0f) {
return;
}
Vec3 useNormal = normal == null || normal.lengthSqr() < 1.0E-6
? new Vec3(0.0, 1.0, 0.0)
: normal.normalize();
Vec3 axis = Math.abs(useNormal.y) < 0.95 ? new Vec3(0.0, 1.0, 0.0) : new Vec3(1.0, 0.0, 0.0);
Vec3 tangent = useNormal.cross(axis);
if (tangent.lengthSqr() < 1.0E-6) {
tangent = new Vec3(1.0, 0.0, 0.0);
} else {
tangent = tangent.normalize();
}
Vec3 bitangent = useNormal.cross(tangent).normalize();
Vec3 offsetCenter = center.add(useNormal.scale(IMPACT_DECAL_OFFSET));
float quadRadius = radius * IMPACT_DECAL_QUAD_PADDING;
float sdfRadius = 1.0f / IMPACT_DECAL_QUAD_PADDING;
Vec3 tangentScaled = tangent.scale(quadRadius);
Vec3 bitangentScaled = bitangent.scale(quadRadius);
mesh.ensureQuadCapacity();
int i1 = mesh.vec3(offsetCenter.subtract(tangentScaled).subtract(bitangentScaled).x,
offsetCenter.subtract(tangentScaled).subtract(bitangentScaled).y,
offsetCenter.subtract(tangentScaled).subtract(bitangentScaled).z)
.vec2(0.0, 0.0)
.color(new RenderColor(argb))
.vec4(sdfRadius, IMPACT_DECAL_EDGE_SOFTNESS, IMPACT_DECAL_STROKE_WIDTH, IMPACT_DECAL_PATTERN_SCALE)
.vec4(IMPACT_DECAL_FILL_ALPHA, IMPACT_DECAL_STROKE_ALPHA, IMPACT_DECAL_PATTERN_STRENGTH, 0.0f)
.next();
int i2 = mesh.vec3(offsetCenter.add(tangentScaled).subtract(bitangentScaled).x,
offsetCenter.add(tangentScaled).subtract(bitangentScaled).y,
offsetCenter.add(tangentScaled).subtract(bitangentScaled).z)
.vec2(1.0, 0.0)
.color(new RenderColor(argb))
.vec4(sdfRadius, IMPACT_DECAL_EDGE_SOFTNESS, IMPACT_DECAL_STROKE_WIDTH, IMPACT_DECAL_PATTERN_SCALE)
.vec4(IMPACT_DECAL_FILL_ALPHA, IMPACT_DECAL_STROKE_ALPHA, IMPACT_DECAL_PATTERN_STRENGTH, 0.0f)
.next();
int i3 = mesh.vec3(offsetCenter.add(tangentScaled).add(bitangentScaled).x,
offsetCenter.add(tangentScaled).add(bitangentScaled).y,
offsetCenter.add(tangentScaled).add(bitangentScaled).z)
.vec2(1.0, 1.0)
.color(new RenderColor(argb))
.vec4(sdfRadius, IMPACT_DECAL_EDGE_SOFTNESS, IMPACT_DECAL_STROKE_WIDTH, IMPACT_DECAL_PATTERN_SCALE)
.vec4(IMPACT_DECAL_FILL_ALPHA, IMPACT_DECAL_STROKE_ALPHA, IMPACT_DECAL_PATTERN_STRENGTH, 0.0f)
.next();
int i4 = mesh.vec3(offsetCenter.subtract(tangentScaled).add(bitangentScaled).x,
offsetCenter.subtract(tangentScaled).add(bitangentScaled).y,
offsetCenter.subtract(tangentScaled).add(bitangentScaled).z)
.vec2(0.0, 1.0)
.color(new RenderColor(argb))
.vec4(sdfRadius, IMPACT_DECAL_EDGE_SOFTNESS, IMPACT_DECAL_STROKE_WIDTH, IMPACT_DECAL_PATTERN_SCALE)
.vec4(IMPACT_DECAL_FILL_ALPHA, IMPACT_DECAL_STROKE_ALPHA, IMPACT_DECAL_PATTERN_STRENGTH, 0.0f)
.next();
mesh.quad(i1, i2, i3, i4);
}
private static Vec3 resolveImpactNormal(HitResult hit, Vec3 velocity) {
if (hit instanceof net.minecraft.world.phys.BlockHitResult blockHit) {
return Vec3.atLowerCornerOf(blockHit.getDirection().getUnitVec3i());
}
if (velocity != null && velocity.lengthSqr() > 1.0E-6) {
return velocity.normalize();
}
return new Vec3(0.0, 1.0, 0.0);
}
private static double partialStepProgress(Vec3 from, Vec3 hitPos, double segmentLengthSq) {
if (from == null || hitPos == null || segmentLengthSq <= 1.0E-6) {
return 1.0;
}
return Mth.clamp(from.distanceTo(hitPos) / Math.sqrt(segmentLengthSq), 0.0, 1.0);
}
private static Vec3 resolveEntityImpactNormal(AABB box, Vec3 hitPos, Vec3 fallbackVelocity) {
if (box == null || hitPos == null) {
return resolveImpactNormal(null, fallbackVelocity);
}
double dxMin = Math.abs(hitPos.x - box.minX);
double dxMax = Math.abs(box.maxX - hitPos.x);
double dyMin = Math.abs(hitPos.y - box.minY);
double dyMax = Math.abs(box.maxY - hitPos.y);
double dzMin = Math.abs(hitPos.z - box.minZ);
double dzMax = Math.abs(box.maxZ - hitPos.z);
double best = dxMin;
Vec3 normal = new Vec3(-1.0, 0.0, 0.0);
if (dxMax < best) {
best = dxMax;
normal = new Vec3(1.0, 0.0, 0.0);
}
if (dyMin < best) {
best = dyMin;
normal = new Vec3(0.0, -1.0, 0.0);
}
if (dyMax < best) {
best = dyMax;
normal = new Vec3(0.0, 1.0, 0.0);
}
if (dzMin < best) {
best = dzMin;
normal = new Vec3(0.0, 0.0, -1.0);
}
if (dzMax < best) {
normal = new Vec3(0.0, 0.0, 1.0);
}
return normal;
}
private static void addBillboardQuad(MeshBuilder mesh, double cx, double cy, double cz,
float size, Quaternionf camRot, int argb) {
Vector3f right = new Vector3f(1.0f, 0.0f, 0.0f).rotate(camRot).mul(size);
Vector3f up = new Vector3f(0.0f, 1.0f, 0.0f).rotate(camRot).mul(size);
double p1x = cx - right.x() - up.x();
double p1y = cy - right.y() - up.y();
double p1z = cz - right.z() - up.z();
double p2x = cx + right.x() - up.x();
double p2y = cy + right.y() - up.y();
double p2z = cz + right.z() - up.z();
double p3x = cx + right.x() + up.x();
double p3y = cy + right.y() + up.y();
double p3z = cz + right.z() + up.z();
double p4x = cx - right.x() + up.x();
double p4y = cy - right.y() + up.y();
double p4z = cz - right.z() + up.z();
mesh.ensureQuadCapacity();
int i1 = mesh.vec3(p1x, p1y, p1z).vec2(0.0, 1.0).color(new RenderColor(argb)).next();
int i2 = mesh.vec3(p2x, p2y, p2z).vec2(1.0, 1.0).color(new RenderColor(argb)).next();
int i3 = mesh.vec3(p3x, p3y, p3z).vec2(1.0, 0.0).color(new RenderColor(argb)).next();
int i4 = mesh.vec3(p4x, p4y, p4z).vec2(0.0, 0.0).color(new RenderColor(argb)).next();
mesh.quad(i1, i2, i3, i4);
}
private static void addFilledBox(Renderer3D renderer, AABB box, int argb) {
int a = (argb >>> 24) & 0xFF;
int r = (argb >>> 16) & 0xFF;
int g = (argb >>> 8) & 0xFF;
int b = argb & 0xFF;
double minX = box.minX;
double minY = box.minY;
double minZ = box.minZ;
double maxX = box.maxX;
double maxY = box.maxY;
double maxZ = box.maxZ;
renderer.quad(minX, minY, minZ, maxX, minY, minZ, maxX, minY, maxZ, minX, minY, maxZ, r, g, b, a);
renderer.quad(minX, maxY, minZ, minX, maxY, maxZ, maxX, maxY, maxZ, maxX, maxY, minZ, r, g, b, a);
renderer.quad(minX, minY, maxZ, maxX, minY, maxZ, maxX, maxY, maxZ, minX, maxY, maxZ, r, g, b, a);
renderer.quad(minX, minY, minZ, minX, maxY, minZ, maxX, maxY, minZ, maxX, minY, minZ, r, g, b, a);
renderer.quad(maxX, minY, minZ, maxX, maxY, minZ, maxX, maxY, maxZ, maxX, minY, maxZ, r, g, b, a);
renderer.quad(minX, minY, minZ, minX, minY, maxZ, minX, maxY, maxZ, minX, maxY, minZ, r, g, b, a);
}
private static void addOutlineBox(Renderer3D renderer, AABB box, int argb) {
int a = (argb >>> 24) & 0xFF;
int r = (argb >>> 16) & 0xFF;
int g = (argb >>> 8) & 0xFF;
int b = argb & 0xFF;
double minX = box.minX;
double minY = box.minY;
double minZ = box.minZ;
double maxX = box.maxX;
double maxY = box.maxY;
double maxZ = box.maxZ;
renderer.line(minX, minY, minZ, maxX, minY, minZ, r, g, b, a);
renderer.line(maxX, minY, minZ, maxX, minY, maxZ, r, g, b, a);
renderer.line(maxX, minY, maxZ, minX, minY, maxZ, r, g, b, a);
renderer.line(minX, minY, maxZ, minX, minY, minZ, r, g, b, a);
renderer.line(minX, maxY, minZ, maxX, maxY, minZ, r, g, b, a);
renderer.line(maxX, maxY, minZ, maxX, maxY, maxZ, r, g, b, a);
renderer.line(maxX, maxY, maxZ, minX, maxY, maxZ, r, g, b, a);
renderer.line(minX, maxY, maxZ, minX, maxY, minZ, r, g, b, a);
renderer.line(minX, minY, minZ, minX, maxY, minZ, r, g, b, a);
renderer.line(maxX, minY, minZ, maxX, maxY, minZ, r, g, b, a);
renderer.line(maxX, minY, maxZ, maxX, maxY, maxZ, r, g, b, a);
renderer.line(minX, minY, maxZ, minX, maxY, maxZ, r, g, b, a);
}
private static int multiplyAlpha(int argb, float alphaMultiplier) {
int baseAlpha = (argb >>> 24) & 0xFF;
int alpha = Math.max(0, Math.min(255, Math.round(baseAlpha * Math.max(0.0f, alphaMultiplier))));
return (argb & 0x00FFFFFF) | (alpha << 24);
}
private static int withAlpha(int argb, int alpha) {
return (argb & 0x00FFFFFF) | (Math.max(0, Math.min(255, alpha)) << 24);
}
private static Vec3 advanceTickVelocity(Entity entity, Vec3 velocity) {
return velocity.scale(0.99).add(0.0, -entity.getGravity(), 0.0);
}
private static Kind kindFromParams(ItemStack stack) {
if (stack == null) return Kind.ARROW;
if (stack.is(Items.TRIDENT)) return Kind.TRIDENT;
if (stack.is(Items.SPLASH_POTION)) return Kind.POTION_SPLASH;
if (stack.is(Items.LINGERING_POTION)) return Kind.POTION_LINGERING;
if (stack.is(Items.SNOWBALL)) return Kind.SNOWBALL;
if (stack.is(Items.EGG)) return Kind.EGG;
if (stack.is(Items.FIREWORK_ROCKET)) return Kind.FIREWORK;
if (stack.is(Items.WIND_CHARGE)) return Kind.WIND_CHARGE;
if (stack.is(Items.FIRE_CHARGE)) return Kind.FIREBALL;
if (stack.is(Items.ENDER_PEARL)) return Kind.PEARL_ENTITY;
return Kind.ARROW;
}
private static Kind kindFromProjectile(Entity entity) {
if (entity instanceof ThrownTrident) return Kind.TRIDENT;
if (entity instanceof AbstractThrownPotion potion) {
return potion.getItem().is(Items.LINGERING_POTION) ? Kind.POTION_LINGERING : Kind.POTION_SPLASH;
}
if (entity instanceof Snowball) return Kind.SNOWBALL;
if (entity instanceof ThrownEgg) return Kind.EGG;
if (entity instanceof FireworkRocketEntity) return Kind.FIREWORK;
if (entity instanceof WindCharge) return Kind.WIND_CHARGE;
if (entity instanceof Fireball) return Kind.FIREBALL;
if (entity instanceof ThrownEnderpearl) return Kind.PEARL_ENTITY;
return Kind.ARROW;
}
@Override @Override
public WorldPhase getWorldPhase() { public WorldPhase getWorldPhase() {
return WorldPhase.END_MAIN; return WorldPhase.END_MAIN;
@ -524,8 +222,8 @@ public class Predictions extends Module {
List<TrajectoryResult> results = showPlate ? simulateProjectiles(tickDelta) : Collections.emptyList(); List<TrajectoryResult> results = showPlate ? simulateProjectiles(tickDelta) : Collections.emptyList();
if (showPlate && !results.isEmpty()) { if (showPlate && !results.isEmpty()) {
for (TrajectoryResult res : results) { for (TrajectoryResult res : results) {
if (res.points.isEmpty()) continue; if (res.points().isEmpty()) continue;
Vec3 end = res.points.getLast(); Vec3 end = res.points().getLast();
Vec3 screen = ScreenProjection.worldToScreen(end, tickDelta); Vec3 screen = ScreenProjection.worldToScreen(end, tickDelta);
if (screen == null) continue; if (screen == null) continue;
drawTimerPlate(renderer, textRenderer, res, (float) screen.x, (float) screen.y); drawTimerPlate(renderer, textRenderer, res, (float) screen.x, (float) screen.y);
@ -567,6 +265,16 @@ public class Predictions extends Module {
} }
} }
private TrajectoryResult simulateEntity(Entity e, Kind kind, float tickDelta) {
return PredictionsSimulation.simulateEntity(e, kind, tickDelta,
maxSimTicks, pearlMaxSimTicks, pearlColorToggle, pearlColorValue);
}
private AimPrediction computeAimPrediction(Player player, float tickDelta) {
return PredictionsAim.computeAimPrediction(player, tickDelta,
maxSimTicks, pearlMaxSimTicks, pearlColorToggle, pearlColorValue);
}
private List<TrajectoryResult> simulateProjectiles(float tickDelta) { private List<TrajectoryResult> simulateProjectiles(float tickDelta) {
ClientLevel world = mc.level; ClientLevel world = mc.level;
if (world == null) return Collections.emptyList(); if (world == null) return Collections.emptyList();
@ -664,268 +372,6 @@ public class Predictions extends Module {
return out; return out;
} }
private TrajectoryResult simulateEntity(Entity e, Kind kind, float tickDelta) {
ClientLevel world = mc.level;
if (world == null) return null;
Vec3 pos = e.position();
Vec3 vel = e.getDeltaMovement();
List<Vec3> points = new ArrayList<>();
points.add(pos);
double t = 0.0;
int baseMaxTicks = Math.max(1, maxSimTicks.get());
int maxTicks = kind == Kind.PEARL_ENTITY
? Math.max(1, pearlMaxSimTicks.get())
: baseMaxTicks;
int hitColor = 0;
long impactTick = world.getGameTime();
Vec3 impactPos = null;
Vec3 impactNormal = null;
Entity hitEntity = null;
boolean hitBlock = false;
for (int i = 0; i < maxTicks; i++) {
Vec3 segmentStart = pos;
Vec3 simulatedVelocity = advanceTickVelocity(e, vel);
Vec3 simulatedPos = pos.add(simulatedVelocity);
double segmentLengthSq = segmentStart.distanceToSqr(simulatedPos);
HitResult hit = world.clip(new ClipContext(
segmentStart,
simulatedPos,
ClipContext.Block.COLLIDER,
ClipContext.Fluid.NONE,
e
));
EntityCollision entityCollision = getEntityCollision(e, segmentStart, simulatedPos);
boolean hasBlockHit = hit.getType() != HitResult.Type.MISS;
boolean useEntityHit = entityCollision != null
&& (!hasBlockHit || segmentStart.distanceToSqr(entityCollision.hitPos()) <= segmentStart.distanceToSqr(hit.getLocation()));
if (useEntityHit) {
hitColor = (entityCollision.entity() == mc.player) ? HIT_SELF_COLOR : HIT_ENTITY_COLOR;
Vec3 entityHitPos = entityCollision.hitPos();
t += partialStepProgress(segmentStart, entityHitPos, segmentLengthSq);
points.add(entityHitPos);
impactTick = world.getGameTime() + (long) Math.ceil(t);
impactPos = entityHitPos;
impactNormal = entityCollision.hitNormal();
hitEntity = entityCollision.entity();
break;
} else if (hasBlockHit) {
Vec3 blockHitPos = hit.getLocation();
t += partialStepProgress(segmentStart, blockHitPos, segmentLengthSq);
points.add(blockHitPos);
impactTick = world.getGameTime() + (long) Math.ceil(t);
impactPos = blockHitPos;
impactNormal = resolveImpactNormal(hit, simulatedVelocity);
hitBlock = true;
break;
}
pos = simulatedPos;
vel = simulatedVelocity;
t += 1.0;
points.add(pos);
impactTick = world.getGameTime() + (long) Math.ceil(t);
}
ItemStack icon = switch (kind) {
case PEARL_ENTITY -> new ItemStack(Items.ENDER_PEARL);
case TRIDENT -> new ItemStack(Items.TRIDENT);
case FIREBALL -> new ItemStack(Items.FIRE_CHARGE);
case WIND_CHARGE -> new ItemStack(Items.WIND_CHARGE);
case FIREWORK -> new ItemStack(Items.FIREWORK_ROCKET);
case SNOWBALL -> new ItemStack(Items.SNOWBALL);
case EGG -> new ItemStack(Items.EGG);
case POTION_SPLASH -> new ItemStack(Items.SPLASH_POTION);
case POTION_LINGERING -> new ItemStack(Items.LINGERING_POTION);
default -> new ItemStack(Items.ARROW);
};
int color = hitColor;
if (color == 0 && kind == Kind.PEARL_ENTITY && pearlColorToggle.get()) {
color = pearlColorValue.getArgb();
}
return new TrajectoryResult(points, t, impactTick, icon, color, kind, impactPos, impactNormal, hitEntity, hitBlock);
}
private EntityCollision getEntityCollision(Entity movingEntity, Vec3 startPos, Vec3 endPos) {
Vec3 direction = endPos.subtract(startPos);
if (direction.lengthSqr() == 0.0) {
return null;
}
AABB projectileBox = movingEntity.getBoundingBox().move(startPos.subtract(movingEntity.position()));
AABB collisionBox = projectileBox.expandTowards(direction).inflate(0.5);
EntityHitResult hitResult = ProjectileUtil.getEntityHitResult(
movingEntity,
startPos,
endPos,
collisionBox,
entity -> canHitPredictedEntity(movingEntity, entity, collisionBox)
&& entity instanceof LivingEntity
&& !(entity instanceof ItemEntity)
&& !(entity instanceof ExperienceOrb)
&& entity != movingEntity,
direction.lengthSqr()
);
if (hitResult == null) {
return null;
}
return new EntityCollision(
hitResult.getEntity(),
hitResult.getLocation(),
resolveEntityImpactNormal(hitResult.getEntity().getBoundingBox(), hitResult.getLocation(), direction)
);
}
private AimPrediction computeAimPrediction(Player player, float tickDelta) {
Projectile projectile = createHeldProjectile(player, tickDelta);
if (projectile == null) {
return null;
}
Kind kind = kindFromProjectile(projectile);
TrajectoryResult result = simulateEntity(projectile, kind, tickDelta);
if (result == null || result.points().isEmpty()) {
return null;
}
Vec3 hitPos = result.impactPos() != null ? result.impactPos() : result.points().getLast();
Entity hitEntity = result.hitEntity();
Vec3 anchorPos;
if (hitEntity instanceof LivingEntity living) {
anchorPos = living.getPosition(tickDelta).add(0.0, living.getBbHeight() * 0.5, 0.0);
} else if (hitEntity != null) {
anchorPos = hitEntity.getBoundingBox().getCenter();
} else {
anchorPos = hitPos;
}
return new AimPrediction(hitEntity, hitPos, anchorPos, result.impactNormal(), result.iconStack(), result);
}
private Projectile createHeldProjectile(Player player, float tickDelta) {
if (mc.level == null) {
return null;
}
InteractionHand hand = null;
ItemStack handStack = ItemStack.EMPTY;
ItemStack mainHand = player.getItemInHand(InteractionHand.MAIN_HAND);
if (isAimUsable(player, mainHand, InteractionHand.MAIN_HAND)) {
hand = InteractionHand.MAIN_HAND;
handStack = mainHand;
} else {
ItemStack offHand = player.getItemInHand(InteractionHand.OFF_HAND);
if (isAimUsable(player, offHand, InteractionHand.OFF_HAND)) {
hand = InteractionHand.OFF_HAND;
handStack = offHand;
}
}
if (hand == null || handStack.isEmpty()) {
return null;
}
Projectile projectile = null;
float speed = 0.0f;
float roll = 0.0f;
if (handStack.is(Items.ENDER_PEARL)) {
projectile = new ThrownEnderpearl(mc.level, player, handStack);
speed = 1.5f;
} else if (handStack.is(Items.SNOWBALL)) {
projectile = new Snowball(mc.level, player, handStack);
speed = 1.5f;
} else if (handStack.is(Items.EGG)) {
projectile = new ThrownEgg(mc.level, player, handStack);
speed = 1.5f;
} else if (handStack.is(Items.SPLASH_POTION) || handStack.is(Items.LINGERING_POTION)) {
projectile = handStack.is(Items.LINGERING_POTION)
? new ThrownLingeringPotion(mc.level, player, handStack)
: new ThrownSplashPotion(mc.level, player, handStack);
speed = 0.5f;
roll = -20.0f;
} else if (handStack.is(Items.EXPERIENCE_BOTTLE)) {
projectile = new ThrownExperienceBottle(mc.level, player, handStack);
speed = 0.7f;
roll = -20.0f;
} else if (handStack.is(Items.WIND_CHARGE)) {
projectile = new WindCharge(player, mc.level, player.position().x, player.getEyePosition().y, player.position().z);
speed = 1.5f;
} else if (handStack.is(Items.FIRE_CHARGE)) {
projectile = new SmallFireball(mc.level, player, Vec3.ZERO);
speed = 1.0f;
} else if (handStack.is(Items.TRIDENT) && player.isUsingItem() && player.getUsedItemHand() == hand) {
projectile = new ThrownTrident(mc.level, player, handStack);
speed = 2.5f;
} else if (handStack.is(Items.BOW) && player.isUsingItem() && player.getUsedItemHand() == hand) {
float pull = BowItem.getPowerForTime(player.getTicksUsingItem());
if (pull <= 0.0f) {
return null;
}
ItemStack arrowStack = new ItemStack(Items.ARROW);
projectile = new net.minecraft.world.entity.projectile.arrow.Arrow(mc.level, player, arrowStack, handStack);
speed = 3.0f * pull;
} else if (handStack.getItem() instanceof CrossbowItem && CrossbowItem.isCharged(handStack)) {
ItemStack arrowStack = new ItemStack(Items.ARROW);
projectile = new net.minecraft.world.entity.projectile.arrow.Arrow(mc.level, player, arrowStack, handStack);
speed = 3.15f;
}
if (projectile == null) {
return null;
}
Vec3 eye = player.getEyePosition(tickDelta);
projectile.absSnapTo(eye.x, eye.y, eye.z);
setHeldProjectileVelocity(projectile, player, player.getXRot(), player.getYRot(), roll, speed);
return projectile;
}
private void setHeldProjectileVelocity(Projectile projectile, Entity shooter, float pitch, float yaw, float roll, float speed) {
float x = -Mth.sin(yaw * ((float) Math.PI / 180.0f)) * Mth.cos(pitch * ((float) Math.PI / 180.0f));
float y = -Mth.sin((pitch + roll) * ((float) Math.PI / 180.0f));
float z = Mth.cos(yaw * ((float) Math.PI / 180.0f)) * Mth.cos(pitch * ((float) Math.PI / 180.0f));
Vec3 velocity = new Vec3(x, y, z).normalize().scale(speed);
projectile.setDeltaMovement(velocity);
Vec3 movement = shooter.getKnownMovement();
projectile.setDeltaMovement(projectile.getDeltaMovement().add(movement.x, shooter.onGround() ? 0.0 : movement.y, movement.z));
}
private boolean isAimUsable(Player player, ItemStack stack, InteractionHand hand) {
if (stack == null || stack.isEmpty()) {
return false;
}
if (stack.is(Items.BOW) || stack.is(Items.TRIDENT)) {
return player.isUsingItem() && player.getUsedItemHand() == hand && player.getUseItem() == stack;
}
if (stack.is(Items.CROSSBOW)) {
return net.minecraft.world.item.CrossbowItem.isCharged(stack);
}
return stack.is(Items.ENDER_PEARL)
|| stack.is(Items.SNOWBALL)
|| stack.is(Items.EGG)
|| stack.is(Items.SPLASH_POTION)
|| stack.is(Items.LINGERING_POTION)
|| stack.is(Items.EXPERIENCE_BOTTLE)
|| stack.is(Items.FIRE_CHARGE)
|| stack.is(Items.WIND_CHARGE);
}
private boolean isKindDisabled(String key) { private boolean isKindDisabled(String key) {
// Ender pearls are always shown; caller must skip check for them. // Ender pearls are always shown; caller must skip check for them.
return !kindToggles.get(key); return !kindToggles.get(key);
@ -941,12 +387,12 @@ public class Predictions extends Module {
} }
private void drawTimerPlate(Renderer2D renderer, TextRenderer textRenderer, TrajectoryResult res, float sx, float sy) { private void drawTimerPlate(Renderer2D renderer, TextRenderer textRenderer, TrajectoryResult res, float sx, float sy) {
double seconds = res.flightTicks / 20.0; double seconds = res.flightTicks() / 20.0;
if (seconds <= 0.0) return; if (seconds <= 0.0) return;
ItemStack iconStack = useItemIcon(res.kind) ? resolveTimerItem(res.kind) : ItemStack.EMPTY; ItemStack iconStack = useItemIcon(res.kind()) ? resolveTimerItem(res.kind()) : ItemStack.EMPTY;
Identifier iconId = iconStack.isEmpty() ? resolveTimerIcon(res.kind) : null; Identifier iconId = iconStack.isEmpty() ? resolveTimerIcon(res.kind()) : null;
drawTimerPlate(renderer, textRenderer, formatTime(seconds), sx, sy, res.argb, iconStack, iconId); drawTimerPlate(renderer, textRenderer, formatTime(seconds), sx, sy, res.argb(), iconStack, iconId);
} }
private void drawTntTimers(Renderer2D renderer, TextRenderer textRenderer, float tickDelta) { private void drawTntTimers(Renderer2D renderer, TextRenderer textRenderer, float tickDelta) {
@ -1300,64 +746,53 @@ public class Predictions extends Module {
} }
} }
private enum Kind { private static void addFilledBox(Renderer3D renderer, AABB box, int argb) {
PEARL_ENTITY, int a = (argb >>> 24) & 0xFF;
ARROW, int r = (argb >>> 16) & 0xFF;
TRIDENT, int g = (argb >>> 8) & 0xFF;
FIREBALL, int b = argb & 0xFF;
WIND_CHARGE,
FIREWORK, double minX = box.minX;
SNOWBALL, double minY = box.minY;
EGG, double minZ = box.minZ;
POTION_SPLASH, double maxX = box.maxX;
POTION_LINGERING double maxY = box.maxY;
double maxZ = box.maxZ;
renderer.quad(minX, minY, minZ, maxX, minY, minZ, maxX, minY, maxZ, minX, minY, maxZ, r, g, b, a);
renderer.quad(minX, maxY, minZ, minX, maxY, maxZ, maxX, maxY, maxZ, maxX, maxY, minZ, r, g, b, a);
renderer.quad(minX, minY, maxZ, maxX, minY, maxZ, maxX, maxY, maxZ, minX, maxY, maxZ, r, g, b, a);
renderer.quad(minX, minY, minZ, minX, maxY, minZ, maxX, maxY, minZ, maxX, minY, minZ, r, g, b, a);
renderer.quad(maxX, minY, minZ, maxX, maxY, minZ, maxX, maxY, maxZ, maxX, minY, maxZ, r, g, b, a);
renderer.quad(minX, minY, minZ, minX, minY, maxZ, minX, maxY, maxZ, minX, maxY, minZ, r, g, b, a);
} }
private record AimPrediction(Entity entity, Vec3 hitPos, Vec3 anchorPos, Vec3 hitNormal, ItemStack displayStack, private static void addOutlineBox(Renderer3D renderer, AABB box, int argb) {
TrajectoryResult trajectory) { int a = (argb >>> 24) & 0xFF;
private AimPrediction(Entity entity, Vec3 hitPos, Vec3 anchorPos, Vec3 hitNormal, ItemStack displayStack, int r = (argb >>> 16) & 0xFF;
TrajectoryResult trajectory) { int g = (argb >>> 8) & 0xFF;
this.entity = entity; int b = argb & 0xFF;
this.hitPos = hitPos;
this.anchorPos = anchorPos;
this.hitNormal = hitNormal;
this.displayStack = displayStack == null ? ItemStack.EMPTY : displayStack;
this.trajectory = trajectory;
}
}
private record TrajectoryResult(List<Vec3> points, double flightTicks, long impactTick, ItemStack iconStack, double minX = box.minX;
int argb, Kind kind, Vec3 impactPos, Vec3 impactNormal, Entity hitEntity, double minY = box.minY;
boolean hitBlock) { double minZ = box.minZ;
private TrajectoryResult(List<Vec3> points, double flightTicks, long impactTick, ItemStack iconStack, double maxX = box.maxX;
int argb, Kind kind, Vec3 impactPos, Vec3 impactNormal, Entity hitEntity, boolean hitBlock) { double maxY = box.maxY;
this.points = points; double maxZ = box.maxZ;
this.flightTicks = flightTicks;
this.impactTick = impactTick;
this.iconStack = iconStack == null ? ItemStack.EMPTY : iconStack;
this.argb = argb;
this.kind = kind;
this.impactPos = impactPos;
this.impactNormal = impactNormal;
this.hitEntity = hitEntity;
this.hitBlock = hitBlock;
}
}
private record EntityCollision(Entity entity, Vec3 hitPos, Vec3 hitNormal) { renderer.line(minX, minY, minZ, maxX, minY, minZ, r, g, b, a);
} renderer.line(maxX, minY, minZ, maxX, minY, maxZ, r, g, b, a);
renderer.line(maxX, minY, maxZ, minX, minY, maxZ, r, g, b, a);
renderer.line(minX, minY, maxZ, minX, minY, minZ, r, g, b, a);
private record TimerPlate(float textX, float textY, String text, renderer.line(minX, maxY, minZ, maxX, maxY, minZ, r, g, b, a);
float iconX, float iconY, float iconScale, ItemStack iconStack) { renderer.line(maxX, maxY, minZ, maxX, maxY, maxZ, r, g, b, a);
private TimerPlate(float textX, float textY, String text, renderer.line(maxX, maxY, maxZ, minX, maxY, maxZ, r, g, b, a);
float iconX, float iconY, float iconScale, ItemStack iconStack) { renderer.line(minX, maxY, maxZ, minX, maxY, minZ, r, g, b, a);
this.textX = textX;
this.textY = textY; renderer.line(minX, minY, minZ, minX, maxY, minZ, r, g, b, a);
this.text = text; renderer.line(maxX, minY, minZ, maxX, maxY, minZ, r, g, b, a);
this.iconX = iconX; renderer.line(maxX, minY, maxZ, maxX, maxY, maxZ, r, g, b, a);
this.iconY = iconY; renderer.line(minX, minY, maxZ, minX, maxY, maxZ, r, g, b, a);
this.iconScale = iconScale;
this.iconStack = iconStack == null ? ItemStack.EMPTY : iconStack;
}
} }
} }

View file

@ -7,9 +7,6 @@
package dev.loki.lovisual.features.module.modules.combat; package dev.loki.lovisual.features.module.modules.combat;
import dev.loki.lovisual.util.time.GameClock;
import net.minecraft.client.Camera;
import net.minecraft.client.Minecraft; import net.minecraft.client.Minecraft;
import net.minecraft.resources.Identifier; import net.minecraft.resources.Identifier;
import net.minecraft.util.Mth; import net.minecraft.util.Mth;
@ -17,8 +14,6 @@ import net.minecraft.world.entity.Entity;
import net.minecraft.world.entity.LivingEntity; import net.minecraft.world.entity.LivingEntity;
import net.minecraft.world.entity.player.Player; import net.minecraft.world.entity.player.Player;
import net.minecraft.world.phys.Vec3; import net.minecraft.world.phys.Vec3;
import org.joml.Quaternionf;
import org.joml.Vector3f;
import dev.loki.lovisual.config.common.CommonSettingSchemas; import dev.loki.lovisual.config.common.CommonSettingSchemas;
import dev.loki.lovisual.config.values.BooleanValue; import dev.loki.lovisual.config.values.BooleanValue;
import dev.loki.lovisual.config.values.ModeValue; import dev.loki.lovisual.config.values.ModeValue;
@ -28,13 +23,16 @@ import dev.loki.lovisual.features.module.Module;
import dev.loki.lovisual.features.module.ModuleCategory; import dev.loki.lovisual.features.module.ModuleCategory;
import dev.loki.lovisual.features.module.ModuleInfo; import dev.loki.lovisual.features.module.ModuleInfo;
import dev.loki.lovisual.features.module.WorldPhase; import dev.loki.lovisual.features.module.WorldPhase;
import dev.loki.lovisual.features.module.modules.combat.targetesp.TargetEspCrystals;
import dev.loki.lovisual.features.module.modules.combat.targetesp.TargetEspEffects;
import dev.loki.lovisual.features.module.modules.combat.targetesp.TargetEspModes;
import dev.loki.lovisual.features.module.modules.combat.targetesp.TargetEspCrystals.CrystalInstance;
import dev.loki.lovisual.features.relations.CategoryService; import dev.loki.lovisual.features.relations.CategoryService;
import dev.loki.lovisual.mixininterface.IEntity; import dev.loki.lovisual.mixininterface.IEntity;
import dev.loki.lovisual.render.engine.RenderState; import dev.loki.lovisual.render.engine.RenderState;
import dev.loki.lovisual.render.engine.TextureStorage; import dev.loki.lovisual.render.engine.TextureStorage;
import dev.loki.lovisual.render.engine.animation.AnimationUtility; import dev.loki.lovisual.render.engine.animation.AnimationUtility;
import dev.loki.lovisual.render.engine.animation.AnimatedRenderColors; import dev.loki.lovisual.render.engine.animation.AnimatedRenderColors;
import dev.loki.lovisual.render.engine.math.RenderMath;
import dev.loki.lovisual.render.engine.pipeline.LoVisualRenderPipelines; import dev.loki.lovisual.render.engine.pipeline.LoVisualRenderPipelines;
import dev.loki.lovisual.render.engine.renderer.Renderer3D; import dev.loki.lovisual.render.engine.renderer.Renderer3D;
import dev.loki.lovisual.render.engine.uniform.MeshBuilder; import dev.loki.lovisual.render.engine.uniform.MeshBuilder;
@ -44,6 +42,14 @@ import java.util.ArrayList;
import java.util.List; import java.util.List;
import java.util.function.IntFunction; import java.util.function.IntFunction;
import static dev.loki.lovisual.features.module.modules.combat.targetesp.TargetEspEffects.renderBlackhole;
import static dev.loki.lovisual.features.module.modules.combat.targetesp.TargetEspEffects.renderGhosts;
import static dev.loki.lovisual.features.module.modules.combat.targetesp.TargetEspModes.drawOldTargetEsp;
import static dev.loki.lovisual.features.module.modules.combat.targetesp.TargetEspModes.renderCaptureMark;
import static dev.loki.lovisual.features.module.modules.combat.targetesp.TargetEspModes.renderVortex;
import static dev.loki.lovisual.features.module.modules.combat.targetesp.TargetEspModes.setCaptureSpeedScale;
import static dev.loki.lovisual.features.module.modules.combat.targetesp.TargetEspModes.tickTargetEsp;
//todo Description //todo Description
@ModuleInfo(id = "targetesp", displayName = "TargetESP", aliases = {"target", "targetrender"}, category = ModuleCategory.COMBAT) @ModuleInfo(id = "targetesp", displayName = "TargetESP", aliases = {"target", "targetrender"}, category = ModuleCategory.COMBAT)
public class TargetESP extends Module { public class TargetESP extends Module {
@ -69,20 +75,9 @@ public class TargetESP extends Module {
private static final String SETTING_BLACKHOLE_SPIN_SPEED = "blackhole_spin_speed"; private static final String SETTING_BLACKHOLE_SPIN_SPEED = "blackhole_spin_speed";
private static final String SETTING_BLACKHOLE_STYLE = "blackhole_style"; private static final String SETTING_BLACKHOLE_STYLE = "blackhole_style";
private static final String SETTING_VORTEX_SPEED = "vortex_speed"; private static final String SETTING_VORTEX_SPEED = "vortex_speed";
private static final Identifier GHOST_PARTICLE_TEXTURE = TextureStorage.FIRE_FLY;
private static final Identifier CAPTURE_MARK_TEXTURE = TextureStorage.CAPTURE;
private static final Identifier CRYSTAL_BLOOM = TextureStorage.BLOOM; private static final Identifier CRYSTAL_BLOOM = TextureStorage.BLOOM;
private static final float CRYSTAL_BASE_SIZE = 0.05f;
private static final int CRYSTAL_SIDES = 8;
private static final float ALPHA_TICK_DT = 1.0f / 20.0f; private static final float ALPHA_TICK_DT = 1.0f / 20.0f;
private static final float ALPHA_SNAP_EPSILON = 0.02f; private static final float ALPHA_SNAP_EPSILON = 0.02f;
private static float prevCircleStep;
private static float circleStep;
private static double captureValue = 1.0;
private static double prevCaptureValue;
private static double captureSpeed = 1.0;
private static double captureSpeedScale = 1.0;
private static boolean captureFlipSpeed;
private final Minecraft mc = Minecraft.getInstance(); private final Minecraft mc = Minecraft.getInstance();
private final ModeValue espMode = modeSetting( private final ModeValue espMode = modeSetting(
"targetEspMode", "targetEspMode",
@ -140,602 +135,6 @@ public class TargetESP extends Module {
private Entity lastCrystalTarget; private Entity lastCrystalTarget;
private float crystalRotationAngle; private float crystalRotationAngle;
private static void tickTargetEsp() {
prevCircleStep = circleStep;
circleStep += 0.15f;
prevCaptureValue = captureValue;
captureValue += captureSpeed * captureSpeedScale;
if (captureSpeed > 25.0) captureFlipSpeed = true;
if (captureSpeed < -25.0) captureFlipSpeed = false;
captureSpeed = captureFlipSpeed
? captureSpeed - 0.5 * captureSpeedScale
: captureSpeed + 0.5 * captureSpeedScale;
}
private static void setCaptureSpeedScale(float scale) {
if (scale <= 0f) {
captureSpeedScale = 0.05;
} else {
captureSpeedScale = scale;
}
}
private static void drawOldTargetEsp(Renderer3D renderer, Entity target, float tickDelta, IntFunction<Integer> palette) {
if (renderer == null || target == null) return;
double cs = prevCircleStep + (circleStep - prevCircleStep) * tickDelta;
double prevSinAnim = absSinAnimation(cs - 0.45f);
double sinAnim = absSinAnimation(cs);
Vec3 pos = RenderMath.getLerpedPos(target, tickDelta);
double x = pos.x;
double y = pos.y + prevSinAnim * target.getBbHeight();
double z = pos.z;
double nextY = pos.y + sinAnim * target.getBbHeight();
MeshBuilder mesh = renderer.tris();
final int segments = 96;
final double step = Math.PI * 2.0 / segments;
final double radius = target.getBbWidth() * 0.8;
for (int i = 0; i < segments; i++) {
double a0 = i * step;
double a1 = (i + 1) * step;
float cos = (float) (x + Math.cos(a0) * radius);
float sin = (float) (z + Math.sin(a0) * radius);
float cosNext = (float) (x + Math.cos(a1) * radius);
float sinNext = (float) (z + Math.sin(a1) * radius);
int idx0 = (int) Math.round(i * (360.0 / segments));
int idx1 = (int) Math.round((i + 1) * (360.0 / segments));
int cTop0 = applyOpacity(palette.apply(idx0), 170.0f / 255.0f);
int cTop1 = applyOpacity(palette.apply(idx1), 170.0f / 255.0f);
int cBottom0 = applyOpacity(palette.apply(idx0), 0.0f);
int cBottom1 = applyOpacity(palette.apply(idx1), 0.0f);
mesh.ensureQuadCapacity();
int i1 = addVertex(mesh, cos, nextY, sin, cTop0);
int i2 = addVertex(mesh, cosNext, nextY, sinNext, cTop1);
int i3 = addVertex(mesh, cosNext, y, sinNext, cBottom1);
int i4 = addVertex(mesh, cos, y, sin, cBottom0);
mesh.quad(i1, i2, i3, i4);
}
}
private static void renderGhosts(Renderer3D renderer,
int espLength, int factor, float shaking, float amplitude,
Entity target, float tickDelta, IntFunction<Integer> palette) {
Minecraft mc = Minecraft.getInstance();
if (renderer == null || mc == null || mc.player == null) return;
Vec3 pos = RenderMath.getLerpedPos(target, tickDelta);
double tPosX = pos.x;
double tPosY = pos.y;
double tPosZ = pos.z;
float iAge = (float) interpolate(target.tickCount - 1, target.tickCount, tickDelta);
MeshBuilder mesh = renderer.batchTextured(
LoVisualRenderPipelines.WORLD_TEXTURED_ADDITIVE,
GHOST_PARTICLE_TEXTURE,
Renderer3D.DepthMode.MAIN
);
if (mesh == null) return;
for (int j = 0; j < 3; j++) {
for (int i = 0; i <= espLength; i++) {
double radians = Math.toRadians((((float) i / 1.5f + iAge) * factor + (j * 120)) % (factor * 360));
double sinQuad = Math.sin(Math.toRadians(iAge * 2.5f + i * (j + 1)) * amplitude) / shaking;
float offset = ((float) i / espLength);
int color = applyOpacity(palette.apply((int) (180 * offset)), offset);
float scale = Math.max(0.24f * (offset), 0.2f);
double cx = tPosX + Math.cos(radians) * target.getBbWidth();
double cy = tPosY + 1 + sinQuad;
double cz = tPosZ + Math.sin(radians) * target.getBbWidth();
addBillboardQuad(mesh, cx, cy, cz, scale, color);
}
}
}
private static final int VORTEX_HELIX_STEPS = 28;
private static final int VORTEX_RING_SEGMENTS = 24;
private static void renderVortex(Renderer3D renderer,
LivingEntity target,
float tickDelta,
IntFunction<Integer> palette,
float spinSpeed) {
if (renderer == null || target == null) return;
Vec3 pos = RenderMath.getLerpedPos(target, tickDelta);
float radius = target.getBbWidth() * 0.7f;
float height = target.getBbHeight();
double time = (GameClock.millis() / 1000.0) * spinSpeed;
float prevWidth = RenderState.lineWidth;
RenderState.lineWidth = 1.4f;
MeshBuilder mesh;
try {
mesh = renderer.batch(LoVisualRenderPipelines.WORLD_COLORED_LINES, Renderer3D.DepthMode.NONE);
} finally {
RenderState.lineWidth = prevWidth;
}
if (mesh == null) return;
for (int strand = 0; strand < 2; strand++) {
double strandOffset = strand * Math.PI;
int prevIndex = -1;
for (int i = 0; i <= VORTEX_HELIX_STEPS; i++) {
double t = (double) i / VORTEX_HELIX_STEPS;
double angle = time * 2.4 + t * Math.PI * 3.0 + strandOffset;
double r = radius * (0.85 + 0.15 * Math.sin(t * Math.PI));
double x = pos.x + Math.cos(angle) * r;
double y = pos.y + t * height;
double z = pos.z + Math.sin(angle) * r;
int color = applyOpacity(palette.apply((int) (t * 180)), (float) (1.0 - t * 0.35));
int index = addVertex(mesh, x, y, z, color);
if (prevIndex >= 0) {
mesh.line(prevIndex, index);
}
prevIndex = index;
}
}
float[] ringHeights = {0.05f, height * 0.5f, height * 0.95f};
float[] ringSpeeds = {0.4f, -0.55f, 0.35f};
for (int ring = 0; ring < ringHeights.length; ring++) {
double ringY = pos.y + ringHeights[ring];
double ringRotation = time * ringSpeeds[ring];
int ringColor = applyOpacity(palette.apply(60), 0.4f);
int prevIndex = -1;
int firstIndex = -1;
for (int i = 0; i <= VORTEX_RING_SEGMENTS; i++) {
double angle = ringRotation + (i % VORTEX_RING_SEGMENTS) * 2.0 * Math.PI / VORTEX_RING_SEGMENTS;
double x = pos.x + Math.cos(angle) * radius;
double z = pos.z + Math.sin(angle) * radius;
int index = addVertex(mesh, x, ringY, z, ringColor);
if (firstIndex < 0) {
firstIndex = index;
}
if (prevIndex >= 0) {
mesh.line(prevIndex, index);
}
prevIndex = index;
}
}
}
private static void renderCaptureMark(Renderer3D renderer,
Entity target,
float tickDelta,
IntFunction<Integer> palette) {
if (renderer == null) return;
Vec3 pos = RenderMath.getLerpedPos(target, tickDelta);
double tPosX = pos.x;
double tPosY = pos.y;
double tPosZ = pos.z;
MeshBuilder mesh = renderer.batchTextured(
LoVisualRenderPipelines.WORLD_TEXTURED_ADDITIVE,
CAPTURE_MARK_TEXTURE,
Renderer3D.DepthMode.MAIN
);
if (mesh == null) return;
float angle = interpolateFloat((float) prevCaptureValue, (float) captureValue, tickDelta);
float size = 0.75f;
double cy = tPosY + target.getEyeHeight(target.getPose()) / 2f;
addRotatedBillboard(mesh, tPosX, cy, tPosZ, size, angle, palette);
}
private static final float[][] BODY_HOLES = {
{0.0f, 0.60f}, // голова
{-0.48f, 0.36f}, // левая рука
{0.48f, 0.36f}, // правая рука
{0.0f, 0.05f}, // грудь
{-0.30f, -0.42f},// левая нога
{0.30f, -0.42f} // правая нога
};
private static void renderBlackhole(Renderer3D renderer,
LivingEntity target,
float tickDelta,
IntFunction<Integer> palette,
float size,
float spinSpeed,
float alpha,
String style) {
if (renderer == null || target == null) return;
Minecraft mc = Minecraft.getInstance();
if (mc.gameRenderer == null || mc.gameRenderer.mainCamera() == null) return;
Vec3 pos = RenderMath.getLerpedPos(target, tickDelta);
Vec3 baseCenter = new Vec3(pos.x, pos.y + target.getBbHeight() * 0.5, pos.z);
// Камера-зависимый billboard-базис: правый/верх в мировых координатах
Camera cam = mc.gameRenderer.mainCamera();
float yaw = (float) Math.toRadians(cam.yRot());
float pitch = (float) Math.toRadians(cam.xRot());
Vec3 fwd = new Vec3(-Math.sin(yaw) * Math.cos(pitch), -Math.sin(pitch), Math.cos(yaw) * Math.cos(pitch)).normalize();
Vec3 rgt = new Vec3(0.0, 1.0, 0.0).cross(fwd);
if (rgt.lengthSqr() < 1.0E-6) {
rgt = new Vec3(1.0, 0.0, 0.0);
} else {
rgt = rgt.normalize();
}
Vec3 upp = fwd.cross(rgt).normalize();
Vector3f right = new Vector3f((float) rgt.x, (float) rgt.y, (float) rgt.z);
Vector3f up = new Vector3f((float) upp.x, (float) upp.y, (float) upp.z);
MeshBuilder diskMesh = renderer.batch(
LoVisualRenderPipelines.WORLD_COLORED_ADDITIVE_DEPTH,
Renderer3D.DepthMode.PRE_DEPTH
);
MeshBuilder coreMesh = renderer.batch(
LoVisualRenderPipelines.WORLD_COLORED_DEPTH,
Renderer3D.DepthMode.PRE_DEPTH
);
if (diskMesh == null || coreMesh == null) return;
float time = (GameClock.millis() % 100000L) / 1000.0f;
if ("Body".equals(style)) {
float holeSize = size * 0.34f;
for (float[] off : BODY_HOLES) {
Vec3 center = new Vec3(
baseCenter.x + rgt.x * off[0] + upp.x * off[1],
baseCenter.y + rgt.y * off[0] + upp.y * off[1],
baseCenter.z + rgt.z * off[0] + upp.z * off[1]
);
appendBlackhole(diskMesh, coreMesh, center, right, up, holeSize, time, spinSpeed, palette, alpha);
}
} else {
appendBlackhole(diskMesh, coreMesh, baseCenter, right, up, size, time, spinSpeed, palette, alpha);
}
}
private static void appendBlackhole(MeshBuilder diskMesh,
MeshBuilder coreMesh,
Vec3 center,
Vector3f right,
Vector3f up,
float size,
float time,
float spinSpeed,
IntFunction<Integer> palette,
float alpha) {
float horizon = 0.35f * size;
int rings = 4;
int segs = 64;
// аккреционный диск: вращающаяся спираль на аддитивном бленде
for (int ring = 0; ring < rings; ring++) {
float r0 = horizon + (size - horizon) * (ring / (float) rings);
float r1 = horizon + (size - horizon) * ((ring + 1) / (float) rings);
for (int s = 0; s < segs; s++) {
float a0 = (float) (Math.PI * 2.0 * s / segs);
float a1 = (float) (Math.PI * 2.0 * (s + 1) / segs);
int v0 = addBlackholeVertex(diskMesh, center, right, up, r0, a0, horizon, size, time, spinSpeed, palette);
int v1 = addBlackholeVertex(diskMesh, center, right, up, r1, a0, horizon, size, time, spinSpeed, palette);
int v2 = addBlackholeVertex(diskMesh, center, right, up, r1, a1, horizon, size, time, spinSpeed, palette);
int v3 = addBlackholeVertex(diskMesh, center, right, up, r0, a1, horizon, size, time, spinSpeed, palette);
diskMesh.triangle(v0, v1, v2);
diskMesh.triangle(v0, v2, v3);
}
}
// чёрное ядро закрывает центр диска
int coreArgb = applyOpacity(0xFF080808, alpha);
int coreCenter = addBlackholeCoreVertex(coreMesh, center, right, up, 0f, 0f, coreArgb);
int coreSegs = 32;
for (int s = 0; s < coreSegs; s++) {
float a0 = (float) (Math.PI * 2.0 * s / coreSegs);
float a1 = (float) (Math.PI * 2.0 * (s + 1) / coreSegs);
int c0 = addBlackholeCoreVertex(coreMesh, center, right, up, horizon, a0, coreArgb);
int c1 = addBlackholeCoreVertex(coreMesh, center, right, up, horizon, a1, coreArgb);
coreMesh.triangle(coreCenter, c1, c0);
}
}
private static int addBlackholeVertex(MeshBuilder mesh,
Vec3 center,
Vector3f right,
Vector3f up,
float r,
float angle,
float horizon,
float size,
float time,
float spinSpeed,
IntFunction<Integer> palette) {
float dist = r / size;
float rot = time * 2.5f * spinSpeed + 1.3f / (dist + 0.05f);
float ca = (float) Math.cos(rot);
float sa = (float) Math.sin(rot);
float ux = dist * (float) Math.cos(angle);
float uy = dist * (float) Math.sin(angle);
float rx = ux * ca - uy * sa;
float ry = ux * sa + uy * ca;
float spiral = Math.abs((float) Math.sin(rx * 14.0f + ry * 4.0f) * 0.5f + 0.5f);
float falloff = (float) Math.pow(horizon / Math.max(dist, 0.001f), 2.2);
float outerFade = smoothstep(1.0f, horizon, dist);
float brightness = falloff * (0.55f + 0.45f * spiral) * outerFade;
int color = palette.apply((int) ((Math.toDegrees(angle) + time * 90f * spinSpeed) % 360f));
color = applyOpacity(color, brightness);
float wx = (float) center.x + right.x() * ux * size + up.x() * uy * size;
float wy = (float) center.y + right.y() * ux * size + up.y() * uy * size;
float wz = (float) center.z + right.z() * ux * size + up.z() * uy * size;
return addVertex(mesh, wx, wy, wz, color);
}
private static int addBlackholeCoreVertex(MeshBuilder mesh,
Vec3 center,
Vector3f right,
Vector3f up,
float r,
float angle,
int argb) {
float ux = r * (float) Math.cos(angle);
float uy = r * (float) Math.sin(angle);
float wx = (float) center.x + right.x() * ux + up.x() * uy;
float wy = (float) center.y + right.y() * ux + up.y() * uy;
float wz = (float) center.z + right.z() * ux + up.z() * uy;
return addVertex(mesh, wx, wy, wz, argb);
}
private static float smoothstep(float edge0, float edge1, float x) {
float t = (x - edge0) / (edge1 - edge0);
t = Math.max(0f, Math.min(1f, t));
return t * t * (3f - 2f * t);
}
private static void appendCrystalGeometry(MeshBuilder mesh,
Vec3 targetPos,
CrystalInstance crystal,
float orbitYawRad,
float scale,
int argb,
boolean filled) {
if (mesh == null || crystal == null || targetPos == null) {
return;
}
float s = CRYSTAL_BASE_SIZE * scale;
float hPrism = s;
float hPyramid = s * 1.5f;
Vector3f[] topVertices = new Vector3f[CRYSTAL_SIDES];
Vector3f[] bottomVertices = new Vector3f[CRYSTAL_SIDES];
for (int i = 0; i < CRYSTAL_SIDES; i++) {
float angle = (float) (2.0 * Math.PI * i / CRYSTAL_SIDES);
float x = (float) (s * Math.cos(angle));
float z = (float) (s * Math.sin(angle));
topVertices[i] = new Vector3f(x, hPrism * 0.5f, z);
bottomVertices[i] = new Vector3f(x, -hPrism * 0.5f, z);
}
Vector3f topPeak = new Vector3f(0.0f, hPrism * 0.5f + hPyramid, 0.0f);
Vector3f bottomPeak = new Vector3f(0.0f, -hPrism * 0.5f - hPyramid, 0.0f);
if (filled) {
for (int i = 0; i < CRYSTAL_SIDES; i++) {
Vector3f v1 = transformCrystalVertex(bottomVertices[i], crystal, orbitYawRad, targetPos);
Vector3f v2 = transformCrystalVertex(bottomVertices[(i + 1) % CRYSTAL_SIDES], crystal, orbitYawRad, targetPos);
Vector3f v3 = transformCrystalVertex(topVertices[(i + 1) % CRYSTAL_SIDES], crystal, orbitYawRad, targetPos);
Vector3f v4 = transformCrystalVertex(topVertices[i], crystal, orbitYawRad, targetPos);
addTriangle(mesh, v1, v2, v3, argb);
addTriangle(mesh, v1, v3, v4, argb);
}
for (int i = 0; i < CRYSTAL_SIDES; i++) {
Vector3f v1 = transformCrystalVertex(topPeak, crystal, orbitYawRad, targetPos);
Vector3f v2 = transformCrystalVertex(topVertices[i], crystal, orbitYawRad, targetPos);
Vector3f v3 = transformCrystalVertex(topVertices[(i + 1) % CRYSTAL_SIDES], crystal, orbitYawRad, targetPos);
addTriangle(mesh, v1, v2, v3, argb);
}
for (int i = 0; i < CRYSTAL_SIDES; i++) {
Vector3f v1 = transformCrystalVertex(bottomPeak, crystal, orbitYawRad, targetPos);
Vector3f v2 = transformCrystalVertex(bottomVertices[(i + 1) % CRYSTAL_SIDES], crystal, orbitYawRad, targetPos);
Vector3f v3 = transformCrystalVertex(bottomVertices[i], crystal, orbitYawRad, targetPos);
addTriangle(mesh, v1, v2, v3, argb);
}
return;
}
for (int i = 0; i < CRYSTAL_SIDES; i++) {
Vector3f bottomA = transformCrystalVertex(bottomVertices[i], crystal, orbitYawRad, targetPos);
Vector3f bottomB = transformCrystalVertex(bottomVertices[(i + 1) % CRYSTAL_SIDES], crystal, orbitYawRad, targetPos);
Vector3f topA = transformCrystalVertex(topVertices[i], crystal, orbitYawRad, targetPos);
Vector3f topB = transformCrystalVertex(topVertices[(i + 1) % CRYSTAL_SIDES], crystal, orbitYawRad, targetPos);
addLine(mesh, bottomA, bottomB, argb);
addLine(mesh, topA, topB, argb);
addLine(mesh, bottomA, topA, argb);
addLine(mesh, transformCrystalVertex(topPeak, crystal, orbitYawRad, targetPos), topA, argb);
addLine(mesh, transformCrystalVertex(bottomPeak, crystal, orbitYawRad, targetPos), bottomA, argb);
}
}
private static Vector3f transformCrystalVertex(Vector3f vertex,
CrystalInstance crystal,
float orbitYawRad,
Vec3 targetPos) {
Vector3f transformed = new Vector3f(vertex).mul(crystal.pulsation());
transformed.rotateX((float) Math.toRadians(crystal.rotation.x));
transformed.rotateY((float) Math.toRadians(crystal.rotation.y + crystal.selfRotationDeg()));
transformed.rotateZ((float) Math.toRadians(crystal.rotation.z));
transformed.add((float) crystal.position.x, (float) crystal.position.y, (float) crystal.position.z);
transformed.rotateY(orbitYawRad);
transformed.add((float) targetPos.x, (float) targetPos.y, (float) targetPos.z);
return transformed;
}
private static void appendBloomSphere(MeshBuilder mesh,
Vec3 targetPos,
CrystalInstance crystal,
float orbitYawRad,
int argb) {
if (mesh == null || crystal == null || targetPos == null) {
return;
}
Vector3f center = new Vector3f((float) crystal.position.x, (float) crystal.position.y, (float) crystal.position.z);
center.rotateY(orbitYawRad);
center.add((float) targetPos.x, (float) targetPos.y, (float) targetPos.z);
float size = CRYSTAL_BASE_SIZE * 13.0f;
Quaternionf camRot = RenderState.cameraRotation;
float yaw = orbitYawRad;
int bloomColor = applyOpacity(argb, (0.4f * 25.0f) / 255.0f);
for (int i = 0; i < 6; i++) {
float angle = (360.0f / 6.0f) * i;
Quaternionf rotation = new Quaternionf()
.rotateY((float) Math.toRadians(angle))
.rotateY(yaw)
.mul(camRot);
addTexturedBillboard(mesh, center.x, center.y, center.z, size, rotation, bloomColor);
}
for (int i = 0; i < 6; i++) {
float angle = (360.0f / 6.0f) * i;
Quaternionf rotation = new Quaternionf()
.rotateX((float) Math.toRadians(90.0f))
.rotateY((float) Math.toRadians(angle))
.rotateY(yaw)
.mul(camRot);
addTexturedBillboard(mesh, center.x, center.y, center.z, size, rotation, bloomColor);
}
}
private static void addTexturedBillboard(MeshBuilder mesh,
double cx, double cy, double cz,
float size, Quaternionf rotation, int argb) {
Vector3f right = new Vector3f(1.0f, 0.0f, 0.0f).rotate(rotation).mul(size * 0.5f);
Vector3f up = new Vector3f(0.0f, 1.0f, 0.0f).rotate(rotation).mul(size * 0.5f);
addBillboardVertices(mesh, cx, cy, cz, right, up, argb, argb, argb, argb);
}
private static void addTriangle(MeshBuilder mesh, Vector3f v1, Vector3f v2, Vector3f v3, int argb) {
mesh.ensureTriCapacity();
int i1 = addVertex(mesh, v1.x, v1.y, v1.z, argb);
int i2 = addVertex(mesh, v2.x, v2.y, v2.z, argb);
int i3 = addVertex(mesh, v3.x, v3.y, v3.z, argb);
mesh.triangle(i1, i2, i3);
}
private static void addLine(MeshBuilder mesh, Vector3f v1, Vector3f v2, int argb) {
mesh.ensureLineCapacity();
int i1 = addVertex(mesh, v1.x, v1.y, v1.z, argb);
int i2 = addVertex(mesh, v2.x, v2.y, v2.z, argb);
mesh.line(i1, i2);
}
private static void addRotatedBillboard(MeshBuilder mesh, double cx, double cy, double cz,
float size, float angle, IntFunction<Integer> palette) {
Quaternionf camRot = RenderState.cameraRotation;
Vector3f right = new Vector3f(1, 0, 0).rotate(camRot);
Vector3f up = new Vector3f(0, 1, 0).rotate(camRot);
Vector3f r = new Vector3f(right).mul(size);
Vector3f u = new Vector3f(up).mul(size);
float sin = Mth.sin((float) Math.toRadians(angle));
float cos = Mth.cos((float) Math.toRadians(angle));
Vector3f rRot = new Vector3f(
r.x() * cos - u.x() * sin,
r.y() * cos - u.y() * sin,
r.z() * cos - u.z() * sin
);
Vector3f uRot = new Vector3f(
r.x() * sin + u.x() * cos,
r.y() * sin + u.y() * cos,
r.z() * sin + u.z() * cos
);
addBillboardVertices(mesh, cx, cy, cz, rRot, uRot,
palette.apply(90),
palette.apply(0),
palette.apply(180),
palette.apply(270));
}
private static void addBillboardQuad(MeshBuilder mesh,
double cx, double cy, double cz,
float size, int argb) {
Quaternionf camRot = RenderState.cameraRotation;
Vector3f right = new Vector3f(1, 0, 0).rotate(camRot).mul(size);
Vector3f up = new Vector3f(0, 1, 0).rotate(camRot).mul(size);
addBillboardVertices(mesh, cx, cy, cz, right, up, argb, argb, argb, argb);
}
private static void addBillboardVertices(MeshBuilder mesh, double cx, double cy, double cz,
Vector3f right, Vector3f up,
int c1, int c2, int c3, int c4) {
double p1x = cx - right.x() - up.x();
double p1y = cy - right.y() - up.y();
double p1z = cz - right.z() - up.z();
double p2x = cx + right.x() - up.x();
double p2y = cy + right.y() - up.y();
double p2z = cz + right.z() - up.z();
double p3x = cx + right.x() + up.x();
double p3y = cy + right.y() + up.y();
double p3z = cz + right.z() + up.z();
double p4x = cx - right.x() + up.x();
double p4y = cy - right.y() + up.y();
double p4z = cz - right.z() + up.z();
mesh.ensureQuadCapacity();
int i1 = addVertex(mesh, p1x, p1y, p1z, 0, 1, c1);
int i2 = addVertex(mesh, p2x, p2y, p2z, 1, 1, c2);
int i3 = addVertex(mesh, p3x, p3y, p3z, 1, 0, c3);
int i4 = addVertex(mesh, p4x, p4y, p4z, 0, 0, c4);
mesh.quad(i1, i2, i3, i4);
}
private static int applyOpacity(int argb, float opacity) {
opacity = Math.min(1f, Math.max(0f, opacity));
int baseA = (argb >>> 24) & 0xFF;
int a = Mth.clamp((int) (baseA * opacity), 0, 255);
return (a << 24) | (argb & 0x00FFFFFF);
}
private static int addVertex(MeshBuilder mesh, double x, double y, double z, int argb) {
int a = (argb >>> 24) & 0xFF;
int r = (argb >>> 16) & 0xFF;
int g = (argb >>> 8) & 0xFF;
int b = argb & 0xFF;
mesh.vec3(x, y, z).color(r, g, b, a);
return mesh.next();
}
private static int addVertex(MeshBuilder mesh, double x, double y, double z, double u, double v, int argb) {
int a = (argb >>> 24) & 0xFF;
int r = (argb >>> 16) & 0xFF;
int g = (argb >>> 8) & 0xFF;
int b = argb & 0xFF;
mesh.vec3(x, y, z).vec2(u, v).color(r, g, b, a);
return mesh.next();
}
private static double absSinAnimation(double input) {
return Math.abs(1 + Math.sin(input)) / 2;
}
private static double interpolate(double oldValue, double newValue, double interpolationValue) {
return (oldValue + (newValue - oldValue) * interpolationValue);
}
private static float interpolateFloat(float oldValue, float newValue, double interpolationValue) {
return (float) interpolate(oldValue, newValue, interpolationValue);
}
@Override @Override
public void onDisable() { public void onDisable() {
target = null; target = null;
@ -1024,38 +423,4 @@ public class TargetESP extends Module {
default -> AnimatedRenderColors.Mode.STATIC; default -> AnimatedRenderColors.Mode.STATIC;
}; };
} }
private static final class CrystalInstance {
private final Vec3 position;
private final Vec3 rotation;
private final float rotationSpeed;
private CrystalInstance(Vec3 position, Vec3 rotation) {
this.position = position;
this.rotation = rotation;
this.rotationSpeed = 0.5f + (float) (Math.random() * 1.5f);
}
private float selfRotationDeg() {
return (GameClock.millis() % 36000L) / 100.0f * rotationSpeed;
}
private float pulsation() {
return 1.0f + (float) (Math.sin(GameClock.millis() / 500.0) * 0.1f);
}
private void append(MeshBuilder additiveMesh,
MeshBuilder fillMesh,
MeshBuilder lineMesh,
MeshBuilder bloomMesh,
Vec3 targetPos,
float orbitYawRad,
int baseColor) {
appendCrystalGeometry(additiveMesh, targetPos, this, orbitYawRad, 1.0f, applyOpacity(baseColor, 0.2f), true);
appendCrystalGeometry(fillMesh, targetPos, this, orbitYawRad, 1.0f, applyOpacity(baseColor, 0.3f), true);
appendCrystalGeometry(lineMesh, targetPos, this, orbitYawRad, 1.0f, applyOpacity(baseColor, 0.8f), false);
appendCrystalGeometry(additiveMesh, targetPos, this, orbitYawRad, 1.2f, applyOpacity(baseColor, 0.3f), true);
appendBloomSphere(bloomMesh, targetPos, this, orbitYawRad, baseColor);
}
}
} }

View file

@ -0,0 +1,190 @@
/*
* This file is part of the LoVisual Client distribution.
* Copyright (c) 2026 loki5512344.
*
* Licensed under the GNU General Public License v3.0.
*/
package dev.loki.lovisual.features.module.modules.combat.predictions;
import dev.loki.lovisual.config.values.BooleanValue;
import dev.loki.lovisual.config.values.NumberValue;
import dev.loki.lovisual.config.values.RGBColorValue;
import dev.loki.lovisual.features.module.modules.combat.predictions.PredictionsModel.AimPrediction;
import dev.loki.lovisual.features.module.modules.combat.predictions.PredictionsModel.Kind;
import dev.loki.lovisual.features.module.modules.combat.predictions.PredictionsModel.TrajectoryResult;
import net.minecraft.client.Minecraft;
import net.minecraft.client.multiplayer.ClientLevel;
import net.minecraft.util.Mth;
import net.minecraft.world.InteractionHand;
import net.minecraft.world.entity.Entity;
import net.minecraft.world.entity.LivingEntity;
import net.minecraft.world.entity.player.Player;
import net.minecraft.world.entity.projectile.Projectile;
import net.minecraft.world.entity.projectile.arrow.Arrow;
import net.minecraft.world.entity.projectile.arrow.ThrownTrident;
import net.minecraft.world.entity.projectile.hurtingprojectile.SmallFireball;
import net.minecraft.world.entity.projectile.hurtingprojectile.windcharge.WindCharge;
import net.minecraft.world.entity.projectile.throwableitemprojectile.*;
import net.minecraft.world.item.BowItem;
import net.minecraft.world.item.CrossbowItem;
import net.minecraft.world.item.ItemStack;
import net.minecraft.world.item.Items;
import net.minecraft.world.phys.Vec3;
/**
* Held-item aim prediction for the Predictions module: builds the hypothetical
* projectile for the item in hand and simulates its trajectory.
*/
public final class PredictionsAim {
private PredictionsAim() {
}
public static AimPrediction computeAimPrediction(Player player, float tickDelta,
NumberValue<Integer> maxSimTicks,
NumberValue<Integer> pearlMaxSimTicks,
BooleanValue pearlColorToggle,
RGBColorValue pearlColorValue) {
Projectile projectile = createHeldProjectile(player, tickDelta);
if (projectile == null) {
return null;
}
Kind kind = PredictionsModel.kindFromProjectile(projectile);
TrajectoryResult result = PredictionsSimulation.simulateEntity(projectile, kind, tickDelta,
maxSimTicks, pearlMaxSimTicks, pearlColorToggle, pearlColorValue);
if (result == null || result.points().isEmpty()) {
return null;
}
Vec3 hitPos = result.impactPos() != null ? result.impactPos() : result.points().getLast();
Entity hitEntity = result.hitEntity();
Vec3 anchorPos;
if (hitEntity instanceof LivingEntity living) {
anchorPos = living.getPosition(tickDelta).add(0.0, living.getBbHeight() * 0.5, 0.0);
} else if (hitEntity != null) {
anchorPos = hitEntity.getBoundingBox().getCenter();
} else {
anchorPos = hitPos;
}
return new AimPrediction(hitEntity, hitPos, anchorPos, result.impactNormal(), result.iconStack(), result);
}
private static Projectile createHeldProjectile(Player player, float tickDelta) {
Minecraft mc = Minecraft.getInstance();
ClientLevel world = mc.level;
if (world == null) {
return null;
}
InteractionHand hand = null;
ItemStack handStack = ItemStack.EMPTY;
ItemStack mainHand = player.getItemInHand(InteractionHand.MAIN_HAND);
if (isAimUsable(player, mainHand, InteractionHand.MAIN_HAND)) {
hand = InteractionHand.MAIN_HAND;
handStack = mainHand;
} else {
ItemStack offHand = player.getItemInHand(InteractionHand.OFF_HAND);
if (isAimUsable(player, offHand, InteractionHand.OFF_HAND)) {
hand = InteractionHand.OFF_HAND;
handStack = offHand;
}
}
if (hand == null || handStack.isEmpty()) {
return null;
}
Projectile projectile = null;
float speed = 0.0f;
float roll = 0.0f;
if (handStack.is(Items.ENDER_PEARL)) {
projectile = new ThrownEnderpearl(world, player, handStack);
speed = 1.5f;
} else if (handStack.is(Items.SNOWBALL)) {
projectile = new Snowball(world, player, handStack);
speed = 1.5f;
} else if (handStack.is(Items.EGG)) {
projectile = new ThrownEgg(world, player, handStack);
speed = 1.5f;
} else if (handStack.is(Items.SPLASH_POTION) || handStack.is(Items.LINGERING_POTION)) {
projectile = handStack.is(Items.LINGERING_POTION)
? new ThrownLingeringPotion(world, player, handStack)
: new ThrownSplashPotion(world, player, handStack);
speed = 0.5f;
roll = -20.0f;
} else if (handStack.is(Items.EXPERIENCE_BOTTLE)) {
projectile = new ThrownExperienceBottle(world, player, handStack);
speed = 0.7f;
roll = -20.0f;
} else if (handStack.is(Items.WIND_CHARGE)) {
projectile = new WindCharge(player, world, player.position().x, player.getEyePosition().y, player.position().z);
speed = 1.5f;
} else if (handStack.is(Items.FIRE_CHARGE)) {
projectile = new SmallFireball(world, player, Vec3.ZERO);
speed = 1.0f;
} else if (handStack.is(Items.TRIDENT) && player.isUsingItem() && player.getUsedItemHand() == hand) {
projectile = new ThrownTrident(world, player, handStack);
speed = 2.5f;
} else if (handStack.is(Items.BOW) && player.isUsingItem() && player.getUsedItemHand() == hand) {
float pull = BowItem.getPowerForTime(player.getTicksUsingItem());
if (pull <= 0.0f) {
return null;
}
ItemStack arrowStack = new ItemStack(Items.ARROW);
projectile = new Arrow(world, player, arrowStack, handStack);
speed = 3.0f * pull;
} else if (handStack.getItem() instanceof CrossbowItem && CrossbowItem.isCharged(handStack)) {
ItemStack arrowStack = new ItemStack(Items.ARROW);
projectile = new Arrow(world, player, arrowStack, handStack);
speed = 3.15f;
}
if (projectile == null) {
return null;
}
Vec3 eye = player.getEyePosition(tickDelta);
projectile.absSnapTo(eye.x, eye.y, eye.z);
setHeldProjectileVelocity(projectile, player, player.getXRot(), player.getYRot(), roll, speed);
return projectile;
}
private static void setHeldProjectileVelocity(Projectile projectile, Entity shooter, float pitch, float yaw, float roll, float speed) {
float x = -Mth.sin(yaw * ((float) Math.PI / 180.0f)) * Mth.cos(pitch * ((float) Math.PI / 180.0f));
float y = -Mth.sin((pitch + roll) * ((float) Math.PI / 180.0f));
float z = Mth.cos(yaw * ((float) Math.PI / 180.0f)) * Mth.cos(pitch * ((float) Math.PI / 180.0f));
Vec3 velocity = new Vec3(x, y, z).normalize().scale(speed);
projectile.setDeltaMovement(velocity);
Vec3 movement = shooter.getKnownMovement();
projectile.setDeltaMovement(projectile.getDeltaMovement().add(movement.x, shooter.onGround() ? 0.0 : movement.y, movement.z));
}
private static boolean isAimUsable(Player player, ItemStack stack, InteractionHand hand) {
if (stack == null || stack.isEmpty()) {
return false;
}
if (stack.is(Items.BOW) || stack.is(Items.TRIDENT)) {
return player.isUsingItem() && player.getUsedItemHand() == hand && player.getUseItem() == stack;
}
if (stack.is(Items.CROSSBOW)) {
return net.minecraft.world.item.CrossbowItem.isCharged(stack);
}
return stack.is(Items.ENDER_PEARL)
|| stack.is(Items.SNOWBALL)
|| stack.is(Items.EGG)
|| stack.is(Items.SPLASH_POTION)
|| stack.is(Items.LINGERING_POTION)
|| stack.is(Items.EXPERIENCE_BOTTLE)
|| stack.is(Items.FIRE_CHARGE)
|| stack.is(Items.WIND_CHARGE);
}
}

View file

@ -0,0 +1,194 @@
/*
* This file is part of the LoVisual Client distribution.
* Copyright (c) 2026 loki5512344.
*
* Licensed under the GNU General Public License v3.0.
*/
package dev.loki.lovisual.features.module.modules.combat.predictions;
import dev.loki.lovisual.render.engine.color.RenderColor;
import dev.loki.lovisual.render.engine.renderer.Renderer3D;
import dev.loki.lovisual.render.engine.uniform.MeshBuilder;
import net.minecraft.util.Mth;
import net.minecraft.world.entity.Entity;
import net.minecraft.world.phys.HitResult;
import net.minecraft.world.phys.Vec3;
import org.joml.Quaternionf;
import org.joml.Vector3f;
/**
* Low level mesh primitives, impact shapes and small vector/color math shared by
* the Predictions renderers.
*/
public final class PredictionsGeometry {
private PredictionsGeometry() {
}
private static final int IMPACT_RING_SEGMENTS = 64;
private static final float IMPACT_RING_CROSS_RADIUS = 0.18f;
private static final float IMPACT_DECAL_EDGE_SOFTNESS = 0.085f;
private static final float IMPACT_DECAL_STROKE_WIDTH = 0.10f;
private static final float IMPACT_DECAL_PATTERN_SCALE = 4.25f;
private static final float IMPACT_DECAL_FILL_ALPHA = 0.48f;
private static final float IMPACT_DECAL_STROKE_ALPHA = 0.92f;
private static final float IMPACT_DECAL_PATTERN_STRENGTH = 0.10f;
private static final float IMPACT_DECAL_QUAD_PADDING = 1.18f;
private static final double IMPACT_DECAL_OFFSET = 0.004;
public static void addGradientLine(MeshBuilder mesh, Vec3 from, Vec3 to, int startColor, int endColor) {
if (mesh == null || from == null || to == null) return;
mesh.ensureLineCapacity();
int i1 = mesh.vec3(from.x, from.y, from.z).color(new RenderColor(startColor)).next();
int i2 = mesh.vec3(to.x, to.y, to.z).color(new RenderColor(endColor)).next();
mesh.line(i1, i2);
}
public static void addImpactRing(MeshBuilder mesh, Vec3 center, Vec3 normal, float radius, int argb) {
if (mesh == null || center == null) return;
Vec3 useNormal = normal == null || normal.lengthSqr() < 1.0E-6
? new Vec3(0.0, 1.0, 0.0)
: normal.normalize();
Vec3 axis = Math.abs(useNormal.y) < 0.95 ? new Vec3(0.0, 1.0, 0.0) : new Vec3(1.0, 0.0, 0.0);
Vec3 tangent = useNormal.cross(axis);
if (tangent.lengthSqr() < 1.0E-6) {
tangent = new Vec3(1.0, 0.0, 0.0);
} else {
tangent = tangent.normalize();
}
Vec3 bitangent = useNormal.cross(tangent).normalize();
for (int i = 0; i < IMPACT_RING_SEGMENTS; i++) {
double angle0 = (Math.PI * 2.0 * i) / IMPACT_RING_SEGMENTS;
double angle1 = (Math.PI * 2.0 * (i + 1)) / IMPACT_RING_SEGMENTS;
Vec3 p0 = center
.add(tangent.scale(Math.cos(angle0) * radius))
.add(bitangent.scale(Math.sin(angle0) * radius));
Vec3 p1 = center
.add(tangent.scale(Math.cos(angle1) * radius))
.add(bitangent.scale(Math.sin(angle1) * radius));
addGradientLine(mesh, p0, p1, argb, argb);
}
Vec3 crossTangent = tangent.scale(IMPACT_RING_CROSS_RADIUS);
Vec3 crossBitangent = bitangent.scale(IMPACT_RING_CROSS_RADIUS);
addGradientLine(mesh, center.subtract(crossTangent), center.add(crossTangent), argb, argb);
addGradientLine(mesh, center.subtract(crossBitangent), center.add(crossBitangent), argb, argb);
}
public static void addImpactDecal(MeshBuilder mesh, Vec3 center, Vec3 normal, float radius, int argb) {
if (mesh == null || center == null || radius <= 0.0f) {
return;
}
Vec3 useNormal = normal == null || normal.lengthSqr() < 1.0E-6
? new Vec3(0.0, 1.0, 0.0)
: normal.normalize();
Vec3 axis = Math.abs(useNormal.y) < 0.95 ? new Vec3(0.0, 1.0, 0.0) : new Vec3(1.0, 0.0, 0.0);
Vec3 tangent = useNormal.cross(axis);
if (tangent.lengthSqr() < 1.0E-6) {
tangent = new Vec3(1.0, 0.0, 0.0);
} else {
tangent = tangent.normalize();
}
Vec3 bitangent = useNormal.cross(tangent).normalize();
Vec3 offsetCenter = center.add(useNormal.scale(IMPACT_DECAL_OFFSET));
float quadRadius = radius * IMPACT_DECAL_QUAD_PADDING;
float sdfRadius = 1.0f / IMPACT_DECAL_QUAD_PADDING;
Vec3 tangentScaled = tangent.scale(quadRadius);
Vec3 bitangentScaled = bitangent.scale(quadRadius);
mesh.ensureQuadCapacity();
int i1 = mesh.vec3(offsetCenter.subtract(tangentScaled).subtract(bitangentScaled).x,
offsetCenter.subtract(tangentScaled).subtract(bitangentScaled).y,
offsetCenter.subtract(tangentScaled).subtract(bitangentScaled).z)
.vec2(0.0, 0.0)
.color(new RenderColor(argb))
.vec4(sdfRadius, IMPACT_DECAL_EDGE_SOFTNESS, IMPACT_DECAL_STROKE_WIDTH, IMPACT_DECAL_PATTERN_SCALE)
.vec4(IMPACT_DECAL_FILL_ALPHA, IMPACT_DECAL_STROKE_ALPHA, IMPACT_DECAL_PATTERN_STRENGTH, 0.0f)
.next();
int i2 = mesh.vec3(offsetCenter.add(tangentScaled).subtract(bitangentScaled).x,
offsetCenter.add(tangentScaled).subtract(bitangentScaled).y,
offsetCenter.add(tangentScaled).subtract(bitangentScaled).z)
.vec2(1.0, 0.0)
.color(new RenderColor(argb))
.vec4(sdfRadius, IMPACT_DECAL_EDGE_SOFTNESS, IMPACT_DECAL_STROKE_WIDTH, IMPACT_DECAL_PATTERN_SCALE)
.vec4(IMPACT_DECAL_FILL_ALPHA, IMPACT_DECAL_STROKE_ALPHA, IMPACT_DECAL_PATTERN_STRENGTH, 0.0f)
.next();
int i3 = mesh.vec3(offsetCenter.add(tangentScaled).add(bitangentScaled).x,
offsetCenter.add(tangentScaled).add(bitangentScaled).y,
offsetCenter.add(tangentScaled).add(bitangentScaled).z)
.vec2(1.0, 1.0)
.color(new RenderColor(argb))
.vec4(sdfRadius, IMPACT_DECAL_EDGE_SOFTNESS, IMPACT_DECAL_STROKE_WIDTH, IMPACT_DECAL_PATTERN_SCALE)
.vec4(IMPACT_DECAL_FILL_ALPHA, IMPACT_DECAL_STROKE_ALPHA, IMPACT_DECAL_PATTERN_STRENGTH, 0.0f)
.next();
int i4 = mesh.vec3(offsetCenter.subtract(tangentScaled).add(bitangentScaled).x,
offsetCenter.subtract(tangentScaled).add(bitangentScaled).y,
offsetCenter.subtract(tangentScaled).add(bitangentScaled).z)
.vec2(0.0, 1.0)
.color(new RenderColor(argb))
.vec4(sdfRadius, IMPACT_DECAL_EDGE_SOFTNESS, IMPACT_DECAL_STROKE_WIDTH, IMPACT_DECAL_PATTERN_SCALE)
.vec4(IMPACT_DECAL_FILL_ALPHA, IMPACT_DECAL_STROKE_ALPHA, IMPACT_DECAL_PATTERN_STRENGTH, 0.0f)
.next();
mesh.quad(i1, i2, i3, i4);
}
public static void addBillboardQuad(MeshBuilder mesh, double cx, double cy, double cz,
float size, Quaternionf camRot, int argb) {
Vector3f right = new Vector3f(1.0f, 0.0f, 0.0f).rotate(camRot).mul(size);
Vector3f up = new Vector3f(0.0f, 1.0f, 0.0f).rotate(camRot).mul(size);
double p1x = cx - right.x() - up.x();
double p1y = cy - right.y() - up.y();
double p1z = cz - right.z() - up.z();
double p2x = cx + right.x() - up.x();
double p2y = cy + right.y() - up.y();
double p2z = cz + right.z() - up.z();
double p3x = cx + right.x() + up.x();
double p3y = cy + right.y() + up.y();
double p3z = cz + right.z() + up.z();
double p4x = cx - right.x() + up.x();
double p4y = cy - right.y() + up.y();
double p4z = cz - right.z() + up.z();
mesh.ensureQuadCapacity();
int i1 = mesh.vec3(p1x, p1y, p1z).vec2(0.0, 1.0).color(new RenderColor(argb)).next();
int i2 = mesh.vec3(p2x, p2y, p2z).vec2(1.0, 1.0).color(new RenderColor(argb)).next();
int i3 = mesh.vec3(p3x, p3y, p3z).vec2(1.0, 0.0).color(new RenderColor(argb)).next();
int i4 = mesh.vec3(p4x, p4y, p4z).vec2(0.0, 0.0).color(new RenderColor(argb)).next();
mesh.quad(i1, i2, i3, i4);
}
public static int multiplyAlpha(int argb, float alphaMultiplier) {
int baseAlpha = (argb >>> 24) & 0xFF;
int alpha = Math.max(0, Math.min(255, Math.round(baseAlpha * Math.max(0.0f, alphaMultiplier))));
return (argb & 0x00FFFFFF) | (alpha << 24);
}
public static int withAlpha(int argb, int alpha) {
return (argb & 0x00FFFFFF) | (Math.max(0, Math.min(255, alpha)) << 24);
}
public static double partialStepProgress(Vec3 from, Vec3 hitPos, double segmentLengthSq) {
if (from == null || hitPos == null || segmentLengthSq <= 1.0E-6) {
return 1.0;
}
return Mth.clamp(from.distanceTo(hitPos) / Math.sqrt(segmentLengthSq), 0.0, 1.0);
}
public static Vec3 resolveImpactNormal(HitResult hit, Vec3 velocity) {
if (hit instanceof net.minecraft.world.phys.BlockHitResult blockHit) {
return Vec3.atLowerCornerOf(blockHit.getDirection().getUnitVec3i());
}
if (velocity != null && velocity.lengthSqr() > 1.0E-6) {
return velocity.normalize();
}
return new Vec3(0.0, 1.0, 0.0);
}
public static Vec3 advanceTickVelocity(Entity entity, Vec3 velocity) {
return velocity.scale(0.99).add(0.0, -entity.getGravity(), 0.0);
}
}

View file

@ -0,0 +1,157 @@
/*
* This file is part of the LoVisual Client distribution.
* Copyright (c) 2026 loki5512344.
*
* Licensed under the GNU General Public License v3.0.
*/
package dev.loki.lovisual.features.module.modules.combat.predictions;
import net.minecraft.world.entity.Entity;
import net.minecraft.world.entity.projectile.FireworkRocketEntity;
import net.minecraft.world.entity.projectile.hurtingprojectile.Fireball;
import net.minecraft.world.entity.projectile.hurtingprojectile.windcharge.WindCharge;
import net.minecraft.world.entity.projectile.arrow.ThrownTrident;
import net.minecraft.world.entity.projectile.throwableitemprojectile.AbstractThrownPotion;
import net.minecraft.world.entity.projectile.throwableitemprojectile.Snowball;
import net.minecraft.world.entity.projectile.throwableitemprojectile.ThrownEgg;
import net.minecraft.world.entity.projectile.throwableitemprojectile.ThrownEnderpearl;
import net.minecraft.world.item.ItemStack;
import net.minecraft.world.item.Items;
import net.minecraft.world.phys.AABB;
import net.minecraft.world.phys.Vec3;
import java.util.List;
/**
* Shared value types and item/entity classification helpers for the Predictions module.
*/
public final class PredictionsModel {
private PredictionsModel() {
}
public enum Kind {
PEARL_ENTITY,
ARROW,
TRIDENT,
FIREBALL,
WIND_CHARGE,
FIREWORK,
SNOWBALL,
EGG,
POTION_SPLASH,
POTION_LINGERING
}
public record TrajectoryResult(List<Vec3> points, double flightTicks, long impactTick, ItemStack iconStack,
int argb, Kind kind, Vec3 impactPos, Vec3 impactNormal, Entity hitEntity,
boolean hitBlock) {
public TrajectoryResult(List<Vec3> points, double flightTicks, long impactTick, ItemStack iconStack,
int argb, Kind kind, Vec3 impactPos, Vec3 impactNormal, Entity hitEntity, boolean hitBlock) {
this.points = points;
this.flightTicks = flightTicks;
this.impactTick = impactTick;
this.iconStack = iconStack == null ? ItemStack.EMPTY : iconStack;
this.argb = argb;
this.kind = kind;
this.impactPos = impactPos;
this.impactNormal = impactNormal;
this.hitEntity = hitEntity;
this.hitBlock = hitBlock;
}
}
public record AimPrediction(Entity entity, Vec3 hitPos, Vec3 anchorPos, Vec3 hitNormal, ItemStack displayStack,
TrajectoryResult trajectory) {
public AimPrediction(Entity entity, Vec3 hitPos, Vec3 anchorPos, Vec3 hitNormal, ItemStack displayStack,
TrajectoryResult trajectory) {
this.entity = entity;
this.hitPos = hitPos;
this.anchorPos = anchorPos;
this.hitNormal = hitNormal;
this.displayStack = displayStack == null ? ItemStack.EMPTY : displayStack;
this.trajectory = trajectory;
}
}
public record EntityCollision(Entity entity, Vec3 hitPos, Vec3 hitNormal) {
}
public record TimerPlate(float textX, float textY, String text,
float iconX, float iconY, float iconScale, ItemStack iconStack) {
public TimerPlate(float textX, float textY, String text,
float iconX, float iconY, float iconScale, ItemStack iconStack) {
this.textX = textX;
this.textY = textY;
this.text = text;
this.iconX = iconX;
this.iconY = iconY;
this.iconScale = iconScale;
this.iconStack = iconStack == null ? ItemStack.EMPTY : iconStack;
}
}
public static Kind kindFromParams(ItemStack stack) {
if (stack == null) return Kind.ARROW;
if (stack.is(Items.TRIDENT)) return Kind.TRIDENT;
if (stack.is(Items.SPLASH_POTION)) return Kind.POTION_SPLASH;
if (stack.is(Items.LINGERING_POTION)) return Kind.POTION_LINGERING;
if (stack.is(Items.SNOWBALL)) return Kind.SNOWBALL;
if (stack.is(Items.EGG)) return Kind.EGG;
if (stack.is(Items.FIREWORK_ROCKET)) return Kind.FIREWORK;
if (stack.is(Items.WIND_CHARGE)) return Kind.WIND_CHARGE;
if (stack.is(Items.FIRE_CHARGE)) return Kind.FIREBALL;
if (stack.is(Items.ENDER_PEARL)) return Kind.PEARL_ENTITY;
return Kind.ARROW;
}
public static Kind kindFromProjectile(Entity entity) {
if (entity instanceof ThrownTrident) return Kind.TRIDENT;
if (entity instanceof AbstractThrownPotion potion) {
return potion.getItem().is(Items.LINGERING_POTION) ? Kind.POTION_LINGERING : Kind.POTION_SPLASH;
}
if (entity instanceof Snowball) return Kind.SNOWBALL;
if (entity instanceof ThrownEgg) return Kind.EGG;
if (entity instanceof FireworkRocketEntity) return Kind.FIREWORK;
if (entity instanceof WindCharge) return Kind.WIND_CHARGE;
if (entity instanceof Fireball) return Kind.FIREBALL;
if (entity instanceof ThrownEnderpearl) return Kind.PEARL_ENTITY;
return Kind.ARROW;
}
public static Vec3 resolveEntityImpactNormal(AABB box, Vec3 hitPos, Vec3 fallbackVelocity) {
if (box == null || hitPos == null) {
return PredictionsGeometry.resolveImpactNormal(null, fallbackVelocity);
}
double dxMin = Math.abs(hitPos.x - box.minX);
double dxMax = Math.abs(box.maxX - hitPos.x);
double dyMin = Math.abs(hitPos.y - box.minY);
double dyMax = Math.abs(box.maxY - hitPos.y);
double dzMin = Math.abs(hitPos.z - box.minZ);
double dzMax = Math.abs(box.maxZ - hitPos.z);
double best = dxMin;
Vec3 normal = new Vec3(-1.0, 0.0, 0.0);
if (dxMax < best) {
best = dxMax;
normal = new Vec3(1.0, 0.0, 0.0);
}
if (dyMin < best) {
best = dyMin;
normal = new Vec3(0.0, -1.0, 0.0);
}
if (dyMax < best) {
best = dyMax;
normal = new Vec3(0.0, 1.0, 0.0);
}
if (dzMin < best) {
best = dzMin;
normal = new Vec3(0.0, 0.0, -1.0);
}
if (dzMax < best) {
normal = new Vec3(0.0, 0.0, 1.0);
}
return normal;
}
}

View file

@ -0,0 +1,196 @@
/*
* This file is part of the LoVisual Client distribution.
* Copyright (c) 2026 loki5512344.
*
* Licensed under the GNU General Public License v3.0.
*/
package dev.loki.lovisual.features.module.modules.combat.predictions;
import dev.loki.lovisual.config.values.BooleanValue;
import dev.loki.lovisual.config.values.NumberValue;
import dev.loki.lovisual.config.values.RGBColorValue;
import dev.loki.lovisual.features.module.modules.combat.predictions.PredictionsModel.EntityCollision;
import dev.loki.lovisual.features.module.modules.combat.predictions.PredictionsModel.Kind;
import dev.loki.lovisual.features.module.modules.combat.predictions.PredictionsModel.TrajectoryResult;
import net.minecraft.client.Minecraft;
import net.minecraft.client.multiplayer.ClientLevel;
import net.minecraft.world.entity.Entity;
import net.minecraft.world.entity.ExperienceOrb;
import net.minecraft.world.entity.LivingEntity;
import net.minecraft.world.entity.item.ItemEntity;
import net.minecraft.world.entity.projectile.Projectile;
import net.minecraft.world.entity.projectile.ProjectileUtil;
import net.minecraft.world.item.ItemStack;
import net.minecraft.world.item.Items;
import net.minecraft.world.level.ClipContext;
import net.minecraft.world.phys.AABB;
import net.minecraft.world.phys.EntityHitResult;
import net.minecraft.world.phys.HitResult;
import net.minecraft.world.phys.Vec3;
import java.util.ArrayList;
import java.util.List;
/**
* Tick-by-tick projectile trajectory simulation and entity collision resolution
* for the Predictions module.
*/
public final class PredictionsSimulation {
private PredictionsSimulation() {
}
public static final int HIT_SELF_COLOR = 0xFFFF4444;
public static final int HIT_ENTITY_COLOR = 0xFFFFCC44;
public static TrajectoryResult simulateEntity(Entity e, Kind kind, float tickDelta,
NumberValue<Integer> maxSimTicks,
NumberValue<Integer> pearlMaxSimTicks,
BooleanValue pearlColorToggle,
RGBColorValue pearlColorValue) {
Minecraft mc = Minecraft.getInstance();
ClientLevel world = mc.level;
if (world == null) return null;
Vec3 pos = e.position();
Vec3 vel = e.getDeltaMovement();
List<Vec3> points = new ArrayList<>();
points.add(pos);
double t = 0.0;
int baseMaxTicks = Math.max(1, maxSimTicks.get());
int maxTicks = kind == Kind.PEARL_ENTITY
? Math.max(1, pearlMaxSimTicks.get())
: baseMaxTicks;
int hitColor = 0;
long impactTick = world.getGameTime();
Vec3 impactPos = null;
Vec3 impactNormal = null;
Entity hitEntity = null;
boolean hitBlock = false;
for (int i = 0; i < maxTicks; i++) {
Vec3 segmentStart = pos;
Vec3 simulatedVelocity = PredictionsGeometry.advanceTickVelocity(e, vel);
Vec3 simulatedPos = pos.add(simulatedVelocity);
double segmentLengthSq = segmentStart.distanceToSqr(simulatedPos);
HitResult hit = world.clip(new ClipContext(
segmentStart,
simulatedPos,
ClipContext.Block.COLLIDER,
ClipContext.Fluid.NONE,
e
));
EntityCollision entityCollision = getEntityCollision(e, segmentStart, simulatedPos);
boolean hasBlockHit = hit.getType() != HitResult.Type.MISS;
boolean useEntityHit = entityCollision != null
&& (!hasBlockHit || segmentStart.distanceToSqr(entityCollision.hitPos()) <= segmentStart.distanceToSqr(hit.getLocation()));
if (useEntityHit) {
hitColor = (entityCollision.entity() == mc.player) ? HIT_SELF_COLOR : HIT_ENTITY_COLOR;
Vec3 entityHitPos = entityCollision.hitPos();
t += PredictionsGeometry.partialStepProgress(segmentStart, entityHitPos, segmentLengthSq);
points.add(entityHitPos);
impactTick = world.getGameTime() + (long) Math.ceil(t);
impactPos = entityHitPos;
impactNormal = entityCollision.hitNormal();
hitEntity = entityCollision.entity();
break;
} else if (hasBlockHit) {
Vec3 blockHitPos = hit.getLocation();
t += PredictionsGeometry.partialStepProgress(segmentStart, blockHitPos, segmentLengthSq);
points.add(blockHitPos);
impactTick = world.getGameTime() + (long) Math.ceil(t);
impactPos = blockHitPos;
impactNormal = PredictionsGeometry.resolveImpactNormal(hit, simulatedVelocity);
hitBlock = true;
break;
}
pos = simulatedPos;
vel = simulatedVelocity;
t += 1.0;
points.add(pos);
impactTick = world.getGameTime() + (long) Math.ceil(t);
}
ItemStack icon = switch (kind) {
case PEARL_ENTITY -> new ItemStack(Items.ENDER_PEARL);
case TRIDENT -> new ItemStack(Items.TRIDENT);
case FIREBALL -> new ItemStack(Items.FIRE_CHARGE);
case WIND_CHARGE -> new ItemStack(Items.WIND_CHARGE);
case FIREWORK -> new ItemStack(Items.FIREWORK_ROCKET);
case SNOWBALL -> new ItemStack(Items.SNOWBALL);
case EGG -> new ItemStack(Items.EGG);
case POTION_SPLASH -> new ItemStack(Items.SPLASH_POTION);
case POTION_LINGERING -> new ItemStack(Items.LINGERING_POTION);
default -> new ItemStack(Items.ARROW);
};
int color = hitColor;
if (color == 0 && kind == Kind.PEARL_ENTITY && pearlColorToggle.get()) {
color = pearlColorValue.getArgb();
}
return new TrajectoryResult(points, t, impactTick, icon, color, kind, impactPos, impactNormal, hitEntity, hitBlock);
}
private static EntityCollision getEntityCollision(Entity movingEntity, Vec3 startPos, Vec3 endPos) {
Vec3 direction = endPos.subtract(startPos);
if (direction.lengthSqr() == 0.0) {
return null;
}
AABB projectileBox = movingEntity.getBoundingBox().move(startPos.subtract(movingEntity.position()));
AABB collisionBox = projectileBox.expandTowards(direction).inflate(0.5);
EntityHitResult hitResult = ProjectileUtil.getEntityHitResult(
movingEntity,
startPos,
endPos,
collisionBox,
entity -> canHitPredictedEntity(movingEntity, entity, collisionBox)
&& entity instanceof LivingEntity
&& !(entity instanceof ItemEntity)
&& !(entity instanceof ExperienceOrb)
&& entity != movingEntity,
direction.lengthSqr()
);
if (hitResult == null) {
return null;
}
return new EntityCollision(
hitResult.getEntity(),
hitResult.getLocation(),
PredictionsModel.resolveEntityImpactNormal(hitResult.getEntity().getBoundingBox(), hitResult.getLocation(), direction)
);
}
private static boolean canHitPredictedEntity(Entity projectile, Entity entity, AABB collisionBox) {
if (projectile == null || entity == null || !entity.isAlive() || !entity.canBeHitByProjectile()) {
return false;
}
if (projectile instanceof Projectile projectileEntity) {
Entity owner = projectileEntity.getOwner();
if (owner != null && !hasPredictedLeftOwner(owner, entity, collisionBox)) {
return !owner.isPassengerOfSameVehicle(entity);
}
}
return true;
}
private static boolean hasPredictedLeftOwner(Entity owner, Entity entity, AABB collisionBox) {
if (owner == null || collisionBox == null) {
return true;
}
return owner.getRootVehicle()
.getSelfAndPassengers()
.filter(Entity::isPickable)
.noneMatch(passenger -> collisionBox.intersects(passenger.getBoundingBox()));
}
}

View file

@ -0,0 +1,179 @@
/*
* This file is part of the LoVisual Client distribution.
* Copyright (c) 2026 loki5512344.
*
* Licensed under the GNU General Public License v3.0.
*/
package dev.loki.lovisual.features.module.modules.combat.targetesp;
import dev.loki.lovisual.render.engine.RenderState;
import dev.loki.lovisual.render.engine.uniform.MeshBuilder;
import dev.loki.lovisual.util.time.GameClock;
import net.minecraft.world.phys.Vec3;
import org.joml.Quaternionf;
import org.joml.Vector3f;
import static dev.loki.lovisual.features.module.modules.combat.targetesp.TargetEspRender.*;
/**
* Crystal orbit geometry for the TargetESP "crystals" mode.
*/
public final class TargetEspCrystals {
private TargetEspCrystals() {
}
private static final float CRYSTAL_BASE_SIZE = 0.05f;
private static final int CRYSTAL_SIDES = 8;
public static final class CrystalInstance {
private final Vec3 position;
private final Vec3 rotation;
private final float rotationSpeed;
public CrystalInstance(Vec3 position, Vec3 rotation) {
this.position = position;
this.rotation = rotation;
this.rotationSpeed = 0.5f + (float) (Math.random() * 1.5f);
}
private float selfRotationDeg() {
return (GameClock.millis() % 36000L) / 100.0f * rotationSpeed;
}
private float pulsation() {
return 1.0f + (float) (Math.sin(GameClock.millis() / 500.0) * 0.1f);
}
public void append(MeshBuilder additiveMesh,
MeshBuilder fillMesh,
MeshBuilder lineMesh,
MeshBuilder bloomMesh,
Vec3 targetPos,
float orbitYawRad,
int baseColor) {
appendCrystalGeometry(additiveMesh, targetPos, this, orbitYawRad, 1.0f, applyOpacity(baseColor, 0.2f), true);
appendCrystalGeometry(fillMesh, targetPos, this, orbitYawRad, 1.0f, applyOpacity(baseColor, 0.3f), true);
appendCrystalGeometry(lineMesh, targetPos, this, orbitYawRad, 1.0f, applyOpacity(baseColor, 0.8f), false);
appendCrystalGeometry(additiveMesh, targetPos, this, orbitYawRad, 1.2f, applyOpacity(baseColor, 0.3f), true);
appendBloomSphere(bloomMesh, targetPos, this, orbitYawRad, baseColor);
}
}
private static void appendCrystalGeometry(MeshBuilder mesh,
Vec3 targetPos,
CrystalInstance crystal,
float orbitYawRad,
float scale,
int argb,
boolean filled) {
if (mesh == null || crystal == null || targetPos == null) {
return;
}
float s = CRYSTAL_BASE_SIZE * scale;
float hPrism = s;
float hPyramid = s * 1.5f;
Vector3f[] topVertices = new Vector3f[CRYSTAL_SIDES];
Vector3f[] bottomVertices = new Vector3f[CRYSTAL_SIDES];
for (int i = 0; i < CRYSTAL_SIDES; i++) {
float angle = (float) (2.0 * Math.PI * i / CRYSTAL_SIDES);
float x = (float) (s * Math.cos(angle));
float z = (float) (s * Math.sin(angle));
topVertices[i] = new Vector3f(x, hPrism * 0.5f, z);
bottomVertices[i] = new Vector3f(x, -hPrism * 0.5f, z);
}
Vector3f topPeak = new Vector3f(0.0f, hPrism * 0.5f + hPyramid, 0.0f);
Vector3f bottomPeak = new Vector3f(0.0f, -hPrism * 0.5f - hPyramid, 0.0f);
if (filled) {
for (int i = 0; i < CRYSTAL_SIDES; i++) {
Vector3f v1 = transformCrystalVertex(bottomVertices[i], crystal, orbitYawRad, targetPos);
Vector3f v2 = transformCrystalVertex(bottomVertices[(i + 1) % CRYSTAL_SIDES], crystal, orbitYawRad, targetPos);
Vector3f v3 = transformCrystalVertex(topVertices[(i + 1) % CRYSTAL_SIDES], crystal, orbitYawRad, targetPos);
Vector3f v4 = transformCrystalVertex(topVertices[i], crystal, orbitYawRad, targetPos);
addTriangle(mesh, v1, v2, v3, argb);
addTriangle(mesh, v1, v3, v4, argb);
}
for (int i = 0; i < CRYSTAL_SIDES; i++) {
Vector3f v1 = transformCrystalVertex(topPeak, crystal, orbitYawRad, targetPos);
Vector3f v2 = transformCrystalVertex(topVertices[i], crystal, orbitYawRad, targetPos);
Vector3f v3 = transformCrystalVertex(topVertices[(i + 1) % CRYSTAL_SIDES], crystal, orbitYawRad, targetPos);
addTriangle(mesh, v1, v2, v3, argb);
}
for (int i = 0; i < CRYSTAL_SIDES; i++) {
Vector3f v1 = transformCrystalVertex(bottomPeak, crystal, orbitYawRad, targetPos);
Vector3f v2 = transformCrystalVertex(bottomVertices[(i + 1) % CRYSTAL_SIDES], crystal, orbitYawRad, targetPos);
Vector3f v3 = transformCrystalVertex(bottomVertices[i], crystal, orbitYawRad, targetPos);
addTriangle(mesh, v1, v2, v3, argb);
}
return;
}
for (int i = 0; i < CRYSTAL_SIDES; i++) {
Vector3f bottomA = transformCrystalVertex(bottomVertices[i], crystal, orbitYawRad, targetPos);
Vector3f bottomB = transformCrystalVertex(bottomVertices[(i + 1) % CRYSTAL_SIDES], crystal, orbitYawRad, targetPos);
Vector3f topA = transformCrystalVertex(topVertices[i], crystal, orbitYawRad, targetPos);
Vector3f topB = transformCrystalVertex(topVertices[(i + 1) % CRYSTAL_SIDES], crystal, orbitYawRad, targetPos);
addLine(mesh, bottomA, bottomB, argb);
addLine(mesh, topA, topB, argb);
addLine(mesh, bottomA, topA, argb);
addLine(mesh, transformCrystalVertex(topPeak, crystal, orbitYawRad, targetPos), topA, argb);
addLine(mesh, transformCrystalVertex(bottomPeak, crystal, orbitYawRad, targetPos), bottomA, argb);
}
}
private static Vector3f transformCrystalVertex(Vector3f vertex,
CrystalInstance crystal,
float orbitYawRad,
Vec3 targetPos) {
Vector3f transformed = new Vector3f(vertex).mul(crystal.pulsation());
transformed.rotateX((float) Math.toRadians(crystal.rotation.x));
transformed.rotateY((float) Math.toRadians(crystal.rotation.y + crystal.selfRotationDeg()));
transformed.rotateZ((float) Math.toRadians(crystal.rotation.z));
transformed.add((float) crystal.position.x, (float) crystal.position.y, (float) crystal.position.z);
transformed.rotateY(orbitYawRad);
transformed.add((float) targetPos.x, (float) targetPos.y, (float) targetPos.z);
return transformed;
}
private static void appendBloomSphere(MeshBuilder mesh,
Vec3 targetPos,
CrystalInstance crystal,
float orbitYawRad,
int argb) {
if (mesh == null || crystal == null || targetPos == null) {
return;
}
Vector3f center = new Vector3f((float) crystal.position.x, (float) crystal.position.y, (float) crystal.position.z);
center.rotateY(orbitYawRad);
center.add((float) targetPos.x, (float) targetPos.y, (float) targetPos.z);
float size = CRYSTAL_BASE_SIZE * 13.0f;
Quaternionf camRot = RenderState.cameraRotation;
float yaw = orbitYawRad;
int bloomColor = applyOpacity(argb, (0.4f * 25.0f) / 255.0f);
for (int i = 0; i < 6; i++) {
float angle = (360.0f / 6.0f) * i;
Quaternionf rotation = new Quaternionf()
.rotateY((float) Math.toRadians(angle))
.rotateY(yaw)
.mul(camRot);
addTexturedBillboard(mesh, center.x, center.y, center.z, size, rotation, bloomColor);
}
for (int i = 0; i < 6; i++) {
float angle = (360.0f / 6.0f) * i;
Quaternionf rotation = new Quaternionf()
.rotateX((float) Math.toRadians(90.0f))
.rotateY((float) Math.toRadians(angle))
.rotateY(yaw)
.mul(camRot);
addTexturedBillboard(mesh, center.x, center.y, center.z, size, rotation, bloomColor);
}
}
}

View file

@ -0,0 +1,178 @@
/*
* This file is part of the LoVisual Client distribution.
* Copyright (c) 2026 loki5512344.
*
* Licensed under the GNU General Public License v3.0.
*/
package dev.loki.lovisual.features.module.modules.combat.targetesp;
import dev.loki.lovisual.render.engine.TextureStorage;
import dev.loki.lovisual.render.engine.math.RenderMath;
import dev.loki.lovisual.render.engine.pipeline.LoVisualRenderPipelines;
import dev.loki.lovisual.render.engine.renderer.Renderer3D;
import dev.loki.lovisual.render.engine.uniform.MeshBuilder;
import dev.loki.lovisual.util.time.GameClock;
import net.minecraft.client.Camera;
import net.minecraft.client.Minecraft;
import net.minecraft.resources.Identifier;
import net.minecraft.world.entity.Entity;
import net.minecraft.world.entity.LivingEntity;
import net.minecraft.world.phys.Vec3;
import org.joml.Vector3f;
import java.util.function.IntFunction;
import static dev.loki.lovisual.features.module.modules.combat.targetesp.TargetEspRender.*;
public final class TargetEspEffects {
private TargetEspEffects() {
}
private static final Identifier GHOST_PARTICLE_TEXTURE = TextureStorage.FIRE_FLY;
private static final float[][] BODY_HOLES = {
{0.0f, 0.60f}, // голова
{-0.48f, 0.36f}, // левая рука
{0.48f, 0.36f}, // правая рука
{0.0f, 0.05f}, // грудь
{-0.30f, -0.42f},// левая нога
{0.30f, -0.42f} // правая нога
};
public static void renderGhosts(Renderer3D renderer,
int espLength, int factor, float shaking, float amplitude,
Entity target, float tickDelta, IntFunction<Integer> palette) {
Minecraft mc = Minecraft.getInstance();
if (renderer == null || mc == null || mc.player == null) return;
Vec3 pos = RenderMath.getLerpedPos(target, tickDelta);
double tPosX = pos.x;
double tPosY = pos.y;
double tPosZ = pos.z;
float iAge = (float) interpolate(target.tickCount - 1, target.tickCount, tickDelta);
MeshBuilder mesh = renderer.batchTextured(
LoVisualRenderPipelines.WORLD_TEXTURED_ADDITIVE,
GHOST_PARTICLE_TEXTURE,
Renderer3D.DepthMode.MAIN
);
if (mesh == null) return;
for (int j = 0; j < 3; j++) {
for (int i = 0; i <= espLength; i++) {
double radians = Math.toRadians((((float) i / 1.5f + iAge) * factor + (j * 120)) % (factor * 360));
double sinQuad = Math.sin(Math.toRadians(iAge * 2.5f + i * (j + 1)) * amplitude) / shaking;
float offset = ((float) i / espLength);
int color = applyOpacity(palette.apply((int) (180 * offset)), offset);
float scale = Math.max(0.24f * (offset), 0.2f);
double cx = tPosX + Math.cos(radians) * target.getBbWidth();
double cy = tPosY + 1 + sinQuad;
double cz = tPosZ + Math.sin(radians) * target.getBbWidth();
addBillboardQuad(mesh, cx, cy, cz, scale, color);
}
}
}
public static void renderBlackhole(Renderer3D renderer,
LivingEntity target,
float tickDelta,
IntFunction<Integer> palette,
float size,
float spinSpeed,
float alpha,
String style) {
if (renderer == null || target == null) return;
Minecraft mc = Minecraft.getInstance();
if (mc.gameRenderer == null || mc.gameRenderer.mainCamera() == null) return;
Vec3 pos = RenderMath.getLerpedPos(target, tickDelta);
Vec3 baseCenter = new Vec3(pos.x, pos.y + target.getBbHeight() * 0.5, pos.z);
// Камера-зависимый billboard-базис: правый/верх в мировых координатах
Camera cam = mc.gameRenderer.mainCamera();
float yaw = (float) Math.toRadians(cam.yRot());
float pitch = (float) Math.toRadians(cam.xRot());
Vec3 fwd = new Vec3(-Math.sin(yaw) * Math.cos(pitch), -Math.sin(pitch), Math.cos(yaw) * Math.cos(pitch)).normalize();
Vec3 rgt = new Vec3(0.0, 1.0, 0.0).cross(fwd);
if (rgt.lengthSqr() < 1.0E-6) {
rgt = new Vec3(1.0, 0.0, 0.0);
} else {
rgt = rgt.normalize();
}
Vec3 upp = fwd.cross(rgt).normalize();
Vector3f right = new Vector3f((float) rgt.x, (float) rgt.y, (float) rgt.z);
Vector3f up = new Vector3f((float) upp.x, (float) upp.y, (float) upp.z);
MeshBuilder diskMesh = renderer.batch(
LoVisualRenderPipelines.WORLD_COLORED_ADDITIVE_DEPTH,
Renderer3D.DepthMode.PRE_DEPTH
);
MeshBuilder coreMesh = renderer.batch(
LoVisualRenderPipelines.WORLD_COLORED_DEPTH,
Renderer3D.DepthMode.PRE_DEPTH
);
if (diskMesh == null || coreMesh == null) return;
float time = (GameClock.millis() % 100000L) / 1000.0f;
if ("Body".equals(style)) {
float holeSize = size * 0.34f;
for (float[] off : BODY_HOLES) {
Vec3 center = new Vec3(
baseCenter.x + rgt.x * off[0] + upp.x * off[1],
baseCenter.y + rgt.y * off[0] + upp.y * off[1],
baseCenter.z + rgt.z * off[0] + upp.z * off[1]
);
appendBlackhole(diskMesh, coreMesh, center, right, up, holeSize, time, spinSpeed, palette, alpha);
}
} else {
appendBlackhole(diskMesh, coreMesh, baseCenter, right, up, size, time, spinSpeed, palette, alpha);
}
}
private static void appendBlackhole(MeshBuilder diskMesh,
MeshBuilder coreMesh,
Vec3 center,
Vector3f right,
Vector3f up,
float size,
float time,
float spinSpeed,
IntFunction<Integer> palette,
float alpha) {
float horizon = 0.35f * size;
int rings = 4;
int segs = 64;
// аккреционный диск: вращающаяся спираль на аддитивном бленде
for (int ring = 0; ring < rings; ring++) {
float r0 = horizon + (size - horizon) * (ring / (float) rings);
float r1 = horizon + (size - horizon) * ((ring + 1) / (float) rings);
for (int s = 0; s < segs; s++) {
float a0 = (float) (Math.PI * 2.0 * s / segs);
float a1 = (float) (Math.PI * 2.0 * (s + 1) / segs);
int v0 = addBlackholeVertex(diskMesh, center, right, up, r0, a0, horizon, size, time, spinSpeed, palette);
int v1 = addBlackholeVertex(diskMesh, center, right, up, r1, a0, horizon, size, time, spinSpeed, palette);
int v2 = addBlackholeVertex(diskMesh, center, right, up, r1, a1, horizon, size, time, spinSpeed, palette);
int v3 = addBlackholeVertex(diskMesh, center, right, up, r0, a1, horizon, size, time, spinSpeed, palette);
diskMesh.triangle(v0, v1, v2);
diskMesh.triangle(v0, v2, v3);
}
}
// чёрное ядро закрывает центр диска
int coreArgb = applyOpacity(0xFF080808, alpha);
int coreCenter = addBlackholeCoreVertex(coreMesh, center, right, up, 0f, 0f, coreArgb);
int coreSegs = 32;
for (int s = 0; s < coreSegs; s++) {
float a0 = (float) (Math.PI * 2.0 * s / coreSegs);
float a1 = (float) (Math.PI * 2.0 * (s + 1) / coreSegs);
int c0 = addBlackholeCoreVertex(coreMesh, center, right, up, horizon, a0, coreArgb);
int c1 = addBlackholeCoreVertex(coreMesh, center, right, up, horizon, a1, coreArgb);
coreMesh.triangle(coreCenter, c1, c0);
}
}
}

View file

@ -0,0 +1,199 @@
/*
* This file is part of the LoVisual Client distribution.
* Copyright (c) 2026 loki5512344.
*
* Licensed under the GNU General Public License v3.0.
*/
package dev.loki.lovisual.features.module.modules.combat.targetesp;
import dev.loki.lovisual.render.engine.RenderState;
import dev.loki.lovisual.render.engine.TextureStorage;
import dev.loki.lovisual.render.engine.math.RenderMath;
import dev.loki.lovisual.render.engine.pipeline.LoVisualRenderPipelines;
import dev.loki.lovisual.render.engine.renderer.Renderer3D;
import dev.loki.lovisual.render.engine.uniform.MeshBuilder;
import dev.loki.lovisual.util.time.GameClock;
import net.minecraft.resources.Identifier;
import net.minecraft.world.entity.Entity;
import net.minecraft.world.entity.LivingEntity;
import net.minecraft.world.phys.Vec3;
import java.util.function.IntFunction;
import static dev.loki.lovisual.features.module.modules.combat.targetesp.TargetEspRender.*;
/**
* State-driven TargetESP modes: animation state, the legacy ring, ghost
* particles and the capture mark.
*/
public final class TargetEspModes {
private TargetEspModes() {
}
private static final Identifier CAPTURE_MARK_TEXTURE = TextureStorage.CAPTURE;
private static final int VORTEX_HELIX_STEPS = 28;
private static final int VORTEX_RING_SEGMENTS = 24;
private static float prevCircleStep;
private static float circleStep;
private static double captureValue = 1.0;
private static double prevCaptureValue;
private static double captureSpeed = 1.0;
private static double captureSpeedScale = 1.0;
private static boolean captureFlipSpeed;
public static void tickTargetEsp() {
prevCircleStep = circleStep;
circleStep += 0.15f;
prevCaptureValue = captureValue;
captureValue += captureSpeed * captureSpeedScale;
if (captureSpeed > 25.0) captureFlipSpeed = true;
if (captureSpeed < -25.0) captureFlipSpeed = false;
captureSpeed = captureFlipSpeed
? captureSpeed - 0.5 * captureSpeedScale
: captureSpeed + 0.5 * captureSpeedScale;
}
public static void setCaptureSpeedScale(float scale) {
if (scale <= 0f) {
captureSpeedScale = 0.05;
} else {
captureSpeedScale = scale;
}
}
public static void drawOldTargetEsp(Renderer3D renderer, Entity target, float tickDelta, IntFunction<Integer> palette) {
if (renderer == null || target == null) return;
double cs = prevCircleStep + (circleStep - prevCircleStep) * tickDelta;
double prevSinAnim = absSinAnimation(cs - 0.45f);
double sinAnim = absSinAnimation(cs);
Vec3 pos = RenderMath.getLerpedPos(target, tickDelta);
double x = pos.x;
double y = pos.y + prevSinAnim * target.getBbHeight();
double z = pos.z;
double nextY = pos.y + sinAnim * target.getBbHeight();
MeshBuilder mesh = renderer.tris();
final int segments = 96;
final double step = Math.PI * 2.0 / segments;
final double radius = target.getBbWidth() * 0.8;
for (int i = 0; i < segments; i++) {
double a0 = i * step;
double a1 = (i + 1) * step;
float cos = (float) (x + Math.cos(a0) * radius);
float sin = (float) (z + Math.sin(a0) * radius);
float cosNext = (float) (x + Math.cos(a1) * radius);
float sinNext = (float) (z + Math.sin(a1) * radius);
int idx0 = (int) Math.round(i * (360.0 / segments));
int idx1 = (int) Math.round((i + 1) * (360.0 / segments));
int cTop0 = applyOpacity(palette.apply(idx0), 170.0f / 255.0f);
int cTop1 = applyOpacity(palette.apply(idx1), 170.0f / 255.0f);
int cBottom0 = applyOpacity(palette.apply(idx0), 0.0f);
int cBottom1 = applyOpacity(palette.apply(idx1), 0.0f);
mesh.ensureQuadCapacity();
int i1 = addVertex(mesh, cos, nextY, sin, cTop0);
int i2 = addVertex(mesh, cosNext, nextY, sinNext, cTop1);
int i3 = addVertex(mesh, cosNext, y, sinNext, cBottom1);
int i4 = addVertex(mesh, cos, y, sin, cBottom0);
mesh.quad(i1, i2, i3, i4);
}
}
public static void renderVortex(Renderer3D renderer,
LivingEntity target,
float tickDelta,
IntFunction<Integer> palette,
float spinSpeed) {
if (renderer == null || target == null) return;
Vec3 pos = RenderMath.getLerpedPos(target, tickDelta);
float radius = target.getBbWidth() * 0.7f;
float height = target.getBbHeight();
double time = (GameClock.millis() / 1000.0) * spinSpeed;
float prevWidth = RenderState.lineWidth;
RenderState.lineWidth = 1.4f;
MeshBuilder mesh;
try {
mesh = renderer.batch(LoVisualRenderPipelines.WORLD_COLORED_LINES, Renderer3D.DepthMode.NONE);
} finally {
RenderState.lineWidth = prevWidth;
}
if (mesh == null) return;
for (int strand = 0; strand < 2; strand++) {
double strandOffset = strand * Math.PI;
int prevIndex = -1;
for (int i = 0; i <= VORTEX_HELIX_STEPS; i++) {
double t = (double) i / VORTEX_HELIX_STEPS;
double angle = time * 2.4 + t * Math.PI * 3.0 + strandOffset;
double r = radius * (0.85 + 0.15 * Math.sin(t * Math.PI));
double x = pos.x + Math.cos(angle) * r;
double y = pos.y + t * height;
double z = pos.z + Math.sin(angle) * r;
int color = applyOpacity(palette.apply((int) (t * 180)), (float) (1.0 - t * 0.35));
int index = addVertex(mesh, x, y, z, color);
if (prevIndex >= 0) {
mesh.line(prevIndex, index);
}
prevIndex = index;
}
}
float[] ringHeights = {0.05f, height * 0.5f, height * 0.95f};
float[] ringSpeeds = {0.4f, -0.55f, 0.35f};
for (int ring = 0; ring < ringHeights.length; ring++) {
double ringY = pos.y + ringHeights[ring];
double ringRotation = time * ringSpeeds[ring];
int ringColor = applyOpacity(palette.apply(60), 0.4f);
int prevIndex = -1;
int firstIndex = -1;
for (int i = 0; i <= VORTEX_RING_SEGMENTS; i++) {
double angle = ringRotation + (i % VORTEX_RING_SEGMENTS) * 2.0 * Math.PI / VORTEX_RING_SEGMENTS;
double x = pos.x + Math.cos(angle) * radius;
double z = pos.z + Math.sin(angle) * radius;
int index = addVertex(mesh, x, ringY, z, ringColor);
if (firstIndex < 0) {
firstIndex = index;
}
if (prevIndex >= 0) {
mesh.line(prevIndex, index);
}
prevIndex = index;
}
}
}
public static void renderCaptureMark(Renderer3D renderer,
Entity target,
float tickDelta,
IntFunction<Integer> palette) {
if (renderer == null) return;
Vec3 pos = RenderMath.getLerpedPos(target, tickDelta);
double tPosX = pos.x;
double tPosY = pos.y;
double tPosZ = pos.z;
MeshBuilder mesh = renderer.batchTextured(
LoVisualRenderPipelines.WORLD_TEXTURED_ADDITIVE,
CAPTURE_MARK_TEXTURE,
Renderer3D.DepthMode.MAIN
);
if (mesh == null) return;
float angle = interpolateFloat((float) prevCaptureValue, (float) captureValue, tickDelta);
float size = 0.75f;
double cy = tPosY + target.getEyeHeight(target.getPose()) / 2f;
addRotatedBillboard(mesh, tPosX, cy, tPosZ, size, angle, palette);
}
}

View file

@ -0,0 +1,199 @@
/*
* This file is part of the LoVisual Client distribution.
* Copyright (c) 2026 loki5512344.
*
* Licensed under the GNU General Public License v3.0.
*/
package dev.loki.lovisual.features.module.modules.combat.targetesp;
import dev.loki.lovisual.render.engine.RenderState;
import dev.loki.lovisual.render.engine.uniform.MeshBuilder;
import net.minecraft.util.Mth;
import net.minecraft.world.phys.Vec3;
import org.joml.Quaternionf;
import org.joml.Vector3f;
import java.util.function.IntFunction;
public final class TargetEspRender {
private TargetEspRender() {
}
public static void addTriangle(MeshBuilder mesh, Vector3f v1, Vector3f v2, Vector3f v3, int argb) {
mesh.ensureTriCapacity();
int i1 = addVertex(mesh, v1.x, v1.y, v1.z, argb);
int i2 = addVertex(mesh, v2.x, v2.y, v2.z, argb);
int i3 = addVertex(mesh, v3.x, v3.y, v3.z, argb);
mesh.triangle(i1, i2, i3);
}
public static void addLine(MeshBuilder mesh, Vector3f v1, Vector3f v2, int argb) {
mesh.ensureLineCapacity();
int i1 = addVertex(mesh, v1.x, v1.y, v1.z, argb);
int i2 = addVertex(mesh, v2.x, v2.y, v2.z, argb);
mesh.line(i1, i2);
}
public static int addVertex(MeshBuilder mesh, double x, double y, double z, int argb) {
int a = (argb >>> 24) & 0xFF;
int r = (argb >>> 16) & 0xFF;
int g = (argb >>> 8) & 0xFF;
int b = argb & 0xFF;
mesh.vec3(x, y, z).color(r, g, b, a);
return mesh.next();
}
public static int addVertex(MeshBuilder mesh, double x, double y, double z, double u, double v, int argb) {
int a = (argb >>> 24) & 0xFF;
int r = (argb >>> 16) & 0xFF;
int g = (argb >>> 8) & 0xFF;
int b = argb & 0xFF;
mesh.vec3(x, y, z).vec2(u, v).color(r, g, b, a);
return mesh.next();
}
public static void addBillboardQuad(MeshBuilder mesh,
double cx, double cy, double cz,
float size, int argb) {
Quaternionf camRot = RenderState.cameraRotation;
Vector3f right = new Vector3f(1, 0, 0).rotate(camRot).mul(size);
Vector3f up = new Vector3f(0, 1, 0).rotate(camRot).mul(size);
addBillboardVertices(mesh, cx, cy, cz, right, up, argb, argb, argb, argb);
}
public static void addTexturedBillboard(MeshBuilder mesh,
double cx, double cy, double cz,
float size, Quaternionf rotation, int argb) {
Vector3f right = new Vector3f(1.0f, 0.0f, 0.0f).rotate(rotation).mul(size * 0.5f);
Vector3f up = new Vector3f(0.0f, 1.0f, 0.0f).rotate(rotation).mul(size * 0.5f);
addBillboardVertices(mesh, cx, cy, cz, right, up, argb, argb, argb, argb);
}
public static void addRotatedBillboard(MeshBuilder mesh, double cx, double cy, double cz,
float size, float angle, IntFunction<Integer> palette) {
Quaternionf camRot = RenderState.cameraRotation;
Vector3f right = new Vector3f(1, 0, 0).rotate(camRot);
Vector3f up = new Vector3f(0, 1, 0).rotate(camRot);
Vector3f r = new Vector3f(right).mul(size);
Vector3f u = new Vector3f(up).mul(size);
float sin = Mth.sin((float) Math.toRadians(angle));
float cos = Mth.cos((float) Math.toRadians(angle));
Vector3f rRot = new Vector3f(
r.x() * cos - u.x() * sin,
r.y() * cos - u.y() * sin,
r.z() * cos - u.z() * sin
);
Vector3f uRot = new Vector3f(
r.x() * sin + u.x() * cos,
r.y() * sin + u.y() * cos,
r.z() * sin + u.z() * cos
);
addBillboardVertices(mesh, cx, cy, cz, rRot, uRot,
palette.apply(90),
palette.apply(0),
palette.apply(180),
palette.apply(270));
}
public static void addBillboardVertices(MeshBuilder mesh, double cx, double cy, double cz,
Vector3f right, Vector3f up,
int c1, int c2, int c3, int c4) {
double p1x = cx - right.x() - up.x();
double p1y = cy - right.y() - up.y();
double p1z = cz - right.z() - up.z();
double p2x = cx + right.x() - up.x();
double p2y = cy + right.y() - up.y();
double p2z = cz + right.z() - up.z();
double p3x = cx + right.x() + up.x();
double p3y = cy + right.y() + up.y();
double p3z = cz + right.z() + up.z();
double p4x = cx - right.x() + up.x();
double p4y = cy - right.y() + up.y();
double p4z = cz - right.z() + up.z();
mesh.ensureQuadCapacity();
int i1 = addVertex(mesh, p1x, p1y, p1z, 0, 1, c1);
int i2 = addVertex(mesh, p2x, p2y, p2z, 1, 1, c2);
int i3 = addVertex(mesh, p3x, p3y, p3z, 1, 0, c3);
int i4 = addVertex(mesh, p4x, p4y, p4z, 0, 0, c4);
mesh.quad(i1, i2, i3, i4);
}
public static int applyOpacity(int argb, float opacity) {
opacity = Math.min(1f, Math.max(0f, opacity));
int baseA = (argb >>> 24) & 0xFF;
int a = Mth.clamp((int) (baseA * opacity), 0, 255);
return (a << 24) | (argb & 0x00FFFFFF);
}
public static int addBlackholeVertex(MeshBuilder mesh,
Vec3 center,
Vector3f right,
Vector3f up,
float r,
float angle,
float horizon,
float size,
float time,
float spinSpeed,
IntFunction<Integer> palette) {
float dist = r / size;
float rot = time * 2.5f * spinSpeed + 1.3f / (dist + 0.05f);
float ca = (float) Math.cos(rot);
float sa = (float) Math.sin(rot);
float ux = dist * (float) Math.cos(angle);
float uy = dist * (float) Math.sin(angle);
float rx = ux * ca - uy * sa;
float ry = ux * sa + uy * ca;
float spiral = Math.abs((float) Math.sin(rx * 14.0f + ry * 4.0f) * 0.5f + 0.5f);
float falloff = (float) Math.pow(horizon / Math.max(dist, 0.001f), 2.2);
float outerFade = smoothstep(1.0f, horizon, dist);
float brightness = falloff * (0.55f + 0.45f * spiral) * outerFade;
int color = palette.apply((int) ((Math.toDegrees(angle) + time * 90f * spinSpeed) % 360f));
color = applyOpacity(color, brightness);
float wx = (float) center.x + right.x() * ux * size + up.x() * uy * size;
float wy = (float) center.y + right.y() * ux * size + up.y() * uy * size;
float wz = (float) center.z + right.z() * ux * size + up.z() * uy * size;
return addVertex(mesh, wx, wy, wz, color);
}
public static int addBlackholeCoreVertex(MeshBuilder mesh,
Vec3 center,
Vector3f right,
Vector3f up,
float r,
float angle,
int argb) {
float ux = r * (float) Math.cos(angle);
float uy = r * (float) Math.sin(angle);
float wx = (float) center.x + right.x() * ux + up.x() * uy;
float wy = (float) center.y + right.y() * ux + up.y() * uy;
float wz = (float) center.z + right.z() * ux + up.z() * uy;
return addVertex(mesh, wx, wy, wz, argb);
}
public static double absSinAnimation(double input) {
return Math.abs(1 + Math.sin(input)) / 2;
}
public static double interpolate(double oldValue, double newValue, double interpolationValue) {
return (oldValue + (newValue - oldValue) * interpolationValue);
}
public static float interpolateFloat(float oldValue, float newValue, double interpolationValue) {
return (float) interpolate(oldValue, newValue, interpolationValue);
}
private static float smoothstep(float edge0, float edge1, float x) {
float t = (x - edge0) / (edge1 - edge0);
t = Math.max(0f, Math.min(1f, t));
return t * t * (3f - 2f * t);
}
}

View file

@ -0,0 +1,105 @@
/*
* This file is part of the LoVisual Client distribution.
* Copyright (c) 2026 loki5512344.
*
* Licensed under the GNU General Public License v3.0.
*/
package dev.loki.lovisual.features.module.modules.misc;
import dev.loki.lovisual.config.values.ModeValue;
import dev.loki.lovisual.features.module.Module;
import dev.loki.lovisual.features.module.ModuleCategory;
import dev.loki.lovisual.features.module.ModuleInfo;
import dev.loki.lovisual.features.module.Notifier;
import net.minecraft.client.Minecraft;
import java.util.UUID;
/**
* Blocks forced server resource packs.
*
* <p>Decline/Remove answer every push with a terminal
* {@code ServerboundResourcePackPacket.Action.DECLINED} before vanilla shows
* its prompt or starts a download; Remove additionally drops packs that are
* already applied.</p>
*/
@ModuleInfo(
id = "noserverpack",
displayName = "No Server Pack",
aliases = {"noserverpack", "nopack"},
category = ModuleCategory.MISC
)
public class NoServerPack extends Module {
private static final String SETTING_ACTION = "action";
private final ModeValue action =
mode("noServerPackAction", SETTING_ACTION, Action.DECLINE.id, Action.REMOVE.id, Action.ASK.id);
private boolean clearedAppliedPacks;
public Action action() {
return Action.byId(action.get());
}
/**
* Packet-hook decision. Never touches the network or GUI from here.
*
* @return true when the push must be cancelled and answered with DECLINED
*/
public boolean shouldDeclinePush() {
return shouldDeclinePush(isEnabled(), action.get());
}
/** Called from the packet hook after a push was answered with DECLINED. */
public void onPackDeclined(UUID packId) {
Minecraft mc = Minecraft.getInstance();
if (mc == null || packId == null) return;
mc.execute(() -> Notifier.info("Server resource pack declined (" + packId + ")"));
}
@Override
public void onTick() {
if (!isEnabled()) {
clearedAppliedPacks = false;
return;
}
if (action() == Action.REMOVE) {
clearAppliedPacksOnce();
} else {
clearedAppliedPacks = false;
}
}
private void clearAppliedPacksOnce() {
if (clearedAppliedPacks) return;
clearedAppliedPacks = true;
Minecraft mc = Minecraft.getInstance();
if (mc == null || mc.getDownloadedPackSource() == null) return;
mc.getDownloadedPackSource().popAll();
Notifier.info("Applied server packs removed");
}
static boolean shouldDeclinePush(boolean enabled, String mode) {
return enabled && Action.byId(mode) != Action.ASK;
}
public enum Action {
DECLINE("Decline"),
REMOVE("Remove"),
ASK("Ask");
public final String id;
Action(String id) {
this.id = id;
}
static Action byId(String id) {
for (Action value : values()) {
if (value.id.equals(id)) return value;
}
return DECLINE;
}
}
}

View file

@ -0,0 +1,88 @@
/*
* This file is part of the LoVisual Client distribution.
* Copyright (c) 2026 loki5512344.
*
* Licensed under the GNU General Public License v3.0.
*/
package dev.loki.lovisual.features.module.modules.player;
import dev.loki.lovisual.config.values.NumberValue;
import dev.loki.lovisual.features.module.Module;
import dev.loki.lovisual.features.module.ModuleCategory;
import dev.loki.lovisual.features.module.ModuleInfo;
import dev.loki.lovisual.util.time.GameClock;
import net.minecraft.client.Minecraft;
/**
* Automatically presses the vanilla respawn path after death, once per death
* and only after a configurable delay.
*/
@ModuleInfo(
id = "autorespawn",
displayName = "Auto Respawn",
aliases = {"autorespawn", "respawn"},
category = ModuleCategory.PLAYER
)
public class AutoRespawn extends Module {
private static final String SETTING_DELAY = "delay";
private final NumberValue<Integer> delay = num("autoRespawnDelay", SETTING_DELAY, 500, 0, 5000);
private final RespawnGate gate = new RespawnGate();
@Override
public void onTick() {
Minecraft mc = Minecraft.getInstance();
if (mc == null || mc.player == null) {
gate.reset();
return;
}
boolean dead = mc.player.isDeadOrDying();
long now = GameClock.millis();
if (!gate.shouldRequestRespawn(dead, now, delay.get())) return;
gate.onRequestSent();
// Exactly what the vanilla death screen "Respawn" button does.
mc.player.respawn();
}
/**
* Pure death/delay state machine: one request per death, reset by coming
* back to life. Unit-testable without Minecraft.
*/
public static final class RespawnGate {
private boolean dead;
private long deathStartedMs;
private boolean requestSent;
/**
* @return true when a respawn request should be sent right now
*/
public boolean shouldRequestRespawn(boolean isDead, long nowMs, long delayMs) {
if (!isDead) {
reset();
return false;
}
if (!this.dead) {
this.dead = true;
this.deathStartedMs = nowMs;
this.requestSent = false;
return false;
}
if (requestSent) return false;
return nowMs - deathStartedMs >= Math.max(0L, delayMs);
}
public void onRequestSent() {
requestSent = true;
}
public void reset() {
dead = false;
requestSent = false;
deathStartedMs = 0L;
}
}
}

View file

@ -0,0 +1,91 @@
/*
* This file is part of the LoVisual Client distribution.
* Copyright (c) 2026 loki5512344.
*
* Licensed under the GNU General Public License v3.0.
*/
package dev.loki.lovisual.features.module.modules.player;
import dev.loki.lovisual.config.values.BooleanValue;
import dev.loki.lovisual.features.command.CommandOutput;
import dev.loki.lovisual.features.module.Module;
import dev.loki.lovisual.features.module.ModuleCategory;
import dev.loki.lovisual.features.module.ModuleInfo;
import dev.loki.lovisual.features.module.Notifier;
import net.minecraft.client.Minecraft;
import java.util.Locale;
/**
* Reports the position of your latest death as a client-side chat message and
* HUD notification, once per death.
*/
@ModuleInfo(
id = "deathcoords",
displayName = "Death Coords",
aliases = {"deathcoords"},
category = ModuleCategory.PLAYER
)
public class DeathCoords extends Module {
private static final String SETTING_SHOW_DIMENSION = "show_dimension";
private final BooleanValue showDimension =
bool("deathCoordsShowDimension", SETTING_SHOW_DIMENSION, true);
private final DeathDetector detector = new DeathDetector();
@Override
public void onDisable() {
detector.reset();
}
@Override
public void onTick() {
Minecraft mc = Minecraft.getInstance();
if (mc == null || mc.player == null || mc.level == null) {
detector.reset();
return;
}
if (!detector.onTick(mc.player.isDeadOrDying())) return;
String message = formatDeathMessage(
mc.player.getX(),
mc.player.getY(),
mc.player.getZ(),
mc.level.dimension().identifier().toString(),
showDimension.get()
);
CommandOutput.warning(message);
Notifier.warning(message);
}
/**
* Pure alive-to-dead edge detector, unit-testable without Minecraft.
*/
public static final class DeathDetector {
private boolean wasDead;
/**
* @return true exactly on the tick the player transitions to dead
*/
public boolean onTick(boolean dead) {
boolean justDied = dead && !wasDead;
wasDead = dead;
return justDied;
}
public void reset() {
wasDead = false;
}
}
static String formatDeathMessage(double x, double y, double z, String dimension, boolean withDimension) {
String coords = String.format(Locale.ROOT, "%.2f, %.2f, %.2f", x, y, z);
if (withDimension && dimension != null && !dimension.isBlank()) {
return "You died at " + coords + " (" + dimension + ")";
}
return "You died at " + coords;
}
}

View file

@ -0,0 +1,92 @@
/*
* This file is part of the LoVisual Client distribution.
* Copyright (c) 2026 loki5512344.
*
* Licensed under the GNU General Public License v3.0.
*/
package dev.loki.lovisual.features.module.modules.player;
import dev.loki.lovisual.config.values.BooleanMapValue;
import dev.loki.lovisual.config.values.BooleanValue;
import dev.loki.lovisual.features.module.Module;
import dev.loki.lovisual.features.module.ModuleCategory;
import dev.loki.lovisual.features.module.ModuleInfo;
import dev.loki.lovisual.features.module.Notifier;
import dev.loki.lovisual.util.protection.NotifyThrottle;
import dev.loki.lovisual.util.protection.SlotLockPolicy;
import net.minecraft.client.Minecraft;
import net.minecraft.client.player.LocalPlayer;
import net.minecraft.util.Util;
import net.minecraft.world.entity.player.Player;
import net.minecraft.world.inventory.Slot;
import net.minecraft.world.level.Level;
/**
* Protects hotbar slots 1-9 from accidental drops.
* Covers the Q drop of the selected hotbar slot and THROW clicks on the
* player's hotbar slots inside container screens.
*/
@ModuleInfo(
id = "lockslot",
displayName = "Lock Slot",
aliases = {"lockslot"},
category = ModuleCategory.PLAYER,
description = "module.lockslot.description"
)
public final class LockSlot extends Module {
private static final double NEARBY_RADIUS_SQR = 32.0 * 32.0;
private static final long WARN_INTERVAL_MS = 1500L;
private final BooleanMapValue slots = group("lockslot_slots", "slots", SlotLockPolicy.defaultSlotStates());
private final BooleanValue onlyPlayersNearby = bool("lockslot_only_players_nearby", "only_players_nearby", true);
private final NotifyThrottle warnThrottle = new NotifyThrottle(WARN_INTERVAL_MS);
/**
* Path A: Q / Ctrl+Q drop of the selected hotbar slot (LocalPlayer.drop).
*/
public boolean shouldBlockSelectedDrop() {
if (!isEnabled()) return false;
LocalPlayer player = Minecraft.getInstance().player;
if (player == null) return false;
return shouldBlockDrop(player, player.getInventory().getSelectedSlot());
}
/**
* Path B: THROW container clicks targeting a hotbar slot in an open screen.
*/
public boolean shouldBlockContainerThrow(Player player, int containerId, int slotId) {
if (!isEnabled() || player == null) return false;
if (player.containerMenu == null || player.containerMenu.containerId != containerId) return false;
if (slotId < 0 || slotId >= player.containerMenu.slots.size()) return false;
Slot slot = player.containerMenu.slots.get(slotId);
if (slot.container != player.getInventory()) return false;
return shouldBlockDrop(player, slot.getContainerSlot());
}
private boolean shouldBlockDrop(Player player, int hotbarSlot) {
boolean playersNearby = hasPlayersNearby(player);
if (!SlotLockPolicy.isDropBlocked(slots.getAll(), hotbarSlot, playersNearby, onlyPlayersNearby.get())) {
return false;
}
notifyBlocked(hotbarSlot);
return true;
}
private boolean hasPlayersNearby(Player self) {
Level level = self.level();
if (level == null) return false;
for (Player other : level.players()) {
if (other == self || other.isSpectator()) continue;
if (other.distanceToSqr(self) <= NEARBY_RADIUS_SQR) return true;
}
return false;
}
private void notifyBlocked(int hotbarSlot) {
if (!warnThrottle.tryAcquire(Util.getMillis())) return;
Notifier.warning("Slot " + (hotbarSlot + 1) + " is locked: drop blocked");
}
}

View file

@ -0,0 +1,92 @@
/*
* This file is part of the LoVisual Client distribution.
* Copyright (c) 2026 loki5512344.
*
* Licensed under the GNU General Public License v3.0.
*/
package dev.loki.lovisual.features.module.modules.player;
import dev.loki.lovisual.config.values.BooleanValue;
import dev.loki.lovisual.config.values.ModeValue;
import dev.loki.lovisual.config.values.NumberValue;
import dev.loki.lovisual.features.module.Module;
import dev.loki.lovisual.features.module.ModuleCategory;
import dev.loki.lovisual.features.module.ModuleInfo;
import dev.loki.lovisual.mixins.accessors.KeyMappingAccessor;
import dev.loki.lovisual.util.time.GameClock;
import net.minecraft.client.KeyMapping;
import net.minecraft.client.Minecraft;
/**
* Repeats the selected mouse button on an interval, mirroring real clicks by
* feeding vanilla {@code KeyMapping.click(...)} so {@code handleKeybinds}
* processes them itself.
*/
@ModuleInfo(
id = "tapemouse",
displayName = "Tape Mouse",
aliases = {"tapemouse", "tape"},
category = ModuleCategory.PLAYER
)
public class TapeMouse extends Module {
private static final String SETTING_BUTTON = "button";
private static final String SETTING_INTERVAL = "interval";
private static final String SETTING_ONLY_HOLD = "only_hold";
private static final String BUTTON_ATTACK = "Attack";
private static final String BUTTON_USE = "Use";
private final ModeValue button = mode("tapeMouseButton", SETTING_BUTTON, BUTTON_ATTACK, BUTTON_USE);
private final NumberValue<Integer> interval = num("tapeMouseInterval", SETTING_INTERVAL, 1000, 100, 5000);
private final BooleanValue onlyHold = bool("tapeMouseOnlyHold", SETTING_ONLY_HOLD, true);
private final ClickPacer pacer = new ClickPacer();
@Override
public void onEnable() {
pacer.reset();
}
@Override
public void onTick() {
Minecraft mc = Minecraft.getInstance();
if (mc == null || mc.player == null || mc.options == null) return;
if (mc.gui == null || mc.gui.screen() != null) return;
boolean attack = BUTTON_ATTACK.equals(button.get());
KeyMapping mapping = attack ? mc.options.keyAttack : mc.options.keyUse;
if (mapping == null) return;
if (onlyHold.get() && !mapping.isDown()) return;
long now = GameClock.millis();
if (!pacer.isDue(now, interval.get())) return;
pacer.mark(now);
// Identical to a physical press: vanilla MouseHandler calls the same
// static KeyMapping.click for real input events.
KeyMapping.click(((KeyMappingAccessor) (Object) mapping).lovisual$key());
}
/**
* Pure interval pacing logic, unit-testable without Minecraft.
*/
public static final class ClickPacer {
private long lastClickMs;
private boolean primed;
public boolean isDue(long nowMs, long intervalMs) {
long safeInterval = Math.max(1L, intervalMs);
return !primed || nowMs - lastClickMs >= safeInterval;
}
public void mark(long nowMs) {
lastClickMs = nowMs;
primed = true;
}
public void reset() {
primed = false;
}
}
}

View file

@ -0,0 +1,110 @@
/*
* This file is part of the LoVisual Client distribution.
* Copyright (c) 2026 loki5512344.
*
* Licensed under the GNU General Public License v3.0.
*/
package dev.loki.lovisual.features.module.modules.visuals;
import dev.loki.lovisual.config.values.BooleanValue;
import dev.loki.lovisual.features.module.Module;
import dev.loki.lovisual.features.module.ModuleCategory;
import dev.loki.lovisual.features.module.ModuleInfo;
import dev.loki.lovisual.util.animation.SmoothValue;
import dev.loki.lovisual.util.screen.ClientScreen;
import net.minecraft.client.Minecraft;
import net.minecraft.client.gui.screens.inventory.AbstractContainerScreen;
/**
* Smooth appear/disappear animations for interface elements.
* <p>
* Every animated quantity is a {@link SmoothValue} whose boolean target is
* computed and advanced in exactly one place: {@link #onFrame(float)}. Renderer
* integration points (custom hotbar, container screens) only read the settled
* per-frame values through the accessors below, so values can never be pulled
* toward two different goals in the same frame.
*/
@ModuleInfo(
id = "animations",
displayName = "Animations",
aliases = {"animations"},
category = ModuleCategory.VISUALS,
description = "module.animations.description"
)
public final class Animations extends Module {
private static final String SETTING_HOTBAR_RAISE = "hotbar_raise";
private static final String SETTING_INVENTORY_OPEN = "inventory";
// Speeds are SmoothValue approach rates in 1/tick units (20 ticks per second).
// Inventory: ~0.15s to visually settle (0.9 -> ~1.0).
private static final float INVENTORY_OPEN_SPEED = 1.5f;
// Hotbar: softer slide-in, ~0.3s.
private static final float HOTBAR_RAISE_SPEED = 0.5f;
private static final float INVENTORY_MIN_SCALE = 0.9f;
private static final float HOTBAR_RAISE_OFFSET_PX = 26.0f;
private final BooleanValue hotbarRaise =
bool("animationsHotbarRaise", SETTING_HOTBAR_RAISE, true);
private final BooleanValue inventoryOpen =
bool("animationsInventoryOpen", SETTING_INVENTORY_OPEN, true);
private final SmoothValue hotbarRaiseValue = new SmoothValue(HOTBAR_RAISE_SPEED);
private final SmoothValue inventoryOpenValue = new SmoothValue(INVENTORY_OPEN_SPEED);
@Override
public void onEnable() {
Minecraft mc = Minecraft.getInstance();
// If a container screen is already open, start settled so enabling
// mid-game never pops the screen. Otherwise the open animation plays
// naturally the next time a screen appears.
boolean containerOpen = ClientScreen.current(mc) instanceof AbstractContainerScreen<?>;
inventoryOpenValue.setInstant(containerOpen);
// Reset the raise so the hotbar slides in when the module turns on.
hotbarRaiseValue.setInstant(false);
}
/**
* The single update point for every animated value: compute the boolean
* target from the current game state, then advance each value exactly once.
*/
@Override
public void onFrame(float delta) {
Minecraft mc = Minecraft.getInstance();
boolean inWorld = mc != null && mc.player != null;
boolean containerOpen = inWorld
&& ClientScreen.current(mc) instanceof AbstractContainerScreen<?>;
hotbarRaiseValue.setTarget(inWorld);
hotbarRaiseValue.update(delta);
inventoryOpenValue.setTarget(containerOpen);
inventoryOpenValue.update(delta);
}
/**
* Vertical GUI-pixel offset the custom hotbar should be shifted down by while
* it slides into place. Reads only; the value is updated in {@link #onFrame(float)}.
*/
public float getHotbarRaiseOffset() {
if (!isEnabled() || !hotbarRaise.get()) {
return 0.0f;
}
return HOTBAR_RAISE_OFFSET_PX * (1.0f - hotbarRaiseValue.value());
}
/**
* Scale container screens should be drawn with while they open
* (1.0 when settled or when the animation is disabled).
*/
public float getInventoryScale() {
if (!isEnabled() || !inventoryOpen.get()) {
return 1.0f;
}
return INVENTORY_MIN_SCALE + (1.0f - INVENTORY_MIN_SCALE) * inventoryOpenValue.value();
}
}

View file

@ -0,0 +1,145 @@
/*
* This file is part of the LoVisual Client distribution.
* Copyright (c) 2026 loki5512344.
*
* Licensed under the GNU General Public License v3.0.
*/
package dev.loki.lovisual.features.module.modules.visuals;
import dev.loki.lovisual.config.values.NumberValue;
import dev.loki.lovisual.config.values.RGBColorValue;
import dev.loki.lovisual.config.values.ModeValue;
import dev.loki.lovisual.features.module.Module;
import dev.loki.lovisual.features.module.ModuleCategory;
import dev.loki.lovisual.features.module.ModuleInfo;
import dev.loki.lovisual.features.module.Modules;
import net.minecraft.client.Camera;
import net.minecraft.client.multiplayer.ClientLevel;
import net.minecraft.client.renderer.fog.FogData;
import net.minecraft.world.level.Level;
import net.minecraft.world.level.material.FogType;
/**
* Атмосферный туман зари: плотность тумана и цвет неба зависят от режима
* (рассвет/сумерки), времени суток, высоты камеры и мягкости градиента.
* В отличие от референса здесь эффект реально применяется к FogData и цвету.
*/
@ModuleInfo(
id = "atmodawnfog",
displayName = "AtmoDawnFog",
aliases = {"atmo", "dawnfog"},
category = ModuleCategory.VISUALS,
description = "module.atmodawnfog.description"
)
public final class AtmoDawnFog extends Module {
private static final float TIME_WINDOW = 0.1f;
private static final float ALTITUDE_FADE = 32.0f;
private final ModeValue mode =
modeSetting("atmoDawnFogMode", "mode", "Dawn", "Dawn", "Dusk", "Custom");
private final NumberValue<Float> density =
num("atmoDawnFogDensity", "density", 0.35f, 0.05f, 0.8f);
private final NumberValue<Float> scatterHeight =
num("atmoDawnFogScatterHeight", "scatter_height", 76.0f, 60.0f, 120.0f);
private final NumberValue<Float> softness =
num("atmoDawnFogSoftness", "softness", 0.6f, 0.0f, 1.0f);
private final RGBColorValue dawnColor =
visibleWhen(colorNoAlpha("atmoDawnFogColor", "dawn_color", "#FFAD7A"), () -> "Custom".equals(mode.get()));
private static int dawnArgb() {
return 0xFFFFAD7A;
}
private static int duskArgb() {
return 0xFF5C6BFF;
}
/** Сила эффекта 0..1: время суток, режим и высота камеры. */
public float strength(double cameraY) {
AtmoDawnFog module = Modules.get(AtmoDawnFog.class);
if (module == null || !module.isEnabled()) return 0.0f;
float timeOfDay = (WorldTweaks.getServerTimeOfDay() % 24000L) / 24000.0f;
return strengthFor(timeOfDay, module.mode.get(), cameraY, module.scatterHeight.get());
}
/** Чистая математика силы эффекта — вынесена для тестов. */
static float strengthFor(float timeOfDay, String mode, double cameraY, float scatterHeight) {
float byMode = switch (mode) {
case "Dusk" -> timeCloseness(timeOfDay, 0.5f);
case "Custom" -> 1.0f;
default -> timeCloseness(timeOfDay, 0.0f);
};
if (byMode <= 0.0f) return 0.0f;
float altitude = 1.0f - (float) Math.max(0.0, cameraY - scatterHeight) / ALTITUDE_FADE;
return byMode * Math.max(0.0f, Math.min(1.0f, altitude));
}
private static float timeCloseness(float timeOfDay, float target) {
float delta = Math.abs(timeOfDay - target);
delta = Math.min(delta, 1.0f - delta);
return Math.max(0.0f, 1.0f - delta / TIME_WINDOW);
}
/**
* Применяет туман к FogData. Вызывается из миксина до WorldTweaks-веток;
* false — эффекта нет в этом кадре.
*/
public static boolean applyFog(FogData data, Camera camera, ClientLevel world,
float viewDistance, float tickDelta) {
AtmoDawnFog module = Modules.get(AtmoDawnFog.class);
if (module == null || !module.isEnabled() || data == null || world == null) return false;
if (world.dimension() != Level.OVERWORLD) return false;
if (camera.getFluidInCamera() != FogType.NONE) return false;
WorldTweaks tweaks = Modules.get(WorldTweaks.class);
if (tweaks != null && tweaks.isFogModifyEnabled()) return false;
float strength = module.strength(camera.position().y);
if (strength <= 0.0f) return false;
float density = module.density.get();
float far = Math.max(viewDistance * (1.0f - 0.75f * density * strength), viewDistance * 0.15f);
float start = far * Math.max(0.05f, module.softness.get());
data.environmentalStart = start;
data.environmentalEnd = far;
data.renderDistanceStart = start;
data.renderDistanceEnd = far;
data.skyEnd = far;
data.cloudEnd = far;
return true;
}
/** Подмешивает цвет зари в туман; true — цвет уже изменён. */
public static boolean tintColor(org.joml.Vector4f color, ClientLevel world, double cameraY) {
AtmoDawnFog module = Modules.get(AtmoDawnFog.class);
if (module == null || !module.isEnabled() || color == null || world == null) return false;
WorldTweaks tweaks = Modules.get(WorldTweaks.class);
if (tweaks != null && tweaks.isFogModifyEnabled()) return false;
float strength = module.strength(cameraY) * 0.8f;
if (strength <= 0.0f) return false;
int argb = switch (module.mode.get()) {
case "Dusk" -> duskArgb();
case "Custom" -> module.dawnColor.getArgb();
default -> dawnArgb();
};
float r = ((argb >> 16) & 0xFF) / 255.0f;
float g = ((argb >> 8) & 0xFF) / 255.0f;
float b = (argb & 0xFF) / 255.0f;
color.set(
color.x + (r - color.x) * strength,
color.y + (g - color.y) * strength,
color.z + (b - color.z) * strength,
color.w
);
return true;
}
}

View file

@ -43,6 +43,7 @@ import dev.loki.lovisual.render.engine.uniform.impl.HandBlendUniforms;
import dev.loki.lovisual.render.engine.uniform.impl.HandChamsUniforms; import dev.loki.lovisual.render.engine.uniform.impl.HandChamsUniforms;
import dev.loki.lovisual.render.engine.uniform.impl.HandGlassUniforms; import dev.loki.lovisual.render.engine.uniform.impl.HandGlassUniforms;
import dev.loki.lovisual.render.engine.uniform.impl.HandMetallicUniforms; import dev.loki.lovisual.render.engine.uniform.impl.HandMetallicUniforms;
import dev.loki.lovisual.render.engine.uniform.impl.HandNoiseUniforms;
import dev.loki.lovisual.render.engine.uniform.impl.HandSmokeUniforms; import dev.loki.lovisual.render.engine.uniform.impl.HandSmokeUniforms;
import dev.loki.lovisual.render.iris.IrisHandMaskState; import dev.loki.lovisual.render.iris.IrisHandMaskState;
import dev.loki.lovisual.render.iris.IrisRuntime; import dev.loki.lovisual.render.iris.IrisRuntime;
@ -73,6 +74,7 @@ public class Chams extends Module {
private static final String SETTING_SMOKE_CONTRAST = "smoke_contrast"; private static final String SETTING_SMOKE_CONTRAST = "smoke_contrast";
private static final String SETTING_SMOKE_SWIRL = "smoke_swirl"; private static final String SETTING_SMOKE_SWIRL = "smoke_swirl";
private static final String SETTING_SMOKE_DENSITY = "smoke_density"; private static final String SETTING_SMOKE_DENSITY = "smoke_density";
private static final String SETTING_NOISE_ALPHA = "noise_alpha";
private static final String SETTING_METALLIC_BASE = "metallic_base"; private static final String SETTING_METALLIC_BASE = "metallic_base";
private static final String SETTING_METALLIC_HIGHLIGHT = "metallic_highlight"; private static final String SETTING_METALLIC_HIGHLIGHT = "metallic_highlight";
private static final String SETTING_METALLIC_INTENSITY = "metallic_intensity"; private static final String SETTING_METALLIC_INTENSITY = "metallic_intensity";
@ -114,9 +116,16 @@ public class Chams extends Module {
private static final String SETTING_BALATRO_CONTRAST = "balatro_contrast"; private static final String SETTING_BALATRO_CONTRAST = "balatro_contrast";
private static final String SETTING_BALATRO_LIGHTING = "balatro_lighting"; private static final String SETTING_BALATRO_LIGHTING = "balatro_lighting";
private static final String SETTING_HIDE_HAND = "hide_hand"; private static final String SETTING_HIDE_HAND = "hide_hand";
// Noise mode tuning; intentionally fixed so the mode stays zero-config besides color/alpha.
private static final float NOISE_OCTAVES = 3.0f;
private static final float NOISE_SCALE = 3.4f;
private static final float NOISE_SPEED = 0.85f;
private static final float NOISE_CONTRAST = 1.15f;
private static final float NOISE_BREATHE = 0.55f;
private static final float NOISE_DENSITY = 1.0f;
private final Minecraft mc = Minecraft.getInstance(); private final Minecraft mc = Minecraft.getInstance();
private final ModeValue mode = private final ModeValue mode =
modeSetting("handChamsMode", SETTING_MODE, "Smoke", "Smoke", "Metallic", "Glass", "Snow", "Chroma", "Solid", "Stripes", "Glow", "Balatro"); modeSetting("handChamsMode", SETTING_MODE, "Smoke", "Smoke", "Metallic", "Glass", "Noise", "Snow", "Chroma", "Solid", "Stripes", "Glow", "Balatro");
private final BooleanValue hands = private final BooleanValue hands =
bool("chamsHands", SETTING_HANDS, true); bool("chamsHands", SETTING_HANDS, true);
@ -164,6 +173,8 @@ public class Chams extends Module {
visibleWhen(num("handChamsSmokeSwirl", SETTING_SMOKE_SWIRL, 0.85f, 0.0f, 2.5f), this::isSmokeMode); visibleWhen(num("handChamsSmokeSwirl", SETTING_SMOKE_SWIRL, 0.85f, 0.0f, 2.5f), this::isSmokeMode);
private final NumberValue<Float> smokeDensity = private final NumberValue<Float> smokeDensity =
visibleWhen(num("handChamsSmokeDensity", SETTING_SMOKE_DENSITY, 1.0f, 0.2f, 2.5f), this::isSmokeMode); visibleWhen(num("handChamsSmokeDensity", SETTING_SMOKE_DENSITY, 1.0f, 0.2f, 2.5f), this::isSmokeMode);
private final NumberValue<Float> noiseAlpha =
visibleWhen(num("handChamsNoiseAlpha", SETTING_NOISE_ALPHA, 0.85f, 0.0f, 1.0f), this::isNoiseMode);
private final NumberValue<Float> metallicIntensity = private final NumberValue<Float> metallicIntensity =
visibleWhen(num("handChamsMetallicIntensity", SETTING_METALLIC_INTENSITY, 1.35f, 0.0f, 5.0f), this::isMetallicMode); visibleWhen(num("handChamsMetallicIntensity", SETTING_METALLIC_INTENSITY, 1.35f, 0.0f, 5.0f), this::isMetallicMode);
private final NumberValue<Float> metallicSharpness = private final NumberValue<Float> metallicSharpness =
@ -548,8 +559,12 @@ public class Chams extends Module {
return "Metallic".equals(mode.get()); return "Metallic".equals(mode.get());
} }
private boolean isNoiseMode() {
return "Noise".equals(mode.get());
}
private boolean isHandEffectMode() { private boolean isHandEffectMode() {
return isSmokeMode() || isMetallicMode(); return isSmokeMode() || isMetallicMode() || isNoiseMode();
} }
private boolean isGlowVisible() { private boolean isGlowVisible() {
@ -766,6 +781,8 @@ public class Chams extends Module {
.sampler("u_Src", src, PostProcessManager.getSampler()) .sampler("u_Src", src, PostProcessManager.getSampler())
.sampler("u_Mask", maskView, getHandMaskSampler()) .sampler("u_Mask", maskView, getHandMaskSampler())
.end(); .end();
} else if ("Noise".equals(mode.get())) {
renderNoise(src, dst, maskView, rawTime);
} else { } else {
renderChamsEffect(src, dst, maskView, mode.get(), rawTime); renderChamsEffect(src, dst, maskView, mode.get(), rawTime);
} }
@ -844,6 +861,33 @@ public class Chams extends Module {
.end(); .end();
} }
private void renderNoise(GpuTextureView src, GpuTextureView dst, GpuTextureView maskView, float rawTime) {
int fillRgb = materialFillRgb();
int glowRgb = glowRgb(fillRgb);
float fillA = hideHand.get() ? 1.0f : clamp01(noiseAlpha.get());
float glowA = glow.get() ? glowAlpha.get() : 0.0f;
int w = mc.getWindow().getWidth();
int h = mc.getWindow().getHeight();
HandNoiseUniforms.update(
channel(fillRgb, 16), channel(fillRgb, 8), channel(fillRgb, 0), fillA,
channel(glowRgb, 16), channel(glowRgb, 8), channel(glowRgb, 0), glowA,
edgeWidth.get(), quality.get(), NOISE_OCTAVES, rawTime,
w, h, NOISE_SCALE, NOISE_SPEED,
NOISE_CONTRAST, NOISE_BREATHE, glowStrength.get(), NOISE_DENSITY
);
FullScreenRenderer.begin("LoVisual Fullscreen Pass")
.attachment(dst)
.pipeline(LoVisualRenderPipelines.HAND_NOISE)
.uniform("HandNoise", HandNoiseUniforms.get())
.sampler("u_Src", src, PostProcessManager.getSampler())
.sampler("u_Mask", maskView, getHandMaskSampler())
.end();
}
private void renderDualShader(GpuTextureView src, GpuTextureView dst, GpuTextureView maskView, float rawTime) { private void renderDualShader(GpuTextureView src, GpuTextureView dst, GpuTextureView maskView, float rawTime) {
ensureDualBuffers(); ensureDualBuffers();
if (dualA == null || dualB == null if (dualA == null || dualB == null

View file

@ -12,11 +12,18 @@ import dev.loki.lovisual.config.values.NumberValue;
import dev.loki.lovisual.features.module.Module; import dev.loki.lovisual.features.module.Module;
import dev.loki.lovisual.features.module.ModuleCategory; import dev.loki.lovisual.features.module.ModuleCategory;
import dev.loki.lovisual.features.module.ModuleInfo; import dev.loki.lovisual.features.module.ModuleInfo;
import dev.loki.lovisual.util.animation.EaseCurves;
import dev.loki.lovisual.util.animation.hand.HandAnimState;
import dev.loki.lovisual.render.helpers.TickDelta;
import net.minecraft.client.Minecraft; import net.minecraft.client.Minecraft;
import net.minecraft.client.player.LocalPlayer;
import net.minecraft.world.InteractionHand;
/** /**
* Smooths and adds a subtle hand-held sway to first-person item rendering, so weapons and tools * Плавная анимация предмета в руке: ease-out-back equip, sway по обзору,
* settle into place with an ease-out-back curve instead of vanilla's linear equip motion. * боб при ходьбе, дрейф при использовании. Состояние шагает ровно раз за
* кадр в {@link #onFrame} — вне рендер-пути, поэтому повторный рендер рук
* (маска Chams) всегда видит те же значения, что и основной проход.
*/ */
@ModuleInfo( @ModuleInfo(
id = "holdmyitems", id = "holdmyitems",
@ -27,30 +34,44 @@ import net.minecraft.client.Minecraft;
) )
public final class HoldMyItems extends Module { public final class HoldMyItems extends Module {
public final Minecraft mc = Minecraft.getInstance(); private final HandAnimState handAnim = new HandAnimState();
public final NumberValue<Float> swayIntensity = num("sway_intensity", 1.0f, 0.0f, 2.0f); public final NumberValue<Float> swayIntensity = num("sway_intensity", 1.0f, 0.0f, 2.0f);
public final NumberValue<Float> easeStrength = num("ease_strength", 1.6f, 0.5f, 3.5f); public final NumberValue<Float> easeStrength = num("ease_strength", 1.6f, 0.5f, 3.5f);
public final BooleanValue offhandToo = bool("offhand", true); public final BooleanValue offhandToo = bool("offhand", true);
public final BooleanValue walkBob = bool("walk_bob", true);
public final NumberValue<Float> bobStrength = visibleWhen(num("bob_strength", 1.0f, 0.0f, 2.0f), walkBob::get);
public final BooleanValue useDrift = bool("use_drift", true);
public final NumberValue<Float> driftStrength = visibleWhen(num("drift_strength", 1.0f, 0.0f, 2.0f), useDrift::get);
public final BooleanValue joinAppear = bool("join_appear", true);
/** public HandAnimState handAnim() {
* Ease-out-back: overshoots slightly past 1.0 before settling, giving the equip motion weight. return handAnim;
* Generalized with a configurable strength.
*/
public float easeOutBack(float x) {
float c1 = easeStrength.get();
float c3 = c1 + 1.0f;
float t = x - 1.0f;
return 1.0f + c3 * t * t * t + c1 * t * t;
} }
/** @Override
* Reshapes the raw [0,1] equip progress with the ease-out-back curve, then blends by intensity public void onFrame(float tickDelta) {
* so a intensity of 0 falls back to vanilla's linear motion. if (!isEnabled()) return;
*/ LocalPlayer player = Minecraft.getInstance().player;
if (player == null) return;
handAnim.beginFrame(
player,
Minecraft.getInstance().level,
player.getViewYRot(tickDelta),
player.getViewXRot(tickDelta),
player.isUsingItem() && player.getUsedItemHand() == InteractionHand.MAIN_HAND,
player.isUsingItem() && player.getUsedItemHand() == InteractionHand.OFF_HAND,
TickDelta.fixedDeltaTicks()
);
}
@Override
public void onEnable() {
handAnim.reset();
}
/** Перекладывает линейный equip-прогресс через ease-out-back с настройками модуля. */
public float shapeEquipProgress(float linear) { public float shapeEquipProgress(float linear) {
float eased = easeOutBack(linear); return EaseCurves.shapeEquipProgress(linear, easeStrength.get(), swayIntensity.get());
float amount = swayIntensity.get();
return linear + (eased - linear) * amount;
} }
} }

View file

@ -7,23 +7,15 @@
package dev.loki.lovisual.features.module.modules.visuals; package dev.loki.lovisual.features.module.modules.visuals;
import dev.loki.lovisual.config.values.BooleanValue;
import dev.loki.lovisual.config.values.NumberValue; import dev.loki.lovisual.config.values.NumberValue;
import dev.loki.lovisual.config.values.RGBAColorValue;
import dev.loki.lovisual.features.module.Module; import dev.loki.lovisual.features.module.Module;
import dev.loki.lovisual.features.module.ModuleCategory; import dev.loki.lovisual.features.module.ModuleCategory;
import dev.loki.lovisual.features.module.ModuleInfo; import dev.loki.lovisual.features.module.ModuleInfo;
import dev.loki.lovisual.features.module.WorldPhase;
import dev.loki.lovisual.features.relations.PlayerRelations;
import dev.loki.lovisual.render.engine.RenderState;
import dev.loki.lovisual.render.engine.pipeline.LoVisualRenderPipelines;
import dev.loki.lovisual.render.engine.renderer.Renderer3D;
import dev.loki.lovisual.render.engine.uniform.MeshBuilder;
import net.minecraft.client.Minecraft;
import net.minecraft.world.effect.MobEffects;
import net.minecraft.world.entity.player.Player;
import net.minecraft.world.phys.AABB;
/**
* Делает невидимых игроков видимыми: модель рендерится полупрозрачной,
* сила прозрачности регулируется слайдером.
*/
@ModuleInfo( @ModuleInfo(
id = "invisesp", id = "invisesp",
displayName = "InvisESP", displayName = "InvisESP",
@ -33,55 +25,19 @@ import net.minecraft.world.phys.AABB;
) )
public final class InvisEsp extends Module { public final class InvisEsp extends Module {
private final Minecraft mc = Minecraft.getInstance(); private final NumberValue<Float> opacity =
num("invisesp_opacity", "opacity", 0.5f, 0.1f, 1.0f);
private final RGBAColorValue outlineColor =
color("invisesp_color", "color", "#FF00E5FF");
private final BooleanValue highlightFriends =
bool("invisesp_highlight_friends", "highlight_friends", true);
private final RGBAColorValue friendColor =
visibleWhen(color("invisesp_friend_color", "friend_color", "#FF00FF00"), highlightFriends::get);
private final NumberValue<Float> lineWidth =
num("invisesp_line_width", "line_width", 1.5f, 0.5f, 6.0f);
{ {
setDefaultBind("L"); setDefaultBind("L");
} }
@Override public float getOpacity() {
public WorldPhase getWorldPhase() { return opacity.get();
return WorldPhase.END_MAIN;
} }
@Override /** Прозрачный рендер имеет смысл только ниже полной непрозрачности. */
public void onRenderWorldEngine(Renderer3D renderer, Renderer3D depthRenderer, float tickDelta) { public boolean isTranslucentReveal() {
if (!isEnabled() || mc.level == null || mc.player == null) return; return opacity.get() < 0.99f;
float previousLineWidth = RenderState.lineWidth;
MeshBuilder lines;
try {
RenderState.lineWidth = lineWidth.get();
lines = renderer.batch(LoVisualRenderPipelines.WORLD_COLORED_LINES, Renderer3D.DepthMode.NONE);
} finally {
RenderState.lineWidth = previousLineWidth;
}
int defaultColor = outlineColor.getArgb();
boolean highlightFriendsEnabled = highlightFriends.get();
for (Player player : mc.level.players()) {
if (player == mc.player) continue;
if (!isInvisible(player)) continue;
int color = highlightFriendsEnabled && PlayerRelations.get().isFriendOrStaff(player.getGameProfile().name())
? friendColor.getArgb()
: defaultColor;
AABB box = Esp2DRenderer.resolveWorldBox(player, tickDelta);
EspMeshBuilder.addOutlineBox(lines, box, color, color, color, color, color, color, color, color);
}
}
private boolean isInvisible(Player player) {
return player.isInvisible() || player.hasEffect(MobEffects.INVISIBILITY);
} }
} }

View file

@ -0,0 +1,52 @@
/*
* This file is part of the LoVisual Client distribution.
* Copyright (c) 2026 loki5512344.
*
* Licensed under the GNU General Public License v3.0.
*/
package dev.loki.lovisual.features.module.modules.visuals;
import dev.loki.lovisual.config.values.BooleanValue;
import dev.loki.lovisual.features.module.Module;
import dev.loki.lovisual.features.module.ModuleCategory;
import dev.loki.lovisual.features.module.ModuleInfo;
/**
* Физика предметов на земле: предметы лежат плашмя, крутятся в воздухе,
* без ваниллового покачивания; опционально уменьшенный размер.
*/
@ModuleInfo(
id = "itemphysic",
displayName = "Item Physic",
aliases = {"itemphysic", "physics"},
category = ModuleCategory.VISUALS,
description = "module.itemphysic.description"
)
public final class ItemPhysic extends Module {
private final BooleanValue reduceSize =
bool("itemphysicReduceSize", "reduce_size", false);
private final BooleanValue layFlat =
bool("itemphysicLayFlat", "lay_flat", true);
private final BooleanValue spinInAir =
bool("itemphysicSpinInAir", "spin_in_air", true);
private final BooleanValue noBob =
bool("itemphysicNoBob", "no_bob", true);
public boolean shouldReduceSize() {
return isEnabled() && reduceSize.get();
}
public boolean shouldLayFlat() {
return isEnabled() && layFlat.get();
}
public boolean shouldSpinInAir() {
return isEnabled() && spinInAir.get();
}
public boolean shouldCancelBob() {
return isEnabled() && noBob.get();
}
}

View file

@ -54,7 +54,7 @@ public class PostFX extends Module implements PostProcessPass {
private final Minecraft mc = Minecraft.getInstance(); private final Minecraft mc = Minecraft.getInstance();
private final ModeValue preset = private final ModeValue preset =
modeSetting("postFxPreset", SETTING_PRESET, "Vanilla", "Vanilla", "Soft", "Warm", "Cool", "Custom"); modeSetting("postFxPreset", SETTING_PRESET, "Cinematic", "Cinematic", "Vanilla", "Soft", "Warm", "Cool", "Custom");
private final ModeValue lutPreset = private final ModeValue lutPreset =
visibleWhen(modeSetting("postFxLutPreset", SETTING_LUT_PRESET, "Identity", "Identity", "Warm", "Cool"), this::isCustomPreset); visibleWhen(modeSetting("postFxLutPreset", SETTING_LUT_PRESET, "Identity", "Identity", "Warm", "Cool"), this::isCustomPreset);
@ -221,7 +221,7 @@ public class PostFX extends Module implements PostProcessPass {
private float resolveLutMode(String presetName, boolean isCustom) { private float resolveLutMode(String presetName, boolean isCustom) {
String pick = isCustom ? lutPreset.get() : presetName; String pick = isCustom ? lutPreset.get() : presetName;
return switch (pick) { return switch (pick) {
case "Warm" -> 1.0f; case "Warm", "Cinematic" -> 1.0f;
case "Cool" -> 2.0f; case "Cool" -> 2.0f;
default -> 0.0f; default -> 0.0f;
}; };
@ -236,100 +236,135 @@ public class PostFX extends Module implements PostProcessPass {
} }
private void applyPreset(String name) { private void applyPreset(String name) {
String presetName = name == null ? "Vanilla" : name; applyPresetTo(name, ubo);
}
/** Чистое заполнение UBO пресетом — вынесено для тестов. */
static void applyPresetTo(String name, float[] out) {
String presetName = name == null ? "Cinematic" : name;
switch (presetName) { switch (presetName) {
case "Soft" -> fillPresetSoft(); case "Vanilla" -> fillPresetVanilla(out);
case "Warm" -> fillPresetWarm(); case "Warm" -> fillPresetWarm(out);
case "Cool" -> fillPresetCool(); case "Cool" -> fillPresetCool(out);
default -> fillPresetVanilla(); case "Cinematic" -> fillPresetCinematic(out);
default -> fillPresetSoft(out);
} }
} }
private void fillPresetVanilla() { private static void setTint(float[] out, int offset, float r, float g, float b, float strength) {
ubo[0] = 0.0f; // exposure out[offset] = r;
ubo[1] = 1.0f; // gamma out[offset + 1] = g;
ubo[2] = 1.0f; // contrast out[offset + 2] = b;
ubo[3] = 1.0f; // saturation out[offset + 3] = strength;
ubo[4] = 1.0f; // vibrance
ubo[5] = 0.0f; // temp
ubo[6] = 0.0f; // tint
ubo[7] = 0.0f; // lut strength
ubo[8] = 0.08f; // vignette strength
ubo[9] = 0.55f; // vignette radius
ubo[10] = 0.35f; // vignette softness
ubo[11] = 0.0f; // chroma
ubo[12] = 0.0f; // grain amount
ubo[13] = 1.0f; // grain size
ubo[14] = 0.05f; // sharpen
ubo[15] = 0.0f; // tone map none
fillColor(ubo, 16, 0xFFFFFFFF, 0.0f);
fillColor(ubo, 20, 0xFFFFFFFF, 0.0f);
fillColor(ubo, 24, 0xFFFFFFFF, 0.0f);
} }
private void fillPresetSoft() { private static void fillPresetCinematic(float[] out) {
ubo[0] = 0.0f; out[0] = 0.02f; // exposure
ubo[1] = 1.0f; out[1] = 1.0f; // gamma
ubo[2] = 1.0f; out[2] = 1.10f; // contrast
ubo[3] = 1.01f; out[3] = 1.14f; // saturation
ubo[4] = 1.0f; out[4] = 1.08f; // vibrance
ubo[5] = 0.0f; out[5] = 0.06f; // temp (теплее)
ubo[6] = 0.0f; out[6] = 0.0f; // tint
ubo[7] = 0.18f; out[7] = 0.30f; // LUT strength (warm)
ubo[8] = 0.08f; out[8] = 0.18f; // vignette strength
ubo[9] = 0.55f; out[9] = 0.55f; // vignette radius
ubo[10] = 0.35f; out[10] = 0.35f; // vignette softness
ubo[11] = 0.01f; out[11] = 0.0f; // chroma
ubo[12] = 0.0f; out[12] = 0.0f; // grain
ubo[13] = 1.0f; out[13] = 1.0f; // grain size
ubo[14] = 0.05f; out[14] = 0.10f; // sharpen
ubo[15] = 0.0f; out[15] = 1.0f; // tone map: filmic
fillColor(ubo, 16, 0xFFFFFFFF, 0.0f); setTint(out, 16, 0.72f, 0.80f, 1.0f, 0.22f); // тени — холодные
fillColor(ubo, 20, 0xFFFFFFFF, 0.0f); setTint(out, 20, 1.0f, 1.0f, 1.0f, 0.0f); // полутона — нейтральные
fillColor(ubo, 24, 0xFFFFFFFF, 0.0f); setTint(out, 24, 1.0f, 0.93f, 0.80f, 0.18f); // света — тёплые
} }
private void fillPresetWarm() { private static void fillPresetVanilla(float[] out) {
ubo[0] = 0.0f; out[0] = 0.0f; // exposure
ubo[1] = 1.0f; out[1] = 1.0f; // gamma
ubo[2] = 1.01f; out[2] = 1.0f; // contrast
ubo[3] = 1.02f; out[3] = 1.0f; // saturation
ubo[4] = 1.01f; out[4] = 1.0f; // vibrance
ubo[5] = 0.03f; out[5] = 0.0f; // temp
ubo[6] = 0.01f; out[6] = 0.0f; // tint
ubo[7] = 0.18f; out[7] = 0.0f; // lut strength
ubo[8] = 0.08f; out[8] = 0.08f; // vignette strength
ubo[9] = 0.55f; out[9] = 0.55f; // vignette radius
ubo[10] = 0.35f; out[10] = 0.35f; // vignette softness
ubo[11] = 0.01f; out[11] = 0.0f; // chroma
ubo[12] = 0.0f; out[12] = 0.0f; // grain amount
ubo[13] = 1.0f; out[13] = 1.0f; // grain size
ubo[14] = 0.05f; out[14] = 0.05f; // sharpen
ubo[15] = 0.0f; out[15] = 0.0f; // tone map none
fillColor(ubo, 16, 0xFFFFFFFF, 0.0f); setTint(out, 16, 1f, 1f, 1f, 0.0f);
fillColor(ubo, 20, 0xFFFFFFFF, 0.0f); setTint(out, 20, 1f, 1f, 1f, 0.0f);
fillColor(ubo, 24, 0xFFFFFFFF, 0.0f); setTint(out, 24, 1f, 1f, 1f, 0.0f);
} }
private void fillPresetCool() { private static void fillPresetSoft(float[] out) {
ubo[0] = 0.0f; out[0] = 0.0f;
ubo[1] = 1.0f; out[1] = 1.0f;
ubo[2] = 1.01f; out[2] = 1.01f;
ubo[3] = 1.0f; out[3] = 1.01f;
ubo[4] = 1.01f; out[4] = 1.0f;
ubo[5] = -0.04f; out[5] = 0.0f;
ubo[6] = 0.0f; out[6] = 0.0f;
ubo[7] = 0.18f; out[7] = 0.18f;
ubo[8] = 0.08f; out[8] = 0.08f;
ubo[9] = 0.55f; out[9] = 0.55f;
ubo[10] = 0.35f; out[10] = 0.35f;
ubo[11] = 0.01f; out[11] = 0.01f;
ubo[12] = 0.0f; out[12] = 0.0f;
ubo[13] = 1.0f; out[13] = 1.0f;
ubo[14] = 0.05f; out[14] = 0.05f;
ubo[15] = 0.0f; out[15] = 0.0f;
fillColor(ubo, 16, 0xFFFFFFFF, 0.0f); setTint(out, 16, 1f, 1f, 1f, 0.0f);
fillColor(ubo, 20, 0xFFFFFFFF, 0.0f); setTint(out, 20, 1f, 1f, 1f, 0.0f);
fillColor(ubo, 24, 0xFFFFFFFF, 0.0f); setTint(out, 24, 1f, 1f, 1f, 0.0f);
}
private static void fillPresetWarm(float[] out) {
out[0] = 0.0f;
out[1] = 1.0f;
out[2] = 1.01f;
out[3] = 1.02f;
out[4] = 1.01f;
out[5] = 0.03f;
out[6] = 0.01f;
out[7] = 0.18f;
out[8] = 0.08f;
out[9] = 0.55f;
out[10] = 0.35f;
out[11] = 0.01f;
out[12] = 0.0f;
out[13] = 1.0f;
out[14] = 0.05f;
out[15] = 0.0f;
setTint(out, 16, 1f, 1f, 1f, 0.0f);
setTint(out, 20, 1f, 1f, 1f, 0.0f);
setTint(out, 24, 1f, 1f, 1f, 0.0f);
}
private static void fillPresetCool(float[] out) {
out[0] = 0.0f;
out[1] = 1.0f;
out[2] = 1.01f;
out[3] = 1.0f;
out[4] = 1.01f;
out[5] = -0.04f;
out[6] = 0.0f;
out[7] = 0.18f;
out[8] = 0.08f;
out[9] = 0.55f;
out[10] = 0.35f;
out[11] = 0.01f;
out[12] = 0.0f;
out[13] = 1.0f;
out[14] = 0.05f;
out[15] = 0.0f;
setTint(out, 16, 1f, 1f, 1f, 0.0f);
setTint(out, 20, 1f, 1f, 1f, 0.0f);
setTint(out, 24, 1f, 1f, 1f, 0.0f);
} }
} }

View file

@ -0,0 +1,154 @@
/*
* This file is part of the LoVisual Client distribution.
* Copyright (c) 2026 loki5512344.
*
* Licensed under the GNU General Public License v3.0.
*/
package dev.loki.lovisual.features.module.modules.visuals;
import dev.loki.lovisual.config.values.NumberValue;
import dev.loki.lovisual.features.gui.hud.HudRenderUtil;
import dev.loki.lovisual.features.module.Module;
import dev.loki.lovisual.features.module.ModuleCategory;
import dev.loki.lovisual.features.module.ModuleInfo;
import dev.loki.lovisual.features.module.Modules;
import dev.loki.lovisual.features.theme.Theme;
import dev.loki.lovisual.features.theme.Themes;
import net.minecraft.client.Minecraft;
import net.minecraft.client.gui.Font;
import net.minecraft.client.gui.GuiGraphicsExtractor;
import net.minecraft.client.renderer.RenderPipelines;
import net.minecraft.core.NonNullList;
import net.minecraft.core.component.DataComponents;
import net.minecraft.util.Mth;
import net.minecraft.world.item.BlockItem;
import net.minecraft.world.item.ItemStack;
import net.minecraft.world.item.component.ItemContainerContents;
import net.minecraft.world.level.block.ShulkerBoxBlock;
@ModuleInfo(
id = "shulkerpreview",
displayName = "Shulker Preview",
aliases = {"shulkerpreview", "preview"},
category = ModuleCategory.VISUALS,
description = "module.shulkerpreview.description"
)
public final class ShulkerPreview extends Module {
private static final int COLS = 9;
private static final int ROWS = 3;
private static final int SLOT_SIZE = 18;
private static final int PAD = 4;
private static final int HEADER_HEIGHT = 10;
private static final int HEADER_GAP = 3;
private static final int CURSOR_OFFSET = 14;
private static final int BG_ALPHA = 0xE6;
private static final int STROKE_ALPHA = 0xDC;
private static final int CELL_ALPHA = 0x8C;
private final NumberValue<Float> scale =
num("shulkerpreview_scale", "scale", 1.0f, 0.5f, 2.0f);
/**
* Called from AbstractContainerScreenMixin at the end of screen extraction.
*/
public static void onScreenExtract(GuiGraphicsExtractor ctx, ItemStack stack, int mouseX, int mouseY) {
ShulkerPreview module = Modules.get(ShulkerPreview.class);
if (module == null || !module.isEnabled()) return;
module.drawPreview(ctx, stack, mouseX, mouseY);
}
private void drawPreview(GuiGraphicsExtractor ctx, ItemStack stack, int mouseX, int mouseY) {
if (ctx == null || !isShulkerBox(stack)) return;
NonNullList<ItemStack> contents = collectContents(stack);
if (contents == null) return;
Minecraft mc = Minecraft.getInstance();
if (mc == null || mc.font == null) return;
float panelScale = Mth.clamp(scale.get(), 0.5f, 2.0f);
int width = PAD * 2 + COLS * SLOT_SIZE;
int height = PAD * 2 + HEADER_HEIGHT + HEADER_GAP + ROWS * SLOT_SIZE;
int x = horizontalOrigin(mouseX, width * panelScale, ctx.guiWidth());
int y = verticalOrigin(mouseY, height * panelScale, ctx.guiHeight());
Themes.Theme theme = Theme.theme();
int bg = HudRenderUtil.setAlpha(theme.windowBg(), BG_ALPHA);
int stroke = HudRenderUtil.setAlpha(
HudRenderUtil.mixColor(theme.accentSoft(), theme.accent(), 0.34f),
STROKE_ALPHA);
int headerColor = HudRenderUtil.setAlpha(theme.textPrimary(), 0xFF);
int cellColor = HudRenderUtil.setAlpha(theme.surface(), CELL_ALPHA);
Font font = mc.font;
ctx.pose().pushMatrix();
ctx.pose().translate(x, y);
ctx.pose().scale(panelScale, panelScale);
try {
ctx.fill(RenderPipelines.GUI, 0, 0, width, height, bg);
ctx.outline(0, 0, width, height, stroke);
ctx.text(font, stack.getHoverName(), PAD, PAD, headerColor);
int gridY = PAD + HEADER_HEIGHT + HEADER_GAP;
for (int row = 0; row < ROWS; row++) {
for (int col = 0; col < COLS; col++) {
int cellX = PAD + col * SLOT_SIZE;
int cellY = gridY + row * SLOT_SIZE;
ctx.fill(RenderPipelines.GUI,
cellX, cellY, cellX + SLOT_SIZE, cellY + SLOT_SIZE,
cellColor);
ItemStack item = contents.get(row * COLS + col);
if (item == null || item.isEmpty()) continue;
ctx.item(item, cellX + 1, cellY + 1);
ctx.itemDecorations(font, item, cellX + 1, cellY + 1);
}
}
} finally {
ctx.pose().popMatrix();
}
}
private static NonNullList<ItemStack> collectContents(ItemStack stack) {
ItemContainerContents container = stack.get(DataComponents.CONTAINER);
if (container == null) return null;
NonNullList<ItemStack> contents = NonNullList.withSize(COLS * ROWS, ItemStack.EMPTY);
container.copyInto(contents);
for (ItemStack item : contents) {
if (item != null && !item.isEmpty()) {
return contents;
}
}
return null;
}
private static boolean isShulkerBox(ItemStack stack) {
if (stack == null || stack.isEmpty()) return false;
if (!(stack.getItem() instanceof BlockItem blockItem)) return false;
return blockItem.getBlock() instanceof ShulkerBoxBlock;
}
private static int horizontalOrigin(int mouseX, float width, int bounds) {
int right = (int) (mouseX + CURSOR_OFFSET);
if (right + width <= bounds) return right;
int left = (int) (mouseX - CURSOR_OFFSET - width);
if (left >= 0) return left;
int clamped = (int) (bounds - width);
return Math.max(0, Math.min(right, clamped));
}
private static int verticalOrigin(int mouseY, float height, int bounds) {
int top = (int) (mouseY - height / 2f);
if (top + height >= bounds) {
top = (int) (bounds - height);
}
return Math.max(0, top);
}
}

View file

@ -61,7 +61,6 @@ public class WorldTweaks extends Module {
private static final String SETTING_CHANGE_TIME = "change_time"; private static final String SETTING_CHANGE_TIME = "change_time";
private static final String SETTING_TIME_HOURS = "time_hours"; private static final String SETTING_TIME_HOURS = "time_hours";
private static final String SETTING_SYNC_SYSTEM_TIME = "sync_system_time"; private static final String SETTING_SYNC_SYSTEM_TIME = "sync_system_time";
private static final String SETTING_ITEM_PHYSICS = "item_physics";
private static volatile long serverTimeOfDay; private static volatile long serverTimeOfDay;
private final BooleanValue fogControlEnabled = private final BooleanValue fogControlEnabled =
bool("worldTweaksFogControl", SETTING_FOG_CONTROL, true); bool("worldTweaksFogControl", SETTING_FOG_CONTROL, true);
@ -128,8 +127,6 @@ public class WorldTweaks extends Module {
private final NumberValue<Integer> timeHours = private final NumberValue<Integer> timeHours =
visibleWhen(num("worldTweaksTimeHours", SETTING_TIME_HOURS, 21, 0, 23), visibleWhen(num("worldTweaksTimeHours", SETTING_TIME_HOURS, 21, 0, 23),
() -> changeTime.get() && !syncSystemTime.get()); () -> changeTime.get() && !syncSystemTime.get());
private final BooleanValue itemPhysicsEnabled =
bool("worldTweaksItemPhysics", SETTING_ITEM_PHYSICS, true);
private boolean wasOverridingTime; private boolean wasOverridingTime;
private static int getHourTicks(int hour) { private static int getHourTicks(int hour) {
@ -224,9 +221,6 @@ public class WorldTweaks extends Module {
return isEnabled() && changeTime.get(); return isEnabled() && changeTime.get();
} }
public boolean isItemPhysicsEnabled() {
return isEnabled() && itemPhysicsEnabled.get();
}
public int getTimeOfDayTicks() { public int getTimeOfDayTicks() {
if (syncSystemTime.get()) { if (syncSystemTime.get()) {

View file

@ -7,8 +7,17 @@
package dev.loki.lovisual.mixins; package dev.loki.lovisual.mixins;
import dev.loki.lovisual.features.module.Modules;
import dev.loki.lovisual.features.module.modules.visuals.Animations;
import dev.loki.lovisual.features.module.modules.visuals.ShulkerPreview;
import net.minecraft.client.gui.GuiGraphicsExtractor;
import net.minecraft.client.gui.screens.inventory.AbstractContainerScreen; import net.minecraft.client.gui.screens.inventory.AbstractContainerScreen;
import net.minecraft.world.inventory.Slot;
import net.minecraft.world.item.ItemStack;
import org.joml.Matrix3x2fStack;
import org.spongepowered.asm.mixin.Mixin; import org.spongepowered.asm.mixin.Mixin;
import org.spongepowered.asm.mixin.Shadow;
import org.spongepowered.asm.mixin.Unique;
import org.spongepowered.asm.mixin.injection.At; import org.spongepowered.asm.mixin.injection.At;
import org.spongepowered.asm.mixin.injection.Inject; import org.spongepowered.asm.mixin.injection.Inject;
import org.spongepowered.asm.mixin.injection.callback.CallbackInfo; import org.spongepowered.asm.mixin.injection.callback.CallbackInfo;
@ -16,8 +25,77 @@ import org.spongepowered.asm.mixin.injection.callback.CallbackInfo;
@Mixin(AbstractContainerScreen.class) @Mixin(AbstractContainerScreen.class)
public abstract class AbstractContainerScreenMixin { public abstract class AbstractContainerScreenMixin {
@Shadow
protected Slot hoveredSlot;
@Unique
private boolean lovisual$animations$scaleApplied;
@Inject(method = "onClose", at = @At("HEAD"), cancellable = true) @Inject(method = "onClose", at = @At("HEAD"), cancellable = true)
private void lovisual$inventoryMove$deferClose(CallbackInfo ci) { private void lovisual$inventoryMove$deferClose(CallbackInfo ci) {
// InventoryMove module removed; keep hook for compatibility. // InventoryMove module removed; keep hook for compatibility.
} }
/**
* Smooth open animation from the Animations module: the whole screen is drawn
* with a scale between 0.9 and 1.0 around the screen center. The animated
* value itself is advanced once per frame inside the module; this only reads
* the settled value and wraps the extraction in a matching push/pop.
*/
@Inject(
method = "extractRenderState(Lnet/minecraft/client/gui/GuiGraphicsExtractor;IIF)V",
at = @At("HEAD")
)
private void lovisual$animations$beginOpenScale(GuiGraphicsExtractor graphics,
int mouseX,
int mouseY,
float partialTick,
CallbackInfo ci) {
lovisual$animations$scaleApplied = false;
Animations animations = Modules.get(Animations.class);
if (animations == null) return;
float scale = animations.getInventoryScale();
if (scale >= 0.999f) return;
Matrix3x2fStack pose = graphics.pose();
float centerX = graphics.guiWidth() * 0.5f;
float centerY = graphics.guiHeight() * 0.5f;
pose.pushMatrix();
pose.translate(centerX, centerY);
pose.scale(scale, scale);
pose.translate(-centerX, -centerY);
lovisual$animations$scaleApplied = true;
}
@Inject(
method = "extractRenderState(Lnet/minecraft/client/gui/GuiGraphicsExtractor;IIF)V",
at = @At("RETURN")
)
private void lovisual$animations$endOpenScale(GuiGraphicsExtractor graphics,
int mouseX,
int mouseY,
float partialTick,
CallbackInfo ci) {
if (!lovisual$animations$scaleApplied) return;
lovisual$animations$scaleApplied = false;
graphics.pose().popMatrix();
}
@Inject(
method = "extractRenderState(Lnet/minecraft/client/gui/GuiGraphicsExtractor;IIF)V",
at = @At("TAIL")
)
private void lovisual$shulkerPreview$draw(GuiGraphicsExtractor graphics,
int mouseX,
int mouseY,
float partialTick,
CallbackInfo ci) {
Slot slot = this.hoveredSlot;
ItemStack stack = slot == null ? ItemStack.EMPTY : slot.getItem();
if (stack.isEmpty()) return;
ShulkerPreview.onScreenExtract(graphics, stack, mouseX, mouseY);
}
} }

View file

@ -18,6 +18,7 @@ import org.spongepowered.asm.mixin.injection.At;
import org.spongepowered.asm.mixin.injection.Inject; import org.spongepowered.asm.mixin.injection.Inject;
import org.spongepowered.asm.mixin.injection.callback.CallbackInfo; import org.spongepowered.asm.mixin.injection.callback.CallbackInfo;
import dev.loki.lovisual.features.module.Modules; import dev.loki.lovisual.features.module.Modules;
import dev.loki.lovisual.features.module.modules.visuals.AtmoDawnFog;
import dev.loki.lovisual.features.module.modules.visuals.WorldTweaks; import dev.loki.lovisual.features.module.modules.visuals.WorldTweaks;
@Mixin(value = AtmosphericFogEnvironment.class, priority = 1200) @Mixin(value = AtmosphericFogEnvironment.class, priority = 1200)
@ -30,6 +31,12 @@ public class AtmosphericFogEnvironmentMixin {
float viewDistance, float viewDistance,
DeltaTracker tickCounter, DeltaTracker tickCounter,
CallbackInfo ci) { CallbackInfo ci) {
if (AtmoDawnFog.applyFog(data, camera, world, viewDistance,
tickCounter.getGameTimeDeltaPartialTick(false))) {
ci.cancel();
return;
}
WorldTweaks module = Modules.get(WorldTweaks.class); WorldTweaks module = Modules.get(WorldTweaks.class);
if (module == null || !module.isFogControlEnabled()) return; if (module == null || !module.isFogControlEnabled()) return;
if (module.isFogModifyEnabled()) { if (module.isFogModifyEnabled()) {

View file

@ -7,10 +7,14 @@
package dev.loki.lovisual.mixins; package dev.loki.lovisual.mixins;
import dev.loki.lovisual.features.module.Modules;
import dev.loki.lovisual.features.module.modules.misc.NoServerPack;
import net.minecraft.client.multiplayer.ClientCommonPacketListenerImpl; import net.minecraft.client.multiplayer.ClientCommonPacketListenerImpl;
import net.minecraft.client.multiplayer.ClientPacketListener; import net.minecraft.client.multiplayer.ClientPacketListener;
import net.minecraft.network.Connection; import net.minecraft.network.Connection;
import net.minecraft.network.protocol.Packet; import net.minecraft.network.protocol.Packet;
import net.minecraft.network.protocol.common.ClientboundResourcePackPushPacket;
import net.minecraft.network.protocol.common.ServerboundResourcePackPacket;
import net.minecraft.network.protocol.game.ServerboundMovePlayerPacket; import net.minecraft.network.protocol.game.ServerboundMovePlayerPacket;
import org.spongepowered.asm.mixin.Final; import org.spongepowered.asm.mixin.Final;
import org.spongepowered.asm.mixin.Mixin; import org.spongepowered.asm.mixin.Mixin;
@ -30,4 +34,20 @@ public abstract class ClientCommonPacketListenerImplMixin {
private void lovisual$flightSendPacket(Packet<?> packet, CallbackInfo ci) { private void lovisual$flightSendPacket(Packet<?> packet, CallbackInfo ci) {
// Flight module removed; no move-packet override remains. // Flight module removed; no move-packet override remains.
} }
@Inject(
method = "handleResourcePackPush(Lnet/minecraft/network/protocol/common/ClientboundResourcePackPushPacket;)V",
at = @At("HEAD"),
cancellable = true
)
private void lovisual$onResourcePackPush(ClientboundResourcePackPushPacket packet, CallbackInfo ci) {
NoServerPack module = Modules.get(NoServerPack.class);
if (module == null || !module.shouldDeclinePush()) return;
// Same terminal answer vanilla sends when the pack is refused, but
// before any prompt screen or download can start.
connection.send(new ServerboundResourcePackPacket(
packet.id(), ServerboundResourcePackPacket.Action.DECLINED));
module.onPackDeclined(packet.id());
ci.cancel();
}
} }

View file

@ -19,6 +19,7 @@ import org.spongepowered.asm.mixin.injection.Inject;
import org.spongepowered.asm.mixin.injection.ModifyVariable; import org.spongepowered.asm.mixin.injection.ModifyVariable;
import org.spongepowered.asm.mixin.injection.callback.CallbackInfo; import org.spongepowered.asm.mixin.injection.callback.CallbackInfo;
import dev.loki.lovisual.features.module.Modules; import dev.loki.lovisual.features.module.Modules;
import dev.loki.lovisual.features.module.modules.visuals.AtmoDawnFog;
import dev.loki.lovisual.features.module.modules.visuals.WorldTweaks; import dev.loki.lovisual.features.module.modules.visuals.WorldTweaks;
@Mixin(value = FogRenderer.class, priority = 1200) @Mixin(value = FogRenderer.class, priority = 1200)
@ -67,6 +68,10 @@ public class FogRendererMixin {
int viewDistance, int viewDistance,
float skyDarkness, float skyDarkness,
Vector4f color, CallbackInfo ci) { Vector4f color, CallbackInfo ci) {
if (AtmoDawnFog.tintColor(color, world, camera.position().y)) {
return;
}
WorldTweaks module = Modules.get(WorldTweaks.class); WorldTweaks module = Modules.get(WorldTweaks.class);
if (module == null || !module.isFogModifyEnabled()) return; if (module == null || !module.isFogModifyEnabled()) return;

View file

@ -0,0 +1,93 @@
/*
* This file is part of the LoVisual Client distribution.
* Copyright (c) 2026 loki5512344.
*
* Licensed under the GNU General Public License v3.0.
*/
package dev.loki.lovisual.mixins;
import com.mojang.blaze3d.vertex.PoseStack;
import com.mojang.math.Axis;
import net.minecraft.client.Minecraft;
import net.minecraft.client.player.AbstractClientPlayer;
import net.minecraft.world.InteractionHand;
import net.minecraft.world.entity.HumanoidArm;
import dev.loki.lovisual.features.module.modules.visuals.HoldMyItems;
import dev.loki.lovisual.render.helpers.TickDelta;
import dev.loki.lovisual.util.animation.hand.BobMath;
import dev.loki.lovisual.util.animation.hand.HandAnimState;
import dev.loki.lovisual.util.animation.hand.HandPoseMath;
/**
* Клей между рендером руки и чистой математикой динамики: собирает ввод
* (дельты взгляда, скорость, использование предмета), собирает позу и
* применяет её к матрице вокруг ванильных трансформов. Состояние не хранит —
* оно живёт в {@link HandAnimState}, обновляемом раз за кадр.
*/
final class HandDynamics {
/** Смещения/углы слабее этого считаются нулевыми и не применяются. */
private static final float SIGNIFICANT_EPSILON = 1.0e-4f;
private HandDynamics() {
}
/** Один шаг динамики за кадр; вызывается из начала submitHandsWithItems. */
static void beginFrame(HandAnimState state, AbstractClientPlayer player, float tickDelta) {
state.beginFrame(
player,
Minecraft.getInstance().level,
player.getViewYRot(tickDelta),
player.getViewXRot(tickDelta),
player.isUsingItem() && player.getUsedItemHand() == InteractionHand.MAIN_HAND,
player.isUsingItem() && player.getUsedItemHand() == InteractionHand.OFF_HAND,
TickDelta.fixedDeltaTicks()
);
}
/**
* Применяет позу динамики к матрице руки: pushPose, затем смещение и
* повороты вокруг точки взгляда до ванильных трансформов. Вызывающий
* обязан сделать popPose, если вернулось true.
*
* @return true, если был pushPose и нужен popPose
*/
static boolean apply(
HoldMyItems module, HandAnimState state, AbstractClientPlayer player,
float tickDelta, InteractionHand hand, PoseStack matrices
) {
boolean mainHand = hand == InteractionHand.MAIN_HAND;
float bobStrength = module.walkBob.get() ? module.bobStrength.get() : 0.0f;
float driftStrength = module.useDrift.get() ? module.driftStrength.get() : 0.0f;
boolean usingThisHand = player.isUsingItem() && player.getUsedItemHand() == hand;
float drift = usingThisHand ? state.drift(mainHand) : 0.0f;
HandPoseMath.Pose pose = HandPoseMath.sway(
state.sway().yawLag(), state.sway().pitchLag(), module.swayIntensity.get());
if (bobStrength > 0.0f) {
pose = HandPoseMath.add(pose, HandPoseMath.bob(
player.walkAnimation.position(tickDelta),
BobMath.amplitude(player.walkAnimation.speed(tickDelta)),
bobStrength));
}
if (driftStrength > 0.0f && drift > 0.0f) {
boolean rightArm = (player.getMainArm() == HumanoidArm.RIGHT) == mainHand;
pose = HandPoseMath.add(pose, HandPoseMath.drift(drift, driftStrength, rightArm));
}
if (module.joinAppear.get()) {
pose = HandPoseMath.add(pose, HandPoseMath.joinDrop(state.joinProgress()));
}
if (!pose.isSignificant(SIGNIFICANT_EPSILON)) {
return false;
}
matrices.pushPose();
matrices.translate(pose.dx(), pose.dy(), pose.dz());
if (pose.rx() != 0.0f) matrices.mulPose(Axis.XP.rotationDegrees(pose.rx()));
if (pose.ry() != 0.0f) matrices.mulPose(Axis.YP.rotationDegrees(pose.ry()));
if (pose.rz() != 0.0f) matrices.mulPose(Axis.ZP.rotationDegrees(pose.rz()));
return true;
}
}

View file

@ -25,8 +25,9 @@ import org.spongepowered.asm.mixin.injection.At;
import org.spongepowered.asm.mixin.injection.Inject; import org.spongepowered.asm.mixin.injection.Inject;
import org.spongepowered.asm.mixin.injection.callback.CallbackInfo; import org.spongepowered.asm.mixin.injection.callback.CallbackInfo;
import dev.loki.lovisual.features.module.Modules; import dev.loki.lovisual.features.module.Modules;
import dev.loki.lovisual.features.module.modules.visuals.WorldTweaks; import dev.loki.lovisual.features.module.modules.visuals.ItemPhysic;
import dev.loki.lovisual.mixininterface.IItemEntityRenderState; import dev.loki.lovisual.mixininterface.IItemEntityRenderState;
import net.minecraft.util.Mth;
@Mixin(ItemEntityRenderer.class) @Mixin(ItemEntityRenderer.class)
public abstract class ItemEntityRendererMixin extends EntityRenderer<ItemEntity, ItemEntityRenderState> { public abstract class ItemEntityRendererMixin extends EntityRenderer<ItemEntity, ItemEntityRenderState> {
@ -51,19 +52,26 @@ public abstract class ItemEntityRendererMixin extends EntityRenderer<ItemEntity,
CameraRenderState cameraState, CameraRenderState cameraState,
CallbackInfo ci CallbackInfo ci
) { ) {
WorldTweaks module = Modules.get(WorldTweaks.class); ItemPhysic physics = Modules.get(ItemPhysic.class);
if (module == null || !module.isItemPhysicsEnabled() || state.item.isEmpty()) { if (physics == null || !physics.isEnabled() || state.item.isEmpty()) {
return; return;
} }
matrices.pushPose(); matrices.pushPose();
if (physics.shouldReduceSize()) {
matrices.scale(0.5F, 0.5F, 0.5F);
}
if (!physics.shouldCancelBob()) {
float bob = Mth.sin(state.ageInTicks / 10.0F + state.bobOffset) * 0.1F + 0.1F;
matrices.translate(0.0F, bob, 0.0F);
}
AABB box = state.item.getModelBoundingBox(); AABB box = state.item.getModelBoundingBox();
float baseY = -((float) box.minY) + 0.0625F; float baseY = -((float) box.minY) + 0.0625F;
matrices.translate(0.0F, baseY, 0.0F); matrices.translate(0.0F, baseY, 0.0F);
if (((IItemEntityRenderState) state).lovisual$isOnGround()) { if (((IItemEntityRenderState) state).lovisual$isOnGround() && physics.shouldLayFlat()) {
matrices.mulPose(Axis.XP.rotationDegrees(90.0F)); matrices.mulPose(Axis.XP.rotationDegrees(90.0F));
} else { } else if (physics.shouldSpinInAir()) {
float rotation = ItemEntity.getSpin(state.ageInTicks, state.bobOffset); float rotation = ItemEntity.getSpin(state.ageInTicks, state.bobOffset);
matrices.mulPose(Axis.XP.rotationDegrees(rotation * 300.0F)); matrices.mulPose(Axis.XP.rotationDegrees(rotation * 300.0F));
} }

View file

@ -28,11 +28,15 @@ import org.spongepowered.asm.mixin.injection.callback.CallbackInfo;
import dev.loki.lovisual.features.module.Modules; import dev.loki.lovisual.features.module.Modules;
import dev.loki.lovisual.features.module.modules.visuals.HoldMyItems; import dev.loki.lovisual.features.module.modules.visuals.HoldMyItems;
import dev.loki.lovisual.features.module.modules.visuals.ViewModel; import dev.loki.lovisual.features.module.modules.visuals.ViewModel;
import dev.loki.lovisual.util.animation.EquipProgressComposer;
import dev.loki.lovisual.util.animation.hand.HandAnimState;
@Environment(EnvType.CLIENT) @Environment(EnvType.CLIENT)
@Mixin(ItemInHandRenderer.class) @Mixin(ItemInHandRenderer.class)
public abstract class ItemInHandRendererMixin { public abstract class ItemInHandRendererMixin {
@Unique private boolean lovisual$handPosePushed;
@Inject(method = "tick", at = @At("HEAD"), cancellable = true) @Inject(method = "tick", at = @At("HEAD"), cancellable = true)
private void lovisual$skipTickWithoutPlayer(CallbackInfo ci) { private void lovisual$skipTickWithoutPlayer(CallbackInfo ci) {
Minecraft mc = Minecraft.getInstance(); Minecraft mc = Minecraft.getInstance();
@ -41,6 +45,36 @@ public abstract class ItemInHandRendererMixin {
} }
} }
// Динамика рук (sway/боб/дрейф/появление): состояние шагает ровно раз за
// кадр в HoldMyItems.onFrame — вне рендер-пути, поэтому повторный проход
// руки (захват маски Chams) видит те же значения, что и основной проход.
// Здесь состояние только читается и применяется до ванильных трансформов.
@Inject(method = "submitArmWithItem", at = @At("HEAD"))
private void lovisual$applyHandDynamics(
AbstractClientPlayer player, float tickDelta, float pitch, InteractionHand hand,
float swingProgress, ItemStack item, float equipProgress, PoseStack matrices,
SubmitNodeCollector queue, int light, CallbackInfo ci
) {
HoldMyItems holdMyItems = Modules.get(HoldMyItems.class);
if (holdMyItems == null || !holdMyItems.isEnabled()) return;
if (hand == InteractionHand.OFF_HAND && !holdMyItems.offhandToo.get()) return;
HandAnimState handAnim = holdMyItems.handAnim();
if (handAnim == null) return;
lovisual$handPosePushed = HandDynamics.apply(holdMyItems, handAnim, player, tickDelta, hand, matrices);
}
@Inject(method = "submitArmWithItem", at = @At("RETURN"))
private void lovisual$restoreHandDynamics(
AbstractClientPlayer player, float tickDelta, float pitch, InteractionHand hand,
float swingProgress, ItemStack item, float equipProgress, PoseStack matrices,
SubmitNodeCollector queue, int light, CallbackInfo ci
) {
if (!lovisual$handPosePushed) return;
lovisual$handPosePushed = false;
matrices.popPose();
}
@Inject( @Inject(
method = "submitArmWithItem", method = "submitArmWithItem",
at = @At("HEAD") at = @At("HEAD")
@ -68,15 +102,32 @@ public abstract class ItemInHandRendererMixin {
); );
} }
// Четвёртый float среди аргументов (tickDelta, pitch, swingProgress, equipProgress) —
// equipProgress; индекс 7 указывал бы на matrices (PoseStack), из-за чего модификаторы
// не применялись. ViewModel и HoldMyItems сводятся в одну цепочку:
// кривая на полном диапазоне, затем занижение амплитуды (EquipProgressComposer).
@ModifyVariable( @ModifyVariable(
method = "submitArmWithItem", method = "submitArmWithItem",
at = @At("HEAD"), at = @At("HEAD"),
argsOnly = true, argsOnly = true,
index = 7) ordinal = 3)
private float lovisual$scaleEquipProgress(float equipProgress) { private float lovisual$composeEquipProgress(
float equipProgress, AbstractClientPlayer player, float tickDelta,
float pitch, InteractionHand hand) {
ViewModel viewModel = Modules.get(ViewModel.class); ViewModel viewModel = Modules.get(ViewModel.class);
if (viewModel == null || !viewModel.isEnabled()) return equipProgress; HoldMyItems holdMyItems = Modules.get(HoldMyItems.class);
return equipProgress * viewModel.equipLowering.get(); boolean lowerEnabled = viewModel != null && viewModel.isEnabled();
boolean curveEnabled = holdMyItems != null && holdMyItems.isEnabled()
&& !(hand == InteractionHand.OFF_HAND && !holdMyItems.offhandToo.get());
if (!lowerEnabled && !curveEnabled) return equipProgress;
return EquipProgressComposer.compose(
equipProgress,
curveEnabled,
curveEnabled ? holdMyItems.easeStrength.get() : 1.0f,
curveEnabled ? holdMyItems.swayIntensity.get() : 1.0f,
lowerEnabled,
lowerEnabled ? viewModel.equipLowering.get() : 1.0f
);
} }
@Unique @Unique
@ -84,20 +135,6 @@ public abstract class ItemInHandRendererMixin {
return player != null && (player.isInWater() || player.isInLava()); return player != null && (player.isInWater() || player.isInLava());
} }
@ModifyVariable(
method = "submitArmWithItem",
at = @At("HEAD"),
argsOnly = true,
index = 7)
private float lovisual$holdMyItemsEquipCurve(float equipProgress, AbstractClientPlayer player, float tickDelta,
float pitch, InteractionHand hand) {
HoldMyItems hmi = Modules.get(HoldMyItems.class);
if (hmi == null || !hmi.isEnabled()) return equipProgress;
if (hand == InteractionHand.OFF_HAND && !hmi.offhandToo.get()) return equipProgress;
return hmi.shapeEquipProgress(equipProgress);
}
@Inject( @Inject(
method = "renderItem(Lnet/minecraft/world/entity/LivingEntity;Lnet/minecraft/world/item/ItemStack;Lnet/minecraft/world/item/ItemDisplayContext;Lcom/mojang/blaze3d/vertex/PoseStack;Lnet/minecraft/client/renderer/SubmitNodeCollector;I)V", method = "renderItem(Lnet/minecraft/world/entity/LivingEntity;Lnet/minecraft/world/item/ItemStack;Lnet/minecraft/world/item/ItemDisplayContext;Lcom/mojang/blaze3d/vertex/PoseStack;Lnet/minecraft/client/renderer/SubmitNodeCollector;I)V",
at = @At( at = @At(

View file

@ -10,16 +10,30 @@ package dev.loki.lovisual.mixins;
import net.minecraft.client.renderer.entity.state.LivingEntityRenderState; import net.minecraft.client.renderer.entity.state.LivingEntityRenderState;
import org.spongepowered.asm.mixin.Mixin; import org.spongepowered.asm.mixin.Mixin;
import org.spongepowered.asm.mixin.Unique; import org.spongepowered.asm.mixin.Unique;
import dev.loki.lovisual.render.SeeInvisiblesState;
import dev.loki.lovisual.render.ViewObstructionFadeState; import dev.loki.lovisual.render.ViewObstructionFadeState;
@Mixin(LivingEntityRenderState.class) @Mixin(LivingEntityRenderState.class)
public abstract class LivingEntityRenderStateMixin implements ViewObstructionFadeState { public abstract class LivingEntityRenderStateMixin implements ViewObstructionFadeState, SeeInvisiblesState {
@Unique @Unique
private boolean lovisual$viewObstructionFadeActive; private boolean lovisual$viewObstructionFadeActive;
@Unique @Unique
private float lovisual$viewObstructionFadeAlpha = 1.0f; private float lovisual$viewObstructionFadeAlpha = 1.0f;
@Unique
private boolean lovisual$seeInvisRevealed;
@Override
public boolean lovisual$isSeeInvisRevealed() {
return lovisual$seeInvisRevealed;
}
@Override
public void lovisual$setSeeInvisRevealed(boolean revealed) {
this.lovisual$seeInvisRevealed = revealed;
}
@Override @Override
public boolean lovisual$isViewObstructionFadeActive() { public boolean lovisual$isViewObstructionFadeActive() {
return lovisual$viewObstructionFadeActive; return lovisual$viewObstructionFadeActive;

View file

@ -32,8 +32,11 @@ import org.spongepowered.asm.mixin.Shadow;
import org.spongepowered.asm.mixin.injection.callback.CallbackInfo; import org.spongepowered.asm.mixin.injection.callback.CallbackInfo;
import org.spongepowered.asm.mixin.injection.callback.CallbackInfoReturnable; import org.spongepowered.asm.mixin.injection.callback.CallbackInfoReturnable;
import dev.loki.lovisual.features.module.Modules; import dev.loki.lovisual.features.module.Modules;
import dev.loki.lovisual.features.module.modules.visuals.InvisEsp;
import dev.loki.lovisual.features.module.modules.visuals.NameTags; import dev.loki.lovisual.features.module.modules.visuals.NameTags;
import dev.loki.lovisual.features.module.modules.visuals.NoRender; import dev.loki.lovisual.features.module.modules.visuals.NoRender;
import dev.loki.lovisual.render.SeeInvisiblesReveal;
import dev.loki.lovisual.render.SeeInvisiblesState;
import dev.loki.lovisual.render.ViewObstructionFadeContext; import dev.loki.lovisual.render.ViewObstructionFadeContext;
import dev.loki.lovisual.render.ViewObstructionFadeState; import dev.loki.lovisual.render.ViewObstructionFadeState;
import dev.loki.lovisual.render.iris.IrisRuntime; import dev.loki.lovisual.render.iris.IrisRuntime;
@ -60,6 +63,24 @@ public abstract class LivingEntityRendererMixin<T extends LivingEntity, S extend
} }
} }
@Inject(
method = "extractRenderState(Lnet/minecraft/world/entity/LivingEntity;Lnet/minecraft/client/renderer/entity/state/LivingEntityRenderState;F)V",
at = @At("TAIL")
)
private void lovisual$seeInvisiblesReveal(T entity, S state, float tickProgress, CallbackInfo ci) {
SeeInvisiblesReveal.reveal(entity, state);
}
@ModifyExpressionValue(
method = "submit",
at = @At(value = "INVOKE", target = "Lnet/minecraft/client/renderer/entity/LivingEntityRenderer;getRenderType(Lnet/minecraft/client/renderer/entity/state/LivingEntityRenderState;ZZZ)Lnet/minecraft/client/renderer/rendertype/RenderType;")
)
private RenderType lovisual$seeInvisiblesTranslucent(RenderType original, @Local(argsOnly = true) S state) {
if (!(state instanceof SeeInvisiblesState seeInvis) || !seeInvis.lovisual$isSeeInvisRevealed()) {
return original;
}
return SeeInvisiblesReveal.renderTypeFor(original, getTextureLocation(state));
}
@Inject( @Inject(
method = "extractRenderState(Lnet/minecraft/world/entity/LivingEntity;Lnet/minecraft/client/renderer/entity/state/LivingEntityRenderState;F)V", method = "extractRenderState(Lnet/minecraft/world/entity/LivingEntity;Lnet/minecraft/client/renderer/entity/state/LivingEntityRenderState;F)V",
at = @At("TAIL") at = @At("TAIL")

View file

@ -0,0 +1,32 @@
/*
* This file is part of the LoVisual Client distribution.
* Copyright (c) 2026 loki5512344.
*
* Licensed under the GNU General Public License v3.0.
*/
package dev.loki.lovisual.mixins;
import dev.loki.lovisual.features.module.Modules;
import dev.loki.lovisual.features.module.modules.player.LockSlot;
import net.minecraft.client.player.LocalPlayer;
import org.spongepowered.asm.mixin.Mixin;
import org.spongepowered.asm.mixin.injection.At;
import org.spongepowered.asm.mixin.injection.Inject;
import org.spongepowered.asm.mixin.injection.callback.CallbackInfoReturnable;
/**
* Cancels the Q / Ctrl+Q drop of the selected hotbar slot
* while LockSlot protects it.
*/
@Mixin(LocalPlayer.class)
public class LocalPlayerDropMixin {
@Inject(method = "drop(Z)Z", at = @At("HEAD"), cancellable = true)
private void lovisual$lockSlot$blockSelectedDrop(boolean wholeStack, CallbackInfoReturnable<Boolean> cir) {
LockSlot lockSlot = Modules.get(LockSlot.class);
if (lockSlot != null && lockSlot.shouldBlockSelectedDrop()) {
cir.setReturnValue(false);
}
}
}

View file

@ -30,6 +30,7 @@ import dev.loki.lovisual.events.impl.AttackEntityEvent;
import dev.loki.lovisual.events.impl.EventBreakBlock; import dev.loki.lovisual.events.impl.EventBreakBlock;
import dev.loki.lovisual.features.gui.hud.draggable.impl.TargetHud; import dev.loki.lovisual.features.gui.hud.draggable.impl.TargetHud;
import dev.loki.lovisual.features.module.Modules; import dev.loki.lovisual.features.module.Modules;
import dev.loki.lovisual.features.module.modules.combat.NoFriendDamage;
import dev.loki.lovisual.features.module.modules.combat.PvpCooldowns; import dev.loki.lovisual.features.module.modules.combat.PvpCooldowns;
import dev.loki.lovisual.features.module.modules.misc.HitSounds; import dev.loki.lovisual.features.module.modules.misc.HitSounds;
import dev.loki.lovisual.features.module.modules.visuals.Freecam; import dev.loki.lovisual.features.module.modules.visuals.Freecam;
@ -141,8 +142,14 @@ public class MultiPlayerGameModeMixin {
} }
@Inject(method = "attack(Lnet/minecraft/world/entity/player/Player;Lnet/minecraft/world/entity/Entity;)V", @Inject(method = "attack(Lnet/minecraft/world/entity/player/Player;Lnet/minecraft/world/entity/Entity;)V",
at = @At("HEAD")) at = @At("HEAD"), cancellable = true)
private void onAttackEntity(Player player, Entity target, CallbackInfo ci) { private void onAttackEntity(Player player, Entity target, CallbackInfo ci) {
NoFriendDamage noFriendDamage = Modules.get(NoFriendDamage.class);
if (noFriendDamage != null && noFriendDamage.shouldBlockAttack(target)) {
ci.cancel();
return;
}
if (!((WorldAccessor) player.level()).lovisual$isClient()) return; if (!((WorldAccessor) player.level()).lovisual$isClient()) return;
if (Events.BUS.hasListeners(AttackEntityEvent.class)) { if (Events.BUS.hasListeners(AttackEntityEvent.class)) {

View file

@ -0,0 +1,36 @@
/*
* This file is part of the LoVisual Client distribution.
* Copyright (c) 2026 loki5512344.
*
* Licensed under the GNU General Public License v3.0.
*/
package dev.loki.lovisual.mixins;
import dev.loki.lovisual.features.module.Modules;
import dev.loki.lovisual.features.module.modules.player.LockSlot;
import net.minecraft.client.multiplayer.MultiPlayerGameMode;
import net.minecraft.world.entity.player.Player;
import net.minecraft.world.inventory.ContainerInput;
import org.spongepowered.asm.mixin.Mixin;
import org.spongepowered.asm.mixin.injection.At;
import org.spongepowered.asm.mixin.injection.Inject;
import org.spongepowered.asm.mixin.injection.callback.CallbackInfo;
/**
* Cancels THROW container clicks (Q / Ctrl+Q over a hovered slot and
* throw clicks) targeting locked hotbar slots while LockSlot is enabled.
*/
@Mixin(MultiPlayerGameMode.class)
public class MultiPlayerGameModeThrowMixin {
@Inject(method = "handleContainerInput", at = @At("HEAD"), cancellable = true)
private void lovisual$lockSlot$blockThrowClick(int containerId, int slotId, int button,
ContainerInput input, Player player, CallbackInfo ci) {
if (input != ContainerInput.THROW) return;
LockSlot lockSlot = Modules.get(LockSlot.class);
if (lockSlot != null && lockSlot.shouldBlockContainerThrow(player, containerId, slotId)) {
ci.cancel();
}
}
}

View file

@ -0,0 +1,20 @@
/*
* This file is part of the LoVisual Client distribution.
* Copyright (c) 2026 loki5512344.
*
* Licensed under the GNU General Public License v3.0.
*/
package dev.loki.lovisual.mixins.accessors;
import com.mojang.blaze3d.platform.InputConstants;
import net.minecraft.client.KeyMapping;
import org.spongepowered.asm.mixin.Mixin;
import org.spongepowered.asm.mixin.gen.Accessor;
@Mixin(KeyMapping.class)
public interface KeyMappingAccessor {
@Accessor("key")
InputConstants.Key lovisual$key();
}

View file

@ -0,0 +1,55 @@
/*
* This file is part of the LoVisual Client distribution.
* Copyright (c) 2026 loki5512344.
*
* Licensed under the GNU General Public License v3.0.
*/
package dev.loki.lovisual.render;
import dev.loki.lovisual.features.module.Modules;
import dev.loki.lovisual.features.module.modules.visuals.InvisEsp;
import dev.loki.lovisual.render.iris.IrisRuntime;
import net.minecraft.client.Minecraft;
import net.minecraft.client.renderer.entity.state.LivingEntityRenderState;
import net.minecraft.client.renderer.rendertype.RenderType;
import net.minecraft.client.renderer.rendertype.RenderTypes;
import net.minecraft.resources.Identifier;
import net.minecraft.world.entity.LivingEntity;
import net.minecraft.world.entity.player.Player;
/**
* Логика See Invisibles: невидимый игрок раскрывается — флаг невидимости
* снимается перед рендером, а render type форсится в полупрозрачный.
*/
public enum SeeInvisiblesReveal {
;
public static <T extends LivingEntity> void reveal(T entity, LivingEntityRenderState state) {
if (!(entity instanceof Player player) || player == Minecraft.getInstance().player) {
return;
}
InvisEsp module = Modules.get(InvisEsp.class);
if (module == null || !module.isEnabled() || !entity.isInvisible()) {
return;
}
if (state instanceof SeeInvisiblesState seeInvis) {
seeInvis.lovisual$setSeeInvisRevealed(true);
}
state.isInvisible = false;
}
public static RenderType renderTypeFor(RenderType original, Identifier texture) {
InvisEsp module = Modules.get(InvisEsp.class);
if (module == null || !module.isEnabled() || IrisRuntime.isRenderingShadowPass()) {
return original;
}
if (!module.isTranslucentReveal()) {
return original;
}
if (original != null && original.hasBlending()) {
return original;
}
return RenderTypes.entityTranslucent(texture, true);
}
}

View file

@ -0,0 +1,18 @@
/*
* This file is part of the LoVisual Client distribution.
* Copyright (c) 2026 loki5512344.
*
* Licensed under the GNU General Public License v3.0.
*/
package dev.loki.lovisual.render;
/**
* Marker injected into entity render states when InvisESP reveals
* an invisible player this frame.
*/
public interface SeeInvisiblesState {
boolean lovisual$isSeeInvisRevealed();
void lovisual$setSeeInvisRevealed(boolean revealed);
}

View file

@ -0,0 +1,60 @@
/*
* This file is part of the LoVisual Client distribution.
* Copyright (c) 2026 loki5512344.
*
* Licensed under the GNU General Public License v3.0.
*/
package dev.loki.lovisual.render.engine.pipeline.pipelines;
import com.mojang.blaze3d.pipeline.BlendFunction;
import com.mojang.blaze3d.pipeline.RenderPipeline;
import com.mojang.blaze3d.platform.BlendFactor;
import com.mojang.blaze3d.shaders.UniformType;
import dev.loki.lovisual.render.engine.pipeline.DepthTestFunction;
import dev.loki.lovisual.render.engine.pipeline.ExtendedRenderPipelineBuilder;
import net.minecraft.resources.Identifier;
/**
* Shared building blocks for the LoVisual pipeline factories: uniform snippets, blend
* presets and the common depth/blend/cull tail that every custom pipeline applies.
*/
public final class PipelineTemplates {
/** Additive blending (SRC_ALPHA, ONE) shared by all additive LoVisual pipelines. */
public static final BlendFunction ADDITIVE = new BlendFunction(BlendFactor.SRC_ALPHA, BlendFactor.ONE);
public static final RenderPipeline.Snippet MESH_UNIFORMS = uniformSnippet("MeshData");
public static final RenderPipeline.Snippet FOG_UNIFORMS = uniformSnippet("Fog");
public static final RenderPipeline.Snippet UI_BATCH_UNIFORMS = uniformSnippet("UIBatch");
private PipelineTemplates() {
}
/**
* Snippet declaring a single {@link UniformType#UNIFORM_BUFFER} uniform.
*/
public static RenderPipeline.Snippet uniformSnippet(String name) {
return new ExtendedRenderPipelineBuilder()
.withUniform(name, UniformType.UNIFORM_BUFFER)
.buildSnippet();
}
/**
* Location identifier for a "pipeline/&lt;name&gt;" entry in the lovisual namespace.
*/
public static Identifier location(String name) {
return Identifier.fromNamespaceAndPath("lovisual", "pipeline/" + name);
}
/**
* Applies the shared pipeline tail (depth test, depth write off, blend, cull) and builds.
*/
public static RenderPipeline finish(ExtendedRenderPipelineBuilder builder, boolean depthTest, BlendFunction blend, boolean cull) {
return builder
.withDepthTestFunction(depthTest ? DepthTestFunction.GEQUAL_DEPTH_TEST : DepthTestFunction.NO_DEPTH_TEST)
.withDepthWrite(false)
.withBlend(blend)
.withCull(cull)
.build();
}
}

View file

@ -0,0 +1,72 @@
/*
* This file is part of the LoVisual Client distribution.
* Copyright (c) 2026 loki5512344.
*
* Licensed under the GNU General Public License v3.0.
*/
package dev.loki.lovisual.render.engine.pipeline.pipelines;
import com.mojang.blaze3d.PrimitiveTopology;
import com.mojang.blaze3d.pipeline.BlendFunction;
import com.mojang.blaze3d.pipeline.RenderPipeline;
import com.mojang.blaze3d.shaders.UniformType;
import dev.loki.lovisual.render.engine.pipeline.ExtendedRenderPipelineBuilder;
import dev.loki.lovisual.render.engine.pipeline.LoVisualRenderPipelines;
import dev.loki.lovisual.render.engine.vertex.LoVisualVertexFormats;
import net.minecraft.client.renderer.RenderPipelines;
import net.minecraft.resources.Identifier;
/**
* Factory templates for LoVisual's fullscreen pos2 pipelines (post effects, hand effects,
* shader skies) plus the vanilla-textured GUI lookup pipeline. Samplers are declared
* before the single uniform buffer, matching the original declaration order.
*/
public final class ScreenPipelines {
private ScreenPipelines() {
}
/**
* Minimal vanilla-textured pipeline used for GUI texture lookups.
*/
public static RenderPipeline guiTextureLookup() {
return new ExtendedRenderPipelineBuilder(RenderPipelines.GUI_TEXTURED_SNIPPET)
.withLocation(PipelineTemplates.location("gui_texture_lookup"))
.withFragmentShader(LoVisualRenderPipelines.SHADER_GUI_TEXTURE_LOOKUP_FRAG)
.build();
}
/**
* Fullscreen pos2 pass: damage-tint vertex shader, no depth test, translucent, no culling.
*/
public static RenderPipeline screen(String name, Identifier fragmentShader, String uniform, String... samplers) {
ExtendedRenderPipelineBuilder builder = base(name)
.withVertexFormat(LoVisualVertexFormats.POS2, PrimitiveTopology.TRIANGLES)
.withVertexShader(LoVisualRenderPipelines.SHADER_DAMAGE_TINT_VERT)
.withFragmentShader(fragmentShader);
for (String sampler : samplers) {
builder.withSampler(sampler);
}
builder.withUniform(uniform, UniformType.UNIFORM_BUFFER);
return PipelineTemplates.finish(builder, false, BlendFunction.TRANSLUCENT, false);
}
/**
* Fullscreen hand effect with the standard "u_Src"/"u_Mask" sampler pair.
*/
public static RenderPipeline handEffect(String name, Identifier fragmentShader, String uniform) {
return screen(name, fragmentShader, uniform, "u_Src", "u_Mask");
}
/**
* Fullscreen chams blend effect sampling two shader textures plus the mask.
*/
public static RenderPipeline handBlendEffect(String name, Identifier fragmentShader) {
return screen(name, fragmentShader, "HandBlend", "u_Shader1", "u_Shader2", "u_Mask");
}
private static ExtendedRenderPipelineBuilder base(String name) {
return new ExtendedRenderPipelineBuilder(PipelineTemplates.MESH_UNIFORMS)
.withLocation(PipelineTemplates.location(name));
}
}

View file

@ -0,0 +1,96 @@
/*
* This file is part of the LoVisual Client distribution.
* Copyright (c) 2026 loki5512344.
*
* Licensed under the GNU General Public License v3.0.
*/
package dev.loki.lovisual.render.engine.pipeline.pipelines;
import com.mojang.blaze3d.PrimitiveTopology;
import com.mojang.blaze3d.pipeline.BlendFunction;
import com.mojang.blaze3d.pipeline.RenderPipeline;
import com.mojang.blaze3d.shaders.UniformType;
import com.mojang.blaze3d.vertex.VertexFormat;
import dev.loki.lovisual.render.engine.pipeline.ExtendedRenderPipelineBuilder;
import dev.loki.lovisual.render.engine.pipeline.LoVisualRenderPipelines;
import dev.loki.lovisual.render.engine.vertex.LoVisualVertexFormats;
import net.minecraft.resources.Identifier;
/**
* Factory templates for LoVisual's UI (2D) pipelines: the base colored/textured set and
* the batched SDF shape pipelines built on the local-color rect-params vertex formats.
* Samplers are declared before extra uniform buffers, matching the original order.
*/
public final class UiPipelines {
private UiPipelines() {
}
/**
* UI colored primitives (pos2 + color); the canvas check in the builder still applies.
*/
public static RenderPipeline colored(String name, PrimitiveTopology topology) {
return PipelineTemplates.finish(base(name)
.withVertexFormat(LoVisualVertexFormats.POS2_COLOR, topology)
.withVertexShader(LoVisualRenderPipelines.SHADER_POS_COLOR_VERT)
.withFragmentShader(LoVisualRenderPipelines.SHADER_POS_COLOR_FRAG), false, BlendFunction.TRANSLUCENT, true);
}
/**
* UI textured triangles (pos2 + tex + color); {@code uniform} may be null.
*/
public static RenderPipeline textured(String name, Identifier vertexShader, Identifier fragmentShader,
BlendFunction blend, boolean cull, String uniform, String... samplers) {
ExtendedRenderPipelineBuilder builder = base(name)
.withVertexFormat(LoVisualVertexFormats.POS2_TEXTURE_COLOR, PrimitiveTopology.TRIANGLES)
.withVertexShader(vertexShader)
.withFragmentShader(fragmentShader);
for (String sampler : samplers) {
builder.withSampler(sampler);
}
if (uniform != null) {
builder.withUniform(uniform, UniformType.UNIFORM_BUFFER);
}
return PipelineTemplates.finish(builder, false, blend, cull);
}
/**
* UI text with liquid glass sampling; MSDF variants add the "MsdfText" uniform.
*/
public static RenderPipeline textLiquidGlass(String name, Identifier fragmentShader, boolean msdf) {
ExtendedRenderPipelineBuilder builder = base(name)
.withVertexFormat(LoVisualVertexFormats.POS2_TEXTURE_COLOR, PrimitiveTopology.TRIANGLES)
.withVertexShader(LoVisualRenderPipelines.SHADER_TEXT_VERT)
.withFragmentShader(fragmentShader)
.withSampler("u_Texture")
.withSampler("u_SceneTexture")
.withSampler("u_BlurTexture")
.withUniform("UIBatch", UniformType.UNIFORM_BUFFER);
if (msdf) {
builder.withUniform("MsdfText", UniformType.UNIFORM_BUFFER);
}
return PipelineTemplates.finish(builder, false, BlendFunction.TRANSLUCENT, true);
}
/**
* Batched SDF shape pipeline (local-color rect-params formats) with the shared UI batch
* uniforms; optional texture samplers keep their original declaration order.
*/
public static RenderPipeline batch(String name, VertexFormat format, Identifier vertexShader, Identifier fragmentShader,
BlendFunction blend, boolean cull, String... samplers) {
ExtendedRenderPipelineBuilder builder = new ExtendedRenderPipelineBuilder(PipelineTemplates.MESH_UNIFORMS, PipelineTemplates.UI_BATCH_UNIFORMS)
.withLocation(PipelineTemplates.location(name))
.withVertexFormat(format, PrimitiveTopology.TRIANGLES)
.withVertexShader(vertexShader)
.withFragmentShader(fragmentShader);
for (String sampler : samplers) {
builder.withSampler(sampler);
}
return PipelineTemplates.finish(builder, false, blend, cull);
}
private static ExtendedRenderPipelineBuilder base(String name) {
return new ExtendedRenderPipelineBuilder(PipelineTemplates.MESH_UNIFORMS)
.withLocation(PipelineTemplates.location(name));
}
}

View file

@ -0,0 +1,151 @@
/*
* This file is part of the LoVisual Client distribution.
* Copyright (c) 2026 loki5512344.
*
* Licensed under the GNU General Public License v3.0.
*/
package dev.loki.lovisual.render.engine.pipeline.pipelines;
import com.mojang.blaze3d.PrimitiveTopology;
import com.mojang.blaze3d.pipeline.BlendFunction;
import com.mojang.blaze3d.pipeline.RenderPipeline;
import com.mojang.blaze3d.shaders.UniformType;
import com.mojang.blaze3d.vertex.DefaultVertexFormat;
import dev.loki.lovisual.render.engine.pipeline.ExtendedRenderPipelineBuilder;
import dev.loki.lovisual.render.engine.pipeline.LoVisualRenderPipelines;
import dev.loki.lovisual.render.engine.vertex.LoVisualVertexFormats;
import net.minecraft.resources.Identifier;
/**
* Factory templates for LoVisual's world-space (3D) pipelines. They all share the mesh
* uniform snippet, disable depth writing and culling; only depth test, blending, shaders
* and vertex format vary. Shader identifiers come from the public facade constants.
*/
public final class WorldPipelines {
private WorldPipelines() {
}
/**
* Solid colored triangles; {@code depthTest} selects GEQUAL vs no depth test.
*/
public static RenderPipeline colored(String name, boolean depthTest, BlendFunction blend) {
return PipelineTemplates.finish(base(name)
.withVertexFormat(DefaultVertexFormat.POSITION_COLOR, PrimitiveTopology.TRIANGLES)
.withVertexShader(LoVisualRenderPipelines.SHADER_POS_COLOR_VERT)
.withFragmentShader(LoVisualRenderPipelines.SHADER_POS_COLOR_FRAG), depthTest, blend, false);
}
/**
* Smooth debug lines rendered with the plain color shader.
*/
public static RenderPipeline coloredLines(String name, boolean depthTest) {
return PipelineTemplates.finish(base(name)
.withLineSmooth()
.withVertexFormat(DefaultVertexFormat.POSITION_COLOR, PrimitiveTopology.DEBUG_LINES)
.withVertexShader(LoVisualRenderPipelines.SHADER_POS_COLOR_VERT)
.withFragmentShader(LoVisualRenderPipelines.SHADER_POS_COLOR_FRAG), depthTest, BlendFunction.TRANSLUCENT, false);
}
/**
* Screen-space wide line expansion; triangles, never depends on glLineWidth.
*/
public static RenderPipeline wideLines(String name, boolean depthTest) {
return PipelineTemplates.finish(base(name)
.withVertexFormat(LoVisualVertexFormats.POS3_COLOR_LINE, PrimitiveTopology.TRIANGLES)
.withVertexShader(LoVisualRenderPipelines.SHADER_WIDE_LINE_VERT)
.withFragmentShader(LoVisualRenderPipelines.SHADER_POS_COLOR_FRAG), depthTest, BlendFunction.TRANSLUCENT, false);
}
/**
* Textured triangles sampling "u_Texture".
*/
public static RenderPipeline textured(String name, boolean depthTest, BlendFunction blend) {
return PipelineTemplates.finish(base(name)
.withVertexFormat(DefaultVertexFormat.POSITION_TEX_COLOR, PrimitiveTopology.TRIANGLES)
.withVertexShader(LoVisualRenderPipelines.SHADER_POS_TEX_COLOR_VERT)
.withFragmentShader(LoVisualRenderPipelines.SHADER_POS_TEX_COLOR_FRAG)
.withSampler("u_Texture"), depthTest, blend, false);
}
/**
* Textured triangles with shader-side tint ("TextureTint" uniform), additive blending.
*/
public static RenderPipeline tintedTextured(String name, boolean depthTest) {
return PipelineTemplates.finish(base(name)
.withVertexFormat(DefaultVertexFormat.POSITION_TEX_COLOR, PrimitiveTopology.TRIANGLES)
.withVertexShader(LoVisualRenderPipelines.SHADER_POS_TEX_COLOR_VERT)
.withFragmentShader(LoVisualRenderPipelines.SHADER_POS_TEX_COLOR_TINT_FRAG)
.withSampler("u_Texture")
.withUniform("TextureTint", UniformType.UNIFORM_BUFFER), depthTest, PipelineTemplates.ADDITIVE, false);
}
/**
* World decal quads with SDF fill/stroke.
*/
public static RenderPipeline decalSdf(String name, boolean depthTest) {
return PipelineTemplates.finish(base(name)
.withVertexFormat(LoVisualVertexFormats.POS3_TEXTURE_COLOR_PARAMS2, PrimitiveTopology.TRIANGLES)
.withVertexShader(LoVisualRenderPipelines.SHADER_POS_TEX_COLOR_PARAMS2_VERT)
.withFragmentShader(LoVisualRenderPipelines.SHADER_WORLD_DECAL_SDF_FRAG), depthTest, BlendFunction.TRANSLUCENT, false);
}
/**
* World text quads; MSDF variants add the "MsdfText" uniform after "u_Texture".
*/
public static RenderPipeline worldText(String name, boolean depthTest, Identifier fragmentShader, boolean msdf) {
ExtendedRenderPipelineBuilder builder = base(name)
.withVertexFormat(DefaultVertexFormat.POSITION_TEX_COLOR, PrimitiveTopology.TRIANGLES)
.withVertexShader(LoVisualRenderPipelines.SHADER_TEXT_VERT)
.withFragmentShader(fragmentShader)
.withSampler("u_Texture");
if (msdf) {
builder.withUniform("MsdfText", UniformType.UNIFORM_BUFFER);
}
return PipelineTemplates.finish(builder, depthTest, BlendFunction.TRANSLUCENT, false);
}
/**
* Textured triangles with procedural sun shading ("SkySun" uniform), additive.
*/
public static RenderPipeline skySun(String name) {
return PipelineTemplates.finish(base(name)
.withVertexFormat(DefaultVertexFormat.POSITION_TEX_COLOR, PrimitiveTopology.TRIANGLES)
.withVertexShader(LoVisualRenderPipelines.SHADER_POS_TEX_COLOR_VERT)
.withFragmentShader(LoVisualRenderPipelines.SHADER_SKY_SUN_FRAG)
.withUniform("SkySun", UniformType.UNIFORM_BUFFER), false, PipelineTemplates.ADDITIVE, false);
}
/**
* Textured skybox with vanilla sky/fog blending ("SkyboxShader" uniform + fog snippet).
*/
public static RenderPipeline texturedSkyboxFog(String name, Identifier fragmentShader, BlendFunction blend) {
return PipelineTemplates.finish(fogBase(name)
.withVertexFormat(DefaultVertexFormat.POSITION_TEX_COLOR, PrimitiveTopology.TRIANGLES)
.withVertexShader(LoVisualRenderPipelines.SHADER_POS_TEX_COLOR_SKY_FOG_VERT)
.withFragmentShader(fragmentShader)
.withSampler("u_Texture")
.withUniform("SkyboxShader", UniformType.UNIFORM_BUFFER), false, blend, false);
}
/**
* Fullscreen procedural skybox with sky/fog blending ("SkyboxShader" uniform + fog snippet).
*/
public static RenderPipeline proceduralSkyboxFog(String name, Identifier fragmentShader) {
return PipelineTemplates.finish(fogBase(name)
.withVertexFormat(LoVisualVertexFormats.POS2, PrimitiveTopology.TRIANGLES)
.withVertexShader(LoVisualRenderPipelines.SHADER_DAMAGE_TINT_VERT)
.withFragmentShader(fragmentShader)
.withUniform("SkyboxShader", UniformType.UNIFORM_BUFFER), false, BlendFunction.TRANSLUCENT, false);
}
private static ExtendedRenderPipelineBuilder base(String name) {
return new ExtendedRenderPipelineBuilder(PipelineTemplates.MESH_UNIFORMS)
.withLocation(PipelineTemplates.location(name));
}
private static ExtendedRenderPipelineBuilder fogBase(String name) {
return new ExtendedRenderPipelineBuilder(PipelineTemplates.MESH_UNIFORMS, PipelineTemplates.FOG_UNIFORMS)
.withLocation(PipelineTemplates.location(name));
}
}

View file

@ -0,0 +1,88 @@
/*
* This file is part of the LoVisual Client distribution.
* Copyright (c) 2026 loki5512344.
*
* Licensed under the GNU General Public License v3.0.
*/
package dev.loki.lovisual.render.engine.uniform.impl;
import dev.loki.lovisual.render.engine.core.LoVisualRenderSystem;
import com.mojang.blaze3d.buffers.GpuBufferSlice;
import com.mojang.blaze3d.buffers.Std140Builder;
import com.mojang.blaze3d.buffers.Std140SizeCalculator;
import dev.loki.lovisual.render.engine.rhi.uniform.LoVisualUniformAllocator;
public enum HandNoiseUniforms {
;
public static final int SIZE = new Std140SizeCalculator()
.putFloat().putFloat().putFloat().putFloat() // u_Color
.putFloat().putFloat().putFloat().putFloat() // u_Glow
.putFloat().putFloat().putFloat().putFloat() // u_Params0
.putFloat().putFloat().putFloat().putFloat() // u_Params1
.putFloat().putFloat().putFloat().putFloat() // u_Params2
.get();
private static final Data DATA = new Data();
private static final String UNIFORM_NAME = "LoVisual - Hand Noise UBO";
private static final int EXPECTED_WRITES_PER_FRAME = 16;
public static void update(
float colorR, float colorG, float colorB, float colorA,
float glowR, float glowG, float glowB, float glowA,
float edgeWidth, float quality, float octaves, float time,
float resX, float resY, float scale, float speed,
float contrast, float breathe, float glowStrength, float density
) {
DATA.colorR = colorR;
DATA.colorG = colorG;
DATA.colorB = colorB;
DATA.colorA = colorA;
DATA.glowR = glowR;
DATA.glowG = glowG;
DATA.glowB = glowB;
DATA.glowA = glowA;
DATA.edgeWidth = edgeWidth;
DATA.quality = quality;
DATA.octaves = octaves;
DATA.time = time;
DATA.resX = resX;
DATA.resY = resY;
DATA.scale = scale;
DATA.speed = speed;
DATA.contrast = contrast;
DATA.breathe = breathe;
DATA.glowStrength = glowStrength;
DATA.density = density;
LoVisualRenderSystem.uniforms().write(UNIFORM_NAME, SIZE, EXPECTED_WRITES_PER_FRAME, DATA);
}
public static GpuBufferSlice get() {
return LoVisualRenderSystem.uniforms().current(UNIFORM_NAME);
}
private static final class Data implements LoVisualUniformAllocator.UniformWriter {
private float colorR, colorG, colorB, colorA;
private float glowR, glowG, glowB, glowA;
private float edgeWidth, quality, octaves, time;
private float resX, resY, scale, speed;
private float contrast, breathe, glowStrength, density;
@Override
public void write(java.nio.ByteBuffer buffer) {
Std140Builder.intoBuffer(buffer)
.putFloat(colorR).putFloat(colorG).putFloat(colorB).putFloat(colorA)
.putFloat(glowR).putFloat(glowG).putFloat(glowB).putFloat(glowA)
.putFloat(edgeWidth).putFloat(quality).putFloat(octaves).putFloat(time)
.putFloat(resX).putFloat(resY).putFloat(scale).putFloat(speed)
.putFloat(contrast).putFloat(breathe).putFloat(glowStrength).putFloat(density);
}
@Override
public boolean equals(Object o) {
return false;
}
}
}

View file

@ -0,0 +1,36 @@
/*
* This file is part of the LoVisual Client distribution.
* Copyright (c) 2026 loki5512344.
*
* Licensed under the GNU General Public License v3.0.
*/
package dev.loki.lovisual.util.animation;
/**
* Кривые анимации для equip-движений руки. Чистая математика без Minecraft.
*/
public enum EaseCurves {
;
/**
* Ease-out-back: перелетает чуть дальше 1.0 перед установкой, давая движению вес.
*
* @param c1 сила «отдачи» (оригинал — 1.70158)
*/
public static float easeOutBack(float x, float c1) {
float c3 = c1 + 1.0f;
float t = x - 1.0f;
return 1.0f + c3 * t * t * t + c1 * t * t;
}
/**
* Перекладывает линейный [0,1] equip-прогресс через ease-out-back,
* затем подмешивает по intensity: 0 — ванильная линейность, 1 — полная кривая.
*/
public static float shapeEquipProgress(float linear, float c1, float intensity) {
float eased = easeOutBack(linear, c1);
float amount = Math.max(0.0f, Math.min(1.0f, intensity));
return linear + (eased - linear) * amount;
}
}

View file

@ -0,0 +1,32 @@
/*
* This file is part of the LoVisual Client distribution.
* Copyright (c) 2026 loki5512344.
*
* Licensed under the GNU General Public License v3.0.
*/
package dev.loki.lovisual.util.animation;
/**
* Единая цепочка обработки equip-прогресса руки: сначала кривая HoldMyItems
* (перелёт через ease-out-back на полном диапазоне 0..1), затем занижение
* амплитуды от ViewModel. Порядок важен: кривая должна получать полный
* диапазон, а ViewModel — масштабировать уже готовое движение, иначе
* конечная позиция предмета уезжает.
*/
public enum EquipProgressComposer {
;
public static float compose(float linear,
boolean curveEnabled, float curveC1, float curveIntensity,
boolean lowerEnabled, float lowering) {
float progress = linear;
if (curveEnabled) {
progress = EaseCurves.shapeEquipProgress(progress, curveC1, curveIntensity);
}
if (lowerEnabled) {
progress *= lowering;
}
return progress;
}
}

View file

@ -0,0 +1,119 @@
/*
* This file is part of the LoVisual Client distribution.
* Copyright (c) 2026 loki5512344.
*
* Licensed under the GNU General Public License v3.0.
*/
package dev.loki.lovisual.util.animation;
/**
* Frame-rate independent smooth 0..1 transition value driven by a boolean state.
* <p>
* The contract is intentionally strict so animations can never fight each other:
* the owner computes the target once per frame with {@link #setTarget(boolean)}
* and advances the value exactly once per frame with {@link #update(float)}.
* Renderers only read {@link #value()}. The value approaches the target with
* exponential easing {@code value += (target - value) * (1 - exp(-speed * delta))},
* which is monotonic, symmetric for appear/disappear, and identical in wall-clock
* time regardless of frame rate.
* <p>
* This class is pure Java: no Minecraft types, safe to unit test.
*/
public final class SmoothValue {
/**
* When the remaining distance to the target drops below this, the value snaps
* exactly onto the target so it converges in finite time instead of forever
* trailing a microscopic difference.
*/
private static final float CONVERGENCE_EPSILON = 1.0e-4f;
private final float speed;
private float value;
private float target;
/**
* @param speed approach rate in 1/delta units; higher is faster.
* With delta measured in ticks (20 per second), a speed of
* {@code s} needs roughly {@code ln(100) / s} ticks to close 99% of a transition.
*/
public SmoothValue(float speed) {
this(speed, 0.0f);
}
public SmoothValue(float speed, float initialValue) {
if (!isFinite(speed) || speed <= 0.0f) {
throw new IllegalArgumentException("speed must be finite and positive: " + speed);
}
this.speed = speed;
this.value = clamp01(initialValue);
this.target = this.value;
}
/**
* Sets the goal for the next {@link #update(float)} calls: 1 when the animated
* state is active, 0 when it is not. Does not move the value by itself.
*/
public void setTarget(boolean active) {
target = active ? 1.0f : 0.0f;
}
/**
* Advances the value one step toward the target. Call exactly once per frame.
*
* @param delta elapsed time since the previous update, in the same units the
* speed was configured for (ticks in this client). Non-positive
* or broken deltas are ignored.
*/
public void update(float delta) {
if (!isFinite(delta) || delta <= 0.0f) {
return;
}
float factor = 1.0f - (float) Math.exp(-speed * delta);
if (factor <= 0.0f) {
return;
}
value += (target - value) * factor;
value = clamp01(value);
if (Math.abs(target - value) <= CONVERGENCE_EPSILON) {
value = target;
}
}
/**
* Teleports both value and target: no transition, the next frame renders settled.
*/
public void setInstant(boolean active) {
target = active ? 1.0f : 0.0f;
value = target;
}
/** Current smoothed position in 0..1. */
public float value() {
return value;
}
/** Current goal in 0..1 as last set by {@link #setTarget(boolean)}. */
public float target() {
return target;
}
/** True once the value has fully reached the target. */
public boolean isSettled() {
return value == target;
}
private static float clamp01(float v) {
if (v < 0.0f) return 0.0f;
if (v > 1.0f) return 1.0f;
return v;
}
private static boolean isFinite(float v) {
return !Float.isNaN(v) && !Float.isInfinite(v);
}
}

View file

@ -0,0 +1,60 @@
/*
* This file is part of the LoVisual Client distribution.
* Copyright (c) 2026 loki5512344.
*
* Licensed under the GNU General Public License v3.0.
*/
package dev.loki.lovisual.util.animation.hand;
/**
* Чистая математика синусоидального покачивания предмета при ходьбе/беге.
* <p>
* Фаза берётся из накопителя шагов игрока ({@code walkAnimation.position()}),
* амплитуда — из нормализованной скорости ({@code walkAnimation.speed()},
* 0..1). Один цикл латерали соответствует двум шагам, вертикаль проседает
* дважды за цикл с лёгким сдвигом фазы (как у ванильного боба камеры).
* Класс чистый: без Minecraft-типов, покрыт юнит-тестами.
*/
public enum BobMath {
;
/** Радиан на единицу пройденной фазы (та же частота шагов, что у конечностей). */
public static final float STEP_RATE = 0.6662f;
/** Сдвиг фазы вертикали, чтобы проседание не совпадало с крайними точками латерали. */
static final float VERTICAL_PHASE_OFFSET = 0.2f;
/**
* Амплитуда боба по нормализованной скорости: 0 — стоим, 1 — бег/полная ходьба.
* Некорректные входы (NaN/Inf) дают 0.
*/
public static float amplitude(float speed01) {
if (Float.isNaN(speed01) || Float.isInfinite(speed01)) {
return 0.0f;
}
if (speed01 <= 0.0f) {
return 0.0f;
}
return Math.min(1.0f, speed01);
}
/** Латеральная компонента, -1..1, период — два шага. */
public static float lateral(float phase) {
return (float) Math.sin(phase * STEP_RATE);
}
/** Вертикальная компонента, -1..0: проседание дважды за цикл. */
public static float vertical(float phase) {
float c = (float) Math.cos(phase * STEP_RATE - VERTICAL_PHASE_OFFSET);
return -Math.abs(c);
}
/**
* Точка фазы, где латеральная компонента достигает максимума:
* полезна тестам для проверки симметрии цикла.
*/
public static float lateralPeakPhase() {
return (float) (Math.PI / 2.0 / STEP_RATE);
}
}

View file

@ -0,0 +1,125 @@
/*
* This file is part of the LoVisual Client distribution.
* Copyright (c) 2026 loki5512344.
*
* Licensed under the GNU General Public License v3.0.
*/
package dev.loki.lovisual.util.animation.hand;
import dev.loki.lovisual.util.animation.SmoothValue;
/**
* Покадровое состояние динамики рук: сводит sway, дрейф использования и
* появление при входе в мир. Обновляется ровно один раз за кадр
* ({@link #beginFrame}), рендер только читает значения — это исключает
* двойные шаги, когда кадр рендерит обе руки.
* <p>
* Player/level передаются как Object-идентичности: смена игрока или мира
* перезапускает анимацию появления и гасит ложные дельты взгляда.
* Класс чистый: без Minecraft-типов, покрыт юнит-тестами.
*/
public final class HandAnimState {
/** Резкая смена взгляда больше этих пределов считается телепортом, а не движением мыши. */
static final float TELEPORT_YAW_DEG = 45.0f;
static final float TELEPORT_PITCH_DEG = 25.0f;
static final float MAX_FRAME_DELTA_TICKS = 5.0f;
/** Скорость дрейфа использования (99% за ~ln(100)/speed тиков). */
static final float DRIFT_SPEED = 1.8f;
/** Скорость появления при входе: ~10 тиков до почти полной посадки. */
static final float JOIN_SPEED = 0.45f;
private final SwayState sway = new SwayState();
private final SmoothValue driftMain = new SmoothValue(DRIFT_SPEED);
private final SmoothValue driftOff = new SmoothValue(DRIFT_SPEED);
private final SmoothValue join = new SmoothValue(JOIN_SPEED);
private float lastYaw = Float.NaN;
private float lastPitch = Float.NaN;
private Object lastPlayer;
private Object lastLevel;
/**
* Один шаг на кадр.
*
* @param player идентичность игрока (для смены мира/респавна)
* @param level идентичность мира
* @param yaw текущий yaw взгляда, градусы (любой диапазон)
* @param pitch текущий pitch взгляда, градусы
* @param usingMain предмет используется в главной руке
* @param usingOff предмет используется во второй руке
* @param deltaTicks реальное время кадра в тиках
*/
public void beginFrame(Object player, Object level, float yaw, float pitch,
boolean usingMain, boolean usingOff, float deltaTicks) {
if (player == null) {
reset();
return;
}
if (player != lastPlayer || level != lastLevel) {
lastPlayer = player;
lastLevel = level;
join.setInstant(false);
lastYaw = Float.NaN;
lastPitch = Float.NaN;
}
float dt = SwayState.sanitize(deltaTicks);
if (Float.isNaN(lastYaw)) {
lastYaw = yaw;
lastPitch = pitch;
return;
}
float deltaYaw = SwayState.wrapDegrees(yaw - lastYaw);
float deltaPitch = pitch - lastPitch;
lastYaw = yaw;
lastPitch = pitch;
boolean lookJump = Math.abs(deltaYaw) > TELEPORT_YAW_DEG
|| Math.abs(deltaPitch) > TELEPORT_PITCH_DEG;
if (dt > 0.0f) {
if (lookJump) {
sway.reset();
} else {
sway.update(deltaYaw, deltaPitch, Math.min(dt, MAX_FRAME_DELTA_TICKS));
}
driftMain.setTarget(usingMain);
driftMain.update(dt);
driftOff.setTarget(usingOff);
driftOff.update(dt);
join.setTarget(true);
join.update(dt);
}
}
/** Полный сброс: следующий кадр считается первым после входа в мир. */
public void reset() {
sway.reset();
driftMain.setInstant(false);
driftOff.setInstant(false);
join.setInstant(false);
lastYaw = Float.NaN;
lastPitch = Float.NaN;
lastPlayer = null;
lastLevel = null;
}
public SwayState sway() {
return sway;
}
/** Сглаженный прогресс дрейфа для указанной руки, 0..1. */
public float drift(boolean mainHand) {
return (mainHand ? driftMain : driftOff).value();
}
/** Прогресс появления при входе в мир, 0..1 (1 — предмет встал на место). */
public float joinProgress() {
return join.value();
}
}

View file

@ -0,0 +1,160 @@
/*
* This file is part of the LoVisual Client distribution.
* Copyright (c) 2026 loki5512344.
*
* Licensed under the GNU General Public License v3.0.
*/
package dev.loki.lovisual.util.animation.hand;
/**
* Сборка итоговой позы руки из отдельных эффектов: sway от обзора, боб от
* ходьбы, дрейф к центру при использовании предмета и «въезд» при входе в мир.
* <p>
* Конвенции знаков соответствуют пространству ванильного рендера руки:
* +X — влево на экране (правая рука стоит при X&lt;0), +Y — вверх, +Z — к
* зрителю; углы в градусах. Смещения — в условных блоках.
* Класс чистый: без Minecraft-типов, покрыт юнит-тестами.
*/
public enum HandPoseMath {
;
/** Нулевая поза — применять трансформы не нужно. */
public static final Pose ZERO = new Pose(0.0f, 0.0f, 0.0f, 0.0f, 0.0f, 0.0f);
private static final float SWAY_POS_PER_DEG = 0.010f;
private static final float SWAY_ROT_Y_PER_DEG = 1.4f;
private static final float SWAY_ROT_X_PER_DEG = 1.1f;
private static final float SWAY_ROT_Z_PER_DEG = 0.5f;
private static final float BOB_POS_X = 0.045f;
private static final float BOB_POS_Y = 0.035f;
private static final float BOB_ROT_Z_DEG = 2.5f;
private static final float BOB_ROT_X_DEG = 3.0f;
private static final float DRIFT_POS_X = 0.10f;
private static final float DRIFT_POS_Z = 0.05f;
private static final float DRIFT_ROT_Y_DEG = 10.0f;
private static final float DRIFT_ROT_Z_DEG = 9.0f;
private static final float JOIN_DROP = 0.85f;
private static final float JOIN_TILT_DEG = 18.0f;
/**
* Отставание от поворота камеры: камера повернула вправо (yaw+) — предмет
* сместился влево на экране (+X) и довернулся назад (ry-), взгляд вниз
* (pitch+) — предмет приподнялся (+Y) и задрался (rx-).
*/
public static Pose sway(float yawLagDeg, float pitchLagDeg, float strength) {
float s = scale(strength);
if (s <= 0.0f) {
return ZERO;
}
return new Pose(
yawLagDeg * SWAY_POS_PER_DEG * s,
pitchLagDeg * SWAY_POS_PER_DEG * s,
0.0f,
-pitchLagDeg * SWAY_ROT_X_PER_DEG * s,
-yawLagDeg * SWAY_ROT_Y_PER_DEG * s,
yawLagDeg * SWAY_ROT_Z_PER_DEG * s
);
}
/** Покачивание при ходьбе: {@code phase} — позиция шага, {@code amplitude01} — 0..1 от скорости. */
public static Pose bob(float phase, float amplitude01, float strength) {
float a = clamp01(amplitude01) * scale(strength);
if (a <= 0.0f) {
return ZERO;
}
float lateral = BobMath.lateral(phase);
float vertical = BobMath.vertical(phase);
return new Pose(
-lateral * BOB_POS_X * a,
vertical * BOB_POS_Y * a,
0.0f,
vertical * BOB_ROT_X_DEG * a,
0.0f,
-lateral * BOB_ROT_Z_DEG * a
);
}
/**
* Дрейф используемого предмета (еда/зелье/щит) к центру экрана.
* Правая рука тянется влево (+X), левая — вправо (-X), плюс лёгкий
* наклон внутрь.
*/
public static Pose drift(float progress01, float strength, boolean rightArm) {
float s = clamp01(progress01) * scale(strength);
if (s <= 0.0f) {
return ZERO;
}
float side = rightArm ? 1.0f : -1.0f;
return new Pose(
side * DRIFT_POS_X * s,
0.0f,
-DRIFT_POS_Z * s,
0.0f,
-side * DRIFT_ROT_Y_DEG * s,
side * DRIFT_ROT_Z_DEG * s
);
}
/** Появление при входе в мир: предмет въезжает снизу и распрямляется. */
public static Pose joinDrop(float progress01) {
float p = clamp01(progress01);
float remain = 1.0f - p;
if (remain <= 0.0f) {
return ZERO;
}
float square = remain * remain;
return new Pose(0.0f, -JOIN_DROP * square, 0.0f, JOIN_TILT_DEG * square, 0.0f, 0.0f);
}
/** Поэлементная сумма поз. */
public static Pose add(Pose a, Pose b) {
if (a == ZERO) return b;
if (b == ZERO) return a;
return new Pose(
a.dx() + b.dx(),
a.dy() + b.dy(),
a.dz() + b.dz(),
a.rx() + b.rx(),
a.ry() + b.ry(),
a.rz() + b.rz()
);
}
static float clamp01(float v) {
if (Float.isNaN(v)) return 0.0f;
if (v < 0.0f) return 0.0f;
if (v > 1.0f) return 1.0f;
return v;
}
/**
* Множитель силы настройки: линейный, без потолка — диапазон настроек 0..2
* должен именно усиливать эффект, а не обрезаться. Отрицательные и
* сломанные значения дают ноль.
*/
static float scale(float strength) {
if (Float.isNaN(strength) || Float.isInfinite(strength)) return 0.0f;
return Math.max(0.0f, strength);
}
/**
* Итоговая поза руки: смещение (dx, dy, dz) и поворот (rx, ry, rz в градусах),
* применяемые вокруг точки взгляда до ванильных трансформов руки.
*/
public record Pose(float dx, float dy, float dz, float rx, float ry, float rz) {
/** Все компоненты по модулю не больше epsilon — трансформ можно не применять. */
public boolean isSignificant(float epsilon) {
return Math.abs(dx) > epsilon
|| Math.abs(dy) > epsilon
|| Math.abs(dz) > epsilon
|| Math.abs(rx) > epsilon
|| Math.abs(ry) > epsilon
|| Math.abs(rz) > epsilon;
}
}
}

View file

@ -0,0 +1,117 @@
/*
* This file is part of the LoVisual Client distribution.
* Copyright (c) 2026 loki5512344.
*
* Licensed under the GNU General Public License v3.0.
*/
package dev.loki.lovisual.util.animation.hand;
/**
* Накопитель «отставания» предмета от поворота камеры (view sway).
* <p>
* Модель: дельты взгляда за кадр (в градусах) накапливаются в отставание
* {@code lag}, ограниченное сверху, и экспоненциально затухают к нулю
* (RELAX). Отображаемое значение догоняет накопленное отставание
* экспонентой (RESPONSE). Всё в градусах, время — в тиках, поэтому
* поведение не зависит от частоты кадров.
* <p>
* Класс чистый: без Minecraft-типов, покрыт юнит-тестами.
*/
public final class SwayState {
public static final float MAX_YAW_LAG_DEG = 6.0f;
public static final float MAX_PITCH_LAG_DEG = 4.0f;
/** Скорость догоняния отображаемого отставания, 1/тик. */
static final float RESPONSE_PER_TICK = 6.0f;
/** Скорость возврата накопленного отставания к нулю, 1/тик. */
static final float RELAX_PER_TICK = 0.25f;
/** Верхняя граница шага, чтобы пауза/лаг не давали рывок. */
static final float MAX_DELTA_TICKS = 5.0f;
/** Ниже этого всё состояние защёлкивается в точный ноль — конечное время затухания. */
private static final float SETTLE_EPSILON = 1.0e-4f;
private float yawLag; // накопленное отставание по yaw, градусы
private float pitchLag; // накопленное отставание по pitch, градусы
private float yawSmooth; // отображаемое отставание по yaw, градусы
private float pitchSmooth;
/**
* Продвигает состояние на один кадр.
*
* @param deltaYawDeg изменение yaw камеры за кадр (может быть любым по знаку)
* @param deltaPitchDeg изменение pitch камеры за кадр
* @param deltaTicks реальное время кадра в тиках; неположительное пропускается
*/
public void update(float deltaYawDeg, float deltaPitchDeg, float deltaTicks) {
float dt = sanitize(deltaTicks);
if (dt <= 0.0f) {
return;
}
yawLag = clamp(yawLag + sanitize(deltaYawDeg), -MAX_YAW_LAG_DEG, MAX_YAW_LAG_DEG);
pitchLag = clamp(pitchLag + sanitize(deltaPitchDeg), -MAX_PITCH_LAG_DEG, MAX_PITCH_LAG_DEG);
float follow = 1.0f - (float) Math.exp(-RESPONSE_PER_TICK * dt);
yawSmooth += (yawLag - yawSmooth) * follow;
pitchSmooth += (pitchLag - pitchSmooth) * follow;
float relax = (float) Math.exp(-RELAX_PER_TICK * dt);
yawLag *= relax;
pitchLag *= relax;
if (Math.abs(yawLag) <= SETTLE_EPSILON && Math.abs(pitchLag) <= SETTLE_EPSILON
&& Math.abs(yawSmooth) <= SETTLE_EPSILON && Math.abs(pitchSmooth) <= SETTLE_EPSILON) {
reset();
}
}
/** Мгновенный сброс: предмет стоит ровно, без хвостов. */
public void reset() {
yawLag = 0.0f;
pitchLag = 0.0f;
yawSmooth = 0.0f;
pitchSmooth = 0.0f;
}
/** Сглаженное отставание по yaw в градусах. */
public float yawLag() {
return yawSmooth;
}
/** Сглаженное отставание по pitch в градусах. */
public float pitchLag() {
return pitchSmooth;
}
/** Обе оси в нуле — считать можно не применять трансформ вовсе. */
public boolean isSettled() {
return yawLag == 0.0f && pitchLag == 0.0f && yawSmooth == 0.0f && pitchSmooth == 0.0f;
}
/**
* Приводит разницу углов к диапазону [-180, 180): переход через
* границу мира не даёт ложного рывка на 360 градусов.
*/
public static float wrapDegrees(float degrees) {
float d = sanitize(degrees) % 360.0f;
if (d >= 180.0f) {
d -= 360.0f;
}
if (d < -180.0f) {
d += 360.0f;
}
return d;
}
static float sanitize(float v) {
return (Float.isNaN(v) || Float.isInfinite(v)) ? 0.0f : v;
}
static float clamp(float v, float min, float max) {
if (v < min) return min;
if (v > max) return max;
return v;
}
}

View file

@ -0,0 +1,61 @@
/*
* This file is part of the LoVisual Client distribution.
* Copyright (c) 2026 loki5512344.
*
* Licensed under the GNU General Public License v3.0.
*/
package dev.loki.lovisual.util.protection;
/**
* Minimal pure throttle for repeated warnings.
*
* <p>The caller supplies the current time, so the class has no clock
* dependency and stays unit-testable.</p>
*/
public final class NotifyThrottle {
private final long intervalMs;
private boolean fired;
private long lastMs;
public NotifyThrottle(long intervalMs) {
this.intervalMs = Math.max(0L, intervalMs);
}
/**
* Returns true when the caller may emit a notification now, recording
* the given time as the last emission. Returns false while the previous
* emission is still inside the interval.
*/
public boolean tryAcquire(long nowMs) {
if (remainingMs(nowMs) > 0L) {
return false;
}
fired = true;
lastMs = nowMs;
return true;
}
/**
* Milliseconds left until the next notification is allowed.
* Zero when the throttle has never fired or the window elapsed.
*/
public long remainingMs(long nowMs) {
if (!fired) {
return 0L;
}
long elapsed = nowMs - lastMs;
if (elapsed < 0L) {
// Clock moved backwards: do not lock out the caller.
return 0L;
}
long left = intervalMs - elapsed;
return Math.max(0L, left);
}
public void reset() {
fired = false;
lastMs = 0L;
}
}

View file

@ -0,0 +1,71 @@
/*
* This file is part of the LoVisual Client distribution.
* Copyright (c) 2026 loki5512344.
*
* Licensed under the GNU General Public License v3.0.
*/
package dev.loki.lovisual.util.protection;
import java.util.LinkedHashMap;
import java.util.Map;
/**
* Pure decision logic for the LockSlot module: whether dropping an item
* from a given hotbar slot must be blocked.
*/
public final class SlotLockPolicy {
public static final int HOTBAR_SIZE = 9;
private SlotLockPolicy() {
}
/**
* Default setting state: all hotbar slots unlocked.
* Keys are ordered "slot1".."slot9".
*/
public static Map<String, Boolean> defaultSlotStates() {
Map<String, Boolean> out = new LinkedHashMap<>();
for (int i = 0; i < HOTBAR_SIZE; i++) {
out.put(slotKey(i), false);
}
return out;
}
/**
* Setting key for a zero-based hotbar slot: 0 -> "slot1", 8 -> "slot9".
*/
public static String slotKey(int hotbarSlot) {
return "slot" + (hotbarSlot + 1);
}
/**
* True when the slot is explicitly locked by the given states map.
* Missing or out-of-range slots are treated as unlocked.
*/
public static boolean isSlotLocked(Map<String, Boolean> slotStates, int hotbarSlot) {
if (slotStates == null || slotStates.isEmpty()) return false;
if (hotbarSlot < 0 || hotbarSlot >= HOTBAR_SIZE) return false;
Boolean locked = slotStates.get(slotKey(hotbarSlot));
return locked != null && locked;
}
/**
* Full drop decision for a hotbar slot.
*
* @param slotStates locked-slot setting states keyed by {@link #slotKey(int)}
* @param selectedSlot zero-based hotbar slot the drop comes from
* @param playersNearby whether other players are within the guard radius
* @param onlyNearby the "only_players_nearby" setting value
*/
public static boolean isDropBlocked(
Map<String, Boolean> slotStates,
int selectedSlot,
boolean playersNearby,
boolean onlyNearby
) {
if (!isSlotLocked(slotStates, selectedSlot)) return false;
return !onlyNearby || playersNearby;
}
}

View file

@ -522,7 +522,6 @@
"setting.worldtweaks.change_time": "Change time", "setting.worldtweaks.change_time": "Change time",
"setting.worldtweaks.time_hours": "Time (hours)", "setting.worldtweaks.time_hours": "Time (hours)",
"setting.worldtweaks.sync_system_time": "Sync with system time", "setting.worldtweaks.sync_system_time": "Sync with system time",
"setting.worldtweaks.item_physics": "Item physics",
"setting.fovcontrol.use_custom_fov": "Use custom FOV", "setting.fovcontrol.use_custom_fov": "Use custom FOV",
"setting.fovcontrol.custom_fov": "Custom FOV", "setting.fovcontrol.custom_fov": "Custom FOV",
"setting.fovcontrol.expand_scale": "Expand scale", "setting.fovcontrol.expand_scale": "Expand scale",
@ -1297,8 +1296,6 @@
"setting.blockhighlight.breaking_animation.option.static": "Static", "setting.blockhighlight.breaking_animation.option.static": "Static",
"setting.blockhighlight.breaking_color": "Breaking color", "setting.blockhighlight.breaking_color": "Breaking color",
"setting.blockhighlight.breaking_color2": "Second breaking color", "setting.blockhighlight.breaking_color2": "Second breaking color",
"setting.invisesp.color": "Color",
"setting.invisesp.line_width": "Line width",
"setting.holeesp.range_xz": "Range XZ", "setting.holeesp.range_xz": "Range XZ",
"setting.holeesp.range_y": "Range Y", "setting.holeesp.range_y": "Range Y",
"setting.holeesp.height": "Height", "setting.holeesp.height": "Height",
@ -1913,7 +1910,19 @@
"setting.draggable.triangulator.triangulator_cache_lifetime": "Cache lifetime", "setting.draggable.triangulator.triangulator_cache_lifetime": "Cache lifetime",
"setting.draggable.triangulator.triangulator_auto_reset": "Auto reset", "setting.draggable.triangulator.triangulator_auto_reset": "Auto reset",
"module.autobed.description": "Automatically places and detonates beds in explosive dimensions.", "module.autobed.description": "Automatically places and detonates beds in explosive dimensions.",
"module.invisesp.description": "Outlines players hidden by invisibility.", "module.invisesp.description": "Makes invisible players visible with adjustable translucency.",
"setting.invisesp.opacity": "Opacity",
"module.itemphysic.description": "Gives dropped items physical behavior: they lie flat on the ground and spin in the air.",
"setting.itemphysic.reduce_size": "Reduce item size",
"setting.itemphysic.lay_flat": "Lie flat on ground",
"setting.itemphysic.spin_in_air": "Spin in air",
"setting.itemphysic.no_bob": "No bobbing",
"module.atmodawnfog.description": "Cinematic atmosphere: dawn/dusk fog that follows the time of day and camera altitude.",
"setting.atmodawnfog.mode": "Mode",
"setting.atmodawnfog.density": "Density",
"setting.atmodawnfog.scatter_height": "Scatter height",
"setting.atmodawnfog.softness": "Softness",
"setting.atmodawnfog.dawn_color": "Dawn color",
"module.holeesp.description": "Highlights safe holes with a distance-faded fill.", "module.holeesp.description": "Highlights safe holes with a distance-faded fill.",
"setting.autobed.place": "Place", "setting.autobed.place": "Place",
"setting.autobed.explode": "Explode", "setting.autobed.explode": "Explode",
@ -1963,6 +1972,8 @@
"setting.autobed.text_color": "Text color", "setting.autobed.text_color": "Text color",
"setting.autobed.slide_delay": "Slide delay", "setting.autobed.slide_delay": "Slide delay",
"setting.autobed.fade_time": "Fade time", "setting.autobed.fade_time": "Fade time",
"module.shulkerpreview.description": "Preview shulker box contents by hovering it in an inventory.",
"setting.shulkerpreview.scale": "Scale",
"module.rubberhand.description": "Allows attacking and module block-use actions while an item is actively being used.", "module.rubberhand.description": "Allows attacking and module block-use actions while an item is actively being used.",
"setting.rubberhand.actions": "Actions", "setting.rubberhand.actions": "Actions",
"setting.rubberhand.actions.option.attack_while_shield": "Hit while shielding", "setting.rubberhand.actions.option.attack_while_shield": "Hit while shielding",
@ -3213,5 +3224,44 @@
"setting.chinahat.brim_thickness": "Brim thickness", "setting.chinahat.brim_thickness": "Brim thickness",
"setting.chinahat.head_top_offset": "Head height", "setting.chinahat.head_top_offset": "Head height",
"setting.chinahat.head_forward_offset": "Forward offset", "setting.chinahat.head_forward_offset": "Forward offset",
"setting.chinahat.head_right_offset": "Right offset" "setting.chinahat.head_right_offset": "Right offset",
"module.animations.description": "Animates interface transitions.",
"setting.animations.hotbar_raise": "Hotbar raise",
"setting.animations.inventory": "Inventory open",
"module.nofrienddamage.description": "Blocks attacks on players added as friends.",
"module.lockslot.description": "Blocks dropping items from locked hotbar slots (anti-accidental Q).",
"setting.lockslot.slots": "Locked slots",
"setting.lockslot.only_players_nearby": "Only with players nearby",
"setting.lockslot.slots.option.slot1": "Slot 1",
"setting.lockslot.slots.option.slot2": "Slot 2",
"setting.lockslot.slots.option.slot3": "Slot 3",
"setting.lockslot.slots.option.slot4": "Slot 4",
"setting.lockslot.slots.option.slot5": "Slot 5",
"setting.lockslot.slots.option.slot6": "Slot 6",
"setting.lockslot.slots.option.slot7": "Slot 7",
"setting.lockslot.slots.option.slot8": "Slot 8",
"setting.lockslot.slots.option.slot9": "Slot 9",
"module.noserverpack.description": "Blocks forced server resource packs: auto-declines pushes and can remove already applied packs.",
"setting.noserverpack.action": "Action",
"setting.noserverpack.action.option.Decline": "Decline",
"setting.noserverpack.action.option.Remove": "Remove",
"setting.noserverpack.action.option.Ask": "Ask",
"module.tapemouse.description": "Repeats the selected mouse button on an interval, e.g. for AFK farms.",
"setting.tapemouse.button": "Button",
"setting.tapemouse.button.option.Attack": "Attack",
"setting.tapemouse.button.option.Use": "Use",
"setting.tapemouse.interval": "Interval",
"setting.tapemouse.only_hold": "Only while held",
"module.autorespawn.description": "Automatically respawns after death once the configured delay passes.",
"setting.autorespawn.delay": "Delay",
"module.deathcoords.description": "Sends the coordinates of your latest death to the chat.",
"setting.deathcoords.show_dimension": "Show dimension",
"setting.holdmyitems.sway_intensity": "Sway intensity",
"setting.holdmyitems.ease_strength": "Equip ease strength",
"setting.holdmyitems.offhand": "Apply to offhand",
"setting.holdmyitems.walk_bob": "Walk bobbing",
"setting.holdmyitems.bob_strength": "Bobbing strength",
"setting.holdmyitems.use_drift": "Using-item drift",
"setting.holdmyitems.drift_strength": "Drift strength",
"setting.holdmyitems.join_appear": "Smooth world entry"
} }

View file

@ -457,7 +457,6 @@
"setting.worldtweaks.change_time": "Изменять время", "setting.worldtweaks.change_time": "Изменять время",
"setting.worldtweaks.time_hours": "Время (часы)", "setting.worldtweaks.time_hours": "Время (часы)",
"setting.worldtweaks.sync_system_time": "Синхронизировать с временем системы", "setting.worldtweaks.sync_system_time": "Синхронизировать с временем системы",
"setting.worldtweaks.item_physics": "Физика предметов",
"setting.fovcontrol.use_custom_fov": "Свой FOV", "setting.fovcontrol.use_custom_fov": "Свой FOV",
"setting.fovcontrol.custom_fov": "Пользовательский FOV", "setting.fovcontrol.custom_fov": "Пользовательский FOV",
"setting.fovcontrol.expand_scale": "Масштаб расширения", "setting.fovcontrol.expand_scale": "Масштаб расширения",
@ -1092,8 +1091,6 @@
"setting.blockhighlight.breaking_color": "Цвет ломания", "setting.blockhighlight.breaking_color": "Цвет ломания",
"setting.blockhighlight.breaking_color2": "Второй цвет ломания", "setting.blockhighlight.breaking_color2": "Второй цвет ломания",
"setting.aspectratio.aspect_multiplier": "Множитель соотношения сторон", "setting.aspectratio.aspect_multiplier": "Множитель соотношения сторон",
"setting.invisesp.color": "Цвет",
"setting.invisesp.line_width": "Толщина линии",
"setting.holeesp.range_xz": "Радиус XZ", "setting.holeesp.range_xz": "Радиус XZ",
"setting.holeesp.range_y": "Радиус Y", "setting.holeesp.range_y": "Радиус Y",
"setting.holeesp.height": "Высота", "setting.holeesp.height": "Высота",
@ -1708,7 +1705,19 @@
"setting.draggable.triangulator.triangulator_cache_lifetime": "Время жизни кеша", "setting.draggable.triangulator.triangulator_cache_lifetime": "Время жизни кеша",
"setting.draggable.triangulator.triangulator_auto_reset": "Автосброс", "setting.draggable.triangulator.triangulator_auto_reset": "Автосброс",
"module.autobed.description": "Автоматически ставит и взрывает кровати в измерениях, где они взрываются.", "module.autobed.description": "Автоматически ставит и взрывает кровати в измерениях, где они взрываются.",
"module.invisesp.description": "Обводит игроков, скрытых невидимостью.", "module.invisesp.description": "Делает невидимых игроков видимыми (полупрозрачно).",
"setting.invisesp.opacity": "Прозрачность",
"module.itemphysic.description": "Даёт предметам на земле физику: лежат плашмя, крутятся в воздухе.",
"setting.itemphysic.reduce_size": "Уменьшить размер",
"setting.itemphysic.lay_flat": "Лежит плашмя на земле",
"setting.itemphysic.spin_in_air": "Крутится в воздухе",
"setting.itemphysic.no_bob": "Без покачивания",
"module.atmodawnfog.description": "Кинематографичная атмосфера: туман рассвета/заката, зависящий от времени суток и высоты.",
"setting.atmodawnfog.mode": "Режим",
"setting.atmodawnfog.density": "Плотность",
"setting.atmodawnfog.scatter_height": "Высота рассеивания",
"setting.atmodawnfog.softness": "Мягкость",
"setting.atmodawnfog.dawn_color": "Цвет зари",
"module.holeesp.description": "Подсвечивает безопасные холки с фейдом по дистанции.", "module.holeesp.description": "Подсвечивает безопасные холки с фейдом по дистанции.",
"setting.autobed.place": "Ставить", "setting.autobed.place": "Ставить",
"setting.autobed.explode": "Взрывать", "setting.autobed.explode": "Взрывать",
@ -1758,6 +1767,8 @@
"setting.autobed.text_color": "Цвет текста", "setting.autobed.text_color": "Цвет текста",
"setting.autobed.slide_delay": "Задержка слайда", "setting.autobed.slide_delay": "Задержка слайда",
"setting.autobed.fade_time": "Время затухания", "setting.autobed.fade_time": "Время затухания",
"module.shulkerpreview.description": "Показывает содержимое шалкера при наведении в инвентаре.",
"setting.shulkerpreview.scale": "Масштаб",
"module.rubberhand.description": "Позволяет атаковать и выполнять блочные действия модулей во время активного использования предмета.", "module.rubberhand.description": "Позволяет атаковать и выполнять блочные действия модулей во время активного использования предмета.",
"setting.rubberhand.actions": "Действия", "setting.rubberhand.actions": "Действия",
"setting.rubberhand.actions.option.attack_while_shield": "Бить в щите", "setting.rubberhand.actions.option.attack_while_shield": "Бить в щите",
@ -3003,5 +3014,44 @@
"setting.chinahat.brim_thickness": "Толщина полей", "setting.chinahat.brim_thickness": "Толщина полей",
"setting.chinahat.head_top_offset": "Высота на голове", "setting.chinahat.head_top_offset": "Высота на голове",
"setting.chinahat.head_forward_offset": "Смещение вперёд", "setting.chinahat.head_forward_offset": "Смещение вперёд",
"setting.chinahat.head_right_offset": "Смещение вправо" "setting.chinahat.head_right_offset": "Смещение вправо",
"module.animations.description": "Плавные анимации интерфейса.",
"setting.animations.hotbar_raise": "Подъём хотбара",
"setting.animations.inventory": "Открытие инвентаря",
"module.nofrienddamage.description": "Блокирует удары по игрокам, добавленным в друзья.",
"module.lockslot.description": "Запрещает выбрасывать предметы из заблокированных слотов хотбара (защита от случайного Q).",
"setting.lockslot.slots": "Заблокированные слоты",
"setting.lockslot.only_players_nearby": "Только при игроках рядом",
"setting.lockslot.slots.option.slot1": "Слот 1",
"setting.lockslot.slots.option.slot2": "Слот 2",
"setting.lockslot.slots.option.slot3": "Слот 3",
"setting.lockslot.slots.option.slot4": "Слот 4",
"setting.lockslot.slots.option.slot5": "Слот 5",
"setting.lockslot.slots.option.slot6": "Слот 6",
"setting.lockslot.slots.option.slot7": "Слот 7",
"setting.lockslot.slots.option.slot8": "Слот 8",
"setting.lockslot.slots.option.slot9": "Слот 9",
"module.noserverpack.description": "Блокирует принудительную загрузку серверного ресурспака: автоматически отклоняет запросы и может убрать уже применённые паки.",
"setting.noserverpack.action": "Действие",
"setting.noserverpack.action.option.Decline": "Отклонять",
"setting.noserverpack.action.option.Remove": "Убирать",
"setting.noserverpack.action.option.Ask": "Спрашивать",
"module.tapemouse.description": "Автоматически нажимает выбранную кнопку мыши с заданным интервалом, например для AFK-ферм.",
"setting.tapemouse.button": "Кнопка",
"setting.tapemouse.button.option.Attack": "Атака",
"setting.tapemouse.button.option.Use": "Использование",
"setting.tapemouse.interval": "Интервал",
"setting.tapemouse.only_hold": "Только при зажатии",
"module.autorespawn.description": "Автоматически возрождает после смерти по истечении заданной задержки.",
"setting.autorespawn.delay": "Задержка",
"module.deathcoords.description": "Отправляет в чат координаты вашей последней смерти.",
"setting.deathcoords.show_dimension": "Показывать измерение",
"setting.holdmyitems.sway_intensity": "Интенсивность покачивания",
"setting.holdmyitems.ease_strength": "Плавность equip",
"setting.holdmyitems.offhand": "Применять к оффхенду",
"setting.holdmyitems.walk_bob": "Покачивание при ходьбе",
"setting.holdmyitems.bob_strength": "Сила покачивания",
"setting.holdmyitems.use_drift": "Дрейф при использовании",
"setting.holdmyitems.drift_strength": "Сила дрейфа",
"setting.holdmyitems.join_appear": "Плавное появление при входе"
} }

View file

@ -0,0 +1,156 @@
#version 330 core
/*
* This file is part of the LoVisual Client distribution.
* Copyright (c) 2026 loki5512344.
*
* Licensed under the GNU General Public License v3.0.
*/
out vec4 color;
uniform sampler2D u_Src;
uniform sampler2D u_Mask;
layout (std140) uniform HandNoise {
vec4 u_Color; // rgb, alpha
vec4 u_Glow; // rgb, alpha
vec4 u_Params0; // edgeWidth, quality, octaves, time
vec4 u_Params1; // resX, resY, scale, speed
vec4 u_Params2; // contrast, breathe, glowStrength, density
};
in vec2 v_TexCoord;
float handNoiseHash21(vec2 p) {
p = fract(p * vec2(127.31, 311.7));
p += dot(p, p + 41.31);
return fract(p.x * p.y);
}
float handNoiseValue(vec2 p) {
vec2 i = floor(p);
vec2 f = fract(p);
float a = handNoiseHash21(i);
float b = handNoiseHash21(i + vec2(1.0, 0.0));
float c = handNoiseHash21(i + vec2(0.0, 1.0));
float d = handNoiseHash21(i + vec2(1.0, 1.0));
vec2 u = f * f * (3.0 - 2.0 * f);
return mix(mix(a, b, u.x), mix(c, d, u.x), u.y);
}
float handNoiseFbm(vec2 p, int octaves) {
float v = 0.0;
float a = 0.55;
mat2 r = mat2(0.80, 0.60, -0.60, 0.80);
int o = clamp(octaves, 1, 3);
for (int i = 0; i < 3; ++i) {
if (i >= o) break;
v += a * handNoiseValue(p);
p = r * p * 2.07 + vec2(23.3, 41.7);
a *= 0.5;
}
return v;
}
float handMaskAt(vec2 uv) {
vec4 mask = texture(u_Mask, uv);
return max(mask.a, max(mask.r, max(mask.g, mask.b)));
}
float handNoiseEdgeDistance(vec2 oneTexel, int quality, float edgeWidth, out float hitAmount) {
float best = 1.0e5;
float hit = 0.0;
int rings = clamp(quality, 1, 4);
int dirs = quality <= 1 ? 8 : 12;
float radiusPx = max(edgeWidth, 1.0);
for (int r = 1; r <= 4; ++r) {
if (r > rings) continue;
float rr = float(r) / float(rings);
float distPx = radiusPx * rr;
for (int i = 0; i < 12; ++i) {
if (i >= dirs) continue;
float angle = (float(i) + float(r) * 0.37) * 6.28318530718 / float(dirs);
vec2 dir = vec2(cos(angle), sin(angle));
float a = handMaskAt(v_TexCoord + dir * oneTexel * distPx);
if (a > 0.02) {
best = min(best, rr);
hit = max(hit, a);
}
}
}
hitAmount = hit;
return best;
}
float handNoiseField(vec2 fragCoord, vec2 resolution, float time, int octaves) {
float scale = max(u_Params1.z, 0.05);
float speed = max(u_Params1.w, 0.0);
float contrast = max(u_Params2.x, 0.05);
float breathe = clamp(u_Params2.y, 0.0, 1.0);
float density = max(u_Params2.w, 0.0);
vec2 uv = fragCoord / max(resolution, vec2(1.0));
vec2 aspect = vec2(resolution.x / max(resolution.y, 1.0), 1.0);
vec2 p = (uv - 0.5) * aspect * scale;
float t = time * speed;
vec2 warp = vec2(
handNoiseFbm(p * 1.3 + vec2(0.0, t * 0.32), octaves),
handNoiseFbm(p * 1.3 + vec2(5.2, -t * 0.26), octaves)
) - 0.5;
float n = handNoiseFbm(p * 1.9 + warp * 1.35 + vec2(t * 0.21, -t * 0.14), octaves);
float pulse = 0.5 + 0.5 * sin(t * 1.6 + warp.x * 2.4);
n = clamp(n * (1.0 + breathe * (pulse - 0.5) * 0.9), 0.0, 1.0);
return pow(clamp(n * (0.85 + density * 0.3), 0.0, 1.0), 1.0 / contrast);
}
void main() {
vec4 base = texture(u_Src, v_TexCoord);
float mask = handMaskAt(v_TexCoord);
vec2 resolution = max(u_Params1.xy, vec2(1.0));
vec2 oneTexel = 1.0 / resolution;
float edgeWidth = max(u_Params0.x, 0.0);
int quality = int(u_Params0.y + 0.5);
int octaves = clamp(int(u_Params0.z + 0.5), 1, 3);
float time = u_Params0.w;
float glowStrength = max(u_Params2.z, 0.0);
if (mask > 0.01) {
float n = handNoiseField(gl_FragCoord.xy, resolution, time, octaves);
float nL = handMaskAt(v_TexCoord + vec2(-oneTexel.x, 0.0));
float nR = handMaskAt(v_TexCoord + vec2(oneTexel.x, 0.0));
float nU = handMaskAt(v_TexCoord + vec2(0.0, -oneTexel.y));
float nD = handMaskAt(v_TexCoord + vec2(0.0, oneTexel.y));
float innerEdge = clamp(1.0 - min(min(nL, nR), min(nU, nD)), 0.0, 1.0);
innerEdge = pow(innerEdge, 0.72);
float alpha = clamp(u_Color.a * mask, 0.0, 1.0);
vec3 shade = u_Color.rgb * (0.55 + 0.75 * n);
shade = mix(shade, u_Glow.rgb, clamp((n - 0.68) * 1.4, 0.0, 0.35));
vec3 outCol = mix(base.rgb, shade, alpha);
outCol += u_Glow.rgb * innerEdge * u_Glow.a * glowStrength * (0.18 + 0.30 * n);
color = vec4(clamp(outCol, 0.0, 1.0), 1.0);
return;
}
float hitAmount = 0.0;
float d = handNoiseEdgeDistance(oneTexel, quality, edgeWidth, hitAmount);
if (hitAmount > 0.01) {
float n = handNoiseField(gl_FragCoord.xy, resolution, time, octaves);
float falloff = clamp(1.0 - d, 0.0, 1.0);
falloff = falloff * falloff * (3.0 - 2.0 * falloff);
float glowA = pow(falloff, 1.3) * u_Glow.a * glowStrength * (0.30 + 0.40 * n);
vec3 outCol = mix(base.rgb, u_Glow.rgb, glowA * 0.5);
outCol += u_Glow.rgb * glowA;
color = vec4(clamp(outCol, 0.0, 1.0), 1.0);
return;
}
color = vec4(base.rgb, 1.0);
}

View file

@ -90,11 +90,13 @@
"LightmapRenderStateExtractorMixin", "LightmapRenderStateExtractorMixin",
"LivingEntityRendererMixin", "LivingEntityRendererMixin",
"LivingEntityRenderStateMixin", "LivingEntityRenderStateMixin",
"LocalPlayerDropMixin",
"LocalPlayerMixin", "LocalPlayerMixin",
"LocatorBarMixin", "LocatorBarMixin",
"MinecraftMixin", "MinecraftMixin",
"MouseMixin", "MouseMixin",
"MultiPlayerGameModeMixin", "MultiPlayerGameModeMixin",
"MultiPlayerGameModeThrowMixin",
"NarratorManagerMixin", "NarratorManagerMixin",
"OptionsNarratorMixin", "OptionsNarratorMixin",
"ParticleManagerMixin", "ParticleManagerMixin",
@ -139,6 +141,7 @@
"accessors.InputAccessor", "accessors.InputAccessor",
"accessors.ItemCooldownEntryAccessor", "accessors.ItemCooldownEntryAccessor",
"accessors.ItemCooldownManagerAccessor", "accessors.ItemCooldownManagerAccessor",
"accessors.KeyMappingAccessor",
"accessors.LevelExtractorAccessor", "accessors.LevelExtractorAccessor",
"accessors.LevelRendererAccessor", "accessors.LevelRendererAccessor",
"accessors.LivingEntityRenderStateAccessor", "accessors.LivingEntityRenderStateAccessor",

View file

@ -0,0 +1,52 @@
/*
* This file is part of the LoVisual Client distribution.
* Copyright (c) 2026 loki5512344.
*
* Licensed under the GNU General Public License v3.0.
*/
package dev.loki.lovisual.features.module.modules.misc;
import org.junit.jupiter.api.Test;
import static org.junit.jupiter.api.Assertions.assertEquals;
import static org.junit.jupiter.api.Assertions.assertFalse;
import static org.junit.jupiter.api.Assertions.assertTrue;
final class NoServerPackTest {
@Test
void declinesInDeclineModeWhenEnabled() {
assertTrue(NoServerPack.shouldDeclinePush(true, NoServerPack.Action.DECLINE.id));
}
@Test
void declinesInRemoveModeWhenEnabled() {
assertTrue(NoServerPack.shouldDeclinePush(true, NoServerPack.Action.REMOVE.id));
}
@Test
void neverDeclinesInAskMode() {
assertFalse(NoServerPack.shouldDeclinePush(true, NoServerPack.Action.ASK.id));
assertFalse(NoServerPack.shouldDeclinePush(false, NoServerPack.Action.ASK.id));
}
@Test
void disabledModuleNeverDeclines() {
assertFalse(NoServerPack.shouldDeclinePush(false, NoServerPack.Action.DECLINE.id));
assertFalse(NoServerPack.shouldDeclinePush(false, NoServerPack.Action.REMOVE.id));
}
@Test
void unknownModeFallsBackToDecline() {
assertEquals(NoServerPack.Action.DECLINE, NoServerPack.Action.byId("bogus"));
assertEquals(NoServerPack.Action.DECLINE, NoServerPack.Action.byId(null));
}
@Test
void modeIdsResolveRoundTrip() {
for (NoServerPack.Action action : NoServerPack.Action.values()) {
assertEquals(action, NoServerPack.Action.byId(action.id));
}
}
}

View file

@ -0,0 +1,81 @@
/*
* This file is part of the LoVisual Client distribution.
* Copyright (c) 2026 loki5512344.
*
* Licensed under the GNU General Public License v3.0.
*/
package dev.loki.lovisual.features.module.modules.player;
import org.junit.jupiter.api.Test;
import static org.junit.jupiter.api.Assertions.assertFalse;
import static org.junit.jupiter.api.Assertions.assertTrue;
final class AutoRespawnTest {
@Test
void deathAloneDoesNotTriggerImmediately() {
AutoRespawn.RespawnGate gate = new AutoRespawn.RespawnGate();
assertFalse(gate.shouldRequestRespawn(true, 1000L, 500L));
}
@Test
void triggersOnceAfterDelay() {
AutoRespawn.RespawnGate gate = new AutoRespawn.RespawnGate();
assertFalse(gate.shouldRequestRespawn(true, 1000L, 500L));
assertFalse(gate.shouldRequestRespawn(true, 1200L, 500L));
assertTrue(gate.shouldRequestRespawn(true, 1500L, 500L));
}
@Test
void requestIsSentOnlyOncePerDeath() {
AutoRespawn.RespawnGate gate = new AutoRespawn.RespawnGate();
assertFalse(gate.shouldRequestRespawn(true, 1000L, 0L));
gate.onRequestSent();
// Player is still locally dead until the server responds.
assertFalse(gate.shouldRequestRespawn(true, 1100L, 0L));
assertFalse(gate.shouldRequestRespawn(true, 5000L, 0L));
}
@Test
void comingBackToLifeRearmsTheGate() {
AutoRespawn.RespawnGate gate = new AutoRespawn.RespawnGate();
assertFalse(gate.shouldRequestRespawn(true, 1000L, 0L));
gate.onRequestSent();
assertFalse(gate.shouldRequestRespawn(true, 2000L, 0L));
assertFalse(gate.shouldRequestRespawn(false, 3000L, 0L));
assertFalse(gate.shouldRequestRespawn(true, 3100L, 500L));
assertTrue(gate.shouldRequestRespawn(true, 3600L, 500L));
}
@Test
void totemReviveWithoutRequestStartsFreshDeath() {
AutoRespawn.RespawnGate gate = new AutoRespawn.RespawnGate();
assertFalse(gate.shouldRequestRespawn(true, 1000L, 2000L));
assertFalse(gate.shouldRequestRespawn(false, 1200L, 2000L));
assertFalse(gate.shouldRequestRespawn(true, 5000L, 2000L));
assertTrue(gate.shouldRequestRespawn(true, 7000L, 2000L));
}
@Test
void negativeDelayTreatedAsZero() {
AutoRespawn.RespawnGate gate = new AutoRespawn.RespawnGate();
assertFalse(gate.shouldRequestRespawn(true, 1000L, -5L));
assertTrue(gate.shouldRequestRespawn(true, 1001L, -5L));
}
@Test
void missingPlayerResetsState() {
AutoRespawn.RespawnGate gate = new AutoRespawn.RespawnGate();
assertFalse(gate.shouldRequestRespawn(true, 1000L, 0L));
gate.onRequestSent();
gate.reset();
assertFalse(gate.shouldRequestRespawn(true, 2000L, 0L));
assertTrue(gate.shouldRequestRespawn(true, 2001L, 0L));
}
}

View file

@ -0,0 +1,66 @@
/*
* This file is part of the LoVisual Client distribution.
* Copyright (c) 2026 loki5512344.
*
* Licensed under the GNU General Public License v3.0.
*/
package dev.loki.lovisual.features.module.modules.player;
import org.junit.jupiter.api.Test;
import static org.junit.jupiter.api.Assertions.assertEquals;
import static org.junit.jupiter.api.Assertions.assertFalse;
import static org.junit.jupiter.api.Assertions.assertTrue;
final class DeathCoordsTest {
@Test
void detectsAliveToDeadTransition() {
DeathCoords.DeathDetector detector = new DeathCoords.DeathDetector();
assertFalse(detector.onTick(false));
assertTrue(detector.onTick(true));
assertFalse(detector.onTick(true));
assertFalse(detector.onTick(true));
}
@Test
void secondDeathIsDetectedAgainAfterRevive() {
DeathCoords.DeathDetector detector = new DeathCoords.DeathDetector();
detector.onTick(true);
assertFalse(detector.onTick(false));
assertTrue(detector.onTick(true));
}
@Test
void resetClearsDeathState() {
DeathCoords.DeathDetector detector = new DeathCoords.DeathDetector();
detector.onTick(true);
detector.reset();
assertTrue(detector.onTick(true));
}
@Test
void messageIncludesDimensionWhenEnabled() {
String message = DeathCoords.formatDeathMessage(12.25, 64.0, -789.5, "minecraft:overworld", true);
assertEquals("You died at 12.25, 64.00, -789.50 (minecraft:overworld)", message);
}
@Test
void messageOmitsDimensionWhenDisabled() {
String message = DeathCoords.formatDeathMessage(0.5, 1.5, 2.5, "minecraft:the_nether", false);
assertEquals("You died at 0.50, 1.50, 2.50", message);
}
@Test
void messageFallsBackWithoutDimensionName() {
assertEquals(
"You died at 1.00, 2.00, 3.00",
DeathCoords.formatDeathMessage(1, 2, 3, "", true)
);
assertEquals(
"You died at 1.00, 2.00, 3.00",
DeathCoords.formatDeathMessage(1, 2, 3, null, true)
);
}
}

View file

@ -0,0 +1,65 @@
/*
* This file is part of the LoVisual Client distribution.
* Copyright (c) 2026 loki5512344.
*
* Licensed under the GNU General Public License v3.0.
*/
package dev.loki.lovisual.features.module.modules.player;
import org.junit.jupiter.api.Test;
import static org.junit.jupiter.api.Assertions.assertFalse;
import static org.junit.jupiter.api.Assertions.assertTrue;
final class TapeMouseTest {
@Test
void firstCallIsDueImmediately() {
TapeMouse.ClickPacer pacer = new TapeMouse.ClickPacer();
assertTrue(pacer.isDue(1000L, 1000L));
}
@Test
void clicksAreSpacedByInterval() {
TapeMouse.ClickPacer pacer = new TapeMouse.ClickPacer();
assertTrue(pacer.isDue(1000L, 500L));
pacer.mark(1000L);
assertFalse(pacer.isDue(1499L, 500L));
assertTrue(pacer.isDue(1500L, 500L));
assertTrue(pacer.isDue(4000L, 500L));
}
@Test
void changingIntervalIsRespected() {
TapeMouse.ClickPacer pacer = new TapeMouse.ClickPacer();
pacer.mark(1000L);
assertFalse(pacer.isDue(2499L, 2000L));
assertTrue(pacer.isDue(3000L, 2000L));
pacer.mark(3000L);
assertTrue(pacer.isDue(3100L, 100L));
}
@Test
void resetAllowsImmediateClickAgain() {
TapeMouse.ClickPacer pacer = new TapeMouse.ClickPacer();
pacer.mark(1000L);
assertFalse(pacer.isDue(1100L, 5000L));
pacer.reset();
assertTrue(pacer.isDue(1100L, 5000L));
}
@Test
void nonPositiveIntervalStillPacesEveryCall() {
TapeMouse.ClickPacer pacer = new TapeMouse.ClickPacer();
assertTrue(pacer.isDue(0L, 0L));
pacer.mark(0L);
assertFalse(pacer.isDue(0L, 0L));
assertTrue(pacer.isDue(1L, 0L));
assertTrue(pacer.isDue(1L, -100L));
}
}

View file

@ -0,0 +1,59 @@
/*
* This file is part of the LoVisual Client distribution.
* Copyright (c) 2026 loki5512344.
*
* Licensed under the GNU General Public License v3.0.
*/
package dev.loki.lovisual.features.module.modules.visuals;
import org.junit.jupiter.api.Test;
import static org.junit.jupiter.api.Assertions.assertEquals;
import static org.junit.jupiter.api.Assertions.assertTrue;
/**
* Чистая математика AtmoDawnFog: окно времени суток и высотное затухание.
*/
final class AtmoDawnFogTest {
private static final float EPS = 1e-4f;
@Test
void dawnPeaksAtSunrise() {
assertEquals(1.0f, AtmoDawnFog.strengthFor(0.0f, "Dawn", 64.0f, 76.0f), EPS);
assertTrue(AtmoDawnFog.strengthFor(0.999f, "Dawn", 64.0f, 76.0f) > 0.98f);
}
@Test
void dawnSilentAtNoonAndDusk() {
assertEquals(0.0f, AtmoDawnFog.strengthFor(0.25f, "Dawn", 64.0f, 76.0f), EPS);
assertEquals(0.0f, AtmoDawnFog.strengthFor(0.5f, "Dawn", 64.0f, 76.0f), EPS);
}
@Test
void duskPeaksAtSunset() {
assertEquals(1.0f, AtmoDawnFog.strengthFor(0.5f, "Dusk", 64.0f, 76.0f), EPS);
assertEquals(0.0f, AtmoDawnFog.strengthFor(0.0f, "Dusk", 64.0f, 76.0f), EPS);
}
@Test
void customModeIgnoresTime() {
assertEquals(1.0f, AtmoDawnFog.strengthFor(0.3f, "Custom", 64.0f, 76.0f), EPS);
assertEquals(1.0f, AtmoDawnFog.strengthFor(0.7f, "Custom", 64.0f, 76.0f), EPS);
}
@Test
void altitudeFadesAboveScatterHeight() {
float below = AtmoDawnFog.strengthFor(0.0f, "Dawn", 76.0f, 76.0f);
float above = AtmoDawnFog.strengthFor(0.0f, "Dawn", 76.0f + 16.0f, 76.0f);
assertEquals(1.0f, below, EPS);
assertEquals(0.5f, above, EPS);
}
@Test
void altitudeClampsAtZero() {
assertEquals(0.0f, AtmoDawnFog.strengthFor(0.0f, "Custom", 76.0f + 1000.0f, 76.0f), EPS);
assertTrue(AtmoDawnFog.strengthFor(0.0f, "Custom", 40.0f, 76.0f) <= 1.0f);
}
}

View file

@ -0,0 +1,78 @@
/*
* This file is part of the LoVisual Client distribution.
* Copyright (c) 2026 loki5512344.
*
* Licensed under the GNU General Public License v3.0.
*/
package dev.loki.lovisual.features.module.modules.visuals;
import dev.loki.lovisual.render.engine.uniform.impl.PostFXUniforms;
import org.junit.jupiter.api.Test;
import static org.junit.jupiter.api.Assertions.assertEquals;
import static org.junit.jupiter.api.Assertions.assertNotEquals;
import static org.junit.jupiter.api.Assertions.assertTrue;
/**
* Пресеты PostFX: Cinematic должен быть визуально заметным,
* Vanilla — нейтральным (это причина «модуль ничего не делал»).
*/
final class PostFxPresetTest {
private static float[] preset(String name) {
float[] out = new float[PostFXUniforms.FLOAT_COUNT];
PostFX.applyPresetTo(name, out);
return out;
}
@Test
void vanillaPresetIsNeutral() {
float[] vanilla = preset("Vanilla");
assertEquals(0.0f, vanilla[0], 1e-6); // exposure
assertEquals(1.0f, vanilla[1], 1e-6); // gamma
assertEquals(1.0f, vanilla[2], 1e-6); // contrast
assertEquals(1.0f, vanilla[3], 1e-6); // saturation
assertEquals(0.0f, vanilla[7], 1e-6); // LUT strength
assertEquals(0.0f, vanilla[15], 1e-6); // tone map off
}
@Test
void cinematicPresetIsVisible() {
float[] cinematic = preset("Cinematic");
assertNotEquals(1.0f, cinematic[2], 1e-4); // contrast изменён
assertTrue(cinematic[3] > 1.05f); // насыщенность выше
assertTrue(cinematic[7] > 0.2f); // тёплый LUT заметен
assertEquals(1.0f, cinematic[15], 1e-6); // filmic tone map
assertTrue(cinematic[19] > 0.0f); // холодные тени
assertTrue(cinematic[27] > 0.0f); // тёплые света
assertTrue(cinematic[8] > 0.1f); // виньетка заметна
}
@Test
void cinematicDiffersFromVanillaOnGrade() {
float[] cinematic = preset("Cinematic");
float[] vanilla = preset("Vanilla");
assertNotEquals(vanilla[2], cinematic[2]);
assertNotEquals(vanilla[3], cinematic[3]);
assertNotEquals(vanilla[7], cinematic[7]);
}
@Test
void unknownPresetFallsBackToSoft() {
float[] unknown = preset("Mystery");
float[] soft = preset("Soft");
for (int i = 0; i < 16; i++) {
assertEquals(soft[i], unknown[i], 1e-6);
}
}
@Test
void nullPresetFallsBackToCinematic() {
float[] byNull = preset(null);
float[] cinematic = preset("Cinematic");
for (int i = 0; i < 16; i++) {
assertEquals(cinematic[i], byNull[i], 1e-6);
}
}
}

View file

@ -0,0 +1,76 @@
/*
* This file is part of the LoVisual Client distribution.
* Copyright (c) 2026 loki5512344.
*
* Licensed under the GNU General Public License v3.0.
*/
package dev.loki.lovisual.util.animation;
import org.junit.jupiter.api.Test;
import static org.junit.jupiter.api.Assertions.assertEquals;
import static org.junit.jupiter.api.Assertions.assertTrue;
/**
* Математика ease-out-back для HoldMyItems.
*/
final class EaseCurvesTest {
private static final float EPS = 1e-4f;
private static final float DEFAULT_C1 = 1.70158f;
@Test
void easeOutBackStartsAtZeroAndEndsAtOne() {
assertEquals(0.0f, EaseCurves.easeOutBack(0.0f, DEFAULT_C1), EPS);
assertEquals(1.0f, EaseCurves.easeOutBack(1.0f, DEFAULT_C1), EPS);
}
@Test
void easeOutBackOvershootsPastOne() {
float max = 0.0f;
for (int i = 0; i <= 100; i++) {
max = Math.max(max, EaseCurves.easeOutBack(i / 100.0f, DEFAULT_C1));
}
assertTrue(max > 1.0f && max < 1.2f, "overshoot должен быть заметным, но умеренным: " + max);
}
@Test
void shapeKeepsEndpoints() {
assertEquals(0.0f, EaseCurves.shapeEquipProgress(0.0f, DEFAULT_C1, 1.0f), EPS);
assertEquals(1.0f, EaseCurves.shapeEquipProgress(1.0f, DEFAULT_C1, 1.0f), EPS);
}
@Test
void zeroIntensityFallsBackToLinear() {
for (int i = 0; i <= 10; i++) {
float x = i / 10.0f;
assertEquals(x, EaseCurves.shapeEquipProgress(x, DEFAULT_C1, 0.0f), EPS);
}
}
@Test
void fullIntensityMatchesCurve() {
float x = 0.7f;
assertEquals(
EaseCurves.easeOutBack(x, DEFAULT_C1),
EaseCurves.shapeEquipProgress(x, DEFAULT_C1, 1.0f),
EPS
);
}
@Test
void intensityClamps() {
float x = 0.5f;
assertEquals(
EaseCurves.shapeEquipProgress(x, DEFAULT_C1, 0.0f),
EaseCurves.shapeEquipProgress(x, DEFAULT_C1, -5.0f),
EPS
);
assertEquals(
EaseCurves.shapeEquipProgress(x, DEFAULT_C1, 1.0f),
EaseCurves.shapeEquipProgress(x, DEFAULT_C1, 5.0f),
EPS
);
}
}

View file

@ -0,0 +1,72 @@
/*
* This file is part of the LoVisual Client distribution.
* Copyright (c) 2026 loki5512344.
*
* Licensed under the GNU General Public License v3.0.
*/
package dev.loki.lovisual.util.animation;
import org.junit.jupiter.api.Test;
import static org.junit.jupiter.api.Assertions.assertEquals;
import static org.junit.jupiter.api.Assertions.assertTrue;
/**
* Совместная работа HoldMyItems (кривая) и ViewModel (занижение):
* конечная позиция определяется занижением, кривая добавляет перелёт.
*/
final class EquipProgressComposerTest {
private static final float EPS = 1e-4f;
private static final float C1 = 1.6f;
@Test
void bothDisabledIsIdentity() {
assertEquals(0.42f, EquipProgressComposer.compose(0.42f, false, 0, 0, false, 0), EPS);
}
@Test
void loweringAloneScalesEndpoints() {
assertEquals(0.0f, EquipProgressComposer.compose(0.0f, false, 0, 0, true, 0.5f), EPS);
assertEquals(0.5f, EquipProgressComposer.compose(1.0f, false, 0, 0, true, 0.5f), EPS);
}
@Test
void curveAloneKeepsEndpoints() {
assertEquals(0.0f, EquipProgressComposer.compose(0.0f, true, C1, 1.0f, false, 0), EPS);
assertEquals(1.0f, EquipProgressComposer.compose(1.0f, true, C1, 1.0f, false, 0), EPS);
}
@Test
void togetherRestingPointIsLowered() {
//equip завершён (progress=1): финальная позиция = занижение, а не 1
float rest = EquipProgressComposer.compose(1.0f, true, C1, 1.0f, true, 0.5f);
assertEquals(0.5f, rest, EPS);
}
@Test
void togetherOvershootExceedsRestingPoint() {
float max = 0.0f;
for (int i = 0; i <= 100; i++) {
max = Math.max(max, EquipProgressComposer.compose(i / 100.0f, true, C1, 1.0f, true, 0.5f));
}
assertTrue(max > 0.5f, "перелёт кривой должен поднимать предмет выше финальной позиции: " + max);
assertTrue(max < 0.5f * 1.2f, "перелёт умеренный: " + max);
}
@Test
void curveDisabledButLoweringOn() {
assertEquals(0.25f, EquipProgressComposer.compose(0.5f, false, 0, 0, true, 0.5f), EPS);
}
@Test
void loweringDisabledButCurveOn() {
float x = 0.7f;
assertEquals(
EaseCurves.shapeEquipProgress(x, C1, 1.0f),
EquipProgressComposer.compose(x, true, C1, 1.0f, false, 0),
EPS
);
}
}

View file

@ -0,0 +1,162 @@
/*
* This file is part of the LoVisual Client distribution.
* Copyright (c) 2026 loki5512344.
*
* Licensed under the GNU General Public License v3.0.
*/
package dev.loki.lovisual.util.animation;
import org.junit.jupiter.api.Test;
import static org.junit.jupiter.api.Assertions.assertEquals;
import static org.junit.jupiter.api.Assertions.assertFalse;
import static org.junit.jupiter.api.Assertions.assertThrows;
import static org.junit.jupiter.api.Assertions.assertTrue;
/**
* Pure-logic tests for the single-update-point animator. No Minecraft types involved.
*/
final class SmoothValueTest {
private static final float EPS = 1.0e-4f;
@Test
void reachesTargetWhenMovingUp() {
SmoothValue value = new SmoothValue(1.5f);
value.setTarget(true);
for (int i = 0; i < 200; i++) {
value.update(0.5f);
}
assertEquals(1.0f, value.value(), EPS);
assertTrue(value.isSettled());
}
@Test
void reachesTargetWhenMovingDown() {
SmoothValue value = new SmoothValue(1.5f, 1.0f);
value.setInstant(true);
value.setTarget(false);
for (int i = 0; i < 200; i++) {
value.update(0.5f);
}
assertEquals(0.0f, value.value(), EPS);
assertTrue(value.isSettled());
}
@Test
void movesUpMonotonically() {
SmoothValue value = new SmoothValue(0.8f);
value.setTarget(true);
float previous = value.value();
for (int i = 0; i < 100; i++) {
value.update(1.0f / 3.0f);
assertTrue(value.value() >= previous, "value must never fall while rising");
assertTrue(value.value() <= 1.0f, "value must stay within 0..1");
previous = value.value();
}
assertEquals(1.0f, value.value(), EPS);
}
@Test
void movesDownMonotonically() {
SmoothValue value = new SmoothValue(0.8f);
value.setInstant(true);
value.setTarget(false);
float previous = value.value();
for (int i = 0; i < 100; i++) {
value.update(1.0f / 3.0f);
assertTrue(value.value() <= previous, "value must never rise while falling");
assertTrue(value.value() >= 0.0f, "value must stay within 0..1");
previous = value.value();
}
assertEquals(0.0f, value.value(), EPS);
}
@Test
void setInstantTeleportsValueAndTarget() {
SmoothValue value = new SmoothValue(1.0f);
assertEquals(0.0f, value.value(), EPS);
value.setInstant(true);
assertEquals(1.0f, value.value(), EPS);
assertEquals(1.0f, value.target(), EPS);
assertTrue(value.isSettled());
value.setInstant(false);
assertEquals(0.0f, value.value(), EPS);
assertEquals(0.0f, value.target(), EPS);
assertTrue(value.isSettled());
}
@Test
void setTargetDoesNotMoveValue() {
SmoothValue value = new SmoothValue(2.0f);
value.setTarget(true);
assertEquals(0.0f, value.value(), EPS, "setTarget must not advance the value");
assertEquals(1.0f, value.target(), EPS);
}
@Test
void frameRateIndependent() {
// One long step must land on the same point as many small steps of equal total time.
float totalTicks = 2.0f;
float speed = 1.1f;
SmoothValue coarse = new SmoothValue(speed);
coarse.setTarget(true);
coarse.update(totalTicks);
SmoothValue fine = new SmoothValue(speed);
fine.setTarget(true);
int steps = 240;
for (int i = 0; i < steps; i++) {
fine.update(totalTicks / steps);
}
assertEquals(coarse.value(), fine.value(), 5.0e-3f);
assertTrue(coarse.value() > 0.5f && coarse.value() < 1.0f);
}
@Test
void zeroAndBrokenDeltasAreIgnored() {
SmoothValue value = new SmoothValue(1.0f);
value.setTarget(true);
value.update(0.0f);
value.update(-1.0f);
value.update(Float.NaN);
assertEquals(0.0f, value.value(), EPS);
assertFalse(value.isSettled());
value.update(1.0f);
assertTrue(value.value() > 0.0f);
}
@Test
void targetFlipsMidFlightReverseDirection() {
SmoothValue value = new SmoothValue(1.0f);
value.setTarget(true);
value.update(1.0f);
assertTrue(value.value() > 0.0f);
value.setTarget(false);
float peak = value.value();
value.update(1.0f);
assertTrue(value.value() < peak, "falling must start immediately after the flip");
}
@Test
void rejectsBrokenSpeed() {
assertThrows(IllegalArgumentException.class, () -> new SmoothValue(0.0f));
assertThrows(IllegalArgumentException.class, () -> new SmoothValue(-1.0f));
assertThrows(IllegalArgumentException.class, () -> new SmoothValue(Float.NaN));
assertThrows(IllegalArgumentException.class, () -> new SmoothValue(Float.POSITIVE_INFINITY));
}
}

View file

@ -0,0 +1,97 @@
/*
* This file is part of the LoVisual Client distribution.
* Copyright (c) 2026 loki5512344.
*
* Licensed under the GNU General Public License v3.0.
*/
package dev.loki.lovisual.util.animation.hand;
import org.junit.jupiter.api.Test;
import static org.junit.jupiter.api.Assertions.assertEquals;
import static org.junit.jupiter.api.Assertions.assertTrue;
/** Фаза и амплитуда покачивания при ходьбе. */
final class BobMathTest {
@Test
void amplitudeIsZeroWhenIdle() {
assertEquals(0.0f, BobMath.amplitude(0.0f), 1.0e-6f);
}
@Test
void amplitudeGrowsWithSpeed() {
float slow = BobMath.amplitude(0.25f);
float fast = BobMath.amplitude(0.9f);
assertTrue(slow > 0.0f && slow < fast, "амплитуда должна расти со скоростью");
assertTrue(slow < 1.0f && fast <= 1.0f);
}
@Test
void amplitudeClampedToOne() {
assertEquals(1.0f, BobMath.amplitude(2.5f), 1.0e-6f);
}
@Test
void brokenSpeedGivesZeroAmplitude() {
assertEquals(0.0f, BobMath.amplitude(Float.NaN), 1.0e-6f);
assertEquals(0.0f, BobMath.amplitude(Float.NEGATIVE_INFINITY), 1.0e-6f);
assertEquals(0.0f, BobMath.amplitude(-0.5f), 1.0e-6f);
}
@Test
void lateralStartsAtZeroAndIsBounded() {
assertEquals(0.0f, BobMath.lateral(0.0f), 1.0e-6f);
for (float phase = 0.0f; phase < 40.0f; phase += 0.125f) {
float value = BobMath.lateral(phase);
assertTrue(value >= -1.0f && value <= 1.0f, "латераль вне диапазона в фазе " + phase);
}
}
@Test
void lateralIsAntisymmetric() {
for (float phase = -3.0f; phase <= 3.0f; phase += 0.25f) {
assertEquals(-BobMath.lateral(phase), BobMath.lateral(-phase), 1.0e-5f);
}
}
@Test
void lateralRepeatsAfterFullCycle() {
float cycle = (float) (2.0 * Math.PI / BobMath.STEP_RATE);
assertEquals(BobMath.lateral(0.3f), BobMath.lateral(0.3f + cycle), 1.0e-4f);
}
@Test
void lateralReachesPeakAtExpectedPhase() {
assertEquals(1.0f, BobMath.lateral(BobMath.lateralPeakPhase()), 1.0e-4f);
}
@Test
void verticalIsNeverPositive() {
for (float phase = 0.0f; phase < 40.0f; phase += 0.125f) {
assertTrue(BobMath.vertical(phase) <= 0.0f, "вертикаль должна тянуть вниз");
assertTrue(BobMath.vertical(phase) >= -1.0f);
}
}
@Test
void verticalDipsTwicePerCycle() {
float cycle = (float) (2.0 * Math.PI / BobMath.STEP_RATE);
int dips = 0;
float previous = 0.0f;
for (float t = 0.0f; t <= cycle; t += 0.01f) {
float value = BobMath.vertical(t);
if (previous > -0.999f && value <= -0.999f) {
dips++;
}
previous = value;
}
assertEquals(2, dips, "за цикл должно быть два проседания (два шага)");
}
@Test
void stepRateMatchesVanillaLimbFrequency() {
assertEquals(0.6662f, BobMath.STEP_RATE, 1.0e-6f);
}
}

Some files were not shown because too many files have changed in this diff Show more