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 <cursoragent@cursor.com>
This commit is contained in:
Ivan Fontosh
2026-07-24 16:29:43 +08:00
parent d3b1c4660d
commit 02d73ddf81
39 changed files with 2563 additions and 36 deletions
+1
View File
@@ -386,6 +386,7 @@ export async function buildProjectFromFoundryDocuments(
previewVideoAutostart: previewAssetType === 'video',
previewRotationDeg: 0,
darkenScene: false,
traps: [],
media: { videos: [], audios: audioRefs },
settings: {
autoplayVideo: previewAssetType === 'video',
+61 -3
View File
@@ -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() };
});
@@ -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 = {
+67 -4
View File
@@ -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<Project> {
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<Project> {
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));
+134
View File
@@ -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<string, SceneTrapRuntime>): Record<string, SceneTrapRuntime> {
const out: Record<string, SceneTrapRuntime> = {};
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<string, Record<string, SceneTrapRuntime>>();
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<string, SceneTrapRuntime> = {};
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<string, SceneTrapRuntime> = {};
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();
}
}
}
}
+53
View File
@@ -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<WindowKind, BrowserWindow>();
@@ -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');