// 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 ; }