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 M key and the pointer hit-testing, so it
5can close itself again while the tree is paused.
6"""
7
8from __future__ import annotations
9
10from settings import (
11 BAR_COLOUR,
12 BAR_COLOUR_SELECTED,
13 TEXT_COLOUR,
14 TEXT_COLOUR_SELECTED,
15 UI_BG_COLOUR,
16 UI_BORDER_COLOUR,
17 UI_BORDER_COLOUR_ACTIVE,
18 UPGRADE_BG_COLOUR_SELECTED,
19)
20
21from simvx.core import Input, MouseButton, Node2D, Property, Signal
22
23CLOSE_SIZE = (120, 40)
24
25
26class UpgradeMenu(Node2D):
27 """Renders the upgrade-screen overlay and consumes navigation input."""
28
29 visible = Property(
30 False,
31 coerce=bool,
32 hint="Whether this node and its subtree are drawn",
33 on_change="_on_visible_changed",
34 )
35
36 # Selection and stat values are plain state, not Properties.
37 dynamic = True
38
39 toggle_requested = Signal()
40
41 def __init__(self, player, **kwargs):
42 super().__init__(name="UpgradeMenu", **kwargs)
43 self.player = player
44 self.attribute_names = list(player.stats.keys())
45 self.selection_index = 0
46 self._cooldown = 0.0
47 self.z_index = 1500
48
49 # -- input --------------------------------------------------------------
50
51 def on_update(self, dt: float):
52 # M opens and closes the overlay; the level does the pausing.
53 if Input.is_action_just_pressed("upgrade_menu"):
54 self.toggle_requested()
55 self._cooldown = 0.25
56 return
57 if not self.visible:
58 return
59
60 self._cooldown = max(0.0, self._cooldown - dt)
61 self._handle_pointer()
62 if self._cooldown > 0:
63 return
64 if Input.is_action_just_pressed("ui_right") and self.selection_index < len(self.attribute_names) - 1:
65 self.selection_index += 1
66 self._cooldown = 0.18
67 elif Input.is_action_just_pressed("ui_left") and self.selection_index > 0:
68 self.selection_index -= 1
69 self._cooldown = 0.18
70 if Input.is_action_just_pressed("ui_select"):
71 self.player.upgrade_stat(self.attribute_names[self.selection_index])
72 self._cooldown = 0.25
73
74 def _handle_pointer(self):
75 """Click a panel to select it; click the selected panel to buy it."""
76 if not Input.is_mouse_button_just_pressed(MouseButton.LEFT):
77 return
78 pos = Input.mouse_position
79 sw, sh = self._screen_size()
80
81 cx, cy = self._close_rect(sw)
82 if cx <= pos.x <= cx + CLOSE_SIZE[0] and cy <= pos.y <= cy + CLOSE_SIZE[1]:
83 self.toggle_requested()
84 return
85
86 for i, (x, top, panel_w, panel_h) in enumerate(self._panel_rects(sw, sh)):
87 if x <= pos.x <= x + panel_w and top <= pos.y <= top + panel_h:
88 if i == self.selection_index:
89 self.player.upgrade_stat(self.attribute_names[i])
90 else:
91 self.selection_index = i
92 self._cooldown = 0.18
93 return
94
95 # -- layout -------------------------------------------------------------
96
97 def _screen_size(self) -> tuple[float, float]:
98 return self.tree.screen_size if self.tree else (1280.0, 720.0)
99
100 def _panel_rects(self, sw: float, sh: float) -> list[tuple[float, float, float, float]]:
101 """Evenly spaced panels with a screen margin, so nothing touches the edges."""
102 n = len(self.attribute_names)
103 margin = sw * 0.05
104 gap = 20.0
105 panel_w = (sw - 2 * margin - gap * (n - 1)) / n
106 panel_h = sh * 0.66
107 top = sh * 0.2
108 return [(margin + i * (panel_w + gap), top, panel_w, panel_h) for i in range(n)]
109
110 def _close_rect(self, sw: float) -> tuple[float, float]:
111 return sw - CLOSE_SIZE[0] - 20, 20.0
112
113 # -- drawing ------------------------------------------------------------
114
115 def on_draw(self, renderer):
116 if not self.visible:
117 return
118 sw, sh = self._screen_size()
119
120 # Opaque backdrop so the world doesn't bleed through.
121 renderer.draw_rect((0, 0), (sw, sh), colour=(0.06, 0.06, 0.08, 1.0), filled=True)
122 heading = "STAT UPGRADE: click a panel to select, click again to buy"
123 hw = renderer.text_width(heading, 1.8)
124 renderer.draw_text(heading, ((sw - hw) / 2, sh * 0.2 - 42), colour=TEXT_COLOUR, scale=1.8)
125
126 for i, (x, top, panel_w, panel_h) in enumerate(self._panel_rects(sw, sh)):
127 attr = self.attribute_names[i]
128 selected = i == self.selection_index
129 bg = UPGRADE_BG_COLOUR_SELECTED if selected else UI_BG_COLOUR
130 renderer.draw_rect((x, top), (panel_w, panel_h), colour=bg, filled=True)
131 renderer.draw_rect((x, top), (panel_w, panel_h), colour=UI_BORDER_COLOUR, filled=False)
132
133 txt_colour = TEXT_COLOUR_SELECTED if selected else TEXT_COLOUR
134 renderer.draw_text(attr.upper(), (x + 14, top + 18), colour=txt_colour, scale=2)
135 cost = self.player.upgrade_cost[attr]
136 renderer.draw_text(f"cost {int(cost)}", (x + 14, top + panel_h - 36), colour=txt_colour, scale=2)
137
138 # Value bar
139 val = self.player.stats[attr]
140 mx = self.player.max_stats[attr]
141 ratio = max(0.0, min(1.0, val / max(1.0, mx)))
142 bar_top = top + 64
143 bar_bot = top + panel_h - 80
144 bar_x = x + panel_w // 2 - 4
145 line_colour = BAR_COLOUR_SELECTED if selected else BAR_COLOUR
146 renderer.draw_rect((bar_x, bar_top), (8, bar_bot - bar_top), colour=line_colour, filled=True)
147 ind_y = bar_bot - (bar_bot - bar_top) * ratio
148 renderer.draw_rect((bar_x - 14, ind_y - 6), (36, 12), colour=line_colour, filled=True)
149
150 # Close button (M does the same thing).
151 cx, cy = self._close_rect(sw)
152 renderer.draw_rect((cx, cy), CLOSE_SIZE, colour=UI_BG_COLOUR, filled=True)
153 renderer.draw_rect((cx, cy), CLOSE_SIZE, colour=UI_BORDER_COLOUR_ACTIVE, filled=False)
154 label = "CLOSE M"
155 lw = renderer.text_width(label, 1.6)
156 renderer.draw_text(label, (cx + (CLOSE_SIZE[0] - lw) / 2, cy + 11), colour=TEXT_COLOUR, scale=1.6)
157
158 # Remaining EXP, so the player can see what a purchase costs them.
159 exp_text = f"XP {int(self.player.exp)}"
160 renderer.draw_text(exp_text, (20, 20), colour=TEXT_COLOUR, scale=2)