import type { EffectInstance, NPoint } from './types/effects'; type FieldEffectInstance = Extract; 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; }