nodes/upgrade.pyΒΆ
Part of Clear Code Zelda.
1"""Upgrade screen: five vertical attribute panels with a selection cursor.
2
3Drawn directly to screen via ``on_draw``. The level shows it and pauses the
4world behind it; this node owns the open-and-close action, the sideways
5navigation and the tap hit-testing, so it can close itself again while the
6tree is paused.
7"""
8
9from __future__ import annotations
10
11from settings import (
12 BAR_COLOUR,
13 BAR_COLOUR_SELECTED,
14 TEXT_COLOUR,
15 TEXT_COLOUR_SELECTED,
16 UI_BG_COLOUR,
17 UI_BORDER_COLOUR,
18 UI_BORDER_COLOUR_ACTIVE,
19 UPGRADE_BG_COLOUR_SELECTED,
20)
21
22from simvx.core import Input, Node2D, Pointer, PointerEvent, PointerPhase, Property, Signal
23
24CLOSE_SIZE = (120, 40)
25
26#: How far the sideways action has to be pushed before the selection steps.
27NAV_THRESHOLD = 0.5
28
29
30class UpgradeMenu(Node2D):
31 """Renders the upgrade-screen overlay and consumes navigation input."""
32
33 visible = Property(
34 False,
35 coerce=bool,
36 hint="Whether this node and its subtree are drawn",
37 on_change="_on_visible_changed",
38 )
39
40 # Selection and stat values are plain state, not Properties.
41 dynamic = True
42
43 toggle_requested = Signal()
44
45 def __init__(self, player, **kwargs):
46 super().__init__(name="UpgradeMenu", **kwargs)
47 self.player = player
48 self.attribute_names = list(player.stats.keys())
49 self.selection_index = 0
50 self._cooldown = 0.0
51 self.z_index = 1500
52
53 # -- input --------------------------------------------------------------
54
55 def on_update(self, dt: float):
56 # M and START open and close the overlay; the level does the pausing.
57 if Input.just_pressed("upgrade_menu") or (self.visible and Input.just_pressed("ui_cancel")):
58 self.toggle_requested.emit()
59 self._cooldown = 0.25
60 return
61 if not self.visible:
62 return
63
64 self._cooldown = max(0.0, self._cooldown - dt)
65 if self._cooldown > 0:
66 return
67 # One typed action for the sideways move: the arrows, A and D, a
68 # controller's left stick and the drawn one all read out as a float,
69 # and the cooldown below is what turns a held push into steps.
70 nav = Input.value("ui_nav")
71 if nav > NAV_THRESHOLD and self.selection_index < len(self.attribute_names) - 1:
72 self.selection_index += 1
73 self._cooldown = 0.18
74 elif nav < -NAV_THRESHOLD and self.selection_index > 0:
75 self.selection_index -= 1
76 self._cooldown = 0.18
77 if Input.just_pressed("ui_select"):
78 self.player.upgrade_stat(self.attribute_names[self.selection_index])
79 self._cooldown = 0.25
80
81 def on_input(self, event) -> bool | None:
82 """Tap a panel to select it, tap the selected panel to buy it.
83
84 This is an event handler rather than a poll of the mouse button so
85 that a thumb landing on the on-screen pad, which is drawn over this
86 overlay and claims its own presses, is not also read as a panel tap.
87 """
88 if not self.visible or not isinstance(event, PointerEvent):
89 return None
90 if event.phase is not PointerPhase.DOWN or event.button not in Pointer.PRIMARY:
91 return None
92 px, py = event.position
93 sw, sh = self.viewport_size
94
95 cx, cy = self._close_rect(sw)
96 if cx <= px <= cx + CLOSE_SIZE[0] and cy <= py <= cy + CLOSE_SIZE[1]:
97 self.toggle_requested.emit()
98 return True
99
100 for i, (x, top, panel_w, panel_h) in enumerate(self._panel_rects(sw, sh)):
101 if x <= px <= x + panel_w and top <= py <= top + panel_h:
102 if i == self.selection_index:
103 self.player.upgrade_stat(self.attribute_names[i])
104 else:
105 self.selection_index = i
106 self._cooldown = 0.18
107 return True
108 return None
109
110 # -- layout -------------------------------------------------------------
111
112 def _panel_rects(self, sw: float, sh: float) -> list[tuple[float, float, float, float]]:
113 """Evenly spaced panels with a screen margin, so nothing touches the edges."""
114 n = len(self.attribute_names)
115 margin = sw * 0.05
116 gap = 20.0
117 panel_w = (sw - 2 * margin - gap * (n - 1)) / n
118 panel_h = sh * 0.66
119 top = sh * 0.2
120 return [(margin + i * (panel_w + gap), top, panel_w, panel_h) for i in range(n)]
121
122 def _close_rect(self, sw: float) -> tuple[float, float]:
123 return sw - CLOSE_SIZE[0] - 20, 20.0
124
125 # -- drawing ------------------------------------------------------------
126
127 def on_draw(self, renderer):
128 if not self.visible:
129 return
130 sw, sh = self.viewport_size
131
132 # Opaque backdrop so the world doesn't bleed through.
133 renderer.rect((0, 0), (sw, sh), fill=(0.06, 0.06, 0.08, 1.0))
134 heading = "STAT UPGRADE: tap a panel to select, tap it again to buy"
135 hw = renderer.text_width(heading, 1.8)
136 renderer.text(heading, ((sw - hw) / 2, sh * 0.2 - 42), fill=TEXT_COLOUR, scale=1.8)
137
138 for i, (x, top, panel_w, panel_h) in enumerate(self._panel_rects(sw, sh)):
139 attr = self.attribute_names[i]
140 selected = i == self.selection_index
141 bg = UPGRADE_BG_COLOUR_SELECTED if selected else UI_BG_COLOUR
142 renderer.rect((x, top), (panel_w, panel_h), fill=bg)
143 renderer.rect((x, top), (panel_w, panel_h), stroke=UI_BORDER_COLOUR)
144
145 txt_colour = TEXT_COLOUR_SELECTED if selected else TEXT_COLOUR
146 renderer.text(attr.upper(), (x + 14, top + 18), fill=txt_colour, scale=2)
147 cost = self.player.upgrade_cost[attr]
148 renderer.text(f"cost {int(cost)}", (x + 14, top + panel_h - 36), fill=txt_colour, scale=2)
149
150 # Value bar
151 val = self.player.stats[attr]
152 mx = self.player.max_stats[attr]
153 ratio = max(0.0, min(1.0, val / max(1.0, mx)))
154 bar_top = top + 64
155 bar_bot = top + panel_h - 80
156 bar_x = x + panel_w // 2 - 4
157 line_colour = BAR_COLOUR_SELECTED if selected else BAR_COLOUR
158 renderer.rect((bar_x, bar_top), (8, bar_bot - bar_top), fill=line_colour)
159 ind_y = bar_bot - (bar_bot - bar_top) * ratio
160 renderer.rect((bar_x - 14, ind_y - 6), (36, 12), fill=line_colour)
161
162 # Close button. M, START and B do the same thing.
163 cx, cy = self._close_rect(sw)
164 renderer.rect((cx, cy), CLOSE_SIZE, fill=UI_BG_COLOUR)
165 renderer.rect((cx, cy), CLOSE_SIZE, stroke=UI_BORDER_COLOUR_ACTIVE)
166 label = "CLOSE M/B"
167 lw = renderer.text_width(label, 1.6)
168 renderer.text(label, (cx + (CLOSE_SIZE[0] - lw) / 2, cy + 11), fill=TEXT_COLOUR, scale=1.6)
169
170 # Remaining EXP, so the player can see what a purchase costs them.
171 exp_text = f"XP {int(self.player.exp)}"
172 renderer.text(exp_text, (20, 20), fill=TEXT_COLOUR, scale=2)