fuzzyworld

nuts / commit

adding visual first

7bec8d9cd8698c649d56b92dd372b00c7fcf6f46 ยท Pavel Safronov ยท 2026-04-12 23:16:36 -0700 ยท parents 728d72e20eb4

adding visual first
 index.html      |   6 ++
 src/lavaLamp.ts | 253 ++++++++++++++++++++++++++++++++++++++++++++++++++++++++
 src/main.ts     |  23 +++++-
 src/style.css   |  41 +++++++++
 4 files changed, 321 insertions(+), 2 deletions(-)

diff --git a/index.html b/index.html
index 26e920d..9a72f52 100644
--- a/index.html
+++ b/index.html
@@ -113,6 +113,12 @@
         </div>
     </div>
 
+    <!-- Level switcher pill (always visible) -->
+    <nav id="level-nav">
+        <button class="level-btn" data-level="" aria-label="Nuts (default)">๐ŸŒฐ Nuts</button>
+        <button class="level-btn" data-level="lava" aria-label="Lava Lamp">๐Ÿซง Lava</button>
+    </nav>
+
     <script type="module" src="/src/main.ts"></script>
 </body>
 
diff --git a/src/lavaLamp.ts b/src/lavaLamp.ts
new file mode 100644
index 0000000..0a38701
--- /dev/null
+++ b/src/lavaLamp.ts
@@ -0,0 +1,253 @@
+// 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.
+
+export function runLavaLamp(): void {
+    // Hide all game UI so only the canvas shows
+    for (const id of ['start-overlay', 'fullscreen-overlay', 'toolbar', 'settings-panel']) {
+        const el = document.getElementById(id) as HTMLElement | null
+        if (el) { el.hidden = true; el.style.display = 'none' }
+    }
+
+    const canvas = document.getElementById('canvas') as HTMLCanvasElement
+    const dpr = window.devicePixelRatio || 1
+    const W = window.innerWidth
+    const H = window.innerHeight
+
+    canvas.width = Math.round(W * dpr)
+    canvas.height = Math.round(H * dpr)
+    canvas.style.width = `${W}px`
+    canvas.style.height = `${H}px`
+
+    const ctx = canvas.getContext('2d')!
+    ctx.scale(dpr, dpr)
+
+    // โ”€โ”€ Layout โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€
+    const cx = W / 2
+    const wallThick = 10
+    const wallR = 16     // inner corner radius
+
+    // Inner (physics / fluid) region
+    const iW = W * 0.62
+    const iH = H * 0.70
+    const iX = cx - iW / 2
+    const iY = H * 0.13
+    const iB = iY + iH      // inner bottom edge
+
+    // Outer glass tube
+    const oX = iX - wallThick
+    const oY = iY - wallThick
+    const oW = iW + wallThick * 2
+    const oH = iH + wallThick * 2
+    const oR = wallR + wallThick
+
+    // โ”€โ”€ Rounded-rect subpath helper โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€
+    // Does NOT call beginPath โ€” caller is responsible.
+    function rr(x: number, y: number, w: number, h: number, r: number): void {
+        ctx.moveTo(x + r, y)
+        ctx.lineTo(x + w - r, y)
+        ctx.arcTo(x + w, y, x + w, y + r, r)
+        ctx.lineTo(x + w, y + h - r)
+        ctx.arcTo(x + w, y + h, x + w - r, y + h, r)
+        ctx.lineTo(x + r, y + h)
+        ctx.arcTo(x, y + h, x, y + h - r, r)
+        ctx.lineTo(x, y + r)
+        ctx.arcTo(x, y, x + r, y, r)
+        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 },
+    ]
+
+    let t = 0
+
+    // โ”€โ”€ Sub-draws โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€
+
+    function drawBackground(): void {
+        ctx.fillStyle = '#0c0c14'
+        ctx.fillRect(0, 0, W, H)
+    }
+
+    function drawHeatSource(): void {
+        // Diffuse ambient glow below / around the base of the container
+        const gy = iB + wallThick
+        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)')
+        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)
+
+        // Heating element bar
+        const ew = iW * 0.68
+        const eh = 7
+        const ex = cx - ew / 2
+        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(1, '#5A2000')
+        ctx.fillStyle = eg
+        ctx.shadowColor = '#FF7020'
+        ctx.shadowBlur = 20
+        ctx.beginPath(); rr(ex, ey, ew, eh, 3); ctx.fill()
+        ctx.shadowBlur = 0
+
+        ctx.fillStyle = 'rgba(255,155,50,0.55)'
+        ctx.font = '11px ui-monospace, monospace'
+        ctx.textAlign = 'center'
+        ctx.fillText('HEAT SOURCE', cx, ey + eh + 17)
+    }
+
+    function drawHeatSink(): void {
+        // Diffuse ambient glow above the container top
+        const gy = iY - wallThick
+        const g = ctx.createRadialGradient(cx, gy, 0, cx, gy, iW * 1.0)
+        g.addColorStop(0, 'rgba(80,210,255,0.30)')
+        g.addColorStop(0.5, 'rgba(20,80,200,0.08)')
+        g.addColorStop(1, 'rgba(0,0,0,0)')
+        ctx.fillStyle = g
+        ctx.fillRect(cx - iW, gy - iW * 0.7, iW * 2, iW * 0.9)
+
+        // Cooling element bar
+        const ew = iW * 0.68
+        const eh = 7
+        const ex = cx - ew / 2
+        const ey = gy - 22
+        const eg = ctx.createLinearGradient(ex, 0, ex + ew, 0)
+        eg.addColorStop(0, '#002050')
+        eg.addColorStop(0.33, '#0088D0')
+        eg.addColorStop(0.50, '#40C8FF')
+        eg.addColorStop(0.67, '#0088D0')
+        eg.addColorStop(1, '#002050')
+        ctx.fillStyle = eg
+        ctx.shadowColor = '#30C0FF'
+        ctx.shadowBlur = 14
+        ctx.beginPath(); rr(ex, ey, ew, eh, 3); ctx.fill()
+        ctx.shadowBlur = 0
+
+        ctx.fillStyle = 'rgba(70,190,255,0.55)'
+        ctx.font = '11px ui-monospace, monospace'
+        ctx.textAlign = 'center'
+        ctx.fillText('HEAT SINK', cx, ey - 9)
+    }
+
+    function drawWater(): void {
+        ctx.save()
+        ctx.beginPath(); rr(iX, iY, iW, iH, wallR); ctx.clip()
+
+        // Vertical temperature gradient: cool-blue at top, warm-amber at bottom
+        const bg = ctx.createLinearGradient(0, iY, 0, iB)
+        bg.addColorStop(0, 'rgba(14, 40, 92, 0.88)')
+        bg.addColorStop(0.55, 'rgba(16, 46, 98, 0.88)')
+        bg.addColorStop(1, 'rgba(60, 32, 10, 0.80)')
+        ctx.fillStyle = bg
+        ctx.fillRect(iX, iY, iW, iH)
+
+        // Heat bloom rising from the bottom wall
+        const hg = ctx.createRadialGradient(cx, iB, 0, cx, iB, iW * 0.9)
+        hg.addColorStop(0, 'rgba(255, 100, 0, 0.24)')
+        hg.addColorStop(1, 'rgba(0,0,0,0)')
+        ctx.fillStyle = hg
+        ctx.fillRect(iX, iY, iW, iH)
+
+        // Cool bloom falling from the top wall
+        const cg = ctx.createRadialGradient(cx, iY, 0, cx, iY, iW * 0.9)
+        cg.addColorStop(0, 'rgba(70, 200, 255, 0.18)')
+        cg.addColorStop(1, 'rgba(0,0,0,0)')
+        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)
+        }
+
+        // Soft vertical caustic โ€” simulates a light beam through the fluid
+        const sl = ctx.createLinearGradient(iX, 0, iX + iW, 0)
+        sl.addColorStop(0, 'rgba(0,0,0,0)')
+        sl.addColorStop(0.30, 'rgba(120,180,255,0.05)')
+        sl.addColorStop(0.38, 'rgba(180,220,255,0.09)')
+        sl.addColorStop(0.46, 'rgba(120,180,255,0.05)')
+        sl.addColorStop(1, 'rgba(0,0,0,0)')
+        ctx.fillStyle = sl
+        ctx.fillRect(iX, iY, iW, iH)
+
+        ctx.restore()
+    }
+
+    function drawGlass(): void {
+        ctx.save()
+
+        // Glass wall fill โ€” evenodd hollows out the inner region
+        ctx.beginPath()
+        rr(oX, oY, oW, oH, oR)
+        rr(iX, iY, iW, iH, wallR)
+        const gf = ctx.createLinearGradient(oX, 0, oX + oW, 0)
+        gf.addColorStop(0, 'rgba(120,175,255,0.22)')
+        gf.addColorStop(0.10, 'rgba(120,175,255,0.06)')
+        gf.addColorStop(0.50, 'rgba(120,175,255,0.02)')
+        gf.addColorStop(0.90, 'rgba(120,175,255,0.06)')
+        gf.addColorStop(1, 'rgba(120,175,255,0.22)')
+        ctx.fillStyle = gf
+        ctx.fill('evenodd')
+
+        // Edge highlight stripes on the glass walls
+        ctx.save()
+        ctx.beginPath()
+        rr(oX, oY, oW, oH, oR)
+        rr(iX, iY, iW, iH, wallR)
+        ctx.clip('evenodd')
+        const hl = ctx.createLinearGradient(oX, 0, oX + oW, 0)
+        hl.addColorStop(0, 'rgba(255,255,255,0.00)')
+        hl.addColorStop(0.05, 'rgba(255,255,255,0.26)')
+        hl.addColorStop(0.15, 'rgba(255,255,255,0.00)')
+        hl.addColorStop(0.85, 'rgba(255,255,255,0.00)')
+        hl.addColorStop(0.95, 'rgba(255,255,255,0.15)')
+        hl.addColorStop(1, 'rgba(255,255,255,0.00)')
+        ctx.fillStyle = hl
+        ctx.fillRect(oX, oY, oW, oH)
+        ctx.restore()
+
+        // Outer rim stroke
+        ctx.beginPath(); rr(oX, oY, oW, oH, oR)
+        ctx.strokeStyle = 'rgba(140,195,255,0.22)'
+        ctx.lineWidth = 1.5
+        ctx.stroke()
+
+        // Inner rim stroke
+        ctx.beginPath(); rr(iX, iY, iW, iH, wallR)
+        ctx.strokeStyle = 'rgba(170,215,255,0.30)'
+        ctx.lineWidth = 1.5
+        ctx.stroke()
+
+        ctx.restore()
+    }
+
+    // โ”€โ”€ Animation loop โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€
+    function frame(): void {
+        drawBackground()
+        drawHeatSource()
+        drawHeatSink()
+        drawWater()
+        drawGlass()
+        t += 1 / 60
+        requestAnimationFrame(frame)
+    }
+
+    requestAnimationFrame(frame)
+}
diff --git a/src/main.ts b/src/main.ts
index 01f7fb8..7402746 100644
--- a/src/main.ts
+++ b/src/main.ts
@@ -13,9 +13,28 @@ import {
 } from './persistence'
 import { buildShareURL, parseShareFromURL, placeSharedNuts } from './share'
 
