diff --git a/backend/banners/nebula.json b/backend/banners/nebula.json index ccfc868..73462e1 100644 --- a/backend/banners/nebula.json +++ b/backend/banners/nebula.json @@ -3,66 +3,57 @@ "slug": "nebula", "description": "An interactive space-themed nebular cloud with fluid movement.", "settings": { - "particleCount": 300, - "speed": 0.005, - "opacity": 0.8, - "attractionForce": 0.25, - "cloudSize": 14, - "returnSpeed": 0.02 + "particleCount": 350, + "speed": 0.003, + "opacity": 0.75, + "smearRadius": 120, + "turbulence": 0.8, + "cohesion": 0.02, + "returnSpeed": 0.012 }, "schema": [ { "key": "particleCount", - "label": "Particle Count", + "label": "Gas Density", "type": "number", - "min": 10, + "min": 100, "max": 1000, - "description": "Number of gas blobs in the nebula. Higher values increase density but may impact performance." + "description": "Number of particles per color cluster." }, { - "key": "speed", - "label": "Drift Speed", + "key": "smearRadius", + "label": "Smear Brush Size", + "type": "slider", + "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." + }, + { + "key": "returnSpeed", + "label": "Healing Speed", "type": "slider", "min": 0.001, "max": 0.05, "step": 0.001, - "description": "How fast the nebula drifts and pulses on its own." + "description": "How fast the carved path closes and the cloud reforms." }, { "key": "opacity", - "label": "Overall Opacity", + "label": "Cloud Opacity", "type": "slider", "min": 0.1, "max": 1.0, - "step": 0.05, - "description": "Transparency of the nebula clouds." - }, - { - "key": "attractionForce", - "label": "Click Attraction", - "type": "slider", - "min": 0.05, - "max": 1.0, - "step": 0.05, - "description": "How strongly the gas condenses when you click and hold." - }, - { - "key": "cloudSize", - "label": "Cloud Size", - "type": "slider", - "min": 5, - "max": 50, - "step": 1, - "description": "The radius of individual gas blobs. Larger values create a thicker, smudgier cloud." - }, - { - "key": "returnSpeed", - "label": "Flow Back Speed", - "type": "slider", - "min": 0.001, - "max": 0.1, - "step": 0.001, - "description": "How quickly the cloud returns to its original shape after being dispersed." + "step": 0.05 } ] } diff --git a/frontend/src/components/NebulaBanner.tsx b/frontend/src/components/NebulaBanner.tsx index f0ed804..cc69ccc 100644 --- a/frontend/src/components/NebulaBanner.tsx +++ b/frontend/src/components/NebulaBanner.tsx @@ -10,8 +10,9 @@ interface NebulaBannerProps { particleCount?: number; speed?: number; opacity?: number; - attractionForce?: number; - cloudSize?: number; + smearRadius?: number; + turbulence?: number; + cohesion?: number; returnSpeed?: number; }; } @@ -20,13 +21,13 @@ const NebulaBanner: React.FC = ({ username, palette, ratingsC const canvasRef = useRef(null); const theme = useTheme(); - // Cloud settings for high density - const particleCount = settings?.particleCount ?? 250; - const speed = settings?.speed ?? 0.003; - const globalOpacity = settings?.opacity ?? 0.85; - const attractionForce = settings?.attractionForce ?? 0.25; - const cloudSizeMultiplier = settings?.cloudSize ?? 12; - const returnSpeed = settings?.returnSpeed ?? 0.015; // Snappier return + 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 getSeed = (str: string) => { let hash = 0; @@ -58,13 +59,10 @@ const NebulaBanner: React.FC = ({ username, palette, ratingsC color: string; alpha: number; targetAlpha: number; - phase: number; - pulse: number; - pulseSpeed: number; + group: number; } let particles: Particle[] = []; - let bgClouds: Particle[] = []; const pseudoRandom = (offset: number) => { const x = Math.sin(seed + offset) * 10000; @@ -72,42 +70,32 @@ const NebulaBanner: React.FC = ({ username, palette, ratingsC }; const initParticles = (width: number, height: number) => { - const p = []; - const bg = []; - - // Foreground Active Gas - for (let i = 0; i < particleCount; i++) { - const bx = pseudoRandom(i) * width; - const by = pseudoRandom(i + 500) * height; - p.push({ - x: bx, y: by, - baseX: bx, baseY: by, - vx: 0, vy: 0, - size: (5 + pseudoRandom(i + 1000) * 15) * cloudSizeMultiplier, - color: colors[i % colors.length], - alpha: 0, - targetAlpha: 0.05 + pseudoRandom(i + 1500) * 0.15, - phase: pseudoRandom(i + 2000) * Math.PI * 2, - pulse: pseudoRandom(i + 2500), - pulseSpeed: 0.005 + pseudoRandom(i + 3000) * 0.01 - }); - } + 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 + })); - // Static background cloud layers for density - for (let i = 0; i < 15; i++) { - const bx = pseudoRandom(i + 4000) * width; - const by = pseudoRandom(i + 4500) * height; - bg.push({ + for (let i = 0; i < particleCount; i++) { + const groupIdx = i % colors.length; + const center = groupCenters[groupIdx]; + const angle = pseudoRandom(i + 100) * Math.PI * 2; + const dist = pseudoRandom(i + 200) * (width * 0.15); + const bx = center.x + Math.cos(angle) * dist; + const by = center.y + Math.sin(angle) * dist; + + newParticles.push({ x: bx, y: by, baseX: bx, baseY: by, vx: 0, vy: 0, - size: (40 + pseudoRandom(i + 5000) * 60) * (cloudSizeMultiplier / 4), - color: colors[i % colors.length], - alpha: 0.02 + pseudoRandom(i + 5500) * 0.05, - targetAlpha: 0, phase: 0, pulse: 0, pulseSpeed: 0 + size: 20 + pseudoRandom(i + 300) * 40, + color: colors[groupIdx], + alpha: 0, + targetAlpha: 0.1 + pseudoRandom(i + 400) * 0.2, + group: groupIdx }); } - return { p, bg }; + return newParticles; }; const resize = () => { @@ -120,9 +108,7 @@ const NebulaBanner: React.FC = ({ username, palette, ratingsC ctx.scale(dpr, dpr); canvas.style.width = `${rect.width}px`; canvas.style.height = `${rect.height}px`; - const { p, bg } = initParticles(rect.width, rect.height); - particles = p; - bgClouds = bg; + particles = initParticles(rect.width, rect.height); } }; @@ -130,9 +116,11 @@ const NebulaBanner: React.FC = ({ username, palette, ratingsC if (canvas.parentElement) resizeObserver.observe(canvas.parentElement); resize(); - let mouse = { x: -1000, y: -1000, down: false }; + 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.x = e.clientX - rect.left; mouse.y = e.clientY - rect.top; }; @@ -157,57 +145,53 @@ const NebulaBanner: React.FC = ({ username, palette, ratingsC } ctx.clearRect(0, 0, width, height); - - // Deep Space Background ctx.fillStyle = '#050508'; ctx.fillRect(0, 0, width, height); time += speed; - // Draw Background atmospheric clouds first - bgClouds.forEach(c => { - const g = ctx.createRadialGradient(c.x, c.y, 0, c.x, c.y, c.size); - g.addColorStop(0, alpha(c.color, c.alpha)); - g.addColorStop(1, alpha(c.color, 0)); - ctx.fillStyle = g; - ctx.fillRect(c.x - c.size, c.y - c.size, c.size * 2, c.size * 2); - }); - - // Process and Draw Active Gas Particles particles.forEach((p) => { - // Restoration Force (Elasticity) + // Restoration Force - Pulls back to group center const dxBase = p.baseX - p.x; const dyBase = p.baseY - p.y; p.vx += dxBase * returnSpeed; p.vy += dyBase * returnSpeed; - // Organic Drift - const driftX = Math.sin(time + p.phase) * 0.2; - const driftY = Math.cos(time * 0.7 + p.phase) * 0.2; - p.vx += driftX; - p.vy += driftY; - - // Mouse Interaction (Fluid feel) + // Mouse Smear / Path Carving const dx = mouse.x - p.x; const dy = mouse.y - p.y; const dist = Math.sqrt(dx * dx + dy * dy); - if (dist < 300) { + if (dist < smearRadius) { + const force = (smearRadius - dist) / smearRadius; + if (mouse.down) { - // Condense / Gravity Pull - const force = (300 - dist) / 300; - p.vx += (dx / dist) * attractionForce * force; - p.vy += (dy / dist) * attractionForce * force; + // Turbulence on click: Swirl + Random jitter + 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; } else { - // Swirl / Turbulence - const swirl = (300 - dist) / 300; - p.vx += (dy / dist) * 0.15 * swirl; - p.vy -= (dx / dist) * 0.15 * swirl; + // Smear: Push away from mouse movement direction + 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 *= 0.94; - p.vy *= 0.94; + // 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; + p.x += p.vx; p.y += p.vy; @@ -218,16 +202,14 @@ const NebulaBanner: React.FC = ({ username, palette, ratingsC if (p.y < -margin) p.y = height + margin; if (p.y > height + margin) p.y = -margin; - // Pulsing Alpha - p.pulse += p.pulseSpeed; - const currentAlpha = p.targetAlpha * (0.5 + Math.sin(p.pulse) * 0.5); - - // Render as additive cloud blob + if (p.alpha < p.targetAlpha) p.alpha += 0.002; + + // Render const grad = ctx.createRadialGradient(p.x, p.y, 0, p.x, p.y, p.size); - const colorAlpha = currentAlpha * globalOpacity; + const colorAlpha = p.alpha * globalOpacity * (0.7 + Math.sin(time * 2 + p.x) * 0.3); grad.addColorStop(0, alpha(p.color, colorAlpha)); - grad.addColorStop(0.4, alpha(p.color, colorAlpha * 0.4)); + grad.addColorStop(0.5, alpha(p.color, colorAlpha * 0.3)); grad.addColorStop(1, alpha(p.color, 0)); ctx.fillStyle = grad; @@ -252,7 +234,7 @@ const NebulaBanner: React.FC = ({ username, palette, ratingsC } cancelAnimationFrame(animationFrameId); }; - }, [username, palette, ratingsCount, followersCount, theme, particleCount, speed, globalOpacity, attractionForce, cloudSizeMultiplier, returnSpeed]); + }, [username, palette, ratingsCount, followersCount, theme, particleCount, speed, globalOpacity, smearRadius, turbulenceStrength, cohesion, returnSpeed]); return (