Replace the face/back boolean with a three-state facing (face, back, standing) seeded from setup placements. Parts now orient about the anchor at the resting face/edge, so back-down parts sit on the table instead of below it and standing parts rest on their bottom edge.
205 lines
7.4 KiB
TypeScript
205 lines
7.4 KiB
TypeScript
/**
|
|
* Source-of-truth game state and the derived render state.
|
|
*
|
|
* The store holds the enabled surfaces and a per-part placement map
|
|
* (`bgm-tabletop.md` §2). Each part on the board is keyed by its id
|
|
* (`package:type#id`) and records which path it's on, its index in that path's
|
|
* stack, and which face is up. A path's ordered children (for stacking) are
|
|
* derived from this map. 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, Facing, Package, Route, Surface } from '@tts/bgm';
|
|
|
|
/** The placement state of a single part on the board. */
|
|
export interface PartState {
|
|
/** The path key this part is on. */
|
|
path: string;
|
|
/** The part's position in its path's stack. */
|
|
index: number;
|
|
/** How the part is oriented on the board. */
|
|
facing: Facing;
|
|
}
|
|
|
|
/** Source-of-truth game state. */
|
|
export interface GameState {
|
|
/** Enabled per surface id (`type#id`). */
|
|
surfaces: Record<string, boolean>;
|
|
/** Part id (`package:type#id`) -> placement state. */
|
|
parts: Record<string, PartState>;
|
|
}
|
|
|
|
/** 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;
|
|
/** How the piece is oriented on the board. */
|
|
facing: Facing;
|
|
}
|
|
|
|
interface TabletopState extends GameState {
|
|
setSurfaces: (surfaces: Record<string, boolean>) => void;
|
|
setParts: (parts: Record<string, PartState>) => void;
|
|
seed: (state: GameState) => void;
|
|
enableSurface: (id: string) => void;
|
|
disableSurface: (id: string) => void;
|
|
setPart: (id: string, patch: Partial<PartState>) => void;
|
|
movePart: (id: string, path: string, index: number) => void;
|
|
}
|
|
|
|
export const useTabletopStore = create<TabletopState>((set) => ({
|
|
surfaces: {},
|
|
parts: {},
|
|
setSurfaces: (surfaces) => set({ surfaces }),
|
|
setParts: (parts) => set({ parts }),
|
|
seed: (state) => set(state),
|
|
enableSurface: (id) => set((s) => ({ surfaces: { ...s.surfaces, [id]: true } })),
|
|
disableSurface: (id) => set((s) => ({ surfaces: { ...s.surfaces, [id]: false } })),
|
|
setPart: (id, patch) =>
|
|
set((s) => {
|
|
const cur = s.parts[id];
|
|
if (!cur) return s;
|
|
return { parts: { ...s.parts, [id]: { ...cur, ...patch } } };
|
|
}),
|
|
movePart: (id, path, index) =>
|
|
set((s) => {
|
|
const cur = s.parts[id];
|
|
if (!cur) return s;
|
|
const parts = { ...s.parts };
|
|
// Siblings on the source path, in order, excluding the moved part.
|
|
const srcIds = Object.keys(parts)
|
|
.filter((pid) => pid !== id && parts[pid]!.path === cur.path)
|
|
.sort((a, b) => parts[a]!.index - parts[b]!.index);
|
|
// Siblings on the destination path, in order, excluding the moved part.
|
|
const dstIds = Object.keys(parts)
|
|
.filter((pid) => pid !== id && parts[pid]!.path === path)
|
|
.sort((a, b) => parts[a]!.index - parts[b]!.index);
|
|
// Reindex the source path so the gap closes.
|
|
srcIds.forEach((pid, i) => {
|
|
parts[pid] = { ...parts[pid]!, index: i };
|
|
});
|
|
// Reindex the destination path with the moved part inserted at `index`.
|
|
const clamped = Math.max(0, Math.min(index, dstIds.length));
|
|
dstIds.forEach((pid, i) => {
|
|
parts[pid] = { ...parts[pid]!, index: i >= clamped ? i + 1 : i };
|
|
});
|
|
parts[id] = { ...cur, path, index: clamped };
|
|
return { 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 };
|
|
}
|
|
|
|
/**
|
|
* Derive each path's ordered children from the parts map. A path's children are
|
|
* its part ids sorted by `index`, used for stacking (`stackSize` and per-piece
|
|
* `index`).
|
|
*/
|
|
export function childrenByPath(parts: Record<string, PartState>): Record<string, string[]> {
|
|
const children: Record<string, string[]> = {};
|
|
for (const [id, ps] of Object.entries(parts)) {
|
|
(children[ps.path] ??= []).push(id);
|
|
}
|
|
for (const list of Object.values(children)) {
|
|
list.sort((a, b) => parts[a]!.index - parts[b]!.index);
|
|
}
|
|
return children;
|
|
}
|
|
|
|
/** Compute the placements for a single surface from the game state's parts. */
|
|
export function computeSurfacePlacements(surface: Surface, parts: Record<string, PartState>): Placement[] {
|
|
const placements: Placement[] = [];
|
|
const surfaceId = `${surface.type}#${surface.id}`;
|
|
const children = childrenByPath(parts);
|
|
for (const [path, ids] of Object.entries(children)) {
|
|
const route = surface.layout.find((r) => matchRoute(r, path));
|
|
if (!route) continue;
|
|
const match = matchRoute(route, path)!;
|
|
const stackSize = ids.length;
|
|
for (const piece of ids) {
|
|
const ps = parts[piece]!;
|
|
placements.push({
|
|
surface: surfaceId,
|
|
path,
|
|
route,
|
|
candidate: match.candidate,
|
|
piece,
|
|
index: ps.index,
|
|
stackSize,
|
|
facing: ps.facing,
|
|
});
|
|
}
|
|
}
|
|
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.parts));
|
|
}
|
|
return out;
|
|
}
|
|
|
|
/**
|
|
* A stable key for a placement, unique across surfaces and pieces. A piece is
|
|
* on exactly one path, so `path` is implied; it may still render on more than
|
|
* one enabled surface, so the surface is part of the key.
|
|
*/
|
|
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 parts = useTabletopStore((s) => s.parts);
|
|
return useMemo(() => computeRenderState(pkg, { surfaces, parts }), [pkg, surfaces, parts]);
|
|
} |