harden asset runtime: guard the swap path, bound the boot, reconcile slots

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.
This commit is contained in:
Claude 2026-07-19 12:12:07 +10:00
parent f827f6f861
commit e34773d35f
13 changed files with 836 additions and 117 deletions

View File

@ -39,12 +39,15 @@ and `docker cp`s the whole of `dist/` on every deploy.
"url": "assets/live/boot.glb", "url": "assets/live/boot.glb",
"offset": [0, 0.2, 0], "offset": [0, 0.2, 0],
"rotationDeg": [0, 90, 0], "rotationDeg": [0, 90, 0],
"scale": 1.5, "scale": 1.5
"idleClip": "idle"
} }
} }
``` ```
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 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 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 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.body` | the paintable body | — |
| `blob.face` | the googly eyes | — | | `blob.face` | the googly eyes | — |
| `ghost.body` | the ghost racer (defaults to `blob.body`) | — | | `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.plate` | the plate frame | the pressed pad (it lights up) |
| `machine.boot` | pad + coils | — | | `machine.boot` | pad + coils | — |
| `machine.bucket` | the shell | the paint fill (it is re-tinted) | | `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 | | `machine.seesaw` | the plank | the fulcrum |
| `course.scenery.cereal` | the giant cereal box | — | | `course.scenery.cereal` | the giant cereal box | — |
| `course.scenery.block` | the purple block | — | | `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 | — | | `fx.puddle` | the puddle slab | — |
Sub-parts that move every frame stay procedural on purpose: a custom model can't 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, accidentally stop the belt scrolling, the fan spinning or the plate lighting up,
and those motions are what make each machine readable. 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 ## Modelling conventions
Y-up, metres, one material, embedded textures ≤ 2048². 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.boot`** — origin at the base; it shakes about its own origin.
- **`machine.fan`** — faces +Z. - **`machine.fan`** — faces +Z.
- **`machine.seesaw`** — plank only, long axis along X, origin at the centre. - **`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 - **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. 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 - 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 `blob.mesh` by reference the moment `createBlob` returns, so the body asset has
to be in the cache before the game is built. 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 ## Checking your work
``` ```
npm run build 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/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/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 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 fires PaintSkin's own outside-in raycast against the fitted blob body, so you can
confirm a splat would land without opening a browser. confirm a splat would land without opening a browser.

View File

@ -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 - The delivered mesh's material `map` is replaced by PaintSkin's canvas
texture at integration (skin binds to whatever mesh is present); ensure texture at integration (skin binds to whatever mesh is present); ensure
the hook exposes the swapped-in paintable mesh as `blob.mesh` (the skin 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 and buffs read it).
AnimationMixer in a fixed-step System (NOT onFrame — hidden-tab rule, see
telegraph.ts docstring for why). **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 - UV warning: if `paintableInfo.uvOk` is false, console.warn and keep the
procedural sphere instead (paint must never silently break). procedural sphere instead (paint must never silently break).
4. `assets/manifest.json` — ship EMPTY `{}` (packs come later via integration). 4. `assets/manifest.json` — ship EMPTY `{}` (packs come later via integration).

View File

@ -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)}`)

View File

