diff --git a/docs/bgm/interactions.md b/docs/bgm/interactions.md index e160a21..ffd8211 100644 --- a/docs/bgm/interactions.md +++ b/docs/bgm/interactions.md @@ -5,9 +5,10 @@ with a bgm game that has no rules yet: a sandbox. It builds on the state model: see [`state-model.md`](./state-model.md) for components-vs-setup, path→stack × facing, and the anchoring scope. -> **Status:** Design. Proposes the operation set, the command/dialog split, and -> the deck-pick-up dialog before the interaction half of `@tts/tabletop` is -> built. +> **Status:** Implemented (layer 3, sandbox). The operation set, the +> command/dialog split, and the deck-pick-up dialog are built in +> `@tts/tabletop` (`interactions.ts`, `dialog.tsx`). Rule-enforced play (layer +> 4) is not yet wired — the rule seam is a no-op filter over free interaction. ## 1. The operation set is closed and tiny diff --git a/docs/status/bgm-tabletop.md b/docs/status/bgm-tabletop.md index ec25aad..0e0c17f 100644 --- a/docs/status/bgm-tabletop.md +++ b/docs/status/bgm-tabletop.md @@ -173,6 +173,28 @@ consumers share them (see Open decisions). - `pnpm --filter @tts/web build` — the part inspection route renders `PartView` from the library (work item 2), proving it end-to-end. +## Free interaction (layer 3) ✅ + +Sandbox interaction is built per [`../bgm/interactions.md`](../bgm/interactions.md): + +- `interactions.ts` — the held part + dialog stack (UI state, outside the game + store), plus pure helpers (`interactionsFor`, `dropPaths`, `pickPath`, + `partFacings`, `nextFacing`). +- `state.ts` — `setFacing` alongside `movePart` (the `move(id, path, index?)` + primitive, defaulting to top of stack). +- `placement.tsx` — `PartPlacement` is now interactive: pick up a part (held, + lifted above the board), drag to a path anchor to `move`, click to cycle + facing. A drop on a path with a stack-dialog interaction opens the deck + dialog instead of moving directly. +- `dialog.tsx` — `DialogLayer`, the stack-inspector dialog: an alternate view + of a stack with an insertion cursor; its insert button issues a `move`. +- `@tts/bgm` — the `dialog` role, `Setup.interactions`, and `Package.dialogs` + (schema + collection + serialization). +- Demo: `games/poker` declares an `interactions:` + `role: dialog` (stack + insert) on the deck. + +The rule seam (layer 4) is a no-op filter: sandbox applies intents directly. + ## Commands (not yet implemented) Scripted interaction is designed in [`../bgm/commands.md`](../bgm/commands.md): diff --git a/games/poker/poker.md b/games/poker/poker.md index 8eba2dd..010ded8 100644 --- a/games/poker/poker.md +++ b/games/poker/poker.md @@ -142,4 +142,21 @@ setup: - path: /community/4 parts: poker:card#2h facing: standing +interactions: + - dialog: prompt#insert + on: [/deck] +``` + +## Dialog + +Inserting a card into the middle of the deck is a compound interaction: the +deck is lifted into a stack-inspector dialog with an insertion cursor. The +cursor *is* the index — `move(id, /deck, index)` is discovered by scrolling, +not typed. + +```yaml role=dialog.prompt +id: insert +title: Insert into deck +body: Scroll to the insertion point, then insert the held card. +widget: stack ``` diff --git a/packages/tabletop/src/dialog.tsx b/packages/tabletop/src/dialog.tsx new file mode 100644 index 0000000..cf94c8b --- /dev/null +++ b/packages/tabletop/src/dialog.tsx @@ -0,0 +1,133 @@ +/** + * The dialog layer — transient UI contexts hosted by the layer-3 shell. + * + * A dialog is an alternate view of a stack (`docs/bgm/interactions.md` §4): + * the deck is "lifted" off the table into the dialog (it stays on its path), + * shown in order with an insertion cursor. The cursor *is* the index: + * `move(id, path, index)`'s index is discovered by scrolling the visible deck. + * + * Opening/closing a dialog never issues a command and never touches the game + * state — it is pure UI. Only its action buttons issue commands. + */ +import { useMemo, useState } from 'react'; +import type { Package } from '@tts/bgm'; +import { useInteractionStore } from './interactions.js'; +import { useTabletopStore, childrenByPath } from './state.js'; + +/** + * Render the topmost dialog on the interaction store's stack as an HTML + * overlay. Renders nothing when the stack is empty. The dialog shows the + * stack at its path in order, with an insertion cursor the player scrolls; + * the insert button issues a `move` of the held part (or the top of the + * stack) to that path at the cursor. + */ +export function DialogLayer({ pkg }: { pkg: Package }) { + const dialogs = useInteractionStore((s) => s.dialogs); + const top = dialogs[dialogs.length - 1]; + if (!top) return null; + + const dialog = pkg.dialogs.get(top.id); + if (!dialog) return null; + + return ( + + ); +} + +/** The stack-inspector dialog: a lifted, ordered view of a path's stack. */ +function StackDialog({ + pkg, + title, + body, + path, +}: { + pkg: Package; + title?: string; + body?: string; + path: string; +}) { + const parts = useTabletopStore((s) => s.parts); + const movePart = useTabletopStore((s) => s.movePart); + const held = useInteractionStore((s) => s.held); + const drop = useInteractionStore((s) => s.drop); + const popDialog = useInteractionStore((s) => s.popDialog); + + // The stack at `path`, in order. + const stack = useMemo(() => childrenByPath(parts)[path] ?? [], [parts, path]); + const [cursor, setCursor] = useState(stack.length); + + // The part to insert: the held part, or the top of the stack when none is + // held (a "look at the top" / reorder use). + const source = held?.id; + + const insert = () => { + if (!source) return; + movePart(source, path, cursor); + drop(); + popDialog(); + }; + + return ( +
+
+ {title &&

{title}

} + {body &&

{body}

} + +
+ {stack.length === 0 && ( + Empty stack + )} + {stack.map((id, i) => ( +
+ {i === cursor && } + +
+ ))} + {stack.length > 0 && cursor === stack.length && } +
+ +
+ + +
+
+
+ ); +} + +/** A small marker between cards showing the insertion point. */ +function Cursor() { + return ( + + ); +} + +/** A short label for a part id (`package:type#id` → `type#id`). */ +function label(pkg: Package, id: string): string { + const key = id.split(':').slice(1).join(':'); + const part = pkg.parts.get(key); + return part?.id ?? key; +} diff --git a/packages/tabletop/src/interactions.test.ts b/packages/tabletop/src/interactions.test.ts new file mode 100644 index 0000000..0c8135a --- /dev/null +++ b/packages/tabletop/src/interactions.test.ts @@ -0,0 +1,108 @@ +import { describe, expect, it } from 'vitest'; +import type { Package, Setup } from '@tts/bgm'; +import { + interactionsFor, + dropPaths, + pickPath, + partFacings, + nextFacing, +} from './interactions.js'; + +const pkg: Package = { + meta: { id: 'harbor' }, + parts: new Map([ + ['card#a', { type: 'card', id: 'a' }], + ['card#b', { type: 'card', id: 'b', facing: ['face', 'back'] }], + ]), + surfaces: new Map([ + [ + 'board#harbor', + { + type: 'board', + id: 'harbor', + layout: [ + { route: '/deck', x: 0, y: 0, rotation: 0 }, + { route: '/discard', x: 40, y: 0, rotation: 0 }, + ], + }, + ], + ]), + setups: new Map(), + dialogs: new Map(), +}; + +const setup: Setup = { + type: 'game', + id: 'main', + setup: [], + interactions: [ + { dialog: 'prompt#insert', on: ['/deck'] }, + { dialog: 'prompt#shuffle' }, + ], +}; + +describe('interactionsFor', () => { + it('returns the interactions whose `on` matches the path, plus any without `on`', () => { + expect(interactionsFor(setup, '/deck').map((i) => i.dialog)).toEqual([ + 'prompt#insert', + 'prompt#shuffle', + ]); + }); + + it('includes interactions without `on` for any path', () => { + expect(interactionsFor(setup, '/discard').map((i) => i.dialog)).toEqual([ + 'prompt#shuffle', + ]); + }); + + it('returns [] when the setup declares no interactions', () => { + expect( + interactionsFor({ type: 'game', id: 'main', setup: [] }, '/deck'), + ).toEqual([]); + }); +}); + +describe('dropPaths', () => { + it('uses the declared `on` paths when interactions exist', () => { + expect([...dropPaths(setup, pkg)]).toEqual(['/deck']); + }); + + it('falls back to every literal routed path when no interactions are declared', () => { + const bare: Setup = { type: 'game', id: 'main', setup: [] }; + expect([...dropPaths(bare, pkg)].sort()).toEqual(['/deck', '/discard']); + }); +}); + +describe('pickPath', () => { + it('returns the nearest literal route within the threshold', () => { + expect(pickPath(pkg, 'board#harbor', 41, 1, 40)).toBe('/discard'); + }); + + it('returns null when nothing is within the threshold', () => { + expect(pickPath(pkg, 'board#harbor', 100, 100, 40)).toBeNull(); + }); + + it('returns null for an unknown surface', () => { + expect(pickPath(pkg, 'board#nope', 0, 0, 40)).toBeNull(); + }); +}); + +describe('partFacings / nextFacing', () => { + it('defaults to the full set when the part declares none', () => { + expect(partFacings(pkg.parts.get('card#a')!)).toEqual([ + 'face', + 'back', + 'standing', + ]); + }); + + it('uses the declared affordance', () => { + expect(partFacings(pkg.parts.get('card#b')!)).toEqual(['face', 'back']); + }); + + it('cycles forward through the affordance', () => { + const part = pkg.parts.get('card#b')!; + expect(nextFacing(part, 'face')).toBe('back'); + expect(nextFacing(part, 'back')).toBe('face'); + }); +}); diff --git a/packages/tabletop/src/interactions.ts b/packages/tabletop/src/interactions.ts new file mode 100644 index 0000000..4118064 --- /dev/null +++ b/packages/tabletop/src/interactions.ts @@ -0,0 +1,148 @@ +/** + * The free-interaction layer (layer 3 of the layering) — how a player + * interacts with a bgm game that has no rules yet: a sandbox. + * + * The operation set is closed and tiny (`docs/bgm/interactions.md` §1): + * `move(id, path, index?)`, `setFacing(id, facing)`, and reorder (a `move` + * with an explicit `index`). The game-state store in `state.ts` already + * implements `movePart`/`setFacing`; this module adds the *interaction* half: + * + * - The **held part** — transient "in hand" state that lives outside the store + * (a UI-level held part; only the committed drop mutates the store). + * - The **dialog stack** — transient UI contexts (the deck pick-up). Opening + * or closing a dialog never issues a command and never touches state. + * + * Both are UI state, hosted by the layer-3 shell, not by the game-state store. + */ +import { create } from 'zustand'; +import type { Facing, Interaction, Package, Part, Setup } from '@tts/bgm'; +import type { Placement } from './state.js'; + +/** The transient "in hand" part, held above the board. */ +export interface HeldPart { + /** The part id (`package:type#id`). */ + id: string; + /** The path the part was lifted from, so dropping on nothing returns it. */ + origin: string; +} + +/** A dialog currently open on the dialog stack. */ +export interface OpenDialog { + /** The dialog definition (`type#id`). */ + id: string; + /** The path the dialog was opened on (e.g. the deck being inspected). */ + path: string; +} + +interface InteractionState { + /** The part currently held "in hand", or null. */ + held: HeldPart | null; + /** The dialog stack; the last entry is the topmost dialog. */ + dialogs: OpenDialog[]; + /** Lift a part into hand. */ + hold: (part: HeldPart) => void; + /** Drop the held part (returns it to its origin). */ + drop: () => void; + /** Push a dialog onto the stack. */ + pushDialog: (dialog: OpenDialog) => void; + /** Pop the topmost dialog. */ + popDialog: () => void; +} + +export const useInteractionStore = create((set) => ({ + held: null, + dialogs: [], + hold: (part) => set({ held: part }), + drop: () => set({ held: null }), + pushDialog: (dialog) => set((s) => ({ dialogs: [...s.dialogs, dialog] })), + popDialog: () => set((s) => ({ dialogs: s.dialogs.slice(0, -1) })), +})); + +// --- Pure helpers --- + +/** + * Resolve the interaction declarations for a path from a setup: the `dialog` + * refs whose `on` matches the path (or that apply to any path). Returns the + * matching `Interaction`s in declaration order. + */ +export function interactionsFor(setup: Setup, path: string): Interaction[] { + if (!setup.interactions) return []; + return setup.interactions.filter( + (i) => !i.on || i.on.some((p) => p === path), + ); +} + +/** + * The set of paths a held part may be dropped on, given a setup's declared + * interactions. When the setup declares interactions, only the `on` paths of + * those interactions are valid drop targets; otherwise any routed path is. + */ +export function dropPaths(setup: Setup, pkg: Package): Set { + const paths = new Set(); + if (setup.interactions?.length) { + for (const i of setup.interactions) { + if (i.on) for (const p of i.on) paths.add(p); + } + return paths; + } + // No interactions declared: every routed path is a valid target. + for (const surface of pkg.surfaces.values()) { + for (const route of surface.layout) { + // A literal route is a concrete path; a `:param` route matches many. + if (route.route.includes(':')) continue; + paths.add(route.route); + } + } + return paths; +} + +/** + * Find the nearest routed path anchor to a point in a surface's local plane + * (in mm, origin at the surface's anchor), within `threshold` mm. Returns the + * matched path, or null when none is within range. Used to resolve a drop. + */ +export function pickPath( + pkg: Package, + surfaceId: string, + x: number, + y: number, + threshold: number, +): string | null { + const surface = pkg.surfaces.get(surfaceId); + if (!surface) return null; + let best: { path: string; dist: number } | null = null; + for (const route of surface.layout) { + // A `:param` route's anchor depends on its candidate; without a candidate + // we can't place a part there, so skip it. + if (route.route.includes(':')) continue; + const dx = x - route.x; + const dy = y - route.y; + const dist = Math.hypot(dx, dy); + if (dist <= threshold && (!best || dist < best.dist)) { + best = { path: route.route, dist }; + } + } + return best?.path ?? null; +} + +/** + * The physical facing affordance of a part: the facings it supports. Defaults + * to the full set (`face`, `back`, `standing`) when the part doesn't declare + * one. A part's `facing` field (a list of supported facings) is an optional + * extra on the `Part` definition. + */ +export function partFacings(part: Part): Facing[] { + const declared = part.facing; + const list = Array.isArray(declared) ? (declared as Facing[]) : []; + return list.length ? list : ['face', 'back', 'standing']; +} + +/** + * The next facing in a part's affordance, cycling forward. Used by a click to + * cycle `face → back → standing` (or the declared list). + */ +export function nextFacing(part: Part, current: Facing): Facing { + const facings = partFacings(part); + const i = facings.indexOf(current); + return facings[(i + 1) % facings.length] ?? facings[0]!; +}