tfrere HF Staff Cursor commited on
Commit
65860b2
Β·
1 Parent(s): ee81e70

feat: gamepad support mirroring the robot runtime mapping

Browse files
Files changed (3) hide show
  1. README.md +11 -0
  2. index.html +4 -1
  3. rl.js +72 -5
README.md CHANGED
@@ -30,10 +30,21 @@ and [apirrone/mjlab_microduck](https://github.com/apirrone/mjlab_microduck).
30
  - Arrow up / down: forward / back
31
  - Arrow left / right: turn
32
  - A / E: strafe
 
33
  - R: reset
34
  - Drag to orbit, scroll to zoom
35
  - Colour dots: repaint the duck (it quacks)
36
 
 
 
 
 
 
 
 
 
 
 
37
  ## How it works
38
 
39
  - `rl.js` fetches the MJCF (`robot_allcollisions.xml`), strips the visual
 
30
  - Arrow up / down: forward / back
31
  - Arrow left / right: turn
32
  - A / E: strafe
33
+ - Space: roulade (one roll, then back to running)
34
  - R: reset
35
  - Drag to orbit, scroll to zoom
36
  - Colour dots: repaint the duck (it quacks)
37
 
38
+ ### Gamepad
39
+
40
+ Plug in a controller and the same mapping as the real robot runtime applies:
41
+
42
+ - Left stick: forward / back + strafe
43
+ - Right stick: turn
44
+ - X: roulade
45
+ - D-pad down: sit / stand toggle
46
+ - Right trigger: mouth (analog) + quack
47
+
48
  ## How it works
49
 
50
  - `rl.js` fetches the MJCF (`robot_allcollisions.xml`), strips the visual
index.html CHANGED
@@ -125,6 +125,8 @@
125
  pointer-events: auto;
126
  }
127
  #hud .keys b { color: rgba(255, 255, 255, 0.75); font-weight: 600; }
 
 
128
  #stats {
129
  position: fixed;
130
  top: 1.4rem;
@@ -164,7 +166,8 @@
164
  </div>
165
  <div class="keys">
166
  <b>&uarr;/&darr;</b> forward / back &nbsp; <b>&larr;/&rarr;</b> turn &nbsp; <b>A/E</b> strafe<br />
167
- <b>Space</b> roulade &nbsp; <b>R</b> reset &nbsp; drag to orbit &nbsp; scroll to zoom
 
168
  </div>
169
  </div>
170
  <div id="loading">Compiling physics&hellip;</div>
 
125
  pointer-events: auto;
126
  }
127
  #hud .keys b { color: rgba(255, 255, 255, 0.75); font-weight: 600; }
128
+ #pad-hint { display: none; }
129
+ body.pad-connected #pad-hint { display: inline; }
130
  #stats {
131
  position: fixed;
132
  top: 1.4rem;
 
166
  </div>
167
  <div class="keys">
168
  <b>&uarr;/&darr;</b> forward / back &nbsp; <b>&larr;/&rarr;</b> turn &nbsp; <b>A/E</b> strafe<br />
169
+ <b>Space</b> roulade &nbsp; <b>R</b> reset &nbsp; drag to orbit &nbsp; scroll to zoom<br />
170
+ <span id="pad-hint">gamepad: sticks move &nbsp; <b>X</b> roulade &nbsp; <b>&dArr;</b> sit &nbsp; <b>RT</b> quack</span>
171
  </div>
172
  </div>
173
  <div id="loading">Compiling physics&hellip;</div>
rl.js CHANGED
@@ -189,6 +189,10 @@ const lastAction = new Float32Array(NUM_JOINTS);
189
  const obs = new Float32Array(OBS_SIZE);
190
  const cmd = new Float32Array(CMD_SIZE); // [vx, vy, wz, head(4), body(6)]
191
  const velCmd = new Float32Array(3); // twist command, driven by held keys
 
 
 
 
192
  // Declared before the control loop starts: buildObs checks it to decide
193
  // between the auto-run default and manual key control.
194
  const held = new Set();
