bubbles / files / src / levels / LavaLampLevel.ts
LavaLampLevel.ts
| 1 | import { ILevel } from './ILevel.js'; |
| 2 | import { Vector2 } from '../physics/Vector2.js'; |
| 3 | import { GlassRenderer } from '../rendering/GlassRenderer.js'; |
| 4 | |
| 5 | /** A single lava-lamp blob. */ |
| 6 | interface Blob { |
| 7 | x: number; |
| 8 | y: number; |
| 9 | vx: number; |
| 10 | vy: number; |
| 11 | radius: number; |
| 12 | /** 0 = cold (sinks), 1 = hot (rises) */ |
| 13 | temp: number; |
| 14 | /** 0 = cool wax, 1 = hot wax */ |
| 15 | type: 0 | 1; |
| 16 | } |
| 17 | |
| 18 | const BLOB_COUNT = 10; |
| 19 | const FLUID_DENSITY = 1.0; |
| 20 | // Hot wax (type 1) at max heat: density 0.90 → rises |
| 21 | // Cool wax (type 0) at max cool: density 1.10 → sinks |
| 22 | // Density interpolated by temp: |
| 23 | // type 0: lerp(1.10, 0.96, temp) |
| 24 | // type 1: lerp(1.04, 0.90, temp) |
| 25 | const THERMAL_RATE = 0.6; // how fast temp changes (1/s) |
| 26 | const DRAG = 4.0; // linear drag coefficient |
| 27 | const REPULSION = 18000; // blob–blob separation force scale |
| 28 | const ATTRACTION = 800; // same-type soft-attraction force scale |
| 29 | const WALL_BOUNCE = 0.35; |
| 30 | |
| 31 | /** |
| 32 | * Lava lamp level. |
| 33 | * Two types of "wax" blobs circulate due to simulated thermal density changes. |
| 34 | * Rendered as metaballs using the CSS blur+contrast Canvas 2D trick. |
| 35 | */ |
| 36 | export class LavaLampLevel implements ILevel { |
| 37 | readonly name = 'Lava Lamp'; |
| 38 | |
| 39 | private blobs: Blob[] = []; |
| 40 | private glassRend: GlassRenderer; |
| 41 | /** Offscreen canvas for metaball compositing. */ |
| 42 | private offscreen: OffscreenCanvas | HTMLCanvasElement; |
| 43 | private offCtx: CanvasRenderingContext2D | OffscreenCanvasRenderingContext2D; |
| 44 | |
| 45 | constructor( |
| 46 | private readonly ctx: CanvasRenderingContext2D, |
| 47 | private glassX: number, |
| 48 | private glassY: number, |
| 49 | private glassW: number, |
| 50 | private glassH: number, |
| 51 | ) { |
| 52 | this.glassRend = new GlassRenderer(ctx, glassX, glassY, glassW, glassH); |
| 53 | const { off, offCtx } = this.makeOffscreen(glassW, glassH); |
| 54 | this.offscreen = off; |
| 55 | this.offCtx = offCtx; |
| 56 | this.initBlobs(); |
| 57 | } |
| 58 | |
| 59 | // ── ILevel ─────────────────────────────────────────────────────────────── |
| 60 | |
| 61 | step(dt: number, gravity: Vector2): void { |
| 62 | const W = this.glassW; |
| 63 | const H = this.glassH; |
| 64 | const gMag = gravity.magnitude(); |
| 65 | // Gravity direction (unit vector pointing "down") |
| 66 | const gx = gMag > 0 ? gravity.x / gMag : 0; |
| 67 | const gy = gMag > 0 ? gravity.y / gMag : 1; |
| 68 | |
| 69 | for (let i = 0; i < this.blobs.length; i++) { |
| 70 | const b = this.blobs[i]!; |
| 71 | |
| 72 | // 1. Thermal update: type-1 heats near the gravity wall, type-0 cools |
| 73 | // "near the gravity wall" = dot(pos_from_center, gDir) > 0 |
| 74 | const cx = b.x - W / 2; |
| 75 | const cy = b.y - H / 2; |
| 76 | const wallProx = (cx * gx + cy * gy) / (H / 2); // -1 (anti-grav wall) … +1 (grav wall) |
| 77 | |
| 78 | let targetTemp: number; |
| 79 | if (b.type === 1) { |
| 80 | // Hot wax heats up near the gravity wall (bottom when upright) |
| 81 | targetTemp = (wallProx + 1) / 2; |
| 82 | } else { |
| 83 | // Cool wax cools down near the anti-gravity wall (top when upright) |
| 84 | targetTemp = 1 - (wallProx + 1) / 2; |
| 85 | } |
| 86 | b.temp += (targetTemp - b.temp) * Math.min(1, dt * THERMAL_RATE); |
| 87 | |
| 88 | // 2. Density → buoyancy |
| 89 | const density = b.type === 1 |
| 90 | ? 1.04 - 0.14 * b.temp // 1.04 → 0.90 |
| 91 | : 1.10 - 0.14 * b.temp; // 1.10 → 0.96 |
| 92 | const buoyancyAcc = (FLUID_DENSITY - density) * gMag; |
| 93 | // Buoyancy opposes gravity direction |
| 94 | const bax = -gx * buoyancyAcc; |
| 95 | const bay = -gy * buoyancyAcc; |
| 96 | |
| 97 | // 3. Body force from gravity |
| 98 | const fax = gx * gMag; |
| 99 | const fay = gy * gMag; |
| 100 | |
| 101 | // 4. Net acceleration |
| 102 | let ax = fax + bax - DRAG * b.vx; |
| 103 | let ay = fay + bay - DRAG * b.vy; |
| 104 | |
| 105 | // 5. Blob–blob forces |
| 106 | for (let j = 0; j < this.blobs.length; j++) { |
| 107 | if (i === j) continue; |
| 108 | const o = this.blobs[j]!; |
| 109 | const dx = b.x - o.x; |
| 110 | const dy = b.y - o.y; |
| 111 | const d2 = dx * dx + dy * dy + 0.01; |
| 112 | const minD = b.radius + o.radius; |
| 113 | // Repulsion: always push apart when close |
| 114 | if (d2 < (minD * 2) * (minD * 2)) { |
| 115 | const d = Math.sqrt(d2); |
| 116 | const f = REPULSION / (d2); |
| 117 | ax += (dx / d) * f; |
| 118 | ay += (dy / d) * f; |
| 119 | } |
| 120 | // Soft attraction: same type, within range |
| 121 | if (b.type === o.type && d2 < (minD * 5) * (minD * 5)) { |
| 122 | const d = Math.sqrt(d2); |
| 123 | const f = ATTRACTION / (d2 + 1); |
| 124 | ax -= (dx / d) * f; |
| 125 | ay -= (dy / d) * f; |
| 126 | } |
| 127 | } |
| 128 | |
| 129 | b.vx += ax * dt; |
| 130 | b.vy += ay * dt; |
| 131 | |
| 132 | b.x += b.vx * dt; |
| 133 | b.y += b.vy * dt; |
| 134 | |
| 135 | // 6. Wall bounce (all four walls — sealed) |
| 136 | if (b.x - b.radius < 0) { |
| 137 | b.x = b.radius; |
| 138 | b.vx = Math.abs(b.vx) * WALL_BOUNCE; |
| 139 | } |
| 140 | if (b.x + b.radius > W) { |
| 141 | b.x = W - b.radius; |
| 142 | b.vx = -Math.abs(b.vx) * WALL_BOUNCE; |
| 143 | } |
| 144 | if (b.y - b.radius < 0) { |
| 145 | b.y = b.radius; |
| 146 | b.vy = Math.abs(b.vy) * WALL_BOUNCE; |
| 147 | } |
| 148 | if (b.y + b.radius > H) { |
| 149 | b.y = H - b.radius; |
| 150 | b.vy = -Math.abs(b.vy) * WALL_BOUNCE; |
| 151 | } |
| 152 | } |
| 153 | } |
| 154 | |
| 155 | draw( |
| 156 | ctx: CanvasRenderingContext2D, |
| 157 | glassX: number, |
| 158 | glassY: number, |
| 159 | glassW: number, |
| 160 | glassH: number, |
| 161 | ): void { |
| 162 | // Render metaballs into offscreen canvas, then composite with blur+contrast |
| 163 | const off = this.offCtx; |
| 164 | const W = glassW; |
| 165 | const H = glassH; |
| 166 | |
| 167 | off.clearRect(0, 0, W, H); |
| 168 | |
| 169 | // Dark background on offscreen (contrast trick needs opaque BG) |
| 170 | off.fillStyle = '#1a0a1a'; |
| 171 | off.fillRect(0, 0, W, H); |
| 172 | |
| 173 | // Draw each blob as a radial gradient |
| 174 | for (const b of this.blobs) { |
| 175 | const hot = b.type === 1 ? b.temp : (1 - b.temp); |
| 176 | const r = b.radius * 1.8; // gradient extends beyond physics radius |
| 177 | const grad = off.createRadialGradient(b.x, b.y, 0, b.x, b.y, r); |
| 178 | |
| 179 | // Color: interpolate between cool (deep red-purple) and hot (orange-red) |
| 180 | const cCool = [120, 20, 60]; |
| 181 | const cHot = [255, 80, 20]; |
| 182 | const cr = Math.round(cCool[0]! + (cHot[0]! - cCool[0]!) * hot); |
| 183 | const cg = Math.round(cCool[1]! + (cHot[1]! - cCool[1]!) * hot); |
| 184 | const cb = Math.round(cCool[2]! + (cHot[2]! - cCool[2]!) * hot); |
| 185 | |
| 186 | grad.addColorStop(0, `rgba(${cr},${cg},${cb},1)`); |
| 187 | grad.addColorStop(0.5, `rgba(${cr},${cg},${cb},0.7)`); |
| 188 | grad.addColorStop(1, `rgba(${cr},${cg},${cb},0)`); |
| 189 | off.fillStyle = grad; |
| 190 | off.beginPath(); |
| 191 | off.arc(b.x, b.y, r, 0, Math.PI * 2); |
| 192 | off.fill(); |
| 193 | } |
| 194 | |
| 195 | // Clip to glass, composite with blur + contrast for metaball merge effect |
| 196 | this.glassRend.beginClip(); |
| 197 | |
| 198 | ctx.save(); |
| 199 | // blur: blends blob edges; contrast: sharpens merged boundary |
| 200 | ctx.filter = 'blur(12px) contrast(18)'; |
| 201 | ctx.drawImage( |
| 202 | this.offscreen as CanvasImageSource, |
| 203 | glassX, glassY, W, H, |
| 204 | glassX, glassY, W, H, |
| 205 | ); |
| 206 | ctx.filter = 'none'; |
| 207 | ctx.restore(); |
| 208 | |
| 209 | // Warm amber glow fill for background fluid (lamp oil) |
| 210 | ctx.fillStyle = 'rgba(40, 15, 5, 0.65)'; |
| 211 | ctx.fillRect(glassX, glassY, W, H); |
| 212 | |
| 213 | this.glassRend.endClip(); |
| 214 | this.glassRend.draw(); |
| 215 | } |
| 216 | |
| 217 | onTouch(localX: number, localY: number, _gravity: Vector2): void { |
| 218 | // Find nearest blob and give it an impulse outward from the tap |
| 219 | let nearest: Blob | undefined; |
| 220 | let nearDist = Infinity; |
| 221 | for (const b of this.blobs) { |
| 222 | const d = Math.hypot(b.x - localX, b.y - localY); |
| 223 | if (d < nearDist) { nearDist = d; nearest = b; } |
| 224 | } |
| 225 | if (nearest) { |
| 226 | const dx = nearest.x - localX; |
| 227 | const dy = nearest.y - localY; |
| 228 | const d = Math.hypot(dx, dy) + 1; |
| 229 | nearest.vx += (dx / d) * 200; |
| 230 | nearest.vy += (dy / d) * 200; |
| 231 | } |
| 232 | } |
| 233 | |
| 234 | resize(glassW: number, glassH: number): void { |
| 235 | const scaleX = glassW / this.glassW; |
| 236 | const scaleY = glassH / this.glassH; |
| 237 | for (const b of this.blobs) { |
| 238 | b.x *= scaleX; |
| 239 | b.y *= scaleY; |
| 240 | } |
| 241 | this.glassW = glassW; |
| 242 | this.glassH = glassH; |
| 243 | this.glassRend = new GlassRenderer(this.ctx, this.glassX, this.glassY, glassW, glassH); |
| 244 | const { off, offCtx } = this.makeOffscreen(glassW, glassH); |
| 245 | this.offscreen = off; |
| 246 | this.offCtx = offCtx; |
| 247 | } |
| 248 | |
| 249 | reset(): void { |
| 250 | this.initBlobs(); |
| 251 | } |
| 252 | |
| 253 | // ── Private ────────────────────────────────────────────────────────────── |
| 254 | |
| 255 | private initBlobs(): void { |
| 256 | const W = this.glassW; |
| 257 | const H = this.glassH; |
| 258 | this.blobs = []; |
| 259 | for (let i = 0; i < BLOB_COUNT; i++) { |
| 260 | const type = (i % 2) as 0 | 1; |
| 261 | this.blobs.push({ |
| 262 | x: W * (0.2 + Math.random() * 0.6), |
| 263 | y: H * (0.2 + Math.random() * 0.6), |
| 264 | vx: (Math.random() - 0.5) * 20, |
| 265 | vy: (Math.random() - 0.5) * 20, |
| 266 | radius: 22 + Math.random() * 14, |
| 267 | temp: type === 1 ? 0.8 : 0.2, |
| 268 | type, |
| 269 | }); |
| 270 | } |
| 271 | } |
| 272 | |
| 273 | private makeOffscreen(w: number, h: number): { |
| 274 | off: OffscreenCanvas | HTMLCanvasElement; |
| 275 | offCtx: CanvasRenderingContext2D | OffscreenCanvasRenderingContext2D; |
| 276 | } { |
| 277 | if (typeof OffscreenCanvas !== 'undefined') { |
| 278 | const off = new OffscreenCanvas(w, h); |
| 279 | return { off, offCtx: off.getContext('2d')! }; |
| 280 | } |
| 281 | const off = document.createElement('canvas'); |
| 282 | off.width = w; |
| 283 | off.height = h; |
| 284 | return { off, offCtx: off.getContext('2d')! }; |
| 285 | } |
| 286 | } |