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

feat: HUD UX pass - state label with slot-machine roll, keycap hints, per-device highlighting, quack sound from the runtime

Browse files
Files changed (5) hide show
  1. .gitattributes +1 -0
  2. README.md +2 -2
  3. assets/quack.wav +3 -0
  4. index.html +135 -15
  5. rl.js +103 -34
.gitattributes CHANGED
@@ -34,3 +34,4 @@ saved_model/**/* filter=lfs diff=lfs merge=lfs -text
34
  *.zst filter=lfs diff=lfs merge=lfs -text
35
  *tfevents* filter=lfs diff=lfs merge=lfs -text
36
  *.stl filter=lfs diff=lfs merge=lfs -text
 
 
34
  *.zst filter=lfs diff=lfs merge=lfs -text
35
  *tfevents* filter=lfs diff=lfs merge=lfs -text
36
  *.stl filter=lfs diff=lfs merge=lfs -text
37
+ *.wav filter=lfs diff=lfs merge=lfs -text
README.md CHANGED
@@ -30,7 +30,7 @@ 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
- - 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)
@@ -41,7 +41,7 @@ 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
 
 
30
  - Arrow up / down: forward / back
31
  - Arrow left / right: turn
32
  - A / E: strafe
33
+ - Space: roll (one barrel roll, then back to running)
34
  - R: reset
35
  - Drag to orbit, scroll to zoom
36
  - Colour dots: repaint the duck (it quacks)
 
41
 
42
  - Left stick: forward / back + strafe
43
  - Right stick: turn
44
+ - X: roll
45
  - D-pad down: sit / stand toggle
46
  - Right trigger: mouth (analog) + quack
47
 
assets/quack.wav ADDED
@@ -0,0 +1,3 @@
 
 
 
 
1
+ version https://git-lfs.github.com/spec/v1
2
+ oid sha256:71c1a42876e55ade7e0dc72a5b2d68244bb192a3bf24caf18cc8645eece6bdee
3
+ size 46840
index.html CHANGED
@@ -73,7 +73,7 @@
73
  display: flex;
74
  flex-direction: column;
75
  align-items: flex-start;
76
- gap: 0.7rem;
77
  pointer-events: auto;
78
  }
79
  #hud .row {
@@ -102,6 +102,30 @@
102
  border-color: #ffc61e;
103
  color: #08080c;
104
  }
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
105
  /* Colour swatches */
106
  #swatches { gap: 0.6rem; }
107
  #swatches button {
@@ -124,14 +148,91 @@
124
  line-height: 1.9;
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;
133
  right: 1.6rem;
134
  z-index: 10;
 
 
 
 
 
 
135
  font-size: 0.68rem;
136
  letter-spacing: 0.08em;
137
  text-align: right;
@@ -139,7 +240,6 @@
139
  line-height: 1.7;
140
  font-variant-numeric: tabular-nums;
141
  }
142
- #stats b { color: var(--yellow); font-weight: 600; }
143
  @media (max-width: 700px) {
144
  #hud { flex-direction: column; align-items: flex-start; }
145
  #hud .keys { display: none; }
@@ -154,20 +254,39 @@
154
  <b>Microduck</b> &middot; real trained policies<br />
155
  <span class="sub">MuJoCo + ONNX, live in your browser</span>
156
  </div>
157
- <div id="stats"></div>
 
 
 
158
  <div id="hud" hidden>
159
  <div class="stack">
160
- <div class="row">
161
- <button id="btn-walk" class="on">Run</button>
162
- <button id="btn-sit">Sit</button>
163
- <button id="btn-roulade">Roulade</button>
164
- </div>
165
  <div class="row" id="swatches"></div>
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>
@@ -184,9 +303,10 @@
184
  appended: a plain src attribute can 401 when the browser blocks the
185
  *.static.hf.space auth cookie inside the hub iframe. -->
186
  <script type="module">
 
187
  const sign = new URLSearchParams(location.search).get("__sign");
188
- const suffix = sign ? `?__sign=${encodeURIComponent(sign)}` : "";
189
- import(`./rl.js${suffix}`);
190
  </script>
191
  </body>
192
  </html>
 
73
  display: flex;
74
  flex-direction: column;
75
  align-items: flex-start;
76
+ gap: 0.45rem;
77
  pointer-events: auto;
78
  }
