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:
@@ -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]);
|
||||
}
|
||||
Reference in New Issue
Block a user