nodes/enchanter.pyΒΆ
Part of Dungeon Explorer.
1"""Enchanter NPC: add a random affix to equipment for gold."""
2
3import random
4
5from scripts.inventory import Inventory
6from scripts.item import Affix, Item
7
8from simvx.core import Control, Property, Vec2
9from simvx.core.input import MouseButton
10from simvx.core.ui import OverlayCanvas
11from simvx.core.ui.enums import AnchorPreset
12
13from ._back_button import back_button_hit, draw_back_button
14
15_PW, _PH = 600, 500
16_TITLE_H = 36
17
18
19def enchant_cost(item: Item) -> int:
20 """Calculate enchanting cost based on rarity and item level."""
21 base = 50
22 rarity_mult = 1 + int(item.rarity) * 0.5
23 ilvl_mult = 1 + item.ilvl * 0.1
24 return int(base * rarity_mult * ilvl_mult)
25
26
27_ENCHANT_POOL = [
28 ("damage", 2, 15, "Sharp"),
29 ("defence", 1, 10, "Hardened"),
30 ("crit_chance", 0.02, 0.08, "Keen"),
31 ("speed", 0.02, 0.10, "Swift"),
32 ("max_hp", 5, 30, "Vital"),
33 ("life_steal", 0.01, 0.05, "Draining"),
34 ("fire_damage", 2, 12, "Blazing"),
35 ("ice_damage", 2, 10, "Frosted"),
36 ("dodge_chance", 0.01, 0.06, "Evasive"),
37 ("thorns", 1, 8, "Thorned"),
38]
39
40
41def enchant_item(item: Item, rng: random.Random | None = None) -> Affix:
42 """Add a random affix to an item. Returns the new affix."""
43 rng = rng or random.Random()
44 stat, lo, hi, display = rng.choice(_ENCHANT_POOL)
45 value = round(rng.uniform(lo, hi), 3)
46 affix = Affix(name=display, affix_type="prefix", stat=stat, value=value)
47 item.prefixes.append(affix)
48 return affix
49
50
51class _EnchanterCanvas(OverlayCanvas):
52 """Custom-drawn enchanter item list."""
53
54 content_size = Property((580.0, 440.0))
55
56 def __init__(self, popup):
57 # The literal, not self.content_size: property storage is set up by
58 # Node.__init__, so reading the descriptor before that call yields None.
59 super().__init__(content_size=(580.0, 440.0))
60 self._popup = popup
61 self.mouse_filter = False
62
63 def custom_draw(self, renderer):
64 x, y, _, _ = self.get_global_rect()
65 p = self._popup
66 renderer.draw_text(
67 "Add a random enchantment to your equipment", (x + 10, y + 10), scale=0.9, colour=(0.7, 0.7, 0.7)
68 )
69
70 if p._inventory is None:
71 return
72
73 items = p._inventory.items()
74 equipment = [(i, item) for i, item in items if item.is_equipment]
75
76 if not equipment:
77 renderer.draw_text("No equipment to enchant.", (x + 10, y + 50), scale=1.0, colour=(0.5, 0.5, 0.5))
78 else:
79 for j, (_idx, item) in enumerate(equipment):
80 ny = y + 45 + j * 36
81 sel = j == p._cursor
82 if sel:
83 renderer.draw_rect((x, ny - 2), (560, 32), colour=(0.2, 0.15, 0.3, 0.8), filled=True)
84 colour = item.colour if not sel else (1.0, 1.0, 1.0, 1.0)
85 cost = enchant_cost(item)
86 renderer.draw_text(f"{item.display_name}", (x + 10, ny), scale=1.0, colour=colour)
87 renderer.draw_text(
88 f" Cost: {cost}g Affixes: {len(item.prefixes) + len(item.suffixes)}",
89 (x + 10, ny + 16),
90 scale=0.8,
91 colour=(0.5, 0.5, 0.5),
92 )
93
94 content_y = y + 45 + max(len(equipment), 1) * 36 + 30
95 if p._message:
96 renderer.draw_text(p._message, (x + 10, content_y), scale=1.0, colour=(0.3, 1.0, 0.3))
97 content_y += 25
98
99 gold = p._player.gold if p._player else 0
100 renderer.draw_text(f"Gold: {gold}", (x + 10, content_y), scale=0.9, colour=(1.0, 0.85, 0.2))
101 renderer.draw_text("Space: Enchant Esc: Close", (x + 10, content_y + 22), scale=0.8, colour=(0.4, 0.4, 0.4))
102
103
104class EnchanterUI(Control):
105 """Enchanter popup: select an item to enchant for gold."""
106
107 visible = Property(
108 False,
109 coerce=bool,
110 hint="Whether this node and its subtree are drawn",
111 on_change="_on_visible_changed",
112 )
113
114 def __init__(self, on_enchant=None, **kwargs):
115 super().__init__(name="EnchanterUI", **kwargs)
116 self.set_anchor_preset(AnchorPreset.FULL_RECT)
117
118 self._inventory: Inventory | None = None
119 self._player = None
120 self._cursor = 0
121 self._on_enchant = on_enchant
122 self._message = ""
123
124 self._canvas = _EnchanterCanvas(self)
125 self.add_child(self._canvas)
126
127 def setup(self, inventory: Inventory, player) -> None:
128 self._inventory = inventory
129 self._player = player
130
131 def show(self):
132 self._cursor = 0
133 self._message = ""
134 self.show_overlay("blocking", dismiss=True)
135
136 # -- Input --
137
138 def _on_gui_input(self, event):
139 if not self.visible or self._inventory is None:
140 return
141 if event.button == MouseButton.LEFT and event.pressed:
142 mx, my = float(event.position[0]), float(event.position[1])
143 if back_button_hit(mx, my):
144 self.cancel_requested.emit()
145 self.close_overlay()
146 event.handled = True
147 return
148 if event.key and event.pressed:
149 items = self._inventory.items()
150 equipment = [(i, item) for i, item in items if item.is_equipment]
151 if event.key in ("w", "up"):
152 self._cursor = max(0, self._cursor - 1)
153 event.handled = True
154 elif event.key in ("s", "down"):
155 self._cursor = min(self._cursor + 1, max(0, len(equipment) - 1))
156 event.handled = True
157 elif event.key in ("space", "enter", "return"):
158 if 0 <= self._cursor < len(equipment):
159 _idx, item = equipment[self._cursor]
160 cost = enchant_cost(item)
161 if self._player and self._player.gold >= cost:
162 self._player.gold -= cost
163 affix = enchant_item(item)
164 self._message = f"Enchanted with {affix.display_text()}!"
165 if self._on_enchant:
166 self._on_enchant(item, affix)
167 else:
168 self._message = f"Need {cost} gold!"
169 event.handled = True
170 elif event.key == "e":
171 self.cancel_requested.emit()
172 self.close_overlay()
173 event.handled = True
174
175 # -- Layout --
176
177 def _screen_size(self) -> tuple[float, float]:
178 if self.tree is not None:
179 sw, sh = self.tree.screen_size
180 return float(sw), float(sh)
181 return 1280.0, 720.0
182
183 def _panel_origin(self) -> tuple[float, float]:
184 sw, sh = self._screen_size()
185 return (sw - _PW) / 2, (sh - _PH) / 2
186
187 # -- Rendering --
188
189 def on_draw(self, renderer):
190 if not self.visible:
191 return
192 sw, sh = self._screen_size()
193 renderer.draw_rect((0, 0), (sw, sh), colour=(0.0, 0.0, 0.0, 0.6), filled=True)
194
195 px, py = self._panel_origin()
196 renderer.draw_rect((px, py), (_PW, _PH), colour=(0.12, 0.12, 0.18, 0.95), filled=True)
197 renderer.draw_rect((px, py), (_PW, _TITLE_H), colour=(0.15, 0.15, 0.25, 1.0), filled=True)
198 renderer.draw_text("ENCHANTER", (px + 12, py + 8), scale=1.5, colour=(1, 1, 1))
199
200 self._canvas.position = Vec2(px + 10, py + _TITLE_H + 8)
201 self._canvas.size = Vec2(_PW - 20, _PH - _TITLE_H - 16)
202
203 draw_back_button(renderer)