Compare commits

..

No commits in common. "d4d41208f89755293d28b7863f0bca79c0a03faa" and "c169bf73aa5d2d7480b9db9bae102a9d17fcae71" have entirely different histories.

37 changed files with 49 additions and 3785 deletions

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Before

Width:  |  Height:  |  Size: 499 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 436 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 801 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 712 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 635 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 566 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 668 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 587 KiB

View File

@ -1,25 +1,6 @@
<!doctype html>
<html lang="en">
<head><meta charset="utf-8" /><title>BLOBBO lane c demo</title>
<style>
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>
<style>html,body{margin:0;height:100%;overflow:hidden}#app{width:100%;height:100%}</style></head>
<body><div id="app"></div><script type="module" src="/src/demo/lane-c.ts"></script></body>
</html>

View File

@ -1,89 +0,0 @@
/**
* Lane A third-person soft-follow camera.
*
* Trails the blob with position lag, eases its yaw to sit behind the direction
* of travel, and kicks FOV a touch at speed for a sense of hustle. Runs as a
* per-frame (visual-only) hook. The controller reads `world.camera` for its
* move basis, so this cam swinging around keeps WASD camera-relative.
*/
import * as THREE from 'three'
import type { World } from '../contracts'
import type { BlobHandle } from './createBlob'
export interface FollowCameraOptions {
distance?: number
height?: number
/** How fast the camera position catches up (higher = tighter). */
followLag?: number
/** How fast the camera swings behind travel (higher = snappier). */
yawLag?: number
}
export interface FollowCamera {
update(dt: number): void
yaw(): number
}
function expo(rate: number, dt: number): number {
return 1 - Math.exp(-rate * dt)
}
export function createFollowCamera(
world: World,
blob: BlobHandle,
opts: FollowCameraOptions = {},
): FollowCamera {
const cam = world.camera
const distance = opts.distance ?? 9
const height = opts.height ?? 4.5
const followLag = opts.followLag ?? 6
const yawLag = opts.yawLag ?? 2.5
const baseFov = cam.fov
let camYaw = Math.PI // start looking toward -Z (down the course)
const desired = new THREE.Vector3()
const look = new THREE.Vector3()
let lookInit = false
return {
update(dt: number): void {
const p = blob.body.translation()
const v = blob.body.linvel()
const size = blob.modifiers.size
const speed = Math.hypot(v.x, v.z)
// ease the rig behind the direction of travel (only while actually moving)
if (speed > 1.5) {
const targetYaw = Math.atan2(v.x, v.z)
let delta = targetYaw - camYaw
delta = Math.atan2(Math.sin(delta), Math.cos(delta))
camYaw += delta * expo(yawLag, dt)
}
const dist = distance * size
const h = height * size
desired.set(
p.x - Math.sin(camYaw) * dist,
p.y + h,
p.z - Math.cos(camYaw) * dist,
)
cam.position.lerp(desired, expo(followLag, dt))
// look at a point a little above the blob, itself eased for smoothness
const lookTarget = new THREE.Vector3(p.x, p.y + 1.0 * size, p.z)
if (!lookInit) {
look.copy(lookTarget)
lookInit = true
} else {
look.lerp(lookTarget, expo(8, dt))
}
cam.lookAt(look)
// FOV kick at speed
const targetFov = baseFov + Math.min(speed * 0.9, 12)
cam.fov += (targetFov - cam.fov) * expo(4, dt)
cam.updateProjectionMatrix()
},
yaw: () => camYaw,
}
}

View File

@ -1,156 +0,0 @@
/**
* Lane A blob controller (the fixed-step System).
*
* Physics only. Reads keyboard + camera, drives the rigid ball with impulses,
* respects `blob.modifiers` EVERY frame, and emits the frozen wire events
* `blob:jumped` / `blob:landed`. It never sets velocity outright (except the
* jump kick), so external forces catapults, fans, shoves from Lane C always
* stack on top instead of being clobbered.
*
* Movement is CAMERA-RELATIVE: W is "away from camera", derived from the live
* camera yaw, so it stays correct as the follow-cam swings around.
*/
import * as THREE from 'three'
import type { System, World } from '../contracts'
import type { BlobHandle } from './createBlob'
export interface BlobControllerOptions {
/** Base top speed (m/s) before speedMul. */
maxSpeed?: number
/** Ground acceleration (m/s^2) before massMul sluggishness. */
accel?: number
/** Take-off vertical speed (m/s) before jumpMul. */
jumpSpeed?: number
}
export function createBlobController(
world: World,
blob: BlobHandle,
opts: BlobControllerOptions = {},
): System {
const { physics, rapier, events, camera } = world
const maxSpeed = opts.maxSpeed ?? 8
const accel = opts.accel ?? 60
const jumpSpeed = opts.jumpSpeed ?? 7.6
// ---- input (self-contained; debug keys live in the demo) ----
const keys = new Set<string>()
let jumpBuffer = 0
addEventListener('keydown', (e) => {
keys.add(e.code)
if (e.code === 'Space') jumpBuffer = 0.12 // buffered so an early tap still fires
})
addEventListener('keyup', (e) => keys.delete(e.code))
const held = (...codes: string[]) => codes.some((c) => keys.has(c))
// ---- state ----
let grounded = false
let coyote = 0 // seconds of "still counts as grounded" after leaving ground
let prevVy = 0
let lastSize = -1
let lastMassMul = -1
const fwd = new THREE.Vector3()
const right = new THREE.Vector3()
const move = new THREE.Vector3()
function applyModifiers(): void {
const m = blob.modifiers
if (m.size !== lastSize) {
blob.collider.setRadius(blob.radius * m.size)
blob.group.scale.setScalar(m.size)
lastSize = m.size
lastMassMul = -1 // radius change resets mass props → re-apply mass below
}
if (m.massMul !== lastMassMul) {
blob.collider.setMass(Math.max(0.05, m.massMul))
lastMassMul = m.massMul
}
// grip boosts real contact friction (slopes / standing) ...
blob.collider.setFriction(0.6 + m.grip * 1.6)
}
return {
update(dt: number): void {
applyModifiers()
const m = blob.modifiers
const body = blob.body
const r = blob.radius * m.size
// ---- grounded check: short ray straight down, excluding self ----
const t = body.translation()
// Sync the visual root to the body in the FIXED step: gameplay reads this
// transform (paint proximity gating), so it must track the sim even when
// render frames stall (hidden tab). The feel layer re-sets it per frame
// with wobble on top — that stays visual-only.
blob.group.position.set(t.x, t.y, t.z)
const ray = new rapier.Ray({ x: t.x, y: t.y, z: t.z }, { x: 0, y: -1, z: 0 })
const hit = physics.castRay(ray, r + 0.18, true, undefined, undefined, blob.collider, body)
const nowGrounded = hit !== null
coyote = nowGrounded ? 0.1 : Math.max(0, coyote - dt)
// landing edge → squash event, impact = downward speed just before contact
if (nowGrounded && !grounded) {
const impact = Math.max(0, -prevVy)
if (impact > 0.8) events.emit('blob:landed', { impact })
}
grounded = nowGrounded
// ---- camera-relative move basis (flatten camera dir onto ground) ----
camera.getWorldDirection(fwd)
fwd.y = 0
if (fwd.lengthSq() < 1e-6) fwd.set(0, 0, -1)
fwd.normalize()
right.set(-fwd.z, 0, fwd.x) // forward × up
move.set(0, 0, 0)
if (held('KeyW', 'ArrowUp')) move.add(fwd)
if (held('KeyS', 'ArrowDown')) move.sub(fwd)
if (held('KeyD', 'ArrowRight')) move.add(right)
if (held('KeyA', 'ArrowLeft')) move.sub(right)
const hasInput = move.lengthSq() > 1e-6
if (hasInput) move.normalize()
const v = body.linvel()
const mass = body.mass()
const speedCap = maxSpeed * m.speedMul
const control = grounded ? 1 : 0.4 // reduced air control
const sluggish = 1 / Math.sqrt(Math.max(0.3, m.massMul)) // heavy = accelerates slower
// ---- drive: additive impulse, but stop pushing past the cap ----
if (hasInput) {
const along = v.x * move.x + v.z * move.z
if (along < speedCap) {
const imp = mass * accel * control * sluggish * dt
body.applyImpulse({ x: move.x * imp, y: 0, z: move.z * imp }, true)
}
if (grounded) {
// scrub sideways drift so turns don't feel like ice (grip tightens it)
const along2 = v.x * move.x + v.z * move.z
const perpX = v.x - along2 * move.x
const perpZ = v.z - along2 * move.z
const k = Math.min(1, (2 + m.grip * 10) * dt)
body.applyImpulse({ x: -perpX * mass * k, y: 0, z: -perpZ * mass * k }, true)
}
} else if (grounded) {
// no input on ground: brake. grip = how hard we kill the slide.
const k = Math.min(1, (6 + m.grip * 22) * dt)
body.applyImpulse({ x: -v.x * mass * k, y: 0, z: -v.z * mass * k }, true)
}
// ---- jump (buffered + coyote) ----
jumpBuffer = Math.max(0, jumpBuffer - dt)
if (jumpBuffer > 0 && coyote > 0) {
const vy = body.linvel().y
const dvy = Math.max(0, jumpSpeed * m.jumpMul - vy) // bring vy up to target
body.applyImpulse({ x: 0, y: mass * dvy, z: 0 }, true)
jumpBuffer = 0
coyote = 0
grounded = false
events.emit('blob:jumped', {})
}
prevVy = body.linvel().y
},
}
}

View File

@ -1,113 +0,0 @@
/**
* Lane A the Blobbo body.
*
* A friendly white soft-looking blob. The physics is a rigid, rotation-locked
* dynamic ball (determinism lives in the capsule); the softness is faked by the
* feel layer wobbling the mesh (see feel.ts). The body mesh is a *clean UV
* sphere with untouched UVs* Lane B paints splats into that UV space, so we
* never touch its geometry attributes.
*/
import * as THREE from 'three'
import type RAPIER from '@dimforge/rapier3d-compat'
import type { Blob, World } from '../contracts'
import { BASE_WHITE, defaultModifiers } from '../contracts'
export interface CreateBlobOptions {
position?: THREE.Vector3
/** Base collider radius before the `size` modifier is applied. */
radius?: number
/** UV-sphere longitude segments (latitude is derived). Keep high for smooth paint. */
segments?: number
}
/**
* Runtime handle. Extends the frozen `Blob` with the concrete bits the Lane A
* controller/feel need (collider, base radius, cosmetic face). Anything typed
* as `Blob` elsewhere still sees exactly the frozen shape.
*/
export interface BlobHandle extends Blob {
collider: RAPIER.Collider
/** Base radius (pre-`size`); collider radius = radius * modifiers.size. */
radius: number
/** Cosmetic eyes group — NOT paintable, kept separate from `mesh`. */
face: THREE.Group
}
function buildFace(radius: number): THREE.Group {
const face = new THREE.Group()
const eyeWhiteMat = new THREE.MeshStandardMaterial({ color: '#ffffff', roughness: 0.3 })
const pupilMat = new THREE.MeshStandardMaterial({ color: '#1a1a22', roughness: 0.5 })
const eyeWhiteGeo = new THREE.SphereGeometry(radius * 0.3, 20, 16)
const pupilGeo = new THREE.SphereGeometry(radius * 0.15, 16, 12)
for (const side of [-1, 1]) {
const eye = new THREE.Group()
eye.position.set(side * radius * 0.34, radius * 0.34, radius * 0.86)
const white = new THREE.Mesh(eyeWhiteGeo, eyeWhiteMat)
white.castShadow = true
const pupil = new THREE.Mesh(pupilGeo, pupilMat)
pupil.position.set(0, 0, radius * 0.2)
eye.add(white, pupil)
face.add(eye)
}
return face
}
export function createBlob(world: World, opts: CreateBlobOptions = {}): BlobHandle {
const { scene, physics, rapier } = world
const radius = opts.radius ?? 0.5
const pos = opts.position ?? new THREE.Vector3(0, 3, 0)
const seg = opts.segments ?? 48
// Root group carries world position, facing yaw, and the `size` scale.
const group = new THREE.Group()
group.position.copy(pos)
scene.add(group)
// Paintable body: clean UV sphere. Default THREE sphere UVs are exactly what
// Lane B wants (equirectangular, seam at the back). Do not decorate this mesh.
const geometry = new THREE.SphereGeometry(radius, seg, Math.round(seg * 0.75))
const material = new THREE.MeshStandardMaterial({
color: BASE_WHITE,
roughness: 0.5,
metalness: 0.0,
emissive: new THREE.Color('#88e0ff'),
emissiveIntensity: 0.0, // driven by modifiers.glow in the feel layer
})
const mesh = new THREE.Mesh(geometry, material)
mesh.castShadow = true
group.add(mesh)
// Cosmetic face, parented to the group (yaws to face travel, scales with size).
const face = buildFace(radius)
group.add(face)
// Physics proxy: dynamic ball, rotations locked so it stays an upright
// character instead of a rolling marble. Comedy roll = visual only.
const body = physics.createRigidBody(
rapier.RigidBodyDesc.dynamic()
.setTranslation(pos.x, pos.y, pos.z)
.setLinearDamping(0.15)
.setCanSleep(false),
)
body.setEnabledRotations(false, false, false, true)
const collider = physics.createCollider(
rapier.ColliderDesc.ball(radius)
.setRestitution(0.0) // no physical bounce — the bounce is the squash
.setFriction(0.6)
.setMass(1.0),
body,
)
return {
group,
mesh,
body,
collider,
radius,
face,
modifiers: defaultModifiers(),
}
}

