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 ---- // ---- grounded check: short ray straight down, excluding self ----
const t = body.translation() 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 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 hit = physics.castRay(ray, r + 0.18, true, undefined, undefined, blob.collider, body)
const nowGrounded = hit !== null const nowGrounded = hit !== null

View File

@ -82,6 +82,10 @@ export interface World {
addSystem(s: System): void addSystem(s: System): void
/** Per-render-frame hooks (visual-only work: cameras, jiggle, particles). */ /** Per-render-frame hooks (visual-only work: cameras, jiggle, particles). */
onFrame(fn: (dt: number) => void): void 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 start(): void
} }

View File

@ -1,56 +1,117 @@
/** /**
* INTEGRATION SEAM owned by integration, not by lanes. * INTEGRATION SEAM owned by integration, not by lanes.
* Composes lane modules into the playable slice. While lanes are in flight this * The playable slice: Lane A blob + course, Lane B paint + buffs, and (when
* boots a placeholder ball on a flat floor so `npm run dev` always shows life. * lane-c merges) the machine chain on the course's machine area.
*/ */
import * as THREE from 'three' import * as THREE from 'three'
import type { World } from './contracts' 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) { export function installGame(world: World) {
const { scene, physics, rapier } = world const course = buildGreybox(world)
// floor // ---- blob (Lane A) ----
const floor = new THREE.Mesh( const blob = createBlob(world, { position: course.spawn })
new THREE.BoxGeometry(60, 1, 60), blob.paint = new PaintSkin(blob.mesh, { size: 256 })
new THREE.MeshStandardMaterial({ color: '#e8d9b8' })) world.blob = blob
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))
// placeholder blob-ball (replaced by lane A's createBlob at integration) world.addSystem(createBlobController(world, blob))
const ball = new THREE.Mesh(
new THREE.SphereGeometry(0.6, 32, 24),
new THREE.MeshStandardMaterial({ color: '#F5F5F7', roughness: 0.35 }))
ball.castShadow = true
scene.add(ball)
const body = physics.createRigidBody(
rapier.RigidBodyDesc.dynamic().setTranslation(0, 3, 0))
physics.createCollider(
rapier.ColliderDesc.ball(0.6).setRestitution(0.4).setFriction(1.0), body)
const keys = new Set<string>() const feel = createBlobFeel(blob)
addEventListener('keydown', (e) => keys.add(e.code)) world.events.on('blob:jumped', () => feel.onJump())
addEventListener('keyup', (e) => keys.delete(e.code)) world.events.on('blob:landed', (p: { impact: number }) => feel.onLand(p.impact))
world.onFrame((dt) => feel.update(dt))
world.addSystem({ const camera = createFollowCamera(world, blob)
update() { world.onFrame((dt) => camera.update(dt))
const f = 18
const impulse = { x: 0, y: 0, z: 0 } // ---- paint (Lane B): cannons staged along the racing line ----
if (keys.has('KeyW') || keys.has('ArrowUp')) impulse.z -= f const skin = blob.paint as PaintSkin
if (keys.has('KeyS') || keys.has('ArrowDown')) impulse.z += f const cannonSpecs = [
if (keys.has('KeyA') || keys.has('ArrowLeft')) impulse.x -= f { color: 'red' as const, position: new THREE.Vector3(-9, 2.5, 14), triggerId: 'cannon-red' },
if (keys.has('KeyD') || keys.has('ArrowRight')) impulse.x += f { color: 'green' as const, position: new THREE.Vector3(9, 4.5, -6), triggerId: 'cannon-green' },
body.applyImpulse({ x: impulse.x / 60, y: 0, z: impulse.z / 60 }, true) { color: 'blue' as const, position: new THREE.Vector3(-9, 2.0, -30), triggerId: 'cannon-blue' },
const p = body.translation() ]
ball.position.set(p.x, p.y, p.z) for (const spec of cannonSpecs) {
const q = body.rotation() world.addSystem(new PaintCannon({
ball.quaternion.set(q.x, q.y, q.z, q.w) 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.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) => { world.onFrame((dt) => {
for (const s of [...sloshers]) s(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)) tip = lerp(tip, tipTarget, Math.min(1, dt * 4))
group.rotation.z = -tip 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 // right the bucket back once poured, then re-arm
if (dumping && tip > Math.PI * 0.8) tipTarget = 0 if (dumping && tip > Math.PI * 0.8) tipTarget = 0
if (dumping && tipTarget === 0 && tip < 0.05) dumping = false if (dumping && tipTarget === 0 && tip < 0.05) dumping = false
},
}) })
return { id: cfg.id, group } return { id: cfg.id, group }

View File

@ -127,7 +127,10 @@ export function telegraph(
if (!list) { if (!list) {
list = [] list = []
perWorld.set(world, 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 } const o = { ...DEFAULTS, ...opts }
return new Promise<void>((resolve) => { 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 frameHooks: Array<(dt: number) => void> = []
const events = makeEvents() const events = makeEvents()
const step = () => {
physics.step()
for (const s of systems) s.update(FIXED_DT)
}
const world: World = { const world: World = {
scene, camera, renderer, physics, rapier: RAPIER, events, scene, camera, renderer, physics, rapier: RAPIER, events,
addSystem: (s) => void systems.push(s), addSystem: (s) => void systems.push(s),
onFrame: (fn) => void frameHooks.push(fn), 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() { start() {
let last = performance.now() let last = performance.now()
let acc = 0 let acc = 0
const loop = (now: number) => { const advance = (now: number) => {
requestAnimationFrame(loop)
const dt = Math.min((now - last) / 1000, 0.1) const dt = Math.min((now - last) / 1000, 0.1)
last = now last = now
acc += dt acc += dt
while (acc >= FIXED_DT) { while (acc >= FIXED_DT) {
physics.step() step()
for (const s of systems) s.update(FIXED_DT)
acc -= FIXED_DT acc -= FIXED_DT
} }
return dt
}
const loop = (now: number) => {
requestAnimationFrame(loop)
const dt = advance(now)
for (const fn of frameHooks) fn(dt) for (const fn of frameHooks) fn(dt)
renderer.render(scene, camera) renderer.render(scene, camera)
} }
requestAnimationFrame(loop) 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])