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
|
|
@ -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));
|
||||
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);
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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,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]);
|
||||
|
|
@ -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;
|
||||
}
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue