From addfc03ab67be887aa4de2e7f058c47427376dac Mon Sep 17 00:00:00 2001 From: hypercross Date: Sun, 16 Aug 2026 12:59:03 +0800 Subject: [PATCH] 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. --- docs/bgm/format.md | 62 ++++++++++++++++++ docs/bgm/interactions.md | 133 +++++++++++++++++++++++++++++++++++++++ docs/overview.md | 1 + 3 files changed, 196 insertions(+) create mode 100644 docs/bgm/interactions.md diff --git a/docs/bgm/format.md b/docs/bgm/format.md index f8fccff..be7cb24 100644 --- a/docs/bgm/format.md +++ b/docs/bgm/format.md @@ -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 diff --git a/docs/bgm/interactions.md b/docs/bgm/interactions.md new file mode 100644 index 0000000..e160a21 --- /dev/null +++ b/docs/bgm/interactions.md @@ -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. \ No newline at end of file diff --git a/docs/overview.md b/docs/overview.md index 6164960..db6111d 100644 --- a/docs/overview.md +++ b/docs/overview.md @@ -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)