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>
- README.md +7 -9
- index.html +8 -9
- 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 |
-
-
|
| 32 |
-
-
|
| 33 |
-
-
|
| 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 |
-
-
|
| 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 +
|
| 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">↑</span><span class="key" id="key-back">↓</span> move</span>
|
| 392 |
-
<span class="hint"><span class="key" id="key-turnl">←</span><span class="key" id="key-turnr">→</span> turn</span>
|
| 393 |
-
<span class="hint"><span class="key" id="key-
|
| 394 |
-
<span class="hint"><span class="key" id="key-
|
| 395 |
-
<span class="hint"><span class="key" id="key-kick">F</span> kick</span>
|
| 396 |
<span class="hint"><span class="key" id="key-ball">B</span> ball</span>
|
| 397 |
<span class="hint"><span class="key" id="key-cam">C</span> camera</span>
|
| 398 |
-
<span class="hint"><span class="key" id="key-reset">
|
| 399 |
drag to orbit · 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>
|
|
@@ -422,9 +421,9 @@
|
|
| 422 |
at 50 Hz. No server, no pre-baked animation.
|
| 423 |
</p>
|
| 424 |
<ul>
|
| 425 |
-
<li><b>Move</b><span>Arrows or ZQSD to run and turn,
|
| 426 |
-
<li><b>Roll</b><span>
|
| 427 |
-
<li><b>Kick</b><span>
|
| 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 = "
|
| 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">↑</span><span class="key" id="key-back">↓</span> move</span>
|
| 392 |
+
<span class="hint"><span class="key" id="key-turnl">←</span><span class="key" id="key-turnr">→</span> turn</span><br />
|
| 393 |
+
<span class="hint"><span class="key" id="key-roll">R</span> roll</span>
|
| 394 |
+
<span class="hint"><span class="key" id="key-kickl">A</span><span class="key" id="key-kickr">E</span> kick</span>
|
|
|
|
| 395 |
<span class="hint"><span class="key" id="key-ball">B</span> ball</span>
|
| 396 |
<span class="hint"><span class="key" id="key-cam">C</span> camera</span>
|
| 397 |
+
<span class="hint"><span class="key" id="key-reset">Space</span> reset</span>
|
| 398 |
drag to orbit · 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>
|
|
|
|
| 421 |
at 50 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 |
-
|
|
|
|
| 81 |
|
| 82 |
-
// Kickable ball: radius
|
| 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 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 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 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 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 |
-
|
| 319 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
| 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:
|
| 393 |
-
//
|
| 394 |
-
// the
|
|
|
|
|
|
|
| 395 |
if (ballActive) {
|
| 396 |
const q = data.qpos;
|
| 397 |
-
const
|
| 398 |
-
|
| 399 |
-
|
|
|
|
| 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
|
| 541 |
-
//
|
| 542 |
-
//
|
| 543 |
-
//
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 544 |
function makeSoccerBallTexture() {
|
|
|
|
| 545 |
const c = document.createElement("canvas");
|
| 546 |
-
c.width =
|
| 547 |
-
c.height =
|
| 548 |
const ctx = c.getContext("2d");
|
| 549 |
-
|
| 550 |
-
|
| 551 |
-
|
| 552 |
-
|
| 553 |
-
|
| 554 |
-
|
| 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 |
-
|
| 580 |
-
|
| 581 |
-
// equator, point-down below, like the real panel orientation.
|
| 582 |
for (let i = 0; i < 5; i++) {
|
| 583 |
-
|
| 584 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 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 |
-
|
| 766 |
-
|
| 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,
|
| 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 |
-
|
|
|
|
| 792 |
dotTurn.style.transform = `translate(${(-wz / VEL_ANG) * STICK_R}px, 0px)`;
|
| 793 |
-
boxMove.classList.toggle("live", manual &&
|
| 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"
|
| 800 |
keyEls[k].classList.toggle("lit", held.has(k));
|
| 801 |
}
|
| 802 |
keyEls.roll.classList.toggle("lit", mode === "roll" && rollSource === "kb");
|
| 803 |
-
keyEls.
|
|
|
|
| 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 |
-
//
|
| 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 === "
|
| 859 |
if (e.code === "KeyB") { spawnBall(); ballFlashAt = performance.now(); return; }
|
| 860 |
if (e.code === "KeyC") { chaseCam = !chaseCam; return; }
|
| 861 |
-
if (e.code === "
|
| 862 |
-
|
| 863 |
-
|
| 864 |
-
|
| 865 |
-
|
| 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 |
-
|
|
|
|
|
|
|
| 897 |
const up = -ly; // browser sticks report up as -1
|
| 898 |
const target = [
|
| 899 |
up >= 0 ? up * VEL_FWD : up * -VEL_BACK,
|
| 900 |
-
|
| 901 |
-
-
|
| 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
|
| 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; },
|