// Interactive berry + rocket canvas — sits behind the hero content. // Drifting berries connected by faint constellation lines (the "circuit" // motif from the logo). Click anywhere to launch a rocket from that point // with a pixelated exhaust trail. The cursor lights up nearby berries. const RocketCanvas = ({ density = 12 }) => { const canvasRef = React.useRef(null); const wrapRef = React.useRef(null); React.useEffect(() => { const canvas = canvasRef.current; const wrap = wrapRef.current; if (!canvas || !wrap) return; const ctx = canvas.getContext('2d'); const DPR = Math.min(window.devicePixelRatio || 1, 2); // ── State ──────────────────────────────────────────── const PALETTE = ['#D6248C', '#A14BCC', '#7027A8', '#1F2754']; const berries = []; const rockets = []; const particles = []; // mouse-trail pixels + rocket exhaust const mouse = { x: -9999, y: -9999, active: false }; let W = 0, H = 0; let running = true, visible = true; let last = performance.now(); const rand = (a, b) => a + Math.random() * (b - a); const pick = arr => arr[(Math.random() * arr.length) | 0]; // ── Berry: little orb with a leaf, drifts slowly ───── function spawnBerry() { return { x: rand(0, W), y: rand(0, H), vx: rand(-0.12, 0.12), vy: rand(-0.08, 0.08), r: rand(5, 11), color: pick(PALETTE), wobble: rand(0, Math.PI * 2), wobbleSpeed: rand(0.005, 0.012), glow: 0, }; } // ── Rocket: spawned on click, flies in its launch direction ── function spawnRocket(x, y) { // Direction: mostly upward, with slight outward bias from screen centre const cx = W / 2, cy = H / 2; const dx = x - cx, dy = y - cy; const baseAngle = -Math.PI / 2 + Math.atan2(dy, dx) * 0.15 + rand(-0.25, 0.25); const speed = rand(7, 10); rockets.push({ x, y, vx: Math.cos(baseAngle) * speed, vy: Math.sin(baseAngle) * speed, angle: baseAngle, life: 0, maxLife: 180, color: pick(PALETTE), size: rand(11, 15), }); // Initial spark cluster at launch point for (let i = 0; i < 14; i++) { particles.push({ x, y, vx: rand(-2.5, 2.5), vy: rand(-2.5, 2.5), life: 1, decay: rand(0.02, 0.04), size: rand(2, 4), color: pick(PALETTE), kind: 'pixel', }); } } // ── Sizing + canvas DPR ───────────────────────────── function resize() { const rect = wrap.getBoundingClientRect(); W = rect.width; H = rect.height; canvas.width = W * DPR; canvas.height = H * DPR; canvas.style.width = W + 'px'; canvas.style.height = H + 'px'; ctx.setTransform(DPR, 0, 0, DPR, 0, 0); } // ── Drawing helpers ───────────────────────────────── function drawBerry(b) { const r = b.r * (1 + Math.sin(b.wobble) * 0.05); // Glow halo if (b.glow > 0) { const g = ctx.createRadialGradient(b.x, b.y, r * 0.4, b.x, b.y, r * 5); g.addColorStop(0, b.color + Math.floor(b.glow * 80).toString(16).padStart(2, '0')); g.addColorStop(1, b.color + '00'); ctx.fillStyle = g; ctx.beginPath(); ctx.arc(b.x, b.y, r * 5, 0, Math.PI * 2); ctx.fill(); } // Body — gradient from logo const grad = ctx.createRadialGradient(b.x - r * 0.4, b.y - r * 0.4, 0, b.x, b.y, r); grad.addColorStop(0, b.color); grad.addColorStop(1, '#1F2754'); ctx.fillStyle = grad; ctx.beginPath(); ctx.arc(b.x, b.y, r, 0, Math.PI * 2); ctx.fill(); // Leaves on top ctx.fillStyle = '#7027A8'; ctx.beginPath(); ctx.ellipse(b.x - r * 0.35, b.y - r * 1.1, r * 0.35, r * 0.2, -0.6, 0, Math.PI * 2); ctx.fill(); ctx.beginPath(); ctx.ellipse(b.x + r * 0.35, b.y - r * 1.1, r * 0.35, r * 0.2, 0.6, 0, Math.PI * 2); ctx.fill(); } function drawRocket(r) { ctx.save(); ctx.translate(r.x, r.y); ctx.rotate(r.angle + Math.PI / 2); // ship points along velocity const s = r.size; // Body ctx.fillStyle = '#FFFFFF'; ctx.beginPath(); ctx.moveTo(0, -s); // nose ctx.bezierCurveTo(s * 0.55, -s * 0.5, s * 0.55, s * 0.4, s * 0.45, s * 0.6); ctx.lineTo(-s * 0.45, s * 0.6); ctx.bezierCurveTo(-s * 0.55, s * 0.4, -s * 0.55, -s * 0.5, 0, -s); ctx.fill(); // Side fins ctx.fillStyle = r.color; ctx.beginPath(); ctx.moveTo(-s * 0.45, s * 0.2); ctx.lineTo(-s * 0.9, s * 0.85); ctx.lineTo(-s * 0.45, s * 0.6); ctx.closePath(); ctx.fill(); ctx.beginPath(); ctx.moveTo( s * 0.45, s * 0.2); ctx.lineTo( s * 0.9, s * 0.85); ctx.lineTo( s * 0.45, s * 0.6); ctx.closePath(); ctx.fill(); // Window ctx.fillStyle = r.color; ctx.beginPath(); ctx.arc(0, -s * 0.15, s * 0.18, 0, Math.PI * 2); ctx.fill(); ctx.restore(); } // ── Main loop ─────────────────────────────────────── function step(now) { if (!running) return; const dt = Math.min(now - last, 50) / 16.67; // ≈ frames last = now; if (!visible) { requestAnimationFrame(step); return; } ctx.clearRect(0, 0, W, H); // 1. Berries: drift + edge wrap + cursor proximity glow for (const b of berries) { b.x += b.vx * dt; b.y += b.vy * dt; b.wobble += b.wobbleSpeed * dt; if (b.x < -20) b.x = W + 20; if (b.x > W+20) b.x = -20; if (b.y < -20) b.y = H + 20; if (b.y > H+20) b.y = -20; const dx = b.x - mouse.x, dy = b.y - mouse.y; const d2 = dx*dx + dy*dy; const NEAR2 = 160 * 160; b.glow = (mouse.active && d2 < NEAR2) ? 1 - Math.sqrt(d2) / 160 : Math.max(0, b.glow - 0.05); } // 2. Constellation lines between berries (logo's circuit motif) ctx.lineWidth = 1; for (let i = 0; i < berries.length; i++) { for (let j = i + 1; j < berries.length; j++) { const a = berries[i], c = berries[j]; const dx = a.x - c.x, dy = a.y - c.y; const d2 = dx*dx + dy*dy; const MAX = 220; if (d2 < MAX * MAX) { const alpha = (1 - Math.sqrt(d2) / MAX) * 0.35; ctx.strokeStyle = `rgba(160, 75, 204, ${alpha})`; ctx.beginPath(); ctx.moveTo(a.x, a.y); ctx.lineTo(c.x, c.y); ctx.stroke(); } } } // Cursor-to-berry lines (only on hover) if (mouse.active) { for (const b of berries) { if (b.glow > 0.05) { ctx.strokeStyle = `rgba(214, 36, 140, ${b.glow * 0.5})`; ctx.beginPath(); ctx.moveTo(mouse.x, mouse.y); ctx.lineTo(b.x, b.y); ctx.stroke(); } } } // 3. Particles (pixel trail + sparks) for (let i = particles.length - 1; i >= 0; i--) { const p = particles[i]; p.x += p.vx * dt; p.y += p.vy * dt; p.vx *= 0.96; p.vy *= 0.96; p.life -= p.decay * dt; if (p.life <= 0) { particles.splice(i, 1); continue; } ctx.globalAlpha = p.life; ctx.fillStyle = p.color; ctx.fillRect(p.x - p.size/2, p.y - p.size/2, p.size, p.size); } ctx.globalAlpha = 1; // 4. Rockets: move + emit pixel trail for (let i = rockets.length - 1; i >= 0; i--) { const r = rockets[i]; r.x += r.vx * dt; r.y += r.vy * dt; r.life += dt; // Subtle gravity / drag (so it doesn't go totally straight) r.vx *= 0.998; r.vy *= 0.998; // Update angle to follow velocity r.angle = Math.atan2(r.vy, r.vx); // Emit exhaust opposite to velocity if (r.life % 1 < 0.6) { const back = -1; const ex = r.x + Math.cos(r.angle + Math.PI) * r.size * 0.5; const ey = r.y + Math.sin(r.angle + Math.PI) * r.size * 0.5; for (let k = 0; k < 2; k++) { particles.push({ x: ex + rand(-2, 2), y: ey + rand(-2, 2), vx: -r.vx * 0.2 + rand(-0.8, 0.8), vy: -r.vy * 0.2 + rand(-0.8, 0.8), life: 1, decay: rand(0.025, 0.05), size: rand(2, 5), color: k === 0 ? r.color : pick(PALETTE), kind: 'pixel', }); } } drawRocket(r); // Despawn off-screen or life exhausted if (r.life > r.maxLife || r.x < -60 || r.x > W + 60 || r.y < -120 || r.y > H + 60) { rockets.splice(i, 1); } } // 5. Berries draw last so they sit on top of constellation lines for (const b of berries) drawBerry(b); requestAnimationFrame(step); } // ── Listeners ─────────────────────────────────────── function onMove(e) { const rect = wrap.getBoundingClientRect(); mouse.x = (e.touches ? e.touches[0].clientX : e.clientX) - rect.left; mouse.y = (e.touches ? e.touches[0].clientY : e.clientY) - rect.top; mouse.active = mouse.x >= 0 && mouse.x <= W && mouse.y >= 0 && mouse.y <= H; } function onLeave() { mouse.active = false; mouse.x = -9999; mouse.y = -9999; } function onClick(e) { const rect = wrap.getBoundingClientRect(); const x = (e.touches ? e.changedTouches[0].clientX : e.clientX) - rect.left; const y = (e.touches ? e.changedTouches[0].clientY : e.clientY) - rect.top; // Only respond if the click was within the hero if (x < 0 || x > W || y < 0 || y > H) return; if (rockets.length < 10) spawnRocket(x, y); } window.addEventListener('mousemove', onMove); window.addEventListener('mouseleave', onLeave); window.addEventListener('click', onClick); window.addEventListener('touchstart', onClick, { passive: true }); window.addEventListener('touchmove', onMove, { passive: true }); // Pause when off-screen (perf) const io = new IntersectionObserver( (entries) => { visible = entries[0].isIntersecting; }, { threshold: 0 } ); io.observe(wrap); // Init resize(); for (let i = 0; i < density; i++) berries.push(spawnBerry()); window.addEventListener('resize', resize); requestAnimationFrame(step); // Demo: launch one rocket after a short delay so users see the effect setTimeout(() => { if (W > 0 && H > 0) spawnRocket(W * 0.5, H * 0.85); }, 1200); return () => { running = false; window.removeEventListener('mousemove', onMove); window.removeEventListener('mouseleave', onLeave); window.removeEventListener('click', onClick); window.removeEventListener('touchstart', onClick); window.removeEventListener('touchmove', onMove); window.removeEventListener('resize', resize); io.disconnect(); }; }, [density]); return (
); }; window.RocketCanvas = RocketCanvas;