From 324d7d456888f180033b985581bb230d8585d28d Mon Sep 17 00:00:00 2001 From: Claude Date: Sun, 19 Jul 2026 11:37:08 +1000 Subject: [PATCH] =?UTF-8?q?Lane=20I:=20asset=20runtime=20=E2=80=94=20manif?= =?UTF-8?q?est,=20registry,=20slot=20hooks,=20IndexedDB=20overrides?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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 --- .gitignore | 3 + demos/lane-i.html | 22 ++ docs/ASSET-SLOTS.md | 127 ++++++++++++ public/assets/manifest.json | 1 + scripts/farm-assets.check.ts | 95 +++++++++ scripts/stage-farm-assets.sh | 13 ++ scripts/ts-resolve.mjs | 25 +++ src/assets/blobBody.ts | 124 ++++++++++++ src/assets/idb.ts | 123 ++++++++++++ src/assets/manifest.test.ts | 92 +++++++++ src/assets/manifest.ts | 139 +++++++++++++ src/assets/registry.test.ts | 133 +++++++++++++ src/assets/registry.ts | 375 +++++++++++++++++++++++++++++++++++ src/assets/slots.ts | 72 +++++++ src/blob/createBlob.ts | 31 ++- src/course/greybox.ts | 20 +- src/demo/lane-i.ts | 139 +++++++++++++ src/ghost/player.ts | 27 +++ src/machine/parts.ts | 42 ++++ src/paint/cannon.ts | 9 + src/paint/puddles.ts | 17 ++ 21 files changed, 1616 insertions(+), 13 deletions(-) create mode 100644 demos/lane-i.html create mode 100644 docs/ASSET-SLOTS.md create mode 100644 public/assets/manifest.json create mode 100644 scripts/farm-assets.check.ts create mode 100755 scripts/stage-farm-assets.sh create mode 100644 scripts/ts-resolve.mjs create mode 100644 src/assets/blobBody.ts create mode 100644 src/assets/idb.ts create mode 100644 src/assets/manifest.test.ts create mode 100644 src/assets/manifest.ts create mode 100644 src/assets/registry.test.ts create mode 100644 src/assets/registry.ts create mode 100644 src/assets/slots.ts create mode 100644 src/demo/lane-i.ts diff --git a/.gitignore b/.gitignore index 93aaaf8..6f4121d 100644 --- a/.gitignore +++ b/.gitignore @@ -3,3 +3,6 @@ __pycache__/ .DS_Store node_modules/ dist/ + +# Lane I: farm GLBs staged for local serving (large, not committed) +public/assets/meshes/ diff --git a/demos/lane-i.html b/demos/lane-i.html new file mode 100644 index 0000000..5fa5dd0 --- /dev/null +++ b/demos/lane-i.html @@ -0,0 +1,22 @@ + + +BLOBBO lane i demo — asset slots + + +
+
+ BLOBBO — Lane I: asset slots
+ Left column: empty manifest (today's game).
+ Right column: the same code with a test manifest pointing at the farm GLBs.
+ 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. +
+ + + diff --git a/docs/ASSET-SLOTS.md b/docs/ASSET-SLOTS.md new file mode 100644 index 0000000..8ee7231 --- /dev/null +++ b/docs/ASSET-SLOTS.md @@ -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:`, +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. diff --git a/public/assets/manifest.json b/public/assets/manifest.json new file mode 100644 index 0000000..0967ef4 --- /dev/null +++ b/public/assets/manifest.json @@ -0,0 +1 @@ +{} diff --git a/scripts/farm-assets.check.ts b/scripts/farm-assets.check.ts new file mode 100644 index 0000000..b8e38d1 --- /dev/null +++ b/scripts/farm-assets.check.ts @@ -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 { + 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 diff --git a/scripts/stage-farm-assets.sh b/scripts/stage-farm-assets.sh new file mode 100755 index 0000000..9f87ef5 --- /dev/null +++ b/scripts/stage-farm-assets.sh @@ -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/)" diff --git a/scripts/ts-resolve.mjs b/scripts/ts-resolve.mjs new file mode 100644 index 0000000..0cdc266 --- /dev/null +++ b/scripts/ts-resolve.mjs @@ -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 + } + }, +}) diff --git a/src/assets/blobBody.ts b/src/assets/blobBody.ts new file mode 100644 index 0000000..ce048b2 --- /dev/null +++ b/src/assets/blobBody.ts @@ -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) +} diff --git a/src/assets/idb.ts b/src/assets/idb.ts new file mode 100644 index 0000000..0454c13 --- /dev/null +++ b/src/assets/idb.ts @@ -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:` 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 { + 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(store: IDBObjectStore, run: (s: IDBObjectStore) => IDBRequest): Promise { + 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( + name: string, + mode: IDBTransactionMode, + run: (s: IDBObjectStore) => IDBRequest, +): Promise { + const db = await openDb() + if (!db) return null + try { + const tx = db.transaction(name, mode) + const out = await request(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 { + const raw = await withStore(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 { + const out = await withStore(STORE_MANIFEST, 'readwrite', (s) => + s.put(manifest, MANIFEST_KEY), + ) + return out !== null +} + +export async function clearOverrides(): Promise { + 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 { + const out = await withStore(STORE_BLOBS, 'readwrite', (s) => s.put(data, key)) + return out !== null +} + +export async function getBlob(key: string): Promise { + return withStore(STORE_BLOBS, 'readonly', (s) => s.get(key)) +} + +export async function listBlobKeys(): Promise { + const keys = await withStore(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 { + const m = await loadOverrideManifest() + return Object.keys(m).length > 0 +} diff --git a/src/assets/manifest.test.ts b/src/assets/manifest.test.ts new file mode 100644 index 0000000..bb53783 --- /dev/null +++ b/src/assets/manifest.test.ts @@ -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)['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`) diff --git a/src/assets/manifest.ts b/src/assets/manifest.ts new file mode 100644 index 0000000..96310cd --- /dev/null +++ b/src/assets/manifest.ts @@ -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:` 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> + +/** 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)) { + 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 + 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 { + 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 +} diff --git a/src/assets/registry.test.ts b/src/assets/registry.test.ts new file mode 100644 index 0000000..4ed6b4b --- /dev/null +++ b/src/assets/registry.test.ts @@ -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`) diff --git a/src/assets/registry.ts b/src/assets/registry.ts new file mode 100644 index 0000000..ceefe3d --- /dev/null +++ b/src/assets/registry.ts @@ -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>() + private readonly resolved = new Map() + private readonly objectUrls = new Map() + private readonly warned = new Set() + + constructor(manifest: Manifest = {}) { + this.manifest = manifest + } + + /** Shipped pack, then the editor's local override (later wins). */ + static async create(): Promise { + 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 { + 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 { + 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 { + 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 | 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 { + 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) +} diff --git a/src/assets/slots.ts b/src/assets/slots.ts new file mode 100644 index 0000000..0f7e984 --- /dev/null +++ b/src/assets/slots.ts @@ -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 = new Set(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 = { + '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> = { + '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.', +} diff --git a/src/blob/createBlob.ts b/src/blob/createBlob.ts index b83ff84..e7cd53d 100644 --- a/src/blob/createBlob.ts +++ b/src/blob/createBlob.ts @@ -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 diff --git a/src/course/greybox.ts b/src/course/greybox.ts index db07e94..e0a0f1d 100644 --- a/src/course/greybox.ts +++ b/src/course/greybox.ts @@ -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), diff --git a/src/demo/lane-i.ts b/src/demo/lane-i.ts new file mode 100644 index 0000000..68a07ef --- /dev/null +++ b/src/demo/lane-i.ts @@ -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 +} diff --git a/src/ghost/player.ts b/src/ghost/player.ts index 732c309..2b7d4e7 100644 --- a/src/ghost/player.ts +++ b/src/ghost/player.ts @@ -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 } diff --git a/src/machine/parts.ts b/src/machine/parts.ts index b9f8aec..294bb9a 100644 --- a/src/machine/parts.ts +++ b/src/machine/parts.ts @@ -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['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({ diff --git a/src/paint/cannon.ts b/src/paint/cannon.ts index 39454d8..aab3971 100644 --- a/src/paint/cannon.ts +++ b/src/paint/cannon.ts @@ -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 } diff --git a/src/paint/puddles.ts b/src/paint/puddles.ts index 8a33f1b..1e3a729 100644 --- a/src/paint/puddles.ts +++ b/src/paint/puddles.ts @@ -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. */