BLOBBO/src/paint/coverage-math.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

180 lines
6.9 KiB
TypeScript

/**
* Pure paint math — no THREE / Rapier / DOM imports, so it is unit-testable in a
* bare node runtime (see coverage-math.test.ts). Everything that touches only
* numbers lives here: the exact pixel-bucket coverage count and the
* coverage -> BlobModifiers buff mapping (GDD §6 MVP subset).
*/
import type { BlobModifiers, CoverageReport, PaintColor } from '../contracts'
/**
* Canonical colour ordering. Index 0 in the mask is the unpainted base; palette
* colours occupy indices 1..7 in this order (matches the PALETTE key order in
* contracts.ts). Keep this list and the mask encoding in lockstep.
*/
export const COLOR_ORDER: readonly PaintColor[] = [
'red', 'orange', 'yellow', 'green', 'blue', 'purple', 'pink',
]
/** Mask palette index for a colour (1..7). Base/unpainted is 0. */
export function colorIndex(color: PaintColor): number {
return COLOR_ORDER.indexOf(color) + 1
}
/**
* Exact integer bucketing of a quantized mask. `mask[i]` is a palette index
* (0 = base, 1..n = colour). Returns counts of length n+1 where counts[0] is the
* unpainted pixel count. This is the whole point of quantizing on stamp: coverage
* is an exact histogram, never a fuzzy nearest-colour match.
*/
export function countBuckets(mask: Uint8Array, numColors: number): number[] {
const counts = new Array<number>(numColors + 1).fill(0)
for (let i = 0; i < mask.length; i++) counts[mask[i]]++
return counts
}
/**
* Turn raw bucket counts into a CoverageReport (fractions of TOTAL surface;
* unpainted counts in the denominator, per contracts).
*/
export function coverageReportFromCounts(
counts: number[],
totalPixels: number,
): CoverageReport {
const byColor = {} as Record<PaintColor, number>
for (let i = 0; i < COLOR_ORDER.length; i++) {
byColor[COLOR_ORDER[i]] = totalPixels > 0 ? counts[i + 1] / totalPixels : 0
}
const painted = totalPixels > 0 ? (totalPixels - counts[0]) / totalPixels : 0
return { total: painted, byColor }
}
// ---- Buff thresholds (GDD §5.1 / §6 MVP subset) ---------------------------
export const ACTIVATE = 0.20 // buff activates at ≥20% of that colour
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
/** Extra take-off speed at full ORANGE strength: jumpMul 1.0 → 1.5 (BOUNCE). */
export const BOUNCE_JUMP = 0.5
// ---- 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)
/**
* Buff strength 0..1 for a single colour's coverage fraction.
* 0 below the 20% threshold; ramps ACTIVATE_FLOOR..1 across 20%→70%; pinned at 1
* once super. This is the "strength scales linearly between thresholds" curve.
*/
export function buffStrength(coverage: number): number {
if (coverage < ACTIVATE) return 0
if (coverage >= SUPER) return 1
const t = (coverage - ACTIVATE) / (SUPER - ACTIVATE)
return ACTIVATE_FLOOR + (1 - ACTIVATE_FLOOR) * t
}
/** Is any single colour at/above the super threshold? */
export function superColor(cov: CoverageReport): PaintColor | null {
let best: PaintColor | null = null
let bestV = SUPER
for (const c of COLOR_ORDER) {
if (cov.byColor[c] >= bestV) {
bestV = cov.byColor[c]
best = c
}
}
return best
}
// ---- Blends & abilities (GDD §5.1/§6): adjacent pairs, both ≥ ACTIVATE -----
/** METEOR = RED + ORANGE both active (GDD §6): flaming cannonball — smashes
* light barriers (see createSmashWall) and burns hotter (BuffSystem embers). */
export function hasMeteor(cov: CoverageReport): boolean {
return cov.byColor.red >= ACTIVATE && cov.byColor.orange >= ACTIVATE
}
/** ZAP = YELLOW active (GDD §6): static charge — triggers signal-driven
* machinery at range (ZapSystem pulses nearby machines' signals). */
export function hasZap(cov: CoverageReport): boolean {
return cov.byColor.yellow >= ACTIVATE
}
/** Dominant (highest-coverage) colour, or null if nothing is painted. */
export function dominantColor(cov: CoverageReport): PaintColor | null {
let best: PaintColor | null = null
let bestV = 0
for (const c of COLOR_ORDER) {
if (cov.byColor[c] > bestV) {
bestV = cov.byColor[c]
best = c
}
}
return best
}
/**
* The core mapping: coverage -> modifiers (GDD §6).
* RED -> speedMul up to 1.6 (BURN)
* ORANGE -> jumpMul up to 1.5 (BOUNCE — rubberized, §6)
* 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.
*/
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
// ORANGE = BOUNCE (GDD §6): rubberized, jumps higher. `jumpMul` was already
// plumbed end-to-end (controller multiplies take-off speed by it, BuffSystem
// copies it) but nothing ever drove it — orange is the colour that does.
const jumpMul = 1 + BOUNCE_JUMP * buffStrength(cov.byColor.orange)
// ---- 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 {
speedMul,
jumpMul,
massMul,
grip,
size,
waterproof,
glow,
}
}