diff --git a/assets/meshes/blobbo-base.glb b/assets/meshes/blobbo-base.glb new file mode 100644 index 0000000..a51d362 Binary files /dev/null and b/assets/meshes/blobbo-base.glb differ diff --git a/assets/meshes/prop-paint-bucket.glb b/assets/meshes/prop-paint-bucket.glb new file mode 100644 index 0000000..cf84b97 Binary files /dev/null and b/assets/meshes/prop-paint-bucket.glb differ diff --git a/assets/meshes/prop-spring-boot.glb b/assets/meshes/prop-spring-boot.glb new file mode 100644 index 0000000..9b4d28a Binary files /dev/null and b/assets/meshes/prop-spring-boot.glb differ diff --git a/assets/meshes/prop-toaster-launcher.glb b/assets/meshes/prop-toaster-launcher.glb new file mode 100644 index 0000000..cc53a14 Binary files /dev/null and b/assets/meshes/prop-toaster-launcher.glb differ diff --git a/assets/meshes/src/blobbo-base-cut.png b/assets/meshes/src/blobbo-base-cut.png new file mode 100644 index 0000000..c447cbf Binary files /dev/null and b/assets/meshes/src/blobbo-base-cut.png differ diff --git a/assets/meshes/src/blobbo-base.png b/assets/meshes/src/blobbo-base.png new file mode 100644 index 0000000..4646b6b Binary files /dev/null and b/assets/meshes/src/blobbo-base.png differ diff --git a/assets/meshes/src/prop-paint-bucket-cut.png b/assets/meshes/src/prop-paint-bucket-cut.png new file mode 100644 index 0000000..e18ef4a Binary files /dev/null and b/assets/meshes/src/prop-paint-bucket-cut.png differ diff --git a/assets/meshes/src/prop-paint-bucket.png b/assets/meshes/src/prop-paint-bucket.png new file mode 100644 index 0000000..b705705 Binary files /dev/null and b/assets/meshes/src/prop-paint-bucket.png differ diff --git a/assets/meshes/src/prop-spring-boot-cut.png b/assets/meshes/src/prop-spring-boot-cut.png new file mode 100644 index 0000000..711669b Binary files /dev/null and b/assets/meshes/src/prop-spring-boot-cut.png differ diff --git a/assets/meshes/src/prop-spring-boot.png b/assets/meshes/src/prop-spring-boot.png new file mode 100644 index 0000000..510c5de Binary files /dev/null and b/assets/meshes/src/prop-spring-boot.png differ diff --git a/assets/meshes/src/prop-toaster-launcher-cut.png b/assets/meshes/src/prop-toaster-launcher-cut.png new file mode 100644 index 0000000..72254e9 Binary files /dev/null and b/assets/meshes/src/prop-toaster-launcher-cut.png differ diff --git a/assets/meshes/src/prop-toaster-launcher.png b/assets/meshes/src/prop-toaster-launcher.png new file mode 100644 index 0000000..c81e3fc Binary files /dev/null and b/assets/meshes/src/prop-toaster-launcher.png differ diff --git a/src/blob/controller.ts b/src/blob/controller.ts index d5c18f0..cbeafe3 100644 --- a/src/blob/controller.ts +++ b/src/blob/controller.ts @@ -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 diff --git a/src/contracts.ts b/src/contracts.ts index 116d9b4..3d60edc 100644 --- a/src/contracts.ts +++ b/src/contracts.ts @@ -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 } diff --git a/src/game.ts b/src/game.ts index b4ac331..0c4cdc1 100644 --- a/src/game.ts +++ b/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() - 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 } } diff --git a/src/machine/parts.ts b/src/machine/parts.ts index 7e46b9a..b9f8aec 100644 --- a/src/machine/parts.ts +++ b/src/machine/parts.ts @@ -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 } diff --git a/src/machine/telegraph.ts b/src/machine/telegraph.ts index c3a0fac..91dc145 100644 --- a/src/machine/telegraph.ts +++ b/src/machine/telegraph.ts @@ -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((resolve) => { diff --git a/src/world.ts b/src/world.ts index df95680..eee94d3 100644 --- a/src/world.ts +++ b/src/world.ts @@ -49,27 +49,47 @@ export async function createWorld(container: HTMLElement): Promise { 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) }, } diff --git a/tools/mesh-wave-1.json b/tools/mesh-wave-1.json new file mode 100644 index 0000000..0d91e90 --- /dev/null +++ b/tools/mesh-wave-1.json @@ -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" + } +] diff --git a/tools/mesh_pipeline.py b/tools/mesh_pipeline.py new file mode 100644 index 0000000..e611a95 --- /dev/null +++ b/tools/mesh_pipeline.py @@ -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])