/** * 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' import { assets } from '../assets/registry' 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 > & { 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() // Slot `cannon.barrel` decorates the base; the named `pivot` child and its // local +Z stay procedural because aimBarrel() steers them every step. 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) // Two slots, because they are two different jobs: the base is a static // stand, the barrel is the thing that aims. Filling only one leaves the // other primitive in place. `cannon.barrel` is attached to the whole group // rather than the pivot so a barrel model does not inherit the aim spin — // the procedural tube stays hidden either way. assets().attachSlot('cannon.base', g, { onSwap: () => { base.visible = false }, }) assets().attachSlot('cannon.barrel', g, { onSwap: () => { tube.visible = false }, }) 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) }