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

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.ModuleInfo;
import dev.loki.lovisual.features.module.phase.WorldPhase;
import dev.loki.lovisual.features.module.modules.visuals.effects.elytratrails.pose.ElytraPoses;
import dev.loki.lovisual.features.module.modules.visuals.effects.elytratrails.pose.WingTipLocator;
import dev.loki.lovisual.features.module.modules.visuals.effects.ribbon.RibbonShape;
import dev.loki.lovisual.features.module.modules.visuals.effects.ribbon.TrackBuffer;
import dev.loki.lovisual.features.module.modules.visuals.effects.ribbon.TrailRibbon;
import dev.loki.lovisual.render.engine.animation.AnimatedRenderColors;
import dev.loki.lovisual.render.engine.renderer.Renderer3D;
import dev.loki.lovisual.util.time.GameClock;
import net.minecraft.client.Minecraft;
import net.minecraft.client.player.AbstractClientPlayer;
import net.minecraft.world.entity.player.Player;
import net.minecraft.world.phys.Vec3;
@ -30,12 +34,27 @@ public class ElytraTrails extends Module {
private final WingTrailStore store = new WingTrailStore();
private static final double MAX_TRACK_DISTANCE_SQ = 16384.0;
private final BooleanValue own = bool("elytraTrailsOwn", "own", true);
private final BooleanValue others = bool("elytraTrailsOthers", "others", true);
private final NumberValue<Integer> duration = num("elytraTrailsDuration", "duration", 700, 150, 3000);
private final NumberValue<Float> width = num("elytraTrailsWidth", "width", 0.35f, 0.05f, 1.5f);
private final NumberValue<Integer> duration = num("elytraTrailsDuration", "duration", 1200, 200, 4000);
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 softEdges = bool("elytraTrailsSoftEdges", "soft_edges", true);
private final BooleanValue fill = bool("elytraTrailsFill", "fill", true);
private final BooleanValue lines = bool("elytraTrailsLines", "lines", false);
private final NumberValue<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 ModeValue colorMode = modeSetting("elytraTrailsColorMode", "color_mode", "DoubleColor",
"Static", "Rainbow", "LightRainbow", "Sky", "Fade", "DoubleColor", "Analogous", "Theme");
@ -60,13 +79,16 @@ public class ElytraTrails extends Module {
if (!isEnabled() || mc.level == null || mc.gameRenderer.mainCamera() == null) return;
long now = GameClock.millis();
long life = duration.get();
Vec3 cam = mc.gameRenderer.mainCamera().position();
for (Player player : mc.level.players()) {
if (!others.get() && player != mc.player) continue;
if (!player.isAlive() || !player.isFallFlying()) continue;
WingTipLocator.Tips tips = WingTipLocator.tips(player.getEyePosition(tickDelta),
player.getViewYRot(tickDelta), player.getViewXRot(tickDelta));
store.sample(player.getUUID(), WingTrailStore.LEFT, tips.left().x, tips.left().y, tips.left().z, now, life);
store.sample(player.getUUID(), WingTrailStore.RIGHT, tips.right().x, tips.right().y, tips.right().z, now, life);
if (!(player instanceof AbstractClientPlayer wearer)) continue;
if (!wearer.isFallFlying() || wearer.isInvisible() || !wearer.isAlive()) continue;
if (!(wearer == mc.player ? own.get() : others.get())) continue;
if (wearer.distanceToSqr(cam) > MAX_TRACK_DISTANCE_SQ) continue;
WingTipLocator.Tips tips = WingTipLocator.tips(ElytraPoses.of(wearer, tickDelta));
float speed = ElytraPoses.speed(wearer);
store.sample(wearer.getUUID(), WingTrailStore.LEFT, tips.left().x, tips.left().y, tips.left().z, now, life, speed);
store.sample(wearer.getUUID(), WingTrailStore.RIGHT, tips.right().x, tips.right().y, tips.right().z, now, life, speed);
}
store.prune(now, life);
List<TrackBuffer> tracks = store.snapshot();
@ -77,10 +99,13 @@ public class ElytraTrails extends Module {
int secondary = color2.getArgb();
int speed = colorSpeed.get();
AnimatedRenderColors.Mode mode = AnimatedRenderColors.modeOf(colorMode.get());
Vec3 cam = mc.gameRenderer.mainCamera().position();
TrailRibbon.render(renderer, tracks, now, life, width.get(), true, glow.get(), lines.get(), lineWidth.get(),
ElytraRibbonStyle style = new ElytraRibbonStyle(RibbonShape.Taper.of(taper.get()), taperAmount.get(),
RibbonShape.Motion.of(motion.get()), waveSize.get(), waveSpeed.get(), speedReactive.get(),
fadeStart.get(), softEdges.get(), glow.get());
TrailRibbon.render(renderer, tracks, now, life, width.get(), fill.get(), false, lines.get(), lineWidth.get(),
depthTest.get(), cam,
(i, n) -> AnimatedRenderColors.resolve(mode, speed,
phase + Math.round(i / (float) Math.max(1, n - 1) * 180), primary, secondary, true));
phase + Math.round(i / (float) Math.max(1, n - 1) * 180), primary, secondary, true),
style);
}
}

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();
}
/** Минимальный шаг записи (блоки), скачок и пауза, после которых лента начинается заново (как у evo). */
static final double MIN_STEP = 0.35;
static final double MAX_STEP = 8.0;
static final long GAP_MS = 300L;
void sample(UUID id, int side, double x, double y, double z, long now, long lifeMs) {
sample(id, side, x, y, z, now, lifeMs, 0.0f);
}
void sample(UUID id, int side, double x, double y, double z, long now, long lifeMs, float speed) {
TrackBuffer[] pair = tracks.computeIfAbsent(id, ignored -> new TrackBuffer[2]);
if (pair[side] == null) pair[side] = new TrackBuffer();
pair[side].add(x, y, z, now, lifeMs);
pair[side].addContinuous(x, y, z, now, lifeMs, speed, MIN_STEP, MAX_STEP, GAP_MS);
}
/** Убирает устаревшие точки; опустевшие ленты и игроки без лент удаляются. */

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;
/** Точка следа: позиция в мире и время (мс) появления. */
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;
}
/** Больше точек лента не держит: самые старые вытесняются. */
public static final int MAX_POINTS = 256;
/**
* Как {@link #add}, но для непрерывной ленты (концы крыльев): сдвиг меньше {@code minStep} не записывается,
* скачок дальше {@code maxStep} или пауза дольше {@code gapMs} (телепорт, перерыв в полёте) начинают ленту заново,
* у каждой точки сохраняется скорость источника.
*/
public boolean addContinuous(double x, double y, double z, long now, long lifeMs, float speed,
double minStep, double maxStep, long gapMs) {
RibbonPoint last = points.peekLast();
if (last != null && (now - lastSeen > gapMs || distance(last, x, y, z) > maxStep)) {
points.clear();
last = null;
}
lastSeen = now;
boolean added = false;
if (last == null || distance(last, x, y, z) >= minStep) {
points.addLast(new RibbonPoint(x, y, z, now, speed));
if (points.size() > MAX_POINTS) points.pollFirst();
added = true;
}
trim(now, lifeMs);
return added;
}
public void trim(long now, long lifeMs) {
while (!points.isEmpty() && now - points.peekFirst().ms() > lifeMs) points.pollFirst();
}

View file

