nuts / commit
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', () => {