Compare commits

...

8 Commits

Author SHA1 Message Date
type-two
ad93d55de9 Custom-assets pill in game, deploy size guard
- game.ts: a forgotten Workshop swap otherwise reads as a broken game with no
  way back; the pill says what is on and links to the Workshop. hasOverrides()
  existed in idb.ts and nothing called it.
- deploy.sh: fail if dist/ exceeds 10MB. stage-farm-assets.sh copies 43MB of
  raw GLBs into public/, vite copies public/ into dist/, and deploy rsyncs all
  of dist/ — I hit exactly this while browser-testing (47MB build).

Browser-verified end to end: stock boot clean (44 scene children, blob r=0.5,
no console errors), Workshop lists all 18 slots, real .glb drop swaps the stage,
Save writes a raw ArrayBuffer manifest, the game loads it (120k-tri boot renders
in-game), pill appears, Clear restores the stock scene exactly.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-19 12:39:09 +10:00
type-two
2823c93382 Wire the Workshop: registry boot, course.tramp slot, editor in the production build
- src/main.ts: await initAssets() before the world is built (createBlob captures
  blob.mesh by reference). Safe: preload races each url against a deadline with
  allSettled, so a stalled asset is a slow boot on built-ins, never a hang.
- src/game.ts: course.tramp hook (hides the MATERIAL — the custom model is a
  child, so hiding the mesh would hide it too). Collider untouched.
- vite.config.ts: editor.html as a build input. Without it the Workshop existed
  only under vite dev — invisible in the production deploy it is built for.
- editor cannon.barrel: anchor the preview at the cannon group's origin (where
  the runtime attaches it) while still hiding the pivot's tube. Preview was
  0.45m above where the game renders it — WYSIWYG off by a measurable amount.
- manifest-io.test: drop the stale NOT_YET_IN_THE_RUNTIME guard now that the
  runtime adopted all four ids; the check is strict both ways, which is what
  stops the two halves drifting again.

Verified: 10/10 test suites (360 assertions), sacred-parity fingerprint
49df4f20 unchanged from pre-workshop main, dist/editor.html + dist/assets/
manifest.json ship.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-19 12:32:23 +10:00
type-two
d318b4f031 Merge fix-editor: demo DB isolation, mesh-less rejection, per-instance clones, storage-failure handling, private-browsing degrade, canonical slot import
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-19 12:26:44 +10:00
type-two
624caf08da fix(editor): stop the workshop destroying the user's work
Seven hardening fixes on the lane-J editor, several sharing one root cause.

- The demo ran against the LIVE database. Opening demos/lane-j.html deleted
  every saved model (pruneOrphans treats anything the demo's manifest omits as
  garbage) and an interrupted run left a fake machine.boot override the real
  game would load. The database name is now a variable, the demo uses a
  throwaway one, deletes it in a finally, and fingerprints the real store
  before/after so the run FAILS if it moved.
- A .glb with no mesh was accepted on every non-paintable slot: original hidden,
  nothing in its place, cheerful success message, savable. The mesh check now
  runs for every slot, before anything is written.
- Multi-object slots (nine puddles, every cannon barrel) hid all instances and
  added one replacement. One fit node per instance now, each at its own pose and
  its own footprint scale — the same contract the runtime's per-instance
  attachSlot already implements.
- Storage failures rejected into void-discarded promises, freezing the UI on
  "Reading…". Nothing leaves Workshop as a rejection any more: failures come
  back as results carrying a sentence a person can read, and every DOM handler
  goes through one guard.
- idbAvailable() was a typeof check, so private windows passed it and then blew
  up, replacing the whole built UI with a raw error string. It now probes open()
  once and caches the boolean; failure means a labelled read-only editor.
- An override whose GLB failed to reload was dropped from the manifest, after
  which the next save deleted its bytes for good. Failed entries are kept,
  flagged unloadable, surfaced in the slot list, and never pruned.
- The editor kept its own slot list, which disagreed with the runtime's in both
  directions. It now imports SLOT_IDS/SLOT_LABELS from src/assets/slots and
  keeps only presentation data, keyed by SlotId so describing a slot the runtime
  lacks is a compile error.

Also fixes a cross-lane mismatch found on the way: the editor stored GLBs as a
{name,bytes,savedAt} wrapper while the game's reader feeds the record straight
into new Blob([buf]). Writes are raw ArrayBuffer now (reads stay tolerant of the
old shape), so a saved swap can actually load in the game.

Build passes; 312 assertions across 9 headless test files, 49 of them new.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-19 12:13:07 +10:00
Claude
e34773d35f harden asset runtime: guard the swap path, bound the boot, reconcile slots
The three that stopped a pack shipping:
 - a mesh-less GLB (armature-only export) fired onSwap anyway, hiding the
   primitive and adding nothing — an invisible prop with a live collider.
   Mesh count is now checked BEFORE the scene is touched, in one guard that
   covers every consumer including slotObject/blob.face.
 - the fulfilment path had no try/catch, so a throw became an unhandled
   rejection. Split in two: a build failure bails out before onSwap can hide
   anything; an onSwap failure keeps the replacement parented, because
   consumers hide their primitive on onSwap's first line and removing the fit
   node there would manufacture the one forbidden state.
 - preload() awaited every url forever. Each is now raced against a 10s
   deadline with allSettled semantics: a stalled host costs one warning and a
   fallback prop, not a game that never boots.

Also:
 - instantiate/instanceSync never throw — createBlob's call site is frozen and
   unguarded, so a throw there is a black screen.
 - ghost and blob share fitBodyToRadius(); a borrowed blob.body was rendering
   the ghost 2.17x oversized with the farm mesh.
 - skinned blob.body is rejected loudly instead of silently half-working; the
   idle-clip mixer is gone (it animated an orphaned skeleton in the fixed step).
 - paintableInfo counts UV islands: blobbo-base.glb passes every other check
   and still paints wrong at 1140 charts. Warning, not rejection.
 - slots.ts gains cannon.base, course.finish, course.tunnel, course.tramp, all
   hooked except tramp (built in frozen game.ts).
 - manifest ignores _-prefixed metadata keys instead of calling them typos.

Empty-manifest parity verified byte-for-byte: scripts/sacred-parity.check.ts
fingerprints the built scene and reports 49df4f20 on main and on this branch.
2026-07-19 12:12:07 +10:00
type-two
f827f6f861 Merge lane-i: asset runtime (manifest, registry, slot hooks, procedural fallbacks)
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-19 11:54:11 +10:00
type-two
367e700934 Lane J: workshop editor — drop-in GLB fitting on the real course
Three-panel editor page (editor.html + src/editor/): slot list, the actual
greybox course built from the game's own builders, and fit controls. Drop a
.glb on a slot, nudge offset/rotation/scale, save to IndexedDB so the live
game picks it up with no deploy, export manifest.json to commit it.

Design notes worth keeping:
- A custom asset never replaces the procedural node; it goes into a new
  sibling 'fit' node and the original is hidden. Reset is instant, references
  other systems captured stay alive, and the manifest transform lives on a
  node nobody else writes to (feel.ts, telegraph.ts and the machine parts all
  own transforms of their own).
- The stage creates physics but never steps it: no systems, no world.start(),
  just renderOnce() on a rAF. Nothing here affects gameplay.
- A blob body whose UVs paint cannot stick to is refused outright rather than
  swapped in — silently broken paint is worse than a missing model.

Headless verification: 148 assertions across three node test files
(manifest round-trip, GLB paintability against real parsed glTF, and the
whole drop/save/restore path over an in-memory IndexedDB).

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-19 11:40:25 +10:00
Claude
324d7d4568 Lane I: asset runtime — manifest, registry, slot hooks, IndexedDB overrides
Custom GLBs drop into 14 named slots without code changes; an empty manifest
produces today's game by construction (the fallback builders are the original
code moved into a closure, and an empty registry returns the caller's own
object by identity).

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

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-19 11:37:08 +10:00
50 changed files with 6012 additions and 17 deletions

3
.gitignore vendored
View File

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

22
demos/lane-i.html Normal file
View File

