From f494a6f9be5bc88a4b011909b1e4fb318000897d Mon Sep 17 00:00:00 2001 From: hypercross Date: Sun, 9 Aug 2026 22:46:03 +0800 Subject: [PATCH] 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. --- .../src/components/tabletop/TabletopScene.tsx | 49 +++++++++++++++++++ apps/web/src/pages/SetupPage.tsx | 6 +++ docs/bgm-tabletop-plan.md | 5 +- packages/tabletop/src/index.ts | 3 ++ packages/tabletop/src/package.test.ts | 19 +++++++ packages/tabletop/src/package.ts | 16 ++++++ 6 files changed, 96 insertions(+), 2 deletions(-) create mode 100644 apps/web/src/components/tabletop/TabletopScene.tsx create mode 100644 packages/tabletop/src/package.test.ts create mode 100644 packages/tabletop/src/package.ts diff --git a/apps/web/src/components/tabletop/TabletopScene.tsx b/apps/web/src/components/tabletop/TabletopScene.tsx new file mode 100644 index 0000000..5a5fac5 --- /dev/null +++ b/apps/web/src/components/tabletop/TabletopScene.tsx @@ -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 ( + + + + {tree.world.map((node) => ( + + ))} + {tree.hud.map((node) => ( + + ))} + + + ); +} \ No newline at end of file diff --git a/apps/web/src/pages/SetupPage.tsx b/apps/web/src/pages/SetupPage.tsx index 4f6f273..77fb0dc 100644 --- a/apps/web/src/pages/SetupPage.tsx +++ b/apps/web/src/pages/SetupPage.tsx @@ -1,6 +1,7 @@ import { useParams } from 'react-router-dom'; import Breadcrumbs from '../components/Breadcrumbs'; import PackageMissing from '../components/PackageMissing'; +import TabletopScene from '../components/tabletop/TabletopScene'; import { findPackage } from './bgm'; /** Detail view for a single setup within a package. */ @@ -30,7 +31,12 @@ export default function SetupPage() {

{found.type}#{found.id}

+

+ {Object.keys(found.setup).length} path + {Object.keys(found.setup).length === 1 ? '' : 's'} +

+
             {JSON.stringify(found.setup, null, 2)}
           
diff --git a/docs/bgm-tabletop-plan.md b/docs/bgm-tabletop-plan.md index b0ed9f9..07cfcc4 100644 --- a/docs/bgm-tabletop-plan.md +++ b/docs/bgm-tabletop-plan.md @@ -3,8 +3,9 @@ > **Scope:** A standalone r3f component library that renders [bgm](./bgm-format.md) > board games: a state store, surface mounting, part placement with stacking, > and per-part meshes. Design: [`bgm-tabletop.md`](./bgm-tabletop.md). -> **Status:** items 1–8 implemented; web part-inspection route renders `PartView` -> from the library. Remaining: wiring the full tabletop scene into a web route. +> **Status:** items 1–8 implemented and the full tabletop scene is wired into +> the web app's setup detail route (`/bgm/:id/setups/:type/:setup`). The +> part-inspection route renders `PartView` from the library. ## Goal diff --git a/packages/tabletop/src/index.ts b/packages/tabletop/src/index.ts index b389b8a..c65c0fc 100644 --- a/packages/tabletop/src/index.ts +++ b/packages/tabletop/src/index.ts @@ -11,6 +11,9 @@ export { } from './part.js'; export { resolveAssetUrl, assetUrl, traceImage } from './http.js'; +// Serialized package -> Package conversion. +export { serializedToPackage } from './package.js'; + // State store + derived render state. export { useTabletopStore, diff --git a/packages/tabletop/src/package.test.ts b/packages/tabletop/src/package.test.ts new file mode 100644 index 0000000..3e58628 --- /dev/null +++ b/packages/tabletop/src/package.test.ts @@ -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' }); + }); +}); \ No newline at end of file diff --git a/packages/tabletop/src/package.ts b/packages/tabletop/src/package.ts new file mode 100644 index 0000000..6889864 --- /dev/null +++ b/packages/tabletop/src/package.ts @@ -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)), + }; +} \ No newline at end of file