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:
@@ -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' },
|
||||
};
|
||||
|
||||
|
||||
@@ -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: [],
|
||||
|
||||
@@ -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']>;
|
||||
|
||||
@@ -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[];
|
||||
|
||||
@@ -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';
|
||||
|
||||
@@ -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),
|
||||
};
|
||||
}
|
||||
@@ -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 };
|
||||
|
||||
|
||||
@@ -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 };
|
||||
Reference in New Issue
Block a user