feat(mod): ElytraTrails and Blizzard reviewed and fixed, shared trail ribbon, spec-style tests

This commit is contained in:
loki5512344 2026-10-02 14:26:41 +02:00
parent 622984f619
commit 9661ff638d
Signed by: boba
GPG key ID: 253067914055423B
25 changed files with 1591 additions and 96 deletions

View file

@ -44,6 +44,14 @@
дистанция, размер), снимок меню `ModulesMenuScreen.presenceSnapshot`. Мини-копия ClickGUI в мире перед игроком из цветных квадов
(без текста). Нужны привязанный аккаунт (`%link`) и мультиплеер. Не сделано: значок «LoVisual» у ника/в табе, названия модулей в панели,
просмотр видео (`.watch` у evo), деплой chat-service на прод СДЕЛАН 2026-10-02 (rsync backend + `docker compose up -d --build chat-service gateway`; на сервер не уехали накопленные правки accounts-service/configs-service, они ждут отдельного деплоя).
- [x] ElytraTrails и Blizzard (код пришёл от остановленных сессий, проверен и доработан; тесты написаны по спецификации, а не по реализации):
ElytraTrails: ленты с концов крыльев только при планировании (`isFallFlying`), опора позади глаз, размах перпендикулярен полёту и
горизонтален (`WingTipLocator`, 7 тестов). Общий код вынесен в `effects/ribbon/` (`TrackBuffer`, `TrailRibbon`, `RibbonPoint`), его
используют ProjectileTrails и ElytraTrails (убрано дублирование; тесты ribbon 14 + ProjectileTrails 6 + Wing store 5).
`AnimatedRenderColors.modeOf` заменил три копии `parse`. Blizzard: исправлено, что «Интенсивность» ничего не меняла (сила
нормировалась к 1), снежинки теперь закрываются блоками (depth-пайплайн), чистая логика вынесена и покрыта (SnowFlakes 19,
Overlay 8, Renderer 9 тестов); добавлены все lang-ключи модулей. Не проверено вживую: вид лент и метели, положение концов крыльев,
снег не отсекается «крышей» (метель идёт и в помещении).
- [ ] Wings: режим «Модель» (Bedrock geo.json + animation.json, 14 видов; отчёт раздел 2.4). Шаги: `BedrockGeoParser` (общий с ножами),
`BedrockAnimation` + процедурные angel/demon/dragon, `WingsRenderer.renderModel`, настройки `wingSource`/`wingModel`, тесты.
Ресурсы (локально): `ref/evo/mod/jar/assets/evolution/cosmetic/wings/`, `.../textures/cosmetic/wings/`.
@ -51,9 +59,23 @@
порядок работ из 8 шагов). Ресурсы: `ref/evo/mod/jar/assets/evolution/cosmetic/melee/`.
- [ ] Быстрые модули P1: AutoSprint (есть `SprintControlEvent`), TotemCounter, EnchantGlint, PlaceAnimation, GodRays
(референс `shaders/core/godrays`), ArmorColor.
- [ ] Дальше P1-P2: Blizzard, ElytraTrails (на нашем `Trails`), TotemSound, Sounds, InventorySort, StorageTracker, ConsumeESP,
CustomHand, DynamicLights (проверить `Light`), FoodInfo, PatPat, Showcase. P3: Chunk Cache, Schematics, HitFix (риск античита),
Camera, Graffiti, Skin Wheel, Discord RPC, Replay, Trainer. Пропустить: GuiPresence. Полный список: отчёт, раздел 1.2.
- [ ] Дальше P1-P2 — перенос модулей из evo поштучно (референс: `docs/EVO_REPORT.md` §1.2;
лицензия evo проприетарная — их ассеты/код в репо не кладём, только идеи):
- [ ] **Blizzard** (render, evo ~1290 LOC, M/L) — высотная метель: ветровые снежинки с горизонтальным
сносом, белая пелена, обмерзание краёв экрана.
- [ ] **FoodInfo** (~889, M) — сытость/восстановление и подсказка оптимального порядка еды на полосе голода.
- [ ] **ElytraTrails** (~2208, M) — светящиеся шлейфы с концов крыльев элитры, переиспользовать наш `Trails`.
- [ ] **CustomHand** (~373, M) — пост-процесс шейдер руки (частично есть `shaders/hand_glass.frag`).
- [ ] **DynamicLights** (~553, M) — факелы/лампы в руке светят по-настоящему; сначала проверить наш `Light`.
- [ ] **TotemSound** (~257, S) — свой звук при срабатывании тотема.
- [ ] **Sounds** (~195, S) — звук на клиентские действия (открытие инвентаря, прыжок, клик в GUI…).
- [ ] **InventorySort** (~390, S/M) — сортировка инвентаря по пресету (клиентские QUICK_MOVE, без чужих пакетов).
- [ ] **StorageTracker** (~678, M) — память содержимого контейнеров, поиск предмета, подсветка где лежит.
- [ ] **ConsumeESP** (~671, M) — круг с кольцом прогресса над целью, когда она ест/пьёт.
- [ ] **PatPat** (~645, M) — гладить моба пустой рукой (бинд, анимация/частицы, всё локально).
- [ ] **Showcase** (~882, L) — кинематографичный облёт постройки камерой; запись клипа — позже (MVP: облёт).
- P3 (следующий заход): Chunk Cache, Schematics, HitFix (риск античита), Camera, Graffiti, Skin Wheel,
Discord RPC, Replay, Trainer. Полный список и сложности: отчёт, раздел 1.2.
## Открытые задачи
### Проверить в игре (код готов, вживую не смотрели)

View file

@ -32,15 +32,6 @@ final class TrailsColor {
}
AnimatedRenderColors.Mode parse(String mode) {
return switch (mode) {
case "Rainbow" -> AnimatedRenderColors.Mode.RAINBOW;
case "LightRainbow" -> AnimatedRenderColors.Mode.LIGHT_RAINBOW;
case "Sky" -> AnimatedRenderColors.Mode.SKY;
case "Fade" -> AnimatedRenderColors.Mode.FADE;
case "DoubleColor" -> AnimatedRenderColors.Mode.DOUBLE_COLOR;
case "Analogous" -> AnimatedRenderColors.Mode.ANALOGOUS;
case "Theme" -> AnimatedRenderColors.Mode.THEME;
default -> AnimatedRenderColors.Mode.STATIC;
};
return AnimatedRenderColors.modeOf(mode);
}
}

View file

@ -0,0 +1,86 @@
package dev.loki.lovisual.features.module.modules.visuals.effects.elytratrails;
import dev.loki.lovisual.config.values.color.RGBAColorValue;
import dev.loki.lovisual.config.values.mode.ModeValue;
import dev.loki.lovisual.config.values.primitive.BooleanValue;
import dev.loki.lovisual.config.values.primitive.NumberValue;
import dev.loki.lovisual.features.module.core.Module;
import dev.loki.lovisual.features.module.core.ModuleCategory;
import dev.loki.lovisual.features.module.core.ModuleInfo;
import dev.loki.lovisual.features.module.phase.WorldPhase;
import dev.loki.lovisual.features.module.modules.visuals.effects.ribbon.TrackBuffer;
import dev.loki.lovisual.features.module.modules.visuals.effects.ribbon.TrailRibbon;
import dev.loki.lovisual.render.engine.animation.AnimatedRenderColors;
import dev.loki.lovisual.render.engine.renderer.Renderer3D;
import dev.loki.lovisual.util.time.GameClock;
import net.minecraft.client.Minecraft;
import net.minecraft.world.entity.player.Player;
import net.minecraft.world.phys.Vec3;
import java.util.List;
/** Светящиеся шлейфы с концов крыльев элитры в полёте (по мотивам evo ElytraTrails). */
@ModuleInfo(
id = "elytratrails",
displayName = "ElytraTrails",
category = ModuleCategory.VISUALS,
description = "Glowing trails streaming from elytra wing tips while gliding"
)
public class ElytraTrails extends Module {
private final WingTrailStore store = new WingTrailStore();
private final BooleanValue others = bool("elytraTrailsOthers", "others", true);
private final NumberValue<Integer> duration = num("elytraTrailsDuration", "duration", 700, 150, 3000);
private final NumberValue<Float> width = num("elytraTrailsWidth", "width", 0.35f, 0.05f, 1.5f);
private final BooleanValue glow = bool("elytraTrailsGlow", "glow", true);
private final BooleanValue lines = bool("elytraTrailsLines", "lines", false);
private final NumberValue<Float> lineWidth = visibleWhen(num("elytraTrailsLineWidth", "line_width", 2.0f, 0.5f, 5.0f), lines::get);
private final BooleanValue depthTest = bool("elytraTrailsDepth", "depth_test", true);
private final ModeValue colorMode = modeSetting("elytraTrailsColorMode", "color_mode", "DoubleColor",
"Static", "Rainbow", "LightRainbow", "Sky", "Fade", "DoubleColor", "Analogous", "Theme");
private final RGBAColorValue color = color("elytraTrailsColor", "color", "#88FF66CC");
private final RGBAColorValue color2 = color("elytraTrailsColor2", "color2", "#6688FFFF");
private final NumberValue<Integer> colorSpeed = num("elytraTrailsColorSpeed", "color_speed", 18, 2, 54);
private final NumberValue<Integer> colorPhase = num("elytraTrailsColorPhase", "color_phase", 0, 0, 360);
@Override
public WorldPhase getWorldPhase() {
return WorldPhase.END_MAIN;
}
@Override
public void onDisable() {
store.clear();
}
@Override
public void onRenderWorldEngine(Renderer3D renderer, Renderer3D depthRenderer, float tickDelta) {
Minecraft mc = Minecraft.getInstance();
if (!isEnabled() || mc.level == null || mc.gameRenderer.mainCamera() == null) return;
long now = GameClock.millis();
long life = duration.get();
for (Player player : mc.level.players()) {
if (!others.get() && player != mc.player) continue;
if (!player.isAlive() || !player.isFallFlying()) continue;
WingTipLocator.Tips tips = WingTipLocator.tips(player.getEyePosition(tickDelta),
player.getViewYRot(tickDelta), player.getViewXRot(tickDelta));
store.sample(player.getUUID(), WingTrailStore.LEFT, tips.left().x, tips.left().y, tips.left().z, now, life);
store.sample(player.getUUID(), WingTrailStore.RIGHT, tips.right().x, tips.right().y, tips.right().z, now, life);
}
store.prune(now, life);
List<TrackBuffer> tracks = store.snapshot();
if (tracks.isEmpty()) return;
int phase = Math.round((mc.player == null ? 0 : mc.player.tickCount + tickDelta) * 4.0f) + colorPhase.get();
int primary = color.getArgb();
int secondary = color2.getArgb();
int speed = colorSpeed.get();
AnimatedRenderColors.Mode mode = AnimatedRenderColors.modeOf(colorMode.get());
Vec3 cam = mc.gameRenderer.mainCamera().position();
TrailRibbon.render(renderer, tracks, now, life, width.get(), true, glow.get(), lines.get(), lineWidth.get(),
depthTest.get(), cam,
(i, n) -> AnimatedRenderColors.resolve(mode, speed,
phase + Math.round(i / (float) Math.max(1, n - 1) * 180), primary, secondary, true));
}
}

