fuzzyworld

nuts / commit

got good physics working

77b7a21813db34c526ee8efa1ae67c87bf6f94d2 · Pavel Safronov · 2026-04-13 23:21:50 -0700 · parents 7bec8d9cd869

got good physics working
 src/config.ts   |   2 +-
 src/lavaLamp.ts | 285 ++++++++++++++++++++++++++++++++++++++++++++++++++------
 2 files changed, 260 insertions(+), 27 deletions(-)

diff --git a/src/config.ts b/src/config.ts
index a31061b..da0b236 100644
--- a/src/config.ts
+++ b/src/config.ts
@@ -28,5 +28,5 @@ export const CONFIG = {
     orientationMaxTilt: 80,   // degrees before clamping
 
     // Debug
-    logToVite: false,  // POST logs to Vite dev server (/__log) — useful for phone debugging
+    logToVite: false,   // POST logs to Vite dev server (/__log) — useful for phone debugging
 } as const
diff --git a/src/lavaLamp.ts b/src/lavaLamp.ts
index 0a38701..7771d22 100644
--- a/src/lavaLamp.ts
+++ b/src/lavaLamp.ts
@@ -1,8 +1,21 @@
 // src/lavaLamp.ts
 //
 // Scene: lava lamp — a sealed glass column filled with water, with a heat
