tfrere HF Staff Cursor commited on
Commit
2d0c558
Β·
1 Parent(s): 96f54de

feat: right-stick camera orbit with inertia, subtle Tron glow on grid

Browse files

- Gamepad right stick orbits the camera around the duck through a
smoothed angular velocity (exponential ramp in, ~0.3 s coast out), so
motion eases instead of stopping dead. Vertical is flight-style
inverted. Any deflection detaches the chase cam; R3 toggles it back,
mirroring the C key. New RS/R3 HUD hints, README gamepad section
updated.
- Grid lines (floor and arena walls) get a thicker antialiased core and
a faint wide halo for a restrained Tron look, plus slightly brighter
line color and opacity.

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

Files changed (3) hide show
  1. README.md +2 -0
  2. index.html +3 -1
  3. rl.js +83 -13
README.md CHANGED
@@ -46,6 +46,8 @@ area so neither the ball nor the duck can wander off.
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
 
46
  Plug in a controller and the same mapping as the real robot runtime applies:
47
 
48
  - Left stick: forward / back + turn
49
+ - Right stick: orbit the camera (detaches the chase cam)
50
+ - R3 (right stick click): toggle the chase cam back on
51
  - X: roll
52
  - Y: pop / respawn the ball
53
  - RB / LB: right / left kick
index.html CHANGED
@@ -552,6 +552,8 @@
552
  <span class="hint"><span class="key" id="key-pad-x">X</span> roll</span> &nbsp;
553
  <span class="hint"><span class="key" id="key-pad-y">Y</span> ball</span> &nbsp;
554
  <span class="hint"><span class="key" id="key-pad-rb">RB</span><span class="key" id="key-pad-lb">LB</span> kick</span> &nbsp;
 
 
555
  <span class="hint"><span class="key" id="key-pad-sit">&dArr;</span> sit</span> &nbsp;
556
  <span class="hint"><span class="key" id="key-pad-run">&uArr;</span> run</span> &nbsp;
557
  <span class="hint"><span class="key" id="key-pad-rt">RT</span> quack</span>
@@ -671,7 +673,7 @@
671
  appended: a plain src attribute can 401 when the browser blocks the
672
  *.static.hf.space auth cookie inside the hub iframe. -->
673
  <script type="module">
674
- const V = "29"; // bump on rl.js changes to defeat heuristic caching
675
  const sign = new URLSearchParams(location.search).get("__sign");
676
  const suffix = sign ? `&__sign=${encodeURIComponent(sign)}` : "";
677
  import(`./rl.js?v=${V}${suffix}`);
 
552
  <span class="hint"><span class="key" id="key-pad-x">X</span> roll</span> &nbsp;
553
  <span class="hint"><span class="key" id="key-pad-y">Y</span> ball</span> &nbsp;
554
  <span class="hint"><span class="key" id="key-pad-rb">RB</span><span class="key" id="key-pad-lb">LB</span> kick</span> &nbsp;
555
+ <span class="hint"><span class="key" id="key-pad-rs">RS</span> camera</span> &nbsp;
556
+ <span class="hint"><span class="key" id="key-pad-r3">R3</span> chase</span> &nbsp;
557
  <span class="hint"><span class="key" id="key-pad-sit">&dArr;</span> sit</span> &nbsp;
558
  <span class="hint"><span class="key" id="key-pad-run">&uArr;</span> run</span> &nbsp;
559
  <span class="hint"><span class="key" id="key-pad-rt">RT</span> quack</span>
 
673
  appended: a plain src attribute can 401 when the browser blocks the
674
  *.static.hf.space auth cookie inside the hub iframe. -->
675
  <script type="module">
676
+ const V = "30"; // bump on rl.js changes to defeat heuristic caching
677
  const sign = new URLSearchParams(location.search).get("__sign");
678
  const suffix = sign ? `&__sign=${encodeURIComponent(sign)}` : "";
679
  import(`./rl.js?v=${V}${suffix}`);
rl.js CHANGED
@@ -256,7 +256,10 @@ const velCmd = new Float32Array(3); // twist command, driven by held keys
256
  // the control loop reads it before the input section below has evaluated.
