nodes/skill_tree_ui.py¶

Part of Dungeon Explorer.

  1"""Skill tree popup UI: tree lines, unique drawn icons, glow effects, full tooltip."""
  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_BRANCH_COLOURS = {
 12    "warrior": (0.8, 0.3, 0.2, 1.0),
 13    "ranger": (0.3, 0.7, 0.3, 1.0),
 14    "mage": (0.3, 0.4, 0.9, 1.0),
 15}
 16_BRANCHES = ["warrior", "ranger", "mage"]
 17
 18_SKILL_ICONS = {
 19    "warrior": "sword",
 20    "ranger": "arrow",
 21    "mage": "orb",
 22}
 23
 24_TITLE_H = 36
 25
 26# Panel-size bounds. The actual panel size is computed per-frame from the
 27# viewport in _panel_size() so the same code path scales from 360x640 mobile
 28# portrait through 1920x1080 desktop without any aspect-ratio branching.
 29_PW_MIN, _PW_MAX = 320.0, 960.0
 30_PH_MIN, _PH_MAX = 420.0, 760.0
 31
 32
 33def _draw_skill_icon(renderer, cx: float, cy: float, branch: str, unlocked: bool, colour: tuple):
 34    """Draw a small iconic shape for a skill node."""
 35    r, g, b, a = colour
 36    if not unlocked:
 37        r, g, b, a = r * 0.4, g * 0.4, b * 0.4, 0.5
 38
 39    icon = _SKILL_ICONS.get(branch, "orb")
 40    if icon == "sword":
 41        renderer.draw_rect((cx - 1, cy - 7), (2, 14), colour=(r, g, b, a), filled=True)
 42        renderer.draw_rect((cx - 4, cy), (8, 2), colour=(r * 0.7, g * 0.7, b * 0.7, a), filled=True)
 43    elif icon == "arrow":
 44        renderer.draw_rect((cx - 1, cy - 6), (2, 12), colour=(r, g, b, a), filled=True)
 45        renderer.fill_triangle(cx, cy - 8, cx - 3, cy - 4, cx + 3, cy - 4, colour=(r, g, b, a))
 46    elif icon == "orb":
 47        renderer.draw_circle((cx, cy), 5, colour=(r, g, b, a), filled=True)
 48        if unlocked:
 49            highlight = (min(1, r + 0.3), min(1, g + 0.3), min(1, b + 0.3), a * 0.6)
 50            renderer.draw_circle((cx, cy), 3, colour=highlight, filled=True)
 51
 52
 53class SkillTreeUI(Control):
 54    """Skill tree popup with three branch columns, tree lines, icons, and tooltips."""
 55
 56    visible = Property(
 57        False,
 58        coerce=bool,
 59        hint="Whether this node and its subtree are drawn",
 60        on_change="_on_visible_changed",
 61    )
 62
 63    # on_draw pulses the selected-node glow and unlocked-icon halos off
 64    # self._timer each frame -> retained 2D must re-run it each frame.
 65    dynamic = True
 66
 67    def __init__(self, skill_tree=None, **kwargs):
 68        super().__init__(name="SkillTreeUI", **kwargs)
 69        self.set_anchor_preset(AnchorPreset.FULL_RECT)
 70
 71        self._skill_tree = skill_tree
 72        self._player = None
 73        self._branch_idx = 0
 74        self._node_idx = 0
 75        self._timer = 0.0
 76
 77        self.cancel_requested.connect(self._on_close_signal)
 78
 79    def set_skill_tree(self, tree):
 80        self._skill_tree = tree
 81
 82    def set_player(self, player):
 83        self._player = player
 84
 85    def show(self):
 86        self._branch_idx = 0
 87        self._node_idx = 0
 88        self._timer = 0.0
 89        if self._player and self._skill_tree:
 90            self._skill_tree.skill_points = self._player.skill_points
 91        self.show_overlay("blocking", dismiss=True)
 92
 93    def _on_close_signal(self):
 94        if self._player and self._skill_tree:
 95            self._player.skill_points = self._skill_tree.skill_points
 96
 97    # -- Frame update --
 98
 99    def on_update(self, dt: float):
