tfrere HF Staff Cursor commited on
Commit
cc9f809
·
1 Parent(s): 2aa0273

feat: sync the soccer ball over webrtc ghosts

Browse files

Peers broadcast their ball's free-joint pose in the state payload (new
optional b field, sent only while a ball is in play). Each ghost renders
it as the same translucent shell as its duck - geometry shared with the
local ball, material cloned - interpolated like the trunk and hidden the
moment the peer's ball despawns. debug() now reports peerId and ball.

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

Files changed (2) hide show
  1. app/src/game/game.js +18 -1
  2. app/src/game/ghosts.js +56 -2
app/src/game/game.js CHANGED
@@ -2087,17 +2087,34 @@ async function boot({ scene, camera, renderer }) {
2087
  (l ? (locos.rollers?.rig ?? ghostRollerRig ?? locos.legs.rig) : locos.legs.rig),
2088
  hasRigFor: (l) => !l || !!(locos.rollers || ghostRollerRig),
2089
  prepareRigFor: (l) => { if (l) ensureGhostRollerRig(); },
 
 
 
 
 
 
 
 
 
 
2090
  getLocalState: () => {
2091
  const qpos = data.qpos;
2092
  const j = new Array(NUM_JOINTS);
2093
  for (let i = 0; i < NUM_JOINTS; i++) j[i] = r3(qpos[qposAdr[i]]);
2094
- return {
2095
  p: [r3(qpos[0]), r3(qpos[1]), r3(qpos[2]), r3(qpos[3]), r3(qpos[4]), r3(qpos[5]), r3(qpos[6])],
2096
  j,
2097
  w: r3(jawOpenNow()),
2098
  v: currentVariant,
2099
  l: loco === "rollers" ? 1 : 0,
2100
  };
 
 
 
 
 
 
 
2101
  },
2102
  });
2103
  liveGhostSessions.add(ghosts);
 
2087
  (l ? (locos.rollers?.rig ?? ghostRollerRig ?? locos.legs.rig) : locos.legs.rig),
2088
  hasRigFor: (l) => !l || !!(locos.rollers || ghostRollerRig),
2089
  prepareRigFor: (l) => { if (l) ensureGhostRollerRig(); },
2090
+ // Ghost ball visual: shares the local ball's geometry and clones its
2091
+ // material (ghosts.js makes it translucent). Same Z-up group trick
2092
+ // as createBallVisual - the mesh takes the raw MJCF free-joint pose.
2093
+ makeGhostBall: () => {
2094
+ const group = new THREE.Group();
2095
+ group.rotation.x = -Math.PI / 2;
2096
+ const mesh = new THREE.Mesh(ballMesh.geometry, ballMesh.material.clone());
2097
+ group.add(mesh);
2098
+ return { group, mesh };
2099
+ },
2100
  getLocalState: () => {
2101
  const qpos = data.qpos;
2102
  const j = new Array(NUM_JOINTS);
2103
  for (let i = 0; i < NUM_JOINTS; i++) j[i] = r3(qpos[qposAdr[i]]);
2104
+ const st = {
2105
  p: [r3(qpos[0]), r3(qpos[1]), r3(qpos[2]), r3(qpos[3]), r3(qpos[4]), r3(qpos[5]), r3(qpos[6])],
2106
  j,
2107
  w: r3(jawOpenNow()),
2108
  v: currentVariant,
2109
  l: loco === "rollers" ? 1 : 0,
2110
  };
2111
+ // Ball free-joint pose, only while a ball is in play (old clients
2112
+ // ignore the extra field; absent = no ball on this peer's field).
2113
+ if (ballActive) {
2114
+ const a = ballQposAdr;
2115
+ st.b = [r3(qpos[a]), r3(qpos[a + 1]), r3(qpos[a + 2]), r3(qpos[a + 3]), r3(qpos[a + 4]), r3(qpos[a + 5]), r3(qpos[a + 6])];
2116
+ }
2117
+ return st;
2118
  },
2119
  });
2120
  liveGhostSessions.add(ghosts);
