Merge lane-i: asset runtime (manifest, registry, slot hooks, procedural fallbacks)

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
type-two 2026-07-19 11:54:11 +10:00
commit f827f6f861
21 changed files with 1616 additions and 13 deletions

3
.gitignore vendored
View File

@ -3,3 +3,6 @@ __pycache__/
.DS_Store
node_modules/
dist/
# Lane I: farm GLBs staged for local serving (large, not committed)
public/assets/meshes/

22
demos/lane-i.html Normal file
View File

@ -0,0 +1,22 @@
<!doctype html>
<html lang="en">
<head><meta charset="utf-8" /><title>BLOBBO lane i demo — asset slots</title>
<style>
html,body{margin:0;height:100%;overflow:hidden;background:#bfe3ff;font-family:system-ui,sans-serif}
#app{width:100%;height:100%}
#legend{position:fixed;bottom:10px;right:10px;padding:10px 12px;border-radius:8px;
background:rgba(20,24,40,.72);color:#eaf2ff;font-size:13px;line-height:1.5;pointer-events:none;max-width:320px}
#legend b{color:#FFD60A}
</style></head>
<body>
<div id="app"></div>
<div id="legend">
<b>BLOBBO — Lane I: asset slots</b><br>
Left column: empty manifest (today's game).<br>
Right column: the same code with a test manifest pointing at the farm GLBs.<br>
The panel top-left reports every slot's load status, the paintability check
for the blob body, and a scripted splat's coverage before/after.
</div>
<script type="module" src="/src/demo/lane-i.ts"></script>
</body>
</html>

127
docs/ASSET-SLOTS.md Normal file
View File

@ -0,0 +1,127 @@
# 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.

View File

@ -0,0 +1 @@
{}

View File

@ -0,0 +1,95 @@
/**
* Headless audit of the four farm GLBs in assets/meshes/. Run:
* node --import ./scripts/ts-resolve.mjs --experimental-strip-types scripts/farm-assets.check.ts
*
* This is the closest thing to browser verification the asset runtime has: it
* parses each real GLB, runs the same paintability check the game runs, and
* for the blob body normalises the geometry and fires the same
* outside-in raycast PaintSkin uses to turn a world point into a UV. If that
* raycast returns a UV, a splat lands; if it returns null, splats silently do
* nothing, which is the failure mode this file exists to catch.
*
* `THREE.GLTFLoader: Couldn't load texture` warnings are expected here node
* has no ImageBitmap. Geometry, UVs and materials all still parse.
*/
import { readFileSync } from 'node:fs'
import { fileURLToPath } from 'node:url'
import * as THREE from 'three'
import { GLTFLoader, type GLTF } from 'three/examples/jsm/loaders/GLTFLoader.js'
import { paintableInfo, normalizeToRadius } from '../src/assets/registry'
// GLTFLoader reaches for `self` when wiring its texture loaders.
;(globalThis as unknown as { self: unknown }).self = globalThis
const MESH_DIR = fileURLToPath(new URL('../assets/meshes/', import.meta.url))
const FILES = ['blobbo-base', 'prop-spring-boot', 'prop-paint-bucket', 'prop-toaster-launcher']
const loader = new GLTFLoader()
let failures = 0
function parse(file: string): Promise<GLTF | null> {
const buf = readFileSync(MESH_DIR + file + '.glb')
const ab = buf.buffer.slice(buf.byteOffset, buf.byteOffset + buf.byteLength) as ArrayBuffer
return new Promise((res) => loader.parse(ab, '', res, () => res(null)))
}
for (const file of FILES) {
const gltf = await parse(file)
if (!gltf) {
console.log(`${file}: PARSE FAILED`)
failures++
continue
}
const info = paintableInfo(gltf.scene)
console.log(
`${file.padEnd(22)} meshes=${info.meshCount} materials=${info.materialCount} ` +
`tris=${info.triCount} uvOk=${info.uvOk} skinned=${info.skinned} paintSafe=${info.ok}` +
(gltf.animations.length ? ` clips=${gltf.animations.map((a) => a.name).join(',')}` : ''),
)
for (const p of info.problems) console.log(` ! ${p}`)
}
// ---- the blob body, end to end ---------------------------------------------
const gltf = await parse('blobbo-base')
if (!gltf) {
failures++
} else {
let mesh: THREE.Mesh | null = null
gltf.scene.traverse((o) => { if (!mesh && (o as THREE.Mesh).isMesh) mesh = o as THREE.Mesh })
const body = mesh as unknown as THREE.Mesh
const geo = body.geometry.clone()
normalizeToRadius(geo, 0.5)
const fitted = new THREE.Mesh(geo, new THREE.MeshStandardMaterial())
fitted.updateWorldMatrix(true, false)
const r = geo.boundingSphere!.radius
const c = geo.boundingSphere!.center
console.log(`\nblob.body fitted: boundingRadius=${r.toFixed(6)} centre=(${c.x.toFixed(6)}, ${c.y.toFixed(6)}, ${c.z.toFixed(6)})`)
if (Math.abs(r - 0.5) > 1e-5) { console.log(' ! radius is not 0.5 — puddle belly contact would drift'); failures++ }
if (c.length() > 1e-5) { console.log(' ! not centred on the origin — PaintSkin raycasts would miss'); failures++ }
// PaintSkin.uvAtWorldPoint, reproduced: cast from outside, inward through the
// centre, and take the UV of the first hit.
const raycaster = new THREE.Raycaster()
let hits = 0
const dirs = [
new THREE.Vector3(1, 0, 0), new THREE.Vector3(-1, 0, 0),
new THREE.Vector3(0, 1, 0), new THREE.Vector3(0, -1, 0),
new THREE.Vector3(0, 0, 1), new THREE.Vector3(0, 0, -1),
new THREE.Vector3(1, 1, 1).normalize(),
]
for (const dir of dirs) {
const origin = dir.clone().multiplyScalar(r * 2.2)
raycaster.set(origin, dir.clone().negate())
raycaster.near = 0
raycaster.far = r * 4.4
const hit = raycaster.intersectObject(fitted, false).find((h) => h.uv)
if (hit?.uv) hits++
else console.log(` ! no UV hit casting along ${dir.toArray().map((n) => n.toFixed(2)).join(',')} — a splat there would vanish`)
}
console.log(`splat raycast: ${hits}/${dirs.length} directions returned a UV`)
if (hits !== dirs.length) failures++
}
console.log(failures === 0 ? '\nfarm-assets.check: OK' : `\nfarm-assets.check: ${failures} FAILURE(S)`)
if (failures > 0) process.exitCode = 1

13
scripts/stage-farm-assets.sh Executable file
View File

@ -0,0 +1,13 @@
#!/usr/bin/env bash
# Copy the four farm GLBs into the served asset directory so the lane-i demo
# (and any manifest that names them) can actually fetch them.
#
# They are NOT committed there: the four total ~43 MB of raw farm output with
# embedded textures, and deploy.sh rsyncs + docker-cps the whole dist/ on every
# deploy. Compress them offline before shipping a pack to production.
set -euo pipefail
root="$(cd "$(dirname "$0")/.." && pwd)"
mkdir -p "$root/public/assets/meshes"
cp "$root"/assets/meshes/*.glb "$root/public/assets/meshes/"
du -sh "$root/public/assets/meshes"
echo "staged -> public/assets/meshes/ (gitignored; served at \${BASE_URL}assets/meshes/)"

25
scripts/ts-resolve.mjs Normal file
View File

@ -0,0 +1,25 @@
/**
* Node resolve hook so the repo's extensionless TS imports (`./manifest`) work
* under `node --experimental-strip-types`, which otherwise demands a real file
* extension. Vite resolves these at build time; node does not.
*
* node --import ./scripts/ts-resolve.mjs --experimental-strip-types src/assets/manifest.test.ts
*/
import { registerHooks } from 'node:module'
import { existsSync } from 'node:fs'
import { fileURLToPath } from 'node:url'
registerHooks({
resolve(specifier, context, next) {
try {
return next(specifier, context)
} catch (err) {
if (!specifier.startsWith('.') || !context.parentURL) throw err
for (const ext of ['.ts', '/index.ts']) {
const url = new URL(specifier + ext, context.parentURL)
if (existsSync(fileURLToPath(url))) return next(specifier + ext, context)
}
throw err
}
},
})

124
src/assets/blobBody.ts Normal file
View File

@ -0,0 +1,124 @@
/**
* 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
* `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
* returns. So this slot is resolved SYNCHRONOUSLY from the preloaded cache, is
* always a single leaf Mesh with a single MeshStandardMaterial, and its
* geometry is re-centred and re-scaled to the procedural radius. Anything that
* 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'
/**
* 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: World,
radius: number,
buildFallback: () => THREE.Mesh,
): THREE.Mesh {
const reg = assets()
const inst = reg.instanceSync('blob.body')
if (!inst) {
if (reg.has('blob.body')) {
console.warn(
'[assets] blob.body was not preloaded in time — keeping the built-in blob. ' +
'Call `await initAssets()` before installGame().',
)
}
return buildFallback()
}
const info = paintableInfo(inst.object)
if (!info.ok) {
console.warn(
'[assets] blob.body cannot be painted, so the built-in blob is being used instead:\n - ' +
info.problems.join('\n - '),
)
return buildFallback()
}
if (info.problems.length) {
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
// feel.ts writes `emissiveIntensity` for the super-glow and PaintSkin forces
// the colour white; an imported material usually has neither set up.
const mat = mesh.material as THREE.MeshStandardMaterial
if (!mat.emissive || mat.emissive.getHex() === 0x000000) {
mat.emissive = new THREE.Color('#88e0ff')
}
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)
}

123
src/assets/idb.ts Normal file
View File

@ -0,0 +1,123 @@
/**
* Lane I local override store (`blobbo-workshop`).
*
* Why IndexedDB and not a server: John must be able to try a custom asset on
* the LIVE site with zero deploys. The editor writes a manifest + the raw .glb
* bytes here; the game reads them at boot and they win over the shipped pack.
*
* Two stores:
* `manifest` single record under key 'current', a Manifest object.
* `blobs` key -> ArrayBuffer of a .glb, addressed as `idb:<key>` urls.
*
* Every function resolves rather than rejects when IndexedDB is unavailable
* (private mode, old browser, node): no override is a normal state, not an error.
*/
import { validateManifest, type Manifest } from './manifest'
export const DB_NAME = 'blobbo-workshop'
export const DB_VERSION = 1
export const STORE_MANIFEST = 'manifest'
export const STORE_BLOBS = 'blobs'
const MANIFEST_KEY = 'current'
export const IDB_URL_PREFIX = 'idb:'
export function isIdbUrl(url: string): boolean {
return url.startsWith(IDB_URL_PREFIX)
}
export function idbKeyFromUrl(url: string): string {
return url.slice(IDB_URL_PREFIX.length)
}
function openDb(): Promise<IDBDatabase | null> {
return new Promise((resolve) => {
if (typeof indexedDB === 'undefined') return resolve(null)
let req: IDBOpenDBRequest
try {
req = indexedDB.open(DB_NAME, DB_VERSION)
} catch {
return resolve(null)
}
req.onupgradeneeded = () => {
const db = req.result
if (!db.objectStoreNames.contains(STORE_MANIFEST)) db.createObjectStore(STORE_MANIFEST)
if (!db.objectStoreNames.contains(STORE_BLOBS)) db.createObjectStore(STORE_BLOBS)
}
req.onsuccess = () => resolve(req.result)
req.onerror = () => resolve(null)
req.onblocked = () => resolve(null)
})
}
function request<T>(store: IDBObjectStore, run: (s: IDBObjectStore) => IDBRequest): Promise<T | null> {
return new Promise((resolve) => {
let req: IDBRequest
try {
req = run(store)
} catch {
return resolve(null)
}
req.onsuccess = () => resolve(req.result as T)
req.onerror = () => resolve(null)
})
}
async function withStore<T>(
name: string,
mode: IDBTransactionMode,
run: (s: IDBObjectStore) => IDBRequest,
): Promise<T | null> {
const db = await openDb()
if (!db) return null
try {
const tx = db.transaction(name, mode)
const out = await request<T>(tx.objectStore(name), run)
return out
} catch {
return null
} finally {
db.close()
}
}
/** The editor's saved pack, or `{}` when there is none. Never throws. */
export async function loadOverrideManifest(): Promise<Manifest> {
const raw = await withStore<unknown>(STORE_MANIFEST, 'readonly', (s) => s.get(MANIFEST_KEY))
if (raw === null || raw === undefined) return {}
const { manifest, problems } = validateManifest(raw)
if (problems.length) console.warn('[assets] local override manifest problems:', problems)
return manifest
}
export async function saveOverrideManifest(manifest: Manifest): Promise<boolean> {
const out = await withStore<IDBValidKey>(STORE_MANIFEST, 'readwrite', (s) =>
s.put(manifest, MANIFEST_KEY),
)
return out !== null
}
export async function clearOverrides(): Promise<void> {
await withStore(STORE_MANIFEST, 'readwrite', (s) => s.clear())
await withStore(STORE_BLOBS, 'readwrite', (s) => s.clear())
}
export async function putBlob(key: string, data: ArrayBuffer): Promise<boolean> {
const out = await withStore<IDBValidKey>(STORE_BLOBS, 'readwrite', (s) => s.put(data, key))
return out !== null
}
export async function getBlob(key: string): Promise<ArrayBuffer | null> {
return withStore<ArrayBuffer>(STORE_BLOBS, 'readonly', (s) => s.get(key))
}
export async function listBlobKeys(): Promise<string[]> {
const keys = await withStore<IDBValidKey[]>(STORE_BLOBS, 'readonly', (s) => s.getAllKeys())
return (keys ?? []).map(String)
}
/** True when a local pack is active — the game shows a "custom assets" pill. */
export async function hasOverrides(): Promise<boolean> {
const m = await loadOverrideManifest()
return Object.keys(m).length > 0
}

View File

@ -0,0 +1,92 @@
/**
* Unit checks for the manifest schema. No test runner / no deps run directly:
* node --experimental-strip-types src/assets/manifest.test.ts
* (Node 22; on Node 23 the flag is unnecessary.) Same pattern as
* paint/coverage-math.test.ts: console + throw only, never bundled.
*
* The manifest is hand-edited by a non-programmer, so the property under test
* is "one bad line never costs you the whole pack".
*/
import { validateManifest, mergeManifests, type Manifest } from './manifest'
import { SLOT_IDS, isSlotId, SLOT_LABELS } from './slots'
let passed = 0
function ok(cond: boolean, msg: string): void {
if (!cond) throw new Error('FAIL: ' + msg)
passed++
}
// ---- garbage in, empty manifest out (never throws) --------------------------
for (const junk of [null, undefined, 42, 'nope', [], true]) {
const r = validateManifest(junk)
ok(Object.keys(r.manifest).length === 0, `junk input ${JSON.stringify(junk)} -> {}`)
}
// ---- the shipped empty manifest ---------------------------------------------
ok(Object.keys(validateManifest(JSON.parse('{}')).manifest).length === 0, 'empty {} -> {}')
ok(validateManifest({}).problems.length === 0, 'empty {} has no problems')
// ---- a good entry survives intact -------------------------------------------
{
const r = validateManifest({
'machine.boot': {
url: 'assets/live/boot.glb',
offset: [0, 1, 0],
rotationDeg: [0, 90, 0],
scale: 2,
idleClip: 'idle',
},
})
const e = r.manifest['machine.boot']
ok(r.problems.length === 0, 'clean entry produces no problems')
ok(e?.url === 'assets/live/boot.glb', 'url kept')
ok(e?.offset?.[1] === 1, 'offset kept')
ok(e?.rotationDeg?.[1] === 90, 'rotationDeg kept')
ok(e?.scale === 2, 'scale kept')
ok(e?.idleClip === 'idle', 'idleClip kept')
}
// ---- one bad line does not cost you the pack --------------------------------
{
const r = validateManifest({
'machine.boot': { url: 'good.glb' },
'machine.bucket': { url: 'also-good.glb', offset: 'sideways' },
'not.a.slot': { url: 'x.glb' },
'machine.arch': { nourl: true },
'machine.fan': 'just a string',
})
ok(r.manifest['machine.boot'] !== undefined, 'good entry survives a bad neighbour')
ok(r.manifest['machine.bucket'] !== undefined, 'entry with a bad field still loads')
ok(r.manifest['machine.bucket']?.offset === undefined, 'bad offset dropped, not kept')
ok(r.manifest['machine.arch'] === undefined, 'entry without a url is dropped')
ok((r.manifest as Record<string, unknown>)['not.a.slot'] === undefined, 'unknown slot dropped')
ok(r.problems.length === 4, `4 problems reported (got ${r.problems.length})`)
ok(r.problems.some((p) => p.slot === 'not.a.slot'), 'unknown slot is named in problems')
}
// ---- scale accepts a number or a triple, rejects zero -----------------------
ok(validateManifest({ 'machine.fan': { url: 'a', scale: [1, 2, 3] } }).manifest['machine.fan']?.scale !== undefined, 'triple scale ok')
ok(validateManifest({ 'machine.fan': { url: 'a', scale: 0 } }).manifest['machine.fan']?.scale === undefined, 'zero scale rejected')
ok(validateManifest({ 'machine.fan': { url: 'a', offset: [1, 2, NaN] } }).manifest['machine.fan']?.offset === undefined, 'NaN offset rejected')
// ---- merge: later source wins whole entries ---------------------------------
{
const shipped: Manifest = {
'machine.boot': { url: 'shipped.glb', scale: 3 },
'machine.fan': { url: 'fan.glb' },
}
const local: Manifest = { 'machine.boot': { url: 'local.glb' } }
const m = mergeManifests(shipped, local)
ok(m['machine.boot']?.url === 'local.glb', 'local override wins')
ok(m['machine.boot']?.scale === undefined, 'override replaces the whole entry, not per-field')
ok(m['machine.fan']?.url === 'fan.glb', 'untouched shipped entry survives the merge')
ok(shipped['machine.boot']?.url === 'shipped.glb', 'merge does not mutate its inputs')
}
// ---- slot table is coherent --------------------------------------------------
ok(new Set(SLOT_IDS).size === SLOT_IDS.length, 'no duplicate slot ids')
ok(SLOT_IDS.every((s) => typeof SLOT_LABELS[s] === 'string'), 'every slot has a plain-language label')
ok(SLOT_IDS.every(isSlotId), 'every slot id passes isSlotId')
ok(!isSlotId('blob.bodyy') && !isSlotId(''), 'isSlotId rejects near-misses')
console.log(`manifest.test.ts: ${passed} checks passed`)

139
src/assets/manifest.ts Normal file
View File

@ -0,0 +1,139 @@
/**
* Lane I manifest schema, validation and load order.
*
* The manifest is the whole "no code changes" promise: a JSON map of slot id ->
* where the GLB is and how to sit it in place. It is hand-editable by a
* non-programmer, so validation is forgiving by design a broken ENTRY is
* dropped with a warning and the rest of the pack still loads; only a manifest
* that isn't an object at all collapses to `{}`.
*
* This module deliberately imports neither `three` nor anything DOM-only at the
* top level, so `validateManifest`/`mergeManifests` can be node-tested.
*/
import { isSlotId, type SlotId } from './slots'
export interface SlotEntry {
/** GLB url. `idb:<key>` resolves to a blob stored by the editor. */
url: string
/** Local offset in metres, applied before rotation/scale. */
offset?: [number, number, number]
/** Local rotation in DEGREES (artist units), XYZ order. */
rotationDeg?: [number, number, number]
/** Uniform number or per-axis. Ignored for `blob.body` (auto-fit). */
scale?: number | [number, number, number]
/** Name of an animation clip to loop (SkinnedMesh assets). */
idleClip?: string
}
export type Manifest = Partial<Record<SlotId, SlotEntry>>
/** Reported by the validator so the editor can show what it threw away. */
export interface ManifestProblem {
slot: string
reason: string
}
export interface ValidationResult {
manifest: Manifest
problems: ManifestProblem[]
}
function isTriple(v: unknown): v is [number, number, number] {
return (
Array.isArray(v) && v.length === 3 &&
v.every((n) => typeof n === 'number' && Number.isFinite(n))
)
}
function isScale(v: unknown): v is number | [number, number, number] {
if (typeof v === 'number') return Number.isFinite(v) && v !== 0
return isTriple(v)
}
/**
* Pure. Never throws a non-object input yields an empty manifest, a bad entry
* is skipped. `problems` is advisory (editor surface + one console.warn).
*/
export function validateManifest(raw: unknown): ValidationResult {
const problems: ManifestProblem[] = []
const manifest: Manifest = {}
if (raw === null || typeof raw !== 'object' || Array.isArray(raw)) {
if (raw !== undefined) problems.push({ slot: '(root)', reason: 'not a JSON object' })
return { manifest, problems }
}
for (const [key, value] of Object.entries(raw as Record<string, unknown>)) {
if (!isSlotId(key)) {
problems.push({ slot: key, reason: 'unknown slot id' })
continue
}
if (value === null || typeof value !== 'object' || Array.isArray(value)) {
problems.push({ slot: key, reason: 'entry is not an object' })
continue
}
const v = value as Record<string, unknown>
if (typeof v.url !== 'string' || v.url.length === 0) {
problems.push({ slot: key, reason: 'missing "url"' })
continue
}
const entry: SlotEntry = { url: v.url }
if (v.offset !== undefined) {
if (isTriple(v.offset)) entry.offset = v.offset
else problems.push({ slot: key, reason: 'offset must be [x,y,z] numbers — ignored' })
}
if (v.rotationDeg !== undefined) {
if (isTriple(v.rotationDeg)) entry.rotationDeg = v.rotationDeg
else problems.push({ slot: key, reason: 'rotationDeg must be [x,y,z] numbers — ignored' })
}
if (v.scale !== undefined) {
if (isScale(v.scale)) entry.scale = v.scale
else problems.push({ slot: key, reason: 'scale must be a non-zero number or [x,y,z] — ignored' })
}
if (v.idleClip !== undefined) {
if (typeof v.idleClip === 'string') entry.idleClip = v.idleClip
else problems.push({ slot: key, reason: 'idleClip must be a string — ignored' })
}
manifest[key] = entry
}
return { manifest, problems }
}
/** Later sources win, per whole entry (not per field). Pure. */
export function mergeManifests(...sources: Manifest[]): Manifest {
const out: Manifest = {}
for (const src of sources) {
for (const [k, v] of Object.entries(src) as [SlotId, SlotEntry][]) {
if (v) out[k] = v
}
}
return out
}
/**
* The one correct way to address a shipped asset in this repo. `/assets/...`
* drops the `/blobbo/` deploy prefix and a bare `assets/...` resolves against
* the PAGE directory, which breaks on the demo pages (one level down).
*/
export function assetUrl(relative: string): string {
const base = import.meta.env.BASE_URL || '/'
return base + relative.replace(/^\/+/, '')
}
export const MANIFEST_URL_PATH = 'assets/manifest.json'
/** Fetch the shipped pack. A 404 is a normal, expected outcome (no pack). */
export async function fetchShippedManifest(): Promise<Manifest> {
let raw: unknown
try {
const res = await fetch(assetUrl(MANIFEST_URL_PATH), { cache: 'no-cache' })
if (!res.ok) return {}
raw = await res.json()
} catch {
return {}
}
const { manifest, problems } = validateManifest(raw)
if (problems.length) {
console.warn('[assets] manifest.json problems:', problems)
}
return manifest
}

133
src/assets/registry.test.ts Normal file
View File

@ -0,0 +1,133 @@
/**
* Headless checks for the asset runtime's geometry/material logic. Run:
* node --import ./scripts/ts-resolve.mjs --experimental-strip-types src/assets/registry.test.ts
* No renderer is created, so this runs without WebGL three's BufferGeometry
* and material classes are pure data.
*
* The load-bearing test here is EMPTY-MANIFEST PARITY: with no manifest, the
* blob hook must hand back the caller's own mesh object, not a rebuild of it.
*/
import * as THREE from 'three'
import type { World } from '../contracts'
import { AssetRegistry, setAssets, paintableInfo, normalizeToRadius, makeFitNode, ensureStandardMaterials } from './registry'
import { resolveBlobBody } from './blobBody'
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-6): void {
ok(Math.abs(a - b) <= eps, `${msg} (got ${a}, want ${b})`)
}
// ---- EMPTY-MANIFEST PARITY --------------------------------------------------
{
setAssets(new AssetRegistry({}))
const systems: unknown[] = []
const fakeWorld = { addSystem: (s: unknown) => systems.push(s) } as unknown as World
const built = new THREE.Mesh(
new THREE.SphereGeometry(0.5, 48, 36),
new THREE.MeshStandardMaterial({ color: '#F5F5F7' }),
)
const out = resolveBlobBody(fakeWorld, 0.5, () => built)
ok(out === built, 'empty manifest returns the caller\'s own mesh object (identity, not a rebuild)')
ok(out.geometry === built.geometry, 'geometry untouched')
ok(out.material === built.material, 'material untouched')
ok(systems.length === 0, 'no system registered when no asset is in play')
// and the slot helpers are pass-throughs too
const fb = new THREE.Object3D()
ok(new AssetRegistry({}).slotObject('machine.boot', () => fb) === fb,
'slotObject with an empty manifest returns the fallback itself (no wrapper Group)')
const host = new THREE.Object3D()
new AssetRegistry({}).attachSlot('machine.boot', host)
ok(host.children.length === 0, 'attachSlot with an empty manifest adds nothing')
}
// ---- paintableInfo ----------------------------------------------------------
{
const good = new THREE.Mesh(new THREE.SphereGeometry(0.5, 24, 18), new THREE.MeshStandardMaterial())
const info = paintableInfo(good)
ok(info.ok, 'a plain UV sphere is paintable')
// three offsets pole UVs slightly outside 0..1; the stock blob must pass.
ok(info.uvOk, 'the stock blob sphere passes the UV test despite its pole offset')
ok(info.materialCount === 1, 'one material')
ok(info.meshCount === 1, 'one mesh')
ok(info.triCount > 0, `tri count reported (${info.triCount})`)
ok(info.problems.length === 0, 'no problems reported')
}
{
const m = new THREE.Mesh(new THREE.SphereGeometry(0.5, 8, 6), [
new THREE.MeshStandardMaterial(), new THREE.MeshStandardMaterial(),
])
const info = paintableInfo(m)
ok(!info.ok, 'a two-material mesh is rejected (paint would be silently invisible)')
ok(info.materialCount === 2, 'material count reported')
ok(info.problems.some((p) => p.includes('materials')), 'the reason names the materials')
}
{
const geo = new THREE.BufferGeometry()
geo.setAttribute('position', new THREE.BufferAttribute(new Float32Array(9), 3))
const info = paintableInfo(new THREE.Mesh(geo, new THREE.MeshStandardMaterial()))
ok(!info.ok && !info.uvOk, 'a mesh with no UVs is rejected')
ok(info.problems.some((p) => p.includes('UV')), 'the reason names UVs')
}
{
const geo = new THREE.PlaneGeometry(1, 1)
const uv = geo.getAttribute('uv') as THREE.BufferAttribute
;(uv.array as Float32Array)[0] = 3.5 // an atlas/tiled layout
const info = paintableInfo(new THREE.Mesh(geo, new THREE.MeshStandardMaterial()))
ok(!info.uvOk, 'UVs outside 0..1 are rejected')
}
{
const info = paintableInfo(new THREE.Group())
ok(!info.ok && info.meshCount === 0, 'an empty file is rejected without throwing')
}
// ---- normalizeToRadius ------------------------------------------------------
{
// A model authored 7x too big with its origin at the feet.
const geo = new THREE.SphereGeometry(3.5, 16, 12)
geo.translate(0, 3.5, 0)
normalizeToRadius(geo, 0.5)
geo.computeBoundingSphere()
const bs = geo.boundingSphere!
near(bs.radius, 0.5, 'normalised to the collider radius')
near(bs.center.length(), 0, 'recentred on the origin', 1e-5)
}
{
const degenerate = new THREE.BufferGeometry()
degenerate.setAttribute('position', new THREE.BufferAttribute(new Float32Array(3), 3))
normalizeToRadius(degenerate, 0.5) // must not divide by zero / throw
ok(true, 'a zero-size geometry does not crash the normaliser')
}
// ---- makeFitNode ------------------------------------------------------------
{
const fit = makeFitNode({ url: 'x', offset: [1, 2, 3], rotationDeg: [0, 90, 0], scale: 2 })
ok(fit.position.x === 1 && fit.position.z === 3, 'offset applied')
near(fit.rotation.y, Math.PI / 2, 'degrees converted to radians')
ok(fit.scale.x === 2 && fit.scale.y === 2, 'uniform scale applied')
const fit2 = makeFitNode({ url: 'x', scale: [1, 2, 3] }, new THREE.Vector3(2, 2, 2))
ok(fit2.scale.x === 2 && fit2.scale.y === 4 && fit2.scale.z === 6, 'per-axis scale times the caller fit')
const id = makeFitNode({ url: 'x' })
ok(id.position.length() === 0 && id.scale.x === 1, 'an entry with no transform is identity')
}
// ---- ensureStandardMaterials ------------------------------------------------
{
// telegraph.ts only flashes MeshStandardMaterial; anything else silently
// breaks the mandatory danger telegraph, so imports are converted.
const m = new THREE.Mesh(new THREE.BoxGeometry(1, 1, 1), new THREE.MeshBasicMaterial({ color: '#ff0000' }))
const n = ensureStandardMaterials(m)
ok(n === 1, 'one material converted')
const mat = m.material as unknown as THREE.MeshStandardMaterial
ok(mat.isMeshStandardMaterial === true, 'basic material became standard')
ok(mat.color.getHexString() === 'ff0000', 'colour preserved through the conversion')
ok(ensureStandardMaterials(m) === 0, 'an already-standard material is left alone')
}
console.log(`registry.test.ts: ${passed} checks passed`)

375
src/assets/registry.ts Normal file
View File

@ -0,0 +1,375 @@
/**
* Lane I the asset registry.
*
* Every construction site in the game asks the registry for its slot instead of
* building a mesh directly. With an empty manifest the registry hands straight
* back the caller's own fallback builder, untouched that is what makes
* "empty manifest == today's game" true by construction rather than by
* re-implementation.
*
* Two shapes:
* `attachSlot(slot, host, opts)` decorate an object that already exists and
* whose transform someone else owns (greybox boxes, the see-saw plank).
* `slotObject(slot, buildFallback)` return a holder Group containing the
* fallback now, swapped for the GLB when it arrives.
*
* The holder/host is never given the manifest transform: telegraph.ts writes
* `root.position`/`root.scale` absolutely every tick and feel.ts owns the blob
* mesh's entire local transform. The manifest offset/rotation/scale therefore
* always lands on a private "fit" node the registry inserts underneath.
*/
import * as THREE from 'three'
import { GLTFLoader, type GLTF } from 'three/examples/jsm/loaders/GLTFLoader.js'
import { clone as skeletonClone } from 'three/examples/jsm/utils/SkeletonUtils.js'
import type { Manifest, SlotEntry } from './manifest'
import { fetchShippedManifest, mergeManifests } from './manifest'
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. */
ok: boolean
uvOk: boolean
triCount: number
materialCount: number
meshCount: number
skinned: boolean
/** Plain-language reasons the asset is not paint-safe (editor surface). */
problems: string[]
}
interface LoadedAsset {
scene: THREE.Group
animations: THREE.AnimationClip[]
}
export interface AttachOptions {
/** Called once when the GLB has arrived and been parented under `host`. */
onSwap?: (asset: THREE.Object3D) => void
/** Extra scale multiplied into the fit node (e.g. a puddle's slab size). */
fit?: THREE.Vector3
}
const loader = new GLTFLoader()
/** How far outside 0..1 a UV may stray before the layout is judged unpaintable. */
const UV_MARGIN = 0.1
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)
}
/** Applies a manifest entry's offset/rotation/scale to a fresh parent node. */
export function makeFitNode(entry: SlotEntry, extra?: THREE.Vector3): THREE.Group {
const fit = new THREE.Group()
fit.name = 'slot-fit'
if (entry.offset) fit.position.set(entry.offset[0], entry.offset[1], entry.offset[2])
if (entry.rotationDeg) fit.rotation.copy(degToRadTriple(entry.rotationDeg))
if (typeof entry.scale === 'number') fit.scale.setScalar(entry.scale)
else if (entry.scale) fit.scale.set(entry.scale[0], entry.scale[1], entry.scale[2])
if (extra) fit.scale.multiply(extra)
return fit
}
/** Shadows are per-mesh flags; a raw GLB import has them all off. */
export function enableShadows(root: THREE.Object3D): void {
root.traverse((o) => {
if ((o as THREE.Mesh).isMesh) {
o.castShadow = true
o.receiveShadow = true
}
})
}
/**
* telegraph.ts only collects emissives from `isMeshStandardMaterial`, and
* feel.ts writes `emissiveIntensity` unconditionally a Basic/Lambert/Phong
* material would silently kill the danger flash and the super-glow. Convert
* rather than reject so an artist's export still works.
*/
export function ensureStandardMaterials(root: THREE.Object3D): number {
let converted = 0
root.traverse((o) => {
const mesh = o as THREE.Mesh
if (!mesh.isMesh) return
const mats = Array.isArray(mesh.material) ? mesh.material : [mesh.material]
const out = mats.map((m) => {
const std = m as THREE.MeshStandardMaterial
if (std.isMeshStandardMaterial) return m
const src = m as THREE.MeshBasicMaterial
converted++
return new THREE.MeshStandardMaterial({
color: src.color ? src.color.clone() : new THREE.Color('#ffffff'),
map: src.map ?? null,
transparent: src.transparent,
opacity: src.opacity,
side: src.side,
})
})
mesh.material = Array.isArray(mesh.material) ? out : out[0]
})
return converted
}
/**
* 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).
*/
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 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'],
}
}
if (meshes.length > 1) problems.push(`${meshes.length} meshes — only the first is painted`)
const mesh = meshes[0]
const skinned = (mesh as THREE.SkinnedMesh).isSkinnedMesh === true
const materialCount = Array.isArray(mesh.material) ? mesh.material.length : 1
if (materialCount > 1) {
problems.push(`${materialCount} materials — paint needs exactly one (splats would be invisible)`)
}
const geo = mesh.geometry
const pos = geo.getAttribute('position')
const index = geo.getIndex()
const triCount = index ? index.count / 3 : (pos ? pos.count / 3 : 0)
const uv = geo.getAttribute('uv')
let uvOk = false
if (!uv) {
problems.push('no UV map — the paint has nowhere to go')
} else {
let min = Infinity
let max = -Infinity
for (let i = 0; i < uv.count * uv.itemSize; i++) {
const v = uv.array[i] as number
if (!Number.isFinite(v)) { min = NaN; break }
if (v < min) min = v
if (v > max) max = v
}
// Tolerance, not equality: three's own SphereGeometry — the mesh this game
// ships — pushes pole UVs to ±0.5/widthSegments outside 0..1, so a strict
// test would reject the stock blob. What actually breaks paint is an ATLAS
// or tiled layout (u running 0..4), which is far outside this margin.
uvOk = Number.isFinite(min) && min >= -UV_MARGIN && max <= 1 + UV_MARGIN
if (!uvOk) {
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')
return {
ok: uvOk && materialCount === 1,
uvOk, triCount: Math.round(triCount), materialCount,
meshCount: meshes.length, skinned, problems,
}
}
/**
* Re-centres and re-sizes geometry so its bounding sphere is exactly
* `radius` around the origin. PaintSkin derives `boundingRadius` from this and
* that number is GAMEPLAY (puddles.ts computes belly contact as
* `y - boundingRadius * size`), so a custom body must not be allowed to move it.
*/
export function normalizeToRadius(geo: THREE.BufferGeometry, radius: number): void {
geo.computeBoundingSphere()
const bs = geo.boundingSphere
if (!bs || bs.radius <= 1e-6) return
geo.translate(-bs.center.x, -bs.center.y, -bs.center.z)
geo.scale(radius / bs.radius, radius / bs.radius, radius / bs.radius)
geo.computeBoundingSphere()
geo.computeVertexNormals()
}
export class AssetRegistry {
readonly manifest: Manifest
private readonly cache = new Map<string, Promise<LoadedAsset>>()
private readonly resolved = new Map<string, LoadedAsset>()
private readonly objectUrls = new Map<string, string>()
private readonly warned = new Set<string>()
constructor(manifest: Manifest = {}) {
this.manifest = manifest
}
/** Shipped pack, then the editor's local override (later wins). */
static async create(): Promise<AssetRegistry> {
const [shipped, local] = await Promise.all([
fetchShippedManifest(),
loadOverrideManifest(),
])
const reg = new AssetRegistry(mergeManifests(shipped, local))
await reg.preload()
return reg
}
entry(slot: SlotId): SlotEntry | undefined {
return this.manifest[slot]
}
has(slot: SlotId): boolean {
return this.manifest[slot] !== undefined
}
/**
* 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.
*/
async preload(): 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)))
}
private warnOnce(key: string, ...args: unknown[]): void {
if (this.warned.has(key)) return
this.warned.add(key)
console.warn(...args)
}
private async resolveUrl(url: string): Promise<string> {
if (!isIdbUrl(url)) return url
const cached = this.objectUrls.get(url)
if (cached) return cached
const buf = await getBlob(idbKeyFromUrl(url))
if (!buf) throw new Error(`no local asset stored under "${idbKeyFromUrl(url)}"`)
const objUrl = URL.createObjectURL(new Blob([buf], { type: 'model/gltf-binary' }))
this.objectUrls.set(url, objUrl)
return objUrl
}
private load(url: string): Promise<LoadedAsset> {
const hit = this.cache.get(url)
if (hit) return hit
const p = (async () => {
const real = await this.resolveUrl(url)
const gltf: GLTF = await loader.loadAsync(real)
const asset: LoadedAsset = { scene: gltf.scene, animations: gltf.animations }
this.resolved.set(url, asset)
return asset
})()
p.catch((err) => {
this.warnOnce(`load:${url}`, `[assets] could not load "${url}" — keeping the built-in version.`, err)
})
this.cache.set(url, p)
return p
}
/** A cloned, shadow-enabled, standard-material instance of a loaded url. */
private instantiate(asset: LoadedAsset): THREE.Group {
const copy = skeletonClone(asset.scene) as THREE.Group
// SkeletonUtils.clone shares materials with the source. Instances get tinted
// per-slot (puddle colour, ghost translucency), so each needs its own.
copy.traverse((o) => {
const mesh = o as THREE.Mesh
if (!mesh.isMesh) return
mesh.material = Array.isArray(mesh.material)
? mesh.material.map((m) => m.clone())
: mesh.material.clone()
})
ensureStandardMaterials(copy)
enableShadows(copy)
return copy
}
/** Already-preloaded instance, or null. Sync — safe inside createBlob. */
instanceSync(slot: SlotId): { object: THREE.Group; entry: SlotEntry; animations: THREE.AnimationClip[] } | null {
const entry = this.manifest[slot]
if (!entry) return null
const asset = this.resolved.get(entry.url)
if (!asset) return null
return { object: this.instantiate(asset), entry, animations: asset.animations }
}
/**
* 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.
*/
attachSlot(slot: SlotId, host: THREE.Object3D, opts: AttachOptions = {}): void {
const entry = this.manifest[slot]
if (!entry) return
void this.load(entry.url).then(
(asset) => {
const fit = makeFitNode(entry, opts.fit)
fit.add(this.instantiate(asset))
host.add(fit)
opts.onSwap?.(fit)
},
() => { /* warned in load(); fallback stays */ },
)
}
/**
* Synchronous placeholder: returns a holder Group containing `buildFallback()`
* right away. If the slot is filled, the fallback is removed and the GLB takes
* its place as soon as it loads (immediately, if preload already ran).
*/
slotObject(slot: SlotId, buildFallback: () => THREE.Object3D): THREE.Object3D {
const fallback = buildFallback()
const entry = this.manifest[slot]
if (!entry) return fallback
const holder = new THREE.Group()
holder.name = `slot:${slot}`
holder.add(fallback)
this.attachSlot(slot, holder, {
onSwap: () => { holder.remove(fallback) },
})
return holder
}
/** Editor/demo surface: how paint-safe is whatever is in `blob.body`? */
paintability(slot: SlotId = 'blob.body'): PaintableInfo | null {
const inst = this.instanceSync(slot)
return inst ? paintableInfo(inst.object) : null
}
dispose(): void {
for (const url of this.objectUrls.values()) URL.revokeObjectURL(url)
this.objectUrls.clear()
}
}
// ---- module singleton ------------------------------------------------------
// Construction sites live in files owned by other lanes and are called from the
// frozen game.ts, so their signatures cannot grow a `registry` parameter. A
// singleton set once at boot is the only wiring that needs no frozen edit
// beyond a single `await initAssets()` in main.ts.
let current = new AssetRegistry({})
let booting: Promise<AssetRegistry> | null = null
/** The active registry. Always non-null — an empty one until initAssets runs. */
export function assets(): AssetRegistry {
return current
}
/** Await before building the game. Idempotent. */
export function initAssets(): Promise<AssetRegistry> {
if (!booting) {
booting = AssetRegistry.create().then(
(reg) => { current = reg; return reg },
(err) => {
console.warn('[assets] registry failed to start — using built-in assets only.', err)
return current
},
)
}
return booting
}
/** Tests/demos: install a hand-built registry without touching the network. */
export function setAssets(reg: AssetRegistry): void {
current = reg
booting = Promise.resolve(reg)
}

