fuzzyworld

nuts / lava / files / src / lavaLamp.ts

Branch lava · default branch · all branches

lavaLamp.ts

19.9 KB · history · blame

1// src/lavaLamp.ts
2//
3// Scene: lava lamp — a sealed glass column filled with water, with a heat
4// source at the bottom and a heat sink at the top.
5
6import Matter from 'matter-js'
7import { CONFIG } from './config'
8
9const { Engine, Bodies, Body, Composite } = Matter
10
11function remoteLog(msg: string): void {
12 if (!CONFIG.logToVite) return
13 fetch('/__log', {
14 method: 'POST',
15 headers: { 'Content-Type': 'application/json' },
16 body: JSON.stringify({ level: 'info', msg }),
17 }).catch(() => { })
18}
19
20export function runLavaLamp(): void {
21 // Hide all game UI so only the canvas shows
22 for (const id of ['start-overlay', 'fullscreen-overlay', 'toolbar', 'settings-panel']) {
23 const el = document.getElementById(id) as HTMLElement | null
24 if (el) { el.hidden = true; el.style.display = 'none' }
25 }
26
27 const canvas = document.getElementById('canvas') as HTMLCanvasElement
28 const dpr = window.devicePixelRatio || 1
29 const W = window.innerWidth
30 const H = window.innerHeight
31
32 canvas.width = Math.round(W * dpr)
33 canvas.height = Math.round(H * dpr)
34 canvas.style.width = `${W}px`
35 canvas.style.height = `${H}px`
36
37 const ctx = canvas.getContext('2d')!
38 ctx.scale(dpr, dpr)
39
40 // ── Layout ────────────────────────────────────────────────────────────
41 const cx = W / 2
42 const wallThick = 10
43 const wallR = 16 // inner corner radius
44
45 // Inner (physics / fluid) region
46 const iW = W * 0.62
47 const iH = H * 0.70
48 const iX = cx - iW / 2
49 const iY = H * 0.13
50 const iB = iY + iH // inner bottom edge
51
52 // Outer glass tube
53 const oX = iX - wallThick
54 const oY = iY - wallThick
55 const oW = iW + wallThick * 2
56 const oH = iH + wallThick * 2
57 const oR = wallR + wallThick
58
59 // ── Rounded-rect subpath helper ───────────────────────────────────────
60 // Does NOT call beginPath — caller is responsible.
61 function rr(x: number, y: number, w: number, h: number, r: number): void {
62 ctx.moveTo(x + r, y)
63 ctx.lineTo(x + w - r, y)
64 ctx.arcTo(x + w, y, x + w, y + r, r)
65 ctx.lineTo(x + w, y + h - r)
66 ctx.arcTo(x + w, y + h, x + w - r, y + h, r)
67 ctx.lineTo(x + r, y + h)
68 ctx.arcTo(x, y + h, x, y + h - r, r)
69 ctx.lineTo(x, y + r)
70 ctx.arcTo(x, y, x + r, y, r)
71 ctx.closePath()
72 }
73
74 // ── Physics engine ────────────────────────────────────────────────────
75 // Gravity is deliberately small — blobs move slowly like real wax.
76 // Terminal velocity ≈ g × (LIFT−1) / frictionAir ≈ 30 px/s → ~18s to cross the tube.
77 const engine = Engine.create({ gravity: { x: 0, y: 0.04 } })
78
79 // Static tube walls — thin rectangles just inside the glass
80 const WT = 30 // wall thickness (physics only, not visual)
81 const tubeWalls = [
82 Bodies.rectangle(cx, iY - WT / 2, iW + WT * 2, WT, { isStatic: true, label: 'wall' }), // top
83 Bodies.rectangle(cx, iB + WT / 2, iW + WT * 2, WT, { isStatic: true, label: 'wall' }), // bottom
84 Bodies.rectangle(iX - WT / 2, iY + iH / 2, WT, iH, { isStatic: true, label: 'wall' }), // left
85 Bodies.rectangle(iX + iW + WT / 2, iY + iH / 2, WT, iH, { isStatic: true, label: 'wall' }), // right
86 ]
87 Composite.add(engine.world, tubeWalls)
88
89 // ── Wax blobs ─────────────────────────────────────────────────────────
90 interface WaxBlob { body: Matter.Body; rBase: number; x: number; y: number; r: number; temp: number }
91
92 const blobDefs = [
93 { xf: -0.29, rf: 0.110 },
94 { xf: -0.07, rf: 0.145 },
95 { xf: +0.13, rf: 0.120 },
96 { xf: +0.30, rf: 0.090 },
97 { xf: -0.18, rf: 0.088 },
98 { xf: +0.05, rf: 0.100 },
99 ]
100
101 const blobs: WaxBlob[] = blobDefs.map((d, i) => {
102 const r = iW * d.rf
103 const x = cx + iW * d.xf
104 const y = iB - r - i * r * 0.3 // stagger so they don't spawn overlapping
105 const body = Bodies.circle(x, y, r, {
106 density: 0.001,
107 restitution: 0.01,
108 friction: 0.05,
109 frictionAir: 0.10, // higher damping — terminal velocity ≈ 30 px/s
110 label: 'wax',
111 })
112 return { body, rBase: r, x, y, r, temp: 1.0 } // start hot (spawned at bottom)
113 })
114 Composite.add(engine.world, blobs.map(b => b.body))
115
116 // ── Buoyancy model ────────────────────────────────────────────────────
117 // temp = 1 at bottom (heat source), 0 at top (heat sink)
118 // Fy = mass * g * (1 - LIFT * temp)
119 // temp = 0: Fy = +mass*g → gravity wins, blob sinks
120 // temp = 1/LIFT: Fy = 0 → neutral buoyancy
121 // temp = 1: Fy = mass*g*(1-LIFT) → net upward, blob rises
122 // Terminal velocity ≈ (g * |1 - LIFT*temp|) / frictionAir (~1-2 px/frame)
123 const LIFT = 2.5 // 1/LIFT = 0.4 = neutral depth (40% from bottom)
124
125 // THERMAL_RATE: how quickly a blob's temperature approaches ambient.
126 // 0.0006/frame ≈ 28s time constant at 60fps — blobs carry heat state a long time,
127 // overshooting the neutral zone and reaching top/bottom before reversing.
128 const THERMAL_RATE = 0.0006
129
130 function applyBuoyancy(): void {
131 const g = engine.gravity.y
132 for (const blob of blobs) {
133 // Ambient temperature at current position (1=bottom, 0=top)
134 const ambient = Math.max(0, Math.min(1, (blob.body.position.y - iY) / iH))
135 // Thermal inertia: blob temp lags ambient
136 blob.temp += (ambient - blob.temp) * THERMAL_RATE
137 // Buoyancy force using blob's own thermal state
138 const Fy = blob.body.mass * g * (1 - LIFT * blob.temp)
139 Body.applyForce(blob.body, blob.body.position, { x: 0, y: Fy })
140 }
141 }
142
143 // Sync display properties (x, y, visual radius) from physics bodies
144 function syncBlobs(): void {
145 for (const blob of blobs) {
146 blob.x = blob.body.position.x
147 blob.y = blob.body.position.y
148 // Visual heat: swell when blob is hot (temp near 1)
149 blob.r = blob.rBase * (1 + Math.max(0, blob.temp * 2 - 1) * 0.14)
150 }
151 }
152
153 // ── Heat bursts (tap-to-heat) ────────────────────────────────────────
154 interface HeatBurst { age: number }
155 const BURST_DURATION = 1.2
156 const heatBursts: HeatBurst[] = []
157 // TAP_FORCE: upward velocity impulse applied directly (px/frame)
158 const TAP_IMPULSE_VY = -0.6 // px/frame upward kick (gentle, matching slow physics)
159
160 function triggerHeat(): void {
161 remoteLog(`[lava] tap at t=${t.toFixed(2)}`)
162 heatBursts.push({ age: 0 })
163 let nudged = 0
164 for (const blob of blobs) {
165 if (blob.temp > 0.30) { // blobs that still have heat get an upward kick
166 Body.setVelocity(blob.body, {
167 x: blob.body.velocity.x,
168 y: blob.body.velocity.y + TAP_IMPULSE_VY * blob.temp,
169 })
170 // Also heat the blob up so buoyancy kicks in
171 blob.temp = Math.min(1, blob.temp + 0.10)
172 nudged++
173 }
174 }
175 remoteLog(`[lava] nudged ${nudged} blobs`)
176 }
177
178 canvas.addEventListener('pointerdown', (e: PointerEvent) => {
179 const rect = canvas.getBoundingClientRect()
180 const tapY = (e.clientY - rect.top)
181 if (tapY > iY + iH * 0.6) triggerHeat()
182 })
183
184 const wW = Math.round(iW)
185 const wH = Math.round(iH)
186 const waxOff = Object.assign(document.createElement('canvas'), { width: wW, height: wH })
187 const waxFinal = Object.assign(document.createElement('canvas'), { width: wW, height: wH })
188 const wOff = waxOff.getContext('2d')!
189 const wFinal = waxFinal.getContext('2d')!
190
191 const BLOB_BLUR = iW * 0.038 // px — controls merge radius
192 const BLOB_CONTRAST = 17 // higher → sharper edges
193
194 let t = 0
195
196 // ── Sub-draws ─────────────────────────────────────────────────────────
197
198 function drawBackground(): void {
199 ctx.fillStyle = '#0c0c14'
200 ctx.fillRect(0, 0, W, H)
201 }
202
203 function drawHeatSource(): void {
204 const gy = iB + wallThick
205
206 // Extra flash when a heat burst is active
207 const burstIntensity = heatBursts.reduce((s, b) => {
208 const fade = 1 - b.age / BURST_DURATION
209 return s + fade * fade // quadratic fade
210 }, 0)
211
212 // Diffuse ambient glow below / around the base of the container
213 const g = ctx.createRadialGradient(cx, gy, 0, cx, gy, iW * 1.1)
214 const baseAlpha = 0.40 + burstIntensity * 0.35
215 g.addColorStop(0, `rgba(255,120,10,${baseAlpha.toFixed(2)})`)
216 g.addColorStop(0.5, `rgba(200,50,0,${(0.12 + burstIntensity * 0.12).toFixed(2)})`)
217 g.addColorStop(1, 'rgba(0,0,0,0)')
218 ctx.fillStyle = g
219 ctx.fillRect(cx - iW * 1.1, gy - iW * 0.25, iW * 2.2, iW * 1.5)
220
221 // Heating element bar
222 const ew = iW * 0.68
223 const eh = 7
224 const ex = cx - ew / 2
225 const ey = gy + 14
226 const eg = ctx.createLinearGradient(ex, 0, ex + ew, 0)
227 eg.addColorStop(0, '#5A2000')
228 eg.addColorStop(0.33, burstIntensity > 0.1 ? '#FF8820' : '#EE5510')
229 eg.addColorStop(0.50, burstIntensity > 0.1 ? '#FFDD60' : '#FFAA30')
230 eg.addColorStop(0.67, burstIntensity > 0.1 ? '#FF8820' : '#EE5510')
231 eg.addColorStop(1, '#5A2000')
232 ctx.fillStyle = eg
233 ctx.shadowColor = burstIntensity > 0.1 ? '#FFBB40' : '#FF7020'
234 ctx.shadowBlur = 20 + burstIntensity * 30
235 ctx.beginPath(); rr(ex, ey, ew, eh, 3); ctx.fill()
236 ctx.shadowBlur = 0
237
238 ctx.fillStyle = 'rgba(255,155,50,0.55)'
239 ctx.font = '11px ui-monospace, monospace'
240 ctx.textAlign = 'center'
241 ctx.fillText('HEAT SOURCE', cx, ey + eh + 17)
242 }
243
244 function drawHeatSink(): void {
245 // Diffuse ambient glow above the container top
246 const gy = iY - wallThick
247 const g = ctx.createRadialGradient(cx, gy, 0, cx, gy, iW * 1.0)
248 g.addColorStop(0, 'rgba(80,210,255,0.30)')
249 g.addColorStop(0.5, 'rgba(20,80,200,0.08)')
250 g.addColorStop(1, 'rgba(0,0,0,0)')
251 ctx.fillStyle = g
252 ctx.fillRect(cx - iW, gy - iW * 0.7, iW * 2, iW * 0.9)
253
254 // Cooling element bar
255 const ew = iW * 0.68
256 const eh = 7
257 const ex = cx - ew / 2
258 const ey = gy - 22
259 const eg = ctx.createLinearGradient(ex, 0, ex + ew, 0)
260 eg.addColorStop(0, '#002050')
261 eg.addColorStop(0.33, '#0088D0')
262 eg.addColorStop(0.50, '#40C8FF')
263 eg.addColorStop(0.67, '#0088D0')
264 eg.addColorStop(1, '#002050')
265 ctx.fillStyle = eg
266 ctx.shadowColor = '#30C0FF'
267 ctx.shadowBlur = 14
268 ctx.beginPath(); rr(ex, ey, ew, eh, 3); ctx.fill()
269 ctx.shadowBlur = 0
270
271 ctx.fillStyle = 'rgba(70,190,255,0.55)'
272 ctx.font = '11px ui-monospace, monospace'
273 ctx.textAlign = 'center'
274 ctx.fillText('HEAT SINK', cx, ey - 9)
275 }
276
277 function drawWater(): void {
278 ctx.save()
279 ctx.beginPath(); rr(iX, iY, iW, iH, wallR); ctx.clip()
280
281 // Vertical temperature gradient: cool-blue at top, warm-amber at bottom
282 const bg = ctx.createLinearGradient(0, iY, 0, iB)
283 bg.addColorStop(0, 'rgba(14, 40, 92, 0.88)')
284 bg.addColorStop(0.55, 'rgba(16, 46, 98, 0.88)')
285 bg.addColorStop(1, 'rgba(60, 32, 10, 0.80)')
286 ctx.fillStyle = bg
287 ctx.fillRect(iX, iY, iW, iH)
288
289 // Heat bloom rising from the bottom wall
290 const hg = ctx.createRadialGradient(cx, iB, 0, cx, iB, iW * 0.9)
291 hg.addColorStop(0, 'rgba(255, 100, 0, 0.24)')
292 hg.addColorStop(1, 'rgba(0,0,0,0)')
293 ctx.fillStyle = hg
294 ctx.fillRect(iX, iY, iW, iH)
295
296 // Cool bloom falling from the top wall
297 const cg = ctx.createRadialGradient(cx, iY, 0, cx, iY, iW * 0.9)
298 cg.addColorStop(0, 'rgba(70, 200, 255, 0.18)')
299 cg.addColorStop(1, 'rgba(0,0,0,0)')
300 ctx.fillStyle = cg
301 ctx.fillRect(iX, iY, iW, iH)
302
303 // Convection ripple — faint water-flow tint
304 const ripple = ctx.createRadialGradient(cx, iB - iH * 0.25, 0, cx, iB - iH * 0.25, iW * 0.7)
305 ripple.addColorStop(0, 'rgba(140,100,60,0.07)')
306 ripple.addColorStop(1, 'rgba(0,0,0,0)')
307 ctx.fillStyle = ripple
308 ctx.fillRect(iX, iY, iW, iH)
309
310 // Soft vertical caustic — simulates a light beam through the fluid
311 const sl = ctx.createLinearGradient(iX, 0, iX + iW, 0)
312 sl.addColorStop(0, 'rgba(0,0,0,0)')
313 sl.addColorStop(0.30, 'rgba(120,180,255,0.05)')
314 sl.addColorStop(0.38, 'rgba(180,220,255,0.09)')
315 sl.addColorStop(0.46, 'rgba(120,180,255,0.05)')
316 sl.addColorStop(1, 'rgba(0,0,0,0)')
317 ctx.fillStyle = sl
318 ctx.fillRect(iX, iY, iW, iH)
319
320 ctx.restore()
321 }
322
323 function drawGlass(): void {
324 ctx.save()
325
326 // Glass wall fill — evenodd hollows out the inner region
327 ctx.beginPath()
328 rr(oX, oY, oW, oH, oR)
329 rr(iX, iY, iW, iH, wallR)
330 const gf = ctx.createLinearGradient(oX, 0, oX + oW, 0)
331 gf.addColorStop(0, 'rgba(120,175,255,0.22)')
332 gf.addColorStop(0.10, 'rgba(120,175,255,0.06)')
333 gf.addColorStop(0.50, 'rgba(120,175,255,0.02)')
334 gf.addColorStop(0.90, 'rgba(120,175,255,0.06)')
335 gf.addColorStop(1, 'rgba(120,175,255,0.22)')
336 ctx.fillStyle = gf
337 ctx.fill('evenodd')
338
339 // Edge highlight stripes on the glass walls
340 ctx.save()
341 ctx.beginPath()
342 rr(oX, oY, oW, oH, oR)
343 rr(iX, iY, iW, iH, wallR)
344 ctx.clip('evenodd')
345 const hl = ctx.createLinearGradient(oX, 0, oX + oW, 0)
346 hl.addColorStop(0, 'rgba(255,255,255,0.00)')
347 hl.addColorStop(0.05, 'rgba(255,255,255,0.26)')
348 hl.addColorStop(0.15, 'rgba(255,255,255,0.00)')
349 hl.addColorStop(0.85, 'rgba(255,255,255,0.00)')
350 hl.addColorStop(0.95, 'rgba(255,255,255,0.15)')
351 hl.addColorStop(1, 'rgba(255,255,255,0.00)')
352 ctx.fillStyle = hl
353 ctx.fillRect(oX, oY, oW, oH)
354 ctx.restore()
355
356 // Outer rim stroke
357 ctx.beginPath(); rr(oX, oY, oW, oH, oR)
358 ctx.strokeStyle = 'rgba(140,195,255,0.22)'
359 ctx.lineWidth = 1.5
360 ctx.stroke()
361
362 // Inner rim stroke
363 ctx.beginPath(); rr(iX, iY, iW, iH, wallR)
364 ctx.strokeStyle = 'rgba(170,215,255,0.30)'
365 ctx.lineWidth = 1.5
366 ctx.stroke()
367
368 ctx.restore()
369 }
370
371 function drawWax(): void {
372 // 1. Draw white circles on waxOff (blob shapes in alpha channel)
373 wOff.clearRect(0, 0, wW, wH)
374 wOff.fillStyle = '#fff'
375 for (const b of blobs) {
376 wOff.beginPath()
377 wOff.arc(b.x - iX, b.y - iY, b.r, 0, Math.PI * 2)
378 wOff.fill()
379 }
380
381 // 2. Gradient shifts warmer when blobs are hotter (using thermal state)
382 const avgTemp = blobs.reduce((s, b) => s + b.temp, 0) / blobs.length
383 const topColor = avgTemp > 0.5 ? '#FFBB55' : '#FF9955'
384 const midColor = avgTemp > 0.5 ? '#FF5511' : '#EE3300'
385
386 wFinal.clearRect(0, 0, wW, wH)
387 wFinal.globalCompositeOperation = 'source-over'
388 const grad = wFinal.createLinearGradient(0, 0, 0, wH)
389 grad.addColorStop(0.0, topColor)
390 grad.addColorStop(0.45, midColor)
391 grad.addColorStop(1.0, '#991100')
392 wFinal.fillStyle = grad
393 wFinal.fillRect(0, 0, wW, wH)
394
395 // destination-in + filter: keep gradient only inside merged blob silhouette
396 wFinal.filter = `blur(${BLOB_BLUR}px) contrast(${BLOB_CONTRAST})`
397 wFinal.globalCompositeOperation = 'destination-in'
398 wFinal.drawImage(waxOff, 0, 0)
399 wFinal.filter = 'none'
400 wFinal.globalCompositeOperation = 'source-over'
401
402 // 3. Composite wax layer onto main canvas (caller has set fluid clip)
403 ctx.drawImage(waxFinal, iX, iY)
404
405 // 4. Soft specular highlights — unfiltered, one per blob
406 for (const b of blobs) {
407 const hx = b.x - b.r * 0.22
408 const hy = b.y - b.r * 0.30
409 const hg = ctx.createRadialGradient(hx, hy, 0, hx, hy, b.r * 0.52)
410 hg.addColorStop(0, 'rgba(255,200,160,0.38)')
411 hg.addColorStop(0.5, 'rgba(255,150,80,0.10)')
412 hg.addColorStop(1, 'rgba(255,0,0,0)')
413 ctx.fillStyle = hg
414 ctx.fillRect(b.x - b.r, b.y - b.r, b.r * 2, b.r * 2)
415 }
416
417 // 5. Subtle inner glow at base of blob mass — heat absorbed from element
418 const baseGlow = ctx.createRadialGradient(cx, iB, 0, cx, iB, iW * 0.55)
419 baseGlow.addColorStop(0, 'rgba(255,60,0,0.18)')
420 baseGlow.addColorStop(1, 'rgba(0,0,0,0)')
421 ctx.fillStyle = baseGlow
422 ctx.fillRect(iX, iY, iW, iH)
423 }
424
425 // ── Animation loop ────────────────────────────────────────────────────────
426 let frameCount = 0
427 function frame(): void {
428 // Age and prune heat bursts
429 for (const b of heatBursts) b.age += 1 / 60
430 for (let i = heatBursts.length - 1; i >= 0; i--) {
431 if (heatBursts[i].age >= BURST_DURATION) heatBursts.splice(i, 1)
432 }
433 // Physics step: buoyancy forces + engine update
434 applyBuoyancy()
435 Engine.update(engine, 1000 / 60)
436 syncBlobs()
437
438 // ── Bounds check: log + clamp any escaped blobs ───────────────────
439 for (let i = 0; i < blobs.length; i++) {
440 const b = blobs[i]
441 const px = b.body.position.x
442 const py = b.body.position.y
443 const outX = px < iX || px > iX + iW
444 const outY = py < iY || py > iB
445 if (outX || outY) {
446 remoteLog(`[lava] blob ${i} OOB: x=${px.toFixed(0)} y=${py.toFixed(0)} vy=${b.body.velocity.y.toFixed(2)} temp=${b.temp.toFixed(3)} — clamping`)
447 // Clamp position back inside tube
448 const clampedX = Math.max(iX + b.rBase, Math.min(iX + iW - b.rBase, px))
449 const clampedY = Math.max(iY + b.rBase, Math.min(iB - b.rBase, py))
450 Matter.Body.setPosition(b.body, { x: clampedX, y: clampedY })
451 Matter.Body.setVelocity(b.body, { x: 0, y: 0 })
452 }
453 }
454
455 // One-time diagnostic on frame 1
456 if (frameCount === 1) {
457 remoteLog(`[lava] tube: iX=${iX.toFixed(0)} iY=${iY.toFixed(0)} iW=${iW.toFixed(0)} iH=${iH.toFixed(0)} iB=${iB.toFixed(0)}`)
458 for (let i = 0; i < blobs.length; i++) {
459 const b = blobs[i]
460 remoteLog(`[lava] blob ${i}: x=${b.x.toFixed(0)} y=${b.y.toFixed(0)} r=${b.r.toFixed(0)} vy=${b.body.velocity.y.toFixed(2)}`)
461 }
462 }
463 // Per-second position summary
464 if (frameCount % 60 === 0) {
465 const summary = blobs.map((b, i) =>
466 `${i}:(${b.x.toFixed(0)},${b.y.toFixed(0)} vy=${b.body.velocity.y.toFixed(1)} t=${b.temp.toFixed(2)})`
467 ).join(' ')
468 remoteLog(`[lava] t=${t.toFixed(0)} ${summary}`)
469 }
470 frameCount++
471
472 drawBackground()
473 drawHeatSource()
474 drawHeatSink()
475 drawWater()
476 ctx.save()
477 ctx.beginPath(); rr(iX, iY, iW, iH, wallR); ctx.clip()
478 drawWax()
479 ctx.restore()
480 drawGlass()
481 t += 1 / 60
482 requestAnimationFrame(frame)
483 }
484
485 requestAnimationFrame(frame)
486}