@ -0,0 +1,22 @@
<!doctype html>
<html lang="en">
<head><meta charset="utf-8" /><title>BLOBBO lane i demo — asset slots</title>
<style>
html,body{margin:0;height:100%;overflow:hidden;background:#bfe3ff;font-family:system-ui,sans-serif}
#app{width:100%;height:100%}
#legend{position:fixed;bottom:10px;right:10px;padding:10px 12px;border-radius:8px;
background:rgba(20,24,40,.72);color:#eaf2ff;font-size:13px;line-height:1.5;pointer-events:none;max-width:320px}
#legend b{color:#FFD60A}
</style></head>
<body>
<div id="app"></div>
<div id="legend">
<b>BLOBBO — Lane I: asset slots</b><br>
Left column: empty manifest (today's game).<br>
Right column: the same code with a test manifest pointing at the farm GLBs.<br>
The panel top-left reports every slot's load status, the paintability check
for the blob body, and a scripted splat's coverage before/after.
</div>
<script type="module" src="/src/demo/lane-i.ts"></script>
</body>
</html>

94
demos/lane-j.html Normal file
View File

@ -0,0 +1,94 @@
<!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,6 +24,19 @@ 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/"

221
docs/ASSET-SLOTS.md Normal file
View File

@ -0,0 +1,221 @@
# 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.

111
editor.html Normal file
View File

@ -0,0 +1,111 @@
<!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,9 +38,18 @@ implement every slot listed there except the UI rows.
- The delivered mesh's material `map` is replaced by PaintSkin's canvas
texture at integration (skin binds to whatever mesh is present); ensure
the hook exposes the swapped-in paintable mesh as `blob.mesh` (the skin
and buffs read it). If the GLB is a SkinnedMesh with `idleClip`, drive an
AnimationMixer in a fixed-step System (NOT onFrame — hidden-tab rule, see
telegraph.ts docstring for why).
and buffs read it).
**NOT BUILT — SkinnedMesh + `idleClip` was authored here and does not work.**
`createBlob` re-parents the body MESH alone into its own group, leaving the
GLB's bones outside the scene graph with no `matrixWorld` update, so the
clip animates nothing while burning fixed-step CPU; and the body's geometry
is re-centred/re-scaled to the collider radius, which invalidates the
skeleton's bind matrices. `paintableInfo` therefore REJECTS a skinned
`blob.body` (`ok` requires `!skinned`), `idleClip` is ignored with a warning
and no AnimationMixer is created. Making it real means keeping the GLB root
in the scene graph and fitting via a parent node instead of baking into the
geometry — a design change, not a bug fix.
- UV warning: if `paintableInfo.uvOk` is false, console.warn and keep the
procedural sphere instead (paint must never silently break).
4. `assets/manifest.json` — ship EMPTY `{}` (packs come later via integration).

View File

@ -0,0 +1 @@
{}

View File

@ -0,0 +1,95 @@
/**
* Headless audit of the four farm GLBs in assets/meshes/. Run:
* node --import ./scripts/ts-resolve.mjs --experimental-strip-types scripts/farm-assets.check.ts
*
* This is the closest thing to browser verification the asset runtime has: it
* parses each real GLB, runs the same paintability check the game runs, and
* for the blob body normalises the geometry and fires the same
* outside-in raycast PaintSkin uses to turn a world point into a UV. If that
* raycast returns a UV, a splat lands; if it returns null, splats silently do
* nothing, which is the failure mode this file exists to catch.
*
* `THREE.GLTFLoader: Couldn't load texture` warnings are expected here node
* has no ImageBitmap. Geometry, UVs and materials all still parse.
*/
import { readFileSync } from 'node:fs'
import { fileURLToPath } from 'node:url'
import * as THREE from 'three'
import { GLTFLoader, type GLTF } from 'three/examples/jsm/loaders/GLTFLoader.js'
import { paintableInfo, normalizeToRadius } from '../src/assets/registry'
// GLTFLoader reaches for `self` when wiring its texture loaders.
;(globalThis as unknown as { self: unknown }).self = globalThis
const MESH_DIR = fileURLToPath(new URL('../assets/meshes/', import.meta.url))
const FILES = ['blobbo-base', 'prop-spring-boot', 'prop-paint-bucket', 'prop-toaster-launcher']
const loader = new GLTFLoader()
let failures = 0
function parse(file: string): Promise<GLTF | null> {
const buf = readFileSync(MESH_DIR + file + '.glb')
const ab = buf.buffer.slice(buf.byteOffset, buf.byteOffset + buf.byteLength) as ArrayBuffer
return new Promise((res) => loader.parse(ab, '', res, () => res(null)))
}
for (const file of FILES) {
const gltf = await parse(file)
if (!gltf) {
console.log(`${file}: PARSE FAILED`)
failures++
continue
}
const info = paintableInfo(gltf.scene)
console.log(
`${file.padEnd(22)} meshes=${info.meshCount} materials=${info.materialCount} ` +
`tris=${info.triCount} uvOk=${info.uvOk} skinned=${info.skinned} paintSafe=${info.ok}` +
(gltf.animations.length ? ` clips=${gltf.animations.map((a) => a.name).join(',')}` : ''),
)
for (const p of info.problems) console.log(` ! ${p}`)
}
// ---- the blob body, end to end ---------------------------------------------
const gltf = await parse('blobbo-base')
if (!gltf) {
failures++
} else {
let mesh: THREE.Mesh | null = null
gltf.scene.traverse((o) => { if (!mesh && (o as THREE.Mesh).isMesh) mesh = o as THREE.Mesh })
const body = mesh as unknown as THREE.Mesh
const geo = body.geometry.clone()
normalizeToRadius(geo, 0.5)
const fitted = new THREE.Mesh(geo, new THREE.MeshStandardMaterial())
fitted.updateWorldMatrix(true, false)
const r = geo.boundingSphere!.radius
const c = geo.boundingSphere!.center
console.log(`\nblob.body fitted: boundingRadius=${r.toFixed(6)} centre=(${c.x.toFixed(6)}, ${c.y.toFixed(6)}, ${c.z.toFixed(6)})`)
if (Math.abs(r - 0.5) > 1e-5) { console.log(' ! radius is not 0.5 — puddle belly contact would drift'); failures++ }
if (c.length() > 1e-5) { console.log(' ! not centred on the origin — PaintSkin raycasts would miss'); failures++ }
// PaintSkin.uvAtWorldPoint, reproduced: cast from outside, inward through the
// centre, and take the UV of the first hit.
const raycaster = new THREE.Raycaster()
let hits = 0
const dirs = [
new THREE.Vector3(1, 0, 0), new THREE.Vector3(-1, 0, 0),
new THREE.Vector3(0, 1, 0), new THREE.Vector3(0, -1, 0),
new THREE.Vector3(0, 0, 1), new THREE.Vector3(0, 0, -1),
new THREE.Vector3(1, 1, 1).normalize(),
]
for (const dir of dirs) {
const origin = dir.clone().multiplyScalar(r * 2.2)
raycaster.set(origin, dir.clone().negate())
raycaster.near = 0
raycaster.far = r * 4.4
const hit = raycaster.intersectObject(fitted, false).find((h) => h.uv)
if (hit?.uv) hits++
else console.log(` ! no UV hit casting along ${dir.toArray().map((n) => n.toFixed(2)).join(',')} — a splat there would vanish`)
}
console.log(`splat raycast: ${hits}/${dirs.length} directions returned a UV`)
if (hits !== dirs.length) failures++
}
console.log(failures === 0 ? '\nfarm-assets.check: OK' : `\nfarm-assets.check: ${failures} FAILURE(S)`)
if (failures > 0) process.exitCode = 1

View File

@ -0,0 +1,63 @@
// THE SACRED PROPERTY: empty manifest + empty IndexedDB must construct exactly
// what the game constructed before the workshop existed. Fingerprints the whole
// scene graph the course + zones + a cannon build, so any drift shows up as a
// changed hash. Run against main and against the branch; the numbers must match.
//
// It has to be BUNDLED rather than strip-typed: the game modules use TypeScript
// parameter properties, which node's --experimental-strip-types refuses.
//
// ./node_modules/.bin/esbuild scripts/sacred-parity.check.ts --bundle \
// --platform=node --format=esm --outfile=/tmp/sacred.mjs && node /tmp/sacred.mjs
//
// Measured on fix-runtime and on main at f827f6f, both:
// nodes=49 colliders=15 bodies=3 greyboxMeshes=11 blobRadius=0.500000
// sceneFingerprint=49df4f20
import * as THREE from 'three'
import RAPIER from '@dimforge/rapier3d-compat'
import { buildGreybox } from '../src/course/greybox.ts'
import { installZones } from '../src/course/zones.ts'
import { PaintCannon } from '../src/paint/cannon.ts'
import { AssetRegistry, setAssets } from '../src/assets/registry.ts'
import { createBlob } from '../src/blob/createBlob.ts'
setAssets(new AssetRegistry({})) // empty manifest, no IndexedDB
await RAPIER.init()
const scene = new THREE.Scene()
const physics = new RAPIER.World({ x: 0, y: -9.81, z: 0 })
const listeners: any = {}
const world: any = {
scene, physics, rapier: RAPIER,
events: { on: (n: string, f: any) => { (listeners[n] ||= []).push(f) }, emit: () => {} },
addSystem: () => {}, onFrame: () => {}, tick: () => {}, renderOnce: () => {}, start: () => {},
}
const gb = buildGreybox(world)
const blob = createBlob(world, {})
world.blob = blob
const skin: any = { boundingRadius: 0.5, splat: () => {}, splatAtWorldPoint: () => {}, coverage: () => ({ total: 0 }) }
installZones(world, blob, skin)
new PaintCannon({ world, color: 'red', position: new THREE.Vector3(0, 2, 0), target: blob.mesh, paint: skin, targetRadius: 0.5 })
await new Promise((r) => setTimeout(r, 50)) // let any async swap settle
function fp(root: THREE.Object3D) {
const parts: string[] = []
root.traverse((o) => {
const m = o as THREE.Mesh
const mat: any = Array.isArray(m.material) ? m.material[0] : m.material
parts.push([
o.type, o.name, o.visible ? 1 : 0,
o.position.toArray().map((n) => n.toFixed(4)).join(','),
o.scale.toArray().map((n) => n.toFixed(4)).join(','),
m.isMesh ? (m.geometry.getAttribute('position')?.count ?? 0) : '-',
mat ? `${mat.type}:${mat.color?.getHexString?.() ?? ''}:${mat.visible ? 1 : 0}` : '-',
].join('|'))
})
return parts
}
const lines = fp(scene)
let h = 0
for (const s of lines) for (let i = 0; i < s.length; i++) h = (Math.imul(31, h) + s.charCodeAt(i)) | 0
console.log(`nodes=${lines.length} colliders=${physics.colliders.len()} bodies=${physics.bodies.len()} greyboxMeshes=${gb.meshes.length} blobRadius=${blob.mesh.geometry.boundingSphere?.radius?.toFixed(6) ?? (blob.mesh.geometry.computeBoundingSphere(), blob.mesh.geometry.boundingSphere!.radius.toFixed(6))}`)
console.log(`sceneFingerprint=${(h >>> 0).toString(16)}`)

13
scripts/stage-farm-assets.sh Executable file
View File

@ -0,0 +1,13 @@
#!/usr/bin/env bash
# Copy the four farm GLBs into the served asset directory so the lane-i demo
# (and any manifest that names them) can actually fetch them.
#
# They are NOT committed there: the four total ~43 MB of raw farm output with
# embedded textures, and deploy.sh rsyncs + docker-cps the whole dist/ on every
# deploy. Compress them offline before shipping a pack to production.
set -euo pipefail
root="$(cd "$(dirname "$0")/.." && pwd)"
mkdir -p "$root/public/assets/meshes"
cp "$root"/assets/meshes/*.glb "$root/public/assets/meshes/"
du -sh "$root/public/assets/meshes"
echo "staged -> public/assets/meshes/ (gitignored; served at \${BASE_URL}assets/meshes/)"

25
scripts/ts-resolve.mjs Normal file
View File

@ -0,0 +1,25 @@
/**
* Node resolve hook so the repo's extensionless TS imports (`./manifest`) work
* under `node --experimental-strip-types`, which otherwise demands a real file
* extension. Vite resolves these at build time; node does not.
*
* node --import ./scripts/ts-resolve.mjs --experimental-strip-types src/assets/manifest.test.ts
*/
import { registerHooks } from 'node:module'
import { existsSync } from 'node:fs'
import { fileURLToPath } from 'node:url'
registerHooks({
resolve(specifier, context, next) {
try {
return next(specifier, context)
} catch (err) {
if (!specifier.startsWith('.') || !context.parentURL) throw err
for (const ext of ['.ts', '/index.ts']) {
const url = new URL(specifier + ext, context.parentURL)
if (existsSync(fileURLToPath(url))) return next(specifier + ext, context)
}
throw err
}
},
})

134
src/assets/blobBody.ts Normal file
View File

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

302
src/assets/harden.test.ts Normal file
View File

@ -0,0 +1,302 @@
/**
* Headless reproductions of the asset-runtime failures found in review. Run:
* node --import ./scripts/ts-resolve.mjs --experimental-strip-types src/assets/harden.test.ts
*
* Each block first states the OBSERVABLE failure it is pinning down, because
* every one of these was reproducible before the fix and silent in a browser.
* No network and no renderer: assets are injected straight into the registry's
* cache, which is exactly the state `preload()` leaves it in.
*/
import * as THREE from 'three'
import type { World } from '../contracts'
import { AssetRegistry, setAssets, paintableInfo, uvLayoutInfo, collectMeshes } from './registry'
import { resolveBlobBody, fitBodyToRadius } from './blobBody'
import { validateManifest } from './manifest'
import { SLOT_IDS, SLOT_LABELS } from './slots'
let passed = 0
function ok(cond: boolean, msg: string): void {
if (!cond) throw new Error('FAIL: ' + msg)
passed++
}
function near(a: number, b: number, msg: string, eps = 1e-5): void {
ok(Math.abs(a - b) <= eps, `${msg} (got ${a}, want ${b})`)
}
/** Put a scene in the registry's cache as if preload() had fetched it. */
function inject(reg: AssetRegistry, url: string, scene: THREE.Group): void {
const asset = { scene, animations: [] as THREE.AnimationClip[] }
const priv = reg as unknown as {
cache: Map<string, Promise<unknown>>
resolved: Map<string, unknown>
}
priv.cache.set(url, Promise.resolve(asset))
priv.resolved.set(url, asset)
}
function sphereGlb(radius = 1): THREE.Group {
const g = new THREE.Group()
g.add(new THREE.Mesh(new THREE.SphereGeometry(radius, 24, 18), new THREE.MeshStandardMaterial()))
return g
}
const warnings: string[] = []
const realWarn = console.warn
console.warn = (...a: unknown[]) => { warnings.push(a.map(String).join(' ')) }
function drainWarnings(): string[] {
const out = warnings.slice()
warnings.length = 0
return out
}
const tick = (): Promise<void> => new Promise((r) => setTimeout(r, 0))
// ---- B1: a mesh-less GLB must not blank the slot ----------------------------
// Before: onSwap fired unconditionally, so greybox hid the box material and
// added nothing — an invisible prop with a live collider.
{
const reg = new AssetRegistry({ 'course.scenery.block': { url: 'rig-only.glb' } })
const empty = new THREE.Group()
empty.add(new THREE.Object3D()) // an armature/empty, no Mesh anywhere
inject(reg, 'rig-only.glb', empty)
const box = new THREE.Mesh(new THREE.BoxGeometry(1, 1, 1), new THREE.MeshStandardMaterial())
let swapped = false
reg.attachSlot('course.scenery.block', box, {
onSwap: () => { swapped = true; (box.material as THREE.Material).visible = false },
})
await tick()
ok(!swapped, 'a mesh-less GLB never fires onSwap')
ok((box.material as THREE.Material).visible, 'the procedural box is still visible')
ok(box.children.length === 0, 'no empty fit node was parented')
ok(drainWarnings().some((w) => w.includes('has no mesh')), 'it says so, once')
}
// blob.face goes through slotObject, which is the same guard one level up.
{
const reg = new AssetRegistry({ 'blob.face': { url: 'rig-only.glb' } })
inject(reg, 'rig-only.glb', new THREE.Group())
const eyes = new THREE.Object3D()
const holder = reg.slotObject('blob.face', () => eyes)
await tick()
ok(holder.children.includes(eyes), 'the eyes survive a mesh-less blob.face asset')
drainWarnings()
}
// ---- B2: nothing thrown in the swap path may escape or half-swap ------------
{
// (a) a throw BEFORE onSwap leaves the primitive visible and adds nothing.
const reg = new AssetRegistry({ 'machine.boot': { url: 'bad.glb' } })
const scene = sphereGlb()
// A material that explodes on clone() — instantiate()'s per-instance clone.
const mesh = collectMeshes(scene)[0]
;(mesh.material as THREE.Material).clone = () => { throw new Error('boom in clone') }
inject(reg, 'bad.glb', scene)
const host = new THREE.Object3D()
let swapped = false
reg.attachSlot('machine.boot', host, { onSwap: () => { swapped = true } })
await tick()
ok(!swapped, 'a throw during instancing never reaches onSwap')
ok(host.children.length === 0, 'nothing half-parented')
ok(drainWarnings().some((w) => w.includes('keeping the built-in version')), 'warned instead of rejecting a promise')
}
{
// (b) a throw INSIDE onSwap must not remove the replacement. Consumers hide
// their primitive on onSwap's first line, so removing the fit node here would
// manufacture the exact "primitive hidden, replacement missing" state.
const reg = new AssetRegistry({ 'fx.puddle': { url: 'ok.glb' } })
inject(reg, 'ok.glb', sphereGlb())
const slab = new THREE.Mesh(new THREE.BoxGeometry(1, 1, 1), new THREE.MeshStandardMaterial())
reg.attachSlot('fx.puddle', slab, {
onSwap: () => {
;(slab.material as THREE.Material).visible = false // consumers do this FIRST
throw new Error('boom in onSwap')
},
})
await tick()
ok(slab.children.length === 1, 'the replacement stays in the scene when onSwap throws')
ok(collectMeshes(slab.children[0]).length === 1, 'and it still contains the mesh')
ok(drainWarnings().some((w) => w.includes('could not be re-styled')), 'the styling failure is reported')
}
{
// (c) instanceSync is called from createBlob, which frozen game.ts calls with
// no try/catch — a throw there is a black screen, not a missing prop.
const reg = new AssetRegistry({ 'blob.body': { url: 'bad.glb' } })
const scene = sphereGlb()
const mesh = collectMeshes(scene)[0]
;(mesh.material as THREE.Material).clone = () => { throw new Error('boom in clone') }
inject(reg, 'bad.glb', scene)
setAssets(reg)
const built = new THREE.Mesh(new THREE.SphereGeometry(0.5, 48, 36), new THREE.MeshStandardMaterial())
const fakeWorld = { addSystem: () => {} } as unknown as World
const out = resolveBlobBody(fakeWorld, 0.5, () => built)
ok(out === built, 'a throwing blob.body falls back to the built-in blob instead of killing the boot')
ok(reg.instanceSync('blob.body') === null, 'instanceSync returns null rather than throwing')
drainWarnings()
}
// ---- B3: preload must not hang the boot -------------------------------------
{
const reg = new AssetRegistry({ 'machine.fan': { url: 'stalled.glb' } })
const priv = reg as unknown as { cache: Map<string, Promise<unknown>> }
priv.cache.set('stalled.glb', new Promise(() => { /* a host that never answers */ }))
const t0 = Date.now()
await reg.preload(60) // same code path, a deadline you can wait for in a test
const dt = Date.now() - t0
ok(dt < 2000, `preload gave up on a stalled url in ${dt}ms instead of never resolving`)
ok(drainWarnings().some((w) => w.includes('did not finish loading')), 'the stalled url is named')
ok(reg.instanceSync('machine.fan') === null, 'the slot simply stays on its fallback')
}
{
// allSettled semantics: one bad url must not starve a good one.
const reg = new AssetRegistry({
'machine.fan': { url: 'stalled.glb' },
'machine.belt': { url: 'good.glb' },
})
const priv = reg as unknown as { cache: Map<string, Promise<unknown>> }
priv.cache.set('stalled.glb', new Promise(() => {}))
inject(reg, 'good.glb', sphereGlb())
await reg.preload(60)
ok(reg.instanceSync('machine.belt') !== null, 'the healthy asset still loaded')
drainWarnings()
}
// ---- ghost sizing -----------------------------------------------------------
// Before: the ghost borrowed blob.body raw, so a 1.084-radius farm mesh
// rendered the ghost 2.17x the blob it is meant to mirror.
{
const raw = sphereGlb(1.0841) // blobbo-base.glb's measured bounding radius
const fit = new THREE.Group()
fit.scale.setScalar(3) // a fit node built from a manifest `scale`, as attachSlot builds it
fit.add(raw)
const mesh = fitBodyToRadius(fit, 0.5)
ok(mesh !== null, 'the shared fit found the body mesh')
fit.updateMatrixWorld(true)
const box = new THREE.Box3().setFromObject(fit)
const size = new THREE.Vector3()
box.getSize(size)
near(size.x / 2, 0.5, 'the ghost body ends up at the blob radius in WORLD space')
near(box.getCenter(new THREE.Vector3()).length(), 0, 'and centred on the ghost origin')
}
{
// The blob and the ghost must agree for the same asset — that is the point.
const reg = new AssetRegistry({ 'blob.body': { url: 'body.glb', scale: 4, offset: [3, 0, 0] } })
inject(reg, 'body.glb', sphereGlb(1.0841))
setAssets(reg)
const fakeWorld = { addSystem: () => {} } as unknown as World
const blobMesh = resolveBlobBody(fakeWorld, 0.5, () => new THREE.Mesh())
blobMesh.geometry.computeBoundingSphere()
const ghostInst = reg.instanceSync('blob.body')!
const ghostFit = new THREE.Group()
ghostFit.add(ghostInst.object)
const ghostMesh = fitBodyToRadius(ghostFit, 0.5, ghostInst.entry)!
ghostMesh.geometry.computeBoundingSphere()
near(
ghostMesh.geometry.boundingSphere!.radius,
blobMesh.geometry.boundingSphere!.radius,
'ghost and blob resolve to the same radius from the same asset',
)
drainWarnings()
}
// ---- skinned blob.body is rejected loudly, not repaired ---------------------
{
const geo = new THREE.SphereGeometry(0.5, 16, 12)
const bone = new THREE.Bone()
const skinned = new THREE.SkinnedMesh(geo, new THREE.MeshStandardMaterial())
skinned.add(bone)
const info = paintableInfo(skinned)
ok(info.skinned, 'the skeleton is detected')
ok(!info.ok, 'a rigged body is REJECTED — the skeleton is orphaned by the design and normalising invalidates the bind matrices')
ok(info.problems.some((p) => p.includes('skeleton/armature')), 'in plain language')
}
{
// ...and no AnimationMixer system is registered for it, so we are not burning
// fixed-step CPU animating something that is not in the scene graph.
const reg = new AssetRegistry({ 'blob.body': { url: 'body.glb', idleClip: 'Idle' } })
inject(reg, 'body.glb', sphereGlb())
setAssets(reg)
const systems: unknown[] = []
const fakeWorld = { addSystem: (s: unknown) => systems.push(s) } as unknown as World
resolveBlobBody(fakeWorld, 0.5, () => new THREE.Mesh())
ok(systems.length === 0, 'idleClip registers no fixed-step system')
ok(drainWarnings().some((w) => w.includes('idleClip')), 'and says the clip is ignored')
}
// ---- UV atlas detection -----------------------------------------------------
// blobbo-base.glb passes every other check (0..1 UVs, one material) and still
// paints wrong: it is a 1140-island auto-atlas.
{
const sphere = uvLayoutInfo(new THREE.SphereGeometry(0.5, 48, 36))
ok(sphere.charts === 3, `three's own sphere is 3 charts (body + 2 pole fans), got ${sphere.charts}`)
ok(paintableInfo(new THREE.Mesh(new THREE.SphereGeometry(0.5, 48, 36), new THREE.MeshStandardMaterial())).problems.length === 0,
'the stock blob is NOT flagged as an atlas')
// A shattered layout: same sphere, every triangle its own island.
const shattered = new THREE.SphereGeometry(0.5, 24, 18).toNonIndexed()
const idx: number[] = []
for (let i = 0; i < shattered.getAttribute('position').count; i++) idx.push(i)
shattered.setIndex(idx)
const info = uvLayoutInfo(shattered)
ok(info.charts > 24, `a per-triangle atlas is detected (${info.charts} charts)`)
const p = paintableInfo(new THREE.Mesh(shattered, new THREE.MeshStandardMaterial()))
ok(p.problems.some((s) => s.includes('islands')), 'and reported as a scatter risk')
ok(p.ok, 'but still accepted — it paints untidily, it does not fail to paint')
}
// ---- manifest metadata keys -------------------------------------------------
{
const res = validateManifest({
_readme: 'exported by the BLOBBO workshop',
_generatedBy: 'workshop 1.0',
'machine.boot': { url: 'a.glb' },
'machine.tractor': { url: 'b.glb' },
})
ok(res.problems.length === 1, `only the real typo is reported (got ${res.problems.length})`)
ok(res.problems[0].slot === 'machine.tractor', 'and it is the typo, not the metadata')
ok(Object.keys(res.manifest).length === 1, 'metadata never becomes a slot')
}
// ---- slot vocabulary --------------------------------------------------------
{
for (const id of ['cannon.base', 'course.finish', 'course.tunnel', 'course.tramp'] as const) {
ok(SLOT_IDS.includes(id), `${id} is in the canonical list the editor imports`)
ok(typeof SLOT_LABELS[id] === 'string' && SLOT_LABELS[id].length > 0, `${id} has a label`)
}
ok(SLOT_IDS.length === new Set(SLOT_IDS).size, 'no duplicate slot ids')
ok(SLOT_IDS.every((id) => SLOT_LABELS[id]), 'every slot id has a label')
}
// ---- multi-instance slots ---------------------------------------------------
// Nine puddles and six cannons share one slot id. Each must get its OWN clone at
// its OWN pose — one shared node would teleport eight puddles onto the ninth.
{
const reg = new AssetRegistry({ 'fx.puddle': { url: 'decal.glb' } })
inject(reg, 'decal.glb', sphereGlb(0.5))
const slabs = [-10, 0, 10].map((x) => {
const m = new THREE.Mesh(new THREE.BoxGeometry(1, 1, 1), new THREE.MeshStandardMaterial())
m.position.set(x, 0, 0)
return m
})
for (const s of slabs) {
reg.attachSlot('fx.puddle', s, {
fit: new THREE.Vector3(2, 1, 2),
onSwap: () => { (s.material as THREE.Material).visible = false },
})
}
await tick()
const roots = slabs.map((s) => s.children[0])
ok(roots.every((r) => r !== undefined), 'every instance got a replacement')
ok(new Set(roots).size === 3, 'three distinct fit nodes, not one shared node')
const mats = slabs.map((s) => collectMeshes(s)[0].material)
ok(new Set(mats).size === 3, 'and three distinct materials, so per-instance tinting cannot bleed')
const world = new THREE.Vector3()
collectMeshes(slabs[2])[0].getWorldPosition(world)
near(world.x, 10, 'each clone sits at its own instance pose')
drainWarnings()
}
console.warn = realWarn
console.log(`harden.test.ts: ${passed} checks passed`)

123
src/assets/idb.ts Normal file
View File

@ -0,0 +1,123 @@
/**
* Lane I local override store (`blobbo-workshop`).
*
* Why IndexedDB and not a server: John must be able to try a custom asset on
* the LIVE site with zero deploys. The editor writes a manifest + the raw .glb
* bytes here; the game reads them at boot and they win over the shipped pack.
*
* Two stores:
* `manifest` single record under key 'current', a Manifest object.
* `blobs` key -> ArrayBuffer of a .glb, addressed as `idb:<key>` urls.
*
* Every function resolves rather than rejects when IndexedDB is unavailable
* (private mode, old browser, node): no override is a normal state, not an error.
*/
import { validateManifest, type Manifest } from './manifest'
export const DB_NAME = 'blobbo-workshop'
export const DB_VERSION = 1
export const STORE_MANIFEST = 'manifest'
export const STORE_BLOBS = 'blobs'
const MANIFEST_KEY = 'current'
export const IDB_URL_PREFIX = 'idb:'
export function isIdbUrl(url: string): boolean {
return url.startsWith(IDB_URL_PREFIX)
}
export function idbKeyFromUrl(url: string): string {
return url.slice(IDB_URL_PREFIX.length)
}
function openDb(): Promise<IDBDatabase | null> {
return new Promise((resolve) => {
if (typeof indexedDB === 'undefined') return resolve(null)
let req: IDBOpenDBRequest
try {
req = indexedDB.open(DB_NAME, DB_VERSION)
} catch {
return resolve(null)
}
req.onupgradeneeded = () => {
const db = req.result
if (!db.objectStoreNames.contains(STORE_MANIFEST)) db.createObjectStore(STORE_MANIFEST)
if (!db.objectStoreNames.contains(STORE_BLOBS)) db.createObjectStore(STORE_BLOBS)
}
req.onsuccess = () => resolve(req.result)
req.onerror = () => resolve(null)
req.onblocked = () => resolve(null)
})
}
function request<T>(store: IDBObjectStore, run: (s: IDBObjectStore) => IDBRequest): Promise<T | null> {
return new Promise((resolve) => {
let req: IDBRequest
try {
req = run(store)
} catch {
return resolve(null)
}
req.onsuccess = () => resolve(req.result as T)
req.onerror = () => resolve(null)
})
}
async function withStore<T>(
name: string,
mode: IDBTransactionMode,
run: (s: IDBObjectStore) => IDBRequest,
): Promise<T | null> {
const db = await openDb()
if (!db) return null
try {
const tx = db.transaction(name, mode)
const out = await request<T>(tx.objectStore(name), run)
return out
} catch {
return null
} finally {
db.close()
}
}
/** The editor's saved pack, or `{}` when there is none. Never throws. */
export async function loadOverrideManifest(): Promise<Manifest> {
const raw = await withStore<unknown>(STORE_MANIFEST, 'readonly', (s) => s.get(MANIFEST_KEY))
if (raw === null || raw === undefined) return {}
const { manifest, problems } = validateManifest(raw)
if (problems.length) console.warn('[assets] local override manifest problems:', problems)
return manifest
}
export async function saveOverrideManifest(manifest: Manifest): Promise<boolean> {
const out = await withStore<IDBValidKey>(STORE_MANIFEST, 'readwrite', (s) =>
s.put(manifest, MANIFEST_KEY),
)
return out !== null
}
export async function clearOverrides(): Promise<void> {
await withStore(STORE_MANIFEST, 'readwrite', (s) => s.clear())
await withStore(STORE_BLOBS, 'readwrite', (s) => s.clear())
}
export async function putBlob(key: string, data: ArrayBuffer): Promise<boolean> {
const out = await withStore<IDBValidKey>(STORE_BLOBS, 'readwrite', (s) => s.put(data, key))
return out !== null
}
export async function getBlob(key: string): Promise<ArrayBuffer | null> {
return withStore<ArrayBuffer>(STORE_BLOBS, 'readonly', (s) => s.get(key))
}
export async function listBlobKeys(): Promise<string[]> {
const keys = await withStore<IDBValidKey[]>(STORE_BLOBS, 'readonly', (s) => s.getAllKeys())
return (keys ?? []).map(String)
}
/** True when a local pack is active — the game shows a "custom assets" pill. */
export async function hasOverrides(): Promise<boolean> {
const m = await loadOverrideManifest()
return Object.keys(m).length > 0
}

View File

@ -0,0 +1,92 @@
/**
* Unit checks for the manifest schema. No test runner / no deps run directly:
* node --experimental-strip-types src/assets/manifest.test.ts
* (Node 22; on Node 23 the flag is unnecessary.) Same pattern as
* paint/coverage-math.test.ts: console + throw only, never bundled.
*
* The manifest is hand-edited by a non-programmer, so the property under test
* is "one bad line never costs you the whole pack".
*/
import { validateManifest, mergeManifests, type Manifest } from './manifest'
import { SLOT_IDS, isSlotId, SLOT_LABELS } from './slots'
let passed = 0
function ok(cond: boolean, msg: string): void {
if (!cond) throw new Error('FAIL: ' + msg)
passed++
}
// ---- garbage in, empty manifest out (never throws) --------------------------
for (const junk of [null, undefined, 42, 'nope', [], true]) {
const r = validateManifest(junk)
ok(Object.keys(r.manifest).length === 0, `junk input ${JSON.stringify(junk)} -> {}`)
}
// ---- the shipped empty manifest ---------------------------------------------
ok(Object.keys(validateManifest(JSON.parse('{}')).manifest).length === 0, 'empty {} -> {}')
ok(validateManifest({}).problems.length === 0, 'empty {} has no problems')
// ---- a good entry survives intact -------------------------------------------
{
const r = validateManifest({
'machine.boot': {
url: 'assets/live/boot.glb',
offset: [0, 1, 0],
rotationDeg: [0, 90, 0],
scale: 2,
idleClip: 'idle',
},
})
const e = r.manifest['machine.boot']
ok(r.problems.length === 0, 'clean entry produces no problems')
ok(e?.url === 'assets/live/boot.glb', 'url kept')
ok(e?.offset?.[1] === 1, 'offset kept')
ok(e?.rotationDeg?.[1] === 90, 'rotationDeg kept')
ok(e?.scale === 2, 'scale kept')
ok(e?.idleClip === 'idle', 'idleClip kept')
}
// ---- one bad line does not cost you the pack --------------------------------
{
const r = validateManifest({
'machine.boot': { url: 'good.glb' },
'machine.bucket': { url: 'also-good.glb', offset: 'sideways' },
'not.a.slot': { url: 'x.glb' },
'machine.arch': { nourl: true },
'machine.fan': 'just a string',
})
ok(r.manifest['machine.boot'] !== undefined, 'good entry survives a bad neighbour')
ok(r.manifest['machine.bucket'] !== undefined, 'entry with a bad field still loads')
ok(r.manifest['machine.bucket']?.offset === undefined, 'bad offset dropped, not kept')
ok(r.manifest['machine.arch'] === undefined, 'entry without a url is dropped')
ok((r.manifest as Record<string, unknown>)['not.a.slot'] === undefined, 'unknown slot dropped')
ok(r.problems.length === 4, `4 problems reported (got ${r.problems.length})`)
ok(r.problems.some((p) => p.slot === 'not.a.slot'), 'unknown slot is named in problems')
}
// ---- scale accepts a number or a triple, rejects zero -----------------------
ok(validateManifest({ 'machine.fan': { url: 'a', scale: [1, 2, 3] } }).manifest['machine.fan']?.scale !== undefined, 'triple scale ok')
ok(validateManifest({ 'machine.fan': { url: 'a', scale: 0 } }).manifest['machine.fan']?.scale === undefined, 'zero scale rejected')
ok(validateManifest({ 'machine.fan': { url: 'a', offset: [1, 2, NaN] } }).manifest['machine.fan']?.offset === undefined, 'NaN offset rejected')
// ---- merge: later source wins whole entries ---------------------------------
{
const shipped: Manifest = {
'machine.boot': { url: 'shipped.glb', scale: 3 },
'machine.fan': { url: 'fan.glb' },
}
const local: Manifest = { 'machine.boot': { url: 'local.glb' } }
const m = mergeManifests(shipped, local)
ok(m['machine.boot']?.url === 'local.glb', 'local override wins')
ok(m['machine.boot']?.scale === undefined, 'override replaces the whole entry, not per-field')
ok(m['machine.fan']?.url === 'fan.glb', 'untouched shipped entry survives the merge')
ok(shipped['machine.boot']?.url === 'shipped.glb', 'merge does not mutate its inputs')
}
// ---- slot table is coherent --------------------------------------------------
ok(new Set(SLOT_IDS).size === SLOT_IDS.length, 'no duplicate slot ids')
ok(SLOT_IDS.every((s) => typeof SLOT_LABELS[s] === 'string'), 'every slot has a plain-language label')
ok(SLOT_IDS.every(isSlotId), 'every slot id passes isSlotId')
ok(!isSlotId('blob.bodyy') && !isSlotId(''), 'isSlotId rejects near-misses')
console.log(`manifest.test.ts: ${passed} checks passed`)

144
src/assets/manifest.ts Normal file
View File

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

133
src/assets/registry.test.ts Normal file
View File

@ -0,0 +1,133 @@
/**
* Headless checks for the asset runtime's geometry/material logic. Run:
* node --import ./scripts/ts-resolve.mjs --experimental-strip-types src/assets/registry.test.ts
* No renderer is created, so this runs without WebGL three's BufferGeometry
* and material classes are pure data.
*
* The load-bearing test here is EMPTY-MANIFEST PARITY: with no manifest, the
* blob hook must hand back the caller's own mesh object, not a rebuild of it.
*/
import * as THREE from 'three'
import type { World } from '../contracts'
import { AssetRegistry, setAssets, paintableInfo, normalizeToRadius, makeFitNode, ensureStandardMaterials } from './registry'
import { resolveBlobBody } from './blobBody'
let passed = 0
function ok(cond: boolean, msg: string): void {
if (!cond) throw new Error('FAIL: ' + msg)
passed++
}
function near(a: number, b: number, msg: string, eps = 1e-6): void {
ok(Math.abs(a - b) <= eps, `${msg} (got ${a}, want ${b})`)
}
// ---- EMPTY-MANIFEST PARITY --------------------------------------------------
{
setAssets(new AssetRegistry({}))
const systems: unknown[] = []
const fakeWorld = { addSystem: (s: unknown) => systems.push(s) } as unknown as World
const built = new THREE.Mesh(
new THREE.SphereGeometry(0.5, 48, 36),
new THREE.MeshStandardMaterial({ color: '#F5F5F7' }),
)
const out = resolveBlobBody(fakeWorld, 0.5, () => built)
ok(out === built, 'empty manifest returns the caller\'s own mesh object (identity, not a rebuild)')
ok(out.geometry === built.geometry, 'geometry untouched')
ok(out.material === built.material, 'material untouched')
ok(systems.length === 0, 'no system registered when no asset is in play')
// and the slot helpers are pass-throughs too
const fb = new THREE.Object3D()
ok(new AssetRegistry({}).slotObject('machine.boot', () => fb) === fb,
'slotObject with an empty manifest returns the fallback itself (no wrapper Group)')
const host = new THREE.Object3D()
new AssetRegistry({}).attachSlot('machine.boot', host)
ok(host.children.length === 0, 'attachSlot with an empty manifest adds nothing')
}
// ---- paintableInfo ----------------------------------------------------------
{
const good = new THREE.Mesh(new THREE.SphereGeometry(0.5, 24, 18), new THREE.MeshStandardMaterial())
const info = paintableInfo(good)
ok(info.ok, 'a plain UV sphere is paintable')
// three offsets pole UVs slightly outside 0..1; the stock blob must pass.
ok(info.uvOk, 'the stock blob sphere passes the UV test despite its pole offset')
ok(info.materialCount === 1, 'one material')
ok(info.meshCount === 1, 'one mesh')
ok(info.triCount > 0, `tri count reported (${info.triCount})`)
ok(info.problems.length === 0, 'no problems reported')
}
{
const m = new THREE.Mesh(new THREE.SphereGeometry(0.5, 8, 6), [
new THREE.MeshStandardMaterial(), new THREE.MeshStandardMaterial(),
])
const info = paintableInfo(m)
ok(!info.ok, 'a two-material mesh is rejected (paint would be silently invisible)')
ok(info.materialCount === 2, 'material count reported')
ok(info.problems.some((p) => p.includes('materials')), 'the reason names the materials')
}
{
const geo = new THREE.BufferGeometry()
geo.setAttribute('position', new THREE.BufferAttribute(new Float32Array(9), 3))
const info = paintableInfo(new THREE.Mesh(geo, new THREE.MeshStandardMaterial()))
ok(!info.ok && !info.uvOk, 'a mesh with no UVs is rejected')
ok(info.problems.some((p) => p.includes('UV')), 'the reason names UVs')
}
{
const geo = new THREE.PlaneGeometry(1, 1)
const uv = geo.getAttribute('uv') as THREE.BufferAttribute
;(uv.array as Float32Array)[0] = 3.5 // an atlas/tiled layout
const info = paintableInfo(new THREE.Mesh(geo, new THREE.MeshStandardMaterial()))
ok(!info.uvOk, 'UVs outside 0..1 are rejected')
}
{
const info = paintableInfo(new THREE.Group())
ok(!info.ok && info.meshCount === 0, 'an empty file is rejected without throwing')
}
// ---- normalizeToRadius ------------------------------------------------------
{
// A model authored 7x too big with its origin at the feet.
const geo = new THREE.SphereGeometry(3.5, 16, 12)
geo.translate(0, 3.5, 0)
normalizeToRadius(geo, 0.5)
geo.computeBoundingSphere()
const bs = geo.boundingSphere!
near(bs.radius, 0.5, 'normalised to the collider radius')
near(bs.center.length(), 0, 'recentred on the origin', 1e-5)
}
{
const degenerate = new THREE.BufferGeometry()
degenerate.setAttribute('position', new THREE.BufferAttribute(new Float32Array(3), 3))
normalizeToRadius(degenerate, 0.5) // must not divide by zero / throw
ok(true, 'a zero-size geometry does not crash the normaliser')
}
// ---- makeFitNode ------------------------------------------------------------
{
const fit = makeFitNode({ url: 'x', offset: [1, 2, 3], rotationDeg: [0, 90, 0], scale: 2 })
ok(fit.position.x === 1 && fit.position.z === 3, 'offset applied')
near(fit.rotation.y, Math.PI / 2, 'degrees converted to radians')
ok(fit.scale.x === 2 && fit.scale.y === 2, 'uniform scale applied')
const fit2 = makeFitNode({ url: 'x', scale: [1, 2, 3] }, new THREE.Vector3(2, 2, 2))
ok(fit2.scale.x === 2 && fit2.scale.y === 4 && fit2.scale.z === 6, 'per-axis scale times the caller fit')
const id = makeFitNode({ url: 'x' })
ok(id.position.length() === 0 && id.scale.x === 1, 'an entry with no transform is identity')
}
// ---- ensureStandardMaterials ------------------------------------------------
{
// telegraph.ts only flashes MeshStandardMaterial; anything else silently
// breaks the mandatory danger telegraph, so imports are converted.
const m = new THREE.Mesh(new THREE.BoxGeometry(1, 1, 1), new THREE.MeshBasicMaterial({ color: '#ff0000' }))
const n = ensureStandardMaterials(m)
ok(n === 1, 'one material converted')
const mat = m.material as unknown as THREE.MeshStandardMaterial
ok(mat.isMeshStandardMaterial === true, 'basic material became standard')
ok(mat.color.getHexString() === 'ff0000', 'colour preserved through the conversion')
ok(ensureStandardMaterials(m) === 0, 'an already-standard material is left alone')
}
console.log(`registry.test.ts: ${passed} checks passed`)

560
src/assets/registry.ts Normal file
View File

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

97
src/assets/slots.ts Normal file
View File

@ -0,0 +1,97 @@
/**
* 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,6 +11,8 @@ import * as THREE from 'three'
import type RAPIER from '@dimforge/rapier3d-compat'
import type { Blob, World } from '../contracts'
import { BASE_WHITE, defaultModifiers } from '../contracts'
import { assets } from '../assets/registry'
import { resolveBlobBody } from '../assets/blobBody'
export interface CreateBlobOptions {
position?: THREE.Vector3
@ -67,20 +69,27 @@ export function createBlob(world: World, opts: CreateBlobOptions = {}): BlobHand
// Paintable body: clean UV sphere. Default THREE sphere UVs are exactly what
// Lane B wants (equirectangular, seam at the back). Do not decorate this mesh.
const geometry = new THREE.SphereGeometry(radius, seg, Math.round(seg * 0.75))
const material = new THREE.MeshStandardMaterial({
color: BASE_WHITE,
roughness: 0.5,
metalness: 0.0,
emissive: new THREE.Color('#88e0ff'),
emissiveIntensity: 0.0, // driven by modifiers.glow in the feel layer
})
const mesh = new THREE.Mesh(geometry, material)
mesh.castShadow = true
// Slot `blob.body` may replace it, but only with a mesh that passes the same
// paint requirements and is refitted to `radius` (see assets/blobBody.ts).
const buildBodyMesh = (): THREE.Mesh => {
const geometry = new THREE.SphereGeometry(radius, seg, Math.round(seg * 0.75))
const material = new THREE.MeshStandardMaterial({
color: BASE_WHITE,
roughness: 0.5,
metalness: 0.0,
emissive: new THREE.Color('#88e0ff'),
emissiveIntensity: 0.0, // driven by modifiers.glow in the feel layer
})
const m = new THREE.Mesh(geometry, material)
m.castShadow = true
return m
}
const mesh = resolveBlobBody(world, radius, buildBodyMesh)
group.add(mesh)
// Cosmetic face, parented to the group (yaws to face travel, scales with size).
const face = buildFace(radius)
const face = new THREE.Group()
face.add(assets().slotObject('blob.face', () => buildFace(radius)))
group.add(face)
// Physics proxy: dynamic ball, rotations locked so it stays an upright

View File

@ -12,6 +12,8 @@
*/
import * as THREE from 'three'
import type { World } from '../contracts'
import { assets } from '../assets/registry'
import type { SlotId } from '../assets/slots'
export interface GreyboxHandle {
/** Recommended blob spawn point (just above the start plateau). */
@ -69,6 +71,17 @@ export function buildGreybox(world: World): GreyboxHandle {
return mesh
}
/**
* Park a custom prop inside a greybox box. The box's material is switched off
* rather than the mesh itself so the prop (a child) still renders, and the
* mesh stays in `meshes[]` so the handle's contract is unchanged.
*/
function decorate(slot: SlotId, mesh: THREE.Mesh): void {
assets().attachSlot(slot, mesh, {
onSwap: () => { (mesh.material as THREE.Material).visible = false },
})
}
// ---- base floor: catches everything, spans the whole course ----
addBox(0, -0.5, -15, 28, 0.5, 60, { color: '#efe3c6', roughness: 1 })
@ -101,11 +114,14 @@ export function buildGreybox(world: World): GreyboxHandle {
})
// ---- finish pad (bright pink, raised) ----
addBox(0, 0.6, -64, 9, 0.6, 6, { color: '#FF6EB4', surface: 'finish', cast: true })
decorate('course.finish', addBox(0, 0.6, -64, 9, 0.6, 6, { color: '#FF6EB4', surface: 'finish', cast: true }))
// ---- absurd-proportion scenery: a giant cereal box beside the start ----
addBox(-20, 7, 26, 4, 7, 3, { color: '#FFD60A', cast: true })
addBox(20, 5, 12, 3, 5, 3, { color: '#AF52DE', cast: true })
// Slot hooks go on the MESH only, after the collider exists: the box volume
// is still solid whatever model is dropped in, so a custom prop can never
// open a hole in the course or grow an invisible wall.
decorate('course.scenery.cereal', addBox(-20, 7, 26, 4, 7, 3, { color: '#FFD60A', cast: true }))
decorate('course.scenery.block', addBox(20, 5, 12, 3, 5, 3, { color: '#AF52DE', cast: true }))
return {
spawn: new THREE.Vector3(0, 3.5, 30),

View File

@ -35,6 +35,7 @@ import { PALETTE } from '../contracts'
import type { PaintSkin } from '../paint/skin'
import { installPuddles, type PaintPuddle, type PaintPuddleConfig } from '../paint/puddles'
import { createPressurePlate, createSpringBoot, type Vec3 } from '../machine'
import { assets } from '../assets/registry'
/** A cannon integration should stage for a zone (fires that zone's colour). */
export interface ZoneCannonConfig {
@ -204,6 +205,13 @@ function buildTunnel(
rapier.ColliderDesc.cuboid(hx, hy, hz).setTranslation(cx, cy, cz),
)
// Slot `course.tunnel` decorates the ROOF only, after the collider exists: the
// clearance a MINI blob squeezes through is the collider's, so no model can
// move it. The warning bar and posts stay procedural — they are signage.
assets().attachSlot('course.tunnel', roof, {
onSwap: () => { roofMat.visible = false },
})
// "MIND YOUR HEAD" clearance bar hanging just under the lip so the low ceiling
// reads before you reach it (visual only).
const barMat = new THREE.MeshStandardMaterial({

140
src/demo/lane-i.ts Normal file
View File

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

249
src/demo/lane-j.ts Normal file
View File

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

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

28
src/editor/glb.ts Normal file
View File

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

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

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

157
src/editor/idb.ts Normal file
View File

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

497
src/editor/main.ts Normal file
View File

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

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

164
src/editor/manifest-io.ts Normal file
View File

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

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

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

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

191
src/editor/slot-swap.ts Normal file
View File

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

180
src/editor/slots.ts Normal file
View File

@ -0,0 +1,180 @@
/**
* 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']

342
src/editor/stage.ts Normal file
View File

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

106
src/editor/store.ts Normal file
View File

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

145
src/editor/tiny-glb.ts Normal file
View File

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

198
src/editor/workshop.test.ts Normal file
View File

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

349
src/editor/workshop.ts Normal file
View File

@ -0,0 +1,349 @@
/**
* 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,6 +13,8 @@ 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'
@ -96,6 +98,12 @@ 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 —
@ -242,6 +250,20 @@ 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,6 +14,8 @@
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
@ -80,6 +82,43 @@ 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,6 +23,20 @@ import type RAPIER from '@dimforge/rapier3d-compat'
import type { World, PaintColor } from '../contracts'
import { PALETTE } from '../contracts'
import { telegraph, isTelegraphing } from './telegraph'
import { assets } from '../assets/registry'
/**
* Slot hook shared by every part: park the custom model under the part's root
* and hide the primitives it replaces. Only the primitives listed in `replaces`
* go away animated sub-parts (the plate cap, the belt chevrons, the fan
* blades) stay procedural so they keep moving.
*/
function slotPart(slot: Parameters<ReturnType<typeof assets>['attachSlot']>[0],
root: THREE.Object3D, replaces: THREE.Object3D[]): void {
assets().attachSlot(slot, root, {
onSwap: () => { for (const o of replaces) o.visible = false },
})
}
export type Vec3 = [number, number, number]
@ -110,6 +124,8 @@ export function createPressurePlate(world: World, cfg: PressurePlateConfig): Mac
cap.receiveShadow = true
group.add(cap)
slotPart('machine.plate', group, [base])
// physics: fixed platform the blob actually rests on
const body = physics.createRigidBody(
rapier.RigidBodyDesc.fixed().setTranslation(pos.x, pos.y + cap.position.y, pos.z),
@ -195,13 +211,17 @@ export function createSpringBoot(world: World, cfg: SpringBootConfig): MachinePa
// coil spring under the pad (telegraph target scales/shakes the whole group)
const coilMat = new THREE.MeshStandardMaterial({ color: '#7f8c8d', metalness: 0.6, roughness: 0.3 })
const coils: THREE.Mesh[] = []
for (let i = 0; i < 3; i++) {
const ring = new THREE.Mesh(new THREE.TorusGeometry(0.7, 0.09, 8, 20), coilMat)
ring.rotation.x = Math.PI / 2
ring.position.y = -0.15 - i * 0.22
group.add(ring)
coils.push(ring)
}
slotPart('machine.boot', group, [pad, ...coils])
// physics pad so the ball can rest here between signal and kick
if (cfg.pad !== false) {
const body = physics.createRigidBody(
@ -305,6 +325,13 @@ export function createSeeSaw(world: World, cfg: SeeSawConfig): MachinePart {
plank.receiveShadow = true
group.add(plank)
// The plank's world pose is copied off the rigid body every frame, so the
// custom model rides as its CHILD and the primitive is hidden by turning its
// material off — hiding the plank itself would hide the child too.
assets().attachSlot('machine.seesaw', plank, {
onSwap: () => { (plank.material as THREE.Material).visible = false },
})
world.onFrame(() => {
const t = plankBody.translation()
const r = plankBody.rotation()
@ -356,6 +383,9 @@ export function createBucketDump(world: World, cfg: BucketDumpConfig): MachinePa
fill.position.y = -0.15
group.add(fill)
// Fill stays procedural: it is the colour read, and it is re-tinted at runtime.
slotPart('machine.bucket', group, [shell])
let dumping = false
let tip = 0 // current tip angle
let tipTarget = 0
@ -519,6 +549,9 @@ export function createConveyorBelt(world: World, cfg: ConveyorBeltConfig): Machi
group.add(c)
chevrons.push(c)
}
// Chevrons stay procedural — they scroll every frame to advertise direction.
slotPart('machine.belt', group, [belt])
const placeChevron = (c: THREE.Mesh, offset: number) => {
const t = ((offset % span) + span) % span - span * 0.5
if (alongX) c.position.x = t
@ -577,15 +610,18 @@ export function createBubbleArch(world: World, cfg: BubbleArchConfig): MachinePa
scene.add(group)
const postMat = new THREE.MeshStandardMaterial({ color: '#00bcd4', roughness: 0.3, metalness: 0.2 })
const archParts: THREE.Mesh[] = []
for (const sx of [-1, 1]) {
const post = new THREE.Mesh(new THREE.CylinderGeometry(0.2, 0.2, h, 12), postMat)
post.position.set((sx * w) / 2, h / 2, 0)
post.castShadow = true
group.add(post)
archParts.push(post)
}
const bar = new THREE.Mesh(new THREE.BoxGeometry(w + 0.4, 0.4, 0.4), postMat)
bar.position.y = h
group.add(bar)
archParts.push(bar)
// a few permanent decorative bubbles clinging to the arch (ambient telegraph)
const bubbleMat = new THREE.MeshStandardMaterial({ color: '#e0f7ff', transparent: true, opacity: 0.5, roughness: 0.05 })
@ -593,8 +629,11 @@ export function createBubbleArch(world: World, cfg: BubbleArchConfig): MachinePa
const b = new THREE.Mesh(new THREE.SphereGeometry(0.15 + Math.random() * 0.2, 10, 8), bubbleMat)
b.position.set((Math.random() - 0.5) * w, Math.random() * h, (Math.random() - 0.5) * d)
group.add(b)
archParts.push(b)
}
slotPart('machine.arch', group, archParts)
const detCenter = { x: pos.x, y: pos.y + h * 0.5, z: pos.z }
const detHalf = { x: w * 0.5, y: h * 0.5, z: d * 0.5 }
@ -705,6 +744,9 @@ export function createFan(world: World, cfg: FanConfig): MachinePart {
blades.position.z = 0.05
group.add(blades)
// Blades stay procedural — the constant spin IS this part's telegraph.
slotPart('machine.fan', group, [housing])
const beam = beamBox(pos, dir, range, spread)
world.addSystem({

View File

@ -1,5 +1,12 @@
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,6 +12,7 @@ import * as THREE from 'three'
import type { PaintColor, System, World } from '../contracts'
import { PALETTE } from '../contracts'
import type { PaintSkin } from './skin'
import { assets } from '../assets/registry'
export interface PaintCannonConfig {
world: World
@ -218,6 +219,8 @@ export class PaintCannon implements System {
private buildBarrel(hex: string): THREE.Group {
const g = new THREE.Group()
// Slot `cannon.barrel` decorates the base; the named `pivot` child and its
// local +Z stay procedural because aimBarrel() steers them every step.
const base = new THREE.Mesh(
new THREE.CylinderGeometry(0.45, 0.55, 0.5, 16),
new THREE.MeshStandardMaterial({ color: '#3a3a44', roughness: 0.7 }),
@ -237,6 +240,17 @@ 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,6 +21,7 @@ import * as THREE from 'three'
import type { Blob, PaintColor, World } from '../contracts'
import { PALETTE } from '../contracts'
import type { PaintSkin } from './skin'
import { assets } from '../assets/registry'
export interface PaintPuddleConfig {
/** Strip centre in world space; y is the track surface the strip lies on. */
@ -83,6 +84,22 @@ export class PaintPuddle {
this.mesh.position.set(x, y + 0.03, z)
this.mesh.receiveShadow = true
world.scene.add(this.mesh)
// Slot `fx.puddle`: a 1x1 decal model is stretched to this strip's footprint.
// Visual only — the trigger is pure maths on the config numbers below.
assets().attachSlot('fx.puddle', this.mesh, {
fit: new THREE.Vector3(w, 1, l),
onSwap: (asset) => {
mat.visible = false
asset.traverse((o) => {
const m = (o as THREE.Mesh).material as THREE.MeshStandardMaterial | undefined
if (m && m.isMeshStandardMaterial) {
m.color.set(PALETTE[cfg.color])
m.emissive.set(PALETTE[cfg.color])
}
})
},
})
}
/** True while the blob's belly is resting inside this strip's footprint. */

View File

@ -8,6 +8,7 @@ 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'),