Dialogue Box

branching NPC dialogue with selectable choices.

▶ Run in browser

Tags: 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.HOVER makes 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_up and ui_down move focus to the row above or below, and ui_accept presses the focused row, exactly as a click does.

  • The root’s input_context redefines 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())