nodes/shop.py

Part of Dungeon Explorer.

  1"""Shop: dual-panel buy/sell UI with comparison tooltips and buy confirmation."""
  2
  3from scripts.inventory import Inventory
  4from scripts.item import Item
  5
  6from simvx.core import Control, Property, Vec2
  7from simvx.core.input import MouseButton
  8from simvx.core.ui import OverlayCanvas
  9from simvx.core.ui.enums import AnchorPreset
 10
 11from ._back_button import back_button_hit, draw_back_button
 12
 13_PW, _PH = 700, 520
 14_TITLE_H = 36
 15
 16
 17class _ShopCanvas(OverlayCanvas):
 18    """Custom renderer for the shop dual-panel layout."""
 19
 20    def __init__(self, popup):
 21        super().__init__(content_size=(680, 480))
 22        self._popup = popup
 23        self.mouse_filter = False  # parent ShopUI handles input via _on_gui_input
 24
 25    def custom_draw(self, renderer):
 26        x, y, w, h = self.get_global_rect()
 27        p = self._popup
 28
 29        half_w = (w - 10) / 2
 30        left_x = x
 31        right_x = x + half_w + 10
 32        list_y = y + 10
 33
 34        renderer.draw_rect((int(x + half_w + 5), list_y), (1, h - 40), colour=(0.3, 0.3, 0.35, 0.5), filled=True)
 35
 36        shop_active = p._selected_side == 0
 37        label_c = (1.0, 0.9, 0.3, 1.0) if shop_active else (0.7, 0.7, 0.7, 1.0)
 38        renderer.draw_text("For Sale", (left_x, list_y), scale=1.2, colour=label_c)
 39        if p._shop_inventory:
 40            for seq_i, (_slot, item) in enumerate(p._shop_inventory.items()):
 41                iy = list_y + 28 + seq_i * 28
 42                selected = shop_active and seq_i == p._shop_cursor
 43                price = p._get_price(item)
 44                affordable = p._player_gold >= price
 45
 46                if selected:
 47                    renderer.draw_rect((left_x, iy - 2), (half_w, 26), colour=(0.25, 0.25, 0.35, 0.8), filled=True)
 48                    colour = item.colour if affordable else (0.6, 0.35, 0.35, 1.0)
 49                else:
 50                    colour = (0.75, 0.75, 0.75, 1.0)
 51
 52                renderer.draw_text(item.display_name, (left_x + 8, iy + 2), scale=0.85, colour=colour)
 53                price_c = (1.0, 0.85, 0.2, 0.8) if affordable else (0.5, 0.3, 0.3, 0.8)
 54                renderer.draw_text(f"{price}g", (left_x + half_w - 50, iy + 2), scale=0.85, colour=price_c)
 55
 56        inv_active = p._selected_side == 1
 57        label_c = (1.0, 0.9, 0.3, 1.0) if inv_active else (0.7, 0.7, 0.7, 1.0)
 58        renderer.draw_text("Your Items", (right_x, list_y), scale=1.2, colour=label_c)
 59        if p._player_inventory:
 60            for seq_i, (_slot, item) in enumerate(p._player_inventory.items()):
 61                iy = list_y + 28 + seq_i * 28
 62                selected = inv_active and seq_i == p._player_cursor
 63                sell_price = p._get_sell_price(item)
 64
 65                if selected:
 66                    renderer.draw_rect((right_x, iy - 2), (half_w, 26), colour=(0.25, 0.25, 0.35, 0.8), filled=True)
 67                    colour = item.colour
 68                else:
 69                    colour = (0.75, 0.75, 0.75, 1.0)
 70
 71                renderer.draw_text(item.display_name, (right_x + 8, iy + 2), scale=0.85, colour=colour)
 72                renderer.draw_text(
 73                    f"sell {sell_price}g", (right_x + half_w - 70, iy + 2), scale=0.8, colour=(0.6, 0.55, 0.3, 0.8)
 74                )
 75
 76        if shop_active and p._shop_inventory and p._player_inventory:
 77            result = p._get_shop_item()
 78            item = result[1] if result else None
 79            if item and item.is_equipment:
 80                equipped = p._player_inventory.get_equipped(item.slot)
 81                if equipped:
 82                    p._draw_comparison(renderer, item, equipped, x + w + 15, y + 10)
 83
 84        if p._confirm_buy is not None:
 85            p._draw_confirm(renderer, x, y, w, h)
 86
 87        renderer.draw_text(
 88            "W/S: Navigate   A/D: Switch Panel   Space: Buy/Sell   Esc: Close",
 89            (x, y + h - 22),
 90            scale=0.85,
 91            colour=(0.45, 0.45, 0.45),
 92        )
 93
 94
 95class ShopUI(Control):
 96    """Buy/sell shop interface: dual-panel blocking overlay."""
 97
 98    visible = Property(
 99        False,
100        coerce=bool,
101        hint="Whether this node and its subtree are drawn",
102        on_change="_on_visible_changed",
103    )
104
105    def __init__(self, on_transaction=None, **kwargs):
106        super().__init__(name="ShopUI", **kwargs)
107        self.set_anchor_preset(AnchorPreset.FULL_RECT)
108
109        self._player_inventory: Inventory | None = None
110        self._shop_inventory: Inventory | None = None
111        self._selected_side = 0
112        self._shop_cursor = 0
113        self._player_cursor = 0
114        self._player_gold = 0
115        self._on_transaction = on_transaction
116        self._player = None
117        self._confirm_buy: Item | None = None
118        self._confirm_price: int = 0
119
120        self._canvas = _ShopCanvas(self)
121        self.add_child(self._canvas)
122
123    # -- Public API --
124
125    def setup(self, player_inventory: Inventory, shop_items: list[Item], player=None):
126        self._player_inventory = player_inventory
127        self._shop_inventory = Inventory(rows=3, cols=6)
128        for item in shop_items:
129            self._shop_inventory.add(item)
130        self._player = player
131        self._player_gold = player.gold if player else 0
132        self._selected_side = 0
133        self._shop_cursor = 0
134        self._player_cursor = 0
135        self._confirm_buy = None
136
137    def show(self):
138        self._shop_cursor = 0
139        self._player_cursor = 0
140        self._selected_side = 0
141        self._confirm_buy = None
142        if self._player:
143            self._player_gold = self._player.gold
144        self.show_overlay("blocking")
145
146    @property
147    def _active_cursor(self) -> int:
148        return self._shop_cursor if self._selected_side == 0 else self._player_cursor
149
150    @_active_cursor.setter
151    def _active_cursor(self, val: int):
152        if self._selected_side == 0:
153            self._shop_cursor = val
154        else:
155            self._player_cursor = val
156
157    # -- Input --
158
159    def _on_gui_input(self, event):
160        if not self.visible:
161            return
162        if event.button == MouseButton.LEFT and event.pressed:
163            self._handle_click(float(event.position[0]), float(event.position[1]), event)
164            return
165        if event.key and event.pressed:
166            if self._confirm_buy is not None:
167                if event.key in ("space", "enter", "return", "e"):
168                    self._do_buy(self._confirm_buy, self._confirm_price)
169                    self._confirm_buy = None
170                    event.handled = True
171                    return
172                if event.key in ("a", "left"):
173                    self._confirm_buy = None
174                    event.handled = True
175                    return
176                # Esc handled by router → close modal
177                return
178
179            if event.key in ("s", "down"):
180                self._active_cursor = self._active_cursor + 1
181                self._clamp_cursor()
182                event.handled = True
183            elif event.key in ("w", "up"):
184                self._active_cursor = max(0, self._active_cursor - 1)
185                event.handled = True
186            elif event.key in ("a", "left", "d", "right"):
187                self._selected_side = 1 - self._selected_side
188                event.handled = True
189            elif event.key in ("space", "enter", "return"):
190                if self._selected_side == 0:
191                    self._initiate_buy()
192                else:
193                    self.sell_selected()
194                event.handled = True
195            elif event.key == "i":
196                self.cancel_requested.emit()
197                self.close_overlay()
198                event.handled = True
199
200    def _handle_click(self, mx: float, my: float, event):
201        if back_button_hit(mx, my):
202            self.cancel_requested.emit()
203            self.close_overlay()
204            event.handled = True
205            return
206        cx, cy, cw, ch = self._canvas.get_global_rect()
207        half_w = (cw - 10) / 2
208        left_x = cx
209        right_x = cx + half_w + 10
210        list_y = cy + 10
211        item_start_y = list_y + 28
212
213        if self._confirm_buy is not None:
214            bw, bh = 300, 100
215            bx = cx + (cw - bw) / 2
216            by = cy + (ch - bh) / 2
217            if bx <= mx <= bx + bw and by <= my <= by + bh:
218                self._do_buy(self._confirm_buy, self._confirm_price)
219                self._confirm_buy = None
220                event.handled = True
221                return
222            self._confirm_buy = None
223            event.handled = True
224            return
225
226        if left_x <= mx <= left_x + half_w:
227            self._selected_side = 0
228            idx = int((my - item_start_y) / 28)
229            if idx >= 0 and self._shop_inventory:
230                count = len(self._shop_inventory.items())
231                if idx < count:
232                    self._shop_cursor = idx
233                    self._initiate_buy()
234            event.handled = True
235        elif right_x <= mx <= right_x + half_w:
236            self._selected_side = 1
237            idx = int((my - item_start_y) / 28)
238            if idx >= 0 and self._player_inventory:
239                count = len(self._player_inventory.items())
240                if idx < count:
241                    self._player_cursor = idx
242                    self.sell_selected()
243            event.handled = True
244
245    # -- Layout --
246
247    def _screen_size(self) -> tuple[float, float]:
248        if self.tree is not None:
249            sw, sh = self.tree.screen_size
250            return float(sw), float(sh)
251        return 1280.0, 720.0
252
253    def _panel_origin(self) -> tuple[float, float]:
254        sw, sh = self._screen_size()
255        return (sw - _PW) / 2, (sh - _PH) / 2
256
257    # -- Rendering --
258
259    def on_draw(self, renderer):
260        if not self.visible:
261            return
262        sw, sh = self._screen_size()
263        renderer.draw_rect((0, 0), (sw, sh), colour=(0.0, 0.0, 0.0, 0.6), filled=True)
264
265        px, py = self._panel_origin()
266        renderer.draw_rect((px, py), (_PW, _PH), colour=(0.12, 0.12, 0.18, 0.95), filled=True)
267        renderer.draw_rect((px, py), (_PW, _TITLE_H), colour=(0.15, 0.15, 0.25, 1.0), filled=True)
268        renderer.draw_text("SHOP", (px + 12, py + 8), scale=1.5, colour=(1, 1, 1))
269        renderer.draw_text(
270            f"Gold: {self._player_gold}", (px + _PW - 130, py + 8), scale=1.2, colour=(1.0, 0.85, 0.2, 1.0)
271        )
272
273        # Position canvas inside the panel under the title bar.
274        self._canvas.position = Vec2(px + 10, py + _TITLE_H + 8)
275        self._canvas.size = Vec2(_PW - 20, _PH - _TITLE_H - 16)
276
277        draw_back_button(renderer)
278
279    # -- Helpers --
280
281    def _clamp_cursor(self):
282        if self._selected_side == 0 and self._shop_inventory:
283            count = len(self._shop_inventory.items())
284            self._shop_cursor = min(self._shop_cursor, max(0, count - 1))
285        elif self._selected_side == 1 and self._player_inventory:
286            count = len(self._player_inventory.items())
287            self._player_cursor = min(self._player_cursor, max(0, count - 1))
288
289    def _get_shop_item(self) -> tuple[int, Item] | None:
290        if not self._shop_inventory:
291            return None
292        items_list = self._shop_inventory.items()
293        if self._shop_cursor >= len(items_list):
294            return None
295        return items_list[self._shop_cursor]
296
297    def _get_player_item(self) -> tuple[int, Item] | None:
298        if not self._player_inventory:
299            return None
300        items_list = self._player_inventory.items()
301        if self._player_cursor >= len(items_list):
302            return None
303        return items_list[self._player_cursor]
304
305    def _initiate_buy(self):
306        if not self._shop_inventory or not self._player:
307            return
308        result = self._get_shop_item()
309        if result is None:
310            return
311        _, item = result
312        price = self._get_price(item)
313        if self._player.gold < price or (self._player_inventory and self._player_inventory.is_full):
314            return
315        self._confirm_buy = item
316        self._confirm_price = price
317
318    def _do_buy(self, item: Item, price: int):
319        if not self._shop_inventory or not self._player_inventory or not self._player:
320            return
321        idx = None
322        for i, it in self._shop_inventory.items():
323            if it is item:
324                idx = i
325                break
326        if idx is None:
327            return
328        self._shop_inventory.remove(idx)
329        self._player_inventory.add(item)
330        self._player.gold -= price
331        self._player_gold = self._player.gold
332        self._clamp_cursor()
333        if self._on_transaction:
334            self._on_transaction("buy", item, price)
335
336    def buy_selected(self) -> bool:
337        if not self._shop_inventory or not self._player_inventory or not self._player:
338            return False
339        result = self._get_shop_item()
340        if result is None:
341            return False
342        slot_idx, item = result
343        price = self._get_price(item)
344        if self._player.gold < price or self._player_inventory.is_full:
345            return False
346        self._shop_inventory.remove(slot_idx)
347        self._player_inventory.add(item)
348        self._player.gold -= price
349        self._player_gold = self._player.gold
350        self._clamp_cursor()
351        if self._on_transaction:
352            self._on_transaction("buy", item, price)
353        return True
354
355    def sell_selected(self) -> bool:
356        if not self._player_inventory or not self._player:
357            return False
358        items_list = self._player_inventory.items()
359        if self._player_cursor >= len(items_list):
360            return False
361        slot_idx, item = items_list[self._player_cursor]
362        price = self._get_sell_price(item)
363        self._player_inventory.remove(slot_idx)
364        self._player.gold += price
365        self._player_gold = self._player.gold
366        self._clamp_cursor()
367        if self._on_transaction:
368            self._on_transaction("sell", item, price)
369        return True
370
371    def _get_price(self, item: Item) -> int:
372        base = 10 + item.ilvl * 5
373        rarity_mult = [1, 2, 4, 8, 16]
374        return base * rarity_mult[min(int(item.rarity), 4)]
375
376    def _get_sell_price(self, item: Item) -> int:
377        return max(1, self._get_price(item) * 2 // 5)
378
379    def _draw_comparison(self, renderer, shop_item: Item, equipped: Item, x: float, y: float):
380        w = 200
381        lines = [(f"vs {equipped.display_base}", (0.6, 0.6, 0.7, 1.0))]
382        for key, val in shop_item.base_stats.items():
383            eq_val = equipped.base_stats.get(key, 0)
384            delta = val - eq_val
385            if abs(delta) > 0.001:
386                label = key.replace("_", " ").title()
387                if isinstance(val, float) and val < 1:
388                    delta_str = f"{delta:+.0%}"
389                else:
390                    delta_str = f"{delta:+.0f}"
391                c = (0.3, 1.0, 0.3, 1.0) if delta > 0 else (1.0, 0.3, 0.3, 1.0)
392                lines.append((f"  {label}: {delta_str}", c))
393        if len(lines) <= 1:
394            return
395        h = len(lines) * 18 + 10
396        renderer.draw_rect((x, y), (w, h), colour=(0.08, 0.08, 0.12, 0.95), filled=True)
397        ty = y + 6
398        for text, colour in lines:
399            renderer.draw_text(text, (x + 8, ty), scale=0.85, colour=colour)
400            ty += 18
401
402    def _draw_confirm(self, renderer, cx: float, cy: float, cw: float, ch: float):
403        item = self._confirm_buy
404        if item is None:
405            return
406        bw, bh = 300, 100
407        bx = cx + (cw - bw) / 2
408        by = cy + (ch - bh) / 2
409        renderer.draw_rect((bx, by), (bw, bh), colour=(0.12, 0.12, 0.18, 0.98), filled=True)
410        renderer.draw_rect((bx, by), (bw, 2), colour=(0.7, 0.55, 0.15, 1.0), filled=True)
411        renderer.draw_text(f"Buy {item.display_name}?", (bx + 15, by + 15), scale=1.0, colour=(1.0, 1.0, 1.0))
412        renderer.draw_text(f"Cost: {self._confirm_price}g", (bx + 15, by + 38), scale=0.9, colour=(1.0, 0.85, 0.2, 1.0))
413        renderer.draw_text("Space: Confirm   Esc: Cancel", (bx + 15, by + bh - 22), scale=0.8, colour=(0.5, 0.5, 0.5))