diff --git a/app/main/effects/effectsStore.ts b/app/main/effects/effectsStore.ts index 8f976e9..dba2cbf 100644 --- a/app/main/effects/effectsStore.ts +++ b/app/main/effects/effectsStore.ts @@ -102,7 +102,14 @@ function applyEvent( case 'field.erase': return bump({ ...state, - instances: applyFieldEffectEraserStroke(state.instances, event.points, event.radiusN, makeId), + instances: applyFieldEffectEraserStroke( + state.instances, + event.points, + event.radiusN, + makeId, + event.types, + event.hitMode ?? 'inclusive', + ), }); default: { // Exhaustiveness diff --git a/app/renderer/control/ControlApp.module.css b/app/renderer/control/ControlApp.module.css index fdc6115..d428261 100644 --- a/app/renderer/control/ControlApp.module.css +++ b/app/renderer/control/ControlApp.module.css @@ -179,11 +179,15 @@ .radiusRow { display: grid; - grid-template-columns: 100px 1fr 44px; + grid-template-columns: 120px 1fr 44px; align-items: center; gap: 8px; } +.effectsSoundRow { + margin-top: 4px; +} + .radiusLabel { color: var(--text2); font-size: var(--text-xs); diff --git a/app/renderer/control/ControlApp.tsx b/app/renderer/control/ControlApp.tsx index 6743450..7670429 100644 --- a/app/renderer/control/ControlApp.tsx +++ b/app/renderer/control/ControlApp.tsx @@ -34,6 +34,9 @@ import { useAssetUrl } from '../shared/useAssetImageUrl'; import styles from './ControlApp.module.css'; import { ControlAudioCard } from './ControlAudioCard'; import { ControlScenePreview } from './ControlScenePreview'; +import { clampEffectsSfxGain, getEffectsSfxGain, setEffectsSfxGain } from './effectsSfxGain'; +import { setFireAmbientActive, syncFireAmbientVolume } from './fireAmbientSfx'; +import { setRainAmbientActive, syncRainAmbientVolume } from './rainAmbientSfx'; import { getFreezeEffectLifeMs, playFreezeEffectSound } from './freezeSfx'; import { getPoisonCloudEffectLifeMs, playPoisonCloudEffectSound } from './poisonCloudSfx'; import { getSunbeamEffectLifeMs, playSunbeamEffectSound } from './sunbeamSfx'; @@ -68,7 +71,7 @@ function lightningEffectSoundUrl(): string { function playLightningEffectSound(): void { try { const el = new Audio(lightningEffectSoundUrl()); - el.volume = 0.88; + el.volume = Math.max(0, Math.min(1, 0.88 * getEffectsSfxGain())); void el.play().catch(() => undefined); } catch { /* ignore */ @@ -108,6 +111,7 @@ export function ControlApp() { const tRef = useRef(t); tRef.current = t; const [fxState, fx] = useEffectsState(); + const [effectsSfxGainUi, setEffectsSfxGainUi] = useState(() => getEffectsSfxGain()); const [sdState, sd] = useSceneDarknessState(); const [materialsOverlay, materialsApi] = useMaterialsOverlayState(); const [npcsOverlay, npcsApi] = useNpcsOverlayState(); @@ -239,6 +243,29 @@ export function ControlApp() { void getPoisonCloudEffectLifeMs().then(setPoisonDraftLifeMs); }, []); + const hasFireOnScene = useMemo( + () => Boolean(fxState?.instances.some((i) => i.type === 'fire' && i.points.length > 0)), + [fxState?.instances], + ); + const hasRainOnScene = useMemo( + () => Boolean(fxState?.instances.some((i) => i.type === 'rain' && i.points.length > 0)), + [fxState?.instances], + ); + + useEffect(() => { + setFireAmbientActive(hasFireOnScene); + return () => { + setFireAmbientActive(false); + }; + }, [hasFireOnScene]); + + useEffect(() => { + setRainAmbientActive(hasRainOnScene); + return () => { + setRainAmbientActive(false); + }; + }, [hasRainOnScene]); + const project = session?.project ?? null; const currentGraphNodeId = project?.currentGraphNodeId ?? null; const currentHistoryIdx = currentGraphNodeId != null ? history.lastIndexOf(currentGraphNodeId) : -1; @@ -911,9 +938,26 @@ export function ControlApp() { return { x: Math.max(0, Math.min(1, x)), y: Math.max(0, Math.min(1, y)) }; } - function dispatchFieldEraserPoints(points: { x: number; y: number; tMs: number }[]): void { + function dispatchFieldEraserPoints( + points: { x: number; y: number; tMs: number }[], + opts?: { + types?: readonly ('fog' | 'fire' | 'rain' | 'water')[]; + hitMode?: 'inclusive' | 'brush'; + }, + ): void { if (points.length === 0) return; - void fx.dispatch({ kind: 'field.erase', points, radiusN: toolRef.current.radiusN }); + void fx.dispatch({ + kind: 'field.erase', + points, + radiusN: toolRef.current.radiusN, + ...(opts?.types ? { types: opts.types } : {}), + ...(opts?.hitMode ? { hitMode: opts.hitMode } : {}), + }); + } + + /** Вода/дождь гасят огонь только под самой кистью (не «аура» радиуса огня). */ + function extinguishFireAlong(points: { x: number; y: number; tMs: number }[]): void { + dispatchFieldEraserPoints(points, { types: ['fire'], hitMode: 'brush' }); } function tryEraseActionEffect(p: { x: number; y: number }): void { @@ -968,6 +1012,13 @@ export function ControlApp() { }); } if (b.tool === 'rain' && b.points && b.points.length > 0) { + await fx.dispatch({ + kind: 'field.erase', + points: b.points, + radiusN: tool.radiusN, + types: ['fire'], + hitMode: 'brush', + }); await fx.dispatch({ kind: 'instance.add', instance: { @@ -983,6 +1034,13 @@ export function ControlApp() { }); } if (b.tool === 'water' && b.points && b.points.length > 0) { + await fx.dispatch({ + kind: 'field.erase', + points: b.points, + radiusN: tool.radiusN, + types: ['fire'], + hitMode: 'brush', + }); await fx.dispatch({ kind: 'instance.add', instance: { @@ -1407,6 +1465,26 @@ export function ControlApp() { />
{Math.round(tool.radiusN * 100)}
+
+
{t('control.effectsSound')}
+ { + const v = Number((e.currentTarget as HTMLInputElement).value); + const next = setEffectsSfxGain(clampEffectsSfxGain(v)); + setEffectsSfxGainUi(next); + syncFireAmbientVolume(); + syncRainAmbientVolume(); + }} + className={styles.range} + aria-label={t('control.effectsSound')} + /> +
{Math.round(effectsSfxGainUi * 100)}
+
@@ -1523,16 +1601,20 @@ export function ControlApp() { return; } draftMetaRef.current = { createdAtMs: Date.now(), seed: 12345 }; + const startPt = { x: p.x, y: p.y, tMs: Date.now() }; brushRef.current = { tool: tool.tool, startN: p, - points: [{ x: p.x, y: p.y, tMs: Date.now() }], + points: [startPt], }; + if (tool.tool === 'water' || tool.tool === 'rain') { + extinguishFireAlong([startPt]); + } if (tool.tool === 'exploreBrush') { void sd.dispatch({ kind: 'draft.set', draft: { - points: [{ x: p.x, y: p.y, tMs: Date.now() }], + points: [startPt], radiusN: tool.radiusN, }, }); @@ -1573,7 +1655,11 @@ export function ControlApp() { const dy = p.y - last.y; const minStep = Math.max(0.004, tool.radiusN * 0.25); if (dx * dx + dy * dy < minStep * minStep) return; - b.points.push({ x: p.x, y: p.y, tMs: Date.now() }); + const pt = { x: p.x, y: p.y, tMs: Date.now() }; + b.points.push(pt); + if (b.tool === 'water' || b.tool === 'rain') { + extinguishFireAlong([last, pt]); + } scheduleDraftRepaint(); }} onPointerUp={() => { diff --git a/app/renderer/control/controlApp.effectsPanel.test.ts b/app/renderer/control/controlApp.effectsPanel.test.ts index 8a2bf9d..fbf85f5 100644 --- a/app/renderer/control/controlApp.effectsPanel.test.ts +++ b/app/renderer/control/controlApp.effectsPanel.test.ts @@ -177,6 +177,30 @@ void test('ControlApp: весь контент скроллится в окне, assert.doesNotMatch(css, /\.rightStack\s*\{[^}]*overflow-y:\s*auto/s); }); +void test('ControlApp: вода/дождь гасят огонь; ambient огня/дождя + слайдер «Звук эффектов»', () => { + const src = readControlApp(); + const fireSfx = fs.readFileSync(path.join(here, 'fireAmbientSfx.ts'), 'utf8'); + const rainSfx = fs.readFileSync(path.join(here, 'rainAmbientSfx.ts'), 'utf8'); + const i18n = fs.readFileSync(path.join(here, '../editor/i18n/editorMessages.ts'), 'utf8'); + + assert.ok(src.includes('extinguishFireAlong')); + assert.ok(src.includes("types: ['fire']")); + assert.ok(src.includes("hitMode: 'brush'")); + assert.ok(src.includes('effectsSoundRow')); + assert.ok(src.includes('setFireAmbientActive')); + assert.ok(src.includes('setRainAmbientActive')); + assert.ok(src.includes('hasFireOnScene')); + assert.ok(src.includes('hasRainOnScene')); + assert.ok(src.includes("t('control.effectsSound')")); + assert.ok(fireSfx.includes('fire-ambient.mp3')); + assert.ok(rainSfx.includes('rain-ambient.mp3')); + assert.ok(i18n.includes("'control.effectsSound': 'Звук эффектов'")); + + const radius = src.indexOf("t('control.brushRadius')"); + const effectsSound = src.indexOf("t('control.effectsSound')"); + assert.ok(radius !== -1 && effectsSound !== -1 && radius < effectsSound); +}); + void test('ControlApp: загрузка камп. аудио — useEffect зависит только от api и campaignAudioSpecKey', () => { const src = readControlApp(); const re = /\/\/ Campaign elements:[\s\S]*?useEffect\(\(\) => \{[\s\S]*?\}\s*,\s*\[([^\]]*)\]\s*\)\s*;/; diff --git a/app/renderer/control/effectsSfxGain.ts b/app/renderer/control/effectsSfxGain.ts new file mode 100644 index 0000000..3cfb0a7 --- /dev/null +++ b/app/renderer/control/effectsSfxGain.ts @@ -0,0 +1,17 @@ +/** Общая громкость звуков эффектов (огонь ambient + one-shot). 0…1. */ + +let effectsSfxGain = 0.75; + +export function clampEffectsSfxGain(v: number): number { + if (!Number.isFinite(v)) return 0.75; + return Math.max(0, Math.min(1, v)); +} + +export function getEffectsSfxGain(): number { + return effectsSfxGain; +} + +export function setEffectsSfxGain(v: number): number { + effectsSfxGain = clampEffectsSfxGain(v); + return effectsSfxGain; +} diff --git a/app/renderer/control/fireAmbientSfx.ts b/app/renderer/control/fireAmbientSfx.ts new file mode 100644 index 0000000..746eac2 --- /dev/null +++ b/app/renderer/control/fireAmbientSfx.ts @@ -0,0 +1,50 @@ +/** Фоновый звук горения при наличии огня на сцене (`public/fire-ambient.mp3`). */ + +import { getEffectsSfxGain } from './effectsSfxGain'; + +const FIRE_AMBIENT_BASE_VOLUME = 0.72; + +export function fireAmbientSoundUrl(): string { + return new URL('fire-ambient.mp3', window.location.href).href; +} + +let ambientEl: HTMLAudioElement | null = null; + +function ensureAmbientEl(): HTMLAudioElement { + if (ambientEl) return ambientEl; + const el = new Audio(fireAmbientSoundUrl()); + el.loop = true; + el.preload = 'auto'; + ambientEl = el; + return el; +} + +function applyVolume(el: HTMLAudioElement): void { + el.volume = Math.max(0, Math.min(1, FIRE_AMBIENT_BASE_VOLUME * getEffectsSfxGain())); +} + +/** Включить/выключить loop горения. */ +export function setFireAmbientActive(active: boolean): void { + try { + const el = ensureAmbientEl(); + applyVolume(el); + if (active) { + if (el.paused) void el.play().catch(() => undefined); + } else if (!el.paused) { + el.pause(); + el.currentTime = 0; + } + } catch { + /* ignore */ + } +} + +/** Обновить громкость, если ambient уже играет. */ +export function syncFireAmbientVolume(): void { + if (!ambientEl) return; + try { + applyVolume(ambientEl); + } catch { + /* ignore */ + } +} diff --git a/app/renderer/control/freezeSfx.ts b/app/renderer/control/freezeSfx.ts index 7f5d7b3..13dd562 100644 --- a/app/renderer/control/freezeSfx.ts +++ b/app/renderer/control/freezeSfx.ts @@ -1,9 +1,11 @@ /** Звук и длительность эффекта «Заморозка» (`public/zamorozka.mp3`). */ +import { getEffectsSfxGain } from './effectsSfxGain'; + const DEFAULT_FREEZE_LIFE_MS = 820; const FREEZE_SFX_BASE_VOLUME = 0.88; -/** На 25% тише базовой громкости эффекта. */ +/** На 25% тише базовой громкости эффекта (до множителя «Звук эффектов»). */ export const FREEZE_SFX_VOLUME = FREEZE_SFX_BASE_VOLUME * 0.75; export function freezeEffectSoundUrl(): string { @@ -44,7 +46,7 @@ export async function getFreezeEffectLifeMs(): Promise { export function playFreezeEffectSound(): void { try { const el = new Audio(freezeEffectSoundUrl()); - el.volume = FREEZE_SFX_VOLUME; + el.volume = Math.max(0, Math.min(1, FREEZE_SFX_VOLUME * getEffectsSfxGain())); void el.play().catch(() => undefined); } catch { /* ignore */ diff --git a/app/renderer/control/poisonCloudSfx.ts b/app/renderer/control/poisonCloudSfx.ts index 493e892..447fa70 100644 --- a/app/renderer/control/poisonCloudSfx.ts +++ b/app/renderer/control/poisonCloudSfx.ts @@ -1,5 +1,7 @@ /** Звук и длительность эффекта «Облако яда» (`public/oblako-yada.mp3`). */ +import { getEffectsSfxGain } from './effectsSfxGain'; + const POISON_CLOUD_SFX_VOLUME = 0.92; /** Запас, если метаданные не прочитались. */ @@ -50,7 +52,7 @@ export async function playPoisonCloudEffectSound(lifeMs: number): Promise const target = Math.max(200, lifeMs); const rate = Math.max(0.25, Math.min(4, rawMs / target)); const el = new Audio(poisonCloudEffectSoundUrl()); - el.volume = POISON_CLOUD_SFX_VOLUME; + el.volume = Math.max(0, Math.min(1, POISON_CLOUD_SFX_VOLUME * getEffectsSfxGain())); el.playbackRate = rate; void el.play().catch(() => undefined); } catch { diff --git a/app/renderer/control/rainAmbientSfx.ts b/app/renderer/control/rainAmbientSfx.ts new file mode 100644 index 0000000..f9fd145 --- /dev/null +++ b/app/renderer/control/rainAmbientSfx.ts @@ -0,0 +1,50 @@ +/** Фоновый звук дождя при наличии дождя на сцене (`public/rain-ambient.mp3`). */ + +import { getEffectsSfxGain } from './effectsSfxGain'; + +const RAIN_AMBIENT_BASE_VOLUME = 0.7; + +export function rainAmbientSoundUrl(): string { + return new URL('rain-ambient.mp3', window.location.href).href; +} + +let ambientEl: HTMLAudioElement | null = null; + +function ensureAmbientEl(): HTMLAudioElement { + if (ambientEl) return ambientEl; + const el = new Audio(rainAmbientSoundUrl()); + el.loop = true; + el.preload = 'auto'; + ambientEl = el; + return el; +} + +function applyVolume(el: HTMLAudioElement): void { + el.volume = Math.max(0, Math.min(1, RAIN_AMBIENT_BASE_VOLUME * getEffectsSfxGain())); +} + +/** Включить/выключить loop дождя. */ +export function setRainAmbientActive(active: boolean): void { + try { + const el = ensureAmbientEl(); + applyVolume(el); + if (active) { + if (el.paused) void el.play().catch(() => undefined); + } else if (!el.paused) { + el.pause(); + el.currentTime = 0; + } + } catch { + /* ignore */ + } +} + +/** Обновить громкость, если ambient уже играет. */ +export function syncRainAmbientVolume(): void { + if (!ambientEl) return; + try { + applyVolume(ambientEl); + } catch { + /* ignore */ + } +} diff --git a/app/renderer/control/sunbeamSfx.ts b/app/renderer/control/sunbeamSfx.ts index 6b81b78..0538aa7 100644 --- a/app/renderer/control/sunbeamSfx.ts +++ b/app/renderer/control/sunbeamSfx.ts @@ -1,5 +1,7 @@ /** Звук эффекта «Луч света» (`public/luch_sveta.mp3`). */ +import { getEffectsSfxGain } from './effectsSfxGain'; + const SUNBEAM_SFX_VOLUME = 0.88; /** Воспроизведение на 50% быстрее → реальная длительность = файл / 1.5. */ export const SUNBEAM_PLAYBACK_RATE = 1.5; @@ -47,7 +49,7 @@ export async function getSunbeamEffectLifeMs(): Promise { export function playSunbeamEffectSound(): void { try { const el = new Audio(sunbeamEffectSoundUrl()); - el.volume = SUNBEAM_SFX_VOLUME; + el.volume = Math.max(0, Math.min(1, SUNBEAM_SFX_VOLUME * getEffectsSfxGain())); el.playbackRate = SUNBEAM_PLAYBACK_RATE; void el.play().catch(() => undefined); } catch { diff --git a/app/renderer/editor/i18n/editorMessages.ts b/app/renderer/editor/i18n/editorMessages.ts index 2f0db99..60c5b1e 100644 --- a/app/renderer/editor/i18n/editorMessages.ts +++ b/app/renderer/editor/i18n/editorMessages.ts @@ -541,6 +541,7 @@ export const EDITOR_MESSAGES: Record> = { 'control.freeze': 'Заморозка', 'control.poisonCloud': 'Облако яда', 'control.brushRadius': 'Радиус кисти', + 'control.effectsSound': 'Звук эффектов', 'control.storyLine': 'СЮЖЕТНАЯ ЛИНИЯ', 'control.gotoScene': 'Перейти к этой сцене', 'control.currentSceneBadge': 'ТЕКУЩАЯ СЦЕНА', @@ -1080,6 +1081,7 @@ export const EDITOR_MESSAGES: Record> = { 'control.freeze': 'Freeze', 'control.poisonCloud': 'Poison cloud', 'control.brushRadius': 'Brush radius', + 'control.effectsSound': 'Effects sound', 'control.storyLine': 'STORYLINE', 'control.gotoScene': 'Go to this scene', 'control.currentSceneBadge': 'CURRENT SCENE', diff --git a/app/renderer/public/fire-ambient.mp3 b/app/renderer/public/fire-ambient.mp3 new file mode 100644 index 0000000..e5824e6 Binary files /dev/null and b/app/renderer/public/fire-ambient.mp3 differ diff --git a/app/renderer/public/rain-ambient.mp3 b/app/renderer/public/rain-ambient.mp3 new file mode 100644 index 0000000..7864b41 Binary files /dev/null and b/app/renderer/public/rain-ambient.mp3 differ diff --git a/app/shared/fieldEffectEraser.test.ts b/app/shared/fieldEffectEraser.test.ts index 6beb36c..06b2e60 100644 --- a/app/shared/fieldEffectEraser.test.ts +++ b/app/shared/fieldEffectEraser.test.ts @@ -123,3 +123,69 @@ void test('applyFieldEffectEraserStroke: пустой штрих — без из assert.equal(next.length, 1); assert.equal(next[0]?.id, 'w1'); }); + +void test('applyFieldEffectEraserStroke: types=["fire"] гасит только огонь', () => { + const fire: EffectInstance = { + ...base, + id: 'fire1', + type: 'fire', + points: [ + { x: 0.5, y: 0.5, tMs: 0 }, + { x: 0.55, y: 0.5, tMs: 50 }, + ], + radiusN: 0.03, + opacity: 1, + lifetimeMs: null, + }; + const fog: EffectInstance = { + ...base, + id: 'fog1', + type: 'fog', + points: [{ x: 0.5, y: 0.5, tMs: 0 }], + radiusN: 0.03, + opacity: 0.5, + lifetimeMs: null, + }; + const next = applyFieldEffectEraserStroke( + [fire, fog], + [{ x: 0.5, y: 0.5, tMs: 0 }], + 0.08, + makeId, + ['fire'], + ); + assert.equal(next.length, 1); + assert.equal(next[0]?.id, 'fog1'); +}); + +void test('applyFieldEffectEraserStroke: hitMode=brush не гасит издалека', () => { + const fire: EffectInstance = { + ...base, + id: 'fire1', + type: 'fire', + points: [{ x: 0.5, y: 0.5, tMs: 0 }], + radiusN: 0.08, + opacity: 1, + lifetimeMs: null, + }; + // brush ≈ 0.38 * 0.08 ≈ 0.030; dist 0.06 — мимо, dist 0.02 — попадание. + const far = applyFieldEffectEraserStroke( + [fire], + [{ x: 0.5, y: 0.56, tMs: 0 }], + 0.08, + makeId, + ['fire'], + 'brush', + ); + assert.equal(far.length, 1); + assert.equal(far[0]?.id, 'fire1'); + + const near = applyFieldEffectEraserStroke( + [fire], + [{ x: 0.5, y: 0.52, tMs: 0 }], + 0.08, + makeId, + ['fire'], + 'brush', + ); + assert.equal(near.length, 0); +}); diff --git a/app/shared/fieldEffectEraser.ts b/app/shared/fieldEffectEraser.ts index 47bcb34..6f13678 100644 --- a/app/shared/fieldEffectEraser.ts +++ b/app/shared/fieldEffectEraser.ts @@ -47,13 +47,34 @@ function minDistToEraserStroke( return Math.sqrt(bestSq); } +/** + * `inclusive` — ластик: радиус кисти + радиус инстанса (снять всё пятно). + * `brush` — вода/дождь по огню: укороченный радиус кисти (спрайты огня шире radiusN, иначе гасится «издалека»). + */ +export type FieldEraseHitMode = 'inclusive' | 'brush'; + +/** Доля радиуса кисти для гашения огня водой/дождём (центр кисти ближе к точке огня). */ +export const FIRE_EXTINGUISH_BRUSH_SCALE = 0.38; + +function eraseThresholdN( + eraserRadiusN: number, + instRadiusN: number, + hitMode: FieldEraseHitMode, +): number { + if (hitMode === 'brush') { + return Math.max(0.006, eraserRadiusN * FIRE_EXTINGUISH_BRUSH_SCALE); + } + return eraserRadiusN + instRadiusN; +} + function isPointErased( p: { x: number; y: number }, instRadiusN: number, eraserPoints: readonly { x: number; y: number }[], eraserRadiusN: number, + hitMode: FieldEraseHitMode, ): boolean { - const threshold = eraserRadiusN + instRadiusN; + const threshold = eraseThresholdN(eraserRadiusN, instRadiusN, hitMode); return minDistToEraserStroke(p, eraserPoints) <= threshold; } @@ -62,11 +83,12 @@ function splitKeptPointRuns( eraserPoints: readonly { x: number; y: number }[], eraserRadiusN: number, instRadiusN: number, + hitMode: FieldEraseHitMode, ): NPoint[][] { const runs: NPoint[][] = []; let current: NPoint[] = []; for (const p of points) { - if (isPointErased(p, instRadiusN, eraserPoints, eraserRadiusN)) { + if (isPointErased(p, instRadiusN, eraserPoints, eraserRadiusN, hitMode)) { if (current.length > 0) { runs.push(current); current = []; @@ -83,14 +105,19 @@ function isFieldEffect(inst: EffectInstance): inst is FieldEffectInstance { return inst.type === 'fog' || inst.type === 'fire' || inst.type === 'rain' || inst.type === 'water'; } +export type FieldEffectType = FieldEffectInstance['type']; + /** Стирает эффекты поля (туман/дождь/огонь/вода) кистью, как «Кисть Открытия» для затемнения. */ export function applyFieldEffectEraserStroke( instances: readonly EffectInstance[], eraserPoints: readonly NPoint[], eraserRadiusN: number, makeId: (prefix: string) => string, + targetTypes?: readonly FieldEffectType[], + hitMode: FieldEraseHitMode = 'inclusive', ): EffectInstance[] { if (eraserPoints.length === 0) return [...instances]; + const typeFilter = targetTypes && targetTypes.length > 0 ? new Set(targetTypes) : null; const out: EffectInstance[] = []; for (const inst of instances) { @@ -98,8 +125,12 @@ export function applyFieldEffectEraserStroke( out.push(inst); continue; } + if (typeFilter && !typeFilter.has(inst.type)) { + out.push(inst); + continue; + } - const runs = splitKeptPointRuns(inst.points, eraserPoints, eraserRadiusN, inst.radiusN); + const runs = splitKeptPointRuns(inst.points, eraserPoints, eraserRadiusN, inst.radiusN, hitMode); if (runs.length === 0) continue; if (runs.length === 1 && runs[0]?.length === inst.points.length) { out.push(inst); diff --git a/app/shared/types/effects.ts b/app/shared/types/effects.ts index ad0f7d9..1ae5fd6 100644 --- a/app/shared/types/effects.ts +++ b/app/shared/types/effects.ts @@ -172,4 +172,15 @@ export type EffectsEvent = | { kind: 'instances.clear' } | { kind: 'instance.add'; instance: EffectInstance } | { kind: 'instance.remove'; id: string } - | { kind: 'field.erase'; points: NPoint[]; radiusN: number }; + | { + kind: 'field.erase'; + points: NPoint[]; + radiusN: number; + /** Если задано — стираются только эти типы полевых эффектов. */ + types?: readonly ('fog' | 'fire' | 'rain' | 'water')[]; + /** + * `inclusive` (по умолчанию) — ластик: radius кисти + radius эффекта. + * `brush` — гашение огня водой/дождём: только radius кисти. + */ + hitMode?: 'inclusive' | 'brush'; + };