tfrere HF Staff Cursor commited on
Commit
3570fe8
·
1 Parent(s): 3ff9fc1

feat: Vite+React+MUI+R3F app shell in app/ (framework-agnostic game core, zustand bridge)

Browse files
Dockerfile ADDED
@@ -0,0 +1,15 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ # Build the Vite app, then serve the static bundle with nginx.
2
+ # HF Spaces docker SDK: the container must listen on app_port (8080);
3
+ # nginx-unprivileged listens on 8080 by default and runs as non-root,
4
+ # which is exactly what Spaces expects.
5
+
6
+ FROM node:22-alpine AS build
7
+ WORKDIR /build
8
+ COPY app/package.json app/package-lock.json ./
9
+ RUN npm ci
10
+ COPY app/ ./
11
+ RUN npm run build
12
+
13
+ FROM nginxinc/nginx-unprivileged:alpine
14
+ COPY --from=build /build/dist /usr/share/nginx/html
15
+ EXPOSE 8080
app/.gitignore ADDED
@@ -0,0 +1,2 @@
 
 
 
1
+ node_modules/
2
+ dist/
app/index.html ADDED
@@ -0,0 +1,21 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ <!doctype html>
2
+ <html lang="en">
3
+ <head>
4
+ <meta charset="utf-8" />
5
+ <meta name="viewport" content="width=device-width, initial-scale=1" />
6
+ <title>Microduck</title>
7
+ <style>
8
+ :root {
9
+ --ink: #08080c;
10
+ }
11
+ * { margin: 0; padding: 0; box-sizing: border-box; }
12
+ html, body { height: 100%; }
13
+ body { background: var(--ink); overflow: hidden; }
14
+ #root { height: 100%; }
15
+ </style>
16
+ </head>
17
+ <body>
18
+ <div id="root"></div>
19
+ <script type="module" src="/src/main.jsx"></script>
20
+ </body>
21
+ </html>
app/package-lock.json ADDED
@@ -0,0 +1,2052 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
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": {
22
+ "@vitejs/plugin-react": "^6.1.0",
23
+ "vite": "^8.2.2"
24
+ }
25
+ },
26
+ "node_modules/@babel/code-frame": {
27
+ "version": "7.29.7",
28
+ "resolved": "https://registry.npmjs.org/@babel/code-frame/-/code-frame-7.29.7.tgz",
29
+ "integrity": "sha512-Aup7aUOfpbAUg2ROOJN6Iw5f9DMBlzu0mIkm/malLQFN/YQgO48wCj0Kxa3sEHJvPVFg7siR+qRInwXd2qhQKw==",
30
+ "license": "MIT",
31
+ "dependencies": {
32
+ "@babel/helper-validator-identifier": "^7.29.7",
33
+ "js-tokens": "^4.0.0",
34
+ "picocolors": "^1.1.1"
35
+ },
36
+ "engines": {
37
+ "node": ">=6.9.0"
38
+ }
39
+ },
40
+ "node_modules/@babel/generator": {
41
+ "version": "7.29.8",
42
+ "resolved": "https://registry.npmjs.org/@babel/generator/-/generator-7.29.8.tgz",
43
+ "integrity": "sha512-gZbepsdh3WDtgZKWL+vTPh71LSBrm/Y4/QDZBVCcYfmeTEEuoOYwlSy+G1StfJg+/Zy550u/3TATbm7qDbbMtg==",
44
+ "license": "MIT",
45
+ "dependencies": {
46
+ "@babel/parser": "^7.29.8",
47
+ "@babel/types": "^7.29.8",
48
+ "@jridgewell/gen-mapping": "^0.3.12",
49
+ "@jridgewell/trace-mapping": "^0.3.28",
50
+ "jsesc": "^3.0.2"
51
+ },
52
+ "engines": {
53
+ "node": ">=6.9.0"
54
+ }
55
+ },
56
+ "node_modules/@babel/helper-globals": {
57
+ "version": "7.29.7",
58
+ "resolved": "https://registry.npmjs.org/@babel/helper-globals/-/helper-globals-7.29.7.tgz",
59
+ "integrity": "sha512-3nQVUAtvkKH9zahfWgw96Jc/uFOmjACE1kQz82E2lqWmHBgjzbNlsC22nuQTfahmWeQtTq5nQ/4Nnd2A1wj4zA==",
60
+ "license": "MIT",
61
+ "engines": {
62
+ "node": ">=6.9.0"
63
+ }
64
+ },
65
+ "node_modules/@babel/helper-module-imports": {
66
+ "version": "7.29.7",
67
+ "resolved": "https://registry.npmjs.org/@babel/helper-module-imports/-/helper-module-imports-7.29.7.tgz",
68
+ "integrity": "sha512-ejHwrQQYcm9xnTivShn2IDOlIzInN34AXskvq9QicvCtEzq1Vzclu/tKF8Jq1Cg8JG2GL6/EmjgsCT7lXepE3g==",
69
+ "license": "MIT",
70
+ "dependencies": {
71
+ "@babel/traverse": "^7.29.7",
72
+ "@babel/types": "^7.29.7"
73
+ },
74
+ "engines": {
75
+ "node": ">=6.9.0"
76
+ }
77
+ },
78
+ "node_modules/@babel/helper-string-parser": {
79
+ "version": "7.29.7",
80
+ "resolved": "https://registry.npmjs.org/@babel/helper-string-parser/-/helper-string-parser-7.29.7.tgz",
81
+ "integrity": "sha512-Pb5ijPrZ89GDH8223L4UP8i6QApWxs04RbPQJTeWDV0/keR2E36MeKnyr6LYmUUvqRRI+Iv87SuF1W6ErINzYw==",
82
+ "license": "MIT",
83
+ "engines": {
84
+ "node": ">=6.9.0"
85
+ }
86
+ },
87
+ "node_modules/@babel/helper-validator-identifier": {
88
+ "version": "7.29.7",
89
+ "resolved": "https://registry.npmjs.org/@babel/helper-validator-identifier/-/helper-validator-identifier-7.29.7.tgz",
90
+ "integrity": "sha512-qehxGkRj55h/ff8EMaJ+cYhyaKlHIxqYDn682wQD7RNp9UujOQsHog2uS0r2vzr4pW+sXf90NeeayjcNaX3fFg==",
91
+ "license": "MIT",
92
+ "engines": {
93
+ "node": ">=6.9.0"
94
+ }
95
+ },
96
+ "node_modules/@babel/parser": {
97
+ "version": "7.29.8",
98
+ "resolved": "https://registry.npmjs.org/@babel/parser/-/parser-7.29.8.tgz",
99
+ "integrity": "sha512-E8lTAYNB1KW+FH+VGJuZM1ioAx2E6oVlvQFRrf5P8ZZmsiJXYAD9vTFV7yyEURNzgh1dFqMZuO6tUwcARbqFCA==",
100
+ "license": "MIT",
101
+ "dependencies": {
102
+ "@babel/types": "^7.29.8"
103
+ },
104
+ "bin": {
105
+ "parser": "bin/babel-parser.js"
106
+ },
107
+ "engines": {
108
+ "node": ">=6.0.0"
109
+ }
110
+ },
111
+ "node_modules/@babel/runtime": {
112
+ "version": "7.29.7",
113
+ "resolved": "https://registry.npmjs.org/@babel/runtime/-/runtime-7.29.7.tgz",
114
+ "integrity": "sha512-Nq8OhGWiZIZGV6hLHoyAKLLcJihP/xFeBMGJoUrxTX2psI8dCifzLhZISFb+VWS3wFMRDmCGw5R+dOySCqPLhw==",
115
+ "license": "MIT",
116
+ "engines": {
117
+ "node": ">=6.9.0"
118
+ }
119
+ },
120
+ "node_modules/@babel/template": {
121
+ "version": "7.29.7",
122
+ "resolved": "https://registry.npmjs.org/@babel/template/-/template-7.29.7.tgz",
123
+ "integrity": "sha512-puq+Gf35oI24FeN11LkoUQFqv9uwNeWpxXZi/Ji3rRIoKAzKnxRaZ+Gkj0vKS9ZCiTESfng1N9LyOyXvo+m+Gg==",
124
+ "license": "MIT",
125
+ "dependencies": {
126
+ "@babel/code-frame": "^7.29.7",
127
+ "@babel/parser": "^7.29.7",
128
+ "@babel/types": "^7.29.7"
129
+ },
130
+ "engines": {
131
+ "node": ">=6.9.0"
132
+ }
133
+ },
134
+ "node_modules/@babel/traverse": {
135
+ "version": "7.29.8",
136
+ "resolved": "https://registry.npmjs.org/@babel/traverse/-/traverse-7.29.8.tgz",
137
+ "integrity": "sha512-I5z7H3bf/41ktsNVLtpN0wAa336HkqIHQ5BuPLEhTkt1jVSyZpeNKIzTgEWmlxjdg81R0IgUCcaE+Ok3NvrfZg==",
138
+ "license": "MIT",
139
+ "dependencies": {
140
+ "@babel/code-frame": "^7.29.7",
141
+ "@babel/generator": "^7.29.8",
142
+ "@babel/helper-globals": "^7.29.7",
143
+ "@babel/parser": "^7.29.8",
144
+ "@babel/template": "^7.29.7",
145
+ "@babel/types": "^7.29.8",
146
+ "debug": "^4.3.1"
147
+ },
148
+ "engines": {
149
+ "node": ">=6.9.0"
150
+ }
151
+ },
152
+ "node_modules/@babel/types": {
153
+ "version": "7.29.8",
154
+ "resolved": "https://registry.npmjs.org/@babel/types/-/types-7.29.8.tgz",
155
+ "integrity": "sha512-Vj1jF3cPfxg7OAfoI7QnVKLoILlm2JF9pnVHrX8qx7AHMiYWT+NDAA7jChlNgRS4WTLc/fD1lXLmPixluj+3Gg==",
156
+ "license": "MIT",
157
+ "dependencies": {
158
+ "@babel/helper-string-parser": "^7.29.7",
159
+ "@babel/helper-validator-identifier": "^7.29.7"
160
+ },
161
+ "engines": {
162
+ "node": ">=6.9.0"
163
+ }
164
+ },
165
+ "node_modules/@emotion/babel-plugin": {
166
+ "version": "11.13.5",
167
+ "resolved": "https://registry.npmjs.org/@emotion/babel-plugin/-/babel-plugin-11.13.5.tgz",
168
+ "integrity": "sha512-pxHCpT2ex+0q+HH91/zsdHkw/lXd468DIN2zvfvLtPKLLMo6gQj7oLObq8PhkrxOZb/gGCq03S3Z7PDhS8pduQ==",
169
+ "license": "MIT",
170
+ "dependencies": {
171
+ "@babel/helper-module-imports": "^7.16.7",
172
+ "@babel/runtime": "^7.18.3",
173
+ "@emotion/hash": "^0.9.2",
174
+ "@emotion/memoize": "^0.9.0",
175
+ "@emotion/serialize": "^1.3.3",
176
+ "babel-plugin-macros": "^3.1.0",
177
+ "convert-source-map": "^1.5.0",
178
+ "escape-string-regexp": "^4.0.0",
179
+ "find-root": "^1.1.0",
180
+ "source-map": "^0.5.7",
181
+ "stylis": "4.2.0"
182
+ }
183
+ },
184
+ "node_modules/@emotion/cache": {
185
+ "version": "11.14.0",
186
+ "resolved": "https://registry.npmjs.org/@emotion/cache/-/cache-11.14.0.tgz",
187
+ "integrity": "sha512-L/B1lc/TViYk4DcpGxtAVbx0ZyiKM5ktoIyafGkH6zg/tj+mA+NE//aPYKG0k8kCHSHVJrpLpcAlOBEXQ3SavA==",
188
+ "license": "MIT",
189
+ "dependencies": {
190
+ "@emotion/memoize": "^0.9.0",
191
+ "@emotion/sheet": "^1.4.0",
192
+ "@emotion/utils": "^1.4.2",
193
+ "@emotion/weak-memoize": "^0.4.0",
194
+ "stylis": "4.2.0"
195
+ }
196
+ },
197
+ "node_modules/@emotion/hash": {
198
+ "version": "0.9.2",
199
+ "resolved": "https://registry.npmjs.org/@emotion/hash/-/hash-0.9.2.tgz",
200
+ "integrity": "sha512-MyqliTZGuOm3+5ZRSaaBGP3USLw6+EGykkwZns2EPC5g8jJ4z9OrdZY9apkl3+UP9+sdz76YYkwCKP5gh8iY3g==",
201
+ "license": "MIT"
202
+ },
203
+ "node_modules/@emotion/is-prop-valid": {
204
+ "version": "1.4.0",
205
+ "resolved": "https://registry.npmjs.org/@emotion/is-prop-valid/-/is-prop-valid-1.4.0.tgz",
206
+ "integrity": "sha512-QgD4fyscGcbbKwJmqNvUMSE02OsHUa+lAWKdEUIJKgqe5IwRSKd7+KhibEWdaKwgjLj0DRSHA9biAIqGBk05lw==",
207
+ "license": "MIT",
208
+ "dependencies": {
209
+ "@emotion/memoize": "^0.9.0"
210
+ }
211
+ },
212
+ "node_modules/@emotion/memoize": {
213
+ "version": "0.9.0",
214
+ "resolved": "https://registry.npmjs.org/@emotion/memoize/-/memoize-0.9.0.tgz",
215
+ "integrity": "sha512-30FAj7/EoJ5mwVPOWhAyCX+FPfMDrVecJAM+Iw9NRoSl4BBAQeqj4cApHHUXOVvIPgLVDsCFoz/hGD+5QQD1GQ==",
216
+ "license": "MIT"
217
+ },
218
+ "node_modules/@emotion/react": {
219
+ "version": "11.14.0",
220
+ "resolved": "https://registry.npmjs.org/@emotion/react/-/react-11.14.0.tgz",
221
+ "integrity": "sha512-O000MLDBDdk/EohJPFUqvnp4qnHeYkVP5B0xEG0D/L7cOKP9kefu2DXn8dj74cQfsEzUqh+sr1RzFqiL1o+PpA==",
222
+ "license": "MIT",
223
+ "peer": true,
224
+ "dependencies": {
225
+ "@babel/runtime": "^7.18.3",
226
+ "@emotion/babel-plugin": "^11.13.5",
227
+ "@emotion/cache": "^11.14.0",
228
+ "@emotion/serialize": "^1.3.3",
229
+ "@emotion/use-insertion-effect-with-fallbacks": "^1.2.0",
230
+ "@emotion/utils": "^1.4.2",
231
+ "@emotion/weak-memoize": "^0.4.0",
232
+ "hoist-non-react-statics": "^3.3.1"
233
+ },
234
+ "peerDependencies": {
235
+ "react": ">=16.8.0"
236
+ },
237
+ "peerDependenciesMeta": {
238
+ "@types/react": {
239
+ "optional": true
240
+ }
241
+ }
242
+ },
243
+ "node_modules/@emotion/serialize": {
244
+ "version": "1.3.3",
245
+ "resolved": "https://registry.npmjs.org/@emotion/serialize/-/serialize-1.3.3.tgz",
246
+ "integrity": "sha512-EISGqt7sSNWHGI76hC7x1CksiXPahbxEOrC5RjmFRJTqLyEK9/9hZvBbiYn70dw4wuwMKiEMCUlR6ZXTSWQqxA==",
247
+ "license": "MIT",
248
+ "dependencies": {
249
+ "@emotion/hash": "^0.9.2",
250
+ "@emotion/memoize": "^0.9.0",
251
+ "@emotion/unitless": "^0.10.0",
252
+ "@emotion/utils": "^1.4.2",
253
+ "csstype": "^3.0.2"
254
+ }
255
+ },
256
+ "node_modules/@emotion/sheet": {
257
+ "version": "1.4.0",
258
+ "resolved": "https://registry.npmjs.org/@emotion/sheet/-/sheet-1.4.0.tgz",
259
+ "integrity": "sha512-fTBW9/8r2w3dXWYM4HCB1Rdp8NLibOw2+XELH5m5+AkWiL/KqYX6dc0kKYlaYyKjrQ6ds33MCdMPEwgs2z1rqg==",
260
+ "license": "MIT"
261
+ },
262
+ "node_modules/@emotion/styled": {
263
+ "version": "11.14.1",
264
+ "resolved": "https://registry.npmjs.org/@emotion/styled/-/styled-11.14.1.tgz",
265
+ "integrity": "sha512-qEEJt42DuToa3gurlH4Qqc1kVpNq8wO8cJtDzU46TjlzWjDlsVyevtYCRijVq3SrHsROS+gVQ8Fnea108GnKzw==",
266
+ "license": "MIT",
267
+ "peer": true,
268
+ "dependencies": {
269
+ "@babel/runtime": "^7.18.3",
270
+ "@emotion/babel-plugin": "^11.13.5",
271
+ "@emotion/is-prop-valid": "^1.3.0",
272
+ "@emotion/serialize": "^1.3.3",
273
+ "@emotion/use-insertion-effect-with-fallbacks": "^1.2.0",
274
+ "@emotion/utils": "^1.4.2"
275
+ },
276
+ "peerDependencies": {
277
+ "@emotion/react": "^11.0.0-rc.0",
278
+ "react": ">=16.8.0"
279
+ },
280
+ "peerDependenciesMeta": {
281
+ "@types/react": {
282
+ "optional": true
283
+ }
284
+ }
285
+ },
286
+ "node_modules/@emotion/unitless": {
287
+ "version": "0.10.0",
288
+ "resolved": "https://registry.npmjs.org/@emotion/unitless/-/unitless-0.10.0.tgz",
289
+ "integrity": "sha512-dFoMUuQA20zvtVTuxZww6OHoJYgrzfKM1t52mVySDJnMSEa08ruEvdYQbhvyu6soU+NeLVd3yKfTfT0NeV6qGg==",
290
+ "license": "MIT"
291
+ },
292
+ "node_modules/@emotion/use-insertion-effect-with-fallbacks": {
293
+ "version": "1.2.0",
294
+ "resolved": "https://registry.npmjs.org/@emotion/use-insertion-effect-with-fallbacks/-/use-insertion-effect-with-fallbacks-1.2.0.tgz",
295
+ "integrity": "sha512-yJMtVdH59sxi/aVJBpk9FQq+OR8ll5GT8oWd57UpeaKEVGab41JWaCFA7FRLoMLloOZF/c/wsPoe+bfGmRKgDg==",
296
+ "license": "MIT",
297
+ "peerDependencies": {
298
+ "react": ">=16.8.0"
299
+ }
300
+ },
301
+ "node_modules/@emotion/utils": {
302
+ "version": "1.4.2",
303
+ "resolved": "https://registry.npmjs.org/@emotion/utils/-/utils-1.4.2.tgz",
304
+ "integrity": "sha512-3vLclRofFziIa3J2wDh9jjbkUz9qk5Vi3IZ/FSTKViB0k+ef0fPV7dYrUIugbgupYDx7v9ud/SjrtEP8Y4xLoA==",
305
+ "license": "MIT"
306
+ },
307
+ "node_modules/@emotion/weak-memoize": {
308
+ "version": "0.4.0",
309
+ "resolved": "https://registry.npmjs.org/@emotion/weak-memoize/-/weak-memoize-0.4.0.tgz",
310
+ "integrity": "sha512-snKqtPW01tN0ui7yu9rGv69aJXr/a/Ywvl11sUjNtEcRc+ng/mQriFL0wLXMef74iHa/EkftbDzU9F8iFbH+zg==",
311
+ "license": "MIT"
312
+ },
313
+ "node_modules/@jridgewell/gen-mapping": {
314
+ "version": "0.3.13",
315
+ "resolved": "https://registry.npmjs.org/@jridgewell/gen-mapping/-/gen-mapping-0.3.13.tgz",
316
+ "integrity": "sha512-2kkt/7niJ6MgEPxF0bYdQ6etZaA+fQvDcLKckhy1yIQOzaoKjBBjSj63/aLVjYE3qhRt5dvM+uUyfCg6UKCBbA==",
317
+ "license": "MIT",
318
+ "dependencies": {
319
+ "@jridgewell/sourcemap-codec": "^1.5.0",
320
+ "@jridgewell/trace-mapping": "^0.3.24"
321
+ }
322
+ },
323
+ "node_modules/@jridgewell/resolve-uri": {
324
+ "version": "3.1.2",
325
+ "resolved": "https://registry.npmjs.org/@jridgewell/resolve-uri/-/resolve-uri-3.1.2.tgz",
326
+ "integrity": "sha512-bRISgCIjP20/tbWSPWMEi54QVPRZExkuD9lJL+UIxUKtwVJA8wW1Trb1jMs1RFXo1CBTNZ/5hpC9QvmKWdopKw==",
327
+ "license": "MIT",
328
+ "engines": {
329
+ "node": ">=6.0.0"
330
+ }
331
+ },
332
+ "node_modules/@jridgewell/sourcemap-codec": {
333
+ "version": "1.5.5",
334
+ "resolved": "https://registry.npmjs.org/@jridgewell/sourcemap-codec/-/sourcemap-codec-1.5.5.tgz",
335
+ "integrity": "sha512-cYQ9310grqxueWbl+WuIUIaiUaDcj7WOq5fVhEljNVgRfOUhY9fy2zTvfoqWsnebh8Sl70VScFbICvJnLKB0Og==",
336
+ "license": "MIT"
337
+ },
338
+ "node_modules/@jridgewell/trace-mapping": {
339
+ "version": "0.3.31",
340
+ "resolved": "https://registry.npmjs.org/@jridgewell/trace-mapping/-/trace-mapping-0.3.31.tgz",
341
+ "integrity": "sha512-zzNR+SdQSDJzc8joaeP8QQoCQr8NuYx2dIIytl1QeBEZHJ9uW6hebsrYgbz8hJwUQao3TWCMtmfV8Nu1twOLAw==",
342
+ "license": "MIT",
343
+ "dependencies": {
344
+ "@jridgewell/resolve-uri": "^3.1.0",
345
+ "@jridgewell/sourcemap-codec": "^1.4.14"
346
+ }
347
+ },
348
+ "node_modules/@mui/core-downloads-tracker": {
349
+ "version": "9.3.1",
350
+ "resolved": "https://registry.npmjs.org/@mui/core-downloads-tracker/-/core-downloads-tracker-9.3.1.tgz",
351
+ "integrity": "sha512-IAyAFNQbT7hysJ9HXphiOmWJF7G1OglzHanqCgvQgH9LA2ydxtmaTBDbcBqw6euZesyShiwvpvbnYO1GY1AyXQ==",
352
+ "license": "MIT",
353
+ "funding": {
354
+ "type": "opencollective",
355
+ "url": "https://opencollective.com/mui-org"
356
+ }
357
+ },
358
+ "node_modules/@mui/material": {
359
+ "version": "9.3.1",
360
+ "resolved": "https://registry.npmjs.org/@mui/material/-/material-9.3.1.tgz",
361
+ "integrity": "sha512-NahAEGIXqS1K0bA4th1jeFxBguS59NOcLbMA0vU+fSaPWKjtwGBGGHeTwlc9PSmzMjOZKceeFWESB8fVHr31hA==",
362
+ "license": "MIT",
363
+ "dependencies": {
364
+ "@babel/runtime": "^7.29.7",
365
+ "@mui/core-downloads-tracker": "^9.3.1",
366
+ "@mui/system": "^9.3.0",
367
+ "@mui/types": "^9.3.0",
368
+ "@mui/utils": "^9.3.0",
369
+ "@popperjs/core": "^2.11.8",
370
+ "@types/react-transition-group": "^4.4.12",
371
+ "clsx": "^2.1.1",
372
+ "csstype": "^3.2.3",
373
+ "prop-types": "^15.8.1",
374
+ "react-is": "^19.2.8",
375
+ "react-transition-group": "^4.4.5"
376
+ },
377
+ "engines": {
378
+ "node": ">=14.0.0"
379
+ },
380
+ "funding": {
381
+ "type": "opencollective",
382
+ "url": "https://opencollective.com/mui-org"
383
+ },
384
+ "peerDependencies": {
385
+ "@emotion/react": "^11.5.0",
386
+ "@emotion/styled": "^11.3.0",
387
+ "@mui/material-pigment-css": "^9.3.0",
388
+ "@types/react": "^17.0.0 || ^18.0.0 || ^19.0.0",
389
+ "react": "^17.0.0 || ^18.0.0 || ^19.0.0",
390
+ "react-dom": "^17.0.0 || ^18.0.0 || ^19.0.0"
391
+ },
392
+ "peerDependenciesMeta": {
393
+ "@emotion/react": {
394
+ "optional": true
395
+ },
396
+ "@emotion/styled": {
397
+ "optional": true
398
+ },
399
+ "@mui/material-pigment-css": {
400
+ "optional": true
401
+ },
402
+ "@types/react": {
403
+ "optional": true
404
+ }
405
+ }
406
+ },
407
+ "node_modules/@mui/private-theming": {
408
+ "version": "9.3.0",
409
+ "resolved": "https://registry.npmjs.org/@mui/private-theming/-/private-theming-9.3.0.tgz",
410
+ "integrity": "sha512-ERvqk5pejf9aRnQcDILSWGtFmsEMiVxlQ4+xsVCjsEvmK0fV9BiVP/cQwAF5dwyDFbve4lTrlcgeFEecVzTNiA==",
411
+ "license": "MIT",
412
+ "dependencies": {
413
+ "@babel/runtime": "^7.29.7",
414
+ "@mui/utils": "^9.3.0",
415
+ "prop-types": "^15.8.1"
416
+ },
417
+ "engines": {
418
+ "node": ">=14.0.0"
419
+ },
420
+ "funding": {
421
+ "type": "opencollective",
422
+ "url": "https://opencollective.com/mui-org"
423
+ },
424
+ "peerDependencies": {
425
+ "@types/react": "^17.0.0 || ^18.0.0 || ^19.0.0",
426
+ "react": "^17.0.0 || ^18.0.0 || ^19.0.0"
427
+ },
428
+ "peerDependenciesMeta": {
429
+ "@types/react": {
430
+ "optional": true
431
+ }
432
+ }
433
+ },
434
+ "node_modules/@mui/styled-engine": {
435
+ "version": "9.3.0",
436
+ "resolved": "https://registry.npmjs.org/@mui/styled-engine/-/styled-engine-9.3.0.tgz",
437
+ "integrity": "sha512-x9+KYxhjoHYZ4nioxdKnvQWdw0RScbhoZfQ4tv3Db742683U3wlYSp6zV6Us78dMFnKnyTrPTkQKyutp14gnKA==",
438
+ "license": "MIT",
439
+ "dependencies": {
440
+ "@babel/runtime": "^7.29.7",
441
+ "@emotion/cache": "^11.14.0",
442
+ "@emotion/serialize": "^1.3.3",
443
+ "@emotion/sheet": "^1.4.0",
444
+ "csstype": "^3.2.3",
445
+ "prop-types": "^15.8.1"
446
+ },
447
+ "engines": {
448
+ "node": ">=14.0.0"
449
+ },
450
+ "funding": {
451
+ "type": "opencollective",
452
+ "url": "https://opencollective.com/mui-org"
453
+ },
454
+ "peerDependencies": {
455
+ "@emotion/react": "^11.4.1",
456
+ "@emotion/styled": "^11.3.0",
457
+ "react": "^17.0.0 || ^18.0.0 || ^19.0.0"
458
+ },
459
+ "peerDependenciesMeta": {
460
+ "@emotion/react": {
461
+ "optional": true
462
+ },
463
+ "@emotion/styled": {
464
+ "optional": true
465
+ }
466
+ }
467
+ },
468
+ "node_modules/@mui/system": {
469
+ "version": "9.3.0",
470
+ "resolved": "https://registry.npmjs.org/@mui/system/-/system-9.3.0.tgz",
471
+ "integrity": "sha512-0l4LqHJxZj65xSrioniGsxm7VNoGXonPo203oZjhBUvIDPeBqRTb7Mqc45Qxs6sO6WGR7WE/9cJ6lb4lkvHkjg==",
472
+ "license": "MIT",
473
+ "dependencies": {
474
+ "@babel/runtime": "^7.29.7",
475
+ "@mui/private-theming": "^9.3.0",
476
+ "@mui/styled-engine": "^9.3.0",
477
+ "@mui/types": "^9.3.0",
478
+ "@mui/utils": "^9.3.0",
479
+ "clsx": "^2.1.1",
480
+ "csstype": "^3.2.3",
481
+ "prop-types": "^15.8.1"
482
+ },
483
+ "engines": {
484
+ "node": ">=14.0.0"
485
+ },
486
+ "funding": {
487
+ "type": "opencollective",
488
+ "url": "https://opencollective.com/mui-org"
489
+ },
490
+ "peerDependencies": {
491
+ "@emotion/react": "^11.5.0",
492
+ "@emotion/styled": "^11.3.0",
493
+ "@types/react": "^17.0.0 || ^18.0.0 || ^19.0.0",
494
+ "react": "^17.0.0 || ^18.0.0 || ^19.0.0"
495
+ },
496
+ "peerDependenciesMeta": {
497
+ "@emotion/react": {
498
+ "optional": true
499
+ },
500
+ "@emotion/styled": {
501
+ "optional": true
502
+ },
503
+ "@types/react": {
504
+ "optional": true
505
+ }
506
+ }
507
+ },
508
+ "node_modules/@mui/types": {
509
+ "version": "9.3.0",
510
+ "resolved": "https://registry.npmjs.org/@mui/types/-/types-9.3.0.tgz",
511
+ "integrity": "sha512-2JSxyfpEFWNUB2vKs/T1BvkfyNisMHWph8bLMj8T0uHwmLl/0qfAwQkfwMT6kxLXN9uIum9AEbECXU8er3amIg==",
512
+ "license": "MIT",
513
+ "dependencies": {
514
+ "@babel/runtime": "^7.29.7"
515
+ },
516
+ "peerDependencies": {
517
+ "@types/react": "^17.0.0 || ^18.0.0 || ^19.0.0"
518
+ },
519
+ "peerDependenciesMeta": {
520
+ "@types/react": {
521
+ "optional": true
522
+ }
523
+ }
524
+ },
525
+ "node_modules/@mui/utils": {
526
+ "version": "9.3.0",
527
+ "resolved": "https://registry.npmjs.org/@mui/utils/-/utils-9.3.0.tgz",
528
+ "integrity": "sha512-2HZdHwWJ6eB+7lVGSOHsByGw8jeRulT4g0NZ608Wb8Q57DE2jbNqrWPFuJsvkQQiBiTmlpvQL3i+/62zsiPrkw==",
529
+ "license": "MIT",
530
+ "dependencies": {
531
+ "@babel/runtime": "^7.29.7",
532
+ "@mui/types": "^9.3.0",
533
+ "@types/prop-types": "^15.7.15",
534
+ "clsx": "^2.1.1",
535
+ "prop-types": "^15.8.1",
536
+ "react-is": "^19.2.8"
537
+ },
538
+ "engines": {
539
+ "node": ">=14.0.0"
540
+ },
541
+ "funding": {
542
+ "type": "opencollective",
543
+ "url": "https://opencollective.com/mui-org"
544
+ },
545
+ "peerDependencies": {
546
+ "@types/react": "^17.0.0 || ^18.0.0 || ^19.0.0",
547
+ "react": "^17.0.0 || ^18.0.0 || ^19.0.0"
548
+ },
549
+ "peerDependenciesMeta": {
550
+ "@types/react": {
551
+ "optional": true
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",
558
+ "integrity": "sha512-XC0QsnnhVe7sLIWmYmdPw7x5P0h4W8vUU3Nv1ySgWXtvCz8NizoAEpGXA0sOYoJQV2Rl13LgURAHQ5cI5ILCSA==",
559
+ "dev": true,
560
+ "license": "MIT",
561
+ "funding": {
562
+ "url": "https://github.com/sponsors/Boshen"
563
+ }
564
+ },
565
+ "node_modules/@popperjs/core": {
566
+ "version": "2.11.8",
567
+ "resolved": "https://registry.npmjs.org/@popperjs/core/-/core-2.11.8.tgz",
568
+ "integrity": "sha512-P1st0aksCrn9sGZhp8GMYwBnQsbvAWsZAX44oXNNvLHGqAOcoVxmjZiohstwQ7SqKnbR47akdNi+uleWD8+g6A==",
569
+ "license": "MIT",
570
+ "funding": {
571
+ "type": "opencollective",
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",
578
+ "integrity": "sha512-EWm9FwcaOZQu/ExFW5rggoCMM1NJet5YbxVxKaOE+KSncrjU0Wx7017qSyGFvupviK89nMYGCWU3BIK4dI1clw==",
579
+ "license": "MIT",
580
+ "dependencies": {
581
+ "@babel/runtime": "^7.17.8",
582
+ "@types/webxr": "*",
583
+ "base64-js": "^1.5.1",
584
+ "buffer": "^6.0.3",
585
+ "its-fine": "^2.0.0",
586
+ "react-use-measure": "^2.1.7",
587
+ "scheduler": "^0.27.0",
588
+ "suspend-react": "^0.1.3",
589
+ "use-sync-external-store": "^1.4.0",
590
+ "zustand": "^5.0.3"
591
+ },
592
+ "peerDependencies": {
593
+ "expo": ">=43.0",
594
+ "expo-asset": ">=8.4",
595
+ "expo-file-system": ">=11.0",
596
+ "expo-gl": ">=11.0",
597
+ "react": ">=19 <19.3",
598
+ "react-dom": ">=19 <19.3",
599
+ "react-native": ">=0.78",
600
+ "three": ">=0.156"
601
+ },
602
+ "peerDependenciesMeta": {
603
+ "expo": {
604
+ "optional": true
605
+ },
606
+ "expo-asset": {
607
+ "optional": true
608
+ },
609
+ "expo-file-system": {
610
+ "optional": true
611
+ },
612
+ "expo-gl": {
613
+ "optional": true
614
+ },
615
+ "react-dom": {
616
+ "optional": true
617
+ },
618
+ "react-native": {
619
+ "optional": true
620
+ }
621
+ }
622
+ },
623
+ "node_modules/@rolldown/binding-android-arm-eabi": {
624
+ "version": "1.2.5",
625
+ "resolved": "https://registry.npmjs.org/@rolldown/binding-android-arm-eabi/-/binding-android-arm-eabi-1.2.5.tgz",
626
+ "integrity": "sha512-DLe/i+l8ynIBY7XEQ191TeZvCoowIGa18R+dIV30GW7DiOtp74i/xX8hs8GUjW5ARV7VZuie3d6AumSmCwbeRA==",
627
+ "cpu": [
628
+ "arm"
629
+ ],
630
+ "dev": true,
631
+ "license": "MIT",
632
+ "optional": true,
633
+ "os": [
634
+ "android"
635
+ ],
636
+ "engines": {
637
+ "node": "^20.19.0 || >=22.12.0"
638
+ }
639
+ },
640
+ "node_modules/@rolldown/binding-android-arm64": {
641
+ "version": "1.2.5",
642
+ "resolved": "https://registry.npmjs.org/@rolldown/binding-android-arm64/-/binding-android-arm64-1.2.5.tgz",
643
+ "integrity": "sha512-zXcwKlQApYAOELHd8PwKDFkagYF9Wy4e0RJ+0qnzl9Pjnpj75TEG8ufv40p2J7kCEfwZAsNiuzRIyNNMWT38ig==",
644
+ "cpu": [
645
+ "arm64"
646
+ ],
647
+ "dev": true,
648
+ "license": "MIT",
649
+ "optional": true,
650
+ "os": [
651
+ "android"
652
+ ],
653
+ "engines": {
654
+ "node": "^20.19.0 || >=22.12.0"
655
+ }
656
+ },
657
+ "node_modules/@rolldown/binding-darwin-arm64": {
658
+ "version": "1.2.5",
659
+ "resolved": "https://registry.npmjs.org/@rolldown/binding-darwin-arm64/-/binding-darwin-arm64-1.2.5.tgz",
660
+ "integrity": "sha512-dK4QakI42nzWgJT5sm4y4y/O//D4OxM75/cH28RLV+nzIN9AY+YsbuUVrUTjlLjXR6vpyxFbSsbmNuJ6BP9sww==",
661
+ "cpu": [
662
+ "arm64"
663
+ ],
664
+ "dev": true,
665
+ "license": "MIT",
666
+ "optional": true,
667
+ "os": [
668
+ "darwin"
669
+ ],
670
+ "engines": {
671
+ "node": "^20.19.0 || >=22.12.0"
672
+ }
673
+ },
674
+ "node_modules/@rolldown/binding-darwin-x64": {
675
+ "version": "1.2.5",
676
+ "resolved": "https://registry.npmjs.org/@rolldown/binding-darwin-x64/-/binding-darwin-x64-1.2.5.tgz",
677
+ "integrity": "sha512-fqSALaUu1Wjd1nK2uW2kJDWdLCc8lx1IcY+MTY26Aurfdx19anlzhqXOgCFbBFQnlFDTn4TC1/7Nz4Bl2mLP3A==",
678
+ "cpu": [
679
+ "x64"
680
+ ],
681
+ "dev": true,
682
+ "license": "MIT",
683
+ "optional": true,
684
+ "os": [
685
+ "darwin"
686
+ ],
687
+ "engines": {
688
+ "node": "^20.19.0 || >=22.12.0"
689
+ }
690
+ },
691
+ "node_modules/@rolldown/binding-freebsd-x64": {
692
+ "version": "1.2.5",
693
+ "resolved": "https://registry.npmjs.org/@rolldown/binding-freebsd-x64/-/binding-freebsd-x64-1.2.5.tgz",
694
+ "integrity": "sha512-/vCnNxlkxs9tKxNDcyWUePpJ/PgTzxIaVhoM5SmG8UV+GR/IcPam4VYxi7GIMo7PSDuNqlJqvprqii9NqqVCMw==",
695
+ "cpu": [
696
+ "x64"
697
+ ],
698
+ "dev": true,
699
+ "license": "MIT",
700
+ "optional": true,
701
+ "os": [
702
+ "freebsd"
703
+ ],
704
+ "engines": {
705
+ "node": "^20.19.0 || >=22.12.0"
706
+ }
707
+ },
708
+ "node_modules/@rolldown/binding-linux-arm-gnueabihf": {
709
+ "version": "1.2.5",
710
+ "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-arm-gnueabihf/-/binding-linux-arm-gnueabihf-1.2.5.tgz",
711
+ "integrity": "sha512-abk0NLA519LxRCszmbE0jYKuQ9YPocOXTiOXOo6Yr+YAT95VH+PtqYAjOJvGKt3viEd/x4qzabAlwd5bHOOARg==",
712
+ "cpu": [
713
+ "arm"
714
+ ],
715
+ "dev": true,
716
+ "license": "MIT",
717
+ "optional": true,
718
+ "os": [
719
+ "linux"
720
+ ],
721
+ "engines": {
722
+ "node": "^20.19.0 || >=22.12.0"
723
+ }
724
+ },
725
+ "node_modules/@rolldown/binding-linux-arm64-gnu": {
726
+ "version": "1.2.5",
727
+ "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-arm64-gnu/-/binding-linux-arm64-gnu-1.2.5.tgz",
728
+ "integrity": "sha512-Y7eALiJ8lr0M2HH103Js+g7V34wf6snlpZLAsHI90uLhr3PVlNsbFVAXJC9d/V6BnPyKtpSwI+NcB/RLxsQxuA==",
729
+ "cpu": [
730
+ "arm64"
731
+ ],
732
+ "dev": true,
733
+ "license": "MIT",
734
+ "optional": true,
735
+ "os": [
736
+ "linux"
737
+ ],
738
+ "engines": {
739
+ "node": "^20.19.0 || >=22.12.0"
740
+ }
741
+ },
742
+ "node_modules/@rolldown/binding-linux-arm64-musl": {
743
+ "version": "1.2.5",
744
+ "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-arm64-musl/-/binding-linux-arm64-musl-1.2.5.tgz",
745
+ "integrity": "sha512-xMvZgnbZg4YVnR/AX2b3oOPDTFYJvUVaJg5FedA/LuvexAtXibZQej4cnTkw3rjsJ/ggUROB64TdtETiim+FYA==",
746
+ "cpu": [
747
+ "arm64"
748
+ ],
749
+ "dev": true,
750
+ "license": "MIT",
751
+ "optional": true,
752
+ "os": [
753
+ "linux"
754
+ ],
755
+ "engines": {
756
+ "node": "^20.19.0 || >=22.12.0"
757
+ }
758
+ },
759
+ "node_modules/@rolldown/binding-linux-ppc64-gnu": {
760
+ "version": "1.2.5",
761
+ "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-ppc64-gnu/-/binding-linux-ppc64-gnu-1.2.5.tgz",
762
+ "integrity": "sha512-GRjeqTUDHTo5GwntsLaAMcBahG3nlpjftXWZLN73HiYQlhwEowvarFgQnRnQZtIp4keXX7quXFbG38uPZBa2EA==",
763
+ "cpu": [
764
+ "ppc64"
765
+ ],
766
+ "dev": true,
767
+ "license": "MIT",
768
+ "optional": true,
769
+ "os": [
770
+ "linux"
771
+ ],
772
+ "engines": {
773
+ "node": "^20.19.0 || >=22.12.0"
774
+ }
775
+ },
776
+ "node_modules/@rolldown/binding-linux-s390x-gnu": {
777
+ "version": "1.2.5",
778
+ "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-s390x-gnu/-/binding-linux-s390x-gnu-1.2.5.tgz",
779
+ "integrity": "sha512-vLNTR45F2Uwc8AufkNXPmB4VliaXs+FvcheEogIzOXzO4l+LzieXF5A/TWxLy5HtqpsRCHUfd0lPVrrdgXdLHQ==",
780
+ "cpu": [
781
+ "s390x"
782
+ ],
783
+ "dev": true,
784
+ "license": "MIT",
785
+ "optional": true,
786
+ "os": [
787
+ "linux"
788
+ ],
789
+ "engines": {
790
+ "node": "^20.19.0 || >=22.12.0"
791
+ }
792
+ },
793
+ "node_modules/@rolldown/binding-linux-x64-gnu": {
794
+ "version": "1.2.5",
795
+ "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-x64-gnu/-/binding-linux-x64-gnu-1.2.5.tgz",
796
+ "integrity": "sha512-Mgj59/HTuYeK9Gz2MA+mBWKnHsAgkBSec15ZMb1st3oIfFbX7gCjOae7GydHhzcyQi9Z/7M1QuN9bR3oFqF0jQ==",
797
+ "cpu": [
798
+ "x64"
799
+ ],
800
+ "dev": true,
801
+ "license": "MIT",
802
+ "optional": true,
803
+ "os": [
804
+ "linux"
805
+ ],
806
+ "engines": {
807
+ "node": "^20.19.0 || >=22.12.0"
808
+ }
809
+ },
810
+ "node_modules/@rolldown/binding-linux-x64-musl": {
811
+ "version": "1.2.5",
812
+ "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-x64-musl/-/binding-linux-x64-musl-1.2.5.tgz",
813
+ "integrity": "sha512-mY8AP0/ichsbhAxGnLa3d3+MwV0EfgrPND2bplI3Ym8T6R2pJ0N87bvrKVwNXmdy3jnr6eQBecdqx/HMknBmpA==",
814
+ "cpu": [
815
+ "x64"
816
+ ],
817
+ "dev": true,
818
+ "license": "MIT",
819
+ "optional": true,
820
+ "os": [
821
+ "linux"
822
+ ],
823
+ "engines": {
824
+ "node": "^20.19.0 || >=22.12.0"
825
+ }
826
+ },
827
+ "node_modules/@rolldown/binding-openharmony-arm64": {
828
+ "version": "1.2.5",
829
+ "resolved": "https://registry.npmjs.org/@rolldown/binding-openharmony-arm64/-/binding-openharmony-arm64-1.2.5.tgz",
830
+ "integrity": "sha512-8SLssA2oweAxyRgDp789ACfRb/3P+zNRJpzZxSizxF9m8NUDQ4+3xjo8ttjhVGGw6Qxb70oZiEtIjaKikCO7Yw==",
831
+ "cpu": [
832
+ "arm64"
833
+ ],
834
+ "dev": true,
835
+ "license": "MIT",
836
+ "optional": true,
837
+ "os": [
838
+ "openharmony"
839
+ ],
840
+ "engines": {
841
+ "node": "^20.19.0 || >=22.12.0"
842
+ }
843
+ },
844
+ "node_modules/@rolldown/binding-win32-arm64-msvc": {
845
+ "version": "1.2.5",
846
+ "resolved": "https://registry.npmjs.org/@rolldown/binding-win32-arm64-msvc/-/binding-win32-arm64-msvc-1.2.5.tgz",
847
+ "integrity": "sha512-vGbruD5zquhoc8D9SViXgN2FBJtNdTyQ4DtG+SWiEGlJiAzoKcZ2xp+xuXCffhubVdt0NJlTZqkeRuERy7g8Cw==",
848
+ "cpu": [
849
+ "arm64"
850
+ ],
851
+ "dev": true,
852
+ "license": "MIT",
853
+ "optional": true,
854
+ "os": [
855
+ "win32"
856
+ ],
857
+ "engines": {
858
+ "node": "^20.19.0 || >=22.12.0"
859
+ }
860
+ },
861
+ "node_modules/@rolldown/binding-win32-x64-msvc": {
862
+ "version": "1.2.5",
863
+ "resolved": "https://registry.npmjs.org/@rolldown/binding-win32-x64-msvc/-/binding-win32-x64-msvc-1.2.5.tgz",
864
+ "integrity": "sha512-e/SXpgISz+IoqVcSSI0rx/d/he8zqLex+/rCWpnHpmVfmPIUjag9H6P7zotf0gJHwPUhQxZ/mF8tr6acebT9yw==",
865
+ "cpu": [
866
+ "x64"
867
+ ],
868
+ "dev": true,
869
+ "license": "MIT",
870
+ "optional": true,
871
+ "os": [
872
+ "win32"
873
+ ],
874
+ "engines": {
875
+ "node": "^20.19.0 || >=22.12.0"
876
+ }
877
+ },
878
+ "node_modules/@rolldown/pluginutils": {
879
+ "version": "1.0.1",
880
+ "resolved": "https://registry.npmjs.org/@rolldown/pluginutils/-/pluginutils-1.0.1.tgz",
881
+ "integrity": "sha512-2j9bGt5Jh8hj+vPtgzPtl72j0yRxHAyumoo6TNfAjsLB04UtpSvPbPcDcBMxz7n+9CYB0c1GxQFxYRg2jimqGw==",
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",
888
+ "integrity": "sha512-dISoDXWWQwUquiKsyZ4Ng+HX2KsPL7LyHKHQwgGFEA3IaKac4Obd+h2a/a6waisAoepJlBcx9paWqjA8/HVjCw==",
889
+ "license": "MIT"
890
+ },
891
+ "node_modules/@types/prop-types": {
892
+ "version": "15.7.15",
893
+ "resolved": "https://registry.npmjs.org/@types/prop-types/-/prop-types-15.7.15.tgz",
894
+ "integrity": "sha512-F6bEyamV9jKGAFBEmlQnesRPGOQqS2+Uwi0Em15xenOxHaf2hv6L8YCVn3rPdPJOiJfPiCnLIRyvwVaqMY3MIw==",
895
+ "license": "MIT"
896
+ },
897
+ "node_modules/@types/react": {
898
+ "version": "19.2.18",
899
+ "resolved": "https://registry.npmjs.org/@types/react/-/react-19.2.18.tgz",
900
+ "integrity": "sha512-AnzbBERsrLKtk2XSfTbYRLjQPdy116Sty4q+T+Bp3IC4l6jNBvreVPAHmpq9qhXQM7CXZPjLVmGMw9sy+hxQ3w==",
901
+ "license": "MIT",
902
+ "peer": true,
903
+ "dependencies": {
904
+ "csstype": "^3.2.2"
905
+ }
906
+ },
907
+ "node_modules/@types/react-reconciler": {
908
+ "version": "0.28.9",
909
+ "resolved": "https://registry.npmjs.org/@types/react-reconciler/-/react-reconciler-0.28.9.tgz",
910
+ "integrity": "sha512-HHM3nxyUZ3zAylX8ZEyrDNd2XZOnQ0D5XfunJF5FLQnZbHHYq4UWvW1QfelQNXv1ICNkwYhfxjwfnqivYB6bFg==",
911
+ "license": "MIT",
912
+ "peerDependencies": {
913
+ "@types/react": "*"
914
+ }
915
+ },
916
+ "node_modules/@types/react-transition-group": {
917
+ "version": "4.4.12",
918
+ "resolved": "https://registry.npmjs.org/@types/react-transition-group/-/react-transition-group-4.4.12.tgz",
919
+ "integrity": "sha512-8TV6R3h2j7a91c+1DXdJi3Syo69zzIZbz7Lg5tORM5LEJG7X/E6a1V3drRyBRZq7/utz7A+c4OgYLiLcYGHG6w==",
920
+ "license": "MIT",
921
+ "peerDependencies": {
922
+ "@types/react": "*"
923
+ }
924
+ },
925
+ "node_modules/@types/webxr": {
926
+ "version": "0.5.24",
927
+ "resolved": "https://registry.npmjs.org/@types/webxr/-/webxr-0.5.24.tgz",
928
+ "integrity": "sha512-h8fgEd/DpoS9CBrjEQXR+dIDraopAEfu4wYVNY2tEPwk60stPWhvZMf4Foo5FakuQ7HFZoa8WceaWFervK2Ovg==",
929
+ "license": "MIT"
930
+ },
931
+ "node_modules/@vitejs/plugin-react": {
932
+ "version": "6.1.0",
933
+ "resolved": "https://registry.npmjs.org/@vitejs/plugin-react/-/plugin-react-6.1.0.tgz",
934
+ "integrity": "sha512-qd2BzUBehkov86WFhg0JkEFEYyCLG9uPCe6qWTY/kRlss9OvJrOF2UbIWT7p+8IzZHkEu0DNGHc4HSv+JdDLsw==",
935
+ "dev": true,
936
+ "license": "MIT",
937
+ "dependencies": {
938
+ "@rolldown/pluginutils": "^1.0.1"
939
+ },
940
+ "engines": {
941
+ "node": "^20.19.0 || >=22.12.0"
942
+ },
943
+ "peerDependencies": {
944
+ "@rolldown/plugin-babel": "^0.1.7 || ^0.2.0",
945
+ "babel-plugin-react-compiler": "^1.0.0",
946
+ "oxc-transform-react": "^0.145.0",
947
+ "vite": "^8.0.0"
948
+ },
949
+ "peerDependenciesMeta": {
950
+ "@rolldown/plugin-babel": {
951
+ "optional": true
952
+ },
953
+ "babel-plugin-react-compiler": {
954
+ "optional": true
955
+ },
956
+ "oxc-transform-react": {
957
+ "optional": true
958
+ }
959
+ }
960
+ },
961
+ "node_modules/babel-plugin-macros": {
962
+ "version": "3.1.0",
963
+ "resolved": "https://registry.npmjs.org/babel-plugin-macros/-/babel-plugin-macros-3.1.0.tgz",
964
+ "integrity": "sha512-Cg7TFGpIr01vOQNODXOOaGz2NpCU5gl8x1qJFbb6hbZxR7XrcE2vtbAsTAbJ7/xwJtUuJEw8K8Zr/AE0LHlesg==",
965
+ "license": "MIT",
966
+ "dependencies": {
967
+ "@babel/runtime": "^7.12.5",
968
+ "cosmiconfig": "^7.0.0",
969
+ "resolve": "^1.19.0"
970
+ },
971
+ "engines": {
972
+ "node": ">=10",
973
+ "npm": ">=6"
974
+ }
975
+ },
976
+ "node_modules/base64-js": {
977
+ "version": "1.5.1",
978
+ "resolved": "https://registry.npmjs.org/base64-js/-/base64-js-1.5.1.tgz",
979
+ "integrity": "sha512-AKpaYlHn8t4SVbOHCy+b5+KKgvR4vrsD8vbvrbiQJps7fKDTkjkDry6ji0rUJjC0kzbNePLwzxq8iypo41qeWA==",
980
+ "funding": [
981
+ {
982
+ "type": "github",
983
+ "url": "https://github.com/sponsors/feross"
984
+ },
985
+ {
986
+ "type": "patreon",
987
+ "url": "https://www.patreon.com/feross"
988
+ },
989
+ {
990
+ "type": "consulting",
991
+ "url": "https://feross.org/support"
992
+ }
993
+ ],
994
+ "license": "MIT"
995
+ },
996
+ "node_modules/buffer": {
997
+ "version": "6.0.3",
998
+ "resolved": "https://registry.npmjs.org/buffer/-/buffer-6.0.3.tgz",
999
+ "integrity": "sha512-FTiCpNxtwiZZHEZbcbTIcZjERVICn9yq/pDFkTl95/AxzD1naBctN7YO68riM/gLSDY7sdrMby8hofADYuuqOA==",
1000
+ "funding": [
1001
+ {
1002
+ "type": "github",
1003
+ "url": "https://github.com/sponsors/feross"
1004
+ },
1005
+ {
1006
+ "type": "patreon",
1007
+ "url": "https://www.patreon.com/feross"
1008
+ },
1009
+ {
1010
+ "type": "consulting",
1011
+ "url": "https://feross.org/support"
1012
+ }
1013
+ ],
1014
+ "license": "MIT",
1015
+ "dependencies": {
1016
+ "base64-js": "^1.3.1",
1017
+ "ieee754": "^1.2.1"
1018
+ }
1019
+ },
1020
+ "node_modules/callsites": {
1021
+ "version": "3.1.0",
1022
+ "resolved": "https://registry.npmjs.org/callsites/-/callsites-3.1.0.tgz",
1023
+ "integrity": "sha512-P8BjAsXvZS+VIDUI11hHCQEv74YT67YUi5JJFNWIqL235sBmjX4+qx9Muvls5ivyNENctx46xQLQ3aTuE7ssaQ==",
1024
+ "license": "MIT",
1025
+ "engines": {
1026
+ "node": ">=6"
1027
+ }
1028
+ },
1029
+ "node_modules/clsx": {
1030
+ "version": "2.1.1",
1031
+ "resolved": "https://registry.npmjs.org/clsx/-/clsx-2.1.1.tgz",
1032
+ "integrity": "sha512-eYm0QWBtUrBWZWG0d386OGAw16Z995PiOVo2B7bjWSbHedGl5e0ZWaq65kOGgUSNesEIDkB9ISbTg/JK9dhCZA==",
1033
+ "license": "MIT",
1034
+ "engines": {
1035
+ "node": ">=6"
1036
+ }
1037
+ },
1038
+ "node_modules/convert-source-map": {
1039
+ "version": "1.9.0",
1040
+ "resolved": "https://registry.npmjs.org/convert-source-map/-/convert-source-map-1.9.0.tgz",
1041
+ "integrity": "sha512-ASFBup0Mz1uyiIjANan1jzLQami9z1PoYSZCiiYW2FczPbenXc45FZdBZLzOT+r6+iciuEModtmCti+hjaAk0A==",
1042
+ "license": "MIT"
1043
+ },
1044
+ "node_modules/cosmiconfig": {
1045
+ "version": "7.1.0",
1046
+ "resolved": "https://registry.npmjs.org/cosmiconfig/-/cosmiconfig-7.1.0.tgz",
1047
+ "integrity": "sha512-AdmX6xUzdNASswsFtmwSt7Vj8po9IuqXm0UXz7QKPuEUmPB4XyjGfaAr2PSuELMwkRMVH1EpIkX5bTZGRB3eCA==",
1048
+ "license": "MIT",
1049
+ "dependencies": {
1050
+ "@types/parse-json": "^4.0.0",
1051
+ "import-fresh": "^3.2.1",
1052
+ "parse-json": "^5.0.0",
1053
+ "path-type": "^4.0.0",
1054
+ "yaml": "^1.10.0"
1055
+ },
1056
+ "engines": {
1057
+ "node": ">=10"
1058
+ }
1059
+ },
1060
+ "node_modules/cosmiconfig/node_modules/yaml": {
1061
+ "version": "1.10.3",
1062
+ "resolved": "https://registry.npmjs.org/yaml/-/yaml-1.10.3.tgz",
1063
+ "integrity": "sha512-vIYeF1u3CjlhAFekPPAk2h/Kv4T3mAkMox5OymRiJQB0spDP10LHvt+K7G9Ny6NuuMAb25/6n1qyUjAcGNf/AA==",
1064
+ "license": "ISC",
1065
+ "engines": {
1066
+ "node": ">= 6"
1067
+ }
1068
+ },
1069
+ "node_modules/csstype": {
1070
+ "version": "3.2.3",
1071
+ "resolved": "https://registry.npmjs.org/csstype/-/csstype-3.2.3.tgz",
1072
+ "integrity": "sha512-z1HGKcYy2xA8AGQfwrn0PAy+PB7X/GSj3UVJW9qKyn43xWa+gl5nXmU4qqLMRzWVLFC8KusUX8T/0kCiOYpAIQ==",
1073
+ "license": "MIT"
1074
+ },
1075
+ "node_modules/debug": {
1076
+ "version": "4.4.3",
1077
+ "resolved": "https://registry.npmjs.org/debug/-/debug-4.4.3.tgz",
1078
+ "integrity": "sha512-RGwwWnwQvkVfavKVt22FGLw+xYSdzARwm0ru6DhTVA3umU5hZc28V3kO4stgYryrTlLpuvgI9GiijltAjNbcqA==",
1079
+ "license": "MIT",
1080
+ "dependencies": {
1081
+ "ms": "^2.1.3"
1082
+ },
1083
+ "engines": {
1084
+ "node": ">=6.0"
1085
+ },
1086
+ "peerDependenciesMeta": {
1087
+ "supports-color": {
1088
+ "optional": true
1089
+ }
1090
+ }
1091
+ },
1092
+ "node_modules/detect-libc": {
1093
+ "version": "2.1.2",
1094
+ "resolved": "https://registry.npmjs.org/detect-libc/-/detect-libc-2.1.2.tgz",
1095
+ "integrity": "sha512-Btj2BOOO83o3WyH59e8MgXsxEQVcarkUOpEYrubB0urwnN10yQ364rsiByU11nZlqWYZm05i/of7io4mzihBtQ==",
1096
+ "dev": true,
1097
+ "license": "Apache-2.0",
1098
+ "engines": {
1099
+ "node": ">=8"
1100
+ }
1101
+ },
1102
+ "node_modules/dom-helpers": {
1103
+ "version": "5.2.1",
1104
+ "resolved": "https://registry.npmjs.org/dom-helpers/-/dom-helpers-5.2.1.tgz",
1105
+ "integrity": "sha512-nRCa7CK3VTrM2NmGkIy4cbK7IZlgBE/PYMn55rrXefr5xXDP0LdtfPnblFDoVdcAfslJ7or6iqAUnx0CCGIWQA==",
1106
+ "license": "MIT",
1107
+ "dependencies": {
1108
+ "@babel/runtime": "^7.8.7",
1109
+ "csstype": "^3.0.2"
1110
+ }
1111
+ },
1112
+ "node_modules/error-ex": {
1113
+ "version": "1.3.4",
1114
+ "resolved": "https://registry.npmjs.org/error-ex/-/error-ex-1.3.4.tgz",
1115
+ "integrity": "sha512-sqQamAnR14VgCr1A618A3sGrygcpK+HEbenA/HiEAkkUwcZIIB/tgWqHFxWgOyDh4nB4JCRimh79dR5Ywc9MDQ==",
1116
+ "license": "MIT",
1117
+ "dependencies": {
1118
+ "is-arrayish": "^0.2.1"
1119
+ }
1120
+ },
1121
+ "node_modules/es-errors": {
1122
+ "version": "1.3.0",
1123
+ "resolved": "https://registry.npmjs.org/es-errors/-/es-errors-1.3.0.tgz",
1124
+ "integrity": "sha512-Zf5H2Kxt2xjTvbJvP2ZWLEICxA6j+hAmMzIlypy4xcBg1vKVnx89Wy0GbS+kf5cwCVFFzdCFh2XSCFNULS6csw==",
1125
+ "license": "MIT",
1126
+ "engines": {
1127
+ "node": ">= 0.4"
1128
+ }
1129
+ },
1130
+ "node_modules/escape-string-regexp": {
1131
+ "version": "4.0.0",
1132
+ "resolved": "https://registry.npmjs.org/escape-string-regexp/-/escape-string-regexp-4.0.0.tgz",
1133
+ "integrity": "sha512-TtpcNJ3XAzx3Gq8sWRzJaVajRs0uVxA2YAkdb1jm2YkPz4G6egUFAyA3n5vtEIZefPk5Wa4UXbKuS5fKkJWdgA==",
1134
+ "license": "MIT",
1135
+ "engines": {
1136
+ "node": ">=10"
1137
+ },
1138
+ "funding": {
1139
+ "url": "https://github.com/sponsors/sindresorhus"
1140
+ }
1141
+ },
1142
+ "node_modules/fdir": {
1143
+ "version": "6.5.0",
1144
+ "resolved": "https://registry.npmjs.org/fdir/-/fdir-6.5.0.tgz",
1145
+ "integrity": "sha512-tIbYtZbucOs0BRGqPJkshJUYdL+SDH7dVM8gjy+ERp3WAUjLEFJE+02kanyHtwjWOnwrKYBiwAmM0p4kLJAnXg==",
1146
+ "dev": true,
1147
+ "license": "MIT",
1148
+ "engines": {
1149
+ "node": ">=12.0.0"
1150
+ },
1151
+ "peerDependencies": {
1152
+ "picomatch": "^3 || ^4"
1153
+ },
1154
+ "peerDependenciesMeta": {
1155
+ "picomatch": {
1156
+ "optional": true
1157
+ }
1158
+ }
1159
+ },
1160
+ "node_modules/find-root": {
1161
+ "version": "1.1.0",
1162
+ "resolved": "https://registry.npmjs.org/find-root/-/find-root-1.1.0.tgz",
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",
1169
+ "integrity": "sha512-5xoDfX+fL7faATnagmWPpbFtwh/R77WmMMqqHGS65C3vvB0YHrgF+B1YmZ3441tMj5n63k0212XNoJwzlhffQw==",
1170
+ "dev": true,
1171
+ "hasInstallScript": true,
1172
+ "license": "MIT",
1173
+ "optional": true,
1174
+ "os": [
1175
+ "darwin"
1176
+ ],
1177
+ "engines": {
1178
+ "node": "^8.16.0 || ^10.6.0 || >=11.0.0"
1179
+ }
1180
+ },
1181
+ "node_modules/function-bind": {
1182
+ "version": "1.1.2",
1183
+ "resolved": "https://registry.npmjs.org/function-bind/-/function-bind-1.1.2.tgz",
1184
+ "integrity": "sha512-7XHNxH7qX9xG5mIwxkhumTox/MIRNcOgDrxWsMt2pAr23WHp6MrRlN7FBSFpCpr+oVO0F744iUgR82nJMfG2SA==",
1185
+ "license": "MIT",
1186
+ "funding": {
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",
1193
+ "integrity": "sha512-T2UbfbBEF32wiepXIsMlTW9+dDYC6wMh/t/vYA4tuOMKqWz/n3vr1NFSxQiyP+zk2mXsoMA/i/7qV6LKut1t1A==",
1194
+ "license": "MIT",
1195
+ "dependencies": {
1196
+ "function-bind": "^1.1.2"
1197
+ },
1198
+ "engines": {
1199
+ "node": ">= 0.4"
1200
+ }
1201
+ },
1202
+ "node_modules/hoist-non-react-statics": {
1203
+ "version": "3.3.2",
1204
+ "resolved": "https://registry.npmjs.org/hoist-non-react-statics/-/hoist-non-react-statics-3.3.2.tgz",
1205
+ "integrity": "sha512-/gGivxi8JPKWNm/W0jSmzcMPpfpPLc3dY/6GxhX2hQ9iGj3aDfklV4ET7NjKpSinLpJ5vafa9iiGIEZg10SfBw==",
1206
+ "license": "BSD-3-Clause",
1207
+ "dependencies": {
1208
+ "react-is": "^16.7.0"
1209
+ }
1210
+ },
1211
+ "node_modules/hoist-non-react-statics/node_modules/react-is": {
1212
+ "version": "16.13.1",
1213
+ "resolved": "https://registry.npmjs.org/react-is/-/react-is-16.13.1.tgz",
1214
+ "integrity": "sha512-24e6ynE2H+OKt4kqsOvNd8kBpV65zoxbA4BVsEOB3ARVWQki/DHzaUoC5KuON/BiccDaCCTZBuOcfZs70kR8bQ==",
1215
+ "license": "MIT"
1216
+ },
1217
+ "node_modules/ieee754": {
1218
+ "version": "1.2.1",
1219
+ "resolved": "https://registry.npmjs.org/ieee754/-/ieee754-1.2.1.tgz",
1220
+ "integrity": "sha512-dcyqhDvX1C46lXZcVqCpK+FtMRQVdIMN6/Df5js2zouUsqG7I6sFxitIC+7KYK29KdXOLHdu9zL4sFnoVQnqaA==",
1221
+ "funding": [
1222
+ {
1223
+ "type": "github",
1224
+ "url": "https://github.com/sponsors/feross"
1225
+ },
1226
+ {
1227
+ "type": "patreon",
1228
+ "url": "https://www.patreon.com/feross"
1229
+ },
1230
+ {
1231
+ "type": "consulting",
1232
+ "url": "https://feross.org/support"
1233
+ }
1234
+ ],
1235
+ "license": "BSD-3-Clause"
1236
+ },
1237
+ "node_modules/import-fresh": {
1238
+ "version": "3.3.1",
1239
+ "resolved": "https://registry.npmjs.org/import-fresh/-/import-fresh-3.3.1.tgz",
1240
+ "integrity": "sha512-TR3KfrTZTYLPB6jUjfx6MF9WcWrHL9su5TObK4ZkYgBdWKPOFoSoQIdEuTuR82pmtxH2spWG9h6etwfr1pLBqQ==",
1241
+ "license": "MIT",
1242
+ "dependencies": {
1243
+ "parent-module": "^1.0.0",
1244
+ "resolve-from": "^4.0.0"
1245
+ },
1246
+ "engines": {
1247
+ "node": ">=6"
1248
+ },
1249
+ "funding": {
1250
+ "url": "https://github.com/sponsors/sindresorhus"
1251
+ }
1252
+ },
1253
+ "node_modules/is-arrayish": {
1254
+ "version": "0.2.1",
1255
+ "resolved": "https://registry.npmjs.org/is-arrayish/-/is-arrayish-0.2.1.tgz",
1256
+ "integrity": "sha512-zz06S8t0ozoDXMG+ube26zeCTNXcKIPJZJi8hBrF4idCLms4CG9QtK7qBl1boi5ODzFpjswb5JPmHCbMpjaYzg==",
1257
+ "license": "MIT"
1258
+ },
1259
+ "node_modules/is-core-module": {
1260
+ "version": "2.16.2",
1261
+ "resolved": "https://registry.npmjs.org/is-core-module/-/is-core-module-2.16.2.tgz",
1262
+ "integrity": "sha512-evOr8xfXKxE6qSR0hSXL2r3sd7ALj8+7jQEUvPYcm5sgZFdJ+AYzT6yNmJenvIYQBgIGwfwz08sL8zoL7yq2BA==",
1263
+ "license": "MIT",
1264
+ "dependencies": {
1265
+ "hasown": "^2.0.3"
1266
+ },
1267
+ "engines": {
1268
+ "node": ">= 0.4"
1269
+ },
1270
+ "funding": {
1271
+ "url": "https://github.com/sponsors/ljharb"
1272
+ }
1273
+ },
1274
+ "node_modules/its-fine": {
1275
+ "version": "2.0.0",
1276
+ "resolved": "https://registry.npmjs.org/its-fine/-/its-fine-2.0.0.tgz",
1277
+ "integrity": "sha512-KLViCmWx94zOvpLwSlsx6yOCeMhZYaxrJV87Po5k/FoZzcPSahvK5qJ7fYhS61sZi5ikmh2S3Hz55A2l3U69ng==",
1278
+ "license": "MIT",
1279
+ "dependencies": {
1280
+ "@types/react-reconciler": "^0.28.9"
1281
+ },
1282
+ "peerDependencies": {
1283
+ "react": "^19.0.0"
1284
+ }
1285
+ },
1286
+ "node_modules/js-tokens": {
1287
+ "version": "4.0.0",
1288
+ "resolved": "https://registry.npmjs.org/js-tokens/-/js-tokens-4.0.0.tgz",
1289
+ "integrity": "sha512-RdJUflcE3cUzKiMqQgsCu06FPu9UdIJO0beYbPhHN4k6apgJtifcoCtT9bcxOpYBtpD2kCM6Sbzg4CausW/PKQ==",
1290
+ "license": "MIT"
1291
+ },
1292
+ "node_modules/jsesc": {
1293
+ "version": "3.1.0",
1294
+ "resolved": "https://registry.npmjs.org/jsesc/-/jsesc-3.1.0.tgz",
1295
+ "integrity": "sha512-/sM3dO2FOzXjKQhJuo0Q173wf2KOo8t4I8vHy6lF9poUp7bKT0/NHE8fPX23PwfhnykfqnC2xRxOnVw5XuGIaA==",
1296
+ "license": "MIT",
1297
+ "bin": {
1298
+ "jsesc": "bin/jsesc"
1299
+ },
1300
+ "engines": {
1301
+ "node": ">=6"
1302
+ }
1303
+ },
1304
+ "node_modules/json-parse-even-better-errors": {
1305
+ "version": "2.3.1",
1306
+ "resolved": "https://registry.npmjs.org/json-parse-even-better-errors/-/json-parse-even-better-errors-2.3.1.tgz",
1307
+ "integrity": "sha512-xyFwyhro/JEof6Ghe2iz2NcXoj2sloNsWr/XsERDK/oiPCfaNhl5ONfp+jQdAZRQQ0IJWNzH9zIZF7li91kh2w==",
1308
+ "license": "MIT"
1309
+ },
1310
+ "node_modules/lightningcss": {
1311
+ "version": "1.33.0",
1312
+ "resolved": "https://registry.npmjs.org/lightningcss/-/lightningcss-1.33.0.tgz",
1313
+ "integrity": "sha512-WkUDrojuJs0xkgGf2udWxa3yGBRxPtxUkB79i6aCZLRgc7PM8fZe9TosfPDcvEpQZbuFASnHYmRLBLUbmLOIIA==",
1314
+ "dev": true,
1315
+ "license": "MPL-2.0",
1316
+ "dependencies": {
1317
+ "detect-libc": "^2.0.3"
1318
+ },
1319
+ "engines": {
1320
+ "node": ">= 12.0.0"
1321
+ },
1322
+ "funding": {
1323
+ "type": "opencollective",
1324
+ "url": "https://opencollective.com/parcel"
1325
+ },
1326
+ "optionalDependencies": {
1327
+ "lightningcss-android-arm64": "1.33.0",
1328
+ "lightningcss-darwin-arm64": "1.33.0",
1329
+ "lightningcss-darwin-x64": "1.33.0",
1330
+ "lightningcss-freebsd-x64": "1.33.0",
1331
+ "lightningcss-linux-arm-gnueabihf": "1.33.0",
1332
+ "lightningcss-linux-arm64-gnu": "1.33.0",
1333
+ "lightningcss-linux-arm64-musl": "1.33.0",
1334
+ "lightningcss-linux-x64-gnu": "1.33.0",
1335
+ "lightningcss-linux-x64-musl": "1.33.0",
1336
+ "lightningcss-win32-arm64-msvc": "1.33.0",
1337
+ "lightningcss-win32-x64-msvc": "1.33.0"
1338
+ }
1339
+ },
1340
+ "node_modules/lightningcss-android-arm64": {
1341
+ "version": "1.33.0",
1342
+ "resolved": "https://registry.npmjs.org/lightningcss-android-arm64/-/lightningcss-android-arm64-1.33.0.tgz",
1343
+ "integrity": "sha512-gEpRTalKdosp4Bb8qWtc2iOgE5SeIHlpS1up9bFq2wAyYhl1UdTObYiHe98zEM9SQvSoqQZ1IQD0JNpg3Ml5pg==",
1344
+ "cpu": [
1345
+ "arm64"
1346
+ ],
1347
+ "dev": true,
1348
+ "license": "MPL-2.0",
1349
+ "optional": true,
1350
+ "os": [
1351
+ "android"
1352
+ ],
1353
+ "engines": {
1354
+ "node": ">= 12.0.0"
1355
+ },
1356
+ "funding": {
1357
+ "type": "opencollective",
1358
+ "url": "https://opencollective.com/parcel"
1359
+ }
1360
+ },
1361
+ "node_modules/lightningcss-darwin-arm64": {
1362
+ "version": "1.33.0",
1363
+ "resolved": "https://registry.npmjs.org/lightningcss-darwin-arm64/-/lightningcss-darwin-arm64-1.33.0.tgz",
1364
+ "integrity": "sha512-Sciaz8eenNTKn9b3t7+xr0ipTp9YxKQY4npwQ3mrRuL0BAVHBLyZxofhaKBAVtzmtRZ/zTyo0/to4B1uWG/Djg==",
1365
+ "cpu": [
1366
+ "arm64"
1367
+ ],
1368
+ "dev": true,
1369
+ "license": "MPL-2.0",
1370
+ "optional": true,
1371
+ "os": [
1372
+ "darwin"
1373
+ ],
1374
+ "engines": {
1375
+ "node": ">= 12.0.0"
1376
+ },
1377
+ "funding": {
1378
+ "type": "opencollective",
1379
+ "url": "https://opencollective.com/parcel"
1380
+ }
1381
+ },
1382
+ "node_modules/lightningcss-darwin-x64": {
1383
+ "version": "1.33.0",
1384
+ "resolved": "https://registry.npmjs.org/lightningcss-darwin-x64/-/lightningcss-darwin-x64-1.33.0.tgz",
1385
+ "integrity": "sha512-Z5UPAxzrjlWNNyGy6i65cJzzvgJ5D3T6wMvs+gWpY9d7qRhANrxqAp6LhxIgZhWEw18RfJTGcRxjuLIBr+m8XQ==",
1386
+ "cpu": [
1387
+ "x64"
1388
+ ],
1389
+ "dev": true,
1390
+ "license": "MPL-2.0",
1391
+ "optional": true,
1392
+ "os": [
1393
+ "darwin"
1394
+ ],
1395
+ "engines": {
1396
+ "node": ">= 12.0.0"
1397
+ },
1398
+ "funding": {
1399
+ "type": "opencollective",
1400
+ "url": "https://opencollective.com/parcel"
1401
+ }
1402
+ },
1403
+ "node_modules/lightningcss-freebsd-x64": {
1404
+ "version": "1.33.0",
1405
+ "resolved": "https://registry.npmjs.org/lightningcss-freebsd-x64/-/lightningcss-freebsd-x64-1.33.0.tgz",
1406
+ "integrity": "sha512-QQM/Ti/hQajJwCY+RiWuCZ9sdtI/XQk7nDK5vC8kkdwixezOlDgvDx7+RT+QjK6FcFT4MpsuoBnHIo/O3StRRg==",
1407
+ "cpu": [
1408
+ "x64"
1409
+ ],
1410
+ "dev": true,
1411
+ "license": "MPL-2.0",
1412
+ "optional": true,
1413
+ "os": [
1414
+ "freebsd"
1415
+ ],
1416
+ "engines": {
1417
+ "node": ">= 12.0.0"
1418
+ },
1419
+ "funding": {
1420
+ "type": "opencollective",
1421
+ "url": "https://opencollective.com/parcel"
1422
+ }
1423
+ },
1424
+ "node_modules/lightningcss-linux-arm-gnueabihf": {
1425
+ "version": "1.33.0",
1426
+ "resolved": "https://registry.npmjs.org/lightningcss-linux-arm-gnueabihf/-/lightningcss-linux-arm-gnueabihf-1.33.0.tgz",
1427
+ "integrity": "sha512-N7FVBe6iS24MlM6R/4RBTxGhQheZGs7tiQ9U32UtF75NzP5Q7xWPRqLBCKxlRQRk3rY1jCIPLzx7WzOhuUIRLQ==",
1428
+ "cpu": [
1429
+ "arm"
1430
+ ],
1431
+ "dev": true,
1432
+ "license": "MPL-2.0",
1433
+ "optional": true,
1434
+ "os": [
1435
+ "linux"
1436
+ ],
1437
+ "engines": {
1438
+ "node": ">= 12.0.0"
1439
+ },
1440
+ "funding": {
1441
+ "type": "opencollective",
1442
+ "url": "https://opencollective.com/parcel"
1443
+ }
1444
+ },
1445
+ "node_modules/lightningcss-linux-arm64-gnu": {
1446
+ "version": "1.33.0",
1447
+ "resolved": "https://registry.npmjs.org/lightningcss-linux-arm64-gnu/-/lightningcss-linux-arm64-gnu-1.33.0.tgz",
1448
+ "integrity": "sha512-j2v/itmy4HlNxlc6voKXYgBqNi0Ng2LShg4z7GufpEgs05P+2suBVyi9I6YHq5uoVFx9ETin3eCEhLVyXGQnKg==",
1449
+ "cpu": [
1450
+ "arm64"
1451
+ ],
1452
+ "dev": true,
1453
+ "license": "MPL-2.0",
1454
+ "optional": true,
1455
+ "os": [
1456
+ "linux"
1457
+ ],
1458
+ "engines": {
1459
+ "node": ">= 12.0.0"
1460
+ },
1461
+ "funding": {
1462
+ "type": "opencollective",
1463
+ "url": "https://opencollective.com/parcel"
1464
+ }
1465
+ },
1466
+ "node_modules/lightningcss-linux-arm64-musl": {
1467
+ "version": "1.33.0",
1468
+ "resolved": "https://registry.npmjs.org/lightningcss-linux-arm64-musl/-/lightningcss-linux-arm64-musl-1.33.0.tgz",
1469
+ "integrity": "sha512-yiO5ROMuYQgXbC60yjZU5CYSFZGKXL0HFATXt9mHJn1+zW55oCtMI9NfcVhYLMFDL7gV7oBPon/EmMMGg2OvtQ==",
1470
+ "cpu": [
1471
+ "arm64"
1472
+ ],
1473
+ "dev": true,
1474
+ "license": "MPL-2.0",
1475
+ "optional": true,
1476
+ "os": [
1477
+ "linux"
1478
+ ],
1479
+ "engines": {
1480
+ "node": ">= 12.0.0"
1481
+ },
1482
+ "funding": {
1483
+ "type": "opencollective",
1484
+ "url": "https://opencollective.com/parcel"
1485
+ }
1486
+ },
1487
+ "node_modules/lightningcss-linux-x64-gnu": {
1488
+ "version": "1.33.0",
1489
+ "resolved": "https://registry.npmjs.org/lightningcss-linux-x64-gnu/-/lightningcss-linux-x64-gnu-1.33.0.tgz",
1490
+ "integrity": "sha512-ar+Ju7LmcN0Jo4FpL4hpFybwNG9/3A/Br5KW2n2jyODg3MEZXaDYADdemoNS+BDNfMgKvylJLj4S5tyRActuAg==",
1491
+ "cpu": [
1492
+ "x64"
1493
+ ],
1494
+ "dev": true,
1495
+ "license": "MPL-2.0",
1496
+ "optional": true,
1497
+ "os": [
1498
+ "linux"
1499
+ ],
1500
+ "engines": {
1501
+ "node": ">= 12.0.0"
1502
+ },
1503
+ "funding": {
1504
+ "type": "opencollective",
1505
+ "url": "https://opencollective.com/parcel"
1506
+ }
1507
+ },
1508
+ "node_modules/lightningcss-linux-x64-musl": {
1509
+ "version": "1.33.0",
1510
+ "resolved": "https://registry.npmjs.org/lightningcss-linux-x64-musl/-/lightningcss-linux-x64-musl-1.33.0.tgz",
1511
+ "integrity": "sha512-RYiYbkokw0trfKqqzfF55lginwEPrD3OJDfTuJzFs1MK6iFnDenaz1fqLLtX4ITG3OktJQXOeTaw1awrBAlZPw==",
1512
+ "cpu": [
1513
+ "x64"
1514
+ ],
1515
+ "dev": true,
1516
+ "license": "MPL-2.0",
1517
+ "optional": true,
1518
+ "os": [
1519
+ "linux"
1520
+ ],
1521
+ "engines": {
1522
+ "node": ">= 12.0.0"
1523
+ },
1524
+ "funding": {
1525
+ "type": "opencollective",
1526
+ "url": "https://opencollective.com/parcel"
1527
+ }
1528
+ },
1529
+ "node_modules/lightningcss-win32-arm64-msvc": {
1530
+ "version": "1.33.0",
1531
+ "resolved": "https://registry.npmjs.org/lightningcss-win32-arm64-msvc/-/lightningcss-win32-arm64-msvc-1.33.0.tgz",
1532
+ "integrity": "sha512-1K+MPfLSFVpphzpdbfkhlWk6wBrTObBzS2T6db10PNOZgR9GoVsAWzwNyuhUYYbTp23j+4RrncfujZ4uAzXvwA==",
1533
+ "cpu": [
1534
+ "arm64"
1535
+ ],
1536
+ "dev": true,
1537
+ "license": "MPL-2.0",
1538
+ "optional": true,
1539
+ "os": [
1540
+ "win32"
1541
+ ],
1542
+ "engines": {
1543
+ "node": ">= 12.0.0"
1544
+ },
1545
+ "funding": {
1546
+ "type": "opencollective",
1547
+ "url": "https://opencollective.com/parcel"
1548
+ }
1549
+ },
1550
+ "node_modules/lightningcss-win32-x64-msvc": {
1551
+ "version": "1.33.0",
1552
+ "resolved": "https://registry.npmjs.org/lightningcss-win32-x64-msvc/-/lightningcss-win32-x64-msvc-1.33.0.tgz",
1553
+ "integrity": "sha512-OlEICDx/Xl0FqSp4bry8zFnCvGpig3Gl4gCquvYwHuqJKEC1+n9NgDniFvqHGmMv1ZkqDJrDqKKSykTDX+ehuA==",
1554
+ "cpu": [
1555
+ "x64"
1556
+ ],
1557
+ "dev": true,
1558
+ "license": "MPL-2.0",
1559
+ "optional": true,
1560
+ "os": [
1561
+ "win32"
1562
+ ],
1563
+ "engines": {
1564
+ "node": ">= 12.0.0"
1565
+ },
1566
+ "funding": {
1567
+ "type": "opencollective",
1568
+ "url": "https://opencollective.com/parcel"
1569
+ }
1570
+ },
1571
+ "node_modules/lines-and-columns": {
1572
+ "version": "1.2.4",
1573
+ "resolved": "https://registry.npmjs.org/lines-and-columns/-/lines-and-columns-1.2.4.tgz",
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",
1580
+ "integrity": "sha512-lyuxPGr/Wfhrlem2CL/UcnUc1zcqKAImBDzukY7Y5F/yQiNdko6+fRLevlw1HgMySw7f611UIY408EtxRSoK3Q==",
1581
+ "license": "MIT",
1582
+ "dependencies": {
1583
+ "js-tokens": "^3.0.0 || ^4.0.0"
1584
+ },
1585
+ "bin": {
1586
+ "loose-envify": "cli.js"
1587
+ }
1588
+ },
1589
+ "node_modules/ms": {
1590
+ "version": "2.1.3",
1591
+ "resolved": "https://registry.npmjs.org/ms/-/ms-2.1.3.tgz",
1592
+ "integrity": "sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA==",
1593
+ "license": "MIT"
1594
+ },
1595
+ "node_modules/nanoid": {
1596
+ "version": "3.3.18",
1597
+ "resolved": "https://registry.npmjs.org/nanoid/-/nanoid-3.3.18.tgz",
1598
+ "integrity": "sha512-DTg4MJbGMWkfi6VZFdNt2/caMbQy4Ou+Op/hJQvGEWcnVfoA1QA+xzRKAzw9jD6+GVOOeYr/mIcuDSdug6F6+w==",
1599
+ "dev": true,
1600
+ "funding": [
1601
+ {
1602
+ "type": "github",
1603
+ "url": "https://github.com/sponsors/ai"
1604
+ }
1605
+ ],
1606
+ "license": "MIT",
1607
+ "bin": {
1608
+ "nanoid": "bin/nanoid.cjs"
1609
+ },
1610
+ "engines": {
1611
+ "node": "^10 || ^12 || ^13.7 || ^14 || >=15.0.1"
1612
+ }
1613
+ },
1614
+ "node_modules/object-assign": {
1615
+ "version": "4.1.1",
1616
+ "resolved": "https://registry.npmjs.org/object-assign/-/object-assign-4.1.1.tgz",
1617
+ "integrity": "sha512-rJgTQnkUnH1sFw8yT6VSU3zD3sWmu6sZhIseY8VX+GRu3P6F7Fu+JNDoXfklElbLJSnc3FUQHVe4cU5hj+BcUg==",
1618
+ "license": "MIT",
1619
+ "engines": {
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",
1626
+ "integrity": "sha512-GQ2EWRpQV8/o+Aw8YqtfZZPfNRWZYkbidE9k5rpl/hC3vtHHBfGm2Ifi6qWV+coDGkrUKZAxE3Lot5kcsRlh+g==",
1627
+ "license": "MIT",
1628
+ "dependencies": {
1629
+ "callsites": "^3.0.0"
1630
+ },
1631
+ "engines": {
1632
+ "node": ">=6"
1633
+ }
1634
+ },
1635
+ "node_modules/parse-json": {
1636
+ "version": "5.2.0",
1637
+ "resolved": "https://registry.npmjs.org/parse-json/-/parse-json-5.2.0.tgz",
1638
+ "integrity": "sha512-ayCKvm/phCGxOkYRSCM82iDwct8/EonSEgCSxWxD7ve6jHggsFl4fZVQBPRNgQoKiuV/odhFrGzQXZwbifC8Rg==",
1639
+ "license": "MIT",
1640
+ "dependencies": {
1641
+ "@babel/code-frame": "^7.0.0",
1642
+ "error-ex": "^1.3.1",
1643
+ "json-parse-even-better-errors": "^2.3.0",
1644
+ "lines-and-columns": "^1.1.6"
1645
+ },
1646
+ "engines": {
1647
+ "node": ">=8"
1648
+ },
1649
+ "funding": {
1650
+ "url": "https://github.com/sponsors/sindresorhus"
1651
+ }
1652
+ },
1653
+ "node_modules/path-parse": {
1654
+ "version": "1.0.7",
1655
+ "resolved": "https://registry.npmjs.org/path-parse/-/path-parse-1.0.7.tgz",
1656
+ "integrity": "sha512-LDJzPVEEEPR+y48z93A0Ed0yXb8pAByGWo/k5YYdYgpY2/2EsOsksJrq7lOHxryrVOn1ejG6oAp8ahvOIQD8sw==",
1657
+ "license": "MIT"
1658
+ },
1659
+ "node_modules/path-type": {
1660
+ "version": "4.0.0",
1661
+ "resolved": "https://registry.npmjs.org/path-type/-/path-type-4.0.0.tgz",
1662
+ "integrity": "sha512-gDKb8aZMDeD/tZWs9P6+q0J9Mwkdl6xMV8TjnGP3qJVJ06bdMgkbBlLU8IdfOsIsFz2BW1rNVT3XuNEl8zPAvw==",
1663
+ "license": "MIT",
1664
+ "engines": {
1665
+ "node": ">=8"
1666
+ }
1667
+ },
1668
+ "node_modules/picocolors": {
1669
+ "version": "1.1.1",
1670
+ "resolved": "https://registry.npmjs.org/picocolors/-/picocolors-1.1.1.tgz",
1671
+ "integrity": "sha512-xceH2snhtb5M9liqDsmEw56le376mTZkEX/jEb/RxNFyegNul7eNslCXP9FDj/Lcu0X8KEyMceP2ntpaHrDEVA==",
1672
+ "license": "ISC"
1673
+ },
1674
+ "node_modules/picomatch": {
1675
+ "version": "4.0.5",
1676
+ "resolved": "https://registry.npmjs.org/picomatch/-/picomatch-4.0.5.tgz",
1677
+ "integrity": "sha512-RvwwcruNjI1ncT5xRakeyS9Lf8lcItv34KD+aif+VH9kduAyfYBipGh12274xtenIPZ119/R9BdTBa8gAwSh0A==",
1678
+ "dev": true,
1679
+ "license": "MIT",
1680
+ "peer": true,
1681
+ "engines": {
1682
+ "node": ">=12"
1683
+ },
1684
+ "funding": {
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",
1691
+ "integrity": "sha512-u82N74LFzG8ca+dD8puPnplTXoGH4fTPpVGuIbt36G3qvNlkvfD0lEAZSxaly3KX8TS/L1A1gsCEmvKmBcVbkQ==",
1692
+ "dev": true,
1693
+ "funding": [
1694
+ {
1695
+ "type": "opencollective",
1696
+ "url": "https://opencollective.com/postcss/"
1697
+ },
1698
+ {
1699
+ "type": "tidelift",
1700
+ "url": "https://tidelift.com/funding/github/npm/postcss"
1701
+ },
1702
+ {
1703
+ "type": "github",
1704
+ "url": "https://github.com/sponsors/ai"
1705
+ }
1706
+ ],
1707
+ "license": "MIT",
1708
+ "dependencies": {
1709
+ "nanoid": "^3.3.17",
1710
+ "picocolors": "^1.1.1",
1711
+ "source-map-js": "^1.2.1"
1712
+ },
1713
+ "engines": {
1714
+ "node": "^10 || ^12 || >=14"
1715
+ }
1716
+ },
1717
+ "node_modules/prop-types": {
1718
+ "version": "15.8.1",
1719
+ "resolved": "https://registry.npmjs.org/prop-types/-/prop-types-15.8.1.tgz",
1720
+ "integrity": "sha512-oj87CgZICdulUohogVAR7AjlC0327U4el4L6eAvOqCeudMDVU0NThNaV+b9Df4dXgSP1gXMTnPdhfe/2qDH5cg==",
1721
+ "license": "MIT",
1722
+ "dependencies": {
1723
+ "loose-envify": "^1.4.0",
1724
+ "object-assign": "^4.1.1",
1725
+ "react-is": "^16.13.1"
1726
+ }
1727
+ },
1728
+ "node_modules/prop-types/node_modules/react-is": {
1729
+ "version": "16.13.1",
1730
+ "resolved": "https://registry.npmjs.org/react-is/-/react-is-16.13.1.tgz",
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",
1737
+ "integrity": "sha512-PWaYA1L/q9u2u7xYQi+Y3L3Yfnie7XyLeaJICV1MGD6LprsBxcAqGjYyr0eY3p+QdsA+x/Irkt4Qif8D63+Sbw==",
1738
+ "license": "MIT",
1739
+ "peer": true,
1740
+ "engines": {
1741
+ "node": ">=0.10.0"
1742
+ }
1743
+ },
1744
+ "node_modules/react-dom": {
1745
+ "version": "19.2.8",
1746
+ "resolved": "https://registry.npmjs.org/react-dom/-/react-dom-19.2.8.tgz",
1747
+ "integrity": "sha512-rVprimfGBG3DR+Tq0IQG2DT5PxKth1WIGDmj5yPmlzr4YBe7uyE+Du4oVqTDXZSHGGGXRtTJEGSSePyQCMBglQ==",
1748
+ "license": "MIT",
1749
+ "peer": true,
1750
+ "dependencies": {
1751
+ "scheduler": "^0.27.0"
1752
+ },
1753
+ "peerDependencies": {
1754
+ "react": "^19.2.8"
1755
+ }
1756
+ },
1757
+ "node_modules/react-is": {
1758
+ "version": "19.2.8",
1759
+ "resolved": "https://registry.npmjs.org/react-is/-/react-is-19.2.8.tgz",
1760
+ "integrity": "sha512-s5un28nYxKJw5gvUHyW5PCC28CvBqLu9r3cWgzHT4Vo/5fqqkFcdRYsGcKf50WMPpjjFZS5d76fn3YCo2njKwQ==",
1761
+ "license": "MIT"
1762
+ },
1763
+ "node_modules/react-transition-group": {
1764
+ "version": "4.4.5",
1765
+ "resolved": "https://registry.npmjs.org/react-transition-group/-/react-transition-group-4.4.5.tgz",
1766
+ "integrity": "sha512-pZcd1MCJoiKiBR2NRxeCRg13uCXbydPnmB4EOeRrY7480qNWO8IIgQG6zlDkm6uRMsURXPuKq0GWtiM59a5Q6g==",
1767
+ "license": "BSD-3-Clause",
1768
+ "dependencies": {
1769
+ "@babel/runtime": "^7.5.5",
1770
+ "dom-helpers": "^5.0.1",
1771
+ "loose-envify": "^1.4.0",
1772
+ "prop-types": "^15.6.2"
1773
+ },
1774
+ "peerDependencies": {
1775
+ "react": ">=16.6.0",
1776
+ "react-dom": ">=16.6.0"
1777
+ }
1778
+ },
1779
+ "node_modules/react-use-measure": {
1780
+ "version": "2.1.7",
1781
+ "resolved": "https://registry.npmjs.org/react-use-measure/-/react-use-measure-2.1.7.tgz",
1782
+ "integrity": "sha512-KrvcAo13I/60HpwGO5jpW7E9DfusKyLPLvuHlUyP5zqnmAPhNc6qTRjUQrdTADl0lpPpDVU2/Gg51UlOGHXbdg==",
1783
+ "license": "MIT",
1784
+ "peerDependencies": {
1785
+ "react": ">=16.13",
1786
+ "react-dom": ">=16.13"
1787
+ },
1788
+ "peerDependenciesMeta": {
1789
+ "react-dom": {
1790
+ "optional": true
1791
+ }
1792
+ }
1793
+ },
1794
+ "node_modules/resolve": {
1795
+ "version": "1.22.12",
1796
+ "resolved": "https://registry.npmjs.org/resolve/-/resolve-1.22.12.tgz",
1797
+ "integrity": "sha512-TyeJ1zif53BPfHootBGwPRYT1RUt6oGWsaQr8UyZW/eAm9bKoijtvruSDEmZHm92CwS9nj7/fWttqPCgzep8CA==",
1798
+ "license": "MIT",
1799
+ "dependencies": {
1800
+ "es-errors": "^1.3.0",
1801
+ "is-core-module": "^2.16.1",
1802
+ "path-parse": "^1.0.7",
1803
+ "supports-preserve-symlinks-flag": "^1.0.0"
1804
+ },
1805
+ "bin": {
1806
+ "resolve": "bin/resolve"
1807
+ },
1808
+ "engines": {
1809
+ "node": ">= 0.4"
1810
+ },
1811
+ "funding": {
1812
+ "url": "https://github.com/sponsors/ljharb"
1813
+ }
1814
+ },
1815
+ "node_modules/resolve-from": {
1816
+ "version": "4.0.0",
1817
+ "resolved": "https://registry.npmjs.org/resolve-from/-/resolve-from-4.0.0.tgz",
1818
+ "integrity": "sha512-pb/MYmXstAkysRFx8piNI1tGFNQIFA3vkE3Gq4EuA1dF6gHp/+vgZqsCGJapvy8N3Q+4o7FwvquPJcnZ7RYy4g==",
1819
+ "license": "MIT",
1820
+ "engines": {
1821
+ "node": ">=4"
1822
+ }
1823
+ },
1824
+ "node_modules/rolldown": {
1825
+ "version": "1.2.5",
1826
+ "resolved": "https://registry.npmjs.org/rolldown/-/rolldown-1.2.5.tgz",
1827
+ "integrity": "sha512-VD2IE5PUG4Oj8zz2VGykiYd5wbnjdIiSsNQb8Qu5B+noEp+A78mu2iVvpp27g8es14Tk9rofNs5Tku9iQCS4fA==",
1828
+ "dev": true,
1829
+ "license": "MIT",
1830
+ "dependencies": {
1831
+ "@oxc-project/types": "=0.146.0",
1832
+ "@rolldown/pluginutils": "^1.0.0"
1833
+ },
1834
+ "bin": {
1835
+ "rolldown": "bin/cli.mjs"
1836
+ },
1837
+ "engines": {
1838
+ "node": "^20.19.0 || >=22.12.0"
1839
+ },
1840
+ "optionalDependencies": {
1841
+ "@rolldown/binding-android-arm-eabi": "1.2.5",
1842
+ "@rolldown/binding-android-arm64": "1.2.5",
1843
+ "@rolldown/binding-darwin-arm64": "1.2.5",
1844
+ "@rolldown/binding-darwin-x64": "1.2.5",
1845
+ "@rolldown/binding-freebsd-x64": "1.2.5",
1846
+ "@rolldown/binding-linux-arm-gnueabihf": "1.2.5",
1847
+ "@rolldown/binding-linux-arm64-gnu": "1.2.5",
1848
+ "@rolldown/binding-linux-arm64-musl": "1.2.5",
1849
+ "@rolldown/binding-linux-ppc64-gnu": "1.2.5",
1850
+ "@rolldown/binding-linux-s390x-gnu": "1.2.5",
1851
+ "@rolldown/binding-linux-x64-gnu": "1.2.5",
1852
+ "@rolldown/binding-linux-x64-musl": "1.2.5",
1853
+ "@rolldown/binding-openharmony-arm64": "1.2.5",
1854
+ "@rolldown/binding-win32-arm64-msvc": "1.2.5",
1855
+ "@rolldown/binding-win32-x64-msvc": "1.2.5"
1856
+ }
1857
+ },
1858
+ "node_modules/scheduler": {
1859
+ "version": "0.27.0",
1860
+ "resolved": "https://registry.npmjs.org/scheduler/-/scheduler-0.27.0.tgz",
1861
+ "integrity": "sha512-eNv+WrVbKu1f3vbYJT/xtiF5syA5HPIMtf9IgY/nKg0sWqzAUEvqY/xm7OcZc/qafLx/iO9FgOmeSAp4v5ti/Q==",
1862
+ "license": "MIT"
1863
+ },
1864
+ "node_modules/source-map": {
1865
+ "version": "0.5.7",
1866
+ "resolved": "https://registry.npmjs.org/source-map/-/source-map-0.5.7.tgz",
1867
+ "integrity": "sha512-LbrmJOMUSdEVxIKvdcJzQC+nQhe8FUZQTXQy6+I75skNgn3OoQ0DZA8YnFa7gp8tqtL3KPf1kmo0R5DoApeSGQ==",
1868
+ "license": "BSD-3-Clause",
1869
+ "engines": {
1870
+ "node": ">=0.10.0"
1871
+ }
1872
+ },
1873
+ "node_modules/source-map-js": {
1874
+ "version": "1.2.1",
1875
+ "resolved": "https://registry.npmjs.org/source-map-js/-/source-map-js-1.2.1.tgz",
1876
+ "integrity": "sha512-UXWMKhLOwVKb728IUtQPXxfYU+usdybtUrK/8uGE8CQMvrhOpwvzDBwj0QhSL7MQc7vIsISBG8VQ8+IDQxpfQA==",
1877
+ "dev": true,
1878
+ "license": "BSD-3-Clause",
1879
+ "engines": {
1880
+ "node": ">=0.10.0"
1881
+ }
1882
+ },
1883
+ "node_modules/stylis": {
1884
+ "version": "4.2.0",
1885
+ "resolved": "https://registry.npmjs.org/stylis/-/stylis-4.2.0.tgz",
1886
+ "integrity": "sha512-Orov6g6BB1sDfYgzWfTHDOxamtX1bE/zo104Dh9e6fqJ3PooipYyfJ0pUmrZO2wAvO8YbEyeFrkV91XTsGMSrw==",
1887
+ "license": "MIT"
1888
+ },
1889
+ "node_modules/supports-preserve-symlinks-flag": {
1890
+ "version": "1.0.0",
1891
+ "resolved": "https://registry.npmjs.org/supports-preserve-symlinks-flag/-/supports-preserve-symlinks-flag-1.0.0.tgz",
1892
+ "integrity": "sha512-ot0WnXS9fgdkgIcePe6RHNk1WA8+muPa6cSjeR3V8K27q9BB1rTE3R1p7Hv0z1ZyAc8s6Vvv8DIyWf681MAt0w==",
1893
+ "license": "MIT",
1894
+ "engines": {
1895
+ "node": ">= 0.4"
1896
+ },
1897
+ "funding": {
1898
+ "url": "https://github.com/sponsors/ljharb"
1899
+ }
1900
+ },
1901
+ "node_modules/suspend-react": {
1902
+ "version": "0.1.3",
1903
+ "resolved": "https://registry.npmjs.org/suspend-react/-/suspend-react-0.1.3.tgz",
1904
+ "integrity": "sha512-aqldKgX9aZqpoDp3e8/BZ8Dm7x1pJl+qI3ZKxDN0i/IQTWUwBx/ManmlVJ3wowqbno6c2bmiIfs+Um6LbsjJyQ==",
1905
+ "license": "MIT",
1906
+ "peerDependencies": {
1907
+ "react": ">=17.0"
1908
+ }
1909
+ },
1910
+ "node_modules/three": {
1911
+ "version": "0.164.1",
1912
+ "resolved": "https://registry.npmjs.org/three/-/three-0.164.1.tgz",
1913
+ "integrity": "sha512-iC/hUBbl1vzFny7f5GtqzVXYjMJKaTPxiCxXfrvVdBi1Sf+jhd1CAkitiFwC7mIBFCo3MrDLJG97yisoaWig0w==",
1914
+ "license": "MIT",
1915
+ "peer": true
1916
+ },
1917
+ "node_modules/tinyglobby": {
1918
+ "version": "0.2.17",
1919
+ "resolved": "https://registry.npmjs.org/tinyglobby/-/tinyglobby-0.2.17.tgz",
1920
+ "integrity": "sha512-wXR/dYpcqKmfWpEdZjiKJOwCNFndD0DMnrW/cYjVGttEkBfVgcLFHoNrlj47mjOVic9yyNu65alsgF4NQyTa2g==",
1921
+ "dev": true,
1922
+ "license": "MIT",
1923
+ "dependencies": {
1924
+ "fdir": "^6.5.0",
1925
+ "picomatch": "^4.0.4"
1926
+ },
1927
+ "engines": {
1928
+ "node": ">=12.0.0"
1929
+ },
1930
+ "funding": {
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",
1937
+ "integrity": "sha512-Pp6GSwGP/NrPIrxVFAIkOQeyw8lFenOHijQWkUTrDvrF4ALqylP2C/KCkeS9dpUM3KvYRQhna5vt7IL95+ZQ9w==",
1938
+ "license": "MIT",
1939
+ "peerDependencies": {
1940
+ "react": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0"
1941
+ }
1942
+ },
1943
+ "node_modules/vite": {
1944
+ "version": "8.2.2",
1945
+ "resolved": "https://registry.npmjs.org/vite/-/vite-8.2.2.tgz",
1946
+ "integrity": "sha512-cFKLV/PRgAUlIRm5WjMjJ86jrftzpqcgH+Us+DS8mI3CDNiH30Whrz8uHL3+MOLPAgqbMBAqWdAHAphOAM+z/Q==",
1947
+ "dev": true,
1948
+ "license": "MIT",
1949
+ "peer": true,
1950
+ "dependencies": {
1951
+ "lightningcss": "^1.33.0",
1952
+ "picomatch": "^4.0.5",
1953
+ "postcss": "^8.5.26",
1954
+ "rolldown": "~1.2.4",
1955
+ "tinyglobby": "^0.2.17"
1956
+ },
1957
+ "bin": {
1958
+ "vite": "bin/vite.js"
1959
+ },
1960
+ "engines": {
1961
+ "node": "^20.19.0 || >=22.12.0"
1962
+ },
1963
+ "funding": {
1964
+ "url": "https://github.com/vitejs/vite?sponsor=1"
1965
+ },
1966
+ "optionalDependencies": {
1967
+ "fsevents": "~2.3.3"
1968
+ },
1969
+ "peerDependencies": {
1970
+ "@types/node": "^20.19.0 || >=22.12.0",
1971
+ "@vitejs/devtools": "^0.4.0 || ^0.5.0",
1972
+ "esbuild": "^0.27.0 || ^0.28.0",
1973
+ "jiti": ">=1.21.0",
1974
+ "less": "^4.0.0",
1975
+ "sass": "^1.70.0",
1976
+ "sass-embedded": "^1.70.0",
1977
+ "stylus": ">=0.54.8",
1978
+ "sugarss": "^5.0.0",
1979
+ "terser": "^5.16.0",
1980
+ "tsx": "^4.8.1",
1981
+ "yaml": "^2.4.2"
1982
+ },
1983
+ "peerDependenciesMeta": {
1984
+ "@types/node": {
1985
+ "optional": true
1986
+ },
1987
+ "@vitejs/devtools": {
1988
+ "optional": true
1989
+ },
1990
+ "esbuild": {
1991
+ "optional": true
1992
+ },
1993
+ "jiti": {
1994
+ "optional": true
1995
+ },
1996
+ "less": {
1997
+ "optional": true
1998
+ },
1999
+ "sass": {
2000
+ "optional": true
2001
+ },
2002
+ "sass-embedded": {
2003
+ "optional": true
2004
+ },
2005
+ "stylus": {
2006
+ "optional": true
2007
+ },
2008
+ "sugarss": {
2009
+ "optional": true
2010
+ },
2011
+ "terser": {
2012
+ "optional": true
2013
+ },
2014
+ "tsx": {
2015
+ "optional": true
2016
+ },
2017
+ "yaml": {
2018
+ "optional": true
2019
+ }
2020
+ }
2021
+ },
2022
+ "node_modules/zustand": {
2023
+ "version": "5.0.15",
2024
+ "resolved": "https://registry.npmjs.org/zustand/-/zustand-5.0.15.tgz",
2025
+ "integrity": "sha512-MpSEjRiBkA9crSYeOUH32rJC7SVqAbm0Fqcqge/bUi2PPoLcBWKOsG+C8mevmpr8TwXHBVkChbbJiyvkE+i/3A==",
2026
+ "license": "MIT",
2027
+ "engines": {
2028
+ "node": ">=12.20.0"
2029
+ },
2030
+ "peerDependencies": {
2031
+ "@types/react": ">=18.0.0",
2032
+ "immer": ">=9.0.6",
2033
+ "react": ">=18.0.0",
2034
+ "use-sync-external-store": ">=1.2.0"
2035
+ },
2036
+ "peerDependenciesMeta": {
2037
+ "@types/react": {
2038
+ "optional": true
2039
+ },
2040
+ "immer": {
2041
+ "optional": true
2042
+ },
2043
+ "react": {
2044
+ "optional": true
2045
+ },
2046
+ "use-sync-external-store": {
2047
+ "optional": true
2048
+ }
2049
+ }
2050
+ }
2051
+ }
2052
+ }
app/package.json ADDED
@@ -0,0 +1,25 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ {
2
+ "name": "microduck-sandbox",
3
+ "private": true,
4
+ "version": "1.0.0",
5
+ "type": "module",
6
+ "scripts": {
7
+ "dev": "vite",
8
+ "build": "vite build",
9
+ "preview": "vite preview"
10
+ },
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": {
22
+ "@vitejs/plugin-react": "^6.1.0",
23
+ "vite": "^8.2.2"
24
+ }
25
+ }
app/public/assets ADDED
@@ -0,0 +1 @@
 
 
1
+ ../../assets
app/public/policies ADDED
@@ -0,0 +1 @@
 
 
1
+ ../../policies
app/public/robot ADDED
@@ -0,0 +1 @@
 
 
1
+ ../../robot
app/src/App.jsx ADDED
@@ -0,0 +1,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), 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";
8
+ 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(() => {
18
+ const params = new URLSearchParams(location.search);
19
+
20
+ // Gamepad presence for the tutorial variant, before the game controller
21
+ // takes over polling (the game loop keeps mirroring it afterwards).
22
+ const pollPad = () => {
23
+ const gp = [...(navigator.getGamepads?.() ?? [])].find((p) => p && p.connected);
24
+ useGame.setState({ padConnected: !!gp });
25
+ };
26
+ window.addEventListener("gamepadconnected", pollPad);
27
+ window.addEventListener("gamepaddisconnected", pollPad);
28
+ pollPad();
29
+
30
+ // Touch detection, latching: (pointer: coarse) OR any real touch proves
31
+ // the device (DevTools device modes and hybrid laptops often fail the
32
+ // media query). __microduckTouched hands the proof to controls/touch.js
33
+ // which boots later. ?touch=1 forces the thumbs on any device.
34
+ const coarse = matchMedia("(pointer: coarse)");
35
+ const armTouch = () => {
36
+ window.__microduckTouched = true;
37
+ useGame.setState({ touchMode: true });
38
+ };
39
+ const onCoarse = () => coarse.matches && armTouch();
40
+ if (coarse.matches) armTouch();
41
+ else coarse.addEventListener("change", onCoarse);
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 () => {
68
+ window.removeEventListener("gamepadconnected", pollPad);
69
+ window.removeEventListener("gamepaddisconnected", pollPad);
70
+ coarse.removeEventListener("change", onCoarse);
71
+ window.removeEventListener("touchstart", armTouch);
72
+ };
73
+ }, []);
74
+
75
+ return (
76
+ <>
77
+ <GameCanvas />
78
+ <Halftone />
79
+ <CrtOverlay />
80
+ <Hud />
81
+ <TouchOverlay />
82
+ <BiosOverlay />
83
+ <TitleMenu />
84
+ <Preboot />
85
+ </>
86
+ );
87
+ }
app/src/game/arena.js ADDED
@@ -0,0 +1,235 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ // Arena visuals: the infinite Tron grid floor and the four wall grid
2
+ // planes, ported verbatim from the pre-React rl.js. Both carry a uReveal
3
+ // uniform for the entrance draw-in (driven by ceremony.js).
4
+ import * as THREE from "three";
5
+ import { ARENA_HALF, ARENA_WALL_H, ARENA_WALL_T, GRID_SECTION } from "./constants.js";
6
+
7
+ // Infinite shader grid, ported from drei's <Grid>: anti-aliased world-space
8
+ // lines at cell/section frequencies with a radial fade around the duck.
9
+ // Lines derive from world coordinates, so re-centering the mesh under the
10
+ // camera target every frame makes the grid effectively infinite without any
11
+ // visible swimming.
12
+ export function makeInfiniteGrid() {
13
+ const material = new THREE.ShaderMaterial({
14
+ transparent: true,
15
+ depthWrite: false,
16
+ uniforms: {
17
+ uCell: { value: 0.1 },
18
+ uSection: { value: GRID_SECTION },
19
+ uCellColor: { value: new THREE.Color(0x8e8371) },
20
+ uSectionColor: { value: new THREE.Color(0xffb366) },
21
+ uFadeDist: { value: 3.0 },
22
+ uFocus: { value: new THREE.Vector3() },
23
+ // Entrance draw-in progress; 1 = steady state (branch skipped).
24
+ // Starts at 0: the world stays hidden behind the welcome modal and
25
+ // the BIOS readout until playBios cues startEntrance.
26
+ uReveal: { value: 0.0 },
27
+ },
28
+ vertexShader: /* glsl */ `
29
+ varying vec3 vWorld;
30
+ void main() {
31
+ vec4 w = modelMatrix * vec4(position, 1.0);
32
+ vWorld = w.xyz;
33
+ gl_Position = projectionMatrix * viewMatrix * w;
34
+ }
35
+ `,
36
+ fragmentShader: /* glsl */ `
37
+ varying vec3 vWorld;
38
+ uniform float uCell, uSection, uFadeDist, uReveal;
39
+ uniform vec3 uCellColor, uSectionColor, uFocus;
40
+ // Tron-style line: a thicker antialiased core plus a faint, much
41
+ // wider halo added on top (squared falloff keeps it a whisper of a
42
+ // glow rather than a bloom wash).
43
+ float lineProf(float g) {
44
+ float core = 1.0 - smoothstep(0.0, 1.8, g);
45
+ float halo = 1.0 - smoothstep(0.0, 7.0, g);
46
+ return core + halo * halo * 0.22;
47
+ }
48
+ float gridLine(vec2 p, float size) {
49
+ vec2 r = p / size;
50
+ vec2 g = abs(fract(r - 0.5) - 0.5) / fwidth(r);
51
+ return lineProf(min(g.x, g.y));
52
+ }
53
+ // Entrance draw-in: one family of parallel lines, drawn line by line.
54
+ // id picks the line, "along" runs down its length. Each line waits
55
+ // out its own hashed delay, then extends from the origin outward with
56
+ // a hard front. Returns (mask, head): head marks the bright segment
57
+ // right behind the draw front while the line is still growing.
58
+ vec2 drawLine(float id, float along, float t0, float spread, float dur, float maxLen) {
59
+ float jit = fract(sin(id * 127.1) * 43758.5453);
60
+ float grow = clamp((uReveal - t0 - jit * spread) / dur, 0.0, 1.0);
61
+ float len = grow * maxLen;
62
+ float a = abs(along);
63
+ float mask = 1.0 - smoothstep(len - 0.05, len, a);
64
+ float head = (1.0 - smoothstep(0.0, 0.6, len - a)) * mask
65
+ * step(0.001, grow) * (1.0 - step(0.999, grow));
66
+ return vec2(mask, head);
67
+ }
68
+ void main() {
69
+ float cell = gridLine(vWorld.xz, uCell);
70
+ // Section lattice shifted half a cell: with 5 sections across the
71
+ // 3 m arena (odd count) this centers a CELL on the origin and puts
72
+ // section lines exactly on the walls at +-1.5.
73
+ vec2 pSec = vWorld.xz + 0.5 * uSection;
74
+ float section = gridLine(pSec, uSection);
75
+ float d = distance(vWorld.xz, uFocus.xz);
76
+ float fade = pow(clamp(1.0 - d / uFadeDist, 0.0, 1.0), 1.6);
77
+ vec3 col = mix(uCellColor, uSectionColor, clamp(section, 0.0, 1.0));
78
+ float alpha = min(max(section * 0.6, cell * 0.4) * fade, 1.0);
79
+ // Entrance: only the bright section lines get the line-by-line draw
80
+ // (staggered, with a hot draw head); the fine cells just fade in
81
+ // over the reveal's second half - drawing every small line reads as
82
+ // visual noise. lineProf(min(gx, gy)) == max of per-axis profiles
83
+ // (profile is monotonic) and the cell fade lands on exactly the
84
+ // steady-state cell term, so at uReveal 1 this branch equals the
85
+ // formula above exactly (and is skipped).
86
+ if (uReveal < 1.0) {
87
+ vec2 rs = pSec / uSection;
88
+ vec2 gs = abs(fract(rs - 0.5) - 0.5) / fwidth(rs);
89
+ // Const-x lines run along z and vice versa; the offset
90
+ // decorrelates the two families' hashed delays.
91
+ vec2 sx = drawLine(floor(rs.x + 0.5), vWorld.z, 0.00, 0.30, 0.35, 8.0);
92
+ vec2 sz = drawLine(floor(rs.y + 0.5) + 57.0, vWorld.x, 0.05, 0.30, 0.35, 8.0);
93
+ float secR = max(lineProf(gs.x) * sx.x, lineProf(gs.y) * sz.x);
94
+ float cellR = cell * smoothstep(0.5, 1.0, uReveal);
95
+ float headGlow = max(lineProf(gs.x) * sx.y, lineProf(gs.y) * sz.y);
96
+ col = mix(uCellColor, uSectionColor, clamp(secR, 0.0, 1.0));
97
+ alpha = min(max(secR * 0.6, cellR * 0.4) * fade, 1.0);
98
+ // Bright draw head: a short white-hot tip sells the "drawing" read.
99
+ headGlow = clamp(headGlow, 0.0, 1.0);
100
+ col = mix(col, vec3(1.0, 0.86, 0.55), headGlow * 0.8);
101
+ alpha = min(alpha + headGlow * fade * 0.5, 1.0);
102
+ }
103
+ if (alpha < 0.004) discard;
104
+ gl_FragColor = vec4(col, alpha);
105
+ }
106
+ `,
107
+ });
108
+ const mesh = new THREE.Mesh(new THREE.PlaneGeometry(30, 30), material);
109
+ mesh.rotation.x = -Math.PI / 2;
110
+ return mesh;
111
+ }
112
+
113
+ // Arena walls, drawn in the same grid language as the floor: identical
114
+ // cell/section lines from world coordinates, same radial fade around the
115
+ // duck, plus a vertical fade toward the top edge so the walls read as a
116
+ // light enclosure instead of solid slabs.
117
+ function makeWallGridMaterial(alongX) {
118
+ return new THREE.ShaderMaterial({
119
+ transparent: true,
120
+ depthWrite: false,
121
+ side: THREE.DoubleSide,
122
+ uniforms: {
123
+ uCell: { value: 0.1 },
124
+ uSection: { value: GRID_SECTION },
125
+ uCellColor: { value: new THREE.Color(0x8e8371) },
126
+ uSectionColor: { value: new THREE.Color(0xffb366) },
127
+ // Gentler radial fade than the floor: the walls sit 1.5+ m from the
128
+ // duck by construction and would vanish with the floor's 3 m fade.
129
+ uFadeDist: { value: 5.0 },
130
+ uFocus: { value: new THREE.Vector3() },
131
+ uWallH: { value: ARENA_WALL_H },
132
+ uAlongX: { value: alongX ? 1.0 : 0.0 },
133
+ // Entrance draw-in progress; 1 = steady state (branch skipped).
134
+ // Starts at 0, same as the floor grid: hidden until startEntrance.
135
+ uReveal: { value: 0.0 },
136
+ },
137
+ vertexShader: /* glsl */ `
138
+ varying vec3 vWorld;
139
+ void main() {
140
+ vec4 w = modelMatrix * vec4(position, 1.0);
141
+ vWorld = w.xyz;
142
+ gl_Position = projectionMatrix * viewMatrix * w;
143
+ }
144
+ `,
145
+ fragmentShader: /* glsl */ `
146
+ varying vec3 vWorld;
147
+ uniform float uCell, uSection, uFadeDist, uWallH, uAlongX, uReveal;
148
+ uniform vec3 uCellColor, uSectionColor, uFocus;
149
+ // Same Tron-style core + faint halo as the floor grid.
150
+ float lineProf(float g) {
151
+ float core = 1.0 - smoothstep(0.0, 1.8, g);
152
+ float halo = 1.0 - smoothstep(0.0, 7.0, g);
153
+ return core + halo * halo * 0.22;
154
+ }
155
+ float gridLine(vec2 p, float size) {
156
+ vec2 r = p / size;
157
+ vec2 g = abs(fract(r - 0.5) - 0.5) / fwidth(r);
158
+ return lineProf(min(g.x, g.y));
159
+ }
160
+ // Same line-by-line draw as the floor grid (see its comments).
161
+ vec2 drawLine(float id, float along, float t0, float spread, float dur, float maxLen) {
162
+ float jit = fract(sin(id * 127.1) * 43758.5453);
163
+ float grow = clamp((uReveal - t0 - jit * spread) / dur, 0.0, 1.0);
164
+ float len = grow * maxLen;
165
+ float a = abs(along);
166
+ float mask = 1.0 - smoothstep(len - 0.05, len, a);
167
+ float head = (1.0 - smoothstep(0.0, 0.35, len - a)) * mask
168
+ * step(0.001, grow) * (1.0 - step(0.999, grow));
169
+ return vec2(mask, head);
170
+ }
171
+ void main() {
172
+ // Wall surface coords: the in-plane horizontal world axis + height.
173
+ float h = mix(vWorld.z, vWorld.x, uAlongX);
174
+ vec2 p = vec2(h, vWorld.y);
175
+ float cell = gridLine(p, uCell);
176
+ // Horizontal axis shifted half a section to match the floor's odd
177
+ // lattice (vertical section lines meet the floor's at the base);
178
+ // the height axis keeps its base line at y = 0.
179
+ vec2 pSec = vec2(p.x + 0.5 * uSection, p.y);
180
+ float section = gridLine(pSec, uSection);
181
+ float d = distance(vWorld.xz, uFocus.xz);
182
+ float fade = pow(clamp(1.0 - d / uFadeDist, 0.0, 1.0), 1.6);
183
+ float vert = 1.0 - clamp(vWorld.y / uWallH, 0.0, 1.0);
184
+ vec3 col = mix(uCellColor, uSectionColor, clamp(section, 0.0, 1.0));
185
+ float alpha = min(max(section * 0.9, cell * 0.6) * fade * (0.3 + 0.7 * vert), 1.0);
186
+ // Entrance: section lines only - horizontals zip out from the
187
+ // wall's center, verticals rise from the ground, each with a
188
+ // hashed delay; the fine cells fade in over the reveal's second
189
+ // half. Same steady-state equivalence argument as the floor grid.
190
+ if (uReveal < 1.0) {
191
+ vec2 rs = pSec / uSection;
192
+ vec2 gs = abs(fract(rs - 0.5) - 0.5) / fwidth(rs);
193
+ // Const-height lines run along h (grow from center outward);
194
+ // const-h lines run along y (grow up from the ground).
195
+ vec2 sh = drawLine(floor(rs.y + 0.5), p.x, 0.00, 0.30, 0.40, 2.0);
196
+ vec2 sv = drawLine(floor(rs.x + 0.5) + 31.0, p.y, 0.30, 0.30, 0.30, uWallH);
197
+ float secR = max(lineProf(gs.y) * sh.x, lineProf(gs.x) * sv.x);
198
+ float cellR = cell * smoothstep(0.5, 1.0, uReveal);
199
+ float headGlow = max(lineProf(gs.y) * sh.y, lineProf(gs.x) * sv.y);
200
+ col = mix(uCellColor, uSectionColor, clamp(secR, 0.0, 1.0));
201
+ alpha = min(max(secR * 0.9, cellR * 0.6) * fade * (0.3 + 0.7 * vert), 1.0);
202
+ headGlow = clamp(headGlow, 0.0, 1.0);
203
+ col = mix(col, vec3(1.0, 0.86, 0.55), headGlow * 0.8);
204
+ alpha = min(alpha + headGlow * fade * 0.5, 1.0);
205
+ }
206
+ if (alpha < 0.004) discard;
207
+ gl_FragColor = vec4(col, alpha);
208
+ }
209
+ `,
210
+ });
211
+ }
212
+
213
+ // The four wall planes at their inner faces (three coords: MJCF x -> x,
214
+ // MJCF y -> -z). Returns the meshes plus their materials (the ceremony and
215
+ // the per-frame focus update both need the material list).
216
+ export function makeArenaWalls() {
217
+ const wallMats = [];
218
+ const wallMeshes = [];
219
+ const wallLen = 2 * (ARENA_HALF + ARENA_WALL_T);
220
+ const wallDefs = [
221
+ { x: ARENA_HALF, z: 0, rotY: -Math.PI / 2, alongX: false },
222
+ { x: -ARENA_HALF, z: 0, rotY: Math.PI / 2, alongX: false },
223
+ { x: 0, z: ARENA_HALF, rotY: Math.PI, alongX: true },
224
+ { x: 0, z: -ARENA_HALF, rotY: 0, alongX: true },
225
+ ];
226
+ for (const w of wallDefs) {
227
+ const mat = makeWallGridMaterial(w.alongX);
228
+ wallMats.push(mat);
229
+ const mesh = new THREE.Mesh(new THREE.PlaneGeometry(wallLen, ARENA_WALL_H), mat);
230
+ mesh.position.set(w.x, ARENA_WALL_H / 2, w.z);
231
+ mesh.rotation.y = w.rotY;
232
+ wallMeshes.push(mesh);
233
+ }
234
+ return { wallMats, wallMeshes };
235
+ }
app/src/game/ball-actor.js ADDED
@@ -0,0 +1,102 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ // Ball actor: physics stays in rl.js, this owns the mesh + wireframe FX.
2
+ //
3
+ // visual:
4
+ // hidden parked, mesh off
5
+ // live following qpos, optional appear scan playing
6
+ // ghost physics already parked; mesh frozen at last pose while the
7
+ // reverse scan peels it away
8
+ //
9
+ // spawn while live/ghost queues a pop: despawn finishes, then the caller
10
+ // places physics and appear()s.
11
+
12
+ // Appear/disappear speed. The shared wireframe FX timeline lasts
13
+ // fx.TOTAL_S (0.9 s, tuned for the duck's ceremony); the ball plays the
14
+ // same timeline time-scaled to these snappier durations.
15
+ const BALL_APPEAR_S = 0.35; // spawn pop-in (scan up)
16
+ const BALL_DISAPPEAR_S = 0.25; // despawn/park peel-away (reverse scan)
17
+
18
+ export function createBallActor({ THREE, scene, camera, renderer, fxModule, mesh, group }) {
19
+ const fx = fxModule.createWireframeFx();
20
+ fx.init({ THREE, scene, root: group, camera, renderer, hidden: false });
21
+
22
+ let visual = "hidden"; // hidden | live | ghost
23
+ let ghost = null;
24
+ let pendingSpawn = false;
25
+ let fxPrev = null;
26
+
27
+ function appear() {
28
+ visual = "live";
29
+ ghost = null;
30
+ mesh.visible = true;
31
+ fx.start();
32
+ fxPrev = performance.now();
33
+ }
34
+
35
+ function despawn({ cancelQueued = false, parkPhysics }) {
36
+ if (cancelQueued) pendingSpawn = false;
37
+ if (visual === "hidden") return;
38
+ if (visual === "live") {
39
+ ghost = {
40
+ pos: mesh.position.clone(),
41
+ quat: mesh.quaternion.clone(),
42
+ };
43
+ visual = "ghost";
44
+ }
45
+ parkPhysics();
46
+ fx.startReverse();
47
+ fxPrev = performance.now();
48
+ }
49
+
50
+ function queueRespawn() {
51
+ pendingSpawn = true;
52
+ }
53
+
54
+ function poseFromQpos(qpos, adr) {
55
+ mesh.position.set(qpos[adr], qpos[adr + 1], qpos[adr + 2]);
56
+ mesh.quaternion.set(qpos[adr + 4], qpos[adr + 5], qpos[adr + 6], qpos[adr + 3]);
57
+ }
58
+
59
+ function sync(qpos, adr, physicsLive) {
60
+ if (visual === "ghost" && ghost) {
61
+ mesh.visible = true;
62
+ mesh.position.copy(ghost.pos);
63
+ mesh.quaternion.copy(ghost.quat);
64
+ } else if (physicsLive) {
65
+ mesh.visible = true;
66
+ poseFromQpos(qpos, adr);
67
+ } else {
68
+ mesh.visible = false;
69
+ }
70
+ }
71
+
72
+ function drive(onQueuedSpawn) {
73
+ if (fx.playing) {
74
+ const now = performance.now();
75
+ if (fxPrev === null) fxPrev = now;
76
+ const dt = Math.min((now - fxPrev) / 1000, 0.25);
77
+ fxPrev = now;
78
+ // Time-scale the FX so the ball pops in/out at its own speed.
79
+ const speed = fx.TOTAL_S / (fx.reversing ? BALL_DISAPPEAR_S : BALL_APPEAR_S);
80
+ fx.update(dt * speed);
81
+ }
82
+ if (visual === "ghost" && fx.isDone()) {
83
+ mesh.visible = false;
84
+ visual = "hidden";
85
+ ghost = null;
86
+ fx.restore();
87
+ const queued = pendingSpawn;
88
+ pendingSpawn = false;
89
+ if (queued) onQueuedSpawn();
90
+ }
91
+ }
92
+
93
+ return {
94
+ appear,
95
+ despawn,
96
+ queueRespawn,
97
+ poseFromQpos,
98
+ sync,
99
+ drive,
100
+ get visual() { return visual; },
101
+ };
102
+ }
app/src/game/ball-visual.js ADDED
@@ -0,0 +1,149 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ // Ball visuals: procedural soccer-ball texture + the render mesh, ported
2
+ // verbatim from the pre-React rl.js.
3
+ import * as THREE from "three";
4
+ import { BALL_RADIUS } from "./constants.js";
5
+
6
+ // Soccer-ball look computed per pixel on the sphere itself, so there is
7
+ // no pole or seam special case by design. The truncated icosahedron is
8
+ // reconstructed as a spherical Voronoi diagram over 32 sites: the 12
9
+ // icosahedron vertices (black pentagon centers, one sitting at each
10
+ // pole) and its 20 face centers (white hexagon centers). A pixel is
11
+ // black when its nearest site is a pentagon center and it sits clear of
12
+ // the cell boundary by a seam margin - which yields big flat-edged black
13
+ // pentagons separated from the white hexagons by thin seams, corners
14
+ // almost touching, exactly like the real panel layout.
15
+ function makeSoccerBallTexture(renderer) {
16
+ const W = 1024, H = 512;
17
+ const c = document.createElement("canvas");
18
+ c.width = W;
19
+ c.height = H;
20
+ const ctx = c.getContext("2d");
21
+ // 12 icosahedron vertices: 2 poles + two staggered rings of 5 at
22
+ // latitude +-atan(1/2) (~26.57 deg) - the pentagon centers.
23
+ const sites = [];
24
+ const addSite = (v, isPent) => {
25
+ const n = Math.hypot(v[0], v[1], v[2]);
26
+ sites.push({ x: v[0] / n, y: v[1] / n, z: v[2] / n, pent: isPent });
27
+ };
28
+ const verts = [[0, 0, 1], [0, 0, -1]];
29
+ const latR = Math.atan(0.5), cr = Math.cos(latR), sr = Math.sin(latR);
30
+ for (let i = 0; i < 5; i++) {
31
+ const a = (i * 72 * Math.PI) / 180;
32
+ const b = ((i * 72 + 36) * Math.PI) / 180;
33
+ verts.push([cr * Math.cos(a), cr * Math.sin(a), sr]);
34
+ verts.push([cr * Math.cos(b), cr * Math.sin(b), -sr]);
35
+ }
36
+ for (const v of verts) addSite(v, true);
37
+ // 20 face centers (hexagon centers): normalized centroids of every
38
+ // mutually-adjacent vertex triple (adjacent pairs have dot = 1/sqrt(5)).
39
+ const adj = (a, b) => a[0] * b[0] + a[1] * b[1] + a[2] * b[2] > 0.3;
40
+ for (let i = 0; i < 12; i++) {
41
+ for (let j = i + 1; j < 12; j++) {
42
+ if (!adj(verts[i], verts[j])) continue;
43
+ for (let k = j + 1; k < 12; k++) {
44
+ if (adj(verts[i], verts[k]) && adj(verts[j], verts[k])) {
45
+ addSite([
46
+ verts[i][0] + verts[j][0] + verts[k][0],
47
+ verts[i][1] + verts[j][1] + verts[k][1],
48
+ verts[i][2] + verts[j][2] + verts[k][2],
49
+ ], false);
50
+ }
51
+ }
52
+ }
53
+ }
54
+ // Seam half-width and anti-alias band, in radians of arc.
55
+ const SEAM = (1.6 * Math.PI) / 180;
56
+ const AA = (0.35 * Math.PI) / 180;
57
+ // Groove reach for the bump map: a touch wider than the painted seam so
58
+ // the recess shoulders catch light on both sides of the line.
59
+ const GROOVE = SEAM * 1.5;
60
+ const BG = [233, 231, 224], INK = [23, 23, 29], STITCH = [200, 197, 188];
61
+ const img = ctx.createImageData(W, H);
62
+ const px = img.data;
63
+ // Height map sharing the same panel construction: seams become recessed
64
+ // grooves, plus a very fine leather/PVC grain over the whole surface.
65
+ const bc = document.createElement("canvas");
66
+ bc.width = W;
67
+ bc.height = H;
68
+ const bctx = bc.getContext("2d");
69
+ const bimg = bctx.createImageData(W, H);
70
+ const bpx = bimg.data;
71
+ for (let row = 0; row < H; row++) {
72
+ const lat = Math.PI / 2 - ((row + 0.5) / H) * Math.PI;
73
+ const cl = Math.cos(lat), sl = Math.sin(lat);
74
+ for (let col = 0; col < W; col++) {
75
+ const lon = ((col + 0.5) / W) * 2 * Math.PI - Math.PI;
76
+ const dx = cl * Math.cos(lon), dy = cl * Math.sin(lon), dz = sl;
77
+ let best = -2, second = -2, bestPent = false;
78
+ for (const s of sites) {
79
+ const d = dx * s.x + dy * s.y + dz * s.z;
80
+ if (d > best) { second = best; best = d; bestPent = s.pent; }
81
+ else if (d > second) second = d;
82
+ }
83
+ // Signed distance to the Voronoi cell boundary along the geodesic.
84
+ const halfGap = (Math.acos(Math.min(1, second)) - Math.acos(Math.min(1, best))) / 2;
85
+ // Black panel: inside a pentagon cell, clear of the seam margin.
86
+ const black = bestPent ? Math.min(1, Math.max(0, (halfGap - SEAM) / AA)) : 0;
87
+ // Subtle stitch line on every remaining cell boundary so the white
88
+ // hexagons read as panels too.
89
+ const stitch = Math.min(1, Math.max(0, 1 - halfGap / (SEAM * 0.6))) * (1 - black);
90
+ const o = (row * W + col) * 4;
91
+ for (let ch = 0; ch < 3; ch++) {
92
+ const base = BG[ch] + (STITCH[ch] - BG[ch]) * stitch;
93
+ px[o + ch] = base + (INK[ch] - base) * black;
94
+ }
95
+ px[o + 3] = 255;
96
+ // Bump: quadratic groove profile (soft shoulders, no golf-ball
97
+ // embossing) + grain noise.
98
+ const groove = Math.max(0, 1 - halfGap / GROOVE) ** 2;
99
+ const hgt = 205 - groove * 115 + (Math.random() - 0.5) * 14;
100
+ const h8 = Math.max(0, Math.min(255, hgt));
101
+ bpx[o] = h8; bpx[o + 1] = h8; bpx[o + 2] = h8;
102
+ bpx[o + 3] = 255;
103
+ }
104
+ }
105
+ ctx.putImageData(img, 0, 0);
106
+ bctx.putImageData(bimg, 0, 0);
107
+ const finish = (canvas, srgb) => {
108
+ const tex = new THREE.CanvasTexture(canvas);
109
+ // The bump map stays linear; only the color map is sRGB.
110
+ if (srgb) tex.colorSpace = THREE.SRGBColorSpace;
111
+ // Texel footprints get extremely anamorphic near the UV poles; without
112
+ // anisotropy the cap edge visibly scallops at close range.
113
+ tex.anisotropy = renderer.capabilities.getMaxAnisotropy();
114
+ return tex;
115
+ };
116
+ return { map: finish(c, true), bumpMap: finish(bc, false) };
117
+ }
118
+
119
+ // Same Z-up -> Y-up trick as the duck rig: the group takes the axis fix,
120
+ // the mesh inside takes the raw MJCF free-joint pose.
121
+ export function createBallVisual(renderer) {
122
+ const group = new THREE.Group();
123
+ group.rotation.x = -Math.PI / 2;
124
+ const tex = makeSoccerBallTexture(renderer);
125
+ const mesh = new THREE.Mesh(
126
+ // 48x32 segments: the coarser default makes the UV interpolation near
127
+ // the poles visibly scallop the round cap edge of the texture.
128
+ new THREE.SphereGeometry(BALL_RADIUS, 48, 32),
129
+ // Physical material for the waxed vintage-leather look: matte-ish base
130
+ // with a whisper of clearcoat so highlights ride the seam grooves.
131
+ new THREE.MeshPhysicalMaterial({
132
+ map: tex.map,
133
+ bumpMap: tex.bumpMap,
134
+ bumpScale: 0.0012,
135
+ metalness: 0,
136
+ roughness: 0.55,
137
+ clearcoat: 0.2,
138
+ clearcoatRoughness: 0.35,
139
+ }),
140
+ );
141
+ mesh.userData.meshName = "ball";
142
+ // The 48x32 render sphere is far too dense for the wireframe scan (it
143
+ // reads as a solid glowing blob); the FX overlay uses this geodesic
144
+ // stand-in instead - 80 triangles, clean hologram lines.
145
+ mesh.userData.fxWireGeometry = new THREE.IcosahedronGeometry(BALL_RADIUS, 1);
146
+ mesh.visible = false;
147
+ group.add(mesh);
148
+ return { group, mesh };
149
+ }
app/src/game/ceremony.js ADDED
@@ -0,0 +1,236 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ // Cutscene layer: page-load entrance and every later respawn.
2
+ //
3
+ // Physics (resetSim) snaps the duck to the stand keyframe; this module
4
+ // owns what the player sees around that snap. Two mutually exclusive
5
+ // timelines share the duck's wireframe FX:
6
+ //
7
+ // entrance (once) grid + walls draw in, then the duck scans up
8
+ // respawn (every kill / Space / loco switch)
9
+ // 0.00 duck clipped away, camera glides home from the kill view
10
+ // 0.80 camera almost settled -> duck scan-up cues
11
+ // 0.90 camera lands
12
+ // 1.70 scan done, inputs unlock
13
+ //
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
+
20
+ const ENTRANCE_GRID_S = 1.1;
21
+ const ENTRANCE_WALL_DELAY_S = 0.35;
22
+ const ENTRANCE_WALL_S = 0.9;
23
+ const ENTRANCE_FX_START_S = 0.6;
24
+
25
+ const clamp01 = (x) => Math.min(Math.max(x, 0), 1);
26
+
27
+ export function createCeremony({
28
+ THREE, scene, camera, renderer, fx,
29
+ getRig, grid, wallMats,
30
+ syncRig, startCameraReset,
31
+ setLocked, flashReset,
32
+ }) {
33
+ const ENTRANCE_TOTAL_S = Math.max(
34
+ ENTRANCE_GRID_S,
35
+ ENTRANCE_WALL_DELAY_S + ENTRANCE_WALL_S,
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
42
+ // own root. They cue whenever the duck's scan cues (entrance and every
43
+ // respawn), offset by their stagger, and drive on their OWN clock so a
44
+ // late-finishing prop never extends the input lock or the ceremony's
45
+ // completion checks.
46
+ const props = []; // { fx, delayS, at, started, prev }
47
+ let entranceT0 = null;
48
+ let entranceFxCued = false;
49
+ let entranceDone = false;
50
+ let respawn = null; // { t0, scanCued } | null
51
+ let entranceFinishedResolve;
52
+ const entranceFinished = new Promise((r) => { entranceFinishedResolve = r; });
53
+
54
+ function bind() {
55
+ const rig = getRig();
56
+ if (fxRig === rig) return;
57
+ fx.dispose();
58
+ fx.init({ THREE, scene, rig, camera, renderer });
59
+ rig.placer.traverse((o) => {
60
+ if (o.isMesh && !o.userData.meshName) o.userData.fxOverlay = true;
61
+ });
62
+ fxRig = rig;
63
+ }
64
+
65
+ function fxStart() {
66
+ bind();
67
+ fx.start();
68
+ fxPrev = performance.now();
69
+ propsCue();
70
+ }
71
+ function propsCue() {
72
+ const now = performance.now();
73
+ for (const p of props) {
74
+ p.at = now + p.delayS * 1000;
75
+ p.started = false;
76
+ p.prev = null;
77
+ }
78
+ }
79
+ function propsHide() {
80
+ for (const p of props) {
81
+ p.fx.setProgress(0);
82
+ p.at = null;
83
+ p.started = false;
84
+ }
85
+ }
86
+ function propsFinish() {
87
+ for (const p of props) {
88
+ if (!p.started) p.fx.start();
89
+ p.fx.update(1e3);
90
+ p.at = null;
91
+ }
92
+ }
93
+ // Deterministic screenshot/test hook (setFx): park every prop at the
94
+ // same scan progress as the duck, detached from the timed driver.
95
+ function propsSetProgress(v) {
96
+ for (const p of props) {
97
+ p.fx.setProgress(v);
98
+ p.at = null;
99
+ p.started = false;
100
+ }
101
+ }
102
+ function driveProps() {
103
+ const now = performance.now();
104
+ for (const p of props) {
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;
111
+ }
112
+ if (p.fx.isDone()) continue;
113
+ const dt = Math.min((now - p.prev) / 1000, 0.25);
114
+ p.prev = now;
115
+ p.fx.update(dt);
116
+ }
117
+ }
118
+ function fxDrive() {
119
+ if (fxPrev === null || fx.isDone()) return;
120
+ const now = performance.now();
121
+ const dt = Math.min((now - fxPrev) / 1000, 0.25);
122
+ fxPrev = now;
123
+ fx.update(dt);
124
+ }
125
+ function fxForceFinish() {
126
+ if (!fx.isDone()) fx.update(1e3);
127
+ }
128
+
129
+ function startEntrance() {
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
+ }
136
+
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;
143
+ if (t >= ENTRANCE_FX_START_S && !entranceFxCued) {
144
+ entranceFxCued = true;
145
+ fxStart();
146
+ }
147
+ fxDrive();
148
+ if (t >= ENTRANCE_TOTAL_S) {
149
+ if (!fx.isDone() && t < ENTRANCE_TOTAL_S + 0.5) return;
150
+ fxForceFinish();
151
+ entranceT0 = null;
152
+ entranceDone = true;
153
+ grid.material.uniforms.uReveal.value = 1;
154
+ for (const m of wallMats) m.uniforms.uReveal.value = 1;
155
+ setLocked(false);
156
+ entranceFinishedResolve();
157
+ }
158
+ }
159
+
160
+ function playRespawn() {
161
+ if (!entranceDone) return;
162
+ setLocked(true);
163
+ flashReset();
164
+ bind();
165
+ startCameraReset();
166
+ syncRig();
167
+ fx.setProgress(0);
168
+ propsHide();
169
+ respawn = { t0: performance.now(), scanCued: false };
170
+ }
171
+
172
+ function driveRespawn() {
173
+ if (!respawn) return;
174
+ const u = (performance.now() - respawn.t0) / 1000 / CAM_RESET_S;
175
+ if (!respawn.scanCued && u >= RESPAWN_SCAN_AT) {
176
+ respawn.scanCued = true;
177
+ fxStart();
178
+ }
179
+ if (respawn.scanCued) fxDrive();
180
+ if (respawn.scanCued && fx.isDone()) {
181
+ respawn = null;
182
+ setLocked(false);
183
+ }
184
+ }
185
+
186
+ function drive() {
187
+ driveEntrance();
188
+ driveRespawn();
189
+ driveProps();
190
+ }
191
+
192
+ function setReveal(floor, wall = floor) {
193
+ entranceT0 = null;
194
+ grid.material.uniforms.uReveal.value = floor;
195
+ for (const m of wallMats) m.uniforms.uReveal.value = wall;
196
+ }
197
+
198
+ function setFx(p) {
199
+ entranceT0 = null;
200
+ respawn = null;
201
+ bind();
202
+ if (p >= 1) {
203
+ fx.start();
204
+ fxForceFinish();
205
+ propsFinish();
206
+ setLocked(false);
207
+ entranceFinishedResolve();
208
+ } else {
209
+ fx.setProgress(p);
210
+ propsSetProgress(p);
211
+ }
212
+ }
213
+
214
+ // Register a decorative prop's wireframe FX (already init'd on its own
215
+ // root, hidden). It materializes delayS after each duck scan cue.
216
+ function addPropFx(propFx, delayS = 0) {
217
+ props.push({ fx: propFx, delayS, at: null, started: false, prev: null });
218
+ }
219
+
220
+ // Boot hidden: clip parked below the feet from the first frame.
221
+ bind();
222
+
223
+ return {
224
+ CAM_RESET_S,
225
+ startEntrance,
226
+ playRespawn,
227
+ drive,
228
+ bind,
229
+ setReveal,
230
+ setFx,
231
+ addPropFx,
232
+ entranceFinished,
233
+ get entranceDone() { return entranceDone; },
234
+ get respawnActive() { return respawn !== null; },
235
+ };
236
+ }
app/src/game/constants.js ADDED
@@ -0,0 +1,74 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ // Shared sim constants, lifted verbatim from the pre-React rl.js.
2
+
3
+ export const POLICY_DIR = "./policies";
4
+ export const POLICIES = {
5
+ walk: `${POLICY_DIR}/BEST_alpha_walking.onnx`,
6
+ sitstand: `${POLICY_DIR}/BEST_alpha_sitstand.onnx`,
7
+ roll: `${POLICY_DIR}/roulade.onnx`,
8
+ // Blind one-shot kicks (the operator aims the robot, no ball in obs):
9
+ // the runtime swaps these in for a 0.5 s window, commands zeroed.
10
+ kickL: `${POLICY_DIR}/ball_kick_left.onnx`,
11
+ kickR: `${POLICY_DIR}/ball_kick_right.onnx`,
12
+ // Roller variant (lazy-loaded on first switch, never at boot):
13
+ // drive = velocity-tracking skating, crouch = one-shot crouch-glide
14
+ // driven by a phase encoding in the command slots (ground-pick style).
15
+ drive: `${POLICY_DIR}/BEST_roller.onnx`,
16
+ crouch: `${POLICY_DIR}/BEST_roller_crouch.onnx`,
17
+ };
18
+
19
+ // From the ONNX metadata (identical for all alpha policies) and the STAND
20
+ // keyframe in mjlab's scene_walk.xml. Order matches the actuators in
21
+ // the MJCF.
22
+ export const JOINT_NAMES = [
23
+ "left_hip_yaw", "left_hip_roll", "left_hip_pitch", "left_knee", "left_ankle",
24
+ "neck_pitch", "head_pitch", "head_yaw", "head_roll",
25
+ "right_hip_yaw", "right_hip_roll", "right_hip_pitch", "right_knee", "right_ankle",
26
+ ];
27
+ export const DEFAULT_POSE = new Float32Array([
28
+ 0, -0.08726646259971647, -0.457924, -0.004940, 0.452984,
29
+ 0.3490658503988659, 0.3490658503988659, 0, 0,
30
+ 0, 0.08726646259971647, 0.457924, 0.004940, -0.452984,
31
+ ]);
32
+ export const NUM_JOINTS = 14;
33
+ export const OBS_SIZE = 61;
34
+ export const CMD_SIZE = 13;
35
+ export const ACTION_SCALE = 1.0;
36
+ export const TIMESTEP = 0.005;
37
+ export const DECIMATION = 4;
38
+ export const CTRL_DT = TIMESTEP * DECIMATION; // 50 Hz
39
+
40
+ // Velocity command limits, same as infer_policy.py's keyboard mapping.
41
+ // No strafe input anymore: the lateral cmd slot stays zeroed for the obs.
42
+ export const VEL_FWD = 0.25, VEL_BACK = -0.2, VEL_ANG = 1.0;
43
+ // Roller mode limits, from the runtime's roller branch: asymmetric vx
44
+ // (0.6 push / 0.5 brake), no lateral. The real runtime launches rollers
45
+ // with --max-angular-vel 0.3: faster commanded turns tip the robot over,
46
+ // so the playground clamps wz the same way.
47
+ export const RVEL_FWD = 0.6, RVEL_BACK = -0.5, RVEL_ANG = 0.3;
48
+ // Crouch-glide one-shot: command = [cos(2pi*phase), sin(2pi*phase), 0],
49
+ // phase advancing at 1/CROUCH_PERIOD_S per second and the cycle exiting
50
+ // at 0.7 - exactly the runtime's ground-pick slot the policy was trained
51
+ // against (mjlab CROUCH_PERIOD = 5.0, cycle end 0.7 => 3.5 s gesture).
52
+ export const CROUCH_PERIOD_S = 5.0;
53
+ export const CROUCH_END_PHASE = 0.7;
54
+
55
+ // Kickable ball: radius and parking spot (far away = hidden by default).
56
+ export const BALL_RADIUS = 0.05;
57
+ export const BALL_PARK_POS = "50 0 0.05";
58
+
59
+ // Square arena boxing the play area: static walls at +-ARENA_HALF keep
60
+ // the ball (and the duck) inside. Tall enough that neither steps over.
61
+ export const ARENA_HALF = 1.5; // inner half-size, m
62
+ export const ARENA_WALL_H = 0.25;
63
+ export const ARENA_WALL_T = 0.05;
64
+ // Section grid: 5 cells across the 3 m arena (ODD, so a true middle
65
+ // column/row of cells exists; the lattice is shifted half a cell in the
66
+ // shaders so the walls land exactly on section lines).
67
+ export const GRID_SECTION = (2 * ARENA_HALF) / 5; // 0.6 m
68
+ // Spawn: center of the middle section cell in the SECOND ROW FROM THE
69
+ // BACK wall. The duck faces +X (identity freejoint quat, walks toward
70
+ // local +X), so "back" is the -X wall: row centers sit at x = -1.2,
71
+ // -0.6, 0, 0.6, 1.2 -> second row is -0.6; middle column is y = 0.
72
+ // MJCF coordinates (three.js: x -> x, y -> -z).
73
+ export const SPAWN_X = -ARENA_HALF + 1.5 * GRID_SECTION; // -0.6
74
+ export const SPAWN_Y = 0;
app/src/game/controls/controller.js ADDED
@@ -0,0 +1,188 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ // Controller: the video-game-style input core. Aggregates pluggable input
2
+ // SOURCES (keyboard, gamepad, later touch) into one game-facing surface:
3
+ //
4
+ // - a continuous COMMAND: the [vx, vy, wz] twist the policy tracks, plus
5
+ // auxiliary analog AXES (jaw 0..1, camera-orbit rates -1..1);
6
+ // - discrete ACTIONS: edge-triggered events (roll, kicks, ball spawn...)
7
+ // dispatched to subscribers the moment a source fires them;
8
+ // - HUD support: per-source pressed-state snapshots and activity info so
9
+ // the hint keycaps can light up per physical control and per device.
10
+ //
11
+ // The game code (rl.js) instantiates the sources, registers them here,
12
+ // calls update(dt) once per render frame, reads getCommand()/getAxes()
13
+ // where it builds the policy observation, and subscribes its trigger
14
+ // functions with on(action, cb).
15
+ //
16
+ // ── Source interface contract ────────────────────────────────────────────
17
+ // Every input source module (keyboard.js, gamepad.js, a future touch.js
18
+ // with a virtual joystick + buttons) implements:
19
+ //
20
+ // id Unique string ("keyboard", "gamepad", "touch"...). Action
21
+ // subscribers receive it as meta.source, so game code can
22
+ // attribute HUD flashes / keycap lighting to the right device.
23
+ // connected Boolean hardware presence. Keyboard: always true. Gamepad:
24
+ // a pad is currently reported by navigator.getGamepads().
25
+ // command Float32Array(3) [vx, vy, wz], ALREADY scaled to the game's
26
+ // velocity limits (sources take a getVelocityLimits() callback
27
+ // returning [fwd, back, ang], so limits can change at runtime,
28
+ // e.g. the legs <-> rollers switch). Must be a stable array
29
+ // reference updated in place: getCommand() hands it out
30
+ // without copying, and the control loop reads it between
31
+ // render frames.
32
+ // axes { jaw, orbitX, orbitY } - auxiliary continuous channels.
33
+ // jaw in [0, 1] (mouth opening), orbit axes in [-1, 1]
34
+ // (camera orbit rate; the inertia/smoothing lives downstream
35
+ // in the camera code, sources report raw deflection). Stable
36
+ // object reference, updated in place. Channels a source does
37
+ // not drive stay 0.
38
+ // pressed Plain object of booleans (stable reference) mirroring which
39
+ // physical controls are currently down. HUD highlighting
40
+ // only - never game logic.
41
+ // isActive() Whether the source currently claims authority over the
42
+ // continuous command. Keyboard: any move key held. Gamepad:
43
+ // stick deflected, and it keeps the claim until its smoothed
44
+ // command settles back to ~zero.
45
+ // init() Attach event listeners / hardware hooks. Called by
46
+ // Controller.init(), NOT at construction (rl.js constructs
47
+ // the sources early but arms the listeners at the same point
48
+ // in the boot where they historically went live).
49
+ // dispose() Detach everything init() attached.
50
+ // poll(dt) Per-frame tick (dt in seconds, clamped by the caller).
51
+ // Read the hardware, update command/axes/pressed in place,
52
+ // and fire edge-triggered actions via this.onAction(name).
53
+ // onAction (name, meta?) => void, assigned by the Controller at
54
+ // registration. Sources may call it from poll() (gamepad
55
+ // button edges) or straight from event handlers (keyboard:
56
+ // keeps the historical press-to-effect latency).
57
+ //
58
+ // ── Arbitration ──────────────────────────────────────────────────────────
59
+ // Continuous command: sources are registered in PRIORITY order (first =
60
+ // highest). Each frame the first source reporting isActive() owns the
61
+ // twist; when none is active the LAST registered source's command is used
62
+ // as the fallback (it reads zero when idle). With [gamepad, keyboard] this
63
+ // reproduces the historical `padActive ? padCmd : velCmd` exactly: live
64
+ // sticks win over held keys, and the keyboard takes back over once the
65
+ // pad's smoothed command has settled.
66
+ // Aux axes are merged across ALL sources regardless of who owns the twist
67
+ // (the pad triggers drive the jaw even while walking on the keyboard):
68
+ // jaw = max over sources, orbit = largest-magnitude value per axis.
69
+ //
70
+ // ── Actions ──────────────────────────────────────────────────────────────
71
+ // roll one-shot roll (crouch-glide in roller mode; game decides)
72
+ // kickL, kickR one-shot kicks, explicit foot
73
+ // alternateKick one-shot kick, feet alternated by the game
74
+ // spawnBall pop / respawn the kickable ball
75
+ // sitToggle sit <-> stand (game gates it to legs mode)
76
+ // locoToggle legs <-> rollers switch
77
+ // chaseToggle chase camera on/off
78
+ // reset full sim reset (Space)
79
+ // walk back to the walk/run mode (pad DpadUp short press)
80
+ // quack chirp + jaw flap (pad RT edge, Schmitt-triggered)
81
+ //
82
+ // ── Input lock ───────────────────────────────────────────────────────────
83
+ // setLocked(true) zeroes getCommand() - the twist gate used while the
84
+ // entrance/respawn ceremony plays. Discrete actions still dispatch: each
85
+ // game trigger applies its own lock policy (e.g. Space-reset and the
86
+ // chase-cam toggle historically work while locked, kicks don't).
87
+
88
+ const ZERO_CMD = new Float32Array(3);
89
+
90
+ export class Controller {
91
+ #sources = [];
92
+ #listeners = new Map(); // action -> Set(cb)
93
+ #locked = false;
94
+ #axes = { jaw: 0, orbitX: 0, orbitY: 0 };
95
+
96
+ constructor({ sources = [] } = {}) {
97
+ for (const s of sources) this.addSource(s);
98
+ }
99
+
100
+ // Register in priority order (first registered wins arbitration ties).
101
+ addSource(source) {
102
+ source.onAction = (action, meta) =>
103
+ this.#dispatch(action, { source: source.id, ...meta });
104
+ this.#sources.push(source);
105
+ }
106
+
107
+ // Read-only source list, for advanced per-source queries the merged view
108
+ // can't answer (e.g. "is ANY source commanding a turn right now?").
109
+ get sources() {
110
+ return this.#sources;
111
+ }
112
+
113
+ // Arm every source's listeners/hardware hooks.
114
+ init() {
115
+ for (const s of this.#sources) s.init?.();
116
+ }
117
+
118
+ dispose() {
119
+ for (const s of this.#sources) s.dispose?.();
120
+ }
121
+
122
+ // Per-frame tick: poll every source (they fire their edge actions from
123
+ // inside poll), then merge the aux axes.
124
+ update(dt) {
125
+ for (const s of this.#sources) s.poll?.(dt);
126
+ let jaw = 0, ox = 0, oy = 0;
127
+ for (const s of this.#sources) {
128
+ const a = s.axes;
129
+ if (!a) continue;
130
+ jaw = Math.max(jaw, a.jaw ?? 0);
131
+ if (Math.abs(a.orbitX ?? 0) > Math.abs(ox)) ox = a.orbitX;
132
+ if (Math.abs(a.orbitY ?? 0) > Math.abs(oy)) oy = a.orbitY;
133
+ }
134
+ this.#axes.jaw = jaw;
135
+ this.#axes.orbitX = ox;
136
+ this.#axes.orbitY = oy;
137
+ }
138
+
139
+ setLocked(v) {
140
+ this.#locked = !!v;
141
+ }
142
+
143
+ get locked() {
144
+ return this.#locked;
145
+ }
146
+
147
+ // Merged continuous twist [vx, vy, wz] (see the arbitration notes above).
148
+ // Returns live source arrays without copying - treat as read-only.
149
+ getCommand() {
150
+ if (this.#locked) return ZERO_CMD;
151
+ for (const s of this.#sources) if (s.isActive()) return s.command;
152
+ const fallback = this.#sources[this.#sources.length - 1];
153
+ return fallback ? fallback.command : ZERO_CMD;
154
+ }
155
+
156
+ // Merged aux axes { jaw, orbitX, orbitY }, refreshed by update().
157
+ getAxes() {
158
+ return this.#axes;
159
+ }
160
+
161
+ // Any source claiming twist authority (HUD "user is driving" signal).
162
+ anyActive() {
163
+ return this.#sources.some((s) => s.isActive());
164
+ }
165
+
166
+ // Per-source pressed snapshots for HUD keycap highlighting:
167
+ // { keyboard: {...}, gamepad: {...} }.
168
+ getPressed() {
169
+ const out = {};
170
+ for (const s of this.#sources) out[s.id] = s.pressed ?? {};
171
+ return out;
172
+ }
173
+
174
+ // Subscribe to a discrete action; cb(meta) with meta.source = source id.
175
+ // Returns an unsubscribe function.
176
+ on(action, cb) {
177
+ let set = this.#listeners.get(action);
178
+ if (!set) this.#listeners.set(action, (set = new Set()));
179
+ set.add(cb);
180
+ return () => set.delete(cb);
181
+ }
182
+
183
+ #dispatch(action, meta) {
184
+ const set = this.#listeners.get(action);
185
+ if (!set) return;
186
+ for (const cb of set) cb(meta);
187
+ }
188
+ }
app/src/game/controls/gamepad.js ADDED
@@ -0,0 +1,153 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ // Gamepad input source (see controller.js for the source interface
2
+ // contract). Same mapping as the robot runtime (microduck_runtime):
3
+ //
4
+ // Left stick vertical = vx (asymmetric fwd/back), horizontal = turn,
5
+ // EMA-smoothed like the runtime's cmd_alpha.
6
+ // Right stick camera orbit rate (reported raw in axes.orbitX/Y; the
7
+ // velocity smoothing / coasting lives downstream in the
8
+ // camera code). R3 toggles the chase cam.
9
+ // X roll (crouch-glide in roller mode - game decides)
10
+ // Y ball spawn/respawn
11
+ // RB / LB right / left kick
12
+ // DpadDown sit <-> stand
13
+ // DpadUp short press = back to run; HOLD ~1 s = legs <-> rollers
14
+ // (like the robot's 3 s hold, shortened for the web)
15
+ // RT / LT analog jaw (max of both); RT edge quacks through a
16
+ // Schmitt trigger (fire at >= 0.35, re-arm below 0.2 - a
17
+ // single threshold re-fires on jitter, which is how one
18
+ // squeeze used to quack several times).
19
+
20
+ const PAD_DEADZONE = 0.15;
21
+ const PAD_ALPHA = 0.12; // EMA smoothing toward the stick target
22
+ const dz = (v) => (Math.abs(v) < PAD_DEADZONE ? 0 : v);
23
+
24
+ // Standard-mapping button indices.
25
+ const BTN_X = 2, BTN_Y = 3, BTN_LB = 4, BTN_RB = 5, BTN_LT = 6, BTN_RT = 7;
26
+ const BTN_R3 = 11, BTN_DPAD_UP = 12, BTN_DPAD_DOWN = 13;
27
+
28
+ const DPAD_UP_HOLD_MS = 1000; // hold-to-switch-loco duration
29
+
30
+ export class GamepadSource {
31
+ id = "gamepad";
32
+ connected = false;
33
+ command = new Float32Array(3); // [vx, 0, wz], EMA-smoothed
34
+ axes = { jaw: 0, orbitX: 0, orbitY: 0 };
35
+ pressed = {
36
+ x: false, y: false, rb: false, lb: false, r3: false,
37
+ dpadDown: false, dpadUp: false,
38
+ };
39
+ onAction = () => {}; // assigned by the Controller at registration
40
+
41
+ #getVelocityLimits;
42
+ #active = false; // owns twist authority (stick input, until EMA settles)
43
+ #dpadUpAt = 0; // wall-clock of the current DpadUp press
44
+ #dpadUpFired = false; // latch: one loco switch per hold
45
+ #rtArmed = true; // Schmitt trigger state for the RT quack
46
+
47
+ constructor({ getVelocityLimits }) {
48
+ this.#getVelocityLimits = getVelocityLimits;
49
+ }
50
+
51
+ init() {} // poll-based: nothing to attach
52
+ dispose() {}
53
+
54
+ isActive() {
55
+ return this.#active;
56
+ }
57
+
58
+ poll() {
59
+ const prev = this.pressed;
60
+ const gp = [...(navigator.getGamepads?.() ?? [])].find((p) => p && p.connected);
61
+ this.connected = !!gp;
62
+ if (!gp) {
63
+ if (this.#active) {
64
+ this.#active = false;
65
+ this.command.fill(0);
66
+ this.axes.jaw = 0;
67
+ }
68
+ this.axes.orbitX = 0;
69
+ this.axes.orbitY = 0;
70
+ return;
71
+ }
72
+ const now = performance.now();
73
+
74
+ // Left stick only: vertical = forward/back, horizontal = turn.
75
+ // (No strafe; the right stick doesn't drive movement.)
76
+ const lx = dz(gp.axes[0] ?? 0), ly = dz(gp.axes[1] ?? 0);
77
+ const up = -ly; // browser sticks report up as -1
78
+ const [limF, limB, limA] = this.#getVelocityLimits();
79
+ const target = [
80
+ up >= 0 ? up * limF : up * -limB,
81
+ 0,
82
+ -lx * limA,
83
+ ];
84
+ for (let i = 0; i < 3; i++) this.command[i] += PAD_ALPHA * (target[i] - this.command[i]);
85
+ // Sticks grab command authority on first input, release when back at
86
+ // rest (then the keyboard takes over again through the Controller's
87
+ // arbitration).
88
+ const stickInput = lx !== 0 || ly !== 0;
89
+ if (stickInput) this.#active = true;
90
+ else if (
91
+ this.#active &&
92
+ Math.abs(this.command[0]) + Math.abs(this.command[1]) + Math.abs(this.command[2]) < 0.01
93
+ ) {
94
+ this.#active = false;
95
+ this.command.fill(0);
96
+ }
97
+
98
+ // Right stick: raw (deadzoned) orbit rate. Reported every frame - the
99
+ // downstream camera step needs the zeros too so a released stick
100
+ // coasts to a stop.
101
+ this.axes.orbitX = dz(gp.axes[2] ?? 0);
102
+ this.axes.orbitY = dz(gp.axes[3] ?? 0);
103
+
104
+ // R3 (right stick click): chase-cam toggle, gamepad twin of KeyC.
105
+ const r3 = !!gp.buttons[BTN_R3]?.pressed;
106
+ if (r3 && !prev.r3) this.onAction("chaseToggle");
107
+ prev.r3 = r3;
108
+
109
+ const x = !!gp.buttons[BTN_X]?.pressed;
110
+ if (x && !prev.x) this.onAction("roll");
111
+ prev.x = x;
112
+
113
+ const y = !!gp.buttons[BTN_Y]?.pressed;
114
+ if (y && !prev.y) this.onAction("spawnBall");
115
+ prev.y = y;
116
+
117
+ const rb = !!gp.buttons[BTN_RB]?.pressed;
118
+ if (rb && !prev.rb) this.onAction("kickR");
119
+ prev.rb = rb;
120
+ const lb = !!gp.buttons[BTN_LB]?.pressed;
121
+ if (lb && !prev.lb) this.onAction("kickL");
122
+ prev.lb = lb;
123
+
124
+ const dpadDown = !!gp.buttons[BTN_DPAD_DOWN]?.pressed;
125
+ if (dpadDown && !prev.dpadDown) this.onAction("sitToggle");
126
+ prev.dpadDown = dpadDown;
127
+
128
+ // DpadUp: short press = back to run; hold fires ONE loco switch.
129
+ const dpadUp = !!gp.buttons[BTN_DPAD_UP]?.pressed;
130
+ if (dpadUp && !prev.dpadUp) {
131
+ this.#dpadUpAt = now;
132
+ this.#dpadUpFired = false;
133
+ this.onAction("walk");
134
+ }
135
+ if (dpadUp && !this.#dpadUpFired && now - this.#dpadUpAt >= DPAD_UP_HOLD_MS) {
136
+ this.#dpadUpFired = true; // latch: one switch per hold
137
+ this.onAction("locoToggle");
138
+ }
139
+ prev.dpadUp = dpadUp;
140
+
141
+ // Triggers drive the mouth (max of both); RT quacks on its
142
+ // Schmitt-triggered rising edge.
143
+ const rt = gp.buttons[BTN_RT]?.value ?? 0;
144
+ const lt = gp.buttons[BTN_LT]?.value ?? 0;
145
+ this.axes.jaw = Math.max(rt, lt);
146
+ if (this.#rtArmed && rt >= 0.35) {
147
+ this.onAction("quack");
148
+ this.#rtArmed = false;
149
+ } else if (!this.#rtArmed && rt < 0.2) {
150
+ this.#rtArmed = true;
151
+ }
152
+ }
153
+ }
app/src/game/controls/keyboard.js ADDED
@@ -0,0 +1,116 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ // Keyboard input source (see controller.js for the source interface
2
+ // contract). Hold-to-command movement keys plus one-shot action keys.
3
+ //
4
+ // e.code is the PHYSICAL key position, so one map covers QWERTY and AZERTY:
5
+ // arrows / WASD (ZQSD) run + turn. No strafe - vy stays 0.
6
+
7
+ const KEYMAP = {
8
+ ArrowUp: "fwd", KeyW: "fwd",
9
+ ArrowDown: "back", KeyS: "back",
10
+ ArrowLeft: "turnl", KeyA: "turnl",
11
+ ArrowRight: "turnr", KeyD: "turnr",
12
+ };
13
+
14
+ // One-shot keys -> controller actions. Physical Q/E (A/E on AZERTY) are the
15
+ // explicit left / right kicks, mirroring the pad's LB / RB; F alternates.
16
+ const ACTION_KEYS = {
17
+ KeyB: "spawnBall",
18
+ KeyC: "chaseToggle",
19
+ KeyM: "locoToggle",
20
+ KeyR: "roll",
21
+ KeyQ: "kickL",
22
+ KeyE: "kickR",
23
+ KeyF: "alternateKick",
24
+ };
25
+
26
+ export class KeyboardSource {
27
+ id = "keyboard";
28
+ connected = true; // a keyboard is always assumed present
29
+ command = new Float32Array(3); // [vx, 0, wz], scaled to velocity limits
30
+ axes = { jaw: 0, orbitX: 0, orbitY: 0 }; // keyboard drives none of these
31
+ pressed = { fwd: false, back: false, turnl: false, turnr: false };
32
+ onAction = () => {}; // assigned by the Controller at registration
33
+
34
+ #held = new Set();
35
+ #getVelocityLimits;
36
+ // Limits snapshot so poll() only re-maps held keys when they actually
37
+ // change (the legs <-> rollers switch); event handlers do the live work.
38
+ #lims = [NaN, NaN, NaN];
39
+ #onKeyDown = null;
40
+ #onKeyUp = null;
41
+ #onBlur = null;
42
+
43
+ constructor({ getVelocityLimits }) {
44
+ this.#getVelocityLimits = getVelocityLimits;
45
+ }
46
+
47
+ init() {
48
+ this.#onKeyDown = (e) => {
49
+ if (e.repeat) return;
50
+ // Space always resets, and must not scroll the page.
51
+ if (e.code === "Space") {
52
+ e.preventDefault();
53
+ this.onAction("reset");
54
+ return;
55
+ }
56
+ const action = ACTION_KEYS[e.code];
57
+ if (action) {
58
+ this.onAction(action);
59
+ return;
60
+ }
61
+ const move = KEYMAP[e.code];
62
+ if (!move) return;
63
+ e.preventDefault();
64
+ this.#held.add(move);
65
+ this.#refresh();
66
+ };
67
+ this.#onKeyUp = (e) => {
68
+ const move = KEYMAP[e.code];
69
+ if (!move) return;
70
+ this.#held.delete(move);
71
+ this.#refresh();
72
+ };
73
+ // Losing window focus drops every held key (keyup events are missed).
74
+ this.#onBlur = () => {
75
+ this.#held.clear();
76
+ this.#refresh();
77
+ };
78
+ window.addEventListener("keydown", this.#onKeyDown);
79
+ window.addEventListener("keyup", this.#onKeyUp);
80
+ window.addEventListener("blur", this.#onBlur);
81
+ }
82
+
83
+ dispose() {
84
+ window.removeEventListener("keydown", this.#onKeyDown);
85
+ window.removeEventListener("keyup", this.#onKeyUp);
86
+ window.removeEventListener("blur", this.#onBlur);
87
+ }
88
+
89
+ // Claims twist authority while any movement key is held.
90
+ isActive() {
91
+ return this.#held.size > 0;
92
+ }
93
+
94
+ // The command is event-driven (recomputed on keydown/keyup/blur, so a
95
+ // press lands the same tick, not on the next frame); poll only re-maps
96
+ // held keys onto fresh velocity limits after a locomotion switch.
97
+ poll() {
98
+ const [limF, limB, limA] = this.#getVelocityLimits();
99
+ if (limF !== this.#lims[0] || limB !== this.#lims[1] || limA !== this.#lims[2]) {
100
+ this.#refresh();
101
+ }
102
+ }
103
+
104
+ #refresh() {
105
+ const lims = this.#getVelocityLimits();
106
+ const [limF, limB, limA] = lims;
107
+ this.#lims = lims;
108
+ const held = this.#held;
109
+ this.command[0] = held.has("fwd") ? limF : held.has("back") ? limB : 0;
110
+ this.command[2] = held.has("turnl") ? limA : held.has("turnr") ? -limA : 0;
111
+ this.pressed.fwd = held.has("fwd");
112
+ this.pressed.back = held.has("back");
113
+ this.pressed.turnl = held.has("turnl");
114
+ this.pressed.turnr = held.has("turnr");
115
+ }
116
+ }
app/src/game/controls/touch.js ADDED
@@ -0,0 +1,180 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ // Touch input source (see controller.js for the source interface
2
+ // contract). Game Boy layout, two thumbs:
3
+ //
4
+ // Left thumb FLOATING analog stick: the lower-left quadrant of the
5
+ // screen (#touch-zone) is the grab area, and the stick base
6
+ // (#touch-stick) re-anchors under the finger wherever it
7
+ // lands - so the thumb never has to find a fixed circle -
8
+ // then snaps back to its resting spot on release. Vertical
9
+ // = vx (asymmetric fwd/back limits), horizontal = turn. The
10
+ // nub tracks the finger clamped to the base circle; the
11
+ // command is EMA-smoothed like the gamepad's so releases
12
+ // don't snap.
13
+ // Right thumb two round caps: A (#touch-a) fires alternateKick - the
14
+ // game alternates feet; B (#touch-b) quacks on press and
15
+ // holds the beak open (axes.jaw = 1) while held.
16
+ //
17
+ // The overlay DOM lives in index.html (#touch-ui); this module only wires
18
+ // pointer events to it. `connected` (mirrored onto body.touch-mode by
19
+ // rl.js, which is what actually shows the overlay) arms two ways:
20
+ // - (pointer: coarse) matches: phones/tablets, before any touch;
21
+ // - OR the first real touch anywhere: catches DevTools device modes and
22
+ // hybrid laptops the media query misses. Latching, like a gamepad.
23
+
24
+ const TOUCH_ALPHA = 0.18; // EMA smoothing toward the stick target
25
+ const TOUCH_DEADZONE = 0.12; // normalized deflection ignored around center
26
+
27
+ export class TouchSource {
28
+ id = "touch";
29
+ connected = false;
30
+ command = new Float32Array(3); // [vx, 0, wz], EMA-smoothed
31
+ axes = { jaw: 0, orbitX: 0, orbitY: 0 };
32
+ pressed = { stick: false, a: false, b: false };
33
+ onAction = () => {}; // assigned by the Controller at registration
34
+
35
+ #getVelocityLimits;
36
+ #active = false; // owns twist authority (stick engaged, until EMA settles)
37
+ #target = [0, 0]; // normalized deflection [x, right+] [y, up+]
38
+ #mq = null;
39
+ #onMq = null;
40
+ #disposers = [];
41
+
42
+ constructor({ getVelocityLimits }) {
43
+ this.#getVelocityLimits = getVelocityLimits;
44
+ }
45
+
46
+ init() {
47
+ this.#mq = window.matchMedia("(pointer: coarse)");
48
+ // Latching: once a device has proven it can touch, keep the thumbs.
49
+ // __microduckTouched carries a touch seen by the title page before
50
+ // this module booted.
51
+ if (window.__microduckTouched) this.connected = true;
52
+ this.#onMq = () => { this.connected = this.connected || this.#mq.matches; };
53
+ this.#mq.addEventListener("change", this.#onMq);
54
+ this.#onMq();
55
+ const armTouch = () => { this.connected = true; };
56
+ window.addEventListener("touchstart", armTouch, { once: true, passive: true });
57
+ this.#disposers.push(() => window.removeEventListener("touchstart", armTouch));
58
+
59
+ const zone = document.getElementById("touch-zone");
60
+ const stick = document.getElementById("touch-stick");
61
+ const nub = stick?.querySelector(".nub");
62
+ if (zone && stick && nub) this.#bindStick(zone, stick, nub);
63
+
64
+ this.#bindButton("touch-a", "a", () => this.onAction("alternateKick"));
65
+ this.#bindButton("touch-b", "b", () => this.onAction("quack"));
66
+ }
67
+
68
+ dispose() {
69
+ this.#mq?.removeEventListener("change", this.#onMq);
70
+ for (const off of this.#disposers) off();
71
+ this.#disposers = [];
72
+ }
73
+
74
+ isActive() {
75
+ return this.#active;
76
+ }
77
+
78
+ poll() {
79
+ const [x, y] = this.#target;
80
+ const [limF, limB, limA] = this.#getVelocityLimits();
81
+ const tvx = y >= 0 ? y * limF : y * -limB;
82
+ const twz = -x * limA;
83
+ this.command[0] += TOUCH_ALPHA * (tvx - this.command[0]);
84
+ this.command[2] += TOUCH_ALPHA * (twz - this.command[2]);
85
+ // Same authority rule as the gamepad sticks: grab on input, release
86
+ // once the smoothed command has settled back to ~zero.
87
+ if (this.pressed.stick) this.#active = true;
88
+ else if (this.#active && Math.abs(this.command[0]) + Math.abs(this.command[2]) < 0.01) {
89
+ this.#active = false;
90
+ this.command.fill(0);
91
+ }
92
+ // B holds the beak open; quack itself fired on the press edge.
93
+ this.axes.jaw = this.pressed.b ? 1 : 0;
94
+ }
95
+
96
+ #on(el, type, fn) {
97
+ el.addEventListener(type, fn);
98
+ this.#disposers.push(() => el.removeEventListener(type, fn));
99
+ }
100
+
101
+ #bindStick(zone, stick, nub) {
102
+ let pointerId = null;
103
+ let center = null; // stick center while grabbed, zone-local px
104
+ const setFrom = (e) => {
105
+ const zr = zone.getBoundingClientRect();
106
+ const R = stick.offsetWidth / 2;
107
+ const travel = R * 0.62; // max nub travel inside the base circle
108
+ let dx = e.clientX - zr.left - center.x;
109
+ let dy = e.clientY - zr.top - center.y;
110
+ const d = Math.hypot(dx, dy);
111
+ if (d > travel) { dx *= travel / d; dy *= travel / d; }
112
+ nub.style.transform = `translate(${dx}px, ${dy}px)`;
113
+ const nx = dx / travel, ny = -dy / travel; // up is +y
114
+ const mag = Math.hypot(nx, ny);
115
+ const live = mag >= TOUCH_DEADZONE;
116
+ this.#target[0] = live ? nx : 0;
117
+ this.#target[1] = live ? ny : 0;
118
+ };
119
+ const release = () => {
120
+ pointerId = null;
121
+ center = null;
122
+ this.pressed.stick = false;
123
+ stick.classList.remove("live");
124
+ nub.style.transform = "";
125
+ // Back to the CSS resting spot until the next grab.
126
+ stick.style.left = "";
127
+ stick.style.top = "";
128
+ stick.style.bottom = "";
129
+ this.#target[0] = 0;
130
+ this.#target[1] = 0;
131
+ };
132
+ this.#on(zone, "pointerdown", (e) => {
133
+ e.preventDefault();
134
+ pointerId = e.pointerId;
135
+ // Anchor the base exactly under the finger - a clamped anchor would
136
+ // start the stick with a phantom deflection near the zone edges. The
137
+ // circle clipping off-screen there is the standard floating-stick
138
+ // look, and it takes no pointer events anyway.
139
+ const zr = zone.getBoundingClientRect();
140
+ const R = stick.offsetWidth / 2;
141
+ center = { x: e.clientX - zr.left, y: e.clientY - zr.top };
142
+ stick.style.left = `${center.x - R}px`;
143
+ stick.style.top = `${center.y - R}px`;
144
+ stick.style.bottom = "auto";
145
+ this.pressed.stick = true;
146
+ stick.classList.add("live");
147
+ setFrom(e); // zero deflection at grab
148
+ // Last: capture can throw on exotic pointers and must not eat the
149
+ // press state above.
150
+ try { zone.setPointerCapture(e.pointerId); } catch {}
151
+ });
152
+ this.#on(zone, "pointermove", (e) => {
153
+ if (e.pointerId === pointerId) setFrom(e);
154
+ });
155
+ this.#on(zone, "pointerup", (e) => {
156
+ if (e.pointerId === pointerId) release();
157
+ });
158
+ this.#on(zone, "pointercancel", (e) => {
159
+ if (e.pointerId === pointerId) release();
160
+ });
161
+ }
162
+
163
+ #bindButton(elId, key, fire) {
164
+ const el = document.getElementById(elId);
165
+ if (!el) return;
166
+ this.#on(el, "pointerdown", (e) => {
167
+ e.preventDefault();
168
+ this.pressed[key] = true;
169
+ el.classList.add("down");
170
+ fire(); // on the press edge, not the release: arcade latency
171
+ try { el.setPointerCapture(e.pointerId); } catch {}
172
+ });
173
+ const release = () => {
174
+ this.pressed[key] = false;
175
+ el.classList.remove("down");
176
+ };
177
+ this.#on(el, "pointerup", release);
178
+ this.#on(el, "pointercancel", release);
179
+ }
180
+ }
app/src/game/duck.js ADDED
@@ -0,0 +1,339 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ // Standalone JS port of microduck_app's src/duck/kinematics.ts.
2
+ // Loads kinematics.json (built from the MJCF by build_kinematics.py) and
3
+ // builds an Object3D tree with one Group per body. Joints become per-body
4
+ // local rotations driven by setJointAngles / setJoint.
5
+
6
+ import * as THREE from "three";
7
+ import { STLLoader } from "three/addons/loaders/STLLoader.js";
8
+ import { mergeVertices, toCreasedNormals } from "three/addons/utils/BufferGeometryUtils.js";
9
+
10
+ // Cache-busting version appended to every STL request. python http.server
11
+ // sends no Cache-Control, so browsers apply heuristic caching keyed on
12
+ // Last-Modified and can keep serving stale mesh bytes even after the files
13
+ // change on disk. Bump this whenever the mesh files are regenerated.
14
+ export const MESH_VERSION = "8";
15
+
16
+ // Default model directory: the complete mjlab export (converted from
17
+ // robot_walk.xml by tools/mjcf_to_kinematics.py). robot/v1.5/ and
18
+ // robot/alpha/ stay on disk for reference but the code no longer
19
+ // targets them.
20
+ export const MODEL_DIR = "./robot/mjlab";
21
+
22
+ // On a private HF Space, asset requests carry the ?__sign JWT (auth
23
+ // cookies may be blocked in the hub iframe). Identity everywhere else.
24
+ import { signed } from "./signed.js";
25
+
26
+ // Meshes fully occluded inside the shells at every demo camera angle,
27
+ // verified empirically by per-mesh pixel-diff (front 3/4, back, low
28
+ // front close-up): hiding each changes exactly 0 pixels. Skipped at
29
+ // load time so their bytes are never fetched. Load everything with
30
+ // `?all=1` for debugging.
31
+ const HIDDEN_MESHES = new Set([]);
32
+
33
+ export async function loadKinematics(url) {
34
+ // Same cache-buster as the STLs: force-cache would otherwise keep
35
+ // serving a stale kinematics.json after the mesh list changes.
36
+ const r = await fetch(signed(`${url}?v=${MESH_VERSION}`), { cache: "force-cache" });
37
+ if (!r.ok) throw new Error(`kinematics fetch ${r.status}`);
38
+ const k = await r.json();
39
+ // Full-resolution meshes by default (the reduction will be redone
40
+ // interactively in Blender later). `?lite=1` opts into a decimated
41
+ // meshes-lite/ sibling if present.
42
+ if (new URLSearchParams(location.search).get("lite") === "1") {
43
+ k.mesh_dir = k.mesh_dir.replace(/\/meshes$/, "/meshes-lite");
44
+ }
45
+ return k;
46
+ }
47
+
48
+ // Materials are PBR (MeshStandardMaterial), same family as the Reachy
49
+ // mobile app's glb viewer. Optional silhouette outlines via three's
50
+ // OutlineEffect - see main.js.
51
+
52
+ export async function buildRig(k, opts = {}) {
53
+ // placer holds world-space position + yaw (no axis-conversion).
54
+ const placer = new THREE.Group();
55
+ placer.name = "duck_placer";
56
+ // root applies the MJCF +Z up -> three.js +Y up convention fix.
57
+ const root = new THREE.Group();
58
+ root.name = "duck_root";
59
+ root.rotation.x = -Math.PI / 2;
60
+ placer.add(root);
61
+
62
+ const bodies = new Map();
63
+ const joints = new Map();
64
+ const loader = new STLLoader();
65
+
66
+ // STL files carry per-facet normals, which shade as a faceted mess.
67
+ // Weld vertices, then rebuild normals with a crease angle: curved
68
+ // surfaces smooth out, machined edges stay hard - the cel look needs
69
+ // both. Two scale/attribute pitfalls here:
70
+ // - mergeVertices hashes ALL attributes, so the per-facet STL normals
71
+ // must be dropped first or coincident vertices never merge.
72
+ // - toCreasedNormals groups vertices on a fixed 0.01-unit hash grid.
73
+ // Our meshes are in meters, so that grid is 1 cm: normals of vertices
74
+ // up to a centimeter apart get averaged together, which shades like a
75
+ // heavily decimated mesh. Scaling to mm makes the grid 10 um.
76
+ const CREASE = Math.PI / 5; // 36 deg
77
+ const meshCache = new Map();
78
+ const loadMesh = (name) => {
79
+ if (!meshCache.has(name)) {
80
+ meshCache.set(
81
+ name,
82
+ loader.loadAsync(signed(`${k.mesh_dir}/${name}?v=${MESH_VERSION}`)).then((raw) => {
83
+ raw.deleteAttribute("normal");
84
+ const welded = mergeVertices(raw, 1e-4);
85
+ welded.scale(1000, 1000, 1000);
86
+ const display = toCreasedNormals(welded, CREASE);
87
+ display.scale(1e-3, 1e-3, 1e-3);
88
+ welded.scale(1e-3, 1e-3, 1e-3);
89
+ return { display, welded };
90
+ }),
91
+ );
92
+ }
93
+ return meshCache.get(name);
94
+ };
95
+
96
+ for (const b of k.bodies) {
97
+ const g = new THREE.Group();
98
+ g.name = b.name;
99
+ g.position.set(b.pos[0], b.pos[1], b.pos[2]);
100
+ g.quaternion.set(b.quat[1], b.quat[2], b.quat[3], b.quat[0]);
101
+ bodies.set(b.name, g);
102
+ }
103
+ for (const b of k.bodies) {
104
+ const g = bodies.get(b.name);
105
+ if (b.parent && bodies.has(b.parent)) bodies.get(b.parent).add(g);
106
+ else root.add(g);
107
+ }
108
+ for (const b of k.bodies) {
109
+ if (!b.joint || (b.joint.type && b.joint.type !== "hinge")) continue;
110
+ const g = bodies.get(b.name);
111
+ joints.set(b.joint.name, {
112
+ body: g,
113
+ axis: new THREE.Vector3(...b.joint.axis).normalize(),
114
+ baseQuat: g.quaternion.clone(),
115
+ range: b.joint.range ?? null,
116
+ });
117
+ }
118
+
119
+ // Cache materials by their resolved PBR props so identical parts share
120
+ // one GPU material instance.
121
+ const matCache = new Map();
122
+ // Optional (meshName, bodyName, rgba) -> material spec hook. A spec is
123
+ // { color: [r,g,b], roughness, metalness, opacity? }; plain rgba arrays
124
+ // are also accepted for backwards compat.
125
+ const materialForMesh = opts.materialForMesh ?? null;
126
+ const matFor = (spec) => {
127
+ const color = spec.color;
128
+ const roughness = spec.roughness ?? 0.5;
129
+ const metalness = spec.metalness ?? 0.0;
130
+ const opacity = spec.opacity ?? 1;
131
+ const key = `${color.join(",")}|${roughness}|${metalness}|${opacity}`;
132
+ const cached = matCache.get(key);
133
+ if (cached) return cached;
134
+ const m = new THREE.MeshStandardMaterial({
135
+ color: new THREE.Color(...color),
136
+ roughness,
137
+ metalness,
138
+ transparent: opacity < 1,
139
+ opacity,
140
+ });
141
+ matCache.set(key, m);
142
+ return m;
143
+ };
144
+ const toSpec = (v, fallbackRgba) => {
145
+ if (!v) return { color: fallbackRgba.slice(0, 3), opacity: fallbackRgba[3] ?? 1 };
146
+ if (Array.isArray(v)) return { color: v.slice(0, 3), opacity: v[3] ?? 1 };
147
+ return v;
148
+ };
149
+
150
+ // Optional interior ink lines: hard edges above the threshold angle
151
+ // drawn as line segments, comic style. Cached per mesh file.
152
+ const inkOpts = opts.inkEdges ?? null;
153
+ const inkMat = inkOpts
154
+ ? new THREE.LineBasicMaterial({
155
+ color: inkOpts.color ?? 0x0a0a0e,
156
+ transparent: true,
157
+ opacity: inkOpts.opacity ?? 0.55,
158
+ })
159
+ : null;
160
+ const edgeCache = new Map();
161
+ const edgesFor = (name, welded) => {
162
+ if (!edgeCache.has(name)) {
163
+ edgeCache.set(name, new THREE.EdgesGeometry(welded, inkOpts.threshold ?? 40));
164
+ }
165
+ return edgeCache.get(name);
166
+ };
167
+
168
+ const pending = [];
169
+ const loadAll = new URLSearchParams(location.search).get("all") === "1";
170
+ // The MJCF lists a few geoms twice with identical transforms (visual +
171
+ // collision copies of power_support, soles, legs); drawing both would
172
+ // only z-fight, so exact duplicates are skipped.
173
+ const seenGeoms = new Set();
174
+ for (const b of k.bodies) {
175
+ const g = bodies.get(b.name);
176
+ if (!g) continue;
177
+ for (const geom of b.geoms) {
178
+ if (geom.type && geom.type !== "mesh") continue;
179
+ if (!geom.mesh) continue;
180
+ if (!loadAll && HIDDEN_MESHES.has(geom.mesh)) continue;
181
+ const dupKey = `${b.name}|${geom.mesh}|${geom.pos}|${geom.quat}`;
182
+ if (seenGeoms.has(dupKey)) continue;
183
+ seenGeoms.add(dupKey);
184
+ pending.push(
185
+ loadMesh(geom.mesh).then(({ display, welded }) => {
186
+ const rgba = geom.color
187
+ ? [geom.color[0], geom.color[1], geom.color[2], geom.color[3] ?? 1]
188
+ : [0.85, 0.85, 0.85, 1];
189
+ const spec = toSpec(materialForMesh?.(geom.mesh, b.name, rgba), rgba);
190
+ const m = new THREE.Mesh(display, matFor(spec));
191
+ // Mesh filename tag so callers can re-skin materials in place
192
+ // (survives cloneRig: Object3D.copy deep-copies userData).
193
+ m.userData.meshName = geom.mesh;
194
+ if (geom.pos) m.position.set(...geom.pos);
195
+ if (geom.quat) m.quaternion.set(geom.quat[1], geom.quat[2], geom.quat[3], geom.quat[0]);
196
+ g.add(m);
197
+ if (inkMat) {
198
+ const lines = new THREE.LineSegments(edgesFor(geom.mesh, welded), inkMat);
199
+ lines.position.copy(m.position);
200
+ lines.quaternion.copy(m.quaternion);
201
+ g.add(lines);
202
+ }
203
+ }),
204
+ );
205
+ }
206
+ }
207
+ await Promise.all(pending);
208
+
209
+ const rig = { placer, root, bodies, joints };
210
+ setupJawPivot(rig);
211
+ return rig;
212
+ }
213
+
214
+ // ── Jaw hinge ───────────────────────────────────────────────────────────
215
+ // The mjlab model has no passive jaw joints: jaw.stl / jaw_soft.stl are
216
+ // rigid geoms of the head body (named "jaw_soft" in the MJCF, it carries
217
+ // the head_roll joint). The quack re-creates the hinge in JS: both jaw
218
+ // meshes are reparented into a "jaw_pivot" group
219
+ // whose origin sits on the rear top edge of their combined bounding box
220
+ // (the physical hinge line), and setJawOpen rotates that pivot about the
221
+ // robot's left-right axis so the beak tip swings down.
222
+ const JAW_MESH_NAMES = new Set(["jaw.stl", "jaw_soft.stl"]);
223
+ export const JAW_MAX_OPEN = 0.32; // rad at openness 1
224
+
225
+ function setupJawPivot(rig) {
226
+ const meshes = [];
227
+ rig.root.traverse((o) => {
228
+ if (o.isMesh && JAW_MESH_NAMES.has(o.userData.meshName)) meshes.push(o);
229
+ });
230
+ if (!meshes.length) return;
231
+ const body = meshes[0].parent;
232
+ // The placer is still untransformed right after buildRig, so world
233
+ // coords == placer coords here: robot forward is +X, up is +Y.
234
+ rig.placer.updateWorldMatrix(true, true);
235
+ const box = new THREE.Box3();
236
+ for (const m of meshes) box.expandByObject(m);
237
+ const hingeW = new THREE.Vector3(box.min.x, box.max.y, (box.min.z + box.max.z) / 2);
238
+ // +angle about -Z rotates the +X beak tip toward -Y (down).
239
+ const axisW = new THREE.Vector3(0, 0, -1);
240
+ const bodyQuatInv = body.getWorldQuaternion(new THREE.Quaternion()).invert();
241
+ const hingeL = body.worldToLocal(hingeW.clone());
242
+ const axisL = axisW.applyQuaternion(bodyQuatInv).normalize();
243
+ const pivot = new THREE.Group();
244
+ pivot.name = "jaw_pivot";
245
+ pivot.position.copy(hingeL);
246
+ // Plain array so Object3D.copy's JSON userData clone preserves it.
247
+ pivot.userData.jawAxis = axisL.toArray();
248
+ body.add(pivot);
249
+ for (const m of meshes) {
250
+ m.position.sub(hingeL);
251
+ pivot.add(m);
252
+ }
253
+ }
254
+
255
+ // Open the beak: 0 = closed, 1 = fully open (JAW_MAX_OPEN rad). The pivot
256
+ // is resolved lazily by name so clones from cloneRig work transparently.
257
+ const _jawAxis = new THREE.Vector3();
258
+ export function setJawOpen(rig, open) {
259
+ if (rig._jawPivot === undefined) {
260
+ rig._jawPivot = rig.placer.getObjectByName("jaw_pivot") ?? null;
261
+ }
262
+ const pivot = rig._jawPivot;
263
+ if (!pivot) return;
264
+ _jawAxis.fromArray(pivot.userData.jawAxis);
265
+ pivot.quaternion.setFromAxisAngle(_jawAxis, JAW_MAX_OPEN * open);
266
+ }
267
+
268
+ // Set one named joint, clamped to its MJCF range when known.
269
+ export function setJoint(rig, name, angle) {
270
+ const j = rig.joints.get(name);
271
+ if (!j) return;
272
+ let a = angle;
273
+ if (j.range) a = Math.min(j.range[1], Math.max(j.range[0], a));
274
+ const rot = _q.setFromAxisAngle(j.axis, a);
275
+ j.body.quaternion.copy(j.baseQuat).multiply(rot);
276
+ }
277
+ const _q = new THREE.Quaternion();
278
+
279
+ export function applyPose(rig, pose) {
280
+ for (const [name, ang] of Object.entries(pose)) setJoint(rig, name, ang);
281
+ }
282
+
283
+ // Deep-clone a built rig without re-parsing the STL files: Object3D.clone
284
+ // shares geometry and materials, so N clones cost almost nothing on top of
285
+ // the first buildRig. The bodies/joints maps are rebuilt by looking up the
286
+ // cloned nodes by name (body names are unique in the MJCF).
287
+ export function cloneRig(rig) {
288
+ const placer = rig.placer.clone(true);
289
+ const root = placer.getObjectByName("duck_root");
290
+ const bodies = new Map();
291
+ for (const name of rig.bodies.keys()) {
292
+ bodies.set(name, placer.getObjectByName(name));
293
+ }
294
+ const joints = new Map();
295
+ for (const [name, j] of rig.joints) {
296
+ joints.set(name, {
297
+ body: placer.getObjectByName(j.body.name),
298
+ axis: j.axis, // read-only, safe to share
299
+ baseQuat: j.baseQuat.clone(),
300
+ range: j.range,
301
+ });
302
+ }
303
+ return { placer, root, bodies, joints };
304
+ }
305
+
306
+ // Ground using the whole rig's bounding box (sitting pose folds the legs
307
+ // under the trunk, so the feet are not the lowest point).
308
+ const _box = new THREE.Box3();
309
+ export function groundFullBody(rig, floorY = 0) {
310
+ rig.placer.updateWorldMatrix(true, true);
311
+ _box.setFromObject(rig.placer);
312
+ if (!Number.isFinite(_box.min.y)) return 0;
313
+ rig.placer.position.y += floorY - _box.min.y;
314
+ return floorY - _box.min.y;
315
+ }
316
+
317
+ // "SIT" keyframe. The mjlab model shares alpha's conventions (same
318
+ // onshape-to-robot pipeline): neck_pitch range max is 1.0472, so
319
+ // neck_pitch sits just under it (headroom for the breathing oscillation,
320
+ // +-0.025) and head_pitch compensates to keep the head level-ish with a
321
+ // slight upward tilt toward the camera. NOTE: head_pitch sign is
322
+ // inverted vs v1.5 (positive = head down), so the compensation is
323
+ // positive here (verified visually).
324
+ export const SITTING_POSE = {
325
+ left_hip_yaw: 0.0,
326
+ left_hip_roll: 0.0,
327
+ left_hip_pitch: -0.5236,
328
+ left_knee: 1.0472,
329
+ left_ankle: 0.0,
330
+ neck_pitch: 1.02,
331
+ head_pitch: 0.9,
332
+ head_yaw: 0.0,
333
+ head_roll: 0.0,
334
+ right_hip_yaw: 0.0,
335
+ right_hip_roll: 0.0,
336
+ right_hip_pitch: 0.5236,
337
+ right_knee: -1.0472,
338
+ right_ankle: 0.0,
339
+ };
app/src/game/fx/demo-glitch.html ADDED
@@ -0,0 +1,140 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ <!doctype html>
2
+ <html lang="en">
3
+ <head>
4
+ <meta charset="utf-8" />
5
+ <meta name="viewport" content="width=device-width, initial-scale=1" />
6
+ <title>fx demo - glitch materialization</title>
7
+ <style>
8
+ html, body { margin: 0; height: 100%; background: #0b0d10; overflow: hidden; }
9
+ #app { position: fixed; inset: 0; }
10
+ #replay {
11
+ position: fixed; top: 16px; left: 16px; z-index: 10;
12
+ font: 700 13px/1 "SF Mono", ui-monospace, Menlo, monospace;
13
+ letter-spacing: 0.14em;
14
+ color: #ff7a2f; background: rgba(11, 13, 16, 0.85);
15
+ border: 1px solid #ff7a2f; border-radius: 4px;
16
+ padding: 10px 18px; cursor: pointer;
17
+ text-shadow: 0 0 8px rgba(255, 122, 47, 0.5);
18
+ }
19
+ #replay:hover { background: rgba(255, 122, 47, 0.15); box-shadow: 0 0 14px rgba(255, 122, 47, 0.35); }
20
+ #replay:active { transform: translateY(1px); }
21
+ #status {
22
+ position: fixed; bottom: 14px; left: 16px; z-index: 10;
23
+ font: 11px/1.4 ui-monospace, Menlo, monospace;
24
+ color: #3d4652; letter-spacing: 0.08em; user-select: none;
25
+ }
26
+ </style>
27
+ <script type="importmap">
28
+ {
29
+ "imports": {
30
+ "three": "https://cdn.jsdelivr.net/npm/three@0.164.1/build/three.module.js",
31
+ "three/addons/": "https://cdn.jsdelivr.net/npm/three@0.164.1/examples/jsm/"
32
+ }
33
+ }
34
+ </script>
35
+ </head>
36
+ <body>
37
+ <div id="app"></div>
38
+ <button id="replay" type="button" aria-label="Replay the glitch materialization">REPLAY</button>
39
+ <div id="status">loading rig...</div>
40
+ <script type="module">
41
+ import * as THREE from "three";
42
+ import { OrbitControls } from "three/addons/controls/OrbitControls.js";
43
+ import { RoomEnvironment } from "three/addons/environments/RoomEnvironment.js";
44
+ import { loadKinematics, buildRig, groundFullBody } from "../duck.js";
45
+ import { VARIANTS, materialHookFor } from "../variants.js";
46
+ import * as fx from "./fx-glitch.js";
47
+
48
+ const mount = document.getElementById("app");
49
+ const statusEl = document.getElementById("status");
50
+
51
+ const scene = new THREE.Scene();
52
+ scene.background = new THREE.Color(0x0b0d10);
53
+
54
+ const camera = new THREE.PerspectiveCamera(40, 1, 0.02, 30);
55
+ camera.position.set(0.42, 0.22, 0.5);
56
+
57
+ const renderer = new THREE.WebGLRenderer({ antialias: true });
58
+ renderer.setPixelRatio(Math.min(2, window.devicePixelRatio));
59
+ renderer.toneMapping = THREE.ACESFilmicToneMapping;
60
+ mount.appendChild(renderer.domElement);
61
+
62
+ const pmrem = new THREE.PMREMGenerator(renderer);
63
+ scene.environment = pmrem.fromScene(new RoomEnvironment()).texture;
64
+ scene.environmentIntensity = 0.45;
65
+
66
+ // Same light rig as the live playground.
67
+ scene.add(new THREE.AmbientLight(0xffffff, 0.6));
68
+ const keyLight = new THREE.DirectionalLight(0xffffff, 1.6);
69
+ keyLight.position.set(2, 4, 2);
70
+ scene.add(keyLight);
71
+ const fill = new THREE.DirectionalLight(0xffffff, 0.4);
72
+ fill.position.set(-2, 2, 1.5);
73
+ scene.add(fill);
74
+ const rim = new THREE.DirectionalLight(0xffb366, 0.7);
75
+ rim.position.set(0, 3, -2);
76
+ scene.add(rim);
77
+
78
+ // Ground: dark disc + faint grid.
79
+ const ground = new THREE.Mesh(
80
+ new THREE.CircleGeometry(1.4, 64).rotateX(-Math.PI / 2),
81
+ new THREE.MeshStandardMaterial({ color: 0x11141a, roughness: 0.95, metalness: 0 }),
82
+ );
83
+ scene.add(ground);
84
+ const grid = new THREE.GridHelper(2.4, 48, 0x232a33, 0x161b22);
85
+ grid.position.y = 0.001;
86
+ scene.add(grid);
87
+
88
+ const controls = new OrbitControls(camera, renderer.domElement);
89
+ controls.enableDamping = true;
90
+ controls.target.set(0, 0.1, 0);
91
+ controls.minDistance = 0.15;
92
+ controls.maxDistance = 3;
93
+
94
+ function resize() {
95
+ const w = mount.clientWidth, h = mount.clientHeight;
96
+ camera.aspect = w / h;
97
+ camera.updateProjectionMatrix();
98
+ renderer.setSize(w, h);
99
+ }
100
+ window.addEventListener("resize", resize);
101
+ resize();
102
+
103
+ // ── Isolated duck rig (classic variant, standing pose) ────────────
104
+ // MODEL_DIR is page-relative ("./robot/mjlab") and resolves wrong from
105
+ // /fx/; mesh_dir inside the JSON is root-absolute so only this differs.
106
+ const k = await loadKinematics("../robot/mjlab/kinematics.json");
107
+ const rig = await buildRig(k, { materialForMesh: materialHookFor(VARIANTS.classic) });
108
+ groundFullBody(rig, 0);
109
+ scene.add(rig.placer);
110
+
111
+ // Aim the camera at the duck's actual center.
112
+ const box = new THREE.Box3().setFromObject(rig.placer);
113
+ const center = box.getCenter(new THREE.Vector3());
114
+ controls.target.copy(center);
115
+
116
+ fx.init({ THREE, scene, rig, camera, renderer });
117
+ statusEl.textContent = `fx: ${fx.name} | drag to orbit`;
118
+
119
+ document.getElementById("replay").addEventListener("click", () => fx.start());
120
+
121
+ // Debug hook for deterministic mid-effect screenshots.
122
+ window.fx = {
123
+ start: () => fx.start(),
124
+ setProgress: (p) => fx.setProgress(p),
125
+ isDone: () => fx.isDone(),
126
+ };
127
+
128
+ const clock = new THREE.Clock();
129
+ renderer.setAnimationLoop(() => {
130
+ const dt = Math.min(0.05, clock.getDelta());
131
+ fx.update(dt, clock.elapsedTime);
132
+ controls.update();
133
+ renderer.render(scene, camera);
134
+ });
135
+
136
+ // Auto-play once everything is loaded.
137
+ setTimeout(() => fx.start(), 500);
138
+ </script>
139
+ </body>
140
+ </html>
app/src/game/fx/demo-wireframe.html ADDED
@@ -0,0 +1,113 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ <!doctype html>
2
+ <html lang="en">
3
+ <head>
4
+ <meta charset="utf-8" />
5
+ <meta name="viewport" content="width=device-width, initial-scale=1" />
6
+ <title>FX demo - holographic wireframe scan-up</title>
7
+ <style>
8
+ html, body { margin: 0; height: 100%; background: #0b0d10; overflow: hidden; }
9
+ #app { position: fixed; inset: 0; }
10
+ #hud {
11
+ position: fixed; top: 14px; left: 14px; z-index: 10;
12
+ font: 12px/1.4 ui-monospace, SFMono-Regular, Menlo, monospace;
13
+ color: #ff7a2f; user-select: none;
14
+ }
15
+ #replay {
16
+ margin-top: 8px; padding: 7px 18px; cursor: pointer;
17
+ font: 600 12px ui-monospace, SFMono-Regular, Menlo, monospace;
18
+ letter-spacing: 0.12em; color: #ff7a2f;
19
+ background: rgba(255, 122, 47, 0.08);
20
+ border: 1px solid rgba(255, 122, 47, 0.55); border-radius: 4px;
21
+ }
22
+ #replay:hover { background: rgba(255, 122, 47, 0.2); }
23
+ </style>
24
+ <script type="importmap">
25
+ {
26
+ "imports": {
27
+ "three": "https://cdn.jsdelivr.net/npm/three@0.164.1/build/three.module.js",
28
+ "three/addons/": "https://cdn.jsdelivr.net/npm/three@0.164.1/examples/jsm/"
29
+ }
30
+ }
31
+ </script>
32
+ </head>
33
+ <body>
34
+ <div id="app"></div>
35
+ <div id="hud">
36
+ FX: HOLOGRAPHIC WIREFRAME SCAN-UP<br />
37
+ <button id="replay">REPLAY</button>
38
+ </div>
39
+ <script type="module">
40
+ import * as THREE from "three";
41
+ import { OrbitControls } from "three/addons/controls/OrbitControls.js";
42
+ import { loadKinematics, buildRig, groundFullBody } from "../duck.js";
43
+ import { VARIANTS, materialHookFor } from "../variants.js";
44
+ import * as fx from "./fx-wireframe.js";
45
+
46
+ const mount = document.getElementById("app");
47
+
48
+ const scene = new THREE.Scene();
49
+ scene.background = new THREE.Color(0x0b0d10);
50
+
51
+ const camera = new THREE.PerspectiveCamera(40, 1, 0.02, 30);
52
+ const renderer = new THREE.WebGLRenderer({ antialias: true });
53
+ renderer.setPixelRatio(Math.min(2, window.devicePixelRatio));
54
+ renderer.toneMapping = THREE.ACESFilmicToneMapping;
55
+ mount.appendChild(renderer.domElement);
56
+
57
+ // Same light rig as the live app so materials read identically.
58
+ scene.add(new THREE.AmbientLight(0xffffff, 0.6));
59
+ const keyLight = new THREE.DirectionalLight(0xffffff, 1.6);
60
+ keyLight.position.set(2, 4, 2);
61
+ scene.add(keyLight);
62
+ const fill = new THREE.DirectionalLight(0xffffff, 0.4);
63
+ fill.position.set(-2, 2, 1.5);
64
+ scene.add(fill);
65
+ const rim = new THREE.DirectionalLight(0xffb366, 0.7);
66
+ rim.position.set(0, 3, -2);
67
+ scene.add(rim);
68
+
69
+ const grid = new THREE.GridHelper(2, 40, 0x232c3a, 0x151a22);
70
+ scene.add(grid);
71
+
72
+ const resize = () => {
73
+ const w = mount.clientWidth, h = mount.clientHeight;
74
+ camera.aspect = w / h;
75
+ camera.updateProjectionMatrix();
76
+ renderer.setSize(w, h);
77
+ };
78
+ window.addEventListener("resize", resize);
79
+ resize();
80
+
81
+ // Isolated duck rig, classic colourway.
82
+ const k = await loadKinematics("/robot/mjlab/kinematics.json");
83
+ const rig = await buildRig(k, { materialForMesh: materialHookFor(VARIANTS.classic) });
84
+ groundFullBody(rig);
85
+ scene.add(rig.placer);
86
+
87
+ // Frame the duck.
88
+ rig.placer.updateWorldMatrix(true, true);
89
+ const box = new THREE.Box3().setFromObject(rig.placer);
90
+ const center = box.getCenter(new THREE.Vector3());
91
+ const size = box.getSize(new THREE.Vector3()).length();
92
+ camera.position.set(center.x + size * 1.5, center.y + size * 0.85, center.z + size * 1.9);
93
+ const controls = new OrbitControls(camera, renderer.domElement);
94
+ controls.target.copy(center);
95
+ controls.enableDamping = true;
96
+ controls.update();
97
+
98
+ fx.init({ THREE, scene, rig, camera, renderer });
99
+ fx.start();
100
+ window.fx = fx; // fx.setProgress(p) for deterministic captures
101
+
102
+ document.getElementById("replay").addEventListener("click", () => fx.start());
103
+
104
+ const clock = new THREE.Clock();
105
+ renderer.setAnimationLoop(() => {
106
+ const dt = Math.min(clock.getDelta(), 0.05);
107
+ fx.update(dt, clock.elapsedTime);
108
+ controls.update();
109
+ renderer.render(scene, camera);
110
+ });
111
+ </script>
112
+ </body>
113
+ </html>
app/src/game/fx/fx-glitch.js ADDED
@@ -0,0 +1,311 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ // Cyberpunk 2077-style glitch materialization for the Microduck rig.
2
+ //
3
+ // The duck appears as stacked horizontal slices that flicker in and out
4
+ // with random screen-space horizontal offsets, converging to their true
5
+ // position. Chromatic aberration is faked with two additive silhouette
6
+ // ghosts (cyan / orange) offset left-right, digital noise blocks flash
7
+ // emissive or invert the surface colour, and a final warm flash "snaps"
8
+ // everything crisp.
9
+ //
10
+ // Implementation: onBeforeCompile injections on CACHED clones of the rig
11
+ // materials (same pattern and same program-cache trap as the entrance
12
+ // dissolve in rl.js: clones are cached per original material and reused
13
+ // on every replay, each with a unique customProgramCacheKey, otherwise
14
+ // three.js would reuse a compiled program without re-binding uniforms).
15
+ // All uniforms are shared singleton objects so one JS write per frame
16
+ // drives every material.
17
+
18
+ export const name = "glitch";
19
+
20
+ const DURATION = 1.15; // seconds
21
+ const TICK_HZ = 30; // discrete glitch update rate (digital feel)
22
+ const SLICES = 24;
23
+
24
+ // ── Shared uniforms (one instance drives all cloned materials) ──────────
25
+ const uP = { value: 0 }; // progress 0..1 (0 = invisible)
26
+ const uSeed = { value: 0 }; // discrete tick, drives all hashes
27
+ const uAmp = { value: 0 }; // slice offset amplitude (view-space meters)
28
+ const uFlash = { value: 0 }; // final snap flash intensity
29
+ const uGhostOff = { value: 0 }; // RGB-split ghost separation (meters)
30
+ const uB = { value: null }; // vec4(minY, height, sliceH, 0) world bounds
31
+
32
+ let T = null; // injected THREE namespace
33
+ let rigRef = null;
34
+ let playing = false;
35
+ let scrubbed = false;
36
+ let done = true;
37
+ let elapsed = 0;
38
+
39
+ // Deterministic JS-side hash, mirrors the GLSL one.
40
+ const jhash = (n) => {
41
+ const s = Math.sin(n * 127.1 + 311.7) * 43758.5453;
42
+ return s - Math.floor(s);
43
+ };
44
+
45
+ // ── GLSL injection ──────────────────────────────────────────────────────
46
+ const GLSL_HELPERS = /* glsl */ `
47
+ varying vec3 vGlW;
48
+ uniform float uGlP, uGlSeed;
49
+ uniform vec4 uGlB;
50
+ float glHash2(vec2 q) { return fract(sin(dot(q, vec2(127.1, 311.7))) * 43758.5453); }
51
+ vec3 glHash3(vec2 q) { return vec3(glHash2(q), glHash2(q + 17.17), glHash2(q + 31.3)); }
52
+ `;
53
+
54
+ function injectGlitch(shader, { signU = null, ghost = false } = {}) {
55
+ shader.uniforms.uGlP = uP;
56
+ shader.uniforms.uGlSeed = uSeed;
57
+ shader.uniforms.uGlAmp = uAmp;
58
+ shader.uniforms.uGlFlash = uFlash;
59
+ shader.uniforms.uGlGhostOff = uGhostOff;
60
+ shader.uniforms.uGlSign = signU ?? { value: 0 };
61
+ shader.uniforms.uGlB = uB;
62
+
63
+ shader.vertexShader = shader.vertexShader
64
+ .replace(
65
+ "#include <common>",
66
+ "#include <common>\n" + GLSL_HELPERS + "uniform float uGlAmp, uGlSign, uGlGhostOff;\n",
67
+ )
68
+ .replace(
69
+ "#include <project_vertex>",
70
+ /* glsl */ `#include <project_vertex>
71
+ vec4 glWp = modelMatrix * vec4(transformed, 1.0);
72
+ vGlW = glWp.xyz;
73
+ if (uGlP < 1.0) {
74
+ float glSid = floor((glWp.y - uGlB.x) / uGlB.z);
75
+ // Fraction of moving slices shrinks as p rises: convergence.
76
+ float glMove = step(0.25 + 0.62 * uGlP, glHash2(vec2(glSid * 1.71 + 4.2, uGlSeed + 13.0)));
77
+ float glOff = (glHash2(vec2(glSid, uGlSeed)) - 0.5) * 2.0 * uGlAmp * glMove;
78
+ // Occasional whole-body frame jump for one tick.
79
+ if (glHash2(vec2(uGlSeed, 3.17)) > 0.86) {
80
+ glOff += (glHash2(vec2(uGlSeed, 9.91)) - 0.5) * 3.0 * uGlAmp;
81
+ }
82
+ glOff += uGlSign * uGlGhostOff;
83
+ // View-space X = screen-horizontal, the classic spawn-glitch axis.
84
+ mvPosition.x += glOff;
85
+ gl_Position = projectionMatrix * mvPosition;
86
+ }`,
87
+ );
88
+
89
+ const sliceDiscard = /* glsl */ `
90
+ float glEdge = 0.0, glInvAmt = 0.0;
91
+ vec3 glAddC = vec3(0.0);
92
+ if (uGlP < 1.0) {
93
+ float glSid = floor((vGlW.y - uGlB.x) / uGlB.z);
94
+ float glH = glHash2(vec2(glSid, uGlSeed + 0.7));
95
+ float glVis = smoothstep(0.02, 0.82, uGlP) * 1.35;
96
+ // Two-three full-silhouette pre-flashes right at the start.
97
+ float glPre = (uGlP > 0.015 && uGlP < 0.14 && glHash2(vec2(uGlSeed, 7.7)) > 0.72) ? 1.0 : 0.0;
98
+ glVis = max(glVis, glPre);
99
+ if (glH > glVis) discard;
100
+ glEdge = max(glPre * 0.7, (1.0 - smoothstep(0.0, 0.3, glVis - glH)) * (1.0 - uGlP));
101
+ ${ghost ? "" : /* glsl */ `
102
+ // Digital noise blocks: world-space rectangular patches, re-seeded per
103
+ // tick. Some invert the surface colour, most flash emissive.
104
+ vec2 glBq = floor((vGlW.xy + vec2(vGlW.z * 0.7, vGlW.z * 0.3)) * 52.0
105
+ + vec2(uGlSeed * 0.373, uGlSeed * 0.719));
106
+ vec3 glBh = glHash3(glBq + uGlSeed);
107
+ float glBGate = (1.0 - uGlP) * 0.16;
108
+ if (glBh.x > 1.0 - glBGate) {
109
+ if (glBh.y < 0.3) glInvAmt = 1.0;
110
+ else glAddC += (glBh.z < 0.6 ? vec3(0.15, 1.3, 1.5)
111
+ : (glBh.z < 0.85 ? vec3(1.4) : vec3(1.6, 0.5, 0.06))) * (0.7 + glBh.y);
112
+ }
113
+ // Faint rolling scanline shimmer.
114
+ glAddC += vec3(0.05, 0.3, 0.35) * (1.0 - uGlP) * 0.18
115
+ * step(0.6, fract(vGlW.y * 150.0 - uGlSeed * 0.21));
116
+ `}
117
+ }`;
118
+
119
+ const emissiveOut = ghost
120
+ ? "" // ghosts are pre-tinted additive silhouettes, no extra emissive
121
+ : /* glsl */ `
122
+ gl_FragColor.rgb = mix(gl_FragColor.rgb, vec3(1.1) - gl_FragColor.rgb, glInvAmt);
123
+ gl_FragColor.rgb += glAddC;
124
+ gl_FragColor.rgb += glEdge * vec3(0.25, 1.15, 1.35);
125
+ gl_FragColor.rgb += uGlFlash * vec3(1.25, 0.7, 0.35);`;
126
+
127
+ shader.fragmentShader = shader.fragmentShader
128
+ .replace(
129
+ "#include <common>",
130
+ "#include <common>\n" + GLSL_HELPERS + "uniform float uGlFlash;\n",
131
+ )
132
+ .replace("#include <clipping_planes_fragment>", "#include <clipping_planes_fragment>" + sliceDiscard)
133
+ .replace("#include <dithering_fragment>", "#include <dithering_fragment>" + emissiveOut);
134
+ }
135
+
136
+ // ── Material clones (cached, never disposed between replays) ────────────
137
+ const clones = new Map(); // original material uuid -> glitch clone
138
+ let cloneNonce = 0;
139
+ let saved = null; // Array<[mesh, originalMat, cloneMat]> while active
140
+
141
+ function cloneFor(orig) {
142
+ let m = clones.get(orig.uuid);
143
+ if (m) return m;
144
+ m = orig.clone();
145
+ m.onBeforeCompile = (shader) => injectGlitch(shader);
146
+ const key = `microduck-glitch-${cloneNonce++}`;
147
+ m.customProgramCacheKey = () => key;
148
+ m.needsUpdate = true;
149
+ clones.set(orig.uuid, m);
150
+ return m;
151
+ }
152
+
153
+ function applyGlitchMats() {
154
+ if (saved) return;
155
+ saved = [];
156
+ rigRef.placer.traverse((o) => {
157
+ if (!o.isMesh || o.userData.glGhost) return;
158
+ const orig = o.material;
159
+ const m = cloneFor(orig);
160
+ saved.push([o, orig, m]);
161
+ o.material = m;
162
+ });
163
+ }
164
+
165
+ function restoreMats() {
166
+ if (!saved) return;
167
+ for (const [mesh, orig, clone] of saved) {
168
+ if (mesh.material === clone) mesh.material = orig;
169
+ }
170
+ saved = null;
171
+ }
172
+
173
+ // ── RGB-split ghosts ────────────────────────────────────────────────────
174
+ // Two additive unlit silhouette copies of every rig mesh (cyan right,
175
+ // orange left), sharing the slice-discard shader so their outline always
176
+ // matches the sliced duck. Added as children of each mesh: they inherit
177
+ // the full kinematic transform for free.
178
+ let ghostMats = [];
179
+ let ghostMeshes = [];
180
+
181
+ function makeGhostMat(r, g, b, sign) {
182
+ const m = new T.MeshBasicMaterial({
183
+ transparent: true,
184
+ opacity: 0,
185
+ depthWrite: false,
186
+ blending: T.AdditiveBlending,
187
+ });
188
+ m.color.setRGB(r, g, b);
189
+ const signU = { value: sign };
190
+ m.onBeforeCompile = (shader) => injectGlitch(shader, { signU, ghost: true });
191
+ m.customProgramCacheKey = () => `microduck-glitch-ghost-${sign > 0 ? "r" : "l"}`;
192
+ return m;
193
+ }
194
+
195
+ function buildGhosts() {
196
+ ghostMats = [makeGhostMat(0.0, 1.1, 1.3, +1), makeGhostMat(1.3, 0.35, 0.02, -1)];
197
+ const hosts = [];
198
+ rigRef.placer.traverse((o) => {
199
+ if (o.isMesh && !o.userData.glGhost) hosts.push(o);
200
+ });
201
+ for (const mesh of hosts) {
202
+ for (const mat of ghostMats) {
203
+ const gm = new T.Mesh(mesh.geometry, mat);
204
+ gm.userData.glGhost = true;
205
+ gm.renderOrder = 2;
206
+ gm.frustumCulled = false;
207
+ gm.visible = false;
208
+ mesh.add(gm);
209
+ ghostMeshes.push(gm);
210
+ }
211
+ }
212
+ }
213
+
214
+ function setGhostsVisible(v) {
215
+ for (const gm of ghostMeshes) gm.visible = v;
216
+ }
217
+
218
+ // ── Deterministic per-progress uniform state ────────────────────────────
219
+ // Everything derives from p alone (tick included), so setProgress(p) gives
220
+ // reproducible frames for debugging/screenshots.
221
+ function setUniformsFor(p) {
222
+ uP.value = p;
223
+ const tick = Math.floor(p * DURATION * TICK_HZ);
224
+ uSeed.value = tick;
225
+ const h = uB.value.y; // duck height
226
+ const gate = 0.35 + 0.65 * jhash(tick + 3.7);
227
+ uAmp.value = p < 0.92 ? h * 0.3 * Math.pow(1 - p, 1.3) * gate : 0;
228
+ uGhostOff.value =
229
+ p > 0.04 && p < 0.9 ? (0.06 + 0.3 * jhash(tick + 11.1)) * (1 - p) * h : 0;
230
+ uFlash.value = p > 0.86 ? 1.5 * Math.exp(-16 * (p - 0.86) * DURATION) : 0;
231
+ const ghostOp =
232
+ p > 0.04 && p < 0.9 ? (0.05 + 0.32 * (1 - p)) * (0.4 + 0.6 * jhash(tick + 5.5)) : 0;
233
+ for (const m of ghostMats) m.opacity = ghostOp;
234
+ }
235
+
236
+ function finish() {
237
+ uP.value = 1;
238
+ uFlash.value = 0;
239
+ setGhostsVisible(false);
240
+ restoreMats();
241
+ playing = false;
242
+ done = true;
243
+ }
244
+
245
+ // ── Public interface ────────────────────────────────────────────────────
246
+ export function init({ THREE, scene, rig, camera, renderer }) {
247
+ T = THREE;
248
+ rigRef = rig;
249
+ rig.placer.updateWorldMatrix(true, true);
250
+ const box = new T.Box3().setFromObject(rig.placer);
251
+ const minY = box.min.y;
252
+ const height = Math.max(1e-3, box.max.y - minY);
253
+ uB.value = new T.Vector4(minY, height, height / SLICES, 0);
254
+ buildGhosts();
255
+ // Duck starts invisible: p=0 discards every fragment.
256
+ uP.value = 0;
257
+ applyGlitchMats();
258
+ done = true;
259
+ playing = false;
260
+ }
261
+
262
+ export function start() {
263
+ applyGlitchMats();
264
+ setGhostsVisible(true);
265
+ elapsed = 0;
266
+ playing = true;
267
+ scrubbed = false;
268
+ done = false;
269
+ setUniformsFor(0);
270
+ }
271
+
272
+ export function update(dt, t) {
273
+ if (!playing || scrubbed) return;
274
+ elapsed += dt;
275
+ const p = Math.min(1, elapsed / DURATION);
276
+ setUniformsFor(p);
277
+ if (p >= 1) finish();
278
+ }
279
+
280
+ export function isDone() {
281
+ return done;
282
+ }
283
+
284
+ export function dispose() {
285
+ restoreMats();
286
+ for (const gm of ghostMeshes) gm.parent?.remove(gm);
287
+ ghostMeshes = [];
288
+ for (const m of ghostMats) m.dispose();
289
+ ghostMats = [];
290
+ for (const m of clones.values()) m.dispose();
291
+ clones.clear();
292
+ playing = false;
293
+ done = true;
294
+ rigRef = null;
295
+ }
296
+
297
+ // Debug hook: freeze the effect at an exact progress value (deterministic,
298
+ // used by the demo page for mid-effect screenshots). start() resumes
299
+ // normal playback.
300
+ export function setProgress(p) {
301
+ scrubbed = true;
302
+ if (p >= 1) {
303
+ finish();
304
+ return;
305
+ }
306
+ playing = true;
307
+ done = false;
308
+ applyGlitchMats();
309
+ setGhostsVisible(true);
310
+ setUniformsFor(Math.max(0, p));
311
+ }
app/src/game/fx/fx-wireframe.js ADDED
@@ -0,0 +1,323 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ // Holographic wireframe scan-up materialization effect.
2
+ //
3
+ // Sequence (~0.9 s, reversible):
4
+ // 1. An invisible scan height rises from the floor through the target;
5
+ // the boundary reads on the surface itself (bright wireframe band
6
+ // right under the scan height - no standalone disc geometry).
7
+ // 2. Below the scan line the target shows as a flickering additive
8
+ // wireframe hologram (scanline stripes, brightness boost near the
9
+ // scan line).
10
+ // 3. A second, fainter "solidify" line trails behind: fragments below
11
+ // it render the real PBR materials (world-Y clip injected via
12
+ // onBeforeCompile), with a hot edge right at the line. Once it
13
+ // clears the top the original materials are restored directly.
14
+ //
15
+ // Played backwards, the same timeline de-materializes (solid peel ->
16
+ // hologram -> gone). Used for the ball despawn; the duck only ever
17
+ // materializes.
18
+ //
19
+ // createWireframeFx() returns an independent instance (duck and ball
20
+ // scan at the same time without sharing uniforms). The module-level
21
+ // named exports wrap a singleton so demo-wireframe.html is unchanged.
22
+ //
23
+ // Instance interface:
24
+ // init({ THREE, scene, rig | root, camera, renderer, hidden? })
25
+ // start() / startReverse() / update(dt) / isDone() / restore() / dispose()
26
+ // Extra: setProgress(p), playing, reversing, TOTAL_S.
27
+
28
+ export const name = "wireframe";
29
+
30
+ // ── Timeline (seconds) ──────────────────────────────────────────────────
31
+ const SCAN_S = 0.6; // wireframe scan line: floor -> top
32
+ const SOLID_DELAY_S = 0.3; // solidify line starts this long after the scan
33
+ const SOLID_S = 0.6; // solidify line: floor -> top
34
+ export const TOTAL_S = SOLID_DELAY_S + SOLID_S; // 0.9
35
+
36
+ const clamp01 = (x) => Math.min(Math.max(x, 0), 1);
37
+ // Ease-out only: the rise starts at full speed the instant the scan cues
38
+ // (a smoothstep ease-in reads as a stall near the feet) and lands softly.
39
+ const ease = (x) => 1 - (1 - x) * (1 - x);
40
+
41
+ // Deterministic pseudo-random flicker so setProgress(p) captures are
42
+ // reproducible (no Math.random).
43
+ const hash = (x) => {
44
+ const s = Math.sin(x * 127.1) * 43758.5453;
45
+ return s - Math.floor(s);
46
+ };
47
+ const flickerAt = (time) => {
48
+ let f = 0.82 + 0.18 * hash(Math.floor(time * 60) + 0.5);
49
+ if (hash(Math.floor(time * 24) + 7.7) < 0.12) f *= 0.5; // dropouts
50
+ return f;
51
+ };
52
+
53
+ // Unique customProgramCacheKey values across every instance: a fresh
54
+ // clone of the same source material would otherwise reuse the compiled
55
+ // program without re-running onBeforeCompile, leaving clip uniforms
56
+ // unbound (see the dissolve comment in rl.js).
57
+ let clipNonce = 0;
58
+
59
+ const BOTTOM_PAD = 0.025;
60
+
61
+ export function createWireframeFx() {
62
+ // Per-instance uniforms: duck and ball must not share a scan height.
63
+ const uScanY = { value: -1e3 };
64
+ const uSolidY = { value: -1e3 };
65
+ const uFlicker = { value: 1 };
66
+ const uTime = { value: 0 };
67
+
68
+ let ctx = null; // { THREE, scene, root, camera, renderer }
69
+ let t = 0;
70
+ let dir = 1; // +1 materialize, -1 dematerialize
71
+ let playing = false;
72
+ let finished = false;
73
+
74
+ let minY = 0;
75
+ let spanY = 0.3;
76
+
77
+ const clipClones = new Map();
78
+ let clipSaved = null; // Array<[mesh, originalMat, cloneMat]> while active
79
+
80
+ let wireMat = null;
81
+ let wireMeshes = [];
82
+
83
+ const isTargetMesh = (o) => o.isMesh && !o.userData.fxOverlay;
84
+
85
+ function clipCloneFor(orig) {
86
+ let m = clipClones.get(orig.uuid);
87
+ if (m) return m;
88
+ m = orig.clone();
89
+ m.onBeforeCompile = (shader) => {
90
+ shader.uniforms.uFxSolidY = uSolidY;
91
+ shader.vertexShader = shader.vertexShader
92
+ .replace("#include <common>", "#include <common>\nvarying vec3 vFxW;")
93
+ .replace(
94
+ "#include <worldpos_vertex>",
95
+ "#include <worldpos_vertex>\nvFxW = (modelMatrix * vec4(transformed, 1.0)).xyz;",
96
+ );
97
+ shader.fragmentShader = shader.fragmentShader
98
+ .replace(
99
+ "#include <common>",
100
+ "#include <common>\nvarying vec3 vFxW;\nuniform float uFxSolidY;",
101
+ )
102
+ .replace(
103
+ "#include <clipping_planes_fragment>",
104
+ /* glsl */ `#include <clipping_planes_fragment>
105
+ float fxEdge = 0.0;
106
+ if (vFxW.y > uFxSolidY) discard;
107
+ fxEdge = 1.0 - smoothstep(0.002, 0.018, uFxSolidY - vFxW.y);`,
108
+ )
109
+ .replace(
110
+ "#include <dithering_fragment>",
111
+ /* glsl */ `#include <dithering_fragment>
112
+ gl_FragColor.rgb += fxEdge * vec3(0.95, 0.32, 0.05);`,
113
+ );
114
+ };
115
+ const key = `microduck-fx-wireframe-${clipNonce++}`;
116
+ m.customProgramCacheKey = () => key;
117
+ m.needsUpdate = true;
118
+ clipClones.set(orig.uuid, m);
119
+ return m;
120
+ }
121
+
122
+ function applyClipMaterials() {
123
+ if (clipSaved) return;
124
+ clipSaved = [];
125
+ ctx.root.traverse((o) => {
126
+ if (!isTargetMesh(o)) return;
127
+ const orig = o.material;
128
+ const clone = clipCloneFor(orig);
129
+ clipSaved.push([o, orig, clone]);
130
+ o.material = clone;
131
+ });
132
+ }
133
+
134
+ function restoreClipMaterials() {
135
+ if (!clipSaved) return;
136
+ for (const [mesh, orig, clone] of clipSaved) {
137
+ if (mesh.material === clone) mesh.material = orig;
138
+ }
139
+ clipSaved = null;
140
+ }
141
+
142
+ function makeWireMaterial(THREE) {
143
+ return new THREE.ShaderMaterial({
144
+ uniforms: { uScanY, uSolidY, uFlicker, uTime },
145
+ vertexShader: /* glsl */ `
146
+ varying vec3 vW;
147
+ void main() {
148
+ vec4 wp = modelMatrix * vec4(position, 1.0);
149
+ vW = wp.xyz;
150
+ gl_Position = projectionMatrix * viewMatrix * wp;
151
+ }`,
152
+ fragmentShader: /* glsl */ `
153
+ uniform float uScanY, uSolidY, uFlicker, uTime;
154
+ varying vec3 vW;
155
+ void main() {
156
+ if (vW.y > uScanY || vW.y < uSolidY) discard;
157
+ float lead = 1.0 - smoothstep(0.0, 0.06, uScanY - vW.y);
158
+ float tail = smoothstep(0.0, 0.018, vW.y - uSolidY);
159
+ float stripes = 0.7 + 0.3 * sin(vW.y * 900.0 - uTime * 45.0);
160
+ vec3 c = vec3(1.0, 0.34, 0.06) * (0.55 + 1.6 * lead);
161
+ float a = (0.10 + 0.40 * lead) * stripes * tail * uFlicker;
162
+ gl_FragColor = vec4(c, a);
163
+ }`,
164
+ wireframe: true,
165
+ transparent: true,
166
+ blending: THREE.AdditiveBlending,
167
+ depthWrite: false,
168
+ depthTest: true,
169
+ });
170
+ }
171
+
172
+ function buildWireOverlays(THREE, root) {
173
+ wireMat = makeWireMaterial(THREE);
174
+ root.traverse((o) => {
175
+ if (!isTargetMesh(o)) return;
176
+ // Child of the source mesh with identity transform so the overlay
177
+ // follows whatever pose the caller writes (duck joints or ball qpos).
178
+ // userData.fxWireGeometry substitutes a coarser geometry for the
179
+ // wireframe pass only (the ball's render sphere is too dense to read
180
+ // as a hologram); the solidify clip still runs on the real mesh.
181
+ const w = new THREE.Mesh(o.userData.fxWireGeometry ?? o.geometry, wireMat);
182
+ w.userData.fxOverlay = true;
183
+ w.renderOrder = 5;
184
+ w.visible = false;
185
+ o.add(w);
186
+ wireMeshes.push(w);
187
+ });
188
+ }
189
+
190
+ function applyAt(time) {
191
+ const scanP = ease(clamp01(time / SCAN_S));
192
+ const solidP = ease(clamp01((time - SOLID_DELAY_S) / SOLID_S));
193
+ uFlicker.value = flickerAt(time);
194
+ uTime.value = time;
195
+
196
+ const jitter = scanP > 0 && scanP < 1 ? (hash(time * 41.3) - 0.5) * 0.02 * spanY : 0;
197
+ uScanY.value = minY + spanY * scanP + jitter;
198
+ uSolidY.value = minY + spanY * solidP;
199
+
200
+ const wiresOn = time > 0 && solidP < 1;
201
+ for (const w of wireMeshes) w.visible = wiresOn;
202
+ }
203
+
204
+ function finish() {
205
+ // Forward: restore the real materials (fully solid). Reverse: leave
206
+ // the clip parked at t=0 (fully hidden) so restoring wouldn't flash
207
+ // the solid mesh for a frame; the caller hides the object then
208
+ // restore()s.
209
+ if (dir > 0) restoreClipMaterials();
210
+ else applyAt(0);
211
+ for (const w of wireMeshes) w.visible = false;
212
+ finished = true;
213
+ playing = false;
214
+ }
215
+
216
+ function computeRange() {
217
+ const { THREE, root } = ctx;
218
+ root.updateWorldMatrix(true, true);
219
+ const box = new THREE.Box3().setFromObject(root);
220
+ minY = Math.min(box.min.y, 0) - BOTTOM_PAD;
221
+ spanY = Math.max(box.max.y - minY, 0.04) * 1.06;
222
+ }
223
+
224
+ function arm(nextDir, resetT) {
225
+ dir = nextDir;
226
+ if (resetT) t = nextDir > 0 ? 0 : TOTAL_S;
227
+ finished = false;
228
+ computeRange();
229
+ applyClipMaterials();
230
+ applyAt(t);
231
+ playing = true;
232
+ }
233
+
234
+ function init({ THREE, scene, rig, root, camera, renderer, hidden = true }) {
235
+ const target = root ?? rig.placer;
236
+ ctx = { THREE, scene, root: target, camera, renderer };
237
+ computeRange();
238
+ buildWireOverlays(THREE, target);
239
+ t = 0;
240
+ dir = 1;
241
+ finished = false;
242
+ playing = false;
243
+ if (hidden) {
244
+ applyClipMaterials();
245
+ applyAt(0);
246
+ }
247
+ }
248
+
249
+ function start() {
250
+ // Mid-reverse: keep the current t and turn around. Fresh play: from 0.
251
+ arm(1, !playing);
252
+ }
253
+
254
+ function startReverse() {
255
+ arm(-1, !playing);
256
+ }
257
+
258
+ function update(dt) {
259
+ if (!playing || finished) return;
260
+ t += dir * dt;
261
+ if (dir > 0 && t >= TOTAL_S) {
262
+ t = TOTAL_S;
263
+ applyAt(t);
264
+ finish();
265
+ return;
266
+ }
267
+ if (dir < 0 && t <= 0) {
268
+ t = 0;
269
+ applyAt(t);
270
+ finish();
271
+ return;
272
+ }
273
+ applyAt(t);
274
+ }
275
+
276
+ function isDone() {
277
+ return finished;
278
+ }
279
+
280
+ function setProgress(p) {
281
+ playing = false;
282
+ finished = false;
283
+ dir = 1;
284
+ computeRange();
285
+ applyClipMaterials();
286
+ t = clamp01(p) * TOTAL_S;
287
+ applyAt(t);
288
+ }
289
+
290
+ function restore() {
291
+ restoreClipMaterials();
292
+ }
293
+
294
+ function dispose() {
295
+ restoreClipMaterials();
296
+ for (const w of wireMeshes) w.parent?.remove(w);
297
+ wireMeshes = [];
298
+ wireMat?.dispose();
299
+ wireMat = null;
300
+ ctx = null;
301
+ playing = false;
302
+ finished = false;
303
+ }
304
+
305
+ return {
306
+ init, start, startReverse, update, isDone, setProgress, restore, dispose,
307
+ get playing() { return playing; },
308
+ get reversing() { return playing && dir < 0; },
309
+ TOTAL_S,
310
+ };
311
+ }
312
+
313
+ // Singleton for the duck (and the isolated demo page). New callers that
314
+ // need a second scan (the ball) go through createWireframeFx().
315
+ const singleton = createWireframeFx();
316
+ export const init = (...a) => singleton.init(...a);
317
+ export const start = (...a) => singleton.start(...a);
318
+ export const startReverse = (...a) => singleton.startReverse(...a);
319
+ export const update = (...a) => singleton.update(...a);
320
+ export const isDone = (...a) => singleton.isDone(...a);
321
+ export const setProgress = (...a) => singleton.setProgress(...a);
322
+ export const restore = (...a) => singleton.restore(...a);
323
+ export const dispose = (...a) => singleton.dispose(...a);
app/src/game/game.js ADDED
@@ -0,0 +1,1305 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ // Microduck RL playground core: the REAL trained policies, not a procedural
2
+ // waddle. Framework-agnostic port of the pre-React rl.js.
3
+ //
4
+ // Physics runs in MuJoCo compiled to WebAssembly (the official
5
+ // @mujoco/mujoco bindings), stepping the same MJCF the policies were
6
+ // trained on (apirrone/mjlab_microduck). The controller is one of the
7
+ // exported ONNX checkpoints from apirrone/microduck_runtime, executed with
8
+ // onnxruntime-web at 50 Hz (timestep 0.005 s, decimation 4) - exactly the
9
+ // loop from mjlab_microduck/scripts/infer_policy.py.
10
+ //
11
+ // Obs layout (61D, "new-cmd-obs" flavor, from the ONNX metadata):
12
+ // [base_ang_vel(3), projected_gravity(3), joint_pos(14), joint_vel(14),
13
+ // last_action(14), command(13)]
14
+ //
15
+ // Integration contract with the React shell:
16
+ // - bootGame({ scene, camera, renderer }) is called once from inside the
17
+ // R3F canvas; it loads everything, wires inputs and starts the 50 Hz
18
+ // control loop.
19
+ // - frame(dt) is called by R3F's useFrame every animation frame; it does
20
+ // everything the old rAF loop did EXCEPT renderer.render (R3F renders).
21
+ // - UI state flows out through the zustand store (throttled), UI intents
22
+ // flow back in through gameApi.
23
+
24
+ import * as THREE from "three";
25
+ import { OrbitControls } from "three/addons/controls/OrbitControls.js";
26
+ import { GLTFLoader } from "three/addons/loaders/GLTFLoader.js";
27
+ import { signed } from "./signed.js";
28
+ import {
29
+ POLICIES, JOINT_NAMES, DEFAULT_POSE, NUM_JOINTS, OBS_SIZE, CMD_SIZE,
30
+ ACTION_SCALE, TIMESTEP, DECIMATION, CTRL_DT,
31
+ VEL_FWD, VEL_BACK, VEL_ANG, RVEL_FWD, RVEL_BACK, RVEL_ANG,
32
+ CROUCH_PERIOD_S, CROUCH_END_PHASE,
33
+ BALL_RADIUS, BALL_PARK_POS, ARENA_HALF, SPAWN_X, SPAWN_Y,
34
+ } from "./constants.js";
35
+ import {
36
+ buildRig, cloneRig, loadKinematics, setJoint, setJawOpen, MODEL_DIR, MESH_VERSION,
37
+ } from "./duck.js";
38
+ import {
39
+ VARIANTS, VARIANT_NAMES, materialHookFor, DEFAULT_VARIANT, applyVariant, specToHex,
40
+ } from "./variants.js";
41
+ import { Controller } from "./controls/controller.js";
42
+ import { KeyboardSource } from "./controls/keyboard.js";
43
+ import { GamepadSource } from "./controls/gamepad.js";
44
+ import { TouchSource } from "./controls/touch.js";
45
+ import * as fx from "./fx/fx-wireframe.js";
46
+ import { createCeremony, CAM_RESET_S } from "./ceremony.js";
47
+ import { createBallActor } from "./ball-actor.js";
48
+ import { initGhosts } from "./ghosts.js";
49
+ import { makeInfiniteGrid, makeArenaWalls } from "./arena.js";
50
+ import { createBallVisual } from "./ball-visual.js";
51
+ import { useGame, gameApi, bootLine, bootNote, bootHalt } from "../store.js";
52
+
53
+ // Physics + inference runtimes stay on the CDN, exactly like the pre-Vite
54
+ // app: mujoco.js resolves its .wasm sidecar relative to its own URL, and
55
+ // onnxruntime fetches its wasm from wasmPaths - neither ever touches the
56
+ // bundle. @vite-ignore keeps Rollup's static analysis out of it.
57
+ const MUJOCO_URL = "https://cdn.jsdelivr.net/npm/@mujoco/mujoco@3.11.0/mujoco.js";
58
+ const ORT_URL = "https://cdn.jsdelivr.net/npm/onnxruntime-web@1.27.0/dist/ort.min.mjs";
59
+
60
+ // Colour dot shown per variant in the quickbar. Variants can force theirs
61
+ // with a `swatch` spec (purple does: its head is warm gray but its
62
+ // identity is the purple accents).
63
+ const SWATCH_SLOT = { classic: "feet", charcoal: "headDome", purple: "feet", blue: "facePlate" };
64
+ export const VARIANT_SWATCHES = Object.fromEntries(
65
+ VARIANT_NAMES.map((name) => {
66
+ const v = VARIANTS[name];
67
+ return [name, specToHex(v.swatch ?? v[SWATCH_SLOT[name] ?? "bodyShell"])];
68
+ }),
69
+ );
70
+
71
+ let bootStarted = false;
72
+
73
+ export async function bootGame({ scene, camera, renderer }) {
74
+ if (bootStarted) return;
75
+ bootStarted = true;
76
+ try {
77
+ await boot({ scene, camera, renderer });
78
+ } catch (err) {
79
+ console.error("[game] boot failed", err);
80
+ bootHalt(err?.message || String(err));
81
+ }
82
+ }
83
+
84
+ async function boot({ scene, camera, renderer }) {
85
+ const setStore = useGame.setState;
86
+ const store = useGame.getState;
87
+
88
+ bootNote("Microduck BIOS v1.0");
89
+ bootLine("MEMORY CHECK")("640K OK");
90
+ bootLine("DUCK FIRMWARE")("PRESENT");
91
+
92
+ // Surface async boot failures in the BIOS halt screen. Gated on the boot
93
+ // still being in flight: post-boot async noise (ghost relay hiccups,
94
+ // audio autoplay rejections...) must NOT cue the halt screen.
95
+ const bootGuard = (e, msg) => {
96
+ if (!store().bootDone && !store().bootFailed) bootHalt(msg);
97
+ };
98
+ window.addEventListener("unhandledrejection", (e) => {
99
+ console.error("[game] unhandled rejection", e.reason);
100
+ bootGuard(e, e.reason?.message || String(e.reason));
101
+ });
102
+ window.addEventListener("error", (e) => {
103
+ console.error("[game] window error", e.message);
104
+ bootGuard(e, e.message);
105
+ });
106
+
107
+ // Halting at the failure site: a rejected await inside this async boot
108
+ // would otherwise only surface through the caller's catch.
109
+ const traced = (label, p) => {
110
+ const done = bootLine(label);
111
+ return p.then(
112
+ (v) => { done("OK"); return v; },
113
+ (err) => {
114
+ done("FAILED");
115
+ console.error(`[game] ${label} FAILED`, err);
116
+ bootHalt(err?.message || String(err));
117
+ throw err;
118
+ },
119
+ );
120
+ };
121
+
122
+ // ── Runtimes (CDN) ──────────────────────────────────────────────────
123
+ const [{ default: loadMujocoFactory }, ort] = await traced(
124
+ "RUNTIME MODULES",
125
+ Promise.all([
126
+ import(/* @vite-ignore */ MUJOCO_URL),
127
+ import(/* @vite-ignore */ ORT_URL),
128
+ ]),
129
+ );
130
+ ort.env.wasm.wasmPaths = "https://cdn.jsdelivr.net/npm/onnxruntime-web@1.27.0/dist/";
131
+ ort.env.wasm.numThreads = 1; // static hosting sends no COOP/COEP headers
132
+
133
+ // ── MJCF preparation ────────────────────────────────────────────────
134
+ // robot_allcollisions.xml is what infer_policy.py's scene.xml includes:
135
+ // it carries body/shell collision geoms that robot_walk.xml lacks, which
136
+ // the sitstand policy needs (a sit rests the trunk on the ground).
137
+ // Visual meshes are irrelevant to the dynamics: every body carries an
138
+ // explicit <inertial>, and visual geoms have contype=0 conaffinity=0.
139
+ // Stripping them means the MuJoCo VFS only needs the ~10 meshes
140
+ // referenced by collision geoms. Works for both variants.
141
+ async function buildPhysicsXml(xmlFile) {
142
+ const src = await (await fetch(signed(`${MODEL_DIR}/${xmlFile}`))).text();
143
+ const doc = new DOMParser().parseFromString(src, "text/xml");
144
+ for (const g of [...doc.querySelectorAll('geom[class="visual"]')]) g.remove();
145
+ const usedMeshes = new Set(
146
+ [...doc.querySelectorAll("geom[mesh]")].map((g) => g.getAttribute("mesh")),
147
+ );
148
+ for (const m of [...doc.querySelectorAll("asset > mesh")]) {
149
+ const name = m.getAttribute("name") ?? m.getAttribute("file").replace(/\.stl$/i, "");
150
+ if (!usedMeshes.has(name)) m.remove();
151
+ }
152
+ const root = doc.documentElement;
153
+ const el = (tag, attrs) => {
154
+ const e = doc.createElement(tag);
155
+ for (const [k, v] of Object.entries(attrs)) e.setAttribute(k, v);
156
+ return e;
157
+ };
158
+ root.appendChild(el("option", { timestep: String(TIMESTEP) }));
159
+ doc.querySelector("worldbody").appendChild(
160
+ el("geom", { name: "floor", type: "plane", size: "0 0 0.05", pos: "0 0 0" }),
161
+ );
162
+ // Arena walls: four static boxes (no joints, so no qpos/keyframe
163
+ // impact); default contype/conaffinity collides with ball and duck.
164
+ const ht = 0.05 / 2, hh = 0.25 / 2;
165
+ const off = ARENA_HALF + ht, span = ARENA_HALF + 0.05;
166
+ const walls = [
167
+ { name: "wall_px", pos: `${off} 0 ${hh}`, size: `${ht} ${span} ${hh}` },
168
+ { name: "wall_nx", pos: `${-off} 0 ${hh}`, size: `${ht} ${span} ${hh}` },
169
+ { name: "wall_py", pos: `0 ${off} ${hh}`, size: `${span} ${ht} ${hh}` },
170
+ { name: "wall_ny", pos: `0 ${-off} ${hh}`, size: `${span} ${ht} ${hh}` },
171
+ ];
172
+ for (const w of walls) {
173
+ doc.querySelector("worldbody").appendChild(
174
+ el("geom", { name: w.name, type: "box", pos: w.pos, size: w.size }),
175
+ );
176
+ }
177
+ // Kickable ball: a light free sphere (beach-ball feel). MuJoCo has no
178
+ // restitution parameter - the bounce comes from solref damping < 1, and
179
+ // the rolling-friction term makes it come to rest. Appended AFTER the
180
+ // robot body so the trunk freejoint stays first in qpos.
181
+ const ballBody = el("body", { name: "ball", pos: BALL_PARK_POS });
182
+ ballBody.appendChild(el("freejoint", { name: "ball_freejoint" }));
183
+ // condim 6 enables the torsional + rolling friction components; with
184
+ // the default condim 3 a rolling ball never decelerates.
185
+ ballBody.appendChild(el("geom", {
186
+ name: "ball_geom", type: "sphere", size: String(BALL_RADIUS),
187
+ mass: "0.03", friction: "0.4 0.01 0.003", solref: "0.03 0.4", condim: "6",
188
+ }));
189
+ doc.querySelector("worldbody").appendChild(ballBody);
190
+ // STAND keyframe from mjlab's scene_walk.xml. qpos must cover every
191
+ // joint in document order: the 14 actuated hinges take DEFAULT_POSE by
192
+ // name, anything else (the roller variant's passive wheels) starts at
193
+ // zero. The ball's 7 free-joint values MUST be appended or nq won't
194
+ // match; parked 50 m away = effectively absent.
195
+ const qposFree = `${SPAWN_X} ${SPAWN_Y} 0.12 1 0 0 0`;
196
+ const poseByName = new Map(JOINT_NAMES.map((n, i) => [n, DEFAULT_POSE[i]]));
197
+ const qposJoints = [...doc.querySelectorAll("body > joint")]
198
+ .map((j) => poseByName.get(j.getAttribute("name")) ?? 0)
199
+ .join(" ");
200
+ const pose14 = Array.from(DEFAULT_POSE).join(" ");
201
+ const kf = doc.createElement("keyframe");
202
+ kf.appendChild(el("key", {
203
+ name: "STAND",
204
+ qpos: `${qposFree} ${qposJoints} ${BALL_PARK_POS} 1 0 0 0`,
205
+ ctrl: pose14,
206
+ }));
207
+ root.appendChild(kf);
208
+ const meshFiles = [...doc.querySelectorAll("asset > mesh")].map((m) => m.getAttribute("file"));
209
+ return { xml: new XMLSerializer().serializeToString(doc), meshFiles };
210
+ }
211
+
212
+ // ── Boot physics + policy in parallel with the render rig ────────────
213
+ const [mujoco, { xml, meshFiles }, k] = await Promise.all([
214
+ traced("MUJOCO WASM", loadMujocoFactory()),
215
+ traced("PHYSICS MJCF", buildPhysicsXml("robot_allcollisions.xml")),
216
+ traced("KINEMATICS", loadKinematics(`${MODEL_DIR}/kinematics.json`)),
217
+ ]);
218
+
219
+ const doneMeshes = bootLine("MESH ASSETS");
220
+ const vfs = new mujoco.MjVFS();
221
+ // One shared VFS for both variants; already-loaded files are skipped so
222
+ // the roller lazy-load only fetches its 5 new meshes.
223
+ const vfsFiles = new Set();
224
+ async function addMeshesToVfs(files) {
225
+ await Promise.all(
226
+ files.map(async (f) => {
227
+ if (vfsFiles.has(f)) return;
228
+ vfsFiles.add(f);
229
+ // Same cache-busted URL as duck.js so the browser reuses the render
230
+ // meshes instead of downloading the collision subset a second time.
231
+ const buf = await (await fetch(signed(`${MODEL_DIR}/meshes/${f}?v=${MESH_VERSION}`), { cache: "force-cache" })).arrayBuffer();
232
+ // meshdir="assets" in the MJCF, so the compiler looks up "assets/<f>".
233
+ vfs.addBuffer(`assets/${f}`, new Uint8Array(buf));
234
+ }),
235
+ );
236
+ }
237
+ try {
238
+ await addMeshesToVfs(meshFiles);
239
+ } catch (err) {
240
+ doneMeshes("FAILED");
241
+ bootHalt(err?.message || String(err));
242
+ throw err;
243
+ }
244
+ doneMeshes(`${meshFiles.length} FILES`);
245
+
246
+ const sessions = {};
247
+ // Always boot on the classic (orange) colourway; the quickbar re-skins live.
248
+ let currentVariant = DEFAULT_VARIANT;
249
+ const rigPromise = (async () => {
250
+ const doneRig = bootLine("RENDER RIG");
251
+ try {
252
+ const builtRig = await buildRig(k, { materialForMesh: materialHookFor(VARIANTS[currentVariant]) });
253
+ doneRig("OK");
254
+ return builtRig;
255
+ } catch (err) {
256
+ doneRig("FAILED");
257
+ bootHalt(err?.message || String(err));
258
+ throw err;
259
+ }
260
+ })();
261
+ // Boot policies with a live [n/5] counter on the BIOS line.
262
+ const donePolicies = bootLine("LOADING POLICIES");
263
+ const sessionOpts = { executionProviders: ["wasm"] };
264
+ let policiesLoaded = 0;
265
+ const bootPolicy = (url) =>
266
+ ort.InferenceSession.create(signed(url), sessionOpts).then((s) => {
267
+ donePolicies.progress(`${++policiesLoaded}/5`);
268
+ return s;
269
+ });
270
+ try {
271
+ [sessions.walk, sessions.sitstand, sessions.roll, sessions.kickL, sessions.kickR] =
272
+ await Promise.all([
273
+ bootPolicy(POLICIES.walk),
274
+ bootPolicy(POLICIES.sitstand),
275
+ bootPolicy(POLICIES.roll),
276
+ bootPolicy(POLICIES.kickL),
277
+ bootPolicy(POLICIES.kickR),
278
+ ]);
279
+ } catch (err) {
280
+ donePolicies("FAILED");
281
+ bootHalt(err?.message || String(err));
282
+ throw err;
283
+ }
284
+ donePolicies("5/5");
285
+
286
+ const doneCompile = bootLine("COMPILING PHYSICS");
287
+ let model, data;
288
+ try {
289
+ model = mujoco.MjModel.from_xml_string(xml, vfs);
290
+ data = new mujoco.MjData(model);
291
+ } catch (err) {
292
+ doneCompile("FAILED");
293
+ bootHalt(err?.message || String(err));
294
+ throw err;
295
+ }
296
+ doneCompile("COMPILED");
297
+
298
+ // Addresses resolved once per compiled variant. qpos/qvel/sensordata
299
+ // views are re-read at each use: the WASM heap can grow and detach
300
+ // earlier TypedArray views.
301
+ const JOINT_SET = new Set(JOINT_NAMES);
302
+ function resolveAddrs(model, kin) {
303
+ return {
304
+ qposAdr: JOINT_NAMES.map((n) => model.jnt(n).qposadr),
305
+ dofAdr: JOINT_NAMES.map((n) => model.jnt(n).dofadr),
306
+ gyroAdr: model.sensor("imu_ang_vel").adr,
307
+ trunkId: mujoco.mj_name2id(model, mujoco.mjtObj.mjOBJ_BODY.value, "trunk_base"),
308
+ standKeyId: mujoco.mj_name2id(model, mujoco.mjtObj.mjOBJ_KEY.value, "STAND"),
309
+ ballQposAdr: model.jnt("ball_freejoint").qposadr,
310
+ ballDofAdr: model.jnt("ball_freejoint").dofadr,
311
+ // Unactuated hinges (the roller variant's 4 passive wheels): not in
312
+ // the obs or ctrl, but synced to the render rig so the wheels spin.
313
+ extraJoints: kin.bodies
314
+ .filter((b) => b.joint && b.joint.type === "hinge" && !JOINT_SET.has(b.joint.name))
315
+ .map((b) => ({ name: b.joint.name, adr: model.jnt(b.joint.name).qposadr })),
316
+ };
317
+ }
318
+ // Active-variant address block, swapped wholesale by activateLoco.
319
+ let { qposAdr, dofAdr, gyroAdr, trunkId, standKeyId, ballQposAdr, ballDofAdr, extraJoints } =
320
+ resolveAddrs(model, k);
321
+
322
+ // Locomotion variants stay resident once built (model + data + rig +
323
+ // addresses); legs is registered when its render rig resolves below.
324
+ const locos = {};
325
+ let loco = "legs"; // "legs" | "rollers"
326
+ const velLims = () => (loco === "rollers"
327
+ ? [RVEL_FWD, RVEL_BACK, RVEL_ANG]
328
+ : [VEL_FWD, VEL_BACK, VEL_ANG]);
329
+
330
+ const lastAction = new Float32Array(NUM_JOINTS);
331
+ const obs = new Float32Array(OBS_SIZE);
332
+ const cmd = new Float32Array(CMD_SIZE); // [vx, vy, wz, head(4), body(6)]
333
+ // Input controller: keyboard + gamepad + touch sources merged into one
334
+ // continuous command + discrete action surface, in priority order.
335
+ const kbSource = new KeyboardSource({ getVelocityLimits: () => velLims() });
336
+ const padSource = new GamepadSource({ getVelocityLimits: () => velLims() });
337
+ const touchSource = new TouchSource({ getVelocityLimits: () => velLims() });
338
+ // Keyboard last: it reads zero when idle, so it doubles as the fallback.
339
+ const controller = new Controller({ sources: [padSource, touchSource, kbSource] });
340
+ // Right-stick camera state, read by the telemetry before the camera-orbit
341
+ // section below has evaluated.
342
+ let padOrbitLive = false;
343
+ // Robot input gate: twist commands, mode changes, rolls, kicks and ball
344
+ // spawns all stay inert until the entrance sequence has fully played out.
345
+ let inputLocked = true;
346
+ let ceremony = null;
347
+ let ball = null;
348
+ let stickers = null; // comic popups, currently disabled
349
+
350
+ let mode = "walk"; // "walk" | "sitstand" | "roll" | "kickL" | "kickR" | "crouch"
351
+ let sitFlag = 0;
352
+ const isKick = () => mode === "kickL" || mode === "kickR";
353
+ // Local-only kickable ball: false while parked at the keyframe spot
354
+ // (mesh hidden), true once popped in front of the duck.
355
+ let ballActive = false;
356
+
357
+ // The twist the policy actually receives. Mid-roll every movement input
358
+ // is ignored (zero twist) until the roll hands back to walk on its own.
359
+ const ZERO_CMD = new Float32Array(3);
360
+ function effectiveCmd() {
361
+ if (inputLocked || mode === "roll" || mode === "crouch" || isKick() || postKickLock > 0)
362
+ return ZERO_CMD;
363
+ return controller.getCommand();
364
+ }
365
+ let rollRun = null;
366
+ let crouchRun = null;
367
+ let kickRun = null;
368
+ let KICK_STEPS = 25;
369
+ // Post-kick grace: keep commands zeroed for a beat after the kick window
370
+ // hands back to walk. Step-counted like everything else.
371
+ const POST_KICK_LOCK_STEPS = 20; // 0.4 s at 50 Hz
372
+ let postKickLock = 0;
373
+
374
+ // Pending mode-transition timers (sit hand-over, stand-up hand-back).
375
+ let sitTimer = null;
376
+ let standTimer = null;
377
+ let fallenSince = null;
378
+ function clearModeTimers() {
379
+ clearTimeout(sitTimer); sitTimer = null;
380
+ clearTimeout(standTimer); standTimer = null;
381
+ }
382
+
383
+ function resetSim() {
384
+ // Single reset path: Space, fall-kill, failed roll, loco switch.
385
+ clearModeTimers();
386
+ rollRun = null;
387
+ kickRun = null;
388
+ crouchRun = null;
389
+ postKickLock = 0;
390
+ fallenSince = null;
391
+ mode = "walk";
392
+ mujoco.mj_resetDataKeyframe(model, data, standKeyId);
393
+ mujoco.mj_forward(model, data);
394
+ lastAction.fill(0);
395
+ sitFlag = 0;
396
+ // Park the ball in physics immediately; if it was on screen, the
397
+ // reverse scan peels it away at its last pose. A queued B-respawn is
398
+ // cancelled: a reset means no ball.
399
+ ball?.despawn({ cancelQueued: true, parkPhysics: parkBallPhysics });
400
+ ballActive = false;
401
+ syncButtons();
402
+ ceremony?.playRespawn();
403
+ }
404
+ resetSim();
405
+
406
+ function parkBallPhysics() {
407
+ const qpos = data.qpos, qvel = data.qvel;
408
+ qpos[ballQposAdr] = 50;
409
+ qpos[ballQposAdr + 1] = 0;
410
+ qpos[ballQposAdr + 2] = BALL_RADIUS;
411
+ qpos[ballQposAdr + 3] = 1;
412
+ qpos[ballQposAdr + 4] = 0;
413
+ qpos[ballQposAdr + 5] = 0;
414
+ qpos[ballQposAdr + 6] = 0;
415
+ for (let i = 0; i < 6; i++) qvel[ballDofAdr + i] = 0;
416
+ mujoco.mj_forward(model, data);
417
+ ballActive = false;
418
+ }
419
+
420
+ // Pop / respawn the ball ~0.35 m in front of the duck, with a small
421
+ // random heading + distance jitter. If the ball is already on screen,
422
+ // peel it away first (reverse scan) and pop the new one when that
423
+ // finishes - same appear/disappear pair as the duck's wireframe ceremony.
424
+ function spawnBall(opts = {}) {
425
+ if (inputLocked && !opts.fromQueue) return;
426
+ if (!ball) return;
427
+ if (ball.visual !== "hidden") {
428
+ ball.queueRespawn();
429
+ ball.despawn({ parkPhysics: parkBallPhysics });
430
+ return;
431
+ }
432
+ const qpos = data.qpos, qvel = data.qvel;
433
+ const yaw = Math.atan2(
434
+ 2 * (qpos[3] * qpos[6] + qpos[4] * qpos[5]),
435
+ 1 - 2 * (qpos[5] * qpos[5] + qpos[6] * qpos[6]),
436
+ );
437
+ const heading = yaw + (Math.random() - 0.5) * 0.7;
438
+ const dist = 0.35 + (Math.random() - 0.5) * 0.1;
439
+ const lim = ARENA_HALF - BALL_RADIUS - 0.05;
440
+ const clamp = (v) => Math.min(lim, Math.max(-lim, v));
441
+ qpos[ballQposAdr] = clamp(qpos[0] + Math.cos(heading) * dist);
442
+ qpos[ballQposAdr + 1] = clamp(qpos[1] + Math.sin(heading) * dist);
443
+ qpos[ballQposAdr + 2] = BALL_RADIUS + 0.02;
444
+ qpos[ballQposAdr + 3] = 1;
445
+ qpos[ballQposAdr + 4] = 0;
446
+ qpos[ballQposAdr + 5] = 0;
447
+ qpos[ballQposAdr + 6] = 0;
448
+ for (let i = 0; i < 6; i++) qvel[ballDofAdr + i] = 0;
449
+ mujoco.mj_forward(model, data);
450
+ ballActive = true;
451
+ // Snap the mesh to the new pose BEFORE the scan starts: the FX
452
+ // recomputes its bbox from the live mesh.
453
+ ball.poseFromQpos(qpos, ballQposAdr);
454
+ ball.appear();
455
+ stickers?.pop("spawn");
456
+ }
457
+
458
+ // ── Observation ─────────────────────────────────────────────────────
459
+ const _q = new THREE.Quaternion();
460
+ const _g = new THREE.Vector3();
461
+
462
+ function buildObs() {
463
+ const qpos = data.qpos, qvel = data.qvel, sens = data.sensordata;
464
+ let i = 0;
465
+ for (let a = 0; a < 3; a++) obs[i++] = sens[gyroAdr + a];
466
+ // projected gravity: world -z rotated into the trunk frame
467
+ const xq = data.body(trunkId).xquat; // [w, x, y, z]
468
+ _q.set(xq[1], xq[2], xq[3], xq[0]).conjugate();
469
+ _g.set(0, 0, -1).applyQuaternion(_q);
470
+ obs[i++] = _g.x; obs[i++] = _g.y; obs[i++] = _g.z;
471
+ for (let j = 0; j < NUM_JOINTS; j++) obs[i++] = qpos[qposAdr[j]] - DEFAULT_POSE[j];
472
+ for (let j = 0; j < NUM_JOINTS; j++) obs[i++] = qvel[dofAdr[j]];
473
+ for (let j = 0; j < NUM_JOINTS; j++) obs[i++] = lastAction[j];
474
+ // command: walking/drive use the twist; sitstand uses cmd[0] as the
475
+ // posture flag; the crouch-glide one-shot carries its phase encoding in
476
+ // the vel slots (ground-pick convention: [cos, sin, 0]).
477
+ cmd.fill(0, 0, 3);
478
+ if (mode === "sitstand") {
479
+ cmd[0] = sitFlag;
480
+ } else if (mode === "crouch" && crouchRun) {
481
+ const a = 2 * Math.PI * crouchRun.phase;
482
+ cmd[0] = Math.cos(a);
483
+ cmd[1] = Math.sin(a);
484
+ } else {
485
+ const c = effectiveCmd();
486
+ cmd[0] = c[0]; cmd[1] = c[1]; cmd[2] = c[2];
487
+ }
488
+ for (let c = 0; c < CMD_SIZE; c++) obs[i++] = cmd[c];
489
+ return obs;
490
+ }
491
+
492
+ // The ONNX session for the current mode: in the roller variant the main
493
+ // velocity mode runs the drive (skating) policy instead of the walker.
494
+ const activeSession = () =>
495
+ sessions[loco === "rollers" && mode === "walk" ? "drive" : mode];
496
+
497
+ // ── Control loop (50 Hz, async because ONNX inference is async) ──────
498
+ let ctrlHz = 0;
499
+
500
+ // Dead pose: walk/sitstand have no get-up skill, so a kill here is just
501
+ // a resetSim. "fallen" = trunk tilted past ~60 deg or sunk below the
502
+ // floor. NaN/Inf is a solver explosion: no grace, reset on the spot.
503
+ function poseIsDead() {
504
+ const z = data.qpos[2];
505
+ const gz = obs[5]; // projected gravity z, from the last obs
506
+ if (!Number.isFinite(z) || !Number.isFinite(gz)) return "exploded";
507
+ if (gz > -0.5 || z < 0.02) return "fallen";
508
+ return null;
509
+ }
510
+
511
+ async function controlStep() {
512
+ const feeds = { obs: new ort.Tensor("float32", buildObs(), [1, OBS_SIZE]) };
513
+ const out = await activeSession().run(feeds);
514
+ const act = out.actions.data;
515
+ lastAction.set(act);
516
+ const ctrl = data.ctrl;
517
+ for (let j = 0; j < NUM_JOINTS; j++) ctrl[j] = DEFAULT_POSE[j] + act[j] * ACTION_SCALE;
518
+ for (let s = 0; s < DECIMATION; s++) mujoco.mj_step(model, data);
519
+
520
+ const death = poseIsDead();
521
+ if (death === "exploded") {
522
+ resetSim();
523
+ } else if (death === "fallen") {
524
+ const now = performance.now();
525
+ const graceMs = mode === "roll" ? 5000 : 1000;
526
+ fallenSince ??= now;
527
+ if (now - fallenSince > graceMs) resetSim();
528
+ } else {
529
+ fallenSince = null;
530
+ }
531
+
532
+ // Ball respawn watchdog: outside the arena bounds means "escaped
533
+ // through a solver glitch", bring it back near the duck.
534
+ if (ballActive) {
535
+ const q = data.qpos;
536
+ const escaped =
537
+ Math.abs(q[ballQposAdr]) > ARENA_HALF + 0.1 ||
538
+ Math.abs(q[ballQposAdr + 1]) > ARENA_HALF + 0.1;
539
+ if (escaped) spawnBall();
540
+ }
541
+
542
+ if (postKickLock > 0 && mode === "walk") postKickLock--;
543
+
544
+ // One-shot kick: fixed 0.5 s window like the robot runtime, then
545
+ // straight back to walking. lastAction is NOT zeroed on either swap.
546
+ if (isKick() && kickRun) {
547
+ kickRun.steps++;
548
+ if (kickRun.steps >= KICK_STEPS) {
549
+ kickRun = null;
550
+ mode = "walk";
551
+ postKickLock = POST_KICK_LOCK_STEPS;
552
+ syncButtons();
553
+ }
554
+ }
555
+
556
+ // Crouch-glide one-shot: advance the trained phase clock and hand back
557
+ // to the drive policy at the runtime's cycle end.
558
+ if (mode === "crouch" && crouchRun) {
559
+ crouchRun.phase += CTRL_DT / CROUCH_PERIOD_S;
560
+ if (crouchRun.phase >= CROUCH_END_PHASE) {
561
+ crouchRun = null;
562
+ mode = "walk";
563
+ syncButtons();
564
+ }
565
+ }
566
+
567
+ // One-shot roll, step-counted like the robot runtime: hand back to
568
+ // walking once the trunk has tipped over and is upright again, or
569
+ // after a hard window if the roll never initiated.
570
+ if (mode === "roll" && rollRun) {
571
+ rollRun.steps++;
572
+ if (obs[5] > -0.3) rollRun.tipped = true;
573
+ const upright = obs[5] < -0.85;
574
+ const done = rollRun.tipped && upright && rollRun.steps >= 40;
575
+ const expired = rollRun.steps >= 150; // 3 s, roll should long be over
576
+ if (done || expired) {
577
+ rollRun = null;
578
+ mode = "walk";
579
+ lastAction.fill(0);
580
+ // Timed out mid-roll: don't hand a tipped duck to the walking
581
+ // policy (it has no get-up skill).
582
+ if (!upright) resetSim();
583
+ syncButtons();
584
+ }
585
+ }
586
+ }
587
+
588
+ let running = true;
589
+ (async function controlLoop() {
590
+ let next = performance.now();
591
+ let count = 0, hzT0 = next;
592
+ while (running) {
593
+ await controlStep();
594
+ count++;
595
+ const now = performance.now();
596
+ if (now - hzT0 > 500) {
597
+ ctrlHz = (count * 1000) / (now - hzT0);
598
+ count = 0; hzT0 = now;
599
+ }
600
+ next += CTRL_DT * 1000;
601
+ const wait = next - performance.now();
602
+ if (wait > 0) await new Promise((r) => setTimeout(r, wait));
603
+ else next = performance.now(); // fell behind: don't spiral
604
+ }
605
+ })();
606
+
607
+ // ── Scene wiring (grid, walls, rig, ball, jukebox) ───────────────────
608
+ // The grid/walls carry ceremony-driven uReveal uniforms and per-frame
609
+ // focus updates, so the game owns them; lights and environment live in
610
+ // the R3F layer.
611
+ const grid = makeInfiniteGrid();
612
+ scene.add(grid);
613
+ const { wallMats, wallMeshes } = makeArenaWalls();
614
+ for (const m of wallMeshes) scene.add(m);
615
+
616
+ let rig = await rigPromise;
617
+ scene.add(rig.placer);
618
+ let trunkGroup = rig.bodies.get("trunk_base");
619
+ locos.legs = {
620
+ model, data, rig, trunkGroup,
621
+ qposAdr, dofAdr, gyroAdr, trunkId, standKeyId, ballQposAdr, ballDofAdr, extraJoints,
622
+ };
623
+
624
+ // ── Locomotion variant switching (legs <-> rollers) ──────────────────
625
+ // The roller stack (XML + 5 extra meshes + kinematics + 2 ONNX policies)
626
+ // is lazy-loaded on the first switch, then kept resident.
627
+ let rollersLoading = null;
628
+ function ensureRollers() {
629
+ rollersLoading ??= (async () => {
630
+ const [{ xml: rXml, meshFiles: rMeshFiles }, rk] = await Promise.all([
631
+ buildPhysicsXml("robot_allcollisions_rollers.xml"),
632
+ loadKinematics(`${MODEL_DIR}/kinematics_rollers.json`),
633
+ ]);
634
+ const [rRig, sDrive, sCrouch] = await Promise.all([
635
+ buildRig(rk, { materialForMesh: materialHookFor(VARIANTS[currentVariant]) }),
636
+ ort.InferenceSession.create(signed(POLICIES.drive), sessionOpts),
637
+ ort.InferenceSession.create(signed(POLICIES.crouch), sessionOpts),
638
+ addMeshesToVfs(rMeshFiles),
639
+ ]);
640
+ sessions.drive = sDrive;
641
+ sessions.crouch = sCrouch;
642
+ const rModel = mujoco.MjModel.from_xml_string(rXml, vfs);
643
+ const rData = new mujoco.MjData(rModel);
644
+ locos.rollers = {
645
+ model: rModel, data: rData, rig: rRig, trunkGroup: rRig.bodies.get("trunk_base"),
646
+ ...resolveAddrs(rModel, rk),
647
+ };
648
+ })();
649
+ return rollersLoading;
650
+ }
651
+
652
+ function activateLoco(name) {
653
+ const L = locos[name];
654
+ loco = name;
655
+ scene.remove(rig.placer);
656
+ ({ model, data, rig, trunkGroup, qposAdr, dofAdr, gyroAdr, trunkId,
657
+ standKeyId, ballQposAdr, ballDofAdr, extraJoints } = L);
658
+ // The rig may have been built (or last shown) under another colourway.
659
+ applyVariant(rig, currentVariant);
660
+ scene.add(rig.placer);
661
+ setStore({ loco: name });
662
+ resetSim();
663
+ }
664
+
665
+ let locoSwitching = false;
666
+ async function setLoco(name, { force = false } = {}) {
667
+ if (name !== "legs" && name !== "rollers") return;
668
+ if (loco === name || locoSwitching) return;
669
+ if (!force && (inputLocked || rollRun || kickRun || crouchRun || standTimer)) return;
670
+ locoSwitching = true;
671
+ setStore({ locoSwitching: true });
672
+ try {
673
+ if (name === "rollers" && !locos.rollers) {
674
+ setStore({ rollersLoading: true });
675
+ await ensureRollers();
676
+ }
677
+ activateLoco(name);
678
+ } catch (e) {
679
+ rollersLoading = null;
680
+ console.error("[game] roller switch failed", e);
681
+ } finally {
682
+ setStore({ rollersLoading: false, locoSwitching: false });
683
+ locoSwitching = false;
684
+ }
685
+ }
686
+
687
+ async function toggleLoco() {
688
+ const next = loco === "legs" ? "rollers" : "legs";
689
+ setStore({ locoWant: next });
690
+ await setLoco(next);
691
+ }
692
+
693
+ // Quickbar loco intent: reconcile locoWant -> actual, retrying until the
694
+ // game allows the switch (mid-roll, respawn ceremony, ...). Replaces the
695
+ // old index.html reconciler that polled window.rl.
696
+ let locoReconciler = null;
697
+ function reconcileLoco() {
698
+ const want = store().locoWant;
699
+ if (want === loco) {
700
+ if (locoReconciler) { clearInterval(locoReconciler); locoReconciler = null; }
701
+ return;
702
+ }
703
+ if (want === "rollers") ensureRollers().catch(() => {});
704
+ if (!locoSwitching) setLoco(want);
705
+ locoReconciler ??= setInterval(reconcileLoco, 250);
706
+ }
707
+ useGame.subscribe((s) => s.locoWant, reconcileLoco);
708
+
709
+ // ── Cutscenes (entrance + respawn) ──────────────────────────────────
710
+ ceremony = createCeremony({
711
+ THREE, scene, camera, renderer, fx,
712
+ getRig: () => rig,
713
+ grid, wallMats,
714
+ syncRig, startCameraReset,
715
+ setLocked: (v) => {
716
+ inputLocked = v;
717
+ controller.setLocked(v);
718
+ // A ball is always in play: pop one the moment the entrance or a
719
+ // respawn ceremony hands control back.
720
+ if (!v && ball && !ballActive) spawnBall({ fromQueue: true });
721
+ },
722
+ flashReset: () => {},
723
+ });
724
+
725
+ const { group: ballGroup, mesh: ballMesh } = createBallVisual(renderer);
726
+ scene.add(ballGroup);
727
+ ball = createBallActor({
728
+ THREE, scene, camera, renderer, fxModule: fx, mesh: ballMesh, group: ballGroup,
729
+ });
730
+
731
+ // ── Jukebox prop (decorative corner dressing, no physics) ────────────
732
+ // assets/props/jukebox.glb carries two meshes exported from Blender: the
733
+ // ~39k-tri render mesh and a 500-tri "jukebox_wire" stand-in used only by
734
+ // the hologram pass (same fxWireGeometry escape hatch as the ball). It
735
+ // materializes with the duck's ceremony, staggered a beat behind, and
736
+ // replays on every respawn.
737
+ const JUKEBOX_H = 0.42; // target height, m
738
+ const JUKEBOX_MARGIN = 0.24; // center distance from each wall inner face
739
+ let jukeboxGroup = null;
740
+ try {
741
+ const gltf = await new GLTFLoader().loadAsync(
742
+ signed(`./assets/props/jukebox.glb?v=1`),
743
+ );
744
+ const jukebox = gltf.scene;
745
+ const jukeWire = jukebox.getObjectByName("jukebox_wire");
746
+ jukeWire.removeFromParent();
747
+ const jukeBox3 = new THREE.Box3().setFromObject(jukebox);
748
+ const jukeScale = JUKEBOX_H / (jukeBox3.max.y - jukeBox3.min.y);
749
+ jukebox.scale.setScalar(jukeScale);
750
+ jukebox.position.y = -jukeBox3.min.y * jukeScale;
751
+ jukebox.traverse((o) => {
752
+ if (o.isMesh) o.userData.fxWireGeometry = jukeWire.geometry;
753
+ });
754
+ const jukeGroup = new THREE.Group();
755
+ jukeGroup.add(jukebox);
756
+ // Back corner, behind the spawn (the duck faces +X), angled toward the
757
+ // center of the arena so the front panel reads from the play area.
758
+ jukeGroup.position.set(
759
+ -(ARENA_HALF - JUKEBOX_MARGIN), 0, -(ARENA_HALF - JUKEBOX_MARGIN),
760
+ );
761
+ jukeGroup.rotation.y = Math.PI / 4;
762
+ scene.add(jukeGroup);
763
+ jukeboxGroup = jukeGroup;
764
+ const jukeFx = fx.createWireframeFx();
765
+ jukeFx.init({ THREE, scene, root: jukeGroup, camera, renderer, hidden: true });
766
+ ceremony.addPropFx(jukeFx, 0.25);
767
+ } catch (err) {
768
+ // Decorative only: a missing/broken GLB must never halt the boot.
769
+ console.warn("[game] jukebox prop disabled:", err);
770
+ }
771
+
772
+ // ── Camera: orbit controls + chase cam + reset glide ─────────────────
773
+ const controls = new OrbitControls(camera, renderer.domElement);
774
+ controls.target.set(SPAWN_X, 0, -SPAWN_Y); // orbit around the spawn cell
775
+ controls.enableDamping = true;
776
+ controls.dampingFactor = 0.08;
777
+ controls.minDistance = 0.25;
778
+ controls.maxDistance = 3;
779
+ controls.maxPolarAngle = Math.PI / 2 - 0.03;
780
+
781
+ // Chase cam (default ON): each frame the camera eases toward a point
782
+ // behind the duck's heading at the current orbit distance, while the
783
+ // orbit target keeps easing to the trunk in syncRig. Implemented by
784
+ // overwriting camera.position AFTER controls.update() so we never fight
785
+ // OrbitControls' own spherical bookkeeping.
786
+ let chaseCam = true;
787
+ const CHASE_PITCH = 0.42; // rad above horizontal, keeps the floor in view
788
+ const CHASE_EASE = 0.05;
789
+ const _chasePos = new THREE.Vector3();
790
+ const _chaseDir = new THREE.Vector3();
791
+ // During one-shot rolls and kicks the trunk tumbles: hold the last
792
+ // healthy yaw for the whole one-shot.
793
+ let chaseHeldYaw = 0;
794
+ // Heading hysteresis (Schmitt trigger): the walking gait wiggles the
795
+ // trunk yaw ~±14 deg per step; two-layer EMA + engage/release thresholds
796
+ // keep the camera steady while walking straight but responsive on turns.
797
+ let chaseYawSmooth = 0;
798
+ let chaseYawFollow = 0;
799
+ let chaseYawTracking = false;
800
+ const CHASE_YAW_SMOOTH_EASE = 0.04;
801
+ const CHASE_YAW_ENGAGE = 0.17;
802
+ const CHASE_YAW_RELEASE = 0.03;
803
+ const CHASE_YAW_EASE = 0.10;
804
+ const CHASE_YAW_EASE_TURN = 0.5;
805
+ const wrapPi = (a) => Math.atan2(Math.sin(a), Math.cos(a));
806
+ function updateChaseCam() {
807
+ // Reset glide: one clean tween from wherever the camera is back to the
808
+ // home framing. Runs instead of the chase logic and hands control back
809
+ // to it on landing.
810
+ if (camResetT0 !== null) {
811
+ if (!chaseCam) { camResetT0 = null; return; }
812
+ const t = (performance.now() - camResetT0) / 1000 / CAM_RESET_S;
813
+ const e = t >= 1 ? 1 : t < 0.5 ? 4 * t * t * t : 1 - Math.pow(-2 * t + 2, 3) / 2;
814
+ camera.position.lerpVectors(_camFrom, _camTo, e);
815
+ controls.target.lerpVectors(_tgtFrom, _tgtTo, e);
816
+ camera.lookAt(controls.target);
817
+ if (t >= 1) camResetT0 = null;
818
+ return;
819
+ }
820
+ if (!chaseCam) return;
821
+ const qpos = data.qpos;
822
+ let rawYaw;
823
+ if (mode === "roll" || isKick()) {
824
+ rawYaw = chaseHeldYaw;
825
+ } else {
826
+ rawYaw = Math.atan2(
827
+ 2 * (qpos[3] * qpos[6] + qpos[4] * qpos[5]),
828
+ 1 - 2 * (qpos[5] * qpos[5] + qpos[6] * qpos[6]),
829
+ );
830
+ chaseHeldYaw = rawYaw;
831
+ }
832
+ // "turning" reads the raw per-source wz commands (not the locked/merged
833
+ // view) so an intentional turn engages on the first frame.
834
+ const turning = controller.sources.some((s) => Math.abs(s.command[2]) > 0.05);
835
+ chaseYawSmooth = wrapPi(
836
+ chaseYawSmooth +
837
+ wrapPi(rawYaw - chaseYawSmooth) * (turning ? CHASE_YAW_EASE_TURN : CHASE_YAW_SMOOTH_EASE),
838
+ );
839
+ const yawErr = wrapPi(chaseYawSmooth - chaseYawFollow);
840
+ if (turning || Math.abs(yawErr) > CHASE_YAW_ENGAGE) chaseYawTracking = true;
841
+ if (chaseYawTracking) {
842
+ chaseYawFollow = wrapPi(
843
+ chaseYawFollow + yawErr * (turning ? CHASE_YAW_EASE_TURN : CHASE_YAW_EASE),
844
+ );
845
+ if (!turning && Math.abs(yawErr) < CHASE_YAW_RELEASE) chaseYawTracking = false;
846
+ }
847
+ const yaw = chaseYawFollow;
848
+ const dist = camera.position.distanceTo(controls.target);
849
+ const horiz = dist * Math.cos(CHASE_PITCH);
850
+ const vert = dist * Math.sin(CHASE_PITCH);
851
+ // Duck forward in MJCF is (cos yaw, sin yaw, 0); Z-up -> Y-up maps it
852
+ // to three-space (cos yaw, 0, -sin yaw). Behind = minus that.
853
+ _chasePos.set(
854
+ controls.target.x - Math.cos(yaw) * horiz,
855
+ controls.target.y + vert,
856
+ controls.target.z + Math.sin(yaw) * horiz,
857
+ );
858
+ camera.position.lerp(_chasePos, CHASE_EASE);
859
+ // Re-project onto the orbit sphere: lerping between two points at the
860
+ // same radius cuts the chord, which would slowly zoom the camera in
861
+ // during large swings.
862
+ _chaseDir.copy(camera.position).sub(controls.target);
863
+ const len = _chaseDir.length();
864
+ if (len > 1e-6) camera.position.copy(controls.target).addScaledVector(_chaseDir, dist / len);
865
+ camera.lookAt(controls.target);
866
+ }
867
+ renderer.domElement.addEventListener("pointerdown", () => { chaseCam = false; });
868
+
869
+ // Camera reset glide (owned by the respawn ceremony): back to the
870
+ // page-load framing - the chase cam's ideal point behind the duck's
871
+ // spawn heading, at the boot orbit distance.
872
+ const CAM_HOME_DIST = camera.position.distanceTo(controls.target);
873
+ let camResetT0 = null;
874
+ const _camFrom = new THREE.Vector3(), _camTo = new THREE.Vector3();
875
+ const _tgtFrom = new THREE.Vector3(), _tgtTo = new THREE.Vector3();
876
+ function startCameraReset() {
877
+ const qpos = data.qpos;
878
+ const yaw = Math.atan2(
879
+ 2 * (qpos[3] * qpos[6] + qpos[4] * qpos[5]),
880
+ 1 - 2 * (qpos[5] * qpos[5] + qpos[6] * qpos[6]),
881
+ );
882
+ chaseHeldYaw = yaw;
883
+ chaseYawSmooth = yaw;
884
+ chaseYawFollow = yaw;
885
+ chaseYawTracking = false;
886
+ _tgtTo.set(qpos[0], qpos[2], -qpos[1]); // trunk at spawn, MJCF -> three
887
+ const horiz = CAM_HOME_DIST * Math.cos(CHASE_PITCH);
888
+ const vert = CAM_HOME_DIST * Math.sin(CHASE_PITCH);
889
+ _camTo.set(
890
+ _tgtTo.x - Math.cos(yaw) * horiz,
891
+ _tgtTo.y + vert,
892
+ _tgtTo.z + Math.sin(yaw) * horiz,
893
+ );
894
+ _camFrom.copy(camera.position);
895
+ _tgtFrom.copy(controls.target);
896
+ camResetT0 = performance.now();
897
+ chaseCam = true; // reset always re-attaches the chase cam
898
+ }
899
+
900
+ // Pause: while the menu is up over a live game, keys belong to the menu.
901
+ const setInputLock = (v) => { inputLocked = v; controller.setLocked(v); };
902
+ useGame.subscribe(
903
+ (s) => s.menuOpen,
904
+ (open) => {
905
+ if (!ceremony.entranceDone) return;
906
+ if (open) setInputLock(true);
907
+ else if (!ceremony.respawnActive) setInputLock(false);
908
+ },
909
+ );
910
+
911
+ // The rig's root already applies the MJCF Z-up -> three Y-up fix, so the
912
+ // trunk group can take the freejoint pose in raw MJCF coordinates.
913
+ const _target = new THREE.Vector3();
914
+ const _follow = new THREE.Vector3();
915
+ function syncRig() {
916
+ const qpos = data.qpos;
917
+ trunkGroup.position.set(qpos[0], qpos[1], qpos[2]);
918
+ trunkGroup.quaternion.set(qpos[4], qpos[5], qpos[6], qpos[3]);
919
+ for (let j = 0; j < NUM_JOINTS; j++) setJoint(rig, JOINT_NAMES[j], qpos[qposAdr[j]]);
920
+ // Passive hinges (roller wheels): purely visual, driven straight from qpos.
921
+ for (const ej of extraJoints) setJoint(rig, ej.name, qpos[ej.adr]);
922
+ // Ball: live follows qpos; ghost freeze is owned by the ball actor.
923
+ if (ball) ball.sync(qpos, ballQposAdr, ballActive);
924
+ // Follow cam: ease the orbit target toward the trunk and translate the
925
+ // camera by the same delta, so the camera-to-duck distance and viewing
926
+ // angle stay constant while the duck walks. Paused while the reset
927
+ // glide owns the camera.
928
+ if (camResetT0 === null) {
929
+ _target.set(qpos[0], qpos[2], -qpos[1]);
930
+ _follow.copy(_target).sub(controls.target);
931
+ // Horizontal follow at the usual rate; vertical much slower so the
932
+ // per-step gait bob doesn't nod the frame.
933
+ _follow.x *= 0.06;
934
+ _follow.z *= 0.06;
935
+ _follow.y *= 0.015;
936
+ controls.target.add(_follow);
937
+ camera.position.add(_follow);
938
+ }
939
+ // Keep the grid plane (and its fade center) under the action; the wall
940
+ // grids share the same radial fade focus.
941
+ grid.position.set(controls.target.x, 0, controls.target.z);
942
+ grid.material.uniforms.uFocus.value.copy(controls.target);
943
+ for (const m of wallMats) m.uniforms.uFocus.value.copy(controls.target);
944
+ }
945
+
946
+ // ── Quack: jaw + chirp ─────────────────────────────────────���──────────
947
+ // The jaw isn't a MuJoCo joint (duck.js re-creates the hinge in JS), so
948
+ // this is purely cosmetic and can't upset the policy. Voice banks from
949
+ // the robot runtime: each colourway gets its own bank and every quack
950
+ // draws a random chirp take from it.
951
+ const QUACK_MS = 480;
952
+ let quackAt = -Infinity;
953
+ let padJaw = 0;
954
+ const CHIRP_TAKES = "abcdefghijkl";
955
+ const VOICE_BANK = { classic: "duck1", charcoal: "duck2", purple: "duck3", blue: "duck4" };
956
+ const chirpCache = new Map();
957
+ function playChirp() {
958
+ const bank = VOICE_BANK[currentVariant] ?? "duck1";
959
+ const take = CHIRP_TAKES[(Math.random() * CHIRP_TAKES.length) | 0];
960
+ const url = signed(`./assets/voices/${bank}/chirp_${take}.wav`);
961
+ let a = chirpCache.get(url);
962
+ if (!a) {
963
+ a = new Audio(url);
964
+ a.volume = 0.7;
965
+ chirpCache.set(url, a);
966
+ }
967
+ a.currentTime = 0;
968
+ a.play().catch(() => {});
969
+ }
970
+ const quackLoud = () => {
971
+ quackAt = performance.now();
972
+ playChirp();
973
+ stickers?.pop("quack");
974
+ };
975
+ function jawOpenNow() {
976
+ const t = (performance.now() - quackAt) / QUACK_MS;
977
+ const flap = t >= 0 && t < 1 ? Math.sin(Math.PI * t) : 0;
978
+ return Math.max(flap, padJaw);
979
+ }
980
+ function syncJaw() {
981
+ setJawOpen(rig, jawOpenNow());
982
+ }
983
+
984
+ // ── Telemetry (throttled into the store) ─────────────────────────────
985
+ // FPS EMA is per-frame; the store write is 4 Hz so React re-renders
986
+ // stay far away from frame rate. Odometer integrates horizontal trunk
987
+ // travel; teleport-sized jumps (resets, loco swaps) don't count.
988
+ let fpsEma = 60;
989
+ let fpsLastT = performance.now();
990
+ let odoM = 0;
991
+ let odoX = null, odoY = null;
992
+ let telemetryLastPush = 0;
993
+ function renderTelemetry() {
994
+ const now = performance.now();
995
+ const dtF = (now - fpsLastT) / 1000;
996
+ fpsLastT = now;
997
+ if (dtF > 0 && dtF < 0.5) fpsEma += (1 / dtF - fpsEma) * 0.05;
998
+ const stepD = (odoX === null) ? 0 : Math.hypot(data.qpos[0] - odoX, data.qpos[1] - odoY);
999
+ if (stepD < 0.05) odoM += stepD; // plausible per-frame travel only
1000
+ odoX = data.qpos[0];
1001
+ odoY = data.qpos[1];
1002
+ if (now - telemetryLastPush < 250) return;
1003
+ telemetryLastPush = now;
1004
+ setStore({
1005
+ telemetry: {
1006
+ fps: Math.round(fpsEma),
1007
+ ctrlHz: Math.round(ctrlHz),
1008
+ speed: Math.hypot(data.qvel[0], data.qvel[1]),
1009
+ odo: odoM,
1010
+ peers: ghosts?.peerCount() ?? 0,
1011
+ },
1012
+ });
1013
+ }
1014
+
1015
+ // ── Right-stick camera orbit (inertia downstream of the controller) ──
1016
+ // The stick steers an angular VELOCITY that eases toward the stick's
1017
+ // target rate, so pushing ramps up gently and releasing coasts to a stop
1018
+ // over ~0.3 s. Vertical is flight-style inverted.
1019
+ const PAD_ORBIT_SPEED = 2.4; // rad/s at full deflection
1020
+ const PAD_ORBIT_SMOOTH = 8; // 1/s response rate (~95% in 0.37 s)
1021
+ const padOrbitVel = { az: 0, el: 0 };
1022
+ const _padSph = new THREE.Spherical();
1023
+ const _padOff = new THREE.Vector3();
1024
+ function padOrbitStep(rx, ry, dt) {
1025
+ padOrbitLive = rx !== 0 || ry !== 0;
1026
+ if (padOrbitLive) chaseCam = false; // detach, same as a mouse grab
1027
+ const k = 1 - Math.exp(-PAD_ORBIT_SMOOTH * dt);
1028
+ padOrbitVel.az += (rx * PAD_ORBIT_SPEED - padOrbitVel.az) * k;
1029
+ padOrbitVel.el += (-ry * PAD_ORBIT_SPEED * 0.75 - padOrbitVel.el) * k;
1030
+ if (chaseCam) { padOrbitVel.az = 0; padOrbitVel.el = 0; return; }
1031
+ if (Math.abs(padOrbitVel.az) < 1e-3 && Math.abs(padOrbitVel.el) < 1e-3) return;
1032
+ _padOff.copy(camera.position).sub(controls.target);
1033
+ _padSph.setFromVector3(_padOff);
1034
+ _padSph.theta -= padOrbitVel.az * dt;
1035
+ _padSph.phi += padOrbitVel.el * dt;
1036
+ _padSph.phi = Math.min(controls.maxPolarAngle, Math.max(0.08, _padSph.phi));
1037
+ _padSph.makeSafe();
1038
+ camera.position.setFromSpherical(_padSph).add(controls.target);
1039
+ camera.lookAt(controls.target);
1040
+ }
1041
+
1042
+ // Multiplayer ghosts, initialised asynchronously at the end of the boot.
1043
+ let ghosts = null;
1044
+
1045
+ // ── Per-frame drive, called by R3F's useFrame ────────────────────────
1046
+ let padWasConnected = null;
1047
+ let touchWasConnected = null;
1048
+ function frame(dt) {
1049
+ controller.update(dt);
1050
+ padJaw = controller.getAxes().jaw;
1051
+ if (padSource.connected !== padWasConnected) {
1052
+ padWasConnected = padSource.connected;
1053
+ setStore({ padConnected: padSource.connected });
1054
+ }
1055
+ if (touchSource.connected !== touchWasConnected) {
1056
+ touchWasConnected = touchSource.connected;
1057
+ setStore({ touchMode: touchSource.connected });
1058
+ }
1059
+ // Camera orbit runs every frame while a pad is present (the coasting
1060
+ // needs the zero-deflection frames too); without a pad, park the state.
1061
+ if (padSource.connected) {
1062
+ padOrbitStep(controller.getAxes().orbitX, controller.getAxes().orbitY, dt);
1063
+ } else {
1064
+ padOrbitLive = false;
1065
+ padOrbitVel.az = 0;
1066
+ padOrbitVel.el = 0;
1067
+ }
1068
+ syncRig();
1069
+ syncJaw();
1070
+ ghosts?.update();
1071
+ controls.update();
1072
+ updateChaseCam();
1073
+ ceremony.drive();
1074
+ ball.drive(() => spawnBall({ fromQueue: true }));
1075
+ renderTelemetry();
1076
+ }
1077
+
1078
+ // ── Input wiring: arm the controller sources, bind actions ───────────
1079
+ controller.init();
1080
+
1081
+ // Keyboard F alternates kicking feet; only advance the alternation on
1082
+ // kicks that actually launched (triggerKick reports that).
1083
+ let kbKickFoot = "left";
1084
+ const srcTag = (source) => (source === "gamepad" ? "pad" : "kb");
1085
+
1086
+ controller.on("reset", () => resetSim());
1087
+ controller.on("spawnBall", () => spawnBall());
1088
+ controller.on("chaseToggle", () => { chaseCam = !chaseCam; });
1089
+ controller.on("locoToggle", () => toggleLoco());
1090
+ controller.on("roll", ({ source }) => triggerRoll(srcTag(source)));
1091
+ controller.on("kickL", ({ source }) => triggerKick("left", srcTag(source)));
1092
+ controller.on("kickR", ({ source }) => triggerKick("right", srcTag(source)));
1093
+ controller.on("alternateKick", ({ source }) => {
1094
+ if (triggerKick(kbKickFoot, srcTag(source))) {
1095
+ kbKickFoot = kbKickFoot === "left" ? "right" : "left";
1096
+ }
1097
+ });
1098
+ controller.on("sitToggle", () => {
1099
+ if (loco !== "legs") return; // sitting is a legs-only skill
1100
+ const sitting = mode === "sitstand" && sitFlag === 1;
1101
+ setMode(sitting ? "walk" : "sit");
1102
+ });
1103
+ // Pad DpadUp short press: straight back to running (ignored mid-roll /
1104
+ // mid-crouch: those hand back to walk on their own).
1105
+ controller.on("walk", () => {
1106
+ if (mode !== "walk" && mode !== "roll" && mode !== "crouch") setMode("walk");
1107
+ });
1108
+ controller.on("quack", () => quackLoud());
1109
+
1110
+ function setMode(next, { force = false } = {}) {
1111
+ if (!force && inputLocked) return;
1112
+ // No policy switching mid-roll or mid-kick: both end on their own and
1113
+ // return to walk - switching now would floor the duck.
1114
+ if ((mode === "roll" && rollRun) || (isKick() && kickRun) || (mode === "crouch" && crouchRun)) return;
1115
+ if (next === "sit" && loco === "rollers") return;
1116
+ clearModeTimers();
1117
+ rollRun = null;
1118
+ crouchRun = null;
1119
+ if (next !== "sit") {
1120
+ // Leaving a sit: let the sitstand policy stand the duck back up first.
1121
+ if (mode === "sitstand" && sitFlag === 1) {
1122
+ sitFlag = 0;
1123
+ standTimer = setTimeout(() => {
1124
+ standTimer = null;
1125
+ mode = next;
1126
+ lastAction.fill(0);
1127
+ syncButtons();
1128
+ }, 2000);
1129
+ syncButtons();
1130
+ return;
1131
+ }
1132
+ mode = next;
1133
+ lastAction.fill(0);
1134
+ } else {
1135
+ // Hand over gently: hold the stand under the sitstand policy for a
1136
+ // moment before commanding the sit, or the abrupt session switch
1137
+ // knocks the duck over.
1138
+ mode = "sitstand";
1139
+ sitFlag = 0;
1140
+ lastAction.fill(0);
1141
+ sitTimer = setTimeout(() => {
1142
+ sitTimer = null;
1143
+ if (mode === "sitstand") { sitFlag = 1; syncButtons(); }
1144
+ }, 800);
1145
+ }
1146
+ syncButtons();
1147
+ }
1148
+
1149
+ // One roll, then straight back to running. lastAction is deliberately
1150
+ // NOT zeroed: the runtime keeps one continuous action history across
1151
+ // policy switches, and the roll initiates more reliably mid-gait.
1152
+ function triggerRoll(source = "kb") {
1153
+ if (loco === "rollers") return triggerCrouch(source);
1154
+ if (inputLocked || mode !== "walk" || standTimer) return;
1155
+ clearModeTimers();
1156
+ mode = "roll";
1157
+ sitFlag = 0;
1158
+ rollRun = { steps: 0, tipped: false };
1159
+ syncButtons();
1160
+ stickers?.pop("roll");
1161
+ }
1162
+
1163
+ // Roller-only one-shot: crouch, glide low, stand back up (phase-driven).
1164
+ function triggerCrouch(source = "kb") {
1165
+ if (inputLocked || mode !== "walk" || locoSwitching) return;
1166
+ clearModeTimers();
1167
+ mode = "crouch";
1168
+ crouchRun = { phase: 0 };
1169
+ syncButtons();
1170
+ stickers?.pop("roll");
1171
+ }
1172
+
1173
+ // One blind kick (the duck can't see any ball - it's a scripted boot).
1174
+ // Returns whether the kick actually launched so the keyboard's foot
1175
+ // alternation only advances on real kicks.
1176
+ function triggerKick(foot, source = "kb") {
1177
+ if (loco === "rollers") return false;
1178
+ if (inputLocked || mode !== "walk" || standTimer) return false;
1179
+ clearModeTimers();
1180
+ mode = foot === "left" ? "kickL" : "kickR";
1181
+ sitFlag = 0;
1182
+ kickRun = { steps: 0 };
1183
+ syncButtons();
1184
+ stickers?.pop("kick");
1185
+ return true;
1186
+ }
1187
+
1188
+ function syncButtons() {
1189
+ const sitting = mode === "sitstand" && sitFlag === 1;
1190
+ const label =
1191
+ mode === "roll" ? "Roll"
1192
+ : mode === "crouch" ? "Crouch"
1193
+ : isKick() ? "Kick"
1194
+ : sitting ? "Sit"
1195
+ : loco === "rollers" ? "Drive"
1196
+ : "Run";
1197
+ if (store().modeLabel !== label) setStore({ modeLabel: label });
1198
+ if (store().ballActive !== ballActive) setStore({ ballActive });
1199
+ }
1200
+
1201
+ // ── Public surface for the React UI ──────────────────────────────────
1202
+ Object.assign(gameApi, {
1203
+ frame,
1204
+ setVariant: (name) => {
1205
+ if (!VARIANTS[name] || name === currentVariant) return;
1206
+ currentVariant = name;
1207
+ applyVariant(rig, name);
1208
+ setStore({ variant: name });
1209
+ },
1210
+ requestLoco: (name) => {
1211
+ if (name !== "legs" && name !== "rollers") return;
1212
+ setStore({ locoWant: name });
1213
+ reconcileLoco();
1214
+ },
1215
+ resetSim,
1216
+ spawnBall: () => spawnBall(),
1217
+ startEntrance: () => ceremony.startEntrance(),
1218
+ });
1219
+
1220
+ // Deterministic hooks for automated verification (rAF pauses in
1221
+ // background tabs, and the control loop is async).
1222
+ window.rl = {
1223
+ get model() { return model; },
1224
+ get data() { return data; },
1225
+ mujoco, camera, controls,
1226
+ get mode() { return mode; },
1227
+ get sitFlag() { return sitFlag; },
1228
+ buildObs, cmd,
1229
+ velCmd: kbSource.command, lastAction, resetSim,
1230
+ controller, kbSource, padSource,
1231
+ spawnBall, triggerKick, triggerRoll, sessions, ort,
1232
+ get loco() { return loco; },
1233
+ get locoSwitching() { return locoSwitching; },
1234
+ toggleLoco, setLoco, ensureRollers,
1235
+ triggerCrouch,
1236
+ get crouchPhase() { return crouchRun?.phase ?? null; },
1237
+ get kickSteps() { return KICK_STEPS; },
1238
+ set kickSteps(v) { KICK_STEPS = v; },
1239
+ get ballActive() { return ballActive; },
1240
+ get ballQposAdr() { return ballQposAdr; },
1241
+ get chaseCam() { return chaseCam; },
1242
+ set chaseCam(v) { chaseCam = !!v; },
1243
+ get jukebox() { return jukeboxGroup; },
1244
+ get camResetActive() { return camResetT0 !== null; },
1245
+ get respawnActive() { return ceremony?.respawnActive ?? false; },
1246
+ get camPose() {
1247
+ return {
1248
+ pos: camera.position.toArray(),
1249
+ target: controls.target.toArray(),
1250
+ };
1251
+ },
1252
+ get chaseYaw() { return { follow: chaseYawFollow, smooth: chaseYawSmooth, held: chaseHeldYaw, tracking: chaseYawTracking }; },
1253
+ padOrbitStep,
1254
+ jawOpenNow,
1255
+ step: async (n = 1) => { for (let i = 0; i < n; i++) await controlStep(); },
1256
+ render: () => { syncRig(); renderer.render(scene, camera); },
1257
+ frame: (dt = 1 / 60) => frame(dt),
1258
+ get ghosts() { return ghosts; },
1259
+ get inputLocked() { return inputLocked; },
1260
+ entrance: {
1261
+ start: () => ceremony.startEntrance(),
1262
+ setReveal: (floor, wall) => ceremony.setReveal(floor, wall),
1263
+ setFx: (p) => ceremony.setFx(p),
1264
+ },
1265
+ };
1266
+
1267
+ // Boot complete: the sim/HUD go live immediately. The BIOS readout (if
1268
+ // the user already waddled in, or when they do) sees bootDone and closes
1269
+ // with READY. + fade on its own.
1270
+ setStore({ bootDone: true });
1271
+
1272
+ // ── Multiplayer ghosts (WebRTC, serverless signaling) ────────────────
1273
+ // Broadcast this duck's pose and render up to 3 other visitors live as
1274
+ // translucent ducks. Fire-and-forget: any failure just means no ghosts.
1275
+ const r3 = (x) => Math.round(x * 1000) / 1000;
1276
+ try {
1277
+ // Ghosts only join once the entrance has fully played: the world (and
1278
+ // this duck) must stay hidden until then, translucent peers included.
1279
+ await ceremony.entranceFinished;
1280
+ ghosts = await initGhosts({
1281
+ scene, rig, cloneRig, setJoint, setJawOpen, applyVariant,
1282
+ jointNames: JOINT_NAMES,
1283
+ // Ghost rig per locomotion flag: peers in roller mode clone the
1284
+ // roller rig once this tab has built it, and fall back to the leg
1285
+ // rig until then. Known v1 limitation, documented in the README.
1286
+ getRigFor: (l) => (l && locos.rollers ? locos.rollers.rig : locos.legs.rig),
1287
+ getLocalState: () => {
1288
+ const qpos = data.qpos;
1289
+ const j = new Array(NUM_JOINTS);
1290
+ for (let i = 0; i < NUM_JOINTS; i++) j[i] = r3(qpos[qposAdr[i]]);
1291
+ return {
1292
+ p: [r3(qpos[0]), r3(qpos[1]), r3(qpos[2]), r3(qpos[3]), r3(qpos[4]), r3(qpos[5]), r3(qpos[6])],
1293
+ j,
1294
+ w: r3(jawOpenNow()),
1295
+ v: currentVariant,
1296
+ l: loco === "rollers" ? 1 : 0,
1297
+ };
1298
+ },
1299
+ });
1300
+ if (ghosts.room) ghosts.room.onPeerJoin = () => stickers?.pop("hi");
1301
+ } catch (e) {
1302
+ window.__ghostErr = String((e && e.stack) || e);
1303
+ console.warn("ghosts disabled:", e);
1304
+ }
1305
+ }
app/src/game/ghosts.js ADDED
@@ -0,0 +1,288 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ // Live multiplayer ghosts: every open tab of the sandbox broadcasts its
2
+ // duck state over WebRTC and renders the other visitors as translucent
3
+ // ducks. There is no backend - this is a static Space - so peer discovery
4
+ // uses Trystero's serverless signaling over public Nostr relays. Payloads
5
+ // are tiny (22 floats + a variant name at SEND_HZ), and at most MAX_GHOSTS
6
+ // ghosts are instantiated regardless of how many peers are in the room.
7
+ //
8
+ // This module deliberately imports nothing from duck.js / variants.js:
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;
18
+ // Snapshot interpolation: each ghost renders INTERP_DELAY_MS in the past,
19
+ // lerping between the two buffered snapshots that bracket the render time
20
+ // (timed by local ARRIVAL, so no clock sync and no payload change - old
21
+ // clients interoperate). This replaces the old exponential chase toward
22
+ // the latest packet, whose move-then-stall rhythm read as micro-stutter.
23
+ // The delay absorbs network jitter; past the newest snapshot the ghost
24
+ // holds pose, so a background-throttled 1 Hz sender degrades into slow
25
+ // discrete steps instead of teleports. Nobody synchronizes on ghosts, so
26
+ // the added latency is invisible.
27
+ const INTERP_DELAY_MS = 150;
28
+ const BUF_MAX = 40; // snapshots kept per ghost (~2.5 s at 15 Hz)
29
+ // Idle visitors stay invisible: a ghost is only shown once its peer has
30
+ // strayed from the pose of its first state message (everyone spawns at
31
+ // the arena center). Once revealed it stays visible for the whole peer
32
+ // session, so a reset teleporting the duck back to center doesn't blink
33
+ // the ghost out.
34
+ const REVEAL_DIST = 0.2; // m, horizontal distance from spawn
35
+ const REVEAL_YAW = 0.4; // rad (~23deg), turning in place also reveals
36
+
37
+ // state.p is raw MuJoCo qpos[0..6]: [x, y, z, qw, qx, qy, qz], Z-up.
38
+ const yawOf = (p) =>
39
+ Math.atan2(2 * (p[3] * p[6] + p[4] * p[5]), 1 - 2 * (p[5] * p[5] + p[6] * p[6]));
40
+ const hasMoved = (spawn, p) => {
41
+ const dx = p[0] - spawn.x, dy = p[1] - spawn.y;
42
+ if (dx * dx + dy * dy > REVEAL_DIST * REVEAL_DIST) return true;
43
+ const dyaw = yawOf(p) - spawn.yaw;
44
+ return Math.abs(Math.atan2(Math.sin(dyaw), Math.cos(dyaw))) > REVEAL_YAW;
45
+ };
46
+
47
+ export async function initGhosts(env) {
48
+ const noop = { update() {}, peerCount: () => 0, ghostCount: () => 0, debug: () => [], mapDots: () => [] };
49
+ let room;
50
+ try {
51
+ const { joinRoom } = await import(/* @vite-ignore */ TRYSTERO_URL);
52
+ room = joinRoom({ appId: APP_ID }, ROOM);
53
+ } catch (e) {
54
+ console.warn("ghosts disabled (signaling unavailable):", e);
55
+ return noop;
56
+ }
57
+
58
+ const { scene, cloneRig, setJoint, setJawOpen, applyVariant, jointNames, getLocalState } = env;
59
+ // Locomotion-variant rig source: state.l 1 = rollers, 0/absent = legs
60
+ // (old clients never send it). Falls back to the leg rig when the local
61
+ // tab hasn't built the roller rig yet - known v1 limitation.
62
+ const rigFor = (l) => (env.getRigFor ? env.getRigFor(l ?? 0) : env.rig);
63
+ // trystero 0.25 (backed by @trystero-p2p): makeAction returns
64
+ // { send, onMessage, onReceiveProgress } where onMessage is a SETTER -
65
+ // the receive handler is registered by assignment, not by calling it.
66
+ const act = room.makeAction("s");
67
+ // send() rejects when a peer's channel drops mid-transfer; that peer will
68
+ // be swept anyway, so failures are non-events.
69
+ const sendState = (data) => act.send(data).catch(() => {});
70
+
71
+ // Ghosts share geometry with the local rig (cloneRig), but get their own
72
+ // transparent material clones so repainting them never touches the
73
+ // visitor's own duck (variants.js caches materials globally).
74
+ //
75
+ // Rendering: a ghost reads as ONE translucent shell, not ~70 individually
76
+ // transparent meshes (through which every internal motor/PCB used to show).
77
+ // Classic depth-prepass silhouette, kept inside the transparent pass so the
78
+ // grid floor/walls (transparent ShaderMaterials at renderOrder 0) still
79
+ // draw behind ghosts instead of being depth-rejected:
80
+ // PREPASS (renderOrder 1): every ghost mesh duplicated as a color-less
81
+ // twin (colorWrite off, depthWrite on) -> the depth buffer ends up
82
+ // holding the nearest ghost surface per pixel.
83
+ // BEAUTY (renderOrder 2): the real meshes, transparent + depthWrite off;
84
+ // the default LessEqual depth test then rejects everything except the
85
+ // fragment matching the prepass depth, so internals and back shell
86
+ // layers are skipped and each pixel is shaded exactly once.
87
+ // Orders are shared by all ghosts (not per-ghost pairs): every beauty pass
88
+ // tests against every ghost's prepass depth, so overlapping ghosts occlude
89
+ // each other like solids instead of double-blending.
90
+ const PREPASS_ORDER = 1;
91
+ const BEAUTY_ORDER = 2;
92
+ const ghostify = (rig) => {
93
+ // Collect first: twins are added below and must not be re-traversed.
94
+ const meshes = [];
95
+ rig.root.traverse((o) => { if (o.isMesh) meshes.push(o); });
96
+ const cache = new Map();
97
+ let depthMat = null;
98
+ for (const o of meshes) {
99
+ // Materialization-FX wire overlays cloned along with the local rig:
100
+ // drop them, they share the LIVE scan shader material and would
101
+ // flash on this ghost every time the local duck re-materializes.
102
+ if (o.userData.fxOverlay) { o.parent?.remove(o); continue; }
103
+ if (o.userData.ghostPrepass) continue; // twin from a previous ghostify
104
+ let m = cache.get(o.material.uuid);
105
+ if (!m) {
106
+ m = o.material.clone();
107
+ m.transparent = true;
108
+ m.opacity = GHOST_OPACITY;
109
+ m.depthWrite = false;
110
+ cache.set(o.material.uuid, m);
111
+ }
112
+ o.material = m;
113
+ o.renderOrder = BEAUTY_ORDER;
114
+ if (!o.userData.hasGhostTwin) {
115
+ if (!depthMat) {
116
+ // Depth-only material: cloned (no THREE import here) and muted.
117
+ // transparent stays true so the twin sorts into the transparent
118
+ // pass, after the grid but before the beauty meshes.
119
+ depthMat = m.clone();
120
+ depthMat.colorWrite = false;
121
+ depthMat.depthWrite = true;
122
+ }
123
+ // Twin as an identity-transform child: follows joints for free. No
124
+ // userData.meshName, so applyVariant never repaints it.
125
+ const twin = new o.constructor(o.geometry, depthMat);
126
+ twin.renderOrder = PREPASS_ORDER;
127
+ twin.userData.ghostPrepass = true;
128
+ o.userData.hasGhostTwin = true;
129
+ o.add(twin);
130
+ }
131
+ }
132
+ };
133
+
134
+ const ghosts = new Map(); // peerId -> ghost
135
+ // Interpolation snapshot: the pose parts of a state message, stamped
136
+ // with the local arrival time.
137
+ const snapOf = (state) => ({ p: state.p, j: state.j, w: state.w ?? 0, at: performance.now() });
138
+ const makeGhost = (state) => {
139
+ const rig = cloneRig(rigFor(state.l));
140
+ applyVariant(rig, state.v);
141
+ ghostify(rig);
142
+ scene.add(rig.placer);
143
+ const trunk = rig.bodies.get("trunk_base");
144
+ // Snap straight to the first received pose: no fly-in from the origin.
145
+ trunk.position.set(state.p[0], state.p[1], state.p[2]);
146
+ trunk.quaternion.set(state.p[4], state.p[5], state.p[6], state.p[3]);
147
+ return {
148
+ rig, trunk, buf: [snapOf(state)],
149
+ variant: state.v, loco: state.l ?? 0,
150
+ };
151
+ };
152
+
153
+ const removeGhost = (peerId) => {
154
+ const g = ghosts.get(peerId);
155
+ if (!g) return;
156
+ scene.remove(g.rig.placer);
157
+ const seen = new Set();
158
+ g.rig.root.traverse((o) => {
159
+ if (o.isMesh && !seen.has(o.material.uuid)) {
160
+ seen.add(o.material.uuid);
161
+ o.material.dispose();
162
+ }
163
+ });
164
+ ghosts.delete(peerId);
165
+ };
166
+
167
+ // Handler signature in this build: (payload, context) where context is
168
+ // { peerId } - NOT the bare peerId string of classic trystero.
169
+ act.onMessage = (state, { peerId }) => {
170
+ let g = ghosts.get(peerId);
171
+ if (!g) {
172
+ if (ghosts.size >= MAX_GHOSTS) return; // room stays open, rendering capped
173
+ // Nostr relays replay recent events, so states from already-dead
174
+ // sessions can arrive right after joining: only ghost live peers.
175
+ if (!(peerId in room.getPeers())) return;
176
+ g = makeGhost(state);
177
+ // Idle-visitor gate: remember where this peer first appeared and keep
178
+ // its rig invisible until it strays from there (see hasMoved above).
179
+ g.spawn = { x: state.p[0], y: state.p[1], yaw: yawOf(state.p) };
180
+ g.revealed = false;
181
+ g.rig.placer.visible = false;
182
+ ghosts.set(peerId, g);
183
+ g.lastSeen = performance.now();
184
+ return;
185
+ }
186
+ g.lastSeen = performance.now();
187
+ g.buf.push(snapOf(state));
188
+ if (g.buf.length > BUF_MAX) g.buf.shift();
189
+ if (!g.revealed && hasMoved(g.spawn, state.p)) {
190
+ g.revealed = true; // latched for the rest of this peer session
191
+ g.rig.placer.visible = true;
192
+ }
193
+ // Peer switched legs <-> rollers: rebuild its ghost on the other rig
194
+ // (cheap - cloneRig shares geometry). The snapshot buffer carries over
195
+ // so the interpolated motion stays continuous across the swap.
196
+ if ((state.l ?? 0) !== g.loco) {
197
+ const { lastSeen, spawn, revealed, buf } = g;
198
+ removeGhost(peerId);
199
+ g = makeGhost(state);
200
+ Object.assign(g, { lastSeen, spawn, revealed, buf });
201
+ g.rig.placer.visible = revealed;
202
+ ghosts.set(peerId, g);
203
+ return;
204
+ }
205
+ if (state.v !== g.variant) {
206
+ g.variant = state.v;
207
+ applyVariant(g.rig, state.v);
208
+ ghostify(g.rig);
209
+ }
210
+ };
211
+
212
+ // Same setter-style registration as onMessage.
213
+ room.onPeerLeave = (peerId) => removeGhost(peerId);
214
+
215
+ // Graceful exit so other tabs drop this ghost immediately...
216
+ window.addEventListener("pagehide", () => {
217
+ try { room.leave(); } catch {}
218
+ });
219
+ // ...and a staleness sweep for peers that vanished without leaving
220
+ // (crashed tab, dropped connection): 5 s without a state packet means
221
+ // the peer is gone, not just lagging (even background-throttled tabs
222
+ // still send at ~1 Hz).
223
+ const STALE_MS = 5000;
224
+
225
+ // Broadcast + stale sweep on setInterval (not rAF) so both keep running
226
+ // in occluded tabs.
227
+ setInterval(() => {
228
+ const s = getLocalState();
229
+ if (s) sendState(s);
230
+ const now = performance.now();
231
+ for (const [peerId, g] of [...ghosts]) {
232
+ if (now - g.lastSeen > STALE_MS) removeGhost(peerId);
233
+ }
234
+ }, 1000 / SEND_HZ);
235
+
236
+ // Scratch THREE.Quaternions without importing three: slerp needs real
237
+ // instances, cloned here from any node of the already-built local rig.
238
+ const _qa = env.rig.placer.quaternion.clone();
239
+ const _qb = env.rig.placer.quaternion.clone();
240
+ return {
241
+ room,
242
+ peerCount: () => Object.keys(room.getPeers()).length,
243
+ ghostCount: () => ghosts.size,
244
+ // Minimap feed: revealed ghosts' arena positions in raw MJCF coords
245
+ // (trunk.position is set from qpos before the root's Z-up fix).
246
+ mapDots: () =>
247
+ [...ghosts.values()]
248
+ .filter((g) => g.revealed)
249
+ .map((g) => ({ x: g.trunk.position.x, y: g.trunk.position.y })),
250
+ debug: () => [...ghosts.values()].map((g) => {
251
+ let meshes = 0, visible = 0, op = null, twins = 0;
252
+ g.rig.root.traverse((o) => {
253
+ if (!o.isMesh) return;
254
+ if (o.userData.ghostPrepass) { twins++; return; }
255
+ meshes++; if (o.visible) visible++; op ??= o.material.opacity;
256
+ });
257
+ const w = g.trunk.getWorldPosition(g.trunk.position.clone());
258
+ return { p: g.trunk.position.toArray(), world: w.toArray(), inScene: !!g.rig.placer.parent, revealed: g.revealed, meshes, visible, twins, op, v: g.variant };
259
+ }),
260
+ update() {
261
+ const renderT = performance.now() - INTERP_DELAY_MS;
262
+ for (const g of ghosts.values()) {
263
+ const buf = g.buf;
264
+ // Drop snapshots fully in the past; keep [0] and [1] bracketing
265
+ // renderT (or the two newest, if renderT has caught up).
266
+ while (buf.length > 2 && buf[1].at <= renderT) buf.shift();
267
+ const a = buf[0];
268
+ const b = buf.length > 1 ? buf[1] : a;
269
+ // Clamped: before the segment -> a, past the newest -> hold b.
270
+ const span = b.at - a.at;
271
+ const u = span > 0 ? Math.min(1, Math.max(0, (renderT - a.at) / span)) : 1;
272
+ // Trunk pose in raw MJCF coords: the cloned root applies Z-up -> Y-up.
273
+ g.trunk.position.set(
274
+ a.p[0] + (b.p[0] - a.p[0]) * u,
275
+ a.p[1] + (b.p[1] - a.p[1]) * u,
276
+ a.p[2] + (b.p[2] - a.p[2]) * u,
277
+ );
278
+ _qa.set(a.p[4], a.p[5], a.p[6], a.p[3]); // THREE order x,y,z,w
279
+ _qb.set(b.p[4], b.p[5], b.p[6], b.p[3]);
280
+ g.trunk.quaternion.copy(_qa.slerp(_qb, u));
281
+ for (let i = 0; i < jointNames.length; i++) {
282
+ setJoint(g.rig, jointNames[i], a.j[i] + (b.j[i] - a.j[i]) * u);
283
+ }
284
+ setJawOpen(g.rig, a.w + (b.w - a.w) * u);
285
+ }
286
+ },
287
+ };
288
+ }
app/src/game/signed.js ADDED
@@ -0,0 +1,13 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ // Private HF Space auth: the hub iframe URL carries a ?__sign JWT, but
2
+ // subresource requests normally rely on a *.static.hf.space cookie that
3
+ // browsers often block inside the iframe (third-party cookie blocking),
4
+ // which 401s every same-origin fetch. Appending the JWT to each request
5
+ // authenticates them regardless of cookie policy. No-op locally.
6
+ //
7
+ // Only ASSET requests (meshes, policies, XML, audio, images) go through
8
+ // this now: application code ships in the Vite bundle, which the page load
9
+ // itself already authenticated.
10
+ const HF_SIGN = new URLSearchParams(location.search).get("__sign");
11
+
12
+ export const signed = (url) =>
13
+ HF_SIGN ? `${url}${url.includes("?") ? "&" : "?"}__sign=${encodeURIComponent(HF_SIGN)}` : url;
app/src/game/stickers.js ADDED
@@ -0,0 +1,222 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ // Comic sticker popups: on notable game events (kick, quack, roll, ball
2
+ // spawn, ghost peer joining) a die-cut sticker or a comic onomatopoeia pops
3
+ // at a semi-random spot on screen, overshoots in, then fades out. Pure
4
+ // CSS animations, no dependencies.
5
+ //
6
+ // Fully self-contained and OPTIONAL: this module owns its DOM layer and its
7
+ // <style> tag, and imports nothing from the rest of the game. rl.js keeps a
8
+ // nullable `stickers` slot and calls `stickers?.pop("kick")` at each event
9
+ // site, so deleting the single import line in rl.js removes the feature
10
+ // without breaking anything.
11
+ //
12
+ // Image assets are the pre-cut Pollen showcase stickers (assets/stickers/,
13
+ // webp with transparency). Their URLs go through the injected `signed()`
14
+ // helper so they load on the private HF Space (?__sign JWT) as well as
15
+ // locally, where signed() is a no-op.
16
+ //
17
+ // const stickers = initStickers({ signed, isLocked });
18
+ // stickers.pop("quack"); // one of: kick, quack, roll, spawn, hi
19
+ // stickers.dispose(); // remove layer + styles (tests/teardown)
20
+
21
+ // Bump when the webp files change; python http.server sends no
22
+ // Cache-Control, same convention as the mesh/module ?v= busters.
23
+ const ASSET_V = "1";
24
+ const DIR = "./assets/stickers";
25
+
26
+ // Comic palette sampled from the sticker sheet itself.
27
+ const C = {
28
+ red: "#ff3b30",
29
+ teal: "#1fb6a8",
30
+ yellow: "#ffd23f",
31
+ blue: "#2f8bff",
32
+ pink: "#ff2d78",
33
+ };
34
+
35
+ // Per-event pools: each pop picks one variant at random. `img` is a die-cut
36
+ // webp sticker (w = on-screen width in px before jitter), `text` a pure
37
+ // CSS onomatopoeia; both together = text overlaid on the image (POP! burst).
38
+ // `cool` is the per-event rate limit in ms - repeated quacks/kicks reuse
39
+ // the sticker on screen instead of stacking a new one per press.
40
+ const EVENTS = {
41
+ kick: {
42
+ cool: 900,
43
+ pool: [
44
+ { img: "bang-red", w: 170 },
45
+ { img: "boom", w: 175 },
46
+ { img: "star-yellow", w: 120 },
47
+ { text: "BAM!", color: C.red },
48
+ ],
49
+ },
50
+ quack: {
51
+ cool: 700,
52
+ pool: [
53
+ { img: "quack", w: 180 },
54
+ { img: "quack-quack", w: 170 },
55
+ { text: "QUACK!", color: C.teal },
56
+ ],
57
+ },
58
+ roll: {
59
+ cool: 1600,
60
+ pool: [
61
+ { text: "WHEE!", color: C.pink },
62
+ { img: "ziouuu", w: 200 },
63
+ { img: "shooting-star", w: 150 },
64
+ ],
65
+ },
66
+ spawn: {
67
+ cool: 900,
68
+ pool: [
69
+ { img: "burst-yellow", w: 140, text: "POP!", color: C.pink },
70
+ { text: "POP!", color: C.yellow },
71
+ ],
72
+ },
73
+ hi: {
74
+ cool: 2500,
75
+ pool: [
76
+ { text: "HI!", color: C.blue },
77
+ { img: "wave", w: 150 },
78
+ ],
79
+ },
80
+ };
81
+
82
+ const MAX_LIVE = 4; // concurrent stickers, all events combined
83
+ const LIFE_MS = 1250; // matches the CSS animation duration below
84
+
85
+ // Safe zone (viewport %): keeps stickers off the HUD corners - mode label
86
+ // top-left, REC/stats top-right (+ colour stack below it), hint keycaps
87
+ // along the bottom. The middle band is all playground.
88
+ const ZONE = { x0: 16, x1: 78, y0: 20, y1: 68 };
89
+
90
+ const CSS = `
91
+ #sticker-layer {
92
+ position: fixed;
93
+ inset: 0;
94
+ z-index: 9; /* above the CRT overlay (5), below the HUD (10) */
95
+ pointer-events: none;
96
+ overflow: hidden;
97
+ }
98
+ #sticker-layer .sticker {
99
+ position: absolute;
100
+ will-change: transform, opacity;
101
+ animation: sticker-pop ${LIFE_MS}ms cubic-bezier(0.22, 1, 0.36, 1) forwards;
102
+ }
103
+ #sticker-layer .sticker img {
104
+ display: block;
105
+ filter: drop-shadow(0 4px 10px rgba(0, 0, 0, 0.35));
106
+ }
107
+ #sticker-layer .sticker-text {
108
+ font-family: "Arial Black", "Avenir-Black", "Helvetica Neue", sans-serif;
109
+ font-weight: 900;
110
+ font-style: italic;
111
+ font-size: clamp(2.4rem, 5.5vw, 4.2rem);
112
+ letter-spacing: 0.02em;
113
+ white-space: nowrap;
114
+ -webkit-text-stroke: 0.24em #fff;
115
+ paint-order: stroke fill;
116
+ text-shadow: 0.07em 0.1em 0 rgba(20, 10, 40, 0.85);
117
+ filter: drop-shadow(0 4px 10px rgba(0, 0, 0, 0.35));
118
+ }
119
+ /* Text overlaid on a die-cut sticker (e.g. POP! on the blank burst). */
120
+ #sticker-layer .sticker-overlay {
121
+ position: absolute;
122
+ inset: 0;
123
+ display: flex;
124
+ align-items: center;
125
+ justify-content: center;
126
+ font-size: clamp(1.6rem, 3.2vw, 2.4rem);
127
+ transform: rotate(-8deg);
128
+ }
129
+ /* Pop-in with scale overshoot + a small rotation settle, short hold, then
130
+ shrink/fade. --rot / --s carry the per-pop random jitter. */
131
+ @keyframes sticker-pop {
132
+ 0% { transform: translate(-50%, -50%) rotate(calc(var(--rot) - 12deg)) scale(0); opacity: 0; }
133
+ 14% { transform: translate(-50%, -50%) rotate(calc(var(--rot) + 3deg)) scale(calc(var(--s) * 1.22)); opacity: 1; }
134
+ 26% { transform: translate(-50%, -50%) rotate(var(--rot)) scale(calc(var(--s) * 0.96)); }
135
+ 36% { transform: translate(-50%, -50%) rotate(var(--rot)) scale(var(--s)); }
136
+ 78% { transform: translate(-50%, -50%) rotate(var(--rot)) scale(var(--s)); opacity: 1; }
137
+ 100% { transform: translate(-50%, -50%) rotate(var(--rot)) scale(calc(var(--s) * 0.5)); opacity: 0; }
138
+ }
139
+ @media (prefers-reduced-motion: reduce) {
140
+ #sticker-layer .sticker { animation-duration: 1ms; }
141
+ }
142
+ `;
143
+
144
+ const rand = (a, b) => a + Math.random() * (b - a);
145
+
146
+ export function initStickers({ signed = (u) => u, isLocked = () => false } = {}) {
147
+ const url = (name) => signed(`${DIR}/${name}.webp?v=${ASSET_V}`);
148
+
149
+ const style = document.createElement("style");
150
+ style.textContent = CSS;
151
+ document.head.appendChild(style);
152
+
153
+ const layer = document.createElement("div");
154
+ layer.id = "sticker-layer";
155
+ layer.setAttribute("aria-hidden", "true");
156
+ document.body.appendChild(layer);
157
+
158
+ // Warm the image cache so the first pop of each sticker isn't a blank
159
+ // frame while the webp downloads.
160
+ for (const { pool } of Object.values(EVENTS))
161
+ for (const v of pool) if (v.img) new Image().src = url(v.img);
162
+
163
+ const lastAt = new Map(); // event -> last pop timestamp
164
+ // Avoid showing the same variant twice in a row per event.
165
+ const lastPick = new Map();
166
+
167
+ const pop = (event) => {
168
+ const cfg = EVENTS[event];
169
+ if (!cfg) return;
170
+ if (isLocked()) return; // no stickers during the entrance/reset ceremony
171
+ const now = performance.now();
172
+ if (now - (lastAt.get(event) ?? -Infinity) < cfg.cool) return;
173
+ if (layer.childElementCount >= MAX_LIVE) return;
174
+ lastAt.set(event, now);
175
+
176
+ let i = (Math.random() * cfg.pool.length) | 0;
177
+ if (cfg.pool.length > 1 && i === lastPick.get(event)) i = (i + 1) % cfg.pool.length;
178
+ lastPick.set(event, i);
179
+ const v = cfg.pool[i];
180
+
181
+ const el = document.createElement("div");
182
+ el.className = "sticker";
183
+ el.style.left = `${rand(ZONE.x0, ZONE.x1)}%`;
184
+ el.style.top = `${rand(ZONE.y0, ZONE.y1)}%`;
185
+ el.style.setProperty("--rot", `${rand(-14, 14)}deg`);
186
+ el.style.setProperty("--s", rand(0.88, 1.12).toFixed(2));
187
+
188
+ if (v.img) {
189
+ const img = document.createElement("img");
190
+ img.src = url(v.img);
191
+ img.width = v.w;
192
+ img.alt = "";
193
+ el.appendChild(img);
194
+ if (v.text) {
195
+ const t = document.createElement("span");
196
+ t.className = "sticker-text sticker-overlay";
197
+ t.style.color = v.color;
198
+ t.textContent = v.text;
199
+ el.appendChild(t);
200
+ }
201
+ } else {
202
+ const t = document.createElement("span");
203
+ t.className = "sticker-text";
204
+ t.style.color = v.color;
205
+ t.textContent = v.text;
206
+ el.appendChild(t);
207
+ }
208
+
209
+ el.addEventListener("animationend", () => el.remove(), { once: true });
210
+ // Fallback sweep in case the animation never fires (hidden tab).
211
+ setTimeout(() => el.remove(), LIFE_MS + 500);
212
+ layer.appendChild(el);
213
+ };
214
+
215
+ return {
216
+ pop,
217
+ dispose() {
218
+ layer.remove();
219
+ style.remove();
220
+ },
221
+ };
222
+ }
app/src/game/variants.js ADDED
@@ -0,0 +1,319 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ // Colour variants of the real Microduck robots (see the reference photo of
2
+ // the four printed units). Each variant fills the same semantic slots; the
3
+ // mesh-filename -> slot mapping lives in meshMaterialsFor(). All colours are
4
+ // linear-space RGB and slightly oversaturated: the renderer tone-maps with
5
+ // ACESFilmicToneMapping under warm key/rim lights, which desaturates and
6
+ // shifts hues toward gold, so the base colours lean past the target to land
7
+ // right on screen.
8
+
9
+ import * as THREE from "three";
10
+
11
+ // ── Shared slot specs (identical on all four robots) ───────────────────
12
+ // sRGB targets from the reference photo, converted to linear with a
13
+ // slight saturation boost (x1.12) so ACES lands on the clean tint.
14
+ const AMBER_YELLOW = { color: [1.0, 0.413, 0.007], roughness: 0.4, metalness: 0.0 }; // #ffb52e
15
+ const BRIGHT_ORANGE = { color: [1.0, 0.144, 0.008], roughness: 0.45, metalness: 0.0 }; // #ff7a2f
16
+ const AMBER = { color: [0.847, 0.339, 0.022], roughness: 0.45, metalness: 0.0 }; // #eda63e
17
+ const CLEAN_YELLOW = { color: [1.0, 0.608, 0.021], roughness: 0.4, metalness: 0.0 }; // #ffd23f
18
+ const CREAM = { color: [0.888, 0.86, 0.798], roughness: 0.35, metalness: 0.0 }; // #f2efe8
19
+ const DARK = { color: [0.012, 0.012, 0.014], roughness: 0.55, metalness: 0.3 }; // #1d1d1f
20
+ const GRAY = { color: [0.256, 0.256, 0.279], roughness: 0.5, metalness: 0.35 }; // #8b8b90
21
+ // Camera-lens eye: very dark blue-black, glossy like coated glass.
22
+ const LENS = { color: [0.01, 0.012, 0.02], roughness: 0.05, metalness: 0.0 };
23
+
24
+ // Per-variant colours.
25
+ const WARM_GRAY = { color: [0.328, 0.312, 0.283], roughness: 0.35, metalness: 0.0 }; // #9b9892
26
+ const AMBER_ORANGE = { color: [0.815, 0.321, 0.019], roughness: 0.5, metalness: 0.0 }; // #e9a23b
27
+ const CHARCOAL = { color: [0.028, 0.028, 0.033], roughness: 0.5, metalness: 0.0 }; // #2f2f33
28
+ const CHARCOAL_FACE = { color: [0.044, 0.044, 0.051], roughness: 0.5, metalness: 0.0 }; // #3c3c40
29
+ const CHARCOAL_BODY = { color: [0.017, 0.017, 0.019], roughness: 0.5, metalness: 0.0 }; // #232326
30
+ const LIGHT_WARM_GRAY = { color: [0.485, 0.459, 0.416], roughness: 0.35, metalness: 0.0 };// #b9b5ae
31
+ const PURPLE_RING = { color: [0.114, 0.058, 0.578], roughness: 0.4, metalness: 0.0 }; // #6a52c8
32
+ const PURPLE_FEET = { color: [0.164, 0.085, 0.584], roughness: 0.45, metalness: 0.0 }; // #7a5fc9
33
+ const PALE_BLUE = { color: [0.441, 0.613, 0.723], roughness: 0.35, metalness: 0.0 }; // #b6cfdd
34
+ const MEDIUM_BLUE = { color: [0.13, 0.321, 0.552], roughness: 0.35, metalness: 0.0 }; // #6f9ec4
35
+ const BLUE_GRAY = { color: [0.183, 0.379, 0.565], roughness: 0.35, metalness: 0.0 }; // #7fa9c6
36
+ const FEET_YELLOW = { color: [0.784, 0.455, 0.023], roughness: 0.5, metalness: 0.0 }; // #e5b93e
37
+
38
+ // Slots (validated against the four-robot reference photo): headDome
39
+ // (top shell), facePlate (around the eye), trim (band under the shell +
40
+ // upper beak base), beakUpper (mouth-roof plate), beakLower (rigid jaw),
41
+ // tongue (soft pad inside the beak), eyeRing, lens, bodyShell (trunk
42
+ // base), sideShells (left + right trunk shells), legShells (upper-leg
43
+ // shells), feet (shoe upper: foot blocks + ankle brackets), soles (shoe
44
+ // lower: sole pads), hips (hip covers), mechDark, mechGray.
45
+ // The shoe is deliberately identical on all variants: yellow upper
46
+ // (feet), orange lower (soles).
47
+ export const VARIANTS = {
48
+ // Front-center robot: warm gray head, cream body, amber eye, orange
49
+ // trim and beak.
50
+ classic: {
51
+ headDome: CREAM,
52
+ facePlate: WARM_GRAY,
53
+ trim: BRIGHT_ORANGE,
54
+ beakUpper: BRIGHT_ORANGE,
55
+ beakLower: BRIGHT_ORANGE,
56
+ tongue: AMBER_YELLOW,
57
+ eyeRing: AMBER_YELLOW,
58
+ lens: LENS,
59
+ bodyShell: CREAM,
60
+ sideShells: CREAM,
61
+ legShells: CREAM,
62
+ feet: BRIGHT_ORANGE,
63
+ soles: AMBER_ORANGE,
64
+ hips: GRAY,
65
+ mechDark: DARK,
66
+ mechGray: GRAY,
67
+ },
68
+ // Front-left robot: charcoal shells, slightly lighter face plate,
69
+ // amber eye, orange/amber beak.
70
+ charcoal: {
71
+ headDome: CHARCOAL,
72
+ facePlate: CHARCOAL_FACE,
73
+ trim: BRIGHT_ORANGE,
74
+ beakUpper: BRIGHT_ORANGE,
75
+ beakLower: BRIGHT_ORANGE,
76
+ tongue: AMBER_YELLOW,
77
+ eyeRing: AMBER_YELLOW,
78
+ lens: LENS,
79
+ bodyShell: CHARCOAL_BODY,
80
+ sideShells: CHARCOAL_BODY,
81
+ legShells: CHARCOAL_BODY,
82
+ feet: BRIGHT_ORANGE,
83
+ soles: AMBER_ORANGE,
84
+ hips: GRAY,
85
+ mechDark: DARK,
86
+ mechGray: GRAY,
87
+ },
88
+ // Back-center robot: light warm gray head, purple eye, YELLOW trim and
89
+ // beak (both plates), cream body.
90
+ purple: {
91
+ headDome: LIGHT_WARM_GRAY,
92
+ facePlate: WARM_GRAY,
93
+ trim: CLEAN_YELLOW,
94
+ beakUpper: CLEAN_YELLOW,
95
+ beakLower: CLEAN_YELLOW,
96
+ tongue: AMBER_YELLOW,
97
+ eyeRing: PURPLE_RING,
98
+ lens: LENS,
99
+ bodyShell: CREAM,
100
+ sideShells: CREAM,
101
+ legShells: CREAM,
102
+ feet: CLEAN_YELLOW,
103
+ soles: PURPLE_RING,
104
+ hips: GRAY,
105
+ mechDark: DARK,
106
+ mechGray: GRAY,
107
+ // UI swatch override: the head is warm gray, but this colourway's
108
+ // identity is its purple accents - the picker dot shows that.
109
+ swatch: PURPLE_FEET,
110
+ },
111
+ // Right robot: pale blue dome over a medium blue face plate (two
112
+ // different blues), amber eye, orange/amber beak, blue-gray body.
113
+ blue: {
114
+ headDome: PALE_BLUE,
115
+ facePlate: MEDIUM_BLUE,
116
+ trim: BRIGHT_ORANGE,
117
+ beakUpper: BRIGHT_ORANGE,
118
+ beakLower: BRIGHT_ORANGE,
119
+ tongue: AMBER_YELLOW,
120
+ eyeRing: AMBER_YELLOW,
121
+ lens: LENS,
122
+ bodyShell: PALE_BLUE,
123
+ sideShells: PALE_BLUE,
124
+ legShells: PALE_BLUE,
125
+ feet: BRIGHT_ORANGE,
126
+ soles: AMBER_ORANGE,
127
+ hips: MEDIUM_BLUE,
128
+ mechDark: DARK,
129
+ mechGray: GRAY,
130
+ },
131
+ };
132
+
133
+ export const VARIANT_NAMES = Object.keys(VARIANTS);
134
+ export const DEFAULT_VARIANT = "classic";
135
+
136
+ export const randomVariantName = () =>
137
+ VARIANT_NAMES[Math.floor(Math.random() * VARIANT_NAMES.length)];
138
+
139
+ // Mesh filename -> material spec for one variant (mjlab model meshes).
140
+ // Anything not listed is small mechanical hardware and falls back to
141
+ // mechGray. Verified visually against the four-robot reference photo.
142
+ export function meshMaterialsFor(v) {
143
+ return {
144
+ // Head: dome, band under it, face plate around the eye.
145
+ "top_head_shell.stl": v.headDome,
146
+ "bottom_head_shell.stl": v.trim,
147
+ "face_part.stl": v.facePlate,
148
+ // The eye: printed ring + camera lens behind it.
149
+ "noenoeil.stl": v.eyeRing,
150
+ "lens.stl": v.lens,
151
+ "m12_lens_holder.stl": v.mechDark,
152
+ // Beak: mouth-roof plate on top, rigid jaw below, soft pad (the
153
+ // tongue) riding on the jaw.
154
+ "soft_mouth_top.stl": v.beakUpper,
155
+ "jaw.stl": v.beakLower,
156
+ "jaw_soft.stl": v.tongue,
157
+ // Body: trunk base + the two side shells (shared slot).
158
+ "trunk_base.stl": v.bodyShell,
159
+ "left_shell.stl": v.sideShells,
160
+ "right_shell.stl": v.sideShells,
161
+ "upper_leg_left.stl": v.legShells,
162
+ "upper_leg_right.stl": v.legShells,
163
+ "hip_l.stl": v.hips,
164
+ // Feet: foot block + ankle bracket, soft sole pad below.
165
+ "foot_left.stl": v.feet,
166
+ "foot_right.stl": v.feet,
167
+ "ankle_left.stl": v.feet,
168
+ "ankle_right.stl": v.feet,
169
+ "sole_left.stl": v.soles,
170
+ "sole_right.stl": v.soles,
171
+ // Roller variant: the blade + ankle bracket take the shoe-upper slot,
172
+ // the rims take the sole accent, the tires are rubber-dark.
173
+ "ankle_l_v1.stl": v.feet,
174
+ "ankle_r_v1.stl": v.feet,
175
+ "roller_blade.stl": v.feet,
176
+ "rim.stl": v.soles,
177
+ "tire.stl": v.mechDark,
178
+ // Dark mechanics. leg.stl is the printed shin that wraps the ankle
179
+ // motor and its bearing ring: in gray they read as two gray motors,
180
+ // so both go dark with the motor they hold.
181
+ "xl330.stl": v.mechDark,
182
+ "leg.stl": v.mechDark,
183
+ "seeed_bearing__configuration_default.stl": v.mechDark,
184
+ // Hip yaw mechanism (ribbed ring + 4-screw plate): reads as the hip
185
+ // motor, so it goes dark too. The printed hip cover (hip_l.stl)
186
+ // keeps the dedicated `hips` slot.
187
+ "yaw2roll.stl": v.mechDark,
188
+ "bearing_roll.stl": v.mechDark,
189
+ "neck.stl": v.mechDark,
190
+ "np_f970.stl": v.mechDark,
191
+ "pcb__raspberry_pi_zero_2_w.stl": v.mechDark,
192
+ "elec_rpi_robot_hat_pcb.stl": v.mechDark,
193
+ "banana_pcb_locker.stl": v.mechDark,
194
+ "speaker.stl": v.mechDark,
195
+ // Gray mechanics
196
+ "upper_leg_rigidity_plate.stl": v.mechGray,
197
+ "yaw_roll_motion.stl": v.mechGray,
198
+ "neck_pitch.stl": v.mechGray,
199
+ "motor_support.stl": v.mechGray,
200
+ "power_support.stl": v.mechGray,
201
+ "seeed_bearing__configuration__22x16x4.stl": v.mechGray,
202
+ };
203
+ }
204
+
205
+ // buildRig materialForMesh hook for one variant.
206
+ export const materialHookFor = (v) => {
207
+ const map = meshMaterialsFor(v);
208
+ return (mesh) => map[mesh] ?? v.mechGray;
209
+ };
210
+
211
+ // ── Live re-skin ────────────────────────────────────────────────────────
212
+ // Swap materials on an already-built rig without reloading any STL.
213
+ // Meshes are identified via userData.meshName (set by duck.js buildRig and
214
+ // preserved by cloneRig). Materials are cached per resolved spec so clones
215
+ // and repeated switches share GPU material instances.
216
+ const matCache = new Map();
217
+ function matFor(spec) {
218
+ const key = `${spec.color.join(",")}|${spec.roughness ?? 0.5}|${spec.metalness ?? 0}`;
219
+ let m = matCache.get(key);
220
+ if (!m) {
221
+ m = new THREE.MeshStandardMaterial({
222
+ color: new THREE.Color(...spec.color),
223
+ roughness: spec.roughness ?? 0.5,
224
+ metalness: spec.metalness ?? 0.0,
225
+ });
226
+ matCache.set(key, m);
227
+ }
228
+ return m;
229
+ }
230
+
231
+ // ── Smooth colourway fades ──────────────────────────────────────────────
232
+ // applyVariant on a rig that is live in a scene tweens every mesh's
233
+ // colour/roughness/metalness toward the new spec (same feel as the
234
+ // showcase site: ~0.35 s, ease in-out) instead of snapping. Off-scene
235
+ // rigs (first paint, the locomotion rig swap before scene.add, freshly
236
+ // cloned ghosts) keep the instant path, so hidden repaints stay exact.
237
+ //
238
+ // Each fading mesh gets a transient material (clone of the target cache
239
+ // entry, rewound to the current look) so the shared matCache instances
240
+ // are never mutated. The per-frame driver writes to that transient
241
+ // material AND to whatever the mesh currently carries: systems that swap
242
+ // materials mid-fade (ghostify's transparent clones, the wireframe FX's
243
+ // clip clones) keep receiving colour updates and still land on the exact
244
+ // target values. When nothing intervened, the mesh settles back on the
245
+ // shared cache material, so the at-rest state is byte-identical to a
246
+ // snap (the wireframe FX caches clip clones per material uuid, which
247
+ // must stay stable across colour changes). Driven by a self-contained
248
+ // rAF ticker that only runs while fades are active - no render-loop hook
249
+ // needed, and rapid re-clicks simply retarget from the current colours.
250
+ const FADE_S = 0.35;
251
+ const easeInOut = (x) => (x < 0.5 ? 4 * x * x * x : 1 - Math.pow(-2 * x + 2, 3) / 2);
252
+ const fades = new Map(); // mesh -> fade record
253
+ let fadeRaf = 0;
254
+
255
+ function driveFades() {
256
+ const t = performance.now() / 1000;
257
+ for (const [mesh, f] of fades) {
258
+ const x = Math.min(1, (t - f.start) / FADE_S);
259
+ const e = easeInOut(x);
260
+ f.mat.color.lerpColors(f.fromColor, f.toColor, e);
261
+ f.mat.roughness = f.fromRough + (f.toRough - f.fromRough) * e;
262
+ f.mat.metalness = f.fromMetal + (f.toMetal - f.fromMetal) * e;
263
+ const cur = mesh.material;
264
+ if (cur !== f.mat && cur?.color) {
265
+ cur.color.copy(f.mat.color);
266
+ cur.roughness = f.mat.roughness;
267
+ cur.metalness = f.mat.metalness;
268
+ }
269
+ if (x >= 1) {
270
+ fades.delete(mesh);
271
+ if (cur === f.mat) {
272
+ mesh.material = matFor(f.spec);
273
+ f.mat.dispose();
274
+ }
275
+ }
276
+ }
277
+ fadeRaf = fades.size ? requestAnimationFrame(driveFades) : 0;
278
+ }
279
+
280
+ export function applyVariant(rig, variant) {
281
+ const v = typeof variant === "string" ? VARIANTS[variant] : variant;
282
+ const map = meshMaterialsFor(v);
283
+ const fade = !!rig.placer?.parent;
284
+ rig.root.traverse((o) => {
285
+ if (!o.isMesh || !o.userData.meshName) return;
286
+ const spec = map[o.userData.meshName] ?? v.mechGray;
287
+ const target = matFor(spec);
288
+ if (!fade) {
289
+ fades.delete(o);
290
+ o.material = target;
291
+ return;
292
+ }
293
+ // Already resting on the target material and not mid-fade: no-op.
294
+ if (o.material === target && !fades.has(o)) return;
295
+ const from = o.material;
296
+ const m = target.clone();
297
+ m.color.copy(from.color);
298
+ m.roughness = from.roughness;
299
+ m.metalness = from.metalness;
300
+ fades.set(o, {
301
+ mat: m,
302
+ spec,
303
+ fromColor: from.color.clone(),
304
+ toColor: target.color.clone(),
305
+ fromRough: from.roughness,
306
+ toRough: target.roughness,
307
+ fromMetal: from.metalness,
308
+ toMetal: target.metalness,
309
+ start: performance.now() / 1000,
310
+ });
311
+ o.material = m;
312
+ });
313
+ if (fades.size && !fadeRaf) fadeRaf = requestAnimationFrame(driveFades);
314
+ }
315
+
316
+ // Linear-space spec colour -> sRGB CSS hex, for swatch UI elements.
317
+ export function specToHex(spec) {
318
+ return `#${new THREE.Color(...spec.color).getHexString()}`;
319
+ }
app/src/main.jsx ADDED
@@ -0,0 +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
+ // No StrictMode: the game core is a heavyweight singleton (MuJoCo WASM,
8
+ // ONNX sessions, WebRTC room) and dev double-mounting would double-boot it.
9
+ createRoot(document.getElementById("root")).render(
10
+ <ThemeProvider theme={theme}>
11
+ <CssBaseline />
12
+ <App />
13
+ </ThemeProvider>,
14
+ );
app/src/scene/GameCanvas.jsx ADDED
@@ -0,0 +1,59 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ // R3F shell around the imperative game core. The Canvas owns the renderer,
2
+ // default camera and the rAF loop; <Game> boots the core once and drives it
3
+ // from useFrame. Lights and environment are declarative; the grid, walls,
4
+ // duck rig, ball and props are added to the scene by the core (they carry
5
+ // ceremony-driven shader state).
6
+ import { useEffect } from "react";
7
+ import * as THREE from "three";
8
+ import { Canvas, useThree, useFrame } from "@react-three/fiber";
9
+ import { RoomEnvironment } from "three/addons/environments/RoomEnvironment.js";
10
+ import { bootGame } from "../game/game.js";
11
+ import { gameApi } from "../store.js";
12
+ import { SPAWN_X, SPAWN_Y } from "../game/constants.js";
13
+
14
+ function Game() {
15
+ const { scene, camera, gl } = useThree();
16
+
17
+ useEffect(() => {
18
+ scene.background = new THREE.Color(0x08080c);
19
+ gl.setClearColor(0x08080c, 1);
20
+ const pmrem = new THREE.PMREMGenerator(gl);
21
+ scene.environment = pmrem.fromScene(new RoomEnvironment()).texture;
22
+ scene.environmentIntensity = 0.45;
23
+ bootGame({ scene, camera, renderer: gl });
24
+ }, [scene, camera, gl]);
25
+
26
+ // dt clamped so a background-tab stall can't slingshot the camera orbit.
27
+ useFrame((_, dt) => {
28
+ gameApi.frame?.(Math.min(dt, 0.05));
29
+ });
30
+
31
+ return (
32
+ <>
33
+ <ambientLight intensity={0.6} />
34
+ <directionalLight position={[2, 4, 2]} intensity={1.6} />
35
+ <directionalLight position={[-2, 2, 1.5]} intensity={0.4} />
36
+ <directionalLight color={0xffb366} position={[0, 3, -2]} intensity={0.7} />
37
+ </>
38
+ );
39
+ }
40
+
41
+ export default function GameCanvas() {
42
+ return (
43
+ <Canvas
44
+ style={{ position: "fixed", inset: 0, zIndex: 1 }}
45
+ dpr={[1, 2]}
46
+ gl={{ antialias: true, alpha: true }}
47
+ // Boot framing translated onto the spawn cell (the orbit target
48
+ // follows), so the follow-cam has nothing to drift toward during boot.
49
+ camera={{
50
+ fov: 40,
51
+ near: 0.02,
52
+ far: 30,
53
+ position: [SPAWN_X + 0.55, 0.35, -SPAWN_Y + 0.7],
54
+ }}
55
+ >
56
+ <Game />
57
+ </Canvas>
58
+ );
59
+ }
app/src/store.js ADDED
@@ -0,0 +1,80 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ // Central bridge between the imperative game core and the React UI.
2
+ //
3
+ // Data flows one way per concern:
4
+ // game -> store state the UI renders (mode label, loco, telemetry, boot
5
+ // milestones). Written via useGame.setState from game code;
6
+ // high-frequency values (telemetry) are throttled at the
7
+ // producer so React never re-renders at frame rate.
8
+ // UI -> gameApi intents (set colour, switch loco, reset, open/close
9
+ // menu side effects). gameApi is a plain object populated
10
+ // once the game boots; optional-chained calls make the UI
11
+ // safe to interact with before that.
12
+ import { create } from "zustand";
13
+ import { subscribeWithSelector } from "zustand/middleware";
14
+
15
+ export const useGame = create(
16
+ subscribeWithSelector(() => ({
17
+ // Boot lifecycle
18
+ prebootDone: false, // title assets ready, grey veil dropped
19
+ entered: false, // first "Waddle in" clicked (latches)
20
+ menuOpen: false, // title / pause overlay up (opened by App after preboot)
21
+ biosVisible: false, // BIOS readout playing over the scene
22
+ bootDone: false,
23
+ bootFailed: false,
24
+
25
+ // Game state mirrored for the UI
26
+ modeLabel: "Run",
27
+ loco: "legs", // "legs" | "rollers" - what the game is actually running
28
+ locoWant: "legs", // what the quickbar asked for (game reconciles)
29
+ locoSwitching: false,
30
+ rollersLoading: false, // OSD line while the roller stack streams in
31
+ variant: "classic",
32
+ padConnected: false,
33
+ touchMode: false,
34
+ ballActive: false,
35
+ // Throttled telemetry block (4 Hz), bottom-right OSD
36
+ telemetry: { fps: 0, ctrlHz: 0, speed: 0, odo: 0, peers: 0 },
37
+ })),
38
+ );
39
+
40
+ // Imperative game surface, assigned by game/game.js once booted. The UI
41
+ // only ever optional-chains into it.
42
+ export const gameApi = {};
43
+
44
+ // Debug handles for the console / automated QA (same spirit as window.rl).
45
+ if (typeof window !== "undefined") {
46
+ window.__store = useGame;
47
+ window.__gameApi = gameApi;
48
+ }
49
+
50
+ // BIOS/POST milestone log. Plain module state on purpose: the readout
51
+ // component polls it inside its own paced replay loop (same as the old
52
+ // playBios), so no reactivity is needed and log spam never re-renders React.
53
+ // Entries: { label, status, raw, halt, progress } - status null = pending.
54
+ export const bootLog = [];
55
+
56
+ export const bootLine = (label) => {
57
+ const entry = { label, status: null, raw: false, progress: null };
58
+ bootLog.push(entry);
59
+ const done = (status = "OK") => {
60
+ entry.status = status;
61
+ };
62
+ done.progress = (p) => {
63
+ entry.progress = p;
64
+ };
65
+ return done;
66
+ };
67
+
68
+ export const bootNote = (label) => {
69
+ bootLog.push({ label, status: "", raw: true });
70
+ };
71
+
72
+ // Fatal boot failure: freeze the BIOS on the halt screen (the readout is
73
+ // the diagnostic surface). Drops the title overlay if still up.
74
+ export function bootHalt(detail) {
75
+ const s = useGame.getState();
76
+ if (s.bootFailed || s.bootDone) return;
77
+ bootNote(`>> ${detail}`);
78
+ bootLog.push({ label: "SYSTEM HALTED", status: "", raw: true, halt: true });
79
+ useGame.setState({ bootFailed: true, menuOpen: false, biosVisible: true });
80
+ }
app/src/theme.js ADDED
@@ -0,0 +1,25 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ // MUI theme carrying the sim's DA: ink background, bright robot orange,
2
+ // system sans for copy, tight monospace for the OSD layers. Components keep
3
+ // most of their look in sx/styled - the theme only centralizes the tokens.
4
+ import { createTheme } from "@mui/material/styles";
5
+
6
+ export const INK = "#08080c";
7
+ export const ORANGE = "#ff7a2f";
8
+ export const MONO = 'ui-monospace, SFMono-Regular, Menlo, Consolas, monospace';
9
+
10
+ export const theme = createTheme({
11
+ palette: {
12
+ mode: "dark",
13
+ primary: { main: ORANGE, contrastText: INK },
14
+ background: { default: INK, paper: INK },
15
+ text: {
16
+ primary: "rgba(255, 255, 255, 0.85)",
17
+ secondary: "rgba(255, 255, 255, 0.45)",
18
+ },
19
+ },
20
+ typography: {
21
+ fontFamily:
22
+ '-apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, sans-serif',
23
+ },
24
+ shape: { borderRadius: 8 },
25
+ });
app/src/ui/BiosOverlay.jsx ADDED
@@ -0,0 +1,188 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ // BIOS/POST boot readout. The real load runs silently behind the title
2
+ // overlay and only RECORDS milestones into bootLog (store.js); the readout
3
+ // itself plays after the first "Waddle in": a rapid-fire replay when
4
+ // everything already finished, or an honest live tracker (cursor blinking
5
+ // on the pending line) when the user enters mid-load. Never slows the
6
+ // actual boot. The line pacing is imperative DOM on purpose - it is a
7
+ // character-level animation, not UI state.
8
+ 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`
15
+ 0%, 55% { opacity: 1; }
16
+ 56%, 100% { opacity: 0; }
17
+ `;
18
+
19
+ const sleep = (ms) => new Promise((r) => setTimeout(r, ms));
20
+
21
+ const lineText = (e) => {
22
+ if (e.raw) return e.label;
23
+ // Pending line: bare label, plus a live counter when the stage reports
24
+ // progress (e.g. "LOADING POLICIES [3/5]").
25
+ if (e.status === null) return e.progress ? `${e.label} [${e.progress}]` : e.label;
26
+ return `${e.label} `.padEnd(26, ".") + ` ${e.status}`;
27
+ };
28
+
29
+ export default function BiosOverlay() {
30
+ const entered = useGame((s) => s.entered);
31
+ const bootFailed = useGame((s) => s.bootFailed);
32
+ const postRef = useRef(null);
33
+ const startedRef = useRef(false);
34
+ const [visible, setVisible] = useState(false);
35
+ const [off, setOff] = useState(false);
36
+
37
+ useEffect(() => {
38
+ // Only the initial entry triggers the replay (startedRef latches);
39
+ // a fatal boot failure forces it up regardless (the halt screen IS
40
+ // the diagnostic surface).
41
+ if ((!entered && !bootFailed) || startedRef.current) return;
42
+ startedRef.current = true;
43
+ playBios();
44
+ // eslint-disable-next-line react-hooks/exhaustive-deps
45
+ }, [entered, bootFailed]);
46
+
47
+ async function playBios() {
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
54
+ // on a few.
55
+ let seed = 11;
56
+ const rand = () => (seed = (seed * 1103515245 + 12345) & 0x7fffffff) / 0x7fffffff;
57
+ const els = new Map(); // entry -> element
58
+ let i = 0;
59
+ for (;;) {
60
+ const { bootDone, bootFailed: failed } = store();
61
+ if (i < bootLog.length) {
62
+ const entry = bootLog[i++];
63
+ const el = document.createElement("div");
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.
70
+ el.textContent = lineText(entry);
71
+ while (entry.status === null && !store().bootDone && !store().bootFailed) {
72
+ await sleep(60);
73
+ el.textContent = lineText(entry); // live [n/m] counter
74
+ }
75
+ await sleep(90);
76
+ el.textContent = lineText(entry);
77
+ } else if (entry.raw || entry.status === null) {
78
+ // Header / note lines: quick, no dotted leader to animate.
79
+ el.textContent = lineText(entry);
80
+ await sleep(20 + 60 * rand());
81
+ } else {
82
+ // Finished check: bursty POST pacing. Most lines snap in nearly
83
+ // instantly; the occasional one stalls on a "slow check" - and
84
+ // on a stall the dotted leader types out one dot at a time
85
+ // before the status lands.
86
+ const r = rand();
87
+ const stall = r < 0.75 ? 20 * rand() : 150 + 250 * rand();
88
+ if (stall < 45) {
89
+ el.textContent = lineText(entry);
90
+ await sleep(stall);
91
+ } else {
92
+ const prefix = `${entry.label} `;
93
+ const nDots = Math.max(26 - prefix.length, 3);
94
+ el.textContent = prefix;
95
+ const per = stall / nDots;
96
+ for (let d = 0; d < nDots; d++) {
97
+ await sleep(per);
98
+ el.textContent += ".";
99
+ }
100
+ await sleep(40);
101
+ el.textContent = lineText(entry);
102
+ }
103
+ }
104
+ } else if (bootDone) {
105
+ break;
106
+ } else {
107
+ // Waiting for the next real stage. On a failed boot this parks the
108
+ // console forever: everything queued (FAIL line, error detail,
109
+ // SYSTEM HALTED) has been printed and nothing more will come.
110
+ await sleep(failed ? 500 : 60);
111
+ }
112
+ }
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:
119
+ // starting it under the fading overlay hides the first (and busiest)
120
+ // part of the animation and only the tail end shows.
121
+ await sleep(500);
122
+ setVisible(false);
123
+ useGame.setState({ biosVisible: false });
124
+ // Cue the world draw-in + duck scan-up on a fully black screen.
125
+ gameApi.startEntrance?.();
126
+ }
127
+
128
+ return (
129
+ <Box
130
+ sx={{
131
+ position: "fixed",
132
+ inset: 0,
133
+ display: visible ? "flex" : "none",
134
+ // POST readout sits bottom-left like a real BIOS, not centered
135
+ alignItems: "flex-end",
136
+ justifyContent: "flex-start",
137
+ p: "2.2rem 2.4rem",
138
+ background: INK,
139
+ zIndex: 20,
140
+ pointerEvents: "none",
141
+ opacity: off ? 0 : 1,
142
+ transition: "opacity 0.45s ease",
143
+ }}
144
+ >
145
+ <Box
146
+ ref={postRef}
147
+ sx={{
148
+ minWidth: "min(380px, 84vw)",
149
+ // Grow upward from the bottom-left anchor; when the log outgrows
150
+ // the viewport, clip the OLDEST lines at the top so the newest
151
+ // line + cursor stay visible.
152
+ maxHeight: "calc(100vh - 4.4rem)",
153
+ display: "flex",
154
+ flexDirection: "column",
155
+ justifyContent: "flex-end",
156
+ overflow: "hidden",
157
+ // Raw 90s POST look: small, tight, unsmoothed monospace.
158
+ fontFamily: MONO,
159
+ fontSize: "12px",
160
+ fontWeight: 400,
161
+ lineHeight: 1.55,
162
+ letterSpacing: 0,
163
+ textTransform: "uppercase",
164
+ textAlign: "left",
165
+ // Robot orange (classic duck's BRIGHT_ORANGE, #ff7a2f in sRGB)
166
+ color: "rgba(255, 122, 47, 0.92)",
167
+ whiteSpace: "pre-wrap",
168
+ overflowWrap: "break-word",
169
+ "& > div:first-of-type": {
170
+ color: "rgba(255, 255, 255, 0.85)",
171
+ marginBottom: "0.55em",
172
+ },
173
+ // Blinking block cursor at the end of the newest line
174
+ "& > div:last-of-type::after": {
175
+ content: '"\\2588"',
176
+ marginLeft: "0.15em",
177
+ animation: `${postBlink} 1s steps(1) infinite`,
178
+ },
179
+ // Fatal boot failure: SYSTEM HALTED blinks like an old POST fault
180
+ "& > div.halt": {
181
+ color: "rgba(255, 82, 47, 0.95)",
182
+ animation: `${postBlink} 1s steps(1) infinite`,
183
+ },
184
+ }}
185
+ />
186
+ </Box>
187
+ );
188
+ }
app/src/ui/Hud.jsx ADDED
@@ -0,0 +1,317 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ // In-game HUD: Back (top-left), mode readout (top-right, Matrix-style
2
+ // letter scramble on change), quickbar (bottom-left: live colour dots +
3
+ // legs/rollers segment), telemetry stack (bottom-right) and the
4
+ // LOADING ROLLERS line while the roller stack streams in.
5
+ //
6
+ // Whole HUD is hidden while the title/pause overlay is up; touch mode
7
+ // strips it down to the thumbs + Back.
8
+ import { useEffect, useRef, useState } from "react";
9
+ import Box from "@mui/material/Box";
10
+ import ButtonBase from "@mui/material/ButtonBase";
11
+ import { keyframes } from "@mui/material/styles";
12
+ import { useGame, gameApi } from "../store.js";
13
+ import { VARIANT_SWATCHES } from "../game/game.js";
14
+ import { INK, ORANGE, MONO } from "../theme.js";
15
+
16
+ // Matrix-style letter scramble: on change every glyph flips through random
17
+ // charset entries, then locks to its target left-to-right over ~0.45 s.
18
+ // Monospace keeps the width stable mid-scramble.
19
+ const SCRAMBLE_GLYPHS = "ABCDEFGHIJKLMNOPQRSTUVWXYZ0123456789#$%&*<>/=+";
20
+ function useScramble(target) {
21
+ const [text, setText] = useState(target.toUpperCase());
22
+ const timer = useRef(null);
23
+ const shown = useRef(target.toUpperCase());
24
+ useEffect(() => {
25
+ const t = target.toUpperCase();
26
+ if (shown.current === t) return;
27
+ shown.current = t;
28
+ if (timer.current) clearInterval(timer.current);
29
+ const n = t.length;
30
+ const DUR = 450; // ms until the last letter locks
31
+ const rnd = () => SCRAMBLE_GLYPHS[(Math.random() * SCRAMBLE_GLYPHS.length) | 0];
32
+ const t0 = performance.now();
33
+ timer.current = setInterval(() => {
34
+ // Letters 0..k-1 are locked; the rest keep boiling.
35
+ const k = Math.floor(((performance.now() - t0) / DUR) * n);
36
+ if (k >= n) {
37
+ clearInterval(timer.current);
38
+ timer.current = null;
39
+ setText(t);
40
+ return;
41
+ }
42
+ let out = t.slice(0, k);
43
+ for (let i = k; i < n; i++) out += rnd();
44
+ setText(out);
45
+ }, 40);
46
+ return () => timer.current && clearInterval(timer.current);
47
+ }, [target]);
48
+ return text;
49
+ }
50
+
51
+ const recBlink = keyframes`
52
+ 0%, 58% { opacity: 1; }
53
+ 59%, 100% { opacity: 0.12; }
54
+ `;
55
+
56
+ function BackButton() {
57
+ return (
58
+ <ButtonBase
59
+ onClick={() => useGame.setState({ menuOpen: true })}
60
+ sx={{
61
+ position: "fixed",
62
+ top: "1.4rem",
63
+ left: "1.6rem",
64
+ zIndex: 10,
65
+ display: "inline-flex",
66
+ alignItems: "center",
67
+ gap: "0.45em",
68
+ border: "1px solid rgba(255, 255, 255, 0.16)",
69
+ background: "rgba(8, 8, 12, 0.55)",
70
+ color: "rgba(255, 255, 255, 0.75)",
71
+ font: "inherit",
72
+ fontSize: "0.8rem",
73
+ fontWeight: 600,
74
+ letterSpacing: "0.14em",
75
+ textTransform: "uppercase",
76
+ p: "0.6rem 1.15rem",
77
+ borderRadius: "999px",
78
+ transition: "color 0.15s ease, background 0.15s ease, border-color 0.15s ease",
79
+ "&:hover": {
80
+ color: "#fff",
81
+ borderColor: "rgba(255, 255, 255, 0.4)",
82
+ background: "rgba(255, 255, 255, 0.06)",
83
+ },
84
+ "&.Mui-focusVisible": {
85
+ outline: `2px solid ${ORANGE}`,
86
+ outlineOffset: 2,
87
+ },
88
+ }}
89
+ >
90
+ {"\u2190"} Back
91
+ </ButtonBase>
92
+ );
93
+ }
94
+
95
+ // Active policy/mode readout, top-right: bright orange caption over a big
96
+ // white monospace name.
97
+ function ModeReadout() {
98
+ const modeLabel = useGame((s) => s.modeLabel);
99
+ const text = useScramble(modeLabel);
100
+ return (
101
+ <Box
102
+ sx={{
103
+ position: "fixed",
104
+ top: "1.4rem",
105
+ right: "1.6rem",
106
+ zIndex: 10,
107
+ fontFamily: MONO,
108
+ pointerEvents: "none",
109
+ textAlign: "right",
110
+ }}
111
+ >
112
+ <Box
113
+ component="span"
114
+ sx={{
115
+ display: "block",
116
+ mb: "0.35rem",
117
+ fontSize: "0.78rem",
118
+ fontWeight: 600,
119
+ letterSpacing: "0.24em",
120
+ textTransform: "uppercase",
121
+ color: ORANGE,
122
+ textShadow: "0 0 10px rgba(255, 122, 47, 0.35)",
123
+ }}
124
+ >
125
+ Mode
126
+ </Box>
127
+ <Box
128
+ sx={{
129
+ minWidth: "8ch",
130
+ minHeight: "1.2em",
131
+ fontSize: "1.7rem",
132
+ fontWeight: 600,
133
+ letterSpacing: "0.16em",
134
+ textTransform: "uppercase",
135
+ color: "rgba(255, 255, 255, 0.95)",
136
+ textAlign: "right",
137
+ textShadow: "0 0 12px rgba(255, 255, 255, 0.28)",
138
+ lineHeight: 1.2,
139
+ }}
140
+ >
141
+ {text}
142
+ </Box>
143
+ </Box>
144
+ );
145
+ }
146
+
147
+ // In-game quick picks, bottom-left: colour dots + legs/rollers, live.
148
+ // Same pill language as the vitrine DuckPlayground.
149
+ function Quickbar() {
150
+ const variant = useGame((s) => s.variant);
151
+ const locoWant = useGame((s) => s.locoWant);
152
+ const pill = {
153
+ display: "inline-flex",
154
+ gap: "0.24rem",
155
+ p: "0.26rem",
156
+ border: "1px solid rgba(255, 255, 255, 0.14)",
157
+ borderRadius: "999px",
158
+ background: "rgba(8, 8, 12, 0.55)",
159
+ };
160
+ return (
161
+ <Box
162
+ sx={{
163
+ position: "fixed",
164
+ bottom: "1.4rem",
165
+ left: "1.6rem",
166
+ zIndex: 10,
167
+ display: "flex",
168
+ flexWrap: "wrap",
169
+ alignItems: "center",
170
+ gap: "0.6rem",
171
+ }}
172
+ >
173
+ <Box role="group" aria-label="Duck colours" sx={pill}>
174
+ {Object.entries(VARIANT_SWATCHES).map(([name, hex]) => (
175
+ <ButtonBase
176
+ key={name}
177
+ aria-label={`${name} colours`}
178
+ aria-pressed={name === variant}
179
+ onClick={() => gameApi.setVariant?.(name)}
180
+ sx={{
181
+ height: { xs: "2rem", sm: "2.3rem" },
182
+ px: { xs: "0.42rem", sm: "0.5rem" },
183
+ borderRadius: "999px",
184
+ "&.Mui-focusVisible": { outline: `2px solid ${ORANGE}`, outlineOffset: 2 },
185
+ "&:hover .dot": { opacity: 1 },
186
+ }}
187
+ >
188
+ <Box
189
+ className="dot"
190
+ sx={{
191
+ width: { xs: "1.2rem", sm: "1.4rem" },
192
+ height: { xs: "1.2rem", sm: "1.4rem" },
193
+ borderRadius: "50%",
194
+ background: hex,
195
+ opacity: name === variant ? 1 : 0.45,
196
+ transform: name === variant ? "scale(1.15)" : "none",
197
+ transition: "transform 0.2s ease, opacity 0.2s ease",
198
+ }}
199
+ />
200
+ </ButtonBase>
201
+ ))}
202
+ </Box>
203
+ <Box role="group" aria-label="Locomotion mode" sx={pill}>
204
+ {["legs", "rollers"].map((name) => (
205
+ <ButtonBase
206
+ key={name}
207
+ aria-pressed={locoWant === name}
208
+ onClick={() => gameApi.requestLoco?.(name)}
209
+ sx={{
210
+ height: { xs: "2rem", sm: "2.3rem" },
211
+ px: { xs: "0.8rem", sm: "1.05rem" },
212
+ borderRadius: "999px",
213
+ font: "inherit",
214
+ fontSize: { xs: "0.68rem", sm: "0.76rem" },
215
+ fontWeight: 600,
216
+ letterSpacing: "0.14em",
217
+ textTransform: "uppercase",
218
+ color: locoWant === name ? ORANGE : "rgba(255, 255, 255, 0.45)",
219
+ background: locoWant === name ? "rgba(255, 122, 47, 0.14)" : "transparent",
220
+ transition: "color 0.2s ease, background 0.2s ease",
221
+ "&.Mui-focusVisible": { outline: `2px solid ${ORANGE}`, outlineOffset: 2 },
222
+ }}
223
+ >
224
+ {name === "legs" ? "Legs" : "Rollers"}
225
+ </ButtonBase>
226
+ ))}
227
+ </Box>
228
+ </Box>
229
+ );
230
+ }
231
+
232
+ // Telemetry stack, bottom-right: peers / speed + odometer / loop rates,
233
+ // one line each, quietest at the bottom.
234
+ function Telemetry() {
235
+ const t = useGame((s) => s.telemetry);
236
+ const odo = t.odo < 1000 ? `${t.odo.toFixed(1)}M` : `${(t.odo / 1000).toFixed(2)}KM`;
237
+ const lines = [];
238
+ if (t.peers) lines.push(`${t.peers + 1} ONLINE`);
239
+ lines.push(`${t.speed.toFixed(2)}M/S \u00b7 ODO ${odo}`);
240
+ lines.push(`FPS ${t.fps} \u00b7 CTRL ${t.ctrlHz}HZ`);
241
+ return (
242
+ <Box
243
+ sx={{
244
+ position: "fixed",
245
+ bottom: "1.4rem",
246
+ right: "1.6rem",
247
+ zIndex: 10,
248
+ pointerEvents: "none",
249
+ fontFamily: MONO,
250
+ fontSize: "0.62rem",
251
+ letterSpacing: "0.14em",
252
+ textTransform: "uppercase",
253
+ color: "rgba(255, 255, 255, 0.35)",
254
+ fontVariantNumeric: "tabular-nums",
255
+ textShadow: "0 0 6px rgba(255, 255, 255, 0.15)",
256
+ whiteSpace: "pre-line",
257
+ textAlign: "right",
258
+ lineHeight: 1.8,
259
+ }}
260
+ >
261
+ {lines.join("\n")}
262
+ </Box>
263
+ );
264
+ }
265
+
266
+ // BIOS-style line while the roller variant streams in on first switch.
267
+ function OsdLoad() {
268
+ const rollersLoading = useGame((s) => s.rollersLoading);
269
+ if (!rollersLoading) return null;
270
+ return (
271
+ <Box
272
+ sx={{
273
+ position: "fixed",
274
+ top: "6.1rem", // below the mode readout
275
+ right: "1.6rem",
276
+ zIndex: 10,
277
+ fontFamily: MONO,
278
+ fontSize: "0.68rem",
279
+ letterSpacing: "0.14em",
280
+ color: ORANGE,
281
+ textShadow: "0 0 8px rgba(255, 122, 47, 0.4)",
282
+ }}
283
+ >
284
+ LOADING ROLLERS
285
+ <Box
286
+ component="span"
287
+ sx={{ display: "inline-block", ml: "0.15em", animation: `${recBlink} 0.8s steps(1) infinite` }}
288
+ >
289
+ {"\u2588"}
290
+ </Box>
291
+ </Box>
292
+ );
293
+ }
294
+
295
+ export default function Hud() {
296
+ const entered = useGame((s) => s.entered);
297
+ const menuOpen = useGame((s) => s.menuOpen);
298
+ const touchMode = useGame((s) => s.touchMode);
299
+ // HUD stays off until the first enter and while the title overlay is up
300
+ // (the BIOS readout simply covers it during boot, same as before).
301
+ if (!entered || menuOpen) return null;
302
+ return (
303
+ <>
304
+ <BackButton />
305
+ {/* Touch mode strips the HUD down to the thumbs: no quickbar, no
306
+ mode readout, no telemetry. Back stays to reach the menu. */}
307
+ {!touchMode && (
308
+ <>
309
+ <ModeReadout />
310
+ <Quickbar />
311
+ <Telemetry />
312
+ <OsdLoad />
313
+ </>
314
+ )}
315
+ </>
316
+ );
317
+ }
app/src/ui/Overlays.jsx ADDED
@@ -0,0 +1,111 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ // Full-viewport decorative layers over the 3D scene: halftone dots and the
2
+ // CRT/old-TV treatment (scanlines + boiling static + vignette). Stacked at
3
+ // z 2-5: above the canvas, below the HUD (z 10), the BIOS screen (z 20)
4
+ // and the title overlay (z 30) - the interface stays crisp and only the
5
+ // scene gets the old-TV treatment.
6
+ //
7
+ // Performance: the only animation is a transform-only jitter on the noise
8
+ // layer, so everything stays on the compositor and never repaints the
9
+ // WebGL canvas underneath.
10
+ import Box from "@mui/material/Box";
11
+ import { keyframes } from "@mui/material/styles";
12
+
13
+ const crtNoise = keyframes`
14
+ 0% { transform: translate(0, 0); }
15
+ 25% { transform: translate(-45px, 30px); }
16
+ 50% { transform: translate(25px, -55px); }
17
+ 75% { transform: translate(-30px, -15px); }
18
+ 100% { transform: translate(0, 0); }
19
+ `;
20
+
21
+ const NOISE_SVG =
22
+ "url(\"data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' width='160' height='160'%3E%3Cfilter id='n'%3E%3CfeTurbulence type='fractalNoise' baseFrequency='0.8' numOctaves='2' stitchTiles='stitch'/%3E%3CfeColorMatrix type='matrix' values='0 0 0 0 1 0 0 0 0 1 0 0 0 0 1 0 0 0 0.7 0'/%3E%3C/filter%3E%3Crect width='160' height='160' filter='url(%23n)'/%3E%3C/svg%3E\")";
23
+
24
+ export function Halftone() {
25
+ return (
26
+ <Box
27
+ aria-hidden
28
+ sx={{
29
+ position: "fixed",
30
+ inset: 0,
31
+ zIndex: 2,
32
+ pointerEvents: "none",
33
+ backgroundImage:
34
+ "radial-gradient(rgba(255, 255, 255, 0.05) 1px, transparent 1.5px)",
35
+ backgroundSize: "21px 21px",
36
+ maskImage: "linear-gradient(to bottom, transparent 30%, black 100%)",
37
+ }}
38
+ />
39
+ );
40
+ }
41
+
42
+ export function CrtOverlay() {
43
+ const scanlineOpacity = 0.13;
44
+ const scanlinePitch = "3px";
45
+ const noiseOpacity = 0.045;
46
+ const vignetteOpacity = 0.42;
47
+ return (
48
+ <Box
49
+ aria-hidden
50
+ sx={{
51
+ position: "fixed",
52
+ inset: 0,
53
+ zIndex: 5,
54
+ pointerEvents: "none",
55
+ overflow: "hidden",
56
+ }}
57
+ >
58
+ {/* SCANLINES: one thin dark line every pitch, plus a very faint
59
+ vertical RGB fringe (aperture-grille tint). Static. */}
60
+ <Box
61
+ sx={{
62
+ position: "absolute",
63
+ inset: 0,
64
+ opacity: scanlineOpacity,
65
+ background: `repeating-linear-gradient(
66
+ to bottom,
67
+ rgba(0, 0, 0, 0.55) 0,
68
+ rgba(0, 0, 0, 0.55) 1px,
69
+ transparent 1px,
70
+ transparent ${scanlinePitch}
71
+ ),
72
+ repeating-linear-gradient(
73
+ to right,
74
+ rgba(255, 70, 60, 0.05) 0,
75
+ rgba(255, 70, 60, 0.05) 1px,
76
+ rgba(70, 255, 90, 0.035) 1px,
77
+ rgba(70, 255, 90, 0.035) 2px,
78
+ rgba(70, 110, 255, 0.05) 2px,
79
+ rgba(70, 110, 255, 0.05) 3px
80
+ )`,
81
+ }}
82
+ />
83
+ {/* NOISE: tiled SVG turbulence, jittered between four offsets with
84
+ steps() so the grain "boils" like broadcast static. */}
85
+ <Box
86
+ sx={{
87
+ position: "absolute",
88
+ inset: "-80px",
89
+ backgroundImage: NOISE_SVG,
90
+ backgroundSize: "160px 160px",
91
+ opacity: noiseOpacity,
92
+ animation: `${crtNoise} 0.5s steps(1) infinite`,
93
+ willChange: "transform",
94
+ "@media (prefers-reduced-motion: reduce)": { animation: "none" },
95
+ }}
96
+ />
97
+ {/* VIGNETTE: gently darkened corners, like curved CRT glass. */}
98
+ <Box
99
+ sx={{
100
+ position: "absolute",
101
+ inset: 0,
102
+ background: `radial-gradient(
103
+ 115% 90% at 50% 50%,
104
+ transparent 62%,
105
+ rgba(0, 0, 0, ${vignetteOpacity}) 100%
106
+ )`,
107
+ }}
108
+ />
109
+ </Box>
110
+ );
111
+ }
app/src/ui/Preboot.jsx ADDED
@@ -0,0 +1,54 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
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/TitleMenu.jsx ADDED
@@ -0,0 +1,429 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ // Single-page title / pause menu: vitrine brand lockup, pitch, controls
2
+ // tutorial (keyboard / gamepad / touch variant) and the CTA. The first
3
+ // "Waddle in" cues the BIOS; later Esc / the in-game Back button reopen it
4
+ // as pause. Rows fade up once, in reading order - the stagger replays on
5
+ // every open because the component remounts.
6
+ import { useEffect, useRef, useState } from "react";
7
+ import Box from "@mui/material/Box";
8
+ import Typography from "@mui/material/Typography";
9
+ import ButtonBase from "@mui/material/ButtonBase";
10
+ import { keyframes, styled } from "@mui/material/styles";
11
+ import { useGame } from "../store.js";
12
+ import { signed } from "../game/signed.js";
13
+ import { INK, ORANGE } from "../theme.js";
14
+
15
+ const rowIn = keyframes`
16
+ from { transform: translateY(12px); opacity: 0; }
17
+ to { transform: none; opacity: 1; }
18
+ `;
19
+ const brandIn = keyframes`
20
+ from { transform: translateY(10px) scale(0.94); opacity: 0; }
21
+ to { transform: none; opacity: 1; }
22
+ `;
23
+
24
+ // One soft ease, small travel, ~80 ms between rows - staged, not showy.
25
+ const row = (delay, name = rowIn) => ({
26
+ animation: `${name} 0.55s cubic-bezier(0.22, 1, 0.36, 1) both`,
27
+ animationDelay: `${delay}s`,
28
+ "@media (prefers-reduced-motion: reduce)": { animation: "none" },
29
+ });
30
+
31
+ const Kbd = styled("kbd")(({ round }) => ({
32
+ display: "inline-flex",
33
+ alignItems: "center",
34
+ justifyContent: "center",
35
+ minWidth: round ? "1.9rem" : "1.65rem",
36
+ height: "1.65rem",
37
+ padding: "0 0.45rem",
38
+ font: "inherit",
39
+ fontSize: "0.68rem",
40
+ fontWeight: 600,
41
+ color: "#fff",
42
+ background: "#14141c",
43
+ border: `2px solid ${INK}`,
44
+ borderRadius: round ? "50%" : 8,
45
+ boxShadow: "0 0 0 2px rgba(255, 255, 255, 0.82)",
46
+ }));
47
+
48
+ const TILES = {
49
+ kb: [
50
+ { caps: "cluster-arrows", name: "Move", hint: "arrows or ZQSD" },
51
+ { caps: ["A", "E"], name: "Kick", hint: "left / right" },
52
+ { caps: ["R"], name: "Roll", hint: "barrel roll" },
53
+ { caps: ["B"], name: "Ball", hint: "pop a fresh one" },
54
+ { caps: ["C"], name: "Camera", hint: "toggle chase" },
55
+ { caps: ["Space"], name: "Reset", hint: "fresh start" },
56
+ ],
57
+ pad: [
58
+ { caps: ["LS"], name: "Move", hint: "left stick" },
59
+ { caps: ["LB", "RB"], name: "Kick", hint: "left / right" },
60
+ { caps: ["X"], name: "Roll", hint: "barrel roll" },
61
+ { caps: ["Y"], name: "Ball", hint: "pop a fresh one" },
62
+ { caps: ["RS"], name: "Camera", hint: "orbit" },
63
+ { caps: ["\u2193"], name: "Sit", hint: "stand up" },
64
+ ],
65
+ touch: [
66
+ { caps: ["Stick"], name: "Move", hint: "left thumb" },
67
+ { caps: ["A"], round: true, name: "Kick", hint: "tap it" },
68
+ { caps: ["B"], round: true, name: "Quack", hint: "hold for beak" },
69
+ ],
70
+ };
71
+ const HINTS = {
72
+ kb: "drag to orbit \u00b7 scroll to zoom",
73
+ pad: "R3 chase \u00b7 RT quack",
74
+ touch: "drag to orbit \u00b7 pinch to zoom",
75
+ };
76
+
77
+ function closeMenu() {
78
+ useGame.setState({ menuOpen: false });
79
+ if (!useGame.getState().entered) useGame.setState({ entered: true });
80
+ }
81
+
82
+ export default function TitleMenu() {
83
+ const menuOpen = useGame((s) => s.menuOpen);
84
+ const entered = useGame((s) => s.entered);
85
+ const padConnected = useGame((s) => s.padConnected);
86
+ const touchMode = useGame((s) => s.touchMode);
87
+ const [closing, setClosing] = useState(false);
88
+ const prevOpen = useRef(menuOpen);
89
+
90
+ // Keep the overlay mounted through the 0.35 s closing fade.
91
+ useEffect(() => {
92
+ const was = prevOpen.current;
93
+ prevOpen.current = menuOpen;
94
+ if (was && !menuOpen) {
95
+ setClosing(true);
96
+ const t = setTimeout(() => setClosing(false), 380);
97
+ return () => clearTimeout(t);
98
+ }
99
+ }, [menuOpen]);
100
+
101
+ // Enter enters / resumes; Esc toggles the pause menu (never over the
102
+ // BIOS readout).
103
+ useEffect(() => {
104
+ const onKey = (e) => {
105
+ const s = useGame.getState();
106
+ if (e.code === "Enter" && s.menuOpen) {
107
+ if (e.target instanceof HTMLButtonElement && e.target.dataset.cta !== "1") return;
108
+ e.preventDefault();
109
+ closeMenu();
110
+ return;
111
+ }
112
+ if (e.code !== "Escape") return;
113
+ if (s.biosVisible) return;
114
+ if (s.menuOpen) {
115
+ if (s.entered) closeMenu();
116
+ return;
117
+ }
118
+ if (s.entered) useGame.setState({ menuOpen: true });
119
+ };
120
+ window.addEventListener("keydown", onKey);
121
+ return () => window.removeEventListener("keydown", onKey);
122
+ }, []);
123
+
124
+ if (!menuOpen && !closing) return null;
125
+
126
+ // A plugged-in gamepad wins over the touch tutorial.
127
+ const variant = padConnected ? "pad" : touchMode ? "touch" : "kb";
128
+ const tiles = TILES[variant];
129
+
130
+ return (
131
+ <Box
132
+ role="dialog"
133
+ aria-modal="true"
134
+ aria-label="Microduck"
135
+ sx={{
136
+ position: "fixed",
137
+ inset: 0,
138
+ zIndex: 30,
139
+ display: "flex",
140
+ flexDirection: "column",
141
+ alignItems: "center",
142
+ justifyContent: "center",
143
+ p: "2.4rem 1.4rem 1.8rem",
144
+ background: INK,
145
+ opacity: menuOpen ? 1 : 0,
146
+ pointerEvents: menuOpen ? "auto" : "none",
147
+ overflowY: "auto",
148
+ transition: "opacity 0.35s ease, background 0.4s ease",
149
+ }}
150
+ >
151
+ <Box sx={{ width: "100%", maxWidth: "min(48rem, 92vw)", textAlign: "center" }}>
152
+ {/* Vitrine brand lockup, centered above the title: drawn duck head
153
+ + name. Hovering swaps to the open-beak frame, same hard sprite
154
+ swap as the vitrine header. */}
155
+ <Box
156
+ aria-hidden
157
+ sx={{
158
+ display: "flex",
159
+ flexDirection: "column",
160
+ alignItems: "center",
161
+ justifyContent: "center",
162
+ gap: "0.55rem",
163
+ mb: "1.3rem",
164
+ userSelect: "none",
165
+ ...row(0, brandIn),
166
+ "&:hover .duck-closed": { opacity: 0 },
167
+ "&:hover .duck-open": { opacity: 1 },
168
+ }}
169
+ >
170
+ <Box
171
+ component="span"
172
+ sx={{
173
+ position: "relative",
174
+ display: "block",
175
+ height: { xs: "2.4rem", sm: "3rem" },
176
+ }}
177
+ >
178
+ <Box
179
+ component="img"
180
+ className="duck-closed"
181
+ alt=""
182
+ src={signed("./assets/duck-head-mark.webp")}
183
+ sx={{ display: "block", height: "100%", width: "auto" }}
184
+ />
185
+ {/* The open frame's canvas (460x333) is a hair wider/taller
186
+ than the closed one (454x269): the dropped jaw sticks out
187
+ past the head. Offsets pin the head in place while the jaw
188
+ hangs below. */}
189
+ <Box
190
+ component="img"
191
+ className="duck-open"
192
+ alt=""
193
+ src={signed("./assets/duck-head-mark-open.webp")}
194
+ sx={{
195
+ position: "absolute",
196
+ top: "-0.75%",
197
+ left: "-1.1%",
198
+ width: "101.3%",
199
+ maxWidth: "none",
200
+ height: "auto",
201
+ opacity: 0,
202
+ }}
203
+ />
204
+ </Box>
205
+ <Box
206
+ component="span"
207
+ sx={{
208
+ fontWeight: 700,
209
+ letterSpacing: "-0.03em",
210
+ fontSize: { xs: "1.3rem", sm: "1.6rem" },
211
+ color: "#fff",
212
+ }}
213
+ >
214
+ MicroDuck
215
+ </Box>
216
+ </Box>
217
+
218
+ <Box
219
+ sx={{
220
+ display: "flex",
221
+ flexWrap: "wrap",
222
+ alignItems: "center",
223
+ justifyContent: "center",
224
+ gap: "0.6rem",
225
+ mb: "1.15rem",
226
+ fontSize: "0.72rem",
227
+ fontWeight: 600,
228
+ letterSpacing: "0.14em",
229
+ textTransform: "uppercase",
230
+ color: "rgba(255, 255, 255, 0.78)",
231
+ ...row(0.08),
232
+ "& span:not(:last-child)::after": {
233
+ content: '"\u00b7"',
234
+ color: ORANGE,
235
+ pl: "0.6em",
236
+ },
237
+ }}
238
+ >
239
+ <span>MuJoCo physics</span>
240
+ <span>ONNX policies at 50 Hz</span>
241
+ </Box>
242
+
243
+ {/* Landing-grade h1: same type conventions as the vitrine hero. */}
244
+ <Typography
245
+ component="h1"
246
+ sx={{
247
+ m: "0 auto",
248
+ maxWidth: "24ch",
249
+ fontSize: { xs: "2rem", sm: "clamp(2.4rem, 5vw, 3.75rem)" },
250
+ fontWeight: 600,
251
+ letterSpacing: "-0.03em",
252
+ color: "#fff",
253
+ lineHeight: 1.02,
254
+ textWrap: "balance",
255
+ ...row(0.16),
256
+ }}
257
+ >
258
+ The Microduck simulator, live in your browser
259
+ </Typography>
260
+ <Typography
261
+ sx={{
262
+ mx: "auto",
263
+ mt: "1.25rem",
264
+ mb: 0,
265
+ "@media (max-height: 700px)": { mt: "0.85rem" },
266
+ maxWidth: "36ch",
267
+ fontSize: { xs: "0.95rem", sm: "clamp(1.05rem, 1.5vw, 1.3rem)" },
268
+ lineHeight: 1.5,
269
+ letterSpacing: "-0.012em",
270
+ color: "rgba(255, 255, 255, 0.72)",
271
+ textWrap: "balance",
272
+ ...row(0.24),
273
+ }}
274
+ >
275
+ The exact same trained policies that drive the real robot.
276
+ </Typography>
277
+
278
+ <Box
279
+ sx={{
280
+ display: "grid",
281
+ gridTemplateColumns: {
282
+ xs: variant === "touch" ? "repeat(3, 1fr)" : "repeat(2, 1fr)",
283
+ sm: "repeat(3, 1fr)",
284
+ },
285
+ gap: "0.55rem",
286
+ m: "1.4rem auto 0",
287
+ maxWidth: "36rem",
288
+ ...row(0.32),
289
+ }}
290
+ >
291
+ {tiles.map((t) => (
292
+ <Box
293
+ key={t.name}
294
+ sx={{
295
+ p: { xs: "0.65rem 0.35rem 0.6rem", sm: "0.85rem 0.45rem 0.75rem" },
296
+ border: "1px solid rgba(255, 255, 255, 0.08)",
297
+ borderRadius: "18px",
298
+ background: "rgba(255, 255, 255, 0.035)",
299
+ }}
300
+ >
301
+ <Box
302
+ sx={{
303
+ display: "flex",
304
+ flexDirection: "column",
305
+ alignItems: "center",
306
+ justifyContent: "center",
307
+ gap: "0.2rem",
308
+ height: { xs: "2.8rem", sm: "3.3rem" },
309
+ // Arrow cluster keycaps are a notch smaller so two rows fit
310
+ "& .cluster kbd": {
311
+ minWidth: "1.4rem",
312
+ height: "1.4rem",
313
+ p: "0 0.3rem",
314
+ fontSize: "0.62rem",
315
+ },
316
+ }}
317
+ >
318
+ {t.caps === "cluster-arrows" ? (
319
+ <Box
320
+ className="cluster"
321
+ sx={{ display: "flex", flexDirection: "column", alignItems: "center", gap: "0.2rem" }}
322
+ >
323
+ <Box sx={{ display: "flex", gap: "0.22rem", justifyContent: "center" }}>
324
+ <Kbd>{"\u2191"}</Kbd>
325
+ </Box>
326
+ <Box sx={{ display: "flex", gap: "0.22rem", justifyContent: "center" }}>
327
+ <Kbd>{"\u2190"}</Kbd>
328
+ <Kbd>{"\u2193"}</Kbd>
329
+ <Kbd>{"\u2192"}</Kbd>
330
+ </Box>
331
+ </Box>
332
+ ) : (
333
+ <Box sx={{ display: "flex", gap: "0.22rem", justifyContent: "center" }}>
334
+ {t.caps.map((c) => (
335
+ <Kbd key={c} round={t.round ? 1 : 0}>{c}</Kbd>
336
+ ))}
337
+ </Box>
338
+ )}
339
+ </Box>
340
+ <Box
341
+ sx={{
342
+ mt: "0.55rem",
343
+ fontSize: "0.72rem",
344
+ fontWeight: 600,
345
+ letterSpacing: "-0.01em",
346
+ color: "#fff",
347
+ }}
348
+ >
349
+ {t.name}
350
+ </Box>
351
+ <Box
352
+ sx={{
353
+ mt: "0.18rem",
354
+ fontSize: "0.62rem",
355
+ fontWeight: 600,
356
+ letterSpacing: "0.12em",
357
+ textTransform: "uppercase",
358
+ color: ORANGE,
359
+ }}
360
+ >
361
+ {t.hint}
362
+ </Box>
363
+ </Box>
364
+ ))}
365
+ </Box>
366
+
367
+ <Typography
368
+ sx={{
369
+ mt: "1rem",
370
+ fontSize: "0.68rem",
371
+ fontWeight: 600,
372
+ letterSpacing: "0.08em",
373
+ color: "rgba(255, 255, 255, 0.4)",
374
+ ...row(0.48),
375
+ }}
376
+ >
377
+ {HINTS[variant]}
378
+ </Typography>
379
+ </Box>
380
+
381
+ <Box
382
+ sx={{
383
+ display: "flex",
384
+ flexDirection: "column",
385
+ alignItems: "center",
386
+ gap: "1rem",
387
+ mt: "1.6rem",
388
+ "@media (max-height: 700px)": { mt: "1.1rem" },
389
+ width: "100%",
390
+ ...row(0.56),
391
+ }}
392
+ >
393
+ <ButtonBase
394
+ data-cta="1"
395
+ onClick={closeMenu}
396
+ focusRipple
397
+ sx={{
398
+ border: `3px solid ${INK}`,
399
+ background: ORANGE,
400
+ color: INK,
401
+ font: "inherit",
402
+ fontSize: { xs: "0.95rem", sm: "1.02rem" },
403
+ fontWeight: 600,
404
+ letterSpacing: "-0.01em",
405
+ p: { xs: "0.75rem 1.5rem", sm: "0.85rem 1.9rem" },
406
+ borderRadius: "999px",
407
+ boxShadow: "0 0 0 3px #fff",
408
+ cursor: "pointer",
409
+ transition: "transform 0.2s ease, box-shadow 0.2s ease",
410
+ "&:hover": {
411
+ transform: "translateY(-2px)",
412
+ boxShadow: "0 0 0 3px #fff, 0 10px 26px rgba(255, 122, 47, 0.3)",
413
+ },
414
+ "&:active": {
415
+ transform: "translateY(1px)",
416
+ boxShadow: "0 0 0 3px #fff",
417
+ },
418
+ "&.Mui-focusVisible": {
419
+ outline: "2px solid #fff",
420
+ outlineOffset: "4px",
421
+ },
422
+ }}
423
+ >
424
+ {entered ? "Resume" : "Waddle in"}
425
+ </ButtonBase>
426
+ </Box>
427
+ </Box>
428
+ );
429
+ }
app/src/ui/TouchOverlay.jsx ADDED
@@ -0,0 +1,128 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ // Touch overlay: Game Boy thumbs. Left thumb = floating virtual stick
2
+ // (the whole lower-left quadrant grabs it, the base re-anchors under the
3
+ // finger), right thumb = A/B caps on the classic diagonal.
4
+ //
5
+ // The DOM here is deliberately plain and keyed by ids: controls/touch.js
6
+ // binds pointer events to #touch-zone / #touch-stick / #touch-a / #touch-b
7
+ // at controller init and toggles the .live/.down classes itself. React
8
+ // only owns the wrapper's visibility, so it never fights those mutations -
9
+ // which is also why this component must stay mounted at all times.
10
+ import Box from "@mui/material/Box";
11
+ import { useGame } from "../store.js";
12
+ import { INK, ORANGE } from "../theme.js";
13
+
14
+ export default function TouchOverlay() {
15
+ const touchMode = useGame((s) => s.touchMode);
16
+ const entered = useGame((s) => s.entered);
17
+ const menuOpen = useGame((s) => s.menuOpen);
18
+ const visible = touchMode && entered && !menuOpen;
19
+
20
+ return (
21
+ <Box
22
+ id="touch-ui"
23
+ sx={{
24
+ display: visible ? "block" : "none",
25
+ // Floating-stick zone: sits BELOW the back button in z so its tap
26
+ // still lands; the resting stick inside marks the zone when idle.
27
+ "& #touch-zone": {
28
+ position: "fixed",
29
+ left: 0,
30
+ right: "50%",
31
+ top: "30%",
32
+ bottom: 0,
33
+ zIndex: 9,
34
+ touchAction: "none",
35
+ },
36
+ "& #touch-stick": {
37
+ position: "absolute",
38
+ left: "1.3rem",
39
+ bottom: "2rem",
40
+ width: "8.2rem",
41
+ height: "8.2rem",
42
+ borderRadius: "50%",
43
+ border: "1px solid rgba(255, 255, 255, 0.18)",
44
+ background: "rgba(8, 8, 12, 0.4)",
45
+ pointerEvents: "none", // the zone owns the pointer
46
+ },
47
+ "& #touch-stick .nub": {
48
+ position: "absolute",
49
+ left: "50%",
50
+ top: "50%",
51
+ width: "3.6rem",
52
+ height: "3.6rem",
53
+ margin: "-1.8rem 0 0 -1.8rem",
54
+ borderRadius: "50%",
55
+ border: "1px solid rgba(255, 255, 255, 0.35)",
56
+ background: "rgba(255, 255, 255, 0.14)",
57
+ pointerEvents: "none",
58
+ },
59
+ "& #touch-stick.live": { borderColor: "rgba(255, 122, 47, 0.55)" },
60
+ "& #touch-stick.live .nub": {
61
+ background: "rgba(255, 122, 47, 0.45)",
62
+ borderColor: ORANGE,
63
+ },
64
+ "& #touch-btns": {
65
+ position: "fixed",
66
+ right: "1.3rem",
67
+ bottom: "2.2rem",
68
+ zIndex: 12,
69
+ width: "9.4rem",
70
+ height: "8.4rem",
71
+ },
72
+ "& .capwrap": {
73
+ position: "absolute",
74
+ display: "flex",
75
+ flexDirection: "column",
76
+ alignItems: "center",
77
+ gap: "0.3rem",
78
+ },
79
+ "& .cap-a": { right: 0, top: 0 },
80
+ "& .cap-b": { left: 0, bottom: 0 },
81
+ "& #touch-btns button": {
82
+ appearance: "none",
83
+ width: "4.2rem",
84
+ height: "4.2rem",
85
+ borderRadius: "50%",
86
+ border: "1px solid rgba(255, 255, 255, 0.22)",
87
+ background: "rgba(8, 8, 12, 0.5)",
88
+ color: "rgba(255, 255, 255, 0.85)",
89
+ font: "inherit",
90
+ fontSize: "1.3rem",
91
+ fontWeight: 700,
92
+ cursor: "pointer",
93
+ touchAction: "none",
94
+ userSelect: "none",
95
+ WebkitUserSelect: "none",
96
+ },
97
+ "& #touch-btns button.down": {
98
+ background: ORANGE,
99
+ borderColor: ORANGE,
100
+ color: INK,
101
+ },
102
+ "& .capwrap span": {
103
+ fontSize: "0.55rem",
104
+ fontWeight: 600,
105
+ letterSpacing: "0.18em",
106
+ textTransform: "uppercase",
107
+ color: "rgba(255, 255, 255, 0.45)",
108
+ },
109
+ }}
110
+ >
111
+ <div id="touch-zone">
112
+ <div id="touch-stick">
113
+ <div className="nub" />
114
+ </div>
115
+ </div>
116
+ <div id="touch-btns">
117
+ <div className="capwrap cap-a">
118
+ <button type="button" id="touch-a" aria-label="Kick">A</button>
119
+ <span>Kick</span>
120
+ </div>
121
+ <div className="capwrap cap-b">
122
+ <button type="button" id="touch-b" aria-label="Quack">B</button>
123
+ <span>Quack</span>
124
+ </div>
125
+ </div>
126
+ </Box>
127
+ );
128
+ }
app/vite.config.js ADDED
@@ -0,0 +1,19 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ import { defineConfig } from "vite";
2
+ import react from "@vitejs/plugin-react";
3
+
4
+ export default defineConfig({
5
+ plugins: [react()],
6
+ // MuJoCo WASM and onnxruntime-web stay on the CDN (dynamic import with
7
+ // @vite-ignore in game/boot.js), exactly like the pre-Vite app: their
8
+ // .wasm sidecars resolve relative to the CDN URL and never touch the
9
+ // bundle.
10
+ server: {
11
+ port: 5173,
12
+ },
13
+ build: {
14
+ // Keep the JS/CSS bundle out of dist/assets/: the game's static assets
15
+ // (public/assets/) land there and must keep their historical URLs.
16
+ assetsDir: "bundle",
17
+ chunkSizeWarningLimit: 1500,
18
+ },
19
+ });