nodes/race.py¶
Part of HexGL.
1"""Race manager: checkpoints, lap tracking, end-of-race state.
2
3Watches the player's track parameter ``t`` and ticks lap progress. Mirrors
4upstream ``Gameplay.js`` time-attack mode: 3 laps, all 6 checkpoints required
5in order. Crashed/destroyed ship counts as DNF.
6
7The race walks four phases, readable from :attr:`phase`:
8
9``idle``
10 Sitting on the title screen. Nothing ticks until :meth:`start`.
11``countdown``
12 Start lights: :attr:`countdown` runs 3 -> 0, then the GO! banner
13 (:attr:`go_banner`) shows for a moment.
14``racing``
15 Lap timing, checkpoint crossings and lap completion.
16``finished``
17 Three laps done, or the ship was destroyed (:attr:`dnf` is then True).
18"""
19
20from __future__ import annotations
21
22from simvx.core import Node, Signal
23
24from .ship import Ship
25from .track import Track
26
27
28class RaceManager(Node):
29 """3-lap time-attack race state machine."""
30
31 #: Seconds of start lights before the flag drops.
32 COUNTDOWN_SECONDS = 3.0
33 #: Seconds the GO! banner stays on screen after the lights go out.
34 GO_BANNER_SECONDS = 0.8
35
36 lap_completed = Signal()
37 race_finished = Signal()
38
39 def __init__(self, ship: Ship, track: Track, max_laps: int = 3, **kwargs) -> None:
40 super().__init__(**kwargs)
41 self.ship = ship
42 self.track = track
43 self.max_laps = int(max_laps)
44
45 # Per-lap state.
46 self.phase: str = "idle"
47 self.dnf: bool = False
48 self.lap: int = 1
49 self.lap_times: list[float] = []
50 self.elapsed: float = 0.0
51 self.lap_start_time: float = 0.0
52 self.best_lap: float | None = None
53
54 # Banner timers the HUD reads.
55 self.countdown: float = 0.0
56 self.go_banner: float = 0.0
57
58 # Checkpoint progress: list of bools indexed by ``track.checkpoints``.
59 self._checkpoints_hit: list[bool] = [False] * len(track.checkpoints)
60 self._prev_t: float = 0.0
61
62 @property
63 def racing(self) -> bool:
64 """True while the player is on the clock (not idle, counting down or done)."""
65 return self.phase == "racing"
66
67 def start(self) -> None:
68 """Reset the race and arm the start lights."""
69 self.phase = "countdown"
70 self.dnf = False
71 self.lap = 1
72 self.lap_times = []
73 self.elapsed = 0.0
74 self.lap_start_time = 0.0
75 self.countdown = self.COUNTDOWN_SECONDS
76 self.go_banner = 0.0
77 self._checkpoints_hit = [False] * len(self.track.checkpoints)
78 self._prev_t = self.ship.t
79
80 def skip_countdown(self) -> None:
81 """Drop straight into ``racing``: used by the capture sweep and the harness."""
82 self.countdown = 0.0
83 self.go_banner = 0.0
84 self.lap_start_time = self.elapsed
85 self.phase = "racing"
86
87 def on_update(self, dt: float) -> None:
88 if self.phase == "countdown":
89 self.countdown -= dt
90 if self.countdown <= 0.0:
91 self.countdown = 0.0
92 self.go_banner = self.GO_BANNER_SECONDS
93 self.lap_start_time = 0.0
94 self.phase = "racing"
95 return
96
97 if self.phase != "racing":
98 return
99
100 if self.go_banner > 0.0:
101 self.go_banner = max(0.0, self.go_banner - dt)
102
103 if self.ship.destroyed:
104 self.phase = "finished"
105 self.dnf = True
106 self.race_finished.emit()
107 return
108
109 self.elapsed += dt
110 cur_t = self.ship.t
111
112 # Mark any checkpoint we cross between prev_t and cur_t (account for wrap).
113 for i, cp_t in enumerate(self.track.checkpoints):
114 if self._crossed(self._prev_t, cur_t, cp_t):
115 self._checkpoints_hit[i] = True
116
117 # Detect lap completion: wrap from t≈1.0 to t≈0.0 with all checkpoints hit.
118 if self._prev_t > 0.7 and cur_t < 0.3 and all(self._checkpoints_hit):
119 lap_time = self.elapsed - self.lap_start_time
120 self.lap_times.append(lap_time)
121 if self.best_lap is None or lap_time < self.best_lap:
122 self.best_lap = lap_time
123 self.lap_completed.emit()
124 self.lap_start_time = self.elapsed
125 self._checkpoints_hit = [False] * len(self.track.checkpoints)
126 if self.lap >= self.max_laps:
127 self.phase = "finished"
128 self.race_finished.emit()
129 else:
130 self.lap += 1
131
132 self._prev_t = cur_t
133
134 @staticmethod
135 def _crossed(prev: float, cur: float, cp: float) -> bool:
136 """Did the parameter cross checkpoint ``cp`` going forward (with wrap)?"""
137 if prev <= cur:
138 return prev < cp <= cur
139 # Wrap-around case: prev > cur, e.g. prev=0.95, cur=0.05.
140 return cp > prev or cp <= cur