fuzzyworld

nuts / commit

ci pasta

728d72e20eb4dfee07d9b76266f0d5d39160afeb · Pavel Safronov · 2026-04-12 23:00:58 -0700 · parents 88e406aa9bb7

ci pasta
 src/main.ts | 777 ++++++++++++++++++++++++++++++------------------------------
 1 file changed, 388 insertions(+), 389 deletions(-)

diff --git a/src/main.ts b/src/main.ts
index 44a683e..01f7fb8 100644
--- a/src/main.ts
+++ b/src/main.ts
@@ -15,438 +15,437 @@ import { buildShareURL, parseShareFromURL, placeSharedNuts } from './share'
 
 // ── Gallery shortcut ───────────────────────────────────────────────────────
 if (new URLSearchParams(window.location.search).has('gallery')) {
-    const { runGallery } = await import('./gallery')
-    runGallery()
-    throw new Error('gallery mode — halting normal init')
-}
-
-// ── Bootstrap ──────────────────────────────────────────────────────────────
+    import('./gallery').then(({ runGallery }) => runGallery())
+} else {
 
-async function main(): Promise<void> {
-    const params = new URLSearchParams(window.location.search)
-    const compat = checkCompat()
-    if (!compat.ok && !params.has('screenshot')) {
-        showUnsupportedWall(compat.reason)
-        return
-    }
+    // ── Bootstrap ──────────────────────────────────────────────────────────────
 
-    const canvas = document.getElementById('canvas') as HTMLCanvasElement
-
-    // Match canvas to the physical pixel dimensions for crisp rendering on HiDPI
-    const dpr = window.devicePixelRatio || 1
-    let cssWidth = window.innerWidth
-    let cssHeight = window.innerHeight
-
-    function getPixelWidth(): number { return Math.round(cssWidth * dpr) }
-    function getPixelHeight(): number { return Math.round(cssHeight * dpr) }
-
-    canvas.width = getPixelWidth()
-    canvas.height = getPixelHeight()
-    canvas.style.width = `${cssWidth}px`
-    canvas.style.height = `${cssHeight}px`
-
-    // Scale the canvas context so all drawing coordinates are in CSS pixels
-    const ctx = canvas.getContext('2d')!
-    ctx.scale(dpr, dpr)
-
-    // Physics world operates in CSS pixel space
-    const physics = createPhysicsWorld(cssWidth, cssHeight)
-    const renderer = createRenderer(canvas)
-
-    // Correct for the DPR scale we already applied to the context
-    // The renderer draws at CSS px resolution; canvas is scaled by dpr above
-    renderer.resize(getPixelWidth() / dpr, getPixelHeight() / dpr)
-
-    let currentNutRadius: number = CONFIG.nutBaseRadius
-    let tapsLocked = false
-    let gravityMode: GravityMode = 'device'
-    let vibrateEnabled = true
-    let vibrateItemsEnabled = true
-    let vibrateEdgesEnabled = true
-    let currentKind: ItemKind = 'random'
-    const DEFAULT_RANDOM_POOL: ItemKind[] = ['nut', 'gem', 'balloon', 'metalnut', 'rock', 'stressball', 'coin']
-    let randomPool: ItemKind[] = [...DEFAULT_RANDOM_POOL]
-    let refillEnabled = 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)
-        }
-        if (savedSettings.vibrateItems === false) {
-            vibrateItemsEnabled = false
-            setVibrateItems(false)
-        }
-        if (savedSettings.vibrateEdges === false) {
-            vibrateEdgesEnabled = false
-            setVibrateEdges(false)
-        }
-        const validKinds: ItemKind[] = ['random', 'nut', 'gem', 'balloon', 'metalnut', 'rock', 'stressball', 'coin']
-        if (savedSettings.kind && (validKinds as string[]).includes(savedSettings.kind)) {
-            currentKind = savedSettings.kind as ItemKind
-        }
-        if (Array.isArray(savedSettings.randomPool)) {
-            const valid = savedSettings.randomPool.filter(k =>
-                (validKinds as string[]).includes(k) && k !== 'random'
-            ) as ItemKind[]
-            if (valid.length > 0) randomPool = valid
+    async function main(): Promise<void> {
+        const params = new URLSearchParams(window.location.search)
+        const compat = checkCompat()
+        if (!compat.ok && !params.has('screenshot')) {
+            showUnsupportedWall(compat.reason)
+            return
         }
-        if (savedSettings.refill === false) refillEnabled = false
-    }
 
-    // ── Restore nut positions (share URL takes priority over snapshot) ────────
-    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 {
-            for (const p of placed) {
-                const dx = p.x - x; const dy = p.y - y
-                if (dx * dx + dy * dy < (p.r + r) ** 2) return false
+        const canvas = document.getElementById('canvas') as HTMLCanvasElement
+
+        // Match canvas to the physical pixel dimensions for crisp rendering on HiDPI
+        const dpr = window.devicePixelRatio || 1
+        let cssWidth = window.innerWidth
+        let cssHeight = window.innerHeight
+
+        function getPixelWidth(): number { return Math.round(cssWidth * dpr) }
+        function getPixelHeight(): number { return Math.round(cssHeight * dpr) }
+
+        canvas.width = getPixelWidth()
+        canvas.height = getPixelHeight()
+        canvas.style.width = `${cssWidth}px`
+        canvas.style.height = `${cssHeight}px`
+
+        // Scale the canvas context so all drawing coordinates are in CSS pixels
+        const ctx = canvas.getContext('2d')!
+        ctx.scale(dpr, dpr)
+
+        // Physics world operates in CSS pixel space
+        const physics = createPhysicsWorld(cssWidth, cssHeight)
+        const renderer = createRenderer(canvas)
+
+        // Correct for the DPR scale we already applied to the context
+        // The renderer draws at CSS px resolution; canvas is scaled by dpr above
+        renderer.resize(getPixelWidth() / dpr, getPixelHeight() / dpr)
+
+        let currentNutRadius: number = CONFIG.nutBaseRadius
+        let tapsLocked = false
+        let gravityMode: GravityMode = 'device'
+        let vibrateEnabled = true
+        let vibrateItemsEnabled = true
+        let vibrateEdgesEnabled = true
+        let currentKind: ItemKind = 'random'
+        const DEFAULT_RANDOM_POOL: ItemKind[] = ['nut', 'gem', 'balloon', 'metalnut', 'rock', 'stressball', 'coin']
+        let randomPool: ItemKind[] = [...DEFAULT_RANDOM_POOL]
+        let refillEnabled = true
+
+        // ── Restore persisted settings ────────────────────────────────────────
+        const savedSettings = loadSettings()
+        if (savedSettings) {
+            currentNutRadius = savedSettings.nutRadius
+            if (savedSettings.gravityMode === 'tumbler' || savedSettings.gravityMode === 'rock') {
+                gravityMode = savedSettings.gravityMode
             }
-            return true
+            if (savedSettings.vibrateEnabled === false) {
+                vibrateEnabled = false
+                setVibrateEnabled(false)
+            }
+            if (savedSettings.vibrateItems === false) {
+                vibrateItemsEnabled = false
+                setVibrateItems(false)
+            }
+            if (savedSettings.vibrateEdges === false) {
+                vibrateEdgesEnabled = false
+                setVibrateEdges(false)
+            }
+            const validKinds: ItemKind[] = ['random', 'nut', 'gem', 'balloon', 'metalnut', 'rock', 'stressball', 'coin']
+            if (savedSettings.kind && (validKinds as string[]).includes(savedSettings.kind)) {
+                currentKind = savedSettings.kind as ItemKind
+            }
+            if (Array.isArray(savedSettings.randomPool)) {
+                const valid = savedSettings.randomPool.filter(k =>
+                    (validKinds as string[]).includes(k) && k !== 'random'
+                ) as ItemKind[]
+                if (valid.length > 0) randomPool = valid
+            }
+            if (savedSettings.refill === false) refillEnabled = false
         }
-        placeSharedNuts(sharedNuts, cssWidth, cssHeight,
-            (x, y, r, kind, gv) => {
-                placed.push({ x, y, r })
-                if (kind === 'gem') physics.addGem(x, y, r, gv)
-                else if (kind === 'balloon') physics.addBalloon(x, y, r, gv)
-                else if (kind === 'metalnut') physics.addMetalNut(x, y, r)
-                else if (kind === 'rock') physics.addRock(x, y, r)
-                else if (kind === 'stressball') physics.addStressBall(x, y, r, gv)
-                else if (kind === 'coin') physics.addCoin(x, y, r, gv)
-                else physics.addNut(x, y, r)
-            },
-            initialHasSpace,
-        )
-    } else {
-        const snapshot = loadSnapshot()
-        if (snapshot) {
-            for (const n of snapshot.nuts) {
-                const x = n.x * cssWidth
-                const y = n.y * cssHeight
-                if (n.kind === 'gem') physics.addGem(x, y, n.radius, n.gemVariant)
-                else if (n.kind === 'balloon') physics.addBalloon(x, y, n.radius, n.colorIndex)
-                else if (n.kind === 'metalnut') physics.addMetalNut(x, y, n.radius)
-                else if (n.kind === 'rock') physics.addRock(x, y, n.radius)
-                else if (n.kind === 'stressball') physics.addStressBall(x, y, n.radius, n.colorIndex)
-                else if (n.kind === 'coin') physics.addCoin(x, y, n.radius, n.gemVariant)
-                else physics.addNut(x, y, n.radius)
+
+        // ── Restore nut positions (share URL takes priority over snapshot) ────────
+        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 {
+                for (const p of placed) {
+                    const dx = p.x - x; const dy = p.y - y
+                    if (dx * dx + dy * dy < (p.r + r) ** 2) return false
+                }
+                return true
+            }
+            placeSharedNuts(sharedNuts, cssWidth, cssHeight,
+                (x, y, r, kind, gv) => {
+                    placed.push({ x, y, r })
+                    if (kind === 'gem') physics.addGem(x, y, r, gv)
+                    else if (kind === 'balloon') physics.addBalloon(x, y, r, gv)
+                    else if (kind === 'metalnut') physics.addMetalNut(x, y, r)
+                    else if (kind === 'rock') physics.addRock(x, y, r)
+                    else if (kind === 'stressball') physics.addStressBall(x, y, r, gv)
+                    else if (kind === 'coin') physics.addCoin(x, y, r, gv)
+                    else physics.addNut(x, y, r)
+                },
+                initialHasSpace,
+            )
+        } else {
+            const snapshot = loadSnapshot()
+            if (snapshot) {
+                for (const n of snapshot.nuts) {
+                    const x = n.x * cssWidth
+                    const y = n.y * cssHeight
+                    if (n.kind === 'gem') physics.addGem(x, y, n.radius, n.gemVariant)
+                    else if (n.kind === 'balloon') physics.addBalloon(x, y, n.radius, n.colorIndex)
+                    else if (n.kind === 'metalnut') physics.addMetalNut(x, y, n.radius)
+                    else if (n.kind === 'rock') physics.addRock(x, y, n.radius)
+                    else if (n.kind === 'stressball') physics.addStressBall(x, y, n.radius, n.colorIndex)
+                    else if (n.kind === 'coin') physics.addCoin(x, y, n.radius, n.gemVariant)
+                    else physics.addNut(x, y, n.radius)
+                }
             }
         }
-    }
 
-    // ── UI ───────────────────────────────────────────────────────────────────
+        // ── UI ───────────────────────────────────────────────────────────────────
 
-    function save(): void {
-        saveSettings({ nutRadius: currentNutRadius, gravityMode, vibrateEnabled, vibrateItems: vibrateItemsEnabled, vibrateEdges: vibrateEdgesEnabled, kind: currentKind, randomPool, refill: refillEnabled })
-    }
+        function save(): void {
+            saveSettings({ nutRadius: currentNutRadius, gravityMode, vibrateEnabled, vibrateItems: vibrateItemsEnabled, vibrateEdges: vibrateEdgesEnabled, kind: currentKind, randomPool, refill: refillEnabled })
+        }
 
-    initUI({
-        initialRadius: currentNutRadius,
-        initialLocked: tapsLocked,
-        initialGravityMode: gravityMode,
-        initialVibrateEnabled: vibrateEnabled,
-        initialVibrateItems: vibrateItemsEnabled,
-        initialVibrateEdges: vibrateEdgesEnabled,
-        initialKind: currentKind,
-        initialRandomPool: randomPool,
-        initialRefill: refillEnabled,
-        onSizeChange: (radius) => { currentNutRadius = radius; save() },
-        onClear: () => {
-            physics.clearNuts()
-            clearSnapshot()
-        },
-        onLockChange: (locked) => { tapsLocked = locked; save() },
-        onKindChange: (kind) => { currentKind = kind; save() },
-        onGravityModeChange: (mode) => { gravityMode = mode; save() },
-        onVibrateChange: (enabled) => { vibrateEnabled = enabled; save() },
-        onVibrateItemsChange: (enabled) => { vibrateItemsEnabled = enabled; save() },
-        onVibrateEdgesChange: (enabled) => { vibrateEdgesEnabled = enabled; save() },
-        onRandomPoolChange: (pool) => { randomPool = pool; save() },
-        onRefillChange: (enabled) => { refillEnabled = enabled; save() },
-        onSettingsOpen: () => { settingsPaused = true },
-        onSettingsClose: () => { settingsPaused = false },
-        getShareURL: () => buildShareURL(physics.nuts, currentNutRadius),
-    })
-
-    // ── Helpers ───────────────────────────────────────────────────────────────
-
-    function hasSpace(x: number, y: number, r: number): boolean {
-        for (const { body, radius } of physics.nuts) {
-            const dx = body.position.x - x
-            const dy = body.position.y - y
-            const minDist = radius + r
-            if (dx * dx + dy * dy < minDist * minDist) return false
+        initUI({
+            initialRadius: currentNutRadius,
+            initialLocked: tapsLocked,
+            initialGravityMode: gravityMode,
+            initialVibrateEnabled: vibrateEnabled,
+            initialVibrateItems: vibrateItemsEnabled,
+            initialVibrateEdges: vibrateEdgesEnabled,
+            initialKind: currentKind,
+            initialRandomPool: randomPool,
+            initialRefill: refillEnabled,
+            onSizeChange: (radius) => { currentNutRadius = radius; save() },
+            onClear: () => {
+                physics.clearNuts()
+                clearSnapshot()
+            },
+            onLockChange: (locked) => { tapsLocked = locked; save() },
+            onKindChange: (kind) => { currentKind = kind; save() },
+            onGravityModeChange: (mode) => { gravityMode = mode; save() },
+            onVibrateChange: (enabled) => { vibrateEnabled = enabled; save() },
+            onVibrateItemsChange: (enabled) => { vibrateItemsEnabled = enabled; save() },
+            onVibrateEdgesChange: (enabled) => { vibrateEdgesEnabled = enabled; save() },
+            onRandomPoolChange: (pool) => { randomPool = pool; save() },
+            onRefillChange: (enabled) => { refillEnabled = enabled; save() },
+            onSettingsOpen: () => { settingsPaused = true },
+            onSettingsClose: () => { settingsPaused = false },
+            getShareURL: () => buildShareURL(physics.nuts, currentNutRadius),
+        })
+
+        // ── Helpers ───────────────────────────────────────────────────────────────
+
+        function hasSpace(x: number, y: number, r: number): boolean {
+            for (const { body, radius } of physics.nuts) {
+                const dx = body.position.x - x
+                const dy = body.position.y - y
+                const minDist = radius + r
+                if (dx * dx + dy * dy < minDist * minDist) return false
+            }
+            return true
         }
-        return true
-    }
 
-    function tryAddNut(cx: number, cy: number): void {
-        if (tapsLocked) return
-        if (hasSpace(cx, cy, currentNutRadius)) {
-            let kind = currentKind
-            if (kind === 'random') {
-                const pool = randomPool.length > 0 ? randomPool : ['nut', 'gem', 'balloon', 'metalnut', 'rock', 'stressball'] as ItemKind[]
-                kind = pool[Math.floor(Math.random() * pool.length)]
+        function tryAddNut(cx: number, cy: number): void {
+            if (tapsLocked) return
+            if (hasSpace(cx, cy, currentNutRadius)) {
+                let kind = currentKind
+                if (kind === 'random') {
+                    const pool = randomPool.length > 0 ? randomPool : ['nut', 'gem', 'balloon', 'metalnut', 'rock', 'stressball'] as ItemKind[]
+                    kind = pool[Math.floor(Math.random() * pool.length)]
+                }
+                if (kind === 'balloon') physics.addBalloon(cx, cy, currentNutRadius)
+                else if (kind === 'gem') physics.addGem(cx, cy, currentNutRadius)
+                else if (kind === 'metalnut') physics.addMetalNut(cx, cy, currentNutRadius)
+                else if (kind === 'rock') physics.addRock(cx, cy, currentNutRadius)
+                else if (kind === 'stressball') physics.addStressBall(cx, cy, currentNutRadius)
+                else if (kind === 'coin') physics.addCoin(cx, cy, currentNutRadius)
+                else physics.addNut(cx, cy, currentNutRadius)
             }
-            if (kind === 'balloon') physics.addBalloon(cx, cy, currentNutRadius)
-            else if (kind === 'gem') physics.addGem(cx, cy, currentNutRadius)
-            else if (kind === 'metalnut') physics.addMetalNut(cx, cy, currentNutRadius)
-            else if (kind === 'rock') physics.addRock(cx, cy, currentNutRadius)
-            else if (kind === 'stressball') physics.addStressBall(cx, cy, currentNutRadius)
-            else if (kind === 'coin') physics.addCoin(cx, cy, currentNutRadius)
-            else physics.addNut(cx, cy, currentNutRadius)
         }
-    }
 
-    function spawnRefill(kind: string, radius: number): void {
-        // Try a few random positions along the top third of the screen
-        for (let attempt = 0; attempt < 20; attempt++) {
-            const x = radius + Math.random() * (cssWidth - radius * 2)
-            const y = radius + Math.random() * (cssHeight * 0.35)
-            if (!hasSpace(x, y, radius)) continue
-            if (kind === 'balloon') physics.addBalloon(x, y, radius)
-            else if (kind === 'rock') physics.addRock(x, y, radius)
-            else if (kind === 'gem') physics.addGem(x, y, radius)
-            else if (kind === 'metalnut') physics.addMetalNut(x, y, radius)
-            else if (kind === 'stressball') physics.addStressBall(x, y, radius)
-            else if (kind === 'coin') physics.addCoin(x, y, radius)
-            else physics.addNut(x, y, radius)
-            return
+        function spawnRefill(kind: string, radius: number): void {
+            // Try a few random positions along the top third of the screen
+            for (let attempt = 0; attempt < 20; attempt++) {
+                const x = radius + Math.random() * (cssWidth - radius * 2)
+                const y = radius + Math.random() * (cssHeight * 0.35)
+                if (!hasSpace(x, y, radius)) continue
+                if (kind === 'balloon') physics.addBalloon(x, y, radius)
+                else if (kind === 'rock') physics.addRock(x, y, radius)
+                else if (kind === 'gem') physics.addGem(x, y, radius)
+                else if (kind === 'metalnut') physics.addMetalNut(x, y, radius)
+                else if (kind === 'stressball') physics.addStressBall(x, y, radius)
+                else if (kind === 'coin') physics.addCoin(x, y, radius)
+                else physics.addNut(x, y, radius)
+                return
+            }
         }
-    }
 
-    // ── Refill hook ──────────────────────────────────────────────────────────
-    physics.onRemoved = (kind, radius) => {
-        if (!refillEnabled) return
-        // Delay slightly so the removal settles before we add a new body
-        setTimeout(() => spawnRefill(kind, radius), 600)
-    }
+        // ── Refill hook ──────────────────────────────────────────────────────────
+        physics.onRemoved = (kind, radius) => {
+            if (!refillEnabled) return
+            // Delay slightly so the removal settles before we add a new body
+            setTimeout(() => spawnRefill(kind, radius), 600)
+        }
 
 
-    // ── 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.
-    let pointerDown = false
-    let eraseMode = false
-
-    canvas.addEventListener('pointerdown', (e: PointerEvent) => {
-        if (tapsLocked) return
-        pointerDown = true
-        try { canvas.setPointerCapture(e.pointerId) } catch { /* ignore */ }
-        const rect = canvas.getBoundingClientRect()
-        const x = e.clientX - rect.left
-        const y = e.clientY - rect.top
-        eraseMode = physics.removeAt(x, y)
-        if (!eraseMode) tryAddNut(x, y)
-    })
-
-    canvas.addEventListener('pointermove', (e: PointerEvent) => {
-        if (!pointerDown) return
-        const rect = canvas.getBoundingClientRect()
-        const x = e.clientX - rect.left
-        const y = e.clientY - rect.top
-        if (eraseMode) physics.removeAt(x, y)
-        else tryAddNut(x, y)
-    })
-
-    canvas.addEventListener('pointerup', () => { pointerDown = false; eraseMode = false })
-    canvas.addEventListener('pointercancel', () => { pointerDown = false; eraseMode = false })
-
-    // ── Resize ───────────────────────────────────────────────────────────────
-    window.addEventListener('resize', () => {
-        cssWidth = window.innerWidth
-        cssHeight = window.innerHeight
-        canvas.width = getPixelWidth()
-        canvas.height = getPixelHeight()
-        canvas.style.width = `${cssWidth}px`
-        canvas.style.height = `${cssHeight}px`
-        const c = canvas.getContext('2d')!
-        c.scale(dpr, dpr)
-        renderer.resize(cssWidth, cssHeight)
-        physics.resize(cssWidth, cssHeight)
-    })
-
-    // ── Motion (shake + tilt gravity) ────────────────────────────────────────
-    let motionEnabled = false
-
-    function startMotion(): void {
-        if (motionEnabled) return
-        motionEnabled = true
-        dbg('startMotion, vibrate?', 'vibrate' in navigator, 'protocol:', location.protocol)
-        initMotion({
-            onShake: (intensity) => {
-                physics.applyShakeForce(intensity)
-            },
-            onOrientation: (gx, gy) => {
-                if (gravityMode === 'device') {
-                    physics.setGravity(gx, gy)
-                }
-            },
+        // ── 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.
+        let pointerDown = false
+        let eraseMode = false
+
+        canvas.addEventListener('pointerdown', (e: PointerEvent) => {
+            if (tapsLocked) return
+            pointerDown = true
+            try { canvas.setPointerCapture(e.pointerId) } catch { /* ignore */ }
+            const rect = canvas.getBoundingClientRect()
+            const x = e.clientX - rect.left
+            const y = e.clientY - rect.top
+            eraseMode = physics.removeAt(x, y)
+            if (!eraseMode) tryAddNut(x, y)
         })
-    }
 
-    // ── Fullscreen + orientation lock ──────────────────────────────────────
-    async function enterFullscreen(): Promise<void> {
-        try {
-            await document.documentElement.requestFullscreen({ navigationUI: 'hide' })
-        } catch {
-            // Not supported or denied – ignore
-        }
-        try {
-            const orientation = screen.orientation as ScreenOrientation & { lock?: (o: string) => Promise<void> }
-            await orientation.lock?.('portrait')
-        } catch {
-            // Not supported (desktop, or browser restriction) – ignore
+        canvas.addEventListener('pointermove', (e: PointerEvent) => {
+            if (!pointerDown) return
+            const rect = canvas.getBoundingClientRect()
+            const x = e.clientX - rect.left
+            const y = e.clientY - rect.top
+            if (eraseMode) physics.removeAt(x, y)
+            else tryAddNut(x, y)
+        })
+
+        canvas.addEventListener('pointerup', () => { pointerDown = false; eraseMode = false })
+        canvas.addEventListener('pointercancel', () => { pointerDown = false; eraseMode = false })
+
+        // ── Resize ───────────────────────────────────────────────────────────────
+        window.addEventListener('resize', () => {
+            cssWidth = window.innerWidth
+            cssHeight = window.innerHeight
+            canvas.width = getPixelWidth()
+            canvas.height = getPixelHeight()
+            canvas.style.width = `${cssWidth}px`
+            canvas.style.height = `${cssHeight}px`
+            const c = canvas.getContext('2d')!
+            c.scale(dpr, dpr)
+            renderer.resize(cssWidth, cssHeight)
+            physics.resize(cssWidth, cssHeight)
+        })
+
+        // ── Motion (shake + tilt gravity) ────────────────────────────────────────
+        let motionEnabled = false
+
+        function startMotion(): void {
+            if (motionEnabled) return
+            motionEnabled = true
+            dbg('startMotion, vibrate?', 'vibrate' in navigator, 'protocol:', location.protocol)
+            initMotion({
+                onShake: (intensity) => {
+                    physics.applyShakeForce(intensity)
+                },
+                onOrientation: (gx, gy) => {
+                    if (gravityMode === 'device') {
+                        physics.setGravity(gx, gy)
+                    }
+                },
+            })
         }
-    }
 
-    // ── Fullscreen guard ──────────────────────────────────────────────────────
-    const fullscreenOverlay = document.getElementById('fullscreen-overlay')!
-    document.addEventListener('fullscreenchange', () => {
-        const isFullscreen = !!document.fullscreenElement
-        fullscreenOverlay.hidden = isFullscreen
-    })
-    fullscreenOverlay.addEventListener('click', async () => {
-        await enterFullscreen()
-    })
-
-    // ── Start overlay / permission ────────────────────────────────────────────
-    const startOverlay = document.getElementById('start-overlay')!
-    startOverlay.addEventListener(
-        'click',
-        async () => {
+        // ── Fullscreen + orientation lock ──────────────────────────────────────
+        async function enterFullscreen(): Promise<void> {
             try {
-                await requestMotionPermission()
-            } catch (e) {
-                dbgError('requestMotionPermission failed', e)
+                await document.documentElement.requestFullscreen({ navigationUI: 'hide' })
+            } catch {
+                // Not supported or denied – ignore
             }
-            unlockVibration()  // must be called from gesture handler
-            preloadSounds()    // kick off audio fetch after first gesture (autoplay policy)
+            try {
+                const orientation = screen.orientation as ScreenOrientation & { lock?: (o: string) => Promise<void> }
+                await orientation.lock?.('portrait')
+            } catch {
+                // Not supported (desktop, or browser restriction) – ignore
+            }
+        }
+
+        // ── Fullscreen guard ──────────────────────────────────────────────────────
+        const fullscreenOverlay = document.getElementById('fullscreen-overlay')!
+        document.addEventListener('fullscreenchange', () => {
+            const isFullscreen = !!document.fullscreenElement
+            fullscreenOverlay.hidden = isFullscreen
+        })
+        fullscreenOverlay.addEventListener('click', async () => {
             await enterFullscreen()
-            startMotion()
-            removeStartOverlay()
-            startLoop()
-        },
-        { once: true }
-    )
-
-    // On non-iOS desktop, start automatically without waiting for tap
-    if (!('ontouchstart' in window)) {
-        // Small delay so the UI paints before we start the loop
-        setTimeout(() => {
-            startMotion()
-            removeStartOverlay()
-            startLoop()
-        }, 400)
-    }
+        })
 
-    // ── Slow-frame recovery ───────────────────────────────────────────────
-    const perfOverlay = document.getElementById('perf-overlay')!
-    const perfResetBtn = document.getElementById('perf-reset-btn')!
-    const perfContinueBtn = document.getElementById('perf-continue-btn')!
-    let perfPaused = false
-    const SLOW_FRAME_MS = 5000
-
-    perfResetBtn.addEventListener('click', () => {
-        physics.clearNuts()
-        clearSnapshot()
-        perfOverlay.hidden = true
-        perfPaused = false
-        lastTimestamp = 0
-        requestAnimationFrame(loop)
-    })
-
-    perfContinueBtn.addEventListener('click', () => {
-        perfOverlay.hidden = true
-        perfPaused = false
-        lastTimestamp = 0
-        requestAnimationFrame(loop)
-    })
-
-    // ── Game loop ─────────────────────────────────────────────────────────────
-    let lastTimestamp = 0
-    let loopRunning = false
-    let settingsPaused = false
-
-    function startLoop(): void {
-        if (loopRunning) return
-        loopRunning = true
-        requestAnimationFrame(loop)
-    }
+        // ── Start overlay / permission ────────────────────────────────────────────
+        const startOverlay = document.getElementById('start-overlay')!
+        startOverlay.addEventListener(
+            'click',
+            async () => {
+                try {
+                    await requestMotionPermission()
+                } catch (e) {
+                    dbgError('requestMotionPermission failed', e)
+                }
+                unlockVibration()  // must be called from gesture handler
+                preloadSounds()    // kick off audio fetch after first gesture (autoplay policy)
+                await enterFullscreen()
+                startMotion()
+                removeStartOverlay()
+                startLoop()
+            },
+            { once: true }
+        )
+
+        // On non-iOS desktop, start automatically without waiting for tap
+        if (!('ontouchstart' in window)) {
+            // Small delay so the UI paints before we start the loop
+            setTimeout(() => {
+                startMotion()
+                removeStartOverlay()
+                startLoop()
+            }, 400)
+        }
 
-    function loop(timestamp: number): void {
-        // Pause when settings are open
-        if (settingsPaused) {
+        // ── Slow-frame recovery ───────────────────────────────────────────────
+        const perfOverlay = document.getElementById('perf-overlay')!
+        const perfResetBtn = document.getElementById('perf-reset-btn')!
+        const perfContinueBtn = document.getElementById('perf-continue-btn')!
+        let perfPaused = false
+        const SLOW_FRAME_MS = 5000
+
+        perfResetBtn.addEventListener('click', () => {
+            physics.clearNuts()
+            clearSnapshot()
+            perfOverlay.hidden = true
+            perfPaused = false
             lastTimestamp = 0
             requestAnimationFrame(loop)
-            return
-        }
+        })
 
-        // On mobile: pause physics + vibration when not fullscreen
-        const isMobile = 'ontouchstart' in window
-        const isFullscreen = !!document.fullscreenElement
-        if (isMobile && !isFullscreen) {
-            setVibrateEnabled(false)
+        perfContinueBtn.addEventListener('click', () => {
+            perfOverlay.hidden = true
+            perfPaused = false
             lastTimestamp = 0
             requestAnimationFrame(loop)
-            return
-        }
-        if (isMobile && vibrateEnabled) {
-            setVibrateEnabled(true)
-        }
+        })
 
-        const delta = lastTimestamp === 0 ? 16.67 : timestamp - lastTimestamp
-        lastTimestamp = timestamp
+        // ── Game loop ─────────────────────────────────────────────────────────────
+        let lastTimestamp = 0
+        let loopRunning = false
+        let settingsPaused = false
 
-        // Detect runaway frame time — tab wake-up or physics explosion
-        if (delta > SLOW_FRAME_MS) {
-            perfOverlay.hidden = false
-            perfPaused = true
-            loopRunning = false
-            return
+        function startLoop(): void {
+            if (loopRunning) return
+            loopRunning = true
+            requestAnimationFrame(loop)
         }
 
-        // ── Auto-gravity modes ───────────────────────────────────────────────
-        if (gravityMode === 'tumbler') {
-            // Full 360° rotation at 0.1 Hz (10 seconds per revolution)
-            const angle = (timestamp / 1000) * 0.1 * 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))
-        }
+        function loop(timestamp: number): void {
+            // Pause when settings are open
+            if (settingsPaused) {
+                lastTimestamp = 0
+                requestAnimationFrame(loop)
+                return
+            }
+
+            // On mobile: pause physics + vibration when not fullscreen
+            const isMobile = 'ontouchstart' in window
+            const isFullscreen = !!document.fullscreenElement
+            if (isMobile && !isFullscreen) {
+                setVibrateEnabled(false)
+                lastTimestamp = 0
+                requestAnimationFrame(loop)
+                return
+            }
+            if (isMobile && vibrateEnabled) {
+                setVibrateEnabled(true)
+            }
 
-        physics.step(delta)
+            const delta = lastTimestamp === 0 ? 16.67 : timestamp - lastTimestamp
+            lastTimestamp = timestamp
 
-        const g = physics.engine.gravity
-        renderer.render(physics.nuts, Math.atan2(g.y, g.x) - Math.PI / 2, gravityMode !== 'device')
+            // Detect runaway frame time — tab wake-up or physics explosion
+            if (delta > SLOW_FRAME_MS) {
+                perfOverlay.hidden = false
+                perfPaused = true
+                loopRunning = false
+                return
+            }
 
-        // Save snapshot every ~3 s
-        if (Math.floor(timestamp / 3000) !== Math.floor((timestamp - delta) / 3000)) {
-            saveSnapshot(physics.nuts, cssWidth, cssHeight)
+            // ── Auto-gravity modes ───────────────────────────────────────────────
+            if (gravityMode === 'tumbler') {
+                // Full 360° rotation at 0.1 Hz (10 seconds per revolution)
+                const angle = (timestamp / 1000) * 0.1 * 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))
+            }
+
+            physics.step(delta)
+
+            const g = physics.engine.gravity
+            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)) {
+                saveSnapshot(physics.nuts, cssWidth, cssHeight)
+            }
+
+            requestAnimationFrame(loop)
         }
 
-        requestAnimationFrame(loop)
+        // Also save on tab close / navigation
+        window.addEventListener('pagehide', () => {
+            saveSnapshot(physics.nuts, cssWidth, cssHeight)
+            saveSettings({ nutRadius: currentNutRadius, gravityMode, vibrateEnabled, vibrateItems: vibrateItemsEnabled, vibrateEdges: vibrateEdgesEnabled, kind: currentKind, randomPool, refill: refillEnabled })
+        })
     }
 
-    // Also save on tab close / navigation
-    window.addEventListener('pagehide', () => {
-        saveSnapshot(physics.nuts, cssWidth, cssHeight)
-        saveSettings({ nutRadius: currentNutRadius, gravityMode, vibrateEnabled, vibrateItems: vibrateItemsEnabled, vibrateEdges: vibrateEdgesEnabled, kind: currentKind, randomPool, refill: refillEnabled })
-    })
+    main().catch(console.error)
 }
-
-main().catch(console.error)