// ═══════════════════════════════════════════════ // Bubble Background — wa2k.com // Sparse frosted bubbles, slow drift, soft merge // ═══════════════════════════════════════════════ (function () { const canvas = document.createElement('canvas'); canvas.id = 'bubble-bg'; Object.assign(canvas.style, { position: 'fixed', top: '0', left: '0', width: '100%', height: '100%', zIndex: '-1', pointerEvents: 'none', }); document.body.prepend(canvas); const ctx = canvas.getContext('2d'); // ── Config ────────────────────────────────── const BG_COLOR = '#0a0e1a'; const BUBBLE_COUNT = 18; // sparse const MIN_R = 28; const MAX_R = 110; const SPEED = 0.12; // very slow drift const MERGE_DIST = 1.08; // merge when centers within 1.08× sum of radii const MERGE_DURATION = 2800; // ms for merge animation const SPLIT_DELAY = [6000, 14000]; // ms before a merged bubble splits // Colour palette — pulled from site's glass theme const COLORS = [ { r: 196, g: 214, b: 226 }, // eww border blue-grey { r: 137, g: 180, b: 250 }, // primary neon { r: 166, g: 209, b: 255 }, // lighter blue { r: 180, g: 200, b: 220 }, // muted steel ]; // ── Resize ────────────────────────────────── function resize() { canvas.width = window.innerWidth; canvas.height = window.innerHeight; } window.addEventListener('resize', resize); resize(); // ── Bubble factory ────────────────────────── let idSeq = 0; function randColor() { return COLORS[Math.floor(Math.random() * COLORS.length)]; } function makeBubble(x, y, r) { const angle = Math.random() * Math.PI * 2; const speed = SPEED * (0.5 + Math.random() * 0.8); const c = randColor(); return { id: idSeq++, x: x ?? Math.random() * canvas.width, y: y ?? Math.random() * canvas.height, r: r ?? MIN_R + Math.random() * (MAX_R - MIN_R), vx: Math.cos(angle) * speed, vy: Math.sin(angle) * speed, c, alpha: 0.13 + Math.random() * 0.10, // merge state merging: false, mergeProgress: 0, mergeTarget: null, dead: false, // split state splitAt: null, }; } // ── Init bubbles ───────────────────────────── let bubbles = []; for (let i = 0; i < BUBBLE_COUNT; i++) bubbles.push(makeBubble()); // ── Merge helpers ──────────────────────────── function startMerge(a, b) { // b merges into a; b dies after animation a.merging = true; a.mergeTarget = b; a.mergeProgress = 0; b.dead = true; // hide b immediately, a absorbs it // Destination: midpoint, combined area radius const combinedR = Math.min(MAX_R * 1.4, Math.sqrt(a.r * a.r + b.r * b.r)); a._mergeFrom = { x: a.x, y: a.y, r: a.r }; a._mergeTo = { x: (a.x * a.r + b.x * b.r) / (a.r + b.r), y: (a.y * a.r + b.y * b.r) / (a.r + b.r), r: combinedR, }; a._mergeDuration = MERGE_DURATION; a._mergeStart = performance.now(); // Schedule split const delay = SPLIT_DELAY[0] + Math.random() * (SPLIT_DELAY[1] - SPLIT_DELAY[0]); a.splitAt = performance.now() + MERGE_DURATION + delay; } function finishMerge(a) { a.x = a._mergeTo.x; a.y = a._mergeTo.y; a.r = a._mergeTo.r; a.merging = false; a.mergeTarget = null; } function splitBubble(a) { // Split into two bubbles roughly original size const childR = a.r / Math.sqrt(2); const angle = Math.random() * Math.PI * 2; const offset = childR * 0.6; const b1 = makeBubble(a.x + Math.cos(angle) * offset, a.y + Math.sin(angle) * offset, childR); const b2 = makeBubble(a.x - Math.cos(angle) * offset, a.y - Math.sin(angle) * offset, childR); // Push apart b1.vx = Math.cos(angle) * SPEED * 1.2; b1.vy = Math.sin(angle) * SPEED * 1.2; b2.vx = -b1.vx; b2.vy = -b1.vy; a.dead = true; bubbles.push(b1, b2); } // ── Draw a single bubble ───────────────────── function drawBubble(b, alpha) { const { x, y, r, c } = b; const a = (alpha ?? b.alpha); // Outer glow const glow = ctx.createRadialGradient(x, y, r * 0.5, x, y, r * 1.3); glow.addColorStop(0, `rgba(${c.r},${c.g},${c.b},0)`); glow.addColorStop(1, `rgba(${c.r},${c.g},${c.b},${(a * 0.18).toFixed(3)})`); ctx.beginPath(); ctx.arc(x, y, r * 1.3, 0, Math.PI * 2); ctx.fillStyle = glow; ctx.fill(); // Body — frosted glass fill const body = ctx.createRadialGradient(x - r * 0.3, y - r * 0.3, r * 0.05, x, y, r); body.addColorStop(0, `rgba(${c.r},${c.g},${c.b},${(a * 0.22).toFixed(3)})`); body.addColorStop(0.6, `rgba(${c.r},${c.g},${c.b},${(a * 0.08).toFixed(3)})`); body.addColorStop(1, `rgba(${c.r},${c.g},${c.b},${(a * 0.03).toFixed(3)})`); ctx.beginPath(); ctx.arc(x, y, r, 0, Math.PI * 2); ctx.fillStyle = body; ctx.fill(); // Rim ctx.beginPath(); ctx.arc(x, y, r, 0, Math.PI * 2); ctx.strokeStyle = `rgba(${c.r},${c.g},${c.b},${(a * 1.1).toFixed(3)})`; ctx.lineWidth = 1.2; ctx.stroke(); // Specular highlight — top-left catch const spec = ctx.createRadialGradient( x - r * 0.38, y - r * 0.38, 0, x - r * 0.28, y - r * 0.28, r * 0.42 ); spec.addColorStop(0, `rgba(255,255,255,${(a * 1.4).toFixed(3)})`); spec.addColorStop(1, 'rgba(255,255,255,0)'); ctx.beginPath(); ctx.arc(x, y, r, 0, Math.PI * 2); ctx.fillStyle = spec; ctx.fill(); } // ── Main loop ──────────────────────────────── let lastTime = performance.now(); function loop(now) { const dt = Math.min(now - lastTime, 50); // cap dt lastTime = now; ctx.clearRect(0, 0, canvas.width, canvas.height); // Background ctx.fillStyle = BG_COLOR; ctx.fillRect(0, 0, canvas.width, canvas.height); // Remove dead bubbles bubbles = bubbles.filter(b => !b.dead); // Replenish if too few while (bubbles.length < BUBBLE_COUNT) bubbles.push(makeBubble()); // Update & draw for (let i = 0; i < bubbles.length; i++) { const b = bubbles[i]; if (b.dead) continue; // Split check if (b.splitAt && now >= b.splitAt && !b.merging) { splitBubble(b); continue; } // Merge animation if (b.merging) { const t = Math.min(1, (now - b._mergeStart) / b._mergeDuration); const ease = t < 0.5 ? 2 * t * t : -1 + (4 - 2 * t) * t; // ease in-out quad b.x = b._mergeFrom.x + (b._mergeTo.x - b._mergeFrom.x) * ease; b.y = b._mergeFrom.y + (b._mergeTo.y - b._mergeFrom.y) * ease; b.r = b._mergeFrom.r + (b._mergeTo.r - b._mergeFrom.r) * ease; if (t >= 1) finishMerge(b); } else { // Drift b.x += b.vx * dt; b.y += b.vy * dt; // Soft wrap-around with margin const m = b.r + 20; if (b.x < -m) b.x = canvas.width + m; if (b.x > canvas.width + m) b.x = -m; if (b.y < -m) b.y = canvas.height + m; if (b.y > canvas.height + m) b.y = -m; // Very gentle direction wobble b.vx += (Math.random() - 0.5) * 0.001; b.vy += (Math.random() - 0.5) * 0.001; // Clamp speed const spd = Math.hypot(b.vx, b.vy); const maxSpd = SPEED * 1.6; if (spd > maxSpd) { b.vx *= maxSpd / spd; b.vy *= maxSpd / spd; } // Check merge with other bubbles for (let j = i + 1; j < bubbles.length; j++) { const o = bubbles[j]; if (o.dead || o.merging || b.merging || b.splitAt || o.splitAt) continue; const dx = o.x - b.x; const dy = o.y - b.y; const dist = Math.hypot(dx, dy); if (dist < (b.r + o.r) * MERGE_DIST) { // Larger absorbs smaller if (b.r >= o.r) startMerge(b, o); else startMerge(o, b); break; } } } drawBubble(b); } requestAnimationFrame(loop); } requestAnimationFrame(loop); })();