feat(editor): побочные сюжетные линии и импорт/экспорт линий
Добавлена полноценная поддержка побочных сюжетных линий в редакторе и на пульте: визуальное выделение компонент графа, запрет недопустимых связей между основной и побочными линиями, метки «ПОБОЧНАЯ» и названия линий. Реализован partial export/import сюжетных линий: - экспорт выбранных линий в урезанный .ttrpg.zip с manifest в project.json; - импорт в открытый проект с выбором линий, разрешением конфликтов названий сцен (одна модалка на операцию) и отчётом о результате; - новое окно источника импорта: «Из проекта» (dropdown, без текущего) или «Из файла»; на главном экране — только полный импорт из файла. Исправлены гонки при открытии/закрытии проекта (сериализация open/close в main, сброс зависших состояний UI), залипание оверлея прогресса экспорта и старт Electron в dev после готовности Vite. Добавлены unit-тесты для lineage, export/import и контрактов zipStore. Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
@@ -278,6 +278,62 @@
|
||||
font-weight: 900;
|
||||
}
|
||||
|
||||
.branchCardReturn {
|
||||
border-color: rgba(0, 120, 212, 0.45);
|
||||
}
|
||||
|
||||
.sideStoryGrid {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(auto-fill, minmax(140px, 1fr));
|
||||
gap: 12px;
|
||||
}
|
||||
|
||||
.sideStoryTile {
|
||||
display: grid;
|
||||
gap: 8px;
|
||||
padding: 0;
|
||||
border: none;
|
||||
background: transparent;
|
||||
cursor: pointer;
|
||||
text-align: left;
|
||||
color: inherit;
|
||||
font: inherit;
|
||||
}
|
||||
|
||||
.sideStoryTile:hover .sideStoryPreview {
|
||||
border-color: rgba(0, 120, 212, 0.55);
|
||||
}
|
||||
|
||||
.sideStoryPreview {
|
||||
width: 100%;
|
||||
aspect-ratio: 4 / 3;
|
||||
border-radius: var(--scene-tile-radius);
|
||||
overflow: hidden;
|
||||
border: 2px solid var(--stroke);
|
||||
background: #0c0c0e;
|
||||
position: relative;
|
||||
}
|
||||
|
||||
.sideStoryVideo {
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
object-fit: cover;
|
||||
display: block;
|
||||
}
|
||||
|
||||
.sideStoryPlaceholder {
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
background: #0c0c0e;
|
||||
}
|
||||
|
||||
.sideStoryTitle {
|
||||
font-size: var(--text-xs);
|
||||
font-weight: 800;
|
||||
line-height: 1.25;
|
||||
color: var(--text1);
|
||||
}
|
||||
|
||||
.musicHeader {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
|
||||
@@ -3,15 +3,18 @@ import React, { useEffect, useLayoutEffect, useMemo, useRef, useState } from 're
|
||||
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 { getDndApi } from '../shared/dndApi';
|
||||
import { RotatedImage } from '../shared/RotatedImage';
|
||||
import { PixiEffectsOverlay } from '../shared/effects/PxiEffectsOverlay';
|
||||
import { SceneDarknessOverlay } from '../shared/effects/SceneDarknessOverlay';
|
||||
import { useEffectsState } from '../shared/effects/useEffectsState';
|
||||
import { useSceneDarknessState } from '../shared/effects/useSceneDarknessState';
|
||||
import { Button } from '../shared/ui/controls';
|
||||
import { Surface } from '../shared/ui/Surface';
|
||||
import { useAssetUrl } from '../shared/useAssetImageUrl';
|
||||
|
||||
import styles from './ControlApp.module.css';
|
||||
import { ControlScenePreview } from './ControlScenePreview';
|
||||
@@ -45,6 +48,41 @@ function playLightningEffectSound(): void {
|
||||
}
|
||||
}
|
||||
|
||||
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();
|
||||
@@ -55,6 +93,9 @@ export function ControlApp() {
|
||||
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());
|
||||
@@ -116,7 +157,11 @@ export function ControlApp() {
|
||||
return api.on(ipcChannels.session.stateChanged, ({ state }) => {
|
||||
setSession(state);
|
||||
const cur = state.project?.currentGraphNodeId ?? null;
|
||||
if (!cur) return;
|
||||
if (!cur) {
|
||||
mainStoryReturnRef.current = null;
|
||||
setMainStoryReturnGraphNodeId(null);
|
||||
return;
|
||||
}
|
||||
const arr = historyRef.current;
|
||||
if (arr[arr.length - 1] !== cur) {
|
||||
historyRef.current = [...arr, cur];
|
||||
@@ -125,6 +170,15 @@ export function ControlApp() {
|
||||
});
|
||||
}, [api]);
|
||||
|
||||
useEffect(() => {
|
||||
return api.on(ipcChannels.windows.multiWindowStateChanged, ({ open }) => {
|
||||
if (!open) {
|
||||
mainStoryReturnRef.current = null;
|
||||
setMainStoryReturnGraphNodeId(null);
|
||||
}
|
||||
});
|
||||
}, [api]);
|
||||
|
||||
useEffect(() => {
|
||||
audioUnmountRef.current = false;
|
||||
return () => {
|
||||
@@ -590,6 +644,54 @@ export function ControlApp() {
|
||||
.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;
|
||||
@@ -1407,10 +1509,23 @@ export function ControlApp() {
|
||||
<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) })}</div>
|
||||
<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
|
||||
@@ -1423,7 +1538,7 @@ export function ControlApp() {
|
||||
</Button>
|
||||
</div>
|
||||
))}
|
||||
{nextScenes.length === 0 ? (
|
||||
{nextScenes.length === 0 && !showReturnToMain ? (
|
||||
<div className={styles.branchEmpty}>
|
||||
<div>{t('control.noBranches')}</div>
|
||||
<Button
|
||||
@@ -1666,6 +1781,25 @@ export function ControlApp() {
|
||||
</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>
|
||||
);
|
||||
|
||||
Reference in New Issue
Block a user