BLOBBO/src/assets/slots.ts
Claude e34773d35f 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.
2026-07-19 12:12:07 +10:00

98 lines
3.8 KiB
TypeScript

/**
* Lane I — the slot inventory.
*
* A "slot" is a named place in the scene where a custom GLB may replace the
* procedural mesh the game builds today. Slot ids are a closed set on purpose:
* the editor (lane J) lists them, the manifest keys on them, and an unknown key
* in a hand-edited manifest should be reported rather than silently ignored.
*
* Kept free of `three` imports so the validator can be node-tested headlessly.
*/
export const SLOT_IDS = [
'blob.body',
'blob.face',
'ghost.body',
'cannon.base',
'cannon.barrel',
'machine.plate',
'machine.boot',
'machine.bucket',
'machine.arch',
'machine.belt',
'machine.fan',
'machine.seesaw',
'course.scenery.cereal',
'course.scenery.block',
'course.finish',
'course.tunnel',
'course.tramp',
'fx.puddle',
] as const
export type SlotId = (typeof SLOT_IDS)[number]
const SLOT_SET: ReadonlySet<string> = new Set<string>(SLOT_IDS)
export function isSlotId(v: unknown): v is SlotId {
return typeof v === 'string' && SLOT_SET.has(v)
}
/** Plain-language labels for the editor UI (lane J reads this). */
export const SLOT_LABELS: Record<SlotId, string> = {
'blob.body': 'Blobbo body (paintable)',
'blob.face': 'Blobbo eyes',
'ghost.body': 'Ghost racer',
'cannon.base': 'Paint cannon base',
'cannon.barrel': 'Paint cannon barrel',
'machine.plate': 'Pressure plate',
'machine.boot': 'Spring boot',
'machine.bucket': 'Paint bucket',
'machine.arch': 'Bubble arch',
'machine.belt': 'Conveyor belt',
'machine.fan': 'Fan',
'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',
}
/**
* Slots whose replacement is constrained beyond "looks different". Surfaced in
* the editor so John sees the rule at the moment he drops a file on the slot.
*/
export const SLOT_NOTES: Partial<Record<SlotId, string>> = {
'blob.body':
'Must be ONE mesh with ONE material and clean 0..1 UVs — the paint is stamped ' +
'through those UVs. Auto-resized to the 1.0u body; manifest scale is ignored here.',
'ghost.body':
'Copied from blob.body automatically unless you set this slot; materials are ' +
'forced translucent.',
'machine.boot': 'Model it standing on the floor, origin at the base — it shakes about its origin.',
'machine.bucket': 'Origin at the TIPPING EDGE, pours toward +X, up axis +Y.',
'machine.belt': 'Model the belt slab only; the moving chevrons stay procedural.',
'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',
])