class Bubble {
constructor() {
// Radius: more small than big for variation
const minR = 16;
const maxR = 70;
const t = pow(random(), 1.4); // bias toward smaller sizes
this.radius = lerp(minR, maxR, t);
// Spawn near bottom center with some horizontal spread
const spread = width * 0.35;
this.x = width / 2 + random(-spread, spread);
this.y = height + random(10, 80); // start just below bottom
// Vertical speed (rise)
this.vy = -lerp(0.7, 1.6, 1 - t); // smaller bubbles a bit slower
// Slow horizontal drift
this.vxBase = random(-0.15, 0.15);
// Wobble: horizontal sinusoidal offset
this.wobbleAmp = random(5, 18);
this.wobbleFreq = random(0.3, 0.8); // cycles per second
this.phase = random(TWO_PI);
// Lifetime for popping
this.birth = millis();
this.lifespan = random(8000, 18000); // 8β18 seconds
// Iridescent color + transparency
this.baseHue = random(0, 360);
this.baseAlpha = random(0.30, 0.55);
// Sheen highlight drift around the bubble
this.sheenSpeed = random(-0.6, 0.6); // radians per second
}
update() {
this.y += this.vy;
this.x += this.vxBase * 0.7; // gentle sideways drift
}
getFadeFactor() {
const age = millis() - this.birth;
const t = constrain(age / this.lifespan, 0, 1);
// Fade-in over first 12% of life
const fadeIn = constrain((t - 0.0) / 0.12, 0, 1);
// Fade-out over last 35% of life
const fadeOut = 1 - constrain((t - 0.65) / 0.35, 0, 1);
return fadeIn * fadeOut;
}
isDead() {
const age = millis() - this.birth;
// Pop when above the top or past lifespan
return age > this.lifespan || this.y + this.radius < -40;
}
display() {
const ctx = drawingContext; // 2D CanvasRenderingContext2D
const r = this.radius;
const time = millis() / 1000;
const fade = this.getFadeFactor();
if (fade <= 0) return;
const alphaScale = this.baseAlpha * fade;
// Wobble: time-based horizontal oscillation
const wobbleX = sin(TWO_PI * this.wobbleFreq * time + this.phase) * this.wobbleAmp;
const x = this.x + wobbleX;
const y = this.y;
// Sheen highlight: small bright spot that orbits around the bubble
const angle = this.sheenSpeed * time + this.phase;
const sheenOffset = r * 0.4;
const gx = x + cos(angle) * sheenOffset;
const gy = y + sin(angle) * sheenOffset;
// Iridescent radial gradient
const grad = ctx.createRadialGradient(gx, gy, r * 0.15, x, y, r);
// Animate the rainbow hue slightly over time
const hue = (this.baseHue + time * 40) % 360;
grad.addColorStop(0.0, `rgba(255,255,255,${0.9 * alphaScale})`);
grad.addColorStop(0.25, `hsla(${hue}, 95%, 85%, ${0.75 * alphaScale})`);
grad.addColorStop(0.5, `hsla(${(hue + 60) % 360}, 90%, 70%, ${0.45 * alphaScale})`);
grad.addColorStop(0.8, `hsla(${(hue + 140) % 360}, 90%, 65%, ${0.20 * alphaScale})`);
grad.addColorStop(1.0, `rgba(255,255,255,0)`);
ctx.fillStyle = grad;
noStroke();
ellipse(x, y, r * 2, r * 2);
// Subtle outer rim to make the bubble edge visible
stroke(255, 255, 255, 160 * alphaScale);
strokeWeight(r * 0.06);
noFill();
ellipse(x, y, r * 2.02, r * 2.02);
// Small inner highlight arc for extra realism
push();
translate(x, y);
rotate(-PI / 3);
stroke(255, 255, 255, 200 * alphaScale);
strokeWeight(r * 0.07);
noFill();
arc(0, 0, r * 1.1, r * 1.1, -PI * 0.15, PI * 0.35);
pop();
}
}