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