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>
BIN
assets/meshes/blobbo-base.glb
Normal file
BIN
assets/meshes/prop-paint-bucket.glb
Normal file
BIN
assets/meshes/prop-spring-boot.glb
Normal file
BIN
assets/meshes/prop-toaster-launcher.glb
Normal file
BIN
assets/meshes/src/blobbo-base-cut.png
Normal file
|
After Width: | Height: | Size: 499 KiB |
BIN
assets/meshes/src/blobbo-base.png
Normal file
|
After Width: | Height: | Size: 436 KiB |
BIN
assets/meshes/src/prop-paint-bucket-cut.png
Normal file
|
After Width: | Height: | Size: 801 KiB |
BIN
assets/meshes/src/prop-paint-bucket.png
Normal file
|
After Width: | Height: | Size: 712 KiB |
BIN
assets/meshes/src/prop-spring-boot-cut.png
Normal file
|
After Width: | Height: | Size: 635 KiB |
BIN
assets/meshes/src/prop-spring-boot.png
Normal file
|
After Width: | Height: | Size: 566 KiB |
BIN
assets/meshes/src/prop-toaster-launcher-cut.png
Normal file
|
After Width: | Height: | Size: 668 KiB |
BIN
assets/meshes/src/prop-toaster-launcher.png
Normal file
|
After Width: | Height: | Size: 587 KiB |
@ -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
|
||||
|
||||
@ -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
|
||||
}
|
||||
|
||||
|
||||
141
src/game.ts
@ -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 }
|
||||
}
|
||||
|
||||
@ -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 }
|
||||
|
||||
@ -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) => {
|
||||
|
||||
28
src/world.ts
@ -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
@ -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
@ -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])
|
||||