From fed5674468b4c339440615f45579966f1da16b53 Mon Sep 17 00:00:00 2001 From: Ivan Fontosh Date: Sat, 25 Jul 2026 12:35:22 +0800 Subject: [PATCH] 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 --- app/main/effects/effectsStore.test.ts | 19 +++ app/main/effects/effectsStore.ts | 1 + app/main/index.ts | 1 + app/main/sceneTraps/sceneTrapsStore.test.ts | 38 ++++++ app/renderer/control/ControlApp.tsx | 123 ++++++++++++++++-- .../control/controlApp.effectsPanel.test.ts | 28 ++++ app/renderer/control/explosionSfx.ts | 63 +++++++++ app/renderer/editor/EditorApp.tsx | 2 +- app/renderer/editor/MaterialsModals.tsx | 2 +- app/renderer/editor/help/helpSections.ts | 1 + app/renderer/editor/i18n/editorMessages.ts | 22 +++- .../editor/state/projectState.race.test.ts | 8 ++ app/renderer/editor/state/projectState.ts | 14 +- app/renderer/public/arrow.mp3 | Bin 0 -> 26866 bytes app/renderer/public/explosion.mp3 | Bin 0 -> 212221 bytes app/renderer/public/laser.mp3 | Bin 0 -> 20082 bytes app/renderer/public/mimic.mp3 | Bin 0 -> 70122 bytes app/renderer/public/pit.mp3 | Bin 0 -> 35363 bytes .../public/vfx/arrow/arrows-grounding.webm | Bin 0 -> 246856 bytes .../vfx/explosion/aerial-debris-smoke.webm | Bin 0 -> 1314499 bytes app/renderer/public/vfx/laser/eye-lasers.webm | Bin 0 -> 246871 bytes .../public/vfx/mimic/tentacle-at-camera.webm | Bin 0 -> 740533 bytes .../public/vfx/pit/infinity-portal.webm | Bin 0 -> 970758 bytes app/renderer/shared/PresentationView.tsx | 10 +- .../effects/ExplosionVideoOverlay.module.css | 18 +++ .../shared/effects/ExplosionVideoOverlay.tsx | 95 ++++++++++++++ .../shared/effects/PxiEffectsOverlay.tsx | 26 ++++ .../shared/traps/SceneTrapsOverlay.module.css | 11 ++ .../shared/traps/SceneTrapsOverlay.tsx | 71 +++++++++- app/renderer/shared/traps/TrapGlyph.tsx | 2 +- app/renderer/shared/traps/mimicActivation.ts | 6 + .../shared/traps/trapActivation.test.ts | 58 +++++++++ app/renderer/shared/traps/trapActivation.ts | 92 +++++++++++++ app/shared/effectEraserHitTest.ts | 6 +- app/shared/types/effects.ts | 12 ++ package.json | 2 +- 36 files changed, 697 insertions(+), 34 deletions(-) create mode 100644 app/main/sceneTraps/sceneTrapsStore.test.ts create mode 100644 app/renderer/control/explosionSfx.ts create mode 100644 app/renderer/public/arrow.mp3 create mode 100644 app/renderer/public/explosion.mp3 create mode 100644 app/renderer/public/laser.mp3 create mode 100644 app/renderer/public/mimic.mp3 create mode 100644 app/renderer/public/pit.mp3 create mode 100644 app/renderer/public/vfx/arrow/arrows-grounding.webm create mode 100644 app/renderer/public/vfx/explosion/aerial-debris-smoke.webm create mode 100644 app/renderer/public/vfx/laser/eye-lasers.webm create mode 100644 app/renderer/public/vfx/mimic/tentacle-at-camera.webm create mode 100644 app/renderer/public/vfx/pit/infinity-portal.webm create mode 100644 app/renderer/shared/effects/ExplosionVideoOverlay.module.css create mode 100644 app/renderer/shared/effects/ExplosionVideoOverlay.tsx create mode 100644 app/renderer/shared/traps/mimicActivation.ts create mode 100644 app/renderer/shared/traps/trapActivation.test.ts create mode 100644 app/renderer/shared/traps/trapActivation.ts diff --git a/app/main/effects/effectsStore.test.ts b/app/main/effects/effectsStore.test.ts index bbba108..61b8464 100644 --- a/app/main/effects/effectsStore.test.ts +++ b/app/main/effects/effectsStore.test.ts @@ -82,3 +82,22 @@ void test('pruneExpired: яд удаляется после lifetime', () => { assert.equal(store.pruneExpired(), true); assert.equal(store.getState().instances.length, 0); }); + +void test('pruneExpired: взрыв удаляется после lifetime', () => { + const store = new EffectsStore(); + store.dispatch({ + kind: 'instance.add', + instance: { + id: 'ex_test', + type: 'explosion', + seed: 1, + createdAtMs: Date.now() - 20_000, + at: { x: 0.5, y: 0.5 }, + radiusN: 0.08, + intensity: 1, + lifetimeMs: 4200, + }, + }); + assert.equal(store.pruneExpired(), true); + assert.equal(store.getState().instances.length, 0); +}); diff --git a/app/main/effects/effectsStore.ts b/app/main/effects/effectsStore.ts index dba2cbf..cb469f0 100644 --- a/app/main/effects/effectsStore.ts +++ b/app/main/effects/effectsStore.ts @@ -52,6 +52,7 @@ export class EffectsStore { i.type === 'lightning' || i.type === 'sunbeam' || i.type === 'poisonCloud' || + i.type === 'explosion' || i.type === 'darkness' ) { return now - i.createdAtMs < i.lifetimeMs; diff --git a/app/main/index.ts b/app/main/index.ts index 020d4d9..c94000b 100644 --- a/app/main/index.ts +++ b/app/main/index.ts @@ -377,6 +377,7 @@ async function main() { registerHandler(ipcChannels.license.acceptEula, ({ version }) => licenseService.acceptEula(version)); registerHandler(ipcChannels.windows.openMultiWindow, () => { sceneDarknessStore.resetSession(); + sceneTrapsStore.resetSession(); openMultiWindow(); const project = projectStore.getOpenProject(); if (project) { diff --git a/app/main/sceneTraps/sceneTrapsStore.test.ts b/app/main/sceneTraps/sceneTrapsStore.test.ts new file mode 100644 index 0000000..92d8ec2 --- /dev/null +++ b/app/main/sceneTraps/sceneTrapsStore.test.ts @@ -0,0 +1,38 @@ +import assert from 'node:assert/strict'; +import test from 'node:test'; + +import { SceneTrapsStore } from './sceneTrapsStore'; + +void test('SceneTrapsStore: сохраняет runtime при переключении сцен внутри сессии', () => { + const store = new SceneTrapsStore(); + store.switchScene('scene_a', ['t1']); + store.dispatch({ kind: 'activate', trapId: 't1' }); + assert.equal(store.getState().byId.t1?.status, 'active'); + assert.equal(store.getState().byId.t1?.revealed, true); + + store.switchScene('scene_b', ['t2']); + assert.equal(store.getState().byId.t2?.status, 'inactive'); + + store.switchScene('scene_a', ['t1']); + assert.equal(store.getState().byId.t1?.status, 'active'); + assert.equal(store.getState().byId.t1?.revealed, true); +}); + +void test('SceneTrapsStore: resetSession сбрасывает кэш к дефолту (кнопка «Запустить»)', () => { + const store = new SceneTrapsStore(); + store.switchScene('scene_a', ['t1', 't2']); + store.dispatch({ kind: 'reveal', trapId: 't1' }); + store.dispatch({ kind: 'activate', trapId: 't2' }); + assert.equal(store.getState().byId.t1?.revealed, true); + assert.equal(store.getState().byId.t2?.status, 'active'); + assert.ok(store.getState().lastActivation); + + store.resetSession(); + store.switchScene('scene_a', ['t1', 't2']); + + assert.equal(store.getState().byId.t1?.status, 'inactive'); + assert.equal(store.getState().byId.t1?.revealed, false); + assert.equal(store.getState().byId.t2?.status, 'inactive'); + assert.equal(store.getState().byId.t2?.revealed, false); + assert.equal(store.getState().lastActivation, null); +}); diff --git a/app/renderer/control/ControlApp.tsx b/app/renderer/control/ControlApp.tsx index 68a4c68..f395ee4 100644 --- a/app/renderer/control/ControlApp.tsx +++ b/app/renderer/control/ControlApp.tsx @@ -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(null); + const [explosionDraft, setExplosionDraft] = useState(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() { > ☣️ + {isDarkenScene ? ( @@ -1671,6 +1729,11 @@ export function ControlApp() { : undefined } /> + {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 ? ( diff --git a/app/renderer/control/controlApp.effectsPanel.test.ts b/app/renderer/control/controlApp.effectsPanel.test.ts index fbf85f5..91f0388 100644 --- a/app/renderer/control/controlApp.effectsPanel.test.ts +++ b/app/renderer/control/controlApp.effectsPanel.test.ts @@ -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')")); diff --git a/app/renderer/control/explosionSfx.ts b/app/renderer/control/explosionSfx.ts new file mode 100644 index 0000000..4bd24b7 --- /dev/null +++ b/app/renderer/control/explosionSfx.ts @@ -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 `