microduck-reachy-simulator / src /ui /MenuReachy.jsx
laureen
Withdraw Code Training from published main pending fixes
99e34b4
Raw
History Blame Contribute Delete
6.77 kB
// Title-screen stage using the same official Reachy Mini MJCF rig that the
// simulator renders. The game has already booted before the title is shown,
// so this is a render-only clone of the live rig: no second physics world is
// created and no invented illustration is used.
import { useEffect, useRef } from "react";
import Box from "@mui/material/Box";
import * as THREE from "three";
import { RoomEnvironment } from "three/addons/environments/RoomEnvironment.js";
let stagePromise = null;
let stageReady = false;
export const isMenuReachyReady = () => stageReady;
export const preloadMenuReachy = () => {
if (!stagePromise) {
stagePromise = new Promise((resolve, reject) => {
const started = performance.now();
const wait = () => {
const root = window.rl?.reachyRig?.root;
if (root) {
stageReady = true;
resolve(buildStage(root));
return;
}
// The shared MuJoCo world may still be compiling when the title
// preloader starts. Give the official rig enough time to arrive so
// the Reachy stage is not silently replaced by an empty panel.
if (performance.now() - started > 30000) {
stageReady = true;
reject(new Error("Reachy Mini title rig was not ready"));
return;
}
requestAnimationFrame(wait);
};
wait();
});
}
return stagePromise;
};
function buildStage(sourceRoot) {
const scene = new THREE.Scene();
const camera = new THREE.PerspectiveCamera(30, 1, 0.02, 20);
camera.position.set(0, 0.28, 0.95);
camera.lookAt(0, 0.25, 0);
const renderer = new THREE.WebGLRenderer({ antialias: true, alpha: true });
renderer.setClearColor(0x000000, 0);
renderer.setPixelRatio(Math.min(2, window.devicePixelRatio));
renderer.toneMapping = THREE.ACESFilmicToneMapping;
const el = renderer.domElement;
el.style.position = "absolute";
el.style.inset = "0";
el.style.width = "100%";
el.style.height = "100%";
el.style.opacity = "0";
el.style.transition = "opacity .45s ease";
const pmrem = new THREE.PMREMGenerator(renderer);
scene.environment = pmrem.fromScene(new RoomEnvironment()).texture;
scene.environmentIntensity = 0.35;
scene.add(new THREE.HemisphereLight(0xffffff, 0x24232b, 1.8));
const key = new THREE.DirectionalLight(0xffffff, 2.1);
key.position.set(2, 4, 3);
scene.add(key);
const rim = new THREE.DirectionalLight(0xffb366, 0.8);
rim.position.set(-2, 3, -2);
scene.add(rim);
const rig = sourceRoot.clone(true);
rig.scale.setScalar(1.15);
rig.traverse((node) => { if (node.isMesh) node.frustumCulled = false; });
scene.add(rig);
const bounds = new THREE.Box3().setFromObject(rig);
const center = bounds.getCenter(new THREE.Vector3());
rig.position.x -= center.x;
rig.position.y -= bounds.min.y;
rig.position.z -= center.z;
// Fit the official model to its narrow half of the title stage instead of
// relying on a hand-tuned camera distance. This keeps the whole robot
// visible even when the MJCF mesh bounds change between releases.
const fitted = new THREE.Box3().setFromObject(rig);
const fittedCenter = fitted.getCenter(new THREE.Vector3());
const radius = fitted.getSize(new THREE.Vector3()).length() * 0.5;
camera.position.set(fittedCenter.x, fittedCenter.y + radius * 0.08, fittedCenter.z + Math.max(0.8, radius * 2.8));
camera.lookAt(fittedCenter);
rig.updateMatrixWorld(true);
const sourceByName = new Map();
// Meshes have an empty Three.js name. Do not map/copy those entries: the
// live pose only belongs to the named MuJoCo body groups, while mesh-local
// transforms must stay exactly as authored in the official MJCF rig.
sourceRoot.traverse((node) => { if (node.name) sourceByName.set(node.name, node); });
const head = [...rig.children].flatMap((node) => {
const found = [];
node.traverse((child) => { if (child.name.endsWith("/xl_330")) found.push(child); });
return found;
})[0] ?? rig.getObjectByName("reachy/xl_330");
const leftAntenna = rig.getObjectByName("reachy/dc15_a01_horn_dummy_8");
const rightAntenna = rig.getObjectByName("reachy/dc15_a01_horn_dummy_7");
const headBase = new THREE.Quaternion();
const leftBase = new THREE.Quaternion();
const rightBase = new THREE.Quaternion();
const copyLivePose = () => rig.traverse((node) => {
const source = node.name ? sourceByName.get(node.name) : null;
if (!source || node === rig) return;
node.position.copy(source.position);
node.quaternion.copy(source.quaternion);
});
let mount = null;
let raf = 0;
let previous = 0;
const loop = (now) => {
raf = requestAnimationFrame(loop);
copyLivePose();
if (head) {
headBase.copy(head.quaternion);
head.quaternion.multiply(new THREE.Quaternion().setFromEuler(new THREE.Euler(
Math.sin(now * 0.0011) * 0.08,
Math.sin(now * 0.0008) * 0.18,
0,
)));
}
if (leftAntenna && rightAntenna) {
leftBase.copy(leftAntenna.quaternion);
rightBase.copy(rightAntenna.quaternion);
const antennaWave = Math.sin(now * 0.0022) * 0.28;
leftAntenna.quaternion.multiply(new THREE.Quaternion().setFromAxisAngle(new THREE.Vector3(0, 0, 1), antennaWave));
rightAntenna.quaternion.multiply(new THREE.Quaternion().setFromAxisAngle(new THREE.Vector3(0, 0, 1), -antennaWave));
}
renderer.render(scene, camera);
previous = now;
if (el.style.opacity === "0") el.style.opacity = "1";
};
const resize = () => {
if (!mount) return;
const width = mount.clientWidth, height = mount.clientHeight;
if (!width || !height) return;
camera.aspect = width / height;
camera.updateProjectionMatrix();
renderer.setSize(width, height);
};
const observer = new ResizeObserver(resize);
return {
attach(host) {
if (mount) this.detach();
mount = host;
mount.appendChild(el);
observer.observe(mount);
resize();
previous = performance.now();
raf = requestAnimationFrame(loop);
},
detach() {
cancelAnimationFrame(raf);
observer.disconnect();
el.remove();
mount = null;
},
};
}
export default function MenuReachy({ sx }) {
const hostRef = useRef(null);
useEffect(() => {
let disposed = false;
preloadMenuReachy().then((stage) => {
if (!disposed && hostRef.current) stage.attach(hostRef.current);
}).catch((error) => console.warn("menu Reachy disabled:", error));
return () => {
disposed = true;
stagePromise?.then((stage) => stage.detach()).catch(() => {});
};
}, []);
return <Box ref={hostRef} aria-label="Reachy Mini" sx={{ position: "relative", touchAction: "none", userSelect: "none", ...sx }} />;
}