nodes/inventory_ui.py¶
Part of Dungeon Explorer.
1"""Inventory popup: 48px cells, drawn icons, rarity borders, equipment panel, mouse support."""
2
3from scripts.inventory import Inventory
4from scripts.item import Item
5from scripts.player_stats import compute_derived_stats
6
7from simvx.core import Control, Property
8from simvx.core.input import MouseButton
9from simvx.core.ui.enums import AnchorPreset
10
11from ._back_button import back_button_hit, draw_back_button
12
13CELL_SIZE = 48
14CELL_PAD = 4
15PANEL_PAD = 12
16_TITLE_H = 36
17
18# Slot type -> icon drawing function key
19_SLOT_ICON_MAP = {
20 "weapon": "sword",
21 "offhand": "shield",
22 "head": "helm",
23 "body": "armour",
24 "feet": "boot",
25 "ring": "ring",
26 "neck": "amulet",
27 "consumable": "potion",
28}
29
30
31def _draw_item_icon(renderer, cx: float, cy: float, size: float, slot: str, colour: tuple):
32 """Draw a simple iconic shape for an item based on its slot type."""
33 s = size * 0.35
34 icon = _SLOT_ICON_MAP.get(slot, "gem")
35 r, g, b = colour[0], colour[1], colour[2]
36 a = colour[3] if len(colour) > 3 else 1.0
37 c = (r, g, b, a)
38 dim = (r * 0.6, g * 0.6, b * 0.6, a)
39
40 if icon == "sword":
41 renderer.draw_rect((cx - 2, cy - s), (4, s * 1.6), colour=c, filled=True)
42 renderer.draw_rect((cx - s * 0.5, cy + s * 0.3), (s, 3), colour=dim, filled=True)
43 renderer.draw_rect((cx - 1, cy + s * 0.5), (2, s * 0.5), colour=dim, filled=True)
44 elif icon == "shield":
45 renderer.draw_rect((cx - s * 0.6, cy - s * 0.6), (s * 1.2, s * 1.4), colour=dim, filled=True)
46 renderer.draw_rect((cx - s * 0.4, cy - s * 0.4), (s * 0.8, s * 1.0), colour=c, filled=True)
47 elif icon == "helm":
48 renderer.draw_rect((cx - s * 0.6, cy - s * 0.3), (s * 1.2, s * 0.9), colour=c, filled=True)
49 renderer.draw_rect((cx - s * 0.7, cy + s * 0.4), (s * 1.4, s * 0.3), colour=dim, filled=True)
50 elif icon == "armour":
51 renderer.draw_rect((cx - s * 0.7, cy - s * 0.5), (s * 1.4, s * 1.2), colour=c, filled=True)
52 renderer.draw_rect((cx - s * 0.3, cy - s * 0.6), (s * 0.6, s * 0.2), colour=dim, filled=True)
53 elif icon == "boot":
54 renderer.draw_rect((cx - s * 0.3, cy - s * 0.5), (s * 0.6, s * 0.9), colour=c, filled=True)
55 renderer.draw_rect((cx - s * 0.3, cy + s * 0.3), (s * 0.9, s * 0.3), colour=dim, filled=True)
56 elif icon == "ring":
57 renderer.draw_circle((cx, cy), s * 0.5, colour=c, filled=True)
58 renderer.draw_circle((cx, cy), s * 0.25, colour=(0.1, 0.1, 0.12, 1.0), filled=True)
59 elif icon == "amulet":
60 renderer.draw_circle((cx, cy + s * 0.2), s * 0.35, colour=c, filled=True)
61 renderer.draw_rect((cx - 1, cy - s * 0.4), (2, s * 0.5), colour=dim, filled=True)
62 elif icon == "potion":
63 renderer.draw_rect((cx - s * 0.2, cy - s * 0.5), (s * 0.4, s * 0.4), colour=dim, filled=True)
64 renderer.draw_rect((cx - s * 0.4, cy - s * 0.1), (s * 0.8, s * 0.7), colour=c, filled=True)
65 else:
66 renderer.draw_rect((cx - s * 0.35, cy - s * 0.35), (s * 0.7, s * 0.7), colour=c, filled=True)
67
68
69class InventoryUI(Control):
70 """Grid-based inventory popup with equipment paper-doll and mouse support."""
71
72 visible = Property(
73 False,
74 coerce=bool,
75 hint="Whether this node and its subtree are drawn",
76 on_change="_on_visible_changed",
77 )
78
79 def __init__(self, on_use_consumable=None, **kwargs):
80 super().__init__(name="InventoryUI", **kwargs)
81 self.set_anchor_preset(AnchorPreset.FULL_RECT)
82
83 self._inventory: Inventory | None = None
84 self._selected_slot: int = 0
85 self._held_item: Item | None = None
86 self._player = None
87 self._skill_tree = None
88 self._mouse_x = 0.0
89 self._mouse_y = 0.0
90 self._hover_slot: int = -1
91 self._hover_equip: str | None = None
92 self._on_use_consumable = on_use_consumable
93 self._focus_mode = "grid"
94 self._equip_focus_idx = 0
95 self._equip_slot_order = ["head", "weapon", "body", "offhand", "feet", "ring", "neck"]
96 self._grid_origin = (0.0, 0.0)
97 self._equip_positions: dict[str, tuple[float, float]] = {}
98 self._pw = 600
99 self._ph = 400
100
101 self.cancel_requested.connect(self._on_close_signal)
102
103 def _compute_panel_size(self):
104 if self._inventory is None:
105 return
106 cols, rows = self._inventory.cols, self._inventory.rows
107 grid_w = cols * (CELL_SIZE + CELL_PAD)
108 equip_panel_w = 200
109 total_w = grid_w + PANEL_PAD * 2 + equip_panel_w
110 grid_h = rows * (CELL_SIZE + CELL_PAD)
111 equip_h = (CELL_SIZE + 6) * 4 + 120 + 22
112 content_h = max(grid_h, equip_h) + 180
113 self._pw = max(total_w + PANEL_PAD * 2, 580)
114 self._ph = content_h + _TITLE_H + 16
115
116 def _screen_size(self) -> tuple[float, float]:
117 if self.tree is not None:
118 sw, sh = self.tree.screen_size
119 return float(sw), float(sh)
120 return 1280.0, 720.0
121
122 def _panel_origin(self) -> tuple[float, float]:
123 sw, sh = self._screen_size()
124 return (sw - self._pw) / 2, (sh - self._ph) / 2
125
126 def set_inventory(self, inventory: Inventory) -> None:
127 self._inventory = inventory
128 self._compute_panel_size()
129
130 def set_player(self, player) -> None:
131 self._player = player
132
133 def set_skill_tree(self, skill_tree):
134 self._skill_tree = skill_tree
135
136 def show(self):
137 self._selected_slot = 0
138 self._held_item = None
139 self._hover_slot = -1
140 self._hover_equip = None
141 self._focus_mode = "grid"
142 self._equip_focus_idx = 0
143 self._compute_panel_size()
144 self.show_overlay("blocking", dismiss=True)
145
146 def _on_close_signal(self):
147 # Outside-click / Esc: stash held item back into inventory.
148 if self._held_item is not None and self._inventory is not None:
149 self._inventory.add(self._held_item)
150 self._held_item = None
151
152 # -- Input --
153
154 def _on_gui_input(self, event):
155 if not self.visible or self._inventory is None:
156 return
157 # Track mouse pos for hover updates each event.
158 self._mouse_x, self._mouse_y = float(event.position[0]), float(event.position[1])
159 self._update_hover()
160
161 # Mouse press
162 if event.button == MouseButton.LEFT and event.pressed:
163 mx, my = self._mouse_x, self._mouse_y
164 if back_button_hit(mx, my):
165 self.cancel_requested.emit()
166 self.close_overlay()
167 event.handled = True
168 return
169 if self._hover_slot >= 0:
170 self._selected_slot = self._hover_slot
171 self._focus_mode = "grid"
172 self._click_slot(self._hover_slot)
173 elif self._hover_equip:
174 self._focus_mode = "equip"
175 if self._hover_equip in self._equip_slot_order:
176 self._equip_focus_idx = self._equip_slot_order.index(self._hover_equip)
177 else:
178 self._equip_focus_idx = 0
179 self._click_equip_slot(self._hover_equip)
180 event.handled = True
181 return
182 if event.button == MouseButton.RIGHT and event.pressed: # right click
183 if self._hover_slot >= 0:
184 self._use_consumable(self._hover_slot)
185 event.handled = True
186 return
187
188 # Keyboard
189 if event.key and event.pressed:
190 cols = self._inventory.cols
191 rows = self._inventory.rows
192 total = rows * cols
193
194 if event.key == "tab":
195 self._focus_mode = "equip" if self._focus_mode == "grid" else "grid"
196 event.handled = True
197 return
198
199 # 1-4: assign selected consumable to hotbar slot
200 if event.key in ("1", "2", "3", "4"):
201 self._assign_item_hotbar(self._selected_slot, int(event.key) - 1)
202 event.handled = True
203 return
204
205 if event.key == "i":
206 self.cancel_requested.emit()
207 self.close_overlay()
208 event.handled = True
209 return
210
211 if self._focus_mode == "grid":
212 if event.key in ("d", "right"):
213 self._selected_slot = min(self._selected_slot + 1, total - 1)
214 event.handled = True
215 elif event.key in ("a", "left"):
216 self._selected_slot = max(self._selected_slot - 1, 0)
217 event.handled = True
218 elif event.key in ("s", "down"):
219 self._selected_slot = min(self._selected_slot + cols, total - 1)
220 event.handled = True
221 elif event.key in ("w", "up"):
222 self._selected_slot = max(self._selected_slot - cols, 0)
223 event.handled = True
224 elif event.key == "e":
225 self._equip_or_click(self._selected_slot)
226 event.handled = True
227 elif event.key in ("space", "enter", "return"):
228 self._click_slot(self._selected_slot)
229 event.handled = True
230 else:
231 n_equip = len(self._equip_slot_order)
232 if event.key in ("s", "down"):
233 self._equip_focus_idx = min(self._equip_focus_idx + 1, n_equip - 1)
234 event.handled = True
235 elif event.key in ("w", "up"):
236 self._equip_focus_idx = max(self._equip_focus_idx - 1, 0)
237 event.handled = True
238 elif event.key in ("space", "enter", "return"):
239 slot_name = self._equip_slot_order[self._equip_focus_idx]
240 self._click_equip_slot(slot_name)
241 event.handled = True
242 elif event.key == "e":
243 slot_name = self._equip_slot_order[self._equip_focus_idx]
244 if self._held_item and self._held_item.is_equipment and self._held_item.slot == slot_name:
245 prev = self._inventory.equip(self._held_item)
246 self._held_item = prev
247 elif self._held_item is None:
248 self._click_equip_slot(slot_name)
249 event.handled = True
250
251 # -- Rendering --
252
253 def on_draw(self, renderer):
254 if not self.visible:
255 return
256 sw, sh = self._screen_size()
257 # Dim background
258 renderer.draw_rect((0, 0), (sw, sh), colour=(0.0, 0.0, 0.0, 0.6), filled=True)
259
260 if self._inventory is None:
261 return
262
263 px, py = self._panel_origin()
264 pw, ph = self._pw, self._ph
265
266 # Panel background
267 renderer.draw_rect((px, py), (pw, ph), colour=(0.12, 0.12, 0.18, 0.95), filled=True)
268
269 # Title bar
270 renderer.draw_rect((px, py), (pw, _TITLE_H), colour=(0.15, 0.15, 0.25, 1.0), filled=True)
271 renderer.draw_text("Inventory", (px + 12, py + 8), scale=1.5, colour=(1, 1, 1))
272
273 inv = self._inventory
274 cols, rows = inv.cols, inv.rows
275
276 # Content origin
277 x = px + PANEL_PAD
278 y = py + _TITLE_H + 8
279 equip_panel_w = 200
280
281 # Equipment panel (paper-doll, left side)
282 epx = x
283 epy = y
284 renderer.draw_text("Equipment", (epx, epy), scale=1.0, colour=(0.8, 0.8, 0.5))
285 epy += 22
286
287 slot_layout = {
288 "head": (epx + 70, epy),
289 "weapon": (epx, epy + CELL_SIZE + 6),
290 "body": (epx + 70, epy + CELL_SIZE + 6),
291 "offhand": (epx + 140, epy + CELL_SIZE + 6),
292 "feet": (epx + 70, epy + (CELL_SIZE + 6) * 2),
293 "ring": (epx, epy + (CELL_SIZE + 6) * 3),
294 "neck": (epx + 140, epy + (CELL_SIZE + 6) * 3),
295 }
296 self._equip_positions = slot_layout
297
298 for slot_name, (sx, sy) in slot_layout.items():
299 equipped = inv.get_equipped(slot_name)
300 hover = self._hover_equip == slot_name
301 equip_focused = (
302 self._focus_mode == "equip"
303 and self._equip_focus_idx < len(self._equip_slot_order)
304 and self._equip_slot_order[self._equip_focus_idx] == slot_name
305 )
306 if equip_focused:
307 bg = (0.3, 0.3, 0.45, 1.0)
308 elif hover:
309 bg = (0.3, 0.3, 0.4, 1.0)
310 else:
311 bg = (0.18, 0.18, 0.22, 1.0)
312 renderer.draw_rect((sx, sy), (CELL_SIZE, CELL_SIZE), colour=bg, filled=True)
313
314 if equipped:
315 rc = equipped.colour
316 renderer.draw_rect((sx, sy), (CELL_SIZE, 2), colour=rc, filled=True)
317 renderer.draw_rect((sx, sy + CELL_SIZE - 2), (CELL_SIZE, 2), colour=rc, filled=True)
318 renderer.draw_rect((sx, sy), (2, CELL_SIZE), colour=rc, filled=True)
319 renderer.draw_rect((sx + CELL_SIZE - 2, sy), (2, CELL_SIZE), colour=rc, filled=True)
320 _draw_item_icon(renderer, sx + CELL_SIZE / 2, sy + CELL_SIZE / 2, CELL_SIZE, slot_name, equipped.colour)
321 else:
322 renderer.draw_text(
323 slot_name[:3], (sx + 4, sy + CELL_SIZE / 2 - 6), scale=0.7, colour=(0.35, 0.35, 0.4, 1.0)
324 )
325
326 # Stats summary
327 if self._player and self._inventory:
328 self._draw_stats_summary(renderer, epx, epy + (CELL_SIZE + 6) * 4 + 8, equip_panel_w - PANEL_PAD)
329
330 # Grid (right side)
331 grid_x = x + equip_panel_w + PANEL_PAD
332 grid_y = y
333 self._grid_origin = (grid_x, grid_y)
334
335 for row in range(rows):
336 for col in range(cols):
337 idx = row * cols + col
338 cx = grid_x + col * (CELL_SIZE + CELL_PAD)
339 cy = grid_y + row * (CELL_SIZE + CELL_PAD)
340
341 is_sel = idx == self._selected_slot
342 is_hov = idx == self._hover_slot
343 if is_sel:
344 bg = (0.3, 0.3, 0.45, 1.0)
345 elif is_hov:
346 bg = (0.25, 0.25, 0.35, 1.0)
347 else:
348 bg = (0.18, 0.18, 0.22, 1.0)
349 renderer.draw_rect((cx, cy), (CELL_SIZE, CELL_SIZE), colour=bg, filled=True)
350
351 item = inv.get(idx)
352 if item is not None:
353 rc = item.colour
354 renderer.draw_rect((cx, cy), (CELL_SIZE, 2), colour=rc, filled=True)
355 renderer.draw_rect((cx, cy + CELL_SIZE - 2), (CELL_SIZE, 2), colour=rc, filled=True)
356 renderer.draw_rect((cx, cy), (2, CELL_SIZE), colour=rc, filled=True)
357 renderer.draw_rect((cx + CELL_SIZE - 2, cy), (2, CELL_SIZE), colour=rc, filled=True)
358
359 label_h = 12
360 # Centre the icon in the area above the label so neither
361 # overlaps the other.
362 icon_area_h = CELL_SIZE - label_h - 4
363 _draw_item_icon(
364 renderer,
365 cx + CELL_SIZE / 2,
366 cy + 2 + icon_area_h / 2,
367 CELL_SIZE,
368 item.slot,
369 item.colour,
370 )
371
372 # Item name renders in the bottom strip of the cell.
373 # Explicit min_scale opts out of the engine's ~10px
374 # readability floor; the cell is only 44px wide and we
375 # accept slightly-small text to keep names legible at a
376 # glance. Full name is also in the tooltip.
377 renderer.draw_text(
378 item.display_base,
379 rect=(cx + 2, cy + CELL_SIZE - label_h - 2, CELL_SIZE - 4, label_h),
380 scale=0.55,
381 alignment="center",
382 vertical_alignment="bottom",
383 fit_to_width=True,
384 min_scale=0.4,
385 colour=item.colour,
386 )
387
388 if item.is_stackable and item.stack_count > 1:
389 renderer.draw_text(
390 str(item.stack_count), (cx + CELL_SIZE - 14, cy + 2), scale=0.7, colour=(1.0, 1.0, 1.0)
391 )
392
393 # Tooltip: fixed area at bottom of panel, never overlaps items
394 tooltip_idx = self._hover_slot if self._hover_slot >= 0 else self._selected_slot
395 sel_item = inv.get(tooltip_idx) if 0 <= tooltip_idx < inv.capacity else None
396 if sel_item is None and self._hover_equip:
397 sel_item = inv.get_equipped(self._hover_equip)
398 if sel_item is not None:
399 grid_h = rows * (CELL_SIZE + CELL_PAD)
400 equip_h = (CELL_SIZE + 6) * 4 + 22
401 content_bottom = y + max(grid_h, equip_h) + 8
402 self._draw_tooltip(renderer, sel_item, px + PANEL_PAD, content_bottom, px + pw - PANEL_PAD)
403
404 # Held item at cursor
405 if self._held_item is not None:
406 mcx, mcy = self._mouse_x, self._mouse_y
407 hs = CELL_SIZE * 0.8
408 renderer.draw_rect((mcx - hs / 2, mcy - hs / 2), (hs, hs), colour=(0.1, 0.1, 0.15, 0.85), filled=True)
409 rc = self._held_item.colour
410 renderer.draw_rect((mcx - hs / 2, mcy - hs / 2), (hs, 2), colour=rc, filled=True)
411 renderer.draw_rect((mcx - hs / 2, mcy + hs / 2 - 2), (hs, 2), colour=rc, filled=True)
412 renderer.draw_rect((mcx - hs / 2, mcy - hs / 2), (2, hs), colour=rc, filled=True)
413 renderer.draw_rect((mcx + hs / 2 - 2, mcy - hs / 2), (2, hs), colour=rc, filled=True)
414 _draw_item_icon(renderer, mcx, mcy - 4, hs, self._held_item.slot, self._held_item.colour)
415 grid_h = rows * (CELL_SIZE + CELL_PAD)
416 bottom_y = grid_y + grid_h + 5
417 renderer.draw_text(
418 f"Holding: {self._held_item.display_name}", (grid_x, bottom_y), scale=0.9, colour=self._held_item.colour
419 )
420
421 # Controls
422 renderer.draw_text(
423 "Arrows: Navigate Space: Pick/Place E: Use/Equip 1-4: Hotbar Tab: Grid/Equip I/Esc: Close",
424 (px + PANEL_PAD, py + ph - 22),
425 scale=0.7,
426 colour=(0.45, 0.45, 0.45, 1.0),
427 )
428 draw_back_button(renderer)
429
430 # -- Hover detection --
431
432 def _update_hover(self):
433 self._hover_slot = -1
434 self._hover_equip = None
435 if self._inventory is None:
436 return
437 gx, gy = self._grid_origin
438 cols = self._inventory.cols
439 rows = self._inventory.rows
440 for row in range(rows):
441 for col in range(cols):
442 cx = gx + col * (CELL_SIZE + CELL_PAD)
443 cy = gy + row * (CELL_SIZE + CELL_PAD)
444 if cx <= self._mouse_x < cx + CELL_SIZE and cy <= self._mouse_y < cy + CELL_SIZE:
445 self._hover_slot = row * cols + col
446 return
447 for slot_name, (ex, ey) in self._equip_positions.items():
448 if ex <= self._mouse_x < ex + CELL_SIZE and ey <= self._mouse_y < ey + CELL_SIZE:
449 self._hover_equip = slot_name
450 return
451
452 # -- Hotbar assignment --
453
454 def _assign_item_hotbar(self, index: int, slot: int):
455 """Assign a consumable item to a hotbar slot."""
456 if self._inventory is None or self._skill_tree is None:
457 return
458 item = self._inventory.get(index)
459 if item is None or not item.is_consumable:
460 return
461 self._skill_tree.assign_hotbar(slot, f"item:{item.template_key}")
462
463 # -- Item actions --
464
465 def _equip_or_click(self, index: int):
466 if self._inventory is None:
467 return
468 item = self._inventory.get(index)
469 if item is not None and item.is_consumable and self._held_item is None:
470 self._use_consumable(index)
471 elif item is not None and item.is_equipment and self._held_item is None:
472 self._inventory.remove(index)
473 prev = self._inventory.equip(item)
474 if prev is not None:
475 self._inventory.set(index, prev)
476 else:
477 self._click_slot(index)
478
479 def _click_slot(self, index: int):
480 if self._inventory is None:
481 return
482 if self._held_item is not None:
483 existing = self._inventory.get(index)
484 self._inventory.set(index, self._held_item)
485 self._held_item = existing
486 else:
487 item = self._inventory.get(index)
488 if item is not None:
489 self._held_item = self._inventory.remove(index)
490
491 def _click_equip_slot(self, slot_name: str):
492 if self._inventory is None:
493 return
494 equipped = self._inventory.get_equipped(slot_name)
495 if equipped and self._held_item is None:
496 self._inventory.unequip(slot_name)
497 self._held_item = equipped
498 elif self._held_item is not None and self._held_item.is_equipment and self._held_item.slot == slot_name:
499 prev = self._inventory.equip(self._held_item)
500 self._held_item = prev
501
502 def _use_consumable(self, index: int):
503 if self._inventory is None or self._player is None:
504 return
505 item = self._inventory.get(index)
506 if item is None or not item.is_consumable:
507 return
508 if item.template_key == "potion":
509 heal_amount = int(item.base_stats.get("heal", 30))
510 self._player.heal(heal_amount)
511 if item.stack_count > 1:
512 item.stack_count -= 1
513 else:
514 self._inventory.remove(index)
515 elif item.template_key in ("town_portal_scroll", "fire_scroll"):
516 if self._on_use_consumable:
517 self._on_use_consumable(item.template_key)
518 if item.stack_count > 1:
519 item.stack_count -= 1
520 else:
521 self._inventory.remove(index)
522
523 # -- Tooltip & stats --
524
525 def _draw_tooltip(self, renderer, item: Item, x: float, y: float, max_x: float = 1280):
526 w = max(220, max_x - x)
527 line_h = 18
528 lines: list[tuple[str, tuple]] = []
529 lines.append((item.display_name, item.colour))
530 lines.append((f"{item.rarity_name} {item.slot.title()}", (0.6, 0.6, 0.6, 1.0)))
531 lines.append((f"Item Level: {item.ilvl}", (0.5, 0.5, 0.5, 1.0)))
532 lines.append(("", (0, 0, 0, 0)))
533
534 equipped = self._inventory.get_equipped(item.slot) if item.is_equipment and self._inventory else None
535 equipped_stats = equipped.base_stats if equipped else {}
536
537 for key, val in item.base_stats.items():
538 label = key.replace("_", " ").title()
539 if isinstance(val, float) and val < 1:
540 text = f" {label}: {val:.0%}"
541 else:
542 text = f" {label}: {int(val)}"
543 if equipped and key in equipped_stats:
544 delta = val - equipped_stats[key]
545 if abs(delta) > 0.001:
546 if isinstance(val, float) and val < 1:
547 delta_str = f"{delta:+.0%}"
548 else:
549 delta_str = f"{delta:+.0f}"
550 colour = (0.3, 1.0, 0.3, 1.0) if delta > 0 else (1.0, 0.3, 0.3, 1.0)
551 text += f" ({delta_str})"
552 lines.append((text, colour))
553 continue
554 lines.append((text, (0.8, 0.8, 0.8, 1.0)))
555
556 for affix in item.prefixes + item.suffixes:
557 lines.append((f" {affix.display_text()}", (0.4, 0.9, 0.4, 1.0)))
558
559 if equipped:
560 lines.append(("", (0, 0, 0, 0)))
561 lines.append((f"Equipped: {equipped.display_base}", (0.5, 0.5, 0.6, 1.0)))
562
563 h = len(lines) * line_h + 16
564
565 renderer.draw_rect((x, y), (w, h), colour=(0.08, 0.08, 0.12, 1.0), filled=True)
566 renderer.draw_rect((x, y), (w, 2), colour=item.colour, filled=True)
567 ty = y + 8
568 for text, colour in lines:
569 if text:
570 renderer.draw_text(text, (x + 8, ty), scale=0.9, colour=colour)
571 ty += line_h
572
573 def _draw_stats_summary(self, renderer, x: float, y: float, w: float):
574 p = self._player
575 inv = self._inventory
576 sb = self._skill_tree.get_passive_bonuses() if self._skill_tree else None
577 derived = compute_derived_stats(
578 int(p.strength),
579 int(p.dexterity),
580 int(p.vitality),
581 int(p.intelligence),
582 int(p.level),
583 inv,
584 skill_bonuses=sb,
585 )
586
587 renderer.draw_rect((x, y), (w, 2), colour=(0.4, 0.4, 0.5, 0.5), filled=True)
588 y += 6
589 renderer.draw_text("Stats", (x, y), scale=0.9, colour=(0.7, 0.7, 0.5))
590 y += 16
591
592 stat_lines = [
593 ("DMG", int(derived["damage"]), (0.9, 0.4, 0.3)),
594 ("DEF", int(derived["defence"]), (0.4, 0.6, 0.9)),
595 ("HP", int(derived["max_hp"]), (0.3, 0.9, 0.3)),
596 ("Crit", f"{derived['crit_chance']:.0%}", (1.0, 0.8, 0.2)),
597 ("Dodge", f"{derived['dodge_chance']:.0%}", (0.5, 0.8, 1.0)),
598 ("KB", f"{derived['knockback']:.0f}", (0.7, 0.5, 0.3)),
599 ]
600
601 for label, val, colour in stat_lines:
602 renderer.draw_text(f"{label}: {val}", (x + 2, y), scale=0.7, colour=colour)
603 y += 13
604
605 gear = inv.equipment_stats()
606 if gear:
607 y += 4
608 renderer.draw_text("Gear:", (x, y), scale=0.7, colour=(0.5, 0.5, 0.5))
609 y += 12
610 for key, val in gear.items():
611 label = key.replace("_", " ").title()
612 if isinstance(val, float) and val < 1:
613 text = f"+{val:.0%} {label}"
614 else:
615 text = f"+{int(val)} {label}"
616 renderer.draw_text(text, (x + 4, y), scale=0.65, colour=(0.5, 0.7, 0.5))
617 y += 11