20c838da7d
- Поле: вода (сплошная заливка по штриху, превью кистью), туман/огонь/дождь без изменений логики. - Действия: облако яда (частицы, круглая текстура, звук oblako-yada.mp3, длительность как у трека), луч света и заморозка со звуками из public/. - Пульт: инструменты воды и яда, синхрон SFX, тесты панели и ластика. - Окно управления: дочернее от окна просмотра (Z-order). - Типы эффектов, effectsStore prune, hit-test ластика. Made-with: Cursor
1146 lines
44 KiB
TypeScript
1146 lines
44 KiB
TypeScript
import React, { useEffect, useMemo, useRef, useState } from 'react';
|
||
|
||
import { pickEraseTargetId } from '../../shared/effectEraserHitTest';
|
||
import { ipcChannels } from '../../shared/ipc/contracts';
|
||
import type { SessionState } from '../../shared/ipc/contracts';
|
||
import type { GraphNodeId, Scene, SceneId } from '../../shared/types';
|
||
import { getDndApi } from '../shared/dndApi';
|
||
import { PixiEffectsOverlay } from '../shared/effects/PxiEffectsOverlay';
|
||
import { useEffectsState } from '../shared/effects/useEffectsState';
|
||
import { Button } from '../shared/ui/controls';
|
||
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';
|
||
const s = Math.floor(sec);
|
||
const m = Math.floor(s / 60);
|
||
const r = s % 60;
|
||
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();
|
||
const [session, setSession] = useState<SessionState | null>(null);
|
||
const historyRef = useRef<GraphNodeId[]>([]);
|
||
const suppressNextHistoryPushRef = useRef(false);
|
||
const [history, setHistory] = useState<GraphNodeId[]>([]);
|
||
const audioElsRef = useRef<Map<string, HTMLAudioElement>>(new Map());
|
||
const audioMetaRef = useRef<Map<string, { lastPlayError: string | null }>>(new Map());
|
||
const [audioStateTick, setAudioStateTick] = useState(0);
|
||
const audioLoadRunRef = useRef(0);
|
||
const audioUnmountRef = useRef(false);
|
||
const previewHostRef = useRef<HTMLDivElement | null>(null);
|
||
const previewVideoRef = useRef<HTMLVideoElement | null>(null);
|
||
const brushRef = useRef<{
|
||
tool: 'fog' | 'fire' | 'rain' | 'water' | 'lightning' | 'sunbeam' | 'poisonCloud' | 'freeze' | 'eraser';
|
||
startN?: { x: number; y: number };
|
||
points?: { x: number; y: number; tMs: number }[];
|
||
} | null>(null);
|
||
const [draftFxTick, setDraftFxTick] = useState(0);
|
||
const [cursorN, setCursorN] = useState<{ x: number; y: number } | null>(null);
|
||
const [previewSize, setPreviewSize] = useState<{ w: number; h: number }>({ w: 1, h: 1 });
|
||
const [previewContentRect, setPreviewContentRect] = useState<{
|
||
x: number;
|
||
y: number;
|
||
w: number;
|
||
h: number;
|
||
} | null>(null);
|
||
|
||
useEffect(() => {
|
||
void api.invoke(ipcChannels.project.get, {}).then((res) => {
|
||
const next: SessionState = {
|
||
project: res.project,
|
||
currentSceneId: res.project?.currentSceneId ?? null,
|
||
};
|
||
setSession(next);
|
||
historyRef.current = next.project?.currentGraphNodeId ? [next.project.currentGraphNodeId] : [];
|
||
setHistory(historyRef.current);
|
||
});
|
||
return api.on(ipcChannels.session.stateChanged, ({ state }) => {
|
||
setSession(state);
|
||
const cur = state.project?.currentGraphNodeId ?? null;
|
||
if (!cur) return;
|
||
const arr = historyRef.current;
|
||
if (suppressNextHistoryPushRef.current) {
|
||
suppressNextHistoryPushRef.current = false;
|
||
setHistory(arr);
|
||
return;
|
||
}
|
||
// Если мы перемотались на уже существующий шаг, не дублируем его в истории.
|
||
if (arr.includes(cur)) {
|
||
setHistory(arr);
|
||
return;
|
||
}
|
||
if (arr[arr.length - 1] !== cur) {
|
||
historyRef.current = [...arr, cur];
|
||
setHistory(historyRef.current);
|
||
}
|
||
});
|
||
}, [api]);
|
||
|
||
useEffect(() => {
|
||
audioUnmountRef.current = false;
|
||
return () => {
|
||
audioUnmountRef.current = true;
|
||
};
|
||
}, []);
|
||
|
||
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 =
|
||
project && session?.currentSceneId ? project.scenes[session.currentSceneId] : undefined;
|
||
const isVideoPreviewScene = currentScene?.previewAssetType === 'video';
|
||
const sceneAudioRefs = useMemo(() => currentScene?.media.audios ?? [], [currentScene]);
|
||
|
||
const sceneAudios = useMemo(() => {
|
||
if (!project) return [];
|
||
return sceneAudioRefs
|
||
.map((r) => {
|
||
const a = project.assets[r.assetId];
|
||
return a?.type === 'audio' ? { ref: r, asset: a } : null;
|
||
})
|
||
.filter((x): x is { ref: (typeof sceneAudioRefs)[number]; asset: NonNullable<typeof x>['asset'] } =>
|
||
Boolean(x),
|
||
);
|
||
}, [project, sceneAudioRefs]);
|
||
|
||
useEffect(() => {
|
||
audioLoadRunRef.current += 1;
|
||
const runId = audioLoadRunRef.current;
|
||
|
||
const oldEls = new Map(audioElsRef.current);
|
||
audioElsRef.current = new Map();
|
||
audioMetaRef.current.clear();
|
||
setAudioStateTick((x) => x + 1);
|
||
|
||
const FADE_OUT_MS = 450;
|
||
const fadeOutCtl = { raf: 0, cancelled: false };
|
||
const finishFadeOut = (): void => {
|
||
for (const el of oldEls.values()) {
|
||
try {
|
||
el.pause();
|
||
el.currentTime = 0;
|
||
el.volume = 1;
|
||
} catch {
|
||
// ignore
|
||
}
|
||
}
|
||
};
|
||
if (oldEls.size > 0) {
|
||
const startVol = new Map<string, number>();
|
||
for (const [id, el] of oldEls) {
|
||
startVol.set(id, el.volume);
|
||
}
|
||
const t0 = performance.now();
|
||
const tickOut = (now: number): void => {
|
||
if (fadeOutCtl.cancelled || audioUnmountRef.current) {
|
||
finishFadeOut();
|
||
return;
|
||
}
|
||
const u = Math.min(1, (now - t0) / FADE_OUT_MS);
|
||
for (const [id, el] of oldEls) {
|
||
try {
|
||
const v0 = startVol.get(id) ?? 1;
|
||
el.volume = v0 * (1 - u);
|
||
} catch {
|
||
// ignore
|
||
}
|
||
}
|
||
if (u < 1) {
|
||
fadeOutCtl.raf = window.requestAnimationFrame(tickOut);
|
||
} else {
|
||
finishFadeOut();
|
||
}
|
||
};
|
||
fadeOutCtl.raf = window.requestAnimationFrame(tickOut);
|
||
}
|
||
|
||
if (!project || !currentScene) {
|
||
return () => {
|
||
fadeOutCtl.cancelled = true;
|
||
window.cancelAnimationFrame(fadeOutCtl.raf);
|
||
};
|
||
}
|
||
|
||
const FADE_IN_MS = 550;
|
||
void (async () => {
|
||
const loaded: { ref: (typeof sceneAudioRefs)[number]; el: HTMLAudioElement }[] = [];
|
||
for (const item of sceneAudioRefs) {
|
||
const r = await api.invoke(ipcChannels.project.assetFileUrl, { assetId: item.assetId });
|
||
if (audioLoadRunRef.current !== runId) return;
|
||
if (!r.url) continue;
|
||
const el = new Audio(r.url);
|
||
el.loop = item.loop;
|
||
el.preload = 'auto';
|
||
el.volume = item.autoplay ? 0 : 1;
|
||
audioMetaRef.current.set(item.assetId, { lastPlayError: null });
|
||
el.addEventListener('play', () => setAudioStateTick((x) => x + 1));
|
||
el.addEventListener('pause', () => setAudioStateTick((x) => x + 1));
|
||
el.addEventListener('ended', () => setAudioStateTick((x) => x + 1));
|
||
el.addEventListener('canplay', () => setAudioStateTick((x) => x + 1));
|
||
el.addEventListener('error', () => setAudioStateTick((x) => x + 1));
|
||
loaded.push({ ref: item, el });
|
||
audioElsRef.current.set(item.assetId, el);
|
||
}
|
||
setAudioStateTick((x) => x + 1);
|
||
for (const { ref, el } of loaded) {
|
||
if (audioLoadRunRef.current !== runId) {
|
||
try {
|
||
el.pause();
|
||
el.currentTime = 0;
|
||
el.volume = 1;
|
||
} catch {
|
||
// ignore
|
||
}
|
||
continue;
|
||
}
|
||
if (!ref.autoplay) continue;
|
||
try {
|
||
await el.play();
|
||
} catch {
|
||
const m = audioMetaRef.current.get(ref.assetId) ?? { lastPlayError: null };
|
||
audioMetaRef.current.set(ref.assetId, {
|
||
...m,
|
||
lastPlayError:
|
||
'Автозапуск заблокирован (нужно действие пользователя) или ошибка воспроизведения.',
|
||
});
|
||
setAudioStateTick((x) => x + 1);
|
||
try {
|
||
el.volume = 1;
|
||
} catch {
|
||
// ignore
|
||
}
|
||
continue;
|
||
}
|
||
if (audioLoadRunRef.current !== runId || audioUnmountRef.current) {
|
||
try {
|
||
el.volume = 1;
|
||
} catch {
|
||
// ignore
|
||
}
|
||
continue;
|
||
}
|
||
const tIn0 = performance.now();
|
||
const tickIn = (now: number): void => {
|
||
if (audioLoadRunRef.current !== runId || audioUnmountRef.current) {
|
||
try {
|
||
el.volume = 1;
|
||
} catch {
|
||
// ignore
|
||
}
|
||
return;
|
||
}
|
||
const u = Math.min(1, (now - tIn0) / FADE_IN_MS);
|
||
try {
|
||
el.volume = u;
|
||
} catch {
|
||
// ignore
|
||
}
|
||
if (u < 1) window.requestAnimationFrame(tickIn);
|
||
};
|
||
window.requestAnimationFrame(tickIn);
|
||
}
|
||
})();
|
||
|
||
return () => {
|
||
fadeOutCtl.cancelled = true;
|
||
window.cancelAnimationFrame(fadeOutCtl.raf);
|
||
};
|
||
}, [api, currentScene, project, sceneAudioRefs]);
|
||
|
||
const anyPlaying = useMemo(() => {
|
||
for (const el of audioElsRef.current.values()) {
|
||
if (!el.paused) return true;
|
||
}
|
||
return false;
|
||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||
}, [audioStateTick]);
|
||
|
||
useEffect(() => {
|
||
if (!anyPlaying) return;
|
||
let raf = 0;
|
||
const tick = () => {
|
||
setAudioStateTick((x) => x + 1);
|
||
raf = window.requestAnimationFrame(tick);
|
||
};
|
||
raf = window.requestAnimationFrame(tick);
|
||
return () => window.cancelAnimationFrame(raf);
|
||
}, [anyPlaying]);
|
||
|
||
useEffect(() => {
|
||
const host = previewHostRef.current;
|
||
if (!host) return;
|
||
const update = () => {
|
||
const r = host.getBoundingClientRect();
|
||
setPreviewSize({ w: Math.max(1, r.width), h: Math.max(1, r.height) });
|
||
};
|
||
update();
|
||
const ro = new ResizeObserver(update);
|
||
ro.observe(host);
|
||
return () => ro.disconnect();
|
||
}, []);
|
||
|
||
function audioStatus(assetId: string): { label: string; detail?: string } {
|
||
const el = audioElsRef.current.get(assetId) ?? null;
|
||
if (!el) return { label: 'URL не получен', detail: 'Не удалось получить dnd://asset URL для аудио.' };
|
||
const meta = audioMetaRef.current.get(assetId) ?? { lastPlayError: null };
|
||
if (meta.lastPlayError) return { label: 'Ошибка/блок', detail: meta.lastPlayError };
|
||
if (el.error)
|
||
return {
|
||
label: 'Ошибка',
|
||
detail: `MediaError code=${String(el.error.code)} (1=ABORTED, 2=NETWORK, 3=DECODE, 4=SRC_NOT_SUPPORTED)`,
|
||
};
|
||
if (el.readyState < 2) return { label: 'Загрузка…' };
|
||
if (!el.paused) return { label: 'Играет' };
|
||
if (el.currentTime > 0) return { label: 'Пауза' };
|
||
return { label: 'Остановлено' };
|
||
}
|
||
const nextScenes = useMemo(() => {
|
||
if (!project) return [];
|
||
if (!currentGraphNodeId) return [];
|
||
const outgoing = project.sceneGraphEdges
|
||
.filter((e) => e.sourceGraphNodeId === currentGraphNodeId)
|
||
.map((e) => {
|
||
const n = project.sceneGraphNodes.find((x) => x.id === e.targetGraphNodeId);
|
||
return n ? { graphNodeId: e.targetGraphNodeId, sceneId: n.sceneId } : null;
|
||
})
|
||
.filter((x): x is { graphNodeId: GraphNodeId; sceneId: SceneId } => Boolean(x));
|
||
return outgoing
|
||
.map((o) => ({ graphNodeId: o.graphNodeId, scene: project.scenes[o.sceneId] }))
|
||
.filter((x): x is { graphNodeId: GraphNodeId; scene: Scene } => x.scene !== undefined);
|
||
}, [currentGraphNodeId, project]);
|
||
|
||
const tool = fxState?.tool ?? { tool: 'fog', radiusN: 0.08, intensity: 0.6 };
|
||
|
||
function toNPoint(e: React.PointerEvent): { x: number; y: number } | null {
|
||
const host = previewHostRef.current;
|
||
if (!host) return null;
|
||
const r = host.getBoundingClientRect();
|
||
const cr = previewContentRect;
|
||
const ox = cr ? cr.x : 0;
|
||
const oy = cr ? cr.y : 0;
|
||
const cw = cr ? cr.w : r.width;
|
||
const ch = cr ? cr.h : r.height;
|
||
const x = (e.clientX - (r.left + ox)) / Math.max(1, cw);
|
||
const y = (e.clientY - (r.top + oy)) / Math.max(1, ch);
|
||
return { x: Math.max(0, Math.min(1, x)), y: Math.max(0, Math.min(1, y)) };
|
||
}
|
||
|
||
async function commitStroke(): Promise<void> {
|
||
if (isVideoPreviewScene) {
|
||
brushRef.current = null;
|
||
setDraftFxTick((x) => x + 1);
|
||
return;
|
||
}
|
||
if (!fxState) return;
|
||
const b = brushRef.current;
|
||
if (!b) return;
|
||
const createdAtMs = Date.now();
|
||
const seed = Math.floor(Math.random() * 1_000_000_000);
|
||
|
||
if (b.tool === 'fog' && b.points && b.points.length > 0) {
|
||
await fx.dispatch({
|
||
kind: 'instance.add',
|
||
instance: {
|
||
id: `fog_${String(createdAtMs)}_${String(seed)}`,
|
||
type: 'fog',
|
||
seed,
|
||
createdAtMs,
|
||
points: b.points,
|
||
radiusN: tool.radiusN,
|
||
opacity: Math.max(0.05, Math.min(0.9, tool.intensity)),
|
||
lifetimeMs: null,
|
||
},
|
||
});
|
||
}
|
||
if (b.tool === 'fire' && b.points && b.points.length > 0) {
|
||
await fx.dispatch({
|
||
kind: 'instance.add',
|
||
instance: {
|
||
id: `fire_${String(createdAtMs)}_${String(seed)}`,
|
||
type: 'fire',
|
||
seed,
|
||
createdAtMs,
|
||
points: b.points,
|
||
radiusN: tool.radiusN,
|
||
// Огонь визуально ярче, но всё равно ограничиваемся безопасными пределами.
|
||
opacity: Math.max(0.12, Math.min(0.95, tool.intensity)),
|
||
lifetimeMs: null,
|
||
},
|
||
});
|
||
}
|
||
if (b.tool === 'rain' && b.points && b.points.length > 0) {
|
||
await fx.dispatch({
|
||
kind: 'instance.add',
|
||
instance: {
|
||
id: `rain_${String(createdAtMs)}_${String(seed)}`,
|
||
type: 'rain',
|
||
seed,
|
||
createdAtMs,
|
||
points: b.points,
|
||
radiusN: tool.radiusN,
|
||
opacity: Math.max(0.08, Math.min(0.9, tool.intensity)),
|
||
lifetimeMs: null,
|
||
},
|
||
});
|
||
}
|
||
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;
|
||
const end = { x: last.x, y: last.y };
|
||
const start = { x: end.x, y: 0 };
|
||
await fx.dispatch({
|
||
kind: 'instance.add',
|
||
instance: {
|
||
id: `lt_${String(createdAtMs)}_${String(seed)}`,
|
||
type: 'lightning',
|
||
seed,
|
||
createdAtMs,
|
||
start,
|
||
end,
|
||
widthN: Math.max(0.01, tool.radiusN * 0.9),
|
||
intensity: Math.max(0.9, Math.min(1.2, tool.intensity * 1.35)),
|
||
lifetimeMs: LIGHTNING_EFFECT_MS,
|
||
},
|
||
});
|
||
await fx.dispatch({
|
||
kind: 'instance.add',
|
||
instance: {
|
||
id: `sc_${String(createdAtMs)}_${String(seed)}`,
|
||
type: 'scorch',
|
||
seed: seed ^ 0x5a5a5a,
|
||
createdAtMs,
|
||
at: end,
|
||
radiusN: Math.max(0.03, tool.radiusN * 0.625),
|
||
opacity: 0.92,
|
||
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: {
|
||
id: `fr_${String(createdAtMs)}_${String(seed)}`,
|
||
type: 'freeze',
|
||
seed,
|
||
createdAtMs,
|
||
at,
|
||
intensity: Math.max(0.8, Math.min(1.25, tool.intensity * 1.15)),
|
||
// Длительность как у zamorozka.mp3 (фазы «замерзания» в PxiEffectsOverlay масштабируются по life).
|
||
lifetimeMs: freezeLifeMs,
|
||
},
|
||
});
|
||
await fx.dispatch({
|
||
kind: 'instance.add',
|
||
instance: {
|
||
id: `ice_${String(createdAtMs)}_${String(seed)}`,
|
||
type: 'ice',
|
||
seed: seed ^ 0x33cc99,
|
||
createdAtMs,
|
||
at,
|
||
radiusN: Math.max(0.03, tool.radiusN * 0.9),
|
||
opacity: 0.85,
|
||
lifetimeMs: null,
|
||
},
|
||
});
|
||
playFreezeEffectSound();
|
||
}
|
||
brushRef.current = null;
|
||
setDraftFxTick((x) => x + 1);
|
||
}
|
||
const draftInstance = useMemo(() => {
|
||
const b = brushRef.current;
|
||
if (!b) return null;
|
||
const seed = 12345;
|
||
const createdAtMs = Date.now();
|
||
if (b.tool === 'fog' && b.points && b.points.length > 0) {
|
||
return {
|
||
id: '__draft__',
|
||
type: 'fog' as const,
|
||
seed,
|
||
createdAtMs,
|
||
points: b.points,
|
||
radiusN: tool.radiusN,
|
||
opacity: Math.max(0.05, Math.min(0.6, tool.intensity * 0.7)),
|
||
lifetimeMs: null,
|
||
};
|
||
}
|
||
if (b.tool === 'fire' && b.points && b.points.length > 0) {
|
||
return {
|
||
id: '__draft__',
|
||
type: 'fire' as const,
|
||
seed,
|
||
createdAtMs,
|
||
points: b.points,
|
||
radiusN: tool.radiusN,
|
||
opacity: Math.max(0.12, Math.min(0.75, tool.intensity * 0.85)),
|
||
lifetimeMs: null,
|
||
};
|
||
}
|
||
if (b.tool === 'rain' && b.points && b.points.length > 0) {
|
||
return {
|
||
id: '__draft__',
|
||
type: 'rain' as const,
|
||
seed,
|
||
createdAtMs,
|
||
points: b.points,
|
||
radiusN: tool.radiusN,
|
||
opacity: Math.max(0.08, Math.min(0.65, tool.intensity * 0.85)),
|
||
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;
|
||
return {
|
||
id: '__draft__',
|
||
type: 'lightning' as const,
|
||
seed,
|
||
createdAtMs,
|
||
start: { x: last.x, y: 0 },
|
||
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: 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) {
|
||
const last = b.points[b.points.length - 1];
|
||
if (last === undefined) return null;
|
||
return {
|
||
id: '__draft__',
|
||
type: 'freeze' as const,
|
||
seed,
|
||
createdAtMs,
|
||
at: { x: last.x, y: last.y },
|
||
intensity: Math.max(0.8, Math.min(1.25, tool.intensity * 1.15)),
|
||
lifetimeMs: freezeDraftLifeMs,
|
||
};
|
||
}
|
||
return null;
|
||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||
}, [
|
||
draftFxTick,
|
||
freezeDraftLifeMs,
|
||
poisonDraftLifeMs,
|
||
sunbeamDraftLifeMs,
|
||
tool.intensity,
|
||
tool.radiusN,
|
||
tool.tool,
|
||
]);
|
||
|
||
const fxMergedState = useMemo(() => {
|
||
if (!fxState) return null;
|
||
if (!draftInstance) return fxState;
|
||
return { ...fxState, instances: [...fxState.instances, draftInstance] };
|
||
}, [draftInstance, fxState]);
|
||
|
||
return (
|
||
<div className={styles.page}>
|
||
<Surface className={styles.remote}>
|
||
<div className={styles.remoteTitle}>ПУЛЬТ УПРАВЛЕНИЯ</div>
|
||
<div className={styles.spacer12} />
|
||
{!isVideoPreviewScene ? (
|
||
<>
|
||
<div className={styles.sectionLabel}>ЭФФЕКТЫ</div>
|
||
<div className={styles.spacer8} />
|
||
<div className={styles.effectsStack}>
|
||
<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>
|
||
<input
|
||
type="range"
|
||
min={0.015}
|
||
max={0.18}
|
||
step={0.001}
|
||
value={tool.radiusN}
|
||
onChange={(e) => {
|
||
const v = Number((e.currentTarget as HTMLInputElement).value);
|
||
const next = Math.max(0.01, Math.min(0.25, Number.isFinite(v) ? v : tool.radiusN));
|
||
void fx.dispatch({ kind: 'tool.set', tool: { ...tool, radiusN: next } });
|
||
}}
|
||
className={styles.range}
|
||
aria-label="Радиус кисти"
|
||
/>
|
||
<div className={styles.radiusValue}>{Math.round(tool.radiusN * 100)}</div>
|
||
</div>
|
||
</div>
|
||
<div className={styles.spacer12} />
|
||
</>
|
||
) : null}
|
||
<div className={styles.storyWrap}>
|
||
<div className={styles.sectionLabel}>СЮЖЕТНАЯ ЛИНИЯ</div>
|
||
<div className={styles.spacer10} />
|
||
<div className={styles.storyScroll}>
|
||
{history.map((gnId, idx) => {
|
||
const gn = project?.sceneGraphNodes.find((n) => n.id === gnId);
|
||
const s = gn ? project?.scenes[gn.sceneId] : undefined;
|
||
const isCurrent = gnId === project?.currentGraphNodeId;
|
||
return (
|
||
<button
|
||
type="button"
|
||
key={`${gnId}_${String(idx)}`}
|
||
disabled={!project || isCurrent}
|
||
className={[styles.historyBtn, isCurrent ? styles.historyBtnCurrent : '']
|
||
.filter(Boolean)
|
||
.join(' ')}
|
||
title={project && !isCurrent ? 'Перейти к этой сцене' : undefined}
|
||
onClick={() => {
|
||
if (!project) return;
|
||
if (isCurrent) return;
|
||
// Перемотка: переходим на выбранный шаг без добавления нового пункта в историю.
|
||
suppressNextHistoryPushRef.current = true;
|
||
void api.invoke(ipcChannels.project.setCurrentGraphNode, { graphNodeId: gnId });
|
||
}}
|
||
>
|
||
{isCurrent ? (
|
||
<div className={styles.historyBadge}>ТЕКУЩАЯ СЦЕНА</div>
|
||
) : (
|
||
<div className={styles.historyMuted}>Пройдено</div>
|
||
)}
|
||
<div className={styles.historyTitle}>{s?.title ?? (gn ? String(gn.sceneId) : gnId)}</div>
|
||
</button>
|
||
);
|
||
})}
|
||
{history.length === 0 ? <div className={styles.emptyStory}>Нет активной сцены.</div> : null}
|
||
</div>
|
||
</div>
|
||
</Surface>
|
||
|
||
<div className={styles.rightStack}>
|
||
<Surface className={styles.surfacePad}>
|
||
<div className={styles.previewHeader}>
|
||
<div className={styles.previewTitle}>Предпросмотр экрана</div>
|
||
<div className={styles.previewActions}>
|
||
<Button onClick={() => void api.invoke(ipcChannels.windows.closeMultiWindow, {})}>
|
||
Выключить демонстрацию
|
||
</Button>
|
||
</div>
|
||
</div>
|
||
<div className={styles.spacer10} />
|
||
{isVideoPreviewScene ? (
|
||
<div className={styles.videoHint}>
|
||
Видео-превью: кисть эффектов отключена (как на экране демонстрации — оверлей только для
|
||
изображения).
|
||
</div>
|
||
) : null}
|
||
<div className={styles.spacer10} />
|
||
<div className={styles.previewFrame}>
|
||
<div ref={previewHostRef} className={styles.previewHost}>
|
||
<ControlScenePreview
|
||
session={session}
|
||
videoRef={previewVideoRef}
|
||
onContentRectChange={setPreviewContentRect}
|
||
/>
|
||
</div>
|
||
{!isVideoPreviewScene ? (
|
||
<>
|
||
<PixiEffectsOverlay
|
||
state={fxMergedState}
|
||
style={{ zIndex: 1 }}
|
||
viewport={
|
||
previewContentRect
|
||
? {
|
||
x: previewContentRect.x,
|
||
y: previewContentRect.y,
|
||
w: previewContentRect.w,
|
||
h: previewContentRect.h,
|
||
}
|
||
: undefined
|
||
}
|
||
/>
|
||
{cursorN ? (
|
||
<div
|
||
className={styles.brushCursor}
|
||
style={{
|
||
left:
|
||
(previewContentRect ? previewContentRect.x : 0) +
|
||
cursorN.x * (previewContentRect ? previewContentRect.w : previewSize.w),
|
||
top:
|
||
(previewContentRect ? previewContentRect.y : 0) +
|
||
cursorN.y * (previewContentRect ? previewContentRect.h : previewSize.h),
|
||
width:
|
||
tool.radiusN *
|
||
Math.min(
|
||
previewContentRect ? previewContentRect.w : previewSize.w,
|
||
previewContentRect ? previewContentRect.h : previewSize.h,
|
||
) *
|
||
2,
|
||
height:
|
||
tool.radiusN *
|
||
Math.min(
|
||
previewContentRect ? previewContentRect.w : previewSize.w,
|
||
previewContentRect ? previewContentRect.h : previewSize.h,
|
||
) *
|
||
2,
|
||
}}
|
||
/>
|
||
) : null}
|
||
<div
|
||
className={styles.brushLayer}
|
||
onPointerEnter={(e) => {
|
||
const p = toNPoint(e);
|
||
if (!p) return;
|
||
setCursorN(p);
|
||
}}
|
||
onPointerLeave={() => setCursorN(null)}
|
||
onPointerDown={(e) => {
|
||
const p = toNPoint(e);
|
||
if (!p) return;
|
||
setCursorN(p);
|
||
(e.currentTarget as HTMLDivElement).setPointerCapture(e.pointerId);
|
||
if (tool.tool === 'eraser') {
|
||
const id = pickEraseTargetId(fxState?.instances ?? [], p, tool.radiusN);
|
||
if (id) void fx.dispatch({ kind: 'instance.remove', id });
|
||
return;
|
||
}
|
||
brushRef.current = {
|
||
tool: tool.tool,
|
||
startN: p,
|
||
points: [{ x: p.x, y: p.y, tMs: Date.now() }],
|
||
};
|
||
setDraftFxTick((x) => x + 1);
|
||
}}
|
||
onPointerMove={(e) => {
|
||
const p = toNPoint(e);
|
||
if (!p) return;
|
||
setCursorN(p);
|
||
if (tool.tool === 'eraser' && (e.buttons & 1) !== 0) {
|
||
const id = pickEraseTargetId(fxState?.instances ?? [], p, tool.radiusN);
|
||
if (id) void fx.dispatch({ kind: 'instance.remove', id });
|
||
return;
|
||
}
|
||
const b = brushRef.current;
|
||
if (!b?.points) return;
|
||
const last = b.points[b.points.length - 1];
|
||
if (!last) return;
|
||
const dx = p.x - last.x;
|
||
const dy = p.y - last.y;
|
||
const minStep = Math.max(0.004, tool.radiusN * 0.25);
|
||
if (dx * dx + dy * dy < minStep * minStep) return;
|
||
b.points.push({ x: p.x, y: p.y, tMs: Date.now() });
|
||
setDraftFxTick((x) => x + 1);
|
||
}}
|
||
onPointerUp={() => {
|
||
void commitStroke();
|
||
}}
|
||
onPointerCancel={() => {
|
||
brushRef.current = null;
|
||
setDraftFxTick((x) => x + 1);
|
||
}}
|
||
/>
|
||
</>
|
||
) : null}
|
||
</div>
|
||
</Surface>
|
||
|
||
<Surface className={styles.surfacePad}>
|
||
<div className={styles.branchTitle}>Варианты ветвления</div>
|
||
<div className={styles.branchGrid}>
|
||
{nextScenes.map((o, i) => (
|
||
<div key={o.graphNodeId} className={styles.branchCard}>
|
||
<div className={styles.branchCardHeader}>
|
||
<div className={styles.branchOption}>ОПЦИЯ {String(i + 1)}</div>
|
||
</div>
|
||
<div className={styles.branchName}>{o.scene.title || 'Без названия'}</div>
|
||
<Button
|
||
variant="primary"
|
||
onClick={() =>
|
||
void api.invoke(ipcChannels.project.setCurrentGraphNode, { graphNodeId: o.graphNodeId })
|
||
}
|
||
>
|
||
Переключить
|
||
</Button>
|
||
</div>
|
||
))}
|
||
{nextScenes.length === 0 ? (
|
||
<div className={styles.branchEmpty}>
|
||
<div>Нет вариантов перехода.</div>
|
||
<Button
|
||
variant="primary"
|
||
disabled={!session?.project?.currentGraphNodeId}
|
||
onClick={() => void api.invoke(ipcChannels.windows.closeMultiWindow, {})}
|
||
>
|
||
Завершить показ
|
||
</Button>
|
||
</div>
|
||
) : null}
|
||
</div>
|
||
</Surface>
|
||
|
||
<Surface className={styles.surfacePad}>
|
||
<div className={styles.musicHeader}>
|
||
<div className={styles.previewTitle}>Музыка</div>
|
||
</div>
|
||
<div className={styles.spacer10} />
|
||
{sceneAudios.length === 0 ? (
|
||
<div className={styles.musicEmpty}>В текущей сцене нет аудио.</div>
|
||
) : (
|
||
<div className={styles.audioList}>
|
||
{sceneAudios.map(({ ref, asset }) => {
|
||
const el = audioElsRef.current.get(ref.assetId) ?? null;
|
||
const st = audioStatus(ref.assetId);
|
||
const dur = el?.duration && Number.isFinite(el.duration) ? el.duration : 0;
|
||
const cur = el?.currentTime && Number.isFinite(el.currentTime) ? el.currentTime : 0;
|
||
const pct = dur > 0 ? Math.max(0, Math.min(1, cur / dur)) : 0;
|
||
return (
|
||
<div key={ref.assetId} className={styles.audioCard}>
|
||
<div className={styles.audioMeta}>
|
||
<div className={styles.audioName}>{asset.originalName}</div>
|
||
<div className={styles.audioBadges}>
|
||
<div>{ref.autoplay ? 'Авто' : 'Ручн.'}</div>
|
||
<div>{ref.loop ? 'Цикл' : 'Один раз'}</div>
|
||
<div title={st.detail}>{st.label}</div>
|
||
</div>
|
||
<div className={styles.spacer10} />
|
||
<div
|
||
role="slider"
|
||
aria-valuemin={0}
|
||
aria-valuemax={dur > 0 ? Math.round(dur) : 0}
|
||
aria-valuenow={Math.round(cur)}
|
||
tabIndex={0}
|
||
onKeyDown={(e) => {
|
||
if (!el) return;
|
||
if (!dur) return;
|
||
if (e.key === 'ArrowLeft') el.currentTime = Math.max(0, el.currentTime - 5);
|
||
if (e.key === 'ArrowRight') el.currentTime = Math.min(dur, el.currentTime + 5);
|
||
setAudioStateTick((x) => x + 1);
|
||
}}
|
||
onClick={(e) => {
|
||
if (!el) return;
|
||
if (!dur) return;
|
||
const rect = (e.currentTarget as HTMLDivElement).getBoundingClientRect();
|
||
const next = (e.clientX - rect.left) / rect.width;
|
||
el.currentTime = Math.max(0, Math.min(dur, next * dur));
|
||
setAudioStateTick((x) => x + 1);
|
||
}}
|
||
className={[
|
||
styles.audioScrub,
|
||
dur > 0 ? styles.audioScrubPointer : styles.audioScrubDefault,
|
||
].join(' ')}
|
||
title={dur > 0 ? 'Клик — перемотка' : 'Длительность неизвестна'}
|
||
>
|
||
<div
|
||
className={styles.scrubFill}
|
||
style={{ width: `${String(Math.round(pct * 100))}%` }}
|
||
/>
|
||
</div>
|
||
<div className={styles.timeRow}>
|
||
<div>{formatTime(cur)}</div>
|
||
<div>{dur ? formatTime(dur) : '—:—'}</div>
|
||
</div>
|
||
</div>
|
||
<div className={styles.audioTransport}>
|
||
<Button
|
||
variant="primary"
|
||
onClick={() => {
|
||
if (!el) return;
|
||
const m = audioMetaRef.current.get(ref.assetId) ?? { lastPlayError: null };
|
||
audioMetaRef.current.set(ref.assetId, { ...m, lastPlayError: null });
|
||
void el.play().catch(() => {
|
||
const mm = audioMetaRef.current.get(ref.assetId) ?? { lastPlayError: null };
|
||
audioMetaRef.current.set(ref.assetId, {
|
||
...mm,
|
||
lastPlayError: 'Не удалось запустить.',
|
||
});
|
||
setAudioStateTick((x) => x + 1);
|
||
});
|
||
}}
|
||
>
|
||
▶
|
||
</Button>
|
||
<Button
|
||
onClick={() => {
|
||
if (!el) return;
|
||
el.pause();
|
||
}}
|
||
>
|
||
❚❚
|
||
</Button>
|
||
<Button
|
||
onClick={() => {
|
||
if (!el) return;
|
||
el.pause();
|
||
el.currentTime = 0;
|
||
setAudioStateTick((x) => x + 1);
|
||
}}
|
||
>
|
||
■
|
||
</Button>
|
||
</div>
|
||
</div>
|
||
);
|
||
})}
|
||
</div>
|
||
)}
|
||
</Surface>
|
||
</div>
|
||
</div>
|
||
);
|
||
}
|