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 в мире перед игроком из цветных квадов дистанция, размер), снимок меню `ModulesMenuScreen.presenceSnapshot`. Мини-копия ClickGUI в мире перед игроком из цветных квадов
(без текста). Нужны привязанный аккаунт (`%link`) и мультиплеер. Не сделано: значок «LoVisual» у ника/в табе, названия модулей в панели, (без текста). Нужны привязанный аккаунт (`%link`) и мультиплеер. Не сделано: значок «LoVisual» у ника/в табе, названия модулей в панели,
просмотр видео (`.watch` у evo), деплой chat-service на прод СДЕЛАН 2026-10-02 (rsync backend + `docker compose up -d --build chat-service gateway`; на сервер не уехали накопленные правки accounts-service/configs-service, они ждут отдельного деплоя). просмотр видео (`.watch` у evo), деплой chat-service на прод СДЕЛАН 2026-10-02 (rsync backend + `docker compose up -d --build chat-service gateway`; на сервер не уехали накопленные правки accounts-service/configs-service, они ждут отдельного деплоя).
- [x] ElytraTrails и Blizzard (код пришёл от остановленных сессий, проверен и доработан; тесты написаны по спецификации, а не по реализации):
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` (общий с ножами), - [ ] Wings: режим «Модель» (Bedrock geo.json + animation.json, 14 видов; отчёт раздел 2.4). Шаги: `BedrockGeoParser` (общий с ножами),
`BedrockAnimation` + процедурные angel/demon/dragon, `WingsRenderer.renderModel`, настройки `wingSource`/`wingModel`, тесты. `BedrockAnimation` + процедурные angel/demon/dragon, `WingsRenderer.renderModel`, настройки `wingSource`/`wingModel`, тесты.
Ресурсы (локально): `ref/evo/mod/jar/assets/evolution/cosmetic/wings/`, `.../textures/cosmetic/wings/`. Ресурсы (локально): `ref/evo/mod/jar/assets/evolution/cosmetic/wings/`, `.../textures/cosmetic/wings/`.
@ -51,9 +59,23 @@
порядок работ из 8 шагов). Ресурсы: `ref/evo/mod/jar/assets/evolution/cosmetic/melee/`. порядок работ из 8 шагов). Ресурсы: `ref/evo/mod/jar/assets/evolution/cosmetic/melee/`.
- [ ] Быстрые модули P1: AutoSprint (есть `SprintControlEvent`), TotemCounter, EnchantGlint, PlaceAnimation, GodRays - [ ] Быстрые модули P1: AutoSprint (есть `SprintControlEvent`), TotemCounter, EnchantGlint, PlaceAnimation, GodRays
(референс `shaders/core/godrays`), ArmorColor. (референс `shaders/core/godrays`), ArmorColor.
- [ ] Дальше P1-P2: Blizzard, ElytraTrails (на нашем `Trails`), TotemSound, Sounds, InventorySort, StorageTracker, ConsumeESP, - [ ] Дальше P1-P2 — перенос модулей из evo поштучно (референс: `docs/EVO_REPORT.md` §1.2;
CustomHand, DynamicLights (проверить `Light`), FoodInfo, PatPat, Showcase. P3: Chunk Cache, Schematics, HitFix (риск античита), лицензия evo проприетарная — их ассеты/код в репо не кладём, только идеи):
Camera, Graffiti, Skin Wheel, Discord RPC, Replay, Trainer. Пропустить: GuiPresence. Полный список: отчёт, раздел 1.2. - [ ] **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) { AnimatedRenderColors.Mode parse(String mode) {
return switch (mode) { return AnimatedRenderColors.modeOf(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

@ -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.Module;
import dev.loki.lovisual.features.module.core.ModuleCategory; import dev.loki.lovisual.features.module.core.ModuleCategory;
import dev.loki.lovisual.features.module.core.ModuleInfo; import dev.loki.lovisual.features.module.core.ModuleInfo;
import dev.loki.lovisual.features.module.modules.visuals.effects.ribbon.TrailRibbon;
import dev.loki.lovisual.features.module.phase.WorldPhase; import dev.loki.lovisual.features.module.phase.WorldPhase;
import dev.loki.lovisual.render.engine.animation.AnimatedRenderColors; import dev.loki.lovisual.render.engine.animation.AnimatedRenderColors;
import dev.loki.lovisual.render.engine.renderer.Renderer3D; import dev.loki.lovisual.render.engine.renderer.Renderer3D;
@ -16,8 +17,6 @@ import net.minecraft.client.Minecraft;
import net.minecraft.world.entity.Entity; import net.minecraft.world.entity.Entity;
import net.minecraft.world.phys.Vec3; import net.minecraft.world.phys.Vec3;
import java.util.ArrayList;
import java.util.List;
import java.util.Map; import java.util.Map;
/** Затухающие ленты за стрелами, жемчугом, трезубцами и другими снарядами. */ /** Затухающие ленты за стрелами, жемчугом, трезубцами и другими снарядами. */
@ -69,27 +68,13 @@ public class ProjectileTrails extends Module {
store.prune(now, life, id -> mc.level.getEntity(id) != null); store.prune(now, life, id -> mc.level.getEntity(id) != null);
if (store.tracks().isEmpty()) return; if (store.tracks().isEmpty()) return;
int phase = Math.round((mc.player == null ? 0 : mc.player.tickCount + tickDelta) * 4.0f); 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 primary = color.getArgb();
int secondary = color2.getArgb(); int secondary = color2.getArgb();
int speed = colorSpeed.get(); int speed = colorSpeed.get();
List<TrailStore.Track> tracks = new ArrayList<>(store.tracks().values());
Vec3 cam = mc.gameRenderer.mainCamera().position(); 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, 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)); (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; 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.core.registries.BuiltInRegistries;
import net.minecraft.world.entity.Entity; import net.minecraft.world.entity.Entity;
import net.minecraft.world.entity.projectile.Projectile; import net.minecraft.world.entity.projectile.Projectile;
import net.minecraft.world.phys.Vec3; import net.minecraft.world.phys.Vec3;
import java.util.ArrayDeque; import java.util.HashMap;
import java.util.Iterator; import java.util.Iterator;
import java.util.Map; import java.util.Map;
import java.util.HashMap; import java.util.function.IntPredicate;
import java.util.function.Predicate;
/** История позиций летящих снарядов: точки с метками времени, устаревшие и пропавшие следы вычищаются. */ /** История позиций летящих снарядов по id сущности; пропавшие из мира снаряды дотлевают и удаляются. */
final class TrailStore { final class TrailStore {
/** Минимальный сдвиг между соседними точками (блоки), чтобы лента не копила шум. */ private final Map<Integer, TrackBuffer> tracks = new HashMap<>();
private static final double MIN_STEP = 0.04;
record Point(double x, double y, double z, long ms) { Map<Integer, TrackBuffer> tracks() {
}
static final class Track {
final ArrayDeque<Point> points = new ArrayDeque<>();
long lastSeen;
}
private final Map<Integer, Track> tracks = new HashMap<>();
Map<Integer, Track> tracks() {
return tracks; return tracks;
} }
@ -38,7 +27,11 @@ final class TrailStore {
/** Ключ вида снаряда для настроек: arrow, trident, pearl, snowball, egg, potion, fireball, wind_charge, firework. */ /** Ключ вида снаряда для настроек: arrow, trident, pearl, snowball, egg, potion, fireball, wind_charge, firework. */
static String kindOf(Entity entity) { static String kindOf(Entity entity) {
if (!(entity instanceof Projectile)) return null; 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("arrow")) return "arrow";
if (path.contains("trident")) return "trident"; if (path.contains("trident")) return "trident";
if (path.contains("pearl")) return "pearl"; if (path.contains("pearl")) return "pearl";
@ -51,33 +44,22 @@ final class TrailStore {
return null; return null;
} }
/** Добавляет текущую позицию снаряда (интерполированную) и отбрасывает старые точки. */ /** Добавляет текущую (интерполированную) позицию снаряда и отбрасывает старые точки. */
void sample(Entity entity, float partialTicks, long now, long lifeMs) { 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); Vec3 p = entity.getPosition(partialTicks);
Point last = track.points.peekLast(); sample(entity.getId(), p.x, p.y, p.z, now, lifeMs);
if (last == null || last.ms() != now && dist(last, p) >= MIN_STEP) { }
track.points.addLast(new Point(p.x, p.y, p.z, now));
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.RenderState;
import dev.loki.lovisual.render.engine.color.RenderColor; import dev.loki.lovisual.render.engine.color.RenderColor;
import dev.loki.lovisual.render.engine.pipeline.LoVisualRenderPipelines; 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 dev.loki.lovisual.render.engine.uniform.MeshBuilder;
import net.minecraft.world.phys.Vec3; import net.minecraft.world.phys.Vec3;
import java.util.ArrayList; import java.util.Collection;
import java.util.List; import java.util.List;
import java.util.function.IntBinaryOperator; import java.util.function.IntBinaryOperator;
/** Строит ленту, повёрнутую к камере, с затуханием и сужением к хвосту, плюс контурные линии по краям. */ /**
final class TrailRibbon { * Лента, повёрнутая к камере: сужается и гаснет к хвосту, с необязательным контуром по краям.
* Общий рендер для ProjectileTrails и ElytraTrails.
*/
public final class TrailRibbon {
private 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 * @param color цвет точки: (индекс от хвоста, число точек) -> ARGB
*/ */
static void render(Renderer3D renderer, List<TrailStore.Track> tracks, long now, long lifeMs, float width, public static void render(Renderer3D renderer, Collection<TrackBuffer> tracks, long now, long lifeMs, float width,
boolean fill, boolean glow, boolean lines, float lineWidth, boolean depth, Vec3 cam, boolean fill, boolean glow, boolean lines, float lineWidth, boolean depth, Vec3 cam,
IntBinaryOperator color) { IntBinaryOperator color) {
MeshBuilder fillMesh = null; MeshBuilder fillMesh = null;
if (fill) { if (fill) {
var pipeline = glow var pipeline = glow
@ -43,34 +65,32 @@ final class TrailRibbon {
} }
} }
if (fillMesh == null && lineMesh == null) return; if (fillMesh == null && lineMesh == null) return;
for (TrailStore.Track track : tracks) { for (TrackBuffer track : tracks) {
List<Point> pts = new ArrayList<>(track.points); List<RibbonPoint> pts = track.points();
if (pts.size() >= 2) draw(pts, now, lifeMs, width, fillMesh, lineMesh, cam, color); if (pts.size() >= 2) draw(pts, now, lifeMs, width, fillMesh, lineMesh, cam, color);
} }
} }
private static void draw(List<Point> pts, long now, long lifeMs, float width, MeshBuilder fill, MeshBuilder line, private static void draw(List<RibbonPoint> pts, long now, long lifeMs, float width, MeshBuilder fill,
Vec3 cam, IntBinaryOperator color) { MeshBuilder line, Vec3 cam, IntBinaryOperator color) {
int n = pts.size(); int n = pts.size();
double[][] left = new double[n][3]; double[][] left = new double[n][3];
double[][] right = new double[n][3]; double[][] right = new double[n][3];
int[] argb = new int[n]; int[] argb = new int[n];
for (int i = 0; i < n; i++) { for (int i = 0; i < n; i++) {
Point p = pts.get(i); RibbonPoint p = pts.get(i);
Point a = pts.get(Math.max(0, i - 1)); RibbonPoint a = pts.get(Math.max(0, i - 1));
Point b = pts.get(Math.min(n - 1, 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 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 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 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); double len = Math.sqrt(sx * sx + sy * sy + sz * sz);
if (len < 1e-6) len = 1.0; if (len < 1e-6) len = 1.0;
float life = 1.0f - Math.min(1.0f, (now - p.ms()) / (float) lifeMs); float life = lifeFraction(now, p.ms(), lifeMs);
double half = width * 0.5 * (0.25 + 0.75 * life) / len; double half = halfWidth(width, life) / len;
left[i] = new double[]{p.x() + sx * half, p.y() + sy * half, p.z() + sz * half}; 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}; right[i] = new double[]{p.x() - sx * half, p.y() - sy * half, p.z() - sz * half};
int base = color.applyAsInt(i, n); argb[i] = fadeAlpha(color.applyAsInt(i, n), life);
int alpha = Math.round(((base >>> 24) & 0xFF) * life * life);
argb[i] = (alpha << 24) | (base & 0x00FFFFFF);
} }
for (int i = 1; i < n; i++) { for (int i = 1; i < n; i++) {
if (fill != null) quad(fill, left[i - 1], right[i - 1], right[i], left[i], argb[i - 1], argb[i]); if (fill != null) 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 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) { public static boolean usesSecondary(Mode mode) {
return mode == Mode.DOUBLE_COLOR || mode == Mode.ANALOGOUS; 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.spatialgui.description": "Renders the interface as a floating panel in 3D space.",
"module.trinket.description": "Attaches decorative trinkets to your character.", "module.trinket.description": "Attaches decorative trinkets to your character.",
"module.wet_world.description": "Makes surfaces look wet during rain.", "module.wet_world.description": "Makes surfaces look wet during rain.",
"module.elytratrails.description": "Streams glowing trails from the elytra wing tips while gliding.",
"module.projectiletrails.description": "Draws fading trails behind arrows, pearls and other projectiles.", "module.projectiletrails.description": "Draws fading trails behind arrows, pearls and other projectiles.",
"setting.projectiletrails.duration": "Duration (ms)", "setting.projectiletrails.duration": "Duration (ms)",
"setting.projectiletrails.width": "Width", "setting.projectiletrails.width": "Width",
@ -3684,5 +3685,38 @@
"setting.guipresence.share": "Share my menu", "setting.guipresence.share": "Share my menu",
"setting.guipresence.show_others": "Show other players' menus", "setting.guipresence.show_others": "Show other players' menus",
"setting.guipresence.distance": "Distance", "setting.guipresence.distance": "Distance",
"setting.guipresence.scale": "Panel size" "setting.guipresence.scale": "Panel size",
"module.blizzard.description": "Wind-driven snowstorm with white haze and frosted screen edges.",
"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.spatialgui.description": "Рисует интерфейс плавающей панелью в 3D-пространстве.",
"module.trinket.description": "Крепит декоративные безделушки к вашему персонажу.", "module.trinket.description": "Крепит декоративные безделушки к вашему персонажу.",
"module.wet_world.description": "Делает поверхности мокрыми во время дождя.", "module.wet_world.description": "Делает поверхности мокрыми во время дождя.",
"module.elytratrails.description": "Тянет светящиеся шлейфы из концов крыльев элитры в полёте.",
"module.projectiletrails.description": "Рисует затухающие ленты за стрелами, жемчугом и другими снарядами.", "module.projectiletrails.description": "Рисует затухающие ленты за стрелами, жемчугом и другими снарядами.",
"setting.projectiletrails.duration": "Длительность (мс)", "setting.projectiletrails.duration": "Длительность (мс)",
"setting.projectiletrails.width": "Ширина", "setting.projectiletrails.width": "Ширина",
@ -3475,5 +3476,38 @@
"setting.guipresence.share": "Показывать моё меню", "setting.guipresence.share": "Показывать моё меню",
"setting.guipresence.show_others": "Показывать меню других игроков", "setting.guipresence.show_others": "Показывать меню других игроков",
"setting.guipresence.distance": "Дистанция", "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);
}
}