Files
DndGamePlayer/app/renderer/control/ControlApp.tsx
T
Ivan Fontosh 089da3cae0 feat(effects): migrate battle effects to VFX
- add WebP frame-sequence assets for lightning, electric accent, fire, sunbeam, poison, rain, water, and rounded fog effects
- replace procedural lightning rendering with VFX frame playback
- align lightning impact to the click point
- remove the full-screen lightning blur/flash overlay
- add electric accent playback for lightning impact dispersion
- keep lightning scorch marks as the persistent ground trace after the action effect
- consolidate the lightning tool into a single public `Молния` action
- replace procedural fire with the VFX fire implementation
- remove the separate `Огонь 2` tool and keep the VFX fire under `Огонь`
- keep VFX fire brightness fixed at full intensity
- preserve fire as a field effect with brush placement and eraser support
- replace procedural sunbeam with the Pulse Discharge VFX implementation
- remove the separate `Луч света 2` tool and keep the VFX sunbeam under `Луч света`
- align sunbeam impact to the click point instead of the raw frame center
- remove the full-screen sunbeam flash overlay
- add Dust Burst VFX playback for poison cloud
- tint poison smoke green
- keep the skull glyph rendered above the poison smoke
- align poison smoke bottom to the click point
- remove the old procedural poison cloud implementation
- remove the separate `Яд 2` tool and keep the VFX poison under `Яд`
- replace procedural rain strokes with medium-rain VFX frame playback
- recolor rain frames to gray/light-gray so rain no longer renders black
- preserve rain as a field effect with brush placement and eraser support
- replace static water fill with generated animated water VFX frames
- render water VFX through the existing stroke mask so water keeps the painted brush shape
- preserve water as a field effect with brush placement and eraser support
- replace procedural fog texture generation with Smokey Atmosphere VFX frames
- generate rounded fog VFX frames with soft alpha falloff at the edges
- size each fog VFX stamp to the brush diameter
- remove per-stamp fog drift and rotation so fog elements stay fixed in place
- keep fog animation limited to internal frame playback
- preload VFX frame textures for smoother first use
- cache VFX textures per Pixi instance to avoid duplicate decoding
- filter expired action instances before node synchronization to stop old effects from flashing back during rapid casts
- add brush-stroke erasing for field effects using the same model as scene darkness reveal
- keep whole-instance erasing for action effects
- update effect hit tests for the consolidated VFX action effects
- update effect store pruning for renamed and consolidated action effects
- update control-panel buttons for consolidated fire, sunbeam, poison, and VFX-backed field effects
- update editor localization for removed and renamed effect tools
- add tests for field-effect brush erasing
- update eraser hit-test coverage for VFX-backed effects
- update effects store lifetime pruning tests for consolidated poison
- update control-panel tests for consolidated effect buttons
- include the field-effect eraser test in the default test script

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-07-03 21:20:43 +08:00

1673 lines
64 KiB
TypeScript
Raw Blame History