@ -1,7 +1,7 @@
/** /**
* Lane I the `blob.body` slot, which is the only slot that is not cosmetic. * 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 * `boundingRadius` from its geometry and that radius is read back as a
* GAMEPLAY number (puddles.ts computes the belly contact point from it). The * GAMEPLAY number (puddles.ts computes the belly contact point from it). The
* frozen game.ts also captures `blob.mesh` by reference the instant createBlob * 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. * fails those tests keeps the procedural sphere and says why, out loud.
*/ */
import * as THREE from 'three' import * as THREE from 'three'
import type { System, World } from '../contracts' import type { World } from '../contracts'
import { assets, normalizeToRadius, paintableInfo } from './registry' 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<SlotEntry, 'offset' | 'rotationDeg'>,
): 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 * 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. * `blob.body` is filled AND paint-safe, otherwise the caller's own sphere.
*/ */
export function resolveBlobBody( 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, world: World,
radius: number, radius: number,
buildFallback: () => THREE.Mesh, buildFallback: () => THREE.Mesh,
): THREE.Mesh { ): THREE.Mesh {
void world
const reg = assets() const reg = assets()
const inst = reg.instanceSync('blob.body') const inst = reg.instanceSync('blob.body')
if (!inst) { if (!inst) {
@ -47,34 +105,22 @@ export function resolveBlobBody(
console.warn('[assets] blob.body loaded with warnings:\n - ' + info.problems.join('\n - ')) 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 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) { if (entry.scale !== undefined) {
console.warn('[assets] blob.body "scale" is ignored — the body is always fitted to the blob size.') console.warn('[assets] blob.body "scale" is ignored — the body is always fitted to the blob size.')
} }
normalizeToRadius(geo, radius) if (entry.idleClip !== undefined) {
mesh.geometry = geo 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 // feel.ts writes `emissiveIntensity` for the super-glow and PaintSkin forces
// the colour white; an imported material usually has neither set up. // the colour white; an imported material usually has neither set up.
@ -84,41 +130,5 @@ export function resolveBlobBody(
} }
mat.emissiveIntensity = 0 mat.emissiveIntensity = 0
mesh.castShadow = true 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 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)
}

302
src/assets/harden.test.ts Normal file
View File

@ -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<string, Promise<unknown>>
resolved: Map<string, unknown>
}
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<void> => 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<string, Promise<unknown>> }
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<string, Promise<unknown>> }
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`)

View File

@ -63,6 +63,11 @@ export function validateManifest(raw: unknown): ValidationResult {
} }
for (const [key, value] of Object.entries(raw as Record<string, unknown>)) { for (const [key, value] of Object.entries(raw as Record<string, unknown>)) {
// `_`-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)) { if (!isSlotId(key)) {
problems.push({ slot: key, reason: 'unknown slot id' }) problems.push({ slot: key, reason: 'unknown slot id' })
continue continue

View File

@ -27,13 +27,21 @@ import { loadOverrideManifest, isIdbUrl, idbKeyFromUrl, getBlob } from './idb'
import type { SlotId } from './slots' import type { SlotId } from './slots'
export interface PaintableInfo { 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 ok: boolean
uvOk: boolean uvOk: boolean
triCount: number triCount: number
materialCount: number materialCount: number
meshCount: number meshCount: number
skinned: boolean 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). */ /** Plain-language reasons the asset is not paint-safe (editor surface). */
problems: string[] 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. */ /** How far outside 0..1 a UV may stray before the layout is judged unpaintable. */
const UV_MARGIN = 0.1 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 { function degToRadTriple(d: [number, number, number]): THREE.Euler {
const k = Math.PI / 180 const k = Math.PI / 180
return new THREE.Euler(d[0] * k, d[1] * k, d[2] * k) 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 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<number>()
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<string, number[]>()
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<string>()
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: * 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 * one unrigged mesh, one material, a `uv` attribute inside 0..1 laid out as a
* assumes an equirectangular layout, so out-of-range or missing UVs mean splats * small number of islands (the skin's stamp wrap assumes a contiguous unwrap, so
* land somewhere unrelated or nowhere). * out-of-range, missing or shattered UVs mean splats land somewhere unrelated
* or nowhere).
*/ */
export function paintableInfo(object: THREE.Object3D): PaintableInfo { export function paintableInfo(object: THREE.Object3D): PaintableInfo {
const meshes: THREE.Mesh[] = [] const meshes = collectMeshes(object)
object.traverse((o) => {
if ((o as THREE.Mesh).isMesh) meshes.push(o as THREE.Mesh)
})
const problems: string[] = [] const problems: string[] = []
if (meshes.length === 0) { if (meshes.length === 0) {
return { return {
ok: false, uvOk: false, triCount: 0, materialCount: 0, meshCount: 0, 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`) 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 triCount = index ? index.count / 3 : (pos ? pos.count / 3 : 0)
const uv = geo.getAttribute('uv') const uv = geo.getAttribute('uv')
const layout = uv ? uvLayoutInfo(geo) : { charts: -1, seamRatio: 0 }
let uvOk = false let uvOk = false
if (!uv) { if (!uv) {
problems.push('no UV map — the paint has nowhere to go') 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)`) 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 { 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, 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 * Resolve every url the manifest mentions. Integration must await this before
* building the game: `blob.mesh`'s identity is captured by reference in the * 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. * 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<void> { async preload(timeoutMs: number = PRELOAD_TIMEOUT_MS): Promise<void> {
const urls = new Set(Object.values(this.manifest).map((e) => e.url)) 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<unknown> {
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 { private warnOnce(key: string, ...args: unknown[]): void {
@ -265,45 +396,99 @@ export class AssetRegistry {
return p return p
} }
/** A cloned, shadow-enabled, standard-material instance of a loaded url. */ /**
private instantiate(asset: LoadedAsset): THREE.Group { * A cloned, shadow-enabled, standard-material instance of a loaded url, or
const copy = skeletonClone(asset.scene) as THREE.Group * null if anything about the file makes three throw.
// SkeletonUtils.clone shares materials with the source. Instances get tinted *
// per-slot (puddle colour, ghost translucency), so each needs its own. * Never throws, on purpose: `instanceSync` is called from `createBlob`, which
copy.traverse((o) => { * the frozen game.ts calls with no try/catch of its own. A throw there is a
const mesh = o as THREE.Mesh * black screen rather than a missing prop.
if (!mesh.isMesh) return */
mesh.material = Array.isArray(mesh.material) private instantiate(asset: LoadedAsset, url: string): THREE.Group | null {
? mesh.material.map((m) => m.clone()) try {
: mesh.material.clone() const copy = skeletonClone(asset.scene) as THREE.Group
}) // SkeletonUtils.clone shares materials with the source. Instances get tinted
ensureStandardMaterials(copy) // per-slot (puddle colour, ghost translucency), so each needs its own.
enableShadows(copy) copy.traverse((o) => {
return copy 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 { instanceSync(slot: SlotId): { object: THREE.Group; entry: SlotEntry; animations: THREE.AnimationClip[] } | null {
const entry = this.manifest[slot] try {
if (!entry) return null const entry = this.manifest[slot]
const asset = this.resolved.get(entry.url) if (!entry) return null
if (!asset) return null const asset = this.resolved.get(entry.url)
return { object: this.instantiate(asset), entry, animations: asset.animations } 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 * 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. * 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 { attachSlot(slot: SlotId, host: THREE.Object3D, opts: AttachOptions = {}): void {
const entry = this.manifest[slot] const entry = this.manifest[slot]
if (!entry) return if (!entry) return
void this.load(entry.url).then( void this.load(entry.url).then(
(asset) => { (asset) => {
const fit = makeFitNode(entry, opts.fit) let fit: THREE.Group | null = null
fit.add(this.instantiate(asset)) try {
host.add(fit) const inst = this.instantiate(asset, entry.url)
opts.onSwap?.(fit) 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 */ }, () => { /* warned in load(); fallback stays */ },
) )

View File

@ -13,6 +13,7 @@ export const SLOT_IDS = [
'blob.body', 'blob.body',
'blob.face', 'blob.face',
'ghost.body', 'ghost.body',
'cannon.base',
'cannon.barrel', 'cannon.barrel',
'machine.plate', 'machine.plate',
'machine.boot', 'machine.boot',
@ -23,6 +24,9 @@ export const SLOT_IDS = [
'machine.seesaw', 'machine.seesaw',
'course.scenery.cereal', 'course.scenery.cereal',
'course.scenery.block', 'course.scenery.block',
'course.finish',
'course.tunnel',
'course.tramp',
'fx.puddle', 'fx.puddle',
] as const ] as const
@ -39,7 +43,8 @@ export const SLOT_LABELS: Record<SlotId, string> = {
'blob.body': 'Blobbo body (paintable)', 'blob.body': 'Blobbo body (paintable)',
'blob.face': 'Blobbo eyes', 'blob.face': 'Blobbo eyes',
'ghost.body': 'Ghost racer', 'ghost.body': 'Ghost racer',
'cannon.barrel': 'Paint cannon', 'cannon.base': 'Paint cannon base',
'cannon.barrel': 'Paint cannon barrel',
'machine.plate': 'Pressure plate', 'machine.plate': 'Pressure plate',
'machine.boot': 'Spring boot', 'machine.boot': 'Spring boot',
'machine.bucket': 'Paint bucket', 'machine.bucket': 'Paint bucket',
@ -49,6 +54,9 @@ export const SLOT_LABELS: Record<SlotId, string> = {
'machine.seesaw': 'See-saw plank', 'machine.seesaw': 'See-saw plank',
'course.scenery.cereal': 'Giant cereal box', 'course.scenery.cereal': 'Giant cereal box',
'course.scenery.block': 'Purple block', 'course.scenery.block': 'Purple block',
'course.finish': 'Finish pad',
'course.tunnel': 'MINI tunnel roof',
'course.tramp': 'Trampoline / gap launcher',
'fx.puddle': 'Paint puddle', 'fx.puddle': 'Paint puddle',
} }
@ -69,4 +77,21 @@ export const SLOT_NOTES: Partial<Record<SlotId, string>> = {
'machine.fan': 'Face +Z. The blades stay procedural so they keep spinning.', '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.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.', '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<string> = new Set<string>([
'blob.body', 'blob.face', 'ghost.body',
'course.finish', 'course.tunnel', 'course.scenery.cereal', 'course.scenery.block',
])

View File

@ -114,7 +114,7 @@ export function buildGreybox(world: World): GreyboxHandle {
}) })
// ---- finish pad (bright pink, raised) ---- // ---- 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 ---- // ---- absurd-proportion scenery: a giant cereal box beside the start ----
// Slot hooks go on the MESH only, after the collider exists: the box volume // Slot hooks go on the MESH only, after the collider exists: the box volume

View File

@ -35,6 +35,7 @@ import { PALETTE } from '../contracts'
import type { PaintSkin } from '../paint/skin' import type { PaintSkin } from '../paint/skin'
import { installPuddles, type PaintPuddle, type PaintPuddleConfig } from '../paint/puddles' import { installPuddles, type PaintPuddle, type PaintPuddleConfig } from '../paint/puddles'
import { createPressurePlate, createSpringBoot, type Vec3 } from '../machine' 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). */ /** A cannon integration should stage for a zone (fires that zone's colour). */
export interface ZoneCannonConfig { export interface ZoneCannonConfig {
@ -204,6 +205,13 @@ function buildTunnel(
rapier.ColliderDesc.cuboid(hx, hy, hz).setTranslation(cx, cy, cz), 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 // "MIND YOUR HEAD" clearance bar hanging just under the lip so the low ceiling
// reads before you reach it (visual only). // reads before you reach it (visual only).
const barMat = new THREE.MeshStandardMaterial({ const barMat = new THREE.MeshStandardMaterial({

View File

@ -105,7 +105,8 @@ if (!info) {
say(' no asset resolved; the built-in sphere is in use.') say(' no asset resolved; the built-in sphere is in use.')
} else { } else {
say(` meshes ${info.meshCount} materials ${info.materialCount} triangles ${info.triCount}`) 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'}`) say(` paint-safe: ${info.ok ? 'YES' : 'NO'}`)
for (const p of info.problems) say(` ! ${p}`) for (const p of info.problems) say(` ! ${p}`)
} }

