File size: 12,954 Bytes
bb90cc7 b2229dc bb90cc7 b2229dc bb90cc7 b2229dc bb90cc7 b2229dc bb90cc7 cfa91ca bb90cc7 b2229dc bb90cc7 b2229dc bb90cc7 b2229dc bb90cc7 b2229dc bb90cc7 b2229dc bb90cc7 cfa91ca bb90cc7 cfa91ca b2229dc cfa91ca bb90cc7 b2229dc bb90cc7 b2229dc cfa91ca b2229dc cfa91ca b2229dc cfa91ca b2229dc cfa91ca bb90cc7 b2229dc bb90cc7 b2229dc bb90cc7 b2229dc bb90cc7 b2229dc bb90cc7 b2229dc bb90cc7 b2229dc bb90cc7 b2229dc bb90cc7 b2229dc bb90cc7 b2229dc bb90cc7 b2229dc bb90cc7 b2229dc bb90cc7 | 1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 53 54 55 56 57 58 59 60 61 62 63 64 65 66 67 68 69 70 71 72 73 74 75 76 77 78 79 80 81 82 83 84 85 86 87 88 89 90 91 92 93 94 95 96 97 98 99 100 101 102 103 104 105 106 107 108 109 110 111 112 113 114 115 116 117 118 119 120 121 122 123 124 125 126 127 128 129 130 131 132 133 134 135 136 137 138 139 140 141 142 143 144 145 146 147 148 149 150 151 152 153 154 155 156 157 158 159 160 161 162 163 164 165 166 167 168 169 170 171 172 173 174 175 176 177 178 179 180 181 182 183 184 185 186 187 188 189 190 191 192 193 194 195 196 197 198 199 200 201 202 203 204 205 206 207 208 209 210 211 212 213 214 215 216 217 218 219 220 221 222 223 224 225 226 227 228 229 230 231 232 233 234 235 236 237 238 239 240 241 242 243 244 245 246 247 248 249 250 251 252 253 254 255 256 257 258 259 260 261 262 263 264 265 266 267 268 269 270 271 272 273 274 275 276 277 278 279 280 281 282 283 284 285 286 287 288 289 | // Live multiplayer ghosts: every open tab of the sandbox broadcasts its
// duck state over WebRTC and renders the other visitors as translucent
// ducks. There is no backend - this is a static Space - so peer discovery
// uses Trystero's serverless signaling over public Nostr relays. Payloads
// are tiny (22 floats + a variant name at SEND_HZ), and at most MAX_GHOSTS
// ghosts are instantiated regardless of how many peers are in the room.
//
// This module deliberately imports nothing from duck.js / variants.js:
// on the private Space those need the ?__sign JWT that only rl.js knows
// how to append, so all rig helpers arrive through init parameters.
const TRYSTERO_URL = "https://esm.run/trystero@0.25.3/nostr";
const APP_ID = "microduck-sandbox";
const ROOM = "lobby";
const MAX_GHOSTS = 3;
const SEND_HZ = 15;
const GHOST_OPACITY = 0.35;
// Snapshot interpolation: each ghost renders INTERP_DELAY_MS in the past,
// lerping between the two buffered snapshots that bracket the render time
// (timed by local ARRIVAL, so no clock sync and no payload change - old
// clients interoperate). This replaces the old exponential chase toward
// the latest packet, whose move-then-stall rhythm read as micro-stutter.
// The delay absorbs network jitter; past the newest snapshot the ghost
// holds pose, so a background-throttled 1 Hz sender degrades into slow
// discrete steps instead of teleports. Nobody synchronizes on ghosts, so
// the added latency is invisible.
const INTERP_DELAY_MS = 150;
const BUF_MAX = 40; // snapshots kept per ghost (~2.5 s at 15 Hz)
// Idle visitors stay invisible: a ghost is only shown once its peer has
// strayed from the pose of its first state message (everyone spawns at
// the arena center). Once revealed it stays visible for the whole peer
// session, so a reset teleporting the duck back to center doesn't blink
// the ghost out.
const REVEAL_DIST = 0.2; // m, horizontal distance from spawn
const REVEAL_YAW = 0.4; // rad (~23deg), turning in place also reveals
// state.p is raw MuJoCo qpos[0..6]: [x, y, z, qw, qx, qy, qz], Z-up.
const yawOf = (p) =>
Math.atan2(2 * (p[3] * p[6] + p[4] * p[5]), 1 - 2 * (p[5] * p[5] + p[6] * p[6]));
const hasMoved = (spawn, p) => {
const dx = p[0] - spawn.x, dy = p[1] - spawn.y;
if (dx * dx + dy * dy > REVEAL_DIST * REVEAL_DIST) return true;
const dyaw = yawOf(p) - spawn.yaw;
return Math.abs(Math.atan2(Math.sin(dyaw), Math.cos(dyaw))) > REVEAL_YAW;
};
export async function initGhosts(env) {
const noop = { update() {}, peerCount: () => 0, ghostCount: () => 0, debug: () => [], mapDots: () => [] };
let room;
try {
const { joinRoom } = await import(TRYSTERO_URL);
room = joinRoom({ appId: APP_ID }, ROOM);
} catch (e) {
console.warn("ghosts disabled (signaling unavailable):", e);
return noop;
}
const { scene, cloneRig, setJoint, setJawOpen, applyVariant, jointNames, getLocalState } = env;
// Locomotion-variant rig source: state.l 1 = rollers, 0/absent = legs
// (old clients never send it). Falls back to the leg rig when the local
// tab hasn't built the roller rig yet - known v1 limitation.
const rigFor = (l) => (env.getRigFor ? env.getRigFor(l ?? 0) : env.rig);
// trystero 0.25 (backed by @trystero-p2p): makeAction returns
// { send, onMessage, onReceiveProgress } where onMessage is a SETTER -
// the receive handler is registered by assignment, not by calling it.
const act = room.makeAction("s");
// send() rejects when a peer's channel drops mid-transfer; that peer will
// be swept anyway, so failures are non-events.
const sendState = (data) => act.send(data).catch(() => {});
// Ghosts share geometry with the local rig (cloneRig), but get their own
// transparent material clones so repainting them never touches the
// visitor's own duck (variants.js caches materials globally).
//
// Rendering: a ghost reads as ONE translucent shell, not ~70 individually
// transparent meshes (through which every internal motor/PCB used to show).
// Classic depth-prepass silhouette, kept inside the transparent pass so the
// grid floor/walls (transparent ShaderMaterials at renderOrder 0) still
// draw behind ghosts instead of being depth-rejected:
// PREPASS (renderOrder 1): every ghost mesh duplicated as a color-less
// twin (colorWrite off, depthWrite on) -> the depth buffer ends up
// holding the nearest ghost surface per pixel.
// BEAUTY (renderOrder 2): the real meshes, transparent + depthWrite off;
// the default LessEqual depth test then rejects everything except the
// fragment matching the prepass depth, so internals and back shell
// layers are skipped and each pixel is shaded exactly once.
// Orders are shared by all ghosts (not per-ghost pairs): every beauty pass
// tests against every ghost's prepass depth, so overlapping ghosts occlude
// each other like solids instead of double-blending.
const PREPASS_ORDER = 1;
const BEAUTY_ORDER = 2;
const ghostify = (rig) => {
// Collect first: twins are added below and must not be re-traversed.
const meshes = [];
rig.root.traverse((o) => { if (o.isMesh) meshes.push(o); });
const cache = new Map();
let depthMat = null;
for (const o of meshes) {
// Materialization-FX wire overlays cloned along with the local rig:
// drop them, they share the LIVE scan shader material and would
// flash on this ghost every time the local duck re-materializes.
if (o.userData.fxOverlay) { o.parent?.remove(o); continue; }
if (o.userData.ghostPrepass) continue; // twin from a previous ghostify
let m = cache.get(o.material.uuid);
if (!m) {
m = o.material.clone();
m.transparent = true;
m.opacity = GHOST_OPACITY;
m.depthWrite = false;
cache.set(o.material.uuid, m);
}
o.material = m;
o.renderOrder = BEAUTY_ORDER;
if (!o.userData.hasGhostTwin) {
if (!depthMat) {
// Depth-only material: cloned (no THREE import here) and muted.
// transparent stays true so the twin sorts into the transparent
// pass, after the grid but before the beauty meshes.
depthMat = m.clone();
depthMat.colorWrite = false;
depthMat.depthWrite = true;
}
// Twin as an identity-transform child: follows joints for free. No
// userData.meshName, so applyVariant never repaints it.
const twin = new o.constructor(o.geometry, depthMat);
twin.renderOrder = PREPASS_ORDER;
twin.userData.ghostPrepass = true;
o.userData.hasGhostTwin = true;
o.add(twin);
}
}
};
const ghosts = new Map(); // peerId -> ghost
// Interpolation snapshot: the pose parts of a state message, stamped
// with the local arrival time.
const snapOf = (state) => ({ p: state.p, j: state.j, w: state.w ?? 0, at: performance.now() });
const makeGhost = (state) => {
const rig = cloneRig(rigFor(state.l));
applyVariant(rig, state.v);
ghostify(rig);
scene.add(rig.placer);
const trunk = rig.bodies.get("trunk_base");
// Snap straight to the first received pose: no fly-in from the origin.
trunk.position.set(state.p[0], state.p[1], state.p[2]);
trunk.quaternion.set(state.p[4], state.p[5], state.p[6], state.p[3]);
return {
rig, trunk, buf: [snapOf(state)],
variant: state.v, loco: state.l ?? 0,
};
};
const removeGhost = (peerId) => {
const g = ghosts.get(peerId);
if (!g) return;
scene.remove(g.rig.placer);
const seen = new Set();
g.rig.root.traverse((o) => {
if (o.isMesh && !seen.has(o.material.uuid)) {
seen.add(o.material.uuid);
o.material.dispose();
}
});
ghosts.delete(peerId);
};
// Handler signature in this build: (payload, context) where context is
// { peerId } - NOT the bare peerId string of classic trystero.
act.onMessage = (state, { peerId }) => {
let g = ghosts.get(peerId);
if (!g) {
if (ghosts.size >= MAX_GHOSTS) return; // room stays open, rendering capped
// Nostr relays replay recent events, so states from already-dead
// sessions can arrive right after joining: only ghost live peers.
if (!(peerId in room.getPeers())) return;
g = makeGhost(state);
// Idle-visitor gate: remember where this peer first appeared and keep
// its rig invisible until it strays from there (see hasMoved above).
g.spawn = { x: state.p[0], y: state.p[1], yaw: yawOf(state.p) };
g.revealed = false;
g.rig.placer.visible = false;
ghosts.set(peerId, g);
g.lastSeen = performance.now();
return;
}
g.lastSeen = performance.now();
g.buf.push(snapOf(state));
if (g.buf.length > BUF_MAX) g.buf.shift();
if (!g.revealed && hasMoved(g.spawn, state.p)) {
g.revealed = true; // latched for the rest of this peer session
g.rig.placer.visible = true;
}
// Peer switched legs <-> rollers: rebuild its ghost on the other rig
// (cheap - cloneRig shares geometry). The snapshot buffer carries over
// so the interpolated motion stays continuous across the swap.
if ((state.l ?? 0) !== g.loco) {
const { lastSeen, spawn, revealed, buf } = g;
removeGhost(peerId);
g = makeGhost(state);
Object.assign(g, { lastSeen, spawn, revealed, buf });
g.rig.placer.visible = revealed;
ghosts.set(peerId, g);
return;
}
if (state.v !== g.variant) {
g.variant = state.v;
applyVariant(g.rig, state.v);
ghostify(g.rig);
}
};
// Same setter-style registration as onMessage.
room.onPeerLeave = (peerId) => removeGhost(peerId);
// Graceful exit so other tabs drop this ghost immediately...
window.addEventListener("pagehide", () => {
try { room.leave(); } catch {}
});
// ...and a staleness sweep for peers that vanished without leaving
// (crashed tab, dropped connection): 5 s without a state packet means
// the peer is gone, not just lagging (even background-throttled tabs
// still send at ~1 Hz).
const STALE_MS = 5000;
// Broadcast + stale sweep on setInterval (not rAF) so both keep running
// in occluded tabs.
setInterval(() => {
const s = getLocalState();
if (s) sendState(s);
const now = performance.now();
for (const [peerId, g] of [...ghosts]) {
if (now - g.lastSeen > STALE_MS) removeGhost(peerId);
}
}, 1000 / SEND_HZ);
// Scratch THREE.Quaternions without importing three: slerp needs real
// instances, cloned here from any node of the already-built local rig.
const _qa = env.rig.placer.quaternion.clone();
const _qb = env.rig.placer.quaternion.clone();
return {
room,
peerCount: () => Object.keys(room.getPeers()).length,
ghostCount: () => ghosts.size,
// Minimap feed: revealed ghosts' arena positions in raw MJCF coords
// (trunk.position is set from qpos before the root's Z-up fix).
mapDots: () =>
[...ghosts.values()]
.filter((g) => g.revealed)
.map((g) => ({ x: g.trunk.position.x, y: g.trunk.position.y })),
debug: () => [...ghosts.values()].map((g) => {
let meshes = 0, visible = 0, op = null, twins = 0;
g.rig.root.traverse((o) => {
if (!o.isMesh) return;
if (o.userData.ghostPrepass) { twins++; return; }
meshes++; if (o.visible) visible++; op ??= o.material.opacity;
});
const w = g.trunk.getWorldPosition(g.trunk.position.clone());
return { p: g.trunk.position.toArray(), world: w.toArray(), inScene: !!g.rig.placer.parent, revealed: g.revealed, meshes, visible, twins, op, v: g.variant };
}),
update() {
const renderT = performance.now() - INTERP_DELAY_MS;
for (const g of ghosts.values()) {
const buf = g.buf;
// Drop snapshots fully in the past; keep [0] and [1] bracketing
// renderT (or the two newest, if renderT has caught up).
while (buf.length > 2 && buf[1].at <= renderT) buf.shift();
const a = buf[0];
const b = buf.length > 1 ? buf[1] : a;
// Clamped: before the segment -> a, past the newest -> hold b.
const span = b.at - a.at;
const u = span > 0 ? Math.min(1, Math.max(0, (renderT - a.at) / span)) : 1;
// Trunk pose in raw MJCF coords: the cloned root applies Z-up -> Y-up.
g.trunk.position.set(
a.p[0] + (b.p[0] - a.p[0]) * u,
a.p[1] + (b.p[1] - a.p[1]) * u,
a.p[2] + (b.p[2] - a.p[2]) * u,
);
_qa.set(a.p[4], a.p[5], a.p[6], a.p[3]); // THREE order x,y,z,w
_qb.set(b.p[4], b.p[5], b.p[6], b.p[3]);
g.trunk.quaternion.copy(_qa.slerp(_qb, u));
for (let i = 0; i < jointNames.length; i++) {
setJoint(g.rig, jointNames[i], a.j[i] + (b.j[i] - a.j[i]) * u);
}
setJawOpen(g.rig, a.w + (b.w - a.w) * u);
}
},
};
}
|