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]