From 02d73ddf8180f877a05ce2e9dc6c11cadcaeadf8 Mon Sep 17 00:00:00 2001 From: Ivan Fontosh Date: Fri, 24 Jul 2026 16:29:43 +0800 Subject: [PATCH] feat: scene traps, material legends, and scene editor window Add trap overlays with session state, material legend editor/panel, and a dedicated scene editor window wired through IPC and project persistence. Co-authored-by: Cursor --- app/main/foundry/foundryImport.ts | 1 + app/main/index.ts | 64 ++- app/main/materials/materialsOverlayStore.ts | 27 + app/main/project/zipStore.ts | 71 ++- app/main/sceneTraps/sceneTrapsStore.ts | 134 +++++ app/main/windows/createWindows.ts | 53 ++ app/renderer/control/ControlApp.tsx | 37 ++ app/renderer/editor/EditorApp.tsx | 18 + .../editor/MaterialLegendEditor.module.css | 137 +++++ app/renderer/editor/MaterialLegendEditor.tsx | 474 ++++++++++++++++++ app/renderer/editor/MaterialsBrowser.tsx | 55 +- .../editor/MaterialsModals.module.css | 17 +- app/renderer/editor/MaterialsModals.tsx | 6 +- app/renderer/editor/state/projectState.ts | 17 + app/renderer/presentation/PresentationApp.tsx | 5 + app/renderer/sceneEditor.html | 13 + .../sceneEditor/SceneEditorApp.module.css | 170 +++++++ app/renderer/sceneEditor/SceneEditorApp.tsx | 365 ++++++++++++++ app/renderer/sceneEditor/main.tsx | 20 + app/renderer/shared/PresentationView.tsx | 21 + .../shared/effects/SceneDarknessOverlay.tsx | 2 +- .../materials/MaterialLegendPanel.module.css | 59 +++ .../shared/materials/MaterialLegendPanel.tsx | 75 +++ .../materials/MaterialOverlay.module.css | 24 +- .../shared/materials/MaterialOverlay.tsx | 20 +- .../shared/traps/SceneTrapsOverlay.module.css | 100 ++++ .../shared/traps/SceneTrapsOverlay.tsx | 151 ++++++ app/renderer/shared/traps/TrapGlyph.tsx | 103 ++++ .../shared/traps/useSceneTrapsState.ts | 31 ++ app/shared/appBranding.ts | 2 + app/shared/graph/sceneListOrder.test.ts | 1 + .../graph/storylineExportImport.test.ts | 1 + app/shared/ipc/contracts.ts | 35 ++ app/shared/types/domain.ts | 6 + app/shared/types/index.ts | 2 + app/shared/types/materialLegend.ts | 153 ++++++ app/shared/types/materials.ts | 3 + app/shared/types/sceneTraps.ts | 125 +++++ vite.config.ts | 1 + 39 files changed, 2563 insertions(+), 36 deletions(-) create mode 100644 app/main/sceneTraps/sceneTrapsStore.ts create mode 100644 app/renderer/editor/MaterialLegendEditor.module.css create mode 100644 app/renderer/editor/MaterialLegendEditor.tsx create mode 100644 app/renderer/sceneEditor.html create mode 100644 app/renderer/sceneEditor/SceneEditorApp.module.css create mode 100644 app/renderer/sceneEditor/SceneEditorApp.tsx create mode 100644 app/renderer/sceneEditor/main.tsx create mode 100644 app/renderer/shared/materials/MaterialLegendPanel.module.css create mode 100644 app/renderer/shared/materials/MaterialLegendPanel.tsx create mode 100644 app/renderer/shared/traps/SceneTrapsOverlay.module.css create mode 100644 app/renderer/shared/traps/SceneTrapsOverlay.tsx create mode 100644 app/renderer/shared/traps/TrapGlyph.tsx create mode 100644 app/renderer/shared/traps/useSceneTrapsState.ts create mode 100644 app/shared/types/materialLegend.ts create mode 100644 app/shared/types/sceneTraps.ts diff --git a/app/main/foundry/foundryImport.ts b/app/main/foundry/foundryImport.ts index c87cb36..f9845b7 100644 --- a/app/main/foundry/foundryImport.ts +++ b/app/main/foundry/foundryImport.ts @@ -386,6 +386,7 @@ export async function buildProjectFromFoundryDocuments( previewVideoAutostart: previewAssetType === 'video', previewRotationDeg: 0, darkenScene: false, + traps: [], media: { videos: [], audios: audioRefs }, settings: { autoplayVideo: previewAssetType === 'video', diff --git a/app/main/index.ts b/app/main/index.ts index 17bc341..020d4d9 100644 --- a/app/main/index.ts +++ b/app/main/index.ts @@ -21,6 +21,7 @@ import type { Project } from '../shared/types'; import { EffectsStore } from './effects/effectsStore'; import { SceneDarknessStore } from './effects/sceneDarknessStore'; +import { SceneTrapsStore } from './sceneTraps/sceneTrapsStore'; import { installIpcRouter, registerHandler, setLicenseAssert } from './ipc/router'; import { LicenseService } from './license/licenseService'; import { MaterialsOverlayStore } from './materials/materialsOverlayStore'; @@ -50,10 +51,12 @@ import { openMaterialsWindow, openMultiWindow, openNpcsEditorWindow, + openSceneEditorWindow, openNpcsWindow, openSceneDescriptionWindow, closeMaterialsWindow, closeNpcsEditorWindow, + closeSceneEditorWindow, closeNpcsWindow, sendToAppWindows, togglePresentationFullscreen, @@ -147,6 +150,7 @@ function installAppMenuForSession(): void { const effectsStore = new EffectsStore(); const sceneDarknessStore = new SceneDarknessStore(); +const sceneTrapsStore = new SceneTrapsStore(); const sceneViewStore = new SceneViewStore(); const videoStore = new VideoPlaybackStore(); const materialsOverlayStore = new MaterialsOverlayStore(); @@ -212,6 +216,20 @@ function syncSceneDarknessForProject(project: Project): void { sceneDarknessStore.switchScene(cacheKey, enabled); } +function emitSceneTrapsState(): void { + const state = sceneTrapsStore.getState(); + for (const win of BrowserWindow.getAllWindows()) { + win.webContents.send(ipcChannels.sceneTraps.stateChanged, { state }); + } +} + +function syncSceneTrapsForProject(project: Project): void { + const cacheKey = project.currentGraphNodeId ?? project.currentSceneId ?? null; + const scene = project.currentSceneId ? project.scenes[project.currentSceneId] : undefined; + const trapIds = (scene?.traps ?? []).map((t) => t.id); + sceneTrapsStore.switchScene(cacheKey, trapIds); +} + function emitVideoState(): void { const state = videoStore.getState(); for (const win of BrowserWindow.getAllWindows()) { @@ -361,8 +379,12 @@ async function main() { sceneDarknessStore.resetSession(); openMultiWindow(); const project = projectStore.getOpenProject(); - if (project) syncSceneDarknessForProject(project); + if (project) { + syncSceneDarknessForProject(project); + syncSceneTrapsForProject(project); + } emitSceneDarknessState(); + emitSceneTrapsState(); return { ok: true }; }); registerHandler(ipcChannels.windows.closeMultiWindow, () => { @@ -403,6 +425,14 @@ async function main() { closeNpcsEditorWindow(); return { ok: true }; }); + registerHandler(ipcChannels.windows.openSceneEditor, () => { + openSceneEditorWindow(); + return { ok: true }; + }); + registerHandler(ipcChannels.windows.closeSceneEditor, () => { + closeSceneEditorWindow(); + return { ok: true }; + }); registerHandler(ipcChannels.windows.openNpcs, () => { openNpcsWindow(); return { ok: true }; @@ -458,11 +488,13 @@ async function main() { materialsOverlayStore.clear(); npcsOverlayStore.clear(); sceneDarknessStore.resetSession(); + sceneTrapsStore.resetSession(); sceneViewStore.reset(); emitEffectsState(); emitMaterialsOverlayState(); emitNpcsOverlayState(); emitSceneDarknessState(); + emitSceneTrapsState(); emitSceneViewState(); emitSessionState(); return { ok: true }; @@ -481,11 +513,15 @@ async function main() { npcsOverlayStore.clear(); sceneViewStore.reset(); const project = projectStore.getOpenProject(); - if (project) syncSceneDarknessForProject(project); + if (project) { + syncSceneDarknessForProject(project); + syncSceneTrapsForProject(project); + } emitEffectsState(); emitMaterialsOverlayState(); emitNpcsOverlayState(); emitSceneDarknessState(); + emitSceneTrapsState(); emitSceneViewState(); emitSessionState(); return { currentSceneId: projectStore.getOpenProject()?.currentSceneId ?? null }; @@ -504,11 +540,15 @@ async function main() { npcsOverlayStore.clear(); sceneViewStore.reset(); const project = projectStore.getOpenProject(); - if (project) syncSceneDarknessForProject(project); + if (project) { + syncSceneDarknessForProject(project); + syncSceneTrapsForProject(project); + } emitEffectsState(); emitMaterialsOverlayState(); emitNpcsOverlayState(); emitSceneDarknessState(); + emitSceneTrapsState(); emitSceneViewState(); emitSessionState(); const p = projectStore.getOpenProject(); @@ -524,6 +564,10 @@ async function main() { syncSceneDarknessForProject(project); emitSceneDarknessState(); } + if (project && project.currentSceneId === sceneId && patch.traps !== undefined) { + syncSceneTrapsForProject(project); + emitSceneTrapsState(); + } emitSessionState(); return { scene: next }; }); @@ -627,6 +671,11 @@ async function main() { emitSessionState(); return { project }; }); + registerHandler(ipcChannels.project.setMaterialLegend, async ({ materialId, legend }) => { + const project = await projectStore.setMaterialLegend(materialId, legend); + emitSessionState(); + return { project }; + }); registerHandler(ipcChannels.project.pickMaterialImage, async () => { const { canceled, filePaths } = await dialog.showOpenDialog({ properties: ['openFile'], @@ -1099,6 +1148,15 @@ async function main() { return { ok: true }; }); + registerHandler(ipcChannels.sceneTraps.getState, () => { + return { state: sceneTrapsStore.getState() }; + }); + registerHandler(ipcChannels.sceneTraps.dispatch, ({ event }) => { + sceneTrapsStore.dispatch(event); + emitSceneTrapsState(); + return { ok: true }; + }); + registerHandler(ipcChannels.sceneView.getState, () => { return { state: sceneViewStore.getState() }; }); diff --git a/app/main/materials/materialsOverlayStore.ts b/app/main/materials/materialsOverlayStore.ts index 8221b7d..8f242e3 100644 --- a/app/main/materials/materialsOverlayStore.ts +++ b/app/main/materials/materialsOverlayStore.ts @@ -14,6 +14,7 @@ function emptyState(): MaterialsOverlayState { revision: 1, activeMaterialId: null, layout: { ...DEFAULT_MATERIALS_OVERLAY_LAYOUT }, + legendLayout: { ...DEFAULT_MATERIALS_OVERLAY_LAYOUT, cx: 0.82, cy: 0.5, scale: 0.85 }, zoomTool: null, }; } @@ -25,6 +26,16 @@ function initialLayout(rotationDeg?: number): MaterialsOverlayLayout { }); } +function initialLegendLayout(): MaterialsOverlayLayout { + return clampMaterialsLayout({ + ...DEFAULT_MATERIALS_OVERLAY_LAYOUT, + cx: 0.82, + cy: 0.5, + scale: 0.85, + rotationDeg: 0, + }); +} + export class MaterialsOverlayStore { private state: MaterialsOverlayState = emptyState(); @@ -40,6 +51,7 @@ export class MaterialsOverlayStore { revision: this.state.revision + 1, activeMaterialId: null, layout: { ...DEFAULT_MATERIALS_OVERLAY_LAYOUT }, + legendLayout: initialLegendLayout(), zoomTool: null, }; return this.state; @@ -54,6 +66,7 @@ export class MaterialsOverlayStore { revision: this.state.revision + 1, activeMaterialId: event.materialId, layout: initialLayout(event.rotationDeg), + legendLayout: initialLegendLayout(), zoomTool: this.state.zoomTool, }; return this.state; @@ -65,6 +78,7 @@ export class MaterialsOverlayStore { revision: this.state.revision + 1, activeMaterialId: event.materialId, layout: initialLayout(event.rotationDeg), + legendLayout: initialLegendLayout(), zoomTool: this.state.zoomTool, }; return this.state; @@ -81,6 +95,19 @@ export class MaterialsOverlayStore { }; return this.state; } + case 'legendLayout.set': { + if (this.state.activeMaterialId === null) return this.state; + this.state = { + ...this.state, + revision: this.state.revision + 1, + legendLayout: clampMaterialsLayout({ + ...this.state.legendLayout, + ...event.layout, + rotationDeg: 0, + }), + }; + return this.state; + } case 'zoomTool.set': { const tool: MaterialsZoomTool = event.tool; this.state = { diff --git a/app/main/project/zipStore.ts b/app/main/project/zipStore.ts index 13d276b..e2910f3 100644 --- a/app/main/project/zipStore.ts +++ b/app/main/project/zipStore.ts @@ -34,6 +34,7 @@ import { stripProjectZipExtension, } from '../../shared/project/projectZipExtension'; import type { + MaterialLegend, MediaAsset, MediaAssetType, NpcBinding, @@ -46,8 +47,11 @@ import type { SceneGraphEdge, SceneGraphNode, SceneId, + SceneTrap, } from '../../shared/types'; import { PROJECT_SCHEMA_VERSION } from '../../shared/types'; +import { normalizeMaterialLegend } from '../../shared/types/materialLegend'; +import { normalizeSceneTrap } from '../../shared/types/sceneTraps'; import type { AssetId, GraphNodeId, MaterialId, NpcGroupId, NpcId, NpcRelationId } from '../../shared/types/ids'; import { asAssetId, @@ -611,10 +615,12 @@ export class ZipProjectStore { previewVideoAutostart: false, previewRotationDeg: 0, darkenScene: false, + traps: [], } satisfies Scene); const next: Scene = { ...base, + traps: base.traps ?? [], ...(patch.title !== undefined ? { title: patch.title } : null), ...(patch.description !== undefined ? { description: patch.description } : null), ...(patch.previewAssetId !== undefined ? { previewAssetId: patch.previewAssetId } : null), @@ -627,6 +633,13 @@ export class ZipProjectStore { : null), ...(patch.previewRotationDeg !== undefined ? { previewRotationDeg: patch.previewRotationDeg } : null), ...(patch.darkenScene !== undefined ? { darkenScene: patch.darkenScene } : null), + ...(patch.traps !== undefined + ? { + traps: patch.traps + .map((t) => normalizeSceneTrap(t)) + .filter((t): t is SceneTrap => Boolean(t)), + } + : null), ...(patch.settings ? { settings: { ...base.settings, ...patch.settings } } : null), ...(patch.media ? { media: { ...base.media, ...patch.media } } : null), ...(patch.layout ? { layout: { ...base.layout, ...patch.layout } } : null), @@ -1163,6 +1176,7 @@ export class ZipProjectStore { name, assetId, rotationDeg: prev.rotationDeg ?? 0, + ...(prev.legend ? { legend: prev.legend } : {}), }; } else { if (!nextAssetId) throw new Error('Material image is required'); @@ -1198,6 +1212,30 @@ export class ZipProjectStore { return latest; } + async setMaterialLegend(materialId: MaterialId, legend: MaterialLegend | null): Promise { + const open = this.openProject; + if (!open) throw new Error('No open project'); + const normalized = legend ? normalizeMaterialLegend(legend) : undefined; + await this.updateProject((p) => { + const materials = (p.materials ?? []).map((m) => { + if (m.id !== materialId) return m; + if ( + !normalized || + (!normalized.enabled && normalized.items.length === 0 && normalized.markers.length === 0) + ) { + const { legend: _drop, ...rest } = m; + void _drop; + return rest; + } + return { ...m, legend: normalized }; + }); + return { ...p, materials }; + }); + const latest = this.getOpenProject(); + if (!latest) throw new Error('No open project'); + return latest; + } + async deleteMaterial(materialId: MaterialId): Promise { const open = this.openProject; if (!open) throw new Error('No open project'); @@ -2270,6 +2308,10 @@ function normalizeScene(s: Scene): Scene { const previewThumbAssetId = (s as unknown as { previewThumbAssetId?: AssetId | null }).previewThumbAssetId ?? null; const darkenScene = Boolean((s as unknown as { darkenScene?: boolean }).darkenScene); + const rawTraps = (s as unknown as { traps?: unknown[] }).traps; + const traps = (Array.isArray(rawTraps) ? rawTraps : []) + .map((t) => normalizeSceneTrap(t)) + .filter((t): t is SceneTrap => Boolean(t)); const rawAudios = Array.isArray(raw.audios) ? raw.audios : []; const audios = rawAudios @@ -2298,6 +2340,7 @@ function normalizeScene(s: Scene): Scene { previewVideoAutostart, previewRotationDeg, darkenScene, + traps, layout: layoutIn ?? { x: 0, y: 0 }, media: { videos: raw.videos ?? [], @@ -2342,17 +2385,37 @@ function normalizeProject(p: Project): Project { const materials = (Array.isArray(rawMaterials) ? rawMaterials : []) .map((m) => { if (!m || typeof m !== 'object') return null; - const obj = m as { id?: string; name?: string; assetId?: AssetId; rotationDeg?: number }; + const obj = m as { + id?: string; + name?: string; + assetId?: AssetId; + rotationDeg?: number; + legend?: unknown; + }; if (!obj.id || !obj.assetId || typeof obj.name !== 'string') return null; const name = obj.name.trim(); if (!name) return null; const rot = obj.rotationDeg; const rotationDeg: 0 | 90 | 180 | 270 = rot === 90 || rot === 180 || rot === 270 ? rot : 0; - return { id: asMaterialId(String(obj.id)), name, assetId: obj.assetId, rotationDeg }; + const legend = normalizeMaterialLegend(obj.legend); + return { + id: asMaterialId(String(obj.id)), + name, + assetId: obj.assetId, + rotationDeg, + ...(legend ? { legend } : {}), + }; }) .filter( - (x): x is { id: MaterialId; name: string; assetId: AssetId; rotationDeg: 0 | 90 | 180 | 270 } => - Boolean(x), + ( + x, + ): x is { + id: MaterialId; + name: string; + assetId: AssetId; + rotationDeg: 0 | 90 | 180 | 270; + legend?: MaterialLegend; + } => Boolean(x), ); const npcGroups = normalizeNpcGroups((p as unknown as { npcGroups?: unknown }).npcGroups); const groupIdSet = new Set(npcGroups.map((g) => g.id)); diff --git a/app/main/sceneTraps/sceneTrapsStore.ts b/app/main/sceneTraps/sceneTrapsStore.ts new file mode 100644 index 0000000..31e8cb4 --- /dev/null +++ b/app/main/sceneTraps/sceneTrapsStore.ts @@ -0,0 +1,134 @@ +import { + defaultTrapRuntime, + type SceneTrapsEvent, + type SceneTrapsState, + type SceneTrapRuntime, +} from '../../shared/types'; + +function emptyState(): SceneTrapsState { + return { + revision: 1, + cacheKey: null, + byId: {}, + lastActivation: null, + }; +} + +function cloneById(byId: Record): Record { + const out: Record = {}; + for (const [k, v] of Object.entries(byId)) { + out[k] = { ...v }; + } + return out; +} + +export class SceneTrapsStore { + private state: SceneTrapsState = emptyState(); + /** Кэш runtime по ключу сцены/ноды на время сессии показа. */ + private cache = new Map>(); + private currentKey: string | null = null; + private activationToken = 0; + + getState(): SceneTrapsState { + return { + ...this.state, + byId: cloneById(this.state.byId), + lastActivation: this.state.lastActivation ? { ...this.state.lastActivation } : null, + }; + } + + resetSession(): void { + this.cache.clear(); + this.currentKey = null; + this.state = emptyState(); + } + + /** + * Переключение сцены: сохраняем runtime в кэш и подгружаем/инициализируем для новых trapIds. + */ + switchScene(cacheKey: string | null, trapIds: readonly string[]): SceneTrapsState { + if (this.currentKey !== null) { + this.cache.set(this.currentKey, cloneById(this.state.byId)); + } + this.currentKey = cacheKey; + const cached = cacheKey ? this.cache.get(cacheKey) : undefined; + const byId: Record = {}; + for (const id of trapIds) { + byId[id] = cached?.[id] ? { ...cached[id]! } : defaultTrapRuntime(); + } + if (cacheKey) { + this.cache.set(cacheKey, cloneById(byId)); + } + this.state = { + revision: this.state.revision + 1, + cacheKey, + byId, + lastActivation: null, + }; + return this.getState(); + } + + dispatch(event: SceneTrapsEvent): SceneTrapsState { + switch (event.kind) { + case 'syncTrapIds': { + const byId: Record = {}; + for (const id of event.trapIds) { + byId[id] = this.state.byId[id] ? { ...this.state.byId[id]! } : defaultTrapRuntime(); + } + if (this.currentKey) this.cache.set(this.currentKey, cloneById(byId)); + this.state = { + ...this.state, + revision: this.state.revision + 1, + byId, + }; + return this.getState(); + } + case 'reveal': { + const cur = this.state.byId[event.trapId]; + if (!cur) return this.getState(); + const byId = cloneById(this.state.byId); + byId[event.trapId] = { ...cur, revealed: true }; + if (this.currentKey) this.cache.set(this.currentKey, cloneById(byId)); + this.state = { + ...this.state, + revision: this.state.revision + 1, + byId, + }; + return this.getState(); + } + case 'activate': { + const cur = this.state.byId[event.trapId]; + if (!cur) return this.getState(); + const byId = cloneById(this.state.byId); + byId[event.trapId] = { status: 'active', revealed: true }; + this.activationToken += 1; + if (this.currentKey) this.cache.set(this.currentKey, cloneById(byId)); + this.state = { + ...this.state, + revision: this.state.revision + 1, + byId, + lastActivation: { trapId: event.trapId, token: this.activationToken }, + }; + return this.getState(); + } + case 'disarm': { + const cur = this.state.byId[event.trapId]; + if (!cur) return this.getState(); + const byId = cloneById(this.state.byId); + byId[event.trapId] = { status: 'disarmed', revealed: true }; + if (this.currentKey) this.cache.set(this.currentKey, cloneById(byId)); + this.state = { + ...this.state, + revision: this.state.revision + 1, + byId, + }; + return this.getState(); + } + default: { + const _x: never = event; + void _x; + return this.getState(); + } + } + } +} diff --git a/app/main/windows/createWindows.ts b/app/main/windows/createWindows.ts index 03787d6..2b8acaa 100644 --- a/app/main/windows/createWindows.ts +++ b/app/main/windows/createWindows.ts @@ -17,6 +17,7 @@ export type WindowKind = | 'sceneDescription' | 'materials' | 'npcsEditor' + | 'sceneEditor' | 'npcs'; /** Окна, которые реально слушают session.stateChanged (редактор синхронизируется через invoke). */ @@ -26,6 +27,7 @@ export const SESSION_STATE_WINDOW_KINDS: readonly WindowKind[] = [ 'materials', 'npcs', 'npcsEditor', + 'sceneEditor', ] as const; const windows = new Map(); @@ -122,6 +124,8 @@ function pageNameForKind(kind: WindowKind): string { return 'materials.html'; case 'npcsEditor': return 'npcsEditor.html'; + case 'sceneEditor': + return 'sceneEditor.html'; case 'npcs': return 'npcs.html'; } @@ -192,6 +196,7 @@ function windowSizeForKind(kind: WindowKind): { width: number; height: number } if (kind === 'sceneDescription') return { width: 720, height: 640 }; if (kind === 'materials') return { width: MATERIALS_WINDOW_WIDTH, height: MATERIALS_WINDOW_HEIGHT }; if (kind === 'npcsEditor') return { width: NPCS_EDITOR_WINDOW_WIDTH, height: NPCS_EDITOR_WINDOW_HEIGHT }; + if (kind === 'sceneEditor') return { width: 1280, height: 800 }; if (kind === 'npcs') return { width: NPCS_WINDOW_WIDTH, height: NPCS_WINDOW_HEIGHT }; return { width: 1280, height: 800 }; } @@ -229,6 +234,15 @@ function createWindow(kind: WindowKind, opts?: CreateWindowOpts): BrowserWindow autoHideMenuBar: true, } : {}), + ...(kind === 'sceneEditor' + ? { + width: 1280, + height: 800, + minWidth: 960, + minHeight: 600, + autoHideMenuBar: true, + } + : {}), ...(kind === 'npcs' ? { width: NPCS_WINDOW_WIDTH, @@ -267,6 +281,7 @@ function createWindow(kind: WindowKind, opts?: CreateWindowOpts): BrowserWindow kind === 'sceneDescription' || kind === 'materials' || kind === 'npcsEditor' || + kind === 'sceneEditor' || kind === 'npcs' ) { win.setMenuBarVisibility(false); @@ -421,6 +436,13 @@ export function closeNpcsEditorWindow(): void { } } +export function closeSceneEditorWindow(): void { + const win = windows.get('sceneEditor'); + if (win && !win.isDestroyed()) { + win.close(); + } +} + export function closeNpcsWindow(): void { const win = windows.get('npcs'); if (win && !win.isDestroyed()) { @@ -536,6 +558,37 @@ export function openNpcsEditorWindow(): void { }); } +/** Редактор сцены: расстановка ловушек и легенды материалов. */ +export function openSceneEditorWindow(): void { + const existing = windows.get('sceneEditor'); + if (existing && !existing.isDestroyed()) { + if (existing.isMinimized()) existing.restore(); + existing.show(); + existing.focus(); + existing.moveTop(); + return; + } + + const parent = windows.get('editor'); + const win = createWindow('sceneEditor', parent ? { parent } : undefined); + const { width, height } = win.getBounds(); + const display = screen.getDisplayMatching(parent?.getBounds() ?? win.getBounds()); + const { x, y, width: dw, height: dh } = display.workArea; + win.setBounds({ + x: Math.round(x + Math.max(0, (dw - width) / 2)), + y: Math.round(y + Math.max(0, (dh - height) / 2)), + width, + height, + }); + win.webContents.once('did-finish-load', () => { + if (!win.isDestroyed()) { + win.show(); + win.focus(); + win.moveTop(); + } + }); +} + /** Пульт НПС: список + описание выбранного; оверлей аватара на сцене. */ export function openNpcsWindow(): void { const existing = windows.get('npcs'); diff --git a/app/renderer/control/ControlApp.tsx b/app/renderer/control/ControlApp.tsx index 412c13d..68a4c68 100644 --- a/app/renderer/control/ControlApp.tsx +++ b/app/renderer/control/ControlApp.tsx @@ -27,12 +27,15 @@ import { SceneDarknessOverlay } from '../shared/effects/SceneDarknessOverlay'; import { useEffectsState } from '../shared/effects/useEffectsState'; import { useSceneDarknessState } from '../shared/effects/useSceneDarknessState'; import { DEFAULT_MATERIALS_OVERLAY_LAYOUT, DEFAULT_NPCS_OVERLAY_LAYOUT } from '../../shared/types'; +import { MaterialLegendPanel } from '../shared/materials/MaterialLegendPanel'; import { MaterialOverlay } from '../shared/materials/MaterialOverlay'; import { useMaterialsOverlayState } from '../shared/materials/useMaterialsOverlayState'; import { NpcsSceneOverlay } from '../shared/npcs/NpcsSceneOverlay'; import { useNpcsOverlayState } from '../shared/npcs/useNpcsOverlayState'; import { SceneOverlayHost } from '../shared/sceneOverlay/SceneOverlayHost'; import { useSceneViewState } from '../shared/sceneView/useSceneViewState'; +import { SceneTrapsOverlay } from '../shared/traps/SceneTrapsOverlay'; +import { useSceneTrapsState } from '../shared/traps/useSceneTrapsState'; import { Button } from '../shared/ui/controls'; import { Surface } from '../shared/ui/Surface'; import { useAssetUrl } from '../shared/useAssetImageUrl'; @@ -119,6 +122,7 @@ export function ControlApp() { const [fxState, fx] = useEffectsState(); const [effectsSfxGainUi, setEffectsSfxGainUi] = useState(() => getEffectsSfxGain()); const [sdState, sd] = useSceneDarknessState(); + const [sceneTraps, sceneTrapsApi] = useSceneTrapsState(); const [sceneView, sceneViewApi] = useSceneViewState(); const [sceneViewDraft, setSceneViewDraft] = useState(null); const [materialsOverlay, materialsApi] = useMaterialsOverlayState(); @@ -1818,6 +1822,17 @@ export function ControlApp() { clearDraftFromPixi(); }} /> + {previewContentRect ? ( + void sceneTrapsApi.dispatch({ kind: 'reveal', trapId })} + onActivate={(trapId) => void sceneTrapsApi.dispatch({ kind: 'activate', trapId })} + onDisarm={(trapId) => void sceneTrapsApi.dispatch({ kind: 'disarm', trapId })} + /> + ) : null} ) : null} {(() => { @@ -1893,6 +1908,28 @@ export function ControlApp() { onLayoutChange={(layout) => { void materialsApi.dispatch({ kind: 'layout.set', layout }); }} + legendMarkers={ + activeMaterial.legend?.enabled + ? (activeMaterial.legend.markers ?? []) + : undefined + } + /> + ) : null} + {showMaterial && activeMaterial?.legend?.enabled ? ( + { + void materialsApi.dispatch({ kind: 'legendLayout.set', layout }); + }} /> ) : null} {showNpcs ? ( diff --git a/app/renderer/editor/EditorApp.tsx b/app/renderer/editor/EditorApp.tsx index a59dc74..93dc8ea 100644 --- a/app/renderer/editor/EditorApp.tsx +++ b/app/renderer/editor/EditorApp.tsx @@ -1564,6 +1564,16 @@ export function EditorApp() { }); }); }} + onLegendChange={async (materialId, legend) => { + try { + await actions.setMaterialLegend(materialId, legend); + } catch (e) { + setAppNotice({ + title: t('common.error'), + message: e instanceof Error ? e.message : String(e), + }); + } + }} /> {t('scene.darkenScene')} +
+ ) : null}
diff --git a/app/renderer/editor/MaterialLegendEditor.module.css b/app/renderer/editor/MaterialLegendEditor.module.css new file mode 100644 index 0000000..733208e --- /dev/null +++ b/app/renderer/editor/MaterialLegendEditor.module.css @@ -0,0 +1,137 @@ +.legendBlock { + display: grid; + gap: 10px; + min-width: 0; +} + +.legendBlockLarge { + gap: 12px; +} + +.row { + display: flex; + align-items: center; + gap: 8px; + flex-wrap: wrap; +} + +.hint { + font-size: 12px; + opacity: 0.7; + line-height: 1.35; +} + +.map { + position: relative; + width: 100%; + max-height: 280px; + overflow: hidden; + border-radius: 10px; + border: 1px solid var(--stroke, #333); + background: #0a0b0e; +} + +.mapLarge { + max-height: none; + height: min(48vh, 440px); + min-height: 260px; + flex: 0 0 auto; + touch-action: none; + cursor: grab; +} + +.mapIdle { + display: grid; + place-items: center; +} + +.mapEmpty { + color: var(--text2); + font-size: 13px; +} + +.mapImg { + display: block; + width: 100%; + height: 100%; + max-height: inherit; + object-fit: contain; + user-select: none; + pointer-events: none; + margin: 0 auto; +} + +.marker { + position: absolute; + transform: translate(-50%, -50%); + width: 24px; + height: 24px; + border-radius: 50%; + background: #1e3a5f; + border: 2px solid #f5c542; + color: #fff; + font-size: 11px; + font-weight: 800; + display: grid; + place-items: center; + cursor: grab; + user-select: none; + z-index: 2; + touch-action: none; +} + +.mapHint { + position: absolute; + left: 12px; + bottom: 12px; + z-index: 3; + font-size: 12px; + padding: 6px 10px; + border-radius: 8px; + background: rgba(0, 0, 0, 0.65); + color: rgba(255, 255, 255, 0.85); + pointer-events: none; +} + +.mapHintZoom { + position: absolute; + right: 12px; + bottom: 12px; + z-index: 3; + font-size: 11px; + padding: 5px 8px; + border-radius: 8px; + background: rgba(0, 0, 0, 0.55); + color: rgba(255, 255, 255, 0.75); + pointer-events: none; +} + +.itemRow { + display: grid; + grid-template-columns: 36px 1fr auto; + gap: 8px; + align-items: center; + padding: 6px 8px; + border-radius: 8px; + border: 1px solid transparent; + cursor: pointer; +} + +.itemRowActive { + border-color: color-mix(in srgb, var(--color-accent, #c9a227) 70%, transparent); + background: color-mix(in srgb, var(--color-accent, #c9a227) 12%, transparent); +} + +.num { + font-weight: 800; + text-align: center; +} + +.itemDrag { + cursor: pointer; + opacity: 0.7; + font-size: 12px; + border: none; + background: transparent; + color: inherit; +} diff --git a/app/renderer/editor/MaterialLegendEditor.tsx b/app/renderer/editor/MaterialLegendEditor.tsx new file mode 100644 index 0000000..733db51 --- /dev/null +++ b/app/renderer/editor/MaterialLegendEditor.tsx @@ -0,0 +1,474 @@ +import React, { useEffect, useMemo, useRef, useState } from 'react'; + +import type { MaterialLegend, MaterialLegendItem, MaterialLegendMarker } from '../../shared/types'; +import { + asMaterialLegendItemId, + asMaterialLegendMarkerId, + EMPTY_MATERIAL_LEGEND, + hostPointToLegendImageUv, + legendImageUvToHostPoint, + nextLegendNumber, +} from '../../shared/types/materialLegend'; +import { + DEFAULT_SCENE_VIEW_CAMERA, + sceneViewPanBy, + sceneViewZoomAt, + type SceneViewCamera, +} from '../../shared/types/sceneView'; +import { RotatedImage } from '../shared/RotatedImage'; +import { Button, Input } from '../shared/ui/controls'; + +import styles from './MaterialLegendEditor.module.css'; + +type Props = { + legend: MaterialLegend | undefined; + previewUrl: string | null; + rotationDeg?: 0 | 90 | 180 | 270; + /** Крупная карта (окно «Материалы»). */ + largeMap?: boolean; + onChange: (legend: MaterialLegend) => void; +}; + +type DragMode = + | { kind: 'pan'; lastX: number; lastY: number } + | { kind: 'marker'; id: string } + | null; + +const PERSIST_DEBOUNCE_MS = 180; + +function rid(prefix: string): string { + return `${prefix}_${Math.random().toString(36).slice(2, 10)}`; +} + +function cloneLegend(legend: MaterialLegend | undefined): MaterialLegend { + const base = legend ?? EMPTY_MATERIAL_LEGEND; + return { + enabled: base.enabled, + items: base.items.map((it) => ({ ...it })), + markers: base.markers.map((m) => ({ ...m })), + }; +} + +export function MaterialLegendEditor({ + legend, + previewUrl, + rotationDeg = 0, + largeMap = false, + onChange, +}: Props) { + const [draft, setDraft] = useState(() => cloneLegend(legend)); + const [activeItemId, setActiveItemId] = useState(null); + const [view, setView] = useState(DEFAULT_SCENE_VIEW_CAMERA); + const [contentRect, setContentRect] = useState<{ x: number; y: number; w: number; h: number } | null>( + null, + ); + + const mapRef = useRef(null); + const dragRef = useRef(null); + const skipMapClickRef = useRef(false); + const spaceDownRef = useRef(false); + const draftRef = useRef(draft); + draftRef.current = draft; + const onChangeRef = useRef(onChange); + onChangeRef.current = onChange; + const saveTimerRef = useRef(0); + const dirtyRef = useRef(false); + + const flushPersist = () => { + if (saveTimerRef.current) { + window.clearTimeout(saveTimerRef.current); + saveTimerRef.current = 0; + } + if (!dirtyRef.current) return; + dirtyRef.current = false; + onChangeRef.current(draftRef.current); + }; + + const schedulePersist = () => { + dirtyRef.current = true; + if (saveTimerRef.current) window.clearTimeout(saveTimerRef.current); + saveTimerRef.current = window.setTimeout(() => { + saveTimerRef.current = 0; + dirtyRef.current = false; + onChangeRef.current(draftRef.current); + }, PERSIST_DEBOUNCE_MS); + }; + + const applyDraft = (next: MaterialLegend) => { + draftRef.current = next; + setDraft(next); + schedulePersist(); + }; + + useEffect(() => { + return () => { + if (saveTimerRef.current) window.clearTimeout(saveTimerRef.current); + if (dirtyRef.current) onChangeRef.current(draftRef.current); + }; + }, []); + + useEffect(() => { + flushPersist(); + setDraft(cloneLegend(legend)); + dirtyRef.current = false; + setActiveItemId(null); + setView(DEFAULT_SCENE_VIEW_CAMERA); + // eslint-disable-next-line react-hooks/exhaustive-deps -- reset only when switching material image + }, [previewUrl]); + + useEffect(() => { + if (activeItemId && !draft.items.some((i) => i.id === activeItemId)) { + setActiveItemId(null); + } + }, [activeItemId, draft.items]); + + useEffect(() => { + if (!largeMap) return; + const onKeyDown = (e: KeyboardEvent) => { + if (e.code === 'Space') spaceDownRef.current = true; + }; + const onKeyUp = (e: KeyboardEvent) => { + if (e.code === 'Space') spaceDownRef.current = false; + }; + window.addEventListener('keydown', onKeyDown); + window.addEventListener('keyup', onKeyUp); + return () => { + window.removeEventListener('keydown', onKeyDown); + window.removeEventListener('keyup', onKeyUp); + }; + }, [largeMap]); + + useEffect(() => { + if (!largeMap) return; + const host = mapRef.current; + if (!host) return; + const nativeWheel = (e: WheelEvent) => { + e.preventDefault(); + const factor = e.deltaY < 0 ? 1.12 : 1 / 1.12; + const cr = contentRect; + if (!cr) { + setView((v) => { + const nextScale = Math.max(1, Math.min(8, v.scale * factor)); + if (nextScale <= 1.001) return { ...DEFAULT_SCENE_VIEW_CAMERA }; + return { ...v, scale: nextScale }; + }); + return; + } + const r = host.getBoundingClientRect(); + setView((v) => { + const containW = cr.w / Math.max(1e-6, v.scale); + const containH = cr.h / Math.max(1e-6, v.scale); + return sceneViewZoomAt(v, { + hostW: r.width, + hostH: r.height, + containW, + containH, + hostX: e.clientX - r.left, + hostY: e.clientY - r.top, + factor, + }); + }); + }; + host.addEventListener('wheel', nativeWheel, { passive: false }); + return () => host.removeEventListener('wheel', nativeWheel); + }, [contentRect, largeMap, previewUrl]); + + const activeItem = useMemo( + () => (activeItemId ? (draft.items.find((i) => i.id === activeItemId) ?? null) : null), + [activeItemId, draft.items], + ); + + const setEnabled = (enabled: boolean) => { + applyDraft({ ...draftRef.current, enabled }); + }; + + const updateItems = (items: MaterialLegendItem[]) => { + applyDraft({ ...draftRef.current, enabled: true, items }); + }; + + const updateMarkers = (markers: MaterialLegendMarker[]) => { + applyDraft({ ...draftRef.current, enabled: true, markers }); + }; + + const addItem = () => { + const cur = draftRef.current; + const number = nextLegendNumber(cur.items); + const item: MaterialLegendItem = { + id: asMaterialLegendItemId(rid('li')), + number, + text: '', + }; + updateItems([...cur.items, item]); + setActiveItemId(item.id); + }; + + const placeMarker = (nx: number, ny: number, number: number) => { + updateMarkers([ + ...draftRef.current.markers, + { + id: asMaterialLegendMarkerId(rid('lm')), + number, + nx, + ny, + }, + ]); + }; + + const toNorm = (clientX: number, clientY: number): { x: number; y: number } | null => { + const el = mapRef.current; + if (!el) return null; + const r = el.getBoundingClientRect(); + // UV неповёрнутого изображения — тот же space, что у MaterialOverlay на пульте/презентации. + if (largeMap && contentRect && contentRect.w > 1 && contentRect.h > 1) { + return hostPointToLegendImageUv( + clientX - r.left, + clientY - r.top, + contentRect, + rotationDeg, + ); + } + const img = el.querySelector('img'); + if (!img) return null; + const ir = img.getBoundingClientRect(); + if (ir.width < 1 || ir.height < 1) return null; + return { + x: Math.max(0, Math.min(1, (clientX - ir.left) / ir.width)), + y: Math.max(0, Math.min(1, (clientY - ir.top) / ir.height)), + }; + }; + + const onMapPointerDown = (e: React.PointerEvent) => { + if (!largeMap) return; + if (e.button === 1 || (e.button === 0 && spaceDownRef.current)) { + e.preventDefault(); + e.stopPropagation(); + (e.currentTarget as HTMLDivElement).setPointerCapture(e.pointerId); + dragRef.current = { kind: 'pan', lastX: e.clientX, lastY: e.clientY }; + skipMapClickRef.current = true; + } + }; + + const onMapPointerMove = (e: React.PointerEvent) => { + const d = dragRef.current; + if (!d) return; + if (d.kind === 'pan') { + const cr = contentRect; + if (!cr) return; + const dx = e.clientX - d.lastX; + const dy = e.clientY - d.lastY; + d.lastX = e.clientX; + d.lastY = e.clientY; + setView((v) => { + const containW = cr.w / Math.max(1e-6, v.scale); + const containH = cr.h / Math.max(1e-6, v.scale); + return sceneViewPanBy(v, { containW, containH, dx, dy }); + }); + return; + } + if (d.kind === 'marker') { + skipMapClickRef.current = true; + const p = toNorm(e.clientX, e.clientY); + if (!p) return; + const nextMarkers = draftRef.current.markers.map((x) => + x.id === d.id ? { ...x, nx: p.x, ny: p.y } : x, + ); + const next = { ...draftRef.current, enabled: true, markers: nextMarkers }; + draftRef.current = next; + setDraft(next); + // persist только на pointerup + } + }; + + const onMapPointerUp = () => { + const d = dragRef.current; + if (d?.kind === 'marker') { + schedulePersist(); + } + dragRef.current = null; + }; + + const onMapClick = (e: React.MouseEvent) => { + if (skipMapClickRef.current) { + skipMapClickRef.current = false; + return; + } + if (dragRef.current) return; + if (!draft.enabled || !activeItem) return; + const p = toNorm(e.clientX, e.clientY); + if (!p) return; + placeMarker(p.x, p.y, activeItem.number); + }; + + const renderMarkers = () => { + if (!draft.enabled) return null; + return draft.markers.map((m) => { + const style = + largeMap && contentRect + ? (() => { + const p = legendImageUvToHostPoint(m.nx, m.ny, contentRect, rotationDeg); + return { left: p.x, top: p.y }; + })() + : { + left: `${String(m.nx * 100)}%`, + top: `${String(m.ny * 100)}%`, + }; + return ( +
{ + if (!draft.enabled) return; + e.stopPropagation(); + e.preventDefault(); + dragRef.current = { kind: 'marker', id: m.id }; + (e.currentTarget as HTMLDivElement).setPointerCapture(e.pointerId); + }} + onPointerMove={onMapPointerMove} + onPointerUp={(e) => { + e.stopPropagation(); + if (dragRef.current?.kind === 'marker') skipMapClickRef.current = true; + onMapPointerUp(); + }} + onClick={(e) => { + e.stopPropagation(); + skipMapClickRef.current = true; + }} + onContextMenu={(e) => { + e.preventDefault(); + e.stopPropagation(); + updateMarkers(draftRef.current.markers.filter((x) => x.id !== m.id)); + }} + title="Перетащите, чтобы переместить. ПКМ — удалить" + > + {m.number} +
+ ); + }); + }; + + const mapBlock = previewUrl ? ( +
{ + if (!draft.enabled) return; + e.preventDefault(); + }} + onDrop={(e) => { + if (!draft.enabled) return; + e.preventDefault(); + const num = Number(e.dataTransfer.getData('application/x-legend-number')); + const p = toNorm(e.clientX, e.clientY); + if (!p || !Number.isFinite(num) || num < 1) return; + placeMarker(p.x, p.y, Math.round(num)); + }} + onClick={onMapClick} + onPointerDown={onMapPointerDown} + onPointerMove={onMapPointerMove} + onPointerUp={onMapPointerUp} + onPointerCancel={onMapPointerUp} + > + {largeMap ? ( + + ) : ( + + )} + {renderMarkers()} + {draft.enabled && !activeItem ? ( +
Выберите строку легенды, чтобы ставить метки
+ ) : null} + {largeMap ? ( +
Колесо — зум · СКМ / Space+ЛКМ — пан
+ ) : null} +
+ ) : ( +
+
Нет изображения
+
+ ); + + return ( +
+ {largeMap ? mapBlock : null} + + + + {draft.enabled ? ( + <> +
+ + + Активная строка подсвечена — клик по картинке ставит метку; клик по метке — перемещение + +
+ {draft.items.map((item) => { + const active = item.id === activeItemId; + return ( +
setActiveItemId(item.id)} + onDragStart={(e) => { + e.dataTransfer.setData('application/x-legend-number', String(item.number)); + setActiveItemId(item.id); + }} + > +
{item.number}
+ { + updateItems( + draftRef.current.items.map((it) => (it.id === item.id ? { ...it, text } : it)), + ); + }} + placeholder="Описание…" + /> + +
+ ); + })} + {!largeMap ? mapBlock : null} + + ) : largeMap ? null : ( + mapBlock + )} +
+ ); +} diff --git a/app/renderer/editor/MaterialsBrowser.tsx b/app/renderer/editor/MaterialsBrowser.tsx index 3356697..7e253ed 100644 --- a/app/renderer/editor/MaterialsBrowser.tsx +++ b/app/renderer/editor/MaterialsBrowser.tsx @@ -1,13 +1,14 @@ import React, { useEffect, useMemo, useState } from 'react'; import { createPortal } from 'react-dom'; -import type { MaterialId, ProjectMaterial } from '../../shared/types'; +import type { MaterialId, MaterialLegend, ProjectMaterial } from '../../shared/types'; import { RotatedImage } from '../shared/RotatedImage'; import { Button, Input } from '../shared/ui/controls'; import { useAssetUrl } from '../shared/useAssetImageUrl'; import styles from './EditorApp.module.css'; import { useEditorI18n } from './i18n/EditorI18nContext'; +import { MaterialLegendEditor } from './MaterialLegendEditor'; import matStyles from './MaterialsModals.module.css'; const DND_MATERIAL_ID_MIME = 'application/x-dnd-material-id'; @@ -24,6 +25,7 @@ export type MaterialsBrowserProps = { onDelete?: (materialId: MaterialId) => Promise; onReorder?: (materialIds: MaterialId[]) => Promise; onRotate?: (materialId: MaterialId, rotationDeg: 0 | 90 | 180 | 270) => void; + onLegendChange?: (materialId: MaterialId, legend: MaterialLegend) => Promise; onTileActivate?: (materialId: MaterialId) => void; toolbar?: React.ReactNode; className?: string | undefined; @@ -44,6 +46,7 @@ export function MaterialsBrowser({ onDelete, onReorder, onRotate, + onLegendChange, onTileActivate, toolbar, className, @@ -179,20 +182,42 @@ export function MaterialsBrowser({
{!listOnly ? ( -
-
- {selected && selectedUrl ? ( -
- -
- ) : ( -
{t('materials.addPrompt')}
- )} -
+
+ {selected && selectedUrl && onLegendChange ? ( +
+ { + void onLegendChange(selected.id, next); + }} + /> +
+ ) : ( +
+ {selected && selectedUrl ? ( +
+ +
+ ) : ( +
{t('materials.addPrompt')}
+ )} +
+ )} {selected && onRotate ? (
, diff --git a/app/renderer/editor/state/projectState.ts b/app/renderer/editor/state/projectState.ts index 4bf781b..967bf0a 100644 --- a/app/renderer/editor/state/projectState.ts +++ b/app/renderer/editor/state/projectState.ts @@ -58,6 +58,10 @@ type Actions = { deleteMaterial: (materialId: MaterialId) => Promise; setMaterialsOrder: (materialIds: MaterialId[]) => Promise; setMaterialRotation: (materialId: MaterialId, rotationDeg: 0 | 90 | 180 | 270) => Promise; + setMaterialLegend: ( + materialId: MaterialId, + legend: import('../../shared/types').MaterialLegend | null, + ) => Promise; pickMaterialImage: () => Promise<{ filePath: string; previewDataUrl: string } | null>; updateScene: ( sceneId: SceneId, @@ -344,6 +348,7 @@ export function useProjectState(licenseActive: boolean, opts?: ProjectStateOpts) previewVideoAutostart: false, previewRotationDeg: 0, darkenScene: false, + traps: [], media: { videos: [], audios: [] }, settings: { autoplayVideo: false, autoplayAudio: true, loopVideo: true, loopAudio: true }, connections: [], @@ -531,6 +536,15 @@ export function useProjectState(licenseActive: boolean, opts?: ProjectStateOpts) await refreshProjects(); }; + const setMaterialLegend = async ( + materialId: MaterialId, + legend: import('../../shared/types').MaterialLegend | null, + ) => { + const res = await api.invoke(ipcChannels.project.setMaterialLegend, { materialId, legend }); + // Список проектов не меняется — не дергаем refreshProjects на каждое обновление легенды. + setState((s) => ({ ...s, project: res.project })); + }; + const pickMaterialImage = async () => { const res = await api.invoke(ipcChannels.project.pickMaterialImage, {}); if (res.canceled) return null; @@ -548,6 +562,7 @@ export function useProjectState(licenseActive: boolean, opts?: ProjectStateOpts) previewVideoAutostart?: boolean; previewRotationDeg?: 0 | 90 | 180 | 270; darkenScene?: boolean; + traps?: import('../../shared/types').SceneTrap[]; settings?: Partial; media?: Partial; layout?: { x: number; y: number }; @@ -574,6 +589,7 @@ export function useProjectState(licenseActive: boolean, opts?: ProjectStateOpts) ? { previewRotationDeg: patch.previewRotationDeg } : null), ...(patch.darkenScene !== undefined ? { darkenScene: patch.darkenScene } : null), + ...(patch.traps !== undefined ? { traps: patch.traps } : null), ...(patch.settings ? { settings: { ...scene.settings, ...patch.settings } } : null), ...(patch.media ? { media: { ...scene.media, ...patch.media } } : null), layout: patch.layout ? { ...scene.layout, ...patch.layout } : scene.layout, @@ -896,6 +912,7 @@ export function useProjectState(licenseActive: boolean, opts?: ProjectStateOpts) deleteMaterial, setMaterialsOrder, setMaterialRotation, + setMaterialLegend, pickMaterialImage, updateScene, updateConnections, diff --git a/app/renderer/presentation/PresentationApp.tsx b/app/renderer/presentation/PresentationApp.tsx index ef5ca6c..7ac0773 100644 --- a/app/renderer/presentation/PresentationApp.tsx +++ b/app/renderer/presentation/PresentationApp.tsx @@ -24,6 +24,11 @@ export function PresentationApp() { const onKeyDown = (e: KeyboardEvent) => { if (e.key === 'Escape') { void api.invoke(ipcChannels.windows.closeMultiWindow, {}); + return; + } + if (e.altKey && (e.key === 'Enter' || e.code === 'Enter' || e.code === 'NumpadEnter')) { + e.preventDefault(); + void api.invoke(ipcChannels.windows.togglePresentationFullscreen, {}); } }; window.addEventListener('keydown', onKeyDown); diff --git a/app/renderer/sceneEditor.html b/app/renderer/sceneEditor.html new file mode 100644 index 0000000..f3d5ed1 --- /dev/null +++ b/app/renderer/sceneEditor.html @@ -0,0 +1,13 @@ + + + + + + + TTRPG + + +
+ + + diff --git a/app/renderer/sceneEditor/SceneEditorApp.module.css b/app/renderer/sceneEditor/SceneEditorApp.module.css new file mode 100644 index 0000000..31593ac --- /dev/null +++ b/app/renderer/sceneEditor/SceneEditorApp.module.css @@ -0,0 +1,170 @@ +.page { + display: grid; + grid-template-columns: 260px 1fr; + height: 100vh; + width: 100vw; + overflow: hidden; + background: var(--bg, #12141a); + color: var(--text, #e8eaef); +} + +.sidebar { + border-right: 1px solid var(--stroke, #2a2f3a); + padding: 12px; + overflow: auto; + display: flex; + flex-direction: column; + gap: 10px; +} + +.sideTitle { + font-weight: 800; + font-size: 13px; + letter-spacing: 0.04em; + text-transform: uppercase; + opacity: 0.85; +} + +.accordion { + border: 1px solid var(--stroke, #2a2f3a); + border-radius: 10px; + overflow: hidden; + background: rgba(255, 255, 255, 0.02); +} + +.accordionHead { + width: 100%; + text-align: left; + padding: 10px 12px; + font-weight: 700; + background: transparent; + border: 0; + color: inherit; + cursor: pointer; +} + +.palette { + display: grid; + gap: 6px; + padding: 8px; +} + +.paletteItem { + display: flex; + align-items: center; + gap: 10px; + padding: 8px 10px; + border-radius: 8px; + border: 1px solid var(--stroke, #2a2f3a); + background: rgba(0, 0, 0, 0.2); + color: inherit; + cursor: grab; + user-select: none; +} + +.paletteItem:active { + cursor: grabbing; +} + +.paletteLabel { + font-size: 13px; + font-weight: 600; +} + +.hint { + font-size: 12px; + opacity: 0.65; + line-height: 1.35; +} + +.stage { + position: relative; + min-width: 0; + min-height: 0; + background: #0a0b0e; +} + +.viewport { + position: absolute; + inset: 0; + overflow: hidden; + cursor: default; +} + +.world { + position: absolute; + left: 0; + top: 0; + transform-origin: 0 0; + will-change: transform; +} + +.mapImg { + display: block; + max-width: none; + user-select: none; + pointer-events: none; +} + +.trap { + position: absolute; + transform: translate(-50%, -50%); + border-radius: 50%; + border: 2px solid rgba(255, 255, 255, 0.55); + background: rgba(0, 0, 0, 0.45); + display: grid; + place-items: center; + box-shadow: 0 0 0 1px rgba(0, 0, 0, 0.4); + cursor: move; + touch-action: none; +} + +.trapSelected { + border-color: #f5c542; + box-shadow: + 0 0 0 1px rgba(0, 0, 0, 0.4), + 0 0 0 3px rgba(245, 197, 66, 0.35); +} + +.trapLabel { + position: absolute; + left: 50%; + top: calc(100% + 4px); + transform: translateX(-50%); + font-size: 11px; + white-space: nowrap; + background: rgba(0, 0, 0, 0.7); + padding: 2px 6px; + border-radius: 4px; +} + +.handle { + position: absolute; + right: -5px; + bottom: -5px; + width: 12px; + height: 12px; + border-radius: 2px; + background: #f5c542; + border: 1px solid #000; + cursor: nwse-resize; + touch-action: none; +} + +.empty { + position: absolute; + inset: 0; + display: grid; + place-items: center; + opacity: 0.7; +} + +.toolbar { + display: flex; + gap: 8px; + flex-wrap: wrap; +} + +.toolbar button { + font-size: 12px; +} diff --git a/app/renderer/sceneEditor/SceneEditorApp.tsx b/app/renderer/sceneEditor/SceneEditorApp.tsx new file mode 100644 index 0000000..b246d44 --- /dev/null +++ b/app/renderer/sceneEditor/SceneEditorApp.tsx @@ -0,0 +1,365 @@ +import React, { useCallback, useEffect, useMemo, useRef, useState } from 'react'; + +import { ipcChannels, type SessionState } from '../../shared/ipc/contracts'; +import type { SceneTrap, SceneTrapType } from '../../shared/types'; +import { + asSceneTrapId, + DEFAULT_SCENE_TRAP_SIZE_N, + SCENE_TRAP_TYPES, + trapTypeLabelRu, +} from '../../shared/types/sceneTraps'; +import { sceneViewPanBy, sceneViewZoomAt } from '../../shared/types/sceneView'; +import { getDndApi } from '../shared/dndApi'; +import { RotatedImage } from '../shared/RotatedImage'; +import { TrapGlyph } from '../shared/traps/TrapGlyph'; +import { Button } from '../shared/ui/controls'; +import { useAssetUrl } from '../shared/useAssetImageUrl'; + +import styles from './SceneEditorApp.module.css'; + +type LocalView = { scale: number; ox: number; oy: number }; + +type DragMode = + | { kind: 'pan'; lastX: number; lastY: number } + | { kind: 'move'; trapId: string; startNx: number; startNy: number; pointerNx: number; pointerNy: number } + | { kind: 'resize'; trapId: string; startSize: number; startDist: number } + | null; + +function randomTrapId(): string { + return `trap_${Math.random().toString(36).slice(2, 10)}`; +} + +export function SceneEditorApp() { + const api = getDndApi(); + const [session, setSession] = useState(null); + const [trapsOpen, setTrapsOpen] = useState(true); + const [selectedId, setSelectedId] = useState(null); + const [view, setView] = useState({ scale: 1, ox: 0.5, oy: 0.5 }); + const [contentRect, setContentRect] = useState<{ x: number; y: number; w: number; h: number } | null>( + null, + ); + const hostRef = useRef(null); + const dragRef = useRef(null); + const saveTimerRef = useRef(0); + const spaceDownRef = useRef(false); + + const project = session?.project ?? null; + const sceneId = project?.currentSceneId ?? null; + const scene = sceneId && project ? project.scenes[sceneId] : undefined; + const url = useAssetUrl(scene?.previewAssetId ?? null); + const rot = scene?.previewRotationDeg ?? 0; + const [localTraps, setLocalTraps] = useState([]); + const trapsRef = useRef([]); + trapsRef.current = localTraps; + + useEffect(() => { + setLocalTraps(scene?.traps ?? []); + setSelectedId(null); + setView({ scale: 1, ox: 0.5, oy: 0.5 }); + }, [sceneId, scene?.previewAssetId]); + + useEffect(() => { + // External updates (other windows) — sync when not dragging + if (dragRef.current) return; + setLocalTraps(scene?.traps ?? []); + }, [scene?.traps]); + + useEffect(() => { + void api.invoke(ipcChannels.project.get, {}).then(({ project: p }) => { + setSession({ project: p, currentSceneId: p?.currentSceneId ?? null }); + }); + return api.on(ipcChannels.session.stateChanged, ({ state }) => { + setSession(state); + }); + }, [api]); + + const persistTraps = useCallback( + async (next: SceneTrap[]) => { + if (!sceneId) return; + setLocalTraps(next); + trapsRef.current = next; + if (saveTimerRef.current) window.clearTimeout(saveTimerRef.current); + saveTimerRef.current = window.setTimeout(() => { + void api.invoke(ipcChannels.project.updateScene, { sceneId, patch: { traps: next } }); + }, 120); + }, + [api, sceneId], + ); + + useEffect(() => { + const onKeyDown = (e: KeyboardEvent) => { + if (e.code === 'Space') spaceDownRef.current = true; + if ((e.key === 'Delete' || e.key === 'Backspace') && selectedId && sceneId) { + e.preventDefault(); + const next = trapsRef.current.filter((t) => t.id !== selectedId); + setSelectedId(null); + void persistTraps(next); + } + }; + const onKeyUp = (e: KeyboardEvent) => { + if (e.code === 'Space') spaceDownRef.current = false; + }; + window.addEventListener('keydown', onKeyDown); + window.addEventListener('keyup', onKeyUp); + return () => { + window.removeEventListener('keydown', onKeyDown); + window.removeEventListener('keyup', onKeyUp); + }; + }, [persistTraps, sceneId, selectedId]); + + const hostToNorm = (clientX: number, clientY: number): { x: number; y: number } | null => { + const host = hostRef.current; + const cr = contentRect; + if (!host || !cr || cr.w < 1 || cr.h < 1) return null; + const r = host.getBoundingClientRect(); + return { + x: Math.max(0, Math.min(1, (clientX - (r.left + cr.x)) / cr.w)), + y: Math.max(0, Math.min(1, (clientY - (r.top + cr.y)) / cr.h)), + }; + }; + + const onWheel = (_e: React.WheelEvent) => { + // native non-passive listener handles zoom + }; + void onWheel; + + const viewCamera = useMemo(() => view, [view]); + + useEffect(() => { + const host = hostRef.current; + if (!host) return; + const nativeWheel = (e: WheelEvent) => { + e.preventDefault(); + const factor = e.deltaY < 0 ? 1.12 : 1 / 1.12; + const cr = contentRect; + if (!cr) { + setView((v) => { + const nextScale = Math.max(1, Math.min(8, v.scale * factor)); + if (nextScale <= 1.001) return { scale: 1, ox: 0.5, oy: 0.5 }; + return { ...v, scale: nextScale }; + }); + return; + } + const r = host.getBoundingClientRect(); + setView((v) => { + const containW = cr.w / Math.max(1e-6, v.scale); + const containH = cr.h / Math.max(1e-6, v.scale); + return sceneViewZoomAt(v, { + hostW: r.width, + hostH: r.height, + containW, + containH, + hostX: e.clientX - r.left, + hostY: e.clientY - r.top, + factor, + }); + }); + }; + host.addEventListener('wheel', nativeWheel, { passive: false }); + return () => host.removeEventListener('wheel', nativeWheel); + }, [contentRect]); + + const addTrapAt = (type: SceneTrapType, nx: number, ny: number) => { + const trap: SceneTrap = { + id: asSceneTrapId(randomTrapId()), + type, + nx, + ny, + sizeN: DEFAULT_SCENE_TRAP_SIZE_N, + ...(type === 'freeform' ? { label: trapTypeLabelRu(type) } : {}), + }; + const next = [...trapsRef.current, trap]; + setSelectedId(trap.id); + void persistTraps(next); + }; + + const onPaletteDragStart = (type: SceneTrapType) => (e: React.DragEvent) => { + e.dataTransfer.setData('application/x-dnd-trap-type', type); + e.dataTransfer.effectAllowed = 'copy'; + }; + + const onStageDrop = (e: React.DragEvent) => { + e.preventDefault(); + const type = e.dataTransfer.getData('application/x-dnd-trap-type') as SceneTrapType; + if (!SCENE_TRAP_TYPES.includes(type)) return; + const p = hostToNorm(e.clientX, e.clientY); + if (!p) return; + addTrapAt(type, p.x, p.y); + }; + + const updateTrap = (id: string, patch: Partial) => { + const next = trapsRef.current.map((t) => (t.id === id ? { ...t, ...patch } : t)); + void persistTraps(next); + }; + + const isImage = scene?.previewAssetType === 'image' && Boolean(url); + + return ( +
+ + +
+ {!isImage ? ( +
Нужно изображение сцены
+ ) : ( +
e.preventDefault()} + onDrop={onStageDrop} + onPointerDown={(e) => { + if (e.button === 1 || (e.button === 0 && spaceDownRef.current)) { + e.preventDefault(); + (e.currentTarget as HTMLDivElement).setPointerCapture(e.pointerId); + dragRef.current = { kind: 'pan', lastX: e.clientX, lastY: e.clientY }; + } + }} + onPointerMove={(e) => { + const d = dragRef.current; + if (!d) return; + if (d.kind === 'pan') { + const cr = contentRect; + if (!cr) return; + const dx = e.clientX - d.lastX; + const dy = e.clientY - d.lastY; + d.lastX = e.clientX; + d.lastY = e.clientY; + setView((v) => { + const containW = cr.w / Math.max(1e-6, v.scale); + const containH = cr.h / Math.max(1e-6, v.scale); + return sceneViewPanBy(v, { containW, containH, dx, dy }); + }); + return; + } + if (d.kind === 'move') { + const p = hostToNorm(e.clientX, e.clientY); + if (!p) return; + updateTrap(d.trapId, { + nx: Math.max(0, Math.min(1, d.startNx + (p.x - d.pointerNx))), + ny: Math.max(0, Math.min(1, d.startNy + (p.y - d.pointerNy))), + }); + return; + } + if (d.kind === 'resize') { + const p = hostToNorm(e.clientX, e.clientY); + const trap = trapsRef.current.find((t) => t.id === d.trapId); + if (!p || !trap) return; + const dist = Math.hypot(p.x - trap.nx, p.y - trap.ny); + const ratio = d.startDist > 1e-6 ? dist / d.startDist : 1; + updateTrap(d.trapId, { + sizeN: Math.max(0.02, Math.min(0.45, d.startSize * ratio)), + }); + } + }} + onPointerUp={() => { + dragRef.current = null; + }} + onPointerCancel={() => { + dragRef.current = null; + }} + > + + {contentRect + ? localTraps.map((trap) => { + const minDim = Math.min(contentRect.w, contentRect.h); + const sizePx = Math.max(16, trap.sizeN * minDim); + const left = contentRect.x + trap.nx * contentRect.w; + const top = contentRect.y + trap.ny * contentRect.h; + const selected = selectedId === trap.id; + return ( +
{ + if (e.button !== 0 || spaceDownRef.current) return; + e.stopPropagation(); + setSelectedId(trap.id); + const p = hostToNorm(e.clientX, e.clientY); + if (!p) return; + (e.currentTarget as HTMLDivElement).setPointerCapture(e.pointerId); + dragRef.current = { + kind: 'move', + trapId: trap.id, + startNx: trap.nx, + startNy: trap.ny, + pointerNx: p.x, + pointerNy: p.y, + }; + }} + > + + {trap.label ?
{trap.label}
: null} + {selected ? ( +
{ + e.stopPropagation(); + e.preventDefault(); + const p = hostToNorm(e.clientX, e.clientY); + if (!p) return; + (e.currentTarget as HTMLDivElement).setPointerCapture(e.pointerId); + dragRef.current = { + kind: 'resize', + trapId: trap.id, + startSize: trap.sizeN, + startDist: Math.max(1e-4, Math.hypot(p.x - trap.nx, p.y - trap.ny)), + }; + }} + /> + ) : null} +
+ ); + }) + : null} +
+ )} +
+
+ ); +} diff --git a/app/renderer/sceneEditor/main.tsx b/app/renderer/sceneEditor/main.tsx new file mode 100644 index 0000000..ebac610 --- /dev/null +++ b/app/renderer/sceneEditor/main.tsx @@ -0,0 +1,20 @@ +import React from 'react'; +import { createRoot } from 'react-dom/client'; + +import '../shared/ui/globals.css'; +import { EditorI18nProvider } from '../editor/i18n/EditorI18nContext'; + +import { SceneEditorApp } from './SceneEditorApp'; + +const rootEl = document.getElementById('root'); +if (!rootEl) { + throw new Error('Missing #root element'); +} + +createRoot(rootEl).render( + + + + + , +); diff --git a/app/renderer/shared/PresentationView.tsx b/app/renderer/shared/PresentationView.tsx index 28d15d7..5568eb6 100644 --- a/app/renderer/shared/PresentationView.tsx +++ b/app/renderer/shared/PresentationView.tsx @@ -8,12 +8,15 @@ import { SceneDarknessOverlay } from './effects/SceneDarknessOverlay'; import { useEffectsState } from './effects/useEffectsState'; import { useSceneDarknessState } from './effects/useSceneDarknessState'; import { DEFAULT_MATERIALS_OVERLAY_LAYOUT, DEFAULT_NPCS_OVERLAY_LAYOUT } from '../../shared/types'; +import { MaterialLegendPanel } from './materials/MaterialLegendPanel'; import { MaterialOverlay } from './materials/MaterialOverlay'; import { useMaterialsOverlayState } from './materials/useMaterialsOverlayState'; import { NpcsSceneOverlay } from './npcs/NpcsSceneOverlay'; import { useNpcsOverlayState } from './npcs/useNpcsOverlayState'; import { SceneOverlayHost } from './sceneOverlay/SceneOverlayHost'; import { useSceneViewState } from './sceneView/useSceneViewState'; +import { SceneTrapsOverlay } from './traps/SceneTrapsOverlay'; +import { useSceneTrapsState } from './traps/useSceneTrapsState'; import styles from './PresentationView.module.css'; import { RotatedImage } from './RotatedImage'; import { useAssetUrl } from './useAssetImageUrl'; @@ -35,6 +38,7 @@ export function PresentationView({ }: PresentationViewProps) { const [fxState] = useEffectsState(); const [sdState] = useSceneDarknessState(); + const [sceneTraps] = useSceneTrapsState(); const [sceneView] = useSceneViewState(); const [materialsOverlay] = useMaterialsOverlayState(); const [npcsOverlay] = useNpcsOverlayState(); @@ -165,6 +169,14 @@ export function PresentationView({ } /> ) : null} + {scene?.previewAssetType === 'image' && contentRect ? ( + + ) : null} {showEffects && scene?.previewAssetType === 'image' && scene.darkenScene && contentRect ? ( ) : null} @@ -174,6 +186,15 @@ export function PresentationView({ embedded assetId={activeMaterial.assetId} layout={materialsOverlay?.layout ?? DEFAULT_MATERIALS_OVERLAY_LAYOUT} + legendMarkers={ + activeMaterial.legend?.enabled ? (activeMaterial.legend.markers ?? []) : undefined + } + /> + ) : null} + {activeMaterial?.legend?.enabled ? ( + ) : null} {activeNpcItems.length > 0 ? : null} diff --git a/app/renderer/shared/effects/SceneDarknessOverlay.tsx b/app/renderer/shared/effects/SceneDarknessOverlay.tsx index 41fc482..984f52b 100644 --- a/app/renderer/shared/effects/SceneDarknessOverlay.tsx +++ b/app/renderer/shared/effects/SceneDarknessOverlay.tsx @@ -88,7 +88,7 @@ export function SceneDarknessOverlay({ height: viewport.h, opacity: overlayAlpha, pointerEvents: 'none', - zIndex: 2, + zIndex: 3, ...style, }} /> diff --git a/app/renderer/shared/materials/MaterialLegendPanel.module.css b/app/renderer/shared/materials/MaterialLegendPanel.module.css new file mode 100644 index 0000000..dcea5e9 --- /dev/null +++ b/app/renderer/shared/materials/MaterialLegendPanel.module.css @@ -0,0 +1,59 @@ +.panel { + position: absolute; + z-index: 45; + min-width: 200px; + max-width: min(360px, 42vw); + max-height: min(70vh, 520px); + overflow: auto; + padding: 14px 16px; + border-radius: 14px; + border: 1px solid rgba(255, 255, 255, 0.14); + background: linear-gradient(160deg, rgba(18, 22, 30, 0.94), rgba(12, 14, 20, 0.92)); + box-shadow: 0 18px 40px rgba(0, 0, 0, 0.45); + color: #f2f4f8; + backdrop-filter: blur(8px); + cursor: move; + touch-action: none; + user-select: none; + pointer-events: auto; +} + +.title { + font-size: 12px; + font-weight: 800; + letter-spacing: 0.08em; + text-transform: uppercase; + opacity: 0.7; + margin-bottom: 10px; +} + +.item { + display: grid; + grid-template-columns: 28px 1fr; + gap: 10px; + align-items: start; + padding: 6px 0; + border-top: 1px solid rgba(255, 255, 255, 0.06); +} + +.item:first-of-type { + border-top: 0; +} + +.num { + width: 28px; + height: 28px; + border-radius: 50%; + display: grid; + place-items: center; + font-weight: 800; + font-size: 12px; + background: #1e3a5f; + border: 2px solid #f5c542; +} + +.text { + font-size: 13px; + line-height: 1.35; + padding-top: 4px; +} diff --git a/app/renderer/shared/materials/MaterialLegendPanel.tsx b/app/renderer/shared/materials/MaterialLegendPanel.tsx new file mode 100644 index 0000000..d6125de --- /dev/null +++ b/app/renderer/shared/materials/MaterialLegendPanel.tsx @@ -0,0 +1,75 @@ +import React, { useRef } from 'react'; + +import type { MaterialLegend, MaterialsOverlayLayout } from '../../../shared/types'; +import { DEFAULT_MATERIALS_OVERLAY_LAYOUT } from '../../../shared/types'; +import { useSceneOverlayView } from '../sceneOverlay/SceneOverlayViewContext'; + +import styles from './MaterialLegendPanel.module.css'; + +type Props = { + legend: MaterialLegend; + layout: MaterialsOverlayLayout; + editable?: boolean; + onLayoutChange?: (layout: MaterialsOverlayLayout) => void; +}; + +export function MaterialLegendPanel({ + legend, + layout, + editable = false, + onLayoutChange, +}: Props) { + const host = useSceneOverlayView(); + const view = host?.view ?? { w: 1, h: 1 }; + const dragRef = useRef<{ startX: number; startY: number; origin: MaterialsOverlayLayout } | null>( + null, + ); + const effective = layout ?? DEFAULT_MATERIALS_OVERLAY_LAYOUT; + const w = Math.max(180, Math.min(view.w * 0.36, 340) * effective.scale); + const left = effective.cx * view.w - w / 2; + const top = effective.cy * view.h - 40; + + if (!legend.enabled || legend.items.length === 0) return null; + + return ( +
{ + if (!editable || !onLayoutChange) return; + e.stopPropagation(); + (e.currentTarget as HTMLDivElement).setPointerCapture(e.pointerId); + dragRef.current = { + startX: e.clientX, + startY: e.clientY, + origin: { ...effective }, + }; + }} + onPointerMove={(e) => { + const d = dragRef.current; + if (!d || !onLayoutChange) return; + const dx = (e.clientX - d.startX) / Math.max(1, view.w); + const dy = (e.clientY - d.startY) / Math.max(1, view.h); + onLayoutChange({ + ...d.origin, + cx: d.origin.cx + dx, + cy: d.origin.cy + dy, + }); + }} + onPointerUp={() => { + dragRef.current = null; + }} + > +
Легенда
+ {legend.items + .slice() + .sort((a, b) => a.number - b.number) + .map((item) => ( +
+
{item.number}
+
{item.text || '—'}
+
+ ))} +
+ ); +} diff --git a/app/renderer/shared/materials/MaterialOverlay.module.css b/app/renderer/shared/materials/MaterialOverlay.module.css index 80232ea..ec3cec2 100644 --- a/app/renderer/shared/materials/MaterialOverlay.module.css +++ b/app/renderer/shared/materials/MaterialOverlay.module.css @@ -50,15 +50,33 @@ .image { position: absolute; - left: 50%; - top: 50%; + inset: 0; + width: 100%; + height: 100%; display: block; object-fit: fill; border-radius: 6px; box-shadow: 0 18px 48px rgba(0, 0, 0, 0.55); user-select: none; pointer-events: none; - transform-origin: center center; +} + +.legendMarker { + position: absolute; + transform: translate(-50%, -50%); + 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: 2; + box-shadow: 0 2px 8px rgba(0, 0, 0, 0.35); } .handle { diff --git a/app/renderer/shared/materials/MaterialOverlay.tsx b/app/renderer/shared/materials/MaterialOverlay.tsx index 74ed02e..aadfa56 100644 --- a/app/renderer/shared/materials/MaterialOverlay.tsx +++ b/app/renderer/shared/materials/MaterialOverlay.tsx @@ -22,6 +22,8 @@ type MaterialOverlayProps = { onZoomAt?: (nx: number, ny: number) => void; /** Без собственного root/dim — внутри `SceneOverlayHost`. */ embedded?: boolean; + /** Маркеры легенды (норм. координаты картинки). */ + legendMarkers?: readonly { id: string; number: number; nx: number; ny: number }[]; }; function RotateIcon() { @@ -99,6 +101,7 @@ export function MaterialOverlay({ onLayoutChange, onZoomAt, embedded = false, + legendMarkers, }: MaterialOverlayProps) { const url = useAssetUrl(assetId); const host = useSceneOverlayView(); @@ -381,16 +384,23 @@ export function MaterialOverlay({ src={url} alt="" draggable={false} - style={{ - width: w, - height: h, - transform: 'translate(-50%, -50%)', - }} onLoad={(e) => { const img = e.currentTarget; setNatural({ w: img.naturalWidth || 1, h: img.naturalHeight || 1 }); }} /> + {(legendMarkers ?? []).map((m) => ( +
+ {m.number} +
+ ))} {editable && !zoomTool ? (['nw', 'ne', 'sw', 'se'] as const).map((corner) => ( + + +
, + document.body, + ) + : null} +
+ ); +} diff --git a/app/renderer/shared/traps/TrapGlyph.tsx b/app/renderer/shared/traps/TrapGlyph.tsx new file mode 100644 index 0000000..ce46c73 --- /dev/null +++ b/app/renderer/shared/traps/TrapGlyph.tsx @@ -0,0 +1,103 @@ +/** Простые SVG-иконки ловушек (MVP). */ + +import type { SceneTrapStatus, SceneTrapType } from '../../shared/types'; + +const TYPE_COLOR: Record = { + mimic: '#c4783a', + explosion: '#e85d3a', + poison: '#6bcb5a', + pit: '#5a5a6e', + arrow: '#d4a017', + laser: '#3ad0e8', + freeform: '#a78bfa', +}; + +export function trapAccentColor(type: SceneTrapType, status: SceneTrapStatus = 'inactive'): string { + if (status === 'disarmed') return '#6b7280'; + if (status === 'active') return TYPE_COLOR[type]; + return TYPE_COLOR[type]; +} + +export function TrapGlyph({ + type, + status = 'inactive', + size = 28, +}: { + type: SceneTrapType; + status?: SceneTrapStatus; + size?: number; +}) { + const stroke = trapAccentColor(type, status); + const opacity = status === 'disarmed' ? 0.55 : 1; + const common = { + width: size, + height: size, + viewBox: '0 0 24 24', + fill: 'none', + stroke, + strokeWidth: 1.8, + strokeLinecap: 'round' as const, + strokeLinejoin: 'round' as const, + opacity, + 'aria-hidden': true as const, + }; + + switch (type) { + case 'mimic': + return ( + + + + + + ); + case 'explosion': + return ( + + + + + ); + case 'poison': + return ( + + + + + + ); + case 'pit': + return ( + + + + + ); + case 'arrow': + return ( + + + + + + ); + case 'laser': + return ( + + + + + + ); + case 'freeform': + return ( + + + + ); + default: { + const _x: never = type; + return {String(_x)}; + } + } +} diff --git a/app/renderer/shared/traps/useSceneTrapsState.ts b/app/renderer/shared/traps/useSceneTrapsState.ts new file mode 100644 index 0000000..7f531fd --- /dev/null +++ b/app/renderer/shared/traps/useSceneTrapsState.ts @@ -0,0 +1,31 @@ +import { useEffect, useState } from 'react'; + +import { ipcChannels } from '../../../shared/ipc/contracts'; +import type { SceneTrapsEvent, SceneTrapsState } from '../../../shared/types'; +import { getDndApi } from '../dndApi'; + +export function useSceneTrapsState(): readonly [ + SceneTrapsState | null, + { dispatch: (event: SceneTrapsEvent) => Promise }, +] { + const api = getDndApi(); + const [state, setState] = useState(null); + + useEffect(() => { + void api.invoke(ipcChannels.sceneTraps.getState, {}).then((r) => { + setState(r.state); + }); + return api.on(ipcChannels.sceneTraps.stateChanged, ({ state: next }) => { + setState(next); + }); + }, [api]); + + return [ + state, + { + dispatch: async (event) => { + await api.invoke(ipcChannels.sceneTraps.dispatch, { event }); + }, + }, + ] as const; +} diff --git a/app/shared/appBranding.ts b/app/shared/appBranding.ts index a6d8e07..b16ba25 100644 --- a/app/shared/appBranding.ts +++ b/app/shared/appBranding.ts @@ -23,6 +23,7 @@ export type AppWindowKind = | 'sceneDescription' | 'materials' | 'npcsEditor' + | 'sceneEditor' | 'npcs'; const WINDOW_SUFFIX: Record = { @@ -33,6 +34,7 @@ const WINDOW_SUFFIX: Record = { sceneDescription: { ru: 'Описание сцены', en: 'Scene description' }, materials: { ru: 'Материалы', en: 'Materials' }, npcsEditor: { ru: 'НПС', en: 'NPCs' }, + sceneEditor: { ru: 'Редактор сцены', en: 'Scene editor' }, npcs: { ru: 'НПС', en: 'NPCs' }, }; diff --git a/app/shared/graph/sceneListOrder.test.ts b/app/shared/graph/sceneListOrder.test.ts index d2d4531..b883a94 100644 --- a/app/shared/graph/sceneListOrder.test.ts +++ b/app/shared/graph/sceneListOrder.test.ts @@ -22,6 +22,7 @@ function scene(id: string): Scene { previewVideoAutostart: false, previewRotationDeg: 0, darkenScene: false, + traps: [], media: { videos: [], audios: [] }, settings: { autoplayVideo: false, autoplayAudio: true, loopVideo: true, loopAudio: true }, connections: [], diff --git a/app/shared/graph/storylineExportImport.test.ts b/app/shared/graph/storylineExportImport.test.ts index cd62834..a394d58 100644 --- a/app/shared/graph/storylineExportImport.test.ts +++ b/app/shared/graph/storylineExportImport.test.ts @@ -27,6 +27,7 @@ function scene(id: string, title: string): Scene { previewVideoAutostart: false, previewRotationDeg: 0, darkenScene: false, + traps: [], media: { videos: [], audios: [] }, settings: { autoplayVideo: false, autoplayAudio: false, loopVideo: false, loopAudio: false }, connections: [], diff --git a/app/shared/ipc/contracts.ts b/app/shared/ipc/contracts.ts index b66af48..8bb7faf 100644 --- a/app/shared/ipc/contracts.ts +++ b/app/shared/ipc/contracts.ts @@ -5,6 +5,7 @@ import type { EffectsState, GraphNodeId, MaterialId, + MaterialLegend, MaterialsOverlayEvent, MaterialsOverlayState, MediaAsset, @@ -20,6 +21,9 @@ import type { SceneDarknessEvent, SceneDarknessState, SceneId, + SceneTrap, + SceneTrapsEvent, + SceneTrapsState, SceneViewEvent, SceneViewState, VideoPlaybackEvent, @@ -60,6 +64,7 @@ export const ipcChannels = { updateCampaignAudios: 'project.updateCampaignAudios', upsertMaterial: 'project.upsertMaterial', setMaterialRotation: 'project.setMaterialRotation', + setMaterialLegend: 'project.setMaterialLegend', deleteMaterial: 'project.deleteMaterial', setMaterialsOrder: 'project.setMaterialsOrder', pickMaterialImage: 'project.pickMaterialImage', @@ -121,6 +126,8 @@ export const ipcChannels = { closeNpcsEditor: 'windows.closeNpcsEditor', openNpcs: 'windows.openNpcs', closeNpcs: 'windows.closeNpcs', + openSceneEditor: 'windows.openSceneEditor', + closeSceneEditor: 'windows.closeSceneEditor', }, session: { stateChanged: 'session.stateChanged', @@ -145,6 +152,11 @@ export const ipcChannels = { dispatch: 'sceneDarkness.dispatch', stateChanged: 'sceneDarkness.stateChanged', }, + sceneTraps: { + getState: 'sceneTraps.getState', + dispatch: 'sceneTraps.dispatch', + stateChanged: 'sceneTraps.stateChanged', + }, sceneView: { getState: 'sceneView.getState', dispatch: 'sceneView.dispatch', @@ -210,6 +222,7 @@ export type IpcEventMap = { [ipcChannels.materialsOverlay.stateChanged]: { state: MaterialsOverlayState }; [ipcChannels.npcsOverlay.stateChanged]: { state: NpcsOverlayState }; [ipcChannels.sceneDarkness.stateChanged]: { state: SceneDarknessState }; + [ipcChannels.sceneTraps.stateChanged]: { state: SceneTrapsState }; [ipcChannels.sceneView.stateChanged]: { state: SceneViewState }; [ipcChannels.video.stateChanged]: { state: VideoPlaybackState }; [ipcChannels.license.statusChanged]: { snapshot: LicenseSnapshot }; @@ -298,6 +311,10 @@ export type IpcInvokeMap = { req: { materialId: MaterialId; rotationDeg: 0 | 90 | 180 | 270 }; res: { project: Project }; }; + [ipcChannels.project.setMaterialLegend]: { + req: { materialId: MaterialId; legend: MaterialLegend | null }; + res: { project: Project }; + }; [ipcChannels.project.deleteMaterial]: { req: { materialId: MaterialId }; res: { project: Project }; @@ -569,6 +586,14 @@ export type IpcInvokeMap = { req: Record; res: { ok: true }; }; + [ipcChannels.windows.openSceneEditor]: { + req: Record; + res: { ok: true }; + }; + [ipcChannels.windows.closeSceneEditor]: { + req: Record; + res: { ok: true }; + }; [ipcChannels.materialsOverlay.getState]: { req: Record; res: { state: MaterialsOverlayState }; @@ -601,6 +626,14 @@ export type IpcInvokeMap = { req: { event: SceneDarknessEvent }; res: { ok: true }; }; + [ipcChannels.sceneTraps.getState]: { + req: Record; + res: { state: SceneTrapsState }; + }; + [ipcChannels.sceneTraps.dispatch]: { + req: { event: SceneTrapsEvent }; + res: { ok: true }; + }; [ipcChannels.sceneView.getState]: { req: Record; res: { state: SceneViewState }; @@ -646,6 +679,7 @@ export type LegacyIpcEventMap = { [ipcChannels.materialsOverlay.stateChanged]: { state: MaterialsOverlayState }; [ipcChannels.npcsOverlay.stateChanged]: { state: NpcsOverlayState }; [ipcChannels.sceneDarkness.stateChanged]: { state: SceneDarknessState }; + [ipcChannels.sceneTraps.stateChanged]: { state: SceneTrapsState }; [ipcChannels.sceneView.stateChanged]: { state: SceneViewState }; [ipcChannels.video.stateChanged]: { state: VideoPlaybackState }; [ipcChannels.license.statusChanged]: Record; @@ -660,6 +694,7 @@ export type ScenePatch = { previewVideoAutostart?: boolean; previewRotationDeg?: 0 | 90 | 180 | 270; darkenScene?: boolean; + traps?: SceneTrap[]; settings?: Partial; media?: Partial; layout?: Partial; diff --git a/app/shared/types/domain.ts b/app/shared/types/domain.ts index 6d05c24..93ee7c8 100644 --- a/app/shared/types/domain.ts +++ b/app/shared/types/domain.ts @@ -8,6 +8,8 @@ import type { ProjectId, SceneId, } from './ids'; +import type { MaterialLegend } from './materialLegend'; +import type { SceneTrap } from './sceneTraps'; export const PROJECT_SCHEMA_VERSION = 9 as const; @@ -17,6 +19,8 @@ export type ProjectMaterial = { name: string; assetId: AssetId; rotationDeg: 0 | 90 | 180 | 270; + /** Опциональная легенда (маркеры + список пунктов). */ + legend?: MaterialLegend; }; /** Группа НПС (дерево через parentId). */ @@ -156,6 +160,8 @@ export type Scene = { previewRotationDeg: 0 | 90 | 180 | 270; /** В режиме показа: сцена начинается полностью затемнённой; мастер «раскрывает» кистью. */ darkenScene: boolean; + /** Ловушки на карте (только для image-превью); расстановка в проекте. */ + traps: SceneTrap[]; media: SceneMediaRefs; settings: SceneSettings; connections: SceneId[]; diff --git a/app/shared/types/index.ts b/app/shared/types/index.ts index 5fdd0e2..c9baf26 100644 --- a/app/shared/types/index.ts +++ b/app/shared/types/index.ts @@ -1,8 +1,10 @@ export * from './domain'; export * from './effects'; export * from './ids'; +export * from './materialLegend'; export * from './materials'; export * from './npcs'; export * from './sceneDarkness'; +export * from './sceneTraps'; export * from './sceneView'; export * from './videoPlayback'; diff --git a/app/shared/types/materialLegend.ts b/app/shared/types/materialLegend.ts new file mode 100644 index 0000000..aeaa53e --- /dev/null +++ b/app/shared/types/materialLegend.ts @@ -0,0 +1,153 @@ +/** Легенда материала: пункты + маркеры на изображении. */ + +export type MaterialLegendItemId = string & { readonly __brand: 'MaterialLegendItemId' }; +export type MaterialLegendMarkerId = string & { readonly __brand: 'MaterialLegendMarkerId' }; + +export function asMaterialLegendItemId(id: string): MaterialLegendItemId { + return id as MaterialLegendItemId; +} + +export function asMaterialLegendMarkerId(id: string): MaterialLegendMarkerId { + return id as MaterialLegendMarkerId; +} + +export type MaterialLegendItem = { + id: MaterialLegendItemId; + /** Автономер (1..n), может повторяться у нескольких маркеров. */ + number: number; + text: string; +}; + +export type MaterialLegendMarker = { + id: MaterialLegendMarkerId; + number: number; + /** 0..1 относительно изображения материала. */ + nx: number; + ny: number; +}; + +export type MaterialLegend = { + enabled: boolean; + items: MaterialLegendItem[]; + markers: MaterialLegendMarker[]; +}; + +export const EMPTY_MATERIAL_LEGEND: MaterialLegend = { + enabled: false, + items: [], + markers: [], +}; + +export function clampLegendCoord(v: number): number { + if (!Number.isFinite(v)) return 0.5; + return Math.max(0, Math.min(1, v)); +} + +export function normalizeMaterialLegend(raw: unknown): MaterialLegend | undefined { + if (!raw || typeof raw !== 'object') return undefined; + const o = raw as { + enabled?: boolean; + items?: unknown[]; + markers?: unknown[]; + }; + const items: MaterialLegendItem[] = (Array.isArray(o.items) ? o.items : []) + .map((it) => { + if (!it || typeof it !== 'object') return null; + const x = it as { id?: string; number?: number; text?: string }; + if (!x.id) return null; + const number = typeof x.number === 'number' && Number.isFinite(x.number) ? Math.max(1, Math.round(x.number)) : 1; + return { + id: asMaterialLegendItemId(String(x.id)), + number, + text: typeof x.text === 'string' ? x.text : '', + }; + }) + .filter((x): x is MaterialLegendItem => Boolean(x)); + const markers: MaterialLegendMarker[] = (Array.isArray(o.markers) ? o.markers : []) + .map((m) => { + if (!m || typeof m !== 'object') return null; + const x = m as { id?: string; number?: number; nx?: number; ny?: number }; + if (!x.id) return null; + const number = typeof x.number === 'number' && Number.isFinite(x.number) ? Math.max(1, Math.round(x.number)) : 1; + return { + id: asMaterialLegendMarkerId(String(x.id)), + number, + nx: clampLegendCoord(x.nx ?? 0.5), + ny: clampLegendCoord(x.ny ?? 0.5), + }; + }) + .filter((x): x is MaterialLegendMarker => Boolean(x)); + return { + enabled: Boolean(o.enabled), + items, + markers, + }; +} + +/** Следующий автономер = max(items.number)+1 или 1. */ +export function nextLegendNumber(items: readonly MaterialLegendItem[]): number { + let max = 0; + for (const it of items) { + if (it.number > max) max = it.number; + } + return max + 1; +} + +type ContentRect = { x: number; y: number; w: number; h: number }; + +/** + * Размер неповёрнутого display-box картинки внутри AABB (`contentRect` от RotatedImage). + * Маркеры легенды хранятся в UV этого box (как в MaterialOverlay), а не в AABB. + */ +export function legendImageDisplaySize( + contentRect: ContentRect, + rotationDeg: 0 | 90 | 180 | 270, +): { iw: number; ih: number } { + if (rotationDeg === 90 || rotationDeg === 270) { + return { iw: contentRect.h, ih: contentRect.w }; + } + return { iw: contentRect.w, ih: contentRect.h }; +} + +/** Экранная точка относительно хоста → UV изображения (0..1). */ +export function hostPointToLegendImageUv( + hostX: number, + hostY: number, + contentRect: ContentRect, + rotationDeg: 0 | 90 | 180 | 270, +): { x: number; y: number } { + const { iw, ih } = legendImageDisplaySize(contentRect, rotationDeg); + if (iw < 1e-6 || ih < 1e-6) return { x: 0.5, y: 0.5 }; + const cx = contentRect.x + contentRect.w / 2; + const cy = contentRect.y + contentRect.h / 2; + const dx = hostX - cx; + const dy = hostY - cy; + const rad = (-rotationDeg * Math.PI) / 180; + const cos = Math.cos(rad); + const sin = Math.sin(rad); + const lx = dx * cos - dy * sin; + const ly = dx * sin + dy * cos; + return { + x: clampLegendCoord((lx + iw / 2) / iw), + y: clampLegendCoord((ly + ih / 2) / ih), + }; +} + +/** UV изображения → позиция центра маркера в координатах хоста (px). */ +export function legendImageUvToHostPoint( + nx: number, + ny: number, + contentRect: ContentRect, + rotationDeg: 0 | 90 | 180 | 270, +): { x: number; y: number } { + const { iw, ih } = legendImageDisplaySize(contentRect, rotationDeg); + const lx = (nx - 0.5) * iw; + const ly = (ny - 0.5) * ih; + const rad = (rotationDeg * Math.PI) / 180; + const cos = Math.cos(rad); + const sin = Math.sin(rad); + return { + x: contentRect.x + contentRect.w / 2 + (lx * cos - ly * sin), + y: contentRect.y + contentRect.h / 2 + (lx * sin + ly * cos), + }; +} diff --git a/app/shared/types/materials.ts b/app/shared/types/materials.ts index 1e4a542..e155eaf 100644 --- a/app/shared/types/materials.ts +++ b/app/shared/types/materials.ts @@ -19,6 +19,8 @@ export type MaterialsOverlayState = { revision: number; activeMaterialId: MaterialId | null; layout: MaterialsOverlayLayout; + /** Раскладка блока описаний легенды (если материал с легендой). */ + legendLayout: MaterialsOverlayLayout; zoomTool: MaterialsZoomTool; }; @@ -34,6 +36,7 @@ export type MaterialsOverlayEvent = | { kind: 'hide' } | { kind: 'toggle'; materialId: MaterialId; rotationDeg?: number } | { kind: 'layout.set'; layout: MaterialsOverlayLayout } + | { kind: 'legendLayout.set'; layout: MaterialsOverlayLayout } | { kind: 'zoomTool.set'; tool: MaterialsZoomTool } | { kind: 'zoomAt'; nx: number; ny: number }; diff --git a/app/shared/types/sceneTraps.ts b/app/shared/types/sceneTraps.ts new file mode 100644 index 0000000..da0a7bc --- /dev/null +++ b/app/shared/types/sceneTraps.ts @@ -0,0 +1,125 @@ +import type { SceneId } from './ids'; + +export const SCENE_TRAP_TYPES = [ + 'mimic', + 'explosion', + 'poison', + 'pit', + 'arrow', + 'laser', + 'freeform', +] as const; + +export type SceneTrapType = (typeof SCENE_TRAP_TYPES)[number]; + +export type SceneTrapId = string & { readonly __brand: 'SceneTrapId' }; + +export function asSceneTrapId(id: string): SceneTrapId { + return id as SceneTrapId; +} + +/** Расстановка ловушки на карте сцены (в проекте). Координаты 0..1 относительно картинки. */ +export type SceneTrap = { + id: SceneTrapId; + type: SceneTrapType; + /** Центр по X (0..1). */ + nx: number; + /** Центр по Y (0..1). */ + ny: number; + /** Размер относительно min(w,h) картинки. */ + sizeN: number; + /** Подпись для freeform (и опционально прочих). */ + label?: string; +}; + +export type SceneTrapStatus = 'inactive' | 'active' | 'disarmed'; + +export type SceneTrapRuntime = { + status: SceneTrapStatus; + /** Показана игрокам (после Проявить / Активировать / Обезвредить). */ + revealed: boolean; +}; + +export type SceneTrapsState = { + revision: number; + cacheKey: string | null; + /** runtime по id ловушки из Scene.traps */ + byId: Record; + /** + * Одноразовый VFX активации: id ловушки + token, чтобы презентация/пульт + * могли проиграть анимацию при смене revision. + */ + lastActivation: { trapId: string; token: number } | null; +}; + +export type SceneTrapsEvent = + | { kind: 'reveal'; trapId: string } + | { kind: 'activate'; trapId: string } + | { kind: 'disarm'; trapId: string } + | { kind: 'syncTrapIds'; trapIds: readonly string[] }; + +export const DEFAULT_SCENE_TRAP_SIZE_N = 0.08; + +export function isSceneTrapType(v: unknown): v is SceneTrapType { + return typeof v === 'string' && (SCENE_TRAP_TYPES as readonly string[]).includes(v); +} + +export function defaultTrapRuntime(): SceneTrapRuntime { + return { status: 'inactive', revealed: false }; +} + +export function clampTrapNorm(v: number): number { + if (!Number.isFinite(v)) return 0.5; + return Math.max(0, Math.min(1, v)); +} + +export function clampTrapSizeN(v: number): number { + if (!Number.isFinite(v)) return DEFAULT_SCENE_TRAP_SIZE_N; + return Math.max(0.02, Math.min(0.45, v)); +} + +export function normalizeSceneTrap(raw: unknown): SceneTrap | null { + if (!raw || typeof raw !== 'object') return null; + const o = raw as { + id?: string; + type?: string; + nx?: number; + ny?: number; + sizeN?: number; + label?: string; + }; + if (!o.id || !isSceneTrapType(o.type)) return null; + return { + id: asSceneTrapId(String(o.id)), + type: o.type, + nx: clampTrapNorm(o.nx ?? 0.5), + ny: clampTrapNorm(o.ny ?? 0.5), + sizeN: clampTrapSizeN(o.sizeN ?? DEFAULT_SCENE_TRAP_SIZE_N), + ...(typeof o.label === 'string' && o.label.trim() ? { label: o.label.trim() } : {}), + }; +} + +export function trapTypeLabelRu(type: SceneTrapType): string { + switch (type) { + case 'mimic': + return 'Мимик'; + case 'explosion': + return 'Взрыв'; + case 'poison': + return 'Яд'; + case 'pit': + return 'Пропасть'; + case 'arrow': + return 'Стрела'; + case 'laser': + return 'Лазер'; + case 'freeform': + return 'Свободная'; + default: { + const _x: never = type; + return String(_x); + } + } +} + +export type { SceneId }; diff --git a/vite.config.ts b/vite.config.ts index a00fac0..626746c 100644 --- a/vite.config.ts +++ b/vite.config.ts @@ -55,6 +55,7 @@ export default defineConfig(({ mode }) => { control: path.resolve(__dirname, 'app/renderer/control.html'), sceneDescription: path.resolve(__dirname, 'app/renderer/sceneDescription.html'), materials: path.resolve(__dirname, 'app/renderer/materials.html'), + sceneEditor: path.resolve(__dirname, 'app/renderer/sceneEditor.html'), npcsEditor: path.resolve(__dirname, 'app/renderer/npcsEditor.html'), npcs: path.resolve(__dirname, 'app/renderer/npcs.html'), },