diff --git a/TODO.md b/TODO.md index 2580046..7d005ea 100644 --- a/TODO.md +++ b/TODO.md @@ -48,18 +48,34 @@ контейнера с настройкой цвета/видимости, БЕЗ увеличения хитбокса (увеличение — чит, удалён в Фазе 3.5). Референс: `visuals/Hitboxes.java` - [x] **Watermark** (HUD, draggable) — обещан в README, в коде отсутствует -- [ ] **Gps** (world) — луч/маркер направления к точке (у Soup ~400 стр.); связка - с Xaero-waypoints и координатами. Референс: `world/Gps.java` +- [x] **Gps** (world) — ЗАВЕРШЕНО 2026-09-15: компас-стрелка + маркер к точке + (`visuals/world/Gps.java`, `util/world/GpsBearing.java` чистая математика + тесты, + команда `%gps | clear`). Референс: `world/Gps.java` — идея взята + (стрелка+маркер+дистанция+авто-удаление), код не копировался. Xaero-связка НЕ + сделана (была опциональной идеей, не в скоупе запроса). Коммит fb0b288. - [x] **Light** (world) — кастомный цвет освещения (у Soup 41 стр.). Референс: `world/Light.java` -- [ ] **HitBubbles** (particles) — пузыри при ударах по игрокам (у Soup 323 стр.). - Референс: `particles/HitBubbles.java` -- [ ] **AmbientParticles + FireFlyParticle** (particles) — атмосферные частицы мира / - светлячки (у Soup 775 + 241 стр.). Референс: `particles/AmbientParticles.java`, - `particles/types/FireFlyParticle.java` +- [x] **HitBubbles** (particles) — ЗАВЕРШЕНО 2026-09-15: отдельный модуль + `visuals/effects/HitBubbles.java` (не режим HitEffect.BUBBLES — независимое включение, + трёхфазная анимация появление/жизнь/исчезновение через новый `ThreeStageProgress`). + Заодно вынесены общие хелперы из HitEffect (DRY): `AttackHitPoint`, + `OrientedQuadRenderer`, `AnimatedColorModeOptions`. Референс: `particles/HitBubbles.java` + (идея взята, код не копировался; интерполяции/множественные текстуры Soup не портированы). + Коммит 5f2fc70. +- [x] **AmbientParticles + FireFlyParticle** (particles) — ЗАВЕРШЕНО 2026-09-15: модуль + `visuals/particles/AmbientParticles.java`, два режима — Ambient (плывущие мошки) и + Firefly (блуждание + кольцевой буфер шлейфа вместо Trail-объектов референса). 19 + визуальных стилей референса (Stars/Hearts/Bloom/.../Pyramid, asset-тяжёлые) НЕ + портированы — фича-крип чит-клиента вне направления проекта; цвет через уже + существующий AnimatedRenderColors вместо своей Sync/Custom-системы. Коммит 28ec166. - [x] **NameProtect** (misc) — подмена своего ника и ников друзей в тексте чата/скорборда (у Soup 42 стр.). Референс: `other/NameProtect.java` -- [ ] **EndCrystal** (visuals) — улучшенный рендер энд-кристаллов (у Soup 217 стр.). - Референс: `other/EndCrystal.java` +- [x] **EndCrystal** (visuals) — ЗАВЕРШЕНО 2026-09-15: `visuals/scene/EndCrystal.java` + + `mixins/render/entity/EndCrystalRendererMixin.java` (WrapOperation на ModelPart.render, + различает 2 вызова glass + 1 cube счётчиком-ordinal — тонировка/скрытие частей) + + inject в `ClientLevelMixin.removeEntity` (осколки-вспышка на месте разрушения, т.к. + ваниль убирает сущность мгновенно). Референс: `other/EndCrystal.java` — идея взята + (кастомный цвет, "доиграть" разрушение), Sync/Custom×4-цвета+Wave/Vertex упрощены до + общего AnimatedRenderColors. Коммит 50db8e3. - [ ] **Trinket 2.0** — апгрейд нашего CosmeticDisplay до уровня Soup Trinket: иконки-спутники (Atom/Ball/Cat/Donut/Flame/Flower/Gem/Ghost/MCTiers), трейл (цвет sync/custom, длина, толщина), звуки (типы + громкость), частицы diff --git a/combatant-client-26.2/TODO.md b/combatant-client-26.2/TODO.md index cd54f0c..772af0f 100644 --- a/combatant-client-26.2/TODO.md +++ b/combatant-client-26.2/TODO.md @@ -409,3 +409,85 @@ (N = проходов за кадр). - [ ] `./gradlew build` после каждого пункта; `./gradlew test` зелёный. - [ ] Порядок: 9.1 (по одному пункту на коммит) → 9.2. + +## Фаза 10: Заход по референсам из Downloads (2026-09-15) +> Пользователь скинул 5 архивов, распакованы в `/tmp/lv_refs/`. Порядок выполнения — +> сверху вниз, по одному коммиту на пункт, `./gradlew build`+`test` после каждого. + +- [x] **TrollfaceMask — почини "не работает"** (`/tmp/lv_refs/trollface/TrollfaceMask.java`, + референс Moonward 1.21.4). Наш модуль: `trollface/TrollfaceMask.java`. + Причина бага: биллборд ориентировался к камере (headPos→camPos); от первого лица + камера почти совпадает по вертикали с головой → view≈(0,1,0) → right=view×up + вырождается в 0 → плоскость ложится горизонтально, не видна. Режимы «Друзей»/«Всех» + брали только `TargetManager.getTarget()` (сущность под прицелом) — маска рисовалась + максимум на одном игроке. Фикс: ориентация по `getViewVector()` цели вместо камеры, + перебор `mc.level.players()` с фильтром по режиму (себя/friend via PlayerRelations/все). + Коммит e7e0ff2. +- [x] **Rain — сравнение с двумя референсами (2026-09-15, вывод: у нас уже паритет/лучше).** + Референс 1 `/tmp/lv_refs/rainfull/` (Cataclysm client) — наш `Rain.java` (894 строки) уже + является портом этого самого класса (Drop/Splash/Droplet/Mist, presetDensity/Wind/Mass, + drawStreaks/Ripples/Crowns/Droplets/Mist — 1:1 совпадающие константы и формулы), ПЛЮС у нас + уже есть их wet-surface screen-space постфх (`WetSurfaceUniforms` + шейдер + `assets/lovisual/shaders/wet_surface.frag`, pipeline `WET_SURFACE`, реализован как + `PostProcessPass` в самом Rain.java) — то, что в референсе вынесено в отдельный + `WetSurfaceRenderer`. Улучшать нечего, разница только в деталях реализации пайплайна. + Референс 2 `/tmp/lv_refs/mokriyzhir/` (`WetWorld.java`, пакет `ez.redux...`) — это ОТДЕЛЬНЫЙ + самостоятельный модуль "Wet World" из чужого чит-клиента (Category.VISUALS), НЕ часть дождя: + мочит поверхности всего мира по реальной погоде сервера (`world.getRainGradient`), с настройками + Quality (Low/Medium/High/Ultra), Reflection Strength, Wet Darkening, Ripples+Speed, Rain Only, + учитывает fog (интеграция с их модулем Fog: цвет/дистанция/яркость подмешивается в шейдер). + У нас такого отдельного всегда-активного "мир мокрый по факту дождя" модуля нет — наш + wet-surface жёстко завязан на модуль Rain (работает только когда сам Rain включён). Если + захочется — можно вынести wet-surface-pass Rain.java в отдельный модуль WetWorld с привязкой + к `mc.level.isRaining()`+`getRainLevel(tickDelta)` вместо `onlyWhenRaining` галки, плюс quality- + пресет и fog-интеграцию. НЕ делаю сейчас — не запрошено явно, чисто идея на будущее. +- [x] **WetWorld — сделан отдельным модулем (2026-09-15), шейдер Rain пофикшен.** + Третий референс `/tmp/lv_refs/wetworld2/` (`ru.white...WetWorld` + `WetWorldPipeline` + + `wet_world.vsh/fsh`) оказался технически лучшим из трёх: настоящий SSR с бинарным + рефайнментом пересечения, dithering шага луча, реконструкция нормали по соседям в буфере + глубины (не screen-space derivative), fbm-небо с sun specular, fog-интеграция. + Проверка нашего `wet_surface.frag` («может кривые») подтвердила баги: + 1) `normalFromDepth` считался через `dFdx/dFdy(viewPos)` — рвётся на границах глубины, + шумная/блочная нормаль -> дрожащий край лужи; + 2) рябь была МЁРТВЫМ КОДОМ: `vec2 perturb = vec2(dFdx(ripple), dFdy(ripple)) * 0.0;` — + умножение на 0 полностью гасило эффект, а сама рябь добавлялась как сырое значение + волны вместо её наклона. + Исправлено (коммит 0986ce6): нормаль — по 4 соседям depth-буфера (как в лучшем референсе), + рябь — аналитический градиент (разность высоты в соседних точках), добавлен bin-рефайнмент + SSR-пересечения + dithering, небо дополнено fbm-облаками/sun specular, затемнение вынесено + в отдельный uniform-параметр (было зашито константой 0.82). Новый модуль + `visuals/weather/WetWorld.java`: мочит мир по факту `mc.level.getRainLevel()`, независимо + от партиклов Rain и тумблера `wet_ground` — Quality (Low/Medium/High/Ultra + -> шаги/дистанция/bin-рефайнмент SSR), Reflection Strength, Darkening, Ripples+Speed, + Rain Only. Переиспользует общий `WET_SURFACE` pipeline/`WetSurfaceUniforms` (при + одновременном включении с Rain.wet_ground эффект стекается — задокументировано в коде). + Fog-интеграция референса НЕ перенесена (нет доступа к чужому Fog-модулю/данным тумана в + нашем движке в этом заходе) — можно добавить отдельно, если понадобится. +- [x] **HitEffect — интегрирован ImpactRing (2026-09-15).** + Референс `/tmp/lv_refs/myasomaybe/ImpactRing/` — `JumpWaveRenderer`/`JumpGlowRenderer` + используют захват сцены + собственный низкоуровневый RHI-пайплайн с SSR-варпом + (кастомный шейдер сэмплит скопированный цветовой буфер по кольцу) — тяжеловесная техника + не в духе нашего движка. Взята идея визуала (кольцо + вертикальная стена свечения в точке + удара), реализована через уже существующий движок: обычные аддитивные квады через + MeshBuilder/Renderer3D (`WORLD_COLORED_ADDITIVE` + `WORLD_COLORED_ADDITIVE_DEPTH`), без + захвата сцены и отдельного шейдера — консистентно с уже существующими WaveEffect/HitBubble + в том же файле. Новый `EffectMode.IMPACT` в HitEffect: настройки impact_radius/ + impact_duration/impact_glow_height, срабатывает как WAVE (по удару), рендер — + расширяющееся кольцо (сегменты, fade к центру) + полупрозрачная вертикальная стена по + контуру круга, затухающая вверх и во времени. Коммит 61c478e. `./gradlew build`+`test` ✓. +- [x] **HoldMyItems — сверка с HMI.zip (2026-09-15), портирован grip.** + Референс `/tmp/lv_refs/hmi/` (Delta HMI): обёртка обфусцированного чит-клиента над + СТОРОННИМ jar-модом `holdmyitems-5.1.1+26.2.jar` + свои `SwingAnimations` (десяток стилей + свинга, weapon-inspect a-la CS:GO karambit-spin, "Pretty Hands" режимы). Полный набор + НЕ портирован — фича-крип чит-клиента, конфликтует с KISS и с правилом «без стороннего + jar в сборке» (Delta буквально бандлит чужой мод и синхронизирует с ним настройки через + `OriginalHmiBridge`). Взята единственная самоценная и самодостаточная идея — **grip**: + лёгкий хват предметов с рукоятью (мечи/инструменты/трезубец/палки — см. + `heldAlongTheFist`) наискось в кулаке вместо строго вертикального ванильного equip. + Портировано (коммит b257fde): `HeldAlongFist` (чистый предикат по ItemTags), + `HandPoseMath.grip(...)` (чистая функция позы, тесты gripMirrorsBySide/ + gripExtendPullsCloserAndAcrossFist), настройки `grip_enabled`+6 slider'ов в HoldMyItems + (дефолты как у референсного пресета "HMI"), проброс ItemStack через + HandDynamics.apply/ItemInHandRendererMixin. `./gradlew build`+`test` зелёные. + +### Проверка после каждого пункта: `./gradlew build` ✓, `./gradlew test` ✓, TODO.md отмечен diff --git a/combatant-client-26.2/src/main/java/dev/loki/lovisual/features/command/impl/world/GpsCommand.java b/combatant-client-26.2/src/main/java/dev/loki/lovisual/features/command/impl/world/GpsCommand.java new file mode 100644 index 0000000..38601a4 --- /dev/null +++ b/combatant-client-26.2/src/main/java/dev/loki/lovisual/features/command/impl/world/GpsCommand.java @@ -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 | %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 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; + } + } +} diff --git a/combatant-client-26.2/src/main/java/dev/loki/lovisual/features/gui/hud/draggable/impl/targethud/render/parts/TargetHudFaceRenderer.java b/combatant-client-26.2/src/main/java/dev/loki/lovisual/features/gui/hud/draggable/impl/targethud/render/parts/TargetHudFaceRenderer.java index 2b3b5e3..5419a28 100644 --- a/combatant-client-26.2/src/main/java/dev/loki/lovisual/features/gui/hud/draggable/impl/targethud/render/parts/TargetHudFaceRenderer.java +++ b/combatant-client-26.2/src/main/java/dev/loki/lovisual/features/gui/hud/draggable/impl/targethud/render/parts/TargetHudFaceRenderer.java @@ -19,13 +19,18 @@ import dev.loki.lovisual.util.player.health.PlayerSkinResolver; import dev.loki.lovisual.util.pvp.opponents.TotemPopSnapshot; import net.minecraft.client.Minecraft; import net.minecraft.client.gui.GuiGraphicsExtractor; +import net.minecraft.core.Holder; import net.minecraft.resources.Identifier; +import net.minecraft.world.entity.EntityType; import net.minecraft.world.entity.LivingEntity; import net.minecraft.world.entity.player.Player; +import net.minecraft.world.item.Item; import net.minecraft.world.item.ItemStack; import net.minecraft.world.item.Items; +import net.minecraft.world.item.SpawnEggItem; import java.util.Locale; +import java.util.Optional; import java.util.UUID; import static dev.loki.lovisual.features.gui.hud.draggable.impl.targethud.TargetHud.*; @@ -127,6 +132,18 @@ public final class TargetHudFaceRenderer { HudRenderUtil.scaleAlpha(element.uiStroke, alphaFactor) ); + ItemStack mobIcon = resolveMobIcon(target); + if (mobIcon != null && !mobIcon.isEmpty()) { + float iconSize = size * 0.72f; + float itemScale = Math.max(0.35f, iconSize / 16.0f); + float ix = x + (size - iconSize) * 0.5f; + float iy = y + (size - iconSize) * 0.5f; + // Slight hurt tint via background already; keep icon full alpha + TargetHudEquipmentRenderer.drawItem(renderer, mobIcon, ix, iy, itemScale, 0, alphaFactor); + // Add tiny hurt overlay as text shadow alternative is already via tint + return; + } + String name = target.getName().getString(); String letter = name.isEmpty() ? "?" : name.substring(0, 1).toUpperCase(Locale.ROOT); TextRenderer faceRenderer = Fonts.renderer("Inter", FontInfo.Type.Bold, textRenderer); @@ -150,6 +167,29 @@ public final class TargetHudFaceRenderer { } } + private static ItemStack resolveMobIcon(LivingEntity target) { + if (target == null) return null; + try { + EntityType type = target.getType(); + Optional> eggHolder = SpawnEggItem.byId(type); + if (eggHolder.isPresent()) { + return new ItemStack(eggHolder.get().value()); + } + } catch (Throwable ignored) { + } + // Fallback skulls / heads for bosses and special mobs without spawn eggs + try { + String id = net.minecraft.core.registries.BuiltInRegistries.ENTITY_TYPE.getKey(target.getType()).toString(); + if (id.contains("ender_dragon")) return new ItemStack(Items.DRAGON_HEAD); + if (id.contains("wither")) return new ItemStack(Items.WITHER_SKELETON_SKULL); + if (id.contains("iron_golem")) return new ItemStack(Items.IRON_BLOCK); + if (id.contains("snow_golem")) return new ItemStack(Items.SNOW_BLOCK); + if (id.contains("giant")) return new ItemStack(Items.ZOMBIE_HEAD); + } catch (Throwable ignored) { + } + return null; + } + public static void drawTotemPopBadge(TargetHud element, Renderer2D renderer, TextRenderer textRenderer, TotemPopSnapshot snapshot, float faceX, float faceY, float faceSize, float faceRadius, float scaleFactor, float alphaFactor) { if (snapshot == null || snapshot.count() <= 0 || element.totemPopAnimation <= 0.001f) { return; diff --git a/combatant-client-26.2/src/main/java/dev/loki/lovisual/features/module/modules/visuals/effects/HitBubbles.java b/combatant-client-26.2/src/main/java/dev/loki/lovisual/features/module/modules/visuals/effects/HitBubbles.java new file mode 100644 index 0000000..94918a9 --- /dev/null +++ b/combatant-client-26.2/src/main/java/dev/loki/lovisual/features/module/modules/visuals/effects/HitBubbles.java @@ -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 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 bubbleScale = num("hitBubblesScale2", "scale", 1.0f, 0.4f, 2.0f); + private final NumberValue rotateSpeed = num("hitBubblesRotateSpeed", "rotate_speed", 2.0f, 0.5f, 5.0f); + private final NumberValue appearMs = num("hitBubblesAppearMs", "appear_duration_ms", 150, 20, 1500); + private final NumberValue existMs = num("hitBubblesExistMs", "exist_duration_ms", 300, 20, 2000); + private final NumberValue 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 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; + } + } +} diff --git a/combatant-client-26.2/src/main/java/dev/loki/lovisual/features/module/modules/visuals/effects/HitEffect.java b/combatant-client-26.2/src/main/java/dev/loki/lovisual/features/module/modules/visuals/effects/HitEffect.java index 463efb9..af3d71a 100644 --- a/combatant-client-26.2/src/main/java/dev/loki/lovisual/features/module/modules/visuals/effects/HitEffect.java +++ b/combatant-client-26.2/src/main/java/dev/loki/lovisual/features/module/modules/visuals/effects/HitEffect.java @@ -22,10 +22,7 @@ import net.minecraft.util.Mth; import net.minecraft.world.entity.Entity; import net.minecraft.world.entity.LivingEntity; import net.minecraft.world.entity.player.Player; -import net.minecraft.world.entity.projectile.ProjectileUtil; import net.minecraft.world.level.block.state.BlockState; -import net.minecraft.world.phys.AABB; -import net.minecraft.world.phys.EntityHitResult; import net.minecraft.world.phys.Vec3; import net.minecraft.world.phys.shapes.VoxelShape; import org.joml.Quaternionf; @@ -39,13 +36,16 @@ import dev.loki.lovisual.mixininterface.entity.ILocalPlayer; import dev.loki.lovisual.mixininterface.entity.IPlayerAttackCooldown; import dev.loki.lovisual.render.engine.RenderState; import dev.loki.lovisual.render.engine.texture.TextureStorage; +import dev.loki.lovisual.render.engine.animation.AnimatedColorModeOptions; import dev.loki.lovisual.render.engine.animation.AnimatedRenderColors; import dev.loki.lovisual.render.engine.color.RenderColor; import dev.loki.lovisual.render.engine.pipeline.LoVisualRenderPipelines; import dev.loki.lovisual.render.engine.renderer.Renderer3D; import dev.loki.lovisual.render.engine.uniform.MeshBuilder; +import dev.loki.lovisual.render.helpers.particle.OrientedQuadRenderer; import dev.loki.lovisual.render.helpers.player.Particle3D; import dev.loki.lovisual.render.helpers.util.ParticleTextureMode; +import dev.loki.lovisual.util.combat.hit.AttackHitPoint; import dev.loki.lovisual.util.time.Timer; import java.awt.*; @@ -72,6 +72,10 @@ public class HitEffect extends Module { private static final String SETTING_COLOR2 = "color2"; private static final String SETTING_WAVE_RADIUS = "wave_radius"; private static final String SETTING_WAVE_DURATION = "wave_duration"; + private static final String SETTING_IMPACT_RADIUS = "impact_radius"; + private static final String SETTING_IMPACT_DURATION = "impact_duration"; + private static final String SETTING_IMPACT_GLOW_HEIGHT = "impact_glow_height"; + private static final int IMPACT_RING_SEGMENTS = 24; private static final String SETTING_HIT_PARTICLES = "hit_particles"; private static final String SETTING_HIT_PARTICLE_COUNT = "hit_particle_count"; private static final String SETTING_HIT_PARTICLE_SIZE = "hit_particle_size"; @@ -122,6 +126,12 @@ public class HitEffect extends Module { visibleWhen(num("hitBubblesWaveRadius", SETTING_WAVE_RADIUS, 12, 3, 32), this::isWaveMode); private final NumberValue waveDurationMs = visibleWhen(num("hitBubblesWaveDuration", SETTING_WAVE_DURATION, 1500, 200, 6000), this::isWaveMode); + private final NumberValue impactRadius = + visibleWhen(num("hitBubblesImpactRadius", SETTING_IMPACT_RADIUS, 1.4f, 0.4f, 4.0f), this::isImpactMode); + private final NumberValue impactDurationMs = + visibleWhen(num("hitBubblesImpactDuration", SETTING_IMPACT_DURATION, 450, 150, 1500), this::isImpactMode); + private final NumberValue impactGlowHeight = + visibleWhen(num("hitBubblesImpactGlowHeight", SETTING_IMPACT_GLOW_HEIGHT, 0.8f, 0.2f, 2.5f), this::isImpactMode); private final BooleanValue hitParticlesEnabled = bool("hitEffectParticlesEnabled", SETTING_HIT_PARTICLES, false); private final NumberValue hitParticleCount = @@ -138,6 +148,7 @@ public class HitEffect extends Module { private final List bubbles = new ArrayList<>(); private final List stars = new ArrayList<>(); private final List waves = new ArrayList<>(); + private final List impacts = new ArrayList<>(); private final List hitParticles = new ArrayList<>(); private final List[] hitParticleBuckets = createBuckets(); private int lastHitParticleTick = Integer.MIN_VALUE; @@ -165,44 +176,6 @@ public class HitEffect extends Module { return (na << 24) | (argb & 0x00FFFFFF); } - private static void addOrientedQuad(MeshBuilder mesh, Vec3 center, float yaw, float pitch, - float spinDeg, float scale, int c1, int c2, int c3, int c4) { - Quaternionf rot = new Quaternionf() - .rotateY((float) Math.toRadians(yaw)) - .rotateX((float) Math.toRadians(pitch)); - - Vector3f forward = new Vector3f(0, 0, 1).rotate(rot); - Vector3f right = new Vector3f(1, 0, 0).rotate(rot); - Vector3f up = new Vector3f(0, 1, 0).rotate(rot); - - Quaternionf spin = new Quaternionf().fromAxisAngleRad(forward, (float) Math.toRadians(spinDeg)); - right.rotate(spin); - up.rotate(spin); - - right.mul(scale / 2f); - up.mul(scale / 2f); - - double p1x = center.x - right.x() - up.x(); - double p1y = center.y - right.y() - up.y(); - double p1z = center.z - right.z() - up.z(); - double p2x = center.x + right.x() - up.x(); - double p2y = center.y + right.y() - up.y(); - double p2z = center.z + right.z() - up.z(); - double p3x = center.x + right.x() + up.x(); - double p3y = center.y + right.y() + up.y(); - double p3z = center.z + right.z() + up.z(); - double p4x = center.x - right.x() + up.x(); - double p4y = center.y - right.y() + up.y(); - double p4z = center.z - right.z() + up.z(); - - mesh.ensureQuadCapacity(); - int i1 = mesh.vec3(p1x, p1y, p1z).vec2(0, 1).color(new RenderColor(c1)).next(); - int i2 = mesh.vec3(p2x, p2y, p2z).vec2(1, 1).color(new RenderColor(c2)).next(); - int i3 = mesh.vec3(p3x, p3y, p3z).vec2(1, 0).color(new RenderColor(c3)).next(); - int i4 = mesh.vec3(p4x, p4y, p4z).vec2(0, 0).color(new RenderColor(c4)).next(); - mesh.quad(i1, i2, i3, i4); - } - private static void addBillboardQuad(MeshBuilder mesh, double cx, double cy, double cz, float size, Quaternionf camRot, int argb) { Vector3f right = new Vector3f(1, 0, 0).rotate(camRot).mul(size); @@ -274,6 +247,7 @@ public class HitEffect extends Module { bubbles.clear(); stars.clear(); waves.clear(); + impacts.clear(); hitParticles.clear(); lastHitParticleTick = Integer.MIN_VALUE; spawnedHitParticlesThisTick = 0; @@ -289,13 +263,14 @@ public class HitEffect extends Module { Vec3 pos = target.position(); BlockPos basePos = BlockPos.containing(pos.x, pos.y - 0.1, pos.z); addWave(basePos); + } else if (effectMode.get() == EffectMode.IMPACT) { + impacts.add(new ImpactRing(target.position(), GameClock.millis())); } else { ILocalPlayer access = (ILocalPlayer) mc.player; float yaw = access.lovisual$getLastYaw(); float pitch = access.lovisual$getLastPitch(); - double distance = mc.player.distanceTo(target) + 1.0; - Vec3 point = getRtxPoint(target, yaw, pitch, distance); + Vec3 point = AttackHitPoint.resolve(target, yaw, pitch); if (point == null) point = target.position(); bubbles.add(new HitBubble(point, -yaw, pitch, new Timer())); @@ -343,10 +318,28 @@ public class HitEffect extends Module { renderWaveEffect(renderer); return; } + if (effectMode.get() == EffectMode.IMPACT) { + renderImpactEffect(renderer); + return; + } renderBubbleEffect(renderer, tickDelta); } + private void renderImpactEffect(Renderer3D renderer) { + if (impacts.isEmpty()) return; + + Iterator iterator = impacts.iterator(); + while (iterator.hasNext()) { + ImpactRing impact = iterator.next(); + if (impact.isExpired()) { + iterator.remove(); + continue; + } + impact.render(renderer); + } + } + private void renderBubbleEffect(Renderer3D renderer, float tickDelta) { boolean useDepth = depthTest.get(); RenderPipeline pipeline = useDepth @@ -371,7 +364,7 @@ public class HitEffect extends Module { int c3 = applyOpacity(getColorArgb(180), 1f - factor); int c4 = applyOpacity(getColorArgb(90), 1f - factor); - addOrientedQuad(mesh, b.pos, b.yaw, b.pitch, angle, scale, c1, c2, c3, c4); + OrientedQuadRenderer.addOrientedQuad(mesh, b.pos, b.yaw, b.pitch, angle, scale, c1, c2, c3, c4); } bubbles.removeIf(b -> b.life.passedMs(lifeTime.get() * 50L)); @@ -553,6 +546,10 @@ public class HitEffect extends Module { return effectMode.get() == EffectMode.WAVE; } + private boolean isImpactMode() { + return effectMode.get() == EffectMode.IMPACT; + } + private boolean usesSecondaryColor() { return AnimatedRenderColors.usesSecondary(animatedColorMode()); } @@ -568,16 +565,7 @@ public class HitEffect extends Module { } private AnimatedRenderColors.Mode animatedColorMode() { - return switch (colorMode.get()) { - case "Rainbow" -> AnimatedRenderColors.Mode.RAINBOW; - case "LightRainbow" -> AnimatedRenderColors.Mode.LIGHT_RAINBOW; - case "Sky" -> AnimatedRenderColors.Mode.SKY; - case "Fade" -> AnimatedRenderColors.Mode.FADE; - case "DoubleColor" -> AnimatedRenderColors.Mode.DOUBLE_COLOR; - case "Analogous" -> AnimatedRenderColors.Mode.ANALOGOUS; - case "Theme" -> AnimatedRenderColors.Mode.THEME; - default -> AnimatedRenderColors.Mode.STATIC; - }; + return AnimatedColorModeOptions.parse(colorMode.get()); } private boolean isCritical(Player attacker, float cooldown) { @@ -607,40 +595,10 @@ public class HitEffect extends Module { } } - private Vec3 getRtxPoint(Entity target, float yaw, float pitch, double distance) { - Minecraft mc = Minecraft.getInstance(); - if (mc.player == null) return null; - - Vec3 start = mc.player.position().add(0, mc.player.getEyeHeight(mc.player.getPose()), 0); - Vec3 rot = getRotationVector(pitch, yaw); - Vec3 end = start.add(rot.scale(distance)); - AABB box = mc.player.getBoundingBox().expandTowards(rot.scale(distance)).inflate(1.0, 1.0, 1.0); - - EntityHitResult hit = ProjectileUtil.getEntityHitResult( - mc.player, - start, - end, - box, - e -> !e.isSpectator() && e.isPickable() && e == target, - distance * distance - ); - - return hit != null ? hit.getLocation() : null; - } - - private Vec3 getRotationVector(float pitch, float yaw) { - float yawRad = -yaw * ((float) Math.PI / 180F); - float pitchRad = pitch * ((float) Math.PI / 180F); - float cosYaw = Mth.cos(yawRad); - float sinYaw = Mth.sin(yawRad); - float cosPitch = Mth.cos(pitchRad); - float sinPitch = Mth.sin(pitchRad); - return new Vec3(sinYaw * cosPitch, -sinPitch, cosYaw * cosPitch); - } - public enum EffectMode { BUBBLES, - WAVE + WAVE, + IMPACT } private record ParticleTexture(Identifier texture, int index) { @@ -772,4 +730,96 @@ public class HitEffect extends Module { return null; } } + + /** + * Кольцевая волна + вертикальная "стена" свечения в точке удара — реализация + * идеи ImpactRing (JumpWaveRenderer/JumpGlowRenderer, экранный SSR-варп в + * референсе): у нас проще, обычными аддитивными квадами через MeshBuilder, + * без захвата сцены — консистентно с остальными эффектами файла. + */ + private class ImpactRing { + private final Vec3 center; + private final long startTime; + + private ImpactRing(Vec3 center, long startTime) { + this.center = center; + this.startTime = startTime; + } + + private boolean isExpired() { + return GameClock.millis() - startTime > Math.max(1L, impactDurationMs.get()); + } + + private void render(Renderer3D renderer) { + long durationMs = Math.max(1L, impactDurationMs.get()); + long elapsed = GameClock.millis() - startTime; + float progress = Mth.clamp((float) elapsed / durationMs, 0f, 1f); + float fade = (float) Math.pow(1.0 - progress, 1.4); + if (fade <= 0.01f) return; + + float maxRadius = Math.max(0.1f, impactRadius.get()); + float radius = maxRadius * (0.15f + 0.85f * progress); + int color = getColorArgb((int) (progress * 360)); + + MeshBuilder ring = renderer.batch(LoVisualRenderPipelines.WORLD_COLORED_ADDITIVE, Renderer3D.DepthMode.MAIN); + if (ring != null) { + renderRing(ring, radius, fade, color); + } + + MeshBuilder glow = renderer.batch(LoVisualRenderPipelines.WORLD_COLORED_ADDITIVE_DEPTH, Renderer3D.DepthMode.PRE_DEPTH); + if (glow != null) { + renderGlowWall(glow, radius, fade * 0.6f, color); + } + } + + private void renderRing(MeshBuilder mesh, float radius, float fade, int color) { + float inner = radius * 0.72f; + for (int i = 0; i < IMPACT_RING_SEGMENTS; i++) { + double a0 = (Math.PI * 2.0 / IMPACT_RING_SEGMENTS) * i; + double a1 = (Math.PI * 2.0 / IMPACT_RING_SEGMENTS) * (i + 1); + + float ix0 = (float) (Math.cos(a0) * inner); + float iz0 = (float) (Math.sin(a0) * inner); + float ix1 = (float) (Math.cos(a1) * inner); + float iz1 = (float) (Math.sin(a1) * inner); + float ox0 = (float) (Math.cos(a0) * radius); + float oz0 = (float) (Math.sin(a0) * radius); + float ox1 = (float) (Math.cos(a1) * radius); + float oz1 = (float) (Math.sin(a1) * radius); + + int edgeColor = applyOpacity(color, fade); + int fadeColor = applyOpacity(color, 0f); + + mesh.ensureQuadCapacity(); + int i1 = mesh.vec3(center.x + ix0, center.y + 0.02, center.z + iz0).color(new RenderColor(fadeColor)).next(); + int i2 = mesh.vec3(center.x + ix1, center.y + 0.02, center.z + iz1).color(new RenderColor(fadeColor)).next(); + int i3 = mesh.vec3(center.x + ox1, center.y + 0.02, center.z + oz1).color(new RenderColor(edgeColor)).next(); + int i4 = mesh.vec3(center.x + ox0, center.y + 0.02, center.z + oz0).color(new RenderColor(edgeColor)).next(); + mesh.quad(i1, i2, i3, i4); + } + } + + private void renderGlowWall(MeshBuilder mesh, float radius, float fade, int color) { + float height = impactGlowHeight.get() * (1.0f - fade * 0.3f); + for (int i = 0; i < IMPACT_RING_SEGMENTS; i++) { + double a0 = (Math.PI * 2.0 / IMPACT_RING_SEGMENTS) * i; + double a1 = (Math.PI * 2.0 / IMPACT_RING_SEGMENTS) * (i + 1); + + float x0 = (float) (Math.cos(a0) * radius); + float z0 = (float) (Math.sin(a0) * radius); + float x1 = (float) (Math.cos(a1) * radius); + float z1 = (float) (Math.sin(a1) * radius); + + int baseColor = applyOpacity(color, fade); + int topColor = applyOpacity(color, 0f); + + mesh.ensureQuadCapacity(); + int i1 = mesh.vec3(center.x + x0, center.y, center.z + z0).color(new RenderColor(baseColor)).next(); + int i2 = mesh.vec3(center.x + x1, center.y, center.z + z1).color(new RenderColor(baseColor)).next(); + int i3 = mesh.vec3(center.x + x1, center.y + height, center.z + z1).color(new RenderColor(topColor)).next(); + int i4 = mesh.vec3(center.x + x0, center.y + height, center.z + z0).color(new RenderColor(topColor)).next(); + mesh.quad(i1, i2, i3, i4); + } + } + } } diff --git a/combatant-client-26.2/src/main/java/dev/loki/lovisual/features/module/modules/visuals/items/HoldMyItems.java b/combatant-client-26.2/src/main/java/dev/loki/lovisual/features/module/modules/visuals/items/HoldMyItems.java index 3e9c6a2..df27839 100644 --- a/combatant-client-26.2/src/main/java/dev/loki/lovisual/features/module/modules/visuals/items/HoldMyItems.java +++ b/combatant-client-26.2/src/main/java/dev/loki/lovisual/features/module/modules/visuals/items/HoldMyItems.java @@ -46,6 +46,15 @@ public final class HoldMyItems extends Module { public final NumberValue driftStrength = visibleWhen(num("drift_strength", 1.0f, 0.0f, 2.0f), useDrift::get); public final BooleanValue joinAppear = bool("join_appear", true); + public final BooleanValue gripEnabled = bool("grip_enabled", true); + public final NumberValue gripExtend = visibleWhen(num("grip_extend", 0.3f, -0.3f, 1.2f), gripEnabled::get); + public final NumberValue gripSlide = visibleWhen(num("grip_slide", 0.1f, -0.2f, 0.4f), gripEnabled::get); + public final NumberValue gripOut = visibleWhen(num("grip_out", 0.05f, -0.25f, 0.25f), gripEnabled::get); + public final NumberValue gripForward = visibleWhen(num("grip_forward", 0.14f, -0.2f, 0.5f), gripEnabled::get); + public final NumberValue gripTilt = visibleWhen(num("grip_tilt", 0.0f, -90.0f, 90.0f), gripEnabled::get); + public final NumberValue gripTurn = visibleWhen(num("grip_turn", 0.0f, -90.0f, 90.0f), gripEnabled::get); + public final NumberValue gripRoll = visibleWhen(num("grip_roll", 0.0f, -180.0f, 180.0f), gripEnabled::get); + public HandAnimState handAnim() { return handAnim; } diff --git a/combatant-client-26.2/src/main/java/dev/loki/lovisual/features/module/modules/visuals/optimization/FpsOptimizer.java b/combatant-client-26.2/src/main/java/dev/loki/lovisual/features/module/modules/visuals/optimization/FpsOptimizer.java index e522e54..7d7b402 100644 --- a/combatant-client-26.2/src/main/java/dev/loki/lovisual/features/module/modules/visuals/optimization/FpsOptimizer.java +++ b/combatant-client-26.2/src/main/java/dev/loki/lovisual/features/module/modules/visuals/optimization/FpsOptimizer.java @@ -7,6 +7,7 @@ package dev.loki.lovisual.features.module.modules.visuals.optimization; +import net.minecraft.core.particles.ParticleTypes; import dev.loki.lovisual.config.values.primitive.BooleanValue; import dev.loki.lovisual.config.values.primitive.NumberValue; import dev.loki.lovisual.events.impl.render.ParticleSpawnEvent; @@ -65,6 +66,11 @@ public final class FpsOptimizer extends Module { @EventHandler private void onParticleSpawn(ParticleSpawnEvent event) { if (!isEnabled() || !disableParticles.get()) return; + // Firework particles are created by FireworkParticles$Starter and assume ParticleEngine.createParticle never returns null. + // Cancelling them returns null and causes NPE at Starter.createParticle -> setTrail(). + // Guard is also present in FireworkParticlesStarterMixin, but exempt here to preserve fireworks even with optimizer enabled. + var type = event.options().getType(); + if (type == ParticleTypes.FIREWORK || type == ParticleTypes.FLASH) return; event.cancel(); } } diff --git a/combatant-client-26.2/src/main/java/dev/loki/lovisual/features/module/modules/visuals/particles/AmbientParticles.java b/combatant-client-26.2/src/main/java/dev/loki/lovisual/features/module/modules/visuals/particles/AmbientParticles.java new file mode 100644 index 0000000..242452f --- /dev/null +++ b/combatant-client-26.2/src/main/java/dev/loki/lovisual/features/module/modules/visuals/particles/AmbientParticles.java @@ -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 mode = enumMode("mode", ParticleMode.AMBIENT, ParticleMode.values()); + private final NumberValue count = num("count", 60, 10, 200); + private final NumberValue scale = num("scale", 1.0f, 0.3f, 3.0f); + private final NumberValue spawnRadius = num("spawn_radius", 25.0f, 10.0f, 40.0f); + private final NumberValue spawnHeight = num("spawn_height", 6.0f, 2.0f, 20.0f); + private final NumberValue trailLength = visibleWhen(num("trail_length", 20, 5, 60), this::isFirefly); + + private final ModeValue colorMode = modeSetting("colorMode", "color_mode", "Static", AnimatedColorModeOptions.OPTIONS); + private final NumberValue 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 motes = new ArrayList<>(); + private final List 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]}; + } + } +} diff --git a/combatant-client-26.2/src/main/java/dev/loki/lovisual/features/module/modules/visuals/scene/EndCrystal.java b/combatant-client-26.2/src/main/java/dev/loki/lovisual/features/module/modules/visuals/scene/EndCrystal.java new file mode 100644 index 0000000..d316f99 --- /dev/null +++ b/combatant-client-26.2/src/main/java/dev/loki/lovisual/features/module/modules/visuals/scene/EndCrystal.java @@ -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 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 shatterDurationMs = visibleWhen(num("shatter_duration_ms", 600, 200, 2000), shatterEnabled::get); + + private final List 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) { + } +} diff --git a/combatant-client-26.2/src/main/java/dev/loki/lovisual/features/module/modules/visuals/trollface/TrollfaceMask.java b/combatant-client-26.2/src/main/java/dev/loki/lovisual/features/module/modules/visuals/trollface/TrollfaceMask.java index 968f2a2..79a9d4c 100644 --- a/combatant-client-26.2/src/main/java/dev/loki/lovisual/features/module/modules/visuals/trollface/TrollfaceMask.java +++ b/combatant-client-26.2/src/main/java/dev/loki/lovisual/features/module/modules/visuals/trollface/TrollfaceMask.java @@ -12,14 +12,16 @@ import dev.loki.lovisual.config.values.primitive.NumberValue; import dev.loki.lovisual.features.module.core.Module; import dev.loki.lovisual.features.module.core.ModuleCategory; import dev.loki.lovisual.features.module.core.ModuleInfo; +import dev.loki.lovisual.features.relations.PlayerRelations; import dev.loki.lovisual.render.engine.renderer.Renderer3D; import dev.loki.lovisual.render.engine.pipeline.LoVisualRenderPipelines; import dev.loki.lovisual.render.engine.texture.TextureStorage; import dev.loki.lovisual.render.engine.uniform.MeshBuilder; -import dev.loki.lovisual.util.target.TargetManager; import net.minecraft.client.Minecraft; +import net.minecraft.client.player.AbstractClientPlayer; import net.minecraft.resources.Identifier; import net.minecraft.world.entity.LivingEntity; +import net.minecraft.world.entity.player.Player; import net.minecraft.world.phys.Vec3; @ModuleInfo( @@ -64,68 +66,6 @@ public class TrollfaceMask extends Module { if (mc.player == null || mc.level == null) return; String targetMode = target.get(); - boolean selfMode = "Себя".equals(targetMode); - - LivingEntity currentTarget = TargetManager.getTarget(); - boolean hasCrosshairTarget = currentTarget != null && currentTarget.isAlive(); - - if (!selfMode && !hasCrosshairTarget) return; - - LivingEntity renderTarget; - boolean shouldRender; - - switch (targetMode) { - case "Себя" -> { - renderTarget = mc.player; - shouldRender = true; - } - case "Друзей" -> { - if (!hasCrosshairTarget) { shouldRender = false; renderTarget = null; break; } - renderTarget = currentTarget; - shouldRender = true; - } - case "Всех" -> { - if (!hasCrosshairTarget) { shouldRender = false; renderTarget = null; break; } - renderTarget = currentTarget; - shouldRender = true; - } - default -> { - renderTarget = mc.player; - shouldRender = true; - } - } - if (!shouldRender || renderTarget == null) return; - - Vec3 camPos = mc.gameRenderer.mainCamera().position(); - double headX = renderTarget.getX(); - double headY = renderTarget.getY() + renderTarget.getBbHeight() * 0.72; - double headZ = renderTarget.getZ(); - - Vec3 headPos = new Vec3(headX, headY, headZ); - Vec3 viewDir = camPos.subtract(headPos).normalize(); - - if (viewDir.lengthSqr() < 1.0E-8) return; - - Vec3 upVec = new Vec3(0, 1, 0); - Vec3 right = viewDir.cross(upVec).normalize(); - if (right.lengthSqr() < 1.0E-8) { - right = new Vec3(1, 0, 0); - } - Vec3 actualUp = right.cross(viewDir).normalize(); - - float s = size.get(); - float alpha = this.alpha.get(); - int color = 0xFFFFFFFF; - int r = (color >> 16) & 0xFF; - int g = (color >> 8) & 0xFF; - int b = color & 0xFF; - int a = (int) (alpha * 255); - - Vec3 p1 = headPos.add(right.scale(-s)).add(actualUp.scale(-s * 0.75)); - Vec3 p2 = headPos.add(right.scale(s)).add(actualUp.scale(-s * 0.75)); - Vec3 p3 = headPos.add(right.scale(s)).add(actualUp.scale(s * 0.75)); - Vec3 p4 = headPos.add(right.scale(-s)).add(actualUp.scale(s * 0.75)); - int textureIndex = maskTypeIndex(maskType.get()); Identifier texId = switch (textureIndex) { case 0 -> TextureStorage.TROLLFACE_1; @@ -137,14 +77,66 @@ public class TrollfaceMask extends Module { default -> TextureStorage.TROLLFACE_1; }; + for (Player player : mc.level.players()) { + if (!shouldMask(player, mc.player, targetMode)) continue; + renderMask(renderer, player, tickDelta, texId); + } + } + + private boolean shouldMask(Player candidate, Player self, String targetMode) { + if (!candidate.isAlive()) return false; + return switch (targetMode) { + case "Себя" -> candidate == self; + case "Друзей" -> candidate != self + && candidate instanceof AbstractClientPlayer + && PlayerRelations.get().isFriend(candidate.getGameProfile().name()); + case "Всех" -> true; + default -> candidate == self; + }; + } + + // Маска рисуется в плоскости лица цели (yaw/pitch головы), а не биллбордом к камере: + // биллборд к камере вырождается для себя от первого лица (камера почти совпадает с головой + // по вертикали -> вектор view~(0,1,0) -> right=view×up=0 -> плоскость ложится горизонтально + // и не видна). Ориентация по взгляду цели работает одинаково для себя/друзей/всех. + private void renderMask(Renderer3D renderer, LivingEntity renderTarget, float tickDelta, Identifier texId) { + double headX = renderTarget.getX(); + double headY = renderTarget.getY() + renderTarget.getBbHeight() * 0.72; + double headZ = renderTarget.getZ(); + Vec3 headPos = new Vec3(headX, headY, headZ); + + Vec3 forward = renderTarget.getViewVector(tickDelta); + if (forward.lengthSqr() < 1.0E-8) return; + forward = forward.normalize(); + + Vec3 worldUp = new Vec3(0, 1, 0); + Vec3 right = forward.cross(worldUp).normalize(); + if (right.lengthSqr() < 1.0E-8) { + right = new Vec3(1, 0, 0); + } + Vec3 actualUp = right.cross(forward).normalize(); + + // Маска висит чуть впереди лица вдоль направления взгляда, иначе z-fight с головой. + headPos = headPos.add(forward.scale(0.35)); + + float s = size.get(); + float alpha = this.alpha.get(); + int r = 255, g = 255, b = 255; + int a = (int) (alpha * 255); + + Vec3 p1 = headPos.add(right.scale(-s)).add(actualUp.scale(-s * 0.75)); + Vec3 p2 = headPos.add(right.scale(s)).add(actualUp.scale(-s * 0.75)); + Vec3 p3 = headPos.add(right.scale(s)).add(actualUp.scale(s * 0.75)); + Vec3 p4 = headPos.add(right.scale(-s)).add(actualUp.scale(s * 0.75)); + MeshBuilder mesh = renderer.batchTextured(LoVisualRenderPipelines.WORLD_TEXTURED, texId, Renderer3D.DepthMode.NONE); if (mesh == null) return; mesh.ensureQuadCapacity(); - int i1 = mesh.vec3(p1.x, p1.y, p1.z).vec2(0, 1).color(r, g, b, a).next(); - int i2 = mesh.vec3(p2.x, p2.y, p2.z).vec2(1, 1).color(r, g, b, a).next(); - int i3 = mesh.vec3(p3.x, p3.y, p3.z).vec2(1, 0).color(r, g, b, a).next(); - int i4 = mesh.vec3(p4.x, p4.y, p4.z).vec2(0, 0).color(r, g, b, a).next(); + int i1 = mesh.vec3(p1.x, p1.y, p1.z).vec2(1, 1).color(r, g, b, a).next(); + int i2 = mesh.vec3(p2.x, p2.y, p2.z).vec2(0, 1).color(r, g, b, a).next(); + int i3 = mesh.vec3(p3.x, p3.y, p3.z).vec2(0, 0).color(r, g, b, a).next(); + int i4 = mesh.vec3(p4.x, p4.y, p4.z).vec2(1, 0).color(r, g, b, a).next(); mesh.quad(i1, i2, i3, i4); } diff --git a/combatant-client-26.2/src/main/java/dev/loki/lovisual/features/module/modules/visuals/weather/Rain.java b/combatant-client-26.2/src/main/java/dev/loki/lovisual/features/module/modules/visuals/weather/Rain.java index 1ce5a54..e3520f8 100644 --- a/combatant-client-26.2/src/main/java/dev/loki/lovisual/features/module/modules/visuals/weather/Rain.java +++ b/combatant-client-26.2/src/main/java/dev/loki/lovisual/features/module/modules/visuals/weather/Rain.java @@ -448,6 +448,14 @@ public final class Rain extends Module implements PostProcessPass { }; } + private int binarySteps() { + return switch (reflectionQuality.get()) { + case HIGH -> 6; + case BALANCED -> 4; + case PERFORMANCE -> 3; + }; + } + private float rainAmount() { return Mth.clamp(0.55f + density.get() * presetDensity() * 0.28f, 0.55f, 1.45f); } @@ -524,7 +532,9 @@ public final class Rain extends Module implements PostProcessPass { reflectionDistance.get(), reflectionSteps(), rainAmount(), - puddleRipples.get() + puddleRipples.get(), + binarySteps(), + 0.18f ); try { diff --git a/combatant-client-26.2/src/main/java/dev/loki/lovisual/features/module/modules/visuals/weather/WetWorld.java b/combatant-client-26.2/src/main/java/dev/loki/lovisual/features/module/modules/visuals/weather/WetWorld.java new file mode 100644 index 0000000..04321db --- /dev/null +++ b/combatant-client-26.2/src/main/java/dev/loki/lovisual/features/module/modules/visuals/weather/WetWorld.java @@ -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 = enumMode("quality", Quality.HIGH, Quality.values()); + private final NumberValue reflectionStrength = num("reflection_strength", 0.75f, 0.1f, 1.5f); + private final NumberValue darkening = num("darkening", 0.30f, 0.0f, 0.8f); + private final BooleanValue ripples = bool("ripples", true); + private final NumberValue 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; + } + } +} diff --git a/combatant-client-26.2/src/main/java/dev/loki/lovisual/features/module/modules/visuals/world/Gps.java b/combatant-client-26.2/src/main/java/dev/loki/lovisual/features/module/modules/visuals/world/Gps.java new file mode 100644 index 0000000..05241c9 --- /dev/null +++ b/combatant-client-26.2/src/main/java/dev/loki/lovisual/features/module/modules/visuals/world/Gps.java @@ -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 } / {@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 arrowSize = num("arrow_size", 16.0f, 8.0f, 32.0f); + private final NumberValue 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 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); + } + } +} diff --git a/combatant-client-26.2/src/main/java/dev/loki/lovisual/mixins/network/game/ClientPacketListenerMixin.java b/combatant-client-26.2/src/main/java/dev/loki/lovisual/mixins/network/game/ClientPacketListenerMixin.java index 8172074..e01b94b 100644 --- a/combatant-client-26.2/src/main/java/dev/loki/lovisual/mixins/network/game/ClientPacketListenerMixin.java +++ b/combatant-client-26.2/src/main/java/dev/loki/lovisual/mixins/network/game/ClientPacketListenerMixin.java @@ -16,10 +16,12 @@ import net.minecraft.world.entity.Entity; import net.minecraft.world.entity.player.Player; import net.minecraft.world.item.Items; import net.minecraft.world.phys.Vec3; +import org.slf4j.Logger; import org.spongepowered.asm.mixin.Mixin; import org.spongepowered.asm.mixin.Unique; import org.spongepowered.asm.mixin.injection.At; import org.spongepowered.asm.mixin.injection.Inject; +import org.spongepowered.asm.mixin.injection.Redirect; import org.spongepowered.asm.mixin.injection.callback.CallbackInfo; import dev.loki.lovisual.config.values.collection.ItemCooldownRulesValue; import dev.loki.lovisual.events.Events; @@ -150,6 +152,14 @@ public abstract class ClientPacketListenerMixin { StaffTracker.INSTANCE.onEntityAppear(player.getUUID(), player.getGameProfile().name()); } + @Redirect(method = "handlePlayerInfoUpdate", at = @At(value = "INVOKE", target = "Lorg/slf4j/Logger;warn(Ljava/lang/String;Ljava/lang/Object;Ljava/lang/Object;)V")) + private void lovisual$silenceUnknownPlayerWarn(Logger instance, String format, Object arg1, Object arg2) { + // Серверы HolyWorld/Funtime шлют UPDATE_DISPLAY_NAME для фейковых записей таба без ADD_PLAYER. + // Ванилла флудит WARN "Ignoring player info update for unknown player" десятки раз в тик. + // Глушим спам -> debug, чтобы лог остался читаемым. Полный update ниже всё равно трекается в lovisual$onPlayerList. + instance.debug(format, arg1, arg2); + } + @Inject(method = "handlePlayerInfoUpdate", at = @At("TAIL")) private void lovisual$onPlayerList(ClientboundPlayerInfoUpdatePacket packet, CallbackInfo ci) { diff --git a/combatant-client-26.2/src/main/java/dev/loki/lovisual/mixins/network/game/MultiPlayerGameModeMixin.java b/combatant-client-26.2/src/main/java/dev/loki/lovisual/mixins/network/game/MultiPlayerGameModeMixin.java index d466a30..3503d8f 100644 --- a/combatant-client-26.2/src/main/java/dev/loki/lovisual/mixins/network/game/MultiPlayerGameModeMixin.java +++ b/combatant-client-26.2/src/main/java/dev/loki/lovisual/mixins/network/game/MultiPlayerGameModeMixin.java @@ -34,6 +34,7 @@ import dev.loki.lovisual.features.module.modules.combat.damage.NoFriendDamage; import dev.loki.lovisual.features.module.modules.combat.pvp.PvpCooldowns; import dev.loki.lovisual.features.module.modules.misc.sound.HitSounds; import dev.loki.lovisual.features.module.modules.visuals.camera.Freecam; +import dev.loki.lovisual.features.module.modules.visuals.effects.HitBubbles; import dev.loki.lovisual.features.module.modules.visuals.effects.HitEffect; import dev.loki.lovisual.mixins.accessors.world.WorldAccessor; import dev.loki.lovisual.util.player.inventory.core.InventorySwap; @@ -167,6 +168,11 @@ public class MultiPlayerGameModeMixin { hitEffect.handleHit(target); } + HitBubbles hitBubbles = Modules.get(HitBubbles.class); + if (hitBubbles != null) { + hitBubbles.handleHit(target); + } + TargetHud.notifyHit(target); TargetManager.onAttack(target); diff --git a/combatant-client-26.2/src/main/java/dev/loki/lovisual/mixins/render/entity/EndCrystalRendererMixin.java b/combatant-client-26.2/src/main/java/dev/loki/lovisual/mixins/render/entity/EndCrystalRendererMixin.java new file mode 100644 index 0000000..bf5f134 --- /dev/null +++ b/combatant-client-26.2/src/main/java/dev/loki/lovisual/mixins/render/entity/EndCrystalRendererMixin.java @@ -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 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); + } + } +} diff --git a/combatant-client-26.2/src/main/java/dev/loki/lovisual/mixins/render/item/ItemInHandRendererMixin.java b/combatant-client-26.2/src/main/java/dev/loki/lovisual/mixins/render/item/ItemInHandRendererMixin.java index fb2a060..2311f64 100644 --- a/combatant-client-26.2/src/main/java/dev/loki/lovisual/mixins/render/item/ItemInHandRendererMixin.java +++ b/combatant-client-26.2/src/main/java/dev/loki/lovisual/mixins/render/item/ItemInHandRendererMixin.java @@ -63,7 +63,7 @@ public abstract class ItemInHandRendererMixin { HandAnimState handAnim = holdMyItems.handAnim(); if (handAnim == null) return; - lovisual$handPosePushed = HandDynamics.apply(holdMyItems, handAnim, player, tickDelta, hand, matrices); + lovisual$handPosePushed = HandDynamics.apply(holdMyItems, handAnim, player, tickDelta, hand, item, matrices); } @Inject(method = "submitArmWithItem", at = @At("RETURN")) diff --git a/combatant-client-26.2/src/main/java/dev/loki/lovisual/mixins/render/level/FireworkParticlesStarterMixin.java b/combatant-client-26.2/src/main/java/dev/loki/lovisual/mixins/render/level/FireworkParticlesStarterMixin.java new file mode 100644 index 0000000..c42980b --- /dev/null +++ b/combatant-client-26.2/src/main/java/dev/loki/lovisual/mixins/render/level/FireworkParticlesStarterMixin.java @@ -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 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 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 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 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 original) { + if (instance == null) return; + original.call(instance, fadeColor); + } +} diff --git a/combatant-client-26.2/src/main/java/dev/loki/lovisual/mixins/world/level/ClientLevelMixin.java b/combatant-client-26.2/src/main/java/dev/loki/lovisual/mixins/world/level/ClientLevelMixin.java index 7eb1c11..28b1402 100644 --- a/combatant-client-26.2/src/main/java/dev/loki/lovisual/mixins/world/level/ClientLevelMixin.java +++ b/combatant-client-26.2/src/main/java/dev/loki/lovisual/mixins/world/level/ClientLevelMixin.java @@ -11,6 +11,8 @@ import net.minecraft.client.multiplayer.ClientLevel; import net.minecraft.core.particles.ItemParticleOption; import net.minecraft.core.particles.ParticleOptions; import net.minecraft.core.particles.ParticleTypes; +import net.minecraft.world.entity.Entity; +import net.minecraft.world.entity.boss.enderdragon.EndCrystal; import org.spongepowered.asm.mixin.Mixin; import org.spongepowered.asm.mixin.injection.At; import org.spongepowered.asm.mixin.injection.Inject; @@ -21,6 +23,20 @@ import dev.loki.lovisual.features.module.modules.visuals.visibility.NoRender; @Mixin(ClientLevel.class) public abstract class ClientLevelMixin { + @Inject(method = "removeEntity", at = @At("HEAD")) + private void lovisual$onEndCrystalRemoved(int entityId, Entity.RemovalReason reason, CallbackInfo ci) { + if (!reason.shouldDestroy()) return; + + dev.loki.lovisual.features.module.modules.visuals.scene.EndCrystal module = + Modules.get(dev.loki.lovisual.features.module.modules.visuals.scene.EndCrystal.class); + if (module == null || !module.isEnabled()) return; + + Entity entity = ((ClientLevel) (Object) this).getEntity(entityId); + if (entity instanceof EndCrystal crystal) { + module.onCrystalDestroyed(crystal.position()); + } + } + @Inject( method = "doAddParticle(Lnet/minecraft/core/particles/ParticleOptions;ZZDDDDDD)V", at = @At("HEAD"), diff --git a/combatant-client-26.2/src/main/java/dev/loki/lovisual/render/engine/animation/AnimatedColorModeOptions.java b/combatant-client-26.2/src/main/java/dev/loki/lovisual/render/engine/animation/AnimatedColorModeOptions.java new file mode 100644 index 0000000..91f5c73 --- /dev/null +++ b/combatant-client-26.2/src/main/java/dev/loki/lovisual/render/engine/animation/AnimatedColorModeOptions.java @@ -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; + }; + } +} diff --git a/combatant-client-26.2/src/main/java/dev/loki/lovisual/render/engine/uniform/MeshBuilder.java b/combatant-client-26.2/src/main/java/dev/loki/lovisual/render/engine/uniform/MeshBuilder.java index 74e5f68..5a6c8c5 100644 --- a/combatant-client-26.2/src/main/java/dev/loki/lovisual/render/engine/uniform/MeshBuilder.java +++ b/combatant-client-26.2/src/main/java/dev/loki/lovisual/render/engine/uniform/MeshBuilder.java @@ -580,8 +580,17 @@ public final class MeshBuilder implements AutoCloseable { + indexBytes + ", capacity=" + indices.capacity() + ", format=" + format); } if (primitiveIndicesCount > 0 && indicesCount % primitiveIndicesCount != 0) { - throw new IllegalStateException(owner + ": MeshBuilder incomplete primitive payload: indices=" + indicesCount - + ", primitiveLength=" + primitiveIndicesCount + ", format=" + format); + if (DEBUG) { + throw new IllegalStateException(owner + ": MeshBuilder incomplete primitive payload: indices=" + indicesCount + + ", primitiveLength=" + primitiveIndicesCount + ", format=" + format); + } else { + int trimmed = (indicesCount / primitiveIndicesCount) * primitiveIndicesCount; + // Silence spam: log once per frame via frame stats? Use logger warn with throttling. + // Trim incomplete tail to avoid Render Frame crash (e.g. 176 % 3 == 2 on TRIANGLES). + org.slf4j.LoggerFactory.getLogger(MeshBuilder.class).warn("{}: trimming incomplete primitive payload: indices={} -> {} (primitiveLength={}, format={})", owner, indicesCount, trimmed, primitiveIndicesCount, format); + indicesCount = trimmed; + if (indicesCount == 0) return; + } } debugValidateIndices(owner); } diff --git a/combatant-client-26.2/src/main/java/dev/loki/lovisual/render/engine/uniform/impl/world/liquid/WetSurfaceUniforms.java b/combatant-client-26.2/src/main/java/dev/loki/lovisual/render/engine/uniform/impl/world/liquid/WetSurfaceUniforms.java index fb8bfdc..6280e68 100644 --- a/combatant-client-26.2/src/main/java/dev/loki/lovisual/render/engine/uniform/impl/world/liquid/WetSurfaceUniforms.java +++ b/combatant-client-26.2/src/main/java/dev/loki/lovisual/render/engine/uniform/impl/world/liquid/WetSurfaceUniforms.java @@ -23,6 +23,7 @@ public enum WetSurfaceUniforms { .putFloat().putFloat().putFloat().putFloat() .putFloat().putFloat().putFloat().putFloat() .putFloat().putFloat().putFloat().putFloat() + .putFloat().putFloat().putFloat().putFloat() .get(); private static final Data DATA = new Data(); @@ -34,7 +35,8 @@ public enum WetSurfaceUniforms { float camX, float camY, float camZ, float invW, float invH, float time, boolean mainDepthEnabled, float wetness, float puddleCoverage, float reflectionStrength, float rippleStrength, - float maxDistance, int reflectionSteps, float rainAmount, boolean ripplesEnabled) { + float maxDistance, int reflectionSteps, float rainAmount, boolean ripplesEnabled, + int binarySteps, float darkeningFactor) { DATA.projection.set(projection); DATA.invView.set(invView); DATA.camX = camX; @@ -52,6 +54,8 @@ public enum WetSurfaceUniforms { DATA.reflectionSteps = reflectionSteps; DATA.rainAmount = rainAmount; DATA.ripplesEnabled = ripplesEnabled ? 1.0f : 0.0f; + DATA.binarySteps = binarySteps; + DATA.darkeningFactor = darkeningFactor; LoVisualRenderSystem.uniforms().write(UNIFORM_NAME, SIZE, EXPECTED_WRITES_PER_FRAME, DATA); } @@ -66,6 +70,7 @@ public enum WetSurfaceUniforms { private float invW, invH, time, mainDepthEnabled; private float wetness, puddleCoverage, reflectionStrength, rippleStrength; private float maxDistance, reflectionSteps, rainAmount, ripplesEnabled; + private float binarySteps, darkeningFactor; @Override public void write(java.nio.ByteBuffer buffer) { @@ -76,6 +81,7 @@ public enum WetSurfaceUniforms { b.putFloat(invW).putFloat(invH).putFloat(time).putFloat(mainDepthEnabled); b.putFloat(wetness).putFloat(puddleCoverage).putFloat(reflectionStrength).putFloat(rippleStrength); b.putFloat(maxDistance).putFloat(reflectionSteps).putFloat(rainAmount).putFloat(ripplesEnabled); + b.putFloat(binarySteps).putFloat(darkeningFactor).putFloat(0.0f).putFloat(0.0f); } @Override diff --git a/combatant-client-26.2/src/main/java/dev/loki/lovisual/render/engine/world/WorldPassCompiler.java b/combatant-client-26.2/src/main/java/dev/loki/lovisual/render/engine/world/WorldPassCompiler.java index 071a33f..1f3c676 100644 --- a/combatant-client-26.2/src/main/java/dev/loki/lovisual/render/engine/world/WorldPassCompiler.java +++ b/combatant-client-26.2/src/main/java/dev/loki/lovisual/render/engine/world/WorldPassCompiler.java @@ -89,12 +89,24 @@ public final class WorldPassCompiler { stats.skippedEmptyCommand(); 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) { stats.skippedEmptyCommand(); 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; MeshBuilder drawMesh = mesh; diff --git a/combatant-client-26.2/src/main/java/dev/loki/lovisual/render/helpers/particle/OrientedQuadRenderer.java b/combatant-client-26.2/src/main/java/dev/loki/lovisual/render/helpers/particle/OrientedQuadRenderer.java new file mode 100644 index 0000000..bdd32ad --- /dev/null +++ b/combatant-client-26.2/src/main/java/dev/loki/lovisual/render/helpers/particle/OrientedQuadRenderer.java @@ -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); + } +} diff --git a/combatant-client-26.2/src/main/java/dev/loki/lovisual/util/animation/ThreeStageProgress.java b/combatant-client-26.2/src/main/java/dev/loki/lovisual/util/animation/ThreeStageProgress.java new file mode 100644 index 0000000..0ca5f5d --- /dev/null +++ b/combatant-client-26.2/src/main/java/dev/loki/lovisual/util/animation/ThreeStageProgress.java @@ -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; + } +} diff --git a/combatant-client-26.2/src/main/java/dev/loki/lovisual/util/animation/hand/HandDynamics.java b/combatant-client-26.2/src/main/java/dev/loki/lovisual/util/animation/hand/HandDynamics.java index a6ee593..f3b9c2c 100644 --- a/combatant-client-26.2/src/main/java/dev/loki/lovisual/util/animation/hand/HandDynamics.java +++ b/combatant-client-26.2/src/main/java/dev/loki/lovisual/util/animation/hand/HandDynamics.java @@ -15,6 +15,7 @@ import net.minecraft.client.Minecraft; import net.minecraft.client.player.AbstractClientPlayer; import net.minecraft.world.InteractionHand; import net.minecraft.world.entity.HumanoidArm; +import net.minecraft.world.item.ItemStack; import dev.loki.lovisual.features.module.modules.visuals.items.HoldMyItems; import dev.loki.lovisual.render.helpers.util.TickDelta; @@ -54,7 +55,7 @@ public final class HandDynamics { */ public static boolean apply( HoldMyItems module, HandAnimState state, AbstractClientPlayer player, - float tickDelta, InteractionHand hand, PoseStack matrices + float tickDelta, InteractionHand hand, ItemStack item, PoseStack matrices ) { boolean mainHand = hand == InteractionHand.MAIN_HAND; float bobStrength = module.walkBob.get() ? module.bobStrength.get() : 0.0f; @@ -62,6 +63,7 @@ public final class HandDynamics { boolean usingThisHand = player.isUsingItem() && player.getUsedItemHand() == hand; float drift = usingThisHand ? state.drift(mainHand) : 0.0f; + boolean rightArm = (player.getMainArm() == HumanoidArm.RIGHT) == mainHand; HandPoseMath.Pose pose = HandPoseMath.sway( state.sway().yawLag(), state.sway().pitchLag(), module.swayIntensity.get()); @@ -72,12 +74,18 @@ public final class HandDynamics { bobStrength)); } if (driftStrength > 0.0f && drift > 0.0f) { - boolean rightArm = (player.getMainArm() == HumanoidArm.RIGHT) == mainHand; pose = HandPoseMath.add(pose, HandPoseMath.drift(drift, driftStrength, rightArm)); } if (module.joinAppear.get()) { pose = HandPoseMath.add(pose, HandPoseMath.joinDrop(state.joinProgress())); } + if (module.gripEnabled.get() && !usingThisHand && HeldAlongFist.test(item)) { + float side = rightArm ? 1.0f : -1.0f; + pose = HandPoseMath.add(pose, HandPoseMath.grip( + module.gripExtend.get(), module.gripSlide.get(), module.gripOut.get(), + module.gripForward.get(), module.gripTilt.get(), module.gripTurn.get(), + module.gripRoll.get(), side)); + } if (!pose.isSignificant(SIGNIFICANT_EPSILON)) { return false; } diff --git a/combatant-client-26.2/src/main/java/dev/loki/lovisual/util/animation/hand/HandPoseMath.java b/combatant-client-26.2/src/main/java/dev/loki/lovisual/util/animation/hand/HandPoseMath.java index 9d9e5dd..5b4d0c3 100644 --- a/combatant-client-26.2/src/main/java/dev/loki/lovisual/util/animation/hand/HandPoseMath.java +++ b/combatant-client-26.2/src/main/java/dev/loki/lovisual/util/animation/hand/HandPoseMath.java @@ -99,6 +99,24 @@ public enum HandPoseMath { ); } + /** + * "Хват вдоль кулака" (grip) для предметов с рукоятью (см. {@link HeldAlongFist}): + * лёгкий сдвиг ближе к камере/в сторону и поворот — имитирует держание меча/палки + * не прямо по-вертикали ванильного equip, а слегка наискось в кулаке. + * {@code side}: +1 — правая рука, -1 — левая. + */ + public static Pose grip(float extend, float slide, float out, float forward, + float tilt, float turn, float roll, float side) { + return new Pose( + 0.32f * side * extend + out * side, + slide, + -0.95f * extend - forward, + tilt, + turn * side, + roll * side + ); + } + /** Появление при входе в мир: предмет въезжает снизу и распрямляется. */ public static Pose joinDrop(float progress01) { float p = clamp01(progress01); diff --git a/combatant-client-26.2/src/main/java/dev/loki/lovisual/util/animation/hand/HeldAlongFist.java b/combatant-client-26.2/src/main/java/dev/loki/lovisual/util/animation/hand/HeldAlongFist.java new file mode 100644 index 0000000..2501d28 --- /dev/null +++ b/combatant-client-26.2/src/main/java/dev/loki/lovisual/util/animation/hand/HeldAlongFist.java @@ -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); + } +} diff --git a/combatant-client-26.2/src/main/java/dev/loki/lovisual/util/combat/attack/AttackUtil.java b/combatant-client-26.2/src/main/java/dev/loki/lovisual/util/combat/attack/AttackUtil.java index 75a49d2..c8d5b56 100644 --- a/combatant-client-26.2/src/main/java/dev/loki/lovisual/util/combat/attack/AttackUtil.java +++ b/combatant-client-26.2/src/main/java/dev/loki/lovisual/util/combat/attack/AttackUtil.java @@ -21,6 +21,7 @@ import dev.loki.lovisual.features.gui.hud.draggable.impl.targethud.TargetHud; import dev.loki.lovisual.features.module.lifecycle.Modules; import dev.loki.lovisual.features.module.modules.combat.pvp.RubberHand; import dev.loki.lovisual.features.module.modules.misc.sound.HitSounds; +import dev.loki.lovisual.features.module.modules.visuals.effects.HitBubbles; import dev.loki.lovisual.features.module.modules.visuals.effects.HitEffect; import dev.loki.lovisual.mixins.accessors.entity.LocalPlayerAccessor; import dev.loki.lovisual.mixins.accessors.misc.MultiPlayerGameModeAccessor; @@ -165,6 +166,10 @@ public enum AttackUtil { if (hitEffect != null) { hitEffect.handleHit(target); } + HitBubbles hitBubbles = Modules.get(HitBubbles.class); + if (hitBubbles != null) { + hitBubbles.handleHit(target); + } TargetHud.notifyHit(target); TargetManager.onAttack(target); } diff --git a/combatant-client-26.2/src/main/java/dev/loki/lovisual/util/combat/hit/AttackHitPoint.java b/combatant-client-26.2/src/main/java/dev/loki/lovisual/util/combat/hit/AttackHitPoint.java new file mode 100644 index 0000000..50fb5f2 --- /dev/null +++ b/combatant-client-26.2/src/main/java/dev/loki/lovisual/util/combat/hit/AttackHitPoint.java @@ -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); + } +} diff --git a/combatant-client-26.2/src/main/java/dev/loki/lovisual/util/world/GpsBearing.java b/combatant-client-26.2/src/main/java/dev/loki/lovisual/util/world/GpsBearing.java new file mode 100644 index 0000000..113f16e --- /dev/null +++ b/combatant-client-26.2/src/main/java/dev/loki/lovisual/util/world/GpsBearing.java @@ -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); + } +} diff --git a/combatant-client-26.2/src/main/resources/assets/lovisual/lang/en_us.json b/combatant-client-26.2/src/main/resources/assets/lovisual/lang/en_us.json index 41b2d3f..ca49666 100644 --- a/combatant-client-26.2/src/main/resources/assets/lovisual/lang/en_us.json +++ b/combatant-client-26.2/src/main/resources/assets/lovisual/lang/en_us.json @@ -3230,6 +3230,7 @@ "command.config.description": "Saves, reloads, or locates the active client config.", "command.coordinates.description": "Shows the current player coordinates.", "command.enemy.description": "Manages the client enemy list.", + "command.gps.description": "Sets or clears the Gps compass target.", "command.friend.description": "Manages the client friend list.", "command.help.description": "Shows every available client command with clickable pages. Prefix: % (legacy @ also works).", "command.hide.description": "Hides or shows a module in ModuleList without disabling it.", diff --git a/combatant-client-26.2/src/main/resources/assets/lovisual/lang/ru_ru.json b/combatant-client-26.2/src/main/resources/assets/lovisual/lang/ru_ru.json index 38daabb..248fe00 100644 --- a/combatant-client-26.2/src/main/resources/assets/lovisual/lang/ru_ru.json +++ b/combatant-client-26.2/src/main/resources/assets/lovisual/lang/ru_ru.json @@ -3025,6 +3025,7 @@ "command.config.description": "Сохраняет, перезагружает или показывает путь к конфигурации клиента.", "command.coordinates.description": "Показывает текущие координаты игрока.", "command.enemy.description": "Управляет списком врагов клиента.", + "command.gps.description": "Задаёт или сбрасывает цель GPS-компаса.", "command.friend.description": "Управляет списком друзей клиента.", "command.help.description": "Показывает все доступные команды клиента с кликабельными страницами. Префикс: % (старый @ тоже работает).", "command.hide.description": "Скрывает или показывает модуль в ModuleList, не отключая его.", diff --git a/combatant-client-26.2/src/main/resources/assets/lovisual/shaders/wet_surface.frag b/combatant-client-26.2/src/main/resources/assets/lovisual/shaders/wet_surface.frag index 98211f9..9b38f1b 100644 --- a/combatant-client-26.2/src/main/resources/assets/lovisual/shaders/wet_surface.frag +++ b/combatant-client-26.2/src/main/resources/assets/lovisual/shaders/wet_surface.frag @@ -19,6 +19,7 @@ layout (std140) uniform WetSurface { vec4 u_Screen; // invW, invH, time, mainDepthEnabled vec4 u_Params; // wetness, puddleCoverage, reflectionStrength, rippleStrength vec4 u_Params2; // maxDistance, reflectionSteps, rainAmount, ripplesEnabled + vec4 u_Params3; // binarySteps, darkeningFactor, unused, unused }; in vec2 v_TexCoord; @@ -36,12 +37,12 @@ vec3 viewPositionFromDepth(vec2 uv, float rawDepth) { return view.xyz / view.w; } -bool projectToScreen(vec3 viewPos, out vec2 uv, out float rawDepth) { +bool projectToScreen(vec3 viewPos, out vec2 uv, out float ndcZ) { vec4 clip = u_Projection * vec4(viewPos, 1.0); if (clip.w <= 0.0001) return false; vec3 ndc = clip.xyz / clip.w; uv = ndc.xy * 0.5 + 0.5; - rawDepth = ndc.z; + ndcZ = ndc.z; return uv.x >= 0.0 && uv.x <= 1.0 && uv.y >= 0.0 && uv.y <= 1.0; } @@ -60,9 +61,60 @@ float valueNoise(vec2 p) { return mix(mix(a, b, u.x), mix(c, d, u.x), u.y); } +float fbm(vec2 p) { + float v = 0.0; + float a = 0.5; + mat2 rot = mat2(0.8, 0.6, -0.6, 0.8); + for (int i = 0; i < 4; i++) { + v += valueNoise(p) * a; + p = rot * p * 2.02 + vec2(1.7, 9.2); + a *= 0.5; + } + return v; +} + float puddleNoise(vec2 worldXZ) { - float n = valueNoise(worldXZ * 0.18) * 0.65 - + valueNoise(worldXZ * 0.45 + 17.0) * 0.35; + return valueNoise(worldXZ * 0.18) * 0.65 + valueNoise(worldXZ * 0.45 + 17.0) * 0.35; +} + +float getDither(vec2 fragCoord) { + return fract(52.9829189 * fract(dot(fragCoord, vec2(0.06711056, 0.00583715)))); +} + +// Реконструкция нормали по 4 соседним сэмплам буфера глубины вместо dFdx/dFdy от view-position: +// screen-space derivative ломается на границах глубины (силуэты, дальний план) и даёт шумную/ +// блочную нормаль на почти плоской поверхности -> дрожащая/рваная граница лужи. Сэмплинг соседей +// по глубине даёт гладкую нормаль и не зависит от размера треугольника меша чанка. +vec3 normalFromDepth(vec2 uv, vec3 viewPos) { + vec2 texel = vec2(u_Screen.x, u_Screen.y); + + float dL = texture(u_MainDepth, clamp(uv - vec2(texel.x, 0.0), vec2(0.0001), vec2(0.9999))).r; + float dR = texture(u_MainDepth, clamp(uv + vec2(texel.x, 0.0), vec2(0.0001), vec2(0.9999))).r; + float dD = texture(u_MainDepth, clamp(uv - vec2(0.0, texel.y), vec2(0.0001), vec2(0.9999))).r; + float dU = texture(u_MainDepth, clamp(uv + vec2(0.0, texel.y), vec2(0.0001), vec2(0.9999))).r; + + vec3 pL = viewPositionFromDepth(uv - vec2(texel.x, 0.0), dL); + vec3 pR = viewPositionFromDepth(uv + vec2(texel.x, 0.0), dR); + vec3 pD = viewPositionFromDepth(uv - vec2(0.0, texel.y), dD); + vec3 pU = viewPositionFromDepth(uv + vec2(0.0, texel.y), dU); + + vec3 vL = viewPos - pL; + vec3 vR = pR - viewPos; + vec3 vD = viewPos - pD; + vec3 vU = pU - viewPos; + + // берём более короткую (менее вероятно пересекающую разрыв глубины) сторону по каждой оси + vec3 dX = (abs(vL.z) < abs(vR.z)) ? vL : vR; + vec3 dY = (abs(vD.z) < abs(vU.z)) ? vD : vU; + + vec3 n = cross(dX, dY); + float len = length(n); + if (len > 1e-6) { + n /= len; + } else { + n = normalize(-viewPos); + } + if (dot(n, -viewPos) < 0.0) n = -n; return n; } @@ -82,18 +134,12 @@ void main() { } vec3 viewPos = viewPositionFromDepth(uv, rawDepth); - - vec3 ddxPos = dFdx(viewPos); - vec3 ddyPos = dFdy(viewPos); - vec3 viewNormal = normalize(cross(ddxPos, ddyPos)); + vec3 viewNormal = normalFromDepth(uv, viewPos); vec3 viewDir = normalize(viewPos); - if (dot(viewNormal, -viewDir) < 0.0) { - viewNormal = -viewNormal; - } vec3 worldNormal = normalize(mat3(u_InvView) * viewNormal); float groundFactor = clamp(dot(worldNormal, vec3(0.0, 1.0, 0.0)), 0.0, 1.0); - groundFactor = smoothstep(0.82, 0.98, groundFactor); + groundFactor = smoothstep(0.6, 0.92, groundFactor); if (groundFactor <= 0.001) { color = base; return; @@ -106,9 +152,11 @@ void main() { float reflectionStrength = clamp(u_Params.z, 0.0, 2.0); float rippleStrength = clamp(u_Params.w, 0.0, 2.0); float maxDistance = max(u_Params2.x, 1.0); - int steps = int(clamp(floor(u_Params2.y + 0.5), 1.0, 32.0)); + int steps = int(clamp(floor(u_Params2.y + 0.5), 1.0, 96.0)); float rainAmount = clamp(u_Params2.z, 0.0, 2.0); bool ripplesEnabled = u_Params2.w > 0.5; + int binarySteps = int(clamp(floor(u_Params3.x + 0.5), 0.0, 8.0)); + float darkeningFactor = clamp(u_Params3.y, 0.0, 1.0); float noise = puddleNoise(worldPos.xz); float wetMask = smoothstep(0.5 - puddleCoverage * 0.55, 0.5 + puddleCoverage * 0.35, noise); @@ -118,48 +166,90 @@ void main() { return; } - vec3 reflNormal = viewNormal; + vec3 reflNormal = worldNormal; if (ripplesEnabled && rippleStrength > 0.001) { + // Аналитический градиент бегущей волны вместо мёртвого dFdx(ripple)*0.0 из старой версии + // (там перемножение на 0 полностью убивало рябь) — считаем волну в двух соседних точках + // и берём разность как наклон поверхности, а не саму высоту волны. float t = u_Screen.z; - float ripple = sin((worldPos.x + worldPos.z) * 3.1 + t * 2.4) * 0.5 - + sin((worldPos.x - worldPos.z * 1.7) * 4.3 - t * 3.1) * 0.5; - vec2 perturb = vec2(dFdx(ripple), dFdy(ripple)) * 0.0; - reflNormal = normalize(viewNormal + vec3(ripple, 0.0, ripple * 0.6) * rippleStrength * 0.05); + float eps = 0.35; + vec2 wp = worldPos.xz; + float h0 = sin(wp.x * 3.1 + t * 2.4) * 0.5 + sin((wp.x - wp.y * 1.7) * 4.3 - t * 3.1) * 0.5; + float hx = sin((wp.x + eps) * 3.1 + t * 2.4) * 0.5 + sin(((wp.x + eps) - wp.y * 1.7) * 4.3 - t * 3.1) * 0.5; + float hy = sin(wp.x * 3.1 + t * 2.4) * 0.5 + sin((wp.x - (wp.y + eps) * 1.7) * 4.3 - t * 3.1) * 0.5; + vec2 slope = vec2(hx - h0, hy - h0) / eps; + vec3 perturbedWorld = normalize(vec3(-slope.x, 1.0, -slope.y) * vec3(rippleStrength * 0.35, 1.0, rippleStrength * 0.35)); + reflNormal = normalize(mix(worldNormal, perturbedWorld, groundFactor)); } + // world-space нормаль обратно в view-space через транспонированную (обратную) чистую ротацию u_InvView + vec3 reflNormalView = normalize(transpose(mat3(u_InvView)) * reflNormal); + if (dot(reflNormalView, -viewDir) < 0.0) reflNormalView = -reflNormalView; - vec3 reflDir = reflect(viewDir, reflNormal); + vec3 reflDir = reflect(viewDir, reflNormalView); + + vec3 skyPitch3 = normalize(mat3(u_InvView) * reflDir); + float skyPitch = clamp(skyPitch3.y * 0.5 + 0.5, 0.0, 1.0); + vec3 horizonColor = vec3(0.30, 0.36, 0.45); + vec3 zenithColor = vec3(0.10, 0.16, 0.28); + vec3 reflectionColor = mix(horizonColor, zenithColor, pow(skyPitch, 0.75)); + float cloud = fbm(skyPitch3.xz * 1.5 / max(abs(skyPitch3.y) + 0.18, 0.08)); + reflectionColor = mix(reflectionColor, vec3(0.42, 0.48, 0.56), cloud * 0.28 * smoothstep(-0.1, 0.5, skyPitch3.y)); + vec3 lightDir = normalize(vec3(0.35, 0.75, 0.25)); + float sunSpec = pow(max(dot(skyPitch3, lightDir), 0.0), 32.0); + reflectionColor += vec3(0.85, 0.88, 0.95) * sunSpec * 0.5; - vec3 reflectionColor = mix(vec3(0.45, 0.52, 0.62), vec3(0.75, 0.82, 0.92), clamp(reflDir.y * 0.5 + 0.5, 0.0, 1.0)); bool hit = false; + float dither = getDither(gl_FragCoord.xy); + float stepLen = maxDistance / float(max(steps, 1)); + vec3 rayOrigin = viewPos + reflNormalView * 0.02; - float stepLen = maxDistance / float(steps); - vec3 marchPos = viewPos + reflNormal * 0.02; - for (int i = 0; i < 32; i++) { + for (int i = 0; i < 96; i++) { if (i >= steps) break; - marchPos += reflDir * stepLen; + float t = (float(i) + dither) * stepLen + stepLen * 0.5; + vec3 marchPos = rayOrigin + reflDir * t; vec2 sampleUv; - float sampleClipDepth; - if (!projectToScreen(marchPos, sampleUv, sampleClipDepth)) break; + float sampleNdcZ; + if (!projectToScreen(marchPos, sampleUv, sampleNdcZ)) break; float sceneRawDepth = texture(u_MainDepth, sampleUv).r; if (!validRawDepth(sceneRawDepth)) continue; vec3 scenePos = viewPositionFromDepth(sampleUv, sceneRawDepth); - float marchDist = length(marchPos); float sceneDist = length(scenePos); float thickness = max(0.15, marchDist * 0.03); if (marchDist > sceneDist && marchDist - sceneDist < thickness) { - reflectionColor = texture(u_Texture, sampleUv).rgb; + // Бинарный рефайнмент: сужаем интервал вокруг найденного пересечения, чтобы убрать + // блочность отражений на резких перепадах глубины (старая версия брала грубый sampleUv как есть). + float tMin = max(0.0, t - stepLen); + float tMax = t; + vec2 refinedUv = sampleUv; + for (int b = 0; b < binarySteps; b++) { + float tMid = (tMin + tMax) * 0.5; + vec3 midPos = rayOrigin + reflDir * tMid; + vec2 midUv; + float midNdcZ; + if (!projectToScreen(midPos, midUv, midNdcZ)) break; + float midDepth = texture(u_MainDepth, midUv).r; + if (!validRawDepth(midDepth)) { tMin = tMid; continue; } + vec3 midScenePos = viewPositionFromDepth(midUv, midDepth); + if (length(midPos) > length(midScenePos)) { + tMax = tMid; + refinedUv = midUv; + } else { + tMin = tMid; + } + } + reflectionColor = texture(u_Texture, refinedUv).rgb; hit = true; break; } } - float fresnel = pow(1.0 - clamp(dot(-viewDir, viewNormal), 0.0, 1.0), 3.0); + float fresnel = pow(1.0 - clamp(dot(-viewDir, reflNormalView), 0.0, 1.0), 3.0); fresnel = mix(0.25, 1.0, fresnel); float hitBoost = hit ? 1.0 : 0.55; @@ -167,7 +257,7 @@ void main() { * mix(0.6, 1.0, clamp(rainAmount, 0.0, 1.0)) * wetness; reflectionAmount = clamp(reflectionAmount, 0.0, 1.0); - vec3 darkened = base.rgb * mix(1.0, 0.82, wetMask * clamp(wetness, 0.0, 1.0)); + vec3 darkened = base.rgb * (1.0 - darkeningFactor * wetMask); vec3 result = mix(darkened, reflectionColor, reflectionAmount); color = vec4(clamp(result, 0.0, 1.0), 1.0); diff --git a/combatant-client-26.2/src/main/resources/lovisual.mixins.json b/combatant-client-26.2/src/main/resources/lovisual.mixins.json index 1b3e2dc..d7b16e5 100644 --- a/combatant-client-26.2/src/main/resources/lovisual.mixins.json +++ b/combatant-client-26.2/src/main/resources/lovisual.mixins.json @@ -140,6 +140,7 @@ "network.game.PlayerInfoMixin", "network.packet.ClientCommonPacketListenerImplMixin", "network.packet.ServerboundUseItemPacketMixin", + "render.entity.EndCrystalRendererMixin", "render.entity.EntityRenderDispatcherMixin", "render.entity.ItemEntityRenderStateMixin", "render.entity.ItemEntityRendererMixin", @@ -154,6 +155,7 @@ "render.item.ItemInHandAlwaysVisibleMixin", "render.item.ItemInHandRendererMixin", "render.level.CameraMixin", + "render.level.FireworkParticlesStarterMixin", "render.level.LevelRendererMixin", "render.level.ParticleManagerMixin", "render.level.SkyRendererMixin", diff --git a/combatant-client-26.2/src/test/java/dev/loki/lovisual/util/animation/ThreeStageProgressTest.java b/combatant-client-26.2/src/test/java/dev/loki/lovisual/util/animation/ThreeStageProgressTest.java new file mode 100644 index 0000000..2ded365 --- /dev/null +++ b/combatant-client-26.2/src/test/java/dev/loki/lovisual/util/animation/ThreeStageProgressTest.java @@ -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)); + } +} diff --git a/combatant-client-26.2/src/test/java/dev/loki/lovisual/util/animation/hand/HandPoseMathTest.java b/combatant-client-26.2/src/test/java/dev/loki/lovisual/util/animation/hand/HandPoseMathTest.java index c64d3ca..1f83d7f 100644 --- a/combatant-client-26.2/src/test/java/dev/loki/lovisual/util/animation/hand/HandPoseMathTest.java +++ b/combatant-client-26.2/src/test/java/dev/loki/lovisual/util/animation/hand/HandPoseMathTest.java @@ -121,6 +121,30 @@ public final class HandPoseMathTest { assertEquals(a, HandPoseMath.add(HandPoseMath.ZERO, a)); } + @Test + void gripZeroParamsIsZero() { + assertFalse(HandPoseMath.grip(0f, 0f, 0f, 0f, 0f, 0f, 0f, 1f).isSignificant(EPS)); + } + + @Test + void gripMirrorsBySide() { + HandPoseMath.Pose right = HandPoseMath.grip(0.3f, 0.1f, 0.05f, 0.14f, 5f, 10f, 15f, 1.0f); + HandPoseMath.Pose left = HandPoseMath.grip(0.3f, 0.1f, 0.05f, 0.14f, 5f, 10f, 15f, -1.0f); + assertEquals(-right.dx(), left.dx(), EPS); + assertEquals(right.dy(), left.dy(), EPS, "slide не зависит от стороны"); + assertEquals(right.dz(), left.dz(), EPS, "extend/forward не зависят от стороны"); + assertEquals(right.rx(), left.rx(), EPS, "tilt не зависит от стороны"); + assertEquals(-right.ry(), left.ry(), EPS); + assertEquals(-right.rz(), left.rz(), EPS); + } + + @Test + void gripExtendPullsCloserAndAcrossFist() { + HandPoseMath.Pose pose = HandPoseMath.grip(1.0f, 0f, 0f, 0f, 0f, 0f, 0f, 1.0f); + assertTrue(pose.dz() < 0.0f, "extend тянет предмет ближе к камере (-Z)"); + assertTrue(pose.dx() > 0.0f, "extend на правой руке сдвигает вбок по стороне"); + } + @Test void significanceThresholdWorks() { assertFalse(HandPoseMath.ZERO.isSignificant(1.0e-4f)); diff --git a/combatant-client-26.2/src/test/java/dev/loki/lovisual/util/world/GpsBearingTest.java b/combatant-client-26.2/src/test/java/dev/loki/lovisual/util/world/GpsBearingTest.java new file mode 100644 index 0000000..c2face8 --- /dev/null +++ b/combatant-client-26.2/src/test/java/dev/loki/lovisual/util/world/GpsBearingTest.java @@ -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); + } +}