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)