260 lines
8.5 KiB
JavaScript
260 lines
8.5 KiB
JavaScript
|
|
// ═══════════════════════════════════════════════
|
|||
|
|
// 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);
|
|||
|
|
})();
|