/** Тесты чистой части погоды: кламп spec'а, вектор ветра, детерминизм и
* заворачивание в окно у слоёв дождя/снега, обесцвечивание облачности. */
import { describe, expect, it } from 'vitest';
import { cloudDim } from '../daynight';
import {
DEFAULT_WEATHER, RAIN_LAYERS, WeatherSystem, clampWeather, rainPoints, snowPoints, windVector,
} from '../weather';
const WIN = { minX: 0, maxX: 48, minZ: 0, maxZ: 32 };
/** Моменты респавна головы капли drop (y подпрыгивает с низа к верху слоя —
* обычное падение за шаг скана даёт сдвиг в доли юнита, не прыжок). */
function headWraps(spec: ReturnType<typeof clampWeather>, drop: number, tMax = 8): number[] {
const wraps: number[] = [];
let prev = -1;
for (let t = 0; t <= tMax; t += 0.002) {
const y = rainPoints(spec, t, WIN)[drop * 3]!.pos[1];
if (prev >= 0 && y - prev > 20) wraps.push(t);
prev = y;
}
return wraps;
}
describe('clampWeather', () => {
it('частичный ввод дополняется дефолтом', () => {
const w = clampWeather({ rain: 0.3 });
expect(w.rain).toBe(0.3);
expect(w.snow).toBe(0);
expect(w.windStrength).toBe(0);
expect(w.windDir).toBe(DEFAULT_WEATHER.windDir);
expect(w.cloud).toBe(0);
});
it('выходит за 0..1 — клампится; NaN/не-число — дефолт', () => {
expect(clampWeather({ rain: 5 }).rain).toBe(1);
expect(clampWeather({ snow: -2 }).snow).toBe(0);
expect(clampWeather({ windStrength: NaN }).windStrength).toBe(0);
expect(clampWeather({ cloud: 'x' as unknown as number }).cloud).toBe(0);
});
});
describe('windVector', () => {
it('ноль силы — ноль дрейфа; сила 1 — 6 юнит/с по направлению', () => {
expect(windVector({ ...DEFAULT_WEATHER, windStrength: 0 })).toEqual([0, 0]);
const [x, z] = windVector({ ...DEFAULT_WEATHER, windStrength: 1, windDir: Math.PI / 2 });
expect(x).toBeCloseTo(6);
expect(z).toBeCloseTo(0);
const [x2, z2] = windVector({ ...DEFAULT_WEATHER, windStrength: 0.5, windDir: 0 });
expect(x2).toBeCloseTo(0);
expect(z2).toBeCloseTo(3);
});
});
describe('rain/snow points', () => {
// окно 48×32 = 1536 кв. юнитов; дождь многослойный (6 слоёв, плотности
// 0.020/0.016/0.013/0.011/0.009/0.008 → 31+25+20+17+14+12 = 119 капель
// × 3 точки штриха = 357); снег (0.010/0.008/0.007/0.006/0.006/0.0045
// → 15+12+11+9+9+7 = 63 хлопьев)
it('число точек = сумма слоёв (плотность × площадь × интенсивность); ноль — пусто', () => {
const spec = { ...DEFAULT_WEATHER, rain: 1, snow: 0 };
expect(rainPoints(spec, 0, WIN).length).toBe(357);
expect(snowPoints({ ...spec, snow: 1 }, 0, WIN).length).toBe(63);
expect(rainPoints({ ...spec, rain: 0 }, 0, WIN).length).toBe(0);
expect(snowPoints({ ...spec, snow: 0 }, 0, WIN).length).toBe(0);
});
it('слои живут в своих полосах глубины и не совпадают колонками', () => {
const spec = { ...DEFAULT_WEATHER, rain: 1 };
const a = rainPoints(spec, 0, WIN, 0); // дальний слой
const b = rainPoints(spec, 0, WIN, RAIN_LAYERS - 1); // ближний
expect(a.length).toBeGreaterThan(0);
expect(b.length).toBeGreaterThan(0);
const sizes = (pts: ReturnType<typeof rainPoints>) => pts.filter((p) => p.size !== undefined).map((p) => p.size!);
// ближний слой крупнее дальнего (аэроперспектива по слоям)
expect(Math.max(...sizes(b))).toBeGreaterThan(Math.max(...sizes(a)));
// колонки слоёв разведены: совпадений позиций нет
const key = (p: ReturnType<typeof rainPoints>[number]) => `${Math.round(p.pos[0] * 10)}:${Math.round(p.pos[2] * 10)}`;
const keysB = new Set(b.map(key));
expect(a.every((p) => !keysB.has(key(p)))).toBe(true);
// сумма слоёв = вызов без слоя
const all = rainPoints(spec, 0, WIN);
let sum = 0;
for (let k = 0; k < RAIN_LAYERS; k++) sum += rainPoints(spec, 0, WIN, k).length;
expect(all.length).toBe(sum);
});
it('детерминизм: тот же t — те же позы', () => {
const spec = { ...DEFAULT_WEATHER, rain: 0.4, snow: 0.6, windStrength: 0.3 };
const a = rainPoints(spec, 3.7, WIN);
const b = rainPoints(spec, 3.7, WIN);
expect(a).toEqual(b);
expect(snowPoints(spec, 1.2, WIN)).toEqual(snowPoints(spec, 1.2, WIN));
});
it('время идёт — капли падают (y убывает, потом цикл)', () => {
const spec = { ...DEFAULT_WEATHER, rain: 1 };
const t0 = rainPoints(spec, 0, WIN)[0]!.pos[1];
const t1 = rainPoints(spec, 0.5, WIN)[0]!.pos[1];
expect(t1).toBeLessThan(t0);
});
it('позы внутри окна (wrap), альфа в 0..1', () => {
const spec = { ...DEFAULT_WEATHER, snow: 1, windStrength: 1 };
for (const p of snowPoints(spec, 17, WIN)) {
expect(p.pos[0]).toBeGreaterThanOrEqual(WIN.minX);
expect(p.pos[0]).toBeLessThanOrEqual(WIN.maxX);
expect(p.pos[2]).toBeGreaterThanOrEqual(WIN.minZ);
expect(p.pos[2]).toBeLessThanOrEqual(WIN.maxZ);
expect(p.alpha).toBeGreaterThanOrEqual(0);
expect(p.alpha).toBeLessThanOrEqual(1);
}
});
it('высота слоя: капли и снег не ниже земли цикла (0..TOP)', () => {
const spec = { ...DEFAULT_WEATHER, rain: 1, snow: 1 };
for (const p of rainPoints(spec, 13, WIN)) expect(p.pos[1]).toBeGreaterThanOrEqual(0);
for (const p of snowPoints(spec, 13, WIN)) expect(p.pos[1]).toBeGreaterThanOrEqual(0);
});
it('респавн меняет колонку: траектория не повторяется по циклам', () => {
// скорость капли теперь зависит ещё и от глубины — момент полного
// цикла находим по скану (два соседних респавна: та же фаза падения)
const spec = { ...DEFAULT_WEATHER, rain: 1 };
const w = headWraps(spec, 0);
expect(w.length).toBeGreaterThanOrEqual(2);
const a = rainPoints(spec, w[0]!, WIN)[0]!.pos;
const b = rainPoints(spec, w[1]!, WIN)[0]!.pos;
expect(b[1]).toBeCloseTo(a[1], 0); // та же фаза — верх слоя
expect(b[0]).not.toBeCloseTo(a[0], 3); // но новая колонка (реролл)
});
it('поле дождя не квази-статично: через цикл переброшены почти все капли', () => {
// Сдвиг респавна в узкой окрестности якоря оставлял каплю в той же
// полосе — трек «горел» на экране вечно. Полный реролл перемещает
// подавляющее большинство капель (совпадение колоны — редкость).
// 5 с ≥ 2 полных циклов любой капли (слой 40, скорость ≥ ~17) —
// к этому моменту у всех переброшен номер цикла падения.
const spec = { ...DEFAULT_WEATHER, rain: 1 };
const a = rainPoints(spec, 0, WIN);
const b = rainPoints(spec, 5, WIN);
let moved = 0;
for (let i = 0; i < a.length; i += 3) { // головы штрихов
if (Math.abs(a[i]!.pos[0] - b[i]!.pos[0]) > 2) moved++;
}
expect(moved).toBeGreaterThanOrEqual((a.length / 3) * 0.8);
});
it('внутри падения колонка неизменна (капля падает по прямой)', () => {
// от сразу после респавна до середины падения — цикл не сменится
// (длина цикла 40/93 ≈ 0.43 с при скорости 93; взяли 0.15)
const spec = { ...DEFAULT_WEATHER, rain: 1 };
const w = headWraps(spec, 0);
const a = rainPoints(spec, w[0]! + 0.05, WIN)[0]!.pos;
const b = rainPoints(spec, w[0]! + 0.15, WIN)[0]!.pos;
expect(b[0]).toBeCloseTo(a[0]!, 6);
expect(b[2]).toBeCloseTo(a[2]!, 6);
});
it('глубина: в поле есть дальний план (бледнее/мельче) и ближний (крупнее)', () => {
const spec = { ...DEFAULT_WEATHER, rain: 1, snow: 1 };
const drops = rainPoints(spec, 2, WIN);
const sizes = drops.map((p) => p.size ?? 0);
expect(Math.max(...sizes)).toBeGreaterThan(Math.min(...sizes) * 2);
expect(Math.min(...drops.map((p) => p.alpha))).toBeLessThan(Math.max(...drops.map((p) => p.alpha)) * 0.5);
const flakes = snowPoints(spec, 2, WIN);
const fsz = flakes.map((p) => p.size ?? 0);
expect(Math.max(...fsz)).toBeGreaterThan(Math.min(...fsz) * 2);
});
});
describe('cloudDim (облачность)', () => {
it('k=0 — цвет без изменений, k=1 — затемнение на долю dim', () => {
expect(cloudDim(0xf7f4ea, 0, 0.7, 0.25)).toBe(0xf7f4ea);
const c = cloudDim(0xf7f4ea, 1, 0.7, 0.25);
expect(c).toBeLessThan(0xf7f4ea);
});
it('обесцвечивание: каналы сходятся к серому при полной облачности', () => {
// насыщенный красный при sat=1 и dim=0 становится серым той же яркости
const c = cloudDim(0xff0000, 1, 1, 0);
const r = (c >> 16) & 255, g = (c >> 8) & 255, b = c & 255;
expect(r).toBe(g);
expect(g).toBe(b);
});
});
describe('WeatherSystem (без WebGL — только спецификация)', () => {
it('set клампит и current отдаёт нормализованную погоду', () => {
const sys = new WeatherSystem({
addParticles: () => ({ update: () => {}, setTint: () => {}, dispose: () => {} }),
addFog: () => ({ update: () => {}, dispose: () => {} }),
viewAxes: () => ({ camPos: [0, 0, 0], target: [0, 0, 0], forward: [0, 0, 1], halfW: 1, halfH: 1 }),
setCloud: () => {},
visibleWindow: () => ({ minX: 0, maxX: 0, minZ: 0, maxZ: 0 }),
} as never);
sys.set({ rain: 5, windStrength: -1, cloud: 0.5 });
expect(sys.current.rain).toBe(1);
expect(sys.current.windStrength).toBe(0);
expect(sys.current.cloud).toBe(0.5);
expect(sys.info()).toEqual({ rainDrops: 0, snowFlakes: 0 }); // счётчики — после update
});
it('update считает фактические капли/снежинки от окна', () => {
const sys = new WeatherSystem({
addParticles: () => ({ update: () => {}, setTint: () => {}, dispose: () => {} }),
setFog: () => {},
setCloud: () => {},
visibleWindow: () => ({ minX: 0, maxX: 48, minZ: 0, maxZ: 32 }),
} as never);
sys.set({ rain: 1 });
sys.update(1);
// окно погоды — след кадра + маржа 16: (48+32)×(32+32) = 5120 кв. юнитов;
// 6 слоёв дождя: 0.020/0.016/0.013/0.011/0.009/0.008 × 5120
// → 102+82+67+56+46+41 = 394 капли
expect(sys.info()).toEqual({ rainDrops: 394, snowFlakes: 0 });
sys.set({ rain: 0, snow: 0 }); // пустая погода прячет слои и счётчики
expect(sys.info()).toEqual({ rainDrops: 0, snowFlakes: 0 });
});
});