The three that stopped a pack shipping: - a mesh-less GLB (armature-only export) fired onSwap anyway, hiding the primitive and adding nothing — an invisible prop with a live collider. Mesh count is now checked BEFORE the scene is touched, in one guard that covers every consumer including slotObject/blob.face. - the fulfilment path had no try/catch, so a throw became an unhandled rejection. Split in two: a build failure bails out before onSwap can hide anything; an onSwap failure keeps the replacement parented, because consumers hide their primitive on onSwap's first line and removing the fit node there would manufacture the one forbidden state. - preload() awaited every url forever. Each is now raced against a 10s deadline with allSettled semantics: a stalled host costs one warning and a fallback prop, not a game that never boots. Also: - instantiate/instanceSync never throw — createBlob's call site is frozen and unguarded, so a throw there is a black screen. - ghost and blob share fitBodyToRadius(); a borrowed blob.body was rendering the ghost 2.17x oversized with the farm mesh. - skinned blob.body is rejected loudly instead of silently half-working; the idle-clip mixer is gone (it animated an orphaned skeleton in the fixed step). - paintableInfo counts UV islands: blobbo-base.glb passes every other check and still paints wrong at 1140 charts. Warning, not rejection. - slots.ts gains cannon.base, course.finish, course.tunnel, course.tramp, all hooked except tramp (built in frozen game.ts). - manifest ignores _-prefixed metadata keys instead of calling them typos. Empty-manifest parity verified byte-for-byte: scripts/sacred-parity.check.ts fingerprints the built scene and reports 49df4f20 on main and on this branch.
141 lines
6.4 KiB
TypeScript
141 lines
6.4 KiB
TypeScript
/**
|
|
* 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 — rejected)' : ''}`)
|
|
say(` UV islands: ${info.uvCharts < 0 ? 'n/a' : info.uvCharts} seam vertices: ${Math.round(info.seamRatio * 100)}%`)
|
|
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
|
|
}
|