Files
DndGamePlayer/app/renderer/control/ControlApp.tsx
T
Ivan Fontosh 2979d06f1c feat: multi-material overlays, presentation guides, and release prebuilds
Align control/presentation with presentation screen rect and darkness z-order; sync window titles and session window cleanup. Pack Win/Mac/Linux with npmRebuild disabled and release-native-prep for classic-level and sharp.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-08-03 11:28:18 +08:00

2418 lines
95 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 { fitAspectRect } from '../../shared/geometry/fitAspectRect';
import { ipcChannels } from '../../shared/ipc/contracts';
import type { SessionState } from '../../shared/ipc/contracts';
import {
isNodeInMainStoryline,
isNodeInSideStoryline,
listSideStoryStarts,
} from '../../shared/graph/sceneGraphLineage';
import type { GraphNodeId, MaterialId, 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 { ExplosionVideoOverlay } from '../shared/effects/ExplosionVideoOverlay';
import {
PixiEffectsOverlay,
type PixiEffectsOverlayHandle,
} from '../shared/effects/PxiEffectsOverlay';
import type { EffectInstance, EffectToolType, ExplosionInstance } 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 { MaterialLegendPanel } from '../shared/materials/MaterialLegendPanel';
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 { SceneGridOverlay } from '../shared/grid/SceneGridOverlay';
import { SceneTokensOverlay } from '../shared/tokens/SceneTokensOverlay';
import { useAppTokens } from '../shared/tokens/useAppTokens';
import { useSceneTokensSession } from '../shared/tokens/useSceneTokensSession';
import { SceneTrapsOverlay } from '../shared/traps/SceneTrapsOverlay';
import { useSceneTrapsState } from '../shared/traps/useSceneTrapsState';
import { Button } from '../shared/ui/controls';
import { EllipsisText } from '../shared/ui/EllipsisText';
import ellipsisStyles from '../shared/ui/ellipsisText.module.css';
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 { getExplosionEffectLifeMs, playExplosionEffectSound } from './explosionSfx';
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 [sceneTraps, sceneTrapsApi] = useSceneTrapsState();
const appTokens = useAppTokens();
const [sceneTokensSession, sceneTokensApi] = useSceneTokensSession();
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'
| 'explosion'
| 'freeze'
| 'exploreBrush'
| 'closeBrush'
| '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 [presentationContentSize, setPresentationContentSize] = useState<{
width: number;
height: number;
} | null>(null);
const previewContentRectRef = useRef(previewContentRect);
previewContentRectRef.current = previewContentRect;
const previewSizeRef = useRef(previewSize);
previewSizeRef.current = previewSize;
const presentationScreenRect = useMemo(() => {
if (!presentationContentSize) return null;
if (previewSize.w <= 1 || previewSize.h <= 1) return null;
return fitAspectRect(
previewSize.w,
previewSize.h,
presentationContentSize.width,
presentationContentSize.height,
);
}, [presentationContentSize, previewSize.h, previewSize.w]);
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 [explosionDraft, setExplosionDraft] = useState<ExplosionInstance | 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(() => {
const refreshPresentationSize = () => {
void api.invoke(ipcChannels.windows.getPresentationContentSize, {}).then((size) => {
if (size.width == null || size.height == null) {
setPresentationContentSize(null);
return;
}
setPresentationContentSize({ width: size.width, height: size.height });
});
};
refreshPresentationSize();
const offSize = api.on(ipcChannels.windows.presentationContentSizeChanged, (size) => {
setPresentationContentSize({ width: size.width, height: size.height });
});
const offMw = api.on(ipcChannels.windows.multiWindowStateChanged, ({ open }) => {
if (!open) {
mainStoryReturnRef.current = null;
setMainStoryReturnGraphNodeId(null);
setPresentationContentSize(null);
return;
}
refreshPresentationSize();
});
return () => {
offSize();
offMw();
};
}, [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;
const [explosionDraftLifeMs, setExplosionDraftLifeMs] = useState(4200);
const explosionDraftLifeMsRef = useRef(explosionDraftLifeMs);
explosionDraftLifeMsRef.current = explosionDraftLifeMs;
useEffect(() => {
void getPoisonCloudEffectLifeMs().then(setPoisonDraftLifeMs);
}, []);
useEffect(() => {
void getExplosionEffectLifeMs().then(setExplosionDraftLifeMs);
}, []);
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: 'none' as const, radiusN: 0.08, intensity: 0.6 };
const toolRef = useRef(tool);
toolRef.current = tool;
/** Повторный клик по активному инструменту снимает выбор. */
function selectEffectTool(next: Exclude<EffectToolType, 'none'>): void {
const toolName: EffectToolType = tool.tool === next ? 'none' : next;
void fx.dispatch({ kind: 'tool.set', tool: { ...tool, tool: toolName } });
}
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 || t.tool === 'none') {
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 === 'explosion' && b.points && b.points.length > 0) {
const last = b.points[b.points.length - 1];
if (last === undefined) return null;
return {
id: '__draft__',
type: 'explosion',
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: explosionDraftLifeMsRef.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 {
const draft = buildDraftEffectInstance();
effectsOverlayRef.current?.setDraft(draft);
setExplosionDraft(draft?.type === 'explosion' ? draft : null);
}
function clearDraftFromPixi(): void {
draftMetaRef.current = null;
effectsOverlayRef.current?.setDraft(null);
setExplosionDraft(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?.tool === 'closeBrush') &&
b.points
) {
void sd.dispatch({
kind: 'draft.set',
draft: {
points: b.points,
radiusN: toolRef.current.radiusN,
mode: b.tool === 'closeBrush' ? 'cover' : 'reveal',
},
});
}
});
}
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.tool === 'closeBrush') &&
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,
mode: b.tool === 'closeBrush' ? 'cover' : 'reveal',
},
});
}
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 === 'explosion' && 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 explosionLifeMs = await getExplosionEffectLifeMs();
await fx.dispatch({
kind: 'instance.add',
instance: {
id: `ex_${String(createdAtMs)}_${String(seed)}`,
type: 'explosion',
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: explosionLifeMs,
},
});
void playExplosionEffectSound(explosionLifeMs);
}
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={() => selectEffectTool('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={() => selectEffectTool('fog')}
>
<span className={styles.iconGlyph}>🌫</span>
</Button>
<Button
variant={tool.tool === 'rain' ? 'primary' : 'ghost'}
iconOnly
title={t('control.rain')}
ariaLabel={t('control.rain')}
onClick={() => selectEffectTool('rain')}
>
<span className={styles.iconGlyph}>🌧</span>
</Button>
<Button
variant={tool.tool === 'fire' ? 'primary' : 'ghost'}
iconOnly
title={t('control.fire')}
ariaLabel={t('control.fire')}
onClick={() => selectEffectTool('fire')}
>
<span className={styles.iconGlyph}>🔥</span>
</Button>
<Button
variant={tool.tool === 'water' ? 'primary' : 'ghost'}
iconOnly
title={t('control.water')}
ariaLabel={t('control.water')}
onClick={() => selectEffectTool('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={() => selectEffectTool('lightning')}
>
<span className={styles.iconGlyph}></span>
</Button>
<Button
variant={tool.tool === 'sunbeam' ? 'primary' : 'ghost'}
iconOnly
title={t('control.sunbeam')}
ariaLabel={t('control.sunbeam')}
onClick={() => selectEffectTool('sunbeam')}
>
<span className={styles.iconGlyph}></span>
</Button>
<Button
variant={tool.tool === 'freeze' ? 'primary' : 'ghost'}
iconOnly
title={t('control.freeze')}
ariaLabel={t('control.freeze')}
onClick={() => selectEffectTool('freeze')}
>
<span className={styles.iconGlyph}></span>
</Button>
<Button
variant={tool.tool === 'darkness' ? 'primary' : 'ghost'}
iconOnly
title={t('control.darkness')}
ariaLabel={t('control.darkness')}
onClick={() => selectEffectTool('darkness')}
>
<span className={styles.iconGlyph}>🌑</span>
</Button>
<Button
variant={tool.tool === 'poisonCloud' ? 'primary' : 'ghost'}
iconOnly
title={t('control.poisonCloud')}
ariaLabel={t('control.poisonCloud')}
onClick={() => selectEffectTool('poisonCloud')}
>
<span className={styles.iconGlyph}></span>
</Button>
<Button
variant={tool.tool === 'explosion' ? 'primary' : 'ghost'}
iconOnly
title={t('control.explosion')}
ariaLabel={t('control.explosion')}
onClick={() => selectEffectTool('explosion')}
>
<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={() => selectEffectTool('exploreBrush')}
>
<span className={styles.iconGlyph}>🔦</span>
</Button>
<Button
variant={tool.tool === 'closeBrush' ? 'primary' : 'ghost'}
iconOnly
title={t('control.closerBrush')}
ariaLabel={t('control.closerBrush')}
onClick={() => selectEffectTool('closeBrush')}
>
<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>
)}
<EllipsisText
text={s?.title ?? (gn ? String(gn.sceneId) : gnId)}
className={[styles.historyTitle, ellipsisStyles.root].join(' ')}
/>
</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 ? (
<>
<SceneGridOverlay grid={currentScene?.grid} viewport={previewContentRect} />
<PixiEffectsOverlay
ref={effectsOverlayRef}
state={fxState}
style={{ zIndex: 6 }}
viewport={
previewContentRect
? {
x: previewContentRect.x,
y: previewContentRect.y,
w: previewContentRect.w,
h: previewContentRect.h,
}
: undefined
}
/>
<ExplosionVideoOverlay
state={fxState}
draft={explosionDraft}
viewport={previewContentRect}
/>
<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;
if (tool.tool === 'none') 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' || tool.tool === 'closeBrush') {
void sd.dispatch({
kind: 'draft.set',
draft: {
points: [startPt],
radiusN: tool.radiusN,
mode: tool.tool === 'closeBrush' ? 'cover' : 'reveal',
},
});
}
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' ||
brushRef.current?.tool === 'closeBrush'
) {
void sd.dispatch({ kind: 'draft.set', draft: null });
}
brushRef.current = null;
clearDraftFromPixi();
}}
/>
{previewContentRect ? (
<SceneTokensOverlay
placements={currentScene?.tokens ?? []}
library={appTokens}
session={sceneTokensSession}
viewport={previewContentRect}
editable
onMove={(placementId, nx, ny) => {
void sceneTokensApi.dispatch({ kind: 'move', placementId, nx, ny });
}}
/>
) : null}
{previewContentRect ? (
<SceneTrapsOverlay
traps={currentScene?.traps ?? []}
session={sceneTraps}
viewport={previewContentRect}
mode="control"
onReveal={(trapId) => void sceneTrapsApi.dispatch({ kind: 'reveal', trapId })}
onActivate={(trapId) => {
void (async () => {
await sceneTrapsApi.dispatch({ kind: 'activate', trapId });
const trap = (currentScene?.traps ?? []).find((t) => t.id === trapId);
if (trap?.type === 'poison') {
// Тот же VFX/SFX, что у инструмента «Облако яда» на пульте эффектов.
const createdAtMs = Date.now();
const seed = Math.floor(Math.random() * 1_000_000_000);
const poisonLifeMs = await getPoisonCloudEffectLifeMs();
await fx.dispatch({
kind: 'instance.add',
instance: {
id: `trap_pc_${trapId}_${String(createdAtMs)}`,
type: 'poisonCloud',
seed,
createdAtMs,
at: { x: trap.nx, y: trap.ny },
radiusN: Math.max(0.04, trap.sizeN * 1.15),
intensity: 1.05,
lifetimeMs: poisonLifeMs,
},
});
void playPoisonCloudEffectSound(poisonLifeMs);
return;
}
if (trap?.type === 'explosion') {
// Тот же VFX/SFX, что у инструмента «Взрыв» на пульте эффектов.
const createdAtMs = Date.now();
const seed = Math.floor(Math.random() * 1_000_000_000);
const explosionLifeMs = await getExplosionEffectLifeMs();
await fx.dispatch({
kind: 'instance.add',
instance: {
id: `trap_ex_${trapId}_${String(createdAtMs)}`,
type: 'explosion',
seed,
createdAtMs,
at: { x: trap.nx, y: trap.ny },
radiusN: Math.max(0.04, trap.sizeN * 1.15),
intensity: 1.05,
lifetimeMs: explosionLifeMs,
},
});
void playExplosionEffectSound(explosionLifeMs);
}
})();
}}
onDisarm={(trapId) => void sceneTrapsApi.dispatch({ kind: 'disarm', trapId })}
/>
) : null}
</>
) : null}
{(() => {
const project = session?.project;
const materialIds = materialsOverlay?.activeMaterialIds ?? [];
const materialItems =
project && materialIds.length > 0
? materialIds
.map((id) => {
const material = (project.materials ?? []).find((m) => m.id === id);
if (!material) return null;
return {
material,
layout: materialsOverlay?.layouts[id] ?? DEFAULT_MATERIALS_OVERLAY_LAYOUT,
legendLayout:
materialsOverlay?.legendLayouts[id] ?? {
...DEFAULT_MATERIALS_OVERLAY_LAYOUT,
cx: 0.82,
cy: 0.5,
scale: 0.85,
},
};
})
.filter((x): x is NonNullable<typeof x> => x !== null)
: [];
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 = materialItems.length > 0;
const showNpcs = npcItems.length > 0;
const screenRect = presentationScreenRect;
const showGuide = Boolean(screenRect) && !isVideoPreviewScene;
if (!showMaterial && !showNpcs && !showGuide) 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;
const materialIdFromTarget = (target: EventTarget | null) => {
if (!(target instanceof Element)) return undefined;
const frame = target.closest('[data-material-id]');
const raw = frame?.getAttribute('data-material-id');
return raw ? (raw as MaterialId) : undefined;
};
return (
<>
{previewContentRect && currentScene?.darkenScene ? (
<SceneDarknessOverlay
state={sdState}
overlayAlpha={0.5}
viewport={previewContentRect}
style={{ zIndex: 30 }}
/>
) : null}
<SceneOverlayHost
active={showMaterial || showNpcs}
viewport={screenRect}
showViewportGuide={showGuide}
zoomTool={materialsZoom}
{...(materialsZoom
? {
onZoomAt: (nx: number, ny: number, evTarget?: EventTarget | null) => {
const mid = materialIdFromTarget(evTarget ?? null);
void materialsApi.dispatch({
kind: 'zoomAt',
nx,
ny,
...(mid ? { materialId: mid } : {}),
});
},
}
: {})}
closes={closes}
>
{materialItems.map(({ material, layout, legendLayout }) => (
<React.Fragment key={material.id}>
<MaterialOverlay
embedded
assetId={material.assetId}
materialId={material.id}
layout={layout}
editable
zoomTool={materialsZoom}
rotateLabel={t('materials.rotateOverlay')}
onLayoutChange={(nextLayout) => {
void materialsApi.dispatch({
kind: 'layout.set',
materialId: material.id,
layout: nextLayout,
});
}}
{...(material.legend?.enabled
? { legendMarkers: material.legend.markers ?? [] }
: {})}
/>
{material.legend?.enabled ? (
<MaterialLegendPanel
legend={material.legend}
layout={legendLayout}
editable
onLayoutChange={(nextLayout) => {
void materialsApi.dispatch({
kind: 'legendLayout.set',
materialId: material.id,
layout: nextLayout,
});
}}
/>
) : null}
</React.Fragment>
))}
{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>
<EllipsisText
text={returnSceneTitle}
className={[styles.branchName, ellipsisStyles.root].join(' ')}
/>
<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>
<EllipsisText
text={o.scene.title || t('control.unnamed')}
className={[styles.branchName, ellipsisStyles.root].join(' ')}
/>
<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>
);
}