nuts / commit
added qr code
src/main.ts | 30 +++++++++++++++++++++++++---- src/renderer.ts | 60 +++++++++++++++++++++++++++++++++++++++++++++++++++------ src/share.ts | 55 +++++++++++++++++++++++++++++++++++++++++++++------- 3 files changed, 128 insertions(+), 17 deletions(-) diff --git a/src/main.ts b/src/main.ts index b95c2a7..a0f6725 100644 --- a/src/main.ts +++ b/src/main.ts @@ -62,9 +62,11 @@ async function main(): Promise<void> { } // ── Restore nut positions (share URL takes priority over snapshot) ──────── - const sharedNuts = parseShareFromURL() - if (sharedNuts && sharedNuts.length > 0) { + 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 { @@ -112,7 +114,7 @@ async function main(): Promise<void> { tapsLocked = locked saveSettings({ nutRadius: currentNutRadius, locked: tapsLocked }) }, - getShareURL: () => buildShareURL(physics.nuts), + getShareURL: () => buildShareURL(physics.nuts, currentNutRadius), }) // ── Helpers ─────────────────────────────────────────────────────────────── @@ -136,6 +138,13 @@ 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, // otherwise we add. The mode persists until pointerup. @@ -143,6 +152,7 @@ async function main(): Promise<void> { let eraseMode = false canvas.addEventListener('pointerdown', (e: PointerEvent) => { + markActive() if (tapsLocked) return pointerDown = true canvas.setPointerCapture(e.pointerId) @@ -156,6 +166,7 @@ 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 @@ -266,8 +277,19 @@ async function main(): Promise<void> { 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 } + } + + const idleElapsed = timestamp - lastActivityTime - IDLE_DELAY_MS + const idleProgress = Math.max(0, Math.min(1, idleElapsed / IDLE_FADE_MS)) + const g = physics.engine.gravity - renderer.render(physics.nuts, Math.atan2(g.y, g.x) - Math.PI / 2) + renderer.setShareURL(buildShareURL(physics.nuts, currentNutRadius)) + renderer.render(physics.nuts, Math.atan2(g.y, g.x) - Math.PI / 2, idleProgress) // Save snapshot every ~3 s if (Math.floor(timestamp / 3000) !== Math.floor((timestamp - delta) / 3000)) { diff --git a/src/renderer.ts b/src/renderer.ts index ce12b6c..505b2ac 100644 --- a/src/renderer.ts +++ b/src/renderer.ts @@ -1,5 +1,6 @@ import type { NutBody } from './physics' import { CONFIG } from './config' +import QRCode from 'qrcode' // ── Nut palette – warm tones ─────────────────────────────────────────────── interface NutColor { hi: string; mid: string; shadow: string; rim: string } @@ -292,15 +293,17 @@ function drawBalloon( ) ctx.strokeStyle = c.string ctx.lineWidth = Math.max(1, r * 0.025) - ctx.globalAlpha = 0.75 + ctx.save() + ctx.globalAlpha *= 0.75 ctx.stroke() - ctx.globalAlpha = 1 + ctx.restore() } // ── Renderer ─────────────────────────────────────────────────────────────── export interface Renderer { - render: (nuts: NutBody[], gravityAngle: number) => void + render: (nuts: NutBody[], gravityAngle: number, idleProgress: number) => void + setShareURL: (url: string) => void resize: (width: number, height: number) => void flash: () => void } @@ -308,12 +311,41 @@ export interface Renderer { export function createRenderer(canvas: HTMLCanvasElement): Renderer { const ctx = canvas.getContext('2d')! let flashAlpha = 0 + let qrBitmap: HTMLCanvasElement | null = null + let qrPending = false + let lastURL = '' + + async function regenerateQR(url: string): Promise<void> { + const size = Math.min(canvas.width, canvas.height) + const qrCanvas = document.createElement('canvas') + await QRCode.toCanvas(qrCanvas, url, { + width: size, + margin: 2, + color: { dark: '#ffffff', light: '#00000000' }, + }) + qrBitmap = qrCanvas + qrPending = false + } + + function setShareURL(url: string): void { + if (url === lastURL) return + lastURL = url + if (!url) { + qrBitmap = null + qrPending = false + return + } + if (!qrPending) { + qrPending = true + regenerateQR(url) + } + } function flash(): void { flashAlpha = 0.35 } - function render(nuts: NutBody[], gravityAngle: number): void { + function render(nuts: NutBody[], gravityAngle: number, idleProgress: number): void { const w = canvas.width const h = canvas.height @@ -321,6 +353,21 @@ 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 @@ -339,9 +386,10 @@ export function createRenderer(canvas: HTMLCanvasElement): Renderer { ctx.drawImage(sprite, -sprite.width / 2, -sprite.height / 2, sprite.width, sprite.height) ctx.restore() } - ctx.restore() } + ctx.restore() // end items fade group + // Flash overlay (fallback haptic cue for Firefox / no-vibration devices) if (flashAlpha > 0) { ctx.fillStyle = `rgba(255,255,255,${flashAlpha.toFixed(3)})` @@ -355,5 +403,5 @@ export function createRenderer(canvas: HTMLCanvasElement): Renderer { canvas.height = height } - return { render, resize, flash } + return { render, setShareURL, resize, flash } } diff --git a/src/share.ts b/src/share.ts index 8dc5aa3..62b9dcf 100644 --- a/src/share.ts +++ b/src/share.ts @@ -1,9 +1,11 @@ import type { NutBody } from './physics' // ── Encoding ─────────────────────────────────────────────────────────────── +// Settings prefix: "z<nutRadius>" e.g. "z130" // Nuts: "r<radius>c<colorIndex>" e.g. "r130c2" // Gems: "r<radius>g<gemVariant>" e.g. "r80g1" -// Comma-separated, e.g. "r130c0,r80g1,r60g3" +// Balloons: "r<radius>b<colorIndex>" e.g. "r80b3" +// Comma-separated after settings: "z130,r130c0,r80g1" export interface SharedNut { radius: number @@ -12,6 +14,11 @@ export interface SharedNut { gemVariant: number } +export interface ParsedShare { + nuts: SharedNut[] + nutRadius: number | null +} + export function encodeNuts(nuts: NutBody[]): string { return nuts.map(n => { if (n.kind === 'gem') return `r${Math.round(n.radius)}g${n.gemVariant}` @@ -38,20 +45,54 @@ export function decodeNuts(encoded: string): SharedNut[] | null { } const BASE_URL = 'https://pavelsafronov.gitlab.io/nuts/' +const MAX_URL_LENGTH = 1800 // comfortable QR limit at medium error-correction -export function buildShareURL(nuts: NutBody[]): string { - const encoded = encodeNuts(nuts) - return `${BASE_URL}#s=${encodeURIComponent(encoded)}` +/** + * Always returns a URL. Encodes the nut size setting first, then fits as many + * items as possible within MAX_URL_LENGTH (truncating from the end). + */ +export function buildShareURL(nuts: NutBody[], nutRadius: number): string { + const settingsPart = `z${Math.round(nutRadius)}` + const base = `${BASE_URL}#s=${encodeURIComponent(settingsPart)}` + // Fit items greedily + const parts: string[] = [] + for (const n of nuts) { + const token = n.kind === 'gem' + ? `r${Math.round(n.radius)}g${n.gemVariant}` + : n.kind === 'balloon' + ? `r${Math.round(n.radius)}b${n.colorIndex % 7}` + : `r${Math.round(n.radius)}c${n.colorIndex % 5}` + const candidate = base + encodeURIComponent(',' + [...parts, token].join(',')) + if (candidate.length > MAX_URL_LENGTH) break + parts.push(token) + } + const payload = parts.length > 0 + ? `${settingsPart},${parts.join(',')}` + : settingsPart + return `${BASE_URL}#s=${encodeURIComponent(payload)}` } /** Parse share data from the current URL hash. Returns null if none present. */ -export function parseShareFromURL(): SharedNut[] | null { +export function parseShareFromURL(): ParsedShare | null { const m = location.hash.match(/[#&]s=([^&]*)/) if (!m) return null - const parsed = decodeNuts(decodeURIComponent(m[1])) + const raw = decodeURIComponent(m[1]) // Clear the hash so refreshing doesn't re-import history.replaceState(null, '', location.pathname + location.search) - return parsed + try { + const parts = raw.split(',') + let nutRadius: number | null = null + const nutParts: string[] = [] + for (const part of parts) { + const setting = part.match(/^z(\d+)$/) + if (setting) { nutRadius = parseInt(setting[1], 10); continue } + nutParts.push(part) + } + const nuts = nutParts.length > 0 ? decodeNuts(nutParts.join(',')) ?? [] : [] + return { nuts, nutRadius } + } catch { + return null + } } /** Place shared nuts/gems into the world with a loose random packing. */