// ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━ // DONAU2SPACE // DEV ENTITY — Canvas Starfield // ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━ import { random, lerp, clamp } from './utils.js'; const STAR_COUNT = 900; const LAYER_COUNT = 3; const SHOOT_CHANCE = 0.001; let canvas, ctx, W, H; let stars = []; let shootingStars = []; let mouseX = 0.5, mouseY = 0.5; let targetParallax = { x: 0, y: 0 }; let parallax = { x: 0, y: 0 }; let warpFactor = 0; let warpTarget = 0; let drift = 0; let running = true; let nebulae = []; function createStar() { const layer = (Math.random() * LAYER_COUNT) | 0; const depth = (layer + 1) / LAYER_COUNT; return { x: Math.random() * 2 - 0.5, y: Math.random() * 2 - 0.5, size: random(0.3, 1.8) * depth, brightness: random(0.3, 1), twinkleSpeed: random(0.5, 3), twinklePhase: random(0, Math.PI * 2), layer, depth, hue: Math.random() < 0.15 ? random(20, 50) : random(200, 240), saturation: Math.random() < 0.8 ? random(0, 15) : random(40, 70), }; } function createNebula() { return { x: random(0.1, 0.9), y: random(0.1, 0.9), radius: random(150, 400), hue: random(180, 300), alpha: random(0.01, 0.03), drift: random(-0.00002, 0.00002), }; } function createShootingStar() { const angle = random(-0.5, -0.2); const speed = random(6, 14); return { x: random(-0.1, 1.1), y: random(-0.1, 0.3), vx: Math.cos(angle) * speed, vy: Math.sin(angle) * -speed, life: 1, decay: random(0.015, 0.035), length: random(40, 100), brightness: random(0.6, 1), }; } export function initStarfield(canvasEl) { canvas = canvasEl; ctx = canvas.getContext('2d'); resize(); window.addEventListener('resize', resize); document.addEventListener('mousemove', e => { mouseX = e.clientX / W; mouseY = e.clientY / H; }); for (let i = 0; i < STAR_COUNT; i++) stars.push(createStar()); for (let i = 0; i < 4; i++) nebulae.push(createNebula()); tick(); } function resize() { W = canvas.width = window.innerWidth; H = canvas.height = window.innerHeight; } function tick() { if (!running) return; requestAnimationFrame(tick); draw(); } function draw() { const time = performance.now() / 1000; drift += 0.0001; // Parallax smoothing targetParallax.x = (mouseX - 0.5) * 30; targetParallax.y = (mouseY - 0.5) * 20; parallax.x = lerp(parallax.x, targetParallax.x, 0.03); parallax.y = lerp(parallax.y, targetParallax.y, 0.03); // Warp smoothing warpFactor = lerp(warpFactor, warpTarget, 0.02); ctx.clearRect(0, 0, W, H); // Nebulae (subtle colored glow patches) for (const neb of nebulae) { neb.x += neb.drift; const nx = neb.x * W + parallax.x * 0.2; const ny = neb.y * H + parallax.y * 0.2; const grad = ctx.createRadialGradient(nx, ny, 0, nx, ny, neb.radius); grad.addColorStop(0, `hsla(${neb.hue}, 60%, 40%, ${neb.alpha})`); grad.addColorStop(1, 'transparent'); ctx.fillStyle = grad; ctx.fillRect(nx - neb.radius, ny - neb.radius, neb.radius * 2, neb.radius * 2); } // Stars for (const star of stars) { const twinkle = Math.sin(time * star.twinkleSpeed + star.twinklePhase) * 0.3 + 0.7; const alpha = star.brightness * twinkle; // Parallax offset based on depth const px = parallax.x * star.depth; const py = parallax.y * star.depth; // Warp stretch const warpStretch = warpFactor * star.depth * 40; let sx = (star.x + drift * star.depth) * W + px; let sy = star.y * H + py; // Wrap around sx = ((sx % W) + W) % W; sy = ((sy % H) + H) % H; const size = star.size * (1 + warpFactor * 2); ctx.globalAlpha = clamp(alpha, 0, 1); if (warpFactor > 0.1) { // Warp mode: draw streaks ctx.strokeStyle = `hsla(${star.hue}, ${star.saturation}%, 85%, ${alpha})`; ctx.lineWidth = size * 0.6; ctx.beginPath(); ctx.moveTo(sx, sy); ctx.lineTo(sx - warpStretch, sy); ctx.stroke(); } else { // Normal: draw dots with subtle glow ctx.fillStyle = `hsla(${star.hue}, ${star.saturation}%, 85%, ${alpha})`; ctx.beginPath(); ctx.arc(sx, sy, size, 0, Math.PI * 2); ctx.fill(); // Glow for brighter stars if (star.brightness > 0.7 && star.size > 1) { ctx.globalAlpha = alpha * 0.15; ctx.beginPath(); ctx.arc(sx, sy, size * 3, 0, Math.PI * 2); ctx.fill(); } } } ctx.globalAlpha = 1; // Shooting stars if (Math.random() < SHOOT_CHANCE && shootingStars.length < 3) { shootingStars.push(createShootingStar()); } for (let i = shootingStars.length - 1; i >= 0; i--) { const ss = shootingStars[i]; ss.x += ss.vx / W; ss.y += ss.vy / H; ss.life -= ss.decay; if (ss.life <= 0) { shootingStars.splice(i, 1); continue; } const sx = ss.x * W; const sy = ss.y * H; const ex = sx - (ss.vx / W) * ss.length; const ey = sy - (ss.vy / H) * ss.length; const grad = ctx.createLinearGradient(sx, sy, ex, ey); grad.addColorStop(0, `rgba(255, 255, 255, ${ss.life * ss.brightness})`); grad.addColorStop(1, 'rgba(255, 255, 255, 0)'); ctx.strokeStyle = grad; ctx.lineWidth = 1.5; ctx.beginPath(); ctx.moveTo(sx, sy); ctx.lineTo(ex, ey); ctx.stroke(); } } export function setWarp(factor) { warpTarget = clamp(factor, 0, 1); } export function triggerShootingStar() { shootingStars.push(createShootingStar()); } export function pause() { running = false; } export function resume() { if (!running) { running = true; tick(); } } export function setOpacity(val) { if (canvas) canvas.style.opacity = clamp(val, 0, 1); }