nodes/hud.pyΒΆ

Part of HeartBeast Action RPG.

 1"""HUD: heart health bar plus the controls strip along the top edge.
 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
17
18#: One-line reminder along the top edge, with the hearts below it. It runs
19#: along the top because the on-screen controller owns the bottom corners.
20CONTROLS_TEXT = "WASD/ARROWS or STICK move   J, Z or A attack   K, X or B roll   ESC quit   |   touch: the drawn pad"
21STRIP_HEIGHT = 30
22
23#: Where the leftmost heart sits, clear of the strip above it.
24HEART_MARGIN = (24, STRIP_HEIGHT + 16)
25
26
27class HUD(CanvasLayer):
28    """Heart health bar under a one-line controls strip."""
29
30    def __init__(self, max_hearts: int = 6, **kwargs):
31        super().__init__(name="HUD", layer=CanvasLayer.Band.UI, **kwargs)
32        self._max_hearts = max_hearts
33        self._current_hearts = max_hearts
34        self._viewport: tuple[float, float] = (0.0, 0.0)
35
36    @property
37    def hearts(self) -> int:
38        """Number of filled hearts currently shown."""
39        return self._current_hearts
40
41    def on_update(self, dt: float):
42        # The strip is laid out from the window size, which the web export can
43        # change at any time: re-capture the drawing when it does.
44        width, height = self.viewport_size
45        size = (float(width), float(height))
46        if size != self._viewport:
47            self._viewport = size
48            self.queue_redraw()
49
50    def on_draw(self, renderer):
51        sw, sh = self.viewport_size
52        x0, y0 = HEART_MARGIN
53        for i in range(self._max_hearts):
54            self._draw_heart(renderer, x0 + i * HEART_SPACING, y0, HEART_SIZE, i < self._current_hearts)
55
56        renderer.rect((0, 0), (sw, STRIP_HEIGHT), fill=COLOUR_UI_BG)
57        scale = renderer.fit_scale(CONTROLS_TEXT, sw - 24, base_scale=1.5)
58        text_y = (STRIP_HEIGHT - renderer.text_height(CONTROLS_TEXT, scale)) / 2
59        renderer.text(CONTROLS_TEXT, (12, text_y), fill=COLOUR_UI_TEXT, scale=scale)
60
61    def _draw_heart(self, renderer, x: float, y: float, size: float, full: bool):
62        """Draw one heart as a filled polygon: two lobes over a point."""
63        colour = COLOUR_HEART_FULL if full else COLOUR_HEART_EMPTY
64        s = size / 2.0
65        cx, cy = x + s, y + s
66        points = [
67            (cx - s, cy - s * 0.4),
68            (cx - s, cy + s * 0.2),
69            (cx, cy + s),
70            (cx + s, cy + s * 0.2),
71            (cx + s, cy - s * 0.4),
72            (cx + s * 0.5, cy - s * 0.9),
73            (cx, cy - s * 0.4),
74            (cx - s * 0.5, cy - s * 0.9),
75        ]
76        renderer.polygon(points, fill=colour)
77
78    def set_health(self, health: int):
79        """Show ``health`` filled hearts.
80
81        ``on_draw`` reads plain attributes rather than Properties, so the
82        retained 2D cache is told by hand that the output changed. The hearts
83        only move on damage, which is why this is a ``queue_redraw`` rather
84        than a per-frame ``dynamic``.
85        """
86        if health == self._current_hearts:
87            return
88        self._current_hearts = health
89        self.queue_redraw()