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 +8 -0
- assets/microduck-wordmark.svg +0 -0
- assets/stickers/bang-red.webp +3 -0
- assets/stickers/boom.webp +3 -0
- assets/stickers/burst-yellow.webp +3 -0
- assets/stickers/quack-quack.webp +3 -0
- assets/stickers/quack.webp +3 -0
- assets/stickers/shooting-star.webp +3 -0
- assets/stickers/star-yellow.webp +3 -0
- assets/stickers/wave.webp +3 -0
- assets/stickers/ziouuu.webp +3 -0
- ball-actor.js +102 -0
- ceremony.js +171 -0
- controls/controller.js +188 -0
- controls/gamepad.js +153 -0
- controls/keyboard.js +116 -0
- fx/demo-glitch.html +140 -0
- fx/demo-wireframe.html +113 -0
- fx/fx-glitch.js +311 -0
- fx/fx-wireframe.js +320 -0
- ghosts.js +136 -32
- index.html +763 -330
- rl.js +777 -453
- stickers.js +222 -0
- variants.js +77 -1
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
|
assets/stickers/boom.webp
ADDED
|
Git LFS Details
|
assets/stickers/burst-yellow.webp
ADDED
|
Git LFS Details
|
assets/stickers/quack-quack.webp
ADDED
|
Git LFS Details
|
assets/stickers/quack.webp
ADDED
|
Git LFS Details
|
assets/stickers/shooting-star.webp
ADDED
|
Git LFS Details
|
assets/stickers/star-yellow.webp
ADDED
|
Git LFS Details
|
assets/stickers/wave.webp
ADDED
|
Git LFS Details
|
assets/stickers/ziouuu.webp
ADDED
|
Git LFS Details
|
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
|
| 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 =
|
| 17 |
const GHOST_OPACITY = 0.35;
|
| 18 |
-
//
|
| 19 |
-
//
|
| 20 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 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 |
-
|
| 52 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 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,
|
| 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.
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 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
|
| 115 |
removeGhost(peerId);
|
| 116 |
g = makeGhost(state);
|
| 117 |
-
|
|
|
|
| 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
|
| 137 |
-
//
|
|
|
|
| 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.
|
| 152 |
-
//
|
| 153 |
-
const
|
|
|
|
| 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)
|
|
|
|
|
|
|
| 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
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 169 |
// Trunk pose in raw MJCF coords: the cloned root applies Z-up -> Y-up.
|
| 170 |
-
g.trunk.position.
|
| 171 |
-
|
| 172 |
-
|
| 173 |
-
|
| 174 |
-
|
|
|
|
|
|
|
|
|
|
| 175 |
for (let i = 0; i < jointNames.length; i++) {
|
| 176 |
-
g.
|
| 177 |
-
setJoint(g.rig, jointNames[i], g.joints[i]);
|
| 178 |
}
|
| 179 |
-
g.
|
| 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
|
| 7 |
<style>
|
| 8 |
:root {
|
| 9 |
--ink: #08080c;
|
| 10 |
-
--
|
|
|
|
| 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 |
-
/*
|
| 32 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 33 |
position: fixed;
|
| 34 |
-
|
| 35 |
-
|
| 36 |
-
|
| 37 |
-
|
| 38 |
-
|
| 39 |
-
|
| 40 |
-
|
| 41 |
-
|
| 42 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 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 |
-
|
| 54 |
-
|
| 55 |
-
|
|
|
|
| 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:
|
| 67 |
-
|
| 68 |
-
|
|
|
|
| 69 |
text-transform: uppercase;
|
| 70 |
text-align: left;
|
| 71 |
-
|
| 72 |
-
|
| 73 |
white-space: pre-wrap;
|
| 74 |
overflow-wrap: break-word;
|
| 75 |
}
|
| 76 |
#loading .post > div:first-child {
|
| 77 |
-
color: rgba(255, 255, 255, 0.
|
| 78 |
-
margin-bottom: 0.
|
| 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.
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 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 |
-
/*
|
| 137 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 138 |
.mode {
|
| 139 |
-
position: relative;
|
| 140 |
-
overflow: hidden;
|
| 141 |
-
height: 1.2em;
|
| 142 |
min-width: 8ch;
|
| 143 |
-
|
|
|
|
| 144 |
font-weight: 600;
|
| 145 |
-
letter-spacing: 0.
|
| 146 |
text-transform: uppercase;
|
| 147 |
-
color: rgba(255,
|
| 148 |
text-align: right;
|
| 149 |
-
text-shadow: 0 0
|
| 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 |
-
|
| 160 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 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:
|
| 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 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 293 |
font-size: 0.62rem;
|
| 294 |
letter-spacing: 0.14em;
|
| 295 |
text-transform: uppercase;
|
| 296 |
-
|
| 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.
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 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(--
|
| 308 |
-
text-shadow: 0 0 8px rgba(255,
|
| 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 |
-
/*
|
| 326 |
-
|
| 327 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 328 |
#welcome[hidden] { display: none; }
|
| 329 |
#welcome {
|
| 330 |
position: fixed;
|
| 331 |
inset: 0;
|
| 332 |
z-index: 30;
|
| 333 |
display: flex;
|
| 334 |
-
|
| 335 |
-
|
| 336 |
-
|
| 337 |
-
|
| 338 |
-
|
| 339 |
opacity: 1;
|
| 340 |
-
|
|
|
|
| 341 |
}
|
| 342 |
#welcome.closing { opacity: 0; pointer-events: none; }
|
| 343 |
-
|
| 344 |
-
|
| 345 |
-
|
| 346 |
-
|
|
|
|
| 347 |
width: 100%;
|
| 348 |
-
max-width:
|
| 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 |
-
|
| 363 |
-
|
| 364 |
-
|
| 365 |
}
|
| 366 |
-
|
| 367 |
-
#welcome .
|
| 368 |
-
|
|
|
|
| 369 |
}
|
| 370 |
-
#welcome .
|
| 371 |
-
|
| 372 |
-
|
| 373 |
-
|
| 374 |
-
|
| 375 |
-
|
| 376 |
-
|
| 377 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
| 378 |
}
|
| 379 |
-
|
| 380 |
-
|
|
|
|
|
|
|
| 381 |
}
|
| 382 |
-
/*
|
| 383 |
-
|
| 384 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 385 |
position: relative;
|
| 386 |
-
|
| 387 |
-
|
| 388 |
-
filter: drop-shadow(0 10px 24px rgba(0, 0, 0, 0.45));
|
| 389 |
}
|
| 390 |
-
#welcome .
|
| 391 |
-
|
| 392 |
-
|
| 393 |
-
|
| 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 |
-
|
| 402 |
-
|
| 403 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 404 |
font-weight: 700;
|
| 405 |
-
letter-spacing: 0.
|
| 406 |
-
|
| 407 |
-
|
| 408 |
-
color: rgba(255, 210, 63, 0.8);
|
| 409 |
}
|
| 410 |
-
|
| 411 |
-
|
| 412 |
-
|
| 413 |
-
|
| 414 |
-
|
| 415 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
| 416 |
text-wrap: balance;
|
| 417 |
}
|
| 418 |
-
|
| 419 |
-
|
| 420 |
-
|
| 421 |
-
|
| 422 |
-
|
| 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:
|
| 430 |
}
|
| 431 |
-
#welcome .
|
| 432 |
-
|
| 433 |
-
|
| 434 |
-
|
| 435 |
-
height:
|
| 436 |
-
|
|
|
|
|
|
|
| 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.
|
| 443 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 444 |
#welcome .tile {
|
| 445 |
-
padding: 0.
|
| 446 |
-
border: 1px solid rgba(255, 255, 255, 0.
|
| 447 |
-
border-radius:
|
| 448 |
-
background: rgba(255, 255, 255, 0.
|
| 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.
|
| 463 |
justify-content: center;
|
| 464 |
}
|
| 465 |
#welcome kbd {
|
| 466 |
display: inline-flex;
|
| 467 |
align-items: center;
|
| 468 |
justify-content: center;
|
| 469 |
-
min-width: 1.
|
| 470 |
-
height: 1.
|
| 471 |
padding: 0 0.45rem;
|
| 472 |
font: inherit;
|
| 473 |
font-size: 0.68rem;
|
| 474 |
font-weight: 600;
|
| 475 |
-
color:
|
| 476 |
-
background:
|
| 477 |
-
border:
|
| 478 |
-
border-radius:
|
| 479 |
-
box-shadow: 0
|
| 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.
|
| 487 |
-
font-weight:
|
| 488 |
-
letter-spacing: 0.
|
| 489 |
-
text-
|
| 490 |
-
text-
|
| 491 |
-
color:
|
| 492 |
}
|
| 493 |
#welcome .t-hint {
|
| 494 |
-
margin-top: 0.
|
| 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 |
-
|
| 502 |
-
|
| 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:
|
| 522 |
}
|
| 523 |
-
#welcome .
|
|
|
|
| 524 |
font-size: 0.68rem;
|
| 525 |
-
|
| 526 |
-
|
| 527 |
-
|
| 528 |
}
|
| 529 |
-
#welcome .
|
| 530 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 531 |
}
|
| 532 |
-
#welcome
|
| 533 |
appearance: none;
|
| 534 |
-
border:
|
| 535 |
-
background: var(--
|
| 536 |
color: var(--ink);
|
| 537 |
font: inherit;
|
| 538 |
-
font-size:
|
| 539 |
-
font-weight:
|
| 540 |
-
letter-spacing: 0.
|
| 541 |
-
text-
|
| 542 |
-
text-
|
| 543 |
-
padding: 0.
|
| 544 |
border-radius: 999px;
|
|
|
|
| 545 |
cursor: pointer;
|
| 546 |
-
transition: transform 0.
|
| 547 |
}
|
| 548 |
-
#welcome
|
| 549 |
-
transform: translateY(-
|
| 550 |
-
box-shadow: 0
|
| 551 |
}
|
| 552 |
-
#welcome
|
| 553 |
-
|
| 554 |
-
|
| 555 |
}
|
| 556 |
-
#welcome .
|
| 557 |
-
|
| 558 |
-
|
| 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 .
|
| 567 |
-
#welcome .
|
| 568 |
-
#welcome .title {
|
| 569 |
-
#welcome .tagline {
|
| 570 |
-
#welcome .
|
| 571 |
-
#welcome .
|
| 572 |
-
#welcome .
|
| 573 |
-
#welcome .
|
| 574 |
-
|
| 575 |
-
|
| 576 |
-
#welcome .
|
| 577 |
-
#welcome .
|
| 578 |
-
|
| 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
|
| 597 |
-
|
| 598 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 599 |
</div>
|
| 600 |
<div id="stats-wrap">
|
| 601 |
<div class="osd-top">
|
| 602 |
<span class="osd-rec"><span class="rec-dot">●</span> REC</span>
|
| 603 |
<span id="osd-time">▶ 00:00:00</span>
|
| 604 |
</div>
|
| 605 |
-
<
|
| 606 |
-
<div id="stats"></div>
|
| 607 |
<div id="osd-load" hidden>LOADING ROLLERS<span class="osd-load-cursor">█</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="
|
| 651 |
-
<
|
| 652 |
-
<
|
| 653 |
-
<img class="closed" id="
|
| 654 |
-
<img class="open" id="
|
| 655 |
-
</
|
| 656 |
-
<
|
| 657 |
-
|
| 658 |
-
|
| 659 |
-
|
| 660 |
-
|
| 661 |
-
|
| 662 |
-
</
|
| 663 |
-
<
|
| 664 |
-
|
| 665 |
-
<div class="
|
| 666 |
-
<div class="
|
| 667 |
-
<
|
| 668 |
-
|
| 669 |
-
<span class="krow"><kbd>←</kbd><kbd>↓</kbd><kbd>→</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="
|
| 701 |
-
|
| 702 |
-
|
| 703 |
-
|
| 704 |
-
<span class="x-label">Gamepad</span>
|
| 705 |
-
Plug one in, same mapping as the real robot.
|
| 706 |
</div>
|
| 707 |
-
<div class="
|
| 708 |
-
<
|
| 709 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 710 |
</div>
|
| 711 |
-
</section>
|
| 712 |
-
<div class="cta">
|
| 713 |
-
<button id="welcome-go">Waddle in</button>
|
| 714 |
</div>
|
| 715 |
-
<
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 716 |
</div>
|
| 717 |
</div>
|
| 718 |
|
| 719 |
<script>
|
| 720 |
-
//
|
| 721 |
-
//
|
|
|
|
|
|
|
|
|
|
|
|
|
| 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("
|
| 729 |
-
document.getElementById("
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 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 |
-
|
| 746 |
-
|
| 747 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 748 |
window.addEventListener("keydown", (e) => {
|
| 749 |
-
if (e.code === "
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 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 = "
|
| 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">← 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">●</span> REC</span>
|
| 882 |
<span id="osd-time">▶ 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">█</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>↑</kbd></span>
|
| 947 |
+
<span class="krow"><kbd>←</kbd><kbd>↓</kbd><kbd>→</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>↓</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 · scroll to zoom</p>
|
| 1011 |
+
<p class="orbit-hint pad-hint">R3 chase · 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,
|
| 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
|
| 97 |
-
|
|
|
|
|
|
|
|
|
|
| 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
|
| 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 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 130 |
const bootLine = (label) => {
|
| 131 |
-
const entry = { label, status: null, raw: false, el: null };
|
| 132 |
bootLog.push(entry);
|
| 133 |
-
|
| 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.
|
| 159 |
postEl.appendChild(entry.el);
|
| 160 |
-
|
| 161 |
-
|
| 162 |
-
|
| 163 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 164 |
} else if (bootDone) {
|
| 165 |
break;
|
| 166 |
} else {
|
| 167 |
-
|
|
|
|
|
|
|
|
|
|
| 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 |
-
|
| 196 |
-
|
| 197 |
});
|
|
|
|
|
|
|
|
|
|
| 198 |
const traced = (label, p) => {
|
| 199 |
const done = bootLine(label);
|
| 200 |
return p.then(
|
| 201 |
(v) => { done("OK"); return v; },
|
| 202 |
-
(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 =
|
| 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 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 321 |
doneMeshes(`${meshFiles.length} FILES`);
|
| 322 |
|
| 323 |
const sessions = {};
|
| 324 |
-
|
|
|
|
| 325 |
const rigPromise = (async () => {
|
| 326 |
const doneRig = bootLine("RENDER RIG");
|
| 327 |
-
|
| 328 |
-
|
| 329 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 330 |
})();
|
|
|
|
|
|
|
|
|
|
| 331 |
const donePolicies = bootLine("LOADING POLICIES");
|
| 332 |
const sessionOpts = { executionProviders: ["wasm"] };
|
| 333 |
-
|
| 334 |
-
|
| 335 |
-
|
| 336 |
-
|
| 337 |
-
|
| 338 |
-
|
| 339 |
-
|
| 340 |
-
]
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 341 |
donePolicies("5/5");
|
| 342 |
|
| 343 |
const doneCompile = bootLine("COMPILING PHYSICS");
|
| 344 |
-
let model
|
| 345 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 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 |
-
|
| 386 |
-
//
|
| 387 |
-
//
|
| 388 |
-
|
| 389 |
-
|
| 390 |
-
|
| 391 |
-
|
| 392 |
-
|
| 393 |
-
};
|
| 394 |
-
|
| 395 |
-
//
|
|
|
|
| 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 |
-
// (
|
|
|
|
| 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:
|
| 416 |
-
//
|
|
|
|
| 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
|
| 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 |
-
//
|
|
|
|
| 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 |
-
//
|
| 472 |
-
//
|
|
|
|
|
|
|
| 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 |
-
|
| 484 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 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 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 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 |
-
|
| 566 |
-
|
| 567 |
-
|
| 568 |
-
|
| 569 |
-
|
| 570 |
-
|
| 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)
|
| 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)
|
| 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 |
-
|
|
|
|
|
|
|
| 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:
|
| 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 |
-
|
| 732 |
-
|
| 733 |
-
|
| 734 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 735 |
}
|
| 736 |
void main() {
|
| 737 |
float cell = gridLine(vWorld.xz, uCell);
|
| 738 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
| 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
|
| 744 |
-
//
|
| 745 |
-
//
|
|
|
|
|
|
|
|
|
|
|
|
|
| 746 |
if (uReveal < 1.0) {
|
| 747 |
-
|
| 748 |
-
vec2
|
| 749 |
-
|
| 750 |
-
|
| 751 |
-
|
| 752 |
-
|
| 753 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 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:
|
| 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 |
-
|
| 808 |
-
|
| 809 |
-
|
| 810 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 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 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
| 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
|
| 824 |
-
//
|
|
|
|
|
|
|
| 825 |
if (uReveal < 1.0) {
|
| 826 |
-
|
| 827 |
-
vec2
|
| 828 |
-
|
| 829 |
-
|
| 830 |
-
|
| 831 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 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
|
| 915 |
-
|
| 916 |
-
|
| 917 |
-
if (
|
| 918 |
locoSwitching = true;
|
| 919 |
try {
|
| 920 |
-
|
| 921 |
-
if (next === "rollers" && !locos.rollers) {
|
| 922 |
osdLoadEl?.removeAttribute("hidden");
|
| 923 |
await ensureRollers();
|
| 924 |
}
|
| 925 |
-
activateLoco(
|
| 926 |
} catch (e) {
|
| 927 |
-
rollersLoading = null;
|
| 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 |
-
|
| 936 |
-
|
| 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
|
| 1220 |
if (mode === "roll" || isKick()) {
|
| 1221 |
-
|
| 1222 |
} else {
|
| 1223 |
-
|
| 1224 |
2 * (qpos[3] * qpos[6] + qpos[4] * qpos[5]),
|
| 1225 |
1 - 2 * (qpos[5] * qpos[5] + qpos[6] * qpos[6]),
|
| 1226 |
);
|
| 1227 |
-
chaseHeldYaw =
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 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:
|
| 1271 |
-
|
| 1272 |
-
if (
|
| 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 |
-
|
| 1283 |
-
|
| 1284 |
-
|
| 1285 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 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 |
-
|
| 1382 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 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 =
|
| 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",
|
| 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",
|
| 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 |
-
|
| 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 |
-
// ββ
|
| 1447 |
-
|
| 1448 |
-
|
| 1449 |
-
|
| 1450 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1451 |
}
|
| 1452 |
|
| 1453 |
-
//
|
| 1454 |
-
//
|
| 1455 |
-
|
| 1456 |
-
|
| 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 |
-
|
| 1532 |
-
|
| 1533 |
-
|
| 1534 |
-
|
| 1535 |
-
|
| 1536 |
-
|
| 1537 |
-
|
| 1538 |
-
|
| 1539 |
-
|
| 1540 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 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 |
-
|
| 1567 |
-
|
| 1568 |
-
|
| 1569 |
-
|
| 1570 |
-
|
| 1571 |
-
|
| 1572 |
-
|
| 1573 |
-
|
| 1574 |
-
|
| 1575 |
-
|
| 1576 |
-
|
| 1577 |
-
|
| 1578 |
-
|
| 1579 |
-
|
| 1580 |
-
|
| 1581 |
-
|
| 1582 |
-
|
| 1583 |
-
|
| 1584 |
-
|
| 1585 |
-
|
| 1586 |
-
|
| 1587 |
-
|
| 1588 |
-
|
| 1589 |
-
|
| 1590 |
-
|
| 1591 |
-
|
| 1592 |
-
|
| 1593 |
-
|
| 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 |
-
|
| 1615 |
-
|
| 1616 |
-
|
| 1617 |
-
|
| 1618 |
-
|
| 1619 |
-
|
| 1620 |
-
|
| 1621 |
-
|
| 1622 |
-
|
| 1623 |
-
|
| 1624 |
-
if (
|
| 1625 |
-
|
| 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 |
-
//
|
| 1718 |
-
//
|
|
|
|
|
|
|
|
|
|
| 1719 |
function setModeLabel(text) {
|
| 1720 |
-
|
| 1721 |
-
|
| 1722 |
-
|
| 1723 |
-
|
| 1724 |
-
|
| 1725 |
-
modeLabel.
|
| 1726 |
-
|
| 1727 |
-
|
| 1728 |
-
|
| 1729 |
-
|
| 1730 |
-
|
| 1731 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 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
|
| 1785 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
| 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();
|
| 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;
|
| 1807 |
-
//
|
| 1808 |
-
// real sequence).
|
| 1809 |
entrance: {
|
| 1810 |
-
start: startEntrance,
|
| 1811 |
-
setReveal: (floor, wall
|
| 1812 |
-
|
| 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 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 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.
|