BLOBBO/src/paint/buffs.ts
type-two 3699a1635a Lane G: colour zones, paint puddles, MEGA/MINI scale buffs
Fixes the live finding that a racer can finish with 0% paint. Paint is now
ROUTE-guaranteed by ground puddles, and colours come in single-colour zones so
buffs actually cross the 20% threshold.

- paint/puddles.ts: PaintPuddle + installPuddles. Flat coloured strips (no
  collider) that stamp the blob's underside via skin.splatAtPoint on a ~6/s
  cadence while rolling through, emitting paint:splatted. The body doesn't
  physically tumble (rotations locked), so an accumulated roll phase walks the
  contact point along the travel great circle — a straight run paints a stripe
  and coverage climbs instead of piling on the south pole.
- paint/coverage-math.ts: extend computeModifiers with purple MEGA (size→1.45 +
  heavier) and pink MINI (size→0.62 + lighter than clean at high %). They fight:
  net size uses (purple − pink) strength; mass floored so MINI stays positive.
  (Lives here, not buffs.ts, because the pure mapping must stay THREE-free for
  the bare-node test; BuffSystem already forwards mods.size/massMul unchanged.)
- paint/coverage-math.test.ts: +MEGA/MINI/fight cases (48 assertions pass).
- course/zones.ts: installZones lays RED/GREEN/BLUE puddles down the centre line
  and the PURPLE(left)/PINK(right) fork onto the machine district — a pressure
  plate (baseMass*1.25) + boot on the MEGA lane, a visibly-low pink tunnel
  (underside y≈1.12) on the MINI lane. Returns cannon configs for integration.
- demo (demos/lane-g.html + src/demo/lane-g.ts): drive the zones, F = hands-free
  hold-forward, 1-4 teleport to each zone, live coverage/size/mass + plate HUD.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-18 12:00:54 +10:00

163 lines
5.7 KiB
TypeScript

/**
* 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).
// NOTE: `size` + `massMul` now also carry the purple MEGA / pink MINI scale
// buffs (Lane G) straight out of computeModifiers — the controller reads
// `size` to scale the collider + visuals, so no extra work is needed here.
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
}
}
}