tfrere HF Staff Cursor commited on
Commit
183f99a
·
1 Parent(s): 1261013

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 CHANGED
@@ -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
 
app/package-lock.json CHANGED
@@ -1,21 +1,23 @@
1
  {
2
- "name": "app",
3
  "version": "1.0.0",
4
  "lockfileVersion": 3,
5
  "requires": true,
6
  "packages": {
7
  "": {
8
- "name": "app",
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",
app/package.json CHANGED
@@ -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": {
app/public/assets/sfx/click_a.wav ADDED
@@ -0,0 +1,3 @@
 
 
 
 
1
+ version https://git-lfs.github.com/spec/v1
2
+ oid sha256:f32c69bad5518fc3c04fac976b3d50579acfe0699512c8ceee91140338add875
3
+ size 590
app/public/assets/sfx/click_b.wav ADDED
@@ -0,0 +1,3 @@
 
 
 
 
1
+ version https://git-lfs.github.com/spec/v1
2
+ oid sha256:4edce0301e1be6d384a20384d6059fa589af454cd1f31b94bef0c2bf7c349bef
3
+ size 1038
app/public/assets/sfx/step_a.wav ADDED
@@ -0,0 +1,3 @@
 
 
 
 
1
+ version https://git-lfs.github.com/spec/v1
2
+ oid sha256:a6afd9783e72a73276e975d5baba29845835fa326002c31546651f0f6dfb6ebd
3
+ size 4622
app/public/assets/sfx/step_b.wav ADDED
@@ -0,0 +1,3 @@
 
 
 
 
1
+ version https://git-lfs.github.com/spec/v1
2
+ oid sha256:2ea5271e219a8c2bc91ec9e253b71714b22e44a7f3e3fd1f5c33cea3435118a7
3
+ size 4698
app/public/assets/sfx/step_c.wav ADDED
@@ -0,0 +1,3 @@
 
 
 
 
1
+ version https://git-lfs.github.com/spec/v1
2
+ oid sha256:a3bdc8a240a9d20b1215e7d24787e0c478373fcf02c4971c919151dbb7113ef4
3
+ size 4924
app/public/assets/sfx/step_d.wav ADDED
@@ -0,0 +1,3 @@
 
 
 
 
1
+ version https://git-lfs.github.com/spec/v1
2
+ oid sha256:bad640d37d62a21589aa363dcfc763546bd548da260ec371841dcfb2f47ee2a5
3
+ size 4824
app/public/assets/sfx/step_e.wav ADDED
@@ -0,0 +1,3 @@
 
 
 
 
1
+ version https://git-lfs.github.com/spec/v1
2
+ oid sha256:2b06c21370d2ffffad065cb0b1a6c54343ec130b9073d291769d24f6ca60e3bb
3
+ size 4994
app/public/assets/sfx/thump_a.wav ADDED
@@ -0,0 +1,3 @@
 
 
 
 
1
+ version https://git-lfs.github.com/spec/v1
2
+ oid sha256:8fc501c13cb17d31b5db880c12326848a09afbdf194b771afbdcefd922a5607f
3
+ size 19066
app/public/assets/sfx/thump_b.wav ADDED
@@ -0,0 +1,3 @@
 
 
 
 
1
+ version https://git-lfs.github.com/spec/v1
2
+ oid sha256:27597d19b0ffc351f0a56b83c8ea7a76aae3c97e6aeb4a9fdb57ebe0400cfe81
3
+ size 17794
app/public/assets/sfx/thump_c.wav ADDED
@@ -0,0 +1,3 @@
 
 
 
 
1
+ version https://git-lfs.github.com/spec/v1
2
+ oid sha256:46205843156b8d4923ed392f213ccc2a326b67881b3f67dc4c082958a52fdf4f
3
+ size 23954
app/src/App.jsx CHANGED
@@ -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), preboot
3
- // veil (40). Also owns the pre-game device detection (gamepad, touch) and
4
- // the preboot -> title -> enter flow.
 
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
- // Preboot veil: hold the title page until its logo and the fonts are
46
- // in (grey spinner meanwhile), capped so a broken asset can never wall
47
- // the menu off. ?boot=1 skips the title entirely - testing hook.
48
  if (params.get("boot") === "1") {
49
  useGame.setState({ prebootDone: true, entered: true, menuOpen: false });
50
  } else {
51
- const img = new Image();
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
  }
app/src/game/arena.js CHANGED
@@ -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).
app/src/game/audio.js ADDED
@@ -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
+ }
app/src/game/ceremony.js CHANGED
@@ -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;
app/src/game/game.js CHANGED
@@ -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 stay on the CDN, exactly like the pre-Vite
58
- // app: mujoco.js resolves its .wasm sidecar relative to its own URL, and
59
- // onnxruntime fetches its wasm from wasmPaths - neither ever touches the
60
- // bundle. @vite-ignore keeps Rollup's static analysis out of it.
61
- const MUJOCO_URL = "https://cdn.jsdelivr.net/npm/@mujoco/mujoco@3.11.0/mujoco.js";
62
- const ORT_URL = "https://cdn.jsdelivr.net/npm/onnxruntime-web@1.27.0/dist/ort.min.mjs";
 
 
63
 
