fuzzyworld

bubbles / files / src / levels / LavaLampLevel.ts

LavaLampLevel.ts

10.3 KB · history · blame

1import { ILevel } from './ILevel.js';
2import { Vector2 } from '../physics/Vector2.js';
3import { GlassRenderer } from '../rendering/GlassRenderer.js';
4
5/** A single lava-lamp blob. */
6interface 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
18const BLOB_COUNT = 10;
19const 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)
25const THERMAL_RATE = 0.6; // how fast temp changes (1/s)
26const DRAG = 4.0; // linear drag coefficient
27const REPULSION = 18000; // blob–blob separation force scale
28const ATTRACTION = 800; // same-type soft-attraction force scale
29const 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 */
36export 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}