docs: add free interaction design to bgm specs

Add the command/dialog split to the state-model and interactions docs, declare role dialog and zone facing in the format spec, and let setups declare which dialogs power open interactions.
This commit is contained in:
2026-08-16 12:59:03 +08:00
parent 211c151971
commit addfc03ab6
3 changed files with 196 additions and 0 deletions
+62
View File
@@ -180,6 +180,7 @@ by their `role=` (or, for real files, their filename) for either
- `part`
- `surface`
- `setup`
- `dialog`
a valid object can either be the root or in the list of the yaml block.
@@ -236,6 +237,10 @@ placed on the board via `setup`, and visualized by routes.
- `size` — `size`. The token is scaled to fit in the box. The x/y aspect
ratio is kept, but not z (thickness).
- `fillet` — number in mm. Used to fillet the shape. Defaults to `0`.
- `facing` — the **physical affordance**: the facings the piece supports.
A card supports `[face, back, standing]`; a tile supports `[face, back]`.
Declares what's *possible*, not what's legal on a given zone (see `surface`
`layout`). Defaults to `[face]`.
#### example
@@ -366,6 +371,49 @@ initialization.
A part's `facing` is seeded into the game state and can change at runtime; it
only affects orientation, never the part's texture.
A setup also declares the **interaction affordances** — which dialogs are the
tool for which open interactions on which paths:
```yaml
interactions:
- dialog: insert
on: [draw, grid] # insertion uses the `insert` dialog on these paths
- dialog: shuffle # open the shuffle dialog on any deck
```
`interactions` is a list of declarations: each names a `dialog` (a `role:
dialog` definition, see below) and the paths it applies to (`on`, matching by
path or by stack). It declares the *interaction surface*, not the legality of
the resulting command — rule scripts (later) gate legality. A dialog can be
opened by a player gesture or pushed by a rule script; only the trigger differs.
### dialog
A `dialog` is declarative content shown in the layer-3 shell. Opening and
closing it never issues a command or mutates state; it is pure UI. Its **action
buttons** issue commands — the bridge between the dialog and the rule seam.
```yaml
role: dialog
type: prompt
id: discard
title: Choose a card to discard
body: |
Select a card from your hand.
actions:
- label: Confirm
command: { move: { part: "#chosen", to: /discard } }
widget: stack # a stack-of-parts content type
```
A dialog's content is a **title**, **body**, **action buttons**, and an optional
`widget`. Supported content types include a **stack of parts** — an ordered,
scrolled view of a path's stack with an insertion cursor — which is what powers
insertion and shuffling dialogs against the state store.
`dialog` is a same-shaped definition as the others: `type`/`id` identify it
(`type#id` unique in the package), and it collides-checks like `part`/`setup`.
---
## 4. Concepts
@@ -384,6 +432,11 @@ A surface is enabled or disabled; a disabled surface is not rendered. Setup
seeds the enabled set (see §3), and it changes at runtime as the game
progresses (e.g. enabling the main board after an expansion-chooser scene).
Dialogs are **not** part of the state store. The dialog stack is UI state
hosted by the shell; opening/closing a dialog never mutates the store. A dialog's
action buttons issue commands (see `role: dialog` §3), which is the one-way
bridge onto the state.
### Routing
A route is a **visualization route**: it maps a part to a location on a
@@ -399,6 +452,15 @@ A route is an express-style URL path with named params, plus the `x`, `y`, and
`rotation` of its anchor. Routes are defined in a **list**, not a map, so the
same route path may appear more than once:
A route may also declare a zone `facing` — the **legal facing** on that zone.
This constrains what the placed parts may be, independent of each part's
physical affordance (see `part` `facing`). A state is legal only when the part's
facing is in both the part's affordance *and* the zone's legal set. When omitted,
the zone is unrestricted beyond the part's affordance. An MTG discard pile
requires `[face]`; a play area allows `[face, tap]`; a facedown deck requires
`[back]`. The same card is face-up in the discard but tapped in play — the
piece's affordance is unchanged, only the zone's rule differs.
```yaml
layout:
- route: /dock/:seat
+133
View File
@@ -0,0 +1,133 @@
# bgm Interactions
The free-interaction layer (layer 3 of the layering) — how a player interacts
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.
## 1. The operation set is closed and tiny
Two assumptions from the state model do most of the work:
1. **Parts are never created or destroyed** — the set of parts is fixed (the
setup's parts).
2. **State is only path, stack, and facing** — there is nowhere to store a free
position in space.
Together they collapse the *entire* space of free interaction into three
operations:
1. **`move(id, path, index?)`** — relocate a part to a path, at a stack
position (default: top of stack).
2. **`setFacing(id, facing)`** — change facing, within the part's physical
affordance.
3. **reorder** — a `move` with an explicit `index`.
That's it. There is no arbitrary positioning and no free placement in 3D because
the state model has nowhere to store one. "Free" means *unconstrained over these
three operations*, not "free in space."
## 2. Free interaction and rule play are the same operations
This is the payoff. Rule-enforced play (layer 4) is **the same three
operations, gated by a legality check**:
- The interaction layer produces an **intent** — "player wants
`move(card, /discard)`."
- **Sandbox mode**: apply it directly.
- **Rule mode**: validate the intent against the setup's shape + the rule script
first; reject if illegal.
So the rule engine needs no interaction vocabulary of its own — it is a **filter
over free interaction**. `move`/`setFacing` are the shared primitives; rules
decide which are legal in the current state. Drag-and-drop and a scripted move
both funnel through the same store mutation. That is the seam between layer 3
and layer 4.
## 3. Commands vs the dialog stack
There are two channels, and they do not mix:
- **Commands** — intent to *change state* (`move`, `setFacing`). They go through
the rule seam and mutate the store.
- **The dialog stack** — transient UI contexts (the deck pick-up, a "confirm
discard," a hint prompt). Opening/closing a dialog **never issues a command**
and never touches state; it is pure UI.
This simplifies the model. The dialog stack is **UI state, hosted by the layer-3
shell, not by the game-state store** — dialogs don't belong in path/stack/facing.
How they connect — one direction only:
- **Player-initiated**: a click on a deck pushes the deck dialog; the dialog's
insert button issues a `move` command.
- **Script-initiated**: a rule script pushes the same dialog (e.g. to force a
discard) and awaits the player's `move` through it. The script's open/close
is tied to the dialog stack; it can `pushDialog`/`popDialog` without mutating
game state.
So the deck dialog is **one implementation, driven either way** — by a player
click or by a rule script. Only the *trigger* differs.
Dialogs are **authored in the manifest**, not hardcoded. A `role: dialog`
definition declares the title, body, action buttons, and an optional widget
(a stack of parts). Setups declare which dialogs are the tool for which
interactions via `interactions:` (see [`format.md`](./format.md) §3). The
rule seam stays clean because a dialog's buttons issue commands while its
open/close is stack-only.
## 4. Compound interactions: the deck pick-up dialog
A dialog is where compound, multi-step manipulation lives, because it owns the
transient sub-state that the store must not. The prime example — inserting a
card into the middle of a deck:
1. pick up the deck
2. scroll through it to find the place
3. insert the card at the cursor
4. put the deck back
(even 0: put down your held hand of cards first).
The dialog is an **alternate view of the stack**: the deck is "lifted" off the
table into the dialog (it stays on its path; the rest of the table becomes
backdrop). Its contents are shown **in order**, with an **insertion cursor**
between cards that you scroll. The cursor *is* the index: `move(id, path,
index)`'s index is discovered by scrolling the visible deck, not typed.
This is a real interaction *mode*, owned by the dialog:
- The scroll position is state.
- Everything else pauses while it's open (or the held part is kept visible).
- It only exists for **stacks**; single parts just snap onto a path.
We treat it as a **reusable pattern** — a stack-inspector dialog — not a one-off
hack for one command, so `deal`, `draw`, and `look-at-the-top` can reuse the
same lifted-deck view.
## 5. The primitives map to concrete gestures
- **Move** — pick up a part → it leaves its stack (transient "in hand"); drag →
resolve the nearest path anchor within a threshold; drop → `move` (drop on
nothing returns to origin).
- **Facing** — click cycles the part through its physical affordance
(`face → back → standing`, or the declared list).
- **Reorder / insert** — the deck dialog above.
The "in hand" state is transient and illegal, so it lives **outside the store**
(a UI-level held part); only the committed outcome (drop) mutates the store.
## Open questions
- **Held source for insertion** — single held card (fits the physical model; the
dialog inserts it at the cursor), vs the dialog owns the source (you cursor a
card *from* the deck to lift). Lean single-held-card, but confirm it doesn't
fight the hand step.
- **Multi-part ops** — picking up a whole stack, dealing N cards. Deferred; the
single-part primitives are the foundation.
- **Scroll window** — a window over a subset of the deck can return with the
cursor's scroll position; exact widget is a render concern, not a state one.