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.
This commit is contained in:
@@ -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"
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
```
|
||||
@@ -0,0 +1,10 @@
|
||||
<!doctype html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="UTF-8" />
|
||||
<title>tabletop build fixture</title>
|
||||
</head>
|
||||
<body>
|
||||
<script type="module" src="/src/main.ts"></script>
|
||||
</body>
|
||||
</html>
|
||||
@@ -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);
|
||||
@@ -9,4 +9,31 @@ export {
|
||||
traceToShape,
|
||||
traceToUvBounds,
|
||||
} from './part.js';
|
||||
export { resolveAssetUrl, assetUrl, traceImage } from './http.js';
|
||||
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';
|
||||
@@ -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<Surface> = {}): [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');
|
||||
});
|
||||
});
|
||||
@@ -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<string, Surface>,
|
||||
enabled: Set<string>,
|
||||
): MountTree {
|
||||
const nodes = new Map<string, MountNode>();
|
||||
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<string>();
|
||||
|
||||
// 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<string, MountNode>, childId: string): MountNode | undefined {
|
||||
for (const node of nodes.values()) {
|
||||
if (node.surface.children?.includes(childId)) return node;
|
||||
}
|
||||
return undefined;
|
||||
}
|
||||
@@ -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 (
|
||||
<group position={[anchorX, 0, anchorY]} rotation={[0, anchorRotation, 0]}>
|
||||
<PartView part={part} baseUrl={part.baseUrl} />
|
||||
</group>
|
||||
);
|
||||
}
|
||||
@@ -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 });
|
||||
});
|
||||
});
|
||||
@@ -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<string, boolean>;
|
||||
paths: Record<string, string[]>;
|
||||
} {
|
||||
const surfaces: Record<string, boolean> = {};
|
||||
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<string, string[]> = {};
|
||||
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;
|
||||
}
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
@@ -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 };
|
||||
}
|
||||
@@ -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> = {}): 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));
|
||||
});
|
||||
});
|
||||
@@ -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<string, boolean>;
|
||||
/** Path -> part list (`package:type#id`). */
|
||||
paths: Record<string, string[]>;
|
||||
}
|
||||
|
||||
/** 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<string, boolean>) => void;
|
||||
setPaths: (paths: Record<string, string[]>) => void;
|
||||
seed: (state: GameState) => void;
|
||||
enableSurface: (id: string) => void;
|
||||
disableSurface: (id: string) => void;
|
||||
setPath: (path: string, parts: string[]) => void;
|
||||
}
|
||||
|
||||
export const useTabletopStore = create<TabletopState>((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<string, string> = {};
|
||||
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<string, string[]>): 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]);
|
||||
}
|
||||
@@ -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 (
|
||||
<Html
|
||||
position={[node.x, 0, node.y]}
|
||||
transform
|
||||
distanceFactor={1}
|
||||
style={{ pointerEvents: 'none' }}
|
||||
>
|
||||
{own.map((p) => (
|
||||
<PartPlacement key={`${p.surface}:${p.piece}`} pkg={pkg} placement={p} />
|
||||
))}
|
||||
{node.children.map((child) => (
|
||||
<SurfaceNode key={child.id} pkg={pkg} node={child} />
|
||||
))}
|
||||
</Html>
|
||||
);
|
||||
}
|
||||
@@ -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 <SurfaceNode pkg={pkg} node={node} />;
|
||||
}
|
||||
|
||||
/** 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 (
|
||||
<group position={[node.x, 0, node.y]} rotation={[0, node.rotation, 0]}>
|
||||
{own.map((p) => (
|
||||
<PartPlacement key={`${p.surface}:${p.piece}`} pkg={pkg} placement={p} />
|
||||
))}
|
||||
{node.children.map((child) => (
|
||||
<SurfaceNode key={child.id} pkg={pkg} node={child} />
|
||||
))}
|
||||
</group>
|
||||
);
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
@@ -7,5 +7,5 @@
|
||||
"jsx": "react-jsx"
|
||||
},
|
||||
"include": ["src"],
|
||||
"exclude": ["src/**/*.test.ts"]
|
||||
"exclude": ["src/__fixtures__", "src/**/*.test.ts"]
|
||||
}
|
||||
Reference in New Issue
Block a user