From 8aaa11cdc184fa2724f4c77709eae8e9d79b1b3b Mon Sep 17 00:00:00 2001 From: type-two Date: Fri, 17 Jul 2026 20:26:52 +1000 Subject: [PATCH] =?UTF-8?q?Lane=20C:=20Rube-Goldberg=20machine=20toolkit?= =?UTF-8?q?=20=E2=80=94=20telegraphs,=20parts,=20surfaces,=20chain=20demo?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Player-triggered contraptions that chain purely via world.events ('machine:signal') with a mandatory ~0.5s telegraph before any force or paint dump (GDD readable-chaos pillar). src/machine/: - telegraph.ts: unmissable windup (accelerating flash + shake + scale pulse) ticked once per world; parts await it and only then fire. - parts.ts: PressurePlate (real body.mass() threshold), SpringBoot (telegraph -> impulse on strike volume), SeeSaw (Rapier revolute joint with limits + optional rest tilt), BucketDump (telegraph teeter -> paint:request-splat + slosh), ConveyorBelt, BubbleArch (rising-edge paint:request-cleanse + bubbles), Fan (per-step impulse so mass does the work). Config-driven; no cross-instance references. - surfaces.ts: normal/ice/oil/honey/mud/water registry with applySurface + surfaceAt (downward ray) query. demos/lane-c.html + src/demo/lane-c.ts: heavy ball runs PressurePlate -> SpringBoot -> SeeSaw -> ice runway -> BucketDump, plus conveyor/arch/fan and ice/honey probe lanes. Debug keys T/L/S/F/P and per-second STATE console lines. Layout constants tuned via a headless physics sweep so the chain completes deterministically. Co-Authored-By: Claude Fable 5 --- demos/lane-c.html | 23 +- src/demo/lane-c.ts | 303 +++++++++++++++- src/machine/index.ts | 35 ++ src/machine/parts.ts | 723 +++++++++++++++++++++++++++++++++++++++ src/machine/surfaces.ts | 113 ++++++ src/machine/telegraph.ts | 154 +++++++++ 6 files changed, 1348 insertions(+), 3 deletions(-) create mode 100644 src/machine/index.ts create mode 100644 src/machine/parts.ts create mode 100644 src/machine/surfaces.ts create mode 100644 src/machine/telegraph.ts diff --git a/demos/lane-c.html b/demos/lane-c.html index 6b42f25..2df8b7b 100644 --- a/demos/lane-c.html +++ b/demos/lane-c.html @@ -1,6 +1,25 @@ BLOBBO lane c demo - -
+ + +
+
+ BLOBBO — Lane C machine demo
+ Tre-run heavy chain ball
+ Ldrop light ball (plate must NOT trip)
+ Sre-run ice/honey surface probes
+ Fdrop light ball in the fan beam
+ Pprint machine state to console +
+ + diff --git a/src/demo/lane-c.ts b/src/demo/lane-c.ts index 2a9ab9f..aca53d2 100644 --- a/src/demo/lane-c.ts +++ b/src/demo/lane-c.ts @@ -1 +1,302 @@ -console.log("lane-c demo: pending — lane agent replaces this file") +/** + * Lane C demo — the Rube-Goldberg chain + surfaces sandbox. + * + * Chain (pure data wiring via `machine:signal`, no cross-instance references): + * heavy ball rests on PressurePlate ──emit 'sig:boot'──▶ SpringBoot + * SpringBoot TELEGRAPHs then kicks the ball ▶ it lands on the SeeSaw + * SeeSaw (real revolute joint) tips ▶ ball rolls into the BucketDump catch zone + * BucketDump TELEGRAPHs (teeter) then dumps ▶ emits 'paint:request-splat' (logged) + * Side rigs: ConveyorBelt, BubbleArch, a Fan, plus ICE and HONEY patches with two + * probe balls so surfaces measurably change rolling behaviour. + * + * Debug keys (browser): + * T — re-run the heavy chain ball from the start + * L — drop a LIGHT ball on the plate (must NOT trip it) + * S — re-run the ice/honey surface probes + * F — drop a light ball into the fan beam + * P — print a one-line machine state snapshot + * + * Console: every machine:signal / paint:* event is logged, and a STATE line is + * printed once per second, so integration can verify the chain without the screen. + */ +import * as THREE from 'three' +import type { World } from '../contracts' +import { + createPressurePlate, createSpringBoot, createSeeSaw, createBucketDump, + createConveyorBelt, createBubbleArch, createFan, + SurfaceRegistry, SURFACES, type SurfaceName, +} from '../machine' + +// ---- tunables (kept in one place so integration can retune the layout) ---- +// Values were tuned in a headless physics sweep so the ball reliably lands on +// the plank and the ice runway carries it into the bucket's catch zone. +const CFG = { + station: [0, 0, 0] as [number, number, number], // plate + boot + plateThreshold: 2.5, + bootImpulse: 36, + bootDir: [0.5, 1.0, 0] as [number, number, number], + seesaw: [4.5, 1.3, 0] as [number, number, number], + plankLen: 5, + maxTilt: 0.45, + restTilt: -0.3, // +X end starts low → delivers +X + runway: [6, 10] as [number, number], // ice strip seesaw → bucket + bucket: [11, 2.4, 0] as [number, number, number], + heavyBall: { radius: 0.7, density: 2.5, spawn: [0, 2.4, 0] as [number, number, number] }, + lightBall: { radius: 0.5, density: 0.35 }, +} + +interface Handles { + world: World + surfaces: SurfaceRegistry + spawnHeavyBall: () => void + spawnLightBall: () => void + runSurfaceProbes: () => void + spawnFanBall: () => void + snapshot: () => Record +} + +export function buildLaneC(world: World): Handles { + const { scene, physics, rapier } = world + + // -------------------------------------------------------------- lighting/floor + const floor = new THREE.Mesh( + new THREE.BoxGeometry(80, 1, 40), + new THREE.MeshStandardMaterial({ color: '#cbb98a', roughness: 0.95 }), + ) + floor.position.y = -0.5 + floor.receiveShadow = true + scene.add(floor) + const floorCol = physics.createCollider( + rapier.ColliderDesc.cuboid(40, 0.5, 20).setTranslation(0, -0.5, 0).setFriction(1.0), + ) + + const surfaces = new SurfaceRegistry(rapier) + surfaces.apply(floorCol, 'normal') + + // low containment wall past the bucket so the chain ball can't overshoot + const wall = new THREE.Mesh( + new THREE.BoxGeometry(1, 3, 8), + new THREE.MeshStandardMaterial({ color: '#b0a06e', roughness: 0.9 }), + ) + wall.position.set(CFG.bucket[0] + 2.5, 0.75, 0) + scene.add(wall) + physics.createCollider( + rapier.ColliderDesc.cuboid(0.5, 1.5, 4).setTranslation(wall.position.x, 0.75, 0), + ) + + // ICE runway on the chain path: carries the ball off the seesaw into the bucket. + const [rx0, rx1] = CFG.runway + const rMid = (rx0 + rx1) / 2, rHalf = (rx1 - rx0) / 2 + const runwayMesh = new THREE.Mesh( + new THREE.BoxGeometry(rHalf * 2, 0.2, 4), + new THREE.MeshStandardMaterial({ color: SURFACES.ice.color, roughness: 0.15, metalness: 0.1 }), + ) + runwayMesh.position.set(rMid, 0.1, 0) + runwayMesh.receiveShadow = true + scene.add(runwayMesh) + const runwayCol = physics.createCollider( + rapier.ColliderDesc.cuboid(rHalf, 0.1, 2).setTranslation(rMid, 0.1, 0), + ) + surfaces.apply(runwayCol, 'ice') + + // ---------------------------------------------------------------- the chain + // (1) PressurePlate — emits the boot signal only past its mass threshold. + const plate = createPressurePlate(world, { + id: 'plate-launch', + position: CFG.station, + massThreshold: CFG.plateThreshold, + size: [3, 3], + emits: 'sig:boot', + }) + + // (2) SpringBoot — co-mounted on the plate (no pad of its own); kicks on signal. + createSpringBoot(world, { + id: 'boot', + position: CFG.station, + impulse: CFG.bootImpulse, + direction: CFG.bootDir, + onSignal: 'sig:boot', + strikeSize: [1.4, 0.9, 1.4], + pad: false, + emits: 'sig:boot-fired', + }) + + // (3) SeeSaw — real revolute joint; pre-biased so the ball is delivered +X. + createSeeSaw(world, { + id: 'seesaw', + position: CFG.seesaw, + plankSize: [CFG.plankLen, 0.4, 2.6], + maxTilt: CFG.maxTilt, + restTilt: CFG.restTilt, + }) + + // (4) BucketDump — self-contained catch zone (placed beyond the plank's reach) + // triggers the teeter+dump when the ball slides in off the ice runway. + createBucketDump(world, { + id: 'bucket', + position: CFG.bucket, + color: 'purple', + radius: 2.2, + trigger: { size: [1.4, 2.2, 4], offset: [-1.7, -2.4, 0] }, + emits: 'sig:bucket-dumped', + }) + + // ---------------------------------------------------------------- side rigs + createConveyorBelt(world, { + id: 'belt', + position: [-6, 0.25, 8], + size: [10, 0.5, 3], + velocity: [3.5, 0, 0], + }) + + createBubbleArch(world, { + id: 'arch', + position: [4, 0, 8], + fraction: 0.5, + size: [4, 4, 2], + }) + + createFan(world, { + id: 'fan', + position: [-4, 1.2, -8], + force: 9, + direction: [1, 0.1, 0], + range: 12, + spread: 1.8, + }) + + // ------------------------------------------------- surface probe lanes (ice/honey) + // Two identical lanes side by side; a probe ball on each gets the same push. + // Ice lets it slide far, honey stops it fast — measurable in the STATE line. + const makePatch = (name: SurfaceName, z: number) => { + const mat = new THREE.MeshStandardMaterial({ color: SURFACES[name].color, roughness: 0.4 }) + const patch = new THREE.Mesh(new THREE.BoxGeometry(18, 0.2, 3), mat) + patch.position.set(3, 0.1, z) + patch.receiveShadow = true + scene.add(patch) + const col = physics.createCollider( + rapier.ColliderDesc.cuboid(9, 0.1, 1.5).setTranslation(3, 0.1, z), + ) + surfaces.apply(col, name) + return z + } + const iceZ = makePatch('ice', -14) + const honeyZ = makePatch('honey', -18) + + // ----------------------------------------------------------- test balls (stub) + // NOTE: this is Lane C's own throwaway ball — NOT Lane A's blob (never imported). + let chainBall: { mesh: THREE.Mesh; body: import('@dimforge/rapier3d-compat').RigidBody } | null = null + + const makeBall = ( + pos: [number, number, number], radius: number, density: number, color = '#F5F5F7', + ) => { + const mesh = new THREE.Mesh( + new THREE.SphereGeometry(radius, 24, 18), + new THREE.MeshStandardMaterial({ color, roughness: 0.4 }), + ) + mesh.castShadow = true + scene.add(mesh) + const body = physics.createRigidBody( + rapier.RigidBodyDesc.dynamic().setTranslation(pos[0], pos[1], pos[2]).setLinearDamping(0.05), + ) + physics.createCollider( + rapier.ColliderDesc.ball(radius).setDensity(density).setFriction(1.0).setRestitution(0.15), + body, + ) + world.onFrame(() => { + const t = body.translation(), r = body.rotation() + mesh.position.set(t.x, t.y, t.z) + mesh.quaternion.set(r.x, r.y, r.z, r.w) + }) + return { mesh, body } + } + + const spawnHeavyBall = () => { + if (chainBall) { + scene.remove(chainBall.mesh) + physics.removeRigidBody(chainBall.body) + } + const { radius, density, spawn } = CFG.heavyBall + chainBall = makeBall(spawn, radius, density, '#F5F5F7') + console.log(`[demo] heavy chain ball spawned (mass=${chainBall.body.mass().toFixed(2)}) — chain should run`) + } + + const spawnLightBall = () => { + const b = makeBall([0, 2.4, 0], CFG.lightBall.radius, CFG.lightBall.density, '#ffd1dc') + console.log(`[demo] LIGHT ball on plate (mass=${b.body.mass().toFixed(2)} < threshold ${CFG.plateThreshold}) — plate must NOT trip`) + } + + const spawnFanBall = () => { + const b = makeBall([-3, 2.2, -8], 0.5, 0.35, '#c8f7c5') + console.log(`[demo] light ball dropped in fan beam (mass=${b.body.mass().toFixed(2)}) — should be blown +X`) + } + + // surface probes: give each an identical +X kick and log resting distance + let iceProbe: import('@dimforge/rapier3d-compat').RigidBody | null = null + let honeyProbe: import('@dimforge/rapier3d-compat').RigidBody | null = null + const runSurfaceProbes = () => { + for (const p of [iceProbe, honeyProbe]) if (p) physics.removeRigidBody(p) + const ice = makeBall([-4, 0.7, iceZ], 0.5, 1.0, '#e6f7ff') + const honey = makeBall([-4, 0.7, honeyZ], 0.5, 1.0, '#fff0c2') + iceProbe = ice.body + honeyProbe = honey.body + ice.body.setLinvel({ x: 8, y: 0, z: 0 }, true) + honey.body.setLinvel({ x: 8, y: 0, z: 0 }, true) + console.log('[demo] surface probes launched (+8 m/s): ICE vs HONEY — compare rest x') + } + + // --------------------------------------------------------------- event logging + world.events.on('machine:signal', (p: { id: string }) => + console.log(`[event] machine:signal id="${p.id}"`)) + world.events.on('paint:request-splat', (p: any) => + console.log('[event] paint:request-splat', { color: p.color, radius: p.radius })) + world.events.on('paint:request-cleanse', (p: any) => + console.log('[event] paint:request-cleanse', p)) + + const snapshot = () => { + const ball = chainBall?.body.translation() + const surfUnderBall = ball + ? surfaces.surfaceAt(physics, new THREE.Vector3(ball.x, ball.y, ball.z), 2.5)?.name + : 'n/a' + return { + ballX: ball ? +ball.x.toFixed(2) : null, + ballY: ball ? +ball.y.toFixed(2) : null, + surfaceUnderBall: surfUnderBall, + iceProbeX: iceProbe ? +iceProbe.translation().x.toFixed(2) : null, + honeyProbeX: honeyProbe ? +honeyProbe.translation().x.toFixed(2) : null, + } + } + + return { world, surfaces, spawnHeavyBall, spawnLightBall, runSurfaceProbes, spawnFanBall, snapshot } +} + +// --------------------------------------------------------------- browser bootstrap +if (typeof document !== 'undefined') { + const { createWorld } = await import('../world') + const world = await createWorld(document.getElementById('app')!) + world.camera.position.set(2, 10, 20) + const h = buildLaneC(world) + + // frame a nice overview + world.onFrame(() => world.camera.lookAt(5, 1, 0)) + + // start the chain, and print a STATE line once a second + h.spawnHeavyBall() + h.runSurfaceProbes() + let acc = 0 + world.onFrame((dt) => { + acc += dt + if (acc >= 1) { acc = 0; console.log('[STATE]', JSON.stringify(h.snapshot())) } + }) + + addEventListener('keydown', (e) => { + if (e.code === 'KeyT') h.spawnHeavyBall() + else if (e.code === 'KeyL') h.spawnLightBall() + else if (e.code === 'KeyS') h.runSurfaceProbes() + else if (e.code === 'KeyF') h.spawnFanBall() + else if (e.code === 'KeyP') console.log('[STATE]', JSON.stringify(h.snapshot())) + }) + + world.start() + console.log('[demo] lane-c ready — keys: T re-run ball · L light ball · S surfaces · F fan · P state') +} diff --git a/src/machine/index.ts b/src/machine/index.ts new file mode 100644 index 0000000..99c4e7c --- /dev/null +++ b/src/machine/index.ts @@ -0,0 +1,35 @@ +/** + * Lane C — Machine toolkit public surface. + * Contraptions chain via `world.events` ('machine:signal') and talk to the paint + * lane via 'paint:request-splat' / 'paint:request-cleanse' only. + */ +export { telegraph, isTelegraphing } from './telegraph' +export type { TelegraphOptions } from './telegraph' + +export { + SURFACES, + SurfaceRegistry, + applySurface, +} from './surfaces' +export type { SurfaceName, SurfaceMaterial } from './surfaces' + +export { + createPressurePlate, + createSpringBoot, + createSeeSaw, + createBucketDump, + createConveyorBelt, + createBubbleArch, + createFan, +} from './parts' +export type { + Vec3, + MachinePart, + PressurePlateConfig, + SpringBootConfig, + SeeSawConfig, + BucketDumpConfig, + ConveyorBeltConfig, + BubbleArchConfig, + FanConfig, +} from './parts' diff --git a/src/machine/parts.ts b/src/machine/parts.ts new file mode 100644 index 0000000..7e46b9a --- /dev/null +++ b/src/machine/parts.ts @@ -0,0 +1,723 @@ +/** + * 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' + +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) + + // 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 }) + 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) + } + + // 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) + + 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) + + 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 + } + }, + }) + + world.onFrame((dt) => { + for (const s of [...sloshers]) s(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) + } + 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 }) + 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) + } + const bar = new THREE.Mesh(new THREE.BoxGeometry(w + 0.4, 0.4, 0.4), postMat) + bar.position.y = h + group.add(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) + } + + 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) + + 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 } +} diff --git a/src/machine/surfaces.ts b/src/machine/surfaces.ts new file mode 100644 index 0000000..834cab0 --- /dev/null +++ b/src/machine/surfaces.ts @@ -0,0 +1,113 @@ +/** + * SURFACES — the material framework (GDD §5.5). Friction / restitution / tags + * per material so honey, ice, oil, mud and water are *data*, not code. The blob + * controller (lane A) queries `surfaceAt(point)` to react to what it is standing + * on; contraptions and courses stamp materials onto colliders via `applySurface`. + * + * Self-contained: no imports from other lanes. Colliders are tagged in a local + * registry keyed by collider handle so `surfaceAt` can resolve a world point back + * to its material with a short downward ray. + */ +import * as THREE from 'three' +import type RAPIER from '@dimforge/rapier3d-compat' + +export type SurfaceName = 'normal' | 'ice' | 'oil' | 'honey' | 'mud' | 'water' + +export interface SurfaceMaterial { + name: SurfaceName + /** Rapier collider friction coefficient. */ + friction: number + /** Rapier collider restitution (bounciness). */ + restitution: number + /** + * How friction combines with the *other* collider (the blob). `Min` lets ice + * stay slippery even under a grippy/green blob; `Max` lets honey stay sticky. + */ + combine: 'average' | 'min' | 'max' | 'multiply' + /** Extra per-frame linear damping the controller may apply while on it. */ + drag: number + /** Behaviour flags read by gameplay (grip/slip/climb/rinse/anti-paint...). */ + tags: string[] + /** Debug/VFX tint. */ + color: string +} + +export const SURFACES: Record = { + // baseline grippy course material + normal: { name: 'normal', friction: 1.0, restitution: 0.15, combine: 'average', drag: 0.0, tags: [], color: '#c9b98f' }, + // near-frictionless: a rolling ball keeps going for ages + ice: { name: 'ice', friction: 0.02, restitution: 0.05, combine: 'min', drag: 0.0, tags: ['slip'], color: '#bfe9ff' }, + // slick + flowy luge; no steering (GDD §5.5 oil) + oil: { name: 'oil', friction: 0.04, restitution: 0.0, combine: 'min', drag: 0.02, tags: ['slip', 'flow', 'flammable'], color: '#3a2f4a' }, + // sticky: kills momentum fast, lets you climb (GDD §5.5 honey) + honey: { name: 'honey', friction: 2.0, restitution: 0.0, combine: 'max', drag: 0.12, tags: ['sticky', 'climb'], color: '#e7a72b' }, + // anti-paint sludge: sticky and masks colour (GDD §5.5 mud) + mud: { name: 'mud', friction: 1.6, restitution: 0.0, combine: 'max', drag: 0.18, tags: ['sticky', 'antipaint'], color: '#6b4a2b' }, + // gradual rinse lane + water: { name: 'water', friction: 0.45, restitution: 0.0, combine: 'average', drag: 0.08, tags: ['flow', 'rinse'], color: '#2f7fd0' }, +} + +function combineRule(rapier: typeof RAPIER, combine: SurfaceMaterial['combine']) { + switch (combine) { + case 'min': return rapier.CoefficientCombineRule.Min + case 'max': return rapier.CoefficientCombineRule.Max + case 'multiply': return rapier.CoefficientCombineRule.Multiply + default: return rapier.CoefficientCombineRule.Average + } +} + +/** Registry mapping collider.handle -> surface, so points can be resolved back. */ +export class SurfaceRegistry { + private byHandle = new Map() + constructor(private rapier: typeof RAPIER) {} + + /** Stamp a material onto a live collider and remember it. */ + apply(collider: RAPIER.Collider, name: SurfaceName): SurfaceMaterial { + const surf = SURFACES[name] + collider.setFriction(surf.friction) + collider.setRestitution(surf.restitution) + const rule = combineRule(this.rapier, surf.combine) + collider.setFrictionCombineRule(rule) + collider.setRestitutionCombineRule(rule) + this.byHandle.set(collider.handle, surf) + return surf + } + + /** Look up the material for a specific collider (or undefined). */ + forCollider(collider: RAPIER.Collider): SurfaceMaterial | undefined { + return this.byHandle.get(collider.handle) + } + + /** + * Resolve the surface under a world point (e.g. beneath the blob) by casting a + * short ray straight down. Returns `normal` if the hit collider is untagged, + * `undefined` only when nothing is below. + */ + surfaceAt(physics: RAPIER.World, point: THREE.Vector3, reach = 1.5): SurfaceMaterial | undefined { + const ray = new this.rapier.Ray( + { x: point.x, y: point.y + 0.05, z: point.z }, + { x: 0, y: -1, z: 0 }, + ) + const hit = physics.castRay(ray, reach, true) + if (!hit) return undefined + return this.byHandle.get(hit.collider.handle) ?? SURFACES.normal + } +} + +/** + * Convenience for callers that don't hold a registry: stamp friction/restitution + * directly onto a collider. (Prefer `SurfaceRegistry.apply` so `surfaceAt` works.) + */ +export function applySurface( + rapier: typeof RAPIER, + collider: RAPIER.Collider, + name: SurfaceName, +): SurfaceMaterial { + const surf = SURFACES[name] + collider.setFriction(surf.friction) + collider.setRestitution(surf.restitution) + const rule = combineRule(rapier, surf.combine) + collider.setFrictionCombineRule(rule) + collider.setRestitutionCombineRule(rule) + return surf +} diff --git a/src/machine/telegraph.ts b/src/machine/telegraph.ts new file mode 100644 index 0000000..c3a0fac --- /dev/null +++ b/src/machine/telegraph.ts @@ -0,0 +1,154 @@ +/** + * TELEGRAPH — the readable-chaos pillar (GDD §2.3, §5.4). + * + * Every contraption that applies force or dumps paint MUST wind up visibly for + * ~0.5s before it fires. This helper plays that windup on a mesh/group — an + * accelerating flash + shake + scale pulse that is unmissable — then resolves + * (and calls `onFire`) at the moment of firing. A part that fires without first + * awaiting a telegraph is a bug, not a shortcut. + * + * The animation is ticked once per render frame via a single `world.onFrame` + * hook installed lazily per world (so importing this module has no side effects + * and parts never wire their own tickers). + */ +import * as THREE from 'three' +import type { World } from '../contracts' + +export interface TelegraphOptions { + /** Windup length in seconds before firing. Pillar default is ~0.5s. */ + duration?: number + /** Peak positional jitter amplitude (world units). */ + shake?: number + /** Peak extra uniform scale (1 -> 1 + scalePulse at the crescendo). */ + scalePulse?: number + /** Emissive flash colour — the "danger" tint. */ + flashColor?: THREE.ColorRepresentation + /** Called exactly once, at the instant the windup completes (firing). */ + onFire?: () => void +} + +const DEFAULTS: Required> = { + duration: 0.5, + shake: 0.08, + scalePulse: 0.22, + flashColor: '#FFEB3B', +} + +interface EmissiveCache { + mat: THREE.MeshStandardMaterial + color: THREE.Color + intensity: number +} + +interface ActiveTelegraph { + root: THREE.Object3D + basePos: THREE.Vector3 + baseScale: THREE.Vector3 + emissives: EmissiveCache[] + flash: THREE.Color + duration: number + shake: number + scalePulse: number + t: number + onFire?: () => void + resolve: () => void +} + +const perWorld = new WeakMap() + +function collectEmissives(root: THREE.Object3D): EmissiveCache[] { + const out: EmissiveCache[] = [] + root.traverse((o) => { + const mesh = o as THREE.Mesh + const mat = mesh.material as THREE.Material | THREE.Material[] | undefined + const mats = Array.isArray(mat) ? mat : mat ? [mat] : [] + for (const m of mats) { + if ((m as THREE.MeshStandardMaterial).isMeshStandardMaterial) { + const sm = m as THREE.MeshStandardMaterial + out.push({ mat: sm, color: sm.emissive.clone(), intensity: sm.emissiveIntensity }) + } + } + }) + return out +} + +function tick(list: ActiveTelegraph[], dt: number) { + for (let i = list.length - 1; i >= 0; i--) { + const tg = list[i] + tg.t += dt + const p = Math.min(tg.t / tg.duration, 1) + + // Intensity ramps up (p^1.6) so the windup visibly *builds* toward firing. + const ramp = Math.pow(p, 1.6) + const wobble = 0.5 + 0.5 * Math.sin(tg.t * 34) // fast panic flicker + + // shake around the base position + const amp = tg.shake * ramp + tg.root.position.set( + tg.basePos.x + (Math.random() * 2 - 1) * amp, + tg.basePos.y + (Math.random() * 2 - 1) * amp, + tg.basePos.z + (Math.random() * 2 - 1) * amp, + ) + // scale pulse + const s = 1 + tg.scalePulse * ramp * (0.55 + 0.45 * wobble) + tg.root.scale.set(tg.baseScale.x * s, tg.baseScale.y * s, tg.baseScale.z * s) + // emissive flash + const glow = ramp * (0.4 + 1.8 * wobble) + for (const e of tg.emissives) { + e.mat.emissive.copy(tg.flash) + e.mat.emissiveIntensity = glow + } + + if (p >= 1) { + // restore the untouched look, then fire + tg.root.position.copy(tg.basePos) + tg.root.scale.copy(tg.baseScale) + for (const e of tg.emissives) { + e.mat.emissive.copy(e.color) + e.mat.emissiveIntensity = e.intensity + } + list.splice(i, 1) + tg.onFire?.() + tg.resolve() + } + } +} + +/** + * Play a windup on `target` and resolve when it fires. Awaitable so a part can + * do `await telegraph(world, mesh, {onFire})` and only apply force afterwards. + */ +export function telegraph( + world: World, + target: THREE.Object3D, + opts: TelegraphOptions = {}, +): Promise { + let list = perWorld.get(world) + if (!list) { + list = [] + perWorld.set(world, list) + world.onFrame((dt) => tick(list!, dt)) + } + const o = { ...DEFAULTS, ...opts } + return new Promise((resolve) => { + list!.push({ + root: target, + basePos: target.position.clone(), + baseScale: target.scale.clone(), + emissives: collectEmissives(target), + flash: new THREE.Color(o.flashColor), + duration: o.duration, + shake: o.shake, + scalePulse: o.scalePulse, + t: 0, + onFire: opts.onFire, + resolve, + }) + }) +} + +/** True while `target` is mid-windup — parts use it to avoid re-triggering. */ +export function isTelegraphing(world: World, target: THREE.Object3D): boolean { + const list = perWorld.get(world) + return !!list && list.some((tg) => tg.root === target) +}