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 )