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>
This commit is contained in:
@@ -4,6 +4,14 @@ 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';
|
||||
|
||||
@@ -18,6 +26,12 @@ type Props = {
|
||||
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;
|
||||
@@ -38,15 +52,28 @@ export function SceneTrapsOverlay({
|
||||
onDisarm,
|
||||
}: Props) {
|
||||
const [menu, setMenu] = useState<{ trapId: string; x: number; y: number } | null>(null);
|
||||
const [flashToken, setFlashToken] = useState<{ trapId: string; token: number } | null>(null);
|
||||
const [activationFx, setActivationFx] = useState<ActivationFx | null>(null);
|
||||
|
||||
useEffect(() => {
|
||||
const act = session?.lastActivation;
|
||||
if (!act) return;
|
||||
setFlashToken(act);
|
||||
const t = window.setTimeout(() => setFlashToken(null), 750);
|
||||
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]);
|
||||
}, [session?.lastActivation?.token, session?.lastActivation?.trapId, traps, mode]);
|
||||
|
||||
useEffect(() => {
|
||||
if (!menu) return;
|
||||
@@ -62,6 +89,16 @@ export function SceneTrapsOverlay({
|
||||
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) => {
|
||||
@@ -96,10 +133,34 @@ export function SceneTrapsOverlay({
|
||||
>
|
||||
<TrapGlyph type={trap.type} status={rt.status} size={Math.max(14, sizePx * 0.55)} />
|
||||
{trap.label ? <div className={styles.label}>{trap.label}</div> : null}
|
||||
{flashToken?.trapId === trap.id ? <div className={styles.flash} /> : 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
|
||||
|
||||
Reference in New Issue
Block a user