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