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)