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