nodes/hud.pyΒΆ

Part of HeartBeast Action RPG.

 1"""HUD: heart health bar plus the on-screen controls strip.
 2
 3Mirrors the upstream HeartBeast health UI: a row of full/empty hearts fed by
 4the player's Stats signals. It is a CanvasLayer, so every coordinate here is
 5screen space and the world camera never moves it.
 6"""
 7
 8from __future__ import annotations
 9
10from settings import COLOUR_HEART_EMPTY, COLOUR_HEART_FULL, COLOUR_UI_BG, COLOUR_UI_TEXT
11
12from simvx.core import CanvasLayer
13
14#: Width and height of one heart, in screen pixels.
15HEART_SIZE = 32
16HEART_SPACING = HEART_SIZE + 6
17HEART_MARGIN = (24, 24)
18
19#: One-line reminder along the bottom edge, the port UX baseline.
20CONTROLS_TEXT = "WASD/ARROWS move   J or Z attack   K or X roll   ESC quit   |   drag to walk, tap ATK / ROLL"
21STRIP_HEIGHT = 30
22
23
24class HUD(CanvasLayer):
25    """Heart health bar and the bottom controls strip."""
26
27    def __init__(self, max_hearts: int = 6, **kwargs):
28        super().__init__(name="HUD", layer=CanvasLayer.Band.UI, **kwargs)
29        self._max_hearts = max_hearts
30        self._current_hearts = max_hearts
31        self._screen: tuple[float, float] = (0.0, 0.0)
32
33    @property
34    def hearts(self) -> int:
35        """Number of filled hearts currently shown."""
36        return self._current_hearts
37
38    def on_update(self, dt: float):
39        # The strip is laid out from the window size, which the web export can
40        # change at any time: re-capture the drawing when it does.
41        size = self.tree.screen_size if self.tree else (1280.0, 720.0)
42        if size != self._screen:
43            self._screen = size
44            self.queue_redraw()
45
46    def on_draw(self, renderer):
47        sw, sh = self.tree.screen_size if self.tree else (1280.0, 720.0)
48        x0, y0 = HEART_MARGIN
49        for i in range(self._max_hearts):
50            self._draw_heart(renderer, x0 + i * HEART_SPACING, y0, HEART_SIZE, i < self._current_hearts)
51
52        renderer.draw_rect((0, sh - STRIP_HEIGHT), (sw, STRIP_HEIGHT), colour=COLOUR_UI_BG, filled=True)
53        scale = renderer.fit_scale(CONTROLS_TEXT, sw - 24, base_scale=1.5)
54        text_y = sh - STRIP_HEIGHT + (STRIP_HEIGHT - renderer.text_height(CONTROLS_TEXT, scale)) / 2
55        renderer.draw_text(CONTROLS_TEXT, (12, text_y), colour=COLOUR_UI_TEXT, scale=scale)
56
57    def _draw_heart(self, renderer, x: float, y: float, size: float, full: bool):
58        """Draw one heart as a filled polygon: two lobes over a point."""
59        colour = COLOUR_HEART_FULL if full else COLOUR_HEART_EMPTY
60        s = size / 2.0
61        cx, cy = x + s, y + s
62        points = [
63            (cx - s, cy - s * 0.4),
64            (cx - s, cy + s * 0.2),
65            (cx, cy + s),
66            (cx + s, cy + s * 0.2),
67            (cx + s, cy - s * 0.4),
68            (cx + s * 0.5, cy - s * 0.9),
69            (cx, cy - s * 0.4),
70            (cx - s * 0.5, cy - s * 0.9),
71        ]
72        renderer.draw_polygon(points, colour=colour)
73
74    def set_health(self, health: int):
75        """Show ``health`` filled hearts.
76
77        ``on_draw`` reads plain attributes rather than Properties, so the
78        retained 2D cache is told by hand that the output changed. The hearts
79        only move on damage, which is why this is a ``queue_redraw`` rather
80        than a per-frame ``dynamic``.
81        """
82        if health == self._current_hearts:
83            return
84        self._current_hearts = health
85        self.queue_redraw()