feat(effects): migrate battle effects to VFX

- add WebP frame-sequence assets for lightning, electric accent, fire, sunbeam, poison, rain, water, and rounded fog effects
- replace procedural lightning rendering with VFX frame playback
- align lightning impact to the click point
- remove the full-screen lightning blur/flash overlay
- add electric accent playback for lightning impact dispersion
- keep lightning scorch marks as the persistent ground trace after the action effect
- consolidate the lightning tool into a single public `Молния` action
- replace procedural fire with the VFX fire implementation
- remove the separate `Огонь 2` tool and keep the VFX fire under `Огонь`
- keep VFX fire brightness fixed at full intensity
- preserve fire as a field effect with brush placement and eraser support
- replace procedural sunbeam with the Pulse Discharge VFX implementation
- remove the separate `Луч света 2` tool and keep the VFX sunbeam under `Луч света`
- align sunbeam impact to the click point instead of the raw frame center
- remove the full-screen sunbeam flash overlay
- add Dust Burst VFX playback for poison cloud
- tint poison smoke green
- keep the skull glyph rendered above the poison smoke
- align poison smoke bottom to the click point
- remove the old procedural poison cloud implementation
- remove the separate `Яд 2` tool and keep the VFX poison under `Яд`
- replace procedural rain strokes with medium-rain VFX frame playback
- recolor rain frames to gray/light-gray so rain no longer renders black
- preserve rain as a field effect with brush placement and eraser support
- replace static water fill with generated animated water VFX frames
- render water VFX through the existing stroke mask so water keeps the painted brush shape
- preserve water as a field effect with brush placement and eraser support
- replace procedural fog texture generation with Smokey Atmosphere VFX frames
- generate rounded fog VFX frames with soft alpha falloff at the edges
- size each fog VFX stamp to the brush diameter
- remove per-stamp fog drift and rotation so fog elements stay fixed in place
- keep fog animation limited to internal frame playback
- preload VFX frame textures for smoother first use
- cache VFX textures per Pixi instance to avoid duplicate decoding
- filter expired action instances before node synchronization to stop old effects from flashing back during rapid casts
- add brush-stroke erasing for field effects using the same model as scene darkness reveal
- keep whole-instance erasing for action effects
- update effect hit tests for the consolidated VFX action effects
- update effect store pruning for renamed and consolidated action effects
- update control-panel buttons for consolidated fire, sunbeam, poison, and VFX-backed field effects
- update editor localization for removed and renamed effect tools
- add tests for field-effect brush erasing
- update eraser hit-test coverage for VFX-backed effects
- update effects store lifetime pruning tests for consolidated poison
- update control-panel tests for consolidated effect buttons
- include the field-effect eraser test in the default test script

Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
Ivan Fontosh
2026-07-03 21:20:43 +08:00
parent f5240d1623
commit 089da3cae0
334 changed files with 1203 additions and 671 deletions
+119
View File
@@ -0,0 +1,119 @@
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);
}
function isPointErased(
p: { x: number; y: number },
instRadiusN: number,
eraserPoints: readonly { x: number; y: number }[],
eraserRadiusN: number,
): boolean {
const threshold = eraserRadiusN + instRadiusN;
return minDistToEraserStroke(p, eraserPoints) <= threshold;
}
function splitKeptPointRuns(
points: NPoint[],
eraserPoints: readonly { x: number; y: number }[],
eraserRadiusN: number,
instRadiusN: number,
): NPoint[][] {
const runs: NPoint[][] = [];
let current: NPoint[] = [];
for (const p of points) {
if (isPointErased(p, instRadiusN, eraserPoints, eraserRadiusN)) {
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 function applyFieldEffectEraserStroke(
instances: readonly EffectInstance[],
eraserPoints: readonly NPoint[],
eraserRadiusN: number,
makeId: (prefix: string) => string,
): EffectInstance[] {
if (eraserPoints.length === 0) return [...instances];
const out: EffectInstance[] = [];
for (const inst of instances) {
if (!isFieldEffect(inst)) {
out.push(inst);
continue;
}
const runs = splitKeptPointRuns(inst.points, eraserPoints, eraserRadiusN, inst.radiusN);
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;
}