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;
}