BLOBBO/src/paint/index.ts
type-two 190a6e27d3 feat(paint): METEOR blend + yellow ZAP — the palette is complete
Three GDD §6 features land together, plus the design bug their verification
exposed.

METEOR (red+orange both >=20%): "smash light barriers" made literal. New
createSmashWall part — a brick-seamed light barrier, solid to everyone, but a
METEOR blob that RAMS it (>=5 u/s) shatters it into 8 real dynamic brick
shards that carry your momentum; rebuilds on race:respawn. Spec kind
'smashwall' with needColors for data courses. HUD shows "☄ METEOR" replacing
the pair's BURN/BOUNCE rows (GDD: the blend replaces its pair) and the ember
trail burns 2.2x while blended.

ZAP (yellow >=20%): "trigger colour-keyed machinery at range". New
machine/zap.ts — a charged blob pulses every 3s and fires any declared zap
target's machine:signal within radius, with an expanding arc ring. Breakfast
declares the fork's spring boot: purple earns it by WEIGHT on the plate,
yellow HOTWIRES it — verified the full arc: pulse -> boot telegraph -> kick
vy 10.6 -> lands on the finish podium. Yellow bubbles by the fork are the
source. Spec courses declare `zapTargets`. HUD shows " ZAP".

THE BUG THE WALL EXPOSED: the finale gate sat at z-59, but the finish box
reaches z-57.2 (podium-face bonks must count) and the podium collider face is
at z-58 — so in a LIVE race you finished before ever touching the gate; it
only ever blocked test blobs with the race idle. The whole gauntlet now lives
at z-56, in FRONT of the line: centre = green gate, left = METEOR smash wall,
right gap plugged (the MINI tunnel roof already gates x3..10), far left = open
detour. The cleanse arch moves to x-6.5/z-54.5 — at x-4.5 its zone overlapped
the METEOR approach and stripped the blend one step before the wall.

Verified in-browser (atomic calls; the wall-clock coverage cache and the
background sim poisoned split observations): clean blob bonks with race
running; METEOR blob shatters (8 shards, signal, drives through rubble to
"you finished 29% ORANGE"); wall rebuilds on R; zap fires exactly once per
cooldown. hasMeteor/hasZap are pure + unit-tested (58 assertions, 10 suites).

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-28 22:22:24 +10:00

78 lines
2.3 KiB
TypeScript

/**
* Lane B (Paint) public surface. Integration (game.ts) imports from here.
*/
import * as THREE from 'three'
import type { PaintColor, World } from '../contracts'
import { PaintSkin } from './skin'
export { PaintSkin } from './skin'
export type { PaintSkinOptions } from './skin'
export { PaintCannon, solveBallisticVelocity } from './cannon'
export type { PaintCannonConfig } from './cannon'
export { BuffSystem } from './buffs'
export type { BuffTarget } from './buffs'
export { PaintHUD } from './hud'
export {
COLOR_ORDER,
colorIndex,
countBuckets,
coverageReportFromCounts,
computeModifiers,
buffStrength,
superColor,
dominantColor,
ACTIVATE,
SUPER,
BOUNCE_JUMP,
hasMeteor,
hasZap,
} from './coverage-math'
export interface PaintEventTarget {
/** The paintable body — its world position is used for proximity gating. */
mesh: THREE.Object3D
paint: PaintSkin
}
/**
* Wire the inbound paint protocol from Lane C onto a blob's skin:
* 'paint:request-splat' {point, color, radius} — bucket dumps, sprays
* 'paint:request-cleanse' {fraction} — bubble arches, rinses
* request-splat is proximity-gated to the blob (ground decals are V1), and a
* matched splat re-emits 'paint:splatted' {target:'blob'} so downstream listeners
* (VFX, audio, netcode) see a single canonical splat event. Returns an unsub fn.
*/
export function installPaint(world: World, target: PaintEventTarget): () => void {
const center = new THREE.Vector3()
const scale = new THREE.Vector3()
const worldRadius = (): number => {
target.mesh.getWorldScale(scale)
return target.paint.boundingRadius * Math.max(scale.x, scale.y, scale.z)
}
const offSplat = world.events.on(
'paint:request-splat',
(p: { point: THREE.Vector3; color: PaintColor; radius: number }) => {
if (!p?.point) return
target.mesh.getWorldPosition(center)
if (center.distanceTo(p.point) <= worldRadius() + p.radius) {
target.paint.splatAtPoint(p.point, p.color, p.radius)
world.events.emit('paint:splatted', { color: p.color, target: 'blob' })
}
},
)
const offCleanse = world.events.on(
'paint:request-cleanse',
(p: { fraction: number }) => {
target.paint.cleanse(p?.fraction ?? 1)
},
)
return () => {
offSplat()
offCleanse()
}
}