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:
Ivan Fontosh
2026-07-25 12:35:22 +08:00
parent 02d73ddf81
commit fed5674468
36 changed files with 697 additions and 34 deletions
@@ -98,3 +98,14 @@
transform: scale(1.6);
}
}
/** Одноразовый VFX ловушки на слое оверлея (не внутри круглого маркера).
* Якорь кадра задаётся inline transform из trapActivationAnchorTransform. */
.trapMediaFx {
position: absolute;
height: auto;
max-width: none;
pointer-events: none;
z-index: 6;
background: transparent;
}
@@ -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
+1 -1
View File
@@ -1,6 +1,6 @@
/** Простые SVG-иконки ловушек (MVP). */
import type { SceneTrapStatus, SceneTrapType } from '../../shared/types';
import type { SceneTrapStatus, SceneTrapType } from '../../../shared/types';
const TYPE_COLOR: Record<SceneTrapType, string> = {
mimic: '#c4783a',
@@ -0,0 +1,6 @@
/** Реэкспорт для совместимости; канонический модуль — `trapActivation.ts`. */
export {
MIMIC_ACTIVATION_MS,
mimicActivationVideoUrl,
playMimicActivationSound,
} from './trapActivation';
@@ -0,0 +1,58 @@
import assert from 'node:assert/strict';
import fs from 'node:fs';
import path from 'node:path';
import test from 'node:test';
import { fileURLToPath } from 'node:url';
const here = path.dirname(fileURLToPath(import.meta.url));
const publicRoot = path.resolve(here, '../../public');
void test('trap activation assets exist in public/ (mimic/pit/arrow/laser; explosion shared with effects)', () => {
const video = path.join(publicRoot, 'vfx/mimic/tentacle-at-camera.webm');
const sfx = path.join(publicRoot, 'mimic.mp3');
assert.ok(fs.existsSync(video), `missing ${video}`);
assert.ok(fs.existsSync(sfx), `missing ${sfx}`);
assert.ok(fs.statSync(video).size > 10_000, 'mimic webm too small');
assert.ok(fs.statSync(sfx).size > 1_000, 'mimic mp3 too small');
const pitVideo = path.join(publicRoot, 'vfx/pit/infinity-portal.webm');
const pitSfx = path.join(publicRoot, 'pit.mp3');
assert.ok(fs.existsSync(pitVideo), `missing ${pitVideo}`);
assert.ok(fs.existsSync(pitSfx), `missing ${pitSfx}`);
assert.ok(fs.statSync(pitVideo).size > 10_000, 'pit webm too small');
assert.ok(fs.statSync(pitSfx).size > 1_000, 'pit mp3 too small');
const arrowVideo = path.join(publicRoot, 'vfx/arrow/arrows-grounding.webm');
const arrowSfx = path.join(publicRoot, 'arrow.mp3');
assert.ok(fs.existsSync(arrowVideo), `missing ${arrowVideo}`);
assert.ok(fs.existsSync(arrowSfx), `missing ${arrowSfx}`);
assert.ok(fs.statSync(arrowVideo).size > 10_000, 'arrow webm too small');
assert.ok(fs.statSync(arrowSfx).size > 1_000, 'arrow mp3 too small');
const laserVideo = path.join(publicRoot, 'vfx/laser/eye-lasers.webm');
const laserSfx = path.join(publicRoot, 'laser.mp3');
assert.ok(fs.existsSync(laserVideo), `missing ${laserVideo}`);
assert.ok(fs.existsSync(laserSfx), `missing ${laserSfx}`);
assert.ok(fs.statSync(laserVideo).size > 10_000, 'laser webm too small');
assert.ok(fs.statSync(laserSfx).size > 1_000, 'laser mp3 too small');
const exVideo = path.join(publicRoot, 'vfx/explosion/aerial-debris-smoke.webm');
const exSfx = path.join(publicRoot, 'explosion.mp3');
assert.ok(fs.existsSync(exVideo), `missing ${exVideo}`);
assert.ok(fs.existsSync(exSfx), `missing ${exSfx}`);
});
void test('trapActivation registry covers mimic + pit + arrow + laser (explosion via effects)', () => {
const src = fs.readFileSync(path.join(here, 'trapActivation.ts'), 'utf8');
assert.match(src, /vfx\/mimic\/tentacle-at-camera\.webm/);
assert.match(src, /mimic\.mp3/);
assert.match(src, /vfx\/pit\/infinity-portal\.webm/);
assert.match(src, /pit\.mp3/);
assert.match(src, /vfx\/arrow\/arrows-grounding\.webm/);
assert.match(src, /arrow\.mp3/);
assert.match(src, /vfx\/laser\/eye-lasers\.webm/);
assert.match(src, /laser\.mp3/);
assert.match(src, /playTrapActivationSound/);
assert.match(src, /isTrapMediaActivationKind/);
assert.ok(!src.includes("videoPath: 'vfx/explosion"), 'explosion should not be local trap media');
});
@@ -0,0 +1,92 @@
/** Одноразовая активация ловушек с собственным VFX/SFX (`public/vfx/<type>/…` + `public/<type>.mp3`).
* Яд/взрыв идут через effects store (как инструменты пульта) — см. ControlApp.
*/
import type { SceneTrapType } from '../../../shared/types';
import { getEffectsSfxGain } from '../../control/effectsSfxGain';
export type TrapMediaActivationKind = 'mimic' | 'pit' | 'arrow' | 'laser';
type TrapMediaSpec = {
videoPath: string;
soundPath: string;
/** Длительность видео + запас на старт (мс). */
lifeMs: number;
sfxVolume: number;
/**
* Точка кадра (0…1), которая совпадает с центром ловушки.
* По умолчанию центр; у лазера — правый конец луча.
*/
anchorNx?: number;
anchorNy?: number;
};
const TRAP_MEDIA: Record<TrapMediaActivationKind, TrapMediaSpec> = {
mimic: {
videoPath: 'vfx/mimic/tentacle-at-camera.webm',
soundPath: 'mimic.mp3',
lifeMs: 4500,
sfxVolume: 0.9,
},
pit: {
videoPath: 'vfx/pit/infinity-portal.webm',
soundPath: 'pit.mp3',
lifeMs: 6000,
sfxVolume: 0.9,
},
arrow: {
videoPath: 'vfx/arrow/arrows-grounding.webm',
soundPath: 'arrow.mp3',
lifeMs: 2000,
sfxVolume: 0.9,
},
laser: {
videoPath: 'vfx/laser/eye-lasers.webm',
soundPath: 'laser.mp3',
lifeMs: 1800,
sfxVolume: 0.9,
// правый кончик луча в кадре ≈ (0.82, 0.39)
anchorNx: 0.82,
anchorNy: 0.39,
},
};
export function isTrapMediaActivationKind(type: SceneTrapType): type is TrapMediaActivationKind {
return type === 'mimic' || type === 'pit' || type === 'arrow' || type === 'laser';
}
export function trapActivationLifeMs(kind: TrapMediaActivationKind): number {
return TRAP_MEDIA[kind].lifeMs;
}
export function trapActivationVideoUrl(kind: TrapMediaActivationKind): string {
return new URL(TRAP_MEDIA[kind].videoPath, window.location.href).href;
}
/** CSS transform, чтобы `anchor` кадра оказался в точке позиционирования. */
export function trapActivationAnchorTransform(kind: TrapMediaActivationKind): string {
const spec = TRAP_MEDIA[kind];
const ax = Math.max(0, Math.min(1, spec.anchorNx ?? 0.5));
const ay = Math.max(0, Math.min(1, spec.anchorNy ?? 0.5));
return `translate(${(-ax * 100).toFixed(2)}%, ${(-ay * 100).toFixed(2)}%)`;
}
export function playTrapActivationSound(kind: TrapMediaActivationKind): void {
try {
const spec = TRAP_MEDIA[kind];
const el = new Audio(new URL(spec.soundPath, window.location.href).href);
el.volume = Math.max(0, Math.min(1, spec.sfxVolume * getEffectsSfxGain()));
void el.play().catch(() => undefined);
} catch {
/* ignore */
}
}
/** @deprecated используй trapActivation* */
export const MIMIC_ACTIVATION_MS = TRAP_MEDIA.mimic.lifeMs;
export function mimicActivationVideoUrl(): string {
return trapActivationVideoUrl('mimic');
}
export function playMimicActivationSound(): void {
playTrapActivationSound('mimic');
}