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
+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));