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