feat(web): render bgm setups as a 3D table
Wire the tabletop library into the setup detail route: seed the store from the setup, resolve the surface mount tree, and render world and HUD surfaces. Add serializedToPackage to convert the vite-emitted package.
This commit is contained in:
@@ -0,0 +1,49 @@
|
|||||||
|
import { Suspense, useMemo } from 'react';
|
||||||
|
import type { SerializedPackage, Setup } from '@tts/bgm';
|
||||||
|
import {
|
||||||
|
SetupLoader,
|
||||||
|
WorldSurfaceView,
|
||||||
|
HudSurfaceView,
|
||||||
|
resolveMountTree,
|
||||||
|
serializedToPackage,
|
||||||
|
useTabletopStore,
|
||||||
|
} from '@tts/tabletop';
|
||||||
|
import Scene from '../viewers/Scene';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Render a bgm setup as an interactive 3D table.
|
||||||
|
*
|
||||||
|
* Seeds the tabletop store from the setup, resolves the surface mount tree,
|
||||||
|
* and renders each enabled surface — world surfaces in world space, HUD
|
||||||
|
* surfaces as an overlay — with their parts placed on routes. The store is
|
||||||
|
* seeded on every render so a setup change re-seeds it.
|
||||||
|
*/
|
||||||
|
export default function TabletopScene({
|
||||||
|
pkg,
|
||||||
|
setup,
|
||||||
|
}: {
|
||||||
|
pkg: SerializedPackage;
|
||||||
|
setup: Setup;
|
||||||
|
}) {
|
||||||
|
const packageData = useMemo(() => serializedToPackage(pkg), [pkg]);
|
||||||
|
const surfaces = useTabletopStore((s) => s.surfaces);
|
||||||
|
|
||||||
|
const tree = useMemo(
|
||||||
|
() => resolveMountTree(packageData.surfaces, new Set(Object.keys(surfaces))),
|
||||||
|
[packageData, surfaces],
|
||||||
|
);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Scene>
|
||||||
|
<SetupLoader pkg={packageData} setup={setup} />
|
||||||
|
<Suspense fallback={null}>
|
||||||
|
{tree.world.map((node) => (
|
||||||
|
<WorldSurfaceView key={node.id} pkg={packageData} node={node} />
|
||||||
|
))}
|
||||||
|
{tree.hud.map((node) => (
|
||||||
|
<HudSurfaceView key={node.id} pkg={packageData} node={node} />
|
||||||
|
))}
|
||||||
|
</Suspense>
|
||||||
|
</Scene>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -1,6 +1,7 @@
|
|||||||
import { useParams } from 'react-router-dom';
|
import { useParams } from 'react-router-dom';
|
||||||
import Breadcrumbs from '../components/Breadcrumbs';
|
import Breadcrumbs from '../components/Breadcrumbs';
|
||||||
import PackageMissing from '../components/PackageMissing';
|
import PackageMissing from '../components/PackageMissing';
|
||||||
|
import TabletopScene from '../components/tabletop/TabletopScene';
|
||||||
import { findPackage } from './bgm';
|
import { findPackage } from './bgm';
|
||||||
|
|
||||||
/** Detail view for a single setup within a package. */
|
/** Detail view for a single setup within a package. */
|
||||||
@@ -30,7 +31,12 @@ export default function SetupPage() {
|
|||||||
<h1 className="text-2xl font-semibold">
|
<h1 className="text-2xl font-semibold">
|
||||||
{found.type}#{found.id}
|
{found.type}#{found.id}
|
||||||
</h1>
|
</h1>
|
||||||
|
<p className="mt-1 text-sm text-zinc-400">
|
||||||
|
{Object.keys(found.setup).length} path
|
||||||
|
{Object.keys(found.setup).length === 1 ? '' : 's'}
|
||||||
|
</p>
|
||||||
</div>
|
</div>
|
||||||
|
<TabletopScene pkg={pkg} setup={found} />
|
||||||
<pre className="overflow-x-auto rounded-lg border border-zinc-800 bg-zinc-900 p-3 text-xs text-zinc-400">
|
<pre className="overflow-x-auto rounded-lg border border-zinc-800 bg-zinc-900 p-3 text-xs text-zinc-400">
|
||||||
{JSON.stringify(found.setup, null, 2)}
|
{JSON.stringify(found.setup, null, 2)}
|
||||||
</pre>
|
</pre>
|
||||||
|
|||||||
@@ -3,8 +3,9 @@
|
|||||||
> **Scope:** A standalone r3f component library that renders [bgm](./bgm-format.md)
|
> **Scope:** A standalone r3f component library that renders [bgm](./bgm-format.md)
|
||||||
> board games: a state store, surface mounting, part placement with stacking,
|
> board games: a state store, surface mounting, part placement with stacking,
|
||||||
> and per-part meshes. Design: [`bgm-tabletop.md`](./bgm-tabletop.md).
|
> and per-part meshes. Design: [`bgm-tabletop.md`](./bgm-tabletop.md).
|
||||||
> **Status:** items 1–8 implemented; web part-inspection route renders `PartView`
|
> **Status:** items 1–8 implemented and the full tabletop scene is wired into
|
||||||
> from the library. Remaining: wiring the full tabletop scene into a web route.
|
> the web app's setup detail route (`/bgm/:id/setups/:type/:setup`). The
|
||||||
|
> part-inspection route renders `PartView` from the library.
|
||||||
|
|
||||||
## Goal
|
## Goal
|
||||||
|
|
||||||
|
|||||||
@@ -11,6 +11,9 @@ export {
|
|||||||
} from './part.js';
|
} from './part.js';
|
||||||
export { resolveAssetUrl, assetUrl, traceImage } from './http.js';
|
export { resolveAssetUrl, assetUrl, traceImage } from './http.js';
|
||||||
|
|
||||||
|
// Serialized package -> Package conversion.
|
||||||
|
export { serializedToPackage } from './package.js';
|
||||||
|
|
||||||
// State store + derived render state.
|
// State store + derived render state.
|
||||||
export {
|
export {
|
||||||
useTabletopStore,
|
useTabletopStore,
|
||||||
|
|||||||
@@ -0,0 +1,19 @@
|
|||||||
|
import { describe, expect, it } from 'vitest';
|
||||||
|
import { serializedToPackage } from './package.js';
|
||||||
|
|
||||||
|
describe('serializedToPackage', () => {
|
||||||
|
it('converts plain-object maps to Map instances', () => {
|
||||||
|
const serialized = {
|
||||||
|
meta: { id: 'harbor' },
|
||||||
|
parts: { 'token#wood': { type: 'token', id: 'wood' } },
|
||||||
|
surfaces: { 'board#harbor': { type: 'board', id: 'harbor', layout: [] } },
|
||||||
|
setups: { 'game#main': { type: 'game', id: 'main', setup: {} } },
|
||||||
|
};
|
||||||
|
const pkg = serializedToPackage(serialized);
|
||||||
|
expect(pkg.meta.id).toBe('harbor');
|
||||||
|
expect(pkg.parts).toBeInstanceOf(Map);
|
||||||
|
expect(pkg.parts.get('token#wood')).toEqual({ type: 'token', id: 'wood' });
|
||||||
|
expect(pkg.surfaces.get('board#harbor')).toMatchObject({ type: 'board' });
|
||||||
|
expect(pkg.setups.get('game#main')).toMatchObject({ type: 'game' });
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -0,0 +1,16 @@
|
|||||||
|
/**
|
||||||
|
* Convert a serialized package (plain objects, as emitted by the bgm vite
|
||||||
|
* plugin) into a `Package` (Maps). Consumers that load a package from
|
||||||
|
* `virtual:bgm/*` get the serialized form; the tabletop components take the
|
||||||
|
* `Package` form.
|
||||||
|
*/
|
||||||
|
import type { Package, SerializedPackage } from '@tts/bgm';
|
||||||
|
|
||||||
|
export function serializedToPackage(serialized: SerializedPackage): Package {
|
||||||
|
return {
|
||||||
|
meta: serialized.meta,
|
||||||
|
parts: new Map(Object.entries(serialized.parts)),
|
||||||
|
surfaces: new Map(Object.entries(serialized.surfaces)),
|
||||||
|
setups: new Map(Object.entries(serialized.setups)),
|
||||||
|
};
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user