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
+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={() => {