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.
320 lines
10 KiB
TypeScript
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)
|
|
}
|