From 2823c9338223d7cae28cb3ad1704991f34064f2f Mon Sep 17 00:00:00 2001 From: type-two Date: Sun, 19 Jul 2026 12:32:23 +1000 Subject: [PATCH] Wire the Workshop: registry boot, course.tramp slot, editor in the production build MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 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 --- src/editor/manifest-io.test.ts | 20 +++++++------------- src/editor/slot-swap.ts | 14 +++++++++++++- src/editor/stage.ts | 11 +++++++++-- src/game.ts | 7 +++++++ src/main.ts | 7 +++++++ vite.config.ts | 1 + 6 files changed, 44 insertions(+), 16 deletions(-) diff --git a/src/editor/manifest-io.test.ts b/src/editor/manifest-io.test.ts index b1e30a4..4ead414 100644 --- a/src/editor/manifest-io.test.ts +++ b/src/editor/manifest-io.test.ts @@ -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(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`) } } diff --git a/src/editor/slot-swap.ts b/src/editor/slot-swap.ts index 49ed1d3..91aae21 100644 --- a/src/editor/slot-swap.ts +++ b/src/editor/slot-swap.ts @@ -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, diff --git a/src/editor/stage.ts b/src/editor/stage.ts index 46df269..a354442 100644 --- a/src/editor/stage.ts +++ b/src/editor/stage.ts @@ -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 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 = (fn: () => T): { result: T; added: THREE.Object3D[] } => { @@ -190,7 +193,11 @@ export async function createEditorStage(container: HTMLElement): Promise 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 diff --git a/src/game.ts b/src/game.ts index f5d9333..54aa01e 100644 --- a/src/game.ts +++ b/src/game.ts @@ -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 — diff --git a/src/main.ts b/src/main.ts index 43d1567..f1c32cf 100644 --- a/src/main.ts +++ b/src/main.ts @@ -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) diff --git a/vite.config.ts b/vite.config.ts index 5d55343..4a17cbd 100644 --- a/vite.config.ts +++ b/vite.config.ts @@ -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'),