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
+53 -25
View File
@@ -19,8 +19,8 @@ import { getFreezeEffectLifeMs, playFreezeEffectSound } from './freezeSfx';
import { getPoisonCloudEffectLifeMs, playPoisonCloudEffectSound } from './poisonCloudSfx';
import { getSunbeamEffectLifeMs, playSunbeamEffectSound } from './sunbeamSfx';
/** Длительность визуала молнии (мс). */
const LIGHTNING_EFFECT_MS = 180;
/** Длительность молнии: быстрый удар + акцент в точке попадания. */
const LIGHTNING_EFFECT_MS = 840;
function formatTime(sec: number): string {
if (!Number.isFinite(sec) || sec < 0) return '0:00';
@@ -154,8 +154,7 @@ export function ControlApp() {
const project = session?.project ?? null;
const currentGraphNodeId = project?.currentGraphNodeId ?? null;
const currentHistoryIdx =
currentGraphNodeId != null ? history.lastIndexOf(currentGraphNodeId) : -1;
const currentHistoryIdx = currentGraphNodeId != null ? history.lastIndexOf(currentGraphNodeId) : -1;
const currentScene =
project && session?.currentSceneId ? project.scenes[session.currentSceneId] : undefined;
const isVideoPreviewScene = currentScene?.previewAssetType === 'video';
@@ -660,6 +659,16 @@ export function ControlApp() {
return { x: Math.max(0, Math.min(1, x)), y: Math.max(0, Math.min(1, y)) };
}
function dispatchFieldEraserPoints(points: { x: number; y: number; tMs: number }[]): void {
if (points.length === 0) return;
void fx.dispatch({ kind: 'field.erase', points, radiusN: toolRef.current.radiusN });
}
function tryEraseActionEffect(p: { x: number; y: number }): void {
const id = pickEraseTargetId(fxState?.instances ?? [], p, toolRef.current.radiusN);
if (id) void fx.dispatch({ kind: 'instance.remove', id });
}
async function commitStroke(): Promise<void> {
if (isVideoPreviewScene) {
brushRef.current = null;
@@ -669,6 +678,10 @@ export function ControlApp() {
if (!fxState) return;
const b = brushRef.current;
if (!b) return;
if (b.tool === 'eraser') {
brushRef.current = null;
return;
}
const createdAtMs = Date.now();
const seed = Math.floor(Math.random() * 1_000_000_000);
@@ -697,8 +710,7 @@ export function ControlApp() {
createdAtMs,
points: b.points,
radiusN: tool.radiusN,
// Огонь визуально ярче, но всё равно ограничиваемся безопасными пределами.
opacity: Math.max(0.12, Math.min(0.95, tool.intensity)),
opacity: 1,
lifetimeMs: null,
},
});
@@ -790,8 +802,8 @@ export function ControlApp() {
createdAtMs,
start,
end,
widthN: Math.max(0.01, tool.radiusN * 0.9),
intensity: Math.max(0.9, Math.min(1.2, tool.intensity * 1.35)),
widthN: Math.max(0.014, tool.radiusN * 1.15),
intensity: Math.max(1, Math.min(1.4, tool.intensity * 1.45)),
lifetimeMs: LIGHTNING_EFFECT_MS,
},
});
@@ -800,12 +812,12 @@ export function ControlApp() {
instance: {
id: `sc_${String(createdAtMs)}_${String(seed)}`,
type: 'scorch',
seed: seed ^ 0x5a5a5a,
seed: seed ^ 0x7a7a7a,
createdAtMs,
at: end,
radiusN: Math.max(0.03, tool.radiusN * 0.625),
opacity: 0.92,
lifetimeMs: 60_000,
radiusN: Math.max(0.04, tool.radiusN * 0.82),
opacity: 0.96,
lifetimeMs: 90_000,
},
});
playLightningEffectSound();
@@ -913,7 +925,7 @@ export function ControlApp() {
createdAtMs,
points: b.points,
radiusN: tool.radiusN,
opacity: Math.max(0.12, Math.min(0.75, tool.intensity * 0.85)),
opacity: 1,
lifetimeMs: null,
};
}
@@ -964,8 +976,8 @@ export function ControlApp() {
createdAtMs,
start: { x: last.x, y: 0 },
end: { x: last.x, y: last.y },
widthN: Math.max(0.01, tool.radiusN * 0.9),
intensity: Math.max(0.9, Math.min(1.2, tool.intensity * 1.35)),
widthN: Math.max(0.014, tool.radiusN * 1.15),
intensity: Math.max(1, Math.min(1.4, tool.intensity * 1.45)),
lifetimeMs: LIGHTNING_EFFECT_MS,
};
}
@@ -1154,7 +1166,9 @@ export function ControlApp() {
iconOnly
title={t('control.darkness')}
ariaLabel={t('control.darkness')}
onClick={() => void fx.dispatch({ kind: 'tool.set', tool: { ...tool, tool: 'darkness' } })}
onClick={() =>
void fx.dispatch({ kind: 'tool.set', tool: { ...tool, tool: 'darkness' } })
}
>
<span className={styles.iconGlyph}>🌑</span>
</Button>
@@ -1288,11 +1302,7 @@ export function ControlApp() {
}
/>
{previewContentRect ? (
<SceneDarknessOverlay
state={sdState}
overlayAlpha={0.5}
viewport={previewContentRect}
/>
<SceneDarknessOverlay state={sdState} overlayAlpha={0.5} viewport={previewContentRect} />
) : null}
<div
ref={brushCursorElRef}
@@ -1319,8 +1329,10 @@ export function ControlApp() {
layoutBrushCursor();
(e.currentTarget as HTMLDivElement).setPointerCapture(e.pointerId);
if (tool.tool === 'eraser') {
const id = pickEraseTargetId(fxState?.instances ?? [], p, tool.radiusN);
if (id) void fx.dispatch({ kind: 'instance.remove', id });
const pt = { x: p.x, y: p.y, tMs: Date.now() };
brushRef.current = { tool: 'eraser', startN: p, points: [pt] };
dispatchFieldEraserPoints([pt]);
tryEraseActionEffect(p);
return;
}
brushRef.current = {
@@ -1345,8 +1357,24 @@ export function ControlApp() {
cursorPosRef.current = p;
layoutBrushCursor();
if (tool.tool === 'eraser' && (e.buttons & 1) !== 0) {
const id = pickEraseTargetId(fxState?.instances ?? [], p, tool.radiusN);
if (id) void fx.dispatch({ kind: 'instance.remove', id });
const b = brushRef.current;
const pt = { x: p.x, y: p.y, tMs: Date.now() };
if (b?.tool !== 'eraser' || !b.points) {
brushRef.current = { tool: 'eraser', startN: p, points: [pt] };
dispatchFieldEraserPoints([pt]);
} else {
const last = b.points[b.points.length - 1];
if (last) {
const dx = p.x - last.x;
const dy = p.y - last.y;
const minStep = Math.max(0.004, tool.radiusN * 0.25);
if (dx * dx + dy * dy >= minStep * minStep) {
b.points.push(pt);
dispatchFieldEraserPoints([last, pt]);
}
}
}
tryEraseActionEffect(p);
return;
}
const b = brushRef.current;
@@ -18,6 +18,7 @@ void test('ControlApp: звук молнии (public/molniya.mp3)', () => {
const src = readControlApp();
assert.ok(src.includes('molniya.mp3'));
assert.ok(src.includes('playLightningEffectSound'));
assert.ok(src.includes('LIGHTNING_EFFECT_MS'));
});
void test('ControlApp: звук заморозки (public/zamorozka.mp3)', () => {
@@ -56,7 +57,9 @@ void test('ControlApp: эффекты в пульте, иконки с тулт
assert.ok(src.includes('SceneDarknessOverlay'));
assert.ok(src.includes('useSceneDarknessState'));
assert.ok(src.includes("t('control.sunbeam')"));
assert.ok(src.includes("t('control.lightning')"));
assert.ok(src.includes("title={t('control.water')}"));
assert.ok(src.includes("title={t('control.fire')}"));
assert.ok(src.includes("title={t('control.darkness')}"));
assert.ok(src.includes("title={t('control.poisonCloud')}"));
assert.ok(src.includes("title={t('control.fog')}"));