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