nodes/ui.py

Part of HeartBeast Action RPG.

  1"""Title card and pointer controls, so the port opens on a menu and plays by touch.
  2
  3Both are CanvasLayers that draw in screen space, above the world and unaffected
  4by the camera. The web export reports a touch as a left mouse button press at
  5the touch position, so polling :class:`simvx.core.Input` for the left button
  6covers mouse and touch alike.
  7"""
  8
  9from __future__ import annotations
 10
 11from settings import COLOUR_UI_BG, COLOUR_UI_TEXT, COLOUR_UI_TEXT_DIM, COLOUR_WHITE
 12
 13from simvx.core import CanvasLayer, Input, MouseButton, Signal, Vec2
 14
 15#: Radius of the steering ring, and how far the knob travels from its origin.
 16STICK_RADIUS = 64.0
 17#: Travel below this many pixels reads as "no input".
 18STICK_DEADZONE = 10.0
 19
 20RING_COLOUR = (1.0, 1.0, 1.0, 0.22)
 21KNOB_COLOUR = (1.0, 1.0, 1.0, 0.40)
 22BUTTON_COLOUR = (0.10, 0.10, 0.12, 0.60)
 23BUTTON_COLOUR_HELD = (0.95, 0.80, 0.20, 0.80)
 24
 25TITLE = "HEARTBEAST ACTION RPG"
 26SUBTITLE = "A SimVX port of HeartBeast's Godot Action RPG tutorial"
 27START_PROMPT = "PRESS SPACE / ENTER OR TAP TO PLAY"
 28
 29
 30class StartScreen(CanvasLayer):
 31    """Title card over the live world. Space, Enter or a tap begins play.
 32
 33    The card sits in the lower third so the world it is introducing, and the
 34    heart bar above it, stay visible behind it.
 35    """
 36
 37    # The prompt blinks off a timer, which is not Property state.
 38    dynamic = True
 39
 40    start_requested = Signal()
 41
 42    def __init__(self, **kwargs):
 43        super().__init__(name="StartScreen", layer=CanvasLayer.Band.OVERLAY, **kwargs)
 44        self._blink = 0.0
 45
 46    def on_update(self, dt: float):
 47        self._blink += dt
 48        if Input.is_action_just_pressed("start") or Input.is_mouse_button_just_pressed(MouseButton.LEFT):
 49            self.start_requested.emit()
 50
 51    def on_draw(self, renderer):
 52        sw, sh = self.tree.screen_size if self.tree else (1280.0, 720.0)
 53        card_h = sh * 0.34
 54        card_y = sh - card_h - sh * 0.06
 55        renderer.draw_rect((0, card_y), (sw, card_h), colour=COLOUR_UI_BG, filled=True)
 56
 57        title_scale = renderer.fit_scale(TITLE, sw * 0.8, base_scale=5.0)
 58        self._centred(renderer, TITLE, sw, card_y + card_h * 0.10, title_scale, COLOUR_WHITE)
 59        sub_scale = renderer.fit_scale(SUBTITLE, sw * 0.8, base_scale=1.8)
 60        self._centred(renderer, SUBTITLE, sw, card_y + card_h * 0.42, sub_scale, COLOUR_UI_TEXT_DIM)
 61
 62        if int(self._blink * 2) % 2 == 0:
 63            prompt_scale = renderer.fit_scale(START_PROMPT, sw * 0.8, base_scale=2.4)
 64            self._centred(renderer, START_PROMPT, sw, card_y + card_h * 0.68, prompt_scale, COLOUR_UI_TEXT)
 65
 66    @staticmethod
 67    def _centred(renderer, text: str, sw: float, y: float, scale: float, colour):
 68        renderer.draw_text(text, ((sw - renderer.text_width(text, scale)) / 2, y), colour=colour, scale=scale)
 69
 70
 71class TouchControls(CanvasLayer):
 72    """Floating steering stick on the left, ATK and ROLL buttons on the right."""
 73
 74    # The knob follows the pointer and the buttons light up while held.
 75    dynamic = True
 76
 77    attack_pressed = Signal()
 78    roll_pressed = Signal()
 79
 80    def __init__(self, **kwargs):
 81        super().__init__(name="TouchControls", layer=CanvasLayer.Band.UI + 1, **kwargs)
 82        #: Unit-clamped steering vector, zero while nothing holds the stick.
 83        self.direction = Vec2(0.0, 0.0)
 84        self._stick_origin: Vec2 | None = None
 85        self._knob = Vec2(0.0, 0.0)
 86        self._held_button: str | None = None
 87
 88    # ── Layout ───────────────────────────────────────────────────────────────
 89
 90    def _screen_size(self) -> tuple[float, float]:
 91        return self.tree.screen_size if self.tree else (1280.0, 720.0)
 92
 93    def _buttons(self, sw: float, sh: float) -> list[tuple[str, float, float, float, str]]:
 94        """``(action, centre x, centre y, radius, label)``, bottom-right cluster."""
 95        return [
 96            ("attack", sw - 104, sh - 150, 48, "ATK"),
 97            ("roll", sw - 196, sh - 96, 34, "ROLL"),
 98        ]
 99
