tfrere HF Staff Cursor commited on
Commit
f3ff1b1
Β·
1 Parent(s): f402bd3

feat: one-shot kick moves from the runtime's ball-kick policies

Browse files

ball_kick_left/right.onnx (same 61-dim obs interface) run for a fixed
0.5 s window with zeroed commands, then hand back to walking - same
mechanics as the robot runtime. F on the keyboard alternates feet,
RB / LB on the gamepad pick explicitly. Kicks only launch from a
standing walk and are mutually locked with the roll.

Co-authored-by: Cursor <cursoragent@cursor.com>

README.md CHANGED
@@ -20,6 +20,7 @@ runs the policy network at 50 Hz. No server, no backend.
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)
@@ -31,6 +32,7 @@ and [apirrone/mjlab_microduck](https://github.com/apirrone/mjlab_microduck).
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)
@@ -42,6 +44,7 @@ Plug in a controller and the same mapping as the real robot runtime applies:
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
 
 
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
+ | Kick | `ball_kick_left.onnx` / `ball_kick_right.onnx` | Blind one-shot kick (0.5 s window, zeroed commands), left or right leg |
24
 
25
  Policies and MJCF model from
26
  [apirrone/microduck_runtime](https://github.com/apirrone/microduck_runtime)
 
32
  - Arrow left / right: turn
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)
 
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
50
 
index.html CHANGED
@@ -392,10 +392,12 @@
392
  <span class="hint"><span class="key" id="key-turnl">&larr;</span><span class="key" id="key-turnr">&rarr;</span> turn</span> &nbsp;
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-reset">R</span> reset</span> &nbsp;
396
  drag to orbit &middot; scroll to zoom<br />
397
  <span id="pad-hint">gamepad:
398
  <span class="hint"><span class="key" id="key-pad-x">X</span> roll</span> &nbsp;
 
399
  <span class="hint"><span class="key" id="key-pad-sit">&dArr;</span> sit</span> &nbsp;
400
  <span class="hint"><span class="key" id="key-pad-run">&uArr;</span> run</span> &nbsp;
401
  <span class="hint"><span class="key" id="key-pad-rt">RT</span> quack</span>
@@ -419,6 +421,7 @@
419
  <ul>
420
  <li><b>Move</b><span>Arrows or ZQSD to run and turn, A / E to strafe, drag to orbit.</span></li>
421
  <li><b>Roll</b><span>Space triggers a barrel roll, then it gets back up.</span></li>
 
422
  <li><b>Gamepad</b><span>Plug one in - same mapping as the real robot.</span></li>
423
  <li><b>Ghosts</b><span>Other visitors right now appear as translucent ducks, live.</span></li>
424
  </ul>
@@ -468,7 +471,7 @@
468
  appended: a plain src attribute can 401 when the browser blocks the
469
  *.static.hf.space auth cookie inside the hub iframe. -->
470
  <script type="module">
471
- const V = "24"; // bump on rl.js changes to defeat heuristic caching
472
  const sign = new URLSearchParams(location.search).get("__sign");
473
  const suffix = sign ? `&__sign=${encodeURIComponent(sign)}` : "";
474
  import(`./rl.js?v=${V}${suffix}`);
 
392
  <span class="hint"><span class="key" id="key-turnl">&larr;</span><span class="key" id="key-turnr">&rarr;</span> turn</span> &nbsp;
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;
403
  <span class="hint"><span class="key" id="key-pad-rt">RT</span> quack</span>
 
