tfrere HF Staff Cursor commited on
Commit
b2229dc
Β·
1 Parent(s): cfa91ca

feat: single-page title menu, in-game quickbar and smooth ghosts

Browse files

- welcome: one-page menu (vitrine brand lockup, landing-size h1,
controls grid with pad variant), pre-boot spinner veil
- in-game: live colour + legs/rollers quickbar bottom-left, back
button, big mode readout top-right, telemetry stack bottom-right
(peers, speed, odometer, fps, ctrl rate)
- ghosts: snapshot interpolation at 15 Hz replaces the exponential
chase (no more micro-stutter, background tabs degrade gracefully)
- modules landed with this batch: ceremony (entrance/respawn), fx
(wireframe/glitch), controls stack, ball actor, comic stickers

Co-authored-by: Cursor <cursoragent@cursor.com>

README.md CHANGED
@@ -48,6 +48,14 @@ and [apirrone/mjlab_microduck](https://github.com/apirrone/mjlab_microduck).
48
  In roller mode the legs-only actions (kicks, sit) are disabled and their
49
  hints fade out; play the ball by driving into it.
50
 
 
 
 
 
 
 
 
 
51
  The ball is local-only: it lives in your tab's physics and is not shared
52
  with the multiplayer ghosts. A square arena (3 x 3 m) fences the play
53
  area so neither the ball nor the duck can wander off.
 
48
  In roller mode the legs-only actions (kicks, sit) are disabled and their
49
  hints fade out; play the ball by driving into it.
50
 
51
+ ### URL parameters
52
+
53
+ - `?boot=1`: skip the welcome modal and land straight on the BIOS console,
54
+ which then shows the real loading progress live (honest loader) before
55
+ the normal entrance plays. Boot failures (missing asset, policy fetch
56
+ error...) freeze the console on a `SYSTEM HALTED` screen with the error
57
+ detail - handy for debugging.
58
+
59
  The ball is local-only: it lives in your tab's physics and is not shared
60
  with the multiplayer ghosts. A square arena (3 x 3 m) fences the play
61
  area so neither the ball nor the duck can wander off.
assets/microduck-wordmark.svg ADDED
assets/stickers/bang-red.webp ADDED

Git LFS Details

  • SHA256: 9ad4baa5430230a2d5e982c54dac279a86f479ffca3763932b943075a3d9a122
  • Pointer size: 130 Bytes
  • Size of remote file: 44.4 kB
assets/stickers/boom.webp ADDED

Git LFS Details

  • SHA256: 81083452beb521d1e304540534d20525c19f46102c4ef63e951f685cab61bfcc
  • Pointer size: 130 Bytes
  • Size of remote file: 44.3 kB
assets/stickers/burst-yellow.webp ADDED

Git LFS Details

  • SHA256: b08865ed6ad01b10d54e0fb7ef0783c9d709fbe532cf99621ce789f12d213bfd
  • Pointer size: 130 Bytes
  • Size of remote file: 20.2 kB
assets/stickers/quack-quack.webp ADDED

Git LFS Details

  • SHA256: 3608b49bda042808ca69c75bddfed14226867bab741f18b186059ecd3e5e0b08
  • Pointer size: 130 Bytes
  • Size of remote file: 33 kB
assets/stickers/quack.webp ADDED

Git LFS Details

  • SHA256: 289e830372b197078e2d3f9e0ab1d8ae7d941895e73e1037d6b5e7871f1fe64f
  • Pointer size: 130 Bytes
  • Size of remote file: 39.5 kB
assets/stickers/shooting-star.webp ADDED

Git LFS Details

  • SHA256: f31230606d12fc64af7c4d6a39efb339e36ae81eb79caa297ec4e0ebc7c073c5
  • Pointer size: 130 Bytes
  • Size of remote file: 26.2 kB
assets/stickers/star-yellow.webp ADDED

Git LFS Details

  • SHA256: 93ee62a3e5e697c4dc7af15a00a24919c81c940f56c53cc730caf9eae110cf77
  • Pointer size: 130 Bytes
  • Size of remote file: 10.5 kB
assets/stickers/wave.webp ADDED

Git LFS Details

  • SHA256: eb115e0f06dcc7d6e86b63052bb52e5d2568186138cff09d801106cc5ab6d607
  • Pointer size: 130 Bytes
  • Size of remote file: 41.6 kB
assets/stickers/ziouuu.webp ADDED

Git LFS Details

  • SHA256: 737f020f74e1cbe3b0b0d9e58de38cf5c5ea0be8b5ee872313aad7cb0673b441
  • Pointer size: 130 Bytes
  • Size of remote file: 36 kB
ball-actor.js ADDED
@@ -0,0 +1,102 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ // Ball actor: physics stays in rl.js, this owns the mesh + wireframe FX.
2
+ //
3
+ // visual:
4
+ // hidden parked, mesh off
5
+ // live following qpos, optional appear scan playing
6
+ // ghost physics already parked; mesh frozen at last pose while the
7
+ // reverse scan peels it away
8
+ //
9
+ // spawn while live/ghost queues a pop: despawn finishes, then the caller
10
+ // places physics and appear()s.
11
+
12
+ // Appear/disappear speed. The shared wireframe FX timeline lasts
13
+ // fx.TOTAL_S (0.9 s, tuned for the duck's ceremony); the ball plays the
14
+ // same timeline time-scaled to these snappier durations.
15
+ const BALL_APPEAR_S = 0.2; // spawn pop-in (scan up)
16
+ const BALL_DISAPPEAR_S = 0.15; // despawn/park peel-away (reverse scan)
17
+
18
+ export function createBallActor({ THREE, scene, camera, renderer, fxModule, mesh, group }) {
19
+ const fx = fxModule.createWireframeFx();
20
+ fx.init({ THREE, scene, root: group, camera, renderer, hidden: false });
21
+
22
+ let visual = "hidden"; // hidden | live | ghost
23
+ let ghost = null;
24
+ let pendingSpawn = false;
25
+ let fxPrev = null;
26
+
27
+ function appear() {
28
+ visual = "live";
29
+ ghost = null;
30
+ mesh.visible = true;
31
+ fx.start();
32
+ fxPrev = performance.now();
33
+ }
34
+
35
+ function despawn({ cancelQueued = false, parkPhysics }) {
36
+ if (cancelQueued) pendingSpawn = false;
37
+ if (visual === "hidden") return;
38
+ if (visual === "live") {
39
+ ghost = {
40
+ pos: mesh.position.clone(),
41
+ quat: mesh.quaternion.clone(),
42
+ };
43
+ visual = "ghost";
44
+ }
45
+ parkPhysics();
46
+ fx.startReverse();
47
+ fxPrev = performance.now();
48
+ }
49
+
50
+ function queueRespawn() {
51
+ pendingSpawn = true;
52
+ }
53
+
54
+ function poseFromQpos(qpos, adr) {
55
+ mesh.position.set(qpos[adr], qpos[adr + 1], qpos[adr + 2]);
56
+ mesh.quaternion.set(qpos[adr + 4], qpos[adr + 5], qpos[adr + 6], qpos[adr + 3]);
57
+ }
58
+
59
+ function sync(qpos, adr, physicsLive) {
60
+ if (visual === "ghost" && ghost) {
61
+ mesh.visible = true;
62
+ mesh.position.copy(ghost.pos);
63
+ mesh.quaternion.copy(ghost.quat);
64
+ } else if (physicsLive) {
65
+ mesh.visible = true;
66
+ poseFromQpos(qpos, adr);
67
+ } else {
68
+ mesh.visible = false;
69
+ }
70
+ }
71
+
72
+ function drive(onQueuedSpawn) {
73
+ if (fx.playing) {
74
+ const now = performance.now();
75
+ if (fxPrev === null) fxPrev = now;
76
+ const dt = Math.min((now - fxPrev) / 1000, 0.25);
77
+ fxPrev = now;
78
+ // Time-scale the FX so the ball pops in/out at its own speed.
79
+ const speed = fx.TOTAL_S / (fx.reversing ? BALL_DISAPPEAR_S : BALL_APPEAR_S);
80
+ fx.update(dt * speed);
81
+ }
82
+ if (visual === "ghost" && fx.isDone()) {
83
+ mesh.visible = false;
84
+ visual = "hidden";
85
+ ghost = null;
86
+ fx.restore();
87
+ const queued = pendingSpawn;
88
+ pendingSpawn = false;
89
+ if (queued) onQueuedSpawn();
90
+ }
91
+ }
92
+
93
+ return {
94
+ appear,
95
+ despawn,
96
+ queueRespawn,
97
+ poseFromQpos,
98
+ sync,
99
+ drive,
100
+ get visual() { return visual; },
101
+ };
102
+ }
ceremony.js ADDED
@@ -0,0 +1,171 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ // Cutscene layer: page-load entrance and every later respawn.
2
+ //
3
+ // Physics (resetSim) snaps the duck to the stand keyframe; this module
4
+ // owns what the player sees around that snap. Two mutually exclusive
5
+ // timelines share the duck's wireframe FX:
6
+ //
7
+ // entrance (once) grid + walls draw in, then the duck scans up
8
+ // respawn (every kill / Space / loco switch)
9
+ // 0.00 duck clipped away, camera glides home from the kill view
10
+ // 0.80 camera almost settled -> duck scan-up cues
11
+ // 0.90 camera lands
12
+ // 1.70 scan done, inputs unlock
13
+ //
14
+ // rl.js injects the world (rig, grid, camera reset) and the lock/flash
15
+ // callbacks; this file never touches MuJoCo.
16
+
17
+ export const CAM_RESET_S = 0.9;
18
+ export const RESPAWN_SCAN_AT = 0.8; // cue scan-up at 80% of the camera glide
19
+
20
+ const ENTRANCE_GRID_S = 1.1;
21
+ const ENTRANCE_WALL_DELAY_S = 0.35;
22
+ const ENTRANCE_WALL_S = 0.9;
23
+ const ENTRANCE_FX_START_S = 0.6;
24
+
25
+ const clamp01 = (x) => Math.min(Math.max(x, 0), 1);
26
+
27
+ export function createCeremony({
28
+ THREE, scene, camera, renderer, fx,
29
+ getRig, grid, wallMats,
30
+ syncRig, startCameraReset,
31
+ setLocked, flashReset,
32
+ }) {
33
+ const ENTRANCE_TOTAL_S = Math.max(
34
+ ENTRANCE_GRID_S,
35
+ ENTRANCE_WALL_DELAY_S + ENTRANCE_WALL_S,
36
+ ENTRANCE_FX_START_S + fx.TOTAL_S,
37
+ );
38
+
39
+ let fxRig = null;
40
+ let fxPrev = null;
41
+ let entranceT0 = null;
42
+ let entranceFxCued = false;
43
+ let entranceDone = false;
44
+ let respawn = null; // { t0, scanCued } | null
45
+ let entranceFinishedResolve;
46
+ const entranceFinished = new Promise((r) => { entranceFinishedResolve = r; });
47
+
48
+ function bind() {
49
+ const rig = getRig();
50
+ if (fxRig === rig) return;
51
+ fx.dispose();
52
+ fx.init({ THREE, scene, rig, camera, renderer });
53
+ rig.placer.traverse((o) => {
54
+ if (o.isMesh && !o.userData.meshName) o.userData.fxOverlay = true;
55
+ });
56
+ fxRig = rig;
57
+ }
58
+
59
+ function fxStart() {
60
+ bind();
61
+ fx.start();
62
+ fxPrev = performance.now();
63
+ }
64
+ function fxDrive() {
65
+ if (fxPrev === null || fx.isDone()) return;
66
+ const now = performance.now();
67
+ const dt = Math.min((now - fxPrev) / 1000, 0.25);
68
+ fxPrev = now;
69
+ fx.update(dt);
70
+ }
71
+ function fxForceFinish() {
72
+ if (!fx.isDone()) fx.update(1e3);
73
+ }
74
+
75
+ function startEntrance() {
76
+ if (entranceT0 !== null || entranceDone) return;
77
+ entranceT0 = performance.now();
78
+ entranceFxCued = false;
79
+ grid.material.uniforms.uReveal.value = 0;
80
+ for (const m of wallMats) m.uniforms.uReveal.value = 0;
81
+ }
82
+
83
+ function driveEntrance() {
84
+ if (entranceT0 === null) return;
85
+ const t = (performance.now() - entranceT0) / 1000;
86
+ grid.material.uniforms.uReveal.value = clamp01(t / ENTRANCE_GRID_S);
87
+ const wallR = clamp01((t - ENTRANCE_WALL_DELAY_S) / ENTRANCE_WALL_S);
88
+ for (const m of wallMats) m.uniforms.uReveal.value = wallR;
89
+ if (t >= ENTRANCE_FX_START_S && !entranceFxCued) {
90
+ entranceFxCued = true;
91
+ fxStart();
92
+ }
93
+ fxDrive();
94
+ if (t >= ENTRANCE_TOTAL_S) {
95
+ if (!fx.isDone() && t < ENTRANCE_TOTAL_S + 0.5) return;
96
+ fxForceFinish();
97
+ entranceT0 = null;
98
+ entranceDone = true;
99
+ grid.material.uniforms.uReveal.value = 1;
100
+ for (const m of wallMats) m.uniforms.uReveal.value = 1;
101
+ setLocked(false);
102
+ entranceFinishedResolve();
103
+ }
104
+ }
105
+
106
+ function playRespawn() {
107
+ if (!entranceDone) return;
108
+ setLocked(true);
109
+ flashReset();
110
+ bind();
111
+ startCameraReset();
112
+ syncRig();
113
+ fx.setProgress(0);
114
+ respawn = { t0: performance.now(), scanCued: false };
115
+ }
116
+
117
+ function driveRespawn() {
118
+ if (!respawn) return;
119
+ const u = (performance.now() - respawn.t0) / 1000 / CAM_RESET_S;
120
+ if (!respawn.scanCued && u >= RESPAWN_SCAN_AT) {
121
+ respawn.scanCued = true;
122
+ fxStart();
123
+ }
124
+ if (respawn.scanCued) fxDrive();
125
+ if (respawn.scanCued && fx.isDone()) {
126
+ respawn = null;
127
+ setLocked(false);
128
+ }
129
+ }
130
+
131
+ function drive() {
132
+ driveEntrance();
133
+ driveRespawn();
134
+ }
135
+
136
+ function setReveal(floor, wall = floor) {
137
+ entranceT0 = null;
138
+ grid.material.uniforms.uReveal.value = floor;
139
+ for (const m of wallMats) m.uniforms.uReveal.value = wall;
140
+ }
141
+
142
+ function setFx(p) {
143
+ entranceT0 = null;
144
+ respawn = null;
145
+ bind();
146
+ if (p >= 1) {
147
+ fx.start();
148
+ fxForceFinish();
149
+ setLocked(false);
150
+ entranceFinishedResolve();
151
+ } else {
152
+ fx.setProgress(p);
153
+ }
154
+ }
155
+
156
+ // Boot hidden: clip parked below the feet from the first frame.
157
+ bind();
158
+
159
+ return {
160
+ CAM_RESET_S,
161
+ startEntrance,
162
+ playRespawn,
163
+ drive,
164
+ bind,
165
+ setReveal,
166
+ setFx,
167
+ entranceFinished,
168
+ get entranceDone() { return entranceDone; },
169
+ get respawnActive() { return respawn !== null; },
170
+ };
171
+ }
controls/controller.js ADDED
@@ -0,0 +1,188 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ // Controller: the video-game-style input core. Aggregates pluggable input
2
+ // SOURCES (keyboard, gamepad, later touch) into one game-facing surface:
3
+ //
4
+ // - a continuous COMMAND: the [vx, vy, wz] twist the policy tracks, plus
5
+ // auxiliary analog AXES (jaw 0..1, camera-orbit rates -1..1);
6
+ // - discrete ACTIONS: edge-triggered events (roll, kicks, ball spawn...)
7
+ // dispatched to subscribers the moment a source fires them;
8
+ // - HUD support: per-source pressed-state snapshots and activity info so
9
+ // the hint keycaps can light up per physical control and per device.
10
+ //
11
+ // The game code (rl.js) instantiates the sources, registers them here,
12
+ // calls update(dt) once per render frame, reads getCommand()/getAxes()
13
+ // where it builds the policy observation, and subscribes its trigger
14
+ // functions with on(action, cb).
15
+ //
16
+ // ── Source interface contract ────────────────────────────────────────────
17
+ // Every input source module (keyboard.js, gamepad.js, a future touch.js
18
+ // with a virtual joystick + buttons) implements:
19
+ //
20
+ // id Unique string ("keyboard", "gamepad", "touch"...). Action
21
+ // subscribers receive it as meta.source, so game code can
22
+ // attribute HUD flashes / keycap lighting to the right device.
23
+ // connected Boolean hardware presence. Keyboard: always true. Gamepad:
24
+ // a pad is currently reported by navigator.getGamepads().
25
+ // command Float32Array(3) [vx, vy, wz], ALREADY scaled to the game's
26
+ // velocity limits (sources take a getVelocityLimits() callback
27
+ // returning [fwd, back, ang], so limits can change at runtime,
28
+ // e.g. the legs <-> rollers switch). Must be a stable array
29
+ // reference updated in place: getCommand() hands it out
30
+ // without copying, and the control loop reads it between
31
+ // render frames.
32
+ // axes { jaw, orbitX, orbitY } - auxiliary continuous channels.
33
+ // jaw in [0, 1] (mouth opening), orbit axes in [-1, 1]
34
+ // (camera orbit rate; the inertia/smoothing lives downstream
35
+ // in the camera code, sources report raw deflection). Stable
36
+ // object reference, updated in place. Channels a source does
37
+ // not drive stay 0.
38
+ // pressed Plain object of booleans (stable reference) mirroring which
39
+ // physical controls are currently down. HUD highlighting
40
+ // only - never game logic.
41
+ // isActive() Whether the source currently claims authority over the
42
+ // continuous command. Keyboard: any move key held. Gamepad:
43
+ // stick deflected, and it keeps the claim until its smoothed
44
+ // command settles back to ~zero.
45
+ // init() Attach event listeners / hardware hooks. Called by
46
+ // Controller.init(), NOT at construction (rl.js constructs
47
+ // the sources early but arms the listeners at the same point
48
+ // in the boot where they historically went live).
49
+ // dispose() Detach everything init() attached.
50
+ // poll(dt) Per-frame tick (dt in seconds, clamped by the caller).
51
+ // Read the hardware, update command/axes/pressed in place,
52
+ // and fire edge-triggered actions via this.onAction(name).
53
+ // onAction (name, meta?) => void, assigned by the Controller at
54
+ // registration. Sources may call it from poll() (gamepad
55
+ // button edges) or straight from event handlers (keyboard:
56
+ // keeps the historical press-to-effect latency).
57
+ //
58
+ // ── Arbitration ──────────────────────────────────────────────────────────
59
+ // Continuous command: sources are registered in PRIORITY order (first =
60
+ // highest). Each frame the first source reporting isActive() owns the
61
+ // twist; when none is active the LAST registered source's command is used
62
+ // as the fallback (it reads zero when idle). With [gamepad, keyboard] this
63
+ // reproduces the historical `padActive ? padCmd : velCmd` exactly: live
64
+ // sticks win over held keys, and the keyboard takes back over once the
65
+ // pad's smoothed command has settled.
66
+ // Aux axes are merged across ALL sources regardless of who owns the twist
67
+ // (the pad triggers drive the jaw even while walking on the keyboard):
68
+ // jaw = max over sources, orbit = largest-magnitude value per axis.
69
+ //
70
+ // ── Actions ──────────────────────────────────────────────────────────────
71
+ // roll one-shot roll (crouch-glide in roller mode; game decides)
72
+ // kickL, kickR one-shot kicks, explicit foot
73
+ // alternateKick one-shot kick, feet alternated by the game
74
+ // spawnBall pop / respawn the kickable ball
75
+ // sitToggle sit <-> stand (game gates it to legs mode)
76
+ // locoToggle legs <-> rollers switch
77
+ // chaseToggle chase camera on/off
78
+ // reset full sim reset (Space)
79
+ // walk back to the walk/run mode (pad DpadUp short press)
80
+ // quack chirp + jaw flap (pad RT edge, Schmitt-triggered)
81
+ //
82
+ // ── Input lock ───────────────────────────────────────────────────────────
83
+ // setLocked(true) zeroes getCommand() - the twist gate used while the
84
+ // entrance/respawn ceremony plays. Discrete actions still dispatch: each
85
+ // game trigger applies its own lock policy (e.g. Space-reset and the
86
+ // chase-cam toggle historically work while locked, kicks don't).
87
+
88
+ const ZERO_CMD = new Float32Array(3);
89
+
90
+ export class Controller {
91
+ #sources = [];
92
+ #listeners = new Map(); // action -> Set(cb)
93
+ #locked = false;
94
+ #axes = { jaw: 0, orbitX: 0, orbitY: 0 };
95
+
96
+ constructor({ sources = [] } = {}) {
97
+ for (const s of sources) this.addSource(s);
98
+ }
99
+
100
+ // Register in priority order (first registered wins arbitration ties).
101
+ addSource(source) {
102
+ source.onAction = (action, meta) =>
103
+ this.#dispatch(action, { source: source.id, ...meta });
104
+ this.#sources.push(source);
105
+ }
106
+
107
+ // Read-only source list, for advanced per-source queries the merged view
108
+ // can't answer (e.g. "is ANY source commanding a turn right now?").
109
+ get sources() {
110
+ return this.#sources;
111
+ }
112
+
113
+ // Arm every source's listeners/hardware hooks.
114
+ init() {
115
+ for (const s of this.#sources) s.init?.();
116
+ }
117
+
118
+ dispose() {
119
+ for (const s of this.#sources) s.dispose?.();
120
+ }
121
+
122
+ // Per-frame tick: poll every source (they fire their edge actions from
123
+ // inside poll), then merge the aux axes.
124
+ update(dt) {
125
+ for (const s of this.#sources) s.poll?.(dt);
126
+ let jaw = 0, ox = 0, oy = 0;
127
+ for (const s of this.#sources) {
128
+ const a = s.axes;
129
+ if (!a) continue;
130
+ jaw = Math.max(jaw, a.jaw ?? 0);
131
+ if (Math.abs(a.orbitX ?? 0) > Math.abs(ox)) ox = a.orbitX;
132
+ if (Math.abs(a.orbitY ?? 0) > Math.abs(oy)) oy = a.orbitY;
133
+ }
134
+ this.#axes.jaw = jaw;
135
+ this.#axes.orbitX = ox;
136
+ this.#axes.orbitY = oy;
137
+ }
138
+
139
+ setLocked(v) {
140
+ this.#locked = !!v;
141
+ }
142
+
143
+ get locked() {
144
+ return this.#locked;
145
+ }
146
+
147
+ // Merged continuous twist [vx, vy, wz] (see the arbitration notes above).
148
+ // Returns live source arrays without copying - treat as read-only.
149
+ getCommand() {
150
+ if (this.#locked) return ZERO_CMD;
151
+ for (const s of this.#sources) if (s.isActive()) return s.command;
152
+ const fallback = this.#sources[this.#sources.length - 1];
153
+ return fallback ? fallback.command : ZERO_CMD;
154
+ }
155
+
156
+ // Merged aux axes { jaw, orbitX, orbitY }, refreshed by update().
157
+ getAxes() {
158
+ return this.#axes;
159
+ }
160
+
161
+ // Any source claiming twist authority (HUD "user is driving" signal).
162
+ anyActive() {
163
+ return this.#sources.some((s) => s.isActive());
164
+ }
165
+
166
+ // Per-source pressed snapshots for HUD keycap highlighting:
167
+ // { keyboard: {...}, gamepad: {...} }.
168
+ getPressed() {
169
+ const out = {};
170
+ for (const s of this.#sources) out[s.id] = s.pressed ?? {};
171
+ return out;
172
+ }
173
+
174
+ // Subscribe to a discrete action; cb(meta) with meta.source = source id.
175
+ // Returns an unsubscribe function.
176
+ on(action, cb) {
177
+ let set = this.#listeners.get(action);
178
+ if (!set) this.#listeners.set(action, (set = new Set()));
179
+ set.add(cb);
180
+ return () => set.delete(cb);
181
+ }
182
+
183
+ #dispatch(action, meta) {
184
+ const set = this.#listeners.get(action);
185
+ if (!set) return;
186
+ for (const cb of set) cb(meta);
187
+ }
188
+ }
controls/gamepad.js ADDED
@@ -0,0 +1,153 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ // Gamepad input source (see controller.js for the source interface
2
+ // contract). Same mapping as the robot runtime (microduck_runtime):
3
+ //
4
+ // Left stick vertical = vx (asymmetric fwd/back), horizontal = turn,
5
+ // EMA-smoothed like the runtime's cmd_alpha.
6
+ // Right stick camera orbit rate (reported raw in axes.orbitX/Y; the
7
+ // velocity smoothing / coasting lives downstream in the
8
+ // camera code). R3 toggles the chase cam.
9
+ // X roll (crouch-glide in roller mode - game decides)
10
+ // Y ball spawn/respawn
11
+ // RB / LB right / left kick
12
+ // DpadDown sit <-> stand
13
+ // DpadUp short press = back to run; HOLD ~1 s = legs <-> rollers
14
+ // (like the robot's 3 s hold, shortened for the web)
15
+ // RT / LT analog jaw (max of both); RT edge quacks through a
16
+ // Schmitt trigger (fire at >= 0.35, re-arm below 0.2 - a
17
+ // single threshold re-fires on jitter, which is how one
18
+ // squeeze used to quack several times).
19
+
20
+ const PAD_DEADZONE = 0.15;
21
+ const PAD_ALPHA = 0.12; // EMA smoothing toward the stick target
22
+ const dz = (v) => (Math.abs(v) < PAD_DEADZONE ? 0 : v);
23
+
24
+ // Standard-mapping button indices.
25
+ const BTN_X = 2, BTN_Y = 3, BTN_LB = 4, BTN_RB = 5, BTN_LT = 6, BTN_RT = 7;
26
+ const BTN_R3 = 11, BTN_DPAD_UP = 12, BTN_DPAD_DOWN = 13;
27
+
28
+ const DPAD_UP_HOLD_MS = 1000; // hold-to-switch-loco duration
29
+
30
+ export class GamepadSource {
31
+ id = "gamepad";
32
+ connected = false;
33
+ command = new Float32Array(3); // [vx, 0, wz], EMA-smoothed
34
+ axes = { jaw: 0, orbitX: 0, orbitY: 0 };
35
+ pressed = {
36
+ x: false, y: false, rb: false, lb: false, r3: false,
37
+ dpadDown: false, dpadUp: false,
38
+ };
39
+ onAction = () => {}; // assigned by the Controller at registration
40
+
41
+ #getVelocityLimits;
42
+ #active = false; // owns twist authority (stick input, until EMA settles)
43
+ #dpadUpAt = 0; // wall-clock of the current DpadUp press
44
+ #dpadUpFired = false; // latch: one loco switch per hold
45
+ #rtArmed = true; // Schmitt trigger state for the RT quack
46
+
47
+ constructor({ getVelocityLimits }) {
48
+ this.#getVelocityLimits = getVelocityLimits;
49
+ }
50
+
51
+ init() {} // poll-based: nothing to attach
52
+ dispose() {}
53
+
54
+ isActive() {
55
+ return this.#active;
56
+ }
57
+
58
+ poll() {
59
+ const prev = this.pressed;
60
+ const gp = [...(navigator.getGamepads?.() ?? [])].find((p) => p && p.connected);
61
+ this.connected = !!gp;
62
+ if (!gp) {
63
+ if (this.#active) {
64
+ this.#active = false;
65
+ this.command.fill(0);
66
+ this.axes.jaw = 0;
67
+ }
68
+ this.axes.orbitX = 0;
69
+ this.axes.orbitY = 0;
70
+ return;
71
+ }
72
+ const now = performance.now();
73
+
74
+ // Left stick only: vertical = forward/back, horizontal = turn.
75
+ // (No strafe; the right stick doesn't drive movement.)
76
+ const lx = dz(gp.axes[0] ?? 0), ly = dz(gp.axes[1] ?? 0);
77
+ const up = -ly; // browser sticks report up as -1
78
+ const [limF, limB, limA] = this.#getVelocityLimits();
79
+ const target = [
80
+ up >= 0 ? up * limF : up * -limB,
81
+ 0,
82
+ -lx * limA,
83
+ ];
84
+ for (let i = 0; i < 3; i++) this.command[i] += PAD_ALPHA * (target[i] - this.command[i]);
85
+ // Sticks grab command authority on first input, release when back at
86
+ // rest (then the keyboard takes over again through the Controller's
87
+ // arbitration).
88
+ const stickInput = lx !== 0 || ly !== 0;
89
+ if (stickInput) this.#active = true;
90
+ else if (
91
+ this.#active &&
92
+ Math.abs(this.command[0]) + Math.abs(this.command[1]) + Math.abs(this.command[2]) < 0.01
93
+ ) {
94
+ this.#active = false;
95
+ this.command.fill(0);
96
+ }
97
+
98
+ // Right stick: raw (deadzoned) orbit rate. Reported every frame - the
99
+ // downstream camera step needs the zeros too so a released stick
100
+ // coasts to a stop.
101
+ this.axes.orbitX = dz(gp.axes[2] ?? 0);
102
+ this.axes.orbitY = dz(gp.axes[3] ?? 0);
103
+
104
+ // R3 (right stick click): chase-cam toggle, gamepad twin of KeyC.
105
+ const r3 = !!gp.buttons[BTN_R3]?.pressed;
106
+ if (r3 && !prev.r3) this.onAction("chaseToggle");
107
+ prev.r3 = r3;
108
+
109
+ const x = !!gp.buttons[BTN_X]?.pressed;
110
+ if (x && !prev.x) this.onAction("roll");
111
+ prev.x = x;
112
+
113
+ const y = !!gp.buttons[BTN_Y]?.pressed;
114
+ if (y && !prev.y) this.onAction("spawnBall");
115
+ prev.y = y;
116
+
117
+ const rb = !!gp.buttons[BTN_RB]?.pressed;
118
+ if (rb && !prev.rb) this.onAction("kickR");
119
+ prev.rb = rb;
120
+ const lb = !!gp.buttons[BTN_LB]?.pressed;
121
+ if (lb && !prev.lb) this.onAction("kickL");
122
+ prev.lb = lb;
123
+
124
+ const dpadDown = !!gp.buttons[BTN_DPAD_DOWN]?.pressed;
125
+ if (dpadDown && !prev.dpadDown) this.onAction("sitToggle");
126
+ prev.dpadDown = dpadDown;
127
+
128
+ // DpadUp: short press = back to run; hold fires ONE loco switch.
129
+ const dpadUp = !!gp.buttons[BTN_DPAD_UP]?.pressed;
130
+ if (dpadUp && !prev.dpadUp) {
131
+ this.#dpadUpAt = now;
132
+ this.#dpadUpFired = false;
133
+ this.onAction("walk");
134
+ }
135
+ if (dpadUp && !this.#dpadUpFired && now - this.#dpadUpAt >= DPAD_UP_HOLD_MS) {
136
+ this.#dpadUpFired = true; // latch: one switch per hold
137
+ this.onAction("locoToggle");
138
+ }
139
+ prev.dpadUp = dpadUp;
140
+
141
+ // Triggers drive the mouth (max of both); RT quacks on its
142
+ // Schmitt-triggered rising edge.
143
+ const rt = gp.buttons[BTN_RT]?.value ?? 0;
144
+ const lt = gp.buttons[BTN_LT]?.value ?? 0;
145
+ this.axes.jaw = Math.max(rt, lt);
146
+ if (this.#rtArmed && rt >= 0.35) {
147
+ this.onAction("quack");
148
+ this.#rtArmed = false;
149
+ } else if (!this.#rtArmed && rt < 0.2) {
150
+ this.#rtArmed = true;
151
+ }
152
+ }
153
+ }
controls/keyboard.js ADDED
@@ -0,0 +1,116 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ // Keyboard input source (see controller.js for the source interface
2
+ // contract). Hold-to-command movement keys plus one-shot action keys.
3
+ //
4
+ // e.code is the PHYSICAL key position, so one map covers QWERTY and AZERTY:
5
+ // arrows / WASD (ZQSD) run + turn. No strafe - vy stays 0.
6
+
7
+ const KEYMAP = {
8
+ ArrowUp: "fwd", KeyW: "fwd",
9
+ ArrowDown: "back", KeyS: "back",
10
+ ArrowLeft: "turnl", KeyA: "turnl",
11
+ ArrowRight: "turnr", KeyD: "turnr",
12
+ };
13
+
14
+ // One-shot keys -> controller actions. Physical Q/E (A/E on AZERTY) are the
15
+ // explicit left / right kicks, mirroring the pad's LB / RB; F alternates.
16
+ const ACTION_KEYS = {
17
+ KeyB: "spawnBall",
18
+ KeyC: "chaseToggle",
19
+ KeyM: "locoToggle",
20
+ KeyR: "roll",
21
+ KeyQ: "kickL",
22
+ KeyE: "kickR",
23
+ KeyF: "alternateKick",
24
+ };
25
+
26
+ export class KeyboardSource {
27
+ id = "keyboard";
28
+ connected = true; // a keyboard is always assumed present
29
+ command = new Float32Array(3); // [vx, 0, wz], scaled to velocity limits
30
+ axes = { jaw: 0, orbitX: 0, orbitY: 0 }; // keyboard drives none of these
31
+ pressed = { fwd: false, back: false, turnl: false, turnr: false };
32
+ onAction = () => {}; // assigned by the Controller at registration
33
+
34
+ #held = new Set();
35
+ #getVelocityLimits;
36
+ // Limits snapshot so poll() only re-maps held keys when they actually
37
+ // change (the legs <-> rollers switch); event handlers do the live work.
38
+ #lims = [NaN, NaN, NaN];
39
+ #onKeyDown = null;
40
+ #onKeyUp = null;
41
+ #onBlur = null;
42
+
43
+ constructor({ getVelocityLimits }) {
44
+ this.#getVelocityLimits = getVelocityLimits;
45
+ }
46
+
47
+ init() {
48
+ this.#onKeyDown = (e) => {
49
+ if (e.repeat) return;
50
+ // Space always resets, and must not scroll the page.
51
+ if (e.code === "Space") {
52
+ e.preventDefault();
53
+ this.onAction("reset");
54
+ return;
55
+ }
56
+ const action = ACTION_KEYS[e.code];
57
+ if (action) {
58
+ this.onAction(action);
59
+ return;
60
+ }
61
+ const move = KEYMAP[e.code];
62
+ if (!move) return;
63
+ e.preventDefault();
64
+ this.#held.add(move);
65
+ this.#refresh();
66
+ };
67
+ this.#onKeyUp = (e) => {
68
+ const move = KEYMAP[e.code];
69
+ if (!move) return;
70
+ this.#held.delete(move);
71
+ this.#refresh();
72
+ };
73
+ // Losing window focus drops every held key (keyup events are missed).
74
+ this.#onBlur = () => {
75
+ this.#held.clear();
76
+ this.#refresh();
77
+ };
78
+ window.addEventListener("keydown", this.#onKeyDown);
79
+ window.addEventListener("keyup", this.#onKeyUp);
80
+ window.addEventListener("blur", this.#onBlur);
81
+ }
82
+
83
+ dispose() {
84
+ window.removeEventListener("keydown", this.#onKeyDown);
85
+ window.removeEventListener("keyup", this.#onKeyUp);
86
+ window.removeEventListener("blur", this.#onBlur);
87
+ }
88
+
89
+ // Claims twist authority while any movement key is held.
90
+ isActive() {
91
+ return this.#held.size > 0;
92
+ }
93
+
94
+ // The command is event-driven (recomputed on keydown/keyup/blur, so a
95
+ // press lands the same tick, not on the next frame); poll only re-maps
96
+ // held keys onto fresh velocity limits after a locomotion switch.
97
+ poll() {
98
+ const [limF, limB, limA] = this.#getVelocityLimits();
99
+ if (limF !== this.#lims[0] || limB !== this.#lims[1] || limA !== this.#lims[2]) {
100
+ this.#refresh();
101
+ }
102
+ }
103
+
104
+ #refresh() {
105
+ const lims = this.#getVelocityLimits();
106
+ const [limF, limB, limA] = lims;
107
+ this.#lims = lims;
108
+ const held = this.#held;
109
+ this.command[0] = held.has("fwd") ? limF : held.has("back") ? limB : 0;
110
+ this.command[2] = held.has("turnl") ? limA : held.has("turnr") ? -limA : 0;
111
+ this.pressed.fwd = held.has("fwd");
112
+ this.pressed.back = held.has("back");
113
+ this.pressed.turnl = held.has("turnl");
114
+ this.pressed.turnr = held.has("turnr");
115
+ }
116
+ }
fx/demo-glitch.html ADDED
@@ -0,0 +1,140 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ <!doctype html>
2
+ <html lang="en">
3
+ <head>
4
+ <meta charset="utf-8" />
5
+ <meta name="viewport" content="width=device-width, initial-scale=1" />
6
+ <title>fx demo - glitch materialization</title>
7
+ <style>
8
+ html, body { margin: 0; height: 100%; background: #0b0d10; overflow: hidden; }
9
+ #app { position: fixed; inset: 0; }
10
+ #replay {
11
+ position: fixed; top: 16px; left: 16px; z-index: 10;
12
+ font: 700 13px/1 "SF Mono", ui-monospace, Menlo, monospace;
13
+ letter-spacing: 0.14em;
14
+ color: #ff7a2f; background: rgba(11, 13, 16, 0.85);
15
+ border: 1px solid #ff7a2f; border-radius: 4px;
16
+ padding: 10px 18px; cursor: pointer;
17
+ text-shadow: 0 0 8px rgba(255, 122, 47, 0.5);
18
+ }
19
+ #replay:hover { background: rgba(255, 122, 47, 0.15); box-shadow: 0 0 14px rgba(255, 122, 47, 0.35); }
20
+ #replay:active { transform: translateY(1px); }
21
+ #status {
22
+ position: fixed; bottom: 14px; left: 16px; z-index: 10;
23
+ font: 11px/1.4 ui-monospace, Menlo, monospace;
24
+ color: #3d4652; letter-spacing: 0.08em; user-select: none;
25
+ }
26
+ </style>
27
+ <script type="importmap">
28
+ {
29
+ "imports": {
30
+ "three": "https://cdn.jsdelivr.net/npm/three@0.164.1/build/three.module.js",
31
+ "three/addons/": "https://cdn.jsdelivr.net/npm/three@0.164.1/examples/jsm/"
32
+ }
33
+ }
34
+ </script>
35
+ </head>
36
+ <body>
37
+ <div id="app"></div>
38
+ <button id="replay" type="button" aria-label="Replay the glitch materialization">REPLAY</button>
39
+ <div id="status">loading rig...</div>
40
+ <script type="module">
41
+ import * as THREE from "three";
42
+ import { OrbitControls } from "three/addons/controls/OrbitControls.js";
43
+ import { RoomEnvironment } from "three/addons/environments/RoomEnvironment.js";
44
+ import { loadKinematics, buildRig, groundFullBody } from "../duck.js";
45
+ import { VARIANTS, materialHookFor } from "../variants.js";
46
+ import * as fx from "./fx-glitch.js";
47
+
48
+ const mount = document.getElementById("app");
49
+ const statusEl = document.getElementById("status");
50
+
51
+ const scene = new THREE.Scene();
52
+ scene.background = new THREE.Color(0x0b0d10);
53
+
54
+ const camera = new THREE.PerspectiveCamera(40, 1, 0.02, 30);
55
+ camera.position.set(0.42, 0.22, 0.5);
56
+
57
+ const renderer = new THREE.WebGLRenderer({ antialias: true });
58
+ renderer.setPixelRatio(Math.min(2, window.devicePixelRatio));
59
+ renderer.toneMapping = THREE.ACESFilmicToneMapping;
60
+ mount.appendChild(renderer.domElement);
61
+
62
+ const pmrem = new THREE.PMREMGenerator(renderer);
63
+ scene.environment = pmrem.fromScene(new RoomEnvironment()).texture;
64
+ scene.environmentIntensity = 0.45;
65
+
66
+ // Same light rig as the live playground.
67
+ scene.add(new THREE.AmbientLight(0xffffff, 0.6));
68
+ const keyLight = new THREE.DirectionalLight(0xffffff, 1.6);
69
+ keyLight.position.set(2, 4, 2);
70
+ scene.add(keyLight);
71
+ const fill = new THREE.DirectionalLight(0xffffff, 0.4);
72
+ fill.position.set(-2, 2, 1.5);
73
+ scene.add(fill);
74
+ const rim = new THREE.DirectionalLight(0xffb366, 0.7);
75
+ rim.position.set(0, 3, -2);
76
+ scene.add(rim);
77
+
78
+ // Ground: dark disc + faint grid.
79
+ const ground = new THREE.Mesh(
80
+ new THREE.CircleGeometry(1.4, 64).rotateX(-Math.PI / 2),
81
+ new THREE.MeshStandardMaterial({ color: 0x11141a, roughness: 0.95, metalness: 0 }),
82
+ );
83
+ scene.add(ground);
84
+ const grid = new THREE.GridHelper(2.4, 48, 0x232a33, 0x161b22);
85
+ grid.position.y = 0.001;
86
+ scene.add(grid);
87
+
88
+ const controls = new OrbitControls(camera, renderer.domElement);
89
+ controls.enableDamping = true;
90
+ controls.target.set(0, 0.1, 0);
91
+ controls.minDistance = 0.15;
92
+ controls.maxDistance = 3;
93
+
94
+ function resize() {
95
+ const w = mount.clientWidth, h = mount.clientHeight;
96
+ camera.aspect = w / h;
97
+ camera.updateProjectionMatrix();
98
+ renderer.setSize(w, h);
99
+ }
100
+ window.addEventListener("resize", resize);
101
+ resize();
102
+
103
+ // ── Isolated duck rig (classic variant, standing pose) ────────────
104
+ // MODEL_DIR is page-relative ("./robot/mjlab") and resolves wrong from
105
+ // /fx/; mesh_dir inside the JSON is root-absolute so only this differs.
106
+ const k = await loadKinematics("../robot/mjlab/kinematics.json");
107
+ const rig = await buildRig(k, { materialForMesh: materialHookFor(VARIANTS.classic) });
108
+ groundFullBody(rig, 0);
109
+ scene.add(rig.placer);
110
+
111
+ // Aim the camera at the duck's actual center.
112
+ const box = new THREE.Box3().setFromObject(rig.placer);
113
+ const center = box.getCenter(new THREE.Vector3());
114
+ controls.target.copy(center);
115
+
116
+ fx.init({ THREE, scene, rig, camera, renderer });
117
+ statusEl.textContent = `fx: ${fx.name} | drag to orbit`;
118
+
119
+ document.getElementById("replay").addEventListener("click", () => fx.start());
120
+
121
+ // Debug hook for deterministic mid-effect screenshots.
122
+ window.fx = {
123
+ start: () => fx.start(),
124
+ setProgress: (p) => fx.setProgress(p),
125
+ isDone: () => fx.isDone(),
126
+ };
127
+
128
+ const clock = new THREE.Clock();
129
+ renderer.setAnimationLoop(() => {
130
+ const dt = Math.min(0.05, clock.getDelta());
131
+ fx.update(dt, clock.elapsedTime);
132
+ controls.update();
133
+ renderer.render(scene, camera);
134
+ });
135
+
136
+ // Auto-play once everything is loaded.
137
+ setTimeout(() => fx.start(), 500);
138
+ </script>
139
+ </body>
140
+ </html>
fx/demo-wireframe.html ADDED
@@ -0,0 +1,113 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ <!doctype html>
2
+ <html lang="en">
3
+ <head>
4
+ <meta charset="utf-8" />
5
+ <meta name="viewport" content="width=device-width, initial-scale=1" />
6
+ <title>FX demo - holographic wireframe scan-up</title>
7
+ <style>
8
+ html, body { margin: 0; height: 100%; background: #0b0d10; overflow: hidden; }
9
+ #app { position: fixed; inset: 0; }
10
+ #hud {
11
+ position: fixed; top: 14px; left: 14px; z-index: 10;
12
+ font: 12px/1.4 ui-monospace, SFMono-Regular, Menlo, monospace;
13
+ color: #ff7a2f; user-select: none;
14
+ }
15
+ #replay {
16
+ margin-top: 8px; padding: 7px 18px; cursor: pointer;
17
+ font: 600 12px ui-monospace, SFMono-Regular, Menlo, monospace;
18
+ letter-spacing: 0.12em; color: #ff7a2f;
19
+ background: rgba(255, 122, 47, 0.08);
20
+ border: 1px solid rgba(255, 122, 47, 0.55); border-radius: 4px;
21
+ }
22
+ #replay:hover { background: rgba(255, 122, 47, 0.2); }
23
+ </style>
24
+ <script type="importmap">
25
+ {
26
+ "imports": {
27
+ "three": "https://cdn.jsdelivr.net/npm/three@0.164.1/build/three.module.js",
28
+ "three/addons/": "https://cdn.jsdelivr.net/npm/three@0.164.1/examples/jsm/"
29
+ }
30
+ }
31
+ </script>
32
+ </head>
33
+ <body>
34
+ <div id="app"></div>
35
+ <div id="hud">
36
+ FX: HOLOGRAPHIC WIREFRAME SCAN-UP<br />
37
+ <button id="replay">REPLAY</button>
38
+ </div>
39
+ <script type="module">
40
+ import * as THREE from "three";
41
+ import { OrbitControls } from "three/addons/controls/OrbitControls.js";
42
+ import { loadKinematics, buildRig, groundFullBody } from "../duck.js";
43
+ import { VARIANTS, materialHookFor } from "../variants.js";
44
+ import * as fx from "./fx-wireframe.js";
45
+
46
+ const mount = document.getElementById("app");
47
+
48
+ const scene = new THREE.Scene();
49
+ scene.background = new THREE.Color(0x0b0d10);
50
+
51
+ const camera = new THREE.PerspectiveCamera(40, 1, 0.02, 30);
52
+ const renderer = new THREE.WebGLRenderer({ antialias: true });
53
+ renderer.setPixelRatio(Math.min(2, window.devicePixelRatio));
54
+ renderer.toneMapping = THREE.ACESFilmicToneMapping;
55
+ mount.appendChild(renderer.domElement);
56
+
57
+ // Same light rig as the live app so materials read identically.
58
+ scene.add(new THREE.AmbientLight(0xffffff, 0.6));
59
+ const keyLight = new THREE.DirectionalLight(0xffffff, 1.6);
60
+ keyLight.position.set(2, 4, 2);
61
+ scene.add(keyLight);
62
+ const fill = new THREE.DirectionalLight(0xffffff, 0.4);
63
+ fill.position.set(-2, 2, 1.5);
64
+ scene.add(fill);
65
+ const rim = new THREE.DirectionalLight(0xffb366, 0.7);
66
+ rim.position.set(0, 3, -2);
67
+ scene.add(rim);
68
+
69
+ const grid = new THREE.GridHelper(2, 40, 0x232c3a, 0x151a22);
70
+ scene.add(grid);
71
+
72
+ const resize = () => {
73
+ const w = mount.clientWidth, h = mount.clientHeight;
74
+ camera.aspect = w / h;
75
+ camera.updateProjectionMatrix();
76
+ renderer.setSize(w, h);
77
+ };
78
+ window.addEventListener("resize", resize);
79
+ resize();
80
+
81
+ // Isolated duck rig, classic colourway.
82
+ const k = await loadKinematics("/robot/mjlab/kinematics.json");
83
+ const rig = await buildRig(k, { materialForMesh: materialHookFor(VARIANTS.classic) });
84
+ groundFullBody(rig);
85
+ scene.add(rig.placer);
86
+
87
+ // Frame the duck.
88
+ rig.placer.updateWorldMatrix(true, true);
89
+ const box = new THREE.Box3().setFromObject(rig.placer);
90
+ const center = box.getCenter(new THREE.Vector3());
91
+ const size = box.getSize(new THREE.Vector3()).length();
92
+ camera.position.set(center.x + size * 1.5, center.y + size * 0.85, center.z + size * 1.9);
93
+ const controls = new OrbitControls(camera, renderer.domElement);
94
+ controls.target.copy(center);
95
+ controls.enableDamping = true;
96
+ controls.update();
97
+
98
+ fx.init({ THREE, scene, rig, camera, renderer });
99
+ fx.start();
100
+ window.fx = fx; // fx.setProgress(p) for deterministic captures
101
+
102
+ document.getElementById("replay").addEventListener("click", () => fx.start());
103
+
104
+ const clock = new THREE.Clock();
105
+ renderer.setAnimationLoop(() => {
106
+ const dt = Math.min(clock.getDelta(), 0.05);
107
+ fx.update(dt, clock.elapsedTime);
108
+ controls.update();
109
+ renderer.render(scene, camera);
110
+ });
111
+ </script>
112
+ </body>
113
+ </html>
fx/fx-glitch.js ADDED
@@ -0,0 +1,311 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ // Cyberpunk 2077-style glitch materialization for the Microduck rig.
2
+ //
3
+ // The duck appears as stacked horizontal slices that flicker in and out
4
+ // with random screen-space horizontal offsets, converging to their true
5
+ // position. Chromatic aberration is faked with two additive silhouette
6
+ // ghosts (cyan / orange) offset left-right, digital noise blocks flash
7
+ // emissive or invert the surface colour, and a final warm flash "snaps"
8
+ // everything crisp.
9
+ //
10
+ // Implementation: onBeforeCompile injections on CACHED clones of the rig
11
+ // materials (same pattern and same program-cache trap as the entrance
12
+ // dissolve in rl.js: clones are cached per original material and reused
13
+ // on every replay, each with a unique customProgramCacheKey, otherwise
14
+ // three.js would reuse a compiled program without re-binding uniforms).
15
+ // All uniforms are shared singleton objects so one JS write per frame
16
+ // drives every material.
17
+
18
+ export const name = "glitch";
19
+
20
+ const DURATION = 1.15; // seconds
21
+ const TICK_HZ = 30; // discrete glitch update rate (digital feel)
22
+ const SLICES = 24;
23
+
24
+ // ── Shared uniforms (one instance drives all cloned materials) ──────────
25
+ const uP = { value: 0 }; // progress 0..1 (0 = invisible)
26
+ const uSeed = { value: 0 }; // discrete tick, drives all hashes
27
+ const uAmp = { value: 0 }; // slice offset amplitude (view-space meters)
28
+ const uFlash = { value: 0 }; // final snap flash intensity
29
+ const uGhostOff = { value: 0 }; // RGB-split ghost separation (meters)
30
+ const uB = { value: null }; // vec4(minY, height, sliceH, 0) world bounds
31
+
32
+ let T = null; // injected THREE namespace
33
+ let rigRef = null;
34
+ let playing = false;
35
+ let scrubbed = false;
36
+ let done = true;
37
+ let elapsed = 0;
38
+
39
+ // Deterministic JS-side hash, mirrors the GLSL one.
40
+ const jhash = (n) => {
41
+ const s = Math.sin(n * 127.1 + 311.7) * 43758.5453;
42
+ return s - Math.floor(s);
43
+ };
44
+
45
+ // ── GLSL injection ──────────────────────────────────────────────────────
46
+ const GLSL_HELPERS = /* glsl */ `
47
+ varying vec3 vGlW;
48
+ uniform float uGlP, uGlSeed;
49
+ uniform vec4 uGlB;
50
+ float glHash2(vec2 q) { return fract(sin(dot(q, vec2(127.1, 311.7))) * 43758.5453); }
51
+ vec3 glHash3(vec2 q) { return vec3(glHash2(q), glHash2(q + 17.17), glHash2(q + 31.3)); }
52
+ `;
53
+
54
+ function injectGlitch(shader, { signU = null, ghost = false } = {}) {
55
+ shader.uniforms.uGlP = uP;
56
+ shader.uniforms.uGlSeed = uSeed;
57
+ shader.uniforms.uGlAmp = uAmp;
58
+ shader.uniforms.uGlFlash = uFlash;
59
+ shader.uniforms.uGlGhostOff = uGhostOff;
60
+ shader.uniforms.uGlSign = signU ?? { value: 0 };
61
+ shader.uniforms.uGlB = uB;
62
+
63
+ shader.vertexShader = shader.vertexShader
64
+ .replace(
65
+ "#include <common>",
66
+ "#include <common>\n" + GLSL_HELPERS + "uniform float uGlAmp, uGlSign, uGlGhostOff;\n",
67
+ )
68
+ .replace(
69
+ "#include <project_vertex>",
70
+ /* glsl */ `#include <project_vertex>
71
+ vec4 glWp = modelMatrix * vec4(transformed, 1.0);
72
+ vGlW = glWp.xyz;
73
+ if (uGlP < 1.0) {
74
+ float glSid = floor((glWp.y - uGlB.x) / uGlB.z);
75
+ // Fraction of moving slices shrinks as p rises: convergence.
76
+ float glMove = step(0.25 + 0.62 * uGlP, glHash2(vec2(glSid * 1.71 + 4.2, uGlSeed + 13.0)));
77
+ float glOff = (glHash2(vec2(glSid, uGlSeed)) - 0.5) * 2.0 * uGlAmp * glMove;
78
+ // Occasional whole-body frame jump for one tick.
79
+ if (glHash2(vec2(uGlSeed, 3.17)) > 0.86) {
80
+ glOff += (glHash2(vec2(uGlSeed, 9.91)) - 0.5) * 3.0 * uGlAmp;
81
+ }
82
+ glOff += uGlSign * uGlGhostOff;
83
+ // View-space X = screen-horizontal, the classic spawn-glitch axis.
84
+ mvPosition.x += glOff;
85
+ gl_Position = projectionMatrix * mvPosition;
86
+ }`,
87
+ );
88
+
89
+ const sliceDiscard = /* glsl */ `
90
+ float glEdge = 0.0, glInvAmt = 0.0;
91
+ vec3 glAddC = vec3(0.0);
92
+ if (uGlP < 1.0) {
93
+ float glSid = floor((vGlW.y - uGlB.x) / uGlB.z);
94
+ float glH = glHash2(vec2(glSid, uGlSeed + 0.7));
95
+ float glVis = smoothstep(0.02, 0.82, uGlP) * 1.35;
96
+ // Two-three full-silhouette pre-flashes right at the start.
97
+ float glPre = (uGlP > 0.015 && uGlP < 0.14 && glHash2(vec2(uGlSeed, 7.7)) > 0.72) ? 1.0 : 0.0;
98
+ glVis = max(glVis, glPre);
99
+ if (glH > glVis) discard;
100
+ glEdge = max(glPre * 0.7, (1.0 - smoothstep(0.0, 0.3, glVis - glH)) * (1.0 - uGlP));
101
+ ${ghost ? "" : /* glsl */ `
102
+ // Digital noise blocks: world-space rectangular patches, re-seeded per
103
+ // tick. Some invert the surface colour, most flash emissive.
104
+ vec2 glBq = floor((vGlW.xy + vec2(vGlW.z * 0.7, vGlW.z * 0.3)) * 52.0
105
+ + vec2(uGlSeed * 0.373, uGlSeed * 0.719));
106
+ vec3 glBh = glHash3(glBq + uGlSeed);
107
+ float glBGate = (1.0 - uGlP) * 0.16;
108
+ if (glBh.x > 1.0 - glBGate) {
109
+ if (glBh.y < 0.3) glInvAmt = 1.0;
110
+ else glAddC += (glBh.z < 0.6 ? vec3(0.15, 1.3, 1.5)
111
+ : (glBh.z < 0.85 ? vec3(1.4) : vec3(1.6, 0.5, 0.06))) * (0.7 + glBh.y);
112
+ }
113
+ // Faint rolling scanline shimmer.
114
+ glAddC += vec3(0.05, 0.3, 0.35) * (1.0 - uGlP) * 0.18
115
+ * step(0.6, fract(vGlW.y * 150.0 - uGlSeed * 0.21));
116
+ `}
117
+ }`;
118
+
119
+ const emissiveOut = ghost
120
+ ? "" // ghosts are pre-tinted additive silhouettes, no extra emissive
121
+ : /* glsl */ `
122
+ gl_FragColor.rgb = mix(gl_FragColor.rgb, vec3(1.1) - gl_FragColor.rgb, glInvAmt);
123
+ gl_FragColor.rgb += glAddC;
124
+ gl_FragColor.rgb += glEdge * vec3(0.25, 1.15, 1.35);
125
+ gl_FragColor.rgb += uGlFlash * vec3(1.25, 0.7, 0.35);`;
126
+
127
+ shader.fragmentShader = shader.fragmentShader
128
+ .replace(
129
+ "#include <common>",
130
+ "#include <common>\n" + GLSL_HELPERS + "uniform float uGlFlash;\n",
131
+ )
132
+ .replace("#include <clipping_planes_fragment>", "#include <clipping_planes_fragment>" + sliceDiscard)
133
+ .replace("#include <dithering_fragment>", "#include <dithering_fragment>" + emissiveOut);
134
+ }
135
+
136
+ // ── Material clones (cached, never disposed between replays) ────────────
137
+ const clones = new Map(); // original material uuid -> glitch clone
138
+ let cloneNonce = 0;
139
+ let saved = null; // Array<[mesh, originalMat, cloneMat]> while active
140
+
141
+ function cloneFor(orig) {
142
+ let m = clones.get(orig.uuid);
143
+ if (m) return m;
144
+ m = orig.clone();
145
+ m.onBeforeCompile = (shader) => injectGlitch(shader);
146
+ const key = `microduck-glitch-${cloneNonce++}`;
147
+ m.customProgramCacheKey = () => key;
148
+ m.needsUpdate = true;
149
+ clones.set(orig.uuid, m);
150
+ return m;
151
+ }
152
+
153
+ function applyGlitchMats() {
154
+ if (saved) return;
155
+ saved = [];
156
+ rigRef.placer.traverse((o) => {
157
+ if (!o.isMesh || o.userData.glGhost) return;
158
+ const orig = o.material;
159
+ const m = cloneFor(orig);
160
+ saved.push([o, orig, m]);
161
+ o.material = m;
162
+ });
163
+ }
164
+
165
+ function restoreMats() {
166
+ if (!saved) return;
167
+ for (const [mesh, orig, clone] of saved) {
168
+ if (mesh.material === clone) mesh.material = orig;
169
+ }
170
+ saved = null;
171
+ }
172
+
173
+ // ── RGB-split ghosts ────────────────────────────────────────────────────
174
+ // Two additive unlit silhouette copies of every rig mesh (cyan right,
175
+ // orange left), sharing the slice-discard shader so their outline always
176
+ // matches the sliced duck. Added as children of each mesh: they inherit
177
+ // the full kinematic transform for free.
178
+ let ghostMats = [];
179
+ let ghostMeshes = [];
180
+
181
+ function makeGhostMat(r, g, b, sign) {
182
+ const m = new T.MeshBasicMaterial({
183
+ transparent: true,
184
+ opacity: 0,
185
+ depthWrite: false,
186
+ blending: T.AdditiveBlending,
187
+ });
188
+ m.color.setRGB(r, g, b);
189
+ const signU = { value: sign };
190
+ m.onBeforeCompile = (shader) => injectGlitch(shader, { signU, ghost: true });
191
+ m.customProgramCacheKey = () => `microduck-glitch-ghost-${sign > 0 ? "r" : "l"}`;
192
+ return m;
193
+ }
194
+
195
+ function buildGhosts() {
196
+ ghostMats = [makeGhostMat(0.0, 1.1, 1.3, +1), makeGhostMat(1.3, 0.35, 0.02, -1)];
197
+ const hosts = [];
198
+ rigRef.placer.traverse((o) => {
199
+ if (o.isMesh && !o.userData.glGhost) hosts.push(o);
200
+ });
201
+ for (const mesh of hosts) {
202
+ for (const mat of ghostMats) {
203
+ const gm = new T.Mesh(mesh.geometry, mat);
204
+ gm.userData.glGhost = true;
205
+ gm.renderOrder = 2;
206
+ gm.frustumCulled = false;
207
+ gm.visible = false;
208
+ mesh.add(gm);
209
+ ghostMeshes.push(gm);
210
+ }
211
+ }
212
+ }
213
+
214
+ function setGhostsVisible(v) {
215
+ for (const gm of ghostMeshes) gm.visible = v;
216
+ }
217
+
218
+ // ── Deterministic per-progress uniform state ────────────────────────────
219
+ // Everything derives from p alone (tick included), so setProgress(p) gives
220
+ // reproducible frames for debugging/screenshots.
221
+ function setUniformsFor(p) {
222
+ uP.value = p;
223
+ const tick = Math.floor(p * DURATION * TICK_HZ);
224
+ uSeed.value = tick;
225
+ const h = uB.value.y; // duck height
226
+ const gate = 0.35 + 0.65 * jhash(tick + 3.7);
227
+ uAmp.value = p < 0.92 ? h * 0.3 * Math.pow(1 - p, 1.3) * gate : 0;
228
+ uGhostOff.value =
229
+ p > 0.04 && p < 0.9 ? (0.06 + 0.3 * jhash(tick + 11.1)) * (1 - p) * h : 0;
230
+ uFlash.value = p > 0.86 ? 1.5 * Math.exp(-16 * (p - 0.86) * DURATION) : 0;
231
+ const ghostOp =
232
+ p > 0.04 && p < 0.9 ? (0.05 + 0.32 * (1 - p)) * (0.4 + 0.6 * jhash(tick + 5.5)) : 0;
233
+ for (const m of ghostMats) m.opacity = ghostOp;
234
+ }
235
+
236
+ function finish() {
237
+ uP.value = 1;
238
+ uFlash.value = 0;
239
+ setGhostsVisible(false);
240
+ restoreMats();
241
+ playing = false;
242
+ done = true;
243
+ }
244
+
245
+ // ── Public interface ────────────────────────────────────────────────────
246
+ export function init({ THREE, scene, rig, camera, renderer }) {
247
+ T = THREE;
248
+ rigRef = rig;
249
+ rig.placer.updateWorldMatrix(true, true);
250
+ const box = new T.Box3().setFromObject(rig.placer);
251
+ const minY = box.min.y;
252
+ const height = Math.max(1e-3, box.max.y - minY);
253
+ uB.value = new T.Vector4(minY, height, height / SLICES, 0);
254
+ buildGhosts();
255
+ // Duck starts invisible: p=0 discards every fragment.
256
+ uP.value = 0;
257
+ applyGlitchMats();
258
+ done = true;
259
+ playing = false;
260
+ }
261
+
262
+ export function start() {
263
+ applyGlitchMats();
264
+ setGhostsVisible(true);
265
+ elapsed = 0;
266
+ playing = true;
267
+ scrubbed = false;
268
+ done = false;
269
+ setUniformsFor(0);
270
+ }
271
+
272
+ export function update(dt, t) {
273
+ if (!playing || scrubbed) return;
274
+ elapsed += dt;
275
+ const p = Math.min(1, elapsed / DURATION);
276
+ setUniformsFor(p);
277
+ if (p >= 1) finish();
278
+ }
279
+
280
+ export function isDone() {
281
+ return done;
282
+ }
283
+
284
+ export function dispose() {
285
+ restoreMats();
286
+ for (const gm of ghostMeshes) gm.parent?.remove(gm);
287
+ ghostMeshes = [];
288
+ for (const m of ghostMats) m.dispose();
289
+ ghostMats = [];
290
+ for (const m of clones.values()) m.dispose();
291
+ clones.clear();
292
+ playing = false;
293
+ done = true;
294
+ rigRef = null;
295
+ }
296
+
297
+ // Debug hook: freeze the effect at an exact progress value (deterministic,
298
+ // used by the demo page for mid-effect screenshots). start() resumes
299
+ // normal playback.
300
+ export function setProgress(p) {
301
+ scrubbed = true;
302
+ if (p >= 1) {
303
+ finish();
304
+ return;
305
+ }
306
+ playing = true;
307
+ done = false;
308
+ applyGlitchMats();
309
+ setGhostsVisible(true);
310
+ setUniformsFor(Math.max(0, p));
311
+ }
fx/fx-wireframe.js ADDED
@@ -0,0 +1,320 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ // Holographic wireframe scan-up materialization effect.
2
+ //
3
+ // Sequence (~0.9 s, reversible):
4
+ // 1. An invisible scan height rises from the floor through the target;
5
+ // the boundary reads on the surface itself (bright wireframe band
6
+ // right under the scan height - no standalone disc geometry).
7
+ // 2. Below the scan line the target shows as a flickering additive
8
+ // wireframe hologram (scanline stripes, brightness boost near the
9
+ // scan line).
10
+ // 3. A second, fainter "solidify" line trails behind: fragments below
11
+ // it render the real PBR materials (world-Y clip injected via
12
+ // onBeforeCompile), with a hot edge right at the line. Once it
13
+ // clears the top the original materials are restored directly.
14
+ //
15
+ // Played backwards, the same timeline de-materializes (solid peel ->
16
+ // hologram -> gone). Used for the ball despawn; the duck only ever
17
+ // materializes.
18
+ //
19
+ // createWireframeFx() returns an independent instance (duck and ball
20
+ // scan at the same time without sharing uniforms). The module-level
21
+ // named exports wrap a singleton so demo-wireframe.html is unchanged.
22
+ //
23
+ // Instance interface:
24
+ // init({ THREE, scene, rig | root, camera, renderer, hidden? })
25
+ // start() / startReverse() / update(dt) / isDone() / restore() / dispose()
26
+ // Extra: setProgress(p), playing, reversing, TOTAL_S.
27
+
28
+ export const name = "wireframe";
29
+
30
+ // ── Timeline (seconds) ──────────────────────────────────────────────────
31
+ const SCAN_S = 0.6; // wireframe scan line: floor -> top
32
+ const SOLID_DELAY_S = 0.3; // solidify line starts this long after the scan
33
+ const SOLID_S = 0.6; // solidify line: floor -> top
34
+ export const TOTAL_S = SOLID_DELAY_S + SOLID_S; // 0.9
35
+
36
+ const clamp01 = (x) => Math.min(Math.max(x, 0), 1);
37
+ // Ease-out only: the rise starts at full speed the instant the scan cues
38
+ // (a smoothstep ease-in reads as a stall near the feet) and lands softly.
39
+ const ease = (x) => 1 - (1 - x) * (1 - x);
40
+
41
+ // Deterministic pseudo-random flicker so setProgress(p) captures are
42
+ // reproducible (no Math.random).
43
+ const hash = (x) => {
44
+ const s = Math.sin(x * 127.1) * 43758.5453;
45
+ return s - Math.floor(s);
46
+ };
47
+ const flickerAt = (time) => {
48
+ let f = 0.82 + 0.18 * hash(Math.floor(time * 60) + 0.5);
49
+ if (hash(Math.floor(time * 24) + 7.7) < 0.12) f *= 0.5; // dropouts
50
+ return f;
51
+ };
52
+
53
+ // Unique customProgramCacheKey values across every instance: a fresh
54
+ // clone of the same source material would otherwise reuse the compiled
55
+ // program without re-running onBeforeCompile, leaving clip uniforms
56
+ // unbound (see the dissolve comment in rl.js).
57
+ let clipNonce = 0;
58
+
59
+ const BOTTOM_PAD = 0.025;
60
+
61
+ export function createWireframeFx() {
62
+ // Per-instance uniforms: duck and ball must not share a scan height.
63
+ const uScanY = { value: -1e3 };
64
+ const uSolidY = { value: -1e3 };
65
+ const uFlicker = { value: 1 };
66
+ const uTime = { value: 0 };
67
+
68
+ let ctx = null; // { THREE, scene, root, camera, renderer }
69
+ let t = 0;
70
+ let dir = 1; // +1 materialize, -1 dematerialize
71
+ let playing = false;
72
+ let finished = false;
73
+
74
+ let minY = 0;
75
+ let spanY = 0.3;
76
+
77
+ const clipClones = new Map();
78
+ let clipSaved = null; // Array<[mesh, originalMat, cloneMat]> while active
79
+
80
+ let wireMat = null;
81
+ let wireMeshes = [];
82
+
83
+ const isTargetMesh = (o) => o.isMesh && !o.userData.fxOverlay;
84
+
85
+ function clipCloneFor(orig) {
86
+ let m = clipClones.get(orig.uuid);
87
+ if (m) return m;
88
+ m = orig.clone();
89
+ m.onBeforeCompile = (shader) => {
90
+ shader.uniforms.uFxSolidY = uSolidY;
91
+ shader.vertexShader = shader.vertexShader
92
+ .replace("#include <common>", "#include <common>\nvarying vec3 vFxW;")
93
+ .replace(
94
+ "#include <worldpos_vertex>",
95
+ "#include <worldpos_vertex>\nvFxW = (modelMatrix * vec4(transformed, 1.0)).xyz;",
96
+ );
97
+ shader.fragmentShader = shader.fragmentShader
98
+ .replace(
99
+ "#include <common>",
100
+ "#include <common>\nvarying vec3 vFxW;\nuniform float uFxSolidY;",
101
+ )
102
+ .replace(
103
+ "#include <clipping_planes_fragment>",
104
+ /* glsl */ `#include <clipping_planes_fragment>
105
+ float fxEdge = 0.0;
106
+ if (vFxW.y > uFxSolidY) discard;
107
+ fxEdge = 1.0 - smoothstep(0.002, 0.018, uFxSolidY - vFxW.y);`,
108
+ )
109
+ .replace(
110
+ "#include <dithering_fragment>",
111
+ /* glsl */ `#include <dithering_fragment>
112
+ gl_FragColor.rgb += fxEdge * vec3(0.95, 0.32, 0.05);`,
113
+ );
114
+ };
115
+ const key = `microduck-fx-wireframe-${clipNonce++}`;
116
+ m.customProgramCacheKey = () => key;
117
+ m.needsUpdate = true;
118
+ clipClones.set(orig.uuid, m);
119
+ return m;
120
+ }
121
+
122
+ function applyClipMaterials() {
123
+ if (clipSaved) return;
124
+ clipSaved = [];
125
+ ctx.root.traverse((o) => {
126
+ if (!isTargetMesh(o)) return;
127
+ const orig = o.material;
128
+ const clone = clipCloneFor(orig);
129
+ clipSaved.push([o, orig, clone]);
130
+ o.material = clone;
131
+ });
132
+ }
133
+
134
+ function restoreClipMaterials() {
135
+ if (!clipSaved) return;
136
+ for (const [mesh, orig, clone] of clipSaved) {
137
+ if (mesh.material === clone) mesh.material = orig;
138
+ }
139
+ clipSaved = null;
140
+ }
141
+
142
+ function makeWireMaterial(THREE) {
143
+ return new THREE.ShaderMaterial({
144
+ uniforms: { uScanY, uSolidY, uFlicker, uTime },
145
+ vertexShader: /* glsl */ `
146
+ varying vec3 vW;
147
+ void main() {
148
+ vec4 wp = modelMatrix * vec4(position, 1.0);
149
+ vW = wp.xyz;
150
+ gl_Position = projectionMatrix * viewMatrix * wp;
151
+ }`,
152
+ fragmentShader: /* glsl */ `
153
+ uniform float uScanY, uSolidY, uFlicker, uTime;
154
+ varying vec3 vW;
155
+ void main() {
156
+ if (vW.y > uScanY || vW.y < uSolidY) discard;
157
+ float lead = 1.0 - smoothstep(0.0, 0.06, uScanY - vW.y);
158
+ float tail = smoothstep(0.0, 0.018, vW.y - uSolidY);
159
+ float stripes = 0.7 + 0.3 * sin(vW.y * 900.0 - uTime * 45.0);
160
+ vec3 c = vec3(1.0, 0.34, 0.06) * (0.55 + 1.6 * lead);
161
+ float a = (0.10 + 0.40 * lead) * stripes * tail * uFlicker;
162
+ gl_FragColor = vec4(c, a);
163
+ }`,
164
+ wireframe: true,
165
+ transparent: true,
166
+ blending: THREE.AdditiveBlending,
167
+ depthWrite: false,
168
+ depthTest: true,
169
+ });
170
+ }
171
+
172
+ function buildWireOverlays(THREE, root) {
173
+ wireMat = makeWireMaterial(THREE);
174
+ root.traverse((o) => {
175
+ if (!isTargetMesh(o)) return;
176
+ // Child of the source mesh with identity transform so the overlay
177
+ // follows whatever pose the caller writes (duck joints or ball qpos).
178
+ const w = new THREE.Mesh(o.geometry, wireMat);
179
+ w.userData.fxOverlay = true;
180
+ w.renderOrder = 5;
181
+ w.visible = false;
182
+ o.add(w);
183
+ wireMeshes.push(w);
184
+ });
185
+ }
186
+
187
+ function applyAt(time) {
188
+ const scanP = ease(clamp01(time / SCAN_S));
189
+ const solidP = ease(clamp01((time - SOLID_DELAY_S) / SOLID_S));
190
+ uFlicker.value = flickerAt(time);
191
+ uTime.value = time;
192
+
193
+ const jitter = scanP > 0 && scanP < 1 ? (hash(time * 41.3) - 0.5) * 0.02 * spanY : 0;
194
+ uScanY.value = minY + spanY * scanP + jitter;
195
+ uSolidY.value = minY + spanY * solidP;
196
+
197
+ const wiresOn = time > 0 && solidP < 1;
198
+ for (const w of wireMeshes) w.visible = wiresOn;
199
+ }
200
+
201
+ function finish() {
202
+ // Forward: restore the real materials (fully solid). Reverse: leave
203
+ // the clip parked at t=0 (fully hidden) so restoring wouldn't flash
204
+ // the solid mesh for a frame; the caller hides the object then
205
+ // restore()s.
206
+ if (dir > 0) restoreClipMaterials();
207
+ else applyAt(0);
208
+ for (const w of wireMeshes) w.visible = false;
209
+ finished = true;
210
+ playing = false;
211
+ }
212
+
213
+ function computeRange() {
214
+ const { THREE, root } = ctx;
215
+ root.updateWorldMatrix(true, true);
216
+ const box = new THREE.Box3().setFromObject(root);
217
+ minY = Math.min(box.min.y, 0) - BOTTOM_PAD;
218
+ spanY = Math.max(box.max.y - minY, 0.04) * 1.06;
219
+ }
220
+
221
+ function arm(nextDir, resetT) {
222
+ dir = nextDir;
223
+ if (resetT) t = nextDir > 0 ? 0 : TOTAL_S;
224
+ finished = false;
225
+ computeRange();
226
+ applyClipMaterials();
227
+ applyAt(t);
228
+ playing = true;
229
+ }
230
+
231
+ function init({ THREE, scene, rig, root, camera, renderer, hidden = true }) {
232
+ const target = root ?? rig.placer;
233
+ ctx = { THREE, scene, root: target, camera, renderer };
234
+ computeRange();
235
+ buildWireOverlays(THREE, target);
236
+ t = 0;
237
+ dir = 1;
238
+ finished = false;
239
+ playing = false;
240
+ if (hidden) {
241
+ applyClipMaterials();
242
+ applyAt(0);
243
+ }
244
+ }
245
+
246
+ function start() {
247
+ // Mid-reverse: keep the current t and turn around. Fresh play: from 0.
248
+ arm(1, !playing);
249
+ }
250
+
251
+ function startReverse() {
252
+ arm(-1, !playing);
253
+ }
254
+
255
+ function update(dt) {
256
+ if (!playing || finished) return;
257
+ t += dir * dt;
258
+ if (dir > 0 && t >= TOTAL_S) {
259
+ t = TOTAL_S;
260
+ applyAt(t);
261
+ finish();
262
+ return;
263
+ }
264
+ if (dir < 0 && t <= 0) {
265
+ t = 0;
266
+ applyAt(t);
267
+ finish();
268
+ return;
269
+ }
270
+ applyAt(t);
271
+ }
272
+
273
+ function isDone() {
274
+ return finished;
275
+ }
276
+
277
+ function setProgress(p) {
278
+ playing = false;
279
+ finished = false;
280
+ dir = 1;
281
+ computeRange();
282
+ applyClipMaterials();
283
+ t = clamp01(p) * TOTAL_S;
284
+ applyAt(t);
285
+ }
286
+
287
+ function restore() {
288
+ restoreClipMaterials();
289
+ }
290
+
291
+ function dispose() {
292
+ restoreClipMaterials();
293
+ for (const w of wireMeshes) w.parent?.remove(w);
294
+ wireMeshes = [];
295
+ wireMat?.dispose();
296
+ wireMat = null;
297
+ ctx = null;
298
+ playing = false;
299
+ finished = false;
300
+ }
301
+
302
+ return {
303
+ init, start, startReverse, update, isDone, setProgress, restore, dispose,
304
+ get playing() { return playing; },
305
+ get reversing() { return playing && dir < 0; },
306
+ TOTAL_S,
307
+ };
308
+ }
309
+
310
+ // Singleton for the duck (and the isolated demo page). New callers that
311
+ // need a second scan (the ball) go through createWireframeFx().
312
+ const singleton = createWireframeFx();
313
+ export const init = (...a) => singleton.init(...a);
314
+ export const start = (...a) => singleton.start(...a);
315
+ export const startReverse = (...a) => singleton.startReverse(...a);
316
+ export const update = (...a) => singleton.update(...a);
317
+ export const isDone = (...a) => singleton.isDone(...a);
318
+ export const setProgress = (...a) => singleton.setProgress(...a);
319
+ export const restore = (...a) => singleton.restore(...a);
320
+ export const dispose = (...a) => singleton.dispose(...a);
ghosts.js CHANGED
@@ -2,7 +2,7 @@
2
  // duck state over WebRTC and renders the other visitors as translucent
3
  // ducks. There is no backend - this is a static Space - so peer discovery
4
  // uses Trystero's serverless signaling over public Nostr relays. Payloads
5
- // are tiny (22 floats + a variant name at 10 Hz), and at most MAX_GHOSTS
6
  // ghosts are instantiated regardless of how many peers are in the room.
7
  //
8
  // This module deliberately imports nothing from duck.js / variants.js:
@@ -13,14 +13,39 @@ const TRYSTERO_URL = "https://esm.run/trystero@0.25.3/nostr";
13
  const APP_ID = "microduck-sandbox";
14
  const ROOM = "lobby";
15
  const MAX_GHOSTS = 3;
16
- const SEND_HZ = 10;
17
  const GHOST_OPACITY = 0.35;
18
- // Per-frame easing toward the last received state (10 Hz updates, ~60 fps
19
- // rendering): position/joints converge in ~4 frames, fast but smooth.
20
- const EASE = 0.25;
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
21
 
22
  export async function initGhosts(env) {
23
- const noop = { update() {}, peerCount: () => 0, ghostCount: () => 0, debug: () => [] };
24
  let room;
25
  try {
26
  const { joinRoom } = await import(TRYSTERO_URL);
@@ -46,10 +71,36 @@ export async function initGhosts(env) {
46
  // Ghosts share geometry with the local rig (cloneRig), but get their own
47
  // transparent material clones so repainting them never touches the
48
  // visitor's own duck (variants.js caches materials globally).
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
49
  const ghostify = (rig) => {
 
 
 
50
  const cache = new Map();
51
- rig.root.traverse((o) => {
52
- if (!o.isMesh) return;
 
 
 
 
 
53
  let m = cache.get(o.material.uuid);
54
  if (!m) {
55
  m = o.material.clone();
@@ -59,10 +110,31 @@ export async function initGhosts(env) {
59
  cache.set(o.material.uuid, m);
60
  }
61
  o.material = m;
62
- });
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
63
  };
64
 
65
  const ghosts = new Map(); // peerId -> ghost
 
 
 
66
  const makeGhost = (state) => {
67
  const rig = cloneRig(rigFor(state.l));
68
  applyVariant(rig, state.v);
@@ -73,7 +145,7 @@ export async function initGhosts(env) {
73
  trunk.position.set(state.p[0], state.p[1], state.p[2]);
74
  trunk.quaternion.set(state.p[4], state.p[5], state.p[6], state.p[3]);
75
  return {
76
- rig, trunk, target: state, joints: state.j.slice(), jaw: state.w ?? 0,
77
  variant: state.v, loco: state.l ?? 0,
78
  };
79
  };
@@ -102,19 +174,31 @@ export async function initGhosts(env) {
102
  // sessions can arrive right after joining: only ghost live peers.
103
  if (!(peerId in room.getPeers())) return;
104
  g = makeGhost(state);
 
 
 
 
 
105
  ghosts.set(peerId, g);
106
  g.lastSeen = performance.now();
107
  return;
108
  }
109
  g.lastSeen = performance.now();
110
- g.target = state;
 
 
 
 
 
111
  // Peer switched legs <-> rollers: rebuild its ghost on the other rig
112
- // (cheap - cloneRig shares geometry).
 
113
  if ((state.l ?? 0) !== g.loco) {
114
- const seen = g.lastSeen;
115
  removeGhost(peerId);
116
  g = makeGhost(state);
117
- g.lastSeen = seen;
 
118
  ghosts.set(peerId, g);
119
  return;
120
  }
@@ -133,8 +217,9 @@ export async function initGhosts(env) {
133
  try { room.leave(); } catch {}
134
  });
135
  // ...and a staleness sweep for peers that vanished without leaving
136
- // (crashed tab, dropped connection): 5 s without a state packet at
137
- // 10 Hz means the peer is gone, not just lagging.
 
138
  const STALE_MS = 5000;
139
 
140
  // Broadcast + stale sweep on setInterval (not rAF) so both keep running
@@ -148,36 +233,55 @@ export async function initGhosts(env) {
148
  }
149
  }, 1000 / SEND_HZ);
150
 
151
- // Scratch THREE.Quaternion without importing three: slerp needs a real
152
- // instance, cloned here from any node of the already-built local rig.
153
- const _q = env.rig.placer.quaternion.clone();
 
154
  return {
155
  room,
156
  peerCount: () => Object.keys(room.getPeers()).length,
157
  ghostCount: () => ghosts.size,
 
 
 
 
 
 
158
  debug: () => [...ghosts.values()].map((g) => {
159
- let meshes = 0, visible = 0, op = null;
160
  g.rig.root.traverse((o) => {
161
- if (o.isMesh) { meshes++; if (o.visible) visible++; op ??= o.material.opacity; }
 
 
162
  });
163
  const w = g.trunk.getWorldPosition(g.trunk.position.clone());
164
- return { p: g.trunk.position.toArray(), world: w.toArray(), inScene: !!g.rig.placer.parent, meshes, visible, op, v: g.variant };
165
  }),
166
  update() {
 
167
  for (const g of ghosts.values()) {
168
- const t = g.target;
 
 
 
 
 
 
 
 
169
  // Trunk pose in raw MJCF coords: the cloned root applies Z-up -> Y-up.
170
- g.trunk.position.x += (t.p[0] - g.trunk.position.x) * EASE;
171
- g.trunk.position.y += (t.p[1] - g.trunk.position.y) * EASE;
172
- g.trunk.position.z += (t.p[2] - g.trunk.position.z) * EASE;
173
- _q.set(t.p[4], t.p[5], t.p[6], t.p[3]); // THREE order x,y,z,w
174
- g.trunk.quaternion.slerp(_q, EASE);
 
 
 
175
  for (let i = 0; i < jointNames.length; i++) {
176
- g.joints[i] += (t.j[i] - g.joints[i]) * EASE;
177
- setJoint(g.rig, jointNames[i], g.joints[i]);
178
  }
179
- g.jaw += ((t.w ?? 0) - g.jaw) * EASE;
180
- setJawOpen(g.rig, g.jaw);
181
  }
182
  },
183
  };
 
2
  // duck state over WebRTC and renders the other visitors as translucent
3
  // ducks. There is no backend - this is a static Space - so peer discovery
4
  // uses Trystero's serverless signaling over public Nostr relays. Payloads
5
+ // are tiny (22 floats + a variant name at SEND_HZ), and at most MAX_GHOSTS
6
  // ghosts are instantiated regardless of how many peers are in the room.
7
  //
8
  // This module deliberately imports nothing from duck.js / variants.js:
 
13
  const APP_ID = "microduck-sandbox";
14
  const ROOM = "lobby";
15
  const MAX_GHOSTS = 3;
16
+ const SEND_HZ = 15;
17
  const GHOST_OPACITY = 0.35;
18
+ // Snapshot interpolation: each ghost renders INTERP_DELAY_MS in the past,
19
+ // lerping between the two buffered snapshots that bracket the render time
20
+ // (timed by local ARRIVAL, so no clock sync and no payload change - old
21
+ // clients interoperate). This replaces the old exponential chase toward
22
+ // the latest packet, whose move-then-stall rhythm read as micro-stutter.
23
+ // The delay absorbs network jitter; past the newest snapshot the ghost
24
+ // holds pose, so a background-throttled 1 Hz sender degrades into slow
25
+ // discrete steps instead of teleports. Nobody synchronizes on ghosts, so
26
+ // the added latency is invisible.
27
+ const INTERP_DELAY_MS = 150;
28
+ const BUF_MAX = 40; // snapshots kept per ghost (~2.5 s at 15 Hz)
29
+ // Idle visitors stay invisible: a ghost is only shown once its peer has
30
+ // strayed from the pose of its first state message (everyone spawns at
31
+ // the arena center). Once revealed it stays visible for the whole peer
32
+ // session, so a reset teleporting the duck back to center doesn't blink
33
+ // the ghost out.
34
+ const REVEAL_DIST = 0.2; // m, horizontal distance from spawn
35
+ const REVEAL_YAW = 0.4; // rad (~23deg), turning in place also reveals
36
+
37
+ // state.p is raw MuJoCo qpos[0..6]: [x, y, z, qw, qx, qy, qz], Z-up.
38
+ const yawOf = (p) =>
39
+ Math.atan2(2 * (p[3] * p[6] + p[4] * p[5]), 1 - 2 * (p[5] * p[5] + p[6] * p[6]));
40
+ const hasMoved = (spawn, p) => {
41
+ const dx = p[0] - spawn.x, dy = p[1] - spawn.y;
42
+ if (dx * dx + dy * dy > REVEAL_DIST * REVEAL_DIST) return true;
43
+ const dyaw = yawOf(p) - spawn.yaw;
44
+ return Math.abs(Math.atan2(Math.sin(dyaw), Math.cos(dyaw))) > REVEAL_YAW;
45
+ };
46
 
47
  export async function initGhosts(env) {
48
+ const noop = { update() {}, peerCount: () => 0, ghostCount: () => 0, debug: () => [], mapDots: () => [] };
49
  let room;
50
  try {
51
  const { joinRoom } = await import(TRYSTERO_URL);
 
71
  // Ghosts share geometry with the local rig (cloneRig), but get their own
72
  // transparent material clones so repainting them never touches the
73
  // visitor's own duck (variants.js caches materials globally).
74
+ //
75
+ // Rendering: a ghost reads as ONE translucent shell, not ~70 individually
76
+ // transparent meshes (through which every internal motor/PCB used to show).
77
+ // Classic depth-prepass silhouette, kept inside the transparent pass so the
78
+ // grid floor/walls (transparent ShaderMaterials at renderOrder 0) still
79
+ // draw behind ghosts instead of being depth-rejected:
80
+ // PREPASS (renderOrder 1): every ghost mesh duplicated as a color-less
81
+ // twin (colorWrite off, depthWrite on) -> the depth buffer ends up
82
+ // holding the nearest ghost surface per pixel.
83
+ // BEAUTY (renderOrder 2): the real meshes, transparent + depthWrite off;
84
+ // the default LessEqual depth test then rejects everything except the
85
+ // fragment matching the prepass depth, so internals and back shell
86
+ // layers are skipped and each pixel is shaded exactly once.
87
+ // Orders are shared by all ghosts (not per-ghost pairs): every beauty pass
88
+ // tests against every ghost's prepass depth, so overlapping ghosts occlude
89
+ // each other like solids instead of double-blending.
90
+ const PREPASS_ORDER = 1;
91
+ const BEAUTY_ORDER = 2;
92
  const ghostify = (rig) => {
93
+ // Collect first: twins are added below and must not be re-traversed.
94
+ const meshes = [];
95
+ rig.root.traverse((o) => { if (o.isMesh) meshes.push(o); });
96
  const cache = new Map();
97
+ let depthMat = null;
98
+ for (const o of meshes) {
99
+ // Materialization-FX wire overlays cloned along with the local rig:
100
+ // drop them, they share the LIVE scan shader material and would
101
+ // flash on this ghost every time the local duck re-materializes.
102
+ if (o.userData.fxOverlay) { o.parent?.remove(o); continue; }
103
+ if (o.userData.ghostPrepass) continue; // twin from a previous ghostify
104
  let m = cache.get(o.material.uuid);
105
  if (!m) {
106
  m = o.material.clone();
 
110
  cache.set(o.material.uuid, m);
111
  }
112
  o.material = m;
113
+ o.renderOrder = BEAUTY_ORDER;
114
+ if (!o.userData.hasGhostTwin) {
115
+ if (!depthMat) {
116
+ // Depth-only material: cloned (no THREE import here) and muted.
117
+ // transparent stays true so the twin sorts into the transparent
118
+ // pass, after the grid but before the beauty meshes.
119
+ depthMat = m.clone();
120
+ depthMat.colorWrite = false;
121
+ depthMat.depthWrite = true;
122
+ }
123
+ // Twin as an identity-transform child: follows joints for free. No
124
+ // userData.meshName, so applyVariant never repaints it.
125
+ const twin = new o.constructor(o.geometry, depthMat);
126
+ twin.renderOrder = PREPASS_ORDER;
127
+ twin.userData.ghostPrepass = true;
128
+ o.userData.hasGhostTwin = true;
129
+ o.add(twin);
130
+ }
131
+ }
132
  };
133
 
134
  const ghosts = new Map(); // peerId -> ghost
135
+ // Interpolation snapshot: the pose parts of a state message, stamped
136
+ // with the local arrival time.
137
+ const snapOf = (state) => ({ p: state.p, j: state.j, w: state.w ?? 0, at: performance.now() });
138
  const makeGhost = (state) => {
139
  const rig = cloneRig(rigFor(state.l));
140
  applyVariant(rig, state.v);
 
145
  trunk.position.set(state.p[0], state.p[1], state.p[2]);
146
  trunk.quaternion.set(state.p[4], state.p[5], state.p[6], state.p[3]);
147
  return {
148
+ rig, trunk, buf: [snapOf(state)],
149
  variant: state.v, loco: state.l ?? 0,
150
  };
151
  };
 
174
  // sessions can arrive right after joining: only ghost live peers.
175
  if (!(peerId in room.getPeers())) return;
176
  g = makeGhost(state);
177
+ // Idle-visitor gate: remember where this peer first appeared and keep
178
+ // its rig invisible until it strays from there (see hasMoved above).
179
+ g.spawn = { x: state.p[0], y: state.p[1], yaw: yawOf(state.p) };
180
+ g.revealed = false;
181
+ g.rig.placer.visible = false;
182
  ghosts.set(peerId, g);
183
  g.lastSeen = performance.now();
184
  return;
185
  }
186
  g.lastSeen = performance.now();
187
+ g.buf.push(snapOf(state));
188
+ if (g.buf.length > BUF_MAX) g.buf.shift();
189
+ if (!g.revealed && hasMoved(g.spawn, state.p)) {
190
+ g.revealed = true; // latched for the rest of this peer session
191
+ g.rig.placer.visible = true;
192
+ }
193
  // Peer switched legs <-> rollers: rebuild its ghost on the other rig
194
+ // (cheap - cloneRig shares geometry). The snapshot buffer carries over
195
+ // so the interpolated motion stays continuous across the swap.
196
  if ((state.l ?? 0) !== g.loco) {
197
+ const { lastSeen, spawn, revealed, buf } = g;
198
  removeGhost(peerId);
199
  g = makeGhost(state);
200
+ Object.assign(g, { lastSeen, spawn, revealed, buf });
201
+ g.rig.placer.visible = revealed;
202
  ghosts.set(peerId, g);
203
  return;
204
  }
 
217
  try { room.leave(); } catch {}
218
  });
219
  // ...and a staleness sweep for peers that vanished without leaving
220
+ // (crashed tab, dropped connection): 5 s without a state packet means
221
+ // the peer is gone, not just lagging (even background-throttled tabs
222
+ // still send at ~1 Hz).
223
  const STALE_MS = 5000;
224
 
225
  // Broadcast + stale sweep on setInterval (not rAF) so both keep running
 
233
  }
234
  }, 1000 / SEND_HZ);
235
 
236
+ // Scratch THREE.Quaternions without importing three: slerp needs real
237
+ // instances, cloned here from any node of the already-built local rig.
238
+ const _qa = env.rig.placer.quaternion.clone();
239
+ const _qb = env.rig.placer.quaternion.clone();
240
  return {
241
  room,
242
  peerCount: () => Object.keys(room.getPeers()).length,
243
  ghostCount: () => ghosts.size,
244
+ // Minimap feed: revealed ghosts' arena positions in raw MJCF coords
245
+ // (trunk.position is set from qpos before the root's Z-up fix).
246
+ mapDots: () =>
247
+ [...ghosts.values()]
248
+ .filter((g) => g.revealed)
249
+ .map((g) => ({ x: g.trunk.position.x, y: g.trunk.position.y })),
250
  debug: () => [...ghosts.values()].map((g) => {
251
+ let meshes = 0, visible = 0, op = null, twins = 0;
252
  g.rig.root.traverse((o) => {
253
+ if (!o.isMesh) return;
254
+ if (o.userData.ghostPrepass) { twins++; return; }
255
+ meshes++; if (o.visible) visible++; op ??= o.material.opacity;
256
  });
257
  const w = g.trunk.getWorldPosition(g.trunk.position.clone());
258
+ return { p: g.trunk.position.toArray(), world: w.toArray(), inScene: !!g.rig.placer.parent, revealed: g.revealed, meshes, visible, twins, op, v: g.variant };
259
  }),
260
  update() {
261
+ const renderT = performance.now() - INTERP_DELAY_MS;
262
  for (const g of ghosts.values()) {
263
+ const buf = g.buf;
264
+ // Drop snapshots fully in the past; keep [0] and [1] bracketing
265
+ // renderT (or the two newest, if renderT has caught up).
266
+ while (buf.length > 2 && buf[1].at <= renderT) buf.shift();
267
+ const a = buf[0];
268
+ const b = buf.length > 1 ? buf[1] : a;
269
+ // Clamped: before the segment -> a, past the newest -> hold b.
270
+ const span = b.at - a.at;
271
+ const u = span > 0 ? Math.min(1, Math.max(0, (renderT - a.at) / span)) : 1;
272
  // Trunk pose in raw MJCF coords: the cloned root applies Z-up -> Y-up.
273
+ g.trunk.position.set(
274
+ a.p[0] + (b.p[0] - a.p[0]) * u,
275
+ a.p[1] + (b.p[1] - a.p[1]) * u,
276
+ a.p[2] + (b.p[2] - a.p[2]) * u,
277
+ );
278
+ _qa.set(a.p[4], a.p[5], a.p[6], a.p[3]); // THREE order x,y,z,w
279
+ _qb.set(b.p[4], b.p[5], b.p[6], b.p[3]);
280
+ g.trunk.quaternion.copy(_qa.slerp(_qb, u));
281
  for (let i = 0; i < jointNames.length; i++) {
282
+ setJoint(g.rig, jointNames[i], a.j[i] + (b.j[i] - a.j[i]) * u);
 
283
  }
284
+ setJawOpen(g.rig, a.w + (b.w - a.w) * u);
 
285
  }
286
  },
287
  };
index.html CHANGED
@@ -3,11 +3,12 @@
3
  <head>
4
  <meta charset="utf-8" />
5
  <meta name="viewport" content="width=device-width, initial-scale=1" />
6
- <title>Microduck RL playground</title>
7
  <style>
8
  :root {
9
  --ink: #08080c;
10
- --yellow: #ffd23f;
 
11
  }
12
  * { margin: 0; padding: 0; box-sizing: border-box; }
13
  html, body { height: 100%; }
@@ -17,31 +18,139 @@
17
  font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, sans-serif;
18
  overflow: hidden;
19
  }
20
- #scene { position: fixed; inset: 0; }
21
  #scene canvas { display: block; }
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
22
  .halftone {
23
  position: fixed;
24
  inset: 0;
 
25
  pointer-events: none;
26
  background-image: radial-gradient(rgba(255, 255, 255, 0.05) 1px, transparent 1.5px);
27
  background-size: 21px 21px;
28
  mask-image: linear-gradient(to bottom, transparent 30%, black 100%);
29
  -webkit-mask-image: linear-gradient(to bottom, transparent 30%, black 100%);
30
  }
31
- /* Top-left corner: what this is */
32
- #brand {
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
33
  position: fixed;
34
- top: 1.4rem;
35
- left: 1.6rem;
36
- z-index: 10;
37
- font-size: 0.72rem;
38
- font-weight: 600;
39
- letter-spacing: 0.14em;
40
- text-transform: uppercase;
41
- color: rgba(255, 255, 255, 0.55);
42
- line-height: 1.8;
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
43
  }
44
- #brand b { color: var(--yellow); font-weight: 600; }
45
  /* BIOS/POST boot readout: hidden while the welcome modal is up (the
46
  real load runs silently behind it); rl.js reveals it after the
47
  first "Waddle in" and replays / tracks the milestones over an
@@ -50,9 +159,10 @@
50
  position: fixed;
51
  inset: 0;
52
  display: none;
53
- align-items: center;
54
- justify-content: center;
55
- padding: 0 2rem;
 
56
  background: var(--ink);
57
  z-index: 20;
58
  pointer-events: none;
@@ -62,25 +172,42 @@
62
  #loading.off { opacity: 0; }
63
  #loading .post {
64
  min-width: min(380px, 84vw);
 
 
 
 
 
 
 
 
 
 
 
65
  font-family: ui-monospace, SFMono-Regular, Menlo, Consolas, monospace;
66
- font-size: 0.72rem;
67
- line-height: 2.1;
68
- letter-spacing: 0.06em;
 
69
  text-transform: uppercase;
70
  text-align: left;
71
- color: rgba(255, 210, 63, 0.72);
72
- text-shadow: 0 0 10px rgba(255, 210, 63, 0.2);
73
  white-space: pre-wrap;
74
  overflow-wrap: break-word;
75
  }
76
  #loading .post > div:first-child {
77
- color: rgba(255, 255, 255, 0.8);
78
- margin-bottom: 0.3rem;
79
  }
80
  /* Blinking block cursor at the end of the newest line */
81
  #loading .post > div:last-child::after {
82
  content: "\2588";
83
- margin-left: 0.4em;
 
 
 
 
 
84
  animation: post-blink 1s steps(1) infinite;
85
  }
86
  @keyframes post-blink {
@@ -133,31 +260,151 @@
133
  border-color: #ffc61e;
134
  color: #08080c;
135
  }
136
- /* Current-state label: quiet line above the telemetry, slot-machine
137
- roll on change */
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
138
  .mode {
139
- position: relative;
140
- overflow: hidden;
141
- height: 1.2em;
142
  min-width: 8ch;
143
- font-size: 0.72rem;
 
144
  font-weight: 600;
145
- letter-spacing: 0.2em;
146
  text-transform: uppercase;
147
- color: rgba(255, 210, 63, 0.8);
148
  text-align: right;
149
- text-shadow: 0 0 8px rgba(255, 210, 63, 0.3);
150
- }
151
- .mode .mode-text {
152
- position: absolute;
153
- left: 0;
154
- top: 0;
155
- width: 100%;
156
  line-height: 1.2;
157
- transition: transform 0.3s cubic-bezier(0.2, 0.8, 0.2, 1), opacity 0.3s ease;
158
  }
159
- .mode .mode-text.in { transform: translateY(130%); opacity: 0; }
160
- .mode .mode-text.out { transform: translateY(-130%); opacity: 0; }
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
161
  /* Colour swatches */
162
  #swatches { gap: 0.6rem; }
163
  #swatches button {
@@ -257,7 +504,7 @@
257
  a phosphor-glow monospace. No scanlines - the type does the work. */
258
  #stats-wrap {
259
  position: fixed;
260
- top: 1.4rem;
261
  right: 1.6rem;
262
  z-index: 10;
263
  display: flex;
@@ -289,166 +536,216 @@
289
  0%, 58% { opacity: 1; }
290
  59%, 100% { opacity: 0.12; }
291
  }
