feat(control): история-аккордеон и форма кисти затемнения
Переименовать блок сюжетной линии в «История» и сделать его сворачиваемым. Добавить выбор круглой/квадратной формы для кистей открытия и закрытия с сохранением в штрихах и сбросом при смене инструмента или сцены без затемнения. Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
@@ -217,11 +217,42 @@
|
|||||||
flex-direction: column;
|
flex-direction: column;
|
||||||
}
|
}
|
||||||
|
|
||||||
.storyScroll {
|
.storyWrapCollapsed {
|
||||||
flex: 0 0 70%;
|
flex: 0 0 auto;
|
||||||
|
}
|
||||||
|
|
||||||
|
.storyWrapCollapsed .historyAccordion {
|
||||||
|
flex: 0 0 auto;
|
||||||
|
}
|
||||||
|
|
||||||
|
.historyAccordion {
|
||||||
|
flex: 1;
|
||||||
min-height: 0;
|
min-height: 0;
|
||||||
border-radius: var(--radius-lg);
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
border: 1px solid var(--stroke);
|
border: 1px solid var(--stroke);
|
||||||
|
border-radius: var(--radius-lg);
|
||||||
|
overflow: hidden;
|
||||||
|
background: rgba(255, 255, 255, 0.02);
|
||||||
|
}
|
||||||
|
|
||||||
|
.historyAccordionHead {
|
||||||
|
width: 100%;
|
||||||
|
text-align: left;
|
||||||
|
padding: 10px 12px;
|
||||||
|
font-size: var(--text-xs);
|
||||||
|
font-weight: 800;
|
||||||
|
color: var(--text2);
|
||||||
|
background: transparent;
|
||||||
|
border: 0;
|
||||||
|
cursor: pointer;
|
||||||
|
flex-shrink: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.storyScroll {
|
||||||
|
flex: 1;
|
||||||
|
min-height: 0;
|
||||||
|
border-top: 1px solid var(--stroke);
|
||||||
background: var(--color-overlay-dark-2);
|
background: var(--color-overlay-dark-2);
|
||||||
overflow: auto;
|
overflow: auto;
|
||||||
padding: 10px;
|
padding: 10px;
|
||||||
@@ -394,6 +425,34 @@
|
|||||||
pointer-events: none;
|
pointer-events: none;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.brushCursorSquare {
|
||||||
|
border-radius: 2px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.brushShapeRow {
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: 120px 1fr;
|
||||||
|
align-items: center;
|
||||||
|
gap: 8px;
|
||||||
|
margin-top: 4px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.brushShapeButtons {
|
||||||
|
display: flex;
|
||||||
|
gap: 8px;
|
||||||
|
flex-wrap: wrap;
|
||||||
|
align-items: center;
|
||||||
|
}
|
||||||
|
|
||||||
|
.brushShapeIcon {
|
||||||
|
display: flex;
|
||||||
|
width: 20px;
|
||||||
|
height: 20px;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
color: var(--text1);
|
||||||
|
}
|
||||||
|
|
||||||
.brushLayer {
|
.brushLayer {
|
||||||
position: absolute;
|
position: absolute;
|
||||||
inset: 0;
|
inset: 0;
|
||||||
|
|||||||
@@ -16,6 +16,7 @@ import type {
|
|||||||
MaterialId,
|
MaterialId,
|
||||||
NpcDisposition,
|
NpcDisposition,
|
||||||
Scene,
|
Scene,
|
||||||
|
SceneDarknessBrushShape,
|
||||||
SceneId,
|
SceneId,
|
||||||
SceneViewCamera,
|
SceneViewCamera,
|
||||||
} from '../../shared/types';
|
} from '../../shared/types';
|
||||||
@@ -185,6 +186,10 @@ export function ControlApp() {
|
|||||||
const [session, setSession] = useState<SessionState | null>(null);
|
const [session, setSession] = useState<SessionState | null>(null);
|
||||||
const historyRef = useRef<GraphNodeId[]>([]);
|
const historyRef = useRef<GraphNodeId[]>([]);
|
||||||
const [history, setHistory] = useState<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 mainStoryReturnRef = useRef<GraphNodeId | null>(null);
|
||||||
const [mainStoryReturnGraphNodeId, setMainStoryReturnGraphNodeId] = useState<GraphNodeId | null>(null);
|
const [mainStoryReturnGraphNodeId, setMainStoryReturnGraphNodeId] = useState<GraphNodeId | null>(null);
|
||||||
@@ -399,6 +404,12 @@ export function ControlApp() {
|
|||||||
);
|
);
|
||||||
const isDarkenScene = Boolean(currentScene?.darkenScene);
|
const isDarkenScene = Boolean(currentScene?.darkenScene);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (!isDarkenScene) {
|
||||||
|
setDarknessBrushShape('circle');
|
||||||
|
}
|
||||||
|
}, [isDarkenScene]);
|
||||||
|
|
||||||
const snapNormActive = useCallback(
|
const snapNormActive = useCallback(
|
||||||
(nx: number, ny: number) => {
|
(nx: number, ny: number) => {
|
||||||
const grid = currentScene?.grid;
|
const grid = currentScene?.grid;
|
||||||
@@ -1105,9 +1116,25 @@ export function ControlApp() {
|
|||||||
/** Повторный клик по активному инструменту снимает выбор. */
|
/** Повторный клик по активному инструменту снимает выбор. */
|
||||||
function selectEffectTool(next: Exclude<EffectToolType, 'none'>): void {
|
function selectEffectTool(next: Exclude<EffectToolType, 'none'>): void {
|
||||||
const toolName: EffectToolType = tool.tool === next ? 'none' : next;
|
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 } });
|
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 {
|
function layoutBrushCursor(): void {
|
||||||
const el = brushCursorElRef.current;
|
const el = brushCursorElRef.current;
|
||||||
const p = cursorPosRef.current;
|
const p = cursorPosRef.current;
|
||||||
@@ -1120,6 +1147,13 @@ export function ControlApp() {
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
el.style.visibility = 'visible';
|
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 ox = cr ? cr.x : 0;
|
||||||
const oy = cr ? cr.y : 0;
|
const oy = cr ? cr.y : 0;
|
||||||
const cw = cr ? cr.w : ps.w;
|
const cw = cr ? cr.w : ps.w;
|
||||||
@@ -1297,11 +1331,10 @@ export function ControlApp() {
|
|||||||
) {
|
) {
|
||||||
void sd.dispatch({
|
void sd.dispatch({
|
||||||
kind: 'draft.set',
|
kind: 'draft.set',
|
||||||
draft: {
|
draft: darknessBrushDraft(
|
||||||
points: b.points,
|
b.points,
|
||||||
radiusN: toolRef.current.radiusN,
|
b.tool === 'closeBrush' ? 'cover' : 'reveal',
|
||||||
mode: b.tool === 'closeBrush' ? 'cover' : 'reveal',
|
),
|
||||||
},
|
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
@@ -1309,7 +1342,7 @@ export function ControlApp() {
|
|||||||
|
|
||||||
useLayoutEffect(() => {
|
useLayoutEffect(() => {
|
||||||
layoutBrushCursor();
|
layoutBrushCursor();
|
||||||
}, [tool.radiusN, previewContentRect, previewSize.w, previewSize.h]);
|
}, [tool.radiusN, previewContentRect, previewSize.w, previewSize.h, darknessBrushShape]);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
return () => {
|
return () => {
|
||||||
@@ -1459,6 +1492,7 @@ export function ControlApp() {
|
|||||||
points: b.points,
|
points: b.points,
|
||||||
radiusN: tool.radiusN,
|
radiusN: tool.radiusN,
|
||||||
mode: b.tool === 'closeBrush' ? 'cover' : 'reveal',
|
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 className={styles.radiusValue}>{Math.round(tool.radiusN * 100)}</div>
|
||||||
</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.radiusRow, styles.effectsSoundRow].join(' ')}>
|
||||||
<div className={styles.radiusLabel}>{t('control.effectsSound')}</div>
|
<div className={styles.radiusLabel}>{t('control.effectsSound')}</div>
|
||||||
<input
|
<input
|
||||||
@@ -1914,9 +1997,17 @@ export function ControlApp() {
|
|||||||
</div>
|
</div>
|
||||||
<div className={styles.spacer12} />
|
<div className={styles.spacer12} />
|
||||||
</>
|
</>
|
||||||
<div className={styles.storyWrap}>
|
<div className={[styles.storyWrap, historyOpen ? '' : styles.storyWrapCollapsed].filter(Boolean).join(' ')}>
|
||||||
<div className={styles.sectionLabel}>{t('control.storyLine')}</div>
|
<div className={styles.historyAccordion} data-testid="control-history-accordion">
|
||||||
<div className={styles.spacer10} />
|
<button
|
||||||
|
type="button"
|
||||||
|
className={styles.historyAccordionHead}
|
||||||
|
aria-expanded={historyOpen}
|
||||||
|
onClick={() => setHistoryOpen((v) => !v)}
|
||||||
|
>
|
||||||
|
{t('control.storyLine')} {historyOpen ? '▾' : '▸'}
|
||||||
|
</button>
|
||||||
|
{historyOpen ? (
|
||||||
<div className={styles.storyScroll}>
|
<div className={styles.storyScroll}>
|
||||||
{history.map((gnId, idx) => {
|
{history.map((gnId, idx) => {
|
||||||
const gn = project?.sceneGraphNodes.find((n) => n.id === gnId);
|
const gn = project?.sceneGraphNodes.find((n) => n.id === gnId);
|
||||||
@@ -1953,6 +2044,8 @@ export function ControlApp() {
|
|||||||
<div className={styles.emptyStory}>{t('control.noActiveScene')}</div>
|
<div className={styles.emptyStory}>{t('control.noActiveScene')}</div>
|
||||||
) : null}
|
) : null}
|
||||||
</div>
|
</div>
|
||||||
|
) : null}
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</Surface>
|
</Surface>
|
||||||
|
|
||||||
@@ -2175,11 +2268,10 @@ export function ControlApp() {
|
|||||||
if (tool.tool === 'exploreBrush' || tool.tool === 'closeBrush') {
|
if (tool.tool === 'exploreBrush' || tool.tool === 'closeBrush') {
|
||||||
void sd.dispatch({
|
void sd.dispatch({
|
||||||
kind: 'draft.set',
|
kind: 'draft.set',
|
||||||
draft: {
|
draft: darknessBrushDraft(
|
||||||
points: [startPt],
|
[startPt],
|
||||||
radiusN: tool.radiusN,
|
tool.tool === 'closeBrush' ? 'cover' : 'reveal',
|
||||||
mode: tool.tool === 'closeBrush' ? 'cover' : 'reveal',
|
),
|
||||||
},
|
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
pushDraftToPixi();
|
pushDraftToPixi();
|
||||||
|
|||||||
@@ -113,7 +113,7 @@ void test('ControlApp: эффекты в пульте, иконки с тулт
|
|||||||
instruments !== -1 && fx !== -1 && instruments < fx,
|
instruments !== -1 && fx !== -1 && instruments < fx,
|
||||||
'Блок инструментов должен быть выше эффектов',
|
'Блок инструментов должен быть выше эффектов',
|
||||||
);
|
);
|
||||||
assert.ok(fx !== -1 && story !== -1 && fx < story, 'Блок эффектов должен быть выше сюжетной линии');
|
assert.ok(fx !== -1 && story !== -1 && fx < story, 'Блок эффектов должен быть выше истории');
|
||||||
});
|
});
|
||||||
|
|
||||||
void test('ControlApp: чекбокс привязки токенов к сетке', () => {
|
void test('ControlApp: чекбокс привязки токенов к сетке', () => {
|
||||||
@@ -128,11 +128,14 @@ void test('ControlApp: чекбокс привязки токенов к сет
|
|||||||
assert.ok(css.includes('.snapToGrid'));
|
assert.ok(css.includes('.snapToGrid'));
|
||||||
});
|
});
|
||||||
|
|
||||||
void test('ControlApp: сюжетная линия — колонка сверху вниз и фон как у карточек ветвления', () => {
|
void test('ControlApp: история — колонка сверху вниз и фон как у карточек ветвления', () => {
|
||||||
const src = readControlApp();
|
const src = readControlApp();
|
||||||
const css = readControlAppCss();
|
const css = readControlAppCss();
|
||||||
const story = src.indexOf("t('control.storyLine')");
|
const story = src.indexOf("t('control.storyLine')");
|
||||||
assert.ok(story !== -1);
|
assert.ok(story !== -1);
|
||||||
|
assert.ok(src.includes('data-testid="control-history-accordion"'));
|
||||||
|
assert.ok(src.includes('aria-expanded={historyOpen}'));
|
||||||
|
assert.ok(src.includes('const [historyOpen, setHistoryOpen] = useState(true)'));
|
||||||
assert.ok(src.includes('className={styles.storyScroll}'));
|
assert.ok(src.includes('className={styles.storyScroll}'));
|
||||||
assert.match(css, /\.storyScroll[\s\S]*?justify-content:\s*flex-start/);
|
assert.match(css, /\.storyScroll[\s\S]*?justify-content:\s*flex-start/);
|
||||||
assert.match(css, /\.storyScroll[\s\S]*?background:\s*var\(--color-overlay-dark-2\)/);
|
assert.match(css, /\.storyScroll[\s\S]*?background:\s*var\(--color-overlay-dark-2\)/);
|
||||||
@@ -173,6 +176,27 @@ void test('ControlApp: слой кисти не использует курсо
|
|||||||
assert.match(css, /\.brushLayer[\s\S]*?cursor:\s*crosshair/);
|
assert.match(css, /\.brushLayer[\s\S]*?cursor:\s*crosshair/);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
void test('ControlApp: форма кисти затемнения — UI, shape в штрихах и сброс', () => {
|
||||||
|
const src = readControlApp();
|
||||||
|
const css = readControlAppCss();
|
||||||
|
assert.ok(src.includes("t('control.brushShape')"));
|
||||||
|
assert.ok(src.includes("t('control.brushShapeCircle')"));
|
||||||
|
assert.ok(src.includes("t('control.brushShapeSquare')"));
|
||||||
|
assert.ok(src.includes('darknessBrushShape'));
|
||||||
|
assert.ok(src.includes('darknessBrushDraft'));
|
||||||
|
assert.ok(src.includes("shape: darknessBrushShapeRef.current"));
|
||||||
|
assert.ok(src.includes('setDarknessBrushShape'));
|
||||||
|
assert.ok(src.includes('styles.brushShapeRow'));
|
||||||
|
assert.ok(src.includes("tool.tool === 'exploreBrush' || tool.tool === 'closeBrush'"));
|
||||||
|
assert.ok(css.includes('.brushCursorSquare'));
|
||||||
|
assert.ok(css.includes('.brushShapeRow'));
|
||||||
|
const radius = src.indexOf("t('control.brushRadius')");
|
||||||
|
const shape = src.indexOf("t('control.brushShape')");
|
||||||
|
const sound = src.indexOf("t('control.effectsSound')");
|
||||||
|
assert.ok(radius !== -1 && shape !== -1 && sound !== -1);
|
||||||
|
assert.ok(radius < shape && shape < sound, 'Форма кисти между радиусом и звуком эффектов');
|
||||||
|
});
|
||||||
|
|
||||||
void test('ControlApp: радиус кисти не в блоке предпросмотра', () => {
|
void test('ControlApp: радиус кисти не в блоке предпросмотра', () => {
|
||||||
const src = readControlApp();
|
const src = readControlApp();
|
||||||
const previewLabel = src.indexOf("t('control.screenPreview')");
|
const previewLabel = src.indexOf("t('control.screenPreview')");
|
||||||
|
|||||||
@@ -630,8 +630,11 @@ export const EDITOR_MESSAGES: Record<EditorLocale, Record<string, string>> = {
|
|||||||
'control.poisonCloud': 'Облако яда',
|
'control.poisonCloud': 'Облако яда',
|
||||||
'control.explosion': 'Взрыв',
|
'control.explosion': 'Взрыв',
|
||||||
'control.brushRadius': 'Радиус кисти',
|
'control.brushRadius': 'Радиус кисти',
|
||||||
|
'control.brushShape': 'Форма кисти',
|
||||||
|
'control.brushShapeCircle': 'Круглая',
|
||||||
|
'control.brushShapeSquare': 'Квадратная',
|
||||||
'control.effectsSound': 'Звук эффектов',
|
'control.effectsSound': 'Звук эффектов',
|
||||||
'control.storyLine': 'СЮЖЕТНАЯ ЛИНИЯ',
|
'control.storyLine': 'ИСТОРИЯ',
|
||||||
'control.gotoScene': 'Перейти к этой сцене',
|
'control.gotoScene': 'Перейти к этой сцене',
|
||||||
'control.currentSceneBadge': 'ТЕКУЩАЯ СЦЕНА',
|
'control.currentSceneBadge': 'ТЕКУЩАЯ СЦЕНА',
|
||||||
'control.passed': 'Пройдено',
|
'control.passed': 'Пройдено',
|
||||||
@@ -1267,8 +1270,11 @@ export const EDITOR_MESSAGES: Record<EditorLocale, Record<string, string>> = {
|
|||||||
'control.poisonCloud': 'Poison cloud',
|
'control.poisonCloud': 'Poison cloud',
|
||||||
'control.explosion': 'Explosion',
|
'control.explosion': 'Explosion',
|
||||||
'control.brushRadius': 'Brush radius',
|
'control.brushRadius': 'Brush radius',
|
||||||
|
'control.brushShape': 'Brush shape',
|
||||||
|
'control.brushShapeCircle': 'Circle',
|
||||||
|
'control.brushShapeSquare': 'Square',
|
||||||
'control.effectsSound': 'Effects sound',
|
'control.effectsSound': 'Effects sound',
|
||||||
'control.storyLine': 'STORYLINE',
|
'control.storyLine': 'HISTORY',
|
||||||
'control.gotoScene': 'Go to this scene',
|
'control.gotoScene': 'Go to this scene',
|
||||||
'control.currentSceneBadge': 'CURRENT SCENE',
|
'control.currentSceneBadge': 'CURRENT SCENE',
|
||||||
'control.passed': 'Visited',
|
'control.passed': 'Visited',
|
||||||
|
|||||||
@@ -1,11 +1,15 @@
|
|||||||
import React, { useEffect, useRef } from 'react';
|
import React, { useEffect, useRef } from 'react';
|
||||||
|
|
||||||
import type {
|
import type {
|
||||||
|
SceneDarknessBrushShape,
|
||||||
SceneDarknessRevealStroke,
|
SceneDarknessRevealStroke,
|
||||||
SceneDarknessState,
|
SceneDarknessState,
|
||||||
SceneDarknessStrokeMode,
|
SceneDarknessStrokeMode,
|
||||||
} from '../../../shared/types';
|
} from '../../../shared/types';
|
||||||
import { normalizeSceneDarknessStrokeMode } from '../../../shared/types/sceneDarkness';
|
import {
|
||||||
|
normalizeSceneDarknessBrushShape,
|
||||||
|
normalizeSceneDarknessStrokeMode,
|
||||||
|
} from '../../../shared/types/sceneDarkness';
|
||||||
|
|
||||||
export type SceneDarknessOverlayProps = {
|
export type SceneDarknessOverlayProps = {
|
||||||
state: SceneDarknessState | null;
|
state: SceneDarknessState | null;
|
||||||
@@ -15,18 +19,44 @@ export type SceneDarknessOverlayProps = {
|
|||||||
style?: React.CSSProperties;
|
style?: React.CSSProperties;
|
||||||
};
|
};
|
||||||
|
|
||||||
function drawDarknessStroke(
|
type DarknessStrokeLike = SceneDarknessRevealStroke | {
|
||||||
|
points: { x: number; y: number }[];
|
||||||
|
radiusN: number;
|
||||||
|
mode?: SceneDarknessStrokeMode;
|
||||||
|
shape?: SceneDarknessBrushShape;
|
||||||
|
};
|
||||||
|
|
||||||
|
function stampSquare(
|
||||||
ctx: CanvasRenderingContext2D,
|
ctx: CanvasRenderingContext2D,
|
||||||
stroke: SceneDarknessRevealStroke | { points: { x: number; y: number }[]; radiusN: number; mode?: SceneDarknessStrokeMode },
|
x: number,
|
||||||
|
y: number,
|
||||||
|
side: number,
|
||||||
|
): void {
|
||||||
|
ctx.fillRect(x - side / 2, y - side / 2, side, side);
|
||||||
|
}
|
||||||
|
|
||||||
|
function drawSquareDarknessStroke(
|
||||||
|
ctx: CanvasRenderingContext2D,
|
||||||
|
stroke: DarknessStrokeLike,
|
||||||
|
w: number,
|
||||||
|
h: number,
|
||||||
|
): void {
|
||||||
|
const pts = stroke.points;
|
||||||
|
if (pts.length === 0) return;
|
||||||
|
const side = stroke.radiusN * Math.min(w, h) * 2;
|
||||||
|
for (const p of pts) {
|
||||||
|
stampSquare(ctx, p.x * w, p.y * h, side);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function drawCircleDarknessStroke(
|
||||||
|
ctx: CanvasRenderingContext2D,
|
||||||
|
stroke: DarknessStrokeLike,
|
||||||
w: number,
|
w: number,
|
||||||
h: number,
|
h: number,
|
||||||
): void {
|
): void {
|
||||||
const pts = stroke.points;
|
const pts = stroke.points;
|
||||||
if (pts.length === 0) return;
|
if (pts.length === 0) return;
|
||||||
const mode = normalizeSceneDarknessStrokeMode(stroke.mode);
|
|
||||||
ctx.globalCompositeOperation = mode === 'cover' ? 'source-over' : 'destination-out';
|
|
||||||
ctx.fillStyle = '#000000';
|
|
||||||
ctx.strokeStyle = '#000000';
|
|
||||||
const r = stroke.radiusN * Math.min(w, h);
|
const r = stroke.radiusN * Math.min(w, h);
|
||||||
if (pts.length === 1) {
|
if (pts.length === 1) {
|
||||||
const p = pts[0];
|
const p = pts[0];
|
||||||
@@ -51,6 +81,24 @@ function drawDarknessStroke(
|
|||||||
ctx.stroke();
|
ctx.stroke();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function drawDarknessStroke(
|
||||||
|
ctx: CanvasRenderingContext2D,
|
||||||
|
stroke: DarknessStrokeLike,
|
||||||
|
w: number,
|
||||||
|
h: number,
|
||||||
|
): void {
|
||||||
|
const mode = normalizeSceneDarknessStrokeMode(stroke.mode);
|
||||||
|
const shape = normalizeSceneDarknessBrushShape(stroke.shape);
|
||||||
|
ctx.globalCompositeOperation = mode === 'cover' ? 'source-over' : 'destination-out';
|
||||||
|
ctx.fillStyle = '#000000';
|
||||||
|
ctx.strokeStyle = '#000000';
|
||||||
|
if (shape === 'square') {
|
||||||
|
drawSquareDarknessStroke(ctx, stroke, w, h);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
drawCircleDarknessStroke(ctx, stroke, w, h);
|
||||||
|
}
|
||||||
|
|
||||||
export function SceneDarknessOverlay({
|
export function SceneDarknessOverlay({
|
||||||
state,
|
state,
|
||||||
viewport,
|
viewport,
|
||||||
|
|||||||
@@ -0,0 +1,20 @@
|
|||||||
|
import assert from 'node:assert/strict';
|
||||||
|
import test from 'node:test';
|
||||||
|
|
||||||
|
import {
|
||||||
|
normalizeSceneDarknessBrushShape,
|
||||||
|
normalizeSceneDarknessStrokeMode,
|
||||||
|
} from './sceneDarkness';
|
||||||
|
|
||||||
|
void test('normalizeSceneDarknessStrokeMode: reveal по умолчанию', () => {
|
||||||
|
assert.equal(normalizeSceneDarknessStrokeMode(undefined), 'reveal');
|
||||||
|
assert.equal(normalizeSceneDarknessStrokeMode('reveal'), 'reveal');
|
||||||
|
assert.equal(normalizeSceneDarknessStrokeMode('cover'), 'cover');
|
||||||
|
});
|
||||||
|
|
||||||
|
void test('normalizeSceneDarknessBrushShape: circle по умолчанию', () => {
|
||||||
|
assert.equal(normalizeSceneDarknessBrushShape(undefined), 'circle');
|
||||||
|
assert.equal(normalizeSceneDarknessBrushShape('circle'), 'circle');
|
||||||
|
assert.equal(normalizeSceneDarknessBrushShape('square'), 'square');
|
||||||
|
assert.equal(normalizeSceneDarknessBrushShape('triangle'), 'circle');
|
||||||
|
});
|
||||||
@@ -3,6 +3,8 @@ import type { NPoint } from './effects';
|
|||||||
/** reveal — снимает тьму (Кисть Открытия); cover — возвращает тьму (Кисть Закрытия). */
|
/** reveal — снимает тьму (Кисть Открытия); cover — возвращает тьму (Кисть Закрытия). */
|
||||||
export type SceneDarknessStrokeMode = 'reveal' | 'cover';
|
export type SceneDarknessStrokeMode = 'reveal' | 'cover';
|
||||||
|
|
||||||
|
export type SceneDarknessBrushShape = 'circle' | 'square';
|
||||||
|
|
||||||
export type SceneDarknessRevealStroke = {
|
export type SceneDarknessRevealStroke = {
|
||||||
id: string;
|
id: string;
|
||||||
seed: number;
|
seed: number;
|
||||||
@@ -11,12 +13,15 @@ export type SceneDarknessRevealStroke = {
|
|||||||
radiusN: number;
|
radiusN: number;
|
||||||
/** По умолчанию reveal (старые штрихи без поля). */
|
/** По умолчанию reveal (старые штрихи без поля). */
|
||||||
mode?: SceneDarknessStrokeMode;
|
mode?: SceneDarknessStrokeMode;
|
||||||
|
/** По умолчанию circle (старые штрихи без поля). */
|
||||||
|
shape?: SceneDarknessBrushShape;
|
||||||
};
|
};
|
||||||
|
|
||||||
export type SceneDarknessDraft = {
|
export type SceneDarknessDraft = {
|
||||||
points: NPoint[];
|
points: NPoint[];
|
||||||
radiusN: number;
|
radiusN: number;
|
||||||
mode?: SceneDarknessStrokeMode;
|
mode?: SceneDarknessStrokeMode;
|
||||||
|
shape?: SceneDarknessBrushShape;
|
||||||
};
|
};
|
||||||
|
|
||||||
export type SceneDarknessState = {
|
export type SceneDarknessState = {
|
||||||
@@ -35,3 +40,7 @@ export type SceneDarknessEvent =
|
|||||||
export function normalizeSceneDarknessStrokeMode(raw: unknown): SceneDarknessStrokeMode {
|
export function normalizeSceneDarknessStrokeMode(raw: unknown): SceneDarknessStrokeMode {
|
||||||
return raw === 'cover' ? 'cover' : 'reveal';
|
return raw === 'cover' ? 'cover' : 'reveal';
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export function normalizeSceneDarknessBrushShape(raw: unknown): SceneDarknessBrushShape {
|
||||||
|
return raw === 'square' ? 'square' : 'circle';
|
||||||
|
}
|
||||||
|
|||||||
+1
-1
@@ -10,7 +10,7 @@
|
|||||||
"build:obfuscate": "node scripts/build.mjs --production --obfuscate",
|
"build:obfuscate": "node scripts/build.mjs --production --obfuscate",
|
||||||
"lint": "eslint . --max-warnings 0",
|
"lint": "eslint . --max-warnings 0",
|
||||||
"typecheck": "tsc -p tsconfig.eslint.json --noEmit",
|
"typecheck": "tsc -p tsconfig.eslint.json --noEmit",
|
||||||
"test": "tsx --test app/renderer/shared/ui/controls.tooltip.test.ts app/renderer/shared/ui/secondaryWindows.stability.test.ts app/renderer/npcs/tiptapEditorSafe.test.ts app/renderer/editor/state/projectState.race.test.ts app/renderer/editor/fileDrop.test.ts app/shared/graph/sceneListOrder.test.ts app/renderer/editor/graph/sceneCardById.test.ts app/renderer/editor/i18n/editorMessages.locale.test.ts app/renderer/editor/help/helpLinkify.test.ts app/renderer/editor/sceneDescriptionHtml.test.ts app/shared/graph/storylineExportImport.test.ts app/shared/foundry/foundryGraph.test.ts app/shared/ipc/contracts.mediaRemoval.test.ts app/shared/effectEraserHitTest.test.ts app/shared/fieldEffectEraser.test.ts app/renderer/control/controlApp.effectsPanel.test.ts app/renderer/control/controlApp.audioPerf.networkRegression.test.ts app/renderer/control/controlApp.brushPerf.networkRegression.test.ts app/renderer/shared/useAssetImageUrl.cache.test.ts app/main/sessionIpc.phase6.networkRegression.test.ts app/renderer/shared/sceneOverlay/sceneOverlayHost.test.ts app/renderer/shared/effects/PxiEffectsOverlay.pointer.test.ts app/renderer/shared/traps/trapActivation.test.ts app/renderer/shared/videoSceneMapParity.test.ts app/main/windows/createWindows.editorClose.test.ts app/main/safeConsole.test.ts app/main/windows/bootWindow.test.ts app/main/effects/effectsStore.test.ts app/main/effects/sceneDarknessStore.test.ts app/main/sceneTraps/sceneTrapsStore.test.ts app/main/project/assetPrune.test.ts app/main/project/optimizeImageImport.test.ts app/main/project/scenePreviewThumbnail.test.ts app/main/project/fsRetry.test.ts app/main/project/zipRead.test.ts app/main/project/replaceFileAtomic.test.ts app/main/project/zipStore.legacyContract.test.ts app/shared/package.build.test.ts app/shared/license/canonicalJson.test.ts app/shared/license/productKey.test.ts app/shared/license/licenseService.networkRegression.test.ts app/shared/video/videoPlaybackPerf.networkRegression.test.ts app/shared/video/videoPlaybackLoop.networkRegression.test.ts app/main/license/verifyLicenseToken.test.ts app/main/license/machineFingerprint.test.ts app/shared/types/appPlayers.test.ts app/shared/types/npcDisposition.test.ts app/shared/types/npcExternalUrl.test.ts app/main/npcs/npcsOverlayStore.test.ts app/shared/types/sceneGrid.test.ts app/shared/types/sceneGridSnap.test.ts app/main/tokens/tokenGridSnapSessionStore.test.ts app/shared/players/playerTeams.test.ts app/shared/players/launchPlayersSelection.test.ts app/main/players/playersStore.test.ts app/main/players/sceneNpcTokensSessionStore.test.ts app/main/players/scenePlayerTokensSessionStore.test.ts app/main/tokens/sceneTokensSessionStore.test.ts app/shared/ipc/contracts.players.test.ts app/shared/types/containMediaRect.test.ts app/shared/types/scenePreviewRotation.test.ts app/shared/types/tokenPath.test.ts app/shared/types/tokenPathPlayback.test.ts && node --test scripts/build-env.test.mjs scripts/obfuscate-main.test.mjs scripts/release-mac-prep.test.mjs scripts/release-native-prep.test.mjs scripts/verify-packaged-sharp.test.mjs app/main/project/sharpRuntime.test.mjs",
|
"test": "tsx --test app/renderer/shared/ui/controls.tooltip.test.ts app/renderer/shared/ui/secondaryWindows.stability.test.ts app/renderer/npcs/tiptapEditorSafe.test.ts app/renderer/editor/state/projectState.race.test.ts app/renderer/editor/fileDrop.test.ts app/shared/graph/sceneListOrder.test.ts app/renderer/editor/graph/sceneCardById.test.ts app/renderer/editor/i18n/editorMessages.locale.test.ts app/renderer/editor/help/helpLinkify.test.ts app/renderer/editor/sceneDescriptionHtml.test.ts app/shared/graph/storylineExportImport.test.ts app/shared/foundry/foundryGraph.test.ts app/shared/ipc/contracts.mediaRemoval.test.ts app/shared/effectEraserHitTest.test.ts app/shared/fieldEffectEraser.test.ts app/renderer/control/controlApp.effectsPanel.test.ts app/renderer/control/controlApp.audioPerf.networkRegression.test.ts app/renderer/control/controlApp.brushPerf.networkRegression.test.ts app/renderer/shared/useAssetImageUrl.cache.test.ts app/main/sessionIpc.phase6.networkRegression.test.ts app/renderer/shared/sceneOverlay/sceneOverlayHost.test.ts app/renderer/shared/effects/PxiEffectsOverlay.pointer.test.ts app/renderer/shared/traps/trapActivation.test.ts app/renderer/shared/videoSceneMapParity.test.ts app/main/windows/createWindows.editorClose.test.ts app/main/safeConsole.test.ts app/main/windows/bootWindow.test.ts app/main/effects/effectsStore.test.ts app/main/effects/sceneDarknessStore.test.ts app/main/sceneTraps/sceneTrapsStore.test.ts app/main/project/assetPrune.test.ts app/main/project/optimizeImageImport.test.ts app/main/project/scenePreviewThumbnail.test.ts app/main/project/fsRetry.test.ts app/main/project/zipRead.test.ts app/main/project/replaceFileAtomic.test.ts app/main/project/zipStore.legacyContract.test.ts app/shared/package.build.test.ts app/shared/license/canonicalJson.test.ts app/shared/license/productKey.test.ts app/shared/license/licenseService.networkRegression.test.ts app/shared/video/videoPlaybackPerf.networkRegression.test.ts app/shared/video/videoPlaybackLoop.networkRegression.test.ts app/main/license/verifyLicenseToken.test.ts app/main/license/machineFingerprint.test.ts app/shared/types/appPlayers.test.ts app/shared/types/npcDisposition.test.ts app/shared/types/npcExternalUrl.test.ts app/main/npcs/npcsOverlayStore.test.ts app/shared/types/sceneGrid.test.ts app/shared/types/sceneGridSnap.test.ts app/shared/types/sceneDarkness.test.ts app/main/tokens/tokenGridSnapSessionStore.test.ts app/shared/players/playerTeams.test.ts app/shared/players/launchPlayersSelection.test.ts app/main/players/playersStore.test.ts app/main/players/sceneNpcTokensSessionStore.test.ts app/main/players/scenePlayerTokensSessionStore.test.ts app/main/tokens/sceneTokensSessionStore.test.ts app/shared/ipc/contracts.players.test.ts app/shared/types/containMediaRect.test.ts app/shared/types/scenePreviewRotation.test.ts app/shared/types/tokenPath.test.ts app/shared/types/tokenPathPlayback.test.ts && node --test scripts/build-env.test.mjs scripts/obfuscate-main.test.mjs scripts/release-mac-prep.test.mjs scripts/release-native-prep.test.mjs scripts/verify-packaged-sharp.test.mjs app/main/project/sharpRuntime.test.mjs",
|
||||||
"format": "prettier . --check",
|
"format": "prettier . --check",
|
||||||
"format:write": "prettier . --write",
|
"format:write": "prettier . --write",
|
||||||
"postinstall": "patch-package",
|
"postinstall": "patch-package",
|
||||||
|
|||||||
Reference in New Issue
Block a user