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>
163 lines
5.7 KiB
TypeScript
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
|
|
}
|
|
}
|
|
}
|