| // 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); | |
| } | |
| }, | |
| }; | |
| } | |