@ -17,6 +17,57 @@ import java.util.function.IntBinaryOperator;
*/
public final class TrailRibbon {
/** Как рисуется лента: ширина и прозрачность по точке, ориентация, мягкие края, ореол и колебания. */
public interface Style {
/** true: ширина лежит горизонтально (как лента-флаг); false: лента повёрнута к камере. */
boolean horizontal();
/** Края ленты тают в прозрачность вместо плоской полосы. */
boolean softEdges();
/** Масштаб ширины отдельного ореола вокруг ленты; 0 без ореола. */
float glowScale();
/** Полуширина в точке {@code i} из {@code n}. */
double halfWidth(float width, int i, int n, RibbonPoint p, float life);
/** Множитель альфы цвета в точке. */
float alpha(int i, int n, RibbonPoint p, float life, float cameraDistance);
/** Смещение точки: {в сторону, по бинормали}; null без колебаний. */
default float[] motion(RibbonPoint p) {
return null;
}
}
/** Прежний вид (ProjectileTrails): лента к камере, сужение к хвосту, альфа как life в квадрате. */
public static final Style LEGACY = new Style() {
@Override
public boolean horizontal() {
return false;
}
@Override
public boolean softEdges() {
return false;
}
@Override
public float glowScale() {
return 0.0f;
}
@Override
public double halfWidth(float width, int i, int n, RibbonPoint p, float life) {
return TrailRibbon.halfWidth(width, life);
}
@Override
public float alpha(int i, int n, RibbonPoint p, float life, float cameraDistance) {
return life * life;
}
};
private TrailRibbon() {
}
@ -40,18 +91,32 @@ public final class TrailRibbon {
return width * 0.5 * (0.25 + 0.75 * l);
}
/** Лента прежнего вида. */
public static void render(Renderer3D renderer, Collection<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
*/
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) {
IntBinaryOperator color, Style style) {
var depthMode = depth ? Renderer3D.DepthMode.PRE_DEPTH : Renderer3D.DepthMode.MAIN;
MeshBuilder fillMesh = null;
if (fill) {
var pipeline = glow
? (depth ? LoVisualRenderPipelines.WORLD_COLORED_ADDITIVE_DEPTH : LoVisualRenderPipelines.WORLD_COLORED_ADDITIVE)
: (depth ? LoVisualRenderPipelines.WORLD_COLORED_LIQUID_IGNORE : LoVisualRenderPipelines.WORLD_COLORED);
fillMesh = renderer.batch(pipeline, depth ? Renderer3D.DepthMode.PRE_DEPTH : Renderer3D.DepthMode.MAIN);
fillMesh = renderer.batch(pipeline, depthMode);
}
MeshBuilder glowMesh = null;
if (fill && style.glowScale() > 0.0f) {
glowMesh = renderer.batch(depth ? LoVisualRenderPipelines.WORLD_COLORED_ADDITIVE_DEPTH
: LoVisualRenderPipelines.WORLD_COLORED_ADDITIVE, depthMode);
}
MeshBuilder lineMesh = null;
if (lines) {
@ -59,41 +124,73 @@ public final class TrailRibbon {
try {
RenderState.lineWidth = Math.max(0.5f, lineWidth);
lineMesh = renderer.batch(depth ? LoVisualRenderPipelines.WORLD_COLORED_LINES_LIQUID_IGNORE
: LoVisualRenderPipelines.WORLD_COLORED_LINES, depth ? Renderer3D.DepthMode.PRE_DEPTH : Renderer3D.DepthMode.MAIN);
: LoVisualRenderPipelines.WORLD_COLORED_LINES, depthMode);
} finally {
RenderState.lineWidth = prev;
}
}
if (fillMesh == null && lineMesh == null) return;
if (fillMesh == null && lineMesh == null && glowMesh == null) return;
for (TrackBuffer track : tracks) {
List<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,
MeshBuilder line, Vec3 cam, IntBinaryOperator color) {
MeshBuilder glowMesh, MeshBuilder line, Vec3 cam, IntBinaryOperator color, Style style) {
int n = pts.size();
double[][] center = new double[n][3];
for (int i = 0; i < n; i++) {
RibbonPoint p = pts.get(i);
center[i] = new double[]{p.x(), p.y(), p.z()};
}
double[][] side = frame(center, cam, style.horizontal());
if (style.motion(pts.get(0)) != null) {
double[][] tangent = tangents(center);
for (int i = 0; i < n; i++) {
float[] m = style.motion(pts.get(i));
if (m == null) continue;
double bx = side[i][1] * tangent[i][2] - side[i][2] * tangent[i][1];
double by = side[i][2] * tangent[i][0] - side[i][0] * tangent[i][2];
double bz = side[i][0] * tangent[i][1] - side[i][1] * tangent[i][0];
center[i][0] += side[i][0] * m[0] + bx * m[1];
center[i][1] += side[i][1] * m[0] + by * m[1];
center[i][2] += side[i][2] * m[0] + bz * m[1];
}
side = frame(center, cam, style.horizontal());
}
double[][] left = new double[n][3];
double[][] right = new double[n][3];
int[] argb = new int[n];
double[] half = new double[n];
for (int i = 0; i < n; i++) {
RibbonPoint p = pts.get(i);
RibbonPoint a = pts.get(Math.max(0, i - 1));
RibbonPoint b = pts.get(Math.min(n - 1, i + 1));
double dx = b.x() - a.x(), dy = b.y() - a.y(), dz = b.z() - a.z();
double vx = cam.x - p.x(), vy = cam.y - p.y(), vz = cam.z - p.z();
double sx = dy * vz - dz * vy, sy = dz * vx - dx * vz, sz = dx * vy - dy * vx;
double len = Math.sqrt(sx * sx + sy * sy + sz * sz);
if (len < 1e-6) len = 1.0;
float life = lifeFraction(now, p.ms(), lifeMs);
double half = halfWidth(width, life) / len;
left[i] = new double[]{p.x() + sx * half, p.y() + sy * half, p.z() + sz * half};
right[i] = new double[]{p.x() - sx * half, p.y() - sy * half, p.z() - sz * half};
argb[i] = fadeAlpha(color.applyAsInt(i, n), life);
float life = style == LEGACY ? lifeFraction(now, p.ms(), lifeMs) : RibbonShape.life(now, p.ms(), lifeMs);
half[i] = style.halfWidth(width, i, n, p, life);
double dx = center[i][0] - cam.x, dy = center[i][1] - cam.y, dz = center[i][2] - cam.z;
float mult = style.alpha(i, n, p, life, (float) Math.sqrt(dx * dx + dy * dy + dz * dz));
argb[i] = scaleAlpha(color.applyAsInt(i, n), mult);
for (int k = 0; k < 3; k++) {
left[i][k] = center[i][k] + side[i][k] * half[i];
right[i][k] = center[i][k] - side[i][k] * half[i];
}
}
boolean soft = style.softEdges();
for (int i = 1; i < n; i++) {
if (fill != null) quad(fill, left[i - 1], right[i - 1], right[i], left[i], argb[i - 1], argb[i]);
if (fill != null) {
if (soft) softQuad(fill, left[i - 1], center[i - 1], right[i - 1], left[i], center[i], right[i],
argb[i - 1], argb[i]);
else quad(fill, left[i - 1], right[i - 1], right[i], left[i], argb[i - 1], argb[i]);
}
if (glowMesh != null) {
float g = style.glowScale();
double[] l0 = offset(center[i - 1], side[i - 1], half[i - 1] * g);
double[] r0 = offset(center[i - 1], side[i - 1], -half[i - 1] * g);
double[] l1 = offset(center[i], side[i], half[i] * g);
double[] r1 = offset(center[i], side[i], -half[i] * g);
softQuad(glowMesh, l0, center[i - 1], r0, l1, center[i], r1,
scaleAlpha(argb[i - 1], GLOW_ALPHA), scaleAlpha(argb[i], GLOW_ALPHA));
}
if (line != null) {
segment(line, left[i - 1], left[i], argb[i - 1], argb[i]);
segment(line, right[i - 1], right[i], argb[i - 1], argb[i]);
@ -101,15 +198,90 @@ public final class TrailRibbon {
}
}
/** Относительная яркость ореола. */
static final float GLOW_ALPHA = 0.35f;
private static double[] offset(double[] c, double[] side, double d) {
return new double[]{c[0] + side[0] * d, c[1] + side[1] * d, c[2] + side[2] * d};
}
/** Единичные касательные по центральным разностям соседей. */
private static double[][] tangents(double[][] c) {
int n = c.length;
double[][] out = new double[n][3];
for (int i = 0; i < n; i++) {
double[] a = c[Math.max(0, i - 1)], b = c[Math.min(n - 1, i + 1)];
double dx = b[0] - a[0], dy = b[1] - a[1], dz = b[2] - a[2];
double len = Math.sqrt(dx * dx + dy * dy + dz * dz);
if (len < 1e-6) {
out[i] = new double[]{0.0, 0.0, 1.0};
} else {
out[i] = new double[]{dx / len, dy / len, dz / len};
}
}
return out;
}
/**
* Единичный вектор «в сторону» для каждой точки: к камере (касательная x взгляд) или горизонтальный
* ({@code (-tz, 0, tx)}), с запасным вариантом для вертикального пути.
*/
static double[][] frame(double[][] c, Vec3 cam, boolean horizontal) {
int n = c.length;
double[][] t = tangents(c);
double[][] out = new double[n][3];
for (int i = 0; i < n; i++) {
double sx, sy, sz;
if (horizontal) {
sx = -t[i][2];
sy = 0.0;
sz = t[i][0];
if (sx * sx + sz * sz < 1.0E-7) {
sx = 1.0;
sz = 0.0;
}
} else {
double vx = cam.x - c[i][0], vy = cam.y - c[i][1], vz = cam.z - c[i][2];
sx = t[i][1] * vz - t[i][2] * vy;
sy = t[i][2] * vx - t[i][0] * vz;
sz = t[i][0] * vy - t[i][1] * vx;
}
double len = Math.sqrt(sx * sx + sy * sy + sz * sz);
if (len < 1e-6) len = 1.0;
out[i] = new double[]{sx / len, sy / len, sz / len};
}
return out;
}
/** Альфа цвета, умноженная на множитель 0..1; RGB не меняется. */
static int scaleAlpha(int argb, float mult) {
float m = mult < 0.0f ? 0.0f : Math.min(mult, 1.0f);
int alpha = Math.round(((argb >>> 24) & 0xFF) * m);
return (alpha << 24) | (argb & 0x00FFFFFF);
}
private static void quad(MeshBuilder m, double[] l0, double[] r0, double[] r1, double[] l1, int c0, int c1) {
quad4(m, l0, r0, r1, l1, c0, c0, c1, c1);
}
private static void quad4(MeshBuilder m, double[] p0, double[] p1, double[] p2, double[] p3,
int c0, int c1, int c2, int c3) {
m.ensureQuadCapacity();
int a = m.vec3(l0[0], l0[1], l0[2]).color(new RenderColor(c0)).next();
int b = m.vec3(r0[0], r0[1], r0[2]).color(new RenderColor(c0)).next();
int c = m.vec3(r1[0], r1[1], r1[2]).color(new RenderColor(c1)).next();
int d = m.vec3(l1[0], l1[1], l1[2]).color(new RenderColor(c1)).next();
int a = m.vec3(p0[0], p0[1], p0[2]).color(new RenderColor(c0)).next();
int b = m.vec3(p1[0], p1[1], p1[2]).color(new RenderColor(c1)).next();
int c = m.vec3(p2[0], p2[1], p2[2]).color(new RenderColor(c2)).next();
int d = m.vec3(p3[0], p3[1], p3[2]).color(new RenderColor(c3)).next();
m.quad(a, b, c, d);
}
/** Две полосы: от прозрачного левого края к непрозрачной оси и от оси к прозрачному правому краю. */
private static void softQuad(MeshBuilder m, double[] l0, double[] k0, double[] r0,
double[] l1, double[] k1, double[] r1, int c0, int c1) {
int e0 = scaleAlpha(c0, 0.0f), e1 = scaleAlpha(c1, 0.0f);
quad4(m, l0, k0, k1, l1, e0, c0, c1, e1);
quad4(m, k0, r0, r1, k1, c0, e0, e1, c1);
}
private static void segment(MeshBuilder m, double[] p0, double[] p1, int c0, int c1) {
m.ensureLineCapacity();
int a = m.vec3(p0[0], p0[1], p0[2]).color(new RenderColor(c0)).next();

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.ModuleInfo;
import dev.loki.lovisual.features.module.phase.HudPhase;
import dev.loki.lovisual.features.module.modules.visuals.weather.blizzard.view.BlizzardFog;
import dev.loki.lovisual.features.module.modules.visuals.weather.blizzard.view.BlizzardOverlay;
import dev.loki.lovisual.features.module.phase.WorldPhase;
import dev.loki.lovisual.render.engine.RenderState;
import dev.loki.lovisual.render.engine.pipeline.LoVisualRenderPipelines;
@ -16,10 +18,14 @@ import dev.loki.lovisual.render.engine.text.render.TextRenderer;
import dev.loki.lovisual.render.engine.uniform.MeshBuilder;
import net.minecraft.client.Camera;
import net.minecraft.client.Minecraft;
import net.minecraft.client.multiplayer.ClientLevel;
import net.minecraft.client.gui.GuiGraphicsExtractor;
import net.minecraft.client.renderer.fog.FogData;
import net.minecraft.core.BlockPos;
import net.minecraft.util.Mth;
import net.minecraft.world.level.biome.Biome;
import net.minecraft.world.level.levelgen.Heightmap;
import net.minecraft.world.level.material.FogType;
import net.minecraft.world.phys.Vec3;
/**
@ -31,28 +37,30 @@ import net.minecraft.world.phys.Vec3;
id = "blizzard",
displayName = "Blizzard",
category = ModuleCategory.VISUALS,
description = "Wind-driven snowstorm with white haze and frosted screen edges"
description = "High-altitude snowstorm with wind-driven flakes, whiteout fog and frosted edges"
)
public final class Blizzard extends Module {
private final NumberValue<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> speed = num("blizzardSpeed", "speed", 1.0f, 0.3f, 3.0f);
private final NumberValue<Float> wind = num("blizzardWind", "wind", 0.7f, 0.0f, 2.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.3f, 2.5f);
private final NumberValue<Float> opacity = num("blizzardOpacity", "opacity", 0.7f, 0.05f, 1.0f);
private final RGBAColorValue tint = color("blizzardTint", "tint", "#F2FFFFFF");
private final NumberValue<Float> haze = num("blizzardHaze", "haze", 0.5f, 0.0f, 1.0f);
private final NumberValue<Float> wind = num("blizzardWind", "wind", 0.7f, 0.0f, 1.0f);
private final NumberValue<Float> turbulence = num("blizzardTurbulence", "turbulence", 0.6f, 0.0f, 1.0f);
private final NumberValue<Float> flakeSize = num("blizzardFlakeSize", "flake_size", 1.0f, 0.5f, 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.8f, 0.1f, 1.0f);
private final BooleanValue whiteout = bool("blizzardWhiteout", "whiteout", true);
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 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 hideVanilla = bool("blizzardHideVanilla", "hide_vanilla", true);
private final RGBAColorValue tint = color("blizzardTint", "tint", "#FFFFFFFF");
private final SnowFlakes flakes = new SnowFlakes();
private long lastFrameNanos;
private float seconds;
private float strength;
private float exposure;
private boolean frameActive;
@Override
public HudPhase getHudPhase() {
@ -66,68 +74,100 @@ public final class Blizzard extends Module {
@Override
public void onEnable() {
lastFrameNanos = 0L;
seconds = 0.0f;
strength = 0.0f;
flakes.clear();
reset();
}
@Override
public void onDisable() {
reset();
}
private void reset() {
lastFrameNanos = 0L;
strength = 0.0f;
exposure = 0.0f;
frameActive = false;
flakes.clear();
}
/** Ванильные полосы дождя и снега скрыты, пока идёт шторм и включена настройка. */
public boolean hidesVanillaWeather() {
return isEnabled() && frameActive && hideVanilla.get();
}
/** Штормовая пелена: сжимает туман и красит его в цвет снега (вызывается из миксина тумана). */
public void applyFog(Camera camera, FogData fog) {
if (!isEnabled() || !whiteout.get() || camera == null || fog == null) return;
FogType type = camera.getFluidInCamera();
if (type != FogType.NONE && type != FogType.ATMOSPHERIC) return;
float f = strength * exposure;
if (!BlizzardFog.active(f)) return;
float end = BlizzardFog.endDistance(visibility.get(), f);
float start = BlizzardFog.startDistance(visibility.get(), f);
fog.environmentalStart = BlizzardFog.limit(fog.environmentalStart, start);
fog.environmentalEnd = BlizzardFog.limit(fog.environmentalEnd, end);
fog.renderDistanceStart = BlizzardFog.limit(fog.renderDistanceStart, start);
fog.renderDistanceEnd = BlizzardFog.limit(fog.renderDistanceEnd, end);
fog.skyEnd = BlizzardFog.limit(fog.skyEnd, end * BlizzardFog.SKY_SHARE);
fog.cloudEnd = BlizzardFog.limit(fog.cloudEnd, end);
int argb = tint.getArgb();
float k = BlizzardFog.brightness(fog.color.x, fog.color.y, fog.color.z);
fog.color.x = BlizzardFog.blend(fog.color.x, ((argb >> 16) & 0xFF) / 255.0f, k, f);
fog.color.y = BlizzardFog.blend(fog.color.y, ((argb >> 8) & 0xFF) / 255.0f, k, f);
fog.color.z = BlizzardFog.blend(fog.color.z, (argb & 0xFF) / 255.0f, k, f);
}
private float frameDeltaSeconds() {
long now = System.nanoTime();
if (lastFrameNanos == 0L) lastFrameNanos = now;
float dt = (float) ((now - lastFrameNanos) / 1_000_000_000.0);
lastFrameNanos = now;
return Mth.clamp(dt, 0.0f, 0.05f);
return Mth.clamp(dt, 0.0f, 0.1f);
}
private float targetStrength() {
Minecraft mc = Minecraft.getInstance();
if (mc.player == null || mc.level == null) return 0.0f;
if (!onlyWhenSnowing.get()) return intensity.get();
if (mc.level.getRainLevel(1.0f) < 0.02f) return 0.0f;
private float targetStrength(Minecraft mc) {
float rain = mc.level.getRainLevel(1.0f);
BlockPos pos = mc.player.blockPosition();
Biome.Precipitation precipitation =
mc.level.getBiome(pos).value().getPrecipitationAt(pos, mc.level.getSeaLevel());
return precipitation == Biome.Precipitation.SNOW ? intensity.get() : 0.0f;
boolean snowBiome = mc.level.getBiome(pos).value()
.getPrecipitationAt(pos, mc.level.getSeaLevel()) == Biome.Precipitation.SNOW;
return SnowFlakes.targetStrength(intensity.get(), onlyWhenSnowing.get(), rain, snowBiome);
}
@Override
public void onRenderWorldEngine(Renderer3D renderer, Renderer3D depthRenderer, float tickDelta) {
Minecraft mc = Minecraft.getInstance();
if (!isEnabled() || mc.player == null || mc.level == null || mc.gameRenderer == null) return;
if (mc.gameRenderer.mainCamera() == null) return;
float dt = frameDeltaSeconds();
seconds += dt;
strength = SnowFlakes.ramp(strength, targetStrength(), dt);
float norm = strength;
if (norm <= 0.001f) {
flakes.clear();
if (!isEnabled() || mc.player == null || mc.level == null || mc.gameRenderer == null
|| mc.gameRenderer.mainCamera() == null) {
frameActive = false;
return;
}
Camera camera = mc.gameRenderer.mainCamera();
Vec3 cam = camera.position();
SnowFlakes.Wind windValue = SnowFlakes.wind(seconds, wind.get(), norm);
int target = SnowFlakes.targetCount(radius.get(), density.get(), norm);
flakes.update(dt, cam.x, cam.y, cam.z, radius.get(), windValue,
SnowFlakes.turbulence(wind.get()), speed.get(), target, seconds);
float dt = frameDeltaSeconds();
Vec3 cam = mc.gameRenderer.mainCamera().position();
ClientLevel level = mc.level;
SnowFlakes.Ground ground = (x, z) -> level.getHeight(Heightmap.Types.MOTION_BLOCKING, x, z);
strength = SnowFlakes.ramp(strength, targetStrength(mc), dt);
float open = SnowFlakes.exposureTarget(cam.y, ground.height(Mth.floor(cam.x), Mth.floor(cam.z)));
exposure = SnowFlakes.approach(exposure, open, SnowFlakes.EXPOSURE_PER_SECOND, dt);
frameActive = strength > 0.01f;
if (!frameActive && flakes.count == 0) return;
double seconds = (System.currentTimeMillis() % 600_000L) / 1000.0;
SnowFlakes.Wind windValue = SnowFlakes.wind(seconds, wind.get(), strength);
int target = frameActive ? SnowFlakes.targetCount(radius.get(), density.get(), strength) : 0;
flakes.update(dt, cam.x, cam.y, cam.z, radius.get(), windValue, SnowFlakes.turbulence(turbulence.get()),
target, seconds, ground);
if (flakes.count == 0) return;
MeshBuilder mesh = renderer.batch(LoVisualRenderPipelines.WORLD_COLORED_DEPTH, Renderer3D.DepthMode.MAIN);
if (mesh == null) return;
BlizzardRenderer.render(mesh, flakes, RenderState.cameraRotation,
cam.x, cam.y, cam.z, radius.get(), tint.getArgb(), norm, flakeSize.get(), opacity.get());
BlizzardRenderer.render(mesh, flakes, RenderState.cameraRotation, cam.x, cam.y, cam.z,
tint.getArgb(), strength, intensity.get(), flakeSize.get(), opacity.get());
}
@Override
public void onRenderHudEngine(Renderer2D renderer, TextRenderer textRenderer,
GuiGraphicsExtractor ctx, float tickDelta) {
if (!isEnabled()) return;
float norm = strength;
if (norm <= 0.01f) return;
BlizzardOverlay.render(ctx, norm, haze.get(), frost.get(), frostIntensity.get(), tint.getArgb());
float current = strength * exposure * intensity.get();
BlizzardOverlay.render(ctx, current, frost.get(), tint.getArgb());
}
}

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

View file

@ -3,19 +3,33 @@ package dev.loki.lovisual.features.module.modules.visuals.weather.blizzard;
import java.util.Random;
/**
* Чистая физика снежинок: спавн в цилиндре вокруг камеры, ветер с порывами,
* турбулентность и рецикл при выходе из зоны. Без зависимостей от Minecraft.
* Чистая физика снежинок по образцу evo: спавн в цилиндре вокруг камеры между землёй (карта высот) и потолком
* над головой, ветер с порывами, турбулентность, рецикл при выходе из зоны или касании земли.
* Под крышей земля выше потолка спавна, поэтому снега внутри помещений нет. Без зависимостей от Minecraft.
*/
final class SnowFlakes {
static final int MAX_FLAKES = 4000;
static final int MAX_FLAKES = 12000;
static final float FALL_SPEED = 1.6f;
static final float AREA_FACTOR = 3.0f;
static final double SPAWN_ABOVE = 8.0;
static final double SPAWN_BELOW = 6.0;
static final float AREA_FACTOR = 9.0f;
static final double SPAWN_CEILING = 8.0;
static final double SPAWN_FLOOR_MARGIN = 6.0;
static final double RECYCLE_MARGIN = 1.25;
static final double RECYCLE_HEIGHT = 16.0;
static final int SPAWN_ATTEMPTS = 3;
static final float WIND_SPEED_MAX = 16.0f;
static final float TURBULENCE_AMPLITUDE = 1.1f;
static final float RISE_PER_SECOND = 0.45f;
static final float FALL_PER_SECOND = 0.6f;
static final float EXPOSURE_PER_SECOND = 1.4f;
/** Горизонтальный ветер с лёгким вертикальным сносом. */
/** Высота верхнего блока, который останавливает снег, в колонке (x, z) (карта MOTION_BLOCKING). */
@FunctionalInterface
interface Ground {
float height(int x, int z);
}
/** Ветер: горизонтальная скорость и лёгкий вертикальный снос. */
record Wind(double x, double y, double z) {
static final Wind ZERO = new Wind(0.0, 0.0, 0.0);
}
@ -32,6 +46,9 @@ final class SnowFlakes {
final float[] phase = new float[MAX_FLAKES];
final float[] rate = new float[MAX_FLAKES];
final float[] age = new float[MAX_FLAKES];
private final float[] ground = new float[MAX_FLAKES];
private final int[] columnX = new int[MAX_FLAKES];
private final int[] columnZ = new int[MAX_FLAKES];
int count;
private final Random random = new Random(1L);
@ -40,80 +57,150 @@ final class SnowFlakes {
count = 0;
}
/** Фиксация seed — детерминизм для тестов. */
/** Фиксация seed: детерминизм для тестов. */
void reseed(long seed) {
random.setSeed(seed);
}
/** Плавный разгон (0.45/с) и затухание (0.6/с) силы шторма к цели, без перелёта. */
static float ramp(float current, float target, float dt) {
if (current < target) return Math.min(target, current + 0.45f * dt);
return Math.max(target, current - 0.6f * dt);
/** Шаг к цели с постоянной скоростью, без перелёта. */
static float approach(float current, float target, float perSecond, float dt) {
float step = perSecond * dt;
if (Math.abs(target - current) <= step) return target;
return current + (target > current ? step : -step);
}
/** Целевое число частиц: площадь цилиндра * плотность * доля силы шторма. */
/** Сила шторма: разгон 0.45/с, затухание 0.6/с. */
static float ramp(float current, float target, float dt) {
return approach(current, target, target > current ? RISE_PER_SECOND : FALL_PER_SECOND, dt);
}
/** Цель экспозиции: 1 на открытом небе (камера не ниже верхнего блока колонки), 0 под крышей. */
static float exposureTarget(double camY, float groundHeight) {
return camY < groundHeight ? 0.0f : 1.0f;
}
/**
* Целевая сила шторма. Без «только в снегопад» это просто интенсивность; иначе шторм идёт, пока в биоме
* игрока снег и идёт осадок, и растёт с его силой.
*/
static float targetStrength(float intensity, boolean onlyInSnowfall, float rainLevel, boolean snowBiome) {
if (!onlyInSnowfall) return intensity;
if (rainLevel <= 0.02f || !snowBiome) return 0.0f;
return Math.max(0.0f, Math.min(1.0f, rainLevel)) * intensity;
}
/** Целевое число частиц: площадь цилиндра * плотность * сила шторма, не больше {@link #MAX_FLAKES}. */
static int targetCount(float radius, float density, float strength) {
if (radius <= 0f || density <= 0f || strength <= 0f) return 0;
double raw = Math.PI * radius * radius * AREA_FACTOR * density * Math.min(1.0f, strength);
if (raw >= MAX_FLAKES) return MAX_FLAKES;
return (int) raw;
double raw = Math.PI * radius * radius * AREA_FACTOR * density * strength;
return (int) Math.min(MAX_FLAKES, raw);
}
/** Ветер с порывами: направление медленно кружит, скорость дышит несколькими синусами. */
static Wind wind(float seconds, float windPower, float strength) {
/** Ветер с порывами: направление медленно поворачивает, скорость дышит двумя синусами. */
static Wind wind(double seconds, float windPower, float strength) {
if (strength <= 0f || windPower <= 0f) return Wind.ZERO;
double angle = seconds * 0.17 + Math.sin(seconds * 0.45) * 0.35;
double angle = seconds * 0.02 + Math.sin(seconds * 0.9 * 0.5) * 0.35;
double gust = 1.0 + Math.sin(seconds * 0.9) * 0.35 + Math.sin(seconds * 2.3) * 0.15;
double speed = windPower * 14.0 * gust * strength;
double speed = windPower * WIND_SPEED_MAX * gust * strength;
return new Wind(Math.cos(angle) * speed, Math.sin(seconds * 2.3) * speed * 0.12, Math.sin(angle) * speed);
}
/** Амплитуда боковой турбулентности, зависящей от мощности ветра. */
static float turbulence(float windPower) {
return 0.25f + 0.55f * Math.max(0f, windPower);
/** Амплитуда боковой турбулентности (блоков/с) по настройке 0..1. */
static float turbulence(float setting) {
return Math.max(0f, setting) * TURBULENCE_AMPLITUDE;
}
void update(float dt, double camX, double camY, double camZ, float radius,
Wind wind, float turbulence, float speedMul, int target, float seconds) {
void update(float dt, double camX, double camY, double camZ, float radius, Wind wind, float turbulence,
int target, double seconds, Ground ground) {
if (dt <= 0f || target < 0) return;
target = Math.min(target, MAX_FLAKES);
if (count > target) count = target;
while (count < target) spawn(count++, camX, camY, camZ, radius);
while (count < target && respawn(count, camX, camY, camZ, radius, wind, ground)) count++;
double recycle = radius * RECYCLE_MARGIN;
double recycleSq = recycle * recycle;
for (int i = 0; i < count; i++) {
float wobbleX = (float) Math.sin(seconds * rate[i] + phase[i]) * turbulence * speedMul;
float wobbleZ = (float) Math.cos(seconds * rate[i] * 0.83f + phase[i] * 1.7f) * turbulence * speedMul;
vx[i] = (float) wind.x() + wobbleX;
vy[i] = (float) wind.y() - FALL_SPEED * fall[i] * speedMul;
vz[i] = (float) wind.z() + wobbleZ;
x[i] += vx[i] * dt;
y[i] += vy[i] * dt;
z[i] += vz[i] * dt;
age[i] += dt;
double dx = x[i] - camX;
double dz = z[i] - camZ;
if (dx * dx + dz * dz > recycleSq
|| y[i] < camY - SPAWN_BELOW
|| y[i] > camY + SPAWN_ABOVE) {
spawn(i, camX, camY, camZ, radius);
for (int i = count - 1; i >= 0; i--) {
float wobbleX = (float) Math.sin(seconds * rate[i] + phase[i]) * turbulence;
float wobbleZ = (float) Math.cos(seconds * rate[i] * 0.83f + phase[i] * 1.7f) * turbulence;
float nvx = (float) wind.x() + wobbleX;
float nvy = (float) wind.y() - FALL_SPEED * fall[i];
float nvz = (float) wind.z() + wobbleZ;
double nx = x[i] + nvx * dt;
double ny = y[i] + nvy * dt;
double nz = z[i] + nvz * dt;
int cx = (int) Math.floor(nx);
int cz = (int) Math.floor(nz);
if (cx != columnX[i] || cz != columnZ[i]) {
columnX[i] = cx;
columnZ[i] = cz;
this.ground[i] = ground.height(cx, cz);
}
double dx = nx - camX;
double dz = nz - camZ;
boolean lost = ny <= this.ground[i] || dx * dx + dz * dz > recycleSq || ny > camY + RECYCLE_HEIGHT;
if (lost) {
if (!respawn(i, camX, camY, camZ, radius, wind, ground)) remove(i);
continue;
}
x[i] = nx;
y[i] = ny;
z[i] = nz;
vx[i] = nvx;
vy[i] = nvy;
vz[i] = nvz;
age[i] += dt;
}
}
private void spawn(int i, double camX, double camY, double camZ, float radius) {
double angle = random.nextDouble() * Math.PI * 2.0;
double dist = radius * Math.sqrt(random.nextDouble());
x[i] = camX + Math.cos(angle) * dist;
z[i] = camZ + Math.sin(angle) * dist;
y[i] = camY + SPAWN_ABOVE - random.nextDouble() * (SPAWN_ABOVE + SPAWN_BELOW);
vx[i] = 0f;
vy[i] = -FALL_SPEED;
vz[i] = 0f;
fall[i] = 0.6f + random.nextFloat() * 0.8f;
size[i] = 0.75f + random.nextFloat() * 0.55f;
alpha[i] = 0.35f + random.nextFloat() * 0.55f;
phase[i] = random.nextFloat() * (float) (Math.PI * 2.0);
rate[i] = 1.2f + random.nextFloat() * 2.2f;
age[i] = 0f;
private void remove(int i) {
int last = --count;
if (i == last) return;
x[i] = x[last];
y[i] = y[last];
z[i] = z[last];
vx[i] = vx[last];
vy[i] = vy[last];
vz[i] = vz[last];
fall[i] = fall[last];
size[i] = size[last];
alpha[i] = alpha[last];
phase[i] = phase[last];
rate[i] = rate[last];
age[i] = age[last];
ground[i] = ground[last];
columnX[i] = columnX[last];
columnZ[i] = columnZ[last];
}
/** Ставит снежинку в случайное место цилиндра между землёй и потолком; false, если над колонкой крыша. */
private boolean respawn(int i, double camX, double camY, double camZ, float radius, Wind wind, Ground world) {
for (int attempt = 0; attempt < SPAWN_ATTEMPTS; attempt++) {
double angle = random.nextDouble() * Math.PI * 2.0;
double dist = radius * Math.sqrt(random.nextDouble());
double px = camX + Math.cos(angle) * dist;
double pz = camZ + Math.sin(angle) * dist;
int cx = (int) Math.floor(px);
int cz = (int) Math.floor(pz);
float height = world.height(cx, cz);
double ceiling = camY + SPAWN_CEILING;
double floor = Math.max(height, camY - SPAWN_FLOOR_MARGIN);
if (ceiling <= floor) continue;
x[i] = px;
y[i] = floor + random.nextDouble() * (ceiling - floor);
z[i] = pz;
fall[i] = 0.6f + random.nextFloat() * 0.9f;
size[i] = 0.55f + random.nextFloat() * 0.9f;
alpha[i] = 0.45f + random.nextFloat() * 0.55f;
phase[i] = random.nextFloat() * (float) (Math.PI * 2.0);
rate[i] = 1.2f + random.nextFloat() * 2.2f;
age[i] = 0f;
vx[i] = (float) wind.x();
vy[i] = -FALL_SPEED;
vz[i] = (float) wind.z();
ground[i] = height;
columnX[i] = cx;
columnZ[i] = cz;
return true;
}
return false;
}
}

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.trinket.description": "Attaches decorative trinkets to your character.",
"module.wet_world.description": "Makes surfaces look wet during rain.",
"module.elytratrails.description": "Streams glowing trails from the elytra wing tips while gliding.",
"module.elytratrails.description": "Draws glowing contrails from the elytra wingtips while gliding.",
"module.projectiletrails.description": "Draws fading trails behind arrows, pearls and other projectiles.",
"setting.projectiletrails.duration": "Duration (ms)",
"setting.projectiletrails.width": "Width",
@ -3686,7 +3686,23 @@
"setting.guipresence.show_others": "Show other players' menus",
"setting.guipresence.distance": "Distance",
"setting.guipresence.scale": "Panel size",
"module.blizzard.description": "Wind-driven snowstorm with white haze and frosted screen edges.",
"module.blizzard.description": "High-altitude snowstorm with wind-driven flakes, whiteout fog and frosted edges.",
"setting.elytratrails.own": "Own trail",
"setting.elytratrails.taper": "Taper",
"setting.elytratrails.taper_amount": "Taper amount",
"setting.elytratrails.motion": "Motion",
"setting.elytratrails.wave_size": "Wave size",
"setting.elytratrails.wave_speed": "Wave speed",
"setting.elytratrails.fade_start": "Fade start",
"setting.elytratrails.speed_reactive": "Speed reactive",
"setting.elytratrails.soft_edges": "Soft edges",
"setting.elytratrails.fill": "Fill",
"setting.elytratrails.taper.option.Uniform": "Uniform",
"setting.elytratrails.taper.option.Grow": "Grow",
"setting.elytratrails.taper.option.Shrink": "Shrink",
"setting.elytratrails.motion.option.Straight": "Straight",
"setting.elytratrails.motion.option.Wave": "Wave",
"setting.elytratrails.motion.option.Helix": "Helix",
"setting.elytratrails.others": "Other players",
"setting.elytratrails.duration": "Duration (ms)",
"setting.elytratrails.width": "Width",
@ -3701,16 +3717,17 @@
"setting.elytratrails.color_phase": "Color phase",
"setting.blizzard.intensity": "Intensity",
"setting.blizzard.density": "Density",
"setting.blizzard.speed": "Fall speed",
"setting.blizzard.wind": "Wind",
"setting.blizzard.hide_vanilla": "Hide vanilla weather",
"setting.blizzard.visibility": "Visibility",
"setting.blizzard.whiteout": "Whiteout",
"setting.blizzard.turbulence": "Turbulence",
"setting.blizzard.radius": "Radius",
"setting.blizzard.flake_size": "Flake size",
"setting.blizzard.opacity": "Flake opacity",
"setting.blizzard.opacity": "Opacity",
"setting.blizzard.tint": "Tint",
"setting.blizzard.haze": "Haze",
"setting.blizzard.frost": "Frosted edges",
"setting.blizzard.frost_intensity": "Frost intensity",
"setting.blizzard.only_when_snowing": "Only when snowing",
"setting.blizzard.frost": "Frosted screen",
"setting.blizzard.only_when_snowing": "Only in snowfall",
"setting.elytratrails.color_mode.option.Static": "Static",
"setting.elytratrails.color_mode.option.Rainbow": "Rainbow",
"setting.elytratrails.color_mode.option.LightRainbow": "Light Rainbow",

View file

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

View file

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

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

View file

@ -4,10 +4,20 @@ import org.junit.jupiter.api.Test;
import static org.junit.jupiter.api.Assertions.*;
/** Спецификация метели: сколько снежинок, как они падают, куда их сносит ветер. */
/**
* Спецификация метели (поведение evo): сколько снежинок, где они живут, как падают, куда их сносит ветер.
* Снег идёт только над землёй и под открытым небом, внутри помещения его нет.
*/
class SnowFlakesTest {
private static final SnowFlakes.Wind CALM = SnowFlakes.Wind.ZERO;
private static final SnowFlakes.Ground FLAT = (x, z) -> 60.0f;
private static final SnowFlakes.Ground ROOF = (x, z) -> 100.0f;
private static void tick(SnowFlakes f, float dt, double cx, double cy, double cz, float radius,
SnowFlakes.Wind wind, float turb, int target, double seconds, SnowFlakes.Ground ground) {
f.update(dt, cx, cy, cz, radius, wind, turb, target, seconds, ground);
}
private static double meanY(SnowFlakes f) {
double sum = 0;
@ -26,8 +36,8 @@ class SnowFlakesTest {
@Test
void noStormMeansNoFlakes() {
assertEquals(0, SnowFlakes.targetCount(0f, 1f, 1f));
assertEquals(0, SnowFlakes.targetCount(14f, 0f, 1f));
assertEquals(0, SnowFlakes.targetCount(14f, 1f, 0f));
assertEquals(0, SnowFlakes.targetCount(12f, 0f, 1f));
assertEquals(0, SnowFlakes.targetCount(12f, 1f, 0f));
assertEquals(0, SnowFlakes.targetCount(-3f, 1f, 1f));
}
@ -39,42 +49,73 @@ class SnowFlakesTest {
}
@Test
void halfTheIntensityGivesAboutHalfTheFlakes() {
void halfTheStrengthGivesAboutHalfTheFlakes() {
double full = SnowFlakes.targetCount(10f, 1f, 1.0f);
double half = SnowFlakes.targetCount(10f, 1f, 0.5f);
assertEquals(0.5, half / full, 0.02);
}
@Test
void flakeCountIsCappedAndStrengthAboveOneChangesNothing() {
assertEquals(SnowFlakes.MAX_FLAKES, SnowFlakes.targetCount(32f, 3f, 1f));
assertEquals(SnowFlakes.targetCount(10f, 1f, 1f), SnowFlakes.targetCount(10f, 1f, 7f));
void flakeCountIsCapped() {
assertEquals(SnowFlakes.MAX_FLAKES, SnowFlakes.targetCount(24f, 3f, 1f));
assertEquals(12000, SnowFlakes.MAX_FLAKES);
}
@Test
void activeFlakeCountFollowsTheTargetUpAndDown() {
SnowFlakes f = new SnowFlakes();
f.update(0.016f, 0, 64, 0, 14f, CALM, 0f, 1f, 500, 0f);
tick(f, 0.016f, 0, 64, 0, 14f, CALM, 0f, 500, 0, FLAT);
assertEquals(500, f.count);
f.update(0.016f, 0, 64, 0, 14f, CALM, 0f, 1f, 120, 0f);
tick(f, 0.016f, 0, 64, 0, 14f, CALM, 0f, 120, 0, FLAT);
assertEquals(120, f.count);
f.update(0.016f, 0, 64, 0, 14f, CALM, 0f, 1f, 0, 0f);
tick(f, 0.016f, 0, 64, 0, 14f, CALM, 0f, 0, 0, FLAT);
assertEquals(0, f.count);
}
// ---- где они живут
// ---- где они живут: над землёй, не в помещении
@Test
void freshFlakesSpawnInsideTheCylinderAroundTheCamera() {
void freshFlakesSpawnInsideTheCylinderBetweenGroundAndCeiling() {
SnowFlakes f = new SnowFlakes();
f.update(0.0001f, 100, 70, -50, 12f, CALM, 0f, 1f, 800, 0f);
tick(f, 0.0001f, 100, 70, -50, 12f, CALM, 0f, 800, 0, FLAT);
for (int i = 0; i < f.count; i++) {
double dx = f.x[i] - 100, dz = f.z[i] + 50;
assertTrue(Math.sqrt(dx * dx + dz * dz) <= 12.0 + 1e-6);
assertTrue(f.y[i] >= 70 - SnowFlakes.SPAWN_BELOW - 1e-6 && f.y[i] <= 70 + SnowFlakes.SPAWN_ABOVE + 1e-6);
assertTrue(f.y[i] > 64.0 - 1e-6, "not below camera - 6 (above the ground 60)");
assertTrue(f.y[i] <= 70 + SnowFlakes.SPAWN_CEILING + 1e-6);
}
}
@Test
void noSnowFallsIndoorsUnderARoof() {
SnowFlakes f = new SnowFlakes();
tick(f, 0.016f, 0, 64, 0, 14f, CALM, 0f, 1000, 0, ROOF);
assertEquals(0, f.count);
}
@Test
void snowStopsAtTheGroundAndNeverSinksBelowIt() {
SnowFlakes f = new SnowFlakes();
double cx = 0;
for (int step = 0; step < 800; step++) {
tick(f, 0.02f, cx, 62, 0, 14f, CALM, 0.4f, 400, step * 0.02, FLAT);
for (int i = 0; i < f.count; i++) assertTrue(f.y[i] > 60.0, "flake sank into the ground");
}
assertTrue(f.count > 0);
}
@Test
void flakesOverARoofedColumnDisappearButOpenSkyKeepsSnowing() {
// The left half (x < 0) is open sky, the right half is a roof.
SnowFlakes.Ground half = (x, z) -> x < 0 ? 60.0f : 100.0f;
SnowFlakes f = new SnowFlakes();
for (int step = 0; step < 600; step++) {
tick(f, 0.02f, 0, 64, 0, 14f, CALM, 0.3f, 600, step * 0.02, half);
}
assertTrue(f.count > 0);
for (int i = 0; i < f.count; i++) assertTrue(f.x[i] < 1.0, "snow appeared under the roof");
}
@Test
void flakesFollowAMovingCameraAndNeverLeaveTheStormVolume() {
SnowFlakes f = new SnowFlakes();
@ -82,13 +123,23 @@ class SnowFlakesTest {
for (int step = 0; step < 600; step++) {
cx += 0.8;
cz -= 0.3;
f.update(0.02f, cx, 64, cz, 14f, SnowFlakes.wind(step * 0.02f, 1.0f, 1.0f), 0.5f, 1f, 600, step * 0.02f);
tick(f, 0.02f, cx, 64, cz, 14f, SnowFlakes.wind(step * 0.02, 1.0f, 1.0f), 0.5f, 600, step * 0.02, FLAT);
}
double limit = 14.0 * SnowFlakes.RECYCLE_MARGIN;
for (int i = 0; i < f.count; i++) {
double dx = f.x[i] - cx, dz = f.z[i] - cz;
assertTrue(dx * dx + dz * dz <= limit * limit + 1e-6, "flake outside the cylinder");
assertTrue(f.y[i] >= 64 - SnowFlakes.SPAWN_BELOW - 1e-6 && f.y[i] <= 64 + SnowFlakes.SPAWN_ABOVE + 1e-6);
assertTrue(f.y[i] <= 64 + SnowFlakes.RECYCLE_HEIGHT + 1e-6);
}
}
@Test
void teleportingTheCameraBringsTheSnowAlong() {
SnowFlakes f = new SnowFlakes();
tick(f, 0.02f, 0, 64, 0, 14f, CALM, 0f, 300, 0, FLAT);
tick(f, 0.02f, 5000, 64, 5000, 14f, CALM, 0f, 300, 0, FLAT);
for (int i = 0; i < f.count; i++) {
assertTrue(Math.abs(f.x[i] - 5000) < 20 && Math.abs(f.z[i] - 5000) < 20);
}
}
@ -97,34 +148,22 @@ class SnowFlakesTest {
@Test
void snowFallsDownwardInCalmAir() {
SnowFlakes f = new SnowFlakes();
f.update(0.0001f, 0, 64, 0, 14f, CALM, 0f, 1f, 200, 0f);
tick(f, 0.0001f, 0, 70, 0, 14f, CALM, 0f, 200, 0, FLAT);
double before = meanY(f);
f.update(0.1f, 0, 64, 0, 14f, CALM, 0f, 1f, 200, 0.1f);
tick(f, 0.1f, 0, 70, 0, 14f, CALM, 0f, 200, 0.1, FLAT);
assertTrue(meanY(f) < before);
for (int i = 0; i < f.count; i++) assertTrue(f.vy[i] < 0f);
}
@Test
void speedSettingMakesSnowFallFaster() {
SnowFlakes slow = new SnowFlakes();
SnowFlakes fast = new SnowFlakes();
slow.update(0.0001f, 0, 64, 0, 14f, CALM, 0f, 0.5f, 200, 0f);
fast.update(0.0001f, 0, 64, 0, 14f, CALM, 0f, 2.0f, 200, 0f);
double slowBefore = meanY(slow), fastBefore = meanY(fast);
slow.update(0.1f, 0, 64, 0, 14f, CALM, 0f, 0.5f, 200, 0.1f);
fast.update(0.1f, 0, 64, 0, 14f, CALM, 0f, 2.0f, 200, 0.1f);
assertTrue(fastBefore - meanY(fast) > slowBefore - meanY(slow));
}
@Test
void windPushesSnowDownwind() {
SnowFlakes calm = new SnowFlakes();
SnowFlakes windy = new SnowFlakes();
calm.update(0.0001f, 0, 64, 0, 14f, CALM, 0f, 1f, 300, 0f);
windy.update(0.0001f, 0, 64, 0, 14f, CALM, 0f, 1f, 300, 0f);
tick(calm, 0.0001f, 0, 70, 0, 14f, CALM, 0f, 300, 0, FLAT);
tick(windy, 0.0001f, 0, 70, 0, 14f, CALM, 0f, 300, 0, FLAT);
double c0 = meanX(calm), w0 = meanX(windy);
calm.update(0.2f, 0, 64, 0, 14f, CALM, 0f, 1f, 300, 0.2f);
windy.update(0.2f, 0, 64, 0, 14f, new SnowFlakes.Wind(12, 0, 0), 0f, 1f, 300, 0.2f);
tick(calm, 0.2f, 0, 70, 0, 14f, CALM, 0f, 300, 0.2, FLAT);
tick(windy, 0.2f, 0, 70, 0, 14f, new SnowFlakes.Wind(12, 0, 0), 0f, 300, 0.2, FLAT);
assertTrue(meanX(windy) - w0 > meanX(calm) - c0 + 1.0);
}
@ -134,8 +173,8 @@ class SnowFlakesTest {
SnowFlakes b = new SnowFlakes();
a.reseed(42L);
b.reseed(42L);
a.update(0.05f, 1, 2, 3, 14f, CALM, 0.3f, 1f, 100, 1f);
b.update(0.05f, 1, 2, 3, 14f, CALM, 0.3f, 1f, 100, 1f);
tick(a, 0.05f, 1, 70, 3, 14f, CALM, 0.3f, 100, 1, FLAT);
tick(b, 0.05f, 1, 70, 3, 14f, CALM, 0.3f, 100, 1, FLAT);
for (int i = 0; i < 100; i++) {
assertEquals(a.x[i], b.x[i], 0.0);
assertEquals(a.y[i], b.y[i], 0.0);
@ -145,18 +184,30 @@ class SnowFlakesTest {
@Test
void zeroTimeStepChangesNothing() {
SnowFlakes f = new SnowFlakes();
f.update(0.0001f, 0, 64, 0, 14f, CALM, 0f, 1f, 50, 0f);
tick(f, 0.0001f, 0, 70, 0, 14f, CALM, 0f, 50, 0, FLAT);
double y0 = f.y[0];
f.update(0f, 0, 64, 0, 14f, CALM, 0f, 1f, 50, 0f);
tick(f, 0f, 0, 70, 0, 14f, CALM, 0f, 50, 0, FLAT);
assertEquals(y0, f.y[0], 0.0);
}
@Test
void turbulenceAddsSidewaysJitterButNoNetDownwindDrift() {
SnowFlakes f = new SnowFlakes();
tick(f, 0.0001f, 0, 70, 0, 14f, CALM, 0f, 400, 0, FLAT);
double x0 = meanX(f);
tick(f, 0.1f, 0, 70, 0, 14f, CALM, SnowFlakes.turbulence(1f), 400, 0.1, FLAT);
boolean sideways = false;
for (int i = 0; i < f.count; i++) sideways |= Math.abs(f.vx[i]) > 0.05f;
assertTrue(sideways);
assertEquals(0.0, meanX(f) - x0, 0.3);
}
// ---- ветер
@Test
void noWindWithoutPowerOrStorm() {
assertEquals(SnowFlakes.Wind.ZERO, SnowFlakes.wind(3f, 0f, 1f));
assertEquals(SnowFlakes.Wind.ZERO, SnowFlakes.wind(3f, 1f, 0f));
assertEquals(SnowFlakes.Wind.ZERO, SnowFlakes.wind(3, 0f, 1f));
assertEquals(SnowFlakes.Wind.ZERO, SnowFlakes.wind(3, 1f, 0f));
}
private static double horizontal(SnowFlakes.Wind w) {
@ -164,39 +215,44 @@ class SnowFlakesTest {
}
@Test
void windGrowsWithPowerAndStormStrength() {
double base = horizontal(SnowFlakes.wind(5f, 1.0f, 0.5f));
assertTrue(horizontal(SnowFlakes.wind(5f, 2.0f, 0.5f)) > base);
assertTrue(horizontal(SnowFlakes.wind(5f, 1.0f, 1.0f)) > base);
assertEquals(2.0, horizontal(SnowFlakes.wind(5f, 2.0f, 0.5f)) / base, 1e-9);
void windGrowsLinearlyWithPowerAndStormStrength() {
double base = horizontal(SnowFlakes.wind(5, 0.5f, 0.5f));
assertEquals(2.0, horizontal(SnowFlakes.wind(5, 1.0f, 0.5f)) / base, 1e-9);
assertEquals(2.0, horizontal(SnowFlakes.wind(5, 0.5f, 1.0f)) / base, 1e-9);
}
@Test
void gustsStayBoundedAndMostlyHorizontal() {
double cap = 1.0 * 14.0 * 1.5 * 1.0;
for (int i = 0; i < 400; i++) {
SnowFlakes.Wind w = SnowFlakes.wind(i * 0.37f, 1.0f, 1.0f);
double cap = SnowFlakes.WIND_SPEED_MAX * 1.5;
double min = Double.MAX_VALUE;
for (int i = 0; i < 2000; i++) {
SnowFlakes.Wind w = SnowFlakes.wind(i * 0.37, 1.0f, 1.0f);
assertTrue(horizontal(w) <= cap + 1e-9);
assertTrue(Math.abs(w.y()) <= horizontal(w) * 0.2 + 1e-9);
assertTrue(Math.abs(w.y()) <= horizontal(w) * 0.12 + 1e-9);
min = Math.min(min, horizontal(w));
}
assertTrue(min >= SnowFlakes.WIND_SPEED_MAX * 0.5 - 1e-9, "the wind never calms below half of its power");
}
@Test
void windDirectionDriftsOverTime() {
SnowFlakes.Wind early = SnowFlakes.wind(0f, 1f, 1f);
SnowFlakes.Wind late = SnowFlakes.wind(30f, 1f, 1f);
double angleDelta = Math.abs(Math.atan2(late.z(), late.x()) - Math.atan2(early.z(), early.x()));
assertTrue(angleDelta > 0.5);
void windDirectionTurnsSlowlyOverTime() {
SnowFlakes.Wind a = SnowFlakes.wind(100, 1f, 1f);
SnowFlakes.Wind b = SnowFlakes.wind(100.5, 1f, 1f);
double step = Math.abs(Math.atan2(b.z(), b.x()) - Math.atan2(a.z(), a.x()));
assertTrue(step < 0.3, "half a second never swings the wind round");
SnowFlakes.Wind late = SnowFlakes.wind(400, 1f, 1f);
double total = Math.abs(Math.atan2(late.z(), late.x()) - Math.atan2(a.z(), a.x()));
assertTrue(total > 0.5, "over minutes the direction drifts");
}
@Test
void strongerWindMeansMoreTurbulence() {
assertTrue(SnowFlakes.turbulence(2f) > SnowFlakes.turbulence(0.5f));
assertTrue(SnowFlakes.turbulence(0f) > 0f);
assertEquals(SnowFlakes.turbulence(0f), SnowFlakes.turbulence(-1f), 0f);
void turbulenceScalesWithTheSetting() {
assertEquals(0f, SnowFlakes.turbulence(0f), 0f);
assertEquals(0f, SnowFlakes.turbulence(-1f), 0f);
assertEquals(2.0, SnowFlakes.turbulence(1f) / SnowFlakes.turbulence(0.5f), 1e-6);
}
// ---- плавный разгон шторма
// ---- сила шторма и экспозиция
@Test
void stormRampsUpToTheIntensitySettingNotToFull() {
@ -210,9 +266,32 @@ class SnowFlakesTest {
float up = SnowFlakes.ramp(0f, 1f, 1f);
float down = 1f - SnowFlakes.ramp(1f, 0f, 1f);
assertTrue(up > 0f && up < 1f);
assertTrue(down > up, "fade-out is quicker than build-up");
assertTrue(down > up);
assertEquals(0f, SnowFlakes.ramp(0.01f, 0f, 5f), 0f);
assertEquals(0.3f, SnowFlakes.ramp(0.3f, 0.3f, 1f), 0f);
assertEquals(0.5f, SnowFlakes.ramp(0.5f, 1f, 0f), 0f);
}
@Test
void skyExposureIsFullOutdoorsAndZeroUnderACeiling() {
assertEquals(1f, SnowFlakes.exposureTarget(70, 65f), 0f);
assertEquals(1f, SnowFlakes.exposureTarget(65, 65f), 0f);
assertEquals(0f, SnowFlakes.exposureTarget(64, 65f), 0f);
}
@Test
void exposureEasesInsteadOfSnapping() {
float e = SnowFlakes.approach(0f, 1f, SnowFlakes.EXPOSURE_PER_SECOND, 0.1f);
assertTrue(e > 0f && e < 0.3f);
assertEquals(1f, SnowFlakes.approach(0f, 1f, SnowFlakes.EXPOSURE_PER_SECOND, 5f), 0f);
}
@Test
void stormFollowsTheSnowfallOnlyOption() {
assertEquals(0.85f, SnowFlakes.targetStrength(0.85f, false, 0f, false), 0f);
assertEquals(0f, SnowFlakes.targetStrength(0.85f, true, 0f, true), 0f);
assertEquals(0f, SnowFlakes.targetStrength(0.85f, true, 1f, false), 0f);
assertEquals(0.85f, SnowFlakes.targetStrength(0.85f, true, 1f, true), 0f);
assertEquals(0.425f, SnowFlakes.targetStrength(0.85f, true, 0.5f, true), 1e-6f);
}
}

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