harden asset runtime: guard the swap path, bound the boot, reconcile slots
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.
This commit is contained in:
parent
f827f6f861
commit
e34773d35f
@ -39,12 +39,15 @@ and `docker cp`s the whole of `dist/` on every deploy.
|
||||
"url": "assets/live/boot.glb",
|
||||
"offset": [0, 0.2, 0],
|
||||
"rotationDeg": [0, 90, 0],
|
||||
"scale": 1.5,
|
||||
"idleClip": "idle"
|
||||
"scale": 1.5
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
Keys starting with `_` (the editor writes `_readme` and `_generatedBy`) are
|
||||
metadata and are ignored silently. `idleClip` is accepted by the schema but
|
||||
currently ignored by the runtime — see "Rigged bodies are not supported" below.
|
||||
|
||||
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
|
||||
@ -62,7 +65,8 @@ custom asset testable on the live site with zero deploys.
|
||||
| `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 |
|
||||
| `cannon.base` | the cannon's stand | — |
|
||||
| `cannon.barrel` | the 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) |
|
||||
@ -72,12 +76,65 @@ custom asset testable on the live site with zero deploys.
|
||||
| `machine.seesaw` | the plank | the fulcrum |
|
||||
| `course.scenery.cereal` | the giant cereal box | — |
|
||||
| `course.scenery.block` | the purple block | — |
|
||||
| `course.finish` | the pink finish podium | — |
|
||||
| `course.tunnel` | the MINI tunnel roof slab | the warning bar and the posts |
|
||||
| `course.tramp` | the orange gap launcher pad | — |
|
||||
| `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.
|
||||
|
||||
`course.tramp` is built in `src/game.ts`, which is frozen for the workshop
|
||||
lanes; the id and label exist so the editor can offer it, and integration wires
|
||||
the hook. Until it does, dropping a file on that slot changes nothing.
|
||||
|
||||
## One slot, many objects
|
||||
|
||||
Most slots are instanced more than once — nine `fx.puddle` strips, six cannons,
|
||||
every machine of a given kind. **Each instance gets its own clone of the asset
|
||||
at its own pose, with its own materials.** That is the contract both the runtime
|
||||
and the editor follow: dropping one puddle model reskins all nine puddles, and
|
||||
tinting one of them (each puddle keeps its zone colour) cannot bleed into the
|
||||
others. Verified in `src/assets/harden.test.ts`.
|
||||
|
||||
Single-instance slots — the ones the game builds exactly once — are listed in
|
||||
`SINGLE_INSTANCE_SLOTS` in `src/assets/slots.ts`: `blob.body`, `blob.face`,
|
||||
`ghost.body`, `course.finish`, `course.tunnel` and the two `course.scenery.*`.
|
||||
|
||||
## What gets rejected, and what only gets a warning
|
||||
|
||||
| Asset | Outcome |
|
||||
|---|---|
|
||||
| GLB fails to load / 404s | slot keeps its built-in version, one warning |
|
||||
| GLB takes longer than 10 s | boot continues on the built-in version, one warning |
|
||||
| GLB contains no mesh (armature/empty/camera only) | slot keeps its built-in version — the primitive is **never** hidden for a replacement that isn't there |
|
||||
| `blob.body` with a skeleton/armature | **rejected**, built-in blob kept (see below) |
|
||||
| `blob.body` with no UVs, UVs outside 0..1, or >1 material | rejected, built-in blob kept |
|
||||
| `blob.body` whose UVs are an auto-atlas | **accepted with a warning** — it paints, just messily |
|
||||
|
||||
### Rigged bodies are not supported
|
||||
|
||||
A `blob.body` that is a `SkinnedMesh` is rejected and the built-in blob is kept.
|
||||
This is a real limitation, not a missing check: `createBlob` re-parents the body
|
||||
mesh alone into its own group, so the GLB's bones are left outside the scene
|
||||
graph and never get a `matrixWorld` update — the pose would never advance. And
|
||||
the body's geometry is re-centred and re-scaled to the collider radius, which
|
||||
invalidates the skeleton's bind matrices, so any deformation that did resolve
|
||||
would be offset. `idleClip` is ignored for the same reason and no animation
|
||||
mixer is created — animating an invisible skeleton in the fixed step would cost
|
||||
CPU for nothing. Export the body with the armature applied.
|
||||
|
||||
### The atlas warning
|
||||
|
||||
`blob.body` reports `uvCharts` (connected UV islands) and `seamRatio`. UVs
|
||||
inside 0..1 with one material is **not** enough: the farm's own
|
||||
`blobbo-base.glb` passes both and still paints wrong, because its unwrap is a
|
||||
1140-island auto-atlas (29% of its vertices sit on a seam) and a splat disc in
|
||||
UV space therefore speckles a thousand unrelated triangles. Above 24 islands the
|
||||
report says so. For reference, three's `SphereGeometry` is 3 islands and a
|
||||
`BoxGeometry` is 6 — a hand unwrap is nowhere near the threshold.
|
||||
|
||||
## Modelling conventions
|
||||
|
||||
Y-up, metres, one material, embedded textures ≤ 2048².
|
||||
@ -94,6 +151,14 @@ Y-up, metres, one material, embedded textures ≤ 2048².
|
||||
- **`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.
|
||||
- **`ghost.body`** — if you leave it empty the ghost borrows `blob.body` and is
|
||||
put through the exact same fit, so it always matches the blob's size. Fill it
|
||||
only when you want the ghost to look like something else; then its own
|
||||
`offset`/`rotation`/`scale` apply normally.
|
||||
- **`course.tunnel`** — roof slab only. Model its underside FLAT at the bottom
|
||||
of the model: the clearance a MINI blob squeezes through is the collider's and
|
||||
never changes, so a model that hangs lower than the collider will look like it
|
||||
blocks a gap it does not.
|
||||
- **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
|
||||
@ -113,15 +178,44 @@ 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.
|
||||
|
||||
`initAssets()` cannot hang the boot: every url in the manifest is raced against
|
||||
a 10 s deadline and settled independently, so a stalled host costs one warning
|
||||
and the built-in prop rather than a game that never starts.
|
||||
|
||||
`course.tramp` still needs one line in `game.ts`, next to where the trampoline
|
||||
pad mesh is built:
|
||||
|
||||
```ts
|
||||
assets().attachSlot('course.tramp', trampMesh, {
|
||||
onSwap: () => { (trampMesh.material as THREE.Material).visible = false },
|
||||
})
|
||||
```
|
||||
|
||||
## 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 src/assets/harden.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
|
||||
`harden.test.ts` is the regression net for the failure modes that used to be
|
||||
silent: a mesh-less GLB blanking a slot, a throw escaping the swap path, a
|
||||
stalled url hanging the boot, an oversized ghost, a rigged body being accepted,
|
||||
and one dropped asset serving nine puddles.
|
||||
|
||||
The sacred property — empty manifest builds today's game — has its own check:
|
||||
|
||||
```
|
||||
./node_modules/.bin/esbuild scripts/sacred-parity.check.ts --bundle \
|
||||
--platform=node --format=esm --outfile=/tmp/sacred.mjs && node /tmp/sacred.mjs
|
||||
```
|
||||
|
||||
It fingerprints every node of the scene the course, zones, blob and a cannon
|
||||
build with an empty registry. The number must not move.
|
||||
|
||||
The farm check 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.
|
||||
|
||||
@ -38,9 +38,18 @@ implement every slot listed there except the UI rows.
|
||||
- The delivered mesh's material `map` is replaced by PaintSkin's canvas
|
||||
texture at integration (skin binds to whatever mesh is present); ensure
|
||||
the hook exposes the swapped-in paintable mesh as `blob.mesh` (the skin
|
||||
and buffs read it). If the GLB is a SkinnedMesh with `idleClip`, drive an
|
||||
AnimationMixer in a fixed-step System (NOT onFrame — hidden-tab rule, see
|
||||
telegraph.ts docstring for why).
|
||||
and buffs read it).
|
||||
|
||||
**NOT BUILT — SkinnedMesh + `idleClip` was authored here and does not work.**
|
||||
`createBlob` re-parents the body MESH alone into its own group, leaving the
|
||||
GLB's bones outside the scene graph with no `matrixWorld` update, so the
|
||||
clip animates nothing while burning fixed-step CPU; and the body's geometry
|
||||
is re-centred/re-scaled to the collider radius, which invalidates the
|
||||
skeleton's bind matrices. `paintableInfo` therefore REJECTS a skinned
|
||||
`blob.body` (`ok` requires `!skinned`), `idleClip` is ignored with a warning
|
||||
and no AnimationMixer is created. Making it real means keeping the GLB root
|
||||
in the scene graph and fitting via a parent node instead of baking into the
|
||||
geometry — a design change, not a bug fix.
|
||||
- UV warning: if `paintableInfo.uvOk` is false, console.warn and keep the
|
||||
procedural sphere instead (paint must never silently break).
|
||||
4. `assets/manifest.json` — ship EMPTY `{}` (packs come later via integration).
|
||||
|
||||
63
scripts/sacred-parity.check.ts
Normal file
63
scripts/sacred-parity.check.ts
Normal file
@ -0,0 +1,63 @@
|
||||
// THE SACRED PROPERTY: empty manifest + empty IndexedDB must construct exactly
|
||||
// what the game constructed before the workshop existed. Fingerprints the whole
|
||||
// scene graph the course + zones + a cannon build, so any drift shows up as a
|
||||
// changed hash. Run against main and against the branch; the numbers must match.
|
||||
//
|
||||
// It has to be BUNDLED rather than strip-typed: the game modules use TypeScript
|
||||
// parameter properties, which node's --experimental-strip-types refuses.
|
||||
//
|
||||
// ./node_modules/.bin/esbuild scripts/sacred-parity.check.ts --bundle \
|
||||
// --platform=node --format=esm --outfile=/tmp/sacred.mjs && node /tmp/sacred.mjs
|
||||
//
|
||||
// Measured on fix-runtime and on main at f827f6f, both:
|
||||
// nodes=49 colliders=15 bodies=3 greyboxMeshes=11 blobRadius=0.500000
|
||||
// sceneFingerprint=49df4f20
|
||||
import * as THREE from 'three'
|
||||
import RAPIER from '@dimforge/rapier3d-compat'
|
||||
import { buildGreybox } from '../src/course/greybox.ts'
|
||||
import { installZones } from '../src/course/zones.ts'
|
||||
import { PaintCannon } from '../src/paint/cannon.ts'
|
||||
import { AssetRegistry, setAssets } from '../src/assets/registry.ts'
|
||||
import { createBlob } from '../src/blob/createBlob.ts'
|
||||
|
||||
setAssets(new AssetRegistry({})) // empty manifest, no IndexedDB
|
||||
await RAPIER.init()
|
||||
|
||||
const scene = new THREE.Scene()
|
||||
const physics = new RAPIER.World({ x: 0, y: -9.81, z: 0 })
|
||||
const listeners: any = {}
|
||||
const world: any = {
|
||||
scene, physics, rapier: RAPIER,
|
||||
events: { on: (n: string, f: any) => { (listeners[n] ||= []).push(f) }, emit: () => {} },
|
||||
addSystem: () => {}, onFrame: () => {}, tick: () => {}, renderOnce: () => {}, start: () => {},
|
||||
}
|
||||
|
||||
const gb = buildGreybox(world)
|
||||
const blob = createBlob(world, {})
|
||||
world.blob = blob
|
||||
const skin: any = { boundingRadius: 0.5, splat: () => {}, splatAtWorldPoint: () => {}, coverage: () => ({ total: 0 }) }
|
||||
installZones(world, blob, skin)
|
||||
new PaintCannon({ world, color: 'red', position: new THREE.Vector3(0, 2, 0), target: blob.mesh, paint: skin, targetRadius: 0.5 })
|
||||
|
||||
await new Promise((r) => setTimeout(r, 50)) // let any async swap settle
|
||||
|
||||
function fp(root: THREE.Object3D) {
|
||||
const parts: string[] = []
|
||||
root.traverse((o) => {
|
||||
const m = o as THREE.Mesh
|
||||
const mat: any = Array.isArray(m.material) ? m.material[0] : m.material
|
||||
parts.push([
|
||||
o.type, o.name, o.visible ? 1 : 0,
|
||||
o.position.toArray().map((n) => n.toFixed(4)).join(','),
|
||||
o.scale.toArray().map((n) => n.toFixed(4)).join(','),
|
||||
m.isMesh ? (m.geometry.getAttribute('position')?.count ?? 0) : '-',
|
||||
mat ? `${mat.type}:${mat.color?.getHexString?.() ?? ''}:${mat.visible ? 1 : 0}` : '-',
|
||||
].join('|'))
|
||||
})
|
||||
return parts
|
||||
}
|
||||
const lines = fp(scene)
|
||||
let h = 0
|
||||
for (const s of lines) for (let i = 0; i < s.length; i++) h = (Math.imul(31, h) + s.charCodeAt(i)) | 0
|
||||
console.log(`nodes=${lines.length} colliders=${physics.colliders.len()} bodies=${physics.bodies.len()} greyboxMeshes=${gb.meshes.length} blobRadius=${blob.mesh.geometry.boundingSphere?.radius?.toFixed(6) ?? (blob.mesh.geometry.computeBoundingSphere(), blob.mesh.geometry.boundingSphere!.radius.toFixed(6))}`)
|
||||
console.log(`sceneFingerprint=${(h >>> 0).toString(16)}`)
|
||||
@ -1,7 +1,7 @@
|
||||
/**
|
||||
* 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
|
||||
* PaintSkin stamps into a canvas texture 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
|
||||
@ -11,18 +11,76 @@
|
||||
* 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'
|
||||
import type { World } from '../contracts'
|
||||
import { assets, collectMeshes, normalizeToRadius, paintableInfo } from './registry'
|
||||
import type { SlotEntry } from './manifest'
|
||||
|
||||
/**
|
||||
* Bake a body asset down to ONE mesh sitting at the origin at exactly `radius`.
|
||||
*
|
||||
* Shared between `resolveBlobBody` and the ghost's borrow of `blob.body`,
|
||||
* because the fit is not decoration: `boundingRadius` is a gameplay number for
|
||||
* the blob, and for the ghost a body that skips this renders at the raw GLB's
|
||||
* size — 2.17x oversized with the farm's blobbo-base.glb, measured.
|
||||
*
|
||||
* Every transform between `root` and the mesh (and `root`'s own) is baked into
|
||||
* the geometry and then zeroed, so no ancestor can re-scale the body after it
|
||||
* has been fitted. `entry.scale` is deliberately dropped: the body is always
|
||||
* the size of its collider.
|
||||
*/
|
||||
export function fitBodyToRadius(
|
||||
root: THREE.Object3D,
|
||||
radius: number,
|
||||
entry?: Pick<SlotEntry, 'offset' | 'rotationDeg'>,
|
||||
): THREE.Mesh | null {
|
||||
const mesh = collectMeshes(root)[0]
|
||||
if (!mesh) return null
|
||||
|
||||
root.updateMatrixWorld(true)
|
||||
const geo = mesh.geometry.clone()
|
||||
// Mesh pose relative to root, so a GLB that parks its mesh under a
|
||||
// transformed node is fitted the same as one that does not.
|
||||
const rel = new THREE.Matrix4().copy(root.matrixWorld).invert().multiply(mesh.matrixWorld)
|
||||
geo.applyMatrix4(rel)
|
||||
|
||||
if (entry?.offset || entry?.rotationDeg) {
|
||||
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]
|
||||
geo.applyMatrix4(new THREE.Matrix4().compose(
|
||||
new THREE.Vector3(off[0], off[1], off[2]),
|
||||
new THREE.Quaternion().setFromEuler(rot),
|
||||
new THREE.Vector3(1, 1, 1),
|
||||
))
|
||||
}
|
||||
|
||||
normalizeToRadius(geo, radius)
|
||||
mesh.geometry = geo
|
||||
|
||||
for (let o: THREE.Object3D | null = mesh; o; o = o === root ? null : o.parent) {
|
||||
o.position.set(0, 0, 0)
|
||||
o.rotation.set(0, 0, 0)
|
||||
o.scale.set(1, 1, 1)
|
||||
o.updateMatrix()
|
||||
}
|
||||
root.updateMatrixWorld(true)
|
||||
return mesh
|
||||
}
|
||||
|
||||
/**
|
||||
* 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` is unused since the idle-clip mixer was removed, but the signature
|
||||
// is called from createBlob and the editor — keep it stable.
|
||||
world: World,
|
||||
radius: number,
|
||||
buildFallback: () => THREE.Mesh,
|
||||
): THREE.Mesh {
|
||||
void world
|
||||
const reg = assets()
|
||||
const inst = reg.instanceSync('blob.body')
|
||||
if (!inst) {
|
||||
@ -47,34 +105,22 @@ export function resolveBlobBody(
|
||||
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
|
||||
if (entry.idleClip !== undefined) {
|
||||
console.warn(
|
||||
'[assets] blob.body "idleClip" is ignored — animated bodies are not supported yet. ' +
|
||||
'The squash-and-stretch on the blob group still plays.',
|
||||
)
|
||||
}
|
||||
|
||||
// Bake the fit into the geometry rather than a parent node: feel.ts owns this
|
||||
// mesh's whole local transform and would clobber a node, and PaintSkin
|
||||
// raycasts in the mesh's own space.
|
||||
const mesh = fitBodyToRadius(inst.object, radius, entry)
|
||||
if (!mesh) return buildFallback()
|
||||
|
||||
// feel.ts writes `emissiveIntensity` for the super-glow and PaintSkin forces
|
||||
// the colour white; an imported material usually has neither set up.
|
||||
@ -84,41 +130,5 @@ export function resolveBlobBody(
|
||||
}
|
||||
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)
|
||||
}
|
||||
|
||||
302
src/assets/harden.test.ts
Normal file
302
src/assets/harden.test.ts
Normal file
@ -0,0 +1,302 @@
|
||||
/**
|
||||
* Headless reproductions of the asset-runtime failures found in review. Run:
|
||||
* node --import ./scripts/ts-resolve.mjs --experimental-strip-types src/assets/harden.test.ts
|
||||
*
|
||||
* Each block first states the OBSERVABLE failure it is pinning down, because
|
||||
* every one of these was reproducible before the fix and silent in a browser.
|
||||
* No network and no renderer: assets are injected straight into the registry's
|
||||
* cache, which is exactly the state `preload()` leaves it in.
|
||||
*/
|
||||
import * as THREE from 'three'
|
||||
import type { World } from '../contracts'
|
||||
import { AssetRegistry, setAssets, paintableInfo, uvLayoutInfo, collectMeshes } from './registry'
|
||||
import { resolveBlobBody, fitBodyToRadius } from './blobBody'
|
||||
import { validateManifest } from './manifest'
|
||||
import { SLOT_IDS, SLOT_LABELS } from './slots'
|
||||
|
||||
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-5): void {
|
||||
ok(Math.abs(a - b) <= eps, `${msg} (got ${a}, want ${b})`)
|
||||
}
|
||||
|
||||
/** Put a scene in the registry's cache as if preload() had fetched it. */
|
||||
function inject(reg: AssetRegistry, url: string, scene: THREE.Group): void {
|
||||
const asset = { scene, animations: [] as THREE.AnimationClip[] }
|
||||
const priv = reg as unknown as {
|
||||
cache: Map<string, Promise<unknown>>
|
||||
resolved: Map<string, unknown>
|
||||
}
|
||||
priv.cache.set(url, Promise.resolve(asset))
|
||||
priv.resolved.set(url, asset)
|
||||
}
|
||||
|
||||
function sphereGlb(radius = 1): THREE.Group {
|
||||
const g = new THREE.Group()
|
||||
g.add(new THREE.Mesh(new THREE.SphereGeometry(radius, 24, 18), new THREE.MeshStandardMaterial()))
|
||||
return g
|
||||
}
|
||||
|
||||
const warnings: string[] = []
|
||||
const realWarn = console.warn
|
||||
console.warn = (...a: unknown[]) => { warnings.push(a.map(String).join(' ')) }
|
||||
function drainWarnings(): string[] {
|
||||
const out = warnings.slice()
|
||||
warnings.length = 0
|
||||
return out
|
||||
}
|
||||
const tick = (): Promise<void> => new Promise((r) => setTimeout(r, 0))
|
||||
|
||||
// ---- B1: a mesh-less GLB must not blank the slot ----------------------------
|
||||
// Before: onSwap fired unconditionally, so greybox hid the box material and
|
||||
// added nothing — an invisible prop with a live collider.
|
||||
{
|
||||
const reg = new AssetRegistry({ 'course.scenery.block': { url: 'rig-only.glb' } })
|
||||
const empty = new THREE.Group()
|
||||
empty.add(new THREE.Object3D()) // an armature/empty, no Mesh anywhere
|
||||
inject(reg, 'rig-only.glb', empty)
|
||||
|
||||
const box = new THREE.Mesh(new THREE.BoxGeometry(1, 1, 1), new THREE.MeshStandardMaterial())
|
||||
let swapped = false
|
||||
reg.attachSlot('course.scenery.block', box, {
|
||||
onSwap: () => { swapped = true; (box.material as THREE.Material).visible = false },
|
||||
})
|
||||
await tick()
|
||||
ok(!swapped, 'a mesh-less GLB never fires onSwap')
|
||||
ok((box.material as THREE.Material).visible, 'the procedural box is still visible')
|
||||
ok(box.children.length === 0, 'no empty fit node was parented')
|
||||
ok(drainWarnings().some((w) => w.includes('has no mesh')), 'it says so, once')
|
||||
}
|
||||
|
||||
// blob.face goes through slotObject, which is the same guard one level up.
|
||||
{
|
||||
const reg = new AssetRegistry({ 'blob.face': { url: 'rig-only.glb' } })
|
||||
inject(reg, 'rig-only.glb', new THREE.Group())
|
||||
const eyes = new THREE.Object3D()
|
||||
const holder = reg.slotObject('blob.face', () => eyes)
|
||||
await tick()
|
||||
ok(holder.children.includes(eyes), 'the eyes survive a mesh-less blob.face asset')
|
||||
drainWarnings()
|
||||
}
|
||||
|
||||
// ---- B2: nothing thrown in the swap path may escape or half-swap ------------
|
||||
{
|
||||
// (a) a throw BEFORE onSwap leaves the primitive visible and adds nothing.
|
||||
const reg = new AssetRegistry({ 'machine.boot': { url: 'bad.glb' } })
|
||||
const scene = sphereGlb()
|
||||
// A material that explodes on clone() — instantiate()'s per-instance clone.
|
||||
const mesh = collectMeshes(scene)[0]
|
||||
;(mesh.material as THREE.Material).clone = () => { throw new Error('boom in clone') }
|
||||
inject(reg, 'bad.glb', scene)
|
||||
|
||||
const host = new THREE.Object3D()
|
||||
let swapped = false
|
||||
reg.attachSlot('machine.boot', host, { onSwap: () => { swapped = true } })
|
||||
await tick()
|
||||
ok(!swapped, 'a throw during instancing never reaches onSwap')
|
||||
ok(host.children.length === 0, 'nothing half-parented')
|
||||
ok(drainWarnings().some((w) => w.includes('keeping the built-in version')), 'warned instead of rejecting a promise')
|
||||
}
|
||||
{
|
||||
// (b) a throw INSIDE onSwap must not remove the replacement. Consumers hide
|
||||
// their primitive on onSwap's first line, so removing the fit node here would
|
||||
// manufacture the exact "primitive hidden, replacement missing" state.
|
||||
const reg = new AssetRegistry({ 'fx.puddle': { url: 'ok.glb' } })
|
||||
inject(reg, 'ok.glb', sphereGlb())
|
||||
const slab = new THREE.Mesh(new THREE.BoxGeometry(1, 1, 1), new THREE.MeshStandardMaterial())
|
||||
reg.attachSlot('fx.puddle', slab, {
|
||||
onSwap: () => {
|
||||
;(slab.material as THREE.Material).visible = false // consumers do this FIRST
|
||||
throw new Error('boom in onSwap')
|
||||
},
|
||||
})
|
||||
await tick()
|
||||
ok(slab.children.length === 1, 'the replacement stays in the scene when onSwap throws')
|
||||
ok(collectMeshes(slab.children[0]).length === 1, 'and it still contains the mesh')
|
||||
ok(drainWarnings().some((w) => w.includes('could not be re-styled')), 'the styling failure is reported')
|
||||
}
|
||||
{
|
||||
// (c) instanceSync is called from createBlob, which frozen game.ts calls with
|
||||
// no try/catch — a throw there is a black screen, not a missing prop.
|
||||
const reg = new AssetRegistry({ 'blob.body': { url: 'bad.glb' } })
|
||||
const scene = sphereGlb()
|
||||
const mesh = collectMeshes(scene)[0]
|
||||
;(mesh.material as THREE.Material).clone = () => { throw new Error('boom in clone') }
|
||||
inject(reg, 'bad.glb', scene)
|
||||
setAssets(reg)
|
||||
|
||||
const built = new THREE.Mesh(new THREE.SphereGeometry(0.5, 48, 36), new THREE.MeshStandardMaterial())
|
||||
const fakeWorld = { addSystem: () => {} } as unknown as World
|
||||
const out = resolveBlobBody(fakeWorld, 0.5, () => built)
|
||||
ok(out === built, 'a throwing blob.body falls back to the built-in blob instead of killing the boot')
|
||||
ok(reg.instanceSync('blob.body') === null, 'instanceSync returns null rather than throwing')
|
||||
drainWarnings()
|
||||
}
|
||||
|
||||
// ---- B3: preload must not hang the boot -------------------------------------
|
||||
{
|
||||
const reg = new AssetRegistry({ 'machine.fan': { url: 'stalled.glb' } })
|
||||
const priv = reg as unknown as { cache: Map<string, Promise<unknown>> }
|
||||
priv.cache.set('stalled.glb', new Promise(() => { /* a host that never answers */ }))
|
||||
const t0 = Date.now()
|
||||
await reg.preload(60) // same code path, a deadline you can wait for in a test
|
||||
const dt = Date.now() - t0
|
||||
ok(dt < 2000, `preload gave up on a stalled url in ${dt}ms instead of never resolving`)
|
||||
ok(drainWarnings().some((w) => w.includes('did not finish loading')), 'the stalled url is named')
|
||||
ok(reg.instanceSync('machine.fan') === null, 'the slot simply stays on its fallback')
|
||||
}
|
||||
{
|
||||
// allSettled semantics: one bad url must not starve a good one.
|
||||
const reg = new AssetRegistry({
|
||||
'machine.fan': { url: 'stalled.glb' },
|
||||
'machine.belt': { url: 'good.glb' },
|
||||
})
|
||||
const priv = reg as unknown as { cache: Map<string, Promise<unknown>> }
|
||||
priv.cache.set('stalled.glb', new Promise(() => {}))
|
||||
inject(reg, 'good.glb', sphereGlb())
|
||||
await reg.preload(60)
|
||||
ok(reg.instanceSync('machine.belt') !== null, 'the healthy asset still loaded')
|
||||
drainWarnings()
|
||||
}
|
||||
|
||||
// ---- ghost sizing -----------------------------------------------------------
|
||||
// Before: the ghost borrowed blob.body raw, so a 1.084-radius farm mesh
|
||||
// rendered the ghost 2.17x the blob it is meant to mirror.
|
||||
{
|
||||
const raw = sphereGlb(1.0841) // blobbo-base.glb's measured bounding radius
|
||||
const fit = new THREE.Group()
|
||||
fit.scale.setScalar(3) // a fit node built from a manifest `scale`, as attachSlot builds it
|
||||
fit.add(raw)
|
||||
const mesh = fitBodyToRadius(fit, 0.5)
|
||||
ok(mesh !== null, 'the shared fit found the body mesh')
|
||||
fit.updateMatrixWorld(true)
|
||||
const box = new THREE.Box3().setFromObject(fit)
|
||||
const size = new THREE.Vector3()
|
||||
box.getSize(size)
|
||||
near(size.x / 2, 0.5, 'the ghost body ends up at the blob radius in WORLD space')
|
||||
near(box.getCenter(new THREE.Vector3()).length(), 0, 'and centred on the ghost origin')
|
||||
}
|
||||
{
|
||||
// The blob and the ghost must agree for the same asset — that is the point.
|
||||
const reg = new AssetRegistry({ 'blob.body': { url: 'body.glb', scale: 4, offset: [3, 0, 0] } })
|
||||
inject(reg, 'body.glb', sphereGlb(1.0841))
|
||||
setAssets(reg)
|
||||
const fakeWorld = { addSystem: () => {} } as unknown as World
|
||||
const blobMesh = resolveBlobBody(fakeWorld, 0.5, () => new THREE.Mesh())
|
||||
blobMesh.geometry.computeBoundingSphere()
|
||||
|
||||
const ghostInst = reg.instanceSync('blob.body')!
|
||||
const ghostFit = new THREE.Group()
|
||||
ghostFit.add(ghostInst.object)
|
||||
const ghostMesh = fitBodyToRadius(ghostFit, 0.5, ghostInst.entry)!
|
||||
ghostMesh.geometry.computeBoundingSphere()
|
||||
near(
|
||||
ghostMesh.geometry.boundingSphere!.radius,
|
||||
blobMesh.geometry.boundingSphere!.radius,
|
||||
'ghost and blob resolve to the same radius from the same asset',
|
||||
)
|
||||
drainWarnings()
|
||||
}
|
||||
|
||||
// ---- skinned blob.body is rejected loudly, not repaired ---------------------
|
||||
{
|
||||
const geo = new THREE.SphereGeometry(0.5, 16, 12)
|
||||
const bone = new THREE.Bone()
|
||||
const skinned = new THREE.SkinnedMesh(geo, new THREE.MeshStandardMaterial())
|
||||
skinned.add(bone)
|
||||
const info = paintableInfo(skinned)
|
||||
ok(info.skinned, 'the skeleton is detected')
|
||||
ok(!info.ok, 'a rigged body is REJECTED — the skeleton is orphaned by the design and normalising invalidates the bind matrices')
|
||||
ok(info.problems.some((p) => p.includes('skeleton/armature')), 'in plain language')
|
||||
}
|
||||
{
|
||||
// ...and no AnimationMixer system is registered for it, so we are not burning
|
||||
// fixed-step CPU animating something that is not in the scene graph.
|
||||
const reg = new AssetRegistry({ 'blob.body': { url: 'body.glb', idleClip: 'Idle' } })
|
||||
inject(reg, 'body.glb', sphereGlb())
|
||||
setAssets(reg)
|
||||
const systems: unknown[] = []
|
||||
const fakeWorld = { addSystem: (s: unknown) => systems.push(s) } as unknown as World
|
||||
resolveBlobBody(fakeWorld, 0.5, () => new THREE.Mesh())
|
||||
ok(systems.length === 0, 'idleClip registers no fixed-step system')
|
||||
ok(drainWarnings().some((w) => w.includes('idleClip')), 'and says the clip is ignored')
|
||||
}
|
||||
|
||||
// ---- UV atlas detection -----------------------------------------------------
|
||||
// blobbo-base.glb passes every other check (0..1 UVs, one material) and still
|
||||
// paints wrong: it is a 1140-island auto-atlas.
|
||||
{
|
||||
const sphere = uvLayoutInfo(new THREE.SphereGeometry(0.5, 48, 36))
|
||||
ok(sphere.charts === 3, `three's own sphere is 3 charts (body + 2 pole fans), got ${sphere.charts}`)
|
||||
ok(paintableInfo(new THREE.Mesh(new THREE.SphereGeometry(0.5, 48, 36), new THREE.MeshStandardMaterial())).problems.length === 0,
|
||||
'the stock blob is NOT flagged as an atlas')
|
||||
|
||||
// A shattered layout: same sphere, every triangle its own island.
|
||||
const shattered = new THREE.SphereGeometry(0.5, 24, 18).toNonIndexed()
|
||||
const idx: number[] = []
|
||||
for (let i = 0; i < shattered.getAttribute('position').count; i++) idx.push(i)
|
||||
shattered.setIndex(idx)
|
||||
const info = uvLayoutInfo(shattered)
|
||||
ok(info.charts > 24, `a per-triangle atlas is detected (${info.charts} charts)`)
|
||||
const p = paintableInfo(new THREE.Mesh(shattered, new THREE.MeshStandardMaterial()))
|
||||
ok(p.problems.some((s) => s.includes('islands')), 'and reported as a scatter risk')
|
||||
ok(p.ok, 'but still accepted — it paints untidily, it does not fail to paint')
|
||||
}
|
||||
|
||||
// ---- manifest metadata keys -------------------------------------------------
|
||||
{
|
||||
const res = validateManifest({
|
||||
_readme: 'exported by the BLOBBO workshop',
|
||||
_generatedBy: 'workshop 1.0',
|
||||
'machine.boot': { url: 'a.glb' },
|
||||
'machine.tractor': { url: 'b.glb' },
|
||||
})
|
||||
ok(res.problems.length === 1, `only the real typo is reported (got ${res.problems.length})`)
|
||||
ok(res.problems[0].slot === 'machine.tractor', 'and it is the typo, not the metadata')
|
||||
ok(Object.keys(res.manifest).length === 1, 'metadata never becomes a slot')
|
||||
}
|
||||
|
||||
// ---- slot vocabulary --------------------------------------------------------
|
||||
{
|
||||
for (const id of ['cannon.base', 'course.finish', 'course.tunnel', 'course.tramp'] as const) {
|
||||
ok(SLOT_IDS.includes(id), `${id} is in the canonical list the editor imports`)
|
||||
ok(typeof SLOT_LABELS[id] === 'string' && SLOT_LABELS[id].length > 0, `${id} has a label`)
|
||||
}
|
||||
ok(SLOT_IDS.length === new Set(SLOT_IDS).size, 'no duplicate slot ids')
|
||||
ok(SLOT_IDS.every((id) => SLOT_LABELS[id]), 'every slot id has a label')
|
||||
}
|
||||
|
||||
// ---- multi-instance slots ---------------------------------------------------
|
||||
// Nine puddles and six cannons share one slot id. Each must get its OWN clone at
|
||||
// its OWN pose — one shared node would teleport eight puddles onto the ninth.
|
||||
{
|
||||
const reg = new AssetRegistry({ 'fx.puddle': { url: 'decal.glb' } })
|
||||
inject(reg, 'decal.glb', sphereGlb(0.5))
|
||||
const slabs = [-10, 0, 10].map((x) => {
|
||||
const m = new THREE.Mesh(new THREE.BoxGeometry(1, 1, 1), new THREE.MeshStandardMaterial())
|
||||
m.position.set(x, 0, 0)
|
||||
return m
|
||||
})
|
||||
for (const s of slabs) {
|
||||
reg.attachSlot('fx.puddle', s, {
|
||||
fit: new THREE.Vector3(2, 1, 2),
|
||||
onSwap: () => { (s.material as THREE.Material).visible = false },
|
||||
})
|
||||
}
|
||||
await tick()
|
||||
const roots = slabs.map((s) => s.children[0])
|
||||
ok(roots.every((r) => r !== undefined), 'every instance got a replacement')
|
||||
ok(new Set(roots).size === 3, 'three distinct fit nodes, not one shared node')
|
||||
const mats = slabs.map((s) => collectMeshes(s)[0].material)
|
||||
ok(new Set(mats).size === 3, 'and three distinct materials, so per-instance tinting cannot bleed')
|
||||
const world = new THREE.Vector3()
|
||||
collectMeshes(slabs[2])[0].getWorldPosition(world)
|
||||
near(world.x, 10, 'each clone sits at its own instance pose')
|
||||
drainWarnings()
|
||||
}
|
||||
|
||||
console.warn = realWarn
|
||||
console.log(`harden.test.ts: ${passed} checks passed`)
|
||||
@ -63,6 +63,11 @@ export function validateManifest(raw: unknown): ValidationResult {
|
||||
}
|
||||
|
||||
for (const [key, value] of Object.entries(raw as Record<string, unknown>)) {
|
||||
// `_`-prefixed keys are metadata, not slots: the editor writes `_readme`
|
||||
// and `_generatedBy` into every export so a hand-opened manifest explains
|
||||
// itself. Reporting them as "unknown slot id" would train John to ignore
|
||||
// the problem list, which is the one place real typos show up.
|
||||
if (key.startsWith('_')) continue
|
||||
if (!isSlotId(key)) {
|
||||
problems.push({ slot: key, reason: 'unknown slot id' })
|
||||
continue
|
||||
|
||||
@ -27,13 +27,21 @@ 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. */
|
||||
/** A single unrigged Mesh with a single material and 0..1 UVs was found. */
|
||||
ok: boolean
|
||||
uvOk: boolean
|
||||
triCount: number
|
||||
materialCount: number
|
||||
meshCount: number
|
||||
skinned: boolean
|
||||
/**
|
||||
* Connected components of the UV layout. A single hand-unwrapped island is 1;
|
||||
* three's own SphereGeometry is 3 (body + two pole fans). An auto-atlas is
|
||||
* hundreds. -1 when the geometry is non-indexed (chart count is undefined).
|
||||
*/
|
||||
uvCharts: number
|
||||
/** Fraction of vertices whose position is shared but whose UV is not. */
|
||||
seamRatio: number
|
||||
/** Plain-language reasons the asset is not paint-safe (editor surface). */
|
||||
problems: string[]
|
||||
}
|
||||
@ -55,6 +63,20 @@ const loader = new GLTFLoader()
|
||||
/** How far outside 0..1 a UV may stray before the layout is judged unpaintable. */
|
||||
const UV_MARGIN = 0.1
|
||||
|
||||
/**
|
||||
* Above this many UV islands the layout is an auto-atlas rather than an unwrap.
|
||||
* Measured: three's SphereGeometry is 3 charts, a BoxGeometry 6, a hand unwrap a
|
||||
* handful; the farm's blobbo-base.glb is 1140. A hand-authored model has no
|
||||
* business being near this number.
|
||||
*/
|
||||
const MAX_UV_CHARTS = 24
|
||||
|
||||
/** Above this many vertices, skip the chart walk rather than stall the boot. */
|
||||
const CHART_VERT_LIMIT = 400_000
|
||||
|
||||
/** Per-url budget in `preload`. Boot awaits preload, so this is a boot budget. */
|
||||
export const PRELOAD_TIMEOUT_MS = 10_000
|
||||
|
||||
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)
|
||||
@ -112,22 +134,90 @@ export function ensureStandardMaterials(root: THREE.Object3D): number {
|
||||
return converted
|
||||
}
|
||||
|
||||
/** Every Mesh under `root`, in traversal order. */
|
||||
export function collectMeshes(root: THREE.Object3D): THREE.Mesh[] {
|
||||
const out: THREE.Mesh[] = []
|
||||
root.traverse((o) => {
|
||||
if ((o as THREE.Mesh).isMesh) out.push(o as THREE.Mesh)
|
||||
})
|
||||
return out
|
||||
}
|
||||
|
||||
/**
|
||||
* How the UVs are laid out, which is what decides whether a splat DISC lands on
|
||||
* one patch of the model or speckles it.
|
||||
*
|
||||
* `uvOk` (0..1 range) is necessary but nowhere near sufficient: the farm's own
|
||||
* blobbo-base.glb has every UV inside 0..1 and exactly one material, yet its
|
||||
* unwrap is a 1140-island auto-atlas, so a circle stamped in UV space bleeds
|
||||
* onto a thousand unrelated triangles. Charts are counted as connected
|
||||
* components of the INDEX graph — a UV seam duplicates the position vertex, so
|
||||
* islands fall out of the topology for free.
|
||||
*/
|
||||
export function uvLayoutInfo(geo: THREE.BufferGeometry): { charts: number; seamRatio: number } {
|
||||
const pos = geo.getAttribute('position')
|
||||
const uv = geo.getAttribute('uv')
|
||||
const index = geo.getIndex()
|
||||
const n = pos ? pos.count : 0
|
||||
if (!pos || !uv || !index || n === 0 || n > CHART_VERT_LIMIT) {
|
||||
return { charts: -1, seamRatio: 0 }
|
||||
}
|
||||
|
||||
const parent = new Int32Array(n)
|
||||
for (let i = 0; i < n; i++) parent[i] = i
|
||||
const find = (a: number): number => {
|
||||
let r = a
|
||||
while (parent[r] !== r) { parent[r] = parent[parent[r]]; r = parent[r] }
|
||||
return r
|
||||
}
|
||||
const union = (a: number, b: number): void => {
|
||||
const ra = find(a)
|
||||
const rb = find(b)
|
||||
if (ra !== rb) parent[ra] = rb
|
||||
}
|
||||
for (let i = 0; i + 2 < index.count; i += 3) {
|
||||
const a = index.getX(i)
|
||||
const b = index.getX(i + 1)
|
||||
union(a, b)
|
||||
union(b, index.getX(i + 2))
|
||||
}
|
||||
const roots = new Set<number>()
|
||||
for (let i = 0; i < n; i++) roots.add(find(i))
|
||||
|
||||
// Seam ratio: vertices that share a position with a vertex carrying a
|
||||
// DIFFERENT uv — i.e. the cut edges of the atlas.
|
||||
const byPos = new Map<string, number[]>()
|
||||
for (let i = 0; i < n; i++) {
|
||||
const key = `${pos.getX(i).toFixed(4)},${pos.getY(i).toFixed(4)},${pos.getZ(i).toFixed(4)}`
|
||||
const bucket = byPos.get(key)
|
||||
if (bucket) bucket.push(i)
|
||||
else byPos.set(key, [i])
|
||||
}
|
||||
let split = 0
|
||||
for (const bucket of byPos.values()) {
|
||||
if (bucket.length < 2) continue
|
||||
const seen = new Set<string>()
|
||||
for (const i of bucket) seen.add(`${uv.getX(i).toFixed(4)},${uv.getY(i).toFixed(4)}`)
|
||||
if (seen.size > 1) split += bucket.length
|
||||
}
|
||||
|
||||
return { charts: roots.size, seamRatio: split / n }
|
||||
}
|
||||
|
||||
/**
|
||||
* 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).
|
||||
* one unrigged mesh, one material, a `uv` attribute inside 0..1 laid out as a
|
||||
* small number of islands (the skin's stamp wrap assumes a contiguous unwrap, so
|
||||
* out-of-range, missing or shattered 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 meshes = collectMeshes(object)
|
||||
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'],
|
||||
skinned: false, uvCharts: -1, seamRatio: 0, problems: ['no mesh in the file'],
|
||||
}
|
||||
}
|
||||
if (meshes.length > 1) problems.push(`${meshes.length} meshes — only the first is painted`)
|
||||
@ -145,6 +235,7 @@ export function paintableInfo(object: THREE.Object3D): PaintableInfo {
|
||||
const triCount = index ? index.count / 3 : (pos ? pos.count / 3 : 0)
|
||||
|
||||
const uv = geo.getAttribute('uv')
|
||||
const layout = uv ? uvLayoutInfo(geo) : { charts: -1, seamRatio: 0 }
|
||||
let uvOk = false
|
||||
if (!uv) {
|
||||
problems.push('no UV map — the paint has nowhere to go')
|
||||
@ -166,12 +257,29 @@ export function paintableInfo(object: THREE.Object3D): PaintableInfo {
|
||||
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')
|
||||
if (layout.charts > MAX_UV_CHARTS) {
|
||||
problems.push(
|
||||
`UVs are split into ${layout.charts} separate islands (${Math.round(layout.seamRatio * 100)}% ` +
|
||||
'of the vertices sit on a seam) — this is an auto-generated atlas, so paint may scatter ' +
|
||||
'across this model instead of landing where it is thrown. Unwrap it as one island.',
|
||||
)
|
||||
}
|
||||
if (skinned) {
|
||||
problems.push(
|
||||
'this model has a skeleton/armature — rigged blobs are not supported yet, ' +
|
||||
'so the built-in body is kept. Export it without the armature (apply the pose first).',
|
||||
)
|
||||
}
|
||||
|
||||
return {
|
||||
ok: uvOk && materialCount === 1,
|
||||
// A shattered atlas is a WARNING, not a rejection: it still paints, just
|
||||
// untidily, and rejecting it would refuse the only farm mesh that exists.
|
||||
// A skeleton is a rejection — see installIdleClip in blobBody.ts.
|
||||
ok: uvOk && materialCount === 1 && !skinned,
|
||||
uvOk, triCount: Math.round(triCount), materialCount,
|
||||
meshCount: meshes.length, skinned, problems,
|
||||
meshCount: meshes.length, skinned,
|
||||
uvCharts: layout.charts, seamRatio: layout.seamRatio,
|
||||
problems,
|
||||
}
|
||||
}
|
||||
|
||||
@ -225,10 +333,33 @@ export class AssetRegistry {
|
||||
* 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.
|
||||
*
|
||||
* Every url is raced against a deadline and settled independently. A host that
|
||||
* accepts the connection and never answers would otherwise leave this promise
|
||||
* pending forever, and since boot AWAITS it, the whole game would never start
|
||||
* — turning "one custom prop is missing" into "the game is broken". A slot
|
||||
* whose url times out keeps its procedural fallback; the load itself is left
|
||||
* running, so a late arrival still populates the cache for the next attach.
|
||||
*/
|
||||
async preload(): Promise<void> {
|
||||
async preload(timeoutMs: number = PRELOAD_TIMEOUT_MS): 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)))
|
||||
await Promise.allSettled([...urls].map((u) => this.loadWithDeadline(u, timeoutMs)))
|
||||
}
|
||||
|
||||
private loadWithDeadline(url: string, timeoutMs: number): Promise<unknown> {
|
||||
const load = this.load(url).catch(() => null)
|
||||
if (!(timeoutMs > 0)) return load
|
||||
return new Promise((resolve) => {
|
||||
const timer = setTimeout(() => {
|
||||
this.warnOnce(
|
||||
`timeout:${url}`,
|
||||
`[assets] "${url}" did not finish loading within ${Math.round(timeoutMs / 1000)}s — ` +
|
||||
'starting the game with the built-in version.',
|
||||
)
|
||||
resolve(null)
|
||||
}, timeoutMs)
|
||||
void load.then((v) => { clearTimeout(timer); resolve(v) })
|
||||
})
|
||||
}
|
||||
|
||||
private warnOnce(key: string, ...args: unknown[]): void {
|
||||
@ -265,14 +396,22 @@ export class AssetRegistry {
|
||||
return p
|
||||
}
|
||||
|
||||
/** A cloned, shadow-enabled, standard-material instance of a loaded url. */
|
||||
private instantiate(asset: LoadedAsset): THREE.Group {
|
||||
/**
|
||||
* A cloned, shadow-enabled, standard-material instance of a loaded url, or
|
||||
* null if anything about the file makes three throw.
|
||||
*
|
||||
* Never throws, on purpose: `instanceSync` is called from `createBlob`, which
|
||||
* the frozen game.ts calls with no try/catch of its own. A throw there is a
|
||||
* black screen rather than a missing prop.
|
||||
*/
|
||||
private instantiate(asset: LoadedAsset, url: string): THREE.Group | null {
|
||||
try {
|
||||
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
|
||||
if (!mesh.isMesh || !mesh.material) return
|
||||
mesh.material = Array.isArray(mesh.material)
|
||||
? mesh.material.map((m) => m.clone())
|
||||
: mesh.material.clone()
|
||||
@ -280,30 +419,76 @@ export class AssetRegistry {
|
||||
ensureStandardMaterials(copy)
|
||||
enableShadows(copy)
|
||||
return copy
|
||||
} catch (err) {
|
||||
this.warnOnce(`inst:${url}`, `[assets] "${url}" could not be prepared — keeping the built-in version.`, err)
|
||||
return null
|
||||
}
|
||||
}
|
||||
|
||||
/** Already-preloaded instance, or null. Sync — safe inside createBlob. */
|
||||
/** Already-preloaded instance, or null. Sync and never throws — safe inside createBlob. */
|
||||
instanceSync(slot: SlotId): { object: THREE.Group; entry: SlotEntry; animations: THREE.AnimationClip[] } | null {
|
||||
try {
|
||||
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 }
|
||||
const object = this.instantiate(asset, entry.url)
|
||||
if (!object || collectMeshes(object).length === 0) {
|
||||
if (object) {
|
||||
this.warnOnce(`nomesh:${entry.url}`, `[assets] "${entry.url}" has no mesh — keeping the built-in version.`)
|
||||
}
|
||||
return null
|
||||
}
|
||||
return { object, entry, animations: asset.animations }
|
||||
} catch (err) {
|
||||
this.warnOnce(`sync:${slot}`, `[assets] "${slot}" could not be instanced — keeping the built-in version.`, err)
|
||||
return null
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 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.
|
||||
*
|
||||
* Two guards, and the split between them is the whole point. Consumers hide
|
||||
* their primitive in the FIRST statement of `onSwap`, so "primitive hidden,
|
||||
* replacement absent" is the one state the scene must never reach:
|
||||
*
|
||||
* BUILD (instance, mesh count, parent) — a failure here happens BEFORE
|
||||
* onSwap has run, so we bail out and the primitive is still visible.
|
||||
* NOTIFY (onSwap) — a failure here happens AFTER the primitive may already
|
||||
* be hidden, so the fit node STAYS parented. Losing a colour tint beats
|
||||
* removing the only thing left to look at.
|
||||
*/
|
||||
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))
|
||||
let fit: THREE.Group | null = null
|
||||
try {
|
||||
const inst = this.instantiate(asset, entry.url)
|
||||
if (!inst) return
|
||||
// A GLB whose scene is only empties/armatures/cameras loads fine and
|
||||
// instances fine. Swapping it in would hide the primitive and add
|
||||
// nothing — an invisible object with a live collider.
|
||||
if (collectMeshes(inst).length === 0) {
|
||||
this.warnOnce(`nomesh:${entry.url}`, `[assets] "${entry.url}" has no mesh — keeping the built-in version.`)
|
||||
return
|
||||
}
|
||||
fit = makeFitNode(entry, opts.fit)
|
||||
fit.add(inst)
|
||||
host.add(fit)
|
||||
} catch (err) {
|
||||
this.warnOnce(`swap:${entry.url}`, `[assets] "${entry.url}" could not be installed — keeping the built-in version.`, err)
|
||||
if (fit?.parent) fit.parent.remove(fit)
|
||||
return
|
||||
}
|
||||
try {
|
||||
opts.onSwap?.(fit)
|
||||
} catch (err) {
|
||||
this.warnOnce(`onswap:${slot}:${entry.url}`, `[assets] "${slot}" swapped in but could not be re-styled.`, err)
|
||||
}
|
||||
},
|
||||
() => { /* warned in load(); fallback stays */ },
|
||||
)
|
||||
|
||||
@ -13,6 +13,7 @@ export const SLOT_IDS = [
|
||||
'blob.body',
|
||||
'blob.face',
|
||||
'ghost.body',
|
||||
'cannon.base',
|
||||
'cannon.barrel',
|
||||
'machine.plate',
|
||||
'machine.boot',
|
||||
@ -23,6 +24,9 @@ export const SLOT_IDS = [
|
||||
'machine.seesaw',
|
||||
'course.scenery.cereal',
|
||||
'course.scenery.block',
|
||||
'course.finish',
|
||||
'course.tunnel',
|
||||
'course.tramp',
|
||||
'fx.puddle',
|
||||
] as const
|
||||
|
||||
@ -39,7 +43,8 @@ export const SLOT_LABELS: Record<SlotId, string> = {
|
||||
'blob.body': 'Blobbo body (paintable)',
|
||||
'blob.face': 'Blobbo eyes',
|
||||
'ghost.body': 'Ghost racer',
|
||||
'cannon.barrel': 'Paint cannon',
|
||||
'cannon.base': 'Paint cannon base',
|
||||
'cannon.barrel': 'Paint cannon barrel',
|
||||
'machine.plate': 'Pressure plate',
|
||||
'machine.boot': 'Spring boot',
|
||||
'machine.bucket': 'Paint bucket',
|
||||
@ -49,6 +54,9 @@ export const SLOT_LABELS: Record<SlotId, string> = {
|
||||
'machine.seesaw': 'See-saw plank',
|
||||
'course.scenery.cereal': 'Giant cereal box',
|
||||
'course.scenery.block': 'Purple block',
|
||||
'course.finish': 'Finish pad',
|
||||
'course.tunnel': 'MINI tunnel roof',
|
||||
'course.tramp': 'Trampoline / gap launcher',
|
||||
'fx.puddle': 'Paint puddle',
|
||||
}
|
||||
|
||||
@ -69,4 +77,21 @@ export const SLOT_NOTES: Partial<Record<SlotId, string>> = {
|
||||
'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.',
|
||||
'cannon.base': 'The stand only; the barrel is a separate slot because it aims every step.',
|
||||
'cannon.barrel': 'The tube only, pointing +Z — the aiming pivot stays procedural.',
|
||||
'course.finish': 'Fill the 18 x 1.2 x 6 pad volume — its collider never moves.',
|
||||
'course.tunnel':
|
||||
'Roof slab only (7 x 0.3 x 14). Keep the underside FLAT at the model\'s bottom: ' +
|
||||
'clearance under it is what a MINI blob squeezes through, and the collider does not change.',
|
||||
'course.tramp': 'Fill the pad volume; the launch impulse is unaffected by the model.',
|
||||
}
|
||||
|
||||
/**
|
||||
* Slots the game builds ONCE. Everything else may be instanced many times over
|
||||
* (nine puddles, six cannons, every machine) and each instance gets its own
|
||||
* clone of the asset at its own pose — see docs/ASSET-SLOTS.md.
|
||||
*/
|
||||
export const SINGLE_INSTANCE_SLOTS: ReadonlySet<string> = new Set<string>([
|
||||
'blob.body', 'blob.face', 'ghost.body',
|
||||
'course.finish', 'course.tunnel', 'course.scenery.cereal', 'course.scenery.block',
|
||||
])
|
||||
|
||||
@ -114,7 +114,7 @@ export function buildGreybox(world: World): GreyboxHandle {
|
||||
})
|
||||
|
||||
// ---- finish pad (bright pink, raised) ----
|
||||
addBox(0, 0.6, -64, 9, 0.6, 6, { color: '#FF6EB4', surface: 'finish', cast: true })
|
||||
decorate('course.finish', 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 ----
|
||||
// Slot hooks go on the MESH only, after the collider exists: the box volume
|
||||
|
||||
@ -35,6 +35,7 @@ import { PALETTE } from '../contracts'
|
||||
import type { PaintSkin } from '../paint/skin'
|
||||
import { installPuddles, type PaintPuddle, type PaintPuddleConfig } from '../paint/puddles'
|
||||
import { createPressurePlate, createSpringBoot, type Vec3 } from '../machine'
|
||||
import { assets } from '../assets/registry'
|
||||
|
||||
/** A cannon integration should stage for a zone (fires that zone's colour). */
|
||||
export interface ZoneCannonConfig {
|
||||
@ -204,6 +205,13 @@ function buildTunnel(
|
||||
rapier.ColliderDesc.cuboid(hx, hy, hz).setTranslation(cx, cy, cz),
|
||||
)
|
||||
|
||||
// Slot `course.tunnel` decorates the ROOF only, after the collider exists: the
|
||||
// clearance a MINI blob squeezes through is the collider's, so no model can
|
||||
// move it. The warning bar and posts stay procedural — they are signage.
|
||||
assets().attachSlot('course.tunnel', roof, {
|
||||
onSwap: () => { roofMat.visible = false },
|
||||
})
|
||||
|
||||
// "MIND YOUR HEAD" clearance bar hanging just under the lip so the low ceiling
|
||||
// reads before you reach it (visual only).
|
||||
const barMat = new THREE.MeshStandardMaterial({
|
||||
|
||||
@ -105,7 +105,8 @@ 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(` 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}`)
|
||||
}
|
||||
|
||||
@ -15,6 +15,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'
|
||||
import { fitBodyToRadius } from '../assets/blobBody'
|
||||
|
||||
const GHOST_OPACITY = 0.35
|
||||
const BOB_AMPLITUDE = 0.06 // metres
|
||||
@ -87,10 +88,21 @@ export class GhostPlayer {
|
||||
// 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'
|
||||
const borrowed = !reg.has('ghost.body')
|
||||
const slot = borrowed ? 'blob.body' : 'ghost.body'
|
||||
reg.attachSlot(slot, g, {
|
||||
onSwap: (asset) => {
|
||||
body.visible = false
|
||||
// A borrowed blob.body has NOT been through the blob's fit: the real
|
||||
// blob normalises its geometry to the collider radius, so without this
|
||||
// the ghost renders at the raw GLB's size (2.17x with blobbo-base.glb)
|
||||
// and off-centre. A dedicated ghost.body keeps its manifest transform.
|
||||
if (borrowed) {
|
||||
const fitted = fitBodyToRadius(asset, this.radius, reg.entry('blob.body'))
|
||||
// fitBodyToRadius clones the geometry (the source is shared with the
|
||||
// real blob), so this one is ours to dispose.
|
||||
if (fitted) this.geos.push(fitted.geometry)
|
||||
}
|
||||
asset.traverse((o) => {
|
||||
const m = (o as THREE.Mesh).material as THREE.MeshStandardMaterial | undefined
|
||||
if (!m) return
|
||||
|
||||
@ -240,11 +240,16 @@ export class PaintCannon implements System {
|
||||
pivot.add(tube)
|
||||
pivot.name = 'pivot'
|
||||
g.add(pivot)
|
||||
// Two slots, because they are two different jobs: the base is a static
|
||||
// stand, the barrel is the thing that aims. Filling only one leaves the
|
||||
// other primitive in place. `cannon.barrel` is attached to the whole group
|
||||
// rather than the pivot so a barrel model does not inherit the aim spin —
|
||||
// the procedural tube stays hidden either way.
|
||||
assets().attachSlot('cannon.base', g, {
|
||||
onSwap: () => { base.visible = false },
|
||||
})
|
||||
assets().attachSlot('cannon.barrel', g, {
|
||||
onSwap: () => {
|
||||
base.visible = false
|
||||
tube.visible = false
|
||||
},
|
||||
onSwap: () => { tube.visible = false },
|
||||
})
|
||||
return g
|
||||
}
|
||||
|
||||
Loading…
Reference in New Issue
Block a user