wa2k.com-Website/www/background.js

260 lines
8.5 KiB
JavaScript
Raw Normal View History

2026-05-08 22:13:32 -07:00
// ═══════════════════════════════════════════════
// 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);
})();