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:
@@ -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={() => {
|
||||
|
||||
Reference in New Issue
Block a user