From 599fb6a76d8422f90104063806d16594ed4e14ba Mon Sep 17 00:00:00 2001 From: hypercross Date: Sun, 9 Aug 2026 21:03:20 +0800 Subject: [PATCH] feat(bgm): add surface mounting and setup surfaces --- docs/bgm-format.md | 59 ++++++++++++++++++- docs/bgm-loader-status.md | 5 +- docs/bgm-tabletop.md | 49 +++++++++++++++ .../bgm/src/__fixtures__/harbor/harbor.md | 33 +++++++++++ packages/bgm/src/collect.test.ts | 13 +++- packages/bgm/src/schemas.ts | 11 ++++ packages/bgm/src/types.ts | 25 +++++++- packages/bgm/src/vite.unit.test.ts | 2 +- 8 files changed, 187 insertions(+), 10 deletions(-) create mode 100644 docs/bgm-tabletop.md diff --git a/docs/bgm-format.md b/docs/bgm-format.md index 1057687..5594770 100644 --- a/docs/bgm-format.md +++ b/docs/bgm-format.md @@ -193,11 +193,29 @@ tables. It does not affect part placement; placement lives in the state store (see §4). A surface need not cover every part — parts with no matching route on this surface are simply not shown. +A surface also declares how it is **mounted**: as the root table surface, on a +HUD area, or as a child of another surface. `mount` is always an object, with +`x`, `y`, and `rotation` (defaulting to `0`) anchoring it like a route. The +`kind` selects the mount type: + +- `table` — the root table surface (default). +- `hud` — mounted to a HUD area, e.g. a player's hand. +- `child` — mounted relative to a parent surface. A surface lists its + `children` (`type#id` refs) so a surface can be repeated, like a player + board; each child is mounted relative to its parent's anchor. + ```yaml type: board id: harbor role: surface size: [300, 200] +mount: + kind: table + x: 0 + y: 0 + rotation: 0 +children: + - board#player layout: - route: /dock/:seat candidates: @@ -208,19 +226,49 @@ layout: rotation: 0 ``` +```yaml +type: hud +id: hand +role: surface +size: [200, 100] +mount: + kind: hud + area: bottom-left +``` + +```yaml +type: board +id: player +role: surface +size: [200, 200] +mount: + kind: child + x: 100 + y: 50 + rotation: 0 +``` + ### setup -`setup` seeds the state store. Each valid game state is a valid setup. +`setup` seeds the state store: the enabled surfaces and the part placement. +Each valid game state is a valid setup. ```yaml role: setup type: game id: main +surfaces: + - board#harbor + - hud#hand setup: /dock/0: harbor:boat#fleet /deck: harbor:card ``` +`surfaces` lists the surfaces enabled at the start. A surface not listed is +disabled and not rendered. When `surfaces` is omitted, all surfaces are +enabled. + The value on a setup path can be either a string, or a string list. The string can either be a one part string, or a type without an id. @@ -233,13 +281,18 @@ When id is omitted, it expands to all parts in that type during game state initi ### Game state -The board's state is a **state store**: a map from path to a **stack** of -parts. It is the authoritative record of where every part is placed. +The board's state is a **state store**: the set of **enabled surfaces** and a +map from path to a **stack** of parts. It is the authoritative record of which +surfaces are in play and where every part is placed. A path is a URL path with named params, like `/dock/1`. A part is identified by a `package:type#id` string. +A surface is enabled or disabled; a disabled surface is not rendered. Setup +seeds the enabled set (see §3), and it changes at runtime as the game +progresses (e.g. enabling the main board after an expansion-chooser scene). + ### Routing A route is a **visualization route**: it maps a part to a location on a diff --git a/docs/bgm-loader-status.md b/docs/bgm-loader-status.md index a27c91e..5367cdb 100644 --- a/docs/bgm-loader-status.md +++ b/docs/bgm-loader-status.md @@ -9,7 +9,7 @@ | File | Purpose | | --- | --- | -| `src/types.ts` | Roles (`Package`, `Part`, `Surface`, `Setup`, `Route`, `Stacking`, …), `SerializedPackage` (the JSON the plugin emits), `BgmError`, `DefFile`, `ParsedDef` | +| `src/types.ts` | Roles (`Package`, `Part`, `Surface`, `Setup`, `Route`, `Stacking`, `SurfaceMount`, …), `SerializedPackage` (the JSON the plugin emits), `BgmError`, `DefFile`, `ParsedDef` | | `src/schemas.ts` | zod schemas + `validate*` for each role | | `src/markdown.ts` | Virtual def files from markdown code blocks, via **`marked`**. `file=` naming + content-hash auto-naming (`./.yaml`); multiple blocks may share a `file=` name | | `src/parse.ts` | yaml/json/toml → def objects (`yaml`, `smol-toml`); real-file walker (incl. `.csv`) | @@ -22,7 +22,7 @@ Deps: `marked`, `typed-csv`, `yaml`, `smol-toml`, `picomatch`, `zod`, `vite`, `@ ### `games/harbor/harbor.md` — example game (new) -Exercises the format end-to-end: package decl, two `file=parts/tokens.yaml` blocks, a surface with `candidates: $variants` against a virtual csv block, and a setup. Same content duplicated as the vitest fixture under `packages/bgm/src/__fixtures__/harbor/`. +Exercises the format end-to-end: package decl, two `file=parts/tokens.yaml` blocks, a table surface with `mount`/`children` and `candidates: $variants` against a virtual csv block, a child player surface, and a setup declaring its enabled `surfaces`. Same content duplicated as the vitest fixture under `packages/bgm/src/__fixtures__/harbor/`. ### `apps/web` — consumer (new) @@ -54,4 +54,5 @@ The vite plugin itself lives in `packages/bgm/src/vite.ts` (exported from `@tts/ - **`$variants` URL paths** — spec mentions file/URL; URLs deferred. - **zod `SerializedPackage` shape for the emitted JSON** — the plugin emits `SerializedPackage` objects; a zod schema for the emitted module would give runtime validation beyond the ambient `declare module`. - **`setup` value expansion** — `type` without `id` → all parts of that type is documented but not implemented in the loader (it's a game-state init concern; noted as future). +- **Surface mounting is validated but not resolved** — `mount`/`children`/`surfaces` are parsed and validated, but the loader doesn't resolve child→parent relationships or enforce that a setup's `surfaces`/a surface's `children` reference existing surfaces. That's a game-state/rendering concern (see `docs/bgm-tabletop.md`). - Docs for the loader itself (this file is the start). diff --git a/docs/bgm-tabletop.md b/docs/bgm-tabletop.md new file mode 100644 index 0000000..e322186 --- /dev/null +++ b/docs/bgm-tabletop.md @@ -0,0 +1,49 @@ +# bgm-tabletop + +a r3f based interactive component library to work with [bgm](./bgm-format.md) board games. will be used somewhere in the `web` app's bgm inspector routes. + +## 1. stack + +`react` - react, react router, tailwindv4 +`r3f` - r3f, drei, postprocessing +`zustand` - for state management + +## 2. states + +source-of-truth game state: + +```ts +{ + surfaces: Record, // enabled per surface id + paths: Record, // path -> part list +} +``` + +**assumption:** each piece on the board has a unique id, even tokens of the same type. so each entry in a path's list is a unique piece id, and a piece id never appears twice in the same path. this makes the render list keyed by piece id stable and unambiguous. + +derived surface render state: game state + surface routes => map of piece id to `{ surface, route, candidate, index, stackSize }` for rendering on a surface. keys of this map makes a stable render list. + +- `route` - the matched route. +- `candidate` - the matched candidate for a `:param` route, carrying its anchor `x`/`y`/`rotation`. absent for routes without candidates. +- `index` - the piece's position in its path's stack. +- `stackSize` - the number of pieces on the path. + +the render map is per enabled surface: a piece may appear on more than one enabled surface (e.g. an expansion path and the main board), and each is rendered independently. + +## 3. components + +- `SetupLoader` side effect only component that seeds the game state with setup (enabled surfaces + part placement). +- `WorldSurfaceView` mounts a surface to world space. +- `HudSurfaceView` mounts a surface to hud space. +- `PartPlacement` a stable per-part component that positions a part on a surface location. uses the stacking hook (below) to apply the route's stacking strategy. +- `PartView` used in `PartPlacement`, creates a mesh from part definition. a standalone component library, so it reuses geometry/shape code from `@tts/mesh` rather than the web app's viewers. + +## 4. stacking + +the format's stacking strategy (curve / limit / align / steps, see `bgm-format.md` §4) is implemented as a hook, e.g. `useStacking(route.stacking, index, stackSize)`, returning the offset/rotation to apply to a piece. `PartPlacement` consumes it. + +## 5. usage + +- we will inspect individual parts with `PartView` in the web app's part inspection route. +- as a library, the public surface is the components above: mount a surface with `WorldSurfaceView`/`HudSurfaceView`, seed state with `SetupLoader`, and let `PartPlacement`/`PartView` render the pieces. the web app is one consumer; the library should not assume the web app's routes or store. +- a surface is mounted only when enabled; a disabled surface is not rendered. \ No newline at end of file diff --git a/packages/bgm/src/__fixtures__/harbor/harbor.md b/packages/bgm/src/__fixtures__/harbor/harbor.md index a9bfafa..dff3c0c 100644 --- a/packages/bgm/src/__fixtures__/harbor/harbor.md +++ b/packages/bgm/src/__fixtures__/harbor/harbor.md @@ -46,6 +46,13 @@ type: board id: harbor role: surface size: [300, 200] +mount: + kind: table + x: 0 + y: 0 + rotation: 0 +children: + - board#player layout: - route: /dock/:seat candidates: @@ -56,6 +63,29 @@ layout: rotation: 0 ``` +```yaml file=parts/player.yaml +type: board +id: player +role: surface +size: [200, 200] +mount: + kind: child + x: 100 + y: 50 + rotation: 0 +layout: + - route: /hand/:slot + candidates: + $variants: ./hand.csv +``` + +```csv file=parts/hand.csv +slot,x,y,rotation +string,number,number,number +0,0,0,0 +1,0,20,0 +``` + ```csv file=parts/seats.csv seat,x,y,rotation string,number,number,number @@ -69,6 +99,9 @@ string,number,number,number role: setup type: game id: main +surfaces: + - board#harbor + - board#player setup: /dock/0: harbor:token#wood /deck: harbor:token#grain diff --git a/packages/bgm/src/collect.test.ts b/packages/bgm/src/collect.test.ts index 2685cdd..bfb2e5c 100644 --- a/packages/bgm/src/collect.test.ts +++ b/packages/bgm/src/collect.test.ts @@ -26,10 +26,12 @@ describe('collectPackages', () => { expect(wood.face).toBe('./assets/tokens.png'); expect(wood.faceCrop).toEqual([1, 0, 5, 2]); - // One surface with a $variants-expanded candidates list. - expect([...harbor.surfaces.keys()]).toEqual(['board#harbor']); + // Two surfaces: the table board and its child player board. + expect([...harbor.surfaces.keys()].sort()).toEqual(['board#harbor', 'board#player']); const board = harbor.surfaces.get('board#harbor')!; expect(board.size).toEqual([300, 200]); + expect(board.mount).toEqual({ kind: 'table', x: 0, y: 0, rotation: 0 }); + expect(board.children).toEqual(['board#player']); expect(board.layout).toHaveLength(2); const dock = board.layout[0]!; expect(dock.route).toBe('/dock/:seat'); @@ -41,9 +43,14 @@ describe('collectPackages', () => { expect(deck.route).toBe('/deck'); expect(deck).toMatchObject({ x: -100, y: 0, rotation: 0 }); - // One setup. + const player = harbor.surfaces.get('board#player')!; + expect(player.mount).toEqual({ kind: 'child', x: 100, y: 50, rotation: 0 }); + expect(player.layout).toHaveLength(1); + + // One setup, declaring the enabled surfaces. expect([...harbor.setups.keys()]).toEqual(['game#main']); const setup = harbor.setups.get('game#main')!; + expect(setup.surfaces).toEqual(['board#harbor', 'board#player']); expect(setup.setup).toEqual({ '/dock/0': 'harbor:token#wood', '/deck': 'harbor:token#grain', diff --git a/packages/bgm/src/schemas.ts b/packages/bgm/src/schemas.ts index 0f72bb7..94c6286 100644 --- a/packages/bgm/src/schemas.ts +++ b/packages/bgm/src/schemas.ts @@ -40,16 +40,27 @@ const partSchema = z.object({ fillet: z.number().optional(), }); +const surfaceMount = z.object({ + kind: z.enum(['table', 'hud', 'child']), + x: z.number().optional(), + y: z.number().optional(), + rotation: z.number().optional(), + area: z.string().optional(), +}); + const surfaceSchema = z.object({ type: z.string().min(1), id: z.string().min(1), size: surfaceSize.optional(), + mount: surfaceMount.optional(), + children: z.array(z.string()).optional(), layout: z.array(route), }); const setupSchema = z.object({ type: z.string().min(1), id: z.string().min(1), + surfaces: z.array(z.string()).optional(), setup: z.record(z.string(), z.union([z.string(), z.array(z.string())])), }); diff --git a/packages/bgm/src/types.ts b/packages/bgm/src/types.ts index 5c51248..419f3a1 100644 --- a/packages/bgm/src/types.ts +++ b/packages/bgm/src/types.ts @@ -104,21 +104,44 @@ export interface Stacking { steps?: number; } +/** How a surface is mounted. `kind` selects the mount type. */ +export type SurfaceMountKind = 'table' | 'hud' | 'child'; + +/** + * How a surface is mounted. Always an object, anchored by `x`/`y`/`rotation` + * like a route. `table` is the root table surface; `hud` mounts to a HUD + * area; `child` mounts relative to a parent surface (see `children`). + */ +export interface SurfaceMount { + kind: SurfaceMountKind; + x?: number; + y?: number; + rotation?: number; + /** HUD area, for `kind: hud`. */ + area?: string; +} + /** A view over the state store, purely for visual rendering. */ export interface Surface { type: string; id: string; /** Reference `[width, height]` in mm; may be scaled to fit the table. */ size?: SurfaceSize; + /** How this surface is mounted; defaults to `{ kind: 'table' }`. */ + mount?: SurfaceMount; + /** Child surfaces (`type#id` refs), mounted relative to this surface. */ + children?: string[]; layout: Route[]; } export type SetupValue = string | string[]; -/** Seeds the state store: a map from path to a stack of parts. */ +/** Seeds the state store: the enabled surfaces and a map from path to parts. */ export interface Setup { type: string; id: string; + /** Surfaces (`type#id` refs) enabled at the start; omitted = all enabled. */ + surfaces?: string[]; setup: Record; } diff --git a/packages/bgm/src/vite.unit.test.ts b/packages/bgm/src/vite.unit.test.ts index d3ed493..9ad383a 100644 --- a/packages/bgm/src/vite.unit.test.ts +++ b/packages/bgm/src/vite.unit.test.ts @@ -67,7 +67,7 @@ describe('bgm vite plugin', () => { // Consumers read the collections with Object.values / Object.keys. expect(Object.values(pkg.parts).map((p: any) => p.id).sort()).toEqual(['grain', 'wood']); - expect(Object.keys(pkg.surfaces)).toEqual(['board#harbor']); + expect(Object.keys(pkg.surfaces)).toEqual(['board#harbor', 'board#player']); expect(Object.keys(pkg.setups)).toEqual(['game#main']); });