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>
220 lines
9.4 KiB
TypeScript
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)
|
|
}
|
|
}
|
|
}
|