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)