feat(mod): ElytraTrails and Blizzard reviewed and fixed, shared trail ribbon, spec-style tests
This commit is contained in:
parent
622984f619
commit
9661ff638d
25 changed files with 1591 additions and 96 deletions
28
mod/TODO.md
28
mod/TODO.md
|
|
@ -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.
|
||||
|
||||
## Открытые задачи
|
||||
### Проверить в игре (код готов, вживую не смотрели)
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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));
|
||||
}
|
||||
}
|
||||
|
|
@ -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)));
|
||||
}
|
||||
}
|
||||
|
|
@ -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();
|
||||
}
|
||||
}
|
||||
|
|
@ -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;
|
||||
};
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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));
|
||||
}
|
||||
trim(track, now, lifeMs);
|
||||
sample(entity.getId(), p.x, p.y, p.z, 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();
|
||||
}
|
||||
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);
|
||||
}
|
||||
|
||||
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);
|
||||
/** Чистит следы: пропавшие снаряды дотлевают, потом след удаляется. */
|
||||
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();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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) {
|
||||
}
|
||||
|
|
@ -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);
|
||||
}
|
||||
}
|
||||
|
|
@ -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,20 +7,43 @@ 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,
|
||||
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;
|
||||
|
|
@ -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]);
|
||||
|
|
@ -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());
|
||||
}
|
||||
}
|
||||
|
|
@ -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;
|
||||
}
|
||||
}
|
||||
|
|
@ -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;
|
||||
}
|
||||
}
|
||||
|
|
@ -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;
|
||||
}
|
||||
}
|
||||
|
|
@ -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;
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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"
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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": "Тема"
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -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());
|
||||
}
|
||||
}
|
||||
|
|
@ -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));
|
||||
}
|
||||
}
|
||||
|
|
@ -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();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -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);
|
||||
}
|
||||
}
|
||||
|
|
@ -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);
|
||||
}
|
||||
}
|
||||
|
|
@ -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);
|
||||
}
|
||||
}
|
||||
|
|
@ -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);
|
||||
}
|
||||
}
|
||||
Loading…
Add table
Add a link
Reference in a new issue