292
- #stats {
 
 
 
 
 
 
 
 
293
  font-size: 0.62rem;
294
  letter-spacing: 0.14em;
295
  text-transform: uppercase;
296
- text-align: right;
297
- color: rgba(255, 255, 255, 0.45);
298
- line-height: 1.7;
299
  font-variant-numeric: tabular-nums;
300
- text-shadow: 0 0 6px rgba(255, 255, 255, 0.18);
 
 
 
 
 
 
 
 
 
 
 
 
 
301
  }
302
  /* BIOS-style line while the roller variant streams in on first switch */
303
  #osd-load[hidden] { display: none; }
304
  #osd-load {
305
  font-size: 0.68rem;
306
  letter-spacing: 0.14em;
307
- color: var(--yellow, #ffd23f);
308
- text-shadow: 0 0 8px rgba(255, 210, 63, 0.4);
309
  }
310
  .osd-load-cursor {
311
  display: inline-block;
312
  margin-left: 0.15em;
313
  animation: rec-blink 0.8s steps(1) infinite;
314
  }
 
 
 
 
 
 
 
 
 
 
 
315
  /* Roller mode: legs-only actions (kicks, sit) are inert - fade their
316
  hints; rl.js toggles body.rollers on every variant switch. */
317
  body.rollers .legs-only { opacity: 0.25; }
