feat: Vite+React+MUI+R3F app shell in app/ (framework-agnostic game core, zustand bridge)
Browse files- Dockerfile +15 -0
- app/.gitignore +2 -0
- app/index.html +21 -0
- app/package-lock.json +2052 -0
- app/package.json +25 -0
- app/public/assets +1 -0
- app/public/policies +1 -0
- app/public/robot +1 -0
- app/src/App.jsx +87 -0
- app/src/game/arena.js +235 -0
- app/src/game/ball-actor.js +102 -0
- app/src/game/ball-visual.js +149 -0
- app/src/game/ceremony.js +236 -0
- app/src/game/constants.js +74 -0
- app/src/game/controls/controller.js +188 -0
- app/src/game/controls/gamepad.js +153 -0
- app/src/game/controls/keyboard.js +116 -0
- app/src/game/controls/touch.js +180 -0
- app/src/game/duck.js +339 -0
- app/src/game/fx/demo-glitch.html +140 -0
- app/src/game/fx/demo-wireframe.html +113 -0
- app/src/game/fx/fx-glitch.js +311 -0
- app/src/game/fx/fx-wireframe.js +323 -0
- app/src/game/game.js +1305 -0
- app/src/game/ghosts.js +288 -0
- app/src/game/signed.js +13 -0
- app/src/game/stickers.js +222 -0
- app/src/game/variants.js +319 -0
- app/src/main.jsx +14 -0
- app/src/scene/GameCanvas.jsx +59 -0
- app/src/store.js +80 -0
- app/src/theme.js +25 -0
- app/src/ui/BiosOverlay.jsx +188 -0
- app/src/ui/Hud.jsx +317 -0
- app/src/ui/Overlays.jsx +111 -0
- app/src/ui/Preboot.jsx +54 -0
- app/src/ui/TitleMenu.jsx +429 -0
- app/src/ui/TouchOverlay.jsx +128 -0
- app/vite.config.js +19 -0
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 |
+
});
|