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>
This commit is contained in:
type-two 2026-07-17 20:50:16 +10:00
parent 6979c0db38
commit d4d41208f8
20 changed files with 262 additions and 45 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

@ -79,6 +79,11 @@ export function createBlobController(
// ---- 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

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
}

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

View File

@ -437,8 +437,14 @@ export function createBucketDump(world: World, cfg: BucketDumpConfig): MachinePa
},
})
// 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
@ -464,6 +470,7 @@ export function createBucketDump(world: World, cfg: BucketDumpConfig): MachinePa
// 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 }

View File

@ -127,7 +127,10 @@ export function telegraph(
if (!list) {
list = []
perWorld.set(world, list)
world.onFrame((dt) => tick(list!, dt))
// 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) => {

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])