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