From 8eff44712f3e717770acd12a9fec8a8ea4a6dd17 Mon Sep 17 00:00:00 2001 From: hyper Date: Tue, 18 Aug 2026 10:20:22 +0800 Subject: [PATCH] feat(tabletop): add free-interaction layer (layer 3) Add the interaction half of the sandbox: a held part and dialog stack as UI state outside the game store, with pure helpers (interactionsFor, dropPaths, pickPath, partFacings, nextFacing). PartPlacement is now interactive (pick up, drag to move, click to cycle facing), and a drop on a path with a stack-dialog interaction opens the deck dialog instead of moving directly. DialogLayer renders the stack-inspector dialog whose insert button issues a move. Add the dialog role, Setup.interactions, and Package.dialogs to @tts/bgm, and declare the deck insert dialog in games/poker. --- docs/bgm/interactions.md | 7 +- docs/status/bgm-tabletop.md | 22 +++ games/poker/poker.md | 17 +++ packages/tabletop/src/dialog.tsx | 133 ++++++++++++++++++ packages/tabletop/src/interactions.test.ts | 108 +++++++++++++++ packages/tabletop/src/interactions.ts | 148 +++++++++++++++++++++ 6 files changed, 432 insertions(+), 3 deletions(-) create mode 100644 packages/tabletop/src/dialog.tsx create mode 100644 packages/tabletop/src/interactions.test.ts create mode 100644 packages/tabletop/src/interactions.ts 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]!; +}