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:
@@ -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