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.
This commit is contained in:
hyper
2026-08-18 10:20:22 +08:00
parent 3813287489
commit 8eff44712f
6 changed files with 432 additions and 3 deletions
+4 -3
View File
@@ -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
+22
View File
@@ -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):
+17
View File
@@ -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
```
+133
View File
@@ -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 (
<StackDialog
key={top.path}
pkg={pkg}
title={dialog.title}
body={dialog.body}
path={top.path}
/>
);
}
/** 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 (
<div className="pointer-events-auto absolute inset-0 z-20 flex items-center justify-center bg-zinc-950/60">
<div className="w-full max-w-md rounded-lg border border-zinc-700 bg-zinc-900 p-4 shadow-xl">
{title && <h2 className="text-lg font-semibold">{title}</h2>}
{body && <p className="mt-1 text-sm text-zinc-400">{body}</p>}
<div className="mt-3 flex flex-wrap items-center gap-1 rounded-md border border-zinc-800 bg-zinc-950 p-2">
{stack.length === 0 && (
<span className="text-xs text-zinc-500">Empty stack</span>
)}
{stack.map((id, i) => (
<div key={id} className="flex items-center gap-1">
{i === cursor && <Cursor />}
<button
onClick={() => setCursor(i)}
className="rounded border border-zinc-700 bg-zinc-800 px-2 py-1 text-xs text-zinc-200 hover:bg-zinc-700"
title={`Insert before ${label(pkg, id)}`}
>
{label(pkg, id)}
</button>
</div>
))}
{stack.length > 0 && cursor === stack.length && <Cursor />}
</div>
<div className="mt-4 flex justify-end gap-2">
<button
onClick={popDialog}
className="rounded-md border border-zinc-700 px-3 py-1.5 text-sm text-zinc-300 hover:bg-zinc-800"
>
Close
</button>
<button
onClick={insert}
disabled={!source}
className="rounded-md bg-zinc-100 px-3 py-1.5 text-sm font-medium text-zinc-900 hover:bg-white disabled:opacity-40"
>
{source ? `Insert ${label(pkg, source)}` : 'Insert'}
</button>
</div>
</div>
</div>
);
}
/** A small marker between cards showing the insertion point. */
function Cursor() {
return (
<span className="h-6 w-0.5 rounded bg-zinc-100" title="Insertion point" />
);
}
/** 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;
}
+108
View File
@@ -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');
});
});
+148
View File
@@ -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<InteractionState>((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<string> {
const paths = new Set<string>();
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]!;
}