nuts / commit
ui changes, moved most controls to the landing page
index.html | 33 +++++++++++----- src/main.ts | 27 ++++++++------ src/persistence.ts | 1 - src/style.css | 108 +++++++++++++++++++++++++++++++++++++++++++++-------- src/ui.ts | 91 +++++++++++++++++++++++++++++++++----------- 5 files changed, 201 insertions(+), 59 deletions(-) diff --git a/index.html b/index.html index d7bbb7c..9ae4a9d 100644 --- a/index.html +++ b/index.html @@ -47,8 +47,29 @@ </div> </div> - <!-- Settings button --> - <button id="settings-btn" aria-label="Settings" hidden>⚙</button> + <!-- Top toolbar (hidden until game starts) --> + <div id="toolbar" hidden> + <!-- Kind cycle: random → nut → gem → balloon → random --> + <button id="kind-btn" class="toolbar-btn" aria-label="Item type">?</button> + + <!-- Size: tap to reveal slider pill --> + <button id="size-btn" class="toolbar-btn" aria-label="Size">●</button> + + <!-- Freeze toggle --> + <button id="freeze-btn" class="toolbar-btn" aria-label="Freeze">⏸</button> + + <!-- Clear --> + <button id="clear-top-btn" class="toolbar-btn" aria-label="Clear">✕</button> + + <!-- Settings --> + <button id="settings-btn" class="toolbar-btn" aria-label="Settings">⚙</button> + </div> + + <!-- Size slider pill (slides down from toolbar) --> + <div id="size-pill" hidden> + <input type="range" id="nut-size" min="10" max="180" value="130" step="1" /> + <span id="nut-size-display" class="size-pill-value">130</span> + </div> <!-- Settings panel --> <div id="settings-panel" hidden> @@ -57,14 +78,6 @@ <div class="panel-handle"></div> <h2>Settings</h2> - <label class="setting-row"> - <span>Size</span> - <span id="nut-size-display" class="setting-value">130</span> - </label> - <input type="range" id="nut-size" min="10" max="180" value="130" step="1" /> - - <button id="lock-btn" class="action-btn">⏸ Pause</button> - <button id="clear-btn" class="action-btn">Clear</button> <button id="test-vibrate-btn" class="action-btn">Test vibration</button> <button id="close-settings" class="close-btn">Done</button> diff --git a/src/main.ts b/src/main.ts index a0f6725..6b70c38 100644 --- a/src/main.ts +++ b/src/main.ts @@ -2,7 +2,7 @@ import { CONFIG } from './config' import { createPhysicsWorld } from './physics' import { createRenderer } from './renderer' import { requestMotionPermission, initMotion } from './motion' -import { initUI, removeStartOverlay } from './ui' +import { initUI, removeStartOverlay, type ItemKind } from './ui' import { setFlashFallback, unlockVibration } from './vibration' import { dbg, dbgError } from './debug' import { checkCompat, showUnsupportedWall } from './compat' @@ -58,7 +58,6 @@ async function main(): Promise<void> { const savedSettings = loadSettings() if (savedSettings) { currentNutRadius = savedSettings.nutRadius - tapsLocked = savedSettings.locked } // ── Restore nut positions (share URL takes priority over snapshot) ──────── @@ -104,7 +103,7 @@ async function main(): Promise<void> { initialLocked: tapsLocked, onSizeChange: (radius) => { currentNutRadius = radius - saveSettings({ nutRadius: currentNutRadius, locked: tapsLocked }) + saveSettings({ nutRadius: currentNutRadius }) }, onClear: () => { physics.clearNuts() @@ -112,12 +111,15 @@ async function main(): Promise<void> { }, onLockChange: (locked) => { tapsLocked = locked - saveSettings({ nutRadius: currentNutRadius, locked: tapsLocked }) + saveSettings({ nutRadius: currentNutRadius }) }, + onKindChange: (kind) => { currentKind = kind }, getShareURL: () => buildShareURL(physics.nuts, currentNutRadius), }) // ── Helpers ─────────────────────────────────────────────────────────────── + let currentKind: ItemKind = 'random' + function hasSpace(x: number, y: number, r: number): boolean { for (const { body, radius } of physics.nuts) { const dx = body.position.x - x @@ -129,11 +131,15 @@ async function main(): Promise<void> { } function tryAddNut(cx: number, cy: number): void { - if (tapsLocked) return // already guarded at pointerdown, but keep for safety + if (tapsLocked) return if (hasSpace(cx, cy, currentNutRadius)) { - const rand = Math.random() - if (rand < 0.20) physics.addBalloon(cx, cy, currentNutRadius) - else if (rand < 0.45) physics.addGem(cx, cy, currentNutRadius) + let kind = currentKind + if (kind === 'random') { + const rand = Math.random() + kind = rand < 0.20 ? 'balloon' : rand < 0.45 ? 'gem' : 'nut' + } + if (kind === 'balloon') physics.addBalloon(cx, cy, currentNutRadius) + else if (kind === 'gem') physics.addGem(cx, cy, currentNutRadius) else physics.addNut(cx, cy, currentNutRadius) } } @@ -155,11 +161,10 @@ async function main(): Promise<void> { markActive() if (tapsLocked) return pointerDown = true - canvas.setPointerCapture(e.pointerId) + try { canvas.setPointerCapture(e.pointerId) } catch { /* ignore */ } const rect = canvas.getBoundingClientRect() const x = e.clientX - rect.left const y = e.clientY - rect.top - // Hit an existing item → erase mode eraseMode = physics.removeAt(x, y) if (!eraseMode) tryAddNut(x, y) }) @@ -302,7 +307,7 @@ async function main(): Promise<void> { // Also save on tab close / navigation window.addEventListener('pagehide', () => { saveSnapshot(physics.nuts, cssWidth, cssHeight) - saveSettings({ nutRadius: currentNutRadius, locked: tapsLocked }) + saveSettings({ nutRadius: currentNutRadius }) }) } diff --git a/src/persistence.ts b/src/persistence.ts index f380786..2839c97 100644 --- a/src/persistence.ts +++ b/src/persistence.ts @@ -10,7 +10,6 @@ const KEYS = { export interface PersistedSettings { v: number nutRadius: number - locked: boolean } export function loadSettings(): PersistedSettings | null { diff --git a/src/style.css b/src/style.css index 5433a92..a067e82 100644 --- a/src/style.css +++ b/src/style.css @@ -100,34 +100,110 @@ body { text-transform: uppercase; } -/* ── Settings button ────────────────────────────────────────── */ -#settings-btn { +/* ── Toolbar ─────────────────────────────────────────────────── */ +#toolbar { position: fixed; - top: max(16px, env(safe-area-inset-top, 16px)); - right: max(16px, env(safe-area-inset-right, 16px)); - width: 44px; - height: 44px; + top: max(10px, env(safe-area-inset-top, 10px)); + left: 50%; + transform: translateX(-50%); + display: none; + gap: 8px; + z-index: 50; + padding: 6px 10px; + background: rgba(20, 20, 20, 0.72); + border-radius: 99px; + backdrop-filter: blur(12px); + -webkit-backdrop-filter: blur(12px); + border: 1px solid rgba(255, 255, 255, 0.08); + pointer-events: none; + /* container doesn't intercept — buttons do */ +} + +#toolbar:not([hidden]) { + display: flex; +} + +.toolbar-btn { + width: 40px; + height: 40px; border-radius: 50%; border: none; - background: rgba(255, 255, 255, 0.1); - color: #aaa; - font-size: 22px; + background: transparent; + color: #bbb; + font-size: 18px; cursor: pointer; - z-index: 50; - backdrop-filter: blur(8px); - -webkit-backdrop-filter: blur(8px); display: flex; align-items: center; justify-content: center; - transition: background 0.2s; + transition: background 0.15s, color 0.15s; + flex-shrink: 0; + pointer-events: auto; + /* re-enable on each button */ + -webkit-tap-highlight-color: transparent; +} + +.toolbar-btn:active { + background: rgba(255, 255, 255, 0.15); +} + +.toolbar-btn.active { + color: #8fce8f; + background: rgba(143, 206, 143, 0.15); +} + +.toolbar-btn.kind-nut { + color: #c8a84b; } -#settings-btn:not([hidden]) { +.toolbar-btn.kind-gem { + color: #80A8FF; +} + +.toolbar-btn.kind-balloon { + color: #FF4466; +} + +.toolbar-btn.kind-random { + color: #aaa; +} + +/* ── Size pill (slides down from toolbar) ────────────────────── */ +#size-pill { + position: fixed; + top: max(68px, calc(env(safe-area-inset-top, 10px) + 58px)); + left: 50%; + transform: translateX(-50%); + z-index: 49; + background: rgba(20, 20, 20, 0.88); + border-radius: 99px; + border: 1px solid rgba(255, 255, 255, 0.1); + backdrop-filter: blur(12px); + -webkit-backdrop-filter: blur(12px); + padding: 10px 18px; + display: none; + align-items: center; + gap: 12px; + min-width: 240px; + pointer-events: none; +} + +#size-pill:not([hidden]) { display: flex; + pointer-events: auto; +} + +.size-pill-value { + font-family: system-ui, -apple-system, sans-serif; + font-size: 14px; + font-weight: 600; + color: #c8a84b; + min-width: 28px; + text-align: right; } -#settings-btn:active { - background: rgba(255, 255, 255, 0.2); +/* Settings btn is now inside toolbar — no separate fixed positioning */ +#settings-btn { + /* inherits .toolbar-btn */ } /* ── Settings panel ─────────────────────────────────────────── */ diff --git a/src/ui.ts b/src/ui.ts index 614e7af..577b6d7 100644 --- a/src/ui.ts +++ b/src/ui.ts @@ -2,48 +2,68 @@ import { CONFIG } from './config' import { dbg } from './debug' import QRCode from 'qrcode' +export type ItemKind = 'random' | 'nut' | 'gem' | 'balloon' + +const KIND_SEQUENCE: ItemKind[] = ['random', 'nut', 'gem', 'balloon'] +const KIND_ICON: Record<ItemKind, string> = { + random: '?', + nut: '🌰', + gem: '💎', + balloon: '🎈', +} + export interface UICallbacks { initialRadius: number initialLocked: boolean onSizeChange: (radius: number) => void onClear: () => void onLockChange: (locked: boolean) => void + onKindChange: (kind: ItemKind) => void getShareURL: () => string } export function initUI(callbacks: UICallbacks): void { + const toolbar = document.getElementById('toolbar') as HTMLDivElement + 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 clearTopBtn = document.getElementById('clear-top-btn') as HTMLButtonElement const settingsBtn = document.getElementById('settings-btn') as HTMLButtonElement - const settingsPanel = document.getElementById('settings-panel') as HTMLDivElement + const sizePill = document.getElementById('size-pill') as HTMLDivElement const nutSizeInput = document.getElementById('nut-size') as HTMLInputElement const nutSizeDisplay = document.getElementById('nut-size-display') as HTMLSpanElement - const lockBtn = document.getElementById('lock-btn') as HTMLButtonElement - const clearBtn = document.getElementById('clear-btn') as HTMLButtonElement + + const settingsPanel = document.getElementById('settings-panel') as HTMLDivElement const testVibrateBtn = document.getElementById('test-vibrate-btn') as HTMLButtonElement const closeBtn = document.getElementById('close-settings') as HTMLButtonElement const backdrop = settingsPanel.querySelector('.panel-backdrop') as HTMLDivElement const qrCanvas = document.getElementById('qr-canvas') as HTMLCanvasElement const copyLinkBtn = document.getElementById('copy-link-btn') as HTMLButtonElement - // Sync slider from config bounds + persisted value + // ── Size slider ────────────────────────────────────────────────────────── nutSizeInput.min = String(CONFIG.nutMinRadius) nutSizeInput.max = String(CONFIG.nutMaxRadius) nutSizeInput.value = String(callbacks.initialRadius) nutSizeDisplay.textContent = String(callbacks.initialRadius) - let locked = callbacks.initialLocked - lockBtn.textContent = locked ? '▶ Resume' : '⏸ Pause' - lockBtn.classList.toggle('active', locked) - - // Show settings button - settingsBtn.hidden = false + let sizePillOpen = false - settingsBtn.addEventListener('click', () => { - settingsPanel.hidden = false - refreshQR() + sizeBtn.addEventListener('click', (e) => { + e.stopPropagation() + sizePillOpen = !sizePillOpen + sizePill.hidden = !sizePillOpen + sizeBtn.classList.toggle('active', sizePillOpen) }) - closeBtn.addEventListener('click', closePanel) - backdrop.addEventListener('click', closePanel) + // Close pill when tapping outside (bubble phase is fine — we don't block propagation) + document.addEventListener('pointerdown', (e) => { + if (!sizePillOpen) return + if (!sizePill.contains(e.target as Node) && e.target !== sizeBtn) { + sizePillOpen = false + sizePill.hidden = true + sizeBtn.classList.remove('active') + } + }) nutSizeInput.addEventListener('input', () => { const radius = parseInt(nutSizeInput.value, 10) @@ -51,19 +71,47 @@ export function initUI(callbacks: UICallbacks): void { callbacks.onSizeChange(radius) }) - lockBtn.addEventListener('click', () => { + // ── Kind toggle ────────────────────────────────────────────────────────── + let kindIndex = 0 // starts at 'random' + kindBtn.textContent = KIND_ICON[KIND_SEQUENCE[kindIndex]] + kindBtn.className = `toolbar-btn kind-${KIND_SEQUENCE[kindIndex]}` + + kindBtn.addEventListener('click', () => { + kindIndex = (kindIndex + 1) % KIND_SEQUENCE.length + const kind = KIND_SEQUENCE[kindIndex] + kindBtn.textContent = KIND_ICON[kind] + kindBtn.className = `toolbar-btn kind-${kind}` + callbacks.onKindChange(kind) + }) + + // ── Freeze ─────────────────────────────────────────────────────────────── + let locked = callbacks.initialLocked + freezeBtn.classList.toggle('active', locked) + freezeBtn.textContent = locked ? '▶' : '⏸' + + freezeBtn.addEventListener('click', () => { locked = !locked - lockBtn.textContent = locked ? '▶ Resume' : '⏸ Pause' - lockBtn.classList.toggle('active', locked) + freezeBtn.classList.toggle('active', locked) + freezeBtn.textContent = locked ? '▶' : '⏸' callbacks.onLockChange(locked) - closePanel() }) - clearBtn.addEventListener('click', () => { + // ── Clear ──────────────────────────────────────────────────────────────── + clearTopBtn.addEventListener('click', () => { callbacks.onClear() - closePanel() }) + // ── Settings panel ─────────────────────────────────────────────────────── + toolbar.hidden = false + + settingsBtn.addEventListener('click', () => { + settingsPanel.hidden = false + refreshQR() + }) + + closeBtn.addEventListener('click', closePanel) + backdrop.addEventListener('click', closePanel) + testVibrateBtn.addEventListener('click', () => { const ok = navigator.vibrate(300) dbg('test vibrate 300ms ->', ok) @@ -108,6 +156,7 @@ export function initUI(callbacks: UICallbacks): void { export function removeStartOverlay(): void { const overlay = document.getElementById('start-overlay') if (!overlay) return + overlay.style.pointerEvents = 'none' overlay.style.transition = 'opacity 0.4s' overlay.style.opacity = '0' setTimeout(() => overlay.remove(), 420)