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