feat(mod): align ElytraTrails and Blizzard with evo (real wing-tip matrices, ribbon styles, ground-aware snow, whiteout fog, hide vanilla weather), spec tests

This commit is contained in:
loki5512344 2026-10-02 15:58:33 +02:00
parent 9661ff638d
commit 60b4fedb29
Signed by: boba
GPG key ID: 253067914055423B
34 changed files with 1937 additions and 623 deletions

View file

@ -44,14 +44,15 @@
дистанция, размер), снимок меню `ModulesMenuScreen.presenceSnapshot`. Мини-копия ClickGUI в мире перед игроком из цветных квадов дистанция, размер), снимок меню `ModulesMenuScreen.presenceSnapshot`. Мини-копия ClickGUI в мире перед игроком из цветных квадов
(без текста). Нужны привязанный аккаунт (`%link`) и мультиплеер. Не сделано: значок «LoVisual» у ника/в табе, названия модулей в панели, (без текста). Нужны привязанный аккаунт (`%link`) и мультиплеер. Не сделано: значок «LoVisual» у ника/в табе, названия модулей в панели,
просмотр видео (`.watch` у evo), деплой chat-service на прод СДЕЛАН 2026-10-02 (rsync backend + `docker compose up -d --build chat-service gateway`; на сервер не уехали накопленные правки accounts-service/configs-service, они ждут отдельного деплоя). просмотр видео (`.watch` у evo), деплой chat-service на прод СДЕЛАН 2026-10-02 (rsync backend + `docker compose up -d --build chat-service gateway`; на сервер не уехали накопленные правки accounts-service/configs-service, они ждут отдельного деплоя).
- [x] ElytraTrails и Blizzard (код пришёл от остановленных сессий, проверен и доработан; тесты написаны по спецификации, а не по реализации): - [x] ElytraTrails и Blizzard сверены с оригиналом evo (`ref/evo/.../ElytraTrailsModule`, `BlizzardModule`), спец-тесты по поведению evo:
ElytraTrails: ленты с концов крыльев только при планировании (`isFallFlying`), опора позади глаз, размах перпендикулярен полёту и ElytraTrails: концы крыльев по настоящей цепочке матриц модели элитры (`pose/WingTipLocator`, `ElytraPoses`), горизонтальная
горизонтален (`WingTipLocator`, 7 тестов). Общий код вынесен в `effects/ribbon/` (`TrackBuffer`, `TrailRibbon`, `RibbonPoint`), его лента, сужение Grow/Shrink, колебания Wave/Helix, плавный старт, реакция на скорость, мягкие края, ореол, свой/чужие, разрыв ленты
используют ProjectileTrails и ElytraTrails (убрано дублирование; тесты ribbon 14 + ProjectileTrails 6 + Wing store 5). при скачке больше 8 блоков или паузе больше 300 мс, шаг записи 0.35, до 256 точек (`ribbon/RibbonShape`, `TrackBuffer.addContinuous`).
`AnimatedRenderColors.modeOf` заменил три копии `parse`. Blizzard: исправлено, что «Интенсивность» ничего не меняла (сила Не сделано из evo: заливка шейдером (8 видов), раздельные цвета крыльев.
нормировалась к 1), снежинки теперь закрываются блоками (depth-пайплайн), чистая логика вынесена и покрыта (SnowFlakes 19, Blizzard: снег только над землёй и под открытым небом (карта высот, спавн между землёй и потолком, нет снега в помещении),
Overlay 8, Renderer 9 тестов); добавлены все lang-ключи модулей. Не проверено вживую: вид лент и метели, положение концов крыльев, константы ветра/плотности/турбулентности как у evo, экспозиция неба, «Whiteout» (миксин тумана `BlizzardFogMixin`), скрытие ванильной
снег не отсекается «крышей» (метель идёт и в помещении). погоды (`WeatherHideMixin`), рендер «капля» вдоль скорости, обмерзание 32 полосы, «только в снегопад» по силе осадков.
Не сделано из evo: «Snow cover» (снег накапливается на земле, шейдер). Не проверено вживую: вид, туман, положение концов крыльев.
- [ ] Wings: режим «Модель» (Bedrock geo.json + animation.json, 14 видов; отчёт раздел 2.4). Шаги: `BedrockGeoParser` (общий с ножами), - [ ] Wings: режим «Модель» (Bedrock geo.json + animation.json, 14 видов; отчёт раздел 2.4). Шаги: `BedrockGeoParser` (общий с ножами),
`BedrockAnimation` + процедурные angel/demon/dragon, `WingsRenderer.renderModel`, настройки `wingSource`/`wingModel`, тесты. `BedrockAnimation` + процедурные angel/demon/dragon, `WingsRenderer.renderModel`, настройки `wingSource`/`wingModel`, тесты.
Ресурсы (локально): `ref/evo/mod/jar/assets/evolution/cosmetic/wings/`, `.../textures/cosmetic/wings/`. Ресурсы (локально): `ref/evo/mod/jar/assets/evolution/cosmetic/wings/`, `.../textures/cosmetic/wings/`.

View file

@ -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);
}
}

View file

@ -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.ModuleCategory;
import dev.loki.lovisual.features.module.core.ModuleInfo; import dev.loki.lovisual.features.module.core.ModuleInfo;
import dev.loki.lovisual.features.module.phase.WorldPhase; 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.TrackBuffer;
import dev.loki.lovisual.features.module.modules.visuals.effects.ribbon.TrailRibbon; 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.animation.AnimatedRenderColors;
import dev.loki.lovisual.render.engine.renderer.Renderer3D; import dev.loki.lovisual.render.engine.renderer.Renderer3D;
import dev.loki.lovisual.util.time.GameClock; import dev.loki.lovisual.util.time.GameClock;
import net.minecraft.client.Minecraft; import net.minecraft.client.Minecraft;
import net.minecraft.client.player.AbstractClientPlayer;
import net.minecraft.world.entity.player.Player; import net.minecraft.world.entity.player.Player;
import net.minecraft.world.phys.Vec3; import net.minecraft.world.phys.Vec3;
@ -30,12 +34,27 @@ public class ElytraTrails extends Module {
private final WingTrailStore store = new WingTrailStore(); 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 BooleanValue others = bool("elytraTrailsOthers", "others", true);
private final NumberValue<Integer> duration = num("elytraTrailsDuration", "duration", 700, 150, 3000); private final NumberValue<Integer> duration = num("elytraTrailsDuration", "duration", 1200, 200, 4000);
private final NumberValue<Float> width = num("elytraTrailsWidth", "width", 0.35f, 0.05f, 1.5f); private final NumberValue<Float> width = num("elytraTrailsWidth", "width", 0.22f, 0.02f, 1.5f);
private final ModeValue taper = modeSetting("elytraTrailsTaper", "taper", "Grow", "Uniform", "Grow", "Shrink");
private final NumberValue<Float> 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<Float> waveSize = visibleWhen(
num("elytraTrailsWaveSize", "wave_size", 0.35f, 0.05f, 2.0f), () -> !"Straight".equals(motion.get()));
private final NumberValue<Float> 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 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 BooleanValue lines = bool("elytraTrailsLines", "lines", false);
private final NumberValue<Float> lineWidth = visibleWhen(num("elytraTrailsLineWidth", "line_width", 2.0f, 0.5f, 5.0f), lines::get); private final NumberValue<Float> 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 BooleanValue depthTest = bool("elytraTrailsDepth", "depth_test", true);
private final ModeValue colorMode = modeSetting("elytraTrailsColorMode", "color_mode", "DoubleColor", private final ModeValue colorMode = modeSetting("elytraTrailsColorMode", "color_mode", "DoubleColor",
"Static", "Rainbow", "LightRainbow", "Sky", "Fade", "DoubleColor", "Analogous", "Theme"); "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; if (!isEnabled() || mc.level == null || mc.gameRenderer.mainCamera() == null) return;
long now = GameClock.millis(); long now = GameClock.millis();
long life = duration.get(); long life = duration.get();
Vec3 cam = mc.gameRenderer.mainCamera().position();
for (Player player : mc.level.players()) { for (Player player : mc.level.players()) {
if (!others.get() && player != mc.player) continue; if (!(player instanceof AbstractClientPlayer wearer)) continue;
if (!player.isAlive() || !player.isFallFlying()) continue; if (!wearer.isFallFlying() || wearer.isInvisible() || !wearer.isAlive()) continue;
WingTipLocator.Tips tips = WingTipLocator.tips(player.getEyePosition(tickDelta), if (!(wearer == mc.player ? own.get() : others.get())) continue;
player.getViewYRot(tickDelta), player.getViewXRot(tickDelta)); if (wearer.distanceToSqr(cam) > MAX_TRACK_DISTANCE_SQ) continue;
store.sample(player.getUUID(), WingTrailStore.LEFT, tips.left().x, tips.left().y, tips.left().z, now, life); WingTipLocator.Tips tips = WingTipLocator.tips(ElytraPoses.of(wearer, tickDelta));
store.sample(player.getUUID(), WingTrailStore.RIGHT, tips.right().x, tips.right().y, tips.right().z, now, life); 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); store.prune(now, life);
List<TrackBuffer> tracks = store.snapshot(); List<TrackBuffer> tracks = store.snapshot();
@ -77,10 +99,13 @@ public class ElytraTrails extends Module {
int secondary = color2.getArgb(); int secondary = color2.getArgb();
int speed = colorSpeed.get(); int speed = colorSpeed.get();
AnimatedRenderColors.Mode mode = AnimatedRenderColors.modeOf(colorMode.get()); AnimatedRenderColors.Mode mode = AnimatedRenderColors.modeOf(colorMode.get());
Vec3 cam = mc.gameRenderer.mainCamera().position(); ElytraRibbonStyle style = new ElytraRibbonStyle(RibbonShape.Taper.of(taper.get()), taperAmount.get(),
TrailRibbon.render(renderer, tracks, now, life, width.get(), true, glow.get(), lines.get(), lineWidth.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, depthTest.get(), cam,
(i, n) -> AnimatedRenderColors.resolve(mode, speed, (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);
} }
} }

View file

@ -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)));
}
}

