d9fbecf5a7
Move audio scrub/volume and brush drafts off root React ticks, coalesce overlay layout IPC, cache machine fingerprint and asset URLs, share one scene overlay host, and stop idle Pixi ticker. Also harden console against EPIPE on window load failures. Co-authored-by: Cursor <cursoragent@cursor.com>
1910 lines
74 KiB
TypeScript
1910 lines
74 KiB
TypeScript
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 } from '../../shared/types';
|
||
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 { 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 { 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 = 0.88;
|
||
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 [sdState, sd] = useSceneDarknessState();
|
||
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 previewVideoRef = useRef<HTMLVideoElement | null>(null);
|
||
const brushRef = useRef<{
|
||
tool:
|
||
| 'fog'
|
||
| 'fire'
|
||
| 'rain'
|
||
| 'water'
|
||
| 'darkness'
|
||
| 'lightning'
|
||
| 'sunbeam'
|
||
| 'poisonCloud'
|
||
| 'freeze'
|
||
| 'exploreBrush'
|
||
| 'eraser';
|
||
startN?: { x: number; y: number };
|
||
points?: { x: number; y: number; tMs: number }[];
|
||
} | null>(null);
|
||
const [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 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();
|
||
}, []);
|
||
|
||
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 }[]): void {
|
||
if (points.length === 0) return;
|
||
void fx.dispatch({ kind: 'field.erase', points, radiusN: toolRef.current.radiusN });
|
||
}
|
||
|
||
function tryEraseActionEffect(p: { x: number; y: number }): void {
|
||
const id = pickEraseTargetId(fxState?.instances ?? [], p, toolRef.current.radiusN);
|
||
if (id) void fx.dispatch({ kind: 'instance.remove', id });
|
||
}
|
||
|
||
async function commitStroke(): Promise<void> {
|
||
if (isVideoPreviewScene) {
|
||
brushRef.current = null;
|
||
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: 'instance.add',
|
||
instance: {
|
||
id: `rain_${String(createdAtMs)}_${String(seed)}`,
|
||
type: 'rain',
|
||
seed,
|
||
createdAtMs,
|
||
points: b.points,
|
||
radiusN: tool.radiusN,
|
||
opacity: Math.max(0.08, Math.min(0.9, tool.intensity)),
|
||
lifetimeMs: null,
|
||
},
|
||
});
|
||
}
|
||
if (b.tool === 'water' && b.points && b.points.length > 0) {
|
||
await fx.dispatch({
|
||
kind: 'instance.add',
|
||
instance: {
|
||
id: `water_${String(createdAtMs)}_${String(seed)}`,
|
||
type: 'water',
|
||
seed,
|
||
createdAtMs,
|
||
points: b.points,
|
||
radiusN: tool.radiusN,
|
||
opacity: Math.max(0.06, Math.min(0.72, tool.intensity * 0.85)),
|
||
lifetimeMs: null,
|
||
},
|
||
});
|
||
}
|
||
if (b.tool === 'exploreBrush' && b.points && b.points.length > 0) {
|
||
await sd.dispatch({
|
||
kind: 'stroke.add',
|
||
stroke: {
|
||
id: `sd_${String(createdAtMs)}_${String(seed)}`,
|
||
seed,
|
||
createdAtMs,
|
||
points: b.points,
|
||
radiusN: tool.radiusN,
|
||
},
|
||
});
|
||
}
|
||
if (b.tool === 'darkness' && b.points && b.points.length > 0) {
|
||
const last = b.points[b.points.length - 1];
|
||
if (last === undefined) return;
|
||
const at = { x: last.x, y: last.y };
|
||
const darknessLifeMs = await getFreezeEffectLifeMs();
|
||
await fx.dispatch({
|
||
kind: 'instance.add',
|
||
instance: {
|
||
id: `dk_${String(createdAtMs)}_${String(seed)}`,
|
||
type: 'darkness',
|
||
seed,
|
||
createdAtMs,
|
||
at,
|
||
intensity: Math.max(0.8, Math.min(1.25, tool.intensity * 1.15)),
|
||
lifetimeMs: darknessLifeMs,
|
||
},
|
||
});
|
||
await fx.dispatch({
|
||
kind: 'instance.add',
|
||
instance: {
|
||
id: `sh_${String(createdAtMs)}_${String(seed)}`,
|
||
type: 'shadow',
|
||
seed: seed ^ 0x0d4a0001,
|
||
createdAtMs,
|
||
at,
|
||
radiusN: Math.max(0.03, tool.radiusN * 0.9),
|
||
opacity: 1,
|
||
lifetimeMs: null,
|
||
},
|
||
});
|
||
}
|
||
if (b.tool === 'lightning' && b.startN && b.points && b.points.length > 0) {
|
||
const last = b.points[b.points.length - 1];
|
||
if (last === undefined) return;
|
||
const end = { x: last.x, y: last.y };
|
||
const start = { x: end.x, y: 0 };
|
||
await fx.dispatch({
|
||
kind: 'instance.add',
|
||
instance: {
|
||
id: `lt_${String(createdAtMs)}_${String(seed)}`,
|
||
type: 'lightning',
|
||
seed,
|
||
createdAtMs,
|
||
start,
|
||
end,
|
||
widthN: Math.max(0.014, tool.radiusN * 1.15),
|
||
intensity: Math.max(1, Math.min(1.4, tool.intensity * 1.45)),
|
||
lifetimeMs: LIGHTNING_EFFECT_MS,
|
||
},
|
||
});
|
||
await fx.dispatch({
|
||
kind: 'instance.add',
|
||
instance: {
|
||
id: `sc_${String(createdAtMs)}_${String(seed)}`,
|
||
type: 'scorch',
|
||
seed: seed ^ 0x7a7a7a,
|
||
createdAtMs,
|
||
at: end,
|
||
radiusN: Math.max(0.04, tool.radiusN * 0.82),
|
||
opacity: 0.96,
|
||
lifetimeMs: 90_000,
|
||
},
|
||
});
|
||
playLightningEffectSound();
|
||
}
|
||
if (b.tool === 'sunbeam' && b.startN && b.points && b.points.length > 0) {
|
||
const last = b.points[b.points.length - 1];
|
||
if (last === undefined) return;
|
||
const end = { x: last.x, y: last.y };
|
||
const start = { x: end.x, y: 0 };
|
||
const sunbeamLifeMs = await getSunbeamEffectLifeMs();
|
||
await fx.dispatch({
|
||
kind: 'instance.add',
|
||
instance: {
|
||
id: `sb_${String(createdAtMs)}_${String(seed)}`,
|
||
type: 'sunbeam',
|
||
seed,
|
||
createdAtMs,
|
||
start,
|
||
end,
|
||
widthN: Math.max(0.012, tool.radiusN * 0.95),
|
||
intensity: Math.max(0.95, Math.min(1.25, tool.intensity * 1.4)),
|
||
lifetimeMs: sunbeamLifeMs,
|
||
},
|
||
});
|
||
playSunbeamEffectSound();
|
||
}
|
||
if (b.tool === 'poisonCloud' && b.points && b.points.length > 0) {
|
||
const last = b.points[b.points.length - 1];
|
||
if (last === undefined) return;
|
||
const at = { x: last.x, y: last.y };
|
||
const poisonLifeMs = await getPoisonCloudEffectLifeMs();
|
||
await fx.dispatch({
|
||
kind: 'instance.add',
|
||
instance: {
|
||
id: `pc_${String(createdAtMs)}_${String(seed)}`,
|
||
type: 'poisonCloud',
|
||
seed,
|
||
createdAtMs,
|
||
at,
|
||
radiusN: Math.max(0.03, tool.radiusN * 0.95),
|
||
intensity: Math.max(0.75, Math.min(1.2, tool.intensity * 1.15)),
|
||
lifetimeMs: poisonLifeMs,
|
||
},
|
||
});
|
||
void playPoisonCloudEffectSound(poisonLifeMs);
|
||
}
|
||
if (b.tool === 'freeze' && b.points && b.points.length > 0) {
|
||
const last = b.points[b.points.length - 1];
|
||
if (last === undefined) return;
|
||
const at = { x: last.x, y: last.y };
|
||
const freezeLifeMs = await getFreezeEffectLifeMs();
|
||
await fx.dispatch({
|
||
kind: 'instance.add',
|
||
instance: {
|
||
id: `fr_${String(createdAtMs)}_${String(seed)}`,
|
||
type: 'freeze',
|
||
seed,
|
||
createdAtMs,
|
||
at,
|
||
intensity: Math.max(0.8, Math.min(1.25, tool.intensity * 1.15)),
|
||
// Длительность как у zamorozka.mp3 (фазы «замерзания» в PxiEffectsOverlay масштабируются по life).
|
||
lifetimeMs: freezeLifeMs,
|
||
},
|
||
});
|
||
await fx.dispatch({
|
||
kind: 'instance.add',
|
||
instance: {
|
||
id: `ice_${String(createdAtMs)}_${String(seed)}`,
|
||
type: 'ice',
|
||
seed: seed ^ 0x33cc99,
|
||
createdAtMs,
|
||
at,
|
||
radiusN: Math.max(0.03, tool.radiusN * 0.9),
|
||
opacity: 0.85,
|
||
lifetimeMs: null,
|
||
},
|
||
});
|
||
playFreezeEffectSound();
|
||
}
|
||
brushRef.current = null;
|
||
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>
|
||
<div className={styles.spacer12} />
|
||
</>
|
||
) : null}
|
||
<div className={styles.storyWrap}>
|
||
<div className={styles.sectionLabel}>{t('control.storyLine')}</div>
|
||
<div className={styles.spacer10} />
|
||
<div className={styles.storyScroll}>
|
||
{history.map((gnId, idx) => {
|
||
const gn = project?.sceneGraphNodes.find((n) => n.id === gnId);
|
||
const s = gn ? project?.scenes[gn.sceneId] : undefined;
|
||
const isCurrent = idx === currentHistoryIdx;
|
||
return (
|
||
<button
|
||
type="button"
|
||
key={`${gnId}_${String(idx)}`}
|
||
disabled={!project || isCurrent}
|
||
className={[styles.historyBtn, isCurrent ? styles.historyBtnCurrent : '']
|
||
.filter(Boolean)
|
||
.join(' ')}
|
||
title={project && !isCurrent ? t('control.gotoScene') : undefined}
|
||
onClick={() => {
|
||
if (!project) return;
|
||
if (isCurrent) return;
|
||
void api.invoke(ipcChannels.project.setCurrentGraphNode, { graphNodeId: gnId });
|
||
}}
|
||
>
|
||
{isCurrent ? (
|
||
<div className={styles.historyBadge}>{t('control.currentSceneBadge')}</div>
|
||
) : (
|
||
<div className={styles.historyMuted}>{t('control.passed')}</div>
|
||
)}
|
||
<div className={styles.historyTitle}>{s?.title ?? (gn ? String(gn.sceneId) : gnId)}</div>
|
||
</button>
|
||
);
|
||
})}
|
||
{history.length === 0 ? (
|
||
<div className={styles.emptyStory}>{t('control.noActiveScene')}</div>
|
||
) : null}
|
||
</div>
|
||
</div>
|
||
</Surface>
|
||
|
||
<div className={styles.rightStack}>
|
||
<Surface className={styles.surfacePad}>
|
||
<div className={styles.previewHeader}>
|
||
<div className={styles.previewTitle}>{t('control.screenPreview')}</div>
|
||
<div className={styles.previewActions}>
|
||
<Button onClick={() => void api.invoke(ipcChannels.windows.closeMultiWindow, {})}>
|
||
{t('control.stopPresentation')}
|
||
</Button>
|
||
</div>
|
||
</div>
|
||
<div className={styles.spacer10} />
|
||
{isVideoPreviewScene ? <div className={styles.videoHint}>{t('control.videoBrushHint')}</div> : null}
|
||
<div className={styles.spacer10} />
|
||
<div className={styles.previewFrame}>
|
||
<div ref={previewHostRef} className={styles.previewHost}>
|
||
<ControlScenePreview
|
||
session={session}
|
||
videoRef={previewVideoRef}
|
||
onContentRectChange={setPreviewContentRect}
|
||
/>
|
||
</div>
|
||
{!isVideoPreviewScene ? (
|
||
<>
|
||
<PixiEffectsOverlay
|
||
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}
|
||
onPointerEnter={(e) => {
|
||
const p = toNPoint(e);
|
||
if (!p) return;
|
||
cursorPosRef.current = p;
|
||
layoutBrushCursor();
|
||
}}
|
||
onPointerLeave={() => {
|
||
cursorPosRef.current = null;
|
||
layoutBrushCursor();
|
||
}}
|
||
onPointerDown={(e) => {
|
||
const p = toNPoint(e);
|
||
if (!p) return;
|
||
cursorPosRef.current = p;
|
||
layoutBrushCursor();
|
||
(e.currentTarget as HTMLDivElement).setPointerCapture(e.pointerId);
|
||
if (tool.tool === 'eraser') {
|
||
const pt = { x: p.x, y: p.y, tMs: Date.now() };
|
||
brushRef.current = { tool: 'eraser', startN: p, points: [pt] };
|
||
dispatchFieldEraserPoints([pt]);
|
||
tryEraseActionEffect(p);
|
||
return;
|
||
}
|
||
draftMetaRef.current = { createdAtMs: Date.now(), seed: 12345 };
|
||
brushRef.current = {
|
||
tool: tool.tool,
|
||
startN: p,
|
||
points: [{ x: p.x, y: p.y, tMs: Date.now() }],
|
||
};
|
||
if (tool.tool === 'exploreBrush') {
|
||
void sd.dispatch({
|
||
kind: 'draft.set',
|
||
draft: {
|
||
points: [{ x: p.x, y: p.y, tMs: Date.now() }],
|
||
radiusN: tool.radiusN,
|
||
},
|
||
});
|
||
}
|
||
pushDraftToPixi();
|
||
}}
|
||
onPointerMove={(e) => {
|
||
const p = toNPoint(e);
|
||
if (!p) return;
|
||
cursorPosRef.current = p;
|
||
layoutBrushCursor();
|
||
if (tool.tool === 'eraser' && (e.buttons & 1) !== 0) {
|
||
const b = brushRef.current;
|
||
const pt = { x: p.x, y: p.y, tMs: Date.now() };
|
||
if (b?.tool !== 'eraser' || !b.points) {
|
||
brushRef.current = { tool: 'eraser', startN: p, points: [pt] };
|
||
dispatchFieldEraserPoints([pt]);
|
||
} else {
|
||
const last = b.points[b.points.length - 1];
|
||
if (last) {
|
||
const dx = p.x - last.x;
|
||
const dy = p.y - last.y;
|
||
const minStep = Math.max(0.004, tool.radiusN * 0.25);
|
||
if (dx * dx + dy * dy >= minStep * minStep) {
|
||
b.points.push(pt);
|
||
dispatchFieldEraserPoints([last, pt]);
|
||
}
|
||
}
|
||
}
|
||
tryEraseActionEffect(p);
|
||
return;
|
||
}
|
||
const b = brushRef.current;
|
||
if (!b?.points) return;
|
||
const last = b.points[b.points.length - 1];
|
||
if (!last) return;
|
||
const dx = p.x - last.x;
|
||
const dy = p.y - last.y;
|
||
const minStep = Math.max(0.004, tool.radiusN * 0.25);
|
||
if (dx * dx + dy * dy < minStep * minStep) return;
|
||
b.points.push({ x: p.x, y: p.y, tMs: Date.now() });
|
||
scheduleDraftRepaint();
|
||
}}
|
||
onPointerUp={() => {
|
||
void commitStroke();
|
||
}}
|
||
onPointerCancel={() => {
|
||
if (brushRef.current?.tool === 'exploreBrush') {
|
||
void sd.dispatch({ kind: 'draft.set', draft: null });
|
||
}
|
||
brushRef.current = null;
|
||
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>
|
||
);
|
||
}
|