This file contains invisible Unicode characters
This file contains invisible Unicode characters that are indistinguishable to humans but may be processed differently by a computer. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
import React, { useEffect, useLayoutEffect, 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 { useEditorI18n } from '../editor/i18n/EditorI18nContext';
import { getDndApi } from '../shared/dndApi';
import { PixiEffectsOverlay } from '../shared/effects/PxiEffectsOverlay';
import { SceneDarknessOverlay } from '../shared/effects/SceneDarknessOverlay';
import { useEffectsState } from '../shared/effects/useEffectsState';
import { useSceneDarknessState } from '../shared/effects/useSceneDarknessState';
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 = 840;
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 { t } = useEditorI18n();
const tRef = useRef(t);
tRef.current = t;
const [fxState, fx] = useEffectsState();
const [sdState, sd] = useSceneDarknessState();
const [session, setSession] = useState<SessionState | null>(null);
const historyRef = useRef<GraphNodeId[]>([]);
const [history, setHistory] = useState<GraphNodeId[]>([]);
// Сюжетная линия — только UI-состояние пульта. Не меняет граф, сцены и связи проекта.
const sceneAudioElsRef = useRef<Map<string, HTMLAudioElement>>(new Map());
const sceneAudioMetaRef = useRef<Map<string, { lastPlayError: string | null }>>(new Map());
const [sceneAudioStateTick, setSceneAudioStateTick] = useState(0);
const sceneAudioLoadRunRef = useRef(0);
const campaignAudioElsRef = useRef<Map<string, HTMLAudioElement>>(new Map());
const campaignAudioMetaRef = useRef<Map<string, { lastPlayError: string | null }>>(new Map());
const [campaignAudioStateTick, setCampaignAudioStateTick] = useState(0);
const campaignAudioLoadRunRef = useRef(0);
/** Snapshot of `!el.paused` per assetId when scene music takes over; used to resume when `allowCampaignAudio` is true again. */
const campaignResumeAfterSceneRef = useRef<Map<string, boolean> | null>(null);
const allowCampaignAudioRef = useRef<boolean>(true);
const audioUnmountRef = useRef(false);
const previewHostRef = useRef<HTMLDivElement | null>(null);
const previewVideoRef = useRef<HTMLVideoElement | null>(null);
const brushRef = useRef<{
tool:
| 'fog'
| 'fire'
| 'rain'
| 'water'
| 'darkness'
| 'lightning'
| 'sunbeam'
| 'poisonCloud'
| 'freeze'
| 'exploreBrush'
| 'eraser';
startN?: { x: number; y: number };
points?: { x: number; y: number; tMs: number }[];
} | null>(null);
const [draftFxTick, setDraftFxTick] = useState(0);
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);
const previewContentRectRef = useRef(previewContentRect);
previewContentRectRef.current = previewContentRect;
const previewSizeRef = useRef(previewSize);
previewSizeRef.current = previewSize;
const brushCursorElRef = useRef<HTMLDivElement | null>(null);
const cursorPosRef = useRef<{ x: number; y: number } | null>(null);
const draftPaintRafRef = useRef(0);
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 (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 [darknessDraftLifeMs, setDarknessDraftLifeMs] = useState(820);
useEffect(() => {
void getFreezeEffectLifeMs().then(setDarknessDraftLifeMs);
}, []);
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 currentHistoryIdx = currentGraphNodeId != null ? history.lastIndexOf(currentGraphNodeId) : -1;
const currentScene =
project && session?.currentSceneId ? project.scenes[session.currentSceneId] : undefined;
const isVideoPreviewScene = currentScene?.previewAssetType === 'video';
const isDarkenScene = Boolean(currentScene?.darkenScene) && !isVideoPreviewScene;
const sceneAudioRefs = useMemo(() => currentScene?.media.audios ?? [], [currentScene]);
// Keep this memo as narrow as possible: project changes on scene switch,
// but campaign audio list/config often does not.
const campaignAudioRefs = useMemo(() => project?.campaignAudios ?? [], [project?.campaignAudios]);
const allowCampaignAudio = !sceneAudioRefs.some((a) => a.autoplay);
allowCampaignAudioRef.current = allowCampaignAudio;
const campaignAudioSpecKey = useMemo(
() =>
campaignAudioRefs.map((r) => `${r.assetId}:${r.loop ? '1' : '0'}:${r.autoplay ? '1' : '0'}`).join('|'),
[campaignAudioRefs],
);
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]);
const campaignAudios = useMemo(() => {
if (!project) return [];
return campaignAudioRefs
.map((r) => {
const a = project.assets[r.assetId];
return a?.type === 'audio' ? { ref: r, asset: a } : null;
})
.filter((x): x is { ref: (typeof campaignAudioRefs)[number]; asset: NonNullable<typeof x>['asset'] } =>
Boolean(x),
);
}, [campaignAudioRefs, project]);
useEffect(() => {
sceneAudioLoadRunRef.current += 1;
const runId = sceneAudioLoadRunRef.current;
const oldEls = new Map(sceneAudioElsRef.current);
sceneAudioElsRef.current = new Map();
sceneAudioMetaRef.current.clear();
setSceneAudioStateTick((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 (sceneAudioLoadRunRef.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;
sceneAudioMetaRef.current.set(item.assetId, { lastPlayError: null });
el.addEventListener('play', () => setSceneAudioStateTick((x) => x + 1));
el.addEventListener('pause', () => setSceneAudioStateTick((x) => x + 1));
el.addEventListener('ended', () => setSceneAudioStateTick((x) => x + 1));
el.addEventListener('canplay', () => setSceneAudioStateTick((x) => x + 1));
el.addEventListener('error', () => setSceneAudioStateTick((x) => x + 1));
loaded.push({ ref: item, el });
sceneAudioElsRef.current.set(item.assetId, el);
}
setSceneAudioStateTick((x) => x + 1);
for (const { ref, el } of loaded) {
if (sceneAudioLoadRunRef.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 = sceneAudioMetaRef.current.get(ref.assetId) ?? { lastPlayError: null };
sceneAudioMetaRef.current.set(ref.assetId, {
...m,
lastPlayError: tRef.current('control.audioAutoplayBlocked'),
});
setSceneAudioStateTick((x) => x + 1);
try {
el.volume = 1;
} catch {
// ignore
}
continue;
}
if (sceneAudioLoadRunRef.current !== runId || audioUnmountRef.current) {
try {
el.volume = 1;
} catch {
// ignore
}
continue;
}
const tIn0 = performance.now();
const tickIn = (now: number): void => {
if (sceneAudioLoadRunRef.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]);
// Campaign elements: lifecycle depends only on campaign track list/config, not scene or allowCampaignAudio
// (scene music uses allowCampaignAudioRef + separate pause/resume effect).
// Spec is encoded in campaignAudioSpecKey; campaignAudioRefs is intentionally omitted to avoid scene-switch churn.
useEffect(() => {
campaignAudioLoadRunRef.current += 1;
const runId = campaignAudioLoadRunRef.current;
const oldEls = new Map(campaignAudioElsRef.current);
campaignAudioElsRef.current = new Map();
campaignAudioMetaRef.current.clear();
setCampaignAudioStateTick((x) => x + 1);
for (const el of oldEls.values()) {
try {
el.pause();
el.volume = 1;
} catch {
// ignore
}
}
if (campaignAudioSpecKey === '') {
return;
}
void (async () => {
const loaded: { ref: (typeof campaignAudioRefs)[number]; el: HTMLAudioElement }[] = [];
for (const item of campaignAudioRefs) {
const r = await api.invoke(ipcChannels.project.assetFileUrl, { assetId: item.assetId });
if (campaignAudioLoadRunRef.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;
campaignAudioMetaRef.current.set(item.assetId, { lastPlayError: null });
el.addEventListener('play', () => setCampaignAudioStateTick((x) => x + 1));
el.addEventListener('pause', () => setCampaignAudioStateTick((x) => x + 1));
el.addEventListener('ended', () => setCampaignAudioStateTick((x) => x + 1));
el.addEventListener('canplay', () => setCampaignAudioStateTick((x) => x + 1));
el.addEventListener('error', () => setCampaignAudioStateTick((x) => x + 1));
loaded.push({ ref: item, el });
campaignAudioElsRef.current.set(item.assetId, el);
}
setCampaignAudioStateTick((x) => x + 1);
if (!allowCampaignAudioRef.current) return;
for (const { ref, el } of loaded) {
if (campaignAudioLoadRunRef.current !== runId) return;
if (!ref.autoplay) continue;
try {
await el.play();
} catch {
const m = campaignAudioMetaRef.current.get(ref.assetId) ?? { lastPlayError: null };
campaignAudioMetaRef.current.set(ref.assetId, {
...m,
lastPlayError: tRef.current('control.audioAutoplayBlocked'),
});
setCampaignAudioStateTick((x) => x + 1);
try {
el.volume = 1;
} catch {
// ignore
}
continue;
}
if (campaignAudioLoadRunRef.current !== runId || audioUnmountRef.current) {
try {
el.volume = 1;
} catch {
// ignore
}
continue;
}
const tIn0 = performance.now();
const tickIn = (now: number): void => {
if (campaignAudioLoadRunRef.current !== runId || audioUnmountRef.current) {
try {
el.volume = 1;
} catch {
// ignore
}
return;
}
const u = Math.min(1, (now - tIn0) / 550);
try {
el.volume = u;
} catch {
// ignore
}
if (u < 1) window.requestAnimationFrame(tickIn);
};
window.requestAnimationFrame(tickIn);
}
})();
// Deps: api + campaignAudioSpecKey only; list iteration uses current campaignAudioRefs (stable while spec is stable).
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [api, campaignAudioSpecKey]);
useEffect(() => {
if (allowCampaignAudio) {
const snap = campaignResumeAfterSceneRef.current;
campaignResumeAfterSceneRef.current = null;
if (snap && snap.size > 0) {
void (async () => {
for (const [assetId, wasPlaying] of snap) {
if (!wasPlaying) continue;
const el = campaignAudioElsRef.current.get(assetId) ?? null;
if (!el) continue;
try {
// If a track was created with autoplay volume ramp but never started yet,
// ensure it is audible on resume.
if (el.volume === 0) el.volume = 1;
await el.play();
} catch {
// ignore; user can press play
}
}
setCampaignAudioStateTick((x) => x + 1);
})();
}
// If we entered a scene that allows campaign audio and there was no "resume snapshot"
// (e.g. first scene had autoplay scene music so campaign autoplay was blocked),
// start campaign tracks that have autoplay enabled.
if (!snap || snap.size === 0) {
void (async () => {
for (const ref of campaignAudioRefs) {
if (!ref.autoplay) continue;
const el = campaignAudioElsRef.current.get(ref.assetId) ?? null;
if (!el) continue;
if (!el.paused) continue;
try {
el.volume = 0;
} catch {
// ignore
}
try {
await el.play();
} catch {
// ignore; user can press play
continue;
}
const tIn0 = performance.now();
const tickIn = (now: number): void => {
if (!allowCampaignAudioRef.current || audioUnmountRef.current) return;
const u = Math.min(1, (now - tIn0) / 550);
try {
el.volume = u;
} catch {
// ignore
}
if (u < 1) window.requestAnimationFrame(tickIn);
};
window.requestAnimationFrame(tickIn);
}
setCampaignAudioStateTick((x) => x + 1);
})();
}
return;
}
// Scene has its own audio: remember what was playing, then pause campaign. (keep currentTime)
const snap = new Map<string, boolean>();
for (const [assetId, el] of campaignAudioElsRef.current) {
snap.set(assetId, !el.paused);
}
campaignResumeAfterSceneRef.current = snap;
for (const el of campaignAudioElsRef.current.values()) {
try {
el.pause();
} catch {
// ignore
}
}
setCampaignAudioStateTick((x) => x + 1);
// Intentionally not depending on campaignAudioRefs: this effect is about scene-driven pausing/resuming.
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [allowCampaignAudio]);
const anyPlaying = useMemo(() => {
for (const el of sceneAudioElsRef.current.values()) {
if (!el.paused) return true;
}
for (const el of campaignAudioElsRef.current.values()) {
if (!el.paused) return true;
}
return false;
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [campaignAudioStateTick, sceneAudioStateTick]);
useEffect(() => {
if (!anyPlaying) return;
let raf = 0;
const tick = () => {
setSceneAudioStateTick((x) => x + 1);
setCampaignAudioStateTick((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(group: 'scene' | 'campaign', assetId: string): { label: string; detail?: string } {
const el =
group === 'scene'
? (sceneAudioElsRef.current.get(assetId) ?? null)
: (campaignAudioElsRef.current.get(assetId) ?? null);
if (!el) return { label: t('control.audioNoUrl'), detail: t('control.audioNoUrlDetail') };
const meta =
group === 'scene'
? (sceneAudioMetaRef.current.get(assetId) ?? { lastPlayError: null })
: (campaignAudioMetaRef.current.get(assetId) ?? { lastPlayError: null });
if (meta.lastPlayError) return { label: t('control.audioBlocked'), detail: meta.lastPlayError };
if (el.error)
return {
label: t('control.audioError'),
detail: t('control.audioMediaError', { code: String(el.error.code) }),
};
if (el.readyState < 2) return { label: t('control.audioLoading') };
if (!el.paused) return { label: t('control.audioPlaying') };
if (el.currentTime > 0) return { label: t('control.audioPaused') };
return { label: t('control.audioStopped') };
}
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 };
const toolRef = useRef(tool);
toolRef.current = tool;
function layoutBrushCursor(): void {
const el = brushCursorElRef.current;
const p = cursorPosRef.current;
const cr = previewContentRectRef.current;
const ps = previewSizeRef.current;
const t = toolRef.current;
if (!el) return;
if (!p) {
el.style.visibility = 'hidden';
return;
}
el.style.visibility = 'visible';
const ox = cr ? cr.x : 0;
const oy = cr ? cr.y : 0;
const cw = cr ? cr.w : ps.w;
const ch = cr ? cr.h : ps.h;
const minDim = Math.min(cw, ch);
const size = Math.max(2, t.radiusN * minDim * 2);
el.style.left = `${String(ox + p.x * cw)}px`;
el.style.top = `${String(oy + p.y * ch)}px`;
el.style.width = `${String(size)}px`;
el.style.height = `${String(size)}px`;
}
function scheduleDraftRepaint(): void {
if (draftPaintRafRef.current !== 0) return;
draftPaintRafRef.current = requestAnimationFrame(() => {
draftPaintRafRef.current = 0;
setDraftFxTick((x) => x + 1);
const b = brushRef.current;
if (b?.tool === 'exploreBrush' && b.points) {
void sd.dispatch({
kind: 'draft.set',
draft: { points: b.points, radiusN: toolRef.current.radiusN },
});
}
});
}
useLayoutEffect(() => {
layoutBrushCursor();
}, [tool.radiusN, previewContentRect, previewSize.w, previewSize.h]);
useEffect(() => {
return () => {
if (draftPaintRafRef.current !== 0) {
cancelAnimationFrame(draftPaintRafRef.current);
}
};
}, []);
function toNPoint(e: React.PointerEvent): { x: number; y: number } | null {
const host = previewHostRef.current;
if (!host) return null;
const r = host.getBoundingClientRect();
const cr = previewContentRectRef.current;
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)) };
}
function dispatchFieldEraserPoints(points: { x: number; y: number; tMs: number }[]): void {
if (points.length === 0) return;
void fx.dispatch({ kind: 'field.erase', points, radiusN: toolRef.current.radiusN });
}
function tryEraseActionEffect(p: { x: number; y: number }): void {
const id = pickEraseTargetId(fxState?.instances ?? [], p, toolRef.current.radiusN);
if (id) void fx.dispatch({ kind: 'instance.remove', id });
}
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;
if (b.tool === 'eraser') {
brushRef.current = null;
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: 1,
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 === 'exploreBrush' && b.points && b.points.length > 0) {
await sd.dispatch({
kind: 'stroke.add',
stroke: {
id: `sd_${String(createdAtMs)}_${String(seed)}`,
seed,
createdAtMs,
points: b.points,
radiusN: tool.radiusN,
},
});
}
if (b.tool === 'darkness' && 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 darknessLifeMs = await getFreezeEffectLifeMs();
await fx.dispatch({
kind: 'instance.add',
instance: {
id: `dk_${String(createdAtMs)}_${String(seed)}`,
type: 'darkness',
seed,
createdAtMs,
at,
intensity: Math.max(0.8, Math.min(1.25, tool.intensity * 1.15)),
lifetimeMs: darknessLifeMs,
},
});
await fx.dispatch({
kind: 'instance.add',
instance: {
id: `sh_${String(createdAtMs)}_${String(seed)}`,
type: 'shadow',
seed: seed ^ 0x0d4a0001,
createdAtMs,
at,
radiusN: Math.max(0.03, tool.radiusN * 0.9),
opacity: 1,
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.014, tool.radiusN * 1.15),
intensity: Math.max(1, Math.min(1.4, tool.intensity * 1.45)),
lifetimeMs: LIGHTNING_EFFECT_MS,
},
});
await fx.dispatch({
kind: 'instance.add',
instance: {
id: `sc_${String(createdAtMs)}_${String(seed)}`,
type: 'scorch',
seed: seed ^ 0x7a7a7a,
createdAtMs,
at: end,
radiusN: Math.max(0.04, tool.radiusN * 0.82),
opacity: 0.96,
lifetimeMs: 90_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: 1,
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 === 'darkness' && b.points && b.points.length > 0) {
const last = b.points[b.points.length - 1];
if (last === undefined) return null;
return {
id: '__draft__',
type: 'darkness' 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: darknessDraftLifeMs,
};
}
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.014, tool.radiusN * 1.15),
intensity: Math.max(1, Math.min(1.4, tool.intensity * 1.45)),
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,
darknessDraftLifeMs,
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}>{t('control.remoteTitle')}</div>
<div className={styles.spacer12} />
{!isVideoPreviewScene ? (
<>
<div className={styles.sectionLabel}>{t('control.effects')}</div>
<div className={styles.spacer8} />
<div className={styles.effectsStack}>
<div className={styles.effectsGroup}>
<div className={styles.subsectionLabel}>{t('control.tools')}</div>
<div className={styles.iconRow}>
<Button
variant={tool.tool === 'eraser' ? 'primary' : 'ghost'}
iconOnly
title={t('control.eraser')}
ariaLabel={t('control.eraser')}
onClick={() => void fx.dispatch({ kind: 'tool.set', tool: { ...tool, tool: 'eraser' } })}
>
<span className={styles.iconGlyph}>🧹</span>
</Button>
<Button
variant="ghost"
iconOnly
title={t('control.clearEffects')}
ariaLabel={t('control.clearEffects')}
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}>{t('control.fieldEffects')}</div>
<div className={styles.iconRow}>
<Button
variant={tool.tool === 'fog' ? 'primary' : 'ghost'}
iconOnly
title={t('control.fog')}
ariaLabel={t('control.fog')}
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={t('control.rain')}
ariaLabel={t('control.rain')}
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={t('control.fire')}
ariaLabel={t('control.fire')}
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={t('control.water')}
ariaLabel={t('control.water')}
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}>{t('control.actionEffects')}</div>
<div className={styles.iconRow}>
<Button
variant={tool.tool === 'lightning' ? 'primary' : 'ghost'}
iconOnly
title={t('control.lightning')}
ariaLabel={t('control.lightning')}
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={t('control.sunbeam')}
ariaLabel={t('control.sunbeam')}
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={t('control.freeze')}
ariaLabel={t('control.freeze')}
onClick={() => void fx.dispatch({ kind: 'tool.set', tool: { ...tool, tool: 'freeze' } })}
>
<span className={styles.iconGlyph}></span>
</Button>
<Button
variant={tool.tool === 'darkness' ? 'primary' : 'ghost'}
iconOnly
title={t('control.darkness')}
ariaLabel={t('control.darkness')}
onClick={() =>
void fx.dispatch({ kind: 'tool.set', tool: { ...tool, tool: 'darkness' } })
}
>
<span className={styles.iconGlyph}>🌑</span>
</Button>
<Button
variant={tool.tool === 'poisonCloud' ? 'primary' : 'ghost'}
iconOnly
title={t('control.poisonCloud')}
ariaLabel={t('control.poisonCloud')}
onClick={() =>
void fx.dispatch({ kind: 'tool.set', tool: { ...tool, tool: 'poisonCloud' } })
}
>
<span className={styles.iconGlyph}></span>
</Button>
</div>
</div>
{isDarkenScene ? (
<div className={styles.effectsGroup}>
<div className={styles.subsectionLabel}>{t('control.darknessControl')}</div>
<div className={styles.iconRow}>
<Button
variant={tool.tool === 'exploreBrush' ? 'primary' : 'ghost'}
iconOnly
title={t('control.explorerBrush')}
ariaLabel={t('control.explorerBrush')}
onClick={() =>
void fx.dispatch({ kind: 'tool.set', tool: { ...tool, tool: 'exploreBrush' } })
}
>
<span className={styles.iconGlyph}>🔦</span>
</Button>
</div>
</div>
) : null}
<div className={styles.radiusRow}>
<div className={styles.radiusLabel}>{t('control.brushRadius')}</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={t('control.brushRadius')}
/>
<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}>{t('control.storyLine')}</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 = idx === currentHistoryIdx;
return (
<button
type="button"
key={`${gnId}_${String(idx)}`}
disabled={!project || isCurrent}
className={[styles.historyBtn, isCurrent ? styles.historyBtnCurrent : '']
.filter(Boolean)
.join(' ')}
title={project && !isCurrent ? t('control.gotoScene') : undefined}
onClick={() => {
if (!project) return;
if (isCurrent) return;
void api.invoke(ipcChannels.project.setCurrentGraphNode, { graphNodeId: gnId });
}}
>
{isCurrent ? (
<div className={styles.historyBadge}>{t('control.currentSceneBadge')}</div>
) : (
<div className={styles.historyMuted}>{t('control.passed')}</div>
)}
<div className={styles.historyTitle}>{s?.title ?? (gn ? String(gn.sceneId) : gnId)}</div>
</button>
);
})}
{history.length === 0 ? (
<div className={styles.emptyStory}>{t('control.noActiveScene')}</div>
) : null}
</div>
</div>
</Surface>
<div className={styles.rightStack}>
<Surface className={styles.surfacePad}>
<div className={styles.previewHeader}>
<div className={styles.previewTitle}>{t('control.screenPreview')}</div>
<div className={styles.previewActions}>
<Button onClick={() => void api.invoke(ipcChannels.windows.closeMultiWindow, {})}>
{t('control.stopPresentation')}
</Button>
</div>
</div>
<div className={styles.spacer10} />
{isVideoPreviewScene ? <div className={styles.videoHint}>{t('control.videoBrushHint')}</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
}
/>
{previewContentRect ? (
<SceneDarknessOverlay state={sdState} overlayAlpha={0.5} viewport={previewContentRect} />
) : null}
<div
ref={brushCursorElRef}
className={styles.brushCursor}
style={{ visibility: 'hidden' }}
aria-hidden
/>
<div
className={styles.brushLayer}
onPointerEnter={(e) => {
const p = toNPoint(e);
if (!p) return;
cursorPosRef.current = p;
layoutBrushCursor();
}}
onPointerLeave={() => {
cursorPosRef.current = null;
layoutBrushCursor();
}}
onPointerDown={(e) => {
const p = toNPoint(e);
if (!p) return;
cursorPosRef.current = p;
layoutBrushCursor();
(e.currentTarget as HTMLDivElement).setPointerCapture(e.pointerId);
if (tool.tool === 'eraser') {
const pt = { x: p.x, y: p.y, tMs: Date.now() };
brushRef.current = { tool: 'eraser', startN: p, points: [pt] };
dispatchFieldEraserPoints([pt]);
tryEraseActionEffect(p);
return;
}
brushRef.current = {
tool: tool.tool,
startN: p,
points: [{ x: p.x, y: p.y, tMs: Date.now() }],
};
if (tool.tool === 'exploreBrush') {
void sd.dispatch({
kind: 'draft.set',
draft: {
points: [{ x: p.x, y: p.y, tMs: Date.now() }],
radiusN: tool.radiusN,
},
});
}
setDraftFxTick((x) => x + 1);
}}
onPointerMove={(e) => {
const p = toNPoint(e);
if (!p) return;
cursorPosRef.current = p;
layoutBrushCursor();
if (tool.tool === 'eraser' && (e.buttons & 1) !== 0) {
const b = brushRef.current;
const pt = { x: p.x, y: p.y, tMs: Date.now() };
if (b?.tool !== 'eraser' || !b.points) {
brushRef.current = { tool: 'eraser', startN: p, points: [pt] };
dispatchFieldEraserPoints([pt]);
} else {
const last = b.points[b.points.length - 1];
if (last) {
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) {
b.points.push(pt);
dispatchFieldEraserPoints([last, pt]);
}
}
}
tryEraseActionEffect(p);
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() });
scheduleDraftRepaint();
}}
onPointerUp={() => {
void commitStroke();
}}
onPointerCancel={() => {
if (brushRef.current?.tool === 'exploreBrush') {
void sd.dispatch({ kind: 'draft.set', draft: null });
}
brushRef.current = null;
setDraftFxTick((x) => x + 1);
}}
/>
</>
) : null}
</div>
</Surface>
<Surface className={styles.surfacePad}>
<div className={styles.branchTitle}>{t('control.branches')}</div>
<div className={styles.branchGrid}>
{nextScenes.map((o, i) => (
<div key={o.graphNodeId} className={styles.branchCard}>
<div className={styles.branchCardHeader}>
<div className={styles.branchOption}>{t('control.option', { n: String(i + 1) })}</div>
</div>
<div className={styles.branchName}>{o.scene.title || t('control.unnamed')}</div>
<Button
variant="primary"
onClick={() =>
void api.invoke(ipcChannels.project.setCurrentGraphNode, { graphNodeId: o.graphNodeId })
}
>
{t('control.switchScene')}
</Button>
</div>
))}
{nextScenes.length === 0 ? (
<div className={styles.branchEmpty}>
<div>{t('control.noBranches')}</div>
<Button
variant="primary"
disabled={!session?.project?.currentGraphNodeId}
onClick={() => void api.invoke(ipcChannels.windows.closeMultiWindow, {})}
>
{t('control.endPresentation')}
</Button>
</div>
) : null}
</div>
</Surface>
<Surface className={styles.surfacePad}>
<div className={styles.musicHeader}>
<div className={styles.previewTitle}>{t('control.music')}</div>
</div>
<div className={styles.spacer10} />
<div className={styles.sectionLabel}>{t('control.sceneMusic')}</div>
<div className={styles.spacer10} />
{sceneAudios.length === 0 ? (
<div className={styles.musicEmpty}>{t('control.noSceneAudio')}</div>
) : null}
{sceneAudios.length > 0 ? (
<div className={styles.audioList}>
{sceneAudios.map(({ ref, asset }) => {
const el = sceneAudioElsRef.current.get(ref.assetId) ?? null;
const st = audioStatus('scene', 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 ? t('control.modeAuto') : t('control.modeManual')}</div>
<div>{ref.loop ? t('control.loop') : t('control.once')}</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);
setSceneAudioStateTick((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));
setSceneAudioStateTick((x) => x + 1);
}}
className={[
styles.audioScrub,
dur > 0 ? styles.audioScrubPointer : styles.audioScrubDefault,
].join(' ')}
title={dur > 0 ? t('control.scrubSeek') : t('control.durationUnknown')}
>
<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 = sceneAudioMetaRef.current.get(ref.assetId) ?? { lastPlayError: null };
sceneAudioMetaRef.current.set(ref.assetId, { ...m, lastPlayError: null });
void el.play().catch(() => {
const mm =
sceneAudioMetaRef.current.get(ref.assetId) ??
({ lastPlayError: null } as const);
sceneAudioMetaRef.current.set(ref.assetId, {
...mm,
lastPlayError: t('control.playFailed'),
});
setSceneAudioStateTick((x) => x + 1);
});
}}
>
</Button>
<Button
onClick={() => {
if (!el) return;
el.pause();
}}
>
</Button>
<Button
onClick={() => {
if (!el) return;
el.pause();
el.currentTime = 0;
setSceneAudioStateTick((x) => x + 1);
}}
>
</Button>
</div>
</div>
);
})}
</div>
) : null}
<div className={styles.spacer12} />
<div className={styles.sectionLabel}>{t('control.gameMusic')}</div>
<div className={styles.spacer10} />
{campaignAudios.length === 0 ? (
<div className={styles.musicEmpty}>{t('control.noGameAudio')}</div>
) : (
<div className={styles.audioList}>
{campaignAudios.map(({ ref, asset }) => {
const el = campaignAudioElsRef.current.get(ref.assetId) ?? null;
const st = audioStatus('campaign', 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 ? t('control.modeAuto') : t('control.modeManual')}</div>
<div>{ref.loop ? t('control.loop') : t('control.once')}</div>
<div title={st.detail}>{st.label}</div>
{!allowCampaignAudio ? (
<div title={t('control.pauseSceneMusicTitle')}>{t('control.pauseSceneMusic')}</div>
) : null}
</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);
setCampaignAudioStateTick((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));
setCampaignAudioStateTick((x) => x + 1);
}}
className={[
styles.audioScrub,
dur > 0 ? styles.audioScrubPointer : styles.audioScrubDefault,
].join(' ')}
title={dur > 0 ? t('control.scrubSeek') : t('control.durationUnknown')}
>
<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"
title={!allowCampaignAudio ? t('control.pauseCampaignTitle') : undefined}
onClick={() => {
if (!el) return;
const m = campaignAudioMetaRef.current.get(ref.assetId) ?? { lastPlayError: null };
campaignAudioMetaRef.current.set(ref.assetId, { ...m, lastPlayError: null });
// If this track was created for autoplay but autoplay was blocked (e.g. scene music),
// it might still be at volume 0. Ensure manual play is audible.
try {
if (el.volume === 0) el.volume = 1;
} catch {
// ignore
}
void el.play().catch(() => {
const mm =
campaignAudioMetaRef.current.get(ref.assetId) ??
({ lastPlayError: null } as const);
campaignAudioMetaRef.current.set(ref.assetId, {
...mm,
lastPlayError: t('control.playFailed'),
});
setCampaignAudioStateTick((x) => x + 1);
});
}}
>
</Button>
<Button
onClick={() => {
if (!el) return;
el.pause();
}}
>
</Button>
<Button
onClick={() => {
if (!el) return;
el.pause();
el.currentTime = 0;
setCampaignAudioStateTick((x) => x + 1);
}}
>
</Button>
</div>
</div>
);
})}
</div>
)}
</Surface>
</div>
</div>
);
}