/** * Colour zones (Lane G) — laid ON the existing greybox (course spans z 30 → -70, * see greybox.ts). Two jobs: * * 1. PAINT BY ROUTE. Each stretch of track gets a single-colour puddle run so a * racer picks up ONE dominant colour there instead of confetti. That matters * because the buff threshold is 20% of a single colour (coverage-math.ts) — * three colours diluting each other never crossed it (observed live). One * colour per zone makes buffs actually activate. * * 2. THE MEGA/MINI TEACHING FORK (machine district, z -37..-58). Paint = size = * route: * LEFT lane (x<0): PURPLE puddle → MEGA (grow + heavy) → trips a pressure * plate → SpringBoot yeet. The course teaches paint=weight. * RIGHT lane (x>3): PINK puddle → MINI (shrink) → fits a deliberately LOW * tunnel (pink roof slab at y≈1.0) that shortcuts toward * the finish. A normal/MEGA blob bonks the roof. * * Zone → colour → buff intent (GDD §6): * RED ramp approach BURN speed helps the climb * GREEN shelves + slope GRIP helps the slope / future gravity flips * BLUE milk river SLICK waterproof — your paint survives the water * PURPLE left fork MEGA heavy → pressure-plate route * PINK right fork MINI small → tunnel shortcut * * Cannon placement decision: installZones does NOT instantiate PaintCannons. It * RETURNS `cannonConfigs` (plain data) so integration builds them through the one * cannon-staging path it already owns in game.ts (target=blob.mesh, muzzle tuning, * etc.), avoiding double-placement. Puddles are the guaranteed paint; cannons are * bonus pressure, so ignoring the configs still satisfies the ≥40% route rule. */ import * as THREE from 'three' import type { Blob, PaintColor, World } from '../contracts' import { PALETTE } from '../contracts' import type { PaintSkin } from '../paint/skin' import { installPuddles, type PaintPuddle, type PaintPuddleConfig } from '../paint/puddles' import { createPressurePlate, createSpringBoot, type Vec3 } from '../machine' /** A cannon integration should stage for a zone (fires that zone's colour). */ export interface ZoneCannonConfig { color: PaintColor position: Vec3 /** Fires on `machine:signal {id}` matching this (Lane C wiring). */ triggerId: string /** Suggested seconds between auto-fires. */ interval: number } export interface TunnelInfo { /** World Y of the roof's underside — only blobs shorter than this fit. */ roofUndersideY: number center: Vec3 /** Roof slab half-extents [x, y, z]. */ half: Vec3 } export interface ZonesHandle { puddles: PaintPuddle[] cannonConfigs: ZoneCannonConfig[] /** Left-fork pressure plate: trips at baseMass*1.25 (needs a MEGA blob). */ plateId: string plateThreshold: number bootSignal: string tunnel: TunnelInfo totalStamps(): number } // --------------------------------------------------------------------------- // Puddle layout — absolute course coordinates (see greybox.ts surface heights). // base floor top y≈0 · shelves top y≈3.5 · river top y≈0.12 · machine top y≈0.24 // Centre-line strips (x≈0) coat a hold-forward run; fork strips sit off-centre. // --------------------------------------------------------------------------- const PUDDLE_LAYOUT: PaintPuddleConfig[] = [ // RED zone (ramp approach, z≈22..8) — wide centre strip off the plateau + onto // the ramp base. Generous so BURN reliably activates before the climb. { color: 'red', position: [0, 0.05, 18.5], size: [9, 9], splatRadius: 0.34 }, { color: 'red', position: [0, 1.1, 12], size: [8, 6], splatRadius: 0.34 }, // GREEN zone (shelves + slope, z≈8..-24) — both blue shelves are flat & always // rolled, so GRIP activates there; a strip on the slope tops it up. { color: 'green', position: [0, 3.55, 6], size: [11, 7], splatRadius: 0.34 }, { color: 'green', position: [0, 3.55, -6], size: [11, 7], splatRadius: 0.34 }, { color: 'green', position: [0, 1.5, -15.5], size: [9, 6], splatRadius: 0.34 }, // BLUE zone (milk river, z≈-24..-37) — long centre strip across the crossing so // SLICK/waterproof is up before you hit the water. { color: 'blue', position: [0, 0.15, -30], size: [11, 13], splatRadius: 0.36 }, // PURPLE/PINK fork (machine district, z≈-37..-58) on the machine-area floor. // Strips sit at the district ENTRANCE so the blob is fully MEGA/MINI BEFORE it // reaches the plate / tunnel further in. { color: 'purple', position: [-6, 0.27, -40], size: [6, 7], splatRadius: 0.34 }, { color: 'pink', position: [6.5, 0.27, -40], size: [6, 7], splatRadius: 0.34 }, ] // Fork geometry constants (obstacles sit PAST the entrance strips). const PLATE_POS: Vec3 = [-6, 0.24, -47] const BOOT_POS: Vec3 = [-6, 0.2, -49.5] const PLATE_ID = 'zone-plate-purple' const BOOT_SIGNAL = 'zone-boot-purple' // Roof underside = 1.27 - 0.15 = 1.12. On the machine floor (top y≈0.24) a blob // rests with its belly down, so its top ≈ 0.24 + 2·(r·size): // normal (size 1.0) → top ≈ 1.24 → BONK // ≥35% pink (≤0.846) → top ≤ 1.086 → CLEARS // full MINI (0.62) → top ≈ 0.86 → easy clearance const TUNNEL_CENTER: Vec3 = [6.5, 1.27, -51] const TUNNEL_HALF: Vec3 = [3.5, 0.15, 7] // spans z -58..-44 /** * Build the colour zones + the MEGA/MINI fork onto the live greybox and wire the * puddle paint system. Integration calls this once, after the blob/skin exist. */ export function installZones(world: World, blob: Blob, skin: PaintSkin): ZonesHandle { const { scene, physics, rapier } = world // ---- puddles (guaranteed paint by route) ---- const puddlesHandle = installPuddles(world, blob, skin, PUDDLE_LAYOUT) // ---- LEFT fork: PURPLE → MEGA → pressure plate → boot yeet ---- // Clean blob mass is ~1; a MEGA (purple) blob is heavy enough to cross *1.25. const baseMass = blob.body.mass() const plateThreshold = baseMass * 1.25 createPressurePlate(world, { id: PLATE_ID, position: PLATE_POS, massThreshold: plateThreshold, emits: BOOT_SIGNAL, size: [4, 4], }) createSpringBoot(world, { id: 'zone-boot', position: BOOT_POS, impulse: baseMass * 20, // sized for a heavy MEGA blob to carry toward the finish direction: [0, 1, -0.7], // up + forward (-Z) onSignal: BOOT_SIGNAL, strikeSize: [2.6, 1.4, 3], }) // ---- RIGHT fork: PINK → MINI → LOW TUNNEL shortcut ---- buildTunnel(scene, physics, rapier) // ---- cannons (returned as data for integration to stage) ---- const cannonConfigs: ZoneCannonConfig[] = [ // RED crossfire: the existing red position + a mirror across the lane. { color: 'red', position: [-9, 2.5, 14], triggerId: 'cannon-red', interval: 2.2 }, { color: 'red', position: [9, 2.5, 14], triggerId: 'cannon-red-2', interval: 2.2 }, // GREEN over the shelves. { color: 'green', position: [9, 4.8, -6], triggerId: 'cannon-green', interval: 2.2 }, // BLUE over the river. { color: 'blue', position: [-9, 2.0, -30], triggerId: 'cannon-blue', interval: 2.4 }, // Fork colours fire on their own lanes. { color: 'purple', position: [-11, 2.0, -43], triggerId: 'cannon-purple', interval: 2.6 }, { color: 'pink', position: [12, 2.0, -43], triggerId: 'cannon-pink', interval: 2.6 }, ] return { puddles: puddlesHandle.puddles, cannonConfigs, plateId: PLATE_ID, plateThreshold, bootSignal: BOOT_SIGNAL, tunnel: { roofUndersideY: TUNNEL_CENTER[1] - TUNNEL_HALF[1], center: TUNNEL_CENTER, half: TUNNEL_HALF }, totalStamps: puddlesHandle.totalStamps, } } /** * The pink low tunnel: a visibly-low pink roof slab (collider) over the right * lane, on stubby posts. Only a MINI blob clears its underside; taller blobs bonk * and must take another lane. Posts are cosmetic (no lane-blocking colliders). */ function buildTunnel( scene: THREE.Scene, physics: World['physics'], rapier: World['rapier'], ): void { const [cx, cy, cz] = TUNNEL_CENTER const [hx, hy, hz] = TUNNEL_HALF const roofMat = new THREE.MeshStandardMaterial({ color: PALETTE.pink, roughness: 0.4, metalness: 0.1, transparent: true, opacity: 0.82, emissive: PALETTE.pink, emissiveIntensity: 0.18, }) const roof = new THREE.Mesh(new THREE.BoxGeometry(hx * 2, hy * 2, hz * 2), roofMat) roof.position.set(cx, cy, cz) roof.castShadow = true roof.receiveShadow = true scene.add(roof) // Solid roof collider — this is what bonks a too-tall blob. physics.createCollider( rapier.ColliderDesc.cuboid(hx, hy, hz).setTranslation(cx, cy, cz), ) // "MIND YOUR HEAD" clearance bar hanging just under the lip so the low ceiling // reads before you reach it (visual only). const barMat = new THREE.MeshStandardMaterial({ color: PALETTE.pink, emissive: PALETTE.pink, emissiveIntensity: 0.4, roughness: 0.5, }) const bar = new THREE.Mesh(new THREE.BoxGeometry(hx * 2, 0.06, 0.12), barMat) bar.position.set(cx, cy - hy - 0.02, cz + hz) // entrance lip scene.add(bar) // cosmetic corner posts (no colliders — they don't block the lane) const postMat = new THREE.MeshStandardMaterial({ color: '#c94f86', roughness: 0.6 }) for (const sx of [-1, 1]) { for (const sz of [-1, 1]) { const post = new THREE.Mesh(new THREE.CylinderGeometry(0.16, 0.16, cy, 10), postMat) post.position.set(cx + sx * hx, cy * 0.5, cz + sz * hz) post.castShadow = true scene.add(post) } } }