Files
DndGamePlayer/app/renderer/sceneEditor/SceneEditorApp.tsx
T
Ivan Fontosh 02d73ddf81 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>
2026-07-24 16:29:43 +08:00

366 lines
14 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
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>
);
}