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>
This commit is contained in:
type-two 2026-07-18 12:00:54 +10:00
parent fedb43817c
commit 3699a1635a
7 changed files with 747 additions and 9 deletions

26
demos/lane-g.html Normal file
View File

@ -0,0 +1,26 @@
<!doctype html>
<html lang="en">
<head><meta charset="utf-8" /><title>BLOBBO lane g demo</title>
<style>
html,body{margin:0;height:100%;overflow:hidden;background:#bfe3ff;font-family:system-ui,sans-serif}
#app{width:100%;height:100%}
#legend{position:fixed;top:10px;left:10px;padding:10px 12px;border-radius:8px;
background:rgba(20,24,40,.72);color:#eaf2ff;font-size:13px;line-height:1.5;pointer-events:none;max-width:340px}
#legend b{color:#FF6EB4}
#legend .k{display:inline-block;min-width:14px;padding:1px 5px;margin-right:6px;border-radius:4px;
background:#354569;color:#fff;font-weight:600;text-align:center}
</style></head>
<body>
<div id="app"></div>
<div id="legend">
<b>BLOBBO — Lane G: zones · puddles · MEGA/MINI</b><br>
WASD move · Space jump<br>
<span class="k">F</span>AUTO hold-forward (hands-free, ≥40% by route)<br>
<span class="k">1</span>teleport RED start · <span class="k">2</span>BLUE river<br>
<span class="k">3</span>PURPLE/MEGA fork · <span class="k">4</span>PINK/MINI tunnel<br>
<span class="k">X</span>full clean · <span class="k">C</span>cleanse 50%<br>
<span class="k">R</span>respawn · <span class="k">P</span>state to console · <span class="k">H</span>HUD
</div>
<script type="module" src="/src/demo/lane-g.ts"></script>
</body>
</html>

219
src/course/zones.ts Normal file
View File

@ -0,0 +1,219 @@
/**
* 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 y1.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)
}
}
}

203
src/demo/lane-g.ts Normal file
View File

@ -0,0 +1,203 @@
/**
* Lane G demo colour zones, paint puddles, and the MEGA/MINI scale fork.
*
* Builds the REAL greybox + blob + controller, then lays Lane G on top via
* installZones (puddles + purple/pink fork + low tunnel). Drive through and watch
* coverage climb by ROUTE no aiming, no cannon luck. The HUD reads live coverage
* / modifiers, whether the MEGA pressure plate has tripped, and your size so you
* can see MEGA grow and MINI shrink.
*
* The three things this demo proves (acceptance):
* 1. A hold-forward run (press F) ends 40% painted with a buff active puddles
* guarantee it. The console STATE line reports total% + active buffs.
* 2. PURPLE puddle MEGA (grow + heavy) trips the left pressure plate boot
* yeet. A clean blob rolls over the plate without tripping it.
* 3. PINK puddle MINI (shrink) fits the low pink tunnel on the right. A
* normal/clean blob bonks the roof and can't pass.
*
* CONTROLS
* WASD / Arrows move (camera-relative) Space jump
* DEBUG KEYS
* F toggle AUTO hold-forward (hands-free straight run through the zones)
* 1 teleport to RED start 2 teleport to the BLUE river
* 3 teleport to the PURPLE (left/MEGA) fork mouth
* 4 teleport to the PINK (right/MINI) tunnel mouth
* X full cleanse (go clean then try the tunnel/plate to see the difference)
* C cleanse 50%
* R respawn at start (also cleanses)
* P print a STATE snapshot to the console
* H toggle the HUD
* A STATE line prints once/second: pos, coverage %s, size/mass, active buffs, plate.
*/
import * as THREE from 'three'
import { createWorld } from '../world'
import { buildGreybox } from '../course/greybox'
import { createBlob } from '../blob/createBlob'
import { createBlobController } from '../blob/controller'
import { createBlobFeel } from '../blob/feel'
import { createFollowCamera } from '../blob/camera'
import { PaintSkin, PaintCannon, BuffSystem, PaintHUD, installPaint, COLOR_ORDER } from '../paint'
import { installZones } from '../course/zones'
const world = await createWorld(document.getElementById('app')!)
const course = buildGreybox(world)
// ---- blob + Lane A layers (controller/feel/camera) ----
const blob = createBlob(world, { position: course.spawn })
blob.paint = new PaintSkin(blob.mesh, { size: 256 })
world.blob = blob
const skin = blob.paint as PaintSkin
world.addSystem(createBlobController(world, blob))
const feel = createBlobFeel(blob)
world.events.on('blob:jumped', () => feel.onJump())
world.events.on('blob:landed', (p: { impact: number }) => feel.onLand(p.impact))
world.onFrame((dt) => feel.update(dt))
const camera = createFollowCamera(world, blob)
world.onFrame((dt) => camera.update(dt))
// ---- buffs + inbound paint protocol ----
world.addSystem(new BuffSystem(world, { mesh: blob.mesh, modifiers: blob.modifiers, paint: skin }))
installPaint(world, { mesh: blob.mesh, paint: skin })
// ---- LANE G: zones (puddles) + purple/pink fork + low tunnel ----
const zones = installZones(world, blob, skin)
// ---- stage the returned cannon configs as bonus pressure (integration's job in
// the real game; done here so the demo shows cannons + puddles together) ----
for (const c of zones.cannonConfigs) {
world.addSystem(new PaintCannon({
world,
position: new THREE.Vector3(...c.position),
color: c.color,
target: blob.mesh,
paint: skin,
targetRadius: blob.radius,
triggerId: c.triggerId,
interval: c.interval,
muzzleSpeed: 26,
splatRadius: 0.45,
}))
}
// ---- demo-only gap trampoline (stands in for game.ts's real one) so an AUTO
// hold-forward run can clear the shelf gap instead of soft-locking in the slot ----
{
world.physics.createCollider(
world.rapier.ColliderDesc.cuboid(6, 0.25, 2).setTranslation(0, 0.25, 0))
const tramp = new THREE.Mesh(
new THREE.BoxGeometry(12, 0.5, 4),
new THREE.MeshStandardMaterial({ color: '#FF9500', roughness: 0.6 }))
tramp.position.set(0, 0.25, 0)
world.scene.add(tramp)
let cd = 0
world.addSystem({
update(dt) {
cd = Math.max(0, cd - dt)
const t = blob.body.translation()
const v = blob.body.linvel()
if (Math.abs(t.x) < 6 && t.z > -2.2 && t.z < 2.2 && t.y < 1.4 && v.y < 0.5 && cd === 0) {
blob.body.setLinvel({ x: v.x * 0.4, y: 14, z: Math.max(-3, Math.min(3, (0.5 - t.z) * 1.5)) }, true)
cd = 0.6
world.events.emit('blob:jumped', {})
}
},
})
}
// ---- MEGA plate telemetry: did purple weight trip it? ----
let plateTripped = false
world.events.on('machine:signal', (p: { id: string }) => {
if (p.id === zones.bootSignal) {
plateTripped = true
console.log('%c[zone] PRESSURE PLATE TRIPPED — MEGA is heavy enough. BOOT YEET!', 'color:#AF52DE;font-weight:700')
}
})
// ---- AUTO hold-forward (dispatch a held KeyW so the real controller drives) ----
let auto = false
function setAuto(on: boolean) {
auto = on
const type = on ? 'keydown' : 'keyup'
dispatchEvent(new KeyboardEvent(type, { code: 'KeyW' }))
console.log(`[demo] AUTO hold-forward ${on ? 'ON' : 'OFF'}`)
}
// re-assert the held key each second (some browsers coalesce synthetic repeats)
world.onFrame(() => { if (auto) dispatchEvent(new KeyboardEvent('keydown', { code: 'KeyW' })) })
function respawn() {
blob.body.setTranslation({ x: course.spawn.x, y: course.spawn.y, z: course.spawn.z }, true)
blob.body.setLinvel({ x: 0, y: 0, z: 0 }, true)
skin.cleanse(1)
plateTripped = false
}
function teleport(x: number, y: number, z: number, label: string) {
blob.body.setTranslation({ x, y, z }, true)
blob.body.setLinvel({ x: 0, y: 0, z: 0 }, true)
console.log(`[demo] teleport → ${label}`)
}
world.onFrame(() => { if (blob.body.translation().y < -25) respawn() })
// ---- HUD (declared before the key handler that toggles it) ----
const hud = new PaintHUD()
addEventListener('keydown', (e) => {
switch (e.code) {
case 'KeyF': setAuto(!auto); break
case 'Digit1': teleport(0, 2.5, 20, 'RED start'); break
case 'Digit2': teleport(0, 1.0, -26, 'BLUE river'); break
case 'Digit3': teleport(-6, 1.0, -37, 'PURPLE / MEGA fork mouth'); break
case 'Digit4': teleport(6.5, 0.8, -37, 'PINK / MINI tunnel mouth'); break
case 'KeyX': skin.cleanse(1); console.log('[demo] full cleanse — blob is CLEAN'); break
case 'KeyC': skin.cleanse(0.5); console.log('[demo] cleanse 50%'); break
case 'KeyR': respawn(); break
case 'KeyP': console.log('[STATE]', JSON.stringify(snapshot())); break
case 'KeyH': hud.root.style.display = hud.root.style.display === 'none' ? '' : 'none'; break
default: return
}
})
// ---- telemetry -------------------------------------------------------------
function snapshot() {
const cov = skin.coverage()
const t = blob.body.translation()
const m = blob.modifiers
return {
pos: [+t.x.toFixed(1), +t.y.toFixed(1), +t.z.toFixed(1)],
total: +(cov.total * 100).toFixed(0),
colours: COLOR_ORDER.filter((c) => cov.byColor[c] > 0.005).map((c) => `${c}:${(cov.byColor[c] * 100).toFixed(0)}%`),
size: +m.size.toFixed(2),
mass: +m.massMul.toFixed(2),
stamps: zones.totalStamps(),
plateThreshold: +zones.plateThreshold.toFixed(2),
plateTripped,
tunnelUndersideY: zones.tunnel.roofUndersideY,
}
}
let acc = 0
world.onFrame((dt) => {
const cov = skin.coverage()
hud.update(cov, blob.modifiers)
acc += dt
if (acc >= 1) {
acc = 0
const s = snapshot()
const m = blob.modifiers
const buffs = [
m.speedMul > 1.001 ? `speed×${m.speedMul.toFixed(2)}` : '',
m.grip > 0.001 ? `grip${m.grip.toFixed(2)}` : '',
m.waterproof ? 'waterproof' : '',
m.size > 1.05 ? `MEGA×${m.size.toFixed(2)}` : m.size < 0.95 ? `MINI×${m.size.toFixed(2)}` : '',
m.glow > 0 ? 'SUPER' : '',
].filter(Boolean).join(' ') || '(none)'
console.log(
`[STATE] pos=(${s.pos.join(',')}) total=${s.total}% ${s.colours.join(' ') || '(clean)'} | ` +
`size=${s.size} mass=${s.mass} | buffs: ${buffs} | plate ${plateTripped ? 'TRIPPED' : 'armed'} | stamps=${s.stamps}`,
)
}
})
world.start()
console.log('%cBLOBBO Lane G — colour zones · puddles · MEGA/MINI', 'color:#AF52DE;font-weight:700')
console.log('WASD move · Space jump · F auto-forward · 1-4 teleport zones · X clean · C cleanse50 · R respawn · P state · H hud')

View File

@ -76,7 +76,10 @@ export class BuffSystem implements System {
const cov = paint.coverage() // 250ms-cached; no per-frame readback
const mods = computeModifiers(cov)
// copy into the shared modifiers object (readers hold this reference)
// Copy into the shared modifiers object (readers hold this reference).
// NOTE: `size` + `massMul` now also carry the purple MEGA / pink MINI scale
// buffs (Lane G) straight out of computeModifiers — the controller reads
// `size` to scale the collider + visuals, so no extra work is needed here.
const m = this.blob.modifiers
m.speedMul = mods.speedMul
m.jumpMul = mods.jumpMul

View File

@ -9,6 +9,11 @@ import type { CoverageReport, PaintColor } from '../contracts'
import {
ACTIVATE,
SUPER,
MEGA_SIZE,
MINI_SIZE,
MEGA_MASS,
MINI_MASS,
MASS_FLOOR,
buffStrength,
computeModifiers,
countBuckets,
@ -109,9 +114,65 @@ near(buffStrength(0.45), 0.15 + 0.85 * 0.5, 'strength ramps linearly at midpoint
ok(m.waterproof === true, 'blue 25% waterproof')
}
{
// massMul clamps at 1.8 when fully painted
// massMul clamps at 1.8 when fully painted (no purple/pink → base only)
const m = computeModifiers(cov({ red: 0.5, blue: 0.5 }))
near(m.massMul, 1.8, 'full coverage massMul 1.8')
}
// ---- purple MEGA (grow + heavy) ------------------------------------------
{
// below 20% purple → no size change, no extra mass
const m = computeModifiers(cov({ purple: 0.19 }))
near(m.size, 1, 'purple 19% → size unchanged')
near(m.massMul, 1 + 0.8 * 0.19, 'purple 19% → base mass only')
}
{
// purple super (70%) → size maxed to 1.45, MEGA mass added, glow
const m = computeModifiers(cov({ purple: 0.7 }))
near(m.size, 1 + MEGA_SIZE, 'purple 70% → size 1.45')
near(m.massMul, 1 + 0.8 * 0.7 + MEGA_MASS, 'purple 70% → base + full MEGA mass')
near(m.glow, 1, 'purple 70% is super (glow)')
ok(m.massMul > 1.25, 'MEGA is heavy enough to trip a *1.25 plate')
}
{
// purple mid (45%) → partial grow via buffStrength
const s = 0.15 + 0.85 * 0.5
const m = computeModifiers(cov({ purple: 0.45 }))
near(m.size, 1 + MEGA_SIZE * s, 'purple 45% → partial grow')
}
// ---- pink MINI (shrink + light) ------------------------------------------
{
// below 20% pink → no size change
const m = computeModifiers(cov({ pink: 0.19 }))
near(m.size, 1, 'pink 19% → size unchanged')
}
{
// pink super (70%) → size shrunk to 0.62, lighter than clean
const m = computeModifiers(cov({ pink: 0.7 }))
near(m.size, 1 - MINI_SIZE, 'pink 70% → size 0.62')
ok(m.size < 1, 'pink shrinks the blob')
near(m.massMul, 1 + 0.8 * 0.7 - MINI_MASS, 'pink 70% → base mass minus full MINI reduction')
ok(m.massMul < 1, 'MINI is lighter than a clean blob at high %')
}
{
// pink can never drive mass to/below zero (MASS_FLOOR guards it)
const m = computeModifiers(cov({ pink: 1 }))
ok(m.massMul >= MASS_FLOOR, 'pink mass floored at MASS_FLOOR')
}
// ---- purple vs pink fight -------------------------------------------------
{
// equal purple + pink strength cancel → net normal size
const m = computeModifiers(cov({ purple: 0.5, pink: 0.5 }))
near(m.size, 1, 'equal purple+pink → size nets to 1 (they fight)')
}
{
// more purple than pink → still grows, but less than pure purple
const strong = computeModifiers(cov({ purple: 0.6 }))
const fought = computeModifiers(cov({ purple: 0.6, pink: 0.3 }))
ok(fought.size > 1, 'purple-dominant mix still grows')
ok(fought.size < strong.size, 'pink drags the grow down')
}
console.log(`coverage-math.test: ${passed} assertions passed ✓`)

View File

@ -54,6 +54,21 @@ export const SUPER = 0.70 // ≥70% one colour = super state
/** Strength floor at the activation threshold so the buff visibly "kicks in". */
const ACTIVATE_FLOOR = 0.15
// ---- Scale buffs (GDD §5.3 / §6): purple MEGA + pink MINI (Lane G) ---------
// Both drive `modifiers.size` (the controller scales collider + visuals) and
// bias `massMul` on top of the paint=weight base. They oppose each other: a blob
// carrying both purple and pink nets out toward normal size (they "fight").
/** Uniform scale added at full purple strength: 1.0 → 1.45 (grow, blocks/plates). */
export const MEGA_SIZE = 0.45
/** Uniform scale removed at full pink strength: 1.0 → 0.62 (shrink, tunnels). */
export const MINI_SIZE = 0.38
/** Extra massMul at full purple strength — MEGA is heavy beyond its paint total. */
export const MEGA_MASS = 1.5
/** massMul removed at full pink strength — MINI is lighter than clean at high %. */
export const MINI_MASS = 0.9
/** Lower bound so a fully-MINI blob stays positively massed (flingable, not zero). */
export const MASS_FLOOR = 0.35
const clamp01 = (x: number) => (x < 0 ? 0 : x > 1 ? 1 : x)
/**
@ -95,11 +110,13 @@ export function dominantColor(cov: CoverageReport): PaintColor | null {
}
/**
* The core mapping: coverage -> modifiers (GDD §6 MVP subset).
* RED -> speedMul up to 1.6 (BURN)
* GREEN -> grip up to 1.0 (GRIP)
* BLUE -> waterproof (SLICK)
* total coverage -> massMul 1.0..1.8 (paint = weight, §5.2)
* The core mapping: coverage -> modifiers (GDD §6).
* RED -> speedMul up to 1.6 (BURN)
* GREEN -> grip up to 1.0 (GRIP)
* BLUE -> waterproof (SLICK)
* PURPLE -> size up to 1.45 + extra mass (MEGA grow big & heavy, §5.3)
* PINK -> size down to 0.62 + less mass (MINI shrink small & light)
* total coverage -> massMul base 1.0..1.8 (paint = weight, §5.2)
* any colour 70% -> super: that buff pinned at max + glow
* Pure and deterministic; the visual side-effects (ember trail, emissive pulse)
* live in BuffSystem and read `glow` / dominant colour off this result + cov.
@ -108,11 +125,24 @@ export function computeModifiers(cov: CoverageReport): BlobModifiers {
const red = cov.byColor.red
const green = cov.byColor.green
const blue = cov.byColor.blue
const purpleS = buffStrength(cov.byColor.purple)
const pinkS = buffStrength(cov.byColor.pink)
const speedMul = 1 + 0.6 * buffStrength(red)
const grip = buffStrength(green) // 0..1
const waterproof = blue >= ACTIVATE
const massMul = 1 + 0.8 * clamp01(cov.total)
// ---- scale (MEGA vs MINI) ----
// net grow/shrink strength; purple and pink oppose so a mixed blob nets out.
const net = purpleS - pinkS
const size = net >= 0 ? 1 + MEGA_SIZE * net : 1 + MINI_SIZE * net
// ---- mass (paint=weight base, biased by MEGA heavier / MINI lighter) ----
const massMul = Math.max(
MASS_FLOOR,
1 + 0.8 * clamp01(cov.total) + MEGA_MASS * purpleS - MINI_MASS * pinkS,
)
const glow = superColor(cov) ? 1 : 0
return {
@ -120,7 +150,7 @@ export function computeModifiers(cov: CoverageReport): BlobModifiers {
jumpMul: 1,
massMul,
grip,
size: 1, // purple/pink grow-shrink is V1 (§5.3) — untouched in MVP
size,
waterproof,
glow,
}

196
src/paint/puddles.ts Normal file
View File

@ -0,0 +1,196 @@
/**
* PaintPuddle (Lane G) a flat coloured strip laid ON the track. Its job is to
* make paint ROUTE-guaranteed instead of aim-guaranteed: while the blob's ground
* contact is inside the strip, we stamp its UNDERSIDE on a steady cadence, so
* simply rolling through a puddle coats your belly. Ballistic cannons (Lane B)
* become bonus pressure on top; a hold-forward racer can no longer finish clean.
*
* A puddle is purely a paint source: a decorative mesh (NO collider you roll
* straight over it) plus a footprint trigger. Each stamp goes through the SAME
* `skin.splatAtPoint` the cannons use and emits the frozen `paint:splatted`
* {color, target:'blob'} event (audio/VFX/netcode hook it downstream).
*
* Belly-coating detail: the blob's physics body doesn't tumble (rotations are
* locked the comedy roll is visual only), so naively stamping straight down
* would pile every stamp onto the south pole. Instead we fake the roll: an
* accumulated roll phase (distance / radius) walks the contact point along the
* travel great circle, so crossing a strip paints a proper stripe around the
* body and coverage actually climbs.
*/
import * as THREE from 'three'
import type { Blob, PaintColor, World } from '../contracts'
import { PALETTE } from '../contracts'
import type { PaintSkin } from './skin'
export interface PaintPuddleConfig {
/** Strip centre in world space; y is the track surface the strip lies on. */
position: [number, number, number]
/** Footprint [width(x), length(z)] in world units. */
size: [number, number]
color: PaintColor
/** Underside stamps per second while rolling through. Default 6. */
rate?: number
/** World-space splat radius per stamp. Default 0.3. */
splatRadius?: number
}
const DEFAULT_RATE = 6
const DEFAULT_SPLAT_RADIUS = 0.3
/** How close the belly must be to the strip plane to count as "rolling on it". */
const CONTACT_TOLERANCE = 0.6
export class PaintPuddle {
readonly color: PaintColor
readonly mesh: THREE.Mesh
/** Total underside stamps this puddle has laid (demo/telemetry). */
stamps = 0
private readonly px: number
private readonly pz: number
private readonly py: number
private readonly hx: number
private readonly hz: number
private readonly period: number
private readonly splatRadius: number
private acc = 0
constructor(world: World, cfg: PaintPuddleConfig) {
this.color = cfg.color
const [x, y, z] = cfg.position
const [w, l] = cfg.size
this.px = x
this.py = y
this.pz = z
this.hx = w * 0.5
this.hz = l * 0.5
this.period = 1 / (cfg.rate ?? DEFAULT_RATE)
this.splatRadius = cfg.splatRadius ?? DEFAULT_SPLAT_RADIUS
// Decorative wet-paint slab — thin, flat, no collider (you roll over it).
const mat = new THREE.MeshStandardMaterial({
color: PALETTE[cfg.color],
roughness: 0.25,
metalness: 0.0,
transparent: true,
opacity: 0.9,
emissive: PALETTE[cfg.color],
emissiveIntensity: 0.12,
})
this.mesh = new THREE.Mesh(new THREE.BoxGeometry(w, 0.05, l), mat)
this.mesh.position.set(x, y + 0.03, z)
this.mesh.receiveShadow = true
world.scene.add(this.mesh)
}
/** True while the blob's belly is resting inside this strip's footprint. */
private covers(cx: number, cz: number, bellyY: number): boolean {
return (
cx >= this.px - this.hx && cx <= this.px + this.hx &&
cz >= this.pz - this.hz && cz <= this.pz + this.hz &&
Math.abs(bellyY - this.py) <= CONTACT_TOLERANCE
)
}
/**
* Advance one fixed step. `stampPoint(out, phase)` writes the world contact
* point for the current roll phase (shared across all puddles so the fake
* roll is continuous). Returns the number of stamps laid this step.
*/
step(
dt: number,
cx: number,
cz: number,
bellyY: number,
phase: number,
stampPoint: (out: THREE.Vector3, phase: number) => void,
skin: PaintSkin,
events: World['events'],
scratch: THREE.Vector3,
): number {
if (!this.covers(cx, cz, bellyY)) {
this.acc = 0
return 0
}
this.acc += dt
let laid = 0
while (this.acc >= this.period) {
this.acc -= this.period
stampPoint(scratch, phase)
skin.splatAtPoint(scratch, this.color, this.splatRadius)
events.emit('paint:splatted', { color: this.color, target: 'blob' })
this.stamps++
laid++
}
return laid
}
}
export interface PuddlesHandle {
puddles: PaintPuddle[]
/** Total stamps laid across all puddles (demo/telemetry). */
totalStamps(): number
}
/**
* Build the puddles from `configs` and register the single fixed-step system
* that coats the blob's belly while it rolls through any of them. Integration
* (and zones.ts) call this; it owns no course geometry beyond the strips.
*/
export function installPuddles(
world: World,
blob: Blob,
skin: PaintSkin,
configs: PaintPuddleConfig[],
): PuddlesHandle {
const puddles = configs.map((c) => new PaintPuddle(world, c))
// Continuous fake-roll state (see class docstring).
const prev = new THREE.Vector3()
let hasPrev = false
let phase = 0
const dir = new THREE.Vector3(0, 0, -1) // course runs toward -Z; sane default
const axis = new THREE.Vector3()
const down = new THREE.Vector3(0, -1, 0)
const center = new THREE.Vector3()
const scratch = new THREE.Vector3()
const rotated = new THREE.Vector3()
world.addSystem({
update(dt: number): void {
const t = blob.body.translation()
center.set(t.x, t.y, t.z)
const r = skin.boundingRadius * Math.max(0.01, blob.modifiers.size)
const bellyY = t.y - r
// advance the roll phase from horizontal displacement (roll w/o slipping)
if (hasPrev) {
const dx = t.x - prev.x
const dz = t.z - prev.z
const moved = Math.hypot(dx, dz)
if (moved > 1e-5) {
dir.set(dx / moved, 0, dz / moved)
phase += moved / r
}
}
prev.set(t.x, t.y, t.z)
hasPrev = true
// horizontal axis perpendicular to travel; contact point rides the great
// circle in the travel-vertical plane as `phase` grows.
axis.set(-dir.z, 0, dir.x)
const stampPoint = (out: THREE.Vector3, ph: number) => {
rotated.copy(down).applyAxisAngle(axis, ph)
out.copy(center).addScaledVector(rotated, r)
}
for (const p of puddles) {
p.step(dt, t.x, t.z, bellyY, phase, stampPoint, skin, world.events, scratch)
}
},
})
return {
puddles,
totalStamps: () => puddles.reduce((s, p) => s + p.stamps, 0),
}
}