BLOBBO/src/paint/coverage-math.ts
type-two eed2eb2646 Lane B (Paint): splat pipeline, coverage compute, buff/threshold framework
The core invention: paint lands on the blob body where projectiles hit, live
per-colour coverage % is computed from a quantized UV mask, and coverage drives
buffs per GDD §6 MVP subset (RED speed+embers, GREEN grip, BLUE waterproof,
total→mass, 20% activate / 70% super+glow).

- src/paint/skin.ts       PaintSkin: Canvas-2D mask on mesh UVs → CanvasTexture;
                          splatAtPoint raycasts impact→face UV; dual soft-canvas
                          + hard quantized-index mask so coverage is exact
                          integer bucketing (never getImageData); 250ms cache;
                          scrub-hole cleanse; U-seam wrap.
- src/paint/coverage-math.ts  pure, unit-tested: countBuckets, coverage report,
                          buffStrength, computeModifiers.
- src/paint/cannon.ts     PaintCannon: gravity-arc glob projectiles (manual
                          swept sphere test), target-leading ballistic solve,
                          fires on interval AND machine:signal; emits paint:splatted.
- src/paint/buffs.ts      BuffSystem: coverage→modifiers each step + ember trail
                          (additive Points) + super-state emissive pulse.
- src/paint/hud.ts        PaintHUD: per-colour bars + active buff kit (throttled).
- src/paint/index.ts      exports + installPaint() wiring the inbound Lane C
                          protocol (request-splat proximity-gated / request-cleanse).
- src/demo/lane-b.ts      stub ball orbiting a RED and GREEN cannon; HUD live;
                          debug keys for fire/signal/flood/cleanse/pause.
- src/paint/coverage-math.test.ts  32 assertions (node --experimental-strip-types).

build: tsc strict + vite pass. No edits outside owned paths; contracts frozen.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-17 20:13:30 +10:00

128 lines
4.4 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
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
}
/** 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 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)
* 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 speedMul = 1 + 0.6 * buffStrength(red)
const grip = buffStrength(green) // 0..1
const waterproof = blue >= ACTIVATE
const massMul = 1 + 0.8 * clamp01(cov.total)
const glow = superColor(cov) ? 1 : 0
return {
speedMul,
jumpMul: 1,
massMul,
grip,
size: 1, // purple/pink grow-shrink is V1 (§5.3) — untouched in MVP
waterproof,
glow,
}
}