421
  <ul>
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
  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}`);
policies/ball_kick_left.onnx ADDED
@@ -0,0 +1,3 @@
 
 
 
 
1
+ version https://git-lfs.github.com/spec/v1
2
+ oid sha256:d6928284dccd3dd61e08bf2f760effa74309fbefd97b2b31afb2a60f526d196a
3
+ size 793685
policies/ball_kick_right.onnx ADDED
@@ -0,0 +1,3 @@
 
 
 
 
1
+ version https://git-lfs.github.com/spec/v1
2
+ oid sha256:147a32c388c6b19111b3ac3b550a9a6dc8b8bf267118af4d8c3712522eedb5af
3
+ size 793685
rl.js CHANGED
@@ -49,6 +49,10 @@ const POLICIES = {
49
  walk: `${POLICY_DIR}/BEST_alpha_walking.onnx`,
50
  sitstand: `${POLICY_DIR}/BEST_alpha_sitstand.onnx`,
51
  roll: `${POLICY_DIR}/roulade.onnx`,
 
 
 
 
52
  };
53
 
54
  // From the ONNX metadata (identical for all alpha policies) and the STAND
@@ -169,11 +173,14 @@ const rigPromise = (async () => {
169
  return buildRig(k, { materialForMesh: materialHookFor(VARIANTS[currentVariant]) });
170
  })();
171
  const sessionOpts = { executionProviders: ["wasm"] };
172
- [sessions.walk, sessions.sitstand, sessions.roll] = await Promise.all([
173
- ort.InferenceSession.create(signed(POLICIES.walk), sessionOpts),
174
- ort.InferenceSession.create(signed(POLICIES.sitstand), sessionOpts),
175
- ort.InferenceSession.create(signed(POLICIES.roll), sessionOpts),
176
- ]);
 
 
 
177
 
178
  setLoading("Compiling physics\u2026");
179
  const model = mujoco.MjModel.from_xml_string(xml, vfs);
@@ -197,13 +204,14 @@ const velCmd = new Float32Array(3); // twist command, driven by held keys
197
  // the control loop reads it before the input section below has evaluated.
198
  const padCmd = new Float32Array(3);
199
  let padActive = false;
200
- const padPrev = { x: false, dpadDown: false, dpadUp: false, rtArmed: true };
201
  // Declared before the control loop starts: buildObs reads it via
202
  // effectiveCmd on the very first control step.
203
  const held = new Set();
204
 
205
- let mode = "walk"; // "walk" | "sitstand" | "roll"
206
  let sitFlag = 0;
 
207
 
208
  // The twist the policy actually receives: live gamepad sticks win over the
209
  // held keys. No input means zero command - the duck stands in place.
@@ -212,13 +220,19 @@ let sitFlag = 0;
212
  // hands back to walk on its own - steering would only knock the roll over.
213
  const ZERO_CMD = new Float32Array(3);
214
  function effectiveCmd() {
215
- if (mode === "roll") return ZERO_CMD;
216
  return padActive ? padCmd : velCmd;
217
  }
218
  // One-shot roll tracking: trigger time + whether the trunk actually
219
  // tipped over yet. Set by triggerRoll, cleared when we hand back to walk.
220
  let rollRun = null;
221
  let rollSource = "kb"; // which hint lights up: keyboard Space or pad X
 
 
 
 
 
 
222
 
223
  // Pending mode-transition timers (sit hand-over, stand-up hand-back).
224
  // Every transition entry point clears them: a stale timer firing after
@@ -237,6 +251,7 @@ function resetSim() {
237
  // freshly reset duck.
238
  clearModeTimers();
239
  rollRun = null;
 
240
  mode = "walk";
241
  mujoco.mj_resetDataKeyframe(model, data, standKeyId);
242
  mujoco.mj_forward(model, data);
@@ -267,11 +282,13 @@ function buildObs() {
267
  // command: walking/roll use the twist; sitstand uses cmd[0] as the
268
  // posture flag.
269
  cmd.fill(0, 0, 3);
270
- if (mode === "walk" || mode === "roll") {
 
 
 
 
271
  const c = effectiveCmd();
272
  cmd[0] = c[0]; cmd[1] = c[1]; cmd[2] = c[2];
273
- } else {
274
- cmd[0] = sitFlag;
275
  }
276
  for (let c = 0; c < CMD_SIZE; c++) obs[i++] = cmd[c];
277
  return obs;
@@ -312,6 +329,18 @@ async function controlStep() {
312
  // tipped over and is upright again, or after a hard 2 s window if the roll
313
  // never initiated. Counting steps instead of wall time keeps the logic
314
  // correct when the sim is fast-forwarded or the tab is throttled.
 
 
 
 
 
 
 
 
 
 
 
 
315
  if (mode === "roll" && rollRun) {
316
  rollRun.steps++;
317
  if (obs[5] > -0.3) rollRun.tipped = true;
@@ -526,10 +555,11 @@ const keyEls = {
526
  fwd: el("key-fwd"), back: el("key-back"),
527
  turnl: el("key-turnl"), turnr: el("key-turnr"),
528
  left: el("key-left"), right: el("key-right"),
529
- roll: el("key-roll"), reset: el("key-reset"),
530
- padX: el("key-pad-x"), padSit: el("key-pad-sit"),
531
- padRun: el("key-pad-run"), padRt: el("key-pad-rt"),
532
- };
 
533
  const STICK_R = 15; // px, max dot travel inside the 46px stick circle
534
  let resetFlashAt = -Infinity;
535
 
@@ -558,8 +588,11 @@ function renderStats() {
558
  keyEls[k].classList.toggle("lit", held.has(k));
559
  }
560
  keyEls.roll.classList.toggle("lit", mode === "roll" && rollSource === "kb");
 
561
  keyEls.reset.classList.toggle("lit", performance.now() - resetFlashAt < 400);
562
  keyEls.padX.classList.toggle("lit", mode === "roll" && rollSource === "pad");
 
 
563
  keyEls.padSit.classList.toggle("lit", sitting);
564
  keyEls.padRun.classList.toggle("lit", padPrev.dpadUp);
565
  keyEls.padRt.classList.toggle("lit", padJaw > 0.3);
@@ -602,10 +635,17 @@ const KEYMAP = {
602
  KeyQ: "left", KeyE: "right",
603
  };
604
 
 
 
605
  window.addEventListener("keydown", (e) => {
606
  if (e.repeat) return;
607
  if (e.code === "KeyR") { resetSim(); resetFlashAt = performance.now(); return; }
608
  if (e.code === "Space") { e.preventDefault(); triggerRoll(); return; }
 
 
 
 
 
609
  const act = KEYMAP[e.code];
610
  if (!act) return;
611
  e.preventDefault();
@@ -652,11 +692,19 @@ pollPad = function pollGamepad() {
652
  padCmd.fill(0);
653
  }
654
 
655
- // Standard mapping indices: X=2, DpadDown=13, RT=7 (analog value).
656
  const x = !!gp.buttons[2]?.pressed;
657
  if (x && !padPrev.x) triggerRoll("pad");
658
  padPrev.x = x;
659
 
 
 
 
 
 
 
 
 
660
  const dpadDown = !!gp.buttons[13]?.pressed;
661
  if (dpadDown && !padPrev.dpadDown) {
662
  const sitting = mode === "sitstand" && sitFlag === 1;
@@ -687,9 +735,9 @@ pollPad = function pollGamepad() {
687
  const modeLabel = document.getElementById("mode-label");
688
 
689
  function setMode(next) {
690
- // No policy switching mid-roll: the roll ends on its own (upright again
691
- // or expired) and returns to walk - switching now would floor the duck.
692
- if (mode === "roll" && rollRun) return;
693
  clearModeTimers();
694
  quack();
695
  rollRun = null;
@@ -739,6 +787,22 @@ function triggerRoll(source = "kb") {
739
  rollRun = { steps: 0, tipped: false };
740
  syncButtons();
741
  }
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
742
  // Slot-machine roll: the old text slides up and out while the new one
743
  // rises in from below the (overflow-hidden) label box.
744
  function setModeLabel(text) {
@@ -758,7 +822,7 @@ function setModeLabel(text) {
758
 
759
  function syncButtons() {
760
  const sitting = mode === "sitstand" && sitFlag === 1;
761
- setModeLabel(mode === "roll" ? "Roll" : sitting ? "Sit" : "Run");
762
  }
763
 
764
  // ── Colour swatches: re-skin the rig live, with a quack ─────────────────
 
49
  walk: `${POLICY_DIR}/BEST_alpha_walking.onnx`,
50
  sitstand: `${POLICY_DIR}/BEST_alpha_sitstand.onnx`,
51
  roll: `${POLICY_DIR}/roulade.onnx`,
52
+ // Blind one-shot kicks (the operator aims the robot, no ball in obs):
53
+ // the runtime swaps these in for a 0.5 s window, commands zeroed.
54
+ kickL: `${POLICY_DIR}/ball_kick_left.onnx`,
55
+ kickR: `${POLICY_DIR}/ball_kick_right.onnx`,
56
  };
57
 
58
  // From the ONNX metadata (identical for all alpha policies) and the STAND
 
173
  return buildRig(k, { materialForMesh: materialHookFor(VARIANTS[currentVariant]) });
174
  })();
175
  const sessionOpts = { executionProviders: ["wasm"] };
176
+ [sessions.walk, sessions.sitstand, sessions.roll, sessions.kickL, sessions.kickR] =
177
+ await Promise.all([
178
+ ort.InferenceSession.create(signed(POLICIES.walk), sessionOpts),
179
+ ort.InferenceSession.create(signed(POLICIES.sitstand), sessionOpts),
180
+ ort.InferenceSession.create(signed(POLICIES.roll), sessionOpts),
181
+ ort.InferenceSession.create(signed(POLICIES.kickL), sessionOpts),
182
+ ort.InferenceSession.create(signed(POLICIES.kickR), sessionOpts),
183
+ ]);
184
 
185
  setLoading("Compiling physics\u2026");
186
  const model = mujoco.MjModel.from_xml_string(xml, vfs);
 
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();
211
 
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.
 
220
  // hands back to walk on its own - steering would only knock the roll over.
221
  const ZERO_CMD = new Float32Array(3);
222
  function effectiveCmd() {
223
+ if (mode === "roll" || isKick()) return ZERO_CMD;
224
  return padActive ? padCmd : velCmd;
225
  }
226
  // One-shot roll tracking: trigger time + whether the trunk actually
227
  // tipped over yet. Set by triggerRoll, cleared when we hand back to walk.
228
  let rollRun = null;
229
  let rollSource = "kb"; // which hint lights up: keyboard Space or pad X
230
+ // One-shot kick tracking: the runtime swaps the kick policy in for a fixed
231
+ // 0.5 s window (25 control steps at 50 Hz) with zeroed commands, then hands
232
+ // straight back to walking. lastAction stays continuous across both swaps.
233
+ let kickRun = null;
234
+ let kickSource = "kb";
235
+ const KICK_STEPS = 25;
236
 
237
  // Pending mode-transition timers (sit hand-over, stand-up hand-back).
238
  // Every transition entry point clears them: a stale timer firing after
 
251
  // freshly reset duck.
252
  clearModeTimers();
253
  rollRun = null;
254
+ kickRun = null;
255
  mode = "walk";
256
  mujoco.mj_resetDataKeyframe(model, data, standKeyId);
257
  mujoco.mj_forward(model, data);
 
282
  // command: walking/roll use the twist; sitstand uses cmd[0] as the
283
  // posture flag.
284
  cmd.fill(0, 0, 3);
285
+ if (mode === "sitstand") {
286
+ cmd[0] = sitFlag;
287
+ } else {
288
+ // walk uses the live twist; roll and kick see all-zero commands
289
+ // (via effectiveCmd), matching how they were trained.
290
  const c = effectiveCmd();
291
  cmd[0] = c[0]; cmd[1] = c[1]; cmd[2] = c[2];
 
 
292
  }
293
  for (let c = 0; c < CMD_SIZE; c++) obs[i++] = cmd[c];
294
  return obs;
 
329
  // tipped over and is upright again, or after a hard 2 s window if the roll
330
  // never initiated. Counting steps instead of wall time keeps the logic
331
  // correct when the sim is fast-forwarded or the tab is throttled.
332
+ // One-shot kick: fixed 0.5 s window like the robot runtime, then straight
333
+ // back to walking. lastAction is NOT zeroed on either swap - the runtime
334
+ // keeps one continuous action history across policy switches.
335
+ if (isKick() && kickRun) {
336
+ kickRun.steps++;
337
+ if (kickRun.steps >= KICK_STEPS) {
338
+ kickRun = null;
339
+ mode = "walk";
340
+ if (uiReady) syncButtons();
341
+ }
342
+ }
343
+
344
  if (mode === "roll" && rollRun) {
345
  rollRun.steps++;
346
  if (obs[5] > -0.3) rollRun.tipped = true;
 
555
  fwd: el("key-fwd"), back: el("key-back"),
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
 
 
588
  keyEls[k].classList.toggle("lit", held.has(k));
589
  }
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);
597
  keyEls.padRun.classList.toggle("lit", padPrev.dpadUp);
598
  keyEls.padRt.classList.toggle("lit", padJaw > 0.3);
 
635
  KeyQ: "left", KeyE: "right",
636
  };
637
 
638
+ // Keyboard kicks alternate feet (the pad picks explicitly via RB/LB).
639
+ 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();
646
+ if (triggerKick(kickNextLeft ? "left" : "right")) kickNextLeft = !kickNextLeft;
647
+ return;
648
+ }
649
  const act = KEYMAP[e.code];
650
  if (!act) return;
651
  e.preventDefault();
 
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");
703
+ padPrev.rb = rb;
704
+ const lb = !!gp.buttons[4]?.pressed;
705
+ if (lb && !padPrev.lb) triggerKick("left", "pad");
706
+ padPrev.lb = lb;
707
+
708
  const dpadDown = !!gp.buttons[13]?.pressed;
709
  if (dpadDown && !padPrev.dpadDown) {
710
  const sitting = mode === "sitstand" && sitFlag === 1;
 
735
  const modeLabel = document.getElementById("mode-label");
736
 
737
  function setMode(next) {
738
+ // No policy switching mid-roll or mid-kick: both end on their own and
739
+ // return to walk - switching now would floor the duck.
740
+ if ((mode === "roll" && rollRun) || (isKick() && kickRun)) return;
741
  clearModeTimers();
742
  quack();
743
  rollRun = null;
 
787
  rollRun = { steps: 0, tipped: false };
788
  syncButtons();
789
  }
790
+
791
+ // One blind kick (the duck can't see any ball - it's a scripted boot),
792
+ // left or right leg. Same launch constraints as the roll. Returns whether
793
+ // the kick actually launched so the keyboard's foot alternation only
794
+ // advances on real kicks.
795
+ function triggerKick(foot, source = "kb") {
796
+ if (mode !== "walk" || standTimer) return false;
797
+ clearModeTimers();
798
+ kickSource = source;
799
+ quack();
800
+ mode = foot === "left" ? "kickL" : "kickR";
801
+ sitFlag = 0;
802
+ kickRun = { steps: 0 };
803
+ syncButtons();
804
+ return true;
805
+ }
806
  // Slot-machine roll: the old text slides up and out while the new one
807
  // rises in from below the (overflow-hidden) label box.
808
  function setModeLabel(text) {
 
822
 
823
  function syncButtons() {
824
  const sitting = mode === "sitstand" && sitFlag === 1;
825
+ setModeLabel(mode === "roll" ? "Roll" : isKick() ? "Kick" : sitting ? "Sit" : "Run");
826
  }
827
 
828
  // ── Colour swatches: re-skin the rig live, with a quack ─────────────────