diff --git a/src/components/backgrounds/BackgroundRenderer.tsx b/src/components/backgrounds/BackgroundRenderer.tsx index 5529d06..c586d6f 100644 --- a/src/components/backgrounds/BackgroundRenderer.tsx +++ b/src/components/backgrounds/BackgroundRenderer.tsx @@ -22,6 +22,8 @@ function reduceMobileSettings(settings: Record): Record, () => Promise import('./background-ripple'), fireflies: () => import('./fireflies'), snowfall: () => import('./snowfall'), + starfield: () => import('./starfield'), }; /** Prefetch the JS chunk for a background type (call early to avoid lazy-load delay) */ @@ -51,6 +52,7 @@ export const backgroundComponents: Record< ripple: lazy(() => import('./background-ripple')), fireflies: lazy(() => import('./fireflies')), snowfall: lazy(() => import('./snowfall')), + starfield: lazy(() => import('./starfield')), }; // Registry of all background definitions with settings for the editor @@ -654,4 +656,31 @@ export const backgroundRegistry: BackgroundDefinition[] = [ }, ], }, + { + type: 'starfield', + labelKey: 'admin.backgrounds.starfield', + descriptionKey: 'admin.backgrounds.starfieldDesc', + category: 'canvas', + settings: [ + { key: 'color', label: 'admin.backgrounds.starColor', type: 'color', default: '#ffffff' }, + { + key: 'starCount', + label: 'admin.backgrounds.particles', + type: 'number', + min: 50, + max: 800, + step: 25, + default: 200, + }, + { + key: 'speed', + label: 'admin.backgrounds.speed', + type: 'number', + min: 0.1, + max: 5, + step: 0.1, + default: 1, + }, + ], + }, ]; diff --git a/src/components/ui/backgrounds/starfield.tsx b/src/components/ui/backgrounds/starfield.tsx new file mode 100644 index 0000000..d161402 --- /dev/null +++ b/src/components/ui/backgrounds/starfield.tsx @@ -0,0 +1,123 @@ +import { useEffect, useRef } from 'react'; +import { sanitizeColor, clampNumber } from './types'; +import { useAnimationLoop, getMobileDpr } from '@/hooks/useAnimationLoop'; + +interface Props { + settings: Record; +} + +const MAX_DEPTH = 1000; + +interface Star { + x: number; + y: number; + z: number; +} + +interface StarfieldState { + ctx: CanvasRenderingContext2D; + stars: Star[]; + w: number; + h: number; + dpr: number; +} + +function spawnStar(w: number, h: number, randomDepth: boolean): Star { + return { + x: (Math.random() - 0.5) * w * 2, + y: (Math.random() - 0.5) * h * 2, + z: randomDepth ? Math.random() * MAX_DEPTH : MAX_DEPTH, + }; +} + +export default function StarfieldBackground({ settings }: Props) { + const canvasRef = useRef(null); + const stateRef = useRef(null); + + const color = sanitizeColor(settings.color, '#ffffff'); + const starCount = clampNumber(settings.starCount, 50, 800, 200); + const speed = clampNumber(settings.speed, 0.1, 5, 1); + + 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 stars = Array.from({ length: Math.floor(starCount) }, () => spawnStar(w, h, true)); + + stateRef.current = { ctx, stars, 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); + }, [starCount]); + + useAnimationLoop(() => { + const state = stateRef.current; + if (!state) return; + + const { ctx, stars, w, h } = state; + const cx = w / 2; + const cy = h / 2; + const fov = Math.min(w, h); + + ctx.clearRect(0, 0, w, h); + ctx.fillStyle = color; + + for (let i = 0; i < stars.length; i++) { + const s = stars[i]; + s.z -= speed * 4; + + if (s.z <= 1) { + stars[i] = spawnStar(w, h, false); + continue; + } + + const k = fov / s.z; + const sx = cx + s.x * k; + const sy = cy + s.y * k; + + if (sx < 0 || sx > w || sy < 0 || sy > h) { + stars[i] = spawnStar(w, h, false); + continue; + } + + const depth = 1 - s.z / MAX_DEPTH; + const radius = Math.max(0.3, depth * 2.2); + + ctx.globalAlpha = 0.2 + depth * 0.8; + ctx.beginPath(); + ctx.arc(sx, sy, radius, 0, Math.PI * 2); + ctx.fill(); + } + + ctx.globalAlpha = 1; + }, [color, starCount, speed]); + + return ; +} diff --git a/src/components/ui/backgrounds/types.ts b/src/components/ui/backgrounds/types.ts index a696457..503a169 100644 --- a/src/components/ui/backgrounds/types.ts +++ b/src/components/ui/backgrounds/types.ts @@ -16,6 +16,7 @@ export type BackgroundType = | 'ripple' | 'fireflies' | 'snowfall' + | 'starfield' | 'none'; export interface AnimationConfig { diff --git a/src/locales/en.json b/src/locales/en.json index 896d8f6..b632a80 100644 --- a/src/locales/en.json +++ b/src/locales/en.json @@ -1157,6 +1157,8 @@ "firefliesDesc": "Drifting glowing dots with pulsation", "snowfall": "Snowfall", "snowfallDesc": "Falling snow with wind drift", + "starfield": "Starfield", + "starfieldDesc": "Stars flying toward the viewer", "geminiEffect": "Gemini Effect", "geminiEffectDesc": "SVG effect like Google Gemini", "speed": "Speed", diff --git a/src/locales/fa.json b/src/locales/fa.json index 2a2b2a9..003b34b 100644 --- a/src/locales/fa.json +++ b/src/locales/fa.json @@ -1039,6 +1039,8 @@ "firefliesDesc": "نقاط درخشان شناور با تپش", "snowfall": "بارش برف", "snowfallDesc": "برف در حال بارش با وزش باد", + "starfield": "میدان ستارگان", + "starfieldDesc": "پرواز در میان ستاره‌ها", "geminiEffect": "افکت Gemini", "geminiEffectDesc": "افکت SVG مشابه Google Gemini", "speed": "سرعت", diff --git a/src/locales/ru.json b/src/locales/ru.json index cca54c5..064f965 100644 --- a/src/locales/ru.json +++ b/src/locales/ru.json @@ -1187,6 +1187,8 @@ "firefliesDesc": "Дрейфующие светящиеся точки с пульсацией", "snowfall": "Snowfall", "snowfallDesc": "Падающий снег с ветром", + "starfield": "Starfield", + "starfieldDesc": "Полёт сквозь звёздное небо", "geminiEffect": "Gemini Effect", "geminiEffectDesc": "SVG-эффект как на Google Gemini", "speed": "Скорость", diff --git a/src/locales/zh.json b/src/locales/zh.json index 2b9884a..e94944e 100644 --- a/src/locales/zh.json +++ b/src/locales/zh.json @@ -1039,6 +1039,8 @@ "firefliesDesc": "漂浮闪烁的发光点", "snowfall": "降雪", "snowfallDesc": "随风飘落的雪花", + "starfield": "星空", + "starfieldDesc": "向观察者飞来的星星", "geminiEffect": "Gemini效果", "geminiEffectDesc": "类似Google Gemini的SVG效果", "speed": "速度", diff --git a/src/utils/backgroundConfig.ts b/src/utils/backgroundConfig.ts index ccb9a77..c980803 100644 --- a/src/utils/backgroundConfig.ts +++ b/src/utils/backgroundConfig.ts @@ -21,6 +21,7 @@ const VALID_TYPES: ReadonlySet = new Set([ 'ripple', 'fireflies', 'snowfall', + 'starfield', 'none', ]);