BLOBBO/src/paint/cannon.ts
Claude e34773d35f harden asset runtime: guard the swap path, bound the boot, reconcile slots
The three that stopped a pack shipping:
 - a mesh-less GLB (armature-only export) fired onSwap anyway, hiding the
   primitive and adding nothing — an invisible prop with a live collider.
   Mesh count is now checked BEFORE the scene is touched, in one guard that
   covers every consumer including slotObject/blob.face.
 - the fulfilment path had no try/catch, so a throw became an unhandled
   rejection. Split in two: a build failure bails out before onSwap can hide
   anything; an onSwap failure keeps the replacement parented, because
   consumers hide their primitive on onSwap's first line and removing the fit
   node there would manufacture the one forbidden state.
 - preload() awaited every url forever. Each is now raced against a 10s
   deadline with allSettled semantics: a stalled host costs one warning and a
   fallback prop, not a game that never boots.

Also:
 - instantiate/instanceSync never throw — createBlob's call site is frozen and
   unguarded, so a throw there is a black screen.
 - ghost and blob share fitBodyToRadius(); a borrowed blob.body was rendering
   the ghost 2.17x oversized with the farm mesh.
 - skinned blob.body is rejected loudly instead of silently half-working; the
   idle-clip mixer is gone (it animated an orphaned skeleton in the fixed step).
 - paintableInfo counts UV islands: blobbo-base.glb passes every other check
   and still paints wrong at 1140 charts. Warning, not rejection.
 - slots.ts gains cannon.base, course.finish, course.tunnel, course.tramp, all
   hooked except tramp (built in frozen game.ts).
 - manifest ignores _-prefixed metadata keys instead of calling them typos.

Empty-manifest parity verified byte-for-byte: scripts/sacred-parity.check.ts
fingerprints the built scene and reports 49df4f20 on main and on this branch.
2026-07-19 12:12:07 +10:00

320 lines
10 KiB
TypeScript

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