From f447fb5993304ec807bebe1fc25d7c8b2b224aed Mon Sep 17 00:00:00 2001 From: waterWang Date: Wed, 5 Aug 2026 05:32:09 +0800 Subject: [PATCH] feat: add animated forge/factory hero background with canvas particle system (Closes #850) - Canvas-based particle system with ember, spark, and molten drip effects - Bottom forge glow with ambient pulsing - Mouse interaction (particles repel from cursor) - Responsive to all screen sizes - 60fps performance with requestAnimationFrame [fj4WqyCCw3C5ShR1RfB7MoBPTpkRrBFYP1uT35g3MvT] --- .../home/AnimatedHeroBackground.tsx | 298 ++++++++++++++++++ frontend/src/components/home/HeroSection.tsx | 10 +- frontend/src/index.css | 10 + frontend/tailwind.config.js | 10 + 4 files changed, 325 insertions(+), 3 deletions(-) create mode 100644 frontend/src/components/home/AnimatedHeroBackground.tsx diff --git a/frontend/src/components/home/AnimatedHeroBackground.tsx b/frontend/src/components/home/AnimatedHeroBackground.tsx new file mode 100644 index 000000000..5cc66a115 --- /dev/null +++ b/frontend/src/components/home/AnimatedHeroBackground.tsx @@ -0,0 +1,298 @@ +import React, { useRef, useEffect, useCallback } from 'react'; + +// ─── Types ─────────────────────────────────────────────────────────────────── +interface Particle { + x: number; + y: number; + vx: number; + vy: number; + life: number; + maxLife: number; + size: number; + alpha: number; + type: 'ember' | 'spark' | 'molten'; + color: string; +} + +interface MoltenStream { + x: number; + y: number; + width: number; + speed: number; + alpha: number; + hue: number; +} + +// ─── Constants ─────────────────────────────────────────────────────────────── +const EMBER_COLORS = ['#FF6B35', '#FF8C42', '#FFB347', '#FFD700', '#E040FB']; +const SPARK_COLORS = ['#00E676', '#7C3AED', '#E040FB', '#40C4FF']; +const MOLTEN_COLORS = ['#FF4500', '#FF6347', '#FF7F50', '#FF8C00']; + +const MAX_PARTICLES = 120; +const STREAMS_COUNT = 4; + +// ─── Component ─────────────────────────────────────────────────────────────── +const AnimatedHeroBackground: React.FC = () => { + const canvasRef = useRef(null); + const particlesRef = useRef([]); + const streamsRef = useRef([]); + const rafRef = useRef(0); + const mouseRef = useRef({ x: 0, y: 0 }); + + // ── Initialize particles ───────────────────────────────────────────────── + const spawnParticle = useCallback((w: number, h: number): Particle => { + const type = Math.random() < 0.5 + ? 'ember' + : Math.random() < 0.6 + ? 'spark' + : 'molten'; + + const colors = type === 'ember' + ? EMBER_COLORS + : type === 'spark' + ? SPARK_COLORS + : MOLTEN_COLORS; + + const angle = type === 'molten' + ? (Math.random() - 0.5) * 0.6 // mostly downward + : type === 'spark' + ? -Math.random() * Math.PI - Math.PI * 0.25 // upward random + : -Math.random() * Math.PI * 0.8 - Math.PI * 0.1; + + const speed = type === 'spark' + ? 1.5 + Math.random() * 2.5 + : type === 'molten' + ? 0.3 + Math.random() * 0.6 + : 0.4 + Math.random() * 1.2; + + return { + x: Math.random() * w, + y: h + Math.random() * 40, + vx: Math.cos(angle) * speed, + vy: Math.sin(angle) * speed, + life: 0, + maxLife: type === 'spark' + ? 60 + Math.random() * 80 + : type === 'molten' + ? 120 + Math.random() * 160 + : 80 + Math.random() * 120, + size: type === 'spark' + ? 1.5 + Math.random() * 1.5 + : type === 'molten' + ? 3 + Math.random() * 4 + : 2 + Math.random() * 3, + alpha: 0, + type, + color: colors[Math.floor(Math.random() * colors.length)], + }; + }, []); + + // ── Initialize molten streams ──────────────────────────────────────────── + const initStreams = useCallback((w: number, h: number) => { + streamsRef.current = Array.from({ length: STREAMS_COUNT }, (_, i) => ({ + x: (w / (STREAMS_COUNT + 1)) * (i + 1) + (Math.random() - 0.5) * 80, + y: 0, + width: 4 + Math.random() * 8, + speed: 0.15 + Math.random() * 0.25, + alpha: 0.06 + Math.random() * 0.08, + hue: 15 + Math.random() * 20, + })); + }, []); + + // ── Draw a single particle ─────────────────────────────────────────────── + const drawParticle = (ctx: CanvasRenderingContext2D, p: Particle) => { + const progress = p.life / p.maxLife; + const fadeIn = Math.min(progress * 3, 1); // fast fade-in + const fadeOut = progress > 0.7 ? 1 - (progress - 0.7) / 0.3 : 1; + p.alpha = fadeIn * fadeOut * 0.8; + + if (p.alpha < 0.01) return; + + ctx.save(); + ctx.globalAlpha = p.alpha; + + // Glow effect + if (p.size > 2) { + ctx.shadowBlur = p.size * 6; + ctx.shadowColor = p.color; + } + + if (p.type === 'spark') { + // Spark = bright thin streak + ctx.strokeStyle = p.color; + ctx.lineWidth = p.size * 0.5; + ctx.beginPath(); + ctx.moveTo(p.x, p.y); + ctx.lineTo(p.x - p.vx * 4, p.y - p.vy * 4); + ctx.stroke(); + } else { + // Ember / Molten = soft circle + const gradient = ctx.createRadialGradient(p.x, p.y, 0, p.x, p.y, p.size * 2); + gradient.addColorStop(0, '#FFFFFF'); + gradient.addColorStop(0.3, p.color); + gradient.addColorStop(1, 'transparent'); + ctx.fillStyle = gradient; + ctx.beginPath(); + ctx.arc(p.x, p.y, p.size * 2, 0, Math.PI * 2); + ctx.fill(); + } + + ctx.restore(); + }; + + // ── Draw molten streams ────────────────────────────────────────────────── + const drawStreams = (ctx: CanvasRenderingContext2D, w: number, h: number, time: number) => { + for (const stream of streamsRef.current) { + const yOffset = (time * stream.speed * 60) % (h * 1.5); + + ctx.save(); + ctx.globalAlpha = stream.alpha; + ctx.shadowBlur = 20; + ctx.shadowColor = `hsl(${stream.hue}, 100%, 50%)`; + + // Drip segments + for (let i = 0; i < 5; i++) { + const segY = (yOffset + i * (h * 0.2)) % (h * 1.5); + const segAlpha = 1 - (i / 5); + const segWidth = stream.width * (1 - i * 0.12); + + ctx.fillStyle = `hsla(${stream.hue}, 100%, ${60 - i * 8}%, ${segAlpha * 0.5})`; + ctx.beginPath(); + ctx.ellipse(stream.x, segY, segWidth, segWidth * 2.5, 0, 0, Math.PI * 2); + ctx.fill(); + } + + ctx.restore(); + } + }; + + // ── Main animation loop ────────────────────────────────────────────────── + const animate = useCallback(() => { + const canvas = canvasRef.current; + if (!canvas) return; + const ctx = canvas.getContext('2d'); + if (!ctx) return; + + const w = canvas.width; + const h = canvas.height; + const time = Date.now() / 1000; + + // Clear with slight trail effect + ctx.fillStyle = 'rgba(5, 5, 5, 0.15)'; + ctx.fillRect(0, 0, w, h); + + // ── Update & draw molten streams ───────────────────────────────────── + drawStreams(ctx, w, h, time); + + // ── Update particles ────────────────────────────────────────────────── + const particles = particlesRef.current; + + for (let i = particles.length - 1; i >= 0; i--) { + const p = particles[i]; + p.life++; + + if (p.life >= p.maxLife) { + particles.splice(i, 1); + continue; + } + + // Apply physics + if (p.type === 'molten') { + p.vy += 0.02; // gravity + p.vx += (Math.random() - 0.5) * 0.1; // wobble + } else if (p.type === 'ember') { + p.vx += (Math.random() - 0.5) * 0.05; + p.vy -= 0.01; // slight upward drift + } + + // Mouse interaction + const dx = p.x - mouseRef.current.x; + const dy = p.y - mouseRef.current.y; + const dist = Math.sqrt(dx * dx + dy * dy); + if (dist < 120 && dist > 0) { + p.vx += (dx / dist) * 0.3; + p.vy += (dy / dist) * 0.3; + } + + p.x += p.vx; + p.y += p.vy; + + // Wrap around + if (p.x < -20) p.x = w + 20; + if (p.x > w + 20) p.x = -20; + if (p.y < -20) p.y = h + 20; + + drawParticle(ctx, p); + } + + // ── Spawn new particles ──────────────────────────────────────────────── + const spawnRate = Math.min(3, Math.max(0.5, 3 - particles.length / 40)); + if (particles.length < MAX_PARTICLES && Math.random() < spawnRate * 0.05) { + particles.push(spawnParticle(w, h)); + } + + // ── Bottom forge glow ────────────────────────────────────────────────── + const forgeGrad = ctx.createRadialGradient(w / 2, h + 60, 0, w / 2, h + 60, h * 0.6); + forgeGrad.addColorStop(0, 'rgba(255, 107, 53, 0.08)'); + forgeGrad.addColorStop(0.4, 'rgba(124, 58, 237, 0.04)'); + forgeGrad.addColorStop(1, 'transparent'); + ctx.fillStyle = forgeGrad; + ctx.fillRect(0, 0, w, h); + + // ── Ambient glow pulse ───────────────────────────────────────────────── + const pulse = Math.sin(time * 0.5) * 0.3 + 0.7; + const ambientGrad = ctx.createRadialGradient(w / 2, h * 0.3, 0, w / 2, h * 0.3, h * 0.5); + ambientGrad.addColorStop(0, `rgba(124, 58, 237, ${0.03 * pulse})`); + ambientGrad.addColorStop(1, 'transparent'); + ctx.fillStyle = ambientGrad; + ctx.fillRect(0, 0, w, h); + + rafRef.current = requestAnimationFrame(animate); + }, [spawnParticle]); + + // ── Setup & teardown ──────────────────────────────────────────────────── + useEffect(() => { + const canvas = canvasRef.current; + if (!canvas) return; + + const resize = () => { + const hero = canvas.parentElement; + if (!hero) return; + canvas.width = hero.clientWidth; + canvas.height = hero.clientHeight; + const w = canvas.width; + const h = canvas.height; + + // Reset particles on resize (keep some) + particlesRef.current = particlesRef.current.filter( + p => p.x < w + 20 && p.y < h + 20 + ); + + initStreams(w, h); + }; + + resize(); + rafRef.current = requestAnimationFrame(animate); + + window.addEventListener('resize', resize); + window.addEventListener('mousemove', (e) => { + mouseRef.current = { x: e.clientX, y: e.clientY }; + }); + + return () => { + cancelAnimationFrame(rafRef.current); + window.removeEventListener('resize', resize); + }; + }, [animate, initStreams]); + + return ( +