BLOBBO/src/course/zones.ts
type-two 3699a1635a Lane G: colour zones, paint puddles, MEGA/MINI scale buffs
Fixes the live finding that a racer can finish with 0% paint. Paint is now
ROUTE-guaranteed by ground puddles, and colours come in single-colour zones so
buffs actually cross the 20% threshold.

- paint/puddles.ts: PaintPuddle + installPuddles. Flat coloured strips (no
  collider) that stamp the blob's underside via skin.splatAtPoint on a ~6/s
  cadence while rolling through, emitting paint:splatted. The body doesn't
  physically tumble (rotations locked), so an accumulated roll phase walks the
  contact point along the travel great circle — a straight run paints a stripe
  and coverage climbs instead of piling on the south pole.
- paint/coverage-math.ts: extend computeModifiers with purple MEGA (size→1.45 +
  heavier) and pink MINI (size→0.62 + lighter than clean at high %). They fight:
  net size uses (purple − pink) strength; mass floored so MINI stays positive.
  (Lives here, not buffs.ts, because the pure mapping must stay THREE-free for
  the bare-node test; BuffSystem already forwards mods.size/massMul unchanged.)
- paint/coverage-math.test.ts: +MEGA/MINI/fight cases (48 assertions pass).
- course/zones.ts: installZones lays RED/GREEN/BLUE puddles down the centre line
  and the PURPLE(left)/PINK(right) fork onto the machine district — a pressure
  plate (baseMass*1.25) + boot on the MEGA lane, a visibly-low pink tunnel
  (underside y≈1.12) on the MINI lane. Returns cannon configs for integration.
- demo (demos/lane-g.html + src/demo/lane-g.ts): drive the zones, F = hands-free
  hold-forward, 1-4 teleport to each zone, live coverage/size/mass + plate HUD.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-18 12:00:54 +10:00

220 lines
9.4 KiB
TypeScript

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