fed5674468
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>
213 lines
7.3 KiB
TypeScript
213 lines
7.3 KiB
TypeScript
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>
|
||
);
|
||
}
|