fuzzyworld

nuts / commit

ui changes, added rocking and rotating mode, remove qr code

937f9abca8e13e19ec53ebbccd894fa9ec641e68 · Pavel Safronov · 2026-04-12 13:00:27 -0700 · parents 4ea6baf72672

ui changes, added rocking and rotating mode, remove qr code
 index.html         |  3 +++
 src/config.ts      |  2 +-
 src/main.ts        | 65 ++++++++++++++++++++++++++++++++++--------------------
 src/persistence.ts |  2 ++
 src/renderer.ts    | 63 +++++++++++++++++++++++++++++++++++++---------------
 src/ui.ts          | 29 ++++++++++++++++++++++++
 6 files changed, 121 insertions(+), 43 deletions(-)

diff --git a/index.html b/index.html
index 7255daa..09fe973 100644
--- a/index.html
+++ b/index.html
@@ -58,6 +58,9 @@
         <!-- Freeze toggle -->
         <button id="freeze-btn" class="toolbar-btn" aria-label="Freeze">⏸</button>
 
+        <!-- Gravity mode: device / tumbler / rock -->
+        <button id="gravity-btn" class="toolbar-btn" aria-label="Gravity mode">📱</button>
+
         <!-- Clear -->
         <button id="clear-top-btn" class="toolbar-btn" aria-label="Clear">✕</button>
 
