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