Merge lane-b: paint skin, coverage, buffs, cannons, HUD (drop tracked build junk)

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
type-two 2026-07-17 20:18:46 +10:00
commit 827b39be2e
8 changed files with 1458 additions and 1 deletions

View File

@ -1 +1,262 @@
console.log("lane-b demo: pending — lane agent replaces this file")
/**
* Lane B demo the paint core, standalone. A stub white test ball (NOT Lane A's
* blob) orbits between a RED and a GREEN cannon. Paint lands where each glob
* hits, coverage drives buffs (speed/embers/grip/waterproof/mass/glow), and the
* HUD reads it live. Cleanse scrubs it off.
*
* Everything Lane B ships is exercised here: PaintSkin, PaintCannon, BuffSystem,
* PaintHUD, and the inbound event protocol via installPaint().
*
* DEBUG KEYS
* 1 / 2 fire the RED / GREEN cannon at the ball right now
* B / N emit machine:signal for the RED / GREEN cannon's trigger id
* 9 / 0 flood ~80% RED / GREEN (fast path to the super state)
* G emit paint:request-splat on the ball (simulates a Lane C bucket dump)
* K emit paint:request-cleanse {0.5} (simulates a Lane C bubble arch)
* C cleanse 50% (scrub holes) X cleanse 100% (full clean)
* P pause / resume the ball's orbit (watch the paint sit)
* H toggle the HUD
* A state line prints to the console every second: coverage %s, buffs, splats/s.
*/
import * as THREE from 'three'
import { createWorld } from '../world'
import { defaultModifiers, PALETTE } from '../contracts'
import type { PaintColor } from '../contracts'
import {
BuffSystem,
PaintCannon,
PaintHUD,
PaintSkin,
installPaint,
COLOR_ORDER,
} from '../paint'
const RED_SIGNAL = 'sig-red'
const GREEN_SIGNAL = 'sig-green'
const ORBIT_R = 3.6
const BALL_R = 0.6
const world = await createWorld(document.getElementById('app')!)
const { scene, camera, physics, rapier, events } = world
camera.position.set(0, 7.5, 12)
// ---- floor ----------------------------------------------------------------
const floor = new THREE.Mesh(
new THREE.BoxGeometry(40, 1, 40),
new THREE.MeshStandardMaterial({ color: '#e8d9b8' }),
)
floor.position.y = -0.5
floor.receiveShadow = true
scene.add(floor)
physics.createCollider(
rapier.ColliderDesc.cuboid(20, 0.5, 20).setTranslation(0, -0.5, 0).setFriction(1.2),
)
// ---- stub test ball (our own — NOT Lane A's blob) -------------------------
const ballMesh = new THREE.Mesh(
new THREE.SphereGeometry(BALL_R, 48, 32),
new THREE.MeshStandardMaterial({ color: '#F5F5F7', roughness: 0.35, metalness: 0 }),
)
ballMesh.castShadow = true
scene.add(ballMesh)
const ballBody = physics.createRigidBody(
rapier.RigidBodyDesc.dynamic().setTranslation(ORBIT_R, BALL_R + 0.2, 0).setAngularDamping(0.4),
)
physics.createCollider(
rapier.ColliderDesc.ball(BALL_R).setRestitution(0.2).setFriction(1.4).setDensity(2),
ballBody,
)
const baseMass = ballBody.mass()
// the blob-like target the paint systems operate on
const blob = {
mesh: ballMesh,
body: ballBody,
modifiers: defaultModifiers(),
paint: new PaintSkin(ballMesh, { size: 256 }),
}
// ---- paint systems --------------------------------------------------------
const hud = new PaintHUD()
const redCannon = new PaintCannon({
world,
position: new THREE.Vector3(-7, 0, -1.5),
color: 'red',
target: ballMesh,
paint: blob.paint,
targetRadius: BALL_R,
triggerId: RED_SIGNAL,
interval: 0.9,
muzzleSpeed: 15,
splatRadius: 0.3,
})
const greenCannon = new PaintCannon({
world,
position: new THREE.Vector3(7, 0, 1.5),
color: 'green',
target: ballMesh,
paint: blob.paint,
targetRadius: BALL_R,
triggerId: GREEN_SIGNAL,
interval: 1.0,
muzzleSpeed: 15,
splatRadius: 0.3,
})
const buffs = new BuffSystem(world, blob)
// inbound Lane C protocol → this blob (proximity-gated splats + cleanse)
installPaint(world, { mesh: ballMesh, paint: blob.paint })
world.addSystem(redCannon)
world.addSystem(greenCannon)
world.addSystem(buffs)
// ---- ball orbit controller (reads modifiers so buffs visibly change it) ---
let paused = false
world.addSystem({
update(dt: number) {
// paint = weight: reflect massMul in the physics body
ballBody.setAdditionalMass(Math.max(0, baseMass * (blob.modifiers.massMul - 1)), true)
const p = ballBody.translation()
if (!paused) {
// chase a point a little ahead on the ring; force-based so it rolls
const cur = Math.atan2(p.z, p.x)
const targetAngle = cur + 0.6
const tx = Math.cos(targetAngle) * ORBIT_R
const tz = Math.sin(targetAngle) * ORBIT_R
const dx = tx - p.x
const dz = tz - p.z
const len = Math.hypot(dx, dz) || 1
const push = 9 * blob.modifiers.speedMul // BURN makes it faster
ballBody.applyImpulse({ x: (dx / len) * push * dt, y: 0, z: (dz / len) * push * dt }, true)
// cap horizontal speed (scaled by speed buff)
const v = ballBody.linvel()
const sp = Math.hypot(v.x, v.z)
const maxSp = 4.2 * blob.modifiers.speedMul
if (sp > maxSp) {
const k = maxSp / sp
ballBody.setLinvel({ x: v.x * k, y: v.y, z: v.z * k }, true)
}
}
// sync mesh to body (rotation included → paint rides the spinning body)
ballMesh.position.set(p.x, p.y, p.z)
const q = ballBody.rotation()
ballMesh.quaternion.set(q.x, q.y, q.z, q.w)
},
})
// ---- camera ---------------------------------------------------------------
world.onFrame(() => {
camera.lookAt(ballMesh.position.x * 0.3, 0.5, ballMesh.position.z * 0.3)
})
// ---- debug keys -----------------------------------------------------------
function floodColor(color: PaintColor, splats = 130) {
for (let i = 0; i < splats; i++) {
blob.paint.applySplat(
new THREE.Vector2(Math.random(), 0.1 + Math.random() * 0.8),
color,
0.55,
)
}
console.log(`[flood] ${color}${splats} splats`)
}
addEventListener('keydown', (e) => {
switch (e.code) {
case 'Digit1':
redCannon.fire()
break
case 'Digit2':
greenCannon.fire()
break
case 'KeyB':
events.emit('machine:signal', { id: RED_SIGNAL })
console.log('[signal] machine:signal', RED_SIGNAL)
break
case 'KeyN':
events.emit('machine:signal', { id: GREEN_SIGNAL })
console.log('[signal] machine:signal', GREEN_SIGNAL)
break
case 'Digit9':
floodColor('red')
break
case 'Digit0':
floodColor('green')
break
case 'KeyG': {
// simulate a Lane C bucket dump (BLUE) landing on the ball
const pos = ballMesh.position.clone().add(new THREE.Vector3(0, BALL_R, 0))
events.emit('paint:request-splat', { point: pos, color: 'blue' as PaintColor, radius: 0.35 })
console.log('[request-splat] blue on ball')
break
}
case 'KeyK':
events.emit('paint:request-cleanse', { fraction: 0.5 })
console.log('[request-cleanse] 0.5')
break
case 'KeyC':
blob.paint.cleanse(0.5)
console.log('[cleanse] 0.5 (scrub)')
break
case 'KeyX':
blob.paint.cleanse(1)
console.log('[cleanse] full')
break
case 'KeyP':
paused = !paused
console.log(`[orbit] ${paused ? 'paused' : 'running'}`)
break
case 'KeyH':
hud.root.style.display = hud.root.style.display === 'none' ? '' : 'none'
break
}
})
// ---- HUD + console state line ---------------------------------------------
let splatCount = 0
events.on('paint:splatted', (p: { color: PaintColor; target: string }) => {
if (p.target === 'blob') splatCount++
})
let lastLog = performance.now()
world.onFrame(() => {
const cov = blob.paint.coverage()
hud.update(cov, blob.modifiers)
const now = performance.now()
if (now - lastLog >= 1000) {
lastLog = now
const pcts = COLOR_ORDER.filter((c) => cov.byColor[c] > 0.005)
.map((c) => `${c}:${(cov.byColor[c] * 100).toFixed(0)}%`)
.join(' ')
const m = blob.modifiers
const buffStr = [
m.speedMul > 1.001 ? `speed×${m.speedMul.toFixed(2)}` : '',
m.grip > 0.001 ? `grip${m.grip.toFixed(2)}` : '',
m.waterproof ? 'waterproof' : '',
`mass×${m.massMul.toFixed(2)}`,
m.glow > 0 ? 'SUPER' : '',
].filter(Boolean).join(' ')
console.log(
`[paint] total ${(cov.total * 100).toFixed(0)}% | ${pcts || '(clean)'} | ${buffStr} | +${splatCount} splats/s`,
)
splatCount = 0
}
})
// friendly banner
console.log(
'%cBLOBBO Lane B — Paint demo',
`color:${PALETTE.red};font-weight:700`,
)
console.log('keys: 1/2 fire B/N signal 9/0 flood G request-splat K request-cleanse C/X cleanse P pause H hud')
world.start()

