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