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:
commit
827b39be2e
@ -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
159
src/paint/buffs.ts
Normal 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
305
src/paint/cannon.ts
Normal 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)
|
||||
}
|
||||
117
src/paint/coverage-math.test.ts
Normal file
117
src/paint/coverage-math.test.ts
Normal 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
127
src/paint/coverage-math.ts
Normal 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
106
src/paint/hud.ts
Normal 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(' · ') : '<span style="opacity:0.5">no buffs</span>'
|
||||
}
|
||||
|
||||
dispose(): void {
|
||||
this.root.remove()
|
||||
}
|
||||
}
|
||||
74
src/paint/index.ts
Normal file
74
src/paint/index.ts
Normal 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
308
src/paint/skin.ts
Normal 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}`
|
||||
}
|
||||
Loading…
Reference in New Issue
Block a user