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
+34 -3
View File
@@ -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);