159
src/paint/buffs.ts Normal file
View File

@ -0,0 +1,159 @@
/**
* BuffSystem every fixed step, reads the blob's coverage() and writes
* blob.modifiers per GDD §6 (MVP subset), then drives the two cheap visual
* side-effects that make the buff READ on the body: an ember trail while the RED
* BURN buff is active, and an emissive glow pulse in the super state.
*
* The number-crunching lives in computeModifiers() (coverage-math.ts, pure &
* unit-tested); this System just applies it and paints the juice.
*/
import * as THREE from 'three'
import type { BlobModifiers, System, PaintColor } from '../contracts'
import { PALETTE } from '../contracts'
import type { PaintSkin } from './skin'
import {
ACTIVATE,
buffStrength,
computeModifiers,
dominantColor,
superColor,
} from './coverage-math'
export interface BuffTarget {
mesh: THREE.Mesh
modifiers: BlobModifiers
paint?: PaintSkin
}
const EMBER_COUNT = 90
export class BuffSystem implements System {
private readonly scene: THREE.Scene
private readonly blob: BuffTarget
// ember pool
private readonly embers: THREE.Points
private readonly emberPos: Float32Array
private readonly emberCol: Float32Array
private readonly emberVel: Float32Array
private readonly emberLife: Float32Array
private emberCursor = 0
private readonly emberHex = new THREE.Color()
private readonly _spawnAt = new THREE.Vector3()
private clock = 0
constructor(world: { scene: THREE.Scene }, blob: BuffTarget) {
this.scene = world.scene
this.blob = blob
// ember tint: red→orange blend, computed once
this.emberHex.set(PALETTE.red).lerp(new THREE.Color(PALETTE.orange), 0.5)
this.emberPos = new Float32Array(EMBER_COUNT * 3)
this.emberCol = new Float32Array(EMBER_COUNT * 3)
this.emberVel = new Float32Array(EMBER_COUNT * 3)
this.emberLife = new Float32Array(EMBER_COUNT) // 0 = dead
const geo = new THREE.BufferGeometry()
geo.setAttribute('position', new THREE.BufferAttribute(this.emberPos, 3))
geo.setAttribute('color', new THREE.BufferAttribute(this.emberCol, 3))
const mat = new THREE.PointsMaterial({
size: 0.16,
vertexColors: true,
transparent: true,
depthWrite: false,
blending: THREE.AdditiveBlending,
sizeAttenuation: true,
})
this.embers = new THREE.Points(geo, mat)
this.embers.frustumCulled = false
this.scene.add(this.embers)
}
update(dt: number): void {
this.clock += dt
const paint = this.blob.paint
if (!paint) return
const cov = paint.coverage() // 250ms-cached; no per-frame readback
const mods = computeModifiers(cov)
// copy into the shared modifiers object (readers hold this reference)
const m = this.blob.modifiers
m.speedMul = mods.speedMul
m.jumpMul = mods.jumpMul
m.massMul = mods.massMul
m.grip = mods.grip
m.size = mods.size
m.waterproof = mods.waterproof
m.glow = mods.glow
this.updateEmbers(dt, cov.byColor.red)
this.updateGlow(mods.glow, superColor(cov) ?? dominantColor(cov))
}
dispose(): void {
this.scene.remove(this.embers)
this.embers.geometry.dispose()
;(this.embers.material as THREE.Material).dispose()
}
// ---- ember trail (RED BURN) ---------------------------------------------
private updateEmbers(dt: number, red: number): void {
const strength = buffStrength(red) // 0 below 20%
if (red >= ACTIVATE) {
// spawn rate scales with how hot the blob is
const spawn = strength * 3
let n = Math.floor(spawn) + (Math.random() < spawn % 1 ? 1 : 0)
this.blob.mesh.getWorldPosition(this._spawnAt)
while (n-- > 0) this.spawnEmber(this._spawnAt)
}
const emberHex = this.emberHex
for (let i = 0; i < EMBER_COUNT; i++) {
if (this.emberLife[i] <= 0) continue
this.emberLife[i] -= dt
const j = i * 3
if (this.emberLife[i] <= 0) {
this.emberCol[j] = this.emberCol[j + 1] = this.emberCol[j + 2] = 0 // invisible
continue
}
this.emberVel[j + 1] += 2.2 * dt // buoyant rise
this.emberPos[j] += this.emberVel[j] * dt
this.emberPos[j + 1] += this.emberVel[j + 1] * dt
this.emberPos[j + 2] += this.emberVel[j + 2] * dt
const fade = Math.min(1, this.emberLife[i] / 0.5)
this.emberCol[j] = emberHex.r * fade
this.emberCol[j + 1] = emberHex.g * fade
this.emberCol[j + 2] = emberHex.b * fade
}
this.embers.geometry.attributes.position.needsUpdate = true
this.embers.geometry.attributes.color.needsUpdate = true
}
private spawnEmber(center: THREE.Vector3): void {
const i = this.emberCursor
this.emberCursor = (this.emberCursor + 1) % EMBER_COUNT
const j = i * 3
this.emberPos[j] = center.x + (Math.random() - 0.5) * 0.5
this.emberPos[j + 1] = center.y + (Math.random() - 0.3) * 0.4
this.emberPos[j + 2] = center.z + (Math.random() - 0.5) * 0.5
this.emberVel[j] = (Math.random() - 0.5) * 0.8
this.emberVel[j + 1] = 0.6 + Math.random() * 0.8
this.emberVel[j + 2] = (Math.random() - 0.5) * 0.8
this.emberLife[i] = 0.5 + Math.random() * 0.4
}
// ---- glow (super state) --------------------------------------------------
private updateGlow(glow: number, color: PaintColor | null): void {
const mat = this.blob.mesh.material as THREE.MeshStandardMaterial
if (!('emissive' in mat)) return
if (glow > 0 && color) {
const pulse = 0.55 + 0.45 * Math.sin(this.clock * 8)
mat.emissive.set(PALETTE[color])
mat.emissiveIntensity = glow * pulse * 1.4
} else {
mat.emissiveIntensity = 0
}
}
}

