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:
parent
d318b4f031
commit
2823c93382
@ -40,30 +40,24 @@ function eq(a: unknown, b: unknown, msg: string): void {
|
|||||||
// such object (nothing calls createFan or createSeeSaw). The editor still
|
// such object (nothing calls createFan or createSeeSaw). The editor still
|
||||||
// LISTS these, marked unavailable, rather than pretending the game has no
|
// 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.
|
// 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
|
// The runtime has since adopted cannon.base / course.tramp / course.tunnel /
|
||||||
// builds them, but src/assets/slots.ts has not adopted the ids yet, so the
|
// course.finish, so the editor may offer every id it registers: the check below
|
||||||
// runtime would discard the manifest entry. The editor must NOT offer them
|
// is now strict in both directions. It is the guard that keeps the two halves
|
||||||
// until it does; the registrations cost nothing and light up on the day.
|
// 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 stageSource = readFileSync(new URL('./stage.ts', import.meta.url), 'utf8')
|
||||||
const registered = new Set(
|
const registered = new Set(
|
||||||
[...stageSource.matchAll(/register\('([^']+)'/g)].map((m) => m[1]!))
|
[...stageSource.matchAll(/register\('([^']+)'/g)].map((m) => m[1]!))
|
||||||
const knownIds = new Set<string>(SLOT_IDS)
|
const knownIds = new Set<string>(SLOT_IDS)
|
||||||
const NOT_IN_THIS_COURSE = new Set(['machine.fan', 'machine.seesaw'])
|
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) {
|
for (const id of SLOT_IDS) {
|
||||||
ok(registered.has(id) || NOT_IN_THIS_COURSE.has(id),
|
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`)
|
`the stage builds something for the "${id}" slot, or it is a known course gap`)
|
||||||
}
|
}
|
||||||
for (const id of registered) {
|
for (const id of registered) {
|
||||||
ok(knownIds.has(id) || NOT_YET_IN_THE_RUNTIME.has(id),
|
ok(knownIds.has(id),
|
||||||
`stage.ts registers "${id}", which the runtime knows about (or is queued for it)`)
|
`stage.ts registers "${id}", which the runtime knows about`)
|
||||||
}
|
|
||||||
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`)
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@ -77,6 +77,18 @@ export function dressAsset(object: THREE.Object3D): void {
|
|||||||
export interface RegisterOptions {
|
export interface RegisterOptions {
|
||||||
/** Per-object extra scale (puddle footprints). Index-aligned with `objects`. */
|
/** Per-object extra scale (puddle footprints). Index-aligned with `objects`. */
|
||||||
extraScale?: [number, number, number][]
|
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 {
|
export class SlotSwap {
|
||||||
@ -98,7 +110,7 @@ export class SlotSwap {
|
|||||||
): void {
|
): void {
|
||||||
const first = objects[0]
|
const first = objects[0]
|
||||||
if (!first) return
|
if (!first) return
|
||||||
const bases = objects.map(readBase)
|
const bases = objects.map((o, i) => opts.anchors?.[i] ?? readBase(o))
|
||||||
this.slots.set(id, {
|
this.slots.set(id, {
|
||||||
id,
|
id,
|
||||||
procedural: objects,
|
procedural: objects,
|
||||||
|
|||||||
@ -27,6 +27,7 @@ import { identityTransform } from './manifest-io'
|
|||||||
import type { SlotEntry } from './manifest-io'
|
import type { SlotEntry } from './manifest-io'
|
||||||
import { SlotSwap } from './slot-swap'
|
import { SlotSwap } from './slot-swap'
|
||||||
import type { SlotStageEntry } from './slot-swap'
|
import type { SlotStageEntry } from './slot-swap'
|
||||||
|
import type { BaseTransform } from './manifest-io'
|
||||||
import { SLOTS } from './slots'
|
import { SLOTS } from './slots'
|
||||||
|
|
||||||
// Some ids below (`cannon.base`, `course.tramp`, `course.tunnel`,
|
// 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 slots = swap.slots
|
||||||
const register = (
|
const register = (
|
||||||
id: string, objects: THREE.Object3D[], extraScale?: [number, number, number][],
|
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. */
|
/** Everything a builder adds straight to the scene, recovered by diffing. */
|
||||||
const capture = <T>(fn: () => T): { result: T; added: THREE.Object3D[] } => {
|
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'))
|
.map((b) => b.children.find((c) => c.name === 'pivot'))
|
||||||
.filter(Boolean) as THREE.Object3D[]
|
.filter(Boolean) as THREE.Object3D[]
|
||||||
if (bases.length) register('cannon.base', bases)
|
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 --------------------------------------------------------
|
// ---- gap launcher --------------------------------------------------------
|
||||||
// game.ts builds this inline and game.ts is frozen, so the stage mirrors its
|
// game.ts builds this inline and game.ts is frozen, so the stage mirrors its
|
||||||
|
|||||||
@ -13,6 +13,7 @@ import { createFollowCamera } from './blob/camera'
|
|||||||
import { PaintSkin, PaintCannon, BuffSystem, PaintHUD, installPaint } from './paint/index'
|
import { PaintSkin, PaintCannon, BuffSystem, PaintHUD, installPaint } from './paint/index'
|
||||||
import { createBucketDump, createBubbleArch, createConveyorBelt } from './machine/index'
|
import { createBucketDump, createBubbleArch, createConveyorBelt } from './machine/index'
|
||||||
import { installZones } from './course/zones'
|
import { installZones } from './course/zones'
|
||||||
|
import { assets } from './assets/registry'
|
||||||
import { installAudio } from './audio/index'
|
import { installAudio } from './audio/index'
|
||||||
import { installGhost } from './ghost/index'
|
import { installGhost } from './ghost/index'
|
||||||
import { installTitle } from './ui/title'
|
import { installTitle } from './ui/title'
|
||||||
@ -96,6 +97,12 @@ export function installGame(world: World) {
|
|||||||
world.scene.add(tramp)
|
world.scene.add(tramp)
|
||||||
world.physics.createCollider(
|
world.physics.createCollider(
|
||||||
world.rapier.ColliderDesc.cuboid(6, 0.25, 2).setTranslation(0, 0.25, 0))
|
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
|
// Fill BOTH under-shelf volumes solid: the front-face plinth stopped
|
||||||
// overshooters, but lane-huggers rolled in from the open x±7 sides and
|
// 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 —
|
// wedged (observed at (-4.1,-7.9)). Under-shelf space serves no gameplay —
|
||||||
|
|||||||
@ -1,5 +1,12 @@
|
|||||||
import { createWorld } from './world'
|
import { createWorld } from './world'
|
||||||
import { installGame } from './game'
|
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')!)
|
const world = await createWorld(document.getElementById('app')!)
|
||||||
installGame(world)
|
installGame(world)
|
||||||
|
|||||||
@ -8,6 +8,7 @@ export default defineConfig({
|
|||||||
rollupOptions: {
|
rollupOptions: {
|
||||||
input: {
|
input: {
|
||||||
main: resolve(__dirname, 'index.html'),
|
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-a': resolve(__dirname, 'demos/lane-a.html'),
|
||||||
'lane-b': resolve(__dirname, 'demos/lane-b.html'),
|
'lane-b': resolve(__dirname, 'demos/lane-b.html'),
|
||||||
'lane-c': resolve(__dirname, 'demos/lane-c.html'),
|
'lane-c': resolve(__dirname, 'demos/lane-c.html'),
|
||||||
|
|||||||
Loading…
Reference in New Issue
Block a user