chore(history): squash 16 commit(s) from 2026-09-15

- fix(stability): FireworkParticles NPE, mesh trim, tab-list warn spam, TargetHud mob icons
- fix(trollface_mask): маска не рендерилась на себе, всех/друзей игнорировал всех кроме прицела
- docs(todo): план Фазы 10 — заход по референсам из Downloads (trollface/rain/hiteffect/hmi)
- docs(todo): вывод по сравнению Rain — уже паритет с обоими референсами, mokriyzhir отдельная идея на будущее
- fix(wet_surface): кривая нормаль/мёртвый код ряби + новый модуль WetWorld (SSR top из 3 референсов)
- docs(todo): отмечен WetWorld + фикс wet_surface шейдера как выполненные
- feat(holdmyitems): grip-хват для мечей/инструментов (портировано из Delta HMI)
- docs(todo): отмечен HoldMyItems grip как выполненный
- feat(hiteffect): режим Impact Ring — кольцевая волна + вертикальное свечение при ударе
- docs(todo): отмечена Фаза 10 полностью выполненной
- feat(gps): новый модуль Gps — компас-стрелка + маркер к точке (идея Soup)
- feat(hitbubbles): новый модуль HitBubbles — отдельно от HitEffect.BUBBLES
- feat(ambientparticles): новый модуль — атмосферные мошки / светлячки со шлейфом
- docs(todo): отмечены Gps/HitBubbles/AmbientParticles как выполненные (Фаза 7)
- feat(endcrystal): новый модуль — тонировка модели + осколки при разрушении
- docs(todo): отмечен EndCrystal как выполненный
This commit is contained in:
loki5512344 2026-09-15 21:37:40 +02:00
parent e06ebc26ff
commit 4a73a70dbf
39 changed files with 2250 additions and 201 deletions

34
TODO.md
View file

@ -48,18 +48,34 @@
контейнера с настройкой цвета/видимости, БЕЗ увеличения хитбокса (увеличение —
чит, удалён в Фазе 3.5). Референс: `visuals/Hitboxes.java`
- [x] **Watermark** (HUD, draggable) — обещан в README, в коде отсутствует
- [ ] **Gps** (world) — луч/маркер направления к точке (у Soup ~400 стр.); связка
с Xaero-waypoints и координатами. Референс: `world/Gps.java`
- [x] **Gps** (world) — ЗАВЕРШЕНО 2026-09-15: компас-стрелка + маркер к точке
(`visuals/world/Gps.java`, `util/world/GpsBearing.java` чистая математика + тесты,
команда `%gps <x> <y> <z> | clear`). Референс: `world/Gps.java` — идея взята
(стрелка+маркер+дистанция+авто-удаление), код не копировался. Xaero-связка НЕ
сделана (была опциональной идеей, не в скоупе запроса). Коммит fb0b288.
- [x] **Light** (world) — кастомный цвет освещения (у Soup 41 стр.). Референс: `world/Light.java`
- [ ] **HitBubbles** (particles) — пузыри при ударах по игрокам (у Soup 323 стр.).
Референс: `particles/HitBubbles.java`
- [ ] **AmbientParticles + FireFlyParticle** (particles) — атмосферные частицы мира /
светлячки (у Soup 775 + 241 стр.). Референс: `particles/AmbientParticles.java`,
`particles/types/FireFlyParticle.java`
- [x] **HitBubbles** (particles) — ЗАВЕРШЕНО 2026-09-15: отдельный модуль
`visuals/effects/HitBubbles.java` (не режим HitEffect.BUBBLES — независимое включение,
трёхфазная анимация появление/жизнь/исчезновение через новый `ThreeStageProgress`).
Заодно вынесены общие хелперы из HitEffect (DRY): `AttackHitPoint`,
`OrientedQuadRenderer`, `AnimatedColorModeOptions`. Референс: `particles/HitBubbles.java`
(идея взята, код не копировался; интерполяции/множественные текстуры Soup не портированы).
Коммит 5f2fc70.
- [x] **AmbientParticles + FireFlyParticle** (particles) — ЗАВЕРШЕНО 2026-09-15: модуль
`visuals/particles/AmbientParticles.java`, два режима — Ambient (плывущие мошки) и
Firefly (блуждание + кольцевой буфер шлейфа вместо Trail-объектов референса). 19
визуальных стилей референса (Stars/Hearts/Bloom/.../Pyramid, asset-тяжёлые) НЕ
портированы — фича-крип чит-клиента вне направления проекта; цвет через уже
существующий AnimatedRenderColors вместо своей Sync/Custom-системы. Коммит 28ec166.
- [x] **NameProtect** (misc) — подмена своего ника и ников друзей в тексте чата/скорборда
(у Soup 42 стр.). Референс: `other/NameProtect.java`
- [ ] **EndCrystal** (visuals) — улучшенный рендер энд-кристаллов (у Soup 217 стр.).
Референс: `other/EndCrystal.java`
- [x] **EndCrystal** (visuals) — ЗАВЕРШЕНО 2026-09-15: `visuals/scene/EndCrystal.java` +
`mixins/render/entity/EndCrystalRendererMixin.java` (WrapOperation на ModelPart.render,
различает 2 вызова glass + 1 cube счётчиком-ordinal — тонировка/скрытие частей) +
inject в `ClientLevelMixin.removeEntity` (осколки-вспышка на месте разрушения, т.к.
ваниль убирает сущность мгновенно). Референс: `other/EndCrystal.java` — идея взята
(кастомный цвет, "доиграть" разрушение), Sync/Custom×4-цвета+Wave/Vertex упрощены до
общего AnimatedRenderColors. Коммит 50db8e3.
- [ ] **Trinket 2.0** — апгрейд нашего CosmeticDisplay до уровня Soup Trinket:
иконки-спутники (Atom/Ball/Cat/Donut/Flame/Flower/Gem/Ghost/MCTiers), трейл
(цвет sync/custom, длина, толщина), звуки (типы + громкость), частицы

View file

@ -409,3 +409,85 @@
(N = проходов за кадр).
- [ ] `./gradlew build` после каждого пункта; `./gradlew test` зелёный.
- [ ] Порядок: 9.1 (по одному пункту на коммит) → 9.2.
## Фаза 10: Заход по референсам из Downloads (2026-09-15)
> Пользователь скинул 5 архивов, распакованы в `/tmp/lv_refs/`. Порядок выполнения —
> сверху вниз, по одному коммиту на пункт, `./gradlew build`+`test` после каждого.
- [x] **TrollfaceMask — почини "не работает"** (`/tmp/lv_refs/trollface/TrollfaceMask.java`,
референс Moonward 1.21.4). Наш модуль: `trollface/TrollfaceMask.java`.
Причина бага: биллборд ориентировался к камере (headPos→camPos); от первого лица
камера почти совпадает по вертикали с головой → view≈(0,1,0) → right=view×up
вырождается в 0 → плоскость ложится горизонтально, не видна. Режимы «Друзей»/«Всех»
брали только `TargetManager.getTarget()` (сущность под прицелом) — маска рисовалась
максимум на одном игроке. Фикс: ориентация по `getViewVector()` цели вместо камеры,
перебор `mc.level.players()` с фильтром по режиму (себя/friend via PlayerRelations/все).
Коммит e7e0ff2.
- [x] **Rain — сравнение с двумя референсами (2026-09-15, вывод: у нас уже паритет/лучше).**
Референс 1 `/tmp/lv_refs/rainfull/` (Cataclysm client) — наш `Rain.java` (894 строки) уже
является портом этого самого класса (Drop/Splash/Droplet/Mist, presetDensity/Wind/Mass,
drawStreaks/Ripples/Crowns/Droplets/Mist — 1:1 совпадающие константы и формулы), ПЛЮС у нас
уже есть их wet-surface screen-space постфх (`WetSurfaceUniforms` + шейдер
`assets/lovisual/shaders/wet_surface.frag`, pipeline `WET_SURFACE`, реализован как
`PostProcessPass` в самом Rain.java) — то, что в референсе вынесено в отдельный
`WetSurfaceRenderer`. Улучшать нечего, разница только в деталях реализации пайплайна.
Референс 2 `/tmp/lv_refs/mokriyzhir/` (`WetWorld.java`, пакет `ez.redux...`) — это ОТДЕЛЬНЫЙ
самостоятельный модуль "Wet World" из чужого чит-клиента (Category.VISUALS), НЕ часть дождя:
мочит поверхности всего мира по реальной погоде сервера (`world.getRainGradient`), с настройками
Quality (Low/Medium/High/Ultra), Reflection Strength, Wet Darkening, Ripples+Speed, Rain Only,
учитывает fog (интеграция с их модулем Fog: цвет/дистанция/яркость подмешивается в шейдер).
У нас такого отдельного всегда-активного "мир мокрый по факту дождя" модуля нет — наш
wet-surface жёстко завязан на модуль Rain (работает только когда сам Rain включён). Если
захочется — можно вынести wet-surface-pass Rain.java в отдельный модуль WetWorld с привязкой
к `mc.level.isRaining()`+`getRainLevel(tickDelta)` вместо `onlyWhenRaining` галки, плюс quality-
пресет и fog-интеграцию. НЕ делаю сейчас — не запрошено явно, чисто идея на будущее.
- [x] **WetWorld — сделан отдельным модулем (2026-09-15), шейдер Rain пофикшен.**
Третий референс `/tmp/lv_refs/wetworld2/` (`ru.white...WetWorld` + `WetWorldPipeline` +
`wet_world.vsh/fsh`) оказался технически лучшим из трёх: настоящий SSR с бинарным
рефайнментом пересечения, dithering шага луча, реконструкция нормали по соседям в буфере
глубины (не screen-space derivative), fbm-небо с sun specular, fog-интеграция.
Проверка нашего `wet_surface.frag` («может кривые») подтвердила баги:
1) `normalFromDepth` считался через `dFdx/dFdy(viewPos)` — рвётся на границах глубины,
шумная/блочная нормаль -> дрожащий край лужи;
2) рябь была МЁРТВЫМ КОДОМ: `vec2 perturb = vec2(dFdx(ripple), dFdy(ripple)) * 0.0;` —
умножение на 0 полностью гасило эффект, а сама рябь добавлялась как сырое значение
волны вместо её наклона.
Исправлено (коммит 0986ce6): нормаль — по 4 соседям depth-буфера (как в лучшем референсе),
рябь — аналитический градиент (разность высоты в соседних точках), добавлен bin-рефайнмент
SSR-пересечения + dithering, небо дополнено fbm-облаками/sun specular, затемнение вынесено
в отдельный uniform-параметр (было зашито константой 0.82). Новый модуль
`visuals/weather/WetWorld.java`: мочит мир по факту `mc.level.getRainLevel()`, независимо
от партиклов Rain и тумблера `wet_ground` — Quality (Low/Medium/High/Ultra
-> шаги/дистанция/bin-рефайнмент SSR), Reflection Strength, Darkening, Ripples+Speed,
Rain Only. Переиспользует общий `WET_SURFACE` pipeline/`WetSurfaceUniforms` (при
одновременном включении с Rain.wet_ground эффект стекается — задокументировано в коде).
Fog-интеграция референса НЕ перенесена (нет доступа к чужому Fog-модулю/данным тумана в
нашем движке в этом заходе) — можно добавить отдельно, если понадобится.
- [x] **HitEffect — интегрирован ImpactRing (2026-09-15).**
Референс `/tmp/lv_refs/myasomaybe/ImpactRing/` — `JumpWaveRenderer`/`JumpGlowRenderer`
используют захват сцены + собственный низкоуровневый RHI-пайплайн с SSR-варпом
(кастомный шейдер сэмплит скопированный цветовой буфер по кольцу) — тяжеловесная техника
не в духе нашего движка. Взята идея визуала (кольцо + вертикальная стена свечения в точке
удара), реализована через уже существующий движок: обычные аддитивные квады через
MeshBuilder/Renderer3D (`WORLD_COLORED_ADDITIVE` + `WORLD_COLORED_ADDITIVE_DEPTH`), без
захвата сцены и отдельного шейдера — консистентно с уже существующими WaveEffect/HitBubble
в том же файле. Новый `EffectMode.IMPACT` в HitEffect: настройки impact_radius/
impact_duration/impact_glow_height, срабатывает как WAVE (по удару), рендер —
расширяющееся кольцо (сегменты, fade к центру) + полупрозрачная вертикальная стена по
контуру круга, затухающая вверх и во времени. Коммит 61c478e. `./gradlew build`+`test` ✓.
- [x] **HoldMyItems — сверка с HMI.zip (2026-09-15), портирован grip.**
Референс `/tmp/lv_refs/hmi/` (Delta HMI): обёртка обфусцированного чит-клиента над
СТОРОННИМ jar-модом `holdmyitems-5.1.1+26.2.jar` + свои `SwingAnimations` (десяток стилей
свинга, weapon-inspect a-la CS:GO karambit-spin, "Pretty Hands" режимы). Полный набор
НЕ портирован — фича-крип чит-клиента, конфликтует с KISS и с правилом «без стороннего
jar в сборке» (Delta буквально бандлит чужой мод и синхронизирует с ним настройки через
`OriginalHmiBridge`). Взята единственная самоценная и самодостаточная идея — **grip**:
лёгкий хват предметов с рукоятью (мечи/инструменты/трезубец/палки — см.
`heldAlongTheFist`) наискось в кулаке вместо строго вертикального ванильного equip.
Портировано (коммит b257fde): `HeldAlongFist` (чистый предикат по ItemTags),
`HandPoseMath.grip(...)` (чистая функция позы, тесты gripMirrorsBySide/
gripExtendPullsCloserAndAcrossFist), настройки `grip_enabled`+6 slider'ов в HoldMyItems
(дефолты как у референсного пресета "HMI"), проброс ItemStack через
HandDynamics.apply/ItemInHandRendererMixin. `./gradlew build`+`test` зелёные.
### Проверка после каждого пункта: `./gradlew build` ✓, `./gradlew test` ✓, TODO.md отмечен

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.command.impl.world;
import dev.loki.lovisual.features.command.core.CommandContext;
import dev.loki.lovisual.features.command.core.CommandInfo;
import dev.loki.lovisual.features.command.util.ClientCommand;
import dev.loki.lovisual.features.command.util.CommandOutput;
import dev.loki.lovisual.features.module.lifecycle.Modules;
import dev.loki.lovisual.features.module.modules.visuals.world.Gps;
import net.minecraft.core.BlockPos;
import java.util.List;
import java.util.Locale;
@CommandInfo(
id = "gps",
usage = "%gps <x> <y> <z> | %gps clear",
descriptionKey = "command.gps.description"
)
public final class GpsCommand implements ClientCommand {
@Override
public boolean execute(CommandContext ctx) {
Gps gps = Modules.get(Gps.class);
if (gps == null) {
CommandOutput.error("Gps module is unavailable.");
return true;
}
String first = ctx.arg(0);
if (first != null && "clear".equalsIgnoreCase(first.trim())) {
gps.clearTarget();
CommandOutput.success("Gps target cleared.");
return true;
}
if (ctx.args().size() < 3) {
CommandOutput.warning("Usage: " + metadata().usage());
return true;
}
Integer x = parseInt(ctx.arg(0));
Integer y = parseInt(ctx.arg(1));
Integer z = parseInt(ctx.arg(2));
if (x == null || y == null || z == null) {
CommandOutput.error("Invalid coordinates. Use integers.");
return true;
}
gps.setTarget(new BlockPos(x, y, z));
CommandOutput.success(String.format(Locale.ROOT, "Gps target set: %d, %d, %d", x, y, z));
return true;
}
@Override
public List<String> suggest(CommandContext ctx, int argIndex, String token) {
if (argIndex == 0) {
String lower = token == null ? "" : token.toLowerCase(Locale.ROOT);
return "clear".startsWith(lower) ? List.of("clear") : List.of();
}
return List.of();
}
private static Integer parseInt(String value) {
if (value == null || value.isBlank()) return null;
try {
return Integer.parseInt(value.trim());
} catch (NumberFormatException ignored) {
return null;
}
}
}

View file

