feat(control): аккордеон «Сцены» на пульте управления

Список сцен активной линии в порядке бокового списка редактора; переход с записью в историю.

Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
Ivan Fontosh
2026-09-02 13:29:12 +08:00
parent 746b862cb9
commit cfb6e62331
7 changed files with 308 additions and 4 deletions
+17 -1
View File
@@ -221,12 +221,28 @@
flex: 0 0 auto;
}
.storyAccordions {
flex: 1;
min-height: 0;
display: flex;
flex-direction: column;
gap: 8px;
}
.storyAccordionExpanded {
flex: 1;
min-height: 0;
}
.storyAccordionCollapsed {
flex: 0 0 auto;
}
.storyWrapCollapsed .historyAccordion {
flex: 0 0 auto;
}
.historyAccordion {
flex: 1;
min-height: 0;
display: flex;
flex-direction: column;
+74 -2
View File
@@ -11,6 +11,7 @@ import {
isNodeInSideStoryline,
listSideStoryStarts,
} from '../../shared/graph/sceneGraphLineage';
import { listActiveStorylineScenes } from '../../shared/graph/activeStorylineScenes';
import type {
GraphNodeId,
MaterialId,
@@ -192,6 +193,7 @@ export function ControlApp() {
const historyRef = useRef<GraphNodeId[]>([]);
const [history, setHistory] = useState<GraphNodeId[]>([]);
const [historyOpen, setHistoryOpen] = useState(true);
const [scenesOpen, setScenesOpen] = useState(false);
const [darknessBrushShape, setDarknessBrushShape] = useState<SceneDarknessBrushShape>('circle');
const darknessBrushShapeRef = useRef(darknessBrushShape);
darknessBrushShapeRef.current = darknessBrushShape;
@@ -413,6 +415,11 @@ export function ControlApp() {
const project = session?.project ?? null;
const currentGraphNodeId = project?.currentGraphNodeId ?? null;
const currentHistoryIdx = currentGraphNodeId != null ? history.lastIndexOf(currentGraphNodeId) : -1;
const historyGraphNodeSet = useMemo(() => new Set(history), [history]);
const storylineScenes = useMemo(
() => (project ? listActiveStorylineScenes(project) : []),
[project],
);
const currentScene =
project && session?.currentSceneId ? project.scenes[session.currentSceneId] : undefined;
const visibleNpcTokens = useMemo(
@@ -2165,8 +2172,17 @@ export function ControlApp() {
</div>
<div className={styles.spacer12} />
</>
<div className={[styles.storyWrap, historyOpen ? '' : styles.storyWrapCollapsed].filter(Boolean).join(' ')}>
<div className={styles.historyAccordion} data-testid="control-history-accordion">
<div className={[styles.storyWrap, historyOpen || scenesOpen ? '' : styles.storyWrapCollapsed].filter(Boolean).join(' ')}>
<div className={styles.storyAccordions}>
<div
className={[
styles.historyAccordion,
historyOpen ? styles.storyAccordionExpanded : styles.storyAccordionCollapsed,
]
.filter(Boolean)
.join(' ')}
data-testid="control-history-accordion"
>
<button
type="button"
className={styles.historyAccordionHead}
@@ -2214,6 +2230,62 @@ export function ControlApp() {
</div>
) : null}
</div>
<div
className={[
styles.historyAccordion,
scenesOpen ? styles.storyAccordionExpanded : styles.storyAccordionCollapsed,
]
.filter(Boolean)
.join(' ')}
data-testid="control-scenes-accordion"
>
<button
type="button"
className={styles.historyAccordionHead}
aria-expanded={scenesOpen}
onClick={() => setScenesOpen((v) => !v)}
>
{t('control.scenesAccordion')} {scenesOpen ? '▾' : '▸'}
</button>
{scenesOpen ? (
<div className={styles.storyScroll}>
{storylineScenes.map(({ sceneId, graphNodeId }) => {
const s = project?.scenes[sceneId];
const isCurrent = graphNodeId === currentGraphNodeId;
const visited = historyGraphNodeSet.has(graphNodeId);
return (
<button
type="button"
key={sceneId}
disabled={!project || isCurrent}
className={[styles.historyBtn, isCurrent ? styles.historyBtnCurrent : '']
.filter(Boolean)
.join(' ')}
title={project && !isCurrent ? t('control.gotoScene') : undefined}
onClick={() => {
if (!project || isCurrent) return;
void api.invoke(ipcChannels.project.setCurrentGraphNode, { graphNodeId });
}}
>
{isCurrent ? (
<div className={styles.historyBadge}>{t('control.currentSceneBadge')}</div>
) : visited ? (
<div className={styles.historyMuted}>{t('control.passed')}</div>
) : null}
<EllipsisText
text={s?.title ?? String(sceneId)}
className={[styles.historyTitle, ellipsisStyles.root].join(' ')}
/>
</button>
);
})}
{storylineScenes.length === 0 ? (
<div className={styles.emptyStory}>{t('control.noActiveScene')}</div>
) : null}
</div>
) : null}
</div>
</div>
</div>
</Surface>
@@ -151,6 +151,18 @@ void test('ControlApp: история — колонка сверху вниз
assert.match(css, /\.branchCard[\s\S]*?background:\s*var\(--color-overlay-dark-2\)/);
});
void test('ControlApp: блок «Сцены» — аккордеон под историей, свёрнут по умолчанию', () => {
const src = readControlApp();
assert.ok(src.includes('data-testid="control-scenes-accordion"'));
assert.ok(src.includes("t('control.scenesAccordion')"));
assert.ok(src.includes('const [scenesOpen, setScenesOpen] = useState(false)'));
assert.ok(src.includes('listActiveStorylineScenes'));
assert.ok(src.includes('storylineScenes.map'));
const historyIdx = src.indexOf('data-testid="control-history-accordion"');
const scenesIdx = src.indexOf('data-testid="control-scenes-accordion"');
assert.ok(historyIdx !== -1 && scenesIdx !== -1 && historyIdx < scenesIdx);
});
void test('ControlApp: клик по истории добавляет новый шаг, не подавляет запись', () => {
const src = readControlApp();
assert.ok(!src.includes('suppressNextHistoryPushRef'));
@@ -646,6 +646,7 @@ export const EDITOR_MESSAGES: Record<EditorLocale, Record<string, string>> = {
'control.brushShapeSquare': 'Квадратная',
'control.effectsSound': 'Звук эффектов',
'control.storyLine': 'ИСТОРИЯ',
'control.scenesAccordion': 'СЦЕНЫ',
'control.gotoScene': 'Перейти к этой сцене',
'control.currentSceneBadge': 'ТЕКУЩАЯ СЦЕНА',
'control.passed': 'Пройдено',
@@ -1297,6 +1298,7 @@ export const EDITOR_MESSAGES: Record<EditorLocale, Record<string, string>> = {
'control.brushShapeSquare': 'Square',
'control.effectsSound': 'Effects sound',
'control.storyLine': 'HISTORY',
'control.scenesAccordion': 'SCENES',
'control.gotoScene': 'Go to this scene',
'control.currentSceneBadge': 'CURRENT SCENE',
'control.passed': 'Visited',
@@ -0,0 +1,125 @@
import assert from 'node:assert/strict';
import test from 'node:test';
import type { Project, SceneGraphEdge, SceneGraphNode } from '../types';
import { asGraphNodeId, asSceneId } from '../types/ids';
import { getActiveStorylineGraphNodeIds, listActiveStorylineScenes } from './activeStorylineScenes';
function node(id: string, sceneId: string, extra: Partial<SceneGraphNode> = {}): SceneGraphNode {
return {
id: asGraphNodeId(id),
sceneId: asSceneId(sceneId),
x: 0,
y: 0,
isStartScene: false,
isSideStoryStart: false,
sideStoryLineTitle: '',
...extra,
};
}
function edge(source: string, target: string): SceneGraphEdge {
return { sourceGraphNodeId: asGraphNodeId(source), targetGraphNodeId: asGraphNodeId(target) };
}
function minimalProject(partial: Partial<Project> & Pick<Project, 'sceneGraphNodes' | 'sceneGraphEdges'>): Project {
return {
id: partial.id ?? ('p1' as Project['id']),
title: partial.title ?? 'Test',
scenes: partial.scenes ?? {},
sceneListOrder: partial.sceneListOrder ?? [],
sceneGraphNodes: partial.sceneGraphNodes,
sceneGraphEdges: partial.sceneGraphEdges,
currentGraphNodeId: partial.currentGraphNodeId ?? null,
currentSceneId: partial.currentSceneId ?? null,
materials: partial.materials ?? [],
campaignAudio: partial.campaignAudio ?? [],
npcs: partial.npcs ?? [],
npcGroups: partial.npcGroups ?? [],
npcRelations: partial.npcRelations ?? [],
};
}
void test('listActiveStorylineScenes: main line ordered by sceneListOrder, deduped', () => {
const s1 = asSceneId('s1');
const s2 = asSceneId('s2');
const s3 = asSceneId('s3');
const nodes = [
node('main', 's1', { isStartScene: true }),
node('a', 's2'),
node('b', 's3'),
node('dup', 's2'),
];
const edges = [edge('main', 'a'), edge('a', 'b'), edge('a', 'dup')];
const project = minimalProject({
scenes: {
[s1]: { id: s1, title: 'Start' },
[s2]: { id: s2, title: 'Mid' },
[s3]: { id: s3, title: 'End' },
},
sceneListOrder: [s3, s1, s2],
sceneGraphNodes: nodes,
sceneGraphEdges: edges,
currentGraphNodeId: asGraphNodeId('a'),
});
const list = listActiveStorylineScenes(project);
assert.deepEqual(
list.map((e) => e.sceneId),
[s3, s1, s2],
);
assert.equal(list[2]?.graphNodeId, asGraphNodeId('a'));
});
void test('listActiveStorylineScenes: side line only', () => {
const s1 = asSceneId('s1');
const s2 = asSceneId('s2');
const sSide = asSceneId('sSide');
const nodes = [
node('main', 's1', { isStartScene: true }),
node('side', 'sSide', { isSideStoryStart: true }),
node('x', 's2'),
];
const edges = [edge('side', 'x')];
const project = minimalProject({
scenes: {
[s1]: { id: s1, title: 'Main' },
[s2]: { id: s2, title: 'Side step' },
[sSide]: { id: sSide, title: 'Side start' },
},
sceneListOrder: [s1, s2, sSide],
sceneGraphNodes: nodes,
sceneGraphEdges: edges,
currentGraphNodeId: asGraphNodeId('x'),
});
const ids = getActiveStorylineGraphNodeIds(nodes, edges, asGraphNodeId('x'));
assert.deepEqual([...ids].sort(), [asGraphNodeId('side'), asGraphNodeId('x')].sort());
const list = listActiveStorylineScenes(project);
assert.deepEqual(
list.map((e) => e.sceneId),
[s2, sSide],
);
});
void test('listActiveStorylineScenes: current node preferred for duplicate sceneId', () => {
const s2 = asSceneId('s2');
const nodes = [
node('main', 's1', { isStartScene: true }),
node('a', 's2'),
node('dup', 's2'),
];
const edges = [edge('main', 'a'), edge('main', 'dup')];
const project = minimalProject({
scenes: { [asSceneId('s1')]: { id: asSceneId('s1'), title: 'S1' }, [s2]: { id: s2, title: 'S2' } },
sceneListOrder: [asSceneId('s1'), s2],
sceneGraphNodes: nodes,
sceneGraphEdges: edges,
currentGraphNodeId: asGraphNodeId('dup'),
});
const list = listActiveStorylineScenes(project);
assert.equal(list.find((e) => e.sceneId === s2)?.graphNodeId, asGraphNodeId('dup'));
});
+77
View File
@@ -0,0 +1,77 @@
import type { GraphNodeId, Project, SceneGraphEdge, SceneGraphNode, SceneId } from '../types';
import { reconcileSceneListOrder } from './sceneListOrder';
import {
buildStoryRootMap,
getNodeStoryRoot,
getSideStoryComponentNodeIds,
isSideStoryRoot,
} from './sceneGraphLineage';
export type ActiveStorylineSceneEntry = {
sceneId: SceneId;
graphNodeId: GraphNodeId;
};
/** Узлы графа, принадлежащие активной сюжетной линии текущего положения. */
export function getActiveStorylineGraphNodeIds(
nodes: SceneGraphNode[],
edges: SceneGraphEdge[],
currentGraphNodeId: GraphNodeId,
): Set<GraphNodeId> {
const root = getNodeStoryRoot(nodes, edges, currentGraphNodeId);
if (root === 'main') {
const rootMap = buildStoryRootMap(nodes, edges);
const ids = new Set<GraphNodeId>();
for (const [id, r] of rootMap) {
if (r === 'main') ids.add(id);
}
return ids;
}
if (isSideStoryRoot(root)) {
return getSideStoryComponentNodeIds(nodes, edges, root);
}
return new Set();
}
function pickGraphNodeForScene(
sceneId: SceneId,
storylineNodeIds: Set<GraphNodeId>,
nodes: SceneGraphNode[],
currentGraphNodeId: GraphNodeId | null,
): GraphNodeId | null {
const matching = nodes.filter((n) => storylineNodeIds.has(n.id) && n.sceneId === sceneId);
if (matching.length === 0) return null;
if (currentGraphNodeId && matching.some((n) => n.id === currentGraphNodeId)) {
return currentGraphNodeId;
}
return matching[0]?.id ?? null;
}
/**
* Сцены активной линии в порядке бокового списка редактора; каждая sceneId — один раз.
*/
export function listActiveStorylineScenes(project: Project): ActiveStorylineSceneEntry[] {
const currentGraphNodeId = project.currentGraphNodeId;
if (!currentGraphNodeId) return [];
const storylineNodeIds = getActiveStorylineGraphNodeIds(
project.sceneGraphNodes,
project.sceneGraphEdges,
currentGraphNodeId,
);
if (storylineNodeIds.size === 0) return [];
const order = reconcileSceneListOrder(project.scenes, project.sceneListOrder);
const out: ActiveStorylineSceneEntry[] = [];
for (const sceneId of order) {
const graphNodeId = pickGraphNodeForScene(
sceneId,
storylineNodeIds,
project.sceneGraphNodes,
currentGraphNodeId,
);
if (graphNodeId) out.push({ sceneId, graphNodeId });
}
return out;
}