Custom GLBs drop into 14 named slots without code changes; an empty manifest
produces today's game by construction (the fallback builders are the original
code moved into a closure, and an empty registry returns the caller's own
object by identity).
- src/assets/{slots,manifest,idb,registry,blobBody}.ts — schema + validation,
GLTF cache with per-instance material cloning, fit nodes, IndexedDB override
layer, paintability report.
- Slot hooks in createBlob, parts, cannon, greybox, puddles, ghost. Mesh-only:
no collider, physics or logic line is touched. Animated sub-parts (plate cap,
belt chevrons, fan blades, cannon pivot) stay procedural so a custom model
cannot stop them moving.
- public/assets/ — the build had NO asset copy step at all, so every asset URL
would have 404'd in production. public/ is vite's default publicDir, so this
needs no vite.config change.
- Tests: 63 headless checks + a farm-GLB audit that fires PaintSkin's own
raycast against the fitted body.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
773 lines
27 KiB
TypeScript
773 lines
27 KiB
TypeScript
/**
|
|
* PARTS — the Rube-Goldberg contraption toolkit (GDD §5.4).
|
|
*
|
|
* Every part is data-placeable: a factory takes `(world, config)` where config is
|
|
* a plain `{ id, position, ... , onSignal?, emits? }` object, wires up its own
|
|
* physics + visuals, and chains to other parts ONLY through `world.events`:
|
|
* - a part with `emits` fires `machine:signal { id: emits }` when it activates
|
|
* - a part with `onSignal` listens for `machine:signal` and acts when the id matches
|
|
* There are NO hardcoded references between part instances — the wiring is the
|
|
* config strings, so the whole course is pure data.
|
|
*
|
|
* PILLAR: every part that applies an impulse or dumps paint plays a mandatory
|
|
* ~0.5s telegraph windup first (see telegraph.ts). SpringBoot and BucketDump do.
|
|
* PressurePlate is an input sensor (no force). ConveyorBelt / Fan / BubbleArch are
|
|
* ambient continuous fields whose constant motion (sliding belt, spinning blades,
|
|
* bubbling arch) is itself the readable, always-on telegraph.
|
|
*
|
|
* Communicates with paint (lane B) only via events: `paint:request-splat`,
|
|
* `paint:request-cleanse`. Never imports src/paint or src/blob.
|
|
*/
|
|
import * as THREE from 'three'
|
|
import type RAPIER from '@dimforge/rapier3d-compat'
|
|
import type { World, PaintColor } from '../contracts'
|
|
import { PALETTE } from '../contracts'
|
|
import { telegraph, isTelegraphing } from './telegraph'
|
|
import { assets } from '../assets/registry'
|
|
|
|
/**
|
|
* Slot hook shared by every part: park the custom model under the part's root
|
|
* and hide the primitives it replaces. Only the primitives listed in `replaces`
|
|
* go away — animated sub-parts (the plate cap, the belt chevrons, the fan
|
|
* blades) stay procedural so they keep moving.
|
|
*/
|
|
function slotPart(slot: Parameters<ReturnType<typeof assets>['attachSlot']>[0],
|
|
root: THREE.Object3D, replaces: THREE.Object3D[]): void {
|
|
assets().attachSlot(slot, root, {
|
|
onSwap: () => { for (const o of replaces) o.visible = false },
|
|
})
|
|
}
|
|
|
|
export type Vec3 = [number, number, number]
|
|
|
|
export interface MachinePart {
|
|
id: string
|
|
group: THREE.Group
|
|
}
|
|
|
|
const IDENTITY_ROT = { x: 0, y: 0, z: 0, w: 1 }
|
|
|
|
const v = (p: Vec3) => ({ x: p[0], y: p[1], z: p[2] })
|
|
const asVec3 = (p: Vec3) => new THREE.Vector3(p[0], p[1], p[2])
|
|
|
|
/** All *dynamic* rigid bodies whose colliders overlap an axis-aligned box. */
|
|
function dynamicBodiesInBox(
|
|
world: World,
|
|
center: { x: number; y: number; z: number },
|
|
half: { x: number; y: number; z: number },
|
|
): Map<number, RAPIER.RigidBody> {
|
|
const shape = new world.rapier.Cuboid(half.x, half.y, half.z)
|
|
const found = new Map<number, RAPIER.RigidBody>()
|
|
world.physics.intersectionsWithShape(center, IDENTITY_ROT, shape, (col) => {
|
|
const b = col.parent()
|
|
if (b && b.isDynamic()) found.set(b.handle, b)
|
|
return true
|
|
})
|
|
return found
|
|
}
|
|
|
|
/** Axis-aligned box covering a beam of length `range` from `origin` along unit `dir`. */
|
|
function beamBox(origin: THREE.Vector3, dir: THREE.Vector3, range: number, cross: number) {
|
|
const center = {
|
|
x: origin.x + dir.x * range * 0.5,
|
|
y: origin.y + dir.y * range * 0.5,
|
|
z: origin.z + dir.z * range * 0.5,
|
|
}
|
|
const half = {
|
|
x: Math.max(Math.abs(dir.x) * range * 0.5, cross),
|
|
y: Math.max(Math.abs(dir.y) * range * 0.5, cross),
|
|
z: Math.max(Math.abs(dir.z) * range * 0.5, cross),
|
|
}
|
|
return { center, half }
|
|
}
|
|
|
|
const lerp = (a: number, b: number, t: number) => a + (b - a) * t
|
|
|
|
// ---------------------------------------------------------------------------
|
|
// PressurePlate — depresses under real weight; trips only past massThreshold.
|
|
// ---------------------------------------------------------------------------
|
|
export interface PressurePlateConfig {
|
|
id: string
|
|
position: Vec3
|
|
/** Trip only when total resting body mass ≥ this (uses body.mass()). */
|
|
massThreshold: number
|
|
/** Signal emitted on the untripped→tripped edge. */
|
|
emits?: string
|
|
/** [width, length] of the plate. Default [3, 3]. */
|
|
size?: [number, number]
|
|
}
|
|
|
|
export function createPressurePlate(world: World, cfg: PressurePlateConfig): MachinePart {
|
|
const { physics, rapier, scene } = world
|
|
const [w, l] = cfg.size ?? [3, 3]
|
|
const capH = 0.28
|
|
const pos = asVec3(cfg.position)
|
|
|
|
const group = new THREE.Group()
|
|
group.position.copy(pos)
|
|
scene.add(group)
|
|
|
|
// static base frame (also catches the body so weight registers)
|
|
const base = new THREE.Mesh(
|
|
new THREE.BoxGeometry(w + 0.6, 0.4, l + 0.6),
|
|
new THREE.MeshStandardMaterial({ color: '#4a4a52', roughness: 0.8 }),
|
|
)
|
|
base.position.y = -0.2
|
|
base.receiveShadow = true
|
|
group.add(base)
|
|
|
|
// the moving cap the blob stands on
|
|
const capMat = new THREE.MeshStandardMaterial({ color: '#8a8a98', roughness: 0.5 })
|
|
const cap = new THREE.Mesh(new THREE.BoxGeometry(w, capH, l), capMat)
|
|
cap.position.y = capH * 0.5 + 0.02
|
|
cap.castShadow = true
|
|
cap.receiveShadow = true
|
|
group.add(cap)
|
|
|
|
slotPart('machine.plate', group, [base])
|
|
|
|
// physics: fixed platform the blob actually rests on
|
|
const body = physics.createRigidBody(
|
|
rapier.RigidBodyDesc.fixed().setTranslation(pos.x, pos.y + cap.position.y, pos.z),
|
|
)
|
|
physics.createCollider(
|
|
rapier.ColliderDesc.cuboid(w * 0.5, capH * 0.5, l * 0.5).setFriction(1.0),
|
|
body,
|
|
)
|
|
|
|
const detCenter = { x: pos.x, y: pos.y + cap.position.y + 0.5, z: pos.z }
|
|
const detHalf = { x: w * 0.5, y: 0.55, z: l * 0.5 }
|
|
|
|
let tripped = false
|
|
const restY = cap.position.y
|
|
const pressedY = restY - 0.16
|
|
let targetY = restY
|
|
|
|
world.addSystem({
|
|
update() {
|
|
const bodies = dynamicBodiesInBox(world, detCenter, detHalf)
|
|
let load = 0
|
|
for (const b of bodies.values()) load += b.mass()
|
|
|
|
if (!tripped && load >= cfg.massThreshold) {
|
|
tripped = true
|
|
targetY = pressedY
|
|
if (cfg.emits) world.events.emit('machine:signal', { id: cfg.emits })
|
|
} else if (tripped && load < cfg.massThreshold * 0.5) {
|
|
// hysteresis re-arm so a settled-then-departed weight can trip again
|
|
tripped = false
|
|
targetY = restY
|
|
}
|
|
},
|
|
})
|
|
|
|
world.onFrame((dt) => {
|
|
cap.position.y = lerp(cap.position.y, targetY, Math.min(1, dt * 12))
|
|
capMat.emissive.setHex(tripped ? 0x224400 : 0x000000)
|
|
capMat.emissiveIntensity = tripped ? 0.6 : 0
|
|
})
|
|
|
|
return { id: cfg.id, group }
|
|
}
|
|
|
|
// ---------------------------------------------------------------------------
|
|
// SpringBoot — on signal: TELEGRAPH → kick everything in its strike volume.
|
|
// ---------------------------------------------------------------------------
|
|
export interface SpringBootConfig {
|
|
id: string
|
|
position: Vec3
|
|
/** Impulse magnitude applied to each struck body. */
|
|
impulse: number
|
|
/** Launch direction (auto-normalised). Default straight up. */
|
|
direction?: Vec3
|
|
/** Signal that triggers the kick. */
|
|
onSignal: string
|
|
/** Optional signal emitted right after the kick fires (for further chaining). */
|
|
emits?: string
|
|
/** Strike volume half-extents above the boot. Default [1, 0.8, 1]. */
|
|
strikeSize?: Vec3
|
|
/** Build its own landing pad collider (true) or kick whatever rests on an
|
|
* existing surface, e.g. when mounted on a PressurePlate (false). Default true. */
|
|
pad?: boolean
|
|
}
|
|
|
|
export function createSpringBoot(world: World, cfg: SpringBootConfig): MachinePart {
|
|
const { physics, rapier, scene } = world
|
|
const pos = asVec3(cfg.position)
|
|
const dir = (cfg.direction ? asVec3(cfg.direction) : new THREE.Vector3(0, 1, 0)).normalize()
|
|
const strike = cfg.strikeSize ?? [1, 0.8, 1]
|
|
|
|
const group = new THREE.Group()
|
|
group.position.copy(pos)
|
|
scene.add(group)
|
|
|
|
// landing pad the blob sits on before being kicked
|
|
const padMat = new THREE.MeshStandardMaterial({ color: '#c0392b', roughness: 0.4, metalness: 0.1 })
|
|
const pad = new THREE.Mesh(new THREE.CylinderGeometry(1.1, 1.1, 0.3, 20), padMat)
|
|
pad.position.y = 0.15
|
|
pad.castShadow = true
|
|
pad.receiveShadow = true
|
|
group.add(pad)
|
|
|
|
// coil spring under the pad (telegraph target scales/shakes the whole group)
|
|
const coilMat = new THREE.MeshStandardMaterial({ color: '#7f8c8d', metalness: 0.6, roughness: 0.3 })
|
|
const coils: THREE.Mesh[] = []
|
|
for (let i = 0; i < 3; i++) {
|
|
const ring = new THREE.Mesh(new THREE.TorusGeometry(0.7, 0.09, 8, 20), coilMat)
|
|
ring.rotation.x = Math.PI / 2
|
|
ring.position.y = -0.15 - i * 0.22
|
|
group.add(ring)
|
|
coils.push(ring)
|
|
}
|
|
|
|
slotPart('machine.boot', group, [pad, ...coils])
|
|
|
|
// physics pad so the ball can rest here between signal and kick
|
|
if (cfg.pad !== false) {
|
|
const body = physics.createRigidBody(
|
|
rapier.RigidBodyDesc.fixed().setTranslation(pos.x, pos.y + 0.15, pos.z),
|
|
)
|
|
physics.createCollider(
|
|
rapier.ColliderDesc.cylinder(0.15, 1.1).setFriction(0.9), body,
|
|
)
|
|
}
|
|
|
|
const strikeCenter = { x: pos.x, y: pos.y + 0.3 + strike[1], z: pos.z }
|
|
const strikeHalf = { x: strike[0], y: strike[1], z: strike[2] }
|
|
|
|
world.events.on('machine:signal', ({ id }: { id: string }) => {
|
|
if (id !== cfg.onSignal || isTelegraphing(world, group)) return
|
|
telegraph(world, group, {
|
|
duration: 0.5,
|
|
flashColor: '#ff5533',
|
|
scalePulse: 0.28,
|
|
shake: 0.06,
|
|
onFire: () => {
|
|
const bodies = dynamicBodiesInBox(world, strikeCenter, strikeHalf)
|
|
for (const b of bodies.values()) {
|
|
b.applyImpulse({ x: dir.x * cfg.impulse, y: dir.y * cfg.impulse, z: dir.z * cfg.impulse }, true)
|
|
}
|
|
if (cfg.emits) world.events.emit('machine:signal', { id: cfg.emits })
|
|
},
|
|
})
|
|
})
|
|
|
|
return { id: cfg.id, group }
|
|
}
|
|
|
|
// ---------------------------------------------------------------------------
|
|
// SeeSaw — plank on a real Rapier revolute joint. Pure physics: the heavier
|
|
// side wins. No telegraph (it applies no force of its own; it just tips).
|
|
// ---------------------------------------------------------------------------
|
|
export interface SeeSawConfig {
|
|
id: string
|
|
position: Vec3
|
|
/** Plank [length(x), thickness(y), width(z)]. Default [8, 0.4, 2.4]. */
|
|
plankSize?: Vec3
|
|
/** Max tilt each way, radians. Default 0.5 (~29°). */
|
|
maxTilt?: number
|
|
/** Initial tilt (rad, about Z). Negative = +X end starts low. Default 0 (level). */
|
|
restTilt?: number
|
|
}
|
|
|
|
export function createSeeSaw(world: World, cfg: SeeSawConfig): MachinePart {
|
|
const { physics, rapier, scene } = world
|
|
const pos = asVec3(cfg.position)
|
|
const [px, py, pz] = cfg.plankSize ?? [8, 0.4, 2.4]
|
|
const maxTilt = cfg.maxTilt ?? 0.5
|
|
|
|
const group = new THREE.Group()
|
|
scene.add(group)
|
|
|
|
// visual fulcrum wedge (fixed)
|
|
const fulcrum = new THREE.Mesh(
|
|
new THREE.CylinderGeometry(0.05, 1.0, 1.2, 3),
|
|
new THREE.MeshStandardMaterial({ color: '#5d4037', roughness: 0.9 }),
|
|
)
|
|
fulcrum.position.set(pos.x, pos.y - 0.6, pos.z)
|
|
fulcrum.castShadow = true
|
|
group.add(fulcrum)
|
|
|
|
// fixed anchor body at the pivot
|
|
const anchor = physics.createRigidBody(
|
|
rapier.RigidBodyDesc.fixed().setTranslation(pos.x, pos.y, pos.z),
|
|
)
|
|
|
|
// dynamic plank (optionally pre-tilted so delivery direction is deterministic)
|
|
const restTilt = cfg.restTilt ?? 0
|
|
const q0 = new THREE.Quaternion().setFromAxisAngle(new THREE.Vector3(0, 0, 1), restTilt)
|
|
const plankBody = physics.createRigidBody(
|
|
rapier.RigidBodyDesc.dynamic()
|
|
.setTranslation(pos.x, pos.y, pos.z)
|
|
.setRotation({ x: q0.x, y: q0.y, z: q0.z, w: q0.w })
|
|
.setAngularDamping(0.6),
|
|
)
|
|
physics.createCollider(
|
|
rapier.ColliderDesc.cuboid(px * 0.5, py * 0.5, pz * 0.5)
|
|
.setDensity(0.4)
|
|
.setFriction(1.0),
|
|
plankBody,
|
|
)
|
|
|
|
// revolute joint about Z → the plank tips in the X/Y plane (blob rolls along X)
|
|
const jd = rapier.JointData.revolute(
|
|
{ x: 0, y: 0, z: 0 }, { x: 0, y: 0, z: 0 }, { x: 0, y: 0, z: 1 },
|
|
)
|
|
jd.limitsEnabled = true
|
|
jd.limits = [-maxTilt, maxTilt]
|
|
physics.createImpulseJoint(jd, anchor, plankBody, true)
|
|
|
|
const plank = new THREE.Mesh(
|
|
new THREE.BoxGeometry(px, py, pz),
|
|
new THREE.MeshStandardMaterial({ color: '#a1887f', roughness: 0.7 }),
|
|
)
|
|
plank.castShadow = true
|
|
plank.receiveShadow = true
|
|
group.add(plank)
|
|
|
|
// The plank's world pose is copied off the rigid body every frame, so the
|
|
// custom model rides as its CHILD and the primitive is hidden by turning its
|
|
// material off — hiding the plank itself would hide the child too.
|
|
assets().attachSlot('machine.seesaw', plank, {
|
|
onSwap: () => { (plank.material as THREE.Material).visible = false },
|
|
})
|
|
|
|
world.onFrame(() => {
|
|
const t = plankBody.translation()
|
|
const r = plankBody.rotation()
|
|
plank.position.set(t.x, t.y, t.z)
|
|
plank.quaternion.set(r.x, r.y, r.z, r.w)
|
|
})
|
|
|
|
return { id: cfg.id, group }
|
|
}
|
|
|
|
// ---------------------------------------------------------------------------
|
|
// BucketDump — on trigger (body in its catch zone) or signal: TELEGRAPH (teeter)
|
|
// → tips over and pours, emitting `paint:request-splat` under the spout.
|
|
// ---------------------------------------------------------------------------
|
|
export interface BucketDumpConfig {
|
|
id: string
|
|
position: Vec3
|
|
color: PaintColor
|
|
/** Splat radius requested from the paint lane. */
|
|
radius: number
|
|
/** Signal that triggers a dump (optional; a catch trigger works too). */
|
|
onSignal?: string
|
|
/** Optional signal emitted after the dump (further chaining). */
|
|
emits?: string
|
|
/** Self-contained catch zone: a body entering it triggers the dump. */
|
|
trigger?: { size: Vec3; offset?: Vec3 }
|
|
}
|
|
|
|
export function createBucketDump(world: World, cfg: BucketDumpConfig): MachinePart {
|
|
const { physics, scene } = world
|
|
const pos = asVec3(cfg.position)
|
|
const paintHex = PALETTE[cfg.color]
|
|
|
|
// pivot group tips about its edge to pour
|
|
const group = new THREE.Group()
|
|
group.position.copy(pos)
|
|
scene.add(group)
|
|
|
|
const shell = new THREE.Mesh(
|
|
new THREE.CylinderGeometry(1.3, 1.0, 2.0, 20, 1, true),
|
|
new THREE.MeshStandardMaterial({ color: '#455a64', metalness: 0.5, roughness: 0.4, side: THREE.DoubleSide }),
|
|
)
|
|
shell.castShadow = true
|
|
group.add(shell)
|
|
|
|
// paint fill (colour reads even before it pours)
|
|
const fillMat = new THREE.MeshStandardMaterial({ color: paintHex, roughness: 0.3 })
|
|
const fill = new THREE.Mesh(new THREE.CylinderGeometry(1.15, 0.9, 1.4, 20), fillMat)
|
|
fill.position.y = -0.15
|
|
group.add(fill)
|
|
|
|
// Fill stays procedural: it is the colour read, and it is re-tinted at runtime.
|
|
slotPart('machine.bucket', group, [shell])
|
|
|
|
let dumping = false
|
|
let tip = 0 // current tip angle
|
|
let tipTarget = 0
|
|
let splatted = false
|
|
|
|
const doDump = () => {
|
|
if (dumping) return
|
|
dumping = true
|
|
splatted = false
|
|
tipTarget = Math.PI * 0.85
|
|
}
|
|
|
|
const startDump = () => {
|
|
if (dumping || isTelegraphing(world, group)) return
|
|
telegraph(world, group, {
|
|
duration: 0.5,
|
|
flashColor: paintHex,
|
|
scalePulse: 0.12,
|
|
shake: 0.1, // the teeter
|
|
onFire: doDump,
|
|
})
|
|
}
|
|
|
|
if (cfg.onSignal) {
|
|
world.events.on('machine:signal', ({ id }: { id: string }) => {
|
|
if (id === cfg.onSignal) startDump()
|
|
})
|
|
}
|
|
|
|
// optional self-contained catch trigger
|
|
let triggerCenter: { x: number; y: number; z: number } | null = null
|
|
let triggerHalf: { x: number; y: number; z: number } | null = null
|
|
let armed = true
|
|
if (cfg.trigger) {
|
|
const off = cfg.trigger.offset ?? [0, -1.5, 0]
|
|
triggerCenter = { x: pos.x + off[0], y: pos.y + off[1], z: pos.z + off[2] }
|
|
triggerHalf = { x: cfg.trigger.size[0] * 0.5, y: cfg.trigger.size[1] * 0.5, z: cfg.trigger.size[2] * 0.5 }
|
|
}
|
|
|
|
const spawnSlosh = () => {
|
|
const blob = new THREE.Mesh(
|
|
new THREE.SphereGeometry(cfg.radius * 0.5, 12, 10),
|
|
new THREE.MeshStandardMaterial({ color: paintHex, transparent: true, opacity: 0.9, roughness: 0.2 }),
|
|
)
|
|
const spoutWorld = new THREE.Vector3(pos.x + 1.1, pos.y - 0.2, pos.z)
|
|
blob.position.copy(spoutWorld)
|
|
scene.add(blob)
|
|
let vy = -1
|
|
let life = 0
|
|
const step = (dt: number) => {
|
|
life += dt
|
|
vy -= 14 * dt
|
|
blob.position.y += vy * dt
|
|
blob.scale.setScalar(1 + life * 1.5)
|
|
;(blob.material as THREE.MeshStandardMaterial).opacity = Math.max(0, 0.9 - life * 0.8)
|
|
if (life > 1.2) {
|
|
scene.remove(blob)
|
|
blob.geometry.dispose()
|
|
;(blob.material as THREE.Material).dispose()
|
|
const i = sloshers.indexOf(step)
|
|
if (i >= 0) sloshers.splice(i, 1)
|
|
}
|
|
}
|
|
sloshers.push(step)
|
|
}
|
|
const sloshers: Array<(dt: number) => void> = []
|
|
|
|
world.addSystem({
|
|
update() {
|
|
if (!triggerCenter || !triggerHalf) return
|
|
const bodies = dynamicBodiesInBox(world, triggerCenter, triggerHalf)
|
|
const occupied = bodies.size > 0
|
|
if (occupied && armed && !dumping) {
|
|
armed = false
|
|
startDump()
|
|
} else if (!occupied && !dumping) {
|
|
armed = true
|
|
}
|
|
},
|
|
})
|
|
|
|
// Visual-only sloshers stay on the render frame…
|
|
world.onFrame((dt) => {
|
|
for (const s of [...sloshers]) s(dt)
|
|
})
|
|
// …but the tip drives the SPLAT — gameplay, so fixed-step (onFrame stalls in
|
|
// hidden tabs and would silence every dump; found at integration).
|
|
world.addSystem({
|
|
update(dt) {
|
|
tip = lerp(tip, tipTarget, Math.min(1, dt * 4))
|
|
group.rotation.z = -tip
|
|
|
|
if (dumping && !splatted && tip > Math.PI * 0.4) {
|
|
splatted = true
|
|
const groundPoint = new THREE.Vector3(pos.x + 1.4, 0, pos.z)
|
|
const ray = new world.rapier.Ray(
|
|
{ x: groundPoint.x, y: pos.y, z: groundPoint.z }, { x: 0, y: -1, z: 0 })
|
|
const hit = world.physics.castRay(ray, 20, true)
|
|
if (hit) groundPoint.y = pos.y - hit.timeOfImpact
|
|
spawnSlosh()
|
|
// ---- paint-lane wire (lane B renders; here we log the request) ----
|
|
world.events.emit('paint:request-splat', {
|
|
point: groundPoint, color: cfg.color, radius: cfg.radius,
|
|
})
|
|
console.log(
|
|
`[machine] BucketDump "${cfg.id}" → paint:request-splat`,
|
|
{ color: cfg.color, radius: cfg.radius, point: groundPoint.toArray().map((n) => +n.toFixed(2)) },
|
|
)
|
|
if (cfg.emits) world.events.emit('machine:signal', { id: cfg.emits })
|
|
}
|
|
|
|
// right the bucket back once poured, then re-arm
|
|
if (dumping && tip > Math.PI * 0.8) tipTarget = 0
|
|
if (dumping && tipTarget === 0 && tip < 0.05) dumping = false
|
|
},
|
|
})
|
|
|
|
return { id: cfg.id, group }
|
|
}
|
|
|
|
// ---------------------------------------------------------------------------
|
|
// ConveyorBelt — ambient surface that carries bodies at a target velocity.
|
|
// (Its constantly-scrolling stripes are the always-on telegraph.)
|
|
// ---------------------------------------------------------------------------
|
|
export interface ConveyorBeltConfig {
|
|
id: string
|
|
position: Vec3
|
|
/** [length(x), thickness(y), width(z)]. Default [10, 0.5, 3]. */
|
|
size?: Vec3
|
|
/** Target surface velocity carried to bodies on the belt. */
|
|
velocity: Vec3
|
|
}
|
|
|
|
export function createConveyorBelt(world: World, cfg: ConveyorBeltConfig): MachinePart {
|
|
const { physics, rapier, scene } = world
|
|
const pos = asVec3(cfg.position)
|
|
const [sx, sy, sz] = cfg.size ?? [10, 0.5, 3]
|
|
const vel = asVec3(cfg.velocity)
|
|
|
|
const group = new THREE.Group()
|
|
group.position.copy(pos)
|
|
scene.add(group)
|
|
|
|
const belt = new THREE.Mesh(
|
|
new THREE.BoxGeometry(sx, sy, sz),
|
|
new THREE.MeshStandardMaterial({ color: '#2c3e50', roughness: 0.6 }),
|
|
)
|
|
belt.receiveShadow = true
|
|
group.add(belt)
|
|
|
|
// direction chevrons that scroll to advertise travel direction
|
|
const dir = vel.clone().normalize()
|
|
const chevronMat = new THREE.MeshStandardMaterial({ color: '#f1c40f', emissive: '#4a3b00', emissiveIntensity: 0.4 })
|
|
const chevrons: THREE.Mesh[] = []
|
|
const alongX = Math.abs(dir.x) >= Math.abs(dir.z)
|
|
const span = alongX ? sx : sz
|
|
const n = 6
|
|
for (let i = 0; i < n; i++) {
|
|
const c = new THREE.Mesh(new THREE.BoxGeometry(alongX ? 0.5 : sz * 0.6, 0.06, alongX ? sz * 0.6 : 0.5), chevronMat)
|
|
c.position.y = sy * 0.5 + 0.03
|
|
group.add(c)
|
|
chevrons.push(c)
|
|
}
|
|
|
|
// Chevrons stay procedural — they scroll every frame to advertise direction.
|
|
slotPart('machine.belt', group, [belt])
|
|
const placeChevron = (c: THREE.Mesh, offset: number) => {
|
|
const t = ((offset % span) + span) % span - span * 0.5
|
|
if (alongX) c.position.x = t
|
|
else c.position.z = t
|
|
}
|
|
|
|
const detCenter = { x: pos.x, y: pos.y + sy * 0.5 + 0.4, z: pos.z }
|
|
const detHalf = { x: sx * 0.5, y: 0.5, z: sz * 0.5 }
|
|
|
|
physics.createCollider(
|
|
rapier.ColliderDesc.cuboid(sx * 0.5, sy * 0.5, sz * 0.5)
|
|
.setTranslation(pos.x, pos.y, pos.z)
|
|
.setFriction(1.2),
|
|
)
|
|
|
|
world.addSystem({
|
|
update() {
|
|
const bodies = dynamicBodiesInBox(world, detCenter, detHalf)
|
|
for (const b of bodies.values()) {
|
|
const lv = b.linvel()
|
|
b.setLinvel({ x: lerp(lv.x, vel.x, 0.18), y: lv.y, z: lerp(lv.z, vel.z, 0.18) }, true)
|
|
}
|
|
},
|
|
})
|
|
|
|
let scroll = 0
|
|
const speed = alongX ? vel.x : vel.z
|
|
world.onFrame((dt) => {
|
|
scroll += speed * dt
|
|
chevrons.forEach((c, i) => placeChevron(c, scroll + (i / n) * span))
|
|
})
|
|
|
|
return { id: cfg.id, group }
|
|
}
|
|
|
|
// ---------------------------------------------------------------------------
|
|
// BubbleArch — walk-through cleanse zone. On blob entry: `paint:request-cleanse`
|
|
// + a bubble burst. Ambient bubbling is the telegraph; you opt in by entering.
|
|
// ---------------------------------------------------------------------------
|
|
export interface BubbleArchConfig {
|
|
id: string
|
|
position: Vec3
|
|
/** Cleanse fraction requested from the paint lane (0..1). */
|
|
fraction: number
|
|
/** Arch [width(x), height(y), depth(z)]. Default [4, 4, 2]. */
|
|
size?: Vec3
|
|
}
|
|
|
|
export function createBubbleArch(world: World, cfg: BubbleArchConfig): MachinePart {
|
|
const { scene } = world
|
|
const pos = asVec3(cfg.position)
|
|
const [w, h, d] = cfg.size ?? [4, 4, 2]
|
|
|
|
const group = new THREE.Group()
|
|
group.position.copy(pos)
|
|
scene.add(group)
|
|
|
|
const postMat = new THREE.MeshStandardMaterial({ color: '#00bcd4', roughness: 0.3, metalness: 0.2 })
|
|
const archParts: THREE.Mesh[] = []
|
|
for (const sx of [-1, 1]) {
|
|
const post = new THREE.Mesh(new THREE.CylinderGeometry(0.2, 0.2, h, 12), postMat)
|
|
post.position.set((sx * w) / 2, h / 2, 0)
|
|
post.castShadow = true
|
|
group.add(post)
|
|
archParts.push(post)
|
|
}
|
|
const bar = new THREE.Mesh(new THREE.BoxGeometry(w + 0.4, 0.4, 0.4), postMat)
|
|
bar.position.y = h
|
|
group.add(bar)
|
|
archParts.push(bar)
|
|
|
|
// a few permanent decorative bubbles clinging to the arch (ambient telegraph)
|
|
const bubbleMat = new THREE.MeshStandardMaterial({ color: '#e0f7ff', transparent: true, opacity: 0.5, roughness: 0.05 })
|
|
for (let i = 0; i < 10; i++) {
|
|
const b = new THREE.Mesh(new THREE.SphereGeometry(0.15 + Math.random() * 0.2, 10, 8), bubbleMat)
|
|
b.position.set((Math.random() - 0.5) * w, Math.random() * h, (Math.random() - 0.5) * d)
|
|
group.add(b)
|
|
archParts.push(b)
|
|
}
|
|
|
|
slotPart('machine.arch', group, archParts)
|
|
|
|
const detCenter = { x: pos.x, y: pos.y + h * 0.5, z: pos.z }
|
|
const detHalf = { x: w * 0.5, y: h * 0.5, z: d * 0.5 }
|
|
|
|
const inside = new Set<number>()
|
|
const bursts: Array<(dt: number) => void> = []
|
|
|
|
const burst = () => {
|
|
const spheres: THREE.Mesh[] = []
|
|
for (let i = 0; i < 14; i++) {
|
|
const s = new THREE.Mesh(
|
|
new THREE.SphereGeometry(0.12 + Math.random() * 0.18, 10, 8),
|
|
new THREE.MeshStandardMaterial({ color: '#eafaff', transparent: true, opacity: 0.85, roughness: 0.05 }),
|
|
)
|
|
s.position.set(pos.x + (Math.random() - 0.5) * w, pos.y + 0.5, pos.z + (Math.random() - 0.5) * d)
|
|
scene.add(s)
|
|
spheres.push(s)
|
|
}
|
|
const vels = spheres.map(() => 1 + Math.random() * 1.5)
|
|
let life = 0
|
|
const step = (dt: number) => {
|
|
life += dt
|
|
spheres.forEach((s, i) => {
|
|
s.position.y += vels[i] * dt
|
|
;(s.material as THREE.MeshStandardMaterial).opacity = Math.max(0, 0.85 - life * 0.7)
|
|
})
|
|
if (life > 1.2) {
|
|
for (const s of spheres) {
|
|
scene.remove(s)
|
|
s.geometry.dispose()
|
|
;(s.material as THREE.Material).dispose()
|
|
}
|
|
const idx = bursts.indexOf(step)
|
|
if (idx >= 0) bursts.splice(idx, 1)
|
|
}
|
|
}
|
|
bursts.push(step)
|
|
}
|
|
|
|
world.addSystem({
|
|
update() {
|
|
const bodies = dynamicBodiesInBox(world, detCenter, detHalf)
|
|
const now = new Set(bodies.keys())
|
|
for (const h of now) {
|
|
if (!inside.has(h)) {
|
|
// rising edge — a blob just entered the arch
|
|
world.events.emit('paint:request-cleanse', { fraction: cfg.fraction })
|
|
console.log(`[machine] BubbleArch "${cfg.id}" → paint:request-cleanse`, { fraction: cfg.fraction })
|
|
burst()
|
|
}
|
|
}
|
|
inside.clear()
|
|
for (const h of now) inside.add(h)
|
|
},
|
|
})
|
|
|
|
world.onFrame((dt) => {
|
|
for (const b of [...bursts]) b(dt)
|
|
})
|
|
|
|
return { id: cfg.id, group }
|
|
}
|
|
|
|
// ---------------------------------------------------------------------------
|
|
// Fan — constant directional force volume. Same force on every body, so light
|
|
// blobs get blown hard and heavy blobs barely budge (mass does the work).
|
|
// ---------------------------------------------------------------------------
|
|
export interface FanConfig {
|
|
id: string
|
|
position: Vec3
|
|
/** Force magnitude applied to each body in the beam, per step. */
|
|
force: number
|
|
/** Blow direction (auto-normalised). Default +x. */
|
|
direction?: Vec3
|
|
/** Beam length. Default 10. */
|
|
range?: number
|
|
/** Beam half-cross-section. Default 1.6. */
|
|
spread?: number
|
|
}
|
|
|
|
export function createFan(world: World, cfg: FanConfig): MachinePart {
|
|
const { scene } = world
|
|
const pos = asVec3(cfg.position)
|
|
const dir = (cfg.direction ? asVec3(cfg.direction) : new THREE.Vector3(1, 0, 0)).normalize()
|
|
const range = cfg.range ?? 10
|
|
const spread = cfg.spread ?? 1.6
|
|
|
|
const group = new THREE.Group()
|
|
group.position.copy(pos)
|
|
// orient housing so blades face the blow direction
|
|
group.quaternion.setFromUnitVectors(new THREE.Vector3(0, 0, 1), dir)
|
|
scene.add(group)
|
|
|
|
const housing = new THREE.Mesh(
|
|
new THREE.CylinderGeometry(1.5, 1.5, 0.6, 24),
|
|
new THREE.MeshStandardMaterial({ color: '#34495e', metalness: 0.4, roughness: 0.5 }),
|
|
)
|
|
housing.rotation.x = Math.PI / 2
|
|
housing.castShadow = true
|
|
group.add(housing)
|
|
|
|
const blades = new THREE.Group()
|
|
const bladeMat = new THREE.MeshStandardMaterial({ color: '#95a5a6', metalness: 0.6, roughness: 0.3 })
|
|
for (let i = 0; i < 4; i++) {
|
|
const blade = new THREE.Mesh(new THREE.BoxGeometry(0.3, 2.4, 0.08), bladeMat)
|
|
blade.rotation.z = (i / 4) * Math.PI * 2
|
|
blades.add(blade)
|
|
}
|
|
blades.position.z = 0.05
|
|
group.add(blades)
|
|
|
|
// Blades stay procedural — the constant spin IS this part's telegraph.
|
|
slotPart('machine.fan', group, [housing])
|
|
|
|
const beam = beamBox(pos, dir, range, spread)
|
|
|
|
world.addSystem({
|
|
update(dt) {
|
|
// Per-step impulse (force·dt) rather than addForce: Rapier's force
|
|
// accumulator persists across steps, so re-adding a force every tick would
|
|
// compound into a runaway. An impulse each step models a constant force
|
|
// cleanly — same force on every body, so light blobs get flung and heavy
|
|
// blobs barely move (mass does the work).
|
|
const bodies = dynamicBodiesInBox(world, beam.center, beam.half)
|
|
const k = cfg.force * dt
|
|
for (const b of bodies.values()) {
|
|
b.applyImpulse({ x: dir.x * k, y: dir.y * k, z: dir.z * k }, true)
|
|
}
|
|
},
|
|
})
|
|
|
|
world.onFrame((dt) => {
|
|
blades.rotation.z += dt * 12 // constant spin = always-on telegraph
|
|
})
|
|
|
|
return { id: cfg.id, group }
|
|
}
|