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)