From 37a363cf1365d1a41dbc6a46740f6a86284422a5 Mon Sep 17 00:00:00 2001 From: type-two Date: Mon, 20 Jul 2026 17:31:45 +1000 Subject: [PATCH] =?UTF-8?q?feat(machine):=20domino=20chain=20=E2=80=94=20k?= =?UTF-8?q?nock=20it=20here,=20it=20fires=20over=20there?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit createDominoChain: a run of real dynamic slabs standing in a row. Roll into the first and physics topples the rest; when the LAST tile (red-striped so the payoff end reads) tips past 45 degrees the chain emits its `emits` signal exactly once. A domino run is a Rube-Goldberg wire: knock it here, something fires over there. Passive physics like the see-saw, so no telegraph — the visible topple IS the telegraph. race:respawn stands every tile back up. Placed in Toaster Alley on the right shoulder: detour into the row and the completed chain triggers a signal-only GREEN cannon volley (interval 0 = never auto-fires) that paints you from across the course — a third, silliest way to earn the gate's green (~10%, a top-up; mine and bubbles stay the main routes). Verified deterministically with world.tick() (no more wall-clock waits in the throttled tab): blob knocks tile 1 -> all 8 topple -> signal emitted once -> both cannons hit (2 green splats, 9.9%) -> respawn re-arms all 8 upright. tsc + build green. Co-Authored-By: Claude Fable 5 --- src/course/spec.ts | 6 +- src/course/toaster-alley.ts | 8 +++ src/machine/index.ts | 2 + src/machine/parts.ts | 117 ++++++++++++++++++++++++++++++++++++ 4 files changed, 131 insertions(+), 2 deletions(-) diff --git a/src/course/spec.ts b/src/course/spec.ts index dca892e..ab12e90 100644 --- a/src/course/spec.ts +++ b/src/course/spec.ts @@ -16,12 +16,12 @@ import { PaintCannon } from '../paint/index' import { createPaintMine, createColorGate, createSpringBoot, createSeeSaw, createFan, createBucketDump, createBubbleArch, createConveyorBelt, createPressurePlate, - createPaintBubbles, applySurface, + createPaintBubbles, createDominoChain, applySurface, } from '../machine/index' import type { PaintMineConfig, ColorGateConfig, SpringBootConfig, SeeSawConfig, FanConfig, BucketDumpConfig, BubbleArchConfig, ConveyorBeltConfig, PressurePlateConfig, - PaintBubblesConfig, SurfaceName, + PaintBubblesConfig, DominoChainConfig, SurfaceName, } from '../machine/index' import { TOASTER_ALLEY } from './toaster-alley' import { SEESAW_LAB } from './seesaw-lab' @@ -54,6 +54,7 @@ export type MachineSpec = | { kind: 'plate'; cfg: PressurePlateConfig } | { kind: 'conveyor'; cfg: ConveyorBeltConfig } | { kind: 'bubbles'; cfg: PaintBubblesConfig } + | { kind: 'dominoes'; cfg: DominoChainConfig } | { kind: 'oil'; pos: [number, number, number]; half: [number, number, number] } export interface CannonSpec { @@ -135,6 +136,7 @@ export function buildCourseFromSpec( case 'plate': createPressurePlate(world, m.cfg); break case 'conveyor': createConveyorBelt(world, m.cfg); break case 'bubbles': createPaintBubbles(world, m.cfg); break + case 'dominoes': createDominoChain(world, m.cfg); break case 'oil': { const [cx, cy, cz] = m.pos const [hx, hy, hz] = m.half diff --git a/src/course/toaster-alley.ts b/src/course/toaster-alley.ts index 0bf19f2..5e7e2e6 100644 --- a/src/course/toaster-alley.ts +++ b/src/course/toaster-alley.ts @@ -45,11 +45,19 @@ export const TOASTER_ALLEY: CourseSpec = { // green gate — opens for the runner who took the reward mine (same loop as // Breakfast Rush, proving the gate works identically from a data spec) { kind: 'gate', cfg: { id: 'ta-gate', position: [0, 0.02, -22], color: 'green', size: [4, 3, 0.6] }, needColor: 'green', needPct: 0.4 }, + // DOMINO RUN on the right shoulder: detour into the first tile and physics + // topples the row; the red-striped last tile completes the chain and fires + // the green volley cannons below — the machine paints you from across the + // course. A third, most Rube-Goldberg way to earn the gate's green. + { kind: 'dominoes', cfg: { id: 'ta-dominoes', position: [5.5, 0, 2], direction: [0, 0, -1], count: 8, emits: 'ta-domino-volley' } }, ], cannons: [ // RED crossfire on the run-up — BURN speed to carry you down the alley { color: 'red', position: [-9, 3.5, 12], interval: 1.8 }, { color: 'red', position: [9, 3.5, 12], interval: 1.8 }, + // domino payoff: signal-only green volley (interval 0 = never auto-fires) + { color: 'green', position: [-8, 3.5, -14], triggerId: 'ta-domino-volley', interval: 0 }, + { color: 'green', position: [8, 3.5, -16], triggerId: 'ta-domino-volley', interval: 0 }, ], // one checkpoint past the mine — a fall (or a bad gate bonk) costs seconds, not the run checkpoints: [[0, 0, -8]], diff --git a/src/machine/index.ts b/src/machine/index.ts index fbd8587..75951fb 100644 --- a/src/machine/index.ts +++ b/src/machine/index.ts @@ -24,6 +24,7 @@ export { createPaintMine, createColorGate, createPaintBubbles, + createDominoChain, } from './parts' export type { Vec3, @@ -38,4 +39,5 @@ export type { PaintMineConfig, ColorGateConfig, PaintBubblesConfig, + DominoChainConfig, } from './parts' diff --git a/src/machine/parts.ts b/src/machine/parts.ts index a6e2bfb..63b254b 100644 --- a/src/machine/parts.ts +++ b/src/machine/parts.ts @@ -1241,3 +1241,120 @@ export function createPaintBubbles(world: World, cfg: PaintBubblesConfig): Machi return { id: cfg.id, group } } + +// --------------------------------------------------------------------------- +// DominoChain — a run of real dynamic slabs standing in a line. Roll into the +// first and physics topples the rest down the row; when the LAST tile tips +// past ~45° the chain emits its `emits` signal exactly once — so a domino run +// is a wire: knock it here, something fires over there (a cannon volley, a +// bucket, a boot). Pure passive physics like the see-saw, so no telegraph — +// the visible slow topple IS the telegraph. Re-arms upright on race:respawn. +// --------------------------------------------------------------------------- +export interface DominoChainConfig { + id: string + /** Base of the FIRST domino (its feet). */ + position: Vec3 + /** Direction the row runs (auto-normalised, horizontal). Default [0,0,-1]. */ + direction?: Vec3 + /** How many tiles. Default 8. */ + count?: number + /** Gap between tile centres. Keep < height so they chain. Default 1.1. */ + spacing?: number + /** Tile [width, height, thickness]. Default [1.2, 1.6, 0.18]. */ + size?: Vec3 + /** Tile colour. Default cream. */ + color?: string + /** Signal emitted once when the last tile has toppled. */ + emits?: string +} + +export function createDominoChain(world: World, cfg: DominoChainConfig): MachinePart { + const { physics, rapier, scene } = world + const base = asVec3(cfg.position) + const dir = (cfg.direction ? asVec3(cfg.direction) : new THREE.Vector3(0, 0, -1)) + dir.y = 0 + dir.normalize() + const count = cfg.count ?? 8 + const spacing = cfg.spacing ?? 1.1 + const [w, h, d] = cfg.size ?? [1.2, 1.6, 0.18] + + const group = new THREE.Group() + scene.add(group) + + // face the slabs perpendicular to the run: thin axis (d) along `dir` + const yaw = Math.atan2(dir.x, dir.z) + const q0 = new THREE.Quaternion().setFromAxisAngle(new THREE.Vector3(0, 1, 0), yaw) + + const mat = new THREE.MeshStandardMaterial({ color: cfg.color ?? '#f5f0e6', roughness: 0.6 }) + const edgeMat = new THREE.MeshStandardMaterial({ color: '#d8506e', roughness: 0.6 }) + + interface Tile { + body: RAPIER.RigidBody + mesh: THREE.Mesh + home: { x: number; y: number; z: number } + } + const tiles: Tile[] = [] + + for (let i = 0; i < count; i++) { + const home = { + x: base.x + dir.x * spacing * i, + y: base.y + h / 2, + z: base.z + dir.z * spacing * i, + } + const body = physics.createRigidBody( + rapier.RigidBodyDesc.dynamic() + .setTranslation(home.x, home.y, home.z) + .setRotation({ x: q0.x, y: q0.y, z: q0.z, w: q0.w }) + .setAngularDamping(0.15), + ) + physics.createCollider( + rapier.ColliderDesc.cuboid(w / 2, h / 2, d / 2).setDensity(0.3).setFriction(0.6), + body, + ) + // last tile gets a marker stripe so the payoff end reads at a glance + const mesh = new THREE.Mesh(new THREE.BoxGeometry(w, h, d), i === count - 1 ? edgeMat : mat) + mesh.castShadow = true + mesh.receiveShadow = true + group.add(mesh) + tiles.push({ body, mesh, home }) + } + + let fired = false + + world.addSystem({ + update() { + if (fired || !cfg.emits) return + const last = tiles[tiles.length - 1] + // y-component of the tile's local +Y in world space: 1 - 2(qx² + qz²). + // Below cos(45°) the last tile has toppled → the chain completes. + const r = last.body.rotation() + const upY = 1 - 2 * (r.x * r.x + r.z * r.z) + if (upY < 0.707) { + fired = true + world.events.emit('machine:signal', { id: cfg.emits }) + } + }, + }) + + // stand every tile back up for the next run + world.events.on('race:respawn', () => { + fired = false + for (const t of tiles) { + t.body.setTranslation(t.home, true) + t.body.setRotation({ x: q0.x, y: q0.y, z: q0.z, w: q0.w }, true) + t.body.setLinvel({ x: 0, y: 0, z: 0 }, true) + t.body.setAngvel({ x: 0, y: 0, z: 0 }, true) + } + }) + + world.onFrame(() => { + for (const t of tiles) { + const p = t.body.translation() + const r = t.body.rotation() + t.mesh.position.set(p.x, p.y, p.z) + t.mesh.quaternion.set(r.x, r.y, r.z, r.w) + } + }) + + return { id: cfg.id, group } +}