/** * 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, } }