feat: sync the soccer ball over webrtc ghosts
Browse filesPeers 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>
- app/src/game/game.js +18 -1
- 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 |
-
|
| 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.
|
| 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 |
-
|
|
|
|
|
|
|
|
|
|
| 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 |
};
|