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
+95
View File
@@ -0,0 +1,95 @@
import assert from 'node:assert/strict';
import test from 'node:test';
import { SceneMarkerStore } from './sceneMarkerStore';
void test('SceneMarkerStore: сохраняет штрихи при переключении сцен', () => {
const store = new SceneMarkerStore();
store.switchScene('scene_a');
store.dispatch({
kind: 'stroke.add',
stroke: {
id: 'm1',
seed: 1,
createdAtMs: 100,
points: [{ x: 0.5, y: 0.5, tMs: 100 }],
radiusN: 0.08,
color: '#ff0000',
},
});
store.switchScene('scene_b');
assert.equal(store.getState().strokes.length, 0);
store.switchScene('scene_a');
assert.equal(store.getState().strokes.length, 1);
assert.equal(store.getState().strokes[0]?.id, 'm1');
});
void test('SceneMarkerStore: resetSession очищает кэш', () => {
const store = new SceneMarkerStore();
store.switchScene('scene_a');
store.dispatch({
kind: 'stroke.add',
stroke: {
id: 'm1',
seed: 1,
createdAtMs: 100,
points: [{ x: 0.2, y: 0.2, tMs: 100 }],
radiusN: 0.08,
color: '#00ff00',
},
});
store.resetSession();
store.switchScene('scene_a');
assert.equal(store.getState().strokes.length, 0);
});
void test('SceneMarkerStore: strokes.erase удаляет точки под ластиком', () => {
const store = new SceneMarkerStore();
store.switchScene('scene_a');
store.dispatch({
kind: 'stroke.add',
stroke: {
id: 'm1',
seed: 1,
createdAtMs: 100,
points: [
{ x: 0.1, y: 0.5, tMs: 100 },
{ x: 0.5, y: 0.5, tMs: 101 },
{ x: 0.9, y: 0.5, tMs: 102 },
],
radiusN: 0.04,
color: '#ff0000',
},
});
store.dispatch({
kind: 'strokes.erase',
points: [{ x: 0.5, y: 0.5, tMs: 200 }],
radiusN: 0.06,
});
const strokes = store.getState().strokes;
assert.ok(strokes.length >= 1);
assert.ok(strokes.every((s) => s.points.every((p) => Math.abs(p.x - 0.5) > 0.05)));
});
void test('SceneMarkerStore: strokes.clear удаляет все штрихи', () => {
const store = new SceneMarkerStore();
store.switchScene('scene_a');
store.dispatch({
kind: 'stroke.add',
stroke: {
id: 'm1',
seed: 1,
createdAtMs: 100,
points: [{ x: 0.5, y: 0.5, tMs: 100 }],
radiusN: 0.08,
color: '#0000ff',
},
});
store.dispatch({ kind: 'strokes.clear' });
assert.equal(store.getState().strokes.length, 0);
store.switchScene('scene_b');
store.switchScene('scene_a');
assert.equal(store.getState().strokes.length, 0);
});
+108
View File
@@ -0,0 +1,108 @@
import { applySceneMarkerEraserStroke } from '../../shared/sceneMarkerEraser';
import type { SceneMarkerEvent, SceneMarkerState, SceneMarkerStroke } from '../../shared/types';
function emptyState(): SceneMarkerState {
return {
revision: 1,
cacheKey: null,
strokes: [],
draft: null,
};
}
function makeStrokeId(prefix: string): string {
return `${prefix}_${String(Date.now())}_${String(Math.floor(Math.random() * 1_000_000_000))}`;
}
export class SceneMarkerStore {
private state: SceneMarkerState = emptyState();
/** Кэш штрихов маркера по ключу (graphNodeId или sceneId) на время сессии показа. */
private cache = new Map<string, SceneMarkerStroke[]>();
private currentKey: string | null = null;
getState(): SceneMarkerState {
return { ...this.state, strokes: [...this.state.strokes] };
}
/** Сброс кэша при новом запуске показа (кнопка «Запустить»). */
resetSession(): void {
this.cache.clear();
this.currentKey = null;
this.state = emptyState();
}
/** Переключение сцены: сохраняем штрихи в кэш и загружаем новую сцену. */
switchScene(cacheKey: string | null): SceneMarkerState {
if (this.currentKey !== null) {
this.cache.set(this.currentKey, [...this.state.strokes]);
}
this.currentKey = cacheKey;
const strokes = cacheKey ? [...(this.cache.get(cacheKey) ?? [])] : [];
this.state = {
revision: this.state.revision + 1,
cacheKey,
strokes,
draft: null,
};
return this.getState();
}
dispatch(event: SceneMarkerEvent): SceneMarkerState {
switch (event.kind) {
case 'draft.set':
this.state = {
...this.state,
revision: this.state.revision + 1,
draft: event.draft,
};
return this.getState();
case 'stroke.add': {
const strokes = [...this.state.strokes, event.stroke];
if (this.currentKey) {
this.cache.set(this.currentKey, strokes);
}
this.state = {
...this.state,
revision: this.state.revision + 1,
strokes,
draft: null,
};
return this.getState();
}
case 'strokes.erase': {
const strokes = applySceneMarkerEraserStroke(
this.state.strokes,
event.points,
event.radiusN,
(prefix) => makeStrokeId(prefix),
);
if (this.currentKey) {
this.cache.set(this.currentKey, strokes);
}
this.state = {
...this.state,
revision: this.state.revision + 1,
strokes,
draft: null,
};
return this.getState();
}
case 'strokes.clear':
if (this.currentKey) {
this.cache.set(this.currentKey, []);
}
this.state = {
...this.state,
revision: this.state.revision + 1,
strokes: [],
draft: null,
};
return this.getState();
default: {
// eslint-disable-next-line @typescript-eslint/no-unused-vars
const _x: never = event;
return this.getState();
}
}
}
}
+34
View File
@@ -27,6 +27,7 @@ import {
import { EffectsStore, effectsDefaultTool } from './effects/effectsStore'; import { EffectsStore, effectsDefaultTool } from './effects/effectsStore';
import { SceneDarknessStore } from './effects/sceneDarknessStore'; import { SceneDarknessStore } from './effects/sceneDarknessStore';
import { SceneMarkerStore } from './effects/sceneMarkerStore';
import { SceneTrapsStore } from './sceneTraps/sceneTrapsStore'; import { SceneTrapsStore } from './sceneTraps/sceneTrapsStore';
import { installIpcRouter, registerHandler, setLicenseAssert } from './ipc/router'; import { installIpcRouter, registerHandler, setLicenseAssert } from './ipc/router';
import { LicenseService } from './license/licenseService'; import { LicenseService } from './license/licenseService';
@@ -165,6 +166,7 @@ function installAppMenuForSession(): void {
const effectsStore = new EffectsStore(); const effectsStore = new EffectsStore();
const sceneDarknessStore = new SceneDarknessStore(); const sceneDarknessStore = new SceneDarknessStore();
const sceneMarkerStore = new SceneMarkerStore();
const sceneTrapsStore = new SceneTrapsStore(); const sceneTrapsStore = new SceneTrapsStore();
const sceneViewStore = new SceneViewStore(); const sceneViewStore = new SceneViewStore();
const videoStore = new VideoPlaybackStore(); const videoStore = new VideoPlaybackStore();
@@ -270,6 +272,13 @@ function emitSceneDarknessState(): void {
} }
} }
function emitSceneMarkerState(): void {
const state = sceneMarkerStore.getState();
for (const win of BrowserWindow.getAllWindows()) {
win.webContents.send(ipcChannels.sceneMarker.stateChanged, { state });
}
}
function emitSceneViewState(): void { function emitSceneViewState(): void {
const state = sceneViewStore.getState(); const state = sceneViewStore.getState();
for (const win of BrowserWindow.getAllWindows()) { for (const win of BrowserWindow.getAllWindows()) {
@@ -357,6 +366,11 @@ function syncSceneDarknessForProject(project: Project): void {
sceneDarknessStore.switchScene(cacheKey, enabled); sceneDarknessStore.switchScene(cacheKey, enabled);
} }
function syncSceneMarkerForProject(project: Project): void {
const cacheKey = project.currentGraphNodeId ?? project.currentSceneId ?? null;
sceneMarkerStore.switchScene(cacheKey);
}
function emitSceneTrapsState(): void { function emitSceneTrapsState(): void {
const state = sceneTrapsStore.getState(); const state = sceneTrapsStore.getState();
for (const win of BrowserWindow.getAllWindows()) { for (const win of BrowserWindow.getAllWindows()) {
@@ -522,6 +536,7 @@ async function main() {
registerHandler(ipcChannels.license.acceptEula, ({ version }) => licenseService.acceptEula(version)); registerHandler(ipcChannels.license.acceptEula, ({ version }) => licenseService.acceptEula(version));
registerHandler(ipcChannels.windows.openMultiWindow, (req) => { registerHandler(ipcChannels.windows.openMultiWindow, (req) => {
sceneDarknessStore.resetSession(); sceneDarknessStore.resetSession();
sceneMarkerStore.resetSession();
sceneTrapsStore.resetSession(); sceneTrapsStore.resetSession();
sceneTokensSessionStore.reset(); sceneTokensSessionStore.reset();
tokenPathSessionStore.reset(); tokenPathSessionStore.reset();
@@ -540,12 +555,14 @@ async function main() {
const project = projectStore.getOpenProject(); const project = projectStore.getOpenProject();
if (project) { if (project) {
syncSceneDarknessForProject(project); syncSceneDarknessForProject(project);
syncSceneMarkerForProject(project);
syncSceneTrapsForProject(project); syncSceneTrapsForProject(project);
seedTokenPathPlaybackForProject(project); seedTokenPathPlaybackForProject(project);
} else { } else {
emitTokenPathSessionState(); emitTokenPathSessionState();
} }
emitSceneDarknessState(); emitSceneDarknessState();
emitSceneMarkerState();
emitSceneTrapsState(); emitSceneTrapsState();
emitSceneTokensSessionState(); emitSceneTokensSessionState();
emitSceneNpcTokensSessionState(); emitSceneNpcTokensSessionState();
@@ -674,10 +691,12 @@ async function main() {
sceneNpcTokensSessionStore.reset(); sceneNpcTokensSessionStore.reset();
clearNpcMapSpawnDrag(); clearNpcMapSpawnDrag();
scenePlayerTokensSessionStore.reset(); scenePlayerTokensSessionStore.reset();
syncSceneMarkerForProject(project);
emitSceneViewState(); emitSceneViewState();
emitSceneTokensSessionState(); emitSceneTokensSessionState();
emitSceneNpcTokensSessionState(); emitSceneNpcTokensSessionState();
emitScenePlayerTokensSessionState(); emitScenePlayerTokensSessionState();
emitSceneMarkerState();
emitSessionState(); emitSessionState();
warmNpcsEditorWindow(); warmNpcsEditorWindow();
return { project }; return { project };
@@ -689,6 +708,7 @@ async function main() {
materialsOverlayStore.clear(); materialsOverlayStore.clear();
npcsOverlayStore.clear(); npcsOverlayStore.clear();
sceneDarknessStore.resetSession(); sceneDarknessStore.resetSession();
sceneMarkerStore.resetSession();
sceneTrapsStore.resetSession(); sceneTrapsStore.resetSession();
sceneViewStore.reset(); sceneViewStore.reset();
sceneTokensSessionStore.reset(); sceneTokensSessionStore.reset();
@@ -700,6 +720,7 @@ async function main() {
emitMaterialsOverlayState(); emitMaterialsOverlayState();
emitNpcsOverlayState(); emitNpcsOverlayState();
emitSceneDarknessState(); emitSceneDarknessState();
emitSceneMarkerState();
emitSceneTrapsState(); emitSceneTrapsState();
emitSceneViewState(); emitSceneViewState();
emitSceneTokensSessionState(); emitSceneTokensSessionState();
@@ -727,12 +748,14 @@ async function main() {
const project = projectStore.getOpenProject(); const project = projectStore.getOpenProject();
if (project) { if (project) {
syncSceneDarknessForProject(project); syncSceneDarknessForProject(project);
syncSceneMarkerForProject(project);
syncSceneTrapsForProject(project); syncSceneTrapsForProject(project);
} }
emitEffectsState(); emitEffectsState();
emitMaterialsOverlayState(); emitMaterialsOverlayState();
emitNpcsOverlayState(); emitNpcsOverlayState();
emitSceneDarknessState(); emitSceneDarknessState();
emitSceneMarkerState();
emitSceneTrapsState(); emitSceneTrapsState();
emitSceneViewState(); emitSceneViewState();
emitSceneTokensSessionState(); emitSceneTokensSessionState();
@@ -755,12 +778,14 @@ async function main() {
const project = projectStore.getOpenProject(); const project = projectStore.getOpenProject();
if (project) { if (project) {
syncSceneDarknessForProject(project); syncSceneDarknessForProject(project);
syncSceneMarkerForProject(project);
syncSceneTrapsForProject(project); syncSceneTrapsForProject(project);
} }
emitEffectsState(); emitEffectsState();
emitMaterialsOverlayState(); emitMaterialsOverlayState();
emitNpcsOverlayState(); emitNpcsOverlayState();
emitSceneDarknessState(); emitSceneDarknessState();
emitSceneMarkerState();
emitSceneTrapsState(); emitSceneTrapsState();
emitSceneViewState(); emitSceneViewState();
emitSceneTokensSessionState(); emitSceneTokensSessionState();
@@ -1427,6 +1452,15 @@ async function main() {
return { ok: true }; return { ok: true };
}); });
registerHandler(ipcChannels.sceneMarker.getState, () => {
return { state: sceneMarkerStore.getState() };
});
registerHandler(ipcChannels.sceneMarker.dispatch, ({ event }) => {
sceneMarkerStore.dispatch(event);
emitSceneMarkerState();
return { ok: true };
});
registerHandler(ipcChannels.sceneTraps.getState, () => { registerHandler(ipcChannels.sceneTraps.getState, () => {
return { state: sceneTrapsStore.getState() }; return { state: sceneTrapsStore.getState() };
}); });
@@ -453,6 +453,33 @@
color: var(--text1); color: var(--text1);
} }
.markerColorInput {
flex: 0 0 40px;
width: 40px;
height: 34px;
padding: 0;
border: 1px solid var(--stroke);
border-radius: var(--radius-sm);
background: transparent;
cursor: pointer;
overflow: hidden;
}
.markerColorInput::-webkit-color-swatch-wrapper {
padding: 0;
border-radius: inherit;
}
.markerColorInput::-webkit-color-swatch {
border: none;
border-radius: inherit;
}
.markerColorInput::-moz-color-swatch {
border: none;
border-radius: inherit;
}
.brushLayer { .brushLayer {
position: absolute; position: absolute;
inset: 0; inset: 0;
+236 -30
View File
@@ -17,9 +17,11 @@ import type {
NpcDisposition, NpcDisposition,
Scene, Scene,
SceneDarknessBrushShape, SceneDarknessBrushShape,
SceneMarkerBrushShape,
SceneId, SceneId,
SceneViewCamera, SceneViewCamera,
} from '../../shared/types'; } from '../../shared/types';
import { normalizeHexColor } from '../../shared/npcs/npcGroups';
import { import {
DEFAULT_NPC_TOKEN_SESSION_SCALE, DEFAULT_NPC_TOKEN_SESSION_SCALE,
DEFAULT_SCENE_NPC_TOKEN_SIZE_N, 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 { PixiEffectsOverlay, type PixiEffectsOverlayHandle } from '../shared/effects/PxiEffectsOverlay';
import type { EffectInstance, EffectToolType, ExplosionInstance } from '../../shared/types/effects'; import type { EffectInstance, EffectToolType, ExplosionInstance } from '../../shared/types/effects';
import { SceneDarknessOverlay } from '../shared/effects/SceneDarknessOverlay'; import { SceneDarknessOverlay } from '../shared/effects/SceneDarknessOverlay';
import { SceneMarkerOverlay } from '../shared/effects/SceneMarkerOverlay';
import { useEffectsState } from '../shared/effects/useEffectsState'; import { useEffectsState } from '../shared/effects/useEffectsState';
import { useSceneDarknessState } from '../shared/effects/useSceneDarknessState'; 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 { DEFAULT_MATERIALS_OVERLAY_LAYOUT, DEFAULT_NPCS_OVERLAY_LAYOUT, defaultLegendLayoutForMaterial } from '../../shared/types';
import { MaterialLegendPanel } from '../shared/materials/MaterialLegendPanel'; import { MaterialLegendPanel } from '../shared/materials/MaterialLegendPanel';
import { MaterialOverlay } from '../shared/materials/MaterialOverlay'; import { MaterialOverlay } from '../shared/materials/MaterialOverlay';
@@ -160,6 +164,7 @@ export function ControlApp() {
const [fxState, fx] = useEffectsState(); const [fxState, fx] = useEffectsState();
const [effectsSfxGainUi, setEffectsSfxGainUi] = useState(() => getEffectsSfxGain()); const [effectsSfxGainUi, setEffectsSfxGainUi] = useState(() => getEffectsSfxGain());
const [sdState, sd] = useSceneDarknessState(); const [sdState, sd] = useSceneDarknessState();
const [markerState, marker] = useSceneMarkerState();
const [sceneTraps, sceneTrapsApi] = useSceneTrapsState(); const [sceneTraps, sceneTrapsApi] = useSceneTrapsState();
const appTokens = useAppTokens(); const appTokens = useAppTokens();
const [sceneTokensSession, sceneTokensApi] = useSceneTokensSession(); const [sceneTokensSession, sceneTokensApi] = useSceneTokensSession();
@@ -190,6 +195,12 @@ export function ControlApp() {
const [darknessBrushShape, setDarknessBrushShape] = useState<SceneDarknessBrushShape>('circle'); const [darknessBrushShape, setDarknessBrushShape] = useState<SceneDarknessBrushShape>('circle');
const darknessBrushShapeRef = useRef(darknessBrushShape); const darknessBrushShapeRef = useRef(darknessBrushShape);
darknessBrushShapeRef.current = 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 mainStoryReturnRef = useRef<GraphNodeId | null>(null);
const [mainStoryReturnGraphNodeId, setMainStoryReturnGraphNodeId] = useState<GraphNodeId | null>(null); const [mainStoryReturnGraphNodeId, setMainStoryReturnGraphNodeId] = useState<GraphNodeId | null>(null);
@@ -232,6 +243,7 @@ export function ControlApp() {
| 'freeze' | 'freeze'
| 'exploreBrush' | 'exploreBrush'
| 'closeBrush' | 'closeBrush'
| 'marker'
| 'eraser'; | 'eraser';
startN?: { x: number; y: number }; startN?: { x: number; y: number };
points?: { x: number; y: number; tMs: number }[]; points?: { x: number; y: number; tMs: number }[];
@@ -262,6 +274,16 @@ export function ControlApp() {
presentationContentSize.height, presentationContentSize.height,
); );
}, [presentationContentSize, previewSize.h, previewSize.w]); }, [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 brushCursorElRef = useRef<HTMLDivElement | null>(null);
const cursorPosRef = useRef<{ x: number; y: number } | null>(null); const cursorPosRef = useRef<{ x: number; y: number } | null>(null);
const draftPaintRafRef = useRef(0); const draftPaintRafRef = useRef(0);
@@ -1123,9 +1145,21 @@ export function ControlApp() {
if (leavingDarknessBrush) { if (leavingDarknessBrush) {
setDarknessBrushShape('circle'); setDarknessBrushShape('circle');
} }
if (tool.tool === 'marker' && toolName !== 'marker') {
setMarkerBrushShape('circle');
}
void fx.dispatch({ kind: 'tool.set', tool: { ...tool, tool: toolName } }); 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') { function darknessBrushDraft(points: { x: number; y: number; tMs: number }[], mode: 'reveal' | 'cover') {
return { return {
points, points,
@@ -1138,7 +1172,8 @@ export function ControlApp() {
function layoutBrushCursor(): void { function layoutBrushCursor(): void {
const el = brushCursorElRef.current; const el = brushCursorElRef.current;
const p = cursorPosRef.current; const p = cursorPosRef.current;
const cr = previewContentRectRef.current; const cr =
toolRef.current.tool === 'marker' ? markerViewportRef.current : previewContentRectRef.current;
const ps = previewSizeRef.current; const ps = previewSizeRef.current;
const t = toolRef.current; const t = toolRef.current;
if (!el) return; if (!el) return;
@@ -1148,9 +1183,11 @@ export function ControlApp() {
} }
el.style.visibility = 'visible'; el.style.visibility = 'visible';
const isDarknessBrush = t.tool === 'exploreBrush' || t.tool === 'closeBrush'; const isDarknessBrush = t.tool === 'exploreBrush' || t.tool === 'closeBrush';
const isMarkerBrush = t.tool === 'marker';
el.className = [ el.className = [
styles.brushCursor, styles.brushCursor,
isDarknessBrush && darknessBrushShapeRef.current === 'square' ? styles.brushCursorSquare : '', isDarknessBrush && darknessBrushShapeRef.current === 'square' ? styles.brushCursorSquare : '',
isMarkerBrush && markerBrushShapeRef.current === 'square' ? styles.brushCursorSquare : '',
] ]
.filter(Boolean) .filter(Boolean)
.join(' '); .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(() => { useLayoutEffect(() => {
layoutBrushCursor(); layoutBrushCursor();
}, [tool.radiusN, previewContentRect, previewSize.w, previewSize.h, darknessBrushShape]); }, [
tool.radiusN,
previewContentRect,
markerViewport,
previewSize.w,
previewSize.h,
darknessBrushShape,
markerBrushShape,
]);
useEffect(() => { useEffect(() => {
return () => { 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; const host = previewHostRef.current;
if (!host) return null; if (!host || !viewport) return null;
const r = host.getBoundingClientRect(); const r = host.getBoundingClientRect();
const cr = previewContentRectRef.current; const x = (clientX - (r.left + viewport.x)) / Math.max(1, viewport.w);
const ox = cr ? cr.x : 0; const y = (clientY - (r.top + viewport.y)) / Math.max(1, viewport.h);
const oy = cr ? cr.y : 0; if (opts?.rejectOutside && (x < 0 || x > 1 || y < 0 || y > 1)) return null;
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);
return { x: Math.max(0, Math.min(1, x)), y: Math.max(0, Math.min(1, y)) }; 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( function dispatchFieldEraserPoints(
points: { x: number; y: number; tMs: number }[], points: { x: number; y: number; tMs: number }[],
opts?: { 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 { function extinguishFireAlong(points: { x: number; y: number; tMs: number }[]): void {
dispatchFieldEraserPoints(points, { types: ['fire'], hitMode: 'brush' }); 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) { if (b.tool === 'darkness' && b.points && b.points.length > 0) {
const last = b.points[b.points.length - 1]; const last = b.points[b.points.length - 1];
if (last === undefined) return; if (last === undefined) return;
@@ -1668,6 +1758,17 @@ export function ControlApp() {
<div className={styles.sectionLabel}>{t('control.instruments')}</div> <div className={styles.sectionLabel}>{t('control.instruments')}</div>
<div className={styles.spacer8} /> <div className={styles.spacer8} />
<div className={styles.iconRow}> <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 <Button
variant="ghost" variant="ghost"
iconOnly iconOnly
@@ -1763,7 +1864,10 @@ export function ControlApp() {
iconOnly iconOnly
title={t('control.clearEffects')} title={t('control.clearEffects')}
ariaLabel={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}> <span className={styles.clearIcon}>
<svg viewBox="0 0 24 24" width="20" height="20" aria-hidden> <svg viewBox="0 0 24 24" width="20" height="20" aria-hidden>
@@ -1974,6 +2078,70 @@ export function ControlApp() {
</div> </div>
</div> </div>
) : null} ) : 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.radiusRow, styles.effectsSoundRow].join(' ')}>
<div className={styles.radiusLabel}>{t('control.effectsSound')}</div> <div className={styles.radiusLabel}>{t('control.effectsSound')}</div>
<input <input
@@ -2219,7 +2387,7 @@ export function ControlApp() {
e.preventDefault(); e.preventDefault();
}} }}
onPointerEnter={(e) => { onPointerEnter={(e) => {
const p = toNPoint(e); const p = brushPointFromEvent(e);
if (!p) return; if (!p) return;
cursorPosRef.current = p; cursorPosRef.current = p;
layoutBrushCursor(); layoutBrushCursor();
@@ -2243,16 +2411,23 @@ export function ControlApp() {
} }
if (e.button !== 0) return; if (e.button !== 0) return;
if (tool.tool === 'none') return; if (tool.tool === 'none') return;
const p = toNPoint(e); const p = brushPointFromEvent(e);
if (!p) return; if (!p) return;
cursorPosRef.current = p; cursorPosRef.current = p;
layoutBrushCursor(); layoutBrushCursor();
(e.currentTarget as HTMLDivElement).setPointerCapture(e.pointerId); (e.currentTarget as HTMLDivElement).setPointerCapture(e.pointerId);
if (tool.tool === 'eraser') { if (tool.tool === 'eraser') {
const pt = { x: p.x, y: p.y, tMs: Date.now() }; const pFx = toNPoint(e);
brushRef.current = { tool: 'eraser', startN: p, points: [pt] }; const pMk = toMarkerNPoint(e);
dispatchFieldEraserPoints([pt]); if (!pFx) return;
tryEraseActionEffect(p); 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; return;
} }
draftMetaRef.current = { createdAtMs: Date.now(), seed: 12345 }; 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(); pushDraftToPixi();
}} }}
onPointerMove={(e) => { onPointerMove={(e) => {
@@ -2291,31 +2472,46 @@ export function ControlApp() {
publishSceneViewCamera(sceneViewPanBy(cam, { containW, containH, dx, dy })); publishSceneViewCamera(sceneViewPanBy(cam, { containW, containH, dx, dy }));
return; return;
} }
const p = toNPoint(e); const p = brushPointFromEvent(e);
if (!p) return; if (tool.tool !== 'eraser' && !p) return;
cursorPosRef.current = p; if (p) {
layoutBrushCursor(); cursorPosRef.current = p;
layoutBrushCursor();
}
if (tool.tool === 'eraser' && (e.buttons & 1) !== 0) { if (tool.tool === 'eraser' && (e.buttons & 1) !== 0) {
const pFx = toNPoint(e);
const pMk = toMarkerNPoint(e);
if (!pFx) return;
const b = brushRef.current; 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) { if (b?.tool !== 'eraser' || !b.points) {
brushRef.current = { tool: 'eraser', startN: p, points: [pt] }; brushRef.current = { tool: 'eraser', startN: pFx, points: [ptFx] };
dispatchFieldEraserPoints([pt]); dispatchFieldEraserPoints([ptFx]);
if (pMk) {
dispatchMarkerEraserPoints([{ x: pMk.x, y: pMk.y, tMs }]);
}
} else { } else {
const last = b.points[b.points.length - 1]; const last = b.points[b.points.length - 1];
if (last) { if (last) {
const dx = p.x - last.x; const dx = pFx.x - last.x;
const dy = p.y - last.y; const dy = pFx.y - last.y;
const minStep = Math.max(0.004, tool.radiusN * 0.25); const minStep = Math.max(0.004, tool.radiusN * 0.25);
if (dx * dx + dy * dy >= minStep * minStep) { if (dx * dx + dy * dy >= minStep * minStep) {
b.points.push(pt); b.points.push(ptFx);
dispatchFieldEraserPoints([last, pt]); dispatchFieldEraserPoints([last, ptFx]);
if (pMk) {
dispatchMarkerEraserPoints([
{ x: pMk.x, y: pMk.y, tMs },
]);
}
} }
} }
} }
tryEraseActionEffect(p); tryEraseActionEffect(pFx);
return; return;
} }
if (!p) return;
const b = brushRef.current; const b = brushRef.current;
if (!b?.points) return; if (!b?.points) return;
const last = b.points[b.points.length - 1]; const last = b.points[b.points.length - 1];
@@ -2353,6 +2549,9 @@ export function ControlApp() {
) { ) {
void sd.dispatch({ kind: 'draft.set', draft: null }); void sd.dispatch({ kind: 'draft.set', draft: null });
} }
if (brushRef.current?.tool === 'marker') {
void marker.dispatch({ kind: 'draft.set', draft: null });
}
brushRef.current = null; brushRef.current = null;
clearDraftFromPixi(); clearDraftFromPixi();
}} }}
@@ -2686,6 +2885,13 @@ export function ControlApp() {
</> </>
); );
})()} })()}
{markerViewport ? (
<SceneMarkerOverlay
state={markerState}
viewport={markerViewport}
style={{ zIndex: 48 }}
/>
) : null}
</div> </div>
</Surface> </Surface>
@@ -78,6 +78,15 @@ void test('ControlApp: эффекты в пульте, иконки с тулт
const src = readControlApp(); const src = readControlApp();
assert.ok(src.includes("t('control.instruments')")); assert.ok(src.includes("t('control.instruments')"));
assert.ok(src.includes("t('control.descriptionTool')")); assert.ok(src.includes("t('control.descriptionTool')"));
assert.ok(src.includes("t('control.markerTool')"));
assert.ok(src.includes("selectEffectTool('marker')"));
assert.ok(src.includes('SceneMarkerOverlay'));
assert.ok(src.includes('useSceneMarkerState'));
assert.ok(src.includes("t('control.markerColor')"));
assert.ok(src.includes('dispatchMarkerEraserPoints'));
assert.ok(src.includes('markerViewport'));
assert.ok(src.includes('toMarkerNPoint'));
assert.ok(src.includes("kind: 'strokes.clear'"));
assert.ok(src.includes("t('control.descriptionMissing')")); assert.ok(src.includes("t('control.descriptionMissing')"));
assert.ok(src.includes('openSceneDescription')); assert.ok(src.includes('openSceneDescription'));
assert.ok(!src.includes('SceneDescriptionViewModal')); assert.ok(!src.includes('SceneDescriptionViewModal'));
@@ -607,6 +607,8 @@ export const EDITOR_MESSAGES: Record<EditorLocale, Record<string, string>> = {
'control.remoteTitle': 'ПУЛЬТ УПРАВЛЕНИЯ', 'control.remoteTitle': 'ПУЛЬТ УПРАВЛЕНИЯ',
'control.instruments': 'ИНСТРУМЕНТЫ', 'control.instruments': 'ИНСТРУМЕНТЫ',
'control.descriptionTool': 'Описание', 'control.descriptionTool': 'Описание',
'control.markerTool': 'Маркер',
'control.markerColor': 'Цвет маркера',
'control.materialsTool': 'Материалы', 'control.materialsTool': 'Материалы',
'control.npcsTool': 'НПС', 'control.npcsTool': 'НПС',
'control.descriptionMissing': 'Описание отсутствует', 'control.descriptionMissing': 'Описание отсутствует',
@@ -1247,6 +1249,8 @@ export const EDITOR_MESSAGES: Record<EditorLocale, Record<string, string>> = {
'control.remoteTitle': 'CONTROL PANEL', 'control.remoteTitle': 'CONTROL PANEL',
'control.instruments': 'TOOLS', 'control.instruments': 'TOOLS',
'control.descriptionTool': 'Description', 'control.descriptionTool': 'Description',
'control.markerTool': 'Marker',
'control.markerColor': 'Marker color',
'control.materialsTool': 'Materials', 'control.materialsTool': 'Materials',
'control.npcsTool': 'NPCs', 'control.npcsTool': 'NPCs',
'control.descriptionMissing': 'No description', 'control.descriptionMissing': 'No description',
+28 -1
View File
@@ -7,8 +7,10 @@ import { USERS_BRANCH_FEATURES_ENABLED } from '../../shared/features/usersBranch
import { ExplosionVideoOverlay } from './effects/ExplosionVideoOverlay'; import { ExplosionVideoOverlay } from './effects/ExplosionVideoOverlay';
import { PixiEffectsOverlay } from './effects/PxiEffectsOverlay'; import { PixiEffectsOverlay } from './effects/PxiEffectsOverlay';
import { SceneDarknessOverlay } from './effects/SceneDarknessOverlay'; import { SceneDarknessOverlay } from './effects/SceneDarknessOverlay';
import { SceneMarkerOverlay } from './effects/SceneMarkerOverlay';
import { useEffectsState } from './effects/useEffectsState'; import { useEffectsState } from './effects/useEffectsState';
import { useSceneDarknessState } from './effects/useSceneDarknessState'; import { useSceneDarknessState } from './effects/useSceneDarknessState';
import { useSceneMarkerState } from './effects/useSceneMarkerState';
import { DEFAULT_MATERIALS_OVERLAY_LAYOUT, DEFAULT_NPCS_OVERLAY_LAYOUT, defaultLegendLayoutForMaterial } from '../../shared/types'; import { DEFAULT_MATERIALS_OVERLAY_LAYOUT, DEFAULT_NPCS_OVERLAY_LAYOUT, defaultLegendLayoutForMaterial } from '../../shared/types';
import { SceneGridOverlay } from './grid/SceneGridOverlay'; import { SceneGridOverlay } from './grid/SceneGridOverlay';
import { MaterialLegendPanel } from './materials/MaterialLegendPanel'; import { MaterialLegendPanel } from './materials/MaterialLegendPanel';
@@ -57,6 +59,7 @@ export function PresentationView({
}: PresentationViewProps) { }: PresentationViewProps) {
const [fxState] = useEffectsState(); const [fxState] = useEffectsState();
const [sdState] = useSceneDarknessState(); const [sdState] = useSceneDarknessState();
const [markerState] = useSceneMarkerState();
const [sceneTraps] = useSceneTrapsState(); const [sceneTraps] = useSceneTrapsState();
const [sceneView] = useSceneViewState(); const [sceneView] = useSceneViewState();
const [materialsOverlay] = useMaterialsOverlayState(); const [materialsOverlay] = useMaterialsOverlayState();
@@ -70,9 +73,30 @@ export function PresentationView({
const [tokenPathSession, tokenPathApi] = useTokenPathSession(); const [tokenPathSession, tokenPathApi] = useTokenPathSession();
const [vp] = useVideoPlaybackState(); const [vp] = useVideoPlaybackState();
const videoElRef = useRef<HTMLVideoElement | null>(null); const videoElRef = useRef<HTMLVideoElement | null>(null);
const rootRef = useRef<HTMLDivElement | null>(null);
const [contentRect, setContentRect] = React.useState<{ x: number; y: number; w: number; h: number } | null>( const [contentRect, setContentRect] = React.useState<{ x: number; y: number; w: number; h: number } | null>(
null, null,
); );
const [markerViewport, setMarkerViewport] = React.useState<{
x: number;
y: number;
w: number;
h: number;
} | null>(null);
useEffect(() => {
const el = rootRef.current;
if (!el) return;
const sync = () => {
const w = Math.max(1, el.clientWidth);
const h = Math.max(1, el.clientHeight);
setMarkerViewport({ x: 0, y: 0, w, h });
};
sync();
const ro = new ResizeObserver(sync);
ro.observe(el);
return () => ro.disconnect();
}, []);
const scene = const scene =
session?.project && session.currentSceneId ? session.project.scenes[session.currentSceneId] : undefined; session?.project && session.currentSceneId ? session.project.scenes[session.currentSceneId] : undefined;
const project = session?.project; const project = session?.project;
@@ -181,7 +205,7 @@ export function PresentationView({
]); ]);
return ( return (
<div className={styles.root}> <div ref={rootRef} className={styles.root}>
{shownImageUrl && scene?.previewAssetType === 'image' ? ( {shownImageUrl && scene?.previewAssetType === 'image' ? (
<div className={styles.fill}> <div className={styles.fill}>
<RotatedImage <RotatedImage
@@ -285,6 +309,9 @@ export function PresentationView({
contentRect ? ( contentRect ? (
<SceneDarknessOverlay state={sdState} overlayAlpha={1} viewport={contentRect} style={{ zIndex: 30 }} /> <SceneDarknessOverlay state={sdState} overlayAlpha={1} viewport={contentRect} style={{ zIndex: 30 }} />
) : null} ) : null}
{markerViewport ? (
<SceneMarkerOverlay state={markerState} viewport={markerViewport} style={{ zIndex: 48 }} />
) : null}
<SceneOverlayHost <SceneOverlayHost
active={activeMaterialItems.length > 0 || activeNpcItems.length > 0} active={activeMaterialItems.length > 0 || activeNpcItems.length > 0}
legendLayer={ legendLayer={
@@ -0,0 +1,131 @@
import React, { useEffect, useRef } from 'react';
import type {
SceneMarkerBrushShape,
SceneMarkerDraft,
SceneMarkerState,
SceneMarkerStroke,
} from '../../../shared/types';
import { normalizeSceneMarkerBrushShape } from '../../../shared/types/sceneMarker';
export type SceneMarkerOverlayProps = {
state: SceneMarkerState | null;
viewport?: { x: number; y: number; w: number; h: number };
style?: React.CSSProperties;
};
type MarkerStrokeLike = SceneMarkerStroke | SceneMarkerDraft;
function stampSquare(
ctx: CanvasRenderingContext2D,
x: number,
y: number,
side: number,
): void {
ctx.fillRect(x - side / 2, y - side / 2, side, side);
}
function drawSquareMarkerStroke(
ctx: CanvasRenderingContext2D,
stroke: MarkerStrokeLike,
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 drawCircleMarkerStroke(
ctx: CanvasRenderingContext2D,
stroke: MarkerStrokeLike,
w: number,
h: number,
): void {
const pts = stroke.points;
if (pts.length === 0) return;
const r = stroke.radiusN * Math.min(w, h);
if (pts.length === 1) {
const p = pts[0];
if (!p) return;
ctx.beginPath();
ctx.arc(p.x * w, p.y * h, r, 0, Math.PI * 2);
ctx.fill();
return;
}
ctx.lineCap = 'round';
ctx.lineJoin = 'round';
ctx.lineWidth = r * 2;
ctx.beginPath();
const first = pts[0];
if (!first) return;
ctx.moveTo(first.x * w, first.y * h);
for (let i = 1; i < pts.length; i += 1) {
const p = pts[i];
if (!p) continue;
ctx.lineTo(p.x * w, p.y * h);
}
ctx.stroke();
}
function drawMarkerStroke(
ctx: CanvasRenderingContext2D,
stroke: MarkerStrokeLike,
w: number,
h: number,
): void {
const shape: SceneMarkerBrushShape = normalizeSceneMarkerBrushShape(stroke.shape);
ctx.globalCompositeOperation = 'source-over';
ctx.fillStyle = stroke.color;
ctx.strokeStyle = stroke.color;
if (shape === 'square') {
drawSquareMarkerStroke(ctx, stroke, w, h);
return;
}
drawCircleMarkerStroke(ctx, stroke, w, h);
}
export function SceneMarkerOverlay({ state, viewport, style }: SceneMarkerOverlayProps) {
const canvasRef = useRef<HTMLCanvasElement | null>(null);
useEffect(() => {
const canvas = canvasRef.current;
if (!canvas || !state || !viewport) return;
const w = Math.max(1, Math.round(viewport.w));
const h = Math.max(1, Math.round(viewport.h));
if (canvas.width !== w) canvas.width = w;
if (canvas.height !== h) canvas.height = h;
const ctx = canvas.getContext('2d');
if (!ctx) return;
ctx.clearRect(0, 0, w, h);
for (const stroke of state.strokes) {
drawMarkerStroke(ctx, stroke, w, h);
}
if (state.draft && state.draft.points.length > 0) {
drawMarkerStroke(ctx, state.draft, w, h);
}
}, [state, viewport]);
if (!state || !viewport) return null;
return (
<canvas
ref={canvasRef}
aria-hidden
style={{
position: 'absolute',
left: viewport.x,
top: viewport.y,
width: viewport.w,
height: viewport.h,
pointerEvents: 'none',
zIndex: 3,
...style,
}}
/>
);
}
@@ -0,0 +1,37 @@
import { useEffect, useState } from 'react';
import { ipcChannels } from '../../../shared/ipc/contracts';
import type { SceneMarkerEvent, SceneMarkerState } from '../../../shared/types';
import { getDndApi } from '../dndApi';
export function useSceneMarkerState(): readonly [
SceneMarkerState | null,
{ dispatch: (event: SceneMarkerEvent) => Promise<void> },
] {
const api = getDndApi();
const [state, setState] = useState<SceneMarkerState | null>(null);
useEffect(() => {
void api.invoke(ipcChannels.sceneMarker.getState, {}).then((r) => {
setState((prev) => {
if (prev && prev.revision > r.state.revision) return prev;
return r.state;
});
});
return api.on(ipcChannels.sceneMarker.stateChanged, ({ state: next }) => {
setState((prev) => {
if (prev && prev.revision > next.revision) return prev;
return next;
});
});
}, [api]);
return [
state,
{
dispatch: async (event) => {
await api.invoke(ipcChannels.sceneMarker.dispatch, { event });
},
},
] as const;
}
@@ -22,6 +22,9 @@ void test('video scenes share map overlays / effects with image scenes', () => {
assert.ok(presentation.includes('SceneTrapsOverlay')); assert.ok(presentation.includes('SceneTrapsOverlay'));
assert.ok(presentation.includes('PixiEffectsOverlay')); assert.ok(presentation.includes('PixiEffectsOverlay'));
assert.ok(presentation.includes('SceneDarknessOverlay')); assert.ok(presentation.includes('SceneDarknessOverlay'));
assert.ok(presentation.includes('SceneMarkerOverlay'));
assert.ok(presentation.includes('markerViewport'));
assert.ok(main.includes('syncSceneMarkerForProject'));
assert.ok(sceneEditor.includes('ContainedVideo')); assert.ok(sceneEditor.includes('ContainedVideo'));
assert.ok(sceneEditor.includes('hasMapMedia')); assert.ok(sceneEditor.includes('hasMapMedia'));
+17
View File
@@ -27,6 +27,8 @@ import type {
Scene, Scene,
SceneDarknessEvent, SceneDarknessEvent,
SceneDarknessState, SceneDarknessState,
SceneMarkerEvent,
SceneMarkerState,
SceneGrid, SceneGrid,
SceneId, SceneId,
SceneNpcToken, SceneNpcToken,
@@ -182,6 +184,11 @@ export const ipcChannels = {
dispatch: 'sceneDarkness.dispatch', dispatch: 'sceneDarkness.dispatch',
stateChanged: 'sceneDarkness.stateChanged', stateChanged: 'sceneDarkness.stateChanged',
}, },
sceneMarker: {
getState: 'sceneMarker.getState',
dispatch: 'sceneMarker.dispatch',
stateChanged: 'sceneMarker.stateChanged',
},
sceneTraps: { sceneTraps: {
getState: 'sceneTraps.getState', getState: 'sceneTraps.getState',
dispatch: 'sceneTraps.dispatch', dispatch: 'sceneTraps.dispatch',
@@ -315,6 +322,7 @@ export type IpcEventMap = {
[ipcChannels.materialsOverlay.stateChanged]: { state: MaterialsOverlayState }; [ipcChannels.materialsOverlay.stateChanged]: { state: MaterialsOverlayState };
[ipcChannels.npcsOverlay.stateChanged]: { state: NpcsOverlayState }; [ipcChannels.npcsOverlay.stateChanged]: { state: NpcsOverlayState };
[ipcChannels.sceneDarkness.stateChanged]: { state: SceneDarknessState }; [ipcChannels.sceneDarkness.stateChanged]: { state: SceneDarknessState };
[ipcChannels.sceneMarker.stateChanged]: { state: SceneMarkerState };
[ipcChannels.sceneTraps.stateChanged]: { state: SceneTrapsState }; [ipcChannels.sceneTraps.stateChanged]: { state: SceneTrapsState };
[ipcChannels.sceneView.stateChanged]: { state: SceneViewState }; [ipcChannels.sceneView.stateChanged]: { state: SceneViewState };
[ipcChannels.tokens.stateChanged]: { tokens: AppToken[] }; [ipcChannels.tokens.stateChanged]: { tokens: AppToken[] };
@@ -771,6 +779,14 @@ export type IpcInvokeMap = {
req: { event: SceneDarknessEvent }; req: { event: SceneDarknessEvent };
res: { ok: true }; res: { ok: true };
}; };
[ipcChannels.sceneMarker.getState]: {
req: Record<string, never>;
res: { state: SceneMarkerState };
};
[ipcChannels.sceneMarker.dispatch]: {
req: { event: SceneMarkerEvent };
res: { ok: true };
};
[ipcChannels.sceneTraps.getState]: { [ipcChannels.sceneTraps.getState]: {
req: Record<string, never>; req: Record<string, never>;
res: { state: SceneTrapsState }; res: { state: SceneTrapsState };
@@ -952,6 +968,7 @@ export type LegacyIpcEventMap = {
[ipcChannels.materialsOverlay.stateChanged]: { state: MaterialsOverlayState }; [ipcChannels.materialsOverlay.stateChanged]: { state: MaterialsOverlayState };
[ipcChannels.npcsOverlay.stateChanged]: { state: NpcsOverlayState }; [ipcChannels.npcsOverlay.stateChanged]: { state: NpcsOverlayState };
[ipcChannels.sceneDarkness.stateChanged]: { state: SceneDarknessState }; [ipcChannels.sceneDarkness.stateChanged]: { state: SceneDarknessState };
[ipcChannels.sceneMarker.stateChanged]: { state: SceneMarkerState };
[ipcChannels.sceneTraps.stateChanged]: { state: SceneTrapsState }; [ipcChannels.sceneTraps.stateChanged]: { state: SceneTrapsState };
[ipcChannels.sceneView.stateChanged]: { state: SceneViewState }; [ipcChannels.sceneView.stateChanged]: { state: SceneViewState };
[ipcChannels.tokens.stateChanged]: { tokens: AppToken[] }; [ipcChannels.tokens.stateChanged]: { tokens: AppToken[] };
+30
View File
@@ -0,0 +1,30 @@
import assert from 'node:assert/strict';
import test from 'node:test';
import { applySceneMarkerEraserStroke } from './sceneMarkerEraser';
import type { SceneMarkerStroke } from './types/sceneMarker';
void test('applySceneMarkerEraserStroke: удаляет точки под ластиком', () => {
const strokes: SceneMarkerStroke[] = [
{
id: 'm1',
seed: 1,
createdAtMs: 100,
points: [
{ x: 0.1, y: 0.5, tMs: 100 },
{ x: 0.5, y: 0.5, tMs: 101 },
{ x: 0.9, y: 0.5, tMs: 102 },
],
radiusN: 0.04,
color: '#ff0000',
},
];
const next = applySceneMarkerEraserStroke(
strokes,
[{ x: 0.5, y: 0.5, tMs: 200 }],
0.06,
(prefix) => `${prefix}_new`,
);
assert.ok(next.length >= 1);
assert.ok(next.every((s) => s.points.every((p) => Math.abs(p.x - 0.5) > 0.05)));
});
+107
View File
@@ -0,0 +1,107 @@
import type { NPoint } from './types/effects';
import type { SceneMarkerStroke } from './types/sceneMarker';
function distSqPointToSegment(
px: number,
py: number,
x1: number,
y1: number,
x2: number,
y2: number,
): number {
const dx = x2 - x1;
const dy = y2 - y1;
const len2 = dx * dx + dy * dy;
if (len2 < 1e-18) {
const ex = px - x1;
const ey = py - y1;
return ex * ex + ey * ey;
}
let t = ((px - x1) * dx + (py - y1) * dy) / len2;
t = Math.max(0, Math.min(1, t));
const qx = x1 + t * dx;
const qy = y1 + t * dy;
const ex = px - qx;
const ey = py - qy;
return ex * ex + ey * ey;
}
function minDistToEraserStroke(
p: { x: number; y: number },
eraserPoints: readonly { x: number; y: number }[],
): number {
if (eraserPoints.length === 0) return Number.POSITIVE_INFINITY;
let bestSq = Number.POSITIVE_INFINITY;
for (const ep of eraserPoints) {
const dx = p.x - ep.x;
const dy = p.y - ep.y;
bestSq = Math.min(bestSq, dx * dx + dy * dy);
}
for (let i = 1; i < eraserPoints.length; i += 1) {
const a = eraserPoints[i - 1];
const b = eraserPoints[i];
if (!a || !b) continue;
bestSq = Math.min(bestSq, distSqPointToSegment(p.x, p.y, a.x, a.y, b.x, b.y));
}
return Math.sqrt(bestSq);
}
function isPointErased(
p: { x: number; y: number },
strokeRadiusN: number,
eraserPoints: readonly { x: number; y: number }[],
eraserRadiusN: number,
): boolean {
const threshold = eraserRadiusN + strokeRadiusN;
return minDistToEraserStroke(p, eraserPoints) <= threshold;
}
function splitKeptPointRuns(
points: NPoint[],
eraserPoints: readonly { x: number; y: number }[],
eraserRadiusN: number,
strokeRadiusN: number,
): NPoint[][] {
const runs: NPoint[][] = [];
let current: NPoint[] = [];
for (const p of points) {
if (isPointErased(p, strokeRadiusN, eraserPoints, eraserRadiusN)) {
if (current.length > 0) {
runs.push(current);
current = [];
}
continue;
}
current.push(p);
}
if (current.length > 0) runs.push(current);
return runs;
}
/** Стирает штрихи маркера ластиком (как туман/огонь). */
export function applySceneMarkerEraserStroke(
strokes: readonly SceneMarkerStroke[],
eraserPoints: readonly NPoint[],
eraserRadiusN: number,
makeId: (prefix: string) => string,
): SceneMarkerStroke[] {
if (eraserPoints.length === 0) return [...strokes];
const out: SceneMarkerStroke[] = [];
for (const stroke of strokes) {
const runs = splitKeptPointRuns(stroke.points, eraserPoints, eraserRadiusN, stroke.radiusN);
if (runs.length === 0) continue;
if (runs.length === 1 && runs[0]?.length === stroke.points.length) {
out.push(stroke);
continue;
}
for (const run of runs) {
if (run.length === 0) continue;
out.push({
...stroke,
id: makeId('mk'),
points: run,
});
}
}
return out;
}
+1
View File
@@ -12,6 +12,7 @@ export type EffectToolType =
| 'freeze' | 'freeze'
| 'exploreBrush' | 'exploreBrush'
| 'closeBrush' | 'closeBrush'
| 'marker'
| 'eraser'; | 'eraser';
export type EffectInstanceType = export type EffectInstanceType =
+1
View File
@@ -9,6 +9,7 @@ export * from './npcDisposition';
export * from './npcExternalUrl'; export * from './npcExternalUrl';
export * from './npcs'; export * from './npcs';
export * from './sceneDarkness'; export * from './sceneDarkness';
export * from './sceneMarker';
export * from './sceneGrid'; export * from './sceneGrid';
export * from './sceneGridSnap'; export * from './sceneGridSnap';
export * from './tokenPath'; export * from './tokenPath';
+37
View File
@@ -0,0 +1,37 @@
import type { NPoint } from './effects';
export type SceneMarkerBrushShape = 'circle' | 'square';
export type SceneMarkerStroke = {
id: string;
seed: number;
createdAtMs: number;
points: NPoint[];
radiusN: number;
color: string;
shape?: SceneMarkerBrushShape;
};
export type SceneMarkerDraft = {
points: NPoint[];
radiusN: number;
color: string;
shape?: SceneMarkerBrushShape;
};
export type SceneMarkerState = {
revision: number;
cacheKey: string | null;
strokes: SceneMarkerStroke[];
draft: SceneMarkerDraft | null;
};
export type SceneMarkerEvent =
| { kind: 'draft.set'; draft: SceneMarkerDraft | null }
| { kind: 'stroke.add'; stroke: SceneMarkerStroke }
| { kind: 'strokes.erase'; points: NPoint[]; radiusN: number }
| { kind: 'strokes.clear' };
export function normalizeSceneMarkerBrushShape(raw: unknown): SceneMarkerBrushShape {
return raw === 'square' ? 'square' : 'circle';
}
+1 -1
View File
@@ -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/shared/types/sceneDarkness.test.ts app/shared/types/materialLegend.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/effects/sceneMarkerStore.test.ts app/shared/sceneMarkerEraser.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/shared/types/materialLegend.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",