64
  let bootStarted = false;
65
 
@@ -123,15 +130,20 @@ async function boot({ scene, camera, renderer }) {
123
  );
124
  };
125
 
126
- // ── Runtimes (CDN) ──────────────────────────────────────────────────
127
  const [{ default: loadMujocoFactory }, ort] = await traced(
128
  "RUNTIME MODULES",
129
  Promise.all([
130
- import(/* @vite-ignore */ MUJOCO_URL),
131
- import(/* @vite-ignore */ ORT_URL),
 
 
 
132
  ]),
133
  );
134
- ort.env.wasm.wasmPaths = "https://cdn.jsdelivr.net/npm/onnxruntime-web@1.27.0/dist/";
 
 
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
- const url = signed(`./assets/voices/${bank}/chirp_${take}.wav`);
1299
- let a = chirpCache.get(url);
1300
- if (!a) {
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 ??= new (window.AudioContext ?? window.webkitAudioContext)();
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(wheeeCtx.destination);
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();
app/src/game/ghosts.js CHANGED
@@ -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
- const { joinRoom } = await import(/* @vite-ignore */ TRYSTERO_URL);
77
- room = joinRoom({ appId: APP_ID }, ROOM);
 
 
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;
app/src/game/vendor/zzfx.js ADDED
@@ -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));
app/src/main.jsx CHANGED
@@ -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
- import App from "./App.jsx";
 
 
 
 
 
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
- <App />
 
 
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
  );
