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:
@@ -6,7 +6,7 @@ import type { EffectInstance } from './types/effects';
|
||||
|
||||
const base = { seed: 1, createdAtMs: 0 };
|
||||
|
||||
void test('pickEraseTargetId: fire/rain по штриху как туман', () => {
|
||||
void test('pickEraseTargetId: fire/rain/water — кистью, не целиком', () => {
|
||||
const fire: EffectInstance = {
|
||||
...base,
|
||||
id: 'f1',
|
||||
@@ -17,10 +17,10 @@ void test('pickEraseTargetId: fire/rain по штриху как туман', ()
|
||||
lifetimeMs: null,
|
||||
};
|
||||
const id = pickEraseTargetId([fire], { x: 0.51, y: 0.5 }, 0.05);
|
||||
assert.equal(id, 'f1');
|
||||
assert.equal(id, null);
|
||||
});
|
||||
|
||||
void test('pickEraseTargetId: вода по штриху как туман', () => {
|
||||
void test('pickEraseTargetId: вода — кистью, не целиком', () => {
|
||||
const water: EffectInstance = {
|
||||
...base,
|
||||
id: 'w1',
|
||||
@@ -31,7 +31,7 @@ void test('pickEraseTargetId: вода по штриху как туман', ()
|
||||
lifetimeMs: null,
|
||||
};
|
||||
const id = pickEraseTargetId([water], { x: 0.41, y: 0.55 }, 0.05);
|
||||
assert.equal(id, 'w1');
|
||||
assert.equal(id, null);
|
||||
});
|
||||
|
||||
void test('pickEraseTargetId: тьма как заморозка — точка удара', () => {
|
||||
@@ -108,3 +108,17 @@ void test('pickEraseTargetId: scorch с учётом inst.radiusN', () => {
|
||||
const id = pickEraseTargetId([sc], { x: 0.59, y: 0.5 }, 0.02);
|
||||
assert.equal(id, 's1');
|
||||
});
|
||||
|
||||
void test('pickEraseTargetId: яд с учётом inst.radiusN', () => {
|
||||
const pc: EffectInstance = {
|
||||
...base,
|
||||
id: 'pc',
|
||||
type: 'poisonCloud',
|
||||
at: { x: 0.5, y: 0.5 },
|
||||
radiusN: 0.08,
|
||||
intensity: 1,
|
||||
lifetimeMs: 1000,
|
||||
};
|
||||
const id = pickEraseTargetId([pc], { x: 0.59, y: 0.5 }, 0.02);
|
||||
assert.equal(id, 'pc');
|
||||
});
|
||||
|
||||
@@ -63,14 +63,19 @@ export function minDistSqEffectToPoint(inst: EffectInstance, p: { x: number; y:
|
||||
}
|
||||
|
||||
function eraseHitThresholdSq(inst: EffectInstance, toolRadiusN: number): number {
|
||||
if (inst.type === 'scorch' || inst.type === 'ice' || inst.type === 'shadow' || inst.type === 'poisonCloud') {
|
||||
if (
|
||||
inst.type === 'scorch' ||
|
||||
inst.type === 'ice' ||
|
||||
inst.type === 'shadow' ||
|
||||
inst.type === 'poisonCloud'
|
||||
) {
|
||||
const r = toolRadiusN + inst.radiusN;
|
||||
return r * r;
|
||||
}
|
||||
return toolRadiusN * toolRadiusN;
|
||||
}
|
||||
|
||||
/** Ближайший эффект в пределах радиуса ластика, иначе `null`. */
|
||||
/** Ближайший эффект действия в пределах радиуса ластика, иначе `null`. Эффекты поля стираются кистью отдельно. */
|
||||
export function pickEraseTargetId(
|
||||
instances: readonly EffectInstance[],
|
||||
p: { x: number; y: number },
|
||||
@@ -78,6 +83,9 @@ export function pickEraseTargetId(
|
||||
): string | null {
|
||||
let best: { id: string; dd: number } | null = null;
|
||||
for (const inst of instances) {
|
||||
if (inst.type === 'fog' || inst.type === 'fire' || inst.type === 'rain' || inst.type === 'water') {
|
||||
continue;
|
||||
}
|
||||
const dd = minDistSqEffectToPoint(inst, p);
|
||||
const th = eraseHitThresholdSq(inst, toolRadiusN);
|
||||
if (dd <= th && (!best || dd < best.dd)) {
|
||||
|
||||
@@ -0,0 +1,125 @@
|
||||
import assert from 'node:assert/strict';
|
||||
import test from 'node:test';
|
||||
|
||||
import { applyFieldEffectEraserStroke } from './fieldEffectEraser';
|
||||
import type { EffectInstance } from './types/effects';
|
||||
|
||||
const base = { seed: 1, createdAtMs: 0 };
|
||||
const makeId = (prefix: string) => `${prefix}_new`;
|
||||
|
||||
void test('applyFieldEffectEraserStroke: удаляет точки в радиусе кисти', () => {
|
||||
const fog: EffectInstance = {
|
||||
...base,
|
||||
id: 'f1',
|
||||
type: 'fog',
|
||||
points: [
|
||||
{ x: 0.2, y: 0.5, tMs: 0 },
|
||||
{ x: 0.5, y: 0.5, tMs: 100 },
|
||||
{ x: 0.8, y: 0.5, tMs: 200 },
|
||||
],
|
||||
radiusN: 0.02,
|
||||
opacity: 0.5,
|
||||
lifetimeMs: null,
|
||||
};
|
||||
const next = applyFieldEffectEraserStroke([fog], [{ x: 0.5, y: 0.5, tMs: 0 }], 0.05, makeId);
|
||||
assert.equal(next.length, 2);
|
||||
const left = next[0];
|
||||
const right = next[1];
|
||||
assert.ok(left);
|
||||
assert.ok(right);
|
||||
assert.equal(left.type, 'fog');
|
||||
assert.equal(right.type, 'fog');
|
||||
assert.equal(left.points.length, 1);
|
||||
assert.equal(left.points[0]?.x, 0.2);
|
||||
assert.equal(right.points.length, 1);
|
||||
assert.equal(right.points[0]?.x, 0.8);
|
||||
});
|
||||
|
||||
void test('applyFieldEffectEraserStroke: стирает вдоль сегмента штриха', () => {
|
||||
const rain: EffectInstance = {
|
||||
...base,
|
||||
id: 'r1',
|
||||
type: 'rain',
|
||||
points: [
|
||||
{ x: 0.1, y: 0.5, tMs: 0 },
|
||||
{ x: 0.3, y: 0.5, tMs: 50 },
|
||||
{ x: 0.5, y: 0.5, tMs: 100 },
|
||||
{ x: 0.7, y: 0.5, tMs: 150 },
|
||||
{ x: 0.9, y: 0.5, tMs: 200 },
|
||||
],
|
||||
radiusN: 0.01,
|
||||
opacity: 0.6,
|
||||
lifetimeMs: null,
|
||||
};
|
||||
const next = applyFieldEffectEraserStroke(
|
||||
[rain],
|
||||
[
|
||||
{ x: 0.25, y: 0.5, tMs: 0 },
|
||||
{ x: 0.75, y: 0.5, tMs: 50 },
|
||||
],
|
||||
0.04,
|
||||
makeId,
|
||||
);
|
||||
assert.equal(next.length, 2);
|
||||
const xs = next.flatMap((i) => (i.type === 'rain' ? i.points.map((p) => p.x) : []));
|
||||
assert.deepEqual(xs, [0.1, 0.9]);
|
||||
});
|
||||
|
||||
void test('applyFieldEffectEraserStroke: VFX-огонь стирается как полевой эффект', () => {
|
||||
const fire: EffectInstance = {
|
||||
...base,
|
||||
id: 'fire_vfx',
|
||||
type: 'fire',
|
||||
points: [
|
||||
{ x: 0.2, y: 0.5, tMs: 0 },
|
||||
{ x: 0.5, y: 0.5, tMs: 100 },
|
||||
],
|
||||
radiusN: 0.02,
|
||||
opacity: 0.8,
|
||||
lifetimeMs: null,
|
||||
};
|
||||
const next = applyFieldEffectEraserStroke([fire], [{ x: 0.5, y: 0.5, tMs: 0 }], 0.05, makeId);
|
||||
assert.equal(next.length, 1);
|
||||
const kept = next[0];
|
||||
assert.equal(kept?.type === 'fire' && kept.points[0]?.x, 0.2);
|
||||
});
|
||||
|
||||
void test('applyFieldEffectEraserStroke: не трогает эффекты действия', () => {
|
||||
const fog: EffectInstance = {
|
||||
...base,
|
||||
id: 'f1',
|
||||
type: 'fog',
|
||||
points: [{ x: 0.5, y: 0.5, tMs: 0 }],
|
||||
radiusN: 0.05,
|
||||
opacity: 1,
|
||||
lifetimeMs: null,
|
||||
};
|
||||
const bolt: EffectInstance = {
|
||||
...base,
|
||||
id: 'L1',
|
||||
type: 'lightning',
|
||||
start: { x: 0.5, y: 0 },
|
||||
end: { x: 0.5, y: 0.8 },
|
||||
widthN: 0.02,
|
||||
intensity: 1,
|
||||
lifetimeMs: 500,
|
||||
};
|
||||
const next = applyFieldEffectEraserStroke([fog, bolt], [{ x: 0.5, y: 0.5, tMs: 0 }], 0.1, makeId);
|
||||
assert.equal(next.length, 1);
|
||||
assert.equal(next[0]?.id, 'L1');
|
||||
});
|
||||
|
||||
void test('applyFieldEffectEraserStroke: пустой штрих — без изменений', () => {
|
||||
const water: EffectInstance = {
|
||||
...base,
|
||||
id: 'w1',
|
||||
type: 'water',
|
||||
points: [{ x: 0.4, y: 0.4, tMs: 0 }],
|
||||
radiusN: 0.05,
|
||||
opacity: 0.5,
|
||||
lifetimeMs: null,
|
||||
};
|
||||
const next = applyFieldEffectEraserStroke([water], [], 0.05, makeId);
|
||||
assert.equal(next.length, 1);
|
||||
assert.equal(next[0]?.id, 'w1');
|
||||
});
|
||||
@@ -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;
|
||||
}
|
||||
@@ -171,4 +171,5 @@ export type EffectsEvent =
|
||||
| { kind: 'tool.set'; tool: EffectToolState }
|
||||
| { kind: 'instances.clear' }
|
||||
| { kind: 'instance.add'; instance: EffectInstance }
|
||||
| { kind: 'instance.remove'; id: string };
|
||||
| { kind: 'instance.remove'; id: string }
|
||||
| { kind: 'field.erase'; points: NPoint[]; radiusN: number };
|
||||
|
||||
Reference in New Issue
Block a user