nodes/hud.pyΒΆ
Part of Mr. Rescue.
1"""HUD: top status strip + bottom water/heat/casualty bar.
2
3Lives inside a CanvasLayer so it renders in screen-space, above the gameplay
4camera. Draw2D submission order is GPU draw order, so the HUD (submitted last)
5sits above the world without any explicit layering call.
6
7The layout is fully resolution-independent: every frame it reads the live
8framebuffer size from ``self.tree.screen_size`` rather than a baked-in window
9size, so it hugs the real screen edges on a resized desktop window or a
10responsive web canvas. ``HUD_H`` is the reserved bottom strip the gameplay
11camera letterboxes above (see ``GameScene``), guaranteeing the player can never
12be drawn behind the bar.
13"""
14
15from __future__ import annotations
16
17from simvx.core import Node
18
19from . import colours as C
20
21# Text metrics drive the strip heights: a glyph is 16 px per unit of scale, so a
22# strip has to reserve a whole line plus its padding or the labels get sliced off
23# at the screen edge.
24TEXT_SCALE = 2
25LINE_H = 16 * TEXT_SCALE
26PAD = 16 # horizontal margin
27GUTTER = 6 # vertical padding above/below a line of text
28
29# Reserved screen-space strips (shared with GameScene's camera letterbox).
30BAR_H = 20 # water / heat gauge height
31HUD_H = GUTTER + LINE_H + GUTTER + BAR_H + GUTTER # bottom bar: label row + gauges
32TOP_H = GUTTER + LINE_H + GUTTER # top status strip: one line
33
34
35class HUD(Node):
36 # ``on_draw`` redraws the water/heat bars from plain (non-Property) state that
37 # ``set_state`` rewrites EVERY frame, plus per-frame OVERLOAD/dying blink driven
38 # by the non-Property ``_blink_t``. It genuinely changes every frame, so declare
39 # it dynamic (a ``queue_redraw`` at the set_state site would be equivalent since
40 # set_state is itself called every frame). This previously relied on the gameplay
41 # camera scroll re-running every on_draw; under view-decoupling it must opt in.
42 dynamic = True
43
44 def __init__(self, **kwargs):
45 super().__init__(**kwargs)
46 # Live state set by gameplay each frame.
47 self.water = 0.0
48 self.water_max = 5.0
49 self.overloaded = False
50 self.temperature = 0.0
51 self.max_temperature = 1.0
52 self.casualties = 0
53 self.max_casualties = 5
54 self.civilians_remaining = 0
55 self.civilians_total = 0
56 self.fires_remaining = 0
57 self.section = 1
58 self.score = 0
59 self.is_dying = False
60 self.heat_flash = 0.0
61 self._blink_t = 0.0
62
63 def set_state(
64 self,
65 *,
66 water,
67 water_max,
68 overloaded,
69 temperature,
70 max_temperature,
71 casualties,
72 max_casualties,
73 civilians_remaining,
74 civilians_total,
75 fires_remaining,
76 section,
77 score,
78 is_dying,
79 heat_flash,
80 ):
81 self.water = water
82 self.water_max = water_max
83 self.overloaded = overloaded
84 self.temperature = temperature
85 self.max_temperature = max_temperature
86 self.casualties = casualties
87 self.max_casualties = max_casualties
88 self.civilians_remaining = civilians_remaining
89 self.civilians_total = civilians_total
90 self.fires_remaining = fires_remaining
91 self.section = section
92 self.score = score
93 self.is_dying = is_dying
94 self.heat_flash = heat_flash
95
96 def on_update(self, dt):
97 self._blink_t += dt
98
99 @property
100 def rescued(self) -> int:
101 return max(0, self.civilians_total - self.casualties - self.civilians_remaining)
102
103 def on_draw(self, renderer):
104 # Live framebuffer size -> the bars track any window/canvas size.
105 w, h = (int(self.tree.screen_size[0]), int(self.tree.screen_size[1])) if self.tree else (1024, 800)
106
107 # Heat wash: painted here rather than in the gameplay scene so it really
108 # covers the framebuffer instead of a camera-sized patch of the world.
109 if self.heat_flash > 0.05:
110 renderer.draw_rect((0, 0), (w, h), filled=True, colour=(1.0, 0.15, 0.05, min(0.45, self.heat_flash * 0.45)))
111 self._draw_bottom_bar(renderer, w, h)
112 self._draw_top_bar(renderer, w)
113
114 # ----------------------------------------------------------- bottom bar
115
116 def _draw_bottom_bar(self, renderer, w: int, h: int):
117 top = h - HUD_H
118 renderer.draw_rect((0, top), (w, HUD_H), colour=C.HUD_BG, filled=True)
119 renderer.draw_rect((0, top), (w, 2), colour=C.HUD_BORDER, filled=True)
120
121 # Two rows inside the strip: labels, then the gauges under them. Both fit
122 # whole, with a gutter left below the gauges.
123 label_y = top + GUTTER
124 by = label_y + LINE_H + GUTTER
125
126 # Three columns (water gauge, heat gauge, casualty pips) share the width,
127 # so the strip stays inside a narrow canvas as well as a desktop window.
128 gap = 40
129 pips_w = self.max_casualties * 18
130 bw = max(120.0, min(220.0, (w - 2 * PAD - 2 * gap - pips_w) / 2))
131
132 # Water gauge: left
133 bx = PAD
134 renderer.draw_rect((bx - 2, by - 2), (bw + 4, BAR_H + 4), colour=C.HUD_BORDER, filled=True)
135 renderer.draw_rect((bx, by), (bw, BAR_H), colour=C.HUD_FG, filled=True)
136 ratio = max(0.0, min(1.0, self.water / max(0.001, self.water_max)))
137 fill_col = C.OVERLOADED_BAR if self.overloaded else C.WATER_BAR
138 renderer.draw_rect((bx, by), (int(bw * ratio), BAR_H), colour=fill_col, filled=True)
139 renderer.draw_text("WATER", (bx, label_y), colour=C.HUD_FG, scale=TEXT_SCALE)
140 if self.overloaded and int(self._blink_t * 5) % 2 == 0:
141 # Flash the OVERLOAD warning so the empty-tank state reads clearly.
142 # Smaller than the label and pinned to the far end of the gauge, so it
143 # shares the row with "WATER" instead of running through it.
144 warn_scale = TEXT_SCALE * 0.75
145 warn_w = renderer.text_width("OVERLOAD", warn_scale)
146 renderer.draw_text("OVERLOAD", (bx + bw - warn_w, label_y + 4), colour=C.RED, scale=warn_scale)
147
148 # Heat gauge: middle
149 tbx = bx + bw + gap
150 renderer.draw_rect((tbx - 2, by - 2), (bw + 4, BAR_H + 4), colour=C.HUD_BORDER, filled=True)
151 renderer.draw_rect((tbx, by), (bw, BAR_H), colour=C.HUD_FG, filled=True)
152 tratio = max(0.0, min(1.0, self.temperature / max(0.001, self.max_temperature)))
153 col = (
154 C.TEMP_LOW[0] + (C.TEMP_HIGH[0] - C.TEMP_LOW[0]) * tratio,
155 C.TEMP_LOW[1] + (C.TEMP_HIGH[1] - C.TEMP_LOW[1]) * tratio,
156 C.TEMP_LOW[2] + (C.TEMP_HIGH[2] - C.TEMP_LOW[2]) * tratio,
157 1.0,
158 )
159 renderer.draw_rect((tbx, by), (int(bw * tratio), BAR_H), colour=col, filled=True)
160 if self.is_dying and int(self._blink_t * 4) % 2 == 0:
161 renderer.draw_rect((tbx, by), (bw, BAR_H), colour=(1, 1, 1, 0.4), filled=True)
162 renderer.draw_text("HEAT", (tbx, label_y), colour=C.HUD_FG, scale=TEXT_SCALE)
163
164 # Casualty pips: right of heat, with a label so they read as lives lost.
165 px = tbx + bw + gap
166 py = by + 3
167 renderer.draw_text("LOST", (px, label_y), colour=C.HUD_DIM, scale=TEXT_SCALE)
168 for i in range(self.max_casualties):
169 col = C.CASUALTY_LOST if i < self.casualties else C.CASUALTY_OK
170 renderer.draw_rect((px + i * 18, py), (14, 14), colour=col, filled=True)
171 renderer.draw_rect((px + i * 18, py), (14, 14), colour=C.HUD_BORDER, filled=False, thickness=1)
172
173 # ----------------------------------------------------------- top bar
174
175 def _draw_top_bar(self, renderer, w: int):
176 # Backing panel so the score/section/civilian text always has contrast
177 # over bright fire pixels (mirrors the bottom strip). One line of text,
178 # run status on the left and rescue status on the right.
179 renderer.draw_rect((0, 0), (w, TOP_H), colour=C.HUD_BG, filled=True)
180 renderer.draw_rect((0, TOP_H - 2), (w, 2), colour=C.HUD_BORDER, filled=True)
181
182 y = GUTTER
183 section = f"SECTION {self.section}"
184 renderer.draw_text(section, (PAD, y), colour=C.HUD_FG, scale=TEXT_SCALE)
185 renderer.draw_text(
186 f"SCORE {self.score}",
187 (PAD + renderer.text_width(section + " ", TEXT_SCALE), y),
188 colour=C.YELLOW,
189 scale=TEXT_SCALE,
190 )
191
192 fires = f"FIRES {self.fires_remaining}"
193 fires_x = w - PAD - renderer.text_width(fires, TEXT_SCALE)
194 renderer.draw_text(fires, (fires_x, y), scale=TEXT_SCALE, colour=C.RED if self.fires_remaining > 0 else C.GREEN)
195 rescued = f"RESCUED {self.rescued}/{self.civilians_total}"
196 renderer.draw_text(
197 rescued, (fires_x - renderer.text_width(rescued + " ", TEXT_SCALE), y), colour=C.HUD_FG, scale=TEXT_SCALE
198 )