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 (
{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 (
{ e.preventDefault(); e.stopPropagation(); const pos = menuPosition(e.clientX, e.clientY); setMenu({ trapId: trap.id, x: pos.x, y: pos.y }); } : undefined } > {trap.label ?
{trap.label}
: null} {flashToken?.trapId === trap.id ?
: null}
); })} {menu && mode === 'control' ? createPortal(
e.stopPropagation()} >
, document.body, ) : null}
); }