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 )