diff --git a/backend/banners/nebula.json b/backend/banners/nebula.json index 73462e1..1b5dc56 100644 --- a/backend/banners/nebula.json +++ b/backend/banners/nebula.json @@ -3,13 +3,13 @@ "slug": "nebula", "description": "An interactive space-themed nebular cloud with fluid movement.", "settings": { - "particleCount": 350, - "speed": 0.003, - "opacity": 0.75, - "smearRadius": 120, - "turbulence": 0.8, - "cohesion": 0.02, - "returnSpeed": 0.012 + "particleCount": 450, + "speed": 0.002, + "opacity": 0.65, + "smearRadius": 140, + "turbulence": 0.5, + "returnSpeed": 0.005, + "starDensity": 180 }, "schema": [ { @@ -18,7 +18,15 @@ "type": "number", "min": 100, "max": 1000, - "description": "Number of particles per color cluster." + "description": "Total number of gas particles. Higher values make the nebula look thicker." + }, + { + "key": "starDensity", + "label": "Star Count", + "type": "number", + "min": 0, + "max": 500, + "description": "Number of background stars in the night sky." }, { "key": "smearRadius", @@ -27,25 +35,16 @@ "min": 50, "max": 300, "step": 10, - "description": "The width of the path you carve when moving the mouse." - }, - { - "key": "turbulence", - "label": "Click Turbulence", - "type": "slider", - "min": 0.1, - "max": 2.0, - "step": 0.1, - "description": "Strength of chaotic motion when clicking the cloud." + "description": "Width of the path carved through the gas." }, { "key": "returnSpeed", "label": "Healing Speed", "type": "slider", "min": 0.001, - "max": 0.05, + "max": 0.02, "step": 0.001, - "description": "How fast the carved path closes and the cloud reforms." + "description": "How fast the gas drifts back. Low values feel like smoke, high values feel like jelly." }, { "key": "opacity", diff --git a/frontend/src/components/NebulaBanner.tsx b/frontend/src/components/NebulaBanner.tsx index cc69ccc..caab744 100644 --- a/frontend/src/components/NebulaBanner.tsx +++ b/frontend/src/components/NebulaBanner.tsx @@ -1,4 +1,4 @@ -import React, { useRef, useEffect } from 'react'; +import React, { useRef, useEffect, useMemo } from 'react'; import { alpha, useTheme } from '@mui/material'; interface NebulaBannerProps { @@ -12,8 +12,8 @@ interface NebulaBannerProps { opacity?: number; smearRadius?: number; turbulence?: number; - cohesion?: number; returnSpeed?: number; + starDensity?: number; }; } @@ -21,13 +21,13 @@ const NebulaBanner: React.FC = ({ username, palette, ratingsC const canvasRef = useRef(null); const theme = useTheme(); - const particleCount = settings?.particleCount ?? 300; - const speed = settings?.speed ?? 0.002; - const globalOpacity = settings?.opacity ?? 0.7; - const smearRadius = settings?.smearRadius ?? 100; - const turbulenceStrength = settings?.turbulence ?? 0.5; - const cohesion = settings?.cohesion ?? 0.02; - const returnSpeed = settings?.returnSpeed ?? 0.01; + const particleCount = settings?.particleCount ?? 400; + const speed = settings?.speed ?? 0.0015; + const globalOpacity = settings?.opacity ?? 0.6; + const smearRadius = settings?.smearRadius ?? 120; + const turbulenceStrength = settings?.turbulence ?? 0.4; + const returnSpeed = settings?.returnSpeed ?? 0.004; // Much lower for smoke-like feel + const starDensity = settings?.starDensity ?? 150; const getSeed = (str: string) => { let hash = 0; @@ -37,6 +37,8 @@ const NebulaBanner: React.FC = ({ username, palette, ratingsC return hash; }; + const seed = useMemo(() => getSeed(username), [username]); + useEffect(() => { const canvas = canvasRef.current; if (!canvas) return; @@ -45,7 +47,6 @@ const NebulaBanner: React.FC = ({ username, palette, ratingsC if (!ctx) return; let animationFrameId: number; - const seed = getSeed(username); const colors = palette.length >= 2 ? palette : [theme.palette.primary.main, theme.palette.secondary.main, theme.palette.info.main]; interface Particle { @@ -59,28 +60,50 @@ const NebulaBanner: React.FC = ({ username, palette, ratingsC color: string; alpha: number; targetAlpha: number; - group: number; + phase: number; + } + + interface Star { + x: number; + y: number; + size: number; + opacity: number; } let particles: Particle[] = []; + let stars: Star[] = []; const pseudoRandom = (offset: number) => { const x = Math.sin(seed + offset) * 10000; return x - Math.floor(x); }; - const initParticles = (width: number, height: number) => { + const initScene = (width: number, height: number) => { + // Init Stars + const newStars = []; + for (let i = 0; i < starDensity; i++) { + newStars.push({ + x: pseudoRandom(i + 5000) * width, + y: pseudoRandom(i + 6000) * height, + size: pseudoRandom(i + 7000) * 1.5, + opacity: 0.1 + pseudoRandom(i + 8000) * 0.5 + }); + } + + // Init Gas Clusters const newParticles = []; - const groupCenters = colors.map((_, i) => ({ - x: (pseudoRandom(i * 10) * 0.6 + 0.2) * width, - y: (pseudoRandom(i * 20 + 5) * 0.6 + 0.2) * height + const clusterCount = colors.length * 3; // More clusters + const groupCenters = Array.from({ length: clusterCount }).map((_, i) => ({ + x: (pseudoRandom(i * 15) * 0.8 + 0.1) * width, + y: (pseudoRandom(i * 25 + 7) * 0.8 + 0.1) * height })); for (let i = 0; i < particleCount; i++) { - const groupIdx = i % colors.length; - const center = groupCenters[groupIdx]; + const clusterIdx = i % clusterCount; + const colorIdx = clusterIdx % colors.length; + const center = groupCenters[clusterIdx]; const angle = pseudoRandom(i + 100) * Math.PI * 2; - const dist = pseudoRandom(i + 200) * (width * 0.15); + const dist = pseudoRandom(i + 200) * (width * 0.12); const bx = center.x + Math.cos(angle) * dist; const by = center.y + Math.sin(angle) * dist; @@ -88,14 +111,14 @@ const NebulaBanner: React.FC = ({ username, palette, ratingsC x: bx, y: by, baseX: bx, baseY: by, vx: 0, vy: 0, - size: 20 + pseudoRandom(i + 300) * 40, - color: colors[groupIdx], + size: 40 + pseudoRandom(i + 300) * 80, // Larger, softer blobs + color: colors[colorIdx], alpha: 0, - targetAlpha: 0.1 + pseudoRandom(i + 400) * 0.2, - group: groupIdx + targetAlpha: 0.05 + pseudoRandom(i + 400) * 0.15, + phase: pseudoRandom(i + 500) * Math.PI * 2 }); } - return newParticles; + return { stars: newStars, particles: newParticles }; }; const resize = () => { @@ -108,7 +131,9 @@ const NebulaBanner: React.FC = ({ username, palette, ratingsC ctx.scale(dpr, dpr); canvas.style.width = `${rect.width}px`; canvas.style.height = `${rect.height}px`; - particles = initParticles(rect.width, rect.height); + const scene = initScene(rect.width, rect.height); + stars = scene.stars; + particles = scene.particles; } }; @@ -119,8 +144,7 @@ const NebulaBanner: React.FC = ({ username, palette, ratingsC let mouse = { x: -1000, y: -1000, px: -1000, py: -1000, down: false }; const handleMouseMove = (e: MouseEvent) => { const rect = canvas.getBoundingClientRect(); - mouse.px = mouse.x; - mouse.py = mouse.y; + mouse.px = mouse.x; mouse.py = mouse.y; mouse.x = e.clientX - rect.left; mouse.y = e.clientY - rect.top; }; @@ -145,71 +169,75 @@ const NebulaBanner: React.FC = ({ username, palette, ratingsC } ctx.clearRect(0, 0, width, height); - ctx.fillStyle = '#050508'; + + // 1. Deep Space Night Sky Gradient + const skyGrad = ctx.createRadialGradient(width/2, height/2, 0, width/2, height/2, width); + skyGrad.addColorStop(0, '#0a0a1a'); // Dark Midnight Blue + skyGrad.addColorStop(0.5, '#05050d'); // Near Black + skyGrad.addColorStop(1, '#020205'); // Deep Space + ctx.fillStyle = skyGrad; ctx.fillRect(0, 0, width, height); + // 2. Draw Distant Stars + ctx.fillStyle = '#fff'; + stars.forEach(s => { + ctx.globalAlpha = s.opacity * (0.7 + Math.sin(time * 2 + s.x) * 0.3); + ctx.beginPath(); + ctx.arc(s.x, s.y, s.size, 0, Math.PI * 2); + ctx.fill(); + }); + ctx.globalAlpha = 1.0; + time += speed; + // 3. Draw Gas Clouds particles.forEach((p) => { - // Restoration Force - Pulls back to group center + // Restoration Force (Airy return) const dxBase = p.baseX - p.x; const dyBase = p.baseY - p.y; p.vx += dxBase * returnSpeed; p.vy += dyBase * returnSpeed; - // Mouse Smear / Path Carving + // Mouse Interaction (Airy Smear) const dx = mouse.x - p.x; const dy = mouse.y - p.y; const dist = Math.sqrt(dx * dx + dy * dy); if (dist < smearRadius) { const force = (smearRadius - dist) / smearRadius; - if (mouse.down) { - // Turbulence on click: Swirl + Random jitter + // High turbulence on click const angle = Math.atan2(dy, dx); - p.vx += Math.cos(angle + Math.PI/2) * turbulenceStrength; - p.vy += Math.sin(angle + Math.PI/2) * turbulenceStrength; - p.vx += (pseudoRandom(p.x + time) - 0.5) * turbulenceStrength; - p.vy += (pseudoRandom(p.y + time) - 0.5) * turbulenceStrength; + p.vx += Math.cos(angle + Math.PI/2) * turbulenceStrength * 2; + p.vy += Math.sin(angle + Math.PI/2) * turbulenceStrength * 2; } else { - // Smear: Push away from mouse movement direction + // Smoke-like smear const mdx = mouse.x - mouse.px; const mdy = mouse.y - mouse.py; - p.vx += mdx * force * 0.2; - p.vy += mdy * force * 0.2; - // Extra push away to "carve" - p.vx -= (dx / dist) * force * 2; - p.vy -= (dy / dist) * force * 2; + p.vx += mdx * force * 0.15; + p.vy += mdy * force * 0.15; + p.vx -= (dx / dist) * force * 0.8; + p.vy -= (dy / dist) * force * 0.8; } } - // Internal drift - p.vx += Math.sin(time + p.x * 0.01) * 0.05; - p.vy += Math.cos(time + p.y * 0.01) * 0.05; - - // Friction - p.vx *= 0.92; - p.vy *= 0.92; + // Smooth drift + p.vx += Math.sin(time + p.phase) * 0.03; + p.vy += Math.cos(time * 0.8 + p.phase) * 0.03; + // High friction for gas feel + p.vx *= 0.95; + p.vy *= 0.95; p.x += p.vx; p.y += p.vy; - // Screen Wrap - const margin = p.size; - if (p.x < -margin) p.x = width + margin; - if (p.x > width + margin) p.x = -margin; - if (p.y < -margin) p.y = height + margin; - if (p.y > height + margin) p.y = -margin; + if (p.alpha < p.targetAlpha) p.alpha += 0.001; - if (p.alpha < p.targetAlpha) p.alpha += 0.002; - - // Render + // Render as very soft additive blob const grad = ctx.createRadialGradient(p.x, p.y, 0, p.x, p.y, p.size); - const colorAlpha = p.alpha * globalOpacity * (0.7 + Math.sin(time * 2 + p.x) * 0.3); - + const colorAlpha = p.alpha * globalOpacity; grad.addColorStop(0, alpha(p.color, colorAlpha)); - grad.addColorStop(0.5, alpha(p.color, colorAlpha * 0.3)); + grad.addColorStop(0.6, alpha(p.color, colorAlpha * 0.2)); grad.addColorStop(1, alpha(p.color, 0)); ctx.fillStyle = grad; @@ -234,7 +262,7 @@ const NebulaBanner: React.FC = ({ username, palette, ratingsC } cancelAnimationFrame(animationFrameId); }; - }, [username, palette, ratingsCount, followersCount, theme, particleCount, speed, globalOpacity, smearRadius, turbulenceStrength, cohesion, returnSpeed]); + }, [seed, palette, ratingsCount, followersCount, theme, particleCount, speed, globalOpacity, smearRadius, turbulenceStrength, returnSpeed, starDensity]); return (