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 ? (