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 ? (
|
||||
|
||||
Reference in New Issue
Block a user