@@ -231,8 +235,12 @@ function buildObs() {
231
  // gets a forward velocity by default, keys override it.
232
  cmd.fill(0, 0, 3);
233
  if (mode === "walk") {
234
- cmd[0] = held.size ? velCmd[0] : VEL_FWD;
235
- cmd[1] = velCmd[1]; cmd[2] = velCmd[2];
 
 
 
 
236
  } else if (mode === "roulade") {
237
  cmd[0] = velCmd[0]; cmd[1] = velCmd[1]; cmd[2] = velCmd[2];
238
  } else {
@@ -438,17 +446,20 @@ function syncRig() {
438
 
439
  // Quack: a quick jaw flap on every mode/colour change. The jaw isn't a
440
  // MuJoCo joint (duck.js re-creates the hinge in JS), so this is purely
441
- // cosmetic and can't upset the policy.
 
442
  const QUACK_MS = 480;
443
  let quackAt = -Infinity;
 
444
  const quack = () => { quackAt = performance.now(); };
445
  function syncJaw() {
446
  const t = (performance.now() - quackAt) / QUACK_MS;
447
- setJawOpen(rig, t >= 0 && t < 1 ? Math.sin(Math.PI * t) : 0);
 
448
  }
449
 
450
  function renderStats() {
451
- const [vx, vy, wz] = velCmd;
452
  const posture = mode === "walk"
453
  ? `running policy`
454
  : mode === "roulade"
@@ -460,8 +471,13 @@ function renderStats() {
460
  `ctrl ${ctrlHz.toFixed(0)} Hz \u00b7 sim t ${data.time.toFixed(1)} s`;
461
  }
462
 
 
 
 
 
463
  function loop() {
464
  requestAnimationFrame(loop);
 
465
  syncRig();
466
  syncJaw();
467
  controls.update();
@@ -504,6 +520,57 @@ window.addEventListener("keyup", (e) => {
504
  });
505
  window.addEventListener("blur", () => { held.clear(); refreshVelCmd(); });
506
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
507
  const btnWalk = document.getElementById("btn-walk");
508
  const btnSit = document.getElementById("btn-sit");
509
  const btnRoulade = document.getElementById("btn-roulade");
 
189
  const obs = new Float32Array(OBS_SIZE);
190
  const cmd = new Float32Array(CMD_SIZE); // [vx, vy, wz, head(4), body(6)]
191
  const velCmd = new Float32Array(3); // twist command, driven by held keys
192
+ // Gamepad twist (EMA-smoothed like the robot runtime). Declared up here:
193
+ // the control loop reads it before the input section below has evaluated.
194
+ const padCmd = new Float32Array(3);
195
+ let padActive = false;
196
  // Declared before the control loop starts: buildObs checks it to decide
197
  // between the auto-run default and manual key control.
198
  const held = new Set();
 
235
  // gets a forward velocity by default, keys override it.
236
  cmd.fill(0, 0, 3);
237
  if (mode === "walk") {
238
+ if (padActive) {
239
+ cmd[0] = padCmd[0]; cmd[1] = padCmd[1]; cmd[2] = padCmd[2];
240
+ } else {
241
+ cmd[0] = held.size ? velCmd[0] : VEL_FWD;
242
+ cmd[1] = velCmd[1]; cmd[2] = velCmd[2];
243
+ }
244
  } else if (mode === "roulade") {
245
  cmd[0] = velCmd[0]; cmd[1] = velCmd[1]; cmd[2] = velCmd[2];
246
  } else {
 
446
 
447
  // Quack: a quick jaw flap on every mode/colour change. The jaw isn't a
448
  // MuJoCo joint (duck.js re-creates the hinge in JS), so this is purely
449
+ // cosmetic and can't upset the policy. A held gamepad trigger drives the
450
+ // jaw analogically on top (same as the robot's mouth trigger).
451
  const QUACK_MS = 480;
452
  let quackAt = -Infinity;
453
+ let padJaw = 0;
454
  const quack = () => { quackAt = performance.now(); };
455
  function syncJaw() {
456
  const t = (performance.now() - quackAt) / QUACK_MS;
457
+ const flap = t >= 0 && t < 1 ? Math.sin(Math.PI * t) : 0;
458
+ setJawOpen(rig, Math.max(flap, padJaw));
459
  }
460
 
461
  function renderStats() {
462
+ const [vx, vy, wz] = padActive ? padCmd : velCmd;
463
  const posture = mode === "walk"
464
  ? `running policy`
465
  : mode === "roulade"
 
471
  `ctrl ${ctrlHz.toFixed(0)} Hz \u00b7 sim t ${data.time.toFixed(1)} s`;
472
  }
473
 
474
+ // Real implementation assigned in the gamepad section below; the render
475
+ // loop starts before that section has evaluated, hence the indirection.
476
+ let pollPad = () => {};
477
+
478
  function loop() {
479
  requestAnimationFrame(loop);
480
+ pollPad();
481
  syncRig();
482
  syncJaw();
483
  controls.update();
 
520
  });
521
  window.addEventListener("blur", () => { held.clear(); refreshVelCmd(); });
522
 
523
+ // ── Gamepad: same mapping as the robot runtime (microduck_runtime) ──────
524
+ // Sticks: L vertical = vx (asymmetric fwd/back), L horizontal = strafe,
525
+ // R horizontal = turn, all EMA-smoothed like the runtime's cmd_alpha.
526
+ // X = roulade, DpadDown = sit/stand toggle, RT = mouth (analog) + quack.
527
+ const PAD_DEADZONE = 0.15;
528
+ const PAD_ALPHA = 0.12;
529
+ const padPrev = { x: false, dpadDown: false, rt: 0 };
530
+ const dz = (v) => (Math.abs(v) < PAD_DEADZONE ? 0 : v);
531
+
532
+ pollPad = function pollGamepad() {
533
+ const gp = [...(navigator.getGamepads?.() ?? [])].find((p) => p && p.connected);
534
+ document.body.classList.toggle("pad-connected", !!gp);
535
+ if (!gp) {
536
+ if (padActive) { padActive = false; padCmd.fill(0); padJaw = 0; }
537
+ return;
538
+ }
539
+ const lx = dz(gp.axes[0] ?? 0), ly = dz(gp.axes[1] ?? 0), rx = dz(gp.axes[2] ?? 0);
540
+ const up = -ly; // browser sticks report up as -1
541
+ const target = [
542
+ up >= 0 ? up * VEL_FWD : up * -VEL_BACK,
543
+ -lx * VEL_LAT,
544
+ -rx * VEL_ANG,
545
+ ];
546
+ for (let i = 0; i < 3; i++) padCmd[i] += PAD_ALPHA * (target[i] - padCmd[i]);
547
+ // Sticks grab command authority on first input, release when back at rest
548
+ // (then keyboard / auto-run take over again).
549
+ const stickInput = lx !== 0 || ly !== 0 || rx !== 0;
550
+ if (stickInput) padActive = true;
551
+ else if (padActive && Math.abs(padCmd[0]) + Math.abs(padCmd[1]) + Math.abs(padCmd[2]) < 0.01) {
552
+ padActive = false;
553
+ padCmd.fill(0);
554
+ }
555
+
556
+ // Standard mapping indices: X=2, DpadDown=13, RT=7 (analog value).
557
+ const x = !!gp.buttons[2]?.pressed;
558
+ if (x && !padPrev.x) triggerRoulade();
559
+ padPrev.x = x;
560
+
561
+ const dpadDown = !!gp.buttons[13]?.pressed;
562
+ if (dpadDown && !padPrev.dpadDown) {
563
+ const sitting = mode === "sitstand" && sitFlag === 1;
564
+ setMode(sitting ? "walk" : "sit");
565
+ }
566
+ padPrev.dpadDown = dpadDown;
567
+
568
+ const rt = gp.buttons[7]?.value ?? 0;
569
+ padJaw = rt;
570
+ if (padPrev.rt < 0.3 && rt >= 0.3) quack();
571
+ padPrev.rt = rt;
572
+ };
573
+
574
  const btnWalk = document.getElementById("btn-walk");
575
  const btnSit = document.getElementById("btn-sit");
576
  const btnRoulade = document.getElementById("btn-roulade");