Files
DndGamePlayer/app/renderer/shared/traps/SceneTrapsOverlay.tsx
T
Ivan Fontosh f270812219 feat(tokens): app-local non-player tokens with session moves and UI polish
Add token library/placements, keep play-time moves for the session, lock presentation interactions, and fix export/import modal layout plus freeform trap label.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-07-27 11:04:12 +08:00

215 lines
7.4 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 && 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'
? 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>
);
}