View File

@ -1,129 +0,0 @@
/**
* Lane A the FEEL layer. This is BLOBBO's comedy engine.
*
* The physics body is a rigid ball. Everything soft and wobbly happens here, on
* the *visual* mesh only, once per rendered frame. We run underdamped springs so
* the jiggle OVERSHOOTS a landing squash should read from across the room, a
* jump should pop tall, and the body should keep quivering for a beat after.
*
* squash : signed vertical squash/stretch (+ = tall, - = flat), spring0
* leanX/Z : inertial jelly lean, driven by horizontal acceleration
* idle : always-on breathing so a resting blob is never dead-still
*
* Wired to the frozen events by the demo/integration:
* world.events.on('blob:jumped', () => feel.onJump())
* world.events.on('blob:landed', (p) => feel.onLand(p.impact))
*/
import * as THREE from 'three'
import type { BlobHandle } from './createBlob'
export interface BlobFeel {
update(dt: number): void
onJump(): void
onLand(impact: number): void
}
// Underdamped so it overshoots and quivers. Higher K = snappier, lower C = wobblier.
const SQUASH_K = 190
const SQUASH_C = 11
const LEAN_K = 150
const LEAN_C = 13
function spring(pos: number, vel: number, k: number, c: number, dt: number): [number, number] {
// semi-implicit Euler — stable at 60fps for these constants.
const acc = -k * pos - c * vel
const nv = vel + acc * dt
return [pos + nv * dt, nv]
}
export function createBlobFeel(blob: BlobHandle): BlobFeel {
let squash = 0
let squashVel = 0
let leanX = 0
let leanXVel = 0
let leanZ = 0
let leanZVel = 0
let t = 0
let yaw = 0
const prevVel = new THREE.Vector3()
const mat = blob.mesh.material as THREE.MeshStandardMaterial
return {
onJump() {
// Pop tall on the way up.
squashVel += 8
},
onLand(impact: number) {
// Squash flat proportional to how hard we hit. Overshoots, then rebounds.
const k = THREE.MathUtils.clamp(impact / 9, 0, 1)
squashVel -= 5 + 9 * k
},
update(dt: number) {
// Clamp dt so a stall / tab-switch can't explode the springs.
const d = Math.min(dt, 1 / 30)
t += d
const bt = blob.body.translation()
const bv = blob.body.linvel()
// ---- position sync (visual follows the rigid ball) ----
blob.group.position.set(bt.x, bt.y, bt.z)
// ---- facing: turn to face horizontal travel, keep last heading when idle ----
const horizSpeed = Math.hypot(bv.x, bv.z)
if (horizSpeed > 1.2) {
const target = Math.atan2(bv.x, bv.z)
// shortest-arc ease
let delta = target - yaw
delta = Math.atan2(Math.sin(delta), Math.cos(delta))
yaw += delta * (1 - Math.exp(-d * 9))
}
blob.group.rotation.y = yaw
// ---- inertial lean, computed in the blob's local frame ----
const ax = (bv.x - prevVel.x) / Math.max(d, 1e-4)
const az = (bv.z - prevVel.z) / Math.max(d, 1e-4)
prevVel.set(bv.x, bv.y, bv.z)
// world -> local (undo yaw)
const cy = Math.cos(yaw)
const sy = Math.sin(yaw)
const localAx = ax * cy - az * sy
const localAz = ax * sy + az * cy
const leanTargetX = THREE.MathUtils.clamp(-localAz * 0.012, -0.22, 0.22) // nod on accel/brake
const leanTargetZ = THREE.MathUtils.clamp(localAx * 0.012, -0.22, 0.22) // bank on strafe/turn
;[leanX, leanXVel] = spring(leanX - leanTargetX, leanXVel, LEAN_K, LEAN_C, d)
leanX += leanTargetX
;[leanZ, leanZVel] = spring(leanZ - leanTargetZ, leanZVel, LEAN_K, LEAN_C, d)
leanZ += leanTargetZ
// ---- squash/stretch spring ----
;[squash, squashVel] = spring(squash, squashVel, SQUASH_K, SQUASH_C, d)
// ---- procedural life: idle breathing + a gallop bob while running ----
const idle = Math.sin(t * 2.4) * 0.02
const speedNorm = THREE.MathUtils.clamp(horizSpeed / 8, 0, 1)
const bob = Math.sin(t * 15) * 0.05 * speedNorm
const s = squash + idle + bob
let syScale = 1 + s
let sxzScale = 1 - 0.55 * s // rough volume preservation: tall => thin
syScale = THREE.MathUtils.clamp(syScale, 0.3, 1.95)
sxzScale = THREE.MathUtils.clamp(sxzScale, 0.55, 1.7)
// Pivot the squash at the blob's base so a landing plants on the floor
// instead of shrinking toward its own center. (group scale handles `size`.)
blob.mesh.scale.set(sxzScale, syScale, sxzScale)
blob.mesh.position.y = blob.radius * (syScale - 1)
blob.mesh.rotation.set(leanX, 0, leanZ)
// Face rides the squash a touch (googly life) and never gets painted.
blob.face.position.y = blob.radius * (syScale - 1) * 0.7
// ---- super-state glow (modifiers.glow) ----
const glow = blob.modifiers.glow
mat.emissiveIntensity = glow > 0 ? glow * (0.6 + 0.4 * Math.sin(t * 8)) : 0
},
}
}

View File

@ -82,10 +82,6 @@ export interface World {
addSystem(s: System): void
/** Per-render-frame hooks (visual-only work: cameras, jiggle, particles). */
onFrame(fn: (dt: number) => void): void
/** Manually advance the fixed-step sim (tests, headless verification). */
tick(steps?: number): void
/** Run frame hooks + render one frame (screenshots while backgrounded). */
renderOnce(): void
start(): void
}

View File

@ -1,115 +0,0 @@
/**
* Lane A greybox course chunk ("Breakfast Rush", primitive edition).
*
* A big, readable, absurdly-proportioned play space built from boxes so the blob
* has somewhere to strut, ramp, gap-jump, slide and splash. Everything sits on
* one generous base floor so a missed jump just drops you a little never into
* the void. Surfaces that mean something to other lanes are tagged on the mesh
* `userData` (e.g. `{ surface: 'water' }`), and colliders carry the same tag so
* Lane C can look either up.
*
* +Z is "behind the start"; the course runs toward -Z (into the camera view).
*/
import * as THREE from 'three'
import type { World } from '../contracts'
export interface GreyboxHandle {
/** Recommended blob spawn point (just above the start plateau). */
spawn: THREE.Vector3
/** Center of the open flat area where Lane C drops machines at integration. */
machineArea: THREE.Vector3
meshes: THREE.Mesh[]
}
interface BoxOpts {
color: THREE.ColorRepresentation
/** Euler rotation in radians (applied to both mesh and collider). */
rot?: THREE.Euler
/** Arbitrary surface/zone tag copied to mesh.userData AND collider.userData. */
surface?: string
transparent?: number // opacity 0..1 when set
roughness?: number
cast?: boolean
}
export function buildGreybox(world: World): GreyboxHandle {
const { scene, physics, rapier } = world
const meshes: THREE.Mesh[] = []
/** Add a static box: THREE mesh + fixed cuboid collider, optionally rotated. */
function addBox(
cx: number, cy: number, cz: number,
hx: number, hy: number, hz: number,
o: BoxOpts,
): THREE.Mesh {
const mat = new THREE.MeshStandardMaterial({
color: o.color,
roughness: o.roughness ?? 0.9,
metalness: 0.0,
transparent: o.transparent !== undefined,
opacity: o.transparent ?? 1,
})
const mesh = new THREE.Mesh(new THREE.BoxGeometry(hx * 2, hy * 2, hz * 2), mat)
mesh.position.set(cx, cy, cz)
mesh.receiveShadow = true
mesh.castShadow = o.cast ?? false
if (o.rot) mesh.rotation.copy(o.rot)
if (o.surface) mesh.userData.surface = o.surface
scene.add(mesh)
meshes.push(mesh)
const desc = rapier.ColliderDesc.cuboid(hx, hy, hz).setTranslation(cx, cy, cz)
if (o.rot) {
const q = new THREE.Quaternion().setFromEuler(o.rot)
desc.setRotation({ x: q.x, y: q.y, z: q.z, w: q.w })
}
const collider = physics.createCollider(desc)
// Mirror the tag onto the collider so Lane C can query from a physics hit.
if (o.surface) (collider as unknown as { userData?: unknown }).userData = { surface: o.surface }
return mesh
}
// ---- base floor: catches everything, spans the whole course ----
addBox(0, -0.5, -15, 28, 0.5, 60, { color: '#efe3c6', roughness: 1 })
// ---- start plateau (raised cream pad) ----
addBox(0, 0.75, 30, 10, 0.75, 9, { color: '#f7edd2', cast: true })
// ---- ramp up (orange) : lifts the -Z end so you climb toward the high shelf ----
addBox(0, 1.7, 14, 6, 0.4, 8, {
color: '#FF9500', rot: new THREE.Euler(0.42, 0, 0), cast: true,
})
// ---- gap jump: two blue shelves with a real gap over the base floor ----
addBox(0, 3.0, 6, 7, 0.5, 4, { color: '#0A84FF', cast: true }) // high shelf, z[2..10]
addBox(0, 3.0, -6, 7, 0.5, 4, { color: '#0A84FF', cast: true }) // landing, z[-10..-2]
// ---- down slope (green) : back to ground level ----
addBox(0, 1.7, -16, 6, 0.4, 8, {
color: '#34C759', rot: new THREE.Euler(-0.36, 0, 0), cast: true,
})
// ---- milk river (water zone) : thin translucent slab flush with the floor ----
addBox(0, 0.06, -30, 14, 0.06, 7, {
color: '#f4f6ff', surface: 'water', transparent: 0.72, roughness: 0.2,
})
// ---- open flat area : Lane C lands machines here at integration ----
const machineArea = new THREE.Vector3(0, 0.12, -48)
addBox(machineArea.x, 0.12, machineArea.z, 16, 0.12, 11, {
color: '#d9c9a0', surface: 'machine-area',
})
// ---- finish pad (bright pink, raised) ----
addBox(0, 0.6, -64, 9, 0.6, 6, { color: '#FF6EB4', surface: 'finish', cast: true })
// ---- absurd-proportion scenery: a giant cereal box beside the start ----
addBox(-20, 7, 26, 4, 7, 3, { color: '#FFD60A', cast: true })
addBox(20, 5, 12, 3, 5, 3, { color: '#AF52DE', cast: true })
return {
spawn: new THREE.Vector3(0, 3.5, 30),
machineArea,
meshes,
}
}

View File

