BLOBBO/src/course/greybox.ts
Claude e34773d35f harden asset runtime: guard the swap path, bound the boot, reconcile slots
The three that stopped a pack shipping:
 - a mesh-less GLB (armature-only export) fired onSwap anyway, hiding the
   primitive and adding nothing — an invisible prop with a live collider.
   Mesh count is now checked BEFORE the scene is touched, in one guard that
   covers every consumer including slotObject/blob.face.
 - the fulfilment path had no try/catch, so a throw became an unhandled
   rejection. Split in two: a build failure bails out before onSwap can hide
   anything; an onSwap failure keeps the replacement parented, because
   consumers hide their primitive on onSwap's first line and removing the fit
   node there would manufacture the one forbidden state.
 - preload() awaited every url forever. Each is now raced against a 10s
   deadline with allSettled semantics: a stalled host costs one warning and a
   fallback prop, not a game that never boots.

Also:
 - instantiate/instanceSync never throw — createBlob's call site is frozen and
   unguarded, so a throw there is a black screen.
 - ghost and blob share fitBodyToRadius(); a borrowed blob.body was rendering
   the ghost 2.17x oversized with the farm mesh.
 - skinned blob.body is rejected loudly instead of silently half-working; the
   idle-clip mixer is gone (it animated an orphaned skeleton in the fixed step).
 - paintableInfo counts UV islands: blobbo-base.glb passes every other check
   and still paints wrong at 1140 charts. Warning, not rejection.
 - slots.ts gains cannon.base, course.finish, course.tunnel, course.tramp, all
   hooked except tramp (built in frozen game.ts).
 - manifest ignores _-prefixed metadata keys instead of calling them typos.

Empty-manifest parity verified byte-for-byte: scripts/sacred-parity.check.ts
fingerprints the built scene and reports 49df4f20 on main and on this branch.
2026-07-19 12:12:07 +10:00

132 lines
5.1 KiB
TypeScript

/**
* Lane A — greybox course chunk ("Breakfast Rush", primitive edition).
*
* A big, readable, absurdly-proportioned play space built from boxes so the blob
* has somewhere to strut, ramp, gap-jump, slide and splash. Everything sits on
* one generous base floor so a missed jump just drops you a little — never into
* the void. Surfaces that mean something to other lanes are tagged on the mesh
* `userData` (e.g. `{ surface: 'water' }`), and colliders carry the same tag so
* Lane C can look either up.
*
* +Z is "behind the start"; the course runs toward -Z (into the camera view).
*/
import * as THREE from 'three'
import type { World } from '../contracts'
import { assets } from '../assets/registry'
import type { SlotId } from '../assets/slots'
export interface GreyboxHandle {
/** Recommended blob spawn point (just above the start plateau). */
spawn: THREE.Vector3
/** Center of the open flat area where Lane C drops machines at integration. */
machineArea: THREE.Vector3
meshes: THREE.Mesh[]
}
interface BoxOpts {
color: THREE.ColorRepresentation
/** Euler rotation in radians (applied to both mesh and collider). */
rot?: THREE.Euler
/** Arbitrary surface/zone tag copied to mesh.userData AND collider.userData. */
surface?: string
transparent?: number // opacity 0..1 when set
roughness?: number
cast?: boolean
}
export function buildGreybox(world: World): GreyboxHandle {
const { scene, physics, rapier } = world
const meshes: THREE.Mesh[] = []
/** Add a static box: THREE mesh + fixed cuboid collider, optionally rotated. */
function addBox(
cx: number, cy: number, cz: number,
hx: number, hy: number, hz: number,
o: BoxOpts,
): THREE.Mesh {
const mat = new THREE.MeshStandardMaterial({
color: o.color,
roughness: o.roughness ?? 0.9,
metalness: 0.0,
transparent: o.transparent !== undefined,
opacity: o.transparent ?? 1,
})
const mesh = new THREE.Mesh(new THREE.BoxGeometry(hx * 2, hy * 2, hz * 2), mat)
mesh.position.set(cx, cy, cz)
mesh.receiveShadow = true
mesh.castShadow = o.cast ?? false
if (o.rot) mesh.rotation.copy(o.rot)
if (o.surface) mesh.userData.surface = o.surface
scene.add(mesh)
meshes.push(mesh)
const desc = rapier.ColliderDesc.cuboid(hx, hy, hz).setTranslation(cx, cy, cz)
if (o.rot) {
const q = new THREE.Quaternion().setFromEuler(o.rot)
desc.setRotation({ x: q.x, y: q.y, z: q.z, w: q.w })
}
const collider = physics.createCollider(desc)
// Mirror the tag onto the collider so Lane C can query from a physics hit.
if (o.surface) (collider as unknown as { userData?: unknown }).userData = { surface: o.surface }
return mesh
}
/**
* Park a custom prop inside a greybox box. The box's material is switched off
* rather than the mesh itself so the prop (a child) still renders, and the
* mesh stays in `meshes[]` so the handle's contract is unchanged.
*/
function decorate(slot: SlotId, mesh: THREE.Mesh): void {
assets().attachSlot(slot, mesh, {
onSwap: () => { (mesh.material as THREE.Material).visible = false },
})
}
// ---- base floor: catches everything, spans the whole course ----
addBox(0, -0.5, -15, 28, 0.5, 60, { color: '#efe3c6', roughness: 1 })
// ---- start plateau (raised cream pad) ----
addBox(0, 0.75, 30, 10, 0.75, 9, { color: '#f7edd2', cast: true })
// ---- ramp up (orange) : lifts the -Z end so you climb toward the high shelf ----
addBox(0, 1.7, 14, 6, 0.4, 8, {
color: '#FF9500', rot: new THREE.Euler(0.42, 0, 0), cast: true,
})
// ---- gap jump: two blue shelves with a real gap over the base floor ----
addBox(0, 3.0, 6, 7, 0.5, 4, { color: '#0A84FF', cast: true }) // high shelf, z[2..10]
addBox(0, 3.0, -6, 7, 0.5, 4, { color: '#0A84FF', cast: true }) // landing, z[-10..-2]
// ---- down slope (green) : back to ground level ----
addBox(0, 1.7, -16, 6, 0.4, 8, {
color: '#34C759', rot: new THREE.Euler(-0.36, 0, 0), cast: true,
})
// ---- milk river (water zone) : thin translucent slab flush with the floor ----
addBox(0, 0.06, -30, 14, 0.06, 7, {
color: '#f4f6ff', surface: 'water', transparent: 0.72, roughness: 0.2,
})
// ---- open flat area : Lane C lands machines here at integration ----
const machineArea = new THREE.Vector3(0, 0.12, -48)
addBox(machineArea.x, 0.12, machineArea.z, 16, 0.12, 11, {
color: '#d9c9a0', surface: 'machine-area',
})
// ---- finish pad (bright pink, raised) ----
decorate('course.finish', addBox(0, 0.6, -64, 9, 0.6, 6, { color: '#FF6EB4', surface: 'finish', cast: true }))
// ---- absurd-proportion scenery: a giant cereal box beside the start ----
// Slot hooks go on the MESH only, after the collider exists: the box volume
// is still solid whatever model is dropped in, so a custom prop can never
// open a hole in the course or grow an invisible wall.
decorate('course.scenery.cereal', addBox(-20, 7, 26, 4, 7, 3, { color: '#FFD60A', cast: true }))
decorate('course.scenery.block', addBox(20, 5, 12, 3, 5, 3, { color: '#AF52DE', cast: true }))
return {
spawn: new THREE.Vector3(0, 3.5, 30),
machineArea,
meshes,
}
}