nodes/dialog_ui.pyΒΆ

Part of Dungeon Explorer.

  1"""Dialog box: NPC portrait area, proper dialog box, typewriter text reveal."""
  2
  3from simvx.core import Anchor, Control, Property
  4from simvx.core.input import Key, KeyEvent, Pointer, PointerEvent, PointerPhase
  5
  6from ._back_button import back_button_hit, draw_back_button
  7
  8
  9class DialogUI(Control):
 10    """Screen-space dialog box with NPC portrait, typewriter effect, and branching choices.
 11
 12    Usage::
 13
 14        dialog = DialogUI()
 15        bridge.add_child(dialog)  # any tree-attached parent works
 16        dialog.start(dialog_tree, game_state, on_action)
 17        bridge.open(dialog)
 18    """
 19
 20    visible = Property(
 21        False,
 22        coerce=bool,
 23        hint="Whether this node and its subtree are drawn",
 24        on_change="_on_visible_changed",
 25    )
 26
 27    # on_draw advances a typewriter reveal (self._revealed_count) and blinks a
 28    # cursor off self._reveal_timer -> retained 2D must re-run it each frame.
 29    dynamic = True
 30
 31    TYPEWRITER_SPEED = 40
 32
 33    def __init__(self, **kwargs):
 34        super().__init__(name="DialogUI", anchor=Anchor.FULL_RECT, **kwargs)
 35
 36        self._dialog_tree: dict | None = None
 37        self._current_node: str | None = None
 38        self._choices: list[dict] = []
 39        self._selected_choice = 0
 40        self._game_state: dict = {}
 41        self._on_action = None
 42        self._closed = False
 43        self._npc_name: str = ""
 44        self._reveal_timer = 0.0
 45        self._full_text = ""
 46        self._revealed_count = 0
 47        self._text_complete = False
 48
 49        self.cancel_requested.connect(self._reset_on_close)
 50
 51    def start(self, dialog_tree: dict, game_state: dict, on_action=None) -> None:
 52        """Configure the dialog tree before opening."""
 53        self._dialog_tree = dialog_tree
 54        self._game_state = game_state
 55        self._on_action = on_action
 56        self._closed = False
 57        self._npc_name = dialog_tree.get("_npc_name", "NPC")
 58        self._navigate("greeting")
 59
 60    def show(self):
 61        self._closed = False
 62        # "light" (no overlay dim scrim): the dialog draws its own subtle
 63        # full-screen scrim in on_draw, so a second blocking dim would stack.
 64        self.show_overlay("light")
 65
 66    def _reset_on_close(self):
 67        self._closed = True
 68        self._dialog_tree = None
 69        self._current_node = None
 70        self._choices = []
 71
 72    @property
 73    def is_active(self) -> bool:
 74        return not self._closed and self._dialog_tree is not None
 75
 76    def _navigate(self, node_id: str | None):
 77        if node_id is None or self._dialog_tree is None:
 78            self._closed = True
 79            self.cancel_requested.emit()
 80            self.close_overlay()
 81            return
 82        node = self._dialog_tree.get(node_id)
 83        if node is None:
 84            self._closed = True
 85            self.cancel_requested.emit()
 86            self.close_overlay()
 87            return
 88        self._current_node = node_id
 89        self._choices = []
 90        for choice in node.get("choices", []):
 91            cond = choice.get("condition")
 92            if cond is None or cond(self._game_state):
 93                self._choices.append(choice)
 94        if not self._choices:
 95            self._choices = [{"text": "...", "next": None}]
 96        self._selected_choice = 0
 97        self._full_text = node.get("text", "")
 98        self._revealed_count = 0
 99        self._reveal_timer = 0.0
