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)