From 9661ff638d767e268558c3a8ad0144ab7d68c9b2 Mon Sep 17 00:00:00 2001 From: loki5512344 Date: Fri, 2 Oct 2026 14:26:41 +0200 Subject: [PATCH] feat(mod): ElytraTrails and Blizzard reviewed and fixed, shared trail ribbon, spec-style tests --- mod/TODO.md | 28 ++- .../effects/ambient/trails/TrailsColor.java | 11 +- .../effects/elytratrails/ElytraTrails.java | 86 +++++++ .../effects/elytratrails/WingTipLocator.java | 39 ++++ .../effects/elytratrails/WingTrailStore.java | 57 +++++ .../projectiletrails/ProjectileTrails.java | 21 +- .../effects/projectiletrails/TrailStore.java | 68 ++---- .../visuals/effects/ribbon/RibbonPoint.java | 5 + .../visuals/effects/ribbon/TrackBuffer.java | 62 +++++ .../TrailRibbon.java | 60 +++-- .../visuals/weather/blizzard/Blizzard.java | 133 +++++++++++ .../weather/blizzard/BlizzardOverlay.java | 79 +++++++ .../weather/blizzard/BlizzardRenderer.java | 119 ++++++++++ .../visuals/weather/blizzard/SnowFlakes.java | 119 ++++++++++ .../animation/AnimatedRenderColors.java | 15 ++ .../resources/assets/lovisual/lang/en_us.json | 36 ++- .../resources/assets/lovisual/lang/ru_ru.json | 36 ++- .../elytratrails/WingTipLocatorTest.java | 81 +++++++ .../elytratrails/WingTrailStoreTest.java | 63 +++++ .../ProjectileTrailStoreTest.java | 70 ++++++ .../effects/ribbon/TrackBufferTest.java | 63 +++++ .../effects/ribbon/TrailRibbonTest.java | 65 ++++++ .../weather/blizzard/BlizzardOverlayTest.java | 79 +++++++ .../blizzard/BlizzardRendererTest.java | 74 ++++++ .../weather/blizzard/SnowFlakesTest.java | 218 ++++++++++++++++++ 25 files changed, 1591 insertions(+), 96 deletions(-) create mode 100644 mod/src/main/java/dev/loki/lovisual/features/module/modules/visuals/effects/elytratrails/ElytraTrails.java create mode 100644 mod/src/main/java/dev/loki/lovisual/features/module/modules/visuals/effects/elytratrails/WingTipLocator.java create mode 100644 mod/src/main/java/dev/loki/lovisual/features/module/modules/visuals/effects/elytratrails/WingTrailStore.java create mode 100644 mod/src/main/java/dev/loki/lovisual/features/module/modules/visuals/effects/ribbon/RibbonPoint.java create mode 100644 mod/src/main/java/dev/loki/lovisual/features/module/modules/visuals/effects/ribbon/TrackBuffer.java rename mod/src/main/java/dev/loki/lovisual/features/module/modules/visuals/effects/{projectiletrails => ribbon}/TrailRibbon.java (62%) create mode 100644 mod/src/main/java/dev/loki/lovisual/features/module/modules/visuals/weather/blizzard/Blizzard.java create mode 100644 mod/src/main/java/dev/loki/lovisual/features/module/modules/visuals/weather/blizzard/BlizzardOverlay.java create mode 100644 mod/src/main/java/dev/loki/lovisual/features/module/modules/visuals/weather/blizzard/BlizzardRenderer.java create mode 100644 mod/src/main/java/dev/loki/lovisual/features/module/modules/visuals/weather/blizzard/SnowFlakes.java create mode 100644 mod/src/test/java/dev/loki/lovisual/features/module/modules/visuals/effects/elytratrails/WingTipLocatorTest.java create mode 100644 mod/src/test/java/dev/loki/lovisual/features/module/modules/visuals/effects/elytratrails/WingTrailStoreTest.java create mode 100644 mod/src/test/java/dev/loki/lovisual/features/module/modules/visuals/effects/projectiletrails/ProjectileTrailStoreTest.java create mode 100644 mod/src/test/java/dev/loki/lovisual/features/module/modules/visuals/effects/ribbon/TrackBufferTest.java create mode 100644 mod/src/test/java/dev/loki/lovisual/features/module/modules/visuals/effects/ribbon/TrailRibbonTest.java create mode 100644 mod/src/test/java/dev/loki/lovisual/features/module/modules/visuals/weather/blizzard/BlizzardOverlayTest.java create mode 100644 mod/src/test/java/dev/loki/lovisual/features/module/modules/visuals/weather/blizzard/BlizzardRendererTest.java create mode 100644 mod/src/test/java/dev/loki/lovisual/features/module/modules/visuals/weather/blizzard/SnowFlakesTest.java diff --git a/mod/TODO.md b/mod/TODO.md index 908cb33..3baab44 100644 --- a/mod/TODO.md +++ b/mod/TODO.md @@ -44,6 +44,14 @@ дистанция, размер), снимок меню `ModulesMenuScreen.presenceSnapshot`. Мини-копия ClickGUI в мире перед игроком из цветных квадов (без текста). Нужны привязанный аккаунт (`%link`) и мультиплеер. Не сделано: значок «LoVisual» у ника/в табе, названия модулей в панели, просмотр видео (`.watch` у evo), деплой chat-service на прод СДЕЛАН 2026-10-02 (rsync backend + `docker compose up -d --build chat-service gateway`; на сервер не уехали накопленные правки accounts-service/configs-service, они ждут отдельного деплоя). +- [x] ElytraTrails и Blizzard (код пришёл от остановленных сессий, проверен и доработан; тесты написаны по спецификации, а не по реализации): + ElytraTrails: ленты с концов крыльев только при планировании (`isFallFlying`), опора позади глаз, размах перпендикулярен полёту и + горизонтален (`WingTipLocator`, 7 тестов). Общий код вынесен в `effects/ribbon/` (`TrackBuffer`, `TrailRibbon`, `RibbonPoint`), его + используют ProjectileTrails и ElytraTrails (убрано дублирование; тесты ribbon 14 + ProjectileTrails 6 + Wing store 5). + `AnimatedRenderColors.modeOf` заменил три копии `parse`. Blizzard: исправлено, что «Интенсивность» ничего не меняла (сила + нормировалась к 1), снежинки теперь закрываются блоками (depth-пайплайн), чистая логика вынесена и покрыта (SnowFlakes 19, + Overlay 8, Renderer 9 тестов); добавлены все lang-ключи модулей. Не проверено вживую: вид лент и метели, положение концов крыльев, + снег не отсекается «крышей» (метель идёт и в помещении). - [ ] Wings: режим «Модель» (Bedrock geo.json + animation.json, 14 видов; отчёт раздел 2.4). Шаги: `BedrockGeoParser` (общий с ножами), `BedrockAnimation` + процедурные angel/demon/dragon, `WingsRenderer.renderModel`, настройки `wingSource`/`wingModel`, тесты. Ресурсы (локально): `ref/evo/mod/jar/assets/evolution/cosmetic/wings/`, `.../textures/cosmetic/wings/`. @@ -51,9 +59,23 @@ порядок работ из 8 шагов). Ресурсы: `ref/evo/mod/jar/assets/evolution/cosmetic/melee/`. - [ ] Быстрые модули P1: AutoSprint (есть `SprintControlEvent`), TotemCounter, EnchantGlint, PlaceAnimation, GodRays (референс `shaders/core/godrays`), ArmorColor. -- [ ] Дальше P1-P2: Blizzard, ElytraTrails (на нашем `Trails`), TotemSound, Sounds, InventorySort, StorageTracker, ConsumeESP, - CustomHand, DynamicLights (проверить `Light`), FoodInfo, PatPat, Showcase. P3: Chunk Cache, Schematics, HitFix (риск античита), - Camera, Graffiti, Skin Wheel, Discord RPC, Replay, Trainer. Пропустить: GuiPresence. Полный список: отчёт, раздел 1.2. +- [ ] Дальше P1-P2 — перенос модулей из evo поштучно (референс: `docs/EVO_REPORT.md` §1.2; + лицензия evo проприетарная — их ассеты/код в репо не кладём, только идеи): + - [ ] **Blizzard** (render, evo ~1290 LOC, M/L) — высотная метель: ветровые снежинки с горизонтальным + сносом, белая пелена, обмерзание краёв экрана. + - [ ] **FoodInfo** (~889, M) — сытость/восстановление и подсказка оптимального порядка еды на полосе голода. + - [ ] **ElytraTrails** (~2208, M) — светящиеся шлейфы с концов крыльев элитры, переиспользовать наш `Trails`. + - [ ] **CustomHand** (~373, M) — пост-процесс шейдер руки (частично есть `shaders/hand_glass.frag`). + - [ ] **DynamicLights** (~553, M) — факелы/лампы в руке светят по-настоящему; сначала проверить наш `Light`. + - [ ] **TotemSound** (~257, S) — свой звук при срабатывании тотема. + - [ ] **Sounds** (~195, S) — звук на клиентские действия (открытие инвентаря, прыжок, клик в GUI…). + - [ ] **InventorySort** (~390, S/M) — сортировка инвентаря по пресету (клиентские QUICK_MOVE, без чужих пакетов). + - [ ] **StorageTracker** (~678, M) — память содержимого контейнеров, поиск предмета, подсветка где лежит. + - [ ] **ConsumeESP** (~671, M) — круг с кольцом прогресса над целью, когда она ест/пьёт. + - [ ] **PatPat** (~645, M) — гладить моба пустой рукой (бинд, анимация/частицы, всё локально). + - [ ] **Showcase** (~882, L) — кинематографичный облёт постройки камерой; запись клипа — позже (MVP: облёт). + - P3 (следующий заход): Chunk Cache, Schematics, HitFix (риск античита), Camera, Graffiti, Skin Wheel, + Discord RPC, Replay, Trainer. Полный список и сложности: отчёт, раздел 1.2. ## Открытые задачи ### Проверить в игре (код готов, вживую не смотрели) diff --git a/mod/src/main/java/dev/loki/lovisual/features/module/modules/visuals/effects/ambient/trails/TrailsColor.java b/mod/src/main/java/dev/loki/lovisual/features/module/modules/visuals/effects/ambient/trails/TrailsColor.java index ca1c8cc..6875740 100644 --- a/mod/src/main/java/dev/loki/lovisual/features/module/modules/visuals/effects/ambient/trails/TrailsColor.java +++ b/mod/src/main/java/dev/loki/lovisual/features/module/modules/visuals/effects/ambient/trails/TrailsColor.java @@ -32,15 +32,6 @@ final class TrailsColor { } AnimatedRenderColors.Mode parse(String mode) { - return switch (mode) { - 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 AnimatedRenderColors.modeOf(mode); } } diff --git a/mod/src/main/java/dev/loki/lovisual/features/module/modules/visuals/effects/elytratrails/ElytraTrails.java b/mod/src/main/java/dev/loki/lovisual/features/module/modules/visuals/effects/elytratrails/ElytraTrails.java new file mode 100644 index 0000000..554e5c1 --- /dev/null +++ b/mod/src/main/java/dev/loki/lovisual/features/module/modules/visuals/effects/elytratrails/ElytraTrails.java @@ -0,0 +1,86 @@ +package dev.loki.lovisual.features.module.modules.visuals.effects.elytratrails; + +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.features.module.modules.visuals.effects.ribbon.TrackBuffer; +import dev.loki.lovisual.features.module.modules.visuals.effects.ribbon.TrailRibbon; +import dev.loki.lovisual.render.engine.animation.AnimatedRenderColors; +import dev.loki.lovisual.render.engine.renderer.Renderer3D; +import dev.loki.lovisual.util.time.GameClock; +import net.minecraft.client.Minecraft; +import net.minecraft.world.entity.player.Player; +import net.minecraft.world.phys.Vec3; + +import java.util.List; + +/** Светящиеся шлейфы с концов крыльев элитры в полёте (по мотивам evo ElytraTrails). */ +@ModuleInfo( + id = "elytratrails", + displayName = "ElytraTrails", + category = ModuleCategory.VISUALS, + description = "Glowing trails streaming from elytra wing tips while gliding" +) +public class ElytraTrails extends Module { + + private final WingTrailStore store = new WingTrailStore(); + + private final BooleanValue others = bool("elytraTrailsOthers", "others", true); + private final NumberValue duration = num("elytraTrailsDuration", "duration", 700, 150, 3000); + private final NumberValue width = num("elytraTrailsWidth", "width", 0.35f, 0.05f, 1.5f); + private final BooleanValue glow = bool("elytraTrailsGlow", "glow", true); + private final BooleanValue lines = bool("elytraTrailsLines", "lines", false); + private final NumberValue lineWidth = visibleWhen(num("elytraTrailsLineWidth", "line_width", 2.0f, 0.5f, 5.0f), lines::get); + private final BooleanValue depthTest = bool("elytraTrailsDepth", "depth_test", true); + private final ModeValue colorMode = modeSetting("elytraTrailsColorMode", "color_mode", "DoubleColor", + "Static", "Rainbow", "LightRainbow", "Sky", "Fade", "DoubleColor", "Analogous", "Theme"); + private final RGBAColorValue color = color("elytraTrailsColor", "color", "#88FF66CC"); + private final RGBAColorValue color2 = color("elytraTrailsColor2", "color2", "#6688FFFF"); + private final NumberValue colorSpeed = num("elytraTrailsColorSpeed", "color_speed", 18, 2, 54); + private final NumberValue colorPhase = num("elytraTrailsColorPhase", "color_phase", 0, 0, 360); + + @Override + public WorldPhase getWorldPhase() { + return WorldPhase.END_MAIN; + } + + @Override + public void onDisable() { + store.clear(); + } + + @Override + public void onRenderWorldEngine(Renderer3D renderer, Renderer3D depthRenderer, float tickDelta) { + Minecraft mc = Minecraft.getInstance(); + if (!isEnabled() || mc.level == null || mc.gameRenderer.mainCamera() == null) return; + long now = GameClock.millis(); + long life = duration.get(); + for (Player player : mc.level.players()) { + if (!others.get() && player != mc.player) continue; + if (!player.isAlive() || !player.isFallFlying()) continue; + WingTipLocator.Tips tips = WingTipLocator.tips(player.getEyePosition(tickDelta), + player.getViewYRot(tickDelta), player.getViewXRot(tickDelta)); + store.sample(player.getUUID(), WingTrailStore.LEFT, tips.left().x, tips.left().y, tips.left().z, now, life); + store.sample(player.getUUID(), WingTrailStore.RIGHT, tips.right().x, tips.right().y, tips.right().z, now, life); + } + store.prune(now, life); + List tracks = store.snapshot(); + if (tracks.isEmpty()) return; + + int phase = Math.round((mc.player == null ? 0 : mc.player.tickCount + tickDelta) * 4.0f) + colorPhase.get(); + int primary = color.getArgb(); + int secondary = color2.getArgb(); + int speed = colorSpeed.get(); + AnimatedRenderColors.Mode mode = AnimatedRenderColors.modeOf(colorMode.get()); + Vec3 cam = mc.gameRenderer.mainCamera().position(); + TrailRibbon.render(renderer, tracks, now, life, width.get(), true, glow.get(), lines.get(), lineWidth.get(), + depthTest.get(), cam, + (i, n) -> AnimatedRenderColors.resolve(mode, speed, + phase + Math.round(i / (float) Math.max(1, n - 1) * 180), primary, secondary, true)); + } +} diff --git a/mod/src/main/java/dev/loki/lovisual/features/module/modules/visuals/effects/elytratrails/WingTipLocator.java b/mod/src/main/java/dev/loki/lovisual/features/module/modules/visuals/effects/elytratrails/WingTipLocator.java new file mode 100644 index 0000000..a161a15 --- /dev/null +++ b/mod/src/main/java/dev/loki/lovisual/features/module/modules/visuals/effects/elytratrails/WingTipLocator.java @@ -0,0 +1,39 @@ +package dev.loki.lovisual.features.module.modules.visuals.effects.elytratrails; + +import net.minecraft.world.phys.Vec3; + +/** + * Концы крыльев планирующего игрока. Точка привязки лежит позади головы вдоль направления полёта, + * размах крыльев перпендикулярен ему и всегда горизонтален (без крена). Чистая математика. + */ +public final class WingTipLocator { + + /** Половина размаха: расстояние от оси тела до конца крыла, блоки. */ + static final float HALF_SPAN = 1.0f; + /** Корни крыльев лежат позади глаз на этом расстоянии вдоль направления полёта. */ + static final float BACK = 0.3f; + /** Смещение по «верху» тела относительно глаз (корни крыльев чуть ниже глаз). */ + static final float HEIGHT = -0.15f; + + private WingTipLocator() { + } + + public record Tips(Vec3 left, Vec3 right) { + } + + /** + * @param eye позиция глаз (интерполированная) + * @param yawDeg рыскание в градусах (0 смотрит на +Z) + * @param pitchDeg наклон в градусах, положительный вниз + */ + public static Tips tips(Vec3 eye, float yawDeg, float pitchDeg) { + double yaw = Math.toRadians(yawDeg); + double pitch = Math.toRadians(pitchDeg); + Vec3 forward = new Vec3(-Math.sin(yaw) * Math.cos(pitch), -Math.sin(pitch), Math.cos(yaw) * Math.cos(pitch)); + // Правая рука игрока зависит только от рыскания, поэтому пара не вырождается при взгляде вверх и вниз. + Vec3 right = new Vec3(-Math.cos(yaw), 0.0, -Math.sin(yaw)); + Vec3 up = right.cross(forward); + Vec3 anchor = eye.subtract(forward.scale(BACK)).add(up.scale(HEIGHT)); + return new Tips(anchor.subtract(right.scale(HALF_SPAN)), anchor.add(right.scale(HALF_SPAN))); + } +} diff --git a/mod/src/main/java/dev/loki/lovisual/features/module/modules/visuals/effects/elytratrails/WingTrailStore.java b/mod/src/main/java/dev/loki/lovisual/features/module/modules/visuals/effects/elytratrails/WingTrailStore.java new file mode 100644 index 0000000..ded29a0 --- /dev/null +++ b/mod/src/main/java/dev/loki/lovisual/features/module/modules/visuals/effects/elytratrails/WingTrailStore.java @@ -0,0 +1,57 @@ +package dev.loki.lovisual.features.module.modules.visuals.effects.elytratrails; + +import dev.loki.lovisual.features.module.modules.visuals.effects.ribbon.TrackBuffer; + +import java.util.ArrayList; +import java.util.HashMap; +import java.util.List; +import java.util.Map; +import java.util.UUID; + +/** Истории концов крыльев: по ленте на каждое крыло каждого игрока; опустевшие ленты и игроки удаляются. */ +final class WingTrailStore { + + static final int LEFT = 0; + static final int RIGHT = 1; + + private final Map tracks = new HashMap<>(); + + void clear() { + tracks.clear(); + } + + void sample(UUID id, int side, double x, double y, double z, long now, long lifeMs) { + TrackBuffer[] pair = tracks.computeIfAbsent(id, ignored -> new TrackBuffer[2]); + if (pair[side] == null) pair[side] = new TrackBuffer(); + pair[side].add(x, y, z, now, lifeMs); + } + + /** Убирает устаревшие точки; опустевшие ленты и игроки без лент удаляются. */ + void prune(long now, long lifeMs) { + tracks.values().removeIf(pair -> { + boolean any = false; + for (int i = 0; i < pair.length; i++) { + if (pair[i] == null) continue; + pair[i].trim(now, lifeMs); + if (pair[i].isEmpty()) pair[i] = null; + else any = true; + } + return !any; + }); + } + + /** Живые ленты для отрисовки (до двух на игрока). */ + List snapshot() { + List out = new ArrayList<>(tracks.size() * 2); + for (TrackBuffer[] pair : tracks.values()) { + for (TrackBuffer track : pair) { + if (track != null && !track.isEmpty()) out.add(track); + } + } + return out; + } + + int trackCount() { + return tracks.size(); + } +} diff --git a/mod/src/main/java/dev/loki/lovisual/features/module/modules/visuals/effects/projectiletrails/ProjectileTrails.java b/mod/src/main/java/dev/loki/lovisual/features/module/modules/visuals/effects/projectiletrails/ProjectileTrails.java index 98992f4..de567cd 100644 --- a/mod/src/main/java/dev/loki/lovisual/features/module/modules/visuals/effects/projectiletrails/ProjectileTrails.java +++ b/mod/src/main/java/dev/loki/lovisual/features/module/modules/visuals/effects/projectiletrails/ProjectileTrails.java @@ -8,6 +8,7 @@ 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.modules.visuals.effects.ribbon.TrailRibbon; import dev.loki.lovisual.features.module.phase.WorldPhase; import dev.loki.lovisual.render.engine.animation.AnimatedRenderColors; import dev.loki.lovisual.render.engine.renderer.Renderer3D; @@ -16,8 +17,6 @@ import net.minecraft.client.Minecraft; import net.minecraft.world.entity.Entity; import net.minecraft.world.phys.Vec3; -import java.util.ArrayList; -import java.util.List; import java.util.Map; /** Затухающие ленты за стрелами, жемчугом, трезубцами и другими снарядами. */ @@ -69,27 +68,13 @@ public class ProjectileTrails extends Module { store.prune(now, life, id -> mc.level.getEntity(id) != null); if (store.tracks().isEmpty()) return; int phase = Math.round((mc.player == null ? 0 : mc.player.tickCount + tickDelta) * 4.0f); - AnimatedRenderColors.Mode mode = parse(colorMode.get()); + AnimatedRenderColors.Mode mode = AnimatedRenderColors.modeOf(colorMode.get()); int primary = color.getArgb(); int secondary = color2.getArgb(); int speed = colorSpeed.get(); - List tracks = new ArrayList<>(store.tracks().values()); Vec3 cam = mc.gameRenderer.mainCamera().position(); - TrailRibbon.render(renderer, tracks, now, life, width.get(), fill.get(), "Glow".equals(fillType.get()), + TrailRibbon.render(renderer, store.tracks().values(), now, life, width.get(), fill.get(), "Glow".equals(fillType.get()), lines.get(), lineWidth.get(), depthTest.get(), cam, (i, n) -> AnimatedRenderColors.resolve(mode, speed, phase + Math.round(i / (float) Math.max(1, n - 1) * 180), primary, secondary, true)); } - - private static AnimatedRenderColors.Mode parse(String mode) { - return switch (mode) { - 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/mod/src/main/java/dev/loki/lovisual/features/module/modules/visuals/effects/projectiletrails/TrailStore.java b/mod/src/main/java/dev/loki/lovisual/features/module/modules/visuals/effects/projectiletrails/TrailStore.java index c84f88b..8edb98d 100644 --- a/mod/src/main/java/dev/loki/lovisual/features/module/modules/visuals/effects/projectiletrails/TrailStore.java +++ b/mod/src/main/java/dev/loki/lovisual/features/module/modules/visuals/effects/projectiletrails/TrailStore.java @@ -1,33 +1,22 @@ package dev.loki.lovisual.features.module.modules.visuals.effects.projectiletrails; +import dev.loki.lovisual.features.module.modules.visuals.effects.ribbon.TrackBuffer; import net.minecraft.core.registries.BuiltInRegistries; import net.minecraft.world.entity.Entity; import net.minecraft.world.entity.projectile.Projectile; import net.minecraft.world.phys.Vec3; -import java.util.ArrayDeque; +import java.util.HashMap; import java.util.Iterator; import java.util.Map; -import java.util.HashMap; -import java.util.function.Predicate; +import java.util.function.IntPredicate; -/** История позиций летящих снарядов: точки с метками времени, устаревшие и пропавшие следы вычищаются. */ +/** История позиций летящих снарядов по id сущности; пропавшие из мира снаряды дотлевают и удаляются. */ final class TrailStore { - /** Минимальный сдвиг между соседними точками (блоки), чтобы лента не копила шум. */ - private static final double MIN_STEP = 0.04; + private final Map tracks = new HashMap<>(); - record Point(double x, double y, double z, long ms) { - } - - static final class Track { - final ArrayDeque points = new ArrayDeque<>(); - long lastSeen; - } - - private final Map tracks = new HashMap<>(); - - Map tracks() { + Map tracks() { return tracks; } @@ -38,7 +27,11 @@ final class TrailStore { /** Ключ вида снаряда для настроек: arrow, trident, pearl, snowball, egg, potion, fireball, wind_charge, firework. */ static String kindOf(Entity entity) { if (!(entity instanceof Projectile)) return null; - String path = BuiltInRegistries.ENTITY_TYPE.getKey(entity.getType()).getPath(); + return kindOfPath(BuiltInRegistries.ENTITY_TYPE.getKey(entity.getType()).getPath()); + } + + /** Вид снаряда по пути id типа сущности (чистая логика); null для неподдерживаемых. */ + static String kindOfPath(String path) { if (path.contains("arrow")) return "arrow"; if (path.contains("trident")) return "trident"; if (path.contains("pearl")) return "pearl"; @@ -51,33 +44,22 @@ final class TrailStore { return null; } - /** Добавляет текущую позицию снаряда (интерполированную) и отбрасывает старые точки. */ + /** Добавляет текущую (интерполированную) позицию снаряда и отбрасывает старые точки. */ void sample(Entity entity, float partialTicks, long now, long lifeMs) { - Track track = tracks.computeIfAbsent(entity.getId(), id -> new Track()); - track.lastSeen = now; Vec3 p = entity.getPosition(partialTicks); - Point last = track.points.peekLast(); - if (last == null || last.ms() != now && dist(last, p) >= MIN_STEP) { - track.points.addLast(new Point(p.x, p.y, p.z, now)); + sample(entity.getId(), p.x, p.y, p.z, now, lifeMs); + } + + void sample(int id, double x, double y, double z, long now, long lifeMs) { + tracks.computeIfAbsent(id, key -> new TrackBuffer()).add(x, y, z, now, lifeMs); + } + + /** Чистит следы: пропавшие снаряды дотлевают, потом след удаляется. */ + void prune(long now, long lifeMs, IntPredicate alive) { + for (Iterator> it = tracks.entrySet().iterator(); it.hasNext(); ) { + Map.Entry e = it.next(); + if (!alive.test(e.getKey())) e.getValue().trim(now, lifeMs); + if (e.getValue().isEmpty()) it.remove(); } - trim(track, now, lifeMs); - } - - /** Чистит следы: пропавшие из мира снаряды дотлевают, потом удаляются. */ - void prune(long now, long lifeMs, Predicate alive) { - for (Iterator> it = tracks.entrySet().iterator(); it.hasNext(); ) { - Map.Entry e = it.next(); - if (!alive.test(e.getKey())) trim(e.getValue(), now, lifeMs); - if (e.getValue().points.isEmpty() || now - e.getValue().lastSeen > lifeMs * 2) it.remove(); - } - } - - private static void trim(Track track, long now, long lifeMs) { - while (!track.points.isEmpty() && now - track.points.peekFirst().ms() > lifeMs) track.points.pollFirst(); - } - - private static double dist(Point a, Vec3 b) { - double dx = a.x() - b.x, dy = a.y() - b.y, dz = a.z() - b.z; - return Math.sqrt(dx * dx + dy * dy + dz * dz); } } diff --git a/mod/src/main/java/dev/loki/lovisual/features/module/modules/visuals/effects/ribbon/RibbonPoint.java b/mod/src/main/java/dev/loki/lovisual/features/module/modules/visuals/effects/ribbon/RibbonPoint.java new file mode 100644 index 0000000..cd72db1 --- /dev/null +++ b/mod/src/main/java/dev/loki/lovisual/features/module/modules/visuals/effects/ribbon/RibbonPoint.java @@ -0,0 +1,5 @@ +package dev.loki.lovisual.features.module.modules.visuals.effects.ribbon; + +/** Точка следа: позиция в мире и время (мс) появления. */ +public record RibbonPoint(double x, double y, double z, long ms) { +} diff --git a/mod/src/main/java/dev/loki/lovisual/features/module/modules/visuals/effects/ribbon/TrackBuffer.java b/mod/src/main/java/dev/loki/lovisual/features/module/modules/visuals/effects/ribbon/TrackBuffer.java new file mode 100644 index 0000000..e4c9618 --- /dev/null +++ b/mod/src/main/java/dev/loki/lovisual/features/module/modules/visuals/effects/ribbon/TrackBuffer.java @@ -0,0 +1,62 @@ +package dev.loki.lovisual.features.module.modules.visuals.effects.ribbon; + +import java.util.ArrayDeque; +import java.util.ArrayList; +import java.util.List; + +/** + * История одной ленты: точки с метками времени. Сдвиг меньше {@link #MIN_STEP} точкой не становится, + * точки старше срока жизни отбрасываются. Общая основа для ProjectileTrails и ElytraTrails. + */ +public final class TrackBuffer { + + /** Минимальный сдвиг между соседними точками (блоки), чтобы лента не копила шум. */ + public static final double MIN_STEP = 0.04; + + private final ArrayDeque points = new ArrayDeque<>(); + private long lastSeen = Long.MIN_VALUE; + + /** + * Фиксирует позицию, затем убирает устаревшие точки. + * + * @return true, если добавлена новая точка + */ + public boolean add(double x, double y, double z, long now, long lifeMs) { + lastSeen = now; + RibbonPoint last = points.peekLast(); + boolean added = false; + if (last == null || (now != last.ms() && distance(last, x, y, z) >= MIN_STEP)) { + points.addLast(new RibbonPoint(x, y, z, now)); + added = true; + } + trim(now, lifeMs); + return added; + } + + public void trim(long now, long lifeMs) { + while (!points.isEmpty() && now - points.peekFirst().ms() > lifeMs) points.pollFirst(); + } + + public boolean isEmpty() { + return points.isEmpty(); + } + + public int size() { + return points.size(); + } + + /** Время последнего вызова {@link #add}, даже если точка не добавилась. */ + public long lastSeen() { + return lastSeen; + } + + /** Копия точек от хвоста к голове. */ + public List points() { + return new ArrayList<>(points); + } + + private static double distance(RibbonPoint a, double x, double y, double z) { + double dx = a.x() - x, dy = a.y() - y, dz = a.z() - z; + return Math.sqrt(dx * dx + dy * dy + dz * dz); + } +} diff --git a/mod/src/main/java/dev/loki/lovisual/features/module/modules/visuals/effects/projectiletrails/TrailRibbon.java b/mod/src/main/java/dev/loki/lovisual/features/module/modules/visuals/effects/ribbon/TrailRibbon.java similarity index 62% rename from mod/src/main/java/dev/loki/lovisual/features/module/modules/visuals/effects/projectiletrails/TrailRibbon.java rename to mod/src/main/java/dev/loki/lovisual/features/module/modules/visuals/effects/ribbon/TrailRibbon.java index 833992c..99e5cc1 100644 --- a/mod/src/main/java/dev/loki/lovisual/features/module/modules/visuals/effects/projectiletrails/TrailRibbon.java +++ b/mod/src/main/java/dev/loki/lovisual/features/module/modules/visuals/effects/ribbon/TrailRibbon.java @@ -1,6 +1,5 @@ -package dev.loki.lovisual.features.module.modules.visuals.effects.projectiletrails; +package dev.loki.lovisual.features.module.modules.visuals.effects.ribbon; -import dev.loki.lovisual.features.module.modules.visuals.effects.projectiletrails.TrailStore.Point; import dev.loki.lovisual.render.engine.RenderState; import dev.loki.lovisual.render.engine.color.RenderColor; import dev.loki.lovisual.render.engine.pipeline.LoVisualRenderPipelines; @@ -8,22 +7,45 @@ import dev.loki.lovisual.render.engine.renderer.Renderer3D; import dev.loki.lovisual.render.engine.uniform.MeshBuilder; import net.minecraft.world.phys.Vec3; -import java.util.ArrayList; +import java.util.Collection; import java.util.List; import java.util.function.IntBinaryOperator; -/** Строит ленту, повёрнутую к камере, с затуханием и сужением к хвосту, плюс контурные линии по краям. */ -final class TrailRibbon { +/** + * Лента, повёрнутая к камере: сужается и гаснет к хвосту, с необязательным контуром по краям. + * Общий рендер для ProjectileTrails и ElytraTrails. + */ +public final class TrailRibbon { private TrailRibbon() { } + /** Доля оставшейся жизни точки: 1 свежая, 0 истёкшая. */ + public static float lifeFraction(long now, long ms, long lifeMs) { + if (lifeMs <= 0) return 0.0f; + float left = 1.0f - (now - ms) / (float) lifeMs; + return Math.max(0.0f, Math.min(1.0f, left)); + } + + /** Альфа точки падает как life в квадрате, RGB не меняется. */ + public static int fadeAlpha(int argb, float life) { + float l = Math.max(0.0f, Math.min(1.0f, life)); + int alpha = Math.round(((argb >>> 24) & 0xFF) * l * l); + return (alpha << 24) | (argb & 0x00FFFFFF); + } + + /** Полуширина ленты: у свежей точки половина {@code width}, к хвосту сужается до четверти. */ + public static double halfWidth(float width, float life) { + float l = Math.max(0.0f, Math.min(1.0f, life)); + return width * 0.5 * (0.25 + 0.75 * l); + } + /** * @param color цвет точки: (индекс от хвоста, число точек) -> ARGB */ - static void render(Renderer3D renderer, List tracks, long now, long lifeMs, float width, - boolean fill, boolean glow, boolean lines, float lineWidth, boolean depth, Vec3 cam, - IntBinaryOperator color) { + public static void render(Renderer3D renderer, Collection tracks, long now, long lifeMs, float width, + boolean fill, boolean glow, boolean lines, float lineWidth, boolean depth, Vec3 cam, + IntBinaryOperator color) { MeshBuilder fillMesh = null; if (fill) { var pipeline = glow @@ -43,34 +65,32 @@ final class TrailRibbon { } } if (fillMesh == null && lineMesh == null) return; - for (TrailStore.Track track : tracks) { - List pts = new ArrayList<>(track.points); + for (TrackBuffer track : tracks) { + List pts = track.points(); if (pts.size() >= 2) draw(pts, now, lifeMs, width, fillMesh, lineMesh, cam, color); } } - private static void draw(List pts, long now, long lifeMs, float width, MeshBuilder fill, MeshBuilder line, - Vec3 cam, IntBinaryOperator color) { + private static void draw(List pts, long now, long lifeMs, float width, MeshBuilder fill, + MeshBuilder line, Vec3 cam, IntBinaryOperator color) { int n = pts.size(); double[][] left = new double[n][3]; double[][] right = new double[n][3]; int[] argb = new int[n]; for (int i = 0; i < n; i++) { - Point p = pts.get(i); - Point a = pts.get(Math.max(0, i - 1)); - Point b = pts.get(Math.min(n - 1, i + 1)); + RibbonPoint p = pts.get(i); + RibbonPoint a = pts.get(Math.max(0, i - 1)); + RibbonPoint b = pts.get(Math.min(n - 1, i + 1)); double dx = b.x() - a.x(), dy = b.y() - a.y(), dz = b.z() - a.z(); double vx = cam.x - p.x(), vy = cam.y - p.y(), vz = cam.z - p.z(); double sx = dy * vz - dz * vy, sy = dz * vx - dx * vz, sz = dx * vy - dy * vx; double len = Math.sqrt(sx * sx + sy * sy + sz * sz); if (len < 1e-6) len = 1.0; - float life = 1.0f - Math.min(1.0f, (now - p.ms()) / (float) lifeMs); - double half = width * 0.5 * (0.25 + 0.75 * life) / len; + float life = lifeFraction(now, p.ms(), lifeMs); + double half = halfWidth(width, life) / len; left[i] = new double[]{p.x() + sx * half, p.y() + sy * half, p.z() + sz * half}; right[i] = new double[]{p.x() - sx * half, p.y() - sy * half, p.z() - sz * half}; - int base = color.applyAsInt(i, n); - int alpha = Math.round(((base >>> 24) & 0xFF) * life * life); - argb[i] = (alpha << 24) | (base & 0x00FFFFFF); + argb[i] = fadeAlpha(color.applyAsInt(i, n), life); } for (int i = 1; i < n; i++) { if (fill != null) quad(fill, left[i - 1], right[i - 1], right[i], left[i], argb[i - 1], argb[i]); diff --git a/mod/src/main/java/dev/loki/lovisual/features/module/modules/visuals/weather/blizzard/Blizzard.java b/mod/src/main/java/dev/loki/lovisual/features/module/modules/visuals/weather/blizzard/Blizzard.java new file mode 100644 index 0000000..bd8fe03 --- /dev/null +++ b/mod/src/main/java/dev/loki/lovisual/features/module/modules/visuals/weather/blizzard/Blizzard.java @@ -0,0 +1,133 @@ +package dev.loki.lovisual.features.module.modules.visuals.weather.blizzard; + +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.features.module.phase.WorldPhase; +import dev.loki.lovisual.render.engine.RenderState; +import dev.loki.lovisual.render.engine.pipeline.LoVisualRenderPipelines; +import dev.loki.lovisual.render.engine.renderer.Renderer2D; +import dev.loki.lovisual.render.engine.renderer.Renderer3D; +import dev.loki.lovisual.render.engine.text.render.TextRenderer; +import dev.loki.lovisual.render.engine.uniform.MeshBuilder; +import net.minecraft.client.Camera; +import net.minecraft.client.Minecraft; +import net.minecraft.client.gui.GuiGraphicsExtractor; +import net.minecraft.core.BlockPos; +import net.minecraft.util.Mth; +import net.minecraft.world.level.biome.Biome; +import net.minecraft.world.phys.Vec3; + +/** + * Метель: снежинки со сносом в мире, белая дымка и обмерзание краёв экрана. + * Фасад: симуляция в {@link SnowFlakes}, 3D-рендер в {@link BlizzardRenderer}, + * экранное покрытие в {@link BlizzardOverlay}. + */ +@ModuleInfo( + id = "blizzard", + displayName = "Blizzard", + category = ModuleCategory.VISUALS, + description = "Wind-driven snowstorm with white haze and frosted screen edges" +) +public final class Blizzard extends Module { + + private final NumberValue intensity = num("blizzardIntensity", "intensity", 0.85f, 0.1f, 1.0f); + private final NumberValue density = num("blizzardDensity", "density", 1.0f, 0.2f, 3.0f); + private final NumberValue speed = num("blizzardSpeed", "speed", 1.0f, 0.3f, 3.0f); + private final NumberValue wind = num("blizzardWind", "wind", 0.7f, 0.0f, 2.0f); + private final NumberValue radius = num("blizzardRadius", "radius", 14.0f, 6.0f, 32.0f); + private final NumberValue flakeSize = num("blizzardFlakeSize", "flake_size", 1.0f, 0.3f, 2.5f); + private final NumberValue opacity = num("blizzardOpacity", "opacity", 0.7f, 0.05f, 1.0f); + private final RGBAColorValue tint = color("blizzardTint", "tint", "#F2FFFFFF"); + private final NumberValue haze = num("blizzardHaze", "haze", 0.5f, 0.0f, 1.0f); + private final BooleanValue frost = bool("blizzardFrost", "frost", true); + private final NumberValue frostIntensity = + visibleWhen(num("blizzardFrostIntensity", "frost_intensity", 0.6f, 0.0f, 1.0f), frost::get); + private final BooleanValue onlyWhenSnowing = bool("blizzardOnlyWhenSnowing", "only_when_snowing", false); + + private final SnowFlakes flakes = new SnowFlakes(); + private long lastFrameNanos; + private float seconds; + private float strength; + + @Override + public HudPhase getHudPhase() { + return HudPhase.AFTER_SUBTITLES; + } + + @Override + public WorldPhase getWorldPhase() { + return WorldPhase.END_MAIN; + } + + @Override + public void onEnable() { + lastFrameNanos = 0L; + seconds = 0.0f; + strength = 0.0f; + flakes.clear(); + } + + @Override + public void onDisable() { + flakes.clear(); + } + + private float frameDeltaSeconds() { + long now = System.nanoTime(); + if (lastFrameNanos == 0L) lastFrameNanos = now; + float dt = (float) ((now - lastFrameNanos) / 1_000_000_000.0); + lastFrameNanos = now; + return Mth.clamp(dt, 0.0f, 0.05f); + } + + private float targetStrength() { + Minecraft mc = Minecraft.getInstance(); + if (mc.player == null || mc.level == null) return 0.0f; + if (!onlyWhenSnowing.get()) return intensity.get(); + if (mc.level.getRainLevel(1.0f) < 0.02f) return 0.0f; + BlockPos pos = mc.player.blockPosition(); + Biome.Precipitation precipitation = + mc.level.getBiome(pos).value().getPrecipitationAt(pos, mc.level.getSeaLevel()); + return precipitation == Biome.Precipitation.SNOW ? intensity.get() : 0.0f; + } + + @Override + public void onRenderWorldEngine(Renderer3D renderer, Renderer3D depthRenderer, float tickDelta) { + Minecraft mc = Minecraft.getInstance(); + if (!isEnabled() || mc.player == null || mc.level == null || mc.gameRenderer == null) return; + if (mc.gameRenderer.mainCamera() == null) return; + float dt = frameDeltaSeconds(); + seconds += dt; + strength = SnowFlakes.ramp(strength, targetStrength(), dt); + float norm = strength; + if (norm <= 0.001f) { + flakes.clear(); + return; + } + Camera camera = mc.gameRenderer.mainCamera(); + Vec3 cam = camera.position(); + SnowFlakes.Wind windValue = SnowFlakes.wind(seconds, wind.get(), norm); + int target = SnowFlakes.targetCount(radius.get(), density.get(), norm); + flakes.update(dt, cam.x, cam.y, cam.z, radius.get(), windValue, + SnowFlakes.turbulence(wind.get()), speed.get(), target, seconds); + if (flakes.count == 0) return; + MeshBuilder mesh = renderer.batch(LoVisualRenderPipelines.WORLD_COLORED_DEPTH, Renderer3D.DepthMode.MAIN); + if (mesh == null) return; + BlizzardRenderer.render(mesh, flakes, RenderState.cameraRotation, + cam.x, cam.y, cam.z, radius.get(), tint.getArgb(), norm, flakeSize.get(), opacity.get()); + } + + @Override + public void onRenderHudEngine(Renderer2D renderer, TextRenderer textRenderer, + GuiGraphicsExtractor ctx, float tickDelta) { + if (!isEnabled()) return; + float norm = strength; + if (norm <= 0.01f) return; + BlizzardOverlay.render(ctx, norm, haze.get(), frost.get(), frostIntensity.get(), tint.getArgb()); + } +} diff --git a/mod/src/main/java/dev/loki/lovisual/features/module/modules/visuals/weather/blizzard/BlizzardOverlay.java b/mod/src/main/java/dev/loki/lovisual/features/module/modules/visuals/weather/blizzard/BlizzardOverlay.java new file mode 100644 index 0000000..c23c517 --- /dev/null +++ b/mod/src/main/java/dev/loki/lovisual/features/module/modules/visuals/weather/blizzard/BlizzardOverlay.java @@ -0,0 +1,79 @@ +package dev.loki.lovisual.features.module.modules.visuals.weather.blizzard; + +import net.minecraft.client.gui.GuiGraphicsExtractor; + +/** + * Белая дымка и обмерзание краёв экрана: слой из полупрозрачных рамок, + * каждая следующая тоньше, поэтому альфа копится к самому краю. + */ +final class BlizzardOverlay { + + static final int FROST_BANDS = 16; + static final float MAX_VEIL = 0.85f; + static final float VEIL_SCALE = 0.55f; + static final float FROST_TOTAL = 0.6f; + + private BlizzardOverlay() { + } + + static void render(GuiGraphicsExtractor ctx, float strength, float haze, + boolean frost, float frostStrength, int tintArgb) { + if (ctx == null || strength <= 0.01f) return; + int w = ctx.guiWidth(); + int h = ctx.guiHeight(); + if (w <= 0 || h <= 0) return; + float veil = veilAlpha(haze, strength); + if (veil > 0.004f) ctx.fill(0, 0, w, h, withAlpha(tintArgb, veil)); + if (!frost || frostStrength <= 0.01f) return; + float total = frostTotal(strength, frostStrength); + if (total <= 0.004f) return; + int color = withAlpha(tintArgb, frostBandAlpha(total)); + int depth = Math.max(2, Math.round(Math.min(w, h) * 0.20f)); + for (int i = 0; i < FROST_BANDS; i++) { + int thickness = bandThickness(i, FROST_BANDS, depth); + ctx.fill(0, 0, w, thickness, color); + ctx.fill(0, h - thickness, w, h, color); + ctx.fill(0, thickness, thickness, h - thickness, color); + ctx.fill(w - thickness, thickness, w, h - thickness, color); + } + } + + /** Непрозрачность белой дымки на весь экран: растёт с дымкой и силой шторма, ограничена {@link #MAX_VEIL}. */ + static float veilAlpha(float haze, float strength) { + return Math.max(0.0f, Math.min(MAX_VEIL, haze * strength * VEIL_SCALE)); + } + + /** Суммарная непрозрачность обмерзания у самого края. */ + static float frostTotal(float strength, float frostStrength) { + return Math.max(0.0f, FROST_TOTAL * strength * frostStrength); + } + + /** Непрозрачность одной полосы обмерзания: все полосы вместе дают {@code total}. */ + static float frostBandAlpha(float total) { + return total / FROST_BANDS; + } + + /** Толщина i-й полосы: первая на всю глубину, каждая следующая тоньше, поэтому альфа копится у края. */ + static int bandThickness(int i, int bands, int depth) { + return Math.max(1, Math.round(depth * (1.0f - (float) Math.sqrt(i / (double) bands)))); + } + + static int withAlpha(int argb, float alpha) { + int a = Math.round(alpha * 255.0f); + if (a < 0) a = 0; + if (a > 255) a = 255; + return (a << 24) | (argb & 0x00FFFFFF); + } + + /** Доля полос обмерзания, покрывающая точку на distance от края экрана. */ + static float edgeCoverage(float distance, float depth, int bands) { + if (bands <= 0 || depth <= 0.0f) return 0.0f; + if (distance <= 0.0f) return 1.0f; + int covered = 0; + for (int i = 0; i < bands; i++) { + float inset = depth * (1.0f - (float) Math.sqrt(i / (double) bands)); + if (distance < inset) covered++; + } + return covered / (float) bands; + } +} diff --git a/mod/src/main/java/dev/loki/lovisual/features/module/modules/visuals/weather/blizzard/BlizzardRenderer.java b/mod/src/main/java/dev/loki/lovisual/features/module/modules/visuals/weather/blizzard/BlizzardRenderer.java new file mode 100644 index 0000000..6d5e1fe --- /dev/null +++ b/mod/src/main/java/dev/loki/lovisual/features/module/modules/visuals/weather/blizzard/BlizzardRenderer.java @@ -0,0 +1,119 @@ +package dev.loki.lovisual.features.module.modules.visuals.weather.blizzard; + +import dev.loki.lovisual.render.engine.color.RenderColor; +import dev.loki.lovisual.render.engine.uniform.MeshBuilder; +import org.joml.Quaternionf; +import org.joml.Vector3f; + +/** + * Рендер снежинок: биллборд-квады, вытянутые по вектору скорости частицы, + * с затуханием у камеры и на границе зоны. + */ +final class BlizzardRenderer { + + private BlizzardRenderer() { + } + + static void render(MeshBuilder mesh, SnowFlakes flakes, Quaternionf camRot, + double camX, double camY, double camZ, float radius, + int argb, float strength, float sizeMul, float opacity) { + if (mesh == null || flakes.count == 0 || strength <= 0.001f || opacity <= 0.004f) return; + int r = (argb >>> 16) & 0xFF; + int g = (argb >>> 8) & 0xFF; + int b = argb & 0xFF; + Vector3f right = new Vector3f(1.0f, 0.0f, 0.0f).rotate(camRot); + Vector3f up = new Vector3f(0.0f, 1.0f, 0.0f).rotate(camRot); + Vector3f forward = new Vector3f(0.0f, 0.0f, -1.0f).rotate(camRot); + for (int i = 0; i < flakes.count; i++) { + double dx = flakes.x[i] - camX; + double dy = flakes.y[i] - camY; + double dz = flakes.z[i] - camZ; + float dist = (float) Math.sqrt(dx * dx + dy * dy + dz * dz); + if (dist < 0.2f) continue; + float facing = (float) (dx * forward.x + dy * forward.y + dz * forward.z); + if (facing <= 0.0f) continue; + + float speedX = flakes.vx[i]; + float speedY = flakes.vy[i]; + float speedZ = flakes.vz[i]; + float along = right.x * speedX + right.y * speedY + right.z * speedZ; + float across = up.x * speedX + up.y * speedY + up.z * speedZ; + float alongLen = (float) Math.sqrt(along * along + across * across); + if (alongLen < 1.0E-4f) { + along = 0.0f; + across = -1.0f; + alongLen = 1.0f; + } + along /= alongLen; + across /= alongLen; + // Единичный вектор скорости в плоскости биллборда. + float dirX = right.x * along + up.x * across; + float dirY = right.y * along + up.y * across; + float dirZ = right.z * along + up.z * across; + // Поперёк: forward x dir (оба перпендикулярны друг другу). + float sideX = forward.y * dirZ - forward.z * dirY; + float sideY = forward.z * dirX - forward.x * dirZ; + float sideZ = forward.x * dirY - forward.y * dirX; + float sideLen = (float) Math.sqrt(sideX * sideX + sideY * sideY + sideZ * sideZ); + if (sideLen < 1.0E-4f) continue; + sideX /= sideLen; + sideY /= sideLen; + sideZ /= sideLen; + + float speed = (float) Math.sqrt(speedX * speedX + speedY * speedY + speedZ * speedZ); + float halfWidth = halfWidth(flakes.size[i], sizeMul, dist); + float halfLength = halfLength(halfWidth, speed, sizeMul); + float alpha = flakeAlpha(dist, flakes.age[i], flakes.alpha[i], radius, opacity, strength); + if (alpha <= 0.0f) continue; + + float cx = (float) flakes.x[i]; + float cy = (float) flakes.y[i]; + float cz = (float) flakes.z[i]; + float ax = dirX * halfLength; + float ay = dirY * halfLength; + float az = dirZ * halfLength; + float bx = sideX * halfWidth; + float by = sideY * halfWidth; + float bz = sideZ * halfWidth; + RenderColor color = new RenderColor(((int) (alpha * 255.0f) << 24) | (r << 16) | (g << 8) | b); + mesh.ensureQuadCapacity(); + int i1 = mesh.vec3(cx - ax - bx, cy - ay - by, cz - az - bz).color(color).next(); + int i2 = mesh.vec3(cx + ax - bx, cy + ay - by, cz + az - bz).color(color).next(); + int i3 = mesh.vec3(cx + ax + bx, cy + ay + by, cz + az + bz).color(color).next(); + int i4 = mesh.vec3(cx - ax + bx, cy - ay + by, cz - az + bz).color(color).next(); + mesh.quad(i1, i2, i3, i4); + } + } + + /** + * Видимость снежинки: невидима у самой линзы, плавно проявляется с расстоянием и возрастом, + * тает к границе зоны шторма; масштабируется прозрачностью и силой шторма, не бывает полностью непрозрачной. + * Слишком слабые снежинки возвращают 0, чтобы их не рисовать. + */ + static float flakeAlpha(float dist, float ageSeconds, float baseAlpha, float radius, float opacity, float strength) { + float edgeStart = radius * 0.62f; + float edgeSpan = Math.max(0.001f, radius * 1.05f - edgeStart); + float edge = 1.0f - clamp01((dist - edgeStart) / edgeSpan); + float near = clamp01((dist - 0.35f) / 0.5f); + float ageFade = Math.min(1.0f, ageSeconds / 0.45f); + float alpha = opacity * strength * baseAlpha * edge * near * ageFade; + if (alpha <= 0.008f) return 0.0f; + return Math.min(0.95f, alpha); + } + + /** Полуширина снежинки; вдали не тоньше доли пикселя, чтобы не пропадала. */ + static float halfWidth(float flakeSize, float sizeMul, float dist) { + float hw = (float) (0.045 * flakeSize) * sizeMul; + return Math.max(hw, dist * 0.0016f); + } + + /** Полудлина штриха: быстрее снежинка, длиннее след, но не больше 0.35 блока. */ + static float halfLength(float halfWidth, float speed, float sizeMul) { + return halfWidth + Math.min(0.35f, speed * 0.022f * sizeMul); + } + + private static float clamp01(float v) { + if (v < 0.0f) return 0.0f; + return v > 1.0f ? 1.0f : v; + } +} diff --git a/mod/src/main/java/dev/loki/lovisual/features/module/modules/visuals/weather/blizzard/SnowFlakes.java b/mod/src/main/java/dev/loki/lovisual/features/module/modules/visuals/weather/blizzard/SnowFlakes.java new file mode 100644 index 0000000..7f9355f --- /dev/null +++ b/mod/src/main/java/dev/loki/lovisual/features/module/modules/visuals/weather/blizzard/SnowFlakes.java @@ -0,0 +1,119 @@ +package dev.loki.lovisual.features.module.modules.visuals.weather.blizzard; + +import java.util.Random; + +/** + * Чистая физика снежинок: спавн в цилиндре вокруг камеры, ветер с порывами, + * турбулентность и рецикл при выходе из зоны. Без зависимостей от Minecraft. + */ +final class SnowFlakes { + + static final int MAX_FLAKES = 4000; + static final float FALL_SPEED = 1.6f; + static final float AREA_FACTOR = 3.0f; + static final double SPAWN_ABOVE = 8.0; + static final double SPAWN_BELOW = 6.0; + static final double RECYCLE_MARGIN = 1.25; + + /** Горизонтальный ветер с лёгким вертикальным сносом. */ + record Wind(double x, double y, double z) { + static final Wind ZERO = new Wind(0.0, 0.0, 0.0); + } + + final double[] x = new double[MAX_FLAKES]; + final double[] y = new double[MAX_FLAKES]; + final double[] z = new double[MAX_FLAKES]; + final float[] vx = new float[MAX_FLAKES]; + final float[] vy = new float[MAX_FLAKES]; + final float[] vz = new float[MAX_FLAKES]; + final float[] fall = new float[MAX_FLAKES]; + final float[] size = new float[MAX_FLAKES]; + final float[] alpha = new float[MAX_FLAKES]; + final float[] phase = new float[MAX_FLAKES]; + final float[] rate = new float[MAX_FLAKES]; + final float[] age = new float[MAX_FLAKES]; + int count; + + private final Random random = new Random(1L); + + void clear() { + count = 0; + } + + /** Фиксация seed — детерминизм для тестов. */ + void reseed(long seed) { + random.setSeed(seed); + } + + /** Плавный разгон (0.45/с) и затухание (0.6/с) силы шторма к цели, без перелёта. */ + static float ramp(float current, float target, float dt) { + if (current < target) return Math.min(target, current + 0.45f * dt); + return Math.max(target, current - 0.6f * dt); + } + + /** Целевое число частиц: площадь цилиндра * плотность * доля силы шторма. */ + static int targetCount(float radius, float density, float strength) { + if (radius <= 0f || density <= 0f || strength <= 0f) return 0; + double raw = Math.PI * radius * radius * AREA_FACTOR * density * Math.min(1.0f, strength); + if (raw >= MAX_FLAKES) return MAX_FLAKES; + return (int) raw; + } + + /** Ветер с порывами: направление медленно кружит, скорость дышит несколькими синусами. */ + static Wind wind(float seconds, float windPower, float strength) { + if (strength <= 0f || windPower <= 0f) return Wind.ZERO; + double angle = seconds * 0.17 + Math.sin(seconds * 0.45) * 0.35; + double gust = 1.0 + Math.sin(seconds * 0.9) * 0.35 + Math.sin(seconds * 2.3) * 0.15; + double speed = windPower * 14.0 * gust * strength; + return new Wind(Math.cos(angle) * speed, Math.sin(seconds * 2.3) * speed * 0.12, Math.sin(angle) * speed); + } + + /** Амплитуда боковой турбулентности, зависящей от мощности ветра. */ + static float turbulence(float windPower) { + return 0.25f + 0.55f * Math.max(0f, windPower); + } + + void update(float dt, double camX, double camY, double camZ, float radius, + Wind wind, float turbulence, float speedMul, int target, float seconds) { + if (dt <= 0f || target < 0) return; + if (count > target) count = target; + while (count < target) spawn(count++, camX, camY, camZ, radius); + double recycle = radius * RECYCLE_MARGIN; + double recycleSq = recycle * recycle; + for (int i = 0; i < count; i++) { + float wobbleX = (float) Math.sin(seconds * rate[i] + phase[i]) * turbulence * speedMul; + float wobbleZ = (float) Math.cos(seconds * rate[i] * 0.83f + phase[i] * 1.7f) * turbulence * speedMul; + vx[i] = (float) wind.x() + wobbleX; + vy[i] = (float) wind.y() - FALL_SPEED * fall[i] * speedMul; + vz[i] = (float) wind.z() + wobbleZ; + x[i] += vx[i] * dt; + y[i] += vy[i] * dt; + z[i] += vz[i] * dt; + age[i] += dt; + double dx = x[i] - camX; + double dz = z[i] - camZ; + if (dx * dx + dz * dz > recycleSq + || y[i] < camY - SPAWN_BELOW + || y[i] > camY + SPAWN_ABOVE) { + spawn(i, camX, camY, camZ, radius); + } + } + } + + private void spawn(int i, double camX, double camY, double camZ, float radius) { + double angle = random.nextDouble() * Math.PI * 2.0; + double dist = radius * Math.sqrt(random.nextDouble()); + x[i] = camX + Math.cos(angle) * dist; + z[i] = camZ + Math.sin(angle) * dist; + y[i] = camY + SPAWN_ABOVE - random.nextDouble() * (SPAWN_ABOVE + SPAWN_BELOW); + vx[i] = 0f; + vy[i] = -FALL_SPEED; + vz[i] = 0f; + fall[i] = 0.6f + random.nextFloat() * 0.8f; + size[i] = 0.75f + random.nextFloat() * 0.55f; + alpha[i] = 0.35f + random.nextFloat() * 0.55f; + phase[i] = random.nextFloat() * (float) (Math.PI * 2.0); + rate[i] = 1.2f + random.nextFloat() * 2.2f; + age[i] = 0f; + } +} diff --git a/mod/src/main/java/dev/loki/lovisual/render/engine/animation/AnimatedRenderColors.java b/mod/src/main/java/dev/loki/lovisual/render/engine/animation/AnimatedRenderColors.java index 1a8e84b..a38a878 100644 --- a/mod/src/main/java/dev/loki/lovisual/render/engine/animation/AnimatedRenderColors.java +++ b/mod/src/main/java/dev/loki/lovisual/render/engine/animation/AnimatedRenderColors.java @@ -31,6 +31,21 @@ public enum AnimatedRenderColors { THEME } + /** Режим по имени варианта настройки ("Rainbow", "LightRainbow", "DoubleColor"...); неизвестное имя даёт STATIC. */ + public static Mode modeOf(String option) { + if (option == null) return Mode.STATIC; + return switch (option) { + case "Rainbow" -> Mode.RAINBOW; + case "LightRainbow" -> Mode.LIGHT_RAINBOW; + case "Sky" -> Mode.SKY; + case "Fade" -> Mode.FADE; + case "DoubleColor" -> Mode.DOUBLE_COLOR; + case "Analogous" -> Mode.ANALOGOUS; + case "Theme" -> Mode.THEME; + default -> Mode.STATIC; + }; + } + public static boolean usesSecondary(Mode mode) { return mode == Mode.DOUBLE_COLOR || mode == Mode.ANALOGOUS; } diff --git a/mod/src/main/resources/assets/lovisual/lang/en_us.json b/mod/src/main/resources/assets/lovisual/lang/en_us.json index 6094ebd..f9c6ce7 100644 --- a/mod/src/main/resources/assets/lovisual/lang/en_us.json +++ b/mod/src/main/resources/assets/lovisual/lang/en_us.json @@ -3635,6 +3635,7 @@ "module.spatialgui.description": "Renders the interface as a floating panel in 3D space.", "module.trinket.description": "Attaches decorative trinkets to your character.", "module.wet_world.description": "Makes surfaces look wet during rain.", + "module.elytratrails.description": "Streams glowing trails from the elytra wing tips while gliding.", "module.projectiletrails.description": "Draws fading trails behind arrows, pearls and other projectiles.", "setting.projectiletrails.duration": "Duration (ms)", "setting.projectiletrails.width": "Width", @@ -3684,5 +3685,38 @@ "setting.guipresence.share": "Share my menu", "setting.guipresence.show_others": "Show other players' menus", "setting.guipresence.distance": "Distance", - "setting.guipresence.scale": "Panel size" + "setting.guipresence.scale": "Panel size", + "module.blizzard.description": "Wind-driven snowstorm with white haze and frosted screen edges.", + "setting.elytratrails.others": "Other players", + "setting.elytratrails.duration": "Duration (ms)", + "setting.elytratrails.width": "Width", + "setting.elytratrails.glow": "Glow", + "setting.elytratrails.lines": "Outline", + "setting.elytratrails.line_width": "Outline width", + "setting.elytratrails.depth_test": "Depth test", + "setting.elytratrails.color_mode": "Color mode", + "setting.elytratrails.color": "Color", + "setting.elytratrails.color2": "Second color", + "setting.elytratrails.color_speed": "Color speed", + "setting.elytratrails.color_phase": "Color phase", + "setting.blizzard.intensity": "Intensity", + "setting.blizzard.density": "Density", + "setting.blizzard.speed": "Fall speed", + "setting.blizzard.wind": "Wind", + "setting.blizzard.radius": "Radius", + "setting.blizzard.flake_size": "Flake size", + "setting.blizzard.opacity": "Flake opacity", + "setting.blizzard.tint": "Tint", + "setting.blizzard.haze": "Haze", + "setting.blizzard.frost": "Frosted edges", + "setting.blizzard.frost_intensity": "Frost intensity", + "setting.blizzard.only_when_snowing": "Only when snowing", + "setting.elytratrails.color_mode.option.Static": "Static", + "setting.elytratrails.color_mode.option.Rainbow": "Rainbow", + "setting.elytratrails.color_mode.option.LightRainbow": "Light Rainbow", + "setting.elytratrails.color_mode.option.Sky": "Sky", + "setting.elytratrails.color_mode.option.Fade": "Fade", + "setting.elytratrails.color_mode.option.DoubleColor": "Double Color", + "setting.elytratrails.color_mode.option.Analogous": "Analogous", + "setting.elytratrails.color_mode.option.Theme": "Theme" } diff --git a/mod/src/main/resources/assets/lovisual/lang/ru_ru.json b/mod/src/main/resources/assets/lovisual/lang/ru_ru.json index 6da1e7c..c9f89a0 100644 --- a/mod/src/main/resources/assets/lovisual/lang/ru_ru.json +++ b/mod/src/main/resources/assets/lovisual/lang/ru_ru.json @@ -3426,6 +3426,7 @@ "module.spatialgui.description": "Рисует интерфейс плавающей панелью в 3D-пространстве.", "module.trinket.description": "Крепит декоративные безделушки к вашему персонажу.", "module.wet_world.description": "Делает поверхности мокрыми во время дождя.", + "module.elytratrails.description": "Тянет светящиеся шлейфы из концов крыльев элитры в полёте.", "module.projectiletrails.description": "Рисует затухающие ленты за стрелами, жемчугом и другими снарядами.", "setting.projectiletrails.duration": "Длительность (мс)", "setting.projectiletrails.width": "Ширина", @@ -3475,5 +3476,38 @@ "setting.guipresence.share": "Показывать моё меню", "setting.guipresence.show_others": "Показывать меню других игроков", "setting.guipresence.distance": "Дистанция", - "setting.guipresence.scale": "Размер панели" + "setting.guipresence.scale": "Размер панели", + "module.blizzard.description": "Метель с ветром, белой дымкой и обмерзанием краёв экрана.", + "setting.elytratrails.others": "Другие игроки", + "setting.elytratrails.duration": "Длительность (мс)", + "setting.elytratrails.width": "Ширина", + "setting.elytratrails.glow": "Свечение", + "setting.elytratrails.lines": "Контур", + "setting.elytratrails.line_width": "Толщина контура", + "setting.elytratrails.depth_test": "Проверка глубины", + "setting.elytratrails.color_mode": "Режим цвета", + "setting.elytratrails.color": "Цвет", + "setting.elytratrails.color2": "Второй цвет", + "setting.elytratrails.color_speed": "Скорость цвета", + "setting.elytratrails.color_phase": "Фаза цвета", + "setting.blizzard.intensity": "Интенсивность", + "setting.blizzard.density": "Плотность", + "setting.blizzard.speed": "Скорость падения", + "setting.blizzard.wind": "Ветер", + "setting.blizzard.radius": "Радиус", + "setting.blizzard.flake_size": "Размер снежинок", + "setting.blizzard.opacity": "Прозрачность снежинок", + "setting.blizzard.tint": "Оттенок", + "setting.blizzard.haze": "Дымка", + "setting.blizzard.frost": "Обмерзание краёв", + "setting.blizzard.frost_intensity": "Сила обмерзания", + "setting.blizzard.only_when_snowing": "Только во время снега", + "setting.elytratrails.color_mode.option.Static": "Статичный", + "setting.elytratrails.color_mode.option.Rainbow": "Радуга", + "setting.elytratrails.color_mode.option.LightRainbow": "Светлая радуга", + "setting.elytratrails.color_mode.option.Sky": "Небо", + "setting.elytratrails.color_mode.option.Fade": "Плавный", + "setting.elytratrails.color_mode.option.DoubleColor": "Два цвета", + "setting.elytratrails.color_mode.option.Analogous": "Аналоговый", + "setting.elytratrails.color_mode.option.Theme": "Тема" } diff --git a/mod/src/test/java/dev/loki/lovisual/features/module/modules/visuals/effects/elytratrails/WingTipLocatorTest.java b/mod/src/test/java/dev/loki/lovisual/features/module/modules/visuals/effects/elytratrails/WingTipLocatorTest.java new file mode 100644 index 0000000..89989e4 --- /dev/null +++ b/mod/src/test/java/dev/loki/lovisual/features/module/modules/visuals/effects/elytratrails/WingTipLocatorTest.java @@ -0,0 +1,81 @@ +package dev.loki.lovisual.features.module.modules.visuals.effects.elytratrails; + +import net.minecraft.world.phys.Vec3; +import org.junit.jupiter.api.Test; + +import static org.junit.jupiter.api.Assertions.*; + +/** Спецификация концов крыльев элитры: где они должны быть относительно планирующего игрока. */ +class WingTipLocatorTest { + + private static final double EPS = 1.0e-6; + private static final float[][] POSES = {{0, 0}, {37, -12}, {-120, 30}, {90, 55}, {200, -70}, {45, 89}}; + + /** Единичный вектор взгляда по правилам Minecraft: yaw 0 смотрит на +Z, pitch>0 вниз. */ + private static Vec3 view(float yawDeg, float pitchDeg) { + double y = Math.toRadians(yawDeg), p = Math.toRadians(pitchDeg); + return new Vec3(-Math.sin(y) * Math.cos(p), -Math.sin(p), Math.cos(y) * Math.cos(p)); + } + + @Test + void tipsAreOneFullSpanApartInEveryPose() { + for (float[] pose : POSES) { + WingTipLocator.Tips t = WingTipLocator.tips(new Vec3(10, 64, -3), pose[0], pose[1]); + assertEquals(2.0 * WingTipLocator.HALF_SPAN, t.left().distanceTo(t.right()), EPS); + } + } + + @Test + void wingSpanIsPerpendicularToTheFlightDirection() { + for (float[] pose : POSES) { + WingTipLocator.Tips t = WingTipLocator.tips(Vec3.ZERO, pose[0], pose[1]); + assertEquals(0.0, t.left().subtract(t.right()).dot(view(pose[0], pose[1])), EPS); + } + } + + @Test + void wingsStayLevelLikeAnAeroplaneWithoutRoll() { + for (float[] pose : POSES) { + WingTipLocator.Tips t = WingTipLocator.tips(new Vec3(0, 70, 0), pose[0], pose[1]); + assertEquals(t.left().y, t.right().y, EPS); + } + } + + @Test + void leftTipIsOnThePlayersLeftHandSide() { + // Facing +Z (yaw 0) the player's left is +X; facing -X (yaw 90) it is +Z. + WingTipLocator.Tips south = WingTipLocator.tips(Vec3.ZERO, 0.0f, 0.0f); + assertTrue(south.left().x > south.right().x); + WingTipLocator.Tips west = WingTipLocator.tips(Vec3.ZERO, 90.0f, 0.0f); + assertTrue(west.left().z > west.right().z); + } + + @Test + void tipsAreSymmetricAroundAnAnchorBehindTheHead() { + Vec3 eye = new Vec3(5, 80, 5); + for (float[] pose : POSES) { + WingTipLocator.Tips t = WingTipLocator.tips(eye, pose[0], pose[1]); + Vec3 mid = t.left().add(t.right()).scale(0.5); + // The wing roots sit BACK blocks behind the eye along the flight direction. + assertEquals(-WingTipLocator.BACK, mid.subtract(eye).dot(view(pose[0], pose[1])), EPS); + } + } + + @Test + void tipsFollowTheBodyWhenItMoves() { + WingTipLocator.Tips a = WingTipLocator.tips(new Vec3(0, 0, 0), 33.0f, -20.0f); + WingTipLocator.Tips b = WingTipLocator.tips(new Vec3(7, -2, 4), 33.0f, -20.0f); + assertEquals(7.0, b.left().x - a.left().x, EPS); + assertEquals(-2.0, b.right().y - a.right().y, EPS); + assertEquals(4.0, b.right().z - a.right().z, EPS); + } + + @Test + void lookingStraightDownOrUpStillGivesAMirroredPair() { + for (float pitch : new float[]{90.0f, -90.0f}) { + WingTipLocator.Tips t = WingTipLocator.tips(Vec3.ZERO, 30.0f, pitch); + assertEquals(2.0 * WingTipLocator.HALF_SPAN, t.left().distanceTo(t.right()), EPS); + assertFalse(Double.isNaN(t.left().x) || Double.isNaN(t.right().z)); + } + } +} diff --git a/mod/src/test/java/dev/loki/lovisual/features/module/modules/visuals/effects/elytratrails/WingTrailStoreTest.java b/mod/src/test/java/dev/loki/lovisual/features/module/modules/visuals/effects/elytratrails/WingTrailStoreTest.java new file mode 100644 index 0000000..1a6f81c --- /dev/null +++ b/mod/src/test/java/dev/loki/lovisual/features/module/modules/visuals/effects/elytratrails/WingTrailStoreTest.java @@ -0,0 +1,63 @@ +package dev.loki.lovisual.features.module.modules.visuals.effects.elytratrails; + +import org.junit.jupiter.api.Test; + +import java.util.UUID; + +import static org.junit.jupiter.api.Assertions.*; + +/** Спецификация хранилища следов крыльев: по ленте на каждое крыло каждого игрока, старое исчезает. */ +class WingTrailStoreTest { + + private static final UUID A = UUID.fromString("00000000-0000-0000-0000-00000000000a"); + private static final UUID B = UUID.fromString("00000000-0000-0000-0000-00000000000b"); + + @Test + void everyWingOfEveryPlayerHasItsOwnRibbon() { + WingTrailStore s = new WingTrailStore(); + s.sample(A, WingTrailStore.LEFT, 0, 0, 0, 0L, 1000L); + s.sample(A, WingTrailStore.RIGHT, 2, 0, 0, 0L, 1000L); + s.sample(B, WingTrailStore.LEFT, 9, 0, 0, 0L, 1000L); + assertEquals(3, s.snapshot().size()); + assertEquals(2, s.trackCount()); + } + + @Test + void leftAndRightHistoriesDoNotMix() { + WingTrailStore s = new WingTrailStore(); + for (int i = 0; i < 4; i++) s.sample(A, WingTrailStore.LEFT, i * 0.5, 0, 0, i * 20L, 1000L); + s.sample(A, WingTrailStore.RIGHT, 5, 0, 0, 0L, 1000L); + var sizes = s.snapshot().stream().map(t -> t.size()).sorted().toList(); + assertEquals(java.util.List.of(1, 4), sizes); + } + + @Test + void ribbonOfAPlayerWhoStoppedGlidingFadesThenDisappears() { + WingTrailStore s = new WingTrailStore(); + s.sample(A, WingTrailStore.LEFT, 0, 0, 0, 0L, 500L); + s.sample(A, WingTrailStore.RIGHT, 2, 0, 0, 0L, 500L); + s.prune(400L, 500L); + assertEquals(2, s.snapshot().size(), "still visible while fading"); + s.prune(900L, 500L); + assertTrue(s.snapshot().isEmpty()); + assertEquals(0, s.trackCount()); + } + + @Test + void oneWingCanOutliveTheOther() { + WingTrailStore s = new WingTrailStore(); + s.sample(A, WingTrailStore.LEFT, 0, 0, 0, 0L, 500L); + s.sample(A, WingTrailStore.RIGHT, 2, 0, 0, 400L, 500L); + s.prune(700L, 500L); + assertEquals(1, s.snapshot().size()); + assertEquals(1, s.trackCount()); + } + + @Test + void clearForgetsEveryone() { + WingTrailStore s = new WingTrailStore(); + s.sample(A, WingTrailStore.LEFT, 0, 0, 0, 0L, 1000L); + s.clear(); + assertTrue(s.snapshot().isEmpty()); + } +} diff --git a/mod/src/test/java/dev/loki/lovisual/features/module/modules/visuals/effects/projectiletrails/ProjectileTrailStoreTest.java b/mod/src/test/java/dev/loki/lovisual/features/module/modules/visuals/effects/projectiletrails/ProjectileTrailStoreTest.java new file mode 100644 index 0000000..3ae573e --- /dev/null +++ b/mod/src/test/java/dev/loki/lovisual/features/module/modules/visuals/effects/projectiletrails/ProjectileTrailStoreTest.java @@ -0,0 +1,70 @@ +package dev.loki.lovisual.features.module.modules.visuals.effects.projectiletrails; + +import org.junit.jupiter.api.Test; + +import static org.junit.jupiter.api.Assertions.*; + +/** Спецификация ProjectileTrails: какие снаряды получают след и когда след исчезает. */ +class ProjectileTrailStoreTest { + + @Test + void knownProjectilesMapToSettingKeys() { + assertEquals("arrow", TrailStore.kindOfPath("arrow")); + assertEquals("arrow", TrailStore.kindOfPath("spectral_arrow")); + assertEquals("trident", TrailStore.kindOfPath("trident")); + assertEquals("pearl", TrailStore.kindOfPath("ender_pearl")); + assertEquals("snowball", TrailStore.kindOfPath("snowball")); + assertEquals("egg", TrailStore.kindOfPath("egg")); + assertEquals("potion", TrailStore.kindOfPath("splash_potion")); + assertEquals("potion", TrailStore.kindOfPath("lingering_potion")); + assertEquals("wind_charge", TrailStore.kindOfPath("wind_charge")); + assertEquals("wind_charge", TrailStore.kindOfPath("breeze_wind_charge")); + assertEquals("fireball", TrailStore.kindOfPath("fireball")); + assertEquals("fireball", TrailStore.kindOfPath("small_fireball")); + assertEquals("firework", TrailStore.kindOfPath("firework_rocket")); + } + + @Test + void unrelatedEntitiesGetNoTrail() { + assertNull(TrailStore.kindOfPath("zombie")); + assertNull(TrailStore.kindOfPath("item")); + assertNull(TrailStore.kindOfPath("experience_bottle")); + } + + @Test + void movingProjectileBuildsATrailThatThenFadesAway() { + TrailStore store = new TrailStore(); + for (int i = 0; i < 5; i++) store.sample(7, i * 0.5, 0, 0, i * 50L, 400L); + assertEquals(5, store.tracks().get(7).size()); + store.prune(1000L, 400L, id -> true); + assertTrue(store.tracks().isEmpty() || store.tracks().get(7).size() <= 5); + } + + @Test + void trailOfAnExistingProjectileIsKeptEvenIfItStandsStill() { + TrailStore store = new TrailStore(); + store.sample(1, 0, 0, 0, 0L, 400L); + store.prune(100L, 400L, id -> true); + assertEquals(1, store.tracks().size()); + } + + @Test + void trailOfAGoneProjectileSmoulderesThenIsRemoved() { + TrailStore store = new TrailStore(); + store.sample(1, 0, 0, 0, 0L, 400L); + store.sample(1, 1, 0, 0, 100L, 400L); + store.prune(300L, 400L, id -> false); + assertEquals(1, store.tracks().size(), "still fading"); + store.prune(900L, 400L, id -> false); + assertTrue(store.tracks().isEmpty()); + } + + @Test + void tracksOfDifferentProjectilesAreIndependent() { + TrailStore store = new TrailStore(); + store.sample(1, 0, 0, 0, 0L, 400L); + store.sample(2, 5, 5, 5, 0L, 400L); + assertEquals(2, store.tracks().size()); + assertNotSame(store.tracks().get(1), store.tracks().get(2)); + } +} diff --git a/mod/src/test/java/dev/loki/lovisual/features/module/modules/visuals/effects/ribbon/TrackBufferTest.java b/mod/src/test/java/dev/loki/lovisual/features/module/modules/visuals/effects/ribbon/TrackBufferTest.java new file mode 100644 index 0000000..4c35519 --- /dev/null +++ b/mod/src/test/java/dev/loki/lovisual/features/module/modules/visuals/effects/ribbon/TrackBufferTest.java @@ -0,0 +1,63 @@ +package dev.loki.lovisual.features.module.modules.visuals.effects.ribbon; + +import org.junit.jupiter.api.Test; + +import static org.junit.jupiter.api.Assertions.*; + +/** Спецификация следа: что должен делать буфер точек, независимо от реализации. */ +class TrackBufferTest { + + @Test + void firstSampleIsAlwaysKept() { + TrackBuffer b = new TrackBuffer(); + assertTrue(b.add(1, 2, 3, 100L, 500L)); + assertEquals(1, b.size()); + } + + @Test + void jitterBelowMinStepIsIgnoredButMovementIsKept() { + TrackBuffer b = new TrackBuffer(); + b.add(0, 0, 0, 0L, 1000L); + assertFalse(b.add(TrackBuffer.MIN_STEP * 0.5, 0, 0, 16L, 1000L)); + assertTrue(b.add(TrackBuffer.MIN_STEP * 2, 0, 0, 32L, 1000L)); + assertEquals(2, b.size()); + } + + @Test + void pointsOlderThanLifetimeAreDropped() { + TrackBuffer b = new TrackBuffer(); + b.add(0, 0, 0, 0L, 500L); + b.add(1, 0, 0, 300L, 500L); + b.add(2, 0, 0, 700L, 500L); + assertEquals(2, b.size()); + assertEquals(1.0, b.points().get(0).x(), 0.0); + } + + @Test + void trimCanEmptyTheBuffer() { + TrackBuffer b = new TrackBuffer(); + b.add(0, 0, 0, 0L, 500L); + b.trim(10_000L, 500L); + assertTrue(b.isEmpty()); + } + + @Test + void lastSeenTracksEveryCallEvenWhenNoPointWasAdded() { + TrackBuffer b = new TrackBuffer(); + b.add(0, 0, 0, 0L, 1000L); + b.add(0, 0, 0, 250L, 1000L); + assertEquals(250L, b.lastSeen()); + assertEquals(1, b.size()); + } + + @Test + void pointsAreOrderedTailToHead() { + TrackBuffer b = new TrackBuffer(); + for (int i = 0; i < 5; i++) b.add(i, 0, 0, i * 10L, 1000L); + long prev = -1; + for (RibbonPoint p : b.points()) { + assertTrue(p.ms() > prev); + prev = p.ms(); + } + } +} diff --git a/mod/src/test/java/dev/loki/lovisual/features/module/modules/visuals/effects/ribbon/TrailRibbonTest.java b/mod/src/test/java/dev/loki/lovisual/features/module/modules/visuals/effects/ribbon/TrailRibbonTest.java new file mode 100644 index 0000000..c79623a --- /dev/null +++ b/mod/src/test/java/dev/loki/lovisual/features/module/modules/visuals/effects/ribbon/TrailRibbonTest.java @@ -0,0 +1,65 @@ +package dev.loki.lovisual.features.module.modules.visuals.effects.ribbon; + +import org.junit.jupiter.api.Test; + +import static org.junit.jupiter.api.Assertions.*; + +/** Спецификация затухания и сужения ленты: свежее яркое и широкое, старое прозрачное и узкое. */ +class TrailRibbonTest { + + @Test + void lifeFractionGoesFromOneToZeroOverTheLifetime() { + assertEquals(1.0f, TrailRibbon.lifeFraction(100L, 100L, 1000L), 1e-6f); + assertEquals(0.5f, TrailRibbon.lifeFraction(600L, 100L, 1000L), 1e-6f); + assertEquals(0.0f, TrailRibbon.lifeFraction(1100L, 100L, 1000L), 1e-6f); + } + + @Test + void lifeFractionIsClampedAndSafeForZeroLifetime() { + assertEquals(0.0f, TrailRibbon.lifeFraction(9999L, 0L, 1000L), 0.0f); + assertEquals(1.0f, TrailRibbon.lifeFraction(0L, 500L, 1000L), 0.0f); + assertEquals(0.0f, TrailRibbon.lifeFraction(100L, 100L, 0L), 0.0f); + } + + @Test + void freshPointKeepsItsColour() { + assertEquals(0x88FF66CC, TrailRibbon.fadeAlpha(0x88FF66CC, 1.0f)); + } + + @Test + void fadingOnlyTouchesAlphaNeverRgb() { + int faded = TrailRibbon.fadeAlpha(0xC8FF66CC, 0.3f); + assertEquals(0xFF66CC, faded & 0x00FFFFFF); + assertEquals(0, TrailRibbon.fadeAlpha(0xC8FF66CC, 0.0f) >>> 24); + } + + @Test + void alphaDecreasesMonotonicallyWithAge() { + int previous = Integer.MAX_VALUE; + for (int i = 10; i >= 0; i--) { + int alpha = TrailRibbon.fadeAlpha(0xFFFFFFFF, i / 10.0f) >>> 24; + assertTrue(alpha <= previous); + previous = alpha; + } + } + + @Test + void outOfRangeLifeIsClamped() { + assertEquals(0x88FF66CC, TrailRibbon.fadeAlpha(0x88FF66CC, 5.0f)); + assertEquals(0x00FF66CC, TrailRibbon.fadeAlpha(0x88FF66CC, -5.0f)); + } + + @Test + void ribbonTapersTowardTheTailButNeverVanishes() { + double head = TrailRibbon.halfWidth(0.4f, 1.0f); + double tail = TrailRibbon.halfWidth(0.4f, 0.0f); + assertEquals(0.2, head, 1e-6); + assertTrue(tail > 0.0 && tail < head); + assertEquals(0.4 * 0.5 * 0.25, tail, 1e-6); + } + + @Test + void halfWidthScalesLinearlyWithWidthSetting() { + assertEquals(2.0 * TrailRibbon.halfWidth(0.3f, 0.6f), TrailRibbon.halfWidth(0.6f, 0.6f), 1e-6); + } +} diff --git a/mod/src/test/java/dev/loki/lovisual/features/module/modules/visuals/weather/blizzard/BlizzardOverlayTest.java b/mod/src/test/java/dev/loki/lovisual/features/module/modules/visuals/weather/blizzard/BlizzardOverlayTest.java new file mode 100644 index 0000000..df9ddd3 --- /dev/null +++ b/mod/src/test/java/dev/loki/lovisual/features/module/modules/visuals/weather/blizzard/BlizzardOverlayTest.java @@ -0,0 +1,79 @@ +package dev.loki.lovisual.features.module.modules.visuals.weather.blizzard; + +import org.junit.jupiter.api.Test; + +import static org.junit.jupiter.api.Assertions.*; + +/** Спецификация экранных эффектов метели: белая дымка и обмерзание краёв. */ +class BlizzardOverlayTest { + + @Test + void alphaIsClampedAndTheTintColourIsKept() { + assertEquals(0xFF, BlizzardOverlay.withAlpha(0x00ABCDEF, 5.0f) >>> 24); + assertEquals(0x00, BlizzardOverlay.withAlpha(0xFFABCDEF, -1.0f) >>> 24); + assertEquals(0xABCDEF, BlizzardOverlay.withAlpha(0x12ABCDEF, 0.5f) & 0xFFFFFF); + } + + @Test + void noHazeOrNoStormMeansNoVeil() { + assertEquals(0f, BlizzardOverlay.veilAlpha(0f, 1f), 0f); + assertEquals(0f, BlizzardOverlay.veilAlpha(1f, 0f), 0f); + } + + @Test + void veilGrowsWithHazeAndStormAndIsCapped() { + assertTrue(BlizzardOverlay.veilAlpha(0.8f, 0.5f) > BlizzardOverlay.veilAlpha(0.4f, 0.5f)); + assertTrue(BlizzardOverlay.veilAlpha(0.5f, 0.9f) > BlizzardOverlay.veilAlpha(0.5f, 0.3f)); + assertEquals(BlizzardOverlay.MAX_VEIL, BlizzardOverlay.veilAlpha(100f, 100f), 0f); + assertTrue(BlizzardOverlay.veilAlpha(1f, 1f) < 1f, "the world must stay visible"); + } + + @Test + void veilIsProportionalBelowTheCap() { + assertEquals(2.0, BlizzardOverlay.veilAlpha(0.2f, 0.6f) / BlizzardOverlay.veilAlpha(0.1f, 0.6f), 1e-5); + } + + @Test + void frostIsStrongestAtTheEdgeAndFadesInward() { + float depth = 100f; + int bands = BlizzardOverlay.FROST_BANDS; + assertEquals(1.0f, BlizzardOverlay.edgeCoverage(0f, depth, bands), 0f); + float previous = 1.0f; + for (float d = 0f; d <= depth; d += 5f) { + float c = BlizzardOverlay.edgeCoverage(d, depth, bands); + assertTrue(c <= previous + 1e-6f); + assertTrue(c >= 0f && c <= 1f); + previous = c; + } + assertEquals(0f, BlizzardOverlay.edgeCoverage(depth + 1f, depth, bands), 0f); + } + + @Test + void frostCoverageNeedsBandsAndDepth() { + assertEquals(0f, BlizzardOverlay.edgeCoverage(1f, 100f, 0), 0f); + assertEquals(0f, BlizzardOverlay.edgeCoverage(1f, 0f, 8), 0f); + } + + @Test + void frostBandsGetThinnerTowardTheEdgeSoAlphaAccumulatesThere() { + int depth = 80; + assertEquals(depth, BlizzardOverlay.bandThickness(0, BlizzardOverlay.FROST_BANDS, depth)); + int previous = Integer.MAX_VALUE; + for (int i = 0; i < BlizzardOverlay.FROST_BANDS; i++) { + int t = BlizzardOverlay.bandThickness(i, BlizzardOverlay.FROST_BANDS, depth); + assertTrue(t <= previous); + assertTrue(t >= 1); + previous = t; + } + } + + @Test + void frostBandsTogetherAddUpToTheConfiguredFrostAlpha() { + float strength = 0.8f, frost = 0.5f; + float total = BlizzardOverlay.frostTotal(strength, frost); + assertEquals(BlizzardOverlay.FROST_TOTAL * strength * frost, total, 1e-6f); + assertEquals(total, BlizzardOverlay.frostBandAlpha(total) * BlizzardOverlay.FROST_BANDS, 1e-6f); + assertEquals(0f, BlizzardOverlay.frostTotal(0f, 1f), 0f); + assertEquals(0f, BlizzardOverlay.frostTotal(1f, 0f), 0f); + } +} diff --git a/mod/src/test/java/dev/loki/lovisual/features/module/modules/visuals/weather/blizzard/BlizzardRendererTest.java b/mod/src/test/java/dev/loki/lovisual/features/module/modules/visuals/weather/blizzard/BlizzardRendererTest.java new file mode 100644 index 0000000..6b1b536 --- /dev/null +++ b/mod/src/test/java/dev/loki/lovisual/features/module/modules/visuals/weather/blizzard/BlizzardRendererTest.java @@ -0,0 +1,74 @@ +package dev.loki.lovisual.features.module.modules.visuals.weather.blizzard; + +import org.junit.jupiter.api.Test; + +import static org.junit.jupiter.api.Assertions.*; + +/** Спецификация видимости снежинки: не лезет в глаза у камеры, плавно появляется и тает на границе зоны. */ +class BlizzardRendererTest { + + private static final float R = 14f; + + private static float alpha(float dist, float age, float base, float opacity, float strength) { + return BlizzardRenderer.flakeAlpha(dist, age, base, R, opacity, strength); + } + + @Test + void flakesRightInFrontOfTheLensAreInvisible() { + assertEquals(0f, alpha(0.1f, 5f, 0.8f, 1f, 1f), 0f); + assertEquals(0f, alpha(0.35f, 5f, 0.8f, 1f, 1f), 0f); + } + + @Test + void flakesFadeInWithDistanceFromTheCamera() { + assertTrue(alpha(0.6f, 5f, 0.8f, 1f, 1f) > alpha(0.45f, 5f, 0.8f, 1f, 1f)); + assertTrue(alpha(2f, 5f, 0.8f, 1f, 1f) >= alpha(0.6f, 5f, 0.8f, 1f, 1f)); + } + + @Test + void flakesMeltAwayAtTheEdgeOfTheStormZone() { + assertTrue(alpha(R * 0.4f, 5f, 0.8f, 1f, 1f) > alpha(R * 0.9f, 5f, 0.8f, 1f, 1f)); + assertEquals(0f, alpha(R * 1.2f, 5f, 0.8f, 1f, 1f), 0f); + } + + @Test + void newlySpawnedFlakesAppearGraduallyInsteadOfPopping() { + assertEquals(0f, alpha(5f, 0f, 0.8f, 1f, 1f), 0f); + assertTrue(alpha(5f, 0.2f, 0.8f, 1f, 1f) < alpha(5f, 1f, 0.8f, 1f, 1f)); + } + + @Test + void opacityAndStormStrengthScaleVisibilityLinearly() { + float full = alpha(5f, 5f, 0.5f, 0.6f, 0.6f); + assertEquals(0.5, alpha(5f, 5f, 0.5f, 0.3f, 0.6f) / full, 1e-5); + assertEquals(0.5, alpha(5f, 5f, 0.5f, 0.6f, 0.3f) / full, 1e-5); + assertEquals(0f, alpha(5f, 5f, 0.5f, 0f, 1f), 0f); + assertEquals(0f, alpha(5f, 5f, 0.5f, 1f, 0f), 0f); + } + + @Test + void aFlakeIsNeverFullyOpaque() { + assertTrue(alpha(5f, 5f, 1f, 1f, 1f) <= 0.95f); + } + + @Test + void faintFlakesAreSkippedInsteadOfDrawnInvisibly() { + assertEquals(0f, alpha(5f, 5f, 0.35f, 0.01f, 0.2f), 0f); + } + + @Test + void distantFlakesStayAtLeastAPixelWide() { + assertEquals(0.045f, BlizzardRenderer.halfWidth(1f, 1f, 5f), 1e-6f); + assertTrue(BlizzardRenderer.halfWidth(1f, 1f, 200f) >= 200f * 0.0016f - 1e-6f); + assertTrue(BlizzardRenderer.halfWidth(2f, 1f, 5f) > BlizzardRenderer.halfWidth(1f, 1f, 5f)); + } + + @Test + void fasterFlakesLeaveLongerStreaksUpToALimit() { + float slow = BlizzardRenderer.halfLength(0.05f, 2f, 1f); + float fast = BlizzardRenderer.halfLength(0.05f, 10f, 1f); + assertTrue(fast > slow); + assertEquals(0.05f + 0.35f, BlizzardRenderer.halfLength(0.05f, 1000f, 1f), 1e-6f); + assertEquals(0.05f, BlizzardRenderer.halfLength(0.05f, 0f, 1f), 1e-6f); + } +} diff --git a/mod/src/test/java/dev/loki/lovisual/features/module/modules/visuals/weather/blizzard/SnowFlakesTest.java b/mod/src/test/java/dev/loki/lovisual/features/module/modules/visuals/weather/blizzard/SnowFlakesTest.java new file mode 100644 index 0000000..6110e54 --- /dev/null +++ b/mod/src/test/java/dev/loki/lovisual/features/module/modules/visuals/weather/blizzard/SnowFlakesTest.java @@ -0,0 +1,218 @@ +package dev.loki.lovisual.features.module.modules.visuals.weather.blizzard; + +import org.junit.jupiter.api.Test; + +import static org.junit.jupiter.api.Assertions.*; + +/** Спецификация метели: сколько снежинок, как они падают, куда их сносит ветер. */ +class SnowFlakesTest { + + private static final SnowFlakes.Wind CALM = SnowFlakes.Wind.ZERO; + + private static double meanY(SnowFlakes f) { + double sum = 0; + for (int i = 0; i < f.count; i++) sum += f.y[i]; + return sum / f.count; + } + + private static double meanX(SnowFlakes f) { + double sum = 0; + for (int i = 0; i < f.count; i++) sum += f.x[i]; + return sum / f.count; + } + + // ---- сколько снежинок + + @Test + void noStormMeansNoFlakes() { + assertEquals(0, SnowFlakes.targetCount(0f, 1f, 1f)); + assertEquals(0, SnowFlakes.targetCount(14f, 0f, 1f)); + assertEquals(0, SnowFlakes.targetCount(14f, 1f, 0f)); + assertEquals(0, SnowFlakes.targetCount(-3f, 1f, 1f)); + } + + @Test + void moreRadiusDensityOrStrengthMeansMoreFlakes() { + assertTrue(SnowFlakes.targetCount(20f, 1f, 0.5f) > SnowFlakes.targetCount(10f, 1f, 0.5f)); + assertTrue(SnowFlakes.targetCount(10f, 2f, 0.5f) > SnowFlakes.targetCount(10f, 1f, 0.5f)); + assertTrue(SnowFlakes.targetCount(10f, 1f, 0.8f) > SnowFlakes.targetCount(10f, 1f, 0.4f)); + } + + @Test + void halfTheIntensityGivesAboutHalfTheFlakes() { + double full = SnowFlakes.targetCount(10f, 1f, 1.0f); + double half = SnowFlakes.targetCount(10f, 1f, 0.5f); + assertEquals(0.5, half / full, 0.02); + } + + @Test + void flakeCountIsCappedAndStrengthAboveOneChangesNothing() { + assertEquals(SnowFlakes.MAX_FLAKES, SnowFlakes.targetCount(32f, 3f, 1f)); + assertEquals(SnowFlakes.targetCount(10f, 1f, 1f), SnowFlakes.targetCount(10f, 1f, 7f)); + } + + @Test + void activeFlakeCountFollowsTheTargetUpAndDown() { + SnowFlakes f = new SnowFlakes(); + f.update(0.016f, 0, 64, 0, 14f, CALM, 0f, 1f, 500, 0f); + assertEquals(500, f.count); + f.update(0.016f, 0, 64, 0, 14f, CALM, 0f, 1f, 120, 0f); + assertEquals(120, f.count); + f.update(0.016f, 0, 64, 0, 14f, CALM, 0f, 1f, 0, 0f); + assertEquals(0, f.count); + } + + // ---- где они живут + + @Test + void freshFlakesSpawnInsideTheCylinderAroundTheCamera() { + SnowFlakes f = new SnowFlakes(); + f.update(0.0001f, 100, 70, -50, 12f, CALM, 0f, 1f, 800, 0f); + for (int i = 0; i < f.count; i++) { + double dx = f.x[i] - 100, dz = f.z[i] + 50; + assertTrue(Math.sqrt(dx * dx + dz * dz) <= 12.0 + 1e-6); + assertTrue(f.y[i] >= 70 - SnowFlakes.SPAWN_BELOW - 1e-6 && f.y[i] <= 70 + SnowFlakes.SPAWN_ABOVE + 1e-6); + } + } + + @Test + void flakesFollowAMovingCameraAndNeverLeaveTheStormVolume() { + SnowFlakes f = new SnowFlakes(); + double cx = 0, cz = 0; + for (int step = 0; step < 600; step++) { + cx += 0.8; + cz -= 0.3; + f.update(0.02f, cx, 64, cz, 14f, SnowFlakes.wind(step * 0.02f, 1.0f, 1.0f), 0.5f, 1f, 600, step * 0.02f); + } + double limit = 14.0 * SnowFlakes.RECYCLE_MARGIN; + for (int i = 0; i < f.count; i++) { + double dx = f.x[i] - cx, dz = f.z[i] - cz; + assertTrue(dx * dx + dz * dz <= limit * limit + 1e-6, "flake outside the cylinder"); + assertTrue(f.y[i] >= 64 - SnowFlakes.SPAWN_BELOW - 1e-6 && f.y[i] <= 64 + SnowFlakes.SPAWN_ABOVE + 1e-6); + } + } + + // ---- как они движутся + + @Test + void snowFallsDownwardInCalmAir() { + SnowFlakes f = new SnowFlakes(); + f.update(0.0001f, 0, 64, 0, 14f, CALM, 0f, 1f, 200, 0f); + double before = meanY(f); + f.update(0.1f, 0, 64, 0, 14f, CALM, 0f, 1f, 200, 0.1f); + assertTrue(meanY(f) < before); + for (int i = 0; i < f.count; i++) assertTrue(f.vy[i] < 0f); + } + + @Test + void speedSettingMakesSnowFallFaster() { + SnowFlakes slow = new SnowFlakes(); + SnowFlakes fast = new SnowFlakes(); + slow.update(0.0001f, 0, 64, 0, 14f, CALM, 0f, 0.5f, 200, 0f); + fast.update(0.0001f, 0, 64, 0, 14f, CALM, 0f, 2.0f, 200, 0f); + double slowBefore = meanY(slow), fastBefore = meanY(fast); + slow.update(0.1f, 0, 64, 0, 14f, CALM, 0f, 0.5f, 200, 0.1f); + fast.update(0.1f, 0, 64, 0, 14f, CALM, 0f, 2.0f, 200, 0.1f); + assertTrue(fastBefore - meanY(fast) > slowBefore - meanY(slow)); + } + + @Test + void windPushesSnowDownwind() { + SnowFlakes calm = new SnowFlakes(); + SnowFlakes windy = new SnowFlakes(); + calm.update(0.0001f, 0, 64, 0, 14f, CALM, 0f, 1f, 300, 0f); + windy.update(0.0001f, 0, 64, 0, 14f, CALM, 0f, 1f, 300, 0f); + double c0 = meanX(calm), w0 = meanX(windy); + calm.update(0.2f, 0, 64, 0, 14f, CALM, 0f, 1f, 300, 0.2f); + windy.update(0.2f, 0, 64, 0, 14f, new SnowFlakes.Wind(12, 0, 0), 0f, 1f, 300, 0.2f); + assertTrue(meanX(windy) - w0 > meanX(calm) - c0 + 1.0); + } + + @Test + void sameSeedGivesTheSameStorm() { + SnowFlakes a = new SnowFlakes(); + SnowFlakes b = new SnowFlakes(); + a.reseed(42L); + b.reseed(42L); + a.update(0.05f, 1, 2, 3, 14f, CALM, 0.3f, 1f, 100, 1f); + b.update(0.05f, 1, 2, 3, 14f, CALM, 0.3f, 1f, 100, 1f); + for (int i = 0; i < 100; i++) { + assertEquals(a.x[i], b.x[i], 0.0); + assertEquals(a.y[i], b.y[i], 0.0); + } + } + + @Test + void zeroTimeStepChangesNothing() { + SnowFlakes f = new SnowFlakes(); + f.update(0.0001f, 0, 64, 0, 14f, CALM, 0f, 1f, 50, 0f); + double y0 = f.y[0]; + f.update(0f, 0, 64, 0, 14f, CALM, 0f, 1f, 50, 0f); + assertEquals(y0, f.y[0], 0.0); + } + + // ---- ветер + + @Test + void noWindWithoutPowerOrStorm() { + assertEquals(SnowFlakes.Wind.ZERO, SnowFlakes.wind(3f, 0f, 1f)); + assertEquals(SnowFlakes.Wind.ZERO, SnowFlakes.wind(3f, 1f, 0f)); + } + + private static double horizontal(SnowFlakes.Wind w) { + return Math.hypot(w.x(), w.z()); + } + + @Test + void windGrowsWithPowerAndStormStrength() { + double base = horizontal(SnowFlakes.wind(5f, 1.0f, 0.5f)); + assertTrue(horizontal(SnowFlakes.wind(5f, 2.0f, 0.5f)) > base); + assertTrue(horizontal(SnowFlakes.wind(5f, 1.0f, 1.0f)) > base); + assertEquals(2.0, horizontal(SnowFlakes.wind(5f, 2.0f, 0.5f)) / base, 1e-9); + } + + @Test + void gustsStayBoundedAndMostlyHorizontal() { + double cap = 1.0 * 14.0 * 1.5 * 1.0; + for (int i = 0; i < 400; i++) { + SnowFlakes.Wind w = SnowFlakes.wind(i * 0.37f, 1.0f, 1.0f); + assertTrue(horizontal(w) <= cap + 1e-9); + assertTrue(Math.abs(w.y()) <= horizontal(w) * 0.2 + 1e-9); + } + } + + @Test + void windDirectionDriftsOverTime() { + SnowFlakes.Wind early = SnowFlakes.wind(0f, 1f, 1f); + SnowFlakes.Wind late = SnowFlakes.wind(30f, 1f, 1f); + double angleDelta = Math.abs(Math.atan2(late.z(), late.x()) - Math.atan2(early.z(), early.x())); + assertTrue(angleDelta > 0.5); + } + + @Test + void strongerWindMeansMoreTurbulence() { + assertTrue(SnowFlakes.turbulence(2f) > SnowFlakes.turbulence(0.5f)); + assertTrue(SnowFlakes.turbulence(0f) > 0f); + assertEquals(SnowFlakes.turbulence(0f), SnowFlakes.turbulence(-1f), 0f); + } + + // ---- плавный разгон шторма + + @Test + void stormRampsUpToTheIntensitySettingNotToFull() { + float s = 0f; + for (int i = 0; i < 400; i++) s = SnowFlakes.ramp(s, 0.4f, 0.016f); + assertEquals(0.4f, s, 1e-6f); + } + + @Test + void rampNeverOvershootsAndFadesOutFasterThanItBuildsUp() { + float up = SnowFlakes.ramp(0f, 1f, 1f); + float down = 1f - SnowFlakes.ramp(1f, 0f, 1f); + assertTrue(up > 0f && up < 1f); + assertTrue(down > up, "fade-out is quicker than build-up"); + assertEquals(0f, SnowFlakes.ramp(0.01f, 0f, 5f), 0f); + assertEquals(0.3f, SnowFlakes.ramp(0.3f, 0.3f, 1f), 0f); + assertEquals(0.5f, SnowFlakes.ramp(0.5f, 1f, 0f), 0f); + } +}