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