nodes/hud.pyΒΆ

Part of HexGL.

  1"""Speedometer / shield / boost / lap timer HUD.
  2
  3Mirrors upstream ``HUD.js`` content (speed, shield bar, lap, time, boost
  4indicator): the readouts are :class:`~simvx.core.Text2D` labels, the gauges
  5are ``renderer.draw_rect`` fills from ``on_draw``. Both draw in screen space
  6over the 3D pass, and every coordinate is derived from ``tree.screen_size``
  7each frame, so the HUD re-lays itself out when the window resizes.
  8"""
  9
 10from __future__ import annotations
 11
 12from simvx.core import Node, Text2D
 13
 14from .race import RaceManager
 15from .ship import Ship
 16
 17CONTROLS_STRIP = "W/UP or LMB thrust    A/D or pointer steer    S/RMB brake    Q/E air-brake    R restart    ESC quit"
 18
 19MARGIN = 24.0
 20STRIP_HEIGHT = 36.0
 21
 22
 23def _format_time(seconds: float) -> str:
 24    if seconds < 0:
 25        seconds = 0.0
 26    m = int(seconds // 60)
 27    s = seconds - m * 60
 28    return f"{m:01d}:{s:06.3f}"
 29
 30
 31class HexHUD(Node):
 32    """Procedurally drawn HUD."""
 33
 34    dynamic = True  # boost/shield/speed bars fill continuously during a race
 35
 36    def __init__(self, ship: Ship, race: RaceManager, **kwargs) -> None:
 37        super().__init__(**kwargs)
 38        self.ship = ship
 39        self.race = race
 40        # Labels are laid out from the window size every frame (see on_update),
 41        # so they are built without positions here.
 42        self._speed_text = Text2D(text="", font_scale=4.0)
 43        self._lap_text = Text2D(text="", font_scale=2.2)
 44        self._time_text = Text2D(text="", font_scale=2.2, align="right")
 45        self._best_text = Text2D(text="", font_scale=1.4, align="right")
 46        self._message_text = Text2D(text="", font_scale=2.6, align="centre")
 47        self._countdown_text = Text2D(text="", font_scale=8.0, align="centre")
 48
 49    def on_ready(self) -> None:
 50        for label in (
 51            self._speed_text,
 52            self._lap_text,
 53            self._time_text,
 54            self._best_text,
 55            self._message_text,
 56            self._countdown_text,
 57        ):
 58            self.add_child(label)
 59
 60    def _screen_size(self) -> tuple[float, float]:
 61        if self.tree is not None:
 62            w, h = self.tree.screen_size
 63            return float(w), float(h)
 64        return 1280.0, 720.0
 65
 66    def on_update(self, dt: float) -> None:
 67        w, h = self._screen_size()
 68        idle = self.race.phase == "idle"
 69
 70        # Layout: everything hangs off an edge, so any window size works.
 71        self._lap_text.position = (MARGIN, MARGIN)
 72        self._time_text.position = (w - MARGIN, MARGIN)
 73        self._best_text.position = (w - MARGIN, MARGIN + 36.0)
 74        self._speed_text.position = (MARGIN, h - STRIP_HEIGHT - 118.0)
 75        self._message_text.position = (w * 0.5, h * 0.28)
 76        self._countdown_text.position = (w * 0.5, h * 0.36)
 77
 78        # Real speed is upstream's getRealSpeed: (speed + boost) scaled. We display km/h-ish.
 79        speed = (self.ship.speed + self.ship.boost) * 4.5  # cosmetic km/h scaling
 80        self._speed_text.text = "" if idle else f"{int(speed):3d} KM/H"
 81        self._lap_text.text = "" if idle else f"LAP {self.race.lap}/{self.race.max_laps}"
 82        self._time_text.text = "" if idle else _format_time(self.race.elapsed)
 83        best = self.race.best_lap
 84        if idle:
 85            self._best_text.text = ""
 86        else:
 87            self._best_text.text = f"BEST {_format_time(best)}" if best else "BEST --:--"
 88
 89        # Start lights, then a short GO! banner.
 90        if self.race.countdown > 0.0:
 91            self._countdown_text.text = str(int(self.race.countdown) + 1)
 92        elif self.race.go_banner > 0.0:
 93            self._countdown_text.text = "GO!"
 94        else:
 95            self._countdown_text.text = ""
 96
 97        # End-of-race banner. Cleared as soon as a new race is armed.
 98        if self.race.phase == "finished":
 99            if self.race.dnf:
100                self._message_text.text = "DESTROYED  -  press R to restart"
101            else:
102                total = sum(self.race.lap_times)
103                self._message_text.text = f"FINISH! Total {_format_time(total)}  -  press R to restart"
104        else:
105            self._message_text.text = ""
106
107    # ------------------------------------------------------------------
108    # Procedural gauges
109    # ------------------------------------------------------------------
110
111    def on_draw(self, renderer) -> None:
112        if self.race.phase == "idle":
113            return  # the title screen owns the frame
114        w, h = self._screen_size()
115
116        # Boost gauge: lower-right.
117        boost_ratio = min(1.0, self.ship.boost / max(0.001, self.ship.booster_speed))
118        self._draw_bar(
119            renderer,
120            x=w - MARGIN - 200.0,
121            y=h - STRIP_HEIGHT - 48.0,
122            w=200.0,
123            h=22.0,
124            ratio=boost_ratio,
125            fg=(1.0, 0.55, 0.18, 1.0),
126            bg=(0.10, 0.06, 0.04, 0.7),
127        )
128        # Shield gauge: lower-left, under the speedometer.
129        shield_ratio = max(0.0, min(1.0, self.ship.shield))
130        self._draw_bar(
131            renderer,
132            x=MARGIN,
133            y=h - STRIP_HEIGHT - 48.0,
134            w=240.0,
135            h=18.0,
136            ratio=shield_ratio,
137            fg=(0.30, 0.85, 1.0, 1.0),
138            bg=(0.04, 0.10, 0.14, 0.7),
139        )
140        # Speed gauge: under the shield.
141        speed_ratio = max(0.0, min(1.0, self.ship.speed_ratio))
142        self._draw_bar(
143            renderer,
144            x=MARGIN,
145            y=h - STRIP_HEIGHT - 24.0,
146            w=240.0,
147            h=14.0,
148            ratio=speed_ratio,
149            fg=(0.85, 0.95, 1.0, 1.0),
150            bg=(0.04, 0.10, 0.14, 0.7),
151        )
152
153        # Controls strip along the bottom edge.
154        renderer.draw_rect((0.0, h - STRIP_HEIGHT), (w, STRIP_HEIGHT), colour=(0.03, 0.05, 0.09, 0.94), filled=True)
155        renderer.draw_text(
156            CONTROLS_STRIP,
157            rect=(0.0, h - STRIP_HEIGHT, w, STRIP_HEIGHT),
158            alignment="centre",
159            vertical_alignment="centre",
160            fit_to_width=True,
161            scale=1.1,
162            colour=(0.66, 0.76, 0.90, 1.0),
163        )
164
165    @staticmethod
166    def _draw_bar(renderer, *, x, y, w, h, ratio, fg, bg) -> None:
167        """Filled bar with a darker background: both via draw_rect."""
168        renderer.draw_rect((x, y), (w, h), colour=bg, filled=True)
169        if ratio > 0.0:
170            renderer.draw_rect((x, y), (w * ratio, h), colour=fg, filled=True)