feat(control): история-аккордеон и форма кисти затемнения

Переименовать блок сюжетной линии в «История» и сделать его сворачиваемым. Добавить выбор круглой/квадратной формы для кистей открытия и закрытия с сохранением в штрихах и сбросом при смене инструмента или сцены без затемнения.

Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
Ivan Fontosh
2026-09-02 10:00:10 +08:00
parent 0a9168b320
commit ea718a26df
8 changed files with 288 additions and 30 deletions
+107 -15
View File
@@ -16,6 +16,7 @@ import type {
MaterialId,
NpcDisposition,
Scene,
SceneDarknessBrushShape,
SceneId,
SceneViewCamera,
} from '../../shared/types';
@@ -185,6 +186,10 @@ export function ControlApp() {
const [session, setSession] = useState<SessionState | null>(null);
const historyRef = useRef<GraphNodeId[]>([]);
const [history, setHistory] = useState<GraphNodeId[]>([]);
const [historyOpen, setHistoryOpen] = useState(true);
const [darknessBrushShape, setDarknessBrushShape] = useState<SceneDarknessBrushShape>('circle');
const darknessBrushShapeRef = useRef(darknessBrushShape);
darknessBrushShapeRef.current = darknessBrushShape;
/** Сцена основного сюжета, с которой ушли в побочную линию (только текущая сессия). */
const mainStoryReturnRef = useRef<GraphNodeId | null>(null);
const [mainStoryReturnGraphNodeId, setMainStoryReturnGraphNodeId] = useState<GraphNodeId | null>(null);
@@ -399,6 +404,12 @@ export function ControlApp() {
);
const isDarkenScene = Boolean(currentScene?.darkenScene);
useEffect(() => {
if (!isDarkenScene) {
setDarknessBrushShape('circle');
}
}, [isDarkenScene]);
const snapNormActive = useCallback(
(nx: number, ny: number) => {
const grid = currentScene?.grid;
@@ -1105,9 +1116,25 @@ export function ControlApp() {
/** Повторный клик по активному инструменту снимает выбор. */
function selectEffectTool(next: Exclude<EffectToolType, 'none'>): void {
const toolName: EffectToolType = tool.tool === next ? 'none' : next;
const leavingDarknessBrush =
(tool.tool === 'exploreBrush' || tool.tool === 'closeBrush') &&
toolName !== 'exploreBrush' &&
toolName !== 'closeBrush';
if (leavingDarknessBrush) {
setDarknessBrushShape('circle');
}
void fx.dispatch({ kind: 'tool.set', tool: { ...tool, tool: toolName } });
}
function darknessBrushDraft(points: { x: number; y: number; tMs: number }[], mode: 'reveal' | 'cover') {
return {
points,
radiusN: toolRef.current.radiusN,
mode,
shape: darknessBrushShapeRef.current,
};
}
function layoutBrushCursor(): void {
const el = brushCursorElRef.current;
const p = cursorPosRef.current;
@@ -1120,6 +1147,13 @@ export function ControlApp() {
return;
}
el.style.visibility = 'visible';
const isDarknessBrush = t.tool === 'exploreBrush' || t.tool === 'closeBrush';
el.className = [
styles.brushCursor,
isDarknessBrush && darknessBrushShapeRef.current === 'square' ? styles.brushCursorSquare : '',
]
.filter(Boolean)
.join(' ');
const ox = cr ? cr.x : 0;
const oy = cr ? cr.y : 0;
const cw = cr ? cr.w : ps.w;
@@ -1297,11 +1331,10 @@ export function ControlApp() {
) {
void sd.dispatch({
kind: 'draft.set',
draft: {
points: b.points,
radiusN: toolRef.current.radiusN,
mode: b.tool === 'closeBrush' ? 'cover' : 'reveal',
},
draft: darknessBrushDraft(
b.points,
b.tool === 'closeBrush' ? 'cover' : 'reveal',
),
});
}
});
@@ -1309,7 +1342,7 @@ export function ControlApp() {
useLayoutEffect(() => {
layoutBrushCursor();
}, [tool.radiusN, previewContentRect, previewSize.w, previewSize.h]);
}, [tool.radiusN, previewContentRect, previewSize.w, previewSize.h, darknessBrushShape]);
useEffect(() => {
return () => {
@@ -1459,6 +1492,7 @@ export function ControlApp() {
points: b.points,
radiusN: tool.radiusN,
mode: b.tool === 'closeBrush' ? 'cover' : 'reveal',
shape: darknessBrushShapeRef.current,
},
});
}
@@ -1891,6 +1925,55 @@ export function ControlApp() {
/>
<div className={styles.radiusValue}>{Math.round(tool.radiusN * 100)}</div>
</div>
{tool.tool === 'exploreBrush' || tool.tool === 'closeBrush' ? (
<div className={styles.brushShapeRow}>
<div className={styles.radiusLabel}>{t('control.brushShape')}</div>
<div className={styles.brushShapeButtons}>
<Button
variant={darknessBrushShape === 'circle' ? 'primary' : 'ghost'}
iconOnly
title={t('control.brushShapeCircle')}
ariaLabel={t('control.brushShapeCircle')}
onClick={() => setDarknessBrushShape('circle')}
>
<span className={styles.brushShapeIcon} aria-hidden>
<svg viewBox="0 0 24 24" width="20" height="20" aria-hidden>
<circle
cx="12"
cy="12"
r="7"
fill="none"
stroke="currentColor"
strokeWidth="2"
/>
</svg>
</span>
</Button>
<Button
variant={darknessBrushShape === 'square' ? 'primary' : 'ghost'}
iconOnly
title={t('control.brushShapeSquare')}
ariaLabel={t('control.brushShapeSquare')}
onClick={() => setDarknessBrushShape('square')}
>
<span className={styles.brushShapeIcon} aria-hidden>
<svg viewBox="0 0 24 24" width="20" height="20" aria-hidden>
<rect
x="5"
y="5"
width="14"
height="14"
fill="none"
stroke="currentColor"
strokeWidth="2"
rx="1"
/>
</svg>
</span>
</Button>
</div>
</div>
) : null}
<div className={[styles.radiusRow, styles.effectsSoundRow].join(' ')}>
<div className={styles.radiusLabel}>{t('control.effectsSound')}</div>
<input
@@ -1914,10 +1997,18 @@ export function ControlApp() {
</div>
<div className={styles.spacer12} />
</>
<div className={styles.storyWrap}>
<div className={styles.sectionLabel}>{t('control.storyLine')}</div>
<div className={styles.spacer10} />
<div className={styles.storyScroll}>
<div className={[styles.storyWrap, historyOpen ? '' : styles.storyWrapCollapsed].filter(Boolean).join(' ')}>
<div className={styles.historyAccordion} data-testid="control-history-accordion">
<button
type="button"
className={styles.historyAccordionHead}
aria-expanded={historyOpen}
onClick={() => setHistoryOpen((v) => !v)}
>
{t('control.storyLine')} {historyOpen ? '▾' : '▸'}
</button>
{historyOpen ? (
<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;
@@ -1952,6 +2043,8 @@ export function ControlApp() {
{history.length === 0 ? (
<div className={styles.emptyStory}>{t('control.noActiveScene')}</div>
) : null}
</div>
) : null}
</div>
</div>
</Surface>
@@ -2175,11 +2268,10 @@ export function ControlApp() {
if (tool.tool === 'exploreBrush' || tool.tool === 'closeBrush') {
void sd.dispatch({
kind: 'draft.set',
draft: {
points: [startPt],
radiusN: tool.radiusN,
mode: tool.tool === 'closeBrush' ? 'cover' : 'reveal',
},
draft: darknessBrushDraft(
[startPt],
tool.tool === 'closeBrush' ? 'cover' : 'reveal',
),
});
}
pushDraftToPixi();