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:
@@ -386,6 +386,7 @@ export async function buildProjectFromFoundryDocuments(
|
|||||||
previewVideoAutostart: previewAssetType === 'video',
|
previewVideoAutostart: previewAssetType === 'video',
|
||||||
previewRotationDeg: 0,
|
previewRotationDeg: 0,
|
||||||
darkenScene: false,
|
darkenScene: false,
|
||||||
|
traps: [],
|
||||||
media: { videos: [], audios: audioRefs },
|
media: { videos: [], audios: audioRefs },
|
||||||
settings: {
|
settings: {
|
||||||
autoplayVideo: previewAssetType === 'video',
|
autoplayVideo: previewAssetType === 'video',
|
||||||
|
|||||||
+61
-3
@@ -21,6 +21,7 @@ import type { Project } from '../shared/types';
|
|||||||
|
|
||||||
import { EffectsStore } from './effects/effectsStore';
|
import { EffectsStore } from './effects/effectsStore';
|
||||||
import { SceneDarknessStore } from './effects/sceneDarknessStore';
|
import { SceneDarknessStore } from './effects/sceneDarknessStore';
|
||||||
|
import { SceneTrapsStore } from './sceneTraps/sceneTrapsStore';
|
||||||
import { installIpcRouter, registerHandler, setLicenseAssert } from './ipc/router';
|
import { installIpcRouter, registerHandler, setLicenseAssert } from './ipc/router';
|
||||||
import { LicenseService } from './license/licenseService';
|
import { LicenseService } from './license/licenseService';
|
||||||
import { MaterialsOverlayStore } from './materials/materialsOverlayStore';
|
import { MaterialsOverlayStore } from './materials/materialsOverlayStore';
|
||||||
@@ -50,10 +51,12 @@ import {
|
|||||||
openMaterialsWindow,
|
openMaterialsWindow,
|
||||||
openMultiWindow,
|
openMultiWindow,
|
||||||
openNpcsEditorWindow,
|
openNpcsEditorWindow,
|
||||||
|
openSceneEditorWindow,
|
||||||
openNpcsWindow,
|
openNpcsWindow,
|
||||||
openSceneDescriptionWindow,
|
openSceneDescriptionWindow,
|
||||||
closeMaterialsWindow,
|
closeMaterialsWindow,
|
||||||
closeNpcsEditorWindow,
|
closeNpcsEditorWindow,
|
||||||
|
closeSceneEditorWindow,
|
||||||
closeNpcsWindow,
|
closeNpcsWindow,
|
||||||
sendToAppWindows,
|
sendToAppWindows,
|
||||||
togglePresentationFullscreen,
|
togglePresentationFullscreen,
|
||||||
@@ -147,6 +150,7 @@ function installAppMenuForSession(): void {
|
|||||||
|
|
||||||
const effectsStore = new EffectsStore();
|
const effectsStore = new EffectsStore();
|
||||||
const sceneDarknessStore = new SceneDarknessStore();
|
const sceneDarknessStore = new SceneDarknessStore();
|
||||||
|
const sceneTrapsStore = new SceneTrapsStore();
|
||||||
const sceneViewStore = new SceneViewStore();
|
const sceneViewStore = new SceneViewStore();
|
||||||
const videoStore = new VideoPlaybackStore();
|
const videoStore = new VideoPlaybackStore();
|
||||||
const materialsOverlayStore = new MaterialsOverlayStore();
|
const materialsOverlayStore = new MaterialsOverlayStore();
|
||||||
@@ -212,6 +216,20 @@ function syncSceneDarknessForProject(project: Project): void {
|
|||||||
sceneDarknessStore.switchScene(cacheKey, enabled);
|
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 {
|
function emitVideoState(): void {
|
||||||
const state = videoStore.getState();
|
const state = videoStore.getState();
|
||||||
for (const win of BrowserWindow.getAllWindows()) {
|
for (const win of BrowserWindow.getAllWindows()) {
|
||||||
@@ -361,8 +379,12 @@ async function main() {
|
|||||||
sceneDarknessStore.resetSession();
|
sceneDarknessStore.resetSession();
|
||||||
openMultiWindow();
|
openMultiWindow();
|
||||||
const project = projectStore.getOpenProject();
|
const project = projectStore.getOpenProject();
|
||||||
if (project) syncSceneDarknessForProject(project);
|
if (project) {
|
||||||
|
syncSceneDarknessForProject(project);
|
||||||
|
syncSceneTrapsForProject(project);
|
||||||
|
}
|
||||||
emitSceneDarknessState();
|
emitSceneDarknessState();
|
||||||
|
emitSceneTrapsState();
|
||||||
return { ok: true };
|
return { ok: true };
|
||||||
});
|
});
|
||||||
registerHandler(ipcChannels.windows.closeMultiWindow, () => {
|
registerHandler(ipcChannels.windows.closeMultiWindow, () => {
|
||||||
@@ -403,6 +425,14 @@ async function main() {
|
|||||||
closeNpcsEditorWindow();
|
closeNpcsEditorWindow();
|
||||||
return { ok: true };
|
return { ok: true };
|
||||||
});
|
});
|
||||||
|
registerHandler(ipcChannels.windows.openSceneEditor, () => {
|
||||||
|
openSceneEditorWindow();
|
||||||
|
return { ok: true };
|
||||||
|
});
|
||||||
|
registerHandler(ipcChannels.windows.closeSceneEditor, () => {
|
||||||
|
closeSceneEditorWindow();
|
||||||
|
return { ok: true };
|
||||||
|
});
|
||||||
registerHandler(ipcChannels.windows.openNpcs, () => {
|
registerHandler(ipcChannels.windows.openNpcs, () => {
|
||||||
openNpcsWindow();
|
openNpcsWindow();
|
||||||
return { ok: true };
|
return { ok: true };
|
||||||
@@ -458,11 +488,13 @@ async function main() {
|
|||||||
materialsOverlayStore.clear();
|
materialsOverlayStore.clear();
|
||||||
npcsOverlayStore.clear();
|
npcsOverlayStore.clear();
|
||||||
sceneDarknessStore.resetSession();
|
sceneDarknessStore.resetSession();
|
||||||
|
sceneTrapsStore.resetSession();
|
||||||
sceneViewStore.reset();
|
sceneViewStore.reset();
|
||||||
emitEffectsState();
|
emitEffectsState();
|
||||||
emitMaterialsOverlayState();
|
emitMaterialsOverlayState();
|
||||||
emitNpcsOverlayState();
|
emitNpcsOverlayState();
|
||||||
emitSceneDarknessState();
|
emitSceneDarknessState();
|
||||||
|
emitSceneTrapsState();
|
||||||
emitSceneViewState();
|
emitSceneViewState();
|
||||||
emitSessionState();
|
emitSessionState();
|
||||||
return { ok: true };
|
return { ok: true };
|
||||||
@@ -481,11 +513,15 @@ async function main() {
|
|||||||
npcsOverlayStore.clear();
|
npcsOverlayStore.clear();
|
||||||
sceneViewStore.reset();
|
sceneViewStore.reset();
|
||||||
const project = projectStore.getOpenProject();
|
const project = projectStore.getOpenProject();
|
||||||
if (project) syncSceneDarknessForProject(project);
|
if (project) {
|
||||||
|
syncSceneDarknessForProject(project);
|
||||||
|
syncSceneTrapsForProject(project);
|
||||||
|
}
|
||||||
emitEffectsState();
|
emitEffectsState();
|
||||||
emitMaterialsOverlayState();
|
emitMaterialsOverlayState();
|
||||||
emitNpcsOverlayState();
|
emitNpcsOverlayState();
|
||||||
emitSceneDarknessState();
|
emitSceneDarknessState();
|
||||||
|
emitSceneTrapsState();
|
||||||
emitSceneViewState();
|
emitSceneViewState();
|
||||||
emitSessionState();
|
emitSessionState();
|
||||||
return { currentSceneId: projectStore.getOpenProject()?.currentSceneId ?? null };
|
return { currentSceneId: projectStore.getOpenProject()?.currentSceneId ?? null };
|
||||||
@@ -504,11 +540,15 @@ async function main() {
|
|||||||
npcsOverlayStore.clear();
|
npcsOverlayStore.clear();
|
||||||
sceneViewStore.reset();
|
sceneViewStore.reset();
|
||||||
const project = projectStore.getOpenProject();
|
const project = projectStore.getOpenProject();
|
||||||
if (project) syncSceneDarknessForProject(project);
|
if (project) {
|
||||||
|
syncSceneDarknessForProject(project);
|
||||||
|
syncSceneTrapsForProject(project);
|
||||||
|
}
|
||||||
emitEffectsState();
|
emitEffectsState();
|
||||||
emitMaterialsOverlayState();
|
emitMaterialsOverlayState();
|
||||||
emitNpcsOverlayState();
|
emitNpcsOverlayState();
|
||||||
emitSceneDarknessState();
|
emitSceneDarknessState();
|
||||||
|
emitSceneTrapsState();
|
||||||
emitSceneViewState();
|
emitSceneViewState();
|
||||||
emitSessionState();
|
emitSessionState();
|
||||||
const p = projectStore.getOpenProject();
|
const p = projectStore.getOpenProject();
|
||||||
@@ -524,6 +564,10 @@ async function main() {
|
|||||||
syncSceneDarknessForProject(project);
|
syncSceneDarknessForProject(project);
|
||||||
emitSceneDarknessState();
|
emitSceneDarknessState();
|
||||||
}
|
}
|
||||||
|
if (project && project.currentSceneId === sceneId && patch.traps !== undefined) {
|
||||||
|
syncSceneTrapsForProject(project);
|
||||||
|
emitSceneTrapsState();
|
||||||
|
}
|
||||||
emitSessionState();
|
emitSessionState();
|
||||||
return { scene: next };
|
return { scene: next };
|
||||||
});
|
});
|
||||||
@@ -627,6 +671,11 @@ async function main() {
|
|||||||
emitSessionState();
|
emitSessionState();
|
||||||
return { project };
|
return { project };
|
||||||
});
|
});
|
||||||
|
registerHandler(ipcChannels.project.setMaterialLegend, async ({ materialId, legend }) => {
|
||||||
|
const project = await projectStore.setMaterialLegend(materialId, legend);
|
||||||
|
emitSessionState();
|
||||||
|
return { project };
|
||||||
|
});
|
||||||
registerHandler(ipcChannels.project.pickMaterialImage, async () => {
|
registerHandler(ipcChannels.project.pickMaterialImage, async () => {
|
||||||
const { canceled, filePaths } = await dialog.showOpenDialog({
|
const { canceled, filePaths } = await dialog.showOpenDialog({
|
||||||
properties: ['openFile'],
|
properties: ['openFile'],
|
||||||
@@ -1099,6 +1148,15 @@ async function main() {
|
|||||||
return { ok: true };
|
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, () => {
|
registerHandler(ipcChannels.sceneView.getState, () => {
|
||||||
return { state: sceneViewStore.getState() };
|
return { state: sceneViewStore.getState() };
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -14,6 +14,7 @@ function emptyState(): MaterialsOverlayState {
|
|||||||
revision: 1,
|
revision: 1,
|
||||||
activeMaterialId: null,
|
activeMaterialId: null,
|
||||||
layout: { ...DEFAULT_MATERIALS_OVERLAY_LAYOUT },
|
layout: { ...DEFAULT_MATERIALS_OVERLAY_LAYOUT },
|
||||||
|
legendLayout: { ...DEFAULT_MATERIALS_OVERLAY_LAYOUT, cx: 0.82, cy: 0.5, scale: 0.85 },
|
||||||
zoomTool: null,
|
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 {
|
export class MaterialsOverlayStore {
|
||||||
private state: MaterialsOverlayState = emptyState();
|
private state: MaterialsOverlayState = emptyState();
|
||||||
|
|
||||||
@@ -40,6 +51,7 @@ export class MaterialsOverlayStore {
|
|||||||
revision: this.state.revision + 1,
|
revision: this.state.revision + 1,
|
||||||
activeMaterialId: null,
|
activeMaterialId: null,
|
||||||
layout: { ...DEFAULT_MATERIALS_OVERLAY_LAYOUT },
|
layout: { ...DEFAULT_MATERIALS_OVERLAY_LAYOUT },
|
||||||
|
legendLayout: initialLegendLayout(),
|
||||||
zoomTool: null,
|
zoomTool: null,
|
||||||
};
|
};
|
||||||
return this.state;
|
return this.state;
|
||||||
@@ -54,6 +66,7 @@ export class MaterialsOverlayStore {
|
|||||||
revision: this.state.revision + 1,
|
revision: this.state.revision + 1,
|
||||||
activeMaterialId: event.materialId,
|
activeMaterialId: event.materialId,
|
||||||
layout: initialLayout(event.rotationDeg),
|
layout: initialLayout(event.rotationDeg),
|
||||||
|
legendLayout: initialLegendLayout(),
|
||||||
zoomTool: this.state.zoomTool,
|
zoomTool: this.state.zoomTool,
|
||||||
};
|
};
|
||||||
return this.state;
|
return this.state;
|
||||||
@@ -65,6 +78,7 @@ export class MaterialsOverlayStore {
|
|||||||
revision: this.state.revision + 1,
|
revision: this.state.revision + 1,
|
||||||
activeMaterialId: event.materialId,
|
activeMaterialId: event.materialId,
|
||||||
layout: initialLayout(event.rotationDeg),
|
layout: initialLayout(event.rotationDeg),
|
||||||
|
legendLayout: initialLegendLayout(),
|
||||||
zoomTool: this.state.zoomTool,
|
zoomTool: this.state.zoomTool,
|
||||||
};
|
};
|
||||||
return this.state;
|
return this.state;
|
||||||
@@ -81,6 +95,19 @@ export class MaterialsOverlayStore {
|
|||||||
};
|
};
|
||||||
return this.state;
|
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': {
|
case 'zoomTool.set': {
|
||||||
const tool: MaterialsZoomTool = event.tool;
|
const tool: MaterialsZoomTool = event.tool;
|
||||||
this.state = {
|
this.state = {
|
||||||
|
|||||||
@@ -34,6 +34,7 @@ import {
|
|||||||
stripProjectZipExtension,
|
stripProjectZipExtension,
|
||||||
} from '../../shared/project/projectZipExtension';
|
} from '../../shared/project/projectZipExtension';
|
||||||
import type {
|
import type {
|
||||||
|
MaterialLegend,
|
||||||
MediaAsset,
|
MediaAsset,
|
||||||
MediaAssetType,
|
MediaAssetType,
|
||||||
NpcBinding,
|
NpcBinding,
|
||||||
@@ -46,8 +47,11 @@ import type {
|
|||||||
SceneGraphEdge,
|
SceneGraphEdge,
|
||||||
SceneGraphNode,
|
SceneGraphNode,
|
||||||
SceneId,
|
SceneId,
|
||||||
|
SceneTrap,
|
||||||
} from '../../shared/types';
|
} from '../../shared/types';
|
||||||
import { PROJECT_SCHEMA_VERSION } 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 type { AssetId, GraphNodeId, MaterialId, NpcGroupId, NpcId, NpcRelationId } from '../../shared/types/ids';
|
||||||
import {
|
import {
|
||||||
asAssetId,
|
asAssetId,
|
||||||
@@ -611,10 +615,12 @@ export class ZipProjectStore {
|
|||||||
previewVideoAutostart: false,
|
previewVideoAutostart: false,
|
||||||
previewRotationDeg: 0,
|
previewRotationDeg: 0,
|
||||||
darkenScene: false,
|
darkenScene: false,
|
||||||
|
traps: [],
|
||||||
} satisfies Scene);
|
} satisfies Scene);
|
||||||
|
|
||||||
const next: Scene = {
|
const next: Scene = {
|
||||||
...base,
|
...base,
|
||||||
|
traps: base.traps ?? [],
|
||||||
...(patch.title !== undefined ? { title: patch.title } : null),
|
...(patch.title !== undefined ? { title: patch.title } : null),
|
||||||
...(patch.description !== undefined ? { description: patch.description } : null),
|
...(patch.description !== undefined ? { description: patch.description } : null),
|
||||||
...(patch.previewAssetId !== undefined ? { previewAssetId: patch.previewAssetId } : null),
|
...(patch.previewAssetId !== undefined ? { previewAssetId: patch.previewAssetId } : null),
|
||||||
@@ -627,6 +633,13 @@ export class ZipProjectStore {
|
|||||||
: null),
|
: null),
|
||||||
...(patch.previewRotationDeg !== undefined ? { previewRotationDeg: patch.previewRotationDeg } : null),
|
...(patch.previewRotationDeg !== undefined ? { previewRotationDeg: patch.previewRotationDeg } : null),
|
||||||
...(patch.darkenScene !== undefined ? { darkenScene: patch.darkenScene } : 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.settings ? { settings: { ...base.settings, ...patch.settings } } : null),
|
||||||
...(patch.media ? { media: { ...base.media, ...patch.media } } : null),
|
...(patch.media ? { media: { ...base.media, ...patch.media } } : null),
|
||||||
...(patch.layout ? { layout: { ...base.layout, ...patch.layout } } : null),
|
...(patch.layout ? { layout: { ...base.layout, ...patch.layout } } : null),
|
||||||
@@ -1163,6 +1176,7 @@ export class ZipProjectStore {
|
|||||||
name,
|
name,
|
||||||
assetId,
|
assetId,
|
||||||
rotationDeg: prev.rotationDeg ?? 0,
|
rotationDeg: prev.rotationDeg ?? 0,
|
||||||
|
...(prev.legend ? { legend: prev.legend } : {}),
|
||||||
};
|
};
|
||||||
} else {
|
} else {
|
||||||
if (!nextAssetId) throw new Error('Material image is required');
|
if (!nextAssetId) throw new Error('Material image is required');
|
||||||
@@ -1198,6 +1212,30 @@ export class ZipProjectStore {
|
|||||||
return latest;
|
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> {
|
async deleteMaterial(materialId: MaterialId): Promise<Project> {
|
||||||
const open = this.openProject;
|
const open = this.openProject;
|
||||||
if (!open) throw new Error('No open project');
|
if (!open) throw new Error('No open project');
|
||||||
@@ -2270,6 +2308,10 @@ function normalizeScene(s: Scene): Scene {
|
|||||||
const previewThumbAssetId =
|
const previewThumbAssetId =
|
||||||
(s as unknown as { previewThumbAssetId?: AssetId | null }).previewThumbAssetId ?? null;
|
(s as unknown as { previewThumbAssetId?: AssetId | null }).previewThumbAssetId ?? null;
|
||||||
const darkenScene = Boolean((s as unknown as { darkenScene?: boolean }).darkenScene);
|
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 rawAudios = Array.isArray(raw.audios) ? raw.audios : [];
|
||||||
const audios = rawAudios
|
const audios = rawAudios
|
||||||
@@ -2298,6 +2340,7 @@ function normalizeScene(s: Scene): Scene {
|
|||||||
previewVideoAutostart,
|
previewVideoAutostart,
|
||||||
previewRotationDeg,
|
previewRotationDeg,
|
||||||
darkenScene,
|
darkenScene,
|
||||||
|
traps,
|
||||||
layout: layoutIn ?? { x: 0, y: 0 },
|
layout: layoutIn ?? { x: 0, y: 0 },
|
||||||
media: {
|
media: {
|
||||||
videos: raw.videos ?? [],
|
videos: raw.videos ?? [],
|
||||||
@@ -2342,17 +2385,37 @@ function normalizeProject(p: Project): Project {
|
|||||||
const materials = (Array.isArray(rawMaterials) ? rawMaterials : [])
|
const materials = (Array.isArray(rawMaterials) ? rawMaterials : [])
|
||||||
.map((m) => {
|
.map((m) => {
|
||||||
if (!m || typeof m !== 'object') return null;
|
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;
|
if (!obj.id || !obj.assetId || typeof obj.name !== 'string') return null;
|
||||||
const name = obj.name.trim();
|
const name = obj.name.trim();
|
||||||
if (!name) return null;
|
if (!name) return null;
|
||||||
const rot = obj.rotationDeg;
|
const rot = obj.rotationDeg;
|
||||||
const rotationDeg: 0 | 90 | 180 | 270 = rot === 90 || rot === 180 || rot === 270 ? rot : 0;
|
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(
|
.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 npcGroups = normalizeNpcGroups((p as unknown as { npcGroups?: unknown }).npcGroups);
|
||||||
const groupIdSet = new Set(npcGroups.map((g) => g.id));
|
const groupIdSet = new Set(npcGroups.map((g) => g.id));
|
||||||
|
|||||||
@@ -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();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -17,6 +17,7 @@ export type WindowKind =
|
|||||||
| 'sceneDescription'
|
| 'sceneDescription'
|
||||||
| 'materials'
|
| 'materials'
|
||||||
| 'npcsEditor'
|
| 'npcsEditor'
|
||||||
|
| 'sceneEditor'
|
||||||
| 'npcs';
|
| 'npcs';
|
||||||
|
|
||||||
/** Окна, которые реально слушают session.stateChanged (редактор синхронизируется через invoke). */
|
/** Окна, которые реально слушают session.stateChanged (редактор синхронизируется через invoke). */
|
||||||
@@ -26,6 +27,7 @@ export const SESSION_STATE_WINDOW_KINDS: readonly WindowKind[] = [
|
|||||||
'materials',
|
'materials',
|
||||||
'npcs',
|
'npcs',
|
||||||
'npcsEditor',
|
'npcsEditor',
|
||||||
|
'sceneEditor',
|
||||||
] as const;
|
] as const;
|
||||||
|
|
||||||
const windows = new Map<WindowKind, BrowserWindow>();
|
const windows = new Map<WindowKind, BrowserWindow>();
|
||||||
@@ -122,6 +124,8 @@ function pageNameForKind(kind: WindowKind): string {
|
|||||||
return 'materials.html';
|
return 'materials.html';
|
||||||
case 'npcsEditor':
|
case 'npcsEditor':
|
||||||
return 'npcsEditor.html';
|
return 'npcsEditor.html';
|
||||||
|
case 'sceneEditor':
|
||||||
|
return 'sceneEditor.html';
|
||||||
case 'npcs':
|
case 'npcs':
|
||||||
return 'npcs.html';
|
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 === 'sceneDescription') return { width: 720, height: 640 };
|
||||||
if (kind === 'materials') return { width: MATERIALS_WINDOW_WIDTH, height: MATERIALS_WINDOW_HEIGHT };
|
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 === '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 };
|
if (kind === 'npcs') return { width: NPCS_WINDOW_WIDTH, height: NPCS_WINDOW_HEIGHT };
|
||||||
return { width: 1280, height: 800 };
|
return { width: 1280, height: 800 };
|
||||||
}
|
}
|
||||||
@@ -229,6 +234,15 @@ function createWindow(kind: WindowKind, opts?: CreateWindowOpts): BrowserWindow
|
|||||||
autoHideMenuBar: true,
|
autoHideMenuBar: true,
|
||||||
}
|
}
|
||||||
: {}),
|
: {}),
|
||||||
|
...(kind === 'sceneEditor'
|
||||||
|
? {
|
||||||
|
width: 1280,
|
||||||
|
height: 800,
|
||||||
|
minWidth: 960,
|
||||||
|
minHeight: 600,
|
||||||
|
autoHideMenuBar: true,
|
||||||
|
}
|
||||||
|
: {}),
|
||||||
...(kind === 'npcs'
|
...(kind === 'npcs'
|
||||||
? {
|
? {
|
||||||
width: NPCS_WINDOW_WIDTH,
|
width: NPCS_WINDOW_WIDTH,
|
||||||
@@ -267,6 +281,7 @@ function createWindow(kind: WindowKind, opts?: CreateWindowOpts): BrowserWindow
|
|||||||
kind === 'sceneDescription' ||
|
kind === 'sceneDescription' ||
|
||||||
kind === 'materials' ||
|
kind === 'materials' ||
|
||||||
kind === 'npcsEditor' ||
|
kind === 'npcsEditor' ||
|
||||||
|
kind === 'sceneEditor' ||
|
||||||
kind === 'npcs'
|
kind === 'npcs'
|
||||||
) {
|
) {
|
||||||
win.setMenuBarVisibility(false);
|
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 {
|
export function closeNpcsWindow(): void {
|
||||||
const win = windows.get('npcs');
|
const win = windows.get('npcs');
|
||||||
if (win && !win.isDestroyed()) {
|
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 {
|
export function openNpcsWindow(): void {
|
||||||
const existing = windows.get('npcs');
|
const existing = windows.get('npcs');
|
||||||
|
|||||||
@@ -27,12 +27,15 @@ import { SceneDarknessOverlay } from '../shared/effects/SceneDarknessOverlay';
|
|||||||
import { useEffectsState } from '../shared/effects/useEffectsState';
|
import { useEffectsState } from '../shared/effects/useEffectsState';
|
||||||
import { useSceneDarknessState } from '../shared/effects/useSceneDarknessState';
|
import { useSceneDarknessState } from '../shared/effects/useSceneDarknessState';
|
||||||
import { DEFAULT_MATERIALS_OVERLAY_LAYOUT, DEFAULT_NPCS_OVERLAY_LAYOUT } from '../../shared/types';
|
import { DEFAULT_MATERIALS_OVERLAY_LAYOUT, DEFAULT_NPCS_OVERLAY_LAYOUT } from '../../shared/types';
|
||||||
|
import { MaterialLegendPanel } from '../shared/materials/MaterialLegendPanel';
|
||||||
import { MaterialOverlay } from '../shared/materials/MaterialOverlay';
|
import { MaterialOverlay } from '../shared/materials/MaterialOverlay';
|
||||||
import { useMaterialsOverlayState } from '../shared/materials/useMaterialsOverlayState';
|
import { useMaterialsOverlayState } from '../shared/materials/useMaterialsOverlayState';
|
||||||
import { NpcsSceneOverlay } from '../shared/npcs/NpcsSceneOverlay';
|
import { NpcsSceneOverlay } from '../shared/npcs/NpcsSceneOverlay';
|
||||||
import { useNpcsOverlayState } from '../shared/npcs/useNpcsOverlayState';
|
import { useNpcsOverlayState } from '../shared/npcs/useNpcsOverlayState';
|
||||||
import { SceneOverlayHost } from '../shared/sceneOverlay/SceneOverlayHost';
|
import { SceneOverlayHost } from '../shared/sceneOverlay/SceneOverlayHost';
|
||||||
import { useSceneViewState } from '../shared/sceneView/useSceneViewState';
|
import { useSceneViewState } from '../shared/sceneView/useSceneViewState';
|
||||||
|
import { SceneTrapsOverlay } from '../shared/traps/SceneTrapsOverlay';
|
||||||
|
import { useSceneTrapsState } from '../shared/traps/useSceneTrapsState';
|
||||||
import { Button } from '../shared/ui/controls';
|
import { Button } from '../shared/ui/controls';
|
||||||
import { Surface } from '../shared/ui/Surface';
|
import { Surface } from '../shared/ui/Surface';
|
||||||
import { useAssetUrl } from '../shared/useAssetImageUrl';
|
import { useAssetUrl } from '../shared/useAssetImageUrl';
|
||||||
@@ -119,6 +122,7 @@ export function ControlApp() {
|
|||||||
const [fxState, fx] = useEffectsState();
|
const [fxState, fx] = useEffectsState();
|
||||||
const [effectsSfxGainUi, setEffectsSfxGainUi] = useState(() => getEffectsSfxGain());
|
const [effectsSfxGainUi, setEffectsSfxGainUi] = useState(() => getEffectsSfxGain());
|
||||||
const [sdState, sd] = useSceneDarknessState();
|
const [sdState, sd] = useSceneDarknessState();
|
||||||
|
const [sceneTraps, sceneTrapsApi] = useSceneTrapsState();
|
||||||
const [sceneView, sceneViewApi] = useSceneViewState();
|
const [sceneView, sceneViewApi] = useSceneViewState();
|
||||||
const [sceneViewDraft, setSceneViewDraft] = useState<SceneViewCamera | null>(null);
|
const [sceneViewDraft, setSceneViewDraft] = useState<SceneViewCamera | null>(null);
|
||||||
const [materialsOverlay, materialsApi] = useMaterialsOverlayState();
|
const [materialsOverlay, materialsApi] = useMaterialsOverlayState();
|
||||||
@@ -1818,6 +1822,17 @@ export function ControlApp() {
|
|||||||
clearDraftFromPixi();
|
clearDraftFromPixi();
|
||||||
}}
|
}}
|
||||||
/>
|
/>
|
||||||
|
{previewContentRect ? (
|
||||||
|
<SceneTrapsOverlay
|
||||||
|
traps={currentScene?.traps ?? []}
|
||||||
|
session={sceneTraps}
|
||||||
|
viewport={previewContentRect}
|
||||||
|
mode="control"
|
||||||
|
onReveal={(trapId) => void sceneTrapsApi.dispatch({ kind: 'reveal', trapId })}
|
||||||
|
onActivate={(trapId) => void sceneTrapsApi.dispatch({ kind: 'activate', trapId })}
|
||||||
|
onDisarm={(trapId) => void sceneTrapsApi.dispatch({ kind: 'disarm', trapId })}
|
||||||
|
/>
|
||||||
|
) : null}
|
||||||
</>
|
</>
|
||||||
) : null}
|
) : null}
|
||||||
{(() => {
|
{(() => {
|
||||||
@@ -1893,6 +1908,28 @@ export function ControlApp() {
|
|||||||
onLayoutChange={(layout) => {
|
onLayoutChange={(layout) => {
|
||||||
void materialsApi.dispatch({ kind: 'layout.set', layout });
|
void materialsApi.dispatch({ kind: 'layout.set', layout });
|
||||||
}}
|
}}
|
||||||
|
legendMarkers={
|
||||||
|
activeMaterial.legend?.enabled
|
||||||
|
? (activeMaterial.legend.markers ?? [])
|
||||||
|
: undefined
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
) : null}
|
||||||
|
{showMaterial && activeMaterial?.legend?.enabled ? (
|
||||||
|
<MaterialLegendPanel
|
||||||
|
legend={activeMaterial.legend}
|
||||||
|
layout={
|
||||||
|
materialsOverlay?.legendLayout ?? {
|
||||||
|
...DEFAULT_MATERIALS_OVERLAY_LAYOUT,
|
||||||
|
cx: 0.82,
|
||||||
|
cy: 0.5,
|
||||||
|
scale: 0.85,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
editable
|
||||||
|
onLayoutChange={(layout) => {
|
||||||
|
void materialsApi.dispatch({ kind: 'legendLayout.set', layout });
|
||||||
|
}}
|
||||||
/>
|
/>
|
||||||
) : null}
|
) : null}
|
||||||
{showNpcs ? (
|
{showNpcs ? (
|
||||||
|
|||||||
@@ -1564,6 +1564,16 @@ export function EditorApp() {
|
|||||||
});
|
});
|
||||||
});
|
});
|
||||||
}}
|
}}
|
||||||
|
onLegendChange={async (materialId, legend) => {
|
||||||
|
try {
|
||||||
|
await actions.setMaterialLegend(materialId, legend);
|
||||||
|
} catch (e) {
|
||||||
|
setAppNotice({
|
||||||
|
title: t('common.error'),
|
||||||
|
message: e instanceof Error ? e.message : String(e),
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}}
|
||||||
/>
|
/>
|
||||||
<MaterialEditModal
|
<MaterialEditModal
|
||||||
open={materialEdit !== null}
|
open={materialEdit !== null}
|
||||||
@@ -2599,6 +2609,14 @@ function SceneInspector({
|
|||||||
/>
|
/>
|
||||||
<span className={styles.spanSm}>{t('scene.darkenScene')}</span>
|
<span className={styles.spanSm}>{t('scene.darkenScene')}</span>
|
||||||
</label>
|
</label>
|
||||||
|
<div className={styles.spacer6} />
|
||||||
|
<Button
|
||||||
|
onClick={() => {
|
||||||
|
void getDndApi().invoke(ipcChannels.windows.openSceneEditor, {});
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
Редактор сцены
|
||||||
|
</Button>
|
||||||
</>
|
</>
|
||||||
) : null}
|
) : null}
|
||||||
<div className={styles.spacer6} />
|
<div className={styles.spacer6} />
|
||||||
|
|||||||
@@ -0,0 +1,137 @@
|
|||||||
|
.legendBlock {
|
||||||
|
display: grid;
|
||||||
|
gap: 10px;
|
||||||
|
min-width: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.legendBlockLarge {
|
||||||
|
gap: 12px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.row {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 8px;
|
||||||
|
flex-wrap: wrap;
|
||||||
|
}
|
||||||
|
|
||||||
|
.hint {
|
||||||
|
font-size: 12px;
|
||||||
|
opacity: 0.7;
|
||||||
|
line-height: 1.35;
|
||||||
|
}
|
||||||
|
|
||||||
|
.map {
|
||||||
|
position: relative;
|
||||||
|
width: 100%;
|
||||||
|
max-height: 280px;
|
||||||
|
overflow: hidden;
|
||||||
|
border-radius: 10px;
|
||||||
|
border: 1px solid var(--stroke, #333);
|
||||||
|
background: #0a0b0e;
|
||||||
|
}
|
||||||
|
|
||||||
|
.mapLarge {
|
||||||
|
max-height: none;
|
||||||
|
height: min(48vh, 440px);
|
||||||
|
min-height: 260px;
|
||||||
|
flex: 0 0 auto;
|
||||||
|
touch-action: none;
|
||||||
|
cursor: grab;
|
||||||
|
}
|
||||||
|
|
||||||
|
.mapIdle {
|
||||||
|
display: grid;
|
||||||
|
place-items: center;
|
||||||
|
}
|
||||||
|
|
||||||
|
.mapEmpty {
|
||||||
|
color: var(--text2);
|
||||||
|
font-size: 13px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.mapImg {
|
||||||
|
display: block;
|
||||||
|
width: 100%;
|
||||||
|
height: 100%;
|
||||||
|
max-height: inherit;
|
||||||
|
object-fit: contain;
|
||||||
|
user-select: none;
|
||||||
|
pointer-events: none;
|
||||||
|
margin: 0 auto;
|
||||||
|
}
|
||||||
|
|
||||||
|
.marker {
|
||||||
|
position: absolute;
|
||||||
|
transform: translate(-50%, -50%);
|
||||||
|
width: 24px;
|
||||||
|
height: 24px;
|
||||||
|
border-radius: 50%;
|
||||||
|
background: #1e3a5f;
|
||||||
|
border: 2px solid #f5c542;
|
||||||
|
color: #fff;
|
||||||
|
font-size: 11px;
|
||||||
|
font-weight: 800;
|
||||||
|
display: grid;
|
||||||
|
place-items: center;
|
||||||
|
cursor: grab;
|
||||||
|
user-select: none;
|
||||||
|
z-index: 2;
|
||||||
|
touch-action: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
.mapHint {
|
||||||
|
position: absolute;
|
||||||
|
left: 12px;
|
||||||
|
bottom: 12px;
|
||||||
|
z-index: 3;
|
||||||
|
font-size: 12px;
|
||||||
|
padding: 6px 10px;
|
||||||
|
border-radius: 8px;
|
||||||
|
background: rgba(0, 0, 0, 0.65);
|
||||||
|
color: rgba(255, 255, 255, 0.85);
|
||||||
|
pointer-events: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
.mapHintZoom {
|
||||||
|
position: absolute;
|
||||||
|
right: 12px;
|
||||||
|
bottom: 12px;
|
||||||
|
z-index: 3;
|
||||||
|
font-size: 11px;
|
||||||
|
padding: 5px 8px;
|
||||||
|
border-radius: 8px;
|
||||||
|
background: rgba(0, 0, 0, 0.55);
|
||||||
|
color: rgba(255, 255, 255, 0.75);
|
||||||
|
pointer-events: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
.itemRow {
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: 36px 1fr auto;
|
||||||
|
gap: 8px;
|
||||||
|
align-items: center;
|
||||||
|
padding: 6px 8px;
|
||||||
|
border-radius: 8px;
|
||||||
|
border: 1px solid transparent;
|
||||||
|
cursor: pointer;
|
||||||
|
}
|
||||||
|
|
||||||
|
.itemRowActive {
|
||||||
|
border-color: color-mix(in srgb, var(--color-accent, #c9a227) 70%, transparent);
|
||||||
|
background: color-mix(in srgb, var(--color-accent, #c9a227) 12%, transparent);
|
||||||
|
}
|
||||||
|
|
||||||
|
.num {
|
||||||
|
font-weight: 800;
|
||||||
|
text-align: center;
|
||||||
|
}
|
||||||
|
|
||||||
|
.itemDrag {
|
||||||
|
cursor: pointer;
|
||||||
|
opacity: 0.7;
|
||||||
|
font-size: 12px;
|
||||||
|
border: none;
|
||||||
|
background: transparent;
|
||||||
|
color: inherit;
|
||||||
|
}
|
||||||
@@ -0,0 +1,474 @@
|
|||||||
|
import React, { useEffect, useMemo, useRef, useState } from 'react';
|
||||||
|
|
||||||
|
import type { MaterialLegend, MaterialLegendItem, MaterialLegendMarker } from '../../shared/types';
|
||||||
|
import {
|
||||||
|
asMaterialLegendItemId,
|
||||||
|
asMaterialLegendMarkerId,
|
||||||
|
EMPTY_MATERIAL_LEGEND,
|
||||||
|
hostPointToLegendImageUv,
|
||||||
|
legendImageUvToHostPoint,
|
||||||
|
nextLegendNumber,
|
||||||
|
} from '../../shared/types/materialLegend';
|
||||||
|
import {
|
||||||
|
DEFAULT_SCENE_VIEW_CAMERA,
|
||||||
|
sceneViewPanBy,
|
||||||
|
sceneViewZoomAt,
|
||||||
|
type SceneViewCamera,
|
||||||
|
} from '../../shared/types/sceneView';
|
||||||
|
import { RotatedImage } from '../shared/RotatedImage';
|
||||||
|
import { Button, Input } from '../shared/ui/controls';
|
||||||
|
|
||||||
|
import styles from './MaterialLegendEditor.module.css';
|
||||||
|
|
||||||
|
type Props = {
|
||||||
|
legend: MaterialLegend | undefined;
|
||||||
|
previewUrl: string | null;
|
||||||
|
rotationDeg?: 0 | 90 | 180 | 270;
|
||||||
|
/** Крупная карта (окно «Материалы»). */
|
||||||
|
largeMap?: boolean;
|
||||||
|
onChange: (legend: MaterialLegend) => void;
|
||||||
|
};
|
||||||
|
|
||||||
|
type DragMode =
|
||||||
|
| { kind: 'pan'; lastX: number; lastY: number }
|
||||||
|
| { kind: 'marker'; id: string }
|
||||||
|
| null;
|
||||||
|
|
||||||
|
const PERSIST_DEBOUNCE_MS = 180;
|
||||||
|
|
||||||
|
function rid(prefix: string): string {
|
||||||
|
return `${prefix}_${Math.random().toString(36).slice(2, 10)}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
function cloneLegend(legend: MaterialLegend | undefined): MaterialLegend {
|
||||||
|
const base = legend ?? EMPTY_MATERIAL_LEGEND;
|
||||||
|
return {
|
||||||
|
enabled: base.enabled,
|
||||||
|
items: base.items.map((it) => ({ ...it })),
|
||||||
|
markers: base.markers.map((m) => ({ ...m })),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
export function MaterialLegendEditor({
|
||||||
|
legend,
|
||||||
|
previewUrl,
|
||||||
|
rotationDeg = 0,
|
||||||
|
largeMap = false,
|
||||||
|
onChange,
|
||||||
|
}: Props) {
|
||||||
|
const [draft, setDraft] = useState<MaterialLegend>(() => cloneLegend(legend));
|
||||||
|
const [activeItemId, setActiveItemId] = useState<string | null>(null);
|
||||||
|
const [view, setView] = useState<SceneViewCamera>(DEFAULT_SCENE_VIEW_CAMERA);
|
||||||
|
const [contentRect, setContentRect] = useState<{ x: number; y: number; w: number; h: number } | null>(
|
||||||
|
null,
|
||||||
|
);
|
||||||
|
|
||||||
|
const mapRef = useRef<HTMLDivElement | null>(null);
|
||||||
|
const dragRef = useRef<DragMode>(null);
|
||||||
|
const skipMapClickRef = useRef(false);
|
||||||
|
const spaceDownRef = useRef(false);
|
||||||
|
const draftRef = useRef(draft);
|
||||||
|
draftRef.current = draft;
|
||||||
|
const onChangeRef = useRef(onChange);
|
||||||
|
onChangeRef.current = onChange;
|
||||||
|
const saveTimerRef = useRef(0);
|
||||||
|
const dirtyRef = useRef(false);
|
||||||
|
|
||||||
|
const flushPersist = () => {
|
||||||
|
if (saveTimerRef.current) {
|
||||||
|
window.clearTimeout(saveTimerRef.current);
|
||||||
|
saveTimerRef.current = 0;
|
||||||
|
}
|
||||||
|
if (!dirtyRef.current) return;
|
||||||
|
dirtyRef.current = false;
|
||||||
|
onChangeRef.current(draftRef.current);
|
||||||
|
};
|
||||||
|
|
||||||
|
const schedulePersist = () => {
|
||||||
|
dirtyRef.current = true;
|
||||||
|
if (saveTimerRef.current) window.clearTimeout(saveTimerRef.current);
|
||||||
|
saveTimerRef.current = window.setTimeout(() => {
|
||||||
|
saveTimerRef.current = 0;
|
||||||
|
dirtyRef.current = false;
|
||||||
|
onChangeRef.current(draftRef.current);
|
||||||
|
}, PERSIST_DEBOUNCE_MS);
|
||||||
|
};
|
||||||
|
|
||||||
|
const applyDraft = (next: MaterialLegend) => {
|
||||||
|
draftRef.current = next;
|
||||||
|
setDraft(next);
|
||||||
|
schedulePersist();
|
||||||
|
};
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
return () => {
|
||||||
|
if (saveTimerRef.current) window.clearTimeout(saveTimerRef.current);
|
||||||
|
if (dirtyRef.current) onChangeRef.current(draftRef.current);
|
||||||
|
};
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
flushPersist();
|
||||||
|
setDraft(cloneLegend(legend));
|
||||||
|
dirtyRef.current = false;
|
||||||
|
setActiveItemId(null);
|
||||||
|
setView(DEFAULT_SCENE_VIEW_CAMERA);
|
||||||
|
// eslint-disable-next-line react-hooks/exhaustive-deps -- reset only when switching material image
|
||||||
|
}, [previewUrl]);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (activeItemId && !draft.items.some((i) => i.id === activeItemId)) {
|
||||||
|
setActiveItemId(null);
|
||||||
|
}
|
||||||
|
}, [activeItemId, draft.items]);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (!largeMap) return;
|
||||||
|
const onKeyDown = (e: KeyboardEvent) => {
|
||||||
|
if (e.code === 'Space') spaceDownRef.current = true;
|
||||||
|
};
|
||||||
|
const onKeyUp = (e: KeyboardEvent) => {
|
||||||
|
if (e.code === 'Space') spaceDownRef.current = false;
|
||||||
|
};
|
||||||
|
window.addEventListener('keydown', onKeyDown);
|
||||||
|
window.addEventListener('keyup', onKeyUp);
|
||||||
|
return () => {
|
||||||
|
window.removeEventListener('keydown', onKeyDown);
|
||||||
|
window.removeEventListener('keyup', onKeyUp);
|
||||||
|
};
|
||||||
|
}, [largeMap]);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (!largeMap) return;
|
||||||
|
const host = mapRef.current;
|
||||||
|
if (!host) return;
|
||||||
|
const nativeWheel = (e: WheelEvent) => {
|
||||||
|
e.preventDefault();
|
||||||
|
const factor = e.deltaY < 0 ? 1.12 : 1 / 1.12;
|
||||||
|
const cr = contentRect;
|
||||||
|
if (!cr) {
|
||||||
|
setView((v) => {
|
||||||
|
const nextScale = Math.max(1, Math.min(8, v.scale * factor));
|
||||||
|
if (nextScale <= 1.001) return { ...DEFAULT_SCENE_VIEW_CAMERA };
|
||||||
|
return { ...v, scale: nextScale };
|
||||||
|
});
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const r = host.getBoundingClientRect();
|
||||||
|
setView((v) => {
|
||||||
|
const containW = cr.w / Math.max(1e-6, v.scale);
|
||||||
|
const containH = cr.h / Math.max(1e-6, v.scale);
|
||||||
|
return sceneViewZoomAt(v, {
|
||||||
|
hostW: r.width,
|
||||||
|
hostH: r.height,
|
||||||
|
containW,
|
||||||
|
containH,
|
||||||
|
hostX: e.clientX - r.left,
|
||||||
|
hostY: e.clientY - r.top,
|
||||||
|
factor,
|
||||||
|
});
|
||||||
|
});
|
||||||
|
};
|
||||||
|
host.addEventListener('wheel', nativeWheel, { passive: false });
|
||||||
|
return () => host.removeEventListener('wheel', nativeWheel);
|
||||||
|
}, [contentRect, largeMap, previewUrl]);
|
||||||
|
|
||||||
|
const activeItem = useMemo(
|
||||||
|
() => (activeItemId ? (draft.items.find((i) => i.id === activeItemId) ?? null) : null),
|
||||||
|
[activeItemId, draft.items],
|
||||||
|
);
|
||||||
|
|
||||||
|
const setEnabled = (enabled: boolean) => {
|
||||||
|
applyDraft({ ...draftRef.current, enabled });
|
||||||
|
};
|
||||||
|
|
||||||
|
const updateItems = (items: MaterialLegendItem[]) => {
|
||||||
|
applyDraft({ ...draftRef.current, enabled: true, items });
|
||||||
|
};
|
||||||
|
|
||||||
|
const updateMarkers = (markers: MaterialLegendMarker[]) => {
|
||||||
|
applyDraft({ ...draftRef.current, enabled: true, markers });
|
||||||
|
};
|
||||||
|
|
||||||
|
const addItem = () => {
|
||||||
|
const cur = draftRef.current;
|
||||||
|
const number = nextLegendNumber(cur.items);
|
||||||
|
const item: MaterialLegendItem = {
|
||||||
|
id: asMaterialLegendItemId(rid('li')),
|
||||||
|
number,
|
||||||
|
text: '',
|
||||||
|
};
|
||||||
|
updateItems([...cur.items, item]);
|
||||||
|
setActiveItemId(item.id);
|
||||||
|
};
|
||||||
|
|
||||||
|
const placeMarker = (nx: number, ny: number, number: number) => {
|
||||||
|
updateMarkers([
|
||||||
|
...draftRef.current.markers,
|
||||||
|
{
|
||||||
|
id: asMaterialLegendMarkerId(rid('lm')),
|
||||||
|
number,
|
||||||
|
nx,
|
||||||
|
ny,
|
||||||
|
},
|
||||||
|
]);
|
||||||
|
};
|
||||||
|
|
||||||
|
const toNorm = (clientX: number, clientY: number): { x: number; y: number } | null => {
|
||||||
|
const el = mapRef.current;
|
||||||
|
if (!el) return null;
|
||||||
|
const r = el.getBoundingClientRect();
|
||||||
|
// UV неповёрнутого изображения — тот же space, что у MaterialOverlay на пульте/презентации.
|
||||||
|
if (largeMap && contentRect && contentRect.w > 1 && contentRect.h > 1) {
|
||||||
|
return hostPointToLegendImageUv(
|
||||||
|
clientX - r.left,
|
||||||
|
clientY - r.top,
|
||||||
|
contentRect,
|
||||||
|
rotationDeg,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
const img = el.querySelector('img');
|
||||||
|
if (!img) return null;
|
||||||
|
const ir = img.getBoundingClientRect();
|
||||||
|
if (ir.width < 1 || ir.height < 1) return null;
|
||||||
|
return {
|
||||||
|
x: Math.max(0, Math.min(1, (clientX - ir.left) / ir.width)),
|
||||||
|
y: Math.max(0, Math.min(1, (clientY - ir.top) / ir.height)),
|
||||||
|
};
|
||||||
|
};
|
||||||
|
|
||||||
|
const onMapPointerDown = (e: React.PointerEvent) => {
|
||||||
|
if (!largeMap) return;
|
||||||
|
if (e.button === 1 || (e.button === 0 && spaceDownRef.current)) {
|
||||||
|
e.preventDefault();
|
||||||
|
e.stopPropagation();
|
||||||
|
(e.currentTarget as HTMLDivElement).setPointerCapture(e.pointerId);
|
||||||
|
dragRef.current = { kind: 'pan', lastX: e.clientX, lastY: e.clientY };
|
||||||
|
skipMapClickRef.current = true;
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const onMapPointerMove = (e: React.PointerEvent) => {
|
||||||
|
const d = dragRef.current;
|
||||||
|
if (!d) return;
|
||||||
|
if (d.kind === 'pan') {
|
||||||
|
const cr = contentRect;
|
||||||
|
if (!cr) return;
|
||||||
|
const dx = e.clientX - d.lastX;
|
||||||
|
const dy = e.clientY - d.lastY;
|
||||||
|
d.lastX = e.clientX;
|
||||||
|
d.lastY = e.clientY;
|
||||||
|
setView((v) => {
|
||||||
|
const containW = cr.w / Math.max(1e-6, v.scale);
|
||||||
|
const containH = cr.h / Math.max(1e-6, v.scale);
|
||||||
|
return sceneViewPanBy(v, { containW, containH, dx, dy });
|
||||||
|
});
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (d.kind === 'marker') {
|
||||||
|
skipMapClickRef.current = true;
|
||||||
|
const p = toNorm(e.clientX, e.clientY);
|
||||||
|
if (!p) return;
|
||||||
|
const nextMarkers = draftRef.current.markers.map((x) =>
|
||||||
|
x.id === d.id ? { ...x, nx: p.x, ny: p.y } : x,
|
||||||
|
);
|
||||||
|
const next = { ...draftRef.current, enabled: true, markers: nextMarkers };
|
||||||
|
draftRef.current = next;
|
||||||
|
setDraft(next);
|
||||||
|
// persist только на pointerup
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const onMapPointerUp = () => {
|
||||||
|
const d = dragRef.current;
|
||||||
|
if (d?.kind === 'marker') {
|
||||||
|
schedulePersist();
|
||||||
|
}
|
||||||
|
dragRef.current = null;
|
||||||
|
};
|
||||||
|
|
||||||
|
const onMapClick = (e: React.MouseEvent) => {
|
||||||
|
if (skipMapClickRef.current) {
|
||||||
|
skipMapClickRef.current = false;
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (dragRef.current) return;
|
||||||
|
if (!draft.enabled || !activeItem) return;
|
||||||
|
const p = toNorm(e.clientX, e.clientY);
|
||||||
|
if (!p) return;
|
||||||
|
placeMarker(p.x, p.y, activeItem.number);
|
||||||
|
};
|
||||||
|
|
||||||
|
const renderMarkers = () => {
|
||||||
|
if (!draft.enabled) return null;
|
||||||
|
return draft.markers.map((m) => {
|
||||||
|
const style =
|
||||||
|
largeMap && contentRect
|
||||||
|
? (() => {
|
||||||
|
const p = legendImageUvToHostPoint(m.nx, m.ny, contentRect, rotationDeg);
|
||||||
|
return { left: p.x, top: p.y };
|
||||||
|
})()
|
||||||
|
: {
|
||||||
|
left: `${String(m.nx * 100)}%`,
|
||||||
|
top: `${String(m.ny * 100)}%`,
|
||||||
|
};
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
key={m.id}
|
||||||
|
className={styles.marker}
|
||||||
|
style={style}
|
||||||
|
onPointerDown={(e) => {
|
||||||
|
if (!draft.enabled) return;
|
||||||
|
e.stopPropagation();
|
||||||
|
e.preventDefault();
|
||||||
|
dragRef.current = { kind: 'marker', id: m.id };
|
||||||
|
(e.currentTarget as HTMLDivElement).setPointerCapture(e.pointerId);
|
||||||
|
}}
|
||||||
|
onPointerMove={onMapPointerMove}
|
||||||
|
onPointerUp={(e) => {
|
||||||
|
e.stopPropagation();
|
||||||
|
if (dragRef.current?.kind === 'marker') skipMapClickRef.current = true;
|
||||||
|
onMapPointerUp();
|
||||||
|
}}
|
||||||
|
onClick={(e) => {
|
||||||
|
e.stopPropagation();
|
||||||
|
skipMapClickRef.current = true;
|
||||||
|
}}
|
||||||
|
onContextMenu={(e) => {
|
||||||
|
e.preventDefault();
|
||||||
|
e.stopPropagation();
|
||||||
|
updateMarkers(draftRef.current.markers.filter((x) => x.id !== m.id));
|
||||||
|
}}
|
||||||
|
title="Перетащите, чтобы переместить. ПКМ — удалить"
|
||||||
|
>
|
||||||
|
{m.number}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
|
const mapBlock = previewUrl ? (
|
||||||
|
<div
|
||||||
|
ref={mapRef}
|
||||||
|
className={[
|
||||||
|
styles.map,
|
||||||
|
largeMap ? styles.mapLarge : '',
|
||||||
|
!draft.enabled ? styles.mapIdle : '',
|
||||||
|
]
|
||||||
|
.filter(Boolean)
|
||||||
|
.join(' ')}
|
||||||
|
onDragOver={(e) => {
|
||||||
|
if (!draft.enabled) return;
|
||||||
|
e.preventDefault();
|
||||||
|
}}
|
||||||
|
onDrop={(e) => {
|
||||||
|
if (!draft.enabled) return;
|
||||||
|
e.preventDefault();
|
||||||
|
const num = Number(e.dataTransfer.getData('application/x-legend-number'));
|
||||||
|
const p = toNorm(e.clientX, e.clientY);
|
||||||
|
if (!p || !Number.isFinite(num) || num < 1) return;
|
||||||
|
placeMarker(p.x, p.y, Math.round(num));
|
||||||
|
}}
|
||||||
|
onClick={onMapClick}
|
||||||
|
onPointerDown={onMapPointerDown}
|
||||||
|
onPointerMove={onMapPointerMove}
|
||||||
|
onPointerUp={onMapPointerUp}
|
||||||
|
onPointerCancel={onMapPointerUp}
|
||||||
|
>
|
||||||
|
{largeMap ? (
|
||||||
|
<RotatedImage
|
||||||
|
url={previewUrl}
|
||||||
|
rotationDeg={rotationDeg}
|
||||||
|
mode="contain"
|
||||||
|
viewCamera={view}
|
||||||
|
onContentRectChange={setContentRect}
|
||||||
|
/>
|
||||||
|
) : (
|
||||||
|
<img className={styles.mapImg} src={previewUrl} alt="" draggable={false} />
|
||||||
|
)}
|
||||||
|
{renderMarkers()}
|
||||||
|
{draft.enabled && !activeItem ? (
|
||||||
|
<div className={styles.mapHint}>Выберите строку легенды, чтобы ставить метки</div>
|
||||||
|
) : null}
|
||||||
|
{largeMap ? (
|
||||||
|
<div className={styles.mapHintZoom}>Колесо — зум · СКМ / Space+ЛКМ — пан</div>
|
||||||
|
) : null}
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
<div className={[styles.map, largeMap ? styles.mapLarge : '', styles.mapIdle].filter(Boolean).join(' ')}>
|
||||||
|
<div className={styles.mapEmpty}>Нет изображения</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className={[styles.legendBlock, largeMap ? styles.legendBlockLarge : ''].filter(Boolean).join(' ')}>
|
||||||
|
{largeMap ? mapBlock : null}
|
||||||
|
|
||||||
|
<label className={styles.row}>
|
||||||
|
<input type="checkbox" checked={draft.enabled} onChange={(e) => setEnabled(e.target.checked)} />
|
||||||
|
<span>Легенда</span>
|
||||||
|
</label>
|
||||||
|
|
||||||
|
{draft.enabled ? (
|
||||||
|
<>
|
||||||
|
<div className={styles.row}>
|
||||||
|
<Button onClick={addItem}>Добавить пункт</Button>
|
||||||
|
<span className={styles.hint}>
|
||||||
|
Активная строка подсвечена — клик по картинке ставит метку; клик по метке — перемещение
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
{draft.items.map((item) => {
|
||||||
|
const active = item.id === activeItemId;
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
key={item.id}
|
||||||
|
className={[styles.itemRow, active ? styles.itemRowActive : ''].filter(Boolean).join(' ')}
|
||||||
|
draggable
|
||||||
|
onClick={() => setActiveItemId(item.id)}
|
||||||
|
onDragStart={(e) => {
|
||||||
|
e.dataTransfer.setData('application/x-legend-number', String(item.number));
|
||||||
|
setActiveItemId(item.id);
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<div className={styles.num}>{item.number}</div>
|
||||||
|
<Input
|
||||||
|
value={item.text}
|
||||||
|
onChange={(text) => {
|
||||||
|
updateItems(
|
||||||
|
draftRef.current.items.map((it) => (it.id === item.id ? { ...it, text } : it)),
|
||||||
|
);
|
||||||
|
}}
|
||||||
|
placeholder="Описание…"
|
||||||
|
/>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
className={styles.itemDrag}
|
||||||
|
title="Удалить"
|
||||||
|
onPointerDown={(e) => e.stopPropagation()}
|
||||||
|
onMouseDown={(e) => e.stopPropagation()}
|
||||||
|
onClick={(e) => {
|
||||||
|
e.preventDefault();
|
||||||
|
e.stopPropagation();
|
||||||
|
const cur = draftRef.current;
|
||||||
|
applyDraft({
|
||||||
|
...cur,
|
||||||
|
enabled: true,
|
||||||
|
items: cur.items.filter((it) => it.id !== item.id),
|
||||||
|
markers: cur.markers.filter((m) => m.number !== item.number),
|
||||||
|
});
|
||||||
|
if (activeItemId === item.id) setActiveItemId(null);
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
×
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
{!largeMap ? mapBlock : null}
|
||||||
|
</>
|
||||||
|
) : largeMap ? null : (
|
||||||
|
mapBlock
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -1,13 +1,14 @@
|
|||||||
import React, { useEffect, useMemo, useState } from 'react';
|
import React, { useEffect, useMemo, useState } from 'react';
|
||||||
import { createPortal } from 'react-dom';
|
import { createPortal } from 'react-dom';
|
||||||
|
|
||||||
import type { MaterialId, ProjectMaterial } from '../../shared/types';
|
import type { MaterialId, MaterialLegend, ProjectMaterial } from '../../shared/types';
|
||||||
import { RotatedImage } from '../shared/RotatedImage';
|
import { RotatedImage } from '../shared/RotatedImage';
|
||||||
import { Button, Input } from '../shared/ui/controls';
|
import { Button, Input } from '../shared/ui/controls';
|
||||||
import { useAssetUrl } from '../shared/useAssetImageUrl';
|
import { useAssetUrl } from '../shared/useAssetImageUrl';
|
||||||
|
|
||||||
import styles from './EditorApp.module.css';
|
import styles from './EditorApp.module.css';
|
||||||
import { useEditorI18n } from './i18n/EditorI18nContext';
|
import { useEditorI18n } from './i18n/EditorI18nContext';
|
||||||
|
import { MaterialLegendEditor } from './MaterialLegendEditor';
|
||||||
import matStyles from './MaterialsModals.module.css';
|
import matStyles from './MaterialsModals.module.css';
|
||||||
|
|
||||||
const DND_MATERIAL_ID_MIME = 'application/x-dnd-material-id';
|
const DND_MATERIAL_ID_MIME = 'application/x-dnd-material-id';
|
||||||
@@ -24,6 +25,7 @@ export type MaterialsBrowserProps = {
|
|||||||
onDelete?: (materialId: MaterialId) => Promise<void>;
|
onDelete?: (materialId: MaterialId) => Promise<void>;
|
||||||
onReorder?: (materialIds: MaterialId[]) => Promise<void>;
|
onReorder?: (materialIds: MaterialId[]) => Promise<void>;
|
||||||
onRotate?: (materialId: MaterialId, rotationDeg: 0 | 90 | 180 | 270) => void;
|
onRotate?: (materialId: MaterialId, rotationDeg: 0 | 90 | 180 | 270) => void;
|
||||||
|
onLegendChange?: (materialId: MaterialId, legend: MaterialLegend) => Promise<void>;
|
||||||
onTileActivate?: (materialId: MaterialId) => void;
|
onTileActivate?: (materialId: MaterialId) => void;
|
||||||
toolbar?: React.ReactNode;
|
toolbar?: React.ReactNode;
|
||||||
className?: string | undefined;
|
className?: string | undefined;
|
||||||
@@ -44,6 +46,7 @@ export function MaterialsBrowser({
|
|||||||
onDelete,
|
onDelete,
|
||||||
onReorder,
|
onReorder,
|
||||||
onRotate,
|
onRotate,
|
||||||
|
onLegendChange,
|
||||||
onTileActivate,
|
onTileActivate,
|
||||||
toolbar,
|
toolbar,
|
||||||
className,
|
className,
|
||||||
@@ -179,20 +182,42 @@ export function MaterialsBrowser({
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
{!listOnly ? (
|
{!listOnly ? (
|
||||||
<div className={matStyles.previewColumn}>
|
<div
|
||||||
<div className={matStyles.previewPane}>
|
className={[
|
||||||
{selected && selectedUrl ? (
|
matStyles.previewColumn,
|
||||||
<div className={matStyles.previewLargeHost}>
|
onLegendChange ? matStyles.previewColumnLegend : '',
|
||||||
<RotatedImage
|
]
|
||||||
url={selectedUrl}
|
.filter(Boolean)
|
||||||
rotationDeg={selected.rotationDeg ?? 0}
|
.join(' ')}
|
||||||
mode="contain"
|
>
|
||||||
/>
|
{selected && selectedUrl && onLegendChange ? (
|
||||||
</div>
|
<div className={matStyles.previewLegendScroll}>
|
||||||
) : (
|
<MaterialLegendEditor
|
||||||
<div className={matStyles.previewEmpty}>{t('materials.addPrompt')}</div>
|
key={selected.id}
|
||||||
)}
|
largeMap
|
||||||
</div>
|
legend={selected.legend}
|
||||||
|
previewUrl={selectedUrl}
|
||||||
|
rotationDeg={selected.rotationDeg ?? 0}
|
||||||
|
onChange={(next) => {
|
||||||
|
void onLegendChange(selected.id, next);
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
<div className={matStyles.previewPane}>
|
||||||
|
{selected && selectedUrl ? (
|
||||||
|
<div className={matStyles.previewLargeHost}>
|
||||||
|
<RotatedImage
|
||||||
|
url={selectedUrl}
|
||||||
|
rotationDeg={selected.rotationDeg ?? 0}
|
||||||
|
mode="contain"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
<div className={matStyles.previewEmpty}>{t('materials.addPrompt')}</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
{selected && onRotate ? (
|
{selected && onRotate ? (
|
||||||
<div className={matStyles.previewActions}>
|
<div className={matStyles.previewActions}>
|
||||||
<Button
|
<Button
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
.managerDialog {
|
.managerDialog {
|
||||||
width: min(960px, calc(100vw - 48px));
|
width: min(1100px, calc(100vw - 48px));
|
||||||
max-width: 960px;
|
max-width: 1100px;
|
||||||
}
|
}
|
||||||
|
|
||||||
.browserRoot {
|
.browserRoot {
|
||||||
@@ -49,7 +49,7 @@
|
|||||||
gap: 14px;
|
gap: 14px;
|
||||||
/* ~3 плитки по высоте + поиск/кнопка; дальше скролл в списке */
|
/* ~3 плитки по высоте + поиск/кнопка; дальше скролл в списке */
|
||||||
min-height: 560px;
|
min-height: 560px;
|
||||||
max-height: min(78vh, 720px);
|
max-height: min(84vh, 820px);
|
||||||
}
|
}
|
||||||
|
|
||||||
.managerBodyFill {
|
.managerBodyFill {
|
||||||
@@ -180,6 +180,17 @@
|
|||||||
min-height: 0;
|
min-height: 0;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.previewColumnLegend {
|
||||||
|
grid-template-rows: minmax(0, 1fr) auto;
|
||||||
|
min-height: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.previewLegendScroll {
|
||||||
|
min-height: 0;
|
||||||
|
overflow: auto;
|
||||||
|
padding-right: 2px;
|
||||||
|
}
|
||||||
|
|
||||||
.previewPane {
|
.previewPane {
|
||||||
width: 100%;
|
width: 100%;
|
||||||
/* высота ≈ 3 плитки списка */
|
/* высота ≈ 3 плитки списка */
|
||||||
|
|||||||
@@ -1,7 +1,7 @@
|
|||||||
import React, { useCallback, useEffect, useState } from 'react';
|
import React, { useCallback, useEffect, useState } from 'react';
|
||||||
import { createPortal, flushSync } from 'react-dom';
|
import { createPortal, flushSync } from 'react-dom';
|
||||||
|
|
||||||
import type { MaterialId, ProjectMaterial } from '../../shared/types';
|
import type { MaterialId, MaterialLegend, ProjectMaterial } from '../../shared/types';
|
||||||
import { Button, Input } from '../shared/ui/controls';
|
import { Button, Input } from '../shared/ui/controls';
|
||||||
import { useAssetUrl } from '../shared/useAssetImageUrl';
|
import { useAssetUrl } from '../shared/useAssetImageUrl';
|
||||||
|
|
||||||
@@ -81,7 +81,6 @@ export function MaterialEditModal({
|
|||||||
onDropPaths: (paths) => {
|
onDropPaths: (paths) => {
|
||||||
const picked = pickFirstMaterialImagePath(paths);
|
const picked = pickFirstMaterialImagePath(paths);
|
||||||
if (!picked) return;
|
if (!picked) return;
|
||||||
// Prefer File blob URL if available from the last drop via entries — handled in onDrop below.
|
|
||||||
setPreviewFromPathAndUrl(picked, '');
|
setPreviewFromPathAndUrl(picked, '');
|
||||||
},
|
},
|
||||||
filterPaths: filterMaterialImagePaths,
|
filterPaths: filterMaterialImagePaths,
|
||||||
@@ -226,6 +225,7 @@ type MaterialsManagerModalProps = {
|
|||||||
onDelete: (materialId: MaterialId) => Promise<void>;
|
onDelete: (materialId: MaterialId) => Promise<void>;
|
||||||
onReorder: (materialIds: MaterialId[]) => Promise<void>;
|
onReorder: (materialIds: MaterialId[]) => Promise<void>;
|
||||||
onRotate: (materialId: MaterialId, rotationDeg: 0 | 90 | 180 | 270) => void;
|
onRotate: (materialId: MaterialId, rotationDeg: 0 | 90 | 180 | 270) => void;
|
||||||
|
onLegendChange?: (materialId: MaterialId, legend: MaterialLegend) => Promise<void>;
|
||||||
};
|
};
|
||||||
|
|
||||||
export function MaterialsManagerModal({
|
export function MaterialsManagerModal({
|
||||||
@@ -237,6 +237,7 @@ export function MaterialsManagerModal({
|
|||||||
onDelete,
|
onDelete,
|
||||||
onReorder,
|
onReorder,
|
||||||
onRotate,
|
onRotate,
|
||||||
|
onLegendChange,
|
||||||
}: MaterialsManagerModalProps) {
|
}: MaterialsManagerModalProps) {
|
||||||
const { t } = useEditorI18n();
|
const { t } = useEditorI18n();
|
||||||
const [selectedId, setSelectedId] = useState<MaterialId | null>(null);
|
const [selectedId, setSelectedId] = useState<MaterialId | null>(null);
|
||||||
@@ -278,6 +279,7 @@ export function MaterialsManagerModal({
|
|||||||
onDelete={onDelete}
|
onDelete={onDelete}
|
||||||
onReorder={onReorder}
|
onReorder={onReorder}
|
||||||
onRotate={onRotate}
|
onRotate={onRotate}
|
||||||
|
onLegendChange={onLegendChange}
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
</>,
|
</>,
|
||||||
|
|||||||
@@ -58,6 +58,10 @@ type Actions = {
|
|||||||
deleteMaterial: (materialId: MaterialId) => Promise<void>;
|
deleteMaterial: (materialId: MaterialId) => Promise<void>;
|
||||||
setMaterialsOrder: (materialIds: MaterialId[]) => Promise<void>;
|
setMaterialsOrder: (materialIds: MaterialId[]) => Promise<void>;
|
||||||
setMaterialRotation: (materialId: MaterialId, rotationDeg: 0 | 90 | 180 | 270) => Promise<void>;
|
setMaterialRotation: (materialId: MaterialId, rotationDeg: 0 | 90 | 180 | 270) => Promise<void>;
|
||||||
|
setMaterialLegend: (
|
||||||
|
materialId: MaterialId,
|
||||||
|
legend: import('../../shared/types').MaterialLegend | null,
|
||||||
|
) => Promise<void>;
|
||||||
pickMaterialImage: () => Promise<{ filePath: string; previewDataUrl: string } | null>;
|
pickMaterialImage: () => Promise<{ filePath: string; previewDataUrl: string } | null>;
|
||||||
updateScene: (
|
updateScene: (
|
||||||
sceneId: SceneId,
|
sceneId: SceneId,
|
||||||
@@ -344,6 +348,7 @@ export function useProjectState(licenseActive: boolean, opts?: ProjectStateOpts)
|
|||||||
previewVideoAutostart: false,
|
previewVideoAutostart: false,
|
||||||
previewRotationDeg: 0,
|
previewRotationDeg: 0,
|
||||||
darkenScene: false,
|
darkenScene: false,
|
||||||
|
traps: [],
|
||||||
media: { videos: [], audios: [] },
|
media: { videos: [], audios: [] },
|
||||||
settings: { autoplayVideo: false, autoplayAudio: true, loopVideo: true, loopAudio: true },
|
settings: { autoplayVideo: false, autoplayAudio: true, loopVideo: true, loopAudio: true },
|
||||||
connections: [],
|
connections: [],
|
||||||
@@ -531,6 +536,15 @@ export function useProjectState(licenseActive: boolean, opts?: ProjectStateOpts)
|
|||||||
await refreshProjects();
|
await refreshProjects();
|
||||||
};
|
};
|
||||||
|
|
||||||
|
const setMaterialLegend = async (
|
||||||
|
materialId: MaterialId,
|
||||||
|
legend: import('../../shared/types').MaterialLegend | null,
|
||||||
|
) => {
|
||||||
|
const res = await api.invoke(ipcChannels.project.setMaterialLegend, { materialId, legend });
|
||||||
|
// Список проектов не меняется — не дергаем refreshProjects на каждое обновление легенды.
|
||||||
|
setState((s) => ({ ...s, project: res.project }));
|
||||||
|
};
|
||||||
|
|
||||||
const pickMaterialImage = async () => {
|
const pickMaterialImage = async () => {
|
||||||
const res = await api.invoke(ipcChannels.project.pickMaterialImage, {});
|
const res = await api.invoke(ipcChannels.project.pickMaterialImage, {});
|
||||||
if (res.canceled) return null;
|
if (res.canceled) return null;
|
||||||
@@ -548,6 +562,7 @@ export function useProjectState(licenseActive: boolean, opts?: ProjectStateOpts)
|
|||||||
previewVideoAutostart?: boolean;
|
previewVideoAutostart?: boolean;
|
||||||
previewRotationDeg?: 0 | 90 | 180 | 270;
|
previewRotationDeg?: 0 | 90 | 180 | 270;
|
||||||
darkenScene?: boolean;
|
darkenScene?: boolean;
|
||||||
|
traps?: import('../../shared/types').SceneTrap[];
|
||||||
settings?: Partial<Scene['settings']>;
|
settings?: Partial<Scene['settings']>;
|
||||||
media?: Partial<Scene['media']>;
|
media?: Partial<Scene['media']>;
|
||||||
layout?: { x: number; y: number };
|
layout?: { x: number; y: number };
|
||||||
@@ -574,6 +589,7 @@ export function useProjectState(licenseActive: boolean, opts?: ProjectStateOpts)
|
|||||||
? { previewRotationDeg: patch.previewRotationDeg }
|
? { previewRotationDeg: patch.previewRotationDeg }
|
||||||
: null),
|
: null),
|
||||||
...(patch.darkenScene !== undefined ? { darkenScene: patch.darkenScene } : null),
|
...(patch.darkenScene !== undefined ? { darkenScene: patch.darkenScene } : null),
|
||||||
|
...(patch.traps !== undefined ? { traps: patch.traps } : null),
|
||||||
...(patch.settings ? { settings: { ...scene.settings, ...patch.settings } } : null),
|
...(patch.settings ? { settings: { ...scene.settings, ...patch.settings } } : null),
|
||||||
...(patch.media ? { media: { ...scene.media, ...patch.media } } : null),
|
...(patch.media ? { media: { ...scene.media, ...patch.media } } : null),
|
||||||
layout: patch.layout ? { ...scene.layout, ...patch.layout } : scene.layout,
|
layout: patch.layout ? { ...scene.layout, ...patch.layout } : scene.layout,
|
||||||
@@ -896,6 +912,7 @@ export function useProjectState(licenseActive: boolean, opts?: ProjectStateOpts)
|
|||||||
deleteMaterial,
|
deleteMaterial,
|
||||||
setMaterialsOrder,
|
setMaterialsOrder,
|
||||||
setMaterialRotation,
|
setMaterialRotation,
|
||||||
|
setMaterialLegend,
|
||||||
pickMaterialImage,
|
pickMaterialImage,
|
||||||
updateScene,
|
updateScene,
|
||||||
updateConnections,
|
updateConnections,
|
||||||
|
|||||||
@@ -24,6 +24,11 @@ export function PresentationApp() {
|
|||||||
const onKeyDown = (e: KeyboardEvent) => {
|
const onKeyDown = (e: KeyboardEvent) => {
|
||||||
if (e.key === 'Escape') {
|
if (e.key === 'Escape') {
|
||||||
void api.invoke(ipcChannels.windows.closeMultiWindow, {});
|
void api.invoke(ipcChannels.windows.closeMultiWindow, {});
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (e.altKey && (e.key === 'Enter' || e.code === 'Enter' || e.code === 'NumpadEnter')) {
|
||||||
|
e.preventDefault();
|
||||||
|
void api.invoke(ipcChannels.windows.togglePresentationFullscreen, {});
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
window.addEventListener('keydown', onKeyDown);
|
window.addEventListener('keydown', onKeyDown);
|
||||||
|
|||||||
@@ -0,0 +1,13 @@
|
|||||||
|
<!doctype html>
|
||||||
|
<html lang="ru">
|
||||||
|
<head>
|
||||||
|
<meta charset="UTF-8" />
|
||||||
|
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
||||||
|
<link rel="icon" href="/app-window-icon.png" type="image/png" />
|
||||||
|
<title>TTRPG</title>
|
||||||
|
</head>
|
||||||
|
<body>
|
||||||
|
<div id="root"></div>
|
||||||
|
<script type="module" src="/sceneEditor/main.tsx"></script>
|
||||||
|
</body>
|
||||||
|
</html>
|
||||||
@@ -0,0 +1,170 @@
|
|||||||
|
.page {
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: 260px 1fr;
|
||||||
|
height: 100vh;
|
||||||
|
width: 100vw;
|
||||||
|
overflow: hidden;
|
||||||
|
background: var(--bg, #12141a);
|
||||||
|
color: var(--text, #e8eaef);
|
||||||
|
}
|
||||||
|
|
||||||
|
.sidebar {
|
||||||
|
border-right: 1px solid var(--stroke, #2a2f3a);
|
||||||
|
padding: 12px;
|
||||||
|
overflow: auto;
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 10px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.sideTitle {
|
||||||
|
font-weight: 800;
|
||||||
|
font-size: 13px;
|
||||||
|
letter-spacing: 0.04em;
|
||||||
|
text-transform: uppercase;
|
||||||
|
opacity: 0.85;
|
||||||
|
}
|
||||||
|
|
||||||
|
.accordion {
|
||||||
|
border: 1px solid var(--stroke, #2a2f3a);
|
||||||
|
border-radius: 10px;
|
||||||
|
overflow: hidden;
|
||||||
|
background: rgba(255, 255, 255, 0.02);
|
||||||
|
}
|
||||||
|
|
||||||
|
.accordionHead {
|
||||||
|
width: 100%;
|
||||||
|
text-align: left;
|
||||||
|
padding: 10px 12px;
|
||||||
|
font-weight: 700;
|
||||||
|
background: transparent;
|
||||||
|
border: 0;
|
||||||
|
color: inherit;
|
||||||
|
cursor: pointer;
|
||||||
|
}
|
||||||
|
|
||||||
|
.palette {
|
||||||
|
display: grid;
|
||||||
|
gap: 6px;
|
||||||
|
padding: 8px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.paletteItem {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 10px;
|
||||||
|
padding: 8px 10px;
|
||||||
|
border-radius: 8px;
|
||||||
|
border: 1px solid var(--stroke, #2a2f3a);
|
||||||
|
background: rgba(0, 0, 0, 0.2);
|
||||||
|
color: inherit;
|
||||||
|
cursor: grab;
|
||||||
|
user-select: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
.paletteItem:active {
|
||||||
|
cursor: grabbing;
|
||||||
|
}
|
||||||
|
|
||||||
|
.paletteLabel {
|
||||||
|
font-size: 13px;
|
||||||
|
font-weight: 600;
|
||||||
|
}
|
||||||
|
|
||||||
|
.hint {
|
||||||
|
font-size: 12px;
|
||||||
|
opacity: 0.65;
|
||||||
|
line-height: 1.35;
|
||||||
|
}
|
||||||
|
|
||||||
|
.stage {
|
||||||
|
position: relative;
|
||||||
|
min-width: 0;
|
||||||
|
min-height: 0;
|
||||||
|
background: #0a0b0e;
|
||||||
|
}
|
||||||
|
|
||||||
|
.viewport {
|
||||||
|
position: absolute;
|
||||||
|
inset: 0;
|
||||||
|
overflow: hidden;
|
||||||
|
cursor: default;
|
||||||
|
}
|
||||||
|
|
||||||
|
.world {
|
||||||
|
position: absolute;
|
||||||
|
left: 0;
|
||||||
|
top: 0;
|
||||||
|
transform-origin: 0 0;
|
||||||
|
will-change: transform;
|
||||||
|
}
|
||||||
|
|
||||||
|
.mapImg {
|
||||||
|
display: block;
|
||||||
|
max-width: none;
|
||||||
|
user-select: none;
|
||||||
|
pointer-events: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
.trap {
|
||||||
|
position: absolute;
|
||||||
|
transform: translate(-50%, -50%);
|
||||||
|
border-radius: 50%;
|
||||||
|
border: 2px solid rgba(255, 255, 255, 0.55);
|
||||||
|
background: rgba(0, 0, 0, 0.45);
|
||||||
|
display: grid;
|
||||||
|
place-items: center;
|
||||||
|
box-shadow: 0 0 0 1px rgba(0, 0, 0, 0.4);
|
||||||
|
cursor: move;
|
||||||
|
touch-action: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
.trapSelected {
|
||||||
|
border-color: #f5c542;
|
||||||
|
box-shadow:
|
||||||
|
0 0 0 1px rgba(0, 0, 0, 0.4),
|
||||||
|
0 0 0 3px rgba(245, 197, 66, 0.35);
|
||||||
|
}
|
||||||
|
|
||||||
|
.trapLabel {
|
||||||
|
position: absolute;
|
||||||
|
left: 50%;
|
||||||
|
top: calc(100% + 4px);
|
||||||
|
transform: translateX(-50%);
|
||||||
|
font-size: 11px;
|
||||||
|
white-space: nowrap;
|
||||||
|
background: rgba(0, 0, 0, 0.7);
|
||||||
|
padding: 2px 6px;
|
||||||
|
border-radius: 4px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.handle {
|
||||||
|
position: absolute;
|
||||||
|
right: -5px;
|
||||||
|
bottom: -5px;
|
||||||
|
width: 12px;
|
||||||
|
height: 12px;
|
||||||
|
border-radius: 2px;
|
||||||
|
background: #f5c542;
|
||||||
|
border: 1px solid #000;
|
||||||
|
cursor: nwse-resize;
|
||||||
|
touch-action: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
.empty {
|
||||||
|
position: absolute;
|
||||||
|
inset: 0;
|
||||||
|
display: grid;
|
||||||
|
place-items: center;
|
||||||
|
opacity: 0.7;
|
||||||
|
}
|
||||||
|
|
||||||
|
.toolbar {
|
||||||
|
display: flex;
|
||||||
|
gap: 8px;
|
||||||
|
flex-wrap: wrap;
|
||||||
|
}
|
||||||
|
|
||||||
|
.toolbar button {
|
||||||
|
font-size: 12px;
|
||||||
|
}
|
||||||
@@ -0,0 +1,365 @@
|
|||||||
|
import React, { useCallback, useEffect, useMemo, useRef, useState } from 'react';
|
||||||
|
|
||||||
|
import { ipcChannels, type SessionState } from '../../shared/ipc/contracts';
|
||||||
|
import type { SceneTrap, SceneTrapType } from '../../shared/types';
|
||||||
|
import {
|
||||||
|
asSceneTrapId,
|
||||||
|
DEFAULT_SCENE_TRAP_SIZE_N,
|
||||||
|
SCENE_TRAP_TYPES,
|
||||||
|
trapTypeLabelRu,
|
||||||
|
} from '../../shared/types/sceneTraps';
|
||||||
|
import { sceneViewPanBy, sceneViewZoomAt } from '../../shared/types/sceneView';
|
||||||
|
import { getDndApi } from '../shared/dndApi';
|
||||||
|
import { RotatedImage } from '../shared/RotatedImage';
|
||||||
|
import { TrapGlyph } from '../shared/traps/TrapGlyph';
|
||||||
|
import { Button } from '../shared/ui/controls';
|
||||||
|
import { useAssetUrl } from '../shared/useAssetImageUrl';
|
||||||
|
|
||||||
|
import styles from './SceneEditorApp.module.css';
|
||||||
|
|
||||||
|
type LocalView = { scale: number; ox: number; oy: number };
|
||||||
|
|
||||||
|
type DragMode =
|
||||||
|
| { kind: 'pan'; lastX: number; lastY: number }
|
||||||
|
| { kind: 'move'; trapId: string; startNx: number; startNy: number; pointerNx: number; pointerNy: number }
|
||||||
|
| { kind: 'resize'; trapId: string; startSize: number; startDist: number }
|
||||||
|
| null;
|
||||||
|
|
||||||
|
function randomTrapId(): string {
|
||||||
|
return `trap_${Math.random().toString(36).slice(2, 10)}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function SceneEditorApp() {
|
||||||
|
const api = getDndApi();
|
||||||
|
const [session, setSession] = useState<SessionState | null>(null);
|
||||||
|
const [trapsOpen, setTrapsOpen] = useState(true);
|
||||||
|
const [selectedId, setSelectedId] = useState<string | null>(null);
|
||||||
|
const [view, setView] = useState<LocalView>({ scale: 1, ox: 0.5, oy: 0.5 });
|
||||||
|
const [contentRect, setContentRect] = useState<{ x: number; y: number; w: number; h: number } | null>(
|
||||||
|
null,
|
||||||
|
);
|
||||||
|
const hostRef = useRef<HTMLDivElement | null>(null);
|
||||||
|
const dragRef = useRef<DragMode>(null);
|
||||||
|
const saveTimerRef = useRef(0);
|
||||||
|
const spaceDownRef = useRef(false);
|
||||||
|
|
||||||
|
const project = session?.project ?? null;
|
||||||
|
const sceneId = project?.currentSceneId ?? null;
|
||||||
|
const scene = sceneId && project ? project.scenes[sceneId] : undefined;
|
||||||
|
const url = useAssetUrl(scene?.previewAssetId ?? null);
|
||||||
|
const rot = scene?.previewRotationDeg ?? 0;
|
||||||
|
const [localTraps, setLocalTraps] = useState<SceneTrap[]>([]);
|
||||||
|
const trapsRef = useRef<SceneTrap[]>([]);
|
||||||
|
trapsRef.current = localTraps;
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
setLocalTraps(scene?.traps ?? []);
|
||||||
|
setSelectedId(null);
|
||||||
|
setView({ scale: 1, ox: 0.5, oy: 0.5 });
|
||||||
|
}, [sceneId, scene?.previewAssetId]);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
// External updates (other windows) — sync when not dragging
|
||||||
|
if (dragRef.current) return;
|
||||||
|
setLocalTraps(scene?.traps ?? []);
|
||||||
|
}, [scene?.traps]);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
void api.invoke(ipcChannels.project.get, {}).then(({ project: p }) => {
|
||||||
|
setSession({ project: p, currentSceneId: p?.currentSceneId ?? null });
|
||||||
|
});
|
||||||
|
return api.on(ipcChannels.session.stateChanged, ({ state }) => {
|
||||||
|
setSession(state);
|
||||||
|
});
|
||||||
|
}, [api]);
|
||||||
|
|
||||||
|
const persistTraps = useCallback(
|
||||||
|
async (next: SceneTrap[]) => {
|
||||||
|
if (!sceneId) return;
|
||||||
|
setLocalTraps(next);
|
||||||
|
trapsRef.current = next;
|
||||||
|
if (saveTimerRef.current) window.clearTimeout(saveTimerRef.current);
|
||||||
|
saveTimerRef.current = window.setTimeout(() => {
|
||||||
|
void api.invoke(ipcChannels.project.updateScene, { sceneId, patch: { traps: next } });
|
||||||
|
}, 120);
|
||||||
|
},
|
||||||
|
[api, sceneId],
|
||||||
|
);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
const onKeyDown = (e: KeyboardEvent) => {
|
||||||
|
if (e.code === 'Space') spaceDownRef.current = true;
|
||||||
|
if ((e.key === 'Delete' || e.key === 'Backspace') && selectedId && sceneId) {
|
||||||
|
e.preventDefault();
|
||||||
|
const next = trapsRef.current.filter((t) => t.id !== selectedId);
|
||||||
|
setSelectedId(null);
|
||||||
|
void persistTraps(next);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
const onKeyUp = (e: KeyboardEvent) => {
|
||||||
|
if (e.code === 'Space') spaceDownRef.current = false;
|
||||||
|
};
|
||||||
|
window.addEventListener('keydown', onKeyDown);
|
||||||
|
window.addEventListener('keyup', onKeyUp);
|
||||||
|
return () => {
|
||||||
|
window.removeEventListener('keydown', onKeyDown);
|
||||||
|
window.removeEventListener('keyup', onKeyUp);
|
||||||
|
};
|
||||||
|
}, [persistTraps, sceneId, selectedId]);
|
||||||
|
|
||||||
|
const hostToNorm = (clientX: number, clientY: number): { x: number; y: number } | null => {
|
||||||
|
const host = hostRef.current;
|
||||||
|
const cr = contentRect;
|
||||||
|
if (!host || !cr || cr.w < 1 || cr.h < 1) return null;
|
||||||
|
const r = host.getBoundingClientRect();
|
||||||
|
return {
|
||||||
|
x: Math.max(0, Math.min(1, (clientX - (r.left + cr.x)) / cr.w)),
|
||||||
|
y: Math.max(0, Math.min(1, (clientY - (r.top + cr.y)) / cr.h)),
|
||||||
|
};
|
||||||
|
};
|
||||||
|
|
||||||
|
const onWheel = (_e: React.WheelEvent) => {
|
||||||
|
// native non-passive listener handles zoom
|
||||||
|
};
|
||||||
|
void onWheel;
|
||||||
|
|
||||||
|
const viewCamera = useMemo(() => view, [view]);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
const host = hostRef.current;
|
||||||
|
if (!host) return;
|
||||||
|
const nativeWheel = (e: WheelEvent) => {
|
||||||
|
e.preventDefault();
|
||||||
|
const factor = e.deltaY < 0 ? 1.12 : 1 / 1.12;
|
||||||
|
const cr = contentRect;
|
||||||
|
if (!cr) {
|
||||||
|
setView((v) => {
|
||||||
|
const nextScale = Math.max(1, Math.min(8, v.scale * factor));
|
||||||
|
if (nextScale <= 1.001) return { scale: 1, ox: 0.5, oy: 0.5 };
|
||||||
|
return { ...v, scale: nextScale };
|
||||||
|
});
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const r = host.getBoundingClientRect();
|
||||||
|
setView((v) => {
|
||||||
|
const containW = cr.w / Math.max(1e-6, v.scale);
|
||||||
|
const containH = cr.h / Math.max(1e-6, v.scale);
|
||||||
|
return sceneViewZoomAt(v, {
|
||||||
|
hostW: r.width,
|
||||||
|
hostH: r.height,
|
||||||
|
containW,
|
||||||
|
containH,
|
||||||
|
hostX: e.clientX - r.left,
|
||||||
|
hostY: e.clientY - r.top,
|
||||||
|
factor,
|
||||||
|
});
|
||||||
|
});
|
||||||
|
};
|
||||||
|
host.addEventListener('wheel', nativeWheel, { passive: false });
|
||||||
|
return () => host.removeEventListener('wheel', nativeWheel);
|
||||||
|
}, [contentRect]);
|
||||||
|
|
||||||
|
const addTrapAt = (type: SceneTrapType, nx: number, ny: number) => {
|
||||||
|
const trap: SceneTrap = {
|
||||||
|
id: asSceneTrapId(randomTrapId()),
|
||||||
|
type,
|
||||||
|
nx,
|
||||||
|
ny,
|
||||||
|
sizeN: DEFAULT_SCENE_TRAP_SIZE_N,
|
||||||
|
...(type === 'freeform' ? { label: trapTypeLabelRu(type) } : {}),
|
||||||
|
};
|
||||||
|
const next = [...trapsRef.current, trap];
|
||||||
|
setSelectedId(trap.id);
|
||||||
|
void persistTraps(next);
|
||||||
|
};
|
||||||
|
|
||||||
|
const onPaletteDragStart = (type: SceneTrapType) => (e: React.DragEvent) => {
|
||||||
|
e.dataTransfer.setData('application/x-dnd-trap-type', type);
|
||||||
|
e.dataTransfer.effectAllowed = 'copy';
|
||||||
|
};
|
||||||
|
|
||||||
|
const onStageDrop = (e: React.DragEvent) => {
|
||||||
|
e.preventDefault();
|
||||||
|
const type = e.dataTransfer.getData('application/x-dnd-trap-type') as SceneTrapType;
|
||||||
|
if (!SCENE_TRAP_TYPES.includes(type)) return;
|
||||||
|
const p = hostToNorm(e.clientX, e.clientY);
|
||||||
|
if (!p) return;
|
||||||
|
addTrapAt(type, p.x, p.y);
|
||||||
|
};
|
||||||
|
|
||||||
|
const updateTrap = (id: string, patch: Partial<SceneTrap>) => {
|
||||||
|
const next = trapsRef.current.map((t) => (t.id === id ? { ...t, ...patch } : t));
|
||||||
|
void persistTraps(next);
|
||||||
|
};
|
||||||
|
|
||||||
|
const isImage = scene?.previewAssetType === 'image' && Boolean(url);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className={styles.page}>
|
||||||
|
<aside className={styles.sidebar}>
|
||||||
|
<div className={styles.sideTitle}>{scene?.title ?? 'Сцена'}</div>
|
||||||
|
<div className={styles.hint}>
|
||||||
|
Колесо — зум. СКМ / Space+ЛКМ — пан. Delete — удалить выбранную ловушку.
|
||||||
|
</div>
|
||||||
|
<div className={styles.accordion}>
|
||||||
|
<button type="button" className={styles.accordionHead} onClick={() => setTrapsOpen((v) => !v)}>
|
||||||
|
Ловушки {trapsOpen ? '▾' : '▸'}
|
||||||
|
</button>
|
||||||
|
{trapsOpen ? (
|
||||||
|
<div className={styles.palette}>
|
||||||
|
{SCENE_TRAP_TYPES.map((type) => (
|
||||||
|
<div
|
||||||
|
key={type}
|
||||||
|
className={styles.paletteItem}
|
||||||
|
draggable
|
||||||
|
onDragStart={onPaletteDragStart(type)}
|
||||||
|
title="Перетащите на карту"
|
||||||
|
>
|
||||||
|
<TrapGlyph type={type} size={22} />
|
||||||
|
<span className={styles.paletteLabel}>{trapTypeLabelRu(type)}</span>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
) : null}
|
||||||
|
</div>
|
||||||
|
{selectedId ? (
|
||||||
|
<div className={styles.toolbar}>
|
||||||
|
<Button
|
||||||
|
onClick={() => {
|
||||||
|
const next = localTraps.filter((t) => t.id !== selectedId);
|
||||||
|
setSelectedId(null);
|
||||||
|
void persistTraps(next);
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
Удалить
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
) : null}
|
||||||
|
</aside>
|
||||||
|
|
||||||
|
<div className={styles.stage}>
|
||||||
|
{!isImage ? (
|
||||||
|
<div className={styles.empty}>Нужно изображение сцены</div>
|
||||||
|
) : (
|
||||||
|
<div
|
||||||
|
ref={hostRef}
|
||||||
|
className={styles.viewport}
|
||||||
|
onDragOver={(e) => e.preventDefault()}
|
||||||
|
onDrop={onStageDrop}
|
||||||
|
onPointerDown={(e) => {
|
||||||
|
if (e.button === 1 || (e.button === 0 && spaceDownRef.current)) {
|
||||||
|
e.preventDefault();
|
||||||
|
(e.currentTarget as HTMLDivElement).setPointerCapture(e.pointerId);
|
||||||
|
dragRef.current = { kind: 'pan', lastX: e.clientX, lastY: e.clientY };
|
||||||
|
}
|
||||||
|
}}
|
||||||
|
onPointerMove={(e) => {
|
||||||
|
const d = dragRef.current;
|
||||||
|
if (!d) return;
|
||||||
|
if (d.kind === 'pan') {
|
||||||
|
const cr = contentRect;
|
||||||
|
if (!cr) return;
|
||||||
|
const dx = e.clientX - d.lastX;
|
||||||
|
const dy = e.clientY - d.lastY;
|
||||||
|
d.lastX = e.clientX;
|
||||||
|
d.lastY = e.clientY;
|
||||||
|
setView((v) => {
|
||||||
|
const containW = cr.w / Math.max(1e-6, v.scale);
|
||||||
|
const containH = cr.h / Math.max(1e-6, v.scale);
|
||||||
|
return sceneViewPanBy(v, { containW, containH, dx, dy });
|
||||||
|
});
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (d.kind === 'move') {
|
||||||
|
const p = hostToNorm(e.clientX, e.clientY);
|
||||||
|
if (!p) return;
|
||||||
|
updateTrap(d.trapId, {
|
||||||
|
nx: Math.max(0, Math.min(1, d.startNx + (p.x - d.pointerNx))),
|
||||||
|
ny: Math.max(0, Math.min(1, d.startNy + (p.y - d.pointerNy))),
|
||||||
|
});
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (d.kind === 'resize') {
|
||||||
|
const p = hostToNorm(e.clientX, e.clientY);
|
||||||
|
const trap = trapsRef.current.find((t) => t.id === d.trapId);
|
||||||
|
if (!p || !trap) return;
|
||||||
|
const dist = Math.hypot(p.x - trap.nx, p.y - trap.ny);
|
||||||
|
const ratio = d.startDist > 1e-6 ? dist / d.startDist : 1;
|
||||||
|
updateTrap(d.trapId, {
|
||||||
|
sizeN: Math.max(0.02, Math.min(0.45, d.startSize * ratio)),
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}}
|
||||||
|
onPointerUp={() => {
|
||||||
|
dragRef.current = null;
|
||||||
|
}}
|
||||||
|
onPointerCancel={() => {
|
||||||
|
dragRef.current = null;
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<RotatedImage
|
||||||
|
url={url!}
|
||||||
|
rotationDeg={rot}
|
||||||
|
mode="contain"
|
||||||
|
viewCamera={viewCamera}
|
||||||
|
onContentRectChange={setContentRect}
|
||||||
|
/>
|
||||||
|
{contentRect
|
||||||
|
? localTraps.map((trap) => {
|
||||||
|
const minDim = Math.min(contentRect.w, contentRect.h);
|
||||||
|
const sizePx = Math.max(16, trap.sizeN * minDim);
|
||||||
|
const left = contentRect.x + trap.nx * contentRect.w;
|
||||||
|
const top = contentRect.y + trap.ny * contentRect.h;
|
||||||
|
const selected = selectedId === trap.id;
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
key={trap.id}
|
||||||
|
className={[styles.trap, selected ? styles.trapSelected : ''].filter(Boolean).join(' ')}
|
||||||
|
style={{ left, top, width: sizePx, height: sizePx }}
|
||||||
|
onPointerDown={(e) => {
|
||||||
|
if (e.button !== 0 || spaceDownRef.current) return;
|
||||||
|
e.stopPropagation();
|
||||||
|
setSelectedId(trap.id);
|
||||||
|
const p = hostToNorm(e.clientX, e.clientY);
|
||||||
|
if (!p) return;
|
||||||
|
(e.currentTarget as HTMLDivElement).setPointerCapture(e.pointerId);
|
||||||
|
dragRef.current = {
|
||||||
|
kind: 'move',
|
||||||
|
trapId: trap.id,
|
||||||
|
startNx: trap.nx,
|
||||||
|
startNy: trap.ny,
|
||||||
|
pointerNx: p.x,
|
||||||
|
pointerNy: p.y,
|
||||||
|
};
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<TrapGlyph type={trap.type} size={Math.max(14, sizePx * 0.55)} />
|
||||||
|
{trap.label ? <div className={styles.trapLabel}>{trap.label}</div> : null}
|
||||||
|
{selected ? (
|
||||||
|
<div
|
||||||
|
className={styles.handle}
|
||||||
|
onPointerDown={(e) => {
|
||||||
|
e.stopPropagation();
|
||||||
|
e.preventDefault();
|
||||||
|
const p = hostToNorm(e.clientX, e.clientY);
|
||||||
|
if (!p) return;
|
||||||
|
(e.currentTarget as HTMLDivElement).setPointerCapture(e.pointerId);
|
||||||
|
dragRef.current = {
|
||||||
|
kind: 'resize',
|
||||||
|
trapId: trap.id,
|
||||||
|
startSize: trap.sizeN,
|
||||||
|
startDist: Math.max(1e-4, Math.hypot(p.x - trap.nx, p.y - trap.ny)),
|
||||||
|
};
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
) : null}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
})
|
||||||
|
: null}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,20 @@
|
|||||||
|
import React from 'react';
|
||||||
|
import { createRoot } from 'react-dom/client';
|
||||||
|
|
||||||
|
import '../shared/ui/globals.css';
|
||||||
|
import { EditorI18nProvider } from '../editor/i18n/EditorI18nContext';
|
||||||
|
|
||||||
|
import { SceneEditorApp } from './SceneEditorApp';
|
||||||
|
|
||||||
|
const rootEl = document.getElementById('root');
|
||||||
|
if (!rootEl) {
|
||||||
|
throw new Error('Missing #root element');
|
||||||
|
}
|
||||||
|
|
||||||
|
createRoot(rootEl).render(
|
||||||
|
<React.StrictMode>
|
||||||
|
<EditorI18nProvider>
|
||||||
|
<SceneEditorApp />
|
||||||
|
</EditorI18nProvider>
|
||||||
|
</React.StrictMode>,
|
||||||
|
);
|
||||||
@@ -8,12 +8,15 @@ import { SceneDarknessOverlay } from './effects/SceneDarknessOverlay';
|
|||||||
import { useEffectsState } from './effects/useEffectsState';
|
import { useEffectsState } from './effects/useEffectsState';
|
||||||
import { useSceneDarknessState } from './effects/useSceneDarknessState';
|
import { useSceneDarknessState } from './effects/useSceneDarknessState';
|
||||||
import { DEFAULT_MATERIALS_OVERLAY_LAYOUT, DEFAULT_NPCS_OVERLAY_LAYOUT } from '../../shared/types';
|
import { DEFAULT_MATERIALS_OVERLAY_LAYOUT, DEFAULT_NPCS_OVERLAY_LAYOUT } from '../../shared/types';
|
||||||
|
import { MaterialLegendPanel } from './materials/MaterialLegendPanel';
|
||||||
import { MaterialOverlay } from './materials/MaterialOverlay';
|
import { MaterialOverlay } from './materials/MaterialOverlay';
|
||||||
import { useMaterialsOverlayState } from './materials/useMaterialsOverlayState';
|
import { useMaterialsOverlayState } from './materials/useMaterialsOverlayState';
|
||||||
import { NpcsSceneOverlay } from './npcs/NpcsSceneOverlay';
|
import { NpcsSceneOverlay } from './npcs/NpcsSceneOverlay';
|
||||||
import { useNpcsOverlayState } from './npcs/useNpcsOverlayState';
|
import { useNpcsOverlayState } from './npcs/useNpcsOverlayState';
|
||||||
import { SceneOverlayHost } from './sceneOverlay/SceneOverlayHost';
|
import { SceneOverlayHost } from './sceneOverlay/SceneOverlayHost';
|
||||||
import { useSceneViewState } from './sceneView/useSceneViewState';
|
import { useSceneViewState } from './sceneView/useSceneViewState';
|
||||||
|
import { SceneTrapsOverlay } from './traps/SceneTrapsOverlay';
|
||||||
|
import { useSceneTrapsState } from './traps/useSceneTrapsState';
|
||||||
import styles from './PresentationView.module.css';
|
import styles from './PresentationView.module.css';
|
||||||
import { RotatedImage } from './RotatedImage';
|
import { RotatedImage } from './RotatedImage';
|
||||||
import { useAssetUrl } from './useAssetImageUrl';
|
import { useAssetUrl } from './useAssetImageUrl';
|
||||||
@@ -35,6 +38,7 @@ export function PresentationView({
|
|||||||
}: PresentationViewProps) {
|
}: PresentationViewProps) {
|
||||||
const [fxState] = useEffectsState();
|
const [fxState] = useEffectsState();
|
||||||
const [sdState] = useSceneDarknessState();
|
const [sdState] = useSceneDarknessState();
|
||||||
|
const [sceneTraps] = useSceneTrapsState();
|
||||||
const [sceneView] = useSceneViewState();
|
const [sceneView] = useSceneViewState();
|
||||||
const [materialsOverlay] = useMaterialsOverlayState();
|
const [materialsOverlay] = useMaterialsOverlayState();
|
||||||
const [npcsOverlay] = useNpcsOverlayState();
|
const [npcsOverlay] = useNpcsOverlayState();
|
||||||
@@ -165,6 +169,14 @@ export function PresentationView({
|
|||||||
}
|
}
|
||||||
/>
|
/>
|
||||||
) : null}
|
) : null}
|
||||||
|
{scene?.previewAssetType === 'image' && contentRect ? (
|
||||||
|
<SceneTrapsOverlay
|
||||||
|
traps={scene.traps ?? []}
|
||||||
|
session={sceneTraps}
|
||||||
|
viewport={contentRect}
|
||||||
|
mode="presentation"
|
||||||
|
/>
|
||||||
|
) : null}
|
||||||
{showEffects && scene?.previewAssetType === 'image' && scene.darkenScene && contentRect ? (
|
{showEffects && scene?.previewAssetType === 'image' && scene.darkenScene && contentRect ? (
|
||||||
<SceneDarknessOverlay state={sdState} overlayAlpha={1} viewport={contentRect} />
|
<SceneDarknessOverlay state={sdState} overlayAlpha={1} viewport={contentRect} />
|
||||||
) : null}
|
) : null}
|
||||||
@@ -174,6 +186,15 @@ export function PresentationView({
|
|||||||
embedded
|
embedded
|
||||||
assetId={activeMaterial.assetId}
|
assetId={activeMaterial.assetId}
|
||||||
layout={materialsOverlay?.layout ?? DEFAULT_MATERIALS_OVERLAY_LAYOUT}
|
layout={materialsOverlay?.layout ?? DEFAULT_MATERIALS_OVERLAY_LAYOUT}
|
||||||
|
legendMarkers={
|
||||||
|
activeMaterial.legend?.enabled ? (activeMaterial.legend.markers ?? []) : undefined
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
) : null}
|
||||||
|
{activeMaterial?.legend?.enabled ? (
|
||||||
|
<MaterialLegendPanel
|
||||||
|
legend={activeMaterial.legend}
|
||||||
|
layout={materialsOverlay?.legendLayout ?? DEFAULT_MATERIALS_OVERLAY_LAYOUT}
|
||||||
/>
|
/>
|
||||||
) : null}
|
) : null}
|
||||||
{activeNpcItems.length > 0 ? <NpcsSceneOverlay embedded items={activeNpcItems} /> : null}
|
{activeNpcItems.length > 0 ? <NpcsSceneOverlay embedded items={activeNpcItems} /> : null}
|
||||||
|
|||||||
@@ -88,7 +88,7 @@ export function SceneDarknessOverlay({
|
|||||||
height: viewport.h,
|
height: viewport.h,
|
||||||
opacity: overlayAlpha,
|
opacity: overlayAlpha,
|
||||||
pointerEvents: 'none',
|
pointerEvents: 'none',
|
||||||
zIndex: 2,
|
zIndex: 3,
|
||||||
...style,
|
...style,
|
||||||
}}
|
}}
|
||||||
/>
|
/>
|
||||||
|
|||||||
@@ -0,0 +1,59 @@
|
|||||||
|
.panel {
|
||||||
|
position: absolute;
|
||||||
|
z-index: 45;
|
||||||
|
min-width: 200px;
|
||||||
|
max-width: min(360px, 42vw);
|
||||||
|
max-height: min(70vh, 520px);
|
||||||
|
overflow: auto;
|
||||||
|
padding: 14px 16px;
|
||||||
|
border-radius: 14px;
|
||||||
|
border: 1px solid rgba(255, 255, 255, 0.14);
|
||||||
|
background: linear-gradient(160deg, rgba(18, 22, 30, 0.94), rgba(12, 14, 20, 0.92));
|
||||||
|
box-shadow: 0 18px 40px rgba(0, 0, 0, 0.45);
|
||||||
|
color: #f2f4f8;
|
||||||
|
backdrop-filter: blur(8px);
|
||||||
|
cursor: move;
|
||||||
|
touch-action: none;
|
||||||
|
user-select: none;
|
||||||
|
pointer-events: auto;
|
||||||
|
}
|
||||||
|
|
||||||
|
.title {
|
||||||
|
font-size: 12px;
|
||||||
|
font-weight: 800;
|
||||||
|
letter-spacing: 0.08em;
|
||||||
|
text-transform: uppercase;
|
||||||
|
opacity: 0.7;
|
||||||
|
margin-bottom: 10px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.item {
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: 28px 1fr;
|
||||||
|
gap: 10px;
|
||||||
|
align-items: start;
|
||||||
|
padding: 6px 0;
|
||||||
|
border-top: 1px solid rgba(255, 255, 255, 0.06);
|
||||||
|
}
|
||||||
|
|
||||||
|
.item:first-of-type {
|
||||||
|
border-top: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.num {
|
||||||
|
width: 28px;
|
||||||
|
height: 28px;
|
||||||
|
border-radius: 50%;
|
||||||
|
display: grid;
|
||||||
|
place-items: center;
|
||||||
|
font-weight: 800;
|
||||||
|
font-size: 12px;
|
||||||
|
background: #1e3a5f;
|
||||||
|
border: 2px solid #f5c542;
|
||||||
|
}
|
||||||
|
|
||||||
|
.text {
|
||||||
|
font-size: 13px;
|
||||||
|
line-height: 1.35;
|
||||||
|
padding-top: 4px;
|
||||||
|
}
|
||||||
@@ -0,0 +1,75 @@
|
|||||||
|
import React, { useRef } from 'react';
|
||||||
|
|
||||||
|
import type { MaterialLegend, MaterialsOverlayLayout } from '../../../shared/types';
|
||||||
|
import { DEFAULT_MATERIALS_OVERLAY_LAYOUT } from '../../../shared/types';
|
||||||
|
import { useSceneOverlayView } from '../sceneOverlay/SceneOverlayViewContext';
|
||||||
|
|
||||||
|
import styles from './MaterialLegendPanel.module.css';
|
||||||
|
|
||||||
|
type Props = {
|
||||||
|
legend: MaterialLegend;
|
||||||
|
layout: MaterialsOverlayLayout;
|
||||||
|
editable?: boolean;
|
||||||
|
onLayoutChange?: (layout: MaterialsOverlayLayout) => void;
|
||||||
|
};
|
||||||
|
|
||||||
|
export function MaterialLegendPanel({
|
||||||
|
legend,
|
||||||
|
layout,
|
||||||
|
editable = false,
|
||||||
|
onLayoutChange,
|
||||||
|
}: Props) {
|
||||||
|
const host = useSceneOverlayView();
|
||||||
|
const view = host?.view ?? { w: 1, h: 1 };
|
||||||
|
const dragRef = useRef<{ startX: number; startY: number; origin: MaterialsOverlayLayout } | null>(
|
||||||
|
null,
|
||||||
|
);
|
||||||
|
const effective = layout ?? DEFAULT_MATERIALS_OVERLAY_LAYOUT;
|
||||||
|
const w = Math.max(180, Math.min(view.w * 0.36, 340) * effective.scale);
|
||||||
|
const left = effective.cx * view.w - w / 2;
|
||||||
|
const top = effective.cy * view.h - 40;
|
||||||
|
|
||||||
|
if (!legend.enabled || legend.items.length === 0) return null;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
className={styles.panel}
|
||||||
|
style={{ left, top, width: w }}
|
||||||
|
onPointerDown={(e) => {
|
||||||
|
if (!editable || !onLayoutChange) return;
|
||||||
|
e.stopPropagation();
|
||||||
|
(e.currentTarget as HTMLDivElement).setPointerCapture(e.pointerId);
|
||||||
|
dragRef.current = {
|
||||||
|
startX: e.clientX,
|
||||||
|
startY: e.clientY,
|
||||||
|
origin: { ...effective },
|
||||||
|
};
|
||||||
|
}}
|
||||||
|
onPointerMove={(e) => {
|
||||||
|
const d = dragRef.current;
|
||||||
|
if (!d || !onLayoutChange) return;
|
||||||
|
const dx = (e.clientX - d.startX) / Math.max(1, view.w);
|
||||||
|
const dy = (e.clientY - d.startY) / Math.max(1, view.h);
|
||||||
|
onLayoutChange({
|
||||||
|
...d.origin,
|
||||||
|
cx: d.origin.cx + dx,
|
||||||
|
cy: d.origin.cy + dy,
|
||||||
|
});
|
||||||
|
}}
|
||||||
|
onPointerUp={() => {
|
||||||
|
dragRef.current = null;
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<div className={styles.title}>Легенда</div>
|
||||||
|
{legend.items
|
||||||
|
.slice()
|
||||||
|
.sort((a, b) => a.number - b.number)
|
||||||
|
.map((item) => (
|
||||||
|
<div key={item.id} className={styles.item}>
|
||||||
|
<div className={styles.num}>{item.number}</div>
|
||||||
|
<div className={styles.text}>{item.text || '—'}</div>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -50,15 +50,33 @@
|
|||||||
|
|
||||||
.image {
|
.image {
|
||||||
position: absolute;
|
position: absolute;
|
||||||
left: 50%;
|
inset: 0;
|
||||||
top: 50%;
|
width: 100%;
|
||||||
|
height: 100%;
|
||||||
display: block;
|
display: block;
|
||||||
object-fit: fill;
|
object-fit: fill;
|
||||||
border-radius: 6px;
|
border-radius: 6px;
|
||||||
box-shadow: 0 18px 48px rgba(0, 0, 0, 0.55);
|
box-shadow: 0 18px 48px rgba(0, 0, 0, 0.55);
|
||||||
user-select: none;
|
user-select: none;
|
||||||
pointer-events: none;
|
pointer-events: none;
|
||||||
transform-origin: center center;
|
}
|
||||||
|
|
||||||
|
.legendMarker {
|
||||||
|
position: absolute;
|
||||||
|
transform: translate(-50%, -50%);
|
||||||
|
width: 26px;
|
||||||
|
height: 26px;
|
||||||
|
border-radius: 50%;
|
||||||
|
background: #1e3a5f;
|
||||||
|
border: 2px solid #f5c542;
|
||||||
|
color: #fff;
|
||||||
|
font-size: 12px;
|
||||||
|
font-weight: 800;
|
||||||
|
display: grid;
|
||||||
|
place-items: center;
|
||||||
|
pointer-events: none;
|
||||||
|
z-index: 2;
|
||||||
|
box-shadow: 0 2px 8px rgba(0, 0, 0, 0.35);
|
||||||
}
|
}
|
||||||
|
|
||||||
.handle {
|
.handle {
|
||||||
|
|||||||
@@ -22,6 +22,8 @@ type MaterialOverlayProps = {
|
|||||||
onZoomAt?: (nx: number, ny: number) => void;
|
onZoomAt?: (nx: number, ny: number) => void;
|
||||||
/** Без собственного root/dim — внутри `SceneOverlayHost`. */
|
/** Без собственного root/dim — внутри `SceneOverlayHost`. */
|
||||||
embedded?: boolean;
|
embedded?: boolean;
|
||||||
|
/** Маркеры легенды (норм. координаты картинки). */
|
||||||
|
legendMarkers?: readonly { id: string; number: number; nx: number; ny: number }[];
|
||||||
};
|
};
|
||||||
|
|
||||||
function RotateIcon() {
|
function RotateIcon() {
|
||||||
@@ -99,6 +101,7 @@ export function MaterialOverlay({
|
|||||||
onLayoutChange,
|
onLayoutChange,
|
||||||
onZoomAt,
|
onZoomAt,
|
||||||
embedded = false,
|
embedded = false,
|
||||||
|
legendMarkers,
|
||||||
}: MaterialOverlayProps) {
|
}: MaterialOverlayProps) {
|
||||||
const url = useAssetUrl(assetId);
|
const url = useAssetUrl(assetId);
|
||||||
const host = useSceneOverlayView();
|
const host = useSceneOverlayView();
|
||||||
@@ -381,16 +384,23 @@ export function MaterialOverlay({
|
|||||||
src={url}
|
src={url}
|
||||||
alt=""
|
alt=""
|
||||||
draggable={false}
|
draggable={false}
|
||||||
style={{
|
|
||||||
width: w,
|
|
||||||
height: h,
|
|
||||||
transform: 'translate(-50%, -50%)',
|
|
||||||
}}
|
|
||||||
onLoad={(e) => {
|
onLoad={(e) => {
|
||||||
const img = e.currentTarget;
|
const img = e.currentTarget;
|
||||||
setNatural({ w: img.naturalWidth || 1, h: img.naturalHeight || 1 });
|
setNatural({ w: img.naturalWidth || 1, h: img.naturalHeight || 1 });
|
||||||
}}
|
}}
|
||||||
/>
|
/>
|
||||||
|
{(legendMarkers ?? []).map((m) => (
|
||||||
|
<div
|
||||||
|
key={m.id}
|
||||||
|
className={styles.legendMarker}
|
||||||
|
style={{
|
||||||
|
left: `${String(m.nx * 100)}%`,
|
||||||
|
top: `${String(m.ny * 100)}%`,
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{m.number}
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
{editable && !zoomTool
|
{editable && !zoomTool
|
||||||
? (['nw', 'ne', 'sw', 'se'] as const).map((corner) => (
|
? (['nw', 'ne', 'sw', 'se'] as const).map((corner) => (
|
||||||
<button
|
<button
|
||||||
|
|||||||
@@ -0,0 +1,100 @@
|
|||||||
|
.layer {
|
||||||
|
position: absolute;
|
||||||
|
inset: 0;
|
||||||
|
pointer-events: none;
|
||||||
|
/* Выше brushLayer (z-index: 3), иначе ПКМ/меню перехватывает кисть. */
|
||||||
|
z-index: 5;
|
||||||
|
}
|
||||||
|
|
||||||
|
.trap {
|
||||||
|
position: absolute;
|
||||||
|
transform: translate(-50%, -50%);
|
||||||
|
border-radius: 50%;
|
||||||
|
border: 2px solid rgba(255, 255, 255, 0.5);
|
||||||
|
background: rgba(0, 0, 0, 0.5);
|
||||||
|
display: grid;
|
||||||
|
place-items: center;
|
||||||
|
pointer-events: auto;
|
||||||
|
cursor: context-menu;
|
||||||
|
box-shadow: 0 0 0 1px rgba(0, 0, 0, 0.35);
|
||||||
|
}
|
||||||
|
|
||||||
|
.trapActive {
|
||||||
|
border-color: #ff6b4a;
|
||||||
|
box-shadow:
|
||||||
|
0 0 0 1px rgba(0, 0, 0, 0.35),
|
||||||
|
0 0 14px rgba(255, 90, 40, 0.45);
|
||||||
|
}
|
||||||
|
|
||||||
|
.trapDisarmed {
|
||||||
|
border-color: #9ca3af;
|
||||||
|
filter: grayscale(0.7);
|
||||||
|
opacity: 0.75;
|
||||||
|
}
|
||||||
|
|
||||||
|
.trapGmHidden {
|
||||||
|
opacity: 0.55;
|
||||||
|
border-style: dashed;
|
||||||
|
}
|
||||||
|
|
||||||
|
.label {
|
||||||
|
position: absolute;
|
||||||
|
left: 50%;
|
||||||
|
top: calc(100% + 3px);
|
||||||
|
transform: translateX(-50%);
|
||||||
|
font-size: 11px;
|
||||||
|
white-space: nowrap;
|
||||||
|
background: rgba(0, 0, 0, 0.75);
|
||||||
|
padding: 1px 6px;
|
||||||
|
border-radius: 4px;
|
||||||
|
pointer-events: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
.menu {
|
||||||
|
position: fixed;
|
||||||
|
z-index: 80;
|
||||||
|
min-width: 180px;
|
||||||
|
padding: 6px;
|
||||||
|
border-radius: 10px;
|
||||||
|
border: 1px solid var(--stroke, #333);
|
||||||
|
background: #1a1d24;
|
||||||
|
box-shadow: 0 12px 32px rgba(0, 0, 0, 0.45);
|
||||||
|
pointer-events: auto;
|
||||||
|
}
|
||||||
|
|
||||||
|
.menuItem {
|
||||||
|
display: block;
|
||||||
|
width: 100%;
|
||||||
|
text-align: left;
|
||||||
|
padding: 8px 10px;
|
||||||
|
border: 0;
|
||||||
|
border-radius: 6px;
|
||||||
|
background: transparent;
|
||||||
|
color: #e8eaef;
|
||||||
|
font-size: 13px;
|
||||||
|
cursor: pointer;
|
||||||
|
}
|
||||||
|
|
||||||
|
.menuItem:hover {
|
||||||
|
background: rgba(255, 255, 255, 0.08);
|
||||||
|
}
|
||||||
|
|
||||||
|
.flash {
|
||||||
|
position: absolute;
|
||||||
|
inset: -30%;
|
||||||
|
border-radius: 50%;
|
||||||
|
background: radial-gradient(circle, rgba(255, 200, 80, 0.85), transparent 70%);
|
||||||
|
animation: trapFlash 0.7s ease-out forwards;
|
||||||
|
pointer-events: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
@keyframes trapFlash {
|
||||||
|
from {
|
||||||
|
opacity: 1;
|
||||||
|
transform: scale(0.4);
|
||||||
|
}
|
||||||
|
to {
|
||||||
|
opacity: 0;
|
||||||
|
transform: scale(1.6);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,151 @@
|
|||||||
|
import React, { useEffect, useState } from 'react';
|
||||||
|
import { createPortal } from 'react-dom';
|
||||||
|
|
||||||
|
import type { SceneTrap, SceneTrapsState } from '../../../shared/types';
|
||||||
|
import { defaultTrapRuntime } from '../../../shared/types/sceneTraps';
|
||||||
|
|
||||||
|
import { TrapGlyph } from './TrapGlyph';
|
||||||
|
import styles from './SceneTrapsOverlay.module.css';
|
||||||
|
|
||||||
|
type Props = {
|
||||||
|
traps: readonly SceneTrap[];
|
||||||
|
session: SceneTrapsState | null;
|
||||||
|
viewport: { x: number; y: number; w: number; h: number } | null;
|
||||||
|
/** Пульт: показывать все ловушки + RMB меню. Презентация: только revealed. */
|
||||||
|
mode: 'control' | 'presentation';
|
||||||
|
onReveal?: (trapId: string) => void;
|
||||||
|
onActivate?: (trapId: string) => void;
|
||||||
|
onDisarm?: (trapId: string) => void;
|
||||||
|
};
|
||||||
|
|
||||||
|
function menuPosition(clientX: number, clientY: number): { x: number; y: number } {
|
||||||
|
const menuW = 200;
|
||||||
|
const menuH = 140;
|
||||||
|
const pad = 8;
|
||||||
|
return {
|
||||||
|
x: Math.max(pad, Math.min(clientX, window.innerWidth - menuW - pad)),
|
||||||
|
y: Math.max(pad, Math.min(clientY, window.innerHeight - menuH - pad)),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
export function SceneTrapsOverlay({
|
||||||
|
traps,
|
||||||
|
session,
|
||||||
|
viewport,
|
||||||
|
mode,
|
||||||
|
onReveal,
|
||||||
|
onActivate,
|
||||||
|
onDisarm,
|
||||||
|
}: Props) {
|
||||||
|
const [menu, setMenu] = useState<{ trapId: string; x: number; y: number } | null>(null);
|
||||||
|
const [flashToken, setFlashToken] = useState<{ trapId: string; token: number } | null>(null);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
const act = session?.lastActivation;
|
||||||
|
if (!act) return;
|
||||||
|
setFlashToken(act);
|
||||||
|
const t = window.setTimeout(() => setFlashToken(null), 750);
|
||||||
|
return () => window.clearTimeout(t);
|
||||||
|
}, [session?.lastActivation?.token, session?.lastActivation?.trapId]);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (!menu) return;
|
||||||
|
const close = (e: PointerEvent) => {
|
||||||
|
const t = e.target;
|
||||||
|
if (t instanceof Element && t.closest('[data-trap-menu-root="1"]')) return;
|
||||||
|
setMenu(null);
|
||||||
|
};
|
||||||
|
window.addEventListener('pointerdown', close, true);
|
||||||
|
return () => window.removeEventListener('pointerdown', close, true);
|
||||||
|
}, [menu]);
|
||||||
|
|
||||||
|
if (!viewport || traps.length === 0) return null;
|
||||||
|
const minDim = Math.min(viewport.w, viewport.h);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className={styles.layer}>
|
||||||
|
{traps.map((trap) => {
|
||||||
|
const rt = session?.byId[trap.id] ?? defaultTrapRuntime();
|
||||||
|
if (mode === 'presentation' && !rt.revealed) return null;
|
||||||
|
const sizePx = Math.max(16, trap.sizeN * minDim);
|
||||||
|
const left = viewport.x + trap.nx * viewport.w;
|
||||||
|
const top = viewport.y + trap.ny * viewport.h;
|
||||||
|
const cls = [
|
||||||
|
styles.trap,
|
||||||
|
rt.status === 'active' ? styles.trapActive : '',
|
||||||
|
rt.status === 'disarmed' ? styles.trapDisarmed : '',
|
||||||
|
mode === 'control' && !rt.revealed ? styles.trapGmHidden : '',
|
||||||
|
]
|
||||||
|
.filter(Boolean)
|
||||||
|
.join(' ');
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
key={trap.id}
|
||||||
|
className={cls}
|
||||||
|
style={{ left, top, width: sizePx, height: sizePx }}
|
||||||
|
onContextMenu={
|
||||||
|
mode === 'control'
|
||||||
|
? (e) => {
|
||||||
|
e.preventDefault();
|
||||||
|
e.stopPropagation();
|
||||||
|
const pos = menuPosition(e.clientX, e.clientY);
|
||||||
|
setMenu({ trapId: trap.id, x: pos.x, y: pos.y });
|
||||||
|
}
|
||||||
|
: undefined
|
||||||
|
}
|
||||||
|
>
|
||||||
|
<TrapGlyph type={trap.type} status={rt.status} size={Math.max(14, sizePx * 0.55)} />
|
||||||
|
{trap.label ? <div className={styles.label}>{trap.label}</div> : null}
|
||||||
|
{flashToken?.trapId === trap.id ? <div className={styles.flash} /> : null}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
{menu && mode === 'control'
|
||||||
|
? createPortal(
|
||||||
|
<div
|
||||||
|
role="menu"
|
||||||
|
data-trap-menu-root="1"
|
||||||
|
className={styles.menu}
|
||||||
|
style={{ left: menu.x, top: menu.y }}
|
||||||
|
onPointerDown={(e) => e.stopPropagation()}
|
||||||
|
>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
role="menuitem"
|
||||||
|
className={styles.menuItem}
|
||||||
|
onClick={() => {
|
||||||
|
onReveal?.(menu.trapId);
|
||||||
|
setMenu(null);
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
Проявить
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
role="menuitem"
|
||||||
|
className={styles.menuItem}
|
||||||
|
onClick={() => {
|
||||||
|
onActivate?.(menu.trapId);
|
||||||
|
setMenu(null);
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
Активировать
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
role="menuitem"
|
||||||
|
className={styles.menuItem}
|
||||||
|
onClick={() => {
|
||||||
|
onDisarm?.(menu.trapId);
|
||||||
|
setMenu(null);
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
Обезвредить
|
||||||
|
</button>
|
||||||
|
</div>,
|
||||||
|
document.body,
|
||||||
|
)
|
||||||
|
: null}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,103 @@
|
|||||||
|
/** Простые SVG-иконки ловушек (MVP). */
|
||||||
|
|
||||||
|
import type { SceneTrapStatus, SceneTrapType } from '../../shared/types';
|
||||||
|
|
||||||
|
const TYPE_COLOR: Record<SceneTrapType, string> = {
|
||||||
|
mimic: '#c4783a',
|
||||||
|
explosion: '#e85d3a',
|
||||||
|
poison: '#6bcb5a',
|
||||||
|
pit: '#5a5a6e',
|
||||||
|
arrow: '#d4a017',
|
||||||
|
laser: '#3ad0e8',
|
||||||
|
freeform: '#a78bfa',
|
||||||
|
};
|
||||||
|
|
||||||
|
export function trapAccentColor(type: SceneTrapType, status: SceneTrapStatus = 'inactive'): string {
|
||||||
|
if (status === 'disarmed') return '#6b7280';
|
||||||
|
if (status === 'active') return TYPE_COLOR[type];
|
||||||
|
return TYPE_COLOR[type];
|
||||||
|
}
|
||||||
|
|
||||||
|
export function TrapGlyph({
|
||||||
|
type,
|
||||||
|
status = 'inactive',
|
||||||
|
size = 28,
|
||||||
|
}: {
|
||||||
|
type: SceneTrapType;
|
||||||
|
status?: SceneTrapStatus;
|
||||||
|
size?: number;
|
||||||
|
}) {
|
||||||
|
const stroke = trapAccentColor(type, status);
|
||||||
|
const opacity = status === 'disarmed' ? 0.55 : 1;
|
||||||
|
const common = {
|
||||||
|
width: size,
|
||||||
|
height: size,
|
||||||
|
viewBox: '0 0 24 24',
|
||||||
|
fill: 'none',
|
||||||
|
stroke,
|
||||||
|
strokeWidth: 1.8,
|
||||||
|
strokeLinecap: 'round' as const,
|
||||||
|
strokeLinejoin: 'round' as const,
|
||||||
|
opacity,
|
||||||
|
'aria-hidden': true as const,
|
||||||
|
};
|
||||||
|
|
||||||
|
switch (type) {
|
||||||
|
case 'mimic':
|
||||||
|
return (
|
||||||
|
<svg {...common}>
|
||||||
|
<rect x="4" y="8" width="16" height="10" rx="1.5" />
|
||||||
|
<path d="M8 8 V6.5 a4 4 0 0 1 8 0 V8" />
|
||||||
|
<path d="M9 13h6M10 16h4" />
|
||||||
|
</svg>
|
||||||
|
);
|
||||||
|
case 'explosion':
|
||||||
|
return (
|
||||||
|
<svg {...common}>
|
||||||
|
<circle cx="12" cy="12" r="3.2" fill={stroke} stroke="none" opacity={opacity * 0.9} />
|
||||||
|
<path d="M12 3v3M12 18v3M3 12h3M18 12h3M5.6 5.6l2.1 2.1M16.3 16.3l2.1 2.1M18.4 5.6l-2.1 2.1M7.7 16.3l-2.1 2.1" />
|
||||||
|
</svg>
|
||||||
|
);
|
||||||
|
case 'poison':
|
||||||
|
return (
|
||||||
|
<svg {...common}>
|
||||||
|
<path d="M12 3c2.5 3.5 5 6.2 5 10a5 5 0 1 1-10 0c0-3.8 2.5-6.5 5-10z" />
|
||||||
|
<circle cx="10" cy="14" r="0.9" fill={stroke} stroke="none" />
|
||||||
|
<circle cx="13.5" cy="15.5" r="0.7" fill={stroke} stroke="none" />
|
||||||
|
</svg>
|
||||||
|
);
|
||||||
|
case 'pit':
|
||||||
|
return (
|
||||||
|
<svg {...common}>
|
||||||
|
<path d="M4 8h16M6 8l2 10h8l2-10" />
|
||||||
|
<path d="M9 14h6" opacity={0.7} />
|
||||||
|
</svg>
|
||||||
|
);
|
||||||
|
case 'arrow':
|
||||||
|
return (
|
||||||
|
<svg {...common}>
|
||||||
|
<path d="M4 12h14" />
|
||||||
|
<path d="M14 7l5 5-5 5" />
|
||||||
|
<path d="M4 9v6" />
|
||||||
|
</svg>
|
||||||
|
);
|
||||||
|
case 'laser':
|
||||||
|
return (
|
||||||
|
<svg {...common}>
|
||||||
|
<circle cx="6" cy="12" r="2.2" />
|
||||||
|
<path d="M9 12h11" strokeWidth="2.4" />
|
||||||
|
<path d="M17 9l3 3-3 3" />
|
||||||
|
</svg>
|
||||||
|
);
|
||||||
|
case 'freeform':
|
||||||
|
return (
|
||||||
|
<svg {...common}>
|
||||||
|
<path d="M12 3l2.2 6.2H21l-5.2 3.8 2 6.5L12 16.2 6.2 19.5l2-6.5L3 9.2h6.8z" />
|
||||||
|
</svg>
|
||||||
|
);
|
||||||
|
default: {
|
||||||
|
const _x: never = type;
|
||||||
|
return <svg {...common}>{String(_x)}</svg>;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,31 @@
|
|||||||
|
import { useEffect, useState } from 'react';
|
||||||
|
|
||||||
|
import { ipcChannels } from '../../../shared/ipc/contracts';
|
||||||
|
import type { SceneTrapsEvent, SceneTrapsState } from '../../../shared/types';
|
||||||
|
import { getDndApi } from '../dndApi';
|
||||||
|
|
||||||
|
export function useSceneTrapsState(): readonly [
|
||||||
|
SceneTrapsState | null,
|
||||||
|
{ dispatch: (event: SceneTrapsEvent) => Promise<void> },
|
||||||
|
] {
|
||||||
|
const api = getDndApi();
|
||||||
|
const [state, setState] = useState<SceneTrapsState | null>(null);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
void api.invoke(ipcChannels.sceneTraps.getState, {}).then((r) => {
|
||||||
|
setState(r.state);
|
||||||
|
});
|
||||||
|
return api.on(ipcChannels.sceneTraps.stateChanged, ({ state: next }) => {
|
||||||
|
setState(next);
|
||||||
|
});
|
||||||
|
}, [api]);
|
||||||
|
|
||||||
|
return [
|
||||||
|
state,
|
||||||
|
{
|
||||||
|
dispatch: async (event) => {
|
||||||
|
await api.invoke(ipcChannels.sceneTraps.dispatch, { event });
|
||||||
|
},
|
||||||
|
},
|
||||||
|
] as const;
|
||||||
|
}
|
||||||
@@ -23,6 +23,7 @@ export type AppWindowKind =
|
|||||||
| 'sceneDescription'
|
| 'sceneDescription'
|
||||||
| 'materials'
|
| 'materials'
|
||||||
| 'npcsEditor'
|
| 'npcsEditor'
|
||||||
|
| 'sceneEditor'
|
||||||
| 'npcs';
|
| 'npcs';
|
||||||
|
|
||||||
const WINDOW_SUFFIX: Record<AppWindowKind, { ru: string; en: string }> = {
|
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' },
|
sceneDescription: { ru: 'Описание сцены', en: 'Scene description' },
|
||||||
materials: { ru: 'Материалы', en: 'Materials' },
|
materials: { ru: 'Материалы', en: 'Materials' },
|
||||||
npcsEditor: { ru: 'НПС', en: 'NPCs' },
|
npcsEditor: { ru: 'НПС', en: 'NPCs' },
|
||||||
|
sceneEditor: { ru: 'Редактор сцены', en: 'Scene editor' },
|
||||||
npcs: { ru: 'НПС', en: 'NPCs' },
|
npcs: { ru: 'НПС', en: 'NPCs' },
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|||||||
@@ -22,6 +22,7 @@ function scene(id: string): Scene {
|
|||||||
previewVideoAutostart: false,
|
previewVideoAutostart: false,
|
||||||
previewRotationDeg: 0,
|
previewRotationDeg: 0,
|
||||||
darkenScene: false,
|
darkenScene: false,
|
||||||
|
traps: [],
|
||||||
media: { videos: [], audios: [] },
|
media: { videos: [], audios: [] },
|
||||||
settings: { autoplayVideo: false, autoplayAudio: true, loopVideo: true, loopAudio: true },
|
settings: { autoplayVideo: false, autoplayAudio: true, loopVideo: true, loopAudio: true },
|
||||||
connections: [],
|
connections: [],
|
||||||
|
|||||||
@@ -27,6 +27,7 @@ function scene(id: string, title: string): Scene {
|
|||||||
previewVideoAutostart: false,
|
previewVideoAutostart: false,
|
||||||
previewRotationDeg: 0,
|
previewRotationDeg: 0,
|
||||||
darkenScene: false,
|
darkenScene: false,
|
||||||
|
traps: [],
|
||||||
media: { videos: [], audios: [] },
|
media: { videos: [], audios: [] },
|
||||||
settings: { autoplayVideo: false, autoplayAudio: false, loopVideo: false, loopAudio: false },
|
settings: { autoplayVideo: false, autoplayAudio: false, loopVideo: false, loopAudio: false },
|
||||||
connections: [],
|
connections: [],
|
||||||
|
|||||||
@@ -5,6 +5,7 @@ import type {
|
|||||||
EffectsState,
|
EffectsState,
|
||||||
GraphNodeId,
|
GraphNodeId,
|
||||||
MaterialId,
|
MaterialId,
|
||||||
|
MaterialLegend,
|
||||||
MaterialsOverlayEvent,
|
MaterialsOverlayEvent,
|
||||||
MaterialsOverlayState,
|
MaterialsOverlayState,
|
||||||
MediaAsset,
|
MediaAsset,
|
||||||
@@ -20,6 +21,9 @@ import type {
|
|||||||
SceneDarknessEvent,
|
SceneDarknessEvent,
|
||||||
SceneDarknessState,
|
SceneDarknessState,
|
||||||
SceneId,
|
SceneId,
|
||||||
|
SceneTrap,
|
||||||
|
SceneTrapsEvent,
|
||||||
|
SceneTrapsState,
|
||||||
SceneViewEvent,
|
SceneViewEvent,
|
||||||
SceneViewState,
|
SceneViewState,
|
||||||
VideoPlaybackEvent,
|
VideoPlaybackEvent,
|
||||||
@@ -60,6 +64,7 @@ export const ipcChannels = {
|
|||||||
updateCampaignAudios: 'project.updateCampaignAudios',
|
updateCampaignAudios: 'project.updateCampaignAudios',
|
||||||
upsertMaterial: 'project.upsertMaterial',
|
upsertMaterial: 'project.upsertMaterial',
|
||||||
setMaterialRotation: 'project.setMaterialRotation',
|
setMaterialRotation: 'project.setMaterialRotation',
|
||||||
|
setMaterialLegend: 'project.setMaterialLegend',
|
||||||
deleteMaterial: 'project.deleteMaterial',
|
deleteMaterial: 'project.deleteMaterial',
|
||||||
setMaterialsOrder: 'project.setMaterialsOrder',
|
setMaterialsOrder: 'project.setMaterialsOrder',
|
||||||
pickMaterialImage: 'project.pickMaterialImage',
|
pickMaterialImage: 'project.pickMaterialImage',
|
||||||
@@ -121,6 +126,8 @@ export const ipcChannels = {
|
|||||||
closeNpcsEditor: 'windows.closeNpcsEditor',
|
closeNpcsEditor: 'windows.closeNpcsEditor',
|
||||||
openNpcs: 'windows.openNpcs',
|
openNpcs: 'windows.openNpcs',
|
||||||
closeNpcs: 'windows.closeNpcs',
|
closeNpcs: 'windows.closeNpcs',
|
||||||
|
openSceneEditor: 'windows.openSceneEditor',
|
||||||
|
closeSceneEditor: 'windows.closeSceneEditor',
|
||||||
},
|
},
|
||||||
session: {
|
session: {
|
||||||
stateChanged: 'session.stateChanged',
|
stateChanged: 'session.stateChanged',
|
||||||
@@ -145,6 +152,11 @@ export const ipcChannels = {
|
|||||||
dispatch: 'sceneDarkness.dispatch',
|
dispatch: 'sceneDarkness.dispatch',
|
||||||
stateChanged: 'sceneDarkness.stateChanged',
|
stateChanged: 'sceneDarkness.stateChanged',
|
||||||
},
|
},
|
||||||
|
sceneTraps: {
|
||||||
|
getState: 'sceneTraps.getState',
|
||||||
|
dispatch: 'sceneTraps.dispatch',
|
||||||
|
stateChanged: 'sceneTraps.stateChanged',
|
||||||
|
},
|
||||||
sceneView: {
|
sceneView: {
|
||||||
getState: 'sceneView.getState',
|
getState: 'sceneView.getState',
|
||||||
dispatch: 'sceneView.dispatch',
|
dispatch: 'sceneView.dispatch',
|
||||||
@@ -210,6 +222,7 @@ export type IpcEventMap = {
|
|||||||
[ipcChannels.materialsOverlay.stateChanged]: { state: MaterialsOverlayState };
|
[ipcChannels.materialsOverlay.stateChanged]: { state: MaterialsOverlayState };
|
||||||
[ipcChannels.npcsOverlay.stateChanged]: { state: NpcsOverlayState };
|
[ipcChannels.npcsOverlay.stateChanged]: { state: NpcsOverlayState };
|
||||||
[ipcChannels.sceneDarkness.stateChanged]: { state: SceneDarknessState };
|
[ipcChannels.sceneDarkness.stateChanged]: { state: SceneDarknessState };
|
||||||
|
[ipcChannels.sceneTraps.stateChanged]: { state: SceneTrapsState };
|
||||||
[ipcChannels.sceneView.stateChanged]: { state: SceneViewState };
|
[ipcChannels.sceneView.stateChanged]: { state: SceneViewState };
|
||||||
[ipcChannels.video.stateChanged]: { state: VideoPlaybackState };
|
[ipcChannels.video.stateChanged]: { state: VideoPlaybackState };
|
||||||
[ipcChannels.license.statusChanged]: { snapshot: LicenseSnapshot };
|
[ipcChannels.license.statusChanged]: { snapshot: LicenseSnapshot };
|
||||||
@@ -298,6 +311,10 @@ export type IpcInvokeMap = {
|
|||||||
req: { materialId: MaterialId; rotationDeg: 0 | 90 | 180 | 270 };
|
req: { materialId: MaterialId; rotationDeg: 0 | 90 | 180 | 270 };
|
||||||
res: { project: Project };
|
res: { project: Project };
|
||||||
};
|
};
|
||||||
|
[ipcChannels.project.setMaterialLegend]: {
|
||||||
|
req: { materialId: MaterialId; legend: MaterialLegend | null };
|
||||||
|
res: { project: Project };
|
||||||
|
};
|
||||||
[ipcChannels.project.deleteMaterial]: {
|
[ipcChannels.project.deleteMaterial]: {
|
||||||
req: { materialId: MaterialId };
|
req: { materialId: MaterialId };
|
||||||
res: { project: Project };
|
res: { project: Project };
|
||||||
@@ -569,6 +586,14 @@ export type IpcInvokeMap = {
|
|||||||
req: Record<string, never>;
|
req: Record<string, never>;
|
||||||
res: { ok: true };
|
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]: {
|
[ipcChannels.materialsOverlay.getState]: {
|
||||||
req: Record<string, never>;
|
req: Record<string, never>;
|
||||||
res: { state: MaterialsOverlayState };
|
res: { state: MaterialsOverlayState };
|
||||||
@@ -601,6 +626,14 @@ export type IpcInvokeMap = {
|
|||||||
req: { event: SceneDarknessEvent };
|
req: { event: SceneDarknessEvent };
|
||||||
res: { ok: true };
|
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]: {
|
[ipcChannels.sceneView.getState]: {
|
||||||
req: Record<string, never>;
|
req: Record<string, never>;
|
||||||
res: { state: SceneViewState };
|
res: { state: SceneViewState };
|
||||||
@@ -646,6 +679,7 @@ export type LegacyIpcEventMap = {
|
|||||||
[ipcChannels.materialsOverlay.stateChanged]: { state: MaterialsOverlayState };
|
[ipcChannels.materialsOverlay.stateChanged]: { state: MaterialsOverlayState };
|
||||||
[ipcChannels.npcsOverlay.stateChanged]: { state: NpcsOverlayState };
|
[ipcChannels.npcsOverlay.stateChanged]: { state: NpcsOverlayState };
|
||||||
[ipcChannels.sceneDarkness.stateChanged]: { state: SceneDarknessState };
|
[ipcChannels.sceneDarkness.stateChanged]: { state: SceneDarknessState };
|
||||||
|
[ipcChannels.sceneTraps.stateChanged]: { state: SceneTrapsState };
|
||||||
[ipcChannels.sceneView.stateChanged]: { state: SceneViewState };
|
[ipcChannels.sceneView.stateChanged]: { state: SceneViewState };
|
||||||
[ipcChannels.video.stateChanged]: { state: VideoPlaybackState };
|
[ipcChannels.video.stateChanged]: { state: VideoPlaybackState };
|
||||||
[ipcChannels.license.statusChanged]: Record<string, never>;
|
[ipcChannels.license.statusChanged]: Record<string, never>;
|
||||||
@@ -660,6 +694,7 @@ export type ScenePatch = {
|
|||||||
previewVideoAutostart?: boolean;
|
previewVideoAutostart?: boolean;
|
||||||
previewRotationDeg?: 0 | 90 | 180 | 270;
|
previewRotationDeg?: 0 | 90 | 180 | 270;
|
||||||
darkenScene?: boolean;
|
darkenScene?: boolean;
|
||||||
|
traps?: SceneTrap[];
|
||||||
settings?: Partial<Scene['settings']>;
|
settings?: Partial<Scene['settings']>;
|
||||||
media?: Partial<Scene['media']>;
|
media?: Partial<Scene['media']>;
|
||||||
layout?: Partial<Scene['layout']>;
|
layout?: Partial<Scene['layout']>;
|
||||||
|
|||||||
@@ -8,6 +8,8 @@ import type {
|
|||||||
ProjectId,
|
ProjectId,
|
||||||
SceneId,
|
SceneId,
|
||||||
} from './ids';
|
} from './ids';
|
||||||
|
import type { MaterialLegend } from './materialLegend';
|
||||||
|
import type { SceneTrap } from './sceneTraps';
|
||||||
|
|
||||||
export const PROJECT_SCHEMA_VERSION = 9 as const;
|
export const PROJECT_SCHEMA_VERSION = 9 as const;
|
||||||
|
|
||||||
@@ -17,6 +19,8 @@ export type ProjectMaterial = {
|
|||||||
name: string;
|
name: string;
|
||||||
assetId: AssetId;
|
assetId: AssetId;
|
||||||
rotationDeg: 0 | 90 | 180 | 270;
|
rotationDeg: 0 | 90 | 180 | 270;
|
||||||
|
/** Опциональная легенда (маркеры + список пунктов). */
|
||||||
|
legend?: MaterialLegend;
|
||||||
};
|
};
|
||||||
|
|
||||||
/** Группа НПС (дерево через parentId). */
|
/** Группа НПС (дерево через parentId). */
|
||||||
@@ -156,6 +160,8 @@ export type Scene = {
|
|||||||
previewRotationDeg: 0 | 90 | 180 | 270;
|
previewRotationDeg: 0 | 90 | 180 | 270;
|
||||||
/** В режиме показа: сцена начинается полностью затемнённой; мастер «раскрывает» кистью. */
|
/** В режиме показа: сцена начинается полностью затемнённой; мастер «раскрывает» кистью. */
|
||||||
darkenScene: boolean;
|
darkenScene: boolean;
|
||||||
|
/** Ловушки на карте (только для image-превью); расстановка в проекте. */
|
||||||
|
traps: SceneTrap[];
|
||||||
media: SceneMediaRefs;
|
media: SceneMediaRefs;
|
||||||
settings: SceneSettings;
|
settings: SceneSettings;
|
||||||
connections: SceneId[];
|
connections: SceneId[];
|
||||||
|
|||||||
@@ -1,8 +1,10 @@
|
|||||||
export * from './domain';
|
export * from './domain';
|
||||||
export * from './effects';
|
export * from './effects';
|
||||||
export * from './ids';
|
export * from './ids';
|
||||||
|
export * from './materialLegend';
|
||||||
export * from './materials';
|
export * from './materials';
|
||||||
export * from './npcs';
|
export * from './npcs';
|
||||||
export * from './sceneDarkness';
|
export * from './sceneDarkness';
|
||||||
|
export * from './sceneTraps';
|
||||||
export * from './sceneView';
|
export * from './sceneView';
|
||||||
export * from './videoPlayback';
|
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;
|
revision: number;
|
||||||
activeMaterialId: MaterialId | null;
|
activeMaterialId: MaterialId | null;
|
||||||
layout: MaterialsOverlayLayout;
|
layout: MaterialsOverlayLayout;
|
||||||
|
/** Раскладка блока описаний легенды (если материал с легендой). */
|
||||||
|
legendLayout: MaterialsOverlayLayout;
|
||||||
zoomTool: MaterialsZoomTool;
|
zoomTool: MaterialsZoomTool;
|
||||||
};
|
};
|
||||||
|
|
||||||
@@ -34,6 +36,7 @@ export type MaterialsOverlayEvent =
|
|||||||
| { kind: 'hide' }
|
| { kind: 'hide' }
|
||||||
| { kind: 'toggle'; materialId: MaterialId; rotationDeg?: number }
|
| { kind: 'toggle'; materialId: MaterialId; rotationDeg?: number }
|
||||||
| { kind: 'layout.set'; layout: MaterialsOverlayLayout }
|
| { kind: 'layout.set'; layout: MaterialsOverlayLayout }
|
||||||
|
| { kind: 'legendLayout.set'; layout: MaterialsOverlayLayout }
|
||||||
| { kind: 'zoomTool.set'; tool: MaterialsZoomTool }
|
| { kind: 'zoomTool.set'; tool: MaterialsZoomTool }
|
||||||
| { kind: 'zoomAt'; nx: number; ny: number };
|
| { 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 };
|
||||||
@@ -55,6 +55,7 @@ export default defineConfig(({ mode }) => {
|
|||||||
control: path.resolve(__dirname, 'app/renderer/control.html'),
|
control: path.resolve(__dirname, 'app/renderer/control.html'),
|
||||||
sceneDescription: path.resolve(__dirname, 'app/renderer/sceneDescription.html'),
|
sceneDescription: path.resolve(__dirname, 'app/renderer/sceneDescription.html'),
|
||||||
materials: path.resolve(__dirname, 'app/renderer/materials.html'),
|
materials: path.resolve(__dirname, 'app/renderer/materials.html'),
|
||||||
|
sceneEditor: path.resolve(__dirname, 'app/renderer/sceneEditor.html'),
|
||||||
npcsEditor: path.resolve(__dirname, 'app/renderer/npcsEditor.html'),
|
npcsEditor: path.resolve(__dirname, 'app/renderer/npcsEditor.html'),
|
||||||
npcs: path.resolve(__dirname, 'app/renderer/npcs.html'),
|
npcs: path.resolve(__dirname, 'app/renderer/npcs.html'),
|
||||||
},
|
},
|
||||||
|
|||||||
Reference in New Issue
Block a user