2c0e6fbf09
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>
151 lines
4.5 KiB
TypeScript
151 lines
4.5 KiB
TypeScript
import type { EffectInstance, NPoint } from './types/effects';
|
|
|
|
type FieldEffectInstance = Extract<EffectInstance, { type: 'fog' | 'fire' | 'rain' | 'water' }>;
|
|
|
|
function distSqPointToSegment(
|
|
px: number,
|
|
py: number,
|
|
x1: number,
|
|
y1: number,
|
|
x2: number,
|
|
y2: number,
|
|
): number {
|
|
const dx = x2 - x1;
|
|
const dy = y2 - y1;
|
|
const len2 = dx * dx + dy * dy;
|
|
if (len2 < 1e-18) {
|
|
const ex = px - x1;
|
|
const ey = py - y1;
|
|
return ex * ex + ey * ey;
|
|
}
|
|
let t = ((px - x1) * dx + (py - y1) * dy) / len2;
|
|
t = Math.max(0, Math.min(1, t));
|
|
const qx = x1 + t * dx;
|
|
const qy = y1 + t * dy;
|
|
const ex = px - qx;
|
|
const ey = py - qy;
|
|
return ex * ex + ey * ey;
|
|
}
|
|
|
|
function minDistToEraserStroke(
|
|
p: { x: number; y: number },
|
|
eraserPoints: readonly { x: number; y: number }[],
|
|
): number {
|
|
if (eraserPoints.length === 0) return Number.POSITIVE_INFINITY;
|
|
let bestSq = Number.POSITIVE_INFINITY;
|
|
for (const ep of eraserPoints) {
|
|
const dx = p.x - ep.x;
|
|
const dy = p.y - ep.y;
|
|
bestSq = Math.min(bestSq, dx * dx + dy * dy);
|
|
}
|
|
for (let i = 1; i < eraserPoints.length; i += 1) {
|
|
const a = eraserPoints[i - 1];
|
|
const b = eraserPoints[i];
|
|
if (!a || !b) continue;
|
|
bestSq = Math.min(bestSq, distSqPointToSegment(p.x, p.y, a.x, a.y, b.x, b.y));
|
|
}
|
|
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 = eraseThresholdN(eraserRadiusN, instRadiusN, hitMode);
|
|
return minDistToEraserStroke(p, eraserPoints) <= threshold;
|
|
}
|
|
|
|
function splitKeptPointRuns(
|
|
points: NPoint[],
|
|
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, hitMode)) {
|
|
if (current.length > 0) {
|
|
runs.push(current);
|
|
current = [];
|
|
}
|
|
continue;
|
|
}
|
|
current.push(p);
|
|
}
|
|
if (current.length > 0) runs.push(current);
|
|
return runs;
|
|
}
|
|
|
|
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) {
|
|
if (!isFieldEffect(inst)) {
|
|
out.push(inst);
|
|
continue;
|
|
}
|
|
if (typeFilter && !typeFilter.has(inst.type)) {
|
|
out.push(inst);
|
|
continue;
|
|
}
|
|
|
|
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);
|
|
continue;
|
|
}
|
|
|
|
for (const run of runs) {
|
|
if (run.length === 0) continue;
|
|
out.push({
|
|
...inst,
|
|
id: makeId(inst.type),
|
|
points: run,
|
|
});
|
|
}
|
|
}
|
|
return out;
|
|
}
|