nuts / commit
lots of changes, added balloons even
index.html | 25 ++- package.json | 5 +- pnpm-lock.yaml | 239 +++++++++++++++++++++++++++++ public/site.webmanifest | 1 + src/compat.ts | 67 +++++++++ src/config.ts | 22 +-- src/debug.ts | 82 ++++++++++ src/env.d.ts | 6 + src/main.ts | 178 ++++++++++++++++++++-- src/motion.ts | 24 ++- src/persistence.ts | 91 +++++++++++ src/physics.ts | 168 ++++++++++++++++++--- src/renderer.ts | 393 ++++++++++++++++++++++++++++++++++++------------ src/share.ts | 82 ++++++++++ src/style.css | 73 +++++++++ src/ui.ts | 90 +++++++++-- src/vibration.ts | 65 +++++++- vite.config.ts | 38 ++++- 18 files changed, 1474 insertions(+), 175 deletions(-) diff --git a/index.html b/index.html index 1813b3b..d7bbb7c 100644 --- a/index.html +++ b/index.html @@ -38,6 +38,15 @@ </div> </div> + <!-- Fullscreen guard overlay --> + <div id="fullscreen-overlay" hidden> + <div class="start-content"> + <img class="nut-icon" src="/acorn.png" alt="Acorn icon" width="80" height="80" /> + <h1>Nuts</h1> + <p>Tap to go fullscreen</p> + </div> + </div> + <!-- Settings button --> <button id="settings-btn" aria-label="Settings" hidden>⚙</button> @@ -49,14 +58,22 @@ <h2>Settings</h2> <label class="setting-row"> - <span>Nuts</span> - <span id="nut-count-display" class="setting-value">20</span> + <span>Size</span> + <span id="nut-size-display" class="setting-value">130</span> </label> - <input type="range" id="nut-count" min="1" max="50" value="20" step="1" /> + <input type="range" id="nut-size" min="10" max="180" value="130" step="1" /> - <button id="reset-btn" class="action-btn">Reset positions</button> + <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> + <div class="share-section"> + <p class="share-label">Share this collection</p> + <canvas id="qr-canvas" width="160" height="160"></canvas> + <button id="copy-link-btn" class="action-btn">Copy link</button> + </div> + <div class="panel-links"> <a href="https://pavelsafronov.gitlab.io/nuts/" target="_blank" rel="noopener">Live app</a> <span aria-hidden="true">·</span> diff --git a/package.json b/package.json index 1dafd7a..24cdaa7 100644 --- a/package.json +++ b/package.json @@ -11,11 +11,14 @@ "generate-icons": "tsx scripts/generate-icons.ts" }, "dependencies": { - "matter-js": "^0.19.0" + "matter-js": "^0.19.0", + "qrcode": "^1.5.4" }, "devDependencies": { "@types/matter-js": "^0.19.6", "@types/node": "^25.6.0", + "@types/qrcode": "^1.5.6", + "@vitejs/plugin-basic-ssl": "^2.3.0", "sharp": "^0.34.5", "tsx": "^4.21.0", "typescript": "^5.4.0", diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index b76ef08..e198ed1 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -11,6 +11,9 @@ importers: matter-js: specifier: ^0.19.0 version: 0.19.0 + qrcode: + specifier: ^1.5.4 + version: 1.5.4 devDependencies: '@types/matter-js': specifier: ^0.19.6 @@ -18,6 +21,12 @@ importers: '@types/node': specifier: ^25.6.0 version: 25.6.0 + '@types/qrcode': + specifier: ^1.5.6 + version: 1.5.6 + '@vitejs/plugin-basic-ssl': + specifier: ^2.3.0 + version: 2.3.0(vite@5.4.21(@types/node@25.6.0)) sharp: specifier: ^0.34.5 version: 0.34.5 @@ -604,10 +613,51 @@ packages: '@types/node@25.6.0': resolution: {integrity: sha512-+qIYRKdNYJwY3vRCZMdJbPLJAtGjQBudzZzdzwQYkEPQd+PJGixUL5QfvCLDaULoLv+RhT3LDkwEfKaAkgSmNQ==} + '@types/qrcode@1.5.6': + resolution: {integrity: sha512-te7NQcV2BOvdj2b1hCAHzAoMNuj65kNBMz0KBaxM6c3VGBOhU0dURQKOtH8CFNI/dsKkwlv32p26qYQTWoB5bw==} + + '@vitejs/plugin-basic-ssl@2.3.0': + resolution: {integrity: sha512-bdyo8rB3NnQbikdMpHaML9Z1OZPBu6fFOBo+OtxsBlvMJtysWskmBcnbIDhUqgC8tcxNv/a+BcV5U+2nQMm1OQ==} + engines: {node: ^18.0.0 || ^20.0.0 || >=22.0.0} + peerDependencies: + vite: ^6.0.0 || ^7.0.0 || ^8.0.0 + + ansi-regex@5.0.1: + resolution: {integrity: sha512-quJQXlTSUGL2LH9SUXo8VwsY4soanhgo6LNSm84E1LBcE8s3O0wpdiRzyR9z/ZZJMlMWv37qOOb9pdJlMUEKFQ==} + engines: {node: '>=8'} + + ansi-styles@4.3.0: + resolution: {integrity: sha512-zbB9rCJAT1rbjiVDb2hqKFHNYLxgtk8NURxZ3IZwD3F6NtxbXZQCnnSi1Lkx+IDohdPlFp222wVALIheZJQSEg==} + engines: {node: '>=8'} + + camelcase@5.3.1: + resolution: {integrity: sha512-L28STB170nwWS63UjtlEOE3dldQApaJXZkOI1uMFfzf3rRuPegHaHesyee+YxQ+W6SvRDQV6UrdOdRiR153wJg==} + engines: {node: '>=6'} + + cliui@6.0.0: + resolution: {integrity: sha512-t6wbgtoCXvAzst7QgXxJYqPt0usEfbgQdftEPbLL/cvv6HPE5VgvqCuAIDR0NgU52ds6rFwqrgakNLrHEjCbrQ==} + + color-convert@2.0.1: + resolution: {integrity: sha512-RRECPsj7iu/xb5oKYcsFHSppFNnsj/52OVTRKb4zP5onXwVF3zVmmToNcOfGC+CRDpfK/U584fMg38ZHCaElKQ==} + engines: {node: '>=7.0.0'} + + color-name@1.1.4: + resolution: {integrity: sha512-dOy+3AuW3a2wNbZHIuMZpTcgjGuLU/uBL/ubcZF9OXbDo8ff4O8yVp5Bf0efS8uEoYo5q4Fx7dY9OgQGXgAsQA==} + + decamelize@1.2.0: + resolution: {integrity: sha512-z2S+W9X73hAUUki+N+9Za2lBlun89zigOyGrsax+KUQ6wKW4ZoWpEYBkGhQjwAjjDCkWxhY0VKEhk8wzY7F5cA==} + engines: {node: '>=0.10.0'} + detect-libc@2.1.2: resolution: {integrity: sha512-Btj2BOOO83o3WyH59e8MgXsxEQVcarkUOpEYrubB0urwnN10yQ364rsiByU11nZlqWYZm05i/of7io4mzihBtQ==} engines: {node: '>=8'} + dijkstrajs@1.0.3: + resolution: {integrity: sha512-qiSlmBq9+BCdCA/L46dw8Uy93mloxsPSbwnm5yrKn2vMPiy8KyAskTF6zuV/j5BMsmOGZDPs7KjU+mjb670kfA==} + + emoji-regex@8.0.0: + resolution: {integrity: sha512-MSjYzcWNOA0ewAHpz0MxpYFvwg6yjy1NG3xteoqz644VCo/RPgnr1/GGt+ic3iJTzQ8Eu3TdM14SawnVUmGE6A==} + esbuild@0.21.5: resolution: {integrity: sha512-mg3OPMV4hXywwpoDxu3Qda5xCKQi+vCTZq8S9J/EpkhB2HzKXq4SNFZE3+NK93JYxc8VMSep+lOUSC/RVKaBqw==} engines: {node: '>=12'} @@ -618,14 +668,30 @@ packages: engines: {node: '>=18'} hasBin: true + find-up@4.1.0: + resolution: {integrity: sha512-PpOwAdQ/YlXQ2vj8a3h8IipDuYRi3wceVQQGYWxNINccq40Anw7BlsEXCMbt1Zt+OLA6Fq9suIpIWD0OsnISlw==} + engines: {node: '>=8'} + fsevents@2.3.3: resolution: {integrity: sha512-5xoDfX+fL7faATnagmWPpbFtwh/R77WmMMqqHGS65C3vvB0YHrgF+B1YmZ3441tMj5n63k0212XNoJwzlhffQw==} engines: {node: ^8.16.0 || ^10.6.0 || >=11.0.0} os: [darwin] + get-caller-file@2.0.5: + resolution: {integrity: sha512-DyFP3BM/3YHTQOCUL/w0OZHR0lpKeGrxotcHWcqNEdnltqFwXVfhEBQ94eIo34AfQpo0rGki4cyIiftY06h2Fg==} + engines: {node: 6.* || 8.* || >= 10.*} + get-tsconfig@4.13.7: resolution: {integrity: sha512-7tN6rFgBlMgpBML5j8typ92BKFi2sFQvIdpAqLA2beia5avZDrMs0FLZiM5etShWq5irVyGcGMEA1jcDaK7A/Q==} + is-fullwidth-code-point@3.0.0: + resolution: {integrity: sha512-zymm5+u+sCsSWyD9qNaejV3DFvhCKclKdizYaJUuHA83RLjb7nSuGnddCHGv0hk+KY7BMAlsWeK4Ueg6EV6XQg==} + engines: {node: '>=8'} + + locate-path@5.0.0: + resolution: {integrity: sha512-t7hw9pI+WvuwNJXwk5zVHpyhIqzg2qTlklJOf0mVxGSbe3Fp2VieZcduNYjaLDoy6p9uGpQEGWG87WpMKlNq8g==} + engines: {node: '>=8'} + matter-js@0.19.0: resolution: {integrity: sha512-v2huwvQGOHTGOkMqtHd2hercCG3f6QAObTisPPHg8TZqq2lz7eIY/5i/5YUV8Ibf3mEioFEmwibcPUF2/fnKKQ==} @@ -634,13 +700,45 @@ packages: engines: {node: ^10 || ^12 || ^13.7 || ^14 || >=15.0.1} hasBin: true + p-limit@2.3.0: + resolution: {integrity: sha512-//88mFWSJx8lxCzwdAABTJL2MyWB12+eIY7MDL2SqLmAkeKU9qxRvWuSyTjm3FUmpBEMuFfckAIqEaVGUDxb6w==} + engines: {node: '>=6'} + + p-locate@4.1.0: + resolution: {integrity: sha512-R79ZZ/0wAxKGu3oYMlz8jy/kbhsNrS7SKZ7PxEHBgJ5+F2mtFW2fK2cOtBh1cHYkQsbzFV7I+EoRKe6Yt0oK7A==} + engines: {node: '>=8'} + + p-try@2.2.0: + resolution: {integrity: sha512-R4nPAVTAU0B9D35/Gk3uJf/7XYbQcyohSKdvAxIRSNghFl4e71hVoGnBNQz9cWaXxO2I10KTC+3jMdvvoKw6dQ==} + engines: {node: '>=6'} + + path-exists@4.0.0: + resolution: {integrity: sha512-ak9Qy5Q7jYb2Wwcey5Fpvg2KoAc/ZIhLSLOSBmRmygPsGwkVVt0fZa0qrtMz+m6tJTAHfZQ8FnmB4MG4LWy7/w==} + engines: {node: '>=8'} + picocolors@1.1.1: resolution: {integrity: sha512-xceH2snhtb5M9liqDsmEw56le376mTZkEX/jEb/RxNFyegNul7eNslCXP9FDj/Lcu0X8KEyMceP2ntpaHrDEVA==} + pngjs@5.0.0: + resolution: {integrity: sha512-40QW5YalBNfQo5yRYmiw7Yz6TKKVr3h6970B2YE+3fQpsWcrbj1PzJgxeJ19DRQjhMbKPIuMY8rFaXc8moolVw==} + engines: {node: '>=10.13.0'} + postcss@8.5.9: resolution: {integrity: sha512-7a70Nsot+EMX9fFU3064K/kdHWZqGVY+BADLyXc8Dfv+mTLLVl6JzJpPaCZ2kQL9gIJvKXSLMHhqdRRjwQeFtw==} engines: {node: ^10 || ^12 || >=14} + qrcode@1.5.4: + resolution: {integrity: sha512-1ca71Zgiu6ORjHqFBDpnSMTR2ReToX4l1Au1VFLyVeBTFavzQnv5JxMFr3ukHVKpSrSA2MCk0lNJSykjUfz7Zg==} + engines: {node: '>=10.13.0'} + hasBin: true + + require-directory@2.1.1: + resolution: {integrity: sha512-fGxEI7+wsG9xrvdjsrlmL22OMTTiHRwAMroiEeMgq8gzoLC/PQr7RsRDSTLUg/bZAZtF+TVIkHc6/4RIKrui+Q==} + engines: {node: '>=0.10.0'} + + require-main-filename@2.0.0: + resolution: {integrity: sha512-NKN5kMDylKuldxYLSUfrbo5Tuzh4hd+2E8NPPX02mZtn1VuREQToYe/ZdlJy+J3uCpfaiGF05e7B8W0iXbQHmg==} + resolve-pkg-maps@1.0.0: resolution: {integrity: sha512-seS2Tj26TBVOC2NIc2rOe2y2ZO7efxITtLZcGSOnHHNOQ7CkiUBfw0Iw2ck6xkIhPwLhKNLS8BO+hEpngQlqzw==} @@ -654,6 +752,9 @@ packages: engines: {node: '>=10'} hasBin: true + set-blocking@2.0.0: + resolution: {integrity: sha512-KiKBS8AnWGEyLzofFfmvKwpdPzqiy16LvQfK3yv/fVH7Bj13/wl3JSR1J+rfgRE9q7xUJK4qvgS8raSOeLUehw==} + sharp@0.34.5: resolution: {integrity: sha512-Ou9I5Ft9WNcCbXrU9cMgPBcCK8LiwLqcbywW3t4oDV37n1pzpuNLsYiAV8eODnjbtQlSDwZ2cUEeQz4E54Hltg==} engines: {node: ^18.17.0 || ^20.3.0 || >=21.0.0} @@ -662,6 +763,14 @@ packages: resolution: {integrity: sha512-UXWMKhLOwVKb728IUtQPXxfYU+usdybtUrK/8uGE8CQMvrhOpwvzDBwj0QhSL7MQc7vIsISBG8VQ8+IDQxpfQA==} engines: {node: '>=0.10.0'} + string-width@4.2.3: + resolution: {integrity: sha512-wKyQRQpjJ0sIp62ErSZdGsjMJWsap5oRNihHhu6G7JVO/9jIB6UyevL+tXuOqrng8j/cxKTWyWUwvSTriiZz/g==} + engines: {node: '>=8'} + + strip-ansi@6.0.1: + resolution: {integrity: sha512-Y38VPSHcqkFrCpFnQ9vuSXmquuv5oXOKpGeT6aGrr3o3Gc9AlVa6JBfUSOCnbxGGZF+/0ooI7KrPuUSztUdU5A==} + engines: {node: '>=8'} + tslib@2.8.1: resolution: {integrity: sha512-oJFu94HQb+KVduSUQL7wnpmqnfmLsOA/nAh6b6EH0wCEoK0/mPeXU6c3wKDV83MkOuHPRHtSXKKU99IBazS/2w==} @@ -717,6 +826,24 @@ packages: terser: optional: true + which-module@2.0.1: + resolution: {integrity: sha512-iBdZ57RDvnOR9AGBhML2vFZf7h8vmBjhoaZqODJBFWHVtKkDmKuHai3cx5PgVMrX5YDNp27AofYbAwctSS+vhQ==} + + wrap-ansi@6.2.0: + resolution: {integrity: sha512-r6lPcBGxZXlIcymEu7InxDMhdW0KDxpLgoFLcguasxCaJ/SOIZwINatK9KY/tf+ZrlywOKU0UDj3ATXUBfxJXA==} + engines: {node: '>=8'} + + y18n@4.0.3: + resolution: {integrity: sha512-JKhqTOwSrqNA1NY5lSztJ1GrBiUodLMmIZuLiDaMRJ+itFd+ABVE8XBjOvIWL+rSqNDC74LCSFmlb/U4UZ4hJQ==} + + yargs-parser@18.1.3: + resolution: {integrity: sha512-o50j0JeToy/4K6OZcaQmW6lyXXKhq7csREXcDwk2omFPJEwUNOVtJKvmDr9EI1fAJZUyZcRF7kxGBWmRXudrCQ==} + engines: {node: '>=6'} + + yargs@15.4.1: + resolution: {integrity: sha512-aePbxDmcYW++PaqBsJ+HYUFwCdv4LVvdnhBy78E57PIor8/OVvhMrADFFEDh8DHDFRv/O9i3lPhsENjO7QX0+A==} + engines: {node: '>=8'} + snapshots: '@emnapi/runtime@1.9.2': @@ -1050,8 +1177,42 @@ snapshots: dependencies: undici-types: 7.19.2 + '@types/qrcode@1.5.6': + dependencies: + '@types/node': 25.6.0 + + '@vitejs/plugin-basic-ssl@2.3.0(vite@5.4.21(@types/node@25.6.0))': + dependencies: + vite: 5.4.21(@types/node@25.6.0) + + ansi-regex@5.0.1: {} + + ansi-styles@4.3.0: + dependencies: + color-convert: 2.0.1 + + camelcase@5.3.1: {} + + cliui@6.0.0: + dependencies: + string-width: 4.2.3 + strip-ansi: 6.0.1 + wrap-ansi: 6.2.0 + + color-convert@2.0.1: + dependencies: + color-name: 1.1.4 + + color-name@1.1.4: {} + + decamelize@1.2.0: {} + detect-libc@2.1.2: {} + dijkstrajs@1.0.3: {} + + emoji-regex@8.0.0: {} + esbuild@0.21.5: optionalDependencies: '@esbuild/aix-ppc64': 0.21.5 @@ -1107,25 +1268,62 @@ snapshots: '@esbuild/win32-ia32': 0.27.7 '@esbuild/win32-x64': 0.27.7 + find-up@4.1.0: + dependencies: + locate-path: 5.0.0 + path-exists: 4.0.0 + fsevents@2.3.3: optional: true + get-caller-file@2.0.5: {} + get-tsconfig@4.13.7: dependencies: resolve-pkg-maps: 1.0.0 + is-fullwidth-code-point@3.0.0: {} + + locate-path@5.0.0: + dependencies: + p-locate: 4.1.0 + matter-js@0.19.0: {} nanoid@3.3.11: {} + p-limit@2.3.0: + dependencies: + p-try: 2.2.0 + + p-locate@4.1.0: + dependencies: + p-limit: 2.3.0 + + p-try@2.2.0: {} + + path-exists@4.0.0: {} + picocolors@1.1.1: {} + pngjs@5.0.0: {} + postcss@8.5.9: dependencies: nanoid: 3.3.11 picocolors: 1.1.1 source-map-js: 1.2.1 + qrcode@1.5.4: + dependencies: + dijkstrajs: 1.0.3 + pngjs: 5.0.0 + yargs: 15.4.1 + + require-directory@2.1.1: {} + + require-main-filename@2.0.0: {} + resolve-pkg-maps@1.0.0: {} rollup@4.60.1: @@ -1161,6 +1359,8 @@ snapshots: semver@7.7.4: {} + set-blocking@2.0.0: {} + sharp@0.34.5: dependencies: '@img/colour': 1.1.0 @@ -1194,6 +1394,16 @@ snapshots: source-map-js@1.2.1: {} + string-width@4.2.3: + dependencies: + emoji-regex: 8.0.0 + is-fullwidth-code-point: 3.0.0 + strip-ansi: 6.0.1 + + strip-ansi@6.0.1: + dependencies: + ansi-regex: 5.0.1 + tslib@2.8.1: optional: true @@ -1223,3 +1433,32 @@ snapshots: optionalDependencies: '@types/node': 25.6.0 fsevents: 2.3.3 + + which-module@2.0.1: {} + + wrap-ansi@6.2.0: + dependencies: + ansi-styles: 4.3.0 + string-width: 4.2.3 + strip-ansi: 6.0.1 + + y18n@4.0.3: {} + + yargs-parser@18.1.3: + dependencies: + camelcase: 5.3.1 + decamelize: 1.2.0 + + yargs@15.4.1: + dependencies: + cliui: 6.0.0 + decamelize: 1.2.0 + find-up: 4.1.0 + get-caller-file: 2.0.5 + require-directory: 2.1.1 + require-main-filename: 2.0.0 + set-blocking: 2.0.0 + string-width: 4.2.3 + which-module: 2.0.1 + y18n: 4.0.3 + yargs-parser: 18.1.3 diff --git a/public/site.webmanifest b/public/site.webmanifest index 2298244..78cd33d 100644 --- a/public/site.webmanifest +++ b/public/site.webmanifest @@ -4,6 +4,7 @@ "description": "A box of nuts bouncing around with haptic feedback.", "start_url": "./", "display": "fullscreen", + "orientation": "portrait", "background_color": "#111111", "theme_color": "#111111", "icons": [ diff --git a/src/compat.ts b/src/compat.ts new file mode 100644 index 0000000..b35c5f7 --- /dev/null +++ b/src/compat.ts @@ -0,0 +1,67 @@ +export type CompatResult = + | { ok: true } + | { ok: false; reason: 'no-touch' | 'no-motion' | 'no-vibration' } + +/** + * Check whether this platform can run the full experience. + * Returns immediately (no async, no permissions needed). + */ +export function checkCompat(): CompatResult { + // Must be a touch device + if (!('ontouchstart' in window) && !navigator.maxTouchPoints) { + return { ok: false, reason: 'no-touch' } + } + + // Must have DeviceMotion (accelerometer / tilt gravity) + if (typeof DeviceMotionEvent === 'undefined') { + return { ok: false, reason: 'no-motion' } + } + + // Must have the Vibration API + if (!('vibrate' in navigator) || typeof navigator.vibrate !== 'function') { + return { ok: false, reason: 'no-vibration' } + } + + return { ok: true } +} + +const MESSAGES: Record<string, { headline: string; body: string }> = { + 'no-touch': { + headline: 'Phone only', + body: 'This app needs a touchscreen to work.\nOpen it on your Android phone in Chrome.', + }, + 'no-motion': { + headline: 'No accelerometer', + body: 'This app needs a motion sensor to work.\nOpen it on your Android phone in Chrome.', + }, + 'no-vibration': { + headline: 'No haptics', + body: 'This app needs vibration to work.\nOpen it in Chrome on your Android phone\n— Firefox and iOS don\'t support haptics.', + }, +} + +/** Replace the page body with a full-screen "not supported" wall. */ +export function showUnsupportedWall(reason: string): void { + const { headline, body } = MESSAGES[reason] ?? { + headline: 'Not supported', + body: 'Open this on your Android phone in Chrome.', + } + + document.body.innerHTML = ` + <div style=" + position:fixed;inset:0; + background:#111; + display:flex;flex-direction:column;align-items:center;justify-content:center; + padding:32px;gap:20px; + font-family:system-ui,-apple-system,sans-serif; + text-align:center; + "> + <div style="font-size:72px;line-height:1;filter:grayscale(1) opacity(0.4)">🔩</div> + <h1 style="color:#fff;font-size:28px;font-weight:700;margin:0">${headline}</h1> + <p style="color:#666;font-size:16px;line-height:1.6;margin:0;white-space:pre-line">${body}</p> + <a href="https://pavelsafronov.gitlab.io/nuts/" + style="margin-top:8px;color:#c8a84b;font-size:13px;opacity:0.6;text-decoration:none"> + pavelsafronov.gitlab.io/nuts + </a> + </div>` +} diff --git a/src/config.ts b/src/config.ts index 6e385e4..581613a 100644 --- a/src/config.ts +++ b/src/config.ts @@ -1,24 +1,24 @@ export const CONFIG = { // Nuts - defaultNutCount: 20, - minNuts: 1, - maxNuts: 50, - nutBaseRadius: 22, // px, circumradius of hex polygon - nutRadiusVariance: 0.28, // ±28% random size variance + nutBaseRadius: 130, // px – semi-major axis (runtime overridden by size slider) + nutMinRadius: 10, // px – slider minimum + nutMaxRadius: 180, // px – slider maximum + nutAspectRatio: 1.75, // width:height – peanut-like elongation + nutRadiusVariance: 0.12, // ±12% size variance // Physics - restitution: 0.45, // bounciness - friction: 0.18, // surface friction - frictionAir: 0.012, // air resistance / damping + restitution: 0.7, // bounciness + friction: 0.02, // surface friction + frictionAir: 0.002, // air resistance / damping gravityScale: 1, // Walls wallThickness: 60, // Collision haptics - impactThreshold: 3, // min speed (px/step) to trigger vibration - maxVibrationMs: 80, // cap on vibration duration - vibrationCooldownMs: 40, // min ms between vibration calls + impactThreshold: 0.5, // min speed (px/step) to trigger vibration + maxVibrationMs: 120, // cap on vibration duration + vibrationCooldownMs: 150, // min ms between vibration calls // Shake shakeAccelThreshold: 14, // m/s² delta to count as shake diff --git a/src/debug.ts b/src/debug.ts new file mode 100644 index 0000000..f827aa7 --- /dev/null +++ b/src/debug.ts @@ -0,0 +1,82 @@ +/** + * Tiny on-screen debug log + remote terminal forwarder. + * Call dbg('some message') from anywhere to show it in the overlay + * and forward it to the Vite dev server terminal as [web] prefix. + * Tap the overlay to dismiss. + * + * In production (import.meta.env.PROD) all calls are no-ops. + */ + +const ENABLED = false // flip to true to re-enable on-screen log + remote forwarding + +let panel: HTMLDivElement | null = null +const MAX_LINES = 20 +const lines: string[] = [] + +function getPanel(): HTMLDivElement { + if (panel) return panel + panel = document.createElement('div') + Object.assign(panel.style, { + position: 'fixed', + bottom: '0', + left: '0', + right: '0', + maxHeight: '40vh', + overflowY: 'auto', + background: 'rgba(0,0,0,0.78)', + color: '#0f0', + fontFamily: 'monospace', + fontSize: '11px', + padding: '8px', + zIndex: '9999', + whiteSpace: 'pre-wrap', + wordBreak: 'break-all', + pointerEvents: 'auto', + }) + panel.addEventListener('click', () => { + panel?.remove() + panel = null + lines.length = 0 + }) + document.body.appendChild(panel) + return panel +} + +function ts(): string { + const d = new Date() + return `${d.getHours().toString().padStart(2, '0')}:${d.getMinutes().toString().padStart(2, '0')}:${d.getSeconds().toString().padStart(2, '0')}.${d.getMilliseconds().toString().padStart(3, '0')}` +} + +function sendToServer(level: 'log' | 'error', msg: string): void { + // Best-effort: fire-and-forget, never throws + fetch('/__log', { + method: 'POST', + headers: { 'content-type': 'application/json' }, + body: JSON.stringify({ level, msg }), + }).catch(() => { /* dev server may not be listening */ }) +} + +function display(msg: string, isError = false): void { + lines.push(msg) + if (lines.length > MAX_LINES) lines.shift() + const p = getPanel() + p.style.color = isError ? '#f55' : '#0f0' + p.textContent = lines.join('\n') + p.scrollTop = p.scrollHeight +} + +export function dbg(...args: unknown[]): void { + if (import.meta.env.PROD || !ENABLED) return + const msg = `[${ts()}] ${args.map(a => typeof a === 'object' ? JSON.stringify(a) : String(a)).join(' ')}` + console.log(msg) + display(msg) + sendToServer('log', msg) +} + +export function dbgError(...args: unknown[]): void { + if (import.meta.env.PROD || !ENABLED) return + const msg = `[${ts()}] ❌ ${args.map(a => a instanceof Error ? a.message : typeof a === 'object' ? JSON.stringify(a) : String(a)).join(' ')}` + console.error(msg) + display(msg, true) + sendToServer('error', msg) +} diff --git a/src/env.d.ts b/src/env.d.ts new file mode 100644 index 0000000..9f90c76 --- /dev/null +++ b/src/env.d.ts @@ -0,0 +1,6 @@ +/// <reference types="vite/client" /> + +declare module '*.png' { + const url: string + export default url +} diff --git a/src/main.ts b/src/main.ts index 89241c7..b95c2a7 100644 --- a/src/main.ts +++ b/src/main.ts @@ -3,10 +3,24 @@ import { createPhysicsWorld } from './physics' import { createRenderer } from './renderer' import { requestMotionPermission, initMotion } from './motion' import { initUI, removeStartOverlay } from './ui' +import { setFlashFallback, unlockVibration } from './vibration' +import { dbg, dbgError } from './debug' +import { checkCompat, showUnsupportedWall } from './compat' +import { + loadSettings, saveSettings, + loadSnapshot, saveSnapshot, clearSnapshot, +} from './persistence' +import { buildShareURL, parseShareFromURL, placeSharedNuts } from './share' // ── Bootstrap ────────────────────────────────────────────────────────────── async function main(): Promise<void> { + const compat = checkCompat() + if (!compat.ok) { + showUnsupportedWall(compat.reason) + return + } + const canvas = document.getElementById('canvas') as HTMLCanvasElement // Match canvas to the physical pixel dimensions for crisp rendering on HiDPI @@ -30,24 +44,128 @@ async function main(): Promise<void> { const physics = createPhysicsWorld(cssWidth, cssHeight) const renderer = createRenderer(canvas) + // Wire visual flash fallback for browsers without Vibration API (e.g. Firefox) + setFlashFallback(() => renderer.flash()) + // 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 nutCount: number = CONFIG.defaultNutCount - physics.setNutCount(nutCount) + let currentNutRadius: number = CONFIG.nutBaseRadius + let tapsLocked = false + + // ── Restore persisted settings ──────────────────────────────────────────── + const savedSettings = loadSettings() + if (savedSettings) { + currentNutRadius = savedSettings.nutRadius + tapsLocked = savedSettings.locked + } + + // ── Restore nut positions (share URL takes priority over snapshot) ──────── + const sharedNuts = parseShareFromURL() + if (sharedNuts && sharedNuts.length > 0) { + clearSnapshot() + // 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 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 physics.addNut(x, y, n.radius) + } + } + } // ── UI ─────────────────────────────────────────────────────────────────── initUI({ - onNutCountChange: (count) => { - nutCount = count - physics.setNutCount(count) + initialRadius: currentNutRadius, + initialLocked: tapsLocked, + onSizeChange: (radius) => { + currentNutRadius = radius + saveSettings({ nutRadius: currentNutRadius, locked: tapsLocked }) }, - onReset: () => { - physics.setNutCount(nutCount) + onClear: () => { + physics.clearNuts() + clearSnapshot() }, + onLockChange: (locked) => { + tapsLocked = locked + saveSettings({ nutRadius: currentNutRadius, locked: tapsLocked }) + }, + getShareURL: () => buildShareURL(physics.nuts), }) + // ── 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 + } + + function tryAddNut(cx: number, cy: number): void { + if (tapsLocked) return // already guarded at pointerdown, but keep for safety + 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) + else physics.addNut(cx, cy, currentNutRadius) + } + } + + // ── 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 + canvas.setPointerCapture(e.pointerId) + 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) + }) + + 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 @@ -68,6 +186,7 @@ async function main(): Promise<void> { function startMotion(): void { if (motionEnabled) return motionEnabled = true + dbg('startMotion, vibrate?', 'vibrate' in navigator, 'protocol:', location.protocol) initMotion({ onShake: (intensity) => { physics.applyShakeForce(intensity) @@ -78,6 +197,31 @@ async function main(): Promise<void> { }) } + // ── 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 + } + } + + // ── 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( @@ -85,9 +229,11 @@ async function main(): Promise<void> { async () => { try { await requestMotionPermission() - } catch { - // Permission denied or unavailable – proceed without motion + } catch (e) { + dbgError('requestMotionPermission failed', e) } + unlockVibration() // must be called from gesture handler + await enterFullscreen() startMotion() removeStartOverlay() startLoop() @@ -120,10 +266,22 @@ async function main(): Promise<void> { lastTimestamp = timestamp physics.step(delta) - renderer.render(physics.nuts) + const g = physics.engine.gravity + renderer.render(physics.nuts, Math.atan2(g.y, g.x) - Math.PI / 2) + + // Save snapshot every ~3 s + if (Math.floor(timestamp / 3000) !== Math.floor((timestamp - delta) / 3000)) { + saveSnapshot(physics.nuts, cssWidth, cssHeight) + } requestAnimationFrame(loop) } + + // Also save on tab close / navigation + window.addEventListener('pagehide', () => { + saveSnapshot(physics.nuts, cssWidth, cssHeight) + saveSettings({ nutRadius: currentNutRadius, locked: tapsLocked }) + }) } main().catch(console.error) diff --git a/src/motion.ts b/src/motion.ts index 220b35f..c01df9f 100644 --- a/src/motion.ts +++ b/src/motion.ts @@ -1,5 +1,6 @@ import { CONFIG } from './config' import { vibrateShake } from './vibration' +import { dbg, dbgError } from './debug' export interface MotionCallbacks { onShake: (intensity: number) => void @@ -15,9 +16,12 @@ export async function requestMotionPermission(): Promise<void> { typeof (DeviceMotionEvent as unknown as { requestPermission: () => Promise<string> }) .requestPermission === 'function' ) { - await ( + const result = await ( DeviceMotionEvent as unknown as { requestPermission: () => Promise<string> } ).requestPermission() + dbg('DeviceMotionEvent permission:', result) + } else { + dbg('DeviceMotionEvent: no permission API (Android or desktop)') } // DeviceOrientationEvent @@ -27,20 +31,34 @@ export async function requestMotionPermission(): Promise<void> { typeof (DeviceOrientationEvent as unknown as { requestPermission: () => Promise<string> }) .requestPermission === 'function' ) { - await ( + const result = await ( DeviceOrientationEvent as unknown as { requestPermission: () => Promise<string> } ).requestPermission() + dbg('DeviceOrientationEvent permission:', result) } } /** Attach DeviceMotion (shake) and DeviceOrientation (gravity tilt) listeners. */ export function initMotion(callbacks: MotionCallbacks): () => void { + dbg('initMotion: attaching listeners, secure?', location.protocol) + + if (typeof DeviceMotionEvent === 'undefined') { + dbgError('DeviceMotionEvent not available') + } + if (typeof DeviceOrientationEvent === 'undefined') { + dbgError('DeviceOrientationEvent not available') + } + let prevX = 0 let prevY = 0 let prevZ = 0 let lastCheckAt = 0 + let motionCount = 0 + let orientationCount = 0 function onMotion(event: DeviceMotionEvent): void { + motionCount++ + if (motionCount === 1) dbg('first devicemotion event') const acc = event.accelerationIncludingGravity if (!acc) return @@ -67,6 +85,8 @@ export function initMotion(callbacks: MotionCallbacks): () => void { } function onOrientation(event: DeviceOrientationEvent): void { + orientationCount++ + if (orientationCount === 1) dbg('first deviceorientation event') if (event.beta === null || event.gamma === null) return // beta = front/back tilt, 0 = flat, 90 = vertical facing up diff --git a/src/persistence.ts b/src/persistence.ts new file mode 100644 index 0000000..f380786 --- /dev/null +++ b/src/persistence.ts @@ -0,0 +1,91 @@ +const DATA_VERSION = 2 + +const KEYS = { + settings: 'nuts-settings-v2', + snapshot: 'nuts-snapshot-v2', +} as const + +// ── Settings ─────────────────────────────────────────────────────────────── + +export interface PersistedSettings { + v: number + nutRadius: number + locked: boolean +} + +export function loadSettings(): PersistedSettings | null { + try { + const raw = localStorage.getItem(KEYS.settings) + if (!raw) return null + const parsed = JSON.parse(raw) as PersistedSettings + if (parsed.v !== DATA_VERSION) return null + return parsed + } catch { + return null + } +} + +export function saveSettings(s: Omit<PersistedSettings, 'v'>): void { + try { + localStorage.setItem(KEYS.settings, JSON.stringify({ v: DATA_VERSION, ...s })) + } catch { /* storage full – ignore */ } +} + +// ── Nut snapshot ─────────────────────────────────────────────────────────── + +export interface NutSnapshot { + x: number // normalised 0..1 (relative to screen size) + y: number + angle: number + radius: number // absolute px + colorIndex: number + kind: 'nut' | 'gem' | 'balloon' + gemVariant: number +} + +export interface SnapshotRecord { + v: number + screenW: number + screenH: number + nuts: NutSnapshot[] +} + +export function saveSnapshot( + nuts: Array<{ body: { position: { x: number; y: number }; angle: number }; radius: number; colorIndex: number; kind: 'nut' | 'gem' | 'balloon'; gemVariant: number }>, + screenW: number, + screenH: number, +): void { + try { + const record: SnapshotRecord = { + v: DATA_VERSION, + screenW, + screenH, + nuts: nuts.map(n => ({ + x: n.body.position.x / screenW, + y: n.body.position.y / screenH, + angle: n.body.angle, + radius: n.radius, + colorIndex: n.colorIndex, + kind: n.kind, + gemVariant: n.gemVariant, + })), + } + localStorage.setItem(KEYS.snapshot, JSON.stringify(record)) + } catch { /* storage full – ignore */ } +} + +export function loadSnapshot(): SnapshotRecord | null { + try { + const raw = localStorage.getItem(KEYS.snapshot) + if (!raw) return null + const record = JSON.parse(raw) as SnapshotRecord + if (record.v !== DATA_VERSION) return null + return record + } catch { + return null + } +} + +export function clearSnapshot(): void { + localStorage.removeItem(KEYS.snapshot) +} diff --git a/src/physics.ts b/src/physics.ts index a14b787..8a0267d 100644 --- a/src/physics.ts +++ b/src/physics.ts @@ -1,8 +1,9 @@ import Matter from 'matter-js' import { CONFIG } from './config' import { vibrate } from './vibration' +import { dbg } from './debug' -const { Engine, Bodies, Body, Composite, Events } = Matter +const { Engine, Bodies, Body, Composite, Events, Vector } = Matter // ── Types ────────────────────────────────────────────────────────────────── @@ -10,12 +11,18 @@ export interface NutBody { body: Matter.Body radius: number colorIndex: number + kind: 'nut' | 'gem' | 'balloon' + gemVariant: number // gems: 0=diamond…4=amethyst balloons: colorIndex } export interface PhysicsWorld { engine: Matter.Engine nuts: NutBody[] - setNutCount: (count: number) => void + addNut: (x: number, y: number, nutRadius?: number) => void + addGem: (x: number, y: number, radius?: number, gemVariant?: number) => void + addBalloon: (x: number, y: number, radius?: number, colorIndex?: number) => void + removeAt: (x: number, y: number) => boolean + clearNuts: () => void resize: (width: number, height: number) => void applyShakeForce: (intensity: number) => void setGravity: (gx: number, gy: number) => void @@ -36,19 +43,55 @@ function buildWalls(width: number, height: number): Matter.Body[] { ] } -function spawnNut(width: number, height: number, colorIndex: number): NutBody { +function spawnNut(x: number, y: number, nutRadius: number, colorIndex: number): NutBody { const variance = 1 + (Math.random() * 2 - 1) * CONFIG.nutRadiusVariance - const radius = CONFIG.nutBaseRadius * variance - const margin = radius * 2 - const x = margin + Math.random() * (width - margin * 2) - const y = margin + Math.random() * (height * 0.5) - const body = Bodies.polygon(x, y, 6, radius, { + const radius = nutRadius * variance + const ry = radius / CONFIG.nutAspectRatio + + // Convex ellipse polygon approximation (20 vertices) + const N = 20 + const verts: Matter.Vector[] = [] + for (let i = 0; i < N; i++) { + const a = (i / N) * Math.PI * 2 + verts.push(Vector.create(Math.cos(a) * radius, Math.sin(a) * ry)) + } + + const body = Bodies.fromVertices(x, y, [verts], { restitution: CONFIG.restitution, friction: CONFIG.friction, frictionAir: CONFIG.frictionAir, label: 'nut', }) - return { body, radius, colorIndex } + return { body, radius, colorIndex, kind: 'nut', gemVariant: 0 } +} + +const GEM_SIDES = [4, 6, 6, 8, 6] as const // sides per gem variant +export const GEM_VARIANT_COUNT = GEM_SIDES.length +export const BALLOON_COLOR_COUNT = 7 + +function spawnGem(x: number, y: number, radius: number, gemVariant: number): NutBody { + const variance = 1 + (Math.random() * 2 - 1) * CONFIG.nutRadiusVariance + const actualRadius = radius * variance + const sides = GEM_SIDES[gemVariant % GEM_SIDES.length] + const body = Bodies.polygon(x, y, sides, actualRadius, { + restitution: CONFIG.restitution, + friction: CONFIG.friction, + frictionAir: CONFIG.frictionAir, + label: 'gem', + }) + return { body, radius: actualRadius, colorIndex: 0, kind: 'gem', gemVariant } +} + +function spawnBalloon(x: number, y: number, radius: number, colorIndex: number): NutBody { + const variance = 1 + (Math.random() * 2 - 1) * CONFIG.nutRadiusVariance * 0.5 + const actualRadius = radius * variance + const body = Bodies.circle(x, y, actualRadius, { + restitution: 0.8, + friction: 0.01, + frictionAir: 0.008, + label: 'balloon', + }) + return { body, radius: actualRadius, colorIndex, kind: 'balloon', gemVariant: colorIndex } } // ── Factory ──────────────────────────────────────────────────────────────── @@ -65,34 +108,96 @@ export function createPhysicsWorld(width: number, height: number): PhysicsWorld Composite.add(engine.world, walls) + // ── Balloon buoyancy ─────────────────────────────────────────────────── + // Apply a force opposite to the current gravity vector (2.2× magnitude), + // so balloons float against whichever direction gravity is currently pointing. + Events.on(engine, 'beforeUpdate', () => { + const scale = engine.gravity.scale * 2.2 + for (const { body, kind } of nuts) { + if (kind === 'balloon') { + Body.applyForce(body, body.position, { + x: -body.mass * engine.gravity.x * scale, + y: -body.mass * engine.gravity.y * scale, + }) + } + } + }) + // ── Collision haptics ────────────────────────────────────────────────── Events.on(engine, 'collisionStart', (event: Matter.IEventCollision<Matter.Engine>) => { for (const pair of event.pairs) { const aWall = pair.bodyA.label === 'wall' const bWall = pair.bodyB.label === 'wall' - if (!aWall && !bWall) continue - - const nutBody = aWall ? pair.bodyB : pair.bodyA - if (nutBody.label !== 'nut') continue - - const speed = nutBody.speed - if (speed > CONFIG.impactThreshold) { - const t = Math.min((speed - CONFIG.impactThreshold) / 12, 1) - vibrate(Math.max(8, Math.round(t * CONFIG.maxVibrationMs))) + const aNut = pair.bodyA.label === 'nut' || pair.bodyA.label === 'gem' || pair.bodyA.label === 'balloon' + const bNut = pair.bodyB.label === 'nut' || pair.bodyB.label === 'gem' || pair.bodyB.label === 'balloon' + + if (aNut && bNut) { + // Nut/gem-nut/gem collision – softer haptic + const speed = Math.max(pair.bodyA.speed, pair.bodyB.speed) + dbg('nut-nut speed:', speed.toFixed(2)) + if (speed > CONFIG.impactThreshold) { + const t = Math.min((speed - CONFIG.impactThreshold) / 12, 1) + const ms = Math.round(10 + t * 30) + vibrate(ms) + } + } else if (aNut || bNut) { + // Nut/gem-wall collision – stronger haptic + const nutBody = aWall ? pair.bodyB : pair.bodyA + if (nutBody.label !== 'nut' && nutBody.label !== 'gem' && nutBody.label !== 'balloon') continue + const speed = nutBody.speed + dbg('wall hit speed:', speed.toFixed(2)) + if (speed > CONFIG.impactThreshold) { + const t = Math.min((speed - CONFIG.impactThreshold) / 12, 1) + const ms = Math.round(30 + t * (CONFIG.maxVibrationMs - 30)) + vibrate(ms) + } } } }) // ── Public API ───────────────────────────────────────────────────────── - function setNutCount(count: number): void { + function addNut(x: number, y: number, nutRadius: number = CONFIG.nutBaseRadius): void { + const colorIndex = nuts.length + const nut = spawnNut(x, y, nutRadius, colorIndex) + Body.setAngle(nut.body, Math.random() * Math.PI * 2) + nuts.push(nut) + Composite.add(engine.world, nut.body) + } + + function addGem(x: number, y: number, radius: number = CONFIG.nutBaseRadius, gemVariant?: number): void { + const variant = gemVariant ?? Math.floor(Math.random() * GEM_VARIANT_COUNT) + const gem = spawnGem(x, y, radius, variant) + Body.setAngle(gem.body, Math.random() * Math.PI * 2) + nuts.push(gem) + Composite.add(engine.world, gem.body) + } + + function addBalloon(x: number, y: number, radius: number = CONFIG.nutBaseRadius, colorIndex?: number): void { + const ci = colorIndex ?? Math.floor(Math.random() * BALLOON_COLOR_COUNT) + const balloon = spawnBalloon(x, y, radius, ci) + nuts.push(balloon) + Composite.add(engine.world, balloon.body) + } + + /** Remove the topmost body whose bounding circle contains (x, y). Returns true if one was removed. */ + function removeAt(x: number, y: number): boolean { + for (let i = nuts.length - 1; i >= 0; i--) { + const { body, radius } = nuts[i] + const dx = body.position.x - x + const dy = body.position.y - y + if (dx * dx + dy * dy <= radius * radius) { + Composite.remove(engine.world, body) + nuts.splice(i, 1) + return true + } + } + return false + } + + function clearNuts(): void { for (const { body } of nuts) Composite.remove(engine.world, body) nuts = [] - for (let i = 0; i < count; i++) { - const nut = spawnNut(currentWidth, currentHeight, i) - nuts.push(nut) - Composite.add(engine.world, nut.body) - } } function resize(newWidth: number, newHeight: number): void { @@ -127,5 +232,18 @@ export function createPhysicsWorld(width: number, height: number): PhysicsWorld Engine.clear(engine) } - return { engine, nuts, setNutCount, resize, applyShakeForce, setGravity, step, destroy } + return { + engine, + get nuts() { return nuts }, + addNut, + addGem, + addBalloon, + removeAt, + clearNuts, + resize, + applyShakeForce, + setGravity, + step, + destroy, + } } diff --git a/src/renderer.ts b/src/renderer.ts index e9c232e..ce12b6c 100644 --- a/src/renderer.ts +++ b/src/renderer.ts @@ -1,27 +1,84 @@ import type { NutBody } from './physics' +import { CONFIG } from './config' -// ── Palette ──────────────────────────────────────────────────────────────── -// Each entry: [fill, highlight, shadow, stroke] -const PALETTE: [string, string, string, string][] = [ - ['#C8A84B', '#E8CC7A', '#7A6218', '#5C4A12'], // brass - ['#A0A0A8', '#CDD0D8', '#606068', '#404048'], // steel - ['#B87333', '#D8993A', '#6B3F14', '#4A2A0A'], // copper - ['#8C6D3F', '#B89060', '#50381A', '#362208'], // bronze - ['#C0C0C0', '#E8E8E8', '#707070', '#484848'], // chrome - ['#787860', '#A0A088', '#404030', '#282818'], // dark zinc +// ── Nut palette – warm tones ─────────────────────────────────────────────── +interface NutColor { hi: string; mid: string; shadow: string; rim: string } +const PALETTE: NutColor[] = [ + { hi: '#E8C86A', mid: '#C8993A', shadow: '#7A5810', rim: '#5A3C08' }, // golden + { hi: '#D4956A', mid: '#AA6030', shadow: '#6A3410', rim: '#4A2008' }, // copper-tan + { hi: '#C8B478', mid: '#A08840', shadow: '#6A5418', rim: '#4A3808' }, // hazel + { hi: '#B87850', mid: '#8C4E20', shadow: '#5A2C0A', rim: '#3A1C04' }, // dark walnut + { hi: '#DDB860', mid: '#B88E30', shadow: '#785808', rim: '#503A04' }, // light chestnut ] // ── Cache pre-rendered sprites ───────────────────────────────────────────── -// Key: "<sizeRounded>_<colorIndex>" const spriteCache = new Map<string, HTMLCanvasElement>() -function roundToNearest2(n: number): number { - return Math.round(n / 2) * 2 +function roundToNearest4(n: number): number { + return Math.round(n / 4) * 4 } -function buildSprite(radius: number, colorIndex: number): HTMLCanvasElement { - const [fill, hi, shadow, stroke] = PALETTE[colorIndex % PALETTE.length] - const size = Math.ceil(radius * 2 + 4) // +padding for stroke/shadow +// ── Gem palette ──────────────────────────────────────────────────────────── +interface GemDef { + sides: number + rotation: number + hi: string; mid: string; shadow: string; rim: string + facetHi: string; facetLo: string +} + +const GEM_TYPES: GemDef[] = [ + // 0 — Diamond: icy white-blue, 4-sided ◇ + { + sides: 4, rotation: Math.PI / 4, + hi: '#F4FBFF', mid: '#B8D8F0', shadow: '#5880A8', rim: '#C8E4F4', + facetHi: 'rgba(255,255,255,0.55)', facetLo: 'rgba(70,120,170,0.35)' + }, + // 1 — Ruby: deep red, hexagon + { + sides: 6, rotation: 0, + hi: '#FF7090', mid: '#C01030', shadow: '#580010', rim: '#FF2040', + facetHi: 'rgba(255,130,150,0.55)', facetLo: 'rgba(55,0,10,0.45)' + }, + // 2 — Sapphire: deep blue, hexagon rotated + { + sides: 6, rotation: Math.PI / 6, + hi: '#80A8FF', mid: '#1838C8', shadow: '#060848', rim: '#3058F0', + facetHi: 'rgba(130,170,255,0.55)', facetLo: 'rgba(4,8,55,0.45)' + }, + // 3 — Emerald: deep green, octagon + { + sides: 8, rotation: Math.PI / 8, + hi: '#70E090', mid: '#108040', shadow: '#022810', rim: '#28B060', + facetHi: 'rgba(100,230,140,0.55)', facetLo: 'rgba(2,24,8,0.45)' + }, + // 4 — Amethyst: purple, hexagon + { + sides: 6, rotation: Math.PI / 12, + hi: '#D080FF', mid: '#7020C0', shadow: '#280040', rim: '#A030E0', + facetHi: 'rgba(205,105,255,0.55)', facetLo: 'rgba(28,0,50,0.45)' + }, +] + +function gemPolygon(cx: number, cy: number, r: number, sides: number, rot: number): Array<[number, number]> { + const pts: Array<[number, number]> = [] + for (let i = 0; i < sides; i++) { + const a = rot + (i / sides) * Math.PI * 2 + pts.push([cx + Math.cos(a) * r, cy + Math.sin(a) * r]) + } + return pts +} + +function tracePoly(ctx: CanvasRenderingContext2D, pts: Array<[number, number]>): void { + ctx.beginPath() + ctx.moveTo(pts[0][0], pts[0][1]) + for (let i = 1; i < pts.length; i++) ctx.lineTo(pts[i][0], pts[i][1]) + ctx.closePath() +} + +function buildGemSprite(radius: number, gemVariant: number): HTMLCanvasElement { + const g = GEM_TYPES[gemVariant % GEM_TYPES.length] + const pad = Math.ceil(radius * 0.35) + const size = Math.ceil(radius * 2 + pad * 2) const oc = document.createElement('canvas') oc.width = size oc.height = size @@ -29,97 +86,234 @@ function buildSprite(radius: number, colorIndex: number): HTMLCanvasElement { const cx = size / 2 const cy = size / 2 - // -- Hex path (regular hexagon, flat-top orientation) -- - ctx.beginPath() - for (let i = 0; i < 6; i++) { - const a = (Math.PI / 3) * i - Math.PI / 6 // pointy-top - const x = cx + radius * Math.cos(a) - const y = cy + radius * Math.sin(a) - if (i === 0) ctx.moveTo(x, y) - else ctx.lineTo(x, y) + // Shadow + ctx.save() + ctx.filter = `blur(${Math.ceil(pad * 0.55)}px)` + ctx.globalAlpha = 0.5 + ctx.fillStyle = '#000' + tracePoly(ctx, gemPolygon(cx + pad * 0.15, cy + pad * 0.35, radius * 0.9, g.sides, g.rotation)) + ctx.fill() + ctx.restore() + + const pts = gemPolygon(cx, cy, radius, g.sides, g.rotation) + + // Base radial gradient fill + const grad = ctx.createRadialGradient(cx - radius * 0.3, cy - radius * 0.35, radius * 0.05, cx, cy, radius) + grad.addColorStop(0, g.hi) + grad.addColorStop(0.55, g.mid) + grad.addColorStop(1, g.shadow) + tracePoly(ctx, pts) + ctx.fillStyle = grad + ctx.fill() + + // Alternating facet triangles + ctx.save() + tracePoly(ctx, pts) + ctx.clip() + for (let i = 0; i < pts.length; i++) { + const [ax, ay] = pts[i] + const [bx, by] = pts[(i + 1) % pts.length] + ctx.beginPath() + ctx.moveTo(cx, cy) + ctx.lineTo(ax, ay) + ctx.lineTo(bx, by) + ctx.closePath() + ctx.fillStyle = i % 2 === 0 ? g.facetHi : g.facetLo + ctx.fill() } - ctx.closePath() + ctx.restore() - // Center hole sub-path (CCW so evenodd creates a hole) - const holeR = radius * 0.36 - ctx.moveTo(cx + holeR, cy) - ctx.arc(cx, cy, holeR, 0, Math.PI * 2, true) - ctx.closePath() + // Bright rim + tracePoly(ctx, pts) + ctx.strokeStyle = g.rim + ctx.lineWidth = Math.max(1.5, radius * 0.04) + ctx.stroke() - // Drop shadow - ctx.shadowColor = 'rgba(0,0,0,0.55)' - ctx.shadowBlur = 6 - ctx.shadowOffsetY = 3 + // Faint facet-edge lines from centre to vertices + ctx.strokeStyle = 'rgba(255,255,255,0.22)' + ctx.lineWidth = 1 + for (const [px, py] of pts) { + ctx.beginPath(); ctx.moveTo(cx, cy); ctx.lineTo(px, py); ctx.stroke() + } - // Radial gradient – off-centre light source (top-left) - const grad = ctx.createRadialGradient( - cx - radius * 0.25, cy - radius * 0.3, radius * 0.05, - cx, cy, radius - ) - grad.addColorStop(0, hi) - grad.addColorStop(0.45, fill) - grad.addColorStop(1, shadow) + // Specular highlight — top-left soft glow + ctx.save() + tracePoly(ctx, pts) + ctx.clip() + const hiGrad = ctx.createRadialGradient(cx - radius * 0.25, cy - radius * 0.3, 0, cx, cy, radius * 0.7) + hiGrad.addColorStop(0, 'rgba(255,255,255,0.55)') + hiGrad.addColorStop(0.4, 'rgba(255,255,255,0.14)') + hiGrad.addColorStop(1, 'rgba(255,255,255,0)') + ctx.fillStyle = hiGrad + ctx.fillRect(0, 0, size, size) + ctx.restore() - ctx.fillStyle = grad - ctx.fill('evenodd') + return oc +} - // Reset shadow for stroke - ctx.shadowColor = 'transparent' - ctx.shadowBlur = 0 - ctx.shadowOffsetY = 0 +function buildSprite(radius: number, colorIndex: number): HTMLCanvasElement { + const c = PALETTE[colorIndex % PALETTE.length] + const rx = radius + const ry = Math.round(radius / CONFIG.nutAspectRatio) + const pad = Math.ceil(rx * 0.35) + const sizeW = Math.ceil(rx * 2 + pad * 2) + const sizeH = Math.ceil(ry * 2 + pad * 2) + const oc = document.createElement('canvas') + oc.width = sizeW + oc.height = sizeH + const ctx = oc.getContext('2d')! + const cx = sizeW / 2 + const cy = sizeH / 2 - // Outer stroke - ctx.lineWidth = 1.5 - ctx.strokeStyle = stroke - // Re-build outer hex only for stroke (no hole) + // Contained drop shadow – blurred offset ellipse drawn before the nut + ctx.save() + ctx.filter = `blur(${Math.ceil(pad * 0.55)}px)` + ctx.globalAlpha = 0.55 + ctx.fillStyle = '#000' ctx.beginPath() - for (let i = 0; i < 6; i++) { - const a = (Math.PI / 3) * i - Math.PI / 6 - const x = cx + radius * Math.cos(a) - const y = cy + radius * Math.sin(a) - if (i === 0) ctx.moveTo(x, y) - else ctx.lineTo(x, y) - } - ctx.closePath() - ctx.stroke() + ctx.ellipse(cx + pad * 0.15, cy + pad * 0.35, rx * 0.88, ry * 0.88, 0, 0, Math.PI * 2) + ctx.fill() + ctx.restore() - // Inner hole edge - ctx.lineWidth = 1 - ctx.strokeStyle = shadow + // Ellipse fill – off-centre radial gradient for 3-D sheen + const grad = ctx.createRadialGradient( + cx - rx * 0.28, cy - ry * 0.32, rx * 0.04, + cx, cy, rx + ) + grad.addColorStop(0, c.hi) + grad.addColorStop(0.5, c.mid) + grad.addColorStop(1, c.shadow) ctx.beginPath() - ctx.arc(cx, cy, holeR, 0, Math.PI * 2) + ctx.ellipse(cx, cy, rx, ry, 0, 0, Math.PI * 2) + ctx.fillStyle = grad + ctx.fill() + + // Rim stroke + ctx.lineWidth = Math.max(1.5, rx * 0.035) + ctx.strokeStyle = c.rim ctx.stroke() - // Highlight arc (top-left glint) - ctx.save() - ctx.clip() // clip to hex (already closed path is gone – need a fresh clip) - ctx.restore() + // Specular highlight – small bright arc top-left + ctx.beginPath() + ctx.ellipse(cx - rx * 0.22, cy - ry * 0.28, rx * 0.28, ry * 0.18, -0.5, 0, Math.PI * 2) + ctx.fillStyle = 'rgba(255,255,255,0.12)' + ctx.fill() return oc } -function getSprite(radius: number, colorIndex: number): HTMLCanvasElement { - const r = roundToNearest2(radius) - const key = `${r}_${colorIndex % PALETTE.length}` +function getSprite(radius: number, colorIndex: number, kind: 'nut' | 'gem', gemVariant: number): HTMLCanvasElement { + const r = roundToNearest4(radius) + const key = kind === 'gem' + ? `gem_${r}_${gemVariant % GEM_TYPES.length}` + : `nut_${r}_${colorIndex % PALETTE.length}` let sprite = spriteCache.get(key) if (!sprite) { - sprite = buildSprite(r, colorIndex) + sprite = kind === 'gem' ? buildGemSprite(r, gemVariant) : buildSprite(r, colorIndex) spriteCache.set(key, sprite) } return sprite } +// ── Balloon palette ───────────────────────────────────────────────────── +interface BalloonColor { body: string; hi: string; shadow: string; rim: string; string: string } +const BALLOON_COLORS: BalloonColor[] = [ + { body: '#FF4466', hi: '#FF99AA', shadow: '#AA0022', rim: '#FF2244', string: '#CC1133' }, // red + { body: '#FF8800', hi: '#FFCC66', shadow: '#AA4400', rim: '#FF6600', string: '#CC5500' }, // orange + { body: '#FFE000', hi: '#FFF599', shadow: '#AA8800', rim: '#FFCC00', string: '#BB9900' }, // yellow + { body: '#33CC55', hi: '#88EE99', shadow: '#116622', rim: '#22AA44', string: '#228833' }, // green + { body: '#4488FF', hi: '#99BBFF', shadow: '#1133CC', rim: '#2255EE', string: '#1144CC' }, // blue + { body: '#EE44CC', hi: '#FF99EE', shadow: '#991188', rim: '#DD22BB', string: '#BB0099' }, // pink + { body: '#22DDEE', hi: '#88EEFF', shadow: '#117788', rim: '#11CCDD', string: '#0099AA' }, // cyan +] + +function drawBalloon( + ctx: CanvasRenderingContext2D, + x: number, y: number, + radius: number, + colorIndex: number, +): void { + const c = BALLOON_COLORS[colorIndex % BALLOON_COLORS.length] + const r = radius + + // Shadow + ctx.save() + ctx.filter = `blur(${Math.ceil(r * 0.14)}px)` + ctx.globalAlpha = 0.32 + ctx.fillStyle = '#000' + ctx.beginPath() + ctx.arc(x + r * 0.12, y + r * 0.15, r * 0.88, 0, Math.PI * 2) + ctx.fill() + ctx.restore() + + // Body + const grad = ctx.createRadialGradient(x - r * 0.3, y - r * 0.35, r * 0.04, x, y, r) + grad.addColorStop(0, c.hi) + grad.addColorStop(0.55, c.body) + grad.addColorStop(1, c.shadow) + ctx.beginPath() + ctx.arc(x, y, r, 0, Math.PI * 2) + ctx.fillStyle = grad + ctx.fill() + ctx.strokeStyle = c.rim + ctx.lineWidth = Math.max(1.5, r * 0.03) + ctx.stroke() + + // Specular highlight + ctx.save() + ctx.beginPath() + ctx.arc(x, y, r, 0, Math.PI * 2) + ctx.clip() + const hiGrad = ctx.createRadialGradient(x - r * 0.3, y - r * 0.38, 0, x - r * 0.1, y - r * 0.1, r * 0.65) + hiGrad.addColorStop(0, 'rgba(255,255,255,0.78)') + hiGrad.addColorStop(0.4, 'rgba(255,255,255,0.18)') + hiGrad.addColorStop(1, 'rgba(255,255,255,0)') + ctx.fillStyle = hiGrad + ctx.fillRect(x - r, y - r, r * 2, r * 2) + ctx.restore() + + // Knot + const knotH = Math.max(4, r * 0.13) + ctx.beginPath() + ctx.moveTo(x, y + r + knotH * 1.6) + ctx.lineTo(x - knotH * 0.55, y + r) + ctx.lineTo(x + knotH * 0.55, y + r) + ctx.closePath() + ctx.fillStyle = c.rim + ctx.fill() + + // String + const stringLen = r * 0.9 + ctx.beginPath() + ctx.moveTo(x, y + r + knotH * 1.6) + ctx.quadraticCurveTo( + x + r * 0.18, y + r + stringLen * 0.5, + x + r * 0.06, y + r + stringLen + knotH * 1.6, + ) + ctx.strokeStyle = c.string + ctx.lineWidth = Math.max(1, r * 0.025) + ctx.globalAlpha = 0.75 + ctx.stroke() + ctx.globalAlpha = 1 +} + // ── Renderer ─────────────────────────────────────────────────────────────── export interface Renderer { - render: (nuts: NutBody[]) => void + render: (nuts: NutBody[], gravityAngle: number) => void resize: (width: number, height: number) => void + flash: () => void } export function createRenderer(canvas: HTMLCanvasElement): Renderer { const ctx = canvas.getContext('2d')! + let flashAlpha = 0 - function render(nuts: NutBody[]): void { + function flash(): void { + flashAlpha = 0.35 + } + + function render(nuts: NutBody[], gravityAngle: number): void { const w = canvas.width const h = canvas.height @@ -127,33 +321,32 @@ export function createRenderer(canvas: HTMLCanvasElement): Renderer { ctx.fillStyle = '#111111' ctx.fillRect(0, 0, w, h) - // Container border (subtle inner glow suggesting a tube/box) - const borderW = 4 - ctx.strokeStyle = '#2a2a2a' - ctx.lineWidth = borderW - ctx.strokeRect(borderW / 2, borderW / 2, w - borderW, h - borderW) - - // Nuts - for (const { body, radius, colorIndex } of nuts) { - const { position, angle, vertices } = body - const sprite = getSprite(radius, colorIndex) - const size = sprite.width - - ctx.save() - ctx.translate(position.x, position.y) - ctx.rotate(angle) - ctx.drawImage(sprite, -size / 2, -size / 2, size, size) + // Nuts, gems & balloons + for (const { body, radius, colorIndex, kind, gemVariant } of nuts) { + const { position, angle } = body + + if (kind === 'balloon') { + ctx.save() + ctx.translate(position.x, position.y) + ctx.rotate(gravityAngle) + drawBalloon(ctx, 0, 0, radius, colorIndex) + ctx.restore() + } else { + const sprite = getSprite(radius, colorIndex, kind, gemVariant) + ctx.save() + ctx.translate(position.x, position.y) + ctx.rotate(angle) + ctx.drawImage(sprite, -sprite.width / 2, -sprite.height / 2, sprite.width, sprite.height) + ctx.restore() + } ctx.restore() + } - // Debug: visualise physics polygon (uncomment for dev) - // ctx.beginPath() - // ctx.moveTo(vertices[0].x, vertices[0].y) - // for (const v of vertices) ctx.lineTo(v.x, v.y) - // ctx.closePath() - // ctx.strokeStyle = 'red' - // ctx.lineWidth = 1 - // ctx.stroke() - void vertices // suppress unused-var warning + // Flash overlay (fallback haptic cue for Firefox / no-vibration devices) + if (flashAlpha > 0) { + ctx.fillStyle = `rgba(255,255,255,${flashAlpha.toFixed(3)})` + ctx.fillRect(0, 0, w, h) + flashAlpha = Math.max(0, flashAlpha - 0.05) } } @@ -162,5 +355,5 @@ export function createRenderer(canvas: HTMLCanvasElement): Renderer { canvas.height = height } - return { render, resize } + return { render, resize, flash } } diff --git a/src/share.ts b/src/share.ts new file mode 100644 index 0000000..8dc5aa3 --- /dev/null +++ b/src/share.ts @@ -0,0 +1,82 @@ +import type { NutBody } from './physics' + +// ── Encoding ─────────────────────────────────────────────────────────────── +// Nuts: "r<radius>c<colorIndex>" e.g. "r130c2" +// Gems: "r<radius>g<gemVariant>" e.g. "r80g1" +// Comma-separated, e.g. "r130c0,r80g1,r60g3" + +export interface SharedNut { + radius: number + colorIndex: number + kind: 'nut' | 'gem' | 'balloon' + gemVariant: number +} + +export function encodeNuts(nuts: NutBody[]): string { + return nuts.map(n => { + if (n.kind === 'gem') return `r${Math.round(n.radius)}g${n.gemVariant}` + if (n.kind === 'balloon') return `r${Math.round(n.radius)}b${n.colorIndex % 7}` + return `r${Math.round(n.radius)}c${n.colorIndex % 5}` + }).join(',') +} + +export function decodeNuts(encoded: string): SharedNut[] | null { + try { + if (!encoded) return null + return encoded.split(',').map(part => { + const gem = part.match(/^r(\d+)g(\d+)$/) + if (gem) return { radius: parseInt(gem[1], 10), colorIndex: 0, kind: 'gem' as const, gemVariant: parseInt(gem[2], 10) } + const balloon = part.match(/^r(\d+)b(\d+)$/) + if (balloon) return { radius: parseInt(balloon[1], 10), colorIndex: parseInt(balloon[2], 10), kind: 'balloon' as const, gemVariant: parseInt(balloon[2], 10) } + const nut = part.match(/^r(\d+)c(\d+)$/) + if (nut) return { radius: parseInt(nut[1], 10), colorIndex: parseInt(nut[2], 10), kind: 'nut' as const, gemVariant: 0 } + throw new Error('invalid') + }) + } catch { + return null + } +} + +const BASE_URL = 'https://pavelsafronov.gitlab.io/nuts/' + +export function buildShareURL(nuts: NutBody[]): string { + const encoded = encodeNuts(nuts) + return `${BASE_URL}#s=${encodeURIComponent(encoded)}` +} + +/** Parse share data from the current URL hash. Returns null if none present. */ +export function parseShareFromURL(): SharedNut[] | null { + const m = location.hash.match(/[#&]s=([^&]*)/) + if (!m) return null + const parsed = decodeNuts(decodeURIComponent(m[1])) + // Clear the hash so refreshing doesn't re-import + history.replaceState(null, '', location.pathname + location.search) + return parsed +} + +/** Place shared nuts/gems into the world with a loose random packing. */ +export function placeSharedNuts( + nuts: SharedNut[], + screenW: number, + screenH: number, + addBody: (x: number, y: number, radius: number, kind: 'nut' | 'gem' | 'balloon', gemVariant: number) => void, + hasSpace: (x: number, y: number, r: number) => boolean, +): void { + for (const { radius, colorIndex: _ci, kind, gemVariant } of nuts) { + // Attempt random positions until one fits (max 200 tries) + const margin = radius * 1.2 + let placed = false + for (let attempt = 0; attempt < 200; attempt++) { + const x = margin + Math.random() * (screenW - margin * 2) + const y = margin + Math.random() * (screenH - margin * 2) + if (hasSpace(x, y, radius)) { + addBody(x, y, radius, kind, gemVariant) + placed = true + break + } + } + if (!placed) { + addBody(margin + Math.random() * (screenW - margin * 2), margin, radius, kind, gemVariant) + } + } +} diff --git a/src/style.css b/src/style.css index 78e0ee9..5433a92 100644 --- a/src/style.css +++ b/src/style.css @@ -38,6 +38,21 @@ body { cursor: pointer; } +#fullscreen-overlay { + position: fixed; + inset: 0; + background: #111; + display: flex; + align-items: center; + justify-content: center; + z-index: 200; + cursor: pointer; +} + +#fullscreen-overlay[hidden] { + display: none; +} + .start-content { display: flex; flex-direction: column; @@ -223,6 +238,12 @@ input[type="range"]::-moz-range-thumb { background: #333; } +.action-btn.active { + background: #2a3a2a; + border-color: #4a7a4a; + color: #8fce8f; +} + .close-btn { background: #c8a84b; border: none; @@ -270,4 +291,56 @@ input[type="range"]::-moz-range-thumb { color: #666; text-decoration: underline; text-underline-offset: 2px; +} + +/* ── Share section ─────────────────────────────────────────────────────── */ +.share-section { + display: flex; + flex-direction: column; + align-items: center; + gap: 10px; + padding-top: 4px; + border-top: 1px solid #2a2a2a; +} + +.share-label { + font-size: 13px; + color: #666; + align-self: flex-start; +} + +#qr-canvas { + border-radius: 10px; + background: #fff; + padding: 8px; + width: 160px; + height: 160px; +} + +/* ── Vibration hint toast ───────────────────────────────────────────────── */ +#vibration-hint { + position: fixed; + bottom: max(24px, env(safe-area-inset-bottom, 24px)); + left: 50%; + transform: translateX(-50%); + background: rgba(30, 30, 30, 0.92); + color: #e0e0e0; + font-family: system-ui, -apple-system, sans-serif; + font-size: 13px; + line-height: 1.5; + padding: 10px 16px; + border-radius: 12px; + backdrop-filter: blur(8px); + -webkit-backdrop-filter: blur(8px); + border: 1px solid rgba(255, 255, 255, 0.08); + white-space: nowrap; + z-index: 60; + pointer-events: auto; + cursor: pointer; + opacity: 1; + transition: opacity 0.4s; +} + +#vibration-hint.fade-out { + opacity: 0; } \ No newline at end of file diff --git a/src/ui.ts b/src/ui.ts index 63dd3a3..614e7af 100644 --- a/src/ui.ts +++ b/src/ui.ts @@ -1,46 +1,104 @@ import { CONFIG } from './config' +import { dbg } from './debug' +import QRCode from 'qrcode' export interface UICallbacks { - onNutCountChange: (count: number) => void - onReset: () => void + initialRadius: number + initialLocked: boolean + onSizeChange: (radius: number) => void + onClear: () => void + onLockChange: (locked: boolean) => void + getShareURL: () => string } export function initUI(callbacks: UICallbacks): void { const settingsBtn = document.getElementById('settings-btn') as HTMLButtonElement const settingsPanel = document.getElementById('settings-panel') as HTMLDivElement - const nutCountInput = document.getElementById('nut-count') as HTMLInputElement - const nutCountDisplay = document.getElementById('nut-count-display') as HTMLSpanElement - const resetBtn = document.getElementById('reset-btn') as HTMLButtonElement + 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 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 bounds from config - nutCountInput.min = String(CONFIG.minNuts) - nutCountInput.max = String(CONFIG.maxNuts) - nutCountInput.value = String(CONFIG.defaultNutCount) - nutCountDisplay.textContent = String(CONFIG.defaultNutCount) + // Sync slider from config bounds + persisted value + 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 settingsBtn.addEventListener('click', () => { settingsPanel.hidden = false + refreshQR() }) closeBtn.addEventListener('click', closePanel) backdrop.addEventListener('click', closePanel) - nutCountInput.addEventListener('input', () => { - const count = parseInt(nutCountInput.value, 10) - nutCountDisplay.textContent = String(count) - callbacks.onNutCountChange(count) + nutSizeInput.addEventListener('input', () => { + const radius = parseInt(nutSizeInput.value, 10) + nutSizeDisplay.textContent = String(radius) + callbacks.onSizeChange(radius) }) - resetBtn.addEventListener('click', () => { - callbacks.onReset() + lockBtn.addEventListener('click', () => { + locked = !locked + lockBtn.textContent = locked ? '▶ Resume' : '⏸ Pause' + lockBtn.classList.toggle('active', locked) + callbacks.onLockChange(locked) closePanel() }) + clearBtn.addEventListener('click', () => { + callbacks.onClear() + closePanel() + }) + + testVibrateBtn.addEventListener('click', () => { + const ok = navigator.vibrate(300) + dbg('test vibrate 300ms ->', ok) + const orig = testVibrateBtn.textContent + testVibrateBtn.textContent = ok ? '✓ Sent' : '✗ Failed' + setTimeout(() => { testVibrateBtn.textContent = orig }, 1500) + }) + + copyLinkBtn.addEventListener('click', () => { + const url = callbacks.getShareURL() + const orig = copyLinkBtn.textContent + if (navigator.share) { + navigator.share({ title: 'My collection', url }).catch(() => { /* cancelled */ }) + } else { + navigator.clipboard.writeText(url).then(() => { + copyLinkBtn.textContent = '✓ Copied!' + setTimeout(() => { copyLinkBtn.textContent = orig }, 1800) + }).catch(() => { + copyLinkBtn.textContent = '✗ Failed' + setTimeout(() => { copyLinkBtn.textContent = orig }, 1800) + }) + } + }) + + async function refreshQR(): Promise<void> { + const url = callbacks.getShareURL() + try { + await QRCode.toCanvas(qrCanvas, url, { + width: 160, + margin: 1, + color: { dark: '#111111', light: '#ffffff' }, + }) + } catch { /* ignore */ } + } + function closePanel(): void { settingsPanel.hidden = true } diff --git a/src/vibration.ts b/src/vibration.ts index cace1f3..57bab60 100644 --- a/src/vibration.ts +++ b/src/vibration.ts @@ -1,21 +1,78 @@ import { CONFIG } from './config' +import { dbg } from './debug' let lastVibrationAt = 0 +let flashFn: (() => void) | undefined +let unlocked = false + +/** Register a visual fallback called when the Vibration API is unavailable. */ +export function setFlashFallback(fn: () => void): void { + flashFn = fn +} + +/** + * Call once from a user-gesture handler (e.g. the tap-to-start). + * Chrome requires at least one vibrate() call from a gesture context before + * it will honour subsequent calls from rAF / event callbacks. + */ +export function unlockVibration(): void { + if (!('vibrate' in navigator && typeof navigator.vibrate === 'function')) { + dbg('vibrate: API not present') + return + } + navigator.vibrate(1) + unlocked = true + dbg('vibrate: unlocked') + showVibrateHint() +} + +function showVibrateHint(): void { + const STORAGE_KEY = 'vibrate-hint-dismissed' + if (localStorage.getItem(STORAGE_KEY)) return + + const toast = document.createElement('div') + toast.id = 'vibration-hint' + toast.textContent = '📳 Tip: turn off Do Not Disturb for haptics' + document.body.appendChild(toast) + + function dismiss(): void { + localStorage.setItem(STORAGE_KEY, '1') + toast.classList.add('fade-out') + setTimeout(() => toast.remove(), 420) + } + + toast.addEventListener('click', dismiss) + setTimeout(dismiss, 5000) +} + +function canVibrate(): boolean { + return unlocked && 'vibrate' in navigator && typeof navigator.vibrate === 'function' +} /** Fire the Web Vibration API, with a minimum interval to avoid spam. */ export function vibrate(durationMs: number): void { - if (!('vibrate' in navigator)) return const now = Date.now() if (now - lastVibrationAt < CONFIG.vibrationCooldownMs) return + // Chrome won't vibrate if the page is backgrounded or display is off + if (document.visibilityState !== 'visible') return lastVibrationAt = now - navigator.vibrate(durationMs) + if (canVibrate()) { + const ok = navigator.vibrate(durationMs) + dbg('vibrate', durationMs, 'ms ->', ok) + } else { + dbg('vibrate: fallback flash (unlocked:', unlocked, ')') + flashFn?.() + } } /** Short double-pulse for a shake event. */ export function vibrateShake(): void { - if (!('vibrate' in navigator)) return const now = Date.now() if (now - lastVibrationAt < CONFIG.vibrationCooldownMs) return lastVibrationAt = now - navigator.vibrate([25, 15, 25]) + if (canVibrate()) { + navigator.vibrate([25, 15, 25]) + } else { + flashFn?.() + } } diff --git a/vite.config.ts b/vite.config.ts index dd6cb2a..2484030 100644 --- a/vite.config.ts +++ b/vite.config.ts @@ -1,5 +1,39 @@ -import { defineConfig } from 'vite' +import { defineConfig, type Plugin } from 'vite' import { qrcode } from 'vite-plugin-qrcode' +import basicSsl from '@vitejs/plugin-basic-ssl' +import type { IncomingMessage, ServerResponse } from 'node:http' + +function remoteLog(): Plugin { + return { + name: 'remote-log', + configureServer(server) { + server.middlewares.use( + '/__log', + (req: IncomingMessage, res: ServerResponse) => { + if (req.method !== 'POST') { + res.writeHead(405).end() + return + } + let body = '' + req.setEncoding('utf8') + req.on('data', (chunk: string) => { body += chunk }) + req.on('end', () => { + try { + const { level, msg } = JSON.parse(body) as { level: string; msg: string } + const prefix = '\x1b[36m[web]\x1b[0m' + if (level === 'error') { + server.config.logger.error(`${prefix} ${msg}`) + } else { + server.config.logger.info(`${prefix} ${msg}`) + } + } catch { /* malformed – ignore */ } + res.writeHead(204).end() + }) + } + ) + }, + } +} export default defineConfig({ base: './', @@ -9,5 +43,5 @@ export default defineConfig({ server: { host: true, // listen on 0.0.0.0 so the phone can reach it }, - plugins: [qrcode()], + plugins: [remoteLog(), basicSsl(), qrcode()], })