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:
parent
194f3e22d3
commit
37a363cf13
@ -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
|
||||
|
||||
@ -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]],
|
||||
|
||||
@ -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'
|
||||
|
||||
@ -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 }
|
||||
}
|
||||
|
||||
Loading…
Reference in New Issue
Block a user