tfrere HF Staff Cursor commited on
Commit
9f9cc1c
Β·
1 Parent(s): 68e86d4

feat: per-key hint highlighting, no default forward command, rename roulade to roll in UI and internals

Browse files
Files changed (3) hide show
  1. README.md +1 -1
  2. index.html +11 -12
  3. rl.js +60 -62
README.md CHANGED
@@ -19,7 +19,7 @@ runs the policy network at 50 Hz. No server, no backend.
19
  |--------|-----------|--------------|
20
  | Run | `BEST_alpha_walking.onnx` | Velocity-tracking locomotion (arrows / WASD to steer) |
21
  | Sit | `BEST_alpha_sitstand.onnx` | Sits down on its hull, stands back up |
22
- | Roulade | `roulade.onnx` | Rolls over and recovers |
23
 
24
  Policies and MJCF model from
25
  [apirrone/microduck_runtime](https://github.com/apirrone/microduck_runtime)
 
19
  |--------|-----------|--------------|
20
  | Run | `BEST_alpha_walking.onnx` | Velocity-tracking locomotion (arrows / WASD to steer) |
21
  | Sit | `BEST_alpha_sitstand.onnx` | Sits down on its hull, stands back up |
22
+ | Roll | `roulade.onnx` | Rolls over and recovers |
23
 
24
  Policies and MJCF model from
25
  [apirrone/microduck_runtime](https://github.com/apirrone/microduck_runtime)
index.html CHANGED
@@ -174,8 +174,7 @@
174
  text-align: center;
175
  transition: background 0.15s ease, color 0.15s ease, border-color 0.15s ease;
176
  }
177
- #hud .keys .hint.lit { color: var(--yellow); }
178
- #hud .keys .hint.lit .key {
179
  background: var(--yellow);
180
  border-color: var(--yellow);
181
  color: var(--ink);
@@ -275,17 +274,17 @@
275
  <span>turn</span>
276
  </div>
277
  </div>
278
- <span class="hint" id="hint-move"><span class="key">&uarr;</span><span class="key">&darr;</span> move</span> &nbsp;
279
- <span class="hint" id="hint-turn"><span class="key">&larr;</span><span class="key">&rarr;</span> turn</span> &nbsp;
280
- <span class="hint" id="hint-strafe"><span class="key">A</span><span class="key">E</span> strafe</span><br />
281
- <span class="hint" id="hint-roulade"><span class="key">Space</span> roll</span> &nbsp;
282
- <span class="hint" id="hint-reset"><span class="key">R</span> reset</span> &nbsp;
283
  drag to orbit &middot; scroll to zoom<br />
284
  <span id="pad-hint">gamepad:
285
- <span class="hint" id="hint-pad-x"><span class="key">X</span> roll</span> &nbsp;
286
- <span class="hint" id="hint-pad-sit"><span class="key">&dArr;</span> sit</span> &nbsp;
287
- <span class="hint" id="hint-pad-run"><span class="key">&uArr;</span> run</span> &nbsp;
288
- <span class="hint" id="hint-pad-rt"><span class="key">RT</span> quack</span>
289
  </span>
290
  </div>
291
  </div>
@@ -303,7 +302,7 @@
303
  appended: a plain src attribute can 401 when the browser blocks the
304
  *.static.hf.space auth cookie inside the hub iframe. -->
305
  <script type="module">
306
- const V = "3"; // bump on rl.js changes to defeat heuristic caching
307
  const sign = new URLSearchParams(location.search).get("__sign");
308
  const suffix = sign ? `&__sign=${encodeURIComponent(sign)}` : "";
309
  import(`./rl.js?v=${V}${suffix}`);
 
174
  text-align: center;
175
  transition: background 0.15s ease, color 0.15s ease, border-color 0.15s ease;
176
  }
177
+ #hud .keys .key.lit {
 
178
  background: var(--yellow);
179
  border-color: var(--yellow);
180
  color: var(--ink);
 
274
  <span>turn</span>
275
  </div>
276
  </div>
