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))