View file

@ -0,0 +1,39 @@
package dev.loki.lovisual.features.module.modules.visuals.effects.elytratrails;
import net.minecraft.world.phys.Vec3;
/**
* Концы крыльев планирующего игрока. Точка привязки лежит позади головы вдоль направления полёта,
* размах крыльев перпендикулярен ему и всегда горизонтален (без крена). Чистая математика.
*/
public final class WingTipLocator {
/** Половина размаха: расстояние от оси тела до конца крыла, блоки. */
static final float HALF_SPAN = 1.0f;
/** Корни крыльев лежат позади глаз на этом расстоянии вдоль направления полёта. */
static final float BACK = 0.3f;
/** Смещение по «верху» тела относительно глаз (корни крыльев чуть ниже глаз). */
static final float HEIGHT = -0.15f;
private WingTipLocator() {
}
public record Tips(Vec3 left, Vec3 right) {
}
/**
* @param eye позиция глаз (интерполированная)
* @param yawDeg рыскание в градусах (0 смотрит на +Z)
* @param pitchDeg наклон в градусах, положительный вниз
*/
public static Tips tips(Vec3 eye, float yawDeg, float pitchDeg) {
double yaw = Math.toRadians(yawDeg);
double pitch = Math.toRadians(pitchDeg);
Vec3 forward = new Vec3(-Math.sin(yaw) * Math.cos(pitch), -Math.sin(pitch), Math.cos(yaw) * Math.cos(pitch));
// Правая рука игрока зависит только от рыскания, поэтому пара не вырождается при взгляде вверх и вниз.
Vec3 right = new Vec3(-Math.cos(yaw), 0.0, -Math.sin(yaw));
Vec3 up = right.cross(forward);
Vec3 anchor = eye.subtract(forward.scale(BACK)).add(up.scale(HEIGHT));
return new Tips(anchor.subtract(right.scale(HALF_SPAN)), anchor.add(right.scale(HALF_SPAN)));
}
}

View file

@ -0,0 +1,57 @@
package dev.loki.lovisual.features.module.modules.visuals.effects.elytratrails;
import dev.loki.lovisual.features.module.modules.visuals.effects.ribbon.TrackBuffer;
import java.util.ArrayList;
import java.util.HashMap;
import java.util.List;
import java.util.Map;
import java.util.UUID;
/** Истории концов крыльев: по ленте на каждое крыло каждого игрока; опустевшие ленты и игроки удаляются. */
final class WingTrailStore {
static final int LEFT = 0;
static final int RIGHT = 1;
private final Map<UUID, TrackBuffer[]> tracks = new HashMap<>();
void clear() {
tracks.clear();
}
void sample(UUID id, int side, double x, double y, double z, long now, long lifeMs) {
TrackBuffer[] pair = tracks.computeIfAbsent(id, ignored -> new TrackBuffer[2]);
if (pair[side] == null) pair[side] = new TrackBuffer();
pair[side].add(x, y, z, now, lifeMs);
}
/** Убирает устаревшие точки; опустевшие ленты и игроки без лент удаляются. */
void prune(long now, long lifeMs) {
tracks.values().removeIf(pair -> {
boolean any = false;
for (int i = 0; i < pair.length; i++) {
if (pair[i] == null) continue;
pair[i].trim(now, lifeMs);
if (pair[i].isEmpty()) pair[i] = null;
else any = true;
}
return !any;
});
}
/** Живые ленты для отрисовки (до двух на игрока). */
List<TrackBuffer> snapshot() {
List<TrackBuffer> out = new ArrayList<>(tracks.size() * 2);
for (TrackBuffer[] pair : tracks.values()) {
for (TrackBuffer track : pair) {
if (track != null && !track.isEmpty()) out.add(track);
}
}
return out;
}
int trackCount() {
return tracks.size();
}
}

View file

@ -8,6 +8,7 @@ import dev.loki.lovisual.config.values.primitive.NumberValue;
import dev.loki.lovisual.features.module.core.Module;
import dev.loki.lovisual.features.module.core.ModuleCategory;
import dev.loki.lovisual.features.module.core.ModuleInfo;
import dev.loki.lovisual.features.module.modules.visuals.effects.ribbon.TrailRibbon;
import dev.loki.lovisual.features.module.phase.WorldPhase;
import dev.loki.lovisual.render.engine.animation.AnimatedRenderColors;
import dev.loki.lovisual.render.engine.renderer.Renderer3D;
@ -16,8 +17,6 @@ import net.minecraft.client.Minecraft;
import net.minecraft.world.entity.Entity;
import net.minecraft.world.phys.Vec3;
import java.util.ArrayList;
import java.util.List;
import java.util.Map;
/** Затухающие ленты за стрелами, жемчугом, трезубцами и другими снарядами. */
@ -69,27 +68,13 @@ public class ProjectileTrails extends Module {
store.prune(now, life, id -> mc.level.getEntity(id) != null);
if (store.tracks().isEmpty()) return;
int phase = Math.round((mc.player == null ? 0 : mc.player.tickCount + tickDelta) * 4.0f);
AnimatedRenderColors.Mode mode = parse(colorMode.get());
AnimatedRenderColors.Mode mode = AnimatedRenderColors.modeOf(colorMode.get());
int primary = color.getArgb();
int secondary = color2.getArgb();
int speed = colorSpeed.get();
List<TrailStore.Track> tracks = new ArrayList<>(store.tracks().values());
Vec3 cam = mc.gameRenderer.mainCamera().position();
TrailRibbon.render(renderer, tracks, now, life, width.get(), fill.get(), "Glow".equals(fillType.get()),
TrailRibbon.render(renderer, store.tracks().values(), now, life, width.get(), fill.get(), "Glow".equals(fillType.get()),
lines.get(), lineWidth.get(), depthTest.get(), cam,
(i, n) -> AnimatedRenderColors.resolve(mode, speed, phase + Math.round(i / (float) Math.max(1, n - 1) * 180), primary, secondary, true));
}
private static AnimatedRenderColors.Mode parse(String mode) {
return switch (mode) {
case "Rainbow" -> AnimatedRenderColors.Mode.RAINBOW;
case "LightRainbow" -> AnimatedRenderColors.Mode.LIGHT_RAINBOW;
case "Sky" -> AnimatedRenderColors.Mode.SKY;
case "Fade" -> AnimatedRenderColors.Mode.FADE;
case "DoubleColor" -> AnimatedRenderColors.Mode.DOUBLE_COLOR;
case "Analogous" -> AnimatedRenderColors.Mode.ANALOGOUS;
case "Theme" -> AnimatedRenderColors.Mode.THEME;
default -> AnimatedRenderColors.Mode.STATIC;
};
}
}

View file