-// โ”€โ”€ Gallery shortcut โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€
-if (new URLSearchParams(window.location.search).has('gallery')) {
+// โ”€โ”€ Level nav (active state + navigation) โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€
+{
+    const params = new URLSearchParams(window.location.search)
+    const currentLevel = params.get('level') ?? ''
+    for (const btn of document.querySelectorAll<HTMLButtonElement>('.level-btn')) {
+        if (btn.dataset.level === currentLevel) btn.classList.add('active')
+        btn.addEventListener('click', () => {
+            const dest = new URLSearchParams(window.location.search)
+            const target = btn.dataset.level ?? ''
+            if (target) dest.set('level', target)
+            else dest.delete('level')
+            window.location.href = `${window.location.pathname}?${dest}`
+        })
+    }
+}
+
+// โ”€โ”€ Scene routing โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€
+const _scene = new URLSearchParams(window.location.search)
+if (_scene.has('gallery')) {
     import('./gallery').then(({ runGallery }) => runGallery())
+} else if (_scene.get('level') === 'lava') {
+    import('./lavaLamp').then(({ runLavaLamp }) => runLavaLamp())
 } else {
 
     // โ”€โ”€ Bootstrap โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€
diff --git a/src/style.css b/src/style.css
index 9752bdf..568541c 100644
--- a/src/style.css
+++ b/src/style.css
@@ -537,4 +537,45 @@ input[type="range"]::-moz-range-thumb {
 
 #vibration-hint.fade-out {
     opacity: 0;
+}
+
+/* โ”€โ”€ Level switcher โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€ */
+#level-nav {
+    position: fixed;
+    bottom: max(18px, env(safe-area-inset-bottom, 18px));
+    left: 50%;
+    transform: translateX(-50%);
+    display: flex;
+    gap: 4px;
+    padding: 4px;
+    background: rgba(20, 20, 20, 0.72);
+    border-radius: 99px;
+    backdrop-filter: blur(12px);
+    -webkit-backdrop-filter: blur(12px);
+    border: 1px solid rgba(255, 255, 255, 0.08);
+    z-index: 60;
+}
+
+.level-btn {
+    padding: 7px 18px;
+    border: none;
+    border-radius: 99px;
+    background: transparent;
+    color: rgba(255, 255, 255, 0.45);
+    font-size: 13px;
+    font-weight: 600;
+    letter-spacing: 0.02em;
+    cursor: pointer;
+    transition: background 0.18s, color 0.18s;
+    -webkit-tap-highlight-color: transparent;
+    white-space: nowrap;
+}
+
+.level-btn:active {
+    background: rgba(255, 255, 255, 0.10);
+}
+
+.level-btn.active {
+    background: rgba(255, 255, 255, 0.13);
+    color: #fff;
 }
\ No newline at end of file