From 657e3c862e127c913cbf3e610612e4c72f51c98f Mon Sep 17 00:00:00 2001 From: Ivan Fontosh Date: Thu, 2 Jul 2026 16:37:06 +0800 Subject: [PATCH] feat(effects): add Darkness action effect like freeze Adds a fullscreen darkness vignette on click and a permanent shadow spot with a black radial gradient (40% edge, 100% center). Includes control panel UI, i18n, eraser support, and tests. Co-authored-by: Cursor --- app/main/effects/effectsStore.ts | 4 +- app/renderer/control/ControlApp.tsx | 61 ++++++- .../control/controlApp.effectsPanel.test.ts | 1 + app/renderer/editor/i18n/editorMessages.ts | 6 +- .../shared/effects/PxiEffectsOverlay.tsx | 166 +++++++++++++++++- app/shared/effectEraserHitTest.test.ts | 27 +++ app/shared/effectEraserHitTest.ts | 6 +- app/shared/types/effects.ts | 25 ++- 8 files changed, 286 insertions(+), 10 deletions(-) diff --git a/app/main/effects/effectsStore.ts b/app/main/effects/effectsStore.ts index 22b97ee..38257d8 100644 --- a/app/main/effects/effectsStore.ts +++ b/app/main/effects/effectsStore.ts @@ -46,8 +46,8 @@ export class EffectsStore { const before = this.state.instances.length; const kept = this.state.instances.filter((i) => { // Пятно льда не истекает по таймеру (только «очистить все» или ластик в UI). - if (i.type === 'ice') return true; - if (i.type === 'lightning' || i.type === 'sunbeam' || i.type === 'poisonCloud') { + if (i.type === 'ice' || i.type === 'shadow') return true; + if (i.type === 'lightning' || i.type === 'sunbeam' || i.type === 'poisonCloud' || i.type === 'darkness') { return now - i.createdAtMs < i.lifetimeMs; } if (i.type === 'scorch') { diff --git a/app/renderer/control/ControlApp.tsx b/app/renderer/control/ControlApp.tsx index 53ada98..cf27ad2 100644 --- a/app/renderer/control/ControlApp.tsx +++ b/app/renderer/control/ControlApp.tsx @@ -69,7 +69,7 @@ export function ControlApp() { const previewHostRef = useRef(null); const previewVideoRef = useRef(null); const brushRef = useRef<{ - tool: 'fog' | 'fire' | 'rain' | 'water' | 'lightning' | 'sunbeam' | 'poisonCloud' | 'freeze' | 'eraser'; + tool: 'fog' | 'fire' | 'rain' | 'water' | 'darkness' | 'lightning' | 'sunbeam' | 'poisonCloud' | 'freeze' | 'eraser'; startN?: { x: number; y: number }; points?: { x: number; y: number; tMs: number }[]; } | null>(null); @@ -133,6 +133,11 @@ export function ControlApp() { void getFreezeEffectLifeMs().then(setFreezeDraftLifeMs); }, []); + const [darknessDraftLifeMs, setDarknessDraftLifeMs] = useState(820); + useEffect(() => { + void getFreezeEffectLifeMs().then(setDarknessDraftLifeMs); + }, []); + const [sunbeamDraftLifeMs, setSunbeamDraftLifeMs] = useState(600); useEffect(() => { void getSunbeamEffectLifeMs().then(setSunbeamDraftLifeMs); @@ -714,6 +719,37 @@ export function ControlApp() { }, }); } + if (b.tool === 'darkness' && b.points && b.points.length > 0) { + const last = b.points[b.points.length - 1]; + if (last === undefined) return; + const at = { x: last.x, y: last.y }; + const darknessLifeMs = await getFreezeEffectLifeMs(); + await fx.dispatch({ + kind: 'instance.add', + instance: { + id: `dk_${String(createdAtMs)}_${String(seed)}`, + type: 'darkness', + seed, + createdAtMs, + at, + intensity: Math.max(0.8, Math.min(1.25, tool.intensity * 1.15)), + lifetimeMs: darknessLifeMs, + }, + }); + await fx.dispatch({ + kind: 'instance.add', + instance: { + id: `sh_${String(createdAtMs)}_${String(seed)}`, + type: 'shadow', + seed: seed ^ 0x0d4a0001, + createdAtMs, + at, + radiusN: Math.max(0.03, tool.radiusN * 0.9), + opacity: 1, + lifetimeMs: null, + }, + }); + } if (b.tool === 'lightning' && b.startN && b.points && b.points.length > 0) { const last = b.points[b.points.length - 1]; if (last === undefined) return; @@ -879,6 +915,19 @@ export function ControlApp() { lifetimeMs: null, }; } + if (b.tool === 'darkness' && b.points && b.points.length > 0) { + const last = b.points[b.points.length - 1]; + if (last === undefined) return null; + return { + id: '__draft__', + type: 'darkness' as const, + seed, + createdAtMs, + at: { x: last.x, y: last.y }, + intensity: Math.max(0.8, Math.min(1.25, tool.intensity * 1.15)), + lifetimeMs: darknessDraftLifeMs, + }; + } if (b.tool === 'lightning' && b.startN && b.points && b.points.length > 0) { const last = b.points[b.points.length - 1]; if (last === undefined) return null; @@ -940,6 +989,7 @@ export function ControlApp() { // eslint-disable-next-line react-hooks/exhaustive-deps }, [ draftFxTick, + darknessDraftLifeMs, freezeDraftLifeMs, poisonDraftLifeMs, sunbeamDraftLifeMs, @@ -1073,6 +1123,15 @@ export function ControlApp() { > ❄️ +