masterjedi1337 commited on
Commit
f4264f8
·
1 Parent(s): 9ce679a

feat: click-to-walk waypoint control

Browse files
app/src/game/controls/waypoint.js ADDED
@@ -0,0 +1,153 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ // Waypoint input source (see controller.js for the source interface
2
+ // contract). PLAN.md Project 1 Phase A: click a point on the arena floor,
3
+ // the duck walks there. No new policy - this only turns a click into the
4
+ // same [vx, vy, wz] twist the walking policy already tracks.
5
+ //
6
+ // Click, not drag-follow: the mouse already drives OrbitControls (camera
7
+ // orbit) and the duck/ball grab-drag, so a live cursor-follow twist would
8
+ // fight both. A plain click (small movement, short hold) on empty floor
9
+ // is otherwise unclaimed input, and reads naturally as "go here" - the
10
+ // same gesture Project 5's tile grid uses.
11
+ //
12
+ // Arbitration: registered LAST in game.js's source list, so it is the
13
+ // Controller's fallback exactly like the keyboard source used to be -
14
+ // any keyboard/pad/touch input immediately preempts it (see
15
+ // controller.js's arbitration notes), and getManualOverride() cancels the
16
+ // pending target outright so releasing manual input doesn't snap the duck
17
+ // back onto a stale click.
18
+
19
+ import * as THREE from "three";
20
+ import { ARENA_HALF } from "../constants.js";
21
+
22
+ const WAYPOINT_ARRIVE_RADIUS = 0.12; // m, dead zone - commands zero inside it
23
+ const WAYPOINT_TURN_GAIN = 2.2; // rad/s commanded per rad of heading error, pre-clamp
24
+ const WAYPOINT_SMOOTH_ALPHA = 0.15; // EMA toward the steered command, per render frame
25
+ const WAYPOINT_CLICK_SLOP = 6; // px, pointerdown -> pointerup movement budget for a "click"
26
+ const WAYPOINT_CLICK_MS = 500; // ms, pointerdown -> pointerup duration budget
27
+
28
+ const wrapPi = (a) => Math.atan2(Math.sin(a), Math.cos(a));
29
+ // Three world (x, y, z) -> MJCF ground (x, -z); floor sits at three y = 0.
30
+ const FLOOR_PLANE = new THREE.Plane(new THREE.Vector3(0, 1, 0), 0);
31
+
32
+ export class WaypointSource {
33
+ id = "waypoint";
34
+ connected = true; // a mouse is assumed present, like the keyboard
35
+ command = new Float32Array(3); // [vx, 0, wz], EMA-smoothed toward the target
36
+ axes = { jaw: 0, orbitX: 0, orbitY: 0 }; // drives none of these
37
+ pressed = {};
38
+ onAction = () => {}; // assigned by the Controller at registration
39
+
40
+ #camera;
41
+ #renderer;
42
+ #getVelocityLimits;
43
+ #getDuckPose; // () => [x, y, yaw] MJCF ground pose
44
+ #isSuppressed; // () => true while a click must not arm (grabbing, locked, wrong mode)
45
+ #getManualOverride; // () => true while another source is actively driving
46
+ #target = null; // [x, y] MJCF, or null when idle
47
+ #active = false;
48
+ #downAt = null; // { x, y, t } pointerdown snapshot, for the click-vs-drag check
49
+ #raycaster = new THREE.Raycaster();
50
+ #ndc = new THREE.Vector2();
51
+ #hit = new THREE.Vector3();
52
+ #onDown = null;
53
+ #onUp = null;
54
+
55
+ constructor({ camera, renderer, getVelocityLimits, getDuckPose, isSuppressed, getManualOverride }) {
56
+ this.#camera = camera;
57
+ this.#renderer = renderer;
58
+ this.#getVelocityLimits = getVelocityLimits;
59
+ this.#getDuckPose = getDuckPose;
60
+ this.#isSuppressed = isSuppressed;
61
+ this.#getManualOverride = getManualOverride;
62
+ }
63
+
64
+ init() {
65
+ this.#onDown = (e) => {
66
+ if (e.pointerType !== "mouse" || e.button !== 0) return;
67
+ if (e.target !== this.#renderer.domElement) return;
68
+ if (this.#isSuppressed()) return;
69
+ this.#downAt = { x: e.clientX, y: e.clientY, t: performance.now() };
70
+ };
71
+ this.#onUp = (e) => {
72
+ if (!this.#downAt || e.pointerType !== "mouse" || e.button !== 0) { this.#downAt = null; return; }
73
+ const { x, y, t } = this.#downAt;
74
+ this.#downAt = null;
75
+ if (this.#isSuppressed()) return;
76
+ const moved = Math.hypot(e.clientX - x, e.clientY - y);
77
+ const held = performance.now() - t;
78
+ // A drag (orbit or grab) shows up as either more movement than a
79
+ // click budget allows, or a slow deliberate press - either way, not
80
+ // a click.
81
+ if (moved > WAYPOINT_CLICK_SLOP || held > WAYPOINT_CLICK_MS) return;
82
+ this.#setTargetFromEvent(e);
83
+ };
84
+ window.addEventListener("pointerdown", this.#onDown);
85
+ window.addEventListener("pointerup", this.#onUp);
86
+ }
87
+
88
+ dispose() {
89
+ window.removeEventListener("pointerdown", this.#onDown);
90
+ window.removeEventListener("pointerup", this.#onUp);
91
+ }
92
+
93
+ isActive() {
94
+ return this.#active;
95
+ }
96
+
97
+ // Current target in MJCF ground coords, or null - read by game.js to
98
+ // place the click marker. Read-only.
99
+ get target() {
100
+ return this.#target;
101
+ }
102
+
103
+ // Cancel a pending target without walking it down (sim reset, mode
104
+ // change out from under it).
105
+ cancel() {
106
+ this.#target = null;
107
+ this.#active = false;
108
+ this.command.fill(0);
109
+ }
110
+
111
+ poll() {
112
+ if (this.#target && this.#getManualOverride()) this.#target = null;
113
+ if (!this.#target) {
114
+ this.command.fill(0);
115
+ this.#active = false;
116
+ return;
117
+ }
118
+ const [px, py, yaw] = this.#getDuckPose();
119
+ const dx = this.#target[0] - px, dy = this.#target[1] - py;
120
+ const dist = Math.hypot(dx, dy);
121
+ if (dist < WAYPOINT_ARRIVE_RADIUS) {
122
+ this.#target = null;
123
+ this.command.fill(0);
124
+ this.#active = false;
125
+ return;
126
+ }
127
+ const err = wrapPi(Math.atan2(dy, dx) - yaw);
128
+ const [limF, , limA] = this.#getVelocityLimits();
129
+ const wz = Math.min(limA, Math.max(-limA, err * WAYPOINT_TURN_GAIN));
130
+ // Forward speed falls off with heading error so the duck turns in
131
+ // place first rather than strafing (there is no strafe) toward a
132
+ // target that's behind it.
133
+ const vx = limF * Math.max(0, Math.cos(err));
134
+ this.command[0] += WAYPOINT_SMOOTH_ALPHA * (vx - this.command[0]);
135
+ this.command[2] += WAYPOINT_SMOOTH_ALPHA * (wz - this.command[2]);
136
+ this.#active = true;
137
+ }
138
+
139
+ #setTargetFromEvent(e) {
140
+ const r = this.#renderer.domElement.getBoundingClientRect();
141
+ this.#ndc.set(
142
+ ((e.clientX - r.left) / r.width) * 2 - 1,
143
+ -((e.clientY - r.top) / r.height) * 2 + 1,
144
+ );
145
+ this.#raycaster.setFromCamera(this.#ndc, this.#camera);
146
+ if (!this.#raycaster.ray.intersectPlane(FLOOR_PLANE, this.#hit)) return;
147
+ const lim = ARENA_HALF - 0.05;
148
+ this.#target = [
149
+ Math.min(lim, Math.max(-lim, this.#hit.x)),
150
+ Math.min(lim, Math.max(-lim, -this.#hit.z)), // three -> MJCF
151
+ ];
152
+ }
153
+ }
app/src/game/game.js CHANGED
@@ -47,6 +47,7 @@ import { KeyboardSource } from "./controls/keyboard.js";
47
  import { GamepadSource } from "./controls/gamepad.js";
48
  import { haptics } from "./haptics.js";
49
  import { TouchSource } from "./controls/touch.js";
 
50
  import * as fx from "./fx/fx-wireframe.js";
51
  import { createCeremony, CAM_RESET_S } from "./ceremony.js";
52
  import {
@@ -71,6 +72,16 @@ import ortWasmUrl from "onnxruntime-web/ort-wasm-simd-threaded.wasm?url";
71
 
72
  let bootStarted = false;
73
 
 
 
 
 
 
 
 
 
 
 
74
  // HMR teardown for the ghost session: invalidating this module (directly or
75
  // via an edit to ghosts.js) used to stack a live 15 Hz broadcast interval
76
  // plus a ghost room per reload (the historical "stale module" bug class).
@@ -396,8 +407,23 @@ async function boot({ scene, camera, renderer }) {
396
  const kbSource = new KeyboardSource({ getVelocityLimits: () => velLims() });
397
  const padSource = new GamepadSource({ getVelocityLimits: () => velLims() });
398
  const touchSource = new TouchSource({ getVelocityLimits: () => velLims() });
399
- // Keyboard last: it reads zero when idle, so it doubles as the fallback.
400
- const controller = new Controller({ sources: [padSource, touchSource, kbSource] });
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
401
  // Right-stick camera state, read by the telemetry before the camera-orbit
402
  // section below has evaluated.
403
  let padOrbitLive = false;
@@ -1274,10 +1300,7 @@ async function boot({ scene, camera, renderer }) {
1274
  if (mode === "roll" || isKick()) {
1275
  rawYaw = chaseHeldYaw;
1276
  } else {
1277
- rawYaw = Math.atan2(
1278
- 2 * (qpos[3] * qpos[6] + qpos[4] * qpos[5]),
1279
- 1 - 2 * (qpos[5] * qpos[5] + qpos[6] * qpos[6]),
1280
- );
1281
  chaseHeldYaw = rawYaw;
1282
  }
1283
  // "turning" reads the raw per-source wz commands (not the locked/merged
@@ -1326,10 +1349,7 @@ async function boot({ scene, camera, renderer }) {
1326
  const _tgtFrom = new THREE.Vector3(), _tgtTo = new THREE.Vector3();
1327
  function startCameraReset() {
1328
  const qpos = data.qpos;
1329
- const yaw = Math.atan2(
1330
- 2 * (qpos[3] * qpos[6] + qpos[4] * qpos[5]),
1331
- 1 - 2 * (qpos[5] * qpos[5] + qpos[6] * qpos[6]),
1332
- );
1333
  chaseHeldYaw = yaw;
1334
  chaseYawSmooth = yaw;
1335
  chaseYawFollow = yaw;
@@ -1437,11 +1457,22 @@ async function boot({ scene, camera, renderer }) {
1437
  if (now - grabHoverAt < 80) return;
1438
  grabHoverAt = now;
1439
  grabRayFrom(e);
1440
- renderer.domElement.style.cursor = grabPick() ? "grab" : "";
 
1441
  });
1442
  window.addEventListener("pointerup", endGrab);
1443
  window.addEventListener("pointercancel", endGrab);
1444
 
 
 
 
 
 
 
 
 
 
 
1445
  // Pause: while the menu is up over a live game, keys belong to the menu.
1446
  const setInputLock = (v) => { inputLocked = v; controller.setLocked(v); };
1447
  useGame.subscribe(
@@ -1760,6 +1791,12 @@ async function boot({ scene, camera, renderer }) {
1760
  updateChaseCam();
1761
  ceremony.drive();
1762
  ball.drive(() => spawnBall({ fromQueue: true }));
 
 
 
 
 
 
1763
  renderTelemetry();
1764
  }
1765
 
@@ -1772,6 +1809,7 @@ async function boot({ scene, camera, renderer }) {
1772
  const srcTag = (source) => (source === "gamepad" ? "pad" : "kb");
1773
 
1774
  controller.on("reset", () => resetSim());
 
1775
  controller.on("spawnBall", () => spawnBall());
1776
  controller.on("headToggle", () => toggleHeadMode());
1777
  controller.on("chaseToggle", () => { chaseCam = !chaseCam; });
 
47
  import { GamepadSource } from "./controls/gamepad.js";
48
  import { haptics } from "./haptics.js";
49
  import { TouchSource } from "./controls/touch.js";
50
+ import { WaypointSource } from "./controls/waypoint.js";
51
  import * as fx from "./fx/fx-wireframe.js";
52
  import { createCeremony, CAM_RESET_S } from "./ceremony.js";
53
  import {
 
72
 
73
  let bootStarted = false;
74
 
75
+ // Trunk yaw from the freejoint quat (MuJoCo wxyz), Z-up so this is rotation
76
+ // about z. Shared by the chase cam and the waypoint follower - both need
77
+ // "which way is the duck facing" in MJCF ground coords.
78
+ function duckYaw(qpos) {
79
+ return Math.atan2(
80
+ 2 * (qpos[3] * qpos[6] + qpos[4] * qpos[5]),
81
+ 1 - 2 * (qpos[5] * qpos[5] + qpos[6] * qpos[6]),
82
+ );
83
+ }
84
+
85
  // HMR teardown for the ghost session: invalidating this module (directly or
86
  // via an edit to ghosts.js) used to stack a live 15 Hz broadcast interval
87
  // plus a ghost room per reload (the historical "stale module" bug class).
 
407
  const kbSource = new KeyboardSource({ getVelocityLimits: () => velLims() });
408
  const padSource = new GamepadSource({ getVelocityLimits: () => velLims() });
409
  const touchSource = new TouchSource({ getVelocityLimits: () => velLims() });
410
+ // Click-to-walk (PLAN.md Project 1 Phase A): reads zero until a floor
411
+ // click arms a target, so - like the keyboard before it - it doubles as
412
+ // the fallback. Any keyboard/pad/touch input preempts it by arbitration
413
+ // order alone; getManualOverride also cancels the pending target outright
414
+ // so releasing manual input doesn't snap the duck back onto a stale click.
415
+ const waypointSource = new WaypointSource({
416
+ camera, renderer,
417
+ getVelocityLimits: () => velLims(),
418
+ getDuckPose: () => {
419
+ const qpos = data.qpos;
420
+ return [qpos[0], qpos[1], duckYaw(qpos)];
421
+ },
422
+ isSuppressed: () => inputLocked || headMode || mode !== "walk" || !!grab,
423
+ getManualOverride: () => padSource.isActive() || touchSource.isActive() || kbSource.isActive(),
424
+ });
425
+ // Keyboard, pad and touch preempt by priority; waypoint is the fallback.
426
+ const controller = new Controller({ sources: [padSource, touchSource, kbSource, waypointSource] });
427
  // Right-stick camera state, read by the telemetry before the camera-orbit
428
  // section below has evaluated.
429
  let padOrbitLive = false;
 
1300
  if (mode === "roll" || isKick()) {
1301
  rawYaw = chaseHeldYaw;
1302
  } else {
1303
+ rawYaw = duckYaw(qpos);
 
 
 
1304
  chaseHeldYaw = rawYaw;
1305
  }
1306
  // "turning" reads the raw per-source wz commands (not the locked/merged
 
1349
  const _tgtFrom = new THREE.Vector3(), _tgtTo = new THREE.Vector3();
1350
  function startCameraReset() {
1351
  const qpos = data.qpos;
1352
+ const yaw = duckYaw(qpos);
 
 
 
1353
  chaseHeldYaw = yaw;
1354
  chaseYawSmooth = yaw;
1355
  chaseYawFollow = yaw;
 
1457
  if (now - grabHoverAt < 80) return;
1458
  grabHoverAt = now;
1459
  grabRayFrom(e);
1460
+ const clickable = mode === "walk" && !headMode;
1461
+ renderer.domElement.style.cursor = grabPick() ? "grab" : (clickable ? "crosshair" : "");
1462
  });
1463
  window.addEventListener("pointerup", endGrab);
1464
  window.addEventListener("pointercancel", endGrab);
1465
 
1466
+ // Waypoint marker: pulsing ring at the clicked floor point, hidden while
1467
+ // idle. Positioned each frame in frame() from waypointSource.target.
1468
+ const waypointMarker = new THREE.Mesh(
1469
+ new THREE.RingGeometry(0.05, 0.065, 32),
1470
+ new THREE.MeshBasicMaterial({ color: 0xff7a2f, transparent: true, opacity: 0.85, side: THREE.DoubleSide }),
1471
+ );
1472
+ waypointMarker.rotation.x = -Math.PI / 2; // flat on the floor, three y-up
1473
+ waypointMarker.visible = false;
1474
+ scene.add(waypointMarker);
1475
+
1476
  // Pause: while the menu is up over a live game, keys belong to the menu.
1477
  const setInputLock = (v) => { inputLocked = v; controller.setLocked(v); };
1478
  useGame.subscribe(
 
1791
  updateChaseCam();
1792
  ceremony.drive();
1793
  ball.drive(() => spawnBall({ fromQueue: true }));
1794
+ const wpTarget = waypointSource.target;
1795
+ waypointMarker.visible = !!wpTarget;
1796
+ if (wpTarget) {
1797
+ waypointMarker.position.set(wpTarget[0], 0.012, -wpTarget[1]); // MJCF -> three
1798
+ waypointMarker.scale.setScalar(1 + 0.15 * Math.sin(performance.now() * 0.006));
1799
+ }
1800
  renderTelemetry();
1801
  }
1802
 
 
1809
  const srcTag = (source) => (source === "gamepad" ? "pad" : "kb");
1810
 
1811
  controller.on("reset", () => resetSim());
1812
+ controller.on("reset", () => waypointSource.cancel());
1813
  controller.on("spawnBall", () => spawnBall());
1814
  controller.on("headToggle", () => toggleHeadMode());
1815
  controller.on("chaseToggle", () => { chaseCam = !chaseCam; });