BLOBBO/docs/ASSET-SLOTS.md
Claude 324d7d4568 Lane I: asset runtime — manifest, registry, slot hooks, IndexedDB overrides
Custom GLBs drop into 14 named slots without code changes; an empty manifest
produces today's game by construction (the fallback builders are the original
code moved into a closure, and an empty registry returns the caller's own
object by identity).

- src/assets/{slots,manifest,idb,registry,blobBody}.ts — schema + validation,
  GLTF cache with per-instance material cloning, fit nodes, IndexedDB override
  layer, paintability report.
- Slot hooks in createBlob, parts, cannon, greybox, puddles, ghost. Mesh-only:
  no collider, physics or logic line is touched. Animated sub-parts (plate cap,
  belt chevrons, fan blades, cannon pivot) stay procedural so a custom model
  cannot stop them moving.
- public/assets/ — the build had NO asset copy step at all, so every asset URL
  would have 404'd in production. public/ is vite's default publicDir, so this
  needs no vite.config change.
- Tests: 63 headless checks + a farm-GLB audit that fires PaintSkin's own
  raycast against the fitted body.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-19 11:37:08 +10:00

128 lines
5.3 KiB
Markdown

# Custom assets — how the slots work
Drop a `.glb` into a named slot and the game uses it. No code changes.
With no manifest at all, the game builds exactly what it builds today.
## The one URL rule
```ts
import.meta.env.BASE_URL + 'assets/manifest.json'
```
Never `/assets/...` (that drops the `/blobbo/` deploy prefix) and never a bare
`assets/...` (that resolves against the *page* directory, which breaks on the
demo pages one level down). `assetUrl()` in `src/assets/manifest.ts` does this
for you.
## Where files live
| What | Path | Ships? |
|---|---|---|
| The manifest | `public/assets/manifest.json` | yes → `dist/assets/manifest.json` |
| A shipped asset pack | `public/assets/live/*.glb` | yes → `dist/assets/live/*.glb` |
| Raw farm output | `assets/meshes/*.glb` | **no** — outside the build |
`public/` is vite's default publicDir, so everything under it is copied into
`dist/` verbatim with the base path already applied. Before this lane, nothing
copied `assets/` into `dist/` at all — every asset URL would have 404'd live.
The four farm GLBs are ~43 MB of raw output with embedded textures. Stage them
for local work with `./scripts/stage-farm-assets.sh` (gitignored). Compress them
offline before any of them goes into `public/assets/live/``deploy.sh` rsyncs
and `docker cp`s the whole of `dist/` on every deploy.
## Manifest format
```json
{
"machine.boot": {
"url": "assets/live/boot.glb",
"offset": [0, 0.2, 0],
"rotationDeg": [0, 90, 0],
"scale": 1.5,
"idleClip": "idle"
}
}
```
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
fails to load leaves the built-in version in place and logs one warning.
Load order, later wins: `public/assets/manifest.json`, then the editor's
IndexedDB override (`blobbo-workshop`). An override url may be `idb:<key>`,
which resolves to a blob stored in the same database — that is what makes a
custom asset testable on the live site with zero deploys.
## Slots
| Slot id | Replaces | Stays procedural |
|---|---|---|
| `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 |
| `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) |
| `machine.arch` | posts, bar, bubbles | — |
| `machine.belt` | the belt slab | the scrolling chevrons |
| `machine.fan` | the housing | the spinning blades |
| `machine.seesaw` | the plank | the fulcrum |
| `course.scenery.cereal` | the giant cereal box | — |
| `course.scenery.block` | the purple block | — |
| `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.
## Modelling conventions
Y-up, metres, one material, embedded textures ≤ 2048².
- **`blob.body`** is the only slot that is not purely cosmetic. It must be a
single mesh with a single material and one UV island inside 0..1 — the paint
is stamped through those UVs. It is automatically re-centred and re-scaled to
the 1.0u body, because `boundingRadius` is a gameplay number (the puddles
compute belly contact from it). `scale` is ignored for this slot; `offset` and
`rotationDeg` are baked into the geometry before the refit. If the mesh fails
the paint checks the built-in blob is kept and the reason is logged — paint
never breaks silently.
- **`machine.bucket`** — origin at the tipping edge, pours toward +X.
- **`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.
- **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
already: the machine danger-flash only drives standard materials, so a Basic
or Phong export would silently kill the telegraph.
## Wiring (integration)
`src/main.ts` needs one line before `installGame(world)`:
```ts
await initAssets()
```
Without it the registry stays empty and every slot falls back — which is safe,
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.
## 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 scripts/farm-assets.check.ts
```
The last one 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.