nodes/buy_screen.pyΒΆ

Part of SNKRX.

  1"""Build / upgrade screen: between rounds.
  2
  3Three offers per visit, drawn from the Warrior/Archer/Mage pool. The player
  4picks one to add to the snake (or skip). Gold gates rerolling. Mirrors
  5the SNKRX buy screen flow at a small scale.
  6"""
  7
  8from __future__ import annotations
  9
 10import random
 11
 12from simvx.core import Input, Mouse, Node, Signal
 13
 14from .colours import BG, BG2, BLUE, FG, GREEN, GREY, YELLOW
 15
 16CARD_W = 250
 17CARD_H = 340
 18CARD_GAP = 30
 19BUTTON_W = 160
 20BUTTON_H = 48
 21
 22
 23CLASS_INFO = {
 24    "warrior": {"colour": YELLOW, "tag": "WARRIOR", "blurb": "melee swipe"},
 25    "archer": {"colour": GREEN, "tag": "ARCHER", "blurb": "pierce arrow"},
 26    "mage": {"colour": BLUE, "tag": "MAGE", "blurb": "AOE bolt"},
 27}
 28
 29
 30def _inside(rect: tuple[int, int, int, int], x: float, y: float) -> bool:
 31    """Point-in-rect test for an (x, y, w, h) rect."""
 32    rx, ry, rw, rh = rect
 33    return rx <= x <= rx + rw and ry <= y <= ry + rh
 34
 35
 36def _roll_offers(level: int) -> list[tuple[str, int]]:
 37    """Roll 3 (klass, level) offers."""
 38    klasses = list(CLASS_INFO.keys())
 39    return [(random.choice(klasses), max(1, min(5, level + random.randint(-1, 1)))) for _ in range(3)]
 40
 41
 42class BuyScreen(Node):
 43    """Between-round shop. Pick one (or skip) and start next wave."""
 44
 45    chosen = Signal()  # emits (klass: str, level: int)
 46    skipped = Signal()
 47    rerolled = Signal()
 48
 49    def __init__(self, *, level: int, gold: int, build: list[tuple[str, int]], **kwargs):
 50        super().__init__(name="BuyScreen", **kwargs)
 51        self.level = level
 52        self.gold = gold
 53        self.build = list(build)
 54        self.offers = _roll_offers(level)
 55        self._selected = 0
 56        self._drawn: tuple | None = None
 57
 58    def _mark_dirty_if_changed(self) -> None:
 59        # The shop only changes on discrete events (selection move via key or
 60        # mouse hover, gold spent on a reroll, fresh offers, a window resize).
 61        # Dirty the retained 2D cache here, the single mutation site, and only
 62        # on a real change, not every frame.
 63        snapshot = (self._selected, tuple(self.offers), self.gold, self._screen())
 64        if snapshot != self._drawn:
 65            self._drawn = snapshot
 66            self.queue_redraw()
 67
 68    def on_update(self, dt: float):
 69        # Mouse position (Input.mouse_position is a Vec2)
 70        mp = Input.mouse_position
 71        mx, my = float(mp.x), float(mp.y)
 72        clicked = Input.is_mouse_button_just_pressed(Mouse.LEFT)
 73
 74        # Keyboard navigation
 75        if Input.just_pressed("left"):
 76            self._selected = (self._selected - 1) % 3
 77        if Input.just_pressed("right"):
 78            self._selected = (self._selected + 1) % 3
 79
 80        # Mouse hover overrides selection; a tap moves the cursor first, so
 81        # touch picks the card under the finger too.
 82        hovered: int | None = None
 83        for i in range(3):
 84            x, y = self._card_xy(i)
 85            if x <= mx <= x + CARD_W and y <= my <= y + CARD_H:
 86                hovered = i
 87                self._selected = i
 88
 89        # Reroll / skip buttons: clickable as well as R / S.
 90        reroll_rect, skip_rect = self._button_rects()
 91        if clicked and _inside(skip_rect, mx, my):
 92            self.skipped.emit()
 93            return
 94        if clicked and _inside(reroll_rect, mx, my):
 95            self._reroll()
 96            self._mark_dirty_if_changed()
 97            return
 98
 99        # ENTER picks the selection; a click picks the card it landed on.
