From 60b4fedb2921745cd1964aa60ebc22e3b5761cc2 Mon Sep 17 00:00:00 2001 From: loki5512344 Date: Fri, 2 Oct 2026 15:58:33 +0200 Subject: [PATCH] feat(mod): align ElytraTrails and Blizzard with evo (real wing-tip matrices, ribbon styles, ground-aware snow, whiteout fog, hide vanilla weather), spec tests --- mod/TODO.md | 17 +- .../elytratrails/ElytraRibbonStyle.java | 66 ++++++ .../effects/elytratrails/ElytraTrails.java | 49 +++- .../effects/elytratrails/WingTipLocator.java | 39 ---- .../effects/elytratrails/WingTrailStore.java | 11 +- .../elytratrails/pose/ElytraPoses.java | 29 +++ .../elytratrails/pose/WingTipLocator.java | 95 ++++++++ .../visuals/effects/ribbon/RibbonPoint.java | 8 +- .../visuals/effects/ribbon/RibbonShape.java | 112 +++++++++ .../visuals/effects/ribbon/TrackBuffer.java | 26 +++ .../visuals/effects/ribbon/TrailRibbon.java | 218 ++++++++++++++++-- .../visuals/weather/blizzard/Blizzard.java | 126 ++++++---- .../weather/blizzard/BlizzardOverlay.java | 79 ------- .../weather/blizzard/BlizzardRenderer.java | 185 ++++++++------- .../visuals/weather/blizzard/SnowFlakes.java | 201 +++++++++++----- .../weather/blizzard/view/BlizzardFog.java | 49 ++++ .../blizzard/view/BlizzardOverlay.java | 69 ++++++ .../render/weather/BlizzardFogMixin.java | 25 ++ .../render/weather/WeatherHideMixin.java | 27 +++ .../resources/assets/lovisual/lang/en_us.json | 33 ++- .../resources/assets/lovisual/lang/ru_ru.json | 33 ++- mod/src/main/resources/lovisual.mixins.json | 2 + .../elytratrails/ElytraRibbonStyleTest.java | 61 +++++ .../elytratrails/WingTipLocatorTest.java | 81 ------- .../elytratrails/WingTrailStoreTest.java | 31 +++ .../elytratrails/pose/WingTipLocatorTest.java | 190 +++++++++++++++ .../effects/ribbon/RibbonShapeTest.java | 147 ++++++++++++ .../effects/ribbon/TrackBufferTest.java | 46 ++++ .../effects/ribbon/TrailRibbonTest.java | 47 ++++ .../weather/blizzard/BlizzardOverlayTest.java | 79 ------- .../blizzard/BlizzardRendererTest.java | 74 +++--- .../weather/blizzard/SnowFlakesTest.java | 199 +++++++++++----- .../blizzard/view/BlizzardFogTest.java | 47 ++++ .../blizzard/view/BlizzardOverlayTest.java | 59 +++++ 34 files changed, 1937 insertions(+), 623 deletions(-) create mode 100644 mod/src/main/java/dev/loki/lovisual/features/module/modules/visuals/effects/elytratrails/ElytraRibbonStyle.java delete 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/pose/ElytraPoses.java create mode 100644 mod/src/main/java/dev/loki/lovisual/features/module/modules/visuals/effects/elytratrails/pose/WingTipLocator.java create mode 100644 mod/src/main/java/dev/loki/lovisual/features/module/modules/visuals/effects/ribbon/RibbonShape.java delete 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/view/BlizzardFog.java create mode 100644 mod/src/main/java/dev/loki/lovisual/features/module/modules/visuals/weather/blizzard/view/BlizzardOverlay.java create mode 100644 mod/src/main/java/dev/loki/lovisual/mixins/render/weather/BlizzardFogMixin.java create mode 100644 mod/src/main/java/dev/loki/lovisual/mixins/render/weather/WeatherHideMixin.java create mode 100644 mod/src/test/java/dev/loki/lovisual/features/module/modules/visuals/effects/elytratrails/ElytraRibbonStyleTest.java delete 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/pose/WingTipLocatorTest.java create mode 100644 mod/src/test/java/dev/loki/lovisual/features/module/modules/visuals/effects/ribbon/RibbonShapeTest.java delete 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/view/BlizzardFogTest.java create mode 100644 mod/src/test/java/dev/loki/lovisual/features/module/modules/visuals/weather/blizzard/view/BlizzardOverlayTest.java diff --git a/mod/TODO.md b/mod/TODO.md index 3baab44..20f781e 100644 --- a/mod/TODO.md +++ b/mod/TODO.md @@ -44,14 +44,15 @@ дистанция, размер), снимок меню `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-ключи модулей. Не проверено вживую: вид лент и метели, положение концов крыльев, - снег не отсекается «крышей» (метель идёт и в помещении). +- [x] ElytraTrails и Blizzard сверены с оригиналом evo (`ref/evo/.../ElytraTrailsModule`, `BlizzardModule`), спец-тесты по поведению evo: + ElytraTrails: концы крыльев по настоящей цепочке матриц модели элитры (`pose/WingTipLocator`, `ElytraPoses`), горизонтальная + лента, сужение Grow/Shrink, колебания Wave/Helix, плавный старт, реакция на скорость, мягкие края, ореол, свой/чужие, разрыв ленты + при скачке больше 8 блоков или паузе больше 300 мс, шаг записи 0.35, до 256 точек (`ribbon/RibbonShape`, `TrackBuffer.addContinuous`). + Не сделано из evo: заливка шейдером (8 видов), раздельные цвета крыльев. + Blizzard: снег только над землёй и под открытым небом (карта высот, спавн между землёй и потолком, нет снега в помещении), + константы ветра/плотности/турбулентности как у evo, экспозиция неба, «Whiteout» (миксин тумана `BlizzardFogMixin`), скрытие ванильной + погоды (`WeatherHideMixin`), рендер «капля» вдоль скорости, обмерзание 32 полосы, «только в снегопад» по силе осадков. + Не сделано из evo: «Snow cover» (снег накапливается на земле, шейдер). Не проверено вживую: вид, туман, положение концов крыльев. - [ ] 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/`. diff --git a/mod/src/main/java/dev/loki/lovisual/features/module/modules/visuals/effects/elytratrails/ElytraRibbonStyle.java b/mod/src/main/java/dev/loki/lovisual/features/module/modules/visuals/effects/elytratrails/ElytraRibbonStyle.java new file mode 100644 index 0000000..f8a9444 --- /dev/null +++ b/mod/src/main/java/dev/loki/lovisual/features/module/modules/visuals/effects/elytratrails/ElytraRibbonStyle.java @@ -0,0 +1,66 @@ +package dev.loki.lovisual.features.module.modules.visuals.effects.elytratrails; + +import dev.loki.lovisual.features.module.modules.visuals.effects.ribbon.RibbonPoint; +import dev.loki.lovisual.features.module.modules.visuals.effects.ribbon.RibbonShape; +import dev.loki.lovisual.features.module.modules.visuals.effects.ribbon.TrailRibbon; + +/** Вид ленты шлейфа элитры (по evo): горизонтальная лента с сужением, скоростью, плавным стартом и колебаниями. */ +final class ElytraRibbonStyle implements TrailRibbon.Style { + + static final float GLOW_WIDTH_SCALE = 2.4f; + + private final RibbonShape.Taper taper; + private final float taperAmount; + private final RibbonShape.Motion motion; + private final float waveSize; + private final float waveSpeed; + private final boolean speedReactive; + private final boolean fadeStart; + private final boolean softEdges; + private final boolean glow; + + ElytraRibbonStyle(RibbonShape.Taper taper, float taperAmount, RibbonShape.Motion motion, float waveSize, + float waveSpeed, boolean speedReactive, boolean fadeStart, boolean softEdges, boolean glow) { + this.taper = taper; + this.taperAmount = taperAmount; + this.motion = motion; + this.waveSize = waveSize; + this.waveSpeed = waveSpeed; + this.speedReactive = speedReactive; + this.fadeStart = fadeStart; + this.softEdges = softEdges; + this.glow = glow; + } + + @Override + public boolean horizontal() { + return true; + } + + @Override + public boolean softEdges() { + return softEdges; + } + + @Override + public float glowScale() { + return glow ? GLOW_WIDTH_SCALE : 0.0f; + } + + @Override + public double halfWidth(float width, int i, int n, RibbonPoint p, float life) { + return width * 0.5 * RibbonShape.taper(taper, taperAmount, RibbonShape.tailness(i, n)) + * RibbonShape.speedWidth(speedReactive, p.speed()); + } + + @Override + public float alpha(int i, int n, RibbonPoint p, float life, float cameraDistance) { + return life * RibbonShape.headRamp(fadeStart, i, n) * RibbonShape.nearFade(cameraDistance) + * RibbonShape.speedAlpha(speedReactive, p.speed()); + } + + @Override + public float[] motion(RibbonPoint p) { + return motion == RibbonShape.Motion.STRAIGHT ? null : RibbonShape.motion(motion, p.ms(), waveSize, waveSpeed); + } +} 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 index 554e5c1..44fcae4 100644 --- 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 @@ -8,12 +8,16 @@ 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.elytratrails.pose.ElytraPoses; +import dev.loki.lovisual.features.module.modules.visuals.effects.elytratrails.pose.WingTipLocator; +import dev.loki.lovisual.features.module.modules.visuals.effects.ribbon.RibbonShape; 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.client.player.AbstractClientPlayer; import net.minecraft.world.entity.player.Player; import net.minecraft.world.phys.Vec3; @@ -30,12 +34,27 @@ public class ElytraTrails extends Module { private final WingTrailStore store = new WingTrailStore(); + private static final double MAX_TRACK_DISTANCE_SQ = 16384.0; + + private final BooleanValue own = bool("elytraTrailsOwn", "own", true); 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 NumberValue duration = num("elytraTrailsDuration", "duration", 1200, 200, 4000); + private final NumberValue width = num("elytraTrailsWidth", "width", 0.22f, 0.02f, 1.5f); + private final ModeValue taper = modeSetting("elytraTrailsTaper", "taper", "Grow", "Uniform", "Grow", "Shrink"); + private final NumberValue taperAmount = visibleWhen( + num("elytraTrailsTaperAmount", "taper_amount", 1.8f, 1.25f, 8.0f), () -> !"Uniform".equals(taper.get())); + private final ModeValue motion = modeSetting("elytraTrailsMotion", "motion", "Straight", "Straight", "Wave", "Helix"); + private final NumberValue waveSize = visibleWhen( + num("elytraTrailsWaveSize", "wave_size", 0.35f, 0.05f, 2.0f), () -> !"Straight".equals(motion.get())); + private final NumberValue waveSpeed = visibleWhen( + num("elytraTrailsWaveSpeed", "wave_speed", 3.0f, 0.5f, 12.0f), () -> !"Straight".equals(motion.get())); + private final BooleanValue fadeStart = bool("elytraTrailsFadeStart", "fade_start", true); + private final BooleanValue speedReactive = bool("elytraTrailsSpeedReactive", "speed_reactive", true); private final BooleanValue glow = bool("elytraTrailsGlow", "glow", true); + private final BooleanValue softEdges = bool("elytraTrailsSoftEdges", "soft_edges", true); + private final BooleanValue fill = bool("elytraTrailsFill", "fill", 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 NumberValue lineWidth = visibleWhen(num("elytraTrailsLineWidth", "line_width", 2.0f, 1.0f, 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"); @@ -60,13 +79,16 @@ public class ElytraTrails extends Module { if (!isEnabled() || mc.level == null || mc.gameRenderer.mainCamera() == null) return; long now = GameClock.millis(); long life = duration.get(); + Vec3 cam = mc.gameRenderer.mainCamera().position(); 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); + if (!(player instanceof AbstractClientPlayer wearer)) continue; + if (!wearer.isFallFlying() || wearer.isInvisible() || !wearer.isAlive()) continue; + if (!(wearer == mc.player ? own.get() : others.get())) continue; + if (wearer.distanceToSqr(cam) > MAX_TRACK_DISTANCE_SQ) continue; + WingTipLocator.Tips tips = WingTipLocator.tips(ElytraPoses.of(wearer, tickDelta)); + float speed = ElytraPoses.speed(wearer); + store.sample(wearer.getUUID(), WingTrailStore.LEFT, tips.left().x, tips.left().y, tips.left().z, now, life, speed); + store.sample(wearer.getUUID(), WingTrailStore.RIGHT, tips.right().x, tips.right().y, tips.right().z, now, life, speed); } store.prune(now, life); List tracks = store.snapshot(); @@ -77,10 +99,13 @@ public class ElytraTrails extends Module { 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(), + ElytraRibbonStyle style = new ElytraRibbonStyle(RibbonShape.Taper.of(taper.get()), taperAmount.get(), + RibbonShape.Motion.of(motion.get()), waveSize.get(), waveSpeed.get(), speedReactive.get(), + fadeStart.get(), softEdges.get(), glow.get()); + TrailRibbon.render(renderer, tracks, now, life, width.get(), fill.get(), false, 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)); + phase + Math.round(i / (float) Math.max(1, n - 1) * 180), primary, secondary, true), + style); } } 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 deleted file mode 100644 index a161a15..0000000 --- a/mod/src/main/java/dev/loki/lovisual/features/module/modules/visuals/effects/elytratrails/WingTipLocator.java +++ /dev/null @@ -1,39 +0,0 @@ -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 index ded29a0..1fddb07 100644 --- 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 @@ -20,10 +20,19 @@ final class WingTrailStore { tracks.clear(); } + /** Минимальный шаг записи (блоки), скачок и пауза, после которых лента начинается заново (как у evo). */ + static final double MIN_STEP = 0.35; + static final double MAX_STEP = 8.0; + static final long GAP_MS = 300L; + void sample(UUID id, int side, double x, double y, double z, long now, long lifeMs) { + sample(id, side, x, y, z, now, lifeMs, 0.0f); + } + + void sample(UUID id, int side, double x, double y, double z, long now, long lifeMs, float speed) { 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); + pair[side].addContinuous(x, y, z, now, lifeMs, speed, MIN_STEP, MAX_STEP, GAP_MS); } /** Убирает устаревшие точки; опустевшие ленты и игроки без лент удаляются. */ diff --git a/mod/src/main/java/dev/loki/lovisual/features/module/modules/visuals/effects/elytratrails/pose/ElytraPoses.java b/mod/src/main/java/dev/loki/lovisual/features/module/modules/visuals/effects/elytratrails/pose/ElytraPoses.java new file mode 100644 index 0000000..0557891 --- /dev/null +++ b/mod/src/main/java/dev/loki/lovisual/features/module/modules/visuals/effects/elytratrails/pose/ElytraPoses.java @@ -0,0 +1,29 @@ +package dev.loki.lovisual.features.module.modules.visuals.effects.elytratrails.pose; + +import net.minecraft.client.player.AbstractClientPlayer; +import net.minecraft.util.Mth; +import net.minecraft.world.phys.Vec3; + +/** Собирает {@link WingTipLocator.Pose} из живого игрока так же, как это делает отрисовка элитры. */ +public final class ElytraPoses { + + private ElytraPoses() { + } + + public static WingTipLocator.Pose of(AbstractClientPlayer player, float partial) { + Vec3 pos = player.getPosition(partial); + Vec3 delta = player.avatarState().deltaMovementOnPreviousTick().lerp(player.getDeltaMovement(), partial); + Vec3 view = player.getViewVector(partial); + float flying = WingTipLocator.flyingYRot(delta.x, delta.z, view.x, view.z); + float bodyYaw = Mth.rotLerp(partial, player.yBodyRotO, player.yBodyRot); + var wing = player.elytraAnimationState; + return new WingTipLocator.Pose(pos.x, pos.y, pos.z, bodyYaw, player.getXRot(partial), player.getScale(), + player.isCrouching(), player.isAutoSpinAttack(), player.getFallFlyingTicks() + partial, flying, + player.tickCount + partial, wing.getRotX(partial), wing.getRotY(partial), wing.getRotZ(partial)); + } + + /** Скорость игрока за тик, блоки (для ширины и прозрачности ленты). */ + public static float speed(AbstractClientPlayer player) { + return (float) player.getDeltaMovement().length(); + } +} diff --git a/mod/src/main/java/dev/loki/lovisual/features/module/modules/visuals/effects/elytratrails/pose/WingTipLocator.java b/mod/src/main/java/dev/loki/lovisual/features/module/modules/visuals/effects/elytratrails/pose/WingTipLocator.java new file mode 100644 index 0000000..23a0331 --- /dev/null +++ b/mod/src/main/java/dev/loki/lovisual/features/module/modules/visuals/effects/elytratrails/pose/WingTipLocator.java @@ -0,0 +1,95 @@ +package dev.loki.lovisual.features.module.modules.visuals.effects.elytratrails.pose; + +import net.minecraft.world.phys.Vec3; +import org.joml.Matrix4f; +import org.joml.Vector4f; + +/** + * Концы крыльев элитры по настоящей модели: та же цепочка преобразований, что у отрисовки элитры на теле + * игрока (масштаб, поворот корпуса, наклон в полёте, поворот по траектории, крыло с анимацией взмаха), + * как в evo. Чистая математика без Minecraft-классов, кроме {@link Vec3}. + */ +public final class WingTipLocator { + + static final float MODEL_SCALE = 0.9375f; + static final float BODY_OFFSET_Y = -1.501f; + static final float WINGS_OFFSET_Z = 0.125f; + static final float WING_PIVOT_X = 0.3125f; + static final float WING_CROUCH_Y = 0.1875f; + static final float TIP_X = 0.6875f; + static final float TIP_Y = 1.3125f; + static final float TIP_Z = 0.1875f; + static final float SPIN_ATTACK_DEGREES = -75.0f; + static final float FALL_FLYING_PITCH_OFFSET = -90.0f; + static final float BODY_ROT_OFFSET = 180.0f; + static final float FALL_FLYING_DIVISOR = 100.0f; + static final float FLIGHT_ROT_EPSILON = 1.0E-5f; + + private static final float DEG = (float) (Math.PI / 180.0); + + private WingTipLocator() { + } + + /** + * Состояние игрока для расчёта. + * + * @param x,y,z интерполированная позиция (ноги) + * @param bodyYawDeg интерполированное рыскание корпуса, градусы + * @param xRotDeg наклон взгляда, градусы + * @param scale масштаб сущности (атрибут размера) + * @param spinAttack крутится трезубцем (riptide) + * @param fallFlyingTicks сколько тиков планирует (с долей кадра) + * @param flyingYRot см. {@link #flyingYRot} + * @param spinTime tickCount + partialTick для вращения riptide + * @param rotX,rotY,rotZ анимация крыла: у правого крыла знаки rotY и rotZ меняются + */ + public record Pose(double x, double y, double z, float bodyYawDeg, float xRotDeg, float scale, + boolean crouching, boolean spinAttack, float fallFlyingTicks, float flyingYRot, + float spinTime, float rotX, float rotY, float rotZ) { + } + + public record Tips(Vec3 left, Vec3 right) { + } + + /** Доля «лёжа»: 0 в начале планирования, 1 после десяти тиков. */ + public static float fallFlyingScale(float fallFlyingTicks) { + float f = fallFlyingTicks * fallFlyingTicks / FALL_FLYING_DIVISOR; + return f < 0.0f ? 0.0f : Math.min(f, 1.0f); + } + + /** + * Поворот корпуса вокруг вертикали при лёгком рысканьи: угол между горизонтальной скоростью и взглядом, + * со знаком по векторному произведению; 0, если игрок почти стоит или смотрит вертикально. + */ + public static float flyingYRot(double deltaX, double deltaZ, double viewX, double viewZ) { + double dl = deltaX * deltaX + deltaZ * deltaZ; + double vl = viewX * viewX + viewZ * viewZ; + if (dl <= 1.0E-5 || vl <= 1.0E-5) return 0.0f; + double dot = (deltaX * viewX + deltaZ * viewZ) / Math.sqrt(dl * vl); + double cross = deltaX * viewZ - deltaZ * viewX; + return (float) (Math.signum(cross) * Math.acos(Math.min(1.0, Math.abs(dot)))); + } + + public static Tips tips(Pose p) { + Matrix4f body = new Matrix4f().scale(p.scale()).rotateY((BODY_ROT_OFFSET - p.bodyYawDeg()) * DEG); + if (p.spinAttack()) { + body.rotateX((FALL_FLYING_PITCH_OFFSET - p.xRotDeg()) * DEG).rotateY(p.spinTime() * SPIN_ATTACK_DEGREES * DEG); + } else { + body.rotateX(fallFlyingScale(p.fallFlyingTicks()) * (FALL_FLYING_PITCH_OFFSET - p.xRotDeg()) * DEG); + if (Math.abs(p.flyingYRot()) > FLIGHT_ROT_EPSILON) body.rotateY(p.flyingYRot()); + } + body.scale(-1.0f, -1.0f, 1.0f).scale(MODEL_SCALE).translate(0.0f, BODY_OFFSET_Y, WINGS_OFFSET_Z); + float pivotY = p.crouching() ? WING_CROUCH_Y : 0.0f; + Vec3 left = tip(body, p, WING_PIVOT_X, pivotY, p.rotZ(), p.rotY(), p.rotX(), -TIP_X); + Vec3 right = tip(body, p, -WING_PIVOT_X, pivotY, -p.rotZ(), -p.rotY(), p.rotX(), TIP_X); + return new Tips(left, right); + } + + private static Vec3 tip(Matrix4f body, Pose p, float pivotX, float pivotY, float rz, float ry, float rx, + float tipX) { + Matrix4f wing = new Matrix4f(body).translate(pivotX, pivotY, 0.0f).rotateZYX(rz, ry, rx); + Vector4f v = new Vector4f(tipX, TIP_Y, TIP_Z, 1.0f); + wing.transform(v); + return new Vec3(p.x() + v.x, p.y() + v.y, p.z() + v.z); + } +} 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 index cd72db1..966dc3b 100644 --- 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 @@ -1,5 +1,9 @@ package dev.loki.lovisual.features.module.modules.visuals.effects.ribbon; -/** Точка следа: позиция в мире и время (мс) появления. */ -public record RibbonPoint(double x, double y, double z, long ms) { +/** Точка следа: позиция в мире, время (мс) появления и скорость источника в этот момент. */ +public record RibbonPoint(double x, double y, double z, long ms, float speed) { + + public RibbonPoint(double x, double y, double z, long ms) { + this(x, y, z, ms, 0.0f); + } } diff --git a/mod/src/main/java/dev/loki/lovisual/features/module/modules/visuals/effects/ribbon/RibbonShape.java b/mod/src/main/java/dev/loki/lovisual/features/module/modules/visuals/effects/ribbon/RibbonShape.java new file mode 100644 index 0000000..33caa07 --- /dev/null +++ b/mod/src/main/java/dev/loki/lovisual/features/module/modules/visuals/effects/ribbon/RibbonShape.java @@ -0,0 +1,112 @@ +package dev.loki.lovisual.features.module.modules.visuals.effects.ribbon; + +import java.util.Locale; + +/** + * Форма ленты шлейфа (как у evo ElytraTrails): сужение вдоль следа, реакция на скорость, плавное начало, + * затухание у камеры и колебания. Чистые функции, без зависимостей от Minecraft. + */ +public final class RibbonShape { + + static final float SPEED_MIN = 0.35f; + static final float SPEED_MAX = 1.6f; + static final float SPEED_WIDTH_FLOOR = 0.45f; + static final float SPEED_ALPHA_FLOOR = 0.25f; + static final float HEAD_RAMP_POINTS = 3.0f; + static final float NEAR_FADE_START = 0.75f; + static final float NEAR_FADE_RANGE = 1.5f; + private static final float TAU = (float) (Math.PI * 2.0); + + private RibbonShape() { + } + + /** Как меняется ширина от головы к хвосту. */ + public enum Taper { + UNIFORM, GROW, SHRINK; + + public static Taper of(String name) { + if (name == null) return UNIFORM; + return switch (name.toLowerCase(Locale.ROOT)) { + case "grow" -> GROW; + case "shrink" -> SHRINK; + default -> UNIFORM; + }; + } + } + + /** Форма пути, в которую «вплетена» лента. */ + public enum Motion { + STRAIGHT, WAVE, HELIX; + + public static Motion of(String name) { + if (name == null) return STRAIGHT; + return switch (name.toLowerCase(Locale.ROOT)) { + case "wave" -> WAVE; + case "helix" -> HELIX; + default -> STRAIGHT; + }; + } + } + + /** Удалённость точки от головы: 0 у самой новой, 1 у самой старой. */ + public static float tailness(int index, int count) { + if (count <= 1) return 0.0f; + return 1.0f - index / (float) (count - 1); + } + + /** Множитель ширины: у головы 1, у хвоста {@code amount} (Grow) или {@code 1/amount} (Shrink). */ + public static float taper(Taper mode, float amount, float tailness) { + return switch (mode) { + case GROW -> 1.0f + tailness * (amount - 1.0f); + case SHRINK -> 1.0f + tailness * (1.0f / amount - 1.0f); + default -> 1.0f; + }; + } + + /** 0 на медленном планировании, 1 на полной скорости. */ + public static float speedFactor(float speed) { + float v = (speed - SPEED_MIN) / (SPEED_MAX - SPEED_MIN); + return v < 0.0f ? 0.0f : Math.min(v, 1.0f); + } + + public static float speedWidth(boolean reactive, float speed) { + return reactive ? SPEED_WIDTH_FLOOR + (1.0f - SPEED_WIDTH_FLOOR) * speedFactor(speed) : 1.0f; + } + + public static float speedAlpha(boolean reactive, float speed) { + return reactive ? SPEED_ALPHA_FLOOR + (1.0f - SPEED_ALPHA_FLOOR) * speedFactor(speed) : 1.0f; + } + + /** Плавное начало: самые новые точки прозрачны и проявляются за три точки. */ + public static float headRamp(boolean fadeStart, int index, int count) { + if (!fadeStart) return 1.0f; + float v = (count - 1 - index) / HEAD_RAMP_POINTS; + return v < 0.0f ? 0.0f : Math.min(v, 1.0f); + } + + /** Лента гаснет, когда камера вплотную к ней. */ + public static float nearFade(float distance) { + float v = (distance - NEAR_FADE_START) / NEAR_FADE_RANGE; + return v < 0.0f ? 0.0f : Math.min(v, 1.0f); + } + + /** Оставшаяся жизнь точки, линейно от 1 до 0. */ + public static float life(long now, long birthMs, long lifeMs) { + if (lifeMs <= 0) return 0.0f; + float v = 1.0f - (now - birthMs) / (float) lifeMs; + return v < 0.0f ? 0.0f : Math.min(v, 1.0f); + } + + /** + * Смещение точки от оси пути: {в сторону, по бинормали}. Зависит от времени рождения точки, + * поэтому узор едет вместе с точкой, а не пляшет на месте. + */ + public static float[] motion(Motion mode, long birthMs, float amplitude, float frequency) { + if (mode == Motion.STRAIGHT) return new float[]{0.0f, 0.0f}; + double cycles = (birthMs * 0.001 * frequency) % 1.0; + float phase = (float) cycles * TAU; + float side = (float) Math.sin(phase) * amplitude; + float binormal = mode == Motion.HELIX ? (float) Math.cos(phase) * amplitude : 0.0f; + return new float[]{side, binormal}; + } +} 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 index e4c9618..c089f7e 100644 --- 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 @@ -33,6 +33,32 @@ public final class TrackBuffer { return added; } + /** Больше точек лента не держит: самые старые вытесняются. */ + public static final int MAX_POINTS = 256; + + /** + * Как {@link #add}, но для непрерывной ленты (концы крыльев): сдвиг меньше {@code minStep} не записывается, + * скачок дальше {@code maxStep} или пауза дольше {@code gapMs} (телепорт, перерыв в полёте) начинают ленту заново, + * у каждой точки сохраняется скорость источника. + */ + public boolean addContinuous(double x, double y, double z, long now, long lifeMs, float speed, + double minStep, double maxStep, long gapMs) { + RibbonPoint last = points.peekLast(); + if (last != null && (now - lastSeen > gapMs || distance(last, x, y, z) > maxStep)) { + points.clear(); + last = null; + } + lastSeen = now; + boolean added = false; + if (last == null || distance(last, x, y, z) >= minStep) { + points.addLast(new RibbonPoint(x, y, z, now, speed)); + if (points.size() > MAX_POINTS) points.pollFirst(); + added = true; + } + trim(now, lifeMs); + return added; + } + public void trim(long now, long lifeMs) { while (!points.isEmpty() && now - points.peekFirst().ms() > lifeMs) points.pollFirst(); } diff --git a/mod/src/main/java/dev/loki/lovisual/features/module/modules/visuals/effects/ribbon/TrailRibbon.java b/mod/src/main/java/dev/loki/lovisual/features/module/modules/visuals/effects/ribbon/TrailRibbon.java index 99e5cc1..ca500f2 100644 --- a/mod/src/main/java/dev/loki/lovisual/features/module/modules/visuals/effects/ribbon/TrailRibbon.java +++ b/mod/src/main/java/dev/loki/lovisual/features/module/modules/visuals/effects/ribbon/TrailRibbon.java @@ -17,6 +17,57 @@ import java.util.function.IntBinaryOperator; */ public final class TrailRibbon { + /** Как рисуется лента: ширина и прозрачность по точке, ориентация, мягкие края, ореол и колебания. */ + public interface Style { + /** true: ширина лежит горизонтально (как лента-флаг); false: лента повёрнута к камере. */ + boolean horizontal(); + + /** Края ленты тают в прозрачность вместо плоской полосы. */ + boolean softEdges(); + + /** Масштаб ширины отдельного ореола вокруг ленты; 0 без ореола. */ + float glowScale(); + + /** Полуширина в точке {@code i} из {@code n}. */ + double halfWidth(float width, int i, int n, RibbonPoint p, float life); + + /** Множитель альфы цвета в точке. */ + float alpha(int i, int n, RibbonPoint p, float life, float cameraDistance); + + /** Смещение точки: {в сторону, по бинормали}; null без колебаний. */ + default float[] motion(RibbonPoint p) { + return null; + } + } + + /** Прежний вид (ProjectileTrails): лента к камере, сужение к хвосту, альфа как life в квадрате. */ + public static final Style LEGACY = new Style() { + @Override + public boolean horizontal() { + return false; + } + + @Override + public boolean softEdges() { + return false; + } + + @Override + public float glowScale() { + return 0.0f; + } + + @Override + public double halfWidth(float width, int i, int n, RibbonPoint p, float life) { + return TrailRibbon.halfWidth(width, life); + } + + @Override + public float alpha(int i, int n, RibbonPoint p, float life, float cameraDistance) { + return life * life; + } + }; + private TrailRibbon() { } @@ -40,18 +91,32 @@ public final class TrailRibbon { return width * 0.5 * (0.25 + 0.75 * l); } + /** Лента прежнего вида. */ + 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) { + render(renderer, tracks, now, lifeMs, width, fill, glow, lines, lineWidth, depth, cam, color, LEGACY); + } + /** + * @param glow вся заливка складывается аддитивно (прежний «Glow»); ореол стиля рисуется отдельным слоем * @param color цвет точки: (индекс от хвоста, число точек) -> ARGB */ 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) { + IntBinaryOperator color, Style style) { + var depthMode = depth ? Renderer3D.DepthMode.PRE_DEPTH : Renderer3D.DepthMode.MAIN; MeshBuilder fillMesh = null; if (fill) { var pipeline = glow ? (depth ? LoVisualRenderPipelines.WORLD_COLORED_ADDITIVE_DEPTH : LoVisualRenderPipelines.WORLD_COLORED_ADDITIVE) : (depth ? LoVisualRenderPipelines.WORLD_COLORED_LIQUID_IGNORE : LoVisualRenderPipelines.WORLD_COLORED); - fillMesh = renderer.batch(pipeline, depth ? Renderer3D.DepthMode.PRE_DEPTH : Renderer3D.DepthMode.MAIN); + fillMesh = renderer.batch(pipeline, depthMode); + } + MeshBuilder glowMesh = null; + if (fill && style.glowScale() > 0.0f) { + glowMesh = renderer.batch(depth ? LoVisualRenderPipelines.WORLD_COLORED_ADDITIVE_DEPTH + : LoVisualRenderPipelines.WORLD_COLORED_ADDITIVE, depthMode); } MeshBuilder lineMesh = null; if (lines) { @@ -59,41 +124,73 @@ public final class TrailRibbon { try { RenderState.lineWidth = Math.max(0.5f, lineWidth); lineMesh = renderer.batch(depth ? LoVisualRenderPipelines.WORLD_COLORED_LINES_LIQUID_IGNORE - : LoVisualRenderPipelines.WORLD_COLORED_LINES, depth ? Renderer3D.DepthMode.PRE_DEPTH : Renderer3D.DepthMode.MAIN); + : LoVisualRenderPipelines.WORLD_COLORED_LINES, depthMode); } finally { RenderState.lineWidth = prev; } } - if (fillMesh == null && lineMesh == null) return; + if (fillMesh == null && lineMesh == null && glowMesh == null) return; for (TrackBuffer track : tracks) { List pts = track.points(); - if (pts.size() >= 2) draw(pts, now, lifeMs, width, fillMesh, lineMesh, cam, color); + if (pts.size() >= 2) draw(pts, now, lifeMs, width, fillMesh, glowMesh, lineMesh, cam, color, style); } } private static void draw(List pts, long now, long lifeMs, float width, MeshBuilder fill, - MeshBuilder line, Vec3 cam, IntBinaryOperator color) { + MeshBuilder glowMesh, MeshBuilder line, Vec3 cam, IntBinaryOperator color, Style style) { int n = pts.size(); + double[][] center = new double[n][3]; + for (int i = 0; i < n; i++) { + RibbonPoint p = pts.get(i); + center[i] = new double[]{p.x(), p.y(), p.z()}; + } + double[][] side = frame(center, cam, style.horizontal()); + if (style.motion(pts.get(0)) != null) { + double[][] tangent = tangents(center); + for (int i = 0; i < n; i++) { + float[] m = style.motion(pts.get(i)); + if (m == null) continue; + double bx = side[i][1] * tangent[i][2] - side[i][2] * tangent[i][1]; + double by = side[i][2] * tangent[i][0] - side[i][0] * tangent[i][2]; + double bz = side[i][0] * tangent[i][1] - side[i][1] * tangent[i][0]; + center[i][0] += side[i][0] * m[0] + bx * m[1]; + center[i][1] += side[i][1] * m[0] + by * m[1]; + center[i][2] += side[i][2] * m[0] + bz * m[1]; + } + side = frame(center, cam, style.horizontal()); + } double[][] left = new double[n][3]; double[][] right = new double[n][3]; int[] argb = new int[n]; + double[] half = new double[n]; for (int i = 0; i < n; i++) { 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 = 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}; - argb[i] = fadeAlpha(color.applyAsInt(i, n), life); + float life = style == LEGACY ? lifeFraction(now, p.ms(), lifeMs) : RibbonShape.life(now, p.ms(), lifeMs); + half[i] = style.halfWidth(width, i, n, p, life); + double dx = center[i][0] - cam.x, dy = center[i][1] - cam.y, dz = center[i][2] - cam.z; + float mult = style.alpha(i, n, p, life, (float) Math.sqrt(dx * dx + dy * dy + dz * dz)); + argb[i] = scaleAlpha(color.applyAsInt(i, n), mult); + for (int k = 0; k < 3; k++) { + left[i][k] = center[i][k] + side[i][k] * half[i]; + right[i][k] = center[i][k] - side[i][k] * half[i]; + } } + boolean soft = style.softEdges(); 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]); + if (fill != null) { + if (soft) softQuad(fill, left[i - 1], center[i - 1], right[i - 1], left[i], center[i], right[i], + argb[i - 1], argb[i]); + else quad(fill, left[i - 1], right[i - 1], right[i], left[i], argb[i - 1], argb[i]); + } + if (glowMesh != null) { + float g = style.glowScale(); + double[] l0 = offset(center[i - 1], side[i - 1], half[i - 1] * g); + double[] r0 = offset(center[i - 1], side[i - 1], -half[i - 1] * g); + double[] l1 = offset(center[i], side[i], half[i] * g); + double[] r1 = offset(center[i], side[i], -half[i] * g); + softQuad(glowMesh, l0, center[i - 1], r0, l1, center[i], r1, + scaleAlpha(argb[i - 1], GLOW_ALPHA), scaleAlpha(argb[i], GLOW_ALPHA)); + } if (line != null) { segment(line, left[i - 1], left[i], argb[i - 1], argb[i]); segment(line, right[i - 1], right[i], argb[i - 1], argb[i]); @@ -101,15 +198,90 @@ public final class TrailRibbon { } } + /** Относительная яркость ореола. */ + static final float GLOW_ALPHA = 0.35f; + + private static double[] offset(double[] c, double[] side, double d) { + return new double[]{c[0] + side[0] * d, c[1] + side[1] * d, c[2] + side[2] * d}; + } + + /** Единичные касательные по центральным разностям соседей. */ + private static double[][] tangents(double[][] c) { + int n = c.length; + double[][] out = new double[n][3]; + for (int i = 0; i < n; i++) { + double[] a = c[Math.max(0, i - 1)], b = c[Math.min(n - 1, i + 1)]; + double dx = b[0] - a[0], dy = b[1] - a[1], dz = b[2] - a[2]; + double len = Math.sqrt(dx * dx + dy * dy + dz * dz); + if (len < 1e-6) { + out[i] = new double[]{0.0, 0.0, 1.0}; + } else { + out[i] = new double[]{dx / len, dy / len, dz / len}; + } + } + return out; + } + + /** + * Единичный вектор «в сторону» для каждой точки: к камере (касательная x взгляд) или горизонтальный + * ({@code (-tz, 0, tx)}), с запасным вариантом для вертикального пути. + */ + static double[][] frame(double[][] c, Vec3 cam, boolean horizontal) { + int n = c.length; + double[][] t = tangents(c); + double[][] out = new double[n][3]; + for (int i = 0; i < n; i++) { + double sx, sy, sz; + if (horizontal) { + sx = -t[i][2]; + sy = 0.0; + sz = t[i][0]; + if (sx * sx + sz * sz < 1.0E-7) { + sx = 1.0; + sz = 0.0; + } + } else { + double vx = cam.x - c[i][0], vy = cam.y - c[i][1], vz = cam.z - c[i][2]; + sx = t[i][1] * vz - t[i][2] * vy; + sy = t[i][2] * vx - t[i][0] * vz; + sz = t[i][0] * vy - t[i][1] * vx; + } + double len = Math.sqrt(sx * sx + sy * sy + sz * sz); + if (len < 1e-6) len = 1.0; + out[i] = new double[]{sx / len, sy / len, sz / len}; + } + return out; + } + + /** Альфа цвета, умноженная на множитель 0..1; RGB не меняется. */ + static int scaleAlpha(int argb, float mult) { + float m = mult < 0.0f ? 0.0f : Math.min(mult, 1.0f); + int alpha = Math.round(((argb >>> 24) & 0xFF) * m); + return (alpha << 24) | (argb & 0x00FFFFFF); + } + private static void quad(MeshBuilder m, double[] l0, double[] r0, double[] r1, double[] l1, int c0, int c1) { + quad4(m, l0, r0, r1, l1, c0, c0, c1, c1); + } + + private static void quad4(MeshBuilder m, double[] p0, double[] p1, double[] p2, double[] p3, + int c0, int c1, int c2, int c3) { m.ensureQuadCapacity(); - int a = m.vec3(l0[0], l0[1], l0[2]).color(new RenderColor(c0)).next(); - int b = m.vec3(r0[0], r0[1], r0[2]).color(new RenderColor(c0)).next(); - int c = m.vec3(r1[0], r1[1], r1[2]).color(new RenderColor(c1)).next(); - int d = m.vec3(l1[0], l1[1], l1[2]).color(new RenderColor(c1)).next(); + int a = m.vec3(p0[0], p0[1], p0[2]).color(new RenderColor(c0)).next(); + int b = m.vec3(p1[0], p1[1], p1[2]).color(new RenderColor(c1)).next(); + int c = m.vec3(p2[0], p2[1], p2[2]).color(new RenderColor(c2)).next(); + int d = m.vec3(p3[0], p3[1], p3[2]).color(new RenderColor(c3)).next(); m.quad(a, b, c, d); } + /** Две полосы: от прозрачного левого края к непрозрачной оси и от оси к прозрачному правому краю. */ + private static void softQuad(MeshBuilder m, double[] l0, double[] k0, double[] r0, + double[] l1, double[] k1, double[] r1, int c0, int c1) { + int e0 = scaleAlpha(c0, 0.0f), e1 = scaleAlpha(c1, 0.0f); + quad4(m, l0, k0, k1, l1, e0, c0, c1, e1); + quad4(m, k0, r0, r1, k1, c0, e0, e1, c1); + } + private static void segment(MeshBuilder m, double[] p0, double[] p1, int c0, int c1) { m.ensureLineCapacity(); int a = m.vec3(p0[0], p0[1], p0[2]).color(new RenderColor(c0)).next(); 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 index bd8fe03..5e9095e 100644 --- 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 @@ -7,6 +7,8 @@ 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.modules.visuals.weather.blizzard.view.BlizzardFog; +import dev.loki.lovisual.features.module.modules.visuals.weather.blizzard.view.BlizzardOverlay; import dev.loki.lovisual.features.module.phase.WorldPhase; import dev.loki.lovisual.render.engine.RenderState; import dev.loki.lovisual.render.engine.pipeline.LoVisualRenderPipelines; @@ -16,10 +18,14 @@ 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.multiplayer.ClientLevel; import net.minecraft.client.gui.GuiGraphicsExtractor; +import net.minecraft.client.renderer.fog.FogData; import net.minecraft.core.BlockPos; import net.minecraft.util.Mth; import net.minecraft.world.level.biome.Biome; +import net.minecraft.world.level.levelgen.Heightmap; +import net.minecraft.world.level.material.FogType; import net.minecraft.world.phys.Vec3; /** @@ -31,28 +37,30 @@ import net.minecraft.world.phys.Vec3; id = "blizzard", displayName = "Blizzard", category = ModuleCategory.VISUALS, - description = "Wind-driven snowstorm with white haze and frosted screen edges" + description = "High-altitude snowstorm with wind-driven flakes, whiteout fog and frosted 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 NumberValue wind = num("blizzardWind", "wind", 0.7f, 0.0f, 1.0f); + private final NumberValue turbulence = num("blizzardTurbulence", "turbulence", 0.6f, 0.0f, 1.0f); + private final NumberValue flakeSize = num("blizzardFlakeSize", "flake_size", 1.0f, 0.5f, 2.5f); + private final NumberValue radius = num("blizzardRadius", "radius", 12.0f, 6.0f, 24.0f); + private final NumberValue opacity = num("blizzardOpacity", "opacity", 0.8f, 0.1f, 1.0f); + private final BooleanValue whiteout = bool("blizzardWhiteout", "whiteout", true); + private final NumberValue visibility = + visibleWhen(num("blizzardVisibility", "visibility", 28.0f, 8.0f, 128.0f), whiteout::get); 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 BooleanValue hideVanilla = bool("blizzardHideVanilla", "hide_vanilla", true); + private final RGBAColorValue tint = color("blizzardTint", "tint", "#FFFFFFFF"); private final SnowFlakes flakes = new SnowFlakes(); private long lastFrameNanos; - private float seconds; private float strength; + private float exposure; + private boolean frameActive; @Override public HudPhase getHudPhase() { @@ -66,68 +74,100 @@ public final class Blizzard extends Module { @Override public void onEnable() { - lastFrameNanos = 0L; - seconds = 0.0f; - strength = 0.0f; - flakes.clear(); + reset(); } @Override public void onDisable() { + reset(); + } + + private void reset() { + lastFrameNanos = 0L; + strength = 0.0f; + exposure = 0.0f; + frameActive = false; flakes.clear(); } + /** Ванильные полосы дождя и снега скрыты, пока идёт шторм и включена настройка. */ + public boolean hidesVanillaWeather() { + return isEnabled() && frameActive && hideVanilla.get(); + } + + /** Штормовая пелена: сжимает туман и красит его в цвет снега (вызывается из миксина тумана). */ + public void applyFog(Camera camera, FogData fog) { + if (!isEnabled() || !whiteout.get() || camera == null || fog == null) return; + FogType type = camera.getFluidInCamera(); + if (type != FogType.NONE && type != FogType.ATMOSPHERIC) return; + float f = strength * exposure; + if (!BlizzardFog.active(f)) return; + float end = BlizzardFog.endDistance(visibility.get(), f); + float start = BlizzardFog.startDistance(visibility.get(), f); + fog.environmentalStart = BlizzardFog.limit(fog.environmentalStart, start); + fog.environmentalEnd = BlizzardFog.limit(fog.environmentalEnd, end); + fog.renderDistanceStart = BlizzardFog.limit(fog.renderDistanceStart, start); + fog.renderDistanceEnd = BlizzardFog.limit(fog.renderDistanceEnd, end); + fog.skyEnd = BlizzardFog.limit(fog.skyEnd, end * BlizzardFog.SKY_SHARE); + fog.cloudEnd = BlizzardFog.limit(fog.cloudEnd, end); + int argb = tint.getArgb(); + float k = BlizzardFog.brightness(fog.color.x, fog.color.y, fog.color.z); + fog.color.x = BlizzardFog.blend(fog.color.x, ((argb >> 16) & 0xFF) / 255.0f, k, f); + fog.color.y = BlizzardFog.blend(fog.color.y, ((argb >> 8) & 0xFF) / 255.0f, k, f); + fog.color.z = BlizzardFog.blend(fog.color.z, (argb & 0xFF) / 255.0f, k, f); + } + 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); + return Mth.clamp(dt, 0.0f, 0.1f); } - 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; + private float targetStrength(Minecraft mc) { + float rain = mc.level.getRainLevel(1.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; + boolean snowBiome = mc.level.getBiome(pos).value() + .getPrecipitationAt(pos, mc.level.getSeaLevel()) == Biome.Precipitation.SNOW; + return SnowFlakes.targetStrength(intensity.get(), onlyWhenSnowing.get(), rain, snowBiome); } @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(); + if (!isEnabled() || mc.player == null || mc.level == null || mc.gameRenderer == null + || mc.gameRenderer.mainCamera() == null) { + frameActive = false; 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); + float dt = frameDeltaSeconds(); + Vec3 cam = mc.gameRenderer.mainCamera().position(); + ClientLevel level = mc.level; + SnowFlakes.Ground ground = (x, z) -> level.getHeight(Heightmap.Types.MOTION_BLOCKING, x, z); + strength = SnowFlakes.ramp(strength, targetStrength(mc), dt); + float open = SnowFlakes.exposureTarget(cam.y, ground.height(Mth.floor(cam.x), Mth.floor(cam.z))); + exposure = SnowFlakes.approach(exposure, open, SnowFlakes.EXPOSURE_PER_SECOND, dt); + frameActive = strength > 0.01f; + if (!frameActive && flakes.count == 0) return; + + double seconds = (System.currentTimeMillis() % 600_000L) / 1000.0; + SnowFlakes.Wind windValue = SnowFlakes.wind(seconds, wind.get(), strength); + int target = frameActive ? SnowFlakes.targetCount(radius.get(), density.get(), strength) : 0; + flakes.update(dt, cam.x, cam.y, cam.z, radius.get(), windValue, SnowFlakes.turbulence(turbulence.get()), + target, seconds, ground); 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()); + BlizzardRenderer.render(mesh, flakes, RenderState.cameraRotation, cam.x, cam.y, cam.z, + tint.getArgb(), strength, intensity.get(), 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()); + float current = strength * exposure * intensity.get(); + BlizzardOverlay.render(ctx, current, frost.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 deleted file mode 100644 index c23c517..0000000 --- a/mod/src/main/java/dev/loki/lovisual/features/module/modules/visuals/weather/blizzard/BlizzardOverlay.java +++ /dev/null @@ -1,79 +0,0 @@ -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 index 6d5e1fe..0fd619a 100644 --- 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 @@ -6,114 +6,127 @@ import org.joml.Quaternionf; import org.joml.Vector3f; /** - * Рендер снежинок: биллборд-квады, вытянутые по вектору скорости частицы, - * с затуханием у камеры и на границе зоны. + * Рендер снежинок по образцу evo: мягкая «капля» из двух воздушных змеев, вытянутая вдоль скорости, + * непрозрачный центр и прозрачные края. Снежинки позади камеры отбрасываются. */ final class BlizzardRenderer { + static final float FLAKE_RADIUS = 0.028f; + static final float MIN_ANGULAR_RADIUS = 0.0024f; + static final float STREAK_TIME = 0.022f; + static final float STREAK_FRONT_SHARE = 0.3f; + static final float FADE_IN_SECONDS = 0.45f; + static final float NEAR_FADE_START = 0.25f; + static final float NEAR_FADE_SPAN = 0.65f; + static final float BEHIND_DOT = -0.35f; + static final float BEHIND_MIN_DISTANCE = 3.0f; + 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) { + static void render(MeshBuilder mesh, SnowFlakes flakes, Quaternionf camRot, double camX, double camY, double camZ, + int argb, float strength, float intensity, float sizeSetting, 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); + int rgb = argb & 0x00FFFFFF; Vector3f forward = new Vector3f(0.0f, 0.0f, -1.0f).rotate(camRot); + Vector3f up = new Vector3f(0.0f, 1.0f, 0.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); + float px = (float) (flakes.x[i] - camX); + float py = (float) (flakes.y[i] - camY); + float pz = (float) (flakes.z[i] - camZ); + float dist = (float) Math.sqrt(px * px + py * py + pz * pz); + if (dist <= 0.0f) continue; + float vx = px / dist, vy = py / dist, vz = pz / dist; + if (isBehind(vx * forward.x + vy * forward.y + vz * forward.z, dist)) continue; + float alpha = flakeAlpha(dist, flakes.age[i], flakes.alpha[i], opacity, strength, intensity); 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); + float speed = (float) Math.sqrt(flakes.vx[i] * flakes.vx[i] + flakes.vy[i] * flakes.vy[i] + + flakes.vz[i] * flakes.vz[i]); + float dx, dy, dz; + if (speed <= 1.0E-4f) { + dx = up.x; + dy = up.y; + dz = up.z; + } else { + dx = flakes.vx[i] / speed; + dy = flakes.vy[i] / speed; + dz = flakes.vz[i] / speed; + } + float sx = dy * vz - dz * vy, sy = dz * vx - dx * vz, sz = dx * vy - dy * vx; + float sideLen = (float) Math.sqrt(sx * sx + sy * sy + sz * sz); + if (sideLen <= 1.0E-4f) { + sx = up.y * vz - up.z * vy; + sy = up.z * vx - up.x * vz; + sz = up.x * vy - up.y * vx; + sideLen = (float) Math.sqrt(sx * sx + sy * sy + sz * sz); + if (sideLen <= 1.0E-4f) continue; + } + sx /= sideLen; + sy /= sideLen; + sz /= sideLen; + + float radius = radius(sizeSetting, flakes.size[i], dist); + float back = streakLength(radius, speed, flakes.size[i]); + float front = frontLength(radius, back); + RenderColor center = new RenderColor(withAlpha(rgb, alpha)); + RenderColor edge = new RenderColor(withAlpha(rgb, 0.0f)); + emit(mesh, px, py, pz, sx * radius, sy * radius, sz * radius, dx * front, dy * front, dz * front, + center, edge); + emit(mesh, px, py, pz, -sx * radius, -sy * radius, -sz * radius, -dx * back, -dy * back, -dz * back, + center, edge); } } + /** Один змей: центр непрозрачный, сторона, острие и противоположная сторона прозрачные. */ + private static void emit(MeshBuilder mesh, float cx, float cy, float cz, float sx, float sy, float sz, + float tx, float ty, float tz, RenderColor center, RenderColor edge) { + mesh.ensureQuadCapacity(); + int a = mesh.vec3(cx, cy, cz).color(center).next(); + int b = mesh.vec3(cx - sx, cy - sy, cz - sz).color(edge).next(); + int c = mesh.vec3(cx + tx, cy + ty, cz + tz).color(edge).next(); + int d = mesh.vec3(cx + sx, cy + sy, cz + sz).color(edge).next(); + mesh.quad(a, b, c, d); + } + + /** Снежинки позади камеры не рисуются, кроме совсем близких (не пропадают на краю экрана). */ + static boolean isBehind(float dotWithForward, float dist) { + return dist > BEHIND_MIN_DISTANCE && dotWithForward < BEHIND_DOT; + } + /** - * Видимость снежинки: невидима у самой линзы, плавно проявляется с расстоянием и возрастом, - * тает к границе зоны шторма; масштабируется прозрачностью и силой шторма, не бывает полностью непрозрачной. - * Слишком слабые снежинки возвращают 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 flakeAlpha(float dist, float ageSeconds, float baseAlpha, float opacity, float strength, + float intensity) { + float near = clamp01((dist - NEAR_FADE_START) / NEAR_FADE_SPAN); + float born = Math.min(ageSeconds / FADE_IN_SECONDS, 1.0f); + float share = clamp01(strength / Math.max(intensity, 0.01f)); + return opacity * share * baseAlpha * near * Math.max(0.0f, born); } - /** Полуширина снежинки; вдали не тоньше доли пикселя, чтобы не пропадала. */ - static float halfWidth(float flakeSize, float sizeMul, float dist) { - float hw = (float) (0.045 * flakeSize) * sizeMul; - return Math.max(hw, dist * 0.0016f); + /** Радиус снежинки: вдали не меньше угловых 0.0024, чтобы не пропадала. */ + static float radius(float sizeSetting, float flakeSize, float dist) { + return Math.max(FLAKE_RADIUS * sizeSetting * flakeSize, dist * MIN_ANGULAR_RADIUS); } - /** Полудлина штриха: быстрее снежинка, длиннее след, но не больше 0.35 блока. */ - static float halfLength(float halfWidth, float speed, float sizeMul) { - return halfWidth + Math.min(0.35f, speed * 0.022f * sizeMul); + /** Длина хвоста за снежинкой: растёт со скоростью. */ + static float streakLength(float radius, float speed, float flakeSize) { + return radius + speed * STREAK_TIME * flakeSize; + } + + /** Длина носа перед снежинкой: короче хвоста, у медленной равна радиусу. */ + static float frontLength(float radius, float back) { + return back * STREAK_FRONT_SHARE + radius * (1.0f - STREAK_FRONT_SHARE); + } + + static int withAlpha(int rgb, float alpha) { + int a = Math.max(0, Math.min(255, Math.round(alpha * 255.0f))); + return (a << 24) | (rgb & 0x00FFFFFF); } private static float clamp01(float v) { - if (v < 0.0f) return 0.0f; - return v > 1.0f ? 1.0f : v; + return v < 0.0f ? 0.0f : Math.min(v, 1.0f); } } 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 index 7f9355f..d0876cf 100644 --- 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 @@ -3,19 +3,33 @@ package dev.loki.lovisual.features.module.modules.visuals.weather.blizzard; import java.util.Random; /** - * Чистая физика снежинок: спавн в цилиндре вокруг камеры, ветер с порывами, - * турбулентность и рецикл при выходе из зоны. Без зависимостей от Minecraft. + * Чистая физика снежинок по образцу evo: спавн в цилиндре вокруг камеры между землёй (карта высот) и потолком + * над головой, ветер с порывами, турбулентность, рецикл при выходе из зоны или касании земли. + * Под крышей земля выше потолка спавна, поэтому снега внутри помещений нет. Без зависимостей от Minecraft. */ final class SnowFlakes { - static final int MAX_FLAKES = 4000; + static final int MAX_FLAKES = 12000; 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 float AREA_FACTOR = 9.0f; + static final double SPAWN_CEILING = 8.0; + static final double SPAWN_FLOOR_MARGIN = 6.0; static final double RECYCLE_MARGIN = 1.25; + static final double RECYCLE_HEIGHT = 16.0; + static final int SPAWN_ATTEMPTS = 3; + static final float WIND_SPEED_MAX = 16.0f; + static final float TURBULENCE_AMPLITUDE = 1.1f; + static final float RISE_PER_SECOND = 0.45f; + static final float FALL_PER_SECOND = 0.6f; + static final float EXPOSURE_PER_SECOND = 1.4f; - /** Горизонтальный ветер с лёгким вертикальным сносом. */ + /** Высота верхнего блока, который останавливает снег, в колонке (x, z) (карта MOTION_BLOCKING). */ + @FunctionalInterface + interface Ground { + float height(int x, int z); + } + + /** Ветер: горизонтальная скорость и лёгкий вертикальный снос. */ record Wind(double x, double y, double z) { static final Wind ZERO = new Wind(0.0, 0.0, 0.0); } @@ -32,6 +46,9 @@ final class SnowFlakes { final float[] phase = new float[MAX_FLAKES]; final float[] rate = new float[MAX_FLAKES]; final float[] age = new float[MAX_FLAKES]; + private final float[] ground = new float[MAX_FLAKES]; + private final int[] columnX = new int[MAX_FLAKES]; + private final int[] columnZ = new int[MAX_FLAKES]; int count; private final Random random = new Random(1L); @@ -40,80 +57,150 @@ final class SnowFlakes { count = 0; } - /** Фиксация seed — детерминизм для тестов. */ + /** Фиксация 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 float approach(float current, float target, float perSecond, float dt) { + float step = perSecond * dt; + if (Math.abs(target - current) <= step) return target; + return current + (target > current ? step : -step); } - /** Целевое число частиц: площадь цилиндра * плотность * доля силы шторма. */ + /** Сила шторма: разгон 0.45/с, затухание 0.6/с. */ + static float ramp(float current, float target, float dt) { + return approach(current, target, target > current ? RISE_PER_SECOND : FALL_PER_SECOND, dt); + } + + /** Цель экспозиции: 1 на открытом небе (камера не ниже верхнего блока колонки), 0 под крышей. */ + static float exposureTarget(double camY, float groundHeight) { + return camY < groundHeight ? 0.0f : 1.0f; + } + + /** + * Целевая сила шторма. Без «только в снегопад» это просто интенсивность; иначе шторм идёт, пока в биоме + * игрока снег и идёт осадок, и растёт с его силой. + */ + static float targetStrength(float intensity, boolean onlyInSnowfall, float rainLevel, boolean snowBiome) { + if (!onlyInSnowfall) return intensity; + if (rainLevel <= 0.02f || !snowBiome) return 0.0f; + return Math.max(0.0f, Math.min(1.0f, rainLevel)) * intensity; + } + + /** Целевое число частиц: площадь цилиндра * плотность * сила шторма, не больше {@link #MAX_FLAKES}. */ 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; + double raw = Math.PI * radius * radius * AREA_FACTOR * density * strength; + return (int) Math.min(MAX_FLAKES, raw); } - /** Ветер с порывами: направление медленно кружит, скорость дышит несколькими синусами. */ - static Wind wind(float seconds, float windPower, float strength) { + /** Ветер с порывами: направление медленно поворачивает, скорость дышит двумя синусами. */ + static Wind wind(double 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 angle = seconds * 0.02 + Math.sin(seconds * 0.9 * 0.5) * 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; + double speed = windPower * WIND_SPEED_MAX * 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); + /** Амплитуда боковой турбулентности (блоков/с) по настройке 0..1. */ + static float turbulence(float setting) { + return Math.max(0f, setting) * TURBULENCE_AMPLITUDE; } - void update(float dt, double camX, double camY, double camZ, float radius, - Wind wind, float turbulence, float speedMul, int target, float seconds) { + void update(float dt, double camX, double camY, double camZ, float radius, Wind wind, float turbulence, + int target, double seconds, Ground ground) { if (dt <= 0f || target < 0) return; + target = Math.min(target, MAX_FLAKES); if (count > target) count = target; - while (count < target) spawn(count++, camX, camY, camZ, radius); + while (count < target && respawn(count, camX, camY, camZ, radius, wind, ground)) count++; 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); + for (int i = count - 1; i >= 0; i--) { + float wobbleX = (float) Math.sin(seconds * rate[i] + phase[i]) * turbulence; + float wobbleZ = (float) Math.cos(seconds * rate[i] * 0.83f + phase[i] * 1.7f) * turbulence; + float nvx = (float) wind.x() + wobbleX; + float nvy = (float) wind.y() - FALL_SPEED * fall[i]; + float nvz = (float) wind.z() + wobbleZ; + double nx = x[i] + nvx * dt; + double ny = y[i] + nvy * dt; + double nz = z[i] + nvz * dt; + int cx = (int) Math.floor(nx); + int cz = (int) Math.floor(nz); + if (cx != columnX[i] || cz != columnZ[i]) { + columnX[i] = cx; + columnZ[i] = cz; + this.ground[i] = ground.height(cx, cz); } + double dx = nx - camX; + double dz = nz - camZ; + boolean lost = ny <= this.ground[i] || dx * dx + dz * dz > recycleSq || ny > camY + RECYCLE_HEIGHT; + if (lost) { + if (!respawn(i, camX, camY, camZ, radius, wind, ground)) remove(i); + continue; + } + x[i] = nx; + y[i] = ny; + z[i] = nz; + vx[i] = nvx; + vy[i] = nvy; + vz[i] = nvz; + age[i] += dt; } } - 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; + private void remove(int i) { + int last = --count; + if (i == last) return; + x[i] = x[last]; + y[i] = y[last]; + z[i] = z[last]; + vx[i] = vx[last]; + vy[i] = vy[last]; + vz[i] = vz[last]; + fall[i] = fall[last]; + size[i] = size[last]; + alpha[i] = alpha[last]; + phase[i] = phase[last]; + rate[i] = rate[last]; + age[i] = age[last]; + ground[i] = ground[last]; + columnX[i] = columnX[last]; + columnZ[i] = columnZ[last]; + } + + /** Ставит снежинку в случайное место цилиндра между землёй и потолком; false, если над колонкой крыша. */ + private boolean respawn(int i, double camX, double camY, double camZ, float radius, Wind wind, Ground world) { + for (int attempt = 0; attempt < SPAWN_ATTEMPTS; attempt++) { + double angle = random.nextDouble() * Math.PI * 2.0; + double dist = radius * Math.sqrt(random.nextDouble()); + double px = camX + Math.cos(angle) * dist; + double pz = camZ + Math.sin(angle) * dist; + int cx = (int) Math.floor(px); + int cz = (int) Math.floor(pz); + float height = world.height(cx, cz); + double ceiling = camY + SPAWN_CEILING; + double floor = Math.max(height, camY - SPAWN_FLOOR_MARGIN); + if (ceiling <= floor) continue; + x[i] = px; + y[i] = floor + random.nextDouble() * (ceiling - floor); + z[i] = pz; + fall[i] = 0.6f + random.nextFloat() * 0.9f; + size[i] = 0.55f + random.nextFloat() * 0.9f; + alpha[i] = 0.45f + random.nextFloat() * 0.55f; + phase[i] = random.nextFloat() * (float) (Math.PI * 2.0); + rate[i] = 1.2f + random.nextFloat() * 2.2f; + age[i] = 0f; + vx[i] = (float) wind.x(); + vy[i] = -FALL_SPEED; + vz[i] = (float) wind.z(); + ground[i] = height; + columnX[i] = cx; + columnZ[i] = cz; + return true; + } + return false; } } diff --git a/mod/src/main/java/dev/loki/lovisual/features/module/modules/visuals/weather/blizzard/view/BlizzardFog.java b/mod/src/main/java/dev/loki/lovisual/features/module/modules/visuals/weather/blizzard/view/BlizzardFog.java new file mode 100644 index 0000000..24d6d16 --- /dev/null +++ b/mod/src/main/java/dev/loki/lovisual/features/module/modules/visuals/weather/blizzard/view/BlizzardFog.java @@ -0,0 +1,49 @@ +package dev.loki.lovisual.features.module.modules.visuals.weather.blizzard.view; + +/** + * Белая пелена по образцу evo: штормовой туман сжимает дальность видимости и подмешивает цвет снега. + * Сила пелены = сила шторма * экспозиция (под крышей пелены нет). Чистая математика. + */ +public final class BlizzardFog { + + public static final float MIN_STRENGTH = 0.01f; + public static final float START_SHARE = 0.05f; + public static final float SKY_SHARE = 0.6f; + public static final float BRIGHTNESS_GAIN = 1.6f; + public static final float BRIGHTNESS_FLOOR = 0.15f; + public static final float COLOR_BLEND = 0.9f; + + private BlizzardFog() { + } + + public static boolean active(float strength) { + return strength > MIN_STRENGTH; + } + + /** Дальность конца тумана: видимость в штиль, делённая на силу; слабая пелена почти не ограничивает обзор. */ + public static float endDistance(float visibility, float strength) { + return visibility / strength; + } + + /** Дальность начала тумана. */ + public static float startDistance(float visibility, float strength) { + return endDistance(visibility, strength) * START_SHARE; + } + + /** Туман только сжимает дальность, но не отодвигает ванильный. */ + public static float limit(float vanilla, float fog) { + return Math.min(vanilla, fog); + } + + /** Яркость цвета снега: светлее небо, светлее пелена; всегда в 0..1. */ + public static float brightness(float r, float g, float b) { + float v = Math.max(r, Math.max(g, b)) * BRIGHTNESS_GAIN + BRIGHTNESS_FLOOR; + return Math.max(0.0f, Math.min(1.0f, v)); + } + + /** Канал цвета тумана: от ванильного к оттенку снега по силе пелены. */ + public static float blend(float vanilla, float tintChannel, float brightness, float strength) { + float t = strength * COLOR_BLEND; + return vanilla + (tintChannel * brightness - vanilla) * t; + } +} diff --git a/mod/src/main/java/dev/loki/lovisual/features/module/modules/visuals/weather/blizzard/view/BlizzardOverlay.java b/mod/src/main/java/dev/loki/lovisual/features/module/modules/visuals/weather/blizzard/view/BlizzardOverlay.java new file mode 100644 index 0000000..a6e79e7 --- /dev/null +++ b/mod/src/main/java/dev/loki/lovisual/features/module/modules/visuals/weather/blizzard/view/BlizzardOverlay.java @@ -0,0 +1,69 @@ +package dev.loki.lovisual.features.module.modules.visuals.weather.blizzard.view; + +import net.minecraft.client.gui.GuiGraphicsExtractor; + +/** + * Дымка и обмерзание краёв экрана по образцу evo: лёгкая заливка на весь экран и 32 рамки, каждая + * следующая тоньше, поэтому непрозрачность копится к самому краю. + */ +public final class BlizzardOverlay { + + public static final int FROST_BANDS = 32; + public static final float FROST_DEPTH_SHARE = 0.2f; + public static final float FROST_EDGE_ALPHA = 0.55f; + public static final float HAZE_ALPHA = 0.12f; + + private BlizzardOverlay() { + } + + /** @param current сила шторма * экспозиция * интенсивность */ + public static void render(GuiGraphicsExtractor ctx, float current, boolean frost, int tintArgb) { + if (ctx == null || current <= 0.01f) return; + int w = ctx.guiWidth(); + int h = ctx.guiHeight(); + if (w <= 0 || h <= 0) return; + ctx.fill(0, 0, w, h, withAlpha(tintArgb, hazeAlpha(current))); + if (!frost) return; + int color = withAlpha(tintArgb, frostBandAlpha(current)); + float depth = Math.min(w, h) * FROST_DEPTH_SHARE; + for (int i = 0; i < FROST_BANDS; i++) { + int t = Math.round(bandThickness(i, FROST_BANDS, depth)); + if (t <= 0) continue; + ctx.fill(0, 0, w, t, color); + ctx.fill(0, h - t, w, h, color); + ctx.fill(0, t, t, h - t, color); + ctx.fill(w - t, t, w, h - t, color); + } + } + + /** Непрозрачность заливки на весь экран. */ + public static float hazeAlpha(float current) { + return Math.max(0.0f, HAZE_ALPHA * current); + } + + /** Непрозрачность одной полосы обмерзания: все полосы вместе у края дают {@code 0.55 * current}. */ + public static float frostBandAlpha(float current) { + return Math.max(0.0f, FROST_EDGE_ALPHA * current / FROST_BANDS); + } + + /** Толщина i-й полосы: первая на всю глубину, дальше тоньше, у последней почти ноль. */ + public static float bandThickness(int i, int bands, float depth) { + return depth * (1.0f - (float) Math.sqrt(i / (double) bands)); + } + + public static int withAlpha(int argb, float alpha) { + int a = Math.max(0, Math.min(255, Math.round(alpha * 255.0f))); + return (a << 24) | (argb & 0x00FFFFFF); + } + + /** Доля полос обмерзания, покрывающая точку на distance от края экрана. */ + public 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++) { + if (distance < bandThickness(i, bands, depth)) covered++; + } + return covered / (float) bands; + } +} diff --git a/mod/src/main/java/dev/loki/lovisual/mixins/render/weather/BlizzardFogMixin.java b/mod/src/main/java/dev/loki/lovisual/mixins/render/weather/BlizzardFogMixin.java new file mode 100644 index 0000000..45b2400 --- /dev/null +++ b/mod/src/main/java/dev/loki/lovisual/mixins/render/weather/BlizzardFogMixin.java @@ -0,0 +1,25 @@ +package dev.loki.lovisual.mixins.render.weather; + +import dev.loki.lovisual.features.module.lifecycle.Modules; +import dev.loki.lovisual.features.module.modules.visuals.weather.blizzard.Blizzard; +import net.minecraft.client.Camera; +import net.minecraft.client.DeltaTracker; +import net.minecraft.client.multiplayer.ClientLevel; +import net.minecraft.client.renderer.fog.FogData; +import net.minecraft.client.renderer.fog.FogRenderer; +import org.spongepowered.asm.mixin.Mixin; +import org.spongepowered.asm.mixin.injection.At; +import org.spongepowered.asm.mixin.injection.Inject; +import org.spongepowered.asm.mixin.injection.callback.CallbackInfoReturnable; + +/** Штормовая пелена Blizzard: после ванильного тумана сжимает дальность и красит туман в цвет снега. */ +@Mixin(value = FogRenderer.class, priority = 1500) +public class BlizzardFogMixin { + + @Inject(method = "setupFog", at = @At("RETURN")) + private void lovisual$blizzardWhiteout(Camera camera, int viewDistance, DeltaTracker tracker, float partial, + ClientLevel level, CallbackInfoReturnable cir) { + Blizzard blizzard = Modules.get(Blizzard.class); + if (blizzard != null) blizzard.applyFog(camera, cir.getReturnValue()); + } +} diff --git a/mod/src/main/java/dev/loki/lovisual/mixins/render/weather/WeatherHideMixin.java b/mod/src/main/java/dev/loki/lovisual/mixins/render/weather/WeatherHideMixin.java new file mode 100644 index 0000000..d95a2d0 --- /dev/null +++ b/mod/src/main/java/dev/loki/lovisual/mixins/render/weather/WeatherHideMixin.java @@ -0,0 +1,27 @@ +package dev.loki.lovisual.mixins.render.weather; + +import dev.loki.lovisual.features.module.lifecycle.Modules; +import dev.loki.lovisual.features.module.modules.visuals.weather.blizzard.Blizzard; +import net.minecraft.client.multiplayer.ClientLevel; +import net.minecraft.client.renderer.WeatherEffectRenderer; +import net.minecraft.client.renderer.state.level.WeatherRenderState; +import net.minecraft.world.phys.Vec3; +import org.spongepowered.asm.mixin.Mixin; +import org.spongepowered.asm.mixin.injection.At; +import org.spongepowered.asm.mixin.injection.Inject; +import org.spongepowered.asm.mixin.injection.callback.CallbackInfo; + +/** Blizzard убирает ванильные полосы дождя и снега, пока идёт шторм. */ +@Mixin(WeatherEffectRenderer.class) +public class WeatherHideMixin { + + @Inject(method = "extractRenderState(Lnet/minecraft/client/multiplayer/ClientLevel;FLnet/minecraft/world/phys/Vec3;Lnet/minecraft/client/renderer/state/level/WeatherRenderState;)V", + at = @At("RETURN")) + private void lovisual$dropVanillaColumns(ClientLevel level, float partial, Vec3 cameraPos, + WeatherRenderState state, CallbackInfo ci) { + Blizzard blizzard = Modules.get(Blizzard.class); + if (blizzard == null || !blizzard.hidesVanillaWeather()) return; + state.rainColumns.clear(); + state.snowColumns.clear(); + } +} 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 f9c6ce7..6bdd7e3 100644 --- a/mod/src/main/resources/assets/lovisual/lang/en_us.json +++ b/mod/src/main/resources/assets/lovisual/lang/en_us.json @@ -3635,7 +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.elytratrails.description": "Draws glowing contrails from the elytra wingtips while gliding.", "module.projectiletrails.description": "Draws fading trails behind arrows, pearls and other projectiles.", "setting.projectiletrails.duration": "Duration (ms)", "setting.projectiletrails.width": "Width", @@ -3686,7 +3686,23 @@ "setting.guipresence.show_others": "Show other players' menus", "setting.guipresence.distance": "Distance", "setting.guipresence.scale": "Panel size", - "module.blizzard.description": "Wind-driven snowstorm with white haze and frosted screen edges.", + "module.blizzard.description": "High-altitude snowstorm with wind-driven flakes, whiteout fog and frosted edges.", + "setting.elytratrails.own": "Own trail", + "setting.elytratrails.taper": "Taper", + "setting.elytratrails.taper_amount": "Taper amount", + "setting.elytratrails.motion": "Motion", + "setting.elytratrails.wave_size": "Wave size", + "setting.elytratrails.wave_speed": "Wave speed", + "setting.elytratrails.fade_start": "Fade start", + "setting.elytratrails.speed_reactive": "Speed reactive", + "setting.elytratrails.soft_edges": "Soft edges", + "setting.elytratrails.fill": "Fill", + "setting.elytratrails.taper.option.Uniform": "Uniform", + "setting.elytratrails.taper.option.Grow": "Grow", + "setting.elytratrails.taper.option.Shrink": "Shrink", + "setting.elytratrails.motion.option.Straight": "Straight", + "setting.elytratrails.motion.option.Wave": "Wave", + "setting.elytratrails.motion.option.Helix": "Helix", "setting.elytratrails.others": "Other players", "setting.elytratrails.duration": "Duration (ms)", "setting.elytratrails.width": "Width", @@ -3701,16 +3717,17 @@ "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.hide_vanilla": "Hide vanilla weather", + "setting.blizzard.visibility": "Visibility", + "setting.blizzard.whiteout": "Whiteout", + "setting.blizzard.turbulence": "Turbulence", "setting.blizzard.radius": "Radius", "setting.blizzard.flake_size": "Flake size", - "setting.blizzard.opacity": "Flake opacity", + "setting.blizzard.opacity": "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.blizzard.frost": "Frosted screen", + "setting.blizzard.only_when_snowing": "Only in snowfall", "setting.elytratrails.color_mode.option.Static": "Static", "setting.elytratrails.color_mode.option.Rainbow": "Rainbow", "setting.elytratrails.color_mode.option.LightRainbow": "Light Rainbow", 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 c9f89a0..59d1b47 100644 --- a/mod/src/main/resources/assets/lovisual/lang/ru_ru.json +++ b/mod/src/main/resources/assets/lovisual/lang/ru_ru.json @@ -3426,7 +3426,7 @@ "module.spatialgui.description": "Рисует интерфейс плавающей панелью в 3D-пространстве.", "module.trinket.description": "Крепит декоративные безделушки к вашему персонажу.", "module.wet_world.description": "Делает поверхности мокрыми во время дождя.", - "module.elytratrails.description": "Тянет светящиеся шлейфы из концов крыльев элитры в полёте.", + "module.elytratrails.description": "Светящиеся шлейфы с концов крыльев элитры при планировании.", "module.projectiletrails.description": "Рисует затухающие ленты за стрелами, жемчугом и другими снарядами.", "setting.projectiletrails.duration": "Длительность (мс)", "setting.projectiletrails.width": "Ширина", @@ -3477,7 +3477,23 @@ "setting.guipresence.show_others": "Показывать меню других игроков", "setting.guipresence.distance": "Дистанция", "setting.guipresence.scale": "Размер панели", - "module.blizzard.description": "Метель с ветром, белой дымкой и обмерзанием краёв экрана.", + "module.blizzard.description": "Высотная метель: ветровые снежинки, белая пелена и обмерзание краёв экрана.", + "setting.elytratrails.own": "Свой шлейф", + "setting.elytratrails.taper": "Сужение", + "setting.elytratrails.taper_amount": "Сила сужения", + "setting.elytratrails.motion": "Движение", + "setting.elytratrails.wave_size": "Размер волны", + "setting.elytratrails.wave_speed": "Скорость волны", + "setting.elytratrails.fade_start": "Плавный старт", + "setting.elytratrails.speed_reactive": "Реакция на скорость", + "setting.elytratrails.soft_edges": "Мягкие края", + "setting.elytratrails.fill": "Заливка", + "setting.elytratrails.taper.option.Uniform": "Ровная", + "setting.elytratrails.taper.option.Grow": "Расширяется", + "setting.elytratrails.taper.option.Shrink": "Сужается", + "setting.elytratrails.motion.option.Straight": "Прямо", + "setting.elytratrails.motion.option.Wave": "Волна", + "setting.elytratrails.motion.option.Helix": "Спираль", "setting.elytratrails.others": "Другие игроки", "setting.elytratrails.duration": "Длительность (мс)", "setting.elytratrails.width": "Ширина", @@ -3492,16 +3508,17 @@ "setting.elytratrails.color_phase": "Фаза цвета", "setting.blizzard.intensity": "Интенсивность", "setting.blizzard.density": "Плотность", - "setting.blizzard.speed": "Скорость падения", "setting.blizzard.wind": "Ветер", + "setting.blizzard.hide_vanilla": "Скрыть ванильную погоду", + "setting.blizzard.visibility": "Видимость", + "setting.blizzard.whiteout": "Белая пелена", + "setting.blizzard.turbulence": "Турбулентность", "setting.blizzard.radius": "Радиус", "setting.blizzard.flake_size": "Размер снежинок", - "setting.blizzard.opacity": "Прозрачность снежинок", + "setting.blizzard.opacity": "Прозрачность", "setting.blizzard.tint": "Оттенок", - "setting.blizzard.haze": "Дымка", - "setting.blizzard.frost": "Обмерзание краёв", - "setting.blizzard.frost_intensity": "Сила обмерзания", - "setting.blizzard.only_when_snowing": "Только во время снега", + "setting.blizzard.frost": "Обмерзание экрана", + "setting.blizzard.only_when_snowing": "Только во время снегопада", "setting.elytratrails.color_mode.option.Static": "Статичный", "setting.elytratrails.color_mode.option.Rainbow": "Радуга", "setting.elytratrails.color_mode.option.LightRainbow": "Светлая радуга", diff --git a/mod/src/main/resources/lovisual.mixins.json b/mod/src/main/resources/lovisual.mixins.json index b40bff0..f5110d5 100644 --- a/mod/src/main/resources/lovisual.mixins.json +++ b/mod/src/main/resources/lovisual.mixins.json @@ -149,6 +149,7 @@ "render.entity.item.ItemEntityRendererMixin", "render.entity.LivingEntityRenderStateMixin", "render.fog.AtmosphericFogEnvironmentMixin", + "render.weather.BlizzardFogMixin", "render.fog.FogRendererMixin", "render.fog.LavaFogModifierMixin", "render.fog.WaterFogEnvironmentMixin", @@ -170,6 +171,7 @@ "render.main.GameRendererViewEffectsMixin", "render.main.MinecraftMixin", "render.main.ScreenEffectRendererMixin", + "render.weather.WeatherHideMixin", "render.spatialgui.GuiRendererCaptureMixin", "render.spatialgui.ScreenSpatialBackgroundMixin", "render.sound.LibraryMixin", diff --git a/mod/src/test/java/dev/loki/lovisual/features/module/modules/visuals/effects/elytratrails/ElytraRibbonStyleTest.java b/mod/src/test/java/dev/loki/lovisual/features/module/modules/visuals/effects/elytratrails/ElytraRibbonStyleTest.java new file mode 100644 index 0000000..5f2fb50 --- /dev/null +++ b/mod/src/test/java/dev/loki/lovisual/features/module/modules/visuals/effects/elytratrails/ElytraRibbonStyleTest.java @@ -0,0 +1,61 @@ +package dev.loki.lovisual.features.module.modules.visuals.effects.elytratrails; + +import dev.loki.lovisual.features.module.modules.visuals.effects.ribbon.RibbonPoint; +import dev.loki.lovisual.features.module.modules.visuals.effects.ribbon.RibbonShape; +import org.junit.jupiter.api.Test; + +import static org.junit.jupiter.api.Assertions.*; + +/** Спецификация вида ленты шлейфа элитры: как настройки влияют на ширину, прозрачность и колебания. */ +class ElytraRibbonStyleTest { + + private static ElytraRibbonStyle style(RibbonShape.Taper taper, RibbonShape.Motion motion, boolean speed, + boolean fadeStart) { + return new ElytraRibbonStyle(taper, 2f, motion, 0.3f, 3f, speed, fadeStart, true, true); + } + + private static final RibbonPoint FAST = new RibbonPoint(0, 0, 0, 0L, 2.0f); + private static final RibbonPoint SLOW = new RibbonPoint(0, 0, 0, 0L, 0.0f); + + @Test + void theRibbonLiesFlatLikeABannerAndHasAHaloOnlyWhenAsked() { + assertTrue(style(RibbonShape.Taper.GROW, RibbonShape.Motion.STRAIGHT, false, false).horizontal()); + assertTrue(style(RibbonShape.Taper.GROW, RibbonShape.Motion.STRAIGHT, false, false).glowScale() > 1.0f); + ElytraRibbonStyle none = new ElytraRibbonStyle(RibbonShape.Taper.GROW, 2f, RibbonShape.Motion.STRAIGHT, 0.3f, 3f, + false, false, false, false); + assertEquals(0f, none.glowScale(), 0f); + assertFalse(none.softEdges()); + } + + @Test + void widthIsHalfTheSettingTimesTaperTimesSpeedFactor() { + ElytraRibbonStyle plain = style(RibbonShape.Taper.UNIFORM, RibbonShape.Motion.STRAIGHT, false, false); + assertEquals(0.11, plain.halfWidth(0.22f, 3, 10, FAST, 1f), 1e-9); + ElytraRibbonStyle grow = style(RibbonShape.Taper.GROW, RibbonShape.Motion.STRAIGHT, false, false); + assertTrue(grow.halfWidth(0.22f, 0, 10, FAST, 1f) > grow.halfWidth(0.22f, 9, 10, FAST, 1f)); + ElytraRibbonStyle reactive = style(RibbonShape.Taper.UNIFORM, RibbonShape.Motion.STRAIGHT, true, false); + assertTrue(reactive.halfWidth(0.22f, 3, 10, SLOW, 1f) < reactive.halfWidth(0.22f, 3, 10, FAST, 1f)); + } + + @Test + void ribbonIsInvisibleRightAtTheCameraAndWhenExpired() { + ElytraRibbonStyle s = style(RibbonShape.Taper.UNIFORM, RibbonShape.Motion.STRAIGHT, false, false); + assertEquals(0f, s.alpha(3, 10, FAST, 1f, 0.3f), 0f); + assertEquals(0f, s.alpha(3, 10, FAST, 0f, 20f), 0f); + assertEquals(1f, s.alpha(3, 10, FAST, 1f, 20f), 1e-6f); + } + + @Test + void fadeStartHidesTheNewestPointOnly() { + ElytraRibbonStyle s = style(RibbonShape.Taper.UNIFORM, RibbonShape.Motion.STRAIGHT, false, true); + assertEquals(0f, s.alpha(9, 10, FAST, 1f, 20f), 0f); + assertEquals(1f, s.alpha(2, 10, FAST, 1f, 20f), 1e-6f); + } + + @Test + void onlyWavyMotionsMoveThePoints() { + assertNull(style(RibbonShape.Taper.UNIFORM, RibbonShape.Motion.STRAIGHT, false, false).motion(FAST)); + assertNotNull(style(RibbonShape.Taper.UNIFORM, RibbonShape.Motion.WAVE, false, false).motion(FAST)); + assertNotNull(style(RibbonShape.Taper.UNIFORM, RibbonShape.Motion.HELIX, false, false).motion(FAST)); + } +} 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 deleted file mode 100644 index 89989e4..0000000 --- a/mod/src/test/java/dev/loki/lovisual/features/module/modules/visuals/effects/elytratrails/WingTipLocatorTest.java +++ /dev/null @@ -1,81 +0,0 @@ -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 index 1a6f81c..9f8515f 100644 --- 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 @@ -60,4 +60,35 @@ class WingTrailStoreTest { s.clear(); assertTrue(s.snapshot().isEmpty()); } + + @Test + void aTeleportOrAPauseStartsAFreshRibbonInsteadOfAStreakAcrossTheMap() { + WingTrailStore s = new WingTrailStore(); + s.sample(A, WingTrailStore.LEFT, 0, 0, 0, 0L, 5000L); + s.sample(A, WingTrailStore.LEFT, 1, 0, 0, 50L, 5000L); + s.sample(A, WingTrailStore.LEFT, 300, 0, 0, 100L, 5000L); + assertEquals(1, s.snapshot().get(0).size()); + s.sample(A, WingTrailStore.LEFT, 301, 0, 0, 150L, 5000L); + s.sample(A, WingTrailStore.LEFT, 302, 0, 0, 2000L, 5000L); + assertEquals(1, s.snapshot().get(0).size(), "a pause of more than 300 ms breaks the ribbon"); + } + + @Test + void pointsRememberHowFastThePlayerFlew() { + WingTrailStore s = new WingTrailStore(); + s.sample(A, WingTrailStore.RIGHT, 0, 0, 0, 0L, 5000L, 0.8f); + s.sample(A, WingTrailStore.RIGHT, 1, 0, 0, 50L, 5000L, 1.6f); + var pts = s.snapshot().get(0).points(); + assertEquals(0.8f, pts.get(0).speed(), 0f); + assertEquals(1.6f, pts.get(1).speed(), 0f); + } + + @Test + void tinyWobbleBelowAThirdOfABlockIsNotRecorded() { + WingTrailStore s = new WingTrailStore(); + s.sample(A, WingTrailStore.LEFT, 0, 0, 0, 0L, 5000L); + s.sample(A, WingTrailStore.LEFT, 0.1, 0, 0, 50L, 5000L); + s.sample(A, WingTrailStore.LEFT, 0.2, 0, 0, 100L, 5000L); + assertEquals(1, s.snapshot().get(0).size()); + } } diff --git a/mod/src/test/java/dev/loki/lovisual/features/module/modules/visuals/effects/elytratrails/pose/WingTipLocatorTest.java b/mod/src/test/java/dev/loki/lovisual/features/module/modules/visuals/effects/elytratrails/pose/WingTipLocatorTest.java new file mode 100644 index 0000000..1dc1d92 --- /dev/null +++ b/mod/src/test/java/dev/loki/lovisual/features/module/modules/visuals/effects/elytratrails/pose/WingTipLocatorTest.java @@ -0,0 +1,190 @@ +package dev.loki.lovisual.features.module.modules.visuals.effects.elytratrails.pose; + +import net.minecraft.world.phys.Vec3; +import org.junit.jupiter.api.Test; + +import static org.junit.jupiter.api.Assertions.*; + +/** + * Спецификация концов крыльев (как в evo): они считаются по настоящей модели элитры на теле игрока, поэтому + * симметричны, следуют за рысканием, масштабом и взмахами крыльев и лежат вплотную к игроку. + */ +class WingTipLocatorTest { + + private static final double EPS = 1.0e-4; + + /** Анимация крыльев при планировании по горизонтали (ElytraModel): крылья широко раскинуты. */ + private static final float[] SPREAD = {0.349f, 0f, -1.5708f}; + + private static WingTipLocator.Pose spread(float yaw, float xRot, float ticks) { + return new WingTipLocator.Pose(10, 70, -3, yaw, xRot, 1f, false, false, ticks, 0f, 0f, SPREAD[0], SPREAD[1], SPREAD[2]); + } + + private static WingTipLocator.Pose pose(float yaw) { + return new WingTipLocator.Pose(10, 70, -3, yaw, 0f, 1f, false, false, 100f, 0f, 0f, 0f, 0f, 0f); + } + + private static Vec3 rel(Vec3 tip, WingTipLocator.Pose p) { + return tip.subtract(p.x(), p.y(), p.z()); + } + + /** Поворот вокруг вертикали так, что направление +Z переходит в направление взгляда при рыскании yaw. */ + private static Vec3 turn(Vec3 v, float yawDeg) { + double a = Math.toRadians(yawDeg); + return new Vec3(v.x * Math.cos(a) - v.z * Math.sin(a), v.y, v.x * Math.sin(a) + v.z * Math.cos(a)); + } + + // ---- форма + + @Test + void tipsAreMirrorImagesAcrossThePlaneOfTheBodyWithRelaxedWings() { + WingTipLocator.Tips t = WingTipLocator.tips(pose(0f)); + Vec3 l = rel(t.left(), pose(0f)), r = rel(t.right(), pose(0f)); + assertEquals(l.x, -r.x, EPS); + assertEquals(l.y, r.y, EPS); + assertEquals(l.z, r.z, EPS); + } + + @Test + void withSpreadWingsTheLeftTipIsOnThePlayersLeftHandSide() { + // Facing +Z (yaw 0) the player's left is +X; facing -X (yaw 90) it is +Z. + WingTipLocator.Tips south = WingTipLocator.tips(spread(0f, 0f, 100f)); + assertTrue(south.left().x > south.right().x); + WingTipLocator.Tips west = WingTipLocator.tips(spread(90f, 0f, 100f)); + assertTrue(west.left().z > west.right().z); + } + + @Test + void spreadWingsReachAboutAMeterAndAHalfToEachSideAndStayCloseToThePlayer() { + for (float ticks : new float[]{0f, 5f, 100f}) { + for (float xRot : new float[]{-30f, 0f, 60f, 90f}) { + WingTipLocator.Pose p = spread(40f, xRot, ticks); + WingTipLocator.Tips t = WingTipLocator.tips(p); + double span = t.left().distanceTo(t.right()); + assertTrue(span > 2.2 && span < 3.2, "span " + span); + Vec3 pos = new Vec3(p.x(), p.y(), p.z()); + assertTrue(t.left().distanceTo(pos) < 3.0 && t.right().distanceTo(pos) < 3.0); + } + } + } + + @Test + void standingWingsHangBehindTheBack() { + for (float yaw : new float[]{0f, 63f, -140f, 270f}) { + WingTipLocator.Pose p = new WingTipLocator.Pose(0, 0, 0, yaw, 0f, 1f, false, false, 0f, 0f, 0f, 0f, 0f, 0f); + Vec3 facing = turn(new Vec3(0, 0, 1), yaw); + WingTipLocator.Tips t = WingTipLocator.tips(p); + assertTrue(t.left().dot(facing) < 0 && t.right().dot(facing) < 0, "yaw " + yaw); + } + } + + // ---- следование за телом + + @Test + void tipsTurnWithTheBodyAroundTheVerticalThroughThePlayer() { + Vec3 base = rel(WingTipLocator.tips(pose(0f)).left(), pose(0f)); + for (float yaw : new float[]{30f, 90f, -75f, 180f, 250f}) { + Vec3 expected = turn(base, yaw); + Vec3 actual = rel(WingTipLocator.tips(pose(yaw)).left(), pose(yaw)); + assertEquals(expected.x, actual.x, EPS); + assertEquals(expected.y, actual.y, EPS); + assertEquals(expected.z, actual.z, EPS); + } + } + + @Test + void tipsFollowThePlayerWhenItMoves() { + WingTipLocator.Pose a = pose(33f); + WingTipLocator.Pose b = new WingTipLocator.Pose(17, 68, 4, 33f, 0f, 1f, false, false, 100f, 0f, 0f, 0f, 0f, 0f); + assertEquals(7.0, WingTipLocator.tips(b).left().x - WingTipLocator.tips(a).left().x, EPS); + assertEquals(-2.0, WingTipLocator.tips(b).right().y - WingTipLocator.tips(a).right().y, EPS); + assertEquals(7.0, WingTipLocator.tips(b).right().z - WingTipLocator.tips(a).right().z, EPS); + } + + @Test + void aBiggerPlayerHasProportionallyBiggerWings() { + WingTipLocator.Pose one = new WingTipLocator.Pose(0, 0, 0, 20f, 10f, 1f, false, false, 100f, 0f, 0f, 0f, 0f, 0f); + WingTipLocator.Pose two = new WingTipLocator.Pose(0, 0, 0, 20f, 10f, 2f, false, false, 100f, 0f, 0f, 0f, 0f, 0f); + Vec3 a = WingTipLocator.tips(one).left(), b = WingTipLocator.tips(two).left(); + assertEquals(2 * a.x, b.x, EPS); + assertEquals(2 * a.y, b.y, EPS); + assertEquals(2 * a.z, b.z, EPS); + } + + // ---- полёт + + @Test + void whileGlidingTheBodyLiesForwardSoTheWingsMoveFromBehindTheBackToAhead() { + double behind = WingTipLocator.tips(spread(0f, 0f, 0f)).left().z + 3; + double ahead = WingTipLocator.tips(spread(0f, 0f, 100f)).left().z + 3; + assertTrue(behind < 0.0, "standing: wings behind the back"); + assertTrue(ahead > behind + 1.0, "gliding: the body lies forward"); + } + + @Test + void fallFlyingEasesInOverTheFirstTicks() { + assertEquals(0f, WingTipLocator.fallFlyingScale(0f), 0f); + assertEquals(0.25f, WingTipLocator.fallFlyingScale(5f), 1e-6f); + assertEquals(1f, WingTipLocator.fallFlyingScale(10f), 1e-6f); + assertEquals(1f, WingTipLocator.fallFlyingScale(400f), 0f); + assertTrue(WingTipLocator.fallFlyingScale(6f) > WingTipLocator.fallFlyingScale(4f)); + } + + @Test + void steeringTurnsTheBodyAlongTheFlightPath() { + // Heading east while looking north: the body banks round by a quarter turn. + assertEquals(0f, WingTipLocator.flyingYRot(1, 0, 1, 0), 1e-6f); + assertEquals(0f, WingTipLocator.flyingYRot(0, 0, 1, 0), 0f, "no movement, no turn"); + assertEquals(0f, WingTipLocator.flyingYRot(1, 0, 0, 0), 0f, "looking straight up or down, no turn"); + float quarter = WingTipLocator.flyingYRot(1, 0, 0, 1); + assertEquals(Math.PI / 2, Math.abs(quarter), 1e-5); + assertEquals(-quarter, WingTipLocator.flyingYRot(-1, 0, 0, 1), 1e-5f, "opposite side, opposite sign"); + for (double a = 0; a < 6.3; a += 0.4) { + assertTrue(Math.abs(WingTipLocator.flyingYRot(Math.cos(a), Math.sin(a), 1, 0)) <= Math.PI / 2 + 1e-6); + } + } + + @Test + void spinAttackSpinsTheBodyButKeepsTheWingsAtTheSameDistanceFromThePlayer() { + Vec3 first = null; + double dist0 = Double.NaN; + boolean moved = false; + for (float time : new float[]{0f, 3f, 7.5f, 21f}) { + WingTipLocator.Pose p = new WingTipLocator.Pose(0, 0, 0, 0f, 20f, 1f, false, true, 100f, 0f, time, + SPREAD[0], SPREAD[1], SPREAD[2]); + Vec3 tip = WingTipLocator.tips(p).left(); + if (first == null) { + first = tip; + dist0 = tip.length(); + } + assertEquals(dist0, tip.length(), EPS); + moved |= tip.distanceTo(first) > 0.5; + } + assertTrue(moved, "the wings circle while spinning"); + } + + // ---- крылья и поза + + @Test + void flappingMovesBothWingsAsMirrorImages() { + WingTipLocator.Pose p = new WingTipLocator.Pose(0, 0, 0, 0f, 0f, 1f, false, false, 100f, 0f, 0f, 0.3f, 0.2f, 0.5f); + WingTipLocator.Tips t = WingTipLocator.tips(p); + assertEquals(t.left().x, -t.right().x, EPS); + assertEquals(t.left().y, t.right().y, EPS); + assertEquals(t.left().z, t.right().z, EPS); + WingTipLocator.Pose still = new WingTipLocator.Pose(0, 0, 0, 0f, 0f, 1f, false, false, 100f, 0f, 0f, 0f, 0f, 0f); + assertTrue(t.left().distanceTo(WingTipLocator.tips(still).left()) > 0.2, "flapping moves the tip"); + } + + @Test + void sneakingShiftsBothWingsTogetherByLessThanAQuarterBlock() { + WingTipLocator.Pose up = new WingTipLocator.Pose(0, 0, 0, 25f, 0f, 1f, false, false, 0f, 0f, 0f, 0f, 0f, 0f); + WingTipLocator.Pose down = new WingTipLocator.Pose(0, 0, 0, 25f, 0f, 1f, true, false, 0f, 0f, 0f, 0f, 0f, 0f); + Vec3 dl = WingTipLocator.tips(down).left().subtract(WingTipLocator.tips(up).left()); + Vec3 dr = WingTipLocator.tips(down).right().subtract(WingTipLocator.tips(up).right()); + assertTrue(dl.length() > 0.01 && dl.length() < 0.25); + assertEquals(dl.x, dr.x, EPS); + assertEquals(dl.y, dr.y, EPS); + assertEquals(dl.z, dr.z, EPS); + } +} diff --git a/mod/src/test/java/dev/loki/lovisual/features/module/modules/visuals/effects/ribbon/RibbonShapeTest.java b/mod/src/test/java/dev/loki/lovisual/features/module/modules/visuals/effects/ribbon/RibbonShapeTest.java new file mode 100644 index 0000000..e7ec9c4 --- /dev/null +++ b/mod/src/test/java/dev/loki/lovisual/features/module/modules/visuals/effects/ribbon/RibbonShapeTest.java @@ -0,0 +1,147 @@ +package dev.loki.lovisual.features.module.modules.visuals.effects.ribbon; + +import org.junit.jupiter.api.Test; + +import static org.junit.jupiter.api.Assertions.*; + +/** Спецификация формы ленты шлейфа: сужение, реакция на скорость, плавное начало, затухание у камеры, колебания. */ +class RibbonShapeTest { + + // ---- сужение + + @Test + void uniformTaperKeepsTheWidth() { + for (float t : new float[]{0f, 0.3f, 1f}) assertEquals(1f, RibbonShape.taper(RibbonShape.Taper.UNIFORM, 1.8f, t), 0f); + } + + @Test + void growTaperWidensTowardsTheTailLikeAJetPlume() { + float head = RibbonShape.taper(RibbonShape.Taper.GROW, 1.8f, 0f); + float tail = RibbonShape.taper(RibbonShape.Taper.GROW, 1.8f, 1f); + assertEquals(1f, head, 1e-6f); + assertEquals(1.8f, tail, 1e-6f); + assertTrue(RibbonShape.taper(RibbonShape.Taper.GROW, 1.8f, 0.5f) > head); + } + + @Test + void shrinkTaperNarrowsTheTailIntoANeedle() { + float head = RibbonShape.taper(RibbonShape.Taper.SHRINK, 2f, 0f); + float tail = RibbonShape.taper(RibbonShape.Taper.SHRINK, 2f, 1f); + assertEquals(1f, head, 1e-6f); + assertEquals(0.5f, tail, 1e-6f); + } + + @Test + void tailnessIsZeroAtTheNewestPointAndOneAtTheOldest() { + assertEquals(1f, RibbonShape.tailness(0, 10), 1e-6f); + assertEquals(0f, RibbonShape.tailness(9, 10), 1e-6f); + assertEquals(0f, RibbonShape.tailness(0, 1), 0f); + } + + // ---- скорость + + @Test + void speedFactorRisesFromSlowGlideToFullSpeed() { + assertEquals(0f, RibbonShape.speedFactor(0.1f), 0f); + assertEquals(1f, RibbonShape.speedFactor(5f), 0f); + assertTrue(RibbonShape.speedFactor(1.0f) > RibbonShape.speedFactor(0.6f)); + } + + @Test + void speedReactiveRibbonIsThinnerAndFainterWhenSlowButNeverVanishes() { + assertEquals(1f, RibbonShape.speedWidth(false, 0f), 0f); + assertEquals(1f, RibbonShape.speedAlpha(false, 0f), 0f); + assertTrue(RibbonShape.speedWidth(true, 0f) > 0.4f && RibbonShape.speedWidth(true, 0f) < 0.5f); + assertTrue(RibbonShape.speedAlpha(true, 0f) >= 0.25f); + assertEquals(1f, RibbonShape.speedWidth(true, 9f), 1e-6f); + assertEquals(1f, RibbonShape.speedAlpha(true, 9f), 1e-6f); + } + + // ---- плавное начало и приближение камеры + + @Test + void fadeStartMakesTheNewestPointsTransparent() { + assertEquals(0f, RibbonShape.headRamp(true, 9, 10), 0f); + assertTrue(RibbonShape.headRamp(true, 8, 10) < RibbonShape.headRamp(true, 6, 10)); + assertEquals(1f, RibbonShape.headRamp(true, 5, 10), 0f); + assertEquals(1f, RibbonShape.headRamp(false, 9, 10), 0f); + } + + @Test + void ribbonFadesOutWhenTheCameraIsRightOnIt() { + assertEquals(0f, RibbonShape.nearFade(0.5f), 0f); + assertEquals(0f, RibbonShape.nearFade(0.75f), 0f); + assertEquals(1f, RibbonShape.nearFade(2.25f), 1e-6f); + assertEquals(1f, RibbonShape.nearFade(40f), 0f); + assertTrue(RibbonShape.nearFade(1.5f) > 0f && RibbonShape.nearFade(1.5f) < 1f); + } + + @Test + void agedPointsFadeLinearlyToNothing() { + assertEquals(1f, RibbonShape.life(1000, 1000, 1200), 0f); + assertEquals(0.5f, RibbonShape.life(1600, 1000, 1200), 1e-6f); + assertEquals(0f, RibbonShape.life(2500, 1000, 1200), 0f); + assertEquals(0f, RibbonShape.life(1000, 1000, 0), 0f); + } + + // ---- колебания + + @Test + void straightMotionDoesNotMoveThePoints() { + float[] o = RibbonShape.motion(RibbonShape.Motion.STRAIGHT, 12345L, 0.5f, 3f); + assertEquals(0f, o[0], 0f); + assertEquals(0f, o[1], 0f); + } + + @Test + void waveSwaysOnlySidewaysAndStaysWithinTheAmplitude() { + for (long ms = 0; ms < 5000; ms += 37) { + float[] o = RibbonShape.motion(RibbonShape.Motion.WAVE, ms, 0.35f, 3f); + assertTrue(Math.abs(o[0]) <= 0.35f + 1e-6f); + assertEquals(0f, o[1], 0f); + } + } + + @Test + void helixCorkscrewsOnACircleOfTheAmplitudeRadius() { + for (long ms = 0; ms < 5000; ms += 53) { + float[] o = RibbonShape.motion(RibbonShape.Motion.HELIX, ms, 0.4f, 2f); + assertEquals(0.4f, Math.hypot(o[0], o[1]), 1e-5); + } + } + + @Test + void motionIsAFunctionOfTheBirthTimeSoItTravelsWithThePoint() { + float[] a = RibbonShape.motion(RibbonShape.Motion.WAVE, 777L, 0.3f, 3f); + float[] b = RibbonShape.motion(RibbonShape.Motion.WAVE, 777L, 0.3f, 3f); + assertArrayEquals(a, b); + } + + @Test + void fasterWaveOscillatesMoreOftenInTheSameTime() { + assertTrue(crossings(1f) < crossings(6f)); + } + + private static int crossings(float freq) { + int count = 0; + float prev = RibbonShape.motion(RibbonShape.Motion.WAVE, 0, 1f, freq)[0]; + for (long ms = 10; ms < 4000; ms += 10) { + float cur = RibbonShape.motion(RibbonShape.Motion.WAVE, ms, 1f, freq)[0]; + if ((prev < 0) != (cur < 0)) count++; + prev = cur; + } + return count; + } + + // ---- разбор настроек + + @Test + void namesParseCaseInsensitivelyWithAFallback() { + assertEquals(RibbonShape.Taper.GROW, RibbonShape.Taper.of("Grow")); + assertEquals(RibbonShape.Taper.SHRINK, RibbonShape.Taper.of("shrink")); + assertEquals(RibbonShape.Taper.UNIFORM, RibbonShape.Taper.of("nonsense")); + assertEquals(RibbonShape.Motion.HELIX, RibbonShape.Motion.of("Helix")); + assertEquals(RibbonShape.Motion.WAVE, RibbonShape.Motion.of("WAVE")); + assertEquals(RibbonShape.Motion.STRAIGHT, RibbonShape.Motion.of(null)); + } +} 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 index 4c35519..c663a12 100644 --- 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 @@ -60,4 +60,50 @@ class TrackBufferTest { prev = p.ms(); } } + + // ---- непрерывность следа (шлейф крыла): телепорт и пауза рвут ленту, шум не копится + + @Test + void aBigJumpStartsANewRibbonInsteadOfDrawingALongStreak() { + TrackBuffer b = new TrackBuffer(); + b.addContinuous(0, 0, 0, 0L, 5000L, 1f, 0.35, 8.0, 300L); + b.addContinuous(1, 0, 0, 50L, 5000L, 1f, 0.35, 8.0, 300L); + b.addContinuous(500, 0, 0, 100L, 5000L, 1f, 0.35, 8.0, 300L); + assertEquals(1, b.size()); + assertEquals(500.0, b.points().get(0).x(), 0.0); + } + + @Test + void aLongPauseStartsANewRibbon() { + TrackBuffer b = new TrackBuffer(); + b.addContinuous(0, 0, 0, 0L, 60_000L, 1f, 0.35, 8.0, 300L); + b.addContinuous(1, 0, 0, 100L, 60_000L, 1f, 0.35, 8.0, 300L); + b.addContinuous(2, 0, 0, 1000L, 60_000L, 1f, 0.35, 8.0, 300L); + assertEquals(1, b.size()); + } + + @Test + void stepsSmallerThanTheMinimumAreNotRecorded() { + TrackBuffer b = new TrackBuffer(); + b.addContinuous(0, 0, 0, 0L, 5000L, 1f, 0.35, 8.0, 300L); + assertFalse(b.addContinuous(0.2, 0, 0, 50L, 5000L, 1f, 0.35, 8.0, 300L)); + assertTrue(b.addContinuous(0.4, 0, 0, 100L, 5000L, 1f, 0.35, 8.0, 300L)); + } + + @Test + void everyPointRemembersTheSpeedItWasTakenAt() { + TrackBuffer b = new TrackBuffer(); + b.addContinuous(0, 0, 0, 0L, 5000L, 0.5f, 0.35, 8.0, 300L); + b.addContinuous(1, 0, 0, 50L, 5000L, 1.5f, 0.35, 8.0, 300L); + assertEquals(0.5f, b.points().get(0).speed(), 0f); + assertEquals(1.5f, b.points().get(1).speed(), 0f); + } + + @Test + void theRibbonHoldsAtMostTwoHundredFiftySixPoints() { + TrackBuffer b = new TrackBuffer(); + for (int i = 0; i < 400; i++) b.addContinuous(i * 0.5, 0, 0, i * 10L, 600_000L, 1f, 0.35, 8.0, 300L); + assertEquals(256, b.size()); + assertEquals(399 * 0.5, b.points().get(255).x(), 1e-9); + } } 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 index c79623a..2e4b9ce 100644 --- 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 @@ -62,4 +62,51 @@ class TrailRibbonTest { void halfWidthScalesLinearlyWithWidthSetting() { assertEquals(2.0 * TrailRibbon.halfWidth(0.3f, 0.6f), TrailRibbon.halfWidth(0.6f, 0.6f), 1e-6); } + + // ---- ориентация ленты + + private static final double[][] PATH = {{0, 64, 0}, {1, 64, 0.5}, {2, 64, 1.0}, {3, 65, 1.5}}; + + @Test + void horizontalRibbonSidesAreLevelUnitAndPerpendicularToTheDirectionOfFlight() { + double[][] side = TrailRibbon.frame(PATH, new net.minecraft.world.phys.Vec3(0, 80, 0), true); + for (int i = 0; i < PATH.length; i++) { + assertEquals(0.0, side[i][1], 1e-12, "level"); + assertEquals(1.0, Math.hypot(side[i][0], side[i][2]), 1e-9, "unit"); + double[] a = PATH[Math.max(0, i - 1)], b = PATH[Math.min(PATH.length - 1, i + 1)]; + assertEquals(0.0, side[i][0] * (b[0] - a[0]) + side[i][2] * (b[2] - a[2]), 1e-9, "perpendicular"); + } + } + + @Test + void cameraFacingRibbonSidesAreUnitAndPerpendicularToBothTheFlightAndTheViewDirection() { + net.minecraft.world.phys.Vec3 cam = new net.minecraft.world.phys.Vec3(1, 70, 8); + double[][] side = TrailRibbon.frame(PATH, cam, false); + for (int i = 0; i < PATH.length; i++) { + assertEquals(1.0, Math.sqrt(side[i][0] * side[i][0] + side[i][1] * side[i][1] + side[i][2] * side[i][2]), 1e-9); + double[] a = PATH[Math.max(0, i - 1)], b = PATH[Math.min(PATH.length - 1, i + 1)]; + double dot = side[i][0] * (b[0] - a[0]) + side[i][1] * (b[1] - a[1]) + side[i][2] * (b[2] - a[2]); + assertEquals(0.0, dot, 1e-9); + double view = side[i][0] * (cam.x - PATH[i][0]) + side[i][1] * (cam.y - PATH[i][1]) + side[i][2] * (cam.z - PATH[i][2]); + assertEquals(0.0, view, 1e-9); + } + } + + @Test + void aPurelyVerticalPathStillGetsAHorizontalSide() { + double[][] up = {{0, 64, 0}, {0, 65, 0}, {0, 66, 0}}; + double[][] side = TrailRibbon.frame(up, new net.minecraft.world.phys.Vec3(5, 65, 5), true); + for (double[] s : side) { + assertEquals(0.0, s[1], 0.0); + assertEquals(1.0, Math.hypot(s[0], s[2]), 1e-9); + } + } + + @Test + void alphaScalingKeepsColourAndClampsTheMultiplier() { + assertEquals(0x80123456, TrailRibbon.scaleAlpha(0xFF123456, 0.5f) | 0, 0x01); + assertEquals(0xFF, TrailRibbon.scaleAlpha(0xFF123456, 9f) >>> 24); + assertEquals(0x00, TrailRibbon.scaleAlpha(0xFF123456, -2f) >>> 24); + assertEquals(0x123456, TrailRibbon.scaleAlpha(0xFF123456, 0.3f) & 0xFFFFFF); + } } 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 deleted file mode 100644 index df9ddd3..0000000 --- a/mod/src/test/java/dev/loki/lovisual/features/module/modules/visuals/weather/blizzard/BlizzardOverlayTest.java +++ /dev/null @@ -1,79 +0,0 @@ -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 index 6b1b536..3d1cbc7 100644 --- 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 @@ -4,71 +4,71 @@ import org.junit.jupiter.api.Test; import static org.junit.jupiter.api.Assertions.*; -/** Спецификация видимости снежинки: не лезет в глаза у камеры, плавно появляется и тает на границе зоны. */ +/** Спецификация вида снежинки (как в evo): не лезет в глаза у линзы, проявляется со временем, тянется вдоль скорости. */ 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); + private static float alpha(float dist, float age, float base, float opacity, float strength, float intensity) { + return BlizzardRenderer.flakeAlpha(dist, age, base, opacity, strength, intensity); } @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); + assertEquals(0f, alpha(0.1f, 5f, 0.8f, 1f, 1f, 1f), 0f); + assertEquals(0f, alpha(0.25f, 5f, 0.8f, 1f, 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); + void flakesFadeInWithDistanceUntilAboutAMeter() { + assertTrue(alpha(0.6f, 5f, 0.8f, 1f, 1f, 1f) > alpha(0.4f, 5f, 0.8f, 1f, 1f, 1f)); + assertEquals(alpha(1.5f, 5f, 0.8f, 1f, 1f, 1f), alpha(30f, 5f, 0.8f, 1f, 1f, 1f), 1e-6f); } @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)); + assertEquals(0f, alpha(5f, 0f, 0.8f, 1f, 1f, 1f), 0f); + assertTrue(alpha(5f, 0.2f, 0.8f, 1f, 1f, 1f) < alpha(5f, 1f, 0.8f, 1f, 1f, 1f)); + assertEquals(alpha(5f, 0.45f, 0.8f, 1f, 1f, 1f), alpha(5f, 9f, 0.8f, 1f, 1f, 1f), 1e-6f); } @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); + void opacityAndShareOfStormScaleVisibilityLinearly() { + float full = alpha(5f, 5f, 0.5f, 0.6f, 0.6f, 0.6f); + assertEquals(0.5, alpha(5f, 5f, 0.5f, 0.3f, 0.6f, 0.6f) / full, 1e-5); + assertEquals(0.5, alpha(5f, 5f, 0.5f, 0.6f, 0.3f, 0.6f) / full, 1e-5); + assertEquals(0f, alpha(5f, 5f, 0.5f, 0f, 1f, 1f), 0f); + assertEquals(0f, alpha(5f, 5f, 0.5f, 1f, 0f, 1f), 0f); } @Test - void aFlakeIsNeverFullyOpaque() { - assertTrue(alpha(5f, 5f, 1f, 1f, 1f) <= 0.95f); + void aFlakeNeverBecomesMoreOpaqueThanItsOwnAlphaTimesOpacity() { + assertTrue(alpha(5f, 5f, 0.7f, 0.8f, 1f, 0.5f) <= 0.7f * 0.8f + 1e-6f); } @Test - void faintFlakesAreSkippedInsteadOfDrawnInvisibly() { - assertEquals(0f, alpha(5f, 5f, 0.35f, 0.01f, 0.2f), 0f); + void flakesBehindTheCameraAreSkippedExceptCloseOnes() { + assertTrue(BlizzardRenderer.isBehind(-0.9f, 10f)); + assertFalse(BlizzardRenderer.isBehind(-0.2f, 10f)); + assertFalse(BlizzardRenderer.isBehind(0.9f, 10f)); + assertFalse(BlizzardRenderer.isBehind(-0.9f, 2f)); } @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)); + void distantFlakesStayAtLeastAPixelWideAndSizeSettingScalesNearOnes() { + assertEquals(0.028f, BlizzardRenderer.radius(1f, 1f, 5f), 1e-6f); + assertEquals(200f * 0.0024f, BlizzardRenderer.radius(1f, 1f, 200f), 1e-5f); + assertTrue(BlizzardRenderer.radius(2f, 1f, 5f) > BlizzardRenderer.radius(1f, 1f, 5f)); + assertTrue(BlizzardRenderer.radius(1f, 1.4f, 5f) > BlizzardRenderer.radius(1f, 0.6f, 5f)); } @Test - void fasterFlakesLeaveLongerStreaksUpToALimit() { - float slow = BlizzardRenderer.halfLength(0.05f, 2f, 1f); - float fast = BlizzardRenderer.halfLength(0.05f, 10f, 1f); + void fasterFlakesLeaveLongerTrailsWhichAreLongerBehindThanInFront() { + float r = 0.03f; + float slow = BlizzardRenderer.streakLength(r, 2f, 1f); + float fast = BlizzardRenderer.streakLength(r, 20f, 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); + assertEquals(r, BlizzardRenderer.streakLength(r, 0f, 1f), 1e-7f); + float front = BlizzardRenderer.frontLength(r, fast); + assertTrue(front < fast && front > r); + assertEquals(r, BlizzardRenderer.frontLength(r, r), 1e-7f); } } 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 index 6110e54..ff14707 100644 --- 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 @@ -4,10 +4,20 @@ import org.junit.jupiter.api.Test; import static org.junit.jupiter.api.Assertions.*; -/** Спецификация метели: сколько снежинок, как они падают, куда их сносит ветер. */ +/** + * Спецификация метели (поведение evo): сколько снежинок, где они живут, как падают, куда их сносит ветер. + * Снег идёт только над землёй и под открытым небом, внутри помещения его нет. + */ class SnowFlakesTest { private static final SnowFlakes.Wind CALM = SnowFlakes.Wind.ZERO; + private static final SnowFlakes.Ground FLAT = (x, z) -> 60.0f; + private static final SnowFlakes.Ground ROOF = (x, z) -> 100.0f; + + private static void tick(SnowFlakes f, float dt, double cx, double cy, double cz, float radius, + SnowFlakes.Wind wind, float turb, int target, double seconds, SnowFlakes.Ground ground) { + f.update(dt, cx, cy, cz, radius, wind, turb, target, seconds, ground); + } private static double meanY(SnowFlakes f) { double sum = 0; @@ -26,8 +36,8 @@ class SnowFlakesTest { @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(12f, 0f, 1f)); + assertEquals(0, SnowFlakes.targetCount(12f, 1f, 0f)); assertEquals(0, SnowFlakes.targetCount(-3f, 1f, 1f)); } @@ -39,42 +49,73 @@ class SnowFlakesTest { } @Test - void halfTheIntensityGivesAboutHalfTheFlakes() { + void halfTheStrengthGivesAboutHalfTheFlakes() { 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)); + void flakeCountIsCapped() { + assertEquals(SnowFlakes.MAX_FLAKES, SnowFlakes.targetCount(24f, 3f, 1f)); + assertEquals(12000, SnowFlakes.MAX_FLAKES); } @Test void activeFlakeCountFollowsTheTargetUpAndDown() { SnowFlakes f = new SnowFlakes(); - f.update(0.016f, 0, 64, 0, 14f, CALM, 0f, 1f, 500, 0f); + tick(f, 0.016f, 0, 64, 0, 14f, CALM, 0f, 500, 0, FLAT); assertEquals(500, f.count); - f.update(0.016f, 0, 64, 0, 14f, CALM, 0f, 1f, 120, 0f); + tick(f, 0.016f, 0, 64, 0, 14f, CALM, 0f, 120, 0, FLAT); assertEquals(120, f.count); - f.update(0.016f, 0, 64, 0, 14f, CALM, 0f, 1f, 0, 0f); + tick(f, 0.016f, 0, 64, 0, 14f, CALM, 0f, 0, 0, FLAT); assertEquals(0, f.count); } - // ---- где они живут + // ---- где они живут: над землёй, не в помещении @Test - void freshFlakesSpawnInsideTheCylinderAroundTheCamera() { + void freshFlakesSpawnInsideTheCylinderBetweenGroundAndCeiling() { SnowFlakes f = new SnowFlakes(); - f.update(0.0001f, 100, 70, -50, 12f, CALM, 0f, 1f, 800, 0f); + tick(f, 0.0001f, 100, 70, -50, 12f, CALM, 0f, 800, 0, FLAT); 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); + assertTrue(f.y[i] > 64.0 - 1e-6, "not below camera - 6 (above the ground 60)"); + assertTrue(f.y[i] <= 70 + SnowFlakes.SPAWN_CEILING + 1e-6); } } + @Test + void noSnowFallsIndoorsUnderARoof() { + SnowFlakes f = new SnowFlakes(); + tick(f, 0.016f, 0, 64, 0, 14f, CALM, 0f, 1000, 0, ROOF); + assertEquals(0, f.count); + } + + @Test + void snowStopsAtTheGroundAndNeverSinksBelowIt() { + SnowFlakes f = new SnowFlakes(); + double cx = 0; + for (int step = 0; step < 800; step++) { + tick(f, 0.02f, cx, 62, 0, 14f, CALM, 0.4f, 400, step * 0.02, FLAT); + for (int i = 0; i < f.count; i++) assertTrue(f.y[i] > 60.0, "flake sank into the ground"); + } + assertTrue(f.count > 0); + } + + @Test + void flakesOverARoofedColumnDisappearButOpenSkyKeepsSnowing() { + // The left half (x < 0) is open sky, the right half is a roof. + SnowFlakes.Ground half = (x, z) -> x < 0 ? 60.0f : 100.0f; + SnowFlakes f = new SnowFlakes(); + for (int step = 0; step < 600; step++) { + tick(f, 0.02f, 0, 64, 0, 14f, CALM, 0.3f, 600, step * 0.02, half); + } + assertTrue(f.count > 0); + for (int i = 0; i < f.count; i++) assertTrue(f.x[i] < 1.0, "snow appeared under the roof"); + } + @Test void flakesFollowAMovingCameraAndNeverLeaveTheStormVolume() { SnowFlakes f = new SnowFlakes(); @@ -82,13 +123,23 @@ class SnowFlakesTest { 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); + tick(f, 0.02f, cx, 64, cz, 14f, SnowFlakes.wind(step * 0.02, 1.0f, 1.0f), 0.5f, 600, step * 0.02, FLAT); } 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); + assertTrue(f.y[i] <= 64 + SnowFlakes.RECYCLE_HEIGHT + 1e-6); + } + } + + @Test + void teleportingTheCameraBringsTheSnowAlong() { + SnowFlakes f = new SnowFlakes(); + tick(f, 0.02f, 0, 64, 0, 14f, CALM, 0f, 300, 0, FLAT); + tick(f, 0.02f, 5000, 64, 5000, 14f, CALM, 0f, 300, 0, FLAT); + for (int i = 0; i < f.count; i++) { + assertTrue(Math.abs(f.x[i] - 5000) < 20 && Math.abs(f.z[i] - 5000) < 20); } } @@ -97,34 +148,22 @@ class SnowFlakesTest { @Test void snowFallsDownwardInCalmAir() { SnowFlakes f = new SnowFlakes(); - f.update(0.0001f, 0, 64, 0, 14f, CALM, 0f, 1f, 200, 0f); + tick(f, 0.0001f, 0, 70, 0, 14f, CALM, 0f, 200, 0, FLAT); double before = meanY(f); - f.update(0.1f, 0, 64, 0, 14f, CALM, 0f, 1f, 200, 0.1f); + tick(f, 0.1f, 0, 70, 0, 14f, CALM, 0f, 200, 0.1, FLAT); 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); + tick(calm, 0.0001f, 0, 70, 0, 14f, CALM, 0f, 300, 0, FLAT); + tick(windy, 0.0001f, 0, 70, 0, 14f, CALM, 0f, 300, 0, FLAT); 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); + tick(calm, 0.2f, 0, 70, 0, 14f, CALM, 0f, 300, 0.2, FLAT); + tick(windy, 0.2f, 0, 70, 0, 14f, new SnowFlakes.Wind(12, 0, 0), 0f, 300, 0.2, FLAT); assertTrue(meanX(windy) - w0 > meanX(calm) - c0 + 1.0); } @@ -134,8 +173,8 @@ class SnowFlakesTest { 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); + tick(a, 0.05f, 1, 70, 3, 14f, CALM, 0.3f, 100, 1, FLAT); + tick(b, 0.05f, 1, 70, 3, 14f, CALM, 0.3f, 100, 1, FLAT); 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); @@ -145,18 +184,30 @@ class SnowFlakesTest { @Test void zeroTimeStepChangesNothing() { SnowFlakes f = new SnowFlakes(); - f.update(0.0001f, 0, 64, 0, 14f, CALM, 0f, 1f, 50, 0f); + tick(f, 0.0001f, 0, 70, 0, 14f, CALM, 0f, 50, 0, FLAT); double y0 = f.y[0]; - f.update(0f, 0, 64, 0, 14f, CALM, 0f, 1f, 50, 0f); + tick(f, 0f, 0, 70, 0, 14f, CALM, 0f, 50, 0, FLAT); assertEquals(y0, f.y[0], 0.0); } + @Test + void turbulenceAddsSidewaysJitterButNoNetDownwindDrift() { + SnowFlakes f = new SnowFlakes(); + tick(f, 0.0001f, 0, 70, 0, 14f, CALM, 0f, 400, 0, FLAT); + double x0 = meanX(f); + tick(f, 0.1f, 0, 70, 0, 14f, CALM, SnowFlakes.turbulence(1f), 400, 0.1, FLAT); + boolean sideways = false; + for (int i = 0; i < f.count; i++) sideways |= Math.abs(f.vx[i]) > 0.05f; + assertTrue(sideways); + assertEquals(0.0, meanX(f) - x0, 0.3); + } + // ---- ветер @Test void noWindWithoutPowerOrStorm() { - assertEquals(SnowFlakes.Wind.ZERO, SnowFlakes.wind(3f, 0f, 1f)); - assertEquals(SnowFlakes.Wind.ZERO, SnowFlakes.wind(3f, 1f, 0f)); + assertEquals(SnowFlakes.Wind.ZERO, SnowFlakes.wind(3, 0f, 1f)); + assertEquals(SnowFlakes.Wind.ZERO, SnowFlakes.wind(3, 1f, 0f)); } private static double horizontal(SnowFlakes.Wind w) { @@ -164,39 +215,44 @@ class SnowFlakesTest { } @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); + void windGrowsLinearlyWithPowerAndStormStrength() { + double base = horizontal(SnowFlakes.wind(5, 0.5f, 0.5f)); + assertEquals(2.0, horizontal(SnowFlakes.wind(5, 1.0f, 0.5f)) / base, 1e-9); + assertEquals(2.0, horizontal(SnowFlakes.wind(5, 0.5f, 1.0f)) / 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); + double cap = SnowFlakes.WIND_SPEED_MAX * 1.5; + double min = Double.MAX_VALUE; + for (int i = 0; i < 2000; i++) { + SnowFlakes.Wind w = SnowFlakes.wind(i * 0.37, 1.0f, 1.0f); assertTrue(horizontal(w) <= cap + 1e-9); - assertTrue(Math.abs(w.y()) <= horizontal(w) * 0.2 + 1e-9); + assertTrue(Math.abs(w.y()) <= horizontal(w) * 0.12 + 1e-9); + min = Math.min(min, horizontal(w)); } + assertTrue(min >= SnowFlakes.WIND_SPEED_MAX * 0.5 - 1e-9, "the wind never calms below half of its power"); } @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); + void windDirectionTurnsSlowlyOverTime() { + SnowFlakes.Wind a = SnowFlakes.wind(100, 1f, 1f); + SnowFlakes.Wind b = SnowFlakes.wind(100.5, 1f, 1f); + double step = Math.abs(Math.atan2(b.z(), b.x()) - Math.atan2(a.z(), a.x())); + assertTrue(step < 0.3, "half a second never swings the wind round"); + SnowFlakes.Wind late = SnowFlakes.wind(400, 1f, 1f); + double total = Math.abs(Math.atan2(late.z(), late.x()) - Math.atan2(a.z(), a.x())); + assertTrue(total > 0.5, "over minutes the direction drifts"); } @Test - void strongerWindMeansMoreTurbulence() { - assertTrue(SnowFlakes.turbulence(2f) > SnowFlakes.turbulence(0.5f)); - assertTrue(SnowFlakes.turbulence(0f) > 0f); - assertEquals(SnowFlakes.turbulence(0f), SnowFlakes.turbulence(-1f), 0f); + void turbulenceScalesWithTheSetting() { + assertEquals(0f, SnowFlakes.turbulence(0f), 0f); + assertEquals(0f, SnowFlakes.turbulence(-1f), 0f); + assertEquals(2.0, SnowFlakes.turbulence(1f) / SnowFlakes.turbulence(0.5f), 1e-6); } - // ---- плавный разгон шторма + // ---- сила шторма и экспозиция @Test void stormRampsUpToTheIntensitySettingNotToFull() { @@ -210,9 +266,32 @@ class SnowFlakesTest { 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"); + assertTrue(down > 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); } + + @Test + void skyExposureIsFullOutdoorsAndZeroUnderACeiling() { + assertEquals(1f, SnowFlakes.exposureTarget(70, 65f), 0f); + assertEquals(1f, SnowFlakes.exposureTarget(65, 65f), 0f); + assertEquals(0f, SnowFlakes.exposureTarget(64, 65f), 0f); + } + + @Test + void exposureEasesInsteadOfSnapping() { + float e = SnowFlakes.approach(0f, 1f, SnowFlakes.EXPOSURE_PER_SECOND, 0.1f); + assertTrue(e > 0f && e < 0.3f); + assertEquals(1f, SnowFlakes.approach(0f, 1f, SnowFlakes.EXPOSURE_PER_SECOND, 5f), 0f); + } + + @Test + void stormFollowsTheSnowfallOnlyOption() { + assertEquals(0.85f, SnowFlakes.targetStrength(0.85f, false, 0f, false), 0f); + assertEquals(0f, SnowFlakes.targetStrength(0.85f, true, 0f, true), 0f); + assertEquals(0f, SnowFlakes.targetStrength(0.85f, true, 1f, false), 0f); + assertEquals(0.85f, SnowFlakes.targetStrength(0.85f, true, 1f, true), 0f); + assertEquals(0.425f, SnowFlakes.targetStrength(0.85f, true, 0.5f, true), 1e-6f); + } } diff --git a/mod/src/test/java/dev/loki/lovisual/features/module/modules/visuals/weather/blizzard/view/BlizzardFogTest.java b/mod/src/test/java/dev/loki/lovisual/features/module/modules/visuals/weather/blizzard/view/BlizzardFogTest.java new file mode 100644 index 0000000..5e3b017 --- /dev/null +++ b/mod/src/test/java/dev/loki/lovisual/features/module/modules/visuals/weather/blizzard/view/BlizzardFogTest.java @@ -0,0 +1,47 @@ +package dev.loki.lovisual.features.module.modules.visuals.weather.blizzard.view; + +import org.junit.jupiter.api.Test; + +import static org.junit.jupiter.api.Assertions.*; + +/** Спецификация белой пелены: чем сильнее шторм, тем ближе туман; пелена только сжимает обзор и красит туман. */ +class BlizzardFogTest { + + @Test + void faintStormLeavesTheFogAlone() { + assertFalse(BlizzardFog.active(0.0f)); + assertFalse(BlizzardFog.active(0.01f)); + assertTrue(BlizzardFog.active(0.2f)); + } + + @Test + void strongerStormPullsTheFogCloser() { + assertTrue(BlizzardFog.endDistance(28f, 1.0f) < BlizzardFog.endDistance(28f, 0.5f)); + assertEquals(28f, BlizzardFog.endDistance(28f, 1.0f), 1e-6f); + assertTrue(BlizzardFog.startDistance(28f, 0.8f) < BlizzardFog.endDistance(28f, 0.8f)); + } + + @Test + void fogNeverMovesFurtherThanTheVanillaOne() { + assertEquals(40f, BlizzardFog.limit(100f, 40f), 0f); + assertEquals(60f, BlizzardFog.limit(60f, 400f), 0f); + } + + @Test + void brightnessIsAlwaysAValidFactorAndBrighterSkyGivesBrighterSnowFog() { + for (float c : new float[]{0f, 0.1f, 0.5f, 1f, 3f}) { + float k = BlizzardFog.brightness(c, c, c); + assertTrue(k >= 0f && k <= 1f); + } + assertTrue(BlizzardFog.brightness(0.6f, 0.3f, 0.3f) > BlizzardFog.brightness(0.1f, 0.1f, 0.1f)); + assertTrue(BlizzardFog.brightness(0f, 0f, 0f) > 0f, "the night fog still glows a little"); + } + + @Test + void fogColourMovesFromVanillaTowardsTheSnowTint() { + assertEquals(0.2f, BlizzardFog.blend(0.2f, 1f, 1f, 0f), 1e-6f); + float half = BlizzardFog.blend(0.2f, 1f, 1f, 0.5f); + float full = BlizzardFog.blend(0.2f, 1f, 1f, 1f); + assertTrue(half > 0.2f && full > half && full <= 1f); + } +} diff --git a/mod/src/test/java/dev/loki/lovisual/features/module/modules/visuals/weather/blizzard/view/BlizzardOverlayTest.java b/mod/src/test/java/dev/loki/lovisual/features/module/modules/visuals/weather/blizzard/view/BlizzardOverlayTest.java new file mode 100644 index 0000000..a97b949 --- /dev/null +++ b/mod/src/test/java/dev/loki/lovisual/features/module/modules/visuals/weather/blizzard/view/BlizzardOverlayTest.java @@ -0,0 +1,59 @@ +package dev.loki.lovisual.features.module.modules.visuals.weather.blizzard.view; + +import org.junit.jupiter.api.Test; + +import static org.junit.jupiter.api.Assertions.*; + +/** Спецификация экранных эффектов метели: лёгкая дымка и обмерзание, которое копится к краям. */ +class BlizzardOverlayTest { + + @Test + void hazeNeverVeilsTheWholeWorldAndGrowsWithTheStorm() { + assertEquals(0f, BlizzardOverlay.hazeAlpha(0f), 0f); + assertTrue(BlizzardOverlay.hazeAlpha(1f) > BlizzardOverlay.hazeAlpha(0.3f)); + assertTrue(BlizzardOverlay.hazeAlpha(1f) <= 0.15f, "the haze stays a light veil"); + } + + @Test + void frostAtTheEdgeAddsUpToTheConfiguredShareOfTheStorm() { + float current = 0.7f; + float total = BlizzardOverlay.frostBandAlpha(current) * BlizzardOverlay.FROST_BANDS; + assertEquals(0.55f * current, total, 1e-6f); + } + + @Test + void frostIsStrongestAtTheEdgeAndFadesTowardsTheCentre() { + float depth = 100f; + float edge = BlizzardOverlay.edgeCoverage(0f, depth, BlizzardOverlay.FROST_BANDS); + float mid = BlizzardOverlay.edgeCoverage(depth * 0.2f, depth, BlizzardOverlay.FROST_BANDS); + float inner = BlizzardOverlay.edgeCoverage(depth * 0.9f, depth, BlizzardOverlay.FROST_BANDS); + float centre = BlizzardOverlay.edgeCoverage(depth * 1.5f, depth, BlizzardOverlay.FROST_BANDS); + assertEquals(1f, edge, 0f); + assertTrue(edge > mid && mid > inner && inner > centre); + assertEquals(0f, centre, 0f); + } + + @Test + void bandsGetThinnerOneByOne() { + float depth = 100f; + float prev = Float.MAX_VALUE; + for (int i = 0; i < BlizzardOverlay.FROST_BANDS; i++) { + float t = BlizzardOverlay.bandThickness(i, BlizzardOverlay.FROST_BANDS, depth); + assertTrue(t <= prev && t >= 0f); + prev = t; + } + assertEquals(depth, BlizzardOverlay.bandThickness(0, BlizzardOverlay.FROST_BANDS, depth), 1e-6f); + } + + @Test + void frostTakesAFifthOfTheScreenAtMost() { + assertEquals(0.2f, BlizzardOverlay.FROST_DEPTH_SHARE, 0f); + } + + @Test + void alphaIsClampedIntoAByte() { + assertEquals(0x00FFFFFF & 0x123456, BlizzardOverlay.withAlpha(0xFF123456, -3f) & 0x00FFFFFF); + assertEquals(0xFF, BlizzardOverlay.withAlpha(0x00123456, 7f) >>> 24); + assertEquals(0x80, BlizzardOverlay.withAlpha(0x00123456, 0.5f) >>> 24); + } +}