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)