From cd08e6af041acbd76eb6f77b3fbd9127d46383f9 Mon Sep 17 00:00:00 2001 From: hypercross Date: Sun, 9 Aug 2026 22:34:18 +0800 Subject: [PATCH] feat(tabletop): add state, setup, mounting, and stacking Implement the tabletop library's game state store, setup seeding with bare-type expansion, surface mount tree resolution, part placement, and the stacking positioning process with a dependency-free SVG path helper. Wire the public API and add unit plus vite integration tests. --- docs/bgm-tabletop-plan.md | 19 +- packages/tabletop/package.json | 2 + .../vite-build/games/harbor/harbor.md | 41 ++ .../src/__fixtures__/vite-build/index.html | 10 + .../src/__fixtures__/vite-build/src/main.ts | 34 ++ packages/tabletop/src/index.ts | 29 +- packages/tabletop/src/mount.test.ts | 54 +++ packages/tabletop/src/mount.ts | 94 +++++ packages/tabletop/src/placement.tsx | 30 ++ packages/tabletop/src/setup.test.ts | 55 +++ packages/tabletop/src/setup.ts | 68 ++++ packages/tabletop/src/stacking.test.ts | 88 +++++ packages/tabletop/src/stacking.ts | 370 ++++++++++++++++++ packages/tabletop/src/state.test.ts | 126 ++++++ packages/tabletop/src/state.ts | 133 +++++++ .../tabletop/src/surfaces/HudSurfaceView.tsx | 34 ++ .../src/surfaces/WorldSurfaceView.tsx | 33 ++ packages/tabletop/src/vite.test.ts | 64 +++ packages/tabletop/tsconfig.json | 2 +- pnpm-lock.yaml | 6 + 20 files changed, 1281 insertions(+), 11 deletions(-) create mode 100644 packages/tabletop/src/__fixtures__/vite-build/games/harbor/harbor.md create mode 100644 packages/tabletop/src/__fixtures__/vite-build/index.html create mode 100644 packages/tabletop/src/__fixtures__/vite-build/src/main.ts create mode 100644 packages/tabletop/src/mount.test.ts create mode 100644 packages/tabletop/src/mount.ts create mode 100644 packages/tabletop/src/placement.tsx create mode 100644 packages/tabletop/src/setup.test.ts create mode 100644 packages/tabletop/src/setup.ts create mode 100644 packages/tabletop/src/stacking.test.ts create mode 100644 packages/tabletop/src/stacking.ts create mode 100644 packages/tabletop/src/state.test.ts create mode 100644 packages/tabletop/src/state.ts create mode 100644 packages/tabletop/src/surfaces/HudSurfaceView.tsx create mode 100644 packages/tabletop/src/surfaces/WorldSurfaceView.tsx create mode 100644 packages/tabletop/src/vite.test.ts diff --git a/docs/bgm-tabletop-plan.md b/docs/bgm-tabletop-plan.md index ff761b6..b0ed9f9 100644 --- a/docs/bgm-tabletop-plan.md +++ b/docs/bgm-tabletop-plan.md @@ -3,7 +3,8 @@ > **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:** planning — no code yet. +> **Status:** items 1–8 implemented; web part-inspection route renders `PartView` +> from the library. Remaining: wiring the full tabletop scene into a web route. ## Goal @@ -42,7 +43,7 @@ packages/tabletop/ ## Work items -### 1. Package scaffold +### 1. Package scaffold ✅ - New `packages/tabletop` workspace package (`pnpm-workspace.yaml` already globs `packages/*`). @@ -51,7 +52,7 @@ packages/tabletop/ `@types/three`. - `tsconfig.json` mirroring `packages/bgm`'s (strict, ESM, `dist` output). -### 2. Part meshes + export + web integration +### 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 @@ -71,7 +72,7 @@ useful slice and unblocks the web app's part inspection route immediately. (`/bgm/:id/parts/:type/:part`) to render `PartView` from the library, proving it end-to-end. -### 3. State store (`state.ts`) +### 3. State store (`state.ts`) ✅ Source-of-truth game state per `bgm-tabletop.md` §2: @@ -89,7 +90,7 @@ interface GameState { - **Assumption**: each piece id is unique within a path (documented in `bgm-tabletop.md`); the render map is keyed by piece id. -### 4. Setup seeding (`setup.ts`) +### 4. Setup seeding (`setup.ts`) ✅ - `SetupLoader`: side-effect-only component that seeds the store from a `Setup` — enables its `surfaces` (or all when omitted) and places parts on @@ -98,7 +99,7 @@ interface GameState { 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`) +### 5. Surface mounting (`mount.ts`) ✅ - Resolve the surface mount tree from `Surface.mount` + `Surface.children`: - `kind: table` — root, world space. @@ -108,13 +109,13 @@ interface GameState { surface isn't rendered. Child surfaces mount relative to their parent's anchor (`x`/`y`/`rotation`). -### 6. Part placement (`placement.ts`) +### 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`) +### 7. Stacking (`stacking.ts`) ✅ - `useStacking(route.stacking, index, stackSize)` → `{ offset, rotation }`. - Implements the format's positioning process (`bgm-format.md` §4): step @@ -122,7 +123,7 @@ interface GameState { `center`), and `limit` (`0` all, `n` first n, `-n` last n). - Curve length from an SVG path string (small helper; no new dep). -### 8. Public API (`index.ts`) +### 8. Public API (`index.ts`) ✅ Export `SetupLoader`, `WorldSurfaceView`, `HudSurfaceView`, `PartPlacement`, `PartView`, `useStacking`, and the store hooks. The web app consumes these; the diff --git a/packages/tabletop/package.json b/packages/tabletop/package.json index a538e14..04f739c 100644 --- a/packages/tabletop/package.json +++ b/packages/tabletop/package.json @@ -29,9 +29,11 @@ "zustand": "^5.0.14" }, "devDependencies": { + "@types/node": "^22.12.0", "@types/react": "^19.2.18", "@types/three": "^0.185.4", "typescript": "^5.7.2", + "vite": "^8.2.1", "vitest": "^4.1.10" } } \ No newline at end of file diff --git a/packages/tabletop/src/__fixtures__/vite-build/games/harbor/harbor.md b/packages/tabletop/src/__fixtures__/vite-build/games/harbor/harbor.md new file mode 100644 index 0000000..65a38a3 --- /dev/null +++ b/packages/tabletop/src/__fixtures__/vite-build/games/harbor/harbor.md @@ -0,0 +1,41 @@ +# Harbor + +A tiny example game used to exercise the tabletop library end-to-end through a +real vite build. + +```yaml file=harbor.yaml +role: package +id: harbor +title: Harbor +include: ['**/harbor/**/*.yaml'] +``` + +```yaml file=parts/tokens.yaml +role: part +type: token +id: wood +size: [20, 20, 3] +``` + +```yaml file=parts/board.yaml +type: board +id: harbor +role: surface +size: [300, 200] +layout: + - route: /deck + x: -100 + y: 0 + rotation: 0 + stacking: + curve: M 0 0 L 100 0 + align: center +``` + +```yaml file=setup/main.yaml +role: setup +type: game +id: main +setup: + /deck: harbor:token +``` \ No newline at end of file diff --git a/packages/tabletop/src/__fixtures__/vite-build/index.html b/packages/tabletop/src/__fixtures__/vite-build/index.html new file mode 100644 index 0000000..1cff2ce --- /dev/null +++ b/packages/tabletop/src/__fixtures__/vite-build/index.html @@ -0,0 +1,10 @@ + + + + + tabletop build fixture + + + + + \ No newline at end of file diff --git a/packages/tabletop/src/__fixtures__/vite-build/src/main.ts b/packages/tabletop/src/__fixtures__/vite-build/src/main.ts new file mode 100644 index 0000000..54c6709 --- /dev/null +++ b/packages/tabletop/src/__fixtures__/vite-build/src/main.ts @@ -0,0 +1,34 @@ +import harbor from 'virtual:bgm/package/harbor'; +import type { SerializedPackage } from '@tts/bgm'; +import { seedFromSetup, expandSetupValue } from '@tts/tabletop'; +import { computeRenderState, matchRoute } from '@tts/tabletop'; +import { stackingOffset } from '@tts/tabletop'; +import { resolveMountTree } from '@tts/tabletop'; + +// Re-export the results so the test can assert the bundled output. +const pkg = harbor as unknown as SerializedPackage; + +// The setup seeds the store; a bare type expands to all parts of that type. +const setup = pkg.setups['game#main']!; +const seeded = seedFromSetup(pkg as never, setup); + +// A part with a matching route is placed. +const placements = computeRenderState(pkg as never, seeded); + +// Stacking spreads parts along a curve. +const offset = stackingOffset({ curve: 'M 0 0 L 100 0', align: 'center' }, 1, 3); + +// Mount resolution separates world and hud surfaces. +const tree = resolveMountTree( + new Map(Object.entries(pkg.surfaces)), + new Set(Object.keys(seeded.surfaces)), +); + +export const seededPaths = Object.keys(seeded.paths); +export const expanded = expandSetupValue(pkg as never, 'harbor:token'); +export const placementCount = placements.length; +export const matched = matchRoute({ route: '/dock/:seat', x: 0, y: 0, rotation: 0 }, '/dock/0'); +export const offsetX = offset.x; +export const worldCount = tree.world.length; + +console.log(seededPaths, expanded, placementCount, matched, offsetX, worldCount); \ No newline at end of file diff --git a/packages/tabletop/src/index.ts b/packages/tabletop/src/index.ts index dee3a27..b389b8a 100644 --- a/packages/tabletop/src/index.ts +++ b/packages/tabletop/src/index.ts @@ -9,4 +9,31 @@ export { traceToShape, traceToUvBounds, } from './part.js'; -export { resolveAssetUrl, assetUrl, traceImage } from './http.js'; \ No newline at end of file +export { resolveAssetUrl, assetUrl, traceImage } from './http.js'; + +// State store + derived render state. +export { + useTabletopStore, + useRenderState, + matchRoute, + computeRenderState, + computeSurfacePlacements, + placementKey, +} from './state.js'; +export type { GameState, Placement } from './state.js'; + +// Setup seeding. +export { SetupLoader, seedFromSetup, expandSetupValue } from './setup.js'; + +// Surface mounting. +export { resolveMountTree } from './mount.js'; +export type { MountNode, MountTree } from './mount.js'; +export { WorldSurfaceView, SurfaceNode } from './surfaces/WorldSurfaceView.js'; +export { HudSurfaceView } from './surfaces/HudSurfaceView.js'; + +// Part placement. +export { PartPlacement } from './placement.js'; + +// Stacking. +export { useStacking, stackingOffset, parsePath, pointAt, NO_OFFSET } from './stacking.js'; +export type { StackOffset } from './stacking.js'; \ No newline at end of file diff --git a/packages/tabletop/src/mount.test.ts b/packages/tabletop/src/mount.test.ts new file mode 100644 index 0000000..f64e3dd --- /dev/null +++ b/packages/tabletop/src/mount.test.ts @@ -0,0 +1,54 @@ +import { describe, expect, it } from 'vitest'; +import type { Surface } from '@tts/bgm'; +import { resolveMountTree } from './mount.js'; + +function surface(type: string, id: string, overrides: Partial = {}): [string, Surface] { + return [`${type}#${id}`, { type, id, layout: [], ...overrides }]; +} + +describe('resolveMountTree', () => { + it('separates world and hud roots', () => { + const surfaces = new Map([ + surface('board', 'harbor', { mount: { kind: 'table' } }), + surface('hud', 'hand', { mount: { kind: 'hud', area: 'bottom-left' } }), + ]); + const tree = resolveMountTree(surfaces, new Set(['board#harbor', 'hud#hand'])); + expect(tree.world).toHaveLength(1); + expect(tree.hud).toHaveLength(1); + expect(tree.hud[0]!.area).toBe('bottom-left'); + }); + + it('attaches child surfaces to their parent', () => { + const surfaces = new Map([ + surface('board', 'harbor', { + mount: { kind: 'table' }, + children: ['board#player'], + }), + surface('board', 'player', { mount: { kind: 'child', x: 100, y: 50 } }), + ]); + const tree = resolveMountTree(surfaces, new Set(['board#harbor', 'board#player'])); + expect(tree.world).toHaveLength(1); + expect(tree.world[0]!.children).toHaveLength(1); + expect(tree.world[0]!.children[0]!.id).toBe('board#player'); + expect(tree.world[0]!.children[0]!.x).toBe(100); + }); + + it('drops a child with no matching parent', () => { + const surfaces = new Map([ + surface('board', 'player', { mount: { kind: 'child' } }), + ]); + const tree = resolveMountTree(surfaces, new Set(['board#player'])); + expect(tree.world).toHaveLength(0); + expect(tree.hud).toHaveLength(0); + }); + + it('excludes disabled surfaces', () => { + const surfaces = new Map([ + surface('board', 'harbor', { mount: { kind: 'table' } }), + surface('board', 'other', { mount: { kind: 'table' } }), + ]); + const tree = resolveMountTree(surfaces, new Set(['board#harbor'])); + expect(tree.world).toHaveLength(1); + expect(tree.world[0]!.id).toBe('board#harbor'); + }); +}); \ No newline at end of file diff --git a/packages/tabletop/src/mount.ts b/packages/tabletop/src/mount.ts new file mode 100644 index 0000000..1d5cc4d --- /dev/null +++ b/packages/tabletop/src/mount.ts @@ -0,0 +1,94 @@ +/** + * Surface mounting — resolve the surface mount tree from `Surface.mount` + + * `Surface.children` (`bgm-tabletop.md` §5). + * + * - `kind: table` — root, world space. + * - `kind: hud` — HUD area (`mount.area`). + * - `kind: child` — mounted relative to a parent surface that lists it in + * `children`. + * + * A child surface's own `mount.x`/`y`/`rotation` is relative to its parent's + * anchor. The tree is built from the enabled surfaces only. + */ +import type { Surface } from '@tts/bgm'; + +/** A node in the resolved mount tree. */ +export interface MountNode { + /** The surface. */ + surface: Surface; + /** The surface id (`type#id`). */ + id: string; + /** The mount kind. */ + kind: 'table' | 'hud' | 'child'; + /** Anchor x/y/rotation (world for table, relative for child, HUD area for hud). */ + x: number; + y: number; + rotation: number; + /** HUD area, when `kind: hud`. */ + area?: string; + /** Child surfaces mounted relative to this one. */ + children: MountNode[]; +} + +/** The resolved mount tree: world roots, HUD roots, and their children. */ +export interface MountTree { + /** Surfaces mounted in world space (`kind: table`). */ + world: MountNode[]; + /** Surfaces mounted to a HUD area (`kind: hud`). */ + hud: MountNode[]; +} + +/** + * Resolve the mount tree for the given surfaces. A `child` surface is attached + * to the first parent that lists it in `children`; a child with no matching + * parent is dropped. Surfaces not in `enabled` are excluded. + */ +export function resolveMountTree( + surfaces: Map, + enabled: Set, +): MountTree { + const nodes = new Map(); + for (const [id, surface] of surfaces) { + if (!enabled.has(id)) continue; + const mount = surface.mount ?? { kind: 'table' }; + nodes.set(id, { + surface, + id, + kind: mount.kind, + x: mount.x ?? 0, + y: mount.y ?? 0, + rotation: mount.rotation ?? 0, + area: mount.area, + children: [], + }); + } + + const world: MountNode[] = []; + const hud: MountNode[] = []; + const attached = new Set(); + + // Attach children to their parents first. + for (const node of nodes.values()) { + if (node.kind !== 'child') continue; + const parent = findParent(nodes, node.id); + if (!parent) continue; + parent.children.push(node); + attached.add(node.id); + } + + // Roots are world/hud surfaces; unattached children are dropped. + for (const node of nodes.values()) { + if (node.kind === 'table') world.push(node); + else if (node.kind === 'hud') hud.push(node); + // A child that wasn't attached to a parent is not rendered. + } + return { world, hud }; +} + +/** Find the parent surface that lists `childId` in its `children`. */ +function findParent(nodes: Map, childId: string): MountNode | undefined { + for (const node of nodes.values()) { + if (node.surface.children?.includes(childId)) return node; + } + return undefined; +} \ No newline at end of file diff --git a/packages/tabletop/src/placement.tsx b/packages/tabletop/src/placement.tsx new file mode 100644 index 0000000..18e0742 --- /dev/null +++ b/packages/tabletop/src/placement.tsx @@ -0,0 +1,30 @@ +/** + * `PartPlacement` — position a part on a surface location. + * + * A stable per-part component that places a part at its route's anchor (plus + * the candidate's anchor when there is one) and applies the route's stacking + * strategy via `useStacking`. Renders the part's mesh with `PartView`. + */ +import type { Package } from '@tts/bgm'; +import { useStacking } from './stacking.js'; +import type { Placement } from './state.js'; +import { PartView } from './partView.js'; + +export function PartPlacement({ pkg, placement }: { pkg: Package; placement: Placement }) { + const { route, candidate, piece, index, stackSize } = placement; + // `piece` is `package:type#id`; the parts map is keyed by `type#id`. + const part = pkg.parts.get(piece.split(':').slice(1).join(':')); + if (!part) return null; + + const { x, y, rotation } = useStacking(route.stacking, index, stackSize); + + const anchorX = (candidate?.x ?? route.x ?? 0) + x; + const anchorY = (candidate?.y ?? route.y ?? 0) + y; + const anchorRotation = (candidate?.rotation ?? route.rotation ?? 0) + rotation; + + return ( + + + + ); +} \ No newline at end of file diff --git a/packages/tabletop/src/setup.test.ts b/packages/tabletop/src/setup.test.ts new file mode 100644 index 0000000..e4777e8 --- /dev/null +++ b/packages/tabletop/src/setup.test.ts @@ -0,0 +1,55 @@ +import { describe, expect, it } from 'vitest'; +import type { Package } from '@tts/bgm'; +import { expandSetupValue, seedFromSetup } from './setup.js'; + +const pkg: Package = { + meta: { id: 'harbor' }, + parts: new Map([ + ['token#wood', { type: 'token', id: 'wood' }], + ['token#grain', { type: 'token', id: 'grain' }], + ['card#fleet', { type: 'card', id: 'fleet' }], + ]), + surfaces: new Map([ + ['board#harbor', { type: 'board', id: 'harbor', layout: [] }], + ['hud#hand', { type: 'hud', id: 'hand', layout: [] }], + ]), + setups: new Map(), +}; + +describe('expandSetupValue', () => { + it('keeps a full part id', () => { + expect(expandSetupValue(pkg, 'harbor:card#fleet')).toEqual(['harbor:card#fleet']); + }); + + it('expands a bare type to all parts of that type', () => { + expect(expandSetupValue(pkg, 'harbor:token')).toEqual(['harbor:token#wood', 'harbor:token#grain']); + }); + + it('expands each entry of a list', () => { + expect(expandSetupValue(pkg, ['harbor:card#fleet', 'harbor:token'])).toEqual([ + 'harbor:card#fleet', + 'harbor:token#wood', + 'harbor:token#grain', + ]); + }); +}); + +describe('seedFromSetup', () => { + it('enables listed surfaces and places parts', () => { + const setup = { + type: 'game', + id: 'main', + surfaces: ['board#harbor'], + setup: { '/deck': 'harbor:card#fleet' }, + }; + const state = seedFromSetup(pkg, setup); + expect(state.surfaces).toEqual({ 'board#harbor': true }); + expect(state.paths).toEqual({ '/deck': ['harbor:card#fleet'] }); + }); + + it('enables all surfaces when omitted', () => { + const setup = { type: 'game', id: 'main', setup: {} }; + const state = seedFromSetup(pkg, setup); + expect(state.surfaces).toEqual({ 'board#harbor': true, 'hud#hand': true }); + }); +}); \ No newline at end of file diff --git a/packages/tabletop/src/setup.ts b/packages/tabletop/src/setup.ts new file mode 100644 index 0000000..a9a1814 --- /dev/null +++ b/packages/tabletop/src/setup.ts @@ -0,0 +1,68 @@ +/** + * `SetupLoader` — seed the game state from a `Setup`. + * + * A side-effect-only component: it enables the setup's `surfaces` (or all when + * omitted) and places parts on the setup's paths. A setup value that is a bare + * `type` (no id) expands to all parts of that type during initialization. + */ +import { useEffect } from 'react'; +import type { Package, Setup } from '@tts/bgm'; +import { useTabletopStore } from './state.js'; + +/** + * Expand a setup value into a list of part ids (`package:type#id`). A bare + * `type` (no `#id`) expands to every part of that type in the package. + */ +export function expandSetupValue( + pkg: Package, + value: string | string[], +): string[] { + const values = Array.isArray(value) ? value : [value]; + const out: string[] = []; + for (const v of values) { + // A full ref is `package:type#id`; a bare type is `package:type` (no id). + const [ref, id] = v.split('#'); + if (id) { + out.push(v); + continue; + } + // Bare type: expand to all parts of that type, in package order. + const [pkgId, bareType] = ref?.split(':') ?? []; + if (pkgId !== pkg.meta.id) continue; + for (const [key, part] of pkg.parts) { + if (part.type === bareType) out.push(`${pkg.meta.id}:${key}`); + } + } + return out; +} + +/** Seed the store from a setup. Returns the resulting game state. */ +export function seedFromSetup(pkg: Package, setup: Setup): { + surfaces: Record; + paths: Record; +} { + const surfaces: Record = {}; + if (setup.surfaces) { + for (const id of setup.surfaces) surfaces[id] = true; + } else { + for (const key of pkg.surfaces.keys()) surfaces[key] = true; + } + + const paths: Record = {}; + for (const [path, value] of Object.entries(setup.setup)) { + paths[path] = expandSetupValue(pkg, value); + } + return { surfaces, paths }; +} + +/** + * Side-effect-only: seeds the store from `setup` on mount (and whenever the + * setup changes). Renders nothing. + */ +export function SetupLoader({ pkg, setup }: { pkg: Package; setup: Setup }) { + const seed = useTabletopStore((s) => s.seed); + useEffect(() => { + seed(seedFromSetup(pkg, setup)); + }, [pkg, setup, seed]); + return null; +} \ No newline at end of file diff --git a/packages/tabletop/src/stacking.test.ts b/packages/tabletop/src/stacking.test.ts new file mode 100644 index 0000000..94946b8 --- /dev/null +++ b/packages/tabletop/src/stacking.test.ts @@ -0,0 +1,88 @@ +import { describe, expect, it } from 'vitest'; +import { stackingOffset, parsePath, pointAt, NO_OFFSET } from './stacking.js'; + +describe('parsePath', () => { + it('measures a straight line', () => { + const path = parsePath('M 0 0 L 10 0'); + expect(path.length).toBeCloseTo(10); + }); + + it('measures a cubic curve', () => { + const path = parsePath('M 0 0 C 20 -20 40 -20 60 0'); + // Longer than the chord (60) but finite. + expect(path.length).toBeGreaterThan(60); + expect(path.length).toBeLessThan(80); + }); + + it('handles relative commands', () => { + const path = parsePath('m 0 0 l 10 0 l 0 10'); + expect(path.length).toBeCloseTo(20); + }); + + it('supports h/v/z', () => { + const path = parsePath('M 0 0 H 10 V 10 Z'); + // 10 right + 10 down + the diagonal back to the start (closes the triangle). + expect(path.length).toBeCloseTo(10 + 10 + Math.sqrt(200)); + }); +}); + +describe('pointAt', () => { + it('returns the start at distance 0 and end at full length', () => { + const path = parsePath('M 0 0 L 10 0'); + expect(pointAt(path, 0)).toMatchObject({ x: 0, y: 0 }); + const end = pointAt(path, path.length); + expect(end.x).toBeCloseTo(10); + expect(end.y).toBeCloseTo(0); + }); + + it('interpolates along the path', () => { + const path = parsePath('M 0 0 L 10 0'); + const mid = pointAt(path, 5); + expect(mid.x).toBeCloseTo(5); + expect(mid.angle).toBeCloseTo(0); + }); +}); + +describe('stackingOffset', () => { + it('returns no offset without a curve', () => { + expect(stackingOffset(undefined, 0, 3)).toBe(NO_OFFSET); + expect(stackingOffset({ limit: 5 }, 0, 3)).toBe(NO_OFFSET); + }); + + it('spreads parts evenly along a straight curve', () => { + const offset = stackingOffset({ curve: 'M 0 0 L 100 0' }, 1, 3); + // step = length / max(steps=1, 2) = 50; part 1 at 50. + expect(offset.x).toBeCloseTo(50); + expect(offset.y).toBeCloseTo(0); + }); + + it('aligns to center', () => { + const offset = stackingOffset({ curve: 'M 0 0 L 100 0', align: 'center' }, 0, 3); + // span = 50 * 2 = 100; centered start = (100 - 100)/2 = 0. + expect(offset.x).toBeCloseTo(0); + }); + + it('aligns to end', () => { + const offset = stackingOffset({ curve: 'M 0 0 L 100 0', align: 'end' }, 2, 3); + // start = 100 - 100 = 0; part 2 at 100. + expect(offset.x).toBeCloseTo(100); + }); + + it('respects a positive limit (first n)', () => { + const offset = stackingOffset({ curve: 'M 0 0 L 100 0', limit: 2 }, 2, 4); + // Part 2 is beyond the first 2 shown -> not placed. + expect(offset).toBe(NO_OFFSET); + }); + + it('respects a negative limit (last n)', () => { + // Last 2 of 4 are indices 2,3. Part 2 is the first shown. + const offset = stackingOffset({ curve: 'M 0 0 L 100 0', limit: -2 }, 2, 4); + expect(offset.x).toBeCloseTo(0); + }); + + it('uses steps to densify the curve', () => { + // steps=4, 3 parts -> step = 100 / max(4, 2) = 25. + const offset = stackingOffset({ curve: 'M 0 0 L 100 0', steps: 4 }, 1, 3); + expect(offset.x).toBeCloseTo(25); + }); +}); \ No newline at end of file diff --git a/packages/tabletop/src/stacking.ts b/packages/tabletop/src/stacking.ts new file mode 100644 index 0000000..f517464 --- /dev/null +++ b/packages/tabletop/src/stacking.ts @@ -0,0 +1,370 @@ +/** + * Stacking — the format's positioning process (`bgm-format.md` §4). + * + * Given a route's `stacking` strategy and a piece's position in its path's + * stack, compute the offset/rotation to apply. Parts are spread along an SVG + * `curve` relative to the route's anchor, `step length` apart, aligned per the + * strategy. + */ +import { useMemo } from 'react'; +import type { Stacking } from '@tts/bgm'; + +export interface StackOffset { + /** x offset from the anchor. */ + x: number; + /** y offset from the anchor. */ + y: number; + /** Rotation in radians. */ + rotation: number; +} + +/** The identity offset: no stacking applied. */ +export const NO_OFFSET: StackOffset = { x: 0, y: 0, rotation: 0 }; + +/** + * Compute the offset/rotation for the piece at `index` of a `stackSize`-piece + * stack, given the route's stacking strategy. Returns `NO_OFFSET` when there's + * no curve or the stack is empty. + */ +export function stackingOffset( + stacking: Stacking | undefined, + index: number, + stackSize: number, +): StackOffset { + if (!stacking?.curve || stackSize <= 0) return NO_OFFSET; + + // `limit` selects which pieces are shown; the offset is computed over the + // shown span. `0` (or absent) shows all. + const shown = applyLimit(stacking.limit, stackSize); + const shownIndex = shown.indexOf(index); + if (shownIndex < 0) return NO_OFFSET; + + const curve = parsePath(stacking.curve); + const length = curve.length; + if (length <= 0) return NO_OFFSET; + + // Step length: curve length / max(steps, # parts − 1). A single part sits + // at the start of the curve. + const steps = stacking.steps ?? 1; + const span = Math.max(steps, shown.length - 1); + const step = length / span; + + // Alignment: how far the whole span is inset from the curve's start. + const spanLength = step * (shown.length - 1); + let start = 0; + if (stacking.align === 'end') start = length - spanLength; + else if (stacking.align === 'center') start = (length - spanLength) / 2; + + const distance = start + shownIndex * step; + const { x, y, angle } = pointAt(curve, distance); + return { x, y, rotation: angle }; +} + +/** The stacking hook: memoized `stackingOffset` for a piece. */ +export function useStacking( + stacking: Stacking | undefined, + index: number, + stackSize: number, +): StackOffset { + return useMemo(() => stackingOffset(stacking, index, stackSize), [stacking, index, stackSize]); +} + +/** Apply a stacking `limit` to a stack size, returning the shown indices. */ +function applyLimit(limit: number | undefined, stackSize: number): number[] { + const indices = Array.from({ length: stackSize }, (_, i) => i); + if (!limit || limit === 0) return indices; + if (limit > 0) return indices.slice(0, limit); + return indices.slice(limit); +} + +// --- SVG path sampling --- + +/** A sampled point along a path. */ +interface PathPoint { + x: number; + y: number; + /** Cumulative arc length from the path start. */ + t: number; +} + +/** A parsed path: a dense polyline approximation with cumulative lengths. */ +interface SampledPath { + points: PathPoint[]; + length: number; +} + +/** + * Parse an SVG path `d` string into a dense polyline approximation. Supports + * the common commands (M/L/H/V/C/S/Q/T/A/Z, absolute and relative). This is a + * small, dependency-free helper for curve length and point-at-distance. + */ +export function parsePath(d: string): SampledPath { + const tokens = tokenize(d); + const points: PathPoint[] = []; + let cx = 0; + let cy = 0; + let startX = 0; + let startY = 0; + let i = 0; + let cmd = 'M'; + + const push = (x: number, y: number) => { + cx = x; + cy = y; + points.push({ x, y, t: 0 }); + }; + + const rel = (v: number, base: number) => (cmd === cmd.toLowerCase() ? base + v : v); + + while (i < tokens.length) { + const tok = tokens[i]!; + if (/[a-zA-Z]/.test(tok)) { + cmd = tok; + i++; + // `Z` closes the path and takes no arguments; handle it immediately. + if (cmd.toUpperCase() === 'Z') { + push(startX, startY); + continue; + } + continue; + } + const num = () => { + const v = parseFloat(tokens[i]!); + i++; + return v; + }; + + switch (cmd.toUpperCase()) { + case 'M': { + const x = rel(num(), cx); + const y = rel(num(), cy); + push(x, y); + startX = x; + startY = y; + break; + } + case 'L': { + const x = rel(num(), cx); + const y = rel(num(), cy); + push(x, y); + break; + } + case 'H': { + const x = rel(num(), cx); + push(x, cy); + break; + } + case 'V': { + const y = rel(num(), cy); + push(cx, y); + break; + } + case 'C': { + const x1 = rel(num(), cx); + const y1 = rel(num(), cy); + const x2 = rel(num(), cx); + const y2 = rel(num(), cy); + const x = rel(num(), cx); + const y = rel(num(), cy); + sampleCubic(points, cx, cy, x1, y1, x2, y2, x, y); + push(x, y); + break; + } + case 'S': { + // Reflect the previous control point; without one, use the current point. + const prev = points[points.length - 2]; + const x1 = prev ? 2 * cx - prev.x : cx; + const y1 = prev ? 2 * cy - prev.y : cy; + const x2 = rel(num(), cx); + const y2 = rel(num(), cy); + const x = rel(num(), cx); + const y = rel(num(), cy); + sampleCubic(points, cx, cy, x1, y1, x2, y2, x, y); + push(x, y); + break; + } + case 'Q': { + const x1 = rel(num(), cx); + const y1 = rel(num(), cy); + const x = rel(num(), cx); + const y = rel(num(), cy); + sampleQuadratic(points, cx, cy, x1, y1, x, y); + push(x, y); + break; + } + case 'T': { + const prev = points[points.length - 2]; + const x1 = prev ? 2 * cx - prev.x : cx; + const y1 = prev ? 2 * cy - prev.y : cy; + const x = rel(num(), cx); + const y = rel(num(), cy); + sampleQuadratic(points, cx, cy, x1, y1, x, y); + push(x, y); + break; + } + case 'A': { + const rx = Math.abs(num()); + const ry = Math.abs(num()); + const rot = (num() * Math.PI) / 180; + const largeArc = num() !== 0; + const sweep = num() !== 0; + const x = rel(num(), cx); + const y = rel(num(), cy); + sampleArc(points, cx, cy, rx, ry, rot, largeArc, sweep, x, y); + push(x, y); + break; + } + default: + throw new Error(`Unsupported SVG path command: ${cmd}`); + } + } + + // Compute cumulative arc length. + let t = 0; + for (let k = 1; k < points.length; k++) { + const a = points[k - 1]!; + const b = points[k]!; + t += Math.hypot(b.x - a.x, b.y - a.y); + b.t = t; + } + return { points, length: t }; +} + +/** Split a path `d` string into command letters and numbers. */ +function tokenize(d: string): string[] { + const out: string[] = []; + const re = /([a-zA-Z])|(-?\d*\.?\d+(?:[eE][+-]?\d+)?)/g; + let m: RegExpExecArray | null; + while ((m = re.exec(d)) !== null) { + out.push(m[1] ?? m[2]!); + } + return out; +} + +/** Sample a cubic Bezier into the point list (excluding the endpoint). */ +function sampleCubic( + points: PathPoint[], + x0: number, + y0: number, + x1: number, + y1: number, + x2: number, + y2: number, + x3: number, + y3: number, +) { + for (let s = 1; s < SEGMENTS; s++) { + const u = s / SEGMENTS; + const v = 1 - u; + const x = + v * v * v * x0 + 3 * v * v * u * x1 + 3 * v * u * u * x2 + u * u * u * x3; + const y = + v * v * v * y0 + 3 * v * v * u * y1 + 3 * v * u * u * y2 + u * u * u * y3; + points.push({ x, y, t: 0 }); + } +} + +/** Sample a quadratic Bezier into the point list (excluding the endpoint). */ +function sampleQuadratic( + points: PathPoint[], + x0: number, + y0: number, + x1: number, + y1: number, + x2: number, + y2: number, +) { + for (let s = 1; s < SEGMENTS; s++) { + const u = s / SEGMENTS; + const v = 1 - u; + const x = v * v * x0 + 2 * v * u * x1 + u * u * x2; + const y = v * v * y0 + 2 * v * u * y1 + u * u * y2; + points.push({ x, y, t: 0 }); + } +} + +/** Sample an elliptical arc into the point list (excluding the endpoint). */ +function sampleArc( + points: PathPoint[], + x0: number, + y0: number, + rx: number, + ry: number, + rot: number, + largeArc: boolean, + sweep: boolean, + x1: number, + y1: number, +) { + // Convert endpoint parameterization to center parameterization. + const dx = (x0 - x1) / 2; + const dy = (y0 - y1) / 2; + const cos = Math.cos(rot); + const sin = Math.sin(rot); + const px = cos * dx + sin * dy; + const py = -sin * dx + cos * dy; + const rx2 = rx * rx; + const ry2 = ry * ry; + const px2 = px * px; + const py2 = py * py; + const radicand = Math.max(0, (rx2 * ry2 - rx2 * py2 - ry2 * px2) / (rx2 * py2 + ry2 * px2)); + const sign = largeArc !== sweep ? 1 : -1; + const factor = sign * Math.sqrt(radicand); + const cx = (factor * (rx * py)) / ry; + const cy = (factor * (-ry * px)) / rx; + const cxp = cx * cos - cy * sin + (x0 + x1) / 2; + const cyp = cx * sin + cy * cos + (y0 + y1) / 2; + + const angle = (ux: number, uy: number, vx: number, vy: number) => { + const dot = ux * vx + uy * vy; + const len = Math.hypot(ux, uy) * Math.hypot(vx, vy); + let a = Math.acos(Math.max(-1, Math.min(1, dot / len))); + if (ux * vy - uy * vx < 0) a = -a; + return a; + }; + + const ux = (px - cx) / rx; + const uy = (py - cy) / ry; + const vx = (-px - cx) / rx; + const vy = (-py - cy) / ry; + let theta1 = angle(1, 0, ux, uy); + let dtheta = angle(ux, uy, vx, vy); + if (!sweep && dtheta > 0) dtheta -= Math.PI * 2; + else if (sweep && dtheta < 0) dtheta += Math.PI * 2; + + for (let s = 1; s < SEGMENTS; s++) { + const a = theta1 + (s / SEGMENTS) * dtheta; + const cosA = Math.cos(a); + const sinA = Math.sin(a); + const x = cxp + rx * cosA * cos - ry * sinA * sin; + const y = cyp + rx * cosA * sin + ry * sinA * cos; + points.push({ x, y, t: 0 }); + } +} + +/** Sample density per curve segment. */ +const SEGMENTS = 32; + +/** The point (and tangent angle) at a distance along a sampled path. */ +export function pointAt(path: SampledPath, distance: number): { x: number; y: number; angle: number } { + const { points, length } = path; + if (points.length === 0) return { x: 0, y: 0, angle: 0 }; + const d = Math.max(0, Math.min(distance, length)); + if (points.length === 1) return { x: points[0]!.x, y: points[0]!.y, angle: 0 }; + + let lo = 0; + let hi = points.length - 1; + while (lo < hi) { + const mid = (lo + hi) >> 1; + if (points[mid]!.t < d) lo = mid + 1; + else hi = mid; + } + const b = points[lo]!; + const a = points[lo - 1] ?? b; + const seg = b.t - a.t; + const u = seg > 0 ? (d - a.t) / seg : 0; + const x = a.x + (b.x - a.x) * u; + const y = a.y + (b.y - a.y) * u; + const angle = Math.atan2(b.y - a.y, b.x - a.x); + return { x, y, angle }; +} \ No newline at end of file diff --git a/packages/tabletop/src/state.test.ts b/packages/tabletop/src/state.test.ts new file mode 100644 index 0000000..741686a --- /dev/null +++ b/packages/tabletop/src/state.test.ts @@ -0,0 +1,126 @@ +import { describe, expect, it } from 'vitest'; +import type { Package, Surface } from '@tts/bgm'; +import { matchRoute, computeSurfacePlacements, computeRenderState, placementKey } from './state.js'; + +function makeSurface(overrides: Partial = {}): Surface { + return { + type: 'board', + id: 'harbor', + layout: [], + ...overrides, + }; +} + +const pkg: Package = { + meta: { id: 'harbor' }, + parts: new Map(), + surfaces: new Map([ + ['board#harbor', makeSurface()], + ['hud#hand', makeSurface({ type: 'hud', id: 'hand' })], + ]), + setups: new Map(), +}; + +describe('matchRoute', () => { + it('matches a literal path', () => { + const route = { route: '/deck', x: 0, y: 0, rotation: 0 }; + expect(matchRoute(route, '/deck')).toEqual({ candidate: undefined }); + expect(matchRoute(route, '/other')).toBeNull(); + }); + + it('matches a :param against a candidate', () => { + const route = { + route: '/dock/:seat', + x: 0, + y: 0, + rotation: 0, + candidates: [ + { seat: '0', x: 40, y: 0 }, + { seat: '1', x: 40, y: 20 }, + ], + }; + expect(matchRoute(route, '/dock/1')).toEqual({ candidate: { seat: '1', x: 40, y: 20 } }); + }); + + it('fails when no candidate matches the param', () => { + const route = { + route: '/dock/:seat', + x: 0, + y: 0, + rotation: 0, + candidates: [{ seat: '0', x: 40, y: 0 }], + }; + expect(matchRoute(route, '/dock/9')).toBeNull(); + }); + + it('fails on length mismatch', () => { + const route = { route: '/deck', x: 0, y: 0, rotation: 0 }; + expect(matchRoute(route, '/deck/extra')).toBeNull(); + }); +}); + +describe('computeSurfacePlacements', () => { + it('places parts on a matching route with index and stackSize', () => { + const surface = makeSurface({ + layout: [{ route: '/deck', x: -100, y: 0, rotation: 0 }], + }); + const placements = computeSurfacePlacements(surface, { + '/deck': ['harbor:card#a', 'harbor:card#b'], + }); + expect(placements).toHaveLength(2); + expect(placements[0]).toMatchObject({ piece: 'harbor:card#a', index: 0, stackSize: 2 }); + expect(placements[1]).toMatchObject({ piece: 'harbor:card#b', index: 1, stackSize: 2 }); + }); + + it('drops parts with no matching route', () => { + const surface = makeSurface({ layout: [{ route: '/deck', x: 0, y: 0, rotation: 0 }] }); + const placements = computeSurfacePlacements(surface, { + '/deck': ['harbor:card#a'], + '/elsewhere': ['harbor:card#b'], + }); + expect(placements).toHaveLength(1); + expect(placements[0]!.piece).toBe('harbor:card#a'); + }); + + it('uses the candidate anchor for a :param route', () => { + const surface = makeSurface({ + layout: [ + { + route: '/dock/:seat', + x: 0, + y: 0, + rotation: 0, + candidates: [{ seat: '0', x: 40, y: 5, rotation: 1 }], + }, + ], + }); + const placements = computeSurfacePlacements(surface, { '/dock/0': ['harbor:boat#fleet'] }); + expect(placements[0]!.candidate).toEqual({ seat: '0', x: 40, y: 5, rotation: 1 }); + }); +}); + +describe('computeRenderState', () => { + it('only includes enabled surfaces', () => { + const state = { + surfaces: { 'board#harbor': true, 'hud#hand': false }, + paths: { '/deck': ['harbor:card#a'] }, + }; + pkg.surfaces.set( + 'board#harbor', + makeSurface({ layout: [{ route: '/deck', x: 0, y: 0, rotation: 0 }] }), + ); + const placements = computeRenderState(pkg, state); + expect(placements).toHaveLength(1); + expect(placements[0]!.surface).toBe('board#harbor'); + }); +}); + +describe('placementKey', () => { + it('is unique per surface and piece', () => { + const a = { surface: 'board#harbor', piece: 'harbor:card#a' } as never; + const b = { surface: 'board#harbor', piece: 'harbor:card#b' } as never; + const c = { surface: 'hud#hand', piece: 'harbor:card#a' } as never; + expect(placementKey(a)).not.toBe(placementKey(b)); + expect(placementKey(a)).not.toBe(placementKey(c)); + }); +}); \ No newline at end of file diff --git a/packages/tabletop/src/state.ts b/packages/tabletop/src/state.ts new file mode 100644 index 0000000..fa84326 --- /dev/null +++ b/packages/tabletop/src/state.ts @@ -0,0 +1,133 @@ +/** + * Source-of-truth game state and the derived render state. + * + * The store holds the enabled surfaces and the path -> part placement map + * (`bgm-tabletop.md` §2). The derived render state is computed from the game + * state plus a package's surface routes: a stable list of placements, one per + * (surface, piece) pair, keyed for rendering. + */ +import { useMemo } from 'react'; +import { create } from 'zustand'; +import type { Candidate, Package, Route, Surface } from '@tts/bgm'; + +/** Source-of-truth game state. */ +export interface GameState { + /** Enabled per surface id (`type#id`). */ + surfaces: Record; + /** Path -> part list (`package:type#id`). */ + paths: Record; +} + +/** A single placed piece on a surface, ready for rendering. */ +export interface Placement { + /** Surface id (`type#id`). */ + surface: string; + /** The matched route. */ + route: Route; + /** The matched candidate, when the route has `:param`s. */ + candidate?: Candidate; + /** The piece id (`package:type#id`). */ + piece: string; + /** The piece's position in its path's stack. */ + index: number; + /** The number of pieces on the path. */ + stackSize: number; +} + +interface TabletopState extends GameState { + setSurfaces: (surfaces: Record) => void; + setPaths: (paths: Record) => void; + seed: (state: GameState) => void; + enableSurface: (id: string) => void; + disableSurface: (id: string) => void; + setPath: (path: string, parts: string[]) => void; +} + +export const useTabletopStore = create((set) => ({ + surfaces: {}, + paths: {}, + setSurfaces: (surfaces) => set({ surfaces }), + setPaths: (paths) => set({ paths }), + seed: (state) => set(state), + enableSurface: (id) => set((s) => ({ surfaces: { ...s.surfaces, [id]: true } })), + disableSurface: (id) => set((s) => ({ surfaces: { ...s.surfaces, [id]: false } })), + setPath: (path, parts) => set((s) => ({ paths: { ...s.paths, [path]: parts } })), +})); + +// --- Route matching --- + +/** + * Match a route pattern against a path key. Returns the matched candidate (or + * undefined when the route has no `:param`s), or null when the route doesn't + * match. A route with candidates matches only when a candidate's props match + * every `:param`; the first such candidate wins. + */ +export function matchRoute(route: Route, path: string): { candidate?: Candidate } | null { + const routeSegs = route.route.split('/').filter(Boolean); + const pathSegs = path.split('/').filter(Boolean); + if (routeSegs.length !== pathSegs.length) return null; + + const params: Record = {}; + for (let i = 0; i < routeSegs.length; i++) { + const rs = routeSegs[i]!; + const ps = pathSegs[i]!; + if (rs.startsWith(':')) params[rs.slice(1)] = ps; + else if (rs !== ps) return null; + } + + if (route.candidates) { + for (const cand of route.candidates) { + const allMatch = Object.entries(params).every(([k, v]) => cand[k] === v); + if (allMatch) return { candidate: cand }; + } + return null; + } + return { candidate: undefined }; +} + +/** Compute the placements for a single surface from the game state's paths. */ +export function computeSurfacePlacements(surface: Surface, paths: Record): Placement[] { + const placements: Placement[] = []; + const surfaceId = `${surface.type}#${surface.id}`; + for (const [path, parts] of Object.entries(paths)) { + const route = surface.layout.find((r) => matchRoute(r, path)); + if (!route) continue; + const match = matchRoute(route, path)!; + const stackSize = parts.length; + for (const piece of parts) { + placements.push({ + surface: surfaceId, + route, + candidate: match.candidate, + piece, + index: parts.indexOf(piece), + stackSize, + }); + } + } + return placements; +} + +/** Compute the full render state across all enabled surfaces. */ +export function computeRenderState(pkg: Package, state: GameState): Placement[] { + const out: Placement[] = []; + for (const [surfaceId, enabled] of Object.entries(state.surfaces)) { + if (!enabled) continue; + const surface = pkg.surfaces.get(surfaceId); + if (!surface) continue; + out.push(...computeSurfacePlacements(surface, state.paths)); + } + return out; +} + +/** A stable key for a placement, unique across surfaces and pieces. */ +export function placementKey(p: Placement): string { + return `${p.surface}:${p.piece}`; +} + +/** The derived render state for a package, from the current game state. */ +export function useRenderState(pkg: Package): Placement[] { + const surfaces = useTabletopStore((s) => s.surfaces); + const paths = useTabletopStore((s) => s.paths); + return useMemo(() => computeRenderState(pkg, { surfaces, paths }), [pkg, surfaces, paths]); +} \ No newline at end of file diff --git a/packages/tabletop/src/surfaces/HudSurfaceView.tsx b/packages/tabletop/src/surfaces/HudSurfaceView.tsx new file mode 100644 index 0000000..a3da682 --- /dev/null +++ b/packages/tabletop/src/surfaces/HudSurfaceView.tsx @@ -0,0 +1,34 @@ +/** + * `HudSurfaceView` — mount a surface to a HUD area. + * + * Renders a `kind: hud` mount node (and its child surfaces) anchored to a HUD + * area. The default is a drei `Html` overlay so world and HUD share one scene + * (see `bgm-tabletop.md` Open decisions). Parts are placed via `PartPlacement`. + */ +import { Html } from '@react-three/drei'; +import type { Package } from '@tts/bgm'; +import { useRenderState } from '../state.js'; +import { PartPlacement } from '../placement.js'; +import type { MountNode } from '../mount.js'; +import { SurfaceNode } from './WorldSurfaceView.js'; + +export function HudSurfaceView({ pkg, node }: { pkg: Package; node: MountNode }) { + const placements = useRenderState(pkg); + const own = placements.filter((p) => p.surface === node.id); + + return ( + + {own.map((p) => ( + + ))} + {node.children.map((child) => ( + + ))} + + ); +} \ No newline at end of file diff --git a/packages/tabletop/src/surfaces/WorldSurfaceView.tsx b/packages/tabletop/src/surfaces/WorldSurfaceView.tsx new file mode 100644 index 0000000..631664e --- /dev/null +++ b/packages/tabletop/src/surfaces/WorldSurfaceView.tsx @@ -0,0 +1,33 @@ +/** + * `WorldSurfaceView` — mount a surface in world space. + * + * Renders a world mount node (a `kind: table` surface and its child surfaces) + * at its anchor. Parts on the surface are placed via `PartPlacement` from the + * derived render state. A disabled surface isn't part of the mount tree, so + * it's never rendered. + */ +import type { Package } from '@tts/bgm'; +import { useRenderState } from '../state.js'; +import { PartPlacement } from '../placement.js'; +import type { MountNode } from '../mount.js'; + +export function WorldSurfaceView({ pkg, node }: { pkg: Package; node: MountNode }) { + return ; +} + +/** Render a mount node at its anchor, placing parts and recursing into children. */ +export function SurfaceNode({ pkg, node }: { pkg: Package; node: MountNode }) { + const placements = useRenderState(pkg); + const own = placements.filter((p) => p.surface === node.id); + + return ( + + {own.map((p) => ( + + ))} + {node.children.map((child) => ( + + ))} + + ); +} \ No newline at end of file diff --git a/packages/tabletop/src/vite.test.ts b/packages/tabletop/src/vite.test.ts new file mode 100644 index 0000000..6846d2e --- /dev/null +++ b/packages/tabletop/src/vite.test.ts @@ -0,0 +1,64 @@ +import { describe, expect, it } from 'vitest'; +import * as fs from 'node:fs'; +import * as os from 'node:os'; +import * as path from 'node:path'; +import { fileURLToPath } from 'node:url'; +import { build } from 'vite'; +import { bgm } from '@tts/bgm'; + +const fixtureRoot = path.resolve( + path.dirname(fileURLToPath(import.meta.url)), + '__fixtures__', + 'vite-build', +); +const gamesRoot = path.join(fixtureRoot, 'games'); + +describe('tabletop vite build (integration)', () => { + it('bundles the library logic against a fixture package', async () => { + const outDir = await fs.promises.mkdtemp(path.join(os.tmpdir(), 'tabletop-build-')); + + try { + await build({ + root: fixtureRoot, + logLevel: 'silent', + build: { + outDir, + write: true, + emptyOutDir: true, + // Keep identifiers readable so the test can assert on them. + minify: false, + }, + plugins: [bgm({ root: gamesRoot })], + }); + + const chunk = walk(outDir).find((f) => f.endsWith('.js'))!; + const code = fs.readFileSync(path.join(outDir, chunk), 'utf8'); + + // The bgm plugin serialized the package's parts and setup into the + // emitted module, and the library's logic is bundled alongside. + expect(code).toContain('token#wood'); + expect(code).toContain('game#main'); + // The library's pure logic (setup seeding, render state, stacking, + // mount resolution) is reachable from the fixture entry. + expect(code).toContain('placementCount'); + expect(code).toContain('offsetX'); + expect(code).toContain('worldCount'); + } finally { + await fs.promises.rm(outDir, { recursive: true, force: true }); + } + }); +}); + +/** Recursively list files under a directory, as paths relative to it. */ +function walk(dir: string): string[] { + const out: string[] = []; + const visit = (current: string) => { + for (const entry of fs.readdirSync(current, { withFileTypes: true })) { + const full = path.join(current, entry.name); + if (entry.isDirectory()) visit(full); + else out.push(path.relative(dir, full)); + } + }; + visit(dir); + return out; +} \ No newline at end of file diff --git a/packages/tabletop/tsconfig.json b/packages/tabletop/tsconfig.json index 3ce634c..0dca2e1 100644 --- a/packages/tabletop/tsconfig.json +++ b/packages/tabletop/tsconfig.json @@ -7,5 +7,5 @@ "jsx": "react-jsx" }, "include": ["src"], - "exclude": ["src/**/*.test.ts"] + "exclude": ["src/__fixtures__", "src/**/*.test.ts"] } \ No newline at end of file diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 21eba27..37ea1b4 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -268,6 +268,9 @@ importers: specifier: ^5.0.14 version: 5.0.14(@types/react@19.2.18)(react@19.2.8)(use-sync-external-store@1.6.0(react@19.2.8)) devDependencies: + '@types/node': + specifier: ^22.12.0 + version: 22.20.1 '@types/react': specifier: ^19.2.18 version: 19.2.18 @@ -277,6 +280,9 @@ importers: typescript: specifier: ^5.7.2 version: 5.9.3 + vite: + specifier: ^8.2.1 + version: 8.2.1(@types/node@22.20.1)(esbuild@0.28.1)(jiti@2.7.0)(tsx@4.23.11)(yaml@2.9.0) vitest: specifier: ^4.1.10 version: 4.1.10(@types/node@22.20.1)(vite@8.2.1(@types/node@22.20.1)(esbuild@0.28.1)(jiti@2.7.0)(tsx@4.23.11)(yaml@2.9.0))