nodes/quest_board.py¶
Part of Dungeon Explorer.
1"""Quest board popup: rotating selection of quests with scroll support."""
2
3import math
4import random
5
6from simvx.core import Control, Property
7from simvx.core.input import MouseButton
8from simvx.core.ui.enums import AnchorPreset
9
10from ._back_button import back_button_hit, draw_back_button
11from .quest_log_ui import _TYPE_COLOURS, _draw_type_icon
12
13BOARD_SLOTS = 4 # Max quests shown at once
14
15
16class QuestBoardUI(Control):
17 """Quest board showing a rotating selection of available quests.
18
19 The board picks up to BOARD_SLOTS quests from the available pool.
20 The selection refreshes when the player reaches a deeper floor or
21 completes a quest, keeping the board fresh without being overwhelming.
22 """
23
24 visible = Property(
25 False,
26 coerce=bool,
27 hint="Whether this node and its subtree are drawn",
28 on_change="_on_visible_changed",
29 )
30
31 # on_draw pulses the selected row off self._timer and fades the feedback
32 # toast off self._feedback_timer -> retained 2D must re-run it each frame.
33 dynamic = True
34
35 def __init__(self, quest_manager=None, **kwargs):
36 super().__init__(name="QuestBoardUI", **kwargs)
37 self.set_anchor_preset(AnchorPreset.FULL_RECT)
38
39 self._mgr = quest_manager
40 self._cursor = 0
41 self._scroll = 0
42 self._current_floor = 1
43 self._timer = 0.0
44 self._feedback_text = ""
45 self._feedback_timer = 0.0
46 self._displayed: list[dict] = []
47 self._last_floor = 0
48 self._last_completed_count = 0
49
50 def set_manager(self, mgr):
51 self._mgr = mgr
52
53 def set_floor(self, floor: int):
54 self._current_floor = floor
55
56 def show(self):
57 self._cursor = 0
58 self._scroll = 0
59 self._timer = 0.0
60 self._feedback_text = ""
61 self._feedback_timer = 0.0
62 self._refresh_if_stale()
63 self.show_overlay("blocking")
64
65 def _refresh_if_stale(self):
66 if self._mgr is None:
67 return
68 completed_count = len(self._mgr.completed)
69 if (
70 self._current_floor != self._last_floor
71 or completed_count != self._last_completed_count
72 or not self._displayed
73 ):
74 self._pick_quests()
75 self._last_floor = self._current_floor
76 self._last_completed_count = completed_count
77
78 def _pick_quests(self):
79 if self._mgr is None:
80 self._displayed = []
81 return
82 pool = self._mgr.available_quests(self._current_floor)
83 if len(pool) <= BOARD_SLOTS:
84 self._displayed = list(pool)
85 return
86 rng = random.Random(self._current_floor * 1000 + len(self._mgr.completed) * 7)
87 shuffled = list(pool)
88 rng.shuffle(shuffled)
89 self._displayed = shuffled[:BOARD_SLOTS]
90
91 # -- Frame update --
92
93 def on_update(self, dt: float):
94 self._timer += dt
95 if self._feedback_timer > 0:
96 self._feedback_timer -= dt
97
98 # -- Input --
99
100 def _on_gui_input(self, event):
101 if not self.visible:
102 return
103 if event.button == MouseButton.LEFT and event.pressed:
104 self._handle_click(float(event.position[0]), float(event.position[1]), event)
105 return
106 if event.key and event.pressed and self._mgr is not None:
107 avail = self._displayed
108 if event.key in ("w", "up"):
109 if self._cursor > 0:
110 self._cursor -= 1
111 if self._cursor < self._scroll:
112 self._scroll = self._cursor
113 event.handled = True
114 elif event.key in ("s", "down"):
115 if self._cursor < len(avail) - 1:
116 self._cursor += 1
117 max_visible = 5
118 if self._cursor >= self._scroll + max_visible:
119 self._scroll = self._cursor - max_visible + 1
120 event.handled = True
121 elif event.key in ("space", "enter", "return"):
122 if 0 <= self._cursor < len(avail):
123 self._accept(avail[self._cursor]["id"])
124 event.handled = True
125 elif event.key == "e":
126 self.cancel_requested.emit()
127 self.close_overlay()
128 event.handled = True
129
130 def _accept(self, quest_id: str):
131 if self._mgr is None:
132 return
133 if len(self._mgr.active) >= self._mgr.MAX_ACTIVE:
134 self._feedback_text = f"Quest log full! ({self._mgr.MAX_ACTIVE}/{self._mgr.MAX_ACTIVE})"
135 self._feedback_timer = 2.0
136 elif self._mgr.accept_quest(quest_id):
137 self._feedback_text = "Quest Accepted!"
138 self._feedback_timer = 1.5
139 self._pick_quests()
140 self._cursor = min(self._cursor, max(0, len(self._displayed) - 1))
141
142 def _handle_click(self, mx: float, my: float, event):
143 if back_button_hit(mx, my):
144 self.cancel_requested.emit()
145 self.close_overlay()
146 event.handled = True
147 return
148 if self._mgr is None:
149 return
150 sw, sh = self._screen_size()
151 panel_w = 600
152 panel_h = 420
153 px = (sw - panel_w) / 2
154 py = (sh - panel_h) / 2
155 list_y = py + 56
156 item_h = 65
157 avail = self._displayed
158 for vi, i in enumerate(range(self._scroll, min(len(avail), self._scroll + 5))):
159 iy = list_y + vi * item_h
160 if px + 10 <= mx <= px + panel_w - 10 and iy - 2 <= my <= iy + item_h - 4:
161 self._cursor = i
162 self._accept(avail[i]["id"])
163 event.handled = True
164 return
165
166 # -- Rendering --
167
168 def _screen_size(self) -> tuple[float, float]:
169 if self.tree is not None:
170 sw, sh = self.tree.screen_size
171 return float(sw), float(sh)
172 return 1280.0, 720.0
173
174 def on_draw(self, renderer):
175 if not self.visible or self._mgr is None:
176 return
177 sw, sh = self._screen_size()
178
179 renderer.draw_rect((0, 0), (sw, sh), colour=(0.0, 0.0, 0.0, 0.7), filled=True)
180
181 panel_w = 600
182 panel_h = 420
183 px = (sw - panel_w) / 2
184 py = (sh - panel_h) / 2
185
186 renderer.draw_rect((px, py), (panel_w, panel_h), colour=(0.08, 0.08, 0.12, 0.95), filled=True)
187 renderer.draw_rect((px, py), (panel_w, 3), colour=(0.6, 0.5, 0.2, 0.8), filled=True)
188 renderer.draw_rect((px, py + panel_h - 3), (panel_w, 3), colour=(0.3, 0.3, 0.35, 0.5), filled=True)
189
190 cx = sw / 2
191
192 renderer.draw_text("QUEST BOARD", (cx - 72, py + 12), scale=2.0, colour=(1.0, 0.85, 0.3))
193 # Floor indicator on the right edge of the title row, well clear of the centred title.
194 floor_text = f"Floor {self._current_floor}"
195 renderer.draw_text(
196 floor_text, (px + panel_w - 12 - len(floor_text) * 8, py + 18), scale=1.0, colour=(0.6, 0.55, 0.35)
197 )
198
199 renderer.draw_rect((px + 15, py + 46), (panel_w - 30, 1), colour=(0.3, 0.3, 0.35, 0.5), filled=True)
200
201 avail = self._displayed
202 list_y = py + 56
203 item_h = 65
204 max_visible = 5
205
206 if not avail:
207 renderer.draw_text(
208 "No quests available for this floor.", (px + 30, list_y + 40), scale=1.1, colour=(0.5, 0.5, 0.5)
209 )
210 renderer.draw_text(
211 "Explore deeper to unlock more quests.", (px + 30, list_y + 65), scale=0.85, colour=(0.4, 0.4, 0.45)
212 )
213 else:
214 visible_start = self._scroll
215 visible_end = min(len(avail), visible_start + max_visible)
216
217 for vi, i in enumerate(range(visible_start, visible_end)):
218 qdef = avail[i]
219 y = list_y + vi * item_h
220 sel = i == self._cursor
221
222 if sel:
223 pulse = 0.22 + 0.04 * math.sin(self._timer * 4.0)
224 renderer.draw_rect(
225 (px + 10, y - 2),
226 (panel_w - 20, item_h - 4),
227 colour=(pulse, pulse, pulse + 0.08, 0.8),
228 filled=True,
229 )
230
231 qtype = qdef.get("type", "kill")
232 tc = _TYPE_COLOURS.get(qtype, (0.6, 0.6, 0.6, 1.0))
233 _draw_type_icon(renderer, px + 32, y + 18, qtype, tc)
234
235 name_colour = (1.0, 0.9, 0.3) if sel else (0.85, 0.85, 0.85)
236 renderer.draw_text(qdef["name"], (px + 50, y + 4), scale=1.1, colour=name_colour)
237
238 target = qdef.get("target", "") or "any"
239 count = qdef.get("count", 1)
240 if qtype == "kill":
241 desc = f"Kill {count} {target.replace('_', ' ').title()}"
242 elif qtype == "floor":
243 desc = f"Reach Floor {count}"
244 elif qtype == "collect":
245 desc = f"Collect {count} {target.replace('_', ' ').title()} Items"
246 elif qtype == "boss":
247 desc = f"Defeat {target.replace('_', ' ').title()}"
248 else:
249 desc = f"{qtype.title()}: {target} x{count}"
250 renderer.draw_text(desc, (px + 50, y + 22), scale=0.85, colour=(0.5, 0.5, 0.55))
251
252 prereq = qdef.get("prerequisite_quest", "")
253 if prereq:
254 renderer.draw_rect((px + 50, y + 40), (40, 14), colour=(0.3, 0.25, 0.1, 0.7), filled=True)
255 renderer.draw_text("Chain", (px + 53, y + 41), scale=0.65, colour=(0.9, 0.75, 0.3))
256
257 rewards = qdef.get("rewards", {})
258 rx = px + panel_w - 30
259 ry = y + 6
260 if "gold" in rewards:
261 gold_str = str(rewards["gold"])
262 renderer.draw_circle(
263 (rx - len(gold_str) * 7 - 14, ry + 6), 5, colour=(1.0, 0.85, 0.2, 0.9), filled=True
264 )
265 renderer.draw_text(gold_str, (rx - len(gold_str) * 7, ry), scale=0.9, colour=(1.0, 0.85, 0.2))
266 ry += 18
267 if "xp" in rewards:
268 xp_str = str(rewards["xp"])
269 sx = rx - len(xp_str) * 7 - 14
270 renderer.draw_rect((sx - 3, ry + 4), (6, 6), colour=(0.4, 0.6, 1.0, 0.9), filled=True)
271 renderer.draw_text(xp_str, (rx - len(xp_str) * 7, ry), scale=0.9, colour=(0.4, 0.6, 1.0))
272
273 if visible_start > 0:
274 renderer.draw_text("^ more ^", (cx - 28, list_y - 12), scale=0.7, colour=(0.5, 0.5, 0.5))
275 if visible_end < len(avail):
276 bottom = list_y + (visible_end - visible_start) * item_h
277 renderer.draw_text("v more v", (cx - 28, bottom), scale=0.7, colour=(0.5, 0.5, 0.5))
278
279 total_avail = len(self._mgr.available_quests(self._current_floor))
280 if total_avail > len(avail):
281 renderer.draw_text(
282 f"Showing {len(avail)} of {total_avail}: new quests appear as you progress",
283 (px + 15, py + panel_h - 62),
284 scale=0.7,
285 colour=(0.4, 0.4, 0.45),
286 )
287
288 if self._feedback_timer > 0 and self._feedback_text:
289 alpha = min(1.0, self._feedback_timer / 0.3)
290 is_error = "full" in self._feedback_text.lower()
291 fb_colour = (1.0, 0.4, 0.3, alpha) if is_error else (0.3, 1.0, 0.5, alpha)
292 fb_bg = (0.3, 0.1, 0.1, alpha * 0.8) if is_error else (0.1, 0.25, 0.1, alpha * 0.8)
293 fb_w = len(self._feedback_text) * 9 + 20
294 fb_x = cx - fb_w / 2
295 fb_y = py + panel_h - 80
296 renderer.draw_rect((fb_x, fb_y), (fb_w, 24), colour=fb_bg, filled=True)
297 renderer.draw_text(self._feedback_text, (fb_x + 10, fb_y + 4), scale=1.0, colour=fb_colour)
298
299 renderer.draw_text(
300 f"Active: {len(self._mgr.active)}/{self._mgr.MAX_ACTIVE}",
301 (px + 15, py + panel_h - 40),
302 scale=0.9,
303 colour=(0.8, 0.8, 0.5),
304 )
305 renderer.draw_text(
306 "Space: Accept E/Esc: Close", (px + 15, py + panel_h - 22), scale=0.8, colour=(0.4, 0.4, 0.4)
307 )
308 draw_back_button(renderer)