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