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

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

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

96 lines
4.2 KiB
TypeScript

/**
* 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