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` чит, удалён в Фазе 3.5). Референс: `visuals/Hitboxes.java`
- [x] **Watermark** (HUD, draggable) — обещан в README, в коде отсутствует - [x] **Watermark** (HUD, draggable) — обещан в README, в коде отсутствует
- [ ] **Gps** (world) — луч/маркер направления к точке (у Soup ~400 стр.); связка - [x] **Gps** (world) — ЗАВЕРШЕНО 2026-09-15: компас-стрелка + маркер к точке
с Xaero-waypoints и координатами. Референс: `world/Gps.java` (`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` - [x] **Light** (world) — кастомный цвет освещения (у Soup 41 стр.). Референс: `world/Light.java`
- [ ] **HitBubbles** (particles) — пузыри при ударах по игрокам (у Soup 323 стр.). - [x] **HitBubbles** (particles) — ЗАВЕРШЕНО 2026-09-15: отдельный модуль
Референс: `particles/HitBubbles.java` `visuals/effects/HitBubbles.java` (не режим HitEffect.BUBBLES — независимое включение,
- [ ] **AmbientParticles + FireFlyParticle** (particles) — атмосферные частицы мира / трёхфазная анимация появление/жизнь/исчезновение через новый `ThreeStageProgress`).
светлячки (у Soup 775 + 241 стр.). Референс: `particles/AmbientParticles.java`, Заодно вынесены общие хелперы из HitEffect (DRY): `AttackHitPoint`,
`particles/types/FireFlyParticle.java` `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) — подмена своего ника и ников друзей в тексте чата/скорборда - [x] **NameProtect** (misc) — подмена своего ника и ников друзей в тексте чата/скорборда
(у Soup 42 стр.). Референс: `other/NameProtect.java` (у Soup 42 стр.). Референс: `other/NameProtect.java`
- [ ] **EndCrystal** (visuals) — улучшенный рендер энд-кристаллов (у Soup 217 стр.). - [x] **EndCrystal** (visuals) — ЗАВЕРШЕНО 2026-09-15: `visuals/scene/EndCrystal.java` +
Референс: `other/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: - [ ] **Trinket 2.0** — апгрейд нашего CosmeticDisplay до уровня Soup Trinket:
иконки-спутники (Atom/Ball/Cat/Donut/Flame/Flower/Gem/Ghost/MCTiers), трейл иконки-спутники (Atom/Ball/Cat/Donut/Flame/Flower/Gem/Ghost/MCTiers), трейл
(цвет sync/custom, длина, толщина), звуки (типы + громкость), частицы (цвет sync/custom, длина, толщина), звуки (типы + громкость), частицы

View file

@ -409,3 +409,85 @@
(N = проходов за кадр). (N = проходов за кадр).
- [ ] `./gradlew build` после каждого пункта; `./gradlew test` зелёный. - [ ] `./gradlew build` после каждого пункта; `./gradlew test` зелёный.
- [ ] Порядок: 9.1 (по одному пункту на коммит) → 9.2. - [ ] Порядок: 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 dev.loki.lovisual.util.pvp.opponents.TotemPopSnapshot;
import net.minecraft.client.Minecraft; import net.minecraft.client.Minecraft;
import net.minecraft.client.gui.GuiGraphicsExtractor; import net.minecraft.client.gui.GuiGraphicsExtractor;
import net.minecraft.core.Holder;
import net.minecraft.resources.Identifier; import net.minecraft.resources.Identifier;
import net.minecraft.world.entity.EntityType;
import net.minecraft.world.entity.LivingEntity; import net.minecraft.world.entity.LivingEntity;
import net.minecraft.world.entity.player.Player; import net.minecraft.world.entity.player.Player;
import net.minecraft.world.item.Item;
import net.minecraft.world.item.ItemStack; import net.minecraft.world.item.ItemStack;
import net.minecraft.world.item.Items; import net.minecraft.world.item.Items;
import net.minecraft.world.item.SpawnEggItem;
import java.util.Locale; import java.util.Locale;
import java.util.Optional;
import java.util.UUID; import java.util.UUID;
import static dev.loki.lovisual.features.gui.hud.draggable.impl.targethud.TargetHud.*; 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) 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 name = target.getName().getString();
String letter = name.isEmpty() ? "?" : name.substring(0, 1).toUpperCase(Locale.ROOT); String letter = name.isEmpty() ? "?" : name.substring(0, 1).toUpperCase(Locale.ROOT);
TextRenderer faceRenderer = Fonts.renderer("Inter", FontInfo.Type.Bold, textRenderer); 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) { 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) { if (snapshot == null || snapshot.count() <= 0 || element.totemPopAnimation <= 0.001f) {
return; 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.Entity;
import net.minecraft.world.entity.LivingEntity; import net.minecraft.world.entity.LivingEntity;
import net.minecraft.world.entity.player.Player; import net.minecraft.world.entity.player.Player;
import net.minecraft.world.entity.projectile.ProjectileUtil;
import net.minecraft.world.level.block.state.BlockState; 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.Vec3;
import net.minecraft.world.phys.shapes.VoxelShape; import net.minecraft.world.phys.shapes.VoxelShape;
import org.joml.Quaternionf; 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.mixininterface.entity.IPlayerAttackCooldown;
import dev.loki.lovisual.render.engine.RenderState; import dev.loki.lovisual.render.engine.RenderState;
import dev.loki.lovisual.render.engine.texture.TextureStorage; 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.animation.AnimatedRenderColors;
import dev.loki.lovisual.render.engine.color.RenderColor; import dev.loki.lovisual.render.engine.color.RenderColor;
import dev.loki.lovisual.render.engine.pipeline.LoVisualRenderPipelines; import dev.loki.lovisual.render.engine.pipeline.LoVisualRenderPipelines;
import dev.loki.lovisual.render.engine.renderer.Renderer3D; import dev.loki.lovisual.render.engine.renderer.Renderer3D;
import dev.loki.lovisual.render.engine.uniform.MeshBuilder; import dev.loki.lovisual.render.engine.uniform.MeshBuilder;
import dev.loki.lovisual.render.helpers.particle.OrientedQuadRenderer;
import dev.loki.lovisual.render.helpers.player.Particle3D; import dev.loki.lovisual.render.helpers.player.Particle3D;
import dev.loki.lovisual.render.helpers.util.ParticleTextureMode; import dev.loki.lovisual.render.helpers.util.ParticleTextureMode;
import dev.loki.lovisual.util.combat.hit.AttackHitPoint;
import dev.loki.lovisual.util.time.Timer; import dev.loki.lovisual.util.time.Timer;
import java.awt.*; import java.awt.*;
@ -72,6 +72,10 @@ public class HitEffect extends Module {
private static final String SETTING_COLOR2 = "color2"; private static final String SETTING_COLOR2 = "color2";
private static final String SETTING_WAVE_RADIUS = "wave_radius"; private static final String SETTING_WAVE_RADIUS = "wave_radius";
private static final String SETTING_WAVE_DURATION = "wave_duration"; 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_PARTICLES = "hit_particles";
private static final String SETTING_HIT_PARTICLE_COUNT = "hit_particle_count"; private static final String SETTING_HIT_PARTICLE_COUNT = "hit_particle_count";
private static final String SETTING_HIT_PARTICLE_SIZE = "hit_particle_size"; 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); visibleWhen(num("hitBubblesWaveRadius", SETTING_WAVE_RADIUS, 12, 3, 32), this::isWaveMode);
private final NumberValue<Integer> waveDurationMs = private final NumberValue<Integer> waveDurationMs =
visibleWhen(num("hitBubblesWaveDuration", SETTING_WAVE_DURATION, 1500, 200, 6000), this::isWaveMode); 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 = private final BooleanValue hitParticlesEnabled =
bool("hitEffectParticlesEnabled", SETTING_HIT_PARTICLES, false); bool("hitEffectParticlesEnabled", SETTING_HIT_PARTICLES, false);
private final NumberValue<Integer> hitParticleCount = private final NumberValue<Integer> hitParticleCount =
@ -138,6 +148,7 @@ public class HitEffect extends Module {
private final List<HitBubble> bubbles = new ArrayList<>(); private final List<HitBubble> bubbles = new ArrayList<>();
private final List<Star> stars = new ArrayList<>(); private final List<Star> stars = new ArrayList<>();
private final List<WaveEffect> waves = 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> hitParticles = new ArrayList<>();
private final List<Particle3D>[] hitParticleBuckets = createBuckets(); private final List<Particle3D>[] hitParticleBuckets = createBuckets();
private int lastHitParticleTick = Integer.MIN_VALUE; private int lastHitParticleTick = Integer.MIN_VALUE;
@ -165,44 +176,6 @@ public class HitEffect extends Module {
return (na << 24) | (argb & 0x00FFFFFF); 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, private static void addBillboardQuad(MeshBuilder mesh, double cx, double cy, double cz,
float size, Quaternionf camRot, int argb) { float size, Quaternionf camRot, int argb) {
Vector3f right = new Vector3f(1, 0, 0).rotate(camRot).mul(size); Vector3f right = new Vector3f(1, 0, 0).rotate(camRot).mul(size);
@ -274,6 +247,7 @@ public class HitEffect extends Module {
bubbles.clear(); bubbles.clear();
stars.clear(); stars.clear();
waves.clear(); waves.clear();
impacts.clear();
hitParticles.clear(); hitParticles.clear();
lastHitParticleTick = Integer.MIN_VALUE; lastHitParticleTick = Integer.MIN_VALUE;
spawnedHitParticlesThisTick = 0; spawnedHitParticlesThisTick = 0;
@ -289,13 +263,14 @@ public class HitEffect extends Module {
Vec3 pos = target.position(); Vec3 pos = target.position();
BlockPos basePos = BlockPos.containing(pos.x, pos.y - 0.1, pos.z); BlockPos basePos = BlockPos.containing(pos.x, pos.y - 0.1, pos.z);
addWave(basePos); addWave(basePos);
} else if (effectMode.get() == EffectMode.IMPACT) {
impacts.add(new ImpactRing(target.position(), GameClock.millis()));
} else { } else {
ILocalPlayer access = (ILocalPlayer) mc.player; ILocalPlayer access = (ILocalPlayer) mc.player;
float yaw = access.lovisual$getLastYaw(); float yaw = access.lovisual$getLastYaw();
float pitch = access.lovisual$getLastPitch(); float pitch = access.lovisual$getLastPitch();
double distance = mc.player.distanceTo(target) + 1.0; Vec3 point = AttackHitPoint.resolve(target, yaw, pitch);
Vec3 point = getRtxPoint(target, yaw, pitch, distance);
if (point == null) point = target.position(); if (point == null) point = target.position();
bubbles.add(new HitBubble(point, -yaw, pitch, new Timer())); bubbles.add(new HitBubble(point, -yaw, pitch, new Timer()));
@ -343,10 +318,28 @@ public class HitEffect extends Module {
renderWaveEffect(renderer); renderWaveEffect(renderer);
return; return;
} }
if (effectMode.get() == EffectMode.IMPACT) {
renderImpactEffect(renderer);
return;
}
renderBubbleEffect(renderer, tickDelta); 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) { private void renderBubbleEffect(Renderer3D renderer, float tickDelta) {
boolean useDepth = depthTest.get(); boolean useDepth = depthTest.get();
RenderPipeline pipeline = useDepth RenderPipeline pipeline = useDepth
@ -371,7 +364,7 @@ public class HitEffect extends Module {
int c3 = applyOpacity(getColorArgb(180), 1f - factor); int c3 = applyOpacity(getColorArgb(180), 1f - factor);
int c4 = applyOpacity(getColorArgb(90), 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)); bubbles.removeIf(b -> b.life.passedMs(lifeTime.get() * 50L));
@ -553,6 +546,10 @@ public class HitEffect extends Module {
return effectMode.get() == EffectMode.WAVE; return effectMode.get() == EffectMode.WAVE;
} }
private boolean isImpactMode() {
return effectMode.get() == EffectMode.IMPACT;
}
private boolean usesSecondaryColor() { private boolean usesSecondaryColor() {
return AnimatedRenderColors.usesSecondary(animatedColorMode()); return AnimatedRenderColors.usesSecondary(animatedColorMode());
} }
@ -568,16 +565,7 @@ public class HitEffect extends Module {
} }
private AnimatedRenderColors.Mode animatedColorMode() { private AnimatedRenderColors.Mode animatedColorMode() {
return switch (colorMode.get()) { return AnimatedColorModeOptions.parse(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;
};
} }
private boolean isCritical(Player attacker, float cooldown) { 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 { public enum EffectMode {
BUBBLES, BUBBLES,
WAVE WAVE,
IMPACT
} }
private record ParticleTexture(Identifier texture, int index) { private record ParticleTexture(Identifier texture, int index) {
@ -772,4 +730,96 @@ public class HitEffect extends Module {
return null; 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 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 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() { public HandAnimState handAnim() {
return handAnim; return handAnim;
} }

View file

@ -7,6 +7,7 @@
package dev.loki.lovisual.features.module.modules.visuals.optimization; 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.BooleanValue;
import dev.loki.lovisual.config.values.primitive.NumberValue; import dev.loki.lovisual.config.values.primitive.NumberValue;
import dev.loki.lovisual.events.impl.render.ParticleSpawnEvent; import dev.loki.lovisual.events.impl.render.ParticleSpawnEvent;
@ -65,6 +66,11 @@ public final class FpsOptimizer extends Module {
@EventHandler @EventHandler
private void onParticleSpawn(ParticleSpawnEvent event) { private void onParticleSpawn(ParticleSpawnEvent event) {
if (!isEnabled() || !disableParticles.get()) return; 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(); 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.Module;
import dev.loki.lovisual.features.module.core.ModuleCategory; import dev.loki.lovisual.features.module.core.ModuleCategory;
import dev.loki.lovisual.features.module.core.ModuleInfo; 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.renderer.Renderer3D;
import dev.loki.lovisual.render.engine.pipeline.LoVisualRenderPipelines; import dev.loki.lovisual.render.engine.pipeline.LoVisualRenderPipelines;
import dev.loki.lovisual.render.engine.texture.TextureStorage; import dev.loki.lovisual.render.engine.texture.TextureStorage;
import dev.loki.lovisual.render.engine.uniform.MeshBuilder; import dev.loki.lovisual.render.engine.uniform.MeshBuilder;
import dev.loki.lovisual.util.target.TargetManager;
import net.minecraft.client.Minecraft; import net.minecraft.client.Minecraft;
import net.minecraft.client.player.AbstractClientPlayer;
import net.minecraft.resources.Identifier; import net.minecraft.resources.Identifier;
import net.minecraft.world.entity.LivingEntity; import net.minecraft.world.entity.LivingEntity;
import net.minecraft.world.entity.player.Player;
import net.minecraft.world.phys.Vec3; import net.minecraft.world.phys.Vec3;
@ModuleInfo( @ModuleInfo(
@ -64,68 +66,6 @@ public class TrollfaceMask extends Module {
if (mc.player == null || mc.level == null) return; if (mc.player == null || mc.level == null) return;
String targetMode = target.get(); 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()); int textureIndex = maskTypeIndex(maskType.get());
Identifier texId = switch (textureIndex) { Identifier texId = switch (textureIndex) {
case 0 -> TextureStorage.TROLLFACE_1; case 0 -> TextureStorage.TROLLFACE_1;
@ -137,14 +77,66 @@ public class TrollfaceMask extends Module {
default -> TextureStorage.TROLLFACE_1; 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); MeshBuilder mesh = renderer.batchTextured(LoVisualRenderPipelines.WORLD_TEXTURED, texId, Renderer3D.DepthMode.NONE);
if (mesh == null) return; if (mesh == null) return;
mesh.ensureQuadCapacity(); mesh.ensureQuadCapacity();
int i1 = mesh.vec3(p1.x, p1.y, p1.z).vec2(0, 1).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(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(1, 0).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(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); 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() { private float rainAmount() {
return Mth.clamp(0.55f + density.get() * presetDensity() * 0.28f, 0.55f, 1.45f); 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(), reflectionDistance.get(),
reflectionSteps(), reflectionSteps(),
rainAmount(), rainAmount(),
puddleRipples.get() puddleRipples.get(),
binarySteps(),
0.18f
); );
try { 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.entity.player.Player;
import net.minecraft.world.item.Items; import net.minecraft.world.item.Items;
import net.minecraft.world.phys.Vec3; import net.minecraft.world.phys.Vec3;
import org.slf4j.Logger;
import org.spongepowered.asm.mixin.Mixin; import org.spongepowered.asm.mixin.Mixin;
import org.spongepowered.asm.mixin.Unique; import org.spongepowered.asm.mixin.Unique;
import org.spongepowered.asm.mixin.injection.At; import org.spongepowered.asm.mixin.injection.At;
import org.spongepowered.asm.mixin.injection.Inject; import org.spongepowered.asm.mixin.injection.Inject;
import org.spongepowered.asm.mixin.injection.Redirect;
import org.spongepowered.asm.mixin.injection.callback.CallbackInfo; import org.spongepowered.asm.mixin.injection.callback.CallbackInfo;
import dev.loki.lovisual.config.values.collection.ItemCooldownRulesValue; import dev.loki.lovisual.config.values.collection.ItemCooldownRulesValue;
import dev.loki.lovisual.events.Events; import dev.loki.lovisual.events.Events;
@ -150,6 +152,14 @@ public abstract class ClientPacketListenerMixin {
StaffTracker.INSTANCE.onEntityAppear(player.getUUID(), player.getGameProfile().name()); 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")) @Inject(method = "handlePlayerInfoUpdate", at = @At("TAIL"))
private void lovisual$onPlayerList(ClientboundPlayerInfoUpdatePacket packet, CallbackInfo ci) { 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.combat.pvp.PvpCooldowns;
import dev.loki.lovisual.features.module.modules.misc.sound.HitSounds; 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.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.features.module.modules.visuals.effects.HitEffect;
import dev.loki.lovisual.mixins.accessors.world.WorldAccessor; import dev.loki.lovisual.mixins.accessors.world.WorldAccessor;
import dev.loki.lovisual.util.player.inventory.core.InventorySwap; import dev.loki.lovisual.util.player.inventory.core.InventorySwap;
@ -167,6 +168,11 @@ public class MultiPlayerGameModeMixin {
hitEffect.handleHit(target); hitEffect.handleHit(target);
} }
HitBubbles hitBubbles = Modules.get(HitBubbles.class);
if (hitBubbles != null) {
hitBubbles.handleHit(target);
}
TargetHud.notifyHit(target); TargetHud.notifyHit(target);
TargetManager.onAttack(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(); HandAnimState handAnim = holdMyItems.handAnim();
if (handAnim == null) return; 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")) @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.ItemParticleOption;
import net.minecraft.core.particles.ParticleOptions; import net.minecraft.core.particles.ParticleOptions;
import net.minecraft.core.particles.ParticleTypes; 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.Mixin;
import org.spongepowered.asm.mixin.injection.At; import org.spongepowered.asm.mixin.injection.At;
import org.spongepowered.asm.mixin.injection.Inject; import org.spongepowered.asm.mixin.injection.Inject;
@ -21,6 +23,20 @@ import dev.loki.lovisual.features.module.modules.visuals.visibility.NoRender;
@Mixin(ClientLevel.class) @Mixin(ClientLevel.class)
public abstract class ClientLevelMixin { 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( @Inject(
method = "doAddParticle(Lnet/minecraft/core/particles/ParticleOptions;ZZDDDDDD)V", method = "doAddParticle(Lnet/minecraft/core/particles/ParticleOptions;ZZDDDDDD)V",
at = @At("HEAD"), 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); + indexBytes + ", capacity=" + indices.capacity() + ", format=" + format);
} }
if (primitiveIndicesCount > 0 && indicesCount % primitiveIndicesCount != 0) { if (primitiveIndicesCount > 0 && indicesCount % primitiveIndicesCount != 0) {
throw new IllegalStateException(owner + ": MeshBuilder incomplete primitive payload: indices=" + indicesCount if (DEBUG) {
+ ", primitiveLength=" + primitiveIndicesCount + ", format=" + format); 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); 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()
.putFloat().putFloat().putFloat().putFloat() .putFloat().putFloat().putFloat().putFloat()
.putFloat().putFloat().putFloat().putFloat()
.get(); .get();
private static final Data DATA = new Data(); private static final Data DATA = new Data();
@ -34,7 +35,8 @@ public enum WetSurfaceUniforms {
float camX, float camY, float camZ, float camX, float camY, float camZ,
float invW, float invH, float time, boolean mainDepthEnabled, float invW, float invH, float time, boolean mainDepthEnabled,
float wetness, float puddleCoverage, float reflectionStrength, float rippleStrength, 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.projection.set(projection);
DATA.invView.set(invView); DATA.invView.set(invView);
DATA.camX = camX; DATA.camX = camX;
@ -52,6 +54,8 @@ public enum WetSurfaceUniforms {
DATA.reflectionSteps = reflectionSteps; DATA.reflectionSteps = reflectionSteps;
DATA.rainAmount = rainAmount; DATA.rainAmount = rainAmount;
DATA.ripplesEnabled = ripplesEnabled ? 1.0f : 0.0f; DATA.ripplesEnabled = ripplesEnabled ? 1.0f : 0.0f;
DATA.binarySteps = binarySteps;
DATA.darkeningFactor = darkeningFactor;
LoVisualRenderSystem.uniforms().write(UNIFORM_NAME, SIZE, EXPECTED_WRITES_PER_FRAME, DATA); 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 invW, invH, time, mainDepthEnabled;
private float wetness, puddleCoverage, reflectionStrength, rippleStrength; private float wetness, puddleCoverage, reflectionStrength, rippleStrength;
private float maxDistance, reflectionSteps, rainAmount, ripplesEnabled; private float maxDistance, reflectionSteps, rainAmount, ripplesEnabled;
private float binarySteps, darkeningFactor;
@Override @Override
public void write(java.nio.ByteBuffer buffer) { 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(invW).putFloat(invH).putFloat(time).putFloat(mainDepthEnabled);
b.putFloat(wetness).putFloat(puddleCoverage).putFloat(reflectionStrength).putFloat(rippleStrength); b.putFloat(wetness).putFloat(puddleCoverage).putFloat(reflectionStrength).putFloat(rippleStrength);
b.putFloat(maxDistance).putFloat(reflectionSteps).putFloat(rainAmount).putFloat(ripplesEnabled); b.putFloat(maxDistance).putFloat(reflectionSteps).putFloat(rainAmount).putFloat(ripplesEnabled);
b.putFloat(binarySteps).putFloat(darkeningFactor).putFloat(0.0f).putFloat(0.0f);
} }
@Override @Override

View file

@ -89,12 +89,24 @@ public final class WorldPassCompiler {
stats.skippedEmptyCommand(); stats.skippedEmptyCommand();
return; return;
} }
if (mesh.isBuilding()) mesh.end(); 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) { if (mesh.getIndicesCount() <= 0) {
stats.skippedEmptyCommand(); stats.skippedEmptyCommand();
return; return;
} }
validateWorldMeshAnchor(ctx, mesh, command); 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; GpuMeshHandle uploaded = null;
MeshBuilder drawMesh = mesh; 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.client.player.AbstractClientPlayer;
import net.minecraft.world.InteractionHand; import net.minecraft.world.InteractionHand;
import net.minecraft.world.entity.HumanoidArm; 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.features.module.modules.visuals.items.HoldMyItems;
import dev.loki.lovisual.render.helpers.util.TickDelta; import dev.loki.lovisual.render.helpers.util.TickDelta;
@ -54,7 +55,7 @@ public final class HandDynamics {
*/ */
public static boolean apply( public static boolean apply(
HoldMyItems module, HandAnimState state, AbstractClientPlayer player, 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; boolean mainHand = hand == InteractionHand.MAIN_HAND;
float bobStrength = module.walkBob.get() ? module.bobStrength.get() : 0.0f; 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; boolean usingThisHand = player.isUsingItem() && player.getUsedItemHand() == hand;
float drift = usingThisHand ? state.drift(mainHand) : 0.0f; float drift = usingThisHand ? state.drift(mainHand) : 0.0f;
boolean rightArm = (player.getMainArm() == HumanoidArm.RIGHT) == mainHand;
HandPoseMath.Pose pose = HandPoseMath.sway( HandPoseMath.Pose pose = HandPoseMath.sway(
state.sway().yawLag(), state.sway().pitchLag(), module.swayIntensity.get()); state.sway().yawLag(), state.sway().pitchLag(), module.swayIntensity.get());
@ -72,12 +74,18 @@ public final class HandDynamics {
bobStrength)); bobStrength));
} }
if (driftStrength > 0.0f && drift > 0.0f) { if (driftStrength > 0.0f && drift > 0.0f) {
boolean rightArm = (player.getMainArm() == HumanoidArm.RIGHT) == mainHand;
pose = HandPoseMath.add(pose, HandPoseMath.drift(drift, driftStrength, rightArm)); pose = HandPoseMath.add(pose, HandPoseMath.drift(drift, driftStrength, rightArm));
} }
if (module.joinAppear.get()) { if (module.joinAppear.get()) {
pose = HandPoseMath.add(pose, HandPoseMath.joinDrop(state.joinProgress())); 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)) { if (!pose.isSignificant(SIGNIFICANT_EPSILON)) {
return false; 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) { public static Pose joinDrop(float progress01) {
float p = clamp01(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.lifecycle.Modules;
import dev.loki.lovisual.features.module.modules.combat.pvp.RubberHand; 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.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.features.module.modules.visuals.effects.HitEffect;
import dev.loki.lovisual.mixins.accessors.entity.LocalPlayerAccessor; import dev.loki.lovisual.mixins.accessors.entity.LocalPlayerAccessor;
import dev.loki.lovisual.mixins.accessors.misc.MultiPlayerGameModeAccessor; import dev.loki.lovisual.mixins.accessors.misc.MultiPlayerGameModeAccessor;
@ -165,6 +166,10 @@ public enum AttackUtil {
if (hitEffect != null) { if (hitEffect != null) {
hitEffect.handleHit(target); hitEffect.handleHit(target);
} }
HitBubbles hitBubbles = Modules.get(HitBubbles.class);
if (hitBubbles != null) {
hitBubbles.handleHit(target);
}
TargetHud.notifyHit(target); TargetHud.notifyHit(target);
TargetManager.onAttack(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.config.description": "Saves, reloads, or locates the active client config.",
"command.coordinates.description": "Shows the current player coordinates.", "command.coordinates.description": "Shows the current player coordinates.",
"command.enemy.description": "Manages the client enemy list.", "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.friend.description": "Manages the client friend list.",
"command.help.description": "Shows every available client command with clickable pages. Prefix: % (legacy @ also works).", "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.", "command.hide.description": "Hides or shows a module in ModuleList without disabling it.",

View file

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

View file

@ -19,6 +19,7 @@ layout (std140) uniform WetSurface {
vec4 u_Screen; // invW, invH, time, mainDepthEnabled vec4 u_Screen; // invW, invH, time, mainDepthEnabled
vec4 u_Params; // wetness, puddleCoverage, reflectionStrength, rippleStrength vec4 u_Params; // wetness, puddleCoverage, reflectionStrength, rippleStrength
vec4 u_Params2; // maxDistance, reflectionSteps, rainAmount, ripplesEnabled vec4 u_Params2; // maxDistance, reflectionSteps, rainAmount, ripplesEnabled
vec4 u_Params3; // binarySteps, darkeningFactor, unused, unused
}; };
in vec2 v_TexCoord; in vec2 v_TexCoord;
@ -36,12 +37,12 @@ vec3 viewPositionFromDepth(vec2 uv, float rawDepth) {
return view.xyz / view.w; 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); vec4 clip = u_Projection * vec4(viewPos, 1.0);
if (clip.w <= 0.0001) return false; if (clip.w <= 0.0001) return false;
vec3 ndc = clip.xyz / clip.w; vec3 ndc = clip.xyz / clip.w;
uv = ndc.xy * 0.5 + 0.5; 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; 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); 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 puddleNoise(vec2 worldXZ) {
float n = valueNoise(worldXZ * 0.18) * 0.65 return valueNoise(worldXZ * 0.18) * 0.65 + valueNoise(worldXZ * 0.45 + 17.0) * 0.35;
+ 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; return n;
} }
@ -82,18 +134,12 @@ void main() {
} }
vec3 viewPos = viewPositionFromDepth(uv, rawDepth); vec3 viewPos = viewPositionFromDepth(uv, rawDepth);
vec3 viewNormal = normalFromDepth(uv, viewPos);
vec3 ddxPos = dFdx(viewPos);
vec3 ddyPos = dFdy(viewPos);
vec3 viewNormal = normalize(cross(ddxPos, ddyPos));
vec3 viewDir = normalize(viewPos); vec3 viewDir = normalize(viewPos);
if (dot(viewNormal, -viewDir) < 0.0) {
viewNormal = -viewNormal;
}
vec3 worldNormal = normalize(mat3(u_InvView) * viewNormal); vec3 worldNormal = normalize(mat3(u_InvView) * viewNormal);
float groundFactor = clamp(dot(worldNormal, vec3(0.0, 1.0, 0.0)), 0.0, 1.0); 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) { if (groundFactor <= 0.001) {
color = base; color = base;
return; return;
@ -106,9 +152,11 @@ void main() {
float reflectionStrength = clamp(u_Params.z, 0.0, 2.0); float reflectionStrength = clamp(u_Params.z, 0.0, 2.0);
float rippleStrength = clamp(u_Params.w, 0.0, 2.0); float rippleStrength = clamp(u_Params.w, 0.0, 2.0);
float maxDistance = max(u_Params2.x, 1.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); float rainAmount = clamp(u_Params2.z, 0.0, 2.0);
bool ripplesEnabled = u_Params2.w > 0.5; 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 noise = puddleNoise(worldPos.xz);
float wetMask = smoothstep(0.5 - puddleCoverage * 0.55, 0.5 + puddleCoverage * 0.35, noise); float wetMask = smoothstep(0.5 - puddleCoverage * 0.55, 0.5 + puddleCoverage * 0.35, noise);
@ -118,48 +166,90 @@ void main() {
return; return;
} }
vec3 reflNormal = viewNormal; vec3 reflNormal = worldNormal;
if (ripplesEnabled && rippleStrength > 0.001) { if (ripplesEnabled && rippleStrength > 0.001) {
// Аналитический градиент бегущей волны вместо мёртвого dFdx(ripple)*0.0 из старой версии
// (там перемножение на 0 полностью убивало рябь) — считаем волну в двух соседних точках
// и берём разность как наклон поверхности, а не саму высоту волны.
float t = u_Screen.z; float t = u_Screen.z;
float ripple = sin((worldPos.x + worldPos.z) * 3.1 + t * 2.4) * 0.5 float eps = 0.35;
+ sin((worldPos.x - worldPos.z * 1.7) * 4.3 - t * 3.1) * 0.5; vec2 wp = worldPos.xz;
vec2 perturb = vec2(dFdx(ripple), dFdy(ripple)) * 0.0; 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;
reflNormal = normalize(viewNormal + vec3(ripple, 0.0, ripple * 0.6) * rippleStrength * 0.05); 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; 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); for (int i = 0; i < 96; i++) {
vec3 marchPos = viewPos + reflNormal * 0.02;
for (int i = 0; i < 32; i++) {
if (i >= steps) break; if (i >= steps) break;
marchPos += reflDir * stepLen; float t = (float(i) + dither) * stepLen + stepLen * 0.5;
vec3 marchPos = rayOrigin + reflDir * t;
vec2 sampleUv; vec2 sampleUv;
float sampleClipDepth; float sampleNdcZ;
if (!projectToScreen(marchPos, sampleUv, sampleClipDepth)) break; if (!projectToScreen(marchPos, sampleUv, sampleNdcZ)) break;
float sceneRawDepth = texture(u_MainDepth, sampleUv).r; float sceneRawDepth = texture(u_MainDepth, sampleUv).r;
if (!validRawDepth(sceneRawDepth)) continue; if (!validRawDepth(sceneRawDepth)) continue;
vec3 scenePos = viewPositionFromDepth(sampleUv, sceneRawDepth); vec3 scenePos = viewPositionFromDepth(sampleUv, sceneRawDepth);
float marchDist = length(marchPos); float marchDist = length(marchPos);
float sceneDist = length(scenePos); float sceneDist = length(scenePos);
float thickness = max(0.15, marchDist * 0.03); float thickness = max(0.15, marchDist * 0.03);
if (marchDist > sceneDist && marchDist - sceneDist < thickness) { 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; hit = true;
break; 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); fresnel = mix(0.25, 1.0, fresnel);
float hitBoost = hit ? 1.0 : 0.55; 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; * mix(0.6, 1.0, clamp(rainAmount, 0.0, 1.0)) * wetness;
reflectionAmount = clamp(reflectionAmount, 0.0, 1.0); 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); vec3 result = mix(darkened, reflectionColor, reflectionAmount);
color = vec4(clamp(result, 0.0, 1.0), 1.0); color = vec4(clamp(result, 0.0, 1.0), 1.0);

View file

@ -140,6 +140,7 @@
"network.game.PlayerInfoMixin", "network.game.PlayerInfoMixin",
"network.packet.ClientCommonPacketListenerImplMixin", "network.packet.ClientCommonPacketListenerImplMixin",
"network.packet.ServerboundUseItemPacketMixin", "network.packet.ServerboundUseItemPacketMixin",
"render.entity.EndCrystalRendererMixin",
"render.entity.EntityRenderDispatcherMixin", "render.entity.EntityRenderDispatcherMixin",
"render.entity.ItemEntityRenderStateMixin", "render.entity.ItemEntityRenderStateMixin",
"render.entity.ItemEntityRendererMixin", "render.entity.ItemEntityRendererMixin",
@ -154,6 +155,7 @@
"render.item.ItemInHandAlwaysVisibleMixin", "render.item.ItemInHandAlwaysVisibleMixin",
"render.item.ItemInHandRendererMixin", "render.item.ItemInHandRendererMixin",
"render.level.CameraMixin", "render.level.CameraMixin",
"render.level.FireworkParticlesStarterMixin",
"render.level.LevelRendererMixin", "render.level.LevelRendererMixin",
"render.level.ParticleManagerMixin", "render.level.ParticleManagerMixin",
"render.level.SkyRendererMixin", "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)); 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 @Test
void significanceThresholdWorks() { void significanceThresholdWorks() {
assertFalse(HandPoseMath.ZERO.isSignificant(1.0e-4f)); 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);
}
}