feat: kickable ball (B / pad Y) and detachable chase camera (C)
Browse filesBall: a light free sphere injected into the MJCF after the robot body
(keyframe qpos grown 21 -> 28, ball parked 50 m away by default). B or
gamepad Y pops/respawns it ~0.35 m in front of the duck with a small
jitter; a watchdog teleports it back when it rolls past the grid fade
(3 m); R re-parks it. Rendered as a procedural beach ball, local-only
(not shared with multiplayer ghosts).
Camera: chase mode on by default eases the camera behind the duck's
heading at the current orbit distance (wheel zoom preserved); dragging
detaches back to free orbit-follow, C toggles chase back on. HUD keycaps,
welcome modal and README updated; cache-buster bumped to 26.
Co-authored-by: Cursor <cursoragent@cursor.com>
- README.md +6 -0
- index.html +6 -1
- rl.js +181 -5
README.md
CHANGED
|
@@ -33,10 +33,15 @@ and [apirrone/mjlab_microduck](https://github.com/apirrone/mjlab_microduck).
|
|
| 33 |
- A / E: strafe
|
| 34 |
- Space: roll (one barrel roll, then back to running)
|
| 35 |
- F: kick, alternating feet
|
|
|
|
|
|
|
| 36 |
- R: reset
|
| 37 |
- Drag to orbit, scroll to zoom
|
| 38 |
- Colour dots: repaint the duck (it quacks)
|
| 39 |
|
|
|
|
|
|
|
|
|
|
| 40 |
### Gamepad
|
| 41 |
|
| 42 |
Plug in a controller and the same mapping as the real robot runtime applies:
|
|
@@ -44,6 +49,7 @@ Plug in a controller and the same mapping as the real robot runtime applies:
|
|
| 44 |
- Left stick: forward / back + strafe
|
| 45 |
- Right stick: turn
|
| 46 |
- X: roll
|
|
|
|
| 47 |
- RB / LB: right / left kick
|
| 48 |
- D-pad down: sit / stand toggle
|
| 49 |
- Right trigger: mouth (analog) + quack
|
|
|
|
| 33 |
- A / E: strafe
|
| 34 |
- Space: roll (one barrel roll, then back to running)
|
| 35 |
- F: kick, alternating feet
|
| 36 |
+
- B: pop / respawn a kickable ball in front of the duck
|
| 37 |
+
- C: toggle the chase camera (on by default; dragging detaches it)
|
| 38 |
- R: reset
|
| 39 |
- Drag to orbit, scroll to zoom
|
| 40 |
- Colour dots: repaint the duck (it quacks)
|
| 41 |
|
| 42 |
+
The ball is local-only: it lives in your tab's physics and is not shared
|
| 43 |
+
with the multiplayer ghosts.
|
| 44 |
+
|
| 45 |
### Gamepad
|
| 46 |
|
| 47 |
Plug in a controller and the same mapping as the real robot runtime applies:
|
|
|
|
| 49 |
- Left stick: forward / back + strafe
|
| 50 |
- Right stick: turn
|
| 51 |
- X: roll
|
| 52 |
+
- Y: pop / respawn the ball
|
| 53 |
- RB / LB: right / left kick
|
| 54 |
- D-pad down: sit / stand toggle
|
| 55 |
- Right trigger: mouth (analog) + quack
|
index.html
CHANGED
|
@@ -393,10 +393,13 @@
|
|
| 393 |
<span class="hint"><span class="key" id="key-left">A</span><span class="key" id="key-right">E</span> strafe</span><br />
|
| 394 |
<span class="hint"><span class="key" id="key-roll">Space</span> roll</span>
|
| 395 |
<span class="hint"><span class="key" id="key-kick">F</span> kick</span>
|
|
|
|
|
|
|
| 396 |
<span class="hint"><span class="key" id="key-reset">R</span> reset</span>
|
| 397 |
drag to orbit · scroll to zoom<br />
|
| 398 |
<span id="pad-hint">gamepad:
|
| 399 |
<span class="hint"><span class="key" id="key-pad-x">X</span> roll</span>
|
|
|
|
| 400 |
<span class="hint"><span class="key" id="key-pad-rb">RB</span><span class="key" id="key-pad-lb">LB</span> kick</span>
|
| 401 |
<span class="hint"><span class="key" id="key-pad-sit">⇓</span> sit</span>
|
| 402 |
<span class="hint"><span class="key" id="key-pad-run">⇑</span> run</span>
|
|
@@ -422,6 +425,8 @@
|
|
| 422 |
<li><b>Move</b><span>Arrows or ZQSD to run and turn, A / E to strafe, drag to orbit.</span></li>
|
| 423 |
<li><b>Roll</b><span>Space triggers a barrel roll, then it gets back up.</span></li>
|
| 424 |
<li><b>Kick</b><span>F throws a kick, alternating feet (RB / LB on the pad).</span></li>
|
|
|
|
|
|
|
| 425 |
<li><b>Gamepad</b><span>Plug one in - same mapping as the real robot.</span></li>
|
| 426 |
<li><b>Ghosts</b><span>Other visitors right now appear as translucent ducks, live.</span></li>
|
| 427 |
</ul>
|
|
@@ -471,7 +476,7 @@
|
|
| 471 |
appended: a plain src attribute can 401 when the browser blocks the
|
| 472 |
*.static.hf.space auth cookie inside the hub iframe. -->
|
| 473 |
<script type="module">
|
| 474 |
-
const V = "
|
| 475 |
const sign = new URLSearchParams(location.search).get("__sign");
|
| 476 |
const suffix = sign ? `&__sign=${encodeURIComponent(sign)}` : "";
|
| 477 |
import(`./rl.js?v=${V}${suffix}`);
|
|
|
|
| 393 |
<span class="hint"><span class="key" id="key-left">A</span><span class="key" id="key-right">E</span> strafe</span><br />
|
| 394 |
<span class="hint"><span class="key" id="key-roll">Space</span> roll</span>
|
| 395 |
<span class="hint"><span class="key" id="key-kick">F</span> kick</span>
|
| 396 |
+
<span class="hint"><span class="key" id="key-ball">B</span> ball</span>
|
| 397 |
+
<span class="hint"><span class="key" id="key-cam">C</span> camera</span>
|
| 398 |
<span class="hint"><span class="key" id="key-reset">R</span> reset</span>
|
| 399 |
drag to orbit · scroll to zoom<br />
|
| 400 |
<span id="pad-hint">gamepad:
|
| 401 |
<span class="hint"><span class="key" id="key-pad-x">X</span> roll</span>
|
| 402 |
+
<span class="hint"><span class="key" id="key-pad-y">Y</span> ball</span>
|
| 403 |
<span class="hint"><span class="key" id="key-pad-rb">RB</span><span class="key" id="key-pad-lb">LB</span> kick</span>
|
| 404 |
<span class="hint"><span class="key" id="key-pad-sit">⇓</span> sit</span>
|
| 405 |
<span class="hint"><span class="key" id="key-pad-run">⇑</span> run</span>
|
|
|
|
| 425 |
<li><b>Move</b><span>Arrows or ZQSD to run and turn, A / E to strafe, drag to orbit.</span></li>
|
| 426 |
<li><b>Roll</b><span>Space triggers a barrel roll, then it gets back up.</span></li>
|
| 427 |
<li><b>Kick</b><span>F throws a kick, alternating feet (RB / LB on the pad).</span></li>
|
| 428 |
+
<li><b>Ball</b><span>B pops a ball to kick around (Y on the pad).</span></li>
|
| 429 |
+
<li><b>Camera</b><span>Follows from behind; drag to break free, C re-attaches.</span></li>
|
| 430 |
<li><b>Gamepad</b><span>Plug one in - same mapping as the real robot.</span></li>
|
| 431 |
<li><b>Ghosts</b><span>Other visitors right now appear as translucent ducks, live.</span></li>
|
| 432 |
</ul>
|
|
|
|
| 476 |
appended: a plain src attribute can 401 when the browser blocks the
|
| 477 |
*.static.hf.space auth cookie inside the hub iframe. -->
|
| 478 |
<script type="module">
|
| 479 |
+
const V = "26"; // bump on rl.js changes to defeat heuristic caching
|
| 480 |
const sign = new URLSearchParams(location.search).get("__sign");
|
| 481 |
const suffix = sign ? `&__sign=${encodeURIComponent(sign)}` : "";
|
| 482 |
import(`./rl.js?v=${V}${suffix}`);
|
rl.js
CHANGED
|
@@ -79,6 +79,13 @@ const CTRL_DT = TIMESTEP * DECIMATION; // 50 Hz
|
|
| 79 |
// Velocity command limits, same as infer_policy.py's keyboard mapping.
|
| 80 |
const VEL_FWD = 0.25, VEL_BACK = -0.2, VEL_LAT = 0.2, VEL_ANG = 1.0;
|
| 81 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 82 |
const mount = document.getElementById("scene");
|
| 83 |
const loadingEl = document.getElementById("loading");
|
| 84 |
const hudEl = document.getElementById("hud");
|
|
@@ -137,11 +144,32 @@ async function buildPhysicsXml() {
|
|
| 137 |
doc.querySelector("worldbody").appendChild(
|
| 138 |
el("geom", { name: "floor", type: "plane", size: "0 0 0.05", pos: "0 0 0" }),
|
| 139 |
);
|
| 140 |
-
//
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 141 |
const qposFree = "0 0 0.12 1 0 0 0";
|
| 142 |
const pose14 = Array.from(DEFAULT_POSE).join(" ");
|
| 143 |
const kf = doc.createElement("keyframe");
|
| 144 |
-
kf.appendChild(el("key", {
|
|
|
|
|
|
|
|
|
|
|
|
|
| 145 |
root.appendChild(kf);
|
| 146 |
const meshFiles = [...doc.querySelectorAll("asset > mesh")].map((m) => m.getAttribute("file"));
|
| 147 |
return { xml: new XMLSerializer().serializeToString(doc), meshFiles };
|
|
@@ -194,6 +222,8 @@ const dofAdr = JOINT_NAMES.map((n) => model.jnt(n).dofadr);
|
|
| 194 |
const gyroAdr = model.sensor("imu_ang_vel").adr;
|
| 195 |
const trunkId = mujoco.mj_name2id(model, mujoco.mjtObj.mjOBJ_BODY.value, "trunk_base");
|
| 196 |
const standKeyId = mujoco.mj_name2id(model, mujoco.mjtObj.mjOBJ_KEY.value, "STAND");
|
|
|
|
|
|
|
| 197 |
|
| 198 |
let uiReady = false;
|
| 199 |
const lastAction = new Float32Array(NUM_JOINTS);
|
|
@@ -204,7 +234,7 @@ const velCmd = new Float32Array(3); // twist command, driven by held keys
|
|
| 204 |
// the control loop reads it before the input section below has evaluated.
|
| 205 |
const padCmd = new Float32Array(3);
|
| 206 |
let padActive = false;
|
| 207 |
-
const padPrev = { x: false, rb: false, lb: false, dpadDown: false, dpadUp: false, rtArmed: true };
|
| 208 |
// Declared before the control loop starts: buildObs reads it via
|
| 209 |
// effectiveCmd on the very first control step.
|
| 210 |
const held = new Set();
|
|
@@ -212,6 +242,9 @@ const held = new Set();
|
|
| 212 |
let mode = "walk"; // "walk" | "sitstand" | "roll" | "kickL" | "kickR"
|
| 213 |
let sitFlag = 0;
|
| 214 |
const isKick = () => mode === "kickL" || mode === "kickR";
|
|
|
|
|
|
|
|
|
|
| 215 |
|
| 216 |
// The twist the policy actually receives: live gamepad sticks win over the
|
| 217 |
// held keys. No input means zero command - the duck stands in place.
|
|
@@ -257,11 +290,40 @@ function resetSim() {
|
|
| 257 |
mujoco.mj_forward(model, data);
|
| 258 |
lastAction.fill(0);
|
| 259 |
sitFlag = 0;
|
|
|
|
|
|
|
|
|
|
| 260 |
// Buttons reflect sitFlag; keep them honest after auto-resets.
|
| 261 |
if (uiReady) syncButtons();
|
| 262 |
}
|
| 263 |
resetSim();
|
| 264 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 265 |
// ── Observation ─────────────────────────────────────────────────────────
|
| 266 |
const _q = new THREE.Quaternion();
|
| 267 |
const _g = new THREE.Vector3();
|
|
@@ -324,6 +386,16 @@ async function controlStep() {
|
|
| 324 |
fallenSince = null;
|
| 325 |
}
|
| 326 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 327 |
// One-shot roll, step-counted like the robot runtime (a single roll is
|
| 328 |
// ~1 s = 50 control steps there): hand back to walking once the trunk has
|
| 329 |
// tipped over and is upright again, or after a hard 2 s window if the roll
|
|
@@ -462,6 +534,36 @@ const rig = await rigPromise;
|
|
| 462 |
scene.add(rig.placer);
|
| 463 |
const trunkGroup = rig.bodies.get("trunk_base");
|
| 464 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 465 |
const controls = new OrbitControls(camera, renderer.domElement);
|
| 466 |
controls.enableDamping = true;
|
| 467 |
controls.dampingFactor = 0.08;
|
|
@@ -469,6 +571,50 @@ controls.minDistance = 0.25;
|
|
| 469 |
controls.maxDistance = 3;
|
| 470 |
controls.maxPolarAngle = Math.PI / 2 - 0.03;
|
| 471 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 472 |
function resize() {
|
| 473 |
const w = mount.clientWidth, h = mount.clientHeight;
|
| 474 |
camera.aspect = w / h;
|
|
@@ -487,6 +633,14 @@ function syncRig() {
|
|
| 487 |
trunkGroup.position.set(qpos[0], qpos[1], qpos[2]);
|
| 488 |
trunkGroup.quaternion.set(qpos[4], qpos[5], qpos[6], qpos[3]);
|
| 489 |
for (let j = 0; j < NUM_JOINTS; j++) setJoint(rig, JOINT_NAMES[j], qpos[qposAdr[j]]);
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 490 |
// Follow cam: ease the orbit target toward the trunk and translate the
|
| 491 |
// camera by the same delta, so the camera-to-duck distance and viewing
|
| 492 |
// angle stay constant while the duck walks. Mouse orbit/zoom still work:
|
|
@@ -556,12 +710,16 @@ const keyEls = {
|
|
| 556 |
turnl: el("key-turnl"), turnr: el("key-turnr"),
|
| 557 |
left: el("key-left"), right: el("key-right"),
|
| 558 |
roll: el("key-roll"), reset: el("key-reset"), kick: el("key-kick"),
|
|
|
|
| 559 |
padX: el("key-pad-x"), padSit: el("key-pad-sit"),
|
| 560 |
padRun: el("key-pad-run"), padRt: el("key-pad-rt"),
|
| 561 |
padRb: el("key-pad-rb"), padLb: el("key-pad-lb"),
|
|
|
|
| 562 |
};
|
| 563 |
const STICK_R = 15; // px, max dot travel inside the 46px stick circle
|
| 564 |
let resetFlashAt = -Infinity;
|
|
|
|
|
|
|
| 565 |
|
| 566 |
function renderStats() {
|
| 567 |
const [vx, vy, wz] = effectiveCmd();
|
|
@@ -590,7 +748,10 @@ function renderStats() {
|
|
| 590 |
keyEls.roll.classList.toggle("lit", mode === "roll" && rollSource === "kb");
|
| 591 |
keyEls.kick.classList.toggle("lit", isKick() && kickSource === "kb");
|
| 592 |
keyEls.reset.classList.toggle("lit", performance.now() - resetFlashAt < 400);
|
|
|
|
|
|
|
| 593 |
keyEls.padX.classList.toggle("lit", mode === "roll" && rollSource === "pad");
|
|
|
|
| 594 |
keyEls.padRb.classList.toggle("lit", mode === "kickR" && kickSource === "pad");
|
| 595 |
keyEls.padLb.classList.toggle("lit", mode === "kickL" && kickSource === "pad");
|
| 596 |
keyEls.padSit.classList.toggle("lit", sitting);
|
|
@@ -611,6 +772,7 @@ function loop() {
|
|
| 611 |
syncJaw();
|
| 612 |
ghosts?.update();
|
| 613 |
controls.update();
|
|
|
|
| 614 |
renderStats();
|
| 615 |
renderer.render(scene, camera);
|
| 616 |
}
|
|
@@ -640,6 +802,8 @@ let kickNextLeft = false;
|
|
| 640 |
window.addEventListener("keydown", (e) => {
|
| 641 |
if (e.repeat) return;
|
| 642 |
if (e.code === "KeyR") { resetSim(); resetFlashAt = performance.now(); return; }
|
|
|
|
|
|
|
| 643 |
if (e.code === "Space") { e.preventDefault(); triggerRoll(); return; }
|
| 644 |
if (e.code === "KeyF") {
|
| 645 |
e.preventDefault();
|
|
@@ -692,11 +856,16 @@ pollPad = function pollGamepad() {
|
|
| 692 |
padCmd.fill(0);
|
| 693 |
}
|
| 694 |
|
| 695 |
-
// Standard mapping indices: X=2, LB=4, RB=5, DpadDown=13, RT=7 (analog).
|
| 696 |
const x = !!gp.buttons[2]?.pressed;
|
| 697 |
if (x && !padPrev.x) triggerRoll("pad");
|
| 698 |
padPrev.x = x;
|
| 699 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 700 |
// RB / LB: right / left kick, same buttons as the robot runtime.
|
| 701 |
const rb = !!gp.buttons[5]?.pressed;
|
| 702 |
if (rb && !padPrev.rb) triggerKick("right", "pad");
|
|
@@ -857,13 +1026,20 @@ uiReady = true;
|
|
| 857 |
// Deterministic hooks for automated verification (rAF pauses in
|
| 858 |
// background tabs, and the control loop is async).
|
| 859 |
window.rl = {
|
| 860 |
-
model, data, mujoco,
|
| 861 |
get mode() { return mode; },
|
| 862 |
get sitFlag() { return sitFlag; },
|
| 863 |
buildObs, cmd,
|
| 864 |
velCmd, lastAction, resetSim,
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 865 |
step: async (n = 1) => { for (let i = 0; i < n; i++) await controlStep(); },
|
| 866 |
render: () => { syncRig(); renderer.render(scene, camera); },
|
|
|
|
|
|
|
| 867 |
get ghosts() { return ghosts; },
|
| 868 |
};
|
| 869 |
|
|
|
|
| 79 |
// Velocity command limits, same as infer_policy.py's keyboard mapping.
|
| 80 |
const VEL_FWD = 0.25, VEL_BACK = -0.2, VEL_LAT = 0.2, VEL_ANG = 1.0;
|
| 81 |
|
| 82 |
+
// Kickable ball: radius, parking spot (far away = hidden by default) and
|
| 83 |
+
// the distance past which the respawn watchdog brings it back (the grid
|
| 84 |
+
// fades out at 3 m, so a ball beyond that is invisible anyway).
|
| 85 |
+
const BALL_RADIUS = 0.05;
|
| 86 |
+
const BALL_PARK_POS = "50 0 0.05";
|
| 87 |
+
const BALL_MAX_DIST = 3.0;
|
| 88 |
+
|
| 89 |
const mount = document.getElementById("scene");
|
| 90 |
const loadingEl = document.getElementById("loading");
|
| 91 |
const hudEl = document.getElementById("hud");
|
|
|
|
| 144 |
doc.querySelector("worldbody").appendChild(
|
| 145 |
el("geom", { name: "floor", type: "plane", size: "0 0 0.05", pos: "0 0 0" }),
|
| 146 |
);
|
| 147 |
+
// Kickable ball: a light free sphere (beach-ball feel). MuJoCo has no
|
| 148 |
+
// restitution parameter - the bounce comes from solref damping < 1, and
|
| 149 |
+
// the rolling-friction term makes it come to rest. Default contype /
|
| 150 |
+
// conaffinity (1) collide with the floor and the duck's collision-class
|
| 151 |
+
// geoms; the self_collision_only class (contype=2) correctly ignores it.
|
| 152 |
+
// Appended AFTER the robot body so the trunk freejoint stays first in
|
| 153 |
+
// qpos: qpos[0..6] indexing is hardcoded in syncRig, the fall watchdog
|
| 154 |
+
// and the ghosts' getLocalState.
|
| 155 |
+
const ballBody = el("body", { name: "ball", pos: BALL_PARK_POS });
|
| 156 |
+
ballBody.appendChild(el("freejoint", { name: "ball_freejoint" }));
|
| 157 |
+
ballBody.appendChild(el("geom", {
|
| 158 |
+
name: "ball_geom", type: "sphere", size: String(BALL_RADIUS),
|
| 159 |
+
mass: "0.03", friction: "0.4 0.01 0.003", solref: "0.03 0.4",
|
| 160 |
+
}));
|
| 161 |
+
doc.querySelector("worldbody").appendChild(ballBody);
|
| 162 |
+
// STAND keyframe from mjlab's scene_walk.xml (STAND2 pose). The ball's
|
| 163 |
+
// 7 free-joint values MUST be appended or nq (21 + 7 = 28) won't match
|
| 164 |
+
// and the model won't compile; parked 50 m away = effectively absent.
|
| 165 |
const qposFree = "0 0 0.12 1 0 0 0";
|
| 166 |
const pose14 = Array.from(DEFAULT_POSE).join(" ");
|
| 167 |
const kf = doc.createElement("keyframe");
|
| 168 |
+
kf.appendChild(el("key", {
|
| 169 |
+
name: "STAND",
|
| 170 |
+
qpos: `${qposFree} ${pose14} ${BALL_PARK_POS} 1 0 0 0`,
|
| 171 |
+
ctrl: pose14,
|
| 172 |
+
}));
|
| 173 |
root.appendChild(kf);
|
| 174 |
const meshFiles = [...doc.querySelectorAll("asset > mesh")].map((m) => m.getAttribute("file"));
|
| 175 |
return { xml: new XMLSerializer().serializeToString(doc), meshFiles };
|
|
|
|
| 222 |
const gyroAdr = model.sensor("imu_ang_vel").adr;
|
| 223 |
const trunkId = mujoco.mj_name2id(model, mujoco.mjtObj.mjOBJ_BODY.value, "trunk_base");
|
| 224 |
const standKeyId = mujoco.mj_name2id(model, mujoco.mjtObj.mjOBJ_KEY.value, "STAND");
|
| 225 |
+
const ballQposAdr = model.jnt("ball_freejoint").qposadr;
|
| 226 |
+
const ballDofAdr = model.jnt("ball_freejoint").dofadr;
|
| 227 |
|
| 228 |
let uiReady = false;
|
| 229 |
const lastAction = new Float32Array(NUM_JOINTS);
|
|
|
|
| 234 |
// the control loop reads it before the input section below has evaluated.
|
| 235 |
const padCmd = new Float32Array(3);
|
| 236 |
let padActive = false;
|
| 237 |
+
const padPrev = { x: false, y: false, rb: false, lb: false, dpadDown: false, dpadUp: false, rtArmed: true };
|
| 238 |
// Declared before the control loop starts: buildObs reads it via
|
| 239 |
// effectiveCmd on the very first control step.
|
| 240 |
const held = new Set();
|
|
|
|
| 242 |
let mode = "walk"; // "walk" | "sitstand" | "roll" | "kickL" | "kickR"
|
| 243 |
let sitFlag = 0;
|
| 244 |
const isKick = () => mode === "kickL" || mode === "kickR";
|
| 245 |
+
// Local-only kickable ball: false while parked at the keyframe spot
|
| 246 |
+
// (mesh hidden), true once popped in front of the duck.
|
| 247 |
+
let ballActive = false;
|
| 248 |
|
| 249 |
// The twist the policy actually receives: live gamepad sticks win over the
|
| 250 |
// held keys. No input means zero command - the duck stands in place.
|
|
|
|
| 290 |
mujoco.mj_forward(model, data);
|
| 291 |
lastAction.fill(0);
|
| 292 |
sitFlag = 0;
|
| 293 |
+
// The keyframe re-parks the ball 50 m away; reflect that in the flag
|
| 294 |
+
// so the render loop hides the mesh again.
|
| 295 |
+
ballActive = false;
|
| 296 |
// Buttons reflect sitFlag; keep them honest after auto-resets.
|
| 297 |
if (uiReady) syncButtons();
|
| 298 |
}
|
| 299 |
resetSim();
|
| 300 |
|
| 301 |
+
// Pop / respawn the ball ~0.35 m in front of the duck, with a small
|
| 302 |
+
// random heading + distance jitter so repeated pops land somewhere
|
| 303 |
+
// nearby instead of always on the same spot. qvel is zeroed so a respawn
|
| 304 |
+
// doesn't carry the old momentum.
|
| 305 |
+
function spawnBall() {
|
| 306 |
+
const qpos = data.qpos, qvel = data.qvel;
|
| 307 |
+
// Trunk yaw from the free-joint quaternion (qpos[3..6] = w x y z);
|
| 308 |
+
// the duck walks toward its local +X.
|
| 309 |
+
const yaw = Math.atan2(
|
| 310 |
+
2 * (qpos[3] * qpos[6] + qpos[4] * qpos[5]),
|
| 311 |
+
1 - 2 * (qpos[5] * qpos[5] + qpos[6] * qpos[6]),
|
| 312 |
+
);
|
| 313 |
+
const heading = yaw + (Math.random() - 0.5) * 0.7;
|
| 314 |
+
const dist = 0.35 + (Math.random() - 0.5) * 0.1;
|
| 315 |
+
qpos[ballQposAdr] = qpos[0] + Math.cos(heading) * dist;
|
| 316 |
+
qpos[ballQposAdr + 1] = qpos[1] + Math.sin(heading) * dist;
|
| 317 |
+
qpos[ballQposAdr + 2] = BALL_RADIUS + 0.02;
|
| 318 |
+
qpos[ballQposAdr + 3] = 1;
|
| 319 |
+
qpos[ballQposAdr + 4] = 0;
|
| 320 |
+
qpos[ballQposAdr + 5] = 0;
|
| 321 |
+
qpos[ballQposAdr + 6] = 0;
|
| 322 |
+
for (let i = 0; i < 6; i++) qvel[ballDofAdr + i] = 0;
|
| 323 |
+
mujoco.mj_forward(model, data);
|
| 324 |
+
ballActive = true;
|
| 325 |
+
}
|
| 326 |
+
|
| 327 |
// ── Observation ─────────────────────────────────────────────────────────
|
| 328 |
const _q = new THREE.Quaternion();
|
| 329 |
const _g = new THREE.Vector3();
|
|
|
|
| 386 |
fallenSince = null;
|
| 387 |
}
|
| 388 |
|
| 389 |
+
// Ball respawn watchdog: a kicked ball that wandered past the grid's
|
| 390 |
+
// fade distance is invisible and unreachable - bring it back near
|
| 391 |
+
// the duck. Re-read qpos: resetSim above may have re-parked the ball.
|
| 392 |
+
if (ballActive) {
|
| 393 |
+
const q = data.qpos;
|
| 394 |
+
const dx = q[ballQposAdr] - q[0];
|
| 395 |
+
const dy = q[ballQposAdr + 1] - q[1];
|
| 396 |
+
if (dx * dx + dy * dy > BALL_MAX_DIST * BALL_MAX_DIST) spawnBall();
|
| 397 |
+
}
|
| 398 |
+
|
| 399 |
// One-shot roll, step-counted like the robot runtime (a single roll is
|
| 400 |
// ~1 s = 50 control steps there): hand back to walking once the trunk has
|
| 401 |
// tipped over and is upright again, or after a hard 2 s window if the roll
|
|
|
|
| 534 |
scene.add(rig.placer);
|
| 535 |
const trunkGroup = rig.bodies.get("trunk_base");
|
| 536 |
|
| 537 |
+
// Beach-ball look: alternating longitude panels drawn on a small canvas
|
| 538 |
+
// and wrapped around the sphere (equirectangular UVs), so the rolling
|
| 539 |
+
// actually reads visually. Colours match the dark scene + yellow accents.
|
| 540 |
+
function makeBeachBallTexture() {
|
| 541 |
+
const c = document.createElement("canvas");
|
| 542 |
+
c.width = 512;
|
| 543 |
+
c.height = 256;
|
| 544 |
+
const ctx = c.getContext("2d");
|
| 545 |
+
const panels = ["#ffd23f", "#efe9dc", "#22222c", "#efe9dc", "#ffd23f", "#efe9dc", "#22222c", "#efe9dc"];
|
| 546 |
+
const w = c.width / panels.length;
|
| 547 |
+
for (let i = 0; i < panels.length; i++) {
|
| 548 |
+
ctx.fillStyle = panels[i];
|
| 549 |
+
ctx.fillRect(Math.floor(i * w), 0, Math.ceil(w) + 1, c.height);
|
| 550 |
+
}
|
| 551 |
+
const tex = new THREE.CanvasTexture(c);
|
| 552 |
+
tex.colorSpace = THREE.SRGBColorSpace;
|
| 553 |
+
return tex;
|
| 554 |
+
}
|
| 555 |
+
// Same Z-up -> Y-up trick as the duck rig: the group takes the axis fix,
|
| 556 |
+
// the mesh inside takes the raw MJCF free-joint pose.
|
| 557 |
+
const ballGroup = new THREE.Group();
|
| 558 |
+
ballGroup.rotation.x = -Math.PI / 2;
|
| 559 |
+
const ballMesh = new THREE.Mesh(
|
| 560 |
+
new THREE.SphereGeometry(BALL_RADIUS, 32, 24),
|
| 561 |
+
new THREE.MeshStandardMaterial({ map: makeBeachBallTexture(), roughness: 0.35, metalness: 0 }),
|
| 562 |
+
);
|
| 563 |
+
ballMesh.visible = false;
|
| 564 |
+
ballGroup.add(ballMesh);
|
| 565 |
+
scene.add(ballGroup);
|
| 566 |
+
|
| 567 |
const controls = new OrbitControls(camera, renderer.domElement);
|
| 568 |
controls.enableDamping = true;
|
| 569 |
controls.dampingFactor = 0.08;
|
|
|
|
| 571 |
controls.maxDistance = 3;
|
| 572 |
controls.maxPolarAngle = Math.PI / 2 - 0.03;
|
| 573 |
|
| 574 |
+
// Chase cam (default ON): each frame the camera eases toward a point
|
| 575 |
+
// behind the duck's heading at the current orbit distance, while the
|
| 576 |
+
// orbit target keeps easing to the trunk in syncRig. Implemented by
|
| 577 |
+
// overwriting camera.position AFTER controls.update() so we never fight
|
| 578 |
+
// OrbitControls' own spherical bookkeeping; the wheel still zooms in
|
| 579 |
+
// chase mode because the behind-point distance is re-read from the live
|
| 580 |
+
// camera-target distance every frame.
|
| 581 |
+
// Detach: any pointer grab on the canvas (drag start) drops back to the
|
| 582 |
+
// free orbit-follow. We listen on pointerdown rather than the controls'
|
| 583 |
+
// "start" event because in this three.js version the wheel dispatches
|
| 584 |
+
// "start" too, and scroll-to-zoom must not detach the chase.
|
| 585 |
+
let chaseCam = true;
|
| 586 |
+
const CHASE_PITCH = 0.42; // rad above horizontal, keeps the floor in view
|
| 587 |
+
const CHASE_EASE = 0.05; // exponential ease, cinematic on turns
|
| 588 |
+
const _chasePos = new THREE.Vector3();
|
| 589 |
+
const _chaseDir = new THREE.Vector3();
|
| 590 |
+
function updateChaseCam() {
|
| 591 |
+
if (!chaseCam) return;
|
| 592 |
+
const qpos = data.qpos;
|
| 593 |
+
const yaw = Math.atan2(
|
| 594 |
+
2 * (qpos[3] * qpos[6] + qpos[4] * qpos[5]),
|
| 595 |
+
1 - 2 * (qpos[5] * qpos[5] + qpos[6] * qpos[6]),
|
| 596 |
+
);
|
| 597 |
+
const dist = camera.position.distanceTo(controls.target);
|
| 598 |
+
const horiz = dist * Math.cos(CHASE_PITCH);
|
| 599 |
+
const vert = dist * Math.sin(CHASE_PITCH);
|
| 600 |
+
// Duck forward in MJCF is (cos yaw, sin yaw, 0); Z-up -> Y-up maps it
|
| 601 |
+
// to three-space (cos yaw, 0, -sin yaw). Behind = minus that.
|
| 602 |
+
_chasePos.set(
|
| 603 |
+
controls.target.x - Math.cos(yaw) * horiz,
|
| 604 |
+
controls.target.y + vert,
|
| 605 |
+
controls.target.z + Math.sin(yaw) * horiz,
|
| 606 |
+
);
|
| 607 |
+
camera.position.lerp(_chasePos, CHASE_EASE);
|
| 608 |
+
// Re-project onto the orbit sphere: lerping between two points at the
|
| 609 |
+
// same radius cuts the chord, which would slowly zoom the camera in
|
| 610 |
+
// during large swings (e.g. re-attaching after the duck turned around).
|
| 611 |
+
_chaseDir.copy(camera.position).sub(controls.target);
|
| 612 |
+
const len = _chaseDir.length();
|
| 613 |
+
if (len > 1e-6) camera.position.copy(controls.target).addScaledVector(_chaseDir, dist / len);
|
| 614 |
+
camera.lookAt(controls.target);
|
| 615 |
+
}
|
| 616 |
+
renderer.domElement.addEventListener("pointerdown", () => { chaseCam = false; });
|
| 617 |
+
|
| 618 |
function resize() {
|
| 619 |
const w = mount.clientWidth, h = mount.clientHeight;
|
| 620 |
camera.aspect = w / h;
|
|
|
|
| 633 |
trunkGroup.position.set(qpos[0], qpos[1], qpos[2]);
|
| 634 |
trunkGroup.quaternion.set(qpos[4], qpos[5], qpos[6], qpos[3]);
|
| 635 |
for (let j = 0; j < NUM_JOINTS; j++) setJoint(rig, JOINT_NAMES[j], qpos[qposAdr[j]]);
|
| 636 |
+
// Ball: raw MJCF pose inside the Z-up group, hidden while parked.
|
| 637 |
+
ballMesh.visible = ballActive;
|
| 638 |
+
if (ballActive) {
|
| 639 |
+
ballMesh.position.set(qpos[ballQposAdr], qpos[ballQposAdr + 1], qpos[ballQposAdr + 2]);
|
| 640 |
+
ballMesh.quaternion.set(
|
| 641 |
+
qpos[ballQposAdr + 4], qpos[ballQposAdr + 5], qpos[ballQposAdr + 6], qpos[ballQposAdr + 3],
|
| 642 |
+
);
|
| 643 |
+
}
|
| 644 |
// Follow cam: ease the orbit target toward the trunk and translate the
|
| 645 |
// camera by the same delta, so the camera-to-duck distance and viewing
|
| 646 |
// angle stay constant while the duck walks. Mouse orbit/zoom still work:
|
|
|
|
| 710 |
turnl: el("key-turnl"), turnr: el("key-turnr"),
|
| 711 |
left: el("key-left"), right: el("key-right"),
|
| 712 |
roll: el("key-roll"), reset: el("key-reset"), kick: el("key-kick"),
|
| 713 |
+
ball: el("key-ball"), cam: el("key-cam"),
|
| 714 |
padX: el("key-pad-x"), padSit: el("key-pad-sit"),
|
| 715 |
padRun: el("key-pad-run"), padRt: el("key-pad-rt"),
|
| 716 |
padRb: el("key-pad-rb"), padLb: el("key-pad-lb"),
|
| 717 |
+
padY: el("key-pad-y"),
|
| 718 |
};
|
| 719 |
const STICK_R = 15; // px, max dot travel inside the 46px stick circle
|
| 720 |
let resetFlashAt = -Infinity;
|
| 721 |
+
let ballFlashAt = -Infinity;
|
| 722 |
+
let padYFlashAt = -Infinity;
|
| 723 |
|
| 724 |
function renderStats() {
|
| 725 |
const [vx, vy, wz] = effectiveCmd();
|
|
|
|
| 748 |
keyEls.roll.classList.toggle("lit", mode === "roll" && rollSource === "kb");
|
| 749 |
keyEls.kick.classList.toggle("lit", isKick() && kickSource === "kb");
|
| 750 |
keyEls.reset.classList.toggle("lit", performance.now() - resetFlashAt < 400);
|
| 751 |
+
keyEls.ball.classList.toggle("lit", performance.now() - ballFlashAt < 400);
|
| 752 |
+
keyEls.cam.classList.toggle("lit", chaseCam); // steady while chasing
|
| 753 |
keyEls.padX.classList.toggle("lit", mode === "roll" && rollSource === "pad");
|
| 754 |
+
keyEls.padY.classList.toggle("lit", performance.now() - padYFlashAt < 400);
|
| 755 |
keyEls.padRb.classList.toggle("lit", mode === "kickR" && kickSource === "pad");
|
| 756 |
keyEls.padLb.classList.toggle("lit", mode === "kickL" && kickSource === "pad");
|
| 757 |
keyEls.padSit.classList.toggle("lit", sitting);
|
|
|
|
| 772 |
syncJaw();
|
| 773 |
ghosts?.update();
|
| 774 |
controls.update();
|
| 775 |
+
updateChaseCam();
|
| 776 |
renderStats();
|
| 777 |
renderer.render(scene, camera);
|
| 778 |
}
|
|
|
|
| 802 |
window.addEventListener("keydown", (e) => {
|
| 803 |
if (e.repeat) return;
|
| 804 |
if (e.code === "KeyR") { resetSim(); resetFlashAt = performance.now(); return; }
|
| 805 |
+
if (e.code === "KeyB") { spawnBall(); ballFlashAt = performance.now(); return; }
|
| 806 |
+
if (e.code === "KeyC") { chaseCam = !chaseCam; return; }
|
| 807 |
if (e.code === "Space") { e.preventDefault(); triggerRoll(); return; }
|
| 808 |
if (e.code === "KeyF") {
|
| 809 |
e.preventDefault();
|
|
|
|
| 856 |
padCmd.fill(0);
|
| 857 |
}
|
| 858 |
|
| 859 |
+
// Standard mapping indices: X=2, Y=3, LB=4, RB=5, DpadDown=13, RT=7 (analog).
|
| 860 |
const x = !!gp.buttons[2]?.pressed;
|
| 861 |
if (x && !padPrev.x) triggerRoll("pad");
|
| 862 |
padPrev.x = x;
|
| 863 |
|
| 864 |
+
// Y: pop / respawn the ball, same rising-edge pattern as X.
|
| 865 |
+
const y = !!gp.buttons[3]?.pressed;
|
| 866 |
+
if (y && !padPrev.y) { spawnBall(); padYFlashAt = performance.now(); }
|
| 867 |
+
padPrev.y = y;
|
| 868 |
+
|
| 869 |
// RB / LB: right / left kick, same buttons as the robot runtime.
|
| 870 |
const rb = !!gp.buttons[5]?.pressed;
|
| 871 |
if (rb && !padPrev.rb) triggerKick("right", "pad");
|
|
|
|
| 1026 |
// Deterministic hooks for automated verification (rAF pauses in
|
| 1027 |
// background tabs, and the control loop is async).
|
| 1028 |
window.rl = {
|
| 1029 |
+
model, data, mujoco, camera, controls,
|
| 1030 |
get mode() { return mode; },
|
| 1031 |
get sitFlag() { return sitFlag; },
|
| 1032 |
buildObs, cmd,
|
| 1033 |
velCmd, lastAction, resetSim,
|
| 1034 |
+
spawnBall,
|
| 1035 |
+
get ballActive() { return ballActive; },
|
| 1036 |
+
get ballQposAdr() { return ballQposAdr; },
|
| 1037 |
+
get chaseCam() { return chaseCam; },
|
| 1038 |
+
set chaseCam(v) { chaseCam = !!v; },
|
| 1039 |
step: async (n = 1) => { for (let i = 0; i < n; i++) await controlStep(); },
|
| 1040 |
render: () => { syncRig(); renderer.render(scene, camera); },
|
| 1041 |
+
// One full render-loop iteration, for tests driving frames manually.
|
| 1042 |
+
frame: () => { syncRig(); syncJaw(); controls.update(); updateChaseCam(); renderStats(); renderer.render(scene, camera); },
|
| 1043 |
get ghosts() { return ghosts; },
|
| 1044 |
};
|
| 1045 |
|