100    def _stick_home(self, sh: float) -> Vec2:
101        """Where the stick rests when nothing is holding it."""
102        return Vec2(116.0, sh - 150.0)
103
104    @staticmethod
105    def _is_stick_zone(pos, sw: float, sh: float) -> bool:
106        """The stick floats: a press anywhere in the lower left grabs it."""
107        return pos.x < sw * 0.5 and pos.y > sh * 0.4
108
109    # ── Per-frame ────────────────────────────────────────────────────────────
110
111    def on_update(self, dt: float):
112        sw, sh = self._screen_size()
113        pos = Input.mouse_position
114        # The press edge comes from Input rather than a held-state diff, so a
115        # tap that starts and ends inside one frame still registers.
116        if Input.is_mouse_button_just_pressed(MouseButton.LEFT):
117            self._on_press(pos, sw, sh)
118        elif not Input.is_mouse_button_pressed(MouseButton.LEFT):
119            self._release()
120
121        if self._stick_origin is not None:
122            self._track_stick(pos)
123
124    def _on_press(self, pos, sw: float, sh: float):
125        for action, cx, cy, radius, _label in self._buttons(sw, sh):
126            if (pos.x - cx) ** 2 + (pos.y - cy) ** 2 <= radius * radius:
127                self._held_button = action
128                (self.attack_pressed if action == "attack" else self.roll_pressed).emit()
129                return
130        if self._is_stick_zone(pos, sw, sh):
131            self._stick_origin = Vec2(pos.x, pos.y)
132            self._knob = Vec2(pos.x, pos.y)
133
134    def _track_stick(self, pos):
135        offset = Vec2(pos.x - self._stick_origin[0], pos.y - self._stick_origin[1])
136        travel = offset.length()
137        if travel <= STICK_DEADZONE:
138            self.direction = Vec2(0.0, 0.0)
139            self._knob = Vec2(self._stick_origin)
140            return
141        unit = offset.normalized()
142        self.direction = unit * (min(travel, STICK_RADIUS) / STICK_RADIUS)
143        self._knob = self._stick_origin + unit * min(travel, STICK_RADIUS)
144
145    def _release(self):
146        self._stick_origin = None
147        self._held_button = None
148        self.direction = Vec2(0.0, 0.0)
149
150    # ── Drawing ──────────────────────────────────────────────────────────────
151
152    def on_draw(self, renderer):
153        sw, sh = self._screen_size()
154        origin = self._stick_origin if self._stick_origin is not None else self._stick_home(sh)
155        knob = self._knob if self._stick_origin is not None else origin
156
157        renderer.draw_circle((origin[0], origin[1]), STICK_RADIUS, colour=RING_COLOUR, filled=True)
158        renderer.draw_circle((knob[0], knob[1]), 24, colour=KNOB_COLOUR, filled=True)
159
160        for action, cx, cy, radius, label in self._buttons(sw, sh):
161            held = action == self._held_button
162            renderer.draw_circle((cx, cy), radius, colour=BUTTON_COLOUR_HELD if held else BUTTON_COLOUR, filled=True)
163            renderer.draw_circle((cx, cy), radius, colour=RING_COLOUR, filled=False)
164            scale = 1.6 if radius >= 40 else 1.2
165            renderer.draw_text(
166                label,
167                (cx - renderer.text_width(label, scale) / 2, cy - renderer.text_height(label, scale) / 2),
168                colour=COLOUR_UI_TEXT,
169                scale=scale,
170            )