tfrere's picture
tfrere HF Staff
feat: single-page title menu, in-game quickbar and smooth ghosts
b2229dc
Raw History Blame
97.8 kB
// Microduck RL playground: the REAL trained policies, not a procedural waddle.
//
// Physics runs in MuJoCo compiled to WebAssembly (the official
// @mujoco/mujoco bindings), stepping the same MJCF the policies were
// trained on (apirrone/mjlab_microduck). The controller is one of the
// exported ONNX checkpoints from apirrone/microduck_runtime, executed with
// onnxruntime-web at 50 Hz (timestep 0.005 s, decimation 4) - exactly the
// loop from mjlab_microduck/scripts/infer_policy.py.
//
// Obs layout (61D, "new-cmd-obs" flavor, from the ONNX metadata):
// [base_ang_vel(3), projected_gravity(3), joint_pos(14), joint_vel(14),
// last_action(14), command(13)]
// command = [vx, vy, wz, head_pose(4), body_pose(6)]; for the sitstand
// policy, command[0] is the posture flag (1 = sit, 0 = stand).
// Action (14) = joint position targets relative to the default pose.
//
// Two locomotion variants share that exact interface (same 14 joints, same
// default pose, same 61D obs - verified from the ONNX metadata):
// legs - the walking robot (robot_allcollisions.xml), boot default
// rollers - the skating variant (robot_allcollisions_rollers.xml): the
// foot/sole assembly is replaced by a blade with 2 passive
// wheels per leg (extra unactuated hinges in qpos, zero in the
// keyframe). Lazy-loaded on the first M / DpadUp-hold switch.
import * as THREE from "three";
import { OrbitControls } from "three/addons/controls/OrbitControls.js";
import { RoomEnvironment } from "three/addons/environments/RoomEnvironment.js";
import loadMujoco from "https://cdn.jsdelivr.net/npm/@mujoco/mujoco@3.11.0/mujoco.js";
import * as ort from "https://cdn.jsdelivr.net/npm/onnxruntime-web@1.27.0/dist/ort.min.mjs";
// Private HF Space auth: the hub iframe URL carries a ?__sign JWT, but
// subresource requests normally rely on a *.static.hf.space cookie that
// browsers often block inside the iframe (third-party cookie blocking),
// which 401s every same-origin fetch. Appending the JWT to each request
// authenticates them regardless of cookie policy. No-op locally.
const HF_SIGN = new URLSearchParams(location.search).get("__sign");
const signed = (url) =>
HF_SIGN ? `${url}${url.includes("?") ? "&" : "?"}__sign=${encodeURIComponent(HF_SIGN)}` : url;
window.__hfSigned = signed; // duck.js uses it for kinematics + STL requests
// Local modules are imported dynamically through signed() for the same
// reason: a static import of ./duck.js would 401 without the cookie.
// They also reuse rl.js's own ?v= cache-buster: python http.server (and
// some CDN edges) send no Cache-Control, so without it browsers can keep
// serving stale module bytes after a deploy.
const SELF_V = new URL(import.meta.url).searchParams.get("v") ?? "0";
const { buildRig, cloneRig, loadKinematics, setJoint, setJawOpen, MODEL_DIR, MESH_VERSION } =
await import(signed(`./duck.js?v=${SELF_V}`));
const { VARIANTS, VARIANT_NAMES, materialHookFor, DEFAULT_VARIANT, applyVariant, specToHex } =
await import(signed(`./variants.js?v=${SELF_V}`));
// Input stack: a video-game-style Controller aggregating pluggable sources
// (keyboard + gamepad today, touch later). controller.js documents the
// source interface contract and the action vocabulary.
const [{ Controller }, { KeyboardSource }, { GamepadSource }] = await Promise.all([
import(signed(`./controls/controller.js?v=${SELF_V}`)),
import(signed(`./controls/keyboard.js?v=${SELF_V}`)),
import(signed(`./controls/gamepad.js?v=${SELF_V}`)),
]);
ort.env.wasm.wasmPaths = "https://cdn.jsdelivr.net/npm/onnxruntime-web@1.27.0/dist/";
ort.env.wasm.numThreads = 1; // static hosting sends no COOP/COEP headers
const POLICY_DIR = "./policies";
const POLICIES = {
walk: `${POLICY_DIR}/BEST_alpha_walking.onnx`,
sitstand: `${POLICY_DIR}/BEST_alpha_sitstand.onnx`,
roll: `${POLICY_DIR}/roulade.onnx`,
// Blind one-shot kicks (the operator aims the robot, no ball in obs):
// the runtime swaps these in for a 0.5 s window, commands zeroed.
kickL: `${POLICY_DIR}/ball_kick_left.onnx`,
kickR: `${POLICY_DIR}/ball_kick_right.onnx`,
// Roller variant (lazy-loaded on first switch, never at boot):
// drive = velocity-tracking skating, crouch = one-shot crouch-glide
// driven by a phase encoding in the command slots (ground-pick style).
drive: `${POLICY_DIR}/BEST_roller.onnx`,
crouch: `${POLICY_DIR}/BEST_roller_crouch.onnx`,
};
// From the ONNX metadata (identical for all alpha policies) and the STAND
// keyframe in mjlab's scene_walk.xml. Order matches the actuators in
// the MJCF.
const JOINT_NAMES = [
"left_hip_yaw", "left_hip_roll", "left_hip_pitch", "left_knee", "left_ankle",
"neck_pitch", "head_pitch", "head_yaw", "head_roll",
"right_hip_yaw", "right_hip_roll", "right_hip_pitch", "right_knee", "right_ankle",
];
const DEFAULT_POSE = new Float32Array([
0, -0.08726646259971647, -0.457924, -0.004940, 0.452984,
0.3490658503988659, 0.3490658503988659, 0, 0,
0, 0.08726646259971647, 0.457924, 0.004940, -0.452984,
]);
const NUM_JOINTS = 14;
const OBS_SIZE = 61;
const CMD_SIZE = 13;
const ACTION_SCALE = 1.0;
const TIMESTEP = 0.005;
const DECIMATION = 4;
const CTRL_DT = TIMESTEP * DECIMATION; // 50 Hz
// Velocity command limits, same as infer_policy.py's keyboard mapping.
// No strafe input anymore: the lateral cmd slot stays zeroed for the obs.
const VEL_FWD = 0.25, VEL_BACK = -0.2, VEL_ANG = 1.0;
// Roller mode limits, from the runtime's roller branch: asymmetric vx
// (0.6 push / 0.5 brake), no lateral. The real runtime launches rollers
// with --max-angular-vel 0.3: faster commanded turns tip the robot over,
// so the playground clamps wz the same way (keyboard and pad both go
// through velLims).
const RVEL_FWD = 0.6, RVEL_BACK = -0.5, RVEL_ANG = 0.3;
// Crouch-glide one-shot: command = [cos(2pi*phase), sin(2pi*phase), 0],
// phase advancing at 1/CROUCH_PERIOD_S per second and the cycle exiting
// at 0.7 - exactly the runtime's ground-pick slot the policy was trained
// against (mjlab CROUCH_PERIOD = 5.0, cycle end 0.7 => 3.5 s gesture).
const CROUCH_PERIOD_S = 5.0;
const CROUCH_END_PHASE = 0.7;
// Kickable ball: radius and parking spot (far away = hidden by default).
const BALL_RADIUS = 0.05;
const BALL_PARK_POS = "50 0 0.05";
// Square arena boxing the play area: static walls at +-ARENA_HALF keep
// the ball (and the duck) inside. Tall enough that neither steps over.
const ARENA_HALF = 1.5; // inner half-size, m
const ARENA_WALL_H = 0.25;
const ARENA_WALL_T = 0.05;
// Section grid: 5 cells across the 3 m arena (ODD, so a true middle
// column/row of cells exists; the lattice is shifted half a cell in the
// shaders so the walls land exactly on section lines).
const GRID_SECTION = (2 * ARENA_HALF) / 5; // 0.6 m
// Spawn: center of the middle section cell in the SECOND ROW FROM THE
// BACK wall. The duck faces +X (identity freejoint quat, walks toward
// local +X), so "back" is the -X wall: row centers sit at x = -1.2,
// -0.6, 0, 0.6, 1.2 -> second row is -0.6; middle column is y = 0.
// MJCF coordinates (three.js: x -> x, y -> -z).
const SPAWN_X = -ARENA_HALF + 1.5 * GRID_SECTION; // -0.6
const SPAWN_Y = 0;
const mount = document.getElementById("scene");
const loadingEl = document.getElementById("loading");
const hudEl = document.getElementById("hud");
const ctrlHzEl = document.getElementById("ctrl-hz");
const osdTimeEl = document.getElementById("osd-time");
// BIOS/POST boot readout. The real load runs silently behind the welcome
// modal and only RECORDS milestones into bootLog; the readout itself plays
// after the first "Waddle in": a rapid-fire replay when everything already
// finished, or an honest live tracker (cursor blinking on the pending
// line) when the user enters mid-load. Never slows the actual boot.
const postEl = loadingEl.querySelector(".post");
const bootLog = []; // { label, status, raw, halt, progress, el } - status null = pending
const lineText = (e) => {
if (e.raw) return e.label;
// Pending line: bare label, plus a live counter when the stage reports
// progress (e.g. "LOADING POLICIES [3/5]").
if (e.status === null) return e.progress ? `${e.label} [${e.progress}]` : e.label;
return `${e.label} `.padEnd(26, ".") + ` ${e.status}`;
};
const bootLine = (label) => {
const entry = { label, status: null, raw: false, el: null, progress: null };
bootLog.push(entry);
const done = (status = "OK") => {
entry.status = status;
if (entry.el) entry.el.textContent = lineText(entry);
};
// Live sub-progress while the stage is still pending (honest path).
done.progress = (p) => {
entry.progress = p;
if (entry.el && entry.status === null) entry.el.textContent = lineText(entry);
};
return done;
};
// Plain line with no dotted leader (header / error text).
const bootNote = (label) => {
bootLog.push({ label, status: "", raw: true, el: null });
};
bootNote("Microduck BIOS v1.0");
bootLine("MEMORY CHECK")("640K OK");
bootLine("DUCK FIRMWARE")("PRESENT");
let bootDone = false;
let bootFailed = false;
let biosStarted = false;
const biosSleep = (ms) => new Promise((r) => setTimeout(r, ms));
// Fatal boot failure: freeze the sequence on the console. The BIOS never
// prints READY, never fades out and never cues the entrance - the halt
// screen IS the diagnostic surface. The welcome modal (if still up) is
// dropped so the console isn't stuck behind its blur.
function bootHalt(detail) {
if (bootFailed || bootDone) return;
bootFailed = true;
bootNote(`>> ${detail}`);
bootLog.push({ label: "SYSTEM HALTED", status: "", raw: true, el: null, halt: true });
const modal = document.getElementById("welcome");
if (modal) modal.hidden = true;
playBios();
}
// Seeded LCG: the replay rhythm is random-feeling but identical on every
// load - real POST screens burst through most checks and stall on a few.
// Seed picked so the draw reads burst/burst/burst/stall/burst/stall.
let biosSeed = 11;
const biosRand = () => (biosSeed = (biosSeed * 1103515245 + 12345) & 0x7fffffff) / 0x7fffffff;
async function playBios() {
if (biosStarted) return;
biosStarted = true;
loadingEl.style.display = "flex";
let i = 0;
for (;;) {
if (i < bootLog.length) {
const entry = bootLog[i++];
entry.el = document.createElement("div");
if (entry.halt) entry.el.className = "halt";
postEl.appendChild(entry.el);
if (entry.status === null && !bootDone && !bootFailed) {
// Honest mode: show the stage label and hold while it's really in
// flight (its completer fills the status and .progress() updates
// the live counter), cursor blinking via CSS.
entry.el.textContent = lineText(entry);
while (entry.status === null && !bootDone && !bootFailed) {
await biosSleep(60);
entry.el.textContent = lineText(entry); // live [n/m] counter
}
await biosSleep(90);
entry.el.textContent = lineText(entry);
} else if (entry.raw || entry.status === null) {
// Header / note lines: quick, no dotted leader to animate.
entry.el.textContent = lineText(entry);
await biosSleep(20 + 60 * biosRand());
} else {
// Finished check: bursty POST pacing. Most lines snap in nearly
// instantly; the occasional one stalls on a "slow check" - and on
// a stall the dotted leader types out one dot at a time before
// the status lands.
const r = biosRand();
const stall = r < 0.75 ? 20 * biosRand() : 150 + 250 * biosRand();
if (stall < 45) {
entry.el.textContent = lineText(entry);
await biosSleep(stall);
} else {
const prefix = `${entry.label} `;
const nDots = Math.max(26 - prefix.length, 3);
entry.el.textContent = prefix;
const per = stall / nDots;
for (let d = 0; d < nDots; d++) {
await biosSleep(per);
entry.el.textContent += ".";
}
await biosSleep(40);
entry.el.textContent = lineText(entry);
}
}
} else if (bootDone) {
break;
} else {
// Waiting for the next real stage. On a failed boot this parks the
// console forever: everything queued (FAIL line, error detail,
// SYSTEM HALTED) has been printed and nothing more will come.
await biosSleep(bootFailed ? 500 : 60);
}
}
const ready = document.createElement("div");
ready.textContent = "READY.";
postEl.appendChild(ready);
await biosSleep(500);
loadingEl.classList.add("off");
// Wait out the overlay's 0.45 s opacity transition BEFORE cueing the
// draw-in: starting it under the fading overlay hides the first (and
// busiest) part of the animation and only the tail end shows.
await biosSleep(500);
loadingEl.style.display = "none";
// Cue the world draw-in + duck scan-up on a fully black screen.
// (Only ever reached after bootDone, so the ceremony module exists.)
ceremony.startEntrance();
}
// Only the initial entry triggers the replay; reopening the modal from
// the brand later never replays (biosStarted latches).
if (window.__microduckEntered) playBios();
else document.addEventListener("microduck:enter", () => playBios(), { once: true });
// Surface boot failures on the page itself: a rejected top-level await
// otherwise leaves a dead page with no visible error. Gated on the boot
// still being in flight: post-boot async noise (ghost relay hiccups,
// audio autoplay rejections...) must NOT cue the BIOS replay early
// behind the welcome modal - that replay belongs to the "Waddle in"
// click alone. Boot-time errors route into the SYSTEM HALTED screen.
window.addEventListener("unhandledrejection", (e) => {
console.error("[rl] unhandled rejection", e.reason);
bootHalt(e.reason?.message || String(e.reason));
});
window.addEventListener("error", (e) => {
console.error("[rl] window error", e.message);
bootHalt(e.message);
});
// Halting at the failure site rather than relying on unhandledrejection:
// a rejected top-level await only surfaces through the dynamic import()
// chain in index.html, which does NOT reliably fire the window handler.
const traced = (label, p) => {
const done = bootLine(label);
return p.then(
(v) => { done("OK"); return v; },
(err) => {
done("FAILED");
console.error(`[rl] ${label} FAILED`, err);
bootHalt(err?.message || String(err));
throw err;
},
);
};
// ── MJCF preparation ────────────────────────────────────────────────────
// robot_allcollisions.xml is what infer_policy.py's scene.xml includes: it
// carries body/shell collision geoms that robot_walk.xml lacks, which the
// sitstand policy needs (a sit rests the trunk on the ground).
// The visual meshes are irrelevant to the dynamics: every body carries an
// explicit <inertial>, and visual geoms have contype=0 conaffinity=0.
// Stripping them means the MuJoCo VFS only needs the ~10 meshes referenced
// by collision geoms.
// Works for both variants: the roller XML only differs by the ankle/wheel
// subtree (4 extra passive hinges), which the keyframe builder below
// handles by walking the joints in document order.
async function buildPhysicsXml(xmlFile) {
const src = await (await fetch(signed(`${MODEL_DIR}/${xmlFile}`))).text();
const doc = new DOMParser().parseFromString(src, "text/xml");
for (const g of [...doc.querySelectorAll('geom[class="visual"]')]) g.remove();
const usedMeshes = new Set(
[...doc.querySelectorAll("geom[mesh]")].map((g) => g.getAttribute("mesh")),
);
for (const m of [...doc.querySelectorAll("asset > mesh")]) {
const name = m.getAttribute("name") ?? m.getAttribute("file").replace(/\.stl$/i, "");
if (!usedMeshes.has(name)) m.remove();
}
const root = doc.documentElement;
const el = (tag, attrs) => {
const e = doc.createElement(tag);
for (const [k, v] of Object.entries(attrs)) e.setAttribute(k, v);
return e;
};
root.appendChild(el("option", { timestep: String(TIMESTEP) }));
doc.querySelector("worldbody").appendChild(
el("geom", { name: "floor", type: "plane", size: "0 0 0.05", pos: "0 0 0" }),
);
// Arena walls: four static boxes (no joints, so no qpos/keyframe
// impact); default contype/conaffinity collides with ball and duck.
const ht = ARENA_WALL_T / 2, hh = ARENA_WALL_H / 2;
const off = ARENA_HALF + ht, span = ARENA_HALF + ARENA_WALL_T;
const walls = [
{ name: "wall_px", pos: `${off} 0 ${hh}`, size: `${ht} ${span} ${hh}` },
{ name: "wall_nx", pos: `${-off} 0 ${hh}`, size: `${ht} ${span} ${hh}` },
{ name: "wall_py", pos: `0 ${off} ${hh}`, size: `${span} ${ht} ${hh}` },
{ name: "wall_ny", pos: `0 ${-off} ${hh}`, size: `${span} ${ht} ${hh}` },
];
for (const w of walls) {
doc.querySelector("worldbody").appendChild(
el("geom", { name: w.name, type: "box", pos: w.pos, size: w.size }),
);
}
// Kickable ball: a light free sphere (beach-ball feel). MuJoCo has no
// restitution parameter - the bounce comes from solref damping < 1, and
// the rolling-friction term makes it come to rest. Default contype /
// conaffinity (1) collide with the floor and the duck's collision-class
// geoms; the self_collision_only class (contype=2) correctly ignores it.
// Appended AFTER the robot body so the trunk freejoint stays first in
// qpos: qpos[0..6] indexing is hardcoded in syncRig, the fall watchdog
// and the ghosts' getLocalState.
const ballBody = el("body", { name: "ball", pos: BALL_PARK_POS });
ballBody.appendChild(el("freejoint", { name: "ball_freejoint" }));
// condim 6 enables the torsional + rolling friction components of the
// friction vector; with the default condim 3 they are ignored and a
// rolling ball never decelerates.
ballBody.appendChild(el("geom", {
name: "ball_geom", type: "sphere", size: String(BALL_RADIUS),
mass: "0.03", friction: "0.4 0.01 0.003", solref: "0.03 0.4", condim: "6",
}));
doc.querySelector("worldbody").appendChild(ballBody);
// STAND keyframe from mjlab's scene_walk.xml (STAND2 pose; the roller
// scene_rollers.xml STAND uses the same trunk height and 14-joint pose).
// qpos must cover every joint in document order: the 14 actuated hinges
// take DEFAULT_POSE by name, anything else (the roller variant's passive
// wheels) starts at zero. The ball's 7 free-joint values MUST be
// appended or nq won't match and the model won't compile; parked 50 m
// away = effectively absent.
const qposFree = `${SPAWN_X} ${SPAWN_Y} 0.12 1 0 0 0`;
const poseByName = new Map(JOINT_NAMES.map((n, i) => [n, DEFAULT_POSE[i]]));
const qposJoints = [...doc.querySelectorAll("body > joint")]
.map((j) => poseByName.get(j.getAttribute("name")) ?? 0)
.join(" ");
const pose14 = Array.from(DEFAULT_POSE).join(" ");
const kf = doc.createElement("keyframe");
kf.appendChild(el("key", {
name: "STAND",
qpos: `${qposFree} ${qposJoints} ${BALL_PARK_POS} 1 0 0 0`,
ctrl: pose14,
}));
root.appendChild(kf);
const meshFiles = [...doc.querySelectorAll("asset > mesh")].map((m) => m.getAttribute("file"));
return { xml: new XMLSerializer().serializeToString(doc), meshFiles };
}
// ── Boot physics + policy in parallel with the render rig ──────────────
const [mujoco, { xml, meshFiles }, k] = await Promise.all([
traced("MUJOCO WASM", loadMujoco()),
traced("PHYSICS MJCF", buildPhysicsXml("robot_allcollisions.xml")),
traced("KINEMATICS", loadKinematics(`${MODEL_DIR}/kinematics.json`)),
]);
const doneMeshes = bootLine("MESH ASSETS");
const vfs = new mujoco.MjVFS();
// One shared VFS for both variants; already-loaded files are skipped so
// the roller lazy-load only fetches its 5 new meshes.
const vfsFiles = new Set();
async function addMeshesToVfs(files) {
await Promise.all(
files.map(async (f) => {
if (vfsFiles.has(f)) return;
vfsFiles.add(f);
// Same cache-busted URL as duck.js so the browser reuses the render
// meshes instead of downloading the collision subset a second time.
const buf = await (await fetch(signed(`${MODEL_DIR}/meshes/${f}?v=${MESH_VERSION}`), { cache: "force-cache" })).arrayBuffer();
// meshdir="assets" in the MJCF, so the compiler looks up "assets/<f>".
vfs.addBuffer(`assets/${f}`, new Uint8Array(buf));
}),
);
}
try {
await addMeshesToVfs(meshFiles);
} catch (err) {
doneMeshes("FAILED");
bootHalt(err?.message || String(err));
throw err;
}
doneMeshes(`${meshFiles.length} FILES`);
const sessions = {};
// Always boot on the classic (orange) colourway; the swatches re-skin live.
let currentVariant = DEFAULT_VARIANT;
const rigPromise = (async () => {
const doneRig = bootLine("RENDER RIG");
try {
const rig = await buildRig(k, { materialForMesh: materialHookFor(VARIANTS[currentVariant]) });
doneRig("OK");
return rig;
} catch (err) {
doneRig("FAILED");
bootHalt(err?.message || String(err));
throw err;
}
})();
// Boot policies with a live [n/5] counter on the BIOS line; any single
// session failure marks the whole line FAILED (the halt detail line names
// the actual error).
const donePolicies = bootLine("LOADING POLICIES");
const sessionOpts = { executionProviders: ["wasm"] };
let policiesLoaded = 0;
const bootPolicy = (url) =>
ort.InferenceSession.create(signed(url), sessionOpts).then((s) => {
donePolicies.progress(`${++policiesLoaded}/5`);
return s;
});
try {
[sessions.walk, sessions.sitstand, sessions.roll, sessions.kickL, sessions.kickR] =
await Promise.all([
bootPolicy(POLICIES.walk),
bootPolicy(POLICIES.sitstand),
bootPolicy(POLICIES.roll),
bootPolicy(POLICIES.kickL),
bootPolicy(POLICIES.kickR),
]);
} catch (err) {
donePolicies("FAILED");
bootHalt(err?.message || String(err));
throw err;
}
donePolicies("5/5");
const doneCompile = bootLine("COMPILING PHYSICS");
let model, data;
try {
model = mujoco.MjModel.from_xml_string(xml, vfs);
data = new mujoco.MjData(model);
} catch (err) {
doneCompile("FAILED");
bootHalt(err?.message || String(err));
throw err;
}
doneCompile("COMPILED");
// Addresses resolved once per compiled variant. qpos/qvel/sensordata views
// are re-read at each use: the WASM heap can grow and detach earlier
// TypedArray views.
// NOTE: unlike the Python bindings, these accessor fields are plain numbers.
const JOINT_SET = new Set(JOINT_NAMES);
function resolveAddrs(model, kin) {
return {
qposAdr: JOINT_NAMES.map((n) => model.jnt(n).qposadr),
dofAdr: JOINT_NAMES.map((n) => model.jnt(n).dofadr),
gyroAdr: model.sensor("imu_ang_vel").adr,
trunkId: mujoco.mj_name2id(model, mujoco.mjtObj.mjOBJ_BODY.value, "trunk_base"),
standKeyId: mujoco.mj_name2id(model, mujoco.mjtObj.mjOBJ_KEY.value, "STAND"),
ballQposAdr: model.jnt("ball_freejoint").qposadr,
ballDofAdr: model.jnt("ball_freejoint").dofadr,
// Unactuated hinges (the roller variant's 4 passive wheels): not in
// the obs or ctrl, but synced to the render rig so the wheels spin.
extraJoints: kin.bodies
.filter((b) => b.joint && b.joint.type === "hinge" && !JOINT_SET.has(b.joint.name))
.map((b) => ({ name: b.joint.name, adr: model.jnt(b.joint.name).qposadr })),
};
}
// Active-variant address block, swapped wholesale by activateLoco.
let { qposAdr, dofAdr, gyroAdr, trunkId, standKeyId, ballQposAdr, ballDofAdr, extraJoints } =
resolveAddrs(model, k);
// Locomotion variants stay resident once built (model + data + rig +
// addresses); legs is registered when its render rig resolves below.
const locos = {};
let loco = "legs"; // "legs" | "rollers"
const velLims = () => (loco === "rollers"
? [RVEL_FWD, RVEL_BACK, RVEL_ANG]
: [VEL_FWD, VEL_BACK, VEL_ANG]);
let uiReady = false;
const lastAction = new Float32Array(NUM_JOINTS);
const obs = new Float32Array(OBS_SIZE);
const cmd = new Float32Array(CMD_SIZE); // [vx, vy, wz, head(4), body(6)]
// Input controller (controls/controller.js): keyboard + gamepad sources
// merged into one continuous command + discrete action surface. Registered
// in priority order - live pad sticks win over held keys, exactly the old
// padActive arbitration. Constructed up here because the control loop reads
// it via effectiveCmd on the very first control step; the sources' event
// listeners are only armed by controller.init() in the input-wiring
// section below, where the raw listeners historically attached.
const kbSource = new KeyboardSource({ getVelocityLimits: () => velLims() });
const padSource = new GamepadSource({ getVelocityLimits: () => velLims() });
const controller = new Controller({ sources: [padSource, kbSource] });
// Right-stick camera state, read by renderStats before the camera-orbit
// section below has evaluated.
let padOrbitLive = false; // HUD: RS keycap lit while deflected
// Robot input gate: twist commands, mode changes, rolls, kicks and ball
// spawns all stay inert until the entrance sequence has fully played out
// (ceremony.drive flips this exactly when the choreography completes).
// Re-engaged by every post-entrance reset for the duck's re-scan.
let inputLocked = true;
// Cutscene + ball-actor modules, assigned once the scene exists. Boot
// resetSim runs before that (physics only); later kills go through these.
let ceremony = null;
let ball = null;
// Comic sticker popups (stickers.js). Nullable on purpose: every hook is a
// one-line `stickers?.pop(...)`, so deleting the single import line below
// (search for initStickers) removes the feature without breaking anything.
let stickers = null;
// HUD: Space keycap flash. Written by the respawn ceremony; read by
// renderStats.
let resetFlashAt = -Infinity;
// "walk" is the main velocity-tracking mode in BOTH variants (legs walking
// policy or roller drive policy - activeSession() picks); "crouch" is the
// roller-only one-shot; the rest are legs-only.
let mode = "walk"; // "walk" | "sitstand" | "roll" | "kickL" | "kickR" | "crouch"
let sitFlag = 0;
const isKick = () => mode === "kickL" || mode === "kickR";
// Local-only kickable ball: false while parked at the keyframe spot
// (mesh hidden), true once popped in front of the duck.
let ballActive = false;
// The twist the policy actually receives: the controller's merged command
// (live gamepad sticks win over held keys via source arbitration). No
// input means zero command - the duck stands in place.
// Shared by buildObs and the HUD mini-sticks so they can never disagree.
// Mid-roll every movement input is ignored (zero twist) until the roll
// hands back to walk on its own - steering would only knock the roll over.
const ZERO_CMD = new Float32Array(3);
function effectiveCmd() {
if (inputLocked || mode === "roll" || mode === "crouch" || isKick() || postKickLock > 0)
return ZERO_CMD;
return controller.getCommand();
}
// One-shot roll tracking: trigger time + whether the trunk actually
// tipped over yet. Set by triggerRoll, cleared when we hand back to walk.
let rollRun = null;
let rollSource = "kb"; // which hint lights up: keyboard Space or pad X
// One-shot crouch-glide tracking (roller variant): phase 0 -> 0.7 over
// 3.5 s, driven per control step. Shares rollSource for keycap lighting
// (same R / pad-X trigger).
let crouchRun = null;
// One-shot kick tracking: the runtime swaps the kick policy in for a fixed
// 0.5 s window (25 control steps at 50 Hz) with zeroed commands, then hands
// straight back to walking. lastAction stays continuous across both swaps.
let kickRun = null;
let kickSource = "kb";
let KICK_STEPS = 25;
// Post-kick grace: keep commands zeroed for a beat after the kick window
// hands back to walk, so the duck finishes the strike cleanly instead of
// instantly sprinting off. Step-counted like everything else.
const POST_KICK_LOCK_STEPS = 20; // 0.4 s at 50 Hz
let postKickLock = 0;
// Pending mode-transition timers (sit hand-over, stand-up hand-back).
// Every transition entry point clears them: a stale timer firing after
// the state has moved on is exactly how a sitting or rolling duck ends
// up handed to the walking policy mid-motion.
let sitTimer = null;
let standTimer = null;
let fallenSince = null; // wall-clock start of the current fallen spell
function clearModeTimers() {
clearTimeout(sitTimer); sitTimer = null;
clearTimeout(standTimer); standTimer = null;
}
function resetSim() {
// Single reset path: Space, fall-kill, failed roll, loco switch. A
// reset is a full transition to the walking stand: cancel any roll in
// flight and any pending sit/stand hand-over, or they'd replay on the
// freshly reset duck.
clearModeTimers();
rollRun = null;
kickRun = null;
crouchRun = null;
postKickLock = 0;
fallenSince = null;
mode = "walk";
mujoco.mj_resetDataKeyframe(model, data, standKeyId);
mujoco.mj_forward(model, data);
lastAction.fill(0);
sitFlag = 0;
// Park the ball in physics immediately; if it was on screen, the
// reverse scan peels it away at its last pose. A queued B-respawn is
// cancelled: a reset means no ball.
ball?.despawn({ cancelQueued: true, parkPhysics: parkBallPhysics });
ballActive = false;
// Buttons reflect sitFlag; keep them honest after auto-resets.
if (uiReady) syncButtons();
ceremony?.playRespawn();
}
resetSim();
// Pop / respawn the ball ~0.35 m in front of the duck, with a small
// random heading + distance jitter so repeated pops land somewhere
// nearby instead of always on the same spot. qvel is zeroed so a respawn
// doesn't carry the old momentum.
// If the ball is already on screen, peel it away first (reverse scan)
// and pop the new one when that finishes - same appear/disappear pair
// as the duck's wireframe ceremony.
function parkBallPhysics() {
const qpos = data.qpos, qvel = data.qvel;
qpos[ballQposAdr] = 50;
qpos[ballQposAdr + 1] = 0;
qpos[ballQposAdr + 2] = BALL_RADIUS;
qpos[ballQposAdr + 3] = 1;
qpos[ballQposAdr + 4] = 0;
qpos[ballQposAdr + 5] = 0;
qpos[ballQposAdr + 6] = 0;
for (let i = 0; i < 6; i++) qvel[ballDofAdr + i] = 0;
mujoco.mj_forward(model, data);
ballActive = false;
}
function spawnBall(opts = {}) {
if (inputLocked && !opts.fromQueue) return;
if (!ball) return;
if (ball.visual !== "hidden") {
ball.queueRespawn();
ball.despawn({ parkPhysics: parkBallPhysics });
return;
}
const qpos = data.qpos, qvel = data.qvel;
// Trunk yaw from the free-joint quaternion (qpos[3..6] = w x y z);
// the duck walks toward its local +X.
const yaw = Math.atan2(
2 * (qpos[3] * qpos[6] + qpos[4] * qpos[5]),
1 - 2 * (qpos[5] * qpos[5] + qpos[6] * qpos[6]),
);
const heading = yaw + (Math.random() - 0.5) * 0.7;
const dist = 0.35 + (Math.random() - 0.5) * 0.1;
// Clamp inside the arena: a duck standing against a wall must not pop
// the ball into (or beyond) it.
const lim = ARENA_HALF - BALL_RADIUS - 0.05;
const clamp = (v) => Math.min(lim, Math.max(-lim, v));
qpos[ballQposAdr] = clamp(qpos[0] + Math.cos(heading) * dist);
qpos[ballQposAdr + 1] = clamp(qpos[1] + Math.sin(heading) * dist);
qpos[ballQposAdr + 2] = BALL_RADIUS + 0.02;
qpos[ballQposAdr + 3] = 1;
qpos[ballQposAdr + 4] = 0;
qpos[ballQposAdr + 5] = 0;
qpos[ballQposAdr + 6] = 0;
for (let i = 0; i < 6; i++) qvel[ballDofAdr + i] = 0;
mujoco.mj_forward(model, data);
ballActive = true;
// Snap the mesh to the new pose BEFORE the scan starts: the FX
// recomputes its bbox from the live mesh.
ball.poseFromQpos(qpos, ballQposAdr);
ball.appear();
stickers?.pop("spawn");
}
// ── Observation ─────────────────────────────────────────────────────────
const _q = new THREE.Quaternion();
const _g = new THREE.Vector3();
function buildObs() {
const qpos = data.qpos, qvel = data.qvel, sens = data.sensordata;
let i = 0;
// base_ang_vel: gyro sensor at the IMU site
for (let a = 0; a < 3; a++) obs[i++] = sens[gyroAdr + a];
// projected gravity: world -z rotated into the trunk frame
const xq = data.body(trunkId).xquat; // [w, x, y, z]
_q.set(xq[1], xq[2], xq[3], xq[0]).conjugate();
_g.set(0, 0, -1).applyQuaternion(_q);
obs[i++] = _g.x; obs[i++] = _g.y; obs[i++] = _g.z;
for (let j = 0; j < NUM_JOINTS; j++) obs[i++] = qpos[qposAdr[j]] - DEFAULT_POSE[j];
for (let j = 0; j < NUM_JOINTS; j++) obs[i++] = qvel[dofAdr[j]];
for (let j = 0; j < NUM_JOINTS; j++) obs[i++] = lastAction[j];
// command: walking/drive use the twist; sitstand uses cmd[0] as the
// posture flag; the crouch-glide one-shot carries its phase encoding in
// the vel slots (ground-pick convention: [cos, sin, 0]).
cmd.fill(0, 0, 3);
if (mode === "sitstand") {
cmd[0] = sitFlag;
} else if (mode === "crouch" && crouchRun) {
const a = 2 * Math.PI * crouchRun.phase;
cmd[0] = Math.cos(a);
cmd[1] = Math.sin(a);
} else {
// walk uses the live twist; roll and kick see all-zero commands
// (via effectiveCmd), matching how they were trained.
const c = effectiveCmd();
cmd[0] = c[0]; cmd[1] = c[1]; cmd[2] = c[2];
}
for (let c = 0; c < CMD_SIZE; c++) obs[i++] = cmd[c];
return obs;
}
// The ONNX session for the current mode: in the roller variant the main
// velocity mode runs the drive (skating) policy instead of the walker.
const activeSession = () =>
sessions[loco === "rollers" && mode === "walk" ? "drive" : mode];
// ── Control loop (50 Hz, async because ONNX inference is async) ────────
let ctrlHz = 0;
// Dead pose: walk/sitstand have no get-up skill, so a kill here is just
// a resetSim (same ceremony as Space). Height alone would false-positive
// on a deep sit, so "fallen" = trunk tilted past ~60 deg (projected
// gravity z above -0.5) or sunk below the floor. NaN/Inf is a solver
// explosion: no grace, reset on the spot. The roll tumbles the trunk on
// purpose and recovers on its own, so it gets a much longer grace window
// before we call it stuck.
function poseIsDead() {
const z = data.qpos[2];
const gz = obs[5]; // projected gravity z, from the last obs
if (!Number.isFinite(z) || !Number.isFinite(gz)) return "exploded";
if (gz > -0.5 || z < 0.02) return "fallen";
return null;
}
async function controlStep() {
const feeds = { obs: new ort.Tensor("float32", buildObs(), [1, OBS_SIZE]) };
const out = await activeSession().run(feeds);
const act = out.actions.data;
lastAction.set(act);
const ctrl = data.ctrl;
for (let j = 0; j < NUM_JOINTS; j++) ctrl[j] = DEFAULT_POSE[j] + act[j] * ACTION_SCALE;
for (let s = 0; s < DECIMATION; s++) mujoco.mj_step(model, data);
const death = poseIsDead();
if (death === "exploded") {
resetSim();
} else if (death === "fallen") {
const now = performance.now();
const graceMs = mode === "roll" ? 5000 : 1000;
fallenSince ??= now;
if (now - fallenSince > graceMs) resetSim();
} else {
fallenSince = null;
}
// Ball respawn watchdog: with the arena walls the ball can no longer
// legitimately leave, so this is a safety net for solver tunnelling -
// outside the arena bounds means "escaped through a glitch", bring it
// back near the duck. Re-read qpos: resetSim above may have re-parked
// the ball.
if (ballActive) {
const q = data.qpos;
const escaped =
Math.abs(q[ballQposAdr]) > ARENA_HALF + 0.1 ||
Math.abs(q[ballQposAdr + 1]) > ARENA_HALF + 0.1;
if (escaped) spawnBall();
}
// One-shot roll, step-counted like the robot runtime (a single roll is
// ~1 s = 50 control steps there): hand back to walking once the trunk has
// tipped over and is upright again, or after a hard 2 s window if the roll
// never initiated. Counting steps instead of wall time keeps the logic
// correct when the sim is fast-forwarded or the tab is throttled.
// One-shot kick: fixed 0.5 s window like the robot runtime, then straight
// back to walking. lastAction is NOT zeroed on either swap - the runtime
// keeps one continuous action history across policy switches.
if (postKickLock > 0 && mode === "walk") postKickLock--;
if (isKick() && kickRun) {
kickRun.steps++;
if (kickRun.steps >= KICK_STEPS) {
kickRun = null;
mode = "walk";
postKickLock = POST_KICK_LOCK_STEPS;
if (uiReady) syncButtons();
}
}
// Crouch-glide one-shot: advance the trained phase clock and hand back
// to the drive policy at the runtime's cycle end (0.7 x 5 s = 3.5 s:
// sink, glide low, stand back up). lastAction stays continuous, same as
// every other policy swap.
if (mode === "crouch" && crouchRun) {
crouchRun.phase += CTRL_DT / CROUCH_PERIOD_S;
if (crouchRun.phase >= CROUCH_END_PHASE) {
crouchRun = null;
mode = "walk";
if (uiReady) syncButtons();
}
}
if (mode === "roll" && rollRun) {
rollRun.steps++;
if (obs[5] > -0.3) rollRun.tipped = true;
const upright = obs[5] < -0.85;
const done = rollRun.tipped && upright && rollRun.steps >= 40;
const expired = rollRun.steps >= 150; // 3 s, roll should long be over
if (done || expired) {
rollRun = null;
mode = "walk";
lastAction.fill(0);
// Timed out mid-roll: don't hand a tipped duck to the walking policy
// (it has no get-up skill). Same ceremony as Space / fall-kill.
if (!upright) resetSim();
if (uiReady) syncButtons();
}
}
}
let running = true;
(async function controlLoop() {
let next = performance.now();
let count = 0, hzT0 = next;
while (running) {
await controlStep();
count++;
const now = performance.now();
if (now - hzT0 > 500) {
ctrlHz = (count * 1000) / (now - hzT0);
count = 0; hzT0 = now;
}
next += CTRL_DT * 1000;
const wait = next - performance.now();
if (wait > 0) await new Promise((r) => setTimeout(r, wait));
else next = performance.now(); // fell behind: don't spiral
}
})();
// ── Rendering (three.js rig driven by qpos) ─────────────────────────────
const scene = new THREE.Scene();
scene.background = new THREE.Color(0x08080c);
const camera = new THREE.PerspectiveCamera(40, 1, 0.02, 30);
// Boot framing translated onto the spawn cell (the orbit target follows
// below), so the follow-cam has nothing to drift toward during boot.
camera.position.set(SPAWN_X + 0.55, 0.35, -SPAWN_Y + 0.7);
const renderer = new THREE.WebGLRenderer({ antialias: true, alpha: true });
renderer.setPixelRatio(Math.min(2, window.devicePixelRatio));
renderer.setClearColor(0x08080c, 1);
renderer.toneMapping = THREE.ACESFilmicToneMapping;
mount.appendChild(renderer.domElement);
const pmrem = new THREE.PMREMGenerator(renderer);
scene.environment = pmrem.fromScene(new RoomEnvironment()).texture;
scene.environmentIntensity = 0.45;
scene.add(new THREE.AmbientLight(0xffffff, 0.6));
const keyLight = new THREE.DirectionalLight(0xffffff, 1.6);
keyLight.position.set(2, 4, 2);
scene.add(keyLight);
const fill = new THREE.DirectionalLight(0xffffff, 0.4);
fill.position.set(-2, 2, 1.5);
scene.add(fill);
const rim = new THREE.DirectionalLight(0xffb366, 0.7);
rim.position.set(0, 3, -2);
scene.add(rim);
// Infinite shader grid, ported from drei's <Grid> (we're in vanilla three,
// not R3F): anti-aliased world-space lines at cell/section frequencies with
// a radial fade around the duck. Lines derive from world coordinates, so
// re-centering the mesh under the camera target every frame makes the grid
// effectively infinite without any visible swimming.
function makeInfiniteGrid() {
const material = new THREE.ShaderMaterial({
transparent: true,
depthWrite: false,
uniforms: {
uCell: { value: 0.1 },
uSection: { value: GRID_SECTION },
uCellColor: { value: new THREE.Color(0x8e8371) },
uSectionColor: { value: new THREE.Color(0xffb366) },
uFadeDist: { value: 3.0 },
uFocus: { value: new THREE.Vector3() },
// Entrance draw-in progress; 1 = steady state (branch skipped).
// Starts at 0: the world stays hidden behind the welcome modal and
// the BIOS readout until playBios cues startEntrance.
uReveal: { value: 0.0 },
},
vertexShader: /* glsl */ `
varying vec3 vWorld;
void main() {
vec4 w = modelMatrix * vec4(position, 1.0);
vWorld = w.xyz;
gl_Position = projectionMatrix * viewMatrix * w;
}
`,
fragmentShader: /* glsl */ `
varying vec3 vWorld;
uniform float uCell, uSection, uFadeDist, uReveal;
uniform vec3 uCellColor, uSectionColor, uFocus;
// Tron-style line: a thicker antialiased core plus a faint, much
// wider halo added on top (squared falloff keeps it a whisper of a
// glow rather than a bloom wash).
float lineProf(float g) {
float core = 1.0 - smoothstep(0.0, 1.8, g);
float halo = 1.0 - smoothstep(0.0, 7.0, g);
return core + halo * halo * 0.22;
}
float gridLine(vec2 p, float size) {
vec2 r = p / size;
vec2 g = abs(fract(r - 0.5) - 0.5) / fwidth(r);
return lineProf(min(g.x, g.y));
}
// Entrance draw-in: one family of parallel lines, drawn line by line.
// id picks the line, "along" runs down its length. Each line waits
// out its own hashed delay, then extends from the origin outward with
// a hard front. Returns (mask, head): head marks the bright segment
// right behind the draw front while the line is still growing.
vec2 drawLine(float id, float along, float t0, float spread, float dur, float maxLen) {
float jit = fract(sin(id * 127.1) * 43758.5453);
float grow = clamp((uReveal - t0 - jit * spread) / dur, 0.0, 1.0);
float len = grow * maxLen;
float a = abs(along);
float mask = 1.0 - smoothstep(len - 0.05, len, a);
float head = (1.0 - smoothstep(0.0, 0.6, len - a)) * mask
* step(0.001, grow) * (1.0 - step(0.999, grow));
return vec2(mask, head);
}
void main() {
float cell = gridLine(vWorld.xz, uCell);
// Section lattice shifted half a cell: with 5 sections across the
// 3 m arena (odd count) this centers a CELL on the origin and puts
// section lines exactly on the walls at +-1.5.
vec2 pSec = vWorld.xz + 0.5 * uSection;
float section = gridLine(pSec, uSection);
float d = distance(vWorld.xz, uFocus.xz);
float fade = pow(clamp(1.0 - d / uFadeDist, 0.0, 1.0), 1.6);
vec3 col = mix(uCellColor, uSectionColor, clamp(section, 0.0, 1.0));
float alpha = min(max(section * 0.6, cell * 0.4) * fade, 1.0);
// Entrance: only the bright section lines get the line-by-line draw
// (staggered, with a hot draw head); the fine cells just fade in
// over the reveal's second half - drawing every small line reads as
// visual noise. lineProf(min(gx, gy)) == max of per-axis profiles
// (profile is monotonic) and the cell fade lands on exactly the
// steady-state cell term, so at uReveal 1 this branch equals the
// formula above exactly (and is skipped).
if (uReveal < 1.0) {
vec2 rs = pSec / uSection;
vec2 gs = abs(fract(rs - 0.5) - 0.5) / fwidth(rs);
// Const-x lines run along z and vice versa; the offset
// decorrelates the two families' hashed delays.
vec2 sx = drawLine(floor(rs.x + 0.5), vWorld.z, 0.00, 0.30, 0.35, 8.0);
vec2 sz = drawLine(floor(rs.y + 0.5) + 57.0, vWorld.x, 0.05, 0.30, 0.35, 8.0);
float secR = max(lineProf(gs.x) * sx.x, lineProf(gs.y) * sz.x);
float cellR = cell * smoothstep(0.5, 1.0, uReveal);
float headGlow = max(lineProf(gs.x) * sx.y, lineProf(gs.y) * sz.y);
col = mix(uCellColor, uSectionColor, clamp(secR, 0.0, 1.0));
alpha = min(max(secR * 0.6, cellR * 0.4) * fade, 1.0);
// Bright draw head: a short white-hot tip sells the "drawing" read.
headGlow = clamp(headGlow, 0.0, 1.0);
col = mix(col, vec3(1.0, 0.86, 0.55), headGlow * 0.8);
alpha = min(alpha + headGlow * fade * 0.5, 1.0);
}
if (alpha < 0.004) discard;
gl_FragColor = vec4(col, alpha);
}
`,
});
const mesh = new THREE.Mesh(new THREE.PlaneGeometry(30, 30), material);
mesh.rotation.x = -Math.PI / 2;
return mesh;
}
const grid = makeInfiniteGrid();
scene.add(grid);
// Arena walls, drawn in the same grid language as the floor: identical
// cell/section lines from world coordinates, same radial fade around the
// duck, plus a vertical fade toward the top edge so the walls read as a
// light enclosure instead of solid slabs.
function makeWallGridMaterial(alongX) {
return new THREE.ShaderMaterial({
transparent: true,
depthWrite: false,
side: THREE.DoubleSide,
uniforms: {
uCell: { value: 0.1 },
uSection: { value: GRID_SECTION },
uCellColor: { value: new THREE.Color(0x8e8371) },
uSectionColor: { value: new THREE.Color(0xffb366) },
// Gentler radial fade than the floor: the walls sit 1.5+ m from the
// duck by construction and would vanish with the floor's 3 m fade.
uFadeDist: { value: 5.0 },
uFocus: { value: new THREE.Vector3() },
uWallH: { value: ARENA_WALL_H },
uAlongX: { value: alongX ? 1.0 : 0.0 },
// Entrance draw-in progress; 1 = steady state (branch skipped).
// Starts at 0, same as the floor grid: hidden until startEntrance.
uReveal: { value: 0.0 },
},
vertexShader: /* glsl */ `
varying vec3 vWorld;
void main() {
vec4 w = modelMatrix * vec4(position, 1.0);
vWorld = w.xyz;
gl_Position = projectionMatrix * viewMatrix * w;
}
`,
fragmentShader: /* glsl */ `
varying vec3 vWorld;
uniform float uCell, uSection, uFadeDist, uWallH, uAlongX, uReveal;
uniform vec3 uCellColor, uSectionColor, uFocus;
// Same Tron-style core + faint halo as the floor grid.
float lineProf(float g) {
float core = 1.0 - smoothstep(0.0, 1.8, g);
float halo = 1.0 - smoothstep(0.0, 7.0, g);
return core + halo * halo * 0.22;
}
float gridLine(vec2 p, float size) {
vec2 r = p / size;
vec2 g = abs(fract(r - 0.5) - 0.5) / fwidth(r);
return lineProf(min(g.x, g.y));
}
// Same line-by-line draw as the floor grid (see its comments).
vec2 drawLine(float id, float along, float t0, float spread, float dur, float maxLen) {
float jit = fract(sin(id * 127.1) * 43758.5453);
float grow = clamp((uReveal - t0 - jit * spread) / dur, 0.0, 1.0);
float len = grow * maxLen;
float a = abs(along);
float mask = 1.0 - smoothstep(len - 0.05, len, a);
float head = (1.0 - smoothstep(0.0, 0.35, len - a)) * mask
* step(0.001, grow) * (1.0 - step(0.999, grow));
return vec2(mask, head);
}
void main() {
// Wall surface coords: the in-plane horizontal world axis + height.
float h = mix(vWorld.z, vWorld.x, uAlongX);
vec2 p = vec2(h, vWorld.y);
float cell = gridLine(p, uCell);
// Horizontal axis shifted half a section to match the floor's odd
// lattice (vertical section lines meet the floor's at the base);
// the height axis keeps its base line at y = 0.
vec2 pSec = vec2(p.x + 0.5 * uSection, p.y);
float section = gridLine(pSec, uSection);
float d = distance(vWorld.xz, uFocus.xz);
float fade = pow(clamp(1.0 - d / uFadeDist, 0.0, 1.0), 1.6);
float vert = 1.0 - clamp(vWorld.y / uWallH, 0.0, 1.0);
vec3 col = mix(uCellColor, uSectionColor, clamp(section, 0.0, 1.0));
float alpha = min(max(section * 0.9, cell * 0.6) * fade * (0.3 + 0.7 * vert), 1.0);
// Entrance: section lines only - horizontals zip out from the
// wall's center, verticals rise from the ground, each with a
// hashed delay; the fine cells fade in over the reveal's second
// half. Same steady-state equivalence argument as the floor grid.
if (uReveal < 1.0) {
vec2 rs = pSec / uSection;
vec2 gs = abs(fract(rs - 0.5) - 0.5) / fwidth(rs);
// Const-height lines run along h (grow from center outward);
// const-h lines run along y (grow up from the ground).
vec2 sh = drawLine(floor(rs.y + 0.5), p.x, 0.00, 0.30, 0.40, 2.0);
vec2 sv = drawLine(floor(rs.x + 0.5) + 31.0, p.y, 0.30, 0.30, 0.30, uWallH);
float secR = max(lineProf(gs.y) * sh.x, lineProf(gs.x) * sv.x);
float cellR = cell * smoothstep(0.5, 1.0, uReveal);
float headGlow = max(lineProf(gs.y) * sh.y, lineProf(gs.x) * sv.y);
col = mix(uCellColor, uSectionColor, clamp(secR, 0.0, 1.0));
alpha = min(max(secR * 0.9, cellR * 0.6) * fade * (0.3 + 0.7 * vert), 1.0);
headGlow = clamp(headGlow, 0.0, 1.0);
col = mix(col, vec3(1.0, 0.86, 0.55), headGlow * 0.8);
alpha = min(alpha + headGlow * fade * 0.5, 1.0);
}
if (alpha < 0.004) discard;
gl_FragColor = vec4(col, alpha);
}
`,
});
}
const wallMats = [];
{
const wallLen = 2 * (ARENA_HALF + ARENA_WALL_T);
// (three coords: MJCF x -> x, MJCF y -> -z; walls sit at their inner faces)
const wallDefs = [
{ x: ARENA_HALF, z: 0, rotY: -Math.PI / 2, alongX: false },
{ x: -ARENA_HALF, z: 0, rotY: Math.PI / 2, alongX: false },
{ x: 0, z: ARENA_HALF, rotY: Math.PI, alongX: true },
{ x: 0, z: -ARENA_HALF, rotY: 0, alongX: true },
];
for (const w of wallDefs) {
const mat = makeWallGridMaterial(w.alongX);
wallMats.push(mat);
const mesh = new THREE.Mesh(new THREE.PlaneGeometry(wallLen, ARENA_WALL_H), mat);
mesh.position.set(w.x, ARENA_WALL_H / 2, w.z);
mesh.rotation.y = w.rotY;
scene.add(mesh);
}
}
let rig = await rigPromise;
scene.add(rig.placer);
let trunkGroup = rig.bodies.get("trunk_base");
locos.legs = {
model, data, rig, trunkGroup,
qposAdr, dofAdr, gyroAdr, trunkId, standKeyId, ballQposAdr, ballDofAdr, extraJoints,
};
// ── Locomotion variant switching (legs <-> rollers) ─────────────────────
// The roller stack (XML + 5 extra meshes + kinematics + 2 ONNX policies)
// is lazy-loaded on the first switch so the default boot stays untouched,
// then kept resident: switching back and forth only swaps references.
let rollersLoading = null;
function ensureRollers() {
rollersLoading ??= (async () => {
const [{ xml: rXml, meshFiles: rMeshFiles }, rk] = await Promise.all([
buildPhysicsXml("robot_allcollisions_rollers.xml"),
loadKinematics(`${MODEL_DIR}/kinematics_rollers.json`),
]);
const [rRig, sDrive, sCrouch] = await Promise.all([
buildRig(rk, { materialForMesh: materialHookFor(VARIANTS[currentVariant]) }),
ort.InferenceSession.create(signed(POLICIES.drive), sessionOpts),
ort.InferenceSession.create(signed(POLICIES.crouch), sessionOpts),
addMeshesToVfs(rMeshFiles),
]);
sessions.drive = sDrive;
sessions.crouch = sCrouch;
const rModel = mujoco.MjModel.from_xml_string(rXml, vfs);
const rData = new mujoco.MjData(rModel);
locos.rollers = {
model: rModel, data: rData, rig: rRig, trunkGroup: rRig.bodies.get("trunk_base"),
...resolveAddrs(rModel, rk),
};
})();
return rollersLoading;
}
function activateLoco(name) {
const L = locos[name];
loco = name;
scene.remove(rig.placer);
({ model, data, rig, trunkGroup, qposAdr, dofAdr, gyroAdr, trunkId,
standKeyId, ballQposAdr, ballDofAdr, extraJoints } = L);
// The rig may have been built (or last shown) under another colourway.
applyVariant(rig, currentVariant);
scene.add(rig.placer);
document.body.classList.toggle("rollers", name === "rollers");
resetSim();
if (uiReady) syncLocoHints();
}
// OSD line while the roller stack streams in, BIOS-style.
let osdLoadEl = null;
let locoSwitching = false;
async function setLoco(name, { force = false } = {}) {
if (name !== "legs" && name !== "rollers") return;
if (loco === name || locoSwitching) return;
if (!force && (inputLocked || rollRun || kickRun || crouchRun || standTimer)) return;
locoSwitching = true;
try {
if (name === "rollers" && !locos.rollers) {
osdLoadEl?.removeAttribute("hidden");
await ensureRollers();
}
activateLoco(name);
} catch (e) {
rollersLoading = null;
console.error("[rl] roller switch failed", e);
} finally {
osdLoadEl?.setAttribute("hidden", "");
locoSwitching = false;
}
}
async function toggleLoco() {
await setLoco(loco === "legs" ? "rollers" : "legs");
}
// ── Cutscenes (entrance + respawn) ──────────────────────────────────────
// Owned by ceremony.js. Physics reset stays in resetSim; this is the
// camera + materialization layer. Boot-hidden: bind parks the duck clip
// below the feet so nothing shows through the welcome modal / BIOS.
const fx = await import(signed(`./fx/fx-wireframe.js?v=${SELF_V}`));
const { createCeremony, CAM_RESET_S } = await import(signed(`./ceremony.js?v=${SELF_V}`));
ceremony = createCeremony({
THREE, scene, camera, renderer, fx,
getRig: () => rig,
grid, wallMats,
syncRig, startCameraReset,
setLocked: (v) => { inputLocked = v; controller.setLocked(v); },
flashReset: () => { resetFlashAt = performance.now(); },
});
// Soccer-ball look computed per pixel on the sphere itself, so there is
// no pole or seam special case by design. The truncated icosahedron is
// reconstructed as a spherical Voronoi diagram over 32 sites: the 12
// icosahedron vertices (black pentagon centers, one sitting at each
// pole) and its 20 face centers (white hexagon centers). A pixel is
// black when its nearest site is a pentagon center and it sits clear of
// the cell boundary by a seam margin - which yields big flat-edged black
// pentagons separated from the white hexagons by thin seams, corners
// almost touching, exactly like the real panel layout.
function makeSoccerBallTexture() {
const W = 1024, H = 512;
const c = document.createElement("canvas");
c.width = W;
c.height = H;
const ctx = c.getContext("2d");
// 12 icosahedron vertices: 2 poles + two staggered rings of 5 at
// latitude +-atan(1/2) (~26.57 deg) - the pentagon centers.
const sites = [];
const addSite = (v, isPent) => {
const n = Math.hypot(v[0], v[1], v[2]);
sites.push({ x: v[0] / n, y: v[1] / n, z: v[2] / n, pent: isPent });
};
const verts = [[0, 0, 1], [0, 0, -1]];
const latR = Math.atan(0.5), cr = Math.cos(latR), sr = Math.sin(latR);
for (let i = 0; i < 5; i++) {
const a = (i * 72 * Math.PI) / 180;
const b = ((i * 72 + 36) * Math.PI) / 180;
verts.push([cr * Math.cos(a), cr * Math.sin(a), sr]);
verts.push([cr * Math.cos(b), cr * Math.sin(b), -sr]);
}
for (const v of verts) addSite(v, true);
// 20 face centers (hexagon centers): normalized centroids of every
// mutually-adjacent vertex triple (adjacent pairs have dot = 1/sqrt(5)).
const adj = (a, b) => a[0] * b[0] + a[1] * b[1] + a[2] * b[2] > 0.3;
for (let i = 0; i < 12; i++) {
for (let j = i + 1; j < 12; j++) {
if (!adj(verts[i], verts[j])) continue;
for (let k = j + 1; k < 12; k++) {
if (adj(verts[i], verts[k]) && adj(verts[j], verts[k])) {
addSite([
verts[i][0] + verts[j][0] + verts[k][0],
verts[i][1] + verts[j][1] + verts[k][1],
verts[i][2] + verts[j][2] + verts[k][2],
], false);
}
}
}
}
// Seam half-width and anti-alias band, in radians of arc.
const SEAM = (1.6 * Math.PI) / 180;
const AA = (0.35 * Math.PI) / 180;
// Groove reach for the bump map: a touch wider than the painted seam so
// the recess shoulders catch light on both sides of the line.
const GROOVE = SEAM * 1.5;
const BG = [233, 231, 224], INK = [23, 23, 29], STITCH = [200, 197, 188];
const img = ctx.createImageData(W, H);
const px = img.data;
// Height map sharing the same panel construction: seams become recessed
// grooves, plus a very fine leather/PVC grain over the whole surface.
const bc = document.createElement("canvas");
bc.width = W;
bc.height = H;
const bctx = bc.getContext("2d");
const bimg = bctx.createImageData(W, H);
const bpx = bimg.data;
for (let row = 0; row < H; row++) {
const lat = Math.PI / 2 - ((row + 0.5) / H) * Math.PI;
const cl = Math.cos(lat), sl = Math.sin(lat);
for (let col = 0; col < W; col++) {
const lon = ((col + 0.5) / W) * 2 * Math.PI - Math.PI;
const dx = cl * Math.cos(lon), dy = cl * Math.sin(lon), dz = sl;
let best = -2, second = -2, bestPent = false;
for (const s of sites) {
const d = dx * s.x + dy * s.y + dz * s.z;
if (d > best) { second = best; best = d; bestPent = s.pent; }
else if (d > second) second = d;
}
// Signed distance to the Voronoi cell boundary along the geodesic.
const halfGap = (Math.acos(Math.min(1, second)) - Math.acos(Math.min(1, best))) / 2;
// Black panel: inside a pentagon cell, clear of the seam margin.
const black = bestPent ? Math.min(1, Math.max(0, (halfGap - SEAM) / AA)) : 0;
// Subtle stitch line on every remaining cell boundary so the white
// hexagons read as panels too.
const stitch = Math.min(1, Math.max(0, 1 - halfGap / (SEAM * 0.6))) * (1 - black);
const o = (row * W + col) * 4;
for (let ch = 0; ch < 3; ch++) {
const base = BG[ch] + (STITCH[ch] - BG[ch]) * stitch;
px[o + ch] = base + (INK[ch] - base) * black;
}
px[o + 3] = 255;
// Bump: quadratic groove profile (soft shoulders, no golf-ball
// embossing) + grain noise.
const groove = Math.max(0, 1 - halfGap / GROOVE) ** 2;
const hgt = 205 - groove * 115 + (Math.random() - 0.5) * 14;
const h8 = Math.max(0, Math.min(255, hgt));
bpx[o] = h8; bpx[o + 1] = h8; bpx[o + 2] = h8;
bpx[o + 3] = 255;
}
}
ctx.putImageData(img, 0, 0);
bctx.putImageData(bimg, 0, 0);
const finish = (canvas, srgb) => {
const tex = new THREE.CanvasTexture(canvas);
// The bump map stays linear; only the color map is sRGB.
if (srgb) tex.colorSpace = THREE.SRGBColorSpace;
// Texel footprints get extremely anamorphic near the UV poles; without
// anisotropy the cap edge visibly scallops at close range.
tex.anisotropy = renderer.capabilities.getMaxAnisotropy();
return tex;
};
return { map: finish(c, true), bumpMap: finish(bc, false) };
}
// Same Z-up -> Y-up trick as the duck rig: the group takes the axis fix,
// the mesh inside takes the raw MJCF free-joint pose.
const ballGroup = new THREE.Group();
ballGroup.rotation.x = -Math.PI / 2;
const ballTex = makeSoccerBallTexture();
const ballMesh = new THREE.Mesh(
// 48x32 segments: the coarser default makes the UV interpolation near
// the poles visibly scallop the round cap edge of the texture.
new THREE.SphereGeometry(BALL_RADIUS, 48, 32),
// Physical material for the waxed vintage-leather look: matte-ish base
// with a whisper of clearcoat so highlights ride the seam grooves.
new THREE.MeshPhysicalMaterial({
map: ballTex.map,
bumpMap: ballTex.bumpMap,
bumpScale: 0.0012,
metalness: 0,
roughness: 0.55,
clearcoat: 0.2,
clearcoatRoughness: 0.35,
}),
);
ballMesh.userData.meshName = "ball";
ballMesh.visible = false;
ballGroup.add(ballMesh);
scene.add(ballGroup);
const { createBallActor } = await import(signed(`./ball-actor.js?v=${SELF_V}`));
ball = createBallActor({
THREE, scene, camera, renderer, fxModule: fx, mesh: ballMesh, group: ballGroup,
});
// Comic sticker popups on game events (kick / quack / roll / ball spawn /
// ghost join). Fully self-contained DOM overlay: delete this import to
// remove the feature (the `stickers?.pop` hooks then no-op).
// Stickers disabled for now - uncomment to re-enable.
// stickers = (await import(signed(`./stickers.js?v=${SELF_V}`)))
// .initStickers({ signed, isLocked: () => inputLocked });
const controls = new OrbitControls(camera, renderer.domElement);
controls.target.set(SPAWN_X, 0, -SPAWN_Y); // orbit around the spawn cell
controls.enableDamping = true;
controls.dampingFactor = 0.08;
controls.minDistance = 0.25;
controls.maxDistance = 3;
controls.maxPolarAngle = Math.PI / 2 - 0.03;
// Chase cam (default ON): each frame the camera eases toward a point
// behind the duck's heading at the current orbit distance, while the
// orbit target keeps easing to the trunk in syncRig. Implemented by
// overwriting camera.position AFTER controls.update() so we never fight
// OrbitControls' own spherical bookkeeping; the wheel still zooms in
// chase mode because the behind-point distance is re-read from the live
// camera-target distance every frame.
// Detach: any pointer grab on the canvas (drag start) drops back to the
// free orbit-follow. We listen on pointerdown rather than the controls'
// "start" event because in this three.js version the wheel dispatches
// "start" too, and scroll-to-zoom must not detach the chase.
let chaseCam = true;
const CHASE_PITCH = 0.42; // rad above horizontal, keeps the floor in view
const CHASE_EASE = 0.05; // exponential ease, cinematic on turns
const _chasePos = new THREE.Vector3();
const _chaseDir = new THREE.Vector3();
// During one-shot rolls and kicks the trunk tumbles, so the yaw read from
// its quaternion spins wildly and would whip the camera around. Lazily
// latch the last healthy yaw while walking/sitting and hold it for the
// whole one-shot; the position/target easing keeps following the trunk.
// No timers needed: both one-shots deterministically hand back to walk,
// and the camera position lerp absorbs the small heading correction when
// live tracking resumes (rolls end facing roughly the same way).
let chaseHeldYaw = 0;
// Heading hysteresis (Schmitt trigger): the walking gait wiggles the trunk
// yaw substantially every step (measured ~Β±14 deg at full speed) and
// tracking it 1:1 makes the camera sway left-right constantly. Two layers:
// 1. chaseYawSmooth: slow EMA of the trunk yaw - the gait's oscillation
// is symmetric so this is the duck's MEAN heading, ~steady while
// walking straight, moving cleanly during a real sustained turn.
// 2. chaseYawFollow: what the camera frames. Frozen until the smooth
// heading deviates beyond ENGAGE, then eases toward it and freezes
// again below RELEASE (classic Schmitt).
// An intentional turn (non-zero wz command from keys or pad) bypasses the
// deadband immediately and both layers track fast, so full-speed turns
// stay responsive.
let chaseYawSmooth = 0; // EMA of trunk yaw (spawn yaw = 0)
let chaseYawFollow = 0; // heading the camera actually frames
let chaseYawTracking = false;
const CHASE_YAW_SMOOTH_EASE = 0.04; // per-frame EMA, ~0.4 s time constant
const CHASE_YAW_ENGAGE = 0.17; // rad, ~10 deg: start re-tracking
const CHASE_YAW_RELEASE = 0.03; // rad, ~1.7 deg: stop once realigned
const CHASE_YAW_EASE = 0.10; // per-frame ease during hysteresis catch-up
// During a commanded turn the heading chain must not stack lag on top of
// the camera position lerp (rollers turn at ~70 deg/s): near-snap ease,
// the position lerp alone provides the smoothing, like pre-hysteresis.
const CHASE_YAW_EASE_TURN = 0.5;
const wrapPi = (a) => Math.atan2(Math.sin(a), Math.cos(a));
function updateChaseCam() {
// Reset glide: one clean tween from wherever the camera is back to the
// home framing (see startCameraReset). Runs instead of the chase logic
// and hands control back to it on landing - the destination IS the
// chase cam's ideal point, so the handoff is seamless.
if (camResetT0 !== null) {
// Any detach (drag, right stick, C/R3 toggle) cancels the glide and
// gives the camera straight back to the user.
if (!chaseCam) { camResetT0 = null; return; }
const t = (performance.now() - camResetT0) / 1000 / CAM_RESET_S;
const e = t >= 1 ? 1 : t < 0.5 ? 4 * t * t * t : 1 - Math.pow(-2 * t + 2, 3) / 2;
camera.position.lerpVectors(_camFrom, _camTo, e);
controls.target.lerpVectors(_tgtFrom, _tgtTo, e);
camera.lookAt(controls.target);
if (t >= 1) camResetT0 = null;
return;
}
if (!chaseCam) return;
const qpos = data.qpos;
let rawYaw;
if (mode === "roll" || isKick()) {
rawYaw = chaseHeldYaw;
} else {
rawYaw = Math.atan2(
2 * (qpos[3] * qpos[6] + qpos[4] * qpos[5]),
1 - 2 * (qpos[5] * qpos[5] + qpos[6] * qpos[6]),
);
chaseHeldYaw = rawYaw;
}
// Schmitt heading follow (see constants above). "turning" reads the raw
// per-source wz commands (not the locked/merged view) so an intentional
// turn engages on the first frame.
const turning = controller.sources.some((s) => Math.abs(s.command[2]) > 0.05);
chaseYawSmooth = wrapPi(
chaseYawSmooth +
wrapPi(rawYaw - chaseYawSmooth) * (turning ? CHASE_YAW_EASE_TURN : CHASE_YAW_SMOOTH_EASE),
);
const yawErr = wrapPi(chaseYawSmooth - chaseYawFollow);
if (turning || Math.abs(yawErr) > CHASE_YAW_ENGAGE) chaseYawTracking = true;
if (chaseYawTracking) {
chaseYawFollow = wrapPi(
chaseYawFollow + yawErr * (turning ? CHASE_YAW_EASE_TURN : CHASE_YAW_EASE),
);
if (!turning && Math.abs(yawErr) < CHASE_YAW_RELEASE) chaseYawTracking = false;
}
const yaw = chaseYawFollow;
const dist = camera.position.distanceTo(controls.target);
const horiz = dist * Math.cos(CHASE_PITCH);
const vert = dist * Math.sin(CHASE_PITCH);
// Duck forward in MJCF is (cos yaw, sin yaw, 0); Z-up -> Y-up maps it
// to three-space (cos yaw, 0, -sin yaw). Behind = minus that.
_chasePos.set(
controls.target.x - Math.cos(yaw) * horiz,
controls.target.y + vert,
controls.target.z + Math.sin(yaw) * horiz,
);
camera.position.lerp(_chasePos, CHASE_EASE);
// Re-project onto the orbit sphere: lerping between two points at the
// same radius cuts the chord, which would slowly zoom the camera in
// during large swings (e.g. re-attaching after the duck turned around).
_chaseDir.copy(camera.position).sub(controls.target);
const len = _chaseDir.length();
if (len > 1e-6) camera.position.copy(controls.target).addScaledVector(_chaseDir, dist / len);
camera.lookAt(controls.target);
}
renderer.domElement.addEventListener("pointerdown", () => { chaseCam = false; });
// ── Camera reset (owned by the respawn ceremony) ────────────────────────
// Glides back to the page-load framing: the chase cam's ideal point
// behind the duck's spawn heading, at the boot orbit distance. The
// distance is captured here, before any user input can zoom; the rest
// of the destination is computed live at reset time from the freshly
// reset qpos, so it lands correctly in both legs and roller modes.
const CAM_HOME_DIST = camera.position.distanceTo(controls.target);
let camResetT0 = null; // wall-clock start while the glide is playing
const _camFrom = new THREE.Vector3(), _camTo = new THREE.Vector3();
const _tgtFrom = new THREE.Vector3(), _tgtTo = new THREE.Vector3();
function startCameraReset() {
const qpos = data.qpos;
const yaw = Math.atan2(
2 * (qpos[3] * qpos[6] + qpos[4] * qpos[5]),
1 - 2 * (qpos[5] * qpos[5] + qpos[6] * qpos[6]),
);
chaseHeldYaw = yaw; // keep the post-glide chase heading coherent
chaseYawSmooth = yaw; // hysteresis state lands with the glide
chaseYawFollow = yaw;
chaseYawTracking = false;
_tgtTo.set(qpos[0], qpos[2], -qpos[1]); // trunk at spawn, MJCF -> three
const horiz = CAM_HOME_DIST * Math.cos(CHASE_PITCH);
const vert = CAM_HOME_DIST * Math.sin(CHASE_PITCH);
_camTo.set(
_tgtTo.x - Math.cos(yaw) * horiz,
_tgtTo.y + vert,
_tgtTo.z + Math.sin(yaw) * horiz,
);
_camFrom.copy(camera.position);
_tgtFrom.copy(controls.target);
camResetT0 = performance.now();
chaseCam = true; // reset always re-attaches the chase cam
}
// Pause: while the menu is up over a live game, keys belong to the menu.
const setInputLock = (v) => { inputLocked = v; controller.setLocked(v); };
document.addEventListener("microduck:menu-open", () => {
if (ceremony.entranceDone) setInputLock(true);
});
document.addEventListener("microduck:menu-close", () => {
if (ceremony.entranceDone && !ceremony.respawnActive) setInputLock(false);
});
function resize() {
const w = mount.clientWidth, h = mount.clientHeight;
camera.aspect = w / h;
camera.updateProjectionMatrix();
renderer.setSize(w, h);
}
new ResizeObserver(resize).observe(mount);
resize();
// The rig's root already applies the MJCF Z-up -> three Y-up fix, so the
// trunk group can take the freejoint pose in raw MJCF coordinates.
const _target = new THREE.Vector3();
const _follow = new THREE.Vector3();
function syncRig() {
const qpos = data.qpos;
trunkGroup.position.set(qpos[0], qpos[1], qpos[2]);
trunkGroup.quaternion.set(qpos[4], qpos[5], qpos[6], qpos[3]);
for (let j = 0; j < NUM_JOINTS; j++) setJoint(rig, JOINT_NAMES[j], qpos[qposAdr[j]]);
// Passive hinges (roller wheels): purely visual, driven straight from qpos.
for (const ej of extraJoints) setJoint(rig, ej.name, qpos[ej.adr]);
// Ball: live follows qpos; ghost freeze is owned by the ball actor
// (physics already parked, mesh holds last pose during reverse scan).
if (ball) ball.sync(qpos, ballQposAdr, ballActive);
// Follow cam: ease the orbit target toward the trunk and translate the
// camera by the same delta, so the camera-to-duck distance and viewing
// angle stay constant while the duck walks. Mouse orbit/zoom still work:
// they only change the (preserved) camera-target offset. Paused while
// the reset glide owns the camera (it tweens the target itself).
if (camResetT0 === null) {
_target.set(qpos[0], qpos[2], -qpos[1]);
_follow.copy(_target).sub(controls.target);
// Horizontal follow at the usual rate; vertical much slower so the
// per-step gait bob doesn't nod the frame (sit/crouch height changes
// still settle in, just over ~1 s instead of instantly).
_follow.x *= 0.06;
_follow.z *= 0.06;
_follow.y *= 0.015;
controls.target.add(_follow);
camera.position.add(_follow);
}
// Keep the grid plane (and its fade center) under the action; the wall
// grids share the same radial fade focus.
grid.position.set(controls.target.x, 0, controls.target.z);
grid.material.uniforms.uFocus.value.copy(controls.target);
for (const m of wallMats) m.uniforms.uFocus.value.copy(controls.target);
}
// Quack: jaw + chirp on the gamepad right trigger only. The jaw isn't a
// MuJoCo joint (duck.js re-creates the hinge in JS), so this is purely
// cosmetic and can't upset the policy. A held gamepad trigger drives the
// jaw analogically on top (same as the robot's mouth trigger).
const QUACK_MS = 480;
let quackAt = -Infinity;
let padJaw = 0;
// Voice banks from the robot runtime: each printed duck ships a different
// voice, so each colourway gets its own bank and every quack draws a
// random chirp take from it - same as the real ducks all sounding
// slightly different. Audio elements are created lazily and cached.
// Browsers block audio until the first user gesture; the rejected play()
// is swallowed and sound simply starts working after the first click/key.
const CHIRP_TAKES = "abcdefghijkl";
const VOICE_BANK = { classic: "duck1", charcoal: "duck2", purple: "duck3", blue: "duck4" };
const chirpCache = new Map();
function playChirp() {
const bank = VOICE_BANK[currentVariant] ?? "duck1";
const take = CHIRP_TAKES[(Math.random() * CHIRP_TAKES.length) | 0];
const url = signed(`./assets/voices/${bank}/chirp_${take}.wav`);
let a = chirpCache.get(url);
if (!a) {
a = new Audio(url);
a.volume = 0.7;
chirpCache.set(url, a);
}
a.currentTime = 0;
a.play().catch(() => {});
}
// The jaw is driven ONLY by the dedicated gamepad triggers: the analog
// RT/LT value (padJaw) plus the flap that accompanies the RT-edge quack
// sound below. Mode changes, rolls, kicks and colour swaps no longer
// move the mouth (the old silent flap was removed by user request).
const quackLoud = () => {
quackAt = performance.now();
playChirp();
stickers?.pop("quack");
};
function jawOpenNow() {
const t = (performance.now() - quackAt) / QUACK_MS;
const flap = t >= 0 && t < 1 ? Math.sin(Math.PI * t) : 0;
return Math.max(flap, padJaw);
}
function syncJaw() {
setJawOpen(rig, jawOpenNow());
}
// HUD elements for the mini command sticks + hint highlighting.
const el = (id) => document.getElementById(id);
const dotMove = el("dot-move"), dotTurn = el("dot-turn");
const boxMove = dotMove.parentElement, boxTurn = dotTurn.parentElement;
const keyEls = {
fwd: el("key-fwd"), back: el("key-back"),
turnl: el("key-turnl"), turnr: el("key-turnr"),
roll: el("key-roll"), reset: el("key-reset"),
kickl: el("key-kickl"), kickr: el("key-kickr"),
ball: el("key-ball"), cam: el("key-cam"),
loco: el("key-loco"),
padX: el("key-pad-x"), padSit: el("key-pad-sit"),
padRun: el("key-pad-run"), padRt: el("key-pad-rt"),
padRb: el("key-pad-rb"), padLb: el("key-pad-lb"),
padY: el("key-pad-y"), padRs: el("key-pad-rs"),
padR3: el("key-pad-r3"),
};
osdLoadEl = el("osd-load");
// Roller mode re-labels the trick hints (R / pad X trigger the crouch-glide
// instead of the roll) and greys the legs-only actions via body.rollers.
const rollLabelEl = el("key-roll-label");
const padXLabelEl = el("key-pad-x-label");
function syncLocoHints() {
const rollers = loco === "rollers";
if (rollLabelEl) rollLabelEl.textContent = rollers ? "crouch" : "roll";
if (padXLabelEl) padXLabelEl.textContent = rollers ? "crouch" : "roll";
}
const STICK_R = 15; // px, max dot travel inside the 46px stick circle
let ballFlashAt = -Infinity;
let padYFlashAt = -Infinity;
// Bottom-right OSD extras: render rate (EMA over frame deltas), live ground
// speed off the freejoint, and an odometer integrating horizontal trunk
// travel. Teleport-sized jumps (resets, loco swaps) don't count as travel.
let fpsEma = 60;
let fpsLastT = performance.now();
let odoM = 0;
let odoX = null, odoY = null;
function renderStats() {
const [vx, , wz] = effectiveCmd();
// The active policy lives in the big center label and the twist in the
// mini sticks; up here only the bare telemetry remains.
const peers = ghosts?.peerCount() ?? 0;
// VHS counter: sim time as mm:ss:ff where ff counts control frames (50Hz).
const t = data.time;
const p2 = (n) => String(n).padStart(2, "0");
osdTimeEl.textContent =
`\u25b6 ${p2(Math.floor(t / 60))}:${p2(Math.floor(t) % 60)}:${p2(Math.floor((t % 1) * 50))}`;
const now = performance.now();
const dtF = (now - fpsLastT) / 1000;
fpsLastT = now;
if (dtF > 0 && dtF < 0.5) fpsEma += (1 / dtF - fpsEma) * 0.05;
const stepD = (odoX === null) ? 0 : Math.hypot(data.qpos[0] - odoX, data.qpos[1] - odoY);
if (stepD < 0.05) odoM += stepD; // plausible per-frame travel only
odoX = data.qpos[0];
odoY = data.qpos[1];
const spd = Math.hypot(data.qvel[0], data.qvel[1]);
// Bottom-right telemetry stack, quietest line last: peers (only when
// someone else is around), then speed + odometer, then the loop rates.
const odo = odoM < 1000 ? `${odoM.toFixed(1)}M` : `${(odoM / 1000).toFixed(2)}KM`;
const lines = [];
if (peers) lines.push(`${peers + 1} ONLINE`);
lines.push(`${spd.toFixed(2)}M/S \u00b7 ODO ${odo}`);
lines.push(`FPS ${Math.round(fpsEma)} \u00b7 CTRL ${ctrlHz.toFixed(0)}HZ`);
ctrlHzEl.textContent = lines.join("\n");
// Mini sticks: the dot mirrors the effective twist, lit yellow while the
// user is actually driving. Normalized against the ACTIVE variant's
// velocity limits so full deflection reads the same in both.
const manual = controller.anyActive() && mode !== "roll";
const [limF, limB, limA] = velLims();
const yN = vx >= 0 ? vx / limF : vx / -limB;
// Move stick is vertical-only now that strafe is gone.
dotMove.style.transform = `translate(0px, ${-yN * STICK_R}px)`;
dotTurn.style.transform = `translate(${(-wz / limA) * STICK_R}px, 0px)`;
boxMove.classList.toggle("live", manual && Math.abs(vx) > 0.01);
boxTurn.classList.toggle("live", manual && Math.abs(wz) > 0.01);
// Keycap highlighting: each individual key lights only while its own
// action is active, and only for its own input device (pressed-state
// snapshots come straight from the controller's sources).
const sitting = mode === "sitstand" && sitFlag === 1;
for (const k of ["fwd", "back", "turnl", "turnr"]) {
keyEls[k].classList.toggle("lit", kbSource.pressed[k]);
}
const trick = mode === "roll" || mode === "crouch"; // same R / pad-X slot
keyEls.roll.classList.toggle("lit", trick && rollSource === "kb");
keyEls.kickl.classList.toggle("lit", mode === "kickL" && kickSource === "kb");
keyEls.kickr.classList.toggle("lit", mode === "kickR" && kickSource === "kb");
keyEls.reset.classList.toggle("lit", performance.now() - resetFlashAt < 400);
keyEls.ball.classList.toggle("lit", performance.now() - ballFlashAt < 400);
keyEls.cam.classList.toggle("lit", chaseCam); // steady while chasing
keyEls.loco?.classList.toggle("lit", loco === "rollers" || locoSwitching);
keyEls.padX.classList.toggle("lit", trick && rollSource === "pad");
keyEls.padY.classList.toggle("lit", performance.now() - padYFlashAt < 400);
keyEls.padRb.classList.toggle("lit", mode === "kickR" && kickSource === "pad");
keyEls.padLb.classList.toggle("lit", mode === "kickL" && kickSource === "pad");
keyEls.padSit.classList.toggle("lit", sitting);
keyEls.padRun.classList.toggle("lit", padSource.pressed.dpadUp);
keyEls.padRt.classList.toggle("lit", padJaw > 0.3);
keyEls.padRs.classList.toggle("lit", padOrbitLive); // while deflected
keyEls.padR3.classList.toggle("lit", chaseCam); // steady while chasing
drawMinimap();
}
// ── Minimap (90s radar, top-right OSD) ──────────────────────────────────
// Fixed top-down view of the 3x3 m arena. World +X (the spawn facing)
// points UP on the map, so the spawn cell sits bottom-middle; world +Y
// points LEFT (right-handed seen from above). Duck = oriented chevron,
// ball = white dot while spawned, revealed ghosts = faint dots.
const minimapEl = document.getElementById("minimap");
const minimapCtx = minimapEl?.getContext("2d");
const MINIMAP_HZ = 20;
const MINIMAP_ORANGE = "#ff7a2f";
let minimapLastDraw = 0;
// Chevron heading: the minimap keeps its OWN yaw EMA, independent of the
// camera's hysteresis state (chaseHeldYaw only updates while the chase
// cam is attached, and the followed yaw freezes on purpose). The EMA
// averages out the Β±14 deg per-step gait wobble while tracking real
// turns continuously; during roll/kick the trunk quaternion tumbles, so
// the last sane value is held (same protection as the camera).
let minimapYaw = 0; // spawn yaw = 0
const MINIMAP_YAW_EASE = 0.15; // per 20 Hz tick, ~0.3 s to settle on a turn
function drawMinimap() {
if (!minimapCtx) return;
const now = performance.now();
if (now - minimapLastDraw < 1000 / MINIMAP_HZ) return;
minimapLastDraw = now;
const ctx = minimapCtx;
const S = minimapEl.width; // square, drawn in device px
// World (MJCF, Z-up) -> map px: u right = -Y, v down = -X.
const u = (y) => (0.5 - y / (2 * ARENA_HALF)) * S;
const v = (x) => (0.5 - x / (2 * ARENA_HALF)) * S;
ctx.clearRect(0, 0, S, S);
// Section grid (0.6 m pitch): inner lines only, the CSS border frames it.
ctx.strokeStyle = "rgba(255, 122, 47, 0.18)";
ctx.lineWidth = 1;
ctx.beginPath();
for (let i = 1; i < 5; i++) {
const c = (i / 5) * S;
ctx.moveTo(c, 0); ctx.lineTo(c, S);
ctx.moveTo(0, c); ctx.lineTo(S, c);
}
ctx.stroke();
const qpos = data.qpos;
// Ghosts: revealed peers only, faint white dots.
ctx.fillStyle = "rgba(255, 255, 255, 0.4)";
for (const g of ghosts?.mapDots() ?? []) {
ctx.beginPath();
ctx.arc(u(g.y), v(g.x), S * 0.015, 0, Math.PI * 2);
ctx.fill();
}
// Ball: only while spawned (parked = 50 m away = hidden).
if (ballActive) {
const bx = qpos[ballQposAdr], by = qpos[ballQposAdr + 1];
if (Math.abs(bx) <= ARENA_HALF && Math.abs(by) <= ARENA_HALF) {
ctx.fillStyle = "rgba(255, 255, 255, 0.92)";
ctx.beginPath();
ctx.arc(u(by), v(bx), S * 0.019, 0, Math.PI * 2);
ctx.fill();
}
}
// Duck: chevron at the trunk position, nose along the smoothed live yaw
// (frozen during roll/kick where the trunk tumbles).
if (mode !== "roll" && !isKick()) {
const rawYaw = Math.atan2(
2 * (qpos[3] * qpos[6] + qpos[4] * qpos[5]),
1 - 2 * (qpos[5] * qpos[5] + qpos[6] * qpos[6]),
);
minimapYaw = wrapPi(minimapYaw + wrapPi(rawYaw - minimapYaw) * MINIMAP_YAW_EASE);
}
ctx.save();
ctx.translate(u(qpos[1]), v(qpos[0]));
// Forward (cos yaw, sin yaw) in world -> (-sin yaw, -cos yaw) on the
// map, i.e. a canvas rotation of -yaw applied to an up-pointing shape.
ctx.rotate(-minimapYaw);
const r = S * 0.032;
ctx.fillStyle = MINIMAP_ORANGE;
ctx.beginPath();
ctx.moveTo(0, -r * 1.35); // nose
ctx.lineTo(r * 0.85, r * 0.95);
ctx.lineTo(0, r * 0.45); // notched tail = chevron
ctx.lineTo(-r * 0.85, r * 0.95);
ctx.closePath();
ctx.fill();
ctx.restore();
}
// ── Right-stick camera orbit (inertia downstream of the controller) ─────
// The gamepad source reports the raw deflection (controller axes.orbitX/Y);
// this step turns it into OrbitControls-compatible motion: rebuild the
// camera-target offset as a spherical, nudge azimuth/elevation, and
// re-place the camera. controls.update() then runs on the result, so the
// damping bookkeeping never fights it (same trick as updateChaseCam).
// The stick does not move the camera directly: it steers an angular
// VELOCITY that eases toward the stick's target rate (frame-rate
// independent exponential), so pushing ramps up gently and releasing
// coasts to a stop over ~0.3 s instead of freezing on the spot.
// Vertical is flight-style inverted by request: stick up orbits the
// camera downward, stick down orbits it upward.
const PAD_ORBIT_SPEED = 2.4; // rad/s at full deflection
const PAD_ORBIT_SMOOTH = 8; // 1/s response rate (~95% in 0.37 s)
const padOrbitVel = { az: 0, el: 0 }; // smoothed angular velocity, rad/s
const _padSph = new THREE.Spherical();
const _padOff = new THREE.Vector3();
function padOrbitStep(rx, ry, dt) {
padOrbitLive = rx !== 0 || ry !== 0;
if (padOrbitLive) chaseCam = false; // detach, same as a mouse grab
const k = 1 - Math.exp(-PAD_ORBIT_SMOOTH * dt);
padOrbitVel.az += (rx * PAD_ORBIT_SPEED - padOrbitVel.az) * k;
// Inverted Y; elevation runs a touch slower, full-rate pitch is twitchy.
padOrbitVel.el += (-ry * PAD_ORBIT_SPEED * 0.75 - padOrbitVel.el) * k;
if (chaseCam) { padOrbitVel.az = 0; padOrbitVel.el = 0; return; }
if (Math.abs(padOrbitVel.az) < 1e-3 && Math.abs(padOrbitVel.el) < 1e-3) return;
_padOff.copy(camera.position).sub(controls.target);
_padSph.setFromVector3(_padOff);
// Stick right sweeps the camera right around the duck.
_padSph.theta -= padOrbitVel.az * dt;
_padSph.phi += padOrbitVel.el * dt;
_padSph.phi = Math.min(controls.maxPolarAngle, Math.max(0.08, _padSph.phi));
_padSph.makeSafe();
camera.position.setFromSpherical(_padSph).add(controls.target);
camera.lookAt(controls.target);
}
// Multiplayer ghosts, initialised asynchronously at the end of the module.
let ghosts = null;
let inputPollT = performance.now();
function loop() {
requestAnimationFrame(loop);
// dt clamped so a background-tab stall can't slingshot the camera orbit.
const inputNow = performance.now();
const dt = Math.min((inputNow - inputPollT) / 1000, 0.05);
inputPollT = inputNow;
controller.update(dt);
padJaw = controller.getAxes().jaw;
document.body.classList.toggle("pad-connected", padSource.connected);
// Camera orbit runs every frame while a pad is present (the coasting
// needs the zero-deflection frames too); without a pad, park the state.
if (padSource.connected) {
padOrbitStep(controller.getAxes().orbitX, controller.getAxes().orbitY, dt);
} else {
padOrbitLive = false;
padOrbitVel.az = 0;
padOrbitVel.el = 0;
}
syncRig();
syncJaw();
ghosts?.update();
controls.update();
updateChaseCam();
ceremony.drive();
ball.drive(() => spawnBall({ fromQueue: true }));
renderStats();
renderer.render(scene, camera);
}
// Boot complete: the sim/HUD go live immediately. The BIOS readout (if
// the user already waddled in, or when they do) sees bootDone and closes
// with READY. + fade on its own.
bootDone = true;
hudEl.hidden = false;
loop();
// ── Input wiring: arm the controller sources, bind actions to triggers ──
// Key/button mappings live in controls/keyboard.js and controls/gamepad.js;
// this section is pure game-side wiring. Arming the listeners HERE (not at
// source construction) keeps the boot behavior identical to when the raw
// keydown/gamepad handlers attached at this point in the module.
controller.init();
// Keyboard F alternates kicking feet; only advance the alternation on
// kicks that actually launched (triggerKick reports that).
let kbKickFoot = "left";
// Action meta.source ("keyboard"/"gamepad") -> the trigger functions'
// historical source tags, which the HUD keycap lighting keys off.
const srcTag = (source) => (source === "gamepad" ? "pad" : "kb");
controller.on("reset", () => resetSim());
controller.on("spawnBall", ({ source }) => {
spawnBall();
if (source === "gamepad") padYFlashAt = performance.now();
else ballFlashAt = performance.now();
});
controller.on("chaseToggle", () => { chaseCam = !chaseCam; });
controller.on("locoToggle", () => toggleLoco());
controller.on("roll", ({ source }) => triggerRoll(srcTag(source)));
controller.on("kickL", ({ source }) => triggerKick("left", srcTag(source)));
controller.on("kickR", ({ source }) => triggerKick("right", srcTag(source)));
controller.on("alternateKick", ({ source }) => {
if (triggerKick(kbKickFoot, srcTag(source))) {
kbKickFoot = kbKickFoot === "left" ? "right" : "left";
}
});
controller.on("sitToggle", () => {
if (loco !== "legs") return; // sitting is a legs-only skill
const sitting = mode === "sitstand" && sitFlag === 1;
setMode(sitting ? "walk" : "sit");
});
// Pad DpadUp short press: straight back to running (ignored mid-roll /
// mid-crouch: those hand back to walk on their own, and switching on a
// tipped duck would floor it).
controller.on("walk", () => {
if (mode !== "walk" && mode !== "roll" && mode !== "crouch") setMode("walk");
});
controller.on("quack", () => quackLoud());
// Read-only state label (bottom-left): reflects the active policy,
// switching happens via keyboard/gamepad only.
const modeLabel = document.getElementById("mode-label");
function setMode(next, { force = false } = {}) {
if (!force && inputLocked) return;
// No policy switching mid-roll or mid-kick: both end on their own and
// return to walk - switching now would floor the duck. Sitting is a
// legs-only skill (the roller stance has no sitstand policy).
if ((mode === "roll" && rollRun) || (isKick() && kickRun) || (mode === "crouch" && crouchRun)) return;
if (next === "sit" && loco === "rollers") return;
clearModeTimers();
rollRun = null;
crouchRun = null;
if (next !== "sit") {
// Leaving a sit: let the sitstand policy stand the duck back up first.
if (mode === "sitstand" && sitFlag === 1) {
sitFlag = 0;
standTimer = setTimeout(() => {
standTimer = null;
mode = next;
lastAction.fill(0);
syncButtons();
}, 2000);
syncButtons();
return;
}
mode = next;
lastAction.fill(0);
} else {
// Hand over gently: hold the stand under the sitstand policy for a
// moment before commanding the sit, or the abrupt session switch
// (walking's action history + instant flag) knocks the duck over.
mode = "sitstand";
sitFlag = 0;
lastAction.fill(0);
sitTimer = setTimeout(() => {
sitTimer = null;
if (mode === "sitstand") { sitFlag = 1; syncButtons(); }
}, 800);
}
syncButtons();
}
// One roll, then straight back to running (Space key or the button).
// lastAction is deliberately NOT zeroed here: the robot runtime keeps one
// continuous action history across policy switches, and the roll initiates
// more reliably mid-gait with the true last actions in the obs.
function triggerRoll(source = "kb") {
// Same trigger slot in the roller variant fires its own trick.
if (loco === "rollers") return triggerCrouch(source);
// Rolls only launch from a standing walk: from a sit (or mid sit/stand
// hand-over) the roll policy just faceplants the duck.
if (inputLocked || mode !== "walk" || standTimer) return;
clearModeTimers();
rollSource = source;
mode = "roll";
sitFlag = 0;
rollRun = { steps: 0, tipped: false };
syncButtons();
stickers?.pop("roll");
}
// Roller-only one-shot: crouch, glide low, stand back up (phase-driven,
// see the crouch constants up top). Reuses the roll's trigger + keycap.
function triggerCrouch(source = "kb") {
if (inputLocked || mode !== "walk" || locoSwitching) return;
clearModeTimers();
rollSource = source;
mode = "crouch";
crouchRun = { phase: 0 };
syncButtons();
stickers?.pop("roll"); // same WHEE - the crouch-glide is the roller "roll"
}
// One blind kick (the duck can't see any ball - it's a scripted boot),
// left or right leg. Same launch constraints as the roll. Returns whether
// the kick actually launched so the keyboard's foot alternation only
// advances on real kicks.
function triggerKick(foot, source = "kb") {
// Kicks are legs-only: in roller mode the ball is played by driving.
if (loco === "rollers") return false;
if (inputLocked || mode !== "walk" || standTimer) return false;
clearModeTimers();
kickSource = source;
mode = foot === "left" ? "kickL" : "kickR";
sitFlag = 0;
kickRun = { steps: 0 };
syncButtons();
stickers?.pop("kick");
return true;
}
// Matrix-style letter scramble: on change every glyph flips through random
// charset entries, then locks to its target left-to-right over ~0.45s.
// One interval for the whole run; monospace keeps the width stable.
const SCRAMBLE_GLYPHS = "ABCDEFGHIJKLMNOPQRSTUVWXYZ0123456789#$%&*<>/=+";
let scrambleTimer = null;
function setModeLabel(text) {
// Guard on the intended target, not the displayed text (which is mid-
// scramble noise while the interval runs).
if (modeLabel.dataset.target === text) return;
modeLabel.dataset.target = text;
if (scrambleTimer) { clearInterval(scrambleTimer); scrambleTimer = null; }
const span = modeLabel.firstElementChild;
const target = text.toUpperCase();
const n = target.length;
const DUR = 450; // ms until the last letter locks
const rnd = () => SCRAMBLE_GLYPHS[(Math.random() * SCRAMBLE_GLYPHS.length) | 0];
const t0 = performance.now();
scrambleTimer = setInterval(() => {
// Letters 0..k-1 are locked; the rest keep boiling.
const k = Math.floor(((performance.now() - t0) / DUR) * n);
if (k >= n) {
clearInterval(scrambleTimer);
scrambleTimer = null;
span.textContent = target;
return;
}
let out = target.slice(0, k);
for (let i = k; i < n; i++) out += rnd();
span.textContent = out;
}, 40);
}
function syncButtons() {
const sitting = mode === "sitstand" && sitFlag === 1;
setModeLabel(
mode === "roll" ? "Roll"
: mode === "crouch" ? "Crouch"
: isKick() ? "Kick"
: sitting ? "Sit"
: loco === "rollers" ? "Drive"
: "Run",
);
}
// ── Colour swatches: re-skin the rig live, with a quack ─────────────────
// One representative colour per variant so the dots read at a glance.
// Variants can force theirs with a `swatch` spec (purple does: its head
// is warm gray but its identity is the purple accents).
const SWATCH_SLOT = { classic: "feet", charcoal: "headDome", purple: "feet", blue: "facePlate" };
const swatchesEl = document.getElementById("swatches");
const swatchBtns = new Map();
for (const name of VARIANT_NAMES) {
const v = VARIANTS[name];
const b = document.createElement("button");
b.style.background = specToHex(v.swatch ?? v[SWATCH_SLOT[name] ?? "bodyShell"]);
b.setAttribute("aria-label", `${name} colours`);
b.addEventListener("click", () => {
if (name === currentVariant) return;
currentVariant = name;
applyVariant(rig, name);
syncSwatches();
});
swatchesEl.appendChild(b);
swatchBtns.set(name, b);
}
function syncSwatches() {
for (const [name, b] of swatchBtns) b.classList.toggle("on", name === currentVariant);
}
syncSwatches();
syncButtons();
uiReady = true;
// Deterministic hooks for automated verification (rAF pauses in
// background tabs, and the control loop is async).
window.rl = {
// model/data are getters: activateLoco swaps them wholesale.
get model() { return model; },
get data() { return data; },
mujoco, camera, controls,
get mode() { return mode; },
get sitFlag() { return sitFlag; },
buildObs, cmd,
// velCmd is the keyboard source's live command array: writing to it
// still drives the duck when no other source is active (the controller
// falls back to it), same as before the controls/ refactor.
velCmd: kbSource.command, lastAction, resetSim,
controller, kbSource, padSource,
spawnBall, triggerKick, triggerRoll, sessions, ort,
get loco() { return loco; },
get locoSwitching() { return locoSwitching; },
toggleLoco, setLoco, ensureRollers,
triggerCrouch,
get crouchPhase() { return crouchRun?.phase ?? null; },
get kickSteps() { return KICK_STEPS; },
set kickSteps(v) { KICK_STEPS = v; },
get ballActive() { return ballActive; },
get ballQposAdr() { return ballQposAdr; },
get chaseCam() { return chaseCam; },
set chaseCam(v) { chaseCam = !!v; },
get camResetActive() { return camResetT0 !== null; },
get respawnActive() { return ceremony?.respawnActive ?? false; },
get camPose() {
return {
pos: camera.position.toArray(),
target: controls.target.toArray(),
};
},
get chaseYaw() { return { follow: chaseYawFollow, smooth: chaseYawSmooth, held: chaseHeldYaw, tracking: chaseYawTracking, map: minimapYaw }; },
padOrbitStep,
jawOpenNow,
step: async (n = 1) => { for (let i = 0; i < n; i++) await controlStep(); },
render: () => { syncRig(); renderer.render(scene, camera); },
// One full render-loop iteration, for tests driving frames manually.
frame: () => { syncRig(); syncJaw(); controls.update(); updateChaseCam(); ceremony.drive(); renderStats(); renderer.render(scene, camera); },
get ghosts() { return ghosts; },
get inputLocked() { return inputLocked; },
// Deterministic entrance controls for screenshots/tests: setting values
// detaches the time-based driver; setFx(1) also runs the FX through its
// finish path (materials restored) and releases the input lock (same
// cleanup as the real sequence).
entrance: {
start: () => ceremony.startEntrance(),
setReveal: (floor, wall) => ceremony.setReveal(floor, wall),
setFx: (p) => ceremony.setFx(p),
},
};
// ── Multiplayer ghosts (WebRTC, serverless signaling) ───────────────────
// Broadcast this duck's pose and render up to 3 other visitors live as
// translucent ducks. Fire-and-forget: any failure just means no ghosts.
const r3 = (x) => Math.round(x * 1000) / 1000;
try {
// Ghosts only join once the entrance has fully played: the world (and
// this duck) must stay hidden until then, translucent peers included.
await ceremony.entranceFinished;
const { initGhosts } = await import(signed(`./ghosts.js?v=${SELF_V}`));
ghosts = await initGhosts({
scene, rig, cloneRig, setJoint, setJawOpen, applyVariant,
jointNames: JOINT_NAMES,
// Ghost rig per locomotion flag: peers in roller mode clone the roller
// rig once this tab has built it, and fall back to the leg rig until
// then (their wheels also don't spin - joints aren't broadcast for
// the passive hinges). Known v1 limitation, documented in the README.
getRigFor: (l) => (l && locos.rollers ? locos.rollers.rig : locos.legs.rig),
getLocalState: () => {
const qpos = data.qpos;
const j = new Array(NUM_JOINTS);
for (let i = 0; i < NUM_JOINTS; i++) j[i] = r3(qpos[qposAdr[i]]);
return {
p: [r3(qpos[0]), r3(qpos[1]), r3(qpos[2]), r3(qpos[3]), r3(qpos[4]), r3(qpos[5]), r3(qpos[6])],
j,
w: r3(jawOpenNow()),
v: currentVariant,
l: loco === "rollers" ? 1 : 0,
};
},
});
// "HI!" sticker when another visitor joins (setter-style registration,
// same trystero build quirk as onPeerLeave inside ghosts.js).
if (ghosts.room) ghosts.room.onPeerJoin = () => stickers?.pop("hi");
} catch (e) {
window.__ghostErr = String((e && e.stack) || e);
console.warn("ghosts disabled:", e);
}