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
+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]!;
}