app/src/ui/BiosOverlay.jsx CHANGED
@@ -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:
app/src/ui/Hud.jsx CHANGED
@@ -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
- function PreorderButton() {
 
 
 
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`;
app/src/ui/MenuDuck.jsx ADDED
@@ -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
+ }
app/src/ui/Preboot.jsx DELETED
@@ -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
- }
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
app/src/ui/Soundboard.jsx ADDED
@@ -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
+ `;
app/src/ui/TitleMenu.jsx CHANGED
@@ -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
- const TILES = {
 
 
 
53
  kb: [
54
- { caps: "cluster-arrows", name: "Move", hint: "arrows or ZQSD" },
55
- { caps: ["A", "E"], name: "Kick", hint: "left / right" },
56
- { caps: ["R"], name: "Sit", hint: "tap again to stand" },
57
- { caps: ["G"], name: "Pick up", hint: "beak to the ground" },
58
- { caps: ["C"], name: "Camera", hint: "toggle chase" },
59
- { caps: ["Space"], name: "Reset", hint: "fresh start" },
60
  ],
61
  pad: [
62
- { caps: ["LS"], name: "Move", hint: "left stick" },
63
- { caps: ["LB", "RB"], name: "Kick", hint: "left / right" },
64
- { caps: ["Y"], name: "Head", hint: "sticks move the head" },
65
- { caps: ["RS"], name: "Camera", hint: "orbit" },
66
- { caps: ["X", "\u2193"], name: "Sit", hint: "tap again to stand" },
67
  ],
68
  touch: [
69
- { caps: ["Stick"], name: "Move", hint: "left thumb" },
70
- { caps: ["A"], round: true, name: "Kick", hint: "tap it" },
71
- { caps: ["B"], round: true, name: "Quack", hint: "hold for beak" },
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 tiles = TILES[tutorialVariant];
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="rgba(255, 122, 47, 0.16)"
159
  size={20}
160
  corner="top-left"
161
  reach={60}
162
  />
163
 
 
 
 
 
 
 
164
  <Box
 
165
  sx={{
166
- position: "relative",
167
- width: "100%",
168
- maxWidth: "min(48rem, 92vw)",
169
- textAlign: "center",
170
- m: "auto 0",
 
 
 
 
 
 
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
- aria-hidden
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
177
  sx={{
 
178
  display: "flex",
179
  flexDirection: "column",
180
  alignItems: "center",
181
- justifyContent: "center",
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
- component="span"
192
  sx={{
193
- position: "relative",
194
- display: "block",
195
- height: { xs: "2.4rem", sm: "3rem" },
196
- filter: "drop-shadow(3px 3px 0 rgba(0, 0, 0, 0.5))",
 
 
 
 
 
 
 
 
 
 
 
 
197
  }}
198
  >
199
- <Box
200
- component="img"
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={ORANGE}
233
- fontSize="clamp(3.1rem, 10vw, 5.4rem)"
234
  lines={[
235
  { text: "Microduck" },
236
- { text: "Simulator", variant: "outline", scale: 0.6 },
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: "36ch",
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: "grid",
262
- // Three columns from ~500px up (two rows of tiles); narrow
263
- // phones fall back to two.
264
- gridTemplateColumns:
265
- tutorialVariant === "touch" ? "repeat(3, 1fr)" : "repeat(2, 1fr)",
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
- {tiles.map((t) => (
276
- <Box
277
- key={t.name}
278
- sx={{
279
- p: { xs: "0.65rem 0.35rem 0.6rem", sm: "0.8rem 0.45rem 0.7rem" },
280
- border: "1px solid rgba(255, 255, 255, 0.09)",
281
- background: "rgba(255, 255, 255, 0.035)",
282
- }}
283
- >
 
 
 
284
  <Box
 
 
285
  sx={{
286
- display: "flex",
287
- flexDirection: "column",
288
  alignItems: "center",
289
  justifyContent: "center",
290
- gap: "0.2rem",
291
- height: { xs: "2.8rem", sm: "3.3rem" },
292
- // Arrow-cluster keycaps are a notch smaller so two rows
293
- // fit.
294
- "& .cluster kbd": {
295
- minWidth: "1.4rem",
296
- height: "1.4rem",
297
- p: "0 0.3rem",
298
- fontSize: "0.62rem",
299
- },
300
  }}
301
  >
302
- {t.caps === "cluster-arrows" ? (
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
- mt: "0.5rem",
332
- fontFamily: ANTON,
333
- fontSize: "0.8rem",
334
- letterSpacing: "0.06em",
335
- textTransform: "uppercase",
336
- color: "#fff",
337
  }}
338
  >
339
- {t.name}
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
340
  </Box>
341
  <Box
 
342
  sx={{
343
- mt: "0.16rem",
344
- fontSize: "0.6rem",
345
- fontWeight: 600,
346
- letterSpacing: "0.11em",
347
  textTransform: "uppercase",
348
- color: ORANGE,
349
  }}
350
  >
351
- {t.hint}
352
  </Box>
353
  </Box>
354
  ))}
@@ -356,39 +690,20 @@ export default function TitleMenu() {
356
 
357
  <Typography
358
  sx={{
359
- mt: "0.85rem",
360
  fontFamily: MONO,
361
- fontSize: "0.64rem",
362
  fontWeight: 600,
363
  letterSpacing: "0.12em",
364
  textTransform: "uppercase",
365
- color: "rgba(255, 255, 255, 0.38)",
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
  }