feat: gamepad support mirroring the robot runtime mapping
Browse files- README.md +11 -0
- index.html +4 -1
- 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>↑/↓</b> forward / back <b>←/→</b> turn <b>A/E</b> strafe<br />
|
| 167 |
-
<b>Space</b> roulade <b>R</b> reset drag to orbit scroll to zoom
|
|
|
|
| 168 |
</div>
|
| 169 |
</div>
|
| 170 |
<div id="loading">Compiling physics…</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>↑/↓</b> forward / back <b>←/→</b> turn <b>A/E</b> strafe<br />
|
| 169 |
+
<b>Space</b> roulade <b>R</b> reset drag to orbit scroll to zoom<br />
|
| 170 |
+
<span id="pad-hint">gamepad: sticks move <b>X</b> roulade <b>⇓</b> sit <b>RT</b> quack</span>
|
| 171 |
</div>
|
| 172 |
</div>
|
| 173 |
<div id="loading">Compiling physics…</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 |
-
|
| 235 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
| 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 |
-
|
|
|
|
| 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");
|