318
  @media (max-width: 700px) {
319
  #hud { flex-direction: column; align-items: flex-start; }
320
  #hud .keys { display: none; }
321
- #brand .sub { display: none; }
322
- /* Drop the OSD below the (single-line) brand so they never collide */
323
- #stats-wrap { top: 3.4rem; }
324
  }
325
- /* ── Welcome modal ─────────────────────────────────────────────── */
326
- /* display:flex would override the hidden attribute's UA display:none,
327
- so hidden must win explicitly. */
 
 
 
 
 
328
  #welcome[hidden] { display: none; }
329
  #welcome {
330
  position: fixed;
331
  inset: 0;
332
  z-index: 30;
333
  display: flex;
334
- padding: 1.25rem;
335
- overflow-y: auto;
336
- background: rgba(8, 8, 12, 0.5);
337
- backdrop-filter: blur(18px) saturate(1.3);
338
- -webkit-backdrop-filter: blur(18px) saturate(1.3);
339
  opacity: 1;
340
- transition: opacity 0.35s ease;
 
341
  }
342
  #welcome.closing { opacity: 0; pointer-events: none; }
343
- #welcome .card {
344
- position: relative;
345
- /* Auto margins center the card and degrade to scroll on overflow */
346
- margin: auto;
 
347
  width: 100%;
348
- max-width: 480px;
349
- padding: 2.3rem 2.4rem 1.8rem;
350
- border: 1px solid rgba(255, 255, 255, 0.1);
351
- border-radius: 24px;
352
- background:
353
- radial-gradient(130% 80% at 50% 0%, rgba(255, 210, 63, 0.06), transparent 55%),
354
- rgba(13, 13, 19, 0.94);
355
- box-shadow:
356
- inset 0 1px 0 rgba(255, 255, 255, 0.06),
357
- 0 24px 60px rgba(0, 0, 0, 0.55),
358
- 0 80px 160px rgba(0, 0, 0, 0.4);
359
  text-align: center;
360
- animation: card-in 0.5s cubic-bezier(0.22, 1, 0.36, 1) both;
361
  }
362
- @keyframes card-in {
363
- from { transform: translateY(14px) scale(0.965); opacity: 0; }
364
- to { transform: none; opacity: 1; }
365
  }
366
- /* Rows fade in with a gentle stagger; no bounce anywhere. */
367
- #welcome .card > * {
368
- animation: row-in 0.55s cubic-bezier(0.22, 1, 0.36, 1) both;
 
369
  }
370
- #welcome .card > :nth-child(1) { animation-delay: 0.05s; }
371
- #welcome .card > :nth-child(2) { animation-delay: 0.11s; }
372
- #welcome .card > :nth-child(3) { animation-delay: 0.17s; }
373
- #welcome .card > :nth-child(4) { animation-delay: 0.23s; }
374
- #welcome .card > :nth-child(5) { animation-delay: 0.27s; }
375
- @keyframes row-in {
376
- from { transform: translateY(8px); opacity: 0; }
377
- to { transform: none; opacity: 1; }
 
 
 
 
378
  }
379
- @media (prefers-reduced-motion: reduce) {
380
- #welcome .card, #welcome .card > * { animation: none; }
 
 
381
  }
382
- /* Header: duck-head mark (closed by default, open on hover - same
383
- swap as the site) over a clean typographic identity. */
384
- #welcome .logo-head {
 
 
 
 
 
 
 
 
 
 
385
  position: relative;
386
- width: 5.8rem;
387
- margin: 0 auto;
388
- filter: drop-shadow(0 10px 24px rgba(0, 0, 0, 0.45));
389
  }
390
- #welcome .logo-head img { display: block; width: 100%; }
391
- #welcome .logo-head .open { position: absolute; inset: 0; opacity: 0; }
392
- #welcome .logo-head:hover .open { opacity: 1; }
393
- #welcome .logo-head:hover .closed { opacity: 0; }
394
- #welcome .title {
395
- margin-top: 0.8rem;
396
- font-size: 1.4rem;
397
- font-weight: 700;
398
- letter-spacing: -0.01em;
399
- color: rgba(255, 255, 255, 0.95);
400
  }
401
- #welcome .subtitle {
402
- margin-top: 0.35rem;
403
- font-size: 0.6rem;
 
 
 
 
 
 
 
 
 
 
 
 
404
  font-weight: 700;
405
- letter-spacing: 0.34em;
406
- text-indent: 0.34em; /* recenter: tracking pads the right edge only */
407
- text-transform: uppercase;
408
- color: rgba(255, 210, 63, 0.8);
409
  }
410
- #welcome .tagline {
411
- margin: 0.8rem auto 0;
412
- max-width: 42ch;
413
- font-size: 0.78rem;
414
- line-height: 1.6;
415
- color: rgba(255, 255, 255, 0.58);
 
 
 
 
416
  text-wrap: balance;
417
  }
418
- /* Section label with hairlines on both sides */
419
- #welcome .section-label {
420
- display: flex;
421
- align-items: center;
422
- gap: 0.9rem;
423
- margin: 1.5rem 0 0.8rem;
424
- font-size: 0.56rem;
425
- font-weight: 700;
426
- letter-spacing: 0.28em;
427
- text-indent: 0.28em;
428
  text-transform: uppercase;
429
- color: rgba(255, 255, 255, 0.35);
430
  }
431
- #welcome .section-label::before,
432
- #welcome .section-label::after {
433
- content: "";
434
- flex: 1;
435
- height: 1px;
436
- background: rgba(255, 255, 255, 0.08);
 
 
437
  }
438
- /* Controls: six keycap tiles on a tidy grid */
439
  #welcome .grid {
440
  display: grid;
441
  grid-template-columns: repeat(3, 1fr);
442
- gap: 0.5rem;
443
- }
 
 
 
 
 
 
 
 
444
  #welcome .tile {
445
- padding: 0.75rem 0.4rem 0.7rem;
446
- border: 1px solid rgba(255, 255, 255, 0.07);
447
- border-radius: 14px;
448
- background: rgba(255, 255, 255, 0.03);
449
  }
450
- /* Fixed-height keycap area so every tile's label sits on the same
451
- baseline whether it holds one cap or the arrow cluster. */
452
  #welcome .caps {
453
  display: flex;
454
  flex-direction: column;
@@ -459,153 +756,135 @@
459
  }
460
  #welcome .caps .krow {
461
  display: flex;
462
- gap: 0.2rem;
463
  justify-content: center;
464
  }
465
  #welcome kbd {
466
  display: inline-flex;
467
  align-items: center;
468
  justify-content: center;
469
- min-width: 1.55rem;
470
- height: 1.55rem;
471
  padding: 0 0.45rem;
472
  font: inherit;
473
  font-size: 0.68rem;
474
  font-weight: 600;
475
- color: rgba(255, 255, 255, 0.88);
476
- background: linear-gradient(180deg, rgba(255, 255, 255, 0.1), rgba(255, 255, 255, 0.04));
477
- border: 1px solid rgba(255, 255, 255, 0.16);
478
- border-radius: 6px;
479
- box-shadow: 0 2px 0 rgba(0, 0, 0, 0.45), inset 0 1px 0 rgba(255, 255, 255, 0.08);
480
  }
481
- /* The arrow cluster packs six caps in two rows, so its caps shrink a
482
- notch; every other tile keeps the full-size cap. */
483
  #welcome .caps.cluster kbd { min-width: 1.4rem; height: 1.4rem; padding: 0 0.3rem; font-size: 0.62rem; }
484
  #welcome .t-name {
485
  margin-top: 0.55rem;
486
- font-size: 0.62rem;
487
- font-weight: 700;
488
- letter-spacing: 0.18em;
489
- text-indent: 0.18em;
490
- text-transform: uppercase;
491
- color: rgba(255, 255, 255, 0.85);
492
  }
493
  #welcome .t-hint {
494
- margin-top: 0.2rem;
495
- font-size: 0.6rem;
496
- color: rgba(255, 255, 255, 0.38);
497
- }
498
- #welcome .orbit-hint {
499
- margin-top: 0.7rem;
500
  font-size: 0.62rem;
501
- letter-spacing: 0.08em;
502
- color: rgba(255, 255, 255, 0.35);
503
- }
504
- /* Secondary row: gamepad + live ghosts */
505
- #welcome .extras {
506
- display: grid;
507
- grid-template-columns: 1fr 1fr;
508
- gap: 1.2rem;
509
- margin-top: 1.3rem;
510
- padding-top: 1.1rem;
511
- border-top: 1px solid rgba(255, 255, 255, 0.07);
512
- }
513
- #welcome .x-label {
514
- display: block;
515
- margin-bottom: 0.3rem;
516
- font-size: 0.56rem;
517
- font-weight: 700;
518
- letter-spacing: 0.24em;
519
- text-indent: 0.24em;
520
  text-transform: uppercase;
521
- color: rgba(255, 210, 63, 0.7);
522
  }
523
- #welcome .x-item {
 
524
  font-size: 0.68rem;
525
- line-height: 1.5;
526
- color: rgba(255, 255, 255, 0.5);
527
- text-wrap: balance;
528
  }
529
- #welcome .cta {
530
- margin-top: 1.5rem;
 
 
 
 
 
531
  }
532
- #welcome-go {
533
  appearance: none;
534
- border: none;
535
- background: var(--yellow);
536
  color: var(--ink);
537
  font: inherit;
538
- font-size: 0.75rem;
539
- font-weight: 700;
540
- letter-spacing: 0.14em;
541
- text-indent: 0.14em;
542
- text-transform: uppercase;
543
- padding: 0.8rem 2.6rem;
544
  border-radius: 999px;
 
545
  cursor: pointer;
546
- transition: transform 0.15s ease, box-shadow 0.15s ease;
547
  }
