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)