View file

@ -20,10 +20,19 @@ final class WingTrailStore {
tracks.clear(); 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) { 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]); TrackBuffer[] pair = tracks.computeIfAbsent(id, ignored -> new TrackBuffer[2]);
if (pair[side] == null) pair[side] = new TrackBuffer(); 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);
} }
/** Убирает устаревшие точки; опустевшие ленты и игроки без лент удаляются. */ /** Убирает устаревшие точки; опустевшие ленты и игроки без лент удаляются. */

View file

@ -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();
}
}

View file

@ -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);
}
}

View file

@ -1,5 +1,9 @@
package dev.loki.lovisual.features.module.modules.visuals.effects.ribbon; 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);
}
} }

View file

@ -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};
}
}

View file

@ -33,6 +33,32 @@ public final class TrackBuffer {
return added; 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) { public void trim(long now, long lifeMs) {
while (!points.isEmpty() && now - points.peekFirst().ms() > lifeMs) points.pollFirst(); while (!points.isEmpty() && now - points.peekFirst().ms() > lifeMs) points.pollFirst();
} }

View file

@ -17,6 +17,57 @@ import java.util.function.IntBinaryOperator;
*/ */
public final class TrailRibbon { 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() { private TrailRibbon() {
} }
@ -40,18 +91,32 @@ public final class TrailRibbon {
return width * 0.5 * (0.25 + 0.75 * l); return width * 0.5 * (0.25 + 0.75 * l);
} }
/** Лента прежнего вида. */
public static void render(Renderer3D renderer, Collection<TrackBuffer> 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 * @param color цвет точки: (индекс от хвоста, число точек) -> ARGB
*/ */
public static void render(Renderer3D renderer, Collection<TrackBuffer> tracks, long now, long lifeMs, float width, public static void render(Renderer3D renderer, Collection<TrackBuffer> tracks, long now, long lifeMs, float width,
boolean fill, boolean glow, boolean lines, float lineWidth, boolean depth, Vec3 cam, 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; MeshBuilder fillMesh = null;
if (fill) { if (fill) {
var pipeline = glow var pipeline = glow
? (depth ? LoVisualRenderPipelines.WORLD_COLORED_ADDITIVE_DEPTH : LoVisualRenderPipelines.WORLD_COLORED_ADDITIVE) ? (depth ? LoVisualRenderPipelines.WORLD_COLORED_ADDITIVE_DEPTH : LoVisualRenderPipelines.WORLD_COLORED_ADDITIVE)
: (depth ? LoVisualRenderPipelines.WORLD_COLORED_LIQUID_IGNORE : LoVisualRenderPipelines.WORLD_COLORED); : (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; MeshBuilder lineMesh = null;
if (lines) { if (lines) {
@ -59,41 +124,73 @@ public final class TrailRibbon {
try { try {
RenderState.lineWidth = Math.max(0.5f, lineWidth); RenderState.lineWidth = Math.max(0.5f, lineWidth);
lineMesh = renderer.batch(depth ? LoVisualRenderPipelines.WORLD_COLORED_LINES_LIQUID_IGNORE 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 { } finally {
RenderState.lineWidth = prev; RenderState.lineWidth = prev;
} }
} }
if (fillMesh == null && lineMesh == null) return; if (fillMesh == null && lineMesh == null && glowMesh == null) return;
for (TrackBuffer track : tracks) { for (TrackBuffer track : tracks) {
List<RibbonPoint> pts = track.points(); List<RibbonPoint> 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<RibbonPoint> pts, long now, long lifeMs, float width, MeshBuilder fill, private static void draw(List<RibbonPoint> 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(); 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[][] left = new double[n][3];
double[][] right = new double[n][3]; double[][] right = new double[n][3];
int[] argb = new int[n]; int[] argb = new int[n];
double[] half = new double[n];
for (int i = 0; i < n; i++) { for (int i = 0; i < n; i++) {
RibbonPoint p = pts.get(i); RibbonPoint p = pts.get(i);
RibbonPoint a = pts.get(Math.max(0, i - 1)); float life = style == LEGACY ? lifeFraction(now, p.ms(), lifeMs) : RibbonShape.life(now, p.ms(), lifeMs);
RibbonPoint b = pts.get(Math.min(n - 1, i + 1)); half[i] = style.halfWidth(width, i, n, p, life);
double dx = b.x() - a.x(), dy = b.y() - a.y(), dz = b.z() - a.z(); double dx = center[i][0] - cam.x, dy = center[i][1] - cam.y, dz = center[i][2] - cam.z;
double vx = cam.x - p.x(), vy = cam.y - p.y(), vz = cam.z - p.z(); float mult = style.alpha(i, n, p, life, (float) Math.sqrt(dx * dx + dy * dy + dz * dz));
double sx = dy * vz - dz * vy, sy = dz * vx - dx * vz, sz = dx * vy - dy * vx; argb[i] = scaleAlpha(color.applyAsInt(i, n), mult);
double len = Math.sqrt(sx * sx + sy * sy + sz * sz); for (int k = 0; k < 3; k++) {
if (len < 1e-6) len = 1.0; left[i][k] = center[i][k] + side[i][k] * half[i];
float life = lifeFraction(now, p.ms(), lifeMs); right[i][k] = center[i][k] - side[i][k] * half[i];
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);
} }
boolean soft = style.softEdges();
for (int i = 1; i < n; i++) { 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) { if (line != null) {
segment(line, left[i - 1], left[i], argb[i - 1], argb[i]); segment(line, left[i - 1], left[i], argb[i - 1], argb[i]);
segment(line, right[i - 1], right[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) { 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(); m.ensureQuadCapacity();
int a = m.vec3(l0[0], l0[1], l0[2]).color(new RenderColor(c0)).next(); int a = m.vec3(p0[0], p0[1], p0[2]).color(new RenderColor(c0)).next();
int b = m.vec3(r0[0], r0[1], r0[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(r1[0], r1[1], r1[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(l1[0], l1[1], l1[2]).color(new RenderColor(c1)).next(); int d = m.vec3(p3[0], p3[1], p3[2]).color(new RenderColor(c3)).next();
m.quad(a, b, c, d); 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) { private static void segment(MeshBuilder m, double[] p0, double[] p1, int c0, int c1) {
m.ensureLineCapacity(); m.ensureLineCapacity();
int a = m.vec3(p0[0], p0[1], p0[2]).color(new RenderColor(c0)).next(); int a = m.vec3(p0[0], p0[1], p0[2]).color(new RenderColor(c0)).next();

View file

@ -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.ModuleCategory;
import dev.loki.lovisual.features.module.core.ModuleInfo; import dev.loki.lovisual.features.module.core.ModuleInfo;
import dev.loki.lovisual.features.module.phase.HudPhase; 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.features.module.phase.WorldPhase;
import dev.loki.lovisual.render.engine.RenderState; import dev.loki.lovisual.render.engine.RenderState;
import dev.loki.lovisual.render.engine.pipeline.LoVisualRenderPipelines; 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 dev.loki.lovisual.render.engine.uniform.MeshBuilder;
import net.minecraft.client.Camera; import net.minecraft.client.Camera;
import net.minecraft.client.Minecraft; import net.minecraft.client.Minecraft;
import net.minecraft.client.multiplayer.ClientLevel;
import net.minecraft.client.gui.GuiGraphicsExtractor; import net.minecraft.client.gui.GuiGraphicsExtractor;
import net.minecraft.client.renderer.fog.FogData;
import net.minecraft.core.BlockPos; import net.minecraft.core.BlockPos;
import net.minecraft.util.Mth; import net.minecraft.util.Mth;
import net.minecraft.world.level.biome.Biome; 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; import net.minecraft.world.phys.Vec3;
/** /**
@ -31,28 +37,30 @@ import net.minecraft.world.phys.Vec3;
id = "blizzard", id = "blizzard",
displayName = "Blizzard", displayName = "Blizzard",
category = ModuleCategory.VISUALS, 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 { public final class Blizzard extends Module {
private final NumberValue<Float> intensity = num("blizzardIntensity", "intensity", 0.85f, 0.1f, 1.0f); private final NumberValue<Float> intensity = num("blizzardIntensity", "intensity", 0.85f, 0.1f, 1.0f);
private final NumberValue<Float> density = num("blizzardDensity", "density", 1.0f, 0.2f, 3.0f); private final NumberValue<Float> density = num("blizzardDensity", "density", 1.0f, 0.2f, 3.0f);
private final NumberValue<Float> speed = num("blizzardSpeed", "speed", 1.0f, 0.3f, 3.0f); private final NumberValue<Float> wind = num("blizzardWind", "wind", 0.7f, 0.0f, 1.0f);
private final NumberValue<Float> wind = num("blizzardWind", "wind", 0.7f, 0.0f, 2.0f); private final NumberValue<Float> turbulence = num("blizzardTurbulence", "turbulence", 0.6f, 0.0f, 1.0f);
private final NumberValue<Float> radius = num("blizzardRadius", "radius", 14.0f, 6.0f, 32.0f); private final NumberValue<Float> flakeSize = num("blizzardFlakeSize", "flake_size", 1.0f, 0.5f, 2.5f);
private final NumberValue<Float> flakeSize = num("blizzardFlakeSize", "flake_size", 1.0f, 0.3f, 2.5f); private final NumberValue<Float> radius = num("blizzardRadius", "radius", 12.0f, 6.0f, 24.0f);
private final NumberValue<Float> opacity = num("blizzardOpacity", "opacity", 0.7f, 0.05f, 1.0f); private final NumberValue<Float> opacity = num("blizzardOpacity", "opacity", 0.8f, 0.1f, 1.0f);
private final RGBAColorValue tint = color("blizzardTint", "tint", "#F2FFFFFF"); private final BooleanValue whiteout = bool("blizzardWhiteout", "whiteout", true);
private final NumberValue<Float> haze = num("blizzardHaze", "haze", 0.5f, 0.0f, 1.0f); private final NumberValue<Float> visibility =
visibleWhen(num("blizzardVisibility", "visibility", 28.0f, 8.0f, 128.0f), whiteout::get);
private final BooleanValue frost = bool("blizzardFrost", "frost", true); private final BooleanValue frost = bool("blizzardFrost", "frost", true);
private final NumberValue<Float> 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 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 final SnowFlakes flakes = new SnowFlakes();
private long lastFrameNanos; private long lastFrameNanos;
private float seconds;
private float strength; private float strength;
private float exposure;
private boolean frameActive;
@Override @Override
public HudPhase getHudPhase() { public HudPhase getHudPhase() {
@ -66,68 +74,100 @@ public final class Blizzard extends Module {
@Override @Override
public void onEnable() { public void onEnable() {
lastFrameNanos = 0L; reset();
seconds = 0.0f;
strength = 0.0f;
flakes.clear();
} }
@Override @Override
public void onDisable() { public void onDisable() {
reset();
}
private void reset() {
lastFrameNanos = 0L;
strength = 0.0f;
exposure = 0.0f;
frameActive = false;
flakes.clear(); 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() { private float frameDeltaSeconds() {
long now = System.nanoTime(); long now = System.nanoTime();
if (lastFrameNanos == 0L) lastFrameNanos = now; if (lastFrameNanos == 0L) lastFrameNanos = now;
float dt = (float) ((now - lastFrameNanos) / 1_000_000_000.0); float dt = (float) ((now - lastFrameNanos) / 1_000_000_000.0);
lastFrameNanos = now; lastFrameNanos = now;
return Mth.clamp(dt, 0.0f, 0.05f); return Mth.clamp(dt, 0.0f, 0.1f);
} }
private float targetStrength() { private float targetStrength(Minecraft mc) {
Minecraft mc = Minecraft.getInstance(); float rain = mc.level.getRainLevel(1.0f);
if (mc.player == null || mc.level == null) return 0.0f;
if (!onlyWhenSnowing.get()) return intensity.get();
if (mc.level.getRainLevel(1.0f) < 0.02f) return 0.0f;
BlockPos pos = mc.player.blockPosition(); BlockPos pos = mc.player.blockPosition();
Biome.Precipitation precipitation = boolean snowBiome = mc.level.getBiome(pos).value()
mc.level.getBiome(pos).value().getPrecipitationAt(pos, mc.level.getSeaLevel()); .getPrecipitationAt(pos, mc.level.getSeaLevel()) == Biome.Precipitation.SNOW;
return precipitation == Biome.Precipitation.SNOW ? intensity.get() : 0.0f; return SnowFlakes.targetStrength(intensity.get(), onlyWhenSnowing.get(), rain, snowBiome);
} }
@Override @Override
public void onRenderWorldEngine(Renderer3D renderer, Renderer3D depthRenderer, float tickDelta) { public void onRenderWorldEngine(Renderer3D renderer, Renderer3D depthRenderer, float tickDelta) {
Minecraft mc = Minecraft.getInstance(); Minecraft mc = Minecraft.getInstance();
if (!isEnabled() || mc.player == null || mc.level == null || mc.gameRenderer == null) return; if (!isEnabled() || mc.player == null || mc.level == null || mc.gameRenderer == null
if (mc.gameRenderer.mainCamera() == null) return; || mc.gameRenderer.mainCamera() == null) {
float dt = frameDeltaSeconds(); frameActive = false;
seconds += dt;
strength = SnowFlakes.ramp(strength, targetStrength(), dt);
float norm = strength;
if (norm <= 0.001f) {
flakes.clear();
return; return;
} }
Camera camera = mc.gameRenderer.mainCamera(); float dt = frameDeltaSeconds();
Vec3 cam = camera.position(); Vec3 cam = mc.gameRenderer.mainCamera().position();
SnowFlakes.Wind windValue = SnowFlakes.wind(seconds, wind.get(), norm); ClientLevel level = mc.level;
int target = SnowFlakes.targetCount(radius.get(), density.get(), norm); SnowFlakes.Ground ground = (x, z) -> level.getHeight(Heightmap.Types.MOTION_BLOCKING, x, z);
flakes.update(dt, cam.x, cam.y, cam.z, radius.get(), windValue, strength = SnowFlakes.ramp(strength, targetStrength(mc), dt);
SnowFlakes.turbulence(wind.get()), speed.get(), target, seconds); 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; if (flakes.count == 0) return;
MeshBuilder mesh = renderer.batch(LoVisualRenderPipelines.WORLD_COLORED_DEPTH, Renderer3D.DepthMode.MAIN); MeshBuilder mesh = renderer.batch(LoVisualRenderPipelines.WORLD_COLORED_DEPTH, Renderer3D.DepthMode.MAIN);
if (mesh == null) return; if (mesh == null) return;
BlizzardRenderer.render(mesh, flakes, RenderState.cameraRotation, BlizzardRenderer.render(mesh, flakes, RenderState.cameraRotation, cam.x, cam.y, cam.z,
cam.x, cam.y, cam.z, radius.get(), tint.getArgb(), norm, flakeSize.get(), opacity.get()); tint.getArgb(), strength, intensity.get(), flakeSize.get(), opacity.get());
} }
@Override @Override
public void onRenderHudEngine(Renderer2D renderer, TextRenderer textRenderer, public void onRenderHudEngine(Renderer2D renderer, TextRenderer textRenderer,
GuiGraphicsExtractor ctx, float tickDelta) { GuiGraphicsExtractor ctx, float tickDelta) {
if (!isEnabled()) return; if (!isEnabled()) return;
float norm = strength; float current = strength * exposure * intensity.get();
if (norm <= 0.01f) return; BlizzardOverlay.render(ctx, current, frost.get(), tint.getArgb());
BlizzardOverlay.render(ctx, norm, haze.get(), frost.get(), frostIntensity.get(), tint.getArgb());
} }
} }

View file

@ -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;
}
}

View file

@ -6,114 +6,127 @@ import org.joml.Quaternionf;
import org.joml.Vector3f; import org.joml.Vector3f;
/** /**
* Рендер снежинок: биллборд-квады, вытянутые по вектору скорости частицы, * Рендер снежинок по образцу evo: мягкая «капля» из двух воздушных змеев, вытянутая вдоль скорости,
* с затуханием у камеры и на границе зоны. * непрозрачный центр и прозрачные края. Снежинки позади камеры отбрасываются.
*/ */
final class BlizzardRenderer { 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() { private BlizzardRenderer() {
} }
static void render(MeshBuilder mesh, SnowFlakes flakes, Quaternionf camRot, static void render(MeshBuilder mesh, SnowFlakes flakes, Quaternionf camRot, double camX, double camY, double camZ,
double camX, double camY, double camZ, float radius, int argb, float strength, float intensity, float sizeSetting, float opacity) {
int argb, float strength, float sizeMul, float opacity) {
if (mesh == null || flakes.count == 0 || strength <= 0.001f || opacity <= 0.004f) return; if (mesh == null || flakes.count == 0 || strength <= 0.001f || opacity <= 0.004f) return;
int r = (argb >>> 16) & 0xFF; int rgb = argb & 0x00FFFFFF;
int g = (argb >>> 8) & 0xFF;
int b = argb & 0xFF;
Vector3f right = new Vector3f(1.0f, 0.0f, 0.0f).rotate(camRot);
Vector3f up = new Vector3f(0.0f, 1.0f, 0.0f).rotate(camRot);
Vector3f forward = new Vector3f(0.0f, 0.0f, -1.0f).rotate(camRot); 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++) { for (int i = 0; i < flakes.count; i++) {
double dx = flakes.x[i] - camX; float px = (float) (flakes.x[i] - camX);
double dy = flakes.y[i] - camY; float py = (float) (flakes.y[i] - camY);
double dz = flakes.z[i] - camZ; float pz = (float) (flakes.z[i] - camZ);
float dist = (float) Math.sqrt(dx * dx + dy * dy + dz * dz); float dist = (float) Math.sqrt(px * px + py * py + pz * pz);
if (dist < 0.2f) continue; if (dist <= 0.0f) continue;
float facing = (float) (dx * forward.x + dy * forward.y + dz * forward.z); float vx = px / dist, vy = py / dist, vz = pz / dist;
if (facing <= 0.0f) continue; 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);
float speedX = flakes.vx[i];
float speedY = flakes.vy[i];
float speedZ = flakes.vz[i];
float along = right.x * speedX + right.y * speedY + right.z * speedZ;
float across = up.x * speedX + up.y * speedY + up.z * speedZ;
float alongLen = (float) Math.sqrt(along * along + across * across);
if (alongLen < 1.0E-4f) {
along = 0.0f;
across = -1.0f;
alongLen = 1.0f;
}
along /= alongLen;
across /= alongLen;
// Единичный вектор скорости в плоскости биллборда.
float dirX = right.x * along + up.x * across;
float dirY = right.y * along + up.y * across;
float dirZ = right.z * along + up.z * across;
// Поперёк: forward x dir (оба перпендикулярны друг другу).
float sideX = forward.y * dirZ - forward.z * dirY;
float sideY = forward.z * dirX - forward.x * dirZ;
float sideZ = forward.x * dirY - forward.y * dirX;
float sideLen = (float) Math.sqrt(sideX * sideX + sideY * sideY + sideZ * sideZ);
if (sideLen < 1.0E-4f) continue;
sideX /= sideLen;
sideY /= sideLen;
sideZ /= sideLen;
float speed = (float) Math.sqrt(speedX * speedX + speedY * speedY + speedZ * speedZ);
float halfWidth = halfWidth(flakes.size[i], sizeMul, dist);
float halfLength = halfLength(halfWidth, speed, sizeMul);
float alpha = flakeAlpha(dist, flakes.age[i], flakes.alpha[i], radius, opacity, strength);
if (alpha <= 0.0f) continue; if (alpha <= 0.0f) continue;
float cx = (float) flakes.x[i]; float speed = (float) Math.sqrt(flakes.vx[i] * flakes.vx[i] + flakes.vy[i] * flakes.vy[i]
float cy = (float) flakes.y[i]; + flakes.vz[i] * flakes.vz[i]);
float cz = (float) flakes.z[i]; float dx, dy, dz;
float ax = dirX * halfLength; if (speed <= 1.0E-4f) {
float ay = dirY * halfLength; dx = up.x;
float az = dirZ * halfLength; dy = up.y;
float bx = sideX * halfWidth; dz = up.z;
float by = sideY * halfWidth; } else {
float bz = sideZ * halfWidth; dx = flakes.vx[i] / speed;
RenderColor color = new RenderColor(((int) (alpha * 255.0f) << 24) | (r << 16) | (g << 8) | b); dy = flakes.vy[i] / speed;
mesh.ensureQuadCapacity(); dz = flakes.vz[i] / speed;
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(); float sx = dy * vz - dz * vy, sy = dz * vx - dx * vz, sz = dx * vy - dy * vx;
int i3 = mesh.vec3(cx + ax + bx, cy + ay + by, cz + az + bz).color(color).next(); float sideLen = (float) Math.sqrt(sx * sx + sy * sy + sz * sz);
int i4 = mesh.vec3(cx - ax + bx, cy - ay + by, cz - az + bz).color(color).next(); if (sideLen <= 1.0E-4f) {
mesh.quad(i1, i2, i3, i4); 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) { static float flakeAlpha(float dist, float ageSeconds, float baseAlpha, float opacity, float strength,
float edgeStart = radius * 0.62f; float intensity) {
float edgeSpan = Math.max(0.001f, radius * 1.05f - edgeStart); float near = clamp01((dist - NEAR_FADE_START) / NEAR_FADE_SPAN);
float edge = 1.0f - clamp01((dist - edgeStart) / edgeSpan); float born = Math.min(ageSeconds / FADE_IN_SECONDS, 1.0f);
float near = clamp01((dist - 0.35f) / 0.5f); float share = clamp01(strength / Math.max(intensity, 0.01f));
float ageFade = Math.min(1.0f, ageSeconds / 0.45f); return opacity * share * baseAlpha * near * Math.max(0.0f, born);
float alpha = opacity * strength * baseAlpha * edge * near * ageFade;
if (alpha <= 0.008f) return 0.0f;
return Math.min(0.95f, alpha);
} }
/** Полуширина снежинки; вдали не тоньше доли пикселя, чтобы не пропадала. */ /** Радиус снежинки: вдали не меньше угловых 0.0024, чтобы не пропадала. */
static float halfWidth(float flakeSize, float sizeMul, float dist) { static float radius(float sizeSetting, float flakeSize, float dist) {
float hw = (float) (0.045 * flakeSize) * sizeMul; return Math.max(FLAKE_RADIUS * sizeSetting * flakeSize, dist * MIN_ANGULAR_RADIUS);
return Math.max(hw, dist * 0.0016f);
} }
/** Полудлина штриха: быстрее снежинка, длиннее след, но не больше 0.35 блока. */ /** Длина хвоста за снежинкой: растёт со скоростью. */
static float halfLength(float halfWidth, float speed, float sizeMul) { static float streakLength(float radius, float speed, float flakeSize) {
return halfWidth + Math.min(0.35f, speed * 0.022f * sizeMul); 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) { private static float clamp01(float v) {
if (v < 0.0f) return 0.0f; return v < 0.0f ? 0.0f : Math.min(v, 1.0f);
return v > 1.0f ? 1.0f : v;
} }
} }

View file

@ -3,19 +3,33 @@ package dev.loki.lovisual.features.module.modules.visuals.weather.blizzard;
import java.util.Random; import java.util.Random;
/** /**
* Чистая физика снежинок: спавн в цилиндре вокруг камеры, ветер с порывами, * Чистая физика снежинок по образцу evo: спавн в цилиндре вокруг камеры между землёй (карта высот) и потолком
* турбулентность и рецикл при выходе из зоны. Без зависимостей от Minecraft. * над головой, ветер с порывами, турбулентность, рецикл при выходе из зоны или касании земли.
* Под крышей земля выше потолка спавна, поэтому снега внутри помещений нет. Без зависимостей от Minecraft.
*/ */
final class SnowFlakes { 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 FALL_SPEED = 1.6f;
static final float AREA_FACTOR = 3.0f; static final float AREA_FACTOR = 9.0f;
static final double SPAWN_ABOVE = 8.0; static final double SPAWN_CEILING = 8.0;
static final double SPAWN_BELOW = 6.0; static final double SPAWN_FLOOR_MARGIN = 6.0;
static final double RECYCLE_MARGIN = 1.25; 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) { record Wind(double x, double y, double z) {
static final Wind ZERO = new Wind(0.0, 0.0, 0.0); 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[] phase = new float[MAX_FLAKES];
final float[] rate = new float[MAX_FLAKES]; final float[] rate = new float[MAX_FLAKES];
final float[] age = 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; int count;
private final Random random = new Random(1L); private final Random random = new Random(1L);
@ -40,80 +57,150 @@ final class SnowFlakes {
count = 0; count = 0;
} }
/** Фиксация seed — детерминизм для тестов. */ /** Фиксация seed: детерминизм для тестов. */
void reseed(long seed) { void reseed(long seed) {
random.setSeed(seed); random.setSeed(seed);
} }
/** Плавный разгон (0.45/с) и затухание (0.6/с) силы шторма к цели, без перелёта. */ /** Шаг к цели с постоянной скоростью, без перелёта. */
static float ramp(float current, float target, float dt) { static float approach(float current, float target, float perSecond, float dt) {
if (current < target) return Math.min(target, current + 0.45f * dt); float step = perSecond * dt;
return Math.max(target, current - 0.6f * 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) { static int targetCount(float radius, float density, float strength) {
if (radius <= 0f || density <= 0f || strength <= 0f) return 0; if (radius <= 0f || density <= 0f || strength <= 0f) return 0;
double raw = Math.PI * radius * radius * AREA_FACTOR * density * Math.min(1.0f, strength); double raw = Math.PI * radius * radius * AREA_FACTOR * density * strength;
if (raw >= MAX_FLAKES) return MAX_FLAKES; return (int) Math.min(MAX_FLAKES, raw);
return (int) 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; 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 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); return new Wind(Math.cos(angle) * speed, Math.sin(seconds * 2.3) * speed * 0.12, Math.sin(angle) * speed);
} }
/** Амплитуда боковой турбулентности, зависящей от мощности ветра. */ /** Амплитуда боковой турбулентности (блоков/с) по настройке 0..1. */
static float turbulence(float windPower) { static float turbulence(float setting) {
return 0.25f + 0.55f * Math.max(0f, windPower); return Math.max(0f, setting) * TURBULENCE_AMPLITUDE;
} }
void update(float dt, double camX, double camY, double camZ, float radius, void update(float dt, double camX, double camY, double camZ, float radius, Wind wind, float turbulence,
Wind wind, float turbulence, float speedMul, int target, float seconds) { int target, double seconds, Ground ground) {
if (dt <= 0f || target < 0) return; if (dt <= 0f || target < 0) return;
target = Math.min(target, MAX_FLAKES);
if (count > target) count = target; 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 recycle = radius * RECYCLE_MARGIN;
double recycleSq = recycle * recycle; double recycleSq = recycle * recycle;
for (int i = 0; i < count; i++) { for (int i = count - 1; i >= 0; i--) {
float wobbleX = (float) Math.sin(seconds * rate[i] + phase[i]) * turbulence * speedMul; 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 * speedMul; float wobbleZ = (float) Math.cos(seconds * rate[i] * 0.83f + phase[i] * 1.7f) * turbulence;
vx[i] = (float) wind.x() + wobbleX; float nvx = (float) wind.x() + wobbleX;
vy[i] = (float) wind.y() - FALL_SPEED * fall[i] * speedMul; float nvy = (float) wind.y() - FALL_SPEED * fall[i];
vz[i] = (float) wind.z() + wobbleZ; float nvz = (float) wind.z() + wobbleZ;
x[i] += vx[i] * dt; double nx = x[i] + nvx * dt;
y[i] += vy[i] * dt; double ny = y[i] + nvy * dt;
z[i] += vz[i] * dt; double nz = z[i] + nvz * dt;
age[i] += dt; int cx = (int) Math.floor(nx);
double dx = x[i] - camX; int cz = (int) Math.floor(nz);
double dz = z[i] - camZ; if (cx != columnX[i] || cz != columnZ[i]) {
if (dx * dx + dz * dz > recycleSq columnX[i] = cx;
|| y[i] < camY - SPAWN_BELOW columnZ[i] = cz;
|| y[i] > camY + SPAWN_ABOVE) { this.ground[i] = ground.height(cx, cz);
spawn(i, camX, camY, camZ, radius);
} }
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) { private void remove(int i) {
double angle = random.nextDouble() * Math.PI * 2.0; int last = --count;
double dist = radius * Math.sqrt(random.nextDouble()); if (i == last) return;
x[i] = camX + Math.cos(angle) * dist; x[i] = x[last];
z[i] = camZ + Math.sin(angle) * dist; y[i] = y[last];
y[i] = camY + SPAWN_ABOVE - random.nextDouble() * (SPAWN_ABOVE + SPAWN_BELOW); z[i] = z[last];
vx[i] = 0f; vx[i] = vx[last];
vy[i] = -FALL_SPEED; vy[i] = vy[last];
vz[i] = 0f; vz[i] = vz[last];
fall[i] = 0.6f + random.nextFloat() * 0.8f; fall[i] = fall[last];
size[i] = 0.75f + random.nextFloat() * 0.55f; size[i] = size[last];
alpha[i] = 0.35f + random.nextFloat() * 0.55f; alpha[i] = alpha[last];
phase[i] = random.nextFloat() * (float) (Math.PI * 2.0); phase[i] = phase[last];
rate[i] = 1.2f + random.nextFloat() * 2.2f; rate[i] = rate[last];
age[i] = 0f; 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;
} }
} }

View file

@ -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;
}
}

View file

@ -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;
}
}

View file

@ -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<FogData> cir) {
Blizzard blizzard = Modules.get(Blizzard.class);
if (blizzard != null) blizzard.applyFog(camera, cir.getReturnValue());
}
}

View file

@ -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();
}
}

View file

@ -3635,7 +3635,7 @@
"module.spatialgui.description": "Renders the interface as a floating panel in 3D space.", "module.spatialgui.description": "Renders the interface as a floating panel in 3D space.",
"module.trinket.description": "Attaches decorative trinkets to your character.", "module.trinket.description": "Attaches decorative trinkets to your character.",
"module.wet_world.description": "Makes surfaces look wet during rain.", "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.", "module.projectiletrails.description": "Draws fading trails behind arrows, pearls and other projectiles.",
"setting.projectiletrails.duration": "Duration (ms)", "setting.projectiletrails.duration": "Duration (ms)",
"setting.projectiletrails.width": "Width", "setting.projectiletrails.width": "Width",
@ -3686,7 +3686,23 @@
"setting.guipresence.show_others": "Show other players' menus", "setting.guipresence.show_others": "Show other players' menus",
"setting.guipresence.distance": "Distance", "setting.guipresence.distance": "Distance",
"setting.guipresence.scale": "Panel size", "setting.guipresence.scale": "Panel size",
"module.blizzard.description": "Wind-driven snowstorm with white haze and frosted screen edges.", "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.others": "Other players",
"setting.elytratrails.duration": "Duration (ms)", "setting.elytratrails.duration": "Duration (ms)",
"setting.elytratrails.width": "Width", "setting.elytratrails.width": "Width",
@ -3701,16 +3717,17 @@
"setting.elytratrails.color_phase": "Color phase", "setting.elytratrails.color_phase": "Color phase",
"setting.blizzard.intensity": "Intensity", "setting.blizzard.intensity": "Intensity",
"setting.blizzard.density": "Density", "setting.blizzard.density": "Density",
"setting.blizzard.speed": "Fall speed",
"setting.blizzard.wind": "Wind", "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.radius": "Radius",
"setting.blizzard.flake_size": "Flake size", "setting.blizzard.flake_size": "Flake size",
"setting.blizzard.opacity": "Flake opacity", "setting.blizzard.opacity": "Opacity",
"setting.blizzard.tint": "Tint", "setting.blizzard.tint": "Tint",
"setting.blizzard.haze": "Haze", "setting.blizzard.frost": "Frosted screen",
"setting.blizzard.frost": "Frosted edges", "setting.blizzard.only_when_snowing": "Only in snowfall",
"setting.blizzard.frost_intensity": "Frost intensity",
"setting.blizzard.only_when_snowing": "Only when snowing",
"setting.elytratrails.color_mode.option.Static": "Static", "setting.elytratrails.color_mode.option.Static": "Static",
"setting.elytratrails.color_mode.option.Rainbow": "Rainbow", "setting.elytratrails.color_mode.option.Rainbow": "Rainbow",
"setting.elytratrails.color_mode.option.LightRainbow": "Light Rainbow", "setting.elytratrails.color_mode.option.LightRainbow": "Light Rainbow",

View file

@ -3426,7 +3426,7 @@
"module.spatialgui.description": "Рисует интерфейс плавающей панелью в 3D-пространстве.", "module.spatialgui.description": "Рисует интерфейс плавающей панелью в 3D-пространстве.",
"module.trinket.description": "Крепит декоративные безделушки к вашему персонажу.", "module.trinket.description": "Крепит декоративные безделушки к вашему персонажу.",
"module.wet_world.description": "Делает поверхности мокрыми во время дождя.", "module.wet_world.description": "Делает поверхности мокрыми во время дождя.",
"module.elytratrails.description": "Тянет светящиеся шлейфы из концов крыльев элитры в полёте.", "module.elytratrails.description": "Светящиеся шлейфы с концов крыльев элитры при планировании.",
"module.projectiletrails.description": "Рисует затухающие ленты за стрелами, жемчугом и другими снарядами.", "module.projectiletrails.description": "Рисует затухающие ленты за стрелами, жемчугом и другими снарядами.",
"setting.projectiletrails.duration": "Длительность (мс)", "setting.projectiletrails.duration": "Длительность (мс)",
"setting.projectiletrails.width": "Ширина", "setting.projectiletrails.width": "Ширина",
@ -3477,7 +3477,23 @@
"setting.guipresence.show_others": "Показывать меню других игроков", "setting.guipresence.show_others": "Показывать меню других игроков",
"setting.guipresence.distance": "Дистанция", "setting.guipresence.distance": "Дистанция",
"setting.guipresence.scale": "Размер панели", "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.others": "Другие игроки",
"setting.elytratrails.duration": "Длительность (мс)", "setting.elytratrails.duration": "Длительность (мс)",
"setting.elytratrails.width": "Ширина", "setting.elytratrails.width": "Ширина",
@ -3492,16 +3508,17 @@
"setting.elytratrails.color_phase": "Фаза цвета", "setting.elytratrails.color_phase": "Фаза цвета",
"setting.blizzard.intensity": "Интенсивность", "setting.blizzard.intensity": "Интенсивность",
"setting.blizzard.density": "Плотность", "setting.blizzard.density": "Плотность",
"setting.blizzard.speed": "Скорость падения",
"setting.blizzard.wind": "Ветер", "setting.blizzard.wind": "Ветер",
"setting.blizzard.hide_vanilla": "Скрыть ванильную погоду",
"setting.blizzard.visibility": "Видимость",
"setting.blizzard.whiteout": "Белая пелена",
"setting.blizzard.turbulence": "Турбулентность",
"setting.blizzard.radius": "Радиус", "setting.blizzard.radius": "Радиус",
"setting.blizzard.flake_size": "Размер снежинок", "setting.blizzard.flake_size": "Размер снежинок",
"setting.blizzard.opacity": "Прозрачность снежинок", "setting.blizzard.opacity": "Прозрачность",
"setting.blizzard.tint": "Оттенок", "setting.blizzard.tint": "Оттенок",
"setting.blizzard.haze": "Дымка", "setting.blizzard.frost": "Обмерзание экрана",
"setting.blizzard.frost": "Обмерзание краёв", "setting.blizzard.only_when_snowing": "Только во время снегопада",
"setting.blizzard.frost_intensity": "Сила обмерзания",
"setting.blizzard.only_when_snowing": "Только во время снега",
"setting.elytratrails.color_mode.option.Static": "Статичный", "setting.elytratrails.color_mode.option.Static": "Статичный",
"setting.elytratrails.color_mode.option.Rainbow": "Радуга", "setting.elytratrails.color_mode.option.Rainbow": "Радуга",
"setting.elytratrails.color_mode.option.LightRainbow": "Светлая радуга", "setting.elytratrails.color_mode.option.LightRainbow": "Светлая радуга",

View file

@ -149,6 +149,7 @@
"render.entity.item.ItemEntityRendererMixin", "render.entity.item.ItemEntityRendererMixin",
"render.entity.LivingEntityRenderStateMixin", "render.entity.LivingEntityRenderStateMixin",
"render.fog.AtmosphericFogEnvironmentMixin", "render.fog.AtmosphericFogEnvironmentMixin",
"render.weather.BlizzardFogMixin",
"render.fog.FogRendererMixin", "render.fog.FogRendererMixin",
"render.fog.LavaFogModifierMixin", "render.fog.LavaFogModifierMixin",
"render.fog.WaterFogEnvironmentMixin", "render.fog.WaterFogEnvironmentMixin",
@ -170,6 +171,7 @@
"render.main.GameRendererViewEffectsMixin", "render.main.GameRendererViewEffectsMixin",
"render.main.MinecraftMixin", "render.main.MinecraftMixin",
"render.main.ScreenEffectRendererMixin", "render.main.ScreenEffectRendererMixin",
"render.weather.WeatherHideMixin",
"render.spatialgui.GuiRendererCaptureMixin", "render.spatialgui.GuiRendererCaptureMixin",
"render.spatialgui.ScreenSpatialBackgroundMixin", "render.spatialgui.ScreenSpatialBackgroundMixin",
"render.sound.LibraryMixin", "render.sound.LibraryMixin",

View file

@ -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));
}
}

View file

@ -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));
}
}
}

View file

@ -60,4 +60,35 @@ class WingTrailStoreTest {
s.clear(); s.clear();
assertTrue(s.snapshot().isEmpty()); 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());
}
} }

View file

@ -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);
}
}

View file

@ -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));
}
}

