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