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 [""]