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:
@@ -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')"));
|
||||
|
||||
@@ -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 */
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user