eb127c11c2
Keep effects pinned to map coordinates when the viewport changes; materials/NPC overlays stay screen-fixed. Co-authored-by: Cursor <cursoragent@cursor.com>
2141 lines
83 KiB
TypeScript
2141 lines
83 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, SceneViewCamera } from '../../shared/types';
|
||
import {
|
||
DEFAULT_SCENE_VIEW_CAMERA,
|
||
sceneViewPanBy,
|
||
sceneViewZoomAt,
|
||
} from '../../shared/types/sceneView';
|
||
import { useEditorI18n } from '../editor/i18n/EditorI18nContext';
|
||
import { isSceneDescriptionEmpty } from '../editor/sceneDescriptionHtml';
|
||
import { getDndApi } from '../shared/dndApi';
|
||
import { RotatedImage } from '../shared/RotatedImage';
|
||
import {
|
||
PixiEffectsOverlay,
|
||
type PixiEffectsOverlayHandle,
|
||
} from '../shared/effects/PxiEffectsOverlay';
|
||
import type { EffectInstance } from '../../shared/types/effects';
|
||
import { SceneDarknessOverlay } from '../shared/effects/SceneDarknessOverlay';
|
||
import { useEffectsState } from '../shared/effects/useEffectsState';
|
||
import { useSceneDarknessState } from '../shared/effects/useSceneDarknessState';
|
||
import { DEFAULT_MATERIALS_OVERLAY_LAYOUT, DEFAULT_NPCS_OVERLAY_LAYOUT } from '../../shared/types';
|
||
import { MaterialOverlay } from '../shared/materials/MaterialOverlay';
|
||
import { useMaterialsOverlayState } from '../shared/materials/useMaterialsOverlayState';
|
||
import { NpcsSceneOverlay } from '../shared/npcs/NpcsSceneOverlay';
|
||
import { useNpcsOverlayState } from '../shared/npcs/useNpcsOverlayState';
|
||
import { SceneOverlayHost } from '../shared/sceneOverlay/SceneOverlayHost';
|
||
import { useSceneViewState } from '../shared/sceneView/useSceneViewState';
|
||
import { Button } from '../shared/ui/controls';
|
||
import { Surface } from '../shared/ui/Surface';
|
||
import { useAssetUrl } from '../shared/useAssetImageUrl';
|
||
|
||
import styles from './ControlApp.module.css';
|
||
import { ControlAudioCard } from './ControlAudioCard';
|
||
import { ControlScenePreview } from './ControlScenePreview';
|
||
import { clampEffectsSfxGain, getEffectsSfxGain, setEffectsSfxGain } from './effectsSfxGain';
|
||
import { setFireAmbientActive, syncFireAmbientVolume } from './fireAmbientSfx';
|
||
import { setRainAmbientActive, syncRainAmbientVolume } from './rainAmbientSfx';
|
||
import { getFreezeEffectLifeMs, playFreezeEffectSound } from './freezeSfx';
|
||
import { getPoisonCloudEffectLifeMs, playPoisonCloudEffectSound } from './poisonCloudSfx';
|
||
import { getSunbeamEffectLifeMs, playSunbeamEffectSound } from './sunbeamSfx';
|
||
|
||
/** Длительность молнии: быстрый удар + акцент в точке попадания. */
|
||
const LIGHTNING_EFFECT_MS = 840;
|
||
|
||
function clampAudioGain(v: number): number {
|
||
if (!Number.isFinite(v)) return 1;
|
||
return Math.max(0, Math.min(1, v));
|
||
}
|
||
|
||
function readAudioGain(gains: Map<string, number>, assetId: string): number {
|
||
return gains.get(assetId) ?? 1;
|
||
}
|
||
|
||
/** Применяет пользовательскую громкость; `factor` — для fade in/out (0…1). */
|
||
function applyAudioGain(
|
||
el: HTMLAudioElement,
|
||
gains: Map<string, number>,
|
||
assetId: string,
|
||
factor = 1,
|
||
): void {
|
||
el.volume = clampAudioGain(readAudioGain(gains, assetId) * factor);
|
||
}
|
||
|
||
/** Файл из `app/renderer/public/molniya.mp3` — рядом с `control.html` в dev и в dist. */
|
||
function lightningEffectSoundUrl(): string {
|
||
return new URL('molniya.mp3', window.location.href).href;
|
||
}
|
||
|
||
function playLightningEffectSound(): void {
|
||
try {
|
||
const el = new Audio(lightningEffectSoundUrl());
|
||
el.volume = Math.max(0, Math.min(1, 0.88 * getEffectsSfxGain()));
|
||
void el.play().catch(() => undefined);
|
||
} catch {
|
||
/* ignore */
|
||
}
|
||
}
|
||
|
||
function SideStoryTile({ scene, title, onClick }: { scene: Scene; title: string; onClick: () => void }) {
|
||
const thumbUrl = useAssetUrl(scene.previewThumbAssetId ?? scene.previewAssetId);
|
||
const previewUrl = useAssetUrl(scene.previewAssetId);
|
||
const imageUrl = thumbUrl ?? (scene.previewAssetType === 'image' ? previewUrl : null);
|
||
return (
|
||
<button type="button" className={styles.sideStoryTile} onClick={onClick}>
|
||
<div className={styles.sideStoryPreview}>
|
||
{imageUrl ? (
|
||
<RotatedImage
|
||
url={imageUrl}
|
||
rotationDeg={scene.previewRotationDeg}
|
||
mode="cover"
|
||
loading="lazy"
|
||
decoding="async"
|
||
style={{ width: '100%', height: '100%' }}
|
||
/>
|
||
) : previewUrl && scene.previewAssetType === 'video' ? (
|
||
<video src={previewUrl} muted playsInline preload="metadata" className={styles.sideStoryVideo} />
|
||
) : (
|
||
<div className={styles.sideStoryPlaceholder} aria-hidden />
|
||
)}
|
||
</div>
|
||
<div className={styles.sideStoryTitle}>{title}</div>
|
||
</button>
|
||
);
|
||
}
|
||
|
||
export function ControlApp() {
|
||
const api = getDndApi();
|
||
const { t } = useEditorI18n();
|
||
const tRef = useRef(t);
|
||
tRef.current = t;
|
||
const [fxState, fx] = useEffectsState();
|
||
const [effectsSfxGainUi, setEffectsSfxGainUi] = useState(() => getEffectsSfxGain());
|
||
const [sdState, sd] = useSceneDarknessState();
|
||
const [sceneView, sceneViewApi] = useSceneViewState();
|
||
const [sceneViewDraft, setSceneViewDraft] = useState<SceneViewCamera | null>(null);
|
||
const [materialsOverlay, materialsApi] = useMaterialsOverlayState();
|
||
const [npcsOverlay, npcsApi] = useNpcsOverlayState();
|
||
const [session, setSession] = useState<SessionState | null>(null);
|
||
const historyRef = useRef<GraphNodeId[]>([]);
|
||
const [history, setHistory] = useState<GraphNodeId[]>([]);
|
||
/** Сцена основного сюжета, с которой ушли в побочную линию (только текущая сессия). */
|
||
const mainStoryReturnRef = useRef<GraphNodeId | null>(null);
|
||
const [mainStoryReturnGraphNodeId, setMainStoryReturnGraphNodeId] = useState<GraphNodeId | null>(null);
|
||
// Сюжетная линия — только UI-состояние пульта. Не меняет граф, сцены и связи проекта.
|
||
const sceneAudioElsRef = useRef<Map<string, HTMLAudioElement>>(new Map());
|
||
const sceneAudioMetaRef = useRef<Map<string, { lastPlayError: string | null }>>(new Map());
|
||
/** Пользовательская громкость 0…1 по assetId (сохраняется между сменами сцен для того же трека). */
|
||
const sceneAudioGainRef = useRef<Map<string, number>>(new Map());
|
||
const [sceneAudioStateTick, setSceneAudioStateTick] = useState(0);
|
||
const sceneAudioLoadRunRef = useRef(0);
|
||
|
||
const campaignAudioElsRef = useRef<Map<string, HTMLAudioElement>>(new Map());
|
||
const campaignAudioMetaRef = useRef<Map<string, { lastPlayError: string | null }>>(new Map());
|
||
const campaignAudioGainRef = useRef<Map<string, number>>(new Map());
|
||
const [campaignAudioStateTick, setCampaignAudioStateTick] = useState(0);
|
||
const campaignAudioLoadRunRef = useRef(0);
|
||
/** Snapshot of `!el.paused` per assetId when scene music takes over; used to resume when `allowCampaignAudio` is true again. */
|
||
const campaignResumeAfterSceneRef = useRef<Map<string, boolean> | null>(null);
|
||
const allowCampaignAudioRef = useRef<boolean>(true);
|
||
const audioUnmountRef = useRef(false);
|
||
const previewHostRef = useRef<HTMLDivElement | null>(null);
|
||
const previewFrameRef = useRef<HTMLDivElement | null>(null);
|
||
const previewVideoRef = useRef<HTMLVideoElement | null>(null);
|
||
const sceneViewRef = useRef<SceneViewCamera>(DEFAULT_SCENE_VIEW_CAMERA);
|
||
const scenePanRef = useRef<{ lastX: number; lastY: number; pointerId: number } | null>(null);
|
||
const spaceDownRef = useRef(false);
|
||
const sceneViewPublishRafRef = useRef(0);
|
||
const pendingSceneViewRef = useRef<SceneViewCamera | null>(null);
|
||
const brushRef = useRef<{
|
||
tool:
|
||
| 'fog'
|
||
| 'fire'
|
||
| 'rain'
|
||
| 'water'
|
||
| 'darkness'
|
||
| 'lightning'
|
||
| 'sunbeam'
|
||
| 'poisonCloud'
|
||
| 'freeze'
|
||
| 'exploreBrush'
|
||
| 'eraser';
|
||
startN?: { x: number; y: number };
|
||
points?: { x: number; y: number; tMs: number }[];
|
||
} | null>(null);
|
||
const [previewSize, setPreviewSize] = useState<{ w: number; h: number }>({ w: 1, h: 1 });
|
||
const [previewContentRect, setPreviewContentRect] = useState<{
|
||
x: number;
|
||
y: number;
|
||
w: number;
|
||
h: number;
|
||
} | null>(null);
|
||
const previewContentRectRef = useRef(previewContentRect);
|
||
previewContentRectRef.current = previewContentRect;
|
||
const previewSizeRef = useRef(previewSize);
|
||
previewSizeRef.current = previewSize;
|
||
const brushCursorElRef = useRef<HTMLDivElement | null>(null);
|
||
const cursorPosRef = useRef<{ x: number; y: number } | null>(null);
|
||
const draftPaintRafRef = useRef(0);
|
||
const effectsOverlayRef = useRef<PixiEffectsOverlayHandle | null>(null);
|
||
const draftMetaRef = useRef<{ createdAtMs: number; seed: number } | null>(null);
|
||
|
||
useEffect(() => {
|
||
void api.invoke(ipcChannels.project.get, {}).then((res) => {
|
||
const next: SessionState = {
|
||
project: res.project,
|
||
currentSceneId: res.project?.currentSceneId ?? null,
|
||
};
|
||
setSession(next);
|
||
historyRef.current = next.project?.currentGraphNodeId ? [next.project.currentGraphNodeId] : [];
|
||
setHistory(historyRef.current);
|
||
});
|
||
return api.on(ipcChannels.session.stateChanged, ({ state }) => {
|
||
setSession(state);
|
||
const cur = state.project?.currentGraphNodeId ?? null;
|
||
if (!cur) {
|
||
mainStoryReturnRef.current = null;
|
||
setMainStoryReturnGraphNodeId(null);
|
||
return;
|
||
}
|
||
const arr = historyRef.current;
|
||
if (arr[arr.length - 1] !== cur) {
|
||
historyRef.current = [...arr, cur];
|
||
setHistory(historyRef.current);
|
||
}
|
||
});
|
||
}, [api]);
|
||
|
||
useEffect(() => {
|
||
return api.on(ipcChannels.windows.multiWindowStateChanged, ({ open }) => {
|
||
if (!open) {
|
||
mainStoryReturnRef.current = null;
|
||
setMainStoryReturnGraphNodeId(null);
|
||
}
|
||
});
|
||
}, [api]);
|
||
|
||
useEffect(() => {
|
||
audioUnmountRef.current = false;
|
||
return () => {
|
||
audioUnmountRef.current = true;
|
||
};
|
||
}, []);
|
||
|
||
const [freezeDraftLifeMs, setFreezeDraftLifeMs] = useState(820);
|
||
const freezeDraftLifeMsRef = useRef(freezeDraftLifeMs);
|
||
freezeDraftLifeMsRef.current = freezeDraftLifeMs;
|
||
useEffect(() => {
|
||
void getFreezeEffectLifeMs().then(setFreezeDraftLifeMs);
|
||
}, []);
|
||
|
||
const [darknessDraftLifeMs, setDarknessDraftLifeMs] = useState(820);
|
||
const darknessDraftLifeMsRef = useRef(darknessDraftLifeMs);
|
||
darknessDraftLifeMsRef.current = darknessDraftLifeMs;
|
||
useEffect(() => {
|
||
void getFreezeEffectLifeMs().then(setDarknessDraftLifeMs);
|
||
}, []);
|
||
|
||
const [sunbeamDraftLifeMs, setSunbeamDraftLifeMs] = useState(600);
|
||
const sunbeamDraftLifeMsRef = useRef(sunbeamDraftLifeMs);
|
||
sunbeamDraftLifeMsRef.current = sunbeamDraftLifeMs;
|
||
useEffect(() => {
|
||
void getSunbeamEffectLifeMs().then(setSunbeamDraftLifeMs);
|
||
}, []);
|
||
|
||
const [poisonDraftLifeMs, setPoisonDraftLifeMs] = useState(1600);
|
||
const poisonDraftLifeMsRef = useRef(poisonDraftLifeMs);
|
||
poisonDraftLifeMsRef.current = poisonDraftLifeMs;
|
||
useEffect(() => {
|
||
void getPoisonCloudEffectLifeMs().then(setPoisonDraftLifeMs);
|
||
}, []);
|
||
|
||
const hasFireOnScene = useMemo(
|
||
() => Boolean(fxState?.instances.some((i) => i.type === 'fire' && i.points.length > 0)),
|
||
[fxState?.instances],
|
||
);
|
||
const hasRainOnScene = useMemo(
|
||
() => Boolean(fxState?.instances.some((i) => i.type === 'rain' && i.points.length > 0)),
|
||
[fxState?.instances],
|
||
);
|
||
|
||
useEffect(() => {
|
||
setFireAmbientActive(hasFireOnScene);
|
||
return () => {
|
||
setFireAmbientActive(false);
|
||
};
|
||
}, [hasFireOnScene]);
|
||
|
||
useEffect(() => {
|
||
setRainAmbientActive(hasRainOnScene);
|
||
return () => {
|
||
setRainAmbientActive(false);
|
||
};
|
||
}, [hasRainOnScene]);
|
||
|
||
const project = session?.project ?? null;
|
||
const currentGraphNodeId = project?.currentGraphNodeId ?? null;
|
||
const currentHistoryIdx = currentGraphNodeId != null ? history.lastIndexOf(currentGraphNodeId) : -1;
|
||
const currentScene =
|
||
project && session?.currentSceneId ? project.scenes[session.currentSceneId] : undefined;
|
||
const isVideoPreviewScene = currentScene?.previewAssetType === 'video';
|
||
const isDarkenScene = Boolean(currentScene?.darkenScene) && !isVideoPreviewScene;
|
||
const sceneDescription = currentScene?.description ?? '';
|
||
const hasSceneDescription = !isSceneDescriptionEmpty(sceneDescription);
|
||
const sceneAudioRefs = useMemo(() => currentScene?.media.audios ?? [], [currentScene]);
|
||
// Keep this memo as narrow as possible: project changes on scene switch,
|
||
// but campaign audio list/config often does not.
|
||
const campaignAudioRefs = useMemo(() => project?.campaignAudios ?? [], [project?.campaignAudios]);
|
||
const allowCampaignAudio = !sceneAudioRefs.some((a) => a.autoplay);
|
||
allowCampaignAudioRef.current = allowCampaignAudio;
|
||
|
||
const campaignAudioSpecKey = useMemo(
|
||
() =>
|
||
campaignAudioRefs.map((r) => `${r.assetId}:${r.loop ? '1' : '0'}:${r.autoplay ? '1' : '0'}`).join('|'),
|
||
[campaignAudioRefs],
|
||
);
|
||
|
||
const sceneAudios = useMemo(() => {
|
||
if (!project) return [];
|
||
return sceneAudioRefs
|
||
.map((r) => {
|
||
const a = project.assets[r.assetId];
|
||
return a?.type === 'audio' ? { ref: r, asset: a } : null;
|
||
})
|
||
.filter((x): x is { ref: (typeof sceneAudioRefs)[number]; asset: NonNullable<typeof x>['asset'] } =>
|
||
Boolean(x),
|
||
);
|
||
}, [project, sceneAudioRefs]);
|
||
|
||
const campaignAudios = useMemo(() => {
|
||
if (!project) return [];
|
||
return campaignAudioRefs
|
||
.map((r) => {
|
||
const a = project.assets[r.assetId];
|
||
return a?.type === 'audio' ? { ref: r, asset: a } : null;
|
||
})
|
||
.filter((x): x is { ref: (typeof campaignAudioRefs)[number]; asset: NonNullable<typeof x>['asset'] } =>
|
||
Boolean(x),
|
||
);
|
||
}, [campaignAudioRefs, project]);
|
||
|
||
useEffect(() => {
|
||
sceneAudioLoadRunRef.current += 1;
|
||
const runId = sceneAudioLoadRunRef.current;
|
||
|
||
const oldEls = new Map(sceneAudioElsRef.current);
|
||
sceneAudioElsRef.current = new Map();
|
||
sceneAudioMetaRef.current.clear();
|
||
setSceneAudioStateTick((x) => x + 1);
|
||
|
||
const FADE_OUT_MS = 450;
|
||
const fadeOutCtl = { raf: 0, cancelled: false };
|
||
const finishFadeOut = (): void => {
|
||
for (const [id, el] of oldEls) {
|
||
try {
|
||
el.pause();
|
||
el.currentTime = 0;
|
||
applyAudioGain(el, sceneAudioGainRef.current, id);
|
||
} catch {
|
||
// ignore
|
||
}
|
||
}
|
||
};
|
||
if (oldEls.size > 0) {
|
||
const startVol = new Map<string, number>();
|
||
for (const [id, el] of oldEls) {
|
||
startVol.set(id, el.volume);
|
||
}
|
||
const t0 = performance.now();
|
||
const tickOut = (now: number): void => {
|
||
if (fadeOutCtl.cancelled || audioUnmountRef.current) {
|
||
finishFadeOut();
|
||
return;
|
||
}
|
||
const u = Math.min(1, (now - t0) / FADE_OUT_MS);
|
||
for (const [id, el] of oldEls) {
|
||
try {
|
||
const v0 = startVol.get(id) ?? 1;
|
||
el.volume = v0 * (1 - u);
|
||
} catch {
|
||
// ignore
|
||
}
|
||
}
|
||
if (u < 1) {
|
||
fadeOutCtl.raf = window.requestAnimationFrame(tickOut);
|
||
} else {
|
||
finishFadeOut();
|
||
}
|
||
};
|
||
fadeOutCtl.raf = window.requestAnimationFrame(tickOut);
|
||
}
|
||
|
||
if (!project || !currentScene) {
|
||
return () => {
|
||
fadeOutCtl.cancelled = true;
|
||
window.cancelAnimationFrame(fadeOutCtl.raf);
|
||
};
|
||
}
|
||
|
||
const FADE_IN_MS = 550;
|
||
void (async () => {
|
||
const loaded: { ref: (typeof sceneAudioRefs)[number]; el: HTMLAudioElement }[] = [];
|
||
for (const item of sceneAudioRefs) {
|
||
const r = await api.invoke(ipcChannels.project.assetFileUrl, { assetId: item.assetId });
|
||
if (sceneAudioLoadRunRef.current !== runId) return;
|
||
if (!r.url) continue;
|
||
const el = new Audio(r.url);
|
||
el.loop = item.loop;
|
||
el.preload = 'auto';
|
||
if (item.autoplay) el.volume = 0;
|
||
else applyAudioGain(el, sceneAudioGainRef.current, item.assetId);
|
||
sceneAudioMetaRef.current.set(item.assetId, { lastPlayError: null });
|
||
el.addEventListener('play', () => setSceneAudioStateTick((x) => x + 1));
|
||
el.addEventListener('pause', () => setSceneAudioStateTick((x) => x + 1));
|
||
el.addEventListener('ended', () => setSceneAudioStateTick((x) => x + 1));
|
||
el.addEventListener('canplay', () => setSceneAudioStateTick((x) => x + 1));
|
||
el.addEventListener('error', () => setSceneAudioStateTick((x) => x + 1));
|
||
loaded.push({ ref: item, el });
|
||
sceneAudioElsRef.current.set(item.assetId, el);
|
||
}
|
||
setSceneAudioStateTick((x) => x + 1);
|
||
for (const { ref, el } of loaded) {
|
||
if (sceneAudioLoadRunRef.current !== runId) {
|
||
try {
|
||
el.pause();
|
||
el.currentTime = 0;
|
||
applyAudioGain(el, sceneAudioGainRef.current, ref.assetId);
|
||
} catch {
|
||
// ignore
|
||
}
|
||
continue;
|
||
}
|
||
if (!ref.autoplay) continue;
|
||
try {
|
||
await el.play();
|
||
} catch {
|
||
const m = sceneAudioMetaRef.current.get(ref.assetId) ?? { lastPlayError: null };
|
||
sceneAudioMetaRef.current.set(ref.assetId, {
|
||
...m,
|
||
lastPlayError: tRef.current('control.audioAutoplayBlocked'),
|
||
});
|
||
setSceneAudioStateTick((x) => x + 1);
|
||
try {
|
||
applyAudioGain(el, sceneAudioGainRef.current, ref.assetId);
|
||
} catch {
|
||
// ignore
|
||
}
|
||
continue;
|
||
}
|
||
if (sceneAudioLoadRunRef.current !== runId || audioUnmountRef.current) {
|
||
try {
|
||
applyAudioGain(el, sceneAudioGainRef.current, ref.assetId);
|
||
} catch {
|
||
// ignore
|
||
}
|
||
continue;
|
||
}
|
||
const tIn0 = performance.now();
|
||
const tickIn = (now: number): void => {
|
||
if (sceneAudioLoadRunRef.current !== runId || audioUnmountRef.current) {
|
||
try {
|
||
applyAudioGain(el, sceneAudioGainRef.current, ref.assetId);
|
||
} catch {
|
||
// ignore
|
||
}
|
||
return;
|
||
}
|
||
const u = Math.min(1, (now - tIn0) / FADE_IN_MS);
|
||
try {
|
||
applyAudioGain(el, sceneAudioGainRef.current, ref.assetId, u);
|
||
} catch {
|
||
// ignore
|
||
}
|
||
if (u < 1) window.requestAnimationFrame(tickIn);
|
||
};
|
||
window.requestAnimationFrame(tickIn);
|
||
}
|
||
})();
|
||
|
||
return () => {
|
||
fadeOutCtl.cancelled = true;
|
||
window.cancelAnimationFrame(fadeOutCtl.raf);
|
||
};
|
||
}, [api, currentScene, project, sceneAudioRefs]);
|
||
|
||
// Campaign elements: lifecycle depends only on campaign track list/config, not scene or allowCampaignAudio
|
||
// (scene music uses allowCampaignAudioRef + separate pause/resume effect).
|
||
// Spec is encoded in campaignAudioSpecKey; campaignAudioRefs is intentionally omitted to avoid scene-switch churn.
|
||
useEffect(() => {
|
||
campaignAudioLoadRunRef.current += 1;
|
||
const runId = campaignAudioLoadRunRef.current;
|
||
|
||
const oldEls = new Map(campaignAudioElsRef.current);
|
||
campaignAudioElsRef.current = new Map();
|
||
campaignAudioMetaRef.current.clear();
|
||
setCampaignAudioStateTick((x) => x + 1);
|
||
|
||
for (const [id, el] of oldEls) {
|
||
try {
|
||
el.pause();
|
||
applyAudioGain(el, campaignAudioGainRef.current, id);
|
||
} catch {
|
||
// ignore
|
||
}
|
||
}
|
||
|
||
if (campaignAudioSpecKey === '') {
|
||
return;
|
||
}
|
||
|
||
void (async () => {
|
||
const loaded: { ref: (typeof campaignAudioRefs)[number]; el: HTMLAudioElement }[] = [];
|
||
for (const item of campaignAudioRefs) {
|
||
const r = await api.invoke(ipcChannels.project.assetFileUrl, { assetId: item.assetId });
|
||
if (campaignAudioLoadRunRef.current !== runId) return;
|
||
if (!r.url) continue;
|
||
const el = new Audio(r.url);
|
||
el.loop = item.loop;
|
||
el.preload = 'auto';
|
||
if (item.autoplay) el.volume = 0;
|
||
else applyAudioGain(el, campaignAudioGainRef.current, item.assetId);
|
||
campaignAudioMetaRef.current.set(item.assetId, { lastPlayError: null });
|
||
el.addEventListener('play', () => setCampaignAudioStateTick((x) => x + 1));
|
||
el.addEventListener('pause', () => setCampaignAudioStateTick((x) => x + 1));
|
||
el.addEventListener('ended', () => setCampaignAudioStateTick((x) => x + 1));
|
||
el.addEventListener('canplay', () => setCampaignAudioStateTick((x) => x + 1));
|
||
el.addEventListener('error', () => setCampaignAudioStateTick((x) => x + 1));
|
||
loaded.push({ ref: item, el });
|
||
campaignAudioElsRef.current.set(item.assetId, el);
|
||
}
|
||
setCampaignAudioStateTick((x) => x + 1);
|
||
|
||
if (!allowCampaignAudioRef.current) return;
|
||
|
||
for (const { ref, el } of loaded) {
|
||
if (campaignAudioLoadRunRef.current !== runId) return;
|
||
if (!ref.autoplay) continue;
|
||
try {
|
||
await el.play();
|
||
} catch {
|
||
const m = campaignAudioMetaRef.current.get(ref.assetId) ?? { lastPlayError: null };
|
||
campaignAudioMetaRef.current.set(ref.assetId, {
|
||
...m,
|
||
lastPlayError: tRef.current('control.audioAutoplayBlocked'),
|
||
});
|
||
setCampaignAudioStateTick((x) => x + 1);
|
||
try {
|
||
applyAudioGain(el, campaignAudioGainRef.current, ref.assetId);
|
||
} catch {
|
||
// ignore
|
||
}
|
||
continue;
|
||
}
|
||
if (campaignAudioLoadRunRef.current !== runId || audioUnmountRef.current) {
|
||
try {
|
||
applyAudioGain(el, campaignAudioGainRef.current, ref.assetId);
|
||
} catch {
|
||
// ignore
|
||
}
|
||
continue;
|
||
}
|
||
const tIn0 = performance.now();
|
||
const tickIn = (now: number): void => {
|
||
if (campaignAudioLoadRunRef.current !== runId || audioUnmountRef.current) {
|
||
try {
|
||
applyAudioGain(el, campaignAudioGainRef.current, ref.assetId);
|
||
} catch {
|
||
// ignore
|
||
}
|
||
return;
|
||
}
|
||
const u = Math.min(1, (now - tIn0) / 550);
|
||
try {
|
||
applyAudioGain(el, campaignAudioGainRef.current, ref.assetId, u);
|
||
} catch {
|
||
// ignore
|
||
}
|
||
if (u < 1) window.requestAnimationFrame(tickIn);
|
||
};
|
||
window.requestAnimationFrame(tickIn);
|
||
}
|
||
})();
|
||
// Deps: api + campaignAudioSpecKey only; list iteration uses current campaignAudioRefs (stable while spec is stable).
|
||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||
}, [api, campaignAudioSpecKey]);
|
||
|
||
useEffect(() => {
|
||
if (allowCampaignAudio) {
|
||
const snap = campaignResumeAfterSceneRef.current;
|
||
campaignResumeAfterSceneRef.current = null;
|
||
if (snap && snap.size > 0) {
|
||
void (async () => {
|
||
for (const [assetId, wasPlaying] of snap) {
|
||
if (!wasPlaying) continue;
|
||
const el = campaignAudioElsRef.current.get(assetId) ?? null;
|
||
if (!el) continue;
|
||
try {
|
||
// If a track was created with autoplay volume ramp but never started yet,
|
||
// ensure it is audible on resume.
|
||
if (el.volume === 0) applyAudioGain(el, campaignAudioGainRef.current, assetId);
|
||
await el.play();
|
||
} catch {
|
||
// ignore; user can press play
|
||
}
|
||
}
|
||
setCampaignAudioStateTick((x) => x + 1);
|
||
})();
|
||
}
|
||
// If we entered a scene that allows campaign audio and there was no "resume snapshot"
|
||
// (e.g. first scene had autoplay scene music so campaign autoplay was blocked),
|
||
// start campaign tracks that have autoplay enabled.
|
||
if (!snap || snap.size === 0) {
|
||
void (async () => {
|
||
for (const ref of campaignAudioRefs) {
|
||
if (!ref.autoplay) continue;
|
||
const el = campaignAudioElsRef.current.get(ref.assetId) ?? null;
|
||
if (!el) continue;
|
||
if (!el.paused) continue;
|
||
try {
|
||
el.volume = 0;
|
||
} catch {
|
||
// ignore
|
||
}
|
||
try {
|
||
await el.play();
|
||
} catch {
|
||
// ignore; user can press play
|
||
continue;
|
||
}
|
||
const tIn0 = performance.now();
|
||
const tickIn = (now: number): void => {
|
||
if (!allowCampaignAudioRef.current || audioUnmountRef.current) return;
|
||
const u = Math.min(1, (now - tIn0) / 550);
|
||
try {
|
||
applyAudioGain(el, campaignAudioGainRef.current, ref.assetId, u);
|
||
} catch {
|
||
// ignore
|
||
}
|
||
if (u < 1) window.requestAnimationFrame(tickIn);
|
||
};
|
||
window.requestAnimationFrame(tickIn);
|
||
}
|
||
setCampaignAudioStateTick((x) => x + 1);
|
||
})();
|
||
}
|
||
return;
|
||
}
|
||
// Scene has its own audio: remember what was playing, then pause campaign. (keep currentTime)
|
||
const snap = new Map<string, boolean>();
|
||
for (const [assetId, el] of campaignAudioElsRef.current) {
|
||
snap.set(assetId, !el.paused);
|
||
}
|
||
campaignResumeAfterSceneRef.current = snap;
|
||
for (const el of campaignAudioElsRef.current.values()) {
|
||
try {
|
||
el.pause();
|
||
} catch {
|
||
// ignore
|
||
}
|
||
}
|
||
setCampaignAudioStateTick((x) => x + 1);
|
||
// Intentionally not depending on campaignAudioRefs: this effect is about scene-driven pausing/resuming.
|
||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||
}, [allowCampaignAudio]);
|
||
|
||
useEffect(() => {
|
||
const host = previewHostRef.current;
|
||
if (!host) return;
|
||
const update = () => {
|
||
const r = host.getBoundingClientRect();
|
||
setPreviewSize({ w: Math.max(1, r.width), h: Math.max(1, r.height) });
|
||
};
|
||
update();
|
||
const ro = new ResizeObserver(update);
|
||
ro.observe(host);
|
||
return () => ro.disconnect();
|
||
}, []);
|
||
|
||
const activeSceneViewCamera: SceneViewCamera = sceneViewDraft ??
|
||
(sceneView
|
||
? { scale: sceneView.scale, ox: sceneView.ox, oy: sceneView.oy }
|
||
: DEFAULT_SCENE_VIEW_CAMERA);
|
||
sceneViewRef.current = activeSceneViewCamera;
|
||
|
||
useEffect(() => {
|
||
if (scenePanRef.current) return;
|
||
setSceneViewDraft(null);
|
||
}, [sceneView?.revision]);
|
||
|
||
function publishSceneViewCamera(camera: SceneViewCamera): void {
|
||
setSceneViewDraft(camera);
|
||
sceneViewRef.current = camera;
|
||
pendingSceneViewRef.current = camera;
|
||
if (sceneViewPublishRafRef.current !== 0) return;
|
||
sceneViewPublishRafRef.current = requestAnimationFrame(() => {
|
||
sceneViewPublishRafRef.current = 0;
|
||
const next = pendingSceneViewRef.current;
|
||
if (!next) return;
|
||
void sceneViewApi.dispatch({ kind: 'set', camera: next });
|
||
});
|
||
}
|
||
const publishSceneViewCameraRef = useRef(publishSceneViewCamera);
|
||
publishSceneViewCameraRef.current = publishSceneViewCamera;
|
||
|
||
useEffect(() => {
|
||
const onKeyDown = (e: KeyboardEvent) => {
|
||
if (e.code === 'Space') spaceDownRef.current = true;
|
||
};
|
||
const onKeyUp = (e: KeyboardEvent) => {
|
||
if (e.code === 'Space') spaceDownRef.current = false;
|
||
};
|
||
window.addEventListener('keydown', onKeyDown);
|
||
window.addEventListener('keyup', onKeyUp);
|
||
return () => {
|
||
window.removeEventListener('keydown', onKeyDown);
|
||
window.removeEventListener('keyup', onKeyUp);
|
||
};
|
||
}, []);
|
||
|
||
useEffect(() => {
|
||
const frame = previewFrameRef.current;
|
||
if (!frame || isVideoPreviewScene) return;
|
||
const onWheel = (e: WheelEvent) => {
|
||
e.preventDefault();
|
||
const host = previewHostRef.current;
|
||
const cr = previewContentRectRef.current;
|
||
if (!host || !cr) return;
|
||
const r = host.getBoundingClientRect();
|
||
const cam = sceneViewRef.current;
|
||
const containW = cr.w / Math.max(1e-6, cam.scale);
|
||
const containH = cr.h / Math.max(1e-6, cam.scale);
|
||
const factor = e.deltaY < 0 ? 1.12 : 1 / 1.12;
|
||
const next = sceneViewZoomAt(cam, {
|
||
hostW: r.width,
|
||
hostH: r.height,
|
||
containW,
|
||
containH,
|
||
hostX: e.clientX - r.left,
|
||
hostY: e.clientY - r.top,
|
||
factor,
|
||
});
|
||
publishSceneViewCameraRef.current(next);
|
||
};
|
||
frame.addEventListener('wheel', onWheel, { passive: false });
|
||
return () => frame.removeEventListener('wheel', onWheel);
|
||
}, [isVideoPreviewScene]);
|
||
|
||
useEffect(() => {
|
||
return () => {
|
||
if (sceneViewPublishRafRef.current !== 0) {
|
||
cancelAnimationFrame(sceneViewPublishRafRef.current);
|
||
sceneViewPublishRafRef.current = 0;
|
||
}
|
||
};
|
||
}, []);
|
||
|
||
function audioStatus(group: 'scene' | 'campaign', assetId: string): { label: string; detail?: string } {
|
||
const el =
|
||
group === 'scene'
|
||
? (sceneAudioElsRef.current.get(assetId) ?? null)
|
||
: (campaignAudioElsRef.current.get(assetId) ?? null);
|
||
if (!el) return { label: t('control.audioNoUrl'), detail: t('control.audioNoUrlDetail') };
|
||
const meta =
|
||
group === 'scene'
|
||
? (sceneAudioMetaRef.current.get(assetId) ?? { lastPlayError: null })
|
||
: (campaignAudioMetaRef.current.get(assetId) ?? { lastPlayError: null });
|
||
if (meta.lastPlayError) return { label: t('control.audioBlocked'), detail: meta.lastPlayError };
|
||
if (el.error)
|
||
return {
|
||
label: t('control.audioError'),
|
||
detail: t('control.audioMediaError', { code: String(el.error.code) }),
|
||
};
|
||
if (el.readyState < 2) return { label: t('control.audioLoading') };
|
||
if (!el.paused) return { label: t('control.audioPlaying') };
|
||
if (el.currentTime > 0) return { label: t('control.audioPaused') };
|
||
return { label: t('control.audioStopped') };
|
||
}
|
||
const nextScenes = useMemo(() => {
|
||
if (!project) return [];
|
||
if (!currentGraphNodeId) return [];
|
||
const outgoing = project.sceneGraphEdges
|
||
.filter((e) => e.sourceGraphNodeId === currentGraphNodeId)
|
||
.map((e) => {
|
||
const n = project.sceneGraphNodes.find((x) => x.id === e.targetGraphNodeId);
|
||
return n ? { graphNodeId: e.targetGraphNodeId, sceneId: n.sceneId } : null;
|
||
})
|
||
.filter((x): x is { graphNodeId: GraphNodeId; sceneId: SceneId } => Boolean(x));
|
||
return outgoing
|
||
.map((o) => ({ graphNodeId: o.graphNodeId, scene: project.scenes[o.sceneId] }))
|
||
.filter((x): x is { graphNodeId: GraphNodeId; scene: Scene } => x.scene !== undefined);
|
||
}, [currentGraphNodeId, project]);
|
||
|
||
const isInSideStoryline = useMemo(() => {
|
||
if (!project || !currentGraphNodeId) return false;
|
||
return isNodeInSideStoryline(project.sceneGraphNodes, project.sceneGraphEdges, currentGraphNodeId);
|
||
}, [currentGraphNodeId, project]);
|
||
|
||
const sideStoryLines = useMemo(() => {
|
||
if (!project) return [];
|
||
return listSideStoryStarts(project.sceneGraphNodes).map((gn) => {
|
||
const scene = project.scenes[gn.sceneId];
|
||
return {
|
||
graphNodeId: gn.id,
|
||
title: gn.sideStoryLineTitle.trim() || scene?.title || t('control.unnamed'),
|
||
scene,
|
||
};
|
||
});
|
||
}, [project, t]);
|
||
|
||
const returnSceneTitle = useMemo(() => {
|
||
if (!project || !mainStoryReturnGraphNodeId) return '';
|
||
const gn = project.sceneGraphNodes.find((n) => n.id === mainStoryReturnGraphNodeId);
|
||
if (!gn) return '';
|
||
return project.scenes[gn.sceneId]?.title || t('control.unnamed');
|
||
}, [mainStoryReturnGraphNodeId, project, t]);
|
||
|
||
const enterSideStoryline = (startGraphNodeId: GraphNodeId) => {
|
||
if (!project) return;
|
||
if (
|
||
currentGraphNodeId &&
|
||
mainStoryReturnRef.current === null &&
|
||
isNodeInMainStoryline(project.sceneGraphNodes, project.sceneGraphEdges, currentGraphNodeId)
|
||
) {
|
||
mainStoryReturnRef.current = currentGraphNodeId;
|
||
setMainStoryReturnGraphNodeId(currentGraphNodeId);
|
||
}
|
||
void api.invoke(ipcChannels.project.setCurrentGraphNode, { graphNodeId: startGraphNodeId });
|
||
};
|
||
|
||
const returnToMainStoryline = () => {
|
||
const ret = mainStoryReturnRef.current;
|
||
if (!ret) return;
|
||
mainStoryReturnRef.current = null;
|
||
setMainStoryReturnGraphNodeId(null);
|
||
void api.invoke(ipcChannels.project.setCurrentGraphNode, { graphNodeId: ret });
|
||
};
|
||
|
||
const showReturnToMain = isInSideStoryline && mainStoryReturnGraphNodeId !== null;
|
||
const branchOptionOffset = showReturnToMain ? 1 : 0;
|
||
|
||
const tool = fxState?.tool ?? { tool: 'fog', radiusN: 0.08, intensity: 0.6 };
|
||
const toolRef = useRef(tool);
|
||
toolRef.current = tool;
|
||
|
||
function layoutBrushCursor(): void {
|
||
const el = brushCursorElRef.current;
|
||
const p = cursorPosRef.current;
|
||
const cr = previewContentRectRef.current;
|
||
const ps = previewSizeRef.current;
|
||
const t = toolRef.current;
|
||
if (!el) return;
|
||
if (!p) {
|
||
el.style.visibility = 'hidden';
|
||
return;
|
||
}
|
||
el.style.visibility = 'visible';
|
||
const ox = cr ? cr.x : 0;
|
||
const oy = cr ? cr.y : 0;
|
||
const cw = cr ? cr.w : ps.w;
|
||
const ch = cr ? cr.h : ps.h;
|
||
const minDim = Math.min(cw, ch);
|
||
const size = Math.max(2, t.radiusN * minDim * 2);
|
||
el.style.left = `${String(ox + p.x * cw)}px`;
|
||
el.style.top = `${String(oy + p.y * ch)}px`;
|
||
el.style.width = `${String(size)}px`;
|
||
el.style.height = `${String(size)}px`;
|
||
}
|
||
|
||
function buildDraftEffectInstance(): EffectInstance | null {
|
||
const b = brushRef.current;
|
||
if (!b) return null;
|
||
const meta = draftMetaRef.current ?? { createdAtMs: Date.now(), seed: 12345 };
|
||
const { createdAtMs, seed } = meta;
|
||
const t = toolRef.current;
|
||
if (b.tool === 'fog' && b.points && b.points.length > 0) {
|
||
return {
|
||
id: '__draft__',
|
||
type: 'fog',
|
||
seed,
|
||
createdAtMs,
|
||
points: b.points,
|
||
radiusN: t.radiusN,
|
||
opacity: Math.max(0.05, Math.min(0.6, t.intensity * 0.7)),
|
||
lifetimeMs: null,
|
||
};
|
||
}
|
||
if (b.tool === 'fire' && b.points && b.points.length > 0) {
|
||
return {
|
||
id: '__draft__',
|
||
type: 'fire',
|
||
seed,
|
||
createdAtMs,
|
||
points: b.points,
|
||
radiusN: t.radiusN,
|
||
opacity: 1,
|
||
lifetimeMs: null,
|
||
};
|
||
}
|
||
if (b.tool === 'rain' && b.points && b.points.length > 0) {
|
||
return {
|
||
id: '__draft__',
|
||
type: 'rain',
|
||
seed,
|
||
createdAtMs,
|
||
points: b.points,
|
||
radiusN: t.radiusN,
|
||
opacity: Math.max(0.08, Math.min(0.65, t.intensity * 0.85)),
|
||
lifetimeMs: null,
|
||
};
|
||
}
|
||
if (b.tool === 'water' && b.points && b.points.length > 0) {
|
||
return {
|
||
id: '__draft__',
|
||
type: 'water',
|
||
seed,
|
||
createdAtMs,
|
||
points: b.points,
|
||
radiusN: t.radiusN,
|
||
opacity: Math.max(0.06, Math.min(0.55, t.intensity * 0.72)),
|
||
lifetimeMs: null,
|
||
};
|
||
}
|
||
if (b.tool === 'darkness' && b.points && b.points.length > 0) {
|
||
const last = b.points[b.points.length - 1];
|
||
if (last === undefined) return null;
|
||
return {
|
||
id: '__draft__',
|
||
type: 'darkness',
|
||
seed,
|
||
createdAtMs,
|
||
at: { x: last.x, y: last.y },
|
||
intensity: Math.max(0.8, Math.min(1.25, t.intensity * 1.15)),
|
||
lifetimeMs: darknessDraftLifeMsRef.current,
|
||
};
|
||
}
|
||
if (b.tool === 'lightning' && b.startN && b.points && b.points.length > 0) {
|
||
const last = b.points[b.points.length - 1];
|
||
if (last === undefined) return null;
|
||
return {
|
||
id: '__draft__',
|
||
type: 'lightning',
|
||
seed,
|
||
createdAtMs,
|
||
start: { x: last.x, y: 0 },
|
||
end: { x: last.x, y: last.y },
|
||
widthN: Math.max(0.014, t.radiusN * 1.15),
|
||
intensity: Math.max(1, Math.min(1.4, t.intensity * 1.45)),
|
||
lifetimeMs: LIGHTNING_EFFECT_MS,
|
||
};
|
||
}
|
||
if (b.tool === 'sunbeam' && b.startN && b.points && b.points.length > 0) {
|
||
const last = b.points[b.points.length - 1];
|
||
if (last === undefined) return null;
|
||
return {
|
||
id: '__draft__',
|
||
type: 'sunbeam',
|
||
seed,
|
||
createdAtMs,
|
||
start: { x: last.x, y: 0 },
|
||
end: { x: last.x, y: last.y },
|
||
widthN: Math.max(0.012, t.radiusN * 0.95),
|
||
intensity: Math.max(0.95, Math.min(1.25, t.intensity * 1.4)),
|
||
lifetimeMs: sunbeamDraftLifeMsRef.current,
|
||
};
|
||
}
|
||
if (b.tool === 'poisonCloud' && b.points && b.points.length > 0) {
|
||
const last = b.points[b.points.length - 1];
|
||
if (last === undefined) return null;
|
||
return {
|
||
id: '__draft__',
|
||
type: 'poisonCloud',
|
||
seed,
|
||
createdAtMs,
|
||
at: { x: last.x, y: last.y },
|
||
radiusN: Math.max(0.03, t.radiusN * 0.95),
|
||
intensity: Math.max(0.75, Math.min(1.2, t.intensity * 1.15)),
|
||
lifetimeMs: poisonDraftLifeMsRef.current,
|
||
};
|
||
}
|
||
if (b.tool === 'freeze' && b.points && b.points.length > 0) {
|
||
const last = b.points[b.points.length - 1];
|
||
if (last === undefined) return null;
|
||
return {
|
||
id: '__draft__',
|
||
type: 'freeze',
|
||
seed,
|
||
createdAtMs,
|
||
at: { x: last.x, y: last.y },
|
||
intensity: Math.max(0.8, Math.min(1.25, t.intensity * 1.15)),
|
||
lifetimeMs: freezeDraftLifeMsRef.current,
|
||
};
|
||
}
|
||
return null;
|
||
}
|
||
|
||
function pushDraftToPixi(): void {
|
||
effectsOverlayRef.current?.setDraft(buildDraftEffectInstance());
|
||
}
|
||
|
||
function clearDraftFromPixi(): void {
|
||
draftMetaRef.current = null;
|
||
effectsOverlayRef.current?.setDraft(null);
|
||
}
|
||
|
||
function scheduleDraftRepaint(): void {
|
||
if (draftPaintRafRef.current !== 0) return;
|
||
draftPaintRafRef.current = requestAnimationFrame(() => {
|
||
draftPaintRafRef.current = 0;
|
||
pushDraftToPixi();
|
||
const b = brushRef.current;
|
||
if (b?.tool === 'exploreBrush' && b.points) {
|
||
void sd.dispatch({
|
||
kind: 'draft.set',
|
||
draft: { points: b.points, radiusN: toolRef.current.radiusN },
|
||
});
|
||
}
|
||
});
|
||
}
|
||
|
||
useLayoutEffect(() => {
|
||
layoutBrushCursor();
|
||
}, [tool.radiusN, previewContentRect, previewSize.w, previewSize.h]);
|
||
|
||
useEffect(() => {
|
||
return () => {
|
||
if (draftPaintRafRef.current !== 0) {
|
||
cancelAnimationFrame(draftPaintRafRef.current);
|
||
}
|
||
};
|
||
}, []);
|
||
|
||
function toNPoint(e: React.PointerEvent): { x: number; y: number } | null {
|
||
const host = previewHostRef.current;
|
||
if (!host) return null;
|
||
const r = host.getBoundingClientRect();
|
||
const cr = previewContentRectRef.current;
|
||
const ox = cr ? cr.x : 0;
|
||
const oy = cr ? cr.y : 0;
|
||
const cw = cr ? cr.w : r.width;
|
||
const ch = cr ? cr.h : r.height;
|
||
const x = (e.clientX - (r.left + ox)) / Math.max(1, cw);
|
||
const y = (e.clientY - (r.top + oy)) / Math.max(1, ch);
|
||
return { x: Math.max(0, Math.min(1, x)), y: Math.max(0, Math.min(1, y)) };
|
||
}
|
||
|
||
function dispatchFieldEraserPoints(
|
||
points: { x: number; y: number; tMs: number }[],
|
||
opts?: {
|
||
types?: readonly ('fog' | 'fire' | 'rain' | 'water')[];
|
||
hitMode?: 'inclusive' | 'brush';
|
||
},
|
||
): void {
|
||
if (points.length === 0) return;
|
||
void fx.dispatch({
|
||
kind: 'field.erase',
|
||
points,
|
||
radiusN: toolRef.current.radiusN,
|
||
...(opts?.types ? { types: opts.types } : {}),
|
||
...(opts?.hitMode ? { hitMode: opts.hitMode } : {}),
|
||
});
|
||
}
|
||
|
||
/** Вода/дождь гасят огонь только под самой кистью (не «аура» радиуса огня). */
|
||
function extinguishFireAlong(points: { x: number; y: number; tMs: number }[]): void {
|
||
dispatchFieldEraserPoints(points, { types: ['fire'], hitMode: 'brush' });
|
||
}
|
||
|
||
function tryEraseActionEffect(p: { x: number; y: number }): void {
|
||
const id = pickEraseTargetId(fxState?.instances ?? [], p, toolRef.current.radiusN);
|
||
if (id) void fx.dispatch({ kind: 'instance.remove', id });
|
||
}
|
||
|
||
async function commitStroke(): Promise<void> {
|
||
if (isVideoPreviewScene) {
|
||
brushRef.current = null;
|
||
clearDraftFromPixi();
|
||
return;
|
||
}
|
||
if (!fxState) return;
|
||
const b = brushRef.current;
|
||
if (!b) return;
|
||
if (b.tool === 'eraser') {
|
||
brushRef.current = null;
|
||
return;
|
||
}
|
||
const createdAtMs = Date.now();
|
||
const seed = Math.floor(Math.random() * 1_000_000_000);
|
||
|
||
if (b.tool === 'fog' && b.points && b.points.length > 0) {
|
||
await fx.dispatch({
|
||
kind: 'instance.add',
|
||
instance: {
|
||
id: `fog_${String(createdAtMs)}_${String(seed)}`,
|
||
type: 'fog',
|
||
seed,
|
||
createdAtMs,
|
||
points: b.points,
|
||
radiusN: tool.radiusN,
|
||
opacity: Math.max(0.05, Math.min(0.9, tool.intensity)),
|
||
lifetimeMs: null,
|
||
},
|
||
});
|
||
}
|
||
if (b.tool === 'fire' && b.points && b.points.length > 0) {
|
||
await fx.dispatch({
|
||
kind: 'instance.add',
|
||
instance: {
|
||
id: `fire_${String(createdAtMs)}_${String(seed)}`,
|
||
type: 'fire',
|
||
seed,
|
||
createdAtMs,
|
||
points: b.points,
|
||
radiusN: tool.radiusN,
|
||
opacity: 1,
|
||
lifetimeMs: null,
|
||
},
|
||
});
|
||
}
|
||
if (b.tool === 'rain' && b.points && b.points.length > 0) {
|
||
await fx.dispatch({
|
||
kind: 'field.erase',
|
||
points: b.points,
|
||
radiusN: tool.radiusN,
|
||
types: ['fire'],
|
||
hitMode: 'brush',
|
||
});
|
||
await fx.dispatch({
|
||
kind: 'instance.add',
|
||
instance: {
|
||
id: `rain_${String(createdAtMs)}_${String(seed)}`,
|
||
type: 'rain',
|
||
seed,
|
||
createdAtMs,
|
||
points: b.points,
|
||
radiusN: tool.radiusN,
|
||
opacity: Math.max(0.08, Math.min(0.9, tool.intensity)),
|
||
lifetimeMs: null,
|
||
},
|
||
});
|
||
}
|
||
if (b.tool === 'water' && b.points && b.points.length > 0) {
|
||
await fx.dispatch({
|
||
kind: 'field.erase',
|
||
points: b.points,
|
||
radiusN: tool.radiusN,
|
||
types: ['fire'],
|
||
hitMode: 'brush',
|
||
});
|
||
await fx.dispatch({
|
||
kind: 'instance.add',
|
||
instance: {
|
||
id: `water_${String(createdAtMs)}_${String(seed)}`,
|
||
type: 'water',
|
||
seed,
|
||
createdAtMs,
|
||
points: b.points,
|
||
radiusN: tool.radiusN,
|
||
opacity: Math.max(0.06, Math.min(0.72, tool.intensity * 0.85)),
|
||
lifetimeMs: null,
|
||
},
|
||
});
|
||
}
|
||
if (b.tool === 'exploreBrush' && b.points && b.points.length > 0) {
|
||
await sd.dispatch({
|
||
kind: 'stroke.add',
|
||
stroke: {
|
||
id: `sd_${String(createdAtMs)}_${String(seed)}`,
|
||
seed,
|
||
createdAtMs,
|
||
points: b.points,
|
||
radiusN: tool.radiusN,
|
||
},
|
||
});
|
||
}
|
||
if (b.tool === 'darkness' && b.points && b.points.length > 0) {
|
||
const last = b.points[b.points.length - 1];
|
||
if (last === undefined) return;
|
||
const at = { x: last.x, y: last.y };
|
||
const darknessLifeMs = await getFreezeEffectLifeMs();
|
||
await fx.dispatch({
|
||
kind: 'instance.add',
|
||
instance: {
|
||
id: `dk_${String(createdAtMs)}_${String(seed)}`,
|
||
type: 'darkness',
|
||
seed,
|
||
createdAtMs,
|
||
at,
|
||
intensity: Math.max(0.8, Math.min(1.25, tool.intensity * 1.15)),
|
||
lifetimeMs: darknessLifeMs,
|
||
},
|
||
});
|
||
await fx.dispatch({
|
||
kind: 'instance.add',
|
||
instance: {
|
||
id: `sh_${String(createdAtMs)}_${String(seed)}`,
|
||
type: 'shadow',
|
||
seed: seed ^ 0x0d4a0001,
|
||
createdAtMs,
|
||
at,
|
||
radiusN: Math.max(0.03, tool.radiusN * 0.9),
|
||
opacity: 1,
|
||
lifetimeMs: null,
|
||
},
|
||
});
|
||
}
|
||
if (b.tool === 'lightning' && b.startN && b.points && b.points.length > 0) {
|
||
const last = b.points[b.points.length - 1];
|
||
if (last === undefined) return;
|
||
const end = { x: last.x, y: last.y };
|
||
const start = { x: end.x, y: 0 };
|
||
await fx.dispatch({
|
||
kind: 'instance.add',
|
||
instance: {
|
||
id: `lt_${String(createdAtMs)}_${String(seed)}`,
|
||
type: 'lightning',
|
||
seed,
|
||
createdAtMs,
|
||
start,
|
||
end,
|
||
widthN: Math.max(0.014, tool.radiusN * 1.15),
|
||
intensity: Math.max(1, Math.min(1.4, tool.intensity * 1.45)),
|
||
lifetimeMs: LIGHTNING_EFFECT_MS,
|
||
},
|
||
});
|
||
await fx.dispatch({
|
||
kind: 'instance.add',
|
||
instance: {
|
||
id: `sc_${String(createdAtMs)}_${String(seed)}`,
|
||
type: 'scorch',
|
||
seed: seed ^ 0x7a7a7a,
|
||
createdAtMs,
|
||
at: end,
|
||
radiusN: Math.max(0.04, tool.radiusN * 0.82),
|
||
opacity: 0.96,
|
||
lifetimeMs: 90_000,
|
||
},
|
||
});
|
||
playLightningEffectSound();
|
||
}
|
||
if (b.tool === 'sunbeam' && b.startN && b.points && b.points.length > 0) {
|
||
const last = b.points[b.points.length - 1];
|
||
if (last === undefined) return;
|
||
const end = { x: last.x, y: last.y };
|
||
const start = { x: end.x, y: 0 };
|
||
const sunbeamLifeMs = await getSunbeamEffectLifeMs();
|
||
await fx.dispatch({
|
||
kind: 'instance.add',
|
||
instance: {
|
||
id: `sb_${String(createdAtMs)}_${String(seed)}`,
|
||
type: 'sunbeam',
|
||
seed,
|
||
createdAtMs,
|
||
start,
|
||
end,
|
||
widthN: Math.max(0.012, tool.radiusN * 0.95),
|
||
intensity: Math.max(0.95, Math.min(1.25, tool.intensity * 1.4)),
|
||
lifetimeMs: sunbeamLifeMs,
|
||
},
|
||
});
|
||
playSunbeamEffectSound();
|
||
}
|
||
if (b.tool === 'poisonCloud' && b.points && b.points.length > 0) {
|
||
const last = b.points[b.points.length - 1];
|
||
if (last === undefined) return;
|
||
const at = { x: last.x, y: last.y };
|
||
const poisonLifeMs = await getPoisonCloudEffectLifeMs();
|
||
await fx.dispatch({
|
||
kind: 'instance.add',
|
||
instance: {
|
||
id: `pc_${String(createdAtMs)}_${String(seed)}`,
|
||
type: 'poisonCloud',
|
||
seed,
|
||
createdAtMs,
|
||
at,
|
||
radiusN: Math.max(0.03, tool.radiusN * 0.95),
|
||
intensity: Math.max(0.75, Math.min(1.2, tool.intensity * 1.15)),
|
||
lifetimeMs: poisonLifeMs,
|
||
},
|
||
});
|
||
void playPoisonCloudEffectSound(poisonLifeMs);
|
||
}
|
||
if (b.tool === 'freeze' && b.points && b.points.length > 0) {
|
||
const last = b.points[b.points.length - 1];
|
||
if (last === undefined) return;
|
||
const at = { x: last.x, y: last.y };
|
||
const freezeLifeMs = await getFreezeEffectLifeMs();
|
||
await fx.dispatch({
|
||
kind: 'instance.add',
|
||
instance: {
|
||
id: `fr_${String(createdAtMs)}_${String(seed)}`,
|
||
type: 'freeze',
|
||
seed,
|
||
createdAtMs,
|
||
at,
|
||
intensity: Math.max(0.8, Math.min(1.25, tool.intensity * 1.15)),
|
||
// Длительность как у zamorozka.mp3 (фазы «замерзания» в PxiEffectsOverlay масштабируются по life).
|
||
lifetimeMs: freezeLifeMs,
|
||
},
|
||
});
|
||
await fx.dispatch({
|
||
kind: 'instance.add',
|
||
instance: {
|
||
id: `ice_${String(createdAtMs)}_${String(seed)}`,
|
||
type: 'ice',
|
||
seed: seed ^ 0x33cc99,
|
||
createdAtMs,
|
||
at,
|
||
radiusN: Math.max(0.03, tool.radiusN * 0.9),
|
||
opacity: 0.85,
|
||
lifetimeMs: null,
|
||
},
|
||
});
|
||
playFreezeEffectSound();
|
||
}
|
||
brushRef.current = null;
|
||
clearDraftFromPixi();
|
||
}
|
||
|
||
return (
|
||
<div className={styles.page}>
|
||
<Surface className={styles.remote}>
|
||
<div className={styles.remoteTitle}>{t('control.remoteTitle')}</div>
|
||
<div className={styles.spacer12} />
|
||
<div className={styles.sectionLabel}>{t('control.instruments')}</div>
|
||
<div className={styles.spacer8} />
|
||
<div className={styles.iconRow}>
|
||
<Button
|
||
variant="ghost"
|
||
iconOnly
|
||
disabled={!hasSceneDescription}
|
||
title={hasSceneDescription ? t('control.descriptionTool') : t('control.descriptionMissing')}
|
||
ariaLabel={hasSceneDescription ? t('control.descriptionTool') : t('control.descriptionMissing')}
|
||
onClick={() => {
|
||
void api.invoke(ipcChannels.windows.openSceneDescription, { html: sceneDescription }).catch((err) => {
|
||
console.error('[control] openSceneDescription failed', err);
|
||
});
|
||
}}
|
||
>
|
||
<span className={styles.iconGlyph} aria-hidden>
|
||
📖
|
||
</span>
|
||
</Button>
|
||
<Button
|
||
variant="ghost"
|
||
iconOnly
|
||
title={t('control.materialsTool')}
|
||
ariaLabel={t('control.materialsTool')}
|
||
onClick={() => {
|
||
void api.invoke(ipcChannels.windows.openMaterials, {}).catch((err) => {
|
||
console.error('[control] openMaterials failed', err);
|
||
});
|
||
}}
|
||
>
|
||
<span className={styles.iconGlyph} aria-hidden>
|
||
<svg viewBox="0 0 24 24" width="20" height="20" aria-hidden>
|
||
<path
|
||
fill="#c9a227"
|
||
d="M4.2 5.4c1.6-.9 3.5-.7 5 .5l1.3 1.1c.4.3 1 .3 1.4 0L13.2 5.9c1.5-1.2 3.4-1.4 5-.5l1.4.8c.8.5 1.3 1.4 1.3 2.3v8.6c0 1.4-1.4 2.3-2.7 1.8l-2.3-.9c-.7-.3-1.5-.2-2.1.2l-1.3.9c-.6.4-1.4.4-2 0l-1.3-.9c-.6-.4-1.4-.5-2.1-.2l-2.3.9c-1.3.5-2.7-.4-2.7-1.8V8.5c0-.9.5-1.8 1.3-2.3l1.5-.8z"
|
||
/>
|
||
<path
|
||
fill="#7a4e1d"
|
||
d="M8.2 10.2c1.3-.4 2.5.2 3.3 1.1.3.3.8.3 1.1 0 .8-.9 2-1.5 3.3-1.1.5.2.8.7.6 1.2-.5 1.4-1.7 2.5-3.1 3.1-.5.2-1 .2-1.4 0-1.4-.6-2.6-1.7-3.1-3.1-.2-.5.1-1 .6-1.2z"
|
||
/>
|
||
<circle cx="12" cy="12.2" r="1.15" fill="#e8c547" />
|
||
<path
|
||
fill="none"
|
||
stroke="#5c3a16"
|
||
strokeWidth="1.1"
|
||
strokeLinecap="round"
|
||
d="M7.5 15.8c1.2.7 2.7 1.1 4.5 1.1s3.3-.4 4.5-1.1"
|
||
/>
|
||
</svg>
|
||
</span>
|
||
</Button>
|
||
<Button
|
||
variant="ghost"
|
||
iconOnly
|
||
title={t('control.npcsTool')}
|
||
ariaLabel={t('control.npcsTool')}
|
||
onClick={() => {
|
||
void api.invoke(ipcChannels.windows.openNpcs, {}).catch((err) => {
|
||
console.error('[control] openNpcs failed', err);
|
||
});
|
||
}}
|
||
>
|
||
<span className={styles.iconGlyph} aria-hidden>
|
||
<svg viewBox="0 0 24 24" width="20" height="20" aria-hidden>
|
||
<circle cx="12" cy="8" r="3.4" fill="#3b82f6" />
|
||
<path
|
||
fill="#22c55e"
|
||
d="M5.2 19.2c.6-3.4 3.2-5.2 6.8-5.2s6.2 1.8 6.8 5.2c.1.5-.3 1-.8 1H6c-.5 0-.9-.5-.8-1z"
|
||
/>
|
||
<circle cx="12" cy="8" r="1.4" fill="#93c5fd" />
|
||
</svg>
|
||
</span>
|
||
</Button>
|
||
</div>
|
||
<div className={styles.spacer12} />
|
||
{!isVideoPreviewScene ? (
|
||
<>
|
||
<div className={styles.sectionLabel}>{t('control.effects')}</div>
|
||
<div className={styles.spacer8} />
|
||
<div className={styles.effectsStack}>
|
||
<div className={styles.effectsGroup}>
|
||
<div className={styles.subsectionLabel}>{t('control.tools')}</div>
|
||
<div className={styles.iconRow}>
|
||
<Button
|
||
variant={tool.tool === 'eraser' ? 'primary' : 'ghost'}
|
||
iconOnly
|
||
title={t('control.eraser')}
|
||
ariaLabel={t('control.eraser')}
|
||
onClick={() => void fx.dispatch({ kind: 'tool.set', tool: { ...tool, tool: 'eraser' } })}
|
||
>
|
||
<span className={styles.iconGlyph}>🧹</span>
|
||
</Button>
|
||
<Button
|
||
variant="ghost"
|
||
iconOnly
|
||
title={t('control.clearEffects')}
|
||
ariaLabel={t('control.clearEffects')}
|
||
onClick={() => void fx.dispatch({ kind: 'instances.clear' })}
|
||
>
|
||
<span className={styles.clearIcon}>
|
||
<svg viewBox="0 0 24 24" width="20" height="20" aria-hidden>
|
||
<circle cx="12" cy="12" r="8" fill="none" stroke="#e5484d" strokeWidth="2" />
|
||
<line
|
||
x1="7"
|
||
y1="17"
|
||
x2="17"
|
||
y2="7"
|
||
stroke="#e5484d"
|
||
strokeWidth="2"
|
||
strokeLinecap="round"
|
||
/>
|
||
</svg>
|
||
</span>
|
||
</Button>
|
||
</div>
|
||
</div>
|
||
<div className={styles.effectsGroup}>
|
||
<div className={styles.subsectionLabel}>{t('control.fieldEffects')}</div>
|
||
<div className={styles.iconRow}>
|
||
<Button
|
||
variant={tool.tool === 'fog' ? 'primary' : 'ghost'}
|
||
iconOnly
|
||
title={t('control.fog')}
|
||
ariaLabel={t('control.fog')}
|
||
onClick={() => void fx.dispatch({ kind: 'tool.set', tool: { ...tool, tool: 'fog' } })}
|
||
>
|
||
<span className={styles.iconGlyph}>🌫️</span>
|
||
</Button>
|
||
<Button
|
||
variant={tool.tool === 'rain' ? 'primary' : 'ghost'}
|
||
iconOnly
|
||
title={t('control.rain')}
|
||
ariaLabel={t('control.rain')}
|
||
onClick={() => void fx.dispatch({ kind: 'tool.set', tool: { ...tool, tool: 'rain' } })}
|
||
>
|
||
<span className={styles.iconGlyph}>🌧️</span>
|
||
</Button>
|
||
<Button
|
||
variant={tool.tool === 'fire' ? 'primary' : 'ghost'}
|
||
iconOnly
|
||
title={t('control.fire')}
|
||
ariaLabel={t('control.fire')}
|
||
onClick={() => void fx.dispatch({ kind: 'tool.set', tool: { ...tool, tool: 'fire' } })}
|
||
>
|
||
<span className={styles.iconGlyph}>🔥</span>
|
||
</Button>
|
||
<Button
|
||
variant={tool.tool === 'water' ? 'primary' : 'ghost'}
|
||
iconOnly
|
||
title={t('control.water')}
|
||
ariaLabel={t('control.water')}
|
||
onClick={() => void fx.dispatch({ kind: 'tool.set', tool: { ...tool, tool: 'water' } })}
|
||
>
|
||
<span className={styles.iconGlyph}>💧</span>
|
||
</Button>
|
||
</div>
|
||
</div>
|
||
<div className={styles.effectsGroup}>
|
||
<div className={styles.subsectionLabel}>{t('control.actionEffects')}</div>
|
||
<div className={styles.iconRow}>
|
||
<Button
|
||
variant={tool.tool === 'lightning' ? 'primary' : 'ghost'}
|
||
iconOnly
|
||
title={t('control.lightning')}
|
||
ariaLabel={t('control.lightning')}
|
||
onClick={() =>
|
||
void fx.dispatch({ kind: 'tool.set', tool: { ...tool, tool: 'lightning' } })
|
||
}
|
||
>
|
||
<span className={styles.iconGlyph}>⚡</span>
|
||
</Button>
|
||
<Button
|
||
variant={tool.tool === 'sunbeam' ? 'primary' : 'ghost'}
|
||
iconOnly
|
||
title={t('control.sunbeam')}
|
||
ariaLabel={t('control.sunbeam')}
|
||
onClick={() => void fx.dispatch({ kind: 'tool.set', tool: { ...tool, tool: 'sunbeam' } })}
|
||
>
|
||
<span className={styles.iconGlyph}>☀️</span>
|
||
</Button>
|
||
<Button
|
||
variant={tool.tool === 'freeze' ? 'primary' : 'ghost'}
|
||
iconOnly
|
||
title={t('control.freeze')}
|
||
ariaLabel={t('control.freeze')}
|
||
onClick={() => void fx.dispatch({ kind: 'tool.set', tool: { ...tool, tool: 'freeze' } })}
|
||
>
|
||
<span className={styles.iconGlyph}>❄️</span>
|
||
</Button>
|
||
<Button
|
||
variant={tool.tool === 'darkness' ? 'primary' : 'ghost'}
|
||
iconOnly
|
||
title={t('control.darkness')}
|
||
ariaLabel={t('control.darkness')}
|
||
onClick={() =>
|
||
void fx.dispatch({ kind: 'tool.set', tool: { ...tool, tool: 'darkness' } })
|
||
}
|
||
>
|
||
<span className={styles.iconGlyph}>🌑</span>
|
||
</Button>
|
||
<Button
|
||
variant={tool.tool === 'poisonCloud' ? 'primary' : 'ghost'}
|
||
iconOnly
|
||
title={t('control.poisonCloud')}
|
||
ariaLabel={t('control.poisonCloud')}
|
||
onClick={() =>
|
||
void fx.dispatch({ kind: 'tool.set', tool: { ...tool, tool: 'poisonCloud' } })
|
||
}
|
||
>
|
||
<span className={styles.iconGlyph}>☣️</span>
|
||
</Button>
|
||
</div>
|
||
</div>
|
||
{isDarkenScene ? (
|
||
<div className={styles.effectsGroup}>
|
||
<div className={styles.subsectionLabel}>{t('control.darknessControl')}</div>
|
||
<div className={styles.iconRow}>
|
||
<Button
|
||
variant={tool.tool === 'exploreBrush' ? 'primary' : 'ghost'}
|
||
iconOnly
|
||
title={t('control.explorerBrush')}
|
||
ariaLabel={t('control.explorerBrush')}
|
||
onClick={() =>
|
||
void fx.dispatch({ kind: 'tool.set', tool: { ...tool, tool: 'exploreBrush' } })
|
||
}
|
||
>
|
||
<span className={styles.iconGlyph}>🔦</span>
|
||
</Button>
|
||
</div>
|
||
</div>
|
||
) : null}
|
||
<div className={styles.radiusRow}>
|
||
<div className={styles.radiusLabel}>{t('control.brushRadius')}</div>
|
||
<input
|
||
type="range"
|
||
min={0.015}
|
||
max={0.18}
|
||
step={0.001}
|
||
value={tool.radiusN}
|
||
onChange={(e) => {
|
||
const v = Number((e.currentTarget as HTMLInputElement).value);
|
||
const next = Math.max(0.01, Math.min(0.25, Number.isFinite(v) ? v : tool.radiusN));
|
||
void fx.dispatch({ kind: 'tool.set', tool: { ...tool, radiusN: next } });
|
||
}}
|
||
className={styles.range}
|
||
aria-label={t('control.brushRadius')}
|
||
/>
|
||
<div className={styles.radiusValue}>{Math.round(tool.radiusN * 100)}</div>
|
||
</div>
|
||
<div className={[styles.radiusRow, styles.effectsSoundRow].join(' ')}>
|
||
<div className={styles.radiusLabel}>{t('control.effectsSound')}</div>
|
||
<input
|
||
type="range"
|
||
min={0}
|
||
max={1}
|
||
step={0.01}
|
||
value={effectsSfxGainUi}
|
||
onChange={(e) => {
|
||
const v = Number((e.currentTarget as HTMLInputElement).value);
|
||
const next = setEffectsSfxGain(clampEffectsSfxGain(v));
|
||
setEffectsSfxGainUi(next);
|
||
syncFireAmbientVolume();
|
||
syncRainAmbientVolume();
|
||
}}
|
||
className={styles.range}
|
||
aria-label={t('control.effectsSound')}
|
||
/>
|
||
<div className={styles.radiusValue}>{Math.round(effectsSfxGainUi * 100)}</div>
|
||
</div>
|
||
</div>
|
||
<div className={styles.spacer12} />
|
||
</>
|
||
) : null}
|
||
<div className={styles.storyWrap}>
|
||
<div className={styles.sectionLabel}>{t('control.storyLine')}</div>
|
||
<div className={styles.spacer10} />
|
||
<div className={styles.storyScroll}>
|
||
{history.map((gnId, idx) => {
|
||
const gn = project?.sceneGraphNodes.find((n) => n.id === gnId);
|
||
const s = gn ? project?.scenes[gn.sceneId] : undefined;
|
||
const isCurrent = idx === currentHistoryIdx;
|
||
return (
|
||
<button
|
||
type="button"
|
||
key={`${gnId}_${String(idx)}`}
|
||
disabled={!project || isCurrent}
|
||
className={[styles.historyBtn, isCurrent ? styles.historyBtnCurrent : '']
|
||
.filter(Boolean)
|
||
.join(' ')}
|
||
title={project && !isCurrent ? t('control.gotoScene') : undefined}
|
||
onClick={() => {
|
||
if (!project) return;
|
||
if (isCurrent) return;
|
||
void api.invoke(ipcChannels.project.setCurrentGraphNode, { graphNodeId: gnId });
|
||
}}
|
||
>
|
||
{isCurrent ? (
|
||
<div className={styles.historyBadge}>{t('control.currentSceneBadge')}</div>
|
||
) : (
|
||
<div className={styles.historyMuted}>{t('control.passed')}</div>
|
||
)}
|
||
<div className={styles.historyTitle}>{s?.title ?? (gn ? String(gn.sceneId) : gnId)}</div>
|
||
</button>
|
||
);
|
||
})}
|
||
{history.length === 0 ? (
|
||
<div className={styles.emptyStory}>{t('control.noActiveScene')}</div>
|
||
) : null}
|
||
</div>
|
||
</div>
|
||
</Surface>
|
||
|
||
<div className={styles.rightStack}>
|
||
<Surface className={styles.surfacePad}>
|
||
<div className={styles.previewHeader}>
|
||
<div className={styles.previewTitle}>{t('control.screenPreview')}</div>
|
||
<div className={styles.previewActions}>
|
||
<Button onClick={() => void api.invoke(ipcChannels.windows.closeMultiWindow, {})}>
|
||
{t('control.stopPresentation')}
|
||
</Button>
|
||
</div>
|
||
</div>
|
||
<div className={styles.spacer10} />
|
||
{isVideoPreviewScene ? <div className={styles.videoHint}>{t('control.videoBrushHint')}</div> : null}
|
||
<div className={styles.spacer10} />
|
||
<div
|
||
ref={previewFrameRef}
|
||
className={styles.previewFrame}
|
||
title={
|
||
isVideoPreviewScene
|
||
? undefined
|
||
: 'Колесо — зум; перетаскивание СКМ/ПКМ или Space+ЛКМ — пан'
|
||
}
|
||
>
|
||
<div ref={previewHostRef} className={styles.previewHost}>
|
||
<ControlScenePreview
|
||
session={session}
|
||
videoRef={previewVideoRef}
|
||
viewCamera={activeSceneViewCamera}
|
||
onContentRectChange={setPreviewContentRect}
|
||
/>
|
||
</div>
|
||
{!isVideoPreviewScene ? (
|
||
<>
|
||
<PixiEffectsOverlay
|
||
ref={effectsOverlayRef}
|
||
state={fxState}
|
||
style={{ zIndex: 1 }}
|
||
viewport={
|
||
previewContentRect
|
||
? {
|
||
x: previewContentRect.x,
|
||
y: previewContentRect.y,
|
||
w: previewContentRect.w,
|
||
h: previewContentRect.h,
|
||
}
|
||
: undefined
|
||
}
|
||
/>
|
||
{previewContentRect ? (
|
||
<SceneDarknessOverlay state={sdState} overlayAlpha={0.5} viewport={previewContentRect} />
|
||
) : null}
|
||
<div
|
||
ref={brushCursorElRef}
|
||
className={styles.brushCursor}
|
||
style={{ visibility: 'hidden' }}
|
||
aria-hidden
|
||
/>
|
||
<div
|
||
className={styles.brushLayer}
|
||
onContextMenu={(e) => {
|
||
e.preventDefault();
|
||
}}
|
||
onPointerEnter={(e) => {
|
||
const p = toNPoint(e);
|
||
if (!p) return;
|
||
cursorPosRef.current = p;
|
||
layoutBrushCursor();
|
||
}}
|
||
onPointerLeave={() => {
|
||
cursorPosRef.current = null;
|
||
layoutBrushCursor();
|
||
}}
|
||
onPointerDown={(e) => {
|
||
const panWithPrimary = e.button === 0 && (spaceDownRef.current || e.altKey);
|
||
const isPan = e.button === 1 || e.button === 2 || panWithPrimary;
|
||
if (isPan) {
|
||
e.preventDefault();
|
||
(e.currentTarget as HTMLDivElement).setPointerCapture(e.pointerId);
|
||
scenePanRef.current = {
|
||
lastX: e.clientX,
|
||
lastY: e.clientY,
|
||
pointerId: e.pointerId,
|
||
};
|
||
return;
|
||
}
|
||
if (e.button !== 0) return;
|
||
const p = toNPoint(e);
|
||
if (!p) return;
|
||
cursorPosRef.current = p;
|
||
layoutBrushCursor();
|
||
(e.currentTarget as HTMLDivElement).setPointerCapture(e.pointerId);
|
||
if (tool.tool === 'eraser') {
|
||
const pt = { x: p.x, y: p.y, tMs: Date.now() };
|
||
brushRef.current = { tool: 'eraser', startN: p, points: [pt] };
|
||
dispatchFieldEraserPoints([pt]);
|
||
tryEraseActionEffect(p);
|
||
return;
|
||
}
|
||
draftMetaRef.current = { createdAtMs: Date.now(), seed: 12345 };
|
||
const startPt = { x: p.x, y: p.y, tMs: Date.now() };
|
||
brushRef.current = {
|
||
tool: tool.tool,
|
||
startN: p,
|
||
points: [startPt],
|
||
};
|
||
if (tool.tool === 'water' || tool.tool === 'rain') {
|
||
extinguishFireAlong([startPt]);
|
||
}
|
||
if (tool.tool === 'exploreBrush') {
|
||
void sd.dispatch({
|
||
kind: 'draft.set',
|
||
draft: {
|
||
points: [startPt],
|
||
radiusN: tool.radiusN,
|
||
},
|
||
});
|
||
}
|
||
pushDraftToPixi();
|
||
}}
|
||
onPointerMove={(e) => {
|
||
const pan = scenePanRef.current;
|
||
if (pan && pan.pointerId === e.pointerId) {
|
||
const cr = previewContentRectRef.current;
|
||
if (!cr) return;
|
||
const cam = sceneViewRef.current;
|
||
const containW = cr.w / Math.max(1e-6, cam.scale);
|
||
const containH = cr.h / Math.max(1e-6, cam.scale);
|
||
const dx = e.clientX - pan.lastX;
|
||
const dy = e.clientY - pan.lastY;
|
||
pan.lastX = e.clientX;
|
||
pan.lastY = e.clientY;
|
||
publishSceneViewCamera(
|
||
sceneViewPanBy(cam, { containW, containH, dx, dy }),
|
||
);
|
||
return;
|
||
}
|
||
const p = toNPoint(e);
|
||
if (!p) return;
|
||
cursorPosRef.current = p;
|
||
layoutBrushCursor();
|
||
if (tool.tool === 'eraser' && (e.buttons & 1) !== 0) {
|
||
const b = brushRef.current;
|
||
const pt = { x: p.x, y: p.y, tMs: Date.now() };
|
||
if (b?.tool !== 'eraser' || !b.points) {
|
||
brushRef.current = { tool: 'eraser', startN: p, points: [pt] };
|
||
dispatchFieldEraserPoints([pt]);
|
||
} else {
|
||
const last = b.points[b.points.length - 1];
|
||
if (last) {
|
||
const dx = p.x - last.x;
|
||
const dy = p.y - last.y;
|
||
const minStep = Math.max(0.004, tool.radiusN * 0.25);
|
||
if (dx * dx + dy * dy >= minStep * minStep) {
|
||
b.points.push(pt);
|
||
dispatchFieldEraserPoints([last, pt]);
|
||
}
|
||
}
|
||
}
|
||
tryEraseActionEffect(p);
|
||
return;
|
||
}
|
||
const b = brushRef.current;
|
||
if (!b?.points) return;
|
||
const last = b.points[b.points.length - 1];
|
||
if (!last) return;
|
||
const dx = p.x - last.x;
|
||
const dy = p.y - last.y;
|
||
const minStep = Math.max(0.004, tool.radiusN * 0.25);
|
||
if (dx * dx + dy * dy < minStep * minStep) return;
|
||
const pt = { x: p.x, y: p.y, tMs: Date.now() };
|
||
b.points.push(pt);
|
||
if (b.tool === 'water' || b.tool === 'rain') {
|
||
extinguishFireAlong([last, pt]);
|
||
}
|
||
scheduleDraftRepaint();
|
||
}}
|
||
onPointerUp={(e) => {
|
||
if (scenePanRef.current?.pointerId === e.pointerId) {
|
||
scenePanRef.current = null;
|
||
const pending = pendingSceneViewRef.current;
|
||
if (pending) {
|
||
void sceneViewApi.dispatch({ kind: 'set', camera: pending });
|
||
}
|
||
return;
|
||
}
|
||
void commitStroke();
|
||
}}
|
||
onPointerCancel={(e) => {
|
||
if (scenePanRef.current?.pointerId === e.pointerId) {
|
||
scenePanRef.current = null;
|
||
return;
|
||
}
|
||
if (brushRef.current?.tool === 'exploreBrush') {
|
||
void sd.dispatch({ kind: 'draft.set', draft: null });
|
||
}
|
||
brushRef.current = null;
|
||
clearDraftFromPixi();
|
||
}}
|
||
/>
|
||
</>
|
||
) : null}
|
||
{(() => {
|
||
const activeMaterial =
|
||
session?.project && materialsOverlay?.activeMaterialId
|
||
? (session.project.materials ?? []).find((m) => m.id === materialsOverlay.activeMaterialId)
|
||
: undefined;
|
||
const project = session?.project;
|
||
const activeIds = npcsOverlay?.activeNpcIds ?? [];
|
||
const npcItems =
|
||
project && activeIds.length > 0
|
||
? activeIds
|
||
.map((id) => {
|
||
const npc = (project.npcs ?? []).find((n) => n.id === id);
|
||
if (!npc) return null;
|
||
return {
|
||
npcId: npc.id,
|
||
assetId: npc.avatarAssetId,
|
||
layout: npcsOverlay?.layouts[id] ?? DEFAULT_NPCS_OVERLAY_LAYOUT,
|
||
};
|
||
})
|
||
.filter((x): x is NonNullable<typeof x> => x !== null)
|
||
: [];
|
||
const showMaterial = Boolean(activeMaterial);
|
||
const showNpcs = npcItems.length > 0;
|
||
if (!showMaterial && !showNpcs) return null;
|
||
const closes = [
|
||
...(showMaterial
|
||
? [
|
||
{
|
||
key: 'materials',
|
||
label: t('materials.closeOverlay'),
|
||
onClose: () => {
|
||
void materialsApi.dispatch({ kind: 'hide' });
|
||
},
|
||
},
|
||
]
|
||
: []),
|
||
...(showNpcs
|
||
? [
|
||
{
|
||
key: 'npcs',
|
||
label: t('npcs.closeOverlay'),
|
||
onClose: () => {
|
||
void npcsApi.dispatch({ kind: 'hide' });
|
||
},
|
||
},
|
||
]
|
||
: []),
|
||
];
|
||
const materialsZoom = materialsOverlay?.zoomTool ?? null;
|
||
return (
|
||
<SceneOverlayHost
|
||
active
|
||
zoomTool={materialsZoom}
|
||
{...(materialsZoom
|
||
? {
|
||
onZoomAt: (nx: number, ny: number) => {
|
||
void materialsApi.dispatch({ kind: 'zoomAt', nx, ny });
|
||
},
|
||
}
|
||
: {})}
|
||
closes={closes}
|
||
>
|
||
{showMaterial && activeMaterial ? (
|
||
<MaterialOverlay
|
||
embedded
|
||
assetId={activeMaterial.assetId}
|
||
layout={materialsOverlay?.layout ?? DEFAULT_MATERIALS_OVERLAY_LAYOUT}
|
||
editable
|
||
zoomTool={materialsZoom}
|
||
rotateLabel={t('materials.rotateOverlay')}
|
||
onLayoutChange={(layout) => {
|
||
void materialsApi.dispatch({ kind: 'layout.set', layout });
|
||
}}
|
||
/>
|
||
) : null}
|
||
{showNpcs ? (
|
||
<NpcsSceneOverlay
|
||
embedded
|
||
items={npcItems}
|
||
editable
|
||
rotateLabel={t('npcs.rotateOverlay')}
|
||
onLayoutChange={(npcId, layout) => {
|
||
void npcsApi.dispatch({ kind: 'layout.set', npcId, layout });
|
||
}}
|
||
/>
|
||
) : null}
|
||
</SceneOverlayHost>
|
||
);
|
||
})()}
|
||
</div>
|
||
</Surface>
|
||
|
||
<Surface className={styles.surfacePad}>
|
||
<div className={styles.branchTitle}>{t('control.branches')}</div>
|
||
<div className={styles.branchGrid}>
|
||
{showReturnToMain ? (
|
||
<div className={[styles.branchCard, styles.branchCardReturn].join(' ')}>
|
||
<div className={styles.branchCardHeader}>
|
||
<div className={styles.branchOption}>{t('control.option', { n: '1' })}</div>
|
||
</div>
|
||
<div className={styles.branchName}>{returnSceneTitle}</div>
|
||
<Button variant="primary" onClick={returnToMainStoryline}>
|
||
{t('control.returnToMainStory')}
|
||
</Button>
|
||
</div>
|
||
) : null}
|
||
{nextScenes.map((o, i) => (
|
||
<div key={o.graphNodeId} className={styles.branchCard}>
|
||
<div className={styles.branchCardHeader}>
|
||
<div className={styles.branchOption}>
|
||
{t('control.option', { n: String(i + 1 + branchOptionOffset) })}
|
||
</div>
|
||
</div>
|
||
<div className={styles.branchName}>{o.scene.title || t('control.unnamed')}</div>
|
||
<Button
|
||
variant="primary"
|
||
onClick={() =>
|
||
void api.invoke(ipcChannels.project.setCurrentGraphNode, { graphNodeId: o.graphNodeId })
|
||
}
|
||
>
|
||
{t('control.switchScene')}
|
||
</Button>
|
||
</div>
|
||
))}
|
||
{nextScenes.length === 0 && !showReturnToMain ? (
|
||
<div className={styles.branchEmpty}>
|
||
<div>{t('control.noBranches')}</div>
|
||
<Button
|
||
variant="primary"
|
||
disabled={!session?.project?.currentGraphNodeId}
|
||
onClick={() => void api.invoke(ipcChannels.windows.closeMultiWindow, {})}
|
||
>
|
||
{t('control.endPresentation')}
|
||
</Button>
|
||
</div>
|
||
) : null}
|
||
</div>
|
||
</Surface>
|
||
|
||
<Surface className={styles.surfacePad}>
|
||
<div className={styles.musicHeader}>
|
||
<div className={styles.previewTitle}>{t('control.music')}</div>
|
||
</div>
|
||
<div className={styles.spacer10} />
|
||
<div className={styles.sectionLabel}>{t('control.sceneMusic')}</div>
|
||
<div className={styles.spacer10} />
|
||
{sceneAudios.length === 0 ? (
|
||
<div className={styles.musicEmpty}>{t('control.noSceneAudio')}</div>
|
||
) : null}
|
||
{sceneAudios.length > 0 ? (
|
||
<div className={styles.audioList}>
|
||
{sceneAudios.map(({ ref, asset }) => {
|
||
const el = sceneAudioElsRef.current.get(ref.assetId) ?? null;
|
||
const st = audioStatus('scene', ref.assetId);
|
||
return (
|
||
<ControlAudioCard
|
||
key={ref.assetId}
|
||
assetId={ref.assetId}
|
||
name={asset.originalName}
|
||
autoplay={ref.autoplay}
|
||
loop={ref.loop}
|
||
statusLabel={st.label}
|
||
{...(st.detail ? { statusDetail: st.detail } : {})}
|
||
audioEl={el}
|
||
initialGain={readAudioGain(sceneAudioGainRef.current, ref.assetId)}
|
||
gainMap={sceneAudioGainRef.current}
|
||
playTitle={t('control.transportPlay')}
|
||
playLabel={t('control.transportPlay')}
|
||
pauseLabel={t('control.transportPause')}
|
||
stopLabel={t('control.transportStop')}
|
||
volumeLabel={t('control.volume')}
|
||
modeAutoLabel={t('control.modeAuto')}
|
||
modeManualLabel={t('control.modeManual')}
|
||
loopLabel={t('control.loop')}
|
||
onceLabel={t('control.once')}
|
||
scrubSeekLabel={t('control.scrubSeek')}
|
||
durationUnknownLabel={t('control.durationUnknown')}
|
||
onStatusChange={() => setSceneAudioStateTick((x) => x + 1)}
|
||
onPlay={() => {
|
||
if (!el) return;
|
||
const m = sceneAudioMetaRef.current.get(ref.assetId) ?? { lastPlayError: null };
|
||
sceneAudioMetaRef.current.set(ref.assetId, { ...m, lastPlayError: null });
|
||
try {
|
||
applyAudioGain(el, sceneAudioGainRef.current, ref.assetId);
|
||
} catch {
|
||
// ignore
|
||
}
|
||
void el.play().catch(() => {
|
||
const mm =
|
||
sceneAudioMetaRef.current.get(ref.assetId) ??
|
||
({ lastPlayError: null } as const);
|
||
sceneAudioMetaRef.current.set(ref.assetId, {
|
||
...mm,
|
||
lastPlayError: t('control.playFailed'),
|
||
});
|
||
setSceneAudioStateTick((x) => x + 1);
|
||
});
|
||
}}
|
||
onPause={() => {
|
||
if (!el) return;
|
||
el.pause();
|
||
}}
|
||
onStop={() => {
|
||
if (!el) return;
|
||
el.pause();
|
||
el.currentTime = 0;
|
||
setSceneAudioStateTick((x) => x + 1);
|
||
}}
|
||
/>
|
||
);
|
||
})}
|
||
</div>
|
||
) : null}
|
||
|
||
<div className={styles.spacer12} />
|
||
<div className={styles.sectionLabel}>{t('control.gameMusic')}</div>
|
||
<div className={styles.spacer10} />
|
||
{campaignAudios.length === 0 ? (
|
||
<div className={styles.musicEmpty}>{t('control.noGameAudio')}</div>
|
||
) : (
|
||
<div className={styles.audioList}>
|
||
{campaignAudios.map(({ ref, asset }) => {
|
||
const el = campaignAudioElsRef.current.get(ref.assetId) ?? null;
|
||
const st = audioStatus('campaign', ref.assetId);
|
||
return (
|
||
<ControlAudioCard
|
||
key={ref.assetId}
|
||
assetId={ref.assetId}
|
||
name={asset.originalName}
|
||
autoplay={ref.autoplay}
|
||
loop={ref.loop}
|
||
statusLabel={st.label}
|
||
{...(st.detail ? { statusDetail: st.detail } : {})}
|
||
{...(!allowCampaignAudio
|
||
? {
|
||
extraBadge: (
|
||
<div title={t('control.pauseSceneMusicTitle')}>{t('control.pauseSceneMusic')}</div>
|
||
),
|
||
}
|
||
: {})}
|
||
audioEl={el}
|
||
initialGain={readAudioGain(campaignAudioGainRef.current, ref.assetId)}
|
||
gainMap={campaignAudioGainRef.current}
|
||
playTitle={
|
||
!allowCampaignAudio ? t('control.pauseCampaignTitle') : t('control.transportPlay')
|
||
}
|
||
playLabel={t('control.transportPlay')}
|
||
pauseLabel={t('control.transportPause')}
|
||
stopLabel={t('control.transportStop')}
|
||
volumeLabel={t('control.volume')}
|
||
modeAutoLabel={t('control.modeAuto')}
|
||
modeManualLabel={t('control.modeManual')}
|
||
loopLabel={t('control.loop')}
|
||
onceLabel={t('control.once')}
|
||
scrubSeekLabel={t('control.scrubSeek')}
|
||
durationUnknownLabel={t('control.durationUnknown')}
|
||
onStatusChange={() => setCampaignAudioStateTick((x) => x + 1)}
|
||
onPlay={() => {
|
||
if (!el) return;
|
||
const m = campaignAudioMetaRef.current.get(ref.assetId) ?? { lastPlayError: null };
|
||
campaignAudioMetaRef.current.set(ref.assetId, { ...m, lastPlayError: null });
|
||
try {
|
||
if (el.volume === 0) {
|
||
applyAudioGain(el, campaignAudioGainRef.current, ref.assetId);
|
||
}
|
||
} catch {
|
||
// ignore
|
||
}
|
||
void el.play().catch(() => {
|
||
const mm =
|
||
campaignAudioMetaRef.current.get(ref.assetId) ??
|
||
({ lastPlayError: null } as const);
|
||
campaignAudioMetaRef.current.set(ref.assetId, {
|
||
...mm,
|
||
lastPlayError: t('control.playFailed'),
|
||
});
|
||
setCampaignAudioStateTick((x) => x + 1);
|
||
});
|
||
}}
|
||
onPause={() => {
|
||
if (!el) return;
|
||
el.pause();
|
||
}}
|
||
onStop={() => {
|
||
if (!el) return;
|
||
el.pause();
|
||
el.currentTime = 0;
|
||
setCampaignAudioStateTick((x) => x + 1);
|
||
}}
|
||
/>
|
||
);
|
||
})}
|
||
</div>
|
||
)}
|
||
</Surface>
|
||
|
||
{sideStoryLines.length > 0 ? (
|
||
<Surface className={styles.surfacePad}>
|
||
<div className={styles.previewTitle}>{t('control.sideStoryLines')}</div>
|
||
<div className={styles.spacer10} />
|
||
<div className={styles.sideStoryGrid}>
|
||
{sideStoryLines.map((line) =>
|
||
line.scene ? (
|
||
<SideStoryTile
|
||
key={line.graphNodeId}
|
||
scene={line.scene}
|
||
title={line.title}
|
||
onClick={() => enterSideStoryline(line.graphNodeId)}
|
||
/>
|
||
) : null,
|
||
)}
|
||
</div>
|
||
</Surface>
|
||
) : null}
|
||
</div>
|
||
</div>
|
||
);
|
||
}
|