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:
@@ -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);
|
||||
});
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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';
|
||||
};
|
||||
|
||||
Reference in New Issue
Block a user