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:
@@ -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')}"));
|
||||
|
||||
Reference in New Issue
Block a user