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