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