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        )