Model part face in tabletop state

Replace the path->part-list store with a per-part map keyed by part id, so each placed part carries its path, stack index, and face. Derive each path's ordered children for stacking, and flip face-down parts in PartPlacement.
This commit is contained in:
2026-08-10 11:10:16 +08:00
parent 6502fdae4f
commit bc418b2c73
8 changed files with 170 additions and 69 deletions
+89 -20
View File
@@ -1,8 +1,11 @@
/**
* 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
* 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.
*/
@@ -10,12 +13,22 @@ import { useMemo } from 'react';
import { create } from 'zustand';
import type { Candidate, 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;
/** Whether the part's face is up (`true`) or down (`false`). */
face: boolean;
}
/** 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[]>;
/** Part id (`package:type#id`) -> placement state. */
parts: Record<string, PartState>;
}
/** A single placed piece on a surface, ready for rendering. */
@@ -34,26 +47,59 @@ export interface Placement {
index: number;
/** The number of pieces on the path. */
stackSize: number;
/** Whether the piece's face is up. */
face: boolean;
}
interface TabletopState extends GameState {
setSurfaces: (surfaces: Record<string, boolean>) => void;
setPaths: (paths: Record<string, string[]>) => void;
setParts: (parts: Record<string, PartState>) => void;
seed: (state: GameState) => void;
enableSurface: (id: string) => void;
disableSurface: (id: string) => void;
setPath: (path: string, parts: string[]) => void;
setPart: (id: string, patch: Partial<PartState>) => void;
movePart: (id: string, path: string, index: number) => void;
}
export const useTabletopStore = create<TabletopState>((set) => ({
surfaces: {},
paths: {},
parts: {},
setSurfaces: (surfaces) => set({ surfaces }),
setPaths: (paths) => set({ paths }),
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 } })),
setPath: (path, parts) => set((s) => ({ paths: { ...s.paths, [path]: parts } })),
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 ---
@@ -87,24 +133,43 @@ export function matchRoute(route: Route, path: string): { candidate?: Candidate
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[] {
/**
* 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}`;
for (const [path, parts] of Object.entries(paths)) {
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 = parts.length;
for (const piece of parts) {
const stackSize = ids.length;
for (const piece of ids) {
const ps = parts[piece]!;
placements.push({
surface: surfaceId,
path,
route,
candidate: match.candidate,
piece,
index: parts.indexOf(piece),
index: ps.index,
stackSize,
face: ps.face,
});
}
}
@@ -118,19 +183,23 @@ export function computeRenderState(pkg: Package, state: GameState): Placement[]
if (!enabled) continue;
const surface = pkg.surfaces.get(surfaceId);
if (!surface) continue;
out.push(...computeSurfacePlacements(surface, state.paths));
out.push(...computeSurfacePlacements(surface, state.parts));
}
return out;
}
/** A stable key for a placement, unique across surfaces, paths, and pieces. */
/**
* 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.path}:${p.piece}`;
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]);
const parts = useTabletopStore((s) => s.parts);
return useMemo(() => computeRenderState(pkg, { surfaces, parts }), [pkg, surfaces, parts]);
}