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