fuzzyworld

nuts / commit

added qr code

b008eb4c45d923dd7643c250c35c6e79678ed9df · Pavel Safronov · 2026-04-12 00:32:12 -0700 · parents f1262fd9acd2

added qr code
 src/main.ts     | 30 +++++++++++++++++++++++++----
 src/renderer.ts | 60 +++++++++++++++++++++++++++++++++++++++++++++++++++------
 src/share.ts    | 55 +++++++++++++++++++++++++++++++++++++++++++++-------
 3 files changed, 128 insertions(+), 17 deletions(-)

diff --git a/src/main.ts b/src/main.ts
index b95c2a7..a0f6725 100644
--- a/src/main.ts
+++ b/src/main.ts
@@ -62,9 +62,11 @@ async function main(): Promise<void> {
     }
 
     // ── Restore nut positions (share URL takes priority over snapshot) ────────
-    const sharedNuts = parseShareFromURL()
-    if (sharedNuts && sharedNuts.length > 0) {
+    const parsedShare = parseShareFromURL()
+    if (parsedShare && (parsedShare.nuts.length > 0 || parsedShare.nutRadius !== null)) {
         clearSnapshot()
+        if (parsedShare.nutRadius !== null) currentNutRadius = parsedShare.nutRadius
+        const sharedNuts = parsedShare.nuts
         // hasSpace not available yet — define inline for initial placement
         const placed: Array<{ x: number; y: number; r: number }> = []
         function initialHasSpace(x: number, y: number, r: number): boolean {
@@ -112,7 +114,7 @@ async function main(): Promise<void> {
             tapsLocked = locked
             saveSettings({ nutRadius: currentNutRadius, locked: tapsLocked })
         },
-        getShareURL: () => buildShareURL(physics.nuts),
+        getShareURL: () => buildShareURL(physics.nuts, currentNutRadius),
     })
 
     // ── Helpers ───────────────────────────────────────────────────────────────
@@ -136,6 +138,13 @@ async function main(): Promise<void> {
         }
     }
 
+    // ── Idle detection ────────────────────────────────────────────────────────
+    const IDLE_DELAY_MS = 5000   // wait before fading
+    const IDLE_FADE_MS = 1000   // fade duration
+    let lastActivityTime = performance.now()
+
+    function markActive(): void { lastActivityTime = performance.now() }
+
     // ── Pointer: tap/drag to add; tap/drag-through to erase ─────────────────
     // Mode is decided on pointerdown: if we hit an existing item we erase,
     // otherwise we add. The mode persists until pointerup.