305
src/paint/cannon.ts Normal file
View File

@ -0,0 +1,305 @@
/**
* PaintCannon a placed emitter that lobs paint-glob projectiles on a gravity
* arc. On hitting the blob it stamps a splat exactly where it struck (the magic
* moment) and emits `paint:splatted`. Fires on an interval AND on
* `machine:signal {id}` matching its trigger id (Lane C wires the signals).
*
* Projectiles are simulated by a manual sweep (no Rapier collider churn): each
* step advances position under gravity and does a swept sphere-vs-sphere test
* against the target so fast shots can't tunnel through.
*/
import * as THREE from 'three'
import type { PaintColor, System, World } from '../contracts'
import { PALETTE } from '../contracts'
import type { PaintSkin } from './skin'
export interface PaintCannonConfig {
world: World
position: THREE.Vector3
color: PaintColor
/** Aim target (usually the blob mesh) — sampled at fire time for a lead shot. */
target: THREE.Object3D
/** Skin to stamp on a blob hit. */
paint: PaintSkin
/** Target bounding radius (world units) for the hit test. */
targetRadius: number
/** Fire on `machine:signal {id}` when id === triggerId. */
triggerId?: string
/** Seconds between auto-fires. 0/undefined disables the auto cadence. */
interval?: number
muzzleSpeed?: number
projRadius?: number
/** World-space splat radius handed to the skin on hit. */
splatRadius?: number
/** Gravity magnitude for the arc (default 14, matching world.ts). */
gravity?: number
/** y below which a projectile is considered to have hit the ground. */
groundY?: number
/** Lead a moving target using its estimated velocity (default true). */
lead?: boolean
}
interface Projectile {
mesh: THREE.Mesh
vel: THREE.Vector3
prev: THREE.Vector3
life: number
}
const MAX_LIFE = 6 // seconds before a stray glob is culled
export class PaintCannon implements System {
readonly color: PaintColor
private readonly cfg: Required<
Omit<PaintCannonConfig, 'triggerId'>
> & { triggerId?: string }
private readonly scene: THREE.Scene
private readonly projectiles: Projectile[] = []
private readonly projGeo: THREE.SphereGeometry
private readonly projMat: THREE.MeshStandardMaterial
private readonly barrel: THREE.Group
private acc = 0
private readonly unsub: () => void
// target velocity estimate (for leading a moving blob)
private readonly _lastTarget = new THREE.Vector3()
private readonly _targetVel = new THREE.Vector3()
private hasLast = false
// scratch
private readonly _tpos = new THREE.Vector3()
private readonly _aim = new THREE.Vector3()
private readonly _hit = new THREE.Vector3()
private readonly _vtmp = new THREE.Vector3()
constructor(config: PaintCannonConfig) {
this.color = config.color
this.cfg = {
interval: 0,
muzzleSpeed: 16,
projRadius: 0.12,
splatRadius: 0.28,
gravity: 14,
groundY: 0,
lead: true,
...config,
}
this.scene = config.world.scene
const hex = PALETTE[config.color]
this.projGeo = new THREE.SphereGeometry(this.cfg.projRadius, 12, 10)
this.projMat = new THREE.MeshStandardMaterial({
color: hex,
emissive: hex,
emissiveIntensity: 0.35,
roughness: 0.4,
})
this.barrel = this.buildBarrel(hex)
this.barrel.position.copy(config.position)
this.scene.add(this.barrel)
this.unsub = config.world.events.on('machine:signal', (p: { id: string }) => {
if (this.cfg.triggerId && p?.id === this.cfg.triggerId) this.fire()
})
}
/** Fire one glob now, leading the target with a ballistic solve. */
fire(): void {
this.target(this._tpos)
const from = this.muzzleWorld()
// Lead: aim where the target will be after the glob's ~time-of-flight.
this._aim.copy(this._tpos)
if (this.cfg.lead && this.hasLast) {
const flight = from.distanceTo(this._tpos) / this.cfg.muzzleSpeed
this._aim.addScaledVector(this._targetVel, flight)
}
const vel =
solveBallisticVelocity(from, this._aim, this.cfg.muzzleSpeed, this.cfg.gravity) ??
this._aim.clone().sub(from).normalize().multiplyScalar(this.cfg.muzzleSpeed)
// Point the barrel along the launch direction.
this.aimBarrel(vel)
const mesh = new THREE.Mesh(this.projGeo, this.projMat)
mesh.castShadow = true
mesh.position.copy(from)
this.scene.add(mesh)
this.projectiles.push({
mesh,
vel: vel.clone(),
prev: from.clone(),
life: 0,
})
}
update(dt: number): void {
// maintain a smoothed target-velocity estimate for leading
if (dt > 0) {
this.target(this._tpos)
if (this.hasLast) {
const inv = 1 / dt
this._vtmp.copy(this._tpos).sub(this._lastTarget).multiplyScalar(inv)
this._targetVel.lerp(this._vtmp, 0.4)
}
this._lastTarget.copy(this._tpos)
this.hasLast = true
}
// auto cadence
if (this.cfg.interval > 0) {
this.acc += dt
while (this.acc >= this.cfg.interval) {
this.acc -= this.cfg.interval
this.fire()
}
}
if (this.projectiles.length === 0) return
this.target(this._tpos)
const hitDist = this.cfg.targetRadius + this.cfg.projRadius
for (let i = this.projectiles.length - 1; i >= 0; i--) {
const p = this.projectiles[i]
p.prev.copy(p.mesh.position)
p.vel.y -= this.cfg.gravity * dt
p.mesh.position.addScaledVector(p.vel, dt)
p.life += dt
// swept hit test against the target sphere (prev -> current segment)
const d = closestDistToSegment(this._tpos, p.prev, p.mesh.position, this._hit)
if (d <= hitDist) {
this.cfg.paint.splatAtPoint(this._hit, this.color, this.cfg.splatRadius)
this.cfg.world.events.emit('paint:splatted', {
color: this.color,
target: 'blob',
})
this.despawn(i)
continue
}
if (p.mesh.position.y <= this.cfg.groundY || p.life > MAX_LIFE) {
// Ground/world decals are V1; just despawn (protocol-complete signal).
this.cfg.world.events.emit('paint:splatted', {
color: this.color,
target: 'world',
})
this.despawn(i)
}
}
}
dispose(): void {
this.unsub()
for (let i = this.projectiles.length - 1; i >= 0; i--) this.despawn(i)
this.scene.remove(this.barrel)
this.projGeo.dispose()
this.projMat.dispose()
}
// ---- internals ----------------------------------------------------------
private target(out: THREE.Vector3): THREE.Vector3 {
return this.cfg.target.getWorldPosition(out)
}
private muzzleWorld(): THREE.Vector3 {
// muzzle a little in front of / above the barrel base
return this.cfg.position.clone().add(new THREE.Vector3(0, 0.55, 0))
}
private despawn(i: number): void {
const p = this.projectiles[i]
this.scene.remove(p.mesh)
this.projectiles.splice(i, 1)
}
private buildBarrel(hex: string): THREE.Group {
const g = new THREE.Group()
const base = new THREE.Mesh(
new THREE.CylinderGeometry(0.45, 0.55, 0.5, 16),
new THREE.MeshStandardMaterial({ color: '#3a3a44', roughness: 0.7 }),
)
base.castShadow = true
g.add(base)
const tube = new THREE.Mesh(
new THREE.CylinderGeometry(0.22, 0.28, 1.1, 16),
new THREE.MeshStandardMaterial({ color: hex, roughness: 0.5 }),
)
tube.castShadow = true
// pivot the tube from the base and tilt it up; child group lets us aim it
const pivot = new THREE.Group()
pivot.position.y = 0.45
tube.position.y = 0.4
tube.rotation.x = Math.PI / 2 // point +Z by default
pivot.add(tube)
pivot.name = 'pivot'
g.add(pivot)
return g
}
private aimBarrel(vel: THREE.Vector3): void {
const pivot = this.barrel.getObjectByName('pivot')
if (!pivot) return
const dir = vel.clone().normalize()
const m = new THREE.Matrix4().lookAt(
new THREE.Vector3(0, 0, 0),
dir,
new THREE.Vector3(0, 1, 0),
)
pivot.quaternion.setFromRotationMatrix(m)
}
}
/**
* Ballistic launch velocity to hit `to` from `from` at fixed `speed` under
* gravity magnitude `g` (down -y). Returns the LOW-arc solution, or null if the
* target is out of range. Exported for potential reuse/testing.
*/
export function solveBallisticVelocity(
from: THREE.Vector3,
to: THREE.Vector3,
speed: number,
g: number,
): THREE.Vector3 | null {
const dx = to.x - from.x
const dz = to.z - from.z
const dy = to.y - from.y
const x = Math.hypot(dx, dz)
if (x < 1e-3) {
return new THREE.Vector3(0, Math.sign(dy || 1) * speed, 0)
}
const s2 = speed * speed
const disc = s2 * s2 - g * (g * x * x + 2 * dy * s2)
if (disc < 0) return null
const root = Math.sqrt(disc)
const tanTheta = (s2 - root) / (g * x) // low arc
const theta = Math.atan(tanTheta)
const hx = dx / x
const hz = dz / x
const vh = speed * Math.cos(theta)
const vy = speed * Math.sin(theta)
return new THREE.Vector3(hx * vh, vy, hz * vh)
}
/** Closest distance from point `p` to segment `a`->`b`; writes the point to `out`. */
function closestDistToSegment(
p: THREE.Vector3,
a: THREE.Vector3,
b: THREE.Vector3,
out: THREE.Vector3,
): number {
const abx = b.x - a.x
const aby = b.y - a.y
const abz = b.z - a.z
const len2 = abx * abx + aby * aby + abz * abz
let t = 0
if (len2 > 1e-12) {
t = ((p.x - a.x) * abx + (p.y - a.y) * aby + (p.z - a.z) * abz) / len2
t = t < 0 ? 0 : t > 1 ? 1 : t
}
out.set(a.x + abx * t, a.y + aby * t, a.z + abz * t)
return out.distanceTo(p)
}

