Dialogue Box¶
branching NPC dialogue with selectable choices.
▶ Run in browserTags: ui dialogue branching game-ui
A dialogue box across the bottom of the screen plays a small conversation graph. Each passage names a speaker and a line of text, then offers the player one or more options: a list of choices, a Continue button that follows the passage on, or Close at an ending, which dismisses the box.
The dialogue graph¶
The conversation is plain data, a dict from passage id to a passage. A passage
has a speaker, a text, and either next (a linear step) or choices, a
list of (label, next_id, sets) tuples. sets is a dict of conversation
variables the choice stores, and later text reads them through str.format:
picking “A travelling scholar” stores {"title": "learned one"}, and “Well met,
{title}.” then greets the player by it. A passage with neither next nor
choices is an ending. The “rumour” branch loops back to the question, so the
player can ask first and still decide.
Choice rows and focus¶
The options are a pool of Button rows in a VBoxContainer, relabelled for
each passage, with the unused ones hidden. The highlighted row is simply the
focused one, so the engine does the navigation:
focus_mode=FocusMode.HOVERmakes each row a directional-focus destination that also takes focus when the mouse comes to rest over it, so the mouse and the keyboard move one highlight.ui_upandui_downmove focus to the row above or below, andui_acceptpresses the focused row, exactly as a click does.The root’s
input_contextredefines those three engine actions to add the arrow keys, W/S and Space. Redefining an engine action replaces its bindings, so the engine’s pad bindings are listed again: the D-pad, the left stick (with the engine’s 0.5 deadzone, so a resting stick stays still) and the A button.
When a new passage appears the focus stays on the row it was on if that row is still shown, and otherwise moves to the first row. Hidden rows are never focused, so the arrows walk only the options on screen.
Controls: Up/Down or W/S move the highlight, Enter or Space confirm, and a click or a tap picks a row directly.
Source¶
1# /// simvx
2# tags = ["ui", "dialogue", "branching", "game-ui"]
3# web = { width = 800, height = 600, responsive = true }
4# ///
5"""Dialogue Box: branching NPC dialogue with selectable choices.
6
7A dialogue box across the bottom of the screen plays a small conversation
8graph. Each passage names a speaker and a line of text, then offers the player
9one or more options: a list of choices, a Continue button that follows the
10passage on, or Close at an ending, which dismisses the box.
11
12## The dialogue graph
13
14The conversation is plain data, a dict from passage id to a passage. A passage
15has a `speaker`, a `text`, and either `next` (a linear step) or `choices`, a
16list of `(label, next_id, sets)` tuples. `sets` is a dict of conversation
17variables the choice stores, and later text reads them through `str.format`:
18picking "A travelling scholar" stores `{"title": "learned one"}`, and "Well met,
19{title}." then greets the player by it. A passage with neither `next` nor
20`choices` is an ending. The "rumour" branch loops back to the question, so the
21player can ask first and still decide.
22
23## Choice rows and focus
24
25The options are a pool of `Button` rows in a `VBoxContainer`, relabelled for
26each passage, with the unused ones hidden. The highlighted row is simply the
27focused one, so the engine does the navigation:
28
29- `focus_mode=FocusMode.HOVER` makes each row a directional-focus destination
30 that also takes focus when the mouse comes to rest over it, so the mouse and
31 the keyboard move one highlight.
32- `ui_up` and `ui_down` move focus to the row above or below, and `ui_accept`
33 presses the focused row, exactly as a click does.
34- The root's `input_context` redefines those three engine actions to add the
35 arrow keys, W/S and Space. Redefining an engine action replaces its
36 bindings, so the engine's pad bindings are listed again: the D-pad, the
37 left stick (with the engine's 0.5 deadzone, so a resting stick stays still)
38 and the A button.
39
40When a new passage appears the focus stays on the row it was on if that row is
41still shown, and otherwise moves to the first row. Hidden rows are never
42focused, so the arrows walk only the options on screen.
43
44Controls: Up/Down or W/S move the highlight, Enter or Space confirm, and a
45click or a tap picks a row directly.
46"""
47
48from simvx.core import (
49 Anchor,
50 Button,
51 Colour,
52 Controller,
53 FocusMode,
54 InputBinding,
55 Key,
56 Label,
57 Node,
58 Panel,
59 VBoxContainer,
60)
61from simvx.graphics import App
62
63GRAPH = {
64 "greet": {
65 "speaker": "Guide",
66 "text": "Welcome to the village, traveller. Who do I have the honour of addressing?",
67 "choices": [
68 ("A knight errant", "brief", {"title": "sir knight"}),
69 ("A travelling scholar", "brief", {"title": "learned one"}),
70 ],
71 },
72 "brief": {
73 "speaker": "Guide",
74 "text": "Well met, {title}. The old mine to the north has been overrun.",
75 "next": "ask",
76 },
77 "ask": {
78 "speaker": "Guide",
79 "text": "Will you take up the task and clear it out?",
80 "choices": [
81 ("What happened down there?", "rumour", {}),
82 ("I will go at once.", "accept", {}),
83 ("Not today.", "decline", {}),
84 ],
85 },
86 "rumour": {
87 "speaker": "Guide",
88 "text": "The miners heard scratching behind the walls. Nobody has gone back down.",
89 "next": "ask",
90 },
91 "accept": {"speaker": "Guide", "text": "Take this lantern, {title}. You will need its light.", "next": "farewell"},
92 "farewell": {"speaker": "Guide", "text": "Good luck out there. Come back safe."},
93 "decline": {"speaker": "Guide", "text": "A pity, {title}. The offer stands should you change your mind."},
94}
95MAX_CHOICES = 3
96
97
98class DialogueDemo(Node):
99 input_context = {
100 "ui_up": [Key.UP, Key.W, Controller.DPAD_UP, InputBinding(Controller.LEFT_STICK.up, deadzone=0.5)],
101 "ui_down": [Key.DOWN, Key.S, Controller.DPAD_DOWN, InputBinding(Controller.LEFT_STICK.down, deadzone=0.5)],
102 "ui_accept": [Key.ENTER, Key.SPACE, Controller.A],
103 }
104
105 def on_ready(self):
106 self.variables: dict[str, str] = {}
107 self.panel = self.add_child(Panel(name="DialoguePanel", anchor=Anchor.BOTTOM_WIDE, inset=20, size=(0, 220)))
108 self.panel.bg_colour = Colour.hex("#15171F")
109
110 self.speaker = self.panel.add_child(
111 Label(name="Speaker", anchor=Anchor.TOP_WIDE, inset=(20, 14), size=(0, 24), font_size=18.0)
112 )
113 self.speaker.text_colour = Colour.hex("#FFCC66")
114 # The band between the speaker line and the rows; autoshrink keeps a long line inside it.
115 self.body = self.panel.add_child(
116 Label(name="Body", anchor=Anchor.FULL_RECT, inset=(20, 44, 20, 124), font_size=15.0, autoshrink=True)
117 )
118 self.body.text_colour = Colour.LIGHT_GRAY
119
120 rows = self.panel.add_child(VBoxContainer(anchor=Anchor.FULL_RECT, inset=(20, 100, 20, 12), separation=6))
121 self.rows = [
122 rows.add_child(
123 Button(name=f"Choice{i}", size=(0, 32), focus_mode=FocusMode.HOVER, on_press=lambda i=i: self.choose(i))
124 )
125 for i in range(MAX_CHOICES)
126 ]
127 self.show("greet")
128
129 def show(self, passage_id: str | None):
130 """Display a passage and its options, or close the box when there is none."""
131 if passage_id is None:
132 self.panel.visible = False
133 return
134 passage = GRAPH[passage_id]
135 self.speaker.text = passage["speaker"]
136 self.body.text = passage["text"].format(**self.variables)
137 next_id = passage.get("next")
138 self.options = passage.get("choices") or [("Continue" if next_id else "Close", next_id, {})]
139 for i, row in enumerate(self.rows):
140 row.visible = i < len(self.options)
141 if row.visible:
142 row.text = self.options[i][0]
143 if not any(row.focused and row.visible for row in self.rows):
144 self.rows[0].grab_focus()
145
146 def choose(self, index: int):
147 _, next_id, sets = self.options[index]
148 self.variables.update(sets)
149 self.show(next_id)
150
151
152if __name__ == "__main__":
153 App(title="SimVX Dialogue Box", width=800, height=600).run(DialogueDemo())