100        self._timer += dt
101
102    # -- Layout --
103
104    def _screen_size(self) -> tuple[float, float]:
105        if self.tree is not None:
106            sw, sh = self.tree.screen_size
107            return float(sw), float(sh)
108        return 1280.0, 720.0
109
110    def _panel_size(self) -> tuple[float, float]:
111        """Compute panel size from viewport with clamps.
112
113        Panel is min(95% viewport, _PW_MAX) wide and min(95% viewport,
114        _PH_MAX) tall. Below the minimums the panel still draws: content
115        compresses via fit_to_width text rendering.
116        """
117        sw, sh = self._screen_size()
118        pw = min(max(sw - 20.0, _PW_MIN), _PW_MAX)
119        ph = min(max(sh - 20.0, _PH_MIN), _PH_MAX)
120        # Never overflow the viewport.
121        pw = min(pw, sw - 10.0)
122        ph = min(ph, sh - 10.0)
123        return pw, ph
124
125    def _panel_origin(self) -> tuple[float, float]:
126        sw, sh = self._screen_size()
127        pw, ph = self._panel_size()
128        return (sw - pw) / 2, (sh - ph) / 2
129
130    # -- Input --
131
132    def _on_gui_input(self, event):
133        if not self.visible or self._skill_tree is None:
134            return
135        if event.button == MouseButton.LEFT and event.pressed:
136            self._handle_click(float(event.position[0]), float(event.position[1]), event)
137            return
138        if event.key and event.pressed:
139            if event.key in ("a", "left"):
140                self._branch_idx = max(0, self._branch_idx - 1)
141                self._node_idx = 0
142                event.handled = True
143            elif event.key in ("d", "right"):
144                self._branch_idx = min(2, self._branch_idx + 1)
145                self._node_idx = 0
146                event.handled = True
147            elif event.key in ("w", "up"):
148                self._node_idx = max(0, self._node_idx - 1)
149                event.handled = True
150            elif event.key in ("s", "down"):
151                branch = _BRANCHES[self._branch_idx]
152                skills = self._skill_tree.branch_skills(branch)
153                self._node_idx = min(self._node_idx + 1, max(0, len(skills) - 1))
154                event.handled = True
155            elif event.key in ("space", "enter", "return"):
156                self._try_unlock()
157                event.handled = True
158            elif event.key == "e":
159                self._assign_hotbar()
160                event.handled = True
161            elif event.key in ("1", "2", "3", "4"):
162                self._assign_hotbar(int(event.key) - 1)
163                event.handled = True
164            elif event.key == "k":
165                self.cancel_requested.emit()
166                self.close_overlay()
167                event.handled = True
168
169    def _handle_click(self, mx: float, my: float, event):
170        if back_button_hit(mx, my):
171            self.cancel_requested.emit()
172            self.close_overlay()
173            event.handled = True
174            return
175        px, py = self._panel_origin()
176        pw, ph = self._panel_size()
177        node_h = self._node_height(ph)
178        cx, cy = px, py + _TITLE_H
179        col_w = (pw - 20) / 3
180        top_y = cy + 30
181        for bi, branch in enumerate(_BRANCHES):
182            bx = cx + 10 + bi * col_w
183            skills = self._skill_tree.branch_skills(branch)
184            for si, skill in enumerate(skills):
185                ny = top_y + 28 + si * node_h
186                if bx <= mx <= bx + col_w and ny - 4 <= my <= ny + node_h - 4:
187                    self._branch_idx = bi
188                    self._node_idx = si
189                    self._skill_tree.unlock(skill["id"])
190                    event.handled = True
191                    return
192        event.handled = True  # consume clicks inside panel
193
194    def _node_height(self, ph: float) -> float:
195        """Skill node row height: fits all rows + tooltip + footer in the panel."""
196        # Need: header (TITLE+30 for SKILL POINTS+30 padding) + N rows*h + tooltip(80) + footer(30)
197        skills = self._skill_tree.branch_skills("warrior") if self._skill_tree else []
198        n = max(1, len(skills))
199        reserved = _TITLE_H + 60 + 80 + 30  # title, skill-pts band, tooltip, footer
200        avail = max(120.0, ph - reserved)
201        return min(48.0, max(26.0, avail / n))
202
203    def _try_unlock(self):
204        branch = _BRANCHES[self._branch_idx]
205        skills = self._skill_tree.branch_skills(branch)
206        if 0 <= self._node_idx < len(skills):
207            self._skill_tree.unlock(skills[self._node_idx]["id"])
208
209    def _assign_hotbar(self, slot: int | None = None):
210        branch = _BRANCHES[self._branch_idx]
211        skills = self._skill_tree.branch_skills(branch)
212        if 0 <= self._node_idx < len(skills):
213            sid = skills[self._node_idx]["id"]
214            if slot is not None:
215                self._skill_tree.assign_hotbar(slot, sid)
216            else:
217                for i in range(4):
218                    if self._skill_tree.hotbar[i] is None:
219                        self._skill_tree.assign_hotbar(i, sid)
220                        return
221                self._skill_tree.assign_hotbar(3, sid)
222
223    # -- Rendering --
224
225    def on_draw(self, renderer):
226        if not self.visible:
227            return
228        sw, sh = self._screen_size()
229        renderer.draw_rect((0, 0), (sw, sh), colour=(0.0, 0.0, 0.0, 0.6), filled=True)
230
231        px, py = self._panel_origin()
232        pw, ph = self._panel_size()
233        renderer.draw_rect((px, py), (pw, ph), colour=(0.12, 0.12, 0.18, 1.0), filled=True)
234        renderer.draw_rect((px, py), (pw, _TITLE_H), colour=(0.15, 0.15, 0.25, 1.0), filled=True)
235        # Title left-aligned with a small inset, but shifted right past the
236        # back button (~68px) when the panel hugs the viewport left edge.
237        title_inset = max(12, int(72 - px))
238        renderer.draw_text(
239            "SKILL TREE",
240            rect=(px + title_inset, py, pw - title_inset - 12, _TITLE_H),
241            scale=1.5,
242            alignment="left",
243            vertical_alignment="center",
244            fit_to_width=True,
245            min_scale=0.9,
246            colour=(1, 1, 1),
247        )
248
249        if self._skill_tree is None:
250            return
251
252        x = px
253        y = py + _TITLE_H
254        w = pw
255
256        renderer.draw_text(
257            f"Skill Points: {self._skill_tree.skill_points}",
258            rect=(x, y + 4, w, 20),
259            scale=1.0,
260            alignment="center",
261            vertical_alignment="center",
262            colour=(1.0, 0.9, 0.3),
263        )
264
265        col_w = (w - 20) / 3
266        node_h = self._node_height(ph)
267        top_y = y + 30
268        # Icon column on the left of each row; label fills the remainder.
269        # 8px gutters on both sides keep glyphs off column boundaries.
270        icon_w = 24.0
271        gutter = 8.0
272        include_cost = col_w >= 200.0
273        # Row text scale follows row height: 0.7 at 28px row -> ~0.95 at 64px.
274        text_scale = min(max(node_h * 0.014 + 0.31, 0.6), 0.95)
275
276        for bi, branch in enumerate(_BRANCHES):
277            bx = x + 10 + bi * col_w
278            colour = _BRANCH_COLOURS[branch]
279            selected_branch = bi == self._branch_idx
280
281            hc = colour if selected_branch else (colour[0] * 0.75, colour[1] * 0.75, colour[2] * 0.75, 1.0)
282            renderer.draw_text(
283                branch.upper(),
284                rect=(bx + gutter, top_y, col_w - 2 * gutter, 22),
285                scale=1.3,
286                alignment="left",
287                vertical_alignment="center",
288                fit_to_width=True,
289                min_scale=0.7,
290                colour=hc,
291            )
292
293            skills = self._skill_tree.branch_skills(branch)
294            # Trunk line runs down through the icon column.
295            icon_cx = bx + gutter + icon_w / 2
296            prev_cy = top_y + 20
297
298            for si, skill in enumerate(skills):
299                ny = top_y + 28 + si * node_h
300                ncy = ny + node_h * 0.4
301
302                if si > 0:
303                    line_c = (colour[0] * 0.3, colour[1] * 0.3, colour[2] * 0.3, 0.4)
304                    renderer.draw_rect(
305                        (int(icon_cx) - 1, int(prev_cy)), (2, int(ncy - prev_cy)), colour=line_c, filled=True
306                    )
307
308                unlocked = skill["id"] in self._skill_tree.unlocked
309                selected = bi == self._branch_idx and si == self._node_idx
310                can = self._skill_tree.can_unlock(skill["id"])
311
312                if selected:
313                    glow_a = 0.3 + 0.1 * math.sin(self._timer * 4.0)
314                    renderer.draw_rect(
315                        (int(bx + 2), ny - 2),
316                        (int(col_w - 4), node_h - 4),
317                        colour=(colour[0] * 0.3, colour[1] * 0.3, colour[2] * 0.3, glow_a),
318                        filled=True,
319                    )
320
321                if unlocked:
322                    glow_r = 0.08 * math.sin(self._timer * 2.0 + si)
323                    renderer.draw_circle(
324                        (icon_cx, ncy), 8, colour=(colour[0], colour[1], colour[2], 0.15 + glow_r), filled=True
325                    )
326
327                _draw_skill_icon(renderer, icon_cx, ncy, branch, unlocked, colour)
328
329                if unlocked:
330                    c = colour
331                elif can:
332                    c = (0.8, 0.8, 0.8, 1.0)
333                else:
334                    c = (0.6, 0.6, 0.65, 1.0)
335
336                prefix = "* " if unlocked else "o "
337                # Label rect: from right of icon to right edge of column, with
338                # both-side gutters. fit_to_width shrinks if the rect is tight.
339                label_x = bx + gutter + icon_w + 4
340                label_w = max(40.0, bx + col_w - label_x - gutter)
341                label_text = (
342                    f"{prefix}{skill['name']} ({skill['cost']}pt)" if include_cost else f"{prefix}{skill['name']}"
343                )
344                renderer.draw_text(
345                    label_text,
346                    rect=(label_x, ny, label_w, node_h),
347                    scale=text_scale,
348                    alignment="left",
349                    vertical_alignment="center",
350                    fit_to_width=True,
351                    min_scale=0.4,
352                    colour=c,
353                )
354
355                prev_cy = ncy + node_h * 0.2
356
357        branch = _BRANCHES[self._branch_idx]
358        skills = self._skill_tree.branch_skills(branch)
359        if 0 <= self._node_idx < len(skills):
360            skill = skills[self._node_idx]
361            # Tooltip sits 8px below the bottom of the longest column, never
362            # below the footer band.
363            cols_bottom = top_y + 28 + len(skills) * node_h + 8
364            footer_top = py + ph - 30
365            tooltip_y = min(cols_bottom, footer_top - 88)
366            self._draw_tooltip(renderer, skill, x + 10, tooltip_y, pw - 20)
367
368        renderer.draw_text(
369            "Arrows: Navigate   Space: Unlock   1-4: Assign to Slot   E: Auto-Assign   K/Esc: Close",
370            rect=(px + 10, py + ph - 28, pw - 20, 24),
371            scale=0.8,
372            alignment="left",
373            vertical_alignment="center",
374            fit_to_width=True,
375            min_scale=0.45,
376            colour=(0.5, 0.5, 0.5),
377        )
378        draw_back_button(renderer)
379
380    def _draw_tooltip(self, renderer, skill: dict, x: float, y: float, w: float = 700):
381        renderer.draw_rect((x, y), (w, 80), colour=(0.06, 0.06, 0.1, 0.9), filled=True)
382        renderer.draw_rect((x, y), (w, 2), colour=(0.5, 0.4, 0.7, 0.6), filled=True)
383
384        unlocked = skill["id"] in self._skill_tree.unlocked
385        name_c = (1.0, 1.0, 1.0) if unlocked else (0.7, 0.7, 0.7)
386        cd = skill.get("cooldown", 0)
387        # Reserve right-side area for cooldown text so it never overlaps the name.
388        cd_text = f"Cooldown: {cd}s" if cd else ""
389        cd_w = renderer.text_width(cd_text, 0.85) + 12 if cd_text else 0
390        name_w = max(60.0, w - 20 - cd_w)
391        renderer.draw_text(
392            skill["name"],
393            rect=(x + 10, y + 6, name_w, 22),
394            scale=1.1,
395            alignment="left",
396            vertical_alignment="center",
397            fit_to_width=True,
398            min_scale=0.6,
399            colour=name_c,
400        )
401        if cd_text:
402            renderer.draw_text(
403                cd_text,
404                rect=(x + w - cd_w - 4, y + 6, cd_w, 22),
405                scale=0.85,
406                alignment="right",
407                vertical_alignment="center",
408                colour=(0.75, 0.75, 0.75),
409            )
410
411        desc = skill.get("description", "")
412        renderer.draw_text(
413            desc,
414            rect=(x + 10, y + 28, w - 20, 22),
415            scale=0.9,
416            alignment="left",
417            vertical_alignment="center",
418            fit_to_width=True,
419            min_scale=0.55,
420            colour=(0.8, 0.8, 0.8),
421        )
422
423        prereqs = skill.get("prerequisites", [])
424        if prereqs:
425            renderer.draw_text(
426                f"Requires: {', '.join(prereqs)}",
427                rect=(x + 10, y + 50, w - 20, 22),
428                scale=0.8,
429                alignment="left",
430                vertical_alignment="center",
431                fit_to_width=True,
432                min_scale=0.55,
433                colour=(0.75, 0.75, 0.75),
434            )