@ -1,121 +1 @@
/**
* Lane A demo the playground.
*
* Boots the shared world, builds the greybox, spawns a blob, and wires the
* controller (physics), feel (wobble, via the frozen events) and follow camera.
*
* CONTROLS
* WASD / Arrows move (camera-relative)
* Space jump
*
* DEBUG KEYS (toggle press again to reset to base). Proves modifier plumbing:
* 1 speedMul 1.6 2 jumpMul 1.5 3 size 1.5
* 4 massMul 3.0 5 grip 1.0 6 glow 1.0 (super-state pulse)
* 0 reset all modifiers R respawn at start
*
* A HUD (top-left) and a once-per-second console "state" line report live values
* so integration can verify fast without a screenshot.
*/
import { createWorld } from '../world'
import { defaultModifiers } from '../contracts'
import { buildGreybox } from '../course/greybox'
import { createBlob } from '../blob/createBlob'
import { createBlobController } from '../blob/controller'
import { createBlobFeel } from '../blob/feel'
import { createFollowCamera } from '../blob/camera'
const world = await createWorld(document.getElementById('app')!)
const course = buildGreybox(world)
const blob = createBlob(world, { position: course.spawn })
world.blob = blob
// physics system (fixed 60Hz)
world.addSystem(createBlobController(world, blob))
// feel layer — driven by the FROZEN wire events, updated per render frame
const feel = createBlobFeel(blob)
world.events.on('blob:jumped', () => feel.onJump())
world.events.on('blob:landed', (p: { impact: number }) => feel.onLand(p.impact))
world.onFrame((dt) => feel.update(dt))
// follow camera
const camera = createFollowCamera(world, blob)
world.onFrame((dt) => camera.update(dt))
// ---- event counters + last-impact (for HUD/console) ----
let jumps = 0
let lands = 0
let lastImpact = 0
world.events.on('blob:jumped', () => { jumps++ })
world.events.on('blob:landed', (p: { impact: number }) => { lands++; lastImpact = p.impact })
// ---- respawn if you fall off the world ----
world.onFrame(() => {
const t = blob.body.translation()
if (t.y < -25) respawn()
})
function respawn(): void {
blob.body.setTranslation({ x: course.spawn.x, y: course.spawn.y, z: course.spawn.z }, true)
blob.body.setLinvel({ x: 0, y: 0, z: 0 }, true)
}
// ---- debug keys ----
const m = blob.modifiers
const toggle = (cur: number, on: number, base: number) => (cur === base ? on : base)
addEventListener('keydown', (e) => {
switch (e.code) {
case 'Digit1': m.speedMul = toggle(m.speedMul, 1.6, 1); break
case 'Digit2': m.jumpMul = toggle(m.jumpMul, 1.5, 1); break
case 'Digit3': m.size = toggle(m.size, 1.5, 1); break
case 'Digit4': m.massMul = toggle(m.massMul, 3.0, 1); break
case 'Digit5': m.grip = toggle(m.grip, 1.0, 0); break
case 'Digit6': m.glow = toggle(m.glow, 1.0, 0); break
case 'Digit0': Object.assign(m, defaultModifiers()); break
case 'KeyR': respawn(); break
default: return
}
console.log('[lane-a] modifiers', JSON.stringify(m))
})
// ---- HUD overlay ----
const hud = document.createElement('div')
hud.style.cssText =
'position:fixed;top:10px;left:10px;font:12px/1.5 ui-monospace,Menlo,monospace;' +
'color:#0a2540;background:rgba(255,255,255,.72);padding:10px 12px;border-radius:8px;' +
'white-space:pre;pointer-events:none;z-index:10'
document.body.appendChild(hud)
// ---- per-frame HUD + fps + once/sec console state line ----
let frames = 0
let fps = 0
let acc = 0
let logAcc = 0
world.onFrame((dt) => {
frames++
acc += dt
if (acc >= 0.5) { fps = Math.round(frames / acc); frames = 0; acc = 0 }
const t = blob.body.translation()
const v = blob.body.linvel()
const speed = Math.hypot(v.x, v.z)
hud.textContent =
`BLOBBO · lane-a ${fps} fps\n` +
`pos ${t.x.toFixed(1)} ${t.y.toFixed(1)} ${t.z.toFixed(1)}\n` +
`spd ${speed.toFixed(2)} jumps ${jumps} lands ${lands} impact ${lastImpact.toFixed(1)}\n` +
`mods speed ${m.speedMul} jump ${m.jumpMul} size ${m.size} mass ${m.massMul} grip ${m.grip} glow ${m.glow}\n` +
`WASD move · Space jump · 1 speed 2 jump 3 size 4 mass 5 grip 6 glow · 0 reset · R respawn`
logAcc += dt
if (logAcc >= 1) {
logAcc = 0
console.log(
`[lane-a] state fps=${fps} pos=(${t.x.toFixed(1)},${t.y.toFixed(1)},${t.z.toFixed(1)}) ` +
`spd=${speed.toFixed(2)} jumps=${jumps} lands=${lands} impact=${lastImpact.toFixed(1)} ` +
`mods=${JSON.stringify(m)}`,
)
}
})
world.start()
console.log('[lane-a] booted. WASD to move, Space to jump, keys 1-6 modifiers, 0 reset, R respawn.')
console.log("lane-a demo: pending — lane agent replaces this file")

View File

@ -1,262 +1 @@
/**
* Lane B demo the paint core, standalone. A stub white test ball (NOT Lane A's
* blob) orbits between a RED and a GREEN cannon. Paint lands where each glob
* hits, coverage drives buffs (speed/embers/grip/waterproof/mass/glow), and the
* HUD reads it live. Cleanse scrubs it off.
*
* Everything Lane B ships is exercised here: PaintSkin, PaintCannon, BuffSystem,
* PaintHUD, and the inbound event protocol via installPaint().
*
* DEBUG KEYS
* 1 / 2 fire the RED / GREEN cannon at the ball right now
* B / N emit machine:signal for the RED / GREEN cannon's trigger id
* 9 / 0 flood ~80% RED / GREEN (fast path to the super state)
* G emit paint:request-splat on the ball (simulates a Lane C bucket dump)
* K emit paint:request-cleanse {0.5} (simulates a Lane C bubble arch)
* C cleanse 50% (scrub holes) X cleanse 100% (full clean)
* P pause / resume the ball's orbit (watch the paint sit)
* H toggle the HUD
* A state line prints to the console every second: coverage %s, buffs, splats/s.
*/
import * as THREE from 'three'
import { createWorld } from '../world'
import { defaultModifiers, PALETTE } from '../contracts'
import type { PaintColor } from '../contracts'
import {
BuffSystem,
PaintCannon,
PaintHUD,
PaintSkin,
installPaint,
COLOR_ORDER,
} from '../paint'
const RED_SIGNAL = 'sig-red'
const GREEN_SIGNAL = 'sig-green'
const ORBIT_R = 3.6
const BALL_R = 0.6
const world = await createWorld(document.getElementById('app')!)
const { scene, camera, physics, rapier, events } = world
camera.position.set(0, 7.5, 12)
// ---- floor ----------------------------------------------------------------
const floor = new THREE.Mesh(
new THREE.BoxGeometry(40, 1, 40),
new THREE.MeshStandardMaterial({ color: '#e8d9b8' }),
)
floor.position.y = -0.5
floor.receiveShadow = true
scene.add(floor)
physics.createCollider(
rapier.ColliderDesc.cuboid(20, 0.5, 20).setTranslation(0, -0.5, 0).setFriction(1.2),
)
// ---- stub test ball (our own — NOT Lane A's blob) -------------------------
const ballMesh = new THREE.Mesh(
new THREE.SphereGeometry(BALL_R, 48, 32),
new THREE.MeshStandardMaterial({ color: '#F5F5F7', roughness: 0.35, metalness: 0 }),
)
ballMesh.castShadow = true
scene.add(ballMesh)
const ballBody = physics.createRigidBody(
rapier.RigidBodyDesc.dynamic().setTranslation(ORBIT_R, BALL_R + 0.2, 0).setAngularDamping(0.4),
)
physics.createCollider(
rapier.ColliderDesc.ball(BALL_R).setRestitution(0.2).setFriction(1.4).setDensity(2),
ballBody,
)
const baseMass = ballBody.mass()
// the blob-like target the paint systems operate on
const blob = {
mesh: ballMesh,
body: ballBody,
modifiers: defaultModifiers(),
paint: new PaintSkin(ballMesh, { size: 256 }),
}
// ---- paint systems --------------------------------------------------------
const hud = new PaintHUD()
const redCannon = new PaintCannon({
world,
position: new THREE.Vector3(-7, 0, -1.5),
color: 'red',
target: ballMesh,
paint: blob.paint,
targetRadius: BALL_R,
triggerId: RED_SIGNAL,
interval: 0.9,
muzzleSpeed: 15,
splatRadius: 0.3,
})
const greenCannon = new PaintCannon({
world,
position: new THREE.Vector3(7, 0, 1.5),
color: 'green',
target: ballMesh,
paint: blob.paint,
targetRadius: BALL_R,
triggerId: GREEN_SIGNAL,
interval: 1.0,
muzzleSpeed: 15,
splatRadius: 0.3,
})
const buffs = new BuffSystem(world, blob)
// inbound Lane C protocol → this blob (proximity-gated splats + cleanse)
installPaint(world, { mesh: ballMesh, paint: blob.paint })
world.addSystem(redCannon)
world.addSystem(greenCannon)
world.addSystem(buffs)
// ---- ball orbit controller (reads modifiers so buffs visibly change it) ---
let paused = false
world.addSystem({
update(dt: number) {
// paint = weight: reflect massMul in the physics body
ballBody.setAdditionalMass(Math.max(0, baseMass * (blob.modifiers.massMul - 1)), true)
const p = ballBody.translation()
if (!paused) {
// chase a point a little ahead on the ring; force-based so it rolls
const cur = Math.atan2(p.z, p.x)
const targetAngle = cur + 0.6
const tx = Math.cos(targetAngle) * ORBIT_R
const tz = Math.sin(targetAngle) * ORBIT_R
const dx = tx - p.x
const dz = tz - p.z
const len = Math.hypot(dx, dz) || 1
const push = 9 * blob.modifiers.speedMul // BURN makes it faster
ballBody.applyImpulse({ x: (dx / len) * push * dt, y: 0, z: (dz / len) * push * dt }, true)
// cap horizontal speed (scaled by speed buff)
const v = ballBody.linvel()
const sp = Math.hypot(v.x, v.z)
const maxSp = 4.2 * blob.modifiers.speedMul
if (sp > maxSp) {
const k = maxSp / sp
ballBody.setLinvel({ x: v.x * k, y: v.y, z: v.z * k }, true)
}
}
// sync mesh to body (rotation included → paint rides the spinning body)
ballMesh.position.set(p.x, p.y, p.z)
const q = ballBody.rotation()
ballMesh.quaternion.set(q.x, q.y, q.z, q.w)
},
})
// ---- camera ---------------------------------------------------------------
world.onFrame(() => {
camera.lookAt(ballMesh.position.x * 0.3, 0.5, ballMesh.position.z * 0.3)
})
// ---- debug keys -----------------------------------------------------------
function floodColor(color: PaintColor, splats = 130) {
for (let i = 0; i < splats; i++) {
blob.paint.applySplat(
new THREE.Vector2(Math.random(), 0.1 + Math.random() * 0.8),
color,
0.55,
)
}
console.log(`[flood] ${color}${splats} splats`)
}
addEventListener('keydown', (e) => {
switch (e.code) {
case 'Digit1':
redCannon.fire()
break
case 'Digit2':
greenCannon.fire()
break
case 'KeyB':
events.emit('machine:signal', { id: RED_SIGNAL })
console.log('[signal] machine:signal', RED_SIGNAL)
break
case 'KeyN':
events.emit('machine:signal', { id: GREEN_SIGNAL })
console.log('[signal] machine:signal', GREEN_SIGNAL)
break
case 'Digit9':
floodColor('red')
break
case 'Digit0':
floodColor('green')
break
case 'KeyG': {
// simulate a Lane C bucket dump (BLUE) landing on the ball
const pos = ballMesh.position.clone().add(new THREE.Vector3(0, BALL_R, 0))
events.emit('paint:request-splat', { point: pos, color: 'blue' as PaintColor, radius: 0.35 })
console.log('[request-splat] blue on ball')
break
}
case 'KeyK':
events.emit('paint:request-cleanse', { fraction: 0.5 })
console.log('[request-cleanse] 0.5')
break
case 'KeyC':
blob.paint.cleanse(0.5)
console.log('[cleanse] 0.5 (scrub)')
break
case 'KeyX':
blob.paint.cleanse(1)
console.log('[cleanse] full')
break
case 'KeyP':
paused = !paused
console.log(`[orbit] ${paused ? 'paused' : 'running'}`)
break
case 'KeyH':
hud.root.style.display = hud.root.style.display === 'none' ? '' : 'none'
break
}
})
// ---- HUD + console state line ---------------------------------------------
let splatCount = 0
events.on('paint:splatted', (p: { color: PaintColor; target: string }) => {
if (p.target === 'blob') splatCount++
})
let lastLog = performance.now()
world.onFrame(() => {
const cov = blob.paint.coverage()
hud.update(cov, blob.modifiers)
const now = performance.now()
if (now - lastLog >= 1000) {
lastLog = now
const pcts = COLOR_ORDER.filter((c) => cov.byColor[c] > 0.005)
.map((c) => `${c}:${(cov.byColor[c] * 100).toFixed(0)}%`)
.join(' ')
const m = blob.modifiers
const buffStr = [
m.speedMul > 1.001 ? `speed×${m.speedMul.toFixed(2)}` : '',
m.grip > 0.001 ? `grip${m.grip.toFixed(2)}` : '',
m.waterproof ? 'waterproof' : '',
`mass×${m.massMul.toFixed(2)}`,
m.glow > 0 ? 'SUPER' : '',
].filter(Boolean).join(' ')
console.log(
`[paint] total ${(cov.total * 100).toFixed(0)}% | ${pcts || '(clean)'} | ${buffStr} | +${splatCount} splats/s`,
)
splatCount = 0
}
})
// friendly banner
console.log(
'%cBLOBBO Lane B — Paint demo',
`color:${PALETTE.red};font-weight:700`,
)
console.log('keys: 1/2 fire B/N signal 9/0 flood G request-splat K request-cleanse C/X cleanse P pause H hud')
world.start()
console.log("lane-b demo: pending — lane agent replaces this file")

