Dialogue Box¶

branching NPC dialogue with selectable choices.

â–¶ Run in browser

Tags: ui dialogue branching game-ui

A bottom-anchored dialogue panel driven by a small dialogue graph. Each passage names a speaker and a line of text; a passage either flows on linearly through the familiar Continue button, or offers 2-3 choices rendered as selectable options. Choices are picked with the keyboard (Up/Down or W/S to highlight, Enter or Space to confirm) or by clicking; hovering a choice moves the highlight with it. One branch loops back to the question, others end the conversation. A choice can set a conversation variable (here, how the guide addresses you) that later passages substitute into their text.

What it demonstrates¶

  • A dialogue graph as plain data: passage id -> speaker, text, and either a next id (linear, Continue button) or a list of (label, next_id, sets) choices.

  • Choice buttons driven by one shared row pool, highlighted via Button.set_visual_state_override, activated by keyboard or click.

  • Input actions on the root’s input_actions class attribute polled with Input.is_action_just_pressed in on_update.

  • Conversation variables: a choice stores {"title": ...} and later text references {title} via str.format.

Controls: Up/Down or W/S - Move the choice highlight Enter / Space - Confirm the highlighted choice, or advance linear text Mouse - Hover to highlight, click to choose / continue

Run: uv run python examples/features/ui/dialog.py Headless self-check: uv run python examples/features/ui/dialog.py –test

Source¶

  1"""Dialogue Box: branching NPC dialogue with selectable choices.
  2
  3A bottom-anchored dialogue panel driven by a small dialogue graph. Each passage
  4names a speaker and a line of text; a passage either flows on linearly through
  5the familiar Continue button, or offers 2-3 choices rendered as selectable
  6options. Choices are picked with the keyboard (Up/Down or W/S to highlight,
  7Enter or Space to confirm) or by clicking; hovering a choice moves the
  8highlight with it. One branch loops back to the question, others end the
  9conversation. A choice can set a conversation variable (here, how the guide
 10addresses you) that later passages substitute into their text.
 11
 12# /// simvx
 13# tags = ["ui", "dialogue", "branching", "game-ui"]
 14# web = { root = "DialogueDemo", width = 800, height = 600, responsive = true }
 15# ///
 16
 17## What it demonstrates
 18
 19- A dialogue graph as plain data: passage id -> speaker, text, and either a
 20  `next` id (linear, Continue button) or a list of `(label, next_id, sets)`
 21  choices.
 22- Choice buttons driven by one shared row pool, highlighted via
 23  `Button.set_visual_state_override`, activated by keyboard or click.
 24- Input actions on the root's `input_actions` class attribute polled with
 25  `Input.is_action_just_pressed` in `on_update`.
 26- Conversation variables: a choice stores `{"title": ...}` and later text
 27  references `{title}` via `str.format`.
 28
 29Controls:
 30  Up/Down or W/S - Move the choice highlight
 31  Enter / Space  - Confirm the highlighted choice, or advance linear text
 32  Mouse          - Hover to highlight, click to choose / continue
 33
 34Run: uv run python examples/features/ui/dialog.py
 35Headless self-check: uv run python examples/features/ui/dialog.py --test
 36"""
 37
 38from simvx.core import AnchorPreset, Button, Colour, Input, Key, Label, Node, Panel
 39from simvx.graphics import App
 40
 41# The conversation as a graph. Each passage has a speaker and text; text may
 42# reference conversation variables with {name} placeholders. A passage then
 43# carries either "next" (linear: the Continue button advances) or "choices"
 44# (a list of (label, next_id, sets) where sets is an optional dict of
 45# variables the choice stores). A passage with neither is an ending: the
 46# button reads Close and dismisses the panel.
 47GRAPH = {
 48    "greet": {
 49        "speaker": "Guide",
 50        "text": "Welcome to the village, traveller. Who do I have the honour of addressing?",
 51        "choices": [
 52            ("A knight errant", "brief", {"title": "sir knight"}),
 53            ("A travelling scholar", "brief", {"title": "learned one"}),
 54        ],
 55    },
 56    "brief": {
 57        "speaker": "Guide",
 58        "text": "Well met, {title}. The old mine to the north has been overrun.",
 59        "next": "ask",
 60    },
 61    "ask": {
 62        "speaker": "Guide",
 63        "text": "Will you take up the task and clear it out?",
 64        "choices": [
 65            ("What happened down there?", "rumour", None),
 66            ("I will go at once.", "accept", None),
 67            ("Not today.", "decline", None),
 68        ],
 69    },
 70    # Loops back to the question, so the player can ask and then still decide.
 71    "rumour": {
 72        "speaker": "Guide",
 73        "text": "The miners heard scratching behind the walls. Nobody has gone back down.",
 74        "next": "ask",
 75    },
 76    "accept": {
 77        "speaker": "Guide",
 78        "text": "Take this lantern, {title}. You will need its light.",
 79        "next": "farewell",
 80    },
 81    "farewell": {
 82        "speaker": "Guide",
 83        "text": "Good luck out there. Come back safe.",
 84    },
 85    "decline": {
 86        "speaker": "Guide",
 87        "text": "A pity, {title}. The offer stands should you change your mind.",
 88    },
 89}
 90
 91START = "greet"
 92MAX_CHOICES = 3
 93ROW_HEIGHT = 32
 94ROW_GAP = 6
 95
 96
 97class DialogueDemo(Node):
 98    """Root node: a bottom-anchored branching dialogue box."""
 99
