Wire the Workshop: registry boot, course.tramp slot, editor in the production build

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

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

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
type-two 2026-07-19 12:32:23 +10:00
parent d318b4f031
commit 2823c93382
6 changed files with 44 additions and 16 deletions

View File

@ -40,30 +40,24 @@ function eq(a: unknown, b: unknown, msg: string): void {
// 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.
// NOT_YET_IN_THE_RUNTIME — the plan's table has these and the stage already
// builds them, but src/assets/slots.ts has not adopted the ids yet, so the
// runtime would discard the manifest entry. The editor must NOT offer them
// until it does; the registrations cost nothing and light up on the day.
// 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'])
const NOT_YET_IN_THE_RUNTIME = new Set([
'cannon.base', 'course.tramp', 'course.tunnel', 'course.finish',
])
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) || NOT_YET_IN_THE_RUNTIME.has(id),
`stage.ts registers "${id}", which the runtime knows about (or is queued for it)`)
}
for (const id of NOT_YET_IN_THE_RUNTIME) {
ok(!knownIds.has(id),
`"${id}" is still absent from the runtime — move it out of this list once added`)
ok(knownIds.has(id),
`stage.ts registers "${id}", which the runtime knows about`)
}
}

View File

@ -77,6 +77,18 @@ export function dressAsset(object: THREE.Object3D): void {
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 {
@ -98,7 +110,7 @@ export class SlotSwap {
): void {
const first = objects[0]
if (!first) return
const bases = objects.map(readBase)
const bases = objects.map((o, i) => opts.anchors?.[i] ?? readBase(o))
this.slots.set(id, {
id,
procedural: objects,

View File

@ -27,6 +27,7 @@ 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`,
@ -67,7 +68,9 @@ export async function createEditorStage(container: HTMLElement): Promise<EditorS
const slots = swap.slots
const register = (
id: string, objects: THREE.Object3D[], extraScale?: [number, number, number][],
): void => swap.register(id, objects, world.scene, extraScale ? { extraScale } : {})
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[] } => {
@ -190,7 +193,11 @@ export async function createEditorStage(container: HTMLElement): Promise<EditorS
.map((b) => b.children.find((c) => c.name === 'pivot'))
.filter(Boolean) as THREE.Object3D[]
if (bases.length) register('cannon.base', bases)
if (pivots.length) register('cannon.barrel', pivots)
// 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

View File

@ -13,6 +13,7 @@ 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 { installAudio } from './audio/index'
import { installGhost } from './ghost/index'
import { installTitle } from './ui/title'
@ -96,6 +97,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 —

View File

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

View File

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