Compare commits
8 Commits
cabc60def8
...
ad93d55de9
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
ad93d55de9 | ||
|
|
2823c93382 | ||
|
|
d318b4f031 | ||
|
|
624caf08da | ||
|
|
e34773d35f | ||
|
|
f827f6f861 | ||
|
|
367e700934 | ||
|
|
324d7d4568 |
3
.gitignore
vendored
3
.gitignore
vendored
@ -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
22
demos/lane-i.html
Normal 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
94
demos/lane-j.html
Normal 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>
|
||||
13
deploy.sh
13
deploy.sh
@ -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
221
docs/ASSET-SLOTS.md
Normal 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
111
editor.html
Normal 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>
|
||||
@ -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).
|
||||
|
||||
1
public/assets/manifest.json
Normal file
1
public/assets/manifest.json
Normal file
@ -0,0 +1 @@
|
||||
{}
|
||||
95
scripts/farm-assets.check.ts
Normal file
95
scripts/farm-assets.check.ts
Normal 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
|
||||
63
scripts/sacred-parity.check.ts
Normal file
63
scripts/sacred-parity.check.ts
Normal file
@ -0,0 +1,63 @@
|
||||
// THE SACRED PROPERTY: empty manifest + empty IndexedDB must construct exactly
|
||||
// what the game constructed before the workshop existed. Fingerprints the whole
|
||||
// scene graph the course + zones + a cannon build, so any drift shows up as a
|
||||
// changed hash. Run against main and against the branch; the numbers must match.
|
||||
//
|
||||
// It has to be BUNDLED rather than strip-typed: the game modules use TypeScript
|
||||
// parameter properties, which node's --experimental-strip-types refuses.
|
||||
//
|
||||
// ./node_modules/.bin/esbuild scripts/sacred-parity.check.ts --bundle \
|
||||
// --platform=node --format=esm --outfile=/tmp/sacred.mjs && node /tmp/sacred.mjs
|
||||
//
|
||||
// Measured on fix-runtime and on main at f827f6f, both:
|
||||
// nodes=49 colliders=15 bodies=3 greyboxMeshes=11 blobRadius=0.500000
|
||||
// sceneFingerprint=49df4f20
|
||||
import * as THREE from 'three'
|
||||
import RAPIER from '@dimforge/rapier3d-compat'
|
||||
import { buildGreybox } from '../src/course/greybox.ts'
|
||||
import { installZones } from '../src/course/zones.ts'
|
||||
import { PaintCannon } from '../src/paint/cannon.ts'
|
||||
import { AssetRegistry, setAssets } from '../src/assets/registry.ts'
|
||||
import { createBlob } from '../src/blob/createBlob.ts'
|
||||
|
||||
setAssets(new AssetRegistry({})) // empty manifest, no IndexedDB
|
||||
await RAPIER.init()
|
||||
|
||||
const scene = new THREE.Scene()
|
||||
const physics = new RAPIER.World({ x: 0, y: -9.81, z: 0 })
|
||||
const listeners: any = {}
|
||||
const world: any = {
|
||||
scene, physics, rapier: RAPIER,
|
||||
events: { on: (n: string, f: any) => { (listeners[n] ||= []).push(f) }, emit: () => {} },
|
||||
addSystem: () => {}, onFrame: () => {}, tick: () => {}, renderOnce: () => {}, start: () => {},
|
||||
}
|
||||
|
||||
const gb = buildGreybox(world)
|
||||
const blob = createBlob(world, {})
|
||||
world.blob = blob
|
||||
const skin: any = { boundingRadius: 0.5, splat: () => {}, splatAtWorldPoint: () => {}, coverage: () => ({ total: 0 }) }
|
||||
installZones(world, blob, skin)
|
||||
new PaintCannon({ world, color: 'red', position: new THREE.Vector3(0, 2, 0), target: blob.mesh, paint: skin, targetRadius: 0.5 })
|
||||
|
||||
await new Promise((r) => setTimeout(r, 50)) // let any async swap settle
|
||||
|
||||
function fp(root: THREE.Object3D) {
|
||||
const parts: string[] = []
|
||||
root.traverse((o) => {
|
||||
const m = o as THREE.Mesh
|
||||
const mat: any = Array.isArray(m.material) ? m.material[0] : m.material
|
||||
parts.push([
|
||||
o.type, o.name, o.visible ? 1 : 0,
|
||||
o.position.toArray().map((n) => n.toFixed(4)).join(','),
|
||||
o.scale.toArray().map((n) => n.toFixed(4)).join(','),
|
||||
m.isMesh ? (m.geometry.getAttribute('position')?.count ?? 0) : '-',
|
||||
mat ? `${mat.type}:${mat.color?.getHexString?.() ?? ''}:${mat.visible ? 1 : 0}` : '-',
|
||||
].join('|'))
|
||||
})
|
||||
return parts
|
||||
}
|
||||
const lines = fp(scene)
|
||||
let h = 0
|
||||
for (const s of lines) for (let i = 0; i < s.length; i++) h = (Math.imul(31, h) + s.charCodeAt(i)) | 0
|
||||
console.log(`nodes=${lines.length} colliders=${physics.colliders.len()} bodies=${physics.bodies.len()} greyboxMeshes=${gb.meshes.length} blobRadius=${blob.mesh.geometry.boundingSphere?.radius?.toFixed(6) ?? (blob.mesh.geometry.computeBoundingSphere(), blob.mesh.geometry.boundingSphere!.radius.toFixed(6))}`)
|
||||
console.log(`sceneFingerprint=${(h >>> 0).toString(16)}`)
|
||||
13
scripts/stage-farm-assets.sh
Executable file
13
scripts/stage-farm-assets.sh
Executable 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
25
scripts/ts-resolve.mjs
Normal 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
134
src/assets/blobBody.ts
Normal 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
302
src/assets/harden.test.ts
Normal file
@ -0,0 +1,302 @@
|
||||
/**
|
||||
* Headless reproductions of the asset-runtime failures found in review. Run:
|
||||
* node --import ./scripts/ts-resolve.mjs --experimental-strip-types src/assets/harden.test.ts
|
||||
*
|
||||
* Each block first states the OBSERVABLE failure it is pinning down, because
|
||||
* every one of these was reproducible before the fix and silent in a browser.
|
||||
* No network and no renderer: assets are injected straight into the registry's
|
||||
* cache, which is exactly the state `preload()` leaves it in.
|
||||
*/
|
||||
import * as THREE from 'three'
|
||||
import type { World } from '../contracts'
|
||||
import { AssetRegistry, setAssets, paintableInfo, uvLayoutInfo, collectMeshes } from './registry'
|
||||
import { resolveBlobBody, fitBodyToRadius } from './blobBody'
|
||||
import { validateManifest } from './manifest'
|
||||
import { SLOT_IDS, SLOT_LABELS } from './slots'
|
||||
|
||||
let passed = 0
|
||||
function ok(cond: boolean, msg: string): void {
|
||||
if (!cond) throw new Error('FAIL: ' + msg)
|
||||
passed++
|
||||
}
|
||||
function near(a: number, b: number, msg: string, eps = 1e-5): void {
|
||||
ok(Math.abs(a - b) <= eps, `${msg} (got ${a}, want ${b})`)
|
||||
}
|
||||
|
||||
/** Put a scene in the registry's cache as if preload() had fetched it. */
|
||||
function inject(reg: AssetRegistry, url: string, scene: THREE.Group): void {
|
||||
const asset = { scene, animations: [] as THREE.AnimationClip[] }
|
||||
const priv = reg as unknown as {
|
||||
cache: Map<string, Promise<unknown>>
|
||||
resolved: Map<string, unknown>
|
||||
}
|
||||
priv.cache.set(url, Promise.resolve(asset))
|
||||
priv.resolved.set(url, asset)
|
||||
}
|
||||
|
||||
function sphereGlb(radius = 1): THREE.Group {
|
||||
const g = new THREE.Group()
|
||||
g.add(new THREE.Mesh(new THREE.SphereGeometry(radius, 24, 18), new THREE.MeshStandardMaterial()))
|
||||
return g
|
||||
}
|
||||
|
||||
const warnings: string[] = []
|
||||
const realWarn = console.warn
|
||||
console.warn = (...a: unknown[]) => { warnings.push(a.map(String).join(' ')) }
|
||||
function drainWarnings(): string[] {
|
||||
const out = warnings.slice()
|
||||
warnings.length = 0
|
||||
return out
|
||||
}
|
||||
const tick = (): Promise<void> => new Promise((r) => setTimeout(r, 0))
|
||||
|
||||
// ---- B1: a mesh-less GLB must not blank the slot ----------------------------
|
||||
// Before: onSwap fired unconditionally, so greybox hid the box material and
|
||||
// added nothing — an invisible prop with a live collider.
|
||||
{
|
||||
const reg = new AssetRegistry({ 'course.scenery.block': { url: 'rig-only.glb' } })
|
||||
const empty = new THREE.Group()
|
||||
empty.add(new THREE.Object3D()) // an armature/empty, no Mesh anywhere
|
||||
inject(reg, 'rig-only.glb', empty)
|
||||
|
||||
const box = new THREE.Mesh(new THREE.BoxGeometry(1, 1, 1), new THREE.MeshStandardMaterial())
|
||||
let swapped = false
|
||||
reg.attachSlot('course.scenery.block', box, {
|
||||
onSwap: () => { swapped = true; (box.material as THREE.Material).visible = false },
|
||||
})
|
||||
await tick()
|
||||
ok(!swapped, 'a mesh-less GLB never fires onSwap')
|
||||
ok((box.material as THREE.Material).visible, 'the procedural box is still visible')
|
||||
ok(box.children.length === 0, 'no empty fit node was parented')
|
||||
ok(drainWarnings().some((w) => w.includes('has no mesh')), 'it says so, once')
|
||||
}
|
||||
|
||||
// blob.face goes through slotObject, which is the same guard one level up.
|
||||
{
|
||||
const reg = new AssetRegistry({ 'blob.face': { url: 'rig-only.glb' } })
|
||||
inject(reg, 'rig-only.glb', new THREE.Group())
|
||||
const eyes = new THREE.Object3D()
|
||||
const holder = reg.slotObject('blob.face', () => eyes)
|
||||
await tick()
|
||||
ok(holder.children.includes(eyes), 'the eyes survive a mesh-less blob.face asset')
|
||||
drainWarnings()
|
||||
}
|
||||
|
||||
// ---- B2: nothing thrown in the swap path may escape or half-swap ------------
|
||||
{
|
||||
// (a) a throw BEFORE onSwap leaves the primitive visible and adds nothing.
|
||||
const reg = new AssetRegistry({ 'machine.boot': { url: 'bad.glb' } })
|
||||
const scene = sphereGlb()
|
||||
// A material that explodes on clone() — instantiate()'s per-instance clone.
|
||||
const mesh = collectMeshes(scene)[0]
|
||||
;(mesh.material as THREE.Material).clone = () => { throw new Error('boom in clone') }
|
||||
inject(reg, 'bad.glb', scene)
|
||||
|
||||
const host = new THREE.Object3D()
|
||||
let swapped = false
|
||||
reg.attachSlot('machine.boot', host, { onSwap: () => { swapped = true } })
|
||||
await tick()
|
||||
ok(!swapped, 'a throw during instancing never reaches onSwap')
|
||||
ok(host.children.length === 0, 'nothing half-parented')
|
||||
ok(drainWarnings().some((w) => w.includes('keeping the built-in version')), 'warned instead of rejecting a promise')
|
||||
}
|
||||
{
|
||||
// (b) a throw INSIDE onSwap must not remove the replacement. Consumers hide
|
||||
// their primitive on onSwap's first line, so removing the fit node here would
|
||||
// manufacture the exact "primitive hidden, replacement missing" state.
|
||||
const reg = new AssetRegistry({ 'fx.puddle': { url: 'ok.glb' } })
|
||||
inject(reg, 'ok.glb', sphereGlb())
|
||||
const slab = new THREE.Mesh(new THREE.BoxGeometry(1, 1, 1), new THREE.MeshStandardMaterial())
|
||||
reg.attachSlot('fx.puddle', slab, {
|
||||
onSwap: () => {
|
||||
;(slab.material as THREE.Material).visible = false // consumers do this FIRST
|
||||
throw new Error('boom in onSwap')
|
||||
},
|
||||
})
|
||||
await tick()
|
||||
ok(slab.children.length === 1, 'the replacement stays in the scene when onSwap throws')
|
||||
ok(collectMeshes(slab.children[0]).length === 1, 'and it still contains the mesh')
|
||||
ok(drainWarnings().some((w) => w.includes('could not be re-styled')), 'the styling failure is reported')
|
||||
}
|
||||
{
|
||||
// (c) instanceSync is called from createBlob, which frozen game.ts calls with
|
||||
// no try/catch — a throw there is a black screen, not a missing prop.
|
||||
const reg = new AssetRegistry({ 'blob.body': { url: 'bad.glb' } })
|
||||
const scene = sphereGlb()
|
||||
const mesh = collectMeshes(scene)[0]
|
||||
;(mesh.material as THREE.Material).clone = () => { throw new Error('boom in clone') }
|
||||
inject(reg, 'bad.glb', scene)
|
||||
setAssets(reg)
|
||||
|
||||
const built = new THREE.Mesh(new THREE.SphereGeometry(0.5, 48, 36), new THREE.MeshStandardMaterial())
|
||||
const fakeWorld = { addSystem: () => {} } as unknown as World
|
||||
const out = resolveBlobBody(fakeWorld, 0.5, () => built)
|
||||
ok(out === built, 'a throwing blob.body falls back to the built-in blob instead of killing the boot')
|
||||
ok(reg.instanceSync('blob.body') === null, 'instanceSync returns null rather than throwing')
|
||||
drainWarnings()
|
||||
}
|
||||
|
||||
// ---- B3: preload must not hang the boot -------------------------------------
|
||||
{
|
||||
const reg = new AssetRegistry({ 'machine.fan': { url: 'stalled.glb' } })
|
||||
const priv = reg as unknown as { cache: Map<string, Promise<unknown>> }
|
||||
priv.cache.set('stalled.glb', new Promise(() => { /* a host that never answers */ }))
|
||||
const t0 = Date.now()
|
||||
await reg.preload(60) // same code path, a deadline you can wait for in a test
|
||||
const dt = Date.now() - t0
|
||||
ok(dt < 2000, `preload gave up on a stalled url in ${dt}ms instead of never resolving`)
|
||||
ok(drainWarnings().some((w) => w.includes('did not finish loading')), 'the stalled url is named')
|
||||
ok(reg.instanceSync('machine.fan') === null, 'the slot simply stays on its fallback')
|
||||
}
|
||||
{
|
||||
// allSettled semantics: one bad url must not starve a good one.
|
||||
const reg = new AssetRegistry({
|
||||
'machine.fan': { url: 'stalled.glb' },
|
||||
'machine.belt': { url: 'good.glb' },
|
||||
})
|
||||
const priv = reg as unknown as { cache: Map<string, Promise<unknown>> }
|
||||
priv.cache.set('stalled.glb', new Promise(() => {}))
|
||||
inject(reg, 'good.glb', sphereGlb())
|
||||
await reg.preload(60)
|
||||
ok(reg.instanceSync('machine.belt') !== null, 'the healthy asset still loaded')
|
||||
drainWarnings()
|
||||
}
|
||||
|
||||
// ---- ghost sizing -----------------------------------------------------------
|
||||
// Before: the ghost borrowed blob.body raw, so a 1.084-radius farm mesh
|
||||
// rendered the ghost 2.17x the blob it is meant to mirror.
|
||||
{
|
||||
const raw = sphereGlb(1.0841) // blobbo-base.glb's measured bounding radius
|
||||
const fit = new THREE.Group()
|
||||
fit.scale.setScalar(3) // a fit node built from a manifest `scale`, as attachSlot builds it
|
||||
fit.add(raw)
|
||||
const mesh = fitBodyToRadius(fit, 0.5)
|
||||
ok(mesh !== null, 'the shared fit found the body mesh')
|
||||
fit.updateMatrixWorld(true)
|
||||
const box = new THREE.Box3().setFromObject(fit)
|
||||
const size = new THREE.Vector3()
|
||||
box.getSize(size)
|
||||
near(size.x / 2, 0.5, 'the ghost body ends up at the blob radius in WORLD space')
|
||||
near(box.getCenter(new THREE.Vector3()).length(), 0, 'and centred on the ghost origin')
|
||||
}
|
||||
{
|
||||
// The blob and the ghost must agree for the same asset — that is the point.
|
||||
const reg = new AssetRegistry({ 'blob.body': { url: 'body.glb', scale: 4, offset: [3, 0, 0] } })
|
||||
inject(reg, 'body.glb', sphereGlb(1.0841))
|
||||
setAssets(reg)
|
||||
const fakeWorld = { addSystem: () => {} } as unknown as World
|
||||
const blobMesh = resolveBlobBody(fakeWorld, 0.5, () => new THREE.Mesh())
|
||||
blobMesh.geometry.computeBoundingSphere()
|
||||
|
||||
const ghostInst = reg.instanceSync('blob.body')!
|
||||
const ghostFit = new THREE.Group()
|
||||
ghostFit.add(ghostInst.object)
|
||||
const ghostMesh = fitBodyToRadius(ghostFit, 0.5, ghostInst.entry)!
|
||||
ghostMesh.geometry.computeBoundingSphere()
|
||||
near(
|
||||
ghostMesh.geometry.boundingSphere!.radius,
|
||||
blobMesh.geometry.boundingSphere!.radius,
|
||||
'ghost and blob resolve to the same radius from the same asset',
|
||||
)
|
||||
drainWarnings()
|
||||
}
|
||||
|
||||
// ---- skinned blob.body is rejected loudly, not repaired ---------------------
|
||||
{
|
||||
const geo = new THREE.SphereGeometry(0.5, 16, 12)
|
||||
const bone = new THREE.Bone()
|
||||
const skinned = new THREE.SkinnedMesh(geo, new THREE.MeshStandardMaterial())
|
||||
skinned.add(bone)
|
||||
const info = paintableInfo(skinned)
|
||||
ok(info.skinned, 'the skeleton is detected')
|
||||
ok(!info.ok, 'a rigged body is REJECTED — the skeleton is orphaned by the design and normalising invalidates the bind matrices')
|
||||
ok(info.problems.some((p) => p.includes('skeleton/armature')), 'in plain language')
|
||||
}
|
||||
{
|
||||
// ...and no AnimationMixer system is registered for it, so we are not burning
|
||||
// fixed-step CPU animating something that is not in the scene graph.
|
||||
const reg = new AssetRegistry({ 'blob.body': { url: 'body.glb', idleClip: 'Idle' } })
|
||||
inject(reg, 'body.glb', sphereGlb())
|
||||
setAssets(reg)
|
||||
const systems: unknown[] = []
|
||||
const fakeWorld = { addSystem: (s: unknown) => systems.push(s) } as unknown as World
|
||||
resolveBlobBody(fakeWorld, 0.5, () => new THREE.Mesh())
|
||||
ok(systems.length === 0, 'idleClip registers no fixed-step system')
|
||||
ok(drainWarnings().some((w) => w.includes('idleClip')), 'and says the clip is ignored')
|
||||
}
|
||||
|
||||
// ---- UV atlas detection -----------------------------------------------------
|
||||
// blobbo-base.glb passes every other check (0..1 UVs, one material) and still
|
||||
// paints wrong: it is a 1140-island auto-atlas.
|
||||
{
|
||||
const sphere = uvLayoutInfo(new THREE.SphereGeometry(0.5, 48, 36))
|
||||
ok(sphere.charts === 3, `three's own sphere is 3 charts (body + 2 pole fans), got ${sphere.charts}`)
|
||||
ok(paintableInfo(new THREE.Mesh(new THREE.SphereGeometry(0.5, 48, 36), new THREE.MeshStandardMaterial())).problems.length === 0,
|
||||
'the stock blob is NOT flagged as an atlas')
|
||||
|
||||
// A shattered layout: same sphere, every triangle its own island.
|
||||
const shattered = new THREE.SphereGeometry(0.5, 24, 18).toNonIndexed()
|
||||
const idx: number[] = []
|
||||
for (let i = 0; i < shattered.getAttribute('position').count; i++) idx.push(i)
|
||||
shattered.setIndex(idx)
|
||||
const info = uvLayoutInfo(shattered)
|
||||
ok(info.charts > 24, `a per-triangle atlas is detected (${info.charts} charts)`)
|
||||
const p = paintableInfo(new THREE.Mesh(shattered, new THREE.MeshStandardMaterial()))
|
||||
ok(p.problems.some((s) => s.includes('islands')), 'and reported as a scatter risk')
|
||||
ok(p.ok, 'but still accepted — it paints untidily, it does not fail to paint')
|
||||
}
|
||||
|
||||
// ---- manifest metadata keys -------------------------------------------------
|
||||
{
|
||||
const res = validateManifest({
|
||||
_readme: 'exported by the BLOBBO workshop',
|
||||
_generatedBy: 'workshop 1.0',
|
||||
'machine.boot': { url: 'a.glb' },
|
||||
'machine.tractor': { url: 'b.glb' },
|
||||
})
|
||||
ok(res.problems.length === 1, `only the real typo is reported (got ${res.problems.length})`)
|
||||
ok(res.problems[0].slot === 'machine.tractor', 'and it is the typo, not the metadata')
|
||||
ok(Object.keys(res.manifest).length === 1, 'metadata never becomes a slot')
|
||||
}
|
||||
|
||||
// ---- slot vocabulary --------------------------------------------------------
|
||||
{
|
||||
for (const id of ['cannon.base', 'course.finish', 'course.tunnel', 'course.tramp'] as const) {
|
||||
ok(SLOT_IDS.includes(id), `${id} is in the canonical list the editor imports`)
|
||||
ok(typeof SLOT_LABELS[id] === 'string' && SLOT_LABELS[id].length > 0, `${id} has a label`)
|
||||
}
|
||||
ok(SLOT_IDS.length === new Set(SLOT_IDS).size, 'no duplicate slot ids')
|
||||
ok(SLOT_IDS.every((id) => SLOT_LABELS[id]), 'every slot id has a label')
|
||||
}
|
||||
|
||||
// ---- multi-instance slots ---------------------------------------------------
|
||||
// Nine puddles and six cannons share one slot id. Each must get its OWN clone at
|
||||
// its OWN pose — one shared node would teleport eight puddles onto the ninth.
|
||||
{
|
||||
const reg = new AssetRegistry({ 'fx.puddle': { url: 'decal.glb' } })
|
||||
inject(reg, 'decal.glb', sphereGlb(0.5))
|
||||
const slabs = [-10, 0, 10].map((x) => {
|
||||
const m = new THREE.Mesh(new THREE.BoxGeometry(1, 1, 1), new THREE.MeshStandardMaterial())
|
||||
m.position.set(x, 0, 0)
|
||||
return m
|
||||
})
|
||||
for (const s of slabs) {
|
||||
reg.attachSlot('fx.puddle', s, {
|
||||
fit: new THREE.Vector3(2, 1, 2),
|
||||
onSwap: () => { (s.material as THREE.Material).visible = false },
|
||||
})
|
||||
}
|
||||
await tick()
|
||||
const roots = slabs.map((s) => s.children[0])
|
||||
ok(roots.every((r) => r !== undefined), 'every instance got a replacement')
|
||||
ok(new Set(roots).size === 3, 'three distinct fit nodes, not one shared node')
|
||||
const mats = slabs.map((s) => collectMeshes(s)[0].material)
|
||||
ok(new Set(mats).size === 3, 'and three distinct materials, so per-instance tinting cannot bleed')
|
||||
const world = new THREE.Vector3()
|
||||
collectMeshes(slabs[2])[0].getWorldPosition(world)
|
||||
near(world.x, 10, 'each clone sits at its own instance pose')
|
||||
drainWarnings()
|
||||
}
|
||||
|
||||
console.warn = realWarn
|
||||
console.log(`harden.test.ts: ${passed} checks passed`)
|
||||
123
src/assets/idb.ts
Normal file
123
src/assets/idb.ts
Normal 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
|
||||
}
|
||||
92
src/assets/manifest.test.ts
Normal file
92
src/assets/manifest.test.ts
Normal 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
144
src/assets/manifest.ts
Normal 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
133
src/assets/registry.test.ts
Normal 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
560
src/assets/registry.ts
Normal 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
97
src/assets/slots.ts
Normal 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',
|
||||
])
|
||||
@ -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
|
||||
|
||||
@ -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),
|
||||
|
||||
@ -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
140
src/demo/lane-i.ts
Normal 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
249
src/demo/lane-j.ts
Normal 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)
|
||||
126
src/editor/demo-isolation.test.ts
Normal file
126
src/editor/demo-isolation.test.ts
Normal 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
28
src/editor/glb.ts
Normal 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)
|
||||
}
|
||||
220
src/editor/hardening.test.ts
Normal file
220
src/editor/hardening.test.ts
Normal 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 ✓`)
|
||||
88
src/editor/idb-degrade.test.ts
Normal file
88
src/editor/idb-degrade.test.ts
Normal 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
157
src/editor/idb.ts
Normal 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
497
src/editor/main.ts
Normal 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 0–1 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.'
|
||||
})
|
||||
}
|
||||
182
src/editor/manifest-io.test.ts
Normal file
182
src/editor/manifest-io.test.ts
Normal 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
164
src/editor/manifest-io.ts
Normal 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))
|
||||
}
|
||||
33
src/editor/node-ts-resolve.mjs
Normal file
33
src/editor/node-ts-resolve.mjs
Normal 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
|
||||
}
|
||||
},
|
||||
})
|
||||
82
src/editor/paintability.test.ts
Normal file
82
src/editor/paintability.test.ts
Normal 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 ✓`)
|
||||
88
src/editor/paintability.ts
Normal file
88
src/editor/paintability.ts
Normal 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 0–1.',
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
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
191
src/editor/slot-swap.ts
Normal 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
180
src/editor/slots.ts
Normal 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
342
src/editor/stage.ts
Normal 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
106
src/editor/store.ts
Normal 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
145
src/editor/tiny-glb.ts
Normal 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, 0–1-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
198
src/editor/workshop.test.ts
Normal 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
349
src/editor/workshop.ts
Normal 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
|
||||
}
|
||||
}
|
||||
}
|
||||
22
src/game.ts
22
src/game.ts
@ -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 })
|
||||
|
||||
@ -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
|
||||
}
|
||||
|
||||
|
||||
@ -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({
|
||||
|
||||
@ -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)
|
||||
|
||||
@ -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
|
||||
}
|
||||
|
||||
|
||||
@ -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. */
|
||||
|
||||
@ -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'),
|
||||
|
||||
Loading…
Reference in New Issue
Block a user