nuts / commit
added sounds
package.json | 1 + pnpm-lock.yaml | 153 ++++++++++++++++++++++++++++++++++ src/config.ts | 3 + src/main.ts | 77 +++++++++-------- src/persistence.ts | 7 +- src/physics.ts | 40 ++++++++- src/sound.ts | 113 +++++++++++++++++++++++++ src/sounds/impactGlass_medium_000.ogg | Bin 0 -> 8400 bytes src/sounds/impactGlass_medium_001.ogg | Bin 0 -> 8605 bytes src/sounds/impactGlass_medium_002.ogg | Bin 0 -> 8341 bytes src/sounds/impactMetal_heavy_000.ogg | Bin 0 -> 6110 bytes src/sounds/impactMetal_heavy_001.ogg | Bin 0 -> 7279 bytes src/sounds/impactMetal_heavy_002.ogg | Bin 0 -> 5634 bytes src/sounds/impactMining_000.ogg | Bin 0 -> 12035 bytes src/sounds/impactMining_001.ogg | Bin 0 -> 11481 bytes src/sounds/impactMining_002.ogg | Bin 0 -> 11235 bytes src/sounds/impactPunch_heavy_000.ogg | Bin 0 -> 11617 bytes src/sounds/impactPunch_heavy_001.ogg | Bin 0 -> 10861 bytes src/sounds/impactPunch_heavy_002.ogg | Bin 0 -> 9025 bytes src/sounds/impactSoft_heavy_000.ogg | Bin 0 -> 6570 bytes src/sounds/impactSoft_heavy_001.ogg | Bin 0 -> 6689 bytes src/sounds/impactSoft_heavy_002.ogg | Bin 0 -> 6749 bytes src/sounds/impactSoft_medium_000.ogg | Bin 0 -> 5351 bytes src/sounds/impactSoft_medium_001.ogg | Bin 0 -> 5506 bytes src/sounds/impactSoft_medium_002.ogg | Bin 0 -> 5358 bytes src/sounds/impactTin_medium_000.ogg | Bin 0 -> 6509 bytes src/sounds/impactTin_medium_001.ogg | Bin 0 -> 6603 bytes src/sounds/impactTin_medium_002.ogg | Bin 0 -> 6156 bytes src/sounds/impactWood_medium_000.ogg | Bin 0 -> 6575 bytes src/sounds/impactWood_medium_001.ogg | Bin 0 -> 6555 bytes src/sounds/impactWood_medium_002.ogg | Bin 0 -> 6607 bytes src/ui.ts | 13 +++ vite.config.ts | 81 +++++++++++++++++- 33 files changed, 448 insertions(+), 40 deletions(-) diff --git a/package.json b/package.json index 24cdaa7..4748406 100644 --- a/package.json +++ b/package.json @@ -19,6 +19,7 @@ "@types/node": "^25.6.0", "@types/qrcode": "^1.5.6", "@vitejs/plugin-basic-ssl": "^2.3.0", + "ffmpeg-static": "^5.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 e198ed1..fdca09a 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -27,6 +27,9 @@ importers: '@vitejs/plugin-basic-ssl': specifier: ^2.3.0 version: 2.3.0(vite@5.4.21(@types/node@25.6.0)) + ffmpeg-static: + specifier: ^5.3.0 + version: 5.3.0 sharp: specifier: ^0.34.5 version: 0.34.5 @@ -45,6 +48,10 @@ importers: packages: + '@derhuerst/http-basic@8.2.4': + resolution: {integrity: sha512-F9rL9k9Xjf5blCz8HsJRO4diy111cayL2vkY2XE4r4t3n0yPXVYy3KD3nJ1qbrSn9743UWSXH4IwuCa/HWlGFw==} + engines: {node: '>=6.0.0'} + '@emnapi/runtime@1.9.2': resolution: {integrity: sha512-3U4+MIWHImeyu1wnmVygh5WlgfYDtyf0k8AbLhMFxOipihf6nrWC4syIm/SwEeec0mNSafiiNnMJwbza/Is6Lw==} @@ -610,6 +617,9 @@ packages: '@types/matter-js@0.19.8': resolution: {integrity: sha512-W2ZWG58Lijv/4v768NgpeyFqqiOyslmAU7qqM1Lhz4XBoUgGtZtPz4CjcOKYtqHIak14dvPldslQhltqLTWwsw==} + '@types/node@10.17.60': + resolution: {integrity: sha512-F0KIgDJfy2nA3zMLmWGKxcH2ZVEtCZXHHdOQs2gSaQ27+lNeEfGxzkIw90aXswATX7AZ33tahPbzy6KAfUreVw==} + '@types/node@25.6.0': resolution: {integrity: sha512-+qIYRKdNYJwY3vRCZMdJbPLJAtGjQBudzZzdzwQYkEPQd+PJGixUL5QfvCLDaULoLv+RhT3LDkwEfKaAkgSmNQ==} @@ -622,6 +632,10 @@ packages: peerDependencies: vite: ^6.0.0 || ^7.0.0 || ^8.0.0 + agent-base@6.0.2: + resolution: {integrity: sha512-RZNwNclF7+MS/8bDg70amg32dyeZGZxiDuQmZxKLAlQjr3jGyLx+4Kkk58UO7D2QdgFIQCovuSuZESne6RG6XQ==} + engines: {node: '>= 6.0.0'} + ansi-regex@5.0.1: resolution: {integrity: sha512-quJQXlTSUGL2LH9SUXo8VwsY4soanhgo6LNSm84E1LBcE8s3O0wpdiRzyR9z/ZZJMlMWv37qOOb9pdJlMUEKFQ==} engines: {node: '>=8'} @@ -630,10 +644,16 @@ packages: resolution: {integrity: sha512-zbB9rCJAT1rbjiVDb2hqKFHNYLxgtk8NURxZ3IZwD3F6NtxbXZQCnnSi1Lkx+IDohdPlFp222wVALIheZJQSEg==} engines: {node: '>=8'} + buffer-from@1.1.2: + resolution: {integrity: sha512-E+XQCRwSbaaiChtv6k6Dwgc+bx+Bs6vuKJHHl5kox/BaKbhiXzqQOwK4cO22yElGp2OCmjwVhT3HmxgyPGnJfQ==} + camelcase@5.3.1: resolution: {integrity: sha512-L28STB170nwWS63UjtlEOE3dldQApaJXZkOI1uMFfzf3rRuPegHaHesyee+YxQ+W6SvRDQV6UrdOdRiR153wJg==} engines: {node: '>=6'} + caseless@0.12.0: + resolution: {integrity: sha512-4tYFyifaFfGacoiObjJegolkwSU4xQNGbVgUiNYVUxbQ2x2lUsFvY4hVgVzGiIe6WLOPqycWXA40l+PWsxthUw==} + cliui@6.0.0: resolution: {integrity: sha512-t6wbgtoCXvAzst7QgXxJYqPt0usEfbgQdftEPbLL/cvv6HPE5VgvqCuAIDR0NgU52ds6rFwqrgakNLrHEjCbrQ==} @@ -644,6 +664,19 @@ packages: color-name@1.1.4: resolution: {integrity: sha512-dOy+3AuW3a2wNbZHIuMZpTcgjGuLU/uBL/ubcZF9OXbDo8ff4O8yVp5Bf0efS8uEoYo5q4Fx7dY9OgQGXgAsQA==} + concat-stream@2.0.0: + resolution: {integrity: sha512-MWufYdFw53ccGjCA+Ol7XJYpAlW6/prSMzuPOTRnJGcGzuhLn4Scrz7qf6o8bROZ514ltazcIFJZevcfbo0x7A==} + engines: {'0': node >= 6.0} + + debug@4.4.3: + resolution: {integrity: sha512-RGwwWnwQvkVfavKVt22FGLw+xYSdzARwm0ru6DhTVA3umU5hZc28V3kO4stgYryrTlLpuvgI9GiijltAjNbcqA==} + engines: {node: '>=6.0'} + peerDependencies: + supports-color: '*' + peerDependenciesMeta: + supports-color: + optional: true + decamelize@1.2.0: resolution: {integrity: sha512-z2S+W9X73hAUUki+N+9Za2lBlun89zigOyGrsax+KUQ6wKW4ZoWpEYBkGhQjwAjjDCkWxhY0VKEhk8wzY7F5cA==} engines: {node: '>=0.10.0'} @@ -658,6 +691,10 @@ packages: emoji-regex@8.0.0: resolution: {integrity: sha512-MSjYzcWNOA0ewAHpz0MxpYFvwg6yjy1NG3xteoqz644VCo/RPgnr1/GGt+ic3iJTzQ8Eu3TdM14SawnVUmGE6A==} + env-paths@2.2.1: + resolution: {integrity: sha512-+h1lkLKhZMTYjog1VEpJNG7NZJWcuc2DDk/qsqSTRRCOXiLjeQ1d1/udrUGhqMxUgAlwKNZ0cf2uqan5GLuS2A==} + engines: {node: '>=6'} + esbuild@0.21.5: resolution: {integrity: sha512-mg3OPMV4hXywwpoDxu3Qda5xCKQi+vCTZq8S9J/EpkhB2HzKXq4SNFZE3+NK93JYxc8VMSep+lOUSC/RVKaBqw==} engines: {node: '>=12'} @@ -668,6 +705,10 @@ packages: engines: {node: '>=18'} hasBin: true + ffmpeg-static@5.3.0: + resolution: {integrity: sha512-H+K6sW6TiIX6VGend0KQwthe+kaceeH/luE8dIZyOP35ik7ahYojDuqlTV1bOrtEwl01sy2HFNGQfi5IDJvotg==} + engines: {node: '>=16'} + find-up@4.1.0: resolution: {integrity: sha512-PpOwAdQ/YlXQ2vj8a3h8IipDuYRi3wceVQQGYWxNINccq40Anw7BlsEXCMbt1Zt+OLA6Fq9suIpIWD0OsnISlw==} engines: {node: '>=8'} @@ -684,6 +725,16 @@ packages: get-tsconfig@4.13.7: resolution: {integrity: sha512-7tN6rFgBlMgpBML5j8typ92BKFi2sFQvIdpAqLA2beia5avZDrMs0FLZiM5etShWq5irVyGcGMEA1jcDaK7A/Q==} + http-response-object@3.0.2: + resolution: {integrity: sha512-bqX0XTF6fnXSQcEJ2Iuyr75yVakyjIDCqroJQ/aHfSdlM743Cwqoi2nDYMzLGWUcuTWGWy8AAvOKXTfiv6q9RA==} + + https-proxy-agent@5.0.1: + resolution: {integrity: sha512-dFcAjpTQFgoLMzC2VwU+C/CbS7uRL0lWmxDITmqm7C+7F0Odmj6s9l6alZc6AELXhrnggM2CeWSXHGOdX2YtwA==} + engines: {node: '>= 6'} + + inherits@2.0.4: + resolution: {integrity: sha512-k/vGaX4/Yla3WzyMCvTQOXYeIHvqOKtnqBduzTHpzpQZzAskKMhZ2K+EnBiSM9zGSoIFeMpXKxa4dYeZIQqewQ==} + is-fullwidth-code-point@3.0.0: resolution: {integrity: sha512-zymm5+u+sCsSWyD9qNaejV3DFvhCKclKdizYaJUuHA83RLjb7nSuGnddCHGv0hk+KY7BMAlsWeK4Ueg6EV6XQg==} engines: {node: '>=8'} @@ -695,6 +746,9 @@ packages: matter-js@0.19.0: resolution: {integrity: sha512-v2huwvQGOHTGOkMqtHd2hercCG3f6QAObTisPPHg8TZqq2lz7eIY/5i/5YUV8Ibf3mEioFEmwibcPUF2/fnKKQ==} + ms@2.1.3: + resolution: {integrity: sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA==} + nanoid@3.3.11: resolution: {integrity: sha512-N8SpfPUnUp1bK+PMYW8qSWdl9U+wwNWI4QKxOYDy9JAro3WMX7p2OeVRF9v+347pnakNevPmiHhNmZ2HbFA76w==} engines: {node: ^10 || ^12 || ^13.7 || ^14 || >=15.0.1} @@ -712,6 +766,9 @@ packages: resolution: {integrity: sha512-R4nPAVTAU0B9D35/Gk3uJf/7XYbQcyohSKdvAxIRSNghFl4e71hVoGnBNQz9cWaXxO2I10KTC+3jMdvvoKw6dQ==} engines: {node: '>=6'} + parse-cache-control@1.0.1: + resolution: {integrity: sha512-60zvsJReQPX5/QP0Kzfd/VrpjScIQ7SHBW6bFCYfEP+fp0Eppr1SHhIO5nd1PjZtvclzSzES9D/p5nFJurwfWg==} + path-exists@4.0.0: resolution: {integrity: sha512-ak9Qy5Q7jYb2Wwcey5Fpvg2KoAc/ZIhLSLOSBmRmygPsGwkVVt0fZa0qrtMz+m6tJTAHfZQ8FnmB4MG4LWy7/w==} engines: {node: '>=8'} @@ -727,11 +784,19 @@ packages: resolution: {integrity: sha512-7a70Nsot+EMX9fFU3064K/kdHWZqGVY+BADLyXc8Dfv+mTLLVl6JzJpPaCZ2kQL9gIJvKXSLMHhqdRRjwQeFtw==} engines: {node: ^10 || ^12 || >=14} + progress@2.0.3: + resolution: {integrity: sha512-7PiHtLll5LdnKIMw100I+8xJXR5gW2QwWYkT6iJva0bXitZKa/XMrSbdmg3r2Xnaidz9Qumd0VPaMrZlF9V9sA==} + engines: {node: '>=0.4.0'} + qrcode@1.5.4: resolution: {integrity: sha512-1ca71Zgiu6ORjHqFBDpnSMTR2ReToX4l1Au1VFLyVeBTFavzQnv5JxMFr3ukHVKpSrSA2MCk0lNJSykjUfz7Zg==} engines: {node: '>=10.13.0'} hasBin: true + readable-stream@3.6.2: + resolution: {integrity: sha512-9u/sniCrY3D5WdsERHzHE4G2YCXqoG5FTHUiCC4SIbr6XcLZBY05ya9EKjYek9O5xOAwjGq+1JdGBAS7Q9ScoA==} + engines: {node: '>= 6'} + require-directory@2.1.1: resolution: {integrity: sha512-fGxEI7+wsG9xrvdjsrlmL22OMTTiHRwAMroiEeMgq8gzoLC/PQr7RsRDSTLUg/bZAZtF+TVIkHc6/4RIKrui+Q==} engines: {node: '>=0.10.0'} @@ -747,6 +812,9 @@ packages: engines: {node: '>=18.0.0', npm: '>=8.0.0'} hasBin: true + safe-buffer@5.2.1: + resolution: {integrity: sha512-rp3So07KcdmmKbGvgaNxQSJr7bGVSVk5S9Eq1F+ppbRo70+YeaDxkw5Dd8NPN+GD6bjnYm2VuPuCXmpuYvmCXQ==} + semver@7.7.4: resolution: {integrity: sha512-vFKC2IEtQnVhpT78h1Yp8wzwrf8CM+MzKMHGJZfBtzhZNycRFnXsHk6E5TxIkkMsgNS7mdX3AGB7x2QM2di4lA==} engines: {node: '>=10'} @@ -767,6 +835,9 @@ packages: resolution: {integrity: sha512-wKyQRQpjJ0sIp62ErSZdGsjMJWsap5oRNihHhu6G7JVO/9jIB6UyevL+tXuOqrng8j/cxKTWyWUwvSTriiZz/g==} engines: {node: '>=8'} + string_decoder@1.3.0: + resolution: {integrity: sha512-hkRX8U1WjJFd8LsDJ2yQ/wWWxaopEsABU1XfkM8A+j0+85JAGppt16cr1Whg6KIbb4okU6Mql6BOj+uup/wKeA==} + strip-ansi@6.0.1: resolution: {integrity: sha512-Y38VPSHcqkFrCpFnQ9vuSXmquuv5oXOKpGeT6aGrr3o3Gc9AlVa6JBfUSOCnbxGGZF+/0ooI7KrPuUSztUdU5A==} engines: {node: '>=8'} @@ -779,6 +850,9 @@ packages: engines: {node: '>=18.0.0'} hasBin: true + typedarray@0.0.6: + resolution: {integrity: sha512-/aCDEGatGvZ2BIk+HmLf4ifCJFwvKFNb9/JeZPMulfgFracn9QFcAf5GO8B/mweUjSoblS5In0cWhqpfs/5PQA==} + typescript@5.9.3: resolution: {integrity: sha512-jl1vZzPDinLr9eUt3J/t7V6FgNEw9QjvBPdysz9KfQDD41fQrC2Y4vKQdiaUpFT4bXlb1RHhLpp8wtm6M5TgSw==} engines: {node: '>=14.17'} @@ -790,6 +864,9 @@ packages: uqr@0.1.3: resolution: {integrity: sha512-0rjE8iEJe4YmT9TOhwsZtqCMRLc5DXZUI2UEYUUg63ikBkqqE5EYWaI0etFe/5KUcmcYwLih2RND1kq+hrUJXA==} + util-deprecate@1.0.2: + resolution: {integrity: sha512-EPD5q1uXyFxJpCrLnCc1nHnq3gOa6DZBocAIiI2TaSCA7VCJ1UJDMagCzIkXNsUYfD1daK//LTEQ8xiIbrHtcw==} + vite-plugin-qrcode@0.4.1: resolution: {integrity: sha512-t3gdCbn/a2HHLK3M8bUrQ+4c+I0YQGJ/STxAL9eI6TBnfdeM/CJr3HctvOb7UYhEtWxHs5xZWpz7kYnRd5w+rA==} peerDependencies: @@ -846,6 +923,13 @@ packages: snapshots: + '@derhuerst/http-basic@8.2.4': + dependencies: + caseless: 0.12.0 + concat-stream: 2.0.0 + http-response-object: 3.0.2 + parse-cache-control: 1.0.1 + '@emnapi/runtime@1.9.2': dependencies: tslib: 2.8.1 @@ -1173,6 +1257,8 @@ snapshots: '@types/matter-js@0.19.8': {} + '@types/node@10.17.60': {} + '@types/node@25.6.0': dependencies: undici-types: 7.19.2 @@ -1185,14 +1271,24 @@ snapshots: dependencies: vite: 5.4.21(@types/node@25.6.0) + agent-base@6.0.2: + dependencies: + debug: 4.4.3 + transitivePeerDependencies: + - supports-color + ansi-regex@5.0.1: {} ansi-styles@4.3.0: dependencies: color-convert: 2.0.1 + buffer-from@1.1.2: {} + camelcase@5.3.1: {} + caseless@0.12.0: {} + cliui@6.0.0: dependencies: string-width: 4.2.3 @@ -1205,6 +1301,17 @@ snapshots: color-name@1.1.4: {} + concat-stream@2.0.0: + dependencies: + buffer-from: 1.1.2 + inherits: 2.0.4 + readable-stream: 3.6.2 + typedarray: 0.0.6 + + debug@4.4.3: + dependencies: + ms: 2.1.3 + decamelize@1.2.0: {} detect-libc@2.1.2: {} @@ -1213,6 +1320,8 @@ snapshots: emoji-regex@8.0.0: {} + env-paths@2.2.1: {} + esbuild@0.21.5: optionalDependencies: '@esbuild/aix-ppc64': 0.21.5 @@ -1268,6 +1377,15 @@ snapshots: '@esbuild/win32-ia32': 0.27.7 '@esbuild/win32-x64': 0.27.7 + ffmpeg-static@5.3.0: + dependencies: + '@derhuerst/http-basic': 8.2.4 + env-paths: 2.2.1 + https-proxy-agent: 5.0.1 + progress: 2.0.3 + transitivePeerDependencies: + - supports-color + find-up@4.1.0: dependencies: locate-path: 5.0.0 @@ -1282,6 +1400,19 @@ snapshots: dependencies: resolve-pkg-maps: 1.0.0 + http-response-object@3.0.2: + dependencies: + '@types/node': 10.17.60 + + https-proxy-agent@5.0.1: + dependencies: + agent-base: 6.0.2 + debug: 4.4.3 + transitivePeerDependencies: + - supports-color + + inherits@2.0.4: {} + is-fullwidth-code-point@3.0.0: {} locate-path@5.0.0: @@ -1290,6 +1421,8 @@ snapshots: matter-js@0.19.0: {} + ms@2.1.3: {} + nanoid@3.3.11: {} p-limit@2.3.0: @@ -1302,6 +1435,8 @@ snapshots: p-try@2.2.0: {} + parse-cache-control@1.0.1: {} + path-exists@4.0.0: {} picocolors@1.1.1: {} @@ -1314,12 +1449,20 @@ snapshots: picocolors: 1.1.1 source-map-js: 1.2.1 + progress@2.0.3: {} + qrcode@1.5.4: dependencies: dijkstrajs: 1.0.3 pngjs: 5.0.0 yargs: 15.4.1 + readable-stream@3.6.2: + dependencies: + inherits: 2.0.4 + string_decoder: 1.3.0 + util-deprecate: 1.0.2 + require-directory@2.1.1: {} require-main-filename@2.0.0: {} @@ -1357,6 +1500,8 @@ snapshots: '@rollup/rollup-win32-x64-msvc': 4.60.1 fsevents: 2.3.3 + safe-buffer@5.2.1: {} + semver@7.7.4: {} set-blocking@2.0.0: {} @@ -1400,6 +1545,10 @@ snapshots: is-fullwidth-code-point: 3.0.0 strip-ansi: 6.0.1 + string_decoder@1.3.0: + dependencies: + safe-buffer: 5.2.1 + strip-ansi@6.0.1: dependencies: ansi-regex: 5.0.1 @@ -1414,12 +1563,16 @@ snapshots: optionalDependencies: fsevents: 2.3.3 + typedarray@0.0.6: {} + typescript@5.9.3: {} undici-types@7.19.2: {} uqr@0.1.3: {} + util-deprecate@1.0.2: {} + vite-plugin-qrcode@0.4.1(vite@5.4.21(@types/node@25.6.0)): dependencies: uqr: 0.1.3 diff --git a/src/config.ts b/src/config.ts index 2f5a9c4..4647804 100644 --- a/src/config.ts +++ b/src/config.ts @@ -26,4 +26,7 @@ export const CONFIG = { // Orientation gravity orientationMaxTilt: 80, // degrees before clamping + + // Debug + logToVite: true, // POST logs to Vite dev server (/__log) — useful for phone debugging } as const diff --git a/src/main.ts b/src/main.ts index 66344c3..1542fff 100644 --- a/src/main.ts +++ b/src/main.ts @@ -4,6 +4,7 @@ import { createRenderer } from './renderer' import { requestMotionPermission, initMotion } from './motion' import { initUI, removeStartOverlay, type ItemKind, type GravityMode } from './ui' import { unlockVibration, setVibrateEnabled, setVibrateItems, setVibrateEdges } from './vibration' +import { preloadSounds } from './sound' import { dbg, dbgError } from './debug' import { checkCompat, showUnsupportedWall } from './compat' import { @@ -57,6 +58,7 @@ async function main(): Promise<void> { let currentKind: ItemKind = 'random' const DEFAULT_RANDOM_POOL: ItemKind[] = ['nut', 'gem', 'balloon', 'metalnut', 'rock', 'stressball', 'coin'] let randomPool: ItemKind[] = [...DEFAULT_RANDOM_POOL] + let refillEnabled = true // ── Restore persisted settings ──────────────────────────────────────── const savedSettings = loadSettings() @@ -87,6 +89,7 @@ async function main(): Promise<void> { ) as ItemKind[] if (valid.length > 0) randomPool = valid } + if (savedSettings.refill === false) refillEnabled = false } // ── Restore nut positions (share URL takes priority over snapshot) ──────── @@ -135,6 +138,11 @@ async function main(): Promise<void> { } // ── UI ─────────────────────────────────────────────────────────────────── + + function save(): void { + saveSettings({ nutRadius: currentNutRadius, gravityMode, vibrateEnabled, vibrateItems: vibrateItemsEnabled, vibrateEdges: vibrateEdgesEnabled, kind: currentKind, randomPool, refill: refillEnabled }) + } + initUI({ initialRadius: currentNutRadius, initialLocked: tapsLocked, @@ -144,42 +152,20 @@ async function main(): Promise<void> { initialVibrateEdges: vibrateEdgesEnabled, initialKind: currentKind, initialRandomPool: randomPool, - onSizeChange: (radius) => { - currentNutRadius = radius - saveSettings({ nutRadius: currentNutRadius, gravityMode, vibrateEnabled, vibrateItems: vibrateItemsEnabled, vibrateEdges: vibrateEdgesEnabled, kind: currentKind, randomPool }) - }, + initialRefill: refillEnabled, + onSizeChange: (radius) => { currentNutRadius = radius; save() }, onClear: () => { physics.clearNuts() clearSnapshot() }, - onLockChange: (locked) => { - tapsLocked = locked - saveSettings({ nutRadius: currentNutRadius, gravityMode, vibrateEnabled, vibrateItems: vibrateItemsEnabled, vibrateEdges: vibrateEdgesEnabled, kind: currentKind, randomPool }) - }, - onKindChange: (kind) => { - currentKind = kind - saveSettings({ nutRadius: currentNutRadius, gravityMode, vibrateEnabled, vibrateItems: vibrateItemsEnabled, vibrateEdges: vibrateEdgesEnabled, kind: currentKind, randomPool }) - }, - onGravityModeChange: (mode) => { - gravityMode = mode - saveSettings({ nutRadius: currentNutRadius, gravityMode, vibrateEnabled, vibrateItems: vibrateItemsEnabled, vibrateEdges: vibrateEdgesEnabled, kind: currentKind, randomPool }) - }, - onVibrateChange: (enabled) => { - vibrateEnabled = enabled - saveSettings({ nutRadius: currentNutRadius, gravityMode, vibrateEnabled, vibrateItems: vibrateItemsEnabled, vibrateEdges: vibrateEdgesEnabled, kind: currentKind, randomPool }) - }, - onVibrateItemsChange: (enabled) => { - vibrateItemsEnabled = enabled - saveSettings({ nutRadius: currentNutRadius, gravityMode, vibrateEnabled, vibrateItems: vibrateItemsEnabled, vibrateEdges: vibrateEdgesEnabled, kind: currentKind, randomPool }) - }, - onVibrateEdgesChange: (enabled) => { - vibrateEdgesEnabled = enabled - saveSettings({ nutRadius: currentNutRadius, gravityMode, vibrateEnabled, vibrateItems: vibrateItemsEnabled, vibrateEdges: vibrateEdgesEnabled, kind: currentKind, randomPool }) - }, - onRandomPoolChange: (pool) => { - randomPool = pool - saveSettings({ nutRadius: currentNutRadius, gravityMode, vibrateEnabled, vibrateItems: vibrateItemsEnabled, vibrateEdges: vibrateEdgesEnabled, kind: currentKind, randomPool }) - }, + onLockChange: (locked) => { tapsLocked = locked; save() }, + onKindChange: (kind) => { currentKind = kind; save() }, + onGravityModeChange: (mode) => { gravityMode = mode; save() }, + onVibrateChange: (enabled) => { vibrateEnabled = enabled; save() }, + onVibrateItemsChange: (enabled) => { vibrateItemsEnabled = enabled; save() }, + onVibrateEdgesChange: (enabled) => { vibrateEdgesEnabled = enabled; save() }, + onRandomPoolChange: (pool) => { randomPool = pool; save() }, + onRefillChange: (enabled) => { refillEnabled = enabled; save() }, onSettingsOpen: () => { settingsPaused = true }, onSettingsClose: () => { settingsPaused = false }, getShareURL: () => buildShareURL(physics.nuts, currentNutRadius), @@ -215,6 +201,30 @@ async function main(): Promise<void> { } } + function spawnRefill(kind: string, radius: number): void { + // Try a few random positions along the top third of the screen + for (let attempt = 0; attempt < 20; attempt++) { + const x = radius + Math.random() * (cssWidth - radius * 2) + const y = radius + Math.random() * (cssHeight * 0.35) + if (!hasSpace(x, y, radius)) continue + if (kind === 'balloon') physics.addBalloon(x, y, radius) + else if (kind === 'rock') physics.addRock(x, y, radius) + else if (kind === 'gem') physics.addGem(x, y, radius) + else if (kind === 'metalnut') physics.addMetalNut(x, y, radius) + else if (kind === 'stressball') physics.addStressBall(x, y, radius) + else if (kind === 'coin') physics.addCoin(x, y, radius) + else physics.addNut(x, y, radius) + return + } + } + + // ── Refill hook ────────────────────────────────────────────────────────── + physics.onRemoved = (kind, radius) => { + if (!refillEnabled) return + // Delay slightly so the removal settles before we add a new body + setTimeout(() => spawnRefill(kind, radius), 600) + } + // ── Pointer: tap/drag to add; tap/drag-through to erase ───────────────── // Mode is decided on pointerdown: if we hit an existing item we erase, @@ -314,6 +324,7 @@ async function main(): Promise<void> { dbgError('requestMotionPermission failed', e) } unlockVibration() // must be called from gesture handler + preloadSounds() // kick off audio fetch after first gesture (autoplay policy) await enterFullscreen() startMotion() removeStartOverlay() @@ -426,7 +437,7 @@ async function main(): Promise<void> { // Also save on tab close / navigation window.addEventListener('pagehide', () => { saveSnapshot(physics.nuts, cssWidth, cssHeight) - saveSettings({ nutRadius: currentNutRadius, gravityMode, vibrateEnabled, vibrateItems: vibrateItemsEnabled, vibrateEdges: vibrateEdgesEnabled, kind: currentKind, randomPool }) + saveSettings({ nutRadius: currentNutRadius, gravityMode, vibrateEnabled, vibrateItems: vibrateItemsEnabled, vibrateEdges: vibrateEdgesEnabled, kind: currentKind, randomPool, refill: refillEnabled }) }) } diff --git a/src/persistence.ts b/src/persistence.ts index 0a47fd1..41e572f 100644 --- a/src/persistence.ts +++ b/src/persistence.ts @@ -1,8 +1,8 @@ -const DATA_VERSION = 2 +const DATA_VERSION = 3 const KEYS = { - settings: 'nuts-settings-v2', - snapshot: 'nuts-snapshot-v2', + settings: 'nuts-settings-v3', + snapshot: 'nuts-snapshot-v3', } as const // ── Settings ─────────────────────────────────────────────────────────────── @@ -16,6 +16,7 @@ export interface PersistedSettings { vibrateEdges?: boolean kind?: string randomPool?: string[] + refill?: boolean } export function loadSettings(): PersistedSettings | null { diff --git a/src/physics.ts b/src/physics.ts index ec62e3a..4459af3 100644 --- a/src/physics.ts +++ b/src/physics.ts @@ -1,6 +1,7 @@ import Matter from 'matter-js' import { CONFIG } from './config' import { vibrate, vibrateWall } from './vibration' +import { playImpact, type ImpactKind } from './sound' import { dbg } from './debug' import { ROCK_TEMPLATES } from './rockTemplates' @@ -25,6 +26,7 @@ export interface NutBody { export interface PhysicsWorld { engine: Matter.Engine nuts: NutBody[] + onRemoved: ((kind: NutBody['kind'], radius: number) => void) | null 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 @@ -236,6 +238,18 @@ export function createPhysicsWorld(width: number, height: number): PhysicsWorld }) // ── Collision haptics ────────────────────────────────────────────────── + + function impactSoundKind(labelA: string, labelB = ''): ImpactKind { + const has = (l: string) => labelA === l || labelB === l + if (has('rock')) return 'rock' + if (has('metalnut')) return 'metalnut' + if (has('coin')) return 'coin' + if (has('gem')) return 'gem' + if (has('stressball')) return 'stressball' + if (has('balloon')) return 'balloon' + return 'nut' + } + Events.on(engine, 'collisionStart', (event: Matter.IEventCollision<Matter.Engine>) => { for (const pair of event.pairs) { const aWall = pair.bodyA.label === 'wall' @@ -308,19 +322,26 @@ export function createPhysicsWorld(width: number, height: number): PhysicsWorld // Body-body collision dbg('nut-nut speed:', speed.toFixed(2)) if (speed > CONFIG.impactThreshold) { + const sndKind = impactSoundKind(pair.bodyA.label, pair.bodyB.label) + const nbA = nutBodyMap.get(pair.bodyA) + const nbB = nutBodyMap.get(pair.bodyB) + const avgRadius = ((nbA?.radius ?? 60) + (nbB?.radius ?? 60)) / 2 if (aRock || bRock) { - // rock collision: earthy thud const t = Math.min((speed - CONFIG.impactThreshold) / 10, 1) vibrate(Math.round(12 + t * 28)) + playImpact(sndKind, 0.15 + t * 0.85, avgRadius) } else if (aGem && bGem) { const t = Math.min((speed - CONFIG.impactThreshold) / 8, 1) vibrate(Math.round(8 + t * 18)) + playImpact(sndKind, 0.15 + t * 0.85, avgRadius) } else if (gemInvolved) { const t = Math.min((speed - CONFIG.impactThreshold) / 10, 1) vibrate(Math.round(10 + t * 22)) + playImpact(sndKind, 0.15 + t * 0.85, avgRadius) } else { const t = Math.min((speed - CONFIG.impactThreshold) / 12, 1) vibrate(Math.round(10 + t * 30)) + playImpact(sndKind, 0.15 + t * 0.85, avgRadius) } } } else if (aNut || bNut) { @@ -328,17 +349,22 @@ export function createPhysicsWorld(width: number, height: number): PhysicsWorld const nutBody = aWall ? pair.bodyB : pair.bodyA if (!ITEM_LABELS.has(nutBody.label as never)) continue const wallSpeed = nutBody.speed + const itemRadius = nutBodyMap.get(nutBody)?.radius ?? 60 dbg('wall hit speed:', wallSpeed.toFixed(2)) if (wallSpeed > CONFIG.impactThreshold) { + const sndKind = impactSoundKind(nutBody.label) if (nutBody.label === 'rock') { const t = Math.min((wallSpeed - CONFIG.impactThreshold) / 10, 1) vibrateWall(Math.round(15 + t * 30)) + playImpact(sndKind, 0.1 + t * 0.7, itemRadius) } else if (nutBody.label === 'gem' || nutBody.label === 'metalnut') { const t = Math.min((wallSpeed - CONFIG.impactThreshold) / 8, 1) vibrateWall(Math.round(15 + t * 35)) + playImpact(sndKind, 0.1 + t * 0.7, itemRadius) } else { const t = Math.min((wallSpeed - CONFIG.impactThreshold) / 12, 1) vibrateWall(Math.round(30 + t * (CONFIG.maxVibrationMs - 30))) + playImpact(sndKind, 0.1 + t * 0.7, itemRadius) } } } @@ -419,6 +445,8 @@ export function createPhysicsWorld(width: number, height: number): PhysicsWorld Composite.remove(engine.world, nut.body) nuts.splice(idx, 1) vibrate(70) // satisfying pop + playImpact('balloon_pop', 0.9, nut.radius) + world.onRemoved?.(nut.kind, nut.radius) } } @@ -447,7 +475,11 @@ export function createPhysicsWorld(width: number, height: number): PhysicsWorld const fragRadius = (nut.radius / Math.sqrt(count)) * 0.95 for (let i = 0; i < count; i++) { - if (fragRadius < CONFIG.nutMinRadius * 0.5) continue + // Fragment too small — fully disintegrated + if (fragRadius < CONFIG.nutMinRadius * 0.5) { + world.onRemoved?.(nut.kind, nut.radius) + continue + } const spreadAngle = (i / count) * Math.PI * 2 + Math.random() * 0.5 const dist = nut.radius * 0.35 const fx = pos.x + Math.cos(spreadAngle) * dist @@ -518,9 +550,10 @@ export function createPhysicsWorld(width: number, height: number): PhysicsWorld Engine.clear(engine) } - return { + const world: PhysicsWorld = { engine, get nuts() { return nuts }, + onRemoved: null, addNut, addGem, addBalloon, @@ -536,4 +569,5 @@ export function createPhysicsWorld(width: number, height: number): PhysicsWorld step, destroy, } + return world } diff --git a/src/sound.ts b/src/sound.ts new file mode 100644 index 0000000..0e863a0 --- /dev/null +++ b/src/sound.ts @@ -0,0 +1,113 @@ +// Sound effects — Kenney Impact Sounds (CC0 1.0) +// Uses Web Audio API with pre-decoded AudioBuffers so multiple sounds can +// play simultaneously (polyphonic). HTMLAudio would serialize on mobile. +import { CONFIG } from './config' + +export type ImpactKind = + | 'nut' + | 'gem' + | 'metalnut' + | 'rock' + | 'stressball' + | 'balloon' + | 'balloon_pop' + | 'coin' + +const BASE: Record<ImpactKind, string> = { + nut: '/sounds/impactWood_medium_', + gem: '/sounds/impactGlass_medium_', + metalnut: '/sounds/impactMetal_heavy_', + rock: '/sounds/impactMining_', + stressball: '/sounds/impactSoft_heavy_', + balloon: '/sounds/impactSoft_medium_', + balloon_pop: '/sounds/impactPunch_heavy_', + coin: '/sounds/impactTin_medium_', +} + +const VARIANTS = 3 +const COOLDOWN_MS = 60 // min ms between sounds of the same kind + +let ctx: AudioContext | null = null +// url → decoded buffer (null while fetch is in flight) +const buffers = new Map<string, AudioBuffer | null>() + +function getCtx(): AudioContext { + if (!ctx) ctx = new AudioContext() + return ctx +} + +function allUrls(): string[] { + return Object.values(BASE).flatMap(base => + Array.from({ length: VARIANTS }, (_, v) => + `${base}${String(v).padStart(3, '0')}.mp3` + ) + ) +} + +async function loadBuffer(url: string): Promise<void> { + if (buffers.has(url)) return + buffers.set(url, null) // mark in-flight + try { + const res = await fetch(url) + const ab = await res.arrayBuffer() + const decoded = await getCtx().decodeAudioData(ab) + buffers.set(url, decoded) + } catch (e) { + remoteLog(`load failed ${url}: ${e}`) + buffers.delete(url) + } +} + +/** Preload all sounds. Must be called from a user-gesture handler (unlocks AudioContext). */ +export function preloadSounds(): void { + if (ctx?.state === 'suspended') ctx.resume() + for (const url of allUrls()) loadBuffer(url) + remoteLog('preloadSounds called') +} + +const lastPlay = new Map<ImpactKind, number>() + +/** + * Play an impact sound for the given kind, scaled by `intensity` (0–1) and + * `radius` (px). Larger radius → louder and deeper; smaller → quieter and higher. + * Silently ignored if the same kind was played too recently. + */ +export function playImpact(kind: ImpactKind, intensity: number, radius = 60): void { + const now = performance.now() + if ((now - (lastPlay.get(kind) ?? 0)) < COOLDOWN_MS) return + lastPlay.set(kind, now) + + const variant = Math.floor(Math.random() * VARIANTS) + const url = `${BASE[kind]}${String(variant).padStart(3, '0')}.mp3` + const buf = buffers.get(url) + remoteLog(`${kind} r=${Math.round(radius)} vol=${intensity.toFixed(2)} buf=${buf ? 'ready' : 'missing'} → ${url}`) + if (!buf) return + + // Pitch: map radius to playbackRate on a log scale. + // Reference 60px → rate 1.0. Half radius → ~1.5× (higher); double → ~0.67× (deeper). + const REF_RADIUS = 60 + const playbackRate = Math.pow(REF_RADIUS / Math.max(radius, 4), 0.42) + + // Volume: larger items are louder (sqrt keeps it from getting too extreme) + const sizeGain = Math.pow(radius / REF_RADIUS, 0.4) + const volume = Math.min(Math.max(intensity * sizeGain, 0), 1) + + const ac = getCtx() + const gain = ac.createGain() + gain.gain.value = volume + gain.connect(ac.destination) + const src = ac.createBufferSource() + src.buffer = buf + src.playbackRate.value = playbackRate + src.connect(gain) + src.start() +} + +function remoteLog(msg: string): void { + if (!CONFIG.logToVite) return + fetch('/__log', { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ level: 'info', msg: `[sound] ${msg}` }), + }).catch(() => { /* dev server not available */ }) +} diff --git a/src/sounds/impactGlass_medium_000.ogg b/src/sounds/impactGlass_medium_000.ogg new file mode 100644 index 0000000..e056cdb Binary files /dev/null and b/src/sounds/impactGlass_medium_000.ogg differ diff --git a/src/sounds/impactGlass_medium_001.ogg b/src/sounds/impactGlass_medium_001.ogg new file mode 100644 index 0000000..16ebe09 Binary files /dev/null and b/src/sounds/impactGlass_medium_001.ogg differ diff --git a/src/sounds/impactGlass_medium_002.ogg b/src/sounds/impactGlass_medium_002.ogg new file mode 100644 index 0000000..8f5efe5 Binary files /dev/null and b/src/sounds/impactGlass_medium_002.ogg differ diff --git a/src/sounds/impactMetal_heavy_000.ogg b/src/sounds/impactMetal_heavy_000.ogg new file mode 100644 index 0000000..d4432f4 Binary files /dev/null and b/src/sounds/impactMetal_heavy_000.ogg differ diff --git a/src/sounds/impactMetal_heavy_001.ogg b/src/sounds/impactMetal_heavy_001.ogg new file mode 100644 index 0000000..e510d3e Binary files /dev/null and b/src/sounds/impactMetal_heavy_001.ogg differ diff --git a/src/sounds/impactMetal_heavy_002.ogg b/src/sounds/impactMetal_heavy_002.ogg new file mode 100644 index 0000000..c757121 Binary files /dev/null and b/src/sounds/impactMetal_heavy_002.ogg differ diff --git a/src/sounds/impactMining_000.ogg b/src/sounds/impactMining_000.ogg new file mode 100644 index 0000000..3346d1a Binary files /dev/null and b/src/sounds/impactMining_000.ogg differ diff --git a/src/sounds/impactMining_001.ogg b/src/sounds/impactMining_001.ogg new file mode 100644 index 0000000..b5475fa Binary files /dev/null and b/src/sounds/impactMining_001.ogg differ diff --git a/src/sounds/impactMining_002.ogg b/src/sounds/impactMining_002.ogg new file mode 100644 index 0000000..5bd4f35 Binary files /dev/null and b/src/sounds/impactMining_002.ogg differ diff --git a/src/sounds/impactPunch_heavy_000.ogg b/src/sounds/impactPunch_heavy_000.ogg new file mode 100644 index 0000000..55e70b5 Binary files /dev/null and b/src/sounds/impactPunch_heavy_000.ogg differ diff --git a/src/sounds/impactPunch_heavy_001.ogg b/src/sounds/impactPunch_heavy_001.ogg new file mode 100644 index 0000000..6655951 Binary files /dev/null and b/src/sounds/impactPunch_heavy_001.ogg differ diff --git a/src/sounds/impactPunch_heavy_002.ogg b/src/sounds/impactPunch_heavy_002.ogg new file mode 100644 index 0000000..8573e89 Binary files /dev/null and b/src/sounds/impactPunch_heavy_002.ogg differ diff --git a/src/sounds/impactSoft_heavy_000.ogg b/src/sounds/impactSoft_heavy_000.ogg new file mode 100644 index 0000000..1c321ef Binary files /dev/null and b/src/sounds/impactSoft_heavy_000.ogg differ diff --git a/src/sounds/impactSoft_heavy_001.ogg b/src/sounds/impactSoft_heavy_001.ogg new file mode 100644 index 0000000..d7fbb0f Binary files /dev/null and b/src/sounds/impactSoft_heavy_001.ogg differ diff --git a/src/sounds/impactSoft_heavy_002.ogg b/src/sounds/impactSoft_heavy_002.ogg new file mode 100644 index 0000000..4748c69 Binary files /dev/null and b/src/sounds/impactSoft_heavy_002.ogg differ diff --git a/src/sounds/impactSoft_medium_000.ogg b/src/sounds/impactSoft_medium_000.ogg new file mode 100644 index 0000000..bf1027b Binary files /dev/null and b/src/sounds/impactSoft_medium_000.ogg differ diff --git a/src/sounds/impactSoft_medium_001.ogg b/src/sounds/impactSoft_medium_001.ogg new file mode 100644 index 0000000..a8c6434 Binary files /dev/null and b/src/sounds/impactSoft_medium_001.ogg differ diff --git a/src/sounds/impactSoft_medium_002.ogg b/src/sounds/impactSoft_medium_002.ogg new file mode 100644 index 0000000..5409c00 Binary files /dev/null and b/src/sounds/impactSoft_medium_002.ogg differ diff --git a/src/sounds/impactTin_medium_000.ogg b/src/sounds/impactTin_medium_000.ogg new file mode 100644 index 0000000..1f3acf7 Binary files /dev/null and b/src/sounds/impactTin_medium_000.ogg differ diff --git a/src/sounds/impactTin_medium_001.ogg b/src/sounds/impactTin_medium_001.ogg new file mode 100644 index 0000000..c45507c Binary files /dev/null and b/src/sounds/impactTin_medium_001.ogg differ diff --git a/src/sounds/impactTin_medium_002.ogg b/src/sounds/impactTin_medium_002.ogg new file mode 100644 index 0000000..18d83d3 Binary files /dev/null and b/src/sounds/impactTin_medium_002.ogg differ diff --git a/src/sounds/impactWood_medium_000.ogg b/src/sounds/impactWood_medium_000.ogg new file mode 100644 index 0000000..97735ff Binary files /dev/null and b/src/sounds/impactWood_medium_000.ogg differ diff --git a/src/sounds/impactWood_medium_001.ogg b/src/sounds/impactWood_medium_001.ogg new file mode 100644 index 0000000..a314921 Binary files /dev/null and b/src/sounds/impactWood_medium_001.ogg differ diff --git a/src/sounds/impactWood_medium_002.ogg b/src/sounds/impactWood_medium_002.ogg new file mode 100644 index 0000000..d5cf1d0 Binary files /dev/null and b/src/sounds/impactWood_medium_002.ogg differ diff --git a/src/ui.ts b/src/ui.ts index 2b4d3e8..2210b39 100644 --- a/src/ui.ts +++ b/src/ui.ts @@ -28,6 +28,7 @@ export interface UICallbacks { initialVibrateEdges: boolean initialKind: ItemKind initialRandomPool: ItemKind[] + initialRefill: boolean onSizeChange: (radius: number) => void onClear: () => void onLockChange: (locked: boolean) => void @@ -37,6 +38,7 @@ export interface UICallbacks { onVibrateItemsChange: (enabled: boolean) => void onVibrateEdgesChange: (enabled: boolean) => void onRandomPoolChange: (pool: ItemKind[]) => void + onRefillChange: (enabled: boolean) => void onSettingsOpen: () => void onSettingsClose: () => void getShareURL: () => string @@ -255,9 +257,20 @@ export function initUI(callbacks: UICallbacks): void { } poolSection.appendChild(poolGrid) + // ── Refill toggle ──────────────────────────────────────────────────────── + const refillBtn = document.createElement('button') + refillBtn.className = 'action-btn' + (callbacks.initialRefill ? ' active' : '') + refillBtn.textContent = callbacks.initialRefill ? 'Refill: On' : 'Refill: Off' + refillBtn.addEventListener('click', () => { + const on = refillBtn.classList.toggle('active') + refillBtn.textContent = on ? 'Refill: On' : 'Refill: Off' + callbacks.onRefillChange(on) + }) + // Insert before the close button const closeBtn2 = settingsPanel.querySelector('#close-settings') as HTMLButtonElement closeBtn2.parentElement!.insertBefore(poolSection, closeBtn2) + closeBtn2.parentElement!.insertBefore(refillBtn, closeBtn2) copyLinkBtn.addEventListener('click', () => { const url = callbacks.getShareURL() diff --git a/vite.config.ts b/vite.config.ts index 2484030..fe40fe6 100644 --- a/vite.config.ts +++ b/vite.config.ts @@ -2,6 +2,85 @@ 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' +import { execFileSync } from 'node:child_process' +import { readdirSync, readFileSync, existsSync, mkdirSync } from 'node:fs' +import { join, dirname } from 'node:path' +import { createRequire } from 'node:module' +import { tmpdir } from 'node:os' + +const require = createRequire(import.meta.url) +// eslint-disable-next-line @typescript-eslint/no-require-imports +const ffmpeg: string = require('ffmpeg-static') + +const SOUNDS_SRC = new URL('./src/sounds', import.meta.url).pathname + +// The specific files sound.ts references (category prefix only, 000–002 appended) +const SOUND_CATEGORIES = [ + 'impactWood_medium', + 'impactGlass_medium', + 'impactMetal_heavy', + 'impactMining', + 'impactSoft_heavy', + 'impactSoft_medium', + 'impactPunch_heavy', + 'impactTin_medium', +] + +function oggToMp3(src: string, dest: string): void { + mkdirSync(dirname(dest), { recursive: true }) + execFileSync(ffmpeg, ['-i', src, '-q:a', '4', '-y', dest], { stdio: 'pipe' }) +} + +function soundFiles(): string[] { + return SOUND_CATEGORIES.flatMap(cat => + [0, 1, 2].map(v => `${cat}_${String(v).padStart(3, '0')}`) + ) +} + +function sounds(): Plugin { + // Temp dir for dev-server cache + const cache = join(tmpdir(), 'nuts-sounds') + mkdirSync(cache, { recursive: true }) + + return { + name: 'sounds', + + // Build: convert + emit each MP3 as an asset + async generateBundle() { + for (const name of soundFiles()) { + const src = join(SOUNDS_SRC, `${name}.ogg`) + if (!existsSync(src)) continue + const tmp = join(cache, `${name}.mp3`) + if (!existsSync(tmp)) oggToMp3(src, tmp) + this.emitFile({ + type: 'asset', + fileName: `sounds/${name}.mp3`, + source: readFileSync(tmp), + }) + } + }, + + // Dev: convert on first request, then serve from cache + configureServer(server) { + server.middlewares.use('/sounds/', (req: IncomingMessage, res: ServerResponse) => { + const mp3Name = (req.url ?? '').replace(/^\//, '') + if (!mp3Name.endsWith('.mp3')) { res.writeHead(404).end(); return } + const oggName = mp3Name.replace(/\.mp3$/, '.ogg') + const src = join(SOUNDS_SRC, oggName) + const dest = join(cache, mp3Name) + if (!existsSync(src)) { res.writeHead(404).end(); return } + try { + if (!existsSync(dest)) oggToMp3(src, dest) + const data = readFileSync(dest) + res.writeHead(200, { 'Content-Type': 'audio/mpeg', 'Content-Length': data.length }) + res.end(data) + } catch (e) { + res.writeHead(500).end(String(e)) + } + }) + }, + } +} function remoteLog(): Plugin { return { @@ -43,5 +122,5 @@ export default defineConfig({ server: { host: true, // listen on 0.0.0.0 so the phone can reach it }, - plugins: [remoteLog(), basicSsl(), qrcode()], + plugins: [sounds(), remoteLog(), basicSsl(), qrcode()], })