feat: replace jukebox with a row of 3 arcade cabinets
Browse filesNew arcade.glb (from the user's FBX, texture downscaled to 1024, plus a
500-tri wire stand-in for the hologram pass). Three clones line up
against the back wall facing the arena, staggered in the ceremony, with
one static collision box covering the row.
Co-authored-by: Cursor <cursoragent@cursor.com>
- README.md +2 -2
- app/public/assets/props/{jukebox.glb β arcade.glb} +2 -2
- app/src/game/constants.js +10 -8
- app/src/game/game.js +53 -46
README.md
CHANGED
|
@@ -102,8 +102,8 @@ UI intents in).
|
|
| 102 |
- `app/src/game/game.js` fetches the MJCF (`robot_allcollisions.xml`, or
|
| 103 |
`robot_allcollisions_rollers.xml` for the roller variant), strips the
|
| 104 |
visual geoms, injects a floor, arena walls, a ball, a collision box for
|
| 105 |
-
the
|
| 106 |
-
`@mujoco/mujoco` WASM bindings.
|
| 107 |
- Both variants share the exact same policy interface: 61D observation
|
| 108 |
(gyro, projected gravity, 14 joint pos/vel, last action, 13D command)
|
| 109 |
and 14 position-targets, matching `mjlab_microduck/scripts/infer_policy.py`.
|
|
|
|
| 102 |
- `app/src/game/game.js` fetches the MJCF (`robot_allcollisions.xml`, or
|
| 103 |
`robot_allcollisions_rollers.xml` for the roller variant), strips the
|
| 104 |
visual geoms, injects a floor, arena walls, a ball, a collision box for
|
| 105 |
+
the arcade cabinet row and a STAND keyframe, and compiles it with the
|
| 106 |
+
official `@mujoco/mujoco` WASM bindings.
|
| 107 |
- Both variants share the exact same policy interface: 61D observation
|
| 108 |
(gyro, projected gravity, 14 joint pos/vel, last action, 13D command)
|
| 109 |
and 14 position-targets, matching `mjlab_microduck/scripts/infer_policy.py`.
|
app/public/assets/props/{jukebox.glb β arcade.glb}
RENAMED
|
@@ -1,3 +1,3 @@
|
|
| 1 |
version https://git-lfs.github.com/spec/v1
|
| 2 |
-
oid sha256:
|
| 3 |
-
size
|
|
|
|
| 1 |
version https://git-lfs.github.com/spec/v1
|
| 2 |
+
oid sha256:1343e6a6851f8f09f91d746c2e0ff030fbf2abeacc3d06b41906fe3200a7704e
|
| 3 |
+
size 498180
|
app/src/game/constants.js
CHANGED
|
@@ -65,14 +65,16 @@ export const ARENA_WALL_T = 0.05;
|
|
| 65 |
// column/row of cells exists; the lattice is shifted half a cell in the
|
| 66 |
// shaders so the walls land exactly on section lines).
|
| 67 |
export const GRID_SECTION = (2 * ARENA_HALF) / 5; // 0.6 m
|
| 68 |
-
//
|
| 69 |
-
//
|
| 70 |
-
|
| 71 |
-
|
| 72 |
-
|
| 73 |
-
|
| 74 |
-
|
| 75 |
-
export const
|
|
|
|
|
|
|
| 76 |
|
| 77 |
// Spawn: center of the middle section cell in the SECOND ROW FROM THE
|
| 78 |
// BACK wall. The duck faces +X (identity freejoint quat, walks toward
|
|
|
|
| 65 |
// column/row of cells exists; the lattice is shifted half a cell in the
|
| 66 |
// shaders so the walls land exactly on section lines).
|
| 67 |
export const GRID_SECTION = (2 * ARENA_HALF) / 5; // 0.6 m
|
| 68 |
+
// Arcade cabinet row: three cabinets side by side against the back (-X)
|
| 69 |
+
// wall, screens facing the arena. Shared between the visual (game.js
|
| 70 |
+
// places the GLB clones) and the physics (buildPhysicsXml adds ONE static
|
| 71 |
+
// collision box covering the whole row). Proportions measured from the
|
| 72 |
+
// GLB's natural size (0.524 x 0.587 x 1.0 m, w x d x h).
|
| 73 |
+
export const ARCADE_H = 0.42; // target height, m
|
| 74 |
+
export const ARCADE_W = ARCADE_H * 0.524; // footprint width, ~0.22 m
|
| 75 |
+
export const ARCADE_D = ARCADE_H * 0.5876; // footprint depth, ~0.247 m
|
| 76 |
+
export const ARCADE_GAP = 0.02; // gap between neighbouring cabinets
|
| 77 |
+
export const ARCADE_WALL_GAP = 0.01; // clearance between backs and the wall
|
| 78 |
|
| 79 |
// Spawn: center of the middle section cell in the SECOND ROW FROM THE
|
| 80 |
// BACK wall. The duck faces +X (identity freejoint quat, walks toward
|
app/src/game/game.js
CHANGED
|
@@ -31,7 +31,7 @@ import {
|
|
| 31 |
VEL_FWD, VEL_BACK, VEL_ANG, RVEL_FWD, RVEL_BACK, RVEL_ANG,
|
| 32 |
CROUCH_PERIOD_S, CROUCH_END_PHASE,
|
| 33 |
BALL_RADIUS, BALL_PARK_POS, ARENA_HALF, SPAWN_X, SPAWN_Y,
|
| 34 |
-
|
| 35 |
} from "./constants.js";
|
| 36 |
import {
|
| 37 |
buildRig, cloneRig, loadKinematics, setJoint, setJawOpen, MODEL_DIR, MESH_VERSION,
|
|
@@ -175,18 +175,15 @@ async function boot({ scene, camera, renderer }) {
|
|
| 175 |
el("geom", { name: w.name, type: "box", pos: w.pos, size: w.size }),
|
| 176 |
);
|
| 177 |
}
|
| 178 |
-
//
|
| 179 |
-
//
|
| 180 |
-
//
|
| 181 |
-
|
| 182 |
-
const jOff = ARENA_HALF - JUKEBOX_MARGIN;
|
| 183 |
-
const jw = Math.cos(JUKEBOX_YAW / 2), jz = Math.sin(JUKEBOX_YAW / 2);
|
| 184 |
doc.querySelector("worldbody").appendChild(
|
| 185 |
el("geom", {
|
| 186 |
-
name: "
|
| 187 |
-
pos: `${-
|
| 188 |
-
|
| 189 |
-
size: JUKEBOX_HALF.join(" "),
|
| 190 |
}),
|
| 191 |
);
|
| 192 |
// Kickable ball: a light free sphere (beach-ball feel). MuJoCo has no
|
|
@@ -619,7 +616,7 @@ async function boot({ scene, camera, renderer }) {
|
|
| 619 |
}
|
| 620 |
})();
|
| 621 |
|
| 622 |
-
// ββ Scene wiring (grid, walls, rig, ball,
|
| 623 |
// The grid/walls carry ceremony-driven uReveal uniforms and per-frame
|
| 624 |
// focus updates, so the game owns them; lights and environment live in
|
| 625 |
// the R3F layer.
|
|
@@ -743,44 +740,54 @@ async function boot({ scene, camera, renderer }) {
|
|
| 743 |
THREE, scene, camera, renderer, fxModule: fx, mesh: ballMesh, group: ballGroup,
|
| 744 |
});
|
| 745 |
|
| 746 |
-
// ββ
|
| 747 |
-
// assets/props/
|
| 748 |
-
//
|
| 749 |
-
//
|
| 750 |
-
//
|
| 751 |
-
//
|
| 752 |
-
//
|
| 753 |
-
|
|
|
|
| 754 |
try {
|
| 755 |
const gltf = await new GLTFLoader().loadAsync(
|
| 756 |
-
signed(`./assets/props/
|
| 757 |
);
|
| 758 |
-
const
|
| 759 |
-
const
|
| 760 |
-
|
| 761 |
-
const
|
| 762 |
-
const
|
| 763 |
-
|
| 764 |
-
|
| 765 |
-
|
| 766 |
-
|
| 767 |
-
|
| 768 |
-
|
| 769 |
-
|
| 770 |
-
|
| 771 |
-
|
| 772 |
-
|
| 773 |
-
|
| 774 |
-
|
| 775 |
-
|
| 776 |
-
|
| 777 |
-
|
| 778 |
-
|
| 779 |
-
|
| 780 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 781 |
} catch (err) {
|
| 782 |
// Decorative only: a missing/broken GLB must never halt the boot.
|
| 783 |
-
console.warn("[game]
|
| 784 |
}
|
| 785 |
|
| 786 |
// ββ Camera: orbit controls + chase cam + reset glide βββββββββββββββββ
|
|
@@ -1254,7 +1261,7 @@ async function boot({ scene, camera, renderer }) {
|
|
| 1254 |
get ballQposAdr() { return ballQposAdr; },
|
| 1255 |
get chaseCam() { return chaseCam; },
|
| 1256 |
set chaseCam(v) { chaseCam = !!v; },
|
| 1257 |
-
get
|
| 1258 |
get camResetActive() { return camResetT0 !== null; },
|
| 1259 |
get respawnActive() { return ceremony?.respawnActive ?? false; },
|
| 1260 |
get camPose() {
|
|
|
|
| 31 |
VEL_FWD, VEL_BACK, VEL_ANG, RVEL_FWD, RVEL_BACK, RVEL_ANG,
|
| 32 |
CROUCH_PERIOD_S, CROUCH_END_PHASE,
|
| 33 |
BALL_RADIUS, BALL_PARK_POS, ARENA_HALF, SPAWN_X, SPAWN_Y,
|
| 34 |
+
ARCADE_H, ARCADE_W, ARCADE_D, ARCADE_GAP, ARCADE_WALL_GAP,
|
| 35 |
} from "./constants.js";
|
| 36 |
import {
|
| 37 |
buildRig, cloneRig, loadKinematics, setJoint, setJawOpen, MODEL_DIR, MESH_VERSION,
|
|
|
|
| 175 |
el("geom", { name: w.name, type: "box", pos: w.pos, size: w.size }),
|
| 176 |
);
|
| 177 |
}
|
| 178 |
+
// Arcade cabinet row: one static box covering all three cabinets so
|
| 179 |
+
// the duck and ball can't clip through them. The row sits against the
|
| 180 |
+
// back (-X) wall, centered on y = 0 (three.js z = 0), axis-aligned.
|
| 181 |
+
const rowHalfW = (3 * ARCADE_W + 2 * ARCADE_GAP) / 2;
|
|
|
|
|
|
|
| 182 |
doc.querySelector("worldbody").appendChild(
|
| 183 |
el("geom", {
|
| 184 |
+
name: "arcade_row", type: "box",
|
| 185 |
+
pos: `${-(ARENA_HALF - ARCADE_WALL_GAP - ARCADE_D / 2)} 0 ${ARCADE_H / 2}`,
|
| 186 |
+
size: `${ARCADE_D / 2} ${rowHalfW} ${ARCADE_H / 2}`,
|
|
|
|
| 187 |
}),
|
| 188 |
);
|
| 189 |
// Kickable ball: a light free sphere (beach-ball feel). MuJoCo has no
|
|
|
|
| 616 |
}
|
| 617 |
})();
|
| 618 |
|
| 619 |
+
// ββ Scene wiring (grid, walls, rig, ball, arcade row) ββββββββββββββββ
|
| 620 |
// The grid/walls carry ceremony-driven uReveal uniforms and per-frame
|
| 621 |
// focus updates, so the game owns them; lights and environment live in
|
| 622 |
// the R3F layer.
|
|
|
|
| 740 |
THREE, scene, camera, renderer, fxModule: fx, mesh: ballMesh, group: ballGroup,
|
| 741 |
});
|
| 742 |
|
| 743 |
+
// ββ Arcade cabinets (wall dressing + static collision box) ββββββββββ
|
| 744 |
+
// assets/props/arcade.glb carries two meshes: the ~9k-tri render mesh
|
| 745 |
+
// and a 500-tri "arcade_wire" stand-in used only by the hologram pass
|
| 746 |
+
// (same fxWireGeometry escape hatch as the ball). Three clones line up
|
| 747 |
+
// against the back (-X) wall, backs to the wall, screens facing the
|
| 748 |
+
// arena; each materializes with the duck's ceremony, staggered, and
|
| 749 |
+
// replays on every respawn. Physics-side, buildPhysicsXml plants one
|
| 750 |
+
// static box covering the whole row (constants ARCADE_*).
|
| 751 |
+
let arcadeGroups = null;
|
| 752 |
try {
|
| 753 |
const gltf = await new GLTFLoader().loadAsync(
|
| 754 |
+
signed(`./assets/props/arcade.glb?v=1`),
|
| 755 |
);
|
| 756 |
+
const proto = gltf.scene;
|
| 757 |
+
const wire = proto.getObjectByName("arcade_wire");
|
| 758 |
+
wire.removeFromParent();
|
| 759 |
+
const box3 = new THREE.Box3().setFromObject(proto);
|
| 760 |
+
const scale = ARCADE_H / (box3.max.y - box3.min.y);
|
| 761 |
+
proto.scale.setScalar(scale);
|
| 762 |
+
proto.position.y = -box3.min.y * scale;
|
| 763 |
+
arcadeGroups = [];
|
| 764 |
+
for (let i = 0; i < 3; i++) {
|
| 765 |
+
// clone(true) shares geometry/materials; userData doesn't survive
|
| 766 |
+
// Object3D.copy (JSON round-trip), so tag the wire geometry after.
|
| 767 |
+
const cab = proto.clone(true);
|
| 768 |
+
cab.traverse((o) => {
|
| 769 |
+
if (o.isMesh) o.userData.fxWireGeometry = wire.geometry;
|
| 770 |
+
});
|
| 771 |
+
const group = new THREE.Group();
|
| 772 |
+
group.add(cab);
|
| 773 |
+
// Row centered on the back wall's middle, one cabinet-width apart,
|
| 774 |
+
// back faces almost touching the wall. The GLB fronts +Z; the row
|
| 775 |
+
// must front +X (toward the spawn), hence the +90 deg yaw.
|
| 776 |
+
group.position.set(
|
| 777 |
+
-(ARENA_HALF - ARCADE_D / 2 - ARCADE_WALL_GAP),
|
| 778 |
+
0,
|
| 779 |
+
(i - 1) * (ARCADE_W + ARCADE_GAP),
|
| 780 |
+
);
|
| 781 |
+
group.rotation.y = Math.PI / 2;
|
| 782 |
+
scene.add(group);
|
| 783 |
+
arcadeGroups.push(group);
|
| 784 |
+
const cabFx = fx.createWireframeFx();
|
| 785 |
+
cabFx.init({ THREE, scene, root: group, camera, renderer, hidden: true });
|
| 786 |
+
ceremony.addPropFx(cabFx, 0.25 + i * 0.12);
|
| 787 |
+
}
|
| 788 |
} catch (err) {
|
| 789 |
// Decorative only: a missing/broken GLB must never halt the boot.
|
| 790 |
+
console.warn("[game] arcade props disabled:", err);
|
| 791 |
}
|
| 792 |
|
| 793 |
// ββ Camera: orbit controls + chase cam + reset glide βββββββββββββββββ
|
|
|
|
| 1261 |
get ballQposAdr() { return ballQposAdr; },
|
| 1262 |
get chaseCam() { return chaseCam; },
|
| 1263 |
set chaseCam(v) { chaseCam = !!v; },
|
| 1264 |
+
get arcades() { return arcadeGroups; },
|
| 1265 |
get camResetActive() { return camResetT0 !== null; },
|
| 1266 |
get respawnActive() { return ceremony?.respawnActive ?? false; },
|
| 1267 |
get camPose() {
|