nodes/hud.py¶
Part of PirateMaker.
1"""Screen-space UI: title menu, bottom controls strip, on-screen movement pad.
2
3Everything here lives on a `CanvasLayer`, so it stays pinned to the window while
4the editor pans its canvas and while `Camera2D` follows the player in play mode.
5Buttons are plain rectangles drawn in `on_draw` and hit-tested against
6`Input.mouse_position`, which makes them work identically under mouse and touch
7(a browser touch reports as `Mouse.LEFT`).
8
9Two button behaviours:
10
11- ``click`` buttons emit :attr:`ButtonLayer.button_pressed` on release inside
12 the rectangle: menu entries, Save, Load, Play.
13- ``hold`` buttons report through :meth:`ButtonLayer.is_held` while the pointer
14 is down on them: the movement pad the platformer reads instead of the keyboard.
15"""
16
17from __future__ import annotations
18
19from simvx.core import CanvasLayer, Input, Mouse, Signal, Text2D, Vec2
20
21STRIP_HEIGHT = 88
22BUTTON_TOP = 8
23BUTTON_H = 46
24BUTTON_GAP = 12
25HINT_TOP = BUTTON_TOP + BUTTON_H + 8
26
27STRIP_BG = (0.13, 0.12, 0.15, 1.0)
28STRIP_EDGE = (0.96, 0.95, 0.87, 0.55)
29BUTTON_BG = (0.29, 0.30, 0.36, 1.0)
30BUTTON_HOVER = (0.40, 0.44, 0.53, 1.0)
31BUTTON_DOWN = (0.61, 0.66, 0.45, 1.0)
32BUTTON_EDGE = (0.96, 0.95, 0.87, 1.0)
33LABEL_COLOUR = (0.96, 0.95, 0.87, 1.0)
34HINT_COLOUR = (0.78, 0.77, 0.70, 1.0)
35
36
37class Button:
38 """One rectangular UI button, laid out by its owning layer each frame."""
39
40 def __init__(self, label: str, action: str, *, width: float = 118.0, hold: bool = False):
41 self.label = label
42 self.action = action
43 self.w = width
44 self.h = BUTTON_H
45 self.hold = hold
46 self.x = 0.0
47 self.y = 0.0
48 self.hovered = False
49 self.down = False
50 #: Latched state for buttons that stand for a mode rather than an event.
51 self.active = False
52
53 def contains(self, point: Vec2) -> bool:
54 return self.x <= point.x <= self.x + self.w and self.y <= point.y <= self.y + self.h
55
56
57class ButtonLayer(CanvasLayer):
58 """Shared button behaviour: layout hook, hit-testing, drawing, labels.
59
60 Subclasses fill ``self.buttons`` and implement :meth:`layout` (called
61 whenever the window size changes, and once on ready).
62 """
63
64 button_pressed = Signal() # (action: str)
65
66 def __init__(self, **kwargs):
67 super().__init__(**kwargs)
68 self.buttons: list[Button] = []
69 self._labels: list[Text2D] = []
70 self._press_target: Button | None = None
71 self._view = Vec2(0, 0)
72
73 # ------------------------------------------------------------------ setup
74
75 def on_ready(self) -> None:
76 for button in self.buttons:
77 self._labels.append(
78 self.add_child(
79 Text2D(
80 text=button.label,
81 font_scale=0.85,
82 align="centre",
83 colour=LABEL_COLOUR,
84 )
85 )
86 )
87 self._relayout(self.viewport_size)
88
89 def layout(self, view: Vec2) -> None:
90 """Place ``self.buttons`` for a window of size ``view``."""
91 raise NotImplementedError
92
93 def _relayout(self, view: Vec2) -> None:
94 self._view = view
95 self.layout(view)
96 for button, label in zip(self.buttons, self._labels, strict=False):
97 # A rect box lets the text builder centre exactly rather than by
98 # guessing a per-character width.
99 label.rect = (button.x, button.y + button.h * 0.5 - 9, button.w, 20)
100 self.queue_redraw()
101
102 # --------------------------------------------------------------- per-frame
103
104 def on_update(self, dt: float) -> None:
105 # Drawing is gated by visibility but updating is not, and a hidden strip
106 # must not keep swallowing clicks.
107 if not self.visible:
108 return
109 view = self.viewport_size
110 if (view.x, view.y) != (self._view.x, self._view.y):
111 self._relayout(view)
112
113 point = Input.mouse_position
114 if Input.is_mouse_button_just_pressed(Mouse.LEFT):
115 self._press_target = next((b for b in self.buttons if b.contains(point)), None)
116
117 # on_draw paints each button from these plain (non-Property) flags, so a
118 # change has to dirty the layer or the highlight freezes under retained 2D.
119 before = self._button_states()
120 for button in self.buttons:
121 button.hovered = button.contains(point)
122 button.down = button.hovered and self._press_target is button
123 if before != self._button_states():
124 self.queue_redraw()
125
126 if Input.is_mouse_button_just_released(Mouse.LEFT):
127 target = self._press_target
128 self._press_target = None
129 if target is not None and not target.hold and target.contains(point):
130 self.button_pressed.emit(target.action)
131
132 def _button_states(self) -> list[tuple[bool, bool, bool]]:
133 return [(b.hovered, b.down, b.active) for b in self.buttons]
134
135 def is_held(self, action: str) -> bool:
136 """Whether a ``hold`` button for ``action`` currently has the pointer down."""
137 return any(b.action == action and b.down for b in self.buttons)
138
139 def set_active(self, active_action: str | None) -> None:
140 """Latch exactly one mode button on, or none of them."""
141 for button in self.buttons:
142 button.active = button.action == active_action
143 self.queue_redraw()
144
145 # -------------------------------------------------------------------- draw
146
147 def draw_buttons(self, renderer) -> None:
148 for button in self.buttons:
149 if button.down or button.active:
150 fill = BUTTON_DOWN
151 elif button.hovered:
152 fill = BUTTON_HOVER
153 else:
154 fill = BUTTON_BG
155 renderer.rect((button.x, button.y), (button.w, button.h), fill=fill)
156 renderer.rect((button.x, button.y), (button.w, button.h), stroke=BUTTON_EDGE)
157
158
159class ControlsStrip(ButtonLayer):
160 """Bottom-of-window strip: labelled buttons plus a one-line keyboard hint."""
161
162 def __init__(self, buttons: list[Button], hint: str, **kwargs):
163 super().__init__(**kwargs)
164 self.buttons = buttons
165 self.hint = hint
166 self._hint_label: Text2D | None = None
167
168 def on_ready(self) -> None:
169 super().on_ready()
170 self._hint_label = self.add_child(
171 Text2D(
172 text=self.hint,
173 font_scale=0.72,
174 align="centre",
175 colour=HINT_COLOUR,
176 )
177 )
178 self._relayout(self.viewport_size)
179
180 def layout(self, view: Vec2) -> None:
181 clickable = [b for b in self.buttons if not b.hold]
182 movement = [b for b in self.buttons if b.hold]
183 strip_top = view.y - STRIP_HEIGHT
184
185 # Movement pad hugs the left edge; the rest is centred in what is left.
186 x = 16.0
187 for button in movement:
188 button.x = x
189 button.y = strip_top + BUTTON_TOP
190 x += button.w + BUTTON_GAP
191
192 total = sum(b.w for b in clickable) + BUTTON_GAP * max(0, len(clickable) - 1)
193 left = max(x, (view.x - total) * 0.5)
194 for button in clickable:
195 button.x = left
196 button.y = strip_top + BUTTON_TOP
197 left += button.w + BUTTON_GAP
198
199 if self._hint_label is not None:
200 self._hint_label.rect = (0, strip_top + HINT_TOP, view.x, 18)
201
202 def blocks(self, point: Vec2) -> bool:
203 """Whether the strip owns ``point``, so the world below must ignore it."""
204 return point.y >= self._view.y - STRIP_HEIGHT
205
206 def on_draw(self, renderer) -> None:
207 view = self._view
208 top = view.y - STRIP_HEIGHT
209 renderer.rect((0, top), (view.x, STRIP_HEIGHT), fill=STRIP_BG)
210 renderer.rect((0, top), (view.x, 2), fill=STRIP_EDGE)
211 self.draw_buttons(renderer)
212
213
214class MenuScreen(ButtonLayer):
215 """Full-window title card shown before the editor opens."""
216
217 TITLE = "PirateMaker"
218 BLURB = "Build a pirate level tile by tile, then play it without leaving the app."
219 LINES = (
220 "Editor: paint with the left button, erase with the right, drag the",
221 "pirate and the horizon handle, pan with the middle button or the wheel.",
222 "Play: run, jump, collect the gold and dodge the teeth.",
223 )
224
225 def __init__(self, **kwargs):
226 super().__init__(**kwargs)
227 self.buttons = [
228 Button("Open editor", "start", width=170),
229 Button("Quit", "quit", width=120),
230 ]
231 self._title: Text2D | None = None
232 self._blurb: Text2D | None = None
233 self._lines: list[Text2D] = []
234
235 def on_ready(self) -> None:
236 super().on_ready()
237 self._title = self.add_child(
238 Text2D(
239 text=self.TITLE,
240 font_scale=3.2,
241 align="centre",
242 colour=(0.96, 0.95, 0.87, 1.0),
243 outline=0.08,
244 outline_colour=(0.13, 0.12, 0.15, 1.0),
245 )
246 )
247 self._blurb = self.add_child(
248 Text2D(
249 text=self.BLURB,
250 font_scale=1.0,
251 align="centre",
252 colour=(0.90, 0.88, 0.78, 1.0),
253 )
254 )
255 self._lines = [
256 self.add_child(Text2D(text=line, font_scale=0.85, align="centre", colour=HINT_COLOUR))
257 for line in self.LINES
258 ]
259 self._relayout(self.viewport_size)
260
261 def layout(self, view: Vec2) -> None:
262 cx, cy = view.x * 0.5, view.y * 0.5
263 if self._title is not None:
264 self._title.rect = (0, cy - 190, view.x, 60)
265 if self._blurb is not None:
266 self._blurb.rect = (0, cy - 110, view.x, 24)
267 for index, line in enumerate(self._lines):
268 line.rect = (0, cy - 50 + index * 26, view.x, 22)
269
270 total = sum(b.w for b in self.buttons) + BUTTON_GAP * (len(self.buttons) - 1)
271 left = cx - total * 0.5
272 for button in self.buttons:
273 button.x = left
274 button.y = cy + 70
275 left += button.w + BUTTON_GAP
276
277 def on_draw(self, renderer) -> None:
278 view = self._view
279 renderer.rect((0, 0), (view.x, view.y), fill=(0.09, 0.11, 0.16, 1.0))
280 self.draw_buttons(renderer)
281
282
283def editor_strip() -> ControlsStrip:
284 """The editor's controls strip.
285
286 ``Erase`` and ``Pan`` latch what the left button does, which is what makes
287 the editor usable with one finger: a touch screen has no right or middle
288 button and no wheel.
289 """
290 return ControlsStrip(
291 buttons=[
292 Button("Play level", "play", width=132),
293 Button("Erase", "tool_erase", width=100),
294 Button("Pan", "tool_pan", width=90),
295 Button("Save", "save", width=90),
296 Button("Load", "load", width=90),
297 Button("Palm fg/bg", "palm_layer", width=136),
298 Button("Quit", "quit", width=90),
299 ],
300 hint="Left button paints - right button erases and cycles the palette - "
301 "middle button or wheel pans - [ and ] step through tiles",
302 layer=CanvasLayer.Band.UI,
303 )
304
305
306def play_strip() -> ControlsStrip:
307 """The play mode's controls strip, including the on-screen movement pad."""
308 return ControlsStrip(
309 buttons=[
310 Button("<", "move_left", width=76, hold=True),
311 Button(">", "move_right", width=76, hold=True),
312 Button("Jump", "jump", width=110, hold=True),
313 Button("Back to editor", "editor", width=180),
314 ],
315 hint="Left/Right or A/D to move - Space to jump - Esc back to the editor",
316 layer=CanvasLayer.Band.UI,
317 )
318
319
320__all__ = [
321 "STRIP_HEIGHT",
322 "Button",
323 "ButtonLayer",
324 "ControlsStrip",
325 "MenuScreen",
326 "editor_strip",
327 "play_strip",
328]