Compare commits

...

7 Commits

Author SHA1 Message Date
type-two
d4d41208f8 M0 playable slice: lanes integrated, machine chain staged, hidden-tab hardening
Integration: blob+paint+machines composed in game.ts; cannons staged along the
racing line (muzzle 26 — default 16 undershoots course distances); plate
threshold above clean-blob mass so only painted blobs trip it (paint=weight=
machine access); belt delivers riders under the bucket (shallow catch timed to
teeter drift); arch moved to a detour lane.

Cross-lane fixes found at integration, all one class — gameplay logic living
on render-frame time, which stalls in hidden tabs:
- telegraph windup -> fixed step (gates forces/dumps)
- bucket tip/pour  -> fixed step (gates the splat)
- blob group sync  -> fixed step in controller (paint proximity gate reads it)
world.ts grew tick()/renderOnce() + a hidden-tab watchdog interval for this.

Assets: mesh wave 1 (4 GLBs via farm: blobbo-base, toaster, spring boot,
paint bucket) + mesh_pipeline.py with outdir scp fallback for the farm's
unregistered-remote-outputs bug.

Verified headless end-to-end via tick(): move/jump/squash, cannon hits on a
moving blob, coverage->buffs (BURN 1.6x, super at 70%), mass 1.0->1.67 with
paint, plate clean-reject + painted-trip, boot launch (peak y 3.9), belt->
bucket dump (23.6% purple), arch cleanse.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-17 20:50:16 +10:00
type-two
6979c0db38 Merge lane-c: telegraph, 7 contraptions, surface registry
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-17 20:33:19 +10:00
type-two
8aaa11cdc1 Lane C: Rube-Goldberg machine toolkit — telegraphs, parts, surfaces, chain demo
Player-triggered contraptions that chain purely via world.events
('machine:signal') with a mandatory ~0.5s telegraph before any force or
paint dump (GDD readable-chaos pillar).

src/machine/:
- telegraph.ts: unmissable windup (accelerating flash + shake + scale pulse)
  ticked once per world; parts await it and only then fire.
- parts.ts: PressurePlate (real body.mass() threshold), SpringBoot
  (telegraph -> impulse on strike volume), SeeSaw (Rapier revolute joint
  with limits + optional rest tilt), BucketDump (telegraph teeter ->
  paint:request-splat + slosh), ConveyorBelt, BubbleArch (rising-edge
  paint:request-cleanse + bubbles), Fan (per-step impulse so mass does the
  work). Config-driven; no cross-instance references.
- surfaces.ts: normal/ice/oil/honey/mud/water registry with applySurface +
  surfaceAt (downward ray) query.

demos/lane-c.html + src/demo/lane-c.ts: heavy ball runs
PressurePlate -> SpringBoot -> SeeSaw -> ice runway -> BucketDump, plus
conveyor/arch/fan and ice/honey probe lanes. Debug keys T/L/S/F/P and
per-second STATE console lines. Layout constants tuned via a headless
physics sweep so the chain completes deterministically.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-17 20:26:52 +10:00
type-two
827b39be2e Merge lane-b: paint skin, coverage, buffs, cannons, HUD (drop tracked build junk)
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-17 20:18:46 +10:00
type-two
72d8c7ca80 Merge lane-a: blob controller, feel layer, camera, greybox course
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-17 20:18:28 +10:00
type-two
eed2eb2646 Lane B (Paint): splat pipeline, coverage compute, buff/threshold framework
The core invention: paint lands on the blob body where projectiles hit, live
per-colour coverage % is computed from a quantized UV mask, and coverage drives
buffs per GDD §6 MVP subset (RED speed+embers, GREEN grip, BLUE waterproof,
total→mass, 20% activate / 70% super+glow).

- src/paint/skin.ts       PaintSkin: Canvas-2D mask on mesh UVs → CanvasTexture;
                          splatAtPoint raycasts impact→face UV; dual soft-canvas
                          + hard quantized-index mask so coverage is exact
                          integer bucketing (never getImageData); 250ms cache;
                          scrub-hole cleanse; U-seam wrap.
- src/paint/coverage-math.ts  pure, unit-tested: countBuckets, coverage report,
                          buffStrength, computeModifiers.
- src/paint/cannon.ts     PaintCannon: gravity-arc glob projectiles (manual
                          swept sphere test), target-leading ballistic solve,
                          fires on interval AND machine:signal; emits paint:splatted.
- src/paint/buffs.ts      BuffSystem: coverage→modifiers each step + ember trail
                          (additive Points) + super-state emissive pulse.
- src/paint/hud.ts        PaintHUD: per-colour bars + active buff kit (throttled).
- src/paint/index.ts      exports + installPaint() wiring the inbound Lane C
                          protocol (request-splat proximity-gated / request-cleanse).