View File

@ -1,302 +1 @@
/**
* 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')
}
console.log("lane-c demo: pending — lane agent replaces this file")

View File

@ -1,117 +1,56 @@
/**
* INTEGRATION SEAM owned by integration, not by lanes.
* The playable slice: Lane A blob + course, Lane B paint + buffs, and (when
* lane-c merges) the machine chain on the course's machine area.
* Composes lane modules into the playable slice. While lanes are in flight this
* boots a placeholder ball on a flat floor so `npm run dev` always shows life.
*/
import * as THREE from 'three'
import type { World } from './contracts'
import { buildGreybox } from './course/greybox'
import { createBlob } from './blob/createBlob'
import { createBlobController } from './blob/controller'
import { createBlobFeel } from './blob/feel'
import { createFollowCamera } from './blob/camera'
import { PaintSkin, PaintCannon, BuffSystem, PaintHUD, installPaint } from './paint/index'
import { createPressurePlate, createSpringBoot, createBucketDump, createBubbleArch, createConveyorBelt } from './machine/index'
export function installGame(world: World) {
const course = buildGreybox(world)
const { scene, physics, rapier } = world
// ---- blob (Lane A) ----
const blob = createBlob(world, { position: course.spawn })
blob.paint = new PaintSkin(blob.mesh, { size: 256 })
world.blob = blob
// floor
const floor = new THREE.Mesh(
new THREE.BoxGeometry(60, 1, 60),
new THREE.MeshStandardMaterial({ color: '#e8d9b8' }))
floor.position.y = -0.5
floor.receiveShadow = true
scene.add(floor)
physics.createCollider(rapier.ColliderDesc.cuboid(30, 0.5, 30)
.setTranslation(0, -0.5, 0))
world.addSystem(createBlobController(world, blob))
// placeholder blob-ball (replaced by lane A's createBlob at integration)
const ball = new THREE.Mesh(
new THREE.SphereGeometry(0.6, 32, 24),
new THREE.MeshStandardMaterial({ color: '#F5F5F7', roughness: 0.35 }))
ball.castShadow = true
scene.add(ball)
const body = physics.createRigidBody(
rapier.RigidBodyDesc.dynamic().setTranslation(0, 3, 0))
physics.createCollider(
rapier.ColliderDesc.ball(0.6).setRestitution(0.4).setFriction(1.0), body)
const feel = createBlobFeel(blob)
world.events.on('blob:jumped', () => feel.onJump())
world.events.on('blob:landed', (p: { impact: number }) => feel.onLand(p.impact))
world.onFrame((dt) => feel.update(dt))
const keys = new Set<string>()
addEventListener('keydown', (e) => keys.add(e.code))
addEventListener('keyup', (e) => keys.delete(e.code))
const camera = createFollowCamera(world, blob)
world.onFrame((dt) => camera.update(dt))
// ---- paint (Lane B): cannons staged along the racing line ----
const skin = blob.paint as PaintSkin
const cannonSpecs = [
{ color: 'red' as const, position: new THREE.Vector3(-9, 2.5, 14), triggerId: 'cannon-red' },
{ color: 'green' as const, position: new THREE.Vector3(9, 4.5, -6), triggerId: 'cannon-green' },
{ color: 'blue' as const, position: new THREE.Vector3(-9, 2.0, -30), triggerId: 'cannon-blue' },
]
for (const spec of cannonSpecs) {
world.addSystem(new PaintCannon({
world,
position: spec.position,
color: spec.color,
target: blob.mesh,
paint: skin,
targetRadius: blob.radius,
triggerId: spec.triggerId,
interval: 2.2,
muzzleSpeed: 26, // default 16 caps ballistic range at ~18u — course distances need ~26
splatRadius: 0.45,
}))
}
// ---- machine chain (Lane C) on the course's machine area ----
// The pressure plate needs MORE than a clean blob's mass: paint = weight =
// machine access (GDD §5.2/§5.4). Clean blobs roll over it; painted trip it.
const baseMass = blob.body.mass()
createPressurePlate(world, {
id: 'plate-1', position: [0, 0.24, -40],
massThreshold: baseMass * 1.25, emits: 'boot-1', size: [4, 3],
})
createSpringBoot(world, {
id: 'boot-1', position: [0, 0.2, -42.5], // 0.5s telegraph ≈ 3u of roll past the plate
impulse: baseMass * 18, // sized for a plate-tripping (painted, heavy) blob to reach the bucket
direction: [0, 1, -0.55],
onSignal: 'boot-1', strikeSize: [2.4, 1.4, 3],
})
// Bucket hangs over the conveyor: the belt delivers you underneath at 4 u/s,
// the catch trigger arms the teeter, and the pour lands at the spout point
// (bucket.x + 1.4) — which is the belt's centerline. Linger and get soaked.
createBucketDump(world, {
// Timed empirically: catch trips at z≈-46, teeter ≈0.8s, belt 3 u/s →
// blob reaches the spout (bucket.z) as the pour lands. Miss was 1.73u
// with bucket at -50 / belt 4.
id: 'bucket-1', position: [4.6, 6, -46.5],
color: 'purple', radius: 0.8,
// Shallow catch just upstream of the spout: observed teeter drift is only
// 0.4-2u depending on speed, so trip near the spout, not a belt-length away.
trigger: { size: [3, 2, 1.6], offset: [1.4, -5.2, 0.75] },
})
createBubbleArch(world, {
id: 'arch-1', position: [-4.5, 0.12, -56], fraction: 0.6, // far-side lane: cleansing is a detour you choose
})
createConveyorBelt(world, {
id: 'belt-1', position: [6, 0.25, -50],
size: [3, 0.5, 10], velocity: [0, 0, -3],
world.addSystem({
update() {
const f = 18
const impulse = { x: 0, y: 0, z: 0 }
if (keys.has('KeyW') || keys.has('ArrowUp')) impulse.z -= f
if (keys.has('KeyS') || keys.has('ArrowDown')) impulse.z += f
if (keys.has('KeyA') || keys.has('ArrowLeft')) impulse.x -= f
if (keys.has('KeyD') || keys.has('ArrowRight')) impulse.x += f
body.applyImpulse({ x: impulse.x / 60, y: 0, z: impulse.z / 60 }, true)
const p = body.translation()
ball.position.set(p.x, p.y, p.z)
const q = body.rotation()
ball.quaternion.set(q.x, q.y, q.z, q.w)
},
})
world.addSystem(new BuffSystem(world, { mesh: blob.mesh, modifiers: blob.modifiers, paint: skin }))
installPaint(world, { mesh: blob.mesh, paint: skin })
const hud = new PaintHUD()
world.onFrame(() => {
hud.update(skin.coverage(), blob.modifiers) // coverage() self-caches at 250ms
world.camera.lookAt(ball.position)
})
// ---- fall respawn + debug ----
const respawn = () => {
blob.body.setTranslation({ x: course.spawn.x, y: course.spawn.y, z: course.spawn.z }, true)
blob.body.setLinvel({ x: 0, y: 0, z: 0 }, true)
world.events.emit('paint:request-cleanse', { fraction: 1 })
}
world.onFrame(() => {
if (blob.body.translation().y < -25) respawn()
})
addEventListener('keydown', (e) => {
if (e.code === 'KeyR') respawn()
if (e.code === 'KeyC') world.events.emit('paint:request-cleanse', { fraction: 0.5 })
})
// debug handle (dev builds are the only builds right now)
;(window as unknown as { BLOBBO: unknown }).BLOBBO = { world, blob, skin, course }
return { course, blob }
}

View File

@ -1,35 +0,0 @@
/**
* 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'

View File

@ -1,730 +0,0 @@
/**
* 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
}
},
})
// 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)
}
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 }
}

View File

@ -1,113 +0,0 @@
/**
* 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
}

View File

@ -1,157 +0,0 @@
/**
* 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)
// Fixed-step, not onFrame: the windup GATES gameplay (forces, paint dumps),
// so it must advance with the sim — onFrame stalls in hidden tabs, which
// would freeze every contraption mid-telegraph (found at integration).
world.addSystem({ update: (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)
}

View File

@ -1,159 +0,0 @@
/**
* BuffSystem every fixed step, reads the blob's coverage() and writes
* blob.modifiers per GDD §6 (MVP subset), then drives the two cheap visual
* side-effects that make the buff READ on the body: an ember trail while the RED
* BURN buff is active, and an emissive glow pulse in the super state.
*
* The number-crunching lives in computeModifiers() (coverage-math.ts, pure &
* unit-tested); this System just applies it and paints the juice.
*/
import * as THREE from 'three'
import type { BlobModifiers, System, PaintColor } from '../contracts'
import { PALETTE } from '../contracts'
import type { PaintSkin } from './skin'
import {
ACTIVATE,
buffStrength,
computeModifiers,
dominantColor,
superColor,
} from './coverage-math'
export interface BuffTarget {
mesh: THREE.Mesh
modifiers: BlobModifiers
paint?: PaintSkin
}
const EMBER_COUNT = 90
export class BuffSystem implements System {
private readonly scene: THREE.Scene
private readonly blob: BuffTarget
// ember pool
private readonly embers: THREE.Points
private readonly emberPos: Float32Array
private readonly emberCol: Float32Array
private readonly emberVel: Float32Array
private readonly emberLife: Float32Array
private emberCursor = 0
private readonly emberHex = new THREE.Color()
private readonly _spawnAt = new THREE.Vector3()
private clock = 0
constructor(world: { scene: THREE.Scene }, blob: BuffTarget) {
this.scene = world.scene
this.blob = blob
// ember tint: red→orange blend, computed once
this.emberHex.set(PALETTE.red).lerp(new THREE.Color(PALETTE.orange), 0.5)
this.emberPos = new Float32Array(EMBER_COUNT * 3)
this.emberCol = new Float32Array(EMBER_COUNT * 3)
this.emberVel = new Float32Array(EMBER_COUNT * 3)
this.emberLife = new Float32Array(EMBER_COUNT) // 0 = dead
const geo = new THREE.BufferGeometry()
geo.setAttribute('position', new THREE.BufferAttribute(this.emberPos, 3))
geo.setAttribute('color', new THREE.BufferAttribute(this.emberCol, 3))
const mat = new THREE.PointsMaterial({
size: 0.16,
vertexColors: true,
transparent: true,
depthWrite: false,
blending: THREE.AdditiveBlending,
sizeAttenuation: true,
})
this.embers = new THREE.Points(geo, mat)
this.embers.frustumCulled = false
this.scene.add(this.embers)
}
update(dt: number): void {
this.clock += dt
const paint = this.blob.paint
if (!paint) return
const cov = paint.coverage() // 250ms-cached; no per-frame readback
const mods = computeModifiers(cov)
// copy into the shared modifiers object (readers hold this reference)
const m = this.blob.modifiers
m.speedMul = mods.speedMul
m.jumpMul = mods.jumpMul
m.massMul = mods.massMul
m.grip = mods.grip
m.size = mods.size
m.waterproof = mods.waterproof
m.glow = mods.glow
this.updateEmbers(dt, cov.byColor.red)
this.updateGlow(mods.glow, superColor(cov) ?? dominantColor(cov))
}
dispose(): void {
this.scene.remove(this.embers)
this.embers.geometry.dispose()
;(this.embers.material as THREE.Material).dispose()
}
// ---- ember trail (RED BURN) ---------------------------------------------
private updateEmbers(dt: number, red: number): void {
const strength = buffStrength(red) // 0 below 20%
if (red >= ACTIVATE) {
// spawn rate scales with how hot the blob is
const spawn = strength * 3
let n = Math.floor(spawn) + (Math.random() < spawn % 1 ? 1 : 0)
this.blob.mesh.getWorldPosition(this._spawnAt)
while (n-- > 0) this.spawnEmber(this._spawnAt)
}
const emberHex = this.emberHex
for (let i = 0; i < EMBER_COUNT; i++) {
if (this.emberLife[i] <= 0) continue
this.emberLife[i] -= dt
const j = i * 3
if (this.emberLife[i] <= 0) {
this.emberCol[j] = this.emberCol[j + 1] = this.emberCol[j + 2] = 0 // invisible
continue
}
this.emberVel[j + 1] += 2.2 * dt // buoyant rise
this.emberPos[j] += this.emberVel[j] * dt
this.emberPos[j + 1] += this.emberVel[j + 1] * dt
this.emberPos[j + 2] += this.emberVel[j + 2] * dt
const fade = Math.min(1, this.emberLife[i] / 0.5)
this.emberCol[j] = emberHex.r * fade
this.emberCol[j + 1] = emberHex.g * fade
this.emberCol[j + 2] = emberHex.b * fade
}
this.embers.geometry.attributes.position.needsUpdate = true
this.embers.geometry.attributes.color.needsUpdate = true
}
private spawnEmber(center: THREE.Vector3): void {
const i = this.emberCursor
this.emberCursor = (this.emberCursor + 1) % EMBER_COUNT
const j = i * 3
this.emberPos[j] = center.x + (Math.random() - 0.5) * 0.5
this.emberPos[j + 1] = center.y + (Math.random() - 0.3) * 0.4
this.emberPos[j + 2] = center.z + (Math.random() - 0.5) * 0.5
this.emberVel[j] = (Math.random() - 0.5) * 0.8
this.emberVel[j + 1] = 0.6 + Math.random() * 0.8
this.emberVel[j + 2] = (Math.random() - 0.5) * 0.8
this.emberLife[i] = 0.5 + Math.random() * 0.4
}
// ---- glow (super state) --------------------------------------------------
private updateGlow(glow: number, color: PaintColor | null): void {
const mat = this.blob.mesh.material as THREE.MeshStandardMaterial
if (!('emissive' in mat)) return
if (glow > 0 && color) {
const pulse = 0.55 + 0.45 * Math.sin(this.clock * 8)
mat.emissive.set(PALETTE[color])
mat.emissiveIntensity = glow * pulse * 1.4
} else {
mat.emissiveIntensity = 0
}
}
}

View File

@ -1,305 +0,0 @@
/**
* PaintCannon a placed emitter that lobs paint-glob projectiles on a gravity
* arc. On hitting the blob it stamps a splat exactly where it struck (the magic
* moment) and emits `paint:splatted`. Fires on an interval AND on
* `machine:signal {id}` matching its trigger id (Lane C wires the signals).
*
* Projectiles are simulated by a manual sweep (no Rapier collider churn): each
* step advances position under gravity and does a swept sphere-vs-sphere test
* against the target so fast shots can't tunnel through.
*/
import * as THREE from 'three'
import type { PaintColor, System, World } from '../contracts'
import { PALETTE } from '../contracts'
import type { PaintSkin } from './skin'
export interface PaintCannonConfig {
world: World
position: THREE.Vector3
color: PaintColor
/** Aim target (usually the blob mesh) — sampled at fire time for a lead shot. */
target: THREE.Object3D
/** Skin to stamp on a blob hit. */
paint: PaintSkin
/** Target bounding radius (world units) for the hit test. */
targetRadius: number
/** Fire on `machine:signal {id}` when id === triggerId. */
triggerId?: string
/** Seconds between auto-fires. 0/undefined disables the auto cadence. */
interval?: number
muzzleSpeed?: number
projRadius?: number
/** World-space splat radius handed to the skin on hit. */
splatRadius?: number
/** Gravity magnitude for the arc (default 14, matching world.ts). */
gravity?: number
/** y below which a projectile is considered to have hit the ground. */
groundY?: number
/** Lead a moving target using its estimated velocity (default true). */
lead?: boolean
}
interface Projectile {
mesh: THREE.Mesh
vel: THREE.Vector3
prev: THREE.Vector3
life: number
}
const MAX_LIFE = 6 // seconds before a stray glob is culled
export class PaintCannon implements System {
readonly color: PaintColor
private readonly cfg: Required<
Omit<PaintCannonConfig, 'triggerId'>
> & { triggerId?: string }
private readonly scene: THREE.Scene
private readonly projectiles: Projectile[] = []
private readonly projGeo: THREE.SphereGeometry
private readonly projMat: THREE.MeshStandardMaterial
private readonly barrel: THREE.Group
private acc = 0
private readonly unsub: () => void
// target velocity estimate (for leading a moving blob)
private readonly _lastTarget = new THREE.Vector3()
private readonly _targetVel = new THREE.Vector3()
private hasLast = false
// scratch
private readonly _tpos = new THREE.Vector3()
private readonly _aim = new THREE.Vector3()
private readonly _hit = new THREE.Vector3()
private readonly _vtmp = new THREE.Vector3()
constructor(config: PaintCannonConfig) {
this.color = config.color
this.cfg = {
interval: 0,
muzzleSpeed: 16,
projRadius: 0.12,
splatRadius: 0.28,
gravity: 14,
groundY: 0,
lead: true,
...config,
}
this.scene = config.world.scene
const hex = PALETTE[config.color]
this.projGeo = new THREE.SphereGeometry(this.cfg.projRadius, 12, 10)
this.projMat = new THREE.MeshStandardMaterial({
color: hex,
emissive: hex,
emissiveIntensity: 0.35,
roughness: 0.4,
})
this.barrel = this.buildBarrel(hex)
this.barrel.position.copy(config.position)
this.scene.add(this.barrel)
this.unsub = config.world.events.on('machine:signal', (p: { id: string }) => {
if (this.cfg.triggerId && p?.id === this.cfg.triggerId) this.fire()
})
}
/** Fire one glob now, leading the target with a ballistic solve. */
fire(): void {
this.target(this._tpos)
const from = this.muzzleWorld()
// Lead: aim where the target will be after the glob's ~time-of-flight.
this._aim.copy(this._tpos)
if (this.cfg.lead && this.hasLast) {
const flight = from.distanceTo(this._tpos) / this.cfg.muzzleSpeed
this._aim.addScaledVector(this._targetVel, flight)
}
const vel =
solveBallisticVelocity(from, this._aim, this.cfg.muzzleSpeed, this.cfg.gravity) ??
this._aim.clone().sub(from).normalize().multiplyScalar(this.cfg.muzzleSpeed)
// Point the barrel along the launch direction.
this.aimBarrel(vel)
const mesh = new THREE.Mesh(this.projGeo, this.projMat)
mesh.castShadow = true
mesh.position.copy(from)
this.scene.add(mesh)
this.projectiles.push({
mesh,
vel: vel.clone(),
prev: from.clone(),
life: 0,
})
}
update(dt: number): void {
// maintain a smoothed target-velocity estimate for leading
if (dt > 0) {
this.target(this._tpos)
if (this.hasLast) {
const inv = 1 / dt
this._vtmp.copy(this._tpos).sub(this._lastTarget).multiplyScalar(inv)
this._targetVel.lerp(this._vtmp, 0.4)
}
this._lastTarget.copy(this._tpos)
this.hasLast = true
}
// auto cadence
if (this.cfg.interval > 0) {
this.acc += dt
while (this.acc >= this.cfg.interval) {
this.acc -= this.cfg.interval
this.fire()
}
}
if (this.projectiles.length === 0) return
this.target(this._tpos)
const hitDist = this.cfg.targetRadius + this.cfg.projRadius
for (let i = this.projectiles.length - 1; i >= 0; i--) {
const p = this.projectiles[i]
p.prev.copy(p.mesh.position)
p.vel.y -= this.cfg.gravity * dt
p.mesh.position.addScaledVector(p.vel, dt)
p.life += dt
// swept hit test against the target sphere (prev -> current segment)
const d = closestDistToSegment(this._tpos, p.prev, p.mesh.position, this._hit)
if (d <= hitDist) {
this.cfg.paint.splatAtPoint(this._hit, this.color, this.cfg.splatRadius)
this.cfg.world.events.emit('paint:splatted', {
color: this.color,
target: 'blob',
})
this.despawn(i)
continue
}
if (p.mesh.position.y <= this.cfg.groundY || p.life > MAX_LIFE) {
// Ground/world decals are V1; just despawn (protocol-complete signal).
this.cfg.world.events.emit('paint:splatted', {
color: this.color,
target: 'world',
})
this.despawn(i)
}
}
}
dispose(): void {
this.unsub()
for (let i = this.projectiles.length - 1; i >= 0; i--) this.despawn(i)
this.scene.remove(this.barrel)
this.projGeo.dispose()
this.projMat.dispose()
}
// ---- internals ----------------------------------------------------------
private target(out: THREE.Vector3): THREE.Vector3 {
return this.cfg.target.getWorldPosition(out)
}
private muzzleWorld(): THREE.Vector3 {
// muzzle a little in front of / above the barrel base
return this.cfg.position.clone().add(new THREE.Vector3(0, 0.55, 0))
}
private despawn(i: number): void {
const p = this.projectiles[i]
this.scene.remove(p.mesh)
this.projectiles.splice(i, 1)
}
private buildBarrel(hex: string): THREE.Group {
const g = new THREE.Group()
const base = new THREE.Mesh(
new THREE.CylinderGeometry(0.45, 0.55, 0.5, 16),
new THREE.MeshStandardMaterial({ color: '#3a3a44', roughness: 0.7 }),
)
base.castShadow = true
g.add(base)
const tube = new THREE.Mesh(
new THREE.CylinderGeometry(0.22, 0.28, 1.1, 16),
new THREE.MeshStandardMaterial({ color: hex, roughness: 0.5 }),
)
tube.castShadow = true
// pivot the tube from the base and tilt it up; child group lets us aim it
const pivot = new THREE.Group()
pivot.position.y = 0.45
tube.position.y = 0.4
tube.rotation.x = Math.PI / 2 // point +Z by default
pivot.add(tube)
pivot.name = 'pivot'
g.add(pivot)
return g
}
private aimBarrel(vel: THREE.Vector3): void {
const pivot = this.barrel.getObjectByName('pivot')
if (!pivot) return
const dir = vel.clone().normalize()
const m = new THREE.Matrix4().lookAt(
new THREE.Vector3(0, 0, 0),
dir,
new THREE.Vector3(0, 1, 0),
)
pivot.quaternion.setFromRotationMatrix(m)
}
}
/**
* Ballistic launch velocity to hit `to` from `from` at fixed `speed` under
* gravity magnitude `g` (down -y). Returns the LOW-arc solution, or null if the
* target is out of range. Exported for potential reuse/testing.
*/
export function solveBallisticVelocity(
from: THREE.Vector3,
to: THREE.Vector3,
speed: number,
g: number,
): THREE.Vector3 | null {
const dx = to.x - from.x
const dz = to.z - from.z
const dy = to.y - from.y
const x = Math.hypot(dx, dz)
if (x < 1e-3) {
return new THREE.Vector3(0, Math.sign(dy || 1) * speed, 0)
}
const s2 = speed * speed
const disc = s2 * s2 - g * (g * x * x + 2 * dy * s2)
if (disc < 0) return null
const root = Math.sqrt(disc)
const tanTheta = (s2 - root) / (g * x) // low arc
const theta = Math.atan(tanTheta)
const hx = dx / x
const hz = dz / x
const vh = speed * Math.cos(theta)
const vy = speed * Math.sin(theta)
return new THREE.Vector3(hx * vh, vy, hz * vh)
}
/** Closest distance from point `p` to segment `a`->`b`; writes the point to `out`. */
function closestDistToSegment(
p: THREE.Vector3,
a: THREE.Vector3,
b: THREE.Vector3,
out: THREE.Vector3,
): number {
const abx = b.x - a.x
const aby = b.y - a.y
const abz = b.z - a.z
const len2 = abx * abx + aby * aby + abz * abz
let t = 0
if (len2 > 1e-12) {
t = ((p.x - a.x) * abx + (p.y - a.y) * aby + (p.z - a.z) * abz) / len2
t = t < 0 ? 0 : t > 1 ? 1 : t
}
out.set(a.x + abx * t, a.y + aby * t, a.z + abz * t)
return out.distanceTo(p)
}

View File

@ -1,117 +0,0 @@
/**
* Unit checks for the pure paint math. No test runner / no deps run directly:
* node --experimental-strip-types src/paint/coverage-math.test.ts
* (Node 22; on Node 23 the flag is unnecessary.) Uses only console + throw so
* it also type-checks cleanly under the project's DOM tsconfig and is never
* bundled (no demo entry imports it).
*/
import type { CoverageReport, PaintColor } from '../contracts'
import {
ACTIVATE,
SUPER,
buffStrength,
computeModifiers,
countBuckets,
coverageReportFromCounts,
colorIndex,
} from './coverage-math'
let passed = 0
function ok(cond: boolean, msg: string): void {
if (!cond) throw new Error('FAIL: ' + msg)
passed++
}
function near(a: number, b: number, msg: string, eps = 1e-9): void {
ok(Math.abs(a - b) <= eps, `${msg} (got ${a}, want ${b})`)
}
// helper: build a CoverageReport from a partial byColor map
function cov(part: Partial<Record<PaintColor, number>>): CoverageReport {
const byColor = {
red: 0, orange: 0, yellow: 0, green: 0, blue: 0, purple: 0, pink: 0,
...part,
}
const total = Object.values(byColor).reduce((a, b) => a + b, 0)
return { total, byColor }
}
// ---- colorIndex ----------------------------------------------------------
ok(colorIndex('red') === 1, 'red index is 1')
ok(colorIndex('pink') === 7, 'pink index is 7')
// ---- countBuckets --------------------------------------------------------
{
const mask = new Uint8Array(100) // all base (0)
for (let i = 0; i < 30; i++) mask[i] = 1 // red
for (let i = 30; i < 50; i++) mask[i] = 4 // green
const c = countBuckets(mask, 7)
ok(c[0] === 50, 'base count 50')
ok(c[1] === 30, 'red count 30')
ok(c[4] === 20, 'green count 20')
ok(c[7] === 0, 'pink count 0')
ok(c.length === 8, 'counts length 8 (base + 7)')
}
// ---- coverageReportFromCounts --------------------------------------------
{
// counts: [base, red, orange, yellow, green, blue, purple, pink]
const counts = [50, 30, 0, 0, 20, 0, 0, 0]
const r = coverageReportFromCounts(counts, 100)
near(r.byColor.red, 0.3, 'red fraction')
near(r.byColor.green, 0.2, 'green fraction')
near(r.total, 0.5, 'total painted fraction')
// empty texture → all zeros, no NaN
const z = coverageReportFromCounts([0, 0, 0, 0, 0, 0, 0, 0], 0)
near(z.total, 0, 'empty total 0')
near(z.byColor.blue, 0, 'empty blue 0')
}
// ---- buffStrength --------------------------------------------------------
near(buffStrength(0.0), 0, 'strength below threshold is 0')
near(buffStrength(0.199), 0, 'strength just below 20% is 0')
near(buffStrength(ACTIVATE), 0.15, 'strength at 20% is the floor')
near(buffStrength(SUPER), 1, 'strength at 70% is 1')
near(buffStrength(0.9), 1, 'strength above 70% pinned at 1')
near(buffStrength(0.45), 0.15 + 0.85 * 0.5, 'strength ramps linearly at midpoint')
// ---- computeModifiers ----------------------------------------------------
{
const clean = computeModifiers(cov({}))
near(clean.speedMul, 1, 'clean speedMul 1')
near(clean.grip, 0, 'clean grip 0')
ok(clean.waterproof === false, 'clean not waterproof')
near(clean.massMul, 1, 'clean massMul 1')
near(clean.glow, 0, 'clean no glow')
near(clean.size, 1, 'clean size 1 (no grow/shrink in MVP)')
}
{
// RED just under activation: no buff yet
const m = computeModifiers(cov({ red: 0.19 }))
near(m.speedMul, 1, 'red 19% → no speed buff')
}
{
// RED at 45% → speedMul = 1 + 0.6 * buffStrength(0.45)
const s = 0.15 + 0.85 * 0.5
const m = computeModifiers(cov({ red: 0.45 }))
near(m.speedMul, 1 + 0.6 * s, 'red 45% speedMul')
near(m.massMul, 1 + 0.8 * 0.45, 'massMul tracks total 45%')
near(m.glow, 0, 'red 45% not super')
}
{
// GREEN super (70%) → grip maxed + glow
const m = computeModifiers(cov({ green: 0.7 }))
near(m.grip, 1, 'green 70% grip maxed')
near(m.glow, 1, 'green 70% is super (glow)')
}
{
// BLUE ≥20% → waterproof
const m = computeModifiers(cov({ blue: 0.25 }))
ok(m.waterproof === true, 'blue 25% waterproof')
}
{
// massMul clamps at 1.8 when fully painted
const m = computeModifiers(cov({ red: 0.5, blue: 0.5 }))
near(m.massMul, 1.8, 'full coverage massMul 1.8')
}
console.log(`coverage-math.test: ${passed} assertions passed ✓`)

View File

@ -1,127 +0,0 @@
/**
* Pure paint math no THREE / Rapier / DOM imports, so it is unit-testable in a
* bare node runtime (see coverage-math.test.ts). Everything that touches only
* numbers lives here: the exact pixel-bucket coverage count and the
* coverage -> BlobModifiers buff mapping (GDD §6 MVP subset).
*/
import type { BlobModifiers, CoverageReport, PaintColor } from '../contracts'
/**
* Canonical colour ordering. Index 0 in the mask is the unpainted base; palette
* colours occupy indices 1..7 in this order (matches the PALETTE key order in
* contracts.ts). Keep this list and the mask encoding in lockstep.
*/
export const COLOR_ORDER: readonly PaintColor[] = [
'red', 'orange', 'yellow', 'green', 'blue', 'purple', 'pink',
]
/** Mask palette index for a colour (1..7). Base/unpainted is 0. */
export function colorIndex(color: PaintColor): number {
return COLOR_ORDER.indexOf(color) + 1
}
/**
* Exact integer bucketing of a quantized mask. `mask[i]` is a palette index
* (0 = base, 1..n = colour). Returns counts of length n+1 where counts[0] is the
* unpainted pixel count. This is the whole point of quantizing on stamp: coverage
* is an exact histogram, never a fuzzy nearest-colour match.
*/
export function countBuckets(mask: Uint8Array, numColors: number): number[] {
const counts = new Array<number>(numColors + 1).fill(0)
for (let i = 0; i < mask.length; i++) counts[mask[i]]++
return counts
}
/**
* Turn raw bucket counts into a CoverageReport (fractions of TOTAL surface;
* unpainted counts in the denominator, per contracts).
*/
export function coverageReportFromCounts(
counts: number[],
totalPixels: number,
): CoverageReport {
const byColor = {} as Record<PaintColor, number>
for (let i = 0; i < COLOR_ORDER.length; i++) {
byColor[COLOR_ORDER[i]] = totalPixels > 0 ? counts[i + 1] / totalPixels : 0
}
const painted = totalPixels > 0 ? (totalPixels - counts[0]) / totalPixels : 0
return { total: painted, byColor }
}
// ---- Buff thresholds (GDD §5.1 / §6 MVP subset) ---------------------------
export const ACTIVATE = 0.20 // buff activates at ≥20% of that colour
export const SUPER = 0.70 // ≥70% one colour = super state
/** Strength floor at the activation threshold so the buff visibly "kicks in". */
const ACTIVATE_FLOOR = 0.15
const clamp01 = (x: number) => (x < 0 ? 0 : x > 1 ? 1 : x)
/**
* Buff strength 0..1 for a single colour's coverage fraction.
* 0 below the 20% threshold; ramps ACTIVATE_FLOOR..1 across 20%70%; pinned at 1
* once super. This is the "strength scales linearly between thresholds" curve.
*/
export function buffStrength(coverage: number): number {
if (coverage < ACTIVATE) return 0
if (coverage >= SUPER) return 1
const t = (coverage - ACTIVATE) / (SUPER - ACTIVATE)
return ACTIVATE_FLOOR + (1 - ACTIVATE_FLOOR) * t
}
/** Is any single colour at/above the super threshold? */
export function superColor(cov: CoverageReport): PaintColor | null {
let best: PaintColor | null = null
let bestV = SUPER
for (const c of COLOR_ORDER) {
if (cov.byColor[c] >= bestV) {
bestV = cov.byColor[c]
best = c
}
}
return best
}
/** Dominant (highest-coverage) colour, or null if nothing is painted. */
export function dominantColor(cov: CoverageReport): PaintColor | null {
let best: PaintColor | null = null
let bestV = 0
for (const c of COLOR_ORDER) {
if (cov.byColor[c] > bestV) {
bestV = cov.byColor[c]
best = c
}
}
return best
}
/**
* The core mapping: coverage -> modifiers (GDD §6 MVP subset).
* RED -> speedMul up to 1.6 (BURN)
* GREEN -> grip up to 1.0 (GRIP)
* BLUE -> waterproof (SLICK)
* total coverage -> massMul 1.0..1.8 (paint = weight, §5.2)
* any colour 70% -> super: that buff pinned at max + glow
* Pure and deterministic; the visual side-effects (ember trail, emissive pulse)
* live in BuffSystem and read `glow` / dominant colour off this result + cov.
*/
export function computeModifiers(cov: CoverageReport): BlobModifiers {
const red = cov.byColor.red
const green = cov.byColor.green
const blue = cov.byColor.blue
const speedMul = 1 + 0.6 * buffStrength(red)
const grip = buffStrength(green) // 0..1
const waterproof = blue >= ACTIVATE
const massMul = 1 + 0.8 * clamp01(cov.total)
const glow = superColor(cov) ? 1 : 0
return {
speedMul,
jumpMul: 1,
massMul,
grip,
size: 1, // purple/pink grow-shrink is V1 (§5.3) — untouched in MVP
waterproof,
glow,
}
}

View File

@ -1,106 +0,0 @@
/**
* PaintHUD a tiny DOM overlay: per-colour coverage bars + the active buff kit.
* No framework, no per-frame layout thrash (throttled to ~10Hz and only rewrites
* text/width, never rebuilds nodes).
*/
import type { BlobModifiers, CoverageReport, PaintColor } from '../contracts'
import { PALETTE } from '../contracts'
import { COLOR_ORDER, ACTIVATE, SUPER } from './coverage-math'
export class PaintHUD {
readonly root: HTMLDivElement
private readonly bars = new Map<PaintColor, { fill: HTMLDivElement; pct: HTMLSpanElement }>()
private readonly totalPct: HTMLSpanElement
private readonly buffLine: HTMLDivElement
private lastUpdate = -1e9
constructor(container: HTMLElement = document.body) {
const root = document.createElement('div')
root.style.cssText = [
'position:fixed', 'top:12px', 'left:12px', 'z-index:10',
'font:12px/1.4 ui-monospace,SFMono-Regular,Menlo,monospace',
'color:#fff', 'background:rgba(18,18,24,0.72)', 'padding:10px 12px',
'border-radius:10px', 'min-width:210px', 'user-select:none',
'box-shadow:0 4px 18px rgba(0,0,0,0.35)', 'backdrop-filter:blur(4px)',
].join(';')
const title = document.createElement('div')
title.textContent = 'PAINT COVERAGE'
title.style.cssText = 'font-weight:700;letter-spacing:0.08em;opacity:0.85;margin-bottom:8px'
root.appendChild(title)
for (const color of COLOR_ORDER) {
const row = document.createElement('div')
row.style.cssText = 'display:flex;align-items:center;gap:6px;margin:3px 0'
const swatch = document.createElement('div')
swatch.style.cssText = `width:10px;height:10px;border-radius:3px;background:${PALETTE[color]};flex:0 0 auto`
row.appendChild(swatch)
const track = document.createElement('div')
track.style.cssText = 'flex:1;height:8px;background:rgba(255,255,255,0.12);border-radius:4px;overflow:hidden'
const fill = document.createElement('div')
fill.style.cssText = `height:100%;width:0%;background:${PALETTE[color]};transition:width 0.12s linear`
track.appendChild(fill)
row.appendChild(track)
const pct = document.createElement('span')
pct.textContent = '0%'
pct.style.cssText = 'width:34px;text-align:right;opacity:0.9'
row.appendChild(pct)
root.appendChild(row)
this.bars.set(color, { fill, pct })
}
const totalRow = document.createElement('div')
totalRow.style.cssText = 'margin-top:8px;border-top:1px solid rgba(255,255,255,0.15);padding-top:6px'
totalRow.innerHTML = 'total painted: '
this.totalPct = document.createElement('span')
this.totalPct.textContent = '0%'
this.totalPct.style.fontWeight = '700'
totalRow.appendChild(this.totalPct)
root.appendChild(totalRow)
this.buffLine = document.createElement('div')
this.buffLine.style.cssText = 'margin-top:6px;opacity:0.95;min-height:16px'
root.appendChild(this.buffLine)
container.appendChild(root)
this.root = root
}
/** Call each frame; internally throttled to ~10Hz. */
update(cov: CoverageReport, mods: BlobModifiers): void {
const now = performance.now()
if (now - this.lastUpdate < 100) return
this.lastUpdate = now
for (const color of COLOR_ORDER) {
const b = this.bars.get(color)!
const v = cov.byColor[color]
b.fill.style.width = `${Math.min(100, v * 100).toFixed(0)}%`
b.pct.textContent = `${(v * 100).toFixed(0)}%`
const active = v >= ACTIVATE
b.pct.style.opacity = active ? '1' : '0.5'
b.fill.style.boxShadow = v >= SUPER ? `0 0 8px ${PALETTE[color]}` : 'none'
}
this.totalPct.textContent = `${(cov.total * 100).toFixed(0)}%`
this.buffLine.innerHTML = this.buffText(cov, mods)
}
private buffText(cov: CoverageReport, mods: BlobModifiers): string {
const parts: string[] = []
if (cov.byColor.red >= ACTIVATE) parts.push(`<b style="color:${PALETTE.red}">BURN</b> ×${mods.speedMul.toFixed(2)}`)
if (cov.byColor.green >= ACTIVATE) parts.push(`<b style="color:${PALETTE.green}">GRIP</b> ${mods.grip.toFixed(2)}`)
if (cov.byColor.blue >= ACTIVATE) parts.push(`<b style="color:${PALETTE.blue}">SLICK</b> waterproof`)
parts.push(`mass ×${mods.massMul.toFixed(2)}`)
if (mods.glow > 0) parts.push('<b style="color:#fff;text-shadow:0 0 6px #fff">★ SUPER</b>')
return parts.length ? parts.join(' &nbsp;·&nbsp; ') : '<span style="opacity:0.5">no buffs</span>'
}
dispose(): void {
this.root.remove()
}
}

View File

@ -1,74 +0,0 @@
/**
* Lane B (Paint) public surface. Integration (game.ts) imports from here.
*/
import * as THREE from 'three'
import type { PaintColor, World } from '../contracts'
import { PaintSkin } from './skin'
export { PaintSkin } from './skin'
export type { PaintSkinOptions } from './skin'
export { PaintCannon, solveBallisticVelocity } from './cannon'
export type { PaintCannonConfig } from './cannon'
export { BuffSystem } from './buffs'
export type { BuffTarget } from './buffs'
export { PaintHUD } from './hud'
export {
COLOR_ORDER,
colorIndex,
countBuckets,
coverageReportFromCounts,
computeModifiers,
buffStrength,
superColor,
dominantColor,
ACTIVATE,
SUPER,
} from './coverage-math'
export interface PaintEventTarget {
/** The paintable body — its world position is used for proximity gating. */
mesh: THREE.Object3D
paint: PaintSkin
}
/**
* Wire the inbound paint protocol from Lane C onto a blob's skin:
* 'paint:request-splat' {point, color, radius} bucket dumps, sprays
* 'paint:request-cleanse' {fraction} bubble arches, rinses
* request-splat is proximity-gated to the blob (ground decals are V1), and a
* matched splat re-emits 'paint:splatted' {target:'blob'} so downstream listeners
* (VFX, audio, netcode) see a single canonical splat event. Returns an unsub fn.
*/
export function installPaint(world: World, target: PaintEventTarget): () => void {
const center = new THREE.Vector3()
const scale = new THREE.Vector3()
const worldRadius = (): number => {
target.mesh.getWorldScale(scale)
return target.paint.boundingRadius * Math.max(scale.x, scale.y, scale.z)
}
const offSplat = world.events.on(
'paint:request-splat',
(p: { point: THREE.Vector3; color: PaintColor; radius: number }) => {
if (!p?.point) return
target.mesh.getWorldPosition(center)
if (center.distanceTo(p.point) <= worldRadius() + p.radius) {
target.paint.splatAtPoint(p.point, p.color, p.radius)
world.events.emit('paint:splatted', { color: p.color, target: 'blob' })
}
},
)
const offCleanse = world.events.on(
'paint:request-cleanse',
(p: { fraction: number }) => {
target.paint.cleanse(p?.fraction ?? 1)
},
)
return () => {
offSplat()
offCleanse()
}
}

View File

@ -1,308 +0,0 @@
/**
* PaintSkin the core invention. A Canvas-2D colour mask painted onto a mesh's
* UVs, wrapped in a THREE.CanvasTexture that becomes the body's material.map over
* the white base. Splats are stamped WHERE the projectile hit (raycast face UV),
* and per-colour coverage is an exact integer histogram of a quantized mask.
*
* Two coupled representations, generated from the SAME set of sub-circles per
* splat so they always agree spatially:
* - `canvas` : soft-edged, irregular, pretty this is what you SEE.
* - `mask` : a Uint8Array of palette indices (0 = base, 1..7 = colour),
* hard-edged this is what we COUNT. Quantized on stamp, so
* coverage() is exact bucketing, never a fuzzy colour match, and
* we NEVER call getImageData (the real per-frame perf killer).
*/
import * as THREE from 'three'
import type { CoverageReport, PaintColor, PaintSkin as IPaintSkin } from '../contracts'
import { BASE_WHITE, PALETTE } from '../contracts'
import {
COLOR_ORDER,
colorIndex,
countBuckets,
coverageReportFromCounts,
} from './coverage-math'
export interface PaintSkinOptions {
/** Mask/texture resolution (square). MVP default 256. */
size?: number
/**
* How many canvas pixels one world-unit of splat radius maps to, expressed as
* a fraction of the texture width per (radius / boundingRadius). Tune for splat
* size on screen; exact value is cosmetic (coverage is area-based regardless).
*/
splatScale?: number
}
interface SubCircle {
x: number
y: number
r: number
}
export class PaintSkin implements IPaintSkin {
readonly canvas: HTMLCanvasElement
readonly texture: THREE.CanvasTexture
readonly boundingRadius: number
private readonly mesh: THREE.Mesh
private readonly ctx: CanvasRenderingContext2D
private readonly size: number
private readonly splatScale: number
private readonly mask: Uint8Array
private readonly raycaster = new THREE.Raycaster()
// 250ms coverage cache (never recount more often than that).
private cache: CoverageReport | null = null
private cacheTime = -1e9
// scratch vectors (avoid per-splat allocation)
private readonly _center = new THREE.Vector3()
private readonly _dir = new THREE.Vector3()
private readonly _origin = new THREE.Vector3()
constructor(mesh: THREE.Mesh, opts: PaintSkinOptions = {}) {
this.mesh = mesh
this.size = opts.size ?? 256
this.splatScale = opts.splatScale ?? 0.16
mesh.geometry.computeBoundingSphere()
this.boundingRadius = mesh.geometry.boundingSphere?.radius ?? 1
const canvas = document.createElement('canvas')
canvas.width = canvas.height = this.size
const ctx = canvas.getContext('2d')
if (!ctx) throw new Error('PaintSkin: 2D canvas context unavailable')
ctx.fillStyle = BASE_WHITE
ctx.fillRect(0, 0, this.size, this.size)
this.canvas = canvas
this.ctx = ctx
this.mask = new Uint8Array(this.size * this.size) // 0 everywhere = all base
const tex = new THREE.CanvasTexture(canvas)
tex.colorSpace = THREE.SRGBColorSpace
tex.wrapS = THREE.RepeatWrapping // U seam wraps (sphere longitude)
tex.wrapT = THREE.ClampToEdgeWrapping
tex.anisotropy = 4
this.texture = tex
// Attach as the base colour map. Keep material colour white so the map isn't
// tinted; the canvas already carries the white base.
const mat = mesh.material as THREE.MeshStandardMaterial
mat.map = tex
if (mat.color) mat.color.set('#ffffff')
mat.needsUpdate = true
}
// ---- public API (contracts.PaintSkin) -----------------------------------
applySplat(uv: THREE.Vector2, color: PaintColor, radius: number): void {
const px = uv.x * this.size
// CanvasTexture default flipY=true: uv v=0 samples canvas bottom row.
const py = (1 - uv.y) * this.size
const pr = this.pixelRadius(radius)
const circles = this.makeSplatCircles(px, py, pr)
this.stampMask(circles, colorIndex(color))
this.stampCanvas(circles, PALETTE[color])
this.texture.needsUpdate = true
}
splatAtPoint(worldPoint: THREE.Vector3, color: PaintColor, radius: number): void {
const uv = this.uvAtWorldPoint(worldPoint)
if (uv) this.applySplat(uv, color, radius)
}
coverage(): CoverageReport {
const now = performance.now()
if (this.cache && now - this.cacheTime < 250) return this.cache
const counts = countBuckets(this.mask, COLOR_ORDER.length)
this.cache = coverageReportFromCounts(counts, this.mask.length)
this.cacheTime = now
return this.cache
}
cleanse(fraction: number): void {
const f = Math.max(0, Math.min(1, fraction))
if (f <= 0) return
if (f >= 1) {
// Full clean: reset everything to base.
this.mask.fill(0)
this.ctx.fillStyle = BASE_WHITE
this.ctx.fillRect(0, 0, this.size, this.size)
this.invalidate()
return
}
// Partial: punch random circular "scrub" holes until ~fraction of the
// currently-painted pixels are erased. Looks scrubbed rather than uniform.
let painted = 0
for (let i = 0; i < this.mask.length; i++) if (this.mask[i] !== 0) painted++
if (painted === 0) return
let target = Math.floor(painted * f)
this.ctx.fillStyle = BASE_WHITE
let guard = 0
while (target > 0 && guard++ < 20000) {
const cx = Math.random() * this.size
const cy = Math.random() * this.size
const r = 6 + Math.random() * 12
target -= this.eraseCircle(cx, cy, r)
}
this.invalidate()
}
// ---- geometry: world point -> UV ----------------------------------------
private uvAtWorldPoint(worldPoint: THREE.Vector3): THREE.Vector2 | null {
this.mesh.updateWorldMatrix(true, false)
this.mesh.getWorldPosition(this._center)
this._dir.copy(worldPoint).sub(this._center)
if (this._dir.lengthSq() < 1e-12) return null
this._dir.normalize()
// Cast from just outside the bounding sphere, straight through the centre.
// The near-surface hit is the point closest to the impact; THREE gives us the
// barycentric-interpolated UV for free.
const worldRadius = this.worldBoundingRadius()
this._origin.copy(this._center).addScaledVector(this._dir, worldRadius * 2.2)
this.raycaster.set(this._origin, this._dir.clone().negate())
this.raycaster.near = 0
this.raycaster.far = worldRadius * 4.4
const hits = this.raycaster.intersectObject(this.mesh, false)
const hit = hits.find((h) => h.uv)
return hit?.uv ? hit.uv.clone() : null
}
private worldBoundingRadius(): number {
const s = this.mesh.getWorldScale(this._origin) // reuse scratch
return this.boundingRadius * Math.max(s.x, s.y, s.z)
}
private pixelRadius(radius: number): number {
const px = (radius / this.boundingRadius) * this.size * this.splatScale
return Math.max(4, Math.min(this.size * 0.4, px))
}
// ---- splat rasterization (mask + canvas share the same circles) ----------
private makeSplatCircles(px: number, py: number, pr: number): SubCircle[] {
const circles: SubCircle[] = [{ x: px, y: py, r: pr * 0.95 }]
// irregular lobes
const lobes = 3 + Math.floor(Math.random() * 3)
for (let i = 0; i < lobes; i++) {
const a = Math.random() * Math.PI * 2
const d = pr * (0.35 + Math.random() * 0.55)
circles.push({
x: px + Math.cos(a) * d,
y: py + Math.sin(a) * d,
r: pr * (0.3 + Math.random() * 0.35),
})
}
// stray droplets
const drops = 2 + Math.floor(Math.random() * 3)
for (let i = 0; i < drops; i++) {
const a = Math.random() * Math.PI * 2
const d = pr * (1.0 + Math.random() * 0.7)
circles.push({
x: px + Math.cos(a) * d,
y: py + Math.sin(a) * d,
r: pr * (0.08 + Math.random() * 0.14),
})
}
return circles
}
/** Hard-edged union of circles into the quantized mask, with U-seam wrap. */
private stampMask(circles: SubCircle[], idx: number): void {
const S = this.size
for (const c of circles) {
// Wrap in X (longitude seam). Draw the circle and, if near an edge, its
// wrapped twin so a stamp straddling u=0/1 lands on both sides.
const offsets = this.wrapOffsets(c.x, c.r, S)
for (const ox of offsets) {
const cx = c.x + ox
const r2 = c.r * c.r
const x0 = Math.max(0, Math.floor(cx - c.r))
const x1 = Math.min(S - 1, Math.ceil(cx + c.r))
const y0 = Math.max(0, Math.floor(c.y - c.r))
const y1 = Math.min(S - 1, Math.ceil(c.y + c.r))
for (let y = y0; y <= y1; y++) {
const dy = y - c.y
for (let x = x0; x <= x1; x++) {
const dx = x - cx
if (dx * dx + dy * dy <= r2) this.mask[y * S + x] = idx
}
}
}
}
}
/** Soft, alpha-blended gradient blobs into the visible canvas, with wrap. */
private stampCanvas(circles: SubCircle[], hex: string): void {
const S = this.size
const ctx = this.ctx
const rgb = hexToRgb(hex)
for (const c of circles) {
const offsets = this.wrapOffsets(c.x, c.r, S)
for (const ox of offsets) {
const cx = c.x + ox
const g = ctx.createRadialGradient(cx, c.y, c.r * 0.15, cx, c.y, c.r)
g.addColorStop(0, `rgba(${rgb},1)`)
g.addColorStop(0.7, `rgba(${rgb},0.95)`)
g.addColorStop(1, `rgba(${rgb},0)`)
ctx.fillStyle = g
ctx.beginPath()
ctx.arc(cx, c.y, c.r, 0, Math.PI * 2)
ctx.fill()
}
}
}
/** Erase a scrub hole from both mask and canvas; returns pixels erased. */
private eraseCircle(cx: number, cy: number, r: number): number {
const S = this.size
let erased = 0
const r2 = r * r
const x0 = Math.max(0, Math.floor(cx - r))
const x1 = Math.min(S - 1, Math.ceil(cx + r))
const y0 = Math.max(0, Math.floor(cy - r))
const y1 = Math.min(S - 1, Math.ceil(cy + r))
for (let y = y0; y <= y1; y++) {
const dy = y - cy
for (let x = x0; x <= x1; x++) {
const dx = x - cx
if (dx * dx + dy * dy <= r2) {
const i = y * S + x
if (this.mask[i] !== 0) {
this.mask[i] = 0
erased++
}
}
}
}
this.ctx.beginPath()
this.ctx.arc(cx, cy, r, 0, Math.PI * 2)
this.ctx.fill() // fillStyle already BASE_WHITE at call sites
return erased
}
private wrapOffsets(x: number, r: number, S: number): number[] {
if (x - r < 0) return [0, S]
if (x + r > S) return [0, -S]
return [0]
}
private invalidate(): void {
this.texture.needsUpdate = true
this.cache = null
this.cacheTime = -1e9
}
}
function hexToRgb(hex: string): string {
const h = hex.replace('#', '')
const n = parseInt(h, 16)
return `${(n >> 16) & 255},${(n >> 8) & 255},${n & 255}`
}

View File

@ -49,47 +49,27 @@ export async function createWorld(container: HTMLElement): Promise<World> {
const frameHooks: Array<(dt: number) => void> = []
const events = makeEvents()
const step = () => {
physics.step()
for (const s of systems) s.update(FIXED_DT)
}
const world: World = {
scene, camera, renderer, physics, rapier: RAPIER, events,
addSystem: (s) => void systems.push(s),
onFrame: (fn) => void frameHooks.push(fn),
tick(steps = 1) {
for (let i = 0; i < steps; i++) step()
},
renderOnce() {
for (const fn of frameHooks) fn(FIXED_DT)
renderer.render(scene, camera)
},
start() {
let last = performance.now()
let acc = 0
const advance = (now: number) => {
const loop = (now: number) => {
requestAnimationFrame(loop)
const dt = Math.min((now - last) / 1000, 0.1)
last = now
acc += dt
while (acc >= FIXED_DT) {
step()
physics.step()
for (const s of systems) s.update(FIXED_DT)
acc -= FIXED_DT
}
return dt
}
const loop = (now: number) => {
requestAnimationFrame(loop)
const dt = advance(now)
for (const fn of frameHooks) fn(dt)
renderer.render(scene, camera)
}
requestAnimationFrame(loop)
// Hidden tabs suspend rAF entirely — keep the sim alive on a coarse
// interval so the game (and headless verification) survives backgrounding.
setInterval(() => {
if (performance.now() - last > 500) advance(performance.now())
}, 250)
},
}

View File

@ -1,18 +0,0 @@
[
{
"name": "blobbo-base",
"prompt": "Single friendly white blob mascot creature standing in a relaxed T-pose, arms slightly out, soft rounded michelin-man-like body, big cute glossy black eyes, tiny smile, stubby legs, three-quarter front view, full body centered, clean flat white background, no shadow, 3D game character render style"
},
{
"name": "prop-toaster-launcher",
"prompt": "Single cartoon chrome toaster as a game prop, chunky friendly toy-like design, oversized launch lever on the side, slightly tilted three-quarter view, whole object centered and fully visible, clean flat white background, no shadow, 3D game asset render style"
},
{
"name": "prop-spring-boot",
"prompt": "Single cartoon giant red rubber boot mounted on a big metal coil spring, chunky toy-like game prop design, three-quarter view, whole object centered and fully visible, clean flat white background, no shadow, 3D game asset render style"
},
{
"name": "prop-paint-bucket",
"prompt": "Single cartoon metal paint bucket brimming with glossy bright red paint, one thick drip over the rim, chunky toy-like game prop design, three-quarter view, whole object centered and fully visible, clean flat white background, no shadow, 3D game asset render style"
}
]

View File

@ -1,99 +0,0 @@
#!/usr/bin/env python3
"""mesh_pipeline.py — flux image -> bg_remove -> image->GLB, per spec entry.
Usage: mesh_pipeline.py tools/mesh-wave-1.json assets/meshes/
Retries each farm job up to 3x (the m4 node's broken mflux venv 126s any flux
job routed to it resubmission re-rolls the load-balancer). Skips any entry
whose .glb already exists, so reruns are cheap.
"""
import json, os, sys, time
sys.path.insert(0, os.path.dirname(os.path.abspath(__file__)))
import mb
RETRIES = 3
def run_job(operator, params=None, asset_id=None, label=''):
for attempt in range(1, RETRIES + 1):
jid = None
while jid is None:
jid = mb.submit(operator, params, asset_id=asset_id)
if jid is None:
print(f'[pipe] {label}: throttled, 15s', flush=True)
time.sleep(15)
st = mb.wait(jid, f'{label} ({operator} try {attempt})', interval=10)
if st == 'done':
return jid
time.sleep(5)
raise RuntimeError(f'{label}: {operator} failed {RETRIES}x')
PRIMARY = 'm3ultra@100.89.131.57'
def scp_from_outdir(jid, out, suffix='.glb'):
"""Fallback for a farm bug (2026-07-17): hunyuan jobs run on remote workers
ship outputs back to the primary's job outdir but never register them as
assets. Pull the textured mesh (not *_shape.glb) straight from outdir."""
import subprocess
j = mb.req(f'/api/jobs/{jid}')
outdir = j.get('outdir')
if not outdir:
return False
ls = subprocess.run(['ssh', '-o', 'ConnectTimeout=8', '-o', 'BatchMode=yes',
PRIMARY, f'ls {outdir}'], capture_output=True, text=True)
names = [n for n in ls.stdout.split()
if n.endswith(suffix) and not n.endswith('_shape.glb')]
if not names:
return False
r = subprocess.run(['scp', '-o', 'ConnectTimeout=8', '-o', 'BatchMode=yes',
f'{PRIMARY}:{outdir}/{names[0]}', out], capture_output=True)
return r.returncode == 0 and os.path.exists(out)
def fetch_output(jid, out, suffix=None):
outs = mb.outputs_for(jid, suffix)
if outs:
n = mb.fetch(outs[0]['id'], out)
print(f'[pipe] wrote {out} ({n//1024}KB)', flush=True)
return
if suffix and scp_from_outdir(jid, out, suffix):
print(f'[pipe] wrote {out} ({os.path.getsize(out)//1024}KB, outdir fallback)', flush=True)
return
raise RuntimeError(f'no output asset for {jid} (api + outdir fallback both empty)')
def main(spec_path, outdir):
specs = json.load(open(spec_path))
srcdir = os.path.join(outdir, 'src')
os.makedirs(srcdir, exist_ok=True)
failed = []
for s in specs:
name = s['name']
glb = os.path.join(outdir, f'{name}.glb')
if os.path.exists(glb):
print(f'[pipe] {name}: glb exists, skip', flush=True)
continue
try:
img = os.path.join(srcdir, f'{name}.png')
if not os.path.exists(img):
jid = run_job('flux_local', {'prompt': s['prompt']}, label=name)
fetch_output(jid, img)
cut = os.path.join(srcdir, f'{name}-cut.png')
if not os.path.exists(cut):
aid = mb.upload(img)
jid = run_job('bg_remove_local', {}, asset_id=aid, label=name)
fetch_output(jid, cut)
aid = mb.upload(cut)
jid = run_job('hunyuan3d_mlx', {}, asset_id=aid, label=name)
fetch_output(jid, glb, suffix='.glb')
except Exception as e:
print(f'[pipe] {name}: FAILED — {e}', flush=True)
failed.append(name)
ok = len(specs) - len(failed)
print(f'[pipe] mesh wave complete: {ok}/{len(specs)} ok'
+ (f' — failed: {failed}' if failed else ''), flush=True)
if __name__ == '__main__':
main(sys.argv[1], sys.argv[2])