feat(bgm): add surface mounting and setup surfaces

This commit is contained in:
2026-08-09 21:03:20 +08:00
parent 4e18ab5bc8
commit 599fb6a76d
8 changed files with 187 additions and 10 deletions
+56 -3
View File
@@ -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 (see §4). A surface need not cover every part — parts with no matching route on
this surface are simply not shown. 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 ```yaml
type: board type: board
id: harbor id: harbor
role: surface role: surface
size: [300, 200] size: [300, 200]
mount:
kind: table
x: 0
y: 0
rotation: 0
children:
- board#player
layout: layout:
- route: /dock/:seat - route: /dock/:seat
candidates: candidates:
@@ -208,19 +226,49 @@ layout:
rotation: 0 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
`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 ```yaml
role: setup role: setup
type: game type: game
id: main id: main
surfaces:
- board#harbor
- hud#hand
setup: setup:
/dock/0: harbor:boat#fleet /dock/0: harbor:boat#fleet
/deck: harbor:card /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 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. 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 ### Game state
The board's state is a **state store**: a map from path to a **stack** of The board's state is a **state store**: the set of **enabled surfaces** and a
parts. It is the authoritative record of where every part is placed. 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 path is a URL path with named params, like `/dock/1`.
A part is identified by a `package:type#id` string. 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 ### Routing
A route is a **visualization route**: it maps a part to a location on a A route is a **visualization route**: it maps a part to a location on a
+3 -2
View File
@@ -9,7 +9,7 @@
| File | Purpose | | 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/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 (`./<hash>.yaml`); multiple blocks may share a `file=` name | | `src/markdown.ts` | Virtual def files from markdown code blocks, via **`marked`**. `file=` naming + content-hash auto-naming (`./<hash>.yaml`); multiple blocks may share a `file=` name |
| `src/parse.ts` | yaml/json/toml → def objects (`yaml`, `smol-toml`); real-file walker (incl. `.csv`) | | `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) ### `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) ### `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. - **`$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`. - **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). - **`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). - Docs for the loader itself (this file is the start).
+49
View File
@@ -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<string, boolean>, // enabled per surface id
paths: Record<string, string[]>, // 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.
@@ -46,6 +46,13 @@ type: board
id: harbor id: harbor
role: surface role: surface
size: [300, 200] size: [300, 200]
mount:
kind: table
x: 0
y: 0
rotation: 0
children:
- board#player
layout: layout:
- route: /dock/:seat - route: /dock/:seat
candidates: candidates:
@@ -56,6 +63,29 @@ layout:
rotation: 0 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 ```csv file=parts/seats.csv
seat,x,y,rotation seat,x,y,rotation
string,number,number,number string,number,number,number
@@ -69,6 +99,9 @@ string,number,number,number
role: setup role: setup
type: game type: game
id: main id: main
surfaces:
- board#harbor
- board#player
setup: setup:
/dock/0: harbor:token#wood /dock/0: harbor:token#wood
/deck: harbor:token#grain /deck: harbor:token#grain
+10 -3
View File
@@ -26,10 +26,12 @@ describe('collectPackages', () => {
expect(wood.face).toBe('./assets/tokens.png'); expect(wood.face).toBe('./assets/tokens.png');
expect(wood.faceCrop).toEqual([1, 0, 5, 2]); expect(wood.faceCrop).toEqual([1, 0, 5, 2]);
// One surface with a $variants-expanded candidates list. // Two surfaces: the table board and its child player board.
expect([...harbor.surfaces.keys()]).toEqual(['board#harbor']); expect([...harbor.surfaces.keys()].sort()).toEqual(['board#harbor', 'board#player']);
const board = harbor.surfaces.get('board#harbor')!; const board = harbor.surfaces.get('board#harbor')!;
expect(board.size).toEqual([300, 200]); 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); expect(board.layout).toHaveLength(2);
const dock = board.layout[0]!; const dock = board.layout[0]!;
expect(dock.route).toBe('/dock/:seat'); expect(dock.route).toBe('/dock/:seat');
@@ -41,9 +43,14 @@ describe('collectPackages', () => {
expect(deck.route).toBe('/deck'); expect(deck.route).toBe('/deck');
expect(deck).toMatchObject({ x: -100, y: 0, rotation: 0 }); 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']); expect([...harbor.setups.keys()]).toEqual(['game#main']);
const setup = harbor.setups.get('game#main')!; const setup = harbor.setups.get('game#main')!;
expect(setup.surfaces).toEqual(['board#harbor', 'board#player']);
expect(setup.setup).toEqual({ expect(setup.setup).toEqual({
'/dock/0': 'harbor:token#wood', '/dock/0': 'harbor:token#wood',
'/deck': 'harbor:token#grain', '/deck': 'harbor:token#grain',
+11
View File
@@ -40,16 +40,27 @@ const partSchema = z.object({
fillet: z.number().optional(), 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({ const surfaceSchema = z.object({
type: z.string().min(1), type: z.string().min(1),
id: z.string().min(1), id: z.string().min(1),
size: surfaceSize.optional(), size: surfaceSize.optional(),
mount: surfaceMount.optional(),
children: z.array(z.string()).optional(),
layout: z.array(route), layout: z.array(route),
}); });
const setupSchema = z.object({ const setupSchema = z.object({
type: z.string().min(1), type: z.string().min(1),
id: 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())])), setup: z.record(z.string(), z.union([z.string(), z.array(z.string())])),
}); });
+24 -1
View File
@@ -104,21 +104,44 @@ export interface Stacking {
steps?: number; 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. */ /** A view over the state store, purely for visual rendering. */
export interface Surface { export interface Surface {
type: string; type: string;
id: string; id: string;
/** Reference `[width, height]` in mm; may be scaled to fit the table. */ /** Reference `[width, height]` in mm; may be scaled to fit the table. */
size?: SurfaceSize; 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[]; layout: Route[];
} }
export type SetupValue = string | string[]; 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 { export interface Setup {
type: string; type: string;
id: string; id: string;
/** Surfaces (`type#id` refs) enabled at the start; omitted = all enabled. */
surfaces?: string[];
setup: Record<string, SetupValue>; setup: Record<string, SetupValue>;
} }
+1 -1
View File
@@ -67,7 +67,7 @@ describe('bgm vite plugin', () => {
// Consumers read the collections with Object.values / Object.keys. // 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.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']); expect(Object.keys(pkg.setups)).toEqual(['game#main']);
}); });