feat(effects): вода, облако яда, луч света; пульт и окна демонстрации
- Поле: вода (сплошная заливка по штриху, превью кистью), туман/огонь/дождь без изменений логики. - Действия: облако яда (частицы, круглая текстура, звук oblako-yada.mp3, длительность как у трека), луч света и заморозка со звуками из public/. - Пульт: инструменты воды и яда, синхрон SFX, тесты панели и ластика. - Окно управления: дочернее от окна просмотра (Z-order). - Типы эффектов, effectsStore prune, hit-test ластика. Made-with: Cursor
This commit is contained in:
@@ -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 {
|
||||
|
||||
@@ -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'));
|
||||
|
||||
@@ -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 */
|
||||
}
|
||||
}
|
||||
@@ -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 */
|
||||
}
|
||||
}
|
||||
@@ -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 */
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user