277
+ <span class="hint"><span class="key" id="key-fwd">&uarr;</span><span class="key" id="key-back">&darr;</span> move</span> &nbsp;
278
+ <span class="hint"><span class="key" id="key-turnl">&larr;</span><span class="key" id="key-turnr">&rarr;</span> turn</span> &nbsp;
279
+ <span class="hint"><span class="key" id="key-left">A</span><span class="key" id="key-right">E</span> strafe</span><br />
280
+ <span class="hint"><span class="key" id="key-roll">Space</span> roll</span> &nbsp;
281
+ <span class="hint"><span class="key" id="key-reset">R</span> reset</span> &nbsp;
282
  drag to orbit &middot; scroll to zoom<br />
283
  <span id="pad-hint">gamepad:
284
+ <span class="hint"><span class="key" id="key-pad-x">X</span> roll</span> &nbsp;
285
+ <span class="hint"><span class="key" id="key-pad-sit">&dArr;</span> sit</span> &nbsp;
286
+ <span class="hint"><span class="key" id="key-pad-run">&uArr;</span> run</span> &nbsp;
287
+ <span class="hint"><span class="key" id="key-pad-rt">RT</span> quack</span>
288
  </span>
289
  </div>
290
  </div>
 
302
  appended: a plain src attribute can 401 when the browser blocks the
303
  *.static.hf.space auth cookie inside the hub iframe. -->
304
  <script type="module">
305
+ const V = "5"; // bump on rl.js changes to defeat heuristic caching
306
  const sign = new URLSearchParams(location.search).get("__sign");
307
  const suffix = sign ? `&__sign=${encodeURIComponent(sign)}` : "";
308
  import(`./rl.js?v=${V}${suffix}`);
rl.js CHANGED
@@ -44,7 +44,7 @@ const POLICY_DIR = "./policies";
44
  const POLICIES = {
45
  walk: `${POLICY_DIR}/BEST_alpha_walking.onnx`,
46
  sitstand: `${POLICY_DIR}/BEST_alpha_sitstand.onnx`,
47
- roulade: `${POLICY_DIR}/roulade.onnx`,
48
  };
49
 
50
  // From the ONNX metadata (identical for all alpha policies) and the STAND
@@ -165,10 +165,10 @@ const rigPromise = (async () => {
165
  return buildRig(k, { materialForMesh: materialHookFor(VARIANTS[currentVariant]) });
166
  })();
167
  const sessionOpts = { executionProviders: ["wasm"] };
168
- [sessions.walk, sessions.sitstand, sessions.roulade] = await Promise.all([
169
  ort.InferenceSession.create(signed(POLICIES.walk), sessionOpts),
170
  ort.InferenceSession.create(signed(POLICIES.sitstand), sessionOpts),
171
- ort.InferenceSession.create(signed(POLICIES.roulade), sessionOpts),
172
  ]);
173
 
174
  setLoading("Compiling physics\u2026");
@@ -194,26 +194,23 @@ const velCmd = new Float32Array(3); // twist command, driven by held keys
194
  const padCmd = new Float32Array(3);
195
  let padActive = false;
196
  const padPrev = { x: false, dpadDown: false, dpadUp: false, rt: 0 };
197
- // Declared before the control loop starts: buildObs checks it to decide
198
- // between the auto-run default and manual key control.
199
  const held = new Set();
200
 
201
- let mode = "walk"; // "walk" | "sitstand" | "roulade"
202
  let sitFlag = 0;
203
 
204
- // The twist the policy actually receives: live gamepad sticks win, held
205
- // keys next, and idle input in walk mode means auto-run forward. Shared by
206
- // buildObs and the HUD mini-sticks so they can never disagree.
207
- const AUTO_CMD = new Float32Array([VEL_FWD, 0, 0]);
208
  function effectiveCmd() {
209
- if (padActive) return padCmd;
210
- if (mode === "walk" && !held.size) return AUTO_CMD;
211
- return velCmd;
212
  }
213
- // One-shot roulade tracking: trigger time + whether the trunk actually
214
- // tipped over yet. Set by triggerRoulade, cleared when we hand back to walk.
215
- let rouladeRun = null;
216
- let rouladeSource = "kb"; // which hint lights up: keyboard Space or pad X
217
 
