nodes/hotbar_assign_ui.pyΒΆ
Part of Dungeon Explorer.
1"""Hotbar assignment popup: assign unlocked active abilities to hotbar slots."""
2
3from simvx.core import Anchor, Control, Property
4from simvx.core.input import Key, KeyEvent, Pointer, PointerEvent, PointerPhase
5
6from ._back_button import back_button_hit, draw_back_button
7
8
9class HotbarAssignUI(Control):
10 """Grid of unlocked active abilities and consumable items with 4 hotbar slots at top."""
11
12 visible = Property(
13 False,
14 coerce=bool,
15 hint="Whether this node and its subtree are drawn",
16 on_change="_on_visible_changed",
17 )
18
19 def __init__(self, skill_tree=None, **kwargs):
20 super().__init__(name="HotbarAssignUI", anchor=Anchor.FULL_RECT, **kwargs)
21
22 self._skill_tree = skill_tree
23 self._inventory = None
24 self._slot_cursor = 0
25 self._ability_cursor = 0
26 self._entries: list[dict] = []
27 self._target_slot: int = 0
28
29 def set_skill_tree(self, tree):
30 self._skill_tree = tree
31
32 def set_inventory(self, inventory):
33 self._inventory = inventory
34
35 def open_for_slot(self, slot: int):
36 self._target_slot = slot
37
38 def show(self):
39 self._slot_cursor = self._target_slot
40 self._ability_cursor = 0
41 self._entries = []
42 if self._skill_tree:
43 for sid in self._skill_tree.unlocked:
44 skill = self._skill_tree.get_skill(sid)
45 if skill and skill.get("type") == "active":
46 self._entries.append({**skill, "_kind": "skill"})
47 if self._inventory:
48 seen = set()
49 for idx in range(self._inventory.capacity):
50 item = self._inventory.get(idx)
51 if item and item.is_consumable and item.template_key not in seen:
52 seen.add(item.template_key)
53 self._entries.append(
54 {
55 "id": f"item:{item.template_key}",
56 "name": item.display_base,
57 "_kind": "item",
58 "_template_key": item.template_key,
59 }
60 )
61 self.show_overlay("blocking")
62
63 # -- Input --
64
65 def on_gui_input(self, event):
66 if not self.visible or self._skill_tree is None:
67 return
68 if isinstance(event, PointerEvent):
69 if event.button in Pointer.PRIMARY and event.phase is PointerPhase.DOWN:
70 return self._handle_click(*event.position)
71 return None
72 if isinstance(event, KeyEvent) and event.pressed:
73 if event.key in (Key.W, Key.UP):
74 self._ability_cursor = max(0, self._ability_cursor - 1)
75 return True
76 elif event.key in (Key.S, Key.DOWN):
77 if self._ability_cursor < len(self._entries) - 1:
78 self._ability_cursor += 1
79 return True
80 elif event.key in (Key.A, Key.LEFT):
81 self._slot_cursor = max(0, self._slot_cursor - 1)
82 return True
83 elif event.key in (Key.D, Key.RIGHT):
84 self._slot_cursor = min(3, self._slot_cursor + 1)
85 return True
86 elif event.key in (Key.SPACE, Key.ENTER):
87 if 0 <= self._ability_cursor < len(self._entries):
88 entry = self._entries[self._ability_cursor]
89 self._skill_tree.assign_hotbar(self._slot_cursor, entry["id"])
90 elif not self._entries:
91 self._skill_tree.assign_hotbar(self._slot_cursor, None)
92 return True
93 elif event.key is Key.E:
94 self._skill_tree.assign_hotbar(self._slot_cursor, None)
95 return True
96
97 def _handle_click(self, mx: float, my: float) -> bool | None:
98 if back_button_hit(mx, my):
99 self.cancel_requested.emit()
100 self.close_overlay()
101 return True
102 sw, sh = self._screen_size()
103 panel_w = 500
104 panel_h = 420
105 px = (sw - panel_w) / 2
106 py = (sh - panel_h) / 2
107 slot_size = 60
108 slot_pad = 10
109 total_sw = 4 * (slot_size + slot_pad)
110 slot_sx = px + (panel_w - total_sw) / 2
111 slot_sy = py + 50
112 for i in range(4):
113 sx = slot_sx + i * (slot_size + slot_pad)
114 if sx <= mx <= sx + slot_size and slot_sy <= my <= slot_sy + slot_size:
115 self._slot_cursor = i
116 if self._skill_tree and self._skill_tree.hotbar[i] is not None:
117 self._skill_tree.assign_hotbar(i, None)
118 return True
119 list_y = py + 140
120 for j in range(len(self._entries)):
121 iy = list_y + j * 32
122 if px + 20 <= mx <= px + panel_w - 20 and iy - 2 <= my <= iy + 28:
123 self._ability_cursor = j
124 self._skill_tree.assign_hotbar(self._slot_cursor, self._entries[j]["id"])
125 return True
126
127 # -- Rendering --
128
129 def _screen_size(self) -> tuple[float, float]:
130 if self.tree is not None:
131 sw, sh = self.tree.screen_size
132 return float(sw), float(sh)
133 return 1280.0, 720.0
134
135 def on_draw(self, renderer):
136 if not self.visible or self._skill_tree is None:
137 return
138 sw, sh = self._screen_size()
139 renderer.rect((0, 0), (sw, sh), fill=(0.0, 0.0, 0.0, 0.7))
140
141 panel_w = 500
142 panel_h = 420
143 px = (sw - panel_w) / 2
144 py = (sh - panel_h) / 2
145 renderer.rect((px, py), (panel_w, panel_h), fill=(0.08, 0.08, 0.12, 0.95))
146 renderer.rect((px, py), (panel_w, 3), fill=(0.5, 0.5, 0.7, 0.8))
147
148 cx = sw / 2
149 renderer.text("ASSIGN ABILITIES", (cx - 85, py + 12), fill=(1.0, 1.0, 1.0), scale=1.8)
150
151 slot_size = 60
152 slot_pad = 10
153 total_sw = 4 * (slot_size + slot_pad)
154 slot_sx = px + (panel_w - total_sw) / 2
155 slot_sy = py + 50
156
157 for i in range(4):
158 sx = slot_sx + i * (slot_size + slot_pad)
159 selected = i == self._slot_cursor
160 bg = (0.2, 0.2, 0.3, 0.9) if selected else (0.12, 0.12, 0.16, 0.9)
161 renderer.rect((sx, slot_sy), (slot_size, slot_size), fill=bg)
162 border = (0.5, 0.6, 0.9, 1.0) if selected else (0.3, 0.3, 0.4, 0.6)
163 for edge in (
164 (sx, slot_sy, slot_size, 1),
165 (sx, slot_sy + slot_size - 1, slot_size, 1),
166 (sx, slot_sy, 1, slot_size),
167 (sx + slot_size - 1, slot_sy, 1, slot_size),
168 ):
169 renderer.rect((edge[0], edge[1]), (edge[2], edge[3]), fill=border)
170 sid = self._skill_tree.hotbar[i]
171 if sid and sid.startswith("item:"):
172 label = sid[5:].replace("_", " ").title()
173 renderer.text(label[:6], (sx + 4, slot_sy + 22), fill=(0.5, 0.9, 0.5), scale=0.8)
174 elif sid:
175 skill = self._skill_tree.get_skill(sid)
176 if skill:
177 renderer.text(skill["name"][:6], (sx + 4, slot_sy + 22), fill=(0.8, 0.8, 0.9), scale=0.8)
178 else:
179 renderer.text("Empty", (sx + 8, slot_sy + 22), fill=(0.4, 0.4, 0.4), scale=0.7)
180 renderer.text(str(i + 1), (sx + slot_size - 14, slot_sy - 14), fill=(0.5, 0.5, 0.5), scale=0.7)
181
182 renderer.rect((px + 15, py + 125), (panel_w - 30, 1), fill=(0.3, 0.3, 0.35, 0.5))
183 list_y = py + 140
184
185 if not self._entries:
186 renderer.text("No abilities or items available.", (px + 30, list_y + 20), fill=(0.5, 0.5, 0.5), scale=1.0)
187 else:
188 for j, entry in enumerate(self._entries):
189 iy = list_y + j * 32
190 sel = j == self._ability_cursor
191 if sel:
192 renderer.rect((px + 20, iy - 2), (panel_w - 40, 28), fill=(0.2, 0.2, 0.3, 0.8))
193 if entry["_kind"] == "item":
194 colour = (0.5, 1.0, 0.5) if sel else (0.4, 0.7, 0.4)
195 renderer.text(f"{entry['name']} (Item)", (px + 30, iy + 2), fill=colour, scale=1.0)
196 else:
197 colour = (1.0, 0.9, 0.3) if sel else (0.7, 0.7, 0.7)
198 branch = entry.get("branch", "mage")
199 bc = {"warrior": "(W)", "ranger": "(R)", "mage": "(M)"}.get(branch, "")
200 renderer.text(f"{entry['name']} {bc}", (px + 30, iy + 2), fill=colour, scale=1.0)
201 cd = entry.get("cooldown", 0)
202 if cd:
203 renderer.text(f"{cd}s CD", (px + panel_w - 80, iy + 4), fill=(0.5, 0.5, 0.5), scale=0.8)
204
205 renderer.text(
206 "A/D: Slot W/S: Ability Space: Assign E: Clear Esc: Close",
207 (px + 15, py + panel_h - 22),
208 fill=(0.4, 0.4, 0.4),
209 scale=0.75,
210 )
211 draw_back_button(renderer)