72
src/assets/slots.ts Normal file
View File

@ -0,0 +1,72 @@
/**
* 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.barrel',
'machine.plate',
'machine.boot',
'machine.bucket',
'machine.arch',
'machine.belt',
'machine.fan',
'machine.seesaw',
'course.scenery.cereal',
'course.scenery.block',
'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.barrel': 'Paint cannon',
'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',
'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.',
}

View File

@ -11,6 +11,8 @@ import * as THREE from 'three'
import type RAPIER from '@dimforge/rapier3d-compat'
import type { Blob, World } from '../contracts'
import { BASE_WHITE, defaultModifiers } from '../contracts'
import { assets } from '../assets/registry'
import { resolveBlobBody } from '../assets/blobBody'
export interface CreateBlobOptions {
position?: THREE.Vector3
@ -67,20 +69,27 @@ export function createBlob(world: World, opts: CreateBlobOptions = {}): BlobHand
// Paintable body: clean UV sphere. Default THREE sphere UVs are exactly what
// Lane B wants (equirectangular, seam at the back). Do not decorate this mesh.
const geometry = new THREE.SphereGeometry(radius, seg, Math.round(seg * 0.75))
const material = new THREE.MeshStandardMaterial({
color: BASE_WHITE,
roughness: 0.5,
metalness: 0.0,
emissive: new THREE.Color('#88e0ff'),
emissiveIntensity: 0.0, // driven by modifiers.glow in the feel layer
})
const mesh = new THREE.Mesh(geometry, material)
mesh.castShadow = true
// Slot `blob.body` may replace it, but only with a mesh that passes the same
// paint requirements and is refitted to `radius` (see assets/blobBody.ts).
const buildBodyMesh = (): THREE.Mesh => {
const geometry = new THREE.SphereGeometry(radius, seg, Math.round(seg * 0.75))
const material = new THREE.MeshStandardMaterial({
color: BASE_WHITE,
roughness: 0.5,
metalness: 0.0,
emissive: new THREE.Color('#88e0ff'),
emissiveIntensity: 0.0, // driven by modifiers.glow in the feel layer
})
const m = new THREE.Mesh(geometry, material)
m.castShadow = true
return m
}
const mesh = resolveBlobBody(world, radius, buildBodyMesh)
group.add(mesh)
// Cosmetic face, parented to the group (yaws to face travel, scales with size).
const face = buildFace(radius)
const face = new THREE.Group()
face.add(assets().slotObject('blob.face', () => buildFace(radius)))
group.add(face)
// Physics proxy: dynamic ball, rotations locked so it stays an upright

View File

@ -12,6 +12,8 @@
*/
import * as THREE from 'three'
import type { World } from '../contracts'
import { assets } from '../assets/registry'
import type { SlotId } from '../assets/slots'
export interface GreyboxHandle {
/** Recommended blob spawn point (just above the start plateau). */
@ -69,6 +71,17 @@ export function buildGreybox(world: World): GreyboxHandle {
return mesh
}
/**
* Park a custom prop inside a greybox box. The box's material is switched off
* rather than the mesh itself so the prop (a child) still renders, and the
* mesh stays in `meshes[]` so the handle's contract is unchanged.
*/
function decorate(slot: SlotId, mesh: THREE.Mesh): void {
assets().attachSlot(slot, mesh, {
onSwap: () => { (mesh.material as THREE.Material).visible = false },
})
}
// ---- base floor: catches everything, spans the whole course ----
addBox(0, -0.5, -15, 28, 0.5, 60, { color: '#efe3c6', roughness: 1 })
@ -104,8 +117,11 @@ export function buildGreybox(world: World): GreyboxHandle {
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 ----
addBox(-20, 7, 26, 4, 7, 3, { color: '#FFD60A', cast: true })
addBox(20, 5, 12, 3, 5, 3, { color: '#AF52DE', cast: true })
// Slot hooks go on the MESH only, after the collider exists: the box volume
// is still solid whatever model is dropped in, so a custom prop can never
// open a hole in the course or grow an invisible wall.
decorate('course.scenery.cereal', addBox(-20, 7, 26, 4, 7, 3, { color: '#FFD60A', cast: true }))
decorate('course.scenery.block', addBox(20, 5, 12, 3, 5, 3, { color: '#AF52DE', cast: true }))
return {
spawn: new THREE.Vector3(0, 3.5, 30),

139
src/demo/lane-i.ts Normal file
View File

@ -0,0 +1,139 @@
/**
* Lane I demo every slot, twice.
*
* Left column is built with an EMPTY registry (today's game). Right column is
* built with a test manifest pointing at the four farm GLBs. Same factories,
* same arguments the only difference is which registry is installed when the
* factory runs, which is exactly the property the asset runtime promises.
*
* The paint check is deliberately numeric rather than visual: it stamps a splat
* at a known point on the swapped body and prints coverage before/after, so
* "the paint landed" is a number you can read, not a thing you squint at.
*
* NOTE: this page is not in vite.config.ts's `rollupOptions.input` (frozen
* file), so it is served by `npm run dev` but is NOT emitted by `npm run build`
* until integration adds the entry.
*/
import * as THREE from 'three'
import { createWorld } from '../world'
import type { World } from '../contracts'
import { PaintSkin } from '../paint/skin'
import { AssetRegistry, setAssets, paintableInfo } from '../assets/registry'
import { resolveBlobBody } from '../assets/blobBody'
import { assetUrl, type Manifest } from '../assets/manifest'
import { SLOT_LABELS, type SlotId } from '../assets/slots'
import {
createPressurePlate, createSpringBoot, createBucketDump,
createConveyorBelt, createBubbleArch, createFan,
} from '../machine/parts'
const FARM = (name: string) => assetUrl(`assets/meshes/${name}.glb`)
/**
* Fit transforms measured against the farm GLBs' own scale (they export large
* and Z-up-ish); committed here so the demo is a real fitting example, not a
* pile of identity transforms.
*/
const TEST_MANIFEST: Manifest = {
'machine.boot': { url: FARM('prop-spring-boot'), scale: 1.2, offset: [0, 0, 0] },
'machine.bucket': { url: FARM('prop-paint-bucket'), scale: 1.0, offset: [0, -0.6, 0] },
'course.scenery.cereal': { url: FARM('prop-toaster-launcher'), scale: 3.0, rotationDeg: [0, 180, 0] },
'blob.body': { url: FARM('blobbo-base') },
}
const log: string[] = []
const panel = document.createElement('div')
panel.style.cssText =
'position:fixed;left:8px;top:8px;max-height:96vh;overflow:auto;width:400px;' +
'font:12px/1.5 ui-monospace,monospace;background:#0b0b12ee;color:#dfe;' +
'padding:10px 12px;border-radius:8px;white-space:pre-wrap;z-index:10'
document.body.appendChild(panel)
function say(line: string): void {
log.push(line)
panel.textContent = log.join('\n')
console.log('[lane-i] ' + line)
}
/** Builds the whole slot line-up at an X offset. Identical calls both times. */
function buildColumn(world: World, x: number): void {
createPressurePlate(world, { id: `plate${x}`, position: [x, 0.2, 6], massThreshold: 1, emits: 'sig' })
createSpringBoot(world, { id: `boot${x}`, position: [x, 0.2, 2], impulse: 8, onSignal: 'sig' })
createBucketDump(world, { id: `bucket${x}`, position: [x, 4, -2], color: 'blue', radius: 1.4, onSignal: 'sig' })
createConveyorBelt(world, { id: `belt${x}`, position: [x, 0.3, -8], velocity: [2, 0, 0], size: [8, 0.5, 3] })
createBubbleArch(world, { id: `arch${x}`, position: [x, 0.2, -14], fraction: 0.5 })
createFan(world, { id: `fan${x}`, position: [x, 1.5, -20], force: 4, direction: [0, 0, 1] })
}
const world = await createWorld(document.getElementById('app')!)
world.camera.position.set(0, 9, 22)
world.camera.lookAt(0, 1, -6)
// ---- LEFT: empty manifest = today's game ------------------------------------
const emptyReg = new AssetRegistry({})
setAssets(emptyReg)
buildColumn(world, -9)
const stockBody = resolveBlobBody(world, 0.5, () => makeStockSphere())
stockBody.position.set(-9, 2, 12)
world.scene.add(stockBody)
say('LEFT (x=-9): empty manifest — procedural, unchanged.')
// ---- RIGHT: test manifest ---------------------------------------------------
const testReg = new AssetRegistry(TEST_MANIFEST)
say('\nRIGHT (x=+9): loading test manifest…')
for (const [slot, entry] of Object.entries(TEST_MANIFEST) as [SlotId, { url: string }][]) {
say(` ${SLOT_LABELS[slot]} <- ${entry.url.split('/').pop()}`)
}
await testReg.preload()
setAssets(testReg)
buildColumn(world, 9)
const customBody = resolveBlobBody(world, 0.5, () => makeStockSphere())
customBody.position.set(9, 2, 12)
world.scene.add(customBody)
// ---- per-slot status --------------------------------------------------------
say('\n--- slot status ---')
for (const slot of Object.keys(TEST_MANIFEST) as SlotId[]) {
const inst = testReg.instanceSync(slot)
say(` ${slot.padEnd(24)} ${inst ? 'LOADED' : 'FAILED -> fallback kept'}`)
}
// ---- paintability report for blob.body --------------------------------------
say('\n--- blob.body paintability ---')
const info = testReg.paintability('blob.body')
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(` paint-safe: ${info.ok ? 'YES' : 'NO'}`)
for (const p of info.problems) say(` ! ${p}`)
}
const swapped = customBody !== stockBody && customBody.geometry !== stockBody.geometry
say(` body actually swapped: ${swapped ? 'YES' : 'no — using the built-in sphere'}`)
say(` bounding radius: ${customBody.geometry.boundingSphere?.radius.toFixed(4)} (must be 0.5000 — it is a gameplay number)`)
// ---- scripted splat: does paint land on whatever body is in use? ------------
say('\n--- scripted splat ---')
for (const [label, mesh] of [['stock', stockBody], ['custom', customBody]] as const) {
const skin = new PaintSkin(mesh, { size: 256 })
const before = skin.coverage().total
// A point on the +X face of the body, in world space.
const p = mesh.getWorldPosition(new THREE.Vector3()).add(new THREE.Vector3(0.5, 0, 0))
skin.splatAtPoint(p, 'red', 0.25)
const after = skin.coverage()
say(` ${label}: coverage ${before.toFixed(4)} -> ${after.total.toFixed(4)} ` +
`red=${after.byColor.red.toFixed(4)} ${after.total > before ? 'PAINT LANDED' : 'NOTHING LANDED'}`)
}
say('\nSlots with no entry above fall back silently — that is the empty-manifest path.')
world.start()
function makeStockSphere(): THREE.Mesh {
const m = new THREE.Mesh(
new THREE.SphereGeometry(0.5, 48, 36),
new THREE.MeshStandardMaterial({ color: '#F5F5F7', roughness: 0.5, emissive: new THREE.Color('#88e0ff'), emissiveIntensity: 0 }),
)
m.castShadow = true
return m
}

View File

@ -14,6 +14,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'
const GHOST_OPACITY = 0.35
const BOB_AMPLITUDE = 0.06 // metres
@ -80,6 +81,32 @@ export class GhostPlayer {
g.renderOrder = 2 // composite over the opaque scene
this.mats.push(bodyMat, eyeMat)
this.geos.push(bodyGeo, eyeGeo)
// Slot `ghost.body`, falling back to whatever is in `blob.body` so a custom
// blob automatically gets a matching ghost. Materials are cloned per
// 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'
reg.attachSlot(slot, g, {
onSwap: (asset) => {
body.visible = false
asset.traverse((o) => {
const m = (o as THREE.Mesh).material as THREE.MeshStandardMaterial | undefined
if (!m) return
m.transparent = true
m.opacity = GHOST_OPACITY
m.depthWrite = false
if (m.isMeshStandardMaterial) {
m.color.set('#eaf6ff')
m.emissive.set('#bfe6ff')
m.emissiveIntensity = 0.5
m.map = null // the real blob's paint canvas is not the ghost's
}
this.mats.push(m)
})
},
})
return g
}

View File

@ -23,6 +23,20 @@ import type RAPIER from '@dimforge/rapier3d-compat'
import type { World, PaintColor } from '../contracts'
import { PALETTE } from '../contracts'
import { telegraph, isTelegraphing } from './telegraph'
import { assets } from '../assets/registry'
/**
* Slot hook shared by every part: park the custom model under the part's root
* and hide the primitives it replaces. Only the primitives listed in `replaces`
* go away animated sub-parts (the plate cap, the belt chevrons, the fan
* blades) stay procedural so they keep moving.
*/
function slotPart(slot: Parameters<ReturnType<typeof assets>['attachSlot']>[0],
root: THREE.Object3D, replaces: THREE.Object3D[]): void {
assets().attachSlot(slot, root, {
onSwap: () => { for (const o of replaces) o.visible = false },
})
}
export type Vec3 = [number, number, number]
@ -110,6 +124,8 @@ export function createPressurePlate(world: World, cfg: PressurePlateConfig): Mac
cap.receiveShadow = true
group.add(cap)
slotPart('machine.plate', group, [base])
// physics: fixed platform the blob actually rests on
const body = physics.createRigidBody(
rapier.RigidBodyDesc.fixed().setTranslation(pos.x, pos.y + cap.position.y, pos.z),
@ -195,13 +211,17 @@ export function createSpringBoot(world: World, cfg: SpringBootConfig): MachinePa
// coil spring under the pad (telegraph target scales/shakes the whole group)
const coilMat = new THREE.MeshStandardMaterial({ color: '#7f8c8d', metalness: 0.6, roughness: 0.3 })
const coils: THREE.Mesh[] = []
for (let i = 0; i < 3; i++) {
const ring = new THREE.Mesh(new THREE.TorusGeometry(0.7, 0.09, 8, 20), coilMat)
ring.rotation.x = Math.PI / 2
ring.position.y = -0.15 - i * 0.22
group.add(ring)
coils.push(ring)
}
slotPart('machine.boot', group, [pad, ...coils])
// physics pad so the ball can rest here between signal and kick
if (cfg.pad !== false) {
const body = physics.createRigidBody(
@ -305,6 +325,13 @@ export function createSeeSaw(world: World, cfg: SeeSawConfig): MachinePart {
plank.receiveShadow = true
group.add(plank)
// The plank's world pose is copied off the rigid body every frame, so the
// custom model rides as its CHILD and the primitive is hidden by turning its
// material off — hiding the plank itself would hide the child too.
assets().attachSlot('machine.seesaw', plank, {
onSwap: () => { (plank.material as THREE.Material).visible = false },
})
world.onFrame(() => {
const t = plankBody.translation()
const r = plankBody.rotation()
@ -356,6 +383,9 @@ export function createBucketDump(world: World, cfg: BucketDumpConfig): MachinePa
fill.position.y = -0.15
group.add(fill)
// Fill stays procedural: it is the colour read, and it is re-tinted at runtime.
slotPart('machine.bucket', group, [shell])
let dumping = false
let tip = 0 // current tip angle
let tipTarget = 0
@ -519,6 +549,9 @@ export function createConveyorBelt(world: World, cfg: ConveyorBeltConfig): Machi
group.add(c)
chevrons.push(c)
}
// Chevrons stay procedural — they scroll every frame to advertise direction.
slotPart('machine.belt', group, [belt])
const placeChevron = (c: THREE.Mesh, offset: number) => {
const t = ((offset % span) + span) % span - span * 0.5
if (alongX) c.position.x = t
@ -577,15 +610,18 @@ export function createBubbleArch(world: World, cfg: BubbleArchConfig): MachinePa
scene.add(group)
const postMat = new THREE.MeshStandardMaterial({ color: '#00bcd4', roughness: 0.3, metalness: 0.2 })
const archParts: THREE.Mesh[] = []
for (const sx of [-1, 1]) {
const post = new THREE.Mesh(new THREE.CylinderGeometry(0.2, 0.2, h, 12), postMat)
post.position.set((sx * w) / 2, h / 2, 0)
post.castShadow = true
group.add(post)
archParts.push(post)
}
const bar = new THREE.Mesh(new THREE.BoxGeometry(w + 0.4, 0.4, 0.4), postMat)
bar.position.y = h
group.add(bar)
archParts.push(bar)
// a few permanent decorative bubbles clinging to the arch (ambient telegraph)
const bubbleMat = new THREE.MeshStandardMaterial({ color: '#e0f7ff', transparent: true, opacity: 0.5, roughness: 0.05 })
@ -593,8 +629,11 @@ export function createBubbleArch(world: World, cfg: BubbleArchConfig): MachinePa
const b = new THREE.Mesh(new THREE.SphereGeometry(0.15 + Math.random() * 0.2, 10, 8), bubbleMat)
b.position.set((Math.random() - 0.5) * w, Math.random() * h, (Math.random() - 0.5) * d)
group.add(b)
archParts.push(b)
}
slotPart('machine.arch', group, archParts)
const detCenter = { x: pos.x, y: pos.y + h * 0.5, z: pos.z }
const detHalf = { x: w * 0.5, y: h * 0.5, z: d * 0.5 }
@ -705,6 +744,9 @@ export function createFan(world: World, cfg: FanConfig): MachinePart {
blades.position.z = 0.05
group.add(blades)
// Blades stay procedural — the constant spin IS this part's telegraph.
slotPart('machine.fan', group, [housing])
const beam = beamBox(pos, dir, range, spread)
world.addSystem({

View File

@ -12,6 +12,7 @@ import * as THREE from 'three'
import type { PaintColor, System, World } from '../contracts'
import { PALETTE } from '../contracts'
import type { PaintSkin } from './skin'
import { assets } from '../assets/registry'
export interface PaintCannonConfig {
world: World
@ -218,6 +219,8 @@ export class PaintCannon implements System {
private buildBarrel(hex: string): THREE.Group {
const g = new THREE.Group()
// Slot `cannon.barrel` decorates the base; the named `pivot` child and its
// local +Z stay procedural because aimBarrel() steers them every step.
const base = new THREE.Mesh(
new THREE.CylinderGeometry(0.45, 0.55, 0.5, 16),
new THREE.MeshStandardMaterial({ color: '#3a3a44', roughness: 0.7 }),
@ -237,6 +240,12 @@ export class PaintCannon implements System {
pivot.add(tube)
pivot.name = 'pivot'
g.add(pivot)
assets().attachSlot('cannon.barrel', g, {
onSwap: () => {
base.visible = false
tube.visible = false
},
})
return g
}

View File

@ -21,6 +21,7 @@ import * as THREE from 'three'
import type { Blob, PaintColor, World } from '../contracts'
import { PALETTE } from '../contracts'
import type { PaintSkin } from './skin'
import { assets } from '../assets/registry'
export interface PaintPuddleConfig {
/** Strip centre in world space; y is the track surface the strip lies on. */
@ -83,6 +84,22 @@ export class PaintPuddle {
this.mesh.position.set(x, y + 0.03, z)
this.mesh.receiveShadow = true
world.scene.add(this.mesh)
// Slot `fx.puddle`: a 1x1 decal model is stretched to this strip's footprint.
// Visual only — the trigger is pure maths on the config numbers below.
assets().attachSlot('fx.puddle', this.mesh, {
fit: new THREE.Vector3(w, 1, l),
onSwap: (asset) => {
mat.visible = false
asset.traverse((o) => {
const m = (o as THREE.Mesh).material as THREE.MeshStandardMaterial | undefined
if (m && m.isMeshStandardMaterial) {
m.color.set(PALETTE[cfg.color])
m.emissive.set(PALETTE[cfg.color])
}
})
},
})
}
/** True while the blob's belly is resting inside this strip's footprint. */