nodes/quest_log_ui.py¶
Part of Dungeon Explorer.
1"""Quest log popup: active/completed tabs, progress bars, type icons, claim indicators."""
2
3import math
4
5from simvx.core import Control, Property
6from simvx.core.input import MouseButton
7from simvx.core.ui.enums import AnchorPreset
8
9from ._back_button import back_button_hit, draw_back_button
10
11# Quest type icons
12_TYPE_COLOURS = {
13 "kill": (0.8, 0.3, 0.2, 1.0),
14 "floor": (0.3, 0.6, 0.9, 1.0),
15 "boss": (0.9, 0.2, 0.5, 1.0),
16 "collect": (0.8, 0.7, 0.2, 1.0),
17}
18
19_PW, _PH = 550, 480
20_TITLE_H = 36
21
22
23class QuestLogUI(Control):
24 """Quest log with active/completed tabs and progress bars.
25
26 Overlay Control hosting a custom-drawn list of quests. With at most a
27 handful of active and completed entries the list fits in the panel
28 without scrolling; if that ever changes, swap the inline draw for an
29 embedded ScrollContainer.
30 """
31
32 visible = Property(
33 False,
34 coerce=bool,
35 hint="Whether this node and its subtree are drawn",
36 on_change="_on_visible_changed",
37 )
38
39 # on_draw pulses the "[COMPLETE -- Space to claim]" prompt off self._timer
40 # each frame -> retained 2D must re-run it each frame.
41 dynamic = True
42
43 def __init__(self, quest_manager=None, on_claim=None, **kwargs):
44 super().__init__(name="QuestLogUI", **kwargs)
45 self.set_anchor_preset(AnchorPreset.FULL_RECT)
46
47 self._mgr = quest_manager
48 self._on_claim = on_claim
49 self._cursor = 0
50 self._tab = 0 # 0=active, 1=completed
51 self._timer = 0.0
52
53 def set_manager(self, mgr):
54 self._mgr = mgr
55
56 def show(self):
57 self._cursor = 0
58 self._tab = 0
59 self._timer = 0.0
60 self.show_overlay("blocking", dismiss=True)
61
62 def _max_cursor(self) -> int:
63 if self._tab == 0:
64 return max(0, len(self._mgr.active) - 1)
65 return max(0, len(self._mgr.completed) - 1)
66
67 # -- Frame update --
68
69 def on_update(self, dt: float):
70 self._timer += dt
71
72 # -- Input --
73
74 def _on_gui_input(self, event):
75 if not self.visible or self._mgr is None:
76 return
77 if event.button == MouseButton.LEFT and event.pressed:
78 mx, my = float(event.position[0]), float(event.position[1])
79 if back_button_hit(mx, my):
80 self.cancel_requested.emit()
81 self.close_overlay()
82 event.handled = True
83 return
84 if event.key and event.pressed:
85 if event.key in ("a", "left", "d", "right"):
86 self._tab = 1 - self._tab
87 self._cursor = 0
88 event.handled = True
89 elif event.key in ("w", "up"):
90 self._cursor = max(0, self._cursor - 1)
91 event.handled = True
92 elif event.key in ("s", "down"):
93 self._cursor = min(self._cursor + 1, self._max_cursor())
94 event.handled = True
95 elif event.key in ("space", "enter", "return"):
96 if self._tab == 0 and 0 <= self._cursor < len(self._mgr.active):
97 quest = self._mgr.active[self._cursor]
98 if quest.is_complete:
99 rewards = self._mgr.claim_rewards(quest.quest_id)
100 if rewards and self._on_claim:
101 self._on_claim(quest.name, rewards)
102 self._cursor = min(self._cursor, self._max_cursor())
103 event.handled = True
104 elif event.key == "j":
105 self.cancel_requested.emit()
106 self.close_overlay()
107 event.handled = True
108
109 # -- Rendering --
110
111 def _screen_size(self) -> tuple[float, float]:
112 if self.tree is not None:
113 sw, sh = self.tree.screen_size
114 return float(sw), float(sh)
115 return 1280.0, 720.0
116
117 def _panel_origin(self) -> tuple[float, float]:
118 sw, sh = self._screen_size()
119 return (sw - _PW) / 2, (sh - _PH) / 2
120
121 def on_draw(self, renderer):
122 if not self.visible:
123 return
124 sw, sh = self._screen_size()
125 renderer.draw_rect((0, 0), (sw, sh), colour=(0.0, 0.0, 0.0, 0.6), filled=True)
126
127 if self._mgr is None:
128 return
129
130 px, py = self._panel_origin()
131 renderer.draw_rect((px, py), (_PW, _PH), colour=(0.12, 0.12, 0.18, 0.95), filled=True)
132 renderer.draw_rect((px, py), (_PW, _TITLE_H), colour=(0.15, 0.15, 0.25, 1.0), filled=True)
133 renderer.draw_text("QUEST LOG", (px + 12, py + 8), scale=1.5, colour=(1, 1, 1))
134
135 # Content origin
136 cx = px
137 cy = py + _TITLE_H
138 pw = _PW
139
140 mgr = self._mgr
141
142 # Tabs
143 tab_y = cy + 10
144 for i, label in enumerate(("Active", "Completed")):
145 tx = cx + 5 + i * 120
146 active = i == self._tab
147 if active:
148 renderer.draw_rect((tx, tab_y), (100, 24), colour=(0.2, 0.2, 0.3, 0.8), filled=True)
149 tc = (1.0, 0.9, 0.3, 1.0)
150 else:
151 tc = (0.5, 0.5, 0.5, 1.0)
152 renderer.draw_text(label, (tx + 8, tab_y + 4), scale=1.0, colour=tc)
153
154 renderer.draw_rect((cx, tab_y + 28), (pw - 10, 1), colour=(0.3, 0.3, 0.35, 0.5), filled=True)
155
156 list_y = tab_y + 40
157
158 if self._tab == 0:
159 if not mgr.active:
160 renderer.draw_text(
161 "No active quests. Visit the quest board in town!",
162 (cx + 10, list_y + 20),
163 scale=1.0,
164 colour=(0.5, 0.5, 0.5),
165 )
166 for i, quest in enumerate(mgr.active):
167 y = list_y + i * 70
168 sel = i == self._cursor
169 if sel:
170 renderer.draw_rect((cx, y - 2), (pw - 10, 66), colour=(0.2, 0.2, 0.3, 0.8), filled=True)
171
172 done = quest.is_complete
173 tc = _TYPE_COLOURS.get(quest.quest_type, (0.6, 0.6, 0.6, 1.0))
174 _draw_type_icon(renderer, cx + 15, y + 12, quest.quest_type, tc)
175
176 if done:
177 colour = (0.3, 1.0, 0.3)
178 elif sel:
179 colour = (1.0, 1.0, 1.0)
180 else:
181 colour = (0.7, 0.7, 0.7)
182 renderer.draw_text(quest.name, (cx + 35, y + 4), scale=1.1, colour=colour)
183
184 bar_x = cx + 35
185 bar_y = y + 26
186 bar_w = 300
187 bar_h = 8
188 ratio = min(1.0, quest.progress / max(1, quest.count))
189 renderer.draw_rect((bar_x, bar_y), (bar_w, bar_h), colour=(0.15, 0.15, 0.18, 0.8), filled=True)
190 bar_colour = (0.3, 0.8, 0.3, 0.9) if done else (0.3, 0.5, 0.8, 0.9)
191 renderer.draw_rect((bar_x, bar_y), (int(bar_w * ratio), bar_h), colour=bar_colour, filled=True)
192 renderer.draw_text(
193 f"{quest.progress}/{quest.count}", (bar_x + bar_w + 8, bar_y - 2), scale=0.8, colour=(0.6, 0.6, 0.6)
194 )
195
196 if done:
197 pulse = 0.6 + 0.4 * math.sin(self._timer * 3.0)
198 renderer.draw_text(
199 "[COMPLETE -- Space to claim]", (cx + 35, y + 42), scale=0.85, colour=(0.3, 1.0, 0.3, pulse)
200 )
201 else:
202 renderer.draw_text(
203 f"Type: {quest.quest_type} | Target: {quest.target}",
204 (cx + 35, y + 42),
205 scale=0.7,
206 colour=(0.45, 0.45, 0.5),
207 )
208 else:
209 if not mgr.completed:
210 renderer.draw_text(
211 "No completed quests yet.", (cx + 10, list_y + 20), scale=1.0, colour=(0.5, 0.5, 0.5)
212 )
213 for i, qid in enumerate(mgr.completed):
214 y = list_y + i * 28
215 sel = i == self._cursor
216 if sel:
217 renderer.draw_rect((cx, y - 2), (pw - 10, 24), colour=(0.15, 0.2, 0.15, 0.6), filled=True)
218 colour = (0.4, 0.7, 0.4) if sel else (0.35, 0.5, 0.35)
219 renderer.draw_text(f" {qid}", (cx + 10, y + 2), scale=0.9, colour=colour)
220
221 renderer.draw_text(
222 f"Completed: {len(mgr.completed)}", (cx + 5, py + _PH - 50), scale=0.9, colour=(0.5, 0.5, 0.5)
223 )
224 renderer.draw_text(
225 "A/D: Tab W/S: Navigate Space: Claim J/Esc: Close",
226 (cx + 5, py + _PH - 25),
227 scale=0.8,
228 colour=(0.4, 0.4, 0.4),
229 )
230 draw_back_button(renderer)
231
232
233def _draw_type_icon(renderer, cx: float, cy: float, quest_type: str, colour: tuple):
234 """Draw a small icon representing the quest type."""
235 r, g, b, a = colour
236 if quest_type == "kill":
237 renderer.draw_rect((cx - 1, cy - 5), (2, 10), colour=(r, g, b, a), filled=True)
238 renderer.draw_rect((cx - 3, cy), (6, 2), colour=(r * 0.7, g * 0.7, b * 0.7, a), filled=True)
239 elif quest_type == "floor":
240 renderer.draw_rect((cx - 4, cy + 2), (4, 3), colour=(r, g, b, a), filled=True)
241 renderer.draw_rect((cx - 1, cy - 1), (4, 3), colour=(r, g, b, a), filled=True)
242 renderer.draw_rect((cx + 2, cy - 4), (4, 3), colour=(r, g, b, a), filled=True)
243 elif quest_type == "boss":
244 renderer.draw_rect((cx - 4, cy), (8, 4), colour=(r, g, b, a), filled=True)
245 renderer.draw_rect((cx - 4, cy - 3), (2, 3), colour=(r, g, b, a), filled=True)
246 renderer.draw_rect((cx - 1, cy - 4), (2, 4), colour=(r, g, b, a), filled=True)
247 renderer.draw_rect((cx + 2, cy - 3), (2, 3), colour=(r, g, b, a), filled=True)
248 elif quest_type == "collect":
249 renderer.draw_rect((cx - 3, cy - 2), (6, 4), colour=(r, g, b, a), filled=True)
250 renderer.draw_rect((cx - 1, cy - 4), (2, 2), colour=(r, g, b, a * 0.7), filled=True)
251 renderer.draw_rect((cx - 1, cy + 2), (2, 2), colour=(r, g, b, a * 0.7), filled=True)
252 else:
253 renderer.draw_circle((cx, cy), 4, colour=(r, g, b, a), filled=True)