app/src/game/ghosts.js CHANGED
@@ -195,6 +195,7 @@ export async function initGhosts(env) {
195
  // stay behind the delayed render time).
196
  const snapOf = (state, prevAt = -Infinity) => ({
197
  p: state.p, j: state.j, w: state.w ?? 0,
 
198
  at: Math.max(performance.now(), prevAt + RESPACE_MIN_MS),
199
  });
200
  const makeGhost = (state) => {
@@ -214,6 +215,7 @@ export async function initGhosts(env) {
214
  return {
215
  rig, trunk, buf: [snapOf(state)],
216
  variant: state.v, loco: served,
 
217
  gapAvg: 1000 / SEND_HZ, // measured arrival cadence, ms
218
  delay: INTERP_DELAY_MS, // current playback delay, ms
219
  };
@@ -230,6 +232,10 @@ export async function initGhosts(env) {
230
  o.material.dispose();
231
  }
232
  });
 
 
 
 
233
  ghosts.delete(peerId);
234
  };
235
 
@@ -242,6 +248,7 @@ export async function initGhosts(env) {
242
  finiteArray(state.p, 7) &&
243
  finiteArray(state.j, jointNames.length) &&
244
  (state.w == null || Number.isFinite(state.w)) &&
 
245
  typeof state.v === "string";
246
  const warnedPeers = new Set(); // one malformed-payload warn per peer
247
 
@@ -383,6 +390,49 @@ export async function initGhosts(env) {
383
  // instances, cloned here from any node of the already-built local rig.
384
  const _qa = env.rig.placer.quaternion.clone();
385
  const _qb = env.rig.placer.quaternion.clone();
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
386
  let lastUpdateAt = performance.now(); // update() frame clock for the delay slew
387
  const api = {
388
  room,
@@ -409,7 +459,7 @@ export async function initGhosts(env) {
409
  [...ghosts.values()]
410
  .filter((g) => g.revealed)
411
  .map((g) => ({ x: g.trunk.position.x, y: g.trunk.position.y })),
412
- debug: () => [...ghosts.values()].map((g) => {
413
  let meshes = 0, visible = 0, op = null, twins = 0;
414
  g.rig.root.traverse((o) => {
415
  if (!o.isMesh) return;
@@ -417,7 +467,10 @@ export async function initGhosts(env) {
417
  meshes++; if (o.visible) visible++; op ??= o.material.opacity;
418
  });
419
  const w = g.trunk.getWorldPosition(g.trunk.position.clone());
420
- return { p: g.trunk.position.toArray(), world: w.toArray(), inScene: !!g.rig.placer.parent, revealed: g.revealed, meshes, visible, twins, op, v: g.variant, l: g.loco, gapAvg: g.gapAvg, delay: g.delay, bufLen: g.buf.length };
 
 
 
421
  }),
422
  update() {
423
  const now = performance.now();
@@ -460,6 +513,7 @@ export async function initGhosts(env) {
460
  setJoint(g.rig, jointNames[i], a.j[i] + (b.j[i] - a.j[i]) * u);
461
  }
462
  setJawOpen(g.rig, a.w + (b.w - a.w) * u);
 
463
  }
464
  },
465
  };
 
195
  // stay behind the delayed render time).
196
  const snapOf = (state, prevAt = -Infinity) => ({
197
  p: state.p, j: state.j, w: state.w ?? 0,
198
+ b: state.b ?? null, // ball free-joint pose, or null while none in play
199
  at: Math.max(performance.now(), prevAt + RESPACE_MIN_MS),
200
  });
201
  const makeGhost = (state) => {
 
215
  return {
216
  rig, trunk, buf: [snapOf(state)],
217
  variant: state.v, loco: served,
218
+ ball: null, // { group, mesh } lazily built on the first ball pose
219
  gapAvg: 1000 / SEND_HZ, // measured arrival cadence, ms
220
  delay: INTERP_DELAY_MS, // current playback delay, ms
221
  };
 
232
  o.material.dispose();
233
  }
234
  });
235
+ if (g.ball) {
236
+ scene.remove(g.ball.group);
237
+ g.ball.mesh.material.dispose(); // cloned per ghost; geometry is shared
238
+ }
239
  ghosts.delete(peerId);
240
  };
241
 
 
248
  finiteArray(state.p, 7) &&
249
  finiteArray(state.j, jointNames.length) &&
250
  (state.w == null || Number.isFinite(state.w)) &&
