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 { isTrapMediaActivationKind, playTrapActivationSound, trapActivationAnchorTransform, trapActivationLifeMs, trapActivationVideoUrl, type TrapMediaActivationKind, } from './trapActivation'; 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; }; type ActivationFx = { trapId: string; token: number; kind: TrapMediaActivationKind | 'flash'; }; 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 [activationFx, setActivationFx] = useState(null); useEffect(() => { const act = session?.lastActivation; if (!act) return; const trap = traps.find((t) => t.id === act.trapId); // poison/explosion: VFX/SFX через effects store (ControlApp), без локального flash/video. if (trap?.type === 'poison' || trap?.type === 'explosion') { setActivationFx(null); return; } const kind: ActivationFx['kind'] = trap && isTrapMediaActivationKind(trap.type) ? trap.type : 'flash'; setActivationFx({ trapId: act.trapId, token: act.token, kind }); if (kind !== 'flash' && mode === 'control') { // SFX только на пульте — иначе при двух окнах звук удвоится (как у прочих эффектов). playTrapActivationSound(kind); } const ms = kind !== 'flash' ? trapActivationLifeMs(kind) : 750; const t = window.setTimeout(() => setActivationFx(null), ms); return () => window.clearTimeout(t); }, [session?.lastActivation?.token, session?.lastActivation?.trapId, traps, mode]); 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); const mediaTrap = activationFx && activationFx.kind !== 'flash' ? traps.find((t) => t.id === activationFx.trapId) : undefined; const mediaSizePx = mediaTrap ? Math.max(16, mediaTrap.sizeN * minDim) : 0; /** Видео 16:9, центрируем на ловушке; ширина ~4× маркера, не меньше 18% кадра. */ const mediaFxW = mediaTrap ? Math.max(mediaSizePx * 4.2, minDim * 0.18) : 0; const mediaKind = activationFx && activationFx.kind !== 'flash' ? activationFx.kind : null; 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} {activationFx?.trapId === trap.id && activationFx.kind === 'flash' ? (
) : null}
); })} {mediaTrap && activationFx && mediaKind ? (
); }