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

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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