548
- #welcome-go:hover {
549
- transform: translateY(-1px);
550
- box-shadow: 0 8px 28px rgba(255, 210, 63, 0.3);
551
  }
552
- #welcome-go:focus-visible {
553
- outline: 2px solid rgba(255, 255, 255, 0.85);
554
- outline-offset: 3px;
555
  }
556
- #welcome .fineprint {
557
- margin-top: 0.9rem;
558
- font-size: 0.58rem;
559
- letter-spacing: 0.1em;
560
- text-transform: uppercase;
561
- color: rgba(255, 255, 255, 0.28);
562
  }
563
- /* Narrow screens: stack the grid two-up, tighten the whole rhythm so
564
- the card still fits a small phone viewport without scrolling */
565
  @media (max-width: 480px) {
566
- #welcome .card { padding: 1.6rem 1.3rem 1.4rem; border-radius: 20px; }
567
- #welcome .logo-head { width: 4.3rem; }
568
- #welcome .title { margin-top: 0.6rem; font-size: 1.2rem; }
569
- #welcome .tagline { margin-top: 0.55rem; font-size: 0.73rem; }
570
- #welcome .section-label { margin: 1.1rem 0 0.7rem; }
571
- #welcome .grid { grid-template-columns: repeat(2, 1fr); gap: 0.45rem; }
572
- #welcome .tile { padding: 0.6rem 0.4rem 0.55rem; }
573
- #welcome .caps { height: 2.7rem; }
574
- #welcome .t-name { margin-top: 0.4rem; }
575
- #welcome .orbit-hint { margin-top: 0.55rem; }
576
- #welcome .extras { grid-template-columns: 1fr; gap: 0.5rem; margin-top: 1rem; padding-top: 0.9rem; }
577
- #welcome .x-label { display: inline; margin-right: 0.45rem; }
578
- #welcome .cta { margin-top: 1.1rem; }
579
- }
580
- /* Short laptop viewports: shave vertical rhythm so it never scrolls */
581
- @media (max-height: 760px) {
582
- #welcome .logo-head { width: 4.8rem; }
583
- #welcome .card { padding-top: 1.7rem; }
584
- #welcome .title { margin-top: 0.6rem; font-size: 1.25rem; }
585
- #welcome .tagline { margin-top: 0.6rem; }
586
- #welcome .section-label { margin: 1.1rem 0 0.7rem; }
587
- #welcome .extras { margin-top: 1rem; padding-top: 0.9rem; }
588
- #welcome .cta { margin-top: 1.1rem; }
589
- }
590
- #brand { cursor: pointer; }
591
  </style>
592
  </head>
593
  <body>
 
594
  <div id="scene"></div>
595
  <div class="halftone"></div>
596
- <div id="brand">
597
- <b>Microduck</b> &middot; real trained policies<br />
598
- <span class="sub">MuJoCo + ONNX, live in your browser</span>
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
599
  </div>
600
  <div id="stats-wrap">
601
  <div class="osd-top">
602
  <span class="osd-rec"><span class="rec-dot">&#9679;</span>&nbsp;REC</span>
603
  <span id="osd-time">&#9654; 00:00:00</span>
604
  </div>
605
- <div class="mode" id="mode-label"><span class="mode-text">Run</span></div>
606
- <div id="stats"></div>
607
  <div id="osd-load" hidden>LOADING ROLLERS<span class="osd-load-cursor">&#9608;</span></div>
608
  </div>
 
609
  <div id="hud" hidden>
610
  <div class="stack">
611
  <span class="caption">Colour</span>
@@ -646,109 +925,263 @@
646
  </div>
647
  <div id="loading"><div class="post"></div></div>
648
 
649
- <div id="welcome" hidden>
650
- <div class="card" role="dialog" aria-modal="true" aria-label="Microduck playground">
651
- <header>
652
- <div class="logo-head">
653
- <img class="closed" id="logo-head-closed" alt="Microduck" />
654
- <img class="open" id="logo-head-open" alt="" aria-hidden="true" />
655
- </div>
656
- <h1 class="title">Microduck</h1>
657
- <div class="subtitle">RL playground</div>
658
- <p class="tagline">
659
- Real physics, real trained neural policies, running live in
660
- your browser fifty times a second.
661
- </p>
662
- </header>
663
- <section aria-label="Controls">
664
- <div class="section-label">Controls</div>
665
- <div class="grid">
666
- <div class="tile">
667
- <div class="caps cluster">
668
- <span class="krow"><kbd>&uarr;</kbd></span>
669
- <span class="krow"><kbd>&larr;</kbd><kbd>&darr;</kbd><kbd>&rarr;</kbd></span>
670
- </div>
671
- <div class="t-name">Move</div>
672
- <div class="t-hint">arrows or ZQSD</div>
673
- </div>
674
- <div class="tile">
675
- <div class="caps"><span class="krow"><kbd>A</kbd><kbd>E</kbd></span></div>
676
- <div class="t-name">Kick</div>
677
- <div class="t-hint">left / right</div>
678
- </div>
679
- <div class="tile">
680
- <div class="caps"><kbd>R</kbd></div>
681
- <div class="t-name">Roll</div>
682
- <div class="t-hint">barrel roll</div>
683
- </div>
684
- <div class="tile">
685
- <div class="caps"><kbd>B</kbd></div>
686
- <div class="t-name">Ball</div>
687
- <div class="t-hint">pop one in</div>
688
- </div>
689
- <div class="tile">
690
- <div class="caps"><kbd>C</kbd></div>
691
- <div class="t-name">Camera</div>
692
- <div class="t-hint">toggle chase</div>
693
- </div>
694
- <div class="tile">
695
- <div class="caps"><kbd>Space</kbd></div>
696
- <div class="t-name">Reset</div>
697
- <div class="t-hint">fresh start</div>
698
  </div>
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
699
  </div>
700
- <div class="orbit-hint">drag to orbit &middot; scroll to zoom &middot; <b>M</b> swaps legs for roller skates</div>
701
- </section>
702
- <section class="extras">
703
- <div class="x-item">
704
- <span class="x-label">Gamepad</span>
705
- Plug one in, same mapping as the real robot.
706
  </div>
707
- <div class="x-item">
708
- <span class="x-label">Live ghosts</span>
709
- Other visitors appear as translucent ducks.
 
 
 
 
 
 
710
  </div>
711
- </section>
712
- <div class="cta">
713
- <button id="welcome-go">Waddle in</button>
714
  </div>
715
- <div class="fineprint">click the title anytime to see this again</div>
 
 
 
 
 
716
  </div>
717
  </div>
718
 
719
  <script>
720
- // Welcome modal: shown on every load, dismissible by button, Escape
721
- // or clicking the blurred backdrop. The top-left brand reopens it.
 
 
 
 
722
  (() => {
723
  const modal = document.getElementById("welcome");
724
- // Logo images need the same ?__sign JWT as every other subresource
725
- // on the private Space (rl.js hasn't installed its signer yet).
726
  const sign = new URLSearchParams(location.search).get("__sign");
727
  const signedUrl = (u) => (sign ? `${u}?__sign=${encodeURIComponent(sign)}` : u);
728
- document.getElementById("logo-head-closed").src = signedUrl("./assets/duck-head-mark.webp");
729
- document.getElementById("logo-head-open").src = signedUrl("./assets/duck-head-mark-open.webp");
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
730
  const open = () => {
731
  modal.hidden = false;
732
  modal.classList.remove("closing");
 
 
 
733
  };
734
  const close = () => {
735
  modal.classList.add("closing");
 
 
736
  setTimeout(() => { modal.hidden = true; }, 380);
737
- // First dismissal only: cue the BIOS boot replay in rl.js. The
738
- // flag covers the race where the user enters before the module
739
- // has finished evaluating.
740
  if (!window.__microduckEntered) {
741
  window.__microduckEntered = true;
742
  document.dispatchEvent(new CustomEvent("microduck:enter"));
743
  }
744
  };
745
- open();
746
- document.getElementById("welcome-go").addEventListener("click", close);
747
- modal.addEventListener("click", (e) => { if (e.target === modal) close(); });
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
748
  window.addEventListener("keydown", (e) => {
749
- if (e.code === "Escape" && !modal.hidden) close();
 
 
 
 
 
 
 
 
 
 
 
 
750
  });
751
- document.getElementById("brand").addEventListener("click", open);
752
  })();
753
  </script>
754
 
@@ -764,7 +1197,7 @@
764
  appended: a plain src attribute can 401 when the browser blocks the
765
  *.static.hf.space auth cookie inside the hub iframe. -->
766
  <script type="module">
767
- const V = "35"; // bump on rl.js changes to defeat heuristic caching
768
  const sign = new URLSearchParams(location.search).get("__sign");
769
  const suffix = sign ? `&__sign=${encodeURIComponent(sign)}` : "";
770
  import(`./rl.js?v=${V}${suffix}`);
 
3
  <head>
4
  <meta charset="utf-8" />
5
  <meta name="viewport" content="width=device-width, initial-scale=1" />
6
+ <title>Microduck</title>
7
  <style>
8
  :root {
9
  --ink: #08080c;
10
+ --orange: #ff7a2f;
11
+ --yellow: var(--orange);
12
  }
13
  * { margin: 0; padding: 0; box-sizing: border-box; }
14
  html, body { height: 100%; }
 
18
  font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, sans-serif;
19
  overflow: hidden;
20
  }
21
+ #scene { position: fixed; inset: 0; z-index: 1; }
22
  #scene canvas { display: block; }
23
+ /* Pre-boot veil: covers everything until the title page's logo and
24
+ fonts are in, so the menu never pops in half-assembled. Light grey
25
+ spinner, centered on the ink. */
26
+ #preboot {
27
+ position: fixed;
28
+ inset: 0;
29
+ z-index: 40;
30
+ display: flex;
31
+ align-items: center;
32
+ justify-content: center;
33
+ background: var(--ink);
34
+ opacity: 1;
35
+ transition: opacity 0.25s ease;
36
+ }
37
+ #preboot.off { opacity: 0; pointer-events: none; }
38
+ #preboot .spin {
39
+ width: 22px;
40
+ height: 22px;
41
+ border-radius: 50%;
42
+ border: 2px solid rgba(255, 255, 255, 0.14);
43
+ border-top-color: rgba(255, 255, 255, 0.55);
44
+ animation: preboot-spin 0.8s linear infinite;
45
+ }
46
+ @keyframes preboot-spin {
47
+ to { transform: rotate(360deg); }
48
+ }
49
+ @media (prefers-reduced-motion: reduce) {
50
+ #preboot .spin { animation-duration: 1.6s; }
51
+ }
52
  .halftone {
53
  position: fixed;
54
  inset: 0;
55
+ z-index: 2;
56
  pointer-events: none;
57
  background-image: radial-gradient(rgba(255, 255, 255, 0.05) 1px, transparent 1.5px);
58
  background-size: 21px 21px;
59
  mask-image: linear-gradient(to bottom, transparent 30%, black 100%);
60
  -webkit-mask-image: linear-gradient(to bottom, transparent 30%, black 100%);
61
  }
62
+ /* ── CRT / old-TV overlay ──────────────────────────────────────
63
+ Independent full-viewport layers stacked over the 3D scene at
64
+ z-index 5: above the canvas and the halftone dots, below the
65
+ HUD (z 10), the BIOS boot screen (z 20) and the welcome modal
66
+ (z 30) - so the interface stays crisp and only the scene gets
67
+ the old-TV treatment.
68
+
69
+ Each effect lives in its own child div + CSS block and can be
70
+ removed by deleting that block and its <div id="crt"> child:
71
+ .crt-scanlines fine horizontal lines + faint RGB fringe
72
+ .crt-noise boiling static grain (tiled SVG turbulence)
73
+ .crt-vignette darkened edges, curved-glass feel (optional)
74
+
75
+ Tuning knobs (custom properties on #crt):
76
+ --crt-scanline-opacity line strength (0 = off)
77
+ --crt-scanline-pitch line spacing in px
78
+ --crt-noise-opacity grain strength (0 = off)
79
+ --crt-vignette-opacity edge darkening (0 = off)
80
+
81
+ Performance: the only animation is a transform-only jitter on
82
+ the noise layer, so everything stays on the compositor and
83
+ never triggers repaints of the WebGL canvas underneath. */
84
+ #crt {
85
  position: fixed;
86
+ inset: 0;
87
+ z-index: 5;
88
+ pointer-events: none;
89
+ overflow: hidden;
90
+ --crt-scanline-opacity: 0.13;
91
+ --crt-scanline-pitch: 3px;
92
+ --crt-noise-opacity: 0.045;
93
+ --crt-vignette-opacity: 0.42;
94
+ }
95
+ /* SCANLINES: one thin dark line every --crt-scanline-pitch, plus a
96
+ very faint vertical RGB fringe (aperture-grille tint). Static,
97
+ zero runtime cost. */
98
+ #crt .crt-scanlines {
99
+ position: absolute;
100
+ inset: 0;
101
+ background:
102
+ repeating-linear-gradient(
103
+ to bottom,
104
+ rgba(0, 0, 0, 0.55) 0,
105
+ rgba(0, 0, 0, 0.55) 1px,
106
+ transparent 1px,
107
+ transparent var(--crt-scanline-pitch)
108
+ ),
109
+ repeating-linear-gradient(
110
+ to right,
111
+ rgba(255, 70, 60, 0.05) 0,
112
+ rgba(255, 70, 60, 0.05) 1px,
113
+ rgba(70, 255, 90, 0.035) 1px,
114
+ rgba(70, 255, 90, 0.035) 2px,
115
+ rgba(70, 110, 255, 0.05) 2px,
116
+ rgba(70, 110, 255, 0.05) 3px
117
+ );
118
+ opacity: var(--crt-scanline-opacity);
119
+ }
120
+ /* NOISE: a small tiled SVG turbulence texture (white specks with
121
+ noisy alpha), jittered between four offsets with steps() so the
122
+ grain "boils" like broadcast static. The layer is oversized so
123
+ the jumps never reveal an edge; only transform animates. */
124
+ #crt .crt-noise {
125
+ position: absolute;
126
+ inset: -80px;
127
+ background-image: url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' width='160' height='160'%3E%3Cfilter id='n'%3E%3CfeTurbulence type='fractalNoise' baseFrequency='0.8' numOctaves='2' stitchTiles='stitch'/%3E%3CfeColorMatrix type='matrix' values='0 0 0 0 1 0 0 0 0 1 0 0 0 0 1 0 0 0 0.7 0'/%3E%3C/filter%3E%3Crect width='160' height='160' filter='url(%23n)'/%3E%3C/svg%3E");
128
+ background-size: 160px 160px;
129
+ opacity: var(--crt-noise-opacity);
130
+ animation: crt-noise 0.5s steps(1) infinite;
131
+ will-change: transform;
132
+ }
133
+ @keyframes crt-noise {
134
+ 0% { transform: translate(0, 0); }
135
+ 25% { transform: translate(-45px, 30px); }
136
+ 50% { transform: translate(25px, -55px); }
137
+ 75% { transform: translate(-30px, -15px); }
138
+ 100% { transform: translate(0, 0); }
139
+ }
140
+ /* VIGNETTE (optional): gently darkened corners, like the curved
141
+ glass of a CRT tube. Static. */
142
+ #crt .crt-vignette {
143
+ position: absolute;
144
+ inset: 0;
145
+ background: radial-gradient(
146
+ 115% 90% at 50% 50%,
147
+ transparent 62%,
148
+ rgba(0, 0, 0, var(--crt-vignette-opacity)) 100%
149
+ );
150
+ }
151
+ @media (prefers-reduced-motion: reduce) {
152
+ #crt .crt-noise { animation: none; }
153
  }
 
154
  /* BIOS/POST boot readout: hidden while the welcome modal is up (the
155
  real load runs silently behind it); rl.js reveals it after the
156
  first "Waddle in" and replays / tracks the milestones over an
 
159
  position: fixed;
160
  inset: 0;
161
  display: none;
162
+ /* POST readout sits bottom-left like a real BIOS, not centered */
163
+ align-items: flex-end;
164
+ justify-content: flex-start;
165
+ padding: 2.2rem 2.4rem;
166
  background: var(--ink);
167
  z-index: 20;
168
  pointer-events: none;
 
172
  #loading.off { opacity: 0; }
173
  #loading .post {
174
  min-width: min(380px, 84vw);
175
+ /* Grow upward from the bottom-left anchor; when the log outgrows
176
+ the viewport, clip the OLDEST lines at the top (content is
177
+ bottom-aligned) so the newest line + cursor stay visible. */
178
+ max-height: calc(100vh - 4.4rem);
179
+ display: flex;
180
+ flex-direction: column;
181
+ justify-content: flex-end;
182
+ overflow: hidden;
183
+ /* Raw 90s POST look: small, tight, unsmoothed monospace - no glow,
184
+ no tracking, plain weight. Reads like a firmware screen, not a
185
+ styled terminal. */
186
  font-family: ui-monospace, SFMono-Regular, Menlo, Consolas, monospace;
187
+ font-size: 12px;
188
+ font-weight: 400;
189
+ line-height: 1.55;
190
+ letter-spacing: 0;
191
  text-transform: uppercase;
192
  text-align: left;
193
+ /* Robot orange (classic duck's BRIGHT_ORANGE, #ff7a2f in sRGB) */
194
+ color: rgba(255, 122, 47, 0.92);
195
  white-space: pre-wrap;
196
  overflow-wrap: break-word;
197
  }
198
  #loading .post > div:first-child {
199
+ color: rgba(255, 255, 255, 0.85);
200
+ margin-bottom: 0.55em;
201
  }
202
  /* Blinking block cursor at the end of the newest line */
203
  #loading .post > div:last-child::after {
204
  content: "\2588";
205
+ margin-left: 0.15em;
206
+ animation: post-blink 1s steps(1) infinite;
207
+ }
208
+ /* Fatal boot failure: SYSTEM HALTED blinks like an old POST fault */
209
+ #loading .post > div.halt {
210
+ color: rgba(255, 82, 47, 0.95);
211
  animation: post-blink 1s steps(1) infinite;
212
  }
213
  @keyframes post-blink {
 
260
  border-color: #ffc61e;
261
  color: #08080c;
262
  }
263
+ /* Active policy/mode readout, top-right: bright orange caption over a
264
+ big white monospace name. rl.js scrambles the letters on change
265
+ (Matrix-style), so the span is plain flow - no slot-machine
266
+ transforms. Monospace keeps the width stable mid-scramble. */
267
+ #mode-wrap {
268
+ position: fixed;
269
+ top: 1.4rem;
270
+ right: 1.6rem;
271
+ z-index: 10;
272
+ font-family: ui-monospace, SFMono-Regular, Menlo, Consolas, monospace;
273
+ pointer-events: none;
274
+ text-align: right;
275
+ }
276
+ #mode-wrap .caption {
277
+ display: block;
278
+ margin-bottom: 0.35rem;
279
+ font-size: 0.78rem;
280
+ font-weight: 600;
281
+ letter-spacing: 0.24em;
282
+ text-transform: uppercase;
283
+ color: var(--orange);
284
+ text-shadow: 0 0 10px rgba(255, 122, 47, 0.35);
285
+ }
286
  .mode {
 
 
 
287
  min-width: 8ch;
288
+ min-height: 1.2em;
289
+ font-size: 1.7rem;
290
  font-weight: 600;
291
+ letter-spacing: 0.16em;
292
  text-transform: uppercase;
293
+ color: rgba(255, 255, 255, 0.95);
294
  text-align: right;
295
+ text-shadow: 0 0 12px rgba(255, 255, 255, 0.28);
 
 
 
 
 
 
296
  line-height: 1.2;
 
297
  }
298
+ /* In-game back-to-menu button, top-left */
299
+ #back-btn {
300
+ position: fixed;
301
+ top: 1.4rem;
302
+ left: 1.6rem;
303
+ z-index: 10;
304
+ appearance: none;
305
+ display: inline-flex;
306
+ align-items: center;
307
+ gap: 0.45em;
308
+ border: 1px solid rgba(255, 255, 255, 0.16);
309
+ background: rgba(8, 8, 12, 0.55);
310
+ color: rgba(255, 255, 255, 0.75);
311
+ font: inherit;
312
+ font-size: 0.8rem;
313
+ font-weight: 600;
314
+ letter-spacing: 0.14em;
315
+ text-transform: uppercase;
316
+ padding: 0.6rem 1.15rem;
317
+ border-radius: 999px;
318
+ cursor: pointer;
319
+ transition: color 0.15s ease, background 0.15s ease, border-color 0.15s ease;
320
+ }
321
+ #back-btn:hover {
322
+ color: #fff;
323
+ border-color: rgba(255, 255, 255, 0.4);
324
+ background: rgba(255, 255, 255, 0.06);
325
+ }
326
+ #back-btn:focus-visible {
327
+ outline: 2px solid var(--orange);
328
+ outline-offset: 2px;
329
+ }
330
+ /* In-game quick picks, bottom-left: colour dots + legs/rollers, live.
331
+ Same pill language as the vitrine DuckPlayground. */
332
+ #quickbar {
333
+ position: fixed;
334
+ bottom: 1.4rem;
335
+ left: 1.6rem;
336
+ z-index: 10;
337
+ display: flex;
338
+ flex-wrap: wrap;
339
+ align-items: center;
340
+ gap: 0.6rem;
341
+ }
342
+ #quickbar .dots,
343
+ #quickbar .seg {
344
+ display: inline-flex;
345
+ gap: 0.24rem;
346
+ padding: 0.26rem;
347
+ border: 1px solid rgba(255, 255, 255, 0.14);
348
+ border-radius: 999px;
349
+ background: rgba(8, 8, 12, 0.55);
350
+ }
351
+ #quickbar .dots button {
352
+ appearance: none;
353
+ display: flex;
354
+ align-items: center;
355
+ justify-content: center;
356
+ width: auto;
357
+ height: 2.3rem;
358
+ padding: 0 0.5rem;
359
+ border: none;
360
+ border-radius: 999px;
361
+ background: transparent;
362
+ cursor: pointer;
363
+ }
364
+ #quickbar .dots button .dot {
365
+ width: 1.4rem;
366
+ height: 1.4rem;
367
+ border-radius: 50%;
368
+ opacity: 0.45;
369
+ transition: transform 0.2s ease, opacity 0.2s ease;
370
+ }
371
+ #quickbar .dots button:hover .dot { opacity: 1; }
372
+ #quickbar .dots button.on .dot {
373
+ opacity: 1;
374
+ transform: scale(1.15);
375
+ }
376
+ #quickbar .seg button {
377
+ appearance: none;
378
+ border: none;
379
+ background: transparent;
380
+ height: 2.3rem;
381
+ padding: 0 1.05rem;
382
+ border-radius: 999px;
383
+ font: inherit;
384
+ font-size: 0.76rem;
385
+ font-weight: 600;
386
+ letter-spacing: 0.14em;
387
+ text-transform: uppercase;
388
+ color: rgba(255, 255, 255, 0.45);
389
+ cursor: pointer;
390
+ transition: color 0.2s ease, background 0.2s ease;
391
+ }
392
+ #quickbar .seg button.on {
393
+ background: rgba(255, 122, 47, 0.14);
394
+ color: var(--orange);
395
+ }
396
+ #quickbar .dots button:focus-visible,
397
+ #quickbar .seg button:focus-visible {
398
+ outline: 2px solid var(--orange);
399
+ outline-offset: 2px;
400
+ }
401
+ /* Narrow screens: pull the quickbar back a notch so it stays on one
402
+ line and clear of the bottom-right telemetry. */
403
+ @media (max-width: 480px) {
404
+ #quickbar .dots button { height: 2rem; padding: 0 0.42rem; }
405
+ #quickbar .dots button .dot { width: 1.2rem; height: 1.2rem; }
406
+ #quickbar .seg button { height: 2rem; padding: 0 0.8rem; font-size: 0.68rem; }
407
+ }
408
  /* Colour swatches */
409
  #swatches { gap: 0.6rem; }
410
  #swatches button {
 
504
  a phosphor-glow monospace. No scanlines - the type does the work. */
505
  #stats-wrap {
506
  position: fixed;
507
+ top: 6.1rem; /* below the mode readout */
508
  right: 1.6rem;
509
  z-index: 10;
510
  display: flex;
 
536
  0%, 58% { opacity: 1; }
537
  59%, 100% { opacity: 0.12; }
538
  }
539
+ /* Telemetry stack, bottom-right: peers / speed + odometer / loop
540
+ rates, one line each, quietest at the bottom. */
541
+ #ctrl-hz {
542
+ position: fixed;
543
+ bottom: 1.4rem;
544
+ right: 1.6rem;
545
+ z-index: 10;
546
+ pointer-events: none;
547
+ font-family: ui-monospace, SFMono-Regular, Menlo, Consolas, monospace;
548
  font-size: 0.62rem;
549
  letter-spacing: 0.14em;
550
  text-transform: uppercase;
551
+ color: rgba(255, 255, 255, 0.35);
 
 
552
  font-variant-numeric: tabular-nums;
553
+ text-shadow: 0 0 6px rgba(255, 255, 255, 0.15);
554
+ white-space: pre-line;
555
+ text-align: right;
556
+ line-height: 1.8;
557
+ }
558
+ /* Radar minimap: top-down arena view in the top-right OSD stack */
559
+ #minimap {
560
+ width: 132px;
561
+ height: 132px;
562
+ margin-top: 0.45rem;
563
+ pointer-events: none;
564
+ background: rgba(0, 0, 0, 0.38);
565
+ border: 1px solid rgba(255, 122, 47, 0.4);
566
+ border-radius: 2px;
567
  }
568
  /* BIOS-style line while the roller variant streams in on first switch */
569
  #osd-load[hidden] { display: none; }
570
  #osd-load {
571
  font-size: 0.68rem;
572
  letter-spacing: 0.14em;
573
+ color: var(--orange);
574
+ text-shadow: 0 0 8px rgba(255, 122, 47, 0.4);
575
  }
576
  .osd-load-cursor {
577
  display: inline-block;
578
  margin-left: 0.15em;
579
  animation: rec-blink 0.8s steps(1) infinite;
580
  }
581
+ /* Temporarily hidden HUD pieces (CSS-only so the DOM, ids and JS
582
+ wiring stay intact and each can be re-enabled by deleting a line):
583
+ - #osd-time: the VHS play/time counter (REC + ONLINE stay);
584
+ - #hud .keys: the keycap hints + mini joysticks block;
585
+ - #hud .stack: the in-game colour swatch pill (the welcome modal
586
+ still clicks the hidden #swatches buttons programmatically). */
587
+ #osd-time { display: none; }
588
+ #hud .keys { display: none; }
589
+ #hud .stack { display: none; }
590
+ .osd-rec { display: none; }
591
+ #minimap { display: none; }
592
  /* Roller mode: legs-only actions (kicks, sit) are inert - fade their
593
  hints; rl.js toggles body.rollers on every variant switch. */
594
  body.rollers .legs-only { opacity: 0.25; }
595
  @media (max-width: 700px) {
596
  #hud { flex-direction: column; align-items: flex-start; }
597
  #hud .keys { display: none; }
 
 
 
598
  }
599
+ /* HUD stays off while the title flow / pause overlay is up. */
600
+ body.welcome-open #hud,
601
+ body.welcome-open #mode-wrap,
602
+ body.welcome-open #stats-wrap,
603
+ body.welcome-open #back-btn,
604
+ body.welcome-open #quickbar,
605
+ body.welcome-open #ctrl-hz { display: none !important; }
606
+ /* ── Title / pause overlay (full viewport, no card) ────────────── */
607
  #welcome[hidden] { display: none; }
608
  #welcome {
609
  position: fixed;
610
  inset: 0;
611
  z-index: 30;
612
  display: flex;
613
+ flex-direction: column;
614
+ align-items: center;
615
+ justify-content: center;
616
+ padding: 2.4rem 1.4rem 1.8rem;
617
+ background: var(--ink);
618
  opacity: 1;
619
+ overflow-y: auto;
620
+ transition: opacity 0.35s ease, background 0.4s ease;
621
  }
622
  #welcome.closing { opacity: 0; pointer-events: none; }
623
+ @keyframes row-in {
624
+ from { transform: translateY(8px); opacity: 0; }
625
+ to { transform: none; opacity: 1; }
626
+ }
627
+ #welcome .wstep {
628
  width: 100%;
629
+ max-width: 36rem;
 
 
 
 
 
 
 
 
 
 
630
  text-align: center;
 
631
  }
632
+ #welcome .wstep[hidden] { display: none; }
633
+ #welcome .wstep > * {
634
+ animation: row-in 0.45s cubic-bezier(0.22, 1, 0.36, 1) both;
635
  }
636
+ #welcome .wstep > :nth-child(2) { animation-delay: 0.06s; }
637
+ #welcome .wstep > :nth-child(3) { animation-delay: 0.12s; }
638
+ @media (prefers-reduced-motion: reduce) {
639
+ #welcome .wstep > * { animation: none; }
640
  }
641
+ #welcome .facts {
642
+ display: flex;
643
+ flex-wrap: wrap;
644
+ align-items: center;
645
+ justify-content: center;
646
+ gap: 0.6rem;
647
+ margin-bottom: 1.15rem;
648
+ font-size: 0.72rem;
649
+ font-weight: 600;
650
+ letter-spacing: 0.14em;
651
+ text-transform: uppercase;
652
+ color: rgba(255, 255, 255, 0.78);
653
  }
654
+ #welcome .facts span:not(:last-child)::after {
655
+ content: "Β·";
656
+ color: var(--orange);
657
+ padding-left: 0.6em;
658
  }
659
+ /* Vitrine brand lockup, centered above the title: drawn duck head +
660
+ name. Hovering swaps to the open-beak frame, same hard sprite swap
661
+ as the vitrine header. */
662
+ #welcome .wbrand {
663
+ display: flex;
664
+ flex-direction: column;
665
+ align-items: center;
666
+ justify-content: center;
667
+ gap: 0.55rem;
668
+ margin-bottom: 1.3rem;
669
+ user-select: none;
670
+ }
671
+ #welcome .wbrand-head {
672
  position: relative;
673
+ display: block;
674
+ height: 3rem;
 
675
  }
676
+ #welcome .wbrand-head .duck-closed {
677
+ display: block;
678
+ height: 100%;
679
+ width: auto;
 
 
 
 
 
 
680
  }
681
+ /* The open frame's canvas (460x333) is a hair wider/taller than the
682
+ closed one (454x269): the dropped jaw sticks out past the head.
683
+ Offsets pin the head in place while the jaw hangs below. */
684
+ #welcome .wbrand-head .duck-open {
685
+ position: absolute;
686
+ top: -0.75%;
687
+ left: -1.1%;
688
+ width: 101.3%;
689
+ max-width: none;
690
+ height: auto;
691
+ opacity: 0;
692
+ }
693
+ #welcome .wbrand:hover .duck-closed { opacity: 0; }
694
+ #welcome .wbrand:hover .duck-open { opacity: 1; }
695
+ #welcome .wbrand-name {
696
  font-weight: 700;
697
+ letter-spacing: -0.03em;
698
+ font-size: 1.6rem;
699
+ color: #fff;
 
700
  }
701
+ /* Landing-grade h1: same type conventions as the vitrine hero
702
+ (600, tight tracking, line-height 1), the loudest line on the page. */
703
+ #welcome .title {
704
+ margin: 0 auto;
705
+ max-width: 20ch;
706
+ font-size: clamp(2.4rem, 5vw, 3.75rem);
707
+ font-weight: 600;
708
+ letter-spacing: -0.03em;
709
+ color: #fff;
710
+ line-height: 1.02;
711
  text-wrap: balance;
712
  }
713
+ #welcome .label {
714
+ font-size: 0.68rem;
715
+ font-weight: 600;
716
+ letter-spacing: 0.18em;
717
+ text-indent: 0.18em;
 
 
 
 
 
718
  text-transform: uppercase;
719
+ color: var(--orange);
720
  }
721
+ #welcome .tagline {
722
+ margin: 1.25rem auto 0;
723
+ max-width: 36ch;
724
+ font-size: clamp(1.05rem, 1.5vw, 1.3rem);
725
+ line-height: 1.5;
726
+ letter-spacing: -0.012em;
727
+ color: rgba(255, 255, 255, 0.72);
728
+ text-wrap: balance;
729
  }
 
730
  #welcome .grid {
731
  display: grid;
732
  grid-template-columns: repeat(3, 1fr);
733
+ gap: 0.55rem;
734
+ margin-top: 1.4rem;
735
+ }
736
+ #welcome .grid-pad { display: none; }
737
+ body.pad-connected #welcome .grid-kb { display: none; }
738
+ body.pad-connected #welcome .grid-pad { display: grid; }
739
+ #welcome .kb-hint { display: block; }
740
+ #welcome .pad-hint { display: none; }
741
+ body.pad-connected #welcome .kb-hint { display: none; }
742
+ body.pad-connected #welcome .pad-hint { display: block; }
743
  #welcome .tile {
744
+ padding: 0.85rem 0.45rem 0.75rem;
745
+ border: 1px solid rgba(255, 255, 255, 0.08);
746
+ border-radius: 18px;
747
+ background: rgba(255, 255, 255, 0.035);
748
  }
 
 
749
  #welcome .caps {
750
  display: flex;
751
  flex-direction: column;
 
756
  }
757
  #welcome .caps .krow {
758
  display: flex;
759
+ gap: 0.22rem;
760
  justify-content: center;
761
  }
762
  #welcome kbd {
763
  display: inline-flex;
764
  align-items: center;
765
  justify-content: center;
766
+ min-width: 1.65rem;
767
+ height: 1.65rem;
768
  padding: 0 0.45rem;
769
  font: inherit;
770
  font-size: 0.68rem;
771
  font-weight: 600;
772
+ color: #fff;
773
+ background: #14141c;
774
+ border: 2px solid var(--ink);
775
+ border-radius: 8px;
776
+ box-shadow: 0 0 0 2px rgba(255, 255, 255, 0.82);
777
  }
 
 
778
  #welcome .caps.cluster kbd { min-width: 1.4rem; height: 1.4rem; padding: 0 0.3rem; font-size: 0.62rem; }
779
  #welcome .t-name {
780
  margin-top: 0.55rem;
781
+ font-size: 0.72rem;
782
+ font-weight: 600;
783
+ letter-spacing: -0.01em;
784
+ text-transform: none;
785
+ text-indent: 0;
786
+ color: #fff;
787
  }
788
  #welcome .t-hint {
789
+ margin-top: 0.18rem;
 
 
 
 
 
790
  font-size: 0.62rem;
791
+ font-weight: 600;
792
+ letter-spacing: 0.12em;
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
793
  text-transform: uppercase;
794
+ color: var(--orange);
795
  }
796
+ #welcome .orbit-hint {
797
+ margin-top: 1rem;
798
  font-size: 0.68rem;
799
+ font-weight: 600;
800
+ letter-spacing: 0.08em;
801
+ color: rgba(255, 255, 255, 0.4);
802
  }
803
+ #welcome .foot {
804
+ display: flex;
805
+ flex-direction: column;
806
+ align-items: center;
807
+ gap: 1rem;
808
+ margin-top: 1.6rem;
809
+ width: 100%;
810
  }
811
+ #welcome .wbtn {
812
  appearance: none;
813
+ border: 3px solid var(--ink);
814
+ background: var(--orange);
815
  color: var(--ink);
816
  font: inherit;
817
+ font-size: 1.02rem;
818
+ font-weight: 600;
819
+ letter-spacing: -0.01em;
820
+ text-transform: none;
821
+ text-indent: 0;
822
+ padding: 0.85rem 1.9rem;
823
  border-radius: 999px;
824
+ box-shadow: 0 0 0 3px #fff;
825
  cursor: pointer;
826
+ transition: transform 0.2s ease, box-shadow 0.2s ease;
827
  }
828
+ #welcome .wbtn:hover {
829
+ transform: translateY(-2px);
830
+ box-shadow: 0 0 0 3px #fff, 0 10px 26px rgba(255, 122, 47, 0.3);
831
  }
832
+ #welcome .wbtn:active {
833
+ transform: translateY(1px);
834
+ box-shadow: 0 0 0 3px #fff;
835
  }
836
+ #welcome .wbtn:focus-visible {
837
+ outline: 2px solid #fff;
838
+ outline-offset: 4px;
 
 
 
839
  }
 
 
840
  @media (max-width: 480px) {
841
+ #welcome .wbrand-head { height: 2.4rem; }
842
+ #welcome .wbrand-name { font-size: 1.3rem; }
843
+ #welcome .title { font-size: 2rem; }
844
+ #welcome .tagline { font-size: 0.95rem; }
845
+ #welcome .grid { grid-template-columns: repeat(2, 1fr); }
846
+ #welcome .tile { padding: 0.65rem 0.35rem 0.6rem; }
847
+ #welcome .caps { height: 2.8rem; }
848
+ #welcome .wbtn { font-size: 0.95rem; padding: 0.75rem 1.5rem; }
849
+ }
850
+ @media (max-height: 700px) {
851
+ #welcome .tagline { margin-top: 0.85rem; }
852
+ #welcome .foot { margin-top: 1.1rem; }
853
+ }
 
 
 
 
 
 
 
 
 
 
 
 
854
  </style>
855
  </head>
856
  <body>
