diff --git a/src/components/backgrounds/BackgroundRenderer.tsx b/src/components/backgrounds/BackgroundRenderer.tsx index df8b53e..5529d06 100644 --- a/src/components/backgrounds/BackgroundRenderer.tsx +++ b/src/components/backgrounds/BackgroundRenderer.tsx @@ -20,6 +20,8 @@ function reduceMobileSettings(settings: Record): Record, () => Promise import('./spotlight-bg'), ripple: () => import('./background-ripple'), fireflies: () => import('./fireflies'), + snowfall: () => import('./snowfall'), }; /** Prefetch the JS chunk for a background type (call early to avoid lazy-load delay) */ @@ -49,6 +50,7 @@ export const backgroundComponents: Record< spotlight: lazy(() => import('./spotlight-bg')), ripple: lazy(() => import('./background-ripple')), fireflies: lazy(() => import('./fireflies')), + snowfall: lazy(() => import('./snowfall')), }; // Registry of all background definitions with settings for the editor @@ -616,4 +618,40 @@ export const backgroundRegistry: BackgroundDefinition[] = [ }, ], }, + { + type: 'snowfall', + labelKey: 'admin.backgrounds.snowfall', + descriptionKey: 'admin.backgrounds.snowfallDesc', + category: 'canvas', + settings: [ + { key: 'color', label: 'admin.backgrounds.particleColor', type: 'color', default: '#ffffff' }, + { + key: 'density', + label: 'admin.backgrounds.density', + type: 'number', + min: 20, + max: 400, + step: 10, + default: 150, + }, + { + key: 'speed', + label: 'admin.backgrounds.speed', + type: 'number', + min: 0.1, + max: 3, + step: 0.1, + default: 1, + }, + { + key: 'wind', + label: 'admin.backgrounds.wind', + type: 'number', + min: -3, + max: 3, + step: 0.5, + default: 0.5, + }, + ], + }, ]; diff --git a/src/components/ui/backgrounds/snowfall.tsx b/src/components/ui/backgrounds/snowfall.tsx new file mode 100644 index 0000000..a4a476a --- /dev/null +++ b/src/components/ui/backgrounds/snowfall.tsx @@ -0,0 +1,114 @@ +import { useEffect, useRef } from 'react'; +import { sanitizeColor, clampNumber } from './types'; +import { useAnimationLoop, getMobileDpr } from '@/hooks/useAnimationLoop'; + +interface Props { + settings: Record; +} + +interface Flake { + x: number; + y: number; + radius: number; + fallSpeed: number; + phase: number; + opacity: number; +} + +interface SnowfallState { + ctx: CanvasRenderingContext2D; + flakes: Flake[]; + w: number; + h: number; + dpr: number; +} + +export default function SnowfallBackground({ settings }: Props) { + const canvasRef = useRef(null); + const stateRef = useRef(null); + + const color = sanitizeColor(settings.color, '#ffffff'); + const density = clampNumber(settings.density, 20, 400, 150); + const speed = clampNumber(settings.speed, 0.1, 3, 1); + const wind = clampNumber(settings.wind, -3, 3, 0.5); + + useEffect(() => { + const canvas = canvasRef.current; + if (!canvas) return; + + const dpr = getMobileDpr(); + const parent = canvas.parentElement; + const w = parent?.offsetWidth ?? window.innerWidth; + const h = parent?.offsetHeight ?? window.innerHeight; + canvas.width = w * dpr; + canvas.height = h * dpr; + canvas.style.width = `${w}px`; + canvas.style.height = `${h}px`; + + const ctx = canvas.getContext('2d'); + if (!ctx) return; + ctx.setTransform(dpr, 0, 0, dpr, 0, 0); + + const flakes: Flake[] = Array.from({ length: Math.floor(density) }, () => ({ + x: Math.random() * w, + y: Math.random() * h, + radius: 0.8 + Math.random() * 2.2, + fallSpeed: 0.4 + Math.random() * 1.2, + phase: Math.random() * Math.PI * 2, + opacity: 0.3 + Math.random() * 0.7, + })); + + stateRef.current = { ctx, flakes, w, h, dpr }; + + const onResize = () => { + const nw = parent?.offsetWidth ?? window.innerWidth; + const nh = parent?.offsetHeight ?? window.innerHeight; + canvas.width = nw * dpr; + canvas.height = nh * dpr; + canvas.style.width = `${nw}px`; + canvas.style.height = `${nh}px`; + if (stateRef.current) { + stateRef.current.ctx.setTransform(dpr, 0, 0, dpr, 0, 0); + stateRef.current.w = nw; + stateRef.current.h = nh; + } + }; + + window.addEventListener('resize', onResize); + return () => window.removeEventListener('resize', onResize); + }, [density]); + + useAnimationLoop( + (time) => { + const state = stateRef.current; + if (!state) return; + + const { ctx, flakes, w, h } = state; + + ctx.clearRect(0, 0, w, h); + ctx.fillStyle = color; + + for (const f of flakes) { + f.y += f.fallSpeed * speed; + f.x += wind * f.fallSpeed * 0.6 + Math.sin(time / 1800 + f.phase) * 0.4; + + if (f.y > h + 5) { + f.y = -5; + f.x = Math.random() * w; + } + if (f.x > w + 5) f.x = -5; + if (f.x < -5) f.x = w + 5; + + ctx.globalAlpha = f.opacity; + ctx.beginPath(); + ctx.arc(f.x, f.y, f.radius, 0, Math.PI * 2); + ctx.fill(); + } + + ctx.globalAlpha = 1; + }, + [color, density, speed, wind], + ); + + return ; +} diff --git a/src/components/ui/backgrounds/types.ts b/src/components/ui/backgrounds/types.ts index 8f814c3..a696457 100644 --- a/src/components/ui/backgrounds/types.ts +++ b/src/components/ui/backgrounds/types.ts @@ -15,6 +15,7 @@ export type BackgroundType = | 'spotlight' | 'ripple' | 'fireflies' + | 'snowfall' | 'none'; export interface AnimationConfig { diff --git a/src/locales/en.json b/src/locales/en.json index 66dc887..896d8f6 100644 --- a/src/locales/en.json +++ b/src/locales/en.json @@ -1155,6 +1155,8 @@ "rippleDesc": "Ripple wave effect", "fireflies": "Fireflies", "firefliesDesc": "Drifting glowing dots with pulsation", + "snowfall": "Snowfall", + "snowfallDesc": "Falling snow with wind drift", "geminiEffect": "Gemini Effect", "geminiEffectDesc": "SVG effect like Google Gemini", "speed": "Speed", @@ -1176,6 +1178,7 @@ "explosionColor": "Explosion Color", "multicolor": "Multicolor", "lineColor": "Line Color", + "wind": "Wind", "bgColor": "Background Color", "fillColor": "Fill Color", "color1": "Color 1", diff --git a/src/locales/fa.json b/src/locales/fa.json index c064a2a..2a2b2a9 100644 --- a/src/locales/fa.json +++ b/src/locales/fa.json @@ -1037,6 +1037,8 @@ "rippleDesc": "اثر موج دایره‌ای", "fireflies": "کرم‌های شب‌تاب", "firefliesDesc": "نقاط درخشان شناور با تپش", + "snowfall": "بارش برف", + "snowfallDesc": "برف در حال بارش با وزش باد", "geminiEffect": "افکت Gemini", "geminiEffectDesc": "افکت SVG مشابه Google Gemini", "speed": "سرعت", @@ -1058,6 +1060,7 @@ "explosionColor": "رنگ انفجار", "multicolor": "حالت چندرنگ", "lineColor": "رنگ خطوط", + "wind": "باد", "bgColor": "رنگ پس‌زمینه", "fillColor": "رنگ پر کردن", "color1": "رنگ ۱", diff --git a/src/locales/ru.json b/src/locales/ru.json index 97d8a1d..cca54c5 100644 --- a/src/locales/ru.json +++ b/src/locales/ru.json @@ -1185,6 +1185,8 @@ "rippleDesc": "Волновой эффект при клике", "fireflies": "Fireflies", "firefliesDesc": "Дрейфующие светящиеся точки с пульсацией", + "snowfall": "Snowfall", + "snowfallDesc": "Падающий снег с ветром", "geminiEffect": "Gemini Effect", "geminiEffectDesc": "SVG-эффект как на Google Gemini", "speed": "Скорость", @@ -1206,6 +1208,7 @@ "explosionColor": "Цвет взрыва", "multicolor": "Разноцветный режим", "lineColor": "Цвет линий", + "wind": "Ветер", "bgColor": "Цвет фона", "fillColor": "Цвет заливки", "color1": "Цвет 1", diff --git a/src/locales/zh.json b/src/locales/zh.json index d8923f9..2b9884a 100644 --- a/src/locales/zh.json +++ b/src/locales/zh.json @@ -1037,6 +1037,8 @@ "rippleDesc": "涟漪波纹效果", "fireflies": "萤火虫", "firefliesDesc": "漂浮闪烁的发光点", + "snowfall": "降雪", + "snowfallDesc": "随风飘落的雪花", "geminiEffect": "Gemini效果", "geminiEffectDesc": "类似Google Gemini的SVG效果", "speed": "速度", @@ -1058,6 +1060,7 @@ "explosionColor": "爆炸颜色", "multicolor": "多彩模式", "lineColor": "线条颜色", + "wind": "风力", "bgColor": "背景颜色", "fillColor": "填充颜色", "color1": "颜色1", diff --git a/src/utils/backgroundConfig.ts b/src/utils/backgroundConfig.ts index 73cd48c..ccb9a77 100644 --- a/src/utils/backgroundConfig.ts +++ b/src/utils/backgroundConfig.ts @@ -20,6 +20,7 @@ const VALID_TYPES: ReadonlySet = new Set([ 'spotlight', 'ripple', 'fireflies', + 'snowfall', 'none', ]);