nuts / commit
Initial commit
.gitignore | 2 + .gitlab-ci.yml | 18 + README.md | 33 ++ acorn.png | Bin 0 -> 23596 bytes index.html | 75 +++ package.json | 25 + pnpm-lock.yaml | 1225 +++++++++++++++++++++++++++++++++++++++++++ public/apple-touch-icon.png | Bin 0 -> 5047 bytes public/favicon-16x16.png | Bin 0 -> 580 bytes public/favicon-32x32.png | Bin 0 -> 1560 bytes public/icon-192.png | Bin 0 -> 5485 bytes public/icon-512.png | Bin 0 -> 15218 bytes public/og-image.png | Bin 0 -> 15885 bytes public/site.webmanifest | 21 + scripts/generate-icons.ts | 77 +++ src/config.ts | 29 + src/main.ts | 129 +++++ src/motion.ts | 94 ++++ src/physics.ts | 131 +++++ src/renderer.ts | 166 ++++++ src/style.css | 270 ++++++++++ src/ui.ts | 56 ++ src/vibration.ts | 21 + tsconfig.json | 22 + vite.config.ts | 13 + 25 files changed, 2407 insertions(+) diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..f06235c --- /dev/null +++ b/.gitignore @@ -0,0 +1,2 @@ +node_modules +dist diff --git a/.gitlab-ci.yml b/.gitlab-ci.yml new file mode 100644 index 0000000..e0586f0 --- /dev/null +++ b/.gitlab-ci.yml @@ -0,0 +1,18 @@ +image: node:20-alpine + +stages: + - deploy + +pages: + stage: deploy + before_script: + - npm install -g pnpm + - pnpm install --frozen-lockfile + script: + - pnpm run build + - mv dist public + artifacts: + paths: + - public + rules: + - if: $CI_COMMIT_BRANCH == $CI_DEFAULT_BRANCH diff --git a/README.md b/README.md new file mode 100644 index 0000000..4e9a4fc --- /dev/null +++ b/README.md @@ -0,0 +1,33 @@ +# nuts + +> A phone-focused full-screen toy: a box of hex nuts bouncing around with haptic feedback. + +## Features + +- **Physics** — [Matter.js](https://brm.io/matter-js/) 2D rigid-body simulation +- **Haptics** — Web Vibration API fires on every wall impact (intensity scales with collision speed) +- **Tilt gravity** — Device orientation rotates the gravity vector so nuts roll as you tilt your phone +- **Shake** — DeviceMotion events detect shakes and punt all nuts with a random impulse + double-vibrate +- **Configurable** — Slider to set 1–50 nuts; reset positions on demand +- **Offline-ready** — No server, no data, runs entirely in the browser + +## Tech stack + +- [pnpm](https://pnpm.io/) + [TypeScript](https://www.typescriptlang.org/) +- [Vite](https://vitejs.dev/) — dev server and bundler +- [Matter.js](https://brm.io/matter-js/) — 2D physics engine + +## Development + +```bash +pnpm install +pnpm dev # http://localhost:5173 +pnpm build # production bundle → dist/ +pnpm preview # preview dist/ build +``` + +## Deployment + +Hosted on GitLab Pages via `.gitlab-ci.yml`. Pushes to the default branch trigger a build and publish `dist/` as the Pages site. + +If your project is in a GitLab group/subgroup the Vite `base: './'` config ensures assets resolve correctly under the Pages sub-path. diff --git a/acorn.png b/acorn.png new file mode 100644 index 0000000..7eba410 Binary files /dev/null and b/acorn.png differ diff --git a/index.html b/index.html new file mode 100644 index 0000000..f59e007 --- /dev/null +++ b/index.html @@ -0,0 +1,75 @@ +<!DOCTYPE html> +<html lang="en"> + +<head> + <meta charset="UTF-8" /> + <meta name="viewport" content="width=device-width, initial-scale=1.0, user-scalable=no, viewport-fit=cover" /> + <meta name="apple-mobile-web-app-capable" content="yes" /> + <meta name="apple-mobile-web-app-status-bar-style" content="black-translucent" /> + <meta name="mobile-web-app-capable" content="yes" /> + <meta name="theme-color" content="#111111" /> + <title>Nuts</title> + + <!-- Favicons --> + <link rel="icon" type="image/png" sizes="32x32" href="/favicon-32x32.png" /> + <link rel="icon" type="image/png" sizes="16x16" href="/favicon-16x16.png" /> + <link rel="apple-touch-icon" sizes="180x180" href="/apple-touch-icon.png" /> + <link rel="manifest" href="/site.webmanifest" /> + + <!-- Open Graph --> + <meta property="og:title" content="Nuts" /> + <meta property="og:description" content="A box of nuts bouncing around with haptic feedback." /> + <meta property="og:image" content="https://pavelsafronov.gitlab.io/nuts/og-image.png" /> + <meta property="og:url" content="https://pavelsafronov.gitlab.io/nuts/" /> + <meta name="twitter:card" content="summary_large_image" /> + + <link rel="stylesheet" href="/src/style.css" /> +</head> + +<body> + <canvas id="canvas"></canvas> + + <!-- Tap-to-start overlay (also handles iOS motion permission) --> + <div id="start-overlay"> + <div class="start-content"> + <img class="nut-icon" src="/icon-192.png" alt="Acorn icon" width="96" height="96" /> + <h1>Nuts</h1> + <p>Tap to begin</p> + </div> + </div> + + <!-- Settings button --> + <button id="settings-btn" aria-label="Settings" hidden>⚙</button> + + <!-- Settings panel --> + <div id="settings-panel" hidden> + <div class="panel-backdrop"></div> + <div class="panel-sheet"> + <div class="panel-handle"></div> + <h2>Settings</h2> + + <label class="setting-row"> + <span>Nuts</span> + <span id="nut-count-display" class="setting-value">20</span> + </label> + <input type="range" id="nut-count" min="1" max="50" value="20" step="1" /> + + <button id="reset-btn" class="action-btn">Reset positions</button> + <button id="close-settings" class="close-btn">Done</button> + + <div class="panel-links"> + <a href="https://pavelsafronov.gitlab.io/nuts/" target="_blank" rel="noopener">Live app</a> + <span aria-hidden="true">·</span> + <a href="https://gitlab.com/PavelSafronov/nuts" target="_blank" rel="noopener">Source</a> + </div> + <p class="attribution"> + Icon by <a href="https://www.flaticon.com/search?author_id=296&style_id=1001&type=standard" + target="_blank" rel="noopener">Freepik</a> via Flaticon + </p> + </div> + </div> + + <script type="module" src="/src/main.ts"></script> +</body> + +</html> \ No newline at end of file diff --git a/package.json b/package.json new file mode 100644 index 0000000..1dafd7a --- /dev/null +++ b/package.json @@ -0,0 +1,25 @@ +{ + "name": "nuts", + "version": "1.0.0", + "private": true, + "type": "module", + "packageManager": "pnpm@9.15.0", + "scripts": { + "dev": "vite", + "build": "tsc && vite build", + "preview": "vite preview", + "generate-icons": "tsx scripts/generate-icons.ts" + }, + "dependencies": { + "matter-js": "^0.19.0" + }, + "devDependencies": { + "@types/matter-js": "^0.19.6", + "@types/node": "^25.6.0", + "sharp": "^0.34.5", + "tsx": "^4.21.0", + "typescript": "^5.4.0", + "vite": "^5.2.0", + "vite-plugin-qrcode": "^0.4.1" + } +} \ No newline at end of file diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml new file mode 100644 index 0000000..b76ef08 --- /dev/null +++ b/pnpm-lock.yaml @@ -0,0 +1,1225 @@ +lockfileVersion: '9.0' + +settings: + autoInstallPeers: true + excludeLinksFromLockfile: false + +importers: + + .: + dependencies: + matter-js: + specifier: ^0.19.0 + version: 0.19.0 + devDependencies: + '@types/matter-js': + specifier: ^0.19.6 + version: 0.19.8 + '@types/node': + specifier: ^25.6.0 + version: 25.6.0 + sharp: + specifier: ^0.34.5 + version: 0.34.5 + tsx: + specifier: ^4.21.0 + version: 4.21.0 + typescript: + specifier: ^5.4.0 + version: 5.9.3 + vite: + specifier: ^5.2.0 + version: 5.4.21(@types/node@25.6.0) + vite-plugin-qrcode: + specifier: ^0.4.1 + version: 0.4.1(vite@5.4.21(@types/node@25.6.0)) + +packages: + + '@emnapi/runtime@1.9.2': + resolution: {integrity: sha512-3U4+MIWHImeyu1wnmVygh5WlgfYDtyf0k8AbLhMFxOipihf6nrWC4syIm/SwEeec0mNSafiiNnMJwbza/Is6Lw==} + + '@esbuild/aix-ppc64@0.21.5': + resolution: {integrity: sha512-1SDgH6ZSPTlggy1yI6+Dbkiz8xzpHJEVAlF/AM1tHPLsf5STom9rwtjE4hKAF20FfXXNTFqEYXyJNWh1GiZedQ==} + engines: {node: '>=12'} + cpu: [ppc64] + os: [aix] + + '@esbuild/aix-ppc64@0.27.7': + resolution: {integrity: sha512-EKX3Qwmhz1eMdEJokhALr0YiD0lhQNwDqkPYyPhiSwKrh7/4KRjQc04sZ8db+5DVVnZ1LmbNDI1uAMPEUBnQPg==} + engines: {node: '>=18'} + cpu: [ppc64] + os: [aix] + + '@esbuild/android-arm64@0.21.5': + resolution: {integrity: sha512-c0uX9VAUBQ7dTDCjq+wdyGLowMdtR/GoC2U5IYk/7D1H1JYC0qseD7+11iMP2mRLN9RcCMRcjC4YMclCzGwS/A==} + engines: {node: '>=12'} + cpu: [arm64] + os: [android] + + '@esbuild/android-arm64@0.27.7': + resolution: {integrity: sha512-62dPZHpIXzvChfvfLJow3q5dDtiNMkwiRzPylSCfriLvZeq0a1bWChrGx/BbUbPwOrsWKMn8idSllklzBy+dgQ==} + engines: {node: '>=18'} + cpu: [arm64] + os: [android] + + '@esbuild/android-arm@0.21.5': + resolution: {integrity: sha512-vCPvzSjpPHEi1siZdlvAlsPxXl7WbOVUBBAowWug4rJHb68Ox8KualB+1ocNvT5fjv6wpkX6o/iEpbDrf68zcg==} + engines: {node: '>=12'} + cpu: [arm] + os: [android] + + '@esbuild/android-arm@0.27.7': + resolution: {integrity: sha512-jbPXvB4Yj2yBV7HUfE2KHe4GJX51QplCN1pGbYjvsyCZbQmies29EoJbkEc+vYuU5o45AfQn37vZlyXy4YJ8RQ==} + engines: {node: '>=18'} + cpu: [arm] + os: [android] + + '@esbuild/android-x64@0.21.5': + resolution: {integrity: sha512-D7aPRUUNHRBwHxzxRvp856rjUHRFW1SdQATKXH2hqA0kAZb1hKmi02OpYRacl0TxIGz/ZmXWlbZgjwWYaCakTA==} + engines: {node: '>=12'} + cpu: [x64] + os: [android] + + '@esbuild/android-x64@0.27.7': + resolution: {integrity: sha512-x5VpMODneVDb70PYV2VQOmIUUiBtY3D3mPBG8NxVk5CogneYhkR7MmM3yR/uMdITLrC1ml/NV1rj4bMJuy9MCg==} + engines: {node: '>=18'} + cpu: [x64] + os: [android] + + '@esbuild/darwin-arm64@0.21.5': + resolution: {integrity: sha512-DwqXqZyuk5AiWWf3UfLiRDJ5EDd49zg6O9wclZ7kUMv2WRFr4HKjXp/5t8JZ11QbQfUS6/cRCKGwYhtNAY88kQ==} + engines: {node: '>=12'} + cpu: [arm64] + os: [darwin] + + '@esbuild/darwin-arm64@0.27.7': + resolution: {integrity: sha512-5lckdqeuBPlKUwvoCXIgI2D9/ABmPq3Rdp7IfL70393YgaASt7tbju3Ac+ePVi3KDH6N2RqePfHnXkaDtY9fkw==} + engines: {node: '>=18'} + cpu: [arm64] + os: [darwin] + + '@esbuild/darwin-x64@0.21.5': + resolution: {integrity: sha512-se/JjF8NlmKVG4kNIuyWMV/22ZaerB+qaSi5MdrXtd6R08kvs2qCN4C09miupktDitvh8jRFflwGFBQcxZRjbw==} + engines: {node: '>=12'} + cpu: [x64] + os: [darwin] + + '@esbuild/darwin-x64@0.27.7': + resolution: {integrity: sha512-rYnXrKcXuT7Z+WL5K980jVFdvVKhCHhUwid+dDYQpH+qu+TefcomiMAJpIiC2EM3Rjtq0sO3StMV/+3w3MyyqQ==} + engines: {node: '>=18'} + cpu: [x64] + os: [darwin] + + '@esbuild/freebsd-arm64@0.21.5': + resolution: {integrity: sha512-5JcRxxRDUJLX8JXp/wcBCy3pENnCgBR9bN6JsY4OmhfUtIHe3ZW0mawA7+RDAcMLrMIZaf03NlQiX9DGyB8h4g==} + engines: {node: '>=12'} + cpu: [arm64] + os: [freebsd] + + '@esbuild/freebsd-arm64@0.27.7': + resolution: {integrity: sha512-B48PqeCsEgOtzME2GbNM2roU29AMTuOIN91dsMO30t+Ydis3z/3Ngoj5hhnsOSSwNzS+6JppqWsuhTp6E82l2w==} + engines: {node: '>=18'} + cpu: [arm64] + os: [freebsd] + + '@esbuild/freebsd-x64@0.21.5': + resolution: {integrity: sha512-J95kNBj1zkbMXtHVH29bBriQygMXqoVQOQYA+ISs0/2l3T9/kj42ow2mpqerRBxDJnmkUDCaQT/dfNXWX/ZZCQ==} + engines: {node: '>=12'} + cpu: [x64] + os: [freebsd] + + '@esbuild/freebsd-x64@0.27.7': + resolution: {integrity: sha512-jOBDK5XEjA4m5IJK3bpAQF9/Lelu/Z9ZcdhTRLf4cajlB+8VEhFFRjWgfy3M1O4rO2GQ/b2dLwCUGpiF/eATNQ==} + engines: {node: '>=18'} + cpu: [x64] + os: [freebsd] + + '@esbuild/linux-arm64@0.21.5': + resolution: {integrity: sha512-ibKvmyYzKsBeX8d8I7MH/TMfWDXBF3db4qM6sy+7re0YXya+K1cem3on9XgdT2EQGMu4hQyZhan7TeQ8XkGp4Q==} + engines: {node: '>=12'} + cpu: [arm64] + os: [linux] + + '@esbuild/linux-arm64@0.27.7': + resolution: {integrity: sha512-RZPHBoxXuNnPQO9rvjh5jdkRmVizktkT7TCDkDmQ0W2SwHInKCAV95GRuvdSvA7w4VMwfCjUiPwDi0ZO6Nfe9A==} + engines: {node: '>=18'} + cpu: [arm64] + os: [linux] + + '@esbuild/linux-arm@0.21.5': + resolution: {integrity: sha512-bPb5AHZtbeNGjCKVZ9UGqGwo8EUu4cLq68E95A53KlxAPRmUyYv2D6F0uUI65XisGOL1hBP5mTronbgo+0bFcA==} + engines: {node: '>=12'} + cpu: [arm] + os: [linux] + + '@esbuild/linux-arm@0.27.7': + resolution: {integrity: sha512-RkT/YXYBTSULo3+af8Ib0ykH8u2MBh57o7q/DAs3lTJlyVQkgQvlrPTnjIzzRPQyavxtPtfg0EopvDyIt0j1rA==} + engines: {node: '>=18'} + cpu: [arm] + os: [linux] + + '@esbuild/linux-ia32@0.21.5': + resolution: {integrity: sha512-YvjXDqLRqPDl2dvRODYmmhz4rPeVKYvppfGYKSNGdyZkA01046pLWyRKKI3ax8fbJoK5QbxblURkwK/MWY18Tg==} + engines: {node: '>=12'} + cpu: [ia32] + os: [linux] + + '@esbuild/linux-ia32@0.27.7': + resolution: {integrity: sha512-GA48aKNkyQDbd3KtkplYWT102C5sn/EZTY4XROkxONgruHPU72l+gW+FfF8tf2cFjeHaRbWpOYa/uRBz/Xq1Pg==} + engines: {node: '>=18'} + cpu: [ia32] + os: [linux] + + '@esbuild/linux-loong64@0.21.5': + resolution: {integrity: sha512-uHf1BmMG8qEvzdrzAqg2SIG/02+4/DHB6a9Kbya0XDvwDEKCoC8ZRWI5JJvNdUjtciBGFQ5PuBlpEOXQj+JQSg==} + engines: {node: '>=12'} + cpu: [loong64] + os: [linux] + + '@esbuild/linux-loong64@0.27.7': + resolution: {integrity: sha512-a4POruNM2oWsD4WKvBSEKGIiWQF8fZOAsycHOt6JBpZ+JN2n2JH9WAv56SOyu9X5IqAjqSIPTaJkqN8F7XOQ5Q==} + engines: {node: '>=18'} + cpu: [loong64] + os: [linux] + + '@esbuild/linux-mips64el@0.21.5': + resolution: {integrity: sha512-IajOmO+KJK23bj52dFSNCMsz1QP1DqM6cwLUv3W1QwyxkyIWecfafnI555fvSGqEKwjMXVLokcV5ygHW5b3Jbg==} + engines: {node: '>=12'} + cpu: [mips64el] + os: [linux] + + '@esbuild/linux-mips64el@0.27.7': + resolution: {integrity: sha512-KabT5I6StirGfIz0FMgl1I+R1H73Gp0ofL9A3nG3i/cYFJzKHhouBV5VWK1CSgKvVaG4q1RNpCTR2LuTVB3fIw==} + engines: {node: '>=18'} + cpu: [mips64el] + os: [linux] + + '@esbuild/linux-ppc64@0.21.5': + resolution: {integrity: sha512-1hHV/Z4OEfMwpLO8rp7CvlhBDnjsC3CttJXIhBi+5Aj5r+MBvy4egg7wCbe//hSsT+RvDAG7s81tAvpL2XAE4w==} + engines: {node: '>=12'} + cpu: [ppc64] + os: [linux] + + '@esbuild/linux-ppc64@0.27.7': + resolution: {integrity: sha512-gRsL4x6wsGHGRqhtI+ifpN/vpOFTQtnbsupUF5R5YTAg+y/lKelYR1hXbnBdzDjGbMYjVJLJTd2OFmMewAgwlQ==} + engines: {node: '>=18'} + cpu: [ppc64] + os: [linux] + + '@esbuild/linux-riscv64@0.21.5': + resolution: {integrity: sha512-2HdXDMd9GMgTGrPWnJzP2ALSokE/0O5HhTUvWIbD3YdjME8JwvSCnNGBnTThKGEB91OZhzrJ4qIIxk/SBmyDDA==} + engines: {node: '>=12'} + cpu: [riscv64] + os: [linux] + + '@esbuild/linux-riscv64@0.27.7': + resolution: {integrity: sha512-hL25LbxO1QOngGzu2U5xeXtxXcW+/GvMN3ejANqXkxZ/opySAZMrc+9LY/WyjAan41unrR3YrmtTsUpwT66InQ==} + engines: {node: '>=18'} + cpu: [riscv64] + os: [linux] + + '@esbuild/linux-s390x@0.21.5': + resolution: {integrity: sha512-zus5sxzqBJD3eXxwvjN1yQkRepANgxE9lgOW2qLnmr8ikMTphkjgXu1HR01K4FJg8h1kEEDAqDcZQtbrRnB41A==} + engines: {node: '>=12'} + cpu: [s390x] + os: [linux] + + '@esbuild/linux-s390x@0.27.7': + resolution: {integrity: sha512-2k8go8Ycu1Kb46vEelhu1vqEP+UeRVj2zY1pSuPdgvbd5ykAw82Lrro28vXUrRmzEsUV0NzCf54yARIK8r0fdw==} + engines: {node: '>=18'} + cpu: [s390x] + os: [linux] + + '@esbuild/linux-x64@0.21.5': + resolution: {integrity: sha512-1rYdTpyv03iycF1+BhzrzQJCdOuAOtaqHTWJZCWvijKD2N5Xu0TtVC8/+1faWqcP9iBCWOmjmhoH94dH82BxPQ==} + engines: {node: '>=12'} + cpu: [x64] + os: [linux] + + '@esbuild/linux-x64@0.27.7': + resolution: {integrity: sha512-hzznmADPt+OmsYzw1EE33ccA+HPdIqiCRq7cQeL1Jlq2gb1+OyWBkMCrYGBJ+sxVzve2ZJEVeePbLM2iEIZSxA==} + engines: {node: '>=18'} + cpu: [x64] + os: [linux] + + '@esbuild/netbsd-arm64@0.27.7': + resolution: {integrity: sha512-b6pqtrQdigZBwZxAn1UpazEisvwaIDvdbMbmrly7cDTMFnw/+3lVxxCTGOrkPVnsYIosJJXAsILG9XcQS+Yu6w==} + engines: {node: '>=18'} + cpu: [arm64] + os: [netbsd] + + '@esbuild/netbsd-x64@0.21.5': + resolution: {integrity: sha512-Woi2MXzXjMULccIwMnLciyZH4nCIMpWQAs049KEeMvOcNADVxo0UBIQPfSmxB3CWKedngg7sWZdLvLczpe0tLg==} + engines: {node: '>=12'} + cpu: [x64] + os: [netbsd] + + '@esbuild/netbsd-x64@0.27.7': + resolution: {integrity: sha512-OfatkLojr6U+WN5EDYuoQhtM+1xco+/6FSzJJnuWiUw5eVcicbyK3dq5EeV/QHT1uy6GoDhGbFpprUiHUYggrw==} + engines: {node: '>=18'} + cpu: [x64] + os: [netbsd] + + '@esbuild/openbsd-arm64@0.27.7': + resolution: {integrity: sha512-AFuojMQTxAz75Fo8idVcqoQWEHIXFRbOc1TrVcFSgCZtQfSdc1RXgB3tjOn/krRHENUB4j00bfGjyl2mJrU37A==} + engines: {node: '>=18'} + cpu: [arm64] + os: [openbsd] + + '@esbuild/openbsd-x64@0.21.5': + resolution: {integrity: sha512-HLNNw99xsvx12lFBUwoT8EVCsSvRNDVxNpjZ7bPn947b8gJPzeHWyNVhFsaerc0n3TsbOINvRP2byTZ5LKezow==} + engines: {node: '>=12'} + cpu: [x64] + os: [openbsd] + + '@esbuild/openbsd-x64@0.27.7': + resolution: {integrity: sha512-+A1NJmfM8WNDv5CLVQYJ5PshuRm/4cI6WMZRg1by1GwPIQPCTs1GLEUHwiiQGT5zDdyLiRM/l1G0Pv54gvtKIg==} + engines: {node: '>=18'} + cpu: [x64] + os: [openbsd] + + '@esbuild/openharmony-arm64@0.27.7': + resolution: {integrity: sha512-+KrvYb/C8zA9CU/g0sR6w2RBw7IGc5J2BPnc3dYc5VJxHCSF1yNMxTV5LQ7GuKteQXZtspjFbiuW5/dOj7H4Yw==} + engines: {node: '>=18'} + cpu: [arm64] + os: [openharmony] + + '@esbuild/sunos-x64@0.21.5': + resolution: {integrity: sha512-6+gjmFpfy0BHU5Tpptkuh8+uw3mnrvgs+dSPQXQOv3ekbordwnzTVEb4qnIvQcYXq6gzkyTnoZ9dZG+D4garKg==} + engines: {node: '>=12'} + cpu: [x64] + os: [sunos] + + '@esbuild/sunos-x64@0.27.7': + resolution: {integrity: sha512-ikktIhFBzQNt/QDyOL580ti9+5mL/YZeUPKU2ivGtGjdTYoqz6jObj6nOMfhASpS4GU4Q/Clh1QtxWAvcYKamA==} + engines: {node: '>=18'} + cpu: [x64] + os: [sunos] + + '@esbuild/win32-arm64@0.21.5': + resolution: {integrity: sha512-Z0gOTd75VvXqyq7nsl93zwahcTROgqvuAcYDUr+vOv8uHhNSKROyU961kgtCD1e95IqPKSQKH7tBTslnS3tA8A==} + engines: {node: '>=12'} + cpu: [arm64] + os: [win32] + + '@esbuild/win32-arm64@0.27.7': + resolution: {integrity: sha512-7yRhbHvPqSpRUV7Q20VuDwbjW5kIMwTHpptuUzV+AA46kiPze5Z7qgt6CLCK3pWFrHeNfDd1VKgyP4O+ng17CA==} + engines: {node: '>=18'} + cpu: [arm64] + os: [win32] + + '@esbuild/win32-ia32@0.21.5': + resolution: {integrity: sha512-SWXFF1CL2RVNMaVs+BBClwtfZSvDgtL//G/smwAc5oVK/UPu2Gu9tIaRgFmYFFKrmg3SyAjSrElf0TiJ1v8fYA==} + engines: {node: '>=12'} + cpu: [ia32] + os: [win32] + + '@esbuild/win32-ia32@0.27.7': + resolution: {integrity: sha512-SmwKXe6VHIyZYbBLJrhOoCJRB/Z1tckzmgTLfFYOfpMAx63BJEaL9ExI8x7v0oAO3Zh6D/Oi1gVxEYr5oUCFhw==} + engines: {node: '>=18'} + cpu: [ia32] + os: [win32] + + '@esbuild/win32-x64@0.21.5': + resolution: {integrity: sha512-tQd/1efJuzPC6rCFwEvLtci/xNFcTZknmXs98FYDfGE4wP9ClFV98nyKrzJKVPMhdDnjzLhdUyMX4PsQAPjwIw==} + engines: {node: '>=12'} + cpu: [x64] + os: [win32] + + '@esbuild/win32-x64@0.27.7': + resolution: {integrity: sha512-56hiAJPhwQ1R4i+21FVF7V8kSD5zZTdHcVuRFMW0hn753vVfQN8xlx4uOPT4xoGH0Z/oVATuR82AiqSTDIpaHg==} + engines: {node: '>=18'} + cpu: [x64] + os: [win32] + + '@img/colour@1.1.0': + resolution: {integrity: sha512-Td76q7j57o/tLVdgS746cYARfSyxk8iEfRxewL9h4OMzYhbW4TAcppl0mT4eyqXddh6L/jwoM75mo7ixa/pCeQ==} + engines: {node: '>=18'} + + '@img/sharp-darwin-arm64@0.34.5': + resolution: {integrity: sha512-imtQ3WMJXbMY4fxb/Ndp6HBTNVtWCUI0WdobyheGf5+ad6xX8VIDO8u2xE4qc/fr08CKG/7dDseFtn6M6g/r3w==} + engines: {node: ^18.17.0 || ^20.3.0 || >=21.0.0} + cpu: [arm64] + os: [darwin] + + '@img/sharp-darwin-x64@0.34.5': + resolution: {integrity: sha512-YNEFAF/4KQ/PeW0N+r+aVVsoIY0/qxxikF2SWdp+NRkmMB7y9LBZAVqQ4yhGCm/H3H270OSykqmQMKLBhBJDEw==} + engines: {node: ^18.17.0 || ^20.3.0 || >=21.0.0} + cpu: [x64] + os: [darwin] + + '@img/sharp-libvips-darwin-arm64@1.2.4': + resolution: {integrity: sha512-zqjjo7RatFfFoP0MkQ51jfuFZBnVE2pRiaydKJ1G/rHZvnsrHAOcQALIi9sA5co5xenQdTugCvtb1cuf78Vf4g==} + cpu: [arm64] + os: [darwin] + + '@img/sharp-libvips-darwin-x64@1.2.4': + resolution: {integrity: sha512-1IOd5xfVhlGwX+zXv2N93k0yMONvUlANylbJw1eTah8K/Jtpi15KC+WSiaX/nBmbm2HxRM1gZ0nSdjSsrZbGKg==} + cpu: [x64] + os: [darwin] + + '@img/sharp-libvips-linux-arm64@1.2.4': + resolution: {integrity: sha512-excjX8DfsIcJ10x1Kzr4RcWe1edC9PquDRRPx3YVCvQv+U5p7Yin2s32ftzikXojb1PIFc/9Mt28/y+iRklkrw==} + cpu: [arm64] + os: [linux] + + '@img/sharp-libvips-linux-arm@1.2.4': + resolution: {integrity: sha512-bFI7xcKFELdiNCVov8e44Ia4u2byA+l3XtsAj+Q8tfCwO6BQ8iDojYdvoPMqsKDkuoOo+X6HZA0s0q11ANMQ8A==} + cpu: [arm] + os: [linux] + + '@img/sharp-libvips-linux-ppc64@1.2.4': + resolution: {integrity: sha512-FMuvGijLDYG6lW+b/UvyilUWu5Ayu+3r2d1S8notiGCIyYU/76eig1UfMmkZ7vwgOrzKzlQbFSuQfgm7GYUPpA==} + cpu: [ppc64] + os: [linux] + + '@img/sharp-libvips-linux-riscv64@1.2.4': + resolution: {integrity: sha512-oVDbcR4zUC0ce82teubSm+x6ETixtKZBh/qbREIOcI3cULzDyb18Sr/Wcyx7NRQeQzOiHTNbZFF1UwPS2scyGA==} + cpu: [riscv64] + os: [linux] + + '@img/sharp-libvips-linux-s390x@1.2.4': + resolution: {integrity: sha512-qmp9VrzgPgMoGZyPvrQHqk02uyjA0/QrTO26Tqk6l4ZV0MPWIW6LTkqOIov+J1yEu7MbFQaDpwdwJKhbJvuRxQ==} + cpu: [s390x] + os: [linux] + + '@img/sharp-libvips-linux-x64@1.2.4': + resolution: {integrity: sha512-tJxiiLsmHc9Ax1bz3oaOYBURTXGIRDODBqhveVHonrHJ9/+k89qbLl0bcJns+e4t4rvaNBxaEZsFtSfAdquPrw==} + cpu: [x64] + os: [linux] + + '@img/sharp-libvips-linuxmusl-arm64@1.2.4': + resolution: {integrity: sha512-FVQHuwx1IIuNow9QAbYUzJ+En8KcVm9Lk5+uGUQJHaZmMECZmOlix9HnH7n1TRkXMS0pGxIJokIVB9SuqZGGXw==} + cpu: [arm64] + os: [linux] + + '@img/sharp-libvips-linuxmusl-x64@1.2.4': + resolution: {integrity: sha512-+LpyBk7L44ZIXwz/VYfglaX/okxezESc6UxDSoyo2Ks6Jxc4Y7sGjpgU9s4PMgqgjj1gZCylTieNamqA1MF7Dg==} + cpu: [x64] + os: [linux] + + '@img/sharp-linux-arm64@0.34.5': + resolution: {integrity: sha512-bKQzaJRY/bkPOXyKx5EVup7qkaojECG6NLYswgktOZjaXecSAeCWiZwwiFf3/Y+O1HrauiE3FVsGxFg8c24rZg==} + engines: {node: ^18.17.0 || ^20.3.0 || >=21.0.0} + cpu: [arm64] + os: [linux] + + '@img/sharp-linux-arm@0.34.5': + resolution: {integrity: sha512-9dLqsvwtg1uuXBGZKsxem9595+ujv0sJ6Vi8wcTANSFpwV/GONat5eCkzQo/1O6zRIkh0m/8+5BjrRr7jDUSZw==} + engines: {node: ^18.17.0 || ^20.3.0 || >=21.0.0} + cpu: [arm] + os: [linux] + + '@img/sharp-linux-ppc64@0.34.5': + resolution: {integrity: sha512-7zznwNaqW6YtsfrGGDA6BRkISKAAE1Jo0QdpNYXNMHu2+0dTrPflTLNkpc8l7MUP5M16ZJcUvysVWWrMefZquA==} + engines: {node: ^18.17.0 || ^20.3.0 || >=21.0.0} + cpu: [ppc64] + os: [linux] + + '@img/sharp-linux-riscv64@0.34.5': + resolution: {integrity: sha512-51gJuLPTKa7piYPaVs8GmByo7/U7/7TZOq+cnXJIHZKavIRHAP77e3N2HEl3dgiqdD/w0yUfiJnII77PuDDFdw==} + engines: {node: ^18.17.0 || ^20.3.0 || >=21.0.0} + cpu: [riscv64] + os: [linux] + + '@img/sharp-linux-s390x@0.34.5': + resolution: {integrity: sha512-nQtCk0PdKfho3eC5MrbQoigJ2gd1CgddUMkabUj+rBevs8tZ2cULOx46E7oyX+04WGfABgIwmMC0VqieTiR4jg==} + engines: {node: ^18.17.0 || ^20.3.0 || >=21.0.0} + cpu: [s390x] + os: [linux] + + '@img/sharp-linux-x64@0.34.5': + resolution: {integrity: sha512-MEzd8HPKxVxVenwAa+JRPwEC7QFjoPWuS5NZnBt6B3pu7EG2Ge0id1oLHZpPJdn3OQK+BQDiw9zStiHBTJQQQQ==} + engines: {node: ^18.17.0 || ^20.3.0 || >=21.0.0} + cpu: [x64] + os: [linux] + + '@img/sharp-linuxmusl-arm64@0.34.5': + resolution: {integrity: sha512-fprJR6GtRsMt6Kyfq44IsChVZeGN97gTD331weR1ex1c1rypDEABN6Tm2xa1wE6lYb5DdEnk03NZPqA7Id21yg==} + engines: {node: ^18.17.0 || ^20.3.0 || >=21.0.0} + cpu: [arm64] + os: [linux] + + '@img/sharp-linuxmusl-x64@0.34.5': + resolution: {integrity: sha512-Jg8wNT1MUzIvhBFxViqrEhWDGzqymo3sV7z7ZsaWbZNDLXRJZoRGrjulp60YYtV4wfY8VIKcWidjojlLcWrd8Q==} + engines: {node: ^18.17.0 || ^20.3.0 || >=21.0.0} + cpu: [x64] + os: [linux] + + '@img/sharp-wasm32@0.34.5': + resolution: {integrity: sha512-OdWTEiVkY2PHwqkbBI8frFxQQFekHaSSkUIJkwzclWZe64O1X4UlUjqqqLaPbUpMOQk6FBu/HtlGXNblIs0huw==} + engines: {node: ^18.17.0 || ^20.3.0 || >=21.0.0} + cpu: [wasm32] + + '@img/sharp-win32-arm64@0.34.5': + resolution: {integrity: sha512-WQ3AgWCWYSb2yt+IG8mnC6Jdk9Whs7O0gxphblsLvdhSpSTtmu69ZG1Gkb6NuvxsNACwiPV6cNSZNzt0KPsw7g==} + engines: {node: ^18.17.0 || ^20.3.0 || >=21.0.0} + cpu: [arm64] + os: [win32] + + '@img/sharp-win32-ia32@0.34.5': + resolution: {integrity: sha512-FV9m/7NmeCmSHDD5j4+4pNI8Cp3aW+JvLoXcTUo0IqyjSfAZJ8dIUmijx1qaJsIiU+Hosw6xM5KijAWRJCSgNg==} + engines: {node: ^18.17.0 || ^20.3.0 || >=21.0.0} + cpu: [ia32] + os: [win32] + + '@img/sharp-win32-x64@0.34.5': + resolution: {integrity: sha512-+29YMsqY2/9eFEiW93eqWnuLcWcufowXewwSNIT6UwZdUUCrM3oFjMWH/Z6/TMmb4hlFenmfAVbpWeup2jryCw==} + engines: {node: ^18.17.0 || ^20.3.0 || >=21.0.0} + cpu: [x64] + os: [win32] + + '@rollup/rollup-android-arm-eabi@4.60.1': + resolution: {integrity: sha512-d6FinEBLdIiK+1uACUttJKfgZREXrF0Qc2SmLII7W2AD8FfiZ9Wjd+rD/iRuf5s5dWrr1GgwXCvPqOuDquOowA==} + cpu: [arm] + os: [android] + + '@rollup/rollup-android-arm64@4.60.1': + resolution: {integrity: sha512-YjG/EwIDvvYI1YvYbHvDz/BYHtkY4ygUIXHnTdLhG+hKIQFBiosfWiACWortsKPKU/+dUwQQCKQM3qrDe8c9BA==} + cpu: [arm64] + os: [android] + + '@rollup/rollup-darwin-arm64@4.60.1': + resolution: {integrity: sha512-mjCpF7GmkRtSJwon+Rq1N8+pI+8l7w5g9Z3vWj4T7abguC4Czwi3Yu/pFaLvA3TTeMVjnu3ctigusqWUfjZzvw==} + cpu: [arm64] + os: [darwin] + + '@rollup/rollup-darwin-x64@4.60.1': + resolution: {integrity: sha512-haZ7hJ1JT4e9hqkoT9R/19XW2QKqjfJVv+i5AGg57S+nLk9lQnJ1F/eZloRO3o9Scy9CM3wQ9l+dkXtcBgN5Ew==} + cpu: [x64] + os: [darwin] + + '@rollup/rollup-freebsd-arm64@4.60.1': + resolution: {integrity: sha512-czw90wpQq3ZsAVBlinZjAYTKduOjTywlG7fEeWKUA7oCmpA8xdTkxZZlwNJKWqILlq0wehoZcJYfBvOyhPTQ6w==} + cpu: [arm64] + os: [freebsd] + + '@rollup/rollup-freebsd-x64@4.60.1': + resolution: {integrity: sha512-KVB2rqsxTHuBtfOeySEyzEOB7ltlB/ux38iu2rBQzkjbwRVlkhAGIEDiiYnO2kFOkJp+Z7pUXKyrRRFuFUKt+g==} + cpu: [x64] + os: [freebsd] + + '@rollup/rollup-linux-arm-gnueabihf@4.60.1': + resolution: {integrity: sha512-L+34Qqil+v5uC0zEubW7uByo78WOCIrBvci69E7sFASRl0X7b/MB6Cqd1lky/CtcSVTydWa2WZwFuWexjS5o6g==} + cpu: [arm] + os: [linux] + + '@rollup/rollup-linux-arm-musleabihf@4.60.1': + resolution: {integrity: sha512-n83O8rt4v34hgFzlkb1ycniJh7IR5RCIqt6mz1VRJD6pmhRi0CXdmfnLu9dIUS6buzh60IvACM842Ffb3xd6Gg==} + cpu: [arm] + os: [linux] + + '@rollup/rollup-linux-arm64-gnu@4.60.1': + resolution: {integrity: sha512-Nql7sTeAzhTAja3QXeAI48+/+GjBJ+QmAH13snn0AJSNL50JsDqotyudHyMbO2RbJkskbMbFJfIJKWA6R1LCJQ==} + cpu: [arm64] + os: [linux] + + '@rollup/rollup-linux-arm64-musl@4.60.1': + resolution: {integrity: sha512-+pUymDhd0ys9GcKZPPWlFiZ67sTWV5UU6zOJat02M1+PiuSGDziyRuI/pPue3hoUwm2uGfxdL+trT6Z9rxnlMA==} + cpu: [arm64] + os: [linux] + + '@rollup/rollup-linux-loong64-gnu@4.60.1': + resolution: {integrity: sha512-VSvgvQeIcsEvY4bKDHEDWcpW4Yw7BtlKG1GUT4FzBUlEKQK0rWHYBqQt6Fm2taXS+1bXvJT6kICu5ZwqKCnvlQ==} + cpu: [loong64] + os: [linux] + + '@rollup/rollup-linux-loong64-musl@4.60.1': + resolution: {integrity: sha512-4LqhUomJqwe641gsPp6xLfhqWMbQV04KtPp7/dIp0nzPxAkNY1AbwL5W0MQpcalLYk07vaW9Kp1PBhdpZYYcEw==} + cpu: [loong64] + os: [linux] + + '@rollup/rollup-linux-ppc64-gnu@4.60.1': + resolution: {integrity: sha512-tLQQ9aPvkBxOc/EUT6j3pyeMD6Hb8QF2BTBnCQWP/uu1lhc9AIrIjKnLYMEroIz/JvtGYgI9dF3AxHZNaEH0rw==} + cpu: [ppc64] + os: [linux] + + '@rollup/rollup-linux-ppc64-musl@4.60.1': + resolution: {integrity: sha512-RMxFhJwc9fSXP6PqmAz4cbv3kAyvD1etJFjTx4ONqFP9DkTkXsAMU4v3Vyc5BgzC+anz7nS/9tp4obsKfqkDHg==} + cpu: [ppc64] + os: [linux] + + '@rollup/rollup-linux-riscv64-gnu@4.60.1': + resolution: {integrity: sha512-QKgFl+Yc1eEk6MmOBfRHYF6lTxiiiV3/z/BRrbSiW2I7AFTXoBFvdMEyglohPj//2mZS4hDOqeB0H1ACh3sBbg==} + cpu: [riscv64] + os: [linux] + + '@rollup/rollup-linux-riscv64-musl@4.60.1': + resolution: {integrity: sha512-RAjXjP/8c6ZtzatZcA1RaQr6O1TRhzC+adn8YZDnChliZHviqIjmvFwHcxi4JKPSDAt6Uhf/7vqcBzQJy0PDJg==} + cpu: [riscv64] + os: [linux] + + '@rollup/rollup-linux-s390x-gnu@4.60.1': + resolution: {integrity: sha512-wcuocpaOlaL1COBYiA89O6yfjlp3RwKDeTIA0hM7OpmhR1Bjo9j31G1uQVpDlTvwxGn2nQs65fBFL5UFd76FcQ==} + cpu: [s390x] + os: [linux] + + '@rollup/rollup-linux-x64-gnu@4.60.1': + resolution: {integrity: sha512-77PpsFQUCOiZR9+LQEFg9GClyfkNXj1MP6wRnzYs0EeWbPcHs02AXu4xuUbM1zhwn3wqaizle3AEYg5aeoohhg==} + cpu: [x64] + os: [linux] + + '@rollup/rollup-linux-x64-musl@4.60.1': + resolution: {integrity: sha512-5cIATbk5vynAjqqmyBjlciMJl1+R/CwX9oLk/EyiFXDWd95KpHdrOJT//rnUl4cUcskrd0jCCw3wpZnhIHdD9w==} + cpu: [x64] + os: [linux] + + '@rollup/rollup-openbsd-x64@4.60.1': + resolution: {integrity: sha512-cl0w09WsCi17mcmWqqglez9Gk8isgeWvoUZ3WiJFYSR3zjBQc2J5/ihSjpl+VLjPqjQ/1hJRcqBfLjssREQILw==} + cpu: [x64] + os: [openbsd] + + '@rollup/rollup-openharmony-arm64@4.60.1': + resolution: {integrity: sha512-4Cv23ZrONRbNtbZa37mLSueXUCtN7MXccChtKpUnQNgF010rjrjfHx3QxkS2PI7LqGT5xXyYs1a7LbzAwT0iCA==} + cpu: [arm64] + os: [openharmony] + + '@rollup/rollup-win32-arm64-msvc@4.60.1': + resolution: {integrity: sha512-i1okWYkA4FJICtr7KpYzFpRTHgy5jdDbZiWfvny21iIKky5YExiDXP+zbXzm3dUcFpkEeYNHgQ5fuG236JPq0g==} + cpu: [arm64] + os: [win32] + + '@rollup/rollup-win32-ia32-msvc@4.60.1': + resolution: {integrity: sha512-u09m3CuwLzShA0EYKMNiFgcjjzwqtUMLmuCJLeZWjjOYA3IT2Di09KaxGBTP9xVztWyIWjVdsB2E9goMjZvTQg==} + cpu: [ia32] + os: [win32] + + '@rollup/rollup-win32-x64-gnu@4.60.1': + resolution: {integrity: sha512-k+600V9Zl1CM7eZxJgMyTUzmrmhB/0XZnF4pRypKAlAgxmedUA+1v9R+XOFv56W4SlHEzfeMtzujLJD22Uz5zg==} + cpu: [x64] + os: [win32] + + '@rollup/rollup-win32-x64-msvc@4.60.1': + resolution: {integrity: sha512-lWMnixq/QzxyhTV6NjQJ4SFo1J6PvOX8vUx5Wb4bBPsEb+8xZ89Bz6kOXpfXj9ak9AHTQVQzlgzBEc1SyM27xQ==} + cpu: [x64] + os: [win32] + + '@types/estree@1.0.8': + resolution: {integrity: sha512-dWHzHa2WqEXI/O1E9OjrocMTKJl2mSrEolh1Iomrv6U+JuNwaHXsXx9bLu5gG7BUWFIN0skIQJQ/L1rIex4X6w==} + + '@types/matter-js@0.19.8': + resolution: {integrity: sha512-W2ZWG58Lijv/4v768NgpeyFqqiOyslmAU7qqM1Lhz4XBoUgGtZtPz4CjcOKYtqHIak14dvPldslQhltqLTWwsw==} + + '@types/node@25.6.0': + resolution: {integrity: sha512-+qIYRKdNYJwY3vRCZMdJbPLJAtGjQBudzZzdzwQYkEPQd+PJGixUL5QfvCLDaULoLv+RhT3LDkwEfKaAkgSmNQ==} + + detect-libc@2.1.2: + resolution: {integrity: sha512-Btj2BOOO83o3WyH59e8MgXsxEQVcarkUOpEYrubB0urwnN10yQ364rsiByU11nZlqWYZm05i/of7io4mzihBtQ==} + engines: {node: '>=8'} + + esbuild@0.21.5: + resolution: {integrity: sha512-mg3OPMV4hXywwpoDxu3Qda5xCKQi+vCTZq8S9J/EpkhB2HzKXq4SNFZE3+NK93JYxc8VMSep+lOUSC/RVKaBqw==} + engines: {node: '>=12'} + hasBin: true + + esbuild@0.27.7: + resolution: {integrity: sha512-IxpibTjyVnmrIQo5aqNpCgoACA/dTKLTlhMHihVHhdkxKyPO1uBBthumT0rdHmcsk9uMonIWS0m4FljWzILh3w==} + engines: {node: '>=18'} + hasBin: true + + 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-tsconfig@4.13.7: + resolution: {integrity: sha512-7tN6rFgBlMgpBML5j8typ92BKFi2sFQvIdpAqLA2beia5avZDrMs0FLZiM5etShWq5irVyGcGMEA1jcDaK7A/Q==} + + matter-js@0.19.0: + resolution: {integrity: sha512-v2huwvQGOHTGOkMqtHd2hercCG3f6QAObTisPPHg8TZqq2lz7eIY/5i/5YUV8Ibf3mEioFEmwibcPUF2/fnKKQ==} + + nanoid@3.3.11: + resolution: {integrity: sha512-N8SpfPUnUp1bK+PMYW8qSWdl9U+wwNWI4QKxOYDy9JAro3WMX7p2OeVRF9v+347pnakNevPmiHhNmZ2HbFA76w==} + engines: {node: ^10 || ^12 || ^13.7 || ^14 || >=15.0.1} + hasBin: true + + picocolors@1.1.1: + resolution: {integrity: sha512-xceH2snhtb5M9liqDsmEw56le376mTZkEX/jEb/RxNFyegNul7eNslCXP9FDj/Lcu0X8KEyMceP2ntpaHrDEVA==} + + postcss@8.5.9: + resolution: {integrity: sha512-7a70Nsot+EMX9fFU3064K/kdHWZqGVY+BADLyXc8Dfv+mTLLVl6JzJpPaCZ2kQL9gIJvKXSLMHhqdRRjwQeFtw==} + engines: {node: ^10 || ^12 || >=14} + + resolve-pkg-maps@1.0.0: + resolution: {integrity: sha512-seS2Tj26TBVOC2NIc2rOe2y2ZO7efxITtLZcGSOnHHNOQ7CkiUBfw0Iw2ck6xkIhPwLhKNLS8BO+hEpngQlqzw==} + + rollup@4.60.1: + resolution: {integrity: sha512-VmtB2rFU/GroZ4oL8+ZqXgSA38O6GR8KSIvWmEFv63pQ0G6KaBH9s07PO8XTXP4vI+3UJUEypOfjkGfmSBBR0w==} + engines: {node: '>=18.0.0', npm: '>=8.0.0'} + hasBin: true + + semver@7.7.4: + resolution: {integrity: sha512-vFKC2IEtQnVhpT78h1Yp8wzwrf8CM+MzKMHGJZfBtzhZNycRFnXsHk6E5TxIkkMsgNS7mdX3AGB7x2QM2di4lA==} + engines: {node: '>=10'} + hasBin: true + + sharp@0.34.5: + resolution: {integrity: sha512-Ou9I5Ft9WNcCbXrU9cMgPBcCK8LiwLqcbywW3t4oDV37n1pzpuNLsYiAV8eODnjbtQlSDwZ2cUEeQz4E54Hltg==} + engines: {node: ^18.17.0 || ^20.3.0 || >=21.0.0} + + source-map-js@1.2.1: + resolution: {integrity: sha512-UXWMKhLOwVKb728IUtQPXxfYU+usdybtUrK/8uGE8CQMvrhOpwvzDBwj0QhSL7MQc7vIsISBG8VQ8+IDQxpfQA==} + engines: {node: '>=0.10.0'} + + tslib@2.8.1: + resolution: {integrity: sha512-oJFu94HQb+KVduSUQL7wnpmqnfmLsOA/nAh6b6EH0wCEoK0/mPeXU6c3wKDV83MkOuHPRHtSXKKU99IBazS/2w==} + + tsx@4.21.0: + resolution: {integrity: sha512-5C1sg4USs1lfG0GFb2RLXsdpXqBSEhAaA/0kPL01wxzpMqLILNxIxIOKiILz+cdg/pLnOUxFYOR5yhHU666wbw==} + engines: {node: '>=18.0.0'} + hasBin: true + + typescript@5.9.3: + resolution: {integrity: sha512-jl1vZzPDinLr9eUt3J/t7V6FgNEw9QjvBPdysz9KfQDD41fQrC2Y4vKQdiaUpFT4bXlb1RHhLpp8wtm6M5TgSw==} + engines: {node: '>=14.17'} + hasBin: true + + undici-types@7.19.2: + resolution: {integrity: sha512-qYVnV5OEm2AW8cJMCpdV20CDyaN3g0AjDlOGf1OW4iaDEx8MwdtChUp4zu4H0VP3nDRF/8RKWH+IPp9uW0YGZg==} + + uqr@0.1.3: + resolution: {integrity: sha512-0rjE8iEJe4YmT9TOhwsZtqCMRLc5DXZUI2UEYUUg63ikBkqqE5EYWaI0etFe/5KUcmcYwLih2RND1kq+hrUJXA==} + + vite-plugin-qrcode@0.4.1: + resolution: {integrity: sha512-t3gdCbn/a2HHLK3M8bUrQ+4c+I0YQGJ/STxAL9eI6TBnfdeM/CJr3HctvOb7UYhEtWxHs5xZWpz7kYnRd5w+rA==} + peerDependencies: + vite: '>=3' + + vite@5.4.21: + resolution: {integrity: sha512-o5a9xKjbtuhY6Bi5S3+HvbRERmouabWbyUcpXXUA1u+GNUKoROi9byOJ8M0nHbHYHkYICiMlqxkg1KkYmm25Sw==} + engines: {node: ^18.0.0 || >=20.0.0} + hasBin: true + peerDependencies: + '@types/node': ^18.0.0 || >=20.0.0 + less: '*' + lightningcss: ^1.21.0 + sass: '*' + sass-embedded: '*' + stylus: '*' + sugarss: '*' + terser: ^5.4.0 + peerDependenciesMeta: + '@types/node': + optional: true + less: + optional: true + lightningcss: + optional: true + sass: + optional: true + sass-embedded: + optional: true + stylus: + optional: true + sugarss: + optional: true + terser: + optional: true + +snapshots: + + '@emnapi/runtime@1.9.2': + dependencies: + tslib: 2.8.1 + optional: true + + '@esbuild/aix-ppc64@0.21.5': + optional: true + + '@esbuild/aix-ppc64@0.27.7': + optional: true + + '@esbuild/android-arm64@0.21.5': + optional: true + + '@esbuild/android-arm64@0.27.7': + optional: true + + '@esbuild/android-arm@0.21.5': + optional: true + + '@esbuild/android-arm@0.27.7': + optional: true + + '@esbuild/android-x64@0.21.5': + optional: true + + '@esbuild/android-x64@0.27.7': + optional: true + + '@esbuild/darwin-arm64@0.21.5': + optional: true + + '@esbuild/darwin-arm64@0.27.7': + optional: true + + '@esbuild/darwin-x64@0.21.5': + optional: true + + '@esbuild/darwin-x64@0.27.7': + optional: true + + '@esbuild/freebsd-arm64@0.21.5': + optional: true + + '@esbuild/freebsd-arm64@0.27.7': + optional: true + + '@esbuild/freebsd-x64@0.21.5': + optional: true + + '@esbuild/freebsd-x64@0.27.7': + optional: true + + '@esbuild/linux-arm64@0.21.5': + optional: true + + '@esbuild/linux-arm64@0.27.7': + optional: true + + '@esbuild/linux-arm@0.21.5': + optional: true + + '@esbuild/linux-arm@0.27.7': + optional: true + + '@esbuild/linux-ia32@0.21.5': + optional: true + + '@esbuild/linux-ia32@0.27.7': + optional: true + + '@esbuild/linux-loong64@0.21.5': + optional: true + + '@esbuild/linux-loong64@0.27.7': + optional: true + + '@esbuild/linux-mips64el@0.21.5': + optional: true + + '@esbuild/linux-mips64el@0.27.7': + optional: true + + '@esbuild/linux-ppc64@0.21.5': + optional: true + + '@esbuild/linux-ppc64@0.27.7': + optional: true + + '@esbuild/linux-riscv64@0.21.5': + optional: true + + '@esbuild/linux-riscv64@0.27.7': + optional: true + + '@esbuild/linux-s390x@0.21.5': + optional: true + + '@esbuild/linux-s390x@0.27.7': + optional: true + + '@esbuild/linux-x64@0.21.5': + optional: true + + '@esbuild/linux-x64@0.27.7': + optional: true + + '@esbuild/netbsd-arm64@0.27.7': + optional: true + + '@esbuild/netbsd-x64@0.21.5': + optional: true + + '@esbuild/netbsd-x64@0.27.7': + optional: true + + '@esbuild/openbsd-arm64@0.27.7': + optional: true + + '@esbuild/openbsd-x64@0.21.5': + optional: true + + '@esbuild/openbsd-x64@0.27.7': + optional: true + + '@esbuild/openharmony-arm64@0.27.7': + optional: true + + '@esbuild/sunos-x64@0.21.5': + optional: true + + '@esbuild/sunos-x64@0.27.7': + optional: true + + '@esbuild/win32-arm64@0.21.5': + optional: true + + '@esbuild/win32-arm64@0.27.7': + optional: true + + '@esbuild/win32-ia32@0.21.5': + optional: true + + '@esbuild/win32-ia32@0.27.7': + optional: true + + '@esbuild/win32-x64@0.21.5': + optional: true + + '@esbuild/win32-x64@0.27.7': + optional: true + + '@img/colour@1.1.0': {} + + '@img/sharp-darwin-arm64@0.34.5': + optionalDependencies: + '@img/sharp-libvips-darwin-arm64': 1.2.4 + optional: true + + '@img/sharp-darwin-x64@0.34.5': + optionalDependencies: + '@img/sharp-libvips-darwin-x64': 1.2.4 + optional: true + + '@img/sharp-libvips-darwin-arm64@1.2.4': + optional: true + + '@img/sharp-libvips-darwin-x64@1.2.4': + optional: true + + '@img/sharp-libvips-linux-arm64@1.2.4': + optional: true + + '@img/sharp-libvips-linux-arm@1.2.4': + optional: true + + '@img/sharp-libvips-linux-ppc64@1.2.4': + optional: true + + '@img/sharp-libvips-linux-riscv64@1.2.4': + optional: true + + '@img/sharp-libvips-linux-s390x@1.2.4': + optional: true + + '@img/sharp-libvips-linux-x64@1.2.4': + optional: true + + '@img/sharp-libvips-linuxmusl-arm64@1.2.4': + optional: true + + '@img/sharp-libvips-linuxmusl-x64@1.2.4': + optional: true + + '@img/sharp-linux-arm64@0.34.5': + optionalDependencies: + '@img/sharp-libvips-linux-arm64': 1.2.4 + optional: true + + '@img/sharp-linux-arm@0.34.5': + optionalDependencies: + '@img/sharp-libvips-linux-arm': 1.2.4 + optional: true + + '@img/sharp-linux-ppc64@0.34.5': + optionalDependencies: + '@img/sharp-libvips-linux-ppc64': 1.2.4 + optional: true + + '@img/sharp-linux-riscv64@0.34.5': + optionalDependencies: + '@img/sharp-libvips-linux-riscv64': 1.2.4 + optional: true + + '@img/sharp-linux-s390x@0.34.5': + optionalDependencies: + '@img/sharp-libvips-linux-s390x': 1.2.4 + optional: true + + '@img/sharp-linux-x64@0.34.5': + optionalDependencies: + '@img/sharp-libvips-linux-x64': 1.2.4 + optional: true + + '@img/sharp-linuxmusl-arm64@0.34.5': + optionalDependencies: + '@img/sharp-libvips-linuxmusl-arm64': 1.2.4 + optional: true + + '@img/sharp-linuxmusl-x64@0.34.5': + optionalDependencies: + '@img/sharp-libvips-linuxmusl-x64': 1.2.4 + optional: true + + '@img/sharp-wasm32@0.34.5': + dependencies: + '@emnapi/runtime': 1.9.2 + optional: true + + '@img/sharp-win32-arm64@0.34.5': + optional: true + + '@img/sharp-win32-ia32@0.34.5': + optional: true + + '@img/sharp-win32-x64@0.34.5': + optional: true + + '@rollup/rollup-android-arm-eabi@4.60.1': + optional: true + + '@rollup/rollup-android-arm64@4.60.1': + optional: true + + '@rollup/rollup-darwin-arm64@4.60.1': + optional: true + + '@rollup/rollup-darwin-x64@4.60.1': + optional: true + + '@rollup/rollup-freebsd-arm64@4.60.1': + optional: true + + '@rollup/rollup-freebsd-x64@4.60.1': + optional: true + + '@rollup/rollup-linux-arm-gnueabihf@4.60.1': + optional: true + + '@rollup/rollup-linux-arm-musleabihf@4.60.1': + optional: true + + '@rollup/rollup-linux-arm64-gnu@4.60.1': + optional: true + + '@rollup/rollup-linux-arm64-musl@4.60.1': + optional: true + + '@rollup/rollup-linux-loong64-gnu@4.60.1': + optional: true + + '@rollup/rollup-linux-loong64-musl@4.60.1': + optional: true + + '@rollup/rollup-linux-ppc64-gnu@4.60.1': + optional: true + + '@rollup/rollup-linux-ppc64-musl@4.60.1': + optional: true + + '@rollup/rollup-linux-riscv64-gnu@4.60.1': + optional: true + + '@rollup/rollup-linux-riscv64-musl@4.60.1': + optional: true + + '@rollup/rollup-linux-s390x-gnu@4.60.1': + optional: true + + '@rollup/rollup-linux-x64-gnu@4.60.1': + optional: true + + '@rollup/rollup-linux-x64-musl@4.60.1': + optional: true + + '@rollup/rollup-openbsd-x64@4.60.1': + optional: true + + '@rollup/rollup-openharmony-arm64@4.60.1': + optional: true + + '@rollup/rollup-win32-arm64-msvc@4.60.1': + optional: true + + '@rollup/rollup-win32-ia32-msvc@4.60.1': + optional: true + + '@rollup/rollup-win32-x64-gnu@4.60.1': + optional: true + + '@rollup/rollup-win32-x64-msvc@4.60.1': + optional: true + + '@types/estree@1.0.8': {} + + '@types/matter-js@0.19.8': {} + + '@types/node@25.6.0': + dependencies: + undici-types: 7.19.2 + + detect-libc@2.1.2: {} + + esbuild@0.21.5: + optionalDependencies: + '@esbuild/aix-ppc64': 0.21.5 + '@esbuild/android-arm': 0.21.5 + '@esbuild/android-arm64': 0.21.5 + '@esbuild/android-x64': 0.21.5 + '@esbuild/darwin-arm64': 0.21.5 + '@esbuild/darwin-x64': 0.21.5 + '@esbuild/freebsd-arm64': 0.21.5 + '@esbuild/freebsd-x64': 0.21.5 + '@esbuild/linux-arm': 0.21.5 + '@esbuild/linux-arm64': 0.21.5 + '@esbuild/linux-ia32': 0.21.5 + '@esbuild/linux-loong64': 0.21.5 + '@esbuild/linux-mips64el': 0.21.5 + '@esbuild/linux-ppc64': 0.21.5 + '@esbuild/linux-riscv64': 0.21.5 + '@esbuild/linux-s390x': 0.21.5 + '@esbuild/linux-x64': 0.21.5 + '@esbuild/netbsd-x64': 0.21.5 + '@esbuild/openbsd-x64': 0.21.5 + '@esbuild/sunos-x64': 0.21.5 + '@esbuild/win32-arm64': 0.21.5 + '@esbuild/win32-ia32': 0.21.5 + '@esbuild/win32-x64': 0.21.5 + + esbuild@0.27.7: + optionalDependencies: + '@esbuild/aix-ppc64': 0.27.7 + '@esbuild/android-arm': 0.27.7 + '@esbuild/android-arm64': 0.27.7 + '@esbuild/android-x64': 0.27.7 + '@esbuild/darwin-arm64': 0.27.7 + '@esbuild/darwin-x64': 0.27.7 + '@esbuild/freebsd-arm64': 0.27.7 + '@esbuild/freebsd-x64': 0.27.7 + '@esbuild/linux-arm': 0.27.7 + '@esbuild/linux-arm64': 0.27.7 + '@esbuild/linux-ia32': 0.27.7 + '@esbuild/linux-loong64': 0.27.7 + '@esbuild/linux-mips64el': 0.27.7 + '@esbuild/linux-ppc64': 0.27.7 + '@esbuild/linux-riscv64': 0.27.7 + '@esbuild/linux-s390x': 0.27.7 + '@esbuild/linux-x64': 0.27.7 + '@esbuild/netbsd-arm64': 0.27.7 + '@esbuild/netbsd-x64': 0.27.7 + '@esbuild/openbsd-arm64': 0.27.7 + '@esbuild/openbsd-x64': 0.27.7 + '@esbuild/openharmony-arm64': 0.27.7 + '@esbuild/sunos-x64': 0.27.7 + '@esbuild/win32-arm64': 0.27.7 + '@esbuild/win32-ia32': 0.27.7 + '@esbuild/win32-x64': 0.27.7 + + fsevents@2.3.3: + optional: true + + get-tsconfig@4.13.7: + dependencies: + resolve-pkg-maps: 1.0.0 + + matter-js@0.19.0: {} + + nanoid@3.3.11: {} + + picocolors@1.1.1: {} + + postcss@8.5.9: + dependencies: + nanoid: 3.3.11 + picocolors: 1.1.1 + source-map-js: 1.2.1 + + resolve-pkg-maps@1.0.0: {} + + rollup@4.60.1: + dependencies: + '@types/estree': 1.0.8 + optionalDependencies: + '@rollup/rollup-android-arm-eabi': 4.60.1 + '@rollup/rollup-android-arm64': 4.60.1 + '@rollup/rollup-darwin-arm64': 4.60.1 + '@rollup/rollup-darwin-x64': 4.60.1 + '@rollup/rollup-freebsd-arm64': 4.60.1 + '@rollup/rollup-freebsd-x64': 4.60.1 + '@rollup/rollup-linux-arm-gnueabihf': 4.60.1 + '@rollup/rollup-linux-arm-musleabihf': 4.60.1 + '@rollup/rollup-linux-arm64-gnu': 4.60.1 + '@rollup/rollup-linux-arm64-musl': 4.60.1 + '@rollup/rollup-linux-loong64-gnu': 4.60.1 + '@rollup/rollup-linux-loong64-musl': 4.60.1 + '@rollup/rollup-linux-ppc64-gnu': 4.60.1 + '@rollup/rollup-linux-ppc64-musl': 4.60.1 + '@rollup/rollup-linux-riscv64-gnu': 4.60.1 + '@rollup/rollup-linux-riscv64-musl': 4.60.1 + '@rollup/rollup-linux-s390x-gnu': 4.60.1 + '@rollup/rollup-linux-x64-gnu': 4.60.1 + '@rollup/rollup-linux-x64-musl': 4.60.1 + '@rollup/rollup-openbsd-x64': 4.60.1 + '@rollup/rollup-openharmony-arm64': 4.60.1 + '@rollup/rollup-win32-arm64-msvc': 4.60.1 + '@rollup/rollup-win32-ia32-msvc': 4.60.1 + '@rollup/rollup-win32-x64-gnu': 4.60.1 + '@rollup/rollup-win32-x64-msvc': 4.60.1 + fsevents: 2.3.3 + + semver@7.7.4: {} + + sharp@0.34.5: + dependencies: + '@img/colour': 1.1.0 + detect-libc: 2.1.2 + semver: 7.7.4 + optionalDependencies: + '@img/sharp-darwin-arm64': 0.34.5 + '@img/sharp-darwin-x64': 0.34.5 + '@img/sharp-libvips-darwin-arm64': 1.2.4 + '@img/sharp-libvips-darwin-x64': 1.2.4 + '@img/sharp-libvips-linux-arm': 1.2.4 + '@img/sharp-libvips-linux-arm64': 1.2.4 + '@img/sharp-libvips-linux-ppc64': 1.2.4 + '@img/sharp-libvips-linux-riscv64': 1.2.4 + '@img/sharp-libvips-linux-s390x': 1.2.4 + '@img/sharp-libvips-linux-x64': 1.2.4 + '@img/sharp-libvips-linuxmusl-arm64': 1.2.4 + '@img/sharp-libvips-linuxmusl-x64': 1.2.4 + '@img/sharp-linux-arm': 0.34.5 + '@img/sharp-linux-arm64': 0.34.5 + '@img/sharp-linux-ppc64': 0.34.5 + '@img/sharp-linux-riscv64': 0.34.5 + '@img/sharp-linux-s390x': 0.34.5 + '@img/sharp-linux-x64': 0.34.5 + '@img/sharp-linuxmusl-arm64': 0.34.5 + '@img/sharp-linuxmusl-x64': 0.34.5 + '@img/sharp-wasm32': 0.34.5 + '@img/sharp-win32-arm64': 0.34.5 + '@img/sharp-win32-ia32': 0.34.5 + '@img/sharp-win32-x64': 0.34.5 + + source-map-js@1.2.1: {} + + tslib@2.8.1: + optional: true + + tsx@4.21.0: + dependencies: + esbuild: 0.27.7 + get-tsconfig: 4.13.7 + optionalDependencies: + fsevents: 2.3.3 + + typescript@5.9.3: {} + + undici-types@7.19.2: {} + + uqr@0.1.3: {} + + vite-plugin-qrcode@0.4.1(vite@5.4.21(@types/node@25.6.0)): + dependencies: + uqr: 0.1.3 + vite: 5.4.21(@types/node@25.6.0) + + vite@5.4.21(@types/node@25.6.0): + dependencies: + esbuild: 0.21.5 + postcss: 8.5.9 + rollup: 4.60.1 + optionalDependencies: + '@types/node': 25.6.0 + fsevents: 2.3.3 diff --git a/public/apple-touch-icon.png b/public/apple-touch-icon.png new file mode 100644 index 0000000..bc5ee6d Binary files /dev/null and b/public/apple-touch-icon.png differ diff --git a/public/favicon-16x16.png b/public/favicon-16x16.png new file mode 100644 index 0000000..8a2eec6 Binary files /dev/null and b/public/favicon-16x16.png differ diff --git a/public/favicon-32x32.png b/public/favicon-32x32.png new file mode 100644 index 0000000..80d1fca Binary files /dev/null and b/public/favicon-32x32.png differ diff --git a/public/icon-192.png b/public/icon-192.png new file mode 100644 index 0000000..a29ce34 Binary files /dev/null and b/public/icon-192.png differ diff --git a/public/icon-512.png b/public/icon-512.png new file mode 100644 index 0000000..5503f7d Binary files /dev/null and b/public/icon-512.png differ diff --git a/public/og-image.png b/public/og-image.png new file mode 100644 index 0000000..7499312 Binary files /dev/null and b/public/og-image.png differ diff --git a/public/site.webmanifest b/public/site.webmanifest new file mode 100644 index 0000000..2298244 --- /dev/null +++ b/public/site.webmanifest @@ -0,0 +1,21 @@ +{ + "name": "Nuts", + "short_name": "Nuts", + "description": "A box of nuts bouncing around with haptic feedback.", + "start_url": "./", + "display": "fullscreen", + "background_color": "#111111", + "theme_color": "#111111", + "icons": [ + { + "src": "icon-192.png", + "sizes": "192x192", + "type": "image/png" + }, + { + "src": "icon-512.png", + "sizes": "512x512", + "type": "image/png" + } + ] +} \ No newline at end of file diff --git a/scripts/generate-icons.ts b/scripts/generate-icons.ts new file mode 100644 index 0000000..3d62af7 --- /dev/null +++ b/scripts/generate-icons.ts @@ -0,0 +1,77 @@ +/** + * Generates all icon / favicon assets from acorn.png into public/. + * Run with: pnpm generate-icons + * + * Icon by Freepik – Flaticon + * https://www.flaticon.com/search?author_id=296&style_id=1001&type=standard + */ + +import sharp from 'sharp' +import { mkdir } from 'node:fs/promises' +import { resolve, dirname } from 'node:path' +import { fileURLToPath } from 'node:url' + +const __dirname = dirname(fileURLToPath(import.meta.url)) +const root = resolve(__dirname, '..') +const src = resolve(root, 'acorn.png') +const out = resolve(root, 'public') + +await mkdir(out, { recursive: true }) + +// ── Size definitions ─────────────────────────────────────────────────────── +interface Color { + r: number + g: number + b: number + alpha: number +} + +interface Task { + file: string + size: number + bg: Color + padFraction?: number +} + +const DARK_BG: Color = { r: 17, g: 17, b: 17, alpha: 1 } +const WHITE_BG: Color = { r: 255, g: 255, b: 255, alpha: 1 } + +const tasks: Task[] = [ + // Standard favicons (white background – legible at tiny sizes on all UIs) + { file: 'favicon-16x16.png', size: 16, bg: WHITE_BG }, + { file: 'favicon-32x32.png', size: 32, bg: WHITE_BG }, + // Apple Touch Icon (dark background to match the app) + { file: 'apple-touch-icon.png', size: 180, bg: DARK_BG }, + // PWA / Android icons + { file: 'icon-192.png', size: 192, bg: DARK_BG }, + { file: 'icon-512.png', size: 512, bg: DARK_BG }, + // OG / share image (larger, dark bg, the icon centred with padding) + { file: 'og-image.png', size: 512, bg: DARK_BG, padFraction: 0.15 }, +] + +for (const { file, size, bg, padFraction = 0 } of tasks) { + const iconSize = Math.round(size * (1 - padFraction * 2)) + const dest = resolve(out, file) + const transparent = { r: 0, g: 0, b: 0, alpha: 0 } as const + + let pipeline = sharp(src).resize(iconSize, iconSize, { + fit: 'contain', + background: transparent, + }) + + if (padFraction > 0) { + const pad = Math.round(size * padFraction) + pipeline = pipeline.extend({ + top: pad, + bottom: pad, + left: pad, + right: pad, + background: transparent, + }) + } + + await pipeline.flatten({ background: bg }).png().toFile(dest) + console.log(` ✓ ${file} (${size}×${size})`) +} + +console.log('\nAll icons written to public/') diff --git a/src/config.ts b/src/config.ts new file mode 100644 index 0000000..6e385e4 --- /dev/null +++ b/src/config.ts @@ -0,0 +1,29 @@ +export const CONFIG = { + // Nuts + defaultNutCount: 20, + minNuts: 1, + maxNuts: 50, + nutBaseRadius: 22, // px, circumradius of hex polygon + nutRadiusVariance: 0.28, // ±28% random size variance + + // Physics + restitution: 0.45, // bounciness + friction: 0.18, // surface friction + frictionAir: 0.012, // 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 + + // Shake + shakeAccelThreshold: 14, // m/s² delta to count as shake + shakeForceScale: 0.007, // impulse magnitude per unit of shake intensity + + // Orientation gravity + orientationMaxTilt: 80, // degrees before clamping +} as const diff --git a/src/main.ts b/src/main.ts new file mode 100644 index 0000000..89241c7 --- /dev/null +++ b/src/main.ts @@ -0,0 +1,129 @@ +import { CONFIG } from './config' +import { createPhysicsWorld } from './physics' +import { createRenderer } from './renderer' +import { requestMotionPermission, initMotion } from './motion' +import { initUI, removeStartOverlay } from './ui' + +// ── Bootstrap ────────────────────────────────────────────────────────────── + +async function main(): Promise<void> { + const canvas = document.getElementById('canvas') as HTMLCanvasElement + + // Match canvas to the physical pixel dimensions for crisp rendering on HiDPI + const dpr = window.devicePixelRatio || 1 + let cssWidth = window.innerWidth + let cssHeight = window.innerHeight + + function getPixelWidth(): number { return Math.round(cssWidth * dpr) } + function getPixelHeight(): number { return Math.round(cssHeight * dpr) } + + canvas.width = getPixelWidth() + canvas.height = getPixelHeight() + canvas.style.width = `${cssWidth}px` + canvas.style.height = `${cssHeight}px` + + // Scale the canvas context so all drawing coordinates are in CSS pixels + const ctx = canvas.getContext('2d')! + ctx.scale(dpr, dpr) + + // Physics world operates in CSS pixel space + const physics = createPhysicsWorld(cssWidth, cssHeight) + const renderer = createRenderer(canvas) + + // Correct for the DPR scale we already applied to the context + // The renderer draws at CSS px resolution; canvas is scaled by dpr above + renderer.resize(getPixelWidth() / dpr, getPixelHeight() / dpr) + + let nutCount: number = CONFIG.defaultNutCount + physics.setNutCount(nutCount) + + // ── UI ─────────────────────────────────────────────────────────────────── + initUI({ + onNutCountChange: (count) => { + nutCount = count + physics.setNutCount(count) + }, + onReset: () => { + physics.setNutCount(nutCount) + }, + }) + + // ── Resize ─────────────────────────────────────────────────────────────── + window.addEventListener('resize', () => { + cssWidth = window.innerWidth + cssHeight = window.innerHeight + canvas.width = getPixelWidth() + canvas.height = getPixelHeight() + canvas.style.width = `${cssWidth}px` + canvas.style.height = `${cssHeight}px` + const c = canvas.getContext('2d')! + c.scale(dpr, dpr) + renderer.resize(cssWidth, cssHeight) + physics.resize(cssWidth, cssHeight) + }) + + // ── Motion (shake + tilt gravity) ──────────────────────────────────────── + let motionEnabled = false + + function startMotion(): void { + if (motionEnabled) return + motionEnabled = true + initMotion({ + onShake: (intensity) => { + physics.applyShakeForce(intensity) + }, + onOrientation: (gx, gy) => { + physics.setGravity(gx, gy) + }, + }) + } + + // ── Start overlay / permission ──────────────────────────────────────────── + const startOverlay = document.getElementById('start-overlay')! + startOverlay.addEventListener( + 'click', + async () => { + try { + await requestMotionPermission() + } catch { + // Permission denied or unavailable – proceed without motion + } + startMotion() + removeStartOverlay() + startLoop() + }, + { once: true } + ) + + // On non-iOS desktop, start automatically without waiting for tap + if (!('ontouchstart' in window)) { + // Small delay so the UI paints before we start the loop + setTimeout(() => { + startMotion() + removeStartOverlay() + startLoop() + }, 400) + } + + // ── Game loop ───────────────────────────────────────────────────────────── + let lastTimestamp = 0 + let loopRunning = false + + function startLoop(): void { + if (loopRunning) return + loopRunning = true + requestAnimationFrame(loop) + } + + function loop(timestamp: number): void { + const delta = lastTimestamp === 0 ? 16.67 : timestamp - lastTimestamp + lastTimestamp = timestamp + + physics.step(delta) + renderer.render(physics.nuts) + + requestAnimationFrame(loop) + } +} + +main().catch(console.error) diff --git a/src/motion.ts b/src/motion.ts new file mode 100644 index 0000000..220b35f --- /dev/null +++ b/src/motion.ts @@ -0,0 +1,94 @@ +import { CONFIG } from './config' +import { vibrateShake } from './vibration' + +export interface MotionCallbacks { + onShake: (intensity: number) => void + onOrientation: (gravX: number, gravY: number) => void +} + +/** Request iOS 13+ permission for DeviceMotion/DeviceOrientation. */ +export async function requestMotionPermission(): Promise<void> { + // DeviceMotionEvent + if ( + typeof DeviceMotionEvent !== 'undefined' && + 'requestPermission' in DeviceMotionEvent && + typeof (DeviceMotionEvent as unknown as { requestPermission: () => Promise<string> }) + .requestPermission === 'function' + ) { + await ( + DeviceMotionEvent as unknown as { requestPermission: () => Promise<string> } + ).requestPermission() + } + + // DeviceOrientationEvent + if ( + typeof DeviceOrientationEvent !== 'undefined' && + 'requestPermission' in DeviceOrientationEvent && + typeof (DeviceOrientationEvent as unknown as { requestPermission: () => Promise<string> }) + .requestPermission === 'function' + ) { + await ( + DeviceOrientationEvent as unknown as { requestPermission: () => Promise<string> } + ).requestPermission() + } +} + +/** Attach DeviceMotion (shake) and DeviceOrientation (gravity tilt) listeners. */ +export function initMotion(callbacks: MotionCallbacks): () => void { + let prevX = 0 + let prevY = 0 + let prevZ = 0 + let lastCheckAt = 0 + + function onMotion(event: DeviceMotionEvent): void { + const acc = event.accelerationIncludingGravity + if (!acc) return + + const now = Date.now() + if (now - lastCheckAt < 80) return // limit check rate to ~12 Hz + lastCheckAt = now + + const ax = acc.x ?? 0 + const ay = acc.y ?? 0 + const az = acc.z ?? 0 + + const delta = Math.sqrt( + (ax - prevX) ** 2 + (ay - prevY) ** 2 + (az - prevZ) ** 2 + ) + + if (delta > CONFIG.shakeAccelThreshold) { + callbacks.onShake(delta) + vibrateShake() + } + + prevX = ax + prevY = ay + prevZ = az + } + + function onOrientation(event: DeviceOrientationEvent): void { + if (event.beta === null || event.gamma === null) return + + // beta = front/back tilt, 0 = flat, 90 = vertical facing up + // gamma = left/right tilt, -90…90 + const clamp = (v: number, min: number, max: number) => + Math.max(min, Math.min(max, v)) + + const beta = clamp(event.beta, -CONFIG.orientationMaxTilt, CONFIG.orientationMaxTilt) + const gamma = clamp(event.gamma, -90, 90) + + // Map to gravity vector components. Normalise so magnitude ≤ 1. + const gx = Math.sin((gamma * Math.PI) / 180) + const gy = Math.sin((beta * Math.PI) / 180) + + callbacks.onOrientation(gx, gy) + } + + window.addEventListener('devicemotion', onMotion) + window.addEventListener('deviceorientation', onOrientation) + + return () => { + window.removeEventListener('devicemotion', onMotion) + window.removeEventListener('deviceorientation', onOrientation) + } +} diff --git a/src/physics.ts b/src/physics.ts new file mode 100644 index 0000000..a14b787 --- /dev/null +++ b/src/physics.ts @@ -0,0 +1,131 @@ +import Matter from 'matter-js' +import { CONFIG } from './config' +import { vibrate } from './vibration' + +const { Engine, Bodies, Body, Composite, Events } = Matter + +// ── Types ────────────────────────────────────────────────────────────────── + +export interface NutBody { + body: Matter.Body + radius: number + colorIndex: number +} + +export interface PhysicsWorld { + engine: Matter.Engine + nuts: NutBody[] + setNutCount: (count: number) => void + resize: (width: number, height: number) => void + applyShakeForce: (intensity: number) => void + setGravity: (gx: number, gy: number) => void + step: (deltaMs: number) => void + destroy: () => void +} + +// ── Helpers ──────────────────────────────────────────────────────────────── + +function buildWalls(width: number, height: number): Matter.Body[] { + const t = CONFIG.wallThickness + const opts: Matter.IBodyDefinition = { isStatic: true, label: 'wall', friction: 0.3, restitution: 0.3 } + return [ + Bodies.rectangle(width / 2, -t / 2, width + t * 2, t, opts), // top + Bodies.rectangle(width / 2, height + t / 2, width + t * 2, t, opts), // bottom + Bodies.rectangle(-t / 2, height / 2, t, height + t * 2, opts), // left + Bodies.rectangle(width + t / 2, height / 2, t, height + t * 2, opts), // right + ] +} + +function spawnNut(width: number, height: 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, { + restitution: CONFIG.restitution, + friction: CONFIG.friction, + frictionAir: CONFIG.frictionAir, + label: 'nut', + }) + return { body, radius, colorIndex } +} + +// ── Factory ──────────────────────────────────────────────────────────────── + +export function createPhysicsWorld(width: number, height: number): PhysicsWorld { + const engine = Engine.create() + engine.gravity.x = 0 + engine.gravity.y = CONFIG.gravityScale + + let walls = buildWalls(width, height) + let nuts: NutBody[] = [] + let currentWidth = width + let currentHeight = height + + Composite.add(engine.world, walls) + + // ── 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))) + } + } + }) + + // ── Public API ───────────────────────────────────────────────────────── + + function setNutCount(count: number): 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 { + currentWidth = newWidth + currentHeight = newHeight + for (const wall of walls) Composite.remove(engine.world, wall) + walls = buildWalls(newWidth, newHeight) + Composite.add(engine.world, walls) + } + + function applyShakeForce(intensity: number): void { + const scale = CONFIG.shakeForceScale * (intensity / CONFIG.shakeAccelThreshold) + for (const { body } of nuts) { + Body.applyForce(body, body.position, { + x: (Math.random() - 0.5) * scale * body.mass, + y: (Math.random() - 0.5) * scale * body.mass, + }) + } + } + + function setGravity(gx: number, gy: number): void { + engine.gravity.x = gx + engine.gravity.y = gy === 0 ? 1e-4 : gy // avoid full zero-gravity + } + + function step(deltaMs: number): void { + Engine.update(engine, Math.min(deltaMs, 50)) + } + + function destroy(): void { + Composite.clear(engine.world, false) + Engine.clear(engine) + } + + return { engine, nuts, setNutCount, resize, applyShakeForce, setGravity, step, destroy } +} diff --git a/src/renderer.ts b/src/renderer.ts new file mode 100644 index 0000000..e9c232e --- /dev/null +++ b/src/renderer.ts @@ -0,0 +1,166 @@ +import type { NutBody } from './physics' + +// ── 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 +] + +// ── 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 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 + const oc = document.createElement('canvas') + oc.width = size + oc.height = size + const ctx = oc.getContext('2d')! + 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) + } + ctx.closePath() + + // 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() + + // Drop shadow + ctx.shadowColor = 'rgba(0,0,0,0.55)' + ctx.shadowBlur = 6 + ctx.shadowOffsetY = 3 + + // 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) + + ctx.fillStyle = grad + ctx.fill('evenodd') + + // Reset shadow for stroke + ctx.shadowColor = 'transparent' + ctx.shadowBlur = 0 + ctx.shadowOffsetY = 0 + + // Outer stroke + ctx.lineWidth = 1.5 + ctx.strokeStyle = stroke + // Re-build outer hex only for stroke (no hole) + 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() + + // Inner hole edge + ctx.lineWidth = 1 + ctx.strokeStyle = shadow + ctx.beginPath() + ctx.arc(cx, cy, holeR, 0, Math.PI * 2) + 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() + + return oc +} + +function getSprite(radius: number, colorIndex: number): HTMLCanvasElement { + const r = roundToNearest2(radius) + const key = `${r}_${colorIndex % PALETTE.length}` + let sprite = spriteCache.get(key) + if (!sprite) { + sprite = buildSprite(r, colorIndex) + spriteCache.set(key, sprite) + } + return sprite +} + +// ── Renderer ─────────────────────────────────────────────────────────────── + +export interface Renderer { + render: (nuts: NutBody[]) => void + resize: (width: number, height: number) => void +} + +export function createRenderer(canvas: HTMLCanvasElement): Renderer { + const ctx = canvas.getContext('2d')! + + function render(nuts: NutBody[]): void { + const w = canvas.width + const h = canvas.height + + // Background + 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) + 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 + } + } + + function resize(width: number, height: number): void { + canvas.width = width + canvas.height = height + } + + return { render, resize } +} diff --git a/src/style.css b/src/style.css new file mode 100644 index 0000000..50ea38a --- /dev/null +++ b/src/style.css @@ -0,0 +1,270 @@ +*, +*::before, +*::after { + box-sizing: border-box; + margin: 0; + padding: 0; +} + +html, +body { + width: 100%; + height: 100%; + overflow: hidden; + background: #111; + touch-action: none; + -webkit-tap-highlight-color: transparent; + user-select: none; + -webkit-user-select: none; +} + +#canvas { + display: block; + position: fixed; + inset: 0; + width: 100%; + height: 100%; +} + +/* ── Start overlay ─────────────────────────────────────────── */ +#start-overlay { + position: fixed; + inset: 0; + background: #111; + display: flex; + align-items: center; + justify-content: center; + z-index: 100; + cursor: pointer; +} + +.start-content { + display: flex; + flex-direction: column; + align-items: center; + gap: 12px; + color: #e0e0e0; +} + +.nut-icon { + width: 96px; + height: 96px; + filter: drop-shadow(0 0 24px rgba(200, 168, 75, 0.6)) invert(1) sepia(1) saturate(3) hue-rotate(5deg); + animation: bob 2.4s ease-in-out infinite; +} + +@keyframes bob { + + 0%, + 100% { + transform: translateY(0) rotate(0deg); + } + + 50% { + transform: translateY(-12px) rotate(15deg); + } +} + +.start-content h1 { + font-family: system-ui, -apple-system, sans-serif; + font-size: 36px; + font-weight: 700; + letter-spacing: 0.08em; + color: #c8a84b; + text-transform: uppercase; +} + +.start-content p { + font-family: system-ui, -apple-system, sans-serif; + font-size: 16px; + color: #666; + letter-spacing: 0.12em; + text-transform: uppercase; +} + +/* ── Settings button ────────────────────────────────────────── */ +#settings-btn { + position: fixed; + top: max(16px, env(safe-area-inset-top, 16px)); + right: max(16px, env(safe-area-inset-right, 16px)); + width: 44px; + height: 44px; + border-radius: 50%; + border: none; + background: rgba(255, 255, 255, 0.1); + color: #aaa; + font-size: 22px; + cursor: pointer; + z-index: 50; + backdrop-filter: blur(8px); + -webkit-backdrop-filter: blur(8px); + display: flex; + align-items: center; + justify-content: center; + transition: background 0.2s; +} + +#settings-btn:not([hidden]) { + display: flex; +} + +#settings-btn:active { + background: rgba(255, 255, 255, 0.2); +} + +/* ── Settings panel ─────────────────────────────────────────── */ +#settings-panel { + position: fixed; + inset: 0; + z-index: 80; +} + +#settings-panel:not([hidden]) { + display: block; +} + +.panel-backdrop { + position: absolute; + inset: 0; + background: rgba(0, 0, 0, 0.6); +} + +.panel-sheet { + position: absolute; + bottom: 0; + left: 0; + right: 0; + background: #1e1e1e; + border-radius: 20px 20px 0 0; + padding: 12px 24px max(24px, env(safe-area-inset-bottom, 24px)); + display: flex; + flex-direction: column; + gap: 16px; + font-family: system-ui, -apple-system, sans-serif; + color: #e0e0e0; +} + +.panel-handle { + width: 36px; + height: 5px; + background: #444; + border-radius: 3px; + align-self: center; + margin-bottom: 4px; +} + +.panel-sheet h2 { + font-size: 18px; + font-weight: 600; + color: #fff; +} + +.setting-row { + display: flex; + justify-content: space-between; + align-items: center; + font-size: 15px; +} + +.setting-value { + font-weight: 600; + color: #c8a84b; + min-width: 28px; + text-align: right; +} + +input[type="range"] { + -webkit-appearance: none; + width: 100%; + height: 6px; + border-radius: 3px; + background: #333; + outline: none; + margin-top: -8px; +} + +input[type="range"]::-webkit-slider-thumb { + -webkit-appearance: none; + width: 26px; + height: 26px; + border-radius: 50%; + background: #c8a84b; + cursor: pointer; + box-shadow: 0 2px 6px rgba(0, 0, 0, 0.4); +} + +input[type="range"]::-moz-range-thumb { + width: 26px; + height: 26px; + border-radius: 50%; + background: #c8a84b; + cursor: pointer; + border: none; + box-shadow: 0 2px 6px rgba(0, 0, 0, 0.4); +} + +.action-btn { + background: #2a2a2a; + border: 1px solid #3a3a3a; + color: #ccc; + font-size: 15px; + padding: 12px 16px; + border-radius: 10px; + cursor: pointer; + font-family: inherit; + text-align: center; + transition: background 0.15s; +} + +.action-btn:active { + background: #333; +} + +.close-btn { + background: #c8a84b; + border: none; + color: #111; + font-size: 16px; + font-weight: 600; + padding: 14px 16px; + border-radius: 12px; + cursor: pointer; + font-family: inherit; + transition: opacity 0.15s; +} + +.close-btn:active { + opacity: 0.85; +} + +.panel-links { + display: flex; + justify-content: center; + gap: 12px; + font-size: 14px; +} + +.panel-links a { + color: #c8a84b; + text-decoration: none; +} + +.panel-links a:active { + opacity: 0.7; +} + +.panel-links span { + color: #444; +} + +.attribution { + font-size: 12px; + color: #555; + text-align: center; +} + +.attribution a { + color: #666; + text-decoration: underline; + text-underline-offset: 2px; +} \ No newline at end of file diff --git a/src/ui.ts b/src/ui.ts new file mode 100644 index 0000000..63dd3a3 --- /dev/null +++ b/src/ui.ts @@ -0,0 +1,56 @@ +import { CONFIG } from './config' + +export interface UICallbacks { + onNutCountChange: (count: number) => void + onReset: () => void +} + +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 closeBtn = document.getElementById('close-settings') as HTMLButtonElement + const backdrop = settingsPanel.querySelector('.panel-backdrop') as HTMLDivElement + + // 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) + + // Show settings button + settingsBtn.hidden = false + + settingsBtn.addEventListener('click', () => { + settingsPanel.hidden = false + }) + + closeBtn.addEventListener('click', closePanel) + backdrop.addEventListener('click', closePanel) + + nutCountInput.addEventListener('input', () => { + const count = parseInt(nutCountInput.value, 10) + nutCountDisplay.textContent = String(count) + callbacks.onNutCountChange(count) + }) + + resetBtn.addEventListener('click', () => { + callbacks.onReset() + closePanel() + }) + + function closePanel(): void { + settingsPanel.hidden = true + } +} + +/** Remove the start overlay (call after user interaction / permission grant). */ +export function removeStartOverlay(): void { + const overlay = document.getElementById('start-overlay') + if (!overlay) return + overlay.style.transition = 'opacity 0.4s' + overlay.style.opacity = '0' + setTimeout(() => overlay.remove(), 420) +} diff --git a/src/vibration.ts b/src/vibration.ts new file mode 100644 index 0000000..cace1f3 --- /dev/null +++ b/src/vibration.ts @@ -0,0 +1,21 @@ +import { CONFIG } from './config' + +let lastVibrationAt = 0 + +/** 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 + lastVibrationAt = now + navigator.vibrate(durationMs) +} + +/** 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]) +} diff --git a/tsconfig.json b/tsconfig.json new file mode 100644 index 0000000..bff732a --- /dev/null +++ b/tsconfig.json @@ -0,0 +1,22 @@ +{ + "compilerOptions": { + "target": "ES2020", + "useDefineForClassFields": true, + "module": "ESNext", + "lib": [ + "ES2020", + "DOM", + "DOM.Iterable" + ], + "skipLibCheck": true, + "moduleResolution": "bundler", + "allowImportingTsExtensions": true, + "resolveJsonModule": true, + "isolatedModules": true, + "noEmit": true, + "strict": true + }, + "include": [ + "src" + ] +} \ No newline at end of file diff --git a/vite.config.ts b/vite.config.ts new file mode 100644 index 0000000..dd6cb2a --- /dev/null +++ b/vite.config.ts @@ -0,0 +1,13 @@ +import { defineConfig } from 'vite' +import { qrcode } from 'vite-plugin-qrcode' + +export default defineConfig({ + base: './', + build: { + outDir: 'dist', + }, + server: { + host: true, // listen on 0.0.0.0 so the phone can reach it + }, + plugins: [qrcode()], +})