857
+ <div id="preboot" aria-hidden="true"><div class="spin"></div></div>
858
  <div id="scene"></div>
859
  <div class="halftone"></div>
860
+ <!-- CRT overlay: each child is one effect; delete a div together with
861
+ its CSS block in <style> to drop that effect. -->
862
+ <div id="crt" aria-hidden="true">
863
+ <div class="crt-scanlines"></div>
864
+ <div class="crt-noise"></div>
865
+ <div class="crt-vignette"></div>
866
+ </div>
867
+ <button type="button" id="back-btn">&larr; Back</button>
868
+ <div id="quickbar">
869
+ <div class="dots" id="quick-colors" role="group" aria-label="Duck colours"></div>
870
+ <div class="seg" id="quick-loco" role="group" aria-label="Locomotion mode">
871
+ <button type="button" data-loco="legs" class="on" aria-pressed="true">Legs</button>
872
+ <button type="button" data-loco="rollers" aria-pressed="false">Rollers</button>
873
+ </div>
874
+ </div>
875
+ <div id="mode-wrap">
876
+ <span class="caption">Mode</span>
877
+ <div class="mode" id="mode-label"><span class="mode-text">Run</span></div>
878
  </div>
879
  <div id="stats-wrap">
880
  <div class="osd-top">
881
  <span class="osd-rec"><span class="rec-dot">&#9679;</span>&nbsp;REC</span>
882
  <span id="osd-time">&#9654; 00:00:00</span>
883
  </div>
884
+ <canvas id="minimap" width="264" height="264" aria-hidden="true"></canvas>
 
885
  <div id="osd-load" hidden>LOADING ROLLERS<span class="osd-load-cursor">&#9608;</span></div>
886
  </div>
887
+ <div id="ctrl-hz"></div>
888
  <div id="hud" hidden>
889
  <div class="stack">
890
  <span class="caption">Colour</span>
 
925
  </div>
926
  <div id="loading"><div class="post"></div></div>
927
 
928
+ <div id="welcome" hidden role="dialog" aria-modal="true" aria-label="Microduck">
929
+ <div class="wstep" id="wstep-0">
930
+ <div class="wbrand" aria-hidden="true">
931
+ <span class="wbrand-head">
932
+ <img class="duck-closed" id="wbrand-head-closed" alt="" />
933
+ <img class="duck-open" id="wbrand-head-open" alt="" />
934
+ </span>
935
+ <span class="wbrand-name">MicroDuck</span>
936
+ </div>
937
+ <div class="facts">
938
+ <span>$399</span>
939
+ <span>Ships before Christmas 2026</span>
940
+ </div>
941
+ <h1 class="title">A tiny biped you drive in the browser</h1>
942
+ <p class="tagline">Same body, same policies as the real robot.</p>
943
+ <div class="grid grid-kb">
944
+ <div class="tile">
945
+ <div class="caps cluster">
946
+ <span class="krow"><kbd>&uarr;</kbd></span>
947
+ <span class="krow"><kbd>&larr;</kbd><kbd>&darr;</kbd><kbd>&rarr;</kbd></span>
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
948
  </div>
949
+ <div class="t-name">Move</div>
950
+ <div class="t-hint">arrows or ZQSD</div>
951
+ </div>
952
+ <div class="tile">
953
+ <div class="caps"><span class="krow"><kbd>A</kbd><kbd>E</kbd></span></div>
954
+ <div class="t-name">Kick</div>
955
+ <div class="t-hint">left / right</div>
956
+ </div>
957
+ <div class="tile">
958
+ <div class="caps"><kbd>R</kbd></div>
959
+ <div class="t-name">Roll</div>
960
+ <div class="t-hint">barrel roll</div>
961
+ </div>
962
+ <div class="tile">
963
+ <div class="caps"><kbd>B</kbd></div>
964
+ <div class="t-name">Ball</div>
965
+ <div class="t-hint">pop one in</div>
966
+ </div>
967
+ <div class="tile">
968
+ <div class="caps"><kbd>C</kbd></div>
969
+ <div class="t-name">Camera</div>
970
+ <div class="t-hint">toggle chase</div>
971
+ </div>
972
+ <div class="tile">
973
+ <div class="caps"><kbd>Space</kbd></div>
974
+ <div class="t-name">Reset</div>
975
+ <div class="t-hint">fresh start</div>
976
+ </div>
977
+ </div>
978
+ <div class="grid grid-pad">
979
+ <div class="tile">
980
+ <div class="caps"><kbd>LS</kbd></div>
981
+ <div class="t-name">Move</div>
982
+ <div class="t-hint">left stick</div>
983
+ </div>
984
+ <div class="tile">
985
+ <div class="caps"><span class="krow"><kbd>LB</kbd><kbd>RB</kbd></span></div>
986
+ <div class="t-name">Kick</div>
987
+ <div class="t-hint">left / right</div>
988
+ </div>
989
+ <div class="tile">
990
+ <div class="caps"><kbd>X</kbd></div>
991
+ <div class="t-name">Roll</div>
992
+ <div class="t-hint">barrel roll</div>
993
  </div>
994
+ <div class="tile">
995
+ <div class="caps"><kbd>Y</kbd></div>
996
+ <div class="t-name">Ball</div>
997
+ <div class="t-hint">pop one in</div>
 
 
998
  </div>
999
+ <div class="tile">
1000
+ <div class="caps"><kbd>RS</kbd></div>
1001
+ <div class="t-name">Camera</div>
1002
+ <div class="t-hint">orbit</div>
1003
+ </div>
1004
+ <div class="tile">
1005
+ <div class="caps"><kbd>&darr;</kbd></div>
1006
+ <div class="t-name">Sit</div>
1007
+ <div class="t-hint">stand up</div>
1008
  </div>
 
 
 
1009
  </div>
1010
+ <p class="orbit-hint kb-hint">drag to orbit &middot; scroll to zoom</p>
1011
+ <p class="orbit-hint pad-hint">R3 chase &middot; RT quack</p>
1012
+ </div>
1013
+
1014
+ <div class="foot">
1015
+ <button type="button" class="wbtn" id="welcome-cta">Waddle in</button>
1016
  </div>
1017
  </div>
1018
 
1019
  <script>
1020
+ // Single-page title / pause menu: pitch + controls + CTA. The first
1021
+ // "Waddle in" cues the BIOS; later Esc / the in-game Menu button
1022
+ // reopen it as pause. The in-game quickbar (bottom-left) picks the
1023
+ // colour and locomotion live: colour clicks the hidden #swatches
1024
+ // buttons, loco goes through rl.setLoco via a small reconciler that
1025
+ // retries until the game allows the switch (mid-roll, respawn, ...).
1026
  (() => {
1027
  const modal = document.getElementById("welcome");
 
 
1028
  const sign = new URLSearchParams(location.search).get("__sign");
1029
  const signedUrl = (u) => (sign ? `${u}?__sign=${encodeURIComponent(sign)}` : u);
1030
+ document.getElementById("wbrand-head-closed").src = signedUrl("./assets/duck-head-mark.webp");
1031
+ document.getElementById("wbrand-head-open").src = signedUrl("./assets/duck-head-mark-open.webp");
1032
+
1033
+ const cta = document.getElementById("welcome-cta");
1034
+
1035
+ // ── Quickbar: colour + locomotion, applied live ─────────────────
1036
+ const want = { color: "classic", loco: "legs" };
1037
+ const gameSwatch = (name) =>
1038
+ document.querySelector(`#swatches button[aria-label="${name} colours"]`);
1039
+ const reconcile = () => {
1040
+ let done = true;
1041
+ const g = gameSwatch(want.color);
1042
+ if (g) {
1043
+ syncDotColors();
1044
+ if (!g.classList.contains("on")) g.click();
1045
+ } else done = false;
1046
+ const rl = window.rl;
1047
+ if (rl) {
1048
+ if (want.loco === "rollers") rl.ensureRollers().catch(() => {});
1049
+ if (rl.loco !== want.loco) {
1050
+ done = false;
1051
+ if (!rl.locoSwitching) rl.setLoco(want.loco);
1052
+ }
1053
+ } else if (want.loco !== "legs") done = false;
1054
+ return done;
1055
+ };
1056
+ let reconciler = null;
1057
+ const kick = () => {
1058
+ if (reconciler || reconcile()) return;
1059
+ reconciler = setInterval(() => {
1060
+ if (reconcile()) { clearInterval(reconciler); reconciler = null; }
1061
+ }, 250);
1062
+ };
1063
+
1064
+ const DOTS = { classic: "#ff7a2f", charcoal: "#2f2f33", purple: "#7a5fc9", blue: "#6f9ec4" };
1065
+ const dotsEl = document.getElementById("quick-colors");
1066
+ const dotBtns = new Map();
1067
+ for (const [name, hex] of Object.entries(DOTS)) {
1068
+ const b = document.createElement("button");
1069
+ b.type = "button";
1070
+ b.setAttribute("aria-label", `${name} colours`);
1071
+ const dot = document.createElement("span");
1072
+ dot.className = "dot";
1073
+ dot.style.background = hex;
1074
+ b.appendChild(dot);
1075
+ b.addEventListener("click", () => { want.color = name; syncPickers(); kick(); });
1076
+ dotsEl.appendChild(b);
1077
+ dotBtns.set(name, b);
1078
+ }
1079
+ const syncDotColors = () => {
1080
+ for (const [name, b] of dotBtns) {
1081
+ const g = gameSwatch(name);
1082
+ if (g) b.querySelector(".dot").style.background = g.style.background;
1083
+ }
1084
+ };
1085
+
1086
+ const segBtns = [...document.querySelectorAll("#quick-loco button")];
1087
+ for (const b of segBtns) {
1088
+ b.addEventListener("click", () => { want.loco = b.dataset.loco; syncPickers(); kick(); });
1089
+ }
1090
+ const syncPickers = () => {
1091
+ for (const [name, b] of dotBtns) {
1092
+ b.classList.toggle("on", name === want.color);
1093
+ b.setAttribute("aria-pressed", String(name === want.color));
1094
+ }
1095
+ for (const b of segBtns) {
1096
+ b.classList.toggle("on", b.dataset.loco === want.loco);
1097
+ b.setAttribute("aria-pressed", String(b.dataset.loco === want.loco));
1098
+ }
1099
+ };
1100
+ syncPickers();
1101
+ // Reflect game-side switches (M key, pad hold) back into the bar.
1102
+ setInterval(() => {
1103
+ if (reconciler) return;
1104
+ const on = document.querySelector("#swatches button.on");
1105
+ if (on) want.color = (on.getAttribute("aria-label") || "").split(" ")[0] || want.color;
1106
+ if (window.rl && !window.rl.locoSwitching) want.loco = window.rl.loco;
1107
+ syncDotColors();
1108
+ syncPickers();
1109
+ }, 500);
1110
+
1111
+ const pollPad = () => {
1112
+ const gp = [...(navigator.getGamepads?.() ?? [])].find((p) => p && p.connected);
1113
+ document.body.classList.toggle("pad-connected", !!gp);
1114
+ };
1115
+ window.addEventListener("gamepadconnected", pollPad);
1116
+ window.addEventListener("gamepaddisconnected", pollPad);
1117
+ pollPad();
1118
+
1119
+ // ── Menu open / close ────────────────────────────────────────────
1120
  const open = () => {
1121
  modal.hidden = false;
1122
  modal.classList.remove("closing");
1123
+ document.body.classList.add("welcome-open");
1124
+ document.dispatchEvent(new CustomEvent("microduck:menu-open"));
1125
+ cta.textContent = window.__microduckEntered ? "Resume" : "Waddle in";
1126
  };
1127
  const close = () => {
1128
  modal.classList.add("closing");
1129
+ document.body.classList.remove("welcome-open");
1130
+ document.dispatchEvent(new CustomEvent("microduck:menu-close"));
1131
  setTimeout(() => { modal.hidden = true; }, 380);
 
 
 
1132
  if (!window.__microduckEntered) {
1133
  window.__microduckEntered = true;
1134
  document.dispatchEvent(new CustomEvent("microduck:enter"));
1135
  }
1136
  };
1137
+
1138
+ // Pre-boot veil: hold the title page until its logo and the fonts
1139
+ // are in (grey spinner meanwhile), capped so a broken asset can
1140
+ // never wall the menu off.
1141
+ const preboot = document.getElementById("preboot");
1142
+ const dropPreboot = () => {
1143
+ preboot.classList.add("off");
1144
+ setTimeout(() => preboot.remove(), 300);
1145
+ };
1146
+ if (new URLSearchParams(location.search).get("boot") === "1") {
1147
+ modal.hidden = true;
1148
+ window.__microduckEntered = true;
1149
+ dropPreboot();
1150
+ document.dispatchEvent(new CustomEvent("microduck:enter"));
1151
+ } else {
1152
+ const headImg = document.getElementById("wbrand-head-closed");
1153
+ const imgReady = new Promise((res) => {
1154
+ if (headImg.complete && headImg.naturalWidth) res();
1155
+ else {
1156
+ headImg.addEventListener("load", res, { once: true });
1157
+ headImg.addEventListener("error", res, { once: true });
1158
+ }
1159
+ });
1160
+ const fontsReady = document.fonts?.ready ?? Promise.resolve();
1161
+ const cap = new Promise((res) => setTimeout(res, 2500));
1162
+ Promise.race([Promise.all([imgReady, fontsReady]), cap]).then(() => {
1163
+ open();
1164
+ dropPreboot();
1165
+ });
1166
+ }
1167
+ cta.addEventListener("click", close);
1168
+ document.getElementById("back-btn").addEventListener("click", open);
1169
+ const biosUp = () => document.getElementById("loading")?.style.display === "flex";
1170
  window.addEventListener("keydown", (e) => {
1171
+ if (e.code === "Enter" && !modal.hidden) {
1172
+ if (e.target instanceof HTMLButtonElement && e.target !== cta) return;
1173
+ e.preventDefault();
1174
+ close();
1175
+ return;
1176
+ }
1177
+ if (e.code !== "Escape") return;
1178
+ if (biosUp()) return;
1179
+ if (!modal.hidden) {
1180
+ if (window.__microduckEntered) close();
1181
+ return;
1182
+ }
1183
+ if (window.__microduckEntered) open();
1184
  });
 
1185
  })();
1186
  </script>
1187
 
 
1197
  appended: a plain src attribute can 401 when the browser blocks the
1198
  *.static.hf.space auth cookie inside the hub iframe. -->
1199
  <script type="module">
1200
+ const V = "65"; // bump on rl.js changes to defeat heuristic caching
1201
  const sign = new URLSearchParams(location.search).get("__sign");
1202
  const suffix = sign ? `&__sign=${encodeURIComponent(sign)}` : "";
1203
  import(`./rl.js?v=${V}${suffix}`);
rl.js CHANGED
@@ -46,8 +46,16 @@ window.__hfSigned = signed; // duck.js uses it for kinematics + STL requests
46
  const SELF_V = new URL(import.meta.url).searchParams.get("v") ?? "0";
47
  const { buildRig, cloneRig, loadKinematics, setJoint, setJawOpen, MODEL_DIR, MESH_VERSION } =
48
  await import(signed(`./duck.js?v=${SELF_V}`));
49
- const { VARIANTS, VARIANT_NAMES, materialHookFor, randomVariantName, applyVariant, specToHex } =
50
  await import(signed(`./variants.js?v=${SELF_V}`));
 
 
 
 
 
 
 
 
51
 
52
  ort.env.wasm.wasmPaths = "https://cdn.jsdelivr.net/npm/onnxruntime-web@1.27.0/dist/";
53
  ort.env.wasm.numThreads = 1; // static hosting sends no COOP/COEP headers
@@ -93,8 +101,11 @@ const CTRL_DT = TIMESTEP * DECIMATION; // 50 Hz
93
  // No strafe input anymore: the lateral cmd slot stays zeroed for the obs.
94
  const VEL_FWD = 0.25, VEL_BACK = -0.2, VEL_ANG = 1.0;
95
  // Roller mode limits, from the runtime's roller branch: asymmetric vx
96
- // (0.6 push / 0.5 brake), no lateral, +-1.0 rad/s heading.
97
- const RVEL_FWD = 0.6, RVEL_BACK = -0.5, RVEL_ANG = 1.0;
 
 
 
98
  // Crouch-glide one-shot: command = [cos(2pi*phase), sin(2pi*phase), 0],
99
  // phase advancing at 1/CROUCH_PERIOD_S per second and the cycle exiting
100
  // at 0.7 - exactly the runtime's ground-pick slot the policy was trained
@@ -111,11 +122,22 @@ const BALL_PARK_POS = "50 0 0.05";
111
  const ARENA_HALF = 1.5; // inner half-size, m
112
  const ARENA_WALL_H = 0.25;
113
  const ARENA_WALL_T = 0.05;
 
 
 
 
 
 
 
 
 
 
 
114
 
115
  const mount = document.getElementById("scene");
116
  const loadingEl = document.getElementById("loading");
117
  const hudEl = document.getElementById("hud");
118
- const statsEl = document.getElementById("stats");
119
  const osdTimeEl = document.getElementById("osd-time");
120
 
121
  // BIOS/POST boot readout. The real load runs silently behind the welcome
@@ -124,16 +146,27 @@ const osdTimeEl = document.getElementById("osd-time");
124
  // finished, or an honest live tracker (cursor blinking on the pending
125
  // line) when the user enters mid-load. Never slows the actual boot.
126
  const postEl = loadingEl.querySelector(".post");
127
- const bootLog = []; // { label, status, raw, el } - status null = pending
128
- const lineText = (e) =>
129
- e.raw || e.status === null ? e.label : `${e.label} `.padEnd(26, ".") + ` ${e.status}`;
 
 
 
 
 
130
  const bootLine = (label) => {
131
- const entry = { label, status: null, raw: false, el: null };
132
  bootLog.push(entry);
133
- return (status = "OK") => {
134
  entry.status = status;
135
  if (entry.el) entry.el.textContent = lineText(entry);
136
  };
 
 
 
 
 
 
137
  };
138
  // Plain line with no dotted leader (header / error text).
139
  const bootNote = (label) => {
@@ -144,8 +177,27 @@ bootLine("MEMORY CHECK")("640K OK");
144
  bootLine("DUCK FIRMWARE")("PRESENT");
145
 
146
  let bootDone = false;
 
147
  let biosStarted = false;
148
  const biosSleep = (ms) => new Promise((r) => setTimeout(r, ms));
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
149
  async function playBios() {
150
  if (biosStarted) return;
151
  biosStarted = true;
@@ -155,28 +207,68 @@ async function playBios() {
155
  if (i < bootLog.length) {
156
  const entry = bootLog[i++];
157
  entry.el = document.createElement("div");
158
- entry.el.textContent = lineText(entry);
159
  postEl.appendChild(entry.el);
160
- // Honest mode: hold on a stage still in flight (its completer will
161
- // fill in the status), cursor blinking on it via CSS.
162
- while (entry.status === null && !bootDone) await biosSleep(60);
163
- await biosSleep(105); // rapid-fire pacing for finished lines
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
164
  } else if (bootDone) {
165
  break;
166
  } else {
167
- await biosSleep(60); // waiting for the next real stage
 
 
 
168
  }
169
  }
170
  const ready = document.createElement("div");
171
  ready.textContent = "READY.";
172
  postEl.appendChild(ready);
173
  await biosSleep(500);
174
- // Cue the world draw-in + duck dissolve right as the overlay fades.
175
- // (Only ever reached after bootDone, so the scene exists by now.)
176
- startEntrance();
177
  loadingEl.classList.add("off");
 
 
 
178
  await biosSleep(500);
179
  loadingEl.style.display = "none";
 
 
 
180
  }
181
  // Only the initial entry triggers the replay; reopening the modal from
182
  // the brand later never replays (biosStarted latches).
@@ -184,22 +276,32 @@ if (window.__microduckEntered) playBios();
184
  else document.addEventListener("microduck:enter", () => playBios(), { once: true });
185
 
186
  // Surface boot failures on the page itself: a rejected top-level await
187
- // otherwise leaves a dead page with no visible error.
 
 
 
 
188
  window.addEventListener("unhandledrejection", (e) => {
189
- const msg = e.reason?.stack || e.reason?.message || String(e.reason);
190
- bootNote(`Boot failed: ${msg}`);
191
- playBios();
192
  console.error("[rl] unhandled rejection", e.reason);
 
193
  });
194
  window.addEventListener("error", (e) => {
195
- bootNote(`Boot failed: ${e.message}`);
196
- playBios();
197
  });
 
 
 
198
  const traced = (label, p) => {
199
  const done = bootLine(label);
200
  return p.then(
201
  (v) => { done("OK"); return v; },
202
- (err) => { done("FAIL"); console.error(`[rl] ${label} FAILED`, err); throw err; },
 
 
 
 
 
203
  );
204
  };
205
 
@@ -275,7 +377,7 @@ async function buildPhysicsXml(xmlFile) {
275
  // wheels) starts at zero. The ball's 7 free-joint values MUST be
276
  // appended or nq won't match and the model won't compile; parked 50 m
277
  // away = effectively absent.
278
- const qposFree = "0 0 0.12 1 0 0 0";
279
  const poseByName = new Map(JOINT_NAMES.map((n, i) => [n, DEFAULT_POSE[i]]));
280
  const qposJoints = [...doc.querySelectorAll("body > joint")]
281
  .map((j) => poseByName.get(j.getAttribute("name")) ?? 0)
@@ -317,32 +419,67 @@ async function addMeshesToVfs(files) {
317
  }),
318
  );
319
  }
320
- await addMeshesToVfs(meshFiles);
 
 
 
 
 
 
321
  doneMeshes(`${meshFiles.length} FILES`);
322
 
323
  const sessions = {};
324
- let currentVariant = randomVariantName();
 
325
  const rigPromise = (async () => {
326
  const doneRig = bootLine("RENDER RIG");
327
- const rig = await buildRig(k, { materialForMesh: materialHookFor(VARIANTS[currentVariant]) });
328
- doneRig("OK");
329
- return rig;
 
 
 
 
 
 
330
  })();
 
 
 
331
  const donePolicies = bootLine("LOADING POLICIES");
332
  const sessionOpts = { executionProviders: ["wasm"] };
333
- [sessions.walk, sessions.sitstand, sessions.roll, sessions.kickL, sessions.kickR] =
334
- await Promise.all([
335
- ort.InferenceSession.create(signed(POLICIES.walk), sessionOpts),
336
- ort.InferenceSession.create(signed(POLICIES.sitstand), sessionOpts),
337
- ort.InferenceSession.create(signed(POLICIES.roll), sessionOpts),
338
- ort.InferenceSession.create(signed(POLICIES.kickL), sessionOpts),
339
- ort.InferenceSession.create(signed(POLICIES.kickR), sessionOpts),
340
- ]);
 
 
 
 
 
 
 
 
 
 
 
 
341
  donePolicies("5/5");
342
 
343
  const doneCompile = bootLine("COMPILING PHYSICS");
344
- let model = mujoco.MjModel.from_xml_string(xml, vfs);
345
- let data = new mujoco.MjData(model);
 
 
 
 
 
 
 
346
  doneCompile("COMPILED");
347
 
348
  // Addresses resolved once per compiled variant. qpos/qvel/sensordata views
@@ -382,25 +519,35 @@ let uiReady = false;
382
  const lastAction = new Float32Array(NUM_JOINTS);
383
  const obs = new Float32Array(OBS_SIZE);
384
  const cmd = new Float32Array(CMD_SIZE); // [vx, vy, wz, head(4), body(6)]
385
- const velCmd = new Float32Array(3); // twist command, driven by held keys
386
- // Gamepad twist (EMA-smoothed like the robot runtime). Declared up here:
387
- // the control loop reads it before the input section below has evaluated.
388
- const padCmd = new Float32Array(3);
389
- let padActive = false;
390
- const padPrev = {
391
- x: false, y: false, rb: false, lb: false, r3: false, dpadDown: false,
392
- dpadUp: false, dpadUpAt: 0, dpadUpFired: false, rtArmed: true,
393
- };
394
- // Right-stick camera state, read by renderStats before the gamepad
395
- // section below has evaluated (same reason as padCmd above).
 
396
  let padOrbitLive = false; // HUD: RS keycap lit while deflected
397
- // Declared before the control loop starts: buildObs reads it via
398
- // effectiveCmd on the very first control step.
399
- const held = new Set();
400
  // Robot input gate: twist commands, mode changes, rolls, kicks and ball
401
  // spawns all stay inert until the entrance sequence has fully played out
402
- // (updateEntrance flips this exactly when the choreography completes).
 
403
  let inputLocked = true;
 
 
 
 
 
 
 
 
 
 
 
404
 
405
  // "walk" is the main velocity-tracking mode in BOTH variants (legs walking
406
  // policy or roller drive policy - activeSession() picks); "crouch" is the
@@ -412,8 +559,9 @@ const isKick = () => mode === "kickL" || mode === "kickR";
412
  // (mesh hidden), true once popped in front of the duck.
413
  let ballActive = false;
414
 
415
- // The twist the policy actually receives: live gamepad sticks win over the
416
- // held keys. No input means zero command - the duck stands in place.
 
417
  // Shared by buildObs and the HUD mini-sticks so they can never disagree.
418
  // Mid-roll every movement input is ignored (zero twist) until the roll
419
  // hands back to walk on its own - steering would only knock the roll over.
@@ -421,7 +569,7 @@ const ZERO_CMD = new Float32Array(3);
421
  function effectiveCmd() {
422
  if (inputLocked || mode === "roll" || mode === "crouch" || isKick() || postKickLock > 0)
423
  return ZERO_CMD;
424
- return padActive ? padCmd : velCmd;
425
  }
426
  // One-shot roll tracking: trigger time + whether the trunk actually
427
  // tipped over yet. Set by triggerRoll, cleared when we hand back to walk.
@@ -449,13 +597,15 @@ let postKickLock = 0;
449
  // up handed to the walking policy mid-motion.
450
  let sitTimer = null;
451
  let standTimer = null;
 
452
  function clearModeTimers() {
453
  clearTimeout(sitTimer); sitTimer = null;
454
  clearTimeout(standTimer); standTimer = null;
455
  }
456
 
