Files
Ivan Fontosh 4456eb0277 feat(control): grid snap, NPC context actions, and overlay dim fix
Re-enable users-branch UI, snap session tokens to square/hex grid from the control preview, refine inactive/open-info NPC menus, block marker actions while an effect brush is active, and dim materials/NPC overlays only when they are open.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-08-06 13:48:59 +08:00

223 lines
7.7 KiB
TypeScript
Raw Permalink Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
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';
/** На пульте: false — без контекстного меню (например, активна кисть эффектов). */
interactive?: boolean;
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,
interactive = true,
onReveal,
onActivate,
onDisarm,
}: Props) {
const [menu, setMenu] = useState<{ trapId: string; x: number; y: number } | null>(null);
const [activationFx, setActivationFx] = useState<ActivationFx | null>(null);
useEffect(() => {
if (!interactive) setMenu(null);
}, [interactive]);
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 (
<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 : '',
mode === 'control' && !interactive ? styles.trapNonInteractive : '',
]
.filter(Boolean)
.join(' ');
return (
<div
key={trap.id}
className={cls}
style={{ left, top, width: sizePx, height: sizePx }}
onContextMenu={
mode === 'control' && interactive
? (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 && trap.label.trim().toLowerCase() !== 'свободная' ? (
<div className={styles.label}>{trap.label}</div>
) : null}
{activationFx?.trapId === trap.id && activationFx.kind === 'flash' ? (
<div className={styles.flash} />
) : null}
</div>
);
})}
{mediaTrap && activationFx && mediaKind ? (
<video
key={activationFx.token}
className={styles.trapMediaFx}
style={{
left: viewport.x + mediaTrap.nx * viewport.w,
top: viewport.y + mediaTrap.ny * viewport.h,
width: mediaFxW,
transform: trapActivationAnchorTransform(mediaKind),
}}
src={trapActivationVideoUrl(mediaKind)}
autoPlay
muted
playsInline
preload="auto"
onEnded={() => {
setActivationFx((cur) =>
cur?.token === activationFx.token && cur.kind === mediaKind ? null : cur,
);
}}
/>
) : null}
{menu && mode === 'control' && interactive
? 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>
);
}