100        self._text_complete = False
101
102    def select_choice(self, index: int) -> None:
103        if self._closed or not self._choices:
104            return
105        if 0 <= index < len(self._choices):
106            choice = self._choices[index]
107            action = choice.get("action")
108            if action and self._on_action:
109                self._on_action(action)
110            self._navigate(choice.get("next"))
111
112    # -- Frame update --
113
114    def on_update(self, dt: float):
115        if self._closed or not self.visible:
116            return
117        self._reveal_timer += dt
118        target = int(self._reveal_timer * self.TYPEWRITER_SPEED)
119        if target >= len(self._full_text):
120            self._revealed_count = len(self._full_text)
121            self._text_complete = True
122        else:
123            self._revealed_count = target
124
125    # -- Input --
126
127    def on_gui_input(self, event):
128        if not self.visible or self._closed:
129            return
130        if isinstance(event, PointerEvent):
131            if event.button in Pointer.PRIMARY and event.phase is PointerPhase.DOWN:
132                return self._handle_click(*event.position)
133            return None
134        if isinstance(event, KeyEvent) and event.pressed:
135            if not self._text_complete and event.key in (Key.SPACE, Key.ENTER, Key.E):
136                self._revealed_count = len(self._full_text)
137                self._text_complete = True
138                return True
139            if event.key in (Key.W, Key.UP):
140                self._selected_choice = max(self._selected_choice - 1, 0)
141                return True
142            elif event.key in (Key.S, Key.DOWN):
143                self._selected_choice = min(self._selected_choice + 1, len(self._choices) - 1)
144                return True
145            elif event.key in (Key.SPACE, Key.ENTER, Key.E):
146                self.select_choice(self._selected_choice)
147                return True
148
149    def _handle_click(self, mx: float, my: float) -> bool | None:
150        if back_button_hit(mx, my):
151            self.cancel_requested.emit()
152            self.close_overlay()
153            return True
154        if not self._text_complete:
155            self._revealed_count = len(self._full_text)
156            self._text_complete = True
157            return True
158        sw, sh = self._screen_size()
159        box_w = min(650, sw - 40)
160        box_x = (sw - box_w) / 2
161        choice_h = len(self._choices) * 26
162        box_h = 180 + choice_h
163        box_y = (sh - box_h) / 2
164        text_x = box_x + 15 + 60 + 15
165        cy = box_y + 120
166        for i in range(len(self._choices)):
167            iy = cy + i * 26
168            if text_x <= mx <= box_x + box_w - 10 and iy - 4 <= my <= iy + 22:
169                self._selected_choice = i
170                self.select_choice(i)
171                return True
172
173    # -- Rendering --
174
175    def _screen_size(self) -> tuple[float, float]:
176        if self.tree is not None:
177            sw, sh = self.tree.screen_size
178            return float(sw), float(sh)
179        return 1280.0, 720.0
180
181    def on_draw(self, renderer):
182        if not self.visible or self._closed or self._dialog_tree is None or self._current_node is None:
183            return
184        sw, sh = self._screen_size()
185
186        renderer.rect((0, 0), (sw, sh), fill=(0.0, 0.0, 0.0, 0.3))
187
188        box_w = min(650, sw - 40)
189        choice_h = len(self._choices) * 26 if self._text_complete else 0
190        box_h = 180 + choice_h
191        box_x = (sw - box_w) / 2
192        box_y = (sh - box_h) / 2
193
194        renderer.rect((box_x, box_y), (box_w, box_h), fill=(0.08, 0.08, 0.12, 0.95))
195        renderer.rect((box_x, box_y), (box_w, 3), fill=(0.4, 0.4, 0.5, 0.8))
196        renderer.rect((box_x, box_y + box_h - 2), (box_w, 2), fill=(0.2, 0.2, 0.25, 0.5))
197
198        portrait_size = 60
199        portrait_x = box_x + 15
200        portrait_y = box_y + 15
201        renderer.rect((portrait_x, portrait_y), (portrait_size, portrait_size), fill=(0.15, 0.15, 0.2, 0.9))
202        renderer.circle(
203            (portrait_x + portrait_size / 2, portrait_y + portrait_size / 2 - 5), 15, fill=(0.6, 0.5, 0.4, 0.8)
204        )
205        renderer.rect((portrait_x + 20, portrait_y + 22), (4, 4), fill=(0.2, 0.2, 0.3, 1.0))
206        renderer.rect((portrait_x + 36, portrait_y + 22), (4, 4), fill=(0.2, 0.2, 0.3, 1.0))
207
208        renderer.text(
209            self._npc_name, (portrait_x + portrait_size + 15, box_y + 15), fill=(0.9, 0.8, 0.5, 1.0), scale=1.2
210        )
211
212        text_x = portrait_x + portrait_size + 15
213        text_y = box_y + 40
214        visible_text = self._full_text[: self._revealed_count]
215        lines = _word_wrap(visible_text, 50)
216        for i, line in enumerate(lines[:4]):
217            renderer.text(line, (text_x, text_y + i * 22), fill=(1.0, 1.0, 1.0, 1.0), scale=1.0)
218
219        if not self._text_complete:
220            cursor_x = text_x + min(len(lines[-1]) * 8 if lines else 0, 400)
221            cursor_y = text_y + (len(lines) - 1) * 22 if lines else text_y
222            blink = int(self._reveal_timer * 3) % 2
223            if blink:
224                renderer.rect((cursor_x, cursor_y), (2, 14), fill=(1.0, 1.0, 1.0, 0.7))
225
226        if self._text_complete:
227            cy = box_y + 120
228            for i, choice in enumerate(self._choices):
229                colour = (1.0, 0.9, 0.3, 1.0) if i == self._selected_choice else (0.85, 0.85, 0.85, 1.0)
230                prefix = "> " if i == self._selected_choice else "  "
231                renderer.text(f"{prefix}{choice['text']}", (text_x, cy), fill=colour, scale=1.0)
232                cy += 26
233
234        if not self._text_complete:
235            renderer.text(
236                "[Space to continue]", (box_x + box_w - 160, box_y + box_h - 22), fill=(0.4, 0.4, 0.4, 0.6), scale=0.8
237            )
238
239        draw_back_button(renderer)
240
241
242def _word_wrap(text: str, max_chars: int) -> list[str]:
243    """Simple word wrapping."""
244    if not text:
245        return [""]
246    words = text.split()
247    lines = []
248    current = ""
249    for word in words:
250        if current and len(current) + 1 + len(word) > max_chars:
251            lines.append(current)
252            current = word
253        elif current:
254            current += " " + word
255        else:
256            current = word
257    if current:
258        lines.append(current)
259    return lines or [""]