100    input_actions = {
101        "choice_up": [Key.UP, Key.W],
102        "choice_down": [Key.DOWN, Key.S],
103        "choice_accept": [Key.ENTER, Key.SPACE],
104    }
105
106    def on_ready(self):
107        self._id = START
108        self._vars: dict[str, str] = {}
109        self._selected = 0
110
111        # Bottom-anchored dialogue panel: full width, fixed height at the
112        # bottom edge. Two margin conventions meet here. On a stretching axis
113        # (left anchor 0, right anchor 1) the right/bottom margins are positive
114        # insets from that edge. On a collapsed axis (both anchors at 1, as the
115        # vertical axis is here) the margin pair encodes offset and size, so
116        # -240 / -20 means "220px tall, 20px up from the bottom".
117        panel = Panel(name="DialoguePanel")
118        panel.set_anchor_preset(AnchorPreset.BOTTOM_WIDE)
119        panel.margin_left = 20
120        panel.margin_right = 20
121        panel.margin_top = -240
122        panel.margin_bottom = -20
123        panel.bg_colour = Colour.hex("#15171F")
124        self.add_child(panel)
125
126        # Speaker name, top-left inside the panel.
127        self._speaker = Label(name="Speaker")
128        self._speaker.font_size = 18.0
129        self._speaker.text_colour = Colour.hex("#FFCC66")
130        # TOP_WIDE collapses the vertical axis, so the top/bottom margin pair
131        # (not size_y) sets the band: 14..38 is a 24px-tall line of text.
132        self._speaker.set_anchor_preset(AnchorPreset.TOP_WIDE)
133        self._speaker.margin_left = 20
134        self._speaker.margin_right = 20
135        self._speaker.margin_top = 14
136        self._speaker.margin_bottom = 38
137        panel.add_child(self._speaker)
138
139        # Body text: the band between the speaker line and the choice rows.
140        self._body = Label(name="Body")
141        self._body.font_size = 15.0
142        self._body.text_colour = Colour.LIGHT_GRAY
143        self._body.set_anchor_preset(AnchorPreset.FULL_RECT)
144        self._body.margin_left = 20
145        self._body.margin_right = 20
146        self._body.margin_top = 48
147        self._body.margin_bottom = 132
148        panel.add_child(self._body)
149
150        # Continue button, bottom-right of the panel, shown on linear passages.
151        # BOTTOM_RIGHT collapses both axes, so the margin pairs alone give a
152        # 140x32 box inset 20px from the right edge and 12px from the bottom.
153        self._continue = Button("Continue", name="ContinueButton", on_press=self._advance)
154        self._continue.set_anchor_preset(AnchorPreset.BOTTOM_RIGHT)
155        self._continue.margin_left = -160
156        self._continue.margin_top = -44
157        self._continue.margin_right = -20
158        self._continue.margin_bottom = -12
159        panel.add_child(self._continue)
160
161        # One shared pool of choice rows, re-labelled and re-stacked per
162        # passage; unused rows are hidden. Hovering a row moves the keyboard
163        # highlight onto it so the two input routes never fight.
164        self._choice_buttons: list[Button] = []
165        for i in range(MAX_CHOICES):
166            btn = Button("", name=f"Choice{i}", on_press=lambda i=i: self._choose(i))
167            btn.set_anchor_preset(AnchorPreset.BOTTOM_WIDE)
168            btn.mouse_entered.connect(lambda i=i: self._set_selected(i))
169            btn.visible = False
170            panel.add_child(btn)
171            self._choice_buttons.append(btn)
172
173        self._panel = panel
174        self._show_current()
175
176    # ------------------------------------------------------------------ state
177
178    @property
179    def _passage(self) -> dict:
180        return GRAPH[self._id]
181
182    def _choices(self) -> list:
183        return self._passage.get("choices", [])
184
185    def _show_current(self):
186        """Render the current passage: text, and either choices or Continue."""
187        passage = self._passage
188        self._speaker.text = passage["speaker"]
189        self._body.text = passage["text"].format(**self._vars)
190
191        choices = self._choices()
192        self._continue.visible = not choices
193        terminal = not choices and "next" not in passage
194        self._continue.text = "Close" if terminal else "Continue"
195
196        # Stack the visible rows upward from the bottom of the panel: row j
197        # (counted from the bottom) sits ROW_HEIGHT tall, j full pitches up
198        # from a 12px inset. BOTTOM_WIDE stretches each row across the panel.
199        for i, btn in enumerate(self._choice_buttons):
200            btn.visible = i < len(choices)
201            # A row keeps focus after a click; drop it so a later Enter fires
202            # the highlighted choice through the action, never a stale focus.
203            btn.release_focus()
204            if i < len(choices):
205                btn.text = choices[i][0]
206                j = len(choices) - 1 - i
207                bottom = 12 + j * (ROW_HEIGHT + ROW_GAP)
208                btn.margin_left = 20
209                btn.margin_right = 20
210                btn.margin_top = -(bottom + ROW_HEIGHT)
211                btn.margin_bottom = -bottom
212        self._set_selected(self._row_under_cursor(len(choices)))
213
214    def _row_under_cursor(self, count: int) -> int:
215        """The freshly laid-out row the mouse already rests on, else row 0.
216
217        `mouse_entered` fires on motion, so when a choice swaps the layout
218        under a stationary cursor no enter event arrives for the new row; the
219        highlight would stay wherever it was while the cursor visibly sits on
220        another row. One hit-test after each relayout keeps the two honest.
221        """
222        pos = Input.mouse_position
223        for i, btn in enumerate(self._choice_buttons[:count]):
224            rect = btn.get_global_rect()
225            if rect[0] <= pos.x <= rect[0] + rect[2] and rect[1] <= pos.y <= rect[1] + rect[3]:
226                return i
227        return 0
228
229    def _set_selected(self, index: int):
230        """Move the keyboard highlight, shown via the hover visual state."""
231        choices = self._choices()
232        if not choices:
233            return
234        self._selected = index % len(choices)
235        for i, btn in enumerate(self._choice_buttons[: len(choices)]):
236            btn.set_visual_state_override("hover" if i == self._selected else None)
237
238    # ------------------------------------------------------------ progression
239
240    def _choose(self, index: int):
241        """Take choice *index*: store its variables, jump to its passage."""
242        label, next_id, sets = self._choices()[index]
243        if sets:
244            self._vars.update(sets)
245        self._id = next_id
246        self._show_current()
247
248    def _advance(self):
249        """Linear step: follow "next", or close the box on an ending."""
250        next_id = self._passage.get("next")
251        if next_id is None:
252            self._panel.visible = False
253            return
254        self._id = next_id
255        self._show_current()
256
257    def on_update(self, dt: float):
258        if not self._panel.visible:
259            return
260        if self._choices():
261            if Input.is_action_just_pressed("choice_up"):
262                self._set_selected(self._selected - 1)
263            if Input.is_action_just_pressed("choice_down"):
264                self._set_selected(self._selected + 1)
265            if Input.is_action_just_pressed("choice_accept"):
266                self._choose(self._selected)
267        elif Input.is_action_just_pressed("choice_accept"):
268            self._advance()
269
270
271def _selftest() -> bool:
272    """Headless: play the conversation by the routes a player uses.
273
274    Every step lands as a click on a button's own rectangle or as a key
275    through the action map, so the anchors, the row stacking, the highlight,
276    the loop-back branch and the variable substitution are all exercised the
277    way the screen exercises them.
278    """
279    from simvx.core.testing import InputSimulator
280    from simvx.core.ui.testing import UITestHarness
281
282    harness = UITestHarness(DialogueDemo(name="DialogueDemo"), screen_size=(800, 600))
283    scene = harness.tree.root
284    sim = InputSimulator(tree=harness.tree)
285    ok = True
286
287    def check(label: str, passed: bool, detail: str) -> None:
288        nonlocal ok
289        ok = ok and passed
290        print(f"{'ok  ' if passed else 'FAIL'} {label}: {detail}")
291
292    def tap(key: Key) -> None:
293        sim.press_key(key)
294        harness.tick()
295        sim.release_key(key)
296        harness.tick()
297
298    harness.tick()
299
300    # BOTTOM_WIDE with a collapsed vertical axis: the panel should be 220px
301    # tall, 20px up from the bottom of an 800x600 screen, full width less its
302    # insets.
303    box = scene._panel
304    px, py, pw, ph = (round(v) for v in box.get_global_rect())
305    check(
306        "the panel sits across the bottom of the screen",
307        (pw, ph) == (760, 220) and (px, py) == (20, 600 - 240),
308        f"{pw}x{ph} at ({px}, {py}) on an 800x600 screen",
309    )
310
311    rows = scene._choice_buttons
312    check(
313        "the opening passage offers two choices and no Continue",
314        [b.visible for b in rows] == [True, True, False] and not scene._continue.visible,
315        f"visible rows = {[b.text for b in rows if b.visible]}",
316    )
317
318    # Click the second choice: it stores the title variable, and the next
319    # passage's text substitutes it.
320    harness.click(rows[1])
321    harness.tick()
322    check(
323        "a clicked choice sets a variable the next line references",
324        scene._vars.get("title") == "learned one" and "learned one" in scene._body.text,
325        f"vars = {scene._vars}, body = {scene._body.text!r}",
326    )
327    check(
328        "a linear passage keeps the Continue flow",
329        scene._continue.visible and scene._continue.text == "Continue",
330        f"button = {scene._continue.text!r}",
331    )
332
333    harness.click(scene._continue)
334    harness.tick()
335    check(
336        "Continue reaches the question with three stacked choices",
337        scene._id == "ask" and [b.visible for b in rows] == [True, True, True],
338        f"at {scene._id!r}, rows = {[b.text for b in rows]}",
339    )
340    r0 = rows[0].get_global_rect()
341    r1 = rows[1].get_global_rect()
342    check(
343        "choice rows stack downward inside the panel",
344        round(r1[1] - r0[1]) == 38 and round(r0[2]) == 720,
345        f"row pitch = {r1[1] - r0[1]:.0f}px, row width = {r0[2]:.0f}px",
346    )
347
348    # The cursor still rests where the Continue click left it, which overlaps
349    # the bottom row, and a relayout hands the highlight to the row under the
350    # cursor (the hover/keyboard handoff a stationary mouse would otherwise
351    # break).
352    check(
353        "a relayout hands the highlight to the row under the cursor",
354        scene._selected == 2,
355        f"selected = {scene._selected} with the cursor parked on the bottom row",
356    )
357
358    # Keyboard: Down twice then Up leaves the highlight on the middle row. The
359    # cursor is parked off the panel first so no row re-claims the highlight.
360    harness.mouse_move((5.0, 5.0))
361    harness.tick()
362    scene._set_selected(0)
363    tap(Key.DOWN)
364    tap(Key.DOWN)
365    tap(Key.UP)
366    check(
367        "Up/Down move the highlight through the rows",
368        scene._selected == 1 and rows[1].visual_state_override == Button.VisualState.HOVER,
369        f"selected = {scene._selected}",
370    )
371
372    # Highlight the first choice and confirm with Enter: that branch loops
373    # back to the same question after one aside.
374    tap(Key.UP)
375    tap(Key.ENTER)
376    check(
377        "Enter takes the highlighted branch",
378        scene._id == "rumour" and "scratching" in scene._body.text,
379        f"at {scene._id!r}",
380    )
381    tap(Key.ENTER)
382    check(
383        "the aside loops back to the question (Enter also advances linear text)",
384        scene._id == "ask" and len(scene._choices()) == 3,
385        f"at {scene._id!r}",
386    )
387
388    # Accept by mouse this time; the farewell passage still remembers the
389    # title chosen at the start, and the ending swaps Continue for Close.
390    harness.click(rows[1])
391    harness.tick()
392    check(
393        "a later passage still substitutes the stored variable",
394        scene._id == "accept" and "learned one" in scene._body.text,
395        f"body = {scene._body.text!r}",
396    )
397    harness.click(scene._continue)
398    harness.tick()
399    check(
400        "the ending passage swaps Continue for Close",
401        scene._id == "farewell" and scene._continue.text == "Close",
402        f"button = {scene._continue.text!r}",
403    )
404    harness.click(scene._continue)
405    harness.tick()
406    check("Close dismisses the box", not box.visible, f"panel visible = {box.visible}")
407
408    harness.teardown()
409    print("SELFTEST:", "PASS" if ok else "FAIL")
410    return ok
411
412
413if __name__ == "__main__":
414    import sys
415
416    if "--test" in sys.argv:
417        sys.exit(0 if _selftest() else 1)
418    App(title="SimVX Dialogue Box", width=800, height=600).run(DialogueDemo())