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()