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:
Ivan Fontosh
2026-07-24 16:29:43 +08:00
parent d3b1c4660d
commit 02d73ddf81
39 changed files with 2563 additions and 36 deletions
@@ -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>
);
}
+103
View File
@@ -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;
}