457
  function resetSim() {
458
- // A reset is a full transition to the walking stand: cancel any roll in
 
459
  // flight and any pending sit/stand hand-over, or they'd replay on the
460
  // freshly reset duck.
461
  clearModeTimers();
@@ -463,16 +613,20 @@ function resetSim() {
463
  kickRun = null;
464
  crouchRun = null;
465
  postKickLock = 0;
 
466
  mode = "walk";
467
  mujoco.mj_resetDataKeyframe(model, data, standKeyId);
468
  mujoco.mj_forward(model, data);
469
  lastAction.fill(0);
470
  sitFlag = 0;
471
- // The keyframe re-parks the ball 50 m away; reflect that in the flag
472
- // so the render loop hides the mesh again.
 
 
473
  ballActive = false;
474
  // Buttons reflect sitFlag; keep them honest after auto-resets.
475
  if (uiReady) syncButtons();
 
476
  }
477
  resetSim();
478
 
@@ -480,8 +634,31 @@ resetSim();
480
  // random heading + distance jitter so repeated pops land somewhere
481
  // nearby instead of always on the same spot. qvel is zeroed so a respawn
482
  // doesn't carry the old momentum.
483
- function spawnBall() {
484
- if (inputLocked) return;
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
485
  const qpos = data.qpos, qvel = data.qvel;
486
  // Trunk yaw from the free-joint quaternion (qpos[3..6] = w x y z);
487
  // the duck walks toward its local +X.
@@ -505,6 +682,11 @@ function spawnBall() {
505
  for (let i = 0; i < 6; i++) qvel[ballDofAdr + i] = 0;
506
  mujoco.mj_forward(model, data);
507
  ballActive = true;
 
 
 
 
 
508
  }
509
 
510
  // ── Observation ─────────────────────────────────────────────────────────
@@ -551,7 +733,21 @@ const activeSession = () =>
551
 
552
  // ── Control loop (50 Hz, async because ONNX inference is async) ────────
553
  let ctrlHz = 0;
554
- let fallenSince = null;
 
 
 
 
 
 
 
 
 
 
 
 
 
 
555
 
556
  async function controlStep() {
557
  const feeds = { obs: new ort.Tensor("float32", buildObs(), [1, OBS_SIZE]) };
@@ -562,19 +758,14 @@ async function controlStep() {
562
  for (let j = 0; j < NUM_JOINTS; j++) ctrl[j] = DEFAULT_POSE[j] + act[j] * ACTION_SCALE;
563
  for (let s = 0; s < DECIMATION; s++) mujoco.mj_step(model, data);
564
 
565
- // Fall detection: walk/sitstand have no get-up skill, so auto-reset when
566
- // down for good. Height alone would false-positive on a deep sit, so
567
- // "fallen" = trunk tilted past ~60 deg (projected gravity z above -0.5)
568
- // or sunk below the floor, sustained for over a second. The roll tumbles
569
- // the trunk on purpose and recovers on its own, so it gets a much longer
570
- // grace window before we call it stuck.
571
- const z = data.qpos[2];
572
- const now = performance.now();
573
- const tipped = obs[5] > -0.5; // projected gravity z, from the last obs
574
- const graceMs = mode === "roll" ? 5000 : 1000;
575
- if (tipped || z < 0.02) {
576
  fallenSince ??= now;
577
- if (now - fallenSince > graceMs) { resetSim(); fallenSince = null; }
578
  } else {
579
  fallenSince = null;
580
  }
@@ -636,7 +827,7 @@ async function controlStep() {
636
  mode = "walk";
637
  lastAction.fill(0);
638
  // Timed out mid-roll: don't hand a tipped duck to the walking policy
639
- // (it has no get-up skill), restart from the keyframe instead.
640
  if (!upright) resetSim();
641
  if (uiReady) syncButtons();
642
  }
@@ -667,10 +858,13 @@ const scene = new THREE.Scene();
667
  scene.background = new THREE.Color(0x08080c);
668
 
669
  const camera = new THREE.PerspectiveCamera(40, 1, 0.02, 30);
670
- camera.position.set(0.55, 0.35, 0.7);
 
 
671
 
672
- const renderer = new THREE.WebGLRenderer({ antialias: true });
673
  renderer.setPixelRatio(Math.min(2, window.devicePixelRatio));
 
674
  renderer.toneMapping = THREE.ACESFilmicToneMapping;
675
  mount.appendChild(renderer.domElement);
676
 
@@ -700,7 +894,7 @@ function makeInfiniteGrid() {
700
  depthWrite: false,
701
  uniforms: {
702
  uCell: { value: 0.1 },
703
- uSection: { value: 0.5 },
704
  uCellColor: { value: new THREE.Color(0x8e8371) },
705
  uSectionColor: { value: new THREE.Color(0xffb366) },
706
  uFadeDist: { value: 3.0 },
@@ -725,32 +919,65 @@ function makeInfiniteGrid() {
725
  // Tron-style line: a thicker antialiased core plus a faint, much
726
  // wider halo added on top (squared falloff keeps it a whisper of a
727
  // glow rather than a bloom wash).
 
 
 
 
 
728
  float gridLine(vec2 p, float size) {
729
  vec2 r = p / size;
730
  vec2 g = abs(fract(r - 0.5) - 0.5) / fwidth(r);
731
- float d = min(g.x, g.y);
732
- float core = 1.0 - smoothstep(0.0, 1.8, d);
733
- float halo = 1.0 - smoothstep(0.0, 7.0, d);
734
- return core + halo * halo * 0.22;
 
 
 
 
 
 
 
 
 
 
 
 
735
  }
736
  void main() {
737
  float cell = gridLine(vWorld.xz, uCell);
738
- float section = gridLine(vWorld.xz, uSection);
 
 
 
 
739
  float d = distance(vWorld.xz, uFocus.xz);
740
  float fade = pow(clamp(1.0 - d / uFadeDist, 0.0, 1.0), 1.6);
741
  vec3 col = mix(uCellColor, uSectionColor, clamp(section, 0.0, 1.0));
742
  float alpha = min(max(section * 0.6, cell * 0.4) * fade, 1.0);
743
- // Entrance draw-in: strokes appear radially from the arena center,
744
- // each section tile staggered by a hash so lines pop one after
745
- // another. Exactly the steady-state shader once uReveal hits 1.
 
 
 
 
746
  if (uReveal < 1.0) {
747
- float rd = length(vWorld.xz) / 6.0;
748
- vec2 tile = floor(vWorld.xz / uSection);
749
- float jit = fract(sin(dot(tile, vec2(127.1, 311.7))) * 43758.5453);
750
- float at = rd * 0.78 + jit * 0.24;
751
- // step() clamps the smoothstep tail: center tiles have at < 0.12
752
- // and would otherwise stay faintly visible at uReveal = 0.
753
- alpha *= smoothstep(at - 0.12, at, uReveal * 1.06) * step(1e-4, uReveal);
 
 
 
 
 
 
 
 
754
  }
755
  if (alpha < 0.004) discard;
756
  gl_FragColor = vec4(col, alpha);
@@ -775,7 +1002,7 @@ function makeWallGridMaterial(alongX) {
775
  side: THREE.DoubleSide,
776
  uniforms: {
777
  uCell: { value: 0.1 },
778
- uSection: { value: 0.5 },
779
  uCellColor: { value: new THREE.Color(0x8e8371) },
780
  uSectionColor: { value: new THREE.Color(0xffb366) },
781
  // Gentler radial fade than the floor: the walls sit 1.5+ m from the
@@ -801,34 +1028,61 @@ function makeWallGridMaterial(alongX) {
801
  uniform float uCell, uSection, uFadeDist, uWallH, uAlongX, uReveal;
802
  uniform vec3 uCellColor, uSectionColor, uFocus;
803
  // Same Tron-style core + faint halo as the floor grid.
 
 
 
 
 
804
  float gridLine(vec2 p, float size) {
805
  vec2 r = p / size;
806
  vec2 g = abs(fract(r - 0.5) - 0.5) / fwidth(r);
807
- float d = min(g.x, g.y);
808
- float core = 1.0 - smoothstep(0.0, 1.8, d);
809
- float halo = 1.0 - smoothstep(0.0, 7.0, d);
810
- return core + halo * halo * 0.22;
 
 
 
 
 
 
 
 
811
  }
812
  void main() {
813
  // Wall surface coords: the in-plane horizontal world axis + height.
814
  float h = mix(vWorld.z, vWorld.x, uAlongX);
815
  vec2 p = vec2(h, vWorld.y);
816
  float cell = gridLine(p, uCell);
817
- float section = gridLine(p, uSection);
 
 
 
 
818
  float d = distance(vWorld.xz, uFocus.xz);
819
  float fade = pow(clamp(1.0 - d / uFadeDist, 0.0, 1.0), 1.6);
820
  float vert = 1.0 - clamp(vWorld.y / uWallH, 0.0, 1.0);
821
  vec3 col = mix(uCellColor, uSectionColor, clamp(section, 0.0, 1.0));
822
  float alpha = min(max(section * 0.9, cell * 0.6) * fade * (0.3 + 0.7 * vert), 1.0);
823
- // Entrance draw-in: wall lines rise bottom-to-top with a per-tile
824
- // stagger. Exactly the steady-state shader once uReveal hits 1.
 
 
825
  if (uReveal < 1.0) {
826
- float hN = clamp(vWorld.y / uWallH, 0.0, 1.0);
827
- vec2 tile = floor(p / uSection);
828
- float jit = fract(sin(dot(tile, vec2(127.1, 311.7))) * 43758.5453);
829
- float at = hN * 0.76 + jit * 0.24;
830
- // Same tail clamp as the floor: fully dark until reveal begins.
831
- alpha *= smoothstep(at - 0.12, at, uReveal * 1.06) * step(1e-4, uReveal);
 
 
 
 
 
 
 
 
832
  }
833
  if (alpha < 0.004) discard;
834
  gl_FragColor = vec4(col, alpha);
@@ -904,27 +1158,25 @@ function activateLoco(name) {
904
  scene.add(rig.placer);
905
  document.body.classList.toggle("rollers", name === "rollers");
906
  resetSim();
907
- refreshVelCmd(); // held keys re-map to the new variant's velocity limits
908
  if (uiReady) syncLocoHints();
909
  }
910
 
911
  // OSD line while the roller stack streams in, BIOS-style.
912
  let osdLoadEl = null;
913
  let locoSwitching = false;
914
- async function toggleLoco() {
915
- // No switching mid one-shot (roll/kick/crouch end on their own and a
916
- // teardown now would strand their timers), or while already switching.
917
- if (locoSwitching || inputLocked || rollRun || kickRun || crouchRun || standTimer) return;
918
  locoSwitching = true;
919
  try {
920
- const next = loco === "legs" ? "rollers" : "legs";
921
- if (next === "rollers" && !locos.rollers) {
922
  osdLoadEl?.removeAttribute("hidden");
923
  await ensureRollers();
924
  }
925
- activateLoco(next);
926
  } catch (e) {
927
- rollersLoading = null; // allow a retry on transient fetch failures
928
  console.error("[rl] roller switch failed", e);
929
  } finally {
930
  osdLoadEl?.setAttribute("hidden", "");
@@ -932,119 +1184,24 @@ async function toggleLoco() {
932
  }
933
  }
934
 
935
- // ── Sci-fi entrance: Tron grid draw-in + duck dissolve-in ───────────────
936
- // Plays once per page load, right as the BIOS overlay fades (playBios
937
- // calls startEntrance). Until then the world stays hidden (grids at
938
- // reveal 0, duck fully dissolved) and every robot input is gated by
939
- // inputLocked; the sim itself keeps running so the duck stands idle
940
- // underneath and is mid-stride the moment it finishes materializing.
941
- // The duck dissolve
942
- // swaps each rig mesh onto a TEMPORARY clone of its material (the shared
943
- // matFor cache and ghost cloning stay untouched) and restores the
944
- // originals when the effect completes, so variants/ghosts behave exactly
945
- // as before once the sequence is over.
946
- const dissolveP = { value: 1.0 }; // shared across all dissolve clones
947
- let dissolveSaved = null; // Array<[mesh, originalMaterial]> while active
948
- function applyDissolve() {
949
- if (dissolveSaved) return;
950
- dissolveSaved = [];
951
- rig.placer.traverse((o) => {
952
- if (!o.isMesh) return;
953
- const orig = o.material;
954
- const m = orig.clone();
955
- m.onBeforeCompile = (shader) => {
956
- shader.uniforms.uDissolveP = dissolveP;
957
- shader.vertexShader = shader.vertexShader
958
- .replace("#include <common>", "#include <common>\nvarying vec3 vDsvW;")
959
- .replace(
960
- "#include <worldpos_vertex>",
961
- "#include <worldpos_vertex>\nvDsvW = (modelMatrix * vec4(transformed, 1.0)).xyz;",
962
- );
963
- shader.fragmentShader = shader.fragmentShader
964
- .replace("#include <common>", /* glsl */ `#include <common>
965
- varying vec3 vDsvW;
966
- uniform float uDissolveP;
967
- float dsvHash(vec3 q) { return fract(sin(dot(q, vec3(127.1, 311.7, 74.7))) * 43758.5453); }
968
- float dsvNoise(vec3 x) {
969
- vec3 i = floor(x), f = fract(x);
970
- f = f * f * (3.0 - 2.0 * f);
971
- return mix(
972
- mix(mix(dsvHash(i), dsvHash(i + vec3(1, 0, 0)), f.x),
973
- mix(dsvHash(i + vec3(0, 1, 0)), dsvHash(i + vec3(1, 1, 0)), f.x), f.y),
974
- mix(mix(dsvHash(i + vec3(0, 0, 1)), dsvHash(i + vec3(1, 0, 1)), f.x),
975
- mix(dsvHash(i + vec3(0, 1, 1)), dsvHash(i + vec3(1, 1, 1)), f.x), f.y), f.z);
976
- }`)
977
- .replace("#include <clipping_planes_fragment>", /* glsl */ `#include <clipping_planes_fragment>
978
- float dsvEdge = 0.0;
979
- if (uDissolveP < 1.0) {
980
- float dsvN = dsvNoise(vDsvW * 26.0);
981
- float dsvThr = uDissolveP * 1.15;
982
- if (dsvN > dsvThr) discard;
983
- dsvEdge = 1.0 - smoothstep(0.015, 0.1, dsvThr - dsvN);
984
- }`)
985
- .replace("#include <dithering_fragment>", /* glsl */ `#include <dithering_fragment>
986
- gl_FragColor.rgb += dsvEdge * vec3(0.5, 0.41, 0.12);`);
987
- };
988
- m.needsUpdate = true;
989
- dissolveSaved.push([o, orig]);
990
- o.material = m;
991
- });
992
- }
993
- function restoreDissolve() {
994
- if (!dissolveSaved) return;
995
- for (const [mesh, orig] of dissolveSaved) {
996
- mesh.material.dispose();
997
- mesh.material = orig;
998
- }
999
- dissolveSaved = null;
1000
- }
1001
- // Boot hidden: the duck is fully dissolved from the first rendered frame
1002
- // (the grids already start at uReveal 0), so nothing shows through the
1003
- // welcome modal's blur or during the BIOS replay.
1004
- dissolveP.value = 0;
1005
- applyDissolve();
1006
-
1007
- const ENTRANCE_GRID_S = 1.5; // floor draw-in duration
1008
- const ENTRANCE_WALL_DELAY_S = 0.4; // walls start after the floor
1009
- const ENTRANCE_DISSOLVE_START_S = 0.9; // ~60% through the floor draw
1010
- const ENTRANCE_DISSOLVE_S = 1.0;
1011
- const clamp01 = (x) => Math.min(Math.max(x, 0), 1);
1012
- const easeOutCubic = (x) => 1 - Math.pow(1 - clamp01(x), 3);
1013
- let entranceT0 = null; // wall-clock start while the sequence is playing
1014
- let entranceDone = false; // latches: once per page load
1015
- // Resolved when the entrance completes; gates the ghosts join below so a
1016
- // translucent peer can't float around in the still-hidden world.
1017
- let entranceFinishedResolve;
1018
- const entranceFinished = new Promise((r) => { entranceFinishedResolve = r; });
1019
- function startEntrance() {
1020
- if (entranceT0 !== null || entranceDone) return;
1021
- entranceT0 = performance.now();
1022
- grid.material.uniforms.uReveal.value = 0;
1023
- for (const m of wallMats) m.uniforms.uReveal.value = 0;
1024
- dissolveP.value = 0; // duck fully dissolved until its cue
1025
- applyDissolve();
1026
- }
1027
- function updateEntrance() {
1028
- if (entranceT0 === null) return;
1029
- const t = (performance.now() - entranceT0) / 1000;
1030
- grid.material.uniforms.uReveal.value = easeOutCubic(t / ENTRANCE_GRID_S);
1031
- const wallR = easeOutCubic((t - ENTRANCE_WALL_DELAY_S) / ENTRANCE_GRID_S);
1032
- for (const m of wallMats) m.uniforms.uReveal.value = wallR;
1033
- const dp = clamp01((t - ENTRANCE_DISSOLVE_START_S) / ENTRANCE_DISSOLVE_S);
1034
- dissolveP.value = dp;
1035
- if (t >= ENTRANCE_WALL_DELAY_S + ENTRANCE_GRID_S && dp >= 1) {
1036
- // Park everything in exact steady state, drop the temp materials and
1037
- // hand control to the user.
1038
- entranceT0 = null;
1039
- entranceDone = true;
1040
- grid.material.uniforms.uReveal.value = 1;
1041
- for (const m of wallMats) m.uniforms.uReveal.value = 1;
1042
- restoreDissolve();
1043
- inputLocked = false;
1044
- entranceFinishedResolve();
1045
- }
1046
  }
1047
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1048
  // Soccer-ball look computed per pixel on the sphere itself, so there is
1049
  // no pole or seam special case by design. The truncated icosahedron is
1050
  // reconstructed as a spherical Voronoi diagram over 32 sites: the 12
@@ -1178,11 +1335,25 @@ const ballMesh = new THREE.Mesh(
1178
  clearcoatRoughness: 0.35,
1179
  }),
1180
  );
 
1181
  ballMesh.visible = false;
1182
  ballGroup.add(ballMesh);
1183
  scene.add(ballGroup);
1184
 
 
 
 
 
 
 
 
 
 
 
 
 
1185
  const controls = new OrbitControls(camera, renderer.domElement);
 
1186
  controls.enableDamping = true;
1187
  controls.dampingFactor = 0.08;
1188
  controls.minDistance = 0.25;
@@ -1213,19 +1384,76 @@ const _chaseDir = new THREE.Vector3();
1213
  // and the camera position lerp absorbs the small heading correction when
1214
  // live tracking resumes (rolls end facing roughly the same way).
1215
  let chaseHeldYaw = 0;
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1216
  function updateChaseCam() {
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1217
  if (!chaseCam) return;
1218
  const qpos = data.qpos;
1219
- let yaw;
1220
  if (mode === "roll" || isKick()) {
1221
- yaw = chaseHeldYaw;
1222
  } else {
1223
- yaw = Math.atan2(
1224
  2 * (qpos[3] * qpos[6] + qpos[4] * qpos[5]),
1225
  1 - 2 * (qpos[5] * qpos[5] + qpos[6] * qpos[6]),
1226
  );
1227
- chaseHeldYaw = yaw;
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1228
  }
 
1229
  const dist = camera.position.distanceTo(controls.target);
1230
  const horiz = dist * Math.cos(CHASE_PITCH);
1231
  const vert = dist * Math.sin(CHASE_PITCH);
@@ -1247,6 +1475,49 @@ function updateChaseCam() {
1247
  }
1248
  renderer.domElement.addEventListener("pointerdown", () => { chaseCam = false; });
1249
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1250
  function resize() {
1251
  const w = mount.clientWidth, h = mount.clientHeight;
1252
  camera.aspect = w / h;
@@ -1267,22 +1538,26 @@ function syncRig() {
1267
  for (let j = 0; j < NUM_JOINTS; j++) setJoint(rig, JOINT_NAMES[j], qpos[qposAdr[j]]);
1268
  // Passive hinges (roller wheels): purely visual, driven straight from qpos.
1269
  for (const ej of extraJoints) setJoint(rig, ej.name, qpos[ej.adr]);
1270
- // Ball: raw MJCF pose inside the Z-up group, hidden while parked.
1271
- ballMesh.visible = ballActive;
1272
- if (ballActive) {
1273
- ballMesh.position.set(qpos[ballQposAdr], qpos[ballQposAdr + 1], qpos[ballQposAdr + 2]);
1274
- ballMesh.quaternion.set(
1275
- qpos[ballQposAdr + 4], qpos[ballQposAdr + 5], qpos[ballQposAdr + 6], qpos[ballQposAdr + 3],
1276
- );
1277
- }
1278
  // Follow cam: ease the orbit target toward the trunk and translate the
1279
  // camera by the same delta, so the camera-to-duck distance and viewing
1280
  // angle stay constant while the duck walks. Mouse orbit/zoom still work:
1281
- // they only change the (preserved) camera-target offset.
1282
- _target.set(qpos[0], qpos[2], -qpos[1]);
1283
- _follow.copy(_target).sub(controls.target).multiplyScalar(0.06);
1284
- controls.target.add(_follow);
1285
- camera.position.add(_follow);
 
 
 
 
 
 
 
 
 
1286
  // Keep the grid plane (and its fade center) under the action; the wall
1287
  // grids share the same radial fade focus.
1288
  grid.position.set(controls.target.x, 0, controls.target.z);
@@ -1326,6 +1601,7 @@ function playChirp() {
1326
  const quackLoud = () => {
1327
  quackAt = performance.now();
1328
  playChirp();
 
1329
  };
1330
  function jawOpenNow() {
1331
  const t = (performance.now() - quackAt) / QUACK_MS;
@@ -1364,10 +1640,17 @@ function syncLocoHints() {
1364
  if (padXLabelEl) padXLabelEl.textContent = rollers ? "crouch" : "roll";
1365
  }
1366
  const STICK_R = 15; // px, max dot travel inside the 46px stick circle
1367
- let resetFlashAt = -Infinity;
1368
  let ballFlashAt = -Infinity;
1369
  let padYFlashAt = -Infinity;
1370
 
 
 
 
 
 
 
 
 
1371
  function renderStats() {
1372
  const [vx, , wz] = effectiveCmd();
1373
  // The active policy lives in the big center label and the twist in the
@@ -1378,13 +1661,30 @@ function renderStats() {
1378
  const p2 = (n) => String(n).padStart(2, "0");
1379
  osdTimeEl.textContent =
1380
  `\u25b6 ${p2(Math.floor(t / 60))}:${p2(Math.floor(t) % 60)}:${p2(Math.floor((t % 1) * 50))}`;
1381
- statsEl.textContent =
1382
- `CTRL ${ctrlHz.toFixed(0)}HZ` + (peers ? ` \u00b7 ${peers + 1} ONLINE` : "");
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1383
 
1384
  // Mini sticks: the dot mirrors the effective twist, lit yellow while the
1385
  // user is actually driving. Normalized against the ACTIVE variant's
1386
  // velocity limits so full deflection reads the same in both.
1387
- const manual = (padActive || held.size > 0) && mode !== "roll";
1388
  const [limF, limB, limA] = velLims();
1389
  const yN = vx >= 0 ? vx / limF : vx / -limB;
1390
  // Move stick is vertical-only now that strafe is gone.
@@ -1394,10 +1694,11 @@ function renderStats() {
1394
  boxTurn.classList.toggle("live", manual && Math.abs(wz) > 0.01);
1395
 
1396
  // Keycap highlighting: each individual key lights only while its own
1397
- // action is active, and only for its own input device.
 
1398
  const sitting = mode === "sitstand" && sitFlag === 1;
1399
  for (const k of ["fwd", "back", "turnl", "turnr"]) {
1400
- keyEls[k].classList.toggle("lit", held.has(k));
1401
  }
1402
  const trick = mode === "roll" || mode === "crouch"; // same R / pad-X slot
1403
  keyEls.roll.classList.toggle("lit", trick && rollSource === "kb");
@@ -1412,89 +1713,101 @@ function renderStats() {
1412
  keyEls.padRb.classList.toggle("lit", mode === "kickR" && kickSource === "pad");
1413
  keyEls.padLb.classList.toggle("lit", mode === "kickL" && kickSource === "pad");
1414
  keyEls.padSit.classList.toggle("lit", sitting);
1415
- keyEls.padRun.classList.toggle("lit", padPrev.dpadUp);
1416
  keyEls.padRt.classList.toggle("lit", padJaw > 0.3);
1417
  keyEls.padRs.classList.toggle("lit", padOrbitLive); // while deflected
1418
  keyEls.padR3.classList.toggle("lit", chaseCam); // steady while chasing
1419
- }
1420
 
1421
- // Real implementation assigned in the gamepad section below; the render
1422
- // loop starts before that section has evaluated, hence the indirection.
1423
- let pollPad = () => {};
1424
- // Multiplayer ghosts, initialised asynchronously at the end of the module.
1425
- let ghosts = null;
1426
-
1427
- function loop() {
1428
- requestAnimationFrame(loop);
1429
- pollPad();
1430
- syncRig();
1431
- syncJaw();
1432
- ghosts?.update();
1433
- controls.update();
1434
- updateChaseCam();
1435
- updateEntrance();
1436
- renderStats();
1437
- renderer.render(scene, camera);
1438
  }
1439
- // Boot complete: the sim/HUD go live immediately. The BIOS readout (if
1440
- // the user already waddled in, or when they do) sees bootDone and closes
1441
- // with READY. + fade on its own.
1442
- bootDone = true;
1443
- hudEl.hidden = false;
1444
- loop();
1445
 
1446
- // ── Input: hold-to-command keys + HUD buttons ───────────────────────────
1447
- function refreshVelCmd() {
1448
- const [limF, limB, limA] = velLims();
1449
- velCmd[0] = held.has("fwd") ? limF : held.has("back") ? limB : 0;
1450
- velCmd[2] = held.has("turnl") ? limA : held.has("turnr") ? -limA : 0;
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1451
  }
1452
 
1453
- // e.code is physical position, so one map covers QWERTY and AZERTY:
1454
- // arrows / WASD (ZQSD) run + turn. No strafe.
1455
- const KEYMAP = {
1456
- ArrowUp: "fwd", KeyW: "fwd",
1457
- ArrowDown: "back", KeyS: "back",
1458
- ArrowLeft: "turnl", KeyA: "turnl",
1459
- ArrowRight: "turnr", KeyD: "turnr",
1460
- };
1461
-
1462
- window.addEventListener("keydown", (e) => {
1463
- if (e.repeat) return;
1464
- if (e.code === "Space") { e.preventDefault(); resetSim(); resetFlashAt = performance.now(); return; }
1465
- if (e.code === "KeyB") { spawnBall(); ballFlashAt = performance.now(); return; }
1466
- if (e.code === "KeyC") { chaseCam = !chaseCam; return; }
1467
- if (e.code === "KeyM") { toggleLoco(); return; }
1468
- if (e.code === "KeyR") { triggerRoll(); return; }
1469
- // Physical Q/E (A/E on AZERTY): explicit left / right kick, mirroring
1470
- // the pad's LB / RB.
1471
- if (e.code === "KeyQ") { triggerKick("left"); return; }
1472
- if (e.code === "KeyE") { triggerKick("right"); return; }
1473
- const act = KEYMAP[e.code];
1474
- if (!act) return;
1475
- e.preventDefault();
1476
- held.add(act);
1477
- refreshVelCmd();
1478
- });
1479
- window.addEventListener("keyup", (e) => {
1480
- const act = KEYMAP[e.code];
1481
- if (!act) return;
1482
- held.delete(act);
1483
- refreshVelCmd();
1484
- });
1485
- window.addEventListener("blur", () => { held.clear(); refreshVelCmd(); });
1486
-
1487
- // ── Gamepad: same mapping as the robot runtime (microduck_runtime) ──────
1488
- // Left stick: vertical = vx (asymmetric fwd/back), horizontal = turn,
1489
- // EMA-smoothed like the runtime's cmd_alpha. Right stick orbits the
1490
- // camera (detaches the chase cam, R3 re-toggles it). X = roll, Y = ball,
1491
- // RB/LB = kicks, DpadDown = sit/stand, DpadUp = back to run, RT = mouth.
1492
- const PAD_DEADZONE = 0.15;
1493
- const PAD_ALPHA = 0.12;
1494
- const dz = (v) => (Math.abs(v) < PAD_DEADZONE ? 0 : v);
1495
-
1496
- // Right-stick camera orbit, in OrbitControls-compatible terms: rebuild
1497
- // the camera-target offset as a spherical, nudge azimuth/elevation, and
1498
  // re-place the camera. controls.update() then runs on the result, so the
1499
  // damping bookkeeping never fights it (same trick as updateChaseCam).
1500
  // The stick does not move the camera directly: it steers an angular
@@ -1528,109 +1841,94 @@ function padOrbitStep(rx, ry, dt) {
1528
  camera.lookAt(controls.target);
1529
  }
1530
 
1531
- let padPollT = performance.now();
1532
- pollPad = function pollGamepad() {
1533
- const now = performance.now();
1534
- // Clamped so a background-tab stall can't slingshot the camera.
1535
- const dt = Math.min((now - padPollT) / 1000, 0.05);
1536
- padPollT = now;
1537
- const gp = [...(navigator.getGamepads?.() ?? [])].find((p) => p && p.connected);
1538
- document.body.classList.toggle("pad-connected", !!gp);
1539
- if (!gp) {
1540
- if (padActive) { padActive = false; padCmd.fill(0); padJaw = 0; }
 
 
 
 
 
 
 
 
1541
  padOrbitLive = false;
1542
  padOrbitVel.az = 0;
1543
  padOrbitVel.el = 0;
1544
- return;
1545
- }
1546
- // Left stick only: vertical = forward/back, horizontal = turn.
1547
- // (No strafe; the right stick no longer drives movement.)
1548
- const lx = dz(gp.axes[0] ?? 0), ly = dz(gp.axes[1] ?? 0);
1549
- const up = -ly; // browser sticks report up as -1
1550
- const [limF, limB, limA] = velLims();
1551
- const target = [
1552
- up >= 0 ? up * limF : up * -limB,
1553
- 0,
1554
- -lx * limA,
1555
- ];
1556
- for (let i = 0; i < 3; i++) padCmd[i] += PAD_ALPHA * (target[i] - padCmd[i]);
1557
- // Sticks grab command authority on first input, release when back at rest
1558
- // (then the keyboard takes over again).
1559
- const stickInput = lx !== 0 || ly !== 0;
1560
- if (stickInput) padActive = true;
1561
- else if (padActive && Math.abs(padCmd[0]) + Math.abs(padCmd[1]) + Math.abs(padCmd[2]) < 0.01) {
1562
- padActive = false;
1563
- padCmd.fill(0);
1564
  }
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1565
 
1566
- // Right stick: camera orbit with velocity smoothing. Runs every frame
1567
- // (not just while deflected) so a released stick coasts to a stop.
1568
- padOrbitStep(dz(gp.axes[2] ?? 0), dz(gp.axes[3] ?? 0), dt);
1569
-
1570
- // R3 (right stick click): toggle the chase cam, gamepad twin of KeyC.
1571
- const r3Btn = !!gp.buttons[11]?.pressed;
1572
- if (r3Btn && !padPrev.r3) chaseCam = !chaseCam;
1573
- padPrev.r3 = r3Btn;
1574
-
1575
- // Standard mapping indices: X=2, Y=3, LB=4, RB=5, DpadDown=13, RT=7 (analog).
1576
- const x = !!gp.buttons[2]?.pressed;
1577
- if (x && !padPrev.x) triggerRoll("pad");
1578
- padPrev.x = x;
1579
-
1580
- // Y: pop / respawn the ball, same rising-edge pattern as X.
1581
- const y = !!gp.buttons[3]?.pressed;
1582
- if (y && !padPrev.y) { spawnBall(); padYFlashAt = performance.now(); }
1583
- padPrev.y = y;
1584
-
1585
- // RB / LB: right / left kick, same buttons as the robot runtime.
1586
- const rb = !!gp.buttons[5]?.pressed;
1587
- if (rb && !padPrev.rb) triggerKick("right", "pad");
1588
- padPrev.rb = rb;
1589
- const lb = !!gp.buttons[4]?.pressed;
1590
- if (lb && !padPrev.lb) triggerKick("left", "pad");
1591
- padPrev.lb = lb;
1592
-
1593
- const dpadDown = !!gp.buttons[13]?.pressed;
1594
- if (dpadDown && !padPrev.dpadDown && loco === "legs") {
1595
- const sitting = mode === "sitstand" && sitFlag === 1;
1596
- setMode(sitting ? "walk" : "sit");
1597
- }
1598
- padPrev.dpadDown = dpadDown;
1599
-
1600
- // DpadUp: short press = straight back to running (ignored mid-roll: it
1601
- // hands back to walk on its own, and switching on a tipped duck would
1602
- // floor it). HOLD ~1 s = legs <-> rollers switch, like the robot's 3 s
1603
- // hold on the real controller (shortened for the web).
1604
- const dpadUp = !!gp.buttons[12]?.pressed;
1605
- if (dpadUp && !padPrev.dpadUp) {
1606
- padPrev.dpadUpAt = now;
1607
- padPrev.dpadUpFired = false;
1608
- if (mode !== "walk" && mode !== "roll" && mode !== "crouch") setMode("walk");
1609
- }
1610
- if (dpadUp && !padPrev.dpadUpFired && now - padPrev.dpadUpAt >= 1000) {
1611
- padPrev.dpadUpFired = true; // latch: one switch per hold
1612
- toggleLoco();
1613
  }
1614
- padPrev.dpadUp = dpadUp;
1615
-
1616
- // Triggers drive the mouth like the runtime (max of both), and the right
1617
- // one quacks on its rising edge β€” same as the robot's chirp.
1618
- // Schmitt trigger on the analog RT value: fire at 0.35, re-arm only once
1619
- // it drops below 0.2. A single threshold re-fires on every jitter around
1620
- // it, which is how one squeeze used to quack several times.
1621
- const rt = gp.buttons[7]?.value ?? 0;
1622
- const lt = gp.buttons[6]?.value ?? 0;
1623
- padJaw = Math.max(rt, lt);
1624
- if (padPrev.rtArmed && rt >= 0.35) { quackLoud(); padPrev.rtArmed = false; }
1625
- else if (!padPrev.rtArmed && rt < 0.2) padPrev.rtArmed = true;
1626
- };
1627
 
1628
  // Read-only state label (bottom-left): reflects the active policy,
1629
  // switching happens via keyboard/gamepad only.
1630
  const modeLabel = document.getElementById("mode-label");
1631
 
1632
- function setMode(next) {
1633
- if (inputLocked) return;
1634
  // No policy switching mid-roll or mid-kick: both end on their own and
1635
  // return to walk - switching now would floor the duck. Sitting is a
1636
  // legs-only skill (the roller stance has no sitstand policy).
@@ -1685,6 +1983,7 @@ function triggerRoll(source = "kb") {
1685
  sitFlag = 0;
1686
  rollRun = { steps: 0, tipped: false };
1687
  syncButtons();
 
1688
  }
1689
 
1690
  // Roller-only one-shot: crouch, glide low, stand back up (phase-driven,
@@ -1696,6 +1995,7 @@ function triggerCrouch(source = "kb") {
1696
  mode = "crouch";
1697
  crouchRun = { phase: 0 };
1698
  syncButtons();
 
1699
  }
1700
 
1701
  // One blind kick (the duck can't see any ball - it's a scripted boot),
@@ -1712,23 +2012,39 @@ function triggerKick(foot, source = "kb") {
1712
  sitFlag = 0;
1713
  kickRun = { steps: 0 };
1714
  syncButtons();
 
1715
  return true;
1716
  }
1717
- // Slot-machine roll: the old text slides up and out while the new one
1718
- // rises in from below the (overflow-hidden) label box.
 
 
 
1719
  function setModeLabel(text) {
1720
- const cur = modeLabel.lastElementChild;
1721
- if (cur && cur.textContent === text) return;
1722
- const next = document.createElement("span");
1723
- next.className = "mode-text in";
1724
- next.textContent = text;
1725
- modeLabel.appendChild(next);
1726
- void next.offsetWidth; // flush layout so the transition actually plays
1727
- if (cur) {
1728
- cur.classList.add("out");
1729
- setTimeout(() => cur.remove(), 350);
1730
- }
1731
- next.classList.remove("in");
 
 
 
 
 
 
 
 
 
 
 
 
1732
  }
1733
 
1734
  function syncButtons() {
@@ -1781,11 +2097,15 @@ window.rl = {
1781
  get mode() { return mode; },
1782
  get sitFlag() { return sitFlag; },
1783
  buildObs, cmd,
1784
- velCmd, lastAction, resetSim,
1785
- spawnBall, triggerKick, sessions, ort,
 
 
 
 
1786
  get loco() { return loco; },
1787
  get locoSwitching() { return locoSwitching; },
1788
- toggleLoco, ensureRollers,
1789
  triggerCrouch,
1790
  get crouchPhase() { return crouchRun?.phase ?? null; },
1791
  get kickSteps() { return KICK_STEPS; },
@@ -1794,30 +2114,31 @@ window.rl = {
1794
  get ballQposAdr() { return ballQposAdr; },
1795
  get chaseCam() { return chaseCam; },
1796
  set chaseCam(v) { chaseCam = !!v; },
 
 
 
 
 
 
 
 
 
1797
  padOrbitStep,
1798
  jawOpenNow,
1799
  step: async (n = 1) => { for (let i = 0; i < n; i++) await controlStep(); },
1800
  render: () => { syncRig(); renderer.render(scene, camera); },
1801
  // One full render-loop iteration, for tests driving frames manually.
1802
- frame: () => { syncRig(); syncJaw(); controls.update(); updateChaseCam(); updateEntrance(); renderStats(); renderer.render(scene, camera); },
1803
  get ghosts() { return ghosts; },
1804
  get inputLocked() { return inputLocked; },
1805
  // Deterministic entrance controls for screenshots/tests: setting values
1806
- // detaches the time-based driver; setDissolve(1) also restores the
1807
- // original materials and releases the input lock (same cleanup as the
1808
- // real sequence).
1809
  entrance: {
1810
- start: startEntrance,
1811
- setReveal: (floor, wall = floor) => {
1812
- entranceT0 = null;
1813
- grid.material.uniforms.uReveal.value = floor;
1814
- for (const m of wallMats) m.uniforms.uReveal.value = wall;
1815
- },
1816
- setDissolve: (p) => {
1817
- entranceT0 = null;
1818
- if (p >= 1) { dissolveP.value = 1; restoreDissolve(); inputLocked = false; entranceFinishedResolve(); }
1819
- else { applyDissolve(); dissolveP.value = p; }
1820
- },
1821
  },
1822
  };
1823
 
@@ -1828,7 +2149,7 @@ const r3 = (x) => Math.round(x * 1000) / 1000;
1828
  try {
1829
  // Ghosts only join once the entrance has fully played: the world (and
1830
  // this duck) must stay hidden until then, translucent peers included.
1831
- await entranceFinished;
1832
  const { initGhosts } = await import(signed(`./ghosts.js?v=${SELF_V}`));
1833
  ghosts = await initGhosts({
1834
  scene, rig, cloneRig, setJoint, setJawOpen, applyVariant,
@@ -1851,6 +2172,9 @@ try {
1851
  };
1852
  },
1853
  });
 
 
 
1854
  } catch (e) {
1855
  window.__ghostErr = String((e && e.stack) || e);
1856
  console.warn("ghosts disabled:", e);
 
46
  const SELF_V = new URL(import.meta.url).searchParams.get("v") ?? "0";
47
  const { buildRig, cloneRig, loadKinematics, setJoint, setJawOpen, MODEL_DIR, MESH_VERSION } =
48
  await import(signed(`./duck.js?v=${SELF_V}`));
49
+ const { VARIANTS, VARIANT_NAMES, materialHookFor, DEFAULT_VARIANT, applyVariant, specToHex } =
50
  await import(signed(`./variants.js?v=${SELF_V}`));
51
+ // Input stack: a video-game-style Controller aggregating pluggable sources
52
+ // (keyboard + gamepad today, touch later). controller.js documents the
53
+ // source interface contract and the action vocabulary.
54
+ const [{ Controller }, { KeyboardSource }, { GamepadSource }] = await Promise.all([
55
+ import(signed(`./controls/controller.js?v=${SELF_V}`)),
56
+ import(signed(`./controls/keyboard.js?v=${SELF_V}`)),
57
+ import(signed(`./controls/gamepad.js?v=${SELF_V}`)),
58
+ ]);
59
 
60
  ort.env.wasm.wasmPaths = "https://cdn.jsdelivr.net/npm/onnxruntime-web@1.27.0/dist/";
61
  ort.env.wasm.numThreads = 1; // static hosting sends no COOP/COEP headers
 
101
  // No strafe input anymore: the lateral cmd slot stays zeroed for the obs.
102
  const VEL_FWD = 0.25, VEL_BACK = -0.2, VEL_ANG = 1.0;
103
  // Roller mode limits, from the runtime's roller branch: asymmetric vx
104
+ // (0.6 push / 0.5 brake), no lateral. The real runtime launches rollers
105
+ // with --max-angular-vel 0.3: faster commanded turns tip the robot over,
106
+ // so the playground clamps wz the same way (keyboard and pad both go
107
+ // through velLims).
108
+ const RVEL_FWD = 0.6, RVEL_BACK = -0.5, RVEL_ANG = 0.3;
109
  // Crouch-glide one-shot: command = [cos(2pi*phase), sin(2pi*phase), 0],
110
  // phase advancing at 1/CROUCH_PERIOD_S per second and the cycle exiting
111
  // at 0.7 - exactly the runtime's ground-pick slot the policy was trained
 
122
  const ARENA_HALF = 1.5; // inner half-size, m
123
  const ARENA_WALL_H = 0.25;
124
  const ARENA_WALL_T = 0.05;
125
+ // Section grid: 5 cells across the 3 m arena (ODD, so a true middle
126
+ // column/row of cells exists; the lattice is shifted half a cell in the
127
+ // shaders so the walls land exactly on section lines).
128
+ const GRID_SECTION = (2 * ARENA_HALF) / 5; // 0.6 m
129
+ // Spawn: center of the middle section cell in the SECOND ROW FROM THE
130
+ // BACK wall. The duck faces +X (identity freejoint quat, walks toward
131
+ // local +X), so "back" is the -X wall: row centers sit at x = -1.2,
132
+ // -0.6, 0, 0.6, 1.2 -> second row is -0.6; middle column is y = 0.
133
+ // MJCF coordinates (three.js: x -> x, y -> -z).
134
+ const SPAWN_X = -ARENA_HALF + 1.5 * GRID_SECTION; // -0.6
135
+ const SPAWN_Y = 0;
136
 
137
  const mount = document.getElementById("scene");
138
  const loadingEl = document.getElementById("loading");
139
  const hudEl = document.getElementById("hud");
140
+ const ctrlHzEl = document.getElementById("ctrl-hz");
141
  const osdTimeEl = document.getElementById("osd-time");
142
 
143
  // BIOS/POST boot readout. The real load runs silently behind the welcome
 
146
  // finished, or an honest live tracker (cursor blinking on the pending
147
  // line) when the user enters mid-load. Never slows the actual boot.
148
  const postEl = loadingEl.querySelector(".post");
149
+ const bootLog = []; // { label, status, raw, halt, progress, el } - status null = pending
150
+ const lineText = (e) => {
151
+ if (e.raw) return e.label;
152
+ // Pending line: bare label, plus a live counter when the stage reports
153
+ // progress (e.g. "LOADING POLICIES [3/5]").
154
+ if (e.status === null) return e.progress ? `${e.label} [${e.progress}]` : e.label;
155
+ return `${e.label} `.padEnd(26, ".") + ` ${e.status}`;
156
+ };
157
  const bootLine = (label) => {
158
+ const entry = { label, status: null, raw: false, el: null, progress: null };
159
  bootLog.push(entry);
160
+ const done = (status = "OK") => {
161
  entry.status = status;
162
  if (entry.el) entry.el.textContent = lineText(entry);
163
  };
164
+ // Live sub-progress while the stage is still pending (honest path).
165
+ done.progress = (p) => {
166
+ entry.progress = p;
167
+ if (entry.el && entry.status === null) entry.el.textContent = lineText(entry);
168
+ };
169
+ return done;
170
  };
171
  // Plain line with no dotted leader (header / error text).
172
  const bootNote = (label) => {
 
177
  bootLine("DUCK FIRMWARE")("PRESENT");
178
 
179
  let bootDone = false;
180
+ let bootFailed = false;
181
  let biosStarted = false;
182
  const biosSleep = (ms) => new Promise((r) => setTimeout(r, ms));
183
+ // Fatal boot failure: freeze the sequence on the console. The BIOS never
184
+ // prints READY, never fades out and never cues the entrance - the halt
185
+ // screen IS the diagnostic surface. The welcome modal (if still up) is
186
+ // dropped so the console isn't stuck behind its blur.
187
+ function bootHalt(detail) {
188
+ if (bootFailed || bootDone) return;
189
+ bootFailed = true;
190
+ bootNote(`>> ${detail}`);
191
+ bootLog.push({ label: "SYSTEM HALTED", status: "", raw: true, el: null, halt: true });
192
+ const modal = document.getElementById("welcome");
193
+ if (modal) modal.hidden = true;
194
+ playBios();
195
+ }
196
+ // Seeded LCG: the replay rhythm is random-feeling but identical on every
197
+ // load - real POST screens burst through most checks and stall on a few.
198
+ // Seed picked so the draw reads burst/burst/burst/stall/burst/stall.
199
+ let biosSeed = 11;
200
+ const biosRand = () => (biosSeed = (biosSeed * 1103515245 + 12345) & 0x7fffffff) / 0x7fffffff;
201
  async function playBios() {
202
  if (biosStarted) return;
203
  biosStarted = true;
 
207
  if (i < bootLog.length) {
208
  const entry = bootLog[i++];
209
  entry.el = document.createElement("div");
210
+ if (entry.halt) entry.el.className = "halt";
211
  postEl.appendChild(entry.el);
212
+ if (entry.status === null && !bootDone && !bootFailed) {
213
+ // Honest mode: show the stage label and hold while it's really in
214
+ // flight (its completer fills the status and .progress() updates
215
+ // the live counter), cursor blinking via CSS.
216
+ entry.el.textContent = lineText(entry);
217
+ while (entry.status === null && !bootDone && !bootFailed) {
218
+ await biosSleep(60);
219
+ entry.el.textContent = lineText(entry); // live [n/m] counter
220
+ }
221
+ await biosSleep(90);
222
+ entry.el.textContent = lineText(entry);
223
+ } else if (entry.raw || entry.status === null) {
224
+ // Header / note lines: quick, no dotted leader to animate.
225
+ entry.el.textContent = lineText(entry);
226
+ await biosSleep(20 + 60 * biosRand());
227
+ } else {
228
+ // Finished check: bursty POST pacing. Most lines snap in nearly
229
+ // instantly; the occasional one stalls on a "slow check" - and on
230
+ // a stall the dotted leader types out one dot at a time before
231
+ // the status lands.
232
+ const r = biosRand();
233
+ const stall = r < 0.75 ? 20 * biosRand() : 150 + 250 * biosRand();
234
+ if (stall < 45) {
235
+ entry.el.textContent = lineText(entry);
236
+ await biosSleep(stall);
237
+ } else {
238
+ const prefix = `${entry.label} `;
239
+ const nDots = Math.max(26 - prefix.length, 3);
240
+ entry.el.textContent = prefix;
241
+ const per = stall / nDots;
242
+ for (let d = 0; d < nDots; d++) {
243
+ await biosSleep(per);
244
+ entry.el.textContent += ".";
245
+ }
246
+ await biosSleep(40);
247
+ entry.el.textContent = lineText(entry);
248
+ }
249
+ }
250
  } else if (bootDone) {
251
  break;
252
  } else {
253
+ // Waiting for the next real stage. On a failed boot this parks the
254
+ // console forever: everything queued (FAIL line, error detail,
255
+ // SYSTEM HALTED) has been printed and nothing more will come.
256
+ await biosSleep(bootFailed ? 500 : 60);
257
  }
258
  }
259
  const ready = document.createElement("div");
260
  ready.textContent = "READY.";
261
  postEl.appendChild(ready);
262
  await biosSleep(500);
 
 
 
263
  loadingEl.classList.add("off");
264
+ // Wait out the overlay's 0.45 s opacity transition BEFORE cueing the
265
+ // draw-in: starting it under the fading overlay hides the first (and
266
+ // busiest) part of the animation and only the tail end shows.
267
  await biosSleep(500);
268
  loadingEl.style.display = "none";
269
+ // Cue the world draw-in + duck scan-up on a fully black screen.
270
+ // (Only ever reached after bootDone, so the ceremony module exists.)
271
+ ceremony.startEntrance();
272
  }
273
  // Only the initial entry triggers the replay; reopening the modal from
274
  // the brand later never replays (biosStarted latches).
 
276
  else document.addEventListener("microduck:enter", () => playBios(), { once: true });
277
 
278
  // Surface boot failures on the page itself: a rejected top-level await
279
+ // otherwise leaves a dead page with no visible error. Gated on the boot
280
+ // still being in flight: post-boot async noise (ghost relay hiccups,
281
+ // audio autoplay rejections...) must NOT cue the BIOS replay early
282
+ // behind the welcome modal - that replay belongs to the "Waddle in"
283
+ // click alone. Boot-time errors route into the SYSTEM HALTED screen.
284
  window.addEventListener("unhandledrejection", (e) => {
 
 
 
285
  console.error("[rl] unhandled rejection", e.reason);
286
+ bootHalt(e.reason?.message || String(e.reason));
287
  });
288
  window.addEventListener("error", (e) => {
289
+ console.error("[rl] window error", e.message);
290
+ bootHalt(e.message);
291
  });
292
+ // Halting at the failure site rather than relying on unhandledrejection:
293
+ // a rejected top-level await only surfaces through the dynamic import()
294
+ // chain in index.html, which does NOT reliably fire the window handler.
295
  const traced = (label, p) => {
296
  const done = bootLine(label);
297
  return p.then(
298
  (v) => { done("OK"); return v; },
299
+ (err) => {
300
+ done("FAILED");
301
+ console.error(`[rl] ${label} FAILED`, err);
302
+ bootHalt(err?.message || String(err));
303
+ throw err;
304
+ },
305
  );
306
  };
307
 
 
377
  // wheels) starts at zero. The ball's 7 free-joint values MUST be
378
  // appended or nq won't match and the model won't compile; parked 50 m
379
  // away = effectively absent.
380
+ const qposFree = `${SPAWN_X} ${SPAWN_Y} 0.12 1 0 0 0`;
381
  const poseByName = new Map(JOINT_NAMES.map((n, i) => [n, DEFAULT_POSE[i]]));
382
  const qposJoints = [...doc.querySelectorAll("body > joint")]
383
  .map((j) => poseByName.get(j.getAttribute("name")) ?? 0)
 
419
  }),
420
  );
421
  }
422
+ try {
423
+ await addMeshesToVfs(meshFiles);
424
+ } catch (err) {
425
+ doneMeshes("FAILED");
426
+ bootHalt(err?.message || String(err));
427
+ throw err;
428
+ }
429
  doneMeshes(`${meshFiles.length} FILES`);
430
 
431
  const sessions = {};
432
+ // Always boot on the classic (orange) colourway; the swatches re-skin live.
433
+ let currentVariant = DEFAULT_VARIANT;
434
  const rigPromise = (async () => {
435
  const doneRig = bootLine("RENDER RIG");
436
+ try {
437
+ const rig = await buildRig(k, { materialForMesh: materialHookFor(VARIANTS[currentVariant]) });
438
+ doneRig("OK");
439
+ return rig;
440
+ } catch (err) {
441
+ doneRig("FAILED");
442
+ bootHalt(err?.message || String(err));
443
+ throw err;
444
+ }
445
  })();
446
+ // Boot policies with a live [n/5] counter on the BIOS line; any single
447
+ // session failure marks the whole line FAILED (the halt detail line names
448
+ // the actual error).
449
  const donePolicies = bootLine("LOADING POLICIES");
450
  const sessionOpts = { executionProviders: ["wasm"] };
451
+ let policiesLoaded = 0;
452
+ const bootPolicy = (url) =>
453
+ ort.InferenceSession.create(signed(url), sessionOpts).then((s) => {
454
+ donePolicies.progress(`${++policiesLoaded}/5`);
455
+ return s;
456
+ });
457
+ try {
458
+ [sessions.walk, sessions.sitstand, sessions.roll, sessions.kickL, sessions.kickR] =
459
+ await Promise.all([
460
+ bootPolicy(POLICIES.walk),
461
+ bootPolicy(POLICIES.sitstand),
462
+ bootPolicy(POLICIES.roll),
463
+ bootPolicy(POLICIES.kickL),
464
+ bootPolicy(POLICIES.kickR),
465
+ ]);
466
+ } catch (err) {
467
+ donePolicies("FAILED");
468
+ bootHalt(err?.message || String(err));
469
+ throw err;
470
+ }
471
  donePolicies("5/5");
472
 
473
  const doneCompile = bootLine("COMPILING PHYSICS");
474
+ let model, data;
475
+ try {
476
+ model = mujoco.MjModel.from_xml_string(xml, vfs);
477
+ data = new mujoco.MjData(model);
478
+ } catch (err) {
479
+ doneCompile("FAILED");
480
+ bootHalt(err?.message || String(err));
481
+ throw err;
482
+ }
483
  doneCompile("COMPILED");
484
 
485
  // Addresses resolved once per compiled variant. qpos/qvel/sensordata views
 
519
  const lastAction = new Float32Array(NUM_JOINTS);
520
  const obs = new Float32Array(OBS_SIZE);
521
  const cmd = new Float32Array(CMD_SIZE); // [vx, vy, wz, head(4), body(6)]
522
+ // Input controller (controls/controller.js): keyboard + gamepad sources
523
+ // merged into one continuous command + discrete action surface. Registered
524
+ // in priority order - live pad sticks win over held keys, exactly the old
525
+ // padActive arbitration. Constructed up here because the control loop reads
526
+ // it via effectiveCmd on the very first control step; the sources' event
527
+ // listeners are only armed by controller.init() in the input-wiring
528
+ // section below, where the raw listeners historically attached.
529
+ const kbSource = new KeyboardSource({ getVelocityLimits: () => velLims() });
530
+ const padSource = new GamepadSource({ getVelocityLimits: () => velLims() });
531
+ const controller = new Controller({ sources: [padSource, kbSource] });
532
+ // Right-stick camera state, read by renderStats before the camera-orbit
533
+ // section below has evaluated.
534
  let padOrbitLive = false; // HUD: RS keycap lit while deflected
 
 
 
535
  // Robot input gate: twist commands, mode changes, rolls, kicks and ball
536
  // spawns all stay inert until the entrance sequence has fully played out
537
+ // (ceremony.drive flips this exactly when the choreography completes).
538
+ // Re-engaged by every post-entrance reset for the duck's re-scan.
539
  let inputLocked = true;
540
+ // Cutscene + ball-actor modules, assigned once the scene exists. Boot
541
+ // resetSim runs before that (physics only); later kills go through these.
542
+ let ceremony = null;
543
+ let ball = null;
544
+ // Comic sticker popups (stickers.js). Nullable on purpose: every hook is a
545
+ // one-line `stickers?.pop(...)`, so deleting the single import line below
546
+ // (search for initStickers) removes the feature without breaking anything.
547
+ let stickers = null;
548
+ // HUD: Space keycap flash. Written by the respawn ceremony; read by
549
+ // renderStats.
550
+ let resetFlashAt = -Infinity;
551
 
552
  // "walk" is the main velocity-tracking mode in BOTH variants (legs walking
553
  // policy or roller drive policy - activeSession() picks); "crouch" is the
 
559
  // (mesh hidden), true once popped in front of the duck.
560
  let ballActive = false;
561
 
562
+ // The twist the policy actually receives: the controller's merged command
563
+ // (live gamepad sticks win over held keys via source arbitration). No
564
+ // input means zero command - the duck stands in place.
565
  // Shared by buildObs and the HUD mini-sticks so they can never disagree.
566
  // Mid-roll every movement input is ignored (zero twist) until the roll
567
  // hands back to walk on its own - steering would only knock the roll over.
 
569
  function effectiveCmd() {
570
  if (inputLocked || mode === "roll" || mode === "crouch" || isKick() || postKickLock > 0)
571
  return ZERO_CMD;
572
+ return controller.getCommand();
573
  }
574
  // One-shot roll tracking: trigger time + whether the trunk actually
575
  // tipped over yet. Set by triggerRoll, cleared when we hand back to walk.
 
597
  // up handed to the walking policy mid-motion.
598
  let sitTimer = null;
599
  let standTimer = null;
600
+ let fallenSince = null; // wall-clock start of the current fallen spell
601
  function clearModeTimers() {
602
  clearTimeout(sitTimer); sitTimer = null;
603
  clearTimeout(standTimer); standTimer = null;
604
  }
605
 
606
  function resetSim() {
607
+ // Single reset path: Space, fall-kill, failed roll, loco switch. A
608
+ // reset is a full transition to the walking stand: cancel any roll in
609
  // flight and any pending sit/stand hand-over, or they'd replay on the
610
  // freshly reset duck.
611
  clearModeTimers();
 
613
  kickRun = null;
614
  crouchRun = null;
615
  postKickLock = 0;
616
+ fallenSince = null;
617
  mode = "walk";
618
  mujoco.mj_resetDataKeyframe(model, data, standKeyId);
619
  mujoco.mj_forward(model, data);
620
  lastAction.fill(0);
621
  sitFlag = 0;
622
+ // Park the ball in physics immediately; if it was on screen, the
623
+ // reverse scan peels it away at its last pose. A queued B-respawn is
624
+ // cancelled: a reset means no ball.
625
+ ball?.despawn({ cancelQueued: true, parkPhysics: parkBallPhysics });
626
  ballActive = false;
627
  // Buttons reflect sitFlag; keep them honest after auto-resets.
628
  if (uiReady) syncButtons();
629
+ ceremony?.playRespawn();
630
  }
631
  resetSim();
632
 
 
634
  // random heading + distance jitter so repeated pops land somewhere
635
  // nearby instead of always on the same spot. qvel is zeroed so a respawn
636
  // doesn't carry the old momentum.
637
+ // If the ball is already on screen, peel it away first (reverse scan)
638
+ // and pop the new one when that finishes - same appear/disappear pair
639
+ // as the duck's wireframe ceremony.
640
+ function parkBallPhysics() {
641
+ const qpos = data.qpos, qvel = data.qvel;
642
+ qpos[ballQposAdr] = 50;
643
+ qpos[ballQposAdr + 1] = 0;
644
+ qpos[ballQposAdr + 2] = BALL_RADIUS;
645
+ qpos[ballQposAdr + 3] = 1;
646
+ qpos[ballQposAdr + 4] = 0;
647
+ qpos[ballQposAdr + 5] = 0;
648
+ qpos[ballQposAdr + 6] = 0;
649
+ for (let i = 0; i < 6; i++) qvel[ballDofAdr + i] = 0;
650
+ mujoco.mj_forward(model, data);
651
+ ballActive = false;
652
+ }
653
+
654
+ function spawnBall(opts = {}) {
655
+ if (inputLocked && !opts.fromQueue) return;
656
+ if (!ball) return;
657
+ if (ball.visual !== "hidden") {
658
+ ball.queueRespawn();
659
+ ball.despawn({ parkPhysics: parkBallPhysics });
660
+ return;
661
+ }
662
  const qpos = data.qpos, qvel = data.qvel;
663
  // Trunk yaw from the free-joint quaternion (qpos[3..6] = w x y z);
664
  // the duck walks toward its local +X.
 
682
  for (let i = 0; i < 6; i++) qvel[ballDofAdr + i] = 0;
683
  mujoco.mj_forward(model, data);
684
  ballActive = true;
685
+ // Snap the mesh to the new pose BEFORE the scan starts: the FX
686
+ // recomputes its bbox from the live mesh.
687
+ ball.poseFromQpos(qpos, ballQposAdr);
688
+ ball.appear();
689
+ stickers?.pop("spawn");
690
  }
691
 
692
  // ── Observation ─────────────────────────────────────────────────────────
 
733
 
734
  // ── Control loop (50 Hz, async because ONNX inference is async) ────────
735
  let ctrlHz = 0;
736
+
737
+ // Dead pose: walk/sitstand have no get-up skill, so a kill here is just
738
+ // a resetSim (same ceremony as Space). Height alone would false-positive
739
+ // on a deep sit, so "fallen" = trunk tilted past ~60 deg (projected
740
+ // gravity z above -0.5) or sunk below the floor. NaN/Inf is a solver
741
+ // explosion: no grace, reset on the spot. The roll tumbles the trunk on
742
+ // purpose and recovers on its own, so it gets a much longer grace window
743
+ // before we call it stuck.
744
+ function poseIsDead() {
745
+ const z = data.qpos[2];
746
+ const gz = obs[5]; // projected gravity z, from the last obs
747
+ if (!Number.isFinite(z) || !Number.isFinite(gz)) return "exploded";
748
+ if (gz > -0.5 || z < 0.02) return "fallen";
749
+ return null;
750
+ }
751
 
752
  async function controlStep() {
753
  const feeds = { obs: new ort.Tensor("float32", buildObs(), [1, OBS_SIZE]) };
 
758
  for (let j = 0; j < NUM_JOINTS; j++) ctrl[j] = DEFAULT_POSE[j] + act[j] * ACTION_SCALE;
759
  for (let s = 0; s < DECIMATION; s++) mujoco.mj_step(model, data);
760
 
761
+ const death = poseIsDead();
762
+ if (death === "exploded") {
763
+ resetSim();
764
+ } else if (death === "fallen") {
765
+ const now = performance.now();
766
+ const graceMs = mode === "roll" ? 5000 : 1000;
 
 
 
 
 
767
  fallenSince ??= now;
768
+ if (now - fallenSince > graceMs) resetSim();
769
  } else {
770
  fallenSince = null;
771
  }
 
827
  mode = "walk";
828
  lastAction.fill(0);
829
  // Timed out mid-roll: don't hand a tipped duck to the walking policy
830
+ // (it has no get-up skill). Same ceremony as Space / fall-kill.
831
  if (!upright) resetSim();
832
  if (uiReady) syncButtons();
833
  }
 
858
  scene.background = new THREE.Color(0x08080c);
859
 
860
  const camera = new THREE.PerspectiveCamera(40, 1, 0.02, 30);
861
+ // Boot framing translated onto the spawn cell (the orbit target follows
862
+ // below), so the follow-cam has nothing to drift toward during boot.
863
+ camera.position.set(SPAWN_X + 0.55, 0.35, -SPAWN_Y + 0.7);
864
 
865
+ const renderer = new THREE.WebGLRenderer({ antialias: true, alpha: true });
866
  renderer.setPixelRatio(Math.min(2, window.devicePixelRatio));
867
+ renderer.setClearColor(0x08080c, 1);
868
  renderer.toneMapping = THREE.ACESFilmicToneMapping;
869
  mount.appendChild(renderer.domElement);
870
 
 
894
  depthWrite: false,
895
  uniforms: {
896
  uCell: { value: 0.1 },
897
+ uSection: { value: GRID_SECTION },
898
  uCellColor: { value: new THREE.Color(0x8e8371) },
899
  uSectionColor: { value: new THREE.Color(0xffb366) },
900
  uFadeDist: { value: 3.0 },
 
919
  // Tron-style line: a thicker antialiased core plus a faint, much
920
  // wider halo added on top (squared falloff keeps it a whisper of a
921
  // glow rather than a bloom wash).
922
+ float lineProf(float g) {
923
+ float core = 1.0 - smoothstep(0.0, 1.8, g);
924
+ float halo = 1.0 - smoothstep(0.0, 7.0, g);
925
+ return core + halo * halo * 0.22;
926
+ }
927
  float gridLine(vec2 p, float size) {
928
  vec2 r = p / size;
929
  vec2 g = abs(fract(r - 0.5) - 0.5) / fwidth(r);
930
+ return lineProf(min(g.x, g.y));
931
+ }
932
+ // Entrance draw-in: one family of parallel lines, drawn line by line.
933
+ // id picks the line, "along" runs down its length. Each line waits
934
+ // out its own hashed delay, then extends from the origin outward with
935
+ // a hard front. Returns (mask, head): head marks the bright segment
936
+ // right behind the draw front while the line is still growing.
937
+ vec2 drawLine(float id, float along, float t0, float spread, float dur, float maxLen) {
938
+ float jit = fract(sin(id * 127.1) * 43758.5453);
939
+ float grow = clamp((uReveal - t0 - jit * spread) / dur, 0.0, 1.0);
940
+ float len = grow * maxLen;
941
+ float a = abs(along);
942
+ float mask = 1.0 - smoothstep(len - 0.05, len, a);
943
+ float head = (1.0 - smoothstep(0.0, 0.6, len - a)) * mask
944
+ * step(0.001, grow) * (1.0 - step(0.999, grow));
945
+ return vec2(mask, head);
946
  }
947
  void main() {
948
  float cell = gridLine(vWorld.xz, uCell);
949
+ // Section lattice shifted half a cell: with 5 sections across the
950
+ // 3 m arena (odd count) this centers a CELL on the origin and puts
951
+ // section lines exactly on the walls at +-1.5.
952
+ vec2 pSec = vWorld.xz + 0.5 * uSection;
953
+ float section = gridLine(pSec, uSection);
954
  float d = distance(vWorld.xz, uFocus.xz);
955
  float fade = pow(clamp(1.0 - d / uFadeDist, 0.0, 1.0), 1.6);
956
  vec3 col = mix(uCellColor, uSectionColor, clamp(section, 0.0, 1.0));
957
  float alpha = min(max(section * 0.6, cell * 0.4) * fade, 1.0);
958
+ // Entrance: only the bright section lines get the line-by-line draw
959
+ // (staggered, with a hot draw head); the fine cells just fade in
960
+ // over the reveal's second half - drawing every small line reads as
961
+ // visual noise. lineProf(min(gx, gy)) == max of per-axis profiles
962
+ // (profile is monotonic) and the cell fade lands on exactly the
963
+ // steady-state cell term, so at uReveal 1 this branch equals the
964
+ // formula above exactly (and is skipped).
965
  if (uReveal < 1.0) {
966
+ vec2 rs = pSec / uSection;
967
+ vec2 gs = abs(fract(rs - 0.5) - 0.5) / fwidth(rs);
968
+ // Const-x lines run along z and vice versa; the offset
969
+ // decorrelates the two families' hashed delays.
970
+ vec2 sx = drawLine(floor(rs.x + 0.5), vWorld.z, 0.00, 0.30, 0.35, 8.0);
971
+ vec2 sz = drawLine(floor(rs.y + 0.5) + 57.0, vWorld.x, 0.05, 0.30, 0.35, 8.0);
972
+ float secR = max(lineProf(gs.x) * sx.x, lineProf(gs.y) * sz.x);
973
+ float cellR = cell * smoothstep(0.5, 1.0, uReveal);
974
+ float headGlow = max(lineProf(gs.x) * sx.y, lineProf(gs.y) * sz.y);
975
+ col = mix(uCellColor, uSectionColor, clamp(secR, 0.0, 1.0));
976
+ alpha = min(max(secR * 0.6, cellR * 0.4) * fade, 1.0);
977
+ // Bright draw head: a short white-hot tip sells the "drawing" read.
978
+ headGlow = clamp(headGlow, 0.0, 1.0);
979
+ col = mix(col, vec3(1.0, 0.86, 0.55), headGlow * 0.8);
980
+ alpha = min(alpha + headGlow * fade * 0.5, 1.0);
981
  }
982
  if (alpha < 0.004) discard;
983
  gl_FragColor = vec4(col, alpha);
 
1002
  side: THREE.DoubleSide,
1003
  uniforms: {
1004
  uCell: { value: 0.1 },
1005
+ uSection: { value: GRID_SECTION },
1006
  uCellColor: { value: new THREE.Color(0x8e8371) },
1007
  uSectionColor: { value: new THREE.Color(0xffb366) },
1008
  // Gentler radial fade than the floor: the walls sit 1.5+ m from the
 
1028
  uniform float uCell, uSection, uFadeDist, uWallH, uAlongX, uReveal;
1029
  uniform vec3 uCellColor, uSectionColor, uFocus;
1030
  // Same Tron-style core + faint halo as the floor grid.
1031
+ float lineProf(float g) {
1032
+ float core = 1.0 - smoothstep(0.0, 1.8, g);
1033
+ float halo = 1.0 - smoothstep(0.0, 7.0, g);
1034
+ return core + halo * halo * 0.22;
1035
+ }
1036
  float gridLine(vec2 p, float size) {
1037
  vec2 r = p / size;
1038
  vec2 g = abs(fract(r - 0.5) - 0.5) / fwidth(r);
1039
+ return lineProf(min(g.x, g.y));
1040
+ }
1041
+ // Same line-by-line draw as the floor grid (see its comments).
1042
+ vec2 drawLine(float id, float along, float t0, float spread, float dur, float maxLen) {
1043
+ float jit = fract(sin(id * 127.1) * 43758.5453);
1044
+ float grow = clamp((uReveal - t0 - jit * spread) / dur, 0.0, 1.0);
1045
+ float len = grow * maxLen;
1046
+ float a = abs(along);
1047
+ float mask = 1.0 - smoothstep(len - 0.05, len, a);
1048
+ float head = (1.0 - smoothstep(0.0, 0.35, len - a)) * mask
1049
+ * step(0.001, grow) * (1.0 - step(0.999, grow));
1050
+ return vec2(mask, head);
1051
  }
1052
  void main() {
1053
  // Wall surface coords: the in-plane horizontal world axis + height.
1054
  float h = mix(vWorld.z, vWorld.x, uAlongX);
1055
  vec2 p = vec2(h, vWorld.y);
1056
  float cell = gridLine(p, uCell);
1057
+ // Horizontal axis shifted half a section to match the floor's odd
1058
+ // lattice (vertical section lines meet the floor's at the base);
1059
+ // the height axis keeps its base line at y = 0.
1060
+ vec2 pSec = vec2(p.x + 0.5 * uSection, p.y);
1061
+ float section = gridLine(pSec, uSection);
1062
  float d = distance(vWorld.xz, uFocus.xz);
1063
  float fade = pow(clamp(1.0 - d / uFadeDist, 0.0, 1.0), 1.6);
1064
  float vert = 1.0 - clamp(vWorld.y / uWallH, 0.0, 1.0);
1065
  vec3 col = mix(uCellColor, uSectionColor, clamp(section, 0.0, 1.0));
1066
  float alpha = min(max(section * 0.9, cell * 0.6) * fade * (0.3 + 0.7 * vert), 1.0);
1067
+ // Entrance: section lines only - horizontals zip out from the
1068
+ // wall's center, verticals rise from the ground, each with a
1069
+ // hashed delay; the fine cells fade in over the reveal's second
1070
+ // half. Same steady-state equivalence argument as the floor grid.
1071
  if (uReveal < 1.0) {
1072
+ vec2 rs = pSec / uSection;
1073
+ vec2 gs = abs(fract(rs - 0.5) - 0.5) / fwidth(rs);
1074
+ // Const-height lines run along h (grow from center outward);
1075
+ // const-h lines run along y (grow up from the ground).
1076
+ vec2 sh = drawLine(floor(rs.y + 0.5), p.x, 0.00, 0.30, 0.40, 2.0);
1077
+ vec2 sv = drawLine(floor(rs.x + 0.5) + 31.0, p.y, 0.30, 0.30, 0.30, uWallH);
1078
+ float secR = max(lineProf(gs.y) * sh.x, lineProf(gs.x) * sv.x);
1079
+ float cellR = cell * smoothstep(0.5, 1.0, uReveal);
1080
+ float headGlow = max(lineProf(gs.y) * sh.y, lineProf(gs.x) * sv.y);
1081
+ col = mix(uCellColor, uSectionColor, clamp(secR, 0.0, 1.0));
1082
+ alpha = min(max(secR * 0.9, cellR * 0.6) * fade * (0.3 + 0.7 * vert), 1.0);
1083
+ headGlow = clamp(headGlow, 0.0, 1.0);
1084
+ col = mix(col, vec3(1.0, 0.86, 0.55), headGlow * 0.8);
1085
+ alpha = min(alpha + headGlow * fade * 0.5, 1.0);
1086
  }
1087
  if (alpha < 0.004) discard;
1088
  gl_FragColor = vec4(col, alpha);
 
1158
  scene.add(rig.placer);
1159
  document.body.classList.toggle("rollers", name === "rollers");
1160
  resetSim();
 
1161
  if (uiReady) syncLocoHints();
1162
  }
1163
 
1164
  // OSD line while the roller stack streams in, BIOS-style.
1165
  let osdLoadEl = null;
1166
  let locoSwitching = false;
1167
+ async function setLoco(name, { force = false } = {}) {
1168
+ if (name !== "legs" && name !== "rollers") return;
1169
+ if (loco === name || locoSwitching) return;
1170
+ if (!force && (inputLocked || rollRun || kickRun || crouchRun || standTimer)) return;
1171
  locoSwitching = true;
1172
  try {
1173
+ if (name === "rollers" && !locos.rollers) {
 
1174
  osdLoadEl?.removeAttribute("hidden");
1175
  await ensureRollers();
1176
  }
1177
+ activateLoco(name);
1178
  } catch (e) {
1179
+ rollersLoading = null;
1180
  console.error("[rl] roller switch failed", e);
1181
  } finally {
1182
  osdLoadEl?.setAttribute("hidden", "");
 
1184
  }
1185
  }
1186
 
1187
+ async function toggleLoco() {
1188
+ await setLoco(loco === "legs" ? "rollers" : "legs");
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1189
  }
1190
 
1191
+ // ── Cutscenes (entrance + respawn) ──────────────────────────────────────
1192
+ // Owned by ceremony.js. Physics reset stays in resetSim; this is the
1193
+ // camera + materialization layer. Boot-hidden: bind parks the duck clip
1194
+ // below the feet so nothing shows through the welcome modal / BIOS.
1195
+ const fx = await import(signed(`./fx/fx-wireframe.js?v=${SELF_V}`));
1196
+ const { createCeremony, CAM_RESET_S } = await import(signed(`./ceremony.js?v=${SELF_V}`));
1197
+ ceremony = createCeremony({
1198
+ THREE, scene, camera, renderer, fx,
1199
+ getRig: () => rig,
1200
+ grid, wallMats,
1201
+ syncRig, startCameraReset,
1202
+ setLocked: (v) => { inputLocked = v; controller.setLocked(v); },
1203
+ flashReset: () => { resetFlashAt = performance.now(); },
1204
+ });
1205
  // Soccer-ball look computed per pixel on the sphere itself, so there is
1206
  // no pole or seam special case by design. The truncated icosahedron is
1207
  // reconstructed as a spherical Voronoi diagram over 32 sites: the 12
 
1335
  clearcoatRoughness: 0.35,
1336
  }),
1337
  );
1338
+ ballMesh.userData.meshName = "ball";
1339
  ballMesh.visible = false;
1340
  ballGroup.add(ballMesh);
1341
  scene.add(ballGroup);
1342
 
1343
+ const { createBallActor } = await import(signed(`./ball-actor.js?v=${SELF_V}`));
1344
+ ball = createBallActor({
1345
+ THREE, scene, camera, renderer, fxModule: fx, mesh: ballMesh, group: ballGroup,
1346
+ });
1347
+
1348
+ // Comic sticker popups on game events (kick / quack / roll / ball spawn /
1349
+ // ghost join). Fully self-contained DOM overlay: delete this import to
1350
+ // remove the feature (the `stickers?.pop` hooks then no-op).
1351
+ // Stickers disabled for now - uncomment to re-enable.
1352
+ // stickers = (await import(signed(`./stickers.js?v=${SELF_V}`)))
1353
+ // .initStickers({ signed, isLocked: () => inputLocked });
1354
+
1355
  const controls = new OrbitControls(camera, renderer.domElement);
1356
+ controls.target.set(SPAWN_X, 0, -SPAWN_Y); // orbit around the spawn cell
1357
  controls.enableDamping = true;
1358
  controls.dampingFactor = 0.08;
1359
  controls.minDistance = 0.25;
 
1384
  // and the camera position lerp absorbs the small heading correction when
1385
  // live tracking resumes (rolls end facing roughly the same way).
1386
  let chaseHeldYaw = 0;
1387
+ // Heading hysteresis (Schmitt trigger): the walking gait wiggles the trunk
1388
+ // yaw substantially every step (measured ~Β±14 deg at full speed) and
1389
+ // tracking it 1:1 makes the camera sway left-right constantly. Two layers:
1390
+ // 1. chaseYawSmooth: slow EMA of the trunk yaw - the gait's oscillation
1391
+ // is symmetric so this is the duck's MEAN heading, ~steady while
1392
+ // walking straight, moving cleanly during a real sustained turn.
1393
+ // 2. chaseYawFollow: what the camera frames. Frozen until the smooth
1394
+ // heading deviates beyond ENGAGE, then eases toward it and freezes
1395
+ // again below RELEASE (classic Schmitt).
1396
+ // An intentional turn (non-zero wz command from keys or pad) bypasses the
1397
+ // deadband immediately and both layers track fast, so full-speed turns
1398
+ // stay responsive.
1399
+ let chaseYawSmooth = 0; // EMA of trunk yaw (spawn yaw = 0)
1400
+ let chaseYawFollow = 0; // heading the camera actually frames
1401
+ let chaseYawTracking = false;
1402
+ const CHASE_YAW_SMOOTH_EASE = 0.04; // per-frame EMA, ~0.4 s time constant
1403
+ const CHASE_YAW_ENGAGE = 0.17; // rad, ~10 deg: start re-tracking
1404
+ const CHASE_YAW_RELEASE = 0.03; // rad, ~1.7 deg: stop once realigned
1405
+ const CHASE_YAW_EASE = 0.10; // per-frame ease during hysteresis catch-up
1406
+ // During a commanded turn the heading chain must not stack lag on top of
1407
+ // the camera position lerp (rollers turn at ~70 deg/s): near-snap ease,
1408
+ // the position lerp alone provides the smoothing, like pre-hysteresis.
1409
+ const CHASE_YAW_EASE_TURN = 0.5;
1410
+ const wrapPi = (a) => Math.atan2(Math.sin(a), Math.cos(a));
1411
  function updateChaseCam() {
1412
+ // Reset glide: one clean tween from wherever the camera is back to the
1413
+ // home framing (see startCameraReset). Runs instead of the chase logic
1414
+ // and hands control back to it on landing - the destination IS the
1415
+ // chase cam's ideal point, so the handoff is seamless.
1416
+ if (camResetT0 !== null) {
1417
+ // Any detach (drag, right stick, C/R3 toggle) cancels the glide and
1418
+ // gives the camera straight back to the user.
1419
+ if (!chaseCam) { camResetT0 = null; return; }
1420
+ const t = (performance.now() - camResetT0) / 1000 / CAM_RESET_S;
1421
+ const e = t >= 1 ? 1 : t < 0.5 ? 4 * t * t * t : 1 - Math.pow(-2 * t + 2, 3) / 2;
1422
+ camera.position.lerpVectors(_camFrom, _camTo, e);
1423
+ controls.target.lerpVectors(_tgtFrom, _tgtTo, e);
1424
+ camera.lookAt(controls.target);
1425
+ if (t >= 1) camResetT0 = null;
1426
+ return;
1427
+ }
1428
  if (!chaseCam) return;
1429
  const qpos = data.qpos;
1430
+ let rawYaw;
1431
  if (mode === "roll" || isKick()) {
1432
+ rawYaw = chaseHeldYaw;
1433
  } else {
1434
+ rawYaw = Math.atan2(
1435
  2 * (qpos[3] * qpos[6] + qpos[4] * qpos[5]),
1436
  1 - 2 * (qpos[5] * qpos[5] + qpos[6] * qpos[6]),
1437
  );
1438
+ chaseHeldYaw = rawYaw;
1439
+ }
1440
+ // Schmitt heading follow (see constants above). "turning" reads the raw
1441
+ // per-source wz commands (not the locked/merged view) so an intentional
1442
+ // turn engages on the first frame.
1443
+ const turning = controller.sources.some((s) => Math.abs(s.command[2]) > 0.05);
1444
+ chaseYawSmooth = wrapPi(
1445
+ chaseYawSmooth +
1446
+ wrapPi(rawYaw - chaseYawSmooth) * (turning ? CHASE_YAW_EASE_TURN : CHASE_YAW_SMOOTH_EASE),
1447
+ );
1448
+ const yawErr = wrapPi(chaseYawSmooth - chaseYawFollow);
1449
+ if (turning || Math.abs(yawErr) > CHASE_YAW_ENGAGE) chaseYawTracking = true;
1450
+ if (chaseYawTracking) {
1451
+ chaseYawFollow = wrapPi(
1452
+ chaseYawFollow + yawErr * (turning ? CHASE_YAW_EASE_TURN : CHASE_YAW_EASE),
1453
+ );
1454
+ if (!turning && Math.abs(yawErr) < CHASE_YAW_RELEASE) chaseYawTracking = false;
1455
  }
1456
+ const yaw = chaseYawFollow;
1457
  const dist = camera.position.distanceTo(controls.target);
1458
  const horiz = dist * Math.cos(CHASE_PITCH);
1459
  const vert = dist * Math.sin(CHASE_PITCH);
 
1475
  }
1476
  renderer.domElement.addEventListener("pointerdown", () => { chaseCam = false; });
1477
 
1478
+ // ── Camera reset (owned by the respawn ceremony) ────────────────────────
1479
+ // Glides back to the page-load framing: the chase cam's ideal point
1480
+ // behind the duck's spawn heading, at the boot orbit distance. The
1481
+ // distance is captured here, before any user input can zoom; the rest
1482
+ // of the destination is computed live at reset time from the freshly
1483
+ // reset qpos, so it lands correctly in both legs and roller modes.
1484
+ const CAM_HOME_DIST = camera.position.distanceTo(controls.target);
1485
+ let camResetT0 = null; // wall-clock start while the glide is playing
1486
+ const _camFrom = new THREE.Vector3(), _camTo = new THREE.Vector3();
1487
+ const _tgtFrom = new THREE.Vector3(), _tgtTo = new THREE.Vector3();
1488
+ function startCameraReset() {
1489
+ const qpos = data.qpos;
1490
+ const yaw = Math.atan2(
1491
+ 2 * (qpos[3] * qpos[6] + qpos[4] * qpos[5]),
1492
+ 1 - 2 * (qpos[5] * qpos[5] + qpos[6] * qpos[6]),
1493
+ );
1494
+ chaseHeldYaw = yaw; // keep the post-glide chase heading coherent
1495
+ chaseYawSmooth = yaw; // hysteresis state lands with the glide
1496
+ chaseYawFollow = yaw;
1497
+ chaseYawTracking = false;
1498
+ _tgtTo.set(qpos[0], qpos[2], -qpos[1]); // trunk at spawn, MJCF -> three
1499
+ const horiz = CAM_HOME_DIST * Math.cos(CHASE_PITCH);
1500
+ const vert = CAM_HOME_DIST * Math.sin(CHASE_PITCH);
1501
+ _camTo.set(
1502
+ _tgtTo.x - Math.cos(yaw) * horiz,
1503
+ _tgtTo.y + vert,
1504
+ _tgtTo.z + Math.sin(yaw) * horiz,
1505
+ );
1506
+ _camFrom.copy(camera.position);
1507
+ _tgtFrom.copy(controls.target);
1508
+ camResetT0 = performance.now();
1509
+ chaseCam = true; // reset always re-attaches the chase cam
1510
+ }
1511
+
1512
+ // Pause: while the menu is up over a live game, keys belong to the menu.
1513
+ const setInputLock = (v) => { inputLocked = v; controller.setLocked(v); };
1514
+ document.addEventListener("microduck:menu-open", () => {
1515
+ if (ceremony.entranceDone) setInputLock(true);
1516
+ });
1517
+ document.addEventListener("microduck:menu-close", () => {
1518
+ if (ceremony.entranceDone && !ceremony.respawnActive) setInputLock(false);
1519
+ });
1520
+
1521
  function resize() {
1522
  const w = mount.clientWidth, h = mount.clientHeight;
1523
  camera.aspect = w / h;
 
1538
  for (let j = 0; j < NUM_JOINTS; j++) setJoint(rig, JOINT_NAMES[j], qpos[qposAdr[j]]);
1539
  // Passive hinges (roller wheels): purely visual, driven straight from qpos.
1540
  for (const ej of extraJoints) setJoint(rig, ej.name, qpos[ej.adr]);
1541
+ // Ball: live follows qpos; ghost freeze is owned by the ball actor
1542
+ // (physics already parked, mesh holds last pose during reverse scan).
1543
+ if (ball) ball.sync(qpos, ballQposAdr, ballActive);
 
 
 
 
 
1544
  // Follow cam: ease the orbit target toward the trunk and translate the
1545
  // camera by the same delta, so the camera-to-duck distance and viewing
1546
  // angle stay constant while the duck walks. Mouse orbit/zoom still work:
1547
+ // they only change the (preserved) camera-target offset. Paused while
1548
+ // the reset glide owns the camera (it tweens the target itself).
1549
+ if (camResetT0 === null) {
1550
+ _target.set(qpos[0], qpos[2], -qpos[1]);
1551
+ _follow.copy(_target).sub(controls.target);
1552
+ // Horizontal follow at the usual rate; vertical much slower so the
1553
+ // per-step gait bob doesn't nod the frame (sit/crouch height changes
1554
+ // still settle in, just over ~1 s instead of instantly).
1555
+ _follow.x *= 0.06;
1556
+ _follow.z *= 0.06;
1557
+ _follow.y *= 0.015;
1558
+ controls.target.add(_follow);
1559
+ camera.position.add(_follow);
1560
+ }
1561
  // Keep the grid plane (and its fade center) under the action; the wall
1562
  // grids share the same radial fade focus.
1563
  grid.position.set(controls.target.x, 0, controls.target.z);
 
1601
  const quackLoud = () => {
1602
  quackAt = performance.now();
1603
  playChirp();
1604
+ stickers?.pop("quack");
1605
  };
1606
  function jawOpenNow() {
1607
  const t = (performance.now() - quackAt) / QUACK_MS;
 
1640
  if (padXLabelEl) padXLabelEl.textContent = rollers ? "crouch" : "roll";
1641
  }
1642
  const STICK_R = 15; // px, max dot travel inside the 46px stick circle
 
1643
  let ballFlashAt = -Infinity;
1644
  let padYFlashAt = -Infinity;
1645
 
1646
+ // Bottom-right OSD extras: render rate (EMA over frame deltas), live ground
1647
+ // speed off the freejoint, and an odometer integrating horizontal trunk
1648
+ // travel. Teleport-sized jumps (resets, loco swaps) don't count as travel.
1649
+ let fpsEma = 60;
1650
+ let fpsLastT = performance.now();
1651
+ let odoM = 0;
1652
+ let odoX = null, odoY = null;
1653
+
1654
  function renderStats() {
1655
  const [vx, , wz] = effectiveCmd();
1656
  // The active policy lives in the big center label and the twist in the
 
1661
  const p2 = (n) => String(n).padStart(2, "0");
1662
  osdTimeEl.textContent =
1663
  `\u25b6 ${p2(Math.floor(t / 60))}:${p2(Math.floor(t) % 60)}:${p2(Math.floor((t % 1) * 50))}`;
1664
+
1665
+ const now = performance.now();
1666
+ const dtF = (now - fpsLastT) / 1000;
1667
+ fpsLastT = now;
1668
+ if (dtF > 0 && dtF < 0.5) fpsEma += (1 / dtF - fpsEma) * 0.05;
1669
+ const stepD = (odoX === null) ? 0 : Math.hypot(data.qpos[0] - odoX, data.qpos[1] - odoY);
1670
+ if (stepD < 0.05) odoM += stepD; // plausible per-frame travel only
1671
+ odoX = data.qpos[0];
1672
+ odoY = data.qpos[1];
1673
+ const spd = Math.hypot(data.qvel[0], data.qvel[1]);
1674
+
1675
+ // Bottom-right telemetry stack, quietest line last: peers (only when
1676
+ // someone else is around), then speed + odometer, then the loop rates.
1677
+ const odo = odoM < 1000 ? `${odoM.toFixed(1)}M` : `${(odoM / 1000).toFixed(2)}KM`;
1678
+ const lines = [];
1679
+ if (peers) lines.push(`${peers + 1} ONLINE`);
1680
+ lines.push(`${spd.toFixed(2)}M/S \u00b7 ODO ${odo}`);
1681
+ lines.push(`FPS ${Math.round(fpsEma)} \u00b7 CTRL ${ctrlHz.toFixed(0)}HZ`);
1682
+ ctrlHzEl.textContent = lines.join("\n");
1683
 
1684
  // Mini sticks: the dot mirrors the effective twist, lit yellow while the
1685
  // user is actually driving. Normalized against the ACTIVE variant's
1686
  // velocity limits so full deflection reads the same in both.
1687
+ const manual = controller.anyActive() && mode !== "roll";
1688
  const [limF, limB, limA] = velLims();
1689
  const yN = vx >= 0 ? vx / limF : vx / -limB;
1690
  // Move stick is vertical-only now that strafe is gone.
 
1694
  boxTurn.classList.toggle("live", manual && Math.abs(wz) > 0.01);
1695
 
1696
  // Keycap highlighting: each individual key lights only while its own
1697
+ // action is active, and only for its own input device (pressed-state
1698
+ // snapshots come straight from the controller's sources).
1699
  const sitting = mode === "sitstand" && sitFlag === 1;
1700
  for (const k of ["fwd", "back", "turnl", "turnr"]) {
1701
+ keyEls[k].classList.toggle("lit", kbSource.pressed[k]);
1702
  }
1703
  const trick = mode === "roll" || mode === "crouch"; // same R / pad-X slot
1704
  keyEls.roll.classList.toggle("lit", trick && rollSource === "kb");
 
1713
  keyEls.padRb.classList.toggle("lit", mode === "kickR" && kickSource === "pad");
1714
  keyEls.padLb.classList.toggle("lit", mode === "kickL" && kickSource === "pad");
1715
  keyEls.padSit.classList.toggle("lit", sitting);
1716
+ keyEls.padRun.classList.toggle("lit", padSource.pressed.dpadUp);
1717
  keyEls.padRt.classList.toggle("lit", padJaw > 0.3);
1718
  keyEls.padRs.classList.toggle("lit", padOrbitLive); // while deflected
1719
  keyEls.padR3.classList.toggle("lit", chaseCam); // steady while chasing
 
1720
 
1721
+ drawMinimap();
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1722
  }
 
 
 
 
 
 
1723
 
1724
+ // ── Minimap (90s radar, top-right OSD) ──────────────────────────────────
1725
+ // Fixed top-down view of the 3x3 m arena. World +X (the spawn facing)
1726
+ // points UP on the map, so the spawn cell sits bottom-middle; world +Y
1727
+ // points LEFT (right-handed seen from above). Duck = oriented chevron,
1728
+ // ball = white dot while spawned, revealed ghosts = faint dots.
1729
+ const minimapEl = document.getElementById("minimap");
1730
+ const minimapCtx = minimapEl?.getContext("2d");
1731
+ const MINIMAP_HZ = 20;
1732
+ const MINIMAP_ORANGE = "#ff7a2f";
1733
+ let minimapLastDraw = 0;
1734
+ // Chevron heading: the minimap keeps its OWN yaw EMA, independent of the
1735
+ // camera's hysteresis state (chaseHeldYaw only updates while the chase
1736
+ // cam is attached, and the followed yaw freezes on purpose). The EMA
1737
+ // averages out the Β±14 deg per-step gait wobble while tracking real
1738
+ // turns continuously; during roll/kick the trunk quaternion tumbles, so
1739
+ // the last sane value is held (same protection as the camera).
1740
+ let minimapYaw = 0; // spawn yaw = 0
1741
+ const MINIMAP_YAW_EASE = 0.15; // per 20 Hz tick, ~0.3 s to settle on a turn
1742
+ function drawMinimap() {
1743
+ if (!minimapCtx) return;
1744
+ const now = performance.now();
1745
+ if (now - minimapLastDraw < 1000 / MINIMAP_HZ) return;
1746
+ minimapLastDraw = now;
1747
+ const ctx = minimapCtx;
1748
+ const S = minimapEl.width; // square, drawn in device px
1749
+ // World (MJCF, Z-up) -> map px: u right = -Y, v down = -X.
1750
+ const u = (y) => (0.5 - y / (2 * ARENA_HALF)) * S;
1751
+ const v = (x) => (0.5 - x / (2 * ARENA_HALF)) * S;
1752
+ ctx.clearRect(0, 0, S, S);
1753
+ // Section grid (0.6 m pitch): inner lines only, the CSS border frames it.
1754
+ ctx.strokeStyle = "rgba(255, 122, 47, 0.18)";
1755
+ ctx.lineWidth = 1;
1756
+ ctx.beginPath();
1757
+ for (let i = 1; i < 5; i++) {
1758
+ const c = (i / 5) * S;
1759
+ ctx.moveTo(c, 0); ctx.lineTo(c, S);
1760
+ ctx.moveTo(0, c); ctx.lineTo(S, c);
1761
+ }
1762
+ ctx.stroke();
1763
+ const qpos = data.qpos;
1764
+ // Ghosts: revealed peers only, faint white dots.
1765
+ ctx.fillStyle = "rgba(255, 255, 255, 0.4)";
1766
+ for (const g of ghosts?.mapDots() ?? []) {
1767
+ ctx.beginPath();
1768
+ ctx.arc(u(g.y), v(g.x), S * 0.015, 0, Math.PI * 2);
1769
+ ctx.fill();
1770
+ }
1771
+ // Ball: only while spawned (parked = 50 m away = hidden).
1772
+ if (ballActive) {
1773
+ const bx = qpos[ballQposAdr], by = qpos[ballQposAdr + 1];
1774
+ if (Math.abs(bx) <= ARENA_HALF && Math.abs(by) <= ARENA_HALF) {
1775
+ ctx.fillStyle = "rgba(255, 255, 255, 0.92)";
1776
+ ctx.beginPath();
1777
+ ctx.arc(u(by), v(bx), S * 0.019, 0, Math.PI * 2);
1778
+ ctx.fill();
1779
+ }
1780
+ }
1781
+ // Duck: chevron at the trunk position, nose along the smoothed live yaw
1782
+ // (frozen during roll/kick where the trunk tumbles).
1783
+ if (mode !== "roll" && !isKick()) {
1784
+ const rawYaw = Math.atan2(
1785
+ 2 * (qpos[3] * qpos[6] + qpos[4] * qpos[5]),
1786
+ 1 - 2 * (qpos[5] * qpos[5] + qpos[6] * qpos[6]),
1787
+ );
1788
+ minimapYaw = wrapPi(minimapYaw + wrapPi(rawYaw - minimapYaw) * MINIMAP_YAW_EASE);
1789
+ }
1790
+ ctx.save();
1791
+ ctx.translate(u(qpos[1]), v(qpos[0]));
1792
+ // Forward (cos yaw, sin yaw) in world -> (-sin yaw, -cos yaw) on the
1793
+ // map, i.e. a canvas rotation of -yaw applied to an up-pointing shape.
1794
+ ctx.rotate(-minimapYaw);
1795
+ const r = S * 0.032;
1796
+ ctx.fillStyle = MINIMAP_ORANGE;
1797
+ ctx.beginPath();
1798
+ ctx.moveTo(0, -r * 1.35); // nose
1799
+ ctx.lineTo(r * 0.85, r * 0.95);
1800
+ ctx.lineTo(0, r * 0.45); // notched tail = chevron
1801
+ ctx.lineTo(-r * 0.85, r * 0.95);
1802
+ ctx.closePath();
1803
+ ctx.fill();
1804
+ ctx.restore();
1805
  }
1806
 
1807
+ // ── Right-stick camera orbit (inertia downstream of the controller) ─────
1808
+ // The gamepad source reports the raw deflection (controller axes.orbitX/Y);
1809
+ // this step turns it into OrbitControls-compatible motion: rebuild the
1810
+ // camera-target offset as a spherical, nudge azimuth/elevation, and
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1811
  // re-place the camera. controls.update() then runs on the result, so the
1812
  // damping bookkeeping never fights it (same trick as updateChaseCam).
1813
  // The stick does not move the camera directly: it steers an angular
 
1841
  camera.lookAt(controls.target);
1842
  }
1843
 
1844
+ // Multiplayer ghosts, initialised asynchronously at the end of the module.
1845
+ let ghosts = null;
1846
+
1847
+ let inputPollT = performance.now();
1848
+ function loop() {
1849
+ requestAnimationFrame(loop);
1850
+ // dt clamped so a background-tab stall can't slingshot the camera orbit.
1851
+ const inputNow = performance.now();
1852
+ const dt = Math.min((inputNow - inputPollT) / 1000, 0.05);
1853
+ inputPollT = inputNow;
1854
+ controller.update(dt);
1855
+ padJaw = controller.getAxes().jaw;
1856
+ document.body.classList.toggle("pad-connected", padSource.connected);
1857
+ // Camera orbit runs every frame while a pad is present (the coasting
1858
+ // needs the zero-deflection frames too); without a pad, park the state.
1859
+ if (padSource.connected) {
1860
+ padOrbitStep(controller.getAxes().orbitX, controller.getAxes().orbitY, dt);
1861
+ } else {
1862
  padOrbitLive = false;
1863
  padOrbitVel.az = 0;
1864
  padOrbitVel.el = 0;
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1865
  }
1866
+ syncRig();
1867
+ syncJaw();
1868
+ ghosts?.update();
1869
+ controls.update();
1870
+ updateChaseCam();
1871
+ ceremony.drive();
1872
+ ball.drive(() => spawnBall({ fromQueue: true }));
1873
+ renderStats();
1874
+ renderer.render(scene, camera);
1875
+ }
1876
+ // Boot complete: the sim/HUD go live immediately. The BIOS readout (if
1877
+ // the user already waddled in, or when they do) sees bootDone and closes
1878
+ // with READY. + fade on its own.
1879
+ bootDone = true;
1880
+ hudEl.hidden = false;
1881
+ loop();
1882
 
1883
+ // ── Input wiring: arm the controller sources, bind actions to triggers ──
1884
+ // Key/button mappings live in controls/keyboard.js and controls/gamepad.js;
1885
+ // this section is pure game-side wiring. Arming the listeners HERE (not at
1886
+ // source construction) keeps the boot behavior identical to when the raw
1887
+ // keydown/gamepad handlers attached at this point in the module.
1888
+ controller.init();
1889
+
1890
+ // Keyboard F alternates kicking feet; only advance the alternation on
1891
+ // kicks that actually launched (triggerKick reports that).
1892
+ let kbKickFoot = "left";
1893
+ // Action meta.source ("keyboard"/"gamepad") -> the trigger functions'
1894
+ // historical source tags, which the HUD keycap lighting keys off.
1895
+ const srcTag = (source) => (source === "gamepad" ? "pad" : "kb");
1896
+
1897
+ controller.on("reset", () => resetSim());
1898
+ controller.on("spawnBall", ({ source }) => {
1899
+ spawnBall();
1900
+ if (source === "gamepad") padYFlashAt = performance.now();
1901
+ else ballFlashAt = performance.now();
1902
+ });
1903
+ controller.on("chaseToggle", () => { chaseCam = !chaseCam; });
1904
+ controller.on("locoToggle", () => toggleLoco());
1905
+ controller.on("roll", ({ source }) => triggerRoll(srcTag(source)));
1906
+ controller.on("kickL", ({ source }) => triggerKick("left", srcTag(source)));
1907
+ controller.on("kickR", ({ source }) => triggerKick("right", srcTag(source)));
1908
+ controller.on("alternateKick", ({ source }) => {
1909
+ if (triggerKick(kbKickFoot, srcTag(source))) {
1910
+ kbKickFoot = kbKickFoot === "left" ? "right" : "left";
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1911
  }
1912
+ });
1913
+ controller.on("sitToggle", () => {
1914
+ if (loco !== "legs") return; // sitting is a legs-only skill
1915
+ const sitting = mode === "sitstand" && sitFlag === 1;
1916
+ setMode(sitting ? "walk" : "sit");
1917
+ });
1918
+ // Pad DpadUp short press: straight back to running (ignored mid-roll /
1919
+ // mid-crouch: those hand back to walk on their own, and switching on a
1920
+ // tipped duck would floor it).
1921
+ controller.on("walk", () => {
1922
+ if (mode !== "walk" && mode !== "roll" && mode !== "crouch") setMode("walk");
1923
+ });
1924
+ controller.on("quack", () => quackLoud());
1925
 
1926
  // Read-only state label (bottom-left): reflects the active policy,
1927
  // switching happens via keyboard/gamepad only.
1928
  const modeLabel = document.getElementById("mode-label");
1929
 
1930
+ function setMode(next, { force = false } = {}) {
1931
+ if (!force && inputLocked) return;
1932
  // No policy switching mid-roll or mid-kick: both end on their own and
1933
  // return to walk - switching now would floor the duck. Sitting is a
1934
  // legs-only skill (the roller stance has no sitstand policy).
 
1983
  sitFlag = 0;
1984
  rollRun = { steps: 0, tipped: false };
1985
  syncButtons();
1986
+ stickers?.pop("roll");
1987
  }
1988
 
1989
  // Roller-only one-shot: crouch, glide low, stand back up (phase-driven,
 
1995
  mode = "crouch";
1996
  crouchRun = { phase: 0 };
1997
  syncButtons();
1998
+ stickers?.pop("roll"); // same WHEE - the crouch-glide is the roller "roll"
1999
  }
2000
 
2001
  // One blind kick (the duck can't see any ball - it's a scripted boot),
 
2012
  sitFlag = 0;
2013
  kickRun = { steps: 0 };
2014
  syncButtons();
2015
+ stickers?.pop("kick");
2016
  return true;
2017
  }
2018
+ // Matrix-style letter scramble: on change every glyph flips through random
2019
+ // charset entries, then locks to its target left-to-right over ~0.45s.
2020
+ // One interval for the whole run; monospace keeps the width stable.
2021
+ const SCRAMBLE_GLYPHS = "ABCDEFGHIJKLMNOPQRSTUVWXYZ0123456789#$%&*<>/=+";
2022
+ let scrambleTimer = null;
2023
  function setModeLabel(text) {
2024
+ // Guard on the intended target, not the displayed text (which is mid-
2025
+ // scramble noise while the interval runs).
2026
+ if (modeLabel.dataset.target === text) return;
2027
+ modeLabel.dataset.target = text;
2028
+ if (scrambleTimer) { clearInterval(scrambleTimer); scrambleTimer = null; }
2029
+ const span = modeLabel.firstElementChild;
2030
+ const target = text.toUpperCase();
2031
+ const n = target.length;
2032
+ const DUR = 450; // ms until the last letter locks
2033
+ const rnd = () => SCRAMBLE_GLYPHS[(Math.random() * SCRAMBLE_GLYPHS.length) | 0];
2034
+ const t0 = performance.now();
2035
+ scrambleTimer = setInterval(() => {
2036
+ // Letters 0..k-1 are locked; the rest keep boiling.
2037
+ const k = Math.floor(((performance.now() - t0) / DUR) * n);
2038
+ if (k >= n) {
2039
+ clearInterval(scrambleTimer);
2040
+ scrambleTimer = null;
2041
+ span.textContent = target;
2042
+ return;
2043
+ }
2044
+ let out = target.slice(0, k);
2045
+ for (let i = k; i < n; i++) out += rnd();
2046
+ span.textContent = out;
2047
+ }, 40);
2048
  }
2049
 
2050
  function syncButtons() {
 
2097
  get mode() { return mode; },
2098
  get sitFlag() { return sitFlag; },
2099
  buildObs, cmd,
2100
+ // velCmd is the keyboard source's live command array: writing to it
2101
+ // still drives the duck when no other source is active (the controller
2102
+ // falls back to it), same as before the controls/ refactor.
2103
+ velCmd: kbSource.command, lastAction, resetSim,
2104
+ controller, kbSource, padSource,
2105
+ spawnBall, triggerKick, triggerRoll, sessions, ort,
2106
  get loco() { return loco; },
2107
  get locoSwitching() { return locoSwitching; },
2108
+ toggleLoco, setLoco, ensureRollers,
2109
  triggerCrouch,
2110
  get crouchPhase() { return crouchRun?.phase ?? null; },
2111
  get kickSteps() { return KICK_STEPS; },
 
2114
  get ballQposAdr() { return ballQposAdr; },
2115
  get chaseCam() { return chaseCam; },
2116
  set chaseCam(v) { chaseCam = !!v; },
2117
+ get camResetActive() { return camResetT0 !== null; },
2118
+ get respawnActive() { return ceremony?.respawnActive ?? false; },
2119
+ get camPose() {
2120
+ return {
2121
+ pos: camera.position.toArray(),
2122
+ target: controls.target.toArray(),
2123
+ };
2124
+ },
2125
+ get chaseYaw() { return { follow: chaseYawFollow, smooth: chaseYawSmooth, held: chaseHeldYaw, tracking: chaseYawTracking, map: minimapYaw }; },
2126
  padOrbitStep,
2127
  jawOpenNow,
2128
  step: async (n = 1) => { for (let i = 0; i < n; i++) await controlStep(); },
2129
  render: () => { syncRig(); renderer.render(scene, camera); },
2130
  // One full render-loop iteration, for tests driving frames manually.
2131
+ frame: () => { syncRig(); syncJaw(); controls.update(); updateChaseCam(); ceremony.drive(); renderStats(); renderer.render(scene, camera); },
2132
  get ghosts() { return ghosts; },
2133
  get inputLocked() { return inputLocked; },
2134
  // Deterministic entrance controls for screenshots/tests: setting values
2135
+ // detaches the time-based driver; setFx(1) also runs the FX through its
2136
+ // finish path (materials restored) and releases the input lock (same
2137
+ // cleanup as the real sequence).
2138
  entrance: {
2139
+ start: () => ceremony.startEntrance(),
2140
+ setReveal: (floor, wall) => ceremony.setReveal(floor, wall),
2141
+ setFx: (p) => ceremony.setFx(p),
 
 
 
 
 
 
 
 
2142
  },
2143
  };
2144
 
 
2149
  try {
2150
  // Ghosts only join once the entrance has fully played: the world (and
2151
  // this duck) must stay hidden until then, translucent peers included.
2152
+ await ceremony.entranceFinished;
2153
  const { initGhosts } = await import(signed(`./ghosts.js?v=${SELF_V}`));
2154
  ghosts = await initGhosts({
2155
  scene, rig, cloneRig, setJoint, setJawOpen, applyVariant,
 
2172
  };
2173
  },
2174
  });
2175
+ // "HI!" sticker when another visitor joins (setter-style registration,
2176
+ // same trystero build quirk as onPeerLeave inside ghosts.js).
2177
+ if (ghosts.room) ghosts.room.onPeerJoin = () => stickers?.pop("hi");
2178
  } catch (e) {
2179
  window.__ghostErr = String((e && e.stack) || e);
2180
  console.warn("ghosts disabled:", e);
stickers.js ADDED
@@ -0,0 +1,222 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ // Comic sticker popups: on notable game events (kick, quack, roll, ball
2
+ // spawn, ghost peer joining) a die-cut sticker or a comic onomatopoeia pops
3
+ // at a semi-random spot on screen, overshoots in, then fades out. Pure
4
+ // CSS animations, no dependencies.
5
+ //
6
+ // Fully self-contained and OPTIONAL: this module owns its DOM layer and its
7
+ // <style> tag, and imports nothing from the rest of the game. rl.js keeps a
8
+ // nullable `stickers` slot and calls `stickers?.pop("kick")` at each event
9
+ // site, so deleting the single import line in rl.js removes the feature
10
+ // without breaking anything.
11
+ //
12
+ // Image assets are the pre-cut Pollen showcase stickers (assets/stickers/,
13
+ // webp with transparency). Their URLs go through the injected `signed()`
14
+ // helper so they load on the private HF Space (?__sign JWT) as well as
15
+ // locally, where signed() is a no-op.
16
+ //
17
+ // const stickers = initStickers({ signed, isLocked });
18
+ // stickers.pop("quack"); // one of: kick, quack, roll, spawn, hi
19
+ // stickers.dispose(); // remove layer + styles (tests/teardown)
20
+
21
+ // Bump when the webp files change; python http.server sends no
22
+ // Cache-Control, same convention as the mesh/module ?v= busters.
23
+ const ASSET_V = "1";
24
+ const DIR = "./assets/stickers";
25
+
26
+ // Comic palette sampled from the sticker sheet itself.
27
+ const C = {
28
+ red: "#ff3b30",
29
+ teal: "#1fb6a8",
30
+ yellow: "#ffd23f",
31
+ blue: "#2f8bff",
32
+ pink: "#ff2d78",
33
+ };
34
+
35
+ // Per-event pools: each pop picks one variant at random. `img` is a die-cut
36
+ // webp sticker (w = on-screen width in px before jitter), `text` a pure
37
+ // CSS onomatopoeia; both together = text overlaid on the image (POP! burst).
38
+ // `cool` is the per-event rate limit in ms - repeated quacks/kicks reuse
39
+ // the sticker on screen instead of stacking a new one per press.
40
+ const EVENTS = {
41
+ kick: {
42
+ cool: 900,
43
+ pool: [
44
+ { img: "bang-red", w: 170 },
45
+ { img: "boom", w: 175 },
46
+ { img: "star-yellow", w: 120 },
47
+ { text: "BAM!", color: C.red },
48
+ ],
49
+ },
50
+ quack: {
51
+ cool: 700,
52
+ pool: [
53
+ { img: "quack", w: 180 },
54
+ { img: "quack-quack", w: 170 },
55
+ { text: "QUACK!", color: C.teal },
56
+ ],
57
+ },
58
+ roll: {
59
+ cool: 1600,
60
+ pool: [
61
+ { text: "WHEE!", color: C.pink },
62
+ { img: "ziouuu", w: 200 },
63
+ { img: "shooting-star", w: 150 },
64
+ ],
65
+ },
66
+ spawn: {
67
+ cool: 900,
68
+ pool: [
69
+ { img: "burst-yellow", w: 140, text: "POP!", color: C.pink },
70
+ { text: "POP!", color: C.yellow },
71
+ ],
72
+ },
73
+ hi: {
74
+ cool: 2500,
75
+ pool: [
76
+ { text: "HI!", color: C.blue },
77
+ { img: "wave", w: 150 },
78
+ ],
79
+ },
80
+ };
81
+
82
+ const MAX_LIVE = 4; // concurrent stickers, all events combined
83
+ const LIFE_MS = 1250; // matches the CSS animation duration below
84
+
85
+ // Safe zone (viewport %): keeps stickers off the HUD corners - mode label
86
+ // top-left, REC/stats top-right (+ colour stack below it), hint keycaps
87
+ // along the bottom. The middle band is all playground.
88
+ const ZONE = { x0: 16, x1: 78, y0: 20, y1: 68 };
89
+
90
+ const CSS = `
91
+ #sticker-layer {
92
+ position: fixed;
93
+ inset: 0;
94
+ z-index: 9; /* above the CRT overlay (5), below the HUD (10) */
95
+ pointer-events: none;
96
+ overflow: hidden;
97
+ }
98
+ #sticker-layer .sticker {
99
+ position: absolute;
100
+ will-change: transform, opacity;
101
+ animation: sticker-pop ${LIFE_MS}ms cubic-bezier(0.22, 1, 0.36, 1) forwards;
102
+ }
103
+ #sticker-layer .sticker img {
104
+ display: block;
105
+ filter: drop-shadow(0 4px 10px rgba(0, 0, 0, 0.35));
106
+ }
107
+ #sticker-layer .sticker-text {
108
+ font-family: "Arial Black", "Avenir-Black", "Helvetica Neue", sans-serif;
109
+ font-weight: 900;
110
+ font-style: italic;
111
+ font-size: clamp(2.4rem, 5.5vw, 4.2rem);
112
+ letter-spacing: 0.02em;
113
+ white-space: nowrap;
114
+ -webkit-text-stroke: 0.24em #fff;
115
+ paint-order: stroke fill;
116
+ text-shadow: 0.07em 0.1em 0 rgba(20, 10, 40, 0.85);
117
+ filter: drop-shadow(0 4px 10px rgba(0, 0, 0, 0.35));
118
+ }
119
+ /* Text overlaid on a die-cut sticker (e.g. POP! on the blank burst). */
120
+ #sticker-layer .sticker-overlay {
121
+ position: absolute;
122
+ inset: 0;
123
+ display: flex;
124
+ align-items: center;
125
+ justify-content: center;
126
+ font-size: clamp(1.6rem, 3.2vw, 2.4rem);
127
+ transform: rotate(-8deg);
128
+ }
129
+ /* Pop-in with scale overshoot + a small rotation settle, short hold, then
130
+ shrink/fade. --rot / --s carry the per-pop random jitter. */
131
+ @keyframes sticker-pop {
132
+ 0% { transform: translate(-50%, -50%) rotate(calc(var(--rot) - 12deg)) scale(0); opacity: 0; }
133
+ 14% { transform: translate(-50%, -50%) rotate(calc(var(--rot) + 3deg)) scale(calc(var(--s) * 1.22)); opacity: 1; }
134
+ 26% { transform: translate(-50%, -50%) rotate(var(--rot)) scale(calc(var(--s) * 0.96)); }
135
+ 36% { transform: translate(-50%, -50%) rotate(var(--rot)) scale(var(--s)); }
136
+ 78% { transform: translate(-50%, -50%) rotate(var(--rot)) scale(var(--s)); opacity: 1; }
137
+ 100% { transform: translate(-50%, -50%) rotate(var(--rot)) scale(calc(var(--s) * 0.5)); opacity: 0; }
138
+ }
139
+ @media (prefers-reduced-motion: reduce) {
140
+ #sticker-layer .sticker { animation-duration: 1ms; }
141
+ }
142
+ `;
143
+
144
+ const rand = (a, b) => a + Math.random() * (b - a);
145
+
146
+ export function initStickers({ signed = (u) => u, isLocked = () => false } = {}) {
147
+ const url = (name) => signed(`${DIR}/${name}.webp?v=${ASSET_V}`);
148
+
149
+ const style = document.createElement("style");
150
+ style.textContent = CSS;
151
+ document.head.appendChild(style);
152
+
153
+ const layer = document.createElement("div");
154
+ layer.id = "sticker-layer";
155
+ layer.setAttribute("aria-hidden", "true");
156
+ document.body.appendChild(layer);
157
+
158
+ // Warm the image cache so the first pop of each sticker isn't a blank
159
+ // frame while the webp downloads.
160
+ for (const { pool } of Object.values(EVENTS))
161
+ for (const v of pool) if (v.img) new Image().src = url(v.img);
162
+
163
+ const lastAt = new Map(); // event -> last pop timestamp
164
+ // Avoid showing the same variant twice in a row per event.
165
+ const lastPick = new Map();
166
+
167
+ const pop = (event) => {
168
+ const cfg = EVENTS[event];
169
+ if (!cfg) return;
170
+ if (isLocked()) return; // no stickers during the entrance/reset ceremony
171
+ const now = performance.now();
172
+ if (now - (lastAt.get(event) ?? -Infinity) < cfg.cool) return;
173
+ if (layer.childElementCount >= MAX_LIVE) return;
174
+ lastAt.set(event, now);
175
+
176
+ let i = (Math.random() * cfg.pool.length) | 0;
177
+ if (cfg.pool.length > 1 && i === lastPick.get(event)) i = (i + 1) % cfg.pool.length;
178
+ lastPick.set(event, i);
179
+ const v = cfg.pool[i];
180
+
181
+ const el = document.createElement("div");
182
+ el.className = "sticker";
183
+ el.style.left = `${rand(ZONE.x0, ZONE.x1)}%`;
184
+ el.style.top = `${rand(ZONE.y0, ZONE.y1)}%`;
185
+ el.style.setProperty("--rot", `${rand(-14, 14)}deg`);
186
+ el.style.setProperty("--s", rand(0.88, 1.12).toFixed(2));
187
+
188
+ if (v.img) {
189
+ const img = document.createElement("img");
190
+ img.src = url(v.img);
191
+ img.width = v.w;
192
+ img.alt = "";
193
+ el.appendChild(img);
194
+ if (v.text) {
195
+ const t = document.createElement("span");
196
+ t.className = "sticker-text sticker-overlay";
197
+ t.style.color = v.color;
198
+ t.textContent = v.text;
199
+ el.appendChild(t);
200
+ }
201
+ } else {
202
+ const t = document.createElement("span");
203
+ t.className = "sticker-text";
204
+ t.style.color = v.color;
205
+ t.textContent = v.text;
206
+ el.appendChild(t);
207
+ }
208
+
209
+ el.addEventListener("animationend", () => el.remove(), { once: true });
210
+ // Fallback sweep in case the animation never fires (hidden tab).
211
+ setTimeout(() => el.remove(), LIFE_MS + 500);
212
+ layer.appendChild(el);
213
+ };
214
+
215
+ return {
216
+ pop,
217
+ dispose() {
218
+ layer.remove();
219
+ style.remove();
220
+ },
221
+ };
222
+ }
variants.js CHANGED
@@ -228,13 +228,89 @@ function matFor(spec) {
228
  return m;
229
  }
230
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
231
  export function applyVariant(rig, variant) {
232
  const v = typeof variant === "string" ? VARIANTS[variant] : variant;
233
  const map = meshMaterialsFor(v);
 
234
  rig.root.traverse((o) => {
235
  if (!o.isMesh || !o.userData.meshName) return;
236
- o.material = matFor(map[o.userData.meshName] ?? v.mechGray);
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
237
  });
 
238
  }
239
 
240
  // Linear-space spec colour -> sRGB CSS hex, for swatch UI elements.
 
228
  return m;
229
  }
230
 
231
+ // ── Smooth colourway fades ──────────────────────────────────────────────
232
+ // applyVariant on a rig that is live in a scene tweens every mesh's
233
+ // colour/roughness/metalness toward the new spec (same feel as the
234
+ // showcase site: ~0.35 s, ease in-out) instead of snapping. Off-scene
235
+ // rigs (first paint, the locomotion rig swap before scene.add, freshly
236
+ // cloned ghosts) keep the instant path, so hidden repaints stay exact.
237
+ //
238
+ // Each fading mesh gets a transient material (clone of the target cache
239
+ // entry, rewound to the current look) so the shared matCache instances
240
+ // are never mutated. The per-frame driver writes to that transient
241
+ // material AND to whatever the mesh currently carries: systems that swap
242
+ // materials mid-fade (ghostify's transparent clones, the wireframe FX's
243
+ // clip clones) keep receiving colour updates and still land on the exact
244
+ // target values. When nothing intervened, the mesh settles back on the
245
+ // shared cache material, so the at-rest state is byte-identical to a
246
+ // snap (the wireframe FX caches clip clones per material uuid, which
247
+ // must stay stable across colour changes). Driven by a self-contained
248
+ // rAF ticker that only runs while fades are active - no render-loop hook
249
+ // needed, and rapid re-clicks simply retarget from the current colours.
250
+ const FADE_S = 0.35;
251
+ const easeInOut = (x) => (x < 0.5 ? 4 * x * x * x : 1 - Math.pow(-2 * x + 2, 3) / 2);
252
+ const fades = new Map(); // mesh -> fade record
253
+ let fadeRaf = 0;
254
+
255
+ function driveFades() {
256
+ const t = performance.now() / 1000;
257
+ for (const [mesh, f] of fades) {
258
+ const x = Math.min(1, (t - f.start) / FADE_S);
259
+ const e = easeInOut(x);
260
+ f.mat.color.lerpColors(f.fromColor, f.toColor, e);
261
+ f.mat.roughness = f.fromRough + (f.toRough - f.fromRough) * e;
262
+ f.mat.metalness = f.fromMetal + (f.toMetal - f.fromMetal) * e;
263
+ const cur = mesh.material;
264
+ if (cur !== f.mat && cur?.color) {
265
+ cur.color.copy(f.mat.color);
266
+ cur.roughness = f.mat.roughness;
267
+ cur.metalness = f.mat.metalness;
268
+ }
269
+ if (x >= 1) {
270
+ fades.delete(mesh);
271
+ if (cur === f.mat) {
272
+ mesh.material = matFor(f.spec);
273
+ f.mat.dispose();
274
+ }
275
+ }
276
+ }
277
+ fadeRaf = fades.size ? requestAnimationFrame(driveFades) : 0;
278
+ }
279
+
280
  export function applyVariant(rig, variant) {
281
  const v = typeof variant === "string" ? VARIANTS[variant] : variant;
282
  const map = meshMaterialsFor(v);
283
+ const fade = !!rig.placer?.parent;
284
  rig.root.traverse((o) => {
285
  if (!o.isMesh || !o.userData.meshName) return;
286
+ const spec = map[o.userData.meshName] ?? v.mechGray;
287
+ const target = matFor(spec);
288
+ if (!fade) {
289
+ fades.delete(o);
290
+ o.material = target;
291
+ return;
292
+ }
293
+ // Already resting on the target material and not mid-fade: no-op.
294
+ if (o.material === target && !fades.has(o)) return;
295
+ const from = o.material;
296
+ const m = target.clone();
297
+ m.color.copy(from.color);
298
+ m.roughness = from.roughness;
299
+ m.metalness = from.metalness;
300
+ fades.set(o, {
301
+ mat: m,
302
+ spec,
303
+ fromColor: from.color.clone(),
304
+ toColor: target.color.clone(),
305
+ fromRough: from.roughness,
306
+ toRough: target.roughness,
307
+ fromMetal: from.metalness,
308
+ toMetal: target.metalness,
309
+ start: performance.now() / 1000,
310
+ });
311
+ o.material = m;
312
  });
313
+ if (fades.size && !fadeRaf) fadeRaf = requestAnimationFrame(driveFades);
314
  }
315
 
316
  // Linear-space spec colour -> sRGB CSS hex, for swatch UI elements.