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)