79
  #hud .row {
 
102
  border-color: #ffc61e;
103
  color: #08080c;
104
  }
105
+ /* Current-state label: quiet line above the telemetry, slot-machine
106
+ roll on change */
107
+ .mode {
108
+ position: relative;
109
+ overflow: hidden;
110
+ height: 1.2em;
111
+ min-width: 8ch;
112
+ font-size: 0.72rem;
113
+ font-weight: 600;
114
+ letter-spacing: 0.2em;
115
+ text-transform: uppercase;
116
+ color: rgba(255, 210, 63, 0.8);
117
+ text-align: right;
118
+ }
119
+ .mode .mode-text {
120
+ position: absolute;
121
+ left: 0;
122
+ top: 0;
123
+ width: 100%;
124
+ line-height: 1.2;
125
+ transition: transform 0.3s cubic-bezier(0.2, 0.8, 0.2, 1), opacity 0.3s ease;
126
+ }
127
+ .mode .mode-text.in { transform: translateY(130%); opacity: 0; }
128
+ .mode .mode-text.out { transform: translateY(-130%); opacity: 0; }
129
  /* Colour swatches */
130
  #swatches { gap: 0.6rem; }
131
  #swatches button {
 
148
  line-height: 1.9;
149
  pointer-events: auto;
150
  }
151
+ /* Corner group captions */
152
+ #hud .caption {
153
+ display: block;
154
+ font-size: 0.55rem;
155
+ font-weight: 600;
156
+ letter-spacing: 0.2em;
157
+ text-transform: uppercase;
158
+ color: rgba(255, 255, 255, 0.28);
159
+ }
160
+ #hud .keys .caption { margin-bottom: 0.45rem; }
161
+ /* Keycap chips in the hints */
162
+ #hud .keys .key {
163
+ display: inline-block;
164
+ min-width: 1.5em;
165
+ padding: 0.12em 0.4em;
166
+ border: 1px solid rgba(255, 255, 255, 0.2);
167
+ border-bottom-width: 2px;
168
+ border-radius: 5px;
169
+ background: rgba(255, 255, 255, 0.05);
170
+ color: rgba(255, 255, 255, 0.75);
171
+ font-size: 0.62rem;
172
+ font-weight: 600;
173
+ line-height: 1.35;
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);
182
+ }
183
  #pad-hint { display: none; }
184
  body.pad-connected #pad-hint { display: inline; }
185
+ /* Mini command sticks: dot = the twist the policy actually receives */
186
+ #sticks {
187
+ display: flex;
188
+ gap: 0.7rem;
189
+ justify-content: flex-end;
190
+ margin-bottom: 0.5rem;
191
+ }
192
+ .stickwrap {
193
+ display: flex;
194
+ flex-direction: column;
195
+ align-items: center;
196
+ gap: 0.15rem;
197
+ }
198
+ .stickwrap span {
199
+ font-size: 0.55rem;
200
+ letter-spacing: 0.14em;
201
+ text-transform: uppercase;
202
+ color: rgba(255, 255, 255, 0.3);
203
+ line-height: 1;
204
+ }
205
+ .stickbox {
206
+ position: relative;
207
+ width: 46px;
208
+ height: 46px;
209
+ border-radius: 50%;
210
+ border: 1px solid rgba(255, 255, 255, 0.16);
211
+ background: rgba(255, 255, 255, 0.03);
212
+ }
213
+ .stickdot {
214
+ position: absolute;
215
+ left: 50%;
216
+ top: 50%;
217
+ width: 12px;
218
+ height: 12px;
219
+ margin: -6px 0 0 -6px;
220
+ border-radius: 50%;
221
+ background: rgba(255, 255, 255, 0.35);
222
+ transition: background 0.15s ease;
223
+ }
224
+ .stickbox.live .stickdot { background: var(--yellow); }
225
+ #stats-wrap {
226
  position: fixed;
