// Slowly drifting, twinkling starfield with the odd shooting star. export function startStarfield(canvas) { const ctx = canvas.getContext('2d'); const reduce = window.matchMedia('(prefers-reduced-motion: reduce)'); const tints = ['#ffffff', '#ffffff', '#ffffff', '#9ff8ff', '#ffb3f0']; let w = 0; let h = 0; let stars = []; let shooters = []; let nextShot = 4000; let last = performance.now(); let raf = 0; function resize() { const dpr = Math.min(window.devicePixelRatio || 1, 2); w = window.innerWidth; h = window.innerHeight; canvas.width = Math.round(w * dpr); canvas.height = Math.round(h * dpr); ctx.setTransform(dpr, 0, 0, dpr, 0, 0); const n = Math.min(320, Math.round((w * h) / 5500)); stars = Array.from({ length: n }, () => ({ x: Math.random() * w, y: Math.random() * h, z: 0.25 + Math.random() * 0.75, phase: Math.random() * Math.PI * 2, speed: 0.6 + Math.random() * 1.8, tint: tints[Math.floor(Math.random() * tints.length)], })); draw(performance.now()); } function draw(now) { ctx.clearRect(0, 0, w, h); for (const s of stars) { const tw = 0.5 + 0.5 * Math.sin(now / 1000 * s.speed + s.phase); ctx.globalAlpha = reduce.matches ? 0.5 * s.z : (0.25 + 0.75 * tw) * s.z; ctx.fillStyle = s.tint; const r = 0.4 + s.z * 1.3; ctx.fillRect(s.x, s.y, r, r); } for (const s of shooters) { const g = ctx.createLinearGradient(s.x, s.y, s.x - s.vx * 9, s.y - s.vy * 9); g.addColorStop(0, 'rgba(255,255,255,0.95)'); g.addColorStop(1, 'rgba(255,58,216,0)'); ctx.globalAlpha = Math.min(1, s.life); ctx.strokeStyle = g; ctx.lineWidth = 1.5; ctx.beginPath(); ctx.moveTo(s.x, s.y); ctx.lineTo(s.x - s.vx * 9, s.y - s.vy * 9); ctx.stroke(); } ctx.globalAlpha = 1; } function frame(now) { const dt = Math.min(64, now - last); last = now; for (const s of stars) { s.x -= s.z * 0.012 * dt; if (s.x < -2) { s.x = w + 2; s.y = Math.random() * h; } } nextShot -= dt; if (nextShot <= 0) { nextShot = 7000 + Math.random() * 9000; shooters.push({ x: Math.random() * w * 0.8 + w * 0.2, y: Math.random() * h * 0.4, vx: -(6 + Math.random() * 4), vy: 2 + Math.random() * 2, life: 1.4 }); } for (const s of shooters) { s.x += s.vx * dt / 16; s.y += s.vy * dt / 16; s.life -= dt / 900; } shooters = shooters.filter((s) => s.life > 0); draw(now); raf = requestAnimationFrame(frame); } function sync() { cancelAnimationFrame(raf); if (!reduce.matches) raf = requestAnimationFrame((t) => { last = t; frame(t); }); else draw(performance.now()); } window.addEventListener('resize', resize); reduce.addEventListener('change', sync); resize(); sync(); }