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
+2
View File
@@ -23,6 +23,7 @@ export type AppWindowKind =
| 'sceneDescription'
| 'materials'
| 'npcsEditor'
| 'sceneEditor'
| 'npcs';
const WINDOW_SUFFIX: Record<AppWindowKind, { ru: string; en: string }> = {
@@ -33,6 +34,7 @@ const WINDOW_SUFFIX: Record<AppWindowKind, { ru: string; en: string }> = {
sceneDescription: { ru: 'Описание сцены', en: 'Scene description' },
materials: { ru: 'Материалы', en: 'Materials' },
npcsEditor: { ru: 'НПС', en: 'NPCs' },
sceneEditor: { ru: 'Редактор сцены', en: 'Scene editor' },
npcs: { ru: 'НПС', en: 'NPCs' },
};
+1
View File
@@ -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: [],
@@ -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: [],
+35
View File
@@ -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<string, never>;
res: { ok: true };
};
[ipcChannels.windows.openSceneEditor]: {
req: Record<string, never>;
res: { ok: true };
};
[ipcChannels.windows.closeSceneEditor]: {
req: Record<string, never>;
res: { ok: true };
};
[ipcChannels.materialsOverlay.getState]: {
req: Record<string, never>;
res: { state: MaterialsOverlayState };
@@ -601,6 +626,14 @@ export type IpcInvokeMap = {
req: { event: SceneDarknessEvent };
res: { ok: true };
};
[ipcChannels.sceneTraps.getState]: {
req: Record<string, never>;
res: { state: SceneTrapsState };
};
[ipcChannels.sceneTraps.dispatch]: {
req: { event: SceneTrapsEvent };
res: { ok: true };
};
[ipcChannels.sceneView.getState]: {
req: Record<string, never>;
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<string, never>;
@@ -660,6 +694,7 @@ export type ScenePatch = {
previewVideoAutostart?: boolean;
previewRotationDeg?: 0 | 90 | 180 | 270;
darkenScene?: boolean;
traps?: SceneTrap[];
settings?: Partial<Scene['settings']>;
media?: Partial<Scene['media']>;
layout?: Partial<Scene['layout']>;
+6
View File
@@ -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[];
+2
View File
@@ -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';
+153
View File
@@ -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),
};
}
+3
View File
@@ -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 };
+125
View File
@@ -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<string, SceneTrapRuntime>;
/**
* Одноразовый 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 };