257
  const padCmd = new Float32Array(3);
258
  let padActive = false;
259
- const padPrev = { x: false, y: false, rb: false, lb: false, dpadDown: false, dpadUp: false, rtArmed: true };
 
 
 
260
  // Declared before the control loop starts: buildObs reads it via
261
  // effectiveCmd on the very first control step.
262
  const held = new Set();
@@ -527,7 +530,7 @@ function makeInfiniteGrid() {
527
  uniforms: {
528
  uCell: { value: 0.1 },
529
  uSection: { value: 0.5 },
530
- uCellColor: { value: new THREE.Color(0x7d7360) },
531
  uSectionColor: { value: new THREE.Color(0xffb366) },
532
  uFadeDist: { value: 3.0 },
533
  uFocus: { value: new THREE.Vector3() },
@@ -544,18 +547,24 @@ function makeInfiniteGrid() {
544
  varying vec3 vWorld;
545
  uniform float uCell, uSection, uFadeDist;
546
  uniform vec3 uCellColor, uSectionColor, uFocus;
 
 
 
547
  float gridLine(vec2 p, float size) {
548
  vec2 r = p / size;
549
  vec2 g = abs(fract(r - 0.5) - 0.5) / fwidth(r);
550
- return 1.0 - min(min(g.x, g.y), 1.0);
 
 
 
551
  }
552
  void main() {
553
  float cell = gridLine(vWorld.xz, uCell);
554
  float section = gridLine(vWorld.xz, uSection);
555
  float d = distance(vWorld.xz, uFocus.xz);
556
  float fade = pow(clamp(1.0 - d / uFadeDist, 0.0, 1.0), 1.6);
557
- vec3 col = mix(uCellColor, uSectionColor, section);
558
- float alpha = max(section * 0.45, cell * 0.3) * fade;
559
  if (alpha < 0.004) discard;
560
  gl_FragColor = vec4(col, alpha);
561
  }
@@ -580,7 +589,7 @@ function makeWallGridMaterial(alongX) {
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.
@@ -601,10 +610,14 @@ function makeWallGridMaterial(alongX) {
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.
@@ -615,8 +628,8 @@ function makeWallGridMaterial(alongX) {
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
  }
@@ -916,7 +929,8 @@ const keyEls = {
916
  padX: el("key-pad-x"), padSit: el("key-pad-sit"),
917
  padRun: el("key-pad-run"), padRt: el("key-pad-rt"),
918
  padRb: el("key-pad-rb"), padLb: el("key-pad-lb"),
919
- padY: el("key-pad-y"),
 
920
  };
921
  const STICK_R = 15; // px, max dot travel inside the 46px stick circle
922
  let resetFlashAt = -Infinity;
@@ -961,6 +975,8 @@ function renderStats() {
961
  keyEls.padSit.classList.toggle("lit", sitting);
962
  keyEls.padRun.classList.toggle("lit", padPrev.dpadUp);
963
  keyEls.padRt.classList.toggle("lit", padJaw > 0.3);
 
 
964
  }
965
 
966
  // Real implementation assigned in the gamepad section below; the render
@@ -1024,18 +1040,62 @@ window.addEventListener("keyup", (e) => {
1024
  window.addEventListener("blur", () => { held.clear(); refreshVelCmd(); });
1025
 
1026
  // ── Gamepad: same mapping as the robot runtime (microduck_runtime) ──────
1027
- // Sticks: L vertical = vx (asymmetric fwd/back), L horizontal = strafe,
1028
- // R horizontal = turn, all EMA-smoothed like the runtime's cmd_alpha.
1029
- // X = roll, DpadDown = sit/stand, DpadUp = back to run, RT = mouth.
 
1030
  const PAD_DEADZONE = 0.15;
1031
  const PAD_ALPHA = 0.12;
1032
  const dz = (v) => (Math.abs(v) < PAD_DEADZONE ? 0 : v);
1033
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1034
  pollPad = function pollGamepad() {
 
 
 
 
1035
  const gp = [...(navigator.getGamepads?.() ?? [])].find((p) => p && p.connected);
1036
  document.body.classList.toggle("pad-connected", !!gp);
1037
  if (!gp) {
1038
  if (padActive) { padActive = false; padCmd.fill(0); padJaw = 0; }
 
 
 
1039
  return;
1040
  }
1041
  // Left stick only: vertical = forward/back, horizontal = turn.
@@ -1057,6 +1117,15 @@ pollPad = function pollGamepad() {
1057
  padCmd.fill(0);
1058
  }
1059
 
 
 
 
 
 
 
 
 
 
1060
  // Standard mapping indices: X=2, Y=3, LB=4, RB=5, DpadDown=13, RT=7 (analog).
1061
  const x = !!gp.buttons[2]?.pressed;
1062
  if (x && !padPrev.x) triggerRoll("pad");
@@ -1239,6 +1308,7 @@ window.rl = {
1239
  get ballQposAdr() { return ballQposAdr; },
1240
  get chaseCam() { return chaseCam; },
1241
  set chaseCam(v) { chaseCam = !!v; },
 
1242
  step: async (n = 1) => { for (let i = 0; i < n; i++) await controlStep(); },
1243
  render: () => { syncRig(); renderer.render(scene, camera); },
1244
  // One full render-loop iteration, for tests driving frames manually.
 
256
  // the control loop reads it before the input section below has evaluated.
257
  const padCmd = new Float32Array(3);
258
  let padActive = false;
259
+ const padPrev = { x: false, y: false, rb: false, lb: false, r3: false, dpadDown: false, dpadUp: false, rtArmed: true };
260
+ // Right-stick camera state, read by renderStats before the gamepad
261
+ // section below has evaluated (same reason as padCmd above).
262
+ let padOrbitLive = false; // HUD: RS keycap lit while deflected
263
  // Declared before the control loop starts: buildObs reads it via
264
  // effectiveCmd on the very first control step.
265
  const held = new Set();
 
530
  uniforms: {
531
  uCell: { value: 0.1 },
532
  uSection: { value: 0.5 },
533
+ uCellColor: { value: new THREE.Color(0x8e8371) },
534
  uSectionColor: { value: new THREE.Color(0xffb366) },
535
  uFadeDist: { value: 3.0 },
536
  uFocus: { value: new THREE.Vector3() },
 
547
  varying vec3 vWorld;
548
  uniform float uCell, uSection, uFadeDist;
549
  uniform vec3 uCellColor, uSectionColor, uFocus;
550
+ // Tron-style line: a thicker antialiased core plus a faint, much
551
+ // wider halo added on top (squared falloff keeps it a whisper of a
552
+ // glow rather than a bloom wash).
553
  float gridLine(vec2 p, float size) {
554
  vec2 r = p / size;
555
  vec2 g = abs(fract(r - 0.5) - 0.5) / fwidth(r);
556
+ float d = min(g.x, g.y);
557
+ float core = 1.0 - smoothstep(0.0, 1.8, d);
558
+ float halo = 1.0 - smoothstep(0.0, 7.0, d);
559
+ return core + halo * halo * 0.22;
560
  }
561
  void main() {
562
  float cell = gridLine(vWorld.xz, uCell);
563
  float section = gridLine(vWorld.xz, uSection);
564
  float d = distance(vWorld.xz, uFocus.xz);
565
  float fade = pow(clamp(1.0 - d / uFadeDist, 0.0, 1.0), 1.6);
566
+ vec3 col = mix(uCellColor, uSectionColor, clamp(section, 0.0, 1.0));
567
+ float alpha = min(max(section * 0.6, cell * 0.4) * fade, 1.0);
568
  if (alpha < 0.004) discard;
569
  gl_FragColor = vec4(col, alpha);
570
  }
 
589
  uniforms: {
590
  uCell: { value: 0.1 },
591
  uSection: { value: 0.5 },
592
+ uCellColor: { value: new THREE.Color(0x8e8371) },
593
  uSectionColor: { value: new THREE.Color(0xffb366) },
594
  // Gentler radial fade than the floor: the walls sit 1.5+ m from the
595
  // duck by construction and would vanish with the floor's 3 m fade.
 
610
  varying vec3 vWorld;
611
  uniform float uCell, uSection, uFadeDist, uWallH, uAlongX;
612
  uniform vec3 uCellColor, uSectionColor, uFocus;
613
+ // Same Tron-style core + faint halo as the floor grid.
614
  float gridLine(vec2 p, float size) {
615
  vec2 r = p / size;
616
  vec2 g = abs(fract(r - 0.5) - 0.5) / fwidth(r);
617
+ float d = min(g.x, g.y);
618
+ float core = 1.0 - smoothstep(0.0, 1.8, d);
619
+ float halo = 1.0 - smoothstep(0.0, 7.0, d);
620
+ return core + halo * halo * 0.22;
621
  }
622
  void main() {
623
  // Wall surface coords: the in-plane horizontal world axis + height.
 
628
  float d = distance(vWorld.xz, uFocus.xz);
629
  float fade = pow(clamp(1.0 - d / uFadeDist, 0.0, 1.0), 1.6);
630
  float vert = 1.0 - clamp(vWorld.y / uWallH, 0.0, 1.0);
631
+ vec3 col = mix(uCellColor, uSectionColor, clamp(section, 0.0, 1.0));
632
+ float alpha = min(max(section * 0.9, cell * 0.6) * fade * (0.3 + 0.7 * vert), 1.0);
633
  if (alpha < 0.004) discard;
634
  gl_FragColor = vec4(col, alpha);
635
  }
 
929
  padX: el("key-pad-x"), padSit: el("key-pad-sit"),
930
  padRun: el("key-pad-run"), padRt: el("key-pad-rt"),
931
  padRb: el("key-pad-rb"), padLb: el("key-pad-lb"),
932
+ padY: el("key-pad-y"), padRs: el("key-pad-rs"),
933
+ padR3: el("key-pad-r3"),
934
  };
935
  const STICK_R = 15; // px, max dot travel inside the 46px stick circle
936
  let resetFlashAt = -Infinity;
 
975
  keyEls.padSit.classList.toggle("lit", sitting);
976
  keyEls.padRun.classList.toggle("lit", padPrev.dpadUp);
977
  keyEls.padRt.classList.toggle("lit", padJaw > 0.3);
978
+ keyEls.padRs.classList.toggle("lit", padOrbitLive); // while deflected
979
+ keyEls.padR3.classList.toggle("lit", chaseCam); // steady while chasing
980
  }
981
 
982
  // Real implementation assigned in the gamepad section below; the render
 
1040
  window.addEventListener("blur", () => { held.clear(); refreshVelCmd(); });
1041
 
1042
  // ── Gamepad: same mapping as the robot runtime (microduck_runtime) ──────
1043
+ // Left stick: vertical = vx (asymmetric fwd/back), horizontal = turn,
1044
+ // EMA-smoothed like the runtime's cmd_alpha. Right stick orbits the
1045
+ // camera (detaches the chase cam, R3 re-toggles it). X = roll, Y = ball,
1046
+ // RB/LB = kicks, DpadDown = sit/stand, DpadUp = back to run, RT = mouth.
1047
  const PAD_DEADZONE = 0.15;
1048
  const PAD_ALPHA = 0.12;
1049
  const dz = (v) => (Math.abs(v) < PAD_DEADZONE ? 0 : v);
1050
 
1051
+ // Right-stick camera orbit, in OrbitControls-compatible terms: rebuild
1052
+ // the camera-target offset as a spherical, nudge azimuth/elevation, and
1053
+ // re-place the camera. controls.update() then runs on the result, so the
1054
+ // damping bookkeeping never fights it (same trick as updateChaseCam).
1055
+ // The stick does not move the camera directly: it steers an angular
1056
+ // VELOCITY that eases toward the stick's target rate (frame-rate
1057
+ // independent exponential), so pushing ramps up gently and releasing
1058
+ // coasts to a stop over ~0.3 s instead of freezing on the spot.
1059
+ // Vertical is flight-style inverted by request: stick up orbits the
1060
+ // camera downward, stick down orbits it upward.
1061
+ const PAD_ORBIT_SPEED = 2.4; // rad/s at full deflection
1062
+ const PAD_ORBIT_SMOOTH = 8; // 1/s response rate (~95% in 0.37 s)
1063
+ const padOrbitVel = { az: 0, el: 0 }; // smoothed angular velocity, rad/s
1064
+ const _padSph = new THREE.Spherical();
1065
+ const _padOff = new THREE.Vector3();
1066
+ function padOrbitStep(rx, ry, dt) {
1067
+ padOrbitLive = rx !== 0 || ry !== 0;
1068
+ if (padOrbitLive) chaseCam = false; // detach, same as a mouse grab
1069
+ const k = 1 - Math.exp(-PAD_ORBIT_SMOOTH * dt);
1070
+ padOrbitVel.az += (rx * PAD_ORBIT_SPEED - padOrbitVel.az) * k;
1071
+ // Inverted Y; elevation runs a touch slower, full-rate pitch is twitchy.
1072
+ padOrbitVel.el += (-ry * PAD_ORBIT_SPEED * 0.75 - padOrbitVel.el) * k;
1073
+ if (chaseCam) { padOrbitVel.az = 0; padOrbitVel.el = 0; return; }
1074
+ if (Math.abs(padOrbitVel.az) < 1e-3 && Math.abs(padOrbitVel.el) < 1e-3) return;
1075
+ _padOff.copy(camera.position).sub(controls.target);
1076
+ _padSph.setFromVector3(_padOff);
1077
+ // Stick right sweeps the camera right around the duck.
1078
+ _padSph.theta -= padOrbitVel.az * dt;
1079
+ _padSph.phi += padOrbitVel.el * dt;
1080
+ _padSph.phi = Math.min(controls.maxPolarAngle, Math.max(0.08, _padSph.phi));
1081
+ _padSph.makeSafe();
1082
+ camera.position.setFromSpherical(_padSph).add(controls.target);
1083
+ camera.lookAt(controls.target);
1084
+ }
1085
+
1086
+ let padPollT = performance.now();
1087
  pollPad = function pollGamepad() {
1088
+ const now = performance.now();
1089
+ // Clamped so a background-tab stall can't slingshot the camera.
1090
+ const dt = Math.min((now - padPollT) / 1000, 0.05);
1091
+ padPollT = now;
1092
  const gp = [...(navigator.getGamepads?.() ?? [])].find((p) => p && p.connected);
1093
  document.body.classList.toggle("pad-connected", !!gp);
1094
  if (!gp) {
1095
  if (padActive) { padActive = false; padCmd.fill(0); padJaw = 0; }
1096
+ padOrbitLive = false;
1097
+ padOrbitVel.az = 0;
1098
+ padOrbitVel.el = 0;
1099
  return;
1100
  }
1101
  // Left stick only: vertical = forward/back, horizontal = turn.
 
1117
  padCmd.fill(0);
1118
  }
1119
 
1120
+ // Right stick: camera orbit with velocity smoothing. Runs every frame
1121
+ // (not just while deflected) so a released stick coasts to a stop.
1122
+ padOrbitStep(dz(gp.axes[2] ?? 0), dz(gp.axes[3] ?? 0), dt);
1123
+
1124
+ // R3 (right stick click): toggle the chase cam, gamepad twin of KeyC.
1125
+ const r3Btn = !!gp.buttons[11]?.pressed;
1126
+ if (r3Btn && !padPrev.r3) chaseCam = !chaseCam;
1127
+ padPrev.r3 = r3Btn;
1128
+
1129
  // Standard mapping indices: X=2, Y=3, LB=4, RB=5, DpadDown=13, RT=7 (analog).
1130
  const x = !!gp.buttons[2]?.pressed;
1131
  if (x && !padPrev.x) triggerRoll("pad");
 
1308
  get ballQposAdr() { return ballQposAdr; },
1309
  get chaseCam() { return chaseCam; },
1310
  set chaseCam(v) { chaseCam = !!v; },
1311
+ padOrbitStep,
1312
  step: async (n = 1) => { for (let i = 0; i < n; i++) await controlStep(); },
1313
  render: () => { syncRig(); renderer.render(scene, camera); },
1314
  // One full render-loop iteration, for tests driving frames manually.