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