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
+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>