feat(effects): extinguish fire with water/rain and ambient SFX volume

Water and rain erase fire under a tight brush hit radius; add looping fire/rain ambients and an Effects sound slider that also scales one-shot effect SFX.

Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
Ivan Fontosh
2026-07-23 13:12:41 +08:00
parent a797162f16
commit 2c0e6fbf09
16 changed files with 370 additions and 16 deletions
+5 -1
View File
@@ -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);
+92 -6
View File
@@ -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() {
/>
<div className={styles.radiusValue}>{Math.round(tool.radiusN * 100)}</div>
</div>
<div className={[styles.radiusRow, styles.effectsSoundRow].join(' ')}>
<div className={styles.radiusLabel}>{t('control.effectsSound')}</div>
<input
type="range"
min={0}
max={1}
step={0.01}
value={effectsSfxGainUi}
onChange={(e) => {
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')}
/>
<div className={styles.radiusValue}>{Math.round(effectsSfxGainUi * 100)}</div>
</div>
</div>
<div className={styles.spacer12} />
</>
@@ -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={() => {
@@ -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*;/;
+17
View File
@@ -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;
}
+50
View File
@@ -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 */
}
}
+4 -2
View File
@@ -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<number> {
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 */
+3 -1
View File
@@ -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<void>
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 {
+50
View File
@@ -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 */
}
}
+3 -1
View File
@@ -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<number> {
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 {
@@ -541,6 +541,7 @@ export const EDITOR_MESSAGES: Record<EditorLocale, Record<string, string>> = {
'control.freeze': 'Заморозка',
'control.poisonCloud': 'Облако яда',
'control.brushRadius': 'Радиус кисти',
'control.effectsSound': 'Звук эффектов',
'control.storyLine': 'СЮЖЕТНАЯ ЛИНИЯ',
'control.gotoScene': 'Перейти к этой сцене',
'control.currentSceneBadge': 'ТЕКУЩАЯ СЦЕНА',
@@ -1080,6 +1081,7 @@ export const EDITOR_MESSAGES: Record<EditorLocale, Record<string, string>> = {
'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',
Binary file not shown.
Binary file not shown.