Compare commits
2
Commits
71ae91960f
...
8eff44712f
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
8eff44712f | ||
|
|
3813287489 |
@@ -42,7 +42,7 @@ export default function FullSetupPage() {
|
||||
}, [id, item?.fileUrl, load]);
|
||||
|
||||
const objects = useMemo(
|
||||
() => (mod ? flattenObjects(mod) : []),
|
||||
() => (mod ? flattenObjects(mod.mod) : []),
|
||||
[mod],
|
||||
);
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
|
||||
@@ -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):
|
||||
|
||||
@@ -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
|
||||
```
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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');
|
||||
});
|
||||
});
|
||||
@@ -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]!;
|
||||
}
|
||||
Reference in New Issue
Block a user