createSeeSaw gains `tipAxis: 'x'|'z'` (default 'x' = unchanged). 'z' rocks the plank fore↔aft so you can cross it in a Z-flowing course — the missing piece that kept the see-saw out of every course (it was X-crossing only). Adds ?course=seesaw, a throwaway lab for tuning the see-saw in isolation: raised deck flush into the plank's near end (no ramp to misalign), teeter across, drop off the far end onto the floor (a drop is never a wedge), fan for momentum. Browser-verified it completes end-to-end. Feel is still open (John's call): at crossing speed the plank barely pitches (reads as a stable wobble-bridge). Knobs left exposed — maxTilt/restTilt on the part, plank density/damping in the factory, fan force in the lab — to tune "does it feel good" together. NOT wired into any shipped course. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
1066 lines
39 KiB
TypeScript
1066 lines
39 KiB
TypeScript
/**
|
|
* 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'
|
|
import { assets } from '../assets/registry'
|
|
|
|
/**
|
|
* Slot hook shared by every part: park the custom model under the part's root
|
|
* and hide the primitives it replaces. Only the primitives listed in `replaces`
|
|
* go away — animated sub-parts (the plate cap, the belt chevrons, the fan
|
|
* blades) stay procedural so they keep moving.
|
|
*/
|
|
function slotPart(slot: Parameters<ReturnType<typeof assets>['attachSlot']>[0],
|
|
root: THREE.Object3D, replaces: THREE.Object3D[]): void {
|
|
assets().attachSlot(slot, root, {
|
|
onSwap: () => { for (const o of replaces) o.visible = false },
|
|
})
|
|
}
|
|
|
|
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)
|
|
|
|
slotPart('machine.plate', group, [base])
|
|
|
|
// 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 })
|
|
const coils: THREE.Mesh[] = []
|
|
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)
|
|
coils.push(ring)
|
|
}
|
|
|
|
slotPart('machine.boot', group, [pad, ...coils])
|
|
|
|
// 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, thickness, width]. Length runs along the crossing axis
|
|
* (see tipAxis); width is the other horizontal. Default [8, 0.4, 2.4]. */
|
|
plankSize?: Vec3
|
|
/** Which horizontal axis you CROSS along: 'x' (default, tips left↔right) or
|
|
* 'z' (tips fore↔aft — for a course that runs along Z). */
|
|
tipAxis?: 'x' | 'z'
|
|
/** Max tilt each way, radians. Default 0.5 (~29°). */
|
|
maxTilt?: number
|
|
/** Initial tilt (rad). Sign follows the tip axis; 0 = level. Default 0. */
|
|
restTilt?: number
|
|
}
|
|
|
|
export function createSeeSaw(world: World, cfg: SeeSawConfig): MachinePart {
|
|
const { physics, rapier, scene } = world
|
|
const pos = asVec3(cfg.position)
|
|
const [len, py, wid] = cfg.plankSize ?? [8, 0.4, 2.4]
|
|
const maxTilt = cfg.maxTilt ?? 0.5
|
|
// Crossing axis → plank footprint + the axis it rocks about. 'z' runs the long
|
|
// side along Z and tips about X, so you roll across it in a Z-flowing course.
|
|
const tipZ = cfg.tipAxis === 'z'
|
|
const hx = (tipZ ? wid : len) * 0.5
|
|
const hz = (tipZ ? len : wid) * 0.5
|
|
const tiltAxis = tipZ ? new THREE.Vector3(1, 0, 0) : new THREE.Vector3(0, 0, 1)
|
|
const jointAxis = tipZ ? { x: 1, y: 0, z: 0 } : { x: 0, y: 0, z: 1 }
|
|
|
|
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(tiltAxis, 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(hx, py * 0.5, hz)
|
|
.setDensity(0.4)
|
|
.setFriction(1.0),
|
|
plankBody,
|
|
)
|
|
|
|
// revolute joint about the tip axis → the plank rocks so you roll across it
|
|
const jd = rapier.JointData.revolute(
|
|
{ x: 0, y: 0, z: 0 }, { x: 0, y: 0, z: 0 }, jointAxis,
|
|
)
|
|
jd.limitsEnabled = true
|
|
jd.limits = [-maxTilt, maxTilt]
|
|
physics.createImpulseJoint(jd, anchor, plankBody, true)
|
|
|
|
const plank = new THREE.Mesh(
|
|
new THREE.BoxGeometry(hx * 2, py, hz * 2),
|
|
new THREE.MeshStandardMaterial({ color: '#a1887f', roughness: 0.7 }),
|
|
)
|
|
plank.castShadow = true
|
|
plank.receiveShadow = true
|
|
group.add(plank)
|
|
|
|
// The plank's world pose is copied off the rigid body every frame, so the
|
|
// custom model rides as its CHILD and the primitive is hidden by turning its
|
|
// material off — hiding the plank itself would hide the child too.
|
|
assets().attachSlot('machine.seesaw', plank, {
|
|
onSwap: () => { (plank.material as THREE.Material).visible = false },
|
|
})
|
|
|
|
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)
|
|
|
|
// Fill stays procedural: it is the colour read, and it is re-tinted at runtime.
|
|
slotPart('machine.bucket', group, [shell])
|
|
|
|
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
|
|
}
|
|
},
|
|
})
|
|
|
|
// Visual-only sloshers stay on the render frame…
|
|
world.onFrame((dt) => {
|
|
for (const s of [...sloshers]) s(dt)
|
|
})
|
|
// …but the tip drives the SPLAT — gameplay, so fixed-step (onFrame stalls in
|
|
// hidden tabs and would silence every dump; found at integration).
|
|
world.addSystem({
|
|
update(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)
|
|
}
|
|
|
|
// Chevrons stay procedural — they scroll every frame to advertise direction.
|
|
slotPart('machine.belt', group, [belt])
|
|
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 })
|
|
const archParts: THREE.Mesh[] = []
|
|
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)
|
|
archParts.push(post)
|
|
}
|
|
const bar = new THREE.Mesh(new THREE.BoxGeometry(w + 0.4, 0.4, 0.4), postMat)
|
|
bar.position.y = h
|
|
group.add(bar)
|
|
archParts.push(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)
|
|
archParts.push(b)
|
|
}
|
|
|
|
slotPart('machine.arch', group, archParts)
|
|
|
|
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)
|
|
|
|
// Blades stay procedural — the constant spin IS this part's telegraph.
|
|
slotPart('machine.fan', group, [housing])
|
|
|
|
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 }
|
|
}
|
|
|
|
// ---------------------------------------------------------------------------
|
|
// PaintMine — a floor panel that arms, and when a body rolls onto it TELEGRAPHs
|
|
// (~0.35s snap windup) then bursts: it coats whoever is on it (paint:request-
|
|
// splat aimed at each struck body, so installPaint lands it on that blob) and
|
|
// pops them with an impulse. Reward vs hazard is pure config: a fat splat of a
|
|
// GOOD colour + an upward launch is a reward; a heavy/unwanted colour + a
|
|
// sideways shove is a hazard.
|
|
//
|
|
// `once` (default true) is the first-through-only trap John asked for: after it
|
|
// fires it reads as SPENT and won't re-fire — so in a pack the leader trips it
|
|
// clean and everyone behind sees a used panel (GDD §5.4 persistent course
|
|
// state). It re-arms on `race:respawn` so every fresh run gets a live panel.
|
|
// `once:false` re-arms the instant the panel clears (a repeatable hazard).
|
|
// ---------------------------------------------------------------------------
|
|
export interface PaintMineConfig {
|
|
id: string
|
|
position: Vec3
|
|
color: PaintColor
|
|
/** Splat radius requested from the paint lane — bigger = more coverage. */
|
|
radius: number
|
|
/** How many splats to douse the body with, spread around it. Default 1.
|
|
* A single disc only wraps one side; 4-5 reliably crosses the buff threshold. */
|
|
splats?: number
|
|
/** Pop impulse applied to each struck body. Default 6. */
|
|
impulse?: number
|
|
/** Pop direction (auto-normalised). Default straight up. */
|
|
direction?: Vec3
|
|
/** Panel footprint [x, z]. Default [3, 3]. */
|
|
size?: [number, number]
|
|
/** First-through-only: fire once, stay spent until race:respawn. Default true. */
|
|
once?: boolean
|
|
/** Also fire on this machine:signal (chaining), on top of step-on. */
|
|
onSignal?: string
|
|
/** Signal emitted right after it bursts (further chaining). */
|
|
emits?: string
|
|
}
|
|
|
|
export function createPaintMine(world: World, cfg: PaintMineConfig): MachinePart {
|
|
const { scene } = world
|
|
const pos = asVec3(cfg.position)
|
|
const [w, l] = cfg.size ?? [3, 3]
|
|
const paintHex = PALETTE[cfg.color]
|
|
const dir = (cfg.direction ? asVec3(cfg.direction) : new THREE.Vector3(0, 1, 0)).normalize()
|
|
const impulse = cfg.impulse ?? 6
|
|
const once = cfg.once !== false
|
|
|
|
const group = new THREE.Group()
|
|
group.position.copy(pos)
|
|
scene.add(group)
|
|
|
|
// dark warning rim under a live colour panel proud of it. NO collider: the
|
|
// course floor already carries the blob, so a panel can never open a hole or
|
|
// grow an invisible wall — it's a decal + a trigger volume just above it.
|
|
const rimMat = new THREE.MeshStandardMaterial({ color: '#1a1a1f', roughness: 0.85 })
|
|
const rim = new THREE.Mesh(new THREE.BoxGeometry(w + 0.28, 0.16, l + 0.28), rimMat)
|
|
rim.position.y = 0.05
|
|
rim.receiveShadow = true
|
|
group.add(rim)
|
|
|
|
const panelMat = new THREE.MeshStandardMaterial({
|
|
color: paintHex, roughness: 0.5, metalness: 0.1,
|
|
emissive: paintHex, emissiveIntensity: 0.35,
|
|
})
|
|
const panel = new THREE.Mesh(new THREE.BoxGeometry(w, 0.12, l), panelMat)
|
|
panel.position.y = 0.13
|
|
panel.receiveShadow = true
|
|
group.add(panel)
|
|
|
|
const detCenter = { x: pos.x, y: pos.y + 0.6, z: pos.z }
|
|
const detHalf = { x: w * 0.5, y: 0.6, z: l * 0.5 }
|
|
|
|
// paint-coloured particle burst (same shape as the bubble arch's burst)
|
|
const bursts: Array<(dt: number) => void> = []
|
|
const burst = () => {
|
|
const parts: THREE.Mesh[] = []
|
|
const vels: THREE.Vector3[] = []
|
|
for (let i = 0; i < 16; i++) {
|
|
const s = new THREE.Mesh(
|
|
new THREE.SphereGeometry(0.1 + Math.random() * 0.16, 8, 6),
|
|
new THREE.MeshStandardMaterial({ color: paintHex, transparent: true, opacity: 0.95, roughness: 0.3 }),
|
|
)
|
|
s.position.set(pos.x + (Math.random() - 0.5) * w, pos.y + 0.3, pos.z + (Math.random() - 0.5) * l)
|
|
scene.add(s)
|
|
parts.push(s)
|
|
vels.push(new THREE.Vector3((Math.random() - 0.5) * 3, 2 + Math.random() * 3, (Math.random() - 0.5) * 3))
|
|
}
|
|
let life = 0
|
|
const step = (dt: number) => {
|
|
life += dt
|
|
parts.forEach((s, i) => {
|
|
vels[i].y -= 9 * dt
|
|
s.position.addScaledVector(vels[i], dt)
|
|
;(s.material as THREE.MeshStandardMaterial).opacity = Math.max(0, 0.95 - life * 0.9)
|
|
})
|
|
if (life > 1.1) {
|
|
for (const s of parts) {
|
|
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)
|
|
}
|
|
|
|
let fired = false
|
|
let armed = true
|
|
|
|
const markLive = () => { panelMat.color.set(paintHex); panelMat.emissive.set(paintHex); panelMat.emissiveIntensity = 0.35 }
|
|
const markSpent = () => { panelMat.color.set('#6b6b6b'); panelMat.emissive.setHex(0x000000); panelMat.emissiveIntensity = 0 }
|
|
|
|
const fire = () => {
|
|
if ((once && fired) || isTelegraphing(world, group)) return
|
|
// Capture who's on the panel NOW (windup start): a fast roller has left the
|
|
// trigger box by the time the ~0.35s telegraph completes, so we pop/coat the
|
|
// bodies that tripped it (at their live position) rather than re-scanning an
|
|
// empty box at fire time.
|
|
const struck = dynamicBodiesInBox(world, detCenter, detHalf)
|
|
telegraph(world, group, {
|
|
duration: 0.35,
|
|
flashColor: paintHex,
|
|
scalePulse: 0.2,
|
|
shake: 0.06,
|
|
onFire: () => {
|
|
// Douse offsets: centre + around the body, so a burst wraps more than one
|
|
// UV face. splatAtPoint stamps where the world point meets the body, so
|
|
// offsetting the point hits different sides (installPaint's proximity gate
|
|
// still passes — offsets are well inside worldRadius + splat radius).
|
|
const offsets = [[0, 0.35, 0], [0.4, 0, 0], [-0.4, 0, 0], [0, 0, 0.4], [0, 0, -0.4]]
|
|
const n = Math.max(1, cfg.splats ?? 1)
|
|
for (const b of struck.values()) {
|
|
const t = b.translation()
|
|
b.applyImpulse({ x: dir.x * impulse, y: dir.y * impulse, z: dir.z * impulse }, true)
|
|
for (let i = 0; i < n; i++) {
|
|
const [ox, oy, oz] = offsets[i % offsets.length]
|
|
world.events.emit('paint:request-splat', {
|
|
point: new THREE.Vector3(t.x + ox, t.y + oy, t.z + oz), color: cfg.color, radius: cfg.radius,
|
|
})
|
|
}
|
|
}
|
|
burst()
|
|
if (cfg.emits) world.events.emit('machine:signal', { id: cfg.emits })
|
|
if (once) { fired = true; markSpent() }
|
|
},
|
|
})
|
|
}
|
|
|
|
if (cfg.onSignal) {
|
|
world.events.on('machine:signal', ({ id }: { id: string }) => {
|
|
if (id === cfg.onSignal) fire()
|
|
})
|
|
}
|
|
|
|
// Fresh panel every run: a spent one-shot re-arms when the race restarts.
|
|
world.events.on('race:respawn', () => { fired = false; armed = true; markLive() })
|
|
|
|
world.addSystem({
|
|
update() {
|
|
const occupied = dynamicBodiesInBox(world, detCenter, detHalf).size > 0
|
|
if (occupied && armed) {
|
|
armed = false
|
|
fire()
|
|
} else if (!occupied && !once) {
|
|
armed = true // repeatable hazard re-arms on clear; a one-shot never does
|
|
}
|
|
},
|
|
})
|
|
|
|
world.onFrame((dt) => {
|
|
for (const b of [...bursts]) b(dt)
|
|
})
|
|
|
|
return { id: cfg.id, group }
|
|
}
|
|
|
|
// ---------------------------------------------------------------------------
|
|
// ColorGate — a portcullis that stays SHUT unless an approaching blob carries
|
|
// enough of a keyed colour, then slides into the floor to let it through and
|
|
// closes behind. This is the GDD §5.4 colour-keyed shortcut: the course paints
|
|
// you, and being painted opens your route. The door colour IS the key (a green
|
|
// door needs green), matching the pink-tunnel signage language.
|
|
//
|
|
// The part never imports the paint lane — integration injects `qualifies()`
|
|
// (e.g. `() => skin.coverage().byColor.green >= 0.4`), so the seam stays clean
|
|
// and this works for whatever "enough colour" the course wants.
|
|
// ---------------------------------------------------------------------------
|
|
export interface ColorGateConfig {
|
|
id: string
|
|
position: Vec3
|
|
/** Which colour keys the gate — sets the door tint (the visible signage). */
|
|
color: PaintColor
|
|
/** Door slab [width, height, depth]. Default [4, 3, 0.6]. */
|
|
size?: Vec3
|
|
/** Approach-zone half-extents on the +Z (racer) side. Default derived from size. */
|
|
detectHalf?: Vec3
|
|
/** True when the approaching blob carries enough of `color`. Injected by
|
|
* integration to keep this part decoupled from the paint lane. */
|
|
qualifies: () => boolean
|
|
/** Signal emitted on the shut→open edge (further chaining). */
|
|
emits?: string
|
|
}
|
|
|
|
export function createColorGate(world: World, cfg: ColorGateConfig): MachinePart {
|
|
const { physics, rapier, scene } = world
|
|
const pos = asVec3(cfg.position)
|
|
const [w, h, d] = cfg.size ?? [4, 3, 0.6]
|
|
const hex = PALETTE[cfg.color]
|
|
|
|
const group = new THREE.Group()
|
|
group.position.copy(pos)
|
|
scene.add(group)
|
|
|
|
// cosmetic frame: two posts + a coloured lintel so the gate reads as a gate.
|
|
const postMat = new THREE.MeshStandardMaterial({ color: '#37474f', metalness: 0.4, roughness: 0.5 })
|
|
for (const sx of [-1, 1]) {
|
|
const post = new THREE.Mesh(new THREE.CylinderGeometry(0.22, 0.22, h + 0.8, 12), postMat)
|
|
post.position.set(sx * (w / 2 + 0.3), (h + 0.8) / 2, 0)
|
|
post.castShadow = true
|
|
group.add(post)
|
|
}
|
|
const lintel = new THREE.Mesh(
|
|
new THREE.BoxGeometry(w + 1.0, 0.4, d + 0.3),
|
|
new THREE.MeshStandardMaterial({ color: hex, emissive: hex, emissiveIntensity: 0.35, roughness: 0.5 }),
|
|
)
|
|
lintel.position.y = h + 0.6
|
|
group.add(lintel)
|
|
|
|
// the door — colour is the key. Fixed body + collider we slide into the floor.
|
|
const doorMat = new THREE.MeshStandardMaterial({
|
|
color: hex, roughness: 0.45, metalness: 0.1,
|
|
emissive: hex, emissiveIntensity: 0.25, transparent: true, opacity: 0.92,
|
|
})
|
|
const door = new THREE.Mesh(new THREE.BoxGeometry(w, h, d), doorMat)
|
|
door.castShadow = true
|
|
door.receiveShadow = true
|
|
group.add(door)
|
|
|
|
const closedY = pos.y + h / 2
|
|
const openY = closedY - (h + 0.3) // sink fully into the ground
|
|
const doorBody = physics.createRigidBody(
|
|
rapier.RigidBodyDesc.fixed().setTranslation(pos.x, closedY, pos.z),
|
|
)
|
|
physics.createCollider(rapier.ColliderDesc.cuboid(w / 2, h / 2, d / 2), doorBody)
|
|
|
|
const dh = cfg.detectHalf ?? [w / 2 + 1, h / 2 + 0.6, 3]
|
|
// detector on the +Z approach side (racers run toward -Z through the course)
|
|
const detCenter = { x: pos.x, y: pos.y + h / 2, z: pos.z + dh[2] }
|
|
const detHalf = { x: dh[0], y: dh[1], z: dh[2] }
|
|
|
|
let open = false
|
|
let curY = closedY
|
|
let targetY = closedY
|
|
|
|
// Everything runs in the FIXED step, including the door's slide. The door is a
|
|
// COLLIDER (gameplay) — if its motion lived in onFrame it would freeze in a
|
|
// hidden tab and leave a half-open gate anyone could walk through (the lesson
|
|
// telegraph.ts and BucketDump already learned). Mesh + glow ride along here at
|
|
// 60Hz, which is smooth enough for a door.
|
|
world.addSystem({
|
|
update(dt) {
|
|
const near = dynamicBodiesInBox(world, detCenter, detHalf).size > 0
|
|
const shouldOpen = near && cfg.qualifies()
|
|
if (shouldOpen && !open) {
|
|
open = true
|
|
targetY = openY
|
|
if (cfg.emits) world.events.emit('machine:signal', { id: cfg.emits })
|
|
} else if (!near && open) {
|
|
open = false
|
|
targetY = closedY
|
|
}
|
|
curY = lerp(curY, targetY, Math.min(1, dt * 6))
|
|
door.position.y = curY - pos.y // mesh is a child of the group at pos
|
|
doorBody.setTranslation({ x: pos.x, y: curY, z: pos.z }, true)
|
|
doorMat.emissiveIntensity = open ? 0.7 : 0.25
|
|
},
|
|
})
|
|
|
|
return { id: cfg.id, group }
|
|
}
|