100        if Input.just_pressed("start") or (clicked and hovered is not None):
101            klass, lvl = self.offers[self._selected]
102            self.chosen.emit(klass, lvl)
103            return
104
105        # Skip with S
106        if Input.just_pressed("skip"):
107            self.skipped.emit()
108            return
109
110        # Reroll with R (costs 2 gold)
111        if Input.just_pressed("reroll"):
112            self._reroll()
113
114        # Re-collect this frame only if the drawn state actually moved.
115        self._mark_dirty_if_changed()
116
117    def _reroll(self) -> None:
118        """Spend 2 gold on a fresh set of offers. No-op when the player is short."""
119        if self.gold < 2:
120            return
121        self.gold -= 2
122        self.offers = _roll_offers(self.level)
123        self.rerolled.emit()
124
125    # ------------------------------------------------------------------ layout
126
127    def _screen(self) -> tuple[int, int]:
128        w, h = self.tree.screen_size if self.tree is not None else (1280, 720)
129        return int(w), int(h)
130
131    def _card_xy(self, i: int) -> tuple[int, int]:
132        w, h = self._screen()
133        total = 3 * CARD_W + 2 * CARD_GAP
134        x0 = w // 2 - total // 2
135        return x0 + i * (CARD_W + CARD_GAP), h // 2 - CARD_H // 2 + 30
136
137    def _button_rects(self) -> tuple[tuple[int, int, int, int], tuple[int, int, int, int]]:
138        """Return the (reroll, skip) button rects as (x, y, w, h), right-aligned above the strip."""
139        w, h = self._screen()
140        y = h - 140
141        skip_x = w - 20 - BUTTON_W
142        reroll_x = skip_x - 20 - BUTTON_W
143        return (reroll_x, y, BUTTON_W, BUTTON_H), (skip_x, y, BUTTON_W, BUTTON_H)
144
145    def on_draw(self, renderer):
146        w, h = self._screen()
147        renderer.rect((0, 0), (w, h), fill=BG)
148
149        # Header
150        title = f"WAVE {self.level} CLEAR"
151        tw = renderer.text_width(title, 5)
152        renderer.text(title, (w // 2 - tw // 2, 70), fill=YELLOW, scale=5)
153
154        sub = "PICK A UNIT TO JOIN YOUR SNAKE"
155        sw = renderer.text_width(sub, 3)
156        renderer.text(sub, (w // 2 - sw // 2, 145), fill=GREY, scale=3)
157
158        # Resources line
159        info = f"GOLD  {self.gold}     BUILD  {len(self.build)}/8"
160        iw = renderer.text_width(info, 2)
161        renderer.text(info, (w // 2 - iw // 2, 195), fill=FG, scale=2)
162
163        # Cards
164        for i, (klass, lvl) in enumerate(self.offers):
165            self._draw_card(renderer, i, klass, lvl, selected=(i == self._selected))
166
167        # Build preview
168        bx = 80
169        by = h - 180
170        renderer.text("YOUR SNAKE", (bx, by), fill=FG, scale=3)
171        cur_x = bx + 20
172        cur_y = by + 50
173        for j, (k, lv) in enumerate(self.build):
174            c = CLASS_INFO[k]["colour"]
175            renderer.circle((cur_x + j * 22, cur_y), 7.0 if j > 0 else 8.5, fill=c, segments=18)
176            renderer.text(f"{lv}", (cur_x + j * 22 - 4, cur_y - 24), fill=FG, scale=1)
177
178        # Clickable reroll / skip buttons (the keyboard shortcuts still work)
179        reroll_rect, skip_rect = self._button_rects()
180        reroll_colour = YELLOW if self.gold >= 2 else GREY
181        self._draw_button(renderer, reroll_rect, "REROLL  2g", reroll_colour)
182        self._draw_button(renderer, skip_rect, "SKIP", FG)
183
184        # Bottom controls strip
185        bottom = h - 56
186        renderer.rect((0, bottom), (w, 56), fill=(0.85, 0.85, 0.88, 1.0))
187        controls = "HOVER / A D  SELECT     CLICK / ENTER  PICK     ESC  QUIT"
188        cw = renderer.text_width(controls, 2)
189        renderer.text(controls, (w // 2 - cw // 2, bottom + 20), fill=(0.10, 0.10, 0.12, 1.0), scale=2)
190
191    def _draw_button(self, renderer, rect: tuple[int, int, int, int], label: str, colour) -> None:
192        x, y, bw, bh = rect
193        renderer.rect((x, y), (bw, bh), fill=BG2)
194        renderer.rect((x, y), (bw, bh), stroke=colour, width=2.0)
195        lw = renderer.text_width(label, 2)
196        renderer.text(label, (x + bw // 2 - lw // 2, y + bh // 2 - 8), fill=colour, scale=2)
197
198    def _draw_card(self, renderer, i: int, klass: str, lvl: int, *, selected: bool):
199        info = CLASS_INFO[klass]
200        x, y = self._card_xy(i)
201        # Card background
202        renderer.rect((x, y), (CARD_W, CARD_H), fill=BG2)
203        border = info["colour"] if selected else (0.30, 0.33, 0.40, 1.0)
204        renderer.rect((x, y), (CARD_W, CARD_H), stroke=border, width=3.0 if selected else 1.5)
205        # Selection halo
206        if selected:
207            renderer.rect(
208                (x - 4, y - 4),
209                (CARD_W + 8, CARD_H + 8),
210                stroke=(info["colour"][0], info["colour"][1], info["colour"][2], 0.35),
211                width=2.0,
212            )
213        # Class label
214        tag = info["tag"]
215        tw = renderer.text_width(tag, 4)
216        renderer.text(tag, (x + CARD_W // 2 - tw // 2, y + 28), fill=info["colour"], scale=4)
217        # Level
218        lvl_str = f"LV {lvl}"
219        lw = renderer.text_width(lvl_str, 3)
220        renderer.text(lvl_str, (x + CARD_W // 2 - lw // 2, y + 90), fill=YELLOW, scale=3)
221        # Pip
222        renderer.circle((x + CARD_W // 2, y + 175), 22.0, fill=info["colour"], segments=20)
223        # Blurb
224        blurb = info["blurb"]
225        bw = renderer.text_width(blurb, 2)
226        renderer.text(blurb, (x + CARD_W // 2 - bw // 2, y + 230), fill=FG, scale=2)
227        # Stat line
228        from .units import CLASS_STATS
229
230        stats = CLASS_STATS[klass]
231        scale = 1.0 + 0.25 * (lvl - 1)
232        line = f"HP {int(stats['hp'] * scale)}  DMG {stats['dmg'] * scale:.0f}"
233        sw = renderer.text_width(line, 2)
234        renderer.text(line, (x + CARD_W // 2 - sw // 2, y + 270), fill=GREY, scale=2)