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 )