@ -19,13 +19,18 @@ import dev.loki.lovisual.util.player.health.PlayerSkinResolver;
import dev.loki.lovisual.util.pvp.opponents.TotemPopSnapshot;
import net.minecraft.client.Minecraft;
import net.minecraft.client.gui.GuiGraphicsExtractor;
import net.minecraft.core.Holder;
import net.minecraft.resources.Identifier;
import net.minecraft.world.entity.EntityType;
import net.minecraft.world.entity.LivingEntity;
import net.minecraft.world.entity.player.Player;
import net.minecraft.world.item.Item;
import net.minecraft.world.item.ItemStack;
import net.minecraft.world.item.Items;
import net.minecraft.world.item.SpawnEggItem;
import java.util.Locale;
import java.util.Optional;
import java.util.UUID;
import static dev.loki.lovisual.features.gui.hud.draggable.impl.targethud.TargetHud.*;
@ -127,6 +132,18 @@ public final class TargetHudFaceRenderer {
HudRenderUtil.scaleAlpha(element.uiStroke, alphaFactor)
);
ItemStack mobIcon = resolveMobIcon(target);
if (mobIcon != null && !mobIcon.isEmpty()) {
float iconSize = size * 0.72f;
float itemScale = Math.max(0.35f, iconSize / 16.0f);
float ix = x + (size - iconSize) * 0.5f;
float iy = y + (size - iconSize) * 0.5f;
// Slight hurt tint via background already; keep icon full alpha
TargetHudEquipmentRenderer.drawItem(renderer, mobIcon, ix, iy, itemScale, 0, alphaFactor);
// Add tiny hurt overlay as text shadow alternative is already via tint
return;
}
String name = target.getName().getString();
String letter = name.isEmpty() ? "?" : name.substring(0, 1).toUpperCase(Locale.ROOT);
TextRenderer faceRenderer = Fonts.renderer("Inter", FontInfo.Type.Bold, textRenderer);
@ -150,6 +167,29 @@ public final class TargetHudFaceRenderer {
}
}
private static ItemStack resolveMobIcon(LivingEntity target) {
if (target == null) return null;
try {
EntityType<?> type = target.getType();
Optional<Holder<Item>> eggHolder = SpawnEggItem.byId(type);
if (eggHolder.isPresent()) {
return new ItemStack(eggHolder.get().value());
}
} catch (Throwable ignored) {
}
// Fallback skulls / heads for bosses and special mobs without spawn eggs
try {
String id = net.minecraft.core.registries.BuiltInRegistries.ENTITY_TYPE.getKey(target.getType()).toString();
if (id.contains("ender_dragon")) return new ItemStack(Items.DRAGON_HEAD);
if (id.contains("wither")) return new ItemStack(Items.WITHER_SKELETON_SKULL);
if (id.contains("iron_golem")) return new ItemStack(Items.IRON_BLOCK);
if (id.contains("snow_golem")) return new ItemStack(Items.SNOW_BLOCK);
if (id.contains("giant")) return new ItemStack(Items.ZOMBIE_HEAD);
} catch (Throwable ignored) {
}
return null;
}
public static void drawTotemPopBadge(TargetHud element, Renderer2D renderer, TextRenderer textRenderer, TotemPopSnapshot snapshot, float faceX, float faceY, float faceSize, float faceRadius, float scaleFactor, float alphaFactor) {
if (snapshot == null || snapshot.count() <= 0 || element.totemPopAnimation <= 0.001f) {
return;

View file

@ -0,0 +1,175 @@
/*
* 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.effects;
import com.mojang.blaze3d.pipeline.RenderPipeline;
import dev.loki.lovisual.config.values.color.RGBAColorValue;
import dev.loki.lovisual.config.values.mode.ModeValue;
import dev.loki.lovisual.config.values.primitive.BooleanValue;
import dev.loki.lovisual.config.values.primitive.NumberValue;
import dev.loki.lovisual.features.module.core.Module;
import dev.loki.lovisual.features.module.core.ModuleCategory;
import dev.loki.lovisual.features.module.core.ModuleInfo;
import dev.loki.lovisual.features.module.phase.WorldPhase;
import dev.loki.lovisual.mixininterface.entity.ILocalPlayer;
import dev.loki.lovisual.render.engine.animation.AnimatedColorModeOptions;
import dev.loki.lovisual.render.engine.animation.AnimatedRenderColors;
import dev.loki.lovisual.render.engine.pipeline.LoVisualRenderPipelines;
import dev.loki.lovisual.render.engine.renderer.Renderer3D;
import dev.loki.lovisual.render.engine.texture.TextureStorage;
import dev.loki.lovisual.render.engine.uniform.MeshBuilder;
import dev.loki.lovisual.render.helpers.particle.OrientedQuadRenderer;
import dev.loki.lovisual.util.animation.ThreeStageProgress;
import dev.loki.lovisual.util.combat.hit.AttackHitPoint;
import dev.loki.lovisual.util.time.GameClock;
import net.minecraft.client.Minecraft;
import net.minecraft.util.Mth;
import net.minecraft.world.entity.Entity;
import net.minecraft.world.entity.LivingEntity;
import net.minecraft.world.entity.player.Player;
import net.minecraft.world.phys.Vec3;
import java.util.ArrayList;
import java.util.List;
/**
* Пузыри-разлёт в точке попадания по цели — отдельный от {@link HitEffect}
* модуль (референс: Soup HitBubbles, идея переиспользована, код не копировался).
* В отличие от режима {@code EffectMode.BUBBLES} в HitEffect (простой fade-out
* за lifeTime), тут трёхфазная анимация появление/жизнь/исчезновение
* ({@link ThreeStageProgress}) и независимое от HitEffect включение —
* оба модуля можно использовать одновременно или по отдельности.
*/
@ModuleInfo(
id = "hitbubbles",
displayName = "HitBubbles",
category = ModuleCategory.VISUALS,
description = "Пузыри в точке попадания с трёхфазной анимацией появления/жизни/исчезновения"
)
public final class HitBubbles extends Module {
private static final float CHARGED_THRESHOLD = 0.9f;
private final ModeValue colorMode = modeSetting("hitBubblesColorMode", "color_mode", "Static", AnimatedColorModeOptions.OPTIONS);
private final NumberValue<Integer> colorSpeed = num("hitBubblesColorSpeed", "color_speed", 18, 2, 54);
private final RGBAColorValue colorValue = color("hitBubblesColor", "color", "#FF55FFFF");
private final RGBAColorValue colorValue2 = visibleWhen(color("hitBubblesColor2", "color2", "#FFFF5555"), this::usesSecondaryColor);
private final NumberValue<Float> bubbleScale = num("hitBubblesScale2", "scale", 1.0f, 0.4f, 2.0f);
private final NumberValue<Float> rotateSpeed = num("hitBubblesRotateSpeed", "rotate_speed", 2.0f, 0.5f, 5.0f);
private final NumberValue<Integer> appearMs = num("hitBubblesAppearMs", "appear_duration_ms", 150, 20, 1500);
private final NumberValue<Integer> existMs = num("hitBubblesExistMs", "exist_duration_ms", 300, 20, 2000);
private final NumberValue<Integer> disappearMs = num("hitBubblesDisappearMs", "disappear_duration_ms", 300, 20, 2000);
private final BooleanValue onlyPlayers = bool("hitBubblesOnlyPlayers2", "only_players", true);
private final BooleanValue depthTest = bool("hitBubblesDepthTest2", "depth_test", true);
private final List<Bubble> bubbles = new ArrayList<>();
@Override
public void onDisable() {
bubbles.clear();
}
public void handleHit(Entity target) {
Minecraft mc = Minecraft.getInstance();
if (!isEnabled() || mc.player == null || mc.level == null) return;
if (!(target instanceof LivingEntity)) return;
if (onlyPlayers.get() && !(target instanceof Player)) return;
ILocalPlayer access = (ILocalPlayer) mc.player;
float yaw = access.lovisual$getLastYaw();
float pitch = access.lovisual$getLastPitch();
Vec3 point = AttackHitPoint.resolve(target, yaw, pitch);
if (point == null) point = target.position();
bubbles.add(new Bubble(point, -yaw, pitch, GameClock.millis()));
}
@Override
public WorldPhase getWorldPhase() {
return WorldPhase.END_MAIN;
}
@Override
public void onRenderWorldEngine(Renderer3D renderer, Renderer3D depthRenderer, float tickDelta) {
Minecraft mc = Minecraft.getInstance();
if (!isEnabled() || mc.player == null || mc.level == null || bubbles.isEmpty()) return;
long totalMs = totalDurationMs();
bubbles.removeIf(b -> GameClock.millis() - b.startMs > totalMs);
if (bubbles.isEmpty()) return;
boolean useDepth = depthTest.get();
RenderPipeline pipeline = useDepth
? LoVisualRenderPipelines.WORLD_TEXTURED_ADDITIVE_LIQUID_IGNORE
: LoVisualRenderPipelines.WORLD_TEXTURED_ADDITIVE;
Renderer3D.DepthMode depthMode = useDepth ? Renderer3D.DepthMode.PRE_DEPTH : Renderer3D.DepthMode.MAIN;
MeshBuilder mesh = renderer.batchTextured(pipeline, TextureStorage.BUBBLE, depthMode);
if (mesh == null) return;
long now = GameClock.millis();
for (Bubble b : bubbles) {
long elapsed = now - b.startMs;
float visibility = ThreeStageProgress.visibility(elapsed, appearMs.get(), existMs.get(), disappearMs.get());
if (visibility <= 0.01f) continue;
b.rotationDeg -= b.angularVelocity * rotateSpeed.get();
float scale = visibility * 2f * bubbleScale.get();
int c1 = applyOpacity(getColorArgb(270), visibility);
int c2 = applyOpacity(getColorArgb(0), visibility);
int c3 = applyOpacity(getColorArgb(180), visibility);
int c4 = applyOpacity(getColorArgb(90), visibility);
OrientedQuadRenderer.addOrientedQuad(mesh, b.pos, b.yaw, b.pitch, b.rotationDeg, scale, c1, c2, c3, c4);
}
}
private long totalDurationMs() {
return (long) appearMs.get() + existMs.get() + disappearMs.get();
}
private boolean usesSecondaryColor() {
return AnimatedRenderColors.usesSecondary(AnimatedColorModeOptions.parse(colorMode.get()));
}
private int getColorArgb(int phase) {
return AnimatedRenderColors.resolve(
AnimatedColorModeOptions.parse(colorMode.get()),
colorSpeed.get(),
phase,
colorValue.getArgb(),
colorValue2.getArgb()
);
}
private static int applyOpacity(int argb, float opacity) {
float clamped = Math.min(1f, Math.max(0f, opacity));
int a = (argb >>> 24) & 0xFF;
int na = Mth.clamp((int) (a * clamped), 0, 255);
return (na << 24) | (argb & 0x00FFFFFF);
}
private static final class Bubble {
private final Vec3 pos;
private final float yaw;
private final float pitch;
private final long startMs;
private final float angularVelocity;
private float rotationDeg;
private Bubble(Vec3 pos, float yaw, float pitch, long startMs) {
this.pos = pos;
this.yaw = yaw;
this.pitch = pitch;
this.startMs = startMs;
this.angularVelocity = 0.5f + (float) Math.random() * 1.5f;
}
}
}

View file

@ -22,10 +22,7 @@ import net.minecraft.util.Mth;
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.ProjectileUtil;
import net.minecraft.world.level.block.state.BlockState;
import net.minecraft.world.phys.AABB;
import net.minecraft.world.phys.EntityHitResult;
import net.minecraft.world.phys.Vec3;
import net.minecraft.world.phys.shapes.VoxelShape;
import org.joml.Quaternionf;
@ -39,13 +36,16 @@ import dev.loki.lovisual.mixininterface.entity.ILocalPlayer;
import dev.loki.lovisual.mixininterface.entity.IPlayerAttackCooldown;
import dev.loki.lovisual.render.engine.RenderState;
import dev.loki.lovisual.render.engine.texture.TextureStorage;
import dev.loki.lovisual.render.engine.animation.AnimatedColorModeOptions;
import dev.loki.lovisual.render.engine.animation.AnimatedRenderColors;
import dev.loki.lovisual.render.engine.color.RenderColor;
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.render.helpers.particle.OrientedQuadRenderer;
import dev.loki.lovisual.render.helpers.player.Particle3D;
import dev.loki.lovisual.render.helpers.util.ParticleTextureMode;
import dev.loki.lovisual.util.combat.hit.AttackHitPoint;
import dev.loki.lovisual.util.time.Timer;
import java.awt.*;
@ -72,6 +72,10 @@ public class HitEffect extends Module {
private static final String SETTING_COLOR2 = "color2";
private static final String SETTING_WAVE_RADIUS = "wave_radius";
private static final String SETTING_WAVE_DURATION = "wave_duration";
private static final String SETTING_IMPACT_RADIUS = "impact_radius";
private static final String SETTING_IMPACT_DURATION = "impact_duration";
private static final String SETTING_IMPACT_GLOW_HEIGHT = "impact_glow_height";
private static final int IMPACT_RING_SEGMENTS = 24;
private static final String SETTING_HIT_PARTICLES = "hit_particles";
private static final String SETTING_HIT_PARTICLE_COUNT = "hit_particle_count";
private static final String SETTING_HIT_PARTICLE_SIZE = "hit_particle_size";
@ -122,6 +126,12 @@ public class HitEffect extends Module {
visibleWhen(num("hitBubblesWaveRadius", SETTING_WAVE_RADIUS, 12, 3, 32), this::isWaveMode);
private final NumberValue<Integer> waveDurationMs =
visibleWhen(num("hitBubblesWaveDuration", SETTING_WAVE_DURATION, 1500, 200, 6000), this::isWaveMode);
private final NumberValue<Float> impactRadius =
visibleWhen(num("hitBubblesImpactRadius", SETTING_IMPACT_RADIUS, 1.4f, 0.4f, 4.0f), this::isImpactMode);
private final NumberValue<Integer> impactDurationMs =
visibleWhen(num("hitBubblesImpactDuration", SETTING_IMPACT_DURATION, 450, 150, 1500), this::isImpactMode);
private final NumberValue<Float> impactGlowHeight =
visibleWhen(num("hitBubblesImpactGlowHeight", SETTING_IMPACT_GLOW_HEIGHT, 0.8f, 0.2f, 2.5f), this::isImpactMode);
private final BooleanValue hitParticlesEnabled =
bool("hitEffectParticlesEnabled", SETTING_HIT_PARTICLES, false);
private final NumberValue<Integer> hitParticleCount =
@ -138,6 +148,7 @@ public class HitEffect extends Module {
private final List<HitBubble> bubbles = new ArrayList<>();
private final List<Star> stars = new ArrayList<>();
private final List<WaveEffect> waves = new ArrayList<>();
private final List<ImpactRing> impacts = new ArrayList<>();
private final List<Particle3D> hitParticles = new ArrayList<>();
private final List<Particle3D>[] hitParticleBuckets = createBuckets();
private int lastHitParticleTick = Integer.MIN_VALUE;
@ -165,44 +176,6 @@ public class HitEffect extends Module {
return (na << 24) | (argb & 0x00FFFFFF);
}
private static void addOrientedQuad(MeshBuilder mesh, Vec3 center, float yaw, float pitch,
float spinDeg, float scale, int c1, int c2, int c3, int c4) {
Quaternionf rot = new Quaternionf()
.rotateY((float) Math.toRadians(yaw))
.rotateX((float) Math.toRadians(pitch));
Vector3f forward = new Vector3f(0, 0, 1).rotate(rot);
Vector3f right = new Vector3f(1, 0, 0).rotate(rot);
Vector3f up = new Vector3f(0, 1, 0).rotate(rot);
Quaternionf spin = new Quaternionf().fromAxisAngleRad(forward, (float) Math.toRadians(spinDeg));
right.rotate(spin);
up.rotate(spin);
right.mul(scale / 2f);
up.mul(scale / 2f);
double p1x = center.x - right.x() - up.x();
double p1y = center.y - right.y() - up.y();
double p1z = center.z - right.z() - up.z();
double p2x = center.x + right.x() - up.x();
double p2y = center.y + right.y() - up.y();
double p2z = center.z + right.z() - up.z();
double p3x = center.x + right.x() + up.x();
double p3y = center.y + right.y() + up.y();
double p3z = center.z + right.z() + up.z();
double p4x = center.x - right.x() + up.x();
double p4y = center.y - right.y() + up.y();
double p4z = center.z - right.z() + up.z();
mesh.ensureQuadCapacity();
int i1 = mesh.vec3(p1x, p1y, p1z).vec2(0, 1).color(new RenderColor(c1)).next();
int i2 = mesh.vec3(p2x, p2y, p2z).vec2(1, 1).color(new RenderColor(c2)).next();
int i3 = mesh.vec3(p3x, p3y, p3z).vec2(1, 0).color(new RenderColor(c3)).next();
int i4 = mesh.vec3(p4x, p4y, p4z).vec2(0, 0).color(new RenderColor(c4)).next();
mesh.quad(i1, i2, i3, i4);
}
private static void addBillboardQuad(MeshBuilder mesh, double cx, double cy, double cz,
float size, Quaternionf camRot, int argb) {
Vector3f right = new Vector3f(1, 0, 0).rotate(camRot).mul(size);
@ -274,6 +247,7 @@ public class HitEffect extends Module {
bubbles.clear();
stars.clear();
waves.clear();
impacts.clear();
hitParticles.clear();
lastHitParticleTick = Integer.MIN_VALUE;
spawnedHitParticlesThisTick = 0;
@ -289,13 +263,14 @@ public class HitEffect extends Module {
Vec3 pos = target.position();
BlockPos basePos = BlockPos.containing(pos.x, pos.y - 0.1, pos.z);
addWave(basePos);
} else if (effectMode.get() == EffectMode.IMPACT) {
impacts.add(new ImpactRing(target.position(), GameClock.millis()));
} else {
ILocalPlayer access = (ILocalPlayer) mc.player;
float yaw = access.lovisual$getLastYaw();
float pitch = access.lovisual$getLastPitch();
double distance = mc.player.distanceTo(target) + 1.0;
Vec3 point = getRtxPoint(target, yaw, pitch, distance);
Vec3 point = AttackHitPoint.resolve(target, yaw, pitch);
if (point == null) point = target.position();
bubbles.add(new HitBubble(point, -yaw, pitch, new Timer()));
@ -343,10 +318,28 @@ public class HitEffect extends Module {
renderWaveEffect(renderer);
return;
}
if (effectMode.get() == EffectMode.IMPACT) {
renderImpactEffect(renderer);
return;
}
renderBubbleEffect(renderer, tickDelta);
}
private void renderImpactEffect(Renderer3D renderer) {
if (impacts.isEmpty()) return;
Iterator<ImpactRing> iterator = impacts.iterator();
while (iterator.hasNext()) {
ImpactRing impact = iterator.next();
if (impact.isExpired()) {
iterator.remove();
continue;
}
impact.render(renderer);
}
}
private void renderBubbleEffect(Renderer3D renderer, float tickDelta) {
boolean useDepth = depthTest.get();
RenderPipeline pipeline = useDepth
@ -371,7 +364,7 @@ public class HitEffect extends Module {
int c3 = applyOpacity(getColorArgb(180), 1f - factor);
int c4 = applyOpacity(getColorArgb(90), 1f - factor);
addOrientedQuad(mesh, b.pos, b.yaw, b.pitch, angle, scale, c1, c2, c3, c4);
OrientedQuadRenderer.addOrientedQuad(mesh, b.pos, b.yaw, b.pitch, angle, scale, c1, c2, c3, c4);
}
bubbles.removeIf(b -> b.life.passedMs(lifeTime.get() * 50L));
@ -553,6 +546,10 @@ public class HitEffect extends Module {
return effectMode.get() == EffectMode.WAVE;
}
private boolean isImpactMode() {
return effectMode.get() == EffectMode.IMPACT;
}
private boolean usesSecondaryColor() {
return AnimatedRenderColors.usesSecondary(animatedColorMode());
}
@ -568,16 +565,7 @@ public class HitEffect extends Module {
}
private AnimatedRenderColors.Mode animatedColorMode() {
return switch (colorMode.get()) {
case "Rainbow" -> AnimatedRenderColors.Mode.RAINBOW;
case "LightRainbow" -> AnimatedRenderColors.Mode.LIGHT_RAINBOW;
case "Sky" -> AnimatedRenderColors.Mode.SKY;
case "Fade" -> AnimatedRenderColors.Mode.FADE;
case "DoubleColor" -> AnimatedRenderColors.Mode.DOUBLE_COLOR;
case "Analogous" -> AnimatedRenderColors.Mode.ANALOGOUS;
case "Theme" -> AnimatedRenderColors.Mode.THEME;
default -> AnimatedRenderColors.Mode.STATIC;
};
return AnimatedColorModeOptions.parse(colorMode.get());
}
private boolean isCritical(Player attacker, float cooldown) {
@ -607,40 +595,10 @@ public class HitEffect extends Module {
}
}
private Vec3 getRtxPoint(Entity target, float yaw, float pitch, double distance) {
Minecraft mc = Minecraft.getInstance();
if (mc.player == null) return null;
Vec3 start = mc.player.position().add(0, mc.player.getEyeHeight(mc.player.getPose()), 0);
Vec3 rot = getRotationVector(pitch, yaw);
Vec3 end = start.add(rot.scale(distance));
AABB box = mc.player.getBoundingBox().expandTowards(rot.scale(distance)).inflate(1.0, 1.0, 1.0);
EntityHitResult hit = ProjectileUtil.getEntityHitResult(
mc.player,
start,
end,
box,
e -> !e.isSpectator() && e.isPickable() && e == target,
distance * distance
);
return hit != null ? hit.getLocation() : null;
}
private Vec3 getRotationVector(float pitch, float yaw) {
float yawRad = -yaw * ((float) Math.PI / 180F);
float pitchRad = pitch * ((float) Math.PI / 180F);
float cosYaw = Mth.cos(yawRad);
float sinYaw = Mth.sin(yawRad);
float cosPitch = Mth.cos(pitchRad);
float sinPitch = Mth.sin(pitchRad);
return new Vec3(sinYaw * cosPitch, -sinPitch, cosYaw * cosPitch);
}
public enum EffectMode {
BUBBLES,
WAVE
WAVE,
IMPACT
}
private record ParticleTexture(Identifier texture, int index) {
@ -772,4 +730,96 @@ public class HitEffect extends Module {
return null;
}
}
/**
* Кольцевая волна + вертикальная "стена" свечения в точке удара — реализация
* идеи ImpactRing (JumpWaveRenderer/JumpGlowRenderer, экранный SSR-варп в
* референсе): у нас проще, обычными аддитивными квадами через MeshBuilder,
* без захвата сцены — консистентно с остальными эффектами файла.
*/
private class ImpactRing {
private final Vec3 center;
private final long startTime;
private ImpactRing(Vec3 center, long startTime) {
this.center = center;
this.startTime = startTime;
}
private boolean isExpired() {
return GameClock.millis() - startTime > Math.max(1L, impactDurationMs.get());
}
private void render(Renderer3D renderer) {
long durationMs = Math.max(1L, impactDurationMs.get());
long elapsed = GameClock.millis() - startTime;
float progress = Mth.clamp((float) elapsed / durationMs, 0f, 1f);
float fade = (float) Math.pow(1.0 - progress, 1.4);
if (fade <= 0.01f) return;
float maxRadius = Math.max(0.1f, impactRadius.get());
float radius = maxRadius * (0.15f + 0.85f * progress);
int color = getColorArgb((int) (progress * 360));
MeshBuilder ring = renderer.batch(LoVisualRenderPipelines.WORLD_COLORED_ADDITIVE, Renderer3D.DepthMode.MAIN);
if (ring != null) {
renderRing(ring, radius, fade, color);
}
MeshBuilder glow = renderer.batch(LoVisualRenderPipelines.WORLD_COLORED_ADDITIVE_DEPTH, Renderer3D.DepthMode.PRE_DEPTH);
if (glow != null) {
renderGlowWall(glow, radius, fade * 0.6f, color);
}
}
private void renderRing(MeshBuilder mesh, float radius, float fade, int color) {
float inner = radius * 0.72f;
for (int i = 0; i < IMPACT_RING_SEGMENTS; i++) {
double a0 = (Math.PI * 2.0 / IMPACT_RING_SEGMENTS) * i;
double a1 = (Math.PI * 2.0 / IMPACT_RING_SEGMENTS) * (i + 1);
float ix0 = (float) (Math.cos(a0) * inner);
float iz0 = (float) (Math.sin(a0) * inner);
float ix1 = (float) (Math.cos(a1) * inner);
float iz1 = (float) (Math.sin(a1) * inner);
float ox0 = (float) (Math.cos(a0) * radius);
float oz0 = (float) (Math.sin(a0) * radius);
float ox1 = (float) (Math.cos(a1) * radius);
float oz1 = (float) (Math.sin(a1) * radius);
int edgeColor = applyOpacity(color, fade);
int fadeColor = applyOpacity(color, 0f);
mesh.ensureQuadCapacity();
int i1 = mesh.vec3(center.x + ix0, center.y + 0.02, center.z + iz0).color(new RenderColor(fadeColor)).next();
int i2 = mesh.vec3(center.x + ix1, center.y + 0.02, center.z + iz1).color(new RenderColor(fadeColor)).next();
int i3 = mesh.vec3(center.x + ox1, center.y + 0.02, center.z + oz1).color(new RenderColor(edgeColor)).next();
int i4 = mesh.vec3(center.x + ox0, center.y + 0.02, center.z + oz0).color(new RenderColor(edgeColor)).next();
mesh.quad(i1, i2, i3, i4);
}
}
private void renderGlowWall(MeshBuilder mesh, float radius, float fade, int color) {
float height = impactGlowHeight.get() * (1.0f - fade * 0.3f);
for (int i = 0; i < IMPACT_RING_SEGMENTS; i++) {
double a0 = (Math.PI * 2.0 / IMPACT_RING_SEGMENTS) * i;
double a1 = (Math.PI * 2.0 / IMPACT_RING_SEGMENTS) * (i + 1);
float x0 = (float) (Math.cos(a0) * radius);
float z0 = (float) (Math.sin(a0) * radius);
float x1 = (float) (Math.cos(a1) * radius);
float z1 = (float) (Math.sin(a1) * radius);
int baseColor = applyOpacity(color, fade);
int topColor = applyOpacity(color, 0f);
mesh.ensureQuadCapacity();
int i1 = mesh.vec3(center.x + x0, center.y, center.z + z0).color(new RenderColor(baseColor)).next();
int i2 = mesh.vec3(center.x + x1, center.y, center.z + z1).color(new RenderColor(baseColor)).next();
int i3 = mesh.vec3(center.x + x1, center.y + height, center.z + z1).color(new RenderColor(topColor)).next();
int i4 = mesh.vec3(center.x + x0, center.y + height, center.z + z0).color(new RenderColor(topColor)).next();
mesh.quad(i1, i2, i3, i4);
}
}
}
}

View file

@ -46,6 +46,15 @@ public final class HoldMyItems extends Module {
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 final BooleanValue gripEnabled = bool("grip_enabled", true);
public final NumberValue<Float> gripExtend = visibleWhen(num("grip_extend", 0.3f, -0.3f, 1.2f), gripEnabled::get);
public final NumberValue<Float> gripSlide = visibleWhen(num("grip_slide", 0.1f, -0.2f, 0.4f), gripEnabled::get);
public final NumberValue<Float> gripOut = visibleWhen(num("grip_out", 0.05f, -0.25f, 0.25f), gripEnabled::get);
public final NumberValue<Float> gripForward = visibleWhen(num("grip_forward", 0.14f, -0.2f, 0.5f), gripEnabled::get);
public final NumberValue<Float> gripTilt = visibleWhen(num("grip_tilt", 0.0f, -90.0f, 90.0f), gripEnabled::get);
public final NumberValue<Float> gripTurn = visibleWhen(num("grip_turn", 0.0f, -90.0f, 90.0f), gripEnabled::get);
public final NumberValue<Float> gripRoll = visibleWhen(num("grip_roll", 0.0f, -180.0f, 180.0f), gripEnabled::get);
public HandAnimState handAnim() {
return handAnim;
}

View file

@ -7,6 +7,7 @@
package dev.loki.lovisual.features.module.modules.visuals.optimization;
import net.minecraft.core.particles.ParticleTypes;
import dev.loki.lovisual.config.values.primitive.BooleanValue;
import dev.loki.lovisual.config.values.primitive.NumberValue;
import dev.loki.lovisual.events.impl.render.ParticleSpawnEvent;
@ -65,6 +66,11 @@ public final class FpsOptimizer extends Module {
@EventHandler
private void onParticleSpawn(ParticleSpawnEvent event) {
if (!isEnabled() || !disableParticles.get()) return;
// Firework particles are created by FireworkParticles$Starter and assume ParticleEngine.createParticle never returns null.
// Cancelling them returns null and causes NPE at Starter.createParticle -> setTrail().
// Guard is also present in FireworkParticlesStarterMixin, but exempt here to preserve fireworks even with optimizer enabled.
var type = event.options().getType();
if (type == ParticleTypes.FIREWORK || type == ParticleTypes.FLASH) return;
event.cancel();
}
}

View file

@ -0,0 +1,293 @@
/*
* 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.particles;
import dev.loki.lovisual.config.values.color.RGBAColorValue;
import dev.loki.lovisual.config.values.mode.EnumValue;
import dev.loki.lovisual.config.values.mode.ModeValue;
import dev.loki.lovisual.config.values.primitive.NumberValue;
import dev.loki.lovisual.features.module.core.Module;
import dev.loki.lovisual.features.module.core.ModuleCategory;
import dev.loki.lovisual.features.module.core.ModuleInfo;
import dev.loki.lovisual.features.module.phase.WorldPhase;
import dev.loki.lovisual.render.engine.RenderState;
import dev.loki.lovisual.render.engine.animation.AnimatedColorModeOptions;
import dev.loki.lovisual.render.engine.animation.AnimatedRenderColors;
import dev.loki.lovisual.render.engine.color.RenderColor;
import dev.loki.lovisual.render.engine.pipeline.LoVisualRenderPipelines;
import dev.loki.lovisual.render.engine.renderer.Renderer3D;
import dev.loki.lovisual.render.engine.texture.TextureStorage;
import dev.loki.lovisual.render.engine.uniform.MeshBuilder;
import net.minecraft.client.Minecraft;
import net.minecraft.util.Mth;
import net.minecraft.world.phys.Vec3;
import org.joml.Quaternionf;
import org.joml.Vector3f;
import java.util.ArrayList;
import java.util.List;
import java.util.Random;
/**
* Атмосферные частицы мира: мягко плывущие вверх светящиеся мошки ("Ambient")
* или странствующие светлячки со шлейфом ("Firefly", идея — Soup
* particles/types/FireFlyParticle.java, переписана без trail-объектов на
* кольцевой буфер позиций). Референс AmbientParticles.java (775 строк, 19
* визуальных стилей Stars/Hearts/Bloom/.../Pyramid) не портирован целиком —
* это asset-тяжёлый фича-крип чит-клиента; взята только атмосферная идея
* (мошки + светлячки), под наши текстуры и правила.
*/
@ModuleInfo(
id = "ambientparticles",
displayName = "AmbientParticles",
category = ModuleCategory.VISUALS,
description = "Атмосферные частицы мира: плывущие мошки или странствующие светлячки со шлейфом"
)
public final class AmbientParticles extends Module {
private static final int TRAIL_CAPACITY = 60;
private static final float MOTE_LIFE_MS = 6000f;
private final EnumValue<ParticleMode> mode = enumMode("mode", ParticleMode.AMBIENT, ParticleMode.values());
private final NumberValue<Integer> count = num("count", 60, 10, 200);
private final NumberValue<Float> scale = num("scale", 1.0f, 0.3f, 3.0f);
private final NumberValue<Float> spawnRadius = num("spawn_radius", 25.0f, 10.0f, 40.0f);
private final NumberValue<Float> spawnHeight = num("spawn_height", 6.0f, 2.0f, 20.0f);
private final NumberValue<Integer> trailLength = visibleWhen(num("trail_length", 20, 5, 60), this::isFirefly);
private final ModeValue colorMode = modeSetting("colorMode", "color_mode", "Static", AnimatedColorModeOptions.OPTIONS);
private final NumberValue<Integer> colorSpeed = num("color_speed", 18, 2, 54);
private final RGBAColorValue colorValue = color("color", "#FFFFE6A0");
private final RGBAColorValue colorValue2 = visibleWhen(color("color2", "#FF89C4FF"), this::usesSecondaryColor);
private final List<Mote> motes = new ArrayList<>();
private final List<Firefly> fireflies = new ArrayList<>();
private final Random random = new Random();
private boolean isFirefly() {
return mode.get() == ParticleMode.FIREFLY;
}
private boolean usesSecondaryColor() {
return AnimatedRenderColors.usesSecondary(AnimatedColorModeOptions.parse(colorMode.get()));
}
private int resolveColorArgb(int phase) {
return AnimatedRenderColors.resolve(
AnimatedColorModeOptions.parse(colorMode.get()), colorSpeed.get(), phase,
colorValue.getArgb(), colorValue2.getArgb());
}
@Override
public void onEnable() {
motes.clear();
fireflies.clear();
}
@Override
public void onDisable() {
motes.clear();
fireflies.clear();
}
@Override
public WorldPhase getWorldPhase() {
return WorldPhase.END_MAIN;
}
@Override
public void onRenderWorldEngine(Renderer3D renderer, Renderer3D depthRenderer, float tickDelta) {
Minecraft mc = Minecraft.getInstance();
if (!isEnabled() || mc.player == null || mc.level == null) return;
Vec3 center = mc.player.position();
if (isFirefly()) {
fireflies.removeIf(f -> f.age <= 0);
spawnFireflies(center);
for (Firefly f : fireflies) f.update(random);
renderFireflies(renderer);
} else {
motes.removeIf(m -> System.currentTimeMillis() - m.spawnMs > MOTE_LIFE_MS);
spawnMotes(center);
for (Mote m : motes) m.update();
renderMotes(renderer);
}
}
private void spawnMotes(Vec3 center) {
int target = count.get();
while (motes.size() < target) {
double angle = random.nextDouble() * Math.PI * 2.0;
double dist = Math.sqrt(random.nextDouble()) * spawnRadius.get();
double x = center.x + Math.cos(angle) * dist;
double z = center.z + Math.sin(angle) * dist;
double y = center.y + random.nextDouble() * spawnHeight.get();
motes.add(new Mote(x, y, z));
}
}
private void spawnFireflies(Vec3 center) {
int target = count.get() / 2;
while (fireflies.size() < target) {
double angle = random.nextDouble() * Math.PI * 2.0;
double dist = Math.sqrt(random.nextDouble()) * spawnRadius.get();
double x = center.x + Math.cos(angle) * dist;
double z = center.z + Math.sin(angle) * dist;
double y = center.y + random.nextDouble() * spawnHeight.get();
fireflies.add(new Firefly(x, y, z, trailLength.get(), random));
}
}
private void renderMotes(Renderer3D renderer) {
if (motes.isEmpty()) return;
MeshBuilder mesh = renderer.batchTextured(LoVisualRenderPipelines.WORLD_TEXTURED_ADDITIVE, TextureStorage.FIRE_FLY, Renderer3D.DepthMode.MAIN);
if (mesh == null) return;
Quaternionf camRot = RenderState.cameraRotation;
for (Mote m : motes) {
float progress = (float) ((System.currentTimeMillis() - m.spawnMs) / MOTE_LIFE_MS);
float fade = (float) Math.sin(Math.min(1.0, progress) * Math.PI);
if (fade <= 0.01f) continue;
int argb = applyOpacity(resolveColorArgb((int) (m.x * 10)), fade);
addBillboard(mesh, m.x, m.y, m.z, scale.get() * 0.35f, camRot, argb);
}
}
private void renderFireflies(Renderer3D renderer) {
if (fireflies.isEmpty()) return;
MeshBuilder mesh = renderer.batchTextured(LoVisualRenderPipelines.WORLD_TEXTURED_ADDITIVE, TextureStorage.FIRE_FLY, Renderer3D.DepthMode.MAIN);
if (mesh == null) return;
Quaternionf camRot = RenderState.cameraRotation;
for (Firefly f : fireflies) {
float bodyFade = Mth.clamp(f.age / (float) f.maxAge, 0f, 1f);
int size = f.trailSize();
for (int i = 0; i < size; i++) {
float trailFade = (i + 1f) / size;
float alpha = bodyFade * trailFade;
if (alpha <= 0.02f) continue;
int argb = applyOpacity(resolveColorArgb(f.hashCode() % 360), alpha);
double[] p = f.trailPoint(i);
addBillboard(mesh, p[0], p[1], p[2], scale.get() * (0.12f + 0.08f * trailFade), camRot, argb);
}
}
}
private static void addBillboard(MeshBuilder mesh, double cx, double cy, double cz,
float size, Quaternionf camRot, int argb) {
Vector3f right = new Vector3f(1, 0, 0).rotate(camRot).mul(size);
Vector3f up = new Vector3f(0, 1, 0).rotate(camRot).mul(size);
mesh.ensureQuadCapacity();
int i1 = mesh.vec3(cx - right.x() - up.x(), cy - right.y() - up.y(), cz - right.z() - up.z()).vec2(0, 1).color(new RenderColor(argb)).next();
int i2 = mesh.vec3(cx + right.x() - up.x(), cy + right.y() - up.y(), cz + right.z() - up.z()).vec2(1, 1).color(new RenderColor(argb)).next();
int i3 = mesh.vec3(cx + right.x() + up.x(), cy + right.y() + up.y(), cz + right.z() + up.z()).vec2(1, 0).color(new RenderColor(argb)).next();
int i4 = mesh.vec3(cx - right.x() + up.x(), cy - right.y() + up.y(), cz - right.z() + up.z()).vec2(0, 0).color(new RenderColor(argb)).next();
mesh.quad(i1, i2, i3, i4);
}
private static int applyOpacity(int argb, float opacity) {
int a = (argb >>> 24) & 0xFF;
int na = Mth.clamp((int) (a * Mth.clamp(opacity, 0f, 1f)), 0, 255);
return (na << 24) | (argb & 0x00FFFFFF);
}
public enum ParticleMode implements EnumValue.IdProvider {
AMBIENT("ambient"),
FIREFLY("firefly");
private final String id;
ParticleMode(String id) {
this.id = id;
}
@Override
public String id() {
return id;
}
}
/** Мошка: плывёт вверх, слегка виляя, живёт {@link #MOTE_LIFE_MS} и гаснет. */
private static final class Mote {
double x, y, z;
final long spawnMs;
final double phase;
Mote(double x, double y, double z) {
this.x = x;
this.y = y;
this.z = z;
this.spawnMs = System.currentTimeMillis();
this.phase = Math.random() * Math.PI * 2.0;
}
void update() {
double t = (System.currentTimeMillis() - spawnMs) / 1000.0;
y += 0.004;
x += Math.sin(t * 0.6 + phase) * 0.003;
z += Math.cos(t * 0.5 + phase) * 0.003;
}
}
/** Светлячок: случайное блуждание с затуханием скорости и кольцевым буфером шлейфа. */
private static final class Firefly {
double x, y, z;
double vx, vy, vz;
int age;
final int maxAge;
private final double[] trailX;
private final double[] trailY;
private final double[] trailZ;
private int trailCount;
private int trailHead;
Firefly(double x, double y, double z, int trailLength, Random random) {
this.x = x;
this.y = y;
this.z = z;
this.vx = (random.nextDouble() * 2.0 - 1.0) * 0.02;
this.vy = (random.nextDouble() * 2.0 - 1.0) * 0.01;
this.vz = (random.nextDouble() * 2.0 - 1.0) * 0.02;
this.age = 100 + random.nextInt(200);
this.maxAge = this.age;
int cap = Mth.clamp(trailLength, 1, TRAIL_CAPACITY);
this.trailX = new double[cap];
this.trailY = new double[cap];
this.trailZ = new double[cap];
}
void update(Random random) {
age--;
x += vx;
y += vy;
z += vz;
vx += (random.nextDouble() * 2.0 - 1.0) * 0.004;
vy += (random.nextDouble() * 2.0 - 1.0) * 0.002;
vz += (random.nextDouble() * 2.0 - 1.0) * 0.004;
vx *= 0.97;
vy *= 0.97;
vz *= 0.97;
trailX[trailHead] = x;
trailY[trailHead] = y;
trailZ[trailHead] = z;
trailHead = (trailHead + 1) % trailX.length;
if (trailCount < trailX.length) trailCount++;
}
int trailSize() {
return trailCount;
}
double[] trailPoint(int ageIndex) {
int idx = Math.floorMod(trailHead - 1 - ageIndex, trailX.length);
return new double[]{trailX[idx], trailY[idx], trailZ[idx]};
}
}
}

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.module.modules.visuals.scene;
import dev.loki.lovisual.config.values.color.RGBAColorValue;
import dev.loki.lovisual.config.values.mode.ModeValue;
import dev.loki.lovisual.config.values.primitive.BooleanValue;
import dev.loki.lovisual.config.values.primitive.NumberValue;
import dev.loki.lovisual.features.module.core.Module;
import dev.loki.lovisual.features.module.core.ModuleCategory;
import dev.loki.lovisual.features.module.core.ModuleInfo;
import dev.loki.lovisual.features.module.phase.WorldPhase;
import dev.loki.lovisual.render.engine.RenderState;
import dev.loki.lovisual.render.engine.animation.AnimatedColorModeOptions;
import dev.loki.lovisual.render.engine.animation.AnimatedRenderColors;
import dev.loki.lovisual.render.engine.color.RenderColor;
import dev.loki.lovisual.render.engine.pipeline.LoVisualRenderPipelines;
import dev.loki.lovisual.render.engine.renderer.Renderer3D;
import dev.loki.lovisual.render.engine.texture.TextureStorage;
import dev.loki.lovisual.render.engine.uniform.MeshBuilder;
import dev.loki.lovisual.util.time.GameClock;
import net.minecraft.util.Mth;
import net.minecraft.world.phys.Vec3;
import org.joml.Quaternionf;
import org.joml.Vector3f;
import java.util.ArrayList;
import java.util.List;
import java.util.Random;
/**
* Кастомизация рендера энд-кристаллов: цвет стекла/куба (см.
* {@code EndCrystalRendererMixin}) и осколки-вспышка на месте разрушения
* (кристалл исчезает из мира мгновенно — ваниль не даёт доиграть анимацию).
* Референс: Soup other/EndCrystal.java — идея взята (тонировка модели,
* персистентный эффект поломки), код не копировался. Гранулярное разбиение
* на "Outer Glass"/"Inner Glass" референса условно: ванильная модель зовёт
* один и тот же {@code ModelPart glass} дважды подряд (внешний и вложенный
* повёрнутый куб) — мы различаем эти два вызова по порядку в миксине.
*/
@ModuleInfo(
id = "endcrystal",
displayName = "EndCrystal",
category = ModuleCategory.VISUALS,
description = "Цвет модели энд-кристалла и эффект осколков при разрушении"
)
public final class EndCrystal extends Module {
private static final int SHARD_COUNT = 14;
private static final float SHARD_SPEED = 0.16f;
private final ModeValue colorMode = modeSetting("colorMode", "color_mode", "Sync", "Sync", "Custom");
private final NumberValue<Integer> colorSpeed = num("color_speed", 18, 2, 54);
private final RGBAColorValue colorValue = visibleWhen(color("color", "#FF7FE0D8"), this::isCustomColor);
private final RGBAColorValue colorValue2 = visibleWhen(color("color2", "#FFB57FE0"), () -> isCustomColor() && usesSecondaryColor());
private final ModeValue colorAnimation = visibleWhen(
modeSetting("colorAnimation", "color_animation", "Static", AnimatedColorModeOptions.OPTIONS), this::isCustomColor);
public final BooleanValue outerGlassVisible = bool("outer_glass", true);
public final BooleanValue innerGlassVisible = bool("inner_glass", true);
public final BooleanValue cubeVisible = bool("cube", true);
private final BooleanValue shatterEnabled = bool("shatter_enabled", true);
private final NumberValue<Integer> shatterDurationMs = visibleWhen(num("shatter_duration_ms", 600, 200, 2000), shatterEnabled::get);
private final List<Shard> shards = new ArrayList<>();
private final Random random = new Random();
private boolean isCustomColor() {
return "Custom".equals(colorMode.get());
}
private boolean usesSecondaryColor() {
return AnimatedRenderColors.usesSecondary(AnimatedColorModeOptions.parse(colorAnimation.get()));
}
/** @return тонировка модели (ARGB), либо -1 (без тонировки — ванильный белый), если Sync. */
public int resolveTintArgb() {
if (!isCustomColor()) return -1;
return AnimatedRenderColors.resolve(
AnimatedColorModeOptions.parse(colorAnimation.get()), colorSpeed.get(), 0,
colorValue.getArgb(), colorValue2.getArgb());
}
/** Вызывается миксином при разрушении кристалла (RemovalReason#shouldDestroy). */
public void onCrystalDestroyed(Vec3 pos) {
if (!isEnabled() || !shatterEnabled.get()) return;
int color = resolveTintArgb();
if (color == -1) color = 0xFF9CF0E8;
long now = GameClock.millis();
for (int i = 0; i < SHARD_COUNT; i++) {
double yaw = random.nextDouble() * Math.PI * 2.0;
double pitch = random.nextDouble() * Math.PI - Math.PI / 2.0;
double speed = SHARD_SPEED * (0.5 + random.nextDouble());
Vec3 velocity = new Vec3(
Math.cos(yaw) * Math.cos(pitch),
Math.sin(pitch),
Math.sin(yaw) * Math.cos(pitch)
).scale(speed);
shards.add(new Shard(pos, velocity, now, color));
}
}
@Override
public void onDisable() {
shards.clear();
}
@Override
public WorldPhase getWorldPhase() {
return WorldPhase.END_MAIN;
}
@Override
public void onRenderWorldEngine(Renderer3D renderer, Renderer3D depthRenderer, float tickDelta) {
if (!isEnabled() || shards.isEmpty()) return;
long durationMs = Math.max(1, shatterDurationMs.get());
long now = GameClock.millis();
shards.removeIf(s -> now - s.startMs > durationMs);
if (shards.isEmpty()) return;
MeshBuilder mesh = renderer.batchTextured(LoVisualRenderPipelines.WORLD_TEXTURED_ADDITIVE, TextureStorage.PARTICLE_STARS, Renderer3D.DepthMode.MAIN);
if (mesh == null) return;
Quaternionf camRot = RenderState.cameraRotation;
for (Shard s : shards) {
float progress = Mth.clamp((now - s.startMs) / (float) durationMs, 0f, 1f);
float fade = 1.0f - progress;
if (fade <= 0.02f) continue;
double dt = (now - s.startMs) / 1000.0;
double x = s.origin.x + s.velocity.x * dt;
double y = s.origin.y + s.velocity.y * dt - 0.35 * dt * dt;
double z = s.origin.z + s.velocity.z * dt;
float size = 0.22f * fade;
int argb = applyOpacity(s.colorArgb, fade);
addBillboard(mesh, x, y, z, size, camRot, argb);
}
}
private static void addBillboard(MeshBuilder mesh, double cx, double cy, double cz,
float size, Quaternionf camRot, int argb) {
Vector3f right = new Vector3f(1, 0, 0).rotate(camRot).mul(size);
Vector3f up = new Vector3f(0, 1, 0).rotate(camRot).mul(size);
mesh.ensureQuadCapacity();
int i1 = mesh.vec3(cx - right.x() - up.x(), cy - right.y() - up.y(), cz - right.z() - up.z()).vec2(0, 1).color(new RenderColor(argb)).next();
int i2 = mesh.vec3(cx + right.x() - up.x(), cy + right.y() - up.y(), cz + right.z() - up.z()).vec2(1, 1).color(new RenderColor(argb)).next();
int i3 = mesh.vec3(cx + right.x() + up.x(), cy + right.y() + up.y(), cz + right.z() + up.z()).vec2(1, 0).color(new RenderColor(argb)).next();
int i4 = mesh.vec3(cx - right.x() + up.x(), cy - right.y() + up.y(), cz - right.z() + up.z()).vec2(0, 0).color(new RenderColor(argb)).next();
mesh.quad(i1, i2, i3, i4);
}
private static int applyOpacity(int argb, float opacity) {
int a = (argb >>> 24) & 0xFF;
int na = Mth.clamp((int) (a * Mth.clamp(opacity, 0f, 1f)), 0, 255);
return (na << 24) | (argb & 0x00FFFFFF);
}
private record Shard(Vec3 origin, Vec3 velocity, long startMs, int colorArgb) {
}
}

View file

@ -12,14 +12,16 @@ import dev.loki.lovisual.config.values.primitive.NumberValue;
import dev.loki.lovisual.features.module.core.Module;
import dev.loki.lovisual.features.module.core.ModuleCategory;
import dev.loki.lovisual.features.module.core.ModuleInfo;
import dev.loki.lovisual.features.relations.PlayerRelations;
import dev.loki.lovisual.render.engine.renderer.Renderer3D;
import dev.loki.lovisual.render.engine.pipeline.LoVisualRenderPipelines;
import dev.loki.lovisual.render.engine.texture.TextureStorage;
import dev.loki.lovisual.render.engine.uniform.MeshBuilder;
import dev.loki.lovisual.util.target.TargetManager;
import net.minecraft.client.Minecraft;
import net.minecraft.client.player.AbstractClientPlayer;
import net.minecraft.resources.Identifier;
import net.minecraft.world.entity.LivingEntity;
import net.minecraft.world.entity.player.Player;
import net.minecraft.world.phys.Vec3;
@ModuleInfo(
@ -64,68 +66,6 @@ public class TrollfaceMask extends Module {
if (mc.player == null || mc.level == null) return;
String targetMode = target.get();
boolean selfMode = "Себя".equals(targetMode);
LivingEntity currentTarget = TargetManager.getTarget();
boolean hasCrosshairTarget = currentTarget != null && currentTarget.isAlive();
if (!selfMode && !hasCrosshairTarget) return;
LivingEntity renderTarget;
boolean shouldRender;
switch (targetMode) {
case "Себя" -> {
renderTarget = mc.player;
shouldRender = true;
}
case "Друзей" -> {
if (!hasCrosshairTarget) { shouldRender = false; renderTarget = null; break; }
renderTarget = currentTarget;
shouldRender = true;
}
case "Всех" -> {
if (!hasCrosshairTarget) { shouldRender = false; renderTarget = null; break; }
renderTarget = currentTarget;
shouldRender = true;
}
default -> {
renderTarget = mc.player;
shouldRender = true;
}
}
if (!shouldRender || renderTarget == null) return;
Vec3 camPos = mc.gameRenderer.mainCamera().position();
double headX = renderTarget.getX();
double headY = renderTarget.getY() + renderTarget.getBbHeight() * 0.72;
double headZ = renderTarget.getZ();
Vec3 headPos = new Vec3(headX, headY, headZ);
Vec3 viewDir = camPos.subtract(headPos).normalize();
if (viewDir.lengthSqr() < 1.0E-8) return;
Vec3 upVec = new Vec3(0, 1, 0);
Vec3 right = viewDir.cross(upVec).normalize();
if (right.lengthSqr() < 1.0E-8) {
right = new Vec3(1, 0, 0);
}
Vec3 actualUp = right.cross(viewDir).normalize();
float s = size.get();
float alpha = this.alpha.get();
int color = 0xFFFFFFFF;
int r = (color >> 16) & 0xFF;
int g = (color >> 8) & 0xFF;
int b = color & 0xFF;
int a = (int) (alpha * 255);
Vec3 p1 = headPos.add(right.scale(-s)).add(actualUp.scale(-s * 0.75));
Vec3 p2 = headPos.add(right.scale(s)).add(actualUp.scale(-s * 0.75));
Vec3 p3 = headPos.add(right.scale(s)).add(actualUp.scale(s * 0.75));
Vec3 p4 = headPos.add(right.scale(-s)).add(actualUp.scale(s * 0.75));
int textureIndex = maskTypeIndex(maskType.get());
Identifier texId = switch (textureIndex) {
case 0 -> TextureStorage.TROLLFACE_1;
@ -137,14 +77,66 @@ public class TrollfaceMask extends Module {
default -> TextureStorage.TROLLFACE_1;
};
for (Player player : mc.level.players()) {
if (!shouldMask(player, mc.player, targetMode)) continue;
renderMask(renderer, player, tickDelta, texId);
}
}
private boolean shouldMask(Player candidate, Player self, String targetMode) {
if (!candidate.isAlive()) return false;
return switch (targetMode) {
case "Себя" -> candidate == self;
case "Друзей" -> candidate != self
&& candidate instanceof AbstractClientPlayer
&& PlayerRelations.get().isFriend(candidate.getGameProfile().name());
case "Всех" -> true;
default -> candidate == self;
};
}
// Маска рисуется в плоскости лица цели (yaw/pitch головы), а не биллбордом к камере:
// биллборд к камере вырождается для себя от первого лица (камера почти совпадает с головой
// по вертикали -> вектор view~(0,1,0) -> right=view×up=0 -> плоскость ложится горизонтально
// и не видна). Ориентация по взгляду цели работает одинаково для себя/друзей/всех.
private void renderMask(Renderer3D renderer, LivingEntity renderTarget, float tickDelta, Identifier texId) {
double headX = renderTarget.getX();
double headY = renderTarget.getY() + renderTarget.getBbHeight() * 0.72;
double headZ = renderTarget.getZ();
Vec3 headPos = new Vec3(headX, headY, headZ);
Vec3 forward = renderTarget.getViewVector(tickDelta);
if (forward.lengthSqr() < 1.0E-8) return;
forward = forward.normalize();
Vec3 worldUp = new Vec3(0, 1, 0);
Vec3 right = forward.cross(worldUp).normalize();
if (right.lengthSqr() < 1.0E-8) {
right = new Vec3(1, 0, 0);
}
Vec3 actualUp = right.cross(forward).normalize();
// Маска висит чуть впереди лица вдоль направления взгляда, иначе z-fight с головой.
headPos = headPos.add(forward.scale(0.35));
float s = size.get();
float alpha = this.alpha.get();
int r = 255, g = 255, b = 255;
int a = (int) (alpha * 255);
Vec3 p1 = headPos.add(right.scale(-s)).add(actualUp.scale(-s * 0.75));
Vec3 p2 = headPos.add(right.scale(s)).add(actualUp.scale(-s * 0.75));
Vec3 p3 = headPos.add(right.scale(s)).add(actualUp.scale(s * 0.75));
Vec3 p4 = headPos.add(right.scale(-s)).add(actualUp.scale(s * 0.75));
MeshBuilder mesh = renderer.batchTextured(LoVisualRenderPipelines.WORLD_TEXTURED, texId, Renderer3D.DepthMode.NONE);
if (mesh == null) return;
mesh.ensureQuadCapacity();
int i1 = mesh.vec3(p1.x, p1.y, p1.z).vec2(0, 1).color(r, g, b, a).next();
int i2 = mesh.vec3(p2.x, p2.y, p2.z).vec2(1, 1).color(r, g, b, a).next();
int i3 = mesh.vec3(p3.x, p3.y, p3.z).vec2(1, 0).color(r, g, b, a).next();
int i4 = mesh.vec3(p4.x, p4.y, p4.z).vec2(0, 0).color(r, g, b, a).next();
int i1 = mesh.vec3(p1.x, p1.y, p1.z).vec2(1, 1).color(r, g, b, a).next();
int i2 = mesh.vec3(p2.x, p2.y, p2.z).vec2(0, 1).color(r, g, b, a).next();
int i3 = mesh.vec3(p3.x, p3.y, p3.z).vec2(0, 0).color(r, g, b, a).next();
int i4 = mesh.vec3(p4.x, p4.y, p4.z).vec2(1, 0).color(r, g, b, a).next();
mesh.quad(i1, i2, i3, i4);
}

View file

@ -448,6 +448,14 @@ public final class Rain extends Module implements PostProcessPass {
};
}
private int binarySteps() {
return switch (reflectionQuality.get()) {
case HIGH -> 6;
case BALANCED -> 4;
case PERFORMANCE -> 3;
};
}
private float rainAmount() {
return Mth.clamp(0.55f + density.get() * presetDensity() * 0.28f, 0.55f, 1.45f);
}
@ -524,7 +532,9 @@ public final class Rain extends Module implements PostProcessPass {
reflectionDistance.get(),
reflectionSteps(),
rainAmount(),
puddleRipples.get()
puddleRipples.get(),
binarySteps(),
0.18f
);
try {

View file

@ -0,0 +1,201 @@
/*
* 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.weather;
import dev.loki.lovisual.config.values.mode.EnumValue;
import dev.loki.lovisual.config.values.primitive.BooleanValue;
import dev.loki.lovisual.config.values.primitive.NumberValue;
import dev.loki.lovisual.features.module.core.Module;
import dev.loki.lovisual.features.module.core.ModuleCategory;
import dev.loki.lovisual.features.module.core.ModuleInfo;
import dev.loki.lovisual.render.engine.RenderState;
import dev.loki.lovisual.render.engine.core.system.LoVisualWorldMatrices;
import dev.loki.lovisual.render.engine.pipeline.LoVisualRenderPipelines;
import dev.loki.lovisual.render.engine.postprocess.post.PostProcessContext;
import dev.loki.lovisual.render.engine.postprocess.post.PostProcessManager;
import dev.loki.lovisual.render.engine.postprocess.post.PostProcessPass;
import dev.loki.lovisual.render.engine.renderer.FullScreenRenderer;
import dev.loki.lovisual.render.engine.uniform.impl.world.liquid.WetSurfaceUniforms;
import com.mojang.blaze3d.textures.GpuTextureView;
import net.minecraft.client.Minecraft;
import net.minecraft.util.Mth;
import org.joml.Matrix4f;
/**
* Мочит все подходящие (смотрящие вверх) поверхности мира по факту реальной погоды сервера —
* в отличие от {@link Rain}, не зависит от партиклов дождя и её собственного тумблера
* {@code wet_ground}: работает как отдельный лёгкий "always-on if raining" эффект. Использует
* тот же SSR-шейдер (wet_surface.frag) и тот же {@link WetSurfaceUniforms}, что и Rain — если
* включены оба модуля одновременно, эффект отражений применится дважды подряд (стек), поэтому
* рекомендуется держать выключенным {@code Rain.wet_ground}, когда включён WetWorld.
*/
@ModuleInfo(
id = "wet_world",
displayName = "Wet World",
category = ModuleCategory.VISUALS,
description = "Мокрые поверхности мира с отражениями по факту дождя на сервере"
)
public final class WetWorld extends Module implements PostProcessPass {
private final Matrix4f projection = new Matrix4f();
private final Matrix4f invView = new Matrix4f();
private boolean depthSupported = true;
private float clock;
private long lastFrameNanos;
{
PostProcessManager.register(this);
}
private final EnumValue<Quality> quality = enumMode("quality", Quality.HIGH, Quality.values());
private final NumberValue<Float> reflectionStrength = num("reflection_strength", 0.75f, 0.1f, 1.5f);
private final NumberValue<Float> darkening = num("darkening", 0.30f, 0.0f, 0.8f);
private final BooleanValue ripples = bool("ripples", true);
private final NumberValue<Float> rippleSpeed = visibleWhen(num("ripple_speed", 1.0f, 0.1f, 3.0f), ripples::get);
private final BooleanValue rainOnly = bool("rain_only", true);
@Override
public void onEnable() {
depthSupported = true;
lastFrameNanos = 0L;
}
private int reflectionSteps() {
return switch (quality.get()) {
case LOW -> 16;
case MEDIUM -> 28;
case HIGH -> 44;
case ULTRA -> 64;
};
}
private int binarySteps() {
return switch (quality.get()) {
case LOW -> 2;
case MEDIUM -> 3;
case HIGH -> 5;
case ULTRA -> 7;
};
}
private float maxDistance() {
return switch (quality.get()) {
case LOW -> 24.0f;
case MEDIUM -> 32.0f;
case HIGH -> 48.0f;
case ULTRA -> 64.0f;
};
}
private float rainGradient(Minecraft mc) {
try {
return mc.level.getRainLevel(1.0f);
} catch (Throwable ignored) {
return mc.level.isRaining() ? 1.0f : 0.0f;
}
}
@Override
public boolean isActive() {
Minecraft mc = Minecraft.getInstance();
if (!isEnabled() || !depthSupported || mc.player == null || mc.level == null) return false;
return !rainOnly.get() || rainGradient(mc) > 0.01f;
}
@Override
public int getPriority() {
return 2;
}
@Override
public Phase getPhase() {
return Phase.PRE_HAND;
}
@Override
public boolean render(GpuTextureView src, GpuTextureView dst, float tickDelta) {
return false;
}
@Override
public boolean render(PostProcessContext context, GpuTextureView src, GpuTextureView dst) {
if (!isActive() || context == null || src == null || dst == null) return false;
Minecraft mc = Minecraft.getInstance();
long now = System.nanoTime();
if (lastFrameNanos == 0L) lastFrameNanos = now;
clock += (float) Mth.clamp((now - lastFrameNanos) / 1_000_000_000.0, 0.0, 0.05);
lastFrameNanos = now;
GpuTextureView mainDepth = context.mainDepth();
boolean hasDepth = mainDepth != null;
if (!hasDepth) mainDepth = src;
Matrix4f proj = LoVisualWorldMatrices.renderProjectionMatrix();
if (proj == null) proj = RenderState.worldProjection;
projection.set(proj);
invView.set(new Matrix4f().rotation(RenderState.cameraRotation));
float rain = rainGradient(mc);
float effectiveStrength = reflectionStrength.get() * (rainOnly.get() ? Mth.clamp(rain, 0.0f, 1.0f) : 1.0f);
WetSurfaceUniforms.update(
projection,
invView,
(float) RenderState.cameraPos.x, (float) RenderState.cameraPos.y, (float) RenderState.cameraPos.z,
context.width() > 0 ? 1.0f / context.width() : 0.0f,
context.height() > 0 ? 1.0f / context.height() : 0.0f,
clock,
hasDepth,
1.0f,
0.95f,
effectiveStrength,
ripples.get() ? 1.0f : 0.0f,
maxDistance(),
reflectionSteps(),
Mth.clamp(0.6f + rain * 0.4f, 0.6f, 1.0f),
ripples.get(),
binarySteps(),
darkening.get()
);
try {
FullScreenRenderer.ensureInit();
FullScreenRenderer.begin("LoVisual WetWorld Pass")
.attachment(dst)
.pipeline(LoVisualRenderPipelines.WET_SURFACE)
.uniform("WetSurface", WetSurfaceUniforms.get())
.sampler("u_Texture", src, PostProcessManager.getSampler())
.sampler("u_MainDepth", mainDepth, PostProcessManager.getSampler())
.end();
} catch (Throwable t) {
depthSupported = false;
return false;
}
return true;
}
public enum Quality implements EnumValue.IdProvider {
LOW("low"),
MEDIUM("medium"),
HIGH("high"),
ULTRA("ultra");
private final String id;
Quality(String id) {
this.id = id;
}
@Override
public String id() {
return id;
}
}
}

View file

@ -0,0 +1,176 @@
/*
* 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.world;
import dev.loki.lovisual.config.values.color.RGBAColorValue;
import dev.loki.lovisual.config.values.primitive.BooleanValue;
import dev.loki.lovisual.config.values.primitive.NumberValue;
import dev.loki.lovisual.features.module.core.Module;
import dev.loki.lovisual.features.module.core.ModuleCategory;
import dev.loki.lovisual.features.module.core.ModuleInfo;
import dev.loki.lovisual.features.module.phase.HudPhase;
import dev.loki.lovisual.render.engine.color.RenderColor;
import dev.loki.lovisual.render.engine.renderer.Renderer2D;
import dev.loki.lovisual.render.engine.text.render.TextRenderer;
import dev.loki.lovisual.render.helpers.clip.ScreenProjection;
import dev.loki.lovisual.util.world.GpsBearing;
import net.minecraft.client.Minecraft;
import net.minecraft.client.gui.GuiGraphicsExtractor;
import net.minecraft.core.BlockPos;
import net.minecraft.world.phys.Vec3;
import java.util.Locale;
/**
* Компас-стрелка к заданной точке мира (луч/маркер направления, референс —
* идея Soup Gps): всегда видимая стрелка на экране, доворачивающаяся к цели
* по мере поворота камеры, плюс маркер прямо на месте, когда точка попадает
* в кадр. Цель ставится командой {@code %gps <x> <y> <z>} / {@code %gps clear}
* (см. {@link dev.loki.lovisual.features.command.impl.world.GpsCommand}).
*/
@ModuleInfo(
id = "gps",
displayName = "Gps",
category = ModuleCategory.VISUALS,
description = "Компас-стрелка и маркер к заданной точке мира"
)
public final class Gps extends Module {
private static final float ARROW_LENGTH_RATIO = 1.6f;
private static final int SCREEN_MARGIN = 24;
private volatile BlockPos target;
private final NumberValue<Float> arrowSize = num("arrow_size", 16.0f, 8.0f, 32.0f);
private final NumberValue<Float> markerSize = num("marker_size", 16.0f, 8.0f, 32.0f);
private final BooleanValue showDistance = bool("show_distance", true);
private final BooleanValue showLocationIcon = bool("show_location_icon", true);
private final RGBAColorValue arrowColor = color("color", "#FF55C8FF");
private final BooleanValue autoDelete = bool("auto_delete", false);
private final NumberValue<Float> autoDeleteDistance = visibleWhen(num("auto_delete_distance", 5.0f, 1.0f, 10.0f), autoDelete::get);
public void setTarget(BlockPos pos) {
this.target = pos;
}
public void clearTarget() {
this.target = null;
}
public BlockPos getTarget() {
return target;
}
@Override
public void onDisable() {
target = null;
}
@Override
public void onTick() {
BlockPos current = target;
if (current == null || !autoDelete.get()) return;
Minecraft mc = Minecraft.getInstance();
if (mc.player == null) return;
double dx = (current.getX() + 0.5) - mc.player.getX();
double dz = (current.getZ() + 0.5) - mc.player.getZ();
if (GpsBearing.horizontalDistance(dx, dz) <= autoDeleteDistance.get()) {
target = null;
}
}
@Override
public HudPhase getHudPhase() {
return HudPhase.FIRST;
}
@Override
public void onRenderHudEngine(Renderer2D renderer, TextRenderer textRenderer, GuiGraphicsExtractor ctx, float tickDelta) {
BlockPos current = target;
Minecraft mc = Minecraft.getInstance();
if (!isEnabled() || current == null || mc.player == null || mc.level == null || mc.getWindow() == null) return;
double dx = (current.getX() + 0.5) - mc.player.getX();
double dz = (current.getZ() + 0.5) - mc.player.getZ();
float bearing = GpsBearing.bearingDegrees(dx, dz);
float playerYaw = mc.player.getViewYRot(tickDelta);
float rotation = GpsBearing.arrowRotationDegrees(bearing, playerYaw);
double distance = GpsBearing.horizontalDistance(dx, dz);
int argb = arrowColor.getArgb();
float centerX = mc.getWindow().getGuiScaledWidth() / 2.0f;
float anchorY = mc.getWindow().getGuiScaledHeight() / 4.0f;
drawArrow(renderer, centerX, anchorY, rotation, argb);
if (showDistance.get()) {
drawDistanceLabel(textRenderer, centerX, anchorY + arrowSize.get() + 4.0f, distance, argb);
}
if (showLocationIcon.get()) {
drawLocationIconIfVisible(renderer, textRenderer, current, tickDelta, distance, argb, mc);
}
}
private void drawArrow(Renderer2D renderer, float anchorX, float anchorY, float rotationDeg, int argb) {
float size = arrowSize.get();
float half = size / 2.0f;
float length = size * ARROW_LENGTH_RATIO / 2.0f;
double rad = Math.toRadians(rotationDeg);
double cos = Math.cos(rad);
double sin = Math.sin(rad);
// Локальные вершины треугольника (нос вверх, назад по X): поворачиваем
// вокруг якоря на rotationDeg и переносим в экранные координаты.
double[] points = new double[]{
rotate(0, -length, cos, sin, anchorX, anchorY, true),
rotate(0, -length, cos, sin, anchorX, anchorY, false),
rotate(-half, half, cos, sin, anchorX, anchorY, true),
rotate(-half, half, cos, sin, anchorX, anchorY, false),
rotate(half, half, cos, sin, anchorX, anchorY, true),
rotate(half, half, cos, sin, anchorX, anchorY, false),
};
renderer.polygon(points, 3, argb);
}
private static double rotate(double lx, double ly, double cos, double sin, double anchorX, double anchorY, boolean wantX) {
double rx = lx * cos - ly * sin;
double ry = lx * sin + ly * cos;
return wantX ? anchorX + rx : anchorY + ry;
}
private void drawDistanceLabel(TextRenderer textRenderer, float centerX, float y, double distance, int argb) {
String text = String.format(Locale.ROOT, "%.1fm", distance);
boolean wasBuilding = textRenderer.isBuilding();
if (!wasBuilding) textRenderer.begin();
double width = textRenderer.getWidth(text, true);
textRenderer.render(text, centerX - width / 2.0, y, new RenderColor(argb), true);
if (!wasBuilding) textRenderer.end();
}
private void drawLocationIconIfVisible(Renderer2D renderer, TextRenderer textRenderer, BlockPos pos,
float tickDelta, double distance, int argb, Minecraft mc) {
Vec3 world = new Vec3(pos.getX() + 0.5, pos.getY() + 0.5, pos.getZ() + 0.5);
Vec3 screen = ScreenProjection.worldToScreen(world, tickDelta);
if (screen == null || screen.z < 0.0 || screen.z > 1.0) return;
int width = mc.getWindow().getGuiScaledWidth();
int height = mc.getWindow().getGuiScaledHeight();
if (screen.x < -SCREEN_MARGIN || screen.x > width + SCREEN_MARGIN
|| screen.y < -SCREEN_MARGIN || screen.y > height + SCREEN_MARGIN) return;
float radius = markerSize.get() / 2.0f;
renderer.circle(screen.x, screen.y, radius, argb);
if (showDistance.get()) {
drawDistanceLabel(textRenderer, (float) screen.x, (float) screen.y + radius + 4.0f, distance, argb);
}
}
}

View file

@ -16,10 +16,12 @@ import net.minecraft.world.entity.Entity;
import net.minecraft.world.entity.player.Player;
import net.minecraft.world.item.Items;
import net.minecraft.world.phys.Vec3;
import org.slf4j.Logger;
import org.spongepowered.asm.mixin.Mixin;
import org.spongepowered.asm.mixin.Unique;
import org.spongepowered.asm.mixin.injection.At;
import org.spongepowered.asm.mixin.injection.Inject;
import org.spongepowered.asm.mixin.injection.Redirect;
import org.spongepowered.asm.mixin.injection.callback.CallbackInfo;
import dev.loki.lovisual.config.values.collection.ItemCooldownRulesValue;
import dev.loki.lovisual.events.Events;
@ -150,6 +152,14 @@ public abstract class ClientPacketListenerMixin {
StaffTracker.INSTANCE.onEntityAppear(player.getUUID(), player.getGameProfile().name());
}
@Redirect(method = "handlePlayerInfoUpdate", at = @At(value = "INVOKE", target = "Lorg/slf4j/Logger;warn(Ljava/lang/String;Ljava/lang/Object;Ljava/lang/Object;)V"))
private void lovisual$silenceUnknownPlayerWarn(Logger instance, String format, Object arg1, Object arg2) {
// Серверы HolyWorld/Funtime шлют UPDATE_DISPLAY_NAME для фейковых записей таба без ADD_PLAYER.
// Ванилла флудит WARN "Ignoring player info update for unknown player" десятки раз в тик.
// Глушим спам -> debug, чтобы лог остался читаемым. Полный update ниже всё равно трекается в lovisual$onPlayerList.
instance.debug(format, arg1, arg2);
}
@Inject(method = "handlePlayerInfoUpdate", at = @At("TAIL"))
private void lovisual$onPlayerList(ClientboundPlayerInfoUpdatePacket packet, CallbackInfo ci) {

View file

@ -34,6 +34,7 @@ import dev.loki.lovisual.features.module.modules.combat.damage.NoFriendDamage;
import dev.loki.lovisual.features.module.modules.combat.pvp.PvpCooldowns;
import dev.loki.lovisual.features.module.modules.misc.sound.HitSounds;
import dev.loki.lovisual.features.module.modules.visuals.camera.Freecam;
import dev.loki.lovisual.features.module.modules.visuals.effects.HitBubbles;
import dev.loki.lovisual.features.module.modules.visuals.effects.HitEffect;
import dev.loki.lovisual.mixins.accessors.world.WorldAccessor;
import dev.loki.lovisual.util.player.inventory.core.InventorySwap;
@ -167,6 +168,11 @@ public class MultiPlayerGameModeMixin {
hitEffect.handleHit(target);
}
HitBubbles hitBubbles = Modules.get(HitBubbles.class);
if (hitBubbles != null) {
hitBubbles.handleHit(target);
}
TargetHud.notifyHit(target);
TargetManager.onAttack(target);

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.mixins.render.entity;
import com.llamalad7.mixinextras.injector.wrapoperation.Operation;
import com.llamalad7.mixinextras.injector.wrapoperation.WrapOperation;
import com.mojang.blaze3d.vertex.PoseStack;
import com.mojang.blaze3d.vertex.VertexConsumer;
import dev.loki.lovisual.features.module.lifecycle.Modules;
import dev.loki.lovisual.features.module.modules.visuals.scene.EndCrystal;
import net.minecraft.client.model.geom.ModelPart;
import net.minecraft.client.renderer.entity.EndCrystalRenderer;
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.Inject;
import org.spongepowered.asm.mixin.injection.callback.CallbackInfo;
/**
* Тонировка стекла/куба энд-кристалла и скрытие отдельных частей модели по
* настройкам {@link EndCrystal}. Ванильный {@code render(...)} зовёт
* {@code this.glass.render(...)} дважды подряд (внешний куб, затем повёрнутый
* вложенный) и {@code this.cube.render(...)} один раз — различаем эти три
* вызова счётчиком {@link #lovisual$glassCallOrdinal}, сбрасываемым в начале
* каждого рендера кристалла.
*/
@Mixin(EndCrystalRenderer.class)
public abstract class EndCrystalRendererMixin {
@Shadow
private ModelPart glass;
@Shadow
private ModelPart cube;
@Unique
private int lovisual$glassCallOrdinal;
@Inject(
method = "render(Lnet/minecraft/world/entity/boss/enderdragon/EndCrystal;FFLcom/mojang/blaze3d/vertex/PoseStack;Lnet/minecraft/client/renderer/MultiBufferSource;I)V",
at = @At("HEAD")
)
private void lovisual$resetOrdinal(Object entity, float entityYaw, float partialTicks, PoseStack poseStack,
Object buffer, int packedLight, CallbackInfo ci) {
lovisual$glassCallOrdinal = 0;
}
@WrapOperation(
method = "render(Lnet/minecraft/world/entity/boss/enderdragon/EndCrystal;FFLcom/mojang/blaze3d/vertex/PoseStack;Lnet/minecraft/client/renderer/MultiBufferSource;I)V",
at = @At(value = "INVOKE", target = "Lnet/minecraft/client/model/geom/ModelPart;render(Lcom/mojang/blaze3d/vertex/PoseStack;Lcom/mojang/blaze3d/vertex/VertexConsumer;II)V")
)
private void lovisual$tintOrHideModelPart(ModelPart instance, PoseStack poseStack, VertexConsumer vertexConsumer,
int light, int overlay, Operation<Void> original) {
EndCrystal module = Modules.get(EndCrystal.class);
if (module == null || !module.isEnabled()) {
original.call(instance, poseStack, vertexConsumer, light, overlay);
return;
}
boolean visible;
if (instance == this.cube) {
visible = module.cubeVisible.get();
} else {
lovisual$glassCallOrdinal++;
visible = lovisual$glassCallOrdinal == 1 ? module.outerGlassVisible.get() : module.innerGlassVisible.get();
}
if (!visible) return;
int tint = module.resolveTintArgb();
if (tint == -1) {
original.call(instance, poseStack, vertexConsumer, light, overlay);
} else {
instance.render(poseStack, vertexConsumer, light, overlay, tint);
}
}
}

View file

@ -63,7 +63,7 @@ public abstract class ItemInHandRendererMixin {
HandAnimState handAnim = holdMyItems.handAnim();
if (handAnim == null) return;
lovisual$handPosePushed = HandDynamics.apply(holdMyItems, handAnim, player, tickDelta, hand, matrices);
lovisual$handPosePushed = HandDynamics.apply(holdMyItems, handAnim, player, tickDelta, hand, item, matrices);
}
@Inject(method = "submitArmWithItem", at = @At("RETURN"))

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.mixins.render.level;
import com.llamalad7.mixinextras.injector.wrapoperation.Operation;
import com.llamalad7.mixinextras.injector.wrapoperation.WrapOperation;
import org.spongepowered.asm.mixin.Mixin;
import org.spongepowered.asm.mixin.injection.At;
/**
* Vanilla bug: FireworkParticles$Starter.createParticle assumes ParticleEngine.createParticle never returns null.
* Our ParticleManagerMixin returns null when a ParticleSpawnEvent is cancelled (e.g. FpsOptimizer.disable_particles).
* This causes NPE: sparkParticle is null -> setTrail() crashes.
* Fix: guard every SparkParticle setter to be null-safe. Use Object for private inner type.
*/
@Mixin(targets = "net.minecraft.client.particle.FireworkParticles$Starter")
public abstract class FireworkParticlesStarterMixin {
@WrapOperation(method = "createParticle", at = @At(value = "INVOKE", target = "Lnet/minecraft/client/particle/FireworkParticles$SparkParticle;setTrail(Z)V"))
private void lovisual$guardSetTrail(Object instance, boolean trail, Operation<Void> original) {
if (instance == null) return;
original.call(instance, trail);
}
@WrapOperation(method = "createParticle", at = @At(value = "INVOKE", target = "Lnet/minecraft/client/particle/FireworkParticles$SparkParticle;setTwinkle(Z)V"))
private void lovisual$guardSetTwinkle(Object instance, boolean twinkle, Operation<Void> original) {
if (instance == null) return;
original.call(instance, twinkle);
}
@WrapOperation(method = "createParticle", at = @At(value = "INVOKE", target = "Lnet/minecraft/client/particle/FireworkParticles$SparkParticle;setAlpha(F)V"))
private void lovisual$guardSetAlpha(Object instance, float alpha, Operation<Void> original) {
if (instance == null) return;
original.call(instance, alpha);
}
@WrapOperation(method = "createParticle", at = @At(value = "INVOKE", target = "Lnet/minecraft/client/particle/FireworkParticles$SparkParticle;setColor(I)V"))
private void lovisual$guardSetColor(Object instance, int color, Operation<Void> original) {
if (instance == null) return;
original.call(instance, color);
}
@WrapOperation(method = "createParticle", at = @At(value = "INVOKE", target = "Lnet/minecraft/client/particle/FireworkParticles$SparkParticle;setFadeColor(I)V"))
private void lovisual$guardSetFadeColor(Object instance, int fadeColor, Operation<Void> original) {
if (instance == null) return;
original.call(instance, fadeColor);
}
}

View file

@ -11,6 +11,8 @@ import net.minecraft.client.multiplayer.ClientLevel;
import net.minecraft.core.particles.ItemParticleOption;
import net.minecraft.core.particles.ParticleOptions;
import net.minecraft.core.particles.ParticleTypes;
import net.minecraft.world.entity.Entity;
import net.minecraft.world.entity.boss.enderdragon.EndCrystal;
import org.spongepowered.asm.mixin.Mixin;
import org.spongepowered.asm.mixin.injection.At;
import org.spongepowered.asm.mixin.injection.Inject;
@ -21,6 +23,20 @@ import dev.loki.lovisual.features.module.modules.visuals.visibility.NoRender;
@Mixin(ClientLevel.class)
public abstract class ClientLevelMixin {
@Inject(method = "removeEntity", at = @At("HEAD"))
private void lovisual$onEndCrystalRemoved(int entityId, Entity.RemovalReason reason, CallbackInfo ci) {
if (!reason.shouldDestroy()) return;
dev.loki.lovisual.features.module.modules.visuals.scene.EndCrystal module =
Modules.get(dev.loki.lovisual.features.module.modules.visuals.scene.EndCrystal.class);
if (module == null || !module.isEnabled()) return;
Entity entity = ((ClientLevel) (Object) this).getEntity(entityId);
if (entity instanceof EndCrystal crystal) {
module.onCrystalDestroyed(crystal.position());
}
}
@Inject(
method = "doAddParticle(Lnet/minecraft/core/particles/ParticleOptions;ZZDDDDDD)V",
at = @At("HEAD"),

View file

@ -0,0 +1,35 @@
/*
* 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.animation;
/**
* Общий набор строковых опций {@link ModeValue}-настройки цвета и парсер в
* {@link AnimatedRenderColors.Mode} — вынесено, чтобы каждый модуль с
* анимированным цветом не дублировал один и тот же switch (см. HitEffect,
* HitBubbles).
*/
public enum AnimatedColorModeOptions {
;
public static final String[] OPTIONS = {
"Static", "Rainbow", "LightRainbow", "Sky", "Fade", "DoubleColor", "Analogous", "Theme"
};
public static AnimatedRenderColors.Mode parse(String value) {
return switch (value == null ? "" : value) {
case "Rainbow" -> AnimatedRenderColors.Mode.RAINBOW;
case "LightRainbow" -> AnimatedRenderColors.Mode.LIGHT_RAINBOW;
case "Sky" -> AnimatedRenderColors.Mode.SKY;
case "Fade" -> AnimatedRenderColors.Mode.FADE;
case "DoubleColor" -> AnimatedRenderColors.Mode.DOUBLE_COLOR;
case "Analogous" -> AnimatedRenderColors.Mode.ANALOGOUS;
case "Theme" -> AnimatedRenderColors.Mode.THEME;
default -> AnimatedRenderColors.Mode.STATIC;
};
}
}

View file

@ -580,8 +580,17 @@ public final class MeshBuilder implements AutoCloseable {
+ indexBytes + ", capacity=" + indices.capacity() + ", format=" + format);
}
if (primitiveIndicesCount > 0 && indicesCount % primitiveIndicesCount != 0) {
if (DEBUG) {
throw new IllegalStateException(owner + ": MeshBuilder incomplete primitive payload: indices=" + indicesCount
+ ", primitiveLength=" + primitiveIndicesCount + ", format=" + format);
} else {
int trimmed = (indicesCount / primitiveIndicesCount) * primitiveIndicesCount;
// Silence spam: log once per frame via frame stats? Use logger warn with throttling.
// Trim incomplete tail to avoid Render Frame crash (e.g. 176 % 3 == 2 on TRIANGLES).
org.slf4j.LoggerFactory.getLogger(MeshBuilder.class).warn("{}: trimming incomplete primitive payload: indices={} -> {} (primitiveLength={}, format={})", owner, indicesCount, trimmed, primitiveIndicesCount, format);
indicesCount = trimmed;
if (indicesCount == 0) return;
}
}
debugValidateIndices(owner);
}

View file

@ -23,6 +23,7 @@ public enum WetSurfaceUniforms {
.putFloat().putFloat().putFloat().putFloat()
.putFloat().putFloat().putFloat().putFloat()
.putFloat().putFloat().putFloat().putFloat()
.putFloat().putFloat().putFloat().putFloat()
.get();
private static final Data DATA = new Data();
@ -34,7 +35,8 @@ public enum WetSurfaceUniforms {
float camX, float camY, float camZ,
float invW, float invH, float time, boolean mainDepthEnabled,
float wetness, float puddleCoverage, float reflectionStrength, float rippleStrength,
float maxDistance, int reflectionSteps, float rainAmount, boolean ripplesEnabled) {
float maxDistance, int reflectionSteps, float rainAmount, boolean ripplesEnabled,
int binarySteps, float darkeningFactor) {
DATA.projection.set(projection);
DATA.invView.set(invView);
DATA.camX = camX;
@ -52,6 +54,8 @@ public enum WetSurfaceUniforms {
DATA.reflectionSteps = reflectionSteps;
DATA.rainAmount = rainAmount;
DATA.ripplesEnabled = ripplesEnabled ? 1.0f : 0.0f;
DATA.binarySteps = binarySteps;
DATA.darkeningFactor = darkeningFactor;
LoVisualRenderSystem.uniforms().write(UNIFORM_NAME, SIZE, EXPECTED_WRITES_PER_FRAME, DATA);
}
@ -66,6 +70,7 @@ public enum WetSurfaceUniforms {
private float invW, invH, time, mainDepthEnabled;
private float wetness, puddleCoverage, reflectionStrength, rippleStrength;
private float maxDistance, reflectionSteps, rainAmount, ripplesEnabled;
private float binarySteps, darkeningFactor;
@Override
public void write(java.nio.ByteBuffer buffer) {
@ -76,6 +81,7 @@ public enum WetSurfaceUniforms {
b.putFloat(invW).putFloat(invH).putFloat(time).putFloat(mainDepthEnabled);
b.putFloat(wetness).putFloat(puddleCoverage).putFloat(reflectionStrength).putFloat(rippleStrength);
b.putFloat(maxDistance).putFloat(reflectionSteps).putFloat(rainAmount).putFloat(ripplesEnabled);
b.putFloat(binarySteps).putFloat(darkeningFactor).putFloat(0.0f).putFloat(0.0f);
}
@Override

View file

@ -89,12 +89,24 @@ public final class WorldPassCompiler {
stats.skippedEmptyCommand();
return;
}
try {
if (mesh.isBuilding()) mesh.end();
} catch (IllegalStateException | IndexOutOfBoundsException e) {
org.slf4j.LoggerFactory.getLogger(WorldPassCompiler.class).warn("Skipping invalid world mesh: {}", e.getMessage());
stats.skippedEmptyCommand();
return;
}
if (mesh.getIndicesCount() <= 0) {
stats.skippedEmptyCommand();
return;
}
try {
validateWorldMeshAnchor(ctx, mesh, command);
} catch (IllegalStateException e) {
org.slf4j.LoggerFactory.getLogger(WorldPassCompiler.class).warn("Skipping world mesh with invalid anchor: {}", e.getMessage());
stats.skippedEmptyCommand();
return;
}
GpuMeshHandle uploaded = null;
MeshBuilder drawMesh = mesh;

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.render.helpers.particle;
import dev.loki.lovisual.render.engine.color.RenderColor;
import dev.loki.lovisual.render.engine.uniform.MeshBuilder;
import net.minecraft.world.phys.Vec3;
import org.joml.Quaternionf;
import org.joml.Vector3f;
/**
* Плоский квад, ориентированный по yaw/pitch/spin вокруг своей точки — общая
* геометрия для "пузырь"-эффектов (HitEffect.BUBBLES, HitBubbles), вынесена
* из HitEffect чтобы не дублировать между модулями.
*/
public enum OrientedQuadRenderer {
;
public static void addOrientedQuad(MeshBuilder mesh, Vec3 center, float yaw, float pitch,
float spinDeg, float scale, int c1, int c2, int c3, int c4) {
Quaternionf rot = new Quaternionf()
.rotateY((float) Math.toRadians(yaw))
.rotateX((float) Math.toRadians(pitch));
Vector3f forward = new Vector3f(0, 0, 1).rotate(rot);
Vector3f right = new Vector3f(1, 0, 0).rotate(rot);
Vector3f up = new Vector3f(0, 1, 0).rotate(rot);
Quaternionf spin = new Quaternionf().fromAxisAngleRad(forward, (float) Math.toRadians(spinDeg));
right.rotate(spin);
up.rotate(spin);
right.mul(scale / 2f);
up.mul(scale / 2f);
double p1x = center.x - right.x() - up.x();
double p1y = center.y - right.y() - up.y();
double p1z = center.z - right.z() - up.z();
double p2x = center.x + right.x() - up.x();
double p2y = center.y + right.y() - up.y();
double p2z = center.z + right.z() - up.z();
double p3x = center.x + right.x() + up.x();
double p3y = center.y + right.y() + up.y();
double p3z = center.z + right.z() + up.z();
double p4x = center.x - right.x() + up.x();
double p4y = center.y - right.y() + up.y();
double p4z = center.z - right.z() + up.z();
mesh.ensureQuadCapacity();
int i1 = mesh.vec3(p1x, p1y, p1z).vec2(0, 1).color(new RenderColor(c1)).next();
int i2 = mesh.vec3(p2x, p2y, p2z).vec2(1, 1).color(new RenderColor(c2)).next();
int i3 = mesh.vec3(p3x, p3y, p3z).vec2(1, 0).color(new RenderColor(c3)).next();
int i4 = mesh.vec3(p4x, p4y, p4z).vec2(0, 0).color(new RenderColor(c4)).next();
mesh.quad(i1, i2, i3, i4);
}
}

View file

@ -0,0 +1,62 @@
/*
* 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;
/**
* Чистая математика трёхфазной анимации (появление/жизнь/исчезновение) —
* идея из Soup HitBubbles (ThreeStageAnimation), без библиотеки именованных
* интерполяций референса: appear — ease-out (быстрый старт), disappear —
* ease-in (медленный старт затухания).
*/
public enum ThreeStageProgress {
;
public enum Stage { APPEAR, EXIST, DISAPPEAR, FINISHED }
public static Stage stage(double elapsedMs, double appearMs, double existMs, double disappearMs) {
if (elapsedMs < appearMs) return Stage.APPEAR;
if (elapsedMs < appearMs + existMs) return Stage.EXIST;
if (elapsedMs < appearMs + existMs + disappearMs) return Stage.DISAPPEAR;
return Stage.FINISHED;
}
public static boolean isFinished(double elapsedMs, double appearMs, double existMs, double disappearMs) {
return stage(elapsedMs, appearMs, existMs, disappearMs) == Stage.FINISHED;
}
/** @return общая "видимость" 0..1: растёт на appear, держится 1 на exist, падает на disappear. */
public static float visibility(double elapsedMs, double appearMs, double existMs, double disappearMs) {
Stage stage = stage(elapsedMs, appearMs, existMs, disappearMs);
return switch (stage) {
case APPEAR -> easeOut(clamp01(safeDiv(elapsedMs, appearMs)));
case EXIST -> 1.0f;
case DISAPPEAR -> 1.0f - easeIn(clamp01(safeDiv(elapsedMs - appearMs - existMs, disappearMs)));
case FINISHED -> 0.0f;
};
}
private static float easeOut(float t) {
float inv = 1.0f - t;
return 1.0f - inv * inv;
}
private static float easeIn(float t) {
return t * t;
}
private static double safeDiv(double value, double divisor) {
return divisor <= 0.0 ? 1.0 : value / divisor;
}
private static float clamp01(double v) {
if (Double.isNaN(v)) return 0.0f;
if (v < 0.0) return 0.0f;
if (v > 1.0) return 1.0f;
return (float) v;
}
}

View file

@ -15,6 +15,7 @@ import net.minecraft.client.Minecraft;
import net.minecraft.client.player.AbstractClientPlayer;
import net.minecraft.world.InteractionHand;
import net.minecraft.world.entity.HumanoidArm;
import net.minecraft.world.item.ItemStack;
import dev.loki.lovisual.features.module.modules.visuals.items.HoldMyItems;
import dev.loki.lovisual.render.helpers.util.TickDelta;
@ -54,7 +55,7 @@ public final class HandDynamics {
*/
public static boolean apply(
HoldMyItems module, HandAnimState state, AbstractClientPlayer player,
float tickDelta, InteractionHand hand, PoseStack matrices
float tickDelta, InteractionHand hand, ItemStack item, PoseStack matrices
) {
boolean mainHand = hand == InteractionHand.MAIN_HAND;
float bobStrength = module.walkBob.get() ? module.bobStrength.get() : 0.0f;
@ -62,6 +63,7 @@ public final class HandDynamics {
boolean usingThisHand = player.isUsingItem() && player.getUsedItemHand() == hand;
float drift = usingThisHand ? state.drift(mainHand) : 0.0f;
boolean rightArm = (player.getMainArm() == HumanoidArm.RIGHT) == mainHand;
HandPoseMath.Pose pose = HandPoseMath.sway(
state.sway().yawLag(), state.sway().pitchLag(), module.swayIntensity.get());
@ -72,12 +74,18 @@ public final class HandDynamics {
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 (module.gripEnabled.get() && !usingThisHand && HeldAlongFist.test(item)) {
float side = rightArm ? 1.0f : -1.0f;
pose = HandPoseMath.add(pose, HandPoseMath.grip(
module.gripExtend.get(), module.gripSlide.get(), module.gripOut.get(),
module.gripForward.get(), module.gripTilt.get(), module.gripTurn.get(),
module.gripRoll.get(), side));
}
if (!pose.isSignificant(SIGNIFICANT_EPSILON)) {
return false;
}

View file

@ -99,6 +99,24 @@ public enum HandPoseMath {
);
}
/**
* "Хват вдоль кулака" (grip) для предметов с рукоятью (см. {@link HeldAlongFist}):
* лёгкий сдвиг ближе к камере/в сторону и поворот — имитирует держание меча/палки
* не прямо по-вертикали ванильного equip, а слегка наискось в кулаке.
* {@code side}: +1 — правая рука, -1 — левая.
*/
public static Pose grip(float extend, float slide, float out, float forward,
float tilt, float turn, float roll, float side) {
return new Pose(
0.32f * side * extend + out * side,
slide,
-0.95f * extend - forward,
tilt,
turn * side,
roll * side
);
}
/** Появление при входе в мир: предмет въезжает снизу и распрямляется. */
public static Pose joinDrop(float progress01) {
float p = clamp01(progress01);

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.hand;
import net.minecraft.tags.ItemTags;
import net.minecraft.world.item.Items;
import net.minecraft.world.item.ItemStack;
/**
* Предметы с рукоятью, которые выглядят естественно при "хвате вдоль кулака"
* (grip): мечи/инструменты/палки. Портировано из идеи Delta HMI
* (heldAlongTheFist), список расширен по нашему набору ванильных предметов.
*/
public enum HeldAlongFist {
;
public static boolean test(ItemStack item) {
if (item == null || item.isEmpty()) return false;
return item.is(ItemTags.SWORDS) || item.is(ItemTags.AXES) || item.is(ItemTags.PICKAXES)
|| item.is(ItemTags.SHOVELS) || item.is(ItemTags.HOES)
|| item.is(Items.MACE) || item.is(Items.TRIDENT)
|| item.is(Items.FISHING_ROD) || item.is(Items.CARROT_ON_A_STICK)
|| item.is(Items.WARPED_FUNGUS_ON_A_STICK) || item.is(Items.STICK)
|| item.is(Items.BLAZE_ROD) || item.is(Items.BREEZE_ROD)
|| item.is(Items.BONE) || item.is(Items.DEBUG_STICK);
}
}

View file

@ -21,6 +21,7 @@ import dev.loki.lovisual.features.gui.hud.draggable.impl.targethud.TargetHud;
import dev.loki.lovisual.features.module.lifecycle.Modules;
import dev.loki.lovisual.features.module.modules.combat.pvp.RubberHand;
import dev.loki.lovisual.features.module.modules.misc.sound.HitSounds;
import dev.loki.lovisual.features.module.modules.visuals.effects.HitBubbles;
import dev.loki.lovisual.features.module.modules.visuals.effects.HitEffect;
import dev.loki.lovisual.mixins.accessors.entity.LocalPlayerAccessor;
import dev.loki.lovisual.mixins.accessors.misc.MultiPlayerGameModeAccessor;
@ -165,6 +166,10 @@ public enum AttackUtil {
if (hitEffect != null) {
hitEffect.handleHit(target);
}
HitBubbles hitBubbles = Modules.get(HitBubbles.class);
if (hitBubbles != null) {
hitBubbles.handleHit(target);
}
TargetHud.notifyHit(target);
TargetManager.onAttack(target);
}

View file

@ -0,0 +1,58 @@
/*
* 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.combat.hit;
import net.minecraft.client.Minecraft;
import net.minecraft.util.Mth;
import net.minecraft.world.entity.Entity;
import net.minecraft.world.entity.projectile.ProjectileUtil;
import net.minecraft.world.phys.AABB;
import net.minecraft.world.phys.EntityHitResult;
import net.minecraft.world.phys.Vec3;
/**
* Точная точка удара по хитбоксу цели — трасса луча от камеры вдоль последнего
* известного взгляда игрока. Общая для эффектов, которым важна не позиция цели
* целиком, а конкретное место попадания (HitEffect, HitBubbles).
*/
public enum AttackHitPoint {
;
/** @return точка попадания на хитбоксе {@code target} или null, если трасса не пересекла его. */
public static Vec3 resolve(Entity target, float lastYaw, float lastPitch) {
Minecraft mc = Minecraft.getInstance();
if (mc.player == null) return null;
double distance = mc.player.distanceTo(target) + 1.0;
Vec3 start = mc.player.position().add(0, mc.player.getEyeHeight(mc.player.getPose()), 0);
Vec3 rot = rotationVector(lastPitch, lastYaw);
Vec3 end = start.add(rot.scale(distance));
AABB box = mc.player.getBoundingBox().expandTowards(rot.scale(distance)).inflate(1.0, 1.0, 1.0);
EntityHitResult hit = ProjectileUtil.getEntityHitResult(
mc.player,
start,
end,
box,
e -> !e.isSpectator() && e.isPickable() && e == target,
distance * distance
);
return hit != null ? hit.getLocation() : null;
}
private static Vec3 rotationVector(float pitch, float yaw) {
float yawRad = -yaw * ((float) Math.PI / 180F);
float pitchRad = pitch * ((float) Math.PI / 180F);
float cosYaw = Mth.cos(yawRad);
float sinYaw = Mth.sin(yawRad);
float cosPitch = Mth.cos(pitchRad);
float sinPitch = Mth.sin(pitchRad);
return new Vec3(sinYaw * cosPitch, -sinPitch, cosYaw * cosPitch);
}
}

View file

@ -0,0 +1,43 @@
/*
* 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.world;
/**
* Чистая математика GPS-компаса: направление на точку и угол доворота стрелки
* от текущего взгляда игрока. Вынесено из модуля для юнит-тестов без Minecraft.
*/
public enum GpsBearing {
;
/**
* Азимут от игрока к точке в системе координат Minecraft (0° = юг, +Z),
* растёт по часовой при виде сверху — совпадает с {@code Entity.getYRot()}.
*/
public static float bearingDegrees(double dx, double dz) {
return (float) (-(Math.atan2(dx, dz) * (180.0 / Math.PI)));
}
/**
* Угол, на который нужно повернуть стрелку-компас (0° — «прямо по курсу»):
* разница между азимутом на цель и текущим yaw игрока, приведённая к (-180, 180].
*/
public static float arrowRotationDegrees(float bearing, float playerYaw) {
return wrapDegrees(bearing - playerYaw);
}
public static float wrapDegrees(float degrees) {
float wrapped = degrees % 360.0f;
if (wrapped >= 180.0f) wrapped -= 360.0f;
if (wrapped < -180.0f) wrapped += 360.0f;
return wrapped;
}
public static double horizontalDistance(double dx, double dz) {
return Math.sqrt(dx * dx + dz * dz);
}
}

View file

@ -3230,6 +3230,7 @@
"command.config.description": "Saves, reloads, or locates the active client config.",
"command.coordinates.description": "Shows the current player coordinates.",
"command.enemy.description": "Manages the client enemy list.",
"command.gps.description": "Sets or clears the Gps compass target.",
"command.friend.description": "Manages the client friend list.",
"command.help.description": "Shows every available client command with clickable pages. Prefix: % (legacy @ also works).",
"command.hide.description": "Hides or shows a module in ModuleList without disabling it.",

View file

@ -3025,6 +3025,7 @@
"command.config.description": "Сохраняет, перезагружает или показывает путь к конфигурации клиента.",
"command.coordinates.description": "Показывает текущие координаты игрока.",
"command.enemy.description": "Управляет списком врагов клиента.",
"command.gps.description": "Задаёт или сбрасывает цель GPS-компаса.",
"command.friend.description": "Управляет списком друзей клиента.",
"command.help.description": "Показывает все доступные команды клиента с кликабельными страницами. Префикс: % (старый @ тоже работает).",
"command.hide.description": "Скрывает или показывает модуль в ModuleList, не отключая его.",

View file

@ -19,6 +19,7 @@ layout (std140) uniform WetSurface {
vec4 u_Screen; // invW, invH, time, mainDepthEnabled
vec4 u_Params; // wetness, puddleCoverage, reflectionStrength, rippleStrength
vec4 u_Params2; // maxDistance, reflectionSteps, rainAmount, ripplesEnabled
vec4 u_Params3; // binarySteps, darkeningFactor, unused, unused
};
in vec2 v_TexCoord;
@ -36,12 +37,12 @@ vec3 viewPositionFromDepth(vec2 uv, float rawDepth) {
return view.xyz / view.w;
}
bool projectToScreen(vec3 viewPos, out vec2 uv, out float rawDepth) {
bool projectToScreen(vec3 viewPos, out vec2 uv, out float ndcZ) {
vec4 clip = u_Projection * vec4(viewPos, 1.0);
if (clip.w <= 0.0001) return false;
vec3 ndc = clip.xyz / clip.w;
uv = ndc.xy * 0.5 + 0.5;
rawDepth = ndc.z;
ndcZ = ndc.z;
return uv.x >= 0.0 && uv.x <= 1.0 && uv.y >= 0.0 && uv.y <= 1.0;
}
@ -60,9 +61,60 @@ float valueNoise(vec2 p) {
return mix(mix(a, b, u.x), mix(c, d, u.x), u.y);
}
float fbm(vec2 p) {
float v = 0.0;
float a = 0.5;
mat2 rot = mat2(0.8, 0.6, -0.6, 0.8);
for (int i = 0; i < 4; i++) {
v += valueNoise(p) * a;
p = rot * p * 2.02 + vec2(1.7, 9.2);
a *= 0.5;
}
return v;
}
float puddleNoise(vec2 worldXZ) {
float n = valueNoise(worldXZ * 0.18) * 0.65
+ valueNoise(worldXZ * 0.45 + 17.0) * 0.35;
return valueNoise(worldXZ * 0.18) * 0.65 + valueNoise(worldXZ * 0.45 + 17.0) * 0.35;
}
float getDither(vec2 fragCoord) {
return fract(52.9829189 * fract(dot(fragCoord, vec2(0.06711056, 0.00583715))));
}
// Реконструкция нормали по 4 соседним сэмплам буфера глубины вместо dFdx/dFdy от view-position:
// screen-space derivative ломается на границах глубины (силуэты, дальний план) и даёт шумную/
// блочную нормаль на почти плоской поверхности -> дрожащая/рваная граница лужи. Сэмплинг соседей
// по глубине даёт гладкую нормаль и не зависит от размера треугольника меша чанка.
vec3 normalFromDepth(vec2 uv, vec3 viewPos) {
vec2 texel = vec2(u_Screen.x, u_Screen.y);
float dL = texture(u_MainDepth, clamp(uv - vec2(texel.x, 0.0), vec2(0.0001), vec2(0.9999))).r;
float dR = texture(u_MainDepth, clamp(uv + vec2(texel.x, 0.0), vec2(0.0001), vec2(0.9999))).r;
float dD = texture(u_MainDepth, clamp(uv - vec2(0.0, texel.y), vec2(0.0001), vec2(0.9999))).r;
float dU = texture(u_MainDepth, clamp(uv + vec2(0.0, texel.y), vec2(0.0001), vec2(0.9999))).r;
vec3 pL = viewPositionFromDepth(uv - vec2(texel.x, 0.0), dL);
vec3 pR = viewPositionFromDepth(uv + vec2(texel.x, 0.0), dR);
vec3 pD = viewPositionFromDepth(uv - vec2(0.0, texel.y), dD);
vec3 pU = viewPositionFromDepth(uv + vec2(0.0, texel.y), dU);
vec3 vL = viewPos - pL;
vec3 vR = pR - viewPos;
vec3 vD = viewPos - pD;
vec3 vU = pU - viewPos;
// берём более короткую (менее вероятно пересекающую разрыв глубины) сторону по каждой оси
vec3 dX = (abs(vL.z) < abs(vR.z)) ? vL : vR;
vec3 dY = (abs(vD.z) < abs(vU.z)) ? vD : vU;
vec3 n = cross(dX, dY);
float len = length(n);
if (len > 1e-6) {
n /= len;
} else {
n = normalize(-viewPos);
}
if (dot(n, -viewPos) < 0.0) n = -n;
return n;
}
@ -82,18 +134,12 @@ void main() {
}
vec3 viewPos = viewPositionFromDepth(uv, rawDepth);
vec3 ddxPos = dFdx(viewPos);
vec3 ddyPos = dFdy(viewPos);
vec3 viewNormal = normalize(cross(ddxPos, ddyPos));
vec3 viewNormal = normalFromDepth(uv, viewPos);
vec3 viewDir = normalize(viewPos);
if (dot(viewNormal, -viewDir) < 0.0) {
viewNormal = -viewNormal;
}
vec3 worldNormal = normalize(mat3(u_InvView) * viewNormal);
float groundFactor = clamp(dot(worldNormal, vec3(0.0, 1.0, 0.0)), 0.0, 1.0);
groundFactor = smoothstep(0.82, 0.98, groundFactor);
groundFactor = smoothstep(0.6, 0.92, groundFactor);
if (groundFactor <= 0.001) {
color = base;
return;
@ -106,9 +152,11 @@ void main() {
float reflectionStrength = clamp(u_Params.z, 0.0, 2.0);
float rippleStrength = clamp(u_Params.w, 0.0, 2.0);
float maxDistance = max(u_Params2.x, 1.0);
int steps = int(clamp(floor(u_Params2.y + 0.5), 1.0, 32.0));
int steps = int(clamp(floor(u_Params2.y + 0.5), 1.0, 96.0));
float rainAmount = clamp(u_Params2.z, 0.0, 2.0);
bool ripplesEnabled = u_Params2.w > 0.5;
int binarySteps = int(clamp(floor(u_Params3.x + 0.5), 0.0, 8.0));
float darkeningFactor = clamp(u_Params3.y, 0.0, 1.0);
float noise = puddleNoise(worldPos.xz);
float wetMask = smoothstep(0.5 - puddleCoverage * 0.55, 0.5 + puddleCoverage * 0.35, noise);
@ -118,48 +166,90 @@ void main() {
return;
}
vec3 reflNormal = viewNormal;
vec3 reflNormal = worldNormal;
if (ripplesEnabled && rippleStrength > 0.001) {
// Аналитический градиент бегущей волны вместо мёртвого dFdx(ripple)*0.0 из старой версии
// (там перемножение на 0 полностью убивало рябь) — считаем волну в двух соседних точках
// и берём разность как наклон поверхности, а не саму высоту волны.
float t = u_Screen.z;
float ripple = sin((worldPos.x + worldPos.z) * 3.1 + t * 2.4) * 0.5
+ sin((worldPos.x - worldPos.z * 1.7) * 4.3 - t * 3.1) * 0.5;
vec2 perturb = vec2(dFdx(ripple), dFdy(ripple)) * 0.0;
reflNormal = normalize(viewNormal + vec3(ripple, 0.0, ripple * 0.6) * rippleStrength * 0.05);
float eps = 0.35;
vec2 wp = worldPos.xz;
float h0 = sin(wp.x * 3.1 + t * 2.4) * 0.5 + sin((wp.x - wp.y * 1.7) * 4.3 - t * 3.1) * 0.5;
float hx = sin((wp.x + eps) * 3.1 + t * 2.4) * 0.5 + sin(((wp.x + eps) - wp.y * 1.7) * 4.3 - t * 3.1) * 0.5;
float hy = sin(wp.x * 3.1 + t * 2.4) * 0.5 + sin((wp.x - (wp.y + eps) * 1.7) * 4.3 - t * 3.1) * 0.5;
vec2 slope = vec2(hx - h0, hy - h0) / eps;
vec3 perturbedWorld = normalize(vec3(-slope.x, 1.0, -slope.y) * vec3(rippleStrength * 0.35, 1.0, rippleStrength * 0.35));
reflNormal = normalize(mix(worldNormal, perturbedWorld, groundFactor));
}
// world-space нормаль обратно в view-space через транспонированную (обратную) чистую ротацию u_InvView
vec3 reflNormalView = normalize(transpose(mat3(u_InvView)) * reflNormal);
if (dot(reflNormalView, -viewDir) < 0.0) reflNormalView = -reflNormalView;
vec3 reflDir = reflect(viewDir, reflNormal);
vec3 reflDir = reflect(viewDir, reflNormalView);
vec3 skyPitch3 = normalize(mat3(u_InvView) * reflDir);
float skyPitch = clamp(skyPitch3.y * 0.5 + 0.5, 0.0, 1.0);
vec3 horizonColor = vec3(0.30, 0.36, 0.45);
vec3 zenithColor = vec3(0.10, 0.16, 0.28);
vec3 reflectionColor = mix(horizonColor, zenithColor, pow(skyPitch, 0.75));
float cloud = fbm(skyPitch3.xz * 1.5 / max(abs(skyPitch3.y) + 0.18, 0.08));
reflectionColor = mix(reflectionColor, vec3(0.42, 0.48, 0.56), cloud * 0.28 * smoothstep(-0.1, 0.5, skyPitch3.y));
vec3 lightDir = normalize(vec3(0.35, 0.75, 0.25));
float sunSpec = pow(max(dot(skyPitch3, lightDir), 0.0), 32.0);
reflectionColor += vec3(0.85, 0.88, 0.95) * sunSpec * 0.5;
vec3 reflectionColor = mix(vec3(0.45, 0.52, 0.62), vec3(0.75, 0.82, 0.92), clamp(reflDir.y * 0.5 + 0.5, 0.0, 1.0));
bool hit = false;
float dither = getDither(gl_FragCoord.xy);
float stepLen = maxDistance / float(max(steps, 1));
vec3 rayOrigin = viewPos + reflNormalView * 0.02;
float stepLen = maxDistance / float(steps);
vec3 marchPos = viewPos + reflNormal * 0.02;
for (int i = 0; i < 32; i++) {
for (int i = 0; i < 96; i++) {
if (i >= steps) break;
marchPos += reflDir * stepLen;
float t = (float(i) + dither) * stepLen + stepLen * 0.5;
vec3 marchPos = rayOrigin + reflDir * t;
vec2 sampleUv;
float sampleClipDepth;
if (!projectToScreen(marchPos, sampleUv, sampleClipDepth)) break;
float sampleNdcZ;
if (!projectToScreen(marchPos, sampleUv, sampleNdcZ)) break;
float sceneRawDepth = texture(u_MainDepth, sampleUv).r;
if (!validRawDepth(sceneRawDepth)) continue;
vec3 scenePos = viewPositionFromDepth(sampleUv, sceneRawDepth);
float marchDist = length(marchPos);
float sceneDist = length(scenePos);
float thickness = max(0.15, marchDist * 0.03);
if (marchDist > sceneDist && marchDist - sceneDist < thickness) {
reflectionColor = texture(u_Texture, sampleUv).rgb;
// Бинарный рефайнмент: сужаем интервал вокруг найденного пересечения, чтобы убрать
// блочность отражений на резких перепадах глубины (старая версия брала грубый sampleUv как есть).
float tMin = max(0.0, t - stepLen);
float tMax = t;
vec2 refinedUv = sampleUv;
for (int b = 0; b < binarySteps; b++) {
float tMid = (tMin + tMax) * 0.5;
vec3 midPos = rayOrigin + reflDir * tMid;
vec2 midUv;
float midNdcZ;
if (!projectToScreen(midPos, midUv, midNdcZ)) break;
float midDepth = texture(u_MainDepth, midUv).r;
if (!validRawDepth(midDepth)) { tMin = tMid; continue; }
vec3 midScenePos = viewPositionFromDepth(midUv, midDepth);
if (length(midPos) > length(midScenePos)) {
tMax = tMid;
refinedUv = midUv;
} else {
tMin = tMid;
}
}
reflectionColor = texture(u_Texture, refinedUv).rgb;
hit = true;
break;
}
}
float fresnel = pow(1.0 - clamp(dot(-viewDir, viewNormal), 0.0, 1.0), 3.0);
float fresnel = pow(1.0 - clamp(dot(-viewDir, reflNormalView), 0.0, 1.0), 3.0);
fresnel = mix(0.25, 1.0, fresnel);
float hitBoost = hit ? 1.0 : 0.55;
@ -167,7 +257,7 @@ void main() {
* mix(0.6, 1.0, clamp(rainAmount, 0.0, 1.0)) * wetness;
reflectionAmount = clamp(reflectionAmount, 0.0, 1.0);
vec3 darkened = base.rgb * mix(1.0, 0.82, wetMask * clamp(wetness, 0.0, 1.0));
vec3 darkened = base.rgb * (1.0 - darkeningFactor * wetMask);
vec3 result = mix(darkened, reflectionColor, reflectionAmount);
color = vec4(clamp(result, 0.0, 1.0), 1.0);

View file

@ -140,6 +140,7 @@
"network.game.PlayerInfoMixin",
"network.packet.ClientCommonPacketListenerImplMixin",
"network.packet.ServerboundUseItemPacketMixin",
"render.entity.EndCrystalRendererMixin",
"render.entity.EntityRenderDispatcherMixin",
"render.entity.ItemEntityRenderStateMixin",
"render.entity.ItemEntityRendererMixin",
@ -154,6 +155,7 @@
"render.item.ItemInHandAlwaysVisibleMixin",
"render.item.ItemInHandRendererMixin",
"render.level.CameraMixin",
"render.level.FireworkParticlesStarterMixin",
"render.level.LevelRendererMixin",
"render.level.ParticleManagerMixin",
"render.level.SkyRendererMixin",

View file

@ -0,0 +1,64 @@
/*
* 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.assertTrue;
final class ThreeStageProgressTest {
private static final float EPS = 1.0e-3f;
@Test
void stagesFollowElapsedTime() {
assertEquals(ThreeStageProgress.Stage.APPEAR, ThreeStageProgress.stage(0, 100, 200, 300));
assertEquals(ThreeStageProgress.Stage.EXIST, ThreeStageProgress.stage(150, 100, 200, 300));
assertEquals(ThreeStageProgress.Stage.DISAPPEAR, ThreeStageProgress.stage(350, 100, 200, 300));
assertEquals(ThreeStageProgress.Stage.FINISHED, ThreeStageProgress.stage(700, 100, 200, 300));
}
@Test
void visibilityStartsAtZeroAndEndsAtOneDuringAppear() {
assertEquals(0.0f, ThreeStageProgress.visibility(0, 100, 200, 300), EPS);
assertEquals(1.0f, ThreeStageProgress.visibility(100, 100, 200, 300), EPS);
}
@Test
void visibilityStaysFullDuringExist() {
assertEquals(1.0f, ThreeStageProgress.visibility(150, 100, 200, 300), EPS);
assertEquals(1.0f, ThreeStageProgress.visibility(299, 100, 200, 300), EPS);
}
@Test
void visibilityFadesToZeroDuringDisappear() {
assertEquals(1.0f, ThreeStageProgress.visibility(300, 100, 200, 300), EPS);
assertTrue(ThreeStageProgress.visibility(450, 100, 200, 300) < 1.0f);
assertEquals(0.0f, ThreeStageProgress.visibility(600, 100, 200, 300), EPS);
}
@Test
void visibilityIsZeroAfterFinished() {
assertEquals(0.0f, ThreeStageProgress.visibility(1000, 100, 200, 300), EPS);
}
@Test
void isFinishedMatchesStage() {
assertFalse(ThreeStageProgress.isFinished(0, 100, 200, 300));
assertFalse(ThreeStageProgress.isFinished(599, 100, 200, 300));
assertTrue(ThreeStageProgress.isFinished(600, 100, 200, 300));
}
@Test
void zeroDurationStagesDoNotDivideByZero() {
assertEquals(1.0f, ThreeStageProgress.visibility(0, 0, 200, 300), EPS);
assertEquals(ThreeStageProgress.Stage.EXIST, ThreeStageProgress.stage(0, 0, 200, 300));
}
}

View file

@ -121,6 +121,30 @@ public final class HandPoseMathTest {
assertEquals(a, HandPoseMath.add(HandPoseMath.ZERO, a));
}
@Test
void gripZeroParamsIsZero() {
assertFalse(HandPoseMath.grip(0f, 0f, 0f, 0f, 0f, 0f, 0f, 1f).isSignificant(EPS));
}
@Test
void gripMirrorsBySide() {
HandPoseMath.Pose right = HandPoseMath.grip(0.3f, 0.1f, 0.05f, 0.14f, 5f, 10f, 15f, 1.0f);
HandPoseMath.Pose left = HandPoseMath.grip(0.3f, 0.1f, 0.05f, 0.14f, 5f, 10f, 15f, -1.0f);
assertEquals(-right.dx(), left.dx(), EPS);
assertEquals(right.dy(), left.dy(), EPS, "slide не зависит от стороны");
assertEquals(right.dz(), left.dz(), EPS, "extend/forward не зависят от стороны");
assertEquals(right.rx(), left.rx(), EPS, "tilt не зависит от стороны");
assertEquals(-right.ry(), left.ry(), EPS);
assertEquals(-right.rz(), left.rz(), EPS);
}
@Test
void gripExtendPullsCloserAndAcrossFist() {
HandPoseMath.Pose pose = HandPoseMath.grip(1.0f, 0f, 0f, 0f, 0f, 0f, 0f, 1.0f);
assertTrue(pose.dz() < 0.0f, "extend тянет предмет ближе к камере (-Z)");
assertTrue(pose.dx() > 0.0f, "extend на правой руке сдвигает вбок по стороне");
}
@Test
void significanceThresholdWorks() {
assertFalse(HandPoseMath.ZERO.isSignificant(1.0e-4f));

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.util.world;
import org.junit.jupiter.api.Test;
import static org.junit.jupiter.api.Assertions.assertEquals;
final class GpsBearingTest {
private static final float EPS = 1.0e-3f;
@Test
void bearingPointsSouthWhenTargetAheadOnZ() {
// +Z (юг) при dx=0 -> азимут 0, совпадает с конвенцией getYRot()
assertEquals(0.0f, GpsBearing.bearingDegrees(0.0, 10.0), EPS);
}
@Test
void bearingPointsWestWhenTargetOnNegativeX() {
assertEquals(90.0f, GpsBearing.bearingDegrees(-10.0, 0.0), EPS);
}
@Test
void arrowRotationIsZeroWhenFacingTarget() {
assertEquals(0.0f, GpsBearing.arrowRotationDegrees(45.0f, 45.0f), EPS);
}
@Test
void arrowRotationWrapsToShortestPath() {
// цель за спиной (разница 180) должна остаться в диапазоне (-180, 180]
float result = GpsBearing.arrowRotationDegrees(170.0f, -170.0f);
assertEquals(-20.0f, result, EPS);
}
@Test
void wrapDegreesStaysInHalfOpenRange() {
assertEquals(-179.0f, GpsBearing.wrapDegrees(181.0f), EPS);
assertEquals(179.0f, GpsBearing.wrapDegrees(-181.0f), EPS);
assertEquals(0.0f, GpsBearing.wrapDegrees(360.0f), EPS);
assertEquals(90.0f, GpsBearing.wrapDegrees(90.0f), EPS);
}
@Test
void horizontalDistanceIsPythagorean() {
assertEquals(5.0, GpsBearing.horizontalDistance(3.0, 4.0), 1.0e-9);
}
}