tfrere's picture
tfrere HF Staff
feat: single-page title menu, in-game quickbar and smooth ghosts
b2229dc
Raw
History Blame
13 kB
// 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);
}
},
};
}