View File

@ -0,0 +1,117 @@
/**
* 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 ✓`)

127
src/paint/coverage-math.ts Normal file
View File

@ -0,0 +1,127 @@
/**
* 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,
}
}

106
src/paint/hud.ts Normal file
View File

@ -0,0 +1,106 @@
/**
* PaintHUD a tiny DOM overlay: per-colour coverage bars + the active buff kit.
* No framework, no per-frame layout thrash (throttled to ~10Hz and only rewrites
* text/width, never rebuilds nodes).
*/
import type { BlobModifiers, CoverageReport, PaintColor } from '../contracts'
import { PALETTE } from '../contracts'
import { COLOR_ORDER, ACTIVATE, SUPER } from './coverage-math'
export class PaintHUD {
readonly root: HTMLDivElement
private readonly bars = new Map<PaintColor, { fill: HTMLDivElement; pct: HTMLSpanElement }>()
private readonly totalPct: HTMLSpanElement
private readonly buffLine: HTMLDivElement
private lastUpdate = -1e9
constructor(container: HTMLElement = document.body) {
const root = document.createElement('div')
root.style.cssText = [
'position:fixed', 'top:12px', 'left:12px', 'z-index:10',
'font:12px/1.4 ui-monospace,SFMono-Regular,Menlo,monospace',
'color:#fff', 'background:rgba(18,18,24,0.72)', 'padding:10px 12px',
'border-radius:10px', 'min-width:210px', 'user-select:none',
'box-shadow:0 4px 18px rgba(0,0,0,0.35)', 'backdrop-filter:blur(4px)',
].join(';')
const title = document.createElement('div')
title.textContent = 'PAINT COVERAGE'
title.style.cssText = 'font-weight:700;letter-spacing:0.08em;opacity:0.85;margin-bottom:8px'
root.appendChild(title)
for (const color of COLOR_ORDER) {
const row = document.createElement('div')
row.style.cssText = 'display:flex;align-items:center;gap:6px;margin:3px 0'
const swatch = document.createElement('div')
swatch.style.cssText = `width:10px;height:10px;border-radius:3px;background:${PALETTE[color]};flex:0 0 auto`
row.appendChild(swatch)
const track = document.createElement('div')
track.style.cssText = 'flex:1;height:8px;background:rgba(255,255,255,0.12);border-radius:4px;overflow:hidden'
const fill = document.createElement('div')
fill.style.cssText = `height:100%;width:0%;background:${PALETTE[color]};transition:width 0.12s linear`
track.appendChild(fill)
row.appendChild(track)
const pct = document.createElement('span')
pct.textContent = '0%'
pct.style.cssText = 'width:34px;text-align:right;opacity:0.9'
row.appendChild(pct)
root.appendChild(row)
this.bars.set(color, { fill, pct })
}
const totalRow = document.createElement('div')
totalRow.style.cssText = 'margin-top:8px;border-top:1px solid rgba(255,255,255,0.15);padding-top:6px'
totalRow.innerHTML = 'total painted: '
this.totalPct = document.createElement('span')
this.totalPct.textContent = '0%'
this.totalPct.style.fontWeight = '700'
totalRow.appendChild(this.totalPct)
root.appendChild(totalRow)
this.buffLine = document.createElement('div')
this.buffLine.style.cssText = 'margin-top:6px;opacity:0.95;min-height:16px'
root.appendChild(this.buffLine)
container.appendChild(root)
this.root = root
}
/** Call each frame; internally throttled to ~10Hz. */
update(cov: CoverageReport, mods: BlobModifiers): void {
const now = performance.now()
if (now - this.lastUpdate < 100) return
this.lastUpdate = now
for (const color of COLOR_ORDER) {
const b = this.bars.get(color)!
const v = cov.byColor[color]
b.fill.style.width = `${Math.min(100, v * 100).toFixed(0)}%`
b.pct.textContent = `${(v * 100).toFixed(0)}%`
const active = v >= ACTIVATE
b.pct.style.opacity = active ? '1' : '0.5'
b.fill.style.boxShadow = v >= SUPER ? `0 0 8px ${PALETTE[color]}` : 'none'
}
this.totalPct.textContent = `${(cov.total * 100).toFixed(0)}%`
this.buffLine.innerHTML = this.buffText(cov, mods)
}
private buffText(cov: CoverageReport, mods: BlobModifiers): string {
const parts: string[] = []
if (cov.byColor.red >= ACTIVATE) parts.push(`<b style="color:${PALETTE.red}">BURN</b> ×${mods.speedMul.toFixed(2)}`)
if (cov.byColor.green >= ACTIVATE) parts.push(`<b style="color:${PALETTE.green}">GRIP</b> ${mods.grip.toFixed(2)}`)
if (cov.byColor.blue >= ACTIVATE) parts.push(`<b style="color:${PALETTE.blue}">SLICK</b> waterproof`)
parts.push(`mass ×${mods.massMul.toFixed(2)}`)
if (mods.glow > 0) parts.push('<b style="color:#fff;text-shadow:0 0 6px #fff">★ SUPER</b>')
return parts.length ? parts.join(' &nbsp;·&nbsp; ') : '<span style="opacity:0.5">no buffs</span>'
}
dispose(): void {
this.root.remove()
}
}