251
+ (state.b == null || finiteArray(state.b, 7)) && // ball pose, optional
252
  typeof state.v === "string";
253
  const warnedPeers = new Set(); // one malformed-payload warn per peer
254
 
 
390
  // instances, cloned here from any node of the already-built local rig.
391
  const _qa = env.rig.placer.quaternion.clone();
392
  const _qb = env.rig.placer.quaternion.clone();
393
+
394
+ // Ghost ball: the peer's soccer ball as the same translucent shell as
395
+ // its duck. Built lazily on the first snapshot carrying a ball pose
396
+ // (env.makeGhostBall shares the local ball's geometry and clones its
397
+ // material), shown only while the ghost itself is revealed, hidden the
398
+ // moment the peer's ball despawns (b turns null in the stream). Poses
399
+ // interpolate like the trunk; on a spawn/despawn edge where only one
400
+ // bracketing snapshot has a ball, it snaps to that pose instead of
401
+ // lerping from nowhere. A single convex mesh needs no depth-prepass
402
+ // twin - plain transparent blending reads fine on a sphere.
403
+ const driveBall = (g, ab, bb, u) => {
404
+ if (!ab && !bb) {
405
+ if (g.ball) g.ball.group.visible = false;
406
+ return;
407
+ }
408
+ if (!g.ball) {
409
+ if (!env.makeGhostBall) return; // host without ball support
410
+ const ball = env.makeGhostBall();
411
+ const m = ball.mesh.material;
412
+ m.transparent = true;
413
+ m.opacity = GHOST_OPACITY;
414
+ m.depthWrite = false;
415
+ ball.mesh.renderOrder = BEAUTY_ORDER;
416
+ scene.add(ball.group);
417
+ g.ball = ball;
418
+ }
419
+ g.ball.group.visible = g.revealed;
420
+ const mesh = g.ball.mesh;
421
+ if (ab && bb) {
422
+ mesh.position.set(
423
+ ab[0] + (bb[0] - ab[0]) * u,
424
+ ab[1] + (bb[1] - ab[1]) * u,
425
+ ab[2] + (bb[2] - ab[2]) * u,
426
+ );
427
+ _qa.set(ab[4], ab[5], ab[6], ab[3]); // MJCF wxyz -> THREE xyzw
428
+ _qb.set(bb[4], bb[5], bb[6], bb[3]);
429
+ mesh.quaternion.copy(_qa.slerp(_qb, u));
430
+ } else {
431
+ const s = ab ?? bb;
432
+ mesh.position.set(s[0], s[1], s[2]);
433
+ mesh.quaternion.set(s[4], s[5], s[6], s[3]);
434
+ }
435
+ };
436
  let lastUpdateAt = performance.now(); // update() frame clock for the delay slew
437
  const api = {
438
  room,
 
459
  [...ghosts.values()]
460
  .filter((g) => g.revealed)
461
  .map((g) => ({ x: g.trunk.position.x, y: g.trunk.position.y })),
462
+ debug: () => [...ghosts.entries()].map(([peerId, g]) => {
463
  let meshes = 0, visible = 0, op = null, twins = 0;
464
  g.rig.root.traverse((o) => {
465
  if (!o.isMesh) return;
 
467
  meshes++; if (o.visible) visible++; op ??= o.material.opacity;
468
  });
469
  const w = g.trunk.getWorldPosition(g.trunk.position.clone());
470
+ const ball = g.ball
471
+ ? { visible: g.ball.group.visible, p: g.ball.mesh.position.toArray() }
472
+ : null;
473
+ return { peerId, p: g.trunk.position.toArray(), world: w.toArray(), inScene: !!g.rig.placer.parent, revealed: g.revealed, meshes, visible, twins, op, v: g.variant, l: g.loco, ball, gapAvg: g.gapAvg, delay: g.delay, bufLen: g.buf.length };
474
  }),
475
  update() {
476
  const now = performance.now();
 
513
  setJoint(g.rig, jointNames[i], a.j[i] + (b.j[i] - a.j[i]) * u);
514
  }
515
  setJawOpen(g.rig, a.w + (b.w - a.w) * u);
516
+ driveBall(g, a.b, b.b, u);
517
  }
518
  },
519
  };