Lane I: asset runtime — manifest, registry, slot hooks, IndexedDB overrides
Custom GLBs drop into 14 named slots without code changes; an empty manifest
produces today's game by construction (the fallback builders are the original
code moved into a closure, and an empty registry returns the caller's own
object by identity).
- src/assets/{slots,manifest,idb,registry,blobBody}.ts — schema + validation,
GLTF cache with per-instance material cloning, fit nodes, IndexedDB override
layer, paintability report.
- Slot hooks in createBlob, parts, cannon, greybox, puddles, ghost. Mesh-only:
no collider, physics or logic line is touched. Animated sub-parts (plate cap,
belt chevrons, fan blades, cannon pivot) stay procedural so a custom model
cannot stop them moving.
- public/assets/ — the build had NO asset copy step at all, so every asset URL
would have 404'd in production. public/ is vite's default publicDir, so this
needs no vite.config change.
- Tests: 63 headless checks + a farm-GLB audit that fires PaintSkin's own
raycast against the fitted body.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
parent
cabc60def8
commit
324d7d4568
3
.gitignore
vendored
3
.gitignore
vendored
@ -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
22
demos/lane-i.html
Normal 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
127
docs/ASSET-SLOTS.md
Normal 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.
|
||||
1
public/assets/manifest.json
Normal file
1
public/assets/manifest.json
Normal file
@ -0,0 +1 @@
|
||||
{}
|
||||
95
scripts/farm-assets.check.ts
Normal file
95
scripts/farm-assets.check.ts
Normal 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
13
scripts/stage-farm-assets.sh
Executable 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
25
scripts/ts-resolve.mjs
Normal 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
124
src/assets/blobBody.ts
Normal 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
123
src/assets/idb.ts
Normal 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
|
||||
}
|
||||
92
src/assets/manifest.test.ts
Normal file
92
src/assets/manifest.test.ts
Normal 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
139
src/assets/manifest.ts
Normal 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
133
src/assets/registry.test.ts
Normal 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
375
src/assets/registry.ts
Normal 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
72
src/assets/slots.ts
Normal 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.',
|
||||
}
|
||||
@ -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
|
||||
|
||||
@ -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
139
src/demo/lane-i.ts
Normal 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
|
||||
}
|
||||
@ -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
|
||||
}
|
||||
|
||||
|
||||
@ -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({
|
||||
|
||||
@ -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
|
||||
}
|
||||
|
||||
|
||||
@ -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. */
|
||||
|
||||
Loading…
Reference in New Issue
Block a user