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)