-// source at the bottom and a heat sink at the top.  Purely visual for now;
-// physics and wax blobs come next.
+// source at the bottom and a heat sink at the top.
+
+import Matter from 'matter-js'
+import { CONFIG } from './config'
+
+const { Engine, Bodies, Body, Composite } = Matter
+
+function remoteLog(msg: string): void {
+    if (!CONFIG.logToVite) return
+    fetch('/__log', {
+        method: 'POST',
+        headers: { 'Content-Type': 'application/json' },
+        body: JSON.stringify({ level: 'info', msg }),
+    }).catch(() => { })
+}
 
 export function runLavaLamp(): void {
     // Hide all game UI so only the canvas shows
@@ -58,13 +71,126 @@ export function runLavaLamp(): void {
         ctx.closePath()
     }
 
-    // ── Convection tracers (slow blobs that will become lava wax later) ───
-    const tracers = [
-        { x: cx - iW * 0.18, phase: 0, speed: 0.14 },
-        { x: cx + iW * 0.20, phase: Math.PI, speed: 0.12 },
-        { x: cx - iW * 0.04, phase: Math.PI * 0.6, speed: 0.17 },
+    // ── Physics engine ────────────────────────────────────────────────────
+    // Gravity is deliberately small — blobs move slowly like real wax.
+    // Terminal velocity ≈ g × (LIFT−1) / frictionAir ≈ 30 px/s → ~18s to cross the tube.
+    const engine = Engine.create({ gravity: { x: 0, y: 0.04 } })
+
+    // Static tube walls — thin rectangles just inside the glass
+    const WT = 30  // wall thickness (physics only, not visual)
+    const tubeWalls = [
+        Bodies.rectangle(cx, iY - WT / 2, iW + WT * 2, WT, { isStatic: true, label: 'wall' }), // top
+        Bodies.rectangle(cx, iB + WT / 2, iW + WT * 2, WT, { isStatic: true, label: 'wall' }), // bottom
+        Bodies.rectangle(iX - WT / 2, iY + iH / 2, WT, iH, { isStatic: true, label: 'wall' }), // left
+        Bodies.rectangle(iX + iW + WT / 2, iY + iH / 2, WT, iH, { isStatic: true, label: 'wall' }), // right
+    ]
+    Composite.add(engine.world, tubeWalls)
+
+    // ── Wax blobs ─────────────────────────────────────────────────────────
+    interface WaxBlob { body: Matter.Body; rBase: number; x: number; y: number; r: number; temp: number }
+
+    const blobDefs = [
+        { xf: -0.29, rf: 0.110 },
+        { xf: -0.07, rf: 0.145 },
+        { xf: +0.13, rf: 0.120 },
+        { xf: +0.30, rf: 0.090 },
+        { xf: -0.18, rf: 0.088 },
+        { xf: +0.05, rf: 0.100 },
     ]
 
+    const blobs: WaxBlob[] = blobDefs.map((d, i) => {
+        const r = iW * d.rf
+        const x = cx + iW * d.xf
+        const y = iB - r - i * r * 0.3  // stagger so they don't spawn overlapping
+        const body = Bodies.circle(x, y, r, {
+            density: 0.001,
+            restitution: 0.01,
+            friction: 0.05,
+            frictionAir: 0.10,  // higher damping — terminal velocity ≈ 30 px/s
+            label: 'wax',
+        })
+        return { body, rBase: r, x, y, r, temp: 1.0 }  // start hot (spawned at bottom)
+    })
+    Composite.add(engine.world, blobs.map(b => b.body))
+
+    // ── Buoyancy model ────────────────────────────────────────────────────
+    // temp = 1 at bottom (heat source), 0 at top (heat sink)
+    // Fy = mass * g * (1 - LIFT * temp)
+    //   temp = 0:        Fy = +mass*g  → gravity wins, blob sinks
+    //   temp = 1/LIFT:   Fy = 0        → neutral buoyancy
+    //   temp = 1:        Fy = mass*g*(1-LIFT) → net upward, blob rises
+    // Terminal velocity ≈ (g * |1 - LIFT*temp|) / frictionAir  (~1-2 px/frame)
+    const LIFT = 2.5   // 1/LIFT = 0.4 = neutral depth (40% from bottom)
+
+    // THERMAL_RATE: how quickly a blob's temperature approaches ambient.
+    // 0.0006/frame ≈ 28s time constant at 60fps — blobs carry heat state a long time,
+    // overshooting the neutral zone and reaching top/bottom before reversing.
+    const THERMAL_RATE = 0.0006
+
+    function applyBuoyancy(): void {
+        const g = engine.gravity.y
+        for (const blob of blobs) {
+            // Ambient temperature at current position (1=bottom, 0=top)
+            const ambient = Math.max(0, Math.min(1, (blob.body.position.y - iY) / iH))
+            // Thermal inertia: blob temp lags ambient
+            blob.temp += (ambient - blob.temp) * THERMAL_RATE
+            // Buoyancy force using blob's own thermal state
+            const Fy = blob.body.mass * g * (1 - LIFT * blob.temp)
+            Body.applyForce(blob.body, blob.body.position, { x: 0, y: Fy })
+        }
+    }
+
+    // Sync display properties (x, y, visual radius) from physics bodies
+    function syncBlobs(): void {
+        for (const blob of blobs) {
+            blob.x = blob.body.position.x
+            blob.y = blob.body.position.y
+            // Visual heat: swell when blob is hot (temp near 1)
+            blob.r = blob.rBase * (1 + Math.max(0, blob.temp * 2 - 1) * 0.14)
+        }
+    }
+
+    // ── Heat bursts (tap-to-heat) ────────────────────────────────────────
+    interface HeatBurst { age: number }
+    const BURST_DURATION = 1.2
+    const heatBursts: HeatBurst[] = []
+    // TAP_FORCE: upward velocity impulse applied directly (px/frame)
+    const TAP_IMPULSE_VY = -0.6  // px/frame upward kick (gentle, matching slow physics)
+
+    function triggerHeat(): void {
+        remoteLog(`[lava] tap at t=${t.toFixed(2)}`)
+        heatBursts.push({ age: 0 })
+        let nudged = 0
+        for (const blob of blobs) {
+            if (blob.temp > 0.30) {  // blobs that still have heat get an upward kick
+                Body.setVelocity(blob.body, {
+                    x: blob.body.velocity.x,
+                    y: blob.body.velocity.y + TAP_IMPULSE_VY * blob.temp,
+                })
+                // Also heat the blob up so buoyancy kicks in
+                blob.temp = Math.min(1, blob.temp + 0.10)
+                nudged++
+            }
+        }
+        remoteLog(`[lava] nudged ${nudged} blobs`)
+    }
+
+    canvas.addEventListener('pointerdown', (e: PointerEvent) => {
+        const rect = canvas.getBoundingClientRect()
+        const tapY = (e.clientY - rect.top)
+        if (tapY > iY + iH * 0.6) triggerHeat()
+    })
+
+    const wW = Math.round(iW)
+    const wH = Math.round(iH)
+    const waxOff = Object.assign(document.createElement('canvas'), { width: wW, height: wH })
+    const waxFinal = Object.assign(document.createElement('canvas'), { width: wW, height: wH })
+    const wOff = waxOff.getContext('2d')!
+    const wFinal = waxFinal.getContext('2d')!
+
+    const BLOB_BLUR = iW * 0.038   // px — controls merge radius
+    const BLOB_CONTRAST = 17           // higher → sharper edges
+
     let t = 0
 
     // ── Sub-draws ─────────────────────────────────────────────────────────
@@ -75,11 +201,19 @@ export function runLavaLamp(): void {
     }
 
     function drawHeatSource(): void {
-        // Diffuse ambient glow below / around the base of the container
         const gy = iB + wallThick
+
+        // Extra flash when a heat burst is active
+        const burstIntensity = heatBursts.reduce((s, b) => {
+            const fade = 1 - b.age / BURST_DURATION
+            return s + fade * fade   // quadratic fade
+        }, 0)
+
+        // Diffuse ambient glow below / around the base of the container
         const g = ctx.createRadialGradient(cx, gy, 0, cx, gy, iW * 1.1)
-        g.addColorStop(0, 'rgba(255,120,10,0.40)')
-        g.addColorStop(0.5, 'rgba(200,50,0,0.12)')
+        const baseAlpha = 0.40 + burstIntensity * 0.35
+        g.addColorStop(0, `rgba(255,120,10,${baseAlpha.toFixed(2)})`)
+        g.addColorStop(0.5, `rgba(200,50,0,${(0.12 + burstIntensity * 0.12).toFixed(2)})`)
         g.addColorStop(1, 'rgba(0,0,0,0)')
         ctx.fillStyle = g
         ctx.fillRect(cx - iW * 1.1, gy - iW * 0.25, iW * 2.2, iW * 1.5)
@@ -91,13 +225,13 @@ export function runLavaLamp(): void {
         const ey = gy + 14
         const eg = ctx.createLinearGradient(ex, 0, ex + ew, 0)
         eg.addColorStop(0, '#5A2000')
-        eg.addColorStop(0.33, '#EE5510')
-        eg.addColorStop(0.50, '#FFAA30')
-        eg.addColorStop(0.67, '#EE5510')
+        eg.addColorStop(0.33, burstIntensity > 0.1 ? '#FF8820' : '#EE5510')
+        eg.addColorStop(0.50, burstIntensity > 0.1 ? '#FFDD60' : '#FFAA30')
+        eg.addColorStop(0.67, burstIntensity > 0.1 ? '#FF8820' : '#EE5510')
         eg.addColorStop(1, '#5A2000')
         ctx.fillStyle = eg
-        ctx.shadowColor = '#FF7020'
-        ctx.shadowBlur = 20
+        ctx.shadowColor = burstIntensity > 0.1 ? '#FFBB40' : '#FF7020'
+        ctx.shadowBlur = 20 + burstIntensity * 30
         ctx.beginPath(); rr(ex, ey, ew, eh, 3); ctx.fill()
         ctx.shadowBlur = 0
 
@@ -166,16 +300,12 @@ export function runLavaLamp(): void {
         ctx.fillStyle = cg
         ctx.fillRect(iX, iY, iW, iH)
 
-        // Convection tracers — glowing blobs drifting up and down
-        for (const tr of tracers) {
-            const y = iY + iH * 0.5 + Math.sin(t * tr.speed * Math.PI * 2 + tr.phase) * (iH * 0.30)
-            const r = iW * 0.30
-            const tg = ctx.createRadialGradient(tr.x, y, 0, tr.x, y, r)
-            tg.addColorStop(0, 'rgba(90, 150, 230, 0.14)')
-            tg.addColorStop(1, 'rgba(0,0,0,0)')
-            ctx.fillStyle = tg
-            ctx.fillRect(iX, iY, iW, iH)
-        }
+        // Convection ripple — faint water-flow tint
+        const ripple = ctx.createRadialGradient(cx, iB - iH * 0.25, 0, cx, iB - iH * 0.25, iW * 0.7)
+        ripple.addColorStop(0, 'rgba(140,100,60,0.07)')
+        ripple.addColorStop(1, 'rgba(0,0,0,0)')
+        ctx.fillStyle = ripple
+        ctx.fillRect(iX, iY, iW, iH)
 
         // Soft vertical caustic — simulates a light beam through the fluid
         const sl = ctx.createLinearGradient(iX, 0, iX + iW, 0)
@@ -238,12 +368,115 @@ export function runLavaLamp(): void {
         ctx.restore()
     }
 
-    // ── Animation loop ────────────────────────────────────────────────────
+    function drawWax(): void {
+        // 1. Draw white circles on waxOff (blob shapes in alpha channel)
+        wOff.clearRect(0, 0, wW, wH)
+        wOff.fillStyle = '#fff'
+        for (const b of blobs) {
+            wOff.beginPath()
+            wOff.arc(b.x - iX, b.y - iY, b.r, 0, Math.PI * 2)
+            wOff.fill()
+        }
+
+        // 2. Gradient shifts warmer when blobs are hotter (using thermal state)
+        const avgTemp = blobs.reduce((s, b) => s + b.temp, 0) / blobs.length
+        const topColor = avgTemp > 0.5 ? '#FFBB55' : '#FF9955'
+        const midColor = avgTemp > 0.5 ? '#FF5511' : '#EE3300'
+
+        wFinal.clearRect(0, 0, wW, wH)
+        wFinal.globalCompositeOperation = 'source-over'
+        const grad = wFinal.createLinearGradient(0, 0, 0, wH)
+        grad.addColorStop(0.0, topColor)
+        grad.addColorStop(0.45, midColor)
+        grad.addColorStop(1.0, '#991100')
+        wFinal.fillStyle = grad
+        wFinal.fillRect(0, 0, wW, wH)
+
+        // destination-in + filter: keep gradient only inside merged blob silhouette
+        wFinal.filter = `blur(${BLOB_BLUR}px) contrast(${BLOB_CONTRAST})`
+        wFinal.globalCompositeOperation = 'destination-in'
+        wFinal.drawImage(waxOff, 0, 0)
+        wFinal.filter = 'none'
+        wFinal.globalCompositeOperation = 'source-over'
+
+        // 3. Composite wax layer onto main canvas (caller has set fluid clip)
+        ctx.drawImage(waxFinal, iX, iY)
+
+        // 4. Soft specular highlights — unfiltered, one per blob
+        for (const b of blobs) {
+            const hx = b.x - b.r * 0.22
+            const hy = b.y - b.r * 0.30
+            const hg = ctx.createRadialGradient(hx, hy, 0, hx, hy, b.r * 0.52)
+            hg.addColorStop(0, 'rgba(255,200,160,0.38)')
+            hg.addColorStop(0.5, 'rgba(255,150,80,0.10)')
+            hg.addColorStop(1, 'rgba(255,0,0,0)')
+            ctx.fillStyle = hg
+            ctx.fillRect(b.x - b.r, b.y - b.r, b.r * 2, b.r * 2)
+        }
+
+        // 5. Subtle inner glow at base of blob mass — heat absorbed from element
+        const baseGlow = ctx.createRadialGradient(cx, iB, 0, cx, iB, iW * 0.55)
+        baseGlow.addColorStop(0, 'rgba(255,60,0,0.18)')
+        baseGlow.addColorStop(1, 'rgba(0,0,0,0)')
+        ctx.fillStyle = baseGlow
+        ctx.fillRect(iX, iY, iW, iH)
+    }
+
+    // ── Animation loop ────────────────────────────────────────────────────────
+    let frameCount = 0
     function frame(): void {
+        // Age and prune heat bursts
+        for (const b of heatBursts) b.age += 1 / 60
+        for (let i = heatBursts.length - 1; i >= 0; i--) {
+            if (heatBursts[i].age >= BURST_DURATION) heatBursts.splice(i, 1)
+        }
+        // Physics step: buoyancy forces + engine update
+        applyBuoyancy()
+        Engine.update(engine, 1000 / 60)
+        syncBlobs()
+
+        // ── Bounds check: log + clamp any escaped blobs ───────────────────
+        for (let i = 0; i < blobs.length; i++) {
+            const b = blobs[i]
+            const px = b.body.position.x
+            const py = b.body.position.y
+            const outX = px < iX || px > iX + iW
+            const outY = py < iY || py > iB
+            if (outX || outY) {
+                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`)
+                // Clamp position back inside tube
+                const clampedX = Math.max(iX + b.rBase, Math.min(iX + iW - b.rBase, px))
+                const clampedY = Math.max(iY + b.rBase, Math.min(iB - b.rBase, py))
+                Matter.Body.setPosition(b.body, { x: clampedX, y: clampedY })
+                Matter.Body.setVelocity(b.body, { x: 0, y: 0 })
+            }
+        }
+
+        // One-time diagnostic on frame 1
+        if (frameCount === 1) {
+            remoteLog(`[lava] tube: iX=${iX.toFixed(0)} iY=${iY.toFixed(0)} iW=${iW.toFixed(0)} iH=${iH.toFixed(0)} iB=${iB.toFixed(0)}`)
+            for (let i = 0; i < blobs.length; i++) {
+                const b = blobs[i]
+                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)}`)
+            }
+        }
+        // Per-second position summary
+        if (frameCount % 60 === 0) {
+            const summary = blobs.map((b, i) =>
+                `${i}:(${b.x.toFixed(0)},${b.y.toFixed(0)} vy=${b.body.velocity.y.toFixed(1)} t=${b.temp.toFixed(2)})`
+            ).join(' ')
+            remoteLog(`[lava] t=${t.toFixed(0)} ${summary}`)
+        }
+        frameCount++
+
         drawBackground()
         drawHeatSource()
         drawHeatSink()
         drawWater()
+        ctx.save()
+        ctx.beginPath(); rr(iX, iY, iW, iH, wallR); ctx.clip()
+        drawWax()
+        ctx.restore()
         drawGlass()
         t += 1 / 60
         requestAnimationFrame(frame)