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.
132 lines
5.1 KiB
TypeScript
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,
|
|
}
|
|
}
|