feat(mod): align ElytraTrails and Blizzard with evo (real wing-tip matrices, ribbon styles, ground-aware snow, whiteout fog, hide vanilla weather), spec tests
This commit is contained in:
parent
9661ff638d
commit
60b4fedb29
34 changed files with 1937 additions and 623 deletions
17
mod/TODO.md
17
mod/TODO.md
|
|
@ -44,14 +44,15 @@
|
|||
дистанция, размер), снимок меню `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-ключи модулей. Не проверено вживую: вид лент и метели, положение концов крыльев,
|
||||
снег не отсекается «крышей» (метель идёт и в помещении).
|
||||
- [x] ElytraTrails и Blizzard сверены с оригиналом evo (`ref/evo/.../ElytraTrailsModule`, `BlizzardModule`), спец-тесты по поведению evo:
|
||||
ElytraTrails: концы крыльев по настоящей цепочке матриц модели элитры (`pose/WingTipLocator`, `ElytraPoses`), горизонтальная
|
||||
лента, сужение Grow/Shrink, колебания Wave/Helix, плавный старт, реакция на скорость, мягкие края, ореол, свой/чужие, разрыв ленты
|
||||
при скачке больше 8 блоков или паузе больше 300 мс, шаг записи 0.35, до 256 точек (`ribbon/RibbonShape`, `TrackBuffer.addContinuous`).
|
||||
Не сделано из evo: заливка шейдером (8 видов), раздельные цвета крыльев.
|
||||
Blizzard: снег только над землёй и под открытым небом (карта высот, спавн между землёй и потолком, нет снега в помещении),
|
||||
константы ветра/плотности/турбулентности как у evo, экспозиция неба, «Whiteout» (миксин тумана `BlizzardFogMixin`), скрытие ванильной
|
||||
погоды (`WeatherHideMixin`), рендер «капля» вдоль скорости, обмерзание 32 полосы, «только в снегопад» по силе осадков.
|
||||
Не сделано из evo: «Snow cover» (снег накапливается на земле, шейдер). Не проверено вживую: вид, туман, положение концов крыльев.
|
||||
- [ ] 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/`.
|
||||
|
|
|
|||
|
|
@ -0,0 +1,66 @@
|
|||
package dev.loki.lovisual.features.module.modules.visuals.effects.elytratrails;
|
||||
|
||||
import dev.loki.lovisual.features.module.modules.visuals.effects.ribbon.RibbonPoint;
|
||||
import dev.loki.lovisual.features.module.modules.visuals.effects.ribbon.RibbonShape;
|
||||
import dev.loki.lovisual.features.module.modules.visuals.effects.ribbon.TrailRibbon;
|
||||
|
||||
/** Вид ленты шлейфа элитры (по evo): горизонтальная лента с сужением, скоростью, плавным стартом и колебаниями. */
|
||||
final class ElytraRibbonStyle implements TrailRibbon.Style {
|
||||
|
||||
static final float GLOW_WIDTH_SCALE = 2.4f;
|
||||
|
||||
private final RibbonShape.Taper taper;
|
||||
private final float taperAmount;
|
||||
private final RibbonShape.Motion motion;
|
||||
private final float waveSize;
|
||||
private final float waveSpeed;
|
||||
private final boolean speedReactive;
|
||||
private final boolean fadeStart;
|
||||
private final boolean softEdges;
|
||||
private final boolean glow;
|
||||
|
||||
ElytraRibbonStyle(RibbonShape.Taper taper, float taperAmount, RibbonShape.Motion motion, float waveSize,
|
||||
float waveSpeed, boolean speedReactive, boolean fadeStart, boolean softEdges, boolean glow) {
|
||||
this.taper = taper;
|
||||
this.taperAmount = taperAmount;
|
||||
this.motion = motion;
|
||||
this.waveSize = waveSize;
|
||||
this.waveSpeed = waveSpeed;
|
||||
this.speedReactive = speedReactive;
|
||||
this.fadeStart = fadeStart;
|
||||
this.softEdges = softEdges;
|
||||
this.glow = glow;
|
||||
}
|
||||
|
||||
@Override
|
||||
public boolean horizontal() {
|
||||
return true;
|
||||
}
|
||||
|
||||
@Override
|
||||
public boolean softEdges() {
|
||||
return softEdges;
|
||||
}
|
||||
|
||||
@Override
|
||||
public float glowScale() {
|
||||
return glow ? GLOW_WIDTH_SCALE : 0.0f;
|
||||
}
|
||||
|
||||
@Override
|
||||
public double halfWidth(float width, int i, int n, RibbonPoint p, float life) {
|
||||
return width * 0.5 * RibbonShape.taper(taper, taperAmount, RibbonShape.tailness(i, n))
|
||||
* RibbonShape.speedWidth(speedReactive, p.speed());
|
||||
}
|
||||
|
||||
@Override
|
||||
public float alpha(int i, int n, RibbonPoint p, float life, float cameraDistance) {
|
||||
return life * RibbonShape.headRamp(fadeStart, i, n) * RibbonShape.nearFade(cameraDistance)
|
||||
* RibbonShape.speedAlpha(speedReactive, p.speed());
|
||||
}
|
||||
|
||||
@Override
|
||||
public float[] motion(RibbonPoint p) {
|
||||
return motion == RibbonShape.Motion.STRAIGHT ? null : RibbonShape.motion(motion, p.ms(), waveSize, waveSpeed);
|
||||
}
|
||||
}
|
||||
|
|
@ -8,12 +8,16 @@ 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.elytratrails.pose.ElytraPoses;
|
||||
import dev.loki.lovisual.features.module.modules.visuals.effects.elytratrails.pose.WingTipLocator;
|
||||
import dev.loki.lovisual.features.module.modules.visuals.effects.ribbon.RibbonShape;
|
||||
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.client.player.AbstractClientPlayer;
|
||||
import net.minecraft.world.entity.player.Player;
|
||||
import net.minecraft.world.phys.Vec3;
|
||||
|
||||
|
|
@ -30,12 +34,27 @@ public class ElytraTrails extends Module {
|
|||
|
||||
private final WingTrailStore store = new WingTrailStore();
|
||||
|
||||
private static final double MAX_TRACK_DISTANCE_SQ = 16384.0;
|
||||
|
||||
private final BooleanValue own = bool("elytraTrailsOwn", "own", true);
|
||||
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 NumberValue<Integer> duration = num("elytraTrailsDuration", "duration", 1200, 200, 4000);
|
||||
private final NumberValue<Float> width = num("elytraTrailsWidth", "width", 0.22f, 0.02f, 1.5f);
|
||||
private final ModeValue taper = modeSetting("elytraTrailsTaper", "taper", "Grow", "Uniform", "Grow", "Shrink");
|
||||
private final NumberValue<Float> taperAmount = visibleWhen(
|
||||
num("elytraTrailsTaperAmount", "taper_amount", 1.8f, 1.25f, 8.0f), () -> !"Uniform".equals(taper.get()));
|
||||
private final ModeValue motion = modeSetting("elytraTrailsMotion", "motion", "Straight", "Straight", "Wave", "Helix");
|
||||
private final NumberValue<Float> waveSize = visibleWhen(
|
||||
num("elytraTrailsWaveSize", "wave_size", 0.35f, 0.05f, 2.0f), () -> !"Straight".equals(motion.get()));
|
||||
private final NumberValue<Float> waveSpeed = visibleWhen(
|
||||
num("elytraTrailsWaveSpeed", "wave_speed", 3.0f, 0.5f, 12.0f), () -> !"Straight".equals(motion.get()));
|
||||
private final BooleanValue fadeStart = bool("elytraTrailsFadeStart", "fade_start", true);
|
||||
private final BooleanValue speedReactive = bool("elytraTrailsSpeedReactive", "speed_reactive", true);
|
||||
private final BooleanValue glow = bool("elytraTrailsGlow", "glow", true);
|
||||
private final BooleanValue softEdges = bool("elytraTrailsSoftEdges", "soft_edges", true);
|
||||
private final BooleanValue fill = bool("elytraTrailsFill", "fill", 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 NumberValue<Float> lineWidth = visibleWhen(num("elytraTrailsLineWidth", "line_width", 2.0f, 1.0f, 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");
|
||||
|
|
@ -60,13 +79,16 @@ public class ElytraTrails extends Module {
|
|||
if (!isEnabled() || mc.level == null || mc.gameRenderer.mainCamera() == null) return;
|
||||
long now = GameClock.millis();
|
||||
long life = duration.get();
|
||||
Vec3 cam = mc.gameRenderer.mainCamera().position();
|
||||
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);
|
||||
if (!(player instanceof AbstractClientPlayer wearer)) continue;
|
||||
if (!wearer.isFallFlying() || wearer.isInvisible() || !wearer.isAlive()) continue;
|
||||
if (!(wearer == mc.player ? own.get() : others.get())) continue;
|
||||
if (wearer.distanceToSqr(cam) > MAX_TRACK_DISTANCE_SQ) continue;
|
||||
WingTipLocator.Tips tips = WingTipLocator.tips(ElytraPoses.of(wearer, tickDelta));
|
||||
float speed = ElytraPoses.speed(wearer);
|
||||
store.sample(wearer.getUUID(), WingTrailStore.LEFT, tips.left().x, tips.left().y, tips.left().z, now, life, speed);
|
||||
store.sample(wearer.getUUID(), WingTrailStore.RIGHT, tips.right().x, tips.right().y, tips.right().z, now, life, speed);
|
||||
}
|
||||
store.prune(now, life);
|
||||
List<TrackBuffer> tracks = store.snapshot();
|
||||
|
|
@ -77,10 +99,13 @@ public class ElytraTrails extends Module {
|
|||
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(),
|
||||
ElytraRibbonStyle style = new ElytraRibbonStyle(RibbonShape.Taper.of(taper.get()), taperAmount.get(),
|
||||
RibbonShape.Motion.of(motion.get()), waveSize.get(), waveSpeed.get(), speedReactive.get(),
|
||||
fadeStart.get(), softEdges.get(), glow.get());
|
||||
TrailRibbon.render(renderer, tracks, now, life, width.get(), fill.get(), false, 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));
|
||||
phase + Math.round(i / (float) Math.max(1, n - 1) * 180), primary, secondary, true),
|
||||
style);
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1,39 +0,0 @@
|
|||
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)));
|
||||
}
|
||||
}
|
||||
|
|
@ -20,10 +20,19 @@ final class WingTrailStore {
|
|||
tracks.clear();
|
||||
}
|
||||
|
||||
/** Минимальный шаг записи (блоки), скачок и пауза, после которых лента начинается заново (как у evo). */
|
||||
static final double MIN_STEP = 0.35;
|
||||
static final double MAX_STEP = 8.0;
|
||||
static final long GAP_MS = 300L;
|
||||
|
||||
void sample(UUID id, int side, double x, double y, double z, long now, long lifeMs) {
|
||||
sample(id, side, x, y, z, now, lifeMs, 0.0f);
|
||||
}
|
||||
|
||||
void sample(UUID id, int side, double x, double y, double z, long now, long lifeMs, float speed) {
|
||||
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);
|
||||
pair[side].addContinuous(x, y, z, now, lifeMs, speed, MIN_STEP, MAX_STEP, GAP_MS);
|
||||
}
|
||||
|
||||
/** Убирает устаревшие точки; опустевшие ленты и игроки без лент удаляются. */
|
||||
|
|
|
|||
|
|
@ -0,0 +1,29 @@
|
|||
package dev.loki.lovisual.features.module.modules.visuals.effects.elytratrails.pose;
|
||||
|
||||
import net.minecraft.client.player.AbstractClientPlayer;
|
||||
import net.minecraft.util.Mth;
|
||||
import net.minecraft.world.phys.Vec3;
|
||||
|
||||
/** Собирает {@link WingTipLocator.Pose} из живого игрока так же, как это делает отрисовка элитры. */
|
||||
public final class ElytraPoses {
|
||||
|
||||
private ElytraPoses() {
|
||||
}
|
||||
|
||||
public static WingTipLocator.Pose of(AbstractClientPlayer player, float partial) {
|
||||
Vec3 pos = player.getPosition(partial);
|
||||
Vec3 delta = player.avatarState().deltaMovementOnPreviousTick().lerp(player.getDeltaMovement(), partial);
|
||||
Vec3 view = player.getViewVector(partial);
|
||||
float flying = WingTipLocator.flyingYRot(delta.x, delta.z, view.x, view.z);
|
||||
float bodyYaw = Mth.rotLerp(partial, player.yBodyRotO, player.yBodyRot);
|
||||
var wing = player.elytraAnimationState;
|
||||
return new WingTipLocator.Pose(pos.x, pos.y, pos.z, bodyYaw, player.getXRot(partial), player.getScale(),
|
||||
player.isCrouching(), player.isAutoSpinAttack(), player.getFallFlyingTicks() + partial, flying,
|
||||
player.tickCount + partial, wing.getRotX(partial), wing.getRotY(partial), wing.getRotZ(partial));
|
||||
}
|
||||
|
||||
/** Скорость игрока за тик, блоки (для ширины и прозрачности ленты). */
|
||||
public static float speed(AbstractClientPlayer player) {
|
||||
return (float) player.getDeltaMovement().length();
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,95 @@
|
|||
package dev.loki.lovisual.features.module.modules.visuals.effects.elytratrails.pose;
|
||||
|
||||
import net.minecraft.world.phys.Vec3;
|
||||
import org.joml.Matrix4f;
|
||||
import org.joml.Vector4f;
|
||||
|
||||
/**
|
||||
* Концы крыльев элитры по настоящей модели: та же цепочка преобразований, что у отрисовки элитры на теле
|
||||
* игрока (масштаб, поворот корпуса, наклон в полёте, поворот по траектории, крыло с анимацией взмаха),
|
||||
* как в evo. Чистая математика без Minecraft-классов, кроме {@link Vec3}.
|
||||
*/
|
||||
public final class WingTipLocator {
|
||||
|
||||
static final float MODEL_SCALE = 0.9375f;
|
||||
static final float BODY_OFFSET_Y = -1.501f;
|
||||
static final float WINGS_OFFSET_Z = 0.125f;
|
||||
static final float WING_PIVOT_X = 0.3125f;
|
||||
static final float WING_CROUCH_Y = 0.1875f;
|
||||
static final float TIP_X = 0.6875f;
|
||||
static final float TIP_Y = 1.3125f;
|
||||
static final float TIP_Z = 0.1875f;
|
||||
static final float SPIN_ATTACK_DEGREES = -75.0f;
|
||||
static final float FALL_FLYING_PITCH_OFFSET = -90.0f;
|
||||
static final float BODY_ROT_OFFSET = 180.0f;
|
||||
static final float FALL_FLYING_DIVISOR = 100.0f;
|
||||
static final float FLIGHT_ROT_EPSILON = 1.0E-5f;
|
||||
|
||||
private static final float DEG = (float) (Math.PI / 180.0);
|
||||
|
||||
private WingTipLocator() {
|
||||
}
|
||||
|
||||
/**
|
||||
* Состояние игрока для расчёта.
|
||||
*
|
||||
* @param x,y,z интерполированная позиция (ноги)
|
||||
* @param bodyYawDeg интерполированное рыскание корпуса, градусы
|
||||
* @param xRotDeg наклон взгляда, градусы
|
||||
* @param scale масштаб сущности (атрибут размера)
|
||||
* @param spinAttack крутится трезубцем (riptide)
|
||||
* @param fallFlyingTicks сколько тиков планирует (с долей кадра)
|
||||
* @param flyingYRot см. {@link #flyingYRot}
|
||||
* @param spinTime tickCount + partialTick для вращения riptide
|
||||
* @param rotX,rotY,rotZ анимация крыла: у правого крыла знаки rotY и rotZ меняются
|
||||
*/
|
||||
public record Pose(double x, double y, double z, float bodyYawDeg, float xRotDeg, float scale,
|
||||
boolean crouching, boolean spinAttack, float fallFlyingTicks, float flyingYRot,
|
||||
float spinTime, float rotX, float rotY, float rotZ) {
|
||||
}
|
||||
|
||||
public record Tips(Vec3 left, Vec3 right) {
|
||||
}
|
||||
|
||||
/** Доля «лёжа»: 0 в начале планирования, 1 после десяти тиков. */
|
||||
public static float fallFlyingScale(float fallFlyingTicks) {
|
||||
float f = fallFlyingTicks * fallFlyingTicks / FALL_FLYING_DIVISOR;
|
||||
return f < 0.0f ? 0.0f : Math.min(f, 1.0f);
|
||||
}
|
||||
|
||||
/**
|
||||
* Поворот корпуса вокруг вертикали при лёгком рысканьи: угол между горизонтальной скоростью и взглядом,
|
||||
* со знаком по векторному произведению; 0, если игрок почти стоит или смотрит вертикально.
|
||||
*/
|
||||
public static float flyingYRot(double deltaX, double deltaZ, double viewX, double viewZ) {
|
||||
double dl = deltaX * deltaX + deltaZ * deltaZ;
|
||||
double vl = viewX * viewX + viewZ * viewZ;
|
||||
if (dl <= 1.0E-5 || vl <= 1.0E-5) return 0.0f;
|
||||
double dot = (deltaX * viewX + deltaZ * viewZ) / Math.sqrt(dl * vl);
|
||||
double cross = deltaX * viewZ - deltaZ * viewX;
|
||||
return (float) (Math.signum(cross) * Math.acos(Math.min(1.0, Math.abs(dot))));
|
||||
}
|
||||
|
||||
public static Tips tips(Pose p) {
|
||||
Matrix4f body = new Matrix4f().scale(p.scale()).rotateY((BODY_ROT_OFFSET - p.bodyYawDeg()) * DEG);
|
||||
if (p.spinAttack()) {
|
||||
body.rotateX((FALL_FLYING_PITCH_OFFSET - p.xRotDeg()) * DEG).rotateY(p.spinTime() * SPIN_ATTACK_DEGREES * DEG);
|
||||
} else {
|
||||
body.rotateX(fallFlyingScale(p.fallFlyingTicks()) * (FALL_FLYING_PITCH_OFFSET - p.xRotDeg()) * DEG);
|
||||
if (Math.abs(p.flyingYRot()) > FLIGHT_ROT_EPSILON) body.rotateY(p.flyingYRot());
|
||||
}
|
||||
body.scale(-1.0f, -1.0f, 1.0f).scale(MODEL_SCALE).translate(0.0f, BODY_OFFSET_Y, WINGS_OFFSET_Z);
|
||||
float pivotY = p.crouching() ? WING_CROUCH_Y : 0.0f;
|
||||
Vec3 left = tip(body, p, WING_PIVOT_X, pivotY, p.rotZ(), p.rotY(), p.rotX(), -TIP_X);
|
||||
Vec3 right = tip(body, p, -WING_PIVOT_X, pivotY, -p.rotZ(), -p.rotY(), p.rotX(), TIP_X);
|
||||
return new Tips(left, right);
|
||||
}
|
||||
|
||||
private static Vec3 tip(Matrix4f body, Pose p, float pivotX, float pivotY, float rz, float ry, float rx,
|
||||
float tipX) {
|
||||
Matrix4f wing = new Matrix4f(body).translate(pivotX, pivotY, 0.0f).rotateZYX(rz, ry, rx);
|
||||
Vector4f v = new Vector4f(tipX, TIP_Y, TIP_Z, 1.0f);
|
||||
wing.transform(v);
|
||||
return new Vec3(p.x() + v.x, p.y() + v.y, p.z() + v.z);
|
||||
}
|
||||
}
|
||||
|
|
@ -1,5 +1,9 @@
|
|||
package dev.loki.lovisual.features.module.modules.visuals.effects.ribbon;
|
||||
|
||||
/** Точка следа: позиция в мире и время (мс) появления. */
|
||||
public record RibbonPoint(double x, double y, double z, long ms) {
|
||||
/** Точка следа: позиция в мире, время (мс) появления и скорость источника в этот момент. */
|
||||
public record RibbonPoint(double x, double y, double z, long ms, float speed) {
|
||||
|
||||
public RibbonPoint(double x, double y, double z, long ms) {
|
||||
this(x, y, z, ms, 0.0f);
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -0,0 +1,112 @@
|
|||
package dev.loki.lovisual.features.module.modules.visuals.effects.ribbon;
|
||||
|
||||
import java.util.Locale;
|
||||
|
||||
/**
|
||||
* Форма ленты шлейфа (как у evo ElytraTrails): сужение вдоль следа, реакция на скорость, плавное начало,
|
||||
* затухание у камеры и колебания. Чистые функции, без зависимостей от Minecraft.
|
||||
*/
|
||||
public final class RibbonShape {
|
||||
|
||||
static final float SPEED_MIN = 0.35f;
|
||||
static final float SPEED_MAX = 1.6f;
|
||||
static final float SPEED_WIDTH_FLOOR = 0.45f;
|
||||
static final float SPEED_ALPHA_FLOOR = 0.25f;
|
||||
static final float HEAD_RAMP_POINTS = 3.0f;
|
||||
static final float NEAR_FADE_START = 0.75f;
|
||||
static final float NEAR_FADE_RANGE = 1.5f;
|
||||
private static final float TAU = (float) (Math.PI * 2.0);
|
||||
|
||||
private RibbonShape() {
|
||||
}
|
||||
|
||||
/** Как меняется ширина от головы к хвосту. */
|
||||
public enum Taper {
|
||||
UNIFORM, GROW, SHRINK;
|
||||
|
||||
public static Taper of(String name) {
|
||||
if (name == null) return UNIFORM;
|
||||
return switch (name.toLowerCase(Locale.ROOT)) {
|
||||
case "grow" -> GROW;
|
||||
case "shrink" -> SHRINK;
|
||||
default -> UNIFORM;
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
/** Форма пути, в которую «вплетена» лента. */
|
||||
public enum Motion {
|
||||
STRAIGHT, WAVE, HELIX;
|
||||
|
||||
public static Motion of(String name) {
|
||||
if (name == null) return STRAIGHT;
|
||||
return switch (name.toLowerCase(Locale.ROOT)) {
|
||||
case "wave" -> WAVE;
|
||||
case "helix" -> HELIX;
|
||||
default -> STRAIGHT;
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
/** Удалённость точки от головы: 0 у самой новой, 1 у самой старой. */
|
||||
public static float tailness(int index, int count) {
|
||||
if (count <= 1) return 0.0f;
|
||||
return 1.0f - index / (float) (count - 1);
|
||||
}
|
||||
|
||||
/** Множитель ширины: у головы 1, у хвоста {@code amount} (Grow) или {@code 1/amount} (Shrink). */
|
||||
public static float taper(Taper mode, float amount, float tailness) {
|
||||
return switch (mode) {
|
||||
case GROW -> 1.0f + tailness * (amount - 1.0f);
|
||||
case SHRINK -> 1.0f + tailness * (1.0f / amount - 1.0f);
|
||||
default -> 1.0f;
|
||||
};
|
||||
}
|
||||
|
||||
/** 0 на медленном планировании, 1 на полной скорости. */
|
||||
public static float speedFactor(float speed) {
|
||||
float v = (speed - SPEED_MIN) / (SPEED_MAX - SPEED_MIN);
|
||||
return v < 0.0f ? 0.0f : Math.min(v, 1.0f);
|
||||
}
|
||||
|
||||
public static float speedWidth(boolean reactive, float speed) {
|
||||
return reactive ? SPEED_WIDTH_FLOOR + (1.0f - SPEED_WIDTH_FLOOR) * speedFactor(speed) : 1.0f;
|
||||
}
|
||||
|
||||
public static float speedAlpha(boolean reactive, float speed) {
|
||||
return reactive ? SPEED_ALPHA_FLOOR + (1.0f - SPEED_ALPHA_FLOOR) * speedFactor(speed) : 1.0f;
|
||||
}
|
||||
|
||||
/** Плавное начало: самые новые точки прозрачны и проявляются за три точки. */
|
||||
public static float headRamp(boolean fadeStart, int index, int count) {
|
||||
if (!fadeStart) return 1.0f;
|
||||
float v = (count - 1 - index) / HEAD_RAMP_POINTS;
|
||||
return v < 0.0f ? 0.0f : Math.min(v, 1.0f);
|
||||
}
|
||||
|
||||
/** Лента гаснет, когда камера вплотную к ней. */
|
||||
public static float nearFade(float distance) {
|
||||
float v = (distance - NEAR_FADE_START) / NEAR_FADE_RANGE;
|
||||
return v < 0.0f ? 0.0f : Math.min(v, 1.0f);
|
||||
}
|
||||
|
||||
/** Оставшаяся жизнь точки, линейно от 1 до 0. */
|
||||
public static float life(long now, long birthMs, long lifeMs) {
|
||||
if (lifeMs <= 0) return 0.0f;
|
||||
float v = 1.0f - (now - birthMs) / (float) lifeMs;
|
||||
return v < 0.0f ? 0.0f : Math.min(v, 1.0f);
|
||||
}
|
||||
|
||||
/**
|
||||
* Смещение точки от оси пути: {в сторону, по бинормали}. Зависит от времени рождения точки,
|
||||
* поэтому узор едет вместе с точкой, а не пляшет на месте.
|
||||
*/
|
||||
public static float[] motion(Motion mode, long birthMs, float amplitude, float frequency) {
|
||||
if (mode == Motion.STRAIGHT) return new float[]{0.0f, 0.0f};
|
||||
double cycles = (birthMs * 0.001 * frequency) % 1.0;
|
||||
float phase = (float) cycles * TAU;
|
||||
float side = (float) Math.sin(phase) * amplitude;
|
||||
float binormal = mode == Motion.HELIX ? (float) Math.cos(phase) * amplitude : 0.0f;
|
||||
return new float[]{side, binormal};
|
||||
}
|
||||
}
|
||||
|
|
@ -33,6 +33,32 @@ public final class TrackBuffer {
|
|||
return added;
|
||||
}
|
||||
|
||||
/** Больше точек лента не держит: самые старые вытесняются. */
|
||||
public static final int MAX_POINTS = 256;
|
||||
|
||||
/**
|
||||
* Как {@link #add}, но для непрерывной ленты (концы крыльев): сдвиг меньше {@code minStep} не записывается,
|
||||
* скачок дальше {@code maxStep} или пауза дольше {@code gapMs} (телепорт, перерыв в полёте) начинают ленту заново,
|
||||
* у каждой точки сохраняется скорость источника.
|
||||
*/
|
||||
public boolean addContinuous(double x, double y, double z, long now, long lifeMs, float speed,
|
||||
double minStep, double maxStep, long gapMs) {
|
||||
RibbonPoint last = points.peekLast();
|
||||
if (last != null && (now - lastSeen > gapMs || distance(last, x, y, z) > maxStep)) {
|
||||
points.clear();
|
||||
last = null;
|
||||
}
|
||||
lastSeen = now;
|
||||
boolean added = false;
|
||||
if (last == null || distance(last, x, y, z) >= minStep) {
|
||||
points.addLast(new RibbonPoint(x, y, z, now, speed));
|
||||
if (points.size() > MAX_POINTS) points.pollFirst();
|
||||
added = true;
|
||||
}
|
||||
trim(now, lifeMs);
|
||||
return added;
|
||||
}
|
||||
|
||||
public void trim(long now, long lifeMs) {
|
||||
while (!points.isEmpty() && now - points.peekFirst().ms() > lifeMs) points.pollFirst();
|
||||
}
|
||||
|
|
|
|||
|
|
@ -17,6 +17,57 @@ import java.util.function.IntBinaryOperator;
|
|||
*/
|
||||
public final class TrailRibbon {
|
||||
|
||||
/** Как рисуется лента: ширина и прозрачность по точке, ориентация, мягкие края, ореол и колебания. */
|
||||
public interface Style {
|
||||
/** true: ширина лежит горизонтально (как лента-флаг); false: лента повёрнута к камере. */
|
||||
boolean horizontal();
|
||||
|
||||
/** Края ленты тают в прозрачность вместо плоской полосы. */
|
||||
boolean softEdges();
|
||||
|
||||
/** Масштаб ширины отдельного ореола вокруг ленты; 0 без ореола. */
|
||||
float glowScale();
|
||||
|
||||
/** Полуширина в точке {@code i} из {@code n}. */
|
||||
double halfWidth(float width, int i, int n, RibbonPoint p, float life);
|
||||
|
||||
/** Множитель альфы цвета в точке. */
|
||||
float alpha(int i, int n, RibbonPoint p, float life, float cameraDistance);
|
||||
|
||||
/** Смещение точки: {в сторону, по бинормали}; null без колебаний. */
|
||||
default float[] motion(RibbonPoint p) {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
/** Прежний вид (ProjectileTrails): лента к камере, сужение к хвосту, альфа как life в квадрате. */
|
||||
public static final Style LEGACY = new Style() {
|
||||
@Override
|
||||
public boolean horizontal() {
|
||||
return false;
|
||||
}
|
||||
|
||||
@Override
|
||||
public boolean softEdges() {
|
||||
return false;
|
||||
}
|
||||
|
||||
@Override
|
||||
public float glowScale() {
|
||||
return 0.0f;
|
||||
}
|
||||
|
||||
@Override
|
||||
public double halfWidth(float width, int i, int n, RibbonPoint p, float life) {
|
||||
return TrailRibbon.halfWidth(width, life);
|
||||
}
|
||||
|
||||
@Override
|
||||
public float alpha(int i, int n, RibbonPoint p, float life, float cameraDistance) {
|
||||
return life * life;
|
||||
}
|
||||
};
|
||||
|
||||
private TrailRibbon() {
|
||||
}
|
||||
|
||||
|
|
@ -40,18 +91,32 @@ public final class TrailRibbon {
|
|||
return width * 0.5 * (0.25 + 0.75 * l);
|
||||
}
|
||||
|
||||
/** Лента прежнего вида. */
|
||||
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) {
|
||||
render(renderer, tracks, now, lifeMs, width, fill, glow, lines, lineWidth, depth, cam, color, LEGACY);
|
||||
}
|
||||
|
||||
/**
|
||||
* @param glow вся заливка складывается аддитивно (прежний «Glow»); ореол стиля рисуется отдельным слоем
|
||||
* @param color цвет точки: (индекс от хвоста, число точек) -> ARGB
|
||||
*/
|
||||
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) {
|
||||
IntBinaryOperator color, Style style) {
|
||||
var depthMode = depth ? Renderer3D.DepthMode.PRE_DEPTH : Renderer3D.DepthMode.MAIN;
|
||||
MeshBuilder fillMesh = null;
|
||||
if (fill) {
|
||||
var pipeline = glow
|
||||
? (depth ? LoVisualRenderPipelines.WORLD_COLORED_ADDITIVE_DEPTH : LoVisualRenderPipelines.WORLD_COLORED_ADDITIVE)
|
||||
: (depth ? LoVisualRenderPipelines.WORLD_COLORED_LIQUID_IGNORE : LoVisualRenderPipelines.WORLD_COLORED);
|
||||
fillMesh = renderer.batch(pipeline, depth ? Renderer3D.DepthMode.PRE_DEPTH : Renderer3D.DepthMode.MAIN);
|
||||
fillMesh = renderer.batch(pipeline, depthMode);
|
||||
}
|
||||
MeshBuilder glowMesh = null;
|
||||
if (fill && style.glowScale() > 0.0f) {
|
||||
glowMesh = renderer.batch(depth ? LoVisualRenderPipelines.WORLD_COLORED_ADDITIVE_DEPTH
|
||||
: LoVisualRenderPipelines.WORLD_COLORED_ADDITIVE, depthMode);
|
||||
}
|
||||
MeshBuilder lineMesh = null;
|
||||
if (lines) {
|
||||
|
|
@ -59,41 +124,73 @@ public final class TrailRibbon {
|
|||
try {
|
||||
RenderState.lineWidth = Math.max(0.5f, lineWidth);
|
||||
lineMesh = renderer.batch(depth ? LoVisualRenderPipelines.WORLD_COLORED_LINES_LIQUID_IGNORE
|
||||
: LoVisualRenderPipelines.WORLD_COLORED_LINES, depth ? Renderer3D.DepthMode.PRE_DEPTH : Renderer3D.DepthMode.MAIN);
|
||||
: LoVisualRenderPipelines.WORLD_COLORED_LINES, depthMode);
|
||||
} finally {
|
||||
RenderState.lineWidth = prev;
|
||||
}
|
||||
}
|
||||
if (fillMesh == null && lineMesh == null) return;
|
||||
if (fillMesh == null && lineMesh == null && glowMesh == null) return;
|
||||
for (TrackBuffer track : tracks) {
|
||||
List<RibbonPoint> pts = track.points();
|
||||
if (pts.size() >= 2) draw(pts, now, lifeMs, width, fillMesh, lineMesh, cam, color);
|
||||
if (pts.size() >= 2) draw(pts, now, lifeMs, width, fillMesh, glowMesh, lineMesh, cam, color, style);
|
||||
}
|
||||
}
|
||||
|
||||
private static void draw(List<RibbonPoint> pts, long now, long lifeMs, float width, MeshBuilder fill,
|
||||
MeshBuilder line, Vec3 cam, IntBinaryOperator color) {
|
||||
MeshBuilder glowMesh, MeshBuilder line, Vec3 cam, IntBinaryOperator color, Style style) {
|
||||
int n = pts.size();
|
||||
double[][] center = new double[n][3];
|
||||
for (int i = 0; i < n; i++) {
|
||||
RibbonPoint p = pts.get(i);
|
||||
center[i] = new double[]{p.x(), p.y(), p.z()};
|
||||
}
|
||||
double[][] side = frame(center, cam, style.horizontal());
|
||||
if (style.motion(pts.get(0)) != null) {
|
||||
double[][] tangent = tangents(center);
|
||||
for (int i = 0; i < n; i++) {
|
||||
float[] m = style.motion(pts.get(i));
|
||||
if (m == null) continue;
|
||||
double bx = side[i][1] * tangent[i][2] - side[i][2] * tangent[i][1];
|
||||
double by = side[i][2] * tangent[i][0] - side[i][0] * tangent[i][2];
|
||||
double bz = side[i][0] * tangent[i][1] - side[i][1] * tangent[i][0];
|
||||
center[i][0] += side[i][0] * m[0] + bx * m[1];
|
||||
center[i][1] += side[i][1] * m[0] + by * m[1];
|
||||
center[i][2] += side[i][2] * m[0] + bz * m[1];
|
||||
}
|
||||
side = frame(center, cam, style.horizontal());
|
||||
}
|
||||
double[][] left = new double[n][3];
|
||||
double[][] right = new double[n][3];
|
||||
int[] argb = new int[n];
|
||||
double[] half = new double[n];
|
||||
for (int i = 0; i < n; i++) {
|
||||
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 = 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};
|
||||
argb[i] = fadeAlpha(color.applyAsInt(i, n), life);
|
||||
float life = style == LEGACY ? lifeFraction(now, p.ms(), lifeMs) : RibbonShape.life(now, p.ms(), lifeMs);
|
||||
half[i] = style.halfWidth(width, i, n, p, life);
|
||||
double dx = center[i][0] - cam.x, dy = center[i][1] - cam.y, dz = center[i][2] - cam.z;
|
||||
float mult = style.alpha(i, n, p, life, (float) Math.sqrt(dx * dx + dy * dy + dz * dz));
|
||||
argb[i] = scaleAlpha(color.applyAsInt(i, n), mult);
|
||||
for (int k = 0; k < 3; k++) {
|
||||
left[i][k] = center[i][k] + side[i][k] * half[i];
|
||||
right[i][k] = center[i][k] - side[i][k] * half[i];
|
||||
}
|
||||
}
|
||||
boolean soft = style.softEdges();
|
||||
for (int i = 1; i < n; i++) {
|
||||
if (fill != null) quad(fill, left[i - 1], right[i - 1], right[i], left[i], argb[i - 1], argb[i]);
|
||||
if (fill != null) {
|
||||
if (soft) softQuad(fill, left[i - 1], center[i - 1], right[i - 1], left[i], center[i], right[i],
|
||||
argb[i - 1], argb[i]);
|
||||
else quad(fill, left[i - 1], right[i - 1], right[i], left[i], argb[i - 1], argb[i]);
|
||||
}
|
||||
if (glowMesh != null) {
|
||||
float g = style.glowScale();
|
||||
double[] l0 = offset(center[i - 1], side[i - 1], half[i - 1] * g);
|
||||
double[] r0 = offset(center[i - 1], side[i - 1], -half[i - 1] * g);
|
||||
double[] l1 = offset(center[i], side[i], half[i] * g);
|
||||
double[] r1 = offset(center[i], side[i], -half[i] * g);
|
||||
softQuad(glowMesh, l0, center[i - 1], r0, l1, center[i], r1,
|
||||
scaleAlpha(argb[i - 1], GLOW_ALPHA), scaleAlpha(argb[i], GLOW_ALPHA));
|
||||
}
|
||||
if (line != null) {
|
||||
segment(line, left[i - 1], left[i], argb[i - 1], argb[i]);
|
||||
segment(line, right[i - 1], right[i], argb[i - 1], argb[i]);
|
||||
|
|
@ -101,15 +198,90 @@ public final class TrailRibbon {
|
|||
}
|
||||
}
|
||||
|
||||
/** Относительная яркость ореола. */
|
||||
static final float GLOW_ALPHA = 0.35f;
|
||||
|
||||
private static double[] offset(double[] c, double[] side, double d) {
|
||||
return new double[]{c[0] + side[0] * d, c[1] + side[1] * d, c[2] + side[2] * d};
|
||||
}
|
||||
|
||||
/** Единичные касательные по центральным разностям соседей. */
|
||||
private static double[][] tangents(double[][] c) {
|
||||
int n = c.length;
|
||||
double[][] out = new double[n][3];
|
||||
for (int i = 0; i < n; i++) {
|
||||
double[] a = c[Math.max(0, i - 1)], b = c[Math.min(n - 1, i + 1)];
|
||||
double dx = b[0] - a[0], dy = b[1] - a[1], dz = b[2] - a[2];
|
||||
double len = Math.sqrt(dx * dx + dy * dy + dz * dz);
|
||||
if (len < 1e-6) {
|
||||
out[i] = new double[]{0.0, 0.0, 1.0};
|
||||
} else {
|
||||
out[i] = new double[]{dx / len, dy / len, dz / len};
|
||||
}
|
||||
}
|
||||
return out;
|
||||
}
|
||||
|
||||
/**
|
||||
* Единичный вектор «в сторону» для каждой точки: к камере (касательная x взгляд) или горизонтальный
|
||||
* ({@code (-tz, 0, tx)}), с запасным вариантом для вертикального пути.
|
||||
*/
|
||||
static double[][] frame(double[][] c, Vec3 cam, boolean horizontal) {
|
||||
int n = c.length;
|
||||
double[][] t = tangents(c);
|
||||
double[][] out = new double[n][3];
|
||||
for (int i = 0; i < n; i++) {
|
||||
double sx, sy, sz;
|
||||
if (horizontal) {
|
||||
sx = -t[i][2];
|
||||
sy = 0.0;
|
||||
sz = t[i][0];
|
||||
if (sx * sx + sz * sz < 1.0E-7) {
|
||||
sx = 1.0;
|
||||
sz = 0.0;
|
||||
}
|
||||
} else {
|
||||
double vx = cam.x - c[i][0], vy = cam.y - c[i][1], vz = cam.z - c[i][2];
|
||||
sx = t[i][1] * vz - t[i][2] * vy;
|
||||
sy = t[i][2] * vx - t[i][0] * vz;
|
||||
sz = t[i][0] * vy - t[i][1] * vx;
|
||||
}
|
||||
double len = Math.sqrt(sx * sx + sy * sy + sz * sz);
|
||||
if (len < 1e-6) len = 1.0;
|
||||
out[i] = new double[]{sx / len, sy / len, sz / len};
|
||||
}
|
||||
return out;
|
||||
}
|
||||
|
||||
/** Альфа цвета, умноженная на множитель 0..1; RGB не меняется. */
|
||||
static int scaleAlpha(int argb, float mult) {
|
||||
float m = mult < 0.0f ? 0.0f : Math.min(mult, 1.0f);
|
||||
int alpha = Math.round(((argb >>> 24) & 0xFF) * m);
|
||||
return (alpha << 24) | (argb & 0x00FFFFFF);
|
||||
}
|
||||
|
||||
private static void quad(MeshBuilder m, double[] l0, double[] r0, double[] r1, double[] l1, int c0, int c1) {
|
||||
quad4(m, l0, r0, r1, l1, c0, c0, c1, c1);
|
||||
}
|
||||
|
||||
private static void quad4(MeshBuilder m, double[] p0, double[] p1, double[] p2, double[] p3,
|
||||
int c0, int c1, int c2, int c3) {
|
||||
m.ensureQuadCapacity();
|
||||
int a = m.vec3(l0[0], l0[1], l0[2]).color(new RenderColor(c0)).next();
|
||||
int b = m.vec3(r0[0], r0[1], r0[2]).color(new RenderColor(c0)).next();
|
||||
int c = m.vec3(r1[0], r1[1], r1[2]).color(new RenderColor(c1)).next();
|
||||
int d = m.vec3(l1[0], l1[1], l1[2]).color(new RenderColor(c1)).next();
|
||||
int a = m.vec3(p0[0], p0[1], p0[2]).color(new RenderColor(c0)).next();
|
||||
int b = m.vec3(p1[0], p1[1], p1[2]).color(new RenderColor(c1)).next();
|
||||
int c = m.vec3(p2[0], p2[1], p2[2]).color(new RenderColor(c2)).next();
|
||||
int d = m.vec3(p3[0], p3[1], p3[2]).color(new RenderColor(c3)).next();
|
||||
m.quad(a, b, c, d);
|
||||
}
|
||||
|
||||
/** Две полосы: от прозрачного левого края к непрозрачной оси и от оси к прозрачному правому краю. */
|
||||
private static void softQuad(MeshBuilder m, double[] l0, double[] k0, double[] r0,
|
||||
double[] l1, double[] k1, double[] r1, int c0, int c1) {
|
||||
int e0 = scaleAlpha(c0, 0.0f), e1 = scaleAlpha(c1, 0.0f);
|
||||
quad4(m, l0, k0, k1, l1, e0, c0, c1, e1);
|
||||
quad4(m, k0, r0, r1, k1, c0, e0, e1, c1);
|
||||
}
|
||||
|
||||
private static void segment(MeshBuilder m, double[] p0, double[] p1, int c0, int c1) {
|
||||
m.ensureLineCapacity();
|
||||
int a = m.vec3(p0[0], p0[1], p0[2]).color(new RenderColor(c0)).next();
|
||||
|
|
|
|||
|
|
@ -7,6 +7,8 @@ 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.modules.visuals.weather.blizzard.view.BlizzardFog;
|
||||
import dev.loki.lovisual.features.module.modules.visuals.weather.blizzard.view.BlizzardOverlay;
|
||||
import dev.loki.lovisual.features.module.phase.WorldPhase;
|
||||
import dev.loki.lovisual.render.engine.RenderState;
|
||||
import dev.loki.lovisual.render.engine.pipeline.LoVisualRenderPipelines;
|
||||
|
|
@ -16,10 +18,14 @@ 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.multiplayer.ClientLevel;
|
||||
import net.minecraft.client.gui.GuiGraphicsExtractor;
|
||||
import net.minecraft.client.renderer.fog.FogData;
|
||||
import net.minecraft.core.BlockPos;
|
||||
import net.minecraft.util.Mth;
|
||||
import net.minecraft.world.level.biome.Biome;
|
||||
import net.minecraft.world.level.levelgen.Heightmap;
|
||||
import net.minecraft.world.level.material.FogType;
|
||||
import net.minecraft.world.phys.Vec3;
|
||||
|
||||
/**
|
||||
|
|
@ -31,28 +37,30 @@ import net.minecraft.world.phys.Vec3;
|
|||
id = "blizzard",
|
||||
displayName = "Blizzard",
|
||||
category = ModuleCategory.VISUALS,
|
||||
description = "Wind-driven snowstorm with white haze and frosted screen edges"
|
||||
description = "High-altitude snowstorm with wind-driven flakes, whiteout fog and frosted 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 NumberValue<Float> wind = num("blizzardWind", "wind", 0.7f, 0.0f, 1.0f);
|
||||
private final NumberValue<Float> turbulence = num("blizzardTurbulence", "turbulence", 0.6f, 0.0f, 1.0f);
|
||||
private final NumberValue<Float> flakeSize = num("blizzardFlakeSize", "flake_size", 1.0f, 0.5f, 2.5f);
|
||||
private final NumberValue<Float> radius = num("blizzardRadius", "radius", 12.0f, 6.0f, 24.0f);
|
||||
private final NumberValue<Float> opacity = num("blizzardOpacity", "opacity", 0.8f, 0.1f, 1.0f);
|
||||
private final BooleanValue whiteout = bool("blizzardWhiteout", "whiteout", true);
|
||||
private final NumberValue<Float> visibility =
|
||||
visibleWhen(num("blizzardVisibility", "visibility", 28.0f, 8.0f, 128.0f), whiteout::get);
|
||||
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 BooleanValue hideVanilla = bool("blizzardHideVanilla", "hide_vanilla", true);
|
||||
private final RGBAColorValue tint = color("blizzardTint", "tint", "#FFFFFFFF");
|
||||
|
||||
private final SnowFlakes flakes = new SnowFlakes();
|
||||
private long lastFrameNanos;
|
||||
private float seconds;
|
||||
private float strength;
|
||||
private float exposure;
|
||||
private boolean frameActive;
|
||||
|
||||
@Override
|
||||
public HudPhase getHudPhase() {
|
||||
|
|
@ -66,68 +74,100 @@ public final class Blizzard extends Module {
|
|||
|
||||
@Override
|
||||
public void onEnable() {
|
||||
lastFrameNanos = 0L;
|
||||
seconds = 0.0f;
|
||||
strength = 0.0f;
|
||||
flakes.clear();
|
||||
reset();
|
||||
}
|
||||
|
||||
@Override
|
||||
public void onDisable() {
|
||||
reset();
|
||||
}
|
||||
|
||||
private void reset() {
|
||||
lastFrameNanos = 0L;
|
||||
strength = 0.0f;
|
||||
exposure = 0.0f;
|
||||
frameActive = false;
|
||||
flakes.clear();
|
||||
}
|
||||
|
||||
/** Ванильные полосы дождя и снега скрыты, пока идёт шторм и включена настройка. */
|
||||
public boolean hidesVanillaWeather() {
|
||||
return isEnabled() && frameActive && hideVanilla.get();
|
||||
}
|
||||
|
||||
/** Штормовая пелена: сжимает туман и красит его в цвет снега (вызывается из миксина тумана). */
|
||||
public void applyFog(Camera camera, FogData fog) {
|
||||
if (!isEnabled() || !whiteout.get() || camera == null || fog == null) return;
|
||||
FogType type = camera.getFluidInCamera();
|
||||
if (type != FogType.NONE && type != FogType.ATMOSPHERIC) return;
|
||||
float f = strength * exposure;
|
||||
if (!BlizzardFog.active(f)) return;
|
||||
float end = BlizzardFog.endDistance(visibility.get(), f);
|
||||
float start = BlizzardFog.startDistance(visibility.get(), f);
|
||||
fog.environmentalStart = BlizzardFog.limit(fog.environmentalStart, start);
|
||||
fog.environmentalEnd = BlizzardFog.limit(fog.environmentalEnd, end);
|
||||
fog.renderDistanceStart = BlizzardFog.limit(fog.renderDistanceStart, start);
|
||||
fog.renderDistanceEnd = BlizzardFog.limit(fog.renderDistanceEnd, end);
|
||||
fog.skyEnd = BlizzardFog.limit(fog.skyEnd, end * BlizzardFog.SKY_SHARE);
|
||||
fog.cloudEnd = BlizzardFog.limit(fog.cloudEnd, end);
|
||||
int argb = tint.getArgb();
|
||||
float k = BlizzardFog.brightness(fog.color.x, fog.color.y, fog.color.z);
|
||||
fog.color.x = BlizzardFog.blend(fog.color.x, ((argb >> 16) & 0xFF) / 255.0f, k, f);
|
||||
fog.color.y = BlizzardFog.blend(fog.color.y, ((argb >> 8) & 0xFF) / 255.0f, k, f);
|
||||
fog.color.z = BlizzardFog.blend(fog.color.z, (argb & 0xFF) / 255.0f, k, f);
|
||||
}
|
||||
|
||||
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);
|
||||
return Mth.clamp(dt, 0.0f, 0.1f);
|
||||
}
|
||||
|
||||
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;
|
||||
private float targetStrength(Minecraft mc) {
|
||||
float rain = mc.level.getRainLevel(1.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;
|
||||
boolean snowBiome = mc.level.getBiome(pos).value()
|
||||
.getPrecipitationAt(pos, mc.level.getSeaLevel()) == Biome.Precipitation.SNOW;
|
||||
return SnowFlakes.targetStrength(intensity.get(), onlyWhenSnowing.get(), rain, snowBiome);
|
||||
}
|
||||
|
||||
@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();
|
||||
if (!isEnabled() || mc.player == null || mc.level == null || mc.gameRenderer == null
|
||||
|| mc.gameRenderer.mainCamera() == null) {
|
||||
frameActive = false;
|
||||
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);
|
||||
float dt = frameDeltaSeconds();
|
||||
Vec3 cam = mc.gameRenderer.mainCamera().position();
|
||||
ClientLevel level = mc.level;
|
||||
SnowFlakes.Ground ground = (x, z) -> level.getHeight(Heightmap.Types.MOTION_BLOCKING, x, z);
|
||||
strength = SnowFlakes.ramp(strength, targetStrength(mc), dt);
|
||||
float open = SnowFlakes.exposureTarget(cam.y, ground.height(Mth.floor(cam.x), Mth.floor(cam.z)));
|
||||
exposure = SnowFlakes.approach(exposure, open, SnowFlakes.EXPOSURE_PER_SECOND, dt);
|
||||
frameActive = strength > 0.01f;
|
||||
if (!frameActive && flakes.count == 0) return;
|
||||
|
||||
double seconds = (System.currentTimeMillis() % 600_000L) / 1000.0;
|
||||
SnowFlakes.Wind windValue = SnowFlakes.wind(seconds, wind.get(), strength);
|
||||
int target = frameActive ? SnowFlakes.targetCount(radius.get(), density.get(), strength) : 0;
|
||||
flakes.update(dt, cam.x, cam.y, cam.z, radius.get(), windValue, SnowFlakes.turbulence(turbulence.get()),
|
||||
target, seconds, ground);
|
||||
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());
|
||||
BlizzardRenderer.render(mesh, flakes, RenderState.cameraRotation, cam.x, cam.y, cam.z,
|
||||
tint.getArgb(), strength, intensity.get(), 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());
|
||||
float current = strength * exposure * intensity.get();
|
||||
BlizzardOverlay.render(ctx, current, frost.get(), tint.getArgb());
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1,79 +0,0 @@
|
|||
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;
|
||||
}
|
||||
}
|
||||
|
|
@ -6,114 +6,127 @@ import org.joml.Quaternionf;
|
|||
import org.joml.Vector3f;
|
||||
|
||||
/**
|
||||
* Рендер снежинок: биллборд-квады, вытянутые по вектору скорости частицы,
|
||||
* с затуханием у камеры и на границе зоны.
|
||||
* Рендер снежинок по образцу evo: мягкая «капля» из двух воздушных змеев, вытянутая вдоль скорости,
|
||||
* непрозрачный центр и прозрачные края. Снежинки позади камеры отбрасываются.
|
||||
*/
|
||||
final class BlizzardRenderer {
|
||||
|
||||
static final float FLAKE_RADIUS = 0.028f;
|
||||
static final float MIN_ANGULAR_RADIUS = 0.0024f;
|
||||
static final float STREAK_TIME = 0.022f;
|
||||
static final float STREAK_FRONT_SHARE = 0.3f;
|
||||
static final float FADE_IN_SECONDS = 0.45f;
|
||||
static final float NEAR_FADE_START = 0.25f;
|
||||
static final float NEAR_FADE_SPAN = 0.65f;
|
||||
static final float BEHIND_DOT = -0.35f;
|
||||
static final float BEHIND_MIN_DISTANCE = 3.0f;
|
||||
|
||||
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) {
|
||||
static void render(MeshBuilder mesh, SnowFlakes flakes, Quaternionf camRot, double camX, double camY, double camZ,
|
||||
int argb, float strength, float intensity, float sizeSetting, 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);
|
||||
int rgb = argb & 0x00FFFFFF;
|
||||
Vector3f forward = new Vector3f(0.0f, 0.0f, -1.0f).rotate(camRot);
|
||||
Vector3f up = new Vector3f(0.0f, 1.0f, 0.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);
|
||||
float px = (float) (flakes.x[i] - camX);
|
||||
float py = (float) (flakes.y[i] - camY);
|
||||
float pz = (float) (flakes.z[i] - camZ);
|
||||
float dist = (float) Math.sqrt(px * px + py * py + pz * pz);
|
||||
if (dist <= 0.0f) continue;
|
||||
float vx = px / dist, vy = py / dist, vz = pz / dist;
|
||||
if (isBehind(vx * forward.x + vy * forward.y + vz * forward.z, dist)) continue;
|
||||
float alpha = flakeAlpha(dist, flakes.age[i], flakes.alpha[i], opacity, strength, intensity);
|
||||
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);
|
||||
float speed = (float) Math.sqrt(flakes.vx[i] * flakes.vx[i] + flakes.vy[i] * flakes.vy[i]
|
||||
+ flakes.vz[i] * flakes.vz[i]);
|
||||
float dx, dy, dz;
|
||||
if (speed <= 1.0E-4f) {
|
||||
dx = up.x;
|
||||
dy = up.y;
|
||||
dz = up.z;
|
||||
} else {
|
||||
dx = flakes.vx[i] / speed;
|
||||
dy = flakes.vy[i] / speed;
|
||||
dz = flakes.vz[i] / speed;
|
||||
}
|
||||
float sx = dy * vz - dz * vy, sy = dz * vx - dx * vz, sz = dx * vy - dy * vx;
|
||||
float sideLen = (float) Math.sqrt(sx * sx + sy * sy + sz * sz);
|
||||
if (sideLen <= 1.0E-4f) {
|
||||
sx = up.y * vz - up.z * vy;
|
||||
sy = up.z * vx - up.x * vz;
|
||||
sz = up.x * vy - up.y * vx;
|
||||
sideLen = (float) Math.sqrt(sx * sx + sy * sy + sz * sz);
|
||||
if (sideLen <= 1.0E-4f) continue;
|
||||
}
|
||||
sx /= sideLen;
|
||||
sy /= sideLen;
|
||||
sz /= sideLen;
|
||||
|
||||
float radius = radius(sizeSetting, flakes.size[i], dist);
|
||||
float back = streakLength(radius, speed, flakes.size[i]);
|
||||
float front = frontLength(radius, back);
|
||||
RenderColor center = new RenderColor(withAlpha(rgb, alpha));
|
||||
RenderColor edge = new RenderColor(withAlpha(rgb, 0.0f));
|
||||
emit(mesh, px, py, pz, sx * radius, sy * radius, sz * radius, dx * front, dy * front, dz * front,
|
||||
center, edge);
|
||||
emit(mesh, px, py, pz, -sx * radius, -sy * radius, -sz * radius, -dx * back, -dy * back, -dz * back,
|
||||
center, edge);
|
||||
}
|
||||
}
|
||||
|
||||
/** Один змей: центр непрозрачный, сторона, острие и противоположная сторона прозрачные. */
|
||||
private static void emit(MeshBuilder mesh, float cx, float cy, float cz, float sx, float sy, float sz,
|
||||
float tx, float ty, float tz, RenderColor center, RenderColor edge) {
|
||||
mesh.ensureQuadCapacity();
|
||||
int a = mesh.vec3(cx, cy, cz).color(center).next();
|
||||
int b = mesh.vec3(cx - sx, cy - sy, cz - sz).color(edge).next();
|
||||
int c = mesh.vec3(cx + tx, cy + ty, cz + tz).color(edge).next();
|
||||
int d = mesh.vec3(cx + sx, cy + sy, cz + sz).color(edge).next();
|
||||
mesh.quad(a, b, c, d);
|
||||
}
|
||||
|
||||
/** Снежинки позади камеры не рисуются, кроме совсем близких (не пропадают на краю экрана). */
|
||||
static boolean isBehind(float dotWithForward, float dist) {
|
||||
return dist > BEHIND_MIN_DISTANCE && dotWithForward < BEHIND_DOT;
|
||||
}
|
||||
|
||||
/**
|
||||
* Видимость снежинки: невидима у самой линзы, плавно проявляется с расстоянием и возрастом,
|
||||
* тает к границе зоны шторма; масштабируется прозрачностью и силой шторма, не бывает полностью непрозрачной.
|
||||
* Слишком слабые снежинки возвращают 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 flakeAlpha(float dist, float ageSeconds, float baseAlpha, float opacity, float strength,
|
||||
float intensity) {
|
||||
float near = clamp01((dist - NEAR_FADE_START) / NEAR_FADE_SPAN);
|
||||
float born = Math.min(ageSeconds / FADE_IN_SECONDS, 1.0f);
|
||||
float share = clamp01(strength / Math.max(intensity, 0.01f));
|
||||
return opacity * share * baseAlpha * near * Math.max(0.0f, born);
|
||||
}
|
||||
|
||||
/** Полуширина снежинки; вдали не тоньше доли пикселя, чтобы не пропадала. */
|
||||
static float halfWidth(float flakeSize, float sizeMul, float dist) {
|
||||
float hw = (float) (0.045 * flakeSize) * sizeMul;
|
||||
return Math.max(hw, dist * 0.0016f);
|
||||
/** Радиус снежинки: вдали не меньше угловых 0.0024, чтобы не пропадала. */
|
||||
static float radius(float sizeSetting, float flakeSize, float dist) {
|
||||
return Math.max(FLAKE_RADIUS * sizeSetting * flakeSize, dist * MIN_ANGULAR_RADIUS);
|
||||
}
|
||||
|
||||
/** Полудлина штриха: быстрее снежинка, длиннее след, но не больше 0.35 блока. */
|
||||
static float halfLength(float halfWidth, float speed, float sizeMul) {
|
||||
return halfWidth + Math.min(0.35f, speed * 0.022f * sizeMul);
|
||||
/** Длина хвоста за снежинкой: растёт со скоростью. */
|
||||
static float streakLength(float radius, float speed, float flakeSize) {
|
||||
return radius + speed * STREAK_TIME * flakeSize;
|
||||
}
|
||||
|
||||
/** Длина носа перед снежинкой: короче хвоста, у медленной равна радиусу. */
|
||||
static float frontLength(float radius, float back) {
|
||||
return back * STREAK_FRONT_SHARE + radius * (1.0f - STREAK_FRONT_SHARE);
|
||||
}
|
||||
|
||||
static int withAlpha(int rgb, float alpha) {
|
||||
int a = Math.max(0, Math.min(255, Math.round(alpha * 255.0f)));
|
||||
return (a << 24) | (rgb & 0x00FFFFFF);
|
||||
}
|
||||
|
||||
private static float clamp01(float v) {
|
||||
if (v < 0.0f) return 0.0f;
|
||||
return v > 1.0f ? 1.0f : v;
|
||||
return v < 0.0f ? 0.0f : Math.min(v, 1.0f);
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -3,19 +3,33 @@ package dev.loki.lovisual.features.module.modules.visuals.weather.blizzard;
|
|||
import java.util.Random;
|
||||
|
||||
/**
|
||||
* Чистая физика снежинок: спавн в цилиндре вокруг камеры, ветер с порывами,
|
||||
* турбулентность и рецикл при выходе из зоны. Без зависимостей от Minecraft.
|
||||
* Чистая физика снежинок по образцу evo: спавн в цилиндре вокруг камеры между землёй (карта высот) и потолком
|
||||
* над головой, ветер с порывами, турбулентность, рецикл при выходе из зоны или касании земли.
|
||||
* Под крышей земля выше потолка спавна, поэтому снега внутри помещений нет. Без зависимостей от Minecraft.
|
||||
*/
|
||||
final class SnowFlakes {
|
||||
|
||||
static final int MAX_FLAKES = 4000;
|
||||
static final int MAX_FLAKES = 12000;
|
||||
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 float AREA_FACTOR = 9.0f;
|
||||
static final double SPAWN_CEILING = 8.0;
|
||||
static final double SPAWN_FLOOR_MARGIN = 6.0;
|
||||
static final double RECYCLE_MARGIN = 1.25;
|
||||
static final double RECYCLE_HEIGHT = 16.0;
|
||||
static final int SPAWN_ATTEMPTS = 3;
|
||||
static final float WIND_SPEED_MAX = 16.0f;
|
||||
static final float TURBULENCE_AMPLITUDE = 1.1f;
|
||||
static final float RISE_PER_SECOND = 0.45f;
|
||||
static final float FALL_PER_SECOND = 0.6f;
|
||||
static final float EXPOSURE_PER_SECOND = 1.4f;
|
||||
|
||||
/** Горизонтальный ветер с лёгким вертикальным сносом. */
|
||||
/** Высота верхнего блока, который останавливает снег, в колонке (x, z) (карта MOTION_BLOCKING). */
|
||||
@FunctionalInterface
|
||||
interface Ground {
|
||||
float height(int x, int z);
|
||||
}
|
||||
|
||||
/** Ветер: горизонтальная скорость и лёгкий вертикальный снос. */
|
||||
record Wind(double x, double y, double z) {
|
||||
static final Wind ZERO = new Wind(0.0, 0.0, 0.0);
|
||||
}
|
||||
|
|
@ -32,6 +46,9 @@ final class SnowFlakes {
|
|||
final float[] phase = new float[MAX_FLAKES];
|
||||
final float[] rate = new float[MAX_FLAKES];
|
||||
final float[] age = new float[MAX_FLAKES];
|
||||
private final float[] ground = new float[MAX_FLAKES];
|
||||
private final int[] columnX = new int[MAX_FLAKES];
|
||||
private final int[] columnZ = new int[MAX_FLAKES];
|
||||
int count;
|
||||
|
||||
private final Random random = new Random(1L);
|
||||
|
|
@ -40,80 +57,150 @@ final class SnowFlakes {
|
|||
count = 0;
|
||||
}
|
||||
|
||||
/** Фиксация seed — детерминизм для тестов. */
|
||||
/** Фиксация 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 float approach(float current, float target, float perSecond, float dt) {
|
||||
float step = perSecond * dt;
|
||||
if (Math.abs(target - current) <= step) return target;
|
||||
return current + (target > current ? step : -step);
|
||||
}
|
||||
|
||||
/** Целевое число частиц: площадь цилиндра * плотность * доля силы шторма. */
|
||||
/** Сила шторма: разгон 0.45/с, затухание 0.6/с. */
|
||||
static float ramp(float current, float target, float dt) {
|
||||
return approach(current, target, target > current ? RISE_PER_SECOND : FALL_PER_SECOND, dt);
|
||||
}
|
||||
|
||||
/** Цель экспозиции: 1 на открытом небе (камера не ниже верхнего блока колонки), 0 под крышей. */
|
||||
static float exposureTarget(double camY, float groundHeight) {
|
||||
return camY < groundHeight ? 0.0f : 1.0f;
|
||||
}
|
||||
|
||||
/**
|
||||
* Целевая сила шторма. Без «только в снегопад» это просто интенсивность; иначе шторм идёт, пока в биоме
|
||||
* игрока снег и идёт осадок, и растёт с его силой.
|
||||
*/
|
||||
static float targetStrength(float intensity, boolean onlyInSnowfall, float rainLevel, boolean snowBiome) {
|
||||
if (!onlyInSnowfall) return intensity;
|
||||
if (rainLevel <= 0.02f || !snowBiome) return 0.0f;
|
||||
return Math.max(0.0f, Math.min(1.0f, rainLevel)) * intensity;
|
||||
}
|
||||
|
||||
/** Целевое число частиц: площадь цилиндра * плотность * сила шторма, не больше {@link #MAX_FLAKES}. */
|
||||
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;
|
||||
double raw = Math.PI * radius * radius * AREA_FACTOR * density * strength;
|
||||
return (int) Math.min(MAX_FLAKES, raw);
|
||||
}
|
||||
|
||||
/** Ветер с порывами: направление медленно кружит, скорость дышит несколькими синусами. */
|
||||
static Wind wind(float seconds, float windPower, float strength) {
|
||||
/** Ветер с порывами: направление медленно поворачивает, скорость дышит двумя синусами. */
|
||||
static Wind wind(double 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 angle = seconds * 0.02 + Math.sin(seconds * 0.9 * 0.5) * 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;
|
||||
double speed = windPower * WIND_SPEED_MAX * 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);
|
||||
/** Амплитуда боковой турбулентности (блоков/с) по настройке 0..1. */
|
||||
static float turbulence(float setting) {
|
||||
return Math.max(0f, setting) * TURBULENCE_AMPLITUDE;
|
||||
}
|
||||
|
||||
void update(float dt, double camX, double camY, double camZ, float radius,
|
||||
Wind wind, float turbulence, float speedMul, int target, float seconds) {
|
||||
void update(float dt, double camX, double camY, double camZ, float radius, Wind wind, float turbulence,
|
||||
int target, double seconds, Ground ground) {
|
||||
if (dt <= 0f || target < 0) return;
|
||||
target = Math.min(target, MAX_FLAKES);
|
||||
if (count > target) count = target;
|
||||
while (count < target) spawn(count++, camX, camY, camZ, radius);
|
||||
while (count < target && respawn(count, camX, camY, camZ, radius, wind, ground)) count++;
|
||||
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);
|
||||
for (int i = count - 1; i >= 0; i--) {
|
||||
float wobbleX = (float) Math.sin(seconds * rate[i] + phase[i]) * turbulence;
|
||||
float wobbleZ = (float) Math.cos(seconds * rate[i] * 0.83f + phase[i] * 1.7f) * turbulence;
|
||||
float nvx = (float) wind.x() + wobbleX;
|
||||
float nvy = (float) wind.y() - FALL_SPEED * fall[i];
|
||||
float nvz = (float) wind.z() + wobbleZ;
|
||||
double nx = x[i] + nvx * dt;
|
||||
double ny = y[i] + nvy * dt;
|
||||
double nz = z[i] + nvz * dt;
|
||||
int cx = (int) Math.floor(nx);
|
||||
int cz = (int) Math.floor(nz);
|
||||
if (cx != columnX[i] || cz != columnZ[i]) {
|
||||
columnX[i] = cx;
|
||||
columnZ[i] = cz;
|
||||
this.ground[i] = ground.height(cx, cz);
|
||||
}
|
||||
double dx = nx - camX;
|
||||
double dz = nz - camZ;
|
||||
boolean lost = ny <= this.ground[i] || dx * dx + dz * dz > recycleSq || ny > camY + RECYCLE_HEIGHT;
|
||||
if (lost) {
|
||||
if (!respawn(i, camX, camY, camZ, radius, wind, ground)) remove(i);
|
||||
continue;
|
||||
}
|
||||
x[i] = nx;
|
||||
y[i] = ny;
|
||||
z[i] = nz;
|
||||
vx[i] = nvx;
|
||||
vy[i] = nvy;
|
||||
vz[i] = nvz;
|
||||
age[i] += dt;
|
||||
}
|
||||
}
|
||||
|
||||
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;
|
||||
private void remove(int i) {
|
||||
int last = --count;
|
||||
if (i == last) return;
|
||||
x[i] = x[last];
|
||||
y[i] = y[last];
|
||||
z[i] = z[last];
|
||||
vx[i] = vx[last];
|
||||
vy[i] = vy[last];
|
||||
vz[i] = vz[last];
|
||||
fall[i] = fall[last];
|
||||
size[i] = size[last];
|
||||
alpha[i] = alpha[last];
|
||||
phase[i] = phase[last];
|
||||
rate[i] = rate[last];
|
||||
age[i] = age[last];
|
||||
ground[i] = ground[last];
|
||||
columnX[i] = columnX[last];
|
||||
columnZ[i] = columnZ[last];
|
||||
}
|
||||
|
||||
/** Ставит снежинку в случайное место цилиндра между землёй и потолком; false, если над колонкой крыша. */
|
||||
private boolean respawn(int i, double camX, double camY, double camZ, float radius, Wind wind, Ground world) {
|
||||
for (int attempt = 0; attempt < SPAWN_ATTEMPTS; attempt++) {
|
||||
double angle = random.nextDouble() * Math.PI * 2.0;
|
||||
double dist = radius * Math.sqrt(random.nextDouble());
|
||||
double px = camX + Math.cos(angle) * dist;
|
||||
double pz = camZ + Math.sin(angle) * dist;
|
||||
int cx = (int) Math.floor(px);
|
||||
int cz = (int) Math.floor(pz);
|
||||
float height = world.height(cx, cz);
|
||||
double ceiling = camY + SPAWN_CEILING;
|
||||
double floor = Math.max(height, camY - SPAWN_FLOOR_MARGIN);
|
||||
if (ceiling <= floor) continue;
|
||||
x[i] = px;
|
||||
y[i] = floor + random.nextDouble() * (ceiling - floor);
|
||||
z[i] = pz;
|
||||
fall[i] = 0.6f + random.nextFloat() * 0.9f;
|
||||
size[i] = 0.55f + random.nextFloat() * 0.9f;
|
||||
alpha[i] = 0.45f + random.nextFloat() * 0.55f;
|
||||
phase[i] = random.nextFloat() * (float) (Math.PI * 2.0);
|
||||
rate[i] = 1.2f + random.nextFloat() * 2.2f;
|
||||
age[i] = 0f;
|
||||
vx[i] = (float) wind.x();
|
||||
vy[i] = -FALL_SPEED;
|
||||
vz[i] = (float) wind.z();
|
||||
ground[i] = height;
|
||||
columnX[i] = cx;
|
||||
columnZ[i] = cz;
|
||||
return true;
|
||||
}
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -0,0 +1,49 @@
|
|||
package dev.loki.lovisual.features.module.modules.visuals.weather.blizzard.view;
|
||||
|
||||
/**
|
||||
* Белая пелена по образцу evo: штормовой туман сжимает дальность видимости и подмешивает цвет снега.
|
||||
* Сила пелены = сила шторма * экспозиция (под крышей пелены нет). Чистая математика.
|
||||
*/
|
||||
public final class BlizzardFog {
|
||||
|
||||
public static final float MIN_STRENGTH = 0.01f;
|
||||
public static final float START_SHARE = 0.05f;
|
||||
public static final float SKY_SHARE = 0.6f;
|
||||
public static final float BRIGHTNESS_GAIN = 1.6f;
|
||||
public static final float BRIGHTNESS_FLOOR = 0.15f;
|
||||
public static final float COLOR_BLEND = 0.9f;
|
||||
|
||||
private BlizzardFog() {
|
||||
}
|
||||
|
||||
public static boolean active(float strength) {
|
||||
return strength > MIN_STRENGTH;
|
||||
}
|
||||
|
||||
/** Дальность конца тумана: видимость в штиль, делённая на силу; слабая пелена почти не ограничивает обзор. */
|
||||
public static float endDistance(float visibility, float strength) {
|
||||
return visibility / strength;
|
||||
}
|
||||
|
||||
/** Дальность начала тумана. */
|
||||
public static float startDistance(float visibility, float strength) {
|
||||
return endDistance(visibility, strength) * START_SHARE;
|
||||
}
|
||||
|
||||
/** Туман только сжимает дальность, но не отодвигает ванильный. */
|
||||
public static float limit(float vanilla, float fog) {
|
||||
return Math.min(vanilla, fog);
|
||||
}
|
||||
|
||||
/** Яркость цвета снега: светлее небо, светлее пелена; всегда в 0..1. */
|
||||
public static float brightness(float r, float g, float b) {
|
||||
float v = Math.max(r, Math.max(g, b)) * BRIGHTNESS_GAIN + BRIGHTNESS_FLOOR;
|
||||
return Math.max(0.0f, Math.min(1.0f, v));
|
||||
}
|
||||
|
||||
/** Канал цвета тумана: от ванильного к оттенку снега по силе пелены. */
|
||||
public static float blend(float vanilla, float tintChannel, float brightness, float strength) {
|
||||
float t = strength * COLOR_BLEND;
|
||||
return vanilla + (tintChannel * brightness - vanilla) * t;
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,69 @@
|
|||
package dev.loki.lovisual.features.module.modules.visuals.weather.blizzard.view;
|
||||
|
||||
import net.minecraft.client.gui.GuiGraphicsExtractor;
|
||||
|
||||
/**
|
||||
* Дымка и обмерзание краёв экрана по образцу evo: лёгкая заливка на весь экран и 32 рамки, каждая
|
||||
* следующая тоньше, поэтому непрозрачность копится к самому краю.
|
||||
*/
|
||||
public final class BlizzardOverlay {
|
||||
|
||||
public static final int FROST_BANDS = 32;
|
||||
public static final float FROST_DEPTH_SHARE = 0.2f;
|
||||
public static final float FROST_EDGE_ALPHA = 0.55f;
|
||||
public static final float HAZE_ALPHA = 0.12f;
|
||||
|
||||
private BlizzardOverlay() {
|
||||
}
|
||||
|
||||
/** @param current сила шторма * экспозиция * интенсивность */
|
||||
public static void render(GuiGraphicsExtractor ctx, float current, boolean frost, int tintArgb) {
|
||||
if (ctx == null || current <= 0.01f) return;
|
||||
int w = ctx.guiWidth();
|
||||
int h = ctx.guiHeight();
|
||||
if (w <= 0 || h <= 0) return;
|
||||
ctx.fill(0, 0, w, h, withAlpha(tintArgb, hazeAlpha(current)));
|
||||
if (!frost) return;
|
||||
int color = withAlpha(tintArgb, frostBandAlpha(current));
|
||||
float depth = Math.min(w, h) * FROST_DEPTH_SHARE;
|
||||
for (int i = 0; i < FROST_BANDS; i++) {
|
||||
int t = Math.round(bandThickness(i, FROST_BANDS, depth));
|
||||
if (t <= 0) continue;
|
||||
ctx.fill(0, 0, w, t, color);
|
||||
ctx.fill(0, h - t, w, h, color);
|
||||
ctx.fill(0, t, t, h - t, color);
|
||||
ctx.fill(w - t, t, w, h - t, color);
|
||||
}
|
||||
}
|
||||
|
||||
/** Непрозрачность заливки на весь экран. */
|
||||
public static float hazeAlpha(float current) {
|
||||
return Math.max(0.0f, HAZE_ALPHA * current);
|
||||
}
|
||||
|
||||
/** Непрозрачность одной полосы обмерзания: все полосы вместе у края дают {@code 0.55 * current}. */
|
||||
public static float frostBandAlpha(float current) {
|
||||
return Math.max(0.0f, FROST_EDGE_ALPHA * current / FROST_BANDS);
|
||||
}
|
||||
|
||||
/** Толщина i-й полосы: первая на всю глубину, дальше тоньше, у последней почти ноль. */
|
||||
public static float bandThickness(int i, int bands, float depth) {
|
||||
return depth * (1.0f - (float) Math.sqrt(i / (double) bands));
|
||||
}
|
||||
|
||||
public static int withAlpha(int argb, float alpha) {
|
||||
int a = Math.max(0, Math.min(255, Math.round(alpha * 255.0f)));
|
||||
return (a << 24) | (argb & 0x00FFFFFF);
|
||||
}
|
||||
|
||||
/** Доля полос обмерзания, покрывающая точку на distance от края экрана. */
|
||||
public 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++) {
|
||||
if (distance < bandThickness(i, bands, depth)) covered++;
|
||||
}
|
||||
return covered / (float) bands;
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,25 @@
|
|||
package dev.loki.lovisual.mixins.render.weather;
|
||||
|
||||
import dev.loki.lovisual.features.module.lifecycle.Modules;
|
||||
import dev.loki.lovisual.features.module.modules.visuals.weather.blizzard.Blizzard;
|
||||
import net.minecraft.client.Camera;
|
||||
import net.minecraft.client.DeltaTracker;
|
||||
import net.minecraft.client.multiplayer.ClientLevel;
|
||||
import net.minecraft.client.renderer.fog.FogData;
|
||||
import net.minecraft.client.renderer.fog.FogRenderer;
|
||||
import org.spongepowered.asm.mixin.Mixin;
|
||||
import org.spongepowered.asm.mixin.injection.At;
|
||||
import org.spongepowered.asm.mixin.injection.Inject;
|
||||
import org.spongepowered.asm.mixin.injection.callback.CallbackInfoReturnable;
|
||||
|
||||
/** Штормовая пелена Blizzard: после ванильного тумана сжимает дальность и красит туман в цвет снега. */
|
||||
@Mixin(value = FogRenderer.class, priority = 1500)
|
||||
public class BlizzardFogMixin {
|
||||
|
||||
@Inject(method = "setupFog", at = @At("RETURN"))
|
||||
private void lovisual$blizzardWhiteout(Camera camera, int viewDistance, DeltaTracker tracker, float partial,
|
||||
ClientLevel level, CallbackInfoReturnable<FogData> cir) {
|
||||
Blizzard blizzard = Modules.get(Blizzard.class);
|
||||
if (blizzard != null) blizzard.applyFog(camera, cir.getReturnValue());
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,27 @@
|
|||
package dev.loki.lovisual.mixins.render.weather;
|
||||
|
||||
import dev.loki.lovisual.features.module.lifecycle.Modules;
|
||||
import dev.loki.lovisual.features.module.modules.visuals.weather.blizzard.Blizzard;
|
||||
import net.minecraft.client.multiplayer.ClientLevel;
|
||||
import net.minecraft.client.renderer.WeatherEffectRenderer;
|
||||
import net.minecraft.client.renderer.state.level.WeatherRenderState;
|
||||
import net.minecraft.world.phys.Vec3;
|
||||
import org.spongepowered.asm.mixin.Mixin;
|
||||
import org.spongepowered.asm.mixin.injection.At;
|
||||
import org.spongepowered.asm.mixin.injection.Inject;
|
||||
import org.spongepowered.asm.mixin.injection.callback.CallbackInfo;
|
||||
|
||||
/** Blizzard убирает ванильные полосы дождя и снега, пока идёт шторм. */
|
||||
@Mixin(WeatherEffectRenderer.class)
|
||||
public class WeatherHideMixin {
|
||||
|
||||
@Inject(method = "extractRenderState(Lnet/minecraft/client/multiplayer/ClientLevel;FLnet/minecraft/world/phys/Vec3;Lnet/minecraft/client/renderer/state/level/WeatherRenderState;)V",
|
||||
at = @At("RETURN"))
|
||||
private void lovisual$dropVanillaColumns(ClientLevel level, float partial, Vec3 cameraPos,
|
||||
WeatherRenderState state, CallbackInfo ci) {
|
||||
Blizzard blizzard = Modules.get(Blizzard.class);
|
||||
if (blizzard == null || !blizzard.hidesVanillaWeather()) return;
|
||||
state.rainColumns.clear();
|
||||
state.snowColumns.clear();
|
||||
}
|
||||
}
|
||||
|
|
@ -3635,7 +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.elytratrails.description": "Draws glowing contrails from the elytra wingtips while gliding.",
|
||||
"module.projectiletrails.description": "Draws fading trails behind arrows, pearls and other projectiles.",
|
||||
"setting.projectiletrails.duration": "Duration (ms)",
|
||||
"setting.projectiletrails.width": "Width",
|
||||
|
|
@ -3686,7 +3686,23 @@
|
|||
"setting.guipresence.show_others": "Show other players' menus",
|
||||
"setting.guipresence.distance": "Distance",
|
||||
"setting.guipresence.scale": "Panel size",
|
||||
"module.blizzard.description": "Wind-driven snowstorm with white haze and frosted screen edges.",
|
||||
"module.blizzard.description": "High-altitude snowstorm with wind-driven flakes, whiteout fog and frosted edges.",
|
||||
"setting.elytratrails.own": "Own trail",
|
||||
"setting.elytratrails.taper": "Taper",
|
||||
"setting.elytratrails.taper_amount": "Taper amount",
|
||||
"setting.elytratrails.motion": "Motion",
|
||||
"setting.elytratrails.wave_size": "Wave size",
|
||||
"setting.elytratrails.wave_speed": "Wave speed",
|
||||
"setting.elytratrails.fade_start": "Fade start",
|
||||
"setting.elytratrails.speed_reactive": "Speed reactive",
|
||||
"setting.elytratrails.soft_edges": "Soft edges",
|
||||
"setting.elytratrails.fill": "Fill",
|
||||
"setting.elytratrails.taper.option.Uniform": "Uniform",
|
||||
"setting.elytratrails.taper.option.Grow": "Grow",
|
||||
"setting.elytratrails.taper.option.Shrink": "Shrink",
|
||||
"setting.elytratrails.motion.option.Straight": "Straight",
|
||||
"setting.elytratrails.motion.option.Wave": "Wave",
|
||||
"setting.elytratrails.motion.option.Helix": "Helix",
|
||||
"setting.elytratrails.others": "Other players",
|
||||
"setting.elytratrails.duration": "Duration (ms)",
|
||||
"setting.elytratrails.width": "Width",
|
||||
|
|
@ -3701,16 +3717,17 @@
|
|||
"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.hide_vanilla": "Hide vanilla weather",
|
||||
"setting.blizzard.visibility": "Visibility",
|
||||
"setting.blizzard.whiteout": "Whiteout",
|
||||
"setting.blizzard.turbulence": "Turbulence",
|
||||
"setting.blizzard.radius": "Radius",
|
||||
"setting.blizzard.flake_size": "Flake size",
|
||||
"setting.blizzard.opacity": "Flake opacity",
|
||||
"setting.blizzard.opacity": "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.blizzard.frost": "Frosted screen",
|
||||
"setting.blizzard.only_when_snowing": "Only in snowfall",
|
||||
"setting.elytratrails.color_mode.option.Static": "Static",
|
||||
"setting.elytratrails.color_mode.option.Rainbow": "Rainbow",
|
||||
"setting.elytratrails.color_mode.option.LightRainbow": "Light Rainbow",
|
||||
|
|
|
|||
|
|
@ -3426,7 +3426,7 @@
|
|||
"module.spatialgui.description": "Рисует интерфейс плавающей панелью в 3D-пространстве.",
|
||||
"module.trinket.description": "Крепит декоративные безделушки к вашему персонажу.",
|
||||
"module.wet_world.description": "Делает поверхности мокрыми во время дождя.",
|
||||
"module.elytratrails.description": "Тянет светящиеся шлейфы из концов крыльев элитры в полёте.",
|
||||
"module.elytratrails.description": "Светящиеся шлейфы с концов крыльев элитры при планировании.",
|
||||
"module.projectiletrails.description": "Рисует затухающие ленты за стрелами, жемчугом и другими снарядами.",
|
||||
"setting.projectiletrails.duration": "Длительность (мс)",
|
||||
"setting.projectiletrails.width": "Ширина",
|
||||
|
|
@ -3477,7 +3477,23 @@
|
|||
"setting.guipresence.show_others": "Показывать меню других игроков",
|
||||
"setting.guipresence.distance": "Дистанция",
|
||||
"setting.guipresence.scale": "Размер панели",
|
||||
"module.blizzard.description": "Метель с ветром, белой дымкой и обмерзанием краёв экрана.",
|
||||
"module.blizzard.description": "Высотная метель: ветровые снежинки, белая пелена и обмерзание краёв экрана.",
|
||||
"setting.elytratrails.own": "Свой шлейф",
|
||||
"setting.elytratrails.taper": "Сужение",
|
||||
"setting.elytratrails.taper_amount": "Сила сужения",
|
||||
"setting.elytratrails.motion": "Движение",
|
||||
"setting.elytratrails.wave_size": "Размер волны",
|
||||
"setting.elytratrails.wave_speed": "Скорость волны",
|
||||
"setting.elytratrails.fade_start": "Плавный старт",
|
||||
"setting.elytratrails.speed_reactive": "Реакция на скорость",
|
||||
"setting.elytratrails.soft_edges": "Мягкие края",
|
||||
"setting.elytratrails.fill": "Заливка",
|
||||
"setting.elytratrails.taper.option.Uniform": "Ровная",
|
||||
"setting.elytratrails.taper.option.Grow": "Расширяется",
|
||||
"setting.elytratrails.taper.option.Shrink": "Сужается",
|
||||
"setting.elytratrails.motion.option.Straight": "Прямо",
|
||||
"setting.elytratrails.motion.option.Wave": "Волна",
|
||||
"setting.elytratrails.motion.option.Helix": "Спираль",
|
||||
"setting.elytratrails.others": "Другие игроки",
|
||||
"setting.elytratrails.duration": "Длительность (мс)",
|
||||
"setting.elytratrails.width": "Ширина",
|
||||
|
|
@ -3492,16 +3508,17 @@
|
|||
"setting.elytratrails.color_phase": "Фаза цвета",
|
||||
"setting.blizzard.intensity": "Интенсивность",
|
||||
"setting.blizzard.density": "Плотность",
|
||||
"setting.blizzard.speed": "Скорость падения",
|
||||
"setting.blizzard.wind": "Ветер",
|
||||
"setting.blizzard.hide_vanilla": "Скрыть ванильную погоду",
|
||||
"setting.blizzard.visibility": "Видимость",
|
||||
"setting.blizzard.whiteout": "Белая пелена",
|
||||
"setting.blizzard.turbulence": "Турбулентность",
|
||||
"setting.blizzard.radius": "Радиус",
|
||||
"setting.blizzard.flake_size": "Размер снежинок",
|
||||
"setting.blizzard.opacity": "Прозрачность снежинок",
|
||||
"setting.blizzard.opacity": "Прозрачность",
|
||||
"setting.blizzard.tint": "Оттенок",
|
||||
"setting.blizzard.haze": "Дымка",
|
||||
"setting.blizzard.frost": "Обмерзание краёв",
|
||||
"setting.blizzard.frost_intensity": "Сила обмерзания",
|
||||
"setting.blizzard.only_when_snowing": "Только во время снега",
|
||||
"setting.blizzard.frost": "Обмерзание экрана",
|
||||
"setting.blizzard.only_when_snowing": "Только во время снегопада",
|
||||
"setting.elytratrails.color_mode.option.Static": "Статичный",
|
||||
"setting.elytratrails.color_mode.option.Rainbow": "Радуга",
|
||||
"setting.elytratrails.color_mode.option.LightRainbow": "Светлая радуга",
|
||||
|
|
|
|||
|
|
@ -149,6 +149,7 @@
|
|||
"render.entity.item.ItemEntityRendererMixin",
|
||||
"render.entity.LivingEntityRenderStateMixin",
|
||||
"render.fog.AtmosphericFogEnvironmentMixin",
|
||||
"render.weather.BlizzardFogMixin",
|
||||
"render.fog.FogRendererMixin",
|
||||
"render.fog.LavaFogModifierMixin",
|
||||
"render.fog.WaterFogEnvironmentMixin",
|
||||
|
|
@ -170,6 +171,7 @@
|
|||
"render.main.GameRendererViewEffectsMixin",
|
||||
"render.main.MinecraftMixin",
|
||||
"render.main.ScreenEffectRendererMixin",
|
||||
"render.weather.WeatherHideMixin",
|
||||
"render.spatialgui.GuiRendererCaptureMixin",
|
||||
"render.spatialgui.ScreenSpatialBackgroundMixin",
|
||||
"render.sound.LibraryMixin",
|
||||
|
|
|
|||
|
|
@ -0,0 +1,61 @@
|
|||
package dev.loki.lovisual.features.module.modules.visuals.effects.elytratrails;
|
||||
|
||||
import dev.loki.lovisual.features.module.modules.visuals.effects.ribbon.RibbonPoint;
|
||||
import dev.loki.lovisual.features.module.modules.visuals.effects.ribbon.RibbonShape;
|
||||
import org.junit.jupiter.api.Test;
|
||||
|
||||
import static org.junit.jupiter.api.Assertions.*;
|
||||
|
||||
/** Спецификация вида ленты шлейфа элитры: как настройки влияют на ширину, прозрачность и колебания. */
|
||||
class ElytraRibbonStyleTest {
|
||||
|
||||
private static ElytraRibbonStyle style(RibbonShape.Taper taper, RibbonShape.Motion motion, boolean speed,
|
||||
boolean fadeStart) {
|
||||
return new ElytraRibbonStyle(taper, 2f, motion, 0.3f, 3f, speed, fadeStart, true, true);
|
||||
}
|
||||
|
||||
private static final RibbonPoint FAST = new RibbonPoint(0, 0, 0, 0L, 2.0f);
|
||||
private static final RibbonPoint SLOW = new RibbonPoint(0, 0, 0, 0L, 0.0f);
|
||||
|
||||
@Test
|
||||
void theRibbonLiesFlatLikeABannerAndHasAHaloOnlyWhenAsked() {
|
||||
assertTrue(style(RibbonShape.Taper.GROW, RibbonShape.Motion.STRAIGHT, false, false).horizontal());
|
||||
assertTrue(style(RibbonShape.Taper.GROW, RibbonShape.Motion.STRAIGHT, false, false).glowScale() > 1.0f);
|
||||
ElytraRibbonStyle none = new ElytraRibbonStyle(RibbonShape.Taper.GROW, 2f, RibbonShape.Motion.STRAIGHT, 0.3f, 3f,
|
||||
false, false, false, false);
|
||||
assertEquals(0f, none.glowScale(), 0f);
|
||||
assertFalse(none.softEdges());
|
||||
}
|
||||
|
||||
@Test
|
||||
void widthIsHalfTheSettingTimesTaperTimesSpeedFactor() {
|
||||
ElytraRibbonStyle plain = style(RibbonShape.Taper.UNIFORM, RibbonShape.Motion.STRAIGHT, false, false);
|
||||
assertEquals(0.11, plain.halfWidth(0.22f, 3, 10, FAST, 1f), 1e-9);
|
||||
ElytraRibbonStyle grow = style(RibbonShape.Taper.GROW, RibbonShape.Motion.STRAIGHT, false, false);
|
||||
assertTrue(grow.halfWidth(0.22f, 0, 10, FAST, 1f) > grow.halfWidth(0.22f, 9, 10, FAST, 1f));
|
||||
ElytraRibbonStyle reactive = style(RibbonShape.Taper.UNIFORM, RibbonShape.Motion.STRAIGHT, true, false);
|
||||
assertTrue(reactive.halfWidth(0.22f, 3, 10, SLOW, 1f) < reactive.halfWidth(0.22f, 3, 10, FAST, 1f));
|
||||
}
|
||||
|
||||
@Test
|
||||
void ribbonIsInvisibleRightAtTheCameraAndWhenExpired() {
|
||||
ElytraRibbonStyle s = style(RibbonShape.Taper.UNIFORM, RibbonShape.Motion.STRAIGHT, false, false);
|
||||
assertEquals(0f, s.alpha(3, 10, FAST, 1f, 0.3f), 0f);
|
||||
assertEquals(0f, s.alpha(3, 10, FAST, 0f, 20f), 0f);
|
||||
assertEquals(1f, s.alpha(3, 10, FAST, 1f, 20f), 1e-6f);
|
||||
}
|
||||
|
||||
@Test
|
||||
void fadeStartHidesTheNewestPointOnly() {
|
||||
ElytraRibbonStyle s = style(RibbonShape.Taper.UNIFORM, RibbonShape.Motion.STRAIGHT, false, true);
|
||||
assertEquals(0f, s.alpha(9, 10, FAST, 1f, 20f), 0f);
|
||||
assertEquals(1f, s.alpha(2, 10, FAST, 1f, 20f), 1e-6f);
|
||||
}
|
||||
|
||||
@Test
|
||||
void onlyWavyMotionsMoveThePoints() {
|
||||
assertNull(style(RibbonShape.Taper.UNIFORM, RibbonShape.Motion.STRAIGHT, false, false).motion(FAST));
|
||||
assertNotNull(style(RibbonShape.Taper.UNIFORM, RibbonShape.Motion.WAVE, false, false).motion(FAST));
|
||||
assertNotNull(style(RibbonShape.Taper.UNIFORM, RibbonShape.Motion.HELIX, false, false).motion(FAST));
|
||||
}
|
||||
}
|
||||
|
|
@ -1,81 +0,0 @@
|
|||
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));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -60,4 +60,35 @@ class WingTrailStoreTest {
|
|||
s.clear();
|
||||
assertTrue(s.snapshot().isEmpty());
|
||||
}
|
||||
|
||||
@Test
|
||||
void aTeleportOrAPauseStartsAFreshRibbonInsteadOfAStreakAcrossTheMap() {
|
||||
WingTrailStore s = new WingTrailStore();
|
||||
s.sample(A, WingTrailStore.LEFT, 0, 0, 0, 0L, 5000L);
|
||||
s.sample(A, WingTrailStore.LEFT, 1, 0, 0, 50L, 5000L);
|
||||
s.sample(A, WingTrailStore.LEFT, 300, 0, 0, 100L, 5000L);
|
||||
assertEquals(1, s.snapshot().get(0).size());
|
||||
s.sample(A, WingTrailStore.LEFT, 301, 0, 0, 150L, 5000L);
|
||||
s.sample(A, WingTrailStore.LEFT, 302, 0, 0, 2000L, 5000L);
|
||||
assertEquals(1, s.snapshot().get(0).size(), "a pause of more than 300 ms breaks the ribbon");
|
||||
}
|
||||
|
||||
@Test
|
||||
void pointsRememberHowFastThePlayerFlew() {
|
||||
WingTrailStore s = new WingTrailStore();
|
||||
s.sample(A, WingTrailStore.RIGHT, 0, 0, 0, 0L, 5000L, 0.8f);
|
||||
s.sample(A, WingTrailStore.RIGHT, 1, 0, 0, 50L, 5000L, 1.6f);
|
||||
var pts = s.snapshot().get(0).points();
|
||||
assertEquals(0.8f, pts.get(0).speed(), 0f);
|
||||
assertEquals(1.6f, pts.get(1).speed(), 0f);
|
||||
}
|
||||
|
||||
@Test
|
||||
void tinyWobbleBelowAThirdOfABlockIsNotRecorded() {
|
||||
WingTrailStore s = new WingTrailStore();
|
||||
s.sample(A, WingTrailStore.LEFT, 0, 0, 0, 0L, 5000L);
|
||||
s.sample(A, WingTrailStore.LEFT, 0.1, 0, 0, 50L, 5000L);
|
||||
s.sample(A, WingTrailStore.LEFT, 0.2, 0, 0, 100L, 5000L);
|
||||
assertEquals(1, s.snapshot().get(0).size());
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -0,0 +1,190 @@
|
|||
package dev.loki.lovisual.features.module.modules.visuals.effects.elytratrails.pose;
|
||||
|
||||
import net.minecraft.world.phys.Vec3;
|
||||
import org.junit.jupiter.api.Test;
|
||||
|
||||
import static org.junit.jupiter.api.Assertions.*;
|
||||
|
||||
/**
|
||||
* Спецификация концов крыльев (как в evo): они считаются по настоящей модели элитры на теле игрока, поэтому
|
||||
* симметричны, следуют за рысканием, масштабом и взмахами крыльев и лежат вплотную к игроку.
|
||||
*/
|
||||
class WingTipLocatorTest {
|
||||
|
||||
private static final double EPS = 1.0e-4;
|
||||
|
||||
/** Анимация крыльев при планировании по горизонтали (ElytraModel): крылья широко раскинуты. */
|
||||
private static final float[] SPREAD = {0.349f, 0f, -1.5708f};
|
||||
|
||||
private static WingTipLocator.Pose spread(float yaw, float xRot, float ticks) {
|
||||
return new WingTipLocator.Pose(10, 70, -3, yaw, xRot, 1f, false, false, ticks, 0f, 0f, SPREAD[0], SPREAD[1], SPREAD[2]);
|
||||
}
|
||||
|
||||
private static WingTipLocator.Pose pose(float yaw) {
|
||||
return new WingTipLocator.Pose(10, 70, -3, yaw, 0f, 1f, false, false, 100f, 0f, 0f, 0f, 0f, 0f);
|
||||
}
|
||||
|
||||
private static Vec3 rel(Vec3 tip, WingTipLocator.Pose p) {
|
||||
return tip.subtract(p.x(), p.y(), p.z());
|
||||
}
|
||||
|
||||
/** Поворот вокруг вертикали так, что направление +Z переходит в направление взгляда при рыскании yaw. */
|
||||
private static Vec3 turn(Vec3 v, float yawDeg) {
|
||||
double a = Math.toRadians(yawDeg);
|
||||
return new Vec3(v.x * Math.cos(a) - v.z * Math.sin(a), v.y, v.x * Math.sin(a) + v.z * Math.cos(a));
|
||||
}
|
||||
|
||||
// ---- форма
|
||||
|
||||
@Test
|
||||
void tipsAreMirrorImagesAcrossThePlaneOfTheBodyWithRelaxedWings() {
|
||||
WingTipLocator.Tips t = WingTipLocator.tips(pose(0f));
|
||||
Vec3 l = rel(t.left(), pose(0f)), r = rel(t.right(), pose(0f));
|
||||
assertEquals(l.x, -r.x, EPS);
|
||||
assertEquals(l.y, r.y, EPS);
|
||||
assertEquals(l.z, r.z, EPS);
|
||||
}
|
||||
|
||||
@Test
|
||||
void withSpreadWingsTheLeftTipIsOnThePlayersLeftHandSide() {
|
||||
// Facing +Z (yaw 0) the player's left is +X; facing -X (yaw 90) it is +Z.
|
||||
WingTipLocator.Tips south = WingTipLocator.tips(spread(0f, 0f, 100f));
|
||||
assertTrue(south.left().x > south.right().x);
|
||||
WingTipLocator.Tips west = WingTipLocator.tips(spread(90f, 0f, 100f));
|
||||
assertTrue(west.left().z > west.right().z);
|
||||
}
|
||||
|
||||
@Test
|
||||
void spreadWingsReachAboutAMeterAndAHalfToEachSideAndStayCloseToThePlayer() {
|
||||
for (float ticks : new float[]{0f, 5f, 100f}) {
|
||||
for (float xRot : new float[]{-30f, 0f, 60f, 90f}) {
|
||||
WingTipLocator.Pose p = spread(40f, xRot, ticks);
|
||||
WingTipLocator.Tips t = WingTipLocator.tips(p);
|
||||
double span = t.left().distanceTo(t.right());
|
||||
assertTrue(span > 2.2 && span < 3.2, "span " + span);
|
||||
Vec3 pos = new Vec3(p.x(), p.y(), p.z());
|
||||
assertTrue(t.left().distanceTo(pos) < 3.0 && t.right().distanceTo(pos) < 3.0);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
void standingWingsHangBehindTheBack() {
|
||||
for (float yaw : new float[]{0f, 63f, -140f, 270f}) {
|
||||
WingTipLocator.Pose p = new WingTipLocator.Pose(0, 0, 0, yaw, 0f, 1f, false, false, 0f, 0f, 0f, 0f, 0f, 0f);
|
||||
Vec3 facing = turn(new Vec3(0, 0, 1), yaw);
|
||||
WingTipLocator.Tips t = WingTipLocator.tips(p);
|
||||
assertTrue(t.left().dot(facing) < 0 && t.right().dot(facing) < 0, "yaw " + yaw);
|
||||
}
|
||||
}
|
||||
|
||||
// ---- следование за телом
|
||||
|
||||
@Test
|
||||
void tipsTurnWithTheBodyAroundTheVerticalThroughThePlayer() {
|
||||
Vec3 base = rel(WingTipLocator.tips(pose(0f)).left(), pose(0f));
|
||||
for (float yaw : new float[]{30f, 90f, -75f, 180f, 250f}) {
|
||||
Vec3 expected = turn(base, yaw);
|
||||
Vec3 actual = rel(WingTipLocator.tips(pose(yaw)).left(), pose(yaw));
|
||||
assertEquals(expected.x, actual.x, EPS);
|
||||
assertEquals(expected.y, actual.y, EPS);
|
||||
assertEquals(expected.z, actual.z, EPS);
|
||||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
void tipsFollowThePlayerWhenItMoves() {
|
||||
WingTipLocator.Pose a = pose(33f);
|
||||
WingTipLocator.Pose b = new WingTipLocator.Pose(17, 68, 4, 33f, 0f, 1f, false, false, 100f, 0f, 0f, 0f, 0f, 0f);
|
||||
assertEquals(7.0, WingTipLocator.tips(b).left().x - WingTipLocator.tips(a).left().x, EPS);
|
||||
assertEquals(-2.0, WingTipLocator.tips(b).right().y - WingTipLocator.tips(a).right().y, EPS);
|
||||
assertEquals(7.0, WingTipLocator.tips(b).right().z - WingTipLocator.tips(a).right().z, EPS);
|
||||
}
|
||||
|
||||
@Test
|
||||
void aBiggerPlayerHasProportionallyBiggerWings() {
|
||||
WingTipLocator.Pose one = new WingTipLocator.Pose(0, 0, 0, 20f, 10f, 1f, false, false, 100f, 0f, 0f, 0f, 0f, 0f);
|
||||
WingTipLocator.Pose two = new WingTipLocator.Pose(0, 0, 0, 20f, 10f, 2f, false, false, 100f, 0f, 0f, 0f, 0f, 0f);
|
||||
Vec3 a = WingTipLocator.tips(one).left(), b = WingTipLocator.tips(two).left();
|
||||
assertEquals(2 * a.x, b.x, EPS);
|
||||
assertEquals(2 * a.y, b.y, EPS);
|
||||
assertEquals(2 * a.z, b.z, EPS);
|
||||
}
|
||||
|
||||
// ---- полёт
|
||||
|
||||
@Test
|
||||
void whileGlidingTheBodyLiesForwardSoTheWingsMoveFromBehindTheBackToAhead() {
|
||||
double behind = WingTipLocator.tips(spread(0f, 0f, 0f)).left().z + 3;
|
||||
double ahead = WingTipLocator.tips(spread(0f, 0f, 100f)).left().z + 3;
|
||||
assertTrue(behind < 0.0, "standing: wings behind the back");
|
||||
assertTrue(ahead > behind + 1.0, "gliding: the body lies forward");
|
||||
}
|
||||
|
||||
@Test
|
||||
void fallFlyingEasesInOverTheFirstTicks() {
|
||||
assertEquals(0f, WingTipLocator.fallFlyingScale(0f), 0f);
|
||||
assertEquals(0.25f, WingTipLocator.fallFlyingScale(5f), 1e-6f);
|
||||
assertEquals(1f, WingTipLocator.fallFlyingScale(10f), 1e-6f);
|
||||
assertEquals(1f, WingTipLocator.fallFlyingScale(400f), 0f);
|
||||
assertTrue(WingTipLocator.fallFlyingScale(6f) > WingTipLocator.fallFlyingScale(4f));
|
||||
}
|
||||
|
||||
@Test
|
||||
void steeringTurnsTheBodyAlongTheFlightPath() {
|
||||
// Heading east while looking north: the body banks round by a quarter turn.
|
||||
assertEquals(0f, WingTipLocator.flyingYRot(1, 0, 1, 0), 1e-6f);
|
||||
assertEquals(0f, WingTipLocator.flyingYRot(0, 0, 1, 0), 0f, "no movement, no turn");
|
||||
assertEquals(0f, WingTipLocator.flyingYRot(1, 0, 0, 0), 0f, "looking straight up or down, no turn");
|
||||
float quarter = WingTipLocator.flyingYRot(1, 0, 0, 1);
|
||||
assertEquals(Math.PI / 2, Math.abs(quarter), 1e-5);
|
||||
assertEquals(-quarter, WingTipLocator.flyingYRot(-1, 0, 0, 1), 1e-5f, "opposite side, opposite sign");
|
||||
for (double a = 0; a < 6.3; a += 0.4) {
|
||||
assertTrue(Math.abs(WingTipLocator.flyingYRot(Math.cos(a), Math.sin(a), 1, 0)) <= Math.PI / 2 + 1e-6);
|
||||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
void spinAttackSpinsTheBodyButKeepsTheWingsAtTheSameDistanceFromThePlayer() {
|
||||
Vec3 first = null;
|
||||
double dist0 = Double.NaN;
|
||||
boolean moved = false;
|
||||
for (float time : new float[]{0f, 3f, 7.5f, 21f}) {
|
||||
WingTipLocator.Pose p = new WingTipLocator.Pose(0, 0, 0, 0f, 20f, 1f, false, true, 100f, 0f, time,
|
||||
SPREAD[0], SPREAD[1], SPREAD[2]);
|
||||
Vec3 tip = WingTipLocator.tips(p).left();
|
||||
if (first == null) {
|
||||
first = tip;
|
||||
dist0 = tip.length();
|
||||
}
|
||||
assertEquals(dist0, tip.length(), EPS);
|
||||
moved |= tip.distanceTo(first) > 0.5;
|
||||
}
|
||||
assertTrue(moved, "the wings circle while spinning");
|
||||
}
|
||||
|
||||
// ---- крылья и поза
|
||||
|
||||
@Test
|
||||
void flappingMovesBothWingsAsMirrorImages() {
|
||||
WingTipLocator.Pose p = new WingTipLocator.Pose(0, 0, 0, 0f, 0f, 1f, false, false, 100f, 0f, 0f, 0.3f, 0.2f, 0.5f);
|
||||
WingTipLocator.Tips t = WingTipLocator.tips(p);
|
||||
assertEquals(t.left().x, -t.right().x, EPS);
|
||||
assertEquals(t.left().y, t.right().y, EPS);
|
||||
assertEquals(t.left().z, t.right().z, EPS);
|
||||
WingTipLocator.Pose still = new WingTipLocator.Pose(0, 0, 0, 0f, 0f, 1f, false, false, 100f, 0f, 0f, 0f, 0f, 0f);
|
||||
assertTrue(t.left().distanceTo(WingTipLocator.tips(still).left()) > 0.2, "flapping moves the tip");
|
||||
}
|
||||
|
||||
@Test
|
||||
void sneakingShiftsBothWingsTogetherByLessThanAQuarterBlock() {
|
||||
WingTipLocator.Pose up = new WingTipLocator.Pose(0, 0, 0, 25f, 0f, 1f, false, false, 0f, 0f, 0f, 0f, 0f, 0f);
|
||||
WingTipLocator.Pose down = new WingTipLocator.Pose(0, 0, 0, 25f, 0f, 1f, true, false, 0f, 0f, 0f, 0f, 0f, 0f);
|
||||
Vec3 dl = WingTipLocator.tips(down).left().subtract(WingTipLocator.tips(up).left());
|
||||
Vec3 dr = WingTipLocator.tips(down).right().subtract(WingTipLocator.tips(up).right());
|
||||
assertTrue(dl.length() > 0.01 && dl.length() < 0.25);
|
||||
assertEquals(dl.x, dr.x, EPS);
|
||||
assertEquals(dl.y, dr.y, EPS);
|
||||
assertEquals(dl.z, dr.z, EPS);
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,147 @@
|
|||
package dev.loki.lovisual.features.module.modules.visuals.effects.ribbon;
|
||||
|
||||
import org.junit.jupiter.api.Test;
|
||||
|
||||
import static org.junit.jupiter.api.Assertions.*;
|
||||
|
||||
/** Спецификация формы ленты шлейфа: сужение, реакция на скорость, плавное начало, затухание у камеры, колебания. */
|
||||
class RibbonShapeTest {
|
||||
|
||||
// ---- сужение
|
||||
|
||||
@Test
|
||||
void uniformTaperKeepsTheWidth() {
|
||||
for (float t : new float[]{0f, 0.3f, 1f}) assertEquals(1f, RibbonShape.taper(RibbonShape.Taper.UNIFORM, 1.8f, t), 0f);
|
||||
}
|
||||
|
||||
@Test
|
||||
void growTaperWidensTowardsTheTailLikeAJetPlume() {
|
||||
float head = RibbonShape.taper(RibbonShape.Taper.GROW, 1.8f, 0f);
|
||||
float tail = RibbonShape.taper(RibbonShape.Taper.GROW, 1.8f, 1f);
|
||||
assertEquals(1f, head, 1e-6f);
|
||||
assertEquals(1.8f, tail, 1e-6f);
|
||||
assertTrue(RibbonShape.taper(RibbonShape.Taper.GROW, 1.8f, 0.5f) > head);
|
||||
}
|
||||
|
||||
@Test
|
||||
void shrinkTaperNarrowsTheTailIntoANeedle() {
|
||||
float head = RibbonShape.taper(RibbonShape.Taper.SHRINK, 2f, 0f);
|
||||
float tail = RibbonShape.taper(RibbonShape.Taper.SHRINK, 2f, 1f);
|
||||
assertEquals(1f, head, 1e-6f);
|
||||
assertEquals(0.5f, tail, 1e-6f);
|
||||
}
|
||||
|
||||
@Test
|
||||
void tailnessIsZeroAtTheNewestPointAndOneAtTheOldest() {
|
||||
assertEquals(1f, RibbonShape.tailness(0, 10), 1e-6f);
|
||||
assertEquals(0f, RibbonShape.tailness(9, 10), 1e-6f);
|
||||
assertEquals(0f, RibbonShape.tailness(0, 1), 0f);
|
||||
}
|
||||
|
||||
// ---- скорость
|
||||
|
||||
@Test
|
||||
void speedFactorRisesFromSlowGlideToFullSpeed() {
|
||||
assertEquals(0f, RibbonShape.speedFactor(0.1f), 0f);
|
||||
assertEquals(1f, RibbonShape.speedFactor(5f), 0f);
|
||||
assertTrue(RibbonShape.speedFactor(1.0f) > RibbonShape.speedFactor(0.6f));
|
||||
}
|
||||
|
||||
@Test
|
||||
void speedReactiveRibbonIsThinnerAndFainterWhenSlowButNeverVanishes() {
|
||||
assertEquals(1f, RibbonShape.speedWidth(false, 0f), 0f);
|
||||
assertEquals(1f, RibbonShape.speedAlpha(false, 0f), 0f);
|
||||
assertTrue(RibbonShape.speedWidth(true, 0f) > 0.4f && RibbonShape.speedWidth(true, 0f) < 0.5f);
|
||||
assertTrue(RibbonShape.speedAlpha(true, 0f) >= 0.25f);
|
||||
assertEquals(1f, RibbonShape.speedWidth(true, 9f), 1e-6f);
|
||||
assertEquals(1f, RibbonShape.speedAlpha(true, 9f), 1e-6f);
|
||||
}
|
||||
|
||||
// ---- плавное начало и приближение камеры
|
||||
|
||||
@Test
|
||||
void fadeStartMakesTheNewestPointsTransparent() {
|
||||
assertEquals(0f, RibbonShape.headRamp(true, 9, 10), 0f);
|
||||
assertTrue(RibbonShape.headRamp(true, 8, 10) < RibbonShape.headRamp(true, 6, 10));
|
||||
assertEquals(1f, RibbonShape.headRamp(true, 5, 10), 0f);
|
||||
assertEquals(1f, RibbonShape.headRamp(false, 9, 10), 0f);
|
||||
}
|
||||
|
||||
@Test
|
||||
void ribbonFadesOutWhenTheCameraIsRightOnIt() {
|
||||
assertEquals(0f, RibbonShape.nearFade(0.5f), 0f);
|
||||
assertEquals(0f, RibbonShape.nearFade(0.75f), 0f);
|
||||
assertEquals(1f, RibbonShape.nearFade(2.25f), 1e-6f);
|
||||
assertEquals(1f, RibbonShape.nearFade(40f), 0f);
|
||||
assertTrue(RibbonShape.nearFade(1.5f) > 0f && RibbonShape.nearFade(1.5f) < 1f);
|
||||
}
|
||||
|
||||
@Test
|
||||
void agedPointsFadeLinearlyToNothing() {
|
||||
assertEquals(1f, RibbonShape.life(1000, 1000, 1200), 0f);
|
||||
assertEquals(0.5f, RibbonShape.life(1600, 1000, 1200), 1e-6f);
|
||||
assertEquals(0f, RibbonShape.life(2500, 1000, 1200), 0f);
|
||||
assertEquals(0f, RibbonShape.life(1000, 1000, 0), 0f);
|
||||
}
|
||||
|
||||
// ---- колебания
|
||||
|
||||
@Test
|
||||
void straightMotionDoesNotMoveThePoints() {
|
||||
float[] o = RibbonShape.motion(RibbonShape.Motion.STRAIGHT, 12345L, 0.5f, 3f);
|
||||
assertEquals(0f, o[0], 0f);
|
||||
assertEquals(0f, o[1], 0f);
|
||||
}
|
||||
|
||||
@Test
|
||||
void waveSwaysOnlySidewaysAndStaysWithinTheAmplitude() {
|
||||
for (long ms = 0; ms < 5000; ms += 37) {
|
||||
float[] o = RibbonShape.motion(RibbonShape.Motion.WAVE, ms, 0.35f, 3f);
|
||||
assertTrue(Math.abs(o[0]) <= 0.35f + 1e-6f);
|
||||
assertEquals(0f, o[1], 0f);
|
||||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
void helixCorkscrewsOnACircleOfTheAmplitudeRadius() {
|
||||
for (long ms = 0; ms < 5000; ms += 53) {
|
||||
float[] o = RibbonShape.motion(RibbonShape.Motion.HELIX, ms, 0.4f, 2f);
|
||||
assertEquals(0.4f, Math.hypot(o[0], o[1]), 1e-5);
|
||||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
void motionIsAFunctionOfTheBirthTimeSoItTravelsWithThePoint() {
|
||||
float[] a = RibbonShape.motion(RibbonShape.Motion.WAVE, 777L, 0.3f, 3f);
|
||||
float[] b = RibbonShape.motion(RibbonShape.Motion.WAVE, 777L, 0.3f, 3f);
|
||||
assertArrayEquals(a, b);
|
||||
}
|
||||
|
||||
@Test
|
||||
void fasterWaveOscillatesMoreOftenInTheSameTime() {
|
||||
assertTrue(crossings(1f) < crossings(6f));
|
||||
}
|
||||
|
||||
private static int crossings(float freq) {
|
||||
int count = 0;
|
||||
float prev = RibbonShape.motion(RibbonShape.Motion.WAVE, 0, 1f, freq)[0];
|
||||
for (long ms = 10; ms < 4000; ms += 10) {
|
||||
float cur = RibbonShape.motion(RibbonShape.Motion.WAVE, ms, 1f, freq)[0];
|
||||
if ((prev < 0) != (cur < 0)) count++;
|
||||
prev = cur;
|
||||
}
|
||||
return count;
|
||||
}
|
||||
|
||||
// ---- разбор настроек
|
||||
|
||||
@Test
|
||||
void namesParseCaseInsensitivelyWithAFallback() {
|
||||
assertEquals(RibbonShape.Taper.GROW, RibbonShape.Taper.of("Grow"));
|
||||
assertEquals(RibbonShape.Taper.SHRINK, RibbonShape.Taper.of("shrink"));
|
||||
assertEquals(RibbonShape.Taper.UNIFORM, RibbonShape.Taper.of("nonsense"));
|
||||
assertEquals(RibbonShape.Motion.HELIX, RibbonShape.Motion.of("Helix"));
|
||||
assertEquals(RibbonShape.Motion.WAVE, RibbonShape.Motion.of("WAVE"));
|
||||
assertEquals(RibbonShape.Motion.STRAIGHT, RibbonShape.Motion.of(null));
|
||||
}
|
||||
}
|
||||
|
|
@ -60,4 +60,50 @@ class TrackBufferTest {
|
|||
prev = p.ms();
|
||||
}
|
||||
}
|
||||
|
||||
// ---- непрерывность следа (шлейф крыла): телепорт и пауза рвут ленту, шум не копится
|
||||
|
||||
@Test
|
||||
void aBigJumpStartsANewRibbonInsteadOfDrawingALongStreak() {
|
||||
TrackBuffer b = new TrackBuffer();
|
||||
b.addContinuous(0, 0, 0, 0L, 5000L, 1f, 0.35, 8.0, 300L);
|
||||
b.addContinuous(1, 0, 0, 50L, 5000L, 1f, 0.35, 8.0, 300L);
|
||||
b.addContinuous(500, 0, 0, 100L, 5000L, 1f, 0.35, 8.0, 300L);
|
||||
assertEquals(1, b.size());
|
||||
assertEquals(500.0, b.points().get(0).x(), 0.0);
|
||||
}
|
||||
|
||||
@Test
|
||||
void aLongPauseStartsANewRibbon() {
|
||||
TrackBuffer b = new TrackBuffer();
|
||||
b.addContinuous(0, 0, 0, 0L, 60_000L, 1f, 0.35, 8.0, 300L);
|
||||
b.addContinuous(1, 0, 0, 100L, 60_000L, 1f, 0.35, 8.0, 300L);
|
||||
b.addContinuous(2, 0, 0, 1000L, 60_000L, 1f, 0.35, 8.0, 300L);
|
||||
assertEquals(1, b.size());
|
||||
}
|
||||
|
||||
@Test
|
||||
void stepsSmallerThanTheMinimumAreNotRecorded() {
|
||||
TrackBuffer b = new TrackBuffer();
|
||||
b.addContinuous(0, 0, 0, 0L, 5000L, 1f, 0.35, 8.0, 300L);
|
||||
assertFalse(b.addContinuous(0.2, 0, 0, 50L, 5000L, 1f, 0.35, 8.0, 300L));
|
||||
assertTrue(b.addContinuous(0.4, 0, 0, 100L, 5000L, 1f, 0.35, 8.0, 300L));
|
||||
}
|
||||
|
||||
@Test
|
||||
void everyPointRemembersTheSpeedItWasTakenAt() {
|
||||
TrackBuffer b = new TrackBuffer();
|
||||
b.addContinuous(0, 0, 0, 0L, 5000L, 0.5f, 0.35, 8.0, 300L);
|
||||
b.addContinuous(1, 0, 0, 50L, 5000L, 1.5f, 0.35, 8.0, 300L);
|
||||
assertEquals(0.5f, b.points().get(0).speed(), 0f);
|
||||
assertEquals(1.5f, b.points().get(1).speed(), 0f);
|
||||
}
|
||||
|
||||
@Test
|
||||
void theRibbonHoldsAtMostTwoHundredFiftySixPoints() {
|
||||
TrackBuffer b = new TrackBuffer();
|
||||
for (int i = 0; i < 400; i++) b.addContinuous(i * 0.5, 0, 0, i * 10L, 600_000L, 1f, 0.35, 8.0, 300L);
|
||||
assertEquals(256, b.size());
|
||||
assertEquals(399 * 0.5, b.points().get(255).x(), 1e-9);
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -62,4 +62,51 @@ class TrailRibbonTest {
|
|||
void halfWidthScalesLinearlyWithWidthSetting() {
|
||||
assertEquals(2.0 * TrailRibbon.halfWidth(0.3f, 0.6f), TrailRibbon.halfWidth(0.6f, 0.6f), 1e-6);
|
||||
}
|
||||
|
||||
// ---- ориентация ленты
|
||||
|
||||
private static final double[][] PATH = {{0, 64, 0}, {1, 64, 0.5}, {2, 64, 1.0}, {3, 65, 1.5}};
|
||||
|
||||
@Test
|
||||
void horizontalRibbonSidesAreLevelUnitAndPerpendicularToTheDirectionOfFlight() {
|
||||
double[][] side = TrailRibbon.frame(PATH, new net.minecraft.world.phys.Vec3(0, 80, 0), true);
|
||||
for (int i = 0; i < PATH.length; i++) {
|
||||
assertEquals(0.0, side[i][1], 1e-12, "level");
|
||||
assertEquals(1.0, Math.hypot(side[i][0], side[i][2]), 1e-9, "unit");
|
||||
double[] a = PATH[Math.max(0, i - 1)], b = PATH[Math.min(PATH.length - 1, i + 1)];
|
||||
assertEquals(0.0, side[i][0] * (b[0] - a[0]) + side[i][2] * (b[2] - a[2]), 1e-9, "perpendicular");
|
||||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
void cameraFacingRibbonSidesAreUnitAndPerpendicularToBothTheFlightAndTheViewDirection() {
|
||||
net.minecraft.world.phys.Vec3 cam = new net.minecraft.world.phys.Vec3(1, 70, 8);
|
||||
double[][] side = TrailRibbon.frame(PATH, cam, false);
|
||||
for (int i = 0; i < PATH.length; i++) {
|
||||
assertEquals(1.0, Math.sqrt(side[i][0] * side[i][0] + side[i][1] * side[i][1] + side[i][2] * side[i][2]), 1e-9);
|
||||
double[] a = PATH[Math.max(0, i - 1)], b = PATH[Math.min(PATH.length - 1, i + 1)];
|
||||
double dot = side[i][0] * (b[0] - a[0]) + side[i][1] * (b[1] - a[1]) + side[i][2] * (b[2] - a[2]);
|
||||
assertEquals(0.0, dot, 1e-9);
|
||||
double view = side[i][0] * (cam.x - PATH[i][0]) + side[i][1] * (cam.y - PATH[i][1]) + side[i][2] * (cam.z - PATH[i][2]);
|
||||
assertEquals(0.0, view, 1e-9);
|
||||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
void aPurelyVerticalPathStillGetsAHorizontalSide() {
|
||||
double[][] up = {{0, 64, 0}, {0, 65, 0}, {0, 66, 0}};
|
||||
double[][] side = TrailRibbon.frame(up, new net.minecraft.world.phys.Vec3(5, 65, 5), true);
|
||||
for (double[] s : side) {
|
||||
assertEquals(0.0, s[1], 0.0);
|
||||
assertEquals(1.0, Math.hypot(s[0], s[2]), 1e-9);
|
||||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
void alphaScalingKeepsColourAndClampsTheMultiplier() {
|
||||
assertEquals(0x80123456, TrailRibbon.scaleAlpha(0xFF123456, 0.5f) | 0, 0x01);
|
||||
assertEquals(0xFF, TrailRibbon.scaleAlpha(0xFF123456, 9f) >>> 24);
|
||||
assertEquals(0x00, TrailRibbon.scaleAlpha(0xFF123456, -2f) >>> 24);
|
||||
assertEquals(0x123456, TrailRibbon.scaleAlpha(0xFF123456, 0.3f) & 0xFFFFFF);
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1,79 +0,0 @@
|
|||
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);
|
||||
}
|
||||
}
|
||||
|
|
@ -4,71 +4,71 @@ import org.junit.jupiter.api.Test;
|
|||
|
||||
import static org.junit.jupiter.api.Assertions.*;
|
||||
|
||||
/** Спецификация видимости снежинки: не лезет в глаза у камеры, плавно появляется и тает на границе зоны. */
|
||||
/** Спецификация вида снежинки (как в evo): не лезет в глаза у линзы, проявляется со временем, тянется вдоль скорости. */
|
||||
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);
|
||||
private static float alpha(float dist, float age, float base, float opacity, float strength, float intensity) {
|
||||
return BlizzardRenderer.flakeAlpha(dist, age, base, opacity, strength, intensity);
|
||||
}
|
||||
|
||||
@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);
|
||||
assertEquals(0f, alpha(0.1f, 5f, 0.8f, 1f, 1f, 1f), 0f);
|
||||
assertEquals(0f, alpha(0.25f, 5f, 0.8f, 1f, 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);
|
||||
void flakesFadeInWithDistanceUntilAboutAMeter() {
|
||||
assertTrue(alpha(0.6f, 5f, 0.8f, 1f, 1f, 1f) > alpha(0.4f, 5f, 0.8f, 1f, 1f, 1f));
|
||||
assertEquals(alpha(1.5f, 5f, 0.8f, 1f, 1f, 1f), alpha(30f, 5f, 0.8f, 1f, 1f, 1f), 1e-6f);
|
||||
}
|
||||
|
||||
@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));
|
||||
assertEquals(0f, alpha(5f, 0f, 0.8f, 1f, 1f, 1f), 0f);
|
||||
assertTrue(alpha(5f, 0.2f, 0.8f, 1f, 1f, 1f) < alpha(5f, 1f, 0.8f, 1f, 1f, 1f));
|
||||
assertEquals(alpha(5f, 0.45f, 0.8f, 1f, 1f, 1f), alpha(5f, 9f, 0.8f, 1f, 1f, 1f), 1e-6f);
|
||||
}
|
||||
|
||||
@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);
|
||||
void opacityAndShareOfStormScaleVisibilityLinearly() {
|
||||
float full = alpha(5f, 5f, 0.5f, 0.6f, 0.6f, 0.6f);
|
||||
assertEquals(0.5, alpha(5f, 5f, 0.5f, 0.3f, 0.6f, 0.6f) / full, 1e-5);
|
||||
assertEquals(0.5, alpha(5f, 5f, 0.5f, 0.6f, 0.3f, 0.6f) / full, 1e-5);
|
||||
assertEquals(0f, alpha(5f, 5f, 0.5f, 0f, 1f, 1f), 0f);
|
||||
assertEquals(0f, alpha(5f, 5f, 0.5f, 1f, 0f, 1f), 0f);
|
||||
}
|
||||
|
||||
@Test
|
||||
void aFlakeIsNeverFullyOpaque() {
|
||||
assertTrue(alpha(5f, 5f, 1f, 1f, 1f) <= 0.95f);
|
||||
void aFlakeNeverBecomesMoreOpaqueThanItsOwnAlphaTimesOpacity() {
|
||||
assertTrue(alpha(5f, 5f, 0.7f, 0.8f, 1f, 0.5f) <= 0.7f * 0.8f + 1e-6f);
|
||||
}
|
||||
|
||||
@Test
|
||||
void faintFlakesAreSkippedInsteadOfDrawnInvisibly() {
|
||||
assertEquals(0f, alpha(5f, 5f, 0.35f, 0.01f, 0.2f), 0f);
|
||||
void flakesBehindTheCameraAreSkippedExceptCloseOnes() {
|
||||
assertTrue(BlizzardRenderer.isBehind(-0.9f, 10f));
|
||||
assertFalse(BlizzardRenderer.isBehind(-0.2f, 10f));
|
||||
assertFalse(BlizzardRenderer.isBehind(0.9f, 10f));
|
||||
assertFalse(BlizzardRenderer.isBehind(-0.9f, 2f));
|
||||
}
|
||||
|
||||
@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));
|
||||
void distantFlakesStayAtLeastAPixelWideAndSizeSettingScalesNearOnes() {
|
||||
assertEquals(0.028f, BlizzardRenderer.radius(1f, 1f, 5f), 1e-6f);
|
||||
assertEquals(200f * 0.0024f, BlizzardRenderer.radius(1f, 1f, 200f), 1e-5f);
|
||||
assertTrue(BlizzardRenderer.radius(2f, 1f, 5f) > BlizzardRenderer.radius(1f, 1f, 5f));
|
||||
assertTrue(BlizzardRenderer.radius(1f, 1.4f, 5f) > BlizzardRenderer.radius(1f, 0.6f, 5f));
|
||||
}
|
||||
|
||||
@Test
|
||||
void fasterFlakesLeaveLongerStreaksUpToALimit() {
|
||||
float slow = BlizzardRenderer.halfLength(0.05f, 2f, 1f);
|
||||
float fast = BlizzardRenderer.halfLength(0.05f, 10f, 1f);
|
||||
void fasterFlakesLeaveLongerTrailsWhichAreLongerBehindThanInFront() {
|
||||
float r = 0.03f;
|
||||
float slow = BlizzardRenderer.streakLength(r, 2f, 1f);
|
||||
float fast = BlizzardRenderer.streakLength(r, 20f, 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);
|
||||
assertEquals(r, BlizzardRenderer.streakLength(r, 0f, 1f), 1e-7f);
|
||||
float front = BlizzardRenderer.frontLength(r, fast);
|
||||
assertTrue(front < fast && front > r);
|
||||
assertEquals(r, BlizzardRenderer.frontLength(r, r), 1e-7f);
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -4,10 +4,20 @@ import org.junit.jupiter.api.Test;
|
|||
|
||||
import static org.junit.jupiter.api.Assertions.*;
|
||||
|
||||
/** Спецификация метели: сколько снежинок, как они падают, куда их сносит ветер. */
|
||||
/**
|
||||
* Спецификация метели (поведение evo): сколько снежинок, где они живут, как падают, куда их сносит ветер.
|
||||
* Снег идёт только над землёй и под открытым небом, внутри помещения его нет.
|
||||
*/
|
||||
class SnowFlakesTest {
|
||||
|
||||
private static final SnowFlakes.Wind CALM = SnowFlakes.Wind.ZERO;
|
||||
private static final SnowFlakes.Ground FLAT = (x, z) -> 60.0f;
|
||||
private static final SnowFlakes.Ground ROOF = (x, z) -> 100.0f;
|
||||
|
||||
private static void tick(SnowFlakes f, float dt, double cx, double cy, double cz, float radius,
|
||||
SnowFlakes.Wind wind, float turb, int target, double seconds, SnowFlakes.Ground ground) {
|
||||
f.update(dt, cx, cy, cz, radius, wind, turb, target, seconds, ground);
|
||||
}
|
||||
|
||||
private static double meanY(SnowFlakes f) {
|
||||
double sum = 0;
|
||||
|
|
@ -26,8 +36,8 @@ class SnowFlakesTest {
|
|||
@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(12f, 0f, 1f));
|
||||
assertEquals(0, SnowFlakes.targetCount(12f, 1f, 0f));
|
||||
assertEquals(0, SnowFlakes.targetCount(-3f, 1f, 1f));
|
||||
}
|
||||
|
||||
|
|
@ -39,42 +49,73 @@ class SnowFlakesTest {
|
|||
}
|
||||
|
||||
@Test
|
||||
void halfTheIntensityGivesAboutHalfTheFlakes() {
|
||||
void halfTheStrengthGivesAboutHalfTheFlakes() {
|
||||
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));
|
||||
void flakeCountIsCapped() {
|
||||
assertEquals(SnowFlakes.MAX_FLAKES, SnowFlakes.targetCount(24f, 3f, 1f));
|
||||
assertEquals(12000, SnowFlakes.MAX_FLAKES);
|
||||
}
|
||||
|
||||
@Test
|
||||
void activeFlakeCountFollowsTheTargetUpAndDown() {
|
||||
SnowFlakes f = new SnowFlakes();
|
||||
f.update(0.016f, 0, 64, 0, 14f, CALM, 0f, 1f, 500, 0f);
|
||||
tick(f, 0.016f, 0, 64, 0, 14f, CALM, 0f, 500, 0, FLAT);
|
||||
assertEquals(500, f.count);
|
||||
f.update(0.016f, 0, 64, 0, 14f, CALM, 0f, 1f, 120, 0f);
|
||||
tick(f, 0.016f, 0, 64, 0, 14f, CALM, 0f, 120, 0, FLAT);
|
||||
assertEquals(120, f.count);
|
||||
f.update(0.016f, 0, 64, 0, 14f, CALM, 0f, 1f, 0, 0f);
|
||||
tick(f, 0.016f, 0, 64, 0, 14f, CALM, 0f, 0, 0, FLAT);
|
||||
assertEquals(0, f.count);
|
||||
}
|
||||
|
||||
// ---- где они живут
|
||||
// ---- где они живут: над землёй, не в помещении
|
||||
|
||||
@Test
|
||||
void freshFlakesSpawnInsideTheCylinderAroundTheCamera() {
|
||||
void freshFlakesSpawnInsideTheCylinderBetweenGroundAndCeiling() {
|
||||
SnowFlakes f = new SnowFlakes();
|
||||
f.update(0.0001f, 100, 70, -50, 12f, CALM, 0f, 1f, 800, 0f);
|
||||
tick(f, 0.0001f, 100, 70, -50, 12f, CALM, 0f, 800, 0, FLAT);
|
||||
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);
|
||||
assertTrue(f.y[i] > 64.0 - 1e-6, "not below camera - 6 (above the ground 60)");
|
||||
assertTrue(f.y[i] <= 70 + SnowFlakes.SPAWN_CEILING + 1e-6);
|
||||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
void noSnowFallsIndoorsUnderARoof() {
|
||||
SnowFlakes f = new SnowFlakes();
|
||||
tick(f, 0.016f, 0, 64, 0, 14f, CALM, 0f, 1000, 0, ROOF);
|
||||
assertEquals(0, f.count);
|
||||
}
|
||||
|
||||
@Test
|
||||
void snowStopsAtTheGroundAndNeverSinksBelowIt() {
|
||||
SnowFlakes f = new SnowFlakes();
|
||||
double cx = 0;
|
||||
for (int step = 0; step < 800; step++) {
|
||||
tick(f, 0.02f, cx, 62, 0, 14f, CALM, 0.4f, 400, step * 0.02, FLAT);
|
||||
for (int i = 0; i < f.count; i++) assertTrue(f.y[i] > 60.0, "flake sank into the ground");
|
||||
}
|
||||
assertTrue(f.count > 0);
|
||||
}
|
||||
|
||||
@Test
|
||||
void flakesOverARoofedColumnDisappearButOpenSkyKeepsSnowing() {
|
||||
// The left half (x < 0) is open sky, the right half is a roof.
|
||||
SnowFlakes.Ground half = (x, z) -> x < 0 ? 60.0f : 100.0f;
|
||||
SnowFlakes f = new SnowFlakes();
|
||||
for (int step = 0; step < 600; step++) {
|
||||
tick(f, 0.02f, 0, 64, 0, 14f, CALM, 0.3f, 600, step * 0.02, half);
|
||||
}
|
||||
assertTrue(f.count > 0);
|
||||
for (int i = 0; i < f.count; i++) assertTrue(f.x[i] < 1.0, "snow appeared under the roof");
|
||||
}
|
||||
|
||||
@Test
|
||||
void flakesFollowAMovingCameraAndNeverLeaveTheStormVolume() {
|
||||
SnowFlakes f = new SnowFlakes();
|
||||
|
|
@ -82,13 +123,23 @@ class SnowFlakesTest {
|
|||
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);
|
||||
tick(f, 0.02f, cx, 64, cz, 14f, SnowFlakes.wind(step * 0.02, 1.0f, 1.0f), 0.5f, 600, step * 0.02, FLAT);
|
||||
}
|
||||
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);
|
||||
assertTrue(f.y[i] <= 64 + SnowFlakes.RECYCLE_HEIGHT + 1e-6);
|
||||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
void teleportingTheCameraBringsTheSnowAlong() {
|
||||
SnowFlakes f = new SnowFlakes();
|
||||
tick(f, 0.02f, 0, 64, 0, 14f, CALM, 0f, 300, 0, FLAT);
|
||||
tick(f, 0.02f, 5000, 64, 5000, 14f, CALM, 0f, 300, 0, FLAT);
|
||||
for (int i = 0; i < f.count; i++) {
|
||||
assertTrue(Math.abs(f.x[i] - 5000) < 20 && Math.abs(f.z[i] - 5000) < 20);
|
||||
}
|
||||
}
|
||||
|
||||
|
|
@ -97,34 +148,22 @@ class SnowFlakesTest {
|
|||
@Test
|
||||
void snowFallsDownwardInCalmAir() {
|
||||
SnowFlakes f = new SnowFlakes();
|
||||
f.update(0.0001f, 0, 64, 0, 14f, CALM, 0f, 1f, 200, 0f);
|
||||
tick(f, 0.0001f, 0, 70, 0, 14f, CALM, 0f, 200, 0, FLAT);
|
||||
double before = meanY(f);
|
||||
f.update(0.1f, 0, 64, 0, 14f, CALM, 0f, 1f, 200, 0.1f);
|
||||
tick(f, 0.1f, 0, 70, 0, 14f, CALM, 0f, 200, 0.1, FLAT);
|
||||
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);
|
||||
tick(calm, 0.0001f, 0, 70, 0, 14f, CALM, 0f, 300, 0, FLAT);
|
||||
tick(windy, 0.0001f, 0, 70, 0, 14f, CALM, 0f, 300, 0, FLAT);
|
||||
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);
|
||||
tick(calm, 0.2f, 0, 70, 0, 14f, CALM, 0f, 300, 0.2, FLAT);
|
||||
tick(windy, 0.2f, 0, 70, 0, 14f, new SnowFlakes.Wind(12, 0, 0), 0f, 300, 0.2, FLAT);
|
||||
assertTrue(meanX(windy) - w0 > meanX(calm) - c0 + 1.0);
|
||||
}
|
||||
|
||||
|
|
@ -134,8 +173,8 @@ class SnowFlakesTest {
|
|||
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);
|
||||
tick(a, 0.05f, 1, 70, 3, 14f, CALM, 0.3f, 100, 1, FLAT);
|
||||
tick(b, 0.05f, 1, 70, 3, 14f, CALM, 0.3f, 100, 1, FLAT);
|
||||
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);
|
||||
|
|
@ -145,18 +184,30 @@ class SnowFlakesTest {
|
|||
@Test
|
||||
void zeroTimeStepChangesNothing() {
|
||||
SnowFlakes f = new SnowFlakes();
|
||||
f.update(0.0001f, 0, 64, 0, 14f, CALM, 0f, 1f, 50, 0f);
|
||||
tick(f, 0.0001f, 0, 70, 0, 14f, CALM, 0f, 50, 0, FLAT);
|
||||
double y0 = f.y[0];
|
||||
f.update(0f, 0, 64, 0, 14f, CALM, 0f, 1f, 50, 0f);
|
||||
tick(f, 0f, 0, 70, 0, 14f, CALM, 0f, 50, 0, FLAT);
|
||||
assertEquals(y0, f.y[0], 0.0);
|
||||
}
|
||||
|
||||
@Test
|
||||
void turbulenceAddsSidewaysJitterButNoNetDownwindDrift() {
|
||||
SnowFlakes f = new SnowFlakes();
|
||||
tick(f, 0.0001f, 0, 70, 0, 14f, CALM, 0f, 400, 0, FLAT);
|
||||
double x0 = meanX(f);
|
||||
tick(f, 0.1f, 0, 70, 0, 14f, CALM, SnowFlakes.turbulence(1f), 400, 0.1, FLAT);
|
||||
boolean sideways = false;
|
||||
for (int i = 0; i < f.count; i++) sideways |= Math.abs(f.vx[i]) > 0.05f;
|
||||
assertTrue(sideways);
|
||||
assertEquals(0.0, meanX(f) - x0, 0.3);
|
||||
}
|
||||
|
||||
// ---- ветер
|
||||
|
||||
@Test
|
||||
void noWindWithoutPowerOrStorm() {
|
||||
assertEquals(SnowFlakes.Wind.ZERO, SnowFlakes.wind(3f, 0f, 1f));
|
||||
assertEquals(SnowFlakes.Wind.ZERO, SnowFlakes.wind(3f, 1f, 0f));
|
||||
assertEquals(SnowFlakes.Wind.ZERO, SnowFlakes.wind(3, 0f, 1f));
|
||||
assertEquals(SnowFlakes.Wind.ZERO, SnowFlakes.wind(3, 1f, 0f));
|
||||
}
|
||||
|
||||
private static double horizontal(SnowFlakes.Wind w) {
|
||||
|
|
@ -164,39 +215,44 @@ class SnowFlakesTest {
|
|||
}
|
||||
|
||||
@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);
|
||||
void windGrowsLinearlyWithPowerAndStormStrength() {
|
||||
double base = horizontal(SnowFlakes.wind(5, 0.5f, 0.5f));
|
||||
assertEquals(2.0, horizontal(SnowFlakes.wind(5, 1.0f, 0.5f)) / base, 1e-9);
|
||||
assertEquals(2.0, horizontal(SnowFlakes.wind(5, 0.5f, 1.0f)) / 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);
|
||||
double cap = SnowFlakes.WIND_SPEED_MAX * 1.5;
|
||||
double min = Double.MAX_VALUE;
|
||||
for (int i = 0; i < 2000; i++) {
|
||||
SnowFlakes.Wind w = SnowFlakes.wind(i * 0.37, 1.0f, 1.0f);
|
||||
assertTrue(horizontal(w) <= cap + 1e-9);
|
||||
assertTrue(Math.abs(w.y()) <= horizontal(w) * 0.2 + 1e-9);
|
||||
assertTrue(Math.abs(w.y()) <= horizontal(w) * 0.12 + 1e-9);
|
||||
min = Math.min(min, horizontal(w));
|
||||
}
|
||||
assertTrue(min >= SnowFlakes.WIND_SPEED_MAX * 0.5 - 1e-9, "the wind never calms below half of its power");
|
||||
}
|
||||
|
||||
@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);
|
||||
void windDirectionTurnsSlowlyOverTime() {
|
||||
SnowFlakes.Wind a = SnowFlakes.wind(100, 1f, 1f);
|
||||
SnowFlakes.Wind b = SnowFlakes.wind(100.5, 1f, 1f);
|
||||
double step = Math.abs(Math.atan2(b.z(), b.x()) - Math.atan2(a.z(), a.x()));
|
||||
assertTrue(step < 0.3, "half a second never swings the wind round");
|
||||
SnowFlakes.Wind late = SnowFlakes.wind(400, 1f, 1f);
|
||||
double total = Math.abs(Math.atan2(late.z(), late.x()) - Math.atan2(a.z(), a.x()));
|
||||
assertTrue(total > 0.5, "over minutes the direction drifts");
|
||||
}
|
||||
|
||||
@Test
|
||||
void strongerWindMeansMoreTurbulence() {
|
||||
assertTrue(SnowFlakes.turbulence(2f) > SnowFlakes.turbulence(0.5f));
|
||||
assertTrue(SnowFlakes.turbulence(0f) > 0f);
|
||||
assertEquals(SnowFlakes.turbulence(0f), SnowFlakes.turbulence(-1f), 0f);
|
||||
void turbulenceScalesWithTheSetting() {
|
||||
assertEquals(0f, SnowFlakes.turbulence(0f), 0f);
|
||||
assertEquals(0f, SnowFlakes.turbulence(-1f), 0f);
|
||||
assertEquals(2.0, SnowFlakes.turbulence(1f) / SnowFlakes.turbulence(0.5f), 1e-6);
|
||||
}
|
||||
|
||||
// ---- плавный разгон шторма
|
||||
// ---- сила шторма и экспозиция
|
||||
|
||||
@Test
|
||||
void stormRampsUpToTheIntensitySettingNotToFull() {
|
||||
|
|
@ -210,9 +266,32 @@ class SnowFlakesTest {
|
|||
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");
|
||||
assertTrue(down > 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);
|
||||
}
|
||||
|
||||
@Test
|
||||
void skyExposureIsFullOutdoorsAndZeroUnderACeiling() {
|
||||
assertEquals(1f, SnowFlakes.exposureTarget(70, 65f), 0f);
|
||||
assertEquals(1f, SnowFlakes.exposureTarget(65, 65f), 0f);
|
||||
assertEquals(0f, SnowFlakes.exposureTarget(64, 65f), 0f);
|
||||
}
|
||||
|
||||
@Test
|
||||
void exposureEasesInsteadOfSnapping() {
|
||||
float e = SnowFlakes.approach(0f, 1f, SnowFlakes.EXPOSURE_PER_SECOND, 0.1f);
|
||||
assertTrue(e > 0f && e < 0.3f);
|
||||
assertEquals(1f, SnowFlakes.approach(0f, 1f, SnowFlakes.EXPOSURE_PER_SECOND, 5f), 0f);
|
||||
}
|
||||
|
||||
@Test
|
||||
void stormFollowsTheSnowfallOnlyOption() {
|
||||
assertEquals(0.85f, SnowFlakes.targetStrength(0.85f, false, 0f, false), 0f);
|
||||
assertEquals(0f, SnowFlakes.targetStrength(0.85f, true, 0f, true), 0f);
|
||||
assertEquals(0f, SnowFlakes.targetStrength(0.85f, true, 1f, false), 0f);
|
||||
assertEquals(0.85f, SnowFlakes.targetStrength(0.85f, true, 1f, true), 0f);
|
||||
assertEquals(0.425f, SnowFlakes.targetStrength(0.85f, true, 0.5f, true), 1e-6f);
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -0,0 +1,47 @@
|
|||
package dev.loki.lovisual.features.module.modules.visuals.weather.blizzard.view;
|
||||
|
||||
import org.junit.jupiter.api.Test;
|
||||
|
||||
import static org.junit.jupiter.api.Assertions.*;
|
||||
|
||||
/** Спецификация белой пелены: чем сильнее шторм, тем ближе туман; пелена только сжимает обзор и красит туман. */
|
||||
class BlizzardFogTest {
|
||||
|
||||
@Test
|
||||
void faintStormLeavesTheFogAlone() {
|
||||
assertFalse(BlizzardFog.active(0.0f));
|
||||
assertFalse(BlizzardFog.active(0.01f));
|
||||
assertTrue(BlizzardFog.active(0.2f));
|
||||
}
|
||||
|
||||
@Test
|
||||
void strongerStormPullsTheFogCloser() {
|
||||
assertTrue(BlizzardFog.endDistance(28f, 1.0f) < BlizzardFog.endDistance(28f, 0.5f));
|
||||
assertEquals(28f, BlizzardFog.endDistance(28f, 1.0f), 1e-6f);
|
||||
assertTrue(BlizzardFog.startDistance(28f, 0.8f) < BlizzardFog.endDistance(28f, 0.8f));
|
||||
}
|
||||
|
||||
@Test
|
||||
void fogNeverMovesFurtherThanTheVanillaOne() {
|
||||
assertEquals(40f, BlizzardFog.limit(100f, 40f), 0f);
|
||||
assertEquals(60f, BlizzardFog.limit(60f, 400f), 0f);
|
||||
}
|
||||
|
||||
@Test
|
||||
void brightnessIsAlwaysAValidFactorAndBrighterSkyGivesBrighterSnowFog() {
|
||||
for (float c : new float[]{0f, 0.1f, 0.5f, 1f, 3f}) {
|
||||
float k = BlizzardFog.brightness(c, c, c);
|
||||
assertTrue(k >= 0f && k <= 1f);
|
||||
}
|
||||
assertTrue(BlizzardFog.brightness(0.6f, 0.3f, 0.3f) > BlizzardFog.brightness(0.1f, 0.1f, 0.1f));
|
||||
assertTrue(BlizzardFog.brightness(0f, 0f, 0f) > 0f, "the night fog still glows a little");
|
||||
}
|
||||
|
||||
@Test
|
||||
void fogColourMovesFromVanillaTowardsTheSnowTint() {
|
||||
assertEquals(0.2f, BlizzardFog.blend(0.2f, 1f, 1f, 0f), 1e-6f);
|
||||
float half = BlizzardFog.blend(0.2f, 1f, 1f, 0.5f);
|
||||
float full = BlizzardFog.blend(0.2f, 1f, 1f, 1f);
|
||||
assertTrue(half > 0.2f && full > half && full <= 1f);
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,59 @@
|
|||
package dev.loki.lovisual.features.module.modules.visuals.weather.blizzard.view;
|
||||
|
||||
import org.junit.jupiter.api.Test;
|
||||
|
||||
import static org.junit.jupiter.api.Assertions.*;
|
||||
|
||||
/** Спецификация экранных эффектов метели: лёгкая дымка и обмерзание, которое копится к краям. */
|
||||
class BlizzardOverlayTest {
|
||||
|
||||
@Test
|
||||
void hazeNeverVeilsTheWholeWorldAndGrowsWithTheStorm() {
|
||||
assertEquals(0f, BlizzardOverlay.hazeAlpha(0f), 0f);
|
||||
assertTrue(BlizzardOverlay.hazeAlpha(1f) > BlizzardOverlay.hazeAlpha(0.3f));
|
||||
assertTrue(BlizzardOverlay.hazeAlpha(1f) <= 0.15f, "the haze stays a light veil");
|
||||
}
|
||||
|
||||
@Test
|
||||
void frostAtTheEdgeAddsUpToTheConfiguredShareOfTheStorm() {
|
||||
float current = 0.7f;
|
||||
float total = BlizzardOverlay.frostBandAlpha(current) * BlizzardOverlay.FROST_BANDS;
|
||||
assertEquals(0.55f * current, total, 1e-6f);
|
||||
}
|
||||
|
||||
@Test
|
||||
void frostIsStrongestAtTheEdgeAndFadesTowardsTheCentre() {
|
||||
float depth = 100f;
|
||||
float edge = BlizzardOverlay.edgeCoverage(0f, depth, BlizzardOverlay.FROST_BANDS);
|
||||
float mid = BlizzardOverlay.edgeCoverage(depth * 0.2f, depth, BlizzardOverlay.FROST_BANDS);
|
||||
float inner = BlizzardOverlay.edgeCoverage(depth * 0.9f, depth, BlizzardOverlay.FROST_BANDS);
|
||||
float centre = BlizzardOverlay.edgeCoverage(depth * 1.5f, depth, BlizzardOverlay.FROST_BANDS);
|
||||
assertEquals(1f, edge, 0f);
|
||||
assertTrue(edge > mid && mid > inner && inner > centre);
|
||||
assertEquals(0f, centre, 0f);
|
||||
}
|
||||
|
||||
@Test
|
||||
void bandsGetThinnerOneByOne() {
|
||||
float depth = 100f;
|
||||
float prev = Float.MAX_VALUE;
|
||||
for (int i = 0; i < BlizzardOverlay.FROST_BANDS; i++) {
|
||||
float t = BlizzardOverlay.bandThickness(i, BlizzardOverlay.FROST_BANDS, depth);
|
||||
assertTrue(t <= prev && t >= 0f);
|
||||
prev = t;
|
||||
}
|
||||
assertEquals(depth, BlizzardOverlay.bandThickness(0, BlizzardOverlay.FROST_BANDS, depth), 1e-6f);
|
||||
}
|
||||
|
||||
@Test
|
||||
void frostTakesAFifthOfTheScreenAtMost() {
|
||||
assertEquals(0.2f, BlizzardOverlay.FROST_DEPTH_SHARE, 0f);
|
||||
}
|
||||
|
||||
@Test
|
||||
void alphaIsClampedIntoAByte() {
|
||||
assertEquals(0x00FFFFFF & 0x123456, BlizzardOverlay.withAlpha(0xFF123456, -3f) & 0x00FFFFFF);
|
||||
assertEquals(0xFF, BlizzardOverlay.withAlpha(0x00123456, 7f) >>> 24);
|
||||
assertEquals(0x80, BlizzardOverlay.withAlpha(0x00123456, 0.5f) >>> 24);
|
||||
}
|
||||
}
|
||||
Loading…
Add table
Add a link
Reference in a new issue