218
  function resetSim() {
219
  mujoco.mj_resetDataKeyframe(model, data, standKeyId);
@@ -242,11 +239,10 @@ function buildObs() {
242
  for (let j = 0; j < NUM_JOINTS; j++) obs[i++] = qpos[qposAdr[j]] - DEFAULT_POSE[j];
243
  for (let j = 0; j < NUM_JOINTS; j++) obs[i++] = qvel[dofAdr[j]];
244
  for (let j = 0; j < NUM_JOINTS; j++) obs[i++] = lastAction[j];
245
- // command: walking/roulade use the twist; sitstand uses cmd[0] as the
246
- // posture flag. "Run" means run: with no keys held the walking policy
247
- // gets a forward velocity by default, keys override it.
248
  cmd.fill(0, 0, 3);
249
- if (mode === "walk" || mode === "roulade") {
250
  const c = effectiveCmd();
251
  cmd[0] = c[0]; cmd[1] = c[1]; cmd[2] = c[2];
252
  } else {
@@ -272,13 +268,13 @@ async function controlStep() {
272
  // Fall detection: walk/sitstand have no get-up skill, so auto-reset when
273
  // down for good. Height alone would false-positive on a deep sit, so
274
  // "fallen" = trunk tilted past ~60 deg (projected gravity z above -0.5)
275
- // or sunk below the floor, sustained for over a second. The roulade rolls
276
  // the trunk on purpose and recovers on its own, so it gets a much longer
277
  // grace window before we call it stuck.
278
  const z = data.qpos[2];
279
  const now = performance.now();
280
  const tipped = obs[5] > -0.5; // projected gravity z, from the last obs
281
- const graceMs = mode === "roulade" ? 5000 : 1000;
282
  if (tipped || z < 0.02) {
283
  fallenSince ??= now;
284
  if (now - fallenSince > graceMs) { resetSim(); fallenSince = null; }
@@ -286,19 +282,19 @@ async function controlStep() {
286
  fallenSince = null;
287
  }
288
 
289
- // One-shot roulade, step-counted like the robot runtime (a single roll is
290
  // ~1 s = 50 control steps there): hand back to walking once the trunk has
291
  // tipped over and is upright again, or after a hard 2 s window if the roll
292
  // never initiated. Counting steps instead of wall time keeps the logic
293
  // correct when the sim is fast-forwarded or the tab is throttled.
294
- if (mode === "roulade" && rouladeRun) {
295
- rouladeRun.steps++;
296
- if (obs[5] > -0.3) rouladeRun.tipped = true;
297
  const upright = obs[5] < -0.85;
298
- const done = rouladeRun.tipped && upright && rouladeRun.steps >= 40;
299
- const expired = rouladeRun.steps >= 150; // 3 s, roll should long be over
300
  if (done || expired) {
301
- rouladeRun = null;
302
  mode = "walk";
303
  lastAction.fill(0);
304
  // Timed out mid-roll: don't hand a tipped duck to the walking policy
@@ -480,11 +476,13 @@ function syncJaw() {
480
  const el = (id) => document.getElementById(id);
481
  const dotMove = el("dot-move"), dotTurn = el("dot-turn");
482
  const boxMove = dotMove.parentElement, boxTurn = dotTurn.parentElement;
483
- const hints = {
484
- move: el("hint-move"), turn: el("hint-turn"), strafe: el("hint-strafe"),
485
- roulade: el("hint-roulade"), reset: el("hint-reset"),
486
- padX: el("hint-pad-x"), padSit: el("hint-pad-sit"),
487
- padRun: el("hint-pad-run"), padRt: el("hint-pad-rt"),
 
 
488
  };
489
  const STICK_R = 15; // px, max dot travel inside the 46px stick circle
490
  let resetFlashAt = -Infinity;
@@ -496,8 +494,8 @@ function renderStats() {
496
  statsEl.textContent =
497
  `ctrl ${ctrlHz.toFixed(0)} Hz \u00b7 sim t ${data.time.toFixed(1)} s`;
498
 
499
- // Mini sticks: the dot mirrors the effective twist (auto-run shows as a
500
- // resting dot at the top), lit yellow while the user is actually driving.
501
  const manual = padActive || held.size > 0;
502
  const yN = vx >= 0 ? vx / VEL_FWD : vx / -VEL_BACK;
503
  dotMove.style.transform = `translate(${(-vy / VEL_LAT) * STICK_R}px, ${-yN * STICK_R}px)`;
@@ -505,18 +503,18 @@ function renderStats() {
505
  boxMove.classList.toggle("live", manual && (Math.abs(vx) > 0.01 || Math.abs(vy) > 0.01));
506
  boxTurn.classList.toggle("live", manual && Math.abs(wz) > 0.01);
507
 
508
- // Hint highlighting: each hint lights only for its own input device, so
509
- // driving with the gamepad doesn't light up the keyboard row.
510
  const sitting = mode === "sitstand" && sitFlag === 1;
511
- hints.move.classList.toggle("lit", held.has("fwd") || held.has("back"));
512
- hints.strafe.classList.toggle("lit", held.has("left") || held.has("right"));
513
- hints.turn.classList.toggle("lit", held.has("turnl") || held.has("turnr"));
514
- hints.roulade.classList.toggle("lit", mode === "roulade" && rouladeSource === "kb");
515
- hints.reset.classList.toggle("lit", performance.now() - resetFlashAt < 400);
516
- hints.padX.classList.toggle("lit", mode === "roulade" && rouladeSource === "pad");
517
- hints.padSit.classList.toggle("lit", sitting);
518
- hints.padRun.classList.toggle("lit", padPrev.dpadUp);
519
- hints.padRt.classList.toggle("lit", padJaw > 0.3);
520
  }
521
 
522
  // Real implementation assigned in the gamepad section below; the render
@@ -553,7 +551,7 @@ const KEYMAP = {
553
  window.addEventListener("keydown", (e) => {
554
  if (e.repeat) return;
555
  if (e.code === "KeyR") { resetSim(); resetFlashAt = performance.now(); return; }
556
- if (e.code === "Space") { e.preventDefault(); triggerRoulade(); return; }
557
  const act = KEYMAP[e.code];
558
  if (!act) return;
559
  e.preventDefault();
@@ -571,7 +569,7 @@ window.addEventListener("blur", () => { held.clear(); refreshVelCmd(); });
571
  // ── Gamepad: same mapping as the robot runtime (microduck_runtime) ──────
572
  // Sticks: L vertical = vx (asymmetric fwd/back), L horizontal = strafe,
573
  // R horizontal = turn, all EMA-smoothed like the runtime's cmd_alpha.
574
- // X = roulade, DpadDown = sit/stand, DpadUp = back to run, RT = mouth.
575
  const PAD_DEADZONE = 0.15;
576
  const PAD_ALPHA = 0.12;
577
  const dz = (v) => (Math.abs(v) < PAD_DEADZONE ? 0 : v);
@@ -592,7 +590,7 @@ pollPad = function pollGamepad() {
592
  ];
593
  for (let i = 0; i < 3; i++) padCmd[i] += PAD_ALPHA * (target[i] - padCmd[i]);
594
  // Sticks grab command authority on first input, release when back at rest
595
- // (then keyboard / auto-run take over again).
596
  const stickInput = lx !== 0 || ly !== 0 || rx !== 0;
597
  if (stickInput) padActive = true;
598
  else if (padActive && Math.abs(padCmd[0]) + Math.abs(padCmd[1]) + Math.abs(padCmd[2]) < 0.01) {
@@ -602,7 +600,7 @@ pollPad = function pollGamepad() {
602
 
603
  // Standard mapping indices: X=2, DpadDown=13, RT=7 (analog value).
604
  const x = !!gp.buttons[2]?.pressed;
605
- if (x && !padPrev.x) triggerRoulade("pad");
606
  padPrev.x = x;
607
 
608
  const dpadDown = !!gp.buttons[13]?.pressed;
@@ -612,10 +610,10 @@ pollPad = function pollGamepad() {
612
  }
613
  padPrev.dpadDown = dpadDown;
614
 
615
- // DpadUp: straight back to running. Ignored mid-roulade (it hands back
616
  // to walk on its own, and switching on a tipped duck would floor it).
617
  const dpadUp = !!gp.buttons[12]?.pressed;
618
- if (dpadUp && !padPrev.dpadUp && mode !== "walk" && mode !== "roulade") setMode("walk");
619
  padPrev.dpadUp = dpadUp;
620
 
621
  // Triggers drive the mouth like the runtime (max of both), and the right
@@ -633,7 +631,7 @@ const modeLabel = document.getElementById("mode-label");
633
 
634
  function setMode(next) {
635
  quack();
636
- rouladeRun = null;
637
  if (next !== "sit") {
638
  // Leaving a sit: let the sitstand policy stand the duck back up first.
639
  if (mode === "sitstand" && sitFlag === 1) {
@@ -658,17 +656,17 @@ function setMode(next) {
658
  syncButtons();
659
  }
660
 
661
- // One roulade, then straight back to running (Space key or the button).
662
  // lastAction is deliberately NOT zeroed here: the robot runtime keeps one
663
  // continuous action history across policy switches, and the roll initiates
664
  // more reliably mid-gait with the true last actions in the obs.
665
- function triggerRoulade(source = "kb") {
666
- if (mode === "roulade") return;
667
- rouladeSource = source;
668
  quack();
669
- mode = "roulade";
670
  sitFlag = 0;
671
- rouladeRun = { steps: 0, tipped: false };
672
  syncButtons();
673
  }
674
  // Slot-machine roll: the old text slides up and out while the new one
@@ -690,7 +688,7 @@ function setModeLabel(text) {
690
 
691
  function syncButtons() {
692
  const sitting = mode === "sitstand" && sitFlag === 1;
693
- setModeLabel(mode === "roulade" ? "Roll" : sitting ? "Sit" : "Run");
694
  }
695
 
696
  // ── Colour swatches: re-skin the rig live, with a quack ─────────────────
 
44
  const POLICIES = {
45
  walk: `${POLICY_DIR}/BEST_alpha_walking.onnx`,
46
  sitstand: `${POLICY_DIR}/BEST_alpha_sitstand.onnx`,
47
+ roll: `${POLICY_DIR}/roulade.onnx`,
48
  };
49
 
50
  // From the ONNX metadata (identical for all alpha policies) and the STAND
 
165
  return buildRig(k, { materialForMesh: materialHookFor(VARIANTS[currentVariant]) });
166
  })();
167
  const sessionOpts = { executionProviders: ["wasm"] };
168
+ [sessions.walk, sessions.sitstand, sessions.roll] = await Promise.all([
169
  ort.InferenceSession.create(signed(POLICIES.walk), sessionOpts),
170
  ort.InferenceSession.create(signed(POLICIES.sitstand), sessionOpts),
171
+ ort.InferenceSession.create(signed(POLICIES.roll), sessionOpts),
172
  ]);
173
 
174
  setLoading("Compiling physics\u2026");
 
194
  const padCmd = new Float32Array(3);
195
  let padActive = false;
196
  const padPrev = { x: false, dpadDown: false, dpadUp: false, rt: 0 };
197
+ // Declared before the control loop starts: buildObs reads it via
198
+ // effectiveCmd on the very first control step.
199
  const held = new Set();
200
 
201
+ let mode = "walk"; // "walk" | "sitstand" | "roll"
202
  let sitFlag = 0;
203
 
204
+ // The twist the policy actually receives: live gamepad sticks win over the
205
+ // held keys. No input means zero command - the duck stands in place.
206
+ // Shared by buildObs and the HUD mini-sticks so they can never disagree.
 
207
  function effectiveCmd() {
208
+ return padActive ? padCmd : velCmd;
 
 
209
  }
210
+ // One-shot roll tracking: trigger time + whether the trunk actually
211
+ // tipped over yet. Set by triggerRoll, cleared when we hand back to walk.
212
+ let rollRun = null;
213
+ let rollSource = "kb"; // which hint lights up: keyboard Space or pad X
214
 
215
  function resetSim() {
216
  mujoco.mj_resetDataKeyframe(model, data, standKeyId);
 
239
  for (let j = 0; j < NUM_JOINTS; j++) obs[i++] = qpos[qposAdr[j]] - DEFAULT_POSE[j];
240
  for (let j = 0; j < NUM_JOINTS; j++) obs[i++] = qvel[dofAdr[j]];
241
  for (let j = 0; j < NUM_JOINTS; j++) obs[i++] = lastAction[j];
242
+ // command: walking/roll use the twist; sitstand uses cmd[0] as the
243
+ // posture flag.
 
244
  cmd.fill(0, 0, 3);
245
+ if (mode === "walk" || mode === "roll") {
246
  const c = effectiveCmd();
247
  cmd[0] = c[0]; cmd[1] = c[1]; cmd[2] = c[2];
248
  } else {
 
268
  // Fall detection: walk/sitstand have no get-up skill, so auto-reset when
269
  // down for good. Height alone would false-positive on a deep sit, so
270
  // "fallen" = trunk tilted past ~60 deg (projected gravity z above -0.5)
271
+ // or sunk below the floor, sustained for over a second. The roll tumbles
272
  // the trunk on purpose and recovers on its own, so it gets a much longer
273
  // grace window before we call it stuck.
274
  const z = data.qpos[2];
275
  const now = performance.now();
276
  const tipped = obs[5] > -0.5; // projected gravity z, from the last obs
277
+ const graceMs = mode === "roll" ? 5000 : 1000;
278
  if (tipped || z < 0.02) {
279
  fallenSince ??= now;
280
  if (now - fallenSince > graceMs) { resetSim(); fallenSince = null; }
 
282
  fallenSince = null;
283
  }
284
 
285
+ // One-shot roll, step-counted like the robot runtime (a single roll is
286
  // ~1 s = 50 control steps there): hand back to walking once the trunk has
287
  // tipped over and is upright again, or after a hard 2 s window if the roll
288
  // never initiated. Counting steps instead of wall time keeps the logic
289
  // correct when the sim is fast-forwarded or the tab is throttled.
290
+ if (mode === "roll" && rollRun) {
291
+ rollRun.steps++;
292
+ if (obs[5] > -0.3) rollRun.tipped = true;
293
  const upright = obs[5] < -0.85;
294
+ const done = rollRun.tipped && upright && rollRun.steps >= 40;
295
+ const expired = rollRun.steps >= 150; // 3 s, roll should long be over
296
  if (done || expired) {
297
+ rollRun = null;
298
  mode = "walk";
299
  lastAction.fill(0);
300
  // Timed out mid-roll: don't hand a tipped duck to the walking policy
 
476
  const el = (id) => document.getElementById(id);
477
  const dotMove = el("dot-move"), dotTurn = el("dot-turn");
478
  const boxMove = dotMove.parentElement, boxTurn = dotTurn.parentElement;
479
+ const keyEls = {
480
+ fwd: el("key-fwd"), back: el("key-back"),
481
+ turnl: el("key-turnl"), turnr: el("key-turnr"),
482
+ left: el("key-left"), right: el("key-right"),
483
+ roll: el("key-roll"), reset: el("key-reset"),
484
+ padX: el("key-pad-x"), padSit: el("key-pad-sit"),
485
+ padRun: el("key-pad-run"), padRt: el("key-pad-rt"),
486
  };
487
  const STICK_R = 15; // px, max dot travel inside the 46px stick circle
488
  let resetFlashAt = -Infinity;
 
494
  statsEl.textContent =
495
  `ctrl ${ctrlHz.toFixed(0)} Hz \u00b7 sim t ${data.time.toFixed(1)} s`;
496
 
497
+ // Mini sticks: the dot mirrors the effective twist, lit yellow while the
498
+ // user is actually driving.
499
  const manual = padActive || held.size > 0;
500
  const yN = vx >= 0 ? vx / VEL_FWD : vx / -VEL_BACK;
501
  dotMove.style.transform = `translate(${(-vy / VEL_LAT) * STICK_R}px, ${-yN * STICK_R}px)`;
 
503
  boxMove.classList.toggle("live", manual && (Math.abs(vx) > 0.01 || Math.abs(vy) > 0.01));
504
  boxTurn.classList.toggle("live", manual && Math.abs(wz) > 0.01);
505
 
506
+ // Keycap highlighting: each individual key lights only while its own
507
+ // action is active, and only for its own input device.
508
  const sitting = mode === "sitstand" && sitFlag === 1;
509
+ for (const k of ["fwd", "back", "turnl", "turnr", "left", "right"]) {
510
+ keyEls[k].classList.toggle("lit", held.has(k));
511
+ }
512
+ keyEls.roll.classList.toggle("lit", mode === "roll" && rollSource === "kb");
513
+ keyEls.reset.classList.toggle("lit", performance.now() - resetFlashAt < 400);
514
+ keyEls.padX.classList.toggle("lit", mode === "roll" && rollSource === "pad");
515
+ keyEls.padSit.classList.toggle("lit", sitting);
516
+ keyEls.padRun.classList.toggle("lit", padPrev.dpadUp);
517
+ keyEls.padRt.classList.toggle("lit", padJaw > 0.3);
518
  }
519
 
520
  // Real implementation assigned in the gamepad section below; the render
 
551
  window.addEventListener("keydown", (e) => {
552
  if (e.repeat) return;
553
  if (e.code === "KeyR") { resetSim(); resetFlashAt = performance.now(); return; }
554
+ if (e.code === "Space") { e.preventDefault(); triggerRoll(); return; }
555
  const act = KEYMAP[e.code];
556
  if (!act) return;
557
  e.preventDefault();
 
569
  // ── Gamepad: same mapping as the robot runtime (microduck_runtime) ──────
570
  // Sticks: L vertical = vx (asymmetric fwd/back), L horizontal = strafe,
571
  // R horizontal = turn, all EMA-smoothed like the runtime's cmd_alpha.
572
+ // X = roll, DpadDown = sit/stand, DpadUp = back to run, RT = mouth.
573
  const PAD_DEADZONE = 0.15;
574
  const PAD_ALPHA = 0.12;
575
  const dz = (v) => (Math.abs(v) < PAD_DEADZONE ? 0 : v);
 
590
  ];
591
  for (let i = 0; i < 3; i++) padCmd[i] += PAD_ALPHA * (target[i] - padCmd[i]);
592
  // Sticks grab command authority on first input, release when back at rest
593
+ // (then the keyboard takes over again).
594
  const stickInput = lx !== 0 || ly !== 0 || rx !== 0;
595
  if (stickInput) padActive = true;
596
  else if (padActive && Math.abs(padCmd[0]) + Math.abs(padCmd[1]) + Math.abs(padCmd[2]) < 0.01) {
 
600
 
601
  // Standard mapping indices: X=2, DpadDown=13, RT=7 (analog value).
602
  const x = !!gp.buttons[2]?.pressed;
603
+ if (x && !padPrev.x) triggerRoll("pad");
604
  padPrev.x = x;
605
 
606
  const dpadDown = !!gp.buttons[13]?.pressed;
 
610
  }
611
  padPrev.dpadDown = dpadDown;
612
 
613
+ // DpadUp: straight back to running. Ignored mid-roll (it hands back
614
  // to walk on its own, and switching on a tipped duck would floor it).
615
  const dpadUp = !!gp.buttons[12]?.pressed;
616
+ if (dpadUp && !padPrev.dpadUp && mode !== "walk" && mode !== "roll") setMode("walk");
617
  padPrev.dpadUp = dpadUp;
618
 
619
  // Triggers drive the mouth like the runtime (max of both), and the right
 
631
 
632
  function setMode(next) {
633
  quack();
634
+ rollRun = null;
635
  if (next !== "sit") {
636
  // Leaving a sit: let the sitstand policy stand the duck back up first.
637
  if (mode === "sitstand" && sitFlag === 1) {
 
656
  syncButtons();
657
  }
658
 
659
+ // One roll, then straight back to running (Space key or the button).
660
  // lastAction is deliberately NOT zeroed here: the robot runtime keeps one
661
  // continuous action history across policy switches, and the roll initiates
662
  // more reliably mid-gait with the true last actions in the obs.
663
+ function triggerRoll(source = "kb") {
664
+ if (mode === "roll") return;
665
+ rollSource = source;
666
  quack();
667
+ mode = "roll";
668
  sitFlag = 0;
669
+ rollRun = { steps: 0, tipped: false };
670
  syncButtons();
671
  }
672
  // Slot-machine roll: the old text slides up and out while the new one
 
688
 
689
  function syncButtons() {
690
  const sitting = mode === "sitstand" && sitFlag === 1;
691
+ setModeLabel(mode === "roll" ? "Roll" : sitting ? "Sit" : "Run");
692
  }
693
 
694
  // ── Colour swatches: re-skin the rig live, with a quack ─────────────────