Compare commits

..

No commits in common. "ad93d55de9a2bf29933d018233c83c91b0797bd9" and "cabc60def862b1b8d950bde9827273c5b77c9c89" have entirely different histories.

50 changed files with 17 additions and 6012 deletions

3
.gitignore vendored
View File

@ -3,6 +3,3 @@ __pycache__/
.DS_Store
node_modules/
dist/
# Lane I: farm GLBs staged for local serving (large, not committed)
public/assets/meshes/

View File

@ -1,22 +0,0 @@
<!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>

View File

@ -1,94 +0,0 @@
<!doctype html>
<html lang="en">
<head><meta charset="utf-8" /><title>BLOBBO lane j demo — workshop editor</title>
<style>
:root{
--ink:#0a2540; --ink-soft:#4a627d; --paper:#f4f7fb; --card:#ffffff;
--line:#d7e2ee; --hot:#FF9500; --good:#34C759; --bad:#FF3B30; --pick:#0A84FF;
}
*{box-sizing:border-box}
html,body{margin:0;height:100%;background:var(--paper);color:var(--ink);
font:15px/1.5 system-ui,-apple-system,"Segoe UI",sans-serif;overflow:hidden}
button,input{font:inherit;color:inherit}
.workshop{display:grid;grid-template-columns:280px 1fr 330px;height:100%}
.panel{overflow-y:auto;padding:16px}
.panel-slots{background:var(--card);border-right:1px solid var(--line)}
.panel-fit{background:var(--card);border-left:1px solid var(--line)}
.panel-stage{position:relative;padding:0;overflow:hidden;background:#bfe3ff}
.stage-host{position:absolute;inset:0}
.stage-host canvas{display:block;width:100%;height:100%}
.brand{margin:0 0 4px;font-size:19px}
.lede{margin:0 0 18px;color:var(--ink-soft);font-size:13px}
.group{margin:16px 0 6px;font-size:11px;letter-spacing:.09em;text-transform:uppercase;color:var(--ink-soft)}
.slot-list{display:flex;flex-direction:column;gap:4px}
.slot{display:flex;justify-content:space-between;align-items:center;gap:8px;width:100%;
padding:9px 11px;border:1px solid var(--line);border-radius:10px;background:#fff;
cursor:pointer;text-align:left}
.slot[data-selected="true"]{border-color:var(--pick);background:#e7f2ff;box-shadow:inset 3px 0 0 var(--pick)}
.slot-name{font-weight:600}
.slot-state{font-size:11px;padding:2px 7px;border-radius:20px;background:#eef3f8;color:var(--ink-soft)}
.slot[data-state="custom"] .slot-state{background:#e6f8ea;color:#1c7a35}
.slot[data-state="derived"] .slot-state{background:#f3ecff;color:#6b3fa0}
.slot[data-state="broken"] .slot-state{background:#fff1ef;color:#a3271d}
.readonly-banner{margin:18px 0 0;padding:11px 12px;border-radius:10px;
border:1px solid #f0d9a8;background:#fff8ea;color:#7a5200;font-size:12.5px}
.big-actions{display:flex;flex-direction:column;gap:8px;margin:22px 0 12px}
.action{padding:12px;border:1px solid var(--line);border-radius:11px;background:#fff;cursor:pointer;font-weight:600}
.action:disabled{opacity:.45;cursor:not-allowed}
.action.primary{background:var(--pick);border-color:var(--pick);color:#fff}
.action.danger{color:var(--bad);border-color:#f6d2cf}
.status{margin:0;padding:10px 12px;border-radius:10px;background:#eef3f8;font-size:13px;color:var(--ink-soft)}
.status[data-tone="warn"]{background:#fff3e2;color:#8a5100}
.stage-hint{position:absolute;left:14px;bottom:14px;padding:7px 11px;border-radius:9px;
background:rgba(10,37,64,.72);color:#fff;font-size:12px;pointer-events:none}
.focus-btn{position:absolute;right:14px;bottom:14px;padding:10px 14px;border:0;border-radius:10px;
background:rgba(10,37,64,.86);color:#fff;font-weight:600;cursor:pointer}
.fit-title{margin:0 0 4px;font-size:18px}
.fit-hint{margin:0 0 14px;color:var(--ink-soft);font-size:13px}
.dropzone{display:flex;flex-direction:column;align-items:center;justify-content:center;gap:5px;
min-height:132px;border:2.5px dashed var(--line);border-radius:14px;background:#fbfdff;
cursor:pointer;text-align:center;padding:14px}
.dropzone[data-hot="true"]{border-color:var(--hot);background:#fff6ea}
.dropzone-label{font-size:17px;font-weight:700}
.dropzone-sub{font-size:12px;color:var(--ink-soft)}
.paint-card{display:none;margin-top:14px;padding:12px;border-radius:12px;border:1px solid var(--line);background:#fbfdff}
.paint-card[data-tone="ok"]{border-color:#bde5c8;background:#f2fcf5}
.paint-card[data-tone="bad"]{border-color:#f3b9b4;background:#fff3f2}
.paint-head{font-weight:700;margin-bottom:6px}
.paint-facts{display:flex;flex-wrap:wrap;gap:6px;margin-bottom:6px}
.paint-facts span{font-size:11px;padding:3px 8px;border-radius:20px;background:#fff;border:1px solid var(--line)}
.paint-note{font-size:12.5px;color:var(--ink-soft);margin-top:4px}
.fit-controls{display:none;margin-top:16px}
.fit-row{display:grid;gap:4px;margin-bottom:11px}
.fit-label{font-size:12px;font-weight:600;color:var(--ink-soft)}
.fit-row input[type=range]{width:100%}
.fit-row input[type=number]{width:96px;padding:5px 7px;border:1px solid var(--line);border-radius:7px;background:#fff}
.help-card{margin-top:20px;border:1px solid var(--line);border-radius:12px;background:#fbfdff;padding:10px 12px}
.help-card summary{cursor:pointer;font-weight:600}
.help-body{font-size:13px;color:var(--ink-soft)}
.help-body h3{margin:14px 0 4px;font-size:13px;color:var(--ink)}
.help-body code{background:#eef3f8;padding:1px 5px;border-radius:5px;font-size:12px}
/* demo report overlay */
#results{position:fixed;top:10px;left:50%;transform:translateX(-50%);z-index:50;
max-height:46vh;overflow:auto;min-width:520px;max-width:74vw;
background:rgba(12,20,34,.92);color:#eaf2ff;padding:12px 15px;border-radius:11px;
font:12.5px/1.55 ui-monospace,Menlo,monospace;box-shadow:0 10px 30px rgba(0,0,0,.28)}
#results .pass{color:#8ef0a8}
#results .fail{color:#ff9b93}
#results .note{color:#ffd98a}
#results .summary{margin-top:8px;padding-top:8px;border-top:1px solid rgba(255,255,255,.18);
font-weight:700;font-size:14px}
#results h1{margin:0 0 8px;font-size:13px;color:#ffd60a;letter-spacing:.05em}
</style></head>
<body>
<div id="workshop" data-manual></div>
<div id="results"><h1>LANE J — SCRIPTED DROP ONTO THE SPRING BOOT SLOT</h1></div>
<script type="module" src="/src/demo/lane-j.ts"></script>
</body>
</html>

View File

@ -24,19 +24,6 @@ echo "[1/4] Building ..."
cd "$SCRIPT_DIR"
npm run build
# Guard: scripts/stage-farm-assets.sh copies ~43MB of raw farm GLBs into
# public/ for local Workshop testing, and vite copies public/ straight into
# dist/. Shipping those means every player downloads 43MB of background props.
# Custom models belong in the player's browser (IndexedDB) or in a decimated
# committed pack — never as an accident of local testing.
DIST_KB="$(du -sk dist | cut -f1)"
if [ "$DIST_KB" -gt 10240 ]; then
echo " ✗ dist/ is ${DIST_KB}KB (>10MB). Staged farm assets leaked into the build?"
echo " Run: rm -rf public/assets/meshes && npm run build"
exit 1
fi
echo " dist/ is ${DIST_KB}KB — ok"
echo "[2/4] Syncing dist/ to VPS staging ..."
ssh "$VPS" "rm -rf '$STAGING' && mkdir -p '$STAGING'"
rsync -az --exclude='.DS_Store' "$SCRIPT_DIR/dist/" "$VPS:$STAGING/"

View File

@ -1,221 +0,0 @@
# 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
}
}
```
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
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.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) |
| `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 | — |
| `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².
- **`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.
- **`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
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.
`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
```
`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.

View File

@ -1,111 +0,0 @@
<!doctype html>
<html lang="en">
<head>
<meta charset="utf-8" />
<meta name="viewport" content="width=device-width,initial-scale=1" />
<title>BLOBBO workshop</title>
<style>
:root{
--ink:#0a2540; --ink-soft:#4a627d; --paper:#f4f7fb; --card:#ffffff;
--line:#d7e2ee; --hot:#FF9500; --good:#34C759; --bad:#FF3B30; --pick:#0A84FF;
}
*{box-sizing:border-box}
html,body{margin:0;height:100%;background:var(--paper);color:var(--ink);
font:15px/1.5 system-ui,-apple-system,"Segoe UI",sans-serif;overflow:hidden}
button,input{font:inherit;color:inherit}
.workshop{display:grid;grid-template-columns:280px 1fr 330px;height:100%}
.panel{overflow-y:auto;padding:16px}
.panel-slots{background:var(--card);border-right:1px solid var(--line)}
.panel-fit{background:var(--card);border-left:1px solid var(--line)}
.panel-stage{position:relative;padding:0;overflow:hidden;background:#bfe3ff}
.stage-host{position:absolute;inset:0}
.stage-host canvas{display:block;width:100%;height:100%}
.brand{margin:0 0 4px;font-size:19px;letter-spacing:.02em}
.lede{margin:0 0 18px;color:var(--ink-soft);font-size:13px}
.group{margin:16px 0 6px;font-size:11px;letter-spacing:.09em;text-transform:uppercase;
color:var(--ink-soft)}
.slot-list{display:flex;flex-direction:column;gap:4px}
.slot{display:flex;justify-content:space-between;align-items:center;gap:8px;
width:100%;padding:9px 11px;border:1px solid var(--line);border-radius:10px;
background:#fff;cursor:pointer;text-align:left;transition:.12s}
.slot:hover{border-color:var(--pick);background:#f2f8ff}
.slot[data-selected="true"]{border-color:var(--pick);background:#e7f2ff;
box-shadow:inset 3px 0 0 var(--pick)}
.slot-name{font-weight:600}
.slot-state{font-size:11px;padding:2px 7px;border-radius:20px;background:#eef3f8;
color:var(--ink-soft);white-space:nowrap}
.slot[data-state="custom"] .slot-state{background:#e6f8ea;color:#1c7a35}
.slot[data-state="derived"] .slot-state{background:#f3ecff;color:#6b3fa0}
.slot[data-state="broken"] .slot-state{background:#fff1ef;color:#a3271d}
.slot[data-state="broken"]{border-color:#f6d2cf}
.slot[data-state="absent"]{opacity:.55}
.slot[data-state="absent"] .slot-state{background:#eef3f8;color:#7b8ca0}
.readonly-banner{margin:18px 0 0;padding:11px 12px;border-radius:10px;
border:1px solid #f0d9a8;background:#fff8ea;color:#7a5200;font-size:12.5px}
.big-actions{display:flex;flex-direction:column;gap:8px;margin:22px 0 12px}
.action{padding:12px;border:1px solid var(--line);border-radius:11px;background:#fff;
cursor:pointer;font-weight:600}
.action:hover{border-color:var(--pick)}
.action:disabled{opacity:.45;cursor:not-allowed}
.action:disabled:hover{border-color:var(--line)}
.action.primary{background:var(--pick);border-color:var(--pick);color:#fff}
.action.danger{color:var(--bad);border-color:#f6d2cf}
.status{margin:0;padding:10px 12px;border-radius:10px;background:#eef3f8;
font-size:13px;color:var(--ink-soft)}
.status[data-tone="warn"]{background:#fff3e2;color:#8a5100}
.stage-hint{position:absolute;left:14px;bottom:14px;padding:7px 11px;border-radius:9px;
background:rgba(10,37,64,.72);color:#fff;font-size:12px;pointer-events:none}
.focus-btn{position:absolute;right:14px;bottom:14px;padding:10px 14px;border:0;
border-radius:10px;background:rgba(10,37,64,.86);color:#fff;font-weight:600;cursor:pointer}
.fit-title{margin:0 0 4px;font-size:18px}
.fit-hint{margin:0 0 14px;color:var(--ink-soft);font-size:13px}
.dropzone{display:flex;flex-direction:column;align-items:center;justify-content:center;
gap:5px;min-height:132px;border:2.5px dashed var(--line);border-radius:14px;
background:#fbfdff;cursor:pointer;text-align:center;padding:14px}
.dropzone:hover,.dropzone[data-hot="true"]{border-color:var(--hot);background:#fff6ea}
.dropzone-label{font-size:17px;font-weight:700}
.dropzone-sub{font-size:12px;color:var(--ink-soft)}
.paint-card{display:none;margin-top:14px;padding:12px;border-radius:12px;
border:1px solid var(--line);background:#fbfdff}
.paint-card[data-tone="ok"]{border-color:#bde5c8;background:#f2fcf5}
.paint-card[data-tone="bad"]{border-color:#f3b9b4;background:#fff3f2}
.paint-head{font-weight:700;margin-bottom:6px}
.paint-facts{display:flex;flex-wrap:wrap;gap:6px;margin-bottom:6px}
.paint-facts span{font-size:11px;padding:3px 8px;border-radius:20px;background:#fff;
border:1px solid var(--line)}
.paint-note{font-size:12.5px;color:var(--ink-soft);margin-top:4px}
.fit-controls{display:none;margin-top:16px}
.fit-row{display:grid;grid-template-columns:1fr;gap:4px;margin-bottom:11px}
.fit-label{font-size:12px;font-weight:600;color:var(--ink-soft)}
.fit-row input[type=range]{width:100%}
.fit-row input[type=number]{width:96px;padding:5px 7px;border:1px solid var(--line);
border-radius:7px;background:#fff}
.help-card{margin-top:20px;border:1px solid var(--line);border-radius:12px;
background:#fbfdff;padding:10px 12px}
.help-card summary{cursor:pointer;font-weight:600}
.help-body{font-size:13px;color:var(--ink-soft)}
.help-body h3{margin:14px 0 4px;font-size:13px;color:var(--ink)}
.help-body ul,.help-body ol{margin:0;padding-left:18px}
.help-body code{background:#eef3f8;padding:1px 5px;border-radius:5px;font-size:12px}
@media (max-width:1100px){
html,body{overflow:auto}
.workshop{grid-template-columns:1fr;height:auto}
.panel-stage{height:60vh}
}
</style>
</head>
<body>
<div id="workshop"></div>
<script type="module" src="/src/editor/main.ts"></script>
</body>
</html>

View File

@ -38,18 +38,9 @@ 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).
**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.
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).
- 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).

View File

@ -1 +0,0 @@
{}

View File

@ -1,95 +0,0 @@
/**
* 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

View File

@ -1,63 +0,0 @@
// 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)}`)

View File

@ -1,13 +0,0 @@
#!/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/)"

View File

@ -1,25 +0,0 @@
/**
* 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
}
},
})

View File

@ -1,134 +0,0 @@
/**
* Lane I the `blob.body` slot, which is the only slot that is not cosmetic.
*
* 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
* 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 { 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) {
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 - '))
}
const entry = inst.entry
if (entry.scale !== undefined) {
console.warn('[assets] blob.body "scale" is ignored — the body is always fitted to the blob size.')
}
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.
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
return mesh
}

View File

@ -1,302 +0,0 @@
/**
* 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`)

View File

@ -1,123 +0,0 @@
/**
* 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
}

View File

@ -1,92 +0,0 @@
/**
* 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`)

View File

@ -1,144 +0,0 @@
/**
* 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>)) {
// `_`-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
}
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
}

View File

@ -1,133 +0,0 @@
/**
* 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`)

View File

@ -1,560 +0,0 @@
/**
* 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 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[]
}
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
/**
* 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)
}
/** 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
}
/** 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 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 = collectMeshes(object)
const problems: string[] = []
if (meshes.length === 0) {
return {
ok: false, uvOk: false, triCount: 0, materialCount: 0, meshCount: 0,
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`)
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')
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')
} 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 (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 {
// 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,
uvCharts: layout.charts, seamRatio: layout.seamRatio,
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.
*
* 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(timeoutMs: number = PRELOAD_TIMEOUT_MS): Promise<void> {
const urls = new Set(Object.values(this.manifest).map((e) => e.url))
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 {
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, 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 || !mesh.material) return
mesh.material = Array.isArray(mesh.material)
? mesh.material.map((m) => m.clone())
: mesh.material.clone()
})
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 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
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) => {
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 */ },
)
}
/**
* 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)
}

View File

@ -1,97 +0,0 @@
/**
* 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.base',
'cannon.barrel',
'machine.plate',
'machine.boot',
'machine.bucket',
'machine.arch',
'machine.belt',
'machine.fan',
'machine.seesaw',
'course.scenery.cereal',
'course.scenery.block',
'course.finish',
'course.tunnel',
'course.tramp',
'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.base': 'Paint cannon base',
'cannon.barrel': 'Paint cannon barrel',
'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',
'course.finish': 'Finish pad',
'course.tunnel': 'MINI tunnel roof',
'course.tramp': 'Trampoline / gap launcher',
'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.',
'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',
])

View File

@ -11,8 +11,6 @@ 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
@ -69,27 +67,20 @@ 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.
// 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)
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
group.add(mesh)
// Cosmetic face, parented to the group (yaws to face travel, scales with size).
const face = new THREE.Group()
face.add(assets().slotObject('blob.face', () => buildFace(radius)))
const face = buildFace(radius)
group.add(face)
// Physics proxy: dynamic ball, rotations locked so it stays an upright

View File

@ -12,8 +12,6 @@
*/
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). */
@ -71,17 +69,6 @@ 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 })
@ -114,14 +101,11 @@ export function buildGreybox(world: World): GreyboxHandle {
})
// ---- finish pad (bright pink, raised) ----
decorate('course.finish', addBox(0, 0.6, -64, 9, 0.6, 6, { color: '#FF6EB4', surface: 'finish', cast: true }))
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
// 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 }))
addBox(-20, 7, 26, 4, 7, 3, { color: '#FFD60A', cast: true })
addBox(20, 5, 12, 3, 5, 3, { color: '#AF52DE', cast: true })
return {
spawn: new THREE.Vector3(0, 3.5, 30),

View File

@ -35,7 +35,6 @@ 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 {
@ -205,13 +204,6 @@ 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({

View File

@ -1,140 +0,0 @@
/**
* 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
}

View File

@ -1,249 +0,0 @@
/**
* Lane J demo the workshop editor booted and then driven by a script.
*
* The scripted drop goes through `bootEditor().dropFile`, which is the exact
* function the real drag-drop handler calls, so a green run here means the
* button works too. Checks, in order: the stage object actually swapped, the
* fit controls move it, the export round-trips, reset restores the procedural
* build, and Clear leaves nothing behind.
*
* THE DEMO IS A GUEST, NOT AN OWNER. It used to run against the same IndexedDB
* database as the real editor, which meant opening this page deleted whatever
* the user had saved (`pruneOrphans` treats every blob key the demo's manifest
* does not mention as garbage), and closing the tab mid-run left a fake
* `machine.boot` override that the LIVE game would then load. So:
*
* - everything runs against a throwaway database, DEMO_DB;
* - the real database is fingerprinted before and after and the run FAILS if
* one byte of it moved;
* - the throwaway database is deleted in a `finally`, so a crash or a reload
* mid-run leaves nothing behind either.
*
* Asset note: `assets/meshes/*.glb` is NOT in the vite build graph today
* (nothing copies `assets/` into dist/), so the real boot GLB is fetched
* best-effort and a generated stand-in GLB is used when it 404s. Which one ran
* is printed the pipeline is identical either way.
*/
import { bootEditor } from '../editor/main'
import {
manifestsEqual, parseManifest, isIdbUrl, idbKey,
} from '../editor/manifest-io'
import { buildTinyGlb } from '../editor/tiny-glb'
import { getGlb, loadOverrideManifest } from '../editor/store'
import {
BLOB_STORE, DEFAULT_DB_NAME, deleteDb, idbKeys, setDbName,
} from '../editor/idb'
const SLOT = 'machine.boot'
const BOOT_URL = import.meta.env.BASE_URL + 'assets/meshes/prop-spring-boot.glb'
/** Never 'blobbo-workshop'. That one belongs to the user. */
const DEMO_DB = 'blobbo-workshop-demo'
const out = document.getElementById('results')!
let passed = 0
let failed = 0
function check(cond: boolean, msg: string, detail = ''): void {
const line = document.createElement('div')
line.className = cond ? 'pass' : 'fail'
line.textContent = `${cond ? '✓' : '✗'} ${msg}${detail ? `${detail}` : ''}`
out.append(line)
if (cond) passed++
else failed++
console[cond ? 'log' : 'error'](`${cond ? 'PASS' : 'FAIL'}: ${msg}`, detail)
}
function note(msg: string): void {
const line = document.createElement('div')
line.className = 'note'
line.textContent = msg
out.append(line)
console.log(msg)
}
async function fetchBootGlb(): Promise<{ name: string; bytes: ArrayBuffer; real: boolean }> {
try {
const res = await fetch(BOOT_URL)
if (res.ok) {
const bytes = await res.arrayBuffer()
if (bytes.byteLength > 0) {
return { name: 'prop-spring-boot.glb', bytes, real: true }
}
}
note(`Real farm GLB not served at ${BOOT_URL} (${res.status}) — using a generated stand-in.`)
} catch {
note(`Real farm GLB not reachable at ${BOOT_URL} — using a generated stand-in.`)
}
return { name: 'prop-spring-boot.glb', bytes: buildTinyGlb({ half: 1.1 }), real: false }
}
/**
* Everything about the user's real store that this demo could possibly disturb:
* the saved manifest and the list of stored files. Read-only. (Reading does open
* the database, which creates it empty if absent an empty store and a missing
* store are the same thing to both the editor and the game.)
*/
async function fingerprintRealDb(): Promise<string> {
setDbName(DEFAULT_DB_NAME)
try {
const manifest = await loadOverrideManifest()
const keys = (await idbKeys(BLOB_STORE)).map(String).sort()
return JSON.stringify({ manifest, keys })
} catch (err) {
return `unreadable:${String(err)}`
}
}
async function run(): Promise<void> {
const before = await fingerprintRealDb()
note(`Real store fingerprinted before the run (${before.length} chars). ` +
`Everything below happens in "${DEMO_DB}".`)
setDbName(DEMO_DB)
try {
await runChecks()
} finally {
// Runs on the crash path too. Without it, an assertion failure between the
// save and the clear left a persisted override behind.
setDbName(DEMO_DB)
await deleteDb(DEMO_DB)
const after = await fingerprintRealDb()
check(after === before,
"the user's own saved swaps were not touched by this demo",
after === before ? 'byte-identical before and after' : 'THE REAL STORE CHANGED')
}
}
async function runChecks(): Promise<void> {
const editor = await bootEditor(document.getElementById('workshop')!)
const { stage, workshop } = editor
check(editor.canSave(), 'this browser can store models (otherwise the demo is read-only)')
// ---- a clean session must be all-procedural ------------------------------
check(stage.slots.has(SLOT), 'the spring boot slot exists on the stage')
check(!workshop.hasCustom(SLOT), 'nothing is swapped before the drop')
const entryRef = stage.slots.get(SLOT)!
check(entryRef.fitNode === null, 'the slot has no custom wrapper yet')
check(entryRef.procedural.every((p) => p.visible), 'the procedural boot is visible')
const proceduralChildCount = entryRef.procedural[0]!.children.length
editor.select(SLOT)
// ---- the scripted drop ---------------------------------------------------
const file = await fetchBootGlb()
note(`Dropping ${file.name} (${file.bytes.byteLength.toLocaleString()} bytes, ` +
`${file.real ? 'real farm asset' : 'generated stand-in'}) onto the spring boot slot.`)
const result = await editor.dropFile(SLOT, file.name, file.bytes)
check(!result.rejected, 'the drop was accepted', result.message)
check(entryRef.fitNode !== null, 'a custom wrapper now holds the dropped model')
check(entryRef.fitNode !== null && entryRef.fitNode.children.length > 0,
'the dropped model is actually inside the wrapper')
check(entryRef.procedural.every((p) => !p.visible),
'the procedural boot is hidden (swapped, not stacked)')
check(entryRef.fitNodes.length === entryRef.procedural.length,
'every original this slot holds got its own replacement',
`${entryRef.procedural.length} original(s) → ${entryRef.fitNodes.length} replacement(s)`)
check(entryRef.procedural[0]!.children.length === proceduralChildCount,
'the procedural boot was hidden, not destroyed (reset can restore it)')
check(isIdbUrl(result.entry.url), 'the manifest entry points at browser storage',
result.entry.url)
const stored = await getGlb(idbKey(result.entry.url))
check(stored !== undefined && stored.bytes.byteLength === file.bytes.byteLength,
'the .glb bytes went into IndexedDB intact')
check(stage.currentObject(SLOT) === entryRef.fitNode,
'the fit panel now acts on the dropped model')
// ---- a file with no model in it is refused, on a slot that isn't paintable
const empty = await editor.dropFile(SLOT, 'empty.glb', buildTinyGlb({ withMesh: false }))
check(empty.rejected, 'a .glb with no model inside is refused', empty.message)
check(/no model inside/i.test(empty.message), 'the refusal is explained in plain words')
check(entryRef.fitNode !== null && workshop.entry(SLOT)?.url === result.entry.url,
'the refused file left the previous good swap exactly as it was')
// Where the swapped boot sits before any nudging.
const basePos = entryRef.fitNode!.position.clone()
// ---- fit controls actually move it ---------------------------------------
workshop.setFit(SLOT, { offset: [0, 1.5, 0], rotationDeg: [0, 90, 0], scale: 2 })
const moved = entryRef.fitNode!
check(Math.abs(moved.position.y - (basePos.y + 1.5)) < 1e-6,
'raising the model moves it up by exactly that much',
`y ${basePos.y.toFixed(3)}${moved.position.y.toFixed(3)}`)
check(Math.abs(moved.rotation.y - Math.PI / 2) < 1e-6, 'turning it applies the rotation')
check(Math.abs(moved.scale.x - 2) < 1e-6, 'resizing it applies the scale')
// ---- multi-instance slots keep every object ------------------------------
// fx.puddle is nine separate patches. Replacing one and hiding all nine is
// what used to happen; the count is asserted rather than assumed.
const puddles = stage.slots.get('fx.puddle')
if (puddles && puddles.procedural.length > 1) {
const n = puddles.procedural.length
const puddleDrop = await editor.dropFile('fx.puddle', 'puddle.glb', buildTinyGlb())
check(!puddleDrop.rejected, `dropping onto the ${n} paint puddles was accepted`,
puddleDrop.message)
check(puddles.fitNodes.length === n,
`all ${n} puddles were replaced, not just one`,
`${puddles.fitNodes.length} replacements for ${n} puddles`)
const distinct = new Set(puddles.fitNodes.map((f) =>
`${f.position.x.toFixed(3)},${f.position.z.toFixed(3)}`))
check(distinct.size === n, 'each replacement sits where its own puddle was',
`${distinct.size} distinct positions`)
workshop.reset('fx.puddle')
check(puddles.procedural.every((p) => p.visible) && puddles.fitNodes.length === 0,
'resetting the puddles brings all of them back')
} else {
note('No multi-instance puddle slot on this stage — skipped the multi-instance check.')
}
// ---- export round-trips --------------------------------------------------
const text = editor.exportManifest()
const reparsed = parseManifest(text)
check(manifestsEqual(workshop.getManifest(), reparsed),
'the downloaded manifest.json parses back to exactly what the editor holds')
check(reparsed[SLOT] !== undefined && reparsed[SLOT]!.offset?.[1] === 1.5,
'the fit survives the round trip')
check(typeof (JSON.parse(text) as { _readme?: string })._readme === 'string',
'the download carries a plain-language note about shipping it')
// Same trip again through a real Blob/File, i.e. the bytes a download produces.
const downloaded = new Blob([text], { type: 'application/json' })
const roundTripped = parseManifest(await downloaded.text())
check(manifestsEqual(reparsed, roundTripped),
'a real downloaded file round-trips identically')
// ---- save + reload persistence ------------------------------------------
await editor.save()
const persisted = await loadOverrideManifest()
check(manifestsEqual(workshop.getManifest(), persisted),
'saving writes the same manifest the live game will read back')
// ---- reset restores the procedural build ---------------------------------
workshop.reset(SLOT)
check(entryRef.fitNode === null, 'reset removes the custom wrapper')
check(entryRef.procedural.every((p) => p.visible), 'reset shows the original boot again')
check(!workshop.hasCustom(SLOT), 'reset drops the manifest entry')
// ---- clear leaves nothing behind ----------------------------------------
await editor.clearAll()
const afterClear = await loadOverrideManifest()
check(Object.keys(afterClear).length === 0, 'Clear removes the saved override manifest')
;(window as unknown as { LANE_J: unknown }).LANE_J = { editor, passed, failed }
}
function summarise(): void {
const summary = document.createElement('div')
summary.className = failed === 0 ? 'summary pass' : 'summary fail'
summary.textContent = failed === 0
? `All ${passed} checks passed ✓`
: `${failed} of ${passed + failed} checks FAILED`
out.append(summary)
}
void run()
.catch((err: unknown) => {
console.error(err)
check(false, 'the demo crashed', String(err))
})
.finally(summarise)

View File

@ -1,126 +0,0 @@
/**
* Lane J the demo cannot touch the user's work.
*
* demos/lane-j.html used to run against the SAME database as the real editor.
* Its scripted `save()` handed `pruneOrphans` a manifest containing only the
* demo's own slot, and pruneOrphans deletes every stored file the manifest given
* to it does not mention so opening the demo deleted every custom model the
* user had saved, and a run interrupted between save and clear left a fake
* override that the LIVE game would then load.
*
* This reproduces both, against the real store/idb code, with the database name
* being the only thing that changed.
*
* node --experimental-strip-types --import ./src/editor/node-ts-resolve.mjs \
* src/editor/demo-isolation.test.ts
*/
export {} // everything is loaded with await import(), so say "module" explicitly
type Store = Map<string, unknown>
const dbs = new Map<string, Map<string, Store>>()
const fakeIndexedDb = {
open(name: string) {
const req: Record<string, unknown> = {
result: null, onsuccess: null, onerror: null, onupgradeneeded: null, onblocked: null,
}
const stores = dbs.get(name) ?? new Map<string, Store>()
dbs.set(name, stores)
const db = {
objectStoreNames: { contains: (s: string) => stores.has(s) },
createObjectStore: (s: string) => void stores.set(s, new Map()),
transaction: (s: string) => ({
objectStore: () => {
const store = stores.get(s)!
const wrap = <T>(result: T): Record<string, unknown> => {
const r: Record<string, unknown> = { result, onsuccess: null, onerror: null }
queueMicrotask(() => void (r.onsuccess as (() => void) | null)?.())
return r
}
return {
get: (k: string) => wrap(store.get(k)),
put: (v: unknown, k: string) => { store.set(k, v); return wrap(undefined) },
delete: (k: string) => { store.delete(k); return wrap(undefined) },
getAllKeys: () => wrap([...store.keys()]),
clear: () => { store.clear(); return wrap(undefined) },
}
},
}),
}
req.result = db
queueMicrotask(() => {
;(req.onupgradeneeded as (() => void) | null)?.()
;(req.onsuccess as (() => void) | null)?.()
})
return req
},
deleteDatabase(name: string) {
const req: Record<string, unknown> = { onsuccess: null, onerror: null, onblocked: null }
dbs.delete(name)
queueMicrotask(() => void (req.onsuccess as (() => void) | null)?.())
return req
},
}
;(globalThis as unknown as { indexedDB: unknown }).indexedDB = fakeIndexedDb
const {
BLOB_STORE, DEFAULT_DB_NAME, deleteDb, idbKeys, setDbName,
} = await import('./idb')
const {
loadOverrideManifest, saveOverrideManifest, putGlb, pruneOrphans,
} = await import('./store')
const DEMO_DB = 'blobbo-workshop-demo'
let passed = 0
function ok(cond: boolean, msg: string): void {
if (!cond) throw new Error('FAIL: ' + msg)
passed++
}
const fingerprint = async (): Promise<string> => JSON.stringify({
manifest: await loadOverrideManifest(),
keys: (await idbKeys(BLOB_STORE)).map(String).sort(),
})
// ---- the user's real, hard-won session --------------------------------------
setDbName(DEFAULT_DB_NAME)
await putGlb('blob.body-my-blob.glb', 'my-blob.glb', new ArrayBuffer(2048))
await putGlb('machine.bucket-my-bucket.glb', 'my-bucket.glb', new ArrayBuffer(4096))
await saveOverrideManifest({
'blob.body': { url: 'idb:blob.body-my-blob.glb', scale: 1.2 },
'machine.bucket': { url: 'idb:machine.bucket-my-bucket.glb' },
})
const before = await fingerprint()
ok(Object.keys(JSON.parse(before).manifest).length === 2, 'the user has two saved swaps')
// ---- the demo runs, in its own database -------------------------------------
setDbName(DEMO_DB)
await putGlb('machine.boot-prop-spring-boot.glb', 'prop-spring-boot.glb', new ArrayBuffer(512))
const demoManifest = { 'machine.boot': { url: 'idb:machine.boot-prop-spring-boot.glb' } }
await saveOverrideManifest(demoManifest)
// The destructive call: with a shared database this deleted BOTH of the user's
// files, because neither appears in the demo's manifest.
const pruned = await pruneOrphans(demoManifest)
ok(pruned === 0, 'pruning inside the demo database finds nothing of the user\'s to delete')
ok(Object.keys(await loadOverrideManifest()).length === 1, 'the demo has its own manifest')
// ---- the demo is interrupted, then cleans up in its finally ------------------
setDbName(DEMO_DB)
await deleteDb(DEMO_DB)
setDbName(DEMO_DB)
ok(Object.keys(await loadOverrideManifest()).length === 0,
'after cleanup the demo leaves no override behind — not even for its own database')
// ---- the user's session is untouched ----------------------------------------
setDbName(DEFAULT_DB_NAME)
const after = await fingerprint()
ok(after === before, 'the real store is byte-identical before and after the demo')
const m = JSON.parse(after).manifest as Record<string, { url: string }>
ok(m['blob.body']?.url === 'idb:blob.body-my-blob.glb', 'the custom blob body survived')
ok(m['machine.bucket']?.url === 'idb:machine.bucket-my-bucket.glb', 'the custom bucket survived')
ok((JSON.parse(after).keys as string[]).length === 2, 'both stored .glb files survived')
ok(!(JSON.parse(after).manifest as Record<string, unknown>)['machine.boot'],
"the demo's fake spring-boot override never reached the game's database")
console.log(`editor demo-isolation.test: ${passed} assertions passed ✓`)

View File

@ -1,28 +0,0 @@
/**
* Lane J GLB bytes in, scene graph out.
*
* Its own module (rather than living in stage.ts) so the drop pipeline can be
* imported without dragging in the whole game module graph stage.ts pulls
* world/course/machine/paint, and a headless test has no business booting any
* of that.
*/
import * as THREE from 'three'
import { GLTFLoader } from 'three/examples/jsm/loaders/GLTFLoader.js'
import { clone as skeletonClone } from 'three/examples/jsm/utils/SkeletonUtils.js'
/** Parse GLB bytes. Rejects rather than handing back half a model. */
export function loadGlb(bytes: ArrayBuffer): Promise<THREE.Group> {
const loader = new GLTFLoader()
return new Promise((resolve, reject) => {
loader.parse(bytes, '', (gltf) => resolve(gltf.scene), reject)
})
}
/** Clone an asset safely — SkeletonUtils for anything rigged, plain clone else. */
export function cloneAsset(object: THREE.Object3D): THREE.Object3D {
let skinned = false
object.traverse((o) => {
if ((o as THREE.SkinnedMesh).isSkinnedMesh) skinned = true
})
return skinned ? skeletonClone(object) : object.clone(true)
}

View File

@ -1,220 +0,0 @@
/**
* Lane J the failure paths, headlessly.
*
* workshop.test.ts covers the happy path. This one covers what the editor does
* when things go wrong, because every case here was once a silent data loss or a
* frozen UI:
*
* [1] a .glb with no model in it, dropped on a slot that isn't the paintable
* body used to hide the original and show nothing;
* [2] a slot the game builds nine of used to hide all nine and add one;
* [3] a full/blocked IndexedDB used to reject into an unhandled promise;
* [4] a saved swap whose stored bytes no longer load used to be dropped from
* the manifest, after which the next save deleted the bytes for good;
* [5] the slot list agreeing with the runtime's, by construction.
*
* node --experimental-strip-types --import ./src/editor/node-ts-resolve.mjs \
* src/editor/hardening.test.ts
*/
import * as THREE from 'three'
// ---- in-memory IndexedDB with a fault switch -------------------------------
type Store = Map<string, unknown>
const dbs = new Map<string, Map<string, Store>>()
/** Flip to make every write fail the way a full disk does. */
let failWrites = false
const fakeIndexedDb = {
open(name: string) {
const req: Record<string, unknown> = {
result: null, onsuccess: null, onerror: null, onupgradeneeded: null, onblocked: null,
}
const stores = dbs.get(name) ?? new Map<string, Store>()
dbs.set(name, stores)
const db = {
objectStoreNames: { contains: (s: string) => stores.has(s) },
createObjectStore: (s: string) => void stores.set(s, new Map()),
close: () => {}, // the runtime's reader closes its handle; the editor's pools it
transaction: (s: string) => ({
objectStore: (_n: string) => {
const store = stores.get(s)!
const wrap = <T>(result: T, fail = false): Record<string, unknown> => {
const r: Record<string, unknown> = {
result, onsuccess: null, onerror: null,
error: fail ? new Error('QuotaExceededError') : null,
}
queueMicrotask(() => {
if (fail) (r.onerror as (() => void) | null)?.()
else (r.onsuccess as (() => void) | null)?.()
})
return r
}
return {
get: (k: string) => wrap(store.get(k)),
put: (v: unknown, k: string) => {
if (failWrites) return wrap(undefined, true)
store.set(k, v)
return wrap(undefined)
},
delete: (k: string) => { store.delete(k); return wrap(undefined) },
getAllKeys: () => wrap([...store.keys()]),
clear: () => { store.clear(); return wrap(undefined) },
}
},
}),
}
req.result = db
queueMicrotask(() => {
;(req.onupgradeneeded as (() => void) | null)?.()
;(req.onsuccess as (() => void) | null)?.()
})
return req
},
deleteDatabase(name: string) {
const req: Record<string, unknown> = { onsuccess: null, onerror: null, onblocked: null }
dbs.delete(name)
queueMicrotask(() => void (req.onsuccess as (() => void) | null)?.())
return req
},
}
;(globalThis as unknown as { indexedDB: unknown }).indexedDB = fakeIndexedDb
const { Workshop } = await import('./workshop')
const { SlotSwap } = await import('./slot-swap')
const { buildTinyGlb } = await import('./tiny-glb')
const { BLOB_STORE, idbKeys, idbPut } = await import('./idb')
const { saveOverrideManifest } = await import('./store')
const { SLOTS, SLOT_IDS } = await import('./slots')
const runtimeSlots = await import('../assets/slots')
type EditorStage = import('./stage').EditorStage
type Entry = import('./manifest-io').SlotEntry
let passed = 0
function ok(cond: boolean, msg: string): void {
if (!cond) throw new Error('FAIL: ' + msg)
passed++
}
const scene = new THREE.Scene()
const swap = new SlotSwap()
const makeMesh = (name: string, pos: [number, number, number]): THREE.Mesh => {
const mesh = new THREE.Mesh(new THREE.BoxGeometry(1, 1, 1), new THREE.MeshStandardMaterial())
mesh.name = name
mesh.position.set(...pos)
scene.add(mesh)
return mesh
}
const stage = {
slots: swap.slots,
setCustom: (s: string, a: THREE.Object3D, e: Entry) => swap.setCustom(s, a, e),
clearCustom: (s: string) => swap.clearCustom(s),
applyFit: (s: string, e: Entry) => swap.applyFit(s, e),
currentObject: (s: string) => swap.currentObject(s),
highlight: () => {},
focus: () => {},
dispose: () => {},
} as unknown as EditorStage
// ---- [1] a file with no model in it -----------------------------------------
const BOOT = 'machine.boot'
swap.register(BOOT, [makeMesh('boot', [-6, 0.2, -49.5])], scene)
const workshop = new Workshop(stage)
const bootSlot = swap.slots.get(BOOT)!
const empty = await workshop.dropFile(BOOT, 'empty.glb', buildTinyGlb({ withMesh: false }))
ok(empty.rejected, '[1] a .glb with no mesh is refused on a non-paintable slot')
ok(/no model inside this file/i.test(empty.message), '[1] the refusal says so in plain words')
ok(bootSlot.procedural.every((p) => p.visible), '[1] the original is still on screen')
ok(bootSlot.fitNodes.length === 0, '[1] nothing was put in its place')
ok(!workshop.hasCustom(BOOT), '[1] nothing was written to the manifest')
ok((await idbKeys(BLOB_STORE)).length === 0, '[1] nothing reached storage either')
// ---- [2] a slot the game builds several of ----------------------------------
const PUD = 'fx.puddle'
const puddleMeshes = [
makeMesh('p1', [0, 0.05, 10]),
makeMesh('p2', [4, 0.05, -12]),
makeMesh('p3', [-3, 0.05, -40]),
]
swap.register(PUD, puddleMeshes, scene, { extraScale: [[2, 1, 3], [1, 1, 1], [4, 1, 2]] })
const puddleDrop = await workshop.dropFile(PUD, 'puddle.glb', buildTinyGlb())
const puddles = swap.slots.get(PUD)!
ok(!puddleDrop.rejected, '[2] the puddle drop was accepted')
ok(puddles.fitNodes.length === 3, `[2] all three puddles were replaced (got ${puddles.fitNodes.length})`)
ok(puddles.procedural.every((p) => !p.visible), '[2] all three originals are hidden')
ok(puddles.fitNodes.every((f, i) =>
Math.abs(f.position.z - puddleMeshes[i]!.position.z) < 1e-9),
'[2] each replacement sits where its own puddle stood')
ok(puddles.fitNodes.every((f) => f.children.length === 1),
'[2] each replacement holds its own copy of the model')
ok(new Set(puddles.fitNodes.map((f) => f.children[0])).size === 3,
'[2] the three copies are three distinct objects, not one object moved about')
ok(Math.abs(puddles.fitNodes[2]!.scale.x - 4) < 1e-9,
"[2] each replacement is stretched to its own puddle's footprint")
ok(/all 3/.test(puddleDrop.message), `[2] the message says how many changed: "${puddleDrop.message}"`)
workshop.reset(PUD)
ok(puddles.procedural.every((p) => p.visible) && puddles.fitNodes.length === 0,
'[2] reset brings every puddle back')
// ---- [3] storage that refuses to keep anything ------------------------------
failWrites = true
const full = await workshop.dropFile(BOOT, 'huge.glb', buildTinyGlb())
ok(full.rejected, '[3] a drop the browser cannot store is refused, not thrown')
ok(/wasn't room in this browser/i.test(full.message),
`[3] the refusal is plain English: "${full.message}"`)
ok(bootSlot.fitNodes.length === 0 && bootSlot.procedural.every((p) => p.visible),
'[3] the slot stayed procedural')
ok(!workshop.hasCustom(BOOT), '[3] the manifest was not touched')
const failedSave = await workshop.saveLocally()
ok(failedSave.ok === false, '[3] a save the browser refuses comes back as a result, not a throw')
ok(/wasn't room in this browser/i.test(failedSave.message), '[3] and it says so in plain words')
failWrites = false
// ---- [4] a saved swap whose file will not load ------------------------------
// The exact shape of the loss: bytes ARE there but truncated, so the load fails.
await idbPut(BLOB_STORE, 'machine.boot-lost.glb', new TextEncoder().encode('trunc').buffer)
await saveOverrideManifest({ [BOOT]: { url: 'idb:machine.boot-lost.glb' } })
const reopened = new Workshop(stage)
const result = await reopened.restore()
ok(result.restored.length === 0, '[4] the broken swap did not come back on screen')
ok(result.unloadable.includes(BOOT), '[4] it is reported as unloadable rather than forgotten')
ok(reopened.hasCustom(BOOT), '[4] the entry is STILL in the working manifest')
ok(reopened.isUnloadable(BOOT), '[4] and is flagged so the UI can say the file is missing')
const saved = await reopened.saveLocally()
ok(saved.ok && saved.saved === 1, '[4] saving after that keeps the entry')
const keysAfter = (await idbKeys(BLOB_STORE)).map(String)
ok(keysAfter.includes('machine.boot-lost.glb'),
`[4] and the stored file was NOT deleted (keys: ${JSON.stringify(keysAfter)})`)
// ---- [5] the slot list is the runtime's, not a copy --------------------------
ok(JSON.stringify(SLOT_IDS) === JSON.stringify([...runtimeSlots.SLOT_IDS]),
'[5] the editor offers exactly the runtime slots, in the runtime order')
ok(SLOTS.every((s) => runtimeSlots.isSlotId(s.id)),
'[5] every slot the editor describes is one the runtime accepts')
ok(SLOTS.every((s) => s.label.length > 0 && s.hint.length > 0),
'[5] every slot has a name and a sentence, including ones with no bespoke text')
ok(SLOTS.every((s) => !/[a-z]+\.[a-z]/.test(s.label)),
'[5] no slot id leaks into a label shown on screen')
// ---- [6] the game can read what the editor writes ---------------------------
// Cross-lane: src/assets/idb.ts `getBlob` feeds its result straight into
// `new Blob([buf])`. The editor used to store a `{name, bytes, savedAt}` wrapper
// there, which produces a Blob of the string "[object Object]" and a GLB the
// game silently fails to parse. Both sides must speak raw ArrayBuffer.
{
const { putGlb } = await import('./store')
const runtimeIdb = await import('../assets/idb')
const bytes = buildTinyGlb({ half: 0.7 })
const url = await putGlb('machine.arch-cross-lane.glb', 'cross-lane.glb', bytes)
const readBack = await runtimeIdb.getBlob(runtimeIdb.idbKeyFromUrl(url))
ok(readBack instanceof ArrayBuffer,
`[6] the game's reader gets an ArrayBuffer back (got ${Object.prototype.toString.call(readBack)})`)
ok(readBack!.byteLength === bytes.byteLength,
'[6] byte-for-byte the same file the editor was handed')
}
console.log(`editor hardening.test: ${passed} assertions passed ✓`)

View File

@ -1,88 +0,0 @@
/**
* Lane J what storage does when the browser says no.
*
* In a Firefox/Safari private window `indexedDB` exists and `open()` then fires
* onerror. The old availability check was `typeof indexedDB !== 'undefined'`,
* which answered "yes, go ahead" and led to the whole editor being replaced by
* an error string. Three properties are asserted here:
*
* [1] availability is a real probe, so it says NO when open fails;
* [2] a failure is not memoised as a rejected promise storage coming back
* (a different database, a granted permission) works without a reload;
* [3] an open that neither succeeds nor errors (a blocked upgrade) still
* settles, so nothing waits forever.
*
* node --experimental-strip-types --import ./src/editor/node-ts-resolve.mjs \
* src/editor/idb-degrade.test.ts
*/
export {} // everything is loaded with await import(), so say "module" explicitly
type Mode = 'error' | 'ok' | 'silent'
let mode: Mode = 'error'
const stores = new Map<string, Map<string, unknown>>()
const fakeIndexedDb = {
open(_name: string) {
const req: Record<string, unknown> = {
result: null, error: null, onsuccess: null, onerror: null,
onupgradeneeded: null, onblocked: null,
}
if (mode === 'silent') return req // fires nothing, ever
if (mode === 'error') {
req.error = new Error('InvalidStateError: storage is not available here')
queueMicrotask(() => void (req.onerror as (() => void) | null)?.())
return req
}
const db = {
objectStoreNames: { contains: (s: string) => stores.has(s) },
createObjectStore: (s: string) => void stores.set(s, new Map()),
transaction: () => ({ objectStore: () => ({}) }),
}
req.result = db
queueMicrotask(() => {
;(req.onupgradeneeded as (() => void) | null)?.()
;(req.onsuccess as (() => void) | null)?.()
})
return req
},
}
;(globalThis as unknown as { indexedDB: unknown }).indexedDB = fakeIndexedDb
const { idbAvailable, resetIdb, setDbName } = await import('./idb')
let passed = 0
function ok(cond: boolean, msg: string): void {
if (!cond) throw new Error('FAIL: ' + msg)
passed++
}
// ---- [1] a real probe, not a typeof check ----------------------------------
ok(typeof indexedDB !== 'undefined',
'[1] the browser object exists (the old check would have said yes here)')
ok((await idbAvailable()) === false,
'[1] but actually opening it fails, so availability is false')
ok((await idbAvailable()) === false, '[1] the answer is stable and cached')
// ---- [2] the failure is not permanent --------------------------------------
mode = 'ok'
resetIdb()
ok((await idbAvailable()) === true,
'[2] once storage works, the same session gets a working database (no memoised rejection)')
// A different database name is a fresh question, never the old cached rejection.
mode = 'error'
setDbName('blobbo-workshop-demo')
ok((await idbAvailable()) === false, '[2] switching database re-asks the question')
// ---- [3] an open that never answers still settles --------------------------
mode = 'silent'
setDbName('blobbo-workshop-blocked')
const started = Date.now()
const answer = await idbAvailable()
ok(answer === false, '[3] a database that never answers is reported unavailable')
ok(Date.now() - started < 10_000,
`[3] and it gives up rather than hanging (took ${Date.now() - started}ms)`)
console.log(`editor idb-degrade.test: ${passed} assertions passed ✓`)

View File

@ -1,157 +0,0 @@
/**
* Lane J the local override store.
*
* Database/store names come from lanes/LANE-I §Deliverables 1 ('blobbo-workshop'
* with `manifest` + `blobs`): the editor WRITES here and the shipped game READS
* here, which is what makes a custom asset playable on the live site with no
* deploy. The manifest is a single record under MANIFEST_KEY; GLB bytes live in
* `blobs` under the key embedded in each `idb:<key>` url.
*
* Raw IDB rather than a wrapper because no new deps are allowed, and the surface
* needed here is four calls wide.
*
* Two rules this module exists to enforce:
* - The database NAME is a variable. The demo points itself at a throwaway
* database so running it can never touch a real saved swap.
* - A failed open is never memoised. A private window, a blocked upgrade or a
* momentarily unavailable disk would otherwise dead-end the whole session.
*/
export const DEFAULT_DB_NAME = 'blobbo-workshop'
/** The database the live game reads. Kept exported: existing callers import it. */
export const DB_NAME = DEFAULT_DB_NAME
export const DB_VERSION = 1
export const MANIFEST_STORE = 'manifest'
export const BLOB_STORE = 'blobs'
/** The single record in the `manifest` store that holds the working manifest. */
export const MANIFEST_KEY = 'current'
/** How long a blocked upgrade may hang before we call storage unusable. */
const OPEN_TIMEOUT_MS = 4000
let dbName = DEFAULT_DB_NAME
let dbPromise: Promise<IDBDatabase> | null = null
let availability: Promise<boolean> | null = null
/** Which database this process talks to. Switching drops every cached handle. */
export function setDbName(name: string): void {
if (name === dbName) return
dbName = name
dbPromise = null
availability = null
}
export const getDbName = (): string => dbName
/** Throw away the cached connection and the cached availability answer. */
export function resetIdb(): void {
dbPromise = null
availability = null
}
function openDb(): Promise<IDBDatabase> {
if (dbPromise) return dbPromise
const attempt = new Promise<IDBDatabase>((resolve, reject) => {
if (typeof indexedDB === 'undefined' || indexedDB === null) {
reject(new Error('This browser has no local storage for models.'))
return
}
let settled = false
const done = (fn: () => void): void => {
if (settled) return
settled = true
fn()
}
// A blocked upgrade fires neither success nor error — without this the
// caller waits forever and the UI never leaves its "reading…" state.
const timer = setTimeout(
() => done(() => reject(new Error('Local storage did not respond.'))),
OPEN_TIMEOUT_MS,
)
const finish = (fn: () => void): void => done(() => { clearTimeout(timer); fn() })
let req: IDBOpenDBRequest
try {
req = indexedDB.open(dbName, DB_VERSION)
} catch (err) {
finish(() => reject(err instanceof Error ? err : new Error(String(err))))
return
}
req.onupgradeneeded = () => {
const db = req.result
if (!db.objectStoreNames.contains(MANIFEST_STORE)) db.createObjectStore(MANIFEST_STORE)
if (!db.objectStoreNames.contains(BLOB_STORE)) db.createObjectStore(BLOB_STORE)
}
req.onsuccess = () => finish(() => resolve(req.result))
req.onerror = () => finish(() => reject(req.error ?? new Error('IndexedDB open failed')))
req.onblocked = () => finish(() => reject(new Error('Another tab is using this storage.')))
})
// Memoise the SUCCESS only: a cached rejection makes one bad moment permanent.
dbPromise = attempt
attempt.catch(() => { if (dbPromise === attempt) dbPromise = null })
return attempt
}
function run<T>(
store: string,
mode: IDBTransactionMode,
fn: (s: IDBObjectStore) => IDBRequest<T>,
): Promise<T> {
return openDb().then(
(db) =>
new Promise<T>((resolve, reject) => {
let req: IDBRequest<T>
try {
const tx = db.transaction(store, mode)
req = fn(tx.objectStore(store))
} catch (err) {
reject(err instanceof Error ? err : new Error(String(err)))
return
}
req.onsuccess = () => resolve(req.result)
req.onerror = () => reject(req.error ?? new Error('IndexedDB request failed'))
}),
)
}
export const idbGet = <T>(store: string, key: string): Promise<T | undefined> =>
run<T | undefined>(store, 'readonly', (s) => s.get(key) as IDBRequest<T | undefined>)
export const idbPut = (store: string, key: string, value: unknown): Promise<unknown> =>
run(store, 'readwrite', (s) => s.put(value, key) as IDBRequest<unknown>)
export const idbDelete = (store: string, key: string): Promise<unknown> =>
run(store, 'readwrite', (s) => s.delete(key) as IDBRequest<unknown>)
export const idbKeys = (store: string): Promise<IDBValidKey[]> =>
run<IDBValidKey[]>(store, 'readonly', (s) => s.getAllKeys())
export const idbClear = (store: string): Promise<unknown> =>
run(store, 'readwrite', (s) => s.clear() as IDBRequest<unknown>)
/**
* True when this browser can actually hold overrides. `typeof indexedDB` is not
* an answer: Safari/Firefox private windows expose the object and then fail the
* open, which is exactly the case the editor has to survive. So open it once for
* real and cache the BOOLEAN.
*/
export function idbAvailable(): Promise<boolean> {
if (!availability) availability = openDb().then(() => true, () => false)
return availability
}
/** Delete a whole database — the demo's cleanup, never used on the real one. */
export function deleteDb(name: string): Promise<void> {
return new Promise((resolve) => {
if (typeof indexedDB === 'undefined' || indexedDB === null) return resolve()
if (name === dbName) resetIdb()
let req: IDBOpenDBRequest
try {
req = indexedDB.deleteDatabase(name)
} catch {
return resolve()
}
req.onsuccess = () => resolve()
req.onerror = () => resolve()
req.onblocked = () => resolve()
})
}

View File

@ -1,497 +0,0 @@
/**
* Lane J the workshop editor page.
*
* Three panels in a CSS grid, no framework: slots (left), the real course
* (centre), fit controls (right). Written for an artist, not a programmer
* no slot ids, file paths or type names appear on screen, and every action has
* exactly one obvious button.
*
* The whole page is visual: it never registers a system and never ticks the
* sim (see stage.ts), so the hidden-tab fixed-step rule doesn't apply here.
*/
import { createEditorStage } from './stage'
import type { EditorStage } from './stage'
import { Workshop } from './workshop'
import type { DropResult } from './workshop'
import { SLOTS, SLOT_GROUPS, slotById } from './slots'
import type { SlotSpec } from './slots'
import { idbAvailable } from './idb'
import type { SlotEntry } from './manifest-io'
const el = <K extends keyof HTMLElementTagNameMap>(
tag: K, cls?: string, text?: string,
): HTMLElementTagNameMap[K] => {
const node = document.createElement(tag)
if (cls) node.className = cls
if (text !== undefined) node.textContent = text
return node
}
const asTriple = (v: number | [number, number, number] | undefined): [number, number, number] =>
v === undefined ? [0, 0, 0] : typeof v === 'number' ? [v, v, v] : v
const uniformScale = (v: SlotEntry['scale']): number =>
v === undefined ? 1 : typeof v === 'number' ? v : v[0]
interface FitRow {
label: string
get: (e: SlotEntry) => number
set: (e: SlotEntry, v: number) => Partial<SlotEntry>
min: number
max: number
step: number
}
/** Shown when the browser will not keep anything (private windows, blocked storage). */
const READ_ONLY_MESSAGE =
"This window won't let me keep anything, so nothing you do here will be remembered. " +
'You can still try models out and download a manifest.json. Open the workshop in a ' +
'normal window to save.'
const AXES = ['left / right', 'up / down', 'front / back'] as const
const FIT_ROWS: FitRow[] = [
...AXES.map((axis, i): FitRow => ({
label: `Move ${axis}`,
min: -6, max: 6, step: 0.05,
get: (e) => asTriple(e.offset)[i],
set: (e, v) => {
const next = asTriple(e.offset)
next[i] = v
return { offset: next }
},
})),
...(['Tilt', 'Turn', 'Roll'] as const).map((name, i): FitRow => ({
label: name,
min: -180, max: 180, step: 1,
get: (e) => asTriple(e.rotationDeg)[i],
set: (e, v) => {
const next = asTriple(e.rotationDeg)
next[i] = v
return { rotationDeg: next }
},
})),
{
label: 'Size', min: 0.05, max: 8, step: 0.05,
get: (e) => uniformScale(e.scale),
set: (_e, v) => ({ scale: v }),
},
]
export async function bootEditor(root: HTMLElement): Promise<{
stage: EditorStage
workshop: Workshop
/** Same code path a real drag-drop takes — the demo calls this. */
dropFile: (slot: string, name: string, bytes: ArrayBuffer) => Promise<DropResult>
select: (slot: string) => void
save: () => Promise<void>
exportManifest: () => string
clearAll: () => Promise<void>
status: () => string
/** False when this browser refuses to store anything (private windows). */
canSave: () => boolean
}> {
root.classList.add('workshop')
// ---- skeleton ------------------------------------------------------------
const left = el('aside', 'panel panel-slots')
const centre = el('main', 'panel panel-stage')
const right = el('aside', 'panel panel-fit')
const stageHost = el('div', 'stage-host')
centre.append(stageHost)
root.append(left, centre, right)
const stage = await createEditorStage(stageHost)
const workshop = new Workshop(stage)
// ---- left panel ----------------------------------------------------------
left.append(el('h1', 'brand', 'BLOBBO workshop'))
left.append(el('p', 'lede', 'Pick a thing, drop your model on it, nudge it until it looks right.'))
const slotButtons = new Map<string, HTMLButtonElement>()
for (const group of SLOT_GROUPS) {
const members = SLOTS.filter((s) => s.group === group)
if (!members.length) continue
left.append(el('h2', 'group', group))
const list = el('div', 'slot-list')
for (const spec of members) {
const button = el('button', 'slot')
button.append(el('span', 'slot-name', spec.label))
button.append(el('span', 'slot-state', ''))
button.title = spec.hint
button.addEventListener('click', () => select(spec.id))
list.append(button)
slotButtons.set(spec.id, button)
}
left.append(list)
}
const bigActions = el('div', 'big-actions')
const saveBtn = el('button', 'action primary', 'Save to this browser')
const testBtn = el('button', 'action', 'Test drive the game')
const exportBtn = el('button', 'action', 'Download manifest.json')
const clearBtn = el('button', 'action danger', 'Clear all my swaps')
bigActions.append(saveBtn, testBtn, exportBtn, clearBtn)
left.append(bigActions)
const statusLine = el('p', 'status', 'Nothing swapped yet — this is the game as it ships.')
left.append(statusLine)
const setStatus = (text: string, tone: 'ok' | 'warn' = 'ok'): void => {
statusLine.textContent = text
statusLine.dataset.tone = tone
}
// ---- centre panel overlay ------------------------------------------------
const stageHint = el('div', 'stage-hint',
'Drag to spin the view · scroll to zoom · right-drag to slide')
centre.append(stageHint)
const focusBtn = el('button', 'focus-btn', 'Show me the selected thing')
focusBtn.addEventListener('click', () => { if (selected) stage.focus(selected) })
centre.append(focusBtn)
// ---- right panel ---------------------------------------------------------
const fitTitle = el('h2', 'fit-title', 'Nothing selected')
const fitHint = el('p', 'fit-hint', 'Pick something from the list on the left.')
const dropZone = el('div', 'dropzone')
const dropLabel = el('div', 'dropzone-label', 'Drop a .glb file here')
const dropSub = el('div', 'dropzone-sub', 'or click to pick one from your computer')
dropZone.append(dropLabel, dropSub)
const filePicker = el('input')
filePicker.type = 'file'
filePicker.accept = '.glb,model/gltf-binary'
filePicker.style.display = 'none'
const paintCard = el('div', 'paint-card')
const fitControls = el('div', 'fit-controls')
const resetBtn = el('button', 'action', 'Put the original back')
const helpCard = el('details', 'help-card')
right.append(fitTitle, fitHint, dropZone, filePicker, paintCard, fitControls, resetBtn, helpCard)
helpCard.append(el('summary', '', 'How to make models that fit (and how to ship them)'))
const helpBody = el('div', 'help-body')
helpBody.innerHTML = `
<h3>In Blender, before you export</h3>
<ul>
<li>Work in <b>metres</b>, Y up, facing Z. The blob is about <b>1 metre wide</b>.</li>
<li>Put the origin in the <b>centre</b> for the blob, and at the <b>ground contact point</b> for props.</li>
<li>One material per model if you can. Textures no bigger than 2048×2048.</li>
<li>Export as <b>.glb</b> (embedded), not .gltf + files.</li>
</ul>
<h3>If it's the blob body</h3>
<ul>
<li>It has to be UV unwrapped, all in one 01 square, <b>no overlaps and no mirroring</b>
that's where the paint goes.</li>
<li>Rigged is fine: one armature, the looping animation named <code>idle</code>, keep it under ~40 bones.</li>
</ul>
<h3>Ship it for real</h3>
<ol>
<li>Press <b>Save to this browser</b>, then <b>Test drive</b> to play with it.</li>
<li>When you're happy, press <b>Download manifest.json</b>.</li>
<li>Copy your .glb files into <code>assets/live/</code> in the repo and put the
downloaded <code>manifest.json</code> next to them as <code>assets/manifest.json</code>.</li>
<li>Change every url that starts with <code>idb:</code> to <code>assets/live/yourfile.glb</code>.</li>
<li>Redeploy. Everyone sees it.</li>
</ol>
<p><b>Saved here stays here.</b> Swaps you save live in this browser only
nobody else sees them until you do the steps above.</p>`
helpCard.append(helpBody)
// ---- selection state -----------------------------------------------------
let selected: string | null = null
const refreshSlotStates = (): void => {
for (const [id, button] of slotButtons) {
const spec = slotById(id)!
const state = button.querySelector('.slot-state') as HTMLElement
if (!stage.slots.has(id)) {
// The runtime has a slot for this, but this course never builds one.
// Saying so beats a row that looks live and does nothing when clicked.
state.textContent = 'not in this course'
button.dataset.state = 'absent'
} else if (spec.derivedFrom) {
state.textContent = 'copies the blob'
button.dataset.state = 'derived'
} else if (workshop.isUnloadable(id)) {
state.textContent = 'file missing'
button.dataset.state = 'broken'
} else if (workshop.hasCustom(id)) {
state.textContent = 'yours'
button.dataset.state = 'custom'
} else {
state.textContent = 'original'
button.dataset.state = 'plain'
}
button.dataset.selected = String(id === selected)
}
}
const renderPaintCard = (spec: SlotSpec): void => {
paintCard.textContent = ''
if (!spec.paintable) {
paintCard.style.display = 'none'
return
}
paintCard.style.display = 'block'
const report = workshop.paintReport(spec.id)
if (!report) {
paintCard.dataset.tone = 'idle'
paintCard.append(el('div', 'paint-head', 'Paint check'))
paintCard.append(el('div', 'paint-note',
'Drop a blob model and I will check the paint sticks to it.'))
return
}
paintCard.dataset.tone = report.uvOk && report.problems.length === 0 ? 'ok' : 'bad'
paintCard.append(el('div', 'paint-head',
report.uvOk && report.problems.length === 0
? 'Paint check passed'
: 'Paint problem'))
const facts = el('div', 'paint-facts')
facts.append(el('span', '', `${report.triCount.toLocaleString()} triangles`))
facts.append(el('span', '', `${report.materialCount} material${report.materialCount === 1 ? '' : 's'}`))
facts.append(el('span', '', report.uvOk ? 'UV map looks right' : 'UV map is wrong'))
paintCard.append(facts)
for (const problem of report.problems) paintCard.append(el('div', 'paint-note', problem))
}
const renderFitControls = (): void => {
fitControls.textContent = ''
const entry = selected ? workshop.entry(selected) : undefined
if (!selected || !entry) {
fitControls.style.display = 'none'
resetBtn.style.display = 'none'
return
}
fitControls.style.display = 'block'
resetBtn.style.display = 'block'
for (const row of FIT_ROWS) {
const wrap = el('label', 'fit-row')
wrap.append(el('span', 'fit-label', row.label))
const slider = el('input')
slider.type = 'range'
slider.min = String(row.min)
slider.max = String(row.max)
slider.step = String(row.step)
const number = el('input')
number.type = 'number'
number.step = String(row.step)
const current = row.get(entry)
slider.value = String(current)
number.value = String(Number(current.toFixed(3)))
const push = (raw: string): void => {
const value = Number(raw)
if (!Number.isFinite(value) || !selected) return
const live = workshop.entry(selected)
if (!live) return
workshop.setFit(selected, row.set(live, value))
slider.value = String(value)
number.value = String(Number(value.toFixed(3)))
setStatus('Nudged. Press "Save to this browser" when it looks right.')
}
slider.addEventListener('input', () => push(slider.value))
number.addEventListener('change', () => push(number.value))
wrap.append(slider, number)
fitControls.append(wrap)
}
}
const select = (slot: string): void => {
selected = slot
const spec = slotById(slot)
if (!spec) return
const absent = !stage.slots.has(slot)
fitTitle.textContent = spec.label
fitHint.textContent = absent
? `${spec.hint} There isn't one of these anywhere in the course at the moment, ` +
'so there is nothing here to replace.'
: spec.derivedFrom
? `${spec.hint} There is nothing to drop here.`
: spec.hint
dropZone.style.display = spec.derivedFrom || absent ? 'none' : 'flex'
stage.highlight(slot)
stage.focus(slot)
renderPaintCard(spec)
renderFitControls()
refreshSlotStates()
}
// ---- drop plumbing -------------------------------------------------------
const handleFile = async (file: File): Promise<void> => {
if (!selected) {
setStatus('Pick something on the left first, then drop your file.', 'warn')
return
}
setStatus(`Reading ${file.name}`)
let bytes: ArrayBuffer
try {
// The file can be moved or renamed between picking it and reading it, and
// then this throws — leaving the status stuck on "Reading…" forever.
bytes = await file.arrayBuffer()
} catch (err) {
console.warn('[workshop] could not read the dropped file', err)
setStatus(`I couldn't read ${file.name}. Is it still where it was?`, 'warn')
return
}
const result = await workshop.dropFile(selected, file.name, bytes)
setStatus(result.message, result.rejected ? 'warn' : 'ok')
const spec = slotById(selected)
if (spec) renderPaintCard(spec)
renderFitControls()
refreshSlotStates()
}
/** Every DOM handler funnels through here: nothing may end as a dead promise. */
const guard = (label: string, work: () => Promise<void>): void => {
void work().catch((err: unknown) => {
console.error(`[workshop] ${label} failed`, err)
setStatus('Something went wrong there and nothing was changed. Try again.', 'warn')
})
}
const stop = (e: Event): void => { e.preventDefault(); e.stopPropagation() }
for (const type of ['dragenter', 'dragover'] as const) {
dropZone.addEventListener(type, (e) => { stop(e); dropZone.dataset.hot = 'true' })
}
for (const type of ['dragleave', 'drop'] as const) {
dropZone.addEventListener(type, (e) => { stop(e); dropZone.dataset.hot = 'false' })
}
dropZone.addEventListener('drop', (e) => {
const file = (e as DragEvent).dataTransfer?.files?.[0]
if (file) guard('the drop', () => handleFile(file))
})
dropZone.addEventListener('click', () => filePicker.click())
filePicker.addEventListener('change', () => {
const file = filePicker.files?.[0]
if (file) guard('the drop', () => handleFile(file))
filePicker.value = ''
})
// Dropping anywhere else must not make the browser navigate to the file.
addEventListener('dragover', (e) => e.preventDefault())
addEventListener('drop', (e) => e.preventDefault())
// ---- buttons -------------------------------------------------------------
resetBtn.addEventListener('click', () => {
if (!selected) return
workshop.reset(selected)
const spec = slotById(selected)
if (spec) renderPaintCard(spec)
renderFitControls()
refreshSlotStates()
setStatus('Back to the original. Save if you want that to stick.')
})
const save = async (): Promise<void> => {
if (!canSave) {
setStatus(READ_ONLY_MESSAGE, 'warn')
return
}
const result = await workshop.saveLocally()
setStatus(result.message, result.ok ? 'ok' : 'warn')
}
saveBtn.addEventListener('click', () => guard('saving', save))
testBtn.addEventListener('click', () => guard('the test drive', async () => {
if (!canSave) {
setStatus(READ_ONLY_MESSAGE, 'warn')
return
}
const result = await workshop.saveLocally()
if (!result.ok) {
// Opening the game now would show none of what is on screen here.
setStatus(result.message, 'warn')
return
}
setStatus('Opened the game in a new tab — your swaps are already in it.')
open(import.meta.env.BASE_URL, '_blank')
}))
const exportManifest = (): string => workshop.exportText()
exportBtn.addEventListener('click', () => {
const text = exportManifest()
const url = URL.createObjectURL(new Blob([text], { type: 'application/json' }))
const a = el('a')
a.href = url
a.download = 'manifest.json'
a.click()
setTimeout(() => URL.revokeObjectURL(url), 1000)
setStatus('Downloaded manifest.json — open the help below to see where it goes.')
})
const clearAll = async (): Promise<void> => {
const ok = await workshop.clearEverything()
refreshSlotStates()
renderFitControls()
if (selected) {
const spec = slotById(selected)
if (spec) renderPaintCard(spec)
}
setStatus(ok
? 'All swaps cleared. The game is back to how it ships.'
: "Everything is off the screen, but this browser wouldn't let me clear what it had stored.",
ok ? 'ok' : 'warn')
}
clearBtn.addEventListener('click', () => {
if (confirm('Throw away every model you dropped in? This cannot be undone.')) {
guard('clearing', clearAll)
}
})
// ---- can this browser keep anything? -------------------------------------
// A real open(), not a `typeof indexedDB` guess: private windows expose the
// object and then fail. Failing that probe means a look-but-don't-save
// editor, NOT an error page over a stage that built perfectly well.
const canSave = await idbAvailable()
if (!canSave) {
saveBtn.disabled = true
clearBtn.disabled = true
testBtn.disabled = true
saveBtn.textContent = "Can't save in this window"
const banner = el('p', 'readonly-banner', READ_ONLY_MESSAGE)
left.insertBefore(banner, bigActions)
setStatus(READ_ONLY_MESSAGE, 'warn')
}
// ---- restore a previous session -----------------------------------------
refreshSlotStates()
renderFitControls()
if (canSave) {
const { restored, unloadable } = await workshop.restore()
if (restored.length || unloadable.length) refreshSlotStates()
const names = unloadable.map((id) => slotById(id)?.label ?? id).join(', ')
if (unloadable.length) {
// Kept, not dropped: the entry stays in the manifest so saving again can
// never delete the stored file it points at.
setStatus(
`Brought back ${restored.length} swap${restored.length === 1 ? '' : 's'}. ` +
`I couldn't find the file for: ${names}. Those are still listed — drop the ` +
'model in again, or press "Put the original back" on each one.', 'warn')
} else if (restored.length) {
setStatus(`Brought back ${restored.length} swap${restored.length === 1 ? '' : 's'} from last time.`)
}
}
return {
stage,
workshop,
dropFile: (slot, name, bytes) => workshop.dropFile(slot, name, bytes),
select,
save,
exportManifest,
clearAll,
status: () => statusLine.textContent ?? '',
canSave: () => canSave,
}
}
// Auto-boot when loaded as the editor page. The demo page marks its host
// `data-manual` and calls bootEditor itself, so it never gets two editors.
const host = document.getElementById('workshop')
if (host && !host.hasAttribute('data-manual')) {
void bootEditor(host).catch((err: unknown) => {
// Storage problems no longer reach here — bootEditor degrades instead. What
// is left is a genuinely dead page (no WebGL), so a plain sentence is right.
console.error(err)
host.textContent =
"The workshop couldn't start in this browser. It needs 3D graphics turned on — " +
'try a different browser, or check your browser settings for hardware acceleration.'
})
}

View File

@ -1,182 +0,0 @@
/**
* Unit checks for the editor's pure core. No test runner / no deps run directly:
* node --experimental-strip-types --import ./src/editor/node-ts-resolve.mjs \
* src/editor/manifest-io.test.ts
* (from the repo root; the --import hook teaches node the repo's extension-less
* imports, which its ESM resolver rejects on its own). Same shape as
* paint/coverage-math.test.ts: console + throw only, never bundled.
*/
import {
IDB_README, SHIPPABLE_README,
cleanManifest, composeFit, idbKey, identityTransform, isIdbUrl, isNeutralFit,
manifestsEqual, parseEntry, parseManifest, serializeManifest,
} from './manifest-io'
import type { Manifest } from './manifest-io'
import { buildTinyGlb, isGlb } from './tiny-glb'
import { SLOTS, SLOT_IDS, slotById } from './slots'
// node:fs, reached through a computed specifier so tsc (whose lib is DOM-only,
// and whose config this lane may not edit) never tries to resolve it. Only this
// file — which never ships — touches it.
const { readFileSync } = await import('node' + ':fs')
let passed = 0
function ok(cond: boolean, msg: string): void {
if (!cond) throw new Error('FAIL: ' + msg)
passed++
}
function eq(a: unknown, b: unknown, msg: string): void {
ok(JSON.stringify(a) === JSON.stringify(b), `${msg} (got ${JSON.stringify(a)}, want ${JSON.stringify(b)})`)
}
// ---- the slot list, the stage and the runtime ------------------------------
// Static source check: the stage builds its objects inside WebGL-only code that
// can't run here, but the registration calls are greppable.
//
// The three lists are deliberately NOT required to be equal, because two honest
// gaps exist and hiding either one is how the editor started lying:
//
// NOT_IN_THIS_COURSE — the runtime supports the slot but this course builds no
// such object (nothing calls createFan or createSeeSaw). The editor still
// LISTS these, marked unavailable, rather than pretending the game has no
// fan slot at all. `stage.slots.has(id)` is what the UI tests at run time.
// The runtime has since adopted cannon.base / course.tramp / course.tunnel /
// course.finish, so the editor may offer every id it registers: the check below
// is now strict in both directions. It is the guard that keeps the two halves
// from drifting again — a slot the stage previews but the runtime would discard
// is exactly the bug that silently ate a reskin before the vocabularies merged.
{
const stageSource = readFileSync(new URL('./stage.ts', import.meta.url), 'utf8')
const registered = new Set(
[...stageSource.matchAll(/register\('([^']+)'/g)].map((m) => m[1]!))
const knownIds = new Set<string>(SLOT_IDS)
const NOT_IN_THIS_COURSE = new Set(['machine.fan', 'machine.seesaw'])
for (const id of SLOT_IDS) {
ok(registered.has(id) || NOT_IN_THIS_COURSE.has(id),
`the stage builds something for the "${id}" slot, or it is a known course gap`)
}
for (const id of registered) {
ok(knownIds.has(id),
`stage.ts registers "${id}", which the runtime knows about`)
}
}
// ---- slot catalogue --------------------------------------------------------
{
ok(SLOT_IDS.length === new Set(SLOT_IDS).size, 'slot ids are unique')
ok(SLOTS.every((s) => s.label.length > 0 && s.hint.length > 0), 'every slot is labelled')
ok(SLOTS.every((s) => !/[._]/.test(s.label)), 'no slot id leaks into an on-screen label')
const derived = SLOTS.filter((s) => s.derivedFrom)
ok(derived.length > 0, 'at least one derived slot exists (the ghost)')
ok(derived.every((s) => slotById(s.derivedFrom!) !== undefined), 'derived slots point at real slots')
ok(SLOTS.filter((s) => s.paintable).length === 1, 'exactly one paintable slot (the body)')
}
// ---- entry parsing ---------------------------------------------------------
{
ok(parseEntry(null) === null, 'null is not an entry')
ok(parseEntry({}) === null, 'an entry without a url is dropped')
ok(parseEntry({ url: '' }) === null, 'an empty url is dropped')
eq(parseEntry({ url: 'a.glb' }), { url: 'a.glb' }, 'a bare url survives')
eq(parseEntry({ url: 'a.glb', offset: [1, 2] }), { url: 'a.glb' }, 'a short offset is dropped')
eq(parseEntry({ url: 'a.glb', offset: [1, 'x', 3] }), { url: 'a.glb' }, 'a non-numeric offset is dropped')
eq(parseEntry({ url: 'a.glb', scale: 2 }), { url: 'a.glb', scale: 2 }, 'a uniform scale survives')
eq(parseEntry({ url: 'a.glb', scale: [1, 2, 3] }), { url: 'a.glb', scale: [1, 2, 3] }, 'a triple scale survives')
eq(parseEntry({ url: 'a.glb', idleClip: 'idle' }), { url: 'a.glb', idleClip: 'idle' }, 'idleClip survives')
}
// ---- manifest parsing is total --------------------------------------------
{
eq(parseManifest('not json at all'), {}, 'bad JSON parses to an empty manifest')
eq(parseManifest('[1,2,3]'), {}, 'an array parses to an empty manifest')
eq(parseManifest('null'), {}, 'null parses to an empty manifest')
eq(parseManifest('{}'), {}, 'an empty object parses to an empty manifest')
eq(parseManifest('{"machine.boot":{"url":"a.glb"},"bad":7}'),
{ 'machine.boot': { url: 'a.glb' } }, 'malformed slots are skipped, good ones kept')
eq(parseManifest(`{"_readme":"hi","machine.boot":{"url":"a.glb"}}`),
{ 'machine.boot': { url: 'a.glb' } }, '_readme is metadata, not a slot')
}
// ---- round trip ------------------------------------------------------------
{
const m: Manifest = {
'machine.boot': { url: 'idb:machine.boot-prop-spring-boot.glb', offset: [0, 0.2, 0], rotationDeg: [0, 90, 0], scale: 1.4 },
'blob.body': { url: 'assets/live/blobbo.glb', scale: [1, 1.2, 1], idleClip: 'idle' },
}
const text = serializeManifest(m)
const back = parseManifest(text)
ok(manifestsEqual(m, back), 'serialize → parse round-trips exactly')
eq(back, cleanManifest(m), 'the round trip deep-equals the cleaned manifest')
ok(JSON.parse(text)._readme === IDB_README, 'an export holding idb: urls carries the commit-it note')
ok(Object.keys(JSON.parse(text)).indexOf('_readme') === 0, '_readme comes first so it reads like a header')
ok(text.endsWith('\n'), 'the export ends with a newline (diff-friendly)')
const shippable: Manifest = { 'machine.boot': { url: 'assets/live/boot.glb' } }
const shippableDoc = JSON.parse(serializeManifest(shippable))
ok(shippableDoc._readme === SHIPPABLE_README, 'a shippable export gets the ship-it note')
ok(shippableDoc._readme !== IDB_README, 'a shippable export does NOT nag about idb urls')
// key order must not change equality
const reordered: Manifest = { 'blob.body': m['blob.body']!, 'machine.boot': m['machine.boot']! }
ok(manifestsEqual(m, reordered), 'slot order does not affect manifest equality')
ok(Object.keys(cleanManifest(reordered))[0] === 'blob.body', 'exports are sorted by slot')
}
// ---- idb urls --------------------------------------------------------------
{
ok(isIdbUrl('idb:x'), 'idb: prefix detected')
ok(!isIdbUrl('assets/live/x.glb'), 'a normal path is not an idb url')
ok(idbKey('idb:machine.boot-a.glb') === 'machine.boot-a.glb', 'the key is everything after idb:')
ok(idbKey('assets/live/x.glb') === '', 'a non-idb url has no key')
}
// ---- fit maths -------------------------------------------------------------
{
const base = { position: [1, 2, 3], rotationDeg: [0, 45, 0], scale: [1, 1, 1] } as const
const neutral = composeFit({ ...base, position: [...base.position], rotationDeg: [...base.rotationDeg], scale: [...base.scale] } as never, { url: 'a.glb' })
eq(neutral.position, [1, 2, 3], 'an empty fit leaves the asset exactly on the procedural pose')
eq(neutral.rotationDeg, [0, 45, 0], 'an empty fit keeps the procedural rotation')
eq(neutral.scale, [1, 1, 1], 'an empty fit keeps the procedural scale')
const fitted = composeFit(
{ position: [1, 2, 3], rotationDeg: [0, 45, 0], scale: [2, 2, 2] },
{ url: 'a.glb', offset: [0, 0.5, -1], rotationDeg: [0, 45, 10], scale: 3 })
eq(fitted.position, [1, 2.5, 2], 'offsets add to the base position')
eq(fitted.rotationDeg, [0, 90, 10], 'rotations add to the base rotation')
eq(fitted.scale, [6, 6, 6], 'scale multiplies the base scale')
const nonUniform = composeFit(identityTransform(), { url: 'a.glb', scale: [1, 2, 3] })
eq(nonUniform.scale, [1, 2, 3], 'a triple scale is applied per axis')
ok(isNeutralFit({ url: 'a.glb' }), 'a bare entry is a neutral fit')
ok(isNeutralFit({ url: 'a.glb', offset: [0, 0, 0], rotationDeg: [0, 0, 0], scale: 1 }), 'explicit zeros are neutral')
ok(!isNeutralFit({ url: 'a.glb', offset: [0, 0.1, 0] }), 'any offset is not neutral')
ok(!isNeutralFit({ url: 'a.glb', scale: 1.0001 }), 'any scale change is not neutral')
}
// ---- tiny glb --------------------------------------------------------------
{
const glb = buildTinyGlb()
ok(isGlb(glb), 'the generated buffer has a valid GLB header and length')
ok(glb.byteLength % 4 === 0, 'the GLB total length is 4-byte aligned')
const view = new DataView(glb)
const jsonLen = view.getUint32(12, true)
ok(jsonLen % 4 === 0, 'the JSON chunk is padded to 4 bytes')
const json = JSON.parse(new TextDecoder().decode(new Uint8Array(glb, 20, jsonLen)).trim())
ok(json.asset.version === '2.0', 'the glTF asset version is 2.0')
ok(json.meshes[0].primitives[0].attributes.TEXCOORD_0 !== undefined, 'the quad ships UVs by default')
ok(json.materials.length === 1, 'the quad has exactly one material')
const binStart = 20 + jsonLen
ok(view.getUint32(binStart + 4, true) === 0x004e4942, 'the second chunk is the BIN chunk')
ok(binStart + 8 + view.getUint32(binStart, true) === glb.byteLength, 'the BIN chunk fills the rest of the file')
const noUv = buildTinyGlb({ withUv: false })
ok(isGlb(noUv), 'the UV-less variant is still a valid GLB')
const noUvLen = new DataView(noUv).getUint32(12, true)
const noUvJson = JSON.parse(new TextDecoder().decode(new Uint8Array(noUv, 20, noUvLen)).trim())
ok(noUvJson.meshes[0].primitives[0].attributes.TEXCOORD_0 === undefined, 'the UV-less variant really has no UVs')
ok(!isGlb(new ArrayBuffer(8)), 'a stub buffer is not a GLB')
}
console.log(`editor manifest-io.test: ${passed} assertions passed ✓`)

View File

@ -1,164 +0,0 @@
/**
* Lane J the THREE-free, DOM-free core of the workshop editor.
*
* Everything here is pure so it can be exercised by `manifest-io.test.ts` under
* plain node (see coverage-math.test.ts for the house pattern). The types mirror
* Lane I's `src/assets/manifest.ts` spec verbatim (lanes/LANE-I §Deliverables 1)
* because Lane I lives in a parallel worktree integration re-points these
* imports at the real module and deletes the duplicates.
*/
/** Mirrors Lane I `SlotEntry`. */
export interface SlotEntry {
url: string
offset?: [number, number, number]
rotationDeg?: [number, number, number]
scale?: number | [number, number, number]
idleClip?: string
}
/** Mirrors Lane I `Manifest` (slot id → entry). */
export type Manifest = Record<string, SlotEntry>
/** The note stamped into an exported manifest.json that still holds idb: urls. */
export const IDB_README =
'Some urls start with "idb:" — those GLB files live only in this browser. ' +
'To make them permanent: copy the .glb files into assets/live/ in the repo, ' +
'change each "idb:..." url to "assets/live/<filename>.glb", then redeploy.'
/** The note stamped into an exported manifest.json whose urls are all shippable. */
export const SHIPPABLE_README =
'Drop this file in the repo as assets/manifest.json, put the .glb files in ' +
'assets/live/, then redeploy. No code changes needed.'
export const isIdbUrl = (url: string): boolean => url.startsWith('idb:')
/** The IndexedDB blob key behind an `idb:<key>` url ('' if not an idb url). */
export const idbKey = (url: string): string => (isIdbUrl(url) ? url.slice(4) : '')
// ---- fit maths -------------------------------------------------------------
/** A slot object's resting pose, in the units the manifest speaks. */
export interface BaseTransform {
position: [number, number, number]
rotationDeg: [number, number, number]
scale: [number, number, number]
}
export const identityTransform = (): BaseTransform => ({
position: [0, 0, 0], rotationDeg: [0, 0, 0], scale: [1, 1, 1],
})
const asTriple = (s: number | [number, number, number]): [number, number, number] =>
typeof s === 'number' ? [s, s, s] : [s[0], s[1], s[2]]
/**
* Where a dropped asset actually sits: the slot's procedural pose plus the
* manifest's offset/rotation/scale. Offsets ADD to the base position and
* rotations ADD (degrees, per axis) to the base rotation, so a manifest of all
* zeros/one lands the asset exactly where the procedural part stood which is
* what makes "reset to procedural" and "empty manifest = today's game" agree.
*/
export function composeFit(base: BaseTransform, entry: SlotEntry): BaseTransform {
const off = entry.offset ?? [0, 0, 0]
const rot = entry.rotationDeg ?? [0, 0, 0]
const sc = asTriple(entry.scale ?? 1)
return {
position: [base.position[0] + off[0], base.position[1] + off[1], base.position[2] + off[2]],
rotationDeg: [
base.rotationDeg[0] + rot[0],
base.rotationDeg[1] + rot[1],
base.rotationDeg[2] + rot[2],
],
scale: [base.scale[0] * sc[0], base.scale[1] * sc[1], base.scale[2] * sc[2]],
}
}
/** True when an entry carries no fit at all (asset sits exactly on the base pose). */
export function isNeutralFit(entry: SlotEntry): boolean {
const off = entry.offset ?? [0, 0, 0]
const rot = entry.rotationDeg ?? [0, 0, 0]
const sc = asTriple(entry.scale ?? 1)
return off.every((v) => v === 0) && rot.every((v) => v === 0) && sc.every((v) => v === 1)
}
// ---- serialisation ---------------------------------------------------------
const num3 = (v: unknown): [number, number, number] | undefined => {
if (!Array.isArray(v) || v.length !== 3) return undefined
const out = v.map((n) => (typeof n === 'number' && Number.isFinite(n) ? n : NaN))
return out.some(Number.isNaN) ? undefined : (out as [number, number, number])
}
/**
* Tolerant single-entry parse. Anything malformed is dropped rather than
* thrown a half-typed manifest must never take the editor (or the game) down.
*/
export function parseEntry(raw: unknown): SlotEntry | null {
if (!raw || typeof raw !== 'object') return null
const o = raw as Record<string, unknown>
if (typeof o.url !== 'string' || o.url === '') return null
const entry: SlotEntry = { url: o.url }
const offset = num3(o.offset)
if (offset) entry.offset = offset
const rotationDeg = num3(o.rotationDeg)
if (rotationDeg) entry.rotationDeg = rotationDeg
if (typeof o.scale === 'number' && Number.isFinite(o.scale)) entry.scale = o.scale
else {
const s3 = num3(o.scale)
if (s3) entry.scale = s3
}
if (typeof o.idleClip === 'string' && o.idleClip !== '') entry.idleClip = o.idleClip
return entry
}
/** Keys that are documentation, not slots — stripped on the way back in. */
const META_KEYS = new Set(['_readme', '_generatedBy'])
/** Tolerant whole-manifest parse. Bad JSON or a bad shape yields `{}`. */
export function parseManifest(text: string): Manifest {
let raw: unknown
try {
raw = JSON.parse(text)
} catch {
return {}
}
if (!raw || typeof raw !== 'object' || Array.isArray(raw)) return {}
const out: Manifest = {}
for (const [slot, value] of Object.entries(raw as Record<string, unknown>)) {
if (META_KEYS.has(slot)) continue
const entry = parseEntry(value)
if (entry) out[slot] = entry
}
return out
}
/** Drop keys the editor never wrote so exports stay diff-friendly. */
export function cleanManifest(manifest: Manifest): Manifest {
const out: Manifest = {}
for (const slot of Object.keys(manifest).sort()) {
const entry = parseEntry(manifest[slot])
if (entry) out[slot] = entry
}
return out
}
/**
* The exact bytes the download button hands over: sorted slots plus a plain-
* language `_readme`. `_readme` is metadata, and `parseManifest` strips it, so
* `parseManifest(serializeManifest(m))` deep-equals `cleanManifest(m)`.
*/
export function serializeManifest(manifest: Manifest): string {
const clean = cleanManifest(manifest)
const anyIdb = Object.values(clean).some((e) => isIdbUrl(e.url))
const doc: Record<string, unknown> = {
_readme: anyIdb ? IDB_README : SHIPPABLE_README,
...clean,
}
return JSON.stringify(doc, null, 2) + '\n'
}
/** Structural equality for manifests (used by the demo's round-trip assertion). */
export function manifestsEqual(a: Manifest, b: Manifest): boolean {
return JSON.stringify(cleanManifest(a)) === JSON.stringify(cleanManifest(b))
}

View File

@ -1,33 +0,0 @@
/**
* Lane J lets node run the repo's extension-less TS imports directly.
*
* The house test style (see paint/coverage-math.test.ts) imports './thing' with
* no extension, which is what tsc's "bundler" resolution and vite both want but
* which node's ESM resolver rejects outright. This registers a resolve hook that
* retries a bare relative specifier as `<specifier>.ts`, so:
*
* node --experimental-strip-types --import ./src/editor/node-ts-resolve.mjs \
* src/editor/manifest-io.test.ts
*
* Plain .mjs on purpose: tsconfig only compiles .ts, and nothing imports this
* file, so it never reaches tsc or the vite bundle.
*/
import { registerHooks } from 'node:module'
registerHooks({
resolve(specifier, context, next) {
try {
return next(specifier, context)
} catch (err) {
if (specifier.startsWith('.') && !/\.[a-z]+$/i.test(specifier)) {
try {
return next(specifier + '.ts', context)
} catch {
// '../machine' style directory imports resolve to their index.ts
return next(specifier + '/index.ts', context)
}
}
throw err
}
},
})

View File

@ -1,82 +0,0 @@
/**
* Headless check of the paint report, run against REAL parsed GLB data rather
* than a hand-built THREE object GLTFLoader.parse needs no DOM for an
* untextured mesh, so the whole "bytes in, verdict out" path is testable:
* node --experimental-strip-types --import ./src/editor/node-ts-resolve.mjs \
* src/editor/paintability.test.ts
*/
import * as THREE from 'three'
import { GLTFLoader } from 'three/examples/jsm/loaders/GLTFLoader.js'
import { buildTinyGlb } from './tiny-glb'
import { firstMesh, inspectPaintability } from './paintability'
let passed = 0
function ok(cond: boolean, msg: string): void {
if (!cond) throw new Error('FAIL: ' + msg)
passed++
}
const parse = (bytes: ArrayBuffer): Promise<THREE.Group> =>
new Promise((resolve, reject) => {
new GLTFLoader().parse(bytes, '', (gltf) => resolve(gltf.scene), reject)
})
// ---- a well-formed asset passes -------------------------------------------
{
const scene = await parse(buildTinyGlb())
const mesh = firstMesh(scene)
ok(mesh !== null, 'the parsed GLB yields a mesh')
ok((mesh as THREE.Mesh).isMesh === true, 'what PaintSkin would bind to really is a Mesh')
const report = inspectPaintability(scene)
ok(report.hasMesh, 'the report found a mesh')
ok(report.uvOk, 'a 0-1 UV map passes the paint check')
ok(report.triCount === 2, `the quad reports 2 triangles (got ${report.triCount})`)
ok(report.materialCount === 1, 'a single-material asset reports 1 material')
ok(report.problems.length === 0, `a good asset raises no problems (got ${JSON.stringify(report.problems)})`)
}
// ---- no UVs is rejected with a plain-language reason ------------------------
{
const scene = await parse(buildTinyGlb({ withUv: false }))
const report = inspectPaintability(scene)
ok(report.hasMesh, 'the UV-less asset still parses to a mesh')
ok(!report.uvOk, 'an asset with no UV map fails the paint check')
ok(report.problems.length > 0, 'the failure comes with a reason')
ok(/unwrap/i.test(report.problems[0]!), 'the reason tells you to unwrap it in Blender')
ok(!/uv attribute|BufferAttribute|undefined/i.test(report.problems[0]!),
'the reason is written for an artist, not a programmer')
}
// ---- an empty group is reported, not crashed on ----------------------------
{
const report = inspectPaintability(new THREE.Group())
ok(!report.hasMesh, 'an empty asset reports no mesh')
ok(!report.uvOk, 'an empty asset cannot be painted')
ok(report.problems.length === 1, 'an empty asset gets exactly one complaint')
}
// ---- UVs outside 0..1 are caught (the silent wrap-around bug) --------------
{
const scene = await parse(buildTinyGlb())
const mesh = firstMesh(scene)!
const uv = mesh.geometry.getAttribute('uv') as THREE.BufferAttribute
uv.setXY(2, 2.5, 1) // push one corner into the next UV tile
uv.needsUpdate = true
const report = inspectPaintability(scene)
ok(!report.uvOk, 'UVs running past 1 fail the paint check')
ok(/0.{0,3}1|square/i.test(report.problems[0]!), 'the reason names the 0-1 square')
}
// ---- more than one material is caught --------------------------------------
{
const scene = await parse(buildTinyGlb())
const mesh = firstMesh(scene)!
mesh.material = [mesh.material as THREE.Material, new THREE.MeshStandardMaterial()]
const report = inspectPaintability(scene)
ok(report.materialCount === 2, 'a material array is counted')
ok(report.problems.some((p) => /single material/i.test(p)),
'multi-material assets are told to join down to one')
}
console.log(`editor paintability.test: ${passed} assertions passed ✓`)

View File

@ -1,88 +0,0 @@
/**
* Lane J "can paint stick to this?" report for the blob body slot.
*
* Mirrors Lane I's `paintableInfo` (lanes/LANE-I §Deliverables 2) so integration
* can collapse the two. The checks exist because PaintSkin stamps into UV space
* through a raycast: no UV attribute means no stamps at all, UVs outside 0..1
* wrap into unrelated parts of the texture, and more than one material means
* PaintSkin's `mat.map = tex` lands on only one of them (the blob then reads as
* unpainted while coverage still climbs a silent, maddening divergence).
*/
import * as THREE from 'three'
export interface PaintableInfo {
/** A UV attribute exists and stays inside 0..1 (with a hair of tolerance). */
uvOk: boolean
triCount: number
materialCount: number
/** False when nothing mesh-like was found at all. */
hasMesh: boolean
/** Plain-language reasons the report is unhappy; empty when all good. */
problems: string[]
}
const UV_TOLERANCE = 0.001
/** The mesh PaintSkin would end up bound to: the first Mesh in the subtree. */
export function firstMesh(object: THREE.Object3D): THREE.Mesh | null {
let found: THREE.Mesh | null = null
object.traverse((o) => {
if (!found && (o as THREE.Mesh).isMesh) found = o as THREE.Mesh
})
return found
}
export function inspectPaintability(object: THREE.Object3D): PaintableInfo {
const mesh = firstMesh(object)
if (!mesh) {
return {
uvOk: false, triCount: 0, materialCount: 0, hasMesh: false,
problems: ['This file has no model in it that paint could stick to.'],
}
}
const geo = mesh.geometry
const problems: string[] = []
const uv = geo.getAttribute('uv') as THREE.BufferAttribute | undefined
let uvOk = false
if (!uv) {
problems.push('No UV map — paint has nowhere to land. Unwrap the model in Blender.')
} else {
let min = Infinity
let max = -Infinity
for (let i = 0; i < uv.count; i++) {
const u = uv.getX(i)
const v = uv.getY(i)
if (u < min) min = u
if (v < min) min = v
if (u > max) max = u
if (v > max) max = v
}
uvOk = min >= -UV_TOLERANCE && max <= 1 + UV_TOLERANCE
if (!uvOk) {
problems.push(
`UV map runs outside the square (${min.toFixed(2)} to ${max.toFixed(2)}) — ` +
'splats will show up in the wrong places. Pack the UVs into 01.',
)
}
}
const index = geo.getIndex()
const position = geo.getAttribute('position') as THREE.BufferAttribute | undefined
const triCount = Math.floor((index ? index.count : (position?.count ?? 0)) / 3)
const materialCount = Array.isArray(mesh.material) ? mesh.material.length : 1
if (materialCount > 1) {
problems.push(
`${materialCount} materials on the body — paint only ever shows on one. ` +
'Join it down to a single material in Blender.',
)
}
const single = Array.isArray(mesh.material) ? mesh.material[0] : mesh.material
if (single && !(single as THREE.MeshStandardMaterial).isMeshStandardMaterial) {
problems.push('The body material is an unusual type — export it as a Principled BSDF.')
}
return { uvOk, triCount, materialCount, hasMesh: true, problems }
}

View File

@ -1,191 +0,0 @@
/**
* Lane J the swap itself, as plain scene-graph surgery.
*
* Split out of stage.ts so it can be driven headlessly (no WebGL, no canvas):
* everything here is THREE object maths. The rule it encodes is the one the
* runtime has to follow too a custom asset never replaces the procedural node,
* it goes into a NEW sibling "fit" node and the procedural node is hidden. That
* keeps reset instant, keeps every reference other systems captured alive, and
* keeps the manifest's offset/rotation/scale on a node nobody else writes to
* (feel.ts, telegraph.ts and the machine parts all own transforms of their own).
*
* ONE FIT NODE PER PROCEDURAL OBJECT. Several slots are backed by more than one
* object nine paint puddles under `fx.puddle`, one barrel per cannon under
* `cannon.barrel`. Hiding all of them and adding a single replacement made the
* rest of the course silently disappear. The runtime calls `attachSlot` once per
* instance (see paint/puddles.ts and paint/cannon.ts), so the editor clones the
* asset per instance at that instance's own resting pose, and what the editor
* shows is what the game builds.
*/
import * as THREE from 'three'
import { composeFit } from './manifest-io'
import type { BaseTransform, SlotEntry } from './manifest-io'
export const DEG = Math.PI / 180
/** How a slot clones its asset. Injected so this module never imports GLTF. */
export type Cloner = (object: THREE.Object3D) => THREE.Object3D
const plainClone: Cloner = (object) => object.clone(true)
export interface SlotStageEntry {
id: string
/** What the game builds today. Hidden — never removed — while a swap is in. */
procedural: THREE.Object3D[]
/** Parent the fit nodes hang off: each procedural object's own parent. */
parent: THREE.Object3D
/** Resting pose of the FIRST procedural object, in manifest units. */
base: BaseTransform
/** Resting pose of every procedural object, index-aligned with `procedural`. */
bases: BaseTransform[]
/**
* Per-instance extra scale, index-aligned. The runtime stretches a puddle
* decal to its own strip footprint (`fit:` in paint/puddles.ts); without the
* same multiplier the editor preview would be a different size to the game.
*/
extraScale: [number, number, number][]
/** One wrapper per procedural object, or empty when the slot is procedural. */
fitNodes: THREE.Group[]
/** The first wrapper, or null. Kept so single-instance callers read naturally. */
fitNode: THREE.Group | null
}
/** Read an object's pose in manifest units (degrees, not radians). */
export function readBase(object: THREE.Object3D): BaseTransform {
const e = new THREE.Euler().setFromQuaternion(object.quaternion, 'XYZ')
return {
position: [object.position.x, object.position.y, object.position.z],
rotationDeg: [e.x / DEG, e.y / DEG, e.z / DEG],
scale: [object.scale.x, object.scale.y, object.scale.z],
}
}
/**
* Farm and Blender exports arrive with shadow flags off, which reads as a
* floating prop rather than a missing checkbox so turn them on for everything
* that lands in a slot.
*/
export function dressAsset(object: THREE.Object3D): void {
object.traverse((o) => {
const m = o as THREE.Mesh
if (!m.isMesh) return
m.castShadow = true
m.receiveShadow = true
})
}
export interface RegisterOptions {
/** Per-object extra scale (puddle footprints). Index-aligned with `objects`. */
extraScale?: [number, number, number][]
/**
* Override the resting pose a fit node is anchored at, when the object being
* HIDDEN is not the object the runtime anchors to. Index-aligned with
* `objects`; a null entry keeps the object's own pose.
*
* Only cannon.barrel needs this today: the runtime attaches the barrel model
* to the whole cannon group (so a custom barrel does not inherit the aim
* spin) while hiding the tube inside the aiming pivot. Anchoring the preview
* on the pivot instead put every barrel 0.45m too high in the editor and
* 0.45m lower in the game WYSIWYG broke by a measurable amount.
*/
anchors?: (BaseTransform | null)[]
}
export class SlotSwap {
readonly slots = new Map<string, SlotStageEntry>()
private readonly clone: Cloner
// Plain assignment rather than a parameter property: node's type-stripping
// mode (how the .test.ts files run) rejects parameter properties outright.
constructor(clone: Cloner = plainClone) {
this.clone = clone
}
/** Called once per slot as the stage builds. First object defines the pose. */
register(
id: string,
objects: THREE.Object3D[],
fallbackParent: THREE.Object3D,
opts: RegisterOptions = {},
): void {
const first = objects[0]
if (!first) return
const bases = objects.map((o, i) => opts.anchors?.[i] ?? readBase(o))
this.slots.set(id, {
id,
procedural: objects,
parent: first.parent ?? fallbackParent,
base: bases[0]!,
bases,
extraScale: objects.map((_, i) => opts.extraScale?.[i] ?? [1, 1, 1]),
fitNodes: [],
fitNode: null,
})
}
has(id: string): boolean {
return this.slots.has(id)
}
/** How many of this thing the course actually holds. */
instanceCount(id: string): number {
return this.slots.get(id)?.procedural.length ?? 0
}
setCustom(id: string, asset: THREE.Object3D, entry: SlotEntry): void {
const s = this.slots.get(id)
if (!s) return
this.clearCustom(id)
s.procedural.forEach((original, i) => {
const fitNode = new THREE.Group()
fitNode.name = s.procedural.length > 1 ? `fit:${id}#${i}` : `fit:${id}`
// Always a copy, never `asset` itself: the caller caches parsed scenes and
// re-uses them across slots, and a scene graph node can only have one
// parent — adopting the original would tear it out of wherever it was.
const instance = this.clone(asset)
dressAsset(instance)
fitNode.add(instance)
;(original.parent ?? s.parent).add(fitNode)
s.fitNodes.push(fitNode)
original.visible = false
})
s.fitNode = s.fitNodes[0] ?? null
this.applyFit(id, entry)
}
clearCustom(id: string): void {
const s = this.slots.get(id)
if (!s) return
for (const node of s.fitNodes) node.parent?.remove(node)
s.fitNodes = []
s.fitNode = null
for (const p of s.procedural) p.visible = true
}
applyFit(id: string, entry: SlotEntry): void {
const s = this.slots.get(id)
if (!s) return
s.fitNodes.forEach((node, i) => {
const base = s.bases[i] ?? s.base
const extra = s.extraScale[i] ?? [1, 1, 1]
const fit = composeFit(base, entry)
node.position.set(fit.position[0], fit.position[1], fit.position[2])
node.rotation.set(
fit.rotationDeg[0] * DEG, fit.rotationDeg[1] * DEG, fit.rotationDeg[2] * DEG)
node.scale.set(
fit.scale[0] * extra[0], fit.scale[1] * extra[1], fit.scale[2] * extra[2])
})
}
/** What the fit panel is acting on: the custom asset if any, else the original. */
currentObject(id: string): THREE.Object3D | null {
const s = this.slots.get(id)
if (!s) return null
return s.fitNodes[0] ?? s.procedural[0] ?? null
}
/** True when this slot is showing a custom asset. */
isCustom(id: string): boolean {
return (this.slots.get(id)?.fitNodes.length ?? 0) > 0
}
}

View File

@ -1,180 +0,0 @@
/**
* Lane J the slot catalogue the editor's left panel renders.
*
* The VOCABULARY is not defined here. `SLOT_IDS` and `SLOT_LABELS` come from
* src/assets/slots.ts, which is the same list the runtime registry answers to
* an editor that offered a slot the game discards (or hid one the game supports)
* would be lying to the user, and the two lists drifted apart exactly that way
* before this import existed.
*
* What lives here is PRESENTATION only: help text, camera framing, grouping,
* ordering. It is keyed by `SlotId`, so describing a slot the runtime does not
* have is a compile error rather than a dead row in the list, and a slot the
* runtime adds shows up automatically with its runtime label.
*
* THREE-free on purpose: the stage maps ids to objects, this file only names
* them, so the catalogue is testable and cheap to import anywhere.
*/
import { SLOT_IDS as RUNTIME_SLOT_IDS, SLOT_LABELS, SLOT_NOTES } from '../assets/slots'
import type { SlotId } from '../assets/slots'
export type { SlotId }
export { isSlotId } from '../assets/slots'
export type SlotGroupName = 'The blob' | 'Machines' | 'The course' | 'Effects'
export interface SlotSpec {
id: SlotId
group: SlotGroupName
/** Plain-language name shown in the list. */
label: string
/** One sentence under the name. */
hint: string
/** Where the camera should sit / look when you press "Show me". */
focus: { target: [number, number, number]; distance: number }
/** True for the paintable body — unlocks the paintability report. */
paintable?: boolean
/**
* Derived slots have no drop zone of their own: they copy another slot.
* (The ghost is a see-through copy of the blob, per the plan.)
*/
derivedFrom?: SlotId
/**
* True when the game builds SEVERAL of this thing (nine puddles, one barrel
* per cannon). A drop replaces every one of them, each at its own place
* which is what the runtime does too.
*/
multi?: boolean
}
interface Presentation {
label?: string
hint: string
focus: { target: [number, number, number]; distance: number }
paintable?: boolean
derivedFrom?: SlotId
multi?: boolean
}
/**
* Keyed by SlotId: a typo, or a slot the runtime dropped, fails `tsc`.
* Partial on purpose a slot with no entry still appears, using its runtime
* label and note, so the editor can never silently hide part of the game.
*/
const PRESENTATION: Partial<Record<SlotId, Presentation>> = {
'blob.body': {
label: 'Blob body',
hint: 'The thing you play as. Paint sticks to this one, so it needs tidy UVs.',
focus: { target: [0, 3.5, 30], distance: 6 },
paintable: true,
},
'blob.face': {
label: 'Blob face',
hint: 'Just the eyes. Kept separate so paint never covers them.',
focus: { target: [0, 3.7, 30], distance: 4 },
},
'ghost.body': {
label: 'Ghost racer',
hint: 'Your best-run ghost. It copies the blob body automatically.',
focus: { target: [3, 3.5, 30], distance: 6 },
derivedFrom: 'blob.body',
},
'cannon.barrel': {
label: 'Paint cannon barrel',
hint: 'The swivelling tube. Every cannon on the course gets your model, ' +
'each one tinted the colour it shoots.',
focus: { target: [-9, 3, 14], distance: 6 },
multi: true,
},
'machine.plate': {
label: 'Pressure plate',
hint: 'The pad a heavy blob stands on to set the boot off. Frame only — the ' +
'pressed pad stays as it is so it can light up.',
focus: { target: [-6, 0.5, -47], distance: 9 },
},
'machine.boot': {
label: 'Spring boot',
hint: 'Kicks you down the course. Model it standing on the floor, origin at the base.',
focus: { target: [-6, 1, -49.5], distance: 9 },
},
'machine.bucket': {
label: 'Paint bucket',
hint: 'Tips a colour over you. Its model must pivot at the lip and pour toward +X.',
focus: { target: [-1.4, 6, -50], distance: 10 },
},
'machine.arch': {
label: 'Bubble wash',
hint: 'Walk through it and paint comes off.',
focus: { target: [-4.5, 2, -56], distance: 9 },
},
'machine.belt': {
label: 'Conveyor belt',
hint: 'Slow-carries you along. Model the slab only — the moving stripes stay animated.',
focus: { target: [0, 0.5, -50], distance: 12 },
},
'machine.fan': {
label: 'Fan',
hint: 'Blows you sideways. Face it toward +Z; the blades stay as they are so they keep spinning.',
focus: { target: [0, 2, -30], distance: 10 },
},
'machine.seesaw': {
label: 'See-saw plank',
hint: 'Tips under your weight. Plank only, long axis left-to-right, origin in the middle.',
focus: { target: [0, 1, -20], distance: 12 },
},
'course.scenery.cereal': {
label: 'Giant cereal box',
hint: 'Big silly prop next to the start. Nothing depends on it — go wild.',
focus: { target: [-20, 7, 26], distance: 22 },
},
'course.scenery.block': {
label: 'Purple block',
hint: 'The other roadside prop. Also purely decorative.',
focus: { target: [20, 5, 12], distance: 20 },
},
'fx.puddle': {
label: 'Paint puddle',
hint: 'The glossy colour patches on the ground. Every puddle gets your model, ' +
'stretched to fit its own patch.',
focus: { target: [0, 1.1, 12], distance: 10 },
multi: true,
},
}
const GROUP_OF: [prefix: string, group: SlotGroupName][] = [
['blob.', 'The blob'],
['ghost.', 'The blob'],
['cannon.', 'Machines'],
['machine.', 'Machines'],
['course.', 'The course'],
['fx.', 'Effects'],
]
const groupFor = (id: SlotId): SlotGroupName =>
GROUP_OF.find(([prefix]) => id.startsWith(prefix))?.[1] ?? 'The course'
/** A slot with no bespoke help text still gets a usable line, never a blank. */
const fallbackHint = (id: SlotId): string =>
SLOT_NOTES[id] ?? `Drop a model here to replace the ${SLOT_LABELS[id].toLowerCase()}.`
export const SLOTS: SlotSpec[] = RUNTIME_SLOT_IDS.map((id): SlotSpec => {
const p = PRESENTATION[id]
return {
id,
group: groupFor(id),
label: p?.label ?? SLOT_LABELS[id],
hint: p?.hint ?? fallbackHint(id),
focus: p?.focus ?? { target: [0, 2, 0], distance: 16 },
...(p?.paintable ? { paintable: true } : {}),
...(p?.derivedFrom ? { derivedFrom: p.derivedFrom } : {}),
...(p?.multi ? { multi: true } : {}),
}
})
export const SLOT_IDS: SlotId[] = SLOTS.map((s) => s.id)
export const slotById = (id: string): SlotSpec | undefined =>
SLOTS.find((s) => s.id === id)
export const SLOT_GROUPS: SlotGroupName[] =
['The blob', 'Machines', 'The course', 'Effects']

View File

@ -1,342 +0,0 @@
/**
* Lane J the editor stage: the REAL course, built from the game's own builders.
*
* Fits are only truthful if you fit against the actual thing, so this imports
* buildGreybox / createBlob / installZones / the machine factories rather than
* re-modelling anything. Physics is created (the builders need it) but never
* stepped: `world.tick()` is never called and no system is registered, so the
* stage is a frozen tableau. Rendering is a plain rAF calling `world.renderOnce()`
* deliberately NOT `world.start()`, which would advance the sim.
*
* Slot objects are captured two ways: handles where a builder returns one, and a
* scene-children diff where it doesn't (installZones and PaintCannon both just
* `scene.add` internally). The diff is why capture() exists.
*/
import * as THREE from 'three'
import { OrbitControls } from 'three/examples/jsm/controls/OrbitControls.js'
import { cloneAsset } from './glb'
import { createWorld } from '../world'
import { buildGreybox } from '../course/greybox'
import { createBlob } from '../blob/createBlob'
import { installZones } from '../course/zones'
import { PaintSkin } from '../paint/skin'
import { PaintCannon } from '../paint/cannon'
import { createBucketDump, createBubbleArch, createConveyorBelt } from '../machine/index'
import type { World } from '../contracts'
import { identityTransform } from './manifest-io'
import type { SlotEntry } from './manifest-io'
import { SlotSwap } from './slot-swap'
import type { SlotStageEntry } from './slot-swap'
import type { BaseTransform } from './manifest-io'
import { SLOTS } from './slots'
// Some ids below (`cannon.base`, `course.tramp`, `course.tunnel`,
// `course.finish`) are registered whether or not the runtime knows them yet.
// Registering costs nothing, the left panel only lists ids the runtime does
// support (see slots.ts), and they light up by themselves the day it does.
export type { SlotStageEntry } from './slot-swap'
export interface EditorStage {
world: World
controls: OrbitControls
slots: Map<string, SlotStageEntry>
/**
* Swap a slot's visual for a loaded GLB scene. The scene is CLONED, once per
* procedural instance the slot holds, so the caller keeps its own copy intact
* and a nine-puddle slot ends up with nine puddles.
*/
setCustom(slot: string, asset: THREE.Object3D, entry: SlotEntry): void
/** Drop the custom asset and show today's procedural build again. */
clearCustom(slot: string): void
/** Re-apply offset/rotation/scale without reloading the asset. */
applyFit(slot: string, entry: SlotEntry): void
/** The object the fit panel is currently acting on (custom asset or procedural). */
currentObject(slot: string): THREE.Object3D | null
highlight(slot: string | null): void
focus(slot: string): void
dispose(): void
}
export { cloneAsset, loadGlb } from './glb'
export async function createEditorStage(container: HTMLElement): Promise<EditorStage> {
const world = await createWorld(container)
world.scene.fog = null // an editor wants to see the far end of the course
const swap = new SlotSwap(cloneAsset)
const slots = swap.slots
const register = (
id: string, objects: THREE.Object3D[], extraScale?: [number, number, number][],
anchors?: (BaseTransform | null)[],
): void => swap.register(id, objects, world.scene,
{ ...(extraScale ? { extraScale } : {}), ...(anchors ? { anchors } : {}) })
/** Everything a builder adds straight to the scene, recovered by diffing. */
const capture = <T>(fn: () => T): { result: T; added: THREE.Object3D[] } => {
const before = new Set(world.scene.children)
const result = fn()
return { result, added: world.scene.children.filter((c) => !before.has(c)) }
}
// ---- the course ----------------------------------------------------------
const course = buildGreybox(world)
const meshAt = (x: number, y: number, z: number): THREE.Mesh | undefined =>
course.meshes.find((m) =>
Math.abs(m.position.x - x) < 0.01 &&
Math.abs(m.position.y - y) < 0.01 &&
Math.abs(m.position.z - z) < 0.01)
const cereal = meshAt(-20, 7, 26)
if (cereal) register('course.scenery.cereal', [cereal])
const block = meshAt(20, 5, 12)
if (block) register('course.scenery.block', [block])
const finish = meshAt(0, 0.6, -64)
if (finish) register('course.finish', [finish])
// ---- blob ----------------------------------------------------------------
const blob = createBlob(world, { position: course.spawn })
const skin = new PaintSkin(blob.mesh, { size: 64 }) // small: nothing paints here
blob.paint = skin
world.blob = blob
register('blob.body', [blob.mesh])
register('blob.face', [blob.face])
// Ghost preview. The shipped GhostPlayer only builds itself when a saved run
// exists, so the editor shows the same thing it would derive: a see-through
// copy of whatever is in the body slot, parked beside the blob.
const ghostRoot = new THREE.Group()
ghostRoot.position.set(course.spawn.x + 2.2, course.spawn.y, course.spawn.z)
world.scene.add(ghostRoot)
const rebuildGhost = (source: THREE.Object3D): void => {
ghostRoot.clear()
const copy = cloneAsset(source)
copy.position.set(0, 0, 0)
copy.rotation.set(0, 0, 0)
copy.traverse((o) => {
const m = o as THREE.Mesh
if (!m.isMesh) return
// Clone the material: the source may share it with the real blob (or with
// other instances via the asset cache) and we must not make those sheer.
const src = Array.isArray(m.material) ? m.material[0] : m.material
const ghostMat = (src as THREE.Material).clone() as THREE.MeshStandardMaterial
ghostMat.transparent = true
ghostMat.opacity = 0.35
ghostMat.depthWrite = false
m.material = ghostMat
})
copy.renderOrder = 2
ghostRoot.add(copy)
}
rebuildGhost(blob.mesh)
register('ghost.body', [ghostRoot.children[0]!])
// ---- zones: puddles, the purple fork's plate + boot, the MINI tunnel ------
// installZones registers systems, but nothing ever ticks them here.
const zoned = capture(() => installZones(world, blob, skin))
const zones = zoned.result
// The plate and boot are the only Groups zones adds; the tunnel's roof/bar/
// posts arrive as loose Meshes. Order is stable (plate, then boot, then tunnel).
const zoneGroups = zoned.added.filter((o) => o instanceof THREE.Group)
if (zoneGroups[0]) register('machine.plate', [zoneGroups[0]])
if (zoneGroups[1]) register('machine.boot', [zoneGroups[1]])
// Each puddle is a BoxGeometry(w, 0.05, l) at scale 1 and the runtime stretches
// a custom decal to that footprint (paint/puddles.ts passes `fit: (w,1,l)`).
// Reading the same numbers back off the geometry keeps the preview honest.
if (zones.puddles.length) {
const puddleMeshList = zones.puddles.map((p) => p.mesh)
const footprints = puddleMeshList.map((m): [number, number, number] => {
const params = (m.geometry as THREE.BoxGeometry).parameters as
{ width?: number; depth?: number } | undefined
return [params?.width ?? 1, 1, params?.depth ?? 1]
})
register('fx.puddle', puddleMeshList, footprints)
}
// The tunnel is whatever loose Meshes are left once the puddles are accounted
// for — installPuddles runs FIRST and also adds bare Meshes to the scene, so
// filtering on "not a Group" alone would swallow all ten puddles.
const puddleMeshes = new Set<THREE.Object3D>(zones.puddles.map((p) => p.mesh))
const tunnelParts = zoned.added.filter(
(o) => !(o instanceof THREE.Group) && !puddleMeshes.has(o))
if (tunnelParts.length) register('course.tunnel', tunnelParts)
// ---- centre-lane machines (same configs as the shipped game) -------------
const bucket = createBucketDump(world, {
id: 'bucket-1', position: [-1.4, 6, -50], color: 'purple', radius: 0.8,
})
register('machine.bucket', [bucket.group])
const belt = createConveyorBelt(world, {
id: 'belt-1', position: [0, 0.0, -50], size: [3, 0.5, 10], velocity: [0, 0, -3],
})
register('machine.belt', [belt.group])
const arch = createBubbleArch(world, {
id: 'arch-1', position: [-4.5, 0.12, -56], fraction: 0.6,
})
register('machine.arch', [arch.group])
// ---- cannons: built for their look only; never added as systems ----------
const cannonCaptures = zones.cannonConfigs.map((spec) =>
capture(() => new PaintCannon({
world,
position: new THREE.Vector3(...spec.position),
color: spec.color,
target: blob.mesh,
paint: skin,
targetRadius: blob.radius,
})))
const barrels = cannonCaptures.flatMap((c) => c.added)
// A barrel group is [base mesh, 'pivot' group] — the plan splits those into
// two slots, and PaintCannon.aimBarrel looks the pivot up BY NAME, so the
// pivot node must survive any swap (a custom barrel goes inside it).
const bases = barrels.map((b) => b.children[0]).filter(Boolean) as THREE.Object3D[]
const pivots = barrels
.map((b) => b.children.find((c) => c.name === 'pivot'))
.filter(Boolean) as THREE.Object3D[]
if (bases.length) register('cannon.base', bases)
// Hide the pivot (it holds the procedural tube the runtime hides), but anchor
// at the cannon group's own origin — src/paint/cannon.ts attaches the barrel
// model to the whole group, deliberately, so it does not inherit the aim spin.
// Without the identity anchor the preview sat 0.45m above where the game puts it.
if (pivots.length) register('cannon.barrel', pivots, undefined, pivots.map(identityTransform))
// ---- gap launcher --------------------------------------------------------
// game.ts builds this inline and game.ts is frozen, so the stage mirrors its
// geometry here. Reported as friction: the editor can preview a fit for this
// slot but nothing reads it until game.ts asks the registry.
const tramp = new THREE.Mesh(
new THREE.BoxGeometry(12, 0.5, 4),
new THREE.MeshStandardMaterial({ color: '#FF9500', roughness: 0.6 }))
tramp.position.set(0, 0.25, 0)
tramp.receiveShadow = true
world.scene.add(tramp)
register('course.tramp', [tramp])
// ---- camera + orbit ------------------------------------------------------
world.camera.position.set(14, 12, 34)
const controls = new OrbitControls(world.camera, world.renderer.domElement)
controls.target.set(0, 2, 22)
controls.enableDamping = true
controls.maxPolarAngle = Math.PI * 0.49 // never orbit under the floor
controls.update()
// ---- selection highlight -------------------------------------------------
const marker = new THREE.Mesh(
new THREE.SphereGeometry(1, 20, 14),
new THREE.MeshBasicMaterial({
color: '#FFD60A', wireframe: true, transparent: true, opacity: 0.85,
}))
marker.visible = false
marker.renderOrder = 3
world.scene.add(marker)
const markerBox = new THREE.Box3()
const markerCenter = new THREE.Vector3()
const markerSize = new THREE.Vector3()
let markerPulse = 0
const highlight = (slot: string | null): void => {
const entry = slot ? slots.get(slot) : undefined
const object = entry ? (entry.fitNode ?? entry.procedural[0]) : undefined
if (!object) {
marker.visible = false
return
}
object.updateWorldMatrix(true, true)
markerBox.setFromObject(object)
if (markerBox.isEmpty()) {
marker.visible = false
return
}
markerBox.getCenter(markerCenter)
markerBox.getSize(markerSize)
marker.position.copy(markerCenter)
marker.scale.setScalar(Math.max(0.6, markerSize.length() * 0.55))
marker.visible = true
}
let selected: string | null = null
// ---- render loop: frame hooks + render only. Physics never advances. -----
let raf = 0
let disposed = false
const loop = (): void => {
if (disposed) return
raf = requestAnimationFrame(loop)
controls.update()
markerPulse += 0.03
if (marker.visible) {
const s = 1 + Math.sin(markerPulse) * 0.04
marker.scale.multiplyScalar(s / (marker.userData.lastPulse ?? 1))
marker.userData.lastPulse = s
}
world.renderOnce()
}
raf = requestAnimationFrame(loop)
const stage: EditorStage = {
world,
controls,
slots,
setCustom(slot, asset, entry) {
swap.setCustom(slot, asset, entry)
if (slot === 'blob.body') rebuildGhost(asset)
if (selected === slot) highlight(slot)
},
clearCustom(slot) {
swap.clearCustom(slot)
if (slot === 'blob.body') rebuildGhost(blob.mesh)
if (selected === slot) highlight(slot)
},
applyFit(slot, entry) {
swap.applyFit(slot, entry)
if (selected === slot) highlight(slot)
},
currentObject(slot) {
return swap.currentObject(slot)
},
highlight(slot) {
selected = slot
highlight(slot)
},
focus(slot) {
const spec = SLOTS.find((x) => x.id === slot)
const s = slots.get(slot)
const target = new THREE.Vector3()
if (s) {
const object = s.fitNode ?? s.procedural[0]!
object.updateWorldMatrix(true, true)
const box = new THREE.Box3().setFromObject(object)
if (!box.isEmpty()) box.getCenter(target)
else object.getWorldPosition(target)
} else if (spec) {
target.set(...spec.focus.target)
}
const distance = spec?.focus.distance ?? 10
controls.target.copy(target)
world.camera.position.set(
target.x + distance * 0.55, target.y + distance * 0.5, target.z + distance * 0.8)
controls.update()
},
dispose() {
disposed = true
cancelAnimationFrame(raf)
controls.dispose()
world.renderer.dispose()
world.renderer.domElement.remove()
},
}
// Nothing is selected on boot; keep the marker off so an untouched session is
// visually identical to the game's own course.
return stage
}
export const stageSlotIds = (stage: EditorStage): string[] => [...stage.slots.keys()]
export { identityTransform }

View File

@ -1,106 +0,0 @@
/**
* Lane J working-manifest persistence on top of idb.ts.
*
* Two things live here: the manifest the game reads on boot, and the raw GLB
* bytes it references via `idb:<key>` urls. Everything is tolerant a corrupt
* record reads back as an empty manifest rather than blocking the editor.
*/
import {
BLOB_STORE, MANIFEST_KEY, MANIFEST_STORE,
idbClear, idbDelete, idbGet, idbKeys, idbPut,
} from './idb'
import { cleanManifest, idbKey, isIdbUrl, parseManifest } from './manifest-io'
import type { Manifest } from './manifest-io'
/**
* What a `blobs` record reads back as.
*
* On disk the record is the RAW ArrayBuffer, because that is what the shipped
* game's reader expects (src/assets/idb.ts `getBlob` feeds its result straight
* into `new Blob([buf])`). An earlier version wrote a `{name, bytes, savedAt}`
* wrapper here, which the game could not read at all so reads stay tolerant of
* that shape and writes never produce it again. The file name is recoverable
* from the key, which is `<slot>-<filename>`.
*/
export interface StoredGlb {
name: string
bytes: ArrayBuffer
savedAt: number
}
/** Legacy wrapper shape, still on disk for anyone who saved before the fix. */
interface LegacyStoredGlb {
name?: unknown
bytes?: unknown
savedAt?: unknown
}
export async function loadOverrideManifest(): Promise<Manifest> {
const raw = await idbGet<unknown>(MANIFEST_STORE, MANIFEST_KEY)
if (typeof raw === 'string') return parseManifest(raw)
if (raw && typeof raw === 'object') return parseManifest(JSON.stringify(raw))
return {}
}
/** Stored as an object (not a string) so Lane I's loader can read it either way. */
export const saveOverrideManifest = (manifest: Manifest): Promise<unknown> =>
idbPut(MANIFEST_STORE, MANIFEST_KEY, cleanManifest(manifest))
/** A stable, human-readable blob key: `<slot>-<filename>`. */
export const blobKeyFor = (slot: string, fileName: string): string =>
`${slot}-${fileName}`.replace(/[^a-zA-Z0-9._-]/g, '_')
export async function putGlb(key: string, _name: string, bytes: ArrayBuffer): Promise<string> {
await idbPut(BLOB_STORE, key, bytes)
return `idb:${key}`
}
export async function getGlb(key: string): Promise<StoredGlb | undefined> {
const raw = await idbGet<unknown>(BLOB_STORE, key)
if (raw instanceof ArrayBuffer) {
return { name: nameFromKey(key), bytes: raw, savedAt: 0 }
}
const legacy = raw as LegacyStoredGlb | undefined
if (legacy && legacy.bytes instanceof ArrayBuffer) {
return {
name: typeof legacy.name === 'string' ? legacy.name : nameFromKey(key),
bytes: legacy.bytes,
savedAt: typeof legacy.savedAt === 'number' ? legacy.savedAt : 0,
}
}
return undefined
}
/** `<slot>-<filename>` back to `<filename>` — best effort, display only. */
const nameFromKey = (key: string): string => {
const dash = key.lastIndexOf('-')
return dash >= 0 ? key.slice(dash + 1) : key
}
/** Every override gone: manifest record and all stored GLB bytes. */
export async function clearOverrides(): Promise<void> {
await idbDelete(MANIFEST_STORE, MANIFEST_KEY)
await idbClear(BLOB_STORE)
}
/**
* Blob keys with nothing pointing at them any more (drop-then-reset leftovers).
*
* CONSTRAINT: whatever manifest is handed in decides what survives, so a caller
* must never hand in a manifest it has already pruned entries from. Workshop
* keeps entries whose file failed to load precisely so their bytes are not
* counted as orphans here dropping them would destroy an unrecoverable file.
*/
export async function orphanBlobKeys(manifest: Manifest): Promise<string[]> {
const live = new Set(
Object.values(manifest).filter((e) => isIdbUrl(e.url)).map((e) => idbKey(e.url)),
)
const keys = await idbKeys(BLOB_STORE)
return keys.map(String).filter((k) => !live.has(k))
}
export async function pruneOrphans(manifest: Manifest): Promise<number> {
const orphans = await orphanBlobKeys(manifest)
for (const k of orphans) await idbDelete(BLOB_STORE, k)
return orphans.length
}

View File

@ -1,145 +0,0 @@
/**
* Lane J a valid, tiny GLB built in memory.
*
* Exists so the drop pipeline can be exercised without a 13 MB farm asset:
* `assets/` is NOT part of the vite build graph today (nothing copies it into
* dist/), so the demo can't count on a real .glb being fetchable. This produces
* a two-triangle, single-material, 01-UV quad the shape a paintability check
* should be happy with as raw GLB bytes.
*
* Pure ArrayBuffer maths: no THREE, no DOM, runs under node.
*/
const MAGIC = 0x46546c67 // 'glTF'
const CHUNK_JSON = 0x4e4f534a // 'JSON'
const CHUNK_BIN = 0x004e4942 // 'BIN\0'
const pad4 = (n: number): number => (n + 3) & ~3
export interface TinyGlbOptions {
/** Quad half-size in metres. Default 0.5 (a 1u-wide plate). */
half?: number
/** Material base colour as [r,g,b,a] in 0..1. Default a workshop orange. */
color?: [number, number, number, number]
/** Omit UVs to produce a deliberately unpaintable asset (for testing warnings). */
withUv?: boolean
/**
* Omit the mesh entirely: a valid GLB whose scene holds one empty node. This
* is what a Blender export with the mesh on a hidden collection looks like,
* and the drop path has to refuse it rather than hide the original and put
* nothing in its place.
*/
withMesh?: boolean
}
export function buildTinyGlb(opts: TinyGlbOptions = {}): ArrayBuffer {
const h = opts.half ?? 0.5
const color = opts.color ?? [1, 0.58, 0, 1]
const withUv = opts.withUv !== false
const withMesh = opts.withMesh !== false
const positions = new Float32Array([
-h, 0, -h,
h, 0, -h,
h, 0, h,
-h, 0, h,
])
const uvs = new Float32Array([0, 0, 1, 0, 1, 1, 0, 1])
const indices = new Uint16Array([0, 1, 2, 0, 2, 3])
const posBytes = positions.byteLength // 48
const uvBytes = withUv ? uvs.byteLength : 0 // 32
const idxBytes = indices.byteLength // 12
const posOffset = 0
const uvOffset = posOffset + posBytes
const idxOffset = pad4(uvOffset + uvBytes)
const binLength = idxOffset + idxBytes
const bin = new ArrayBuffer(pad4(binLength))
new Uint8Array(bin, posOffset, posBytes).set(new Uint8Array(positions.buffer))
if (withUv) new Uint8Array(bin, uvOffset, uvBytes).set(new Uint8Array(uvs.buffer))
new Uint8Array(bin, idxOffset, idxBytes).set(new Uint8Array(indices.buffer))
const bufferViews: unknown[] = [
{ buffer: 0, byteOffset: posOffset, byteLength: posBytes, target: 34962 },
]
const accessors: unknown[] = [
{
bufferView: 0, componentType: 5126, count: 4, type: 'VEC3',
min: [-h, 0, -h], max: [h, 0, h],
},
]
const attributes: Record<string, number> = { POSITION: 0 }
if (withUv) {
bufferViews.push({ buffer: 0, byteOffset: uvOffset, byteLength: uvBytes, target: 34962 })
accessors.push({
bufferView: bufferViews.length - 1, componentType: 5126, count: 4, type: 'VEC2',
min: [0, 0], max: [1, 1],
})
attributes.TEXCOORD_0 = accessors.length - 1
}
bufferViews.push({ buffer: 0, byteOffset: idxOffset, byteLength: idxBytes, target: 34963 })
accessors.push({
bufferView: bufferViews.length - 1, componentType: 5123, count: 6, type: 'SCALAR',
min: [0], max: [3],
})
const indexAccessor = accessors.length - 1
const gltf = {
asset: { version: '2.0', generator: 'BLOBBO workshop tiny-glb' },
scene: 0,
scenes: [{ nodes: [0] }],
nodes: withMesh ? [{ mesh: 0, name: 'TinyQuad' }] : [{ name: 'EmptyNode' }],
// glTF forbids an empty `meshes` array, so the key is dropped entirely.
...(withMesh
? {
meshes: [{
name: 'TinyQuad',
primitives: [{ attributes, indices: indexAccessor, material: 0, mode: 4 }],
}],
}
: {}),
materials: [{
name: 'TinyMat',
pbrMetallicRoughness: { baseColorFactor: color, metallicFactor: 0, roughnessFactor: 0.6 },
}],
accessors,
bufferViews,
buffers: [{ byteLength: bin.byteLength }],
}
const jsonText = JSON.stringify(gltf)
const jsonBytes = new TextEncoder().encode(jsonText)
const jsonPadded = pad4(jsonBytes.length)
const total = 12 + 8 + jsonPadded + 8 + bin.byteLength
const out = new ArrayBuffer(total)
const view = new DataView(out)
const bytes = new Uint8Array(out)
view.setUint32(0, MAGIC, true)
view.setUint32(4, 2, true)
view.setUint32(8, total, true)
view.setUint32(12, jsonPadded, true)
view.setUint32(16, CHUNK_JSON, true)
bytes.set(jsonBytes, 20)
// glTF requires JSON chunk padding to be spaces, BIN padding to be zeroes.
for (let i = jsonBytes.length; i < jsonPadded; i++) bytes[20 + i] = 0x20
const binChunkStart = 20 + jsonPadded
view.setUint32(binChunkStart, bin.byteLength, true)
view.setUint32(binChunkStart + 4, CHUNK_BIN, true)
bytes.set(new Uint8Array(bin), binChunkStart + 8)
return out
}
/** Cheap structural validation — the header checks GLTFLoader does first. */
export function isGlb(buffer: ArrayBuffer): boolean {
if (buffer.byteLength < 20) return false
const view = new DataView(buffer)
return view.getUint32(0, true) === MAGIC &&
view.getUint32(8, true) === buffer.byteLength
}

View File

@ -1,198 +0,0 @@
/**
* Headless end-to-end check of the drop store swap export restore path.
*
* The real SlotSwap does the scene surgery (it is pure THREE, no WebGL), the
* real Workshop drives it, and a small in-memory stand-in plays IndexedDB so the
* persistence path is exercised rather than mocked away. Only the WebGL stage
* shell and the DOM panels are absent those are the parts this cannot reach
* without a browser.
*
* node --experimental-strip-types --import ./src/editor/node-ts-resolve.mjs \
* src/editor/workshop.test.ts
*/
import * as THREE from 'three'
// ---- in-memory IndexedDB (installed before anything imports the store) -----
type Store = Map<string, unknown>
const dbs = new Map<string, Map<string, Store>>()
function fireLater<T>(req: { onsuccess: (() => void) | null; result: T }): void {
queueMicrotask(() => req.onsuccess?.())
}
const fakeIndexedDb = {
open(name: string) {
const req: Record<string, unknown> = { result: null, onsuccess: null, onerror: null, onupgradeneeded: null }
const stores = dbs.get(name) ?? new Map<string, Store>()
dbs.set(name, stores)
const db = {
objectStoreNames: { contains: (s: string) => stores.has(s) },
createObjectStore: (s: string) => void stores.set(s, new Map()),
transaction: (s: string) => ({
objectStore: (_n: string) => {
const store = stores.get(s)!
const wrap = <T>(result: T): Record<string, unknown> => {
const r: Record<string, unknown> = { result, onsuccess: null, onerror: null }
fireLater(r as never)
return r
}
return {
get: (k: string) => wrap(store.get(k)),
put: (v: unknown, k: string) => { store.set(k, v); return wrap(undefined) },
delete: (k: string) => { store.delete(k); return wrap(undefined) },
getAllKeys: () => wrap([...store.keys()]),
clear: () => { store.clear(); return wrap(undefined) },
}
},
}),
}
req.result = db
queueMicrotask(() => {
;(req.onupgradeneeded as (() => void) | null)?.()
;(req.onsuccess as (() => void) | null)?.()
})
return req
},
}
;(globalThis as unknown as { indexedDB: unknown }).indexedDB = fakeIndexedDb
const { Workshop } = await import('./workshop')
const { SlotSwap } = await import('./slot-swap')
const { buildTinyGlb } = await import('./tiny-glb')
const { manifestsEqual, parseManifest, isIdbUrl, idbKey } = await import('./manifest-io')
const { getGlb, loadOverrideManifest } = await import('./store')
type EditorStage = import('./stage').EditorStage
let passed = 0
function ok(cond: boolean, msg: string): void {
if (!cond) throw new Error('FAIL: ' + msg)
passed++
}
// ---- a stage stand-in whose swap logic is the REAL SlotSwap ----------------
const scene = new THREE.Scene()
const swap = new SlotSwap()
const makeProcedural = (name: string, pos: [number, number, number]): THREE.Mesh => {
const mesh = new THREE.Mesh(new THREE.BoxGeometry(1, 1, 1), new THREE.MeshStandardMaterial())
mesh.name = name
mesh.position.set(...pos)
scene.add(mesh)
return mesh
}
swap.register('machine.boot', [makeProcedural('boot', [-6, 0.2, -49.5])], scene)
swap.register('blob.body', [makeProcedural('body', [0, 3.5, 30])], scene)
type Entry = import('./manifest-io').SlotEntry
const stage = {
slots: swap.slots,
setCustom: (s: string, a: THREE.Object3D, e: Entry) => swap.setCustom(s, a, e),
clearCustom: (s: string) => swap.clearCustom(s),
applyFit: (s: string, e: Entry) => swap.applyFit(s, e),
currentObject: (s: string) => swap.currentObject(s),
highlight: () => {},
focus: () => {},
dispose: () => {},
} as unknown as EditorStage
const workshop = new Workshop(stage)
const BOOT = 'machine.boot'
const bootEntry = swap.slots.get(BOOT)!
// ---- clean session ---------------------------------------------------------
ok(!workshop.hasCustom(BOOT), 'nothing is swapped on a fresh session')
ok(bootEntry.fitNode === null, 'no fit wrapper exists yet')
ok(JSON.stringify(workshop.getManifest()) === '{}', 'a fresh session exports an empty manifest')
// ---- the drop --------------------------------------------------------------
const bytes = buildTinyGlb({ half: 1.1 })
const dropped = await workshop.dropFile(BOOT, 'prop-spring-boot.glb', bytes)
ok(!dropped.rejected, `the drop was accepted (${dropped.message})`)
ok(bootEntry.fitNode !== null, 'the swap created a fit wrapper')
ok(bootEntry.fitNode!.children.length === 1, 'the dropped model is inside the wrapper')
ok(bootEntry.procedural.every((p) => !p.visible), 'the procedural boot was hidden')
ok(bootEntry.procedural[0]!.parent === scene, 'the procedural boot is still in the scene, just hidden')
ok(isIdbUrl(dropped.entry.url), 'the manifest entry uses an idb: url')
const stored = await getGlb(idbKey(dropped.entry.url))
ok(stored !== undefined && stored.bytes.byteLength === bytes.byteLength,
'the GLB bytes reached storage intact')
ok(workshop.hasCustom(BOOT), 'the slot now reports a custom asset')
// shadows: a farm export arrives with them off and must not float
let anyShadow = false
bootEntry.fitNode!.traverse((o) => { if ((o as THREE.Mesh).isMesh && o.castShadow) anyShadow = true })
ok(anyShadow, 'the dropped model casts a shadow (not left floating)')
// the swap lands exactly on the procedural pose with no fit applied
ok(bootEntry.fitNode!.position.distanceTo(bootEntry.procedural[0]!.position) < 1e-9,
'with no nudging, the custom model sits exactly where the original stood')
// ---- fit -------------------------------------------------------------------
workshop.setFit(BOOT, { offset: [0, 1.5, 0], rotationDeg: [0, 90, 0], scale: 2 })
ok(Math.abs(bootEntry.fitNode!.position.y - (0.2 + 1.5)) < 1e-9, 'raising it moves it exactly that far')
ok(Math.abs(bootEntry.fitNode!.rotation.y - Math.PI / 2) < 1e-9, 'turning it rotates it')
ok(Math.abs(bootEntry.fitNode!.scale.x - 2) < 1e-9, 'resizing it scales it')
// ---- export round-trip -----------------------------------------------------
const text = workshop.exportText()
const back = parseManifest(text)
ok(manifestsEqual(workshop.getManifest(), back), 'the exported manifest round-trips exactly')
ok(back[BOOT]!.offset?.[1] === 1.5, 'the fit survives export and re-import')
// ---- values survive a slot switch -----------------------------------------
await workshop.dropFile('blob.body', 'tiny.glb', buildTinyGlb())
ok(workshop.entry(BOOT)?.offset?.[1] === 1.5, "another slot's drop does not disturb the boot's fit")
ok(workshop.hasCustom('blob.body'), 'the second slot swapped too')
// ---- a body that paint cannot stick to is refused --------------------------
workshop.reset('blob.body')
const bad = await workshop.dropFile('blob.body', 'no-uvs.glb', buildTinyGlb({ withUv: false }))
ok(bad.rejected, 'a body model with no UV map is refused')
ok(!workshop.hasCustom('blob.body'), 'the refused model is not written into the manifest')
ok(swap.slots.get('blob.body')!.procedural.every((p) => p.visible),
'the plain blob is still on screen after a refusal')
ok(bad.paint !== undefined && !bad.paint.uvOk, 'the refusal carries the paint report')
ok(/paint would not stick/i.test(bad.message), 'the refusal is explained in plain words')
// ---- junk bytes are refused, not thrown ------------------------------------
const junk = await workshop.dropFile(BOOT, 'notes.txt', new TextEncoder().encode('hello').buffer)
ok(junk.rejected, 'a non-GLB file is refused')
ok(bootEntry.fitNode !== null, 'a refused drop leaves the previous good swap alone')
ok(workshop.entry(BOOT)?.url === dropped.entry.url, 'a refused drop does not overwrite the manifest entry')
// ---- derived slots refuse drops with an explanation ------------------------
swap.register('ghost.body', [makeProcedural('ghost', [2, 3.5, 30])], scene)
const ghost = await workshop.dropFile('ghost.body', 'x.glb', buildTinyGlb())
ok(ghost.rejected, 'the ghost slot refuses its own drop')
ok(/blob body/i.test(ghost.message), 'it points you at the blob body slot instead')
// ---- save + restore --------------------------------------------------------
const saveResult = await workshop.saveLocally()
ok(saveResult.ok, 'saving succeeded')
ok(saveResult.saved === 1, `saving wrote the one live swap (got ${saveResult.saved})`)
const persisted = await loadOverrideManifest()
ok(manifestsEqual(workshop.getManifest(), persisted),
'what was saved is exactly what the live game reads back')
const reopened = new Workshop(stage)
swap.clearCustom(BOOT)
const restored = await reopened.restore()
ok(restored.restored.includes(BOOT), 'reopening the editor brings the swap back')
ok(restored.unloadable.length === 0, 'nothing was reported as missing')
ok(bootEntry.fitNode !== null, 'the restored swap is on the stage again')
ok(Math.abs(bootEntry.fitNode!.position.y - 1.7) < 1e-9, 'the restored swap keeps its fit')
// ---- reset -----------------------------------------------------------------
reopened.reset(BOOT)
ok(bootEntry.fitNode === null, 'reset removes the custom model')
ok(bootEntry.procedural.every((p) => p.visible), 'reset shows the original again')
ok(!reopened.hasCustom(BOOT), 'reset clears the manifest entry')
// ---- clear everything ------------------------------------------------------
await workshop.clearEverything()
const afterClear = await loadOverrideManifest()
ok(Object.keys(afterClear).length === 0, 'Clear wipes the saved manifest')
ok(JSON.stringify(workshop.getManifest()) === '{}', 'Clear empties the working manifest')
ok(swap.slots.get(BOOT)!.procedural.every((p) => p.visible), 'Clear puts every original back on screen')
console.log(`editor workshop.test: ${passed} assertions passed ✓`)

View File

@ -1,349 +0,0 @@
/**
* Lane J the editor's brain, with no DOM in it.
*
* Holds the working manifest, owns the drop store swap pipeline, and is the
* single code path both the real drag-drop and the demo's scripted drop go
* through (so a green demo means the real button works).
*
* TWO INVARIANTS, both learned the hard way:
*
* 1. NOTHING LEAVES THIS CLASS AS A REJECTED PROMISE. Every caller is a DOM
* handler that can only `void` the result, so a rejection used to vanish
* into an unhandled-rejection with the status line frozen mid-sentence.
* Storage failures come back as a `rejected` result carrying a sentence a
* person can read, and the slot stays procedural.
*
* 2. A SAVED SWAP IS NEVER DESTROYED BY THE EDITOR. An entry whose file will
* not re-load stays in the manifest, flagged unloadable, so the next save
* cannot prune its bytes away. Losing a file the user cannot regenerate is
* worse than showing them a broken row.
*/
import * as THREE from 'three'
import type { EditorStage } from './stage'
import { loadGlb } from './glb'
import { firstMesh, inspectPaintability } from './paintability'
import type { PaintableInfo } from './paintability'
import {
cleanManifest, idbKey, isIdbUrl, serializeManifest,
} from './manifest-io'
import type { Manifest, SlotEntry } from './manifest-io'
import {
blobKeyFor, clearOverrides, getGlb, loadOverrideManifest, pruneOrphans,
putGlb, saveOverrideManifest,
} from './store'
import { slotById } from './slots'
export interface DropResult {
slot: string
entry: SlotEntry
/** Only produced for the paintable body slot. */
paint?: PaintableInfo
/** True when the asset was rejected and the procedural build was kept. */
rejected: boolean
/** Plain-language outcome for the status line. */
message: string
}
export interface SaveResult {
/** How many swaps are now saved. */
saved: number
/** False when the browser refused to keep them. */
ok: boolean
/** Plain-language outcome for the status line. */
message: string
}
export interface RestoreResult {
/** Slots whose model came back and is on the stage. */
restored: string[]
/** Slots still in the manifest whose file could not be read back. */
unloadable: string[]
}
/** What the user is told when the browser will not keep a file. */
const NO_ROOM =
"There wasn't room in this browser to keep that file, so the original is still there. " +
'Try a smaller model, or clear some swaps you no longer want.'
const NO_MESH = "There's no model inside this file — the original is still there."
export class Workshop {
private manifest: Manifest = {}
/** Parsed GLB scenes by url, so a slot switch never re-parses. */
private readonly cache = new Map<string, THREE.Object3D>()
private readonly paintReports = new Map<string, PaintableInfo>()
/** Slots whose stored file would not load: kept, shown, never pruned. */
private readonly unloadable = new Set<string>()
private readonly stage: EditorStage
// Plain assignment rather than a parameter property: node's type-stripping
// mode (how the .test.ts files run) rejects parameter properties outright.
constructor(stage: EditorStage) {
this.stage = stage
}
getManifest(): Manifest {
return cleanManifest(this.manifest)
}
entry(slot: string): SlotEntry | undefined {
return this.manifest[slot]
}
paintReport(slot: string): PaintableInfo | undefined {
return this.paintReports.get(slot)
}
hasCustom(slot: string): boolean {
return this.manifest[slot] !== undefined
}
/** True when this slot's saved file is missing or unreadable. */
isUnloadable(slot: string): boolean {
return this.unloadable.has(slot)
}
unloadableSlots(): string[] {
return [...this.unloadable]
}
/**
* Re-hydrate everything the browser remembered from a previous session.
* Never throws: no storage is a normal state, not a reason to lose the editor.
*/
async restore(): Promise<RestoreResult> {
let stored: Manifest = {}
try {
stored = await loadOverrideManifest()
} catch (err) {
console.warn('[workshop] could not read what was saved last time', err)
return { restored: [], unloadable: [] }
}
const restored: string[] = []
const unloadable: string[] = []
for (const [slot, entry] of Object.entries(stored)) {
if (!this.stage.slots.has(slot)) continue
let ok = false
try {
ok = await this.applyEntry(slot, entry)
} catch (err) {
console.warn(`[workshop] stored asset for ${slot} could not be restored`, err)
}
// The entry is kept either way. Dropping it here is what used to make the
// next save delete the only copy of the file.
this.manifest[slot] = entry
if (ok) {
this.unloadable.delete(slot)
restored.push(slot)
} else {
this.unloadable.add(slot)
unloadable.push(slot)
}
}
return { restored, unloadable }
}
/**
* The one drop path. Bytes in, stage swapped, manifest updated.
* A body asset that paint can't stick to is REJECTED and the procedural
* sphere stays: broken paint is worse than a missing model.
*/
async dropFile(slot: string, fileName: string, bytes: ArrayBuffer): Promise<DropResult> {
const spec = slotById(slot)
if (!spec) {
return {
slot, entry: { url: '' }, rejected: true,
message: "That isn't something this game can swap out.",
}
}
if (!this.stage.slots.has(slot)) {
return {
slot, entry: { url: '' }, rejected: true,
message: `There isn't a ${spec.label.toLowerCase()} anywhere in the course at the ` +
'moment, so there is nothing here to replace.',
}
}
if (spec.derivedFrom) {
return {
slot, entry: { url: '' }, rejected: true,
message: `${spec.label} copies "${slotById(spec.derivedFrom)?.label}" — drop your model there instead.`,
}
}
let scene: THREE.Object3D
try {
scene = await loadGlb(bytes)
} catch (err) {
console.warn(`[workshop] ${fileName} could not be read as a .glb`, err)
return {
slot, entry: { url: '' }, rejected: true,
message: `${fileName} isn't a .glb file this browser can read. Try exporting again from Blender.`,
}
}
// EVERY slot, not just the paintable one: a file with no mesh in it used to
// be accepted, hide the original and put nothing in its place — a cheerful
// success message for an object that had just vanished from the course.
if (!firstMesh(scene)) {
return { slot, entry: { url: '' }, rejected: true, message: NO_MESH }
}
let paint: PaintableInfo | undefined
if (spec.paintable) {
paint = inspectPaintability(scene)
this.paintReports.set(slot, paint)
if (!paint.uvOk) {
return {
slot, entry: { url: '' }, rejected: true, paint,
message: 'Paint would not stick to this model, so the plain blob is still in. ' +
(paint.problems[0] ?? ''),
}
}
}
// Storage first: a swap the browser cannot keep must not appear on the
// stage, or Save would silently drop it and the preview would be a lie.
const key = blobKeyFor(slot, fileName)
let url: string
try {
url = await putGlb(key, fileName, bytes)
} catch (err) {
console.warn(`[workshop] could not store ${fileName}`, err)
this.paintReports.delete(slot)
return { slot, entry: { url: '' }, rejected: true, paint, message: NO_ROOM }
}
this.cache.set(url, scene)
const previous = this.manifest[slot]
const entry: SlotEntry = {
url,
offset: previous?.offset ?? [0, 0, 0],
rotationDeg: previous?.rotationDeg ?? [0, 0, 0],
scale: previous?.scale ?? 1,
}
this.manifest[slot] = entry
this.unloadable.delete(slot)
this.stage.setCustom(slot, scene, entry)
const count = this.stage.slots.get(slot)?.procedural.length ?? 1
return {
slot, entry, paint, rejected: false,
message: count > 1
? `${fileName} is now all ${count} of the ${spec.label.toLowerCase()}s.`
: `${fileName} is in the ${spec.label.toLowerCase()} slot.`,
}
}
/** Nudge the fit. Re-applies to the stage immediately; nothing is re-parsed. */
setFit(slot: string, patch: Partial<SlotEntry>): SlotEntry | undefined {
const current = this.manifest[slot]
if (!current) return undefined
const next: SlotEntry = { ...current, ...patch }
this.manifest[slot] = next
this.stage.applyFit(slot, next)
return next
}
/** Back to what the game builds today. */
reset(slot: string): void {
delete this.manifest[slot]
this.paintReports.delete(slot)
this.unloadable.delete(slot)
this.stage.clearCustom(slot)
}
/**
* Write the working manifest where the live game will find it.
* Pruning happens only after the write succeeds, and only against a manifest
* that still holds every entry including the ones that would not load.
*/
async saveLocally(): Promise<SaveResult> {
const clean = cleanManifest(this.manifest)
const saved = Object.keys(clean).length
try {
await saveOverrideManifest(clean)
} catch (err) {
console.warn('[workshop] could not save the manifest', err)
return { saved: 0, ok: false, message: NO_ROOM }
}
try {
await pruneOrphans(clean)
} catch (err) {
// Leftover bytes waste space but break nothing — never fail a save for it.
console.warn('[workshop] could not tidy up unused files', err)
}
return {
saved, ok: true,
message: saved === 0
? 'Saved — no swaps, so the game looks like it always did.'
: `Saved ${saved} swap${saved === 1 ? '' : 's'}. Press "Test drive" to play with them.`,
}
}
/** Wipe every override — the game goes back to its shipped look. */
async clearEverything(): Promise<boolean> {
for (const slot of Object.keys(this.manifest)) this.stage.clearCustom(slot)
this.manifest = {}
this.paintReports.clear()
this.unloadable.clear()
this.cache.clear()
try {
await clearOverrides()
return true
} catch (err) {
console.warn('[workshop] could not clear stored swaps', err)
return false
}
}
/** The exact text the download button hands over. */
exportText(): string {
return serializeManifest(this.manifest)
}
/** Re-apply a stored entry to the stage (used by restore and by the demo). */
private async applyEntry(slot: string, entry: SlotEntry): Promise<boolean> {
let scene = this.cache.get(entry.url)
if (!scene) {
const bytes = await this.readBytes(entry.url)
if (!bytes) return false
try {
scene = await loadGlb(bytes)
} catch (err) {
console.warn(`[workshop] stored asset for ${slot} could not be read`, err)
return false
}
if (!firstMesh(scene)) {
console.warn(`[workshop] stored asset for ${slot} has no model in it`)
return false
}
this.cache.set(entry.url, scene)
const spec = slotById(slot)
if (spec?.paintable) this.paintReports.set(slot, inspectPaintability(scene))
}
this.stage.setCustom(slot, scene, entry)
return true
}
private async readBytes(url: string): Promise<ArrayBuffer | null> {
if (isIdbUrl(url)) {
try {
const record = await getGlb(idbKey(url))
return record?.bytes ?? null
} catch (err) {
console.warn(`[workshop] could not read the stored file for ${url}`, err)
return null
}
}
try {
const res = await fetch(url)
if (!res.ok) {
console.warn(`[workshop] ${url} answered ${res.status} — keeping the original.`)
return null
}
return await res.arrayBuffer()
} catch (err) {
console.warn(`[workshop] could not fetch ${url}`, err)
return null
}
}
}

View File

@ -13,8 +13,6 @@ import { createFollowCamera } from './blob/camera'
import { PaintSkin, PaintCannon, BuffSystem, PaintHUD, installPaint } from './paint/index'
import { createBucketDump, createBubbleArch, createConveyorBelt } from './machine/index'
import { installZones } from './course/zones'
import { assets } from './assets/registry'
import { hasOverrides } from './assets/idb'
import { installAudio } from './audio/index'
import { installGhost } from './ghost/index'
import { installTitle } from './ui/title'
@ -98,12 +96,6 @@ export function installGame(world: World) {
world.scene.add(tramp)
world.physics.createCollider(
world.rapier.ColliderDesc.cuboid(6, 0.25, 2).setTranslation(0, 0.25, 0))
// course.tramp slot: hide the MATERIAL, not the mesh — the custom model is
// parented as a child, so hiding the mesh would hide it too. The collider
// above is untouched, so the bounce is identical whatever the pad looks like.
assets().attachSlot('course.tramp', tramp, {
onSwap: () => { (tramp.material as THREE.Material).visible = false },
})
// Fill BOTH under-shelf volumes solid: the front-face plinth stopped
// overshooters, but lane-huggers rolled in from the open x±7 sides and
// wedged (observed at (-4.1,-7.9)). Under-shelf space serves no gameplay —
@ -250,20 +242,6 @@ export function installGame(world: World) {
setTimeout(() => toast.remove(), 3200)
})
// ---- custom-assets pill: a forgotten Workshop swap otherwise reads as a
// broken game ("why is the boot a weird cylinder?"), with no way back. ----
void hasOverrides().then((on) => {
if (!on) return
const pill = document.createElement('a')
pill.href = 'editor.html'
pill.textContent = '🎨 your custom models are on — open the Workshop'
pill.style.cssText =
'position:fixed;bottom:14px;left:14px;font:12px ui-monospace,Menlo,monospace;' +
'color:#0a2540;background:rgba(255,255,255,.86);padding:7px 12px;border-radius:999px;' +
'text-decoration:none;z-index:18;box-shadow:0 1px 6px rgba(0,0,0,.18)'
document.body.appendChild(pill)
})
// ---- wave 2: audio, ghost, title ----
installAudio(world)
installGhost(world, blob, { radius: blob.radius })

View File

@ -14,8 +14,6 @@
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
@ -82,43 +80,6 @@ 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 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
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
}

View File

@ -23,20 +23,6 @@ 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]
@ -124,8 +110,6 @@ 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),
@ -211,17 +195,13 @@ 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(
@ -325,13 +305,6 @@ 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()
@ -383,9 +356,6 @@ 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
@ -549,9 +519,6 @@ 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
@ -610,18 +577,15 @@ 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 })
@ -629,11 +593,8 @@ 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 }
@ -744,9 +705,6 @@ 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({

View File

@ -1,12 +1,5 @@
import { createWorld } from './world'
import { installGame } from './game'
import { initAssets } from './assets/registry'
// Custom assets (the Workshop) must be resolved before anything is built —
// createBlob captures blob.mesh by reference. Safe to await: preload races each
// url against a deadline with allSettled, so a stalled asset costs a slow boot
// on built-in props, never a hang.
await initAssets()
const world = await createWorld(document.getElementById('app')!)
installGame(world)

View File

@ -12,7 +12,6 @@ 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
@ -219,8 +218,6 @@ 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 }),
@ -240,17 +237,6 @@ 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: () => { tube.visible = false },
})
return g
}

View File

@ -21,7 +21,6 @@ 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. */
@ -84,22 +83,6 @@ 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. */

View File

@ -8,7 +8,6 @@ export default defineConfig({
rollupOptions: {
input: {
main: resolve(__dirname, 'index.html'),
editor: resolve(__dirname, 'editor.html'), // the Workshop — without this it exists only under `vite dev`
'lane-a': resolve(__dirname, 'demos/lane-a.html'),
'lane-b': resolve(__dirname, 'demos/lane-b.html'),
'lane-c': resolve(__dirname, 'demos/lane-c.html'),