/** * 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(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 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, } }