A piece can appear on more than one path of the same surface (e.g. the poker deck expands to every card while the flop also places the ace), so the render key must include the path to stay unique.
136 lines
4.8 KiB
TypeScript
136 lines
4.8 KiB
TypeScript
/**
|
|
* 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 path key this placement came from. */
|
|
path: 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,
|
|
path,
|
|
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, paths, and pieces. */
|
|
export function placementKey(p: Placement): string {
|
|
return `${p.surface}:${p.path}:${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]);
|
|
} |