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)