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