View File

@ -15,6 +15,7 @@ import * as THREE from 'three'
import type { World } from '../contracts' import type { World } from '../contracts'
import { loadGhost, sampleAt, type GhostSample, type GhostTrack } from './format' import { loadGhost, sampleAt, type GhostSample, type GhostTrack } from './format'
import { assets } from '../assets/registry' import { assets } from '../assets/registry'
import { fitBodyToRadius } from '../assets/blobBody'
const GHOST_OPACITY = 0.35 const GHOST_OPACITY = 0.35
const BOB_AMPLITUDE = 0.06 // metres const BOB_AMPLITUDE = 0.06 // metres
@ -87,10 +88,21 @@ export class GhostPlayer {
// instance by the registry, so forcing them translucent here cannot make // instance by the registry, so forcing them translucent here cannot make
// the real blob see-through. // the real blob see-through.
const reg = assets() 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, { reg.attachSlot(slot, g, {
onSwap: (asset) => { onSwap: (asset) => {
body.visible = false 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) => { asset.traverse((o) => {
const m = (o as THREE.Mesh).material as THREE.MeshStandardMaterial | undefined const m = (o as THREE.Mesh).material as THREE.MeshStandardMaterial | undefined
if (!m) return if (!m) return

View File

@ -240,11 +240,16 @@ export class PaintCannon implements System {
pivot.add(tube) pivot.add(tube)
pivot.name = 'pivot' pivot.name = 'pivot'
g.add(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, { assets().attachSlot('cannon.barrel', g, {
onSwap: () => { onSwap: () => { tube.visible = false },
base.visible = false
tube.visible = false
},
}) })
return g return g
} }