nuts / commit
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)