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';
+ };