View file

@ -60,4 +60,50 @@ class TrackBufferTest {
prev = p.ms(); 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);
}
} }

View file

@ -62,4 +62,51 @@ class TrailRibbonTest {
void halfWidthScalesLinearlyWithWidthSetting() { void halfWidthScalesLinearlyWithWidthSetting() {
assertEquals(2.0 * TrailRibbon.halfWidth(0.3f, 0.6f), TrailRibbon.halfWidth(0.6f, 0.6f), 1e-6); 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);
}
} }

View file

@ -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);
}
}

View file

@ -4,71 +4,71 @@ import org.junit.jupiter.api.Test;
import static org.junit.jupiter.api.Assertions.*; import static org.junit.jupiter.api.Assertions.*;
/** Спецификация видимости снежинки: не лезет в глаза у камеры, плавно появляется и тает на границе зоны. */ /** Спецификация вида снежинки (как в evo): не лезет в глаза у линзы, проявляется со временем, тянется вдоль скорости. */
class BlizzardRendererTest { class BlizzardRendererTest {
private static final float R = 14f; 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);
private static float alpha(float dist, float age, float base, float opacity, float strength) {
return BlizzardRenderer.flakeAlpha(dist, age, base, R, opacity, strength);
} }
@Test @Test
void flakesRightInFrontOfTheLensAreInvisible() { void flakesRightInFrontOfTheLensAreInvisible() {
assertEquals(0f, alpha(0.1f, 5f, 0.8f, 1f, 1f), 0f); assertEquals(0f, alpha(0.1f, 5f, 0.8f, 1f, 1f, 1f), 0f);
assertEquals(0f, alpha(0.35f, 5f, 0.8f, 1f, 1f), 0f); assertEquals(0f, alpha(0.25f, 5f, 0.8f, 1f, 1f, 1f), 0f);
} }
@Test @Test
void flakesFadeInWithDistanceFromTheCamera() { void flakesFadeInWithDistanceUntilAboutAMeter() {
assertTrue(alpha(0.6f, 5f, 0.8f, 1f, 1f) > alpha(0.45f, 5f, 0.8f, 1f, 1f)); assertTrue(alpha(0.6f, 5f, 0.8f, 1f, 1f, 1f) > alpha(0.4f, 5f, 0.8f, 1f, 1f, 1f));
assertTrue(alpha(2f, 5f, 0.8f, 1f, 1f) >= alpha(0.6f, 5f, 0.8f, 1f, 1f)); assertEquals(alpha(1.5f, 5f, 0.8f, 1f, 1f, 1f), alpha(30f, 5f, 0.8f, 1f, 1f, 1f), 1e-6f);
}
@Test
void flakesMeltAwayAtTheEdgeOfTheStormZone() {
assertTrue(alpha(R * 0.4f, 5f, 0.8f, 1f, 1f) > alpha(R * 0.9f, 5f, 0.8f, 1f, 1f));
assertEquals(0f, alpha(R * 1.2f, 5f, 0.8f, 1f, 1f), 0f);
} }
@Test @Test
void newlySpawnedFlakesAppearGraduallyInsteadOfPopping() { void newlySpawnedFlakesAppearGraduallyInsteadOfPopping() {
assertEquals(0f, alpha(5f, 0f, 0.8f, 1f, 1f), 0f); assertEquals(0f, alpha(5f, 0f, 0.8f, 1f, 1f, 1f), 0f);
assertTrue(alpha(5f, 0.2f, 0.8f, 1f, 1f) < alpha(5f, 1f, 0.8f, 1f, 1f)); 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 @Test
void opacityAndStormStrengthScaleVisibilityLinearly() { void opacityAndShareOfStormScaleVisibilityLinearly() {
float full = alpha(5f, 5f, 0.5f, 0.6f, 0.6f); 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) / full, 1e-5); 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) / 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), 0f); assertEquals(0f, alpha(5f, 5f, 0.5f, 0f, 1f, 1f), 0f);
assertEquals(0f, alpha(5f, 5f, 0.5f, 1f, 0f), 0f); assertEquals(0f, alpha(5f, 5f, 0.5f, 1f, 0f, 1f), 0f);
} }
@Test @Test
void aFlakeIsNeverFullyOpaque() { void aFlakeNeverBecomesMoreOpaqueThanItsOwnAlphaTimesOpacity() {
assertTrue(alpha(5f, 5f, 1f, 1f, 1f) <= 0.95f); assertTrue(alpha(5f, 5f, 0.7f, 0.8f, 1f, 0.5f) <= 0.7f * 0.8f + 1e-6f);
} }
@Test @Test
void faintFlakesAreSkippedInsteadOfDrawnInvisibly() { void flakesBehindTheCameraAreSkippedExceptCloseOnes() {
assertEquals(0f, alpha(5f, 5f, 0.35f, 0.01f, 0.2f), 0f); 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 @Test
void distantFlakesStayAtLeastAPixelWide() { void distantFlakesStayAtLeastAPixelWideAndSizeSettingScalesNearOnes() {
assertEquals(0.045f, BlizzardRenderer.halfWidth(1f, 1f, 5f), 1e-6f); assertEquals(0.028f, BlizzardRenderer.radius(1f, 1f, 5f), 1e-6f);
assertTrue(BlizzardRenderer.halfWidth(1f, 1f, 200f) >= 200f * 0.0016f - 1e-6f); assertEquals(200f * 0.0024f, BlizzardRenderer.radius(1f, 1f, 200f), 1e-5f);
assertTrue(BlizzardRenderer.halfWidth(2f, 1f, 5f) > BlizzardRenderer.halfWidth(1f, 1f, 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 @Test
void fasterFlakesLeaveLongerStreaksUpToALimit() { void fasterFlakesLeaveLongerTrailsWhichAreLongerBehindThanInFront() {
float slow = BlizzardRenderer.halfLength(0.05f, 2f, 1f); float r = 0.03f;
float fast = BlizzardRenderer.halfLength(0.05f, 10f, 1f); float slow = BlizzardRenderer.streakLength(r, 2f, 1f);
float fast = BlizzardRenderer.streakLength(r, 20f, 1f);
assertTrue(fast > slow); assertTrue(fast > slow);
assertEquals(0.05f + 0.35f, BlizzardRenderer.halfLength(0.05f, 1000f, 1f), 1e-6f); assertEquals(r, BlizzardRenderer.streakLength(r, 0f, 1f), 1e-7f);
assertEquals(0.05f, BlizzardRenderer.halfLength(0.05f, 0f, 1f), 1e-6f); float front = BlizzardRenderer.frontLength(r, fast);
assertTrue(front < fast && front > r);
assertEquals(r, BlizzardRenderer.frontLength(r, r), 1e-7f);
} }
} }

View file

@ -4,10 +4,20 @@ import org.junit.jupiter.api.Test;
import static org.junit.jupiter.api.Assertions.*; import static org.junit.jupiter.api.Assertions.*;
/** Спецификация метели: сколько снежинок, как они падают, куда их сносит ветер. */ /**
* Спецификация метели (поведение evo): сколько снежинок, где они живут, как падают, куда их сносит ветер.
* Снег идёт только над землёй и под открытым небом, внутри помещения его нет.
*/
class SnowFlakesTest { class SnowFlakesTest {
private static final SnowFlakes.Wind CALM = SnowFlakes.Wind.ZERO; 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) { private static double meanY(SnowFlakes f) {
double sum = 0; double sum = 0;
@ -26,8 +36,8 @@ class SnowFlakesTest {
@Test @Test
void noStormMeansNoFlakes() { void noStormMeansNoFlakes() {
assertEquals(0, SnowFlakes.targetCount(0f, 1f, 1f)); assertEquals(0, SnowFlakes.targetCount(0f, 1f, 1f));
assertEquals(0, SnowFlakes.targetCount(14f, 0f, 1f)); assertEquals(0, SnowFlakes.targetCount(12f, 0f, 1f));
assertEquals(0, SnowFlakes.targetCount(14f, 1f, 0f)); assertEquals(0, SnowFlakes.targetCount(12f, 1f, 0f));
assertEquals(0, SnowFlakes.targetCount(-3f, 1f, 1f)); assertEquals(0, SnowFlakes.targetCount(-3f, 1f, 1f));
} }
@ -39,42 +49,73 @@ class SnowFlakesTest {
} }
@Test @Test
void halfTheIntensityGivesAboutHalfTheFlakes() { void halfTheStrengthGivesAboutHalfTheFlakes() {
double full = SnowFlakes.targetCount(10f, 1f, 1.0f); double full = SnowFlakes.targetCount(10f, 1f, 1.0f);
double half = SnowFlakes.targetCount(10f, 1f, 0.5f); double half = SnowFlakes.targetCount(10f, 1f, 0.5f);
assertEquals(0.5, half / full, 0.02); assertEquals(0.5, half / full, 0.02);
} }
@Test @Test
void flakeCountIsCappedAndStrengthAboveOneChangesNothing() { void flakeCountIsCapped() {
assertEquals(SnowFlakes.MAX_FLAKES, SnowFlakes.targetCount(32f, 3f, 1f)); assertEquals(SnowFlakes.MAX_FLAKES, SnowFlakes.targetCount(24f, 3f, 1f));
assertEquals(SnowFlakes.targetCount(10f, 1f, 1f), SnowFlakes.targetCount(10f, 1f, 7f)); assertEquals(12000, SnowFlakes.MAX_FLAKES);
} }
@Test @Test
void activeFlakeCountFollowsTheTargetUpAndDown() { void activeFlakeCountFollowsTheTargetUpAndDown() {
SnowFlakes f = new SnowFlakes(); 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); 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); 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); assertEquals(0, f.count);
} }
// ---- где они живут // ---- где они живут: над землёй, не в помещении
@Test @Test
void freshFlakesSpawnInsideTheCylinderAroundTheCamera() { void freshFlakesSpawnInsideTheCylinderBetweenGroundAndCeiling() {
SnowFlakes f = new SnowFlakes(); 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++) { for (int i = 0; i < f.count; i++) {
double dx = f.x[i] - 100, dz = f.z[i] + 50; double dx = f.x[i] - 100, dz = f.z[i] + 50;
assertTrue(Math.sqrt(dx * dx + dz * dz) <= 12.0 + 1e-6); 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 @Test
void flakesFollowAMovingCameraAndNeverLeaveTheStormVolume() { void flakesFollowAMovingCameraAndNeverLeaveTheStormVolume() {
SnowFlakes f = new SnowFlakes(); SnowFlakes f = new SnowFlakes();
@ -82,13 +123,23 @@ class SnowFlakesTest {
for (int step = 0; step < 600; step++) { for (int step = 0; step < 600; step++) {
cx += 0.8; cx += 0.8;
cz -= 0.3; 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; double limit = 14.0 * SnowFlakes.RECYCLE_MARGIN;
for (int i = 0; i < f.count; i++) { for (int i = 0; i < f.count; i++) {
double dx = f.x[i] - cx, dz = f.z[i] - cz; 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(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 @Test
void snowFallsDownwardInCalmAir() { void snowFallsDownwardInCalmAir() {
SnowFlakes f = new SnowFlakes(); 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); 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); assertTrue(meanY(f) < before);
for (int i = 0; i < f.count; i++) assertTrue(f.vy[i] < 0f); 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 @Test
void windPushesSnowDownwind() { void windPushesSnowDownwind() {
SnowFlakes calm = new SnowFlakes(); SnowFlakes calm = new SnowFlakes();
SnowFlakes windy = new SnowFlakes(); SnowFlakes windy = new SnowFlakes();
calm.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);
windy.update(0.0001f, 0, 64, 0, 14f, CALM, 0f, 1f, 300, 0f); tick(windy, 0.0001f, 0, 70, 0, 14f, CALM, 0f, 300, 0, FLAT);
double c0 = meanX(calm), w0 = meanX(windy); double c0 = meanX(calm), w0 = meanX(windy);
calm.update(0.2f, 0, 64, 0, 14f, CALM, 0f, 1f, 300, 0.2f); tick(calm, 0.2f, 0, 70, 0, 14f, CALM, 0f, 300, 0.2, FLAT);
windy.update(0.2f, 0, 64, 0, 14f, new SnowFlakes.Wind(12, 0, 0), 0f, 1f, 300, 0.2f); 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); assertTrue(meanX(windy) - w0 > meanX(calm) - c0 + 1.0);
} }
@ -134,8 +173,8 @@ class SnowFlakesTest {
SnowFlakes b = new SnowFlakes(); SnowFlakes b = new SnowFlakes();
a.reseed(42L); a.reseed(42L);
b.reseed(42L); b.reseed(42L);
a.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);
b.update(0.05f, 1, 2, 3, 14f, CALM, 0.3f, 1f, 100, 1f); tick(b, 0.05f, 1, 70, 3, 14f, CALM, 0.3f, 100, 1, FLAT);
for (int i = 0; i < 100; i++) { for (int i = 0; i < 100; i++) {
assertEquals(a.x[i], b.x[i], 0.0); assertEquals(a.x[i], b.x[i], 0.0);
assertEquals(a.y[i], b.y[i], 0.0); assertEquals(a.y[i], b.y[i], 0.0);
@ -145,18 +184,30 @@ class SnowFlakesTest {
@Test @Test
void zeroTimeStepChangesNothing() { void zeroTimeStepChangesNothing() {
SnowFlakes f = new SnowFlakes(); 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]; 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); 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 @Test
void noWindWithoutPowerOrStorm() { void noWindWithoutPowerOrStorm() {
assertEquals(SnowFlakes.Wind.ZERO, SnowFlakes.wind(3f, 0f, 1f)); assertEquals(SnowFlakes.Wind.ZERO, SnowFlakes.wind(3, 0f, 1f));
assertEquals(SnowFlakes.Wind.ZERO, SnowFlakes.wind(3f, 1f, 0f)); assertEquals(SnowFlakes.Wind.ZERO, SnowFlakes.wind(3, 1f, 0f));
} }
private static double horizontal(SnowFlakes.Wind w) { private static double horizontal(SnowFlakes.Wind w) {
@ -164,39 +215,44 @@ class SnowFlakesTest {
} }
@Test @Test
void windGrowsWithPowerAndStormStrength() { void windGrowsLinearlyWithPowerAndStormStrength() {
double base = horizontal(SnowFlakes.wind(5f, 1.0f, 0.5f)); double base = horizontal(SnowFlakes.wind(5, 0.5f, 0.5f));
assertTrue(horizontal(SnowFlakes.wind(5f, 2.0f, 0.5f)) > base); assertEquals(2.0, horizontal(SnowFlakes.wind(5, 1.0f, 0.5f)) / base, 1e-9);
assertTrue(horizontal(SnowFlakes.wind(5f, 1.0f, 1.0f)) > base); assertEquals(2.0, horizontal(SnowFlakes.wind(5, 0.5f, 1.0f)) / base, 1e-9);
assertEquals(2.0, horizontal(SnowFlakes.wind(5f, 2.0f, 0.5f)) / base, 1e-9);
} }
@Test @Test
void gustsStayBoundedAndMostlyHorizontal() { void gustsStayBoundedAndMostlyHorizontal() {
double cap = 1.0 * 14.0 * 1.5 * 1.0; double cap = SnowFlakes.WIND_SPEED_MAX * 1.5;
for (int i = 0; i < 400; i++) { double min = Double.MAX_VALUE;
SnowFlakes.Wind w = SnowFlakes.wind(i * 0.37f, 1.0f, 1.0f); 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(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 @Test
void windDirectionDriftsOverTime() { void windDirectionTurnsSlowlyOverTime() {
SnowFlakes.Wind early = SnowFlakes.wind(0f, 1f, 1f); SnowFlakes.Wind a = SnowFlakes.wind(100, 1f, 1f);
SnowFlakes.Wind late = SnowFlakes.wind(30f, 1f, 1f); SnowFlakes.Wind b = SnowFlakes.wind(100.5, 1f, 1f);
double angleDelta = Math.abs(Math.atan2(late.z(), late.x()) - Math.atan2(early.z(), early.x())); double step = Math.abs(Math.atan2(b.z(), b.x()) - Math.atan2(a.z(), a.x()));
assertTrue(angleDelta > 0.5); 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 @Test
void strongerWindMeansMoreTurbulence() { void turbulenceScalesWithTheSetting() {
assertTrue(SnowFlakes.turbulence(2f) > SnowFlakes.turbulence(0.5f)); assertEquals(0f, SnowFlakes.turbulence(0f), 0f);
assertTrue(SnowFlakes.turbulence(0f) > 0f); assertEquals(0f, SnowFlakes.turbulence(-1f), 0f);
assertEquals(SnowFlakes.turbulence(0f), SnowFlakes.turbulence(-1f), 0f); assertEquals(2.0, SnowFlakes.turbulence(1f) / SnowFlakes.turbulence(0.5f), 1e-6);
} }
// ---- плавный разгон шторма // ---- сила шторма и экспозиция
@Test @Test
void stormRampsUpToTheIntensitySettingNotToFull() { void stormRampsUpToTheIntensitySettingNotToFull() {
@ -210,9 +266,32 @@ class SnowFlakesTest {
float up = SnowFlakes.ramp(0f, 1f, 1f); float up = SnowFlakes.ramp(0f, 1f, 1f);
float down = 1f - SnowFlakes.ramp(1f, 0f, 1f); float down = 1f - SnowFlakes.ramp(1f, 0f, 1f);
assertTrue(up > 0f && up < 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(0f, SnowFlakes.ramp(0.01f, 0f, 5f), 0f);
assertEquals(0.3f, SnowFlakes.ramp(0.3f, 0.3f, 1f), 0f); assertEquals(0.3f, SnowFlakes.ramp(0.3f, 0.3f, 1f), 0f);
assertEquals(0.5f, SnowFlakes.ramp(0.5f, 1f, 0f), 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);
}
} }

View file

@ -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);
}
}

View file

@ -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);
}
}