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)