feat: title menu revamp, vendored deps, ghost relay fix
Browse files- Title menu: split layout with live 3D duck (vitrine-style materialize
shader), variant-tinted accents, unified boot spinner (assets + font +
game core), duck hidden on narrow touch screens
- Audio: new WebAudio sound system (sweeps synced to the tron shader,
per-prop pitch scaling) - shipped disabled via SOUND_DISABLED until the
sound design is finished; HUD mute toggle commented out accordingly
- Supply chain: MuJoCo, onnxruntime-web and trystero vendored from npm
instead of jsDelivr/esm.run CDNs (wasm assets bundled locally)
- Ghosts: explicit healthy Nostr relay list; the appId-seeded default
subset had rotted (dead/blocking/rate-limiting relays), silently
breaking peer discovery in prod
Co-authored-by: Cursor <cursoragent@cursor.com>
- README.md +2 -0
- app/package-lock.json +184 -3
- app/package.json +3 -0
- app/public/assets/sfx/click_a.wav +3 -0
- app/public/assets/sfx/click_b.wav +3 -0
- app/public/assets/sfx/step_a.wav +3 -0
- app/public/assets/sfx/step_b.wav +3 -0
- app/public/assets/sfx/step_c.wav +3 -0
- app/public/assets/sfx/step_d.wav +3 -0
- app/public/assets/sfx/step_e.wav +3 -0
- app/public/assets/sfx/thump_a.wav +3 -0
- app/public/assets/sfx/thump_b.wav +3 -0
- app/public/assets/sfx/thump_c.wav +3 -0
- app/src/App.jsx +8 -23
- app/src/game/arena.js +33 -0
- app/src/game/audio.js +581 -0
- app/src/game/ceremony.js +31 -0
- app/src/game/game.js +193 -26
- app/src/game/ghosts.js +19 -3
- app/src/game/vendor/zzfx.js +60 -0
- app/src/main.jsx +10 -2
- app/src/ui/BiosOverlay.jsx +7 -0
- app/src/ui/Hud.jsx +78 -6
- app/src/ui/MenuDuck.jsx +350 -0
- app/src/ui/Preboot.jsx +0 -54
- app/src/ui/Soundboard.jsx +368 -0
- app/src/ui/TitleMenu.jsx +481 -166
|
@@ -72,6 +72,8 @@ Plug in a controller and the same mapping as the real robot runtime applies:
|
|
| 72 |
- D-pad down: sit / stand toggle (legs only)
|
| 73 |
- D-pad up: hold ~1 s to switch legs <-> rollers (the real robot uses a 3 s hold)
|
| 74 |
- Right trigger: mouth (analog) + quack
|
|
|
|
|
|
|
| 75 |
|
| 76 |
## Multiplayer ghosts
|
| 77 |
|
|
|
|
| 72 |
- D-pad down: sit / stand toggle (legs only)
|
| 73 |
- D-pad up: hold ~1 s to switch legs <-> rollers (the real robot uses a 3 s hold)
|
| 74 |
- Right trigger: mouth (analog) + quack
|
| 75 |
+
- A: enter / resume the game while the title or pause overlay is up
|
| 76 |
+
- Menu/Start (or Select): exit back to the title overlay while in-game
|
| 77 |
|
| 78 |
## Multiplayer ghosts
|
| 79 |
|
|
@@ -1,21 +1,23 @@
|
|
| 1 |
{
|
| 2 |
-
"name": "
|
| 3 |
"version": "1.0.0",
|
| 4 |
"lockfileVersion": 3,
|
| 5 |
"requires": true,
|
| 6 |
"packages": {
|
| 7 |
"": {
|
| 8 |
-
"name": "
|
| 9 |
"version": "1.0.0",
|
| 10 |
-
"license": "ISC",
|
| 11 |
"dependencies": {
|
| 12 |
"@emotion/react": "^11.14.0",
|
| 13 |
"@emotion/styled": "^11.14.1",
|
| 14 |
"@mui/material": "^9.3.1",
|
|
|
|
| 15 |
"@react-three/fiber": "^9.7.0",
|
|
|
|
| 16 |
"react": "^19.2.8",
|
| 17 |
"react-dom": "^19.2.8",
|
| 18 |
"three": "^0.164.1",
|
|
|
|
| 19 |
"zustand": "^5.0.15"
|
| 20 |
},
|
| 21 |
"devDependencies": {
|
|
@@ -552,6 +554,21 @@
|
|
| 552 |
}
|
| 553 |
}
|
| 554 |
},
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 555 |
"node_modules/@oxc-project/types": {
|
| 556 |
"version": "0.146.0",
|
| 557 |
"resolved": "https://registry.npmjs.org/@oxc-project/types/-/types-0.146.0.tgz",
|
|
@@ -572,6 +589,63 @@
|
|
| 572 |
"url": "https://opencollective.com/popperjs"
|
| 573 |
}
|
| 574 |
},
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 575 |
"node_modules/@react-three/fiber": {
|
| 576 |
"version": "9.7.0",
|
| 577 |
"resolved": "https://registry.npmjs.org/@react-three/fiber/-/fiber-9.7.0.tgz",
|
|
@@ -882,6 +956,31 @@
|
|
| 882 |
"dev": true,
|
| 883 |
"license": "MIT"
|
| 884 |
},
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 885 |
"node_modules/@types/parse-json": {
|
| 886 |
"version": "4.0.2",
|
| 887 |
"resolved": "https://registry.npmjs.org/@types/parse-json/-/parse-json-4.0.2.tgz",
|
|
@@ -1163,6 +1262,12 @@
|
|
| 1163 |
"integrity": "sha512-NKfW6bec6GfKc0SGx1e07QZY9PE99u0Bft/0rzSD5k3sO/vwkVUpDUKVm5Gpp5Ue3YfShPFTX2070tDs5kB9Ng==",
|
| 1164 |
"license": "MIT"
|
| 1165 |
},
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1166 |
"node_modules/fsevents": {
|
| 1167 |
"version": "2.3.3",
|
| 1168 |
"resolved": "https://registry.npmjs.org/fsevents/-/fsevents-2.3.3.tgz",
|
|
@@ -1187,6 +1292,12 @@
|
|
| 1187 |
"url": "https://github.com/sponsors/ljharb"
|
| 1188 |
}
|
| 1189 |
},
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1190 |
"node_modules/hasown": {
|
| 1191 |
"version": "2.0.4",
|
| 1192 |
"resolved": "https://registry.npmjs.org/hasown/-/hasown-2.0.4.tgz",
|
|
@@ -1574,6 +1685,12 @@
|
|
| 1574 |
"integrity": "sha512-7ylylesZQ/PV29jhEDl3Ufjo6ZX7gCqJr5F7PKrqc93v7fzSymt1BpwEU8nAUXs8qzzvqhbjhK5QZg6Mt/HkBg==",
|
| 1575 |
"license": "MIT"
|
| 1576 |
},
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1577 |
"node_modules/loose-envify": {
|
| 1578 |
"version": "1.4.0",
|
| 1579 |
"resolved": "https://registry.npmjs.org/loose-envify/-/loose-envify-1.4.0.tgz",
|
|
@@ -1620,6 +1737,26 @@
|
|
| 1620 |
"node": ">=0.10.0"
|
| 1621 |
}
|
| 1622 |
},
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1623 |
"node_modules/parent-module": {
|
| 1624 |
"version": "1.0.1",
|
| 1625 |
"resolved": "https://registry.npmjs.org/parent-module/-/parent-module-1.0.1.tgz",
|
|
@@ -1685,6 +1822,12 @@
|
|
| 1685 |
"url": "https://github.com/sponsors/jonschlinkert"
|
| 1686 |
}
|
| 1687 |
},
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1688 |
"node_modules/postcss": {
|
| 1689 |
"version": "8.5.26",
|
| 1690 |
"resolved": "https://registry.npmjs.org/postcss/-/postcss-8.5.26.tgz",
|
|
@@ -1731,6 +1874,29 @@
|
|
| 1731 |
"integrity": "sha512-24e6ynE2H+OKt4kqsOvNd8kBpV65zoxbA4BVsEOB3ARVWQki/DHzaUoC5KuON/BiccDaCCTZBuOcfZs70kR8bQ==",
|
| 1732 |
"license": "MIT"
|
| 1733 |
},
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1734 |
"node_modules/react": {
|
| 1735 |
"version": "19.2.8",
|
| 1736 |
"resolved": "https://registry.npmjs.org/react/-/react-19.2.8.tgz",
|
|
@@ -1931,6 +2097,21 @@
|
|
| 1931 |
"url": "https://github.com/sponsors/SuperchupuDev"
|
| 1932 |
}
|
| 1933 |
},
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1934 |
"node_modules/use-sync-external-store": {
|
| 1935 |
"version": "1.6.0",
|
| 1936 |
"resolved": "https://registry.npmjs.org/use-sync-external-store/-/use-sync-external-store-1.6.0.tgz",
|
|
|
|
| 1 |
{
|
| 2 |
+
"name": "microduck-sandbox",
|
| 3 |
"version": "1.0.0",
|
| 4 |
"lockfileVersion": 3,
|
| 5 |
"requires": true,
|
| 6 |
"packages": {
|
| 7 |
"": {
|
| 8 |
+
"name": "microduck-sandbox",
|
| 9 |
"version": "1.0.0",
|
|
|
|
| 10 |
"dependencies": {
|
| 11 |
"@emotion/react": "^11.14.0",
|
| 12 |
"@emotion/styled": "^11.14.1",
|
| 13 |
"@mui/material": "^9.3.1",
|
| 14 |
+
"@mujoco/mujoco": "^3.11.0",
|
| 15 |
"@react-three/fiber": "^9.7.0",
|
| 16 |
+
"onnxruntime-web": "^1.27.0",
|
| 17 |
"react": "^19.2.8",
|
| 18 |
"react-dom": "^19.2.8",
|
| 19 |
"three": "^0.164.1",
|
| 20 |
+
"trystero": "^0.25.3",
|
| 21 |
"zustand": "^5.0.15"
|
| 22 |
},
|
| 23 |
"devDependencies": {
|
|
|
|
| 554 |
}
|
| 555 |
}
|
| 556 |
},
|
| 557 |
+
"node_modules/@mujoco/mujoco": {
|
| 558 |
+
"version": "3.11.0",
|
| 559 |
+
"resolved": "https://registry.npmjs.org/@mujoco/mujoco/-/mujoco-3.11.0.tgz",
|
| 560 |
+
"integrity": "sha512-ZtBhIX8It5EZUcRfxalYTilSG52JYoItEOBYOXdkviwZWaZ8XBuuSuNBwnXqhV1VkXpWbpPc27vGdQsTiSVX3Q==",
|
| 561 |
+
"license": "Apache-2.0"
|
| 562 |
+
},
|
| 563 |
+
"node_modules/@noble/secp256k1": {
|
| 564 |
+
"version": "3.2.0",
|
| 565 |
+
"resolved": "https://registry.npmjs.org/@noble/secp256k1/-/secp256k1-3.2.0.tgz",
|
| 566 |
+
"integrity": "sha512-Z3ZAWOTxJ0EuTuZTi7Y69iK7GgrLHh8sgm45lVDhg/b3Nk1TpAiKhick2KkZisHuupeepSkyIydN/J459SdX1w==",
|
| 567 |
+
"license": "MIT",
|
| 568 |
+
"funding": {
|
| 569 |
+
"url": "https://paulmillr.com/funding/"
|
| 570 |
+
}
|
| 571 |
+
},
|
| 572 |
"node_modules/@oxc-project/types": {
|
| 573 |
"version": "0.146.0",
|
| 574 |
"resolved": "https://registry.npmjs.org/@oxc-project/types/-/types-0.146.0.tgz",
|
|
|
|
| 589 |
"url": "https://opencollective.com/popperjs"
|
| 590 |
}
|
| 591 |
},
|
| 592 |
+
"node_modules/@protobufjs/aspromise": {
|
| 593 |
+
"version": "1.1.2",
|
| 594 |
+
"resolved": "https://registry.npmjs.org/@protobufjs/aspromise/-/aspromise-1.1.2.tgz",
|
| 595 |
+
"integrity": "sha512-j+gKExEuLmKwvz3OgROXtrJ2UG2x8Ch2YZUxahh+s1F2HZ+wAceUNLkvy6zKCPVRkU++ZWQrdxsUeQXmcg4uoQ==",
|
| 596 |
+
"license": "BSD-3-Clause"
|
| 597 |
+
},
|
| 598 |
+
"node_modules/@protobufjs/base64": {
|
| 599 |
+
"version": "1.1.2",
|
| 600 |
+
"resolved": "https://registry.npmjs.org/@protobufjs/base64/-/base64-1.1.2.tgz",
|
| 601 |
+
"integrity": "sha512-AZkcAA5vnN/v4PDqKyMR5lx7hZttPDgClv83E//FMNhR2TMcLUhfRUBHCmSl0oi9zMgDDqRUJkSxO3wm85+XLg==",
|
| 602 |
+
"license": "BSD-3-Clause"
|
| 603 |
+
},
|
| 604 |
+
"node_modules/@protobufjs/codegen": {
|
| 605 |
+
"version": "2.0.5",
|
| 606 |
+
"resolved": "https://registry.npmjs.org/@protobufjs/codegen/-/codegen-2.0.5.tgz",
|
| 607 |
+
"integrity": "sha512-zgXFLzW3Ap33e6d0Wlj4MGIm6Ce8O89n/apUaGNB/jx+hw+ruWEp7EwGUshdLKVRCxZW12fp9r40E1mQrf/34g==",
|
| 608 |
+
"license": "BSD-3-Clause"
|
| 609 |
+
},
|
| 610 |
+
"node_modules/@protobufjs/eventemitter": {
|
| 611 |
+
"version": "1.1.1",
|
| 612 |
+
"resolved": "https://registry.npmjs.org/@protobufjs/eventemitter/-/eventemitter-1.1.1.tgz",
|
| 613 |
+
"integrity": "sha512-vW1GmwMZNnL+gMRaovlh9yZX74kc+TTU3FObkkurpMaRtBfLP3ldjS9KQWlwZgraRE0+dheEEoAxdzcJQ8eXZg==",
|
| 614 |
+
"license": "BSD-3-Clause"
|
| 615 |
+
},
|
| 616 |
+
"node_modules/@protobufjs/fetch": {
|
| 617 |
+
"version": "1.1.1",
|
| 618 |
+
"resolved": "https://registry.npmjs.org/@protobufjs/fetch/-/fetch-1.1.1.tgz",
|
| 619 |
+
"integrity": "sha512-GpptLrs57adMSuHi3VNj0mAF8dwh36LMaYF6XyJ6JMWlVsc+t42tm1HSEDmOs3A8fC9yyeisgLhsTVQokOZ0zw==",
|
| 620 |
+
"license": "BSD-3-Clause",
|
| 621 |
+
"dependencies": {
|
| 622 |
+
"@protobufjs/aspromise": "^1.1.1"
|
| 623 |
+
}
|
| 624 |
+
},
|
| 625 |
+
"node_modules/@protobufjs/float": {
|
| 626 |
+
"version": "1.0.2",
|
| 627 |
+
"resolved": "https://registry.npmjs.org/@protobufjs/float/-/float-1.0.2.tgz",
|
| 628 |
+
"integrity": "sha512-Ddb+kVXlXst9d+R9PfTIxh1EdNkgoRe5tOX6t01f1lYWOvJnSPDBlG241QLzcyPdoNTsblLUdujGSE4RzrTZGQ==",
|
| 629 |
+
"license": "BSD-3-Clause"
|
| 630 |
+
},
|
| 631 |
+
"node_modules/@protobufjs/path": {
|
| 632 |
+
"version": "1.1.2",
|
| 633 |
+
"resolved": "https://registry.npmjs.org/@protobufjs/path/-/path-1.1.2.tgz",
|
| 634 |
+
"integrity": "sha512-6JOcJ5Tm08dOHAbdR3GrvP+yUUfkjG5ePsHYczMFLq3ZmMkAD98cDgcT2iA1lJ9NVwFd4tH/iSSoe44YWkltEA==",
|
| 635 |
+
"license": "BSD-3-Clause"
|
| 636 |
+
},
|
| 637 |
+
"node_modules/@protobufjs/pool": {
|
| 638 |
+
"version": "1.1.0",
|
| 639 |
+
"resolved": "https://registry.npmjs.org/@protobufjs/pool/-/pool-1.1.0.tgz",
|
| 640 |
+
"integrity": "sha512-0kELaGSIDBKvcgS4zkjz1PeddatrjYcmMWOlAuAPwAeccUrPHdUqo/J6LiymHHEiJT5NrF1UVwxY14f+fy4WQw==",
|
| 641 |
+
"license": "BSD-3-Clause"
|
| 642 |
+
},
|
| 643 |
+
"node_modules/@protobufjs/utf8": {
|
| 644 |
+
"version": "1.1.2",
|
| 645 |
+
"resolved": "https://registry.npmjs.org/@protobufjs/utf8/-/utf8-1.1.2.tgz",
|
| 646 |
+
"integrity": "sha512-b1UQwcEZ4yCnMCD8DAL1VlbvBJE9/IX4FTIp7BG1xYpf29SLazLSrqUkj4w7Y5y7cCVP6E5tcqqcI0xemPkHug==",
|
| 647 |
+
"license": "BSD-3-Clause"
|
| 648 |
+
},
|
| 649 |
"node_modules/@react-three/fiber": {
|
| 650 |
"version": "9.7.0",
|
| 651 |
"resolved": "https://registry.npmjs.org/@react-three/fiber/-/fiber-9.7.0.tgz",
|
|
|
|
| 956 |
"dev": true,
|
| 957 |
"license": "MIT"
|
| 958 |
},
|
| 959 |
+
"node_modules/@trystero-p2p/core": {
|
| 960 |
+
"version": "0.25.3",
|
| 961 |
+
"resolved": "https://registry.npmjs.org/@trystero-p2p/core/-/core-0.25.3.tgz",
|
| 962 |
+
"integrity": "sha512-lQKNq/ha+vF6kQZrpaXJGzlzxLF/Fhizoy5dwJUYQlkqRrbPup/Jov2EpPX/CPr2X+f79HxVzonkeni3MxmCuQ==",
|
| 963 |
+
"license": "MIT"
|
| 964 |
+
},
|
| 965 |
+
"node_modules/@trystero-p2p/nostr": {
|
| 966 |
+
"version": "0.25.3",
|
| 967 |
+
"resolved": "https://registry.npmjs.org/@trystero-p2p/nostr/-/nostr-0.25.3.tgz",
|
| 968 |
+
"integrity": "sha512-nZV9Fl/GXuhIkJSQ+wIkdMoRLO1oSTUL/+vZorukaojeAdOpT/61e8b9Vzbt8L1ktMTaGPEJHw2BG/B5BCwBuw==",
|
| 969 |
+
"license": "MIT",
|
| 970 |
+
"dependencies": {
|
| 971 |
+
"@noble/secp256k1": "^3.1.0",
|
| 972 |
+
"@trystero-p2p/core": "0.25.3"
|
| 973 |
+
}
|
| 974 |
+
},
|
| 975 |
+
"node_modules/@types/node": {
|
| 976 |
+
"version": "26.4.1",
|
| 977 |
+
"resolved": "https://registry.npmjs.org/@types/node/-/node-26.4.1.tgz",
|
| 978 |
+
"integrity": "sha512-k97ENvZWtvA6yqz5/FS6a7duDgOPEeOQOc2iKS/nY6mX6qJUKtLnWzQS+Xj6tXweyj6ZcTAK2Qecetnvi9nCLA==",
|
| 979 |
+
"license": "MIT",
|
| 980 |
+
"dependencies": {
|
| 981 |
+
"undici-types": "~8.3.0"
|
| 982 |
+
}
|
| 983 |
+
},
|
| 984 |
"node_modules/@types/parse-json": {
|
| 985 |
"version": "4.0.2",
|
| 986 |
"resolved": "https://registry.npmjs.org/@types/parse-json/-/parse-json-4.0.2.tgz",
|
|
|
|
| 1262 |
"integrity": "sha512-NKfW6bec6GfKc0SGx1e07QZY9PE99u0Bft/0rzSD5k3sO/vwkVUpDUKVm5Gpp5Ue3YfShPFTX2070tDs5kB9Ng==",
|
| 1263 |
"license": "MIT"
|
| 1264 |
},
|
| 1265 |
+
"node_modules/flatbuffers": {
|
| 1266 |
+
"version": "25.9.23",
|
| 1267 |
+
"resolved": "https://registry.npmjs.org/flatbuffers/-/flatbuffers-25.9.23.tgz",
|
| 1268 |
+
"integrity": "sha512-MI1qs7Lo4Syw0EOzUl0xjs2lsoeqFku44KpngfIduHBYvzm8h2+7K8YMQh1JtVVVrUvhLpNwqVi4DERegUJhPQ==",
|
| 1269 |
+
"license": "Apache-2.0"
|
| 1270 |
+
},
|
| 1271 |
"node_modules/fsevents": {
|
| 1272 |
"version": "2.3.3",
|
| 1273 |
"resolved": "https://registry.npmjs.org/fsevents/-/fsevents-2.3.3.tgz",
|
|
|
|
| 1292 |
"url": "https://github.com/sponsors/ljharb"
|
| 1293 |
}
|
| 1294 |
},
|
| 1295 |
+
"node_modules/guid-typescript": {
|
| 1296 |
+
"version": "1.0.9",
|
| 1297 |
+
"resolved": "https://registry.npmjs.org/guid-typescript/-/guid-typescript-1.0.9.tgz",
|
| 1298 |
+
"integrity": "sha512-Y8T4vYhEfwJOTbouREvG+3XDsjr8E3kIr7uf+JZ0BYloFsttiHU0WfvANVsR7TxNUJa/WpCnw/Ino/p+DeBhBQ==",
|
| 1299 |
+
"license": "ISC"
|
| 1300 |
+
},
|
| 1301 |
"node_modules/hasown": {
|
| 1302 |
"version": "2.0.4",
|
| 1303 |
"resolved": "https://registry.npmjs.org/hasown/-/hasown-2.0.4.tgz",
|
|
|
|
| 1685 |
"integrity": "sha512-7ylylesZQ/PV29jhEDl3Ufjo6ZX7gCqJr5F7PKrqc93v7fzSymt1BpwEU8nAUXs8qzzvqhbjhK5QZg6Mt/HkBg==",
|
| 1686 |
"license": "MIT"
|
| 1687 |
},
|
| 1688 |
+
"node_modules/long": {
|
| 1689 |
+
"version": "5.3.2",
|
| 1690 |
+
"resolved": "https://registry.npmjs.org/long/-/long-5.3.2.tgz",
|
| 1691 |
+
"integrity": "sha512-mNAgZ1GmyNhD7AuqnTG3/VQ26o760+ZYBPKjPvugO8+nLbYfX6TVpJPseBvopbdY+qpZ/lKUnmEc1LeZYS3QAA==",
|
| 1692 |
+
"license": "Apache-2.0"
|
| 1693 |
+
},
|
| 1694 |
"node_modules/loose-envify": {
|
| 1695 |
"version": "1.4.0",
|
| 1696 |
"resolved": "https://registry.npmjs.org/loose-envify/-/loose-envify-1.4.0.tgz",
|
|
|
|
| 1737 |
"node": ">=0.10.0"
|
| 1738 |
}
|
| 1739 |
},
|
| 1740 |
+
"node_modules/onnxruntime-common": {
|
| 1741 |
+
"version": "1.27.0",
|
| 1742 |
+
"resolved": "https://registry.npmjs.org/onnxruntime-common/-/onnxruntime-common-1.27.0.tgz",
|
| 1743 |
+
"integrity": "sha512-3KxL5wIVqa8Ex08jxSzncm9CMgw8CjOFyOQ7SxvG9o0cVLlhTNKXyIQuTbtX4tGPJEf73OER2xrjt4HJSBL4ow==",
|
| 1744 |
+
"license": "MIT"
|
| 1745 |
+
},
|
| 1746 |
+
"node_modules/onnxruntime-web": {
|
| 1747 |
+
"version": "1.27.0",
|
| 1748 |
+
"resolved": "https://registry.npmjs.org/onnxruntime-web/-/onnxruntime-web-1.27.0.tgz",
|
| 1749 |
+
"integrity": "sha512-ogDLsqIozHZwifPuN37OproAo0byX6t43/bP8GzeZWBWD6MOGExswFAx3up4NS/vvWBOg2u2PXomDt3rMmdQSg==",
|
| 1750 |
+
"license": "MIT",
|
| 1751 |
+
"dependencies": {
|
| 1752 |
+
"flatbuffers": "^25.1.24",
|
| 1753 |
+
"guid-typescript": "^1.0.9",
|
| 1754 |
+
"long": "^5.2.3",
|
| 1755 |
+
"onnxruntime-common": "1.27.0",
|
| 1756 |
+
"platform": "^1.3.6",
|
| 1757 |
+
"protobufjs": "^7.2.4"
|
| 1758 |
+
}
|
| 1759 |
+
},
|
| 1760 |
"node_modules/parent-module": {
|
| 1761 |
"version": "1.0.1",
|
| 1762 |
"resolved": "https://registry.npmjs.org/parent-module/-/parent-module-1.0.1.tgz",
|
|
|
|
| 1822 |
"url": "https://github.com/sponsors/jonschlinkert"
|
| 1823 |
}
|
| 1824 |
},
|
| 1825 |
+
"node_modules/platform": {
|
| 1826 |
+
"version": "1.3.6",
|
| 1827 |
+
"resolved": "https://registry.npmjs.org/platform/-/platform-1.3.6.tgz",
|
| 1828 |
+
"integrity": "sha512-fnWVljUchTro6RiCFvCXBbNhJc2NijN7oIQxbwsyL0buWJPG85v81ehlHI9fXrJsMNgTofEoWIQeClKpgxFLrg==",
|
| 1829 |
+
"license": "MIT"
|
| 1830 |
+
},
|
| 1831 |
"node_modules/postcss": {
|
| 1832 |
"version": "8.5.26",
|
| 1833 |
"resolved": "https://registry.npmjs.org/postcss/-/postcss-8.5.26.tgz",
|
|
|
|
| 1874 |
"integrity": "sha512-24e6ynE2H+OKt4kqsOvNd8kBpV65zoxbA4BVsEOB3ARVWQki/DHzaUoC5KuON/BiccDaCCTZBuOcfZs70kR8bQ==",
|
| 1875 |
"license": "MIT"
|
| 1876 |
},
|
| 1877 |
+
"node_modules/protobufjs": {
|
| 1878 |
+
"version": "7.6.6",
|
| 1879 |
+
"resolved": "https://registry.npmjs.org/protobufjs/-/protobufjs-7.6.6.tgz",
|
| 1880 |
+
"integrity": "sha512-dYDWdjSl5RNb7SgPxGQcRU+GtvP7s2fpkrY0r432PcOIaZ0/rBcxEZnQN67iJhFuQiVw754JDoPruPCNdGsbjg==",
|
| 1881 |
+
"hasInstallScript": true,
|
| 1882 |
+
"license": "BSD-3-Clause",
|
| 1883 |
+
"dependencies": {
|
| 1884 |
+
"@protobufjs/aspromise": "^1.1.2",
|
| 1885 |
+
"@protobufjs/base64": "^1.1.2",
|
| 1886 |
+
"@protobufjs/codegen": "^2.0.5",
|
| 1887 |
+
"@protobufjs/eventemitter": "^1.1.1",
|
| 1888 |
+
"@protobufjs/fetch": "^1.1.1",
|
| 1889 |
+
"@protobufjs/float": "^1.0.2",
|
| 1890 |
+
"@protobufjs/path": "^1.1.2",
|
| 1891 |
+
"@protobufjs/pool": "^1.1.0",
|
| 1892 |
+
"@protobufjs/utf8": "^1.1.1",
|
| 1893 |
+
"@types/node": ">=13.7.0",
|
| 1894 |
+
"long": "^5.3.2"
|
| 1895 |
+
},
|
| 1896 |
+
"engines": {
|
| 1897 |
+
"node": ">=12.0.0"
|
| 1898 |
+
}
|
| 1899 |
+
},
|
| 1900 |
"node_modules/react": {
|
| 1901 |
"version": "19.2.8",
|
| 1902 |
"resolved": "https://registry.npmjs.org/react/-/react-19.2.8.tgz",
|
|
|
|
| 2097 |
"url": "https://github.com/sponsors/SuperchupuDev"
|
| 2098 |
}
|
| 2099 |
},
|
| 2100 |
+
"node_modules/trystero": {
|
| 2101 |
+
"version": "0.25.3",
|
| 2102 |
+
"resolved": "https://registry.npmjs.org/trystero/-/trystero-0.25.3.tgz",
|
| 2103 |
+
"integrity": "sha512-mm6Mzmm10OXKfvOA0tUG6XXqlQDIPdHg0//mEqA4ayrtdqiie4ZQpSluEp6074L+dCtotCfCn/B/CWLWavZK6Q==",
|
| 2104 |
+
"license": "MIT",
|
| 2105 |
+
"dependencies": {
|
| 2106 |
+
"@trystero-p2p/nostr": "0.25.3"
|
| 2107 |
+
}
|
| 2108 |
+
},
|
| 2109 |
+
"node_modules/undici-types": {
|
| 2110 |
+
"version": "8.3.0",
|
| 2111 |
+
"resolved": "https://registry.npmjs.org/undici-types/-/undici-types-8.3.0.tgz",
|
| 2112 |
+
"integrity": "sha512-j375ScV60dom+YkPFIfTLcOiPxkN/buHz5GobjLhixFuANaNs3C9l4GmrWqejgXWJ7BbJcFYpTEUkS1Ge8bpZQ==",
|
| 2113 |
+
"license": "MIT"
|
| 2114 |
+
},
|
| 2115 |
"node_modules/use-sync-external-store": {
|
| 2116 |
"version": "1.6.0",
|
| 2117 |
"resolved": "https://registry.npmjs.org/use-sync-external-store/-/use-sync-external-store-1.6.0.tgz",
|
|
@@ -12,10 +12,13 @@
|
|
| 12 |
"@emotion/react": "^11.14.0",
|
| 13 |
"@emotion/styled": "^11.14.1",
|
| 14 |
"@mui/material": "^9.3.1",
|
|
|
|
| 15 |
"@react-three/fiber": "^9.7.0",
|
|
|
|
| 16 |
"react": "^19.2.8",
|
| 17 |
"react-dom": "^19.2.8",
|
| 18 |
"three": "^0.164.1",
|
|
|
|
| 19 |
"zustand": "^5.0.15"
|
| 20 |
},
|
| 21 |
"devDependencies": {
|
|
|
|
| 12 |
"@emotion/react": "^11.14.0",
|
| 13 |
"@emotion/styled": "^11.14.1",
|
| 14 |
"@mui/material": "^9.3.1",
|
| 15 |
+
"@mujoco/mujoco": "^3.11.0",
|
| 16 |
"@react-three/fiber": "^9.7.0",
|
| 17 |
+
"onnxruntime-web": "^1.27.0",
|
| 18 |
"react": "^19.2.8",
|
| 19 |
"react-dom": "^19.2.8",
|
| 20 |
"three": "^0.164.1",
|
| 21 |
+
"trystero": "^0.25.3",
|
| 22 |
"zustand": "^5.0.15"
|
| 23 |
},
|
| 24 |
"devDependencies": {
|
|
@@ -0,0 +1,3 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
version https://git-lfs.github.com/spec/v1
|
| 2 |
+
oid sha256:f32c69bad5518fc3c04fac976b3d50579acfe0699512c8ceee91140338add875
|
| 3 |
+
size 590
|
|
@@ -0,0 +1,3 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
version https://git-lfs.github.com/spec/v1
|
| 2 |
+
oid sha256:4edce0301e1be6d384a20384d6059fa589af454cd1f31b94bef0c2bf7c349bef
|
| 3 |
+
size 1038
|
|
@@ -0,0 +1,3 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
version https://git-lfs.github.com/spec/v1
|
| 2 |
+
oid sha256:a6afd9783e72a73276e975d5baba29845835fa326002c31546651f0f6dfb6ebd
|
| 3 |
+
size 4622
|
|
@@ -0,0 +1,3 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
version https://git-lfs.github.com/spec/v1
|
| 2 |
+
oid sha256:2ea5271e219a8c2bc91ec9e253b71714b22e44a7f3e3fd1f5c33cea3435118a7
|
| 3 |
+
size 4698
|
|
@@ -0,0 +1,3 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
version https://git-lfs.github.com/spec/v1
|
| 2 |
+
oid sha256:a3bdc8a240a9d20b1215e7d24787e0c478373fcf02c4971c919151dbb7113ef4
|
| 3 |
+
size 4924
|
|
@@ -0,0 +1,3 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
version https://git-lfs.github.com/spec/v1
|
| 2 |
+
oid sha256:bad640d37d62a21589aa363dcfc763546bd548da260ec371841dcfb2f47ee2a5
|
| 3 |
+
size 4824
|
|
@@ -0,0 +1,3 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
version https://git-lfs.github.com/spec/v1
|
| 2 |
+
oid sha256:2b06c21370d2ffffad065cb0b1a6c54343ec130b9073d291769d24f6ca60e3bb
|
| 3 |
+
size 4994
|
|
@@ -0,0 +1,3 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
version https://git-lfs.github.com/spec/v1
|
| 2 |
+
oid sha256:8fc501c13cb17d31b5db880c12326848a09afbdf194b771afbdcefd922a5607f
|
| 3 |
+
size 19066
|
|
@@ -0,0 +1,3 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
version https://git-lfs.github.com/spec/v1
|
| 2 |
+
oid sha256:27597d19b0ffc351f0a56b83c8ea7a76aae3c97e6aeb4a9fdb57ebe0400cfe81
|
| 3 |
+
size 17794
|
|
@@ -0,0 +1,3 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
version https://git-lfs.github.com/spec/v1
|
| 2 |
+
oid sha256:46205843156b8d4923ed392f213ccc2a326b67881b3f67dc4c082958a52fdf4f
|
| 3 |
+
size 23954
|
|
@@ -1,7 +1,8 @@
|
|
| 1 |
// App shell: layers in z order - scene canvas (1), halftone (2), CRT (5),
|
| 2 |
-
// HUD + touch overlay (9-12), BIOS readout (20), title menu (30)
|
| 3 |
-
//
|
| 4 |
-
//
|
|
|
|
| 5 |
import { useEffect } from "react";
|
| 6 |
import GameCanvas from "./scene/GameCanvas.jsx";
|
| 7 |
import { Halftone, CrtOverlay } from "./ui/Overlays.jsx";
|
|
@@ -9,9 +10,7 @@ import Hud from "./ui/Hud.jsx";
|
|
| 9 |
import TouchOverlay from "./ui/TouchOverlay.jsx";
|
| 10 |
import BiosOverlay from "./ui/BiosOverlay.jsx";
|
| 11 |
import TitleMenu from "./ui/TitleMenu.jsx";
|
| 12 |
-
import Preboot from "./ui/Preboot.jsx";
|
| 13 |
import { useGame } from "./store.js";
|
| 14 |
-
import { signed } from "./game/signed.js";
|
| 15 |
|
| 16 |
export default function App() {
|
| 17 |
useEffect(() => {
|
|
@@ -42,26 +41,13 @@ export default function App() {
|
|
| 42 |
window.addEventListener("touchstart", armTouch, { once: true, passive: true });
|
| 43 |
if (params.get("touch") === "1") armTouch();
|
| 44 |
|
| 45 |
-
//
|
| 46 |
-
//
|
| 47 |
-
//
|
| 48 |
if (params.get("boot") === "1") {
|
| 49 |
useGame.setState({ prebootDone: true, entered: true, menuOpen: false });
|
| 50 |
} else {
|
| 51 |
-
|
| 52 |
-
img.src = signed("./assets/duck-head-mark.webp");
|
| 53 |
-
const imgReady = new Promise((res) => {
|
| 54 |
-
if (img.complete && img.naturalWidth) res();
|
| 55 |
-
else {
|
| 56 |
-
img.addEventListener("load", res, { once: true });
|
| 57 |
-
img.addEventListener("error", res, { once: true });
|
| 58 |
-
}
|
| 59 |
-
});
|
| 60 |
-
const fontsReady = document.fonts?.ready ?? Promise.resolve();
|
| 61 |
-
const cap = new Promise((res) => setTimeout(res, 2500));
|
| 62 |
-
Promise.race([Promise.all([imgReady, fontsReady]), cap]).then(() => {
|
| 63 |
-
useGame.setState({ prebootDone: true, menuOpen: true });
|
| 64 |
-
});
|
| 65 |
}
|
| 66 |
|
| 67 |
return () => {
|
|
@@ -81,7 +67,6 @@ export default function App() {
|
|
| 81 |
<TouchOverlay />
|
| 82 |
<BiosOverlay />
|
| 83 |
<TitleMenu />
|
| 84 |
-
<Preboot />
|
| 85 |
</>
|
| 86 |
);
|
| 87 |
}
|
|
|
|
| 1 |
// App shell: layers in z order - scene canvas (1), halftone (2), CRT (5),
|
| 2 |
+
// HUD + touch overlay (9-12), BIOS readout (20), title menu (30). Also owns
|
| 3 |
+
// the pre-game device detection (gamepad, touch) and the title -> enter
|
| 4 |
+
// flow. The title menu carries its own loading gate (single central
|
| 5 |
+
// spinner), so no separate preboot veil.
|
| 6 |
import { useEffect } from "react";
|
| 7 |
import GameCanvas from "./scene/GameCanvas.jsx";
|
| 8 |
import { Halftone, CrtOverlay } from "./ui/Overlays.jsx";
|
|
|
|
| 10 |
import TouchOverlay from "./ui/TouchOverlay.jsx";
|
| 11 |
import BiosOverlay from "./ui/BiosOverlay.jsx";
|
| 12 |
import TitleMenu from "./ui/TitleMenu.jsx";
|
|
|
|
| 13 |
import { useGame } from "./store.js";
|
|
|
|
| 14 |
|
| 15 |
export default function App() {
|
| 16 |
useEffect(() => {
|
|
|
|
| 41 |
window.addEventListener("touchstart", armTouch, { once: true, passive: true });
|
| 42 |
if (params.get("touch") === "1") armTouch();
|
| 43 |
|
| 44 |
+
// The title menu opens right away and holds its own loading gate
|
| 45 |
+
// (central spinner) until logo, fonts, duck stage and game boot are
|
| 46 |
+
// in. ?boot=1 skips the title entirely - testing hook.
|
| 47 |
if (params.get("boot") === "1") {
|
| 48 |
useGame.setState({ prebootDone: true, entered: true, menuOpen: false });
|
| 49 |
} else {
|
| 50 |
+
useGame.setState({ prebootDone: true, menuOpen: true });
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 51 |
}
|
| 52 |
|
| 53 |
return () => {
|
|
|
|
| 67 |
<TouchOverlay />
|
| 68 |
<BiosOverlay />
|
| 69 |
<TitleMenu />
|
|
|
|
| 70 |
</>
|
| 71 |
);
|
| 72 |
}
|
|
@@ -243,6 +243,39 @@ function makeWallGridMaterial(alongX) {
|
|
| 243 |
});
|
| 244 |
}
|
| 245 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 246 |
// The four wall planes at their inner faces (three coords: MJCF x -> x,
|
| 247 |
// MJCF y -> -z). Returns the meshes plus their materials (the ceremony and
|
| 248 |
// the per-frame focus update both need the material list).
|
|
|
|
| 243 |
});
|
| 244 |
}
|
| 245 |
|
| 246 |
+
// Entrance draw-in cue schedule, for the ceremony's per-line audio blips.
|
| 247 |
+
// Replicates the shaders' hashed stagger EXACTLY (same hash, same t0 /
|
| 248 |
+
// spread constants, same line ids as drawLine's floor(rs + 0.5)), so each
|
| 249 |
+
// blip lands the instant its line starts drawing. Times are normalized to
|
| 250 |
+
// the respective reveal (0..1); the ceremony maps them to seconds with its
|
| 251 |
+
// own grid/wall reveal durations. `u` is the line's jitter, reused by the
|
| 252 |
+
// audio side for per-line pitch variation. The four walls share one id
|
| 253 |
+
// space, so their lines draw (and blip) in unison - one cue each.
|
| 254 |
+
const jitHash = (x) => {
|
| 255 |
+
const s = Math.sin(x * 127.1) * 43758.5453;
|
| 256 |
+
return s - Math.floor(s);
|
| 257 |
+
};
|
| 258 |
+
export function entranceLineCues() {
|
| 259 |
+
const grid = [];
|
| 260 |
+
const walls = [];
|
| 261 |
+
// Floor: 6 section lines per axis (world x in {-1.5..1.5} step 0.6 maps
|
| 262 |
+
// to ids -2..3); the z family is offset by 57 in the shader.
|
| 263 |
+
for (let i = -2; i <= 3; i++) {
|
| 264 |
+
const jx = jitHash(i);
|
| 265 |
+
const jz = jitHash(i + 57);
|
| 266 |
+
grid.push({ at: 0.00 + jx * 0.30, u: jx });
|
| 267 |
+
grid.push({ at: 0.05 + jz * 0.30, u: jz });
|
| 268 |
+
}
|
| 269 |
+
// Walls: one horizontal section line (the y = 0 base, id 0) and 6
|
| 270 |
+
// verticals (ids -2..3 offset by 31), per the 0.25 m wall height.
|
| 271 |
+
walls.push({ at: 0.00 + jitHash(0) * 0.30, u: jitHash(0) });
|
| 272 |
+
for (let i = -2; i <= 3; i++) {
|
| 273 |
+
const j = jitHash(i + 31);
|
| 274 |
+
walls.push({ at: 0.30 + j * 0.30, u: j });
|
| 275 |
+
}
|
| 276 |
+
return { grid, walls };
|
| 277 |
+
}
|
| 278 |
+
|
| 279 |
// The four wall planes at their inner faces (three coords: MJCF x -> x,
|
| 280 |
// MJCF y -> -z). Returns the meshes plus their materials (the ceremony and
|
| 281 |
// the per-frame focus update both need the material list).
|
|
@@ -0,0 +1,581 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
// Game audio: one shared AudioContext behind a master gain, three buses
|
| 2 |
+
// (ambient / sfx / voice). Plain functions and maps, no classes, matching
|
| 3 |
+
// the imperative game core. The voice bus carries the existing quack and
|
| 4 |
+
// wheee (the stars of the mix); sfx carries footsteps / ball thumps / UI
|
| 5 |
+
// clicks / the entrance sweep; ambient carries the arcade-room hum bed.
|
| 6 |
+
//
|
| 7 |
+
// Samples (CC0, Kenney.nl): footsteps and ball thumps from "Impact Sounds",
|
| 8 |
+
// UI clicks from "Interface Sounds" - trimmed to mono 22.05 kHz wav in
|
| 9 |
+
// public/assets/sfx/. The CRT entrance sweep, the ambient hum and the
|
| 10 |
+
// roller rumble are synthesized (oscillators + filtered noise): zero
|
| 11 |
+
// payload and a better fit for the retro DA.
|
| 12 |
+
//
|
| 13 |
+
// Spatialization is plain Web Audio: the context listener follows the
|
| 14 |
+
// camera every frame and cheap equalpower PannerNodes act as emitters on
|
| 15 |
+
// the duck and the ball (footsteps, thumps and the roller rumble pan and
|
| 16 |
+
// attenuate with the chase cam). Voice and UI stay non-spatial.
|
| 17 |
+
import { signed } from "./signed.js";
|
| 18 |
+
|
| 19 |
+
// ── Master kill switch ────────────────────────────────────────────────
|
| 20 |
+
// Kill switch: flip to true to silence the whole mix (every public entry
|
| 21 |
+
// point early-returns, master gain pinned to 0, context never resumed).
|
| 22 |
+
// Node-returning helpers (busNode, createEmitter) keep building their
|
| 23 |
+
// graph either way so callers stay untouched.
|
| 24 |
+
const SOUND_DISABLED = true;
|
| 25 |
+
|
| 26 |
+
// ── Context, master, buses ────────────────────────────────────────────
|
| 27 |
+
const MASTER_LEVEL = 0.9;
|
| 28 |
+
const BUS_LEVELS = { ambient: 1, sfx: 0.9, voice: 1 };
|
| 29 |
+
const MUTE_KEY = "microduck-muted";
|
| 30 |
+
let ctx = null;
|
| 31 |
+
let master = null;
|
| 32 |
+
const buses = {}; // name -> GainNode
|
| 33 |
+
let muted = false;
|
| 34 |
+
try { muted = localStorage.getItem(MUTE_KEY) === "1"; } catch { /* private mode */ }
|
| 35 |
+
|
| 36 |
+
function unlockOnce() {
|
| 37 |
+
if (ctx?.state === "suspended") ctx.resume().catch(() => {});
|
| 38 |
+
}
|
| 39 |
+
|
| 40 |
+
export function audioCtx() {
|
| 41 |
+
if (ctx) return ctx;
|
| 42 |
+
ctx = new (window.AudioContext ?? window.webkitAudioContext)();
|
| 43 |
+
master = ctx.createGain();
|
| 44 |
+
master.gain.value = SOUND_DISABLED || muted ? 0 : MASTER_LEVEL;
|
| 45 |
+
master.connect(ctx.destination);
|
| 46 |
+
for (const [name, level] of Object.entries(BUS_LEVELS)) {
|
| 47 |
+
const g = ctx.createGain();
|
| 48 |
+
g.gain.value = level;
|
| 49 |
+
g.connect(master);
|
| 50 |
+
buses[name] = g;
|
| 51 |
+
}
|
| 52 |
+
// Autoplay policy: resume on the first user gesture (the Waddle-in
|
| 53 |
+
// click normally lands here; keyboard covers the ?boot=1 test path).
|
| 54 |
+
// While sound is disabled the context is never resumed, so the
|
| 55 |
+
// suspended-state guards below keep every one-shot silent too.
|
| 56 |
+
if (!SOUND_DISABLED) {
|
| 57 |
+
window.addEventListener("pointerdown", unlockOnce, { passive: true });
|
| 58 |
+
window.addEventListener("keydown", unlockOnce);
|
| 59 |
+
}
|
| 60 |
+
return ctx;
|
| 61 |
+
}
|
| 62 |
+
|
| 63 |
+
export function busNode(name) {
|
| 64 |
+
audioCtx();
|
| 65 |
+
return buses[name];
|
| 66 |
+
}
|
| 67 |
+
|
| 68 |
+
// ── Master mute (HUD toggle, persisted) ───────────────────────────────
|
| 69 |
+
// Ramped over ~50 ms instead of a hard cut; the sources keep running so
|
| 70 |
+
// unmuting picks the mix back up exactly where it was.
|
| 71 |
+
export const isMuted = () => muted;
|
| 72 |
+
|
| 73 |
+
export function setMuted(next) {
|
| 74 |
+
if (SOUND_DISABLED) return;
|
| 75 |
+
muted = !!next;
|
| 76 |
+
try { localStorage.setItem(MUTE_KEY, muted ? "1" : "0"); } catch { /* private mode */ }
|
| 77 |
+
if (!ctx) return;
|
| 78 |
+
master.gain.setTargetAtTime(muted ? 0 : MASTER_LEVEL, ctx.currentTime, 0.017);
|
| 79 |
+
}
|
| 80 |
+
|
| 81 |
+
// ── Sample bank (decode-once cache, random takes per name) ────────────
|
| 82 |
+
const SFX_TAKES = { step: 5, thump: 3, click: 2 };
|
| 83 |
+
const bufCache = new Map(); // url -> Promise<AudioBuffer>
|
| 84 |
+
|
| 85 |
+
function bufferFor(url) {
|
| 86 |
+
let p = bufCache.get(url);
|
| 87 |
+
if (!p) {
|
| 88 |
+
p = fetch(url)
|
| 89 |
+
.then((r) => r.arrayBuffer())
|
| 90 |
+
.then((ab) => audioCtx().decodeAudioData(ab));
|
| 91 |
+
bufCache.set(url, p);
|
| 92 |
+
}
|
| 93 |
+
return p;
|
| 94 |
+
}
|
| 95 |
+
|
| 96 |
+
const takeUrl = (name, i) => signed(`./assets/sfx/${name}_${"abcde"[i]}.wav`);
|
| 97 |
+
|
| 98 |
+
export function preloadSfx() {
|
| 99 |
+
if (SOUND_DISABLED) return;
|
| 100 |
+
for (const [name, n] of Object.entries(SFX_TAKES)) {
|
| 101 |
+
for (let i = 0; i < n; i++) bufferFor(takeUrl(name, i)).catch(() => {});
|
| 102 |
+
}
|
| 103 |
+
}
|
| 104 |
+
|
| 105 |
+
// Fire-and-forget one-shot. `out` routes to an emitter panner (spatial);
|
| 106 |
+
// otherwise the sound lands on the flat sfx bus.
|
| 107 |
+
export function playSfx(name, { gain = 1, rate = 1, out = null } = {}) {
|
| 108 |
+
if (SOUND_DISABLED) return;
|
| 109 |
+
const c = audioCtx();
|
| 110 |
+
if (c.state === "suspended") return; // pre-gesture: stay silent, don't queue
|
| 111 |
+
const take = (Math.random() * SFX_TAKES[name]) | 0;
|
| 112 |
+
bufferFor(takeUrl(name, take)).then((buf) => {
|
| 113 |
+
const src = c.createBufferSource();
|
| 114 |
+
src.buffer = buf;
|
| 115 |
+
src.playbackRate.value = rate;
|
| 116 |
+
const g = c.createGain();
|
| 117 |
+
g.gain.value = gain;
|
| 118 |
+
src.connect(g);
|
| 119 |
+
g.connect(out ?? buses.sfx);
|
| 120 |
+
src.onended = () => g.disconnect();
|
| 121 |
+
src.start();
|
| 122 |
+
}).catch(() => {});
|
| 123 |
+
}
|
| 124 |
+
|
| 125 |
+
// Decoded playback for arbitrary URLs (the colourway voice banks): same
|
| 126 |
+
// cache, routed to a bus - replaces the old HTMLAudio chirp path so the
|
| 127 |
+
// quack sits behind the master gain like everything else.
|
| 128 |
+
export function playUrl(url, { bus = "voice", gain = 1, rate = 1 } = {}) {
|
| 129 |
+
if (SOUND_DISABLED) return;
|
| 130 |
+
const c = audioCtx();
|
| 131 |
+
if (c.state === "suspended") return;
|
| 132 |
+
bufferFor(url).then((buf) => {
|
| 133 |
+
const src = c.createBufferSource();
|
| 134 |
+
src.buffer = buf;
|
| 135 |
+
src.playbackRate.value = rate;
|
| 136 |
+
const g = c.createGain();
|
| 137 |
+
g.gain.value = gain;
|
| 138 |
+
src.connect(g);
|
| 139 |
+
g.connect(buses[bus]);
|
| 140 |
+
src.onended = () => g.disconnect();
|
| 141 |
+
src.start();
|
| 142 |
+
}).catch(() => {});
|
| 143 |
+
}
|
| 144 |
+
|
| 145 |
+
export function uiClick() {
|
| 146 |
+
playSfx("click", { gain: 0.16, rate: 0.95 + Math.random() * 0.1 });
|
| 147 |
+
}
|
| 148 |
+
|
| 149 |
+
// ── Spatial listener + emitters ───────────────────────────────────────
|
| 150 |
+
// Listener follows the three.js camera (three world coords). Emitters are
|
| 151 |
+
// equalpower panners: cheap, and plenty for "the duck is over there".
|
| 152 |
+
const _fwd = { x: 0, y: 0, z: -1 };
|
| 153 |
+
|
| 154 |
+
export function updateListener(camera) {
|
| 155 |
+
if (!ctx || ctx.state === "suspended") return;
|
| 156 |
+
const l = ctx.listener;
|
| 157 |
+
const e = camera.matrixWorld.elements;
|
| 158 |
+
// Camera forward = -Z column of the world matrix, up = +Y column.
|
| 159 |
+
_fwd.x = -e[8]; _fwd.y = -e[9]; _fwd.z = -e[10];
|
| 160 |
+
const t = ctx.currentTime;
|
| 161 |
+
if (l.positionX) {
|
| 162 |
+
l.positionX.setTargetAtTime(e[12], t, 0.02);
|
| 163 |
+
l.positionY.setTargetAtTime(e[13], t, 0.02);
|
| 164 |
+
l.positionZ.setTargetAtTime(e[14], t, 0.02);
|
| 165 |
+
l.forwardX.setTargetAtTime(_fwd.x, t, 0.02);
|
| 166 |
+
l.forwardY.setTargetAtTime(_fwd.y, t, 0.02);
|
| 167 |
+
l.forwardZ.setTargetAtTime(_fwd.z, t, 0.02);
|
| 168 |
+
l.upX.setTargetAtTime(e[4], t, 0.02);
|
| 169 |
+
l.upY.setTargetAtTime(e[5], t, 0.02);
|
| 170 |
+
l.upZ.setTargetAtTime(e[6], t, 0.02);
|
| 171 |
+
} else {
|
| 172 |
+
l.setPosition(e[12], e[13], e[14]);
|
| 173 |
+
l.setOrientation(_fwd.x, _fwd.y, _fwd.z, e[4], e[5], e[6]);
|
| 174 |
+
}
|
| 175 |
+
}
|
| 176 |
+
|
| 177 |
+
export function createEmitter({ refDistance = 0.6 } = {}) {
|
| 178 |
+
const c = audioCtx();
|
| 179 |
+
const panner = c.createPanner();
|
| 180 |
+
panner.panningModel = "equalpower";
|
| 181 |
+
panner.distanceModel = "inverse";
|
| 182 |
+
panner.refDistance = refDistance;
|
| 183 |
+
panner.rolloffFactor = 1.1;
|
| 184 |
+
panner.connect(buses.sfx);
|
| 185 |
+
return {
|
| 186 |
+
node: panner,
|
| 187 |
+
setPosition(x, y, z) {
|
| 188 |
+
if (panner.positionX) {
|
| 189 |
+
const t = c.currentTime;
|
| 190 |
+
panner.positionX.setTargetAtTime(x, t, 0.03);
|
| 191 |
+
panner.positionY.setTargetAtTime(y, t, 0.03);
|
| 192 |
+
panner.positionZ.setTargetAtTime(z, t, 0.03);
|
| 193 |
+
} else {
|
| 194 |
+
panner.setPosition(x, y, z);
|
| 195 |
+
}
|
| 196 |
+
},
|
| 197 |
+
};
|
| 198 |
+
}
|
| 199 |
+
|
| 200 |
+
// ── Ambient bed (synthesized arcade-room hum) ─────────────────────────
|
| 201 |
+
// Mains hum (60 Hz + weak 120 Hz octave) plus a slow-breathing bed of
|
| 202 |
+
// lowpassed noise. Sits VERY low in the mix. The whole bed runs through a
|
| 203 |
+
// duck filter + duck gain automated when the pause/title overlay opens.
|
| 204 |
+
const AMBIENT_LEVEL = 0.05;
|
| 205 |
+
const AMBIENT_DUCKED = 0.018;
|
| 206 |
+
let ambient = null; // { duckGain, duckFilter }
|
| 207 |
+
|
| 208 |
+
export function startAmbient() {
|
| 209 |
+
if (SOUND_DISABLED || ambient) return;
|
| 210 |
+
const c = audioCtx();
|
| 211 |
+
const duckFilter = c.createBiquadFilter();
|
| 212 |
+
duckFilter.type = "lowpass";
|
| 213 |
+
duckFilter.frequency.value = 18000;
|
| 214 |
+
const duckGain = c.createGain();
|
| 215 |
+
duckGain.gain.value = 0; // fade in below
|
| 216 |
+
duckFilter.connect(duckGain);
|
| 217 |
+
duckGain.connect(buses.ambient);
|
| 218 |
+
|
| 219 |
+
const hum = c.createOscillator();
|
| 220 |
+
hum.type = "sine";
|
| 221 |
+
hum.frequency.value = 60;
|
| 222 |
+
const humG = c.createGain();
|
| 223 |
+
humG.gain.value = 0.5;
|
| 224 |
+
hum.connect(humG);
|
| 225 |
+
humG.connect(duckFilter);
|
| 226 |
+
|
| 227 |
+
const hum2 = c.createOscillator();
|
| 228 |
+
hum2.type = "triangle";
|
| 229 |
+
hum2.frequency.value = 120;
|
| 230 |
+
const hum2G = c.createGain();
|
| 231 |
+
hum2G.gain.value = 0.14;
|
| 232 |
+
hum2.connect(hum2G);
|
| 233 |
+
hum2G.connect(duckFilter);
|
| 234 |
+
|
| 235 |
+
const noise = c.createBufferSource();
|
| 236 |
+
noise.buffer = noiseBuffer(c, 2.0);
|
| 237 |
+
noise.loop = true;
|
| 238 |
+
const noiseF = c.createBiquadFilter();
|
| 239 |
+
noiseF.type = "lowpass";
|
| 240 |
+
noiseF.frequency.value = 260;
|
| 241 |
+
const noiseG = c.createGain();
|
| 242 |
+
noiseG.gain.value = 0.5;
|
| 243 |
+
noise.connect(noiseF);
|
| 244 |
+
noiseF.connect(noiseG);
|
| 245 |
+
noiseG.connect(duckFilter);
|
| 246 |
+
|
| 247 |
+
// Slow breathing on the noise bed (~0.08 Hz) so the room feels alive.
|
| 248 |
+
const lfo = c.createOscillator();
|
| 249 |
+
lfo.frequency.value = 0.08;
|
| 250 |
+
const lfoG = c.createGain();
|
| 251 |
+
lfoG.gain.value = 0.15;
|
| 252 |
+
lfo.connect(lfoG);
|
| 253 |
+
lfoG.connect(noiseG.gain);
|
| 254 |
+
|
| 255 |
+
hum.start();
|
| 256 |
+
hum2.start();
|
| 257 |
+
noise.start();
|
| 258 |
+
lfo.start();
|
| 259 |
+
duckGain.gain.setTargetAtTime(AMBIENT_LEVEL, c.currentTime, 1.2);
|
| 260 |
+
ambient = { duckGain, duckFilter };
|
| 261 |
+
}
|
| 262 |
+
|
| 263 |
+
export function setAmbientDucked(ducked) {
|
| 264 |
+
if (!ambient) return;
|
| 265 |
+
const t = ctx.currentTime;
|
| 266 |
+
ambient.duckGain.gain.setTargetAtTime(ducked ? AMBIENT_DUCKED : AMBIENT_LEVEL, t, 0.25);
|
| 267 |
+
ambient.duckFilter.frequency.setTargetAtTime(ducked ? 300 : 18000, t, 0.25);
|
| 268 |
+
}
|
| 269 |
+
|
| 270 |
+
function noiseBuffer(c, seconds) {
|
| 271 |
+
const n = (c.sampleRate * seconds) | 0;
|
| 272 |
+
const buf = c.createBuffer(1, n, c.sampleRate);
|
| 273 |
+
const d = buf.getChannelData(0);
|
| 274 |
+
for (let i = 0; i < n; i++) d[i] = Math.random() * 2 - 1;
|
| 275 |
+
return buf;
|
| 276 |
+
}
|
| 277 |
+
|
| 278 |
+
// ── Materialize sweeps (phase-locked to the wireframe FX) ─────────────
|
| 279 |
+
// fx-wireframe.js runs two overlapping passes over totalS seconds (duck:
|
| 280 |
+
// 0.9 s): a wireframe scan line over the first 2/3 and a trailing
|
| 281 |
+
// solidify line over the last 2/3, BOTH driven by the same ease-out
|
| 282 |
+
// (1 - (1-x)^2): the line launches at full speed and decelerates into
|
| 283 |
+
// the top. The pitch of each layer follows that exact curve (sampled
|
| 284 |
+
// into setValueCurveAtTime), so the sweep rushes then settles exactly
|
| 285 |
+
// like the line the player watches - not a straight climb through it.
|
| 286 |
+
//
|
| 287 |
+
// scan [0 .. 2/3 T] square, 150 -> 1500 Hz along the fx ease
|
| 288 |
+
// solid [1/3 T .. T] triangle an octave down, same curve, trailing
|
| 289 |
+
// T resolve: sine drop thunk + soft high ping
|
| 290 |
+
//
|
| 291 |
+
// Props reuse the same voice via playPropSweep: their fx stretches the
|
| 292 |
+
// timeline by sqrt(size) (see fx-wireframe REF_SPAN), so the duration
|
| 293 |
+
// itself encodes the target's size - the sweep divides its pitch by
|
| 294 |
+
// that stretch, and big objects (arcade cabinet) materialize DEEPER
|
| 295 |
+
// and longer while small ones chirp through quickly.
|
| 296 |
+
const FX_EASE = (x) => 1 - (1 - x) * (1 - x); // fx-wireframe's ease()
|
| 297 |
+
const FX_BASE_S = 0.9; // fx TOTAL_S before size stretch
|
| 298 |
+
|
| 299 |
+
function easedPitchCurve(f0, f1, n = 48) {
|
| 300 |
+
const arr = new Float32Array(n);
|
| 301 |
+
for (let i = 0; i < n; i++) arr[i] = f0 * (f1 / f0) ** FX_EASE(i / (n - 1));
|
| 302 |
+
return arr;
|
| 303 |
+
}
|
| 304 |
+
|
| 305 |
+
// One materialize voice. level scales every layer; pitchK divides the
|
| 306 |
+
// whole sweep's register (bigger target = lower); shimmer + ping are the
|
| 307 |
+
// duck's hero garnish, props skip them.
|
| 308 |
+
function materializeSweep(c, { totalS, level = 1, pitchK = 1, hero = true }) {
|
| 309 |
+
const T = Math.max(0.3, totalS);
|
| 310 |
+
const t0 = c.currentTime + 0.01;
|
| 311 |
+
const scanEnd = t0 + T * (2 / 3);
|
| 312 |
+
const solidStart = t0 + T * (1 / 3);
|
| 313 |
+
const tEnd = t0 + T;
|
| 314 |
+
const k = 1 / pitchK;
|
| 315 |
+
|
| 316 |
+
const out = c.createGain();
|
| 317 |
+
out.gain.value = level;
|
| 318 |
+
const tone = c.createBiquadFilter();
|
| 319 |
+
tone.type = "lowpass";
|
| 320 |
+
tone.frequency.value = 5200; // tame the squares' buzz, keep the bite
|
| 321 |
+
tone.connect(out);
|
| 322 |
+
out.connect(buses.sfx);
|
| 323 |
+
|
| 324 |
+
// Static tick right as the scan cues.
|
| 325 |
+
const tick = c.createBufferSource();
|
| 326 |
+
tick.buffer = noiseBuffer(c, 0.06);
|
| 327 |
+
const tickF = c.createBiquadFilter();
|
| 328 |
+
tickF.type = "highpass";
|
| 329 |
+
tickF.frequency.value = 2500;
|
| 330 |
+
const tickG = c.createGain();
|
| 331 |
+
tickG.gain.setValueAtTime(0.1, t0);
|
| 332 |
+
tickG.gain.exponentialRampToValueAtTime(0.001, t0 + 0.06);
|
| 333 |
+
tick.connect(tickF);
|
| 334 |
+
tickF.connect(tickG);
|
| 335 |
+
tickG.connect(out);
|
| 336 |
+
tick.start(t0);
|
| 337 |
+
|
| 338 |
+
// Scan pass: rising square riding the scan line's exact ease-out.
|
| 339 |
+
const scan = c.createOscillator();
|
| 340 |
+
scan.type = "square";
|
| 341 |
+
scan.frequency.setValueCurveAtTime(
|
| 342 |
+
easedPitchCurve(150 * k, 1500 * k), t0, T * (2 / 3),
|
| 343 |
+
);
|
| 344 |
+
const scanG = c.createGain();
|
| 345 |
+
scanG.gain.setValueAtTime(0, t0);
|
| 346 |
+
scanG.gain.linearRampToValueAtTime(0.14, t0 + 0.03);
|
| 347 |
+
scanG.gain.setValueAtTime(0.14, scanEnd - 0.05);
|
| 348 |
+
scanG.gain.exponentialRampToValueAtTime(0.001, scanEnd + 0.08);
|
| 349 |
+
scan.connect(scanG);
|
| 350 |
+
scanG.connect(tone);
|
| 351 |
+
scan.start(t0);
|
| 352 |
+
scan.stop(scanEnd + 0.1);
|
| 353 |
+
|
| 354 |
+
// Solidify pass: same eased climb an octave down, trailing like the
|
| 355 |
+
// second line, swelling as the target fills in solid.
|
| 356 |
+
const solid = c.createOscillator();
|
| 357 |
+
solid.type = "triangle";
|
| 358 |
+
solid.frequency.setValueCurveAtTime(
|
| 359 |
+
easedPitchCurve(85 * k, 760 * k), solidStart, T * (2 / 3),
|
| 360 |
+
);
|
| 361 |
+
const solidG = c.createGain();
|
| 362 |
+
solidG.gain.setValueAtTime(0, solidStart);
|
| 363 |
+
solidG.gain.linearRampToValueAtTime(0.08, solidStart + 0.06);
|
| 364 |
+
solidG.gain.linearRampToValueAtTime(0.16, tEnd - 0.05);
|
| 365 |
+
solidG.gain.exponentialRampToValueAtTime(0.001, tEnd + 0.06);
|
| 366 |
+
solid.connect(solidG);
|
| 367 |
+
solidG.connect(tone);
|
| 368 |
+
solid.start(solidStart);
|
| 369 |
+
solid.stop(tEnd + 0.1);
|
| 370 |
+
|
| 371 |
+
if (hero) {
|
| 372 |
+
// Hologram shimmer: airy bandpassed noise, centre frequency easing
|
| 373 |
+
// up with the reveal like everything else.
|
| 374 |
+
const shimmer = c.createBufferSource();
|
| 375 |
+
shimmer.buffer = noiseBuffer(c, T);
|
| 376 |
+
const shimF = c.createBiquadFilter();
|
| 377 |
+
shimF.type = "bandpass";
|
| 378 |
+
shimF.frequency.setValueCurveAtTime(
|
| 379 |
+
easedPitchCurve(1800 * k, 5200 * k), t0, T,
|
| 380 |
+
);
|
| 381 |
+
shimF.Q.value = 1.2;
|
| 382 |
+
const shimG = c.createGain();
|
| 383 |
+
shimG.gain.setValueAtTime(0, t0);
|
| 384 |
+
shimG.gain.linearRampToValueAtTime(0.05, t0 + 0.1);
|
| 385 |
+
shimG.gain.setValueAtTime(0.05, tEnd - 0.15);
|
| 386 |
+
shimG.gain.linearRampToValueAtTime(0, tEnd);
|
| 387 |
+
shimmer.connect(shimF);
|
| 388 |
+
shimF.connect(shimG);
|
| 389 |
+
shimG.connect(out);
|
| 390 |
+
shimmer.start(t0);
|
| 391 |
+
}
|
| 392 |
+
|
| 393 |
+
// Resolve: pitch-drop thunk right when the solidify line clears the
|
| 394 |
+
// top and the real materials land. Deeper for bigger targets.
|
| 395 |
+
const thunk = c.createOscillator();
|
| 396 |
+
thunk.type = "sine";
|
| 397 |
+
thunk.frequency.setValueAtTime(170 * k, tEnd);
|
| 398 |
+
thunk.frequency.exponentialRampToValueAtTime(62 * k, tEnd + 0.13);
|
| 399 |
+
const thunkG = c.createGain();
|
| 400 |
+
thunkG.gain.setValueAtTime(hero ? 0.3 : 0.22, tEnd);
|
| 401 |
+
thunkG.gain.exponentialRampToValueAtTime(0.001, tEnd + 0.22);
|
| 402 |
+
thunk.connect(thunkG);
|
| 403 |
+
thunkG.connect(out);
|
| 404 |
+
thunk.start(tEnd);
|
| 405 |
+
thunk.stop(tEnd + 0.25);
|
| 406 |
+
|
| 407 |
+
if (hero) {
|
| 408 |
+
const ping = c.createOscillator();
|
| 409 |
+
ping.type = "sine";
|
| 410 |
+
ping.frequency.value = 1320;
|
| 411 |
+
const pingG = c.createGain();
|
| 412 |
+
pingG.gain.setValueAtTime(0.07, tEnd);
|
| 413 |
+
pingG.gain.exponentialRampToValueAtTime(0.001, tEnd + 0.3);
|
| 414 |
+
ping.connect(pingG);
|
| 415 |
+
pingG.connect(out);
|
| 416 |
+
ping.start(tEnd);
|
| 417 |
+
ping.stop(tEnd + 0.32);
|
| 418 |
+
ping.onended = () => out.disconnect();
|
| 419 |
+
} else {
|
| 420 |
+
thunk.onended = () => out.disconnect();
|
| 421 |
+
}
|
| 422 |
+
}
|
| 423 |
+
|
| 424 |
+
// The duck's hero sweep (entrance and every respawn scan-up).
|
| 425 |
+
export function playEntranceSweep(totalS = 0.9) {
|
| 426 |
+
if (SOUND_DISABLED) return;
|
| 427 |
+
const c = audioCtx();
|
| 428 |
+
if (c.state === "suspended") return;
|
| 429 |
+
materializeSweep(c, { totalS, level: 1, pitchK: 1, hero: true });
|
| 430 |
+
}
|
| 431 |
+
|
| 432 |
+
// One prop materializing (arcade cabinet, boombox...). The fx stretches
|
| 433 |
+
// its timeline by sqrt(size), so recover that stretch from the duration
|
| 434 |
+
// and drop the register by it: a 2.2 s cabinet sweeps ~2.5x deeper than
|
| 435 |
+
// the duck, a duck-sized prop sounds like a quieter duck. A tiny random
|
| 436 |
+
// detune keeps staggered props from phasing into one thick chord.
|
| 437 |
+
export function playPropSweep(totalS = 0.9) {
|
| 438 |
+
if (SOUND_DISABLED) return;
|
| 439 |
+
const c = audioCtx();
|
| 440 |
+
if (c.state === "suspended") return;
|
| 441 |
+
const durK = Math.max(1, totalS / FX_BASE_S);
|
| 442 |
+
const detune = 2 ** ((Math.random() - 0.5) * 0.16);
|
| 443 |
+
materializeSweep(c, {
|
| 444 |
+
totalS,
|
| 445 |
+
level: 0.4,
|
| 446 |
+
pitchK: durK * detune,
|
| 447 |
+
hero: false,
|
| 448 |
+
});
|
| 449 |
+
}
|
| 450 |
+
|
| 451 |
+
// ── BIOS / menu one-shots (synthesized, retro POST language) ──────────
|
| 452 |
+
// All guarded on a suspended context: the BIOS replay only starts after
|
| 453 |
+
// the enter click (the unlock gesture), but a gamepad-only entry can
|
| 454 |
+
// leave the context locked - then these simply stay silent.
|
| 455 |
+
|
| 456 |
+
// Single POST beep, the classic "one beep = all good".
|
| 457 |
+
export function playBiosBeep() {
|
| 458 |
+
if (SOUND_DISABLED) return;
|
| 459 |
+
const c = audioCtx();
|
| 460 |
+
if (c.state === "suspended") return;
|
| 461 |
+
const t0 = c.currentTime + 0.01;
|
| 462 |
+
const osc = c.createOscillator();
|
| 463 |
+
osc.type = "square";
|
| 464 |
+
osc.frequency.value = 940;
|
| 465 |
+
const g = c.createGain();
|
| 466 |
+
g.gain.setValueAtTime(0, t0);
|
| 467 |
+
g.gain.linearRampToValueAtTime(0.08, t0 + 0.008);
|
| 468 |
+
g.gain.setValueAtTime(0.08, t0 + 0.1);
|
| 469 |
+
g.gain.exponentialRampToValueAtTime(0.001, t0 + 0.16);
|
| 470 |
+
osc.connect(g);
|
| 471 |
+
g.connect(buses.sfx);
|
| 472 |
+
osc.start(t0);
|
| 473 |
+
osc.stop(t0 + 0.18);
|
| 474 |
+
osc.onended = () => g.disconnect();
|
| 475 |
+
}
|
| 476 |
+
|
| 477 |
+
// Teletype tick, one per printed POST line. Whisper-level.
|
| 478 |
+
export function playBiosTick() {
|
| 479 |
+
if (SOUND_DISABLED) return;
|
| 480 |
+
const c = audioCtx();
|
| 481 |
+
if (c.state === "suspended") return;
|
| 482 |
+
const t0 = c.currentTime;
|
| 483 |
+
const osc = c.createOscillator();
|
| 484 |
+
osc.type = "square";
|
| 485 |
+
osc.frequency.value = 1750 + Math.random() * 350;
|
| 486 |
+
const g = c.createGain();
|
| 487 |
+
g.gain.setValueAtTime(0.02, t0);
|
| 488 |
+
g.gain.exponentialRampToValueAtTime(0.001, t0 + 0.018);
|
| 489 |
+
osc.connect(g);
|
| 490 |
+
g.connect(buses.sfx);
|
| 491 |
+
osc.start(t0);
|
| 492 |
+
osc.stop(t0 + 0.02);
|
| 493 |
+
osc.onended = () => g.disconnect();
|
| 494 |
+
}
|
| 495 |
+
|
| 496 |
+
// Menu-confirm bloop (Waddle in / Resume / pad A): two rising square
|
| 497 |
+
// notes, C5 -> G5. This rides the very gesture that unlocks the context,
|
| 498 |
+
// so a suspended context gets one resume-then-play attempt.
|
| 499 |
+
export function playConfirmBloop() {
|
| 500 |
+
if (SOUND_DISABLED) return;
|
| 501 |
+
const c = audioCtx();
|
| 502 |
+
if (c.state === "suspended") {
|
| 503 |
+
c.resume().then(() => confirmNow(c)).catch(() => {});
|
| 504 |
+
return;
|
| 505 |
+
}
|
| 506 |
+
confirmNow(c);
|
| 507 |
+
}
|
| 508 |
+
|
| 509 |
+
function confirmNow(c) {
|
| 510 |
+
const t0 = c.currentTime + 0.01;
|
| 511 |
+
const tone = c.createBiquadFilter();
|
| 512 |
+
tone.type = "lowpass";
|
| 513 |
+
tone.frequency.value = 4200;
|
| 514 |
+
tone.connect(buses.sfx);
|
| 515 |
+
const osc = c.createOscillator();
|
| 516 |
+
osc.type = "square";
|
| 517 |
+
osc.frequency.setValueAtTime(523, t0);
|
| 518 |
+
osc.frequency.setValueAtTime(784, t0 + 0.07);
|
| 519 |
+
const g = c.createGain();
|
| 520 |
+
g.gain.setValueAtTime(0, t0);
|
| 521 |
+
g.gain.linearRampToValueAtTime(0.13, t0 + 0.01);
|
| 522 |
+
g.gain.setValueAtTime(0.13, t0 + 0.12);
|
| 523 |
+
g.gain.exponentialRampToValueAtTime(0.001, t0 + 0.2);
|
| 524 |
+
osc.connect(g);
|
| 525 |
+
g.connect(tone);
|
| 526 |
+
osc.start(t0);
|
| 527 |
+
osc.stop(t0 + 0.22);
|
| 528 |
+
osc.onended = () => tone.disconnect();
|
| 529 |
+
}
|
| 530 |
+
|
| 531 |
+
// ── Entrance line blips ───────────────────────────────────────────────
|
| 532 |
+
// One tiny tick per grid/wall section line as it starts drawing in. `u`
|
| 533 |
+
// is the line's own draw jitter (0..1), reused as pitch variation so the
|
| 534 |
+
// flurry chirps instead of machine-gunning. Very low in the mix.
|
| 535 |
+
export function playLineBlip(u = 0.5) {
|
| 536 |
+
if (SOUND_DISABLED) return;
|
| 537 |
+
const c = audioCtx();
|
| 538 |
+
if (c.state === "suspended") return;
|
| 539 |
+
const t0 = c.currentTime;
|
| 540 |
+
const osc = c.createOscillator();
|
| 541 |
+
osc.type = "triangle";
|
| 542 |
+
osc.frequency.value = 1400 * 2 ** (u * 0.9); // ~1.4-2.6 kHz across lines
|
| 543 |
+
const g = c.createGain();
|
| 544 |
+
g.gain.setValueAtTime(0.05, t0);
|
| 545 |
+
g.gain.exponentialRampToValueAtTime(0.001, t0 + 0.05);
|
| 546 |
+
osc.connect(g);
|
| 547 |
+
g.connect(buses.sfx);
|
| 548 |
+
osc.start(t0);
|
| 549 |
+
osc.stop(t0 + 0.06);
|
| 550 |
+
osc.onended = () => g.disconnect();
|
| 551 |
+
}
|
| 552 |
+
|
| 553 |
+
// ── Roller rumble (synthesized rolling-noise loop) ────────────────────
|
| 554 |
+
// Bandpassed noise on the duck emitter; gain and a slight pitch follow
|
| 555 |
+
// ground speed, silent at rest. Built lazily on first use.
|
| 556 |
+
let rumble = null; // { gain, src }
|
| 557 |
+
|
| 558 |
+
export function setRumble(level, out = null) {
|
| 559 |
+
if (SOUND_DISABLED) return;
|
| 560 |
+
const c = audioCtx();
|
| 561 |
+
if (!rumble) {
|
| 562 |
+
if (level <= 0) return;
|
| 563 |
+
const src = c.createBufferSource();
|
| 564 |
+
src.buffer = noiseBuffer(c, 1.5);
|
| 565 |
+
src.loop = true;
|
| 566 |
+
const f = c.createBiquadFilter();
|
| 567 |
+
f.type = "bandpass";
|
| 568 |
+
f.frequency.value = 110;
|
| 569 |
+
f.Q.value = 0.8;
|
| 570 |
+
const g = c.createGain();
|
| 571 |
+
g.gain.value = 0;
|
| 572 |
+
src.connect(f);
|
| 573 |
+
f.connect(g);
|
| 574 |
+
g.connect(out?.node ?? buses.sfx);
|
| 575 |
+
src.start();
|
| 576 |
+
rumble = { gain: g, src };
|
| 577 |
+
}
|
| 578 |
+
const t = c.currentTime;
|
| 579 |
+
rumble.gain.gain.setTargetAtTime(Math.min(1, level) * 0.22, t, 0.08);
|
| 580 |
+
rumble.src.playbackRate.setTargetAtTime(0.85 + 0.5 * Math.min(1, level), t, 0.12);
|
| 581 |
+
}
|
|
@@ -14,6 +14,8 @@
|
|
| 14 |
// rl.js injects the world (rig, grid, camera reset) and the lock/flash
|
| 15 |
// callbacks; this file never touches MuJoCo.
|
| 16 |
|
|
|
|
|
|
|
| 17 |
export const CAM_RESET_S = 0.9;
|
| 18 |
export const RESPAWN_SCAN_AT = 0.8; // cue scan-up at 80% of the camera glide
|
| 19 |
|
|
@@ -29,6 +31,9 @@ export function createCeremony({
|
|
| 29 |
getRig, grid, wallMats,
|
| 30 |
syncRig, startCameraReset,
|
| 31 |
setLocked, flashReset,
|
|
|
|
|
|
|
|
|
|
| 32 |
}) {
|
| 33 |
const ENTRANCE_TOTAL_S = Math.max(
|
| 34 |
ENTRANCE_GRID_S,
|
|
@@ -36,6 +41,19 @@ export function createCeremony({
|
|
| 36 |
ENTRANCE_FX_START_S + fx.TOTAL_S,
|
| 37 |
);
|
| 38 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 39 |
let fxRig = null;
|
| 40 |
let fxPrev = null;
|
| 41 |
// Decorative props (addPropFx): each owns a wireframe FX bound to its
|
|
@@ -67,6 +85,9 @@ export function createCeremony({
|
|
| 67 |
fx.start();
|
| 68 |
fxPrev = performance.now();
|
| 69 |
propsCue();
|
|
|
|
|
|
|
|
|
|
| 70 |
}
|
| 71 |
function propsCue() {
|
| 72 |
const now = performance.now();
|
|
@@ -105,6 +126,10 @@ export function createCeremony({
|
|
| 105 |
if (p.at === null || now < p.at) continue;
|
| 106 |
if (!p.started) {
|
| 107 |
p.fx.start();
|
|
|
|
|
|
|
|
|
|
|
|
|
| 108 |
p.started = true;
|
| 109 |
p.prev = now;
|
| 110 |
continue;
|
|
@@ -130,6 +155,7 @@ export function createCeremony({
|
|
| 130 |
if (entranceT0 !== null || entranceDone) return;
|
| 131 |
entranceT0 = performance.now();
|
| 132 |
entranceFxCued = false;
|
|
|
|
| 133 |
grid.material.uniforms.uReveal.value = 0;
|
| 134 |
for (const m of wallMats) m.uniforms.uReveal.value = 0;
|
| 135 |
}
|
|
@@ -137,6 +163,11 @@ export function createCeremony({
|
|
| 137 |
function driveEntrance() {
|
| 138 |
if (entranceT0 === null) return;
|
| 139 |
const t = (performance.now() - entranceT0) / 1000;
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 140 |
grid.material.uniforms.uReveal.value = clamp01(t / ENTRANCE_GRID_S);
|
| 141 |
const wallR = clamp01((t - ENTRANCE_WALL_DELAY_S) / ENTRANCE_WALL_S);
|
| 142 |
for (const m of wallMats) m.uniforms.uReveal.value = wallR;
|
|
|
|
| 14 |
// rl.js injects the world (rig, grid, camera reset) and the lock/flash
|
| 15 |
// callbacks; this file never touches MuJoCo.
|
| 16 |
|
| 17 |
+
import { entranceLineCues } from "./arena.js";
|
| 18 |
+
|
| 19 |
export const CAM_RESET_S = 0.9;
|
| 20 |
export const RESPAWN_SCAN_AT = 0.8; // cue scan-up at 80% of the camera glide
|
| 21 |
|
|
|
|
| 31 |
getRig, grid, wallMats,
|
| 32 |
syncRig, startCameraReset,
|
| 33 |
setLocked, flashReset,
|
| 34 |
+
onScanCue = () => {},
|
| 35 |
+
onLineCue = () => {},
|
| 36 |
+
onPropCue = () => {},
|
| 37 |
}) {
|
| 38 |
const ENTRANCE_TOTAL_S = Math.max(
|
| 39 |
ENTRANCE_GRID_S,
|
|
|
|
| 41 |
ENTRANCE_FX_START_S + fx.TOTAL_S,
|
| 42 |
);
|
| 43 |
|
| 44 |
+
// Per-line audio cues for the entrance draw-in: normalized reveal times
|
| 45 |
+
// from arena.js (exact same hashed stagger as the shaders), mapped onto
|
| 46 |
+
// this timeline's grid/wall reveal windows. Entrance-only: respawns
|
| 47 |
+
// never redraw the arena.
|
| 48 |
+
const cues = entranceLineCues();
|
| 49 |
+
const lineCues = [
|
| 50 |
+
...cues.grid.map((c) => ({ at: c.at * ENTRANCE_GRID_S, u: c.u })),
|
| 51 |
+
...cues.walls.map((c) => ({
|
| 52 |
+
at: ENTRANCE_WALL_DELAY_S + c.at * ENTRANCE_WALL_S, u: c.u,
|
| 53 |
+
})),
|
| 54 |
+
].sort((a, b) => a.at - b.at);
|
| 55 |
+
let lineCueIdx = lineCues.length; // parked until startEntrance
|
| 56 |
+
|
| 57 |
let fxRig = null;
|
| 58 |
let fxPrev = null;
|
| 59 |
// Decorative props (addPropFx): each owns a wireframe FX bound to its
|
|
|
|
| 85 |
fx.start();
|
| 86 |
fxPrev = performance.now();
|
| 87 |
propsCue();
|
| 88 |
+
// Sound cue riding the same trigger as the visual scan; the duration
|
| 89 |
+
// lets the sweep span the materialize exactly.
|
| 90 |
+
onScanCue(fx.TOTAL_S);
|
| 91 |
}
|
| 92 |
function propsCue() {
|
| 93 |
const now = performance.now();
|
|
|
|
| 126 |
if (p.at === null || now < p.at) continue;
|
| 127 |
if (!p.started) {
|
| 128 |
p.fx.start();
|
| 129 |
+
// Audio twin, cued the frame this prop's scan actually starts;
|
| 130 |
+
// TOTAL_S carries the size-stretched duration so the sound can
|
| 131 |
+
// match both length and register.
|
| 132 |
+
onPropCue(p.fx.TOTAL_S);
|
| 133 |
p.started = true;
|
| 134 |
p.prev = now;
|
| 135 |
continue;
|
|
|
|
| 155 |
if (entranceT0 !== null || entranceDone) return;
|
| 156 |
entranceT0 = performance.now();
|
| 157 |
entranceFxCued = false;
|
| 158 |
+
lineCueIdx = 0;
|
| 159 |
grid.material.uniforms.uReveal.value = 0;
|
| 160 |
for (const m of wallMats) m.uniforms.uReveal.value = 0;
|
| 161 |
}
|
|
|
|
| 163 |
function driveEntrance() {
|
| 164 |
if (entranceT0 === null) return;
|
| 165 |
const t = (performance.now() - entranceT0) / 1000;
|
| 166 |
+
// Fire the per-line blips as their lines start drawing in.
|
| 167 |
+
while (lineCueIdx < lineCues.length && lineCues[lineCueIdx].at <= t) {
|
| 168 |
+
onLineCue(lineCues[lineCueIdx].u);
|
| 169 |
+
lineCueIdx++;
|
| 170 |
+
}
|
| 171 |
grid.material.uniforms.uReveal.value = clamp01(t / ENTRANCE_GRID_S);
|
| 172 |
const wallR = clamp01((t - ENTRANCE_WALL_DELAY_S) / ENTRANCE_WALL_S);
|
| 173 |
for (const m of wallMats) m.uniforms.uReveal.value = wallR;
|
|
@@ -48,18 +48,25 @@ import { GamepadSource } from "./controls/gamepad.js";
|
|
| 48 |
import { TouchSource } from "./controls/touch.js";
|
| 49 |
import * as fx from "./fx/fx-wireframe.js";
|
| 50 |
import { createCeremony, CAM_RESET_S } from "./ceremony.js";
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 51 |
import { createBallActor } from "./ball-actor.js";
|
| 52 |
import { initGhosts } from "./ghosts.js";
|
| 53 |
import { makeInfiniteGrid, makeArenaWalls } from "./arena.js";
|
| 54 |
import { createBallVisual } from "./ball-visual.js";
|
| 55 |
import { useGame, gameApi, bootLine, bootNote, bootHalt } from "../store.js";
|
| 56 |
|
| 57 |
-
// Physics + inference runtimes
|
| 58 |
-
//
|
| 59 |
-
//
|
| 60 |
-
//
|
| 61 |
-
|
| 62 |
-
|
|
|
|
|
|
|
| 63 |
|
| 64 |
let bootStarted = false;
|
| 65 |
|
|
@@ -123,15 +130,20 @@ async function boot({ scene, camera, renderer }) {
|
|
| 123 |
);
|
| 124 |
};
|
| 125 |
|
| 126 |
-
// ── Runtimes (
|
| 127 |
const [{ default: loadMujocoFactory }, ort] = await traced(
|
| 128 |
"RUNTIME MODULES",
|
| 129 |
Promise.all([
|
| 130 |
-
import(
|
| 131 |
-
|
|
|
|
|
|
|
|
|
|
| 132 |
]),
|
| 133 |
);
|
| 134 |
-
|
|
|
|
|
|
|
| 135 |
ort.env.wasm.numThreads = 1; // static hosting sends no COOP/COEP headers
|
| 136 |
|
| 137 |
// ── MJCF preparation ────────────────────────────────────────────────
|
|
@@ -243,7 +255,11 @@ async function boot({ scene, camera, renderer }) {
|
|
| 243 |
|
| 244 |
// ── Boot physics + policy in parallel with the render rig ────────────
|
| 245 |
const [mujoco, { xml, meshFiles }, k] = await Promise.all([
|
| 246 |
-
traced("MUJOCO WASM", loadMujocoFactory(
|
|
|
|
|
|
|
|
|
|
|
|
|
| 247 |
traced("PHYSICS MJCF", buildPhysicsXml("robot_allcollisions.xml")),
|
| 248 |
traced("KINEMATICS", loadKinematics(`${MODEL_DIR}/kinematics.json`)),
|
| 249 |
]);
|
|
@@ -348,6 +364,10 @@ async function boot({ scene, camera, renderer }) {
|
|
| 348 |
standKeyId: mujoco.mj_name2id(model, mujoco.mjtObj.mjOBJ_KEY.value, "STAND"),
|
| 349 |
ballQposAdr: model.jnt("ball_freejoint").qposadr,
|
| 350 |
ballDofAdr: model.jnt("ball_freejoint").dofadr,
|
|
|
|
|
|
|
|
|
|
|
|
|
| 351 |
// Unactuated hinges (the roller variant's 4 passive wheels): not in
|
| 352 |
// the obs or ctrl, but synced to the render rig so the wheels spin.
|
| 353 |
extraJoints: kin.bodies
|
|
@@ -356,8 +376,8 @@ async function boot({ scene, camera, renderer }) {
|
|
| 356 |
};
|
| 357 |
}
|
| 358 |
// Active-variant address block, swapped wholesale by activateLoco.
|
| 359 |
-
let { qposAdr, dofAdr, gyroAdr, trunkId, standKeyId, ballQposAdr, ballDofAdr, extraJoints
|
| 360 |
-
resolveAddrs(model, k);
|
| 361 |
|
| 362 |
// Locomotion variants stay resident once built (model + data + rig +
|
| 363 |
// addresses); legs is registered when its render rig resolves below.
|
|
@@ -678,6 +698,130 @@ async function boot({ scene, camera, renderer }) {
|
|
| 678 |
return null;
|
| 679 |
}
|
| 680 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 681 |
async function controlStep() {
|
| 682 |
driveRelief(CTRL_DT); // kinematic terrain, written before the physics steps
|
| 683 |
// Settle phase: ctrl frozen on the pose held at the fall (approximates
|
|
@@ -694,6 +838,7 @@ async function boot({ scene, camera, renderer }) {
|
|
| 694 |
applyGrabForce(); // mouse perturbation, fresh velocity every substep
|
| 695 |
mujoco.mj_step(model, data);
|
| 696 |
}
|
|
|
|
| 697 |
|
| 698 |
const death = poseIsDead();
|
| 699 |
if (death === "exploded") {
|
|
@@ -925,7 +1070,7 @@ async function boot({ scene, camera, renderer }) {
|
|
| 925 |
loco = name;
|
| 926 |
scene.remove(rig.placer);
|
| 927 |
({ model, data, rig, trunkGroup, qposAdr, dofAdr, gyroAdr, trunkId,
|
| 928 |
-
standKeyId, ballQposAdr, ballDofAdr, extraJoints } = L);
|
| 929 |
// The rig may have been built (or last shown) under another colourway.
|
| 930 |
applyVariant(rig, currentVariant);
|
| 931 |
scene.add(rig.placer);
|
|
@@ -992,8 +1137,29 @@ async function boot({ scene, camera, renderer }) {
|
|
| 992 |
if (!v && ball && !ballActive) spawnBall({ fromQueue: true });
|
| 993 |
},
|
| 994 |
flashReset: () => {},
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 995 |
});
|
| 996 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 997 |
const { group: ballGroup, mesh: ballMesh } = createBallVisual(renderer);
|
| 998 |
scene.add(ballGroup);
|
| 999 |
ball = createBallActor({
|
|
@@ -1291,19 +1457,12 @@ async function boot({ scene, camera, renderer }) {
|
|
| 1291 |
let padJaw = 0;
|
| 1292 |
const CHIRP_TAKES = "abcdefghijkl";
|
| 1293 |
const VOICE_BANK = { classic: "duck1", charcoal: "duck2", purple: "duck3", blue: "duck4" };
|
| 1294 |
-
const chirpCache = new Map();
|
| 1295 |
function playChirp() {
|
| 1296 |
const bank = VOICE_BANK[currentVariant] ?? "duck1";
|
| 1297 |
const take = CHIRP_TAKES[(Math.random() * CHIRP_TAKES.length) | 0];
|
| 1298 |
-
|
| 1299 |
-
|
| 1300 |
-
|
| 1301 |
-
a = new Audio(url);
|
| 1302 |
-
a.volume = 0.7;
|
| 1303 |
-
chirpCache.set(url, a);
|
| 1304 |
-
}
|
| 1305 |
-
a.currentTime = 0;
|
| 1306 |
-
a.play().catch(() => {});
|
| 1307 |
}
|
| 1308 |
const quackLoud = () => {
|
| 1309 |
quackAt = performance.now();
|
|
@@ -1385,7 +1544,7 @@ async function boot({ scene, camera, renderer }) {
|
|
| 1385 |
}
|
| 1386 |
async function startWheee() {
|
| 1387 |
stopWheee({ silent: true }); // a re-press replaces the current ride
|
| 1388 |
-
wheeeCtx ??=
|
| 1389 |
if (wheeeCtx.state === "suspended") wheeeCtx.resume().catch(() => {});
|
| 1390 |
const bank = VOICE_BANK[currentVariant] ?? "duck1";
|
| 1391 |
const take = WHEEE_TAKES[(Math.random() * WHEEE_TAKES.length) | 0];
|
|
@@ -1399,7 +1558,7 @@ async function boot({ scene, camera, renderer }) {
|
|
| 1399 |
}
|
| 1400 |
if (wheeeRide !== ride) return; // released (or replaced) during decode
|
| 1401 |
const gain = wheeeCtx.createGain();
|
| 1402 |
-
gain.connect(
|
| 1403 |
const t0 = wheeeCtx.currentTime + 0.02;
|
| 1404 |
// The loop is steady-state audio (no authored attack): a ~20 ms fade-in
|
| 1405 |
// makes a clean note onset instead of a click. Constant gain after that.
|
|
@@ -1548,6 +1707,14 @@ async function boot({ scene, camera, renderer }) {
|
|
| 1548 |
syncRig();
|
| 1549 |
syncJaw();
|
| 1550 |
ghosts?.update();
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1551 |
controls.update();
|
| 1552 |
updateChaseCam();
|
| 1553 |
ceremony.drive();
|
|
|
|
| 48 |
import { TouchSource } from "./controls/touch.js";
|
| 49 |
import * as fx from "./fx/fx-wireframe.js";
|
| 50 |
import { createCeremony, CAM_RESET_S } from "./ceremony.js";
|
| 51 |
+
import {
|
| 52 |
+
audioCtx, busNode, preloadSfx, playSfx, playUrl, updateListener,
|
| 53 |
+
createEmitter, startAmbient, setAmbientDucked, playEntranceSweep,
|
| 54 |
+
playPropSweep, playLineBlip, setRumble,
|
| 55 |
+
} from "./audio.js";
|
| 56 |
import { createBallActor } from "./ball-actor.js";
|
| 57 |
import { initGhosts } from "./ghosts.js";
|
| 58 |
import { makeInfiniteGrid, makeArenaWalls } from "./arena.js";
|
| 59 |
import { createBallVisual } from "./ball-visual.js";
|
| 60 |
import { useGame, gameApi, bootLine, bootNote, bootHalt } from "../store.js";
|
| 61 |
|
| 62 |
+
// Physics + inference runtimes are vendored npm dependencies (no CDN):
|
| 63 |
+
// everything visitors execute is built from package-lock-verified
|
| 64 |
+
// tarballs and served from the Space itself, closing the jsDelivr
|
| 65 |
+
// supply-chain surface. The .wasm binaries ride the bundle as hashed
|
| 66 |
+
// assets via Vite ?url imports; the JS modules stay dynamic imports so
|
| 67 |
+
// they land in their own lazy chunks like before.
|
| 68 |
+
import mujocoWasmUrl from "@mujoco/mujoco/mujoco.wasm?url";
|
| 69 |
+
import ortWasmUrl from "onnxruntime-web/ort-wasm-simd-threaded.wasm?url";
|
| 70 |
|
| 71 |
let bootStarted = false;
|
| 72 |
|
|
|
|
| 130 |
);
|
| 131 |
};
|
| 132 |
|
| 133 |
+
// ── Runtimes (vendored, lazy chunks) ─────────────────────────────────
|
| 134 |
const [{ default: loadMujocoFactory }, ort] = await traced(
|
| 135 |
"RUNTIME MODULES",
|
| 136 |
Promise.all([
|
| 137 |
+
import("@mujoco/mujoco"),
|
| 138 |
+
// wasm-only build: the sessions only ever use the "wasm" execution
|
| 139 |
+
// provider, and the default entry would emit the 26 MB WebGPU (jsep)
|
| 140 |
+
// wasm into the dist for nothing.
|
| 141 |
+
import("onnxruntime-web/wasm"),
|
| 142 |
]),
|
| 143 |
);
|
| 144 |
+
// The bundler build embeds its JS loader; only the .wasm binary is
|
| 145 |
+
// fetched at runtime, from our own hashed asset.
|
| 146 |
+
ort.env.wasm.wasmPaths = { wasm: ortWasmUrl };
|
| 147 |
ort.env.wasm.numThreads = 1; // static hosting sends no COOP/COEP headers
|
| 148 |
|
| 149 |
// ── MJCF preparation ────────────────────────────────────────────────
|
|
|
|
| 255 |
|
| 256 |
// ── Boot physics + policy in parallel with the render rig ────────────
|
| 257 |
const [mujoco, { xml, meshFiles }, k] = await Promise.all([
|
| 258 |
+
traced("MUJOCO WASM", loadMujocoFactory({
|
| 259 |
+
// Emscripten sidecar resolution: point at the Vite-emitted asset
|
| 260 |
+
// instead of a path relative to the module's own URL.
|
| 261 |
+
locateFile: (p) => (p.endsWith(".wasm") ? mujocoWasmUrl : p),
|
| 262 |
+
})),
|
| 263 |
traced("PHYSICS MJCF", buildPhysicsXml("robot_allcollisions.xml")),
|
| 264 |
traced("KINEMATICS", loadKinematics(`${MODEL_DIR}/kinematics.json`)),
|
| 265 |
]);
|
|
|
|
| 364 |
standKeyId: mujoco.mj_name2id(model, mujoco.mjtObj.mjOBJ_KEY.value, "STAND"),
|
| 365 |
ballQposAdr: model.jnt("ball_freejoint").qposadr,
|
| 366 |
ballDofAdr: model.jnt("ball_freejoint").dofadr,
|
| 367 |
+
// Foot bodies for the footstep audio heuristic (-1 when a variant
|
| 368 |
+
// has no ankles, e.g. if a future model renames them).
|
| 369 |
+
ankleIds: ["ankle_left", "ankle_right"].map(
|
| 370 |
+
(n) => mujoco.mj_name2id(model, mujoco.mjtObj.mjOBJ_BODY.value, n)),
|
| 371 |
// Unactuated hinges (the roller variant's 4 passive wheels): not in
|
| 372 |
// the obs or ctrl, but synced to the render rig so the wheels spin.
|
| 373 |
extraJoints: kin.bodies
|
|
|
|
| 376 |
};
|
| 377 |
}
|
| 378 |
// Active-variant address block, swapped wholesale by activateLoco.
|
| 379 |
+
let { qposAdr, dofAdr, gyroAdr, trunkId, standKeyId, ballQposAdr, ballDofAdr, extraJoints,
|
| 380 |
+
ankleIds } = resolveAddrs(model, k);
|
| 381 |
|
| 382 |
// Locomotion variants stay resident once built (model + data + rig +
|
| 383 |
// addresses); legs is registered when its render rig resolves below.
|
|
|
|
| 698 |
return null;
|
| 699 |
}
|
| 700 |
|
| 701 |
+
// ── Sim-driven audio (footsteps / roller rumble / ball impacts) ──────
|
| 702 |
+
// Footsteps use a per-foot height heuristic instead of MuJoCo contacts
|
| 703 |
+
// (the WASM bindings expose no contact array): each ankle's height
|
| 704 |
+
// RELATIVE to the lower ankle (the planted foot approximates the local
|
| 705 |
+
// ground, so the measure self-calibrates on the relief terrain), with
|
| 706 |
+
// lift/contact hysteresis and a per-foot debounce. Tap gain scales with
|
| 707 |
+
// the landing speed. Rollers get a speed-following rumble loop instead,
|
| 708 |
+
// and the ball thumps on velocity deltas between control steps.
|
| 709 |
+
const STEP_LIFT = 0.012; // m above the planted foot = foot in swing
|
| 710 |
+
const STEP_CONTACT = 0.005; // m: dropping below this while in swing = step
|
| 711 |
+
const STEP_DEBOUNCE_MS = 130;
|
| 712 |
+
const stepFeet = [
|
| 713 |
+
{ air: false, prevZ: 0, lastAt: 0 },
|
| 714 |
+
{ air: false, prevZ: 0, lastAt: 0 },
|
| 715 |
+
];
|
| 716 |
+
const duckEmitter = createEmitter({ refDistance: 0.5 });
|
| 717 |
+
const ballEmitter = createEmitter({ refDistance: 0.5 });
|
| 718 |
+
const ballPrevV = [0, 0, 0];
|
| 719 |
+
let ballPrevValid = false;
|
| 720 |
+
let ballThumpAt = 0;
|
| 721 |
+
// Body bumps: trunk velocity deltas gated on obstacle proximity (walls
|
| 722 |
+
// or prop collider footprints) so the walking gait's own accelerations
|
| 723 |
+
// (comparable in magnitude to a slow wall hit) never false-trigger.
|
| 724 |
+
// Duller than the ball thumps: same samples pitched way down.
|
| 725 |
+
const BUMP_DV = 0.35; // m/s per control step
|
| 726 |
+
const BUMP_DEBOUNCE_MS = 300; // rubbing a wall must not machine-gun
|
| 727 |
+
const BUMP_WALL_PAD = 0.16; // trunk half-width-ish reach to a wall
|
| 728 |
+
const bumpZones = propColliders().map((c) => {
|
| 729 |
+
const [px, py] = c.pos.split(" ").map(Number);
|
| 730 |
+
const [sx, sy] = c.size.split(" ").map(Number);
|
| 731 |
+
return { x: px, y: py, r: Math.hypot(sx, sy) + 0.14 };
|
| 732 |
+
});
|
| 733 |
+
const bumpPrevV = [0, 0, 0];
|
| 734 |
+
let bumpPrevValid = false;
|
| 735 |
+
let bumpAt = 0;
|
| 736 |
+
function nearObstacle(x, y) {
|
| 737 |
+
if (Math.max(Math.abs(x), Math.abs(y)) > ARENA_HALF - BUMP_WALL_PAD) return true;
|
| 738 |
+
for (const z of bumpZones) {
|
| 739 |
+
if (Math.hypot(x - z.x, y - z.y) < z.r) return true;
|
| 740 |
+
}
|
| 741 |
+
return false;
|
| 742 |
+
}
|
| 743 |
+
|
| 744 |
+
function stepAudioSim() {
|
| 745 |
+
const now = performance.now();
|
| 746 |
+
if (loco === "legs" && !inputLocked && ankleIds[0] >= 0 && ankleIds[1] >= 0) {
|
| 747 |
+
const xpos = data.xpos;
|
| 748 |
+
const zL = xpos[ankleIds[0] * 3 + 2];
|
| 749 |
+
const zR = xpos[ankleIds[1] * 3 + 2];
|
| 750 |
+
const ground = Math.min(zL, zR);
|
| 751 |
+
for (let i = 0; i < 2; i++) {
|
| 752 |
+
const f = stepFeet[i];
|
| 753 |
+
const z = i === 0 ? zL : zR;
|
| 754 |
+
const rel = z - ground;
|
| 755 |
+
const vz = (z - f.prevZ) / CTRL_DT;
|
| 756 |
+
f.prevZ = z;
|
| 757 |
+
if (rel > STEP_LIFT) {
|
| 758 |
+
f.air = true;
|
| 759 |
+
} else if (f.air && rel < STEP_CONTACT && vz < -0.02 &&
|
| 760 |
+
now - f.lastAt > STEP_DEBOUNCE_MS) {
|
| 761 |
+
f.air = false;
|
| 762 |
+
f.lastAt = now;
|
| 763 |
+
const u = Math.min(1, Math.abs(vz) / 0.35);
|
| 764 |
+
playSfx("step", {
|
| 765 |
+
gain: 0.12 + 0.14 * u,
|
| 766 |
+
rate: 0.9 + Math.random() * 0.25,
|
| 767 |
+
out: duckEmitter.node,
|
| 768 |
+
});
|
| 769 |
+
}
|
| 770 |
+
}
|
| 771 |
+
}
|
| 772 |
+
// Roller rumble: gain and slight pitch follow ground speed.
|
| 773 |
+
const speed = Math.hypot(data.qvel[0], data.qvel[1]);
|
| 774 |
+
setRumble(
|
| 775 |
+
loco === "rollers" && !inputLocked ? Math.min(1, speed / 0.5) : 0,
|
| 776 |
+
duckEmitter,
|
| 777 |
+
);
|
| 778 |
+
// Body bumps: trunk |dv| against a nearby wall/prop. The proximity
|
| 779 |
+
// gate keeps gait/kick jerks (which rival slow wall hits) silent.
|
| 780 |
+
if (!inputLocked) {
|
| 781 |
+
const v = data.qvel;
|
| 782 |
+
if (bumpPrevValid) {
|
| 783 |
+
const dv = Math.hypot(v[0] - bumpPrevV[0], v[1] - bumpPrevV[1], v[2] - bumpPrevV[2]);
|
| 784 |
+
if (dv > BUMP_DV && now - bumpAt > BUMP_DEBOUNCE_MS &&
|
| 785 |
+
nearObstacle(data.qpos[0], data.qpos[1])) {
|
| 786 |
+
bumpAt = now;
|
| 787 |
+
const u = Math.min(1, (dv - BUMP_DV) / 1.5);
|
| 788 |
+
playSfx("thump", {
|
| 789 |
+
gain: 0.12 + 0.3 * u,
|
| 790 |
+
rate: 0.55 + 0.15 * u + Math.random() * 0.06, // way below the ball's range
|
| 791 |
+
out: duckEmitter.node,
|
| 792 |
+
});
|
| 793 |
+
}
|
| 794 |
+
}
|
| 795 |
+
bumpPrevV[0] = v[0]; bumpPrevV[1] = v[1]; bumpPrevV[2] = v[2];
|
| 796 |
+
bumpPrevValid = true;
|
| 797 |
+
} else {
|
| 798 |
+
bumpPrevValid = false;
|
| 799 |
+
}
|
| 800 |
+
// Ball impacts: |dv| between control steps. Gravity alone accounts for
|
| 801 |
+
// ~0.2 m/s per 20 ms step; the 0.5 threshold clears it and rolling noise.
|
| 802 |
+
if (ballActive) {
|
| 803 |
+
const v = data.qvel;
|
| 804 |
+
const b = ballDofAdr;
|
| 805 |
+
if (ballPrevValid) {
|
| 806 |
+
const dv = Math.hypot(
|
| 807 |
+
v[b] - ballPrevV[0], v[b + 1] - ballPrevV[1], v[b + 2] - ballPrevV[2]);
|
| 808 |
+
if (dv > 0.5 && now - ballThumpAt > 90) {
|
| 809 |
+
ballThumpAt = now;
|
| 810 |
+
const u = Math.min(1, (dv - 0.5) / 3.5); // full at kick-grade hits
|
| 811 |
+
playSfx("thump", {
|
| 812 |
+
gain: 0.14 + 0.4 * u,
|
| 813 |
+
rate: 1.25 - 0.45 * u + Math.random() * 0.08,
|
| 814 |
+
out: ballEmitter.node,
|
| 815 |
+
});
|
| 816 |
+
}
|
| 817 |
+
}
|
| 818 |
+
ballPrevV[0] = v[b]; ballPrevV[1] = v[b + 1]; ballPrevV[2] = v[b + 2];
|
| 819 |
+
ballPrevValid = true;
|
| 820 |
+
} else {
|
| 821 |
+
ballPrevValid = false;
|
| 822 |
+
}
|
| 823 |
+
}
|
| 824 |
+
|
| 825 |
async function controlStep() {
|
| 826 |
driveRelief(CTRL_DT); // kinematic terrain, written before the physics steps
|
| 827 |
// Settle phase: ctrl frozen on the pose held at the fall (approximates
|
|
|
|
| 838 |
applyGrabForce(); // mouse perturbation, fresh velocity every substep
|
| 839 |
mujoco.mj_step(model, data);
|
| 840 |
}
|
| 841 |
+
stepAudioSim(); // footsteps / rumble / ball thumps off the fresh state
|
| 842 |
|
| 843 |
const death = poseIsDead();
|
| 844 |
if (death === "exploded") {
|
|
|
|
| 1070 |
loco = name;
|
| 1071 |
scene.remove(rig.placer);
|
| 1072 |
({ model, data, rig, trunkGroup, qposAdr, dofAdr, gyroAdr, trunkId,
|
| 1073 |
+
standKeyId, ballQposAdr, ballDofAdr, extraJoints, ankleIds } = L);
|
| 1074 |
// The rig may have been built (or last shown) under another colourway.
|
| 1075 |
applyVariant(rig, currentVariant);
|
| 1076 |
scene.add(rig.placer);
|
|
|
|
| 1137 |
if (!v && ball && !ballActive) spawnBall({ fromQueue: true });
|
| 1138 |
},
|
| 1139 |
flashReset: () => {},
|
| 1140 |
+
// Audio twins of the wireframe materialize FX (entrance and respawns):
|
| 1141 |
+
// the duck's hero sweep spans the FX's exact duration and follows its
|
| 1142 |
+
// ease-out; each prop's scan gets its own smaller, size-pitched sweep
|
| 1143 |
+
// the frame it starts; each arena line drawing in gets a tiny blip.
|
| 1144 |
+
onScanCue: (durS) => playEntranceSweep(durS),
|
| 1145 |
+
onPropCue: (durS) => playPropSweep(durS),
|
| 1146 |
+
onLineCue: (u) => playLineBlip(u),
|
| 1147 |
});
|
| 1148 |
|
| 1149 |
+
// ── Ambient bed lifecycle ─────────────────────────────────────────────
|
| 1150 |
+
// The Waddle-in click latches `entered` and doubles as the unlock
|
| 1151 |
+
// gesture; the hum starts there and ducks whenever the pause/title
|
| 1152 |
+
// overlay comes back up. fireImmediately covers ?boot=1 (already
|
| 1153 |
+
// entered by the time the game boots).
|
| 1154 |
+
useGame.subscribe((s) => s.entered, (entered) => {
|
| 1155 |
+
if (!entered) return;
|
| 1156 |
+
audioCtx();
|
| 1157 |
+
preloadSfx();
|
| 1158 |
+
startAmbient();
|
| 1159 |
+
}, { fireImmediately: true });
|
| 1160 |
+
useGame.subscribe((s) => s.menuOpen, (open) => setAmbientDucked(open),
|
| 1161 |
+
{ fireImmediately: true });
|
| 1162 |
+
|
| 1163 |
const { group: ballGroup, mesh: ballMesh } = createBallVisual(renderer);
|
| 1164 |
scene.add(ballGroup);
|
| 1165 |
ball = createBallActor({
|
|
|
|
| 1457 |
let padJaw = 0;
|
| 1458 |
const CHIRP_TAKES = "abcdefghijkl";
|
| 1459 |
const VOICE_BANK = { classic: "duck1", charcoal: "duck2", purple: "duck3", blue: "duck4" };
|
|
|
|
| 1460 |
function playChirp() {
|
| 1461 |
const bank = VOICE_BANK[currentVariant] ?? "duck1";
|
| 1462 |
const take = CHIRP_TAKES[(Math.random() * CHIRP_TAKES.length) | 0];
|
| 1463 |
+
// Decoded through the shared context on the voice bus (used to be a
|
| 1464 |
+
// bare HTMLAudio element outside the master gain).
|
| 1465 |
+
playUrl(signed(`./assets/voices/${bank}/chirp_${take}.wav`), { gain: 0.7 });
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1466 |
}
|
| 1467 |
const quackLoud = () => {
|
| 1468 |
quackAt = performance.now();
|
|
|
|
| 1544 |
}
|
| 1545 |
async function startWheee() {
|
| 1546 |
stopWheee({ silent: true }); // a re-press replaces the current ride
|
| 1547 |
+
wheeeCtx ??= audioCtx(); // shared game context, ride lands on the voice bus
|
| 1548 |
if (wheeeCtx.state === "suspended") wheeeCtx.resume().catch(() => {});
|
| 1549 |
const bank = VOICE_BANK[currentVariant] ?? "duck1";
|
| 1550 |
const take = WHEEE_TAKES[(Math.random() * WHEEE_TAKES.length) | 0];
|
|
|
|
| 1558 |
}
|
| 1559 |
if (wheeeRide !== ride) return; // released (or replaced) during decode
|
| 1560 |
const gain = wheeeCtx.createGain();
|
| 1561 |
+
gain.connect(busNode("voice"));
|
| 1562 |
const t0 = wheeeCtx.currentTime + 0.02;
|
| 1563 |
// The loop is steady-state audio (no authored attack): a ~20 ms fade-in
|
| 1564 |
// makes a clean note onset instead of a click. Constant gain after that.
|
|
|
|
| 1707 |
syncRig();
|
| 1708 |
syncJaw();
|
| 1709 |
ghosts?.update();
|
| 1710 |
+
// Spatial audio follows the movers: listener on the camera, emitters
|
| 1711 |
+
// on the duck trunk and the ball (MJCF Z-up -> three Y-up).
|
| 1712 |
+
updateListener(camera);
|
| 1713 |
+
duckEmitter.setPosition(data.qpos[0], data.qpos[2], -data.qpos[1]);
|
| 1714 |
+
if (ballActive) {
|
| 1715 |
+
const q = data.qpos;
|
| 1716 |
+
ballEmitter.setPosition(q[ballQposAdr], q[ballQposAdr + 2], -q[ballQposAdr + 1]);
|
| 1717 |
+
}
|
| 1718 |
controls.update();
|
| 1719 |
updateChaseCam();
|
| 1720 |
ceremony.drive();
|
|
@@ -9,9 +9,23 @@
|
|
| 9 |
// on the private Space those need the ?__sign JWT that only rl.js knows
|
| 10 |
// how to append, so all rig helpers arrive through init parameters.
|
| 11 |
|
| 12 |
-
const TRYSTERO_URL = "https://esm.run/trystero@0.25.3/nostr";
|
| 13 |
const APP_ID = "microduck-sandbox";
|
| 14 |
const ROOM = "lobby";
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 15 |
const MAX_GHOSTS = 3;
|
| 16 |
const SEND_HZ = 15;
|
| 17 |
const GHOST_OPACITY = 0.35;
|
|
@@ -73,8 +87,10 @@ export async function initGhosts(env) {
|
|
| 73 |
};
|
| 74 |
let room;
|
| 75 |
try {
|
| 76 |
-
|
| 77 |
-
|
|
|
|
|
|
|
| 78 |
} catch (e) {
|
| 79 |
console.warn("ghosts disabled (signaling unavailable):", e);
|
| 80 |
return noop;
|
|
|
|
| 9 |
// on the private Space those need the ?__sign JWT that only rl.js knows
|
| 10 |
// how to append, so all rig helpers arrive through init parameters.
|
| 11 |
|
|
|
|
| 12 |
const APP_ID = "microduck-sandbox";
|
| 13 |
const ROOM = "lobby";
|
| 14 |
+
// Explicit relay list instead of Trystero's default appId-seeded pick.
|
| 15 |
+
// The deterministic subset that "microduck-sandbox" draws from the default
|
| 16 |
+
// pool rotted away (2026-09 audit: 3 relays dead, 1 rejecting ephemeral
|
| 17 |
+
// events, 1 rate-limiting), which silently killed ghost discovery in prod.
|
| 18 |
+
// These were verified live to accept AND route Trystero's ephemeral
|
| 19 |
+
// events; the warnOnRelayFailure path logs any that decay later.
|
| 20 |
+
const RELAY_URLS = [
|
| 21 |
+
"wss://relay.damus.io",
|
| 22 |
+
"wss://nos.lol",
|
| 23 |
+
"wss://nostr.tegila.com.br",
|
| 24 |
+
"wss://staging.yabu.me",
|
| 25 |
+
"wss://social.amanah.eblessing.co",
|
| 26 |
+
"wss://relay-rpi.edufeed.org",
|
| 27 |
+
"wss://strfry.shock.network",
|
| 28 |
+
];
|
| 29 |
const MAX_GHOSTS = 3;
|
| 30 |
const SEND_HZ = 15;
|
| 31 |
const GHOST_OPACITY = 0.35;
|
|
|
|
| 87 |
};
|
| 88 |
let room;
|
| 89 |
try {
|
| 90 |
+
// Vendored npm dep (was esm.run); dynamic so it stays a lazy chunk
|
| 91 |
+
// loaded only when the ghost session actually starts.
|
| 92 |
+
const { joinRoom } = await import("trystero/nostr");
|
| 93 |
+
room = joinRoom({ appId: APP_ID, relayConfig: { urls: RELAY_URLS } }, ROOM);
|
| 94 |
} catch (e) {
|
| 95 |
console.warn("ghosts disabled (signaling unavailable):", e);
|
| 96 |
return noop;
|
|
@@ -0,0 +1,60 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
/* eslint-disable */
|
| 2 |
+
// ZzFX - Zuper Zmall Zound Zynth - v1.3.2 by Frank Force
|
| 3 |
+
// https://github.com/KilledByAPixel/ZzFX
|
| 4 |
+
//
|
| 5 |
+
// MIT License - Copyright (c) 2019 Frank Force
|
| 6 |
+
// Permission is hereby granted, free of charge, to any person obtaining a
|
| 7 |
+
// copy of this software and associated documentation files (the "Software"),
|
| 8 |
+
// to deal in the Software without restriction, including without limitation
|
| 9 |
+
// the rights to use, copy, modify, merge, publish, distribute, sublicense,
|
| 10 |
+
// and/or sell copies of the Software. The above copyright notice and this
|
| 11 |
+
// permission notice shall be included in all copies or substantial portions
|
| 12 |
+
// of the Software. THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY
|
| 13 |
+
// KIND.
|
| 14 |
+
//
|
| 15 |
+
// Local adaptation (soundboard experiment): the upstream ZzFXMicro build
|
| 16 |
+
// creates its own AudioContext and plays straight to the hardware output.
|
| 17 |
+
// This build splits synthesis (zzfxG, byte-for-byte the upstream v1.3.2
|
| 18 |
+
// sample generator) from playback (zzfxP), and playback goes through a
|
| 19 |
+
// caller-provided AudioContext + destination node, so candidate sounds ride
|
| 20 |
+
// the game's shared master gain / sfx bus like the runtime sounds do.
|
| 21 |
+
|
| 22 |
+
export const zzfxR = 44100; // sample rate
|
| 23 |
+
export const zzfxV = 0.3; // default volume scale
|
| 24 |
+
|
| 25 |
+
// Generate samples for a ZzFX sound. Parameter order:
|
| 26 |
+
// volume, randomness, frequency, attack, sustain, release, shape, shapeCurve,
|
| 27 |
+
// slide, deltaSlide, pitchJump, pitchJumpTime, repeatTime, noise, modulation,
|
| 28 |
+
// bitCrush, delay, sustainVolume, decay, tremolo, filter
|
| 29 |
+
export const zzfxG = (p=1,k=.05,b=220,e=0,r=0,t=.1,q=0,D=1,u=0,y=0,v=0,z=0,l=0,E=0,A=0,F=0,c=0,w=1,m=0,B=0,N=0)=>{
|
| 30 |
+
let M=Math,d=2*M.PI,R=zzfxR,G=u*=500*d/R/R,C=b*=(1-k+2*k*M.random(k=[]))*d/R,
|
| 31 |
+
g=0,H=0,a=0,n=1,I=0,J=0,f=0,h=N<0?-1:1,x=d*h*N*2/R,L=M.cos(x),Z=M.sin,K=Z(x)/4,O=1+K,
|
| 32 |
+
X=-2*L/O,Y=(1-K)/O,P=(1+h*L)/2/O,Q=-(h+L)/O,S=P,T=0,U=0,V=0,W=0;
|
| 33 |
+
e=R*e+9;m*=R;r*=R;t*=R;c*=R;y*=500*d/R**3;A*=d/R;v*=d/R;z*=R;l=R*l|0;p*=zzfxV;
|
| 34 |
+
for(h=e+m+r+t+c|0;a<h;k[a++]=f*p)
|
| 35 |
+
++J%(100*F|0)||(
|
| 36 |
+
f=q?1<q?2<q?3<q?4<q?(g/d%1<D/2)*2-1:Z(g**3):M.max(M.min(M.tan(g),1),-1):1-(2*g/d%2+2)%2:1-4*M.abs(M.round(g/d)-g/d):Z(g),
|
| 37 |
+
f=(l?1-B+B*Z(d*a/l):1)*(4<q?f:(f<0?-1:1)*M.abs(f)**D)*(a<e?a/e:a<e+m?1-(a-e)/m*(1-w):a<e+m+r?w:a<h-c?(h-a-c)/t*w:0),
|
| 38 |
+
f=c?f/2+(c>a?0:(a<h-c?1:(h-a)/c)*k[a-c|0]/2/p):f,
|
| 39 |
+
N?f=W=S*T+Q*(T=U)+P*(U=f)-Y*V-X*(V=W):0
|
| 40 |
+
),
|
| 41 |
+
x=(b+=u+=y)*M.cos(A*H++),
|
| 42 |
+
g+=x+x*E*Z(a**5),
|
| 43 |
+
n&&++n>z&&(b+=v,C+=v,n=0),
|
| 44 |
+
!l||++I%l||(b=C,u=G,n=n||1);
|
| 45 |
+
return k;
|
| 46 |
+
};
|
| 47 |
+
|
| 48 |
+
// Play generated samples through the given context/destination.
|
| 49 |
+
export function zzfxP(ctx, destination, samples) {
|
| 50 |
+
const buf = ctx.createBuffer(1, samples.length, zzfxR);
|
| 51 |
+
buf.getChannelData(0).set(samples);
|
| 52 |
+
const src = ctx.createBufferSource();
|
| 53 |
+
src.buffer = buf;
|
| 54 |
+
src.connect(destination);
|
| 55 |
+
src.start();
|
| 56 |
+
return src;
|
| 57 |
+
}
|
| 58 |
+
|
| 59 |
+
// Convenience: synthesize + play in one call.
|
| 60 |
+
export const zzfx = (ctx, destination, ...params) => zzfxP(ctx, destination, zzfxG(...params));
|
|
@@ -1,8 +1,14 @@
|
|
|
|
|
| 1 |
import { createRoot } from "react-dom/client";
|
| 2 |
import { ThemeProvider } from "@mui/material/styles";
|
| 3 |
import CssBaseline from "@mui/material/CssBaseline";
|
| 4 |
import { theme } from "./theme.js";
|
| 5 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 6 |
|
| 7 |
// Build tag: bump to change the bundle's content hash, e.g. to bust a stale
|
| 8 |
// edge-cached asset URL on the HF Space.
|
|
@@ -14,6 +20,8 @@ console.info(`Microduck build ${BUILD_TAG}`);
|
|
| 14 |
createRoot(document.getElementById("root")).render(
|
| 15 |
<ThemeProvider theme={theme}>
|
| 16 |
<CssBaseline />
|
| 17 |
-
<
|
|
|
|
|
|
|
| 18 |
</ThemeProvider>,
|
| 19 |
);
|
|
|
|
| 1 |
+
import { lazy, Suspense } from "react";
|
| 2 |
import { createRoot } from "react-dom/client";
|
| 3 |
import { ThemeProvider } from "@mui/material/styles";
|
| 4 |
import CssBaseline from "@mui/material/CssBaseline";
|
| 5 |
import { theme } from "./theme.js";
|
| 6 |
+
|
| 7 |
+
// ?soundboard=1: standalone audio A/B experiment page instead of the game
|
| 8 |
+
// (lazy on both sides so the soundboard never pulls the game bundle in).
|
| 9 |
+
const App = new URLSearchParams(location.search).get("soundboard") === "1"
|
| 10 |
+
? lazy(() => import("./ui/Soundboard.jsx"))
|
| 11 |
+
: lazy(() => import("./App.jsx"));
|
| 12 |
|
| 13 |
// Build tag: bump to change the bundle's content hash, e.g. to bust a stale
|
| 14 |
// edge-cached asset URL on the HF Space.
|
|
|
|
| 20 |
createRoot(document.getElementById("root")).render(
|
| 21 |
<ThemeProvider theme={theme}>
|
| 22 |
<CssBaseline />
|
| 23 |
+
<Suspense fallback={null}>
|
| 24 |
+
<App />
|
| 25 |
+
</Suspense>
|
| 26 |
</ThemeProvider>,
|
| 27 |
);
|
|
@@ -9,6 +9,7 @@ import { useEffect, useRef, useState } from "react";
|
|
| 9 |
import Box from "@mui/material/Box";
|
| 10 |
import { keyframes } from "@mui/material/styles";
|
| 11 |
import { useGame, gameApi, bootLog } from "../store.js";
|
|
|
|
| 12 |
import { INK, MONO } from "../theme.js";
|
| 13 |
|
| 14 |
const postBlink = keyframes`
|
|
@@ -48,6 +49,10 @@ export default function BiosOverlay() {
|
|
| 48 |
const store = useGame.getState;
|
| 49 |
setVisible(true);
|
| 50 |
useGame.setState({ biosVisible: true });
|
|
|
|
|
|
|
|
|
|
|
|
|
| 51 |
const postEl = postRef.current;
|
| 52 |
// Seeded LCG: the replay rhythm is random-feeling but identical on
|
| 53 |
// every load - real POST screens burst through most checks and stall
|
|
@@ -64,6 +69,7 @@ export default function BiosOverlay() {
|
|
| 64 |
if (entry.halt) el.className = "halt";
|
| 65 |
postEl.appendChild(el);
|
| 66 |
els.set(entry, el);
|
|
|
|
| 67 |
if (entry.status === null && !bootDone && !failed) {
|
| 68 |
// Honest mode: show the stage label and hold while it's really
|
| 69 |
// in flight, cursor blinking via CSS.
|
|
@@ -113,6 +119,7 @@ export default function BiosOverlay() {
|
|
| 113 |
const ready = document.createElement("div");
|
| 114 |
ready.textContent = "READY.";
|
| 115 |
postEl.appendChild(ready);
|
|
|
|
| 116 |
await sleep(500);
|
| 117 |
setOff(true);
|
| 118 |
// Wait out the 0.45 s opacity transition BEFORE cueing the draw-in:
|
|
|
|
| 9 |
import Box from "@mui/material/Box";
|
| 10 |
import { keyframes } from "@mui/material/styles";
|
| 11 |
import { useGame, gameApi, bootLog } from "../store.js";
|
| 12 |
+
import { playBiosBeep, playBiosTick } from "../game/audio.js";
|
| 13 |
import { INK, MONO } from "../theme.js";
|
| 14 |
|
| 15 |
const postBlink = keyframes`
|
|
|
|
| 49 |
const store = useGame.getState;
|
| 50 |
setVisible(true);
|
| 51 |
useGame.setState({ biosVisible: true });
|
| 52 |
+
// POST beep: the replay only starts after the enter click (the audio
|
| 53 |
+
// unlock gesture), and the synth no-ops if the context is still locked
|
| 54 |
+
// (gamepad-only entry).
|
| 55 |
+
playBiosBeep();
|
| 56 |
const postEl = postRef.current;
|
| 57 |
// Seeded LCG: the replay rhythm is random-feeling but identical on
|
| 58 |
// every load - real POST screens burst through most checks and stall
|
|
|
|
| 69 |
if (entry.halt) el.className = "halt";
|
| 70 |
postEl.appendChild(el);
|
| 71 |
els.set(entry, el);
|
| 72 |
+
playBiosTick(); // whisper-level teletype tick per printed line
|
| 73 |
if (entry.status === null && !bootDone && !failed) {
|
| 74 |
// Honest mode: show the stage label and hold while it's really
|
| 75 |
// in flight, cursor blinking via CSS.
|
|
|
|
| 119 |
const ready = document.createElement("div");
|
| 120 |
ready.textContent = "READY.";
|
| 121 |
postEl.appendChild(ready);
|
| 122 |
+
playBiosTick();
|
| 123 |
await sleep(500);
|
| 124 |
setOff(true);
|
| 125 |
// Wait out the 0.45 s opacity transition BEFORE cueing the draw-in:
|
|
@@ -19,6 +19,7 @@ import Box from "@mui/material/Box";
|
|
| 19 |
import { keyframes } from "@mui/material/styles";
|
| 20 |
import { useGame, gameApi } from "../store.js";
|
| 21 |
import { VARIANT_LABELS, VARIANT_SWATCH_HEX } from "../game/variants.js";
|
|
|
|
| 22 |
import { ORANGE, MONO } from "../theme.js";
|
| 23 |
import { ANTON, COMIC_INK, CREAM } from "./comic.jsx";
|
| 24 |
|
|
@@ -157,6 +158,34 @@ function HudPlate({ caption, captionSide = "left", captionFill = "cream", childr
|
|
| 157 |
);
|
| 158 |
}
|
| 159 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 160 |
function BackArrowIcon() {
|
| 161 |
return (
|
| 162 |
<Box
|
|
@@ -215,7 +244,7 @@ function BackButton() {
|
|
| 215 |
<Box
|
| 216 |
component="button"
|
| 217 |
type="button"
|
| 218 |
-
onClick={() => useGame.setState({ menuOpen: true })}
|
| 219 |
sx={{
|
| 220 |
...hudHitSx,
|
| 221 |
"&:hover": {
|
|
@@ -231,9 +260,12 @@ function BackButton() {
|
|
| 231 |
);
|
| 232 |
}
|
| 233 |
|
| 234 |
-
const SHOP_URL = "https://store.pollen-robotics.com/collections/microduck";
|
| 235 |
|
| 236 |
-
|
|
|
|
|
|
|
|
|
|
| 237 |
const locoWant = useGame((s) => s.locoWant);
|
| 238 |
const text = useScramble(locoWant === "rollers" ? "Pre-order pack" : "Pre-order");
|
| 239 |
return (
|
|
@@ -241,13 +273,14 @@ function PreorderButton() {
|
|
| 241 |
caption="Shop"
|
| 242 |
captionSide="right"
|
| 243 |
captionFill="orange"
|
| 244 |
-
sx={{ position: "fixed", top: "1.25rem", right: "1.5rem", zIndex: 10 }}
|
| 245 |
>
|
| 246 |
<Box
|
| 247 |
component="a"
|
| 248 |
href={SHOP_URL}
|
| 249 |
target="_blank"
|
| 250 |
rel="noopener noreferrer"
|
|
|
|
| 251 |
sx={{
|
| 252 |
...hudHitSx,
|
| 253 |
background: ORANGE,
|
|
@@ -301,7 +334,7 @@ function Quickbar() {
|
|
| 301 |
title={label}
|
| 302 |
aria-label={`${label} colours`}
|
| 303 |
aria-pressed={selected}
|
| 304 |
-
onClick={() => gameApi.setVariant?.(name)}
|
| 305 |
sx={{
|
| 306 |
appearance: "none",
|
| 307 |
width: CELL_H, // square cell inside frame + padding
|
|
@@ -351,7 +384,7 @@ function Quickbar() {
|
|
| 351 |
component="button"
|
| 352 |
type="button"
|
| 353 |
aria-pressed={selected}
|
| 354 |
-
onClick={() => gameApi.requestLoco?.(name)}
|
| 355 |
sx={{
|
| 356 |
...hudHitSx,
|
| 357 |
px: "1.05rem",
|
|
@@ -370,10 +403,49 @@ function Quickbar() {
|
|
| 370 |
})}
|
| 371 |
</Box>
|
| 372 |
</HudPlate>
|
|
|
|
|
|
|
|
|
|
| 373 |
</Box>
|
| 374 |
);
|
| 375 |
}
|
| 376 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 377 |
function Telemetry() {
|
| 378 |
const t = useGame((s) => s.telemetry);
|
| 379 |
const odo = t.odo < 1000 ? `${t.odo.toFixed(1)}M` : `${(t.odo / 1000).toFixed(2)}KM`;
|
|
|
|
| 19 |
import { keyframes } from "@mui/material/styles";
|
| 20 |
import { useGame, gameApi } from "../store.js";
|
| 21 |
import { VARIANT_LABELS, VARIANT_SWATCH_HEX } from "../game/variants.js";
|
| 22 |
+
import { uiClick, isMuted, setMuted } from "../game/audio.js";
|
| 23 |
import { ORANGE, MONO } from "../theme.js";
|
| 24 |
import { ANTON, COMIC_INK, CREAM } from "./comic.jsx";
|
| 25 |
|
|
|
|
| 158 |
);
|
| 159 |
}
|
| 160 |
|
| 161 |
+
// Speaker icon in the same square-stroke language as the back arrow:
|
| 162 |
+
// body + cone always, waves when live, a hard ink cross when muted.
|
| 163 |
+
function SpeakerIcon({ muted }) {
|
| 164 |
+
return (
|
| 165 |
+
<Box
|
| 166 |
+
component="svg"
|
| 167 |
+
viewBox="0 0 24 24"
|
| 168 |
+
aria-hidden
|
| 169 |
+
fill="none"
|
| 170 |
+
stroke="currentColor"
|
| 171 |
+
strokeWidth={2.25}
|
| 172 |
+
strokeLinecap="square"
|
| 173 |
+
strokeLinejoin="miter"
|
| 174 |
+
sx={{ width: "1.05em", height: "1.05em", display: "block", flex: "none" }}
|
| 175 |
+
>
|
| 176 |
+
<path d="M4 9h4l5-4v14l-5-4H4z" />
|
| 177 |
+
{muted ? (
|
| 178 |
+
<path d="M16 9l6 6M22 9l-6 6" />
|
| 179 |
+
) : (
|
| 180 |
+
<>
|
| 181 |
+
<path d="M16 9.5a3.5 3.5 0 0 1 0 5" />
|
| 182 |
+
<path d="M18.5 7a7 7 0 0 1 0 10" />
|
| 183 |
+
</>
|
| 184 |
+
)}
|
| 185 |
+
</Box>
|
| 186 |
+
);
|
| 187 |
+
}
|
| 188 |
+
|
| 189 |
function BackArrowIcon() {
|
| 190 |
return (
|
| 191 |
<Box
|
|
|
|
| 244 |
<Box
|
| 245 |
component="button"
|
| 246 |
type="button"
|
| 247 |
+
onClick={() => { uiClick(); useGame.setState({ menuOpen: true }); }}
|
| 248 |
sx={{
|
| 249 |
...hudHitSx,
|
| 250 |
"&:hover": {
|
|
|
|
| 260 |
);
|
| 261 |
}
|
| 262 |
|
| 263 |
+
export const SHOP_URL = "https://store.pollen-robotics.com/collections/microduck";
|
| 264 |
|
| 265 |
+
// Exported: the title/pause overlay renders the same plate in the same
|
| 266 |
+
// spot, so the pause fade reads as the button staying put, not two
|
| 267 |
+
// different buttons swapping.
|
| 268 |
+
export function PreorderButton({ sx }) {
|
| 269 |
const locoWant = useGame((s) => s.locoWant);
|
| 270 |
const text = useScramble(locoWant === "rollers" ? "Pre-order pack" : "Pre-order");
|
| 271 |
return (
|
|
|
|
| 273 |
caption="Shop"
|
| 274 |
captionSide="right"
|
| 275 |
captionFill="orange"
|
| 276 |
+
sx={{ position: "fixed", top: "1.25rem", right: "1.5rem", zIndex: 10, ...sx }}
|
| 277 |
>
|
| 278 |
<Box
|
| 279 |
component="a"
|
| 280 |
href={SHOP_URL}
|
| 281 |
target="_blank"
|
| 282 |
rel="noopener noreferrer"
|
| 283 |
+
onClick={() => uiClick()}
|
| 284 |
sx={{
|
| 285 |
...hudHitSx,
|
| 286 |
background: ORANGE,
|
|
|
|
| 334 |
title={label}
|
| 335 |
aria-label={`${label} colours`}
|
| 336 |
aria-pressed={selected}
|
| 337 |
+
onClick={() => { uiClick(); gameApi.setVariant?.(name); }}
|
| 338 |
sx={{
|
| 339 |
appearance: "none",
|
| 340 |
width: CELL_H, // square cell inside frame + padding
|
|
|
|
| 384 |
component="button"
|
| 385 |
type="button"
|
| 386 |
aria-pressed={selected}
|
| 387 |
+
onClick={() => { uiClick(); gameApi.requestLoco?.(name); }}
|
| 388 |
sx={{
|
| 389 |
...hudHitSx,
|
| 390 |
px: "1.05rem",
|
|
|
|
| 403 |
})}
|
| 404 |
</Box>
|
| 405 |
</HudPlate>
|
| 406 |
+
{/* Sound is fully disabled for now (SOUND_DISABLED in audio.js);
|
| 407 |
+
re-enable this toggle together with that flag. */}
|
| 408 |
+
{/* <SoundToggle /> */}
|
| 409 |
</Box>
|
| 410 |
);
|
| 411 |
}
|
| 412 |
|
| 413 |
+
// Mute / unmute panel: same cartouche language as the other groups. The
|
| 414 |
+
// toggle ramps audio.js's master gain (~50 ms) and persists; unmuting
|
| 415 |
+
// plays the HUD click as confirmation (muting stays silent, obviously).
|
| 416 |
+
function SoundToggle() {
|
| 417 |
+
const [muted, setMutedState] = useState(isMuted);
|
| 418 |
+
const toggle = () => {
|
| 419 |
+
const next = !muted;
|
| 420 |
+
setMuted(next);
|
| 421 |
+
setMutedState(next);
|
| 422 |
+
if (!next) uiClick();
|
| 423 |
+
};
|
| 424 |
+
return (
|
| 425 |
+
<HudPlate caption="Sound">
|
| 426 |
+
<Box
|
| 427 |
+
component="button"
|
| 428 |
+
type="button"
|
| 429 |
+
aria-label={muted ? "Unmute sound" : "Mute sound"}
|
| 430 |
+
aria-pressed={muted}
|
| 431 |
+
onClick={toggle}
|
| 432 |
+
sx={{
|
| 433 |
+
...hudHitSx,
|
| 434 |
+
px: "0.75rem",
|
| 435 |
+
color: muted ? "rgba(250, 248, 242, 0.5)" : CREAM,
|
| 436 |
+
"&:hover": {
|
| 437 |
+
color: muted ? CREAM : ORANGE,
|
| 438 |
+
background: "rgba(255, 122, 47, 0.12)",
|
| 439 |
+
},
|
| 440 |
+
"&:active": { filter: "brightness(0.92)" },
|
| 441 |
+
}}
|
| 442 |
+
>
|
| 443 |
+
<SpeakerIcon muted={muted} />
|
| 444 |
+
</Box>
|
| 445 |
+
</HudPlate>
|
| 446 |
+
);
|
| 447 |
+
}
|
| 448 |
+
|
| 449 |
function Telemetry() {
|
| 450 |
const t = useGame((s) => s.telemetry);
|
| 451 |
const odo = t.odo < 1000 ? `${t.odo.toFixed(1)}M` : `${(t.odo / 1000).toFixed(2)}KM`;
|
|
@@ -0,0 +1,350 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
// Title-menu duck stage: the simulated twin, sitting on the title screen
|
| 2 |
+
// like the vitrine landing's colour module - head aims at the pointer
|
| 3 |
+
// (idle glances at the camera when it's quiet), click/tap quacks. No
|
| 4 |
+
// physics, no policies: just the shared rig posed and micro-animated.
|
| 5 |
+
//
|
| 6 |
+
// Why a separate rig instead of showing the game scene: the boot
|
| 7 |
+
// dramaturgy keeps the in-game duck hidden until the BIOS readout cues
|
| 8 |
+
// the entrance scan-up, so the title screen needs its own duck. Geometry
|
| 9 |
+
// is free anyway - duck.js caches the GLB module-wide, so this rig and
|
| 10 |
+
// the game's share the same buffers.
|
| 11 |
+
//
|
| 12 |
+
// The stage is a lazy module singleton: built once on first open, then
|
| 13 |
+
// re-attached on every pause. The rAF loop only runs while attached.
|
| 14 |
+
import { useEffect, useRef } from "react";
|
| 15 |
+
import Box from "@mui/material/Box";
|
| 16 |
+
import * as THREE from "three";
|
| 17 |
+
import { RoomEnvironment } from "three/addons/environments/RoomEnvironment.js";
|
| 18 |
+
import {
|
| 19 |
+
loadKinematics, buildRig, setJoint, setJawOpen, applyPose,
|
| 20 |
+
SITTING_POSE, groundFullBody, MODEL_DIR,
|
| 21 |
+
} from "../game/duck.js";
|
| 22 |
+
import { VARIANTS, DEFAULT_VARIANT, materialHookFor, applyVariant } from "../game/variants.js";
|
| 23 |
+
import { createWireframeFx } from "../game/fx/fx-wireframe.js";
|
| 24 |
+
import { playUrl } from "../game/audio.js";
|
| 25 |
+
import { signed } from "../game/signed.js";
|
| 26 |
+
import { useGame } from "../store.js";
|
| 27 |
+
|
| 28 |
+
// Aim calibration, verbatim from the vitrine's duck3d/scene.js: positive
|
| 29 |
+
// head_pitch is head-down on the mjlab model, so screen-up subtracts.
|
| 30 |
+
const SIGN_YAW = 1;
|
| 31 |
+
const SIGN_PITCH = -1;
|
| 32 |
+
const SPREAD_YAW = 0.55; // rad of head yaw across half the stage width
|
| 33 |
+
const SPREAD_PITCH = 0.32; // rad of head pitch across half the stage height
|
| 34 |
+
|
| 35 |
+
// Quack envelope: openness eases toward a target instead of replaying a
|
| 36 |
+
// fixed curve, so clicking mid-quack never snaps the jaw backwards.
|
| 37 |
+
const QUACK_HOLD_S = 0.3;
|
| 38 |
+
const QUACK_ATTACK_TAU = 0.04;
|
| 39 |
+
const QUACK_RELEASE_TAU = 0.09;
|
| 40 |
+
|
| 41 |
+
// The vitrine's aim framing, loosened for the menu column: the duck fills
|
| 42 |
+
// ~2/3 of the frame height instead of edge to edge. The look height is
|
| 43 |
+
// tuned WITH the zoom to put the feet (y=0) ~18% above the frame's bottom
|
| 44 |
+
// edge - floating well clear of the shortcut strip.
|
| 45 |
+
const CAM_POS = [0.28, 0.2, 0.43];
|
| 46 |
+
const CAM_LOOK = [0, 0.122, 0];
|
| 47 |
+
const CAM_ZOOM = 0.78;
|
| 48 |
+
const FOV_DEG = 32;
|
| 49 |
+
|
| 50 |
+
// Same voice banks as the game's quack (game.js VOICE_BANK).
|
| 51 |
+
const VOICE_BANK = { classic: "duck1", charcoal: "duck2", purple: "duck3", blue: "duck4" };
|
| 52 |
+
const CHIRP_TAKES = "abcdefghijkl";
|
| 53 |
+
|
| 54 |
+
const clamp = (x, a, b) => Math.min(b, Math.max(a, x));
|
| 55 |
+
const rand = (a, b) => a + Math.random() * (b - a);
|
| 56 |
+
|
| 57 |
+
let stagePromise = null;
|
| 58 |
+
let liveStage = null;
|
| 59 |
+
let stageReady = false;
|
| 60 |
+
|
| 61 |
+
function getStage() {
|
| 62 |
+
if (!stagePromise) {
|
| 63 |
+
stagePromise = buildStage()
|
| 64 |
+
.then((s) => {
|
| 65 |
+
liveStage = s;
|
| 66 |
+
stageReady = true;
|
| 67 |
+
return s;
|
| 68 |
+
})
|
| 69 |
+
.catch((e) => {
|
| 70 |
+
// Still flip ready: the title screen must not spin forever when
|
| 71 |
+
// WebGL / assets fail - it just shows without the duck.
|
| 72 |
+
stageReady = true;
|
| 73 |
+
throw e;
|
| 74 |
+
});
|
| 75 |
+
}
|
| 76 |
+
return stagePromise;
|
| 77 |
+
}
|
| 78 |
+
|
| 79 |
+
// Title-screen loading gate: the menu holds a spinner until the stage's
|
| 80 |
+
// assets are in (kinematics + GLB + rig build), so the content reveal
|
| 81 |
+
// and the duck's entrance scan cue together instead of the duck popping
|
| 82 |
+
// into an already-settled screen.
|
| 83 |
+
export const preloadMenuDuck = () => getStage();
|
| 84 |
+
export const isMenuDuckReady = () => stageReady;
|
| 85 |
+
|
| 86 |
+
async function buildStage() {
|
| 87 |
+
const scene = new THREE.Scene();
|
| 88 |
+
const camera = new THREE.PerspectiveCamera(FOV_DEG, 1, 0.05, 20);
|
| 89 |
+
camera.position.set(
|
| 90 |
+
...CAM_LOOK.map((l, i) => l + (CAM_POS[i] - l) / CAM_ZOOM),
|
| 91 |
+
);
|
| 92 |
+
camera.lookAt(...CAM_LOOK);
|
| 93 |
+
|
| 94 |
+
const renderer = new THREE.WebGLRenderer({ antialias: true, alpha: true });
|
| 95 |
+
renderer.setClearColor(0x000000, 0);
|
| 96 |
+
renderer.setPixelRatio(Math.min(2, window.devicePixelRatio));
|
| 97 |
+
// Same output pipeline the variant palette was calibrated against.
|
| 98 |
+
renderer.toneMapping = THREE.ACESFilmicToneMapping;
|
| 99 |
+
const el = renderer.domElement;
|
| 100 |
+
el.style.position = "absolute";
|
| 101 |
+
el.style.inset = "0";
|
| 102 |
+
el.style.width = "100%";
|
| 103 |
+
el.style.height = "100%";
|
| 104 |
+
// Anti-flicker: invisible until the first frame has been presented.
|
| 105 |
+
el.style.opacity = "0";
|
| 106 |
+
el.style.transition = "opacity 0.45s ease";
|
| 107 |
+
|
| 108 |
+
const pmrem = new THREE.PMREMGenerator(renderer);
|
| 109 |
+
scene.environment = pmrem.fromScene(new RoomEnvironment()).texture;
|
| 110 |
+
scene.environmentIntensity = 0.25;
|
| 111 |
+
|
| 112 |
+
// Vitrine light rig: strong white key, soft fill, warm orange rim.
|
| 113 |
+
scene.add(new THREE.AmbientLight(0xffffff, 0.45));
|
| 114 |
+
const key = new THREE.DirectionalLight(0xffffff, 1.25);
|
| 115 |
+
key.position.set(2, 4, 2);
|
| 116 |
+
scene.add(key);
|
| 117 |
+
const fill = new THREE.DirectionalLight(0xffffff, 0.4);
|
| 118 |
+
fill.position.set(-2, 2, 1.5);
|
| 119 |
+
scene.add(fill);
|
| 120 |
+
const rim = new THREE.DirectionalLight(0xffb366, 0.7);
|
| 121 |
+
rim.position.set(0, 3, -2);
|
| 122 |
+
scene.add(rim);
|
| 123 |
+
|
| 124 |
+
const k = await loadKinematics(`${MODEL_DIR}/kinematics.json`);
|
| 125 |
+
let appliedVariant = useGame.getState().variant ?? DEFAULT_VARIANT;
|
| 126 |
+
if (!VARIANTS[appliedVariant]) appliedVariant = DEFAULT_VARIANT;
|
| 127 |
+
const rig = await buildRig(k, {
|
| 128 |
+
materialForMesh: materialHookFor(VARIANTS[appliedVariant]),
|
| 129 |
+
});
|
| 130 |
+
// Face the camera: MJCF forward is +X; the placer yaw turns it to +Z.
|
| 131 |
+
rig.placer.rotation.y = -Math.PI / 2;
|
| 132 |
+
scene.add(rig.placer);
|
| 133 |
+
applyPose(rig, SITTING_POSE);
|
| 134 |
+
groundFullBody(rig);
|
| 135 |
+
|
| 136 |
+
// Vitrine-style entrance: the duck materializes with the wireframe
|
| 137 |
+
// scan-up the first time the menu shows. Hidden (clip at floor level)
|
| 138 |
+
// until the stage is attached, then start() cues the scan; once done
|
| 139 |
+
// the fx restores the real materials and is dropped for good - pause
|
| 140 |
+
// reopens show the solid duck immediately.
|
| 141 |
+
//
|
| 142 |
+
// Shader pre-warm: the scan cycles through three material states, each
|
| 143 |
+
// compiling its GPU program on the frame it first renders - the plain
|
| 144 |
+
// PBR originals (restored when the scan finishes), the per-mesh clip
|
| 145 |
+
// clones (unique customProgramCacheKey each, so ~30 separate PBR
|
| 146 |
+
// compiles), and the wireframe ShaderMaterial (only drawn once the
|
| 147 |
+
// scan is running). Left alone, those compiles land mid-animation as
|
| 148 |
+
// driver-timed freezes. The canvas is still detached and invisible
|
| 149 |
+
// here, so render each state once now: solid first, then a mid-scan
|
| 150 |
+
// frame (wires on, clones clipping), then rewind to hidden.
|
| 151 |
+
renderer.render(scene, camera);
|
| 152 |
+
let entranceFx = createWireframeFx();
|
| 153 |
+
entranceFx.init({ THREE, scene, root: rig.placer, camera, renderer, hidden: true });
|
| 154 |
+
entranceFx.setProgress(0.5);
|
| 155 |
+
renderer.render(scene, camera);
|
| 156 |
+
entranceFx.setProgress(0);
|
| 157 |
+
let entranceStarted = false;
|
| 158 |
+
|
| 159 |
+
const headPivot = rig.joints.get("head_yaw").body;
|
| 160 |
+
const headWorld = new THREE.Vector3();
|
| 161 |
+
const camDir = new THREE.Vector3();
|
| 162 |
+
|
| 163 |
+
// ── Pointer → aim state ───────────────────────────────────────────────
|
| 164 |
+
const ndc = new THREE.Vector2(0, 0);
|
| 165 |
+
let mountEl = null;
|
| 166 |
+
let lastT = 0;
|
| 167 |
+
let lastMouseT = -Infinity;
|
| 168 |
+
let hasFocus = document.hasFocus();
|
| 169 |
+
let mouseInside = true;
|
| 170 |
+
let az = 0, el2 = 0;
|
| 171 |
+
let attention = 0;
|
| 172 |
+
const o1 = rand(0, 6.3), o2 = rand(0, 6.3);
|
| 173 |
+
let quackHoldUntil = -Infinity;
|
| 174 |
+
let quackOpen = 0;
|
| 175 |
+
|
| 176 |
+
const onPointerMove = (e) => {
|
| 177 |
+
if (!mountEl) return;
|
| 178 |
+
const r = mountEl.getBoundingClientRect();
|
| 179 |
+
if (!r.width || !r.height) return;
|
| 180 |
+
ndc.x = clamp(((e.clientX - r.left) / r.width) * 2 - 1, -2, 2);
|
| 181 |
+
ndc.y = clamp(-(((e.clientY - r.top) / r.height) * 2 - 1), -2, 2);
|
| 182 |
+
lastMouseT = lastT;
|
| 183 |
+
mouseInside = true;
|
| 184 |
+
};
|
| 185 |
+
const onBlur = () => { hasFocus = false; };
|
| 186 |
+
const onFocus = () => { hasFocus = true; };
|
| 187 |
+
const onMouseLeave = () => { mouseInside = false; };
|
| 188 |
+
const onMouseEnter = () => { mouseInside = true; };
|
| 189 |
+
// Any click on the title screen quacks, not just the stage box: the
|
| 190 |
+
// overlay owns the whole viewport while the menu is up, and the quack
|
| 191 |
+
// never swallows the event (buttons underneath still do their thing).
|
| 192 |
+
const onPointerDown = () => quack();
|
| 193 |
+
|
| 194 |
+
function quack() {
|
| 195 |
+
quackHoldUntil = lastT + QUACK_HOLD_S;
|
| 196 |
+
const bank = VOICE_BANK[appliedVariant] ?? "duck1";
|
| 197 |
+
const take = CHIRP_TAKES[(Math.random() * CHIRP_TAKES.length) | 0];
|
| 198 |
+
// The click that lands here is a user gesture, so this also unlocks
|
| 199 |
+
// the shared AudioContext on the very first interaction.
|
| 200 |
+
playUrl(signed(`./assets/voices/${bank}/chirp_${take}.wav`), { gain: 0.7 });
|
| 201 |
+
}
|
| 202 |
+
|
| 203 |
+
function step(dt, t) {
|
| 204 |
+
lastT = t;
|
| 205 |
+
headPivot.getWorldPosition(headWorld);
|
| 206 |
+
camDir.copy(camera.position).sub(headWorld);
|
| 207 |
+
const azCam = Math.atan2(camDir.x, camDir.z);
|
| 208 |
+
const elCam = Math.atan2(camDir.y, Math.hypot(camDir.x, camDir.z));
|
| 209 |
+
|
| 210 |
+
// Attention 0..1: eases toward 1 while the page is focused, the
|
| 211 |
+
// pointer is around and moved recently; lazy camera glances otherwise.
|
| 212 |
+
const engaged = hasFocus && mouseInside && t - lastMouseT < 3.0;
|
| 213 |
+
attention += ((engaged ? 1 : 0) - attention) * (1 - Math.exp(-dt / 0.3));
|
| 214 |
+
|
| 215 |
+
const azAim = azCam + ndc.x * SPREAD_YAW;
|
| 216 |
+
const elAim = elCam * 0.5 + ndc.y * SPREAD_PITCH;
|
| 217 |
+
const azIdle = azCam + 0.45 * Math.sin(0.21 * t + o1) * Math.sin(0.09 * t + o2) * 2;
|
| 218 |
+
const elIdle = elCam * 0.5 + 0.1 * Math.sin(0.4 * t + o2);
|
| 219 |
+
const azT = azIdle + (azAim - azIdle) * attention;
|
| 220 |
+
const elT = elIdle + (elAim - elIdle) * attention;
|
| 221 |
+
const kSmooth = 1 - Math.exp(-dt * (2.5 + 7.5 * attention));
|
| 222 |
+
az += (azT - az) * kSmooth;
|
| 223 |
+
el2 += (elT - el2) * kSmooth;
|
| 224 |
+
|
| 225 |
+
const breathe = Math.sin(t * 1.4) * 0.025;
|
| 226 |
+
setJoint(rig, "neck_pitch", SITTING_POSE.neck_pitch + breathe);
|
| 227 |
+
setJoint(rig, "head_yaw", SIGN_YAW * az);
|
| 228 |
+
setJoint(rig, "head_pitch", SITTING_POSE.head_pitch + SIGN_PITCH * el2);
|
| 229 |
+
setJoint(rig, "head_roll", 0.3 * SIGN_YAW * (az - azCam));
|
| 230 |
+
|
| 231 |
+
const quackTarget = t < quackHoldUntil ? 1 : 0;
|
| 232 |
+
const tau = quackTarget > quackOpen ? QUACK_ATTACK_TAU : QUACK_RELEASE_TAU;
|
| 233 |
+
quackOpen += (quackTarget - quackOpen) * (1 - Math.exp(-dt / tau));
|
| 234 |
+
setJawOpen(rig, quackOpen);
|
| 235 |
+
|
| 236 |
+
if (entranceFx) {
|
| 237 |
+
if (!entranceStarted) {
|
| 238 |
+
entranceFx.start();
|
| 239 |
+
entranceStarted = true;
|
| 240 |
+
}
|
| 241 |
+
entranceFx.update(dt);
|
| 242 |
+
if (entranceFx.isDone()) {
|
| 243 |
+
entranceFx.dispose();
|
| 244 |
+
entranceFx = null;
|
| 245 |
+
}
|
| 246 |
+
}
|
| 247 |
+
|
| 248 |
+
// Colourway follows the in-game duck (store.variant), fading live.
|
| 249 |
+
// Deferred while the entrance scan holds its clip-material clones:
|
| 250 |
+
// an applyVariant mid-scan would repaint clones that get thrown away
|
| 251 |
+
// on restore. The sync lands on the first frame after the scan.
|
| 252 |
+
const v = useGame.getState().variant;
|
| 253 |
+
if (!entranceFx && v !== appliedVariant && VARIANTS[v]) {
|
| 254 |
+
appliedVariant = v;
|
| 255 |
+
applyVariant(rig, v);
|
| 256 |
+
}
|
| 257 |
+
|
| 258 |
+
renderer.render(scene, camera);
|
| 259 |
+
if (el.style.opacity === "0") el.style.opacity = "1";
|
| 260 |
+
}
|
| 261 |
+
|
| 262 |
+
// ── Attach / detach (rAF only runs while a mount hosts the canvas) ────
|
| 263 |
+
let raf = 0;
|
| 264 |
+
let prevNow = 0;
|
| 265 |
+
const loop = (now) => {
|
| 266 |
+
raf = requestAnimationFrame(loop);
|
| 267 |
+
const dt = Math.min((now - prevNow) / 1000, 0.05);
|
| 268 |
+
prevNow = now;
|
| 269 |
+
step(dt, now / 1000);
|
| 270 |
+
};
|
| 271 |
+
|
| 272 |
+
function resize() {
|
| 273 |
+
if (!mountEl) return;
|
| 274 |
+
const w = mountEl.clientWidth;
|
| 275 |
+
const h = mountEl.clientHeight;
|
| 276 |
+
if (!w || !h) return;
|
| 277 |
+
camera.aspect = w / h;
|
| 278 |
+
camera.updateProjectionMatrix();
|
| 279 |
+
renderer.setSize(w, h);
|
| 280 |
+
}
|
| 281 |
+
const resizeObserver = new ResizeObserver(resize);
|
| 282 |
+
|
| 283 |
+
function attach(mount) {
|
| 284 |
+
if (!mount || mountEl === mount) return;
|
| 285 |
+
detach();
|
| 286 |
+
mountEl = mount;
|
| 287 |
+
mount.appendChild(el);
|
| 288 |
+
resizeObserver.observe(mount);
|
| 289 |
+
resize();
|
| 290 |
+
window.addEventListener("pointermove", onPointerMove);
|
| 291 |
+
window.addEventListener("pointerdown", onPointerDown);
|
| 292 |
+
window.addEventListener("blur", onBlur);
|
| 293 |
+
window.addEventListener("focus", onFocus);
|
| 294 |
+
document.addEventListener("mouseleave", onMouseLeave);
|
| 295 |
+
document.addEventListener("mouseenter", onMouseEnter);
|
| 296 |
+
prevNow = performance.now();
|
| 297 |
+
raf = requestAnimationFrame(loop);
|
| 298 |
+
}
|
| 299 |
+
function detach() {
|
| 300 |
+
if (!mountEl) return;
|
| 301 |
+
cancelAnimationFrame(raf);
|
| 302 |
+
resizeObserver.disconnect();
|
| 303 |
+
window.removeEventListener("pointermove", onPointerMove);
|
| 304 |
+
window.removeEventListener("pointerdown", onPointerDown);
|
| 305 |
+
window.removeEventListener("blur", onBlur);
|
| 306 |
+
window.removeEventListener("focus", onFocus);
|
| 307 |
+
document.removeEventListener("mouseleave", onMouseLeave);
|
| 308 |
+
document.removeEventListener("mouseenter", onMouseEnter);
|
| 309 |
+
el.remove();
|
| 310 |
+
mountEl = null;
|
| 311 |
+
}
|
| 312 |
+
|
| 313 |
+
const stage = { attach, detach, quack, step };
|
| 314 |
+
// Deterministic hook for automated verification, same spirit as the
|
| 315 |
+
// game's window.rl: rAF pauses in background tabs, so tests drive
|
| 316 |
+
// frames by hand.
|
| 317 |
+
window.__menuDuck = stage;
|
| 318 |
+
return stage;
|
| 319 |
+
}
|
| 320 |
+
|
| 321 |
+
export default function MenuDuck({ sx }) {
|
| 322 |
+
const hostRef = useRef(null);
|
| 323 |
+
|
| 324 |
+
useEffect(() => {
|
| 325 |
+
let disposed = false;
|
| 326 |
+
getStage()
|
| 327 |
+
.then((stage) => {
|
| 328 |
+
if (disposed) return;
|
| 329 |
+
if (hostRef.current) stage.attach(hostRef.current);
|
| 330 |
+
})
|
| 331 |
+
.catch((e) => console.warn("menu duck disabled:", e));
|
| 332 |
+
return () => {
|
| 333 |
+
disposed = true;
|
| 334 |
+
liveStage?.detach();
|
| 335 |
+
};
|
| 336 |
+
}, []);
|
| 337 |
+
|
| 338 |
+
return (
|
| 339 |
+
<Box
|
| 340 |
+
ref={hostRef}
|
| 341 |
+
aria-hidden
|
| 342 |
+
sx={{
|
| 343 |
+
position: "relative",
|
| 344 |
+
touchAction: "manipulation",
|
| 345 |
+
userSelect: "none",
|
| 346 |
+
...sx,
|
| 347 |
+
}}
|
| 348 |
+
/>
|
| 349 |
+
);
|
| 350 |
+
}
|
|
@@ -1,54 +0,0 @@
|
|
| 1 |
-
// Pre-boot veil: covers everything until the title page's logo and fonts
|
| 2 |
-
// are in, so the menu never pops in half-assembled. Light grey spinner,
|
| 3 |
-
// centered on the ink.
|
| 4 |
-
import { useEffect, useState } from "react";
|
| 5 |
-
import Box from "@mui/material/Box";
|
| 6 |
-
import { keyframes } from "@mui/material/styles";
|
| 7 |
-
import { useGame } from "../store.js";
|
| 8 |
-
import { INK } from "../theme.js";
|
| 9 |
-
|
| 10 |
-
const spin = keyframes`to { transform: rotate(360deg); }`;
|
| 11 |
-
|
| 12 |
-
export default function Preboot() {
|
| 13 |
-
const prebootDone = useGame((s) => s.prebootDone);
|
| 14 |
-
const [gone, setGone] = useState(false);
|
| 15 |
-
|
| 16 |
-
useEffect(() => {
|
| 17 |
-
if (!prebootDone) return;
|
| 18 |
-
const t = setTimeout(() => setGone(true), 300);
|
| 19 |
-
return () => clearTimeout(t);
|
| 20 |
-
}, [prebootDone]);
|
| 21 |
-
|
| 22 |
-
if (gone) return null;
|
| 23 |
-
return (
|
| 24 |
-
<Box
|
| 25 |
-
aria-hidden
|
| 26 |
-
sx={{
|
| 27 |
-
position: "fixed",
|
| 28 |
-
inset: 0,
|
| 29 |
-
zIndex: 40,
|
| 30 |
-
display: "flex",
|
| 31 |
-
alignItems: "center",
|
| 32 |
-
justifyContent: "center",
|
| 33 |
-
background: INK,
|
| 34 |
-
opacity: prebootDone ? 0 : 1,
|
| 35 |
-
pointerEvents: prebootDone ? "none" : "auto",
|
| 36 |
-
transition: "opacity 0.25s ease",
|
| 37 |
-
}}
|
| 38 |
-
>
|
| 39 |
-
<Box
|
| 40 |
-
sx={{
|
| 41 |
-
width: 22,
|
| 42 |
-
height: 22,
|
| 43 |
-
borderRadius: "50%",
|
| 44 |
-
border: "2px solid rgba(255, 255, 255, 0.14)",
|
| 45 |
-
borderTopColor: "rgba(255, 255, 255, 0.55)",
|
| 46 |
-
animation: `${spin} 0.8s linear infinite`,
|
| 47 |
-
"@media (prefers-reduced-motion: reduce)": {
|
| 48 |
-
animationDuration: "1.6s",
|
| 49 |
-
},
|
| 50 |
-
}}
|
| 51 |
-
/>
|
| 52 |
-
</Box>
|
| 53 |
-
);
|
| 54 |
-
}
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
@@ -0,0 +1,368 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
// Soundboard experiment page (?soundboard=1): trigger and A/B-compare every
|
| 2 |
+
// game sound - the CURRENT runtime implementation (straight from audio.js,
|
| 3 |
+
// exact same code paths, non-spatial) against a ZzFX-synthesized candidate.
|
| 4 |
+
// Throwaway experiment: plain CSS, no game boot, additive only.
|
| 5 |
+
import { useEffect, useRef, useState } from "react";
|
| 6 |
+
import {
|
| 7 |
+
audioCtx, busNode, preloadSfx, playSfx, playUrl, uiClick,
|
| 8 |
+
startAmbient, setRumble, playEntranceSweep, playPropSweep, playLineBlip,
|
| 9 |
+
playBiosBeep, playBiosTick, playConfirmBloop,
|
| 10 |
+
} from "../game/audio.js";
|
| 11 |
+
import { signed } from "../game/signed.js";
|
| 12 |
+
import { zzfx } from "../game/vendor/zzfx.js";
|
| 13 |
+
|
| 14 |
+
// Bus levels mirrored from audio.js BUS_LEVELS; the master volume slider
|
| 15 |
+
// rescales the (module-private) bus gains through busNode(), which is safe
|
| 16 |
+
// here because the game never mounts on this page.
|
| 17 |
+
const BUS_DEFAULTS = { ambient: 1, sfx: 0.9, voice: 1 };
|
| 18 |
+
|
| 19 |
+
// ZzFX candidate presets. Parameter order:
|
| 20 |
+
// [volume, randomness, frequency, attack, sustain, release, shape, shapeCurve,
|
| 21 |
+
// slide, deltaSlide, pitchJump, pitchJumpTime, repeatTime, noise, modulation,
|
| 22 |
+
// bitCrush, delay, sustainVolume, decay, tremolo, filter]
|
| 23 |
+
// Shapes: 0 sin, 1 triangle, 2 saw, 3 tan, 4 noise, 5 square (duty=shapeCurve/2).
|
| 24 |
+
export const ZZFX_PRESETS = {
|
| 25 |
+
// Soft noise-burst tap, quick pitch drop, mirrors the Kenney step takes.
|
| 26 |
+
footstepTap: [1.4, .3, 120, .001, .01, .05, 4, 1.9, -12, , , , , 1.3, , .1, , .5, .02],
|
| 27 |
+
// Duller, breathier alternative take (more noise, lower).
|
| 28 |
+
footstepSoft: [1.1, .4, 85, .001, .008, .04, 4, 2.2, -8, , , , , 2, , .2, , .45, .015],
|
| 29 |
+
// Round sine drop with a fast slide: hollow bouncing-ball body.
|
| 30 |
+
ballThump: [2, .15, 210, .002, .02, .16, 0, 2.4, -30, , , , , .3, , , , .8, .08],
|
| 31 |
+
// Same family an octave-ish down, more noise: duller body bump.
|
| 32 |
+
bodyBump: [1.7, .2, 90, .002, .03, .19, 0, 2.9, -15, , , , , 1, , .3, , .7, .11],
|
| 33 |
+
// Tiny triangle tick around 1.1 kHz.
|
| 34 |
+
uiClick: [.5, .2, 1100, .001, .005, .02, 1, 1.4, , , , , , , , , , .6, .01],
|
| 35 |
+
// Two rising square notes C5 -> G5 (pitchJump +261 Hz at 70 ms).
|
| 36 |
+
confirmBloop: [.7, 0, 523, .005, .12, .08, 5, 1, , , 261, .07, , , , , , .8, .02],
|
| 37 |
+
// Classic single POST beep, steady 940 Hz square.
|
| 38 |
+
biosBeep: [.4, 0, 940, .004, .1, .05, 5, 1, , , , , , , , , , .9, .01],
|
| 39 |
+
// Whisper teletype tick, high square with slight randomness.
|
| 40 |
+
biosTick: [.25, .2, 1850, .001, .004, .014, 5, 1, , , , , , , , , , .5, .005],
|
| 41 |
+
// Per-line scan chirp ~1.9 kHz triangle; randomness spreads the flurry.
|
| 42 |
+
scanBlip: [.35, .25, 1900, .001, .01, .04, 1, 1, , , , , , , , , , .6, .01],
|
| 43 |
+
// Rising saw sweep + noise shimmer + long release: sweep approximation.
|
| 44 |
+
entranceSweep: [1, 0, 150, .05, .5, .3, 2, 1, 11, .4, , , , .35, , .15, , .8, .2],
|
| 45 |
+
};
|
| 46 |
+
|
| 47 |
+
// Resume-then-run wrapper: every trigger lands in a user gesture, so a
|
| 48 |
+
// suspended context gets one resume attempt before the sound fires (the
|
| 49 |
+
// runtime helpers silently no-op on a suspended context).
|
| 50 |
+
async function armed(fn) {
|
| 51 |
+
const c = audioCtx();
|
| 52 |
+
if (c.state === "suspended") {
|
| 53 |
+
try { await c.resume(); } catch { return; }
|
| 54 |
+
}
|
| 55 |
+
fn();
|
| 56 |
+
}
|
| 57 |
+
|
| 58 |
+
// ZzFX candidates land on the sfx bus: same master gain as the runtime.
|
| 59 |
+
const playZ = (params) => zzfx(audioCtx(), busNode("sfx"), ...params);
|
| 60 |
+
|
| 61 |
+
const CHIRP_TAKES = "abcdefghijkl";
|
| 62 |
+
|
| 63 |
+
export default function Soundboard() {
|
| 64 |
+
const [vol, setVol] = useState(1);
|
| 65 |
+
const [ambientOn, setAmbientOn] = useState(false);
|
| 66 |
+
const [rumbleOn, setRumbleOn] = useState(false);
|
| 67 |
+
const [wheeeOn, setWheeeOn] = useState(false);
|
| 68 |
+
const wheeeRide = useRef(null);
|
| 69 |
+
const chirpIdx = useRef(0);
|
| 70 |
+
|
| 71 |
+
useEffect(() => {
|
| 72 |
+
audioCtx();
|
| 73 |
+
preloadSfx();
|
| 74 |
+
}, []);
|
| 75 |
+
|
| 76 |
+
// Master volume: rescale all three bus gains (ambient stays at 0 while
|
| 77 |
+
// its loop toggle is off - startAmbient() has no runtime stop hook, so
|
| 78 |
+
// the toggle gates the whole ambient bus instead).
|
| 79 |
+
useEffect(() => {
|
| 80 |
+
audioCtx();
|
| 81 |
+
for (const [name, base] of Object.entries(BUS_DEFAULTS)) {
|
| 82 |
+
const scale = name === "ambient" && !ambientOn ? 0 : vol;
|
| 83 |
+
busNode(name).gain.value = base * scale;
|
| 84 |
+
}
|
| 85 |
+
}, [vol, ambientOn]);
|
| 86 |
+
|
| 87 |
+
const toggleAmbient = () => armed(() => {
|
| 88 |
+
if (!ambientOn) startAmbient();
|
| 89 |
+
setAmbientOn((v) => !v);
|
| 90 |
+
});
|
| 91 |
+
|
| 92 |
+
const toggleRumble = () => armed(() => {
|
| 93 |
+
setRumble(rumbleOn ? 0 : 0.7);
|
| 94 |
+
setRumbleOn((v) => !v);
|
| 95 |
+
});
|
| 96 |
+
|
| 97 |
+
// Wheee reference loop: minimal version of the game's ride (loop take a,
|
| 98 |
+
// voice bus, short fades). Token in the ref guards the decode race.
|
| 99 |
+
const toggleWheee = () => armed(async () => {
|
| 100 |
+
const c = audioCtx();
|
| 101 |
+
const ride = wheeeRide.current;
|
| 102 |
+
if (ride) {
|
| 103 |
+
wheeeRide.current = null;
|
| 104 |
+
setWheeeOn(false);
|
| 105 |
+
if (ride.src) {
|
| 106 |
+
ride.gain.gain.setTargetAtTime(0, c.currentTime, 0.05);
|
| 107 |
+
try { ride.src.stop(c.currentTime + 0.3); } catch { /* ended */ }
|
| 108 |
+
}
|
| 109 |
+
return;
|
| 110 |
+
}
|
| 111 |
+
const next = {};
|
| 112 |
+
wheeeRide.current = next;
|
| 113 |
+
setWheeeOn(true);
|
| 114 |
+
let buf;
|
| 115 |
+
try {
|
| 116 |
+
const res = await fetch(signed("./assets/voices/duck1/wheee_loop_a.wav"));
|
| 117 |
+
buf = await c.decodeAudioData(await res.arrayBuffer());
|
| 118 |
+
} catch {
|
| 119 |
+
if (wheeeRide.current === next) { wheeeRide.current = null; setWheeeOn(false); }
|
| 120 |
+
return;
|
| 121 |
+
}
|
| 122 |
+
if (wheeeRide.current !== next) return; // toggled off during decode
|
| 123 |
+
const gain = c.createGain();
|
| 124 |
+
gain.connect(busNode("voice"));
|
| 125 |
+
const t0 = c.currentTime + 0.02;
|
| 126 |
+
gain.gain.setValueAtTime(0, t0);
|
| 127 |
+
gain.gain.linearRampToValueAtTime(0.7, t0 + 0.02);
|
| 128 |
+
const src = c.createBufferSource();
|
| 129 |
+
src.buffer = buf;
|
| 130 |
+
src.loop = true;
|
| 131 |
+
src.connect(gain);
|
| 132 |
+
src.start(t0);
|
| 133 |
+
Object.assign(next, { src, gain });
|
| 134 |
+
});
|
| 135 |
+
|
| 136 |
+
const playQuack = () => {
|
| 137 |
+
const take = CHIRP_TAKES[chirpIdx.current++ % CHIRP_TAKES.length];
|
| 138 |
+
playUrl(signed(`./assets/voices/duck1/chirp_${take}.wav`), { gain: 0.7 });
|
| 139 |
+
};
|
| 140 |
+
|
| 141 |
+
// One-shot rows: name, category, note, current trigger, zzfx candidates.
|
| 142 |
+
const rows = [
|
| 143 |
+
{
|
| 144 |
+
name: "Footstep", cat: "sampled", note: "step_a-e, random take, in-game gain/rate spread",
|
| 145 |
+
current: () => playSfx("step", { gain: 0.2, rate: 0.9 + Math.random() * 0.25 }),
|
| 146 |
+
zz: [["tap", ZZFX_PRESETS.footstepTap], ["soft", ZZFX_PRESETS.footstepSoft]],
|
| 147 |
+
},
|
| 148 |
+
{
|
| 149 |
+
name: "Ball thump", cat: "sampled", note: "thump_a-c, mid-strength kick",
|
| 150 |
+
current: () => playSfx("thump", { gain: 0.34, rate: 1.05 + Math.random() * 0.08 }),
|
| 151 |
+
zz: [["thump", ZZFX_PRESETS.ballThump]],
|
| 152 |
+
},
|
| 153 |
+
{
|
| 154 |
+
name: "Body bump", cat: "sampled", note: "thump takes pitched down to ~0.57",
|
| 155 |
+
current: () => playSfx("thump", { gain: 0.27, rate: 0.57 + Math.random() * 0.06 }),
|
| 156 |
+
zz: [["bump", ZZFX_PRESETS.bodyBump]],
|
| 157 |
+
},
|
| 158 |
+
{
|
| 159 |
+
name: "UI click", cat: "sampled", note: "click_a/b via uiClick()",
|
| 160 |
+
current: () => uiClick(),
|
| 161 |
+
zz: [["click", ZZFX_PRESETS.uiClick]],
|
| 162 |
+
},
|
| 163 |
+
{
|
| 164 |
+
name: "Confirm bloop", cat: "synth", note: "menu confirm, C5 -> G5 squares",
|
| 165 |
+
current: () => playConfirmBloop(),
|
| 166 |
+
zz: [["bloop", ZZFX_PRESETS.confirmBloop]],
|
| 167 |
+
},
|
| 168 |
+
{
|
| 169 |
+
name: "BIOS beep", cat: "synth", note: "POST beep, 940 Hz square",
|
| 170 |
+
current: () => playBiosBeep(),
|
| 171 |
+
zz: [["beep", ZZFX_PRESETS.biosBeep]],
|
| 172 |
+
},
|
| 173 |
+
{
|
| 174 |
+
name: "BIOS tick", cat: "synth", note: "teletype tick per POST line",
|
| 175 |
+
current: () => playBiosTick(),
|
| 176 |
+
zz: [["tick", ZZFX_PRESETS.biosTick]],
|
| 177 |
+
},
|
| 178 |
+
{
|
| 179 |
+
name: "Scan blip", cat: "synth", note: "per-line entrance blip, random pitch",
|
| 180 |
+
current: () => playLineBlip(Math.random()),
|
| 181 |
+
zz: [["blip", ZZFX_PRESETS.scanBlip]],
|
| 182 |
+
},
|
| 183 |
+
{
|
| 184 |
+
name: "Entrance sweep", cat: "synth", note: "3-layer materialize sweep + thunk + ping (0.9 s)",
|
| 185 |
+
current: () => playEntranceSweep(0.9),
|
| 186 |
+
zz: [["sweep", ZZFX_PRESETS.entranceSweep]],
|
| 187 |
+
},
|
| 188 |
+
{
|
| 189 |
+
name: "Prop sweep (small)", cat: "synth", note: "duck-sized prop materialize (0.9 s, quieter, no garnish)",
|
| 190 |
+
current: () => playPropSweep(0.9),
|
| 191 |
+
zz: null,
|
| 192 |
+
},
|
| 193 |
+
{
|
| 194 |
+
name: "Prop sweep (cabinet)", cat: "synth", note: "big prop: size-stretched 2.2 s, register dropped by the stretch",
|
| 195 |
+
current: () => playPropSweep(2.2),
|
| 196 |
+
zz: null,
|
| 197 |
+
},
|
| 198 |
+
{
|
| 199 |
+
name: "Quack", cat: "voice", note: "reference only, chirp takes a-l round-robin",
|
| 200 |
+
current: playQuack,
|
| 201 |
+
zz: null,
|
| 202 |
+
},
|
| 203 |
+
];
|
| 204 |
+
|
| 205 |
+
const loops = [
|
| 206 |
+
{
|
| 207 |
+
name: "Ambient bed", cat: "synth", note: "arcade-room hum loop; ZzFX n/a (already synth)",
|
| 208 |
+
on: ambientOn, toggle: toggleAmbient,
|
| 209 |
+
},
|
| 210 |
+
{
|
| 211 |
+
name: "Roller rumble", cat: "synth", note: "rolling-noise loop at 0.7 speed; ZzFX n/a (already synth)",
|
| 212 |
+
on: rumbleOn, toggle: toggleRumble,
|
| 213 |
+
},
|
| 214 |
+
{
|
| 215 |
+
name: "Wheee", cat: "voice", note: "reference only, loop take a on the voice bus",
|
| 216 |
+
on: wheeeOn, toggle: toggleWheee,
|
| 217 |
+
},
|
| 218 |
+
];
|
| 219 |
+
|
| 220 |
+
return (
|
| 221 |
+
<div className="sb-root">
|
| 222 |
+
<style>{CSS}</style>
|
| 223 |
+
<header className="sb-head">
|
| 224 |
+
<h1>Microduck Soundboard</h1>
|
| 225 |
+
<p className="sb-sub">
|
| 226 |
+
A/B: current runtime implementation vs ZzFX candidate. First click unlocks audio.
|
| 227 |
+
</p>
|
| 228 |
+
<label className="sb-vol">
|
| 229 |
+
Master volume
|
| 230 |
+
<input
|
| 231 |
+
type="range" min="0" max="1" step="0.01" value={vol}
|
| 232 |
+
aria-label="Master volume"
|
| 233 |
+
onChange={(e) => setVol(Number(e.target.value))}
|
| 234 |
+
/>
|
| 235 |
+
<span>{Math.round(vol * 100)}%</span>
|
| 236 |
+
</label>
|
| 237 |
+
</header>
|
| 238 |
+
|
| 239 |
+
<div className="sb-grid" role="table" aria-label="One-shot sounds">
|
| 240 |
+
<div className="sb-row sb-row-head" role="row">
|
| 241 |
+
<span>Sound</span><span>Category</span><span>Current</span><span>ZzFX candidate</span>
|
| 242 |
+
</div>
|
| 243 |
+
{rows.map((r) => (
|
| 244 |
+
<div className="sb-row" role="row" key={r.name}>
|
| 245 |
+
<span className="sb-name">
|
| 246 |
+
{r.name}
|
| 247 |
+
<small>{r.note}</small>
|
| 248 |
+
</span>
|
| 249 |
+
<span><em className={`sb-cat sb-cat-${r.cat}`}>{r.cat}</em></span>
|
| 250 |
+
<span>
|
| 251 |
+
<button type="button" className="sb-btn sb-btn-cur" onClick={() => armed(r.current)}>
|
| 252 |
+
Play
|
| 253 |
+
</button>
|
| 254 |
+
</span>
|
| 255 |
+
<span className="sb-zz">
|
| 256 |
+
{r.zz
|
| 257 |
+
? r.zz.map(([label, params]) => (
|
| 258 |
+
<button
|
| 259 |
+
type="button" key={label} className="sb-btn sb-btn-zz"
|
| 260 |
+
onClick={() => armed(() => playZ(params))}
|
| 261 |
+
>
|
| 262 |
+
{label}
|
| 263 |
+
</button>
|
| 264 |
+
))
|
| 265 |
+
: <em className="sb-na">n/a (voice reference)</em>}
|
| 266 |
+
</span>
|
| 267 |
+
</div>
|
| 268 |
+
))}
|
| 269 |
+
</div>
|
| 270 |
+
|
| 271 |
+
<h2 className="sb-h2">Loops</h2>
|
| 272 |
+
<div className="sb-grid" role="table" aria-label="Looping sounds">
|
| 273 |
+
{loops.map((l) => (
|
| 274 |
+
<div className="sb-row" role="row" key={l.name}>
|
| 275 |
+
<span className="sb-name">
|
| 276 |
+
{l.name}
|
| 277 |
+
<small>{l.note}</small>
|
| 278 |
+
</span>
|
| 279 |
+
<span><em className={`sb-cat sb-cat-${l.cat}`}>{l.cat}</em></span>
|
| 280 |
+
<span>
|
| 281 |
+
<button
|
| 282 |
+
type="button"
|
| 283 |
+
className={`sb-btn sb-btn-cur ${l.on ? "sb-on" : ""}`}
|
| 284 |
+
aria-pressed={l.on}
|
| 285 |
+
onClick={l.toggle}
|
| 286 |
+
>
|
| 287 |
+
{l.on ? "Stop" : "Start"}
|
| 288 |
+
</button>
|
| 289 |
+
</span>
|
| 290 |
+
<span className="sb-zz"><em className="sb-na">n/a</em></span>
|
| 291 |
+
</div>
|
| 292 |
+
))}
|
| 293 |
+
</div>
|
| 294 |
+
</div>
|
| 295 |
+
);
|
| 296 |
+
}
|
| 297 |
+
|
| 298 |
+
const CSS = `
|
| 299 |
+
/* The game's index.html pins body { overflow: hidden } for the canvas;
|
| 300 |
+
this page is a document, so re-enable normal scrolling. */
|
| 301 |
+
html, body { height: auto; overflow: auto; }
|
| 302 |
+
#root { height: auto; }
|
| 303 |
+
.sb-root {
|
| 304 |
+
min-height: 100vh;
|
| 305 |
+
background: #faf8f2;
|
| 306 |
+
color: #101018;
|
| 307 |
+
font: 15px/1.45 ui-monospace, SFMono-Regular, Menlo, Consolas, monospace;
|
| 308 |
+
padding: 2.2rem clamp(1rem, 5vw, 4rem) 4rem;
|
| 309 |
+
}
|
| 310 |
+
.sb-head h1 {
|
| 311 |
+
font-family: 'Anton', 'Arial Narrow', Impact, sans-serif;
|
| 312 |
+
font-size: clamp(2rem, 5vw, 3.2rem);
|
| 313 |
+
text-transform: uppercase;
|
| 314 |
+
letter-spacing: 0.02em;
|
| 315 |
+
margin: 0;
|
| 316 |
+
color: #101018;
|
| 317 |
+
-webkit-text-stroke: 1px #101018;
|
| 318 |
+
}
|
| 319 |
+
.sb-sub { margin: 0.3rem 0 1.2rem; opacity: 0.75; }
|
| 320 |
+
.sb-vol {
|
| 321 |
+
display: inline-flex; align-items: center; gap: 0.7rem;
|
| 322 |
+
border: 2px solid #101018; background: #fff;
|
| 323 |
+
padding: 0.5rem 0.9rem; box-shadow: 4px 4px 0 #101018;
|
| 324 |
+
margin-bottom: 1.6rem; font-weight: 700;
|
| 325 |
+
}
|
| 326 |
+
.sb-vol input { accent-color: #ff7a2f; width: 200px; }
|
| 327 |
+
.sb-h2 {
|
| 328 |
+
font-family: 'Anton', 'Arial Narrow', Impact, sans-serif;
|
| 329 |
+
text-transform: uppercase; margin: 2rem 0 0.6rem;
|
| 330 |
+
}
|
| 331 |
+
.sb-grid { border: 2px solid #101018; background: #fff; box-shadow: 6px 6px 0 #101018; }
|
| 332 |
+
.sb-row {
|
| 333 |
+
display: grid;
|
| 334 |
+
grid-template-columns: minmax(220px, 1.6fr) 110px 130px minmax(160px, 1fr);
|
| 335 |
+
align-items: center; gap: 0.8rem;
|
| 336 |
+
padding: 0.55rem 0.9rem;
|
| 337 |
+
border-bottom: 1px solid rgba(16, 16, 24, 0.18);
|
| 338 |
+
}
|
| 339 |
+
.sb-row:last-child { border-bottom: none; }
|
| 340 |
+
.sb-row-head {
|
| 341 |
+
font-weight: 700; text-transform: uppercase; font-size: 12px;
|
| 342 |
+
background: #101018; color: #faf8f2;
|
| 343 |
+
}
|
| 344 |
+
.sb-name { display: flex; flex-direction: column; font-weight: 700; }
|
| 345 |
+
.sb-name small { font-weight: 400; opacity: 0.6; font-size: 11px; }
|
| 346 |
+
.sb-cat {
|
| 347 |
+
font-style: normal; font-size: 11px; font-weight: 700;
|
| 348 |
+
text-transform: uppercase; padding: 2px 7px; border: 1.5px solid #101018;
|
| 349 |
+
}
|
| 350 |
+
.sb-cat-sampled { background: #ffe9db; }
|
| 351 |
+
.sb-cat-synth { background: #e5eeff; }
|
| 352 |
+
.sb-cat-voice { background: #fff3b0; }
|
| 353 |
+
.sb-btn {
|
| 354 |
+
font: inherit; font-weight: 700; text-transform: uppercase; font-size: 12px;
|
| 355 |
+
border: 2px solid #101018; padding: 0.35rem 0.9rem; cursor: pointer;
|
| 356 |
+
box-shadow: 3px 3px 0 #101018; background: #faf8f2; color: #101018;
|
| 357 |
+
margin: 2px 6px 2px 0;
|
| 358 |
+
}
|
| 359 |
+
.sb-btn:active { transform: translate(2px, 2px); box-shadow: 1px 1px 0 #101018; }
|
| 360 |
+
.sb-btn:focus-visible { outline: 3px solid #ff7a2f; outline-offset: 2px; }
|
| 361 |
+
.sb-btn-zz { background: #ff7a2f; color: #101018; }
|
| 362 |
+
.sb-btn-cur.sb-on { background: #101018; color: #faf8f2; }
|
| 363 |
+
.sb-na { opacity: 0.5; font-size: 12px; }
|
| 364 |
+
@media (max-width: 700px) {
|
| 365 |
+
.sb-row { grid-template-columns: 1fr; gap: 0.3rem; }
|
| 366 |
+
.sb-row-head { display: none; }
|
| 367 |
+
}
|
| 368 |
+
`;
|
|
@@ -13,8 +13,11 @@ import Typography from "@mui/material/Typography";
|
|
| 13 |
import { keyframes, styled } from "@mui/material/styles";
|
| 14 |
import { useGame } from "../store.js";
|
| 15 |
import { signed } from "../game/signed.js";
|
|
|
|
| 16 |
import { INK, ORANGE, MONO } from "../theme.js";
|
| 17 |
-
import { ComicButton, ComicTitle, HalftoneRamp, ANTON } from "./comic.jsx";
|
|
|
|
|
|
|
| 18 |
|
| 19 |
const rowIn = keyframes`
|
| 20 |
from { transform: translateY(12px); opacity: 0; }
|
|
@@ -24,7 +27,15 @@ const brandIn = keyframes`
|
|
| 24 |
from { transform: translateY(10px) scale(0.94); opacity: 0; }
|
| 25 |
to { transform: none; opacity: 1; }
|
| 26 |
`;
|
| 27 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 28 |
// One soft ease, small travel, ~80 ms between rows - staged, not showy.
|
| 29 |
const row = (delay, name = rowIn) => ({
|
| 30 |
animation: `${name} 0.55s cubic-bezier(0.22, 1, 0.36, 1) both`,
|
|
@@ -49,37 +60,60 @@ const Kbd = styled("kbd")(({ round }) => ({
|
|
| 49 |
boxShadow: "0 0 0 2px rgba(255, 255, 255, 0.82)",
|
| 50 |
}));
|
| 51 |
|
| 52 |
-
|
|
|
|
|
|
|
|
|
|
| 53 |
kb: [
|
| 54 |
-
{ caps: "
|
| 55 |
-
{ caps: ["A", "E"], name: "Kick"
|
| 56 |
-
{ caps: ["R"], name: "Sit"
|
| 57 |
-
{ caps: ["G"], name: "Pick up"
|
| 58 |
-
{ caps: ["C"], name: "Camera"
|
| 59 |
-
{ caps: ["Space"], name: "Reset"
|
| 60 |
],
|
| 61 |
pad: [
|
| 62 |
-
{ caps: ["LS"], name: "Move"
|
| 63 |
-
{ caps: ["LB", "RB"], name: "Kick"
|
| 64 |
-
{ caps: ["Y"], name: "Head"
|
| 65 |
-
{ caps: ["RS"], name: "Camera"
|
| 66 |
-
{ caps: ["X", "\u2193"], name: "Sit"
|
| 67 |
],
|
| 68 |
touch: [
|
| 69 |
-
{ caps: ["Stick"], name: "Move"
|
| 70 |
-
{ caps: ["A"], round: true, name: "Kick"
|
| 71 |
-
{ caps: ["B"], round: true, name: "Quack"
|
| 72 |
],
|
| 73 |
};
|
| 74 |
const HINTS = {
|
| 75 |
-
kb: "drag to orbit \u00b7 scroll to zoom",
|
| 76 |
pad: "A ground pick \u00b7 RT quack \u00b7 hold LT wheee \u00b7 R3 chase",
|
| 77 |
touch: "drag to orbit \u00b7 pinch to zoom",
|
| 78 |
};
|
| 79 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 80 |
function closeMenu() {
|
| 81 |
useGame.setState({ menuOpen: false });
|
| 82 |
if (!useGame.getState().entered) useGame.setState({ entered: true });
|
|
|
|
|
|
|
|
|
|
|
|
|
| 83 |
}
|
| 84 |
|
| 85 |
export default function TitleMenu() {
|
|
@@ -87,9 +121,101 @@ export default function TitleMenu() {
|
|
| 87 |
const entered = useGame((s) => s.entered);
|
| 88 |
const padConnected = useGame((s) => s.padConnected);
|
| 89 |
const touchMode = useGame((s) => s.touchMode);
|
|
|
|
|
|
|
|
|
|
| 90 |
const [closing, setClosing] = useState(false);
|
| 91 |
const prevOpen = useRef(menuOpen);
|
| 92 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 93 |
// Keep the overlay mounted through the 0.35 s closing fade.
|
| 94 |
useEffect(() => {
|
| 95 |
const was = prevOpen.current;
|
|
@@ -124,12 +250,65 @@ export default function TitleMenu() {
|
|
| 124 |
return () => window.removeEventListener("keydown", onKey);
|
| 125 |
}, []);
|
| 126 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 127 |
if (!menuOpen && !closing) return null;
|
| 128 |
|
| 129 |
// A plugged-in gamepad wins over the touch tutorial.
|
| 130 |
const tutorialVariant = padConnected ? "pad" : touchMode ? "touch" : "kb";
|
| 131 |
-
const
|
| 132 |
const ctaLabel = entered ? "Resume" : "Waddle in";
|
|
|
|
|
|
|
|
|
|
| 133 |
|
| 134 |
return (
|
| 135 |
<Box
|
|
@@ -155,95 +334,190 @@ export default function TitleMenu() {
|
|
| 155 |
{/* Theme-orange halftone pooling in the top-left corner, dots
|
| 156 |
growing toward it - the landing's screen-tone on ink ground. */}
|
| 157 |
<HalftoneRamp
|
| 158 |
-
color=
|
| 159 |
size={20}
|
| 160 |
corner="top-left"
|
| 161 |
reach={60}
|
| 162 |
/>
|
| 163 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 164 |
<Box
|
|
|
|
| 165 |
sx={{
|
| 166 |
-
position: "
|
| 167 |
-
|
| 168 |
-
|
| 169 |
-
|
| 170 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 171 |
}}
|
| 172 |
>
|
| 173 |
-
{/* Vitrine brand lockup: drawn duck head + name. Hovering swaps to
|
| 174 |
-
the open-beak frame, same hard sprite swap as the vitrine. */}
|
| 175 |
<Box
|
| 176 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 177 |
sx={{
|
|
|
|
| 178 |
display: "flex",
|
| 179 |
flexDirection: "column",
|
| 180 |
alignItems: "center",
|
| 181 |
-
|
| 182 |
-
gap: "0.5rem",
|
| 183 |
-
mb: "1.15rem",
|
| 184 |
-
userSelect: "none",
|
| 185 |
-
...row(0, brandIn),
|
| 186 |
-
"&:hover .duck-closed": { opacity: 0 },
|
| 187 |
-
"&:hover .duck-open": { opacity: 1 },
|
| 188 |
}}
|
| 189 |
>
|
| 190 |
<Box
|
| 191 |
-
|
| 192 |
sx={{
|
| 193 |
-
|
| 194 |
-
|
| 195 |
-
|
| 196 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 197 |
}}
|
| 198 |
>
|
| 199 |
-
|
| 200 |
-
|
| 201 |
-
className="duck-closed"
|
| 202 |
-
alt=""
|
| 203 |
-
src={signed("./assets/duck-head-mark.webp")}
|
| 204 |
-
sx={{ display: "block", height: "100%", width: "auto" }}
|
| 205 |
-
/>
|
| 206 |
-
{/* The open frame's canvas (460x333) is a hair wider/taller
|
| 207 |
-
than the closed one (454x269); offsets pin the head while
|
| 208 |
-
the jaw hangs below. */}
|
| 209 |
-
<Box
|
| 210 |
-
component="img"
|
| 211 |
-
className="duck-open"
|
| 212 |
-
alt=""
|
| 213 |
-
src={signed("./assets/duck-head-mark-open.webp")}
|
| 214 |
-
sx={{
|
| 215 |
-
position: "absolute",
|
| 216 |
-
top: "-0.75%",
|
| 217 |
-
left: "-1.1%",
|
| 218 |
-
width: "101.3%",
|
| 219 |
-
maxWidth: "none",
|
| 220 |
-
height: "auto",
|
| 221 |
-
opacity: 0,
|
| 222 |
-
}}
|
| 223 |
-
/>
|
| 224 |
-
</Box>
|
| 225 |
</Box>
|
|
|
|
| 226 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 227 |
{/* The landing hero's mistracked-VHS treatment: orange fill with
|
| 228 |
ink drop + chroma ghosts, hollow echo line below. */}
|
| 229 |
<ComicTitle
|
| 230 |
component="h1"
|
| 231 |
tone="dark"
|
| 232 |
-
accent={
|
| 233 |
-
fontSize="clamp(3.
|
| 234 |
lines={[
|
| 235 |
{ text: "Microduck" },
|
| 236 |
-
{ text: "Simulator", variant: "outline", scale: 0.
|
| 237 |
]}
|
| 238 |
sx={{ ...row(0.08) }}
|
| 239 |
/>
|
| 240 |
|
| 241 |
<Typography
|
| 242 |
sx={{
|
| 243 |
-
mx: "auto",
|
| 244 |
mt: "1.1rem",
|
| 245 |
"@media (max-height: 700px)": { mt: "0.8rem" },
|
| 246 |
-
maxWidth: "
|
| 247 |
fontSize: { xs: "0.95rem", sm: "1.05rem" },
|
| 248 |
lineHeight: 1.5,
|
| 249 |
letterSpacing: "-0.012em",
|
|
@@ -256,99 +530,159 @@ export default function TitleMenu() {
|
|
| 256 |
live in your browser.
|
| 257 |
</Typography>
|
| 258 |
|
|
|
|
|
|
|
| 259 |
<Box
|
| 260 |
sx={{
|
| 261 |
-
display: "
|
| 262 |
-
|
| 263 |
-
|
| 264 |
-
|
| 265 |
-
|
| 266 |
-
"@media (min-width: 500px)": {
|
| 267 |
-
gridTemplateColumns: "repeat(3, 1fr)",
|
| 268 |
-
},
|
| 269 |
-
gap: "0.55rem",
|
| 270 |
-
m: "1.35rem auto 0",
|
| 271 |
-
maxWidth: "36rem",
|
| 272 |
...row(0.32),
|
| 273 |
}}
|
| 274 |
>
|
| 275 |
-
|
| 276 |
-
|
| 277 |
-
|
| 278 |
-
|
| 279 |
-
|
| 280 |
-
|
| 281 |
-
|
| 282 |
-
|
| 283 |
-
|
|
|
|
|
|
|
|
|
|
| 284 |
<Box
|
|
|
|
|
|
|
| 285 |
sx={{
|
| 286 |
-
display: "flex",
|
| 287 |
-
flexDirection: "column",
|
| 288 |
alignItems: "center",
|
| 289 |
justifyContent: "center",
|
| 290 |
-
|
| 291 |
-
height:
|
| 292 |
-
|
| 293 |
-
|
| 294 |
-
|
| 295 |
-
|
| 296 |
-
|
| 297 |
-
p: "0 0.3rem",
|
| 298 |
-
fontSize: "0.62rem",
|
| 299 |
-
},
|
| 300 |
}}
|
| 301 |
>
|
| 302 |
-
|
| 303 |
-
<Box
|
| 304 |
-
className="cluster"
|
| 305 |
-
sx={{
|
| 306 |
-
display: "flex",
|
| 307 |
-
flexDirection: "column",
|
| 308 |
-
alignItems: "center",
|
| 309 |
-
gap: "0.2rem",
|
| 310 |
-
}}
|
| 311 |
-
>
|
| 312 |
-
<Box sx={{ display: "flex", gap: "0.22rem", justifyContent: "center" }}>
|
| 313 |
-
<Kbd>{"\u2191"}</Kbd>
|
| 314 |
-
</Box>
|
| 315 |
-
<Box sx={{ display: "flex", gap: "0.22rem", justifyContent: "center" }}>
|
| 316 |
-
<Kbd>{"\u2190"}</Kbd>
|
| 317 |
-
<Kbd>{"\u2193"}</Kbd>
|
| 318 |
-
<Kbd>{"\u2192"}</Kbd>
|
| 319 |
-
</Box>
|
| 320 |
-
</Box>
|
| 321 |
-
) : (
|
| 322 |
-
<Box sx={{ display: "flex", gap: "0.22rem", justifyContent: "center" }}>
|
| 323 |
-
{t.caps.map((c) => (
|
| 324 |
-
<Kbd key={c} round={t.round ? 1 : 0}>{c}</Kbd>
|
| 325 |
-
))}
|
| 326 |
-
</Box>
|
| 327 |
-
)}
|
| 328 |
</Box>
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 329 |
<Box
|
|
|
|
| 330 |
sx={{
|
| 331 |
-
|
| 332 |
-
|
| 333 |
-
|
| 334 |
-
|
| 335 |
-
|
| 336 |
-
|
| 337 |
}}
|
| 338 |
>
|
| 339 |
-
{
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 340 |
</Box>
|
| 341 |
<Box
|
|
|
|
| 342 |
sx={{
|
| 343 |
-
|
| 344 |
-
fontSize: "0.
|
| 345 |
-
|
| 346 |
-
letterSpacing: "0.11em",
|
| 347 |
textTransform: "uppercase",
|
| 348 |
-
color:
|
| 349 |
}}
|
| 350 |
>
|
| 351 |
-
{
|
| 352 |
</Box>
|
| 353 |
</Box>
|
| 354 |
))}
|
|
@@ -356,39 +690,20 @@ export default function TitleMenu() {
|
|
| 356 |
|
| 357 |
<Typography
|
| 358 |
sx={{
|
| 359 |
-
mt: "0.
|
| 360 |
fontFamily: MONO,
|
| 361 |
-
fontSize: "0.
|
| 362 |
fontWeight: 600,
|
| 363 |
letterSpacing: "0.12em",
|
| 364 |
textTransform: "uppercase",
|
| 365 |
-
color: "rgba(255, 255, 255, 0.
|
| 366 |
-
...row(0.4),
|
| 367 |
}}
|
| 368 |
>
|
| 369 |
{HINTS[tutorialVariant]}
|
| 370 |
</Typography>
|
| 371 |
-
|
| 372 |
-
<Box
|
| 373 |
-
sx={{
|
| 374 |
-
display: "flex",
|
| 375 |
-
justifyContent: "center",
|
| 376 |
-
mt: "1.7rem",
|
| 377 |
-
"@media (max-height: 700px)": { mt: "1.2rem" },
|
| 378 |
-
...row(0.48),
|
| 379 |
-
}}
|
| 380 |
-
>
|
| 381 |
-
<ComicButton
|
| 382 |
-
scheme="orange"
|
| 383 |
-
size="medium"
|
| 384 |
-
onDark
|
| 385 |
-
data-cta="1"
|
| 386 |
-
onClick={closeMenu}
|
| 387 |
-
>
|
| 388 |
-
{ctaLabel}
|
| 389 |
-
</ComicButton>
|
| 390 |
-
</Box>
|
| 391 |
</Box>
|
|
|
|
|
|
|
| 392 |
</Box>
|
| 393 |
);
|
| 394 |
}
|
|
|
|
| 13 |
import { keyframes, styled } from "@mui/material/styles";
|
| 14 |
import { useGame } from "../store.js";
|
| 15 |
import { signed } from "../game/signed.js";
|
| 16 |
+
import { playConfirmBloop } from "../game/audio.js";
|
| 17 |
import { INK, ORANGE, MONO } from "../theme.js";
|
| 18 |
+
import { ComicButton, ComicTitle, HalftoneRamp, ANTON, CREAM } from "./comic.jsx";
|
| 19 |
+
import MenuDuck, { preloadMenuDuck, isMenuDuckReady } from "./MenuDuck.jsx";
|
| 20 |
+
import { PreorderButton } from "./Hud.jsx";
|
| 21 |
|
| 22 |
const rowIn = keyframes`
|
| 23 |
from { transform: translateY(12px); opacity: 0; }
|
|
|
|
| 27 |
from { transform: translateY(10px) scale(0.94); opacity: 0; }
|
| 28 |
to { transform: none; opacity: 1; }
|
| 29 |
`;
|
| 30 |
+
// Title-screen blink for the "press Enter" prompt - a soft pulse, not a
|
| 31 |
+
// hard arcade strobe, so it invites rather than nags.
|
| 32 |
+
const promptPulse = keyframes`
|
| 33 |
+
from { opacity: 0.8; }
|
| 34 |
+
to { opacity: 0.18; }
|
| 35 |
+
`;
|
| 36 |
+
const spin = keyframes`
|
| 37 |
+
to { transform: rotate(360deg); }
|
| 38 |
+
`;
|
| 39 |
// One soft ease, small travel, ~80 ms between rows - staged, not showy.
|
| 40 |
const row = (delay, name = rowIn) => ({
|
| 41 |
animation: `${name} 0.55s cubic-bezier(0.22, 1, 0.36, 1) both`,
|
|
|
|
| 60 |
boxShadow: "0 0 0 2px rgba(255, 255, 255, 0.82)",
|
| 61 |
}));
|
| 62 |
|
| 63 |
+
// Arcade-style cheat strip pinned at the bottom of the title screen: one
|
| 64 |
+
// keycap group + label per move, scannable in a second. The CTA is the
|
| 65 |
+
// screen's centre of gravity - players enter first and learn in game.
|
| 66 |
+
const SHORTCUTS = {
|
| 67 |
kb: [
|
| 68 |
+
{ caps: ["\u2191\u2190\u2193\u2192"], name: "Move" },
|
| 69 |
+
{ caps: ["A", "E"], name: "Kick" },
|
| 70 |
+
{ caps: ["R"], name: "Sit" },
|
| 71 |
+
{ caps: ["G"], name: "Pick up" },
|
| 72 |
+
{ caps: ["C"], name: "Camera" },
|
| 73 |
+
{ caps: ["Space"], name: "Reset" },
|
| 74 |
],
|
| 75 |
pad: [
|
| 76 |
+
{ caps: ["LS"], name: "Move" },
|
| 77 |
+
{ caps: ["LB", "RB"], name: "Kick" },
|
| 78 |
+
{ caps: ["Y"], name: "Head" },
|
| 79 |
+
{ caps: ["RS"], name: "Camera" },
|
| 80 |
+
{ caps: ["X", "\u2193"], name: "Sit" },
|
| 81 |
],
|
| 82 |
touch: [
|
| 83 |
+
{ caps: ["Stick"], name: "Move" },
|
| 84 |
+
{ caps: ["A"], round: true, name: "Kick" },
|
| 85 |
+
{ caps: ["B"], round: true, name: "Quack" },
|
| 86 |
],
|
| 87 |
};
|
| 88 |
const HINTS = {
|
| 89 |
+
kb: "ZQSD works too \u00b7 drag to orbit \u00b7 scroll to zoom",
|
| 90 |
pad: "A ground pick \u00b7 RT quack \u00b7 hold LT wheee \u00b7 R3 chase",
|
| 91 |
touch: "drag to orbit \u00b7 pinch to zoom",
|
| 92 |
};
|
| 93 |
|
| 94 |
+
// Per-colourway screen accent: the title fill and the halftone pool
|
| 95 |
+
// follow the duck on stage. Cream keeps the landing's brand orange (the
|
| 96 |
+
// classic duck's trim colour); lavender and sky use the press-kit swatch
|
| 97 |
+
// hexes (shared with the vitrine picker); graphite's swatch is too dark
|
| 98 |
+
// for type on ink, so it wears the palette's light warm gray instead.
|
| 99 |
+
const VARIANT_ACCENTS = {
|
| 100 |
+
classic: ORANGE,
|
| 101 |
+
charcoal: "#b9b5ae",
|
| 102 |
+
purple: "#bfa9cf",
|
| 103 |
+
blue: "#a9dbe8",
|
| 104 |
+
};
|
| 105 |
+
|
| 106 |
+
// #rrggbb -> rgba() at the halftone's alpha.
|
| 107 |
+
const tint = (hex, a) =>
|
| 108 |
+
`rgba(${parseInt(hex.slice(1, 3), 16)}, ${parseInt(hex.slice(3, 5), 16)}, ${parseInt(hex.slice(5, 7), 16)}, ${a})`;
|
| 109 |
+
|
| 110 |
function closeMenu() {
|
| 111 |
useGame.setState({ menuOpen: false });
|
| 112 |
if (!useGame.getState().entered) useGame.setState({ entered: true });
|
| 113 |
+
// Menu-confirm bloop. On the first entry the `entered` latch above has
|
| 114 |
+
// just created the shared AudioContext inside this very gesture, so the
|
| 115 |
+
// bloop rides the unlock (the synth resume-retries if still suspended).
|
| 116 |
+
playConfirmBloop();
|
| 117 |
}
|
| 118 |
|
| 119 |
export default function TitleMenu() {
|
|
|
|
| 121 |
const entered = useGame((s) => s.entered);
|
| 122 |
const padConnected = useGame((s) => s.padConnected);
|
| 123 |
const touchMode = useGame((s) => s.touchMode);
|
| 124 |
+
const variant = useGame((s) => s.variant);
|
| 125 |
+
const bootDone = useGame((s) => s.bootDone);
|
| 126 |
+
const bootFailed = useGame((s) => s.bootFailed);
|
| 127 |
const [closing, setClosing] = useState(false);
|
| 128 |
const prevOpen = useRef(menuOpen);
|
| 129 |
|
| 130 |
+
// Touch devices and narrow (mobile-sized) screens skip the 3D stage
|
| 131 |
+
// entirely: no duck column, and the GLB is never even downloaded. The
|
| 132 |
+
// width check matches the layout's md breakpoint, so the stage never
|
| 133 |
+
// renders where the column would stack under the text anyway. Everyone
|
| 134 |
+
// else holds a spinner until the stage's assets are in, so the content
|
| 135 |
+
// reveal and the duck's entrance scan cue together. The stage is a
|
| 136 |
+
// module singleton, so pause reopens resolve instantly (no second
|
| 137 |
+
// spinner).
|
| 138 |
+
const [wide, setWide] = useState(
|
| 139 |
+
() => window.matchMedia("(min-width: 900px)").matches,
|
| 140 |
+
);
|
| 141 |
+
useEffect(() => {
|
| 142 |
+
const mq = window.matchMedia("(min-width: 900px)");
|
| 143 |
+
const onChange = () => setWide(mq.matches);
|
| 144 |
+
mq.addEventListener("change", onChange);
|
| 145 |
+
return () => mq.removeEventListener("change", onChange);
|
| 146 |
+
}, []);
|
| 147 |
+
const showDuck = !touchMode && wide;
|
| 148 |
+
const [duckReady, setDuckReady] = useState(isMenuDuckReady);
|
| 149 |
+
useEffect(() => {
|
| 150 |
+
if (!showDuck || duckReady) return;
|
| 151 |
+
let on = true;
|
| 152 |
+
preloadMenuDuck()
|
| 153 |
+
.catch(() => {})
|
| 154 |
+
.then(() => { if (on) setDuckReady(true); });
|
| 155 |
+
return () => { on = false; };
|
| 156 |
+
}, [showDuck, duckReady]);
|
| 157 |
+
|
| 158 |
+
// The display face too: Anton is the screen's voice (title, CTA,
|
| 159 |
+
// strip) and would otherwise FOUT in after the reveal. It loads
|
| 160 |
+
// lazily on first use, so nudge it explicitly - the content is gated
|
| 161 |
+
// and would never request it. Timeout so a stuck font never wedges
|
| 162 |
+
// the menu behind the spinner.
|
| 163 |
+
const [fontsReady, setFontsReady] = useState(
|
| 164 |
+
() => !!document.fonts?.check?.("1rem Anton"),
|
| 165 |
+
);
|
| 166 |
+
useEffect(() => {
|
| 167 |
+
if (fontsReady) return;
|
| 168 |
+
let on = true;
|
| 169 |
+
const done = () => { if (on) setFontsReady(true); };
|
| 170 |
+
const anton = document.fonts?.load
|
| 171 |
+
? document.fonts.load("1rem Anton")
|
| 172 |
+
: Promise.resolve();
|
| 173 |
+
const allFonts = document.fonts?.ready ?? Promise.resolve();
|
| 174 |
+
Promise.all([anton, allFonts]).then(done, done);
|
| 175 |
+
const t = setTimeout(done, 4000);
|
| 176 |
+
return () => { on = false; clearTimeout(t); };
|
| 177 |
+
}, [fontsReady]);
|
| 178 |
+
|
| 179 |
+
// The brand lockup images too (formerly the preboot veil's job): the
|
| 180 |
+
// corner chrome is part of the gated reveal, so the logo must not pop
|
| 181 |
+
// in after it. Capped so a broken asset never wedges the menu.
|
| 182 |
+
const [brandReady, setBrandReady] = useState(false);
|
| 183 |
+
useEffect(() => {
|
| 184 |
+
if (brandReady) return;
|
| 185 |
+
let on = true;
|
| 186 |
+
const done = () => { if (on) setBrandReady(true); };
|
| 187 |
+
const load = (src) =>
|
| 188 |
+
new Promise((res) => {
|
| 189 |
+
const img = new Image();
|
| 190 |
+
img.src = signed(src);
|
| 191 |
+
if (img.complete && img.naturalWidth) res();
|
| 192 |
+
else {
|
| 193 |
+
img.addEventListener("load", res, { once: true });
|
| 194 |
+
img.addEventListener("error", res, { once: true });
|
| 195 |
+
}
|
| 196 |
+
});
|
| 197 |
+
Promise.all([
|
| 198 |
+
load("./assets/duck-head-mark.webp"),
|
| 199 |
+
load("./assets/duck-head-mark-open.webp"),
|
| 200 |
+
]).then(done, done);
|
| 201 |
+
const t = setTimeout(done, 2500);
|
| 202 |
+
return () => { on = false; clearTimeout(t); };
|
| 203 |
+
}, [brandReady]);
|
| 204 |
+
|
| 205 |
+
// The game core too: it boots eagerly behind the title screen (MuJoCo
|
| 206 |
+
// WASM, scene geometry, policy sessions), saturating the main thread
|
| 207 |
+
// in ~10-40 ms chunks for a couple of seconds. Revealing the menu
|
| 208 |
+
// mid-boot made the duck's entrance scan stutter (profiled: the scan
|
| 209 |
+
// window sat inside the boot's busy region), so the spinner holds
|
| 210 |
+
// until the boot settles - the reveal then animates on a free thread
|
| 211 |
+
// and Waddle-in is instant. bootFailed releases the gate: the halt
|
| 212 |
+
// screen owns the viewport at that point.
|
| 213 |
+
const ready =
|
| 214 |
+
(duckReady || !showDuck) &&
|
| 215 |
+
fontsReady &&
|
| 216 |
+
brandReady &&
|
| 217 |
+
(bootDone || bootFailed);
|
| 218 |
+
|
| 219 |
// Keep the overlay mounted through the 0.35 s closing fade.
|
| 220 |
useEffect(() => {
|
| 221 |
const was = prevOpen.current;
|
|
|
|
| 250 |
return () => window.removeEventListener("keydown", onKey);
|
| 251 |
}, []);
|
| 252 |
|
| 253 |
+
// Gamepad twin of the handler above: A (button 0) enters / resumes
|
| 254 |
+
// through the same closeMenu() as the CTA click; Start (9) or Select (8)
|
| 255 |
+
// reopens the overlay in-game, like Escape. Polled with a light rAF loop
|
| 256 |
+
// because the game's own gamepad source only exists once the core has
|
| 257 |
+
// booted (and its actions are game-facing). Edge state persists across
|
| 258 |
+
// the open/close transition, so one press fires exactly one action.
|
| 259 |
+
useEffect(() => {
|
| 260 |
+
const prev = { enter: false, menu: false };
|
| 261 |
+
// The A press that closes the menu is applied one frame LATE on
|
| 262 |
+
// purpose: closing unlocks the game inputs synchronously, and the
|
| 263 |
+
// in-game gamepad source polls in the same rAF round - if it sees the
|
| 264 |
+
// press only after the unlock, its own A edge fires (groundPick). The
|
| 265 |
+
// one-frame delay guarantees it polls the press at least once while
|
| 266 |
+
// the pause lock still gates it, so by the unlock frame its edge
|
| 267 |
+
// state already knows the button is down and the entering press is
|
| 268 |
+
// swallowed.
|
| 269 |
+
let enterPending = false;
|
| 270 |
+
let raf;
|
| 271 |
+
const poll = () => {
|
| 272 |
+
raf = requestAnimationFrame(poll);
|
| 273 |
+
if (enterPending) {
|
| 274 |
+
enterPending = false;
|
| 275 |
+
if (useGame.getState().menuOpen) closeMenu();
|
| 276 |
+
}
|
| 277 |
+
// Same pad preference as controls/gamepad.js: standard mapping first.
|
| 278 |
+
const pads = [...(navigator.getGamepads?.() ?? [])].filter((p) => p && p.connected);
|
| 279 |
+
const gp = pads.find((p) => p.mapping === "standard") ?? pads[0];
|
| 280 |
+
if (!gp) {
|
| 281 |
+
prev.enter = false;
|
| 282 |
+
prev.menu = false;
|
| 283 |
+
return;
|
| 284 |
+
}
|
| 285 |
+
const enter = !!gp.buttons[0]?.pressed;
|
| 286 |
+
const menu = !!(gp.buttons[9]?.pressed || gp.buttons[8]?.pressed);
|
| 287 |
+
const enterEdge = enter && !prev.enter;
|
| 288 |
+
const menuEdge = menu && !prev.menu;
|
| 289 |
+
prev.enter = enter;
|
| 290 |
+
prev.menu = menu;
|
| 291 |
+
const s = useGame.getState();
|
| 292 |
+
if (enterEdge && s.menuOpen) {
|
| 293 |
+
enterPending = true;
|
| 294 |
+
return;
|
| 295 |
+
}
|
| 296 |
+
if (menuEdge && !s.menuOpen && s.entered && !s.biosVisible)
|
| 297 |
+
useGame.setState({ menuOpen: true });
|
| 298 |
+
};
|
| 299 |
+
raf = requestAnimationFrame(poll);
|
| 300 |
+
return () => cancelAnimationFrame(raf);
|
| 301 |
+
}, []);
|
| 302 |
+
|
| 303 |
if (!menuOpen && !closing) return null;
|
| 304 |
|
| 305 |
// A plugged-in gamepad wins over the touch tutorial.
|
| 306 |
const tutorialVariant = padConnected ? "pad" : touchMode ? "touch" : "kb";
|
| 307 |
+
const shortcuts = SHORTCUTS[tutorialVariant];
|
| 308 |
const ctaLabel = entered ? "Resume" : "Waddle in";
|
| 309 |
+
// Blinking key prompt under the CTA; touch has no key to press.
|
| 310 |
+
const enterHint = padConnected ? "press A" : touchMode ? null : "press Enter";
|
| 311 |
+
const accent = VARIANT_ACCENTS[variant] ?? ORANGE;
|
| 312 |
|
| 313 |
return (
|
| 314 |
<Box
|
|
|
|
| 334 |
{/* Theme-orange halftone pooling in the top-left corner, dots
|
| 335 |
growing toward it - the landing's screen-tone on ink ground. */}
|
| 336 |
<HalftoneRamp
|
| 337 |
+
color={tint(accent, 0.16)}
|
| 338 |
size={20}
|
| 339 |
corner="top-left"
|
| 340 |
reach={60}
|
| 341 |
/>
|
| 342 |
|
| 343 |
+
{/* Corner chrome, shared grammar with the vitrine header (drawn
|
| 344 |
+
duck head, top-left) and the in-game HUD (pre-order, top-right):
|
| 345 |
+
the title screen wears the same screen furniture as the game.
|
| 346 |
+
Gated with the rest of the reveal so the loading state is a
|
| 347 |
+
single centered spinner on bare ink. */}
|
| 348 |
+
{ready && (
|
| 349 |
<Box
|
| 350 |
+
aria-hidden
|
| 351 |
sx={{
|
| 352 |
+
position: "absolute",
|
| 353 |
+
top: "1.25rem",
|
| 354 |
+
left: "1.5rem",
|
| 355 |
+
zIndex: 1,
|
| 356 |
+
display: "flex",
|
| 357 |
+
alignItems: "center",
|
| 358 |
+
gap: "0.65rem",
|
| 359 |
+
userSelect: "none",
|
| 360 |
+
...row(0, brandIn),
|
| 361 |
+
"&:hover .duck-closed": { opacity: 0 },
|
| 362 |
+
"&:hover .duck-open": { opacity: 1 },
|
| 363 |
}}
|
| 364 |
>
|
|
|
|
|
|
|
| 365 |
<Box
|
| 366 |
+
component="span"
|
| 367 |
+
sx={{
|
| 368 |
+
position: "relative",
|
| 369 |
+
display: "block",
|
| 370 |
+
height: "2.1rem",
|
| 371 |
+
filter: "drop-shadow(2px 2px 0 rgba(0, 0, 0, 0.5))",
|
| 372 |
+
}}
|
| 373 |
+
>
|
| 374 |
+
<Box
|
| 375 |
+
component="img"
|
| 376 |
+
className="duck-closed"
|
| 377 |
+
alt=""
|
| 378 |
+
src={signed("./assets/duck-head-mark.webp")}
|
| 379 |
+
sx={{ display: "block", height: "100%", width: "auto" }}
|
| 380 |
+
/>
|
| 381 |
+
{/* The open frame's canvas (460x333) is a hair wider/taller
|
| 382 |
+
than the closed one (454x269); offsets pin the head while
|
| 383 |
+
the jaw hangs below. */}
|
| 384 |
+
<Box
|
| 385 |
+
component="img"
|
| 386 |
+
className="duck-open"
|
| 387 |
+
alt=""
|
| 388 |
+
src={signed("./assets/duck-head-mark-open.webp")}
|
| 389 |
+
sx={{
|
| 390 |
+
position: "absolute",
|
| 391 |
+
top: "-0.75%",
|
| 392 |
+
left: "-1.1%",
|
| 393 |
+
width: "101.3%",
|
| 394 |
+
maxWidth: "none",
|
| 395 |
+
height: "auto",
|
| 396 |
+
opacity: 0,
|
| 397 |
+
}}
|
| 398 |
+
/>
|
| 399 |
+
</Box>
|
| 400 |
+
<Box
|
| 401 |
+
component="span"
|
| 402 |
+
sx={{
|
| 403 |
+
fontFamily: ANTON,
|
| 404 |
+
fontSize: "1rem",
|
| 405 |
+
letterSpacing: "0.05em",
|
| 406 |
+
textTransform: "uppercase",
|
| 407 |
+
color: "#fff",
|
| 408 |
+
display: { xs: "none", sm: "block" },
|
| 409 |
+
}}
|
| 410 |
+
>
|
| 411 |
+
Microduck
|
| 412 |
+
</Box>
|
| 413 |
+
</Box>
|
| 414 |
+
)}
|
| 415 |
+
|
| 416 |
+
{/* The in-game HUD's shop plate, verbatim: same component, same
|
| 417 |
+
fixed spot, so pausing never swaps it for a lookalike. */}
|
| 418 |
+
{ready && <PreorderButton sx={{ ...row(0.1) }} />}
|
| 419 |
+
|
| 420 |
+
{/* Loading gate: one centered spinner on bare ink until logo,
|
| 421 |
+
fonts, the duck stage's assets and the game boot are all in -
|
| 422 |
+
then the whole composition (corner chrome included) reveals at
|
| 423 |
+
once (row stagger + entrance scan). */}
|
| 424 |
+
{!ready && (
|
| 425 |
+
<Box
|
| 426 |
+
role="status"
|
| 427 |
+
aria-label="Loading"
|
| 428 |
sx={{
|
| 429 |
+
m: "auto",
|
| 430 |
display: "flex",
|
| 431 |
flexDirection: "column",
|
| 432 |
alignItems: "center",
|
| 433 |
+
gap: "1.1rem",
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 434 |
}}
|
| 435 |
>
|
| 436 |
<Box
|
| 437 |
+
aria-hidden
|
| 438 |
sx={{
|
| 439 |
+
width: "2.4rem",
|
| 440 |
+
height: "2.4rem",
|
| 441 |
+
borderRadius: "50%",
|
| 442 |
+
border: "3px solid rgba(255, 255, 255, 0.14)",
|
| 443 |
+
borderTopColor: accent,
|
| 444 |
+
animation: `${spin} 0.8s linear infinite`,
|
| 445 |
+
}}
|
| 446 |
+
/>
|
| 447 |
+
<Typography
|
| 448 |
+
sx={{
|
| 449 |
+
fontFamily: MONO,
|
| 450 |
+
fontSize: "0.64rem",
|
| 451 |
+
fontWeight: 600,
|
| 452 |
+
letterSpacing: "0.16em",
|
| 453 |
+
textTransform: "uppercase",
|
| 454 |
+
color: "rgba(255, 255, 255, 0.5)",
|
| 455 |
}}
|
| 456 |
>
|
| 457 |
+
Loading
|
| 458 |
+
</Typography>
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 459 |
</Box>
|
| 460 |
+
)}
|
| 461 |
|
| 462 |
+
{/* Split title screen: brand + CTA column on the left, the live
|
| 463 |
+
duck stage on the right (stacked on narrow screens). The row
|
| 464 |
+
stretches to fill the space between the corner chrome and the
|
| 465 |
+
bottom strip, so the duck stands ON the strip's rule - the
|
| 466 |
+
screen has a floor instead of elements floating mid-air. */}
|
| 467 |
+
{ready && (
|
| 468 |
+
<>
|
| 469 |
+
<Box
|
| 470 |
+
sx={{
|
| 471 |
+
position: "relative",
|
| 472 |
+
display: "flex",
|
| 473 |
+
flexDirection: { xs: "column", md: "row" },
|
| 474 |
+
alignItems: "stretch",
|
| 475 |
+
justifyContent: "center",
|
| 476 |
+
gap: { xs: "0.4rem", md: "1.5rem" },
|
| 477 |
+
width: "100%",
|
| 478 |
+
maxWidth: "min(66rem, 94vw)",
|
| 479 |
+
flex: "1 1 auto",
|
| 480 |
+
minHeight: 0,
|
| 481 |
+
pt: { xs: "2.6rem", md: 0 },
|
| 482 |
+
}}
|
| 483 |
+
>
|
| 484 |
+
<Box
|
| 485 |
+
sx={{
|
| 486 |
+
position: "relative",
|
| 487 |
+
flex: { md: "0 1 33rem" },
|
| 488 |
+
width: "100%",
|
| 489 |
+
maxWidth: { xs: "min(48rem, 92vw)", md: "none" },
|
| 490 |
+
mx: { xs: "auto", md: 0 },
|
| 491 |
+
// Game-menu grammar: the text block rides the column's vertical
|
| 492 |
+
// centre on desktop; left-anchored there, centered when stacked
|
| 493 |
+
// or when the duck column is absent (touch mode).
|
| 494 |
+
display: "flex",
|
| 495 |
+
flexDirection: "column",
|
| 496 |
+
justifyContent: "center",
|
| 497 |
+
alignItems: { xs: "center", md: showDuck ? "flex-start" : "center" },
|
| 498 |
+
textAlign: { xs: "center", md: showDuck ? "left" : "center" },
|
| 499 |
+
}}
|
| 500 |
+
>
|
| 501 |
{/* The landing hero's mistracked-VHS treatment: orange fill with
|
| 502 |
ink drop + chroma ghosts, hollow echo line below. */}
|
| 503 |
<ComicTitle
|
| 504 |
component="h1"
|
| 505 |
tone="dark"
|
| 506 |
+
accent={accent}
|
| 507 |
+
fontSize="clamp(3.4rem, 9vw, 6.2rem)"
|
| 508 |
lines={[
|
| 509 |
{ text: "Microduck" },
|
| 510 |
+
{ text: "Simulator", variant: "outline", scale: 0.78 },
|
| 511 |
]}
|
| 512 |
sx={{ ...row(0.08) }}
|
| 513 |
/>
|
| 514 |
|
| 515 |
<Typography
|
| 516 |
sx={{
|
| 517 |
+
mx: { xs: "auto", md: 0 },
|
| 518 |
mt: "1.1rem",
|
| 519 |
"@media (max-height: 700px)": { mt: "0.8rem" },
|
| 520 |
+
maxWidth: "34ch",
|
| 521 |
fontSize: { xs: "0.95rem", sm: "1.05rem" },
|
| 522 |
lineHeight: 1.5,
|
| 523 |
letterSpacing: "-0.012em",
|
|
|
|
| 530 |
live in your browser.
|
| 531 |
</Typography>
|
| 532 |
|
| 533 |
+
{/* CTA + key prompt travel as one block so the prompt stays
|
| 534 |
+
centred under the button whatever the column's alignment. */}
|
| 535 |
<Box
|
| 536 |
sx={{
|
| 537 |
+
display: "inline-flex",
|
| 538 |
+
flexDirection: "column",
|
| 539 |
+
alignItems: "center",
|
| 540 |
+
mt: "2rem",
|
| 541 |
+
"@media (max-height: 700px)": { mt: "1.3rem" },
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 542 |
...row(0.32),
|
| 543 |
}}
|
| 544 |
>
|
| 545 |
+
<ComicButton
|
| 546 |
+
scheme="orange"
|
| 547 |
+
size="medium"
|
| 548 |
+
onDark
|
| 549 |
+
data-cta="1"
|
| 550 |
+
onClick={closeMenu}
|
| 551 |
+
>
|
| 552 |
+
{/* Pad prompt: ink circle with the A face button, shown while a
|
| 553 |
+
gamepad is connected - it mirrors the A-to-enter binding of
|
| 554 |
+
the poll loop above. Decorative; the text label carries the
|
| 555 |
+
meaning. */}
|
| 556 |
+
{padConnected && (
|
| 557 |
<Box
|
| 558 |
+
component="span"
|
| 559 |
+
aria-hidden
|
| 560 |
sx={{
|
| 561 |
+
display: "inline-flex",
|
|
|
|
| 562 |
alignItems: "center",
|
| 563 |
justifyContent: "center",
|
| 564 |
+
width: "1.5em",
|
| 565 |
+
height: "1.5em",
|
| 566 |
+
borderRadius: "50%",
|
| 567 |
+
background: INK,
|
| 568 |
+
color: CREAM,
|
| 569 |
+
fontSize: "0.72em",
|
| 570 |
+
lineHeight: 1,
|
|
|
|
|
|
|
|
|
|
| 571 |
}}
|
| 572 |
>
|
| 573 |
+
A
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 574 |
</Box>
|
| 575 |
+
)}
|
| 576 |
+
{ctaLabel}
|
| 577 |
+
</ComicButton>
|
| 578 |
+
|
| 579 |
+
{enterHint && (
|
| 580 |
+
<Typography
|
| 581 |
+
aria-hidden
|
| 582 |
+
sx={{
|
| 583 |
+
mt: "0.85rem",
|
| 584 |
+
fontFamily: MONO,
|
| 585 |
+
fontSize: "0.66rem",
|
| 586 |
+
fontWeight: 600,
|
| 587 |
+
letterSpacing: "0.14em",
|
| 588 |
+
textTransform: "uppercase",
|
| 589 |
+
color: "rgba(255, 255, 255, 0.6)",
|
| 590 |
+
...row(0.4),
|
| 591 |
+
}}
|
| 592 |
+
>
|
| 593 |
<Box
|
| 594 |
+
component="span"
|
| 595 |
sx={{
|
| 596 |
+
display: "inline-block",
|
| 597 |
+
animation: `${promptPulse} 0.95s ease-in-out infinite alternate`,
|
| 598 |
+
"@media (prefers-reduced-motion: reduce)": {
|
| 599 |
+
animation: "none",
|
| 600 |
+
opacity: 0.6,
|
| 601 |
+
},
|
| 602 |
}}
|
| 603 |
>
|
| 604 |
+
{enterHint}
|
| 605 |
+
</Box>
|
| 606 |
+
</Typography>
|
| 607 |
+
)}
|
| 608 |
+
</Box>
|
| 609 |
+
</Box>
|
| 610 |
+
|
| 611 |
+
{/* Right column: the duck's simulated twin, live - head follows the
|
| 612 |
+
pointer, click quacks. Its colourway tracks the in-game duck. No
|
| 613 |
+
backdrop, no fake shadow: the render alone, standing on the
|
| 614 |
+
bottom strip's rule like a character select. Touch devices skip
|
| 615 |
+
the stage entirely (no column, no GLB download). */}
|
| 616 |
+
{showDuck && (
|
| 617 |
+
<Box
|
| 618 |
+
sx={{
|
| 619 |
+
position: "relative",
|
| 620 |
+
flex: { xs: "1 1 auto", md: "1 1 22rem" },
|
| 621 |
+
width: "100%",
|
| 622 |
+
minHeight: { xs: "min(30vh, 16rem)", md: 0 },
|
| 623 |
+
...row(0.16, brandIn),
|
| 624 |
+
}}
|
| 625 |
+
>
|
| 626 |
+
<MenuDuck sx={{ position: "absolute", inset: 0 }} />
|
| 627 |
+
</Box>
|
| 628 |
+
)}
|
| 629 |
+
</Box>
|
| 630 |
+
|
| 631 |
+
{/* Bottom cheat strip: every move on one wrapping line, arcade
|
| 632 |
+
instruction-card style. Reference material, not the main event -
|
| 633 |
+
it sits at the screen's bottom edge, out of the CTA's way. */}
|
| 634 |
+
<Box
|
| 635 |
+
sx={{
|
| 636 |
+
position: "relative",
|
| 637 |
+
width: "100%",
|
| 638 |
+
textAlign: "center",
|
| 639 |
+
mt: "1.2rem",
|
| 640 |
+
pt: "1.15rem",
|
| 641 |
+
borderTop: "1px solid rgba(255, 255, 255, 0.08)",
|
| 642 |
+
...row(0.5),
|
| 643 |
+
}}
|
| 644 |
+
>
|
| 645 |
+
<Box
|
| 646 |
+
sx={{
|
| 647 |
+
display: "flex",
|
| 648 |
+
flexWrap: "wrap",
|
| 649 |
+
justifyContent: "center",
|
| 650 |
+
alignItems: "center",
|
| 651 |
+
gap: "0.55rem 1.25rem",
|
| 652 |
+
// Strip keycaps are a notch smaller than the CTA scale.
|
| 653 |
+
"& kbd": {
|
| 654 |
+
minWidth: "1.45rem",
|
| 655 |
+
height: "1.45rem",
|
| 656 |
+
p: "0 0.34rem",
|
| 657 |
+
fontSize: "0.6rem",
|
| 658 |
+
},
|
| 659 |
+
}}
|
| 660 |
+
>
|
| 661 |
+
{shortcuts.map((s) => (
|
| 662 |
+
<Box
|
| 663 |
+
key={s.name}
|
| 664 |
+
sx={{
|
| 665 |
+
display: "inline-flex",
|
| 666 |
+
alignItems: "center",
|
| 667 |
+
gap: "0.45rem",
|
| 668 |
+
}}
|
| 669 |
+
>
|
| 670 |
+
<Box sx={{ display: "inline-flex", gap: "0.22rem" }}>
|
| 671 |
+
{s.caps.map((c) => (
|
| 672 |
+
<Kbd key={c} round={s.round ? 1 : 0}>{c}</Kbd>
|
| 673 |
+
))}
|
| 674 |
</Box>
|
| 675 |
<Box
|
| 676 |
+
component="span"
|
| 677 |
sx={{
|
| 678 |
+
fontFamily: ANTON,
|
| 679 |
+
fontSize: "0.74rem",
|
| 680 |
+
letterSpacing: "0.07em",
|
|
|
|
| 681 |
textTransform: "uppercase",
|
| 682 |
+
color: "rgba(255, 255, 255, 0.88)",
|
| 683 |
}}
|
| 684 |
>
|
| 685 |
+
{s.name}
|
| 686 |
</Box>
|
| 687 |
</Box>
|
| 688 |
))}
|
|
|
|
| 690 |
|
| 691 |
<Typography
|
| 692 |
sx={{
|
| 693 |
+
mt: "0.7rem",
|
| 694 |
fontFamily: MONO,
|
| 695 |
+
fontSize: "0.62rem",
|
| 696 |
fontWeight: 600,
|
| 697 |
letterSpacing: "0.12em",
|
| 698 |
textTransform: "uppercase",
|
| 699 |
+
color: "rgba(255, 255, 255, 0.34)",
|
|
|
|
| 700 |
}}
|
| 701 |
>
|
| 702 |
{HINTS[tutorialVariant]}
|
| 703 |
</Typography>
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 704 |
</Box>
|
| 705 |
+
</>
|
| 706 |
+
)}
|
| 707 |
</Box>
|
| 708 |
);
|
| 709 |
}
|