feat(machine): domino chain — knock it here, it fires over there

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 <noreply@anthropic.com>
This commit is contained in:
type-two 2026-07-20 17:31:45 +10:00
parent 194f3e22d3
commit 37a363cf13
4 changed files with 131 additions and 2 deletions

View File

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

View File

@ -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]],

View File

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

View File

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