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",
"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.

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
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).

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.
*
* 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<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
* `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)
}

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>)) {
// `_`-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

View File

@ -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<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:
* 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<void> {
async preload(timeoutMs: number = PRELOAD_TIMEOUT_MS): Promise<void> {
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 {
@ -265,14 +396,22 @@ export class AssetRegistry {
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
* 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) return
if (!mesh.isMesh || !mesh.material) return
mesh.material = Array.isArray(mesh.material)
? mesh.material.map((m) => m.clone())
: mesh.material.clone()
@ -280,30 +419,76 @@ export class AssetRegistry {
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 {
try {
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 }
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))
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 */ },
)

View File

@ -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<SlotId, string> = {
'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<SlotId, string> = {
'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<Record<SlotId, string>> = {
'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<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) ----
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

View File

@ -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({

View File

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

View File

@ -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

View File

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