From e34773d35f2a8bafa33c26639a01f9a8889f93a9 Mon Sep 17 00:00:00 2001 From: Claude Date: Sun, 19 Jul 2026 12:12:07 +1000 Subject: [PATCH] harden asset runtime: guard the swap path, bound the boot, reconcile slots MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The three that stopped a pack shipping: - a mesh-less GLB (armature-only export) fired onSwap anyway, hiding the primitive and adding nothing — an invisible prop with a live collider. Mesh count is now checked BEFORE the scene is touched, in one guard that covers every consumer including slotObject/blob.face. - the fulfilment path had no try/catch, so a throw became an unhandled rejection. Split in two: a build failure bails out before onSwap can hide anything; an onSwap failure keeps the replacement parented, because consumers hide their primitive on onSwap's first line and removing the fit node there would manufacture the one forbidden state. - preload() awaited every url forever. Each is now raced against a 10s deadline with allSettled semantics: a stalled host costs one warning and a fallback prop, not a game that never boots. Also: - instantiate/instanceSync never throw — createBlob's call site is frozen and unguarded, so a throw there is a black screen. - ghost and blob share fitBodyToRadius(); a borrowed blob.body was rendering the ghost 2.17x oversized with the farm mesh. - skinned blob.body is rejected loudly instead of silently half-working; the idle-clip mixer is gone (it animated an orphaned skeleton in the fixed step). - paintableInfo counts UV islands: blobbo-base.glb passes every other check and still paints wrong at 1140 charts. Warning, not rejection. - slots.ts gains cannon.base, course.finish, course.tunnel, course.tramp, all hooked except tramp (built in frozen game.ts). - manifest ignores _-prefixed metadata keys instead of calling them typos. Empty-manifest parity verified byte-for-byte: scripts/sacred-parity.check.ts fingerprints the built scene and reports 49df4f20 on main and on this branch. --- docs/ASSET-SLOTS.md | 102 ++++++++++- lanes/LANE-I-asset-runtime.md | 15 +- scripts/sacred-parity.check.ts | 63 +++++++ src/assets/blobBody.ts | 136 ++++++++------- src/assets/harden.test.ts | 302 +++++++++++++++++++++++++++++++++ src/assets/manifest.ts | 5 + src/assets/registry.ts | 263 +++++++++++++++++++++++----- src/assets/slots.ts | 27 ++- src/course/greybox.ts | 2 +- src/course/zones.ts | 8 + src/demo/lane-i.ts | 3 +- src/ghost/player.ts | 14 +- src/paint/cannon.ts | 13 +- 13 files changed, 836 insertions(+), 117 deletions(-) create mode 100644 scripts/sacred-parity.check.ts create mode 100644 src/assets/harden.test.ts diff --git a/docs/ASSET-SLOTS.md b/docs/ASSET-SLOTS.md index 8ee7231..0d9e41c 100644 --- a/docs/ASSET-SLOTS.md +++ b/docs/ASSET-SLOTS.md @@ -39,12 +39,15 @@ and `docker cp`s the whole of `dist/` on every deploy. "url": "assets/live/boot.glb", "offset": [0, 0.2, 0], "rotationDeg": [0, 90, 0], - "scale": 1.5, - "idleClip": "idle" + "scale": 1.5 } } ``` +Keys starting with `_` (the editor writes `_readme` and `_generatedBy`) are +metadata and are ignored silently. `idleClip` is accepted by the schema but +currently ignored by the runtime — see "Rigged bodies are not supported" below. + Everything except `url` is optional. A bad field is dropped with a warning and the rest of the entry still loads; a bad entry is dropped and the rest of the pack still loads; a file that isn't JSON at all falls back to `{}`. A GLB that @@ -62,7 +65,8 @@ custom asset testable on the live site with zero deploys. | `blob.body` | the paintable body | — | | `blob.face` | the googly eyes | — | | `ghost.body` | the ghost racer (defaults to `blob.body`) | — | -| `cannon.barrel` | cannon base + tube | the aiming pivot | +| `cannon.base` | the cannon's stand | — | +| `cannon.barrel` | the tube | the aiming pivot | | `machine.plate` | the plate frame | the pressed pad (it lights up) | | `machine.boot` | pad + coils | — | | `machine.bucket` | the shell | the paint fill (it is re-tinted) | @@ -72,12 +76,65 @@ custom asset testable on the live site with zero deploys. | `machine.seesaw` | the plank | the fulcrum | | `course.scenery.cereal` | the giant cereal box | — | | `course.scenery.block` | the purple block | — | +| `course.finish` | the pink finish podium | — | +| `course.tunnel` | the MINI tunnel roof slab | the warning bar and the posts | +| `course.tramp` | the orange gap launcher pad | — | | `fx.puddle` | the puddle slab | — | Sub-parts that move every frame stay procedural on purpose: a custom model can't accidentally stop the belt scrolling, the fan spinning or the plate lighting up, and those motions are what make each machine readable. +`course.tramp` is built in `src/game.ts`, which is frozen for the workshop +lanes; the id and label exist so the editor can offer it, and integration wires +the hook. Until it does, dropping a file on that slot changes nothing. + +## One slot, many objects + +Most slots are instanced more than once — nine `fx.puddle` strips, six cannons, +every machine of a given kind. **Each instance gets its own clone of the asset +at its own pose, with its own materials.** That is the contract both the runtime +and the editor follow: dropping one puddle model reskins all nine puddles, and +tinting one of them (each puddle keeps its zone colour) cannot bleed into the +others. Verified in `src/assets/harden.test.ts`. + +Single-instance slots — the ones the game builds exactly once — are listed in +`SINGLE_INSTANCE_SLOTS` in `src/assets/slots.ts`: `blob.body`, `blob.face`, +`ghost.body`, `course.finish`, `course.tunnel` and the two `course.scenery.*`. + +## What gets rejected, and what only gets a warning + +| Asset | Outcome | +|---|---| +| GLB fails to load / 404s | slot keeps its built-in version, one warning | +| GLB takes longer than 10 s | boot continues on the built-in version, one warning | +| GLB contains no mesh (armature/empty/camera only) | slot keeps its built-in version — the primitive is **never** hidden for a replacement that isn't there | +| `blob.body` with a skeleton/armature | **rejected**, built-in blob kept (see below) | +| `blob.body` with no UVs, UVs outside 0..1, or >1 material | rejected, built-in blob kept | +| `blob.body` whose UVs are an auto-atlas | **accepted with a warning** — it paints, just messily | + +### Rigged bodies are not supported + +A `blob.body` that is a `SkinnedMesh` is rejected and the built-in blob is kept. +This is a real limitation, not a missing check: `createBlob` re-parents the body +mesh alone into its own group, so the GLB's bones are left outside the scene +graph and never get a `matrixWorld` update — the pose would never advance. And +the body's geometry is re-centred and re-scaled to the collider radius, which +invalidates the skeleton's bind matrices, so any deformation that did resolve +would be offset. `idleClip` is ignored for the same reason and no animation +mixer is created — animating an invisible skeleton in the fixed step would cost +CPU for nothing. Export the body with the armature applied. + +### The atlas warning + +`blob.body` reports `uvCharts` (connected UV islands) and `seamRatio`. UVs +inside 0..1 with one material is **not** enough: the farm's own +`blobbo-base.glb` passes both and still paints wrong, because its unwrap is a +1140-island auto-atlas (29% of its vertices sit on a seam) and a splat disc in +UV space therefore speckles a thousand unrelated triangles. Above 24 islands the +report says so. For reference, three's `SphereGeometry` is 3 islands and a +`BoxGeometry` is 6 — a hand unwrap is nowhere near the threshold. + ## Modelling conventions Y-up, metres, one material, embedded textures ≤ 2048². @@ -94,6 +151,14 @@ Y-up, metres, one material, embedded textures ≤ 2048². - **`machine.boot`** — origin at the base; it shakes about its own origin. - **`machine.fan`** — faces +Z. - **`machine.seesaw`** — plank only, long axis along X, origin at the centre. +- **`ghost.body`** — if you leave it empty the ghost borrows `blob.body` and is + put through the exact same fit, so it always matches the blob's size. Fill it + only when you want the ghost to look like something else; then its own + `offset`/`rotation`/`scale` apply normally. +- **`course.tunnel`** — roof slab only. Model its underside FLAT at the bottom + of the model: the clearance a MINI blob squeezes through is the collider's and + never changes, so a model that hangs lower than the collider will look like it + blocks a gap it does not. - **Course boxes** keep their collider whatever you drop in, so a custom prop can never open a hole in the course or grow an invisible wall. Fill the volume. - Materials are converted to `MeshStandardMaterial` on load if they aren't one @@ -113,15 +178,44 @@ but nothing ever swaps. It must be awaited: the frozen `game.ts` captures `blob.mesh` by reference the moment `createBlob` returns, so the body asset has to be in the cache before the game is built. +`initAssets()` cannot hang the boot: every url in the manifest is raced against +a 10 s deadline and settled independently, so a stalled host costs one warning +and the built-in prop rather than a game that never starts. + +`course.tramp` still needs one line in `game.ts`, next to where the trampoline +pad mesh is built: + +```ts +assets().attachSlot('course.tramp', trampMesh, { + onSwap: () => { (trampMesh.material as THREE.Material).visible = false }, +}) +``` + ## Checking your work ``` npm run build node --import ./scripts/ts-resolve.mjs --experimental-strip-types src/assets/manifest.test.ts node --import ./scripts/ts-resolve.mjs --experimental-strip-types src/assets/registry.test.ts +node --import ./scripts/ts-resolve.mjs --experimental-strip-types src/assets/harden.test.ts node --import ./scripts/ts-resolve.mjs --experimental-strip-types scripts/farm-assets.check.ts ``` -The last one parses the real farm GLBs, runs the paintability check on each and +`harden.test.ts` is the regression net for the failure modes that used to be +silent: a mesh-less GLB blanking a slot, a throw escaping the swap path, a +stalled url hanging the boot, an oversized ghost, a rigged body being accepted, +and one dropped asset serving nine puddles. + +The sacred property — empty manifest builds today's game — has its own check: + +``` +./node_modules/.bin/esbuild scripts/sacred-parity.check.ts --bundle \ + --platform=node --format=esm --outfile=/tmp/sacred.mjs && node /tmp/sacred.mjs +``` + +It fingerprints every node of the scene the course, zones, blob and a cannon +build with an empty registry. The number must not move. + +The farm check parses the real farm GLBs, runs the paintability check on each and fires PaintSkin's own outside-in raycast against the fitted blob body, so you can confirm a splat would land without opening a browser. diff --git a/lanes/LANE-I-asset-runtime.md b/lanes/LANE-I-asset-runtime.md index cbe4fe6..92d2c17 100644 --- a/lanes/LANE-I-asset-runtime.md +++ b/lanes/LANE-I-asset-runtime.md @@ -38,9 +38,18 @@ implement every slot listed there except the UI rows. - The delivered mesh's material `map` is replaced by PaintSkin's canvas texture at integration (skin binds to whatever mesh is present); ensure the hook exposes the swapped-in paintable mesh as `blob.mesh` (the skin - and buffs read it). If the GLB is a SkinnedMesh with `idleClip`, drive an - AnimationMixer in a fixed-step System (NOT onFrame — hidden-tab rule, see - telegraph.ts docstring for why). + and buffs read it). + + **NOT BUILT — SkinnedMesh + `idleClip` was authored here and does not work.** + `createBlob` re-parents the body MESH alone into its own group, leaving the + GLB's bones outside the scene graph with no `matrixWorld` update, so the + clip animates nothing while burning fixed-step CPU; and the body's geometry + is re-centred/re-scaled to the collider radius, which invalidates the + skeleton's bind matrices. `paintableInfo` therefore REJECTS a skinned + `blob.body` (`ok` requires `!skinned`), `idleClip` is ignored with a warning + and no AnimationMixer is created. Making it real means keeping the GLB root + in the scene graph and fitting via a parent node instead of baking into the + geometry — a design change, not a bug fix. - UV warning: if `paintableInfo.uvOk` is false, console.warn and keep the procedural sphere instead (paint must never silently break). 4. `assets/manifest.json` — ship EMPTY `{}` (packs come later via integration). diff --git a/scripts/sacred-parity.check.ts b/scripts/sacred-parity.check.ts new file mode 100644 index 0000000..4909664 --- /dev/null +++ b/scripts/sacred-parity.check.ts @@ -0,0 +1,63 @@ +// THE SACRED PROPERTY: empty manifest + empty IndexedDB must construct exactly +// what the game constructed before the workshop existed. Fingerprints the whole +// scene graph the course + zones + a cannon build, so any drift shows up as a +// changed hash. Run against main and against the branch; the numbers must match. +// +// It has to be BUNDLED rather than strip-typed: the game modules use TypeScript +// parameter properties, which node's --experimental-strip-types refuses. +// +// ./node_modules/.bin/esbuild scripts/sacred-parity.check.ts --bundle \ +// --platform=node --format=esm --outfile=/tmp/sacred.mjs && node /tmp/sacred.mjs +// +// Measured on fix-runtime and on main at f827f6f, both: +// nodes=49 colliders=15 bodies=3 greyboxMeshes=11 blobRadius=0.500000 +// sceneFingerprint=49df4f20 +import * as THREE from 'three' +import RAPIER from '@dimforge/rapier3d-compat' +import { buildGreybox } from '../src/course/greybox.ts' +import { installZones } from '../src/course/zones.ts' +import { PaintCannon } from '../src/paint/cannon.ts' +import { AssetRegistry, setAssets } from '../src/assets/registry.ts' +import { createBlob } from '../src/blob/createBlob.ts' + +setAssets(new AssetRegistry({})) // empty manifest, no IndexedDB +await RAPIER.init() + +const scene = new THREE.Scene() +const physics = new RAPIER.World({ x: 0, y: -9.81, z: 0 }) +const listeners: any = {} +const world: any = { + scene, physics, rapier: RAPIER, + events: { on: (n: string, f: any) => { (listeners[n] ||= []).push(f) }, emit: () => {} }, + addSystem: () => {}, onFrame: () => {}, tick: () => {}, renderOnce: () => {}, start: () => {}, +} + +const gb = buildGreybox(world) +const blob = createBlob(world, {}) +world.blob = blob +const skin: any = { boundingRadius: 0.5, splat: () => {}, splatAtWorldPoint: () => {}, coverage: () => ({ total: 0 }) } +installZones(world, blob, skin) +new PaintCannon({ world, color: 'red', position: new THREE.Vector3(0, 2, 0), target: blob.mesh, paint: skin, targetRadius: 0.5 }) + +await new Promise((r) => setTimeout(r, 50)) // let any async swap settle + +function fp(root: THREE.Object3D) { + const parts: string[] = [] + root.traverse((o) => { + const m = o as THREE.Mesh + const mat: any = Array.isArray(m.material) ? m.material[0] : m.material + parts.push([ + o.type, o.name, o.visible ? 1 : 0, + o.position.toArray().map((n) => n.toFixed(4)).join(','), + o.scale.toArray().map((n) => n.toFixed(4)).join(','), + m.isMesh ? (m.geometry.getAttribute('position')?.count ?? 0) : '-', + mat ? `${mat.type}:${mat.color?.getHexString?.() ?? ''}:${mat.visible ? 1 : 0}` : '-', + ].join('|')) + }) + return parts +} +const lines = fp(scene) +let h = 0 +for (const s of lines) for (let i = 0; i < s.length; i++) h = (Math.imul(31, h) + s.charCodeAt(i)) | 0 +console.log(`nodes=${lines.length} colliders=${physics.colliders.len()} bodies=${physics.bodies.len()} greyboxMeshes=${gb.meshes.length} blobRadius=${blob.mesh.geometry.boundingSphere?.radius?.toFixed(6) ?? (blob.mesh.geometry.computeBoundingSphere(), blob.mesh.geometry.boundingSphere!.radius.toFixed(6))}`) +console.log(`sceneFingerprint=${(h >>> 0).toString(16)}`) diff --git a/src/assets/blobBody.ts b/src/assets/blobBody.ts index ce048b2..74e5fbe 100644 --- a/src/assets/blobBody.ts +++ b/src/assets/blobBody.ts @@ -1,7 +1,7 @@ /** * Lane I — the `blob.body` slot, which is the only slot that is not cosmetic. * - * PaintSkin stamps into a canvas through this mesh's UVs and derives + * PaintSkin stamps into a canvas texture through this mesh's UVs and derives * `boundingRadius` from its geometry — and that radius is read back as a * GAMEPLAY number (puddles.ts computes the belly contact point from it). The * frozen game.ts also captures `blob.mesh` by reference the instant createBlob @@ -11,18 +11,76 @@ * fails those tests keeps the procedural sphere and says why, out loud. */ import * as THREE from 'three' -import type { System, World } from '../contracts' -import { assets, normalizeToRadius, paintableInfo } from './registry' +import type { World } from '../contracts' +import { assets, collectMeshes, normalizeToRadius, paintableInfo } from './registry' +import type { SlotEntry } from './manifest' + +/** + * Bake a body asset down to ONE mesh sitting at the origin at exactly `radius`. + * + * Shared between `resolveBlobBody` and the ghost's borrow of `blob.body`, + * because the fit is not decoration: `boundingRadius` is a gameplay number for + * the blob, and for the ghost a body that skips this renders at the raw GLB's + * size — 2.17x oversized with the farm's blobbo-base.glb, measured. + * + * Every transform between `root` and the mesh (and `root`'s own) is baked into + * the geometry and then zeroed, so no ancestor can re-scale the body after it + * has been fitted. `entry.scale` is deliberately dropped: the body is always + * the size of its collider. + */ +export function fitBodyToRadius( + root: THREE.Object3D, + radius: number, + entry?: Pick, +): THREE.Mesh | null { + const mesh = collectMeshes(root)[0] + if (!mesh) return null + + root.updateMatrixWorld(true) + const geo = mesh.geometry.clone() + // Mesh pose relative to root, so a GLB that parks its mesh under a + // transformed node is fitted the same as one that does not. + const rel = new THREE.Matrix4().copy(root.matrixWorld).invert().multiply(mesh.matrixWorld) + geo.applyMatrix4(rel) + + if (entry?.offset || entry?.rotationDeg) { + const k = Math.PI / 180 + const rot = entry.rotationDeg + ? new THREE.Euler(entry.rotationDeg[0] * k, entry.rotationDeg[1] * k, entry.rotationDeg[2] * k) + : new THREE.Euler() + const off = entry.offset ?? [0, 0, 0] + geo.applyMatrix4(new THREE.Matrix4().compose( + new THREE.Vector3(off[0], off[1], off[2]), + new THREE.Quaternion().setFromEuler(rot), + new THREE.Vector3(1, 1, 1), + )) + } + + normalizeToRadius(geo, radius) + mesh.geometry = geo + + for (let o: THREE.Object3D | null = mesh; o; o = o === root ? null : o.parent) { + o.position.set(0, 0, 0) + o.rotation.set(0, 0, 0) + o.scale.set(1, 1, 1) + o.updateMatrix() + } + root.updateMatrixWorld(true) + return mesh +} /** * Returns the paintable body mesh for the blob: the swapped-in GLB mesh when * `blob.body` is filled AND paint-safe, otherwise the caller's own sphere. */ export function resolveBlobBody( + // `world` is unused since the idle-clip mixer was removed, but the signature + // is called from createBlob and the editor — keep it stable. world: World, radius: number, buildFallback: () => THREE.Mesh, ): THREE.Mesh { + void world const reg = assets() const inst = reg.instanceSync('blob.body') if (!inst) { @@ -47,34 +105,22 @@ export function resolveBlobBody( console.warn('[assets] blob.body loaded with warnings:\n - ' + info.problems.join('\n - ')) } - let found: THREE.Mesh | null = null - inst.object.traverse((o) => { - if (!found && (o as THREE.Mesh).isMesh) found = o as THREE.Mesh - }) - const mesh = found as THREE.Mesh | null - if (!mesh) return buildFallback() - - // Bake the manifest's offset/rotation into the geometry rather than a parent - // node: feel.ts owns this mesh's whole local transform and would clobber it, - // and PaintSkin raycasts in the mesh's own space. `scale` is intentionally - // ignored — the body is always normalised back to the collider radius. const entry = inst.entry - const geo = mesh.geometry.clone() - if (entry.offset || entry.rotationDeg) { - const m = new THREE.Matrix4() - const k = Math.PI / 180 - const rot = entry.rotationDeg - ? new THREE.Euler(entry.rotationDeg[0] * k, entry.rotationDeg[1] * k, entry.rotationDeg[2] * k) - : new THREE.Euler() - const off = entry.offset ?? [0, 0, 0] - m.compose(new THREE.Vector3(off[0], off[1], off[2]), new THREE.Quaternion().setFromEuler(rot), new THREE.Vector3(1, 1, 1)) - geo.applyMatrix4(m) - } if (entry.scale !== undefined) { console.warn('[assets] blob.body "scale" is ignored — the body is always fitted to the blob size.') } - normalizeToRadius(geo, radius) - mesh.geometry = geo + if (entry.idleClip !== undefined) { + console.warn( + '[assets] blob.body "idleClip" is ignored — animated bodies are not supported yet. ' + + 'The squash-and-stretch on the blob group still plays.', + ) + } + + // Bake the fit into the geometry rather than a parent node: feel.ts owns this + // mesh's whole local transform and would clobber a node, and PaintSkin + // raycasts in the mesh's own space. + const mesh = fitBodyToRadius(inst.object, radius, entry) + if (!mesh) return buildFallback() // feel.ts writes `emissiveIntensity` for the super-glow and PaintSkin forces // the colour white; an imported material usually has neither set up. @@ -84,41 +130,5 @@ export function resolveBlobBody( } mat.emissiveIntensity = 0 mesh.castShadow = true - mesh.position.set(0, 0, 0) - mesh.rotation.set(0, 0, 0) - mesh.scale.set(1, 1, 1) - - installIdleClip(world, mesh, inst.object, inst.animations, entry.idleClip) return mesh } - -/** - * Idle animation runs in the FIXED step, never onFrame: render frames stop - * entirely in a hidden tab (see machine/telegraph.ts) and a rigged body's rest - * pose is what paint UVs are read against — letting it drift with wall-clock - * time would desync a backgrounded run from a foregrounded one. - */ -function installIdleClip( - world: World, - mesh: THREE.Mesh, - root: THREE.Object3D, - animations: THREE.AnimationClip[], - clipName: string | undefined, -): void { - if (!clipName) return - const clip = animations.find((c) => c.name === clipName) - if (!clip) { - console.warn( - `[assets] blob.body has no animation named "${clipName}" ` + - `(found: ${animations.map((c) => c.name).join(', ') || 'none'}).`, - ) - return - } - // The mesh is re-parented out of the GLB scene, so drive the mixer from the - // mesh itself; bone tracks still resolve through its skeleton. - const target = (mesh as THREE.SkinnedMesh).isSkinnedMesh ? mesh : root - const mixer = new THREE.AnimationMixer(target) - mixer.clipAction(clip).play() - const system: System = { update: (dt) => mixer.update(dt) } - world.addSystem(system) -} diff --git a/src/assets/harden.test.ts b/src/assets/harden.test.ts new file mode 100644 index 0000000..2b9e381 --- /dev/null +++ b/src/assets/harden.test.ts @@ -0,0 +1,302 @@ +/** + * Headless reproductions of the asset-runtime failures found in review. Run: + * node --import ./scripts/ts-resolve.mjs --experimental-strip-types src/assets/harden.test.ts + * + * Each block first states the OBSERVABLE failure it is pinning down, because + * every one of these was reproducible before the fix and silent in a browser. + * No network and no renderer: assets are injected straight into the registry's + * cache, which is exactly the state `preload()` leaves it in. + */ +import * as THREE from 'three' +import type { World } from '../contracts' +import { AssetRegistry, setAssets, paintableInfo, uvLayoutInfo, collectMeshes } from './registry' +import { resolveBlobBody, fitBodyToRadius } from './blobBody' +import { validateManifest } from './manifest' +import { SLOT_IDS, SLOT_LABELS } from './slots' + +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-5): void { + ok(Math.abs(a - b) <= eps, `${msg} (got ${a}, want ${b})`) +} + +/** Put a scene in the registry's cache as if preload() had fetched it. */ +function inject(reg: AssetRegistry, url: string, scene: THREE.Group): void { + const asset = { scene, animations: [] as THREE.AnimationClip[] } + const priv = reg as unknown as { + cache: Map> + resolved: Map + } + priv.cache.set(url, Promise.resolve(asset)) + priv.resolved.set(url, asset) +} + +function sphereGlb(radius = 1): THREE.Group { + const g = new THREE.Group() + g.add(new THREE.Mesh(new THREE.SphereGeometry(radius, 24, 18), new THREE.MeshStandardMaterial())) + return g +} + +const warnings: string[] = [] +const realWarn = console.warn +console.warn = (...a: unknown[]) => { warnings.push(a.map(String).join(' ')) } +function drainWarnings(): string[] { + const out = warnings.slice() + warnings.length = 0 + return out +} +const tick = (): Promise => new Promise((r) => setTimeout(r, 0)) + +// ---- B1: a mesh-less GLB must not blank the slot ---------------------------- +// Before: onSwap fired unconditionally, so greybox hid the box material and +// added nothing — an invisible prop with a live collider. +{ + const reg = new AssetRegistry({ 'course.scenery.block': { url: 'rig-only.glb' } }) + const empty = new THREE.Group() + empty.add(new THREE.Object3D()) // an armature/empty, no Mesh anywhere + inject(reg, 'rig-only.glb', empty) + + const box = new THREE.Mesh(new THREE.BoxGeometry(1, 1, 1), new THREE.MeshStandardMaterial()) + let swapped = false + reg.attachSlot('course.scenery.block', box, { + onSwap: () => { swapped = true; (box.material as THREE.Material).visible = false }, + }) + await tick() + ok(!swapped, 'a mesh-less GLB never fires onSwap') + ok((box.material as THREE.Material).visible, 'the procedural box is still visible') + ok(box.children.length === 0, 'no empty fit node was parented') + ok(drainWarnings().some((w) => w.includes('has no mesh')), 'it says so, once') +} + +// blob.face goes through slotObject, which is the same guard one level up. +{ + const reg = new AssetRegistry({ 'blob.face': { url: 'rig-only.glb' } }) + inject(reg, 'rig-only.glb', new THREE.Group()) + const eyes = new THREE.Object3D() + const holder = reg.slotObject('blob.face', () => eyes) + await tick() + ok(holder.children.includes(eyes), 'the eyes survive a mesh-less blob.face asset') + drainWarnings() +} + +// ---- B2: nothing thrown in the swap path may escape or half-swap ------------ +{ + // (a) a throw BEFORE onSwap leaves the primitive visible and adds nothing. + const reg = new AssetRegistry({ 'machine.boot': { url: 'bad.glb' } }) + const scene = sphereGlb() + // A material that explodes on clone() — instantiate()'s per-instance clone. + const mesh = collectMeshes(scene)[0] + ;(mesh.material as THREE.Material).clone = () => { throw new Error('boom in clone') } + inject(reg, 'bad.glb', scene) + + const host = new THREE.Object3D() + let swapped = false + reg.attachSlot('machine.boot', host, { onSwap: () => { swapped = true } }) + await tick() + ok(!swapped, 'a throw during instancing never reaches onSwap') + ok(host.children.length === 0, 'nothing half-parented') + ok(drainWarnings().some((w) => w.includes('keeping the built-in version')), 'warned instead of rejecting a promise') +} +{ + // (b) a throw INSIDE onSwap must not remove the replacement. Consumers hide + // their primitive on onSwap's first line, so removing the fit node here would + // manufacture the exact "primitive hidden, replacement missing" state. + const reg = new AssetRegistry({ 'fx.puddle': { url: 'ok.glb' } }) + inject(reg, 'ok.glb', sphereGlb()) + const slab = new THREE.Mesh(new THREE.BoxGeometry(1, 1, 1), new THREE.MeshStandardMaterial()) + reg.attachSlot('fx.puddle', slab, { + onSwap: () => { + ;(slab.material as THREE.Material).visible = false // consumers do this FIRST + throw new Error('boom in onSwap') + }, + }) + await tick() + ok(slab.children.length === 1, 'the replacement stays in the scene when onSwap throws') + ok(collectMeshes(slab.children[0]).length === 1, 'and it still contains the mesh') + ok(drainWarnings().some((w) => w.includes('could not be re-styled')), 'the styling failure is reported') +} +{ + // (c) instanceSync is called from createBlob, which frozen game.ts calls with + // no try/catch — a throw there is a black screen, not a missing prop. + const reg = new AssetRegistry({ 'blob.body': { url: 'bad.glb' } }) + const scene = sphereGlb() + const mesh = collectMeshes(scene)[0] + ;(mesh.material as THREE.Material).clone = () => { throw new Error('boom in clone') } + inject(reg, 'bad.glb', scene) + setAssets(reg) + + const built = new THREE.Mesh(new THREE.SphereGeometry(0.5, 48, 36), new THREE.MeshStandardMaterial()) + const fakeWorld = { addSystem: () => {} } as unknown as World + const out = resolveBlobBody(fakeWorld, 0.5, () => built) + ok(out === built, 'a throwing blob.body falls back to the built-in blob instead of killing the boot') + ok(reg.instanceSync('blob.body') === null, 'instanceSync returns null rather than throwing') + drainWarnings() +} + +// ---- B3: preload must not hang the boot ------------------------------------- +{ + const reg = new AssetRegistry({ 'machine.fan': { url: 'stalled.glb' } }) + const priv = reg as unknown as { cache: Map> } + priv.cache.set('stalled.glb', new Promise(() => { /* a host that never answers */ })) + const t0 = Date.now() + await reg.preload(60) // same code path, a deadline you can wait for in a test + const dt = Date.now() - t0 + ok(dt < 2000, `preload gave up on a stalled url in ${dt}ms instead of never resolving`) + ok(drainWarnings().some((w) => w.includes('did not finish loading')), 'the stalled url is named') + ok(reg.instanceSync('machine.fan') === null, 'the slot simply stays on its fallback') +} +{ + // allSettled semantics: one bad url must not starve a good one. + const reg = new AssetRegistry({ + 'machine.fan': { url: 'stalled.glb' }, + 'machine.belt': { url: 'good.glb' }, + }) + const priv = reg as unknown as { cache: Map> } + priv.cache.set('stalled.glb', new Promise(() => {})) + inject(reg, 'good.glb', sphereGlb()) + await reg.preload(60) + ok(reg.instanceSync('machine.belt') !== null, 'the healthy asset still loaded') + drainWarnings() +} + +// ---- ghost sizing ----------------------------------------------------------- +// Before: the ghost borrowed blob.body raw, so a 1.084-radius farm mesh +// rendered the ghost 2.17x the blob it is meant to mirror. +{ + const raw = sphereGlb(1.0841) // blobbo-base.glb's measured bounding radius + const fit = new THREE.Group() + fit.scale.setScalar(3) // a fit node built from a manifest `scale`, as attachSlot builds it + fit.add(raw) + const mesh = fitBodyToRadius(fit, 0.5) + ok(mesh !== null, 'the shared fit found the body mesh') + fit.updateMatrixWorld(true) + const box = new THREE.Box3().setFromObject(fit) + const size = new THREE.Vector3() + box.getSize(size) + near(size.x / 2, 0.5, 'the ghost body ends up at the blob radius in WORLD space') + near(box.getCenter(new THREE.Vector3()).length(), 0, 'and centred on the ghost origin') +} +{ + // The blob and the ghost must agree for the same asset — that is the point. + const reg = new AssetRegistry({ 'blob.body': { url: 'body.glb', scale: 4, offset: [3, 0, 0] } }) + inject(reg, 'body.glb', sphereGlb(1.0841)) + setAssets(reg) + const fakeWorld = { addSystem: () => {} } as unknown as World + const blobMesh = resolveBlobBody(fakeWorld, 0.5, () => new THREE.Mesh()) + blobMesh.geometry.computeBoundingSphere() + + const ghostInst = reg.instanceSync('blob.body')! + const ghostFit = new THREE.Group() + ghostFit.add(ghostInst.object) + const ghostMesh = fitBodyToRadius(ghostFit, 0.5, ghostInst.entry)! + ghostMesh.geometry.computeBoundingSphere() + near( + ghostMesh.geometry.boundingSphere!.radius, + blobMesh.geometry.boundingSphere!.radius, + 'ghost and blob resolve to the same radius from the same asset', + ) + drainWarnings() +} + +// ---- skinned blob.body is rejected loudly, not repaired --------------------- +{ + const geo = new THREE.SphereGeometry(0.5, 16, 12) + const bone = new THREE.Bone() + const skinned = new THREE.SkinnedMesh(geo, new THREE.MeshStandardMaterial()) + skinned.add(bone) + const info = paintableInfo(skinned) + ok(info.skinned, 'the skeleton is detected') + ok(!info.ok, 'a rigged body is REJECTED — the skeleton is orphaned by the design and normalising invalidates the bind matrices') + ok(info.problems.some((p) => p.includes('skeleton/armature')), 'in plain language') +} +{ + // ...and no AnimationMixer system is registered for it, so we are not burning + // fixed-step CPU animating something that is not in the scene graph. + const reg = new AssetRegistry({ 'blob.body': { url: 'body.glb', idleClip: 'Idle' } }) + inject(reg, 'body.glb', sphereGlb()) + setAssets(reg) + const systems: unknown[] = [] + const fakeWorld = { addSystem: (s: unknown) => systems.push(s) } as unknown as World + resolveBlobBody(fakeWorld, 0.5, () => new THREE.Mesh()) + ok(systems.length === 0, 'idleClip registers no fixed-step system') + ok(drainWarnings().some((w) => w.includes('idleClip')), 'and says the clip is ignored') +} + +// ---- UV atlas detection ----------------------------------------------------- +// blobbo-base.glb passes every other check (0..1 UVs, one material) and still +// paints wrong: it is a 1140-island auto-atlas. +{ + const sphere = uvLayoutInfo(new THREE.SphereGeometry(0.5, 48, 36)) + ok(sphere.charts === 3, `three's own sphere is 3 charts (body + 2 pole fans), got ${sphere.charts}`) + ok(paintableInfo(new THREE.Mesh(new THREE.SphereGeometry(0.5, 48, 36), new THREE.MeshStandardMaterial())).problems.length === 0, + 'the stock blob is NOT flagged as an atlas') + + // A shattered layout: same sphere, every triangle its own island. + const shattered = new THREE.SphereGeometry(0.5, 24, 18).toNonIndexed() + const idx: number[] = [] + for (let i = 0; i < shattered.getAttribute('position').count; i++) idx.push(i) + shattered.setIndex(idx) + const info = uvLayoutInfo(shattered) + ok(info.charts > 24, `a per-triangle atlas is detected (${info.charts} charts)`) + const p = paintableInfo(new THREE.Mesh(shattered, new THREE.MeshStandardMaterial())) + ok(p.problems.some((s) => s.includes('islands')), 'and reported as a scatter risk') + ok(p.ok, 'but still accepted — it paints untidily, it does not fail to paint') +} + +// ---- manifest metadata keys ------------------------------------------------- +{ + const res = validateManifest({ + _readme: 'exported by the BLOBBO workshop', + _generatedBy: 'workshop 1.0', + 'machine.boot': { url: 'a.glb' }, + 'machine.tractor': { url: 'b.glb' }, + }) + ok(res.problems.length === 1, `only the real typo is reported (got ${res.problems.length})`) + ok(res.problems[0].slot === 'machine.tractor', 'and it is the typo, not the metadata') + ok(Object.keys(res.manifest).length === 1, 'metadata never becomes a slot') +} + +// ---- slot vocabulary -------------------------------------------------------- +{ + for (const id of ['cannon.base', 'course.finish', 'course.tunnel', 'course.tramp'] as const) { + ok(SLOT_IDS.includes(id), `${id} is in the canonical list the editor imports`) + ok(typeof SLOT_LABELS[id] === 'string' && SLOT_LABELS[id].length > 0, `${id} has a label`) + } + ok(SLOT_IDS.length === new Set(SLOT_IDS).size, 'no duplicate slot ids') + ok(SLOT_IDS.every((id) => SLOT_LABELS[id]), 'every slot id has a label') +} + +// ---- multi-instance slots --------------------------------------------------- +// Nine puddles and six cannons share one slot id. Each must get its OWN clone at +// its OWN pose — one shared node would teleport eight puddles onto the ninth. +{ + const reg = new AssetRegistry({ 'fx.puddle': { url: 'decal.glb' } }) + inject(reg, 'decal.glb', sphereGlb(0.5)) + const slabs = [-10, 0, 10].map((x) => { + const m = new THREE.Mesh(new THREE.BoxGeometry(1, 1, 1), new THREE.MeshStandardMaterial()) + m.position.set(x, 0, 0) + return m + }) + for (const s of slabs) { + reg.attachSlot('fx.puddle', s, { + fit: new THREE.Vector3(2, 1, 2), + onSwap: () => { (s.material as THREE.Material).visible = false }, + }) + } + await tick() + const roots = slabs.map((s) => s.children[0]) + ok(roots.every((r) => r !== undefined), 'every instance got a replacement') + ok(new Set(roots).size === 3, 'three distinct fit nodes, not one shared node') + const mats = slabs.map((s) => collectMeshes(s)[0].material) + ok(new Set(mats).size === 3, 'and three distinct materials, so per-instance tinting cannot bleed') + const world = new THREE.Vector3() + collectMeshes(slabs[2])[0].getWorldPosition(world) + near(world.x, 10, 'each clone sits at its own instance pose') + drainWarnings() +} + +console.warn = realWarn +console.log(`harden.test.ts: ${passed} checks passed`) diff --git a/src/assets/manifest.ts b/src/assets/manifest.ts index 96310cd..04af559 100644 --- a/src/assets/manifest.ts +++ b/src/assets/manifest.ts @@ -63,6 +63,11 @@ export function validateManifest(raw: unknown): ValidationResult { } for (const [key, value] of Object.entries(raw as Record)) { + // `_`-prefixed keys are metadata, not slots: the editor writes `_readme` + // and `_generatedBy` into every export so a hand-opened manifest explains + // itself. Reporting them as "unknown slot id" would train John to ignore + // the problem list, which is the one place real typos show up. + if (key.startsWith('_')) continue if (!isSlotId(key)) { problems.push({ slot: key, reason: 'unknown slot id' }) continue diff --git a/src/assets/registry.ts b/src/assets/registry.ts index ceefe3d..6ad61d9 100644 --- a/src/assets/registry.ts +++ b/src/assets/registry.ts @@ -27,13 +27,21 @@ import { loadOverrideManifest, isIdbUrl, idbKeyFromUrl, getBlob } from './idb' import type { SlotId } from './slots' export interface PaintableInfo { - /** A single Mesh with a single material and 0..1 UVs was found. */ + /** A single unrigged Mesh with a single material and 0..1 UVs was found. */ ok: boolean uvOk: boolean triCount: number materialCount: number meshCount: number skinned: boolean + /** + * Connected components of the UV layout. A single hand-unwrapped island is 1; + * three's own SphereGeometry is 3 (body + two pole fans). An auto-atlas is + * hundreds. -1 when the geometry is non-indexed (chart count is undefined). + */ + uvCharts: number + /** Fraction of vertices whose position is shared but whose UV is not. */ + seamRatio: number /** Plain-language reasons the asset is not paint-safe (editor surface). */ problems: string[] } @@ -55,6 +63,20 @@ const loader = new GLTFLoader() /** How far outside 0..1 a UV may stray before the layout is judged unpaintable. */ const UV_MARGIN = 0.1 +/** + * Above this many UV islands the layout is an auto-atlas rather than an unwrap. + * Measured: three's SphereGeometry is 3 charts, a BoxGeometry 6, a hand unwrap a + * handful; the farm's blobbo-base.glb is 1140. A hand-authored model has no + * business being near this number. + */ +const MAX_UV_CHARTS = 24 + +/** Above this many vertices, skip the chart walk rather than stall the boot. */ +const CHART_VERT_LIMIT = 400_000 + +/** Per-url budget in `preload`. Boot awaits preload, so this is a boot budget. */ +export const PRELOAD_TIMEOUT_MS = 10_000 + function degToRadTriple(d: [number, number, number]): THREE.Euler { const k = Math.PI / 180 return new THREE.Euler(d[0] * k, d[1] * k, d[2] * k) @@ -112,22 +134,90 @@ export function ensureStandardMaterials(root: THREE.Object3D): number { return converted } +/** Every Mesh under `root`, in traversal order. */ +export function collectMeshes(root: THREE.Object3D): THREE.Mesh[] { + const out: THREE.Mesh[] = [] + root.traverse((o) => { + if ((o as THREE.Mesh).isMesh) out.push(o as THREE.Mesh) + }) + return out +} + +/** + * How the UVs are laid out, which is what decides whether a splat DISC lands on + * one patch of the model or speckles it. + * + * `uvOk` (0..1 range) is necessary but nowhere near sufficient: the farm's own + * blobbo-base.glb has every UV inside 0..1 and exactly one material, yet its + * unwrap is a 1140-island auto-atlas, so a circle stamped in UV space bleeds + * onto a thousand unrelated triangles. Charts are counted as connected + * components of the INDEX graph — a UV seam duplicates the position vertex, so + * islands fall out of the topology for free. + */ +export function uvLayoutInfo(geo: THREE.BufferGeometry): { charts: number; seamRatio: number } { + const pos = geo.getAttribute('position') + const uv = geo.getAttribute('uv') + const index = geo.getIndex() + const n = pos ? pos.count : 0 + if (!pos || !uv || !index || n === 0 || n > CHART_VERT_LIMIT) { + return { charts: -1, seamRatio: 0 } + } + + const parent = new Int32Array(n) + for (let i = 0; i < n; i++) parent[i] = i + const find = (a: number): number => { + let r = a + while (parent[r] !== r) { parent[r] = parent[parent[r]]; r = parent[r] } + return r + } + const union = (a: number, b: number): void => { + const ra = find(a) + const rb = find(b) + if (ra !== rb) parent[ra] = rb + } + for (let i = 0; i + 2 < index.count; i += 3) { + const a = index.getX(i) + const b = index.getX(i + 1) + union(a, b) + union(b, index.getX(i + 2)) + } + const roots = new Set() + for (let i = 0; i < n; i++) roots.add(find(i)) + + // Seam ratio: vertices that share a position with a vertex carrying a + // DIFFERENT uv — i.e. the cut edges of the atlas. + const byPos = new Map() + for (let i = 0; i < n; i++) { + const key = `${pos.getX(i).toFixed(4)},${pos.getY(i).toFixed(4)},${pos.getZ(i).toFixed(4)}` + const bucket = byPos.get(key) + if (bucket) bucket.push(i) + else byPos.set(key, [i]) + } + let split = 0 + for (const bucket of byPos.values()) { + if (bucket.length < 2) continue + const seen = new Set() + for (const i of bucket) seen.add(`${uv.getX(i).toFixed(4)},${uv.getY(i).toFixed(4)}`) + if (seen.size > 1) split += bucket.length + } + + return { charts: roots.size, seamRatio: split / n } +} + /** * Pure-ish geometry inspection. Reports whether an object can carry PaintSkin: - * one mesh, one material, a `uv` attribute inside 0..1 (the skin's stamp wrap - * assumes an equirectangular layout, so out-of-range or missing UVs mean splats - * land somewhere unrelated — or nowhere). + * one unrigged mesh, one material, a `uv` attribute inside 0..1 laid out as a + * small number of islands (the skin's stamp wrap assumes a contiguous unwrap, so + * out-of-range, missing or shattered UVs mean splats land somewhere unrelated — + * or nowhere). */ export function paintableInfo(object: THREE.Object3D): PaintableInfo { - const meshes: THREE.Mesh[] = [] - object.traverse((o) => { - if ((o as THREE.Mesh).isMesh) meshes.push(o as THREE.Mesh) - }) + const meshes = collectMeshes(object) const problems: string[] = [] if (meshes.length === 0) { return { ok: false, uvOk: false, triCount: 0, materialCount: 0, meshCount: 0, - skinned: false, problems: ['no mesh in the file'], + skinned: false, uvCharts: -1, seamRatio: 0, problems: ['no mesh in the file'], } } if (meshes.length > 1) problems.push(`${meshes.length} meshes — only the first is painted`) @@ -145,6 +235,7 @@ export function paintableInfo(object: THREE.Object3D): PaintableInfo { const triCount = index ? index.count / 3 : (pos ? pos.count / 3 : 0) const uv = geo.getAttribute('uv') + const layout = uv ? uvLayoutInfo(geo) : { charts: -1, seamRatio: 0 } let uvOk = false if (!uv) { problems.push('no UV map — the paint has nowhere to go') @@ -166,12 +257,29 @@ export function paintableInfo(object: THREE.Object3D): PaintableInfo { problems.push(`UVs run ${Number.isFinite(min) ? min.toFixed(2) : '?'}..${Number.isFinite(max) ? max.toFixed(2) : '?'} — they must stay inside 0..1 (one unwrapped island, no tiling)`) } } - if (skinned) problems.push('rigged mesh — paint is placed using the rest pose') + if (layout.charts > MAX_UV_CHARTS) { + problems.push( + `UVs are split into ${layout.charts} separate islands (${Math.round(layout.seamRatio * 100)}% ` + + 'of the vertices sit on a seam) — this is an auto-generated atlas, so paint may scatter ' + + 'across this model instead of landing where it is thrown. Unwrap it as one island.', + ) + } + if (skinned) { + problems.push( + 'this model has a skeleton/armature — rigged blobs are not supported yet, ' + + 'so the built-in body is kept. Export it without the armature (apply the pose first).', + ) + } return { - ok: uvOk && materialCount === 1, + // A shattered atlas is a WARNING, not a rejection: it still paints, just + // untidily, and rejecting it would refuse the only farm mesh that exists. + // A skeleton is a rejection — see installIdleClip in blobBody.ts. + ok: uvOk && materialCount === 1 && !skinned, uvOk, triCount: Math.round(triCount), materialCount, - meshCount: meshes.length, skinned, problems, + meshCount: meshes.length, skinned, + uvCharts: layout.charts, seamRatio: layout.seamRatio, + problems, } } @@ -225,10 +333,33 @@ export class AssetRegistry { * Resolve every url the manifest mentions. Integration must await this before * building the game: `blob.mesh`'s identity is captured by reference in the * frozen game.ts, so the body asset cannot arrive after createBlob returns. + * + * Every url is raced against a deadline and settled independently. A host that + * accepts the connection and never answers would otherwise leave this promise + * pending forever, and since boot AWAITS it, the whole game would never start + * — turning "one custom prop is missing" into "the game is broken". A slot + * whose url times out keeps its procedural fallback; the load itself is left + * running, so a late arrival still populates the cache for the next attach. */ - async preload(): Promise { + async preload(timeoutMs: number = PRELOAD_TIMEOUT_MS): Promise { const urls = new Set(Object.values(this.manifest).map((e) => e.url)) - await Promise.all([...urls].map((u) => this.load(u).catch(() => null))) + await Promise.allSettled([...urls].map((u) => this.loadWithDeadline(u, timeoutMs))) + } + + private loadWithDeadline(url: string, timeoutMs: number): Promise { + const load = this.load(url).catch(() => null) + if (!(timeoutMs > 0)) return load + return new Promise((resolve) => { + const timer = setTimeout(() => { + this.warnOnce( + `timeout:${url}`, + `[assets] "${url}" did not finish loading within ${Math.round(timeoutMs / 1000)}s — ` + + 'starting the game with the built-in version.', + ) + resolve(null) + }, timeoutMs) + void load.then((v) => { clearTimeout(timer); resolve(v) }) + }) } private warnOnce(key: string, ...args: unknown[]): void { @@ -265,45 +396,99 @@ export class AssetRegistry { return p } - /** A cloned, shadow-enabled, standard-material instance of a loaded url. */ - private instantiate(asset: LoadedAsset): THREE.Group { - const copy = skeletonClone(asset.scene) as THREE.Group - // SkeletonUtils.clone shares materials with the source. Instances get tinted - // per-slot (puddle colour, ghost translucency), so each needs its own. - copy.traverse((o) => { - const mesh = o as THREE.Mesh - if (!mesh.isMesh) return - mesh.material = Array.isArray(mesh.material) - ? mesh.material.map((m) => m.clone()) - : mesh.material.clone() - }) - ensureStandardMaterials(copy) - enableShadows(copy) - return copy + /** + * A cloned, shadow-enabled, standard-material instance of a loaded url, or + * null if anything about the file makes three throw. + * + * Never throws, on purpose: `instanceSync` is called from `createBlob`, which + * the frozen game.ts calls with no try/catch of its own. A throw there is a + * black screen rather than a missing prop. + */ + private instantiate(asset: LoadedAsset, url: string): THREE.Group | null { + try { + const copy = skeletonClone(asset.scene) as THREE.Group + // SkeletonUtils.clone shares materials with the source. Instances get tinted + // per-slot (puddle colour, ghost translucency), so each needs its own. + copy.traverse((o) => { + const mesh = o as THREE.Mesh + if (!mesh.isMesh || !mesh.material) return + mesh.material = Array.isArray(mesh.material) + ? mesh.material.map((m) => m.clone()) + : mesh.material.clone() + }) + ensureStandardMaterials(copy) + enableShadows(copy) + return copy + } catch (err) { + this.warnOnce(`inst:${url}`, `[assets] "${url}" could not be prepared — keeping the built-in version.`, err) + return null + } } - /** Already-preloaded instance, or null. Sync — safe inside createBlob. */ + /** Already-preloaded instance, or null. Sync and never throws — safe inside createBlob. */ instanceSync(slot: SlotId): { object: THREE.Group; entry: SlotEntry; animations: THREE.AnimationClip[] } | null { - const entry = this.manifest[slot] - if (!entry) return null - const asset = this.resolved.get(entry.url) - if (!asset) return null - return { object: this.instantiate(asset), entry, animations: asset.animations } + try { + const entry = this.manifest[slot] + if (!entry) return null + const asset = this.resolved.get(entry.url) + if (!asset) return null + const object = this.instantiate(asset, entry.url) + if (!object || collectMeshes(object).length === 0) { + if (object) { + this.warnOnce(`nomesh:${entry.url}`, `[assets] "${entry.url}" has no mesh — keeping the built-in version.`) + } + return null + } + return { object, entry, animations: asset.animations } + } catch (err) { + this.warnOnce(`sync:${slot}`, `[assets] "${slot}" could not be instanced — keeping the built-in version.`, err) + return null + } } /** * Parent the slot's asset under an object that already exists. The host's own * transform is never touched — only a private fit node is added. + * + * Two guards, and the split between them is the whole point. Consumers hide + * their primitive in the FIRST statement of `onSwap`, so "primitive hidden, + * replacement absent" is the one state the scene must never reach: + * + * BUILD (instance, mesh count, parent) — a failure here happens BEFORE + * onSwap has run, so we bail out and the primitive is still visible. + * NOTIFY (onSwap) — a failure here happens AFTER the primitive may already + * be hidden, so the fit node STAYS parented. Losing a colour tint beats + * removing the only thing left to look at. */ attachSlot(slot: SlotId, host: THREE.Object3D, opts: AttachOptions = {}): void { const entry = this.manifest[slot] if (!entry) return void this.load(entry.url).then( (asset) => { - const fit = makeFitNode(entry, opts.fit) - fit.add(this.instantiate(asset)) - host.add(fit) - opts.onSwap?.(fit) + let fit: THREE.Group | null = null + try { + const inst = this.instantiate(asset, entry.url) + if (!inst) return + // A GLB whose scene is only empties/armatures/cameras loads fine and + // instances fine. Swapping it in would hide the primitive and add + // nothing — an invisible object with a live collider. + if (collectMeshes(inst).length === 0) { + this.warnOnce(`nomesh:${entry.url}`, `[assets] "${entry.url}" has no mesh — keeping the built-in version.`) + return + } + fit = makeFitNode(entry, opts.fit) + fit.add(inst) + host.add(fit) + } catch (err) { + this.warnOnce(`swap:${entry.url}`, `[assets] "${entry.url}" could not be installed — keeping the built-in version.`, err) + if (fit?.parent) fit.parent.remove(fit) + return + } + try { + opts.onSwap?.(fit) + } catch (err) { + this.warnOnce(`onswap:${slot}:${entry.url}`, `[assets] "${slot}" swapped in but could not be re-styled.`, err) + } }, () => { /* warned in load(); fallback stays */ }, ) diff --git a/src/assets/slots.ts b/src/assets/slots.ts index 0f7e984..d71023a 100644 --- a/src/assets/slots.ts +++ b/src/assets/slots.ts @@ -13,6 +13,7 @@ export const SLOT_IDS = [ 'blob.body', 'blob.face', 'ghost.body', + 'cannon.base', 'cannon.barrel', 'machine.plate', 'machine.boot', @@ -23,6 +24,9 @@ export const SLOT_IDS = [ 'machine.seesaw', 'course.scenery.cereal', 'course.scenery.block', + 'course.finish', + 'course.tunnel', + 'course.tramp', 'fx.puddle', ] as const @@ -39,7 +43,8 @@ export const SLOT_LABELS: Record = { 'blob.body': 'Blobbo body (paintable)', 'blob.face': 'Blobbo eyes', 'ghost.body': 'Ghost racer', - 'cannon.barrel': 'Paint cannon', + 'cannon.base': 'Paint cannon base', + 'cannon.barrel': 'Paint cannon barrel', 'machine.plate': 'Pressure plate', 'machine.boot': 'Spring boot', 'machine.bucket': 'Paint bucket', @@ -49,6 +54,9 @@ export const SLOT_LABELS: Record = { 'machine.seesaw': 'See-saw plank', 'course.scenery.cereal': 'Giant cereal box', 'course.scenery.block': 'Purple block', + 'course.finish': 'Finish pad', + 'course.tunnel': 'MINI tunnel roof', + 'course.tramp': 'Trampoline / gap launcher', 'fx.puddle': 'Paint puddle', } @@ -69,4 +77,21 @@ export const SLOT_NOTES: Partial> = { 'machine.fan': 'Face +Z. The blades stay procedural so they keep spinning.', 'machine.seesaw': 'Plank only, long axis along X, origin at the plank centre.', 'machine.plate': 'Frame only — the pressed pad stays procedural so it can light up.', + 'cannon.base': 'The stand only; the barrel is a separate slot because it aims every step.', + 'cannon.barrel': 'The tube only, pointing +Z — the aiming pivot stays procedural.', + 'course.finish': 'Fill the 18 x 1.2 x 6 pad volume — its collider never moves.', + 'course.tunnel': + 'Roof slab only (7 x 0.3 x 14). Keep the underside FLAT at the model\'s bottom: ' + + 'clearance under it is what a MINI blob squeezes through, and the collider does not change.', + 'course.tramp': 'Fill the pad volume; the launch impulse is unaffected by the model.', } + +/** + * Slots the game builds ONCE. Everything else may be instanced many times over + * (nine puddles, six cannons, every machine) and each instance gets its own + * clone of the asset at its own pose — see docs/ASSET-SLOTS.md. + */ +export const SINGLE_INSTANCE_SLOTS: ReadonlySet = new Set([ + 'blob.body', 'blob.face', 'ghost.body', + 'course.finish', 'course.tunnel', 'course.scenery.cereal', 'course.scenery.block', +]) diff --git a/src/course/greybox.ts b/src/course/greybox.ts index e0a0f1d..7b84dea 100644 --- a/src/course/greybox.ts +++ b/src/course/greybox.ts @@ -114,7 +114,7 @@ export function buildGreybox(world: World): GreyboxHandle { }) // ---- finish pad (bright pink, raised) ---- - addBox(0, 0.6, -64, 9, 0.6, 6, { color: '#FF6EB4', surface: 'finish', cast: true }) + decorate('course.finish', 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 ---- // Slot hooks go on the MESH only, after the collider exists: the box volume diff --git a/src/course/zones.ts b/src/course/zones.ts index 8a50f27..058ca5b 100644 --- a/src/course/zones.ts +++ b/src/course/zones.ts @@ -35,6 +35,7 @@ import { PALETTE } from '../contracts' import type { PaintSkin } from '../paint/skin' import { installPuddles, type PaintPuddle, type PaintPuddleConfig } from '../paint/puddles' import { createPressurePlate, createSpringBoot, type Vec3 } from '../machine' +import { assets } from '../assets/registry' /** A cannon integration should stage for a zone (fires that zone's colour). */ export interface ZoneCannonConfig { @@ -204,6 +205,13 @@ function buildTunnel( rapier.ColliderDesc.cuboid(hx, hy, hz).setTranslation(cx, cy, cz), ) + // Slot `course.tunnel` decorates the ROOF only, after the collider exists: the + // clearance a MINI blob squeezes through is the collider's, so no model can + // move it. The warning bar and posts stay procedural — they are signage. + assets().attachSlot('course.tunnel', roof, { + onSwap: () => { roofMat.visible = false }, + }) + // "MIND YOUR HEAD" clearance bar hanging just under the lip so the low ceiling // reads before you reach it (visual only). const barMat = new THREE.MeshStandardMaterial({ diff --git a/src/demo/lane-i.ts b/src/demo/lane-i.ts index 68a07ef..4df4fe8 100644 --- a/src/demo/lane-i.ts +++ b/src/demo/lane-i.ts @@ -105,7 +105,8 @@ if (!info) { say(' no asset resolved; the built-in sphere is in use.') } else { say(` meshes ${info.meshCount} materials ${info.materialCount} triangles ${info.triCount}`) - say(` UVs inside 0..1: ${info.uvOk ? 'yes' : 'NO'}${info.skinned ? ' (rigged)' : ''}`) + say(` UVs inside 0..1: ${info.uvOk ? 'yes' : 'NO'}${info.skinned ? ' (RIGGED — rejected)' : ''}`) + say(` UV islands: ${info.uvCharts < 0 ? 'n/a' : info.uvCharts} seam vertices: ${Math.round(info.seamRatio * 100)}%`) say(` paint-safe: ${info.ok ? 'YES' : 'NO'}`) for (const p of info.problems) say(` ! ${p}`) } diff --git a/src/ghost/player.ts b/src/ghost/player.ts index 2b7d4e7..47144a6 100644 --- a/src/ghost/player.ts +++ b/src/ghost/player.ts @@ -15,6 +15,7 @@ import * as THREE from 'three' import type { World } from '../contracts' import { loadGhost, sampleAt, type GhostSample, type GhostTrack } from './format' import { assets } from '../assets/registry' +import { fitBodyToRadius } from '../assets/blobBody' const GHOST_OPACITY = 0.35 const BOB_AMPLITUDE = 0.06 // metres @@ -87,10 +88,21 @@ export class GhostPlayer { // instance by the registry, so forcing them translucent here cannot make // the real blob see-through. const reg = assets() - const slot = reg.has('ghost.body') ? 'ghost.body' : 'blob.body' + const borrowed = !reg.has('ghost.body') + const slot = borrowed ? 'blob.body' : 'ghost.body' reg.attachSlot(slot, g, { onSwap: (asset) => { body.visible = false + // A borrowed blob.body has NOT been through the blob's fit: the real + // blob normalises its geometry to the collider radius, so without this + // the ghost renders at the raw GLB's size (2.17x with blobbo-base.glb) + // and off-centre. A dedicated ghost.body keeps its manifest transform. + if (borrowed) { + const fitted = fitBodyToRadius(asset, this.radius, reg.entry('blob.body')) + // fitBodyToRadius clones the geometry (the source is shared with the + // real blob), so this one is ours to dispose. + if (fitted) this.geos.push(fitted.geometry) + } asset.traverse((o) => { const m = (o as THREE.Mesh).material as THREE.MeshStandardMaterial | undefined if (!m) return diff --git a/src/paint/cannon.ts b/src/paint/cannon.ts index aab3971..468246e 100644 --- a/src/paint/cannon.ts +++ b/src/paint/cannon.ts @@ -240,11 +240,16 @@ export class PaintCannon implements System { pivot.add(tube) pivot.name = 'pivot' g.add(pivot) + // Two slots, because they are two different jobs: the base is a static + // stand, the barrel is the thing that aims. Filling only one leaves the + // other primitive in place. `cannon.barrel` is attached to the whole group + // rather than the pivot so a barrel model does not inherit the aim spin — + // the procedural tube stays hidden either way. + assets().attachSlot('cannon.base', g, { + onSwap: () => { base.visible = false }, + }) assets().attachSlot('cannon.barrel', g, { - onSwap: () => { - base.visible = false - tube.visible = false - }, + onSwap: () => { tube.visible = false }, }) return g }