- src/demo/lane-b.ts      stub ball orbiting a RED and GREEN cannon; HUD live;
                          debug keys for fire/signal/flood/cleanse/pause.
- src/paint/coverage-math.test.ts  32 assertions (node --experimental-strip-types).

build: tsc strict + vite pass. No edits outside owned paths; contracts frozen.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-17 20:13:30 +10:00
type-two
4b08ba65ac Lane A: blob controller, feel layer, follow camera, greybox
- src/blob/createBlob.ts: rotation-locked dynamic ball + clean UV-sphere body
  mesh (untouched UVs for Lane B) + cosmetic eyes; implements frozen Blob.
- src/blob/controller.ts: fixed-step System. Camera-relative WASD via impulses,
  buffered+coyote jump, grounded raycast, emits blob:jumped/blob:landed. Reads
  blob.modifiers every frame: speedMul, jumpMul, massMul, grip, size.
- src/blob/feel.ts: the comedy engine. Underdamped springs for landing squash,
  jump stretch, inertial jelly lean, idle breathing + run bob; base-pivoted so
  squash plants on the floor. Driven by the frozen wire events. glow -> emissive.
- src/blob/camera.ts: soft-follow third-person cam with position lag, yaw ease
  behind travel, and FOV kick at speed.
- src/course/greybox.ts: Breakfast Rush greybox — start plateau, ramp, gap jump,
  slope, translucent milk river (surface:water), machine drop area, finish pad.
- src/demo/lane-a.ts: boots world+greybox+blob, HUD + once/sec state log, debug
  keys 1/2/3 (speed/jump/size) plus 4/5/6 (mass/grip/glow), 0 reset, R respawn.

npm run build passes (tsc strict + vite). Owned paths only.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-17 20:11:55 +10:00
37 changed files with 3785 additions and 49 deletions

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

After

Width:  |  Height:  |  Size: 499 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 436 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 801 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 712 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 635 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 566 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 668 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 587 KiB

View File

@ -1,6 +1,25 @@
<!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}#app{width:100%;height:100%}</style></head>
<body><div id="app"></div><script type="module" src="/src/demo/lane-c.ts"></script></body>
<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>
</html>

89
src/blob/camera.ts Normal file
View File

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

156
src/blob/controller.ts Normal file
View File

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

113
src/blob/createBlob.ts Normal file
View File

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

129
src/blob/feel.ts Normal file
View File

@ -0,0 +1,129 @@
/**
* 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,6 +82,10 @@ 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
}

115
src/course/greybox.ts Normal file
View File

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

View File

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

View File

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

View File

@ -1,56 +1,117 @@
/**
* INTEGRATION SEAM owned by integration, not by lanes.
* 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.
* 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.
*/
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 { scene, physics, rapier } = world
const course = buildGreybox(world)
// 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))
// ---- blob (Lane A) ----
const blob = createBlob(world, { position: course.spawn })
blob.paint = new PaintSkin(blob.mesh, { size: 256 })
world.blob = 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)
world.addSystem(createBlobController(world, blob))
const keys = new Set<string>()
addEventListener('keydown', (e) => keys.add(e.code))
addEventListener('keyup', (e) => keys.delete(e.code))
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))
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)
},
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(new BuffSystem(world, { mesh: blob.mesh, modifiers: blob.modifiers, paint: skin }))
installPaint(world, { mesh: blob.mesh, paint: skin })
const hud = new PaintHUD()
world.onFrame(() => {
world.camera.lookAt(ball.position)
hud.update(skin.coverage(), blob.modifiers) // coverage() self-caches at 250ms
})
// ---- 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 }
}

35
src/machine/index.ts Normal file
View File

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

730
src/machine/parts.ts Normal file
View File

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

113
src/machine/surfaces.ts Normal file
View File

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

157
src/machine/telegraph.ts Normal file
View File

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

159
src/paint/buffs.ts Normal file
View File

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

305
src/paint/cannon.ts Normal file
View File

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

@ -0,0 +1,117 @@
/**
* 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 ✓`)

127
src/paint/coverage-math.ts Normal file
View File

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

106
src/paint/hud.ts Normal file
View File

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

74
src/paint/index.ts Normal file
View File

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

308
src/paint/skin.ts Normal file
View File

@ -0,0 +1,308 @@
/**
* 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,27 +49,47 @@ 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 loop = (now: number) => {
requestAnimationFrame(loop)
const advance = (now: number) => {
const dt = Math.min((now - last) / 1000, 0.1)
last = now
acc += dt
while (acc >= FIXED_DT) {
physics.step()
for (const s of systems) s.update(FIXED_DT)
step()
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)
},
}

18
tools/mesh-wave-1.json Normal file
View File

@ -0,0 +1,18 @@
[
{
"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"
}
]

99
tools/mesh_pipeline.py Normal file
View File

@ -0,0 +1,99 @@
#!/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])