Files
DndGamePlayer/app/renderer/shared/traps/SceneTrapsOverlay.tsx
T
Ivan Fontosh fed5674468 feat(traps): activation VFX/SFX, explosion effect, and help section
Wire mimic/pit/arrow/laser media on activate, poison/explosion via effects, and document the scene editor and traps in Instructions.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-07-25 12:35:22 +08:00

213 lines
7.3 KiB
TypeScript
Raw 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';
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<ActivationFx | null>(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 (
<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}
{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'
? 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>
);
}