Files
DndGamePlayer/app/renderer/control/ControlApp.tsx
T
Ivan Fontosh eb127c11c2 feat(scene): shared zoom/pan for control and presentation
Keep effects pinned to map coordinates when the viewport changes; materials/NPC overlays stay screen-fixed.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-07-23 14:08:19 +08:00

2141 lines
83 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 {
isNodeInMainStoryline,
isNodeInSideStoryline,
listSideStoryStarts,
} from '../../shared/graph/sceneGraphLineage';
import type { GraphNodeId, Scene, SceneId, SceneViewCamera } from '../../shared/types';
import {
DEFAULT_SCENE_VIEW_CAMERA,
sceneViewPanBy,
sceneViewZoomAt,
} from '../../shared/types/sceneView';
import { useEditorI18n } from '../editor/i18n/EditorI18nContext';
import { isSceneDescriptionEmpty } from '../editor/sceneDescriptionHtml';
import { getDndApi } from '../shared/dndApi';
import { RotatedImage } from '../shared/RotatedImage';
import {
PixiEffectsOverlay,
type PixiEffectsOverlayHandle,
} from '../shared/effects/PxiEffectsOverlay';
import type { EffectInstance } from '../../shared/types/effects';
import { SceneDarknessOverlay } from '../shared/effects/SceneDarknessOverlay';
import { useEffectsState } from '../shared/effects/useEffectsState';
import { useSceneDarknessState } from '../shared/effects/useSceneDarknessState';
import { DEFAULT_MATERIALS_OVERLAY_LAYOUT, DEFAULT_NPCS_OVERLAY_LAYOUT } from '../../shared/types';
import { MaterialOverlay } from '../shared/materials/MaterialOverlay';
import { useMaterialsOverlayState } from '../shared/materials/useMaterialsOverlayState';
import { NpcsSceneOverlay } from '../shared/npcs/NpcsSceneOverlay';
import { useNpcsOverlayState } from '../shared/npcs/useNpcsOverlayState';
import { SceneOverlayHost } from '../shared/sceneOverlay/SceneOverlayHost';
import { useSceneViewState } from '../shared/sceneView/useSceneViewState';
import { Button } from '../shared/ui/controls';
import { Surface } from '../shared/ui/Surface';
import { useAssetUrl } from '../shared/useAssetImageUrl';
import styles from './ControlApp.module.css';
import { ControlAudioCard } from './ControlAudioCard';
import { ControlScenePreview } from './ControlScenePreview';
import { clampEffectsSfxGain, getEffectsSfxGain, setEffectsSfxGain } from './effectsSfxGain';
import { setFireAmbientActive, syncFireAmbientVolume } from './fireAmbientSfx';
import { setRainAmbientActive, syncRainAmbientVolume } from './rainAmbientSfx';
import { getFreezeEffectLifeMs, playFreezeEffectSound } from './freezeSfx';
import { getPoisonCloudEffectLifeMs, playPoisonCloudEffectSound } from './poisonCloudSfx';
import { getSunbeamEffectLifeMs, playSunbeamEffectSound } from './sunbeamSfx';
/** Длительность молнии: быстрый удар + акцент в точке попадания. */
const LIGHTNING_EFFECT_MS = 840;
function clampAudioGain(v: number): number {
if (!Number.isFinite(v)) return 1;
return Math.max(0, Math.min(1, v));
}
function readAudioGain(gains: Map<string, number>, assetId: string): number {
return gains.get(assetId) ?? 1;
}
/** Применяет пользовательскую громкость; `factor` — для fade in/out (0…1). */
function applyAudioGain(
el: HTMLAudioElement,
gains: Map<string, number>,
assetId: string,
factor = 1,
): void {
el.volume = clampAudioGain(readAudioGain(gains, assetId) * factor);
}
/** Файл из `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 = Math.max(0, Math.min(1, 0.88 * getEffectsSfxGain()));
void el.play().catch(() => undefined);
} catch {
/* ignore */
}
}
function SideStoryTile({ scene, title, onClick }: { scene: Scene; title: string; onClick: () => void }) {
const thumbUrl = useAssetUrl(scene.previewThumbAssetId ?? scene.previewAssetId);
const previewUrl = useAssetUrl(scene.previewAssetId);
const imageUrl = thumbUrl ?? (scene.previewAssetType === 'image' ? previewUrl : null);
return (
<button type="button" className={styles.sideStoryTile} onClick={onClick}>
<div className={styles.sideStoryPreview}>
{imageUrl ? (
<RotatedImage
url={imageUrl}
rotationDeg={scene.previewRotationDeg}
mode="cover"
loading="lazy"
decoding="async"
style={{ width: '100%', height: '100%' }}
/>
) : previewUrl && scene.previewAssetType === 'video' ? (
<video src={previewUrl} muted playsInline preload="metadata" className={styles.sideStoryVideo} />
) : (
<div className={styles.sideStoryPlaceholder} aria-hidden />
)}
</div>
<div className={styles.sideStoryTitle}>{title}</div>
</button>
);
}
export function ControlApp() {
const api = getDndApi();
const { t } = useEditorI18n();
const tRef = useRef(t);
tRef.current = t;
const [fxState, fx] = useEffectsState();
const [effectsSfxGainUi, setEffectsSfxGainUi] = useState(() => getEffectsSfxGain());
const [sdState, sd] = useSceneDarknessState();
const [sceneView, sceneViewApi] = useSceneViewState();
const [sceneViewDraft, setSceneViewDraft] = useState<SceneViewCamera | null>(null);
const [materialsOverlay, materialsApi] = useMaterialsOverlayState();
const [npcsOverlay, npcsApi] = useNpcsOverlayState();
const [session, setSession] = useState<SessionState | null>(null);
const historyRef = useRef<GraphNodeId[]>([]);
const [history, setHistory] = useState<GraphNodeId[]>([]);
/** Сцена основного сюжета, с которой ушли в побочную линию (только текущая сессия). */
const mainStoryReturnRef = useRef<GraphNodeId | null>(null);
const [mainStoryReturnGraphNodeId, setMainStoryReturnGraphNodeId] = useState<GraphNodeId | null>(null);
// Сюжетная линия — только UI-состояние пульта. Не меняет граф, сцены и связи проекта.
const sceneAudioElsRef = useRef<Map<string, HTMLAudioElement>>(new Map());
const sceneAudioMetaRef = useRef<Map<string, { lastPlayError: string | null }>>(new Map());
/** Пользовательская громкость 0…1 по assetId (сохраняется между сменами сцен для того же трека). */
const sceneAudioGainRef = useRef<Map<string, number>>(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 campaignAudioGainRef = useRef<Map<string, number>>(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 previewFrameRef = useRef<HTMLDivElement | null>(null);
const previewVideoRef = useRef<HTMLVideoElement | null>(null);
const sceneViewRef = useRef<SceneViewCamera>(DEFAULT_SCENE_VIEW_CAMERA);
const scenePanRef = useRef<{ lastX: number; lastY: number; pointerId: number } | null>(null);
const spaceDownRef = useRef(false);
const sceneViewPublishRafRef = useRef(0);
const pendingSceneViewRef = useRef<SceneViewCamera | 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 [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);
const effectsOverlayRef = useRef<PixiEffectsOverlayHandle | null>(null);
const draftMetaRef = useRef<{ createdAtMs: number; seed: 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) {
mainStoryReturnRef.current = null;
setMainStoryReturnGraphNodeId(null);
return;
}
const arr = historyRef.current;
if (arr[arr.length - 1] !== cur) {
historyRef.current = [...arr, cur];
setHistory(historyRef.current);
}
});
}, [api]);
useEffect(() => {
return api.on(ipcChannels.windows.multiWindowStateChanged, ({ open }) => {
if (!open) {
mainStoryReturnRef.current = null;
setMainStoryReturnGraphNodeId(null);
}
});
}, [api]);
useEffect(() => {
audioUnmountRef.current = false;
return () => {
audioUnmountRef.current = true;
};
}, []);
const [freezeDraftLifeMs, setFreezeDraftLifeMs] = useState(820);
const freezeDraftLifeMsRef = useRef(freezeDraftLifeMs);
freezeDraftLifeMsRef.current = freezeDraftLifeMs;
useEffect(() => {
void getFreezeEffectLifeMs().then(setFreezeDraftLifeMs);
}, []);
const [darknessDraftLifeMs, setDarknessDraftLifeMs] = useState(820);
const darknessDraftLifeMsRef = useRef(darknessDraftLifeMs);
darknessDraftLifeMsRef.current = darknessDraftLifeMs;
useEffect(() => {
void getFreezeEffectLifeMs().then(setDarknessDraftLifeMs);
}, []);
const [sunbeamDraftLifeMs, setSunbeamDraftLifeMs] = useState(600);
const sunbeamDraftLifeMsRef = useRef(sunbeamDraftLifeMs);
sunbeamDraftLifeMsRef.current = sunbeamDraftLifeMs;
useEffect(() => {
void getSunbeamEffectLifeMs().then(setSunbeamDraftLifeMs);
}, []);
const [poisonDraftLifeMs, setPoisonDraftLifeMs] = useState(1600);
const poisonDraftLifeMsRef = useRef(poisonDraftLifeMs);
poisonDraftLifeMsRef.current = poisonDraftLifeMs;
useEffect(() => {
void getPoisonCloudEffectLifeMs().then(setPoisonDraftLifeMs);
}, []);
const hasFireOnScene = useMemo(
() => Boolean(fxState?.instances.some((i) => i.type === 'fire' && i.points.length > 0)),
[fxState?.instances],
);
const hasRainOnScene = useMemo(
() => Boolean(fxState?.instances.some((i) => i.type === 'rain' && i.points.length > 0)),
[fxState?.instances],
);
useEffect(() => {
setFireAmbientActive(hasFireOnScene);
return () => {
setFireAmbientActive(false);
};
}, [hasFireOnScene]);
useEffect(() => {
setRainAmbientActive(hasRainOnScene);
return () => {
setRainAmbientActive(false);
};
}, [hasRainOnScene]);
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 sceneDescription = currentScene?.description ?? '';
const hasSceneDescription = !isSceneDescriptionEmpty(sceneDescription);
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 [id, el] of oldEls) {
try {
el.pause();
el.currentTime = 0;
applyAudioGain(el, sceneAudioGainRef.current, id);
} 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';
if (item.autoplay) el.volume = 0;
else applyAudioGain(el, sceneAudioGainRef.current, item.assetId);
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;
applyAudioGain(el, sceneAudioGainRef.current, ref.assetId);
} 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 {
applyAudioGain(el, sceneAudioGainRef.current, ref.assetId);
} catch {
// ignore
}
continue;
}
if (sceneAudioLoadRunRef.current !== runId || audioUnmountRef.current) {
try {
applyAudioGain(el, sceneAudioGainRef.current, ref.assetId);
} catch {
// ignore
}
continue;
}
const tIn0 = performance.now();
const tickIn = (now: number): void => {
if (sceneAudioLoadRunRef.current !== runId || audioUnmountRef.current) {
try {
applyAudioGain(el, sceneAudioGainRef.current, ref.assetId);
} catch {
// ignore
}
return;
}
const u = Math.min(1, (now - tIn0) / FADE_IN_MS);
try {
applyAudioGain(el, sceneAudioGainRef.current, ref.assetId, 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 [id, el] of oldEls) {
try {
el.pause();
applyAudioGain(el, campaignAudioGainRef.current, id);
} 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';
if (item.autoplay) el.volume = 0;
else applyAudioGain(el, campaignAudioGainRef.current, item.assetId);
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 {
applyAudioGain(el, campaignAudioGainRef.current, ref.assetId);
} catch {
// ignore
}
continue;
}
if (campaignAudioLoadRunRef.current !== runId || audioUnmountRef.current) {
try {
applyAudioGain(el, campaignAudioGainRef.current, ref.assetId);
} catch {
// ignore
}
continue;
}
const tIn0 = performance.now();
const tickIn = (now: number): void => {
if (campaignAudioLoadRunRef.current !== runId || audioUnmountRef.current) {
try {
applyAudioGain(el, campaignAudioGainRef.current, ref.assetId);
} catch {
// ignore
}
return;
}
const u = Math.min(1, (now - tIn0) / 550);
try {
applyAudioGain(el, campaignAudioGainRef.current, ref.assetId, 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) applyAudioGain(el, campaignAudioGainRef.current, assetId);
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 {
applyAudioGain(el, campaignAudioGainRef.current, ref.assetId, 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]);
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();
}, []);
const activeSceneViewCamera: SceneViewCamera = sceneViewDraft ??
(sceneView
? { scale: sceneView.scale, ox: sceneView.ox, oy: sceneView.oy }
: DEFAULT_SCENE_VIEW_CAMERA);
sceneViewRef.current = activeSceneViewCamera;
useEffect(() => {
if (scenePanRef.current) return;
setSceneViewDraft(null);
}, [sceneView?.revision]);
function publishSceneViewCamera(camera: SceneViewCamera): void {
setSceneViewDraft(camera);
sceneViewRef.current = camera;
pendingSceneViewRef.current = camera;
if (sceneViewPublishRafRef.current !== 0) return;
sceneViewPublishRafRef.current = requestAnimationFrame(() => {
sceneViewPublishRafRef.current = 0;
const next = pendingSceneViewRef.current;
if (!next) return;
void sceneViewApi.dispatch({ kind: 'set', camera: next });
});
}
const publishSceneViewCameraRef = useRef(publishSceneViewCamera);
publishSceneViewCameraRef.current = publishSceneViewCamera;
useEffect(() => {
const onKeyDown = (e: KeyboardEvent) => {
if (e.code === 'Space') spaceDownRef.current = true;
};
const onKeyUp = (e: KeyboardEvent) => {
if (e.code === 'Space') spaceDownRef.current = false;
};
window.addEventListener('keydown', onKeyDown);
window.addEventListener('keyup', onKeyUp);
return () => {
window.removeEventListener('keydown', onKeyDown);
window.removeEventListener('keyup', onKeyUp);
};
}, []);
useEffect(() => {
const frame = previewFrameRef.current;
if (!frame || isVideoPreviewScene) return;
const onWheel = (e: WheelEvent) => {
e.preventDefault();
const host = previewHostRef.current;
const cr = previewContentRectRef.current;
if (!host || !cr) return;
const r = host.getBoundingClientRect();
const cam = sceneViewRef.current;
const containW = cr.w / Math.max(1e-6, cam.scale);
const containH = cr.h / Math.max(1e-6, cam.scale);
const factor = e.deltaY < 0 ? 1.12 : 1 / 1.12;
const next = sceneViewZoomAt(cam, {
hostW: r.width,
hostH: r.height,
containW,
containH,
hostX: e.clientX - r.left,
hostY: e.clientY - r.top,
factor,
});
publishSceneViewCameraRef.current(next);
};
frame.addEventListener('wheel', onWheel, { passive: false });
return () => frame.removeEventListener('wheel', onWheel);
}, [isVideoPreviewScene]);
useEffect(() => {
return () => {
if (sceneViewPublishRafRef.current !== 0) {
cancelAnimationFrame(sceneViewPublishRafRef.current);
sceneViewPublishRafRef.current = 0;
}
};
}, []);
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 isInSideStoryline = useMemo(() => {
if (!project || !currentGraphNodeId) return false;
return isNodeInSideStoryline(project.sceneGraphNodes, project.sceneGraphEdges, currentGraphNodeId);
}, [currentGraphNodeId, project]);
const sideStoryLines = useMemo(() => {
if (!project) return [];
return listSideStoryStarts(project.sceneGraphNodes).map((gn) => {
const scene = project.scenes[gn.sceneId];
return {
graphNodeId: gn.id,
title: gn.sideStoryLineTitle.trim() || scene?.title || t('control.unnamed'),
scene,
};
});
}, [project, t]);
const returnSceneTitle = useMemo(() => {
if (!project || !mainStoryReturnGraphNodeId) return '';
const gn = project.sceneGraphNodes.find((n) => n.id === mainStoryReturnGraphNodeId);
if (!gn) return '';
return project.scenes[gn.sceneId]?.title || t('control.unnamed');
}, [mainStoryReturnGraphNodeId, project, t]);
const enterSideStoryline = (startGraphNodeId: GraphNodeId) => {
if (!project) return;
if (
currentGraphNodeId &&
mainStoryReturnRef.current === null &&
isNodeInMainStoryline(project.sceneGraphNodes, project.sceneGraphEdges, currentGraphNodeId)
) {
mainStoryReturnRef.current = currentGraphNodeId;
setMainStoryReturnGraphNodeId(currentGraphNodeId);
}
void api.invoke(ipcChannels.project.setCurrentGraphNode, { graphNodeId: startGraphNodeId });
};
const returnToMainStoryline = () => {
const ret = mainStoryReturnRef.current;
if (!ret) return;
mainStoryReturnRef.current = null;
setMainStoryReturnGraphNodeId(null);
void api.invoke(ipcChannels.project.setCurrentGraphNode, { graphNodeId: ret });
};
const showReturnToMain = isInSideStoryline && mainStoryReturnGraphNodeId !== null;
const branchOptionOffset = showReturnToMain ? 1 : 0;
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 buildDraftEffectInstance(): EffectInstance | null {
const b = brushRef.current;
if (!b) return null;
const meta = draftMetaRef.current ?? { createdAtMs: Date.now(), seed: 12345 };
const { createdAtMs, seed } = meta;
const t = toolRef.current;
if (b.tool === 'fog' && b.points && b.points.length > 0) {
return {
id: '__draft__',
type: 'fog',
seed,
createdAtMs,
points: b.points,
radiusN: t.radiusN,
opacity: Math.max(0.05, Math.min(0.6, t.intensity * 0.7)),
lifetimeMs: null,
};
}
if (b.tool === 'fire' && b.points && b.points.length > 0) {
return {
id: '__draft__',
type: 'fire',
seed,
createdAtMs,
points: b.points,
radiusN: t.radiusN,
opacity: 1,
lifetimeMs: null,
};
}
if (b.tool === 'rain' && b.points && b.points.length > 0) {
return {
id: '__draft__',
type: 'rain',
seed,
createdAtMs,
points: b.points,
radiusN: t.radiusN,
opacity: Math.max(0.08, Math.min(0.65, t.intensity * 0.85)),
lifetimeMs: null,
};
}
if (b.tool === 'water' && b.points && b.points.length > 0) {
return {
id: '__draft__',
type: 'water',
seed,
createdAtMs,
points: b.points,
radiusN: t.radiusN,
opacity: Math.max(0.06, Math.min(0.55, t.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',
seed,
createdAtMs,
at: { x: last.x, y: last.y },
intensity: Math.max(0.8, Math.min(1.25, t.intensity * 1.15)),
lifetimeMs: darknessDraftLifeMsRef.current,
};
}
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',
seed,
createdAtMs,
start: { x: last.x, y: 0 },
end: { x: last.x, y: last.y },
widthN: Math.max(0.014, t.radiusN * 1.15),
intensity: Math.max(1, Math.min(1.4, t.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',
seed,
createdAtMs,
start: { x: last.x, y: 0 },
end: { x: last.x, y: last.y },
widthN: Math.max(0.012, t.radiusN * 0.95),
intensity: Math.max(0.95, Math.min(1.25, t.intensity * 1.4)),
lifetimeMs: sunbeamDraftLifeMsRef.current,
};
}
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',
seed,
createdAtMs,
at: { x: last.x, y: last.y },
radiusN: Math.max(0.03, t.radiusN * 0.95),
intensity: Math.max(0.75, Math.min(1.2, t.intensity * 1.15)),
lifetimeMs: poisonDraftLifeMsRef.current,
};
}
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',
seed,
createdAtMs,
at: { x: last.x, y: last.y },
intensity: Math.max(0.8, Math.min(1.25, t.intensity * 1.15)),
lifetimeMs: freezeDraftLifeMsRef.current,
};
}
return null;
}
function pushDraftToPixi(): void {
effectsOverlayRef.current?.setDraft(buildDraftEffectInstance());
}
function clearDraftFromPixi(): void {
draftMetaRef.current = null;
effectsOverlayRef.current?.setDraft(null);
}
function scheduleDraftRepaint(): void {
if (draftPaintRafRef.current !== 0) return;
draftPaintRafRef.current = requestAnimationFrame(() => {
draftPaintRafRef.current = 0;
pushDraftToPixi();
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 }[],
opts?: {
types?: readonly ('fog' | 'fire' | 'rain' | 'water')[];
hitMode?: 'inclusive' | 'brush';
},
): void {
if (points.length === 0) return;
void fx.dispatch({
kind: 'field.erase',
points,
radiusN: toolRef.current.radiusN,
...(opts?.types ? { types: opts.types } : {}),
...(opts?.hitMode ? { hitMode: opts.hitMode } : {}),
});
}
/** Вода/дождь гасят огонь только под самой кистью (не «аура» радиуса огня). */
function extinguishFireAlong(points: { x: number; y: number; tMs: number }[]): void {
dispatchFieldEraserPoints(points, { types: ['fire'], hitMode: 'brush' });
}
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;
clearDraftFromPixi();
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: 'field.erase',
points: b.points,
radiusN: tool.radiusN,
types: ['fire'],
hitMode: 'brush',
});
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: 'field.erase',
points: b.points,
radiusN: tool.radiusN,
types: ['fire'],
hitMode: 'brush',
});
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;
clearDraftFromPixi();
}
return (
<div className={styles.page}>
<Surface className={styles.remote}>
<div className={styles.remoteTitle}>{t('control.remoteTitle')}</div>
<div className={styles.spacer12} />
<div className={styles.sectionLabel}>{t('control.instruments')}</div>
<div className={styles.spacer8} />
<div className={styles.iconRow}>
<Button
variant="ghost"
iconOnly
disabled={!hasSceneDescription}
title={hasSceneDescription ? t('control.descriptionTool') : t('control.descriptionMissing')}
ariaLabel={hasSceneDescription ? t('control.descriptionTool') : t('control.descriptionMissing')}
onClick={() => {
void api.invoke(ipcChannels.windows.openSceneDescription, { html: sceneDescription }).catch((err) => {
console.error('[control] openSceneDescription failed', err);
});
}}
>
<span className={styles.iconGlyph} aria-hidden>
📖
</span>
</Button>
<Button
variant="ghost"
iconOnly
title={t('control.materialsTool')}
ariaLabel={t('control.materialsTool')}
onClick={() => {
void api.invoke(ipcChannels.windows.openMaterials, {}).catch((err) => {
console.error('[control] openMaterials failed', err);
});
}}
>
<span className={styles.iconGlyph} aria-hidden>
<svg viewBox="0 0 24 24" width="20" height="20" aria-hidden>
<path
fill="#c9a227"
d="M4.2 5.4c1.6-.9 3.5-.7 5 .5l1.3 1.1c.4.3 1 .3 1.4 0L13.2 5.9c1.5-1.2 3.4-1.4 5-.5l1.4.8c.8.5 1.3 1.4 1.3 2.3v8.6c0 1.4-1.4 2.3-2.7 1.8l-2.3-.9c-.7-.3-1.5-.2-2.1.2l-1.3.9c-.6.4-1.4.4-2 0l-1.3-.9c-.6-.4-1.4-.5-2.1-.2l-2.3.9c-1.3.5-2.7-.4-2.7-1.8V8.5c0-.9.5-1.8 1.3-2.3l1.5-.8z"
/>
<path
fill="#7a4e1d"
d="M8.2 10.2c1.3-.4 2.5.2 3.3 1.1.3.3.8.3 1.1 0 .8-.9 2-1.5 3.3-1.1.5.2.8.7.6 1.2-.5 1.4-1.7 2.5-3.1 3.1-.5.2-1 .2-1.4 0-1.4-.6-2.6-1.7-3.1-3.1-.2-.5.1-1 .6-1.2z"
/>
<circle cx="12" cy="12.2" r="1.15" fill="#e8c547" />
<path
fill="none"
stroke="#5c3a16"
strokeWidth="1.1"
strokeLinecap="round"
d="M7.5 15.8c1.2.7 2.7 1.1 4.5 1.1s3.3-.4 4.5-1.1"
/>
</svg>
</span>
</Button>
<Button
variant="ghost"
iconOnly
title={t('control.npcsTool')}
ariaLabel={t('control.npcsTool')}
onClick={() => {
void api.invoke(ipcChannels.windows.openNpcs, {}).catch((err) => {
console.error('[control] openNpcs failed', err);
});
}}
>
<span className={styles.iconGlyph} aria-hidden>
<svg viewBox="0 0 24 24" width="20" height="20" aria-hidden>
<circle cx="12" cy="8" r="3.4" fill="#3b82f6" />
<path
fill="#22c55e"
d="M5.2 19.2c.6-3.4 3.2-5.2 6.8-5.2s6.2 1.8 6.8 5.2c.1.5-.3 1-.8 1H6c-.5 0-.9-.5-.8-1z"
/>
<circle cx="12" cy="8" r="1.4" fill="#93c5fd" />
</svg>
</span>
</Button>
</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 className={[styles.radiusRow, styles.effectsSoundRow].join(' ')}>
<div className={styles.radiusLabel}>{t('control.effectsSound')}</div>
<input
type="range"
min={0}
max={1}
step={0.01}
value={effectsSfxGainUi}
onChange={(e) => {
const v = Number((e.currentTarget as HTMLInputElement).value);
const next = setEffectsSfxGain(clampEffectsSfxGain(v));
setEffectsSfxGainUi(next);
syncFireAmbientVolume();
syncRainAmbientVolume();
}}
className={styles.range}
aria-label={t('control.effectsSound')}
/>
<div className={styles.radiusValue}>{Math.round(effectsSfxGainUi * 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
ref={previewFrameRef}
className={styles.previewFrame}
title={
isVideoPreviewScene
? undefined
: 'Колесо — зум; перетаскивание СКМ/ПКМ или Space+ЛКМ — пан'
}
>
<div ref={previewHostRef} className={styles.previewHost}>
<ControlScenePreview
session={session}
videoRef={previewVideoRef}
viewCamera={activeSceneViewCamera}
onContentRectChange={setPreviewContentRect}
/>
</div>
{!isVideoPreviewScene ? (
<>
<PixiEffectsOverlay
ref={effectsOverlayRef}
state={fxState}
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}
onContextMenu={(e) => {
e.preventDefault();
}}
onPointerEnter={(e) => {
const p = toNPoint(e);
if (!p) return;
cursorPosRef.current = p;
layoutBrushCursor();
}}
onPointerLeave={() => {
cursorPosRef.current = null;
layoutBrushCursor();
}}
onPointerDown={(e) => {
const panWithPrimary = e.button === 0 && (spaceDownRef.current || e.altKey);
const isPan = e.button === 1 || e.button === 2 || panWithPrimary;
if (isPan) {
e.preventDefault();
(e.currentTarget as HTMLDivElement).setPointerCapture(e.pointerId);
scenePanRef.current = {
lastX: e.clientX,
lastY: e.clientY,
pointerId: e.pointerId,
};
return;
}
if (e.button !== 0) return;
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;
}
draftMetaRef.current = { createdAtMs: Date.now(), seed: 12345 };
const startPt = { x: p.x, y: p.y, tMs: Date.now() };
brushRef.current = {
tool: tool.tool,
startN: p,
points: [startPt],
};
if (tool.tool === 'water' || tool.tool === 'rain') {
extinguishFireAlong([startPt]);
}
if (tool.tool === 'exploreBrush') {
void sd.dispatch({
kind: 'draft.set',
draft: {
points: [startPt],
radiusN: tool.radiusN,
},
});
}
pushDraftToPixi();
}}
onPointerMove={(e) => {
const pan = scenePanRef.current;
if (pan && pan.pointerId === e.pointerId) {
const cr = previewContentRectRef.current;
if (!cr) return;
const cam = sceneViewRef.current;
const containW = cr.w / Math.max(1e-6, cam.scale);
const containH = cr.h / Math.max(1e-6, cam.scale);
const dx = e.clientX - pan.lastX;
const dy = e.clientY - pan.lastY;
pan.lastX = e.clientX;
pan.lastY = e.clientY;
publishSceneViewCamera(
sceneViewPanBy(cam, { containW, containH, dx, dy }),
);
return;
}
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;
const pt = { x: p.x, y: p.y, tMs: Date.now() };
b.points.push(pt);
if (b.tool === 'water' || b.tool === 'rain') {
extinguishFireAlong([last, pt]);
}
scheduleDraftRepaint();
}}
onPointerUp={(e) => {
if (scenePanRef.current?.pointerId === e.pointerId) {
scenePanRef.current = null;
const pending = pendingSceneViewRef.current;
if (pending) {
void sceneViewApi.dispatch({ kind: 'set', camera: pending });
}
return;
}
void commitStroke();
}}
onPointerCancel={(e) => {
if (scenePanRef.current?.pointerId === e.pointerId) {
scenePanRef.current = null;
return;
}
if (brushRef.current?.tool === 'exploreBrush') {
void sd.dispatch({ kind: 'draft.set', draft: null });
}
brushRef.current = null;
clearDraftFromPixi();
}}
/>
</>
) : null}
{(() => {
const activeMaterial =
session?.project && materialsOverlay?.activeMaterialId
? (session.project.materials ?? []).find((m) => m.id === materialsOverlay.activeMaterialId)
: undefined;
const project = session?.project;
const activeIds = npcsOverlay?.activeNpcIds ?? [];
const npcItems =
project && activeIds.length > 0
? activeIds
.map((id) => {
const npc = (project.npcs ?? []).find((n) => n.id === id);
if (!npc) return null;
return {
npcId: npc.id,
assetId: npc.avatarAssetId,
layout: npcsOverlay?.layouts[id] ?? DEFAULT_NPCS_OVERLAY_LAYOUT,
};
})
.filter((x): x is NonNullable<typeof x> => x !== null)
: [];
const showMaterial = Boolean(activeMaterial);
const showNpcs = npcItems.length > 0;
if (!showMaterial && !showNpcs) return null;
const closes = [
...(showMaterial
? [
{
key: 'materials',
label: t('materials.closeOverlay'),
onClose: () => {
void materialsApi.dispatch({ kind: 'hide' });
},
},
]
: []),
...(showNpcs
? [
{
key: 'npcs',
label: t('npcs.closeOverlay'),
onClose: () => {
void npcsApi.dispatch({ kind: 'hide' });
},
},
]
: []),
];
const materialsZoom = materialsOverlay?.zoomTool ?? null;
return (
<SceneOverlayHost
active
zoomTool={materialsZoom}
{...(materialsZoom
? {
onZoomAt: (nx: number, ny: number) => {
void materialsApi.dispatch({ kind: 'zoomAt', nx, ny });
},
}
: {})}
closes={closes}
>
{showMaterial && activeMaterial ? (
<MaterialOverlay
embedded
assetId={activeMaterial.assetId}
layout={materialsOverlay?.layout ?? DEFAULT_MATERIALS_OVERLAY_LAYOUT}
editable
zoomTool={materialsZoom}
rotateLabel={t('materials.rotateOverlay')}
onLayoutChange={(layout) => {
void materialsApi.dispatch({ kind: 'layout.set', layout });
}}
/>
) : null}
{showNpcs ? (
<NpcsSceneOverlay
embedded
items={npcItems}
editable
rotateLabel={t('npcs.rotateOverlay')}
onLayoutChange={(npcId, layout) => {
void npcsApi.dispatch({ kind: 'layout.set', npcId, layout });
}}
/>
) : null}
</SceneOverlayHost>
);
})()}
</div>
</Surface>
<Surface className={styles.surfacePad}>
<div className={styles.branchTitle}>{t('control.branches')}</div>
<div className={styles.branchGrid}>
{showReturnToMain ? (
<div className={[styles.branchCard, styles.branchCardReturn].join(' ')}>
<div className={styles.branchCardHeader}>
<div className={styles.branchOption}>{t('control.option', { n: '1' })}</div>
</div>
<div className={styles.branchName}>{returnSceneTitle}</div>
<Button variant="primary" onClick={returnToMainStoryline}>
{t('control.returnToMainStory')}
</Button>
</div>
) : null}
{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 + branchOptionOffset) })}
</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 && !showReturnToMain ? (
<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);
return (
<ControlAudioCard
key={ref.assetId}
assetId={ref.assetId}
name={asset.originalName}
autoplay={ref.autoplay}
loop={ref.loop}
statusLabel={st.label}
{...(st.detail ? { statusDetail: st.detail } : {})}
audioEl={el}
initialGain={readAudioGain(sceneAudioGainRef.current, ref.assetId)}
gainMap={sceneAudioGainRef.current}
playTitle={t('control.transportPlay')}
playLabel={t('control.transportPlay')}
pauseLabel={t('control.transportPause')}
stopLabel={t('control.transportStop')}
volumeLabel={t('control.volume')}
modeAutoLabel={t('control.modeAuto')}
modeManualLabel={t('control.modeManual')}
loopLabel={t('control.loop')}
onceLabel={t('control.once')}
scrubSeekLabel={t('control.scrubSeek')}
durationUnknownLabel={t('control.durationUnknown')}
onStatusChange={() => setSceneAudioStateTick((x) => x + 1)}
onPlay={() => {
if (!el) return;
const m = sceneAudioMetaRef.current.get(ref.assetId) ?? { lastPlayError: null };
sceneAudioMetaRef.current.set(ref.assetId, { ...m, lastPlayError: null });
try {
applyAudioGain(el, sceneAudioGainRef.current, ref.assetId);
} catch {
// ignore
}
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);
});
}}
onPause={() => {
if (!el) return;
el.pause();
}}
onStop={() => {
if (!el) return;
el.pause();
el.currentTime = 0;
setSceneAudioStateTick((x) => x + 1);
}}
/>
);
})}
</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);
return (
<ControlAudioCard
key={ref.assetId}
assetId={ref.assetId}
name={asset.originalName}
autoplay={ref.autoplay}
loop={ref.loop}
statusLabel={st.label}
{...(st.detail ? { statusDetail: st.detail } : {})}
{...(!allowCampaignAudio
? {
extraBadge: (
<div title={t('control.pauseSceneMusicTitle')}>{t('control.pauseSceneMusic')}</div>
),
}
: {})}
audioEl={el}
initialGain={readAudioGain(campaignAudioGainRef.current, ref.assetId)}
gainMap={campaignAudioGainRef.current}
playTitle={
!allowCampaignAudio ? t('control.pauseCampaignTitle') : t('control.transportPlay')
}
playLabel={t('control.transportPlay')}
pauseLabel={t('control.transportPause')}
stopLabel={t('control.transportStop')}
volumeLabel={t('control.volume')}
modeAutoLabel={t('control.modeAuto')}
modeManualLabel={t('control.modeManual')}
loopLabel={t('control.loop')}
onceLabel={t('control.once')}
scrubSeekLabel={t('control.scrubSeek')}
durationUnknownLabel={t('control.durationUnknown')}
onStatusChange={() => setCampaignAudioStateTick((x) => x + 1)}
onPlay={() => {
if (!el) return;
const m = campaignAudioMetaRef.current.get(ref.assetId) ?? { lastPlayError: null };
campaignAudioMetaRef.current.set(ref.assetId, { ...m, lastPlayError: null });
try {
if (el.volume === 0) {
applyAudioGain(el, campaignAudioGainRef.current, ref.assetId);
}
} 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);
});
}}
onPause={() => {
if (!el) return;
el.pause();
}}
onStop={() => {
if (!el) return;
el.pause();
el.currentTime = 0;
setCampaignAudioStateTick((x) => x + 1);
}}
/>
);
})}
</div>
)}
</Surface>
{sideStoryLines.length > 0 ? (
<Surface className={styles.surfacePad}>
<div className={styles.previewTitle}>{t('control.sideStoryLines')}</div>
<div className={styles.spacer10} />
<div className={styles.sideStoryGrid}>
{sideStoryLines.map((line) =>
line.scene ? (
<SideStoryTile
key={line.graphNodeId}
scene={line.scene}
title={line.title}
onClick={() => enterSideStoryline(line.graphNodeId)}
/>
) : null,
)}
</div>
</Surface>
) : null}
</div>
</div>
);
}