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:
@@ -80,15 +80,22 @@ Source-of-truth game state per `bgm-tabletop.md` §2:
|
|||||||
```ts
|
```ts
|
||||||
interface GameState {
|
interface GameState {
|
||||||
surfaces: Record<string, boolean>; // enabled per surface id
|
surfaces: Record<string, boolean>; // enabled per surface id
|
||||||
paths: Record<string, string[]>; // path -> part list
|
parts: Record<string, PartState>; // part id -> placement state
|
||||||
|
}
|
||||||
|
|
||||||
|
interface PartState {
|
||||||
|
path: string; // the path key this part is on
|
||||||
|
index: number; // the part's position in its path's stack
|
||||||
|
face: boolean; // whether the part's face is up
|
||||||
}
|
}
|
||||||
```
|
```
|
||||||
|
|
||||||
- A zustand store holding `GameState`.
|
- A zustand store holding `GameState`.
|
||||||
- **Derived render state**: `game state + surface routes => map of piece id to
|
- **Derived render state**: `game state + surface routes => map of piece id to
|
||||||
`{ surface, route, candidate, index, stackSize }``, per enabled surface.
|
`{ surface, route, candidate, index, stackSize, face }``, per enabled surface.
|
||||||
Computed with a selector/memo so the render list is stable.
|
Computed with a selector/memo so the render list is stable. A path's ordered
|
||||||
- **Assumption**: each piece id is unique within a path (documented in
|
children (for stacking) are derived from the parts map by sorting on `index`.
|
||||||
|
- **Assumption**: each piece id is unique on the board (documented in
|
||||||
`bgm-tabletop.md`); the render map is keyed by piece id.
|
`bgm-tabletop.md`); the render map is keyed by piece id.
|
||||||
|
|
||||||
### 4. Setup seeding (`setup.ts`) ✅
|
### 4. Setup seeding (`setup.ts`) ✅
|
||||||
|
|||||||
+10
-3
@@ -15,18 +15,25 @@ source-of-truth game state:
|
|||||||
```ts
|
```ts
|
||||||
{
|
{
|
||||||
surfaces: Record<string, boolean>, // enabled per surface id
|
surfaces: Record<string, boolean>, // enabled per surface id
|
||||||
paths: Record<string, string[]>, // path -> part list
|
parts: Record<string, PartState>, // part id -> placement state
|
||||||
|
}
|
||||||
|
|
||||||
|
interface PartState {
|
||||||
|
path: string, // the path key this part is on
|
||||||
|
index: number, // the part's position in its path's stack
|
||||||
|
face: boolean, // whether the part's face is up
|
||||||
}
|
}
|
||||||
```
|
```
|
||||||
|
|
||||||
**assumption:** each piece on the board has a unique id, even tokens of the same type. so each entry in a path's list is a unique piece id, and a piece id never appears twice in the same path. this makes the render list keyed by piece id stable and unambiguous.
|
**assumption:** each piece on the board has a unique id, even tokens of the same type. so a part id appears at most once, and a path's ordered children (for stacking) are derived from the map by sorting on `index`. this makes the render list keyed by piece id stable and unambiguous.
|
||||||
|
|
||||||
derived surface render state: game state + surface routes => map of piece id to `{ surface, route, candidate, index, stackSize }` for rendering on a surface. keys of this map makes a stable render list.
|
derived surface render state: game state + surface routes => map of piece id to `{ surface, route, candidate, index, stackSize, face }` for rendering on a surface. keys of this map makes a stable render list.
|
||||||
|
|
||||||
- `route` - the matched route.
|
- `route` - the matched route.
|
||||||
- `candidate` - the matched candidate for a `:param` route, carrying its anchor `x`/`y`/`rotation`. absent for routes without candidates.
|
- `candidate` - the matched candidate for a `:param` route, carrying its anchor `x`/`y`/`rotation`. absent for routes without candidates.
|
||||||
- `index` - the piece's position in its path's stack.
|
- `index` - the piece's position in its path's stack.
|
||||||
- `stackSize` - the number of pieces on the path.
|
- `stackSize` - the number of pieces on the path.
|
||||||
|
- `face` - whether the piece's face is up.
|
||||||
|
|
||||||
the render map is per enabled surface: a piece may appear on more than one enabled surface (e.g. an expansion path and the main board), and each is rendered independently.
|
the render map is per enabled surface: a piece may appear on more than one enabled surface (e.g. an expansion path and the main board), and each is rendered independently.
|
||||||
|
|
||||||
|
|||||||
@@ -24,7 +24,7 @@ export {
|
|||||||
computeSurfacePlacements,
|
computeSurfacePlacements,
|
||||||
placementKey,
|
placementKey,
|
||||||
} from './state.js';
|
} from './state.js';
|
||||||
export type { GameState, Placement } from './state.js';
|
export type { GameState, Placement, PartState } from './state.js';
|
||||||
|
|
||||||
// Setup seeding.
|
// Setup seeding.
|
||||||
export { SetupLoader, seedFromSetup, expandSetupValue } from './setup.js';
|
export { SetupLoader, seedFromSetup, expandSetupValue } from './setup.js';
|
||||||
|
|||||||
@@ -12,7 +12,7 @@ import { PartView } from './partView.js';
|
|||||||
import { MM_TO_WORLD, DEG_TO_RAD } from './part.js';
|
import { MM_TO_WORLD, DEG_TO_RAD } from './part.js';
|
||||||
|
|
||||||
export function PartPlacement({ pkg, placement }: { pkg: Package; placement: Placement }) {
|
export function PartPlacement({ pkg, placement }: { pkg: Package; placement: Placement }) {
|
||||||
const { route, candidate, piece, index, stackSize } = placement;
|
const { route, candidate, piece, index, stackSize, face } = placement;
|
||||||
// `piece` is `package:type#id`; the parts map is keyed by `type#id`.
|
// `piece` is `package:type#id`; the parts map is keyed by `type#id`.
|
||||||
const part = pkg.parts.get(piece.split(':').slice(1).join(':'));
|
const part = pkg.parts.get(piece.split(':').slice(1).join(':'));
|
||||||
if (!part) return null;
|
if (!part) return null;
|
||||||
@@ -36,10 +36,13 @@ export function PartPlacement({ pkg, placement }: { pkg: Package; placement: Pla
|
|||||||
|
|
||||||
return (
|
return (
|
||||||
<group position={[anchorX, anchorZ, anchorY]} rotation={[0, anchorRotation, 0]}>
|
<group position={[anchorX, anchorZ, anchorY]} rotation={[0, anchorRotation, 0]}>
|
||||||
{/* The part mesh extrudes along +Z; lay it flat so its face points up. */}
|
{/* The part mesh extrudes along +Z; lay it flat so its face points up.
|
||||||
|
A face-down part is flipped over about its local X axis. */}
|
||||||
<group rotation={[-Math.PI / 2, tilt * DEG_TO_RAD, 0]}>
|
<group rotation={[-Math.PI / 2, tilt * DEG_TO_RAD, 0]}>
|
||||||
|
<group rotation={!face ? [Math.PI, 0, 0] : [0, 0, 0]}>
|
||||||
<PartView part={part} baseUrl={part.baseUrl} />
|
<PartView part={part} baseUrl={part.baseUrl} />
|
||||||
</group>
|
</group>
|
||||||
</group>
|
</group>
|
||||||
|
</group>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -44,7 +44,7 @@ describe('seedFromSetup', () => {
|
|||||||
};
|
};
|
||||||
const state = seedFromSetup(pkg, setup);
|
const state = seedFromSetup(pkg, setup);
|
||||||
expect(state.surfaces).toEqual({ 'board#harbor': true });
|
expect(state.surfaces).toEqual({ 'board#harbor': true });
|
||||||
expect(state.paths).toEqual({ '/deck': ['harbor:card#fleet'] });
|
expect(state.parts).toEqual({ 'harbor:card#fleet': { path: '/deck', index: 0, face: true } });
|
||||||
});
|
});
|
||||||
|
|
||||||
it('enables all surfaces when omitted', () => {
|
it('enables all surfaces when omitted', () => {
|
||||||
|
|||||||
@@ -7,7 +7,7 @@
|
|||||||
*/
|
*/
|
||||||
import { useEffect } from 'react';
|
import { useEffect } from 'react';
|
||||||
import type { Package, Setup } from '@tts/bgm';
|
import type { Package, Setup } from '@tts/bgm';
|
||||||
import { useTabletopStore } from './state.js';
|
import { useTabletopStore, type PartState } from './state.js';
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Expand a setup value into a list of part ids (`package:type#id`). A bare
|
* Expand a setup value into a list of part ids (`package:type#id`). A bare
|
||||||
@@ -39,7 +39,7 @@ export function expandSetupValue(
|
|||||||
/** Seed the store from a setup. Returns the resulting game state. */
|
/** Seed the store from a setup. Returns the resulting game state. */
|
||||||
export function seedFromSetup(pkg: Package, setup: Setup): {
|
export function seedFromSetup(pkg: Package, setup: Setup): {
|
||||||
surfaces: Record<string, boolean>;
|
surfaces: Record<string, boolean>;
|
||||||
paths: Record<string, string[]>;
|
parts: Record<string, PartState>;
|
||||||
} {
|
} {
|
||||||
const surfaces: Record<string, boolean> = {};
|
const surfaces: Record<string, boolean> = {};
|
||||||
if (setup.surfaces) {
|
if (setup.surfaces) {
|
||||||
@@ -48,11 +48,14 @@ export function seedFromSetup(pkg: Package, setup: Setup): {
|
|||||||
for (const key of pkg.surfaces.keys()) surfaces[key] = true;
|
for (const key of pkg.surfaces.keys()) surfaces[key] = true;
|
||||||
}
|
}
|
||||||
|
|
||||||
const paths: Record<string, string[]> = {};
|
const parts: Record<string, PartState> = {};
|
||||||
for (const [path, value] of Object.entries(setup.setup)) {
|
for (const [path, value] of Object.entries(setup.setup)) {
|
||||||
paths[path] = expandSetupValue(pkg, value);
|
const ids = expandSetupValue(pkg, value);
|
||||||
|
ids.forEach((id, index) => {
|
||||||
|
parts[id] = { path, index, face: true };
|
||||||
|
});
|
||||||
}
|
}
|
||||||
return { surfaces, paths };
|
return { surfaces, parts };
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
|
|||||||
@@ -1,6 +1,12 @@
|
|||||||
import { describe, expect, it } from 'vitest';
|
import { describe, expect, it } from 'vitest';
|
||||||
import type { Package, Surface } from '@tts/bgm';
|
import type { Package, Surface } from '@tts/bgm';
|
||||||
import { matchRoute, computeSurfacePlacements, computeRenderState, placementKey } from './state.js';
|
import {
|
||||||
|
matchRoute,
|
||||||
|
childrenByPath,
|
||||||
|
computeSurfacePlacements,
|
||||||
|
computeRenderState,
|
||||||
|
placementKey,
|
||||||
|
} from './state.js';
|
||||||
|
|
||||||
function makeSurface(overrides: Partial<Surface> = {}): Surface {
|
function makeSurface(overrides: Partial<Surface> = {}): Surface {
|
||||||
return {
|
return {
|
||||||
@@ -59,24 +65,39 @@ describe('matchRoute', () => {
|
|||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
describe('childrenByPath', () => {
|
||||||
|
it('groups parts by path, ordered by index', () => {
|
||||||
|
const parts = {
|
||||||
|
'harbor:card#a': { path: '/deck', index: 1, face: true },
|
||||||
|
'harbor:card#b': { path: '/deck', index: 0, face: false },
|
||||||
|
'harbor:card#c': { path: '/community/0', index: 0, face: true },
|
||||||
|
};
|
||||||
|
expect(childrenByPath(parts)).toEqual({
|
||||||
|
'/deck': ['harbor:card#b', 'harbor:card#a'],
|
||||||
|
'/community/0': ['harbor:card#c'],
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
describe('computeSurfacePlacements', () => {
|
describe('computeSurfacePlacements', () => {
|
||||||
it('places parts on a matching route with index and stackSize', () => {
|
it('places parts on a matching route with index, stackSize, and face', () => {
|
||||||
const surface = makeSurface({
|
const surface = makeSurface({
|
||||||
layout: [{ route: '/deck', x: -100, y: 0, rotation: 0 }],
|
layout: [{ route: '/deck', x: -100, y: 0, rotation: 0 }],
|
||||||
});
|
});
|
||||||
const placements = computeSurfacePlacements(surface, {
|
const placements = computeSurfacePlacements(surface, {
|
||||||
'/deck': ['harbor:card#a', 'harbor:card#b'],
|
'harbor:card#a': { path: '/deck', index: 0, face: true },
|
||||||
|
'harbor:card#b': { path: '/deck', index: 1, face: false },
|
||||||
});
|
});
|
||||||
expect(placements).toHaveLength(2);
|
expect(placements).toHaveLength(2);
|
||||||
expect(placements[0]).toMatchObject({ piece: 'harbor:card#a', index: 0, stackSize: 2 });
|
expect(placements[0]).toMatchObject({ piece: 'harbor:card#a', index: 0, stackSize: 2, face: true });
|
||||||
expect(placements[1]).toMatchObject({ piece: 'harbor:card#b', index: 1, stackSize: 2 });
|
expect(placements[1]).toMatchObject({ piece: 'harbor:card#b', index: 1, stackSize: 2, face: false });
|
||||||
});
|
});
|
||||||
|
|
||||||
it('drops parts with no matching route', () => {
|
it('drops parts with no matching route', () => {
|
||||||
const surface = makeSurface({ layout: [{ route: '/deck', x: 0, y: 0, rotation: 0 }] });
|
const surface = makeSurface({ layout: [{ route: '/deck', x: 0, y: 0, rotation: 0 }] });
|
||||||
const placements = computeSurfacePlacements(surface, {
|
const placements = computeSurfacePlacements(surface, {
|
||||||
'/deck': ['harbor:card#a'],
|
'harbor:card#a': { path: '/deck', index: 0, face: true },
|
||||||
'/elsewhere': ['harbor:card#b'],
|
'harbor:card#b': { path: '/elsewhere', index: 0, face: true },
|
||||||
});
|
});
|
||||||
expect(placements).toHaveLength(1);
|
expect(placements).toHaveLength(1);
|
||||||
expect(placements[0]!.piece).toBe('harbor:card#a');
|
expect(placements[0]!.piece).toBe('harbor:card#a');
|
||||||
@@ -94,7 +115,9 @@ describe('computeSurfacePlacements', () => {
|
|||||||
},
|
},
|
||||||
],
|
],
|
||||||
});
|
});
|
||||||
const placements = computeSurfacePlacements(surface, { '/dock/0': ['harbor:boat#fleet'] });
|
const placements = computeSurfacePlacements(surface, {
|
||||||
|
'harbor:boat#fleet': { path: '/dock/0', index: 0, face: true },
|
||||||
|
});
|
||||||
expect(placements[0]!.candidate).toEqual({ seat: '0', x: 40, y: 5, rotation: 1 });
|
expect(placements[0]!.candidate).toEqual({ seat: '0', x: 40, y: 5, rotation: 1 });
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -110,29 +133,21 @@ describe('computeSurfacePlacements', () => {
|
|||||||
},
|
},
|
||||||
],
|
],
|
||||||
});
|
});
|
||||||
const placements = computeSurfacePlacements(surface, { '/dock/0': ['harbor:boat#fleet'] });
|
const placements = computeSurfacePlacements(surface, {
|
||||||
|
'harbor:boat#fleet': { path: '/dock/0', index: 0, face: true },
|
||||||
|
});
|
||||||
expect(placements[0]!.candidate).toEqual({ seat: '0', x: 40, y: 5, stacking: { tilt: 2 } });
|
expect(placements[0]!.candidate).toEqual({ seat: '0', x: 40, y: 5, stacking: { tilt: 2 } });
|
||||||
});
|
});
|
||||||
|
|
||||||
it('keeps the same piece on two paths as distinct placements', () => {
|
it('orders a path by index regardless of insertion order', () => {
|
||||||
const surface = makeSurface({
|
const surface = makeSurface({
|
||||||
layout: [
|
layout: [{ route: '/deck', x: 0, y: 0, rotation: 0 }],
|
||||||
{ route: '/deck', x: 0, y: 0, rotation: 0 },
|
|
||||||
{ route: '/community/:slot', x: 0, y: 0, rotation: 0 },
|
|
||||||
],
|
|
||||||
});
|
});
|
||||||
// The deck expands to every card (including `as`); the flop also places `as`.
|
|
||||||
const placements = computeSurfacePlacements(surface, {
|
const placements = computeSurfacePlacements(surface, {
|
||||||
'/deck': ['poker:card#as', 'poker:card#kh'],
|
'harbor:card#b': { path: '/deck', index: 1, face: true },
|
||||||
'/community/0': ['poker:card#as'],
|
'harbor:card#a': { path: '/deck', index: 0, face: true },
|
||||||
});
|
});
|
||||||
expect(placements).toHaveLength(3);
|
expect(placements.map((p) => p.piece)).toEqual(['harbor:card#a', 'harbor:card#b']);
|
||||||
const deck = placements.filter((p) => p.path === '/deck');
|
|
||||||
const flop = placements.filter((p) => p.path === '/community/0');
|
|
||||||
expect(deck).toHaveLength(2);
|
|
||||||
expect(flop).toHaveLength(1);
|
|
||||||
// The same piece on two paths yields distinct placement keys.
|
|
||||||
expect(placementKey(deck[0]!)).not.toBe(placementKey(flop[0]!));
|
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -140,7 +155,7 @@ describe('computeRenderState', () => {
|
|||||||
it('only includes enabled surfaces', () => {
|
it('only includes enabled surfaces', () => {
|
||||||
const state = {
|
const state = {
|
||||||
surfaces: { 'board#harbor': true, 'hud#hand': false },
|
surfaces: { 'board#harbor': true, 'hud#hand': false },
|
||||||
paths: { '/deck': ['harbor:card#a'] },
|
parts: { 'harbor:card#a': { path: '/deck', index: 0, face: true } },
|
||||||
};
|
};
|
||||||
pkg.surfaces.set(
|
pkg.surfaces.set(
|
||||||
'board#harbor',
|
'board#harbor',
|
||||||
@@ -153,14 +168,11 @@ describe('computeRenderState', () => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
describe('placementKey', () => {
|
describe('placementKey', () => {
|
||||||
it('is unique per surface, path, and piece', () => {
|
it('is unique per surface and piece', () => {
|
||||||
const a = { surface: 'board#harbor', path: '/deck', piece: 'harbor:card#a' } as never;
|
const a = { surface: 'board#harbor', piece: 'harbor:card#a' } as never;
|
||||||
const b = { surface: 'board#harbor', path: '/deck', piece: 'harbor:card#b' } as never;
|
const b = { surface: 'board#harbor', piece: 'harbor:card#b' } as never;
|
||||||
const c = { surface: 'hud#hand', path: '/deck', piece: 'harbor:card#a' } as never;
|
const c = { surface: 'hud#hand', piece: 'harbor:card#a' } as never;
|
||||||
// The same piece on two paths of the same surface is a distinct placement.
|
|
||||||
const d = { surface: 'board#harbor', path: '/community/0', piece: 'harbor:card#a' } as never;
|
|
||||||
expect(placementKey(a)).not.toBe(placementKey(b));
|
expect(placementKey(a)).not.toBe(placementKey(b));
|
||||||
expect(placementKey(a)).not.toBe(placementKey(c));
|
expect(placementKey(a)).not.toBe(placementKey(c));
|
||||||
expect(placementKey(a)).not.toBe(placementKey(d));
|
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
@@ -1,8 +1,11 @@
|
|||||||
/**
|
/**
|
||||||
* Source-of-truth game state and the derived render state.
|
* Source-of-truth game state and the derived render state.
|
||||||
*
|
*
|
||||||
* The store holds the enabled surfaces and the path -> part placement map
|
* The store holds the enabled surfaces and a per-part placement map
|
||||||
* (`bgm-tabletop.md` §2). The derived render state is computed from the game
|
* (`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
|
* state plus a package's surface routes: a stable list of placements, one per
|
||||||
* (surface, piece) pair, keyed for rendering.
|
* (surface, piece) pair, keyed for rendering.
|
||||||
*/
|
*/
|
||||||
@@ -10,12 +13,22 @@ import { useMemo } from 'react';
|
|||||||
import { create } from 'zustand';
|
import { create } from 'zustand';
|
||||||
import type { Candidate, Package, Route, Surface } from '@tts/bgm';
|
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. */
|
/** Source-of-truth game state. */
|
||||||
export interface GameState {
|
export interface GameState {
|
||||||
/** Enabled per surface id (`type#id`). */
|
/** Enabled per surface id (`type#id`). */
|
||||||
surfaces: Record<string, boolean>;
|
surfaces: Record<string, boolean>;
|
||||||
/** Path -> part list (`package:type#id`). */
|
/** Part id (`package:type#id`) -> placement state. */
|
||||||
paths: Record<string, string[]>;
|
parts: Record<string, PartState>;
|
||||||
}
|
}
|
||||||
|
|
||||||
/** A single placed piece on a surface, ready for rendering. */
|
/** A single placed piece on a surface, ready for rendering. */
|
||||||
@@ -34,26 +47,59 @@ export interface Placement {
|
|||||||
index: number;
|
index: number;
|
||||||
/** The number of pieces on the path. */
|
/** The number of pieces on the path. */
|
||||||
stackSize: number;
|
stackSize: number;
|
||||||
|
/** Whether the piece's face is up. */
|
||||||
|
face: boolean;
|
||||||
}
|
}
|
||||||
|
|
||||||
interface TabletopState extends GameState {
|
interface TabletopState extends GameState {
|
||||||
setSurfaces: (surfaces: Record<string, boolean>) => void;
|
setSurfaces: (surfaces: Record<string, boolean>) => void;
|
||||||
setPaths: (paths: Record<string, string[]>) => void;
|
setParts: (parts: Record<string, PartState>) => void;
|
||||||
seed: (state: GameState) => void;
|
seed: (state: GameState) => void;
|
||||||
enableSurface: (id: string) => void;
|
enableSurface: (id: string) => void;
|
||||||
disableSurface: (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) => ({
|
export const useTabletopStore = create<TabletopState>((set) => ({
|
||||||
surfaces: {},
|
surfaces: {},
|
||||||
paths: {},
|
parts: {},
|
||||||
setSurfaces: (surfaces) => set({ surfaces }),
|
setSurfaces: (surfaces) => set({ surfaces }),
|
||||||
setPaths: (paths) => set({ paths }),
|
setParts: (parts) => set({ parts }),
|
||||||
seed: (state) => set(state),
|
seed: (state) => set(state),
|
||||||
enableSurface: (id) => set((s) => ({ surfaces: { ...s.surfaces, [id]: true } })),
|
enableSurface: (id) => set((s) => ({ surfaces: { ...s.surfaces, [id]: true } })),
|
||||||
disableSurface: (id) => set((s) => ({ surfaces: { ...s.surfaces, [id]: false } })),
|
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 ---
|
// --- Route matching ---
|
||||||
@@ -87,24 +133,43 @@ export function matchRoute(route: Route, path: string): { candidate?: Candidate
|
|||||||
return { candidate: undefined };
|
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 placements: Placement[] = [];
|
||||||
const surfaceId = `${surface.type}#${surface.id}`;
|
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));
|
const route = surface.layout.find((r) => matchRoute(r, path));
|
||||||
if (!route) continue;
|
if (!route) continue;
|
||||||
const match = matchRoute(route, path)!;
|
const match = matchRoute(route, path)!;
|
||||||
const stackSize = parts.length;
|
const stackSize = ids.length;
|
||||||
for (const piece of parts) {
|
for (const piece of ids) {
|
||||||
|
const ps = parts[piece]!;
|
||||||
placements.push({
|
placements.push({
|
||||||
surface: surfaceId,
|
surface: surfaceId,
|
||||||
path,
|
path,
|
||||||
route,
|
route,
|
||||||
candidate: match.candidate,
|
candidate: match.candidate,
|
||||||
piece,
|
piece,
|
||||||
index: parts.indexOf(piece),
|
index: ps.index,
|
||||||
stackSize,
|
stackSize,
|
||||||
|
face: ps.face,
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -118,19 +183,23 @@ export function computeRenderState(pkg: Package, state: GameState): Placement[]
|
|||||||
if (!enabled) continue;
|
if (!enabled) continue;
|
||||||
const surface = pkg.surfaces.get(surfaceId);
|
const surface = pkg.surfaces.get(surfaceId);
|
||||||
if (!surface) continue;
|
if (!surface) continue;
|
||||||
out.push(...computeSurfacePlacements(surface, state.paths));
|
out.push(...computeSurfacePlacements(surface, state.parts));
|
||||||
}
|
}
|
||||||
return out;
|
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 {
|
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. */
|
/** The derived render state for a package, from the current game state. */
|
||||||
export function useRenderState(pkg: Package): Placement[] {
|
export function useRenderState(pkg: Package): Placement[] {
|
||||||
const surfaces = useTabletopStore((s) => s.surfaces);
|
const surfaces = useTabletopStore((s) => s.surfaces);
|
||||||
const paths = useTabletopStore((s) => s.paths);
|
const parts = useTabletopStore((s) => s.parts);
|
||||||
return useMemo(() => computeRenderState(pkg, { surfaces, paths }), [pkg, surfaces, paths]);
|
return useMemo(() => computeRenderState(pkg, { surfaces, parts }), [pkg, surfaces, parts]);
|
||||||
}
|
}
|
||||||
Reference in New Issue
Block a user