From d663f4afeae5d91815ad7010ecb67f7b44d8758c Mon Sep 17 00:00:00 2001 From: hypercross Date: Mon, 10 Aug 2026 11:47:58 +0800 Subject: [PATCH] feat: add facing orientation to parts 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. --- docs/bgm-format.md | 14 ++++++++++++++ docs/bgm-tabletop-plan.md | 2 +- docs/bgm-tabletop.md | 6 +++--- packages/bgm/src/schemas.ts | 1 + packages/bgm/src/types.ts | 8 ++++++++ packages/tabletop/src/index.ts | 1 + packages/tabletop/src/part.test.ts | 20 +++++++++++++++++++ packages/tabletop/src/part.ts | 27 +++++++++++++++++++++++++- packages/tabletop/src/placement.tsx | 16 +++++++++------ packages/tabletop/src/setup.test.ts | 23 +++++++++++++++++++--- packages/tabletop/src/setup.ts | 2 +- packages/tabletop/src/state.test.ts | 30 ++++++++++++++--------------- packages/tabletop/src/state.ts | 12 ++++++------ 13 files changed, 126 insertions(+), 36 deletions(-) diff --git a/docs/bgm-format.md b/docs/bgm-format.md index 4ff426f..8970580 100644 --- a/docs/bgm-format.md +++ b/docs/bgm-format.md @@ -272,6 +272,10 @@ setup: parts: harbor:boat#fleet - path: /deck parts: harbor:card + facing: back + - path: /table + parts: harbor:token#wood + facing: standing ``` `surfaces` lists the surfaces enabled at the start. A surface not listed is @@ -287,6 +291,16 @@ the deck, then move these cards to the flop". either. A bare type expands to all parts of that type during game state initialization. +`facing` sets how the placed parts are oriented on the board, defaulting to +`face`: + +- `face` — lay flat, front up, resting on the bottom face. +- `back` — lay flat, front down (flipped over), resting on the top face. +- `standing` — stand upright on the bottom edge, front texture still showing. + +A part's `facing` is seeded into the game state and can change at runtime; it +only affects orientation, never the part's texture. + --- ## 4. Concepts diff --git a/docs/bgm-tabletop-plan.md b/docs/bgm-tabletop-plan.md index a015ae0..ba1e1e4 100644 --- a/docs/bgm-tabletop-plan.md +++ b/docs/bgm-tabletop-plan.md @@ -86,7 +86,7 @@ interface GameState { 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 + facing: 'face' | 'back' | 'standing'; // how the part is oriented on the board } ``` diff --git a/docs/bgm-tabletop.md b/docs/bgm-tabletop.md index 1f05270..fcd7d39 100644 --- a/docs/bgm-tabletop.md +++ b/docs/bgm-tabletop.md @@ -21,19 +21,19 @@ source-of-truth game 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 + facing: 'face' | 'back' | 'standing', // how the part is oriented on the board } ``` **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, face }` 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, facing }` for rendering on a surface. keys of this map makes a stable render list. - `route` - the matched route. - `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. - `stackSize` - the number of pieces on the path. -- `face` - whether the piece's face is up. +- `facing` - how the piece is oriented on the board (`face` / `back` / `standing`). 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. diff --git a/packages/bgm/src/schemas.ts b/packages/bgm/src/schemas.ts index 7c49860..c6916cb 100644 --- a/packages/bgm/src/schemas.ts +++ b/packages/bgm/src/schemas.ts @@ -65,6 +65,7 @@ const setupValue = z.union([z.string(), z.array(z.string())]); const setupPlacement = z.object({ path: z.string(), parts: setupValue, + facing: z.enum(['face', 'back', 'standing']).optional(), }); const setupSchema = z.object({ diff --git a/packages/bgm/src/types.ts b/packages/bgm/src/types.ts index abec3dd..8114ead 100644 --- a/packages/bgm/src/types.ts +++ b/packages/bgm/src/types.ts @@ -157,6 +157,12 @@ export interface Surface { export type SetupValue = string | string[]; +/** + * How a part is oriented on the board. `face` lays it flat front-up, `back` + * flips it over front-down, and `standing` stands it on its bottom edge. + */ +export type Facing = 'face' | 'back' | 'standing'; + /** * One setup placement: move `parts` to `path`. Entries are applied in order, * so a part listed in a later entry ends up on that entry's path. @@ -166,6 +172,8 @@ export interface SetupPlacement { path: string; /** Parts to place: a part id, a bare type (expands to all of that type), or a list of either. */ parts: SetupValue; + /** Initial facing for the placed parts; defaults to `face`. */ + facing?: Facing; } /** Seeds the state store: the enabled surfaces and an ordered list of placements. */ diff --git a/packages/tabletop/src/index.ts b/packages/tabletop/src/index.ts index 61b9e8b..b26aebf 100644 --- a/packages/tabletop/src/index.ts +++ b/packages/tabletop/src/index.ts @@ -8,6 +8,7 @@ export { fallbackShape, traceToShape, traceToUvBounds, + facingTransform, MM_TO_WORLD, } from './part.js'; export { resolveAssetUrl, assetUrl, traceImage } from './http.js'; diff --git a/packages/tabletop/src/part.test.ts b/packages/tabletop/src/part.test.ts index 79900c5..77fa455 100644 --- a/packages/tabletop/src/part.test.ts +++ b/packages/tabletop/src/part.test.ts @@ -6,6 +6,7 @@ import { fallbackShape, traceToShape, traceToUvBounds, + facingTransform, } from './part.js'; describe('partDimensions', () => { @@ -20,6 +21,25 @@ describe('partDimensions', () => { }); }); +describe('facingTransform', () => { + const dims = { height: 2, depth: 0.1 }; + + it('lays face-up flat on the minZ face', () => { + expect(facingTransform('face', dims)).toEqual({ pivot: [0, 0, 0], xRotation: -Math.PI / 2 }); + }); + + it('lays back-down flat on the maxZ face', () => { + expect(facingTransform('back', dims)).toEqual({ pivot: [0, 0, 0.1], xRotation: Math.PI / 2 }); + }); + + it('stands on the bottom (minY) edge', () => { + expect(facingTransform('standing', dims)).toEqual({ + pivot: [0, -1, 0.05], + xRotation: 0, + }); + }); +}); + describe('spriteUvFromCrop', () => { it('returns full-image UVs without a crop', () => { expect(spriteUvFromCrop(undefined)).toEqual({ repeatX: 1, repeatY: 1, offsetX: 0, offsetY: 0 }); diff --git a/packages/tabletop/src/part.ts b/packages/tabletop/src/part.ts index 70ed452..d31455c 100644 --- a/packages/tabletop/src/part.ts +++ b/packages/tabletop/src/part.ts @@ -3,7 +3,7 @@ * so they can be unit-tested in a plain node environment (mirroring the web * app's `cardResolution.ts`). */ -import type { Part, Crop } from '@tts/bgm'; +import type { Part, Crop, Facing } from '@tts/bgm'; import { rectShape, roundedRectShape, @@ -31,6 +31,31 @@ export function partDimensions(part: Part): { width: number; height: number; dep }; } +/** + * The transform that orients a part for a `facing`, in the part's mesh-local + * frame (shape in XY centered at origin, extruded along +Z from `0` to + * `depth`). `pivot` is the center of the face/edge that rests on the table and + * should land at the anchor; `xRotation` (radians, about the local X axis) + * orients the part. Tilt is applied separately about the local Y (long) axis, + * so both the facing rotation and tilt spin about the anchor. + */ +export function facingTransform( + facing: Facing, + dims: { height: number; depth: number }, +): { pivot: [number, number, number]; xRotation: number } { + switch (facing) { + case 'face': + // Lay flat, front up, resting on the minZ face. + return { pivot: [0, 0, 0], xRotation: -Math.PI / 2 }; + case 'back': + // Lay flat, front down, resting on the maxZ face. + return { pivot: [0, 0, dims.depth], xRotation: Math.PI / 2 }; + case 'standing': + // Stand upright on the bottom (minY) edge. + return { pivot: [0, -dims.height / 2, dims.depth / 2], xRotation: 0 }; + } +} + /** * UV repeat/offset that selects a single sprite from a sheet, given a crop * `[col, row, cols, rows]` that divides the image into a `cols` x `rows` grid diff --git a/packages/tabletop/src/placement.tsx b/packages/tabletop/src/placement.tsx index da6b301..0ae51c5 100644 --- a/packages/tabletop/src/placement.tsx +++ b/packages/tabletop/src/placement.tsx @@ -9,10 +9,10 @@ import type { Package } from '@tts/bgm'; import { useStacking } from './stacking.js'; import type { Placement } from './state.js'; import { PartView } from './partView.js'; -import { MM_TO_WORLD, DEG_TO_RAD } from './part.js'; +import { MM_TO_WORLD, DEG_TO_RAD, facingTransform, partDimensions } from './part.js'; export function PartPlacement({ pkg, placement }: { pkg: Package; placement: Placement }) { - const { route, candidate, piece, index, stackSize, face } = placement; + const { route, candidate, piece, index, stackSize, facing } = placement; // `piece` is `package:type#id`; the parts map is keyed by `type#id`. const part = pkg.parts.get(piece.split(':').slice(1).join(':')); if (!part) return null; @@ -34,12 +34,16 @@ export function PartPlacement({ pkg, placement }: { pkg: Package; placement: Pla const anchorRotation = ((candidate?.rotation ?? route.rotation ?? 0) - rotation) * DEG_TO_RAD; + // The facing pivot is the center of the face/edge that rests on the table + // and should land at the anchor. Translate the mesh by the pivot, then apply + // the facing rotation and tilt about it, so the part sits on the table. + const { width, height, depth } = partDimensions(part); + const { pivot, xRotation } = facingTransform(facing, { height, depth }); + return ( - {/* 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. */} - - + + diff --git a/packages/tabletop/src/setup.test.ts b/packages/tabletop/src/setup.test.ts index e119b71..d8947fd 100644 --- a/packages/tabletop/src/setup.test.ts +++ b/packages/tabletop/src/setup.test.ts @@ -44,7 +44,7 @@ describe('seedFromSetup', () => { }; const state = seedFromSetup(pkg, setup); expect(state.surfaces).toEqual({ 'board#harbor': true }); - expect(state.parts).toEqual({ 'harbor:card#fleet': { path: '/deck', index: 0, face: true } }); + expect(state.parts).toEqual({ 'harbor:card#fleet': { path: '/deck', index: 0, facing: 'face' } }); }); it('enables all surfaces when omitted', () => { @@ -65,7 +65,24 @@ describe('seedFromSetup', () => { ], }; const state = seedFromSetup(pkg, setup); - expect(state.parts['harbor:card#fleet']).toEqual({ path: '/hand', index: 0, face: true }); - expect(state.parts['harbor:token#wood']).toEqual({ path: '/deck', index: 0, face: true }); + expect(state.parts['harbor:card#fleet']).toEqual({ path: '/hand', index: 0, facing: 'face' }); + expect(state.parts['harbor:token#wood']).toEqual({ path: '/deck', index: 0, facing: 'face' }); + }); + + it('seeds the facing from the placement, defaulting to face', () => { + const setup = { + type: 'game', + id: 'main', + setup: [ + { path: '/deck', parts: 'harbor:card#fleet', facing: 'back' }, + { path: '/table', parts: 'harbor:token#wood', facing: 'standing' }, + { path: '/hand', parts: 'harbor:token#grain' }, + ], + }; + const state = seedFromSetup(pkg, setup); + expect(state.parts['harbor:card#fleet']).toEqual({ path: '/deck', index: 0, facing: 'back' }); + expect(state.parts['harbor:token#wood']).toEqual({ path: '/table', index: 0, facing: 'standing' }); + // No `facing` on the placement defaults to `face`. + expect(state.parts['harbor:token#grain']).toEqual({ path: '/hand', index: 0, facing: 'face' }); }); }); \ No newline at end of file diff --git a/packages/tabletop/src/setup.ts b/packages/tabletop/src/setup.ts index 3fdef1e..b9f52a5 100644 --- a/packages/tabletop/src/setup.ts +++ b/packages/tabletop/src/setup.ts @@ -55,7 +55,7 @@ export function seedFromSetup(pkg: Package, setup: Setup): { const parts: Record = {}; for (const placement of setup.setup) { for (const id of expandSetupValue(pkg, placement.parts)) { - parts[id] = { path: placement.path, index: 0, face: true }; + parts[id] = { path: placement.path, index: 0, facing: placement.facing ?? 'face' }; } } const byPath: Record = {}; diff --git a/packages/tabletop/src/state.test.ts b/packages/tabletop/src/state.test.ts index 0bcd781..4bf721d 100644 --- a/packages/tabletop/src/state.test.ts +++ b/packages/tabletop/src/state.test.ts @@ -68,9 +68,9 @@ 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 }, + 'harbor:card#a': { path: '/deck', index: 1, facing: 'face' }, + 'harbor:card#b': { path: '/deck', index: 0, facing: 'back' }, + 'harbor:card#c': { path: '/community/0', index: 0, facing: 'standing' }, }; expect(childrenByPath(parts)).toEqual({ '/deck': ['harbor:card#b', 'harbor:card#a'], @@ -80,24 +80,24 @@ describe('childrenByPath', () => { }); describe('computeSurfacePlacements', () => { - it('places parts on a matching route with index, stackSize, and face', () => { + it('places parts on a matching route with index, stackSize, and facing', () => { const surface = makeSurface({ layout: [{ route: '/deck', x: -100, y: 0, rotation: 0 }], }); const placements = computeSurfacePlacements(surface, { - 'harbor:card#a': { path: '/deck', index: 0, face: true }, - 'harbor:card#b': { path: '/deck', index: 1, face: false }, + 'harbor:card#a': { path: '/deck', index: 0, facing: 'face' }, + 'harbor:card#b': { path: '/deck', index: 1, facing: 'back' }, }); expect(placements).toHaveLength(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, face: false }); + expect(placements[0]).toMatchObject({ piece: 'harbor:card#a', index: 0, stackSize: 2, facing: 'face' }); + expect(placements[1]).toMatchObject({ piece: 'harbor:card#b', index: 1, stackSize: 2, facing: 'back' }); }); it('drops parts with no matching route', () => { const surface = makeSurface({ layout: [{ route: '/deck', x: 0, y: 0, rotation: 0 }] }); const placements = computeSurfacePlacements(surface, { - 'harbor:card#a': { path: '/deck', index: 0, face: true }, - 'harbor:card#b': { path: '/elsewhere', index: 0, face: true }, + 'harbor:card#a': { path: '/deck', index: 0, facing: 'face' }, + 'harbor:card#b': { path: '/elsewhere', index: 0, facing: 'face' }, }); expect(placements).toHaveLength(1); expect(placements[0]!.piece).toBe('harbor:card#a'); @@ -116,7 +116,7 @@ describe('computeSurfacePlacements', () => { ], }); const placements = computeSurfacePlacements(surface, { - 'harbor:boat#fleet': { path: '/dock/0', index: 0, face: true }, + 'harbor:boat#fleet': { path: '/dock/0', index: 0, facing: 'face' }, }); expect(placements[0]!.candidate).toEqual({ seat: '0', x: 40, y: 5, rotation: 1 }); }); @@ -134,7 +134,7 @@ describe('computeSurfacePlacements', () => { ], }); const placements = computeSurfacePlacements(surface, { - 'harbor:boat#fleet': { path: '/dock/0', index: 0, face: true }, + 'harbor:boat#fleet': { path: '/dock/0', index: 0, facing: 'face' }, }); expect(placements[0]!.candidate).toEqual({ seat: '0', x: 40, y: 5, stacking: { tilt: 2 } }); }); @@ -144,8 +144,8 @@ describe('computeSurfacePlacements', () => { layout: [{ route: '/deck', x: 0, y: 0, rotation: 0 }], }); const placements = computeSurfacePlacements(surface, { - 'harbor:card#b': { path: '/deck', index: 1, face: true }, - 'harbor:card#a': { path: '/deck', index: 0, face: true }, + 'harbor:card#b': { path: '/deck', index: 1, facing: 'face' }, + 'harbor:card#a': { path: '/deck', index: 0, facing: 'face' }, }); expect(placements.map((p) => p.piece)).toEqual(['harbor:card#a', 'harbor:card#b']); }); @@ -155,7 +155,7 @@ describe('computeRenderState', () => { it('only includes enabled surfaces', () => { const state = { surfaces: { 'board#harbor': true, 'hud#hand': false }, - parts: { 'harbor:card#a': { path: '/deck', index: 0, face: true } }, + parts: { 'harbor:card#a': { path: '/deck', index: 0, facing: 'face' } }, }; pkg.surfaces.set( 'board#harbor', diff --git a/packages/tabletop/src/state.ts b/packages/tabletop/src/state.ts index 08cc651..18c305b 100644 --- a/packages/tabletop/src/state.ts +++ b/packages/tabletop/src/state.ts @@ -11,7 +11,7 @@ */ import { useMemo } from 'react'; import { create } from 'zustand'; -import type { Candidate, Package, Route, Surface } from '@tts/bgm'; +import type { Candidate, Facing, Package, Route, Surface } from '@tts/bgm'; /** The placement state of a single part on the board. */ export interface PartState { @@ -19,8 +19,8 @@ export interface PartState { 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; + /** How the part is oriented on the board. */ + facing: Facing; } /** Source-of-truth game state. */ @@ -47,8 +47,8 @@ export interface Placement { index: number; /** The number of pieces on the path. */ stackSize: number; - /** Whether the piece's face is up. */ - face: boolean; + /** How the piece is oriented on the board. */ + facing: Facing; } interface TabletopState extends GameState { @@ -169,7 +169,7 @@ export function computeSurfacePlacements(surface: Surface, parts: Record