mirror of
https://github.com/chillpadclub/bedolaga-cabinet.git
synced 2026-07-28 09:33:46 +00:00
feat(backgrounds): add starfield background with depth projection
This commit is contained in:
@@ -22,6 +22,8 @@ function reduceMobileSettings(settings: Record<string, unknown>): Record<string,
|
|||||||
reduced.particleDensity = Math.max(50, Math.floor(reduced.particleDensity / 4));
|
reduced.particleDensity = Math.max(50, Math.floor(reduced.particleDensity / 4));
|
||||||
if (typeof reduced.density === 'number')
|
if (typeof reduced.density === 'number')
|
||||||
reduced.density = Math.max(10, Math.floor(reduced.density / 2));
|
reduced.density = Math.max(10, Math.floor(reduced.density / 2));
|
||||||
|
if (typeof reduced.starCount === 'number')
|
||||||
|
reduced.starCount = Math.max(50, Math.floor(reduced.starCount / 4));
|
||||||
if (typeof reduced.number === 'number')
|
if (typeof reduced.number === 'number')
|
||||||
reduced.number = Math.max(5, Math.floor(reduced.number / 4));
|
reduced.number = Math.max(5, Math.floor(reduced.number / 4));
|
||||||
if ('interactive' in reduced) reduced.interactive = false;
|
if ('interactive' in reduced) reduced.interactive = false;
|
||||||
|
|||||||
@@ -20,6 +20,7 @@ const backgroundImports: Record<Exclude<BackgroundType, 'none'>, () => Promise<u
|
|||||||
ripple: () => import('./background-ripple'),
|
ripple: () => import('./background-ripple'),
|
||||||
fireflies: () => import('./fireflies'),
|
fireflies: () => import('./fireflies'),
|
||||||
snowfall: () => import('./snowfall'),
|
snowfall: () => import('./snowfall'),
|
||||||
|
starfield: () => import('./starfield'),
|
||||||
};
|
};
|
||||||
|
|
||||||
/** Prefetch the JS chunk for a background type (call early to avoid lazy-load delay) */
|
/** 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')),
|
ripple: lazy(() => import('./background-ripple')),
|
||||||
fireflies: lazy(() => import('./fireflies')),
|
fireflies: lazy(() => import('./fireflies')),
|
||||||
snowfall: lazy(() => import('./snowfall')),
|
snowfall: lazy(() => import('./snowfall')),
|
||||||
|
starfield: lazy(() => import('./starfield')),
|
||||||
};
|
};
|
||||||
|
|
||||||
// Registry of all background definitions with settings for the editor
|
// 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,
|
||||||
|
},
|
||||||
|
],
|
||||||
|
},
|
||||||
];
|
];
|
||||||
|
|||||||
123
src/components/ui/backgrounds/starfield.tsx
Normal file
123
src/components/ui/backgrounds/starfield.tsx
Normal file
@@ -0,0 +1,123 @@
|
|||||||
|
import { useEffect, useRef } from 'react';
|
||||||
|
import { sanitizeColor, clampNumber } from './types';
|
||||||
|
import { useAnimationLoop, getMobileDpr } from '@/hooks/useAnimationLoop';
|
||||||
|
|
||||||
|
interface Props {
|
||||||
|
settings: Record<string, unknown>;
|
||||||
|
}
|
||||||
|
|
||||||
|
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<HTMLCanvasElement>(null);
|
||||||
|
const stateRef = useRef<StarfieldState | null>(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 <canvas ref={canvasRef} className="absolute inset-0 h-full w-full" />;
|
||||||
|
}
|
||||||
@@ -16,6 +16,7 @@ export type BackgroundType =
|
|||||||
| 'ripple'
|
| 'ripple'
|
||||||
| 'fireflies'
|
| 'fireflies'
|
||||||
| 'snowfall'
|
| 'snowfall'
|
||||||
|
| 'starfield'
|
||||||
| 'none';
|
| 'none';
|
||||||
|
|
||||||
export interface AnimationConfig {
|
export interface AnimationConfig {
|
||||||
|
|||||||
@@ -1157,6 +1157,8 @@
|
|||||||
"firefliesDesc": "Drifting glowing dots with pulsation",
|
"firefliesDesc": "Drifting glowing dots with pulsation",
|
||||||
"snowfall": "Snowfall",
|
"snowfall": "Snowfall",
|
||||||
"snowfallDesc": "Falling snow with wind drift",
|
"snowfallDesc": "Falling snow with wind drift",
|
||||||
|
"starfield": "Starfield",
|
||||||
|
"starfieldDesc": "Stars flying toward the viewer",
|
||||||
"geminiEffect": "Gemini Effect",
|
"geminiEffect": "Gemini Effect",
|
||||||
"geminiEffectDesc": "SVG effect like Google Gemini",
|
"geminiEffectDesc": "SVG effect like Google Gemini",
|
||||||
"speed": "Speed",
|
"speed": "Speed",
|
||||||
|
|||||||
@@ -1039,6 +1039,8 @@
|
|||||||
"firefliesDesc": "نقاط درخشان شناور با تپش",
|
"firefliesDesc": "نقاط درخشان شناور با تپش",
|
||||||
"snowfall": "بارش برف",
|
"snowfall": "بارش برف",
|
||||||
"snowfallDesc": "برف در حال بارش با وزش باد",
|
"snowfallDesc": "برف در حال بارش با وزش باد",
|
||||||
|
"starfield": "میدان ستارگان",
|
||||||
|
"starfieldDesc": "پرواز در میان ستارهها",
|
||||||
"geminiEffect": "افکت Gemini",
|
"geminiEffect": "افکت Gemini",
|
||||||
"geminiEffectDesc": "افکت SVG مشابه Google Gemini",
|
"geminiEffectDesc": "افکت SVG مشابه Google Gemini",
|
||||||
"speed": "سرعت",
|
"speed": "سرعت",
|
||||||
|
|||||||
@@ -1187,6 +1187,8 @@
|
|||||||
"firefliesDesc": "Дрейфующие светящиеся точки с пульсацией",
|
"firefliesDesc": "Дрейфующие светящиеся точки с пульсацией",
|
||||||
"snowfall": "Snowfall",
|
"snowfall": "Snowfall",
|
||||||
"snowfallDesc": "Падающий снег с ветром",
|
"snowfallDesc": "Падающий снег с ветром",
|
||||||
|
"starfield": "Starfield",
|
||||||
|
"starfieldDesc": "Полёт сквозь звёздное небо",
|
||||||
"geminiEffect": "Gemini Effect",
|
"geminiEffect": "Gemini Effect",
|
||||||
"geminiEffectDesc": "SVG-эффект как на Google Gemini",
|
"geminiEffectDesc": "SVG-эффект как на Google Gemini",
|
||||||
"speed": "Скорость",
|
"speed": "Скорость",
|
||||||
|
|||||||
@@ -1039,6 +1039,8 @@
|
|||||||
"firefliesDesc": "漂浮闪烁的发光点",
|
"firefliesDesc": "漂浮闪烁的发光点",
|
||||||
"snowfall": "降雪",
|
"snowfall": "降雪",
|
||||||
"snowfallDesc": "随风飘落的雪花",
|
"snowfallDesc": "随风飘落的雪花",
|
||||||
|
"starfield": "星空",
|
||||||
|
"starfieldDesc": "向观察者飞来的星星",
|
||||||
"geminiEffect": "Gemini效果",
|
"geminiEffect": "Gemini效果",
|
||||||
"geminiEffectDesc": "类似Google Gemini的SVG效果",
|
"geminiEffectDesc": "类似Google Gemini的SVG效果",
|
||||||
"speed": "速度",
|
"speed": "速度",
|
||||||
|
|||||||
@@ -21,6 +21,7 @@ const VALID_TYPES: ReadonlySet<string> = new Set<BackgroundType>([
|
|||||||
'ripple',
|
'ripple',
|
||||||
'fireflies',
|
'fireflies',
|
||||||
'snowfall',
|
'snowfall',
|
||||||
|
'starfield',
|
||||||
'none',
|
'none',
|
||||||
]);
|
]);
|
||||||
|
|
||||||
|
|||||||
Reference in New Issue
Block a user