74
src/paint/index.ts Normal file
View File

@ -0,0 +1,74 @@
/**
* 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,
} 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()
}
}

308
src/paint/skin.ts Normal file
View File

@ -0,0 +1,308 @@
/**
* PaintSkin the core invention. A Canvas-2D colour mask painted onto a mesh's
* UVs, wrapped in a THREE.CanvasTexture that becomes the body's material.map over
* the white base. Splats are stamped WHERE the projectile hit (raycast face UV),
* and per-colour coverage is an exact integer histogram of a quantized mask.
*
* Two coupled representations, generated from the SAME set of sub-circles per
* splat so they always agree spatially:
* - `canvas` : soft-edged, irregular, pretty this is what you SEE.
* - `mask` : a Uint8Array of palette indices (0 = base, 1..7 = colour),
* hard-edged this is what we COUNT. Quantized on stamp, so
* coverage() is exact bucketing, never a fuzzy colour match, and
* we NEVER call getImageData (the real per-frame perf killer).
*/
import * as THREE from 'three'
import type { CoverageReport, PaintColor, PaintSkin as IPaintSkin } from '../contracts'
import { BASE_WHITE, PALETTE } from '../contracts'
import {
COLOR_ORDER,
colorIndex,
countBuckets,
coverageReportFromCounts,
} from './coverage-math'
export interface PaintSkinOptions {
/** Mask/texture resolution (square). MVP default 256. */
size?: number
/**
* How many canvas pixels one world-unit of splat radius maps to, expressed as
* a fraction of the texture width per (radius / boundingRadius). Tune for splat
* size on screen; exact value is cosmetic (coverage is area-based regardless).
*/
splatScale?: number
}
interface SubCircle {
x: number
y: number
r: number
}
export class PaintSkin implements IPaintSkin {
readonly canvas: HTMLCanvasElement
readonly texture: THREE.CanvasTexture
readonly boundingRadius: number
private readonly mesh: THREE.Mesh
private readonly ctx: CanvasRenderingContext2D
private readonly size: number
private readonly splatScale: number
private readonly mask: Uint8Array
private readonly raycaster = new THREE.Raycaster()
// 250ms coverage cache (never recount more often than that).
private cache: CoverageReport | null = null
private cacheTime = -1e9
// scratch vectors (avoid per-splat allocation)
private readonly _center = new THREE.Vector3()
private readonly _dir = new THREE.Vector3()
private readonly _origin = new THREE.Vector3()
constructor(mesh: THREE.Mesh, opts: PaintSkinOptions = {}) {
this.mesh = mesh
this.size = opts.size ?? 256
this.splatScale = opts.splatScale ?? 0.16
mesh.geometry.computeBoundingSphere()
this.boundingRadius = mesh.geometry.boundingSphere?.radius ?? 1
const canvas = document.createElement('canvas')
canvas.width = canvas.height = this.size
const ctx = canvas.getContext('2d')
if (!ctx) throw new Error('PaintSkin: 2D canvas context unavailable')
ctx.fillStyle = BASE_WHITE
ctx.fillRect(0, 0, this.size, this.size)
this.canvas = canvas
this.ctx = ctx
this.mask = new Uint8Array(this.size * this.size) // 0 everywhere = all base
const tex = new THREE.CanvasTexture(canvas)
tex.colorSpace = THREE.SRGBColorSpace
tex.wrapS = THREE.RepeatWrapping // U seam wraps (sphere longitude)
tex.wrapT = THREE.ClampToEdgeWrapping
tex.anisotropy = 4
this.texture = tex
// Attach as the base colour map. Keep material colour white so the map isn't
// tinted; the canvas already carries the white base.
const mat = mesh.material as THREE.MeshStandardMaterial
mat.map = tex
if (mat.color) mat.color.set('#ffffff')
mat.needsUpdate = true
}
// ---- public API (contracts.PaintSkin) -----------------------------------
applySplat(uv: THREE.Vector2, color: PaintColor, radius: number): void {
const px = uv.x * this.size
// CanvasTexture default flipY=true: uv v=0 samples canvas bottom row.
const py = (1 - uv.y) * this.size
const pr = this.pixelRadius(radius)
const circles = this.makeSplatCircles(px, py, pr)
this.stampMask(circles, colorIndex(color))
this.stampCanvas(circles, PALETTE[color])
this.texture.needsUpdate = true
}
splatAtPoint(worldPoint: THREE.Vector3, color: PaintColor, radius: number): void {
const uv = this.uvAtWorldPoint(worldPoint)
if (uv) this.applySplat(uv, color, radius)
}
coverage(): CoverageReport {
const now = performance.now()
if (this.cache && now - this.cacheTime < 250) return this.cache
const counts = countBuckets(this.mask, COLOR_ORDER.length)
this.cache = coverageReportFromCounts(counts, this.mask.length)
this.cacheTime = now
return this.cache
}
cleanse(fraction: number): void {
const f = Math.max(0, Math.min(1, fraction))
if (f <= 0) return
if (f >= 1) {
// Full clean: reset everything to base.
this.mask.fill(0)
this.ctx.fillStyle = BASE_WHITE
this.ctx.fillRect(0, 0, this.size, this.size)
this.invalidate()
return
}
// Partial: punch random circular "scrub" holes until ~fraction of the
// currently-painted pixels are erased. Looks scrubbed rather than uniform.
let painted = 0
for (let i = 0; i < this.mask.length; i++) if (this.mask[i] !== 0) painted++
if (painted === 0) return
let target = Math.floor(painted * f)
this.ctx.fillStyle = BASE_WHITE
let guard = 0
while (target > 0 && guard++ < 20000) {
const cx = Math.random() * this.size
const cy = Math.random() * this.size
const r = 6 + Math.random() * 12
target -= this.eraseCircle(cx, cy, r)
}
this.invalidate()
}
// ---- geometry: world point -> UV ----------------------------------------
private uvAtWorldPoint(worldPoint: THREE.Vector3): THREE.Vector2 | null {
this.mesh.updateWorldMatrix(true, false)
this.mesh.getWorldPosition(this._center)
this._dir.copy(worldPoint).sub(this._center)
if (this._dir.lengthSq() < 1e-12) return null
this._dir.normalize()
// Cast from just outside the bounding sphere, straight through the centre.
// The near-surface hit is the point closest to the impact; THREE gives us the
// barycentric-interpolated UV for free.
const worldRadius = this.worldBoundingRadius()
this._origin.copy(this._center).addScaledVector(this._dir, worldRadius * 2.2)
this.raycaster.set(this._origin, this._dir.clone().negate())
this.raycaster.near = 0
this.raycaster.far = worldRadius * 4.4
const hits = this.raycaster.intersectObject(this.mesh, false)
const hit = hits.find((h) => h.uv)
return hit?.uv ? hit.uv.clone() : null
}
private worldBoundingRadius(): number {
const s = this.mesh.getWorldScale(this._origin) // reuse scratch
return this.boundingRadius * Math.max(s.x, s.y, s.z)
}
private pixelRadius(radius: number): number {
const px = (radius / this.boundingRadius) * this.size * this.splatScale
return Math.max(4, Math.min(this.size * 0.4, px))
}
// ---- splat rasterization (mask + canvas share the same circles) ----------
private makeSplatCircles(px: number, py: number, pr: number): SubCircle[] {
const circles: SubCircle[] = [{ x: px, y: py, r: pr * 0.95 }]
// irregular lobes
const lobes = 3 + Math.floor(Math.random() * 3)
for (let i = 0; i < lobes; i++) {
const a = Math.random() * Math.PI * 2
const d = pr * (0.35 + Math.random() * 0.55)
circles.push({
x: px + Math.cos(a) * d,
y: py + Math.sin(a) * d,
r: pr * (0.3 + Math.random() * 0.35),
})
}
// stray droplets
const drops = 2 + Math.floor(Math.random() * 3)
for (let i = 0; i < drops; i++) {
const a = Math.random() * Math.PI * 2
const d = pr * (1.0 + Math.random() * 0.7)
circles.push({
x: px + Math.cos(a) * d,
y: py + Math.sin(a) * d,
r: pr * (0.08 + Math.random() * 0.14),
})
}
return circles
}
/** Hard-edged union of circles into the quantized mask, with U-seam wrap. */
private stampMask(circles: SubCircle[], idx: number): void {
const S = this.size
for (const c of circles) {
// Wrap in X (longitude seam). Draw the circle and, if near an edge, its
// wrapped twin so a stamp straddling u=0/1 lands on both sides.
const offsets = this.wrapOffsets(c.x, c.r, S)
for (const ox of offsets) {
const cx = c.x + ox
const r2 = c.r * c.r
const x0 = Math.max(0, Math.floor(cx - c.r))
const x1 = Math.min(S - 1, Math.ceil(cx + c.r))
const y0 = Math.max(0, Math.floor(c.y - c.r))
const y1 = Math.min(S - 1, Math.ceil(c.y + c.r))
for (let y = y0; y <= y1; y++) {
const dy = y - c.y
for (let x = x0; x <= x1; x++) {
const dx = x - cx
if (dx * dx + dy * dy <= r2) this.mask[y * S + x] = idx
}
}
}
}
}
/** Soft, alpha-blended gradient blobs into the visible canvas, with wrap. */
private stampCanvas(circles: SubCircle[], hex: string): void {
const S = this.size
const ctx = this.ctx
const rgb = hexToRgb(hex)
for (const c of circles) {
const offsets = this.wrapOffsets(c.x, c.r, S)
for (const ox of offsets) {
const cx = c.x + ox
const g = ctx.createRadialGradient(cx, c.y, c.r * 0.15, cx, c.y, c.r)
g.addColorStop(0, `rgba(${rgb},1)`)
g.addColorStop(0.7, `rgba(${rgb},0.95)`)
g.addColorStop(1, `rgba(${rgb},0)`)
ctx.fillStyle = g
ctx.beginPath()
ctx.arc(cx, c.y, c.r, 0, Math.PI * 2)
ctx.fill()
}
}
}
/** Erase a scrub hole from both mask and canvas; returns pixels erased. */
private eraseCircle(cx: number, cy: number, r: number): number {
const S = this.size
let erased = 0
const r2 = r * r
const x0 = Math.max(0, Math.floor(cx - r))
const x1 = Math.min(S - 1, Math.ceil(cx + r))
const y0 = Math.max(0, Math.floor(cy - r))
const y1 = Math.min(S - 1, Math.ceil(cy + r))
for (let y = y0; y <= y1; y++) {
const dy = y - cy
for (let x = x0; x <= x1; x++) {
const dx = x - cx
if (dx * dx + dy * dy <= r2) {
const i = y * S + x
if (this.mask[i] !== 0) {
this.mask[i] = 0
erased++
}
}
}
}
this.ctx.beginPath()
this.ctx.arc(cx, cy, r, 0, Math.PI * 2)
this.ctx.fill() // fillStyle already BASE_WHITE at call sites
return erased
}
private wrapOffsets(x: number, r: number, S: number): number[] {
if (x - r < 0) return [0, S]
if (x + r > S) return [0, -S]
return [0]
}
private invalidate(): void {
this.texture.needsUpdate = true
this.cache = null
this.cacheTime = -1e9
}
}
function hexToRgb(hex: string): string {
const h = hex.replace('#', '')
const n = parseInt(h, 16)
return `${(n >> 16) & 255},${(n >> 8) & 255},${n & 255}`
}