@@ -143,6 +152,7 @@ async function main(): Promise<void> {
     let eraseMode = false
 
     canvas.addEventListener('pointerdown', (e: PointerEvent) => {
+        markActive()
         if (tapsLocked) return
         pointerDown = true
         canvas.setPointerCapture(e.pointerId)
@@ -156,6 +166,7 @@ async function main(): Promise<void> {
 
     canvas.addEventListener('pointermove', (e: PointerEvent) => {
         if (!pointerDown) return
+        markActive()
         const rect = canvas.getBoundingClientRect()
         const x = e.clientX - rect.left
         const y = e.clientY - rect.top
@@ -266,8 +277,19 @@ async function main(): Promise<void> {
         lastTimestamp = timestamp
 
         physics.step(delta)
+
+        // Any body moving above threshold resets idle timer
+        const SPEED_THRESHOLD = 0.5
+        for (const { body } of physics.nuts) {
+            if (body.speed > SPEED_THRESHOLD) { markActive(); break }
+        }
+
+        const idleElapsed = timestamp - lastActivityTime - IDLE_DELAY_MS
+        const idleProgress = Math.max(0, Math.min(1, idleElapsed / IDLE_FADE_MS))
+
         const g = physics.engine.gravity
-        renderer.render(physics.nuts, Math.atan2(g.y, g.x) - Math.PI / 2)
+        renderer.setShareURL(buildShareURL(physics.nuts, currentNutRadius))
+        renderer.render(physics.nuts, Math.atan2(g.y, g.x) - Math.PI / 2, idleProgress)
 
         // Save snapshot every ~3 s
         if (Math.floor(timestamp / 3000) !== Math.floor((timestamp - delta) / 3000)) {
diff --git a/src/renderer.ts b/src/renderer.ts
index ce12b6c..505b2ac 100644
--- a/src/renderer.ts
+++ b/src/renderer.ts
@@ -1,5 +1,6 @@
 import type { NutBody } from './physics'
 import { CONFIG } from './config'
+import QRCode from 'qrcode'
 
 // ── Nut palette – warm tones ───────────────────────────────────────────────
 interface NutColor { hi: string; mid: string; shadow: string; rim: string }
@@ -292,15 +293,17 @@ function drawBalloon(
     )
     ctx.strokeStyle = c.string
     ctx.lineWidth = Math.max(1, r * 0.025)
-    ctx.globalAlpha = 0.75
+    ctx.save()
+    ctx.globalAlpha *= 0.75
     ctx.stroke()
-    ctx.globalAlpha = 1
+    ctx.restore()
 }
 
 // ── Renderer ───────────────────────────────────────────────────────────────
 
 export interface Renderer {
-    render: (nuts: NutBody[], gravityAngle: number) => void
+    render: (nuts: NutBody[], gravityAngle: number, idleProgress: number) => void
+    setShareURL: (url: string) => void
     resize: (width: number, height: number) => void
     flash: () => void
 }
@@ -308,12 +311,41 @@ export interface Renderer {
 export function createRenderer(canvas: HTMLCanvasElement): Renderer {
     const ctx = canvas.getContext('2d')!
     let flashAlpha = 0
+    let qrBitmap: HTMLCanvasElement | null = null
+    let qrPending = false
+    let lastURL = ''
+
+    async function regenerateQR(url: string): Promise<void> {
+        const size = Math.min(canvas.width, canvas.height)
+        const qrCanvas = document.createElement('canvas')
+        await QRCode.toCanvas(qrCanvas, url, {
+            width: size,
+            margin: 2,
+            color: { dark: '#ffffff', light: '#00000000' },
+        })
+        qrBitmap = qrCanvas
+        qrPending = false
+    }
+
+    function setShareURL(url: string): void {
+        if (url === lastURL) return
+        lastURL = url
+        if (!url) {
+            qrBitmap = null
+            qrPending = false
+            return
+        }
+        if (!qrPending) {
+            qrPending = true
+            regenerateQR(url)
+        }
+    }
 
     function flash(): void {
         flashAlpha = 0.35
     }
 
-    function render(nuts: NutBody[], gravityAngle: number): void {
+    function render(nuts: NutBody[], gravityAngle: number, idleProgress: number): void {
         const w = canvas.width
         const h = canvas.height
 
@@ -321,6 +353,21 @@ export function createRenderer(canvas: HTMLCanvasElement): Renderer {
         ctx.fillStyle = '#111111'
         ctx.fillRect(0, 0, w, h)
 
+        // QR watermark — faint normally, prominent when idle
+        if (qrBitmap) {
+            const size = Math.min(w, h)
+            const dx = (w - size) / 2
+            const dy = (h - size) / 2
+            ctx.save()
+            ctx.globalAlpha = 0.07 + 0.78 * idleProgress
+            ctx.drawImage(qrBitmap, dx, dy, size, size)
+            ctx.restore()
+        }
+
+        // Items — fade out when idle
+        ctx.save()
+        ctx.globalAlpha = 1 - 0.92 * idleProgress
+
         // Nuts, gems & balloons
         for (const { body, radius, colorIndex, kind, gemVariant } of nuts) {
             const { position, angle } = body
@@ -339,9 +386,10 @@ export function createRenderer(canvas: HTMLCanvasElement): Renderer {
                 ctx.drawImage(sprite, -sprite.width / 2, -sprite.height / 2, sprite.width, sprite.height)
                 ctx.restore()
             }
-            ctx.restore()
         }
 
+        ctx.restore()  // end items fade group
+
         // Flash overlay (fallback haptic cue for Firefox / no-vibration devices)
         if (flashAlpha > 0) {
             ctx.fillStyle = `rgba(255,255,255,${flashAlpha.toFixed(3)})`
@@ -355,5 +403,5 @@ export function createRenderer(canvas: HTMLCanvasElement): Renderer {
         canvas.height = height
     }
 
-    return { render, resize, flash }
+    return { render, setShareURL, resize, flash }
 }
diff --git a/src/share.ts b/src/share.ts
index 8dc5aa3..62b9dcf 100644
--- a/src/share.ts
+++ b/src/share.ts
@@ -1,9 +1,11 @@
 import type { NutBody } from './physics'
 
 // ── Encoding ───────────────────────────────────────────────────────────────
+// Settings prefix:  "z<nutRadius>"  e.g. "z130"
 // Nuts:  "r<radius>c<colorIndex>"  e.g. "r130c2"
 // Gems:  "r<radius>g<gemVariant>"  e.g. "r80g1"
-// Comma-separated, e.g. "r130c0,r80g1,r60g3"
+// Balloons: "r<radius>b<colorIndex>"  e.g. "r80b3"
+// Comma-separated after settings: "z130,r130c0,r80g1"
 
 export interface SharedNut {
     radius: number
@@ -12,6 +14,11 @@ export interface SharedNut {
     gemVariant: number
 }
 
+export interface ParsedShare {
+    nuts: SharedNut[]
+    nutRadius: number | null
+}
+
 export function encodeNuts(nuts: NutBody[]): string {
     return nuts.map(n => {
         if (n.kind === 'gem') return `r${Math.round(n.radius)}g${n.gemVariant}`
@@ -38,20 +45,54 @@ export function decodeNuts(encoded: string): SharedNut[] | null {
 }
 
 const BASE_URL = 'https://pavelsafronov.gitlab.io/nuts/'
+const MAX_URL_LENGTH = 1800  // comfortable QR limit at medium error-correction
 
-export function buildShareURL(nuts: NutBody[]): string {
-    const encoded = encodeNuts(nuts)
-    return `${BASE_URL}#s=${encodeURIComponent(encoded)}`
+/**
+ * Always returns a URL. Encodes the nut size setting first, then fits as many
+ * items as possible within MAX_URL_LENGTH (truncating from the end).
+ */
+export function buildShareURL(nuts: NutBody[], nutRadius: number): string {
+    const settingsPart = `z${Math.round(nutRadius)}`
+    const base = `${BASE_URL}#s=${encodeURIComponent(settingsPart)}`
+    // Fit items greedily
+    const parts: string[] = []
+    for (const n of nuts) {
+        const token = n.kind === 'gem'
+            ? `r${Math.round(n.radius)}g${n.gemVariant}`
+            : n.kind === 'balloon'
+                ? `r${Math.round(n.radius)}b${n.colorIndex % 7}`
+                : `r${Math.round(n.radius)}c${n.colorIndex % 5}`
+        const candidate = base + encodeURIComponent(',' + [...parts, token].join(','))
+        if (candidate.length > MAX_URL_LENGTH) break
+        parts.push(token)
+    }
+    const payload = parts.length > 0
+        ? `${settingsPart},${parts.join(',')}`
+        : settingsPart
+    return `${BASE_URL}#s=${encodeURIComponent(payload)}`
 }
 
 /** Parse share data from the current URL hash. Returns null if none present. */
-export function parseShareFromURL(): SharedNut[] | null {
+export function parseShareFromURL(): ParsedShare | null {
     const m = location.hash.match(/[#&]s=([^&]*)/)
     if (!m) return null
-    const parsed = decodeNuts(decodeURIComponent(m[1]))
+    const raw = decodeURIComponent(m[1])
     // Clear the hash so refreshing doesn't re-import
     history.replaceState(null, '', location.pathname + location.search)
-    return parsed
+    try {
+        const parts = raw.split(',')
+        let nutRadius: number | null = null
+        const nutParts: string[] = []
+        for (const part of parts) {
+            const setting = part.match(/^z(\d+)$/)
+            if (setting) { nutRadius = parseInt(setting[1], 10); continue }
+            nutParts.push(part)
+        }
+        const nuts = nutParts.length > 0 ? decodeNuts(nutParts.join(',')) ?? [] : []
+        return { nuts, nutRadius }
+    } catch {
+        return null
+    }
 }
 
 /** Place shared nuts/gems into the world with a loose random packing. */