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))