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]