@ -1,33 +1,22 @@
package dev.loki.lovisual.features.module.modules.visuals.effects.projectiletrails;
import dev.loki.lovisual.features.module.modules.visuals.effects.ribbon.TrackBuffer;
import net.minecraft.core.registries.BuiltInRegistries;
import net.minecraft.world.entity.Entity;
import net.minecraft.world.entity.projectile.Projectile;
import net.minecraft.world.phys.Vec3;
import java.util.ArrayDeque;
import java.util.HashMap;
import java.util.Iterator;
import java.util.Map;
import java.util.HashMap;
import java.util.function.Predicate;
import java.util.function.IntPredicate;
/** История позиций летящих снарядов: точки с метками времени, устаревшие и пропавшие следы вычищаются. */
/** История позиций летящих снарядов по id сущности; пропавшие из мира снаряды дотлевают и удаляются. */
final class TrailStore {
/** Минимальный сдвиг между соседними точками (блоки), чтобы лента не копила шум. */
private static final double MIN_STEP = 0.04;
private final Map<Integer, TrackBuffer> tracks = new HashMap<>();
record Point(double x, double y, double z, long ms) {
}
static final class Track {
final ArrayDeque<Point> points = new ArrayDeque<>();
long lastSeen;
}
private final Map<Integer, Track> tracks = new HashMap<>();
Map<Integer, Track> tracks() {
Map<Integer, TrackBuffer> tracks() {
return tracks;
}
@ -38,7 +27,11 @@ final class TrailStore {
/** Ключ вида снаряда для настроек: arrow, trident, pearl, snowball, egg, potion, fireball, wind_charge, firework. */
static String kindOf(Entity entity) {
if (!(entity instanceof Projectile)) return null;
String path = BuiltInRegistries.ENTITY_TYPE.getKey(entity.getType()).getPath();
return kindOfPath(BuiltInRegistries.ENTITY_TYPE.getKey(entity.getType()).getPath());
}
/** Вид снаряда по пути id типа сущности (чистая логика); null для неподдерживаемых. */
static String kindOfPath(String path) {
if (path.contains("arrow")) return "arrow";
if (path.contains("trident")) return "trident";
if (path.contains("pearl")) return "pearl";
@ -51,33 +44,22 @@ final class TrailStore {
return null;
}
/** Добавляет текущую позицию снаряда (интерполированную) и отбрасывает старые точки. */
/** Добавляет текущую (интерполированную) позицию снаряда и отбрасывает старые точки. */
void sample(Entity entity, float partialTicks, long now, long lifeMs) {
Track track = tracks.computeIfAbsent(entity.getId(), id -> new Track());
track.lastSeen = now;
Vec3 p = entity.getPosition(partialTicks);
Point last = track.points.peekLast();
if (last == null || last.ms() != now && dist(last, p) >= MIN_STEP) {
track.points.addLast(new Point(p.x, p.y, p.z, now));
sample(entity.getId(), p.x, p.y, p.z, now, lifeMs);
}
void sample(int id, double x, double y, double z, long now, long lifeMs) {
tracks.computeIfAbsent(id, key -> new TrackBuffer()).add(x, y, z, now, lifeMs);
}
/** Чистит следы: пропавшие снаряды дотлевают, потом след удаляется. */
void prune(long now, long lifeMs, IntPredicate alive) {
for (Iterator<Map.Entry<Integer, TrackBuffer>> it = tracks.entrySet().iterator(); it.hasNext(); ) {
Map.Entry<Integer, TrackBuffer> e = it.next();
if (!alive.test(e.getKey())) e.getValue().trim(now, lifeMs);
if (e.getValue().isEmpty()) it.remove();
}
trim(track, now, lifeMs);
}
/** Чистит следы: пропавшие из мира снаряды дотлевают, потом удаляются. */
void prune(long now, long lifeMs, Predicate<Integer> alive) {
for (Iterator<Map.Entry<Integer, Track>> it = tracks.entrySet().iterator(); it.hasNext(); ) {
Map.Entry<Integer, Track> e = it.next();
if (!alive.test(e.getKey())) trim(e.getValue(), now, lifeMs);
if (e.getValue().points.isEmpty() || now - e.getValue().lastSeen > lifeMs * 2) it.remove();
}
}
private static void trim(Track track, long now, long lifeMs) {
while (!track.points.isEmpty() && now - track.points.peekFirst().ms() > lifeMs) track.points.pollFirst();
}
private static double dist(Point a, Vec3 b) {
double dx = a.x() - b.x, dy = a.y() - b.y, dz = a.z() - b.z;
return Math.sqrt(dx * dx + dy * dy + dz * dz);
}
}

View file

@ -0,0 +1,5 @@
package dev.loki.lovisual.features.module.modules.visuals.effects.ribbon;
/** Точка следа: позиция в мире и время (мс) появления. */
public record RibbonPoint(double x, double y, double z, long ms) {
}

View file

@ -0,0 +1,62 @@
package dev.loki.lovisual.features.module.modules.visuals.effects.ribbon;
import java.util.ArrayDeque;
import java.util.ArrayList;
import java.util.List;
/**
* История одной ленты: точки с метками времени. Сдвиг меньше {@link #MIN_STEP} точкой не становится,
* точки старше срока жизни отбрасываются. Общая основа для ProjectileTrails и ElytraTrails.
*/
public final class TrackBuffer {
/** Минимальный сдвиг между соседними точками (блоки), чтобы лента не копила шум. */
public static final double MIN_STEP = 0.04;
private final ArrayDeque<RibbonPoint> points = new ArrayDeque<>();
private long lastSeen = Long.MIN_VALUE;
/**
* Фиксирует позицию, затем убирает устаревшие точки.
*
* @return true, если добавлена новая точка
*/
public boolean add(double x, double y, double z, long now, long lifeMs) {
lastSeen = now;
RibbonPoint last = points.peekLast();
boolean added = false;
if (last == null || (now != last.ms() && distance(last, x, y, z) >= MIN_STEP)) {
points.addLast(new RibbonPoint(x, y, z, now));
added = true;
}
trim(now, lifeMs);
return added;
}
public void trim(long now, long lifeMs) {
while (!points.isEmpty() && now - points.peekFirst().ms() > lifeMs) points.pollFirst();
}
public boolean isEmpty() {
return points.isEmpty();
}
public int size() {
return points.size();
}
/** Время последнего вызова {@link #add}, даже если точка не добавилась. */
public long lastSeen() {
return lastSeen;
}
/** Копия точек от хвоста к голове. */
public List<RibbonPoint> points() {
return new ArrayList<>(points);
}
private static double distance(RibbonPoint a, double x, double y, double z) {
double dx = a.x() - x, dy = a.y() - y, dz = a.z() - z;
return Math.sqrt(dx * dx + dy * dy + dz * dz);
}
}

View file

@ -1,6 +1,5 @@
package dev.loki.lovisual.features.module.modules.visuals.effects.projectiletrails;
package dev.loki.lovisual.features.module.modules.visuals.effects.ribbon;
import dev.loki.lovisual.features.module.modules.visuals.effects.projectiletrails.TrailStore.Point;
import dev.loki.lovisual.render.engine.RenderState;
import dev.loki.lovisual.render.engine.color.RenderColor;
import dev.loki.lovisual.render.engine.pipeline.LoVisualRenderPipelines;
@ -8,22 +7,45 @@ import dev.loki.lovisual.render.engine.renderer.Renderer3D;
import dev.loki.lovisual.render.engine.uniform.MeshBuilder;
import net.minecraft.world.phys.Vec3;
import java.util.ArrayList;
import java.util.Collection;
import java.util.List;
import java.util.function.IntBinaryOperator;
/** Строит ленту, повёрнутую к камере, с затуханием и сужением к хвосту, плюс контурные линии по краям. */
final class TrailRibbon {
/**
* Лента, повёрнутая к камере: сужается и гаснет к хвосту, с необязательным контуром по краям.
* Общий рендер для ProjectileTrails и ElytraTrails.
*/
public final class TrailRibbon {
private TrailRibbon() {
}
/** Доля оставшейся жизни точки: 1 свежая, 0 истёкшая. */
public static float lifeFraction(long now, long ms, long lifeMs) {
if (lifeMs <= 0) return 0.0f;
float left = 1.0f - (now - ms) / (float) lifeMs;
return Math.max(0.0f, Math.min(1.0f, left));
}
/** Альфа точки падает как life в квадрате, RGB не меняется. */
public static int fadeAlpha(int argb, float life) {
float l = Math.max(0.0f, Math.min(1.0f, life));
int alpha = Math.round(((argb >>> 24) & 0xFF) * l * l);
return (alpha << 24) | (argb & 0x00FFFFFF);
}
/** Полуширина ленты: у свежей точки половина {@code width}, к хвосту сужается до четверти. */
public static double halfWidth(float width, float life) {
float l = Math.max(0.0f, Math.min(1.0f, life));
return width * 0.5 * (0.25 + 0.75 * l);
}
/**
* @param color цвет точки: (индекс от хвоста, число точек) -> ARGB
*/
static void render(Renderer3D renderer, List<TrailStore.Track> tracks, long now, long lifeMs, float width,
boolean fill, boolean glow, boolean lines, float lineWidth, boolean depth, Vec3 cam,
IntBinaryOperator color) {
public static void render(Renderer3D renderer, Collection<TrackBuffer> tracks, long now, long lifeMs, float width,
boolean fill, boolean glow, boolean lines, float lineWidth, boolean depth, Vec3 cam,
IntBinaryOperator color) {
MeshBuilder fillMesh = null;
if (fill) {
var pipeline = glow
@ -43,34 +65,32 @@ final class TrailRibbon {
}
}
if (fillMesh == null && lineMesh == null) return;
for (TrailStore.Track track : tracks) {
List<Point> pts = new ArrayList<>(track.points);
for (TrackBuffer track : tracks) {
List<RibbonPoint> pts = track.points();
if (pts.size() >= 2) draw(pts, now, lifeMs, width, fillMesh, lineMesh, cam, color);
}
}
private static void draw(List<Point> pts, long now, long lifeMs, float width, MeshBuilder fill, MeshBuilder line,
Vec3 cam, IntBinaryOperator color) {
private static void draw(List<RibbonPoint> pts, long now, long lifeMs, float width, MeshBuilder fill,
MeshBuilder line, Vec3 cam, IntBinaryOperator color) {
int n = pts.size();
double[][] left = new double[n][3];
double[][] right = new double[n][3];
int[] argb = new int[n];
for (int i = 0; i < n; i++) {
Point p = pts.get(i);
Point a = pts.get(Math.max(0, i - 1));
Point b = pts.get(Math.min(n - 1, i + 1));
RibbonPoint p = pts.get(i);
RibbonPoint a = pts.get(Math.max(0, i - 1));
RibbonPoint b = pts.get(Math.min(n - 1, i + 1));
double dx = b.x() - a.x(), dy = b.y() - a.y(), dz = b.z() - a.z();
double vx = cam.x - p.x(), vy = cam.y - p.y(), vz = cam.z - p.z();
double sx = dy * vz - dz * vy, sy = dz * vx - dx * vz, sz = dx * vy - dy * vx;
double len = Math.sqrt(sx * sx + sy * sy + sz * sz);
if (len < 1e-6) len = 1.0;
float life = 1.0f - Math.min(1.0f, (now - p.ms()) / (float) lifeMs);
double half = width * 0.5 * (0.25 + 0.75 * life) / len;
float life = lifeFraction(now, p.ms(), lifeMs);
double half = halfWidth(width, life) / len;
left[i] = new double[]{p.x() + sx * half, p.y() + sy * half, p.z() + sz * half};
right[i] = new double[]{p.x() - sx * half, p.y() - sy * half, p.z() - sz * half};
int base = color.applyAsInt(i, n);
int alpha = Math.round(((base >>> 24) & 0xFF) * life * life);
argb[i] = (alpha << 24) | (base & 0x00FFFFFF);
argb[i] = fadeAlpha(color.applyAsInt(i, n), life);
}
for (int i = 1; i < n; i++) {
if (fill != null) quad(fill, left[i - 1], right[i - 1], right[i], left[i], argb[i - 1], argb[i]);

View file

@ -0,0 +1,133 @@
package dev.loki.lovisual.features.module.modules.visuals.weather.blizzard;
import dev.loki.lovisual.config.values.color.RGBAColorValue;
import dev.loki.lovisual.config.values.primitive.BooleanValue;
import dev.loki.lovisual.config.values.primitive.NumberValue;
import dev.loki.lovisual.features.module.core.Module;
import dev.loki.lovisual.features.module.core.ModuleCategory;
import dev.loki.lovisual.features.module.core.ModuleInfo;
import dev.loki.lovisual.features.module.phase.HudPhase;
import dev.loki.lovisual.features.module.phase.WorldPhase;
import dev.loki.lovisual.render.engine.RenderState;
import dev.loki.lovisual.render.engine.pipeline.LoVisualRenderPipelines;
import dev.loki.lovisual.render.engine.renderer.Renderer2D;
import dev.loki.lovisual.render.engine.renderer.Renderer3D;
import dev.loki.lovisual.render.engine.text.render.TextRenderer;
import dev.loki.lovisual.render.engine.uniform.MeshBuilder;
import net.minecraft.client.Camera;
import net.minecraft.client.Minecraft;
import net.minecraft.client.gui.GuiGraphicsExtractor;
import net.minecraft.core.BlockPos;
import net.minecraft.util.Mth;
import net.minecraft.world.level.biome.Biome;
import net.minecraft.world.phys.Vec3;
/**
* Метель: снежинки со сносом в мире, белая дымка и обмерзание краёв экрана.
* Фасад: симуляция в {@link SnowFlakes}, 3D-рендер в {@link BlizzardRenderer},
* экранное покрытие в {@link BlizzardOverlay}.
*/
@ModuleInfo(
id = "blizzard",
displayName = "Blizzard",
category = ModuleCategory.VISUALS,
description = "Wind-driven snowstorm with white haze and frosted screen edges"
)
public final class Blizzard extends Module {
private final NumberValue<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 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 SnowFlakes flakes = new SnowFlakes();
private long lastFrameNanos;
private float seconds;
private float strength;
@Override
public HudPhase getHudPhase() {
return HudPhase.AFTER_SUBTITLES;
}
@Override
public WorldPhase getWorldPhase() {
return WorldPhase.END_MAIN;
}
@Override
public void onEnable() {
lastFrameNanos = 0L;
seconds = 0.0f;
strength = 0.0f;
flakes.clear();
}
@Override
public void onDisable() {
flakes.clear();
}
private float frameDeltaSeconds() {
long now = System.nanoTime();
if (lastFrameNanos == 0L) lastFrameNanos = now;
float dt = (float) ((now - lastFrameNanos) / 1_000_000_000.0);
lastFrameNanos = now;
return Mth.clamp(dt, 0.0f, 0.05f);
}
private float targetStrength() {
Minecraft mc = Minecraft.getInstance();
if (mc.player == null || mc.level == null) return 0.0f;
if (!onlyWhenSnowing.get()) return intensity.get();
if (mc.level.getRainLevel(1.0f) < 0.02f) return 0.0f;
BlockPos pos = mc.player.blockPosition();
Biome.Precipitation precipitation =
mc.level.getBiome(pos).value().getPrecipitationAt(pos, mc.level.getSeaLevel());
return precipitation == Biome.Precipitation.SNOW ? intensity.get() : 0.0f;
}
@Override
public void onRenderWorldEngine(Renderer3D renderer, Renderer3D depthRenderer, float tickDelta) {
Minecraft mc = Minecraft.getInstance();
if (!isEnabled() || mc.player == null || mc.level == null || mc.gameRenderer == null) return;
if (mc.gameRenderer.mainCamera() == null) return;
float dt = frameDeltaSeconds();
seconds += dt;
strength = SnowFlakes.ramp(strength, targetStrength(), dt);
float norm = strength;
if (norm <= 0.001f) {
flakes.clear();
return;
}
Camera camera = mc.gameRenderer.mainCamera();
Vec3 cam = camera.position();
SnowFlakes.Wind windValue = SnowFlakes.wind(seconds, wind.get(), norm);
int target = SnowFlakes.targetCount(radius.get(), density.get(), norm);
flakes.update(dt, cam.x, cam.y, cam.z, radius.get(), windValue,
SnowFlakes.turbulence(wind.get()), speed.get(), target, seconds);
if (flakes.count == 0) return;
MeshBuilder mesh = renderer.batch(LoVisualRenderPipelines.WORLD_COLORED_DEPTH, Renderer3D.DepthMode.MAIN);
if (mesh == null) return;
BlizzardRenderer.render(mesh, flakes, RenderState.cameraRotation,
cam.x, cam.y, cam.z, radius.get(), tint.getArgb(), norm, flakeSize.get(), opacity.get());
}
@Override
public void onRenderHudEngine(Renderer2D renderer, TextRenderer textRenderer,
GuiGraphicsExtractor ctx, float tickDelta) {
if (!isEnabled()) return;
float norm = strength;
if (norm <= 0.01f) return;
BlizzardOverlay.render(ctx, norm, haze.get(), frost.get(), frostIntensity.get(), tint.getArgb());
}
}

View file

@ -0,0 +1,79 @@
package dev.loki.lovisual.features.module.modules.visuals.weather.blizzard;
import net.minecraft.client.gui.GuiGraphicsExtractor;
/**
* Белая дымка и обмерзание краёв экрана: слой из полупрозрачных рамок,
* каждая следующая тоньше, поэтому альфа копится к самому краю.
*/
final class BlizzardOverlay {
static final int FROST_BANDS = 16;
static final float MAX_VEIL = 0.85f;
static final float VEIL_SCALE = 0.55f;
static final float FROST_TOTAL = 0.6f;
private BlizzardOverlay() {
}
static void render(GuiGraphicsExtractor ctx, float strength, float haze,
boolean frost, float frostStrength, int tintArgb) {
if (ctx == null || strength <= 0.01f) return;
int w = ctx.guiWidth();
int h = ctx.guiHeight();
if (w <= 0 || h <= 0) return;
float veil = veilAlpha(haze, strength);
if (veil > 0.004f) ctx.fill(0, 0, w, h, withAlpha(tintArgb, veil));
if (!frost || frostStrength <= 0.01f) return;
float total = frostTotal(strength, frostStrength);
if (total <= 0.004f) return;
int color = withAlpha(tintArgb, frostBandAlpha(total));
int depth = Math.max(2, Math.round(Math.min(w, h) * 0.20f));
for (int i = 0; i < FROST_BANDS; i++) {
int thickness = bandThickness(i, FROST_BANDS, depth);
ctx.fill(0, 0, w, thickness, color);
ctx.fill(0, h - thickness, w, h, color);
ctx.fill(0, thickness, thickness, h - thickness, color);
ctx.fill(w - thickness, thickness, w, h - thickness, color);
}
}
/** Непрозрачность белой дымки на весь экран: растёт с дымкой и силой шторма, ограничена {@link #MAX_VEIL}. */
static float veilAlpha(float haze, float strength) {
return Math.max(0.0f, Math.min(MAX_VEIL, haze * strength * VEIL_SCALE));
}
/** Суммарная непрозрачность обмерзания у самого края. */
static float frostTotal(float strength, float frostStrength) {
return Math.max(0.0f, FROST_TOTAL * strength * frostStrength);
}
/** Непрозрачность одной полосы обмерзания: все полосы вместе дают {@code total}. */
static float frostBandAlpha(float total) {
return total / FROST_BANDS;
}
/** Толщина i-й полосы: первая на всю глубину, каждая следующая тоньше, поэтому альфа копится у края. */
static int bandThickness(int i, int bands, int depth) {
return Math.max(1, Math.round(depth * (1.0f - (float) Math.sqrt(i / (double) bands))));
}
static int withAlpha(int argb, float alpha) {
int a = Math.round(alpha * 255.0f);
if (a < 0) a = 0;
if (a > 255) a = 255;
return (a << 24) | (argb & 0x00FFFFFF);
}
/** Доля полос обмерзания, покрывающая точку на distance от края экрана. */
static float edgeCoverage(float distance, float depth, int bands) {
if (bands <= 0 || depth <= 0.0f) return 0.0f;
if (distance <= 0.0f) return 1.0f;
int covered = 0;
for (int i = 0; i < bands; i++) {
float inset = depth * (1.0f - (float) Math.sqrt(i / (double) bands));
if (distance < inset) covered++;
}
return covered / (float) bands;
}
}

View file

@ -0,0 +1,119 @@
package dev.loki.lovisual.features.module.modules.visuals.weather.blizzard;
import dev.loki.lovisual.render.engine.color.RenderColor;
import dev.loki.lovisual.render.engine.uniform.MeshBuilder;
import org.joml.Quaternionf;
import org.joml.Vector3f;
/**
* Рендер снежинок: биллборд-квады, вытянутые по вектору скорости частицы,
* с затуханием у камеры и на границе зоны.
*/
final class BlizzardRenderer {
private BlizzardRenderer() {
}
static void render(MeshBuilder mesh, SnowFlakes flakes, Quaternionf camRot,
double camX, double camY, double camZ, float radius,
int argb, float strength, float sizeMul, float opacity) {
if (mesh == null || flakes.count == 0 || strength <= 0.001f || opacity <= 0.004f) return;
int r = (argb >>> 16) & 0xFF;
int g = (argb >>> 8) & 0xFF;
int b = argb & 0xFF;
Vector3f right = new Vector3f(1.0f, 0.0f, 0.0f).rotate(camRot);
Vector3f up = new Vector3f(0.0f, 1.0f, 0.0f).rotate(camRot);
Vector3f forward = new Vector3f(0.0f, 0.0f, -1.0f).rotate(camRot);
for (int i = 0; i < flakes.count; i++) {
double dx = flakes.x[i] - camX;
double dy = flakes.y[i] - camY;
double dz = flakes.z[i] - camZ;
float dist = (float) Math.sqrt(dx * dx + dy * dy + dz * dz);
if (dist < 0.2f) continue;
float facing = (float) (dx * forward.x + dy * forward.y + dz * forward.z);
if (facing <= 0.0f) continue;
float speedX = flakes.vx[i];
float speedY = flakes.vy[i];
float speedZ = flakes.vz[i];
float along = right.x * speedX + right.y * speedY + right.z * speedZ;
float across = up.x * speedX + up.y * speedY + up.z * speedZ;
float alongLen = (float) Math.sqrt(along * along + across * across);
if (alongLen < 1.0E-4f) {
along = 0.0f;
across = -1.0f;
alongLen = 1.0f;
}
along /= alongLen;
across /= alongLen;
// Единичный вектор скорости в плоскости биллборда.
float dirX = right.x * along + up.x * across;
float dirY = right.y * along + up.y * across;
float dirZ = right.z * along + up.z * across;
// Поперёк: forward x dir (оба перпендикулярны друг другу).
float sideX = forward.y * dirZ - forward.z * dirY;
float sideY = forward.z * dirX - forward.x * dirZ;
float sideZ = forward.x * dirY - forward.y * dirX;
float sideLen = (float) Math.sqrt(sideX * sideX + sideY * sideY + sideZ * sideZ);
if (sideLen < 1.0E-4f) continue;
sideX /= sideLen;
sideY /= sideLen;
sideZ /= sideLen;
float speed = (float) Math.sqrt(speedX * speedX + speedY * speedY + speedZ * speedZ);
float halfWidth = halfWidth(flakes.size[i], sizeMul, dist);
float halfLength = halfLength(halfWidth, speed, sizeMul);
float alpha = flakeAlpha(dist, flakes.age[i], flakes.alpha[i], radius, opacity, strength);
if (alpha <= 0.0f) continue;
float cx = (float) flakes.x[i];
float cy = (float) flakes.y[i];
float cz = (float) flakes.z[i];
float ax = dirX * halfLength;
float ay = dirY * halfLength;
float az = dirZ * halfLength;
float bx = sideX * halfWidth;
float by = sideY * halfWidth;
float bz = sideZ * halfWidth;
RenderColor color = new RenderColor(((int) (alpha * 255.0f) << 24) | (r << 16) | (g << 8) | b);
mesh.ensureQuadCapacity();
int i1 = mesh.vec3(cx - ax - bx, cy - ay - by, cz - az - bz).color(color).next();
int i2 = mesh.vec3(cx + ax - bx, cy + ay - by, cz + az - bz).color(color).next();
int i3 = mesh.vec3(cx + ax + bx, cy + ay + by, cz + az + bz).color(color).next();
int i4 = mesh.vec3(cx - ax + bx, cy - ay + by, cz - az + bz).color(color).next();
mesh.quad(i1, i2, i3, i4);
}
}
/**
* Видимость снежинки: невидима у самой линзы, плавно проявляется с расстоянием и возрастом,
* тает к границе зоны шторма; масштабируется прозрачностью и силой шторма, не бывает полностью непрозрачной.
* Слишком слабые снежинки возвращают 0, чтобы их не рисовать.
*/
static float flakeAlpha(float dist, float ageSeconds, float baseAlpha, float radius, float opacity, float strength) {
float edgeStart = radius * 0.62f;
float edgeSpan = Math.max(0.001f, radius * 1.05f - edgeStart);
float edge = 1.0f - clamp01((dist - edgeStart) / edgeSpan);
float near = clamp01((dist - 0.35f) / 0.5f);
float ageFade = Math.min(1.0f, ageSeconds / 0.45f);
float alpha = opacity * strength * baseAlpha * edge * near * ageFade;
if (alpha <= 0.008f) return 0.0f;
return Math.min(0.95f, alpha);
}
/** Полуширина снежинки; вдали не тоньше доли пикселя, чтобы не пропадала. */
static float halfWidth(float flakeSize, float sizeMul, float dist) {
float hw = (float) (0.045 * flakeSize) * sizeMul;
return Math.max(hw, dist * 0.0016f);
}
/** Полудлина штриха: быстрее снежинка, длиннее след, но не больше 0.35 блока. */
static float halfLength(float halfWidth, float speed, float sizeMul) {
return halfWidth + Math.min(0.35f, speed * 0.022f * sizeMul);
}
private static float clamp01(float v) {
if (v < 0.0f) return 0.0f;
return v > 1.0f ? 1.0f : v;
}
}

View file

@ -0,0 +1,119 @@
package dev.loki.lovisual.features.module.modules.visuals.weather.blizzard;
import java.util.Random;
/**
* Чистая физика снежинок: спавн в цилиндре вокруг камеры, ветер с порывами,
* турбулентность и рецикл при выходе из зоны. Без зависимостей от Minecraft.
*/
final class SnowFlakes {
static final int MAX_FLAKES = 4000;
static final float FALL_SPEED = 1.6f;
static final float AREA_FACTOR = 3.0f;
static final double SPAWN_ABOVE = 8.0;
static final double SPAWN_BELOW = 6.0;
static final double RECYCLE_MARGIN = 1.25;
/** Горизонтальный ветер с лёгким вертикальным сносом. */
record Wind(double x, double y, double z) {
static final Wind ZERO = new Wind(0.0, 0.0, 0.0);
}
final double[] x = new double[MAX_FLAKES];
final double[] y = new double[MAX_FLAKES];
final double[] z = new double[MAX_FLAKES];
final float[] vx = new float[MAX_FLAKES];
final float[] vy = new float[MAX_FLAKES];
final float[] vz = new float[MAX_FLAKES];
final float[] fall = new float[MAX_FLAKES];
final float[] size = new float[MAX_FLAKES];
final float[] alpha = new float[MAX_FLAKES];
final float[] phase = new float[MAX_FLAKES];
final float[] rate = new float[MAX_FLAKES];
final float[] age = new float[MAX_FLAKES];
int count;
private final Random random = new Random(1L);
void clear() {
count = 0;
}
/** Фиксация seed — детерминизм для тестов. */
void reseed(long seed) {
random.setSeed(seed);
}
/** Плавный разгон (0.45/с) и затухание (0.6/с) силы шторма к цели, без перелёта. */
static float ramp(float current, float target, float dt) {
if (current < target) return Math.min(target, current + 0.45f * dt);
return Math.max(target, current - 0.6f * dt);
}
/** Целевое число частиц: площадь цилиндра * плотность * доля силы шторма. */
static int targetCount(float radius, float density, float strength) {
if (radius <= 0f || density <= 0f || strength <= 0f) return 0;
double raw = Math.PI * radius * radius * AREA_FACTOR * density * Math.min(1.0f, strength);
if (raw >= MAX_FLAKES) return MAX_FLAKES;
return (int) raw;
}
/** Ветер с порывами: направление медленно кружит, скорость дышит несколькими синусами. */
static Wind wind(float seconds, float windPower, float strength) {
if (strength <= 0f || windPower <= 0f) return Wind.ZERO;
double angle = seconds * 0.17 + Math.sin(seconds * 0.45) * 0.35;
double gust = 1.0 + Math.sin(seconds * 0.9) * 0.35 + Math.sin(seconds * 2.3) * 0.15;
double speed = windPower * 14.0 * gust * strength;
return new Wind(Math.cos(angle) * speed, Math.sin(seconds * 2.3) * speed * 0.12, Math.sin(angle) * speed);
}
/** Амплитуда боковой турбулентности, зависящей от мощности ветра. */
static float turbulence(float windPower) {
return 0.25f + 0.55f * Math.max(0f, windPower);
}
void update(float dt, double camX, double camY, double camZ, float radius,
Wind wind, float turbulence, float speedMul, int target, float seconds) {
if (dt <= 0f || target < 0) return;
if (count > target) count = target;
while (count < target) spawn(count++, camX, camY, camZ, radius);
double recycle = radius * RECYCLE_MARGIN;
double recycleSq = recycle * recycle;
for (int i = 0; i < count; i++) {
float wobbleX = (float) Math.sin(seconds * rate[i] + phase[i]) * turbulence * speedMul;
float wobbleZ = (float) Math.cos(seconds * rate[i] * 0.83f + phase[i] * 1.7f) * turbulence * speedMul;
vx[i] = (float) wind.x() + wobbleX;
vy[i] = (float) wind.y() - FALL_SPEED * fall[i] * speedMul;
vz[i] = (float) wind.z() + wobbleZ;
x[i] += vx[i] * dt;
y[i] += vy[i] * dt;
z[i] += vz[i] * dt;
age[i] += dt;
double dx = x[i] - camX;
double dz = z[i] - camZ;
if (dx * dx + dz * dz > recycleSq
|| y[i] < camY - SPAWN_BELOW
|| y[i] > camY + SPAWN_ABOVE) {
spawn(i, camX, camY, camZ, radius);
}
}
}
private void spawn(int i, double camX, double camY, double camZ, float radius) {
double angle = random.nextDouble() * Math.PI * 2.0;
double dist = radius * Math.sqrt(random.nextDouble());
x[i] = camX + Math.cos(angle) * dist;
z[i] = camZ + Math.sin(angle) * dist;
y[i] = camY + SPAWN_ABOVE - random.nextDouble() * (SPAWN_ABOVE + SPAWN_BELOW);
vx[i] = 0f;
vy[i] = -FALL_SPEED;
vz[i] = 0f;
fall[i] = 0.6f + random.nextFloat() * 0.8f;
size[i] = 0.75f + random.nextFloat() * 0.55f;
alpha[i] = 0.35f + random.nextFloat() * 0.55f;
phase[i] = random.nextFloat() * (float) (Math.PI * 2.0);
rate[i] = 1.2f + random.nextFloat() * 2.2f;
age[i] = 0f;
}
}

View file

@ -31,6 +31,21 @@ public enum AnimatedRenderColors {
THEME
}
/** Режим по имени варианта настройки ("Rainbow", "LightRainbow", "DoubleColor"...); неизвестное имя даёт STATIC. */
public static Mode modeOf(String option) {
if (option == null) return Mode.STATIC;
return switch (option) {
case "Rainbow" -> Mode.RAINBOW;
case "LightRainbow" -> Mode.LIGHT_RAINBOW;
case "Sky" -> Mode.SKY;
case "Fade" -> Mode.FADE;
case "DoubleColor" -> Mode.DOUBLE_COLOR;
case "Analogous" -> Mode.ANALOGOUS;
case "Theme" -> Mode.THEME;
default -> Mode.STATIC;
};
}
public static boolean usesSecondary(Mode mode) {
return mode == Mode.DOUBLE_COLOR || mode == Mode.ANALOGOUS;
}

View file

@ -3635,6 +3635,7 @@
"module.spatialgui.description": "Renders the interface as a floating panel in 3D space.",
"module.trinket.description": "Attaches decorative trinkets to your character.",
"module.wet_world.description": "Makes surfaces look wet during rain.",
"module.elytratrails.description": "Streams glowing trails from the elytra wing tips while gliding.",
"module.projectiletrails.description": "Draws fading trails behind arrows, pearls and other projectiles.",
"setting.projectiletrails.duration": "Duration (ms)",
"setting.projectiletrails.width": "Width",
@ -3684,5 +3685,38 @@
"setting.guipresence.share": "Share my menu",
"setting.guipresence.show_others": "Show other players' menus",
"setting.guipresence.distance": "Distance",
"setting.guipresence.scale": "Panel size"
"setting.guipresence.scale": "Panel size",
"module.blizzard.description": "Wind-driven snowstorm with white haze and frosted screen edges.",
"setting.elytratrails.others": "Other players",
"setting.elytratrails.duration": "Duration (ms)",
"setting.elytratrails.width": "Width",
"setting.elytratrails.glow": "Glow",
"setting.elytratrails.lines": "Outline",
"setting.elytratrails.line_width": "Outline width",
"setting.elytratrails.depth_test": "Depth test",
"setting.elytratrails.color_mode": "Color mode",
"setting.elytratrails.color": "Color",
"setting.elytratrails.color2": "Second color",
"setting.elytratrails.color_speed": "Color speed",
"setting.elytratrails.color_phase": "Color phase",
"setting.blizzard.intensity": "Intensity",
"setting.blizzard.density": "Density",
"setting.blizzard.speed": "Fall speed",
"setting.blizzard.wind": "Wind",
"setting.blizzard.radius": "Radius",
"setting.blizzard.flake_size": "Flake size",
"setting.blizzard.opacity": "Flake opacity",
"setting.blizzard.tint": "Tint",
"setting.blizzard.haze": "Haze",
"setting.blizzard.frost": "Frosted edges",
"setting.blizzard.frost_intensity": "Frost intensity",
"setting.blizzard.only_when_snowing": "Only when snowing",
"setting.elytratrails.color_mode.option.Static": "Static",
"setting.elytratrails.color_mode.option.Rainbow": "Rainbow",
"setting.elytratrails.color_mode.option.LightRainbow": "Light Rainbow",
"setting.elytratrails.color_mode.option.Sky": "Sky",
"setting.elytratrails.color_mode.option.Fade": "Fade",
"setting.elytratrails.color_mode.option.DoubleColor": "Double Color",
"setting.elytratrails.color_mode.option.Analogous": "Analogous",
"setting.elytratrails.color_mode.option.Theme": "Theme"
}

View file

@ -3426,6 +3426,7 @@
"module.spatialgui.description": "Рисует интерфейс плавающей панелью в 3D-пространстве.",
"module.trinket.description": "Крепит декоративные безделушки к вашему персонажу.",
"module.wet_world.description": "Делает поверхности мокрыми во время дождя.",
"module.elytratrails.description": "Тянет светящиеся шлейфы из концов крыльев элитры в полёте.",
"module.projectiletrails.description": "Рисует затухающие ленты за стрелами, жемчугом и другими снарядами.",
"setting.projectiletrails.duration": "Длительность (мс)",
"setting.projectiletrails.width": "Ширина",
@ -3475,5 +3476,38 @@
"setting.guipresence.share": "Показывать моё меню",
"setting.guipresence.show_others": "Показывать меню других игроков",
"setting.guipresence.distance": "Дистанция",
"setting.guipresence.scale": "Размер панели"
"setting.guipresence.scale": "Размер панели",
"module.blizzard.description": "Метель с ветром, белой дымкой и обмерзанием краёв экрана.",
"setting.elytratrails.others": "Другие игроки",
"setting.elytratrails.duration": "Длительность (мс)",
"setting.elytratrails.width": "Ширина",
"setting.elytratrails.glow": "Свечение",
"setting.elytratrails.lines": "Контур",
"setting.elytratrails.line_width": "Толщина контура",
"setting.elytratrails.depth_test": "Проверка глубины",
"setting.elytratrails.color_mode": "Режим цвета",
"setting.elytratrails.color": "Цвет",
"setting.elytratrails.color2": "Второй цвет",
"setting.elytratrails.color_speed": "Скорость цвета",
"setting.elytratrails.color_phase": "Фаза цвета",
"setting.blizzard.intensity": "Интенсивность",
"setting.blizzard.density": "Плотность",
"setting.blizzard.speed": "Скорость падения",
"setting.blizzard.wind": "Ветер",
"setting.blizzard.radius": "Радиус",
"setting.blizzard.flake_size": "Размер снежинок",
"setting.blizzard.opacity": "Прозрачность снежинок",
"setting.blizzard.tint": "Оттенок",
"setting.blizzard.haze": "Дымка",
"setting.blizzard.frost": "Обмерзание краёв",
"setting.blizzard.frost_intensity": "Сила обмерзания",
"setting.blizzard.only_when_snowing": "Только во время снега",
"setting.elytratrails.color_mode.option.Static": "Статичный",
"setting.elytratrails.color_mode.option.Rainbow": "Радуга",
"setting.elytratrails.color_mode.option.LightRainbow": "Светлая радуга",
"setting.elytratrails.color_mode.option.Sky": "Небо",
"setting.elytratrails.color_mode.option.Fade": "Плавный",
"setting.elytratrails.color_mode.option.DoubleColor": "Два цвета",
"setting.elytratrails.color_mode.option.Analogous": "Аналоговый",
"setting.elytratrails.color_mode.option.Theme": "Тема"
}

View file

@ -0,0 +1,81 @@
package dev.loki.lovisual.features.module.modules.visuals.effects.elytratrails;
import net.minecraft.world.phys.Vec3;
import org.junit.jupiter.api.Test;
import static org.junit.jupiter.api.Assertions.*;
/** Спецификация концов крыльев элитры: где они должны быть относительно планирующего игрока. */
class WingTipLocatorTest {
private static final double EPS = 1.0e-6;
private static final float[][] POSES = {{0, 0}, {37, -12}, {-120, 30}, {90, 55}, {200, -70}, {45, 89}};
/** Единичный вектор взгляда по правилам Minecraft: yaw 0 смотрит на +Z, pitch>0 вниз. */
private static Vec3 view(float yawDeg, float pitchDeg) {
double y = Math.toRadians(yawDeg), p = Math.toRadians(pitchDeg);
return new Vec3(-Math.sin(y) * Math.cos(p), -Math.sin(p), Math.cos(y) * Math.cos(p));
}
@Test
void tipsAreOneFullSpanApartInEveryPose() {
for (float[] pose : POSES) {
WingTipLocator.Tips t = WingTipLocator.tips(new Vec3(10, 64, -3), pose[0], pose[1]);
assertEquals(2.0 * WingTipLocator.HALF_SPAN, t.left().distanceTo(t.right()), EPS);
}
}
@Test
void wingSpanIsPerpendicularToTheFlightDirection() {
for (float[] pose : POSES) {
WingTipLocator.Tips t = WingTipLocator.tips(Vec3.ZERO, pose[0], pose[1]);
assertEquals(0.0, t.left().subtract(t.right()).dot(view(pose[0], pose[1])), EPS);
}
}
@Test
void wingsStayLevelLikeAnAeroplaneWithoutRoll() {
for (float[] pose : POSES) {
WingTipLocator.Tips t = WingTipLocator.tips(new Vec3(0, 70, 0), pose[0], pose[1]);
assertEquals(t.left().y, t.right().y, EPS);
}
}
@Test
void leftTipIsOnThePlayersLeftHandSide() {
// Facing +Z (yaw 0) the player's left is +X; facing -X (yaw 90) it is +Z.
WingTipLocator.Tips south = WingTipLocator.tips(Vec3.ZERO, 0.0f, 0.0f);
assertTrue(south.left().x > south.right().x);
WingTipLocator.Tips west = WingTipLocator.tips(Vec3.ZERO, 90.0f, 0.0f);
assertTrue(west.left().z > west.right().z);
}
@Test
void tipsAreSymmetricAroundAnAnchorBehindTheHead() {
Vec3 eye = new Vec3(5, 80, 5);
for (float[] pose : POSES) {
WingTipLocator.Tips t = WingTipLocator.tips(eye, pose[0], pose[1]);
Vec3 mid = t.left().add(t.right()).scale(0.5);
// The wing roots sit BACK blocks behind the eye along the flight direction.
assertEquals(-WingTipLocator.BACK, mid.subtract(eye).dot(view(pose[0], pose[1])), EPS);
}
}
@Test
void tipsFollowTheBodyWhenItMoves() {
WingTipLocator.Tips a = WingTipLocator.tips(new Vec3(0, 0, 0), 33.0f, -20.0f);
WingTipLocator.Tips b = WingTipLocator.tips(new Vec3(7, -2, 4), 33.0f, -20.0f);
assertEquals(7.0, b.left().x - a.left().x, EPS);
assertEquals(-2.0, b.right().y - a.right().y, EPS);
assertEquals(4.0, b.right().z - a.right().z, EPS);
}
@Test
void lookingStraightDownOrUpStillGivesAMirroredPair() {
for (float pitch : new float[]{90.0f, -90.0f}) {
WingTipLocator.Tips t = WingTipLocator.tips(Vec3.ZERO, 30.0f, pitch);
assertEquals(2.0 * WingTipLocator.HALF_SPAN, t.left().distanceTo(t.right()), EPS);
assertFalse(Double.isNaN(t.left().x) || Double.isNaN(t.right().z));
}
}
}

View file

@ -0,0 +1,63 @@
package dev.loki.lovisual.features.module.modules.visuals.effects.elytratrails;
import org.junit.jupiter.api.Test;
import java.util.UUID;
import static org.junit.jupiter.api.Assertions.*;
/** Спецификация хранилища следов крыльев: по ленте на каждое крыло каждого игрока, старое исчезает. */
class WingTrailStoreTest {
private static final UUID A = UUID.fromString("00000000-0000-0000-0000-00000000000a");
private static final UUID B = UUID.fromString("00000000-0000-0000-0000-00000000000b");
@Test
void everyWingOfEveryPlayerHasItsOwnRibbon() {
WingTrailStore s = new WingTrailStore();
s.sample(A, WingTrailStore.LEFT, 0, 0, 0, 0L, 1000L);
s.sample(A, WingTrailStore.RIGHT, 2, 0, 0, 0L, 1000L);
s.sample(B, WingTrailStore.LEFT, 9, 0, 0, 0L, 1000L);
assertEquals(3, s.snapshot().size());
assertEquals(2, s.trackCount());
}
@Test
void leftAndRightHistoriesDoNotMix() {
WingTrailStore s = new WingTrailStore();
for (int i = 0; i < 4; i++) s.sample(A, WingTrailStore.LEFT, i * 0.5, 0, 0, i * 20L, 1000L);
s.sample(A, WingTrailStore.RIGHT, 5, 0, 0, 0L, 1000L);
var sizes = s.snapshot().stream().map(t -> t.size()).sorted().toList();
assertEquals(java.util.List.of(1, 4), sizes);
}
@Test
void ribbonOfAPlayerWhoStoppedGlidingFadesThenDisappears() {
WingTrailStore s = new WingTrailStore();
s.sample(A, WingTrailStore.LEFT, 0, 0, 0, 0L, 500L);
s.sample(A, WingTrailStore.RIGHT, 2, 0, 0, 0L, 500L);
s.prune(400L, 500L);
assertEquals(2, s.snapshot().size(), "still visible while fading");
s.prune(900L, 500L);
assertTrue(s.snapshot().isEmpty());
assertEquals(0, s.trackCount());
}
@Test
void oneWingCanOutliveTheOther() {
WingTrailStore s = new WingTrailStore();
s.sample(A, WingTrailStore.LEFT, 0, 0, 0, 0L, 500L);
s.sample(A, WingTrailStore.RIGHT, 2, 0, 0, 400L, 500L);
s.prune(700L, 500L);
assertEquals(1, s.snapshot().size());
assertEquals(1, s.trackCount());
}
@Test
void clearForgetsEveryone() {
WingTrailStore s = new WingTrailStore();
s.sample(A, WingTrailStore.LEFT, 0, 0, 0, 0L, 1000L);
s.clear();
assertTrue(s.snapshot().isEmpty());
}
}

View file

@ -0,0 +1,70 @@
package dev.loki.lovisual.features.module.modules.visuals.effects.projectiletrails;
import org.junit.jupiter.api.Test;
import static org.junit.jupiter.api.Assertions.*;
/** Спецификация ProjectileTrails: какие снаряды получают след и когда след исчезает. */
class ProjectileTrailStoreTest {
@Test
void knownProjectilesMapToSettingKeys() {
assertEquals("arrow", TrailStore.kindOfPath("arrow"));
assertEquals("arrow", TrailStore.kindOfPath("spectral_arrow"));
assertEquals("trident", TrailStore.kindOfPath("trident"));
assertEquals("pearl", TrailStore.kindOfPath("ender_pearl"));
assertEquals("snowball", TrailStore.kindOfPath("snowball"));
assertEquals("egg", TrailStore.kindOfPath("egg"));
assertEquals("potion", TrailStore.kindOfPath("splash_potion"));
assertEquals("potion", TrailStore.kindOfPath("lingering_potion"));
assertEquals("wind_charge", TrailStore.kindOfPath("wind_charge"));
assertEquals("wind_charge", TrailStore.kindOfPath("breeze_wind_charge"));
assertEquals("fireball", TrailStore.kindOfPath("fireball"));
assertEquals("fireball", TrailStore.kindOfPath("small_fireball"));
assertEquals("firework", TrailStore.kindOfPath("firework_rocket"));
}
@Test
void unrelatedEntitiesGetNoTrail() {
assertNull(TrailStore.kindOfPath("zombie"));
assertNull(TrailStore.kindOfPath("item"));
assertNull(TrailStore.kindOfPath("experience_bottle"));
}
@Test
void movingProjectileBuildsATrailThatThenFadesAway() {
TrailStore store = new TrailStore();
for (int i = 0; i < 5; i++) store.sample(7, i * 0.5, 0, 0, i * 50L, 400L);
assertEquals(5, store.tracks().get(7).size());
store.prune(1000L, 400L, id -> true);
assertTrue(store.tracks().isEmpty() || store.tracks().get(7).size() <= 5);
}
@Test
void trailOfAnExistingProjectileIsKeptEvenIfItStandsStill() {
TrailStore store = new TrailStore();
store.sample(1, 0, 0, 0, 0L, 400L);
store.prune(100L, 400L, id -> true);
assertEquals(1, store.tracks().size());
}
@Test
void trailOfAGoneProjectileSmoulderesThenIsRemoved() {
TrailStore store = new TrailStore();
store.sample(1, 0, 0, 0, 0L, 400L);
store.sample(1, 1, 0, 0, 100L, 400L);
store.prune(300L, 400L, id -> false);
assertEquals(1, store.tracks().size(), "still fading");
store.prune(900L, 400L, id -> false);
assertTrue(store.tracks().isEmpty());
}
@Test
void tracksOfDifferentProjectilesAreIndependent() {
TrailStore store = new TrailStore();
store.sample(1, 0, 0, 0, 0L, 400L);
store.sample(2, 5, 5, 5, 0L, 400L);
assertEquals(2, store.tracks().size());
assertNotSame(store.tracks().get(1), store.tracks().get(2));
}
}

View file

@ -0,0 +1,63 @@
package dev.loki.lovisual.features.module.modules.visuals.effects.ribbon;
import org.junit.jupiter.api.Test;
import static org.junit.jupiter.api.Assertions.*;
/** Спецификация следа: что должен делать буфер точек, независимо от реализации. */
class TrackBufferTest {
@Test
void firstSampleIsAlwaysKept() {
TrackBuffer b = new TrackBuffer();
assertTrue(b.add(1, 2, 3, 100L, 500L));
assertEquals(1, b.size());
}
@Test
void jitterBelowMinStepIsIgnoredButMovementIsKept() {
TrackBuffer b = new TrackBuffer();
b.add(0, 0, 0, 0L, 1000L);
assertFalse(b.add(TrackBuffer.MIN_STEP * 0.5, 0, 0, 16L, 1000L));
assertTrue(b.add(TrackBuffer.MIN_STEP * 2, 0, 0, 32L, 1000L));
assertEquals(2, b.size());
}
@Test
void pointsOlderThanLifetimeAreDropped() {
TrackBuffer b = new TrackBuffer();
b.add(0, 0, 0, 0L, 500L);
b.add(1, 0, 0, 300L, 500L);
b.add(2, 0, 0, 700L, 500L);
assertEquals(2, b.size());
assertEquals(1.0, b.points().get(0).x(), 0.0);
}
@Test
void trimCanEmptyTheBuffer() {
TrackBuffer b = new TrackBuffer();
b.add(0, 0, 0, 0L, 500L);
b.trim(10_000L, 500L);
assertTrue(b.isEmpty());
}
@Test
void lastSeenTracksEveryCallEvenWhenNoPointWasAdded() {
TrackBuffer b = new TrackBuffer();
b.add(0, 0, 0, 0L, 1000L);
b.add(0, 0, 0, 250L, 1000L);
assertEquals(250L, b.lastSeen());
assertEquals(1, b.size());
}
@Test
void pointsAreOrderedTailToHead() {
TrackBuffer b = new TrackBuffer();
for (int i = 0; i < 5; i++) b.add(i, 0, 0, i * 10L, 1000L);
long prev = -1;
for (RibbonPoint p : b.points()) {
assertTrue(p.ms() > prev);
prev = p.ms();
}
}
}

View file

@ -0,0 +1,65 @@
package dev.loki.lovisual.features.module.modules.visuals.effects.ribbon;
import org.junit.jupiter.api.Test;
import static org.junit.jupiter.api.Assertions.*;
/** Спецификация затухания и сужения ленты: свежее яркое и широкое, старое прозрачное и узкое. */
class TrailRibbonTest {
@Test
void lifeFractionGoesFromOneToZeroOverTheLifetime() {
assertEquals(1.0f, TrailRibbon.lifeFraction(100L, 100L, 1000L), 1e-6f);
assertEquals(0.5f, TrailRibbon.lifeFraction(600L, 100L, 1000L), 1e-6f);
assertEquals(0.0f, TrailRibbon.lifeFraction(1100L, 100L, 1000L), 1e-6f);
}
@Test
void lifeFractionIsClampedAndSafeForZeroLifetime() {
assertEquals(0.0f, TrailRibbon.lifeFraction(9999L, 0L, 1000L), 0.0f);
assertEquals(1.0f, TrailRibbon.lifeFraction(0L, 500L, 1000L), 0.0f);
assertEquals(0.0f, TrailRibbon.lifeFraction(100L, 100L, 0L), 0.0f);
}
@Test
void freshPointKeepsItsColour() {
assertEquals(0x88FF66CC, TrailRibbon.fadeAlpha(0x88FF66CC, 1.0f));
}
@Test
void fadingOnlyTouchesAlphaNeverRgb() {
int faded = TrailRibbon.fadeAlpha(0xC8FF66CC, 0.3f);
assertEquals(0xFF66CC, faded & 0x00FFFFFF);
assertEquals(0, TrailRibbon.fadeAlpha(0xC8FF66CC, 0.0f) >>> 24);
}
@Test
void alphaDecreasesMonotonicallyWithAge() {
int previous = Integer.MAX_VALUE;
for (int i = 10; i >= 0; i--) {
int alpha = TrailRibbon.fadeAlpha(0xFFFFFFFF, i / 10.0f) >>> 24;
assertTrue(alpha <= previous);
previous = alpha;
}
}
@Test
void outOfRangeLifeIsClamped() {
assertEquals(0x88FF66CC, TrailRibbon.fadeAlpha(0x88FF66CC, 5.0f));
assertEquals(0x00FF66CC, TrailRibbon.fadeAlpha(0x88FF66CC, -5.0f));
}
@Test
void ribbonTapersTowardTheTailButNeverVanishes() {
double head = TrailRibbon.halfWidth(0.4f, 1.0f);
double tail = TrailRibbon.halfWidth(0.4f, 0.0f);
assertEquals(0.2, head, 1e-6);
assertTrue(tail > 0.0 && tail < head);
assertEquals(0.4 * 0.5 * 0.25, tail, 1e-6);
}
@Test
void halfWidthScalesLinearlyWithWidthSetting() {
assertEquals(2.0 * TrailRibbon.halfWidth(0.3f, 0.6f), TrailRibbon.halfWidth(0.6f, 0.6f), 1e-6);
}
}

View file

@ -0,0 +1,79 @@
package dev.loki.lovisual.features.module.modules.visuals.weather.blizzard;
import org.junit.jupiter.api.Test;
import static org.junit.jupiter.api.Assertions.*;
/** Спецификация экранных эффектов метели: белая дымка и обмерзание краёв. */
class BlizzardOverlayTest {
@Test
void alphaIsClampedAndTheTintColourIsKept() {
assertEquals(0xFF, BlizzardOverlay.withAlpha(0x00ABCDEF, 5.0f) >>> 24);
assertEquals(0x00, BlizzardOverlay.withAlpha(0xFFABCDEF, -1.0f) >>> 24);
assertEquals(0xABCDEF, BlizzardOverlay.withAlpha(0x12ABCDEF, 0.5f) & 0xFFFFFF);
}
@Test
void noHazeOrNoStormMeansNoVeil() {
assertEquals(0f, BlizzardOverlay.veilAlpha(0f, 1f), 0f);
assertEquals(0f, BlizzardOverlay.veilAlpha(1f, 0f), 0f);
}
@Test
void veilGrowsWithHazeAndStormAndIsCapped() {
assertTrue(BlizzardOverlay.veilAlpha(0.8f, 0.5f) > BlizzardOverlay.veilAlpha(0.4f, 0.5f));
assertTrue(BlizzardOverlay.veilAlpha(0.5f, 0.9f) > BlizzardOverlay.veilAlpha(0.5f, 0.3f));
assertEquals(BlizzardOverlay.MAX_VEIL, BlizzardOverlay.veilAlpha(100f, 100f), 0f);
assertTrue(BlizzardOverlay.veilAlpha(1f, 1f) < 1f, "the world must stay visible");
}
@Test
void veilIsProportionalBelowTheCap() {
assertEquals(2.0, BlizzardOverlay.veilAlpha(0.2f, 0.6f) / BlizzardOverlay.veilAlpha(0.1f, 0.6f), 1e-5);
}
@Test
void frostIsStrongestAtTheEdgeAndFadesInward() {
float depth = 100f;
int bands = BlizzardOverlay.FROST_BANDS;
assertEquals(1.0f, BlizzardOverlay.edgeCoverage(0f, depth, bands), 0f);
float previous = 1.0f;
for (float d = 0f; d <= depth; d += 5f) {
float c = BlizzardOverlay.edgeCoverage(d, depth, bands);
assertTrue(c <= previous + 1e-6f);
assertTrue(c >= 0f && c <= 1f);
previous = c;
}
assertEquals(0f, BlizzardOverlay.edgeCoverage(depth + 1f, depth, bands), 0f);
}
@Test
void frostCoverageNeedsBandsAndDepth() {
assertEquals(0f, BlizzardOverlay.edgeCoverage(1f, 100f, 0), 0f);
assertEquals(0f, BlizzardOverlay.edgeCoverage(1f, 0f, 8), 0f);
}
@Test
void frostBandsGetThinnerTowardTheEdgeSoAlphaAccumulatesThere() {
int depth = 80;
assertEquals(depth, BlizzardOverlay.bandThickness(0, BlizzardOverlay.FROST_BANDS, depth));
int previous = Integer.MAX_VALUE;
for (int i = 0; i < BlizzardOverlay.FROST_BANDS; i++) {
int t = BlizzardOverlay.bandThickness(i, BlizzardOverlay.FROST_BANDS, depth);
assertTrue(t <= previous);
assertTrue(t >= 1);
previous = t;
}
}
@Test
void frostBandsTogetherAddUpToTheConfiguredFrostAlpha() {
float strength = 0.8f, frost = 0.5f;
float total = BlizzardOverlay.frostTotal(strength, frost);
assertEquals(BlizzardOverlay.FROST_TOTAL * strength * frost, total, 1e-6f);
assertEquals(total, BlizzardOverlay.frostBandAlpha(total) * BlizzardOverlay.FROST_BANDS, 1e-6f);
assertEquals(0f, BlizzardOverlay.frostTotal(0f, 1f), 0f);
assertEquals(0f, BlizzardOverlay.frostTotal(1f, 0f), 0f);
}
}

View file

@ -0,0 +1,74 @@
package dev.loki.lovisual.features.module.modules.visuals.weather.blizzard;
import org.junit.jupiter.api.Test;
import static org.junit.jupiter.api.Assertions.*;
/** Спецификация видимости снежинки: не лезет в глаза у камеры, плавно появляется и тает на границе зоны. */
class BlizzardRendererTest {
private static final float R = 14f;
private static float alpha(float dist, float age, float base, float opacity, float strength) {
return BlizzardRenderer.flakeAlpha(dist, age, base, R, opacity, strength);
}
@Test
void flakesRightInFrontOfTheLensAreInvisible() {
assertEquals(0f, alpha(0.1f, 5f, 0.8f, 1f, 1f), 0f);
assertEquals(0f, alpha(0.35f, 5f, 0.8f, 1f, 1f), 0f);
}
@Test
void flakesFadeInWithDistanceFromTheCamera() {
assertTrue(alpha(0.6f, 5f, 0.8f, 1f, 1f) > alpha(0.45f, 5f, 0.8f, 1f, 1f));
assertTrue(alpha(2f, 5f, 0.8f, 1f, 1f) >= alpha(0.6f, 5f, 0.8f, 1f, 1f));
}
@Test
void flakesMeltAwayAtTheEdgeOfTheStormZone() {
assertTrue(alpha(R * 0.4f, 5f, 0.8f, 1f, 1f) > alpha(R * 0.9f, 5f, 0.8f, 1f, 1f));
assertEquals(0f, alpha(R * 1.2f, 5f, 0.8f, 1f, 1f), 0f);
}
@Test
void newlySpawnedFlakesAppearGraduallyInsteadOfPopping() {
assertEquals(0f, alpha(5f, 0f, 0.8f, 1f, 1f), 0f);
assertTrue(alpha(5f, 0.2f, 0.8f, 1f, 1f) < alpha(5f, 1f, 0.8f, 1f, 1f));
}
@Test
void opacityAndStormStrengthScaleVisibilityLinearly() {
float full = alpha(5f, 5f, 0.5f, 0.6f, 0.6f);
assertEquals(0.5, alpha(5f, 5f, 0.5f, 0.3f, 0.6f) / full, 1e-5);
assertEquals(0.5, alpha(5f, 5f, 0.5f, 0.6f, 0.3f) / full, 1e-5);
assertEquals(0f, alpha(5f, 5f, 0.5f, 0f, 1f), 0f);
assertEquals(0f, alpha(5f, 5f, 0.5f, 1f, 0f), 0f);
}
@Test
void aFlakeIsNeverFullyOpaque() {
assertTrue(alpha(5f, 5f, 1f, 1f, 1f) <= 0.95f);
}
@Test
void faintFlakesAreSkippedInsteadOfDrawnInvisibly() {
assertEquals(0f, alpha(5f, 5f, 0.35f, 0.01f, 0.2f), 0f);
}
@Test
void distantFlakesStayAtLeastAPixelWide() {
assertEquals(0.045f, BlizzardRenderer.halfWidth(1f, 1f, 5f), 1e-6f);
assertTrue(BlizzardRenderer.halfWidth(1f, 1f, 200f) >= 200f * 0.0016f - 1e-6f);
assertTrue(BlizzardRenderer.halfWidth(2f, 1f, 5f) > BlizzardRenderer.halfWidth(1f, 1f, 5f));
}
@Test
void fasterFlakesLeaveLongerStreaksUpToALimit() {
float slow = BlizzardRenderer.halfLength(0.05f, 2f, 1f);
float fast = BlizzardRenderer.halfLength(0.05f, 10f, 1f);
assertTrue(fast > slow);
assertEquals(0.05f + 0.35f, BlizzardRenderer.halfLength(0.05f, 1000f, 1f), 1e-6f);
assertEquals(0.05f, BlizzardRenderer.halfLength(0.05f, 0f, 1f), 1e-6f);
}
}

View file

@ -0,0 +1,218 @@
package dev.loki.lovisual.features.module.modules.visuals.weather.blizzard;
import org.junit.jupiter.api.Test;
import static org.junit.jupiter.api.Assertions.*;
/** Спецификация метели: сколько снежинок, как они падают, куда их сносит ветер. */
class SnowFlakesTest {
private static final SnowFlakes.Wind CALM = SnowFlakes.Wind.ZERO;
private static double meanY(SnowFlakes f) {
double sum = 0;
for (int i = 0; i < f.count; i++) sum += f.y[i];
return sum / f.count;
}
private static double meanX(SnowFlakes f) {
double sum = 0;
for (int i = 0; i < f.count; i++) sum += f.x[i];
return sum / f.count;
}
// ---- сколько снежинок
@Test
void noStormMeansNoFlakes() {
assertEquals(0, SnowFlakes.targetCount(0f, 1f, 1f));
assertEquals(0, SnowFlakes.targetCount(14f, 0f, 1f));
assertEquals(0, SnowFlakes.targetCount(14f, 1f, 0f));
assertEquals(0, SnowFlakes.targetCount(-3f, 1f, 1f));
}
@Test
void moreRadiusDensityOrStrengthMeansMoreFlakes() {
assertTrue(SnowFlakes.targetCount(20f, 1f, 0.5f) > SnowFlakes.targetCount(10f, 1f, 0.5f));
assertTrue(SnowFlakes.targetCount(10f, 2f, 0.5f) > SnowFlakes.targetCount(10f, 1f, 0.5f));
assertTrue(SnowFlakes.targetCount(10f, 1f, 0.8f) > SnowFlakes.targetCount(10f, 1f, 0.4f));
}
@Test
void halfTheIntensityGivesAboutHalfTheFlakes() {
double full = SnowFlakes.targetCount(10f, 1f, 1.0f);
double half = SnowFlakes.targetCount(10f, 1f, 0.5f);
assertEquals(0.5, half / full, 0.02);
}
@Test
void flakeCountIsCappedAndStrengthAboveOneChangesNothing() {
assertEquals(SnowFlakes.MAX_FLAKES, SnowFlakes.targetCount(32f, 3f, 1f));
assertEquals(SnowFlakes.targetCount(10f, 1f, 1f), SnowFlakes.targetCount(10f, 1f, 7f));
}
@Test
void activeFlakeCountFollowsTheTargetUpAndDown() {
SnowFlakes f = new SnowFlakes();
f.update(0.016f, 0, 64, 0, 14f, CALM, 0f, 1f, 500, 0f);
assertEquals(500, f.count);
f.update(0.016f, 0, 64, 0, 14f, CALM, 0f, 1f, 120, 0f);
assertEquals(120, f.count);
f.update(0.016f, 0, 64, 0, 14f, CALM, 0f, 1f, 0, 0f);
assertEquals(0, f.count);
}
// ---- где они живут
@Test
void freshFlakesSpawnInsideTheCylinderAroundTheCamera() {
SnowFlakes f = new SnowFlakes();
f.update(0.0001f, 100, 70, -50, 12f, CALM, 0f, 1f, 800, 0f);
for (int i = 0; i < f.count; i++) {
double dx = f.x[i] - 100, dz = f.z[i] + 50;
assertTrue(Math.sqrt(dx * dx + dz * dz) <= 12.0 + 1e-6);
assertTrue(f.y[i] >= 70 - SnowFlakes.SPAWN_BELOW - 1e-6 && f.y[i] <= 70 + SnowFlakes.SPAWN_ABOVE + 1e-6);
}
}
@Test
void flakesFollowAMovingCameraAndNeverLeaveTheStormVolume() {
SnowFlakes f = new SnowFlakes();
double cx = 0, cz = 0;
for (int step = 0; step < 600; step++) {
cx += 0.8;
cz -= 0.3;
f.update(0.02f, cx, 64, cz, 14f, SnowFlakes.wind(step * 0.02f, 1.0f, 1.0f), 0.5f, 1f, 600, step * 0.02f);
}
double limit = 14.0 * SnowFlakes.RECYCLE_MARGIN;
for (int i = 0; i < f.count; i++) {
double dx = f.x[i] - cx, dz = f.z[i] - cz;
assertTrue(dx * dx + dz * dz <= limit * limit + 1e-6, "flake outside the cylinder");
assertTrue(f.y[i] >= 64 - SnowFlakes.SPAWN_BELOW - 1e-6 && f.y[i] <= 64 + SnowFlakes.SPAWN_ABOVE + 1e-6);
}
}
// ---- как они движутся
@Test
void snowFallsDownwardInCalmAir() {
SnowFlakes f = new SnowFlakes();
f.update(0.0001f, 0, 64, 0, 14f, CALM, 0f, 1f, 200, 0f);
double before = meanY(f);
f.update(0.1f, 0, 64, 0, 14f, CALM, 0f, 1f, 200, 0.1f);
assertTrue(meanY(f) < before);
for (int i = 0; i < f.count; i++) assertTrue(f.vy[i] < 0f);
}
@Test
void speedSettingMakesSnowFallFaster() {
SnowFlakes slow = new SnowFlakes();
SnowFlakes fast = new SnowFlakes();
slow.update(0.0001f, 0, 64, 0, 14f, CALM, 0f, 0.5f, 200, 0f);
fast.update(0.0001f, 0, 64, 0, 14f, CALM, 0f, 2.0f, 200, 0f);
double slowBefore = meanY(slow), fastBefore = meanY(fast);
slow.update(0.1f, 0, 64, 0, 14f, CALM, 0f, 0.5f, 200, 0.1f);
fast.update(0.1f, 0, 64, 0, 14f, CALM, 0f, 2.0f, 200, 0.1f);
assertTrue(fastBefore - meanY(fast) > slowBefore - meanY(slow));
}
@Test
void windPushesSnowDownwind() {
SnowFlakes calm = new SnowFlakes();
SnowFlakes windy = new SnowFlakes();
calm.update(0.0001f, 0, 64, 0, 14f, CALM, 0f, 1f, 300, 0f);
windy.update(0.0001f, 0, 64, 0, 14f, CALM, 0f, 1f, 300, 0f);
double c0 = meanX(calm), w0 = meanX(windy);
calm.update(0.2f, 0, 64, 0, 14f, CALM, 0f, 1f, 300, 0.2f);
windy.update(0.2f, 0, 64, 0, 14f, new SnowFlakes.Wind(12, 0, 0), 0f, 1f, 300, 0.2f);
assertTrue(meanX(windy) - w0 > meanX(calm) - c0 + 1.0);
}
@Test
void sameSeedGivesTheSameStorm() {
SnowFlakes a = new SnowFlakes();
SnowFlakes b = new SnowFlakes();
a.reseed(42L);
b.reseed(42L);
a.update(0.05f, 1, 2, 3, 14f, CALM, 0.3f, 1f, 100, 1f);
b.update(0.05f, 1, 2, 3, 14f, CALM, 0.3f, 1f, 100, 1f);
for (int i = 0; i < 100; i++) {
assertEquals(a.x[i], b.x[i], 0.0);
assertEquals(a.y[i], b.y[i], 0.0);
}
}
@Test
void zeroTimeStepChangesNothing() {
SnowFlakes f = new SnowFlakes();
f.update(0.0001f, 0, 64, 0, 14f, CALM, 0f, 1f, 50, 0f);
double y0 = f.y[0];
f.update(0f, 0, 64, 0, 14f, CALM, 0f, 1f, 50, 0f);
assertEquals(y0, f.y[0], 0.0);
}
// ---- ветер
@Test
void noWindWithoutPowerOrStorm() {
assertEquals(SnowFlakes.Wind.ZERO, SnowFlakes.wind(3f, 0f, 1f));
assertEquals(SnowFlakes.Wind.ZERO, SnowFlakes.wind(3f, 1f, 0f));
}
private static double horizontal(SnowFlakes.Wind w) {
return Math.hypot(w.x(), w.z());
}
@Test
void windGrowsWithPowerAndStormStrength() {
double base = horizontal(SnowFlakes.wind(5f, 1.0f, 0.5f));
assertTrue(horizontal(SnowFlakes.wind(5f, 2.0f, 0.5f)) > base);
assertTrue(horizontal(SnowFlakes.wind(5f, 1.0f, 1.0f)) > base);
assertEquals(2.0, horizontal(SnowFlakes.wind(5f, 2.0f, 0.5f)) / base, 1e-9);
}
@Test
void gustsStayBoundedAndMostlyHorizontal() {
double cap = 1.0 * 14.0 * 1.5 * 1.0;
for (int i = 0; i < 400; i++) {
SnowFlakes.Wind w = SnowFlakes.wind(i * 0.37f, 1.0f, 1.0f);
assertTrue(horizontal(w) <= cap + 1e-9);
assertTrue(Math.abs(w.y()) <= horizontal(w) * 0.2 + 1e-9);
}
}
@Test
void windDirectionDriftsOverTime() {
SnowFlakes.Wind early = SnowFlakes.wind(0f, 1f, 1f);
SnowFlakes.Wind late = SnowFlakes.wind(30f, 1f, 1f);
double angleDelta = Math.abs(Math.atan2(late.z(), late.x()) - Math.atan2(early.z(), early.x()));
assertTrue(angleDelta > 0.5);
}
@Test
void strongerWindMeansMoreTurbulence() {
assertTrue(SnowFlakes.turbulence(2f) > SnowFlakes.turbulence(0.5f));
assertTrue(SnowFlakes.turbulence(0f) > 0f);
assertEquals(SnowFlakes.turbulence(0f), SnowFlakes.turbulence(-1f), 0f);
}
// ---- плавный разгон шторма
@Test
void stormRampsUpToTheIntensitySettingNotToFull() {
float s = 0f;
for (int i = 0; i < 400; i++) s = SnowFlakes.ramp(s, 0.4f, 0.016f);
assertEquals(0.4f, s, 1e-6f);
}
@Test
void rampNeverOvershootsAndFadesOutFasterThanItBuildsUp() {
float up = SnowFlakes.ramp(0f, 1f, 1f);
float down = 1f - SnowFlakes.ramp(1f, 0f, 1f);
assertTrue(up > 0f && up < 1f);
assertTrue(down > up, "fade-out is quicker than build-up");
assertEquals(0f, SnowFlakes.ramp(0.01f, 0f, 5f), 0f);
assertEquals(0.3f, SnowFlakes.ramp(0.3f, 0.3f, 1f), 0f);
assertEquals(0.5f, SnowFlakes.ramp(0.5f, 1f, 0f), 0f);
}
}