feat(control): инструмент «Маркер» на области видимости

Добавлена кисть маркера на пульте и презентации с настройками в панели эффектов; штрихи привязаны к жёлтой рамке, ластик и очистка работают с отдельным сессионным слоем.

Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
Ivan Fontosh
2026-09-02 12:53:06 +08:00
parent 153be98780
commit 8b28a9b11a
18 changed files with 906 additions and 32 deletions
+236 -30
View File
@@ -17,9 +17,11 @@ import type {
NpcDisposition,
Scene,
SceneDarknessBrushShape,
SceneMarkerBrushShape,
SceneId,
SceneViewCamera,
} from '../../shared/types';
import { normalizeHexColor } from '../../shared/npcs/npcGroups';
import {
DEFAULT_NPC_TOKEN_SESSION_SCALE,
DEFAULT_SCENE_NPC_TOKEN_SIZE_N,
@@ -47,8 +49,10 @@ import { ExplosionVideoOverlay } from '../shared/effects/ExplosionVideoOverlay';
import { PixiEffectsOverlay, type PixiEffectsOverlayHandle } from '../shared/effects/PxiEffectsOverlay';
import type { EffectInstance, EffectToolType, ExplosionInstance } from '../../shared/types/effects';
import { SceneDarknessOverlay } from '../shared/effects/SceneDarknessOverlay';
import { SceneMarkerOverlay } from '../shared/effects/SceneMarkerOverlay';
import { useEffectsState } from '../shared/effects/useEffectsState';
import { useSceneDarknessState } from '../shared/effects/useSceneDarknessState';
import { useSceneMarkerState } from '../shared/effects/useSceneMarkerState';
import { DEFAULT_MATERIALS_OVERLAY_LAYOUT, DEFAULT_NPCS_OVERLAY_LAYOUT, defaultLegendLayoutForMaterial } from '../../shared/types';
import { MaterialLegendPanel } from '../shared/materials/MaterialLegendPanel';
import { MaterialOverlay } from '../shared/materials/MaterialOverlay';
@@ -160,6 +164,7 @@ export function ControlApp() {
const [fxState, fx] = useEffectsState();
const [effectsSfxGainUi, setEffectsSfxGainUi] = useState(() => getEffectsSfxGain());
const [sdState, sd] = useSceneDarknessState();
const [markerState, marker] = useSceneMarkerState();
const [sceneTraps, sceneTrapsApi] = useSceneTrapsState();
const appTokens = useAppTokens();
const [sceneTokensSession, sceneTokensApi] = useSceneTokensSession();
@@ -190,6 +195,12 @@ export function ControlApp() {
const [darknessBrushShape, setDarknessBrushShape] = useState<SceneDarknessBrushShape>('circle');
const darknessBrushShapeRef = useRef(darknessBrushShape);
darknessBrushShapeRef.current = darknessBrushShape;
const [markerBrushShape, setMarkerBrushShape] = useState<SceneMarkerBrushShape>('circle');
const markerBrushShapeRef = useRef(markerBrushShape);
markerBrushShapeRef.current = markerBrushShape;
const [markerColor, setMarkerColor] = useState('#ff0000');
const markerColorRef = useRef(markerColor);
markerColorRef.current = markerColor;
/** Сцена основного сюжета, с которой ушли в побочную линию (только текущая сессия). */
const mainStoryReturnRef = useRef<GraphNodeId | null>(null);
const [mainStoryReturnGraphNodeId, setMainStoryReturnGraphNodeId] = useState<GraphNodeId | null>(null);
@@ -232,6 +243,7 @@ export function ControlApp() {
| 'freeze'
| 'exploreBrush'
| 'closeBrush'
| 'marker'
| 'eraser';
startN?: { x: number; y: number };
points?: { x: number; y: number; tMs: number }[];
@@ -262,6 +274,16 @@ export function ControlApp() {
presentationContentSize.height,
);
}, [presentationContentSize, previewSize.h, previewSize.w]);
/** Область видимости (жёлтая рамка на пульте); маркер рисуется в этих координатах. */
const markerViewport = useMemo(() => {
if (presentationScreenRect) return presentationScreenRect;
if (previewSize.w > 1 && previewSize.h > 1) {
return { x: 0, y: 0, w: previewSize.w, h: previewSize.h };
}
return previewContentRect;
}, [presentationScreenRect, previewContentRect, previewSize.h, previewSize.w]);
const markerViewportRef = useRef(markerViewport);
markerViewportRef.current = markerViewport;
const brushCursorElRef = useRef<HTMLDivElement | null>(null);
const cursorPosRef = useRef<{ x: number; y: number } | null>(null);
const draftPaintRafRef = useRef(0);
@@ -1123,9 +1145,21 @@ export function ControlApp() {
if (leavingDarknessBrush) {
setDarknessBrushShape('circle');
}
if (tool.tool === 'marker' && toolName !== 'marker') {
setMarkerBrushShape('circle');
}
void fx.dispatch({ kind: 'tool.set', tool: { ...tool, tool: toolName } });
}
function markerBrushDraft(points: { x: number; y: number; tMs: number }[]) {
return {
points,
radiusN: toolRef.current.radiusN,
color: markerColorRef.current,
shape: markerBrushShapeRef.current,
};
}
function darknessBrushDraft(points: { x: number; y: number; tMs: number }[], mode: 'reveal' | 'cover') {
return {
points,
@@ -1138,7 +1172,8 @@ export function ControlApp() {
function layoutBrushCursor(): void {
const el = brushCursorElRef.current;
const p = cursorPosRef.current;
const cr = previewContentRectRef.current;
const cr =
toolRef.current.tool === 'marker' ? markerViewportRef.current : previewContentRectRef.current;
const ps = previewSizeRef.current;
const t = toolRef.current;
if (!el) return;
@@ -1148,9 +1183,11 @@ export function ControlApp() {
}
el.style.visibility = 'visible';
const isDarknessBrush = t.tool === 'exploreBrush' || t.tool === 'closeBrush';
const isMarkerBrush = t.tool === 'marker';
el.className = [
styles.brushCursor,
isDarknessBrush && darknessBrushShapeRef.current === 'square' ? styles.brushCursorSquare : '',
isMarkerBrush && markerBrushShapeRef.current === 'square' ? styles.brushCursorSquare : '',
]
.filter(Boolean)
.join(' ');
@@ -1337,12 +1374,26 @@ export function ControlApp() {
),
});
}
if (b?.tool === 'marker' && b.points) {
void marker.dispatch({
kind: 'draft.set',
draft: markerBrushDraft(b.points),
});
}
});
}
useLayoutEffect(() => {
layoutBrushCursor();
}, [tool.radiusN, previewContentRect, previewSize.w, previewSize.h, darknessBrushShape]);
}, [
tool.radiusN,
previewContentRect,
markerViewport,
previewSize.w,
previewSize.h,
darknessBrushShape,
markerBrushShape,
]);
useEffect(() => {
return () => {
@@ -1352,20 +1403,36 @@ export function ControlApp() {
};
}, []);
function toNPoint(e: React.PointerEvent): { x: number; y: number } | null {
function normPointInViewport(
clientX: number,
clientY: number,
viewport: { x: number; y: number; w: number; h: number } | null,
opts?: { rejectOutside?: boolean },
): { x: number; y: number } | null {
const host = previewHostRef.current;
if (!host) return null;
if (!host || !viewport) 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);
const x = (clientX - (r.left + viewport.x)) / Math.max(1, viewport.w);
const y = (clientY - (r.top + viewport.y)) / Math.max(1, viewport.h);
if (opts?.rejectOutside && (x < 0 || x > 1 || y < 0 || y > 1)) return null;
return { x: Math.max(0, Math.min(1, x)), y: Math.max(0, Math.min(1, y)) };
}
function toNPoint(e: React.PointerEvent): { x: number; y: number } | null {
return normPointInViewport(e.clientX, e.clientY, previewContentRectRef.current);
}
function toMarkerNPoint(e: React.PointerEvent): { x: number; y: number } | null {
return normPointInViewport(e.clientX, e.clientY, markerViewportRef.current, {
rejectOutside: true,
});
}
function brushPointFromEvent(e: React.PointerEvent): { x: number; y: number } | null {
if (toolRef.current.tool === 'marker') return toMarkerNPoint(e);
return toNPoint(e);
}
function dispatchFieldEraserPoints(
points: { x: number; y: number; tMs: number }[],
opts?: {
@@ -1383,6 +1450,15 @@ export function ControlApp() {
});
}
function dispatchMarkerEraserPoints(points: { x: number; y: number; tMs: number }[]): void {
if (points.length === 0) return;
void marker.dispatch({
kind: 'strokes.erase',
points,
radiusN: toolRef.current.radiusN,
});
}
/** Вода/дождь гасят огонь только под самой кистью (не «аура» радиуса огня). */
function extinguishFireAlong(points: { x: number; y: number; tMs: number }[]): void {
dispatchFieldEraserPoints(points, { types: ['fire'], hitMode: 'brush' });
@@ -1496,6 +1572,20 @@ export function ControlApp() {
},
});
}
if (b.tool === 'marker' && b.points && b.points.length > 0) {
await marker.dispatch({
kind: 'stroke.add',
stroke: {
id: `mk_${String(createdAtMs)}_${String(seed)}`,
seed,
createdAtMs,
points: b.points,
radiusN: tool.radiusN,
color: markerColorRef.current,
shape: markerBrushShapeRef.current,
},
});
}
if (b.tool === 'darkness' && b.points && b.points.length > 0) {
const last = b.points[b.points.length - 1];
if (last === undefined) return;
@@ -1668,6 +1758,17 @@ export function ControlApp() {
<div className={styles.sectionLabel}>{t('control.instruments')}</div>
<div className={styles.spacer8} />
<div className={styles.iconRow}>
<Button
variant={tool.tool === 'marker' ? 'primary' : 'ghost'}
iconOnly
title={t('control.markerTool')}
ariaLabel={t('control.markerTool')}
onClick={() => selectEffectTool('marker')}
>
<span className={styles.iconGlyph} aria-hidden>
🖍️
</span>
</Button>
<Button
variant="ghost"
iconOnly
@@ -1763,7 +1864,10 @@ export function ControlApp() {
iconOnly
title={t('control.clearEffects')}
ariaLabel={t('control.clearEffects')}
onClick={() => void fx.dispatch({ kind: 'instances.clear' })}
onClick={() => {
void fx.dispatch({ kind: 'instances.clear' });
void marker.dispatch({ kind: 'strokes.clear' });
}}
>
<span className={styles.clearIcon}>
<svg viewBox="0 0 24 24" width="20" height="20" aria-hidden>
@@ -1974,6 +2078,70 @@ export function ControlApp() {
</div>
</div>
) : null}
{tool.tool === 'marker' ? (
<>
<div className={styles.brushShapeRow}>
<div className={styles.radiusLabel}>{t('control.brushShape')}</div>
<div className={styles.brushShapeButtons}>
<Button
variant={markerBrushShape === 'circle' ? 'primary' : 'ghost'}
iconOnly
title={t('control.brushShapeCircle')}
ariaLabel={t('control.brushShapeCircle')}
onClick={() => setMarkerBrushShape('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={markerBrushShape === 'square' ? 'primary' : 'ghost'}
iconOnly
title={t('control.brushShapeSquare')}
ariaLabel={t('control.brushShapeSquare')}
onClick={() => setMarkerBrushShape('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>
<div className={styles.radiusRow}>
<div className={styles.radiusLabel}>{t('control.markerColor')}</div>
<input
type="color"
value={markerColor}
onChange={(e) => {
const next = normalizeHexColor((e.currentTarget as HTMLInputElement).value, '#ff0000');
setMarkerColor(next);
}}
className={styles.markerColorInput}
aria-label={t('control.markerColor')}
/>
</div>
</>
) : null}
<div className={[styles.radiusRow, styles.effectsSoundRow].join(' ')}>
<div className={styles.radiusLabel}>{t('control.effectsSound')}</div>
<input
@@ -2219,7 +2387,7 @@ export function ControlApp() {
e.preventDefault();
}}
onPointerEnter={(e) => {
const p = toNPoint(e);
const p = brushPointFromEvent(e);
if (!p) return;
cursorPosRef.current = p;
layoutBrushCursor();
@@ -2243,16 +2411,23 @@ export function ControlApp() {
}
if (e.button !== 0) return;
if (tool.tool === 'none') return;
const p = toNPoint(e);
const p = brushPointFromEvent(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);
const pFx = toNPoint(e);
const pMk = toMarkerNPoint(e);
if (!pFx) return;
const tMs = Date.now();
const ptFx = { x: pFx.x, y: pFx.y, tMs };
brushRef.current = { tool: 'eraser', startN: pFx, points: [ptFx] };
dispatchFieldEraserPoints([ptFx]);
if (pMk) {
dispatchMarkerEraserPoints([{ x: pMk.x, y: pMk.y, tMs }]);
}
tryEraseActionEffect(pFx);
return;
}
draftMetaRef.current = { createdAtMs: Date.now(), seed: 12345 };
@@ -2274,6 +2449,12 @@ export function ControlApp() {
),
});
}
if (tool.tool === 'marker') {
void marker.dispatch({
kind: 'draft.set',
draft: markerBrushDraft([startPt]),
});
}
pushDraftToPixi();
}}
onPointerMove={(e) => {
@@ -2291,31 +2472,46 @@ export function ControlApp() {
publishSceneViewCamera(sceneViewPanBy(cam, { containW, containH, dx, dy }));
return;
}
const p = toNPoint(e);
if (!p) return;
cursorPosRef.current = p;
layoutBrushCursor();
const p = brushPointFromEvent(e);
if (tool.tool !== 'eraser' && !p) return;
if (p) {
cursorPosRef.current = p;
layoutBrushCursor();
}
if (tool.tool === 'eraser' && (e.buttons & 1) !== 0) {
const pFx = toNPoint(e);
const pMk = toMarkerNPoint(e);
if (!pFx) return;
const b = brushRef.current;
const pt = { x: p.x, y: p.y, tMs: Date.now() };
const tMs = Date.now();
const ptFx = { x: pFx.x, y: pFx.y, tMs };
if (b?.tool !== 'eraser' || !b.points) {
brushRef.current = { tool: 'eraser', startN: p, points: [pt] };
dispatchFieldEraserPoints([pt]);
brushRef.current = { tool: 'eraser', startN: pFx, points: [ptFx] };
dispatchFieldEraserPoints([ptFx]);
if (pMk) {
dispatchMarkerEraserPoints([{ x: pMk.x, y: pMk.y, tMs }]);
}
} else {
const last = b.points[b.points.length - 1];
if (last) {
const dx = p.x - last.x;
const dy = p.y - last.y;
const dx = pFx.x - last.x;
const dy = pFx.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]);
b.points.push(ptFx);
dispatchFieldEraserPoints([last, ptFx]);
if (pMk) {
dispatchMarkerEraserPoints([
{ x: pMk.x, y: pMk.y, tMs },
]);
}
}
}
}
tryEraseActionEffect(p);
tryEraseActionEffect(pFx);
return;
}
if (!p) return;
const b = brushRef.current;
if (!b?.points) return;
const last = b.points[b.points.length - 1];
@@ -2353,6 +2549,9 @@ export function ControlApp() {
) {
void sd.dispatch({ kind: 'draft.set', draft: null });
}
if (brushRef.current?.tool === 'marker') {
void marker.dispatch({ kind: 'draft.set', draft: null });
}
brushRef.current = null;
clearDraftFromPixi();
}}
@@ -2686,6 +2885,13 @@ export function ControlApp() {
</>
);
})()}
{markerViewport ? (
<SceneMarkerOverlay
state={markerState}
viewport={markerViewport}
style={{ zIndex: 48 }}
/>
) : null}
</div>
</Surface>