tfrere HF Staff Cursor commited on
Commit
c4184fb
Β·
1 Parent(s): accdb25

feat: simpler controls, post-kick grace, arena walls, true soccer texture

Browse files

- Remap controls: left stick / A-D turn (strafe removed), Q/E kick
left/right, R rolls, Space resets; HUD, welcome modal and README
updated to match.
- Hold commands at zero for 20 control steps after a kick ends so the
duck finishes the strike cleanly instead of instantly sprinting.
- Rebuild the ball texture per pixel as a spherical Voronoi over the 32
truncated-icosahedron panel centers: large flat-edged black pentagons
with thin seams and no pole special case.
- Enclose the play area in a 3x3 m arena of static box walls rendered
with the floor's grid shader; the respawn watchdog now only fires if
the ball somehow escapes the arena.

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

Files changed (3) hide show
  1. README.md +7 -9
  2. index.html +8 -9
  3. rl.js +229 -80
README.md CHANGED
@@ -28,26 +28,24 @@ and [apirrone/mjlab_microduck](https://github.com/apirrone/mjlab_microduck).
28
 
29
  ## Controls
30
 
31
- - Arrow up / down: forward / back
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
  - B: pop / respawn a kickable ball in front of the duck
37
  - C: toggle the chase camera (on by default; dragging detaches it)
38
- - R: reset
39
  - Drag to orbit, scroll to zoom
40
  - Colour dots: repaint the duck (it quacks)
41
 
42
  The ball is local-only: it lives in your tab's physics and is not shared
43
- with the multiplayer ghosts.
 
44
 
45
  ### Gamepad
46
 
47
  Plug in a controller and the same mapping as the real robot runtime applies:
48
 
49
- - Left stick: forward / back + strafe
50
- - Right stick: turn
51
  - X: roll
52
  - Y: pop / respawn the ball
53
  - RB / LB: right / left kick
 
28
 
29
  ## Controls
30
 
31
+ - Arrows or WASD (ZQSD): forward / back + turn
32
+ - Q / E (A / E on AZERTY): kick left / right
33
+ - R: roll (one barrel roll, then back to running)
 
 
34
  - B: pop / respawn a kickable ball in front of the duck
35
  - C: toggle the chase camera (on by default; dragging detaches it)
36
+ - Space: reset
37
  - Drag to orbit, scroll to zoom
38
  - Colour dots: repaint the duck (it quacks)
39
 
40
  The ball is local-only: it lives in your tab's physics and is not shared
41
+ with the multiplayer ghosts. A square arena (3 x 3 m) fences the play
42
+ area so neither the ball nor the duck can wander off.
43
 
44
  ### Gamepad
45
 
46
  Plug in a controller and the same mapping as the real robot runtime applies:
47
 
48
+ - Left stick: forward / back + turn
 
49
  - X: roll
50
  - Y: pop / respawn the ball
51
  - RB / LB: right / left kick
index.html CHANGED
@@ -389,13 +389,12 @@
389
  </div>
390
  </div>
391
  <span class="hint"><span class="key" id="key-fwd">&uarr;</span><span class="key" id="key-back">&darr;</span> move</span> &nbsp;
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-ball">B</span> ball</span> &nbsp;
397
  <span class="hint"><span class="key" id="key-cam">C</span> camera</span> &nbsp;
398
- <span class="hint"><span class="key" id="key-reset">R</span> reset</span> &nbsp;
399
  drag to orbit &middot; scroll to zoom<br />
400
  <span id="pad-hint">gamepad:
401
  <span class="hint"><span class="key" id="key-pad-x">X</span> roll</span> &nbsp;
@@ -422,9 +421,9 @@
422
  at 50&nbsp;Hz. No server, no pre-baked animation.
423
  </p>
424
  <ul>
425
- <li><b>Move</b><span>Arrows or ZQSD to run and turn, A / E to strafe, drag to orbit.</span></li>
426
- <li><b>Roll</b><span>Space triggers a barrel roll, then it gets back up.</span></li>
427
- <li><b>Kick</b><span>F throws a kick, alternating feet (RB / LB on the pad).</span></li>
428
  <li><b>Ball</b><span>B pops a ball to kick around (Y on the pad).</span></li>
429
  <li><b>Camera</b><span>Follows from behind; drag to break free, C re-attaches.</span></li>
430
  <li><b>Gamepad</b><span>Plug one in - same mapping as the real robot.</span></li>
@@ -476,7 +475,7 @@
476
  appended: a plain src attribute can 401 when the browser blocks the
477
  *.static.hf.space auth cookie inside the hub iframe. -->
478
  <script type="module">
479
- const V = "28"; // bump on rl.js changes to defeat heuristic caching
480
  const sign = new URLSearchParams(location.search).get("__sign");
481
  const suffix = sign ? `&__sign=${encodeURIComponent(sign)}` : "";
482
  import(`./rl.js?v=${V}${suffix}`);
 
389
  </div>
390
  </div>
391
  <span class="hint"><span class="key" id="key-fwd">&uarr;</span><span class="key" id="key-back">&darr;</span> move</span> &nbsp;
392
+ <span class="hint"><span class="key" id="key-turnl">&larr;</span><span class="key" id="key-turnr">&rarr;</span> turn</span><br />
393
+ <span class="hint"><span class="key" id="key-roll">R</span> roll</span> &nbsp;
394
+ <span class="hint"><span class="key" id="key-kickl">A</span><span class="key" id="key-kickr">E</span> kick</span> &nbsp;
 
395
  <span class="hint"><span class="key" id="key-ball">B</span> ball</span> &nbsp;
396
  <span class="hint"><span class="key" id="key-cam">C</span> camera</span> &nbsp;
397
+ <span class="hint"><span class="key" id="key-reset">Space</span> reset</span> &nbsp;
398
  drag to orbit &middot; scroll to zoom<br />
399
  <span id="pad-hint">gamepad:
400
  <span class="hint"><span class="key" id="key-pad-x">X</span> roll</span> &nbsp;
 
421
  at 50&nbsp;Hz. No server, no pre-baked animation.
422
  </p>
423
  <ul>
424
+ <li><b>Move</b><span>Arrows or ZQSD to run and turn, drag to orbit.</span></li>
425
+ <li><b>Roll</b><span>R triggers a barrel roll, then it gets back up.</span></li>
426
+ <li><b>Kick</b><span>A / E kick left / right (LB / RB on the pad).</span></li>
427
  <li><b>Ball</b><span>B pops a ball to kick around (Y on the pad).</span></li>
428
  <li><b>Camera</b><span>Follows from behind; drag to break free, C re-attaches.</span></li>
429
  <li><b>Gamepad</b><span>Plug one in - same mapping as the real robot.</span></li>
 
475
  appended: a plain src attribute can 401 when the browser blocks the
476
  *.static.hf.space auth cookie inside the hub iframe. -->
477
  <script type="module">
478
+ const V = "29"; // bump on rl.js changes to defeat heuristic caching
479
  const sign = new URLSearchParams(location.search).get("__sign");
480
  const suffix = sign ? `&__sign=${encodeURIComponent(sign)}` : "";
481
  import(`./rl.js?v=${V}${suffix}`);
rl.js CHANGED
@@ -77,14 +77,18 @@ const DECIMATION = 4;
77
  const CTRL_DT = TIMESTEP * DECIMATION; // 50 Hz
78
 
79
  // Velocity command limits, same as infer_policy.py's keyboard mapping.
80
- const VEL_FWD = 0.25, VEL_BACK = -0.2, VEL_LAT = 0.2, VEL_ANG = 1.0;
 
81
 
82
- // Kickable ball: radius, parking spot (far away = hidden by default) and
83
- // the distance past which the respawn watchdog brings it back (the grid
84
- // fades out at 3 m, so a ball beyond that is invisible anyway).
85
  const BALL_RADIUS = 0.05;
86
  const BALL_PARK_POS = "50 0 0.05";
87
- const BALL_MAX_DIST = 3.0;
 
 
 
 
 
88
 
89
  const mount = document.getElementById("scene");
90
  const loadingEl = document.getElementById("loading");
@@ -144,6 +148,21 @@ async function buildPhysicsXml() {
144
  doc.querySelector("worldbody").appendChild(
145
  el("geom", { name: "floor", type: "plane", size: "0 0 0.05", pos: "0 0 0" }),
146
  );
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
147
  // Kickable ball: a light free sphere (beach-ball feel). MuJoCo has no
148
  // restitution parameter - the bounce comes from solref damping < 1, and
149
  // the rolling-friction term makes it come to rest. Default contype /
@@ -256,7 +275,7 @@ let ballActive = false;
256
  // hands back to walk on its own - steering would only knock the roll over.
257
  const ZERO_CMD = new Float32Array(3);
258
  function effectiveCmd() {
259
- if (mode === "roll" || isKick()) return ZERO_CMD;
260
  return padActive ? padCmd : velCmd;
261
  }
262
  // One-shot roll tracking: trigger time + whether the trunk actually
@@ -268,7 +287,12 @@ let rollSource = "kb"; // which hint lights up: keyboard Space or pad X
268
  // straight back to walking. lastAction stays continuous across both swaps.
269
  let kickRun = null;
270
  let kickSource = "kb";
271
- const KICK_STEPS = 25;
 
 
 
 
 
272
 
273
  // Pending mode-transition timers (sit hand-over, stand-up hand-back).
274
  // Every transition entry point clears them: a stale timer firing after
@@ -288,6 +312,7 @@ function resetSim() {
288
  clearModeTimers();
289
  rollRun = null;
290
  kickRun = null;
 
291
  mode = "walk";
292
  mujoco.mj_resetDataKeyframe(model, data, standKeyId);
293
  mujoco.mj_forward(model, data);
@@ -315,8 +340,12 @@ function spawnBall() {
315
  );
316
  const heading = yaw + (Math.random() - 0.5) * 0.7;
317
  const dist = 0.35 + (Math.random() - 0.5) * 0.1;
318
- qpos[ballQposAdr] = qpos[0] + Math.cos(heading) * dist;
319
- qpos[ballQposAdr + 1] = qpos[1] + Math.sin(heading) * dist;
 
 
 
 
320
  qpos[ballQposAdr + 2] = BALL_RADIUS + 0.02;
321
  qpos[ballQposAdr + 3] = 1;
322
  qpos[ballQposAdr + 4] = 0;
@@ -389,14 +418,17 @@ async function controlStep() {
389
  fallenSince = null;
390
  }
391
 
392
- // Ball respawn watchdog: a kicked ball that wandered past the grid's
393
- // fade distance is invisible and unreachable - bring it back near
394
- // the duck. Re-read qpos: resetSim above may have re-parked the ball.
 
 
395
  if (ballActive) {
396
  const q = data.qpos;
397
- const dx = q[ballQposAdr] - q[0];
398
- const dy = q[ballQposAdr + 1] - q[1];
399
- if (dx * dx + dy * dy > BALL_MAX_DIST * BALL_MAX_DIST) spawnBall();
 
400
  }
401
 
402
  // One-shot roll, step-counted like the robot runtime (a single roll is
@@ -407,11 +439,14 @@ async function controlStep() {
407
  // One-shot kick: fixed 0.5 s window like the robot runtime, then straight
408
  // back to walking. lastAction is NOT zeroed on either swap - the runtime
409
  // keeps one continuous action history across policy switches.
 
 
410
  if (isKick() && kickRun) {
411
  kickRun.steps++;
412
  if (kickRun.steps >= KICK_STEPS) {
413
  kickRun = null;
414
  mode = "walk";
 
415
  if (uiReady) syncButtons();
416
  }
417
  }
@@ -533,56 +568,167 @@ function makeInfiniteGrid() {
533
  const grid = makeInfiniteGrid();
534
  scene.add(grid);
535
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
536
  const rig = await rigPromise;
537
  scene.add(rig.placer);
538
  const trunkGroup = rig.bodies.get("trunk_base");
539
 
540
- // Soccer-ball look on an equirectangular CanvasTexture: off-white base
541
- // (pure white would blow out under ACES tone mapping) with black
542
- // pentagons - a cap at each pole plus two staggered rings - so the
543
- // rolling actually reads visually.
 
 
 
 
 
544
  function makeSoccerBallTexture() {
 
545
  const c = document.createElement("canvas");
546
- c.width = 1024;
547
- c.height = 512;
548
  const ctx = c.getContext("2d");
549
- ctx.fillStyle = "#e9e7e0";
550
- ctx.fillRect(0, 0, c.width, c.height);
551
- ctx.fillStyle = "#17171d";
552
- // Pole "pentagons": a full-width strip at each canvas edge maps to a
553
- // clean round patch at the pole, sidestepping the equirect pinch that
554
- // would smear an actual drawn polygon there.
555
- const capH = c.height * 0.075;
556
- ctx.fillRect(0, 0, c.width, capH);
557
- ctx.fillRect(0, c.height - capH, c.width, capH);
558
- // Pentagon at (lon, lat), angular radius r (all degrees). Horizontal
559
- // extent is stretched by 1/cos(lat) to counter the equirect longitude
560
- // compression away from the equator; drawn three times so panels
561
- // crossing the +-180 deg seam wrap around cleanly.
562
- const pent = (lonDeg, latDeg, rDeg, rot) => {
563
- const x = ((lonDeg + 180) / 360) * c.width;
564
- const y = ((90 - latDeg) / 180) * c.height;
565
- const ry = (rDeg / 180) * c.height;
566
- const rx = ry / Math.cos((latDeg * Math.PI) / 180);
567
- for (const dx of [-c.width, 0, c.width]) {
568
- ctx.beginPath();
569
- for (let i = 0; i < 5; i++) {
570
- const a = rot + (i * 2 * Math.PI) / 5;
571
- const px = x + dx + Math.cos(a) * rx;
572
- const py = y + Math.sin(a) * ry;
573
- if (i === 0) ctx.moveTo(px, py); else ctx.lineTo(px, py);
574
- }
575
- ctx.closePath();
576
- ctx.fill();
577
- }
578
  };
579
- // Two staggered rings of five (12 pentagons total with the poles),
580
- // roughly the truncated-icosahedron layout. Point-up above the
581
- // equator, point-down below, like the real panel orientation.
582
  for (let i = 0; i < 5; i++) {
583
- pent(-180 + i * 72, 27, 15, -Math.PI / 2);
584
- pent(-144 + i * 72, -27, 15, Math.PI / 2);
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
585
  }
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
586
  const tex = new THREE.CanvasTexture(c);
587
  tex.colorSpace = THREE.SRGBColorSpace;
588
  // Texel footprints get extremely anamorphic near the UV poles; without
@@ -703,9 +849,11 @@ function syncRig() {
703
  _follow.copy(_target).sub(controls.target).multiplyScalar(0.06);
704
  controls.target.add(_follow);
705
  camera.position.add(_follow);
706
- // Keep the grid plane (and its fade center) under the action.
 
707
  grid.position.set(controls.target.x, 0, controls.target.z);
708
  grid.material.uniforms.uFocus.value.copy(controls.target);
 
709
  }
710
 
711
  // Quack: a quick jaw flap on every mode/colour change. The jaw isn't a
@@ -762,8 +910,8 @@ const boxMove = dotMove.parentElement, boxTurn = dotTurn.parentElement;
762
  const keyEls = {
763
  fwd: el("key-fwd"), back: el("key-back"),
764
  turnl: el("key-turnl"), turnr: el("key-turnr"),
765
- left: el("key-left"), right: el("key-right"),
766
- roll: el("key-roll"), reset: el("key-reset"), kick: el("key-kick"),
767
  ball: el("key-ball"), cam: el("key-cam"),
768
  padX: el("key-pad-x"), padSit: el("key-pad-sit"),
769
  padRun: el("key-pad-run"), padRt: el("key-pad-rt"),
@@ -776,7 +924,7 @@ let ballFlashAt = -Infinity;
776
  let padYFlashAt = -Infinity;
777
 
778
  function renderStats() {
779
- const [vx, vy, wz] = effectiveCmd();
780
  // The active policy lives in the big center label and the twist in the
781
  // mini sticks; up here only the bare telemetry remains.
782
  const peers = ghosts?.peerCount() ?? 0;
@@ -788,19 +936,21 @@ function renderStats() {
788
  // user is actually driving.
789
  const manual = (padActive || held.size > 0) && mode !== "roll";
790
  const yN = vx >= 0 ? vx / VEL_FWD : vx / -VEL_BACK;
791
- dotMove.style.transform = `translate(${(-vy / VEL_LAT) * STICK_R}px, ${-yN * STICK_R}px)`;
 
792
  dotTurn.style.transform = `translate(${(-wz / VEL_ANG) * STICK_R}px, 0px)`;
793
- boxMove.classList.toggle("live", manual && (Math.abs(vx) > 0.01 || Math.abs(vy) > 0.01));
794
  boxTurn.classList.toggle("live", manual && Math.abs(wz) > 0.01);
795
 
796
  // Keycap highlighting: each individual key lights only while its own
797
  // action is active, and only for its own input device.
798
  const sitting = mode === "sitstand" && sitFlag === 1;
799
- for (const k of ["fwd", "back", "turnl", "turnr", "left", "right"]) {
800
  keyEls[k].classList.toggle("lit", held.has(k));
801
  }
802
  keyEls.roll.classList.toggle("lit", mode === "roll" && rollSource === "kb");
803
- keyEls.kick.classList.toggle("lit", isKick() && kickSource === "kb");
 
804
  keyEls.reset.classList.toggle("lit", performance.now() - resetFlashAt < 400);
805
  keyEls.ball.classList.toggle("lit", performance.now() - ballFlashAt < 400);
806
  keyEls.cam.classList.toggle("lit", chaseCam); // steady while chasing
@@ -837,33 +987,28 @@ loop();
837
  // ── Input: hold-to-command keys + HUD buttons ───────────────────────────
838
  function refreshVelCmd() {
839
  velCmd[0] = held.has("fwd") ? VEL_FWD : held.has("back") ? VEL_BACK : 0;
840
- velCmd[1] = held.has("left") ? VEL_LAT : held.has("right") ? -VEL_LAT : 0;
841
  velCmd[2] = held.has("turnl") ? VEL_ANG : held.has("turnr") ? -VEL_ANG : 0;
842
  }
843
 
844
  // e.code is physical position, so one map covers QWERTY and AZERTY:
845
- // WASD/ZQSD run + turn, the physical Q/E row (A/E on AZERTY) strafes.
846
  const KEYMAP = {
847
  ArrowUp: "fwd", KeyW: "fwd",
848
  ArrowDown: "back", KeyS: "back",
849
  ArrowLeft: "turnl", KeyA: "turnl",
850
  ArrowRight: "turnr", KeyD: "turnr",
851
- KeyQ: "left", KeyE: "right",
852
  };
853
 
854
- // Keyboard kicks alternate feet (the pad picks explicitly via RB/LB).
855
- let kickNextLeft = false;
856
  window.addEventListener("keydown", (e) => {
857
  if (e.repeat) return;
858
- if (e.code === "KeyR") { resetSim(); resetFlashAt = performance.now(); return; }
859
  if (e.code === "KeyB") { spawnBall(); ballFlashAt = performance.now(); return; }
860
  if (e.code === "KeyC") { chaseCam = !chaseCam; return; }
861
- if (e.code === "Space") { e.preventDefault(); triggerRoll(); return; }
862
- if (e.code === "KeyF") {
863
- e.preventDefault();
864
- if (triggerKick(kickNextLeft ? "left" : "right")) kickNextLeft = !kickNextLeft;
865
- return;
866
- }
867
  const act = KEYMAP[e.code];
868
  if (!act) return;
869
  e.preventDefault();
@@ -893,17 +1038,19 @@ pollPad = function pollGamepad() {
893
  if (padActive) { padActive = false; padCmd.fill(0); padJaw = 0; }
894
  return;
895
  }
896
- const lx = dz(gp.axes[0] ?? 0), ly = dz(gp.axes[1] ?? 0), rx = dz(gp.axes[2] ?? 0);
 
 
897
  const up = -ly; // browser sticks report up as -1
898
  const target = [
899
  up >= 0 ? up * VEL_FWD : up * -VEL_BACK,
900
- -lx * VEL_LAT,
901
- -rx * VEL_ANG,
902
  ];
903
  for (let i = 0; i < 3; i++) padCmd[i] += PAD_ALPHA * (target[i] - padCmd[i]);
904
  // Sticks grab command authority on first input, release when back at rest
905
  // (then the keyboard takes over again).
906
- const stickInput = lx !== 0 || ly !== 0 || rx !== 0;
907
  if (stickInput) padActive = true;
908
  else if (padActive && Math.abs(padCmd[0]) + Math.abs(padCmd[1]) + Math.abs(padCmd[2]) < 0.01) {
909
  padActive = false;
@@ -1085,7 +1232,9 @@ window.rl = {
1085
  get sitFlag() { return sitFlag; },
1086
  buildObs, cmd,
1087
  velCmd, lastAction, resetSim,
1088
- spawnBall,
 
 
1089
  get ballActive() { return ballActive; },
1090
  get ballQposAdr() { return ballQposAdr; },
1091
  get chaseCam() { return chaseCam; },
 
77
  const CTRL_DT = TIMESTEP * DECIMATION; // 50 Hz
78
 
79
  // Velocity command limits, same as infer_policy.py's keyboard mapping.
80
+ // No strafe input anymore: the lateral cmd slot stays zeroed for the obs.
81
+ const VEL_FWD = 0.25, VEL_BACK = -0.2, VEL_ANG = 1.0;
82
 
83
+ // Kickable ball: radius and parking spot (far away = hidden by default).
 
 
84
  const BALL_RADIUS = 0.05;
85
  const BALL_PARK_POS = "50 0 0.05";
86
+
87
+ // Square arena boxing the play area: static walls at +-ARENA_HALF keep
88
+ // the ball (and the duck) inside. Tall enough that neither steps over.
89
+ const ARENA_HALF = 1.5; // inner half-size, m
90
+ const ARENA_WALL_H = 0.25;
91
+ const ARENA_WALL_T = 0.05;
92
 
93
  const mount = document.getElementById("scene");
94
  const loadingEl = document.getElementById("loading");
 
148
  doc.querySelector("worldbody").appendChild(
149
  el("geom", { name: "floor", type: "plane", size: "0 0 0.05", pos: "0 0 0" }),
150
  );
151
+ // Arena walls: four static boxes (no joints, so no qpos/keyframe
152
+ // impact); default contype/conaffinity collides with ball and duck.
153
+ const ht = ARENA_WALL_T / 2, hh = ARENA_WALL_H / 2;
154
+ const off = ARENA_HALF + ht, span = ARENA_HALF + ARENA_WALL_T;
155
+ const walls = [
156
+ { name: "wall_px", pos: `${off} 0 ${hh}`, size: `${ht} ${span} ${hh}` },
157
+ { name: "wall_nx", pos: `${-off} 0 ${hh}`, size: `${ht} ${span} ${hh}` },
158
+ { name: "wall_py", pos: `0 ${off} ${hh}`, size: `${span} ${ht} ${hh}` },
159
+ { name: "wall_ny", pos: `0 ${-off} ${hh}`, size: `${span} ${ht} ${hh}` },
160
+ ];
161
+ for (const w of walls) {
162
+ doc.querySelector("worldbody").appendChild(
163
+ el("geom", { name: w.name, type: "box", pos: w.pos, size: w.size }),
164
+ );
165
+ }
166
  // Kickable ball: a light free sphere (beach-ball feel). MuJoCo has no
167
  // restitution parameter - the bounce comes from solref damping < 1, and
168
  // the rolling-friction term makes it come to rest. Default contype /
 
275
  // hands back to walk on its own - steering would only knock the roll over.
276
  const ZERO_CMD = new Float32Array(3);
277
  function effectiveCmd() {
278
+ if (mode === "roll" || isKick() || postKickLock > 0) return ZERO_CMD;
279
  return padActive ? padCmd : velCmd;
280
  }
281
  // One-shot roll tracking: trigger time + whether the trunk actually
 
287
  // straight back to walking. lastAction stays continuous across both swaps.
288
  let kickRun = null;
289
  let kickSource = "kb";
290
+ let KICK_STEPS = 25;
291
+ // Post-kick grace: keep commands zeroed for a beat after the kick window
292
+ // hands back to walk, so the duck finishes the strike cleanly instead of
293
+ // instantly sprinting off. Step-counted like everything else.
294
+ const POST_KICK_LOCK_STEPS = 20; // 0.4 s at 50 Hz
295
+ let postKickLock = 0;
296
 
297
  // Pending mode-transition timers (sit hand-over, stand-up hand-back).
298
  // Every transition entry point clears them: a stale timer firing after
 
312
  clearModeTimers();
313
  rollRun = null;
314
  kickRun = null;
315
+ postKickLock = 0;
316
  mode = "walk";
317
  mujoco.mj_resetDataKeyframe(model, data, standKeyId);
318
  mujoco.mj_forward(model, data);
 
340
  );
341
  const heading = yaw + (Math.random() - 0.5) * 0.7;
342
  const dist = 0.35 + (Math.random() - 0.5) * 0.1;
343
+ // Clamp inside the arena: a duck standing against a wall must not pop
344
+ // the ball into (or beyond) it.
345
+ const lim = ARENA_HALF - BALL_RADIUS - 0.05;
346
+ const clamp = (v) => Math.min(lim, Math.max(-lim, v));
347
+ qpos[ballQposAdr] = clamp(qpos[0] + Math.cos(heading) * dist);
348
+ qpos[ballQposAdr + 1] = clamp(qpos[1] + Math.sin(heading) * dist);
349
  qpos[ballQposAdr + 2] = BALL_RADIUS + 0.02;
350
  qpos[ballQposAdr + 3] = 1;
351
  qpos[ballQposAdr + 4] = 0;
 
418
  fallenSince = null;
419
  }
420
 
421
+ // Ball respawn watchdog: with the arena walls the ball can no longer
422
+ // legitimately leave, so this is a safety net for solver tunnelling -
423
+ // outside the arena bounds means "escaped through a glitch", bring it
424
+ // back near the duck. Re-read qpos: resetSim above may have re-parked
425
+ // the ball.
426
  if (ballActive) {
427
  const q = data.qpos;
428
+ const escaped =
429
+ Math.abs(q[ballQposAdr]) > ARENA_HALF + 0.1 ||
430
+ Math.abs(q[ballQposAdr + 1]) > ARENA_HALF + 0.1;
431
+ if (escaped) spawnBall();
432
  }
433
 
434
  // One-shot roll, step-counted like the robot runtime (a single roll is
 
439
  // One-shot kick: fixed 0.5 s window like the robot runtime, then straight
440
  // back to walking. lastAction is NOT zeroed on either swap - the runtime
441
  // keeps one continuous action history across policy switches.
442
+ if (postKickLock > 0 && mode === "walk") postKickLock--;
443
+
444
  if (isKick() && kickRun) {
445
  kickRun.steps++;
446
  if (kickRun.steps >= KICK_STEPS) {
447
  kickRun = null;
448
  mode = "walk";
449
+ postKickLock = POST_KICK_LOCK_STEPS;
450
  if (uiReady) syncButtons();
451
  }
452
  }
 
568
  const grid = makeInfiniteGrid();
569
  scene.add(grid);
570
 
571
+ // Arena walls, drawn in the same grid language as the floor: identical
572
+ // cell/section lines from world coordinates, same radial fade around the
573
+ // duck, plus a vertical fade toward the top edge so the walls read as a
574
+ // light enclosure instead of solid slabs.
575
+ function makeWallGridMaterial(alongX) {
576
+ return new THREE.ShaderMaterial({
577
+ transparent: true,
578
+ depthWrite: false,
579
+ side: THREE.DoubleSide,
580
+ uniforms: {
581
+ uCell: { value: 0.1 },
582
+ uSection: { value: 0.5 },
583
+ uCellColor: { value: new THREE.Color(0x7d7360) },
584
+ uSectionColor: { value: new THREE.Color(0xffb366) },
585
+ // Gentler radial fade than the floor: the walls sit 1.5+ m from the
586
+ // duck by construction and would vanish with the floor's 3 m fade.
587
+ uFadeDist: { value: 5.0 },
588
+ uFocus: { value: new THREE.Vector3() },
589
+ uWallH: { value: ARENA_WALL_H },
590
+ uAlongX: { value: alongX ? 1.0 : 0.0 },
591
+ },
592
+ vertexShader: /* glsl */ `
593
+ varying vec3 vWorld;
594
+ void main() {
595
+ vec4 w = modelMatrix * vec4(position, 1.0);
596
+ vWorld = w.xyz;
597
+ gl_Position = projectionMatrix * viewMatrix * w;
598
+ }
599
+ `,
600
+ fragmentShader: /* glsl */ `
601
+ varying vec3 vWorld;
602
+ uniform float uCell, uSection, uFadeDist, uWallH, uAlongX;
603
+ uniform vec3 uCellColor, uSectionColor, uFocus;
604
+ float gridLine(vec2 p, float size) {
605
+ vec2 r = p / size;
606
+ vec2 g = abs(fract(r - 0.5) - 0.5) / fwidth(r);
607
+ return 1.0 - min(min(g.x, g.y), 1.0);
608
+ }
609
+ void main() {
610
+ // Wall surface coords: the in-plane horizontal world axis + height.
611
+ float h = mix(vWorld.z, vWorld.x, uAlongX);
612
+ vec2 p = vec2(h, vWorld.y);
613
+ float cell = gridLine(p, uCell);
614
+ float section = gridLine(p, uSection);
615
+ float d = distance(vWorld.xz, uFocus.xz);
616
+ float fade = pow(clamp(1.0 - d / uFadeDist, 0.0, 1.0), 1.6);
617
+ float vert = 1.0 - clamp(vWorld.y / uWallH, 0.0, 1.0);
618
+ vec3 col = mix(uCellColor, uSectionColor, section);
619
+ float alpha = max(section * 0.7, cell * 0.45) * fade * (0.3 + 0.7 * vert);
620
+ if (alpha < 0.004) discard;
621
+ gl_FragColor = vec4(col, alpha);
622
+ }
623
+ `,
624
+ });
625
+ }
626
+ const wallMats = [];
627
+ {
628
+ const wallLen = 2 * (ARENA_HALF + ARENA_WALL_T);
629
+ // (three coords: MJCF x -> x, MJCF y -> -z; walls sit at their inner faces)
630
+ const wallDefs = [
631
+ { x: ARENA_HALF, z: 0, rotY: -Math.PI / 2, alongX: false },
632
+ { x: -ARENA_HALF, z: 0, rotY: Math.PI / 2, alongX: false },
633
+ { x: 0, z: ARENA_HALF, rotY: Math.PI, alongX: true },
634
+ { x: 0, z: -ARENA_HALF, rotY: 0, alongX: true },
635
+ ];
636
+ for (const w of wallDefs) {
637
+ const mat = makeWallGridMaterial(w.alongX);
638
+ wallMats.push(mat);
639
+ const mesh = new THREE.Mesh(new THREE.PlaneGeometry(wallLen, ARENA_WALL_H), mat);
640
+ mesh.position.set(w.x, ARENA_WALL_H / 2, w.z);
641
+ mesh.rotation.y = w.rotY;
642
+ scene.add(mesh);
643
+ }
644
+ }
645
+
646
  const rig = await rigPromise;
647
  scene.add(rig.placer);
648
  const trunkGroup = rig.bodies.get("trunk_base");
649
 
650
+ // Soccer-ball look computed per pixel on the sphere itself, so there is
651
+ // no pole or seam special case by design. The truncated icosahedron is
652
+ // reconstructed as a spherical Voronoi diagram over 32 sites: the 12
653
+ // icosahedron vertices (black pentagon centers, one sitting at each
654
+ // pole) and its 20 face centers (white hexagon centers). A pixel is
655
+ // black when its nearest site is a pentagon center and it sits clear of
656
+ // the cell boundary by a seam margin - which yields big flat-edged black
657
+ // pentagons separated from the white hexagons by thin seams, corners
658
+ // almost touching, exactly like the real panel layout.
659
  function makeSoccerBallTexture() {
660
+ const W = 1024, H = 512;
661
  const c = document.createElement("canvas");
662
+ c.width = W;
663
+ c.height = H;
664
  const ctx = c.getContext("2d");
665
+ // 12 icosahedron vertices: 2 poles + two staggered rings of 5 at
666
+ // latitude +-atan(1/2) (~26.57 deg) - the pentagon centers.
667
+ const sites = [];
668
+ const addSite = (v, isPent) => {
669
+ const n = Math.hypot(v[0], v[1], v[2]);
670
+ sites.push({ x: v[0] / n, y: v[1] / n, z: v[2] / n, pent: isPent });
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
671
  };
672
+ const verts = [[0, 0, 1], [0, 0, -1]];
673
+ const latR = Math.atan(0.5), cr = Math.cos(latR), sr = Math.sin(latR);
 
674
  for (let i = 0; i < 5; i++) {
675
+ const a = (i * 72 * Math.PI) / 180;
676
+ const b = ((i * 72 + 36) * Math.PI) / 180;
677
+ verts.push([cr * Math.cos(a), cr * Math.sin(a), sr]);
678
+ verts.push([cr * Math.cos(b), cr * Math.sin(b), -sr]);
679
+ }
680
+ for (const v of verts) addSite(v, true);
681
+ // 20 face centers (hexagon centers): normalized centroids of every
682
+ // mutually-adjacent vertex triple (adjacent pairs have dot = 1/sqrt(5)).
683
+ const adj = (a, b) => a[0] * b[0] + a[1] * b[1] + a[2] * b[2] > 0.3;
684
+ for (let i = 0; i < 12; i++) {
685
+ for (let j = i + 1; j < 12; j++) {
686
+ if (!adj(verts[i], verts[j])) continue;
687
+ for (let k = j + 1; k < 12; k++) {
688
+ if (adj(verts[i], verts[k]) && adj(verts[j], verts[k])) {
689
+ addSite([
690
+ verts[i][0] + verts[j][0] + verts[k][0],
691
+ verts[i][1] + verts[j][1] + verts[k][1],
692
+ verts[i][2] + verts[j][2] + verts[k][2],
693
+ ], false);
694
+ }
695
+ }
696
+ }
697
  }
698
+ // Seam half-width and anti-alias band, in radians of arc.
699
+ const SEAM = (1.6 * Math.PI) / 180;
700
+ const AA = (0.35 * Math.PI) / 180;
701
+ const BG = [233, 231, 224], INK = [23, 23, 29], STITCH = [200, 197, 188];
702
+ const img = ctx.createImageData(W, H);
703
+ const px = img.data;
704
+ for (let row = 0; row < H; row++) {
705
+ const lat = Math.PI / 2 - ((row + 0.5) / H) * Math.PI;
706
+ const cl = Math.cos(lat), sl = Math.sin(lat);
707
+ for (let col = 0; col < W; col++) {
708
+ const lon = ((col + 0.5) / W) * 2 * Math.PI - Math.PI;
709
+ const dx = cl * Math.cos(lon), dy = cl * Math.sin(lon), dz = sl;
710
+ let best = -2, second = -2, bestPent = false;
711
+ for (const s of sites) {
712
+ const d = dx * s.x + dy * s.y + dz * s.z;
713
+ if (d > best) { second = best; best = d; bestPent = s.pent; }
714
+ else if (d > second) second = d;
715
+ }
716
+ // Signed distance to the Voronoi cell boundary along the geodesic.
717
+ const halfGap = (Math.acos(Math.min(1, second)) - Math.acos(Math.min(1, best))) / 2;
718
+ // Black panel: inside a pentagon cell, clear of the seam margin.
719
+ const black = bestPent ? Math.min(1, Math.max(0, (halfGap - SEAM) / AA)) : 0;
720
+ // Subtle stitch line on every remaining cell boundary so the white
721
+ // hexagons read as panels too.
722
+ const stitch = Math.min(1, Math.max(0, 1 - halfGap / (SEAM * 0.6))) * (1 - black);
723
+ const o = (row * W + col) * 4;
724
+ for (let ch = 0; ch < 3; ch++) {
725
+ const base = BG[ch] + (STITCH[ch] - BG[ch]) * stitch;
726
+ px[o + ch] = base + (INK[ch] - base) * black;
727
+ }
728
+ px[o + 3] = 255;
729
+ }
730
+ }
731
+ ctx.putImageData(img, 0, 0);
732
  const tex = new THREE.CanvasTexture(c);
733
  tex.colorSpace = THREE.SRGBColorSpace;
734
  // Texel footprints get extremely anamorphic near the UV poles; without
 
849
  _follow.copy(_target).sub(controls.target).multiplyScalar(0.06);
850
  controls.target.add(_follow);
851
  camera.position.add(_follow);
852
+ // Keep the grid plane (and its fade center) under the action; the wall
853
+ // grids share the same radial fade focus.
854
  grid.position.set(controls.target.x, 0, controls.target.z);
855
  grid.material.uniforms.uFocus.value.copy(controls.target);
856
+ for (const m of wallMats) m.uniforms.uFocus.value.copy(controls.target);
857
  }
858
 
859
  // Quack: a quick jaw flap on every mode/colour change. The jaw isn't a
 
910
  const keyEls = {
911
  fwd: el("key-fwd"), back: el("key-back"),
912
  turnl: el("key-turnl"), turnr: el("key-turnr"),
913
+ roll: el("key-roll"), reset: el("key-reset"),
914
+ kickl: el("key-kickl"), kickr: el("key-kickr"),
915
  ball: el("key-ball"), cam: el("key-cam"),
916
  padX: el("key-pad-x"), padSit: el("key-pad-sit"),
917
  padRun: el("key-pad-run"), padRt: el("key-pad-rt"),
 
924
  let padYFlashAt = -Infinity;
925
 
926
  function renderStats() {
927
+ const [vx, , wz] = effectiveCmd();
928
  // The active policy lives in the big center label and the twist in the
929
  // mini sticks; up here only the bare telemetry remains.
930
  const peers = ghosts?.peerCount() ?? 0;
 
936
  // user is actually driving.
937
  const manual = (padActive || held.size > 0) && mode !== "roll";
938
  const yN = vx >= 0 ? vx / VEL_FWD : vx / -VEL_BACK;
939
+ // Move stick is vertical-only now that strafe is gone.
940
+ dotMove.style.transform = `translate(0px, ${-yN * STICK_R}px)`;
941
  dotTurn.style.transform = `translate(${(-wz / VEL_ANG) * STICK_R}px, 0px)`;
942
+ boxMove.classList.toggle("live", manual && Math.abs(vx) > 0.01);
943
  boxTurn.classList.toggle("live", manual && Math.abs(wz) > 0.01);
944
 
945
  // Keycap highlighting: each individual key lights only while its own
946
  // action is active, and only for its own input device.
947
  const sitting = mode === "sitstand" && sitFlag === 1;
948
+ for (const k of ["fwd", "back", "turnl", "turnr"]) {
949
  keyEls[k].classList.toggle("lit", held.has(k));
950
  }
951
  keyEls.roll.classList.toggle("lit", mode === "roll" && rollSource === "kb");
952
+ keyEls.kickl.classList.toggle("lit", mode === "kickL" && kickSource === "kb");
953
+ keyEls.kickr.classList.toggle("lit", mode === "kickR" && kickSource === "kb");
954
  keyEls.reset.classList.toggle("lit", performance.now() - resetFlashAt < 400);
955
  keyEls.ball.classList.toggle("lit", performance.now() - ballFlashAt < 400);
956
  keyEls.cam.classList.toggle("lit", chaseCam); // steady while chasing
 
987
  // ── Input: hold-to-command keys + HUD buttons ───────────────────────────
988
  function refreshVelCmd() {
989
  velCmd[0] = held.has("fwd") ? VEL_FWD : held.has("back") ? VEL_BACK : 0;
 
990
  velCmd[2] = held.has("turnl") ? VEL_ANG : held.has("turnr") ? -VEL_ANG : 0;
991
  }
992
 
993
  // e.code is physical position, so one map covers QWERTY and AZERTY:
994
+ // arrows / WASD (ZQSD) run + turn. No strafe.
995
  const KEYMAP = {
996
  ArrowUp: "fwd", KeyW: "fwd",
997
  ArrowDown: "back", KeyS: "back",
998
  ArrowLeft: "turnl", KeyA: "turnl",
999
  ArrowRight: "turnr", KeyD: "turnr",
 
1000
  };
1001
 
 
 
1002
  window.addEventListener("keydown", (e) => {
1003
  if (e.repeat) return;
1004
+ if (e.code === "Space") { e.preventDefault(); resetSim(); resetFlashAt = performance.now(); return; }
1005
  if (e.code === "KeyB") { spawnBall(); ballFlashAt = performance.now(); return; }
1006
  if (e.code === "KeyC") { chaseCam = !chaseCam; return; }
1007
+ if (e.code === "KeyR") { triggerRoll(); return; }
1008
+ // Physical Q/E (A/E on AZERTY): explicit left / right kick, mirroring
1009
+ // the pad's LB / RB.
1010
+ if (e.code === "KeyQ") { triggerKick("left"); return; }
1011
+ if (e.code === "KeyE") { triggerKick("right"); return; }
 
1012
  const act = KEYMAP[e.code];
1013
  if (!act) return;
1014
  e.preventDefault();
 
1038
  if (padActive) { padActive = false; padCmd.fill(0); padJaw = 0; }
1039
  return;
1040
  }
1041
+ // Left stick only: vertical = forward/back, horizontal = turn.
1042
+ // (No strafe; the right stick no longer drives movement.)
1043
+ const lx = dz(gp.axes[0] ?? 0), ly = dz(gp.axes[1] ?? 0);
1044
  const up = -ly; // browser sticks report up as -1
1045
  const target = [
1046
  up >= 0 ? up * VEL_FWD : up * -VEL_BACK,
1047
+ 0,
1048
+ -lx * VEL_ANG,
1049
  ];
1050
  for (let i = 0; i < 3; i++) padCmd[i] += PAD_ALPHA * (target[i] - padCmd[i]);
1051
  // Sticks grab command authority on first input, release when back at rest
1052
  // (then the keyboard takes over again).
1053
+ const stickInput = lx !== 0 || ly !== 0;
1054
  if (stickInput) padActive = true;
1055
  else if (padActive && Math.abs(padCmd[0]) + Math.abs(padCmd[1]) + Math.abs(padCmd[2]) < 0.01) {
1056
  padActive = false;
 
1232
  get sitFlag() { return sitFlag; },
1233
  buildObs, cmd,
1234
  velCmd, lastAction, resetSim,
1235
+ spawnBall, triggerKick, sessions, ort,
1236
+ get kickSteps() { return KICK_STEPS; },
1237
+ set kickSteps(v) { KICK_STEPS = v; },
1238
  get ballActive() { return ballActive; },
1239
  get ballQposAdr() { return ballQposAdr; },
1240
  get chaseCam() { return chaseCam; },