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
+14 -1
View File
@@ -42,7 +42,20 @@
.effectsStack {
display: flex;
flex-direction: column;
gap: 10px;
gap: 12px;
}
.effectsGroup {
display: flex;
flex-direction: column;
gap: 8px;
}
.subsectionLabel {
font-size: var(--text-xs);
font-weight: 700;
letter-spacing: 0.02em;
color: var(--text2);
}
.iconRow {
+274 -85
View File
@@ -12,6 +12,12 @@ import { Surface } from '../shared/ui/Surface';
import styles from './ControlApp.module.css';
import { ControlScenePreview } from './ControlScenePreview';
import { getFreezeEffectLifeMs, playFreezeEffectSound } from './freezeSfx';
import { getPoisonCloudEffectLifeMs, playPoisonCloudEffectSound } from './poisonCloudSfx';
import { getSunbeamEffectLifeMs, playSunbeamEffectSound } from './sunbeamSfx';
/** Длительность визуала молнии (мс). */
const LIGHTNING_EFFECT_MS = 180;
function formatTime(sec: number): string {
if (!Number.isFinite(sec) || sec < 0) return '0:00';
@@ -21,6 +27,21 @@ function formatTime(sec: number): string {
return `${String(m)}:${String(r).padStart(2, '0')}`;
}
/** Файл из `app/renderer/public/molniya.mp3` — рядом с `control.html` в dev и в dist. */
function lightningEffectSoundUrl(): string {
return new URL('molniya.mp3', window.location.href).href;
}
function playLightningEffectSound(): void {
try {
const el = new Audio(lightningEffectSoundUrl());
el.volume = 0.88;
void el.play().catch(() => undefined);
} catch {
/* ignore */
}
}
export function ControlApp() {
const api = getDndApi();
const [fxState, fx] = useEffectsState();
@@ -36,7 +57,7 @@ export function ControlApp() {
const previewHostRef = useRef<HTMLDivElement | null>(null);
const previewVideoRef = useRef<HTMLVideoElement | null>(null);
const brushRef = useRef<{
tool: 'fog' | 'fire' | 'rain' | 'lightning' | 'freeze' | 'eraser';
tool: 'fog' | 'fire' | 'rain' | 'water' | 'lightning' | 'sunbeam' | 'poisonCloud' | 'freeze' | 'eraser';
startN?: { x: number; y: number };
points?: { x: number; y: number; tMs: number }[];
} | null>(null);
@@ -89,6 +110,21 @@ export function ControlApp() {
};
}, []);
const [freezeDraftLifeMs, setFreezeDraftLifeMs] = useState(820);
useEffect(() => {
void getFreezeEffectLifeMs().then(setFreezeDraftLifeMs);
}, []);
const [sunbeamDraftLifeMs, setSunbeamDraftLifeMs] = useState(600);
useEffect(() => {
void getSunbeamEffectLifeMs().then(setSunbeamDraftLifeMs);
}, []);
const [poisonDraftLifeMs, setPoisonDraftLifeMs] = useState(1600);
useEffect(() => {
void getPoisonCloudEffectLifeMs().then(setPoisonDraftLifeMs);
}, []);
const project = session?.project ?? null;
const currentGraphNodeId = project?.currentGraphNodeId ?? null;
const currentScene =
@@ -388,6 +424,21 @@ export function ControlApp() {
},
});
}
if (b.tool === 'water' && b.points && b.points.length > 0) {
await fx.dispatch({
kind: 'instance.add',
instance: {
id: `water_${String(createdAtMs)}_${String(seed)}`,
type: 'water',
seed,
createdAtMs,
points: b.points,
radiusN: tool.radiusN,
opacity: Math.max(0.06, Math.min(0.72, tool.intensity * 0.85)),
lifetimeMs: null,
},
});
}
if (b.tool === 'lightning' && b.startN && b.points && b.points.length > 0) {
const last = b.points[b.points.length - 1];
if (last === undefined) return;
@@ -404,7 +455,7 @@ export function ControlApp() {
end,
widthN: Math.max(0.01, tool.radiusN * 0.9),
intensity: Math.max(0.9, Math.min(1.2, tool.intensity * 1.35)),
lifetimeMs: 180,
lifetimeMs: LIGHTNING_EFFECT_MS,
},
});
await fx.dispatch({
@@ -420,11 +471,55 @@ export function ControlApp() {
lifetimeMs: 60_000,
},
});
playLightningEffectSound();
}
if (b.tool === 'sunbeam' && b.startN && b.points && b.points.length > 0) {
const last = b.points[b.points.length - 1];
if (last === undefined) return;
const end = { x: last.x, y: last.y };
const start = { x: end.x, y: 0 };
const sunbeamLifeMs = await getSunbeamEffectLifeMs();
await fx.dispatch({
kind: 'instance.add',
instance: {
id: `sb_${String(createdAtMs)}_${String(seed)}`,
type: 'sunbeam',
seed,
createdAtMs,
start,
end,
widthN: Math.max(0.012, tool.radiusN * 0.95),
intensity: Math.max(0.95, Math.min(1.25, tool.intensity * 1.4)),
lifetimeMs: sunbeamLifeMs,
},
});
playSunbeamEffectSound();
}
if (b.tool === 'poisonCloud' && b.points && b.points.length > 0) {
const last = b.points[b.points.length - 1];
if (last === undefined) return;
const at = { x: last.x, y: last.y };
const poisonLifeMs = await getPoisonCloudEffectLifeMs();
await fx.dispatch({
kind: 'instance.add',
instance: {
id: `pc_${String(createdAtMs)}_${String(seed)}`,
type: 'poisonCloud',
seed,
createdAtMs,
at,
radiusN: Math.max(0.03, tool.radiusN * 0.95),
intensity: Math.max(0.75, Math.min(1.2, tool.intensity * 1.15)),
lifetimeMs: poisonLifeMs,
},
});
void playPoisonCloudEffectSound(poisonLifeMs);
}
if (b.tool === 'freeze' && b.points && b.points.length > 0) {
const last = b.points[b.points.length - 1];
if (last === undefined) return;
const at = { x: last.x, y: last.y };
const freezeLifeMs = await getFreezeEffectLifeMs();
await fx.dispatch({
kind: 'instance.add',
instance: {
@@ -434,8 +529,8 @@ export function ControlApp() {
createdAtMs,
at,
intensity: Math.max(0.8, Math.min(1.25, tool.intensity * 1.15)),
// Быстро появиться → чуть задержаться → плавно исчезнуть.
lifetimeMs: 820,
// Длительность как у zamorozka.mp3 (фазы «замерзания» в PxiEffectsOverlay масштабируются по life).
lifetimeMs: freezeLifeMs,
},
});
await fx.dispatch({
@@ -448,9 +543,10 @@ export function ControlApp() {
at,
radiusN: Math.max(0.03, tool.radiusN * 0.9),
opacity: 0.85,
lifetimeMs: 60_000,
lifetimeMs: null,
},
});
playFreezeEffectSound();
}
brushRef.current = null;
setDraftFxTick((x) => x + 1);
@@ -496,6 +592,18 @@ export function ControlApp() {
lifetimeMs: null,
};
}
if (b.tool === 'water' && b.points && b.points.length > 0) {
return {
id: '__draft__',
type: 'water' as const,
seed,
createdAtMs,
points: b.points,
radiusN: tool.radiusN,
opacity: Math.max(0.06, Math.min(0.55, tool.intensity * 0.72)),
lifetimeMs: null,
};
}
if (b.tool === 'lightning' && b.startN && b.points && b.points.length > 0) {
const last = b.points[b.points.length - 1];
if (last === undefined) return null;
@@ -508,7 +616,36 @@ export function ControlApp() {
end: { x: last.x, y: last.y },
widthN: Math.max(0.01, tool.radiusN * 0.9),
intensity: Math.max(0.9, Math.min(1.2, tool.intensity * 1.35)),
lifetimeMs: 180,
lifetimeMs: LIGHTNING_EFFECT_MS,
};
}
if (b.tool === 'sunbeam' && b.startN && b.points && b.points.length > 0) {
const last = b.points[b.points.length - 1];
if (last === undefined) return null;
return {
id: '__draft__',
type: 'sunbeam' as const,
seed,
createdAtMs,
start: { x: last.x, y: 0 },
end: { x: last.x, y: last.y },
widthN: Math.max(0.012, tool.radiusN * 0.95),
intensity: Math.max(0.95, Math.min(1.25, tool.intensity * 1.4)),
lifetimeMs: sunbeamDraftLifeMs,
};
}
if (b.tool === 'poisonCloud' && b.points && b.points.length > 0) {
const last = b.points[b.points.length - 1];
if (last === undefined) return null;
return {
id: '__draft__',
type: 'poisonCloud' as const,
seed,
createdAtMs,
at: { x: last.x, y: last.y },
radiusN: Math.max(0.03, tool.radiusN * 0.95),
intensity: Math.max(0.75, Math.min(1.2, tool.intensity * 1.15)),
lifetimeMs: poisonDraftLifeMs,
};
}
if (b.tool === 'freeze' && b.points && b.points.length > 0) {
@@ -521,12 +658,20 @@ export function ControlApp() {
createdAtMs,
at: { x: last.x, y: last.y },
intensity: Math.max(0.8, Math.min(1.25, tool.intensity * 1.15)),
lifetimeMs: 240,
lifetimeMs: freezeDraftLifeMs,
};
}
return null;
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [draftFxTick, tool.intensity, tool.radiusN, tool.tool]);
}, [
draftFxTick,
freezeDraftLifeMs,
poisonDraftLifeMs,
sunbeamDraftLifeMs,
tool.intensity,
tool.radiusN,
tool.tool,
]);
const fxMergedState = useMemo(() => {
if (!fxState) return null;
@@ -544,83 +689,127 @@ export function ControlApp() {
<div className={styles.sectionLabel}>ЭФФЕКТЫ</div>
<div className={styles.spacer8} />
<div className={styles.effectsStack}>
<div className={styles.iconRow}>
<Button
variant={tool.tool === 'fog' ? 'primary' : 'ghost'}
iconOnly
title="Туман"
ariaLabel="Туман"
onClick={() => void fx.dispatch({ kind: 'tool.set', tool: { ...tool, tool: 'fog' } })}
>
<span className={styles.iconGlyph}>🌫</span>
</Button>
<Button
variant={tool.tool === 'fire' ? 'primary' : 'ghost'}
iconOnly
title="Огонь"
ariaLabel="Огонь"
onClick={() => void fx.dispatch({ kind: 'tool.set', tool: { ...tool, tool: 'fire' } })}
>
<span className={styles.iconGlyph}>🔥</span>
</Button>
<Button
variant={tool.tool === 'rain' ? 'primary' : 'ghost'}
iconOnly
title="Дождь"
ariaLabel="Дождь"
onClick={() => void fx.dispatch({ kind: 'tool.set', tool: { ...tool, tool: 'rain' } })}
>
<span className={styles.iconGlyph}>🌧</span>
</Button>
<Button
variant={tool.tool === 'lightning' ? 'primary' : 'ghost'}
iconOnly
title="Молния"
ariaLabel="Молния"
onClick={() => void fx.dispatch({ kind: 'tool.set', tool: { ...tool, tool: 'lightning' } })}
>
<span className={styles.iconGlyph}></span>
</Button>
<Button
variant={tool.tool === 'freeze' ? 'primary' : 'ghost'}
iconOnly
title="Заморозка"
ariaLabel="Заморозка"
onClick={() => void fx.dispatch({ kind: 'tool.set', tool: { ...tool, tool: 'freeze' } })}
>
<span className={styles.iconGlyph}></span>
</Button>
<Button
variant={tool.tool === 'eraser' ? 'primary' : 'ghost'}
iconOnly
title="Ластик"
ariaLabel="Ластик"
onClick={() => void fx.dispatch({ kind: 'tool.set', tool: { ...tool, tool: 'eraser' } })}
>
<span className={styles.iconGlyph}>🧹</span>
</Button>
<Button
variant="ghost"
iconOnly
title="Очистить эффекты"
ariaLabel="Очистить эффекты"
onClick={() => void fx.dispatch({ kind: 'instances.clear' })}
>
<span className={styles.clearIcon}>
<svg viewBox="0 0 24 24" width="20" height="20" aria-hidden>
<circle cx="12" cy="12" r="8" fill="none" stroke="#e5484d" strokeWidth="2" />
<line
x1="7"
y1="17"
x2="17"
y2="7"
stroke="#e5484d"
strokeWidth="2"
strokeLinecap="round"
/>
</svg>
</span>
</Button>
<div className={styles.effectsGroup}>
<div className={styles.subsectionLabel}>Инструменты</div>
<div className={styles.iconRow}>
<Button
variant={tool.tool === 'eraser' ? 'primary' : 'ghost'}
iconOnly
title="Ластик"
ariaLabel="Ластик"
onClick={() => void fx.dispatch({ kind: 'tool.set', tool: { ...tool, tool: 'eraser' } })}
>
<span className={styles.iconGlyph}>🧹</span>
</Button>
<Button
variant="ghost"
iconOnly
title="Очистить эффекты"
ariaLabel="Очистить эффекты"
onClick={() => void fx.dispatch({ kind: 'instances.clear' })}
>
<span className={styles.clearIcon}>
<svg viewBox="0 0 24 24" width="20" height="20" aria-hidden>
<circle cx="12" cy="12" r="8" fill="none" stroke="#e5484d" strokeWidth="2" />
<line
x1="7"
y1="17"
x2="17"
y2="7"
stroke="#e5484d"
strokeWidth="2"
strokeLinecap="round"
/>
</svg>
</span>
</Button>
</div>
</div>
<div className={styles.effectsGroup}>
<div className={styles.subsectionLabel}>Эффекты поля</div>
<div className={styles.iconRow}>
<Button
variant={tool.tool === 'fog' ? 'primary' : 'ghost'}
iconOnly
title="Туман"
ariaLabel="Туман"
onClick={() => void fx.dispatch({ kind: 'tool.set', tool: { ...tool, tool: 'fog' } })}
>
<span className={styles.iconGlyph}>🌫</span>
</Button>
<Button
variant={tool.tool === 'rain' ? 'primary' : 'ghost'}
iconOnly
title="Дождь"
ariaLabel="Дождь"
onClick={() => void fx.dispatch({ kind: 'tool.set', tool: { ...tool, tool: 'rain' } })}
>
<span className={styles.iconGlyph}>🌧</span>
</Button>
<Button
variant={tool.tool === 'fire' ? 'primary' : 'ghost'}
iconOnly
title="Огонь"
ariaLabel="Огонь"
onClick={() => void fx.dispatch({ kind: 'tool.set', tool: { ...tool, tool: 'fire' } })}
>
<span className={styles.iconGlyph}>🔥</span>
</Button>
<Button
variant={tool.tool === 'water' ? 'primary' : 'ghost'}
iconOnly
title="Вода"
ariaLabel="Вода"
onClick={() => void fx.dispatch({ kind: 'tool.set', tool: { ...tool, tool: 'water' } })}
>
<span className={styles.iconGlyph}>💧</span>
</Button>
</div>
</div>
<div className={styles.effectsGroup}>
<div className={styles.subsectionLabel}>Эффекты действий</div>
<div className={styles.iconRow}>
<Button
variant={tool.tool === 'lightning' ? 'primary' : 'ghost'}
iconOnly
title="Молния"
ariaLabel="Молния"
onClick={() =>
void fx.dispatch({ kind: 'tool.set', tool: { ...tool, tool: 'lightning' } })
}
>
<span className={styles.iconGlyph}></span>
</Button>
<Button
variant={tool.tool === 'sunbeam' ? 'primary' : 'ghost'}
iconOnly
title="Луч света"
ariaLabel="Луч света"
onClick={() => void fx.dispatch({ kind: 'tool.set', tool: { ...tool, tool: 'sunbeam' } })}
>
<span className={styles.iconGlyph}></span>
</Button>
<Button
variant={tool.tool === 'freeze' ? 'primary' : 'ghost'}
iconOnly
title="Заморозка"
ariaLabel="Заморозка"
onClick={() => void fx.dispatch({ kind: 'tool.set', tool: { ...tool, tool: 'freeze' } })}
>
<span className={styles.iconGlyph}></span>
</Button>
<Button
variant={tool.tool === 'poisonCloud' ? 'primary' : 'ghost'}
iconOnly
title="Облако яда"
ariaLabel="Облако яда"
onClick={() =>
void fx.dispatch({ kind: 'tool.set', tool: { ...tool, tool: 'poisonCloud' } })
}
>
<span className={styles.iconGlyph}></span>
</Button>
</div>
</div>
<div className={styles.radiusRow}>
<div className={styles.radiusLabel}>Радиус кисти</div>
@@ -14,9 +14,46 @@ function readControlAppCss(): string {
return fs.readFileSync(path.join(here, 'ControlApp.module.css'), 'utf8');
}
void test('ControlApp: звук молнии (public/molniya.mp3)', () => {
const src = readControlApp();
assert.ok(src.includes('molniya.mp3'));
assert.ok(src.includes('playLightningEffectSound'));
});
void test('ControlApp: звук заморозки (public/zamorozka.mp3)', () => {
const src = readControlApp();
assert.ok(src.includes('zamorozka.mp3'));
assert.ok(src.includes('playFreezeEffectSound'));
});
void test('ControlApp: звук луча света (public/luch_sveta.mp3)', () => {
const appSrc = readControlApp();
const sfxSrc = fs.readFileSync(path.join(here, 'sunbeamSfx.ts'), 'utf8');
assert.ok(appSrc.includes('playSunbeamEffectSound'));
assert.ok(appSrc.includes('getSunbeamEffectLifeMs'));
assert.ok(sfxSrc.includes('luch_sveta.mp3'));
assert.ok(sfxSrc.includes('playbackRate'));
assert.ok(sfxSrc.includes('SUNBEAM_PLAYBACK_RATE'));
});
void test('ControlApp: звук облака яда (public/oblako-yada.mp3)', () => {
const appSrc = readControlApp();
const sfxSrc = fs.readFileSync(path.join(here, 'poisonCloudSfx.ts'), 'utf8');
assert.ok(appSrc.includes('getPoisonCloudEffectLifeMs'));
assert.ok(appSrc.includes('playPoisonCloudEffectSound'));
assert.ok(sfxSrc.includes('oblako-yada.mp3'));
assert.ok(sfxSrc.includes('playbackRate'));
});
void test('ControlApp: эффекты в пульте, иконки с тултипами и подписью для a11y', () => {
const src = readControlApp();
assert.ok(src.includes('ЭФФЕКТЫ'));
assert.ok(src.includes('Инструменты'));
assert.ok(src.includes('Эффекты поля'));
assert.ok(src.includes('Эффекты действий'));
assert.ok(src.includes('Луч света'));
assert.ok(src.includes('title="Вода"'));
assert.ok(src.includes('title="Облако яда"'));
assert.ok(src.includes('title="Туман"'));
assert.ok(src.includes('ariaLabel="Туман"'));
assert.ok(src.includes('iconOnly'));
+52
View File
@@ -0,0 +1,52 @@
/** Звук и длительность эффекта «Заморозка» (`public/zamorozka.mp3`). */
const DEFAULT_FREEZE_LIFE_MS = 820;
const FREEZE_SFX_BASE_VOLUME = 0.88;
/** На 25% тише базовой громкости эффекта. */
export const FREEZE_SFX_VOLUME = FREEZE_SFX_BASE_VOLUME * 0.75;
export function freezeEffectSoundUrl(): string {
return new URL('zamorozka.mp3', window.location.href).href;
}
let cachedFreezeSfxDurationMs: number | null = null;
/** Длительность трека в мс (кэш после первого чтения метаданных). */
export function getFreezeSfxDurationMs(): Promise<number> {
if (cachedFreezeSfxDurationMs !== null) {
return Promise.resolve(cachedFreezeSfxDurationMs);
}
const url = freezeEffectSoundUrl();
return new Promise((resolve) => {
const a = new Audio();
const done = (ms: number): void => {
cachedFreezeSfxDurationMs = ms;
a.removeAttribute('src');
resolve(ms);
};
a.addEventListener('loadedmetadata', () => {
const d = a.duration;
done(Number.isFinite(d) && d > 0 ? Math.round(d * 1000) : DEFAULT_FREEZE_LIFE_MS);
});
a.addEventListener('error', () => done(DEFAULT_FREEZE_LIFE_MS));
a.src = url;
a.load();
});
}
/** Длительность полноэкранной заморозки и льда — по времени звука, с разумными пределами. */
export async function getFreezeEffectLifeMs(): Promise<number> {
const raw = await getFreezeSfxDurationMs();
return Math.min(45_000, Math.max(400, raw));
}
export function playFreezeEffectSound(): void {
try {
const el = new Audio(freezeEffectSoundUrl());
el.volume = FREEZE_SFX_VOLUME;
void el.play().catch(() => undefined);
} catch {
/* ignore */
}
}
+59
View File
@@ -0,0 +1,59 @@
/** Звук и длительность эффекта «Облако яда» (`public/oblako-yada.mp3`). */
const POISON_CLOUD_SFX_VOLUME = 0.92;
/** Запас, если метаданные не прочитались. */
const DEFAULT_POISON_CLOUD_LIFE_MS = 1600;
export function poisonCloudEffectSoundUrl(): string {
return new URL('oblako-yada.mp3', window.location.href).href;
}
let cachedPoisonCloudSfxDurationMs: number | null = null;
/** Длительность трека в мс (кэш после первого чтения метаданных). */
export function getPoisonCloudSfxDurationMs(): Promise<number> {
if (cachedPoisonCloudSfxDurationMs !== null) {
return Promise.resolve(cachedPoisonCloudSfxDurationMs);
}
const url = poisonCloudEffectSoundUrl();
return new Promise((resolve) => {
const a = new Audio();
const done = (ms: number): void => {
cachedPoisonCloudSfxDurationMs = ms;
a.removeAttribute('src');
resolve(ms);
};
a.addEventListener('loadedmetadata', () => {
const d = a.duration;
done(Number.isFinite(d) && d > 0 ? Math.round(d * 1000) : DEFAULT_POISON_CLOUD_LIFE_MS);
});
a.addEventListener('error', () => done(DEFAULT_POISON_CLOUD_LIFE_MS));
a.src = url;
a.load();
});
}
/** Длительность визуала = длительности файла (с разумными пределами). */
export async function getPoisonCloudEffectLifeMs(): Promise<number> {
const raw = await getPoisonCloudSfxDurationMs();
return Math.min(60_000, Math.max(600, raw));
}
/**
* Воспроизведение с подгонкой скорости: фактическое время звука по часам ≈ `lifeMs`,
* чтобы совпасть с анимацией.
*/
export async function playPoisonCloudEffectSound(lifeMs: number): Promise<void> {
try {
const rawMs = await getPoisonCloudSfxDurationMs();
const target = Math.max(200, lifeMs);
const rate = Math.max(0.25, Math.min(4, rawMs / target));
const el = new Audio(poisonCloudEffectSoundUrl());
el.volume = POISON_CLOUD_SFX_VOLUME;
el.playbackRate = rate;
void el.play().catch(() => undefined);
} catch {
/* ignore */
}
}
+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 */
}
}