Add click-to-walk waypoint source.

#3
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
@@ -46,6 +46,7 @@ import { Controller } from "./controls/controller.js";
46
  import { KeyboardSource } from "./controls/keyboard.js";
47
  import { GamepadSource } from "./controls/gamepad.js";
48
  import { TouchSource } from "./controls/touch.js";
 
49
  import * as fx from "./fx/fx-wireframe.js";
50
  import { createCeremony, CAM_RESET_S } from "./ceremony.js";
51
  import { createBallActor } from "./ball-actor.js";
@@ -63,6 +64,16 @@ const ORT_URL = "https://cdn.jsdelivr.net/npm/onnxruntime-web@1.27.0/dist/ort.mi
63
 
64
  let bootStarted = false;
65
 
 
 
 
 
 
 
 
 
 
 
66
  // HMR teardown for the ghost session: invalidating this module (directly or
67
  // via an edit to ghosts.js) used to stack a live 15 Hz broadcast interval
68
  // plus a ghost room per reload (the historical "stale module" bug class).
@@ -375,8 +386,23 @@ async function boot({ scene, camera, renderer }) {
375
  const kbSource = new KeyboardSource({ getVelocityLimits: () => velLims() });
376
  const padSource = new GamepadSource({ getVelocityLimits: () => velLims() });
377
  const touchSource = new TouchSource({ getVelocityLimits: () => velLims() });
378
- // Keyboard last: it reads zero when idle, so it doubles as the fallback.
379
- const controller = new Controller({ sources: [padSource, touchSource, kbSource] });
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
380
  // Right-stick camera state, read by the telemetry before the camera-orbit
381
  // section below has evaluated.
382
  let padOrbitLive = false;
@@ -1067,10 +1093,7 @@ async function boot({ scene, camera, renderer }) {
1067
  if (mode === "roll" || isKick()) {
1068
  rawYaw = chaseHeldYaw;
1069
  } else {
1070
- rawYaw = Math.atan2(
1071
- 2 * (qpos[3] * qpos[6] + qpos[4] * qpos[5]),
1072
- 1 - 2 * (qpos[5] * qpos[5] + qpos[6] * qpos[6]),
1073
- );
1074
  chaseHeldYaw = rawYaw;
1075
  }
1076
  // "turning" reads the raw per-source wz commands (not the locked/merged
@@ -1119,10 +1142,7 @@ async function boot({ scene, camera, renderer }) {
1119
  const _tgtFrom = new THREE.Vector3(), _tgtTo = new THREE.Vector3();
1120
  function startCameraReset() {
1121
  const qpos = data.qpos;
1122
- const yaw = Math.atan2(
1123
- 2 * (qpos[3] * qpos[6] + qpos[4] * qpos[5]),
1124
- 1 - 2 * (qpos[5] * qpos[5] + qpos[6] * qpos[6]),
1125
- );
1126
  chaseHeldYaw = yaw;
1127
  chaseYawSmooth = yaw;
1128
  chaseYawFollow = yaw;
@@ -1230,11 +1250,22 @@ async function boot({ scene, camera, renderer }) {
1230
  if (now - grabHoverAt < 80) return;
1231
  grabHoverAt = now;
1232
  grabRayFrom(e);
1233
- renderer.domElement.style.cursor = grabPick() ? "grab" : "";
 
1234
  });
1235
  window.addEventListener("pointerup", endGrab);
1236
  window.addEventListener("pointercancel", endGrab);
1237
 
 
 
 
 
 
 
 
 
 
 
1238
  // Pause: while the menu is up over a live game, keys belong to the menu.
1239
  const setInputLock = (v) => { inputLocked = v; controller.setLocked(v); };
1240
  useGame.subscribe(
@@ -1552,6 +1583,12 @@ async function boot({ scene, camera, renderer }) {
1552
  updateChaseCam();
1553
  ceremony.drive();
1554
  ball.drive(() => spawnBall({ fromQueue: true }));
 
 
 
 
 
 
1555
  renderTelemetry();
1556
  }
1557
 
@@ -1564,6 +1601,7 @@ async function boot({ scene, camera, renderer }) {
1564
  const srcTag = (source) => (source === "gamepad" ? "pad" : "kb");
1565
 
1566
  controller.on("reset", () => resetSim());
 
1567
  controller.on("spawnBall", () => spawnBall());
1568
  controller.on("headToggle", () => toggleHeadMode());
1569
  controller.on("chaseToggle", () => { chaseCam = !chaseCam; });
 
46
  import { KeyboardSource } from "./controls/keyboard.js";
47
  import { GamepadSource } from "./controls/gamepad.js";
48
  import { TouchSource } from "./controls/touch.js";
49
+ import { WaypointSource } from "./controls/waypoint.js";
50
  import * as fx from "./fx/fx-wireframe.js";
51
  import { createCeremony, CAM_RESET_S } from "./ceremony.js";
52
  import { createBallActor } from "./ball-actor.js";
 
64
 
65
  let bootStarted = false;
66
 
67
+ // Trunk yaw from the freejoint quat (MuJoCo wxyz), Z-up so this is rotation
68
+ // about z. Shared by the chase cam and the waypoint follower - both need
69
+ // "which way is the duck facing" in MJCF ground coords.
70
+ function duckYaw(qpos) {
71
+ return Math.atan2(
72
+ 2 * (qpos[3] * qpos[6] + qpos[4] * qpos[5]),
73
+ 1 - 2 * (qpos[5] * qpos[5] + qpos[6] * qpos[6]),
74
+ );
75
+ }
76
+
77
  // HMR teardown for the ghost session: invalidating this module (directly or
78
  // via an edit to ghosts.js) used to stack a live 15 Hz broadcast interval
79
  // plus a ghost room per reload (the historical "stale module" bug class).
 
386
  const kbSource = new KeyboardSource({ getVelocityLimits: () => velLims() });
387
  const padSource = new GamepadSource({ getVelocityLimits: () => velLims() });
388
  const touchSource = new TouchSource({ getVelocityLimits: () => velLims() });
389
+ // Click-to-walk (PLAN.md Project 1 Phase A): reads zero until a floor
390
+ // click arms a target, so - like the keyboard before it - it doubles as
391
+ // the fallback. Any keyboard/pad/touch input preempts it by arbitration
392
+ // order alone; getManualOverride also cancels the pending target outright
393
+ // so releasing manual input doesn't snap the duck back onto a stale click.
394
+ const waypointSource = new WaypointSource({
395
+ camera, renderer,
396
+ getVelocityLimits: () => velLims(),
397
+ getDuckPose: () => {
398
+ const qpos = data.qpos;
399
+ return [qpos[0], qpos[1], duckYaw(qpos)];
400
+ },
401
+ isSuppressed: () => inputLocked || headMode || mode !== "walk" || !!grab,
402
+ getManualOverride: () => padSource.isActive() || touchSource.isActive() || kbSource.isActive(),
403
+ });
404
+ // Keyboard, pad and touch preempt by priority; waypoint is the fallback.
405
+ const controller = new Controller({ sources: [padSource, touchSource, kbSource, waypointSource] });
406
  // Right-stick camera state, read by the telemetry before the camera-orbit
407
  // section below has evaluated.
408
  let padOrbitLive = false;
 
1093
  if (mode === "roll" || isKick()) {
1094
  rawYaw = chaseHeldYaw;
1095
  } else {
1096
+ rawYaw = duckYaw(qpos);
 
 
 
1097
  chaseHeldYaw = rawYaw;
1098
  }
1099
  // "turning" reads the raw per-source wz commands (not the locked/merged
 
1142
  const _tgtFrom = new THREE.Vector3(), _tgtTo = new THREE.Vector3();
1143
  function startCameraReset() {
1144
  const qpos = data.qpos;
1145
+ const yaw = duckYaw(qpos);
 
 
 
1146
  chaseHeldYaw = yaw;
1147
  chaseYawSmooth = yaw;
1148
  chaseYawFollow = yaw;
 
1250
  if (now - grabHoverAt < 80) return;
1251
  grabHoverAt = now;
1252
  grabRayFrom(e);
1253
+ const clickable = mode === "walk" && !headMode;
1254
+ renderer.domElement.style.cursor = grabPick() ? "grab" : (clickable ? "crosshair" : "");
1255
  });
1256
  window.addEventListener("pointerup", endGrab);
1257
  window.addEventListener("pointercancel", endGrab);
1258
 
1259
+ // Waypoint marker: pulsing ring at the clicked floor point, hidden while
1260
+ // idle. Positioned each frame in frame() from waypointSource.target.
1261
+ const waypointMarker = new THREE.Mesh(
1262
+ new THREE.RingGeometry(0.05, 0.065, 32),
1263
+ new THREE.MeshBasicMaterial({ color: 0xff7a2f, transparent: true, opacity: 0.85, side: THREE.DoubleSide }),
1264
+ );
1265
+ waypointMarker.rotation.x = -Math.PI / 2; // flat on the floor, three y-up
1266
+ waypointMarker.visible = false;
1267
+ scene.add(waypointMarker);
1268
+
1269
  // Pause: while the menu is up over a live game, keys belong to the menu.
1270
  const setInputLock = (v) => { inputLocked = v; controller.setLocked(v); };
1271
  useGame.subscribe(
 
1583
  updateChaseCam();
1584
  ceremony.drive();
1585
  ball.drive(() => spawnBall({ fromQueue: true }));
1586
+ const wpTarget = waypointSource.target;
1587
+ waypointMarker.visible = !!wpTarget;
1588
+ if (wpTarget) {
1589
+ waypointMarker.position.set(wpTarget[0], 0.012, -wpTarget[1]); // MJCF -> three
1590
+ waypointMarker.scale.setScalar(1 + 0.15 * Math.sin(performance.now() * 0.006));
1591
+ }
1592
  renderTelemetry();
1593
  }
1594
 
 
1601
  const srcTag = (source) => (source === "gamepad" ? "pad" : "kb");
1602
 
1603
  controller.on("reset", () => resetSim());
1604
+ controller.on("reset", () => waypointSource.cancel());
1605
  controller.on("spawnBall", () => spawnBall());
1606
  controller.on("headToggle", () => toggleHeadMode());
1607
  controller.on("chaseToggle", () => { chaseCam = !chaseCam; });