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
+115 -8
View File
@@ -18,11 +18,12 @@ import { useEditorI18n } from '../editor/i18n/EditorI18nContext';
import { isSceneDescriptionEmpty } from '../editor/sceneDescriptionHtml';
import { getDndApi } from '../shared/dndApi';
import { RotatedImage } from '../shared/RotatedImage';
import { ExplosionVideoOverlay } from '../shared/effects/ExplosionVideoOverlay';
import {
PixiEffectsOverlay,
type PixiEffectsOverlayHandle,
} from '../shared/effects/PxiEffectsOverlay';
import type { EffectInstance } from '../../shared/types/effects';
import type { EffectInstance, ExplosionInstance } from '../../shared/types/effects';
import { SceneDarknessOverlay } from '../shared/effects/SceneDarknessOverlay';
import { useEffectsState } from '../shared/effects/useEffectsState';
import { useSceneDarknessState } from '../shared/effects/useSceneDarknessState';
@@ -47,6 +48,7 @@ import { clampEffectsSfxGain, getEffectsSfxGain, setEffectsSfxGain } from './eff
import { setFireAmbientActive, syncFireAmbientVolume } from './fireAmbientSfx';
import { setRainAmbientActive, syncRainAmbientVolume } from './rainAmbientSfx';
import { getFreezeEffectLifeMs, playFreezeEffectSound } from './freezeSfx';
import { getExplosionEffectLifeMs, playExplosionEffectSound } from './explosionSfx';
import { getPoisonCloudEffectLifeMs, playPoisonCloudEffectSound } from './poisonCloudSfx';
import { getSunbeamEffectLifeMs, playSunbeamEffectSound } from './sunbeamSfx';
@@ -168,6 +170,7 @@ export function ControlApp() {
| 'lightning'
| 'sunbeam'
| 'poisonCloud'
| 'explosion'
| 'freeze'
| 'exploreBrush'
| 'eraser';
@@ -189,6 +192,7 @@ export function ControlApp() {
const cursorPosRef = useRef<{ x: number; y: number } | null>(null);
const draftPaintRafRef = useRef(0);
const effectsOverlayRef = useRef<PixiEffectsOverlayHandle | null>(null);
const [explosionDraft, setExplosionDraft] = useState<ExplosionInstance | null>(null);
const draftMetaRef = useRef<{ createdAtMs: number; seed: number } | null>(null);
useEffect(() => {
@@ -257,9 +261,15 @@ export function ControlApp() {
const [poisonDraftLifeMs, setPoisonDraftLifeMs] = useState(1600);
const poisonDraftLifeMsRef = useRef(poisonDraftLifeMs);
poisonDraftLifeMsRef.current = poisonDraftLifeMs;
const [explosionDraftLifeMs, setExplosionDraftLifeMs] = useState(4200);
const explosionDraftLifeMsRef = useRef(explosionDraftLifeMs);
explosionDraftLifeMsRef.current = explosionDraftLifeMs;
useEffect(() => {
void getPoisonCloudEffectLifeMs().then(setPoisonDraftLifeMs);
}, []);
useEffect(() => {
void getExplosionEffectLifeMs().then(setExplosionDraftLifeMs);
}, []);
const hasFireOnScene = useMemo(
() => Boolean(fxState?.instances.some((i) => i.type === 'fire' && i.points.length > 0)),
@@ -968,6 +978,20 @@ export function ControlApp() {
lifetimeMs: poisonDraftLifeMsRef.current,
};
}
if (b.tool === 'explosion' && b.points && b.points.length > 0) {
const last = b.points[b.points.length - 1];
if (last === undefined) return null;
return {
id: '__draft__',
type: 'explosion',
seed,
createdAtMs,
at: { x: last.x, y: last.y },
radiusN: Math.max(0.03, t.radiusN * 0.95),
intensity: Math.max(0.75, Math.min(1.2, t.intensity * 1.15)),
lifetimeMs: explosionDraftLifeMsRef.current,
};
}
if (b.tool === 'freeze' && b.points && b.points.length > 0) {
const last = b.points[b.points.length - 1];
if (last === undefined) return null;
@@ -985,12 +1009,15 @@ export function ControlApp() {
}
function pushDraftToPixi(): void {
effectsOverlayRef.current?.setDraft(buildDraftEffectInstance());
const draft = buildDraftEffectInstance();
effectsOverlayRef.current?.setDraft(draft);
setExplosionDraft(draft?.type === 'explosion' ? draft : null);
}
function clearDraftFromPixi(): void {
draftMetaRef.current = null;
effectsOverlayRef.current?.setDraft(null);
setExplosionDraft(null);
}
function scheduleDraftRepaint(): void {
@@ -1270,6 +1297,26 @@ export function ControlApp() {
});
void playPoisonCloudEffectSound(poisonLifeMs);
}
if (b.tool === 'explosion' && b.points && b.points.length > 0) {
const last = b.points[b.points.length - 1];
if (last === undefined) return;
const at = { x: last.x, y: last.y };
const explosionLifeMs = await getExplosionEffectLifeMs();
await fx.dispatch({
kind: 'instance.add',
instance: {
id: `ex_${String(createdAtMs)}_${String(seed)}`,
type: 'explosion',
seed,
createdAtMs,
at,
radiusN: Math.max(0.03, tool.radiusN * 0.95),
intensity: Math.max(0.75, Math.min(1.2, tool.intensity * 1.15)),
lifetimeMs: explosionLifeMs,
},
});
void playExplosionEffectSound(explosionLifeMs);
}
if (b.tool === 'freeze' && b.points && b.points.length > 0) {
const last = b.points[b.points.length - 1];
if (last === undefined) return;
@@ -1523,6 +1570,17 @@ export function ControlApp() {
>
<span className={styles.iconGlyph}></span>
</Button>
<Button
variant={tool.tool === 'explosion' ? 'primary' : 'ghost'}
iconOnly
title={t('control.explosion')}
ariaLabel={t('control.explosion')}
onClick={() =>
void fx.dispatch({ kind: 'tool.set', tool: { ...tool, tool: 'explosion' } })
}
>
<span className={styles.iconGlyph}>💥</span>
</Button>
</div>
</div>
{isDarkenScene ? (
@@ -1671,6 +1729,11 @@ export function ControlApp() {
: undefined
}
/>
<ExplosionVideoOverlay
state={fxState}
draft={explosionDraft}
viewport={previewContentRect}
/>
{previewContentRect ? (
<SceneDarknessOverlay state={sdState} overlayAlpha={0.5} viewport={previewContentRect} />
) : null}
@@ -1829,7 +1892,53 @@ export function ControlApp() {
viewport={previewContentRect}
mode="control"
onReveal={(trapId) => void sceneTrapsApi.dispatch({ kind: 'reveal', trapId })}
onActivate={(trapId) => void sceneTrapsApi.dispatch({ kind: 'activate', trapId })}
onActivate={(trapId) => {
void (async () => {
await sceneTrapsApi.dispatch({ kind: 'activate', trapId });
const trap = (currentScene?.traps ?? []).find((t) => t.id === trapId);
if (trap?.type === 'poison') {
// Тот же VFX/SFX, что у инструмента «Облако яда» на пульте эффектов.
const createdAtMs = Date.now();
const seed = Math.floor(Math.random() * 1_000_000_000);
const poisonLifeMs = await getPoisonCloudEffectLifeMs();
await fx.dispatch({
kind: 'instance.add',
instance: {
id: `trap_pc_${trapId}_${String(createdAtMs)}`,
type: 'poisonCloud',
seed,
createdAtMs,
at: { x: trap.nx, y: trap.ny },
radiusN: Math.max(0.04, trap.sizeN * 1.15),
intensity: 1.05,
lifetimeMs: poisonLifeMs,
},
});
void playPoisonCloudEffectSound(poisonLifeMs);
return;
}
if (trap?.type === 'explosion') {
// Тот же VFX/SFX, что у инструмента «Взрыв» на пульте эффектов.
const createdAtMs = Date.now();
const seed = Math.floor(Math.random() * 1_000_000_000);
const explosionLifeMs = await getExplosionEffectLifeMs();
await fx.dispatch({
kind: 'instance.add',
instance: {
id: `trap_ex_${trapId}_${String(createdAtMs)}`,
type: 'explosion',
seed,
createdAtMs,
at: { x: trap.nx, y: trap.ny },
radiusN: Math.max(0.04, trap.sizeN * 1.15),
intensity: 1.05,
lifetimeMs: explosionLifeMs,
},
});
void playExplosionEffectSound(explosionLifeMs);
}
})();
}}
onDisarm={(trapId) => void sceneTrapsApi.dispatch({ kind: 'disarm', trapId })}
/>
) : null}
@@ -1908,11 +2017,9 @@ export function ControlApp() {
onLayoutChange={(layout) => {
void materialsApi.dispatch({ kind: 'layout.set', layout });
}}
legendMarkers={
activeMaterial.legend?.enabled
? (activeMaterial.legend.markers ?? [])
: undefined
}
{...(activeMaterial.legend?.enabled
? { legendMarkers: activeMaterial.legend.markers ?? [] }
: {})}
/>
) : null}
{showMaterial && activeMaterial?.legend?.enabled ? (
@@ -46,6 +46,34 @@ void test('ControlApp: звук облака яда (public/oblako-yada.mp3)', (
assert.ok(sfxSrc.includes('playbackRate'));
});
void test('ControlApp: активация ловушки «яд» спавнит poisonCloud как на пульте эффектов', () => {
const src = readControlApp();
assert.ok(src.includes("trap?.type === 'poison'"));
assert.ok(src.includes("type: 'poisonCloud'"));
assert.ok(src.includes('trap_pc_'));
assert.ok(src.includes('playPoisonCloudEffectSound(poisonLifeMs)'));
});
void test('ControlApp: эффект «взрыв» + ловушка используют explosion webm/mp3', () => {
const appSrc = readControlApp();
const sfxSrc = fs.readFileSync(path.join(here, 'explosionSfx.ts'), 'utf8');
assert.ok(appSrc.includes("title={t('control.explosion')}"));
assert.ok(appSrc.includes("tool: 'explosion'"));
assert.ok(appSrc.includes("type: 'explosion'"));
assert.ok(appSrc.includes('getExplosionEffectLifeMs'));
assert.ok(appSrc.includes('playExplosionEffectSound'));
assert.ok(appSrc.includes("trap?.type === 'explosion'"));
assert.ok(appSrc.includes('trap_ex_'));
assert.ok(sfxSrc.includes('explosion.mp3'));
assert.ok(sfxSrc.includes('aerial-debris-smoke.webm'));
assert.ok(appSrc.includes('ExplosionVideoOverlay'));
const videoOverlaySrc = fs.readFileSync(
path.join(here, '..', 'shared', 'effects', 'ExplosionVideoOverlay.tsx'),
'utf8',
);
assert.ok(videoOverlaySrc.includes('explosionEffectVideoUrl'));
});
void test('ControlApp: эффекты в пульте, иконки с тултипами и подписью для a11y', () => {
const src = readControlApp();
assert.ok(src.includes("t('control.instruments')"));
+63
View File
@@ -0,0 +1,63 @@
/** Звук и длительность эффекта «Взрыв» (`public/explosion.mp3` + WebM с альфой). */
import { getEffectsSfxGain } from './effectsSfxGain';
const EXPLOSION_SFX_VOLUME = 0.95;
/** Запас, если метаданные не прочитались (длина webm ~3.97 с). */
const DEFAULT_EXPLOSION_LIFE_MS = 4200;
export function explosionEffectSoundUrl(): string {
return new URL('explosion.mp3', window.location.href).href;
}
/** VP8/VP9 WebM с `alpha_mode=1` — рендер через HTML `<video>`, не Pixi. */
export function explosionEffectVideoUrl(): string {
return new URL('vfx/explosion/aerial-debris-smoke.webm', window.location.href).href;
}
let cachedExplosionSfxDurationMs: number | null = null;
/** Длительность трека в мс (кэш после первого чтения метаданных). */
export function getExplosionSfxDurationMs(): Promise<number> {
if (cachedExplosionSfxDurationMs !== null) {
return Promise.resolve(cachedExplosionSfxDurationMs);
}
const url = explosionEffectSoundUrl();
return new Promise((resolve) => {
const a = new Audio();
const done = (ms: number): void => {
cachedExplosionSfxDurationMs = ms;
a.removeAttribute('src');
resolve(ms);
};
a.addEventListener('loadedmetadata', () => {
const d = a.duration;
done(Number.isFinite(d) && d > 0 ? Math.round(d * 1000) : DEFAULT_EXPLOSION_LIFE_MS);
});
a.addEventListener('error', () => done(DEFAULT_EXPLOSION_LIFE_MS));
a.src = url;
a.load();
});
}
/** Длительность визуала ≈ max(звук, webm), с разумными пределами. */
export async function getExplosionEffectLifeMs(): Promise<number> {
const sfxMs = await getExplosionSfxDurationMs();
const raw = Math.max(sfxMs, DEFAULT_EXPLOSION_LIFE_MS);
return Math.min(60_000, Math.max(600, raw));
}
export async function playExplosionEffectSound(lifeMs: number): Promise<void> {
try {
const rawMs = await getExplosionSfxDurationMs();
const target = Math.max(200, lifeMs);
const rate = Math.max(0.25, Math.min(4, rawMs / target));
const el = new Audio(explosionEffectSoundUrl());
el.volume = Math.max(0, Math.min(1, EXPLOSION_SFX_VOLUME * getEffectsSfxGain()));
el.playbackRate = rate;
void el.play().catch(() => undefined);
} catch {
/* ignore */
}
}