tfrere HF Staff Cursor commited on
Commit
bbe381e
·
1 Parent(s): ef74255

feat: kickable ball (B / pad Y) and detachable chase camera (C)

Browse files

Ball: 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>

Files changed (3) hide show
  1. README.md +6 -0
  2. index.html +6 -1
  3. 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> &nbsp;
395
  <span class="hint"><span class="key" id="key-kick">F</span> kick</span> &nbsp;
 
 
396
  <span class="hint"><span class="key" id="key-reset">R</span> reset</span> &nbsp;
397
  drag to orbit &middot; 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> &nbsp;
 
400
  <span class="hint"><span class="key" id="key-pad-rb">RB</span><span class="key" id="key-pad-lb">LB</span> kick</span> &nbsp;
401
  <span class="hint"><span class="key" id="key-pad-sit">&dArr;</span> sit</span> &nbsp;
402
  <span class="hint"><span class="key" id="key-pad-run">&uArr;</span> run</span> &nbsp;
@@ -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 = "25"; // bump on rl.js changes to defeat heuristic caching
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> &nbsp;
395
  <span class="hint"><span class="key" id="key-kick">F</span> kick</span> &nbsp;
396
+ <span class="hint"><span class="key" id="key-ball">B</span> ball</span> &nbsp;
397
+ <span class="hint"><span class="key" id="key-cam">C</span> camera</span> &nbsp;
398
  <span class="hint"><span class="key" id="key-reset">R</span> reset</span> &nbsp;
399
  drag to orbit &middot; 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> &nbsp;
402
+ <span class="hint"><span class="key" id="key-pad-y">Y</span> ball</span> &nbsp;
403
  <span class="hint"><span class="key" id="key-pad-rb">RB</span><span class="key" id="key-pad-lb">LB</span> kick</span> &nbsp;
404
  <span class="hint"><span class="key" id="key-pad-sit">&dArr;</span> sit</span> &nbsp;
405
  <span class="hint"><span class="key" id="key-pad-run">&uArr;</span> run</span> &nbsp;
 
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
- // STAND keyframe from mjlab's scene_walk.xml (STAND2 pose).
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
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", { name: "STAND", qpos: `${qposFree} ${pose14}`, ctrl: pose14 }));
 
 
 
 
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