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, MouseButton, 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(MouseButton.LEFT)
 73
 74        # Keyboard navigation
 75        if Input.is_action_just_pressed("left"):
 76            self._selected = (self._selected - 1) % 3
 77        if Input.is_action_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.is_action_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.is_action_just_pressed("skip"):
107            self.skipped.emit()
108            return
109
110        # Reroll with R (costs 2 gold)
111        if Input.is_action_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.draw_rect((0, 0), (w, h), colour=BG, filled=True)
148
149        # Header
150        title = f"WAVE {self.level} CLEAR"
151        tw = renderer.text_width(title, 5)
152        renderer.draw_text(title, (w // 2 - tw // 2, 70), scale=5, colour=YELLOW)
153
154        sub = "PICK A UNIT TO JOIN YOUR SNAKE"
155        sw = renderer.text_width(sub, 3)
156        renderer.draw_text(sub, (w // 2 - sw // 2, 145), scale=3, colour=GREY)
157
158        # Resources line
159        info = f"GOLD  {self.gold}     BUILD  {len(self.build)}/8"
160        iw = renderer.text_width(info, 2)
161        renderer.draw_text(info, (w // 2 - iw // 2, 195), scale=2, colour=FG)
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.draw_text("YOUR SNAKE", (bx, by), scale=3, colour=FG)
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.draw_circle(
176                (cur_x + j * 22, cur_y),
177                7.0 if j > 0 else 8.5,
178                colour=c,
179                filled=True,
180                segments=18,
181            )
182            renderer.draw_text(f"{lv}", (cur_x + j * 22 - 4, cur_y - 24), scale=1, colour=FG)
183
184        # Clickable reroll / skip buttons (the keyboard shortcuts still work)
185        reroll_rect, skip_rect = self._button_rects()
186        reroll_colour = YELLOW if self.gold >= 2 else GREY
187        self._draw_button(renderer, reroll_rect, "REROLL  2g", reroll_colour)
188        self._draw_button(renderer, skip_rect, "SKIP", FG)
189
190        # Bottom controls strip
191        bottom = h - 56
192        renderer.draw_rect((0, bottom), (w, 56), colour=(0.85, 0.85, 0.88, 1.0), filled=True)
193        controls = "HOVER / A D  SELECT     CLICK / ENTER  PICK     ESC  QUIT"
194        cw = renderer.text_width(controls, 2)
195        renderer.draw_text(controls, (w // 2 - cw // 2, bottom + 20), scale=2, colour=(0.10, 0.10, 0.12, 1.0))
196
197    def _draw_button(self, renderer, rect: tuple[int, int, int, int], label: str, colour) -> None:
198        x, y, bw, bh = rect
199        renderer.draw_rect((x, y), (bw, bh), colour=BG2, filled=True)
200        renderer.draw_rect((x, y), (bw, bh), colour=colour, filled=False, thickness=2.0)
201        lw = renderer.text_width(label, 2)
202        renderer.draw_text(label, (x + bw // 2 - lw // 2, y + bh // 2 - 8), scale=2, colour=colour)
203
204    def _draw_card(self, renderer, i: int, klass: str, lvl: int, *, selected: bool):
205        info = CLASS_INFO[klass]
206        x, y = self._card_xy(i)
207        # Card background
208        renderer.draw_rect((x, y), (CARD_W, CARD_H), colour=BG2, filled=True)
209        border = info["colour"] if selected else (0.30, 0.33, 0.40, 1.0)
210        renderer.draw_rect((x, y), (CARD_W, CARD_H), colour=border, filled=False, thickness=3.0 if selected else 1.5)
211        # Selection halo
212        if selected:
213            renderer.draw_rect(
214                (x - 4, y - 4),
215                (CARD_W + 8, CARD_H + 8),
216                colour=(info["colour"][0], info["colour"][1], info["colour"][2], 0.35),
217                filled=False,
218                thickness=2.0,
219            )
220        # Class label
221        tag = info["tag"]
222        tw = renderer.text_width(tag, 4)
223        renderer.draw_text(tag, (x + CARD_W // 2 - tw // 2, y + 28), scale=4, colour=info["colour"])
224        # Level
225        lvl_str = f"LV {lvl}"
226        lw = renderer.text_width(lvl_str, 3)
227        renderer.draw_text(lvl_str, (x + CARD_W // 2 - lw // 2, y + 90), scale=3, colour=YELLOW)
228        # Pip
229        renderer.draw_circle(
230            (x + CARD_W // 2, y + 175),
231            22.0,
232            colour=info["colour"],
233            filled=True,
234            segments=20,
235        )
236        # Blurb
237        blurb = info["blurb"]
238        bw = renderer.text_width(blurb, 2)
239        renderer.draw_text(blurb, (x + CARD_W // 2 - bw // 2, y + 230), scale=2, colour=FG)
240        # Stat line
241        from .units import CLASS_STATS
242
243        stats = CLASS_STATS[klass]
244        scale = 1.0 + 0.25 * (lvl - 1)
245        line = f"HP {int(stats['hp']*scale)}  DMG {stats['dmg']*scale:.0f}"
246        sw = renderer.text_width(line, 2)
247        renderer.draw_text(line, (x + CARD_W // 2 - sw // 2, y + 270), scale=2, colour=GREY)