Merge lane-c: telegraph, 7 contraptions, surface registry

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
type-two 2026-07-17 20:33:19 +10:00
commit 6979c0db38
6 changed files with 1348 additions and 3 deletions

View File

@ -1,6 +1,25 @@
<!doctype html> <!doctype html>
<html lang="en"> <html lang="en">
<head><meta charset="utf-8" /><title>BLOBBO lane c demo</title> <head><meta charset="utf-8" /><title>BLOBBO lane c demo</title>
<style>html,body{margin:0;height:100%;overflow:hidden}#app{width:100%;height:100%}</style></head> <style>
<body><div id="app"></div><script type="module" src="/src/demo/lane-c.ts"></script></body> html,body{margin:0;height:100%;overflow:hidden;background:#bfe3ff;font-family:system-ui,sans-serif}
#app{width:100%;height:100%}
#legend{position:fixed;top:10px;left:10px;padding:10px 12px;border-radius:8px;
background:rgba(20,24,40,.72);color:#eaf2ff;font-size:13px;line-height:1.5;pointer-events:none}
#legend b{color:#ffd60a}
#legend .k{display:inline-block;min-width:14px;padding:1px 5px;margin-right:6px;border-radius:4px;
background:#354569;color:#fff;font-weight:600;text-align:center}
</style></head>
<body>
<div id="app"></div>
<div id="legend">
<b>BLOBBO — Lane C machine demo</b><br>
<span class="k">T</span>re-run heavy chain ball<br>
<span class="k">L</span>drop light ball (plate must NOT trip)<br>
<span class="k">S</span>re-run ice/honey surface probes<br>
<span class="k">F</span>drop light ball in the fan beam<br>
<span class="k">P</span>print machine state to console
</div>
<script type="module" src="/src/demo/lane-c.ts"></script>
</body>
</html> </html>

View File

@ -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<string, unknown>
}
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')
}

35
src/machine/index.ts Normal file
View File

@ -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'

723
src/machine/parts.ts Normal file
View File

@ -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<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)
// 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<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)
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 }
}

113
src/machine/surfaces.ts Normal file
View File

@ -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<SurfaceName, SurfaceMaterial> = {
// 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<number, SurfaceMaterial>()
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
}

154
src/machine/telegraph.ts Normal file
View File

@ -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<Omit<TelegraphOptions, 'onFire'>> = {
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<World, ActiveTelegraph[]>()
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<void> {
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<void>((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)
}