/** * 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['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 { const shape = new world.rapier.Cuboid(half.x, half.y, half.z) const found = new Map() 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, thickness, width]. Length runs along the crossing axis * (see tipAxis); width is the other horizontal. Default [8, 0.4, 2.4]. */ plankSize?: Vec3 /** Which horizontal axis you CROSS along: 'x' (default, tips left↔right) or * 'z' (tips fore↔aft — for a course that runs along Z). */ tipAxis?: 'x' | 'z' /** Max tilt each way, radians. Default 0.5 (~29°). */ maxTilt?: number /** Initial tilt (rad). Sign follows the tip axis; 0 = level. Default 0. */ restTilt?: number } export function createSeeSaw(world: World, cfg: SeeSawConfig): MachinePart { const { physics, rapier, scene } = world const pos = asVec3(cfg.position) const [len, py, wid] = cfg.plankSize ?? [8, 0.4, 2.4] const maxTilt = cfg.maxTilt ?? 0.5 // Crossing axis → plank footprint + the axis it rocks about. 'z' runs the long // side along Z and tips about X, so you roll across it in a Z-flowing course. const tipZ = cfg.tipAxis === 'z' const hx = (tipZ ? wid : len) * 0.5 const hz = (tipZ ? len : wid) * 0.5 const tiltAxis = tipZ ? new THREE.Vector3(1, 0, 0) : new THREE.Vector3(0, 0, 1) const jointAxis = tipZ ? { x: 1, y: 0, z: 0 } : { x: 0, y: 0, z: 1 } 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(tiltAxis, 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(hx, py * 0.5, hz) .setDensity(0.4) .setFriction(1.0), plankBody, ) // revolute joint about the tip axis → the plank rocks so you roll across it const jd = rapier.JointData.revolute( { x: 0, y: 0, z: 0 }, { x: 0, y: 0, z: 0 }, jointAxis, ) jd.limitsEnabled = true jd.limits = [-maxTilt, maxTilt] physics.createImpulseJoint(jd, anchor, plankBody, true) const plank = new THREE.Mesh( new THREE.BoxGeometry(hx * 2, py, hz * 2), 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() 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 } } // --------------------------------------------------------------------------- // PaintMine — a floor panel that arms, and when a body rolls onto it TELEGRAPHs // (~0.35s snap windup) then bursts: it coats whoever is on it (paint:request- // splat aimed at each struck body, so installPaint lands it on that blob) and // pops them with an impulse. Reward vs hazard is pure config: a fat splat of a // GOOD colour + an upward launch is a reward; a heavy/unwanted colour + a // sideways shove is a hazard. // // `once` (default true) is the first-through-only trap John asked for: after it // fires it reads as SPENT and won't re-fire — so in a pack the leader trips it // clean and everyone behind sees a used panel (GDD §5.4 persistent course // state). It re-arms on `race:respawn` so every fresh run gets a live panel. // `once:false` re-arms the instant the panel clears (a repeatable hazard). // --------------------------------------------------------------------------- export interface PaintMineConfig { id: string position: Vec3 color: PaintColor /** Splat radius requested from the paint lane — bigger = more coverage. */ radius: number /** How many splats to douse the body with, spread around it. Default 1. * A single disc only wraps one side; 4-5 reliably crosses the buff threshold. */ splats?: number /** Pop impulse applied to each struck body. Default 6. */ impulse?: number /** Pop direction (auto-normalised). Default straight up. */ direction?: Vec3 /** Panel footprint [x, z]. Default [3, 3]. */ size?: [number, number] /** First-through-only: fire once, stay spent until race:respawn. Default true. */ once?: boolean /** Also fire on this machine:signal (chaining), on top of step-on. */ onSignal?: string /** Signal emitted right after it bursts (further chaining). */ emits?: string } export function createPaintMine(world: World, cfg: PaintMineConfig): MachinePart { const { scene } = world const pos = asVec3(cfg.position) const [w, l] = cfg.size ?? [3, 3] const paintHex = PALETTE[cfg.color] const dir = (cfg.direction ? asVec3(cfg.direction) : new THREE.Vector3(0, 1, 0)).normalize() const impulse = cfg.impulse ?? 6 const once = cfg.once !== false const group = new THREE.Group() group.position.copy(pos) scene.add(group) // dark warning rim under a live colour panel proud of it. NO collider: the // course floor already carries the blob, so a panel can never open a hole or // grow an invisible wall — it's a decal + a trigger volume just above it. const rimMat = new THREE.MeshStandardMaterial({ color: '#1a1a1f', roughness: 0.85 }) const rim = new THREE.Mesh(new THREE.BoxGeometry(w + 0.28, 0.16, l + 0.28), rimMat) rim.position.y = 0.05 rim.receiveShadow = true group.add(rim) const panelMat = new THREE.MeshStandardMaterial({ color: paintHex, roughness: 0.5, metalness: 0.1, emissive: paintHex, emissiveIntensity: 0.35, }) const panel = new THREE.Mesh(new THREE.BoxGeometry(w, 0.12, l), panelMat) panel.position.y = 0.13 panel.receiveShadow = true group.add(panel) const detCenter = { x: pos.x, y: pos.y + 0.6, z: pos.z } const detHalf = { x: w * 0.5, y: 0.6, z: l * 0.5 } // paint-coloured particle burst (same shape as the bubble arch's burst) const bursts: Array<(dt: number) => void> = [] const burst = () => { const parts: THREE.Mesh[] = [] const vels: THREE.Vector3[] = [] for (let i = 0; i < 16; i++) { const s = new THREE.Mesh( new THREE.SphereGeometry(0.1 + Math.random() * 0.16, 8, 6), new THREE.MeshStandardMaterial({ color: paintHex, transparent: true, opacity: 0.95, roughness: 0.3 }), ) s.position.set(pos.x + (Math.random() - 0.5) * w, pos.y + 0.3, pos.z + (Math.random() - 0.5) * l) scene.add(s) parts.push(s) vels.push(new THREE.Vector3((Math.random() - 0.5) * 3, 2 + Math.random() * 3, (Math.random() - 0.5) * 3)) } let life = 0 const step = (dt: number) => { life += dt parts.forEach((s, i) => { vels[i].y -= 9 * dt s.position.addScaledVector(vels[i], dt) ;(s.material as THREE.MeshStandardMaterial).opacity = Math.max(0, 0.95 - life * 0.9) }) if (life > 1.1) { for (const s of parts) { 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) } let fired = false let armed = true const markLive = () => { panelMat.color.set(paintHex); panelMat.emissive.set(paintHex); panelMat.emissiveIntensity = 0.35 } const markSpent = () => { panelMat.color.set('#6b6b6b'); panelMat.emissive.setHex(0x000000); panelMat.emissiveIntensity = 0 } const fire = () => { if ((once && fired) || isTelegraphing(world, group)) return // Capture who's on the panel NOW (windup start): a fast roller has left the // trigger box by the time the ~0.35s telegraph completes, so we pop/coat the // bodies that tripped it (at their live position) rather than re-scanning an // empty box at fire time. const struck = dynamicBodiesInBox(world, detCenter, detHalf) telegraph(world, group, { duration: 0.35, flashColor: paintHex, scalePulse: 0.2, shake: 0.06, onFire: () => { // Douse offsets: centre + around the body, so a burst wraps more than one // UV face. splatAtPoint stamps where the world point meets the body, so // offsetting the point hits different sides (installPaint's proximity gate // still passes — offsets are well inside worldRadius + splat radius). const offsets = [[0, 0.35, 0], [0.4, 0, 0], [-0.4, 0, 0], [0, 0, 0.4], [0, 0, -0.4]] const n = Math.max(1, cfg.splats ?? 1) for (const b of struck.values()) { const t = b.translation() b.applyImpulse({ x: dir.x * impulse, y: dir.y * impulse, z: dir.z * impulse }, true) for (let i = 0; i < n; i++) { const [ox, oy, oz] = offsets[i % offsets.length] world.events.emit('paint:request-splat', { point: new THREE.Vector3(t.x + ox, t.y + oy, t.z + oz), color: cfg.color, radius: cfg.radius, }) } } burst() if (cfg.emits) world.events.emit('machine:signal', { id: cfg.emits }) if (once) { fired = true; markSpent() } }, }) } if (cfg.onSignal) { world.events.on('machine:signal', ({ id }: { id: string }) => { if (id === cfg.onSignal) fire() }) } // Fresh panel every run: a spent one-shot re-arms when the race restarts. world.events.on('race:respawn', () => { fired = false; armed = true; markLive() }) world.addSystem({ update() { const occupied = dynamicBodiesInBox(world, detCenter, detHalf).size > 0 if (occupied && armed) { armed = false fire() } else if (!occupied && !once) { armed = true // repeatable hazard re-arms on clear; a one-shot never does } }, }) world.onFrame((dt) => { for (const b of [...bursts]) b(dt) }) return { id: cfg.id, group } } // --------------------------------------------------------------------------- // ColorGate — a portcullis that stays SHUT unless an approaching blob carries // enough of a keyed colour, then slides into the floor to let it through and // closes behind. This is the GDD §5.4 colour-keyed shortcut: the course paints // you, and being painted opens your route. The door colour IS the key (a green // door needs green), matching the pink-tunnel signage language. // // The part never imports the paint lane — integration injects `qualifies()` // (e.g. `() => skin.coverage().byColor.green >= 0.4`), so the seam stays clean // and this works for whatever "enough colour" the course wants. // --------------------------------------------------------------------------- export interface ColorGateConfig { id: string position: Vec3 /** Which colour keys the gate — sets the door tint (the visible signage). */ color: PaintColor /** Door slab [width, height, depth]. Default [4, 3, 0.6]. */ size?: Vec3 /** Approach-zone half-extents on the +Z (racer) side. Default derived from size. */ detectHalf?: Vec3 /** True when the approaching blob carries enough of `color`. Injected by * integration to keep this part decoupled from the paint lane. */ qualifies: () => boolean /** Signal emitted on the shut→open edge (further chaining). */ emits?: string } export function createColorGate(world: World, cfg: ColorGateConfig): MachinePart { const { physics, rapier, scene } = world const pos = asVec3(cfg.position) const [w, h, d] = cfg.size ?? [4, 3, 0.6] const hex = PALETTE[cfg.color] const group = new THREE.Group() group.position.copy(pos) scene.add(group) // cosmetic frame: two posts + a coloured lintel so the gate reads as a gate. const postMat = new THREE.MeshStandardMaterial({ color: '#37474f', metalness: 0.4, roughness: 0.5 }) for (const sx of [-1, 1]) { const post = new THREE.Mesh(new THREE.CylinderGeometry(0.22, 0.22, h + 0.8, 12), postMat) post.position.set(sx * (w / 2 + 0.3), (h + 0.8) / 2, 0) post.castShadow = true group.add(post) } const lintel = new THREE.Mesh( new THREE.BoxGeometry(w + 1.0, 0.4, d + 0.3), new THREE.MeshStandardMaterial({ color: hex, emissive: hex, emissiveIntensity: 0.35, roughness: 0.5 }), ) lintel.position.y = h + 0.6 group.add(lintel) // the door — colour is the key. Fixed body + collider we slide into the floor. const doorMat = new THREE.MeshStandardMaterial({ color: hex, roughness: 0.45, metalness: 0.1, emissive: hex, emissiveIntensity: 0.25, transparent: true, opacity: 0.92, }) const door = new THREE.Mesh(new THREE.BoxGeometry(w, h, d), doorMat) door.castShadow = true door.receiveShadow = true group.add(door) const closedY = pos.y + h / 2 const openY = closedY - (h + 0.3) // sink fully into the ground const doorBody = physics.createRigidBody( rapier.RigidBodyDesc.fixed().setTranslation(pos.x, closedY, pos.z), ) physics.createCollider(rapier.ColliderDesc.cuboid(w / 2, h / 2, d / 2), doorBody) const dh = cfg.detectHalf ?? [w / 2 + 1, h / 2 + 0.6, 3] // detector on the +Z approach side (racers run toward -Z through the course) const detCenter = { x: pos.x, y: pos.y + h / 2, z: pos.z + dh[2] } const detHalf = { x: dh[0], y: dh[1], z: dh[2] } let open = false let curY = closedY let targetY = closedY // Everything runs in the FIXED step, including the door's slide. The door is a // COLLIDER (gameplay) — if its motion lived in onFrame it would freeze in a // hidden tab and leave a half-open gate anyone could walk through (the lesson // telegraph.ts and BucketDump already learned). Mesh + glow ride along here at // 60Hz, which is smooth enough for a door. world.addSystem({ update(dt) { const near = dynamicBodiesInBox(world, detCenter, detHalf).size > 0 const shouldOpen = near && cfg.qualifies() if (shouldOpen && !open) { open = true targetY = openY if (cfg.emits) world.events.emit('machine:signal', { id: cfg.emits }) } else if (!near && open) { open = false targetY = closedY } curY = lerp(curY, targetY, Math.min(1, dt * 6)) door.position.y = curY - pos.y // mesh is a child of the group at pos doorBody.setTranslation({ x: pos.x, y: curY, z: pos.z }, true) doorMat.emissiveIntensity = open ? 0.7 : 0.25 }, }) return { id: cfg.id, group } }