Dialogue Box¶
branching NPC dialogue with selectable choices.
â–¶ Run in browserTags: 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
nextid (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_actionsclass attribute polled withInput.is_action_just_pressedinon_update.Conversation variables: a choice stores
{"title": ...}and later text references{title}viastr.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())