Add the interaction half of the sandbox: a held part and dialog stack as UI state outside the game store, with pure helpers (interactionsFor, dropPaths, pickPath, partFacings, nextFacing). PartPlacement is now interactive (pick up, drag to move, click to cycle facing), and a drop on a path with a stack-dialog interaction opens the deck dialog instead of moving directly. DialogLayer renders the stack-inspector dialog whose insert button issues a move. Add the dialog role, Setup.interactions, and Package.dialogs to @tts/bgm, and declare the deck insert dialog in games/poker.
9.5 KiB
bgm-tabletop — Implementation Plan / Status
Scope: A standalone r3f component library that renders bgm board games: a state store, surface mounting, part placement with stacking, and per-part meshes. Design:
../bgm/tabletop.md. 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 rendersPartViewfrom the library.
Goal
A library (new packages/tabletop) that takes a bgm package and renders it as
an interactive 3D table: enabled surfaces mounted in world/HUD space, parts
placed on their routes, stacked per the format's stacking strategy. The web
app's bgm inspector routes are one consumer; the library must not depend on the
web app.
Stack
react, react-router (types only), tailwind (styles only), r3f
(@react-three/fiber), drei, postprocessing, zustand, three,
@tts/bgm (types), @tts/mesh (geometry).
Package layout
packages/tabletop/
package.json # @tts/tabletop
tsconfig.json
vitest.config.ts
src/
index.ts # public exports
state.ts # zustand store + derived render state
setup.ts # SetupLoader: seed state from a setup
mount.ts # resolve surface mount tree (table/hud/child)
stacking.ts # useStacking hook
placement.ts # PartPlacement
partView.tsx # PartView: mesh from a part definition
surfaces/
WorldSurfaceView.tsx
HudSurfaceView.tsx
*.test.ts # colocated unit tests
Work items
1. Package scaffold ✅
- New
packages/tabletopworkspace package (pnpm-workspace.yamlalready globspackages/*). - Deps:
@tts/bgm,@tts/mesh,three,@react-three/fiber,@react-three/drei,@react-three/postprocessing,zustand. Dev:vitest,typescript,@types/three. tsconfig.jsonmirroringpackages/bgm's (strict, ESM,distoutput).
2. Part meshes + export + web integration ✅
First deliverable: PartView renders a single part's mesh from its definition,
reusing @tts/mesh geometry (not the web app's viewers). This is the smallest
useful slice and unblocks the web app's part inspection route immediately.
PartView(partView.tsx): creates a mesh from aPartdefinition:size→ world dimensions;fillet→ corner radius.face/faceCrop/back/backCrop→ textures (dreiuseTexture), sprite UVs fromfaceCrop/backCrop(a[col,row,cols,rows]grid cell).shape→ traced silhouette (via the proxy/trace, like the web token viewer) or a fallback rect/rounded-rect.extrudeShapePartsfrom@tts/meshfor the mesh.
- Shared geometry/material caching (module-level
Maps) so repeated parts reuse buffers, mirroring the web viewers'sharedResources. - Export
PartViewfromindex.ts. - Web integration: replace the web app's part inspection route
(
/bgm/:id/parts/:type/:part) to renderPartViewfrom the library, proving it end-to-end.
3. State store (state.ts) ✅
Source-of-truth game state per ../bgm/tabletop.md §2:
interface GameState {
surfaces: Record<string, boolean>; // enabled per surface id
parts: Record<string, PartState>; // part id -> placement state
}
interface PartState {
path: string; // the path key this part is on
index: number; // the part's position in its path's stack
facing: 'face' | 'back' | 'standing'; // how the part is oriented on the board
}
- A zustand store holding
GameState. - Derived render state:
game state + surface routes => map of piece id to{ surface, route, candidate, index, stackSize, face }``, per enabled surface. Computed with a selector/memo so the render list is stable. A path's ordered children (for stacking) are derived from the parts map by sorting onindex. - Assumption: each piece id is unique on the board (documented in
../bgm/tabletop.md); the render map is keyed by piece id.
4. Setup seeding (setup.ts) ✅
SetupLoader: side-effect-only component that seeds the store from aSetup— enables itssurfaces(or all when omitted) and applies its orderedsetupplacements (each moves itspartsto apath).setupvalue expansion: a baretype(no id) expands to all parts of that type (documented in../bgm/format.md§3; the loader doesn't do this — it's a game-state init concern, so it lives here).
5. Surface mounting (mount.ts) ✅
- Resolve the surface mount tree from
Surface.mount+Surface.children:kind: table— root, world space.kind: hud— HUD area (mount.area).kind: child— mounted relative to a parent that lists it inchildren.
WorldSurfaceView/HudSurfaceViewmount an enabled surface; a disabled surface isn't rendered. Child surfaces mount relative to their parent's anchor (x/y/rotation).
6. Part placement (placement.ts) ✅
PartPlacement: stable per-part component that positions a part on a surface location from the derived render state (route anchor + candidate anchor).- Applies the route's stacking strategy via
useStacking.
7. Stacking (stacking.ts) ✅
useStacking(route.stacking, index, stackSize)→{ x, y, rotation, z, tilt }.- Implements the format's positioning process (
../bgm/format.md§4): step length from curve length /max(steps, count-1), alignment (start/end/center), andlimit(0all,nfirst n,-nlast n). zramps linearly fromzStarttozEndacross the curve's span;tiltrotates each shown part about its local Y (long) axis.- Curve length from an SVG path string (small helper; no new dep).
8. Public API (index.ts) ✅
Export SetupLoader, WorldSurfaceView, HudSurfaceView, PartPlacement,
PartView, useStacking, and the store hooks. The web app consumes these; the
library never imports from apps/*.
Reuse from @tts/mesh
extrudeShapeParts/extrudeShape— front/back/walls geometry.rectShape,roundedRectShape,circleShape,polygonShape,hexShape,frameShape,scaleShape— shape generators for parts without ashapesprite.shapeFromThree— author shapes with the three.js path API.ExtrudedGeometry/UVBounds— raw typed arrays + UV framing.
The web viewers (TokenViewer/CardViewer) contain logic we'll mirror rather
than import: trace-to-shape conversion, sprite UV math, texture flipping. These
are candidates to lift into @tts/mesh or @tts/tabletop later so both
consumers share them (see Open decisions).
Testing
state.ts— derived render state: enabled surfaces, route matching, candidate selection, stacking index/stackSize.stacking.ts— positioning process: step length, alignment, limit, z ramp, tilt.setup.ts— seeding + bare-type expansion.mount.ts— mount tree resolution (table/hud/child, children refs).partView.tsx— geometry from a part def (size/fillet/crop), sprite UVs.- A real
vite buildintegration test (mirroringpackages/bgm/src/vite.test.ts) proving the library bundles against a fixture package.
Validation
pnpm --filter @tts/tabletop build/typecheck/test.- Root
pnpm teststays green. pnpm --filter @tts/web build— the part inspection route rendersPartViewfrom the library (work item 2), proving it end-to-end.
Free interaction (layer 3) ✅
Sandbox interaction is built per ../bgm/interactions.md:
interactions.ts— the held part + dialog stack (UI state, outside the game store), plus pure helpers (interactionsFor,dropPaths,pickPath,partFacings,nextFacing).state.ts—setFacingalongsidemovePart(themove(id, path, index?)primitive, defaulting to top of stack).placement.tsx—PartPlacementis now interactive: pick up a part (held, lifted above the board), drag to a path anchor tomove, click to cycle facing. A drop on a path with a stack-dialog interaction opens the deck dialog instead of moving directly.dialog.tsx—DialogLayer, the stack-inspector dialog: an alternate view of a stack with an insertion cursor; its insert button issues amove.@tts/bgm— thedialogrole,Setup.interactions, andPackage.dialogs(schema + collection + serialization).- Demo:
games/pokerdeclares aninteractions:+role: dialog(stack insert) on the deck.
The rule seam (layer 4) is a no-op filter: sandbox applies intents directly.
Commands (not yet implemented)
Scripted interaction is designed in ../bgm/commands.md:
async commands with ok/cancel/error results, per-invocation run
contexts, fire-and-forget vs self-managed waiting, and tap interaction with
part-local trigger points. Implementation order: types + run-context manager,
tap detection, then the first commands (wait: tap, focus).
Open decisions (defaults in bold)
- Where the trace/sprite helpers live — lift into
@tts/mesh(shared by web viewers + tabletop) vs duplicate in@tts/tabletop. Lifting is cleaner but touches the web viewers; decide whenPartView(work item 2) needs them. - HUD rendering — drei
Html/orthographic overlay vs a secondCanvas. Default to an overlay so world + HUD share one scene. - Curve length — small internal SVG-path length helper vs a dependency
(e.g.
svg-path-properties). Prefer the helper to avoid a dep.