Compare commits
3 Commits
0a9168b320
...
8b28a9b11a
| Author | SHA1 | Date | |
|---|---|---|---|
| 8b28a9b11a | |||
| 153be98780 | |||
| ea718a26df |
@@ -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);
|
||||||
|
});
|
||||||
@@ -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();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
+35
-5
@@ -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();
|
||||||
@@ -745,11 +768,7 @@ async function main() {
|
|||||||
const open = projectStore.getOpenProject();
|
const open = projectStore.getOpenProject();
|
||||||
if (!open) throw new Error('No open project');
|
if (!open) throw new Error('No open project');
|
||||||
const gn = graphNodeId ? open.sceneGraphNodes.find((n) => n.id === graphNodeId) : null;
|
const gn = graphNodeId ? open.sceneGraphNodes.find((n) => n.id === graphNodeId) : null;
|
||||||
await projectStore.updateProject((p) => ({
|
projectStore.setSessionPointer(graphNodeId, gn ? gn.sceneId : null);
|
||||||
...p,
|
|
||||||
currentGraphNodeId: graphNodeId,
|
|
||||||
currentSceneId: gn ? gn.sceneId : null,
|
|
||||||
}));
|
|
||||||
effectsStore.clear();
|
effectsStore.clear();
|
||||||
materialsOverlayStore.clear();
|
materialsOverlayStore.clear();
|
||||||
npcsOverlayStore.clear();
|
npcsOverlayStore.clear();
|
||||||
@@ -759,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();
|
||||||
@@ -1431,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() };
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -105,6 +105,23 @@ void test('zipStore: listProjects skips unreadable archives', () => {
|
|||||||
);
|
);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
void test('zipStore: setSessionPointer updates pointer in memory without disk write', () => {
|
||||||
|
const src = fs.readFileSync(path.join(here, 'zipStore.ts'), 'utf8');
|
||||||
|
const m = src.match(/setSessionPointer\([\s\S]*?\n \}/);
|
||||||
|
assert.ok(m);
|
||||||
|
assert.doesNotMatch(m[0], /writeCacheProject|queueSave|enqueueProjectUpdate/);
|
||||||
|
});
|
||||||
|
|
||||||
|
void test('main index: setCurrentGraphNode uses setSessionPointer not updateProject', () => {
|
||||||
|
const src = fs.readFileSync(path.join(here, '..', 'index.ts'), 'utf8');
|
||||||
|
const m = src.match(
|
||||||
|
/registerHandler\(ipcChannels\.project\.setCurrentGraphNode[\s\S]*?emitSessionState\(\)/,
|
||||||
|
);
|
||||||
|
assert.ok(m);
|
||||||
|
assert.match(m[0], /setSessionPointer/);
|
||||||
|
assert.doesNotMatch(m[0], /updateProject/);
|
||||||
|
});
|
||||||
|
|
||||||
void test('atomicReplace: replaceFileAtomic must not rm destination before successful commit', () => {
|
void test('atomicReplace: replaceFileAtomic must not rm destination before successful commit', () => {
|
||||||
const src = fs.readFileSync(path.join(here, 'atomicReplace.ts'), 'utf8');
|
const src = fs.readFileSync(path.join(here, 'atomicReplace.ts'), 'utf8');
|
||||||
const i = src.indexOf('export async function replaceFileAtomic');
|
const i = src.indexOf('export async function replaceFileAtomic');
|
||||||
|
|||||||
@@ -411,6 +411,18 @@ export class ZipProjectStore {
|
|||||||
return this.openProject?.project ?? null;
|
return this.openProject?.project ?? null;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** Только указатель сессии в памяти — без записи project.json на диск. */
|
||||||
|
setSessionPointer(currentGraphNodeId: GraphNodeId | null, currentSceneId: SceneId | null): Project {
|
||||||
|
const open = this.openProject;
|
||||||
|
if (!open) throw new Error('No open project');
|
||||||
|
open.project = {
|
||||||
|
...open.project,
|
||||||
|
currentGraphNodeId,
|
||||||
|
currentSceneId,
|
||||||
|
};
|
||||||
|
return open.project;
|
||||||
|
}
|
||||||
|
|
||||||
/** Публичный URL для ассетов — кастомная схема `dnd:` (см. `registerDndAssetProtocol`). */
|
/** Публичный URL для ассетов — кастомная схема `dnd:` (см. `registerDndAssetProtocol`). */
|
||||||
getAssetFileUrl(assetId: AssetId): string | null {
|
getAssetFileUrl(assetId: AssetId): string | null {
|
||||||
if (!this.getAssetReadInfo(assetId)) return null;
|
if (!this.getAssetReadInfo(assetId)) return null;
|
||||||
|
|||||||
@@ -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,61 @@
|
|||||||
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);
|
||||||
|
}
|
||||||
|
|
||||||
|
.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;
|
||||||
|
|||||||
@@ -16,9 +16,12 @@ import type {
|
|||||||
MaterialId,
|
MaterialId,
|
||||||
NpcDisposition,
|
NpcDisposition,
|
||||||
Scene,
|
Scene,
|
||||||
|
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,
|
||||||
@@ -46,9 +49,11 @@ 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 { DEFAULT_MATERIALS_OVERLAY_LAYOUT, DEFAULT_NPCS_OVERLAY_LAYOUT } from '../../shared/types';
|
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 { MaterialLegendPanel } from '../shared/materials/MaterialLegendPanel';
|
||||||
import { MaterialOverlay } from '../shared/materials/MaterialOverlay';
|
import { MaterialOverlay } from '../shared/materials/MaterialOverlay';
|
||||||
import { useMaterialsOverlayState } from '../shared/materials/useMaterialsOverlayState';
|
import { useMaterialsOverlayState } from '../shared/materials/useMaterialsOverlayState';
|
||||||
@@ -159,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();
|
||||||
@@ -185,6 +191,16 @@ 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 [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);
|
||||||
@@ -227,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 }[];
|
||||||
@@ -257,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);
|
||||||
@@ -399,6 +426,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,13 +1138,42 @@ 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');
|
||||||
|
}
|
||||||
|
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') {
|
||||||
|
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;
|
||||||
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;
|
||||||
@@ -1120,6 +1182,15 @@ export function ControlApp() {
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
el.style.visibility = 'visible';
|
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(' ');
|
||||||
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 +1368,16 @@ 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',
|
),
|
||||||
},
|
});
|
||||||
|
}
|
||||||
|
if (b?.tool === 'marker' && b.points) {
|
||||||
|
void marker.dispatch({
|
||||||
|
kind: 'draft.set',
|
||||||
|
draft: markerBrushDraft(b.points),
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
@@ -1309,7 +1385,15 @@ export function ControlApp() {
|
|||||||
|
|
||||||
useLayoutEffect(() => {
|
useLayoutEffect(() => {
|
||||||
layoutBrushCursor();
|
layoutBrushCursor();
|
||||||
}, [tool.radiusN, previewContentRect, previewSize.w, previewSize.h]);
|
}, [
|
||||||
|
tool.radiusN,
|
||||||
|
previewContentRect,
|
||||||
|
markerViewport,
|
||||||
|
previewSize.w,
|
||||||
|
previewSize.h,
|
||||||
|
darknessBrushShape,
|
||||||
|
markerBrushShape,
|
||||||
|
]);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
return () => {
|
return () => {
|
||||||
@@ -1319,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?: {
|
||||||
@@ -1350,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' });
|
||||||
@@ -1459,6 +1568,21 @@ 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,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
}
|
||||||
|
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,
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
@@ -1634,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
|
||||||
@@ -1729,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>
|
||||||
@@ -1891,6 +2029,119 @@ 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}
|
||||||
|
{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
|
||||||
@@ -1914,9 +2165,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 +2212,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>
|
||||||
|
|
||||||
@@ -2126,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();
|
||||||
@@ -2150,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 };
|
||||||
@@ -2175,11 +2443,16 @@ 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',
|
),
|
||||||
},
|
});
|
||||||
|
}
|
||||||
|
if (tool.tool === 'marker') {
|
||||||
|
void marker.dispatch({
|
||||||
|
kind: 'draft.set',
|
||||||
|
draft: markerBrushDraft([startPt]),
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
pushDraftToPixi();
|
pushDraftToPixi();
|
||||||
@@ -2199,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;
|
||||||
|
if (p) {
|
||||||
cursorPosRef.current = p;
|
cursorPosRef.current = p;
|
||||||
layoutBrushCursor();
|
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];
|
||||||
@@ -2261,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();
|
||||||
}}
|
}}
|
||||||
@@ -2457,12 +2748,7 @@ export function ControlApp() {
|
|||||||
material,
|
material,
|
||||||
layout: materialsOverlay?.layouts[id] ?? DEFAULT_MATERIALS_OVERLAY_LAYOUT,
|
layout: materialsOverlay?.layouts[id] ?? DEFAULT_MATERIALS_OVERLAY_LAYOUT,
|
||||||
legendLayout:
|
legendLayout:
|
||||||
materialsOverlay?.legendLayouts[id] ?? {
|
materialsOverlay?.legendLayouts[id] ?? defaultLegendLayoutForMaterial(),
|
||||||
...DEFAULT_MATERIALS_OVERLAY_LAYOUT,
|
|
||||||
cx: 0.82,
|
|
||||||
cy: 0.5,
|
|
||||||
scale: 0.85,
|
|
||||||
},
|
|
||||||
};
|
};
|
||||||
})
|
})
|
||||||
.filter((x): x is NonNullable<typeof x> => x !== null)
|
.filter((x): x is NonNullable<typeof x> => x !== null)
|
||||||
@@ -2485,6 +2771,7 @@ export function ControlApp() {
|
|||||||
const showMaterial = materialItems.length > 0;
|
const showMaterial = materialItems.length > 0;
|
||||||
const showNpcs = npcItems.length > 0;
|
const showNpcs = npcItems.length > 0;
|
||||||
const screenRect = presentationScreenRect;
|
const screenRect = presentationScreenRect;
|
||||||
|
const overlayViewport = screenRect;
|
||||||
const showGuide = Boolean(screenRect);
|
const showGuide = Boolean(screenRect);
|
||||||
if (!showMaterial && !showNpcs && !showGuide) return null;
|
if (!showMaterial && !showNpcs && !showGuide) return null;
|
||||||
const closes = [
|
const closes = [
|
||||||
@@ -2522,7 +2809,7 @@ export function ControlApp() {
|
|||||||
<>
|
<>
|
||||||
<SceneOverlayHost
|
<SceneOverlayHost
|
||||||
active={showMaterial || showNpcs}
|
active={showMaterial || showNpcs}
|
||||||
viewport={screenRect}
|
viewport={overlayViewport}
|
||||||
showViewportGuide={showGuide}
|
showViewportGuide={showGuide}
|
||||||
zoomTool={materialsZoom}
|
zoomTool={materialsZoom}
|
||||||
{...(materialsZoom
|
{...(materialsZoom
|
||||||
@@ -2539,10 +2826,31 @@ export function ControlApp() {
|
|||||||
}
|
}
|
||||||
: {})}
|
: {})}
|
||||||
closes={closes}
|
closes={closes}
|
||||||
|
legendLayer={
|
||||||
|
<>
|
||||||
|
{materialItems.map(({ material, legendLayout }) =>
|
||||||
|
material.legend?.enabled ? (
|
||||||
|
<MaterialLegendPanel
|
||||||
|
key={material.id}
|
||||||
|
legend={material.legend}
|
||||||
|
layout={legendLayout}
|
||||||
|
editable
|
||||||
|
onLayoutChange={(nextLayout) => {
|
||||||
|
void materialsApi.dispatch({
|
||||||
|
kind: 'legendLayout.set',
|
||||||
|
materialId: material.id,
|
||||||
|
layout: nextLayout,
|
||||||
|
});
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
) : null,
|
||||||
|
)}
|
||||||
|
</>
|
||||||
|
}
|
||||||
>
|
>
|
||||||
{materialItems.map(({ material, layout, legendLayout }) => (
|
{materialItems.map(({ material, layout }) => (
|
||||||
<React.Fragment key={material.id}>
|
|
||||||
<MaterialOverlay
|
<MaterialOverlay
|
||||||
|
key={material.id}
|
||||||
embedded
|
embedded
|
||||||
assetId={material.assetId}
|
assetId={material.assetId}
|
||||||
materialId={material.id}
|
materialId={material.id}
|
||||||
@@ -2557,25 +2865,10 @@ export function ControlApp() {
|
|||||||
layout: nextLayout,
|
layout: nextLayout,
|
||||||
});
|
});
|
||||||
}}
|
}}
|
||||||
{...(material.legend?.enabled
|
{...(material.legend?.enabled && (material.legend.markers?.length ?? 0) > 0
|
||||||
? { legendMarkers: material.legend.markers ?? [] }
|
? { legendMarkers: material.legend.markers }
|
||||||
: {})}
|
: {})}
|
||||||
/>
|
/>
|
||||||
{material.legend?.enabled ? (
|
|
||||||
<MaterialLegendPanel
|
|
||||||
legend={material.legend}
|
|
||||||
layout={legendLayout}
|
|
||||||
editable
|
|
||||||
onLayoutChange={(nextLayout) => {
|
|
||||||
void materialsApi.dispatch({
|
|
||||||
kind: 'legendLayout.set',
|
|
||||||
materialId: material.id,
|
|
||||||
layout: nextLayout,
|
|
||||||
});
|
|
||||||
}}
|
|
||||||
/>
|
|
||||||
) : null}
|
|
||||||
</React.Fragment>
|
|
||||||
))}
|
))}
|
||||||
{showNpcs ? (
|
{showNpcs ? (
|
||||||
<NpcsSceneOverlay
|
<NpcsSceneOverlay
|
||||||
@@ -2592,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'));
|
||||||
@@ -113,7 +122,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 +137,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 +185,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')");
|
||||||
|
|||||||
@@ -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': 'Описание отсутствует',
|
||||||
@@ -630,8 +632,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': 'Пройдено',
|
||||||
@@ -1244,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',
|
||||||
@@ -1267,8 +1274,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',
|
||||||
|
|||||||
@@ -7,9 +7,11 @@ 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 { DEFAULT_MATERIALS_OVERLAY_LAYOUT, DEFAULT_NPCS_OVERLAY_LAYOUT } from '../../shared/types';
|
import { useSceneMarkerState } from './effects/useSceneMarkerState';
|
||||||
|
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';
|
||||||
import { MaterialOverlay } from './materials/MaterialOverlay';
|
import { MaterialOverlay } from './materials/MaterialOverlay';
|
||||||
@@ -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;
|
||||||
@@ -99,7 +123,7 @@ export function PresentationView({
|
|||||||
return {
|
return {
|
||||||
material,
|
material,
|
||||||
layout: materialsOverlay?.layouts[id] ?? DEFAULT_MATERIALS_OVERLAY_LAYOUT,
|
layout: materialsOverlay?.layouts[id] ?? DEFAULT_MATERIALS_OVERLAY_LAYOUT,
|
||||||
legendLayout: materialsOverlay?.legendLayouts[id] ?? DEFAULT_MATERIALS_OVERLAY_LAYOUT,
|
legendLayout: materialsOverlay?.legendLayouts[id] ?? defaultLegendLayoutForMaterial(),
|
||||||
};
|
};
|
||||||
})
|
})
|
||||||
.filter((x): x is NonNullable<typeof x> => x !== null)
|
.filter((x): x is NonNullable<typeof x> => x !== null)
|
||||||
@@ -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,20 +309,32 @@ 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}
|
||||||
<SceneOverlayHost active={activeMaterialItems.length > 0 || activeNpcItems.length > 0}>
|
{markerViewport ? (
|
||||||
{activeMaterialItems.map(({ material, layout, legendLayout }) => (
|
<SceneMarkerOverlay state={markerState} viewport={markerViewport} style={{ zIndex: 48 }} />
|
||||||
<React.Fragment key={material.id}>
|
) : null}
|
||||||
|
<SceneOverlayHost
|
||||||
|
active={activeMaterialItems.length > 0 || activeNpcItems.length > 0}
|
||||||
|
legendLayer={
|
||||||
|
<>
|
||||||
|
{activeMaterialItems.map(({ material, legendLayout }) =>
|
||||||
|
material.legend?.enabled ? (
|
||||||
|
<MaterialLegendPanel key={material.id} legend={material.legend} layout={legendLayout} />
|
||||||
|
) : null,
|
||||||
|
)}
|
||||||
|
</>
|
||||||
|
}
|
||||||
|
>
|
||||||
|
{activeMaterialItems.map(({ material, layout }) => (
|
||||||
<MaterialOverlay
|
<MaterialOverlay
|
||||||
|
key={material.id}
|
||||||
embedded
|
embedded
|
||||||
assetId={material.assetId}
|
assetId={material.assetId}
|
||||||
layout={layout}
|
layout={layout}
|
||||||
materialId={material.id}
|
materialId={material.id}
|
||||||
{...(material.legend?.enabled ? { legendMarkers: material.legend.markers ?? [] } : {})}
|
{...(material.legend?.enabled && (material.legend.markers?.length ?? 0) > 0
|
||||||
|
? { legendMarkers: material.legend.markers }
|
||||||
|
: {})}
|
||||||
/>
|
/>
|
||||||
{material.legend?.enabled ? (
|
|
||||||
<MaterialLegendPanel legend={material.legend} layout={legendLayout} />
|
|
||||||
) : null}
|
|
||||||
</React.Fragment>
|
|
||||||
))}
|
))}
|
||||||
{activeNpcItems.length > 0 ? <NpcsSceneOverlay embedded items={activeNpcItems} /> : null}
|
{activeNpcItems.length > 0 ? <NpcsSceneOverlay embedded items={activeNpcItems} /> : null}
|
||||||
</SceneOverlayHost>
|
</SceneOverlayHost>
|
||||||
|
|||||||
@@ -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,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;
|
||||||
|
}
|
||||||
@@ -1,10 +1,8 @@
|
|||||||
.panel {
|
.panel {
|
||||||
position: absolute;
|
position: absolute;
|
||||||
z-index: 45;
|
z-index: 45;
|
||||||
min-width: 200px;
|
overflow: visible;
|
||||||
max-width: min(360px, 42vw);
|
box-sizing: border-box;
|
||||||
max-height: min(70vh, 520px);
|
|
||||||
overflow: auto;
|
|
||||||
padding: 14px 16px;
|
padding: 14px 16px;
|
||||||
border-radius: 14px;
|
border-radius: 14px;
|
||||||
border: 1px solid rgba(255, 255, 255, 0.14);
|
border: 1px solid rgba(255, 255, 255, 0.14);
|
||||||
@@ -16,6 +14,7 @@
|
|||||||
touch-action: none;
|
touch-action: none;
|
||||||
user-select: none;
|
user-select: none;
|
||||||
pointer-events: auto;
|
pointer-events: auto;
|
||||||
|
transform-origin: center center;
|
||||||
}
|
}
|
||||||
|
|
||||||
.title {
|
.title {
|
||||||
|
|||||||
@@ -2,6 +2,7 @@ import React, { useRef } from 'react';
|
|||||||
|
|
||||||
import type { MaterialLegend, MaterialsOverlayLayout } from '../../../shared/types';
|
import type { MaterialLegend, MaterialsOverlayLayout } from '../../../shared/types';
|
||||||
import { DEFAULT_MATERIALS_OVERLAY_LAYOUT } from '../../../shared/types';
|
import { DEFAULT_MATERIALS_OVERLAY_LAYOUT } from '../../../shared/types';
|
||||||
|
import { legendPanelVisualLayout } from '../../../shared/types/materialLegend';
|
||||||
import { useSceneOverlayView } from '../sceneOverlay/SceneOverlayViewContext';
|
import { useSceneOverlayView } from '../sceneOverlay/SceneOverlayViewContext';
|
||||||
|
|
||||||
import styles from './MaterialLegendPanel.module.css';
|
import styles from './MaterialLegendPanel.module.css';
|
||||||
@@ -21,20 +22,24 @@ export function MaterialLegendPanel({
|
|||||||
}: Props) {
|
}: Props) {
|
||||||
const host = useSceneOverlayView();
|
const host = useSceneOverlayView();
|
||||||
const view = host?.view ?? { w: 1, h: 1 };
|
const view = host?.view ?? { w: 1, h: 1 };
|
||||||
|
const viewport = host?.viewport ?? { x: 0, y: 0, w: view.w, h: view.h };
|
||||||
const dragRef = useRef<{ startX: number; startY: number; origin: MaterialsOverlayLayout } | null>(
|
const dragRef = useRef<{ startX: number; startY: number; origin: MaterialsOverlayLayout } | null>(
|
||||||
null,
|
null,
|
||||||
);
|
);
|
||||||
const effective = layout ?? DEFAULT_MATERIALS_OVERLAY_LAYOUT;
|
const effective = layout ?? DEFAULT_MATERIALS_OVERLAY_LAYOUT;
|
||||||
const w = Math.max(180, Math.min(view.w * 0.36, 340) * effective.scale);
|
const vis = legendPanelVisualLayout(effective, viewport);
|
||||||
const left = effective.cx * view.w - w / 2;
|
|
||||||
const top = effective.cy * view.h - 40;
|
|
||||||
|
|
||||||
if (!legend.enabled || legend.items.length === 0) return null;
|
if (!legend.enabled || legend.items.length === 0) return null;
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div
|
<div
|
||||||
className={styles.panel}
|
className={styles.panel}
|
||||||
style={{ left, top, width: w }}
|
style={{
|
||||||
|
left: vis.left,
|
||||||
|
top: vis.top,
|
||||||
|
width: vis.width,
|
||||||
|
transform: `translate(-50%, -50%) scale(${String(vis.scale)})`,
|
||||||
|
}}
|
||||||
onPointerDown={(e) => {
|
onPointerDown={(e) => {
|
||||||
if (!editable || !onLayoutChange) return;
|
if (!editable || !onLayoutChange) return;
|
||||||
e.stopPropagation();
|
e.stopPropagation();
|
||||||
|
|||||||
@@ -17,6 +17,29 @@
|
|||||||
pointer-events: none;
|
pointer-events: none;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.hostViewport {
|
||||||
|
position: absolute;
|
||||||
|
overflow: hidden;
|
||||||
|
pointer-events: none;
|
||||||
|
z-index: 41;
|
||||||
|
}
|
||||||
|
|
||||||
|
.markerLayer {
|
||||||
|
position: absolute;
|
||||||
|
inset: 0;
|
||||||
|
z-index: 45;
|
||||||
|
overflow: visible;
|
||||||
|
pointer-events: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
.legendLayer {
|
||||||
|
position: absolute;
|
||||||
|
inset: 0;
|
||||||
|
z-index: 46;
|
||||||
|
overflow: visible;
|
||||||
|
pointer-events: none;
|
||||||
|
}
|
||||||
|
|
||||||
.viewportGuide {
|
.viewportGuide {
|
||||||
position: absolute;
|
position: absolute;
|
||||||
inset: 0;
|
inset: 0;
|
||||||
@@ -65,7 +88,7 @@
|
|||||||
width: 100%;
|
width: 100%;
|
||||||
height: 100%;
|
height: 100%;
|
||||||
display: block;
|
display: block;
|
||||||
object-fit: fill;
|
object-fit: contain;
|
||||||
border-radius: 6px;
|
border-radius: 6px;
|
||||||
box-shadow: 0 18px 48px rgba(0, 0, 0, 0.55);
|
box-shadow: 0 18px 48px rgba(0, 0, 0, 0.55);
|
||||||
user-select: none;
|
user-select: none;
|
||||||
@@ -90,6 +113,24 @@
|
|||||||
box-shadow: 0 2px 8px rgba(0, 0, 0, 0.35);
|
box-shadow: 0 2px 8px rgba(0, 0, 0, 0.35);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.legendMarkerHost {
|
||||||
|
position: absolute;
|
||||||
|
transform-origin: center center;
|
||||||
|
width: 26px;
|
||||||
|
height: 26px;
|
||||||
|
border-radius: 50%;
|
||||||
|
background: #1e3a5f;
|
||||||
|
border: 2px solid #f5c542;
|
||||||
|
color: #fff;
|
||||||
|
font-size: 12px;
|
||||||
|
font-weight: 800;
|
||||||
|
display: grid;
|
||||||
|
place-items: center;
|
||||||
|
pointer-events: none;
|
||||||
|
z-index: 47;
|
||||||
|
box-shadow: 0 2px 8px rgba(0, 0, 0, 0.35);
|
||||||
|
}
|
||||||
|
|
||||||
.handle {
|
.handle {
|
||||||
position: absolute;
|
position: absolute;
|
||||||
width: 12px;
|
width: 12px;
|
||||||
|
|||||||
@@ -1,7 +1,13 @@
|
|||||||
import React, { useEffect, useRef, useState } from 'react';
|
import React, { useEffect, useLayoutEffect, useRef, useState } from 'react';
|
||||||
|
import { createPortal } from 'react-dom';
|
||||||
|
|
||||||
import type { AssetId, MaterialId, MaterialsOverlayLayout, MaterialsZoomTool, NpcsZoomTool } from '../../../shared/types';
|
import type { AssetId, MaterialId, MaterialsOverlayLayout, MaterialsZoomTool, NpcsZoomTool } from '../../../shared/types';
|
||||||
import { DEFAULT_MATERIALS_OVERLAY_LAYOUT } from '../../../shared/types';
|
import { DEFAULT_MATERIALS_OVERLAY_LAYOUT } from '../../../shared/types';
|
||||||
|
import {
|
||||||
|
LEGEND_MARKER_DESIGN_SIZE,
|
||||||
|
legendMarkerPositionInOverlayHost,
|
||||||
|
legendMarkerScreenScale,
|
||||||
|
} from '../../../shared/types/materialLegend';
|
||||||
import { useSceneOverlayView } from '../sceneOverlay/SceneOverlayViewContext';
|
import { useSceneOverlayView } from '../sceneOverlay/SceneOverlayViewContext';
|
||||||
import { useAssetUrl } from '../useAssetImageUrl';
|
import { useAssetUrl } from '../useAssetImageUrl';
|
||||||
|
|
||||||
@@ -111,6 +117,7 @@ export function MaterialOverlay({
|
|||||||
const rootRef = embedded && host ? host.rootRef : localRootRef;
|
const rootRef = embedded && host ? host.rootRef : localRootRef;
|
||||||
const [natural, setNatural] = useState<{ w: number; h: number }>({ w: 1600, h: 900 });
|
const [natural, setNatural] = useState<{ w: number; h: number }>({ w: 1600, h: 900 });
|
||||||
const [localView, setLocalView] = useState({ w: 1, h: 1 });
|
const [localView, setLocalView] = useState({ w: 1, h: 1 });
|
||||||
|
const [markerLayerReady, setMarkerLayerReady] = useState(false);
|
||||||
/** Локальный layout + IPC не чаще 1/frame (лайв, без спама pointermove). */
|
/** Локальный layout + IPC не чаще 1/frame (лайв, без спама pointermove). */
|
||||||
const [draftLayout, setDraftLayout] = useState<MaterialsOverlayLayout | null>(null);
|
const [draftLayout, setDraftLayout] = useState<MaterialsOverlayLayout | null>(null);
|
||||||
const pendingLayoutRef = useRef<MaterialsOverlayLayout | null>(null);
|
const pendingLayoutRef = useRef<MaterialsOverlayLayout | null>(null);
|
||||||
@@ -170,6 +177,22 @@ export function MaterialOverlay({
|
|||||||
pendingLayoutRef.current = null;
|
pendingLayoutRef.current = null;
|
||||||
}, [layout]);
|
}, [layout]);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (!url) return;
|
||||||
|
const img = new Image();
|
||||||
|
const apply = () => {
|
||||||
|
setNatural({ w: img.naturalWidth || 1, h: img.naturalHeight || 1 });
|
||||||
|
};
|
||||||
|
img.onload = apply;
|
||||||
|
img.src = url;
|
||||||
|
if (img.complete) apply();
|
||||||
|
}, [url]);
|
||||||
|
|
||||||
|
useLayoutEffect(() => {
|
||||||
|
if (!embedded) return;
|
||||||
|
setMarkerLayerReady(Boolean(host?.markerLayerRef.current));
|
||||||
|
}, [embedded, host, host?.view.w, host?.view.h, natural.w, natural.h, legendMarkers, layout, draftLayout]);
|
||||||
|
|
||||||
if (!assetId || !url) return null;
|
if (!assetId || !url) return null;
|
||||||
|
|
||||||
const interactive = editable || showClose || Boolean(zoomTool);
|
const interactive = editable || showClose || Boolean(zoomTool);
|
||||||
@@ -392,18 +415,6 @@ export function MaterialOverlay({
|
|||||||
setNatural({ w: img.naturalWidth || 1, h: img.naturalHeight || 1 });
|
setNatural({ w: img.naturalWidth || 1, h: img.naturalHeight || 1 });
|
||||||
}}
|
}}
|
||||||
/>
|
/>
|
||||||
{(legendMarkers ?? []).map((m) => (
|
|
||||||
<div
|
|
||||||
key={m.id}
|
|
||||||
className={styles.legendMarker}
|
|
||||||
style={{
|
|
||||||
left: `${String(m.nx * 100)}%`,
|
|
||||||
top: `${String(m.ny * 100)}%`,
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
{m.number}
|
|
||||||
</div>
|
|
||||||
))}
|
|
||||||
{editable && !zoomTool
|
{editable && !zoomTool
|
||||||
? (['nw', 'ne', 'sw', 'se'] as const).map((corner) => (
|
? (['nw', 'ne', 'sw', 'se'] as const).map((corner) => (
|
||||||
<button
|
<button
|
||||||
@@ -430,7 +441,46 @@ export function MaterialOverlay({
|
|||||||
);
|
);
|
||||||
|
|
||||||
if (embedded) {
|
if (embedded) {
|
||||||
return frame;
|
const overlayViewport = host?.viewport ?? { x: 0, y: 0, w: view.w, h: view.h };
|
||||||
|
const markerLayer = host?.markerLayerRef.current;
|
||||||
|
const markerScale = legendMarkerScreenScale(overlayViewport.w);
|
||||||
|
const markerNodes =
|
||||||
|
(legendMarkers ?? []).length > 0
|
||||||
|
? (legendMarkers ?? []).map((m) => {
|
||||||
|
const pos = legendMarkerPositionInOverlayHost(
|
||||||
|
m.nx,
|
||||||
|
m.ny,
|
||||||
|
effectiveLayout,
|
||||||
|
w,
|
||||||
|
h,
|
||||||
|
natural.w,
|
||||||
|
natural.h,
|
||||||
|
overlayViewport,
|
||||||
|
);
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
key={m.id}
|
||||||
|
className={styles.legendMarkerHost}
|
||||||
|
style={{
|
||||||
|
left: pos.left,
|
||||||
|
top: pos.top,
|
||||||
|
width: LEGEND_MARKER_DESIGN_SIZE,
|
||||||
|
height: LEGEND_MARKER_DESIGN_SIZE,
|
||||||
|
transform: `translate(-50%, -50%) scale(${String(markerScale)})`,
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{m.number}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
})
|
||||||
|
: null;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<>
|
||||||
|
{frame}
|
||||||
|
{markerLayerReady && markerLayer && markerNodes ? createPortal(markerNodes, markerLayer) : null}
|
||||||
|
</>
|
||||||
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
|
|||||||
@@ -78,7 +78,9 @@
|
|||||||
}
|
}
|
||||||
|
|
||||||
.namePlate {
|
.namePlate {
|
||||||
max-width: 100%;
|
width: max-content;
|
||||||
|
min-width: 100%;
|
||||||
|
max-width: none;
|
||||||
padding: 4px 10px;
|
padding: 4px 10px;
|
||||||
border-radius: 6px;
|
border-radius: 6px;
|
||||||
background: rgba(9, 9, 11, 0.88);
|
background: rgba(9, 9, 11, 0.88);
|
||||||
@@ -88,6 +90,4 @@
|
|||||||
line-height: 1.3;
|
line-height: 1.3;
|
||||||
text-align: center;
|
text-align: center;
|
||||||
white-space: nowrap;
|
white-space: nowrap;
|
||||||
overflow: hidden;
|
|
||||||
text-overflow: ellipsis;
|
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -17,7 +17,7 @@ type SceneOverlayHostProps = {
|
|||||||
active: boolean;
|
active: boolean;
|
||||||
/**
|
/**
|
||||||
* Область раскладки кадров материалов/NPC (и жёлтой рамки).
|
* Область раскладки кадров материалов/NPC (и жёлтой рамки).
|
||||||
* На пульте — прямоугольник соотношения сторон презентации; на презентации обычно не задаётся (весь экран).
|
* На пульте — экран игроков (жёлтая рамка); на презентации не задаётся (весь экран).
|
||||||
*/
|
*/
|
||||||
viewport?: SceneOverlayViewport | null;
|
viewport?: SceneOverlayViewport | null;
|
||||||
/** Жёлтая рамка видимой области презентации (предпросмотр пульта). Без dim. */
|
/** Жёлтая рамка видимой области презентации (предпросмотр пульта). Без dim. */
|
||||||
@@ -25,7 +25,10 @@ type SceneOverlayHostProps = {
|
|||||||
zoomTool?: MaterialsZoomTool | NpcsZoomTool;
|
zoomTool?: MaterialsZoomTool | NpcsZoomTool;
|
||||||
onZoomAt?: (nx: number, ny: number, target: EventTarget | null) => void;
|
onZoomAt?: (nx: number, ny: number, target: EventTarget | null) => void;
|
||||||
closes?: readonly SceneOverlayCloseAction[];
|
closes?: readonly SceneOverlayCloseAction[];
|
||||||
|
/** Кадры материалов/NPC — внутри viewport (overflow hidden). */
|
||||||
children: React.ReactNode;
|
children: React.ReactNode;
|
||||||
|
/** Блоки легенды — вне viewport, не обрезаются жёлтой рамкой. */
|
||||||
|
legendLayer?: React.ReactNode;
|
||||||
};
|
};
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -41,12 +44,19 @@ export function SceneOverlayHost({
|
|||||||
onZoomAt,
|
onZoomAt,
|
||||||
closes = [],
|
closes = [],
|
||||||
children,
|
children,
|
||||||
|
legendLayer = null,
|
||||||
}: SceneOverlayHostProps) {
|
}: SceneOverlayHostProps) {
|
||||||
const rootRef = useRef<HTMLDivElement | null>(null);
|
const outerRef = useRef<HTMLDivElement | null>(null);
|
||||||
|
const markerLayerRef = useRef<HTMLDivElement | null>(null);
|
||||||
const [view, setView] = useState({ w: 1, h: 1 });
|
const [view, setView] = useState({ w: 1, h: 1 });
|
||||||
|
|
||||||
|
const layoutViewport = useMemo((): SceneOverlayViewport => {
|
||||||
|
if (viewport && viewport.w > 0 && viewport.h > 0) return viewport;
|
||||||
|
return { x: 0, y: 0, w: view.w, h: view.h };
|
||||||
|
}, [viewport, view.h, view.w]);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
const el = rootRef.current;
|
const el = outerRef.current;
|
||||||
if (!el) return;
|
if (!el) return;
|
||||||
let raf = 0;
|
let raf = 0;
|
||||||
const sync = () => {
|
const sync = () => {
|
||||||
@@ -67,7 +77,15 @@ export function SceneOverlayHost({
|
|||||||
};
|
};
|
||||||
}, [active, showViewportGuide, viewport]);
|
}, [active, showViewportGuide, viewport]);
|
||||||
|
|
||||||
const ctx = useMemo(() => ({ rootRef, view }), [view]);
|
const ctx = useMemo(
|
||||||
|
() => ({
|
||||||
|
rootRef: outerRef,
|
||||||
|
markerLayerRef,
|
||||||
|
view: { w: layoutViewport.w, h: layoutViewport.h },
|
||||||
|
viewport: layoutViewport,
|
||||||
|
}),
|
||||||
|
[layoutViewport],
|
||||||
|
);
|
||||||
|
|
||||||
if (!active && !showViewportGuide) return null;
|
if (!active && !showViewportGuide) return null;
|
||||||
|
|
||||||
@@ -76,12 +94,11 @@ export function SceneOverlayHost({
|
|||||||
zoomTool === 'zoomIn' ? styles.cursorZoomIn : zoomTool === 'zoomOut' ? styles.cursorZoomOut : '';
|
zoomTool === 'zoomIn' ? styles.cursorZoomIn : zoomTool === 'zoomOut' ? styles.cursorZoomOut : '';
|
||||||
|
|
||||||
const toNorm = (clientX: number, clientY: number) => {
|
const toNorm = (clientX: number, clientY: number) => {
|
||||||
const root = rootRef.current;
|
const root = outerRef.current;
|
||||||
if (!root) return { nx: 0.5, ny: 0.5 };
|
if (!root || layoutViewport.w <= 0 || layoutViewport.h <= 0) return { nx: 0.5, ny: 0.5 };
|
||||||
const r = root.getBoundingClientRect();
|
|
||||||
return {
|
return {
|
||||||
nx: (clientX - r.left) / Math.max(1, r.width),
|
nx: (clientX - root.getBoundingClientRect().left - layoutViewport.x) / layoutViewport.w,
|
||||||
ny: (clientY - r.top) / Math.max(1, r.height),
|
ny: (clientY - root.getBoundingClientRect().top - layoutViewport.y) / layoutViewport.h,
|
||||||
};
|
};
|
||||||
};
|
};
|
||||||
|
|
||||||
@@ -89,11 +106,10 @@ export function SceneOverlayHost({
|
|||||||
<SceneOverlayViewContext.Provider value={ctx}>
|
<SceneOverlayViewContext.Provider value={ctx}>
|
||||||
{active ? <div className={styles.dim} aria-hidden /> : null}
|
{active ? <div className={styles.dim} aria-hidden /> : null}
|
||||||
<div
|
<div
|
||||||
ref={rootRef}
|
ref={outerRef}
|
||||||
className={[styles.root, styles.hostHitThrough, captureZoom ? styles.captureZoom : '', zoomCursor]
|
className={[styles.root, styles.hostHitThrough, captureZoom ? styles.captureZoom : '', zoomCursor]
|
||||||
.filter(Boolean)
|
.filter(Boolean)
|
||||||
.join(' ')}
|
.join(' ')}
|
||||||
style={overlayRootStyle(viewport)}
|
|
||||||
role="presentation"
|
role="presentation"
|
||||||
onClick={(e) => {
|
onClick={(e) => {
|
||||||
if (!captureZoom || !onZoomAt) return;
|
if (!captureZoom || !onZoomAt) return;
|
||||||
@@ -101,6 +117,12 @@ export function SceneOverlayHost({
|
|||||||
const { nx, ny } = toNorm(e.clientX, e.clientY);
|
const { nx, ny } = toNorm(e.clientX, e.clientY);
|
||||||
onZoomAt(nx, ny, e.target);
|
onZoomAt(nx, ny, e.target);
|
||||||
}}
|
}}
|
||||||
|
>
|
||||||
|
<div ref={markerLayerRef} className={styles.markerLayer} />
|
||||||
|
<div
|
||||||
|
className={styles.hostViewport}
|
||||||
|
style={overlayRootStyle(layoutViewport)}
|
||||||
|
role="presentation"
|
||||||
>
|
>
|
||||||
{showViewportGuide ? <div className={styles.viewportGuide} aria-hidden /> : null}
|
{showViewportGuide ? <div className={styles.viewportGuide} aria-hidden /> : null}
|
||||||
{children}
|
{children}
|
||||||
@@ -124,6 +146,8 @@ export function SceneOverlayHost({
|
|||||||
</div>
|
</div>
|
||||||
) : null}
|
) : null}
|
||||||
</div>
|
</div>
|
||||||
|
{legendLayer ? <div className={styles.legendLayer}>{legendLayer}</div> : null}
|
||||||
|
</div>
|
||||||
</SceneOverlayViewContext.Provider>
|
</SceneOverlayViewContext.Provider>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,8 +1,16 @@
|
|||||||
import React, { createContext, useContext } from 'react';
|
import React, { createContext, useContext } from 'react';
|
||||||
|
|
||||||
|
import type { SceneOverlayViewport } from './overlayViewport';
|
||||||
|
|
||||||
export type SceneOverlayViewContextValue = {
|
export type SceneOverlayViewContextValue = {
|
||||||
|
/** Внешний контейнер оверлея (полная область превью / презентации). */
|
||||||
rootRef: React.RefObject<HTMLDivElement | null>;
|
rootRef: React.RefObject<HTMLDivElement | null>;
|
||||||
|
/** Слой маркеров легенды на материале (вне clipped viewport). */
|
||||||
|
markerLayerRef: React.RefObject<HTMLDivElement | null>;
|
||||||
|
/** Размеры области раскладки материалов (viewport.w/h). */
|
||||||
view: { w: number; h: number };
|
view: { w: number; h: number };
|
||||||
|
/** Смещение и размер viewport внутри rootRef (contentRect на презентации). */
|
||||||
|
viewport: SceneOverlayViewport | null;
|
||||||
};
|
};
|
||||||
|
|
||||||
export const SceneOverlayViewContext = createContext<SceneOverlayViewContextValue | null>(null);
|
export const SceneOverlayViewContext = createContext<SceneOverlayViewContextValue | null>(null);
|
||||||
|
|||||||
@@ -46,7 +46,7 @@ void test('MaterialOverlay / NpcsSceneOverlay поддерживают embedded
|
|||||||
assert.ok(npcs.includes('embedded'));
|
assert.ok(npcs.includes('embedded'));
|
||||||
assert.ok(npcs.includes('useSceneOverlayView'));
|
assert.ok(npcs.includes('useSceneOverlayView'));
|
||||||
// В embedded-ветке не рисуем второй dim.
|
// В embedded-ветке не рисуем второй dim.
|
||||||
assert.match(material, /if \(embedded\) \{\s*return frame;/);
|
assert.match(material, /if \(embedded\) \{[\s\S]*createPortal/);
|
||||||
assert.match(npcs, /if \(embedded\) \{\s*return <>\{frames\}<\/>;/);
|
assert.match(npcs, /if \(embedded\) \{\s*return <>\{frames\}<\/>;/);
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -76,8 +76,53 @@ void test('overlays: layout IPC live через rAF coalesce, RO host coalesced'
|
|||||||
npcs,
|
npcs,
|
||||||
/requestAnimationFrame\(\(\) => \{[\s\S]*?onLayoutChangeRef\.current\?\.\(item\.npcId, pending\)/,
|
/requestAnimationFrame\(\(\) => \{[\s\S]*?onLayoutChangeRef\.current\?\.\(item\.npcId, pending\)/,
|
||||||
);
|
);
|
||||||
assert.match(
|
assert.match(npcs, /if \(drag\.mode === 'move'\) \{[\s\S]*?publishDraft\(\{[\s\S]*?return;\s*\}/,
|
||||||
npcs,
|
|
||||||
/if \(drag\.mode === 'move'\) \{[\s\S]*?publishDraft\(\{[\s\S]*?return;\s*\}/,
|
|
||||||
);
|
);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
void test('SceneOverlayHost: presentation and control share player-screen viewport for overlays', () => {
|
||||||
|
const presentation = fs.readFileSync(path.join(root, 'app/renderer/shared/PresentationView.tsx'), 'utf8');
|
||||||
|
const control = fs.readFileSync(path.join(root, 'app/renderer/control/ControlApp.tsx'), 'utf8');
|
||||||
|
const host = fs.readFileSync(path.join(here, 'SceneOverlayHost.tsx'), 'utf8');
|
||||||
|
assert.ok(presentation.includes('<SceneOverlayHost'));
|
||||||
|
assert.doesNotMatch(presentation, /<SceneOverlayHost[\s\S]*?viewport=\{contentRect\}/);
|
||||||
|
assert.ok(control.includes('overlayViewport'));
|
||||||
|
assert.ok(control.includes('const overlayViewport = screenRect'));
|
||||||
|
assert.ok(control.includes('legendLayer'));
|
||||||
|
assert.ok(host.includes('legendLayer'));
|
||||||
|
});
|
||||||
|
|
||||||
|
void test('MaterialLegendPanel: uniform scale, no inner scroll', () => {
|
||||||
|
const panel = fs.readFileSync(
|
||||||
|
path.join(root, 'app/renderer/shared/materials/MaterialLegendPanel.tsx'),
|
||||||
|
'utf8',
|
||||||
|
);
|
||||||
|
const css = fs.readFileSync(
|
||||||
|
path.join(root, 'app/renderer/shared/materials/MaterialLegendPanel.module.css'),
|
||||||
|
'utf8',
|
||||||
|
);
|
||||||
|
assert.ok(panel.includes('legendPanelVisualLayout'));
|
||||||
|
assert.match(css, /overflow:\s*visible/);
|
||||||
|
assert.doesNotMatch(css, /overflow:\s*auto/);
|
||||||
|
assert.doesNotMatch(css, /max-height/);
|
||||||
|
});
|
||||||
|
|
||||||
|
void test('MaterialOverlay: legend markers use viewport positioning', () => {
|
||||||
|
const material = fs.readFileSync(
|
||||||
|
path.join(root, 'app/renderer/shared/materials/MaterialOverlay.tsx'),
|
||||||
|
'utf8',
|
||||||
|
);
|
||||||
|
const css = fs.readFileSync(
|
||||||
|
path.join(root, 'app/renderer/shared/materials/MaterialOverlay.module.css'),
|
||||||
|
'utf8',
|
||||||
|
);
|
||||||
|
const host = fs.readFileSync(path.join(here, 'SceneOverlayHost.tsx'), 'utf8');
|
||||||
|
assert.ok(material.includes('legendMarkerPositionInOverlayHost'));
|
||||||
|
assert.ok(material.includes('legendMarkerScreenScale'));
|
||||||
|
assert.ok(material.includes('createPortal'));
|
||||||
|
assert.ok(material.includes('markerLayerRef'));
|
||||||
|
assert.match(css, /object-fit:\s*contain/);
|
||||||
|
assert.ok(host.includes('legendLayer'));
|
||||||
|
assert.ok(host.includes('markerLayer'));
|
||||||
|
assert.ok(host.includes('hostViewport'));
|
||||||
|
});
|
||||||
|
|||||||
@@ -125,6 +125,17 @@ void test('EditorApp: split Run button and launch-with-players modal', () => {
|
|||||||
assert.ok(editor.includes('top.runWithPlayers'));
|
assert.ok(editor.includes('top.runWithPlayers'));
|
||||||
});
|
});
|
||||||
|
|
||||||
|
void test('PlayerTokenView: name plate shows full text without ellipsis', () => {
|
||||||
|
const css = fs.readFileSync(
|
||||||
|
path.join(rendererRoot, 'shared/playerToken/PlayerTokenView.module.css'),
|
||||||
|
'utf8',
|
||||||
|
);
|
||||||
|
const block = css.match(/\.namePlate\s*\{[^}]+\}/)?.[0] ?? '';
|
||||||
|
assert.match(block, /width:\s*max-content/);
|
||||||
|
assert.doesNotMatch(block, /text-overflow:\s*ellipsis/);
|
||||||
|
assert.doesNotMatch(block, /overflow:\s*hidden/);
|
||||||
|
});
|
||||||
|
|
||||||
void test('NpcsEditorApp: token appearance via PlayerTokenView', () => {
|
void test('NpcsEditorApp: token appearance via PlayerTokenView', () => {
|
||||||
const src = fs.readFileSync(path.join(rendererRoot, 'npcs/NpcsEditorApp.tsx'), 'utf8');
|
const src = fs.readFileSync(path.join(rendererRoot, 'npcs/NpcsEditorApp.tsx'), 'utf8');
|
||||||
assert.ok(src.includes('PlayerTokenView'));
|
assert.ok(src.includes('PlayerTokenView'));
|
||||||
|
|||||||
@@ -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'));
|
||||||
|
|||||||
@@ -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[] };
|
||||||
|
|||||||
@@ -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)));
|
||||||
|
});
|
||||||
@@ -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;
|
||||||
|
}
|
||||||
@@ -12,6 +12,7 @@ export type EffectToolType =
|
|||||||
| 'freeze'
|
| 'freeze'
|
||||||
| 'exploreBrush'
|
| 'exploreBrush'
|
||||||
| 'closeBrush'
|
| 'closeBrush'
|
||||||
|
| 'marker'
|
||||||
| 'eraser';
|
| 'eraser';
|
||||||
|
|
||||||
export type EffectInstanceType =
|
export type EffectInstanceType =
|
||||||
|
|||||||
@@ -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';
|
||||||
|
|||||||
@@ -0,0 +1,72 @@
|
|||||||
|
import assert from 'node:assert/strict';
|
||||||
|
import test from 'node:test';
|
||||||
|
|
||||||
|
import {
|
||||||
|
LEGEND_PANEL_DESIGN_WIDTH,
|
||||||
|
LEGEND_PANEL_REF_VIEW_W,
|
||||||
|
legendMarkerPositionInContainFrame,
|
||||||
|
legendMarkerPositionInOverlayHost,
|
||||||
|
legendMarkerPositionInViewport,
|
||||||
|
legendMarkerScreenScale,
|
||||||
|
legendPanelVisualLayout,
|
||||||
|
} from './materialLegend';
|
||||||
|
|
||||||
|
void test('legendMarkerPositionInContainFrame: letterboxed 16:9 in square frame', () => {
|
||||||
|
const p = legendMarkerPositionInContainFrame(0.5, 0.5, 400, 400, 1920, 1080);
|
||||||
|
assert.ok(Math.abs(p.left - 200) < 0.01);
|
||||||
|
assert.ok(Math.abs(p.top - 200) < 0.01);
|
||||||
|
});
|
||||||
|
|
||||||
|
void test('legendMarkerPositionInContainFrame: top-left UV at image corner', () => {
|
||||||
|
const p = legendMarkerPositionInContainFrame(0, 0, 400, 400, 1920, 1080);
|
||||||
|
const imageH = (400 * 1080) / 1920;
|
||||||
|
const imageY = (400 - imageH) / 2;
|
||||||
|
assert.ok(Math.abs(p.left - 0) < 0.01);
|
||||||
|
assert.ok(Math.abs(p.top - imageY) < 0.01);
|
||||||
|
});
|
||||||
|
|
||||||
|
void test('legendMarkerPositionInViewport: center marker at frame center', () => {
|
||||||
|
const p = legendMarkerPositionInViewport(0.5, 0.5, { cx: 0.5, cy: 0.5, rotationDeg: 0 }, 400, 300, 800, 600, 800, 600);
|
||||||
|
assert.ok(Math.abs(p.left - 400) < 0.01);
|
||||||
|
assert.ok(Math.abs(p.top - 300) < 0.01);
|
||||||
|
});
|
||||||
|
|
||||||
|
void test('legendMarkerPositionInOverlayHost: adds viewport offset', () => {
|
||||||
|
const p = legendMarkerPositionInOverlayHost(
|
||||||
|
0.5,
|
||||||
|
0.5,
|
||||||
|
{ cx: 0.5, cy: 0.5, rotationDeg: 0 },
|
||||||
|
400,
|
||||||
|
300,
|
||||||
|
800,
|
||||||
|
600,
|
||||||
|
{ x: 100, y: 50, w: 800, h: 600 },
|
||||||
|
);
|
||||||
|
assert.ok(Math.abs(p.left - 500) < 0.01);
|
||||||
|
assert.ok(Math.abs(p.top - 350) < 0.01);
|
||||||
|
});
|
||||||
|
|
||||||
|
void test('legendPanelVisualLayout: same cx/cy stays proportional across screen sizes', () => {
|
||||||
|
const layout = { cx: 0.82, cy: 0.5, scale: 0.85 };
|
||||||
|
const a = legendPanelVisualLayout(layout, { x: 10, y: 20, w: 800, h: 450 });
|
||||||
|
const b = legendPanelVisualLayout(layout, { x: 0, y: 0, w: 1920, h: 1080 });
|
||||||
|
assert.equal(a.width, LEGEND_PANEL_DESIGN_WIDTH);
|
||||||
|
assert.equal(b.width, LEGEND_PANEL_DESIGN_WIDTH);
|
||||||
|
assert.ok(Math.abs(a.left - (10 + 0.82 * 800)) < 0.01);
|
||||||
|
assert.ok(Math.abs(b.left - 0.82 * 1920) < 0.01);
|
||||||
|
assert.ok(Math.abs(a.scale / b.scale - 800 / LEGEND_PANEL_REF_VIEW_W) < 1e-9);
|
||||||
|
});
|
||||||
|
|
||||||
|
void test('legendPanelVisualLayout: size ignores viewport origin (no edge shrink)', () => {
|
||||||
|
const layout = { cx: 0.02, cy: 0.98, scale: 1 };
|
||||||
|
const near = legendPanelVisualLayout(layout, { x: 0, y: 0, w: 1920, h: 1080 });
|
||||||
|
const far = legendPanelVisualLayout({ cx: 0.5, cy: 0.5, scale: 1 }, { x: 0, y: 0, w: 1920, h: 1080 });
|
||||||
|
assert.equal(near.scale, far.scale);
|
||||||
|
assert.equal(near.width, far.width);
|
||||||
|
});
|
||||||
|
|
||||||
|
void test('legendMarkerScreenScale: proportional to player screen, ignores edge', () => {
|
||||||
|
assert.equal(legendMarkerScreenScale(1920), 1);
|
||||||
|
assert.ok(Math.abs(legendMarkerScreenScale(800) - 800 / 1920) < 1e-9);
|
||||||
|
assert.equal(legendMarkerScreenScale(800), legendMarkerScreenScale(800));
|
||||||
|
});
|
||||||
@@ -151,3 +151,115 @@ export function legendImageUvToHostPoint(
|
|||||||
y: contentRect.y + contentRect.h / 2 + (lx * sin + ly * cos),
|
y: contentRect.y + contentRect.h / 2 + (lx * sin + ly * cos),
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** UV маркера → px внутри кадра MaterialOverlay (object-fit: contain). */
|
||||||
|
export function legendMarkerPositionInContainFrame(
|
||||||
|
nx: number,
|
||||||
|
ny: number,
|
||||||
|
frameW: number,
|
||||||
|
frameH: number,
|
||||||
|
imageNaturalW: number,
|
||||||
|
imageNaturalH: number,
|
||||||
|
): { left: number; top: number } {
|
||||||
|
const bw = Math.max(1, frameW);
|
||||||
|
const bh = Math.max(1, frameH);
|
||||||
|
const aw = Math.max(1, imageNaturalW);
|
||||||
|
const ah = Math.max(1, imageNaturalH);
|
||||||
|
const boxAspect = bw / bh;
|
||||||
|
const targetAspect = aw / ah;
|
||||||
|
let ir: ContentRect;
|
||||||
|
if (boxAspect > targetAspect) {
|
||||||
|
const h = bh;
|
||||||
|
const w = h * targetAspect;
|
||||||
|
ir = { x: (bw - w) / 2, y: 0, w, h };
|
||||||
|
} else {
|
||||||
|
const w = bw;
|
||||||
|
const h = w / targetAspect;
|
||||||
|
ir = { x: 0, y: (bh - h) / 2, w, h };
|
||||||
|
}
|
||||||
|
return {
|
||||||
|
left: ir.x + clampLegendCoord(nx) * ir.w,
|
||||||
|
top: ir.y + clampLegendCoord(ny) * ir.h,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
/** UV маркера → px внутри viewport SceneOverlayHost (без смещения viewport.x/y). */
|
||||||
|
export function legendMarkerPositionInViewport(
|
||||||
|
nx: number,
|
||||||
|
ny: number,
|
||||||
|
layout: { cx: number; cy: number; rotationDeg: number },
|
||||||
|
frameW: number,
|
||||||
|
frameH: number,
|
||||||
|
imageNaturalW: number,
|
||||||
|
imageNaturalH: number,
|
||||||
|
viewportW: number,
|
||||||
|
viewportH: number,
|
||||||
|
): { left: number; top: number } {
|
||||||
|
const local = legendMarkerPositionInContainFrame(nx, ny, frameW, frameH, imageNaturalW, imageNaturalH);
|
||||||
|
const cx = layout.cx * viewportW;
|
||||||
|
const cy = layout.cy * viewportH;
|
||||||
|
const lx = local.left - frameW / 2;
|
||||||
|
const ly = local.top - frameH / 2;
|
||||||
|
const rad = (layout.rotationDeg * Math.PI) / 180;
|
||||||
|
const cos = Math.cos(rad);
|
||||||
|
const sin = Math.sin(rad);
|
||||||
|
return {
|
||||||
|
left: cx + lx * cos - ly * sin,
|
||||||
|
top: cy + lx * sin + ly * cos,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
/** UV маркера → px в координатах SceneOverlayHost (учитывает viewport offset и поворот кадра). */
|
||||||
|
export function legendMarkerPositionInOverlayHost(
|
||||||
|
nx: number,
|
||||||
|
ny: number,
|
||||||
|
layout: { cx: number; cy: number; rotationDeg: number },
|
||||||
|
frameW: number,
|
||||||
|
frameH: number,
|
||||||
|
imageNaturalW: number,
|
||||||
|
imageNaturalH: number,
|
||||||
|
viewport: ContentRect,
|
||||||
|
): { left: number; top: number } {
|
||||||
|
const p = legendMarkerPositionInViewport(
|
||||||
|
nx,
|
||||||
|
ny,
|
||||||
|
layout,
|
||||||
|
frameW,
|
||||||
|
frameH,
|
||||||
|
imageNaturalW,
|
||||||
|
imageNaturalH,
|
||||||
|
viewport.w,
|
||||||
|
viewport.h,
|
||||||
|
);
|
||||||
|
return { left: viewport.x + p.left, top: viewport.y + p.top };
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Ширина карточки легенды в design-px при scale=1 на экране 1920px. */
|
||||||
|
export const LEGEND_PANEL_DESIGN_WIDTH = 320;
|
||||||
|
export const LEGEND_PANEL_REF_VIEW_W = 1920;
|
||||||
|
/** Диаметр маркера на материале в design-px (как кружок в списке легенды). */
|
||||||
|
export const LEGEND_MARKER_DESIGN_SIZE = 26;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Позиция и единый масштаб карточки легенды.
|
||||||
|
* Размер не зависит от близости к краю — только от ширины экрана игроков и layout.scale.
|
||||||
|
*/
|
||||||
|
export function legendPanelVisualLayout(
|
||||||
|
layout: { cx: number; cy: number; scale: number },
|
||||||
|
viewport: ContentRect,
|
||||||
|
): { left: number; top: number; width: number; scale: number } {
|
||||||
|
const vw = Math.max(1, viewport.w);
|
||||||
|
const vh = Math.max(1, viewport.h);
|
||||||
|
const layoutScale = Number.isFinite(layout.scale) && layout.scale > 0 ? layout.scale : 1;
|
||||||
|
return {
|
||||||
|
left: viewport.x + layout.cx * vw,
|
||||||
|
top: viewport.y + layout.cy * vh,
|
||||||
|
width: LEGEND_PANEL_DESIGN_WIDTH,
|
||||||
|
scale: (vw / LEGEND_PANEL_REF_VIEW_W) * layoutScale,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Единый масштаб цифр на материале — как у карточки легенды, без сжатия у края. */
|
||||||
|
export function legendMarkerScreenScale(viewportW: number): number {
|
||||||
|
return Math.max(1, viewportW) / LEGEND_PANEL_REF_VIEW_W;
|
||||||
|
}
|
||||||
|
|||||||
@@ -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';
|
||||||
|
}
|
||||||
|
|||||||
@@ -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
@@ -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/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",
|
||||||
|
|||||||
Reference in New Issue
Block a user