diff --git a/src/config.ts b/src/config.ts
index 581613a..2f5a9c4 100644
--- a/src/config.ts
+++ b/src/config.ts
@@ -1,7 +1,7 @@
 export const CONFIG = {
     // Nuts
     nutBaseRadius: 130,       // px – semi-major axis (runtime overridden by size slider)
-    nutMinRadius: 10,         // px – slider minimum
+    nutMinRadius: 4,          // px – slider minimum
     nutMaxRadius: 180,        // px – slider maximum
     nutAspectRatio: 1.75,     // width:height – peanut-like elongation
     nutRadiusVariance: 0.12,  // ±12% size variance
diff --git a/src/main.ts b/src/main.ts
index 19a0c42..de651c7 100644
--- a/src/main.ts
+++ b/src/main.ts
@@ -2,8 +2,8 @@ import { CONFIG } from './config'
 import { createPhysicsWorld } from './physics'
 import { createRenderer } from './renderer'
 import { requestMotionPermission, initMotion } from './motion'
-import { initUI, removeStartOverlay, type ItemKind } from './ui'
-import { setFlashFallback, unlockVibration } from './vibration'
+import { initUI, removeStartOverlay, type ItemKind, type GravityMode } from './ui'
+import { setFlashFallback, unlockVibration, setVibrateEnabled } from './vibration'
 import { dbg, dbgError } from './debug'
 import { checkCompat, showUnsupportedWall } from './compat'
 import {
@@ -53,11 +53,20 @@ async function main(): Promise<void> {
 
     let currentNutRadius: number = CONFIG.nutBaseRadius
     let tapsLocked = false
+    let gravityMode: GravityMode = 'device'
+    let vibrateEnabled = true
 
     // ── Restore persisted settings ────────────────────────────────────────────
     const savedSettings = loadSettings()
     if (savedSettings) {
         currentNutRadius = savedSettings.nutRadius
+        if (savedSettings.gravityMode === 'tumbler' || savedSettings.gravityMode === 'rock') {
+            gravityMode = savedSettings.gravityMode
+        }
+        if (savedSettings.vibrateEnabled === false) {
+            vibrateEnabled = false
+            setVibrateEnabled(false)
+        }
     }
 
     // ── Restore nut positions (share URL takes priority over snapshot) ────────
@@ -103,9 +112,11 @@ async function main(): Promise<void> {
     initUI({
         initialRadius: currentNutRadius,
         initialLocked: tapsLocked,
+        initialGravityMode: gravityMode,
+        initialVibrateEnabled: vibrateEnabled,
         onSizeChange: (radius) => {
             currentNutRadius = radius
-            saveSettings({ nutRadius: currentNutRadius })
+            saveSettings({ nutRadius: currentNutRadius, gravityMode, vibrateEnabled })
         },
         onClear: () => {
             physics.clearNuts()
@@ -113,9 +124,17 @@ async function main(): Promise<void> {
         },
         onLockChange: (locked) => {
             tapsLocked = locked
-            saveSettings({ nutRadius: currentNutRadius })
+            saveSettings({ nutRadius: currentNutRadius, gravityMode, vibrateEnabled })
         },
         onKindChange: (kind) => { currentKind = kind },
+        onGravityModeChange: (mode) => {
+            gravityMode = mode
+            saveSettings({ nutRadius: currentNutRadius, gravityMode, vibrateEnabled })
+        },
+        onVibrateChange: (enabled) => {
+            vibrateEnabled = enabled
+            saveSettings({ nutRadius: currentNutRadius, gravityMode, vibrateEnabled })
+        },
         getShareURL: () => buildShareURL(physics.nuts, currentNutRadius),
     })
 
@@ -147,12 +166,6 @@ 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,
@@ -161,7 +174,6 @@ async function main(): Promise<void> {
     let eraseMode = false
 
     canvas.addEventListener('pointerdown', (e: PointerEvent) => {
-        markActive()
         if (tapsLocked) return
         pointerDown = true
         try { canvas.setPointerCapture(e.pointerId) } catch { /* ignore */ }
@@ -174,7 +186,6 @@ 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
@@ -206,12 +217,16 @@ async function main(): Promise<void> {
         if (motionEnabled) return
         motionEnabled = true
         dbg('startMotion, vibrate?', 'vibrate' in navigator, 'protocol:', location.protocol)
+        let lastGx = 0, lastGy = 0
         initMotion({
             onShake: (intensity) => {
                 physics.applyShakeForce(intensity)
             },
             onOrientation: (gx, gy) => {
-                physics.setGravity(gx, gy)
+                if (gravityMode === 'device') {
+                    physics.setGravity(gx, gy)
+                    lastGx = gx; lastGy = gy
+                }
             },
         })
     }
@@ -284,20 +299,22 @@ async function main(): Promise<void> {
         const delta = lastTimestamp === 0 ? 16.67 : timestamp - lastTimestamp
         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 }
+        // ── Auto-gravity modes ───────────────────────────────────────────────
+        if (gravityMode === 'tumbler') {
+            // Full 360° rotation at ~18 seconds per revolution
+            const angle = (timestamp / 1000) * Math.PI * 2
+            physics.setGravity(Math.sin(angle), Math.cos(angle))
+        } else if (gravityMode === 'rock') {
+            // Oscillate ±45° around landscape-down (gravity pointing right in portrait)
+            const centerAngle = Math.PI / 2  // landscape "down" = 90° from vertical
+            const angle = centerAngle + Math.sin((timestamp / 30000) * Math.PI * 2) * (Math.PI / 4)
+            physics.setGravity(Math.sin(angle), Math.cos(angle))
         }
 
-        const idleElapsed = timestamp - lastActivityTime - IDLE_DELAY_MS
-        const idleProgress = Math.max(0, Math.min(1, idleElapsed / IDLE_FADE_MS))
+        physics.step(delta)
 
         const g = physics.engine.gravity
-        renderer.setShareURL(buildShareURL(physics.nuts, currentNutRadius))
-        renderer.render(physics.nuts, Math.atan2(g.y, g.x) - Math.PI / 2, idleProgress)
+        renderer.render(physics.nuts, Math.atan2(g.y, g.x) - Math.PI / 2, gravityMode !== 'device')
 
         // Save snapshot every ~3 s
         if (Math.floor(timestamp / 3000) !== Math.floor((timestamp - delta) / 3000)) {
@@ -310,7 +327,7 @@ async function main(): Promise<void> {
     // Also save on tab close / navigation
     window.addEventListener('pagehide', () => {
         saveSnapshot(physics.nuts, cssWidth, cssHeight)
-        saveSettings({ nutRadius: currentNutRadius })
+        saveSettings({ nutRadius: currentNutRadius, gravityMode, vibrateEnabled })
     })
 }
 
diff --git a/src/persistence.ts b/src/persistence.ts
index 7195806..24edf50 100644
--- a/src/persistence.ts
+++ b/src/persistence.ts
@@ -10,6 +10,8 @@ const KEYS = {
 export interface PersistedSettings {
     v: number
     nutRadius: number
+    gravityMode?: string
+    vibrateEnabled?: boolean
 }
 
 export function loadSettings(): PersistedSettings | null {
diff --git a/src/renderer.ts b/src/renderer.ts
index e37b386..041837c 100644
--- a/src/renderer.ts
+++ b/src/renderer.ts
@@ -424,7 +424,7 @@ function drawBalloon(
 // ── Renderer ───────────────────────────────────────────────────────────────
 
 export interface Renderer {
-    render: (nuts: NutBody[], gravityAngle: number, idleProgress: number) => void
+    render: (nuts: NutBody[], gravityAngle: number, showArrow?: boolean) => void
     setShareURL: (url: string) => void
     resize: (width: number, height: number) => void
     flash: () => void
@@ -467,7 +467,7 @@ export function createRenderer(canvas: HTMLCanvasElement): Renderer {
         flashAlpha = 0.35
     }
 
-    function render(nuts: NutBody[], gravityAngle: number, idleProgress: number): void {
+    function render(nuts: NutBody[], gravityAngle: number, showArrow = false): void {
         const w = canvas.width
         const h = canvas.height
 
@@ -475,21 +475,6 @@ 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
@@ -510,7 +495,49 @@ export function createRenderer(canvas: HTMLCanvasElement): Renderer {
             }
         }
 
-        ctx.restore()  // end items fade group
+        // Gravity direction arrow (shown in auto-gravity modes)
+        if (showArrow) {
+            const arrowAlpha = 0.55
+            const margin = 28
+            const len = 22
+            const headLen = 9
+            const headAngle = 0.42
+            // Arrow is placed bottom-centre, pointing "down" (current gravity direction)
+            const ax = w / 2
+            const ay = h - margin
+            const dx = Math.sin(gravityAngle)   // gravityAngle is atan2(gy,gx) - π/2
+            const dy = -Math.cos(gravityAngle)  // so sin/cos maps to screen x/y gravity
+
+            ctx.save()
+            ctx.globalAlpha = arrowAlpha
+            ctx.strokeStyle = '#ffffff'
+            ctx.fillStyle = '#ffffff'
+            ctx.lineWidth = 2.5
+            ctx.lineCap = 'round'
+            ctx.lineJoin = 'round'
+
+            const tx = ax + dx * len
+            const ty = ay + dy * len
+            const bx = ax - dx * (len * 0.3)
+            const by = ay - dy * (len * 0.3)
+
+            // Shaft
+            ctx.beginPath()
+            ctx.moveTo(bx, by)
+            ctx.lineTo(tx, ty)
+            ctx.stroke()
+
+            // Arrowhead
+            const angle = Math.atan2(dy, dx)
+            ctx.beginPath()
+            ctx.moveTo(tx, ty)
+            ctx.lineTo(tx - headLen * Math.cos(angle - headAngle), ty - headLen * Math.sin(angle - headAngle))
+            ctx.lineTo(tx - headLen * Math.cos(angle + headAngle), ty - headLen * Math.sin(angle + headAngle))
+            ctx.closePath()
+            ctx.fill()
+
+            ctx.restore()
+        }
 
         // Flash overlay (fallback haptic cue for Firefox / no-vibration devices)
         if (flashAlpha > 0) {
diff --git a/src/ui.ts b/src/ui.ts
index 18a1c4d..f2c7020 100644
--- a/src/ui.ts
+++ b/src/ui.ts
@@ -5,6 +5,7 @@ import { hasVibration } from './compat'
 import { setVibrateEnabled, isVibrateEnabled } from './vibration'
 
 export type ItemKind = 'random' | 'nut' | 'gem' | 'balloon' | 'metalnut'
+export type GravityMode = 'device' | 'tumbler' | 'rock'
 
 const KIND_SEQUENCE: ItemKind[] = ['random', 'nut', 'gem', 'balloon', 'metalnut']
 const KIND_ICON: Record<ItemKind, string> = {
@@ -18,10 +19,14 @@ const KIND_ICON: Record<ItemKind, string> = {
 export interface UICallbacks {
     initialRadius: number
     initialLocked: boolean
+    initialGravityMode: GravityMode
+    initialVibrateEnabled: boolean
     onSizeChange: (radius: number) => void
     onClear: () => void
     onLockChange: (locked: boolean) => void
     onKindChange: (kind: ItemKind) => void
+    onGravityModeChange: (mode: GravityMode) => void
+    onVibrateChange: (enabled: boolean) => void
     getShareURL: () => string
 }
 
@@ -30,6 +35,7 @@ export function initUI(callbacks: UICallbacks): void {
     const kindBtn = document.getElementById('kind-btn') as HTMLButtonElement
     const sizeBtn = document.getElementById('size-btn') as HTMLButtonElement
     const freezeBtn = document.getElementById('freeze-btn') as HTMLButtonElement
+    const gravityBtn = document.getElementById('gravity-btn') as HTMLButtonElement
     const clearTopBtn = document.getElementById('clear-top-btn') as HTMLButtonElement
     const settingsBtn = document.getElementById('settings-btn') as HTMLButtonElement
     const sizePill = document.getElementById('size-pill') as HTMLDivElement
@@ -105,6 +111,23 @@ export function initUI(callbacks: UICallbacks): void {
         callbacks.onClear()
     })
 
+    // ── Gravity mode ─────────────────────────────────────────────────────────
+    const GRAVITY_SEQUENCE: GravityMode[] = ['device', 'tumbler', 'rock']
+    const GRAVITY_ICON: Record<GravityMode, string> = { device: '📱', tumbler: '🪨', rock: '🌀' }
+    let gravityModeIndex = GRAVITY_SEQUENCE.indexOf(callbacks.initialGravityMode)
+    if (gravityModeIndex < 0) gravityModeIndex = 0
+    const initMode = GRAVITY_SEQUENCE[gravityModeIndex]
+    gravityBtn.textContent = GRAVITY_ICON[initMode]
+    gravityBtn.classList.toggle('active', initMode !== 'device')
+
+    gravityBtn.addEventListener('click', () => {
+        gravityModeIndex = (gravityModeIndex + 1) % GRAVITY_SEQUENCE.length
+        const mode = GRAVITY_SEQUENCE[gravityModeIndex]
+        gravityBtn.textContent = GRAVITY_ICON[mode]
+        gravityBtn.classList.toggle('active', mode !== 'device')
+        callbacks.onGravityModeChange(mode)
+    })
+
     // ── Settings panel ───────────────────────────────────────────────────────
     toolbar.hidden = false
 
@@ -112,6 +135,11 @@ export function initUI(callbacks: UICallbacks): void {
     if (!hasVibration()) {
         testVibrateBtn.hidden = true
         vibrateToggleBtn.hidden = true
+    } else {
+        // Restore persisted vibration state
+        setVibrateEnabled(callbacks.initialVibrateEnabled)
+        vibrateToggleBtn.textContent = callbacks.initialVibrateEnabled ? 'Vibration: On' : 'Vibration: Off'
+        vibrateToggleBtn.classList.toggle('active', callbacks.initialVibrateEnabled)
     }
 
     settingsBtn.addEventListener('click', () => {
@@ -135,6 +163,7 @@ export function initUI(callbacks: UICallbacks): void {
         setVibrateEnabled(on)
         vibrateToggleBtn.textContent = on ? 'Vibration: On' : 'Vibration: Off'
         vibrateToggleBtn.classList.toggle('active', on)
+        callbacks.onVibrateChange(on)
     })
 
     copyLinkBtn.addEventListener('click', () => {