feat(effects): вода, облако яда, луч света; пульт и окна демонстрации

- Поле: вода (сплошная заливка по штриху, превью кистью), туман/огонь/дождь без изменений логики.

- Действия: облако яда (частицы, круглая текстура, звук oblako-yada.mp3, длительность как у трека), луч света и заморозка со звуками из public/.

- Пульт: инструменты воды и яда, синхрон SFX, тесты панели и ластика.

- Окно управления: дочернее от окна просмотра (Z-order).

- Типы эффектов, effectsStore prune, hit-test ластика.

Made-with: Cursor
This commit is contained in:
Ivan Fontosh
2026-04-20 11:03:57 +08:00
parent 726c89e104
commit 20c838da7d
19 changed files with 1154 additions and 111 deletions
+56
View File
@@ -0,0 +1,56 @@
/** Звук эффекта «Луч света» (`public/luch_sveta.mp3`). */
const SUNBEAM_SFX_VOLUME = 0.88;
/** Воспроизведение на 50% быстрее → реальная длительность = файл / 1.5. */
export const SUNBEAM_PLAYBACK_RATE = 1.5;
const DEFAULT_SUNBEAM_LIFE_MS = 600;
export function sunbeamEffectSoundUrl(): string {
return new URL('luch_sveta.mp3', window.location.href).href;
}
let cachedSunbeamSfxDurationMs: number | null = null;
/** Длительность файла в мс (как в метаданных, без ускорения). */
export function getSunbeamSfxDurationMs(): Promise<number> {
if (cachedSunbeamSfxDurationMs !== null) {
return Promise.resolve(cachedSunbeamSfxDurationMs);
}
const url = sunbeamEffectSoundUrl();
return new Promise((resolve) => {
const a = new Audio();
const done = (ms: number): void => {
cachedSunbeamSfxDurationMs = ms;
a.removeAttribute('src');
resolve(ms);
};
a.addEventListener('loadedmetadata', () => {
const d = a.duration;
done(Number.isFinite(d) && d > 0 ? Math.round(d * 1000) : DEFAULT_SUNBEAM_LIFE_MS);
});
a.addEventListener('error', () => done(DEFAULT_SUNBEAM_LIFE_MS));
a.src = url;
a.load();
});
}
/**
* Длительность визуала луча = время проигрывания при ускорении 1.5× (совпадает со звуком).
*/
export async function getSunbeamEffectLifeMs(): Promise<number> {
const raw = await getSunbeamSfxDurationMs();
const wallMs = raw / SUNBEAM_PLAYBACK_RATE;
return Math.min(60_000, Math.max(400, Math.round(wallMs)));
}
export function playSunbeamEffectSound(): void {
try {
const el = new Audio(sunbeamEffectSoundUrl());
el.volume = SUNBEAM_SFX_VOLUME;
el.playbackRate = SUNBEAM_PLAYBACK_RATE;
void el.play().catch(() => undefined);
} catch {
/* ignore */
}
}