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 <cursoragent@cursor.com>
This commit is contained in:
Ivan Fontosh
2026-07-02 16:37:06 +08:00
parent 97c77a025a
commit 657e3c862e
8 changed files with 286 additions and 10 deletions
+60 -1
View File
@@ -69,7 +69,7 @@ export function ControlApp() {
const previewHostRef = useRef<HTMLDivElement | null>(null);
const previewVideoRef = useRef<HTMLVideoElement | null>(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() {
>
<span className={styles.iconGlyph}></span>
</Button>
<Button
variant={tool.tool === 'darkness' ? 'primary' : 'ghost'}
iconOnly
title={t('control.darkness')}
ariaLabel={t('control.darkness')}
onClick={() => void fx.dispatch({ kind: 'tool.set', tool: { ...tool, tool: 'darkness' } })}
>
<span className={styles.iconGlyph}>🌑</span>
</Button>
<Button
variant={tool.tool === 'poisonCloud' ? 'primary' : 'ghost'}
iconOnly