227
  top: 1.4rem;
228
  right: 1.6rem;
229
  z-index: 10;
230
+ display: flex;
231
+ flex-direction: column;
232
+ align-items: flex-end;
233
+ gap: 0.15rem;
234
+ }
235
+ #stats {
236
  font-size: 0.68rem;
237
  letter-spacing: 0.08em;
238
  text-align: right;
 
240
  line-height: 1.7;
241
  font-variant-numeric: tabular-nums;
242
  }
 
243
  @media (max-width: 700px) {
244
  #hud { flex-direction: column; align-items: flex-start; }
245
  #hud .keys { display: none; }
 
254
  <b>Microduck</b> &middot; real trained policies<br />
255
  <span class="sub">MuJoCo + ONNX, live in your browser</span>
256
  </div>
257
+ <div id="stats-wrap">
258
+ <div class="mode" id="mode-label"><span class="mode-text">Run</span></div>
259
+ <div id="stats"></div>
260
+ </div>
261
  <div id="hud" hidden>
262
  <div class="stack">
263
+ <span class="caption">Colour</span>
 
 
 
 
264
  <div class="row" id="swatches"></div>
265
  </div>
266
  <div class="keys">
267
+ <span class="caption">Controls</span>
268
+ <div id="sticks">
269
+ <div class="stickwrap">
270
+ <div class="stickbox"><div class="stickdot" id="dot-move"></div></div>
271
+ <span>move</span>
272
+ </div>
273
+ <div class="stickwrap">
274
+ <div class="stickbox"><div class="stickdot" id="dot-turn"></div></div>
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>
292
  <div id="loading">Compiling physics&hellip;</div>
 
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}`);
310
  </script>
311
  </body>
312
  </html>
rl.js CHANGED
@@ -193,15 +193,27 @@ const velCmd = new Float32Array(3); // twist command, driven by held keys
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();
199
 
200
  let mode = "walk"; // "walk" | "sitstand" | "roulade"
201
  let sitFlag = 0;
 
 
 
 
 
 
 
 
 
 
202
  // One-shot roulade tracking: trigger time + whether the trunk actually
203
  // tipped over yet. Set by triggerRoulade, cleared when we hand back to walk.
204
  let rouladeRun = null;
 
205
 
206
  function resetSim() {
207
  mujoco.mj_resetDataKeyframe(model, data, standKeyId);
@@ -234,15 +246,9 @@ function buildObs() {
234
  // posture flag. "Run" means run: with no keys held the walking policy
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 {
247
  cmd[0] = sitFlag;
248
  }
@@ -451,24 +457,66 @@ function syncRig() {
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"
466
- ? `roulade policy`
467
- : `sitstand policy \u00b7 ${sitFlag ? "sit" : "stand"}`;
468
- statsEl.innerHTML =
469
- `<b>${posture}</b><br>` +
470
- `cmd vx ${vx.toFixed(2)} \u00b7 vy ${vy.toFixed(2)} \u00b7 wz ${wz.toFixed(2)}<br>` +
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
@@ -504,7 +552,7 @@ const KEYMAP = {
504
 
505
  window.addEventListener("keydown", (e) => {
506
  if (e.repeat) return;
507
- if (e.code === "KeyR") { resetSim(); return; }
508
  if (e.code === "Space") { e.preventDefault(); triggerRoulade(); return; }
509
  const act = KEYMAP[e.code];
510
  if (!act) return;
@@ -523,10 +571,9 @@ window.addEventListener("blur", () => { held.clear(); refreshVelCmd(); });
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() {
@@ -555,7 +602,7 @@ pollPad = function pollGamepad() {
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;
@@ -565,15 +612,24 @@ pollPad = function pollGamepad() {
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");
577
 
578
  function setMode(next) {
579
  quack();
@@ -606,23 +662,36 @@ function setMode(next) {
606
  // lastAction is deliberately NOT zeroed here: the robot runtime keeps one
607
  // continuous action history across policy switches, and the roll initiates
608
  // more reliably mid-gait with the true last actions in the obs.
609
- function triggerRoulade() {
610
  if (mode === "roulade") return;
 
611
  quack();
612
  mode = "roulade";
613
  sitFlag = 0;
614
  rouladeRun = { steps: 0, tipped: false };
615
  syncButtons();
616
  }
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
617
  function syncButtons() {
618
  const sitting = mode === "sitstand" && sitFlag === 1;
619
- btnWalk.classList.toggle("on", mode === "walk" || (mode === "sitstand" && !sitting));
620
- btnSit.classList.toggle("on", sitting);
621
- btnRoulade.classList.toggle("on", mode === "roulade");
622
  }
623
- btnWalk.addEventListener("click", () => setMode("walk"));
624
- btnSit.addEventListener("click", () => setMode("sit"));
625
- btnRoulade.addEventListener("click", triggerRoulade);
626
 
627
  // ── Colour swatches: re-skin the rig live, with a quack ─────────────────
628
  // One representative colour per variant so the dots read at a glance.
 
193
  // the control loop reads it before the input section below has evaluated.
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);
 
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 {
253
  cmd[0] = sitFlag;
254
  }
 
457
  const QUACK_MS = 480;
458
  let quackAt = -Infinity;
459
  let padJaw = 0;
460
+ // Same sound as the robot: the runtime plays its voice-bank chirp on every
461
+ // mouth-open edge and falls back to the stock quack.wav β€” the only audio
462
+ // asset in the repo, so that's what we ship. Restarting playback on each
463
+ // trigger matches the runtime's "cut off any still-playing sound".
464
+ // Browsers block audio until the first user gesture; the rejected play()
465
+ // is swallowed and sound simply starts working after the first click/key.
466
+ const quackSound = new Audio(signed("./assets/quack.wav"));
467
+ quackSound.volume = 0.7;
468
+ const quack = () => {
469
+ quackAt = performance.now();
470
+ quackSound.currentTime = 0;
471
+ quackSound.play().catch(() => {});
472
+ };
473
  function syncJaw() {
474
  const t = (performance.now() - quackAt) / QUACK_MS;
475
  const flap = t >= 0 && t < 1 ? Math.sin(Math.PI * t) : 0;
476
  setJawOpen(rig, Math.max(flap, padJaw));
477
  }
478
 
479
+ // HUD elements for the mini command sticks + hint highlighting.
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;
491
+
492
  function renderStats() {
493
+ const [vx, vy, wz] = effectiveCmd();
494
+ // The active policy lives in the big center label and the twist in the
495
+ // mini sticks; up here only the bare telemetry remains.
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)`;
504
+ dotTurn.style.transform = `translate(${(-wz / VEL_ANG) * STICK_R}px, 0px)`;
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
 
