feat: scene traps, material legends, and scene editor window
Add trap overlays with session state, material legend editor/panel, and a dedicated scene editor window wired through IPC and project persistence. Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
@@ -8,12 +8,15 @@ import { SceneDarknessOverlay } from './effects/SceneDarknessOverlay';
|
||||
import { useEffectsState } from './effects/useEffectsState';
|
||||
import { useSceneDarknessState } from './effects/useSceneDarknessState';
|
||||
import { DEFAULT_MATERIALS_OVERLAY_LAYOUT, DEFAULT_NPCS_OVERLAY_LAYOUT } from '../../shared/types';
|
||||
import { MaterialLegendPanel } from './materials/MaterialLegendPanel';
|
||||
import { MaterialOverlay } from './materials/MaterialOverlay';
|
||||
import { useMaterialsOverlayState } from './materials/useMaterialsOverlayState';
|
||||
import { NpcsSceneOverlay } from './npcs/NpcsSceneOverlay';
|
||||
import { useNpcsOverlayState } from './npcs/useNpcsOverlayState';
|
||||
import { SceneOverlayHost } from './sceneOverlay/SceneOverlayHost';
|
||||
import { useSceneViewState } from './sceneView/useSceneViewState';
|
||||
import { SceneTrapsOverlay } from './traps/SceneTrapsOverlay';
|
||||
import { useSceneTrapsState } from './traps/useSceneTrapsState';
|
||||
import styles from './PresentationView.module.css';
|
||||
import { RotatedImage } from './RotatedImage';
|
||||
import { useAssetUrl } from './useAssetImageUrl';
|
||||
@@ -35,6 +38,7 @@ export function PresentationView({
|
||||
}: PresentationViewProps) {
|
||||
const [fxState] = useEffectsState();
|
||||
const [sdState] = useSceneDarknessState();
|
||||
const [sceneTraps] = useSceneTrapsState();
|
||||
const [sceneView] = useSceneViewState();
|
||||
const [materialsOverlay] = useMaterialsOverlayState();
|
||||
const [npcsOverlay] = useNpcsOverlayState();
|
||||
@@ -165,6 +169,14 @@ export function PresentationView({
|
||||
}
|
||||
/>
|
||||
) : null}
|
||||
{scene?.previewAssetType === 'image' && contentRect ? (
|
||||
<SceneTrapsOverlay
|
||||
traps={scene.traps ?? []}
|
||||
session={sceneTraps}
|
||||
viewport={contentRect}
|
||||
mode="presentation"
|
||||
/>
|
||||
) : null}
|
||||
{showEffects && scene?.previewAssetType === 'image' && scene.darkenScene && contentRect ? (
|
||||
<SceneDarknessOverlay state={sdState} overlayAlpha={1} viewport={contentRect} />
|
||||
) : null}
|
||||
@@ -174,6 +186,15 @@ export function PresentationView({
|
||||
embedded
|
||||
assetId={activeMaterial.assetId}
|
||||
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}
|
||||
{activeNpcItems.length > 0 ? <NpcsSceneOverlay embedded items={activeNpcItems} /> : null}
|
||||
|
||||
@@ -88,7 +88,7 @@ export function SceneDarknessOverlay({
|
||||
height: viewport.h,
|
||||
opacity: overlayAlpha,
|
||||
pointerEvents: 'none',
|
||||
zIndex: 2,
|
||||
zIndex: 3,
|
||||
...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 {
|
||||
position: absolute;
|
||||
left: 50%;
|
||||
top: 50%;
|
||||
inset: 0;
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
display: block;
|
||||
object-fit: fill;
|
||||
border-radius: 6px;
|
||||
box-shadow: 0 18px 48px rgba(0, 0, 0, 0.55);
|
||||
user-select: 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 {
|
||||
|
||||
@@ -22,6 +22,8 @@ type MaterialOverlayProps = {
|
||||
onZoomAt?: (nx: number, ny: number) => void;
|
||||
/** Без собственного root/dim — внутри `SceneOverlayHost`. */
|
||||
embedded?: boolean;
|
||||
/** Маркеры легенды (норм. координаты картинки). */
|
||||
legendMarkers?: readonly { id: string; number: number; nx: number; ny: number }[];
|
||||
};
|
||||
|
||||
function RotateIcon() {
|
||||
@@ -99,6 +101,7 @@ export function MaterialOverlay({
|
||||
onLayoutChange,
|
||||
onZoomAt,
|
||||
embedded = false,
|
||||
legendMarkers,
|
||||
}: MaterialOverlayProps) {
|
||||
const url = useAssetUrl(assetId);
|
||||
const host = useSceneOverlayView();
|
||||
@@ -381,16 +384,23 @@ export function MaterialOverlay({
|
||||
src={url}
|
||||
alt=""
|
||||
draggable={false}
|
||||
style={{
|
||||
width: w,
|
||||
height: h,
|
||||
transform: 'translate(-50%, -50%)',
|
||||
}}
|
||||
onLoad={(e) => {
|
||||
const img = e.currentTarget;
|
||||
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
|
||||
? (['nw', 'ne', 'sw', 'se'] as const).map((corner) => (
|
||||
<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;
|
||||
}
|
||||
Reference in New Issue
Block a user