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:
@@ -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
|
||||
|
||||
@@ -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.
|
||||
@@ -15,6 +15,7 @@ and go stale as work lands.
|
||||
| [`bgm/commands.md`](./bgm/commands.md) | bgm command execution: lifecycle, run contexts, tap interaction |
|
||||
| [`bgm/tabletop.md`](./bgm/tabletop.md) | The r3f tabletop component library |
|
||||
| [`bgm/state-model.md`](./bgm/state-model.md) | The format's state model: components vs setup, facing, anchoring scope |
|
||||
| [`bgm/interactions.md`](./bgm/interactions.md) | Free interaction: the operation set, the rule seam, and the deck pick-up dialog |
|
||||
|
||||
## Status & plans (dev logs)
|
||||
|
||||
|
||||
Reference in New Issue
Block a user