552
 
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;
 
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);
578
 
579
  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
  }
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
622
+ // one quacks on its rising edge β€” same as the robot's chirp.
623
  const rt = gp.buttons[7]?.value ?? 0;
624
+ const lt = gp.buttons[6]?.value ?? 0;
625
+ padJaw = Math.max(rt, lt);
626
  if (padPrev.rt < 0.3 && rt >= 0.3) quack();
627
  padPrev.rt = rt;
628
  };
629
 
630
+ // Read-only state label (bottom-left): reflects the active policy,
631
+ // switching happens via keyboard/gamepad only.
632
+ const modeLabel = document.getElementById("mode-label");
633
 
634
  function setMode(next) {
635
  quack();
 
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
675
+ // rises in from below the (overflow-hidden) label box.
676
+ function setModeLabel(text) {
677
+ const cur = modeLabel.lastElementChild;
678
+ if (cur && cur.textContent === text) return;
679
+ const next = document.createElement("span");
680
+ next.className = "mode-text in";
681
+ next.textContent = text;
682
+ modeLabel.appendChild(next);
683
+ void next.offsetWidth; // flush layout so the transition actually plays
684
+ if (cur) {
685
+ cur.classList.add("out");
686
+ setTimeout(() => cur.remove(), 350);
687
+ }
688
+ next.classList.remove("in");
689
+ }
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 ─────────────────
697
  // One representative colour per variant so the dots read at a glance.