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>
118 lines
4.1 KiB
TypeScript
118 lines
4.1 KiB
TypeScript
/**
|
|
* Unit checks for the pure paint math. No test runner / no deps — run directly:
|
|
* node --experimental-strip-types src/paint/coverage-math.test.ts
|
|
* (Node ≥22; on Node ≥23 the flag is unnecessary.) Uses only console + throw so
|
|
* it also type-checks cleanly under the project's DOM tsconfig and is never
|
|
* bundled (no demo entry imports it).
|
|
*/
|
|
import type { CoverageReport, PaintColor } from '../contracts'
|
|
import {
|
|
ACTIVATE,
|
|
SUPER,
|
|
buffStrength,
|
|
computeModifiers,
|
|
countBuckets,
|
|
coverageReportFromCounts,
|
|
colorIndex,
|
|
} from './coverage-math'
|
|
|
|
let passed = 0
|
|
function ok(cond: boolean, msg: string): void {
|
|
if (!cond) throw new Error('FAIL: ' + msg)
|
|
passed++
|
|
}
|
|
function near(a: number, b: number, msg: string, eps = 1e-9): void {
|
|
ok(Math.abs(a - b) <= eps, `${msg} (got ${a}, want ${b})`)
|
|
}
|
|
|
|
// helper: build a CoverageReport from a partial byColor map
|
|
function cov(part: Partial<Record<PaintColor, number>>): CoverageReport {
|
|
const byColor = {
|
|
red: 0, orange: 0, yellow: 0, green: 0, blue: 0, purple: 0, pink: 0,
|
|
...part,
|
|
}
|
|
const total = Object.values(byColor).reduce((a, b) => a + b, 0)
|
|
return { total, byColor }
|
|
}
|
|
|
|
// ---- colorIndex ----------------------------------------------------------
|
|
ok(colorIndex('red') === 1, 'red index is 1')
|
|
ok(colorIndex('pink') === 7, 'pink index is 7')
|
|
|
|
// ---- countBuckets --------------------------------------------------------
|
|
{
|
|
const mask = new Uint8Array(100) // all base (0)
|
|
for (let i = 0; i < 30; i++) mask[i] = 1 // red
|
|
for (let i = 30; i < 50; i++) mask[i] = 4 // green
|
|
const c = countBuckets(mask, 7)
|
|
ok(c[0] === 50, 'base count 50')
|
|
ok(c[1] === 30, 'red count 30')
|
|
ok(c[4] === 20, 'green count 20')
|
|
ok(c[7] === 0, 'pink count 0')
|
|
ok(c.length === 8, 'counts length 8 (base + 7)')
|
|
}
|
|
|
|
// ---- coverageReportFromCounts --------------------------------------------
|
|
{
|
|
// counts: [base, red, orange, yellow, green, blue, purple, pink]
|
|
const counts = [50, 30, 0, 0, 20, 0, 0, 0]
|
|
const r = coverageReportFromCounts(counts, 100)
|
|
near(r.byColor.red, 0.3, 'red fraction')
|
|
near(r.byColor.green, 0.2, 'green fraction')
|
|
near(r.total, 0.5, 'total painted fraction')
|
|
// empty texture → all zeros, no NaN
|
|
const z = coverageReportFromCounts([0, 0, 0, 0, 0, 0, 0, 0], 0)
|
|
near(z.total, 0, 'empty total 0')
|
|
near(z.byColor.blue, 0, 'empty blue 0')
|
|
}
|
|
|
|
// ---- buffStrength --------------------------------------------------------
|
|
near(buffStrength(0.0), 0, 'strength below threshold is 0')
|
|
near(buffStrength(0.199), 0, 'strength just below 20% is 0')
|
|
near(buffStrength(ACTIVATE), 0.15, 'strength at 20% is the floor')
|
|
near(buffStrength(SUPER), 1, 'strength at 70% is 1')
|
|
near(buffStrength(0.9), 1, 'strength above 70% pinned at 1')
|
|
near(buffStrength(0.45), 0.15 + 0.85 * 0.5, 'strength ramps linearly at midpoint')
|
|
|
|
// ---- computeModifiers ----------------------------------------------------
|
|
{
|
|
const clean = computeModifiers(cov({}))
|
|
near(clean.speedMul, 1, 'clean speedMul 1')
|
|
near(clean.grip, 0, 'clean grip 0')
|
|
ok(clean.waterproof === false, 'clean not waterproof')
|
|
near(clean.massMul, 1, 'clean massMul 1')
|
|
near(clean.glow, 0, 'clean no glow')
|
|
near(clean.size, 1, 'clean size 1 (no grow/shrink in MVP)')
|
|
}
|
|
{
|
|
// RED just under activation: no buff yet
|
|
const m = computeModifiers(cov({ red: 0.19 }))
|
|
near(m.speedMul, 1, 'red 19% → no speed buff')
|
|
}
|
|
{
|
|
// RED at 45% → speedMul = 1 + 0.6 * buffStrength(0.45)
|
|
const s = 0.15 + 0.85 * 0.5
|
|
const m = computeModifiers(cov({ red: 0.45 }))
|
|
near(m.speedMul, 1 + 0.6 * s, 'red 45% speedMul')
|
|
near(m.massMul, 1 + 0.8 * 0.45, 'massMul tracks total 45%')
|
|
near(m.glow, 0, 'red 45% not super')
|
|
}
|
|
{
|
|
// GREEN super (70%) → grip maxed + glow
|
|
const m = computeModifiers(cov({ green: 0.7 }))
|
|
near(m.grip, 1, 'green 70% grip maxed')
|
|
near(m.glow, 1, 'green 70% is super (glow)')
|
|
}
|
|
{
|
|
// BLUE ≥20% → waterproof
|
|
const m = computeModifiers(cov({ blue: 0.25 }))
|
|
ok(m.waterproof === true, 'blue 25% waterproof')
|
|
}
|
|
{
|
|
// massMul clamps at 1.8 when fully painted
|
|
const m = computeModifiers(cov({ red: 0.5, blue: 0.5 }))
|
|
near(m.massMul, 1.8, 'full coverage massMul 1.8')
|
|
}
|
|
|
|
console.log(`coverage-math.test: ${passed} assertions passed ✓`)
|