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