/**
* Атмосферные частицы (v1 Atmosphere): тёплые мотыльки над лугами
* (светятся цветом, дрейф по синусу) и дымка прудов (серые полупрозрачные
* клочья, лежащие на воде). Слои частиц движка (не воксели): мотыльки —
* спрайты-билборды с окном видимости камеры (с перекрытием и альфой в
* марже — заворачивание за экраном), дымка — плоские квадраты НА воде,
* якорящиеся на клетки и медленно бродящие на месте. Позы — детермини-
* рованные синусы от времени цикла, гейт-стабильно.
*/
import type { ParticleLayer, VoxelRenderer } from '@rpg/engine';
import { edgeAlpha, particleWindow, wrap } from './particlesWindow';
import type { WaterCell } from './waterShimmer';
/** Профиль частиц области. */
export type AmbientKind = 'moths' | 'fog';
const MOTH_COUNT = 16; // окно — весь кадр: чуть больше, чтобы стая читалась
const MOTH_COLORS = ['#f0d878', '#d8b050']; // B-тёплые: только «жизнь»
const FOG_COUNT = 22;
const FOG_COLOR = '#a7b3bd'; // светлее воды — клочья читаются туманом
const FOG_ALPHA = 0.55; // полупрозрачные пятна — как в v1
const FOG_Y = 3.05; // чуть над поверхностью воды (вода — слой y=2, верх y=3)
const MARGIN = 16; // перекрытие окна мотыльков за краем кадра
export class AmbientParticles {
private readonly layer: ParticleLayer;
constructor(
private readonly renderer: VoxelRenderer,
private readonly kind: AmbientKind,
/** Клетки поверхности воды — якоря клочьев дымки (только для fog). */
private readonly cells: readonly WaterCell[] = [],
) {
this.layer = kind === 'fog' ? renderer.addFlatParticles() : renderer.addParticles();
}
/** Снапшот для моста: профиль и число частиц (у мотыльков всегда видимы). */
get info(): { kind: AmbientKind; count: number } {
return {
kind: this.kind,
count: this.kind === 'moths' ? MOTH_COUNT : this.cells.length ? FOG_COUNT : 0,
};
}
/** Поза слоя: время цикла (окно мотыльков — из цели камеры рендерера). */
update(t: number): void {
const points: { pos: [number, number, number]; color: string; alpha?: number; size?: number }[] = [];
if (this.kind === 'moths') {
const win = particleWindow(this.renderer, 5, 13, MARGIN);
for (let i = 0; i < MOTH_COUNT; i++) {
const p = i * 2.399; // золотое сечение: разброс фаз
// мировой якорь (окно камеры заворачивает) + кружение НА месте:
// мотылёк не следует за героем — герой летит сквозь стаю
const ax = wrap(i * 53.1 + Math.sin(t * 0.13 + p) * 4, win.minX, win.maxX - win.minX);
const az = wrap(i * 89.7 + Math.cos(t * 0.11 + p * 1.7) * 4, win.minZ, win.maxZ - win.minZ);
// полёт: плавная волна над травой + трепет (герой ~16 юнитов)
const y = 9 + Math.sin(t * 0.4 + p * 2) * 4.4;
const x = ax + Math.sin(t * 1.7 + p) * 0.6;
const z = az + Math.cos(t * 1.5 + p) * 0.6;
points.push({
pos: [x, y, z],
color: MOTH_COLORS[i % MOTH_COLORS.length]!,
alpha: edgeAlpha(win, MARGIN, x, z),
});
}
} else {
// клочья дымят НА воде: якорь — клетка поверхности, медленное
// блуждание на месте (без якорей — клочьев нет: дымка только прудам)
const n = this.cells.length;
for (let i = 0; i < FOG_COUNT && n > 0; i++) {
const p = i * 2.399;
const a = this.cells[(i * 131) % n]!; // 131 взаимно прост с длиной — разброс
points.push({
pos: [a.x + Math.sin(t * 0.05 + p) * 2.5, FOG_Y, a.z + Math.cos(t * 0.04 + p * 1.3) * 2.5],
color: FOG_COLOR,
alpha: FOG_ALPHA,
size: 2, // клок 2×2, лежит на воде
});
}
}
this.layer.update(points);
}
/** Скрыть слой (мост setAsh / смена профиля). */
hide(): void {
this.layer.update([]);
}
dispose(): void {
this.layer.dispose();
}
}