nirmalpratheep commited on
Commit
de9fc8c
Β·
verified Β·
1 Parent(s): 41a9651

Upload 7 files

Browse files
game/README.md ADDED
@@ -0,0 +1,144 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ # Game
2
+
3
+ Pygame-based curriculum car racer used as the simulation backend for RL training.
4
+
5
+ ## Running
6
+
7
+ ```bash
8
+ # From project root
9
+ uv run python main.py # start at track 1
10
+ uv run python main.py 5 # start at track 5
11
+
12
+ # As a module
13
+ uv run python -m game.curriculum_game 5
14
+ ```
15
+
16
+ ## Controls
17
+
18
+ | Key | Action |
19
+ |-----|--------|
20
+ | Arrow keys | Drive (up=throttle, down=brake, left/right=steer) |
21
+ | N / P | Next / previous track |
22
+ | 1 – 9 | Jump to track number |
23
+ | R | Restart (counts as an attempt) |
24
+ | ESC | Quit |
25
+
26
+ ## Tracks
27
+
28
+ 16 tracks across 4 difficulty tiers. Each level narrows the road, tightens turns, or increases speed cap.
29
+
30
+ | Tier | Levels | Shape | Description |
31
+ |------|--------|-------|-------------|
32
+ | A β€” Easy | 1–4 | Full ellipses | Wide to narrow ovals, superspeedway |
33
+ | B β€” Medium-Easy | 5–8 | Rounded rectangles | Stadium oval, tight rectangle |
34
+ | C β€” Medium-Hard | 9–12 | Two-arc layouts | Hairpin, chicane, double-hairpin, asymmetric |
35
+ | D β€” Hard | 13–16 | Polygon circuits | L-shape, T-notch, complex circuit, master challenge |
36
+
37
+ ## Game Rules
38
+
39
+ - Complete **one full lap** without touching the white fence border.
40
+ - Touching the fence **or** pressing R = restart from start, attempt counter +1.
41
+ - Cross the start/finish line cleanly to finish. A summary screen shows stats.
42
+
43
+ ## HUD
44
+
45
+ A single top bar shows: track name Β· speed Β· attempt count Β· lap time Β· total time Β· distance Β· max speed. Timer starts on first key press, not on load.
46
+
47
+ ## File Structure
48
+
49
+ ```
50
+ game/
51
+ oval_racer.py Original single-oval game. Exports draw_headlights, draw_car,
52
+ SCREEN_W, SCREEN_H used by curriculum_game and env/.
53
+ tracks.py 16 TrackDef objects. Each knows its waypoints, road width,
54
+ start position/angle, speed cap, and on-track mask.
55
+ curriculum_game.py Main playable game. RaceState drives the lap logic,
56
+ reset-on-crash, finish detection, and HUD rendering.
57
+ rl_splits.py CarEnv (gym-style wrapper), CurriculumSampler, Evaluator,
58
+ and TRAIN / VAL / TEST splits for RL training.
59
+ test_tracks.py Headless test: builds all 16 tracks and simulates 150 steps
60
+ each. Run with: uv run python -m game.test_tracks
61
+ ```
62
+
63
+ ## Physics Constants
64
+
65
+ | Constant | Value | Effect |
66
+ |----------|-------|--------|
67
+ | `ACCEL` | 0.13 | Throttle acceleration per frame |
68
+ | `BRAKE_DECEL` | 0.22 | Braking deceleration per frame |
69
+ | `FRICTION` | 0.038 | Passive speed decay per frame |
70
+ | `STEER_DEG` | 2.7 | Degrees rotated per steer step |
71
+ | `max_speed` | 3.0–4.5 | Per-track speed cap (px/frame) |
72
+
73
+ Speed is in px/frame. Multiply by FPS (60) to get px/s shown in HUD.
74
+
75
+ ## Finish Line Detection
76
+
77
+ Two-phase gate crossing to handle fast cars reliably:
78
+
79
+ 1. **Arm** β€” wait until `gate_side > 50 px` ahead (car is clearly past the gate going forward).
80
+ 2. **Trigger** β€” detect `prev_side < 0` and `curr_side >= 0` with `speed > 0.3`.
81
+
82
+ This prevents the car from triggering on spawn or when reversing back over the line.
83
+
84
+ ## Track Metadata (used by reward)
85
+
86
+ Each `TrackDef` computes three values at construction time:
87
+
88
+ | Field | Formula | Purpose |
89
+ |-------|---------|---------|
90
+ | `optimal_dist` | Waypoint polygon perimeter (px) | Theoretical shortest lap path |
91
+ | `par_time_steps` | `optimal_dist / (max_speed Γ— 0.7)` | Expected lap frames at 70% speed |
92
+ | `complexity` | `(115 / width) Γ— (max_speed / 3.0)` | Difficulty multiplier (1.0 β†’ 3.45) |
93
+
94
+ ## RL Interface (`rl_splits.py`)
95
+
96
+ `CarEnv` exposes a gym-style API:
97
+
98
+ ```python
99
+ from game.rl_splits import make_env, TRAIN
100
+
101
+ env = make_env(TRAIN[0])
102
+ obs = env.reset() # [x/W, y/H, sin, cos, speed/max, on_track, gate_side]
103
+ obs, reward, done, info = env.step([accel, steer])
104
+ # info keys: lap, on_track, step, crashes, lap_dist, out_of_bounds
105
+ ```
106
+
107
+ ### Reward Function
108
+
109
+ Rewards are **not** scaled by complexity β€” all values are fixed and comparable
110
+ across every track. Complexity only scales the curriculum `threshold`.
111
+
112
+ | Term | Trigger | Value | Purpose |
113
+ |------|---------|-------|---------|
114
+ | Forward pulse | Every step | `+speed/max_speed Γ— 0.01` | Prevent stalling |
115
+ | Off-track | Every step off road | `βˆ’0.5` | Stay on road |
116
+ | Crash event | onβ†’off transition | `βˆ’5.0` | Penalise each boundary hit |
117
+ | Lap completion | Gate crossed cleanly | `+50 Γ— time_ratio Γ— dist_ratio` | Fast + efficient path |
118
+ | Out of bounds | Terminal | `βˆ’100` | Don't leave screen |
119
+
120
+ **Lap completion breakdown:**
121
+
122
+ ```
123
+ time_ratio = clamp(par_time_steps / actual_lap_steps, 0.5, 2.0)
124
+ dist_ratio = clamp(optimal_dist / actual_lap_dist, 0.5, 1.0)
125
+ ```
126
+
127
+ - `dist_ratio` capped at **1.0** β€” no bonus for paths shorter than the centreline
128
+ (any such path involves off-track corner cutting). `lap_dist` is only
129
+ accumulated while `on_track=True`, closing the corner-cutting exploit.
130
+ - Best lap: `50 Γ— 2.0 Γ— 1.0 = 100`
131
+ - Worst completed lap: `50 Γ— 0.5 Γ— 0.5 = 12.5`
132
+
133
+ **Curriculum threshold scales with complexity, rewards do not:**
134
+
135
+ ```
136
+ effective_threshold = base_threshold Γ— track.complexity
137
+ ```
138
+
139
+ | Track | C | Effective threshold (base=30) |
140
+ |-------|---|-------------------------------|
141
+ | 1 β€” Wide Oval | 1.00 | 30 |
142
+ | 8 β€” Small Oval | 2.03 | 61 |
143
+ | 14 β€” T-Notch | 2.66 | 80 |
144
+ | 16 β€” Master Challenge | 3.45 | 104 |
game/__init__.py ADDED
@@ -0,0 +1 @@
 
 
1
+ from .oval_racer import SCREEN_W, SCREEN_H, draw_headlights, draw_car
game/curriculum_game.py ADDED
@@ -0,0 +1,405 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """
2
+ Curriculum Car Racer β€” One-lap challenge.
3
+
4
+ Rules:
5
+ * Complete one full lap without touching the fence (white border).
6
+ * Touching the fence OR pressing R = OUT -> restart from start, attempt +1.
7
+ * Trace path and distance covered reset on every restart.
8
+ * Game ends when the finish line (= start line) is crossed cleanly.
9
+
10
+ Controls:
11
+ Arrow keys drive
12
+ N / P next / prev track
13
+ 1-9 jump to track 1-9
14
+ R manual restart (counts as an attempt)
15
+ ESC quit
16
+ """
17
+
18
+ import math
19
+ import pygame
20
+
21
+ from .oval_racer import SCREEN_W, SCREEN_H, draw_headlights, draw_car
22
+ from .tracks import TRACKS
23
+
24
+ FPS = 60
25
+
26
+ ACCEL = 0.13
27
+ BRAKE_DECEL = 0.22
28
+ FRICTION = 0.038
29
+ STEER_DEG = 2.7
30
+
31
+ C_YELLOW = (255, 215, 0)
32
+ C_HUD = (230, 230, 230)
33
+ C_GREEN = ( 50, 220, 80)
34
+ C_BLUE = ( 60, 140, 255)
35
+
36
+ RACING = "racing"
37
+ DONE = "done"
38
+
39
+ PATH_SAMPLE_EVERY = 2 # record a path point every N frames
40
+
41
+
42
+ # ── Car ──────────────────────────────────────────────────────────────────────
43
+
44
+ class Car:
45
+ def __init__(self, track):
46
+ self.track = track
47
+ self.reset()
48
+
49
+ def reset(self):
50
+ self.x = float(self.track.start_pos[0])
51
+ self.y = float(self.track.start_pos[1])
52
+ self.angle = float(self.track.start_angle)
53
+ self.speed = 0.0
54
+
55
+ def update(self, accel, steer):
56
+ ms = self.track.max_speed
57
+ ratio = min(abs(self.speed) / ms, 1.0) if ms > 0 else 0.0
58
+ self.angle += steer * STEER_DEG * max(0.3, ratio)
59
+ if accel > 0:
60
+ self.speed = min(self.speed + ACCEL, ms)
61
+ elif accel < 0:
62
+ self.speed = max(self.speed - BRAKE_DECEL, -ms * 0.4)
63
+ if self.speed > 0:
64
+ self.speed = max(0.0, self.speed - FRICTION)
65
+ elif self.speed < 0:
66
+ self.speed = min(0.0, self.speed + FRICTION)
67
+ rad = math.radians(self.angle)
68
+ self.x += self.speed * math.cos(rad)
69
+ self.y += self.speed * math.sin(rad)
70
+
71
+
72
+ # ── Drawing ───────────────────────────────────────────────────────────────────
73
+
74
+ _RAY_ANGLES = [-90, -45, 0, 45, 90]
75
+ _RAY_MAX = 120
76
+ _RAY_STEP = 2
77
+
78
+ # Colour gradient: red (close) β†’ yellow β†’ green (far)
79
+ def _ray_colour(ratio):
80
+ r = int(255 * (1 - ratio))
81
+ g = int(255 * ratio)
82
+ return (r, g, 0)
83
+
84
+
85
+ def draw_raycasts(surf, track, car):
86
+ """
87
+ Draw the 5 RL observation rays from the car.
88
+ Each ray is coloured green (far) β†’ red (close) to show clearance.
89
+ Press V in game to toggle.
90
+ """
91
+ overlay = pygame.Surface((SCREEN_W, SCREEN_H), pygame.SRCALPHA)
92
+ for rel_deg in _RAY_ANGLES:
93
+ abs_rad = math.radians(car.angle + rel_deg)
94
+ dx = math.cos(abs_rad) * _RAY_STEP
95
+ dy = math.sin(abs_rad) * _RAY_STEP
96
+ px, py = car.x, car.y
97
+ dist = 0.0
98
+ while dist < _RAY_MAX:
99
+ px += dx
100
+ py += dy
101
+ dist += _RAY_STEP
102
+ if not track.on_track(px, py):
103
+ break
104
+ ratio = dist / _RAY_MAX
105
+ colour = _ray_colour(ratio) + (180,)
106
+ end_x = car.x + math.cos(abs_rad) * dist
107
+ end_y = car.y + math.sin(abs_rad) * dist
108
+ pygame.draw.line(overlay, colour, (int(car.x), int(car.y)),
109
+ (int(end_x), int(end_y)), 1) # 1px line β€” subtle
110
+ pygame.draw.circle(overlay, colour, (int(end_x), int(end_y)), 2) # 2px dot
111
+ surf.blit(overlay, (0, 0))
112
+
113
+
114
+ def _draw_path(surf, pts, color, width=2):
115
+ if len(pts) >= 2:
116
+ ipts = [(int(x), int(y)) for x, y in pts]
117
+ pygame.draw.lines(surf, color, False, ipts, width)
118
+
119
+
120
+ def draw_hud(surf, track, car, race, fonts):
121
+ _, small = fonts
122
+
123
+ lt = race.lap_elapsed()
124
+ text = (
125
+ f"Lv{track.level}: {track.name}"
126
+ f" Spd {abs(car.speed)*FPS:4.1f}"
127
+ f" Attempt {race.attempts}"
128
+ f" Lap {lt:.2f}s"
129
+ f" Total {race.total_elapsed():.2f}s"
130
+ f" Dist {race.current_distance:.0f}px"
131
+ f" Max {race._max_spd:.1f}"
132
+ f" | Arrows=drive N/P=track 1-9=jump R=restart V=rays ESC=quit"
133
+ )
134
+
135
+ rendered = small.render(text, True, C_HUD)
136
+ bar_h = rendered.get_height() + 4
137
+ bar = pygame.Surface((SCREEN_W, bar_h), pygame.SRCALPHA)
138
+ bar.fill((0, 0, 0, 200))
139
+ surf.blit(bar, (0, 0))
140
+ surf.blit(rendered, (6, 2))
141
+
142
+
143
+ def draw_summary(surf, race, fonts):
144
+ """Blocking summary overlay shown when finish line is crossed."""
145
+ font, small = fonts
146
+ big = pygame.font.SysFont("consolas", 38, bold=True)
147
+ med = pygame.font.SysFont("consolas", 22, bold=True)
148
+
149
+ overlay = pygame.Surface((SCREEN_W, SCREEN_H), pygame.SRCALPHA)
150
+ overlay.fill((0, 0, 0, 170))
151
+ surf.blit(overlay, (0, 0))
152
+
153
+ cx = SCREEN_W // 2
154
+
155
+ def centre(text, color, fnt, y):
156
+ s = fnt.render(text, True, color)
157
+ surf.blit(s, (cx - s.get_width() // 2, y))
158
+
159
+ centre("FINISH!", C_GREEN, big, 130)
160
+
161
+ pygame.draw.line(surf, (100, 100, 100), (cx - 220, 183), (cx + 220, 183), 1)
162
+
163
+ rows = [
164
+ ("Lap time", f"{race.lap_time:.2f} s"),
165
+ ("Total time", f"{race.total_time:.2f} s"),
166
+ ("Distance", f"{race.lap_dist:.0f} px"),
167
+ ("Max speed", f"{race.lap_max_spd:.1f} px/s"),
168
+ ("Avg speed", f"{race.lap_avg_spd:.1f} px/s"),
169
+ ("Attempts", str(race.attempts)),
170
+ ]
171
+ label_x = cx - 130
172
+ value_x = cx + 140
173
+ for i, (label, value) in enumerate(rows):
174
+ y = 196 + i * 28
175
+ surf.blit(med.render(label, True, (170, 170, 170)), (label_x, y))
176
+ surf.blit(med.render(value, True, C_HUD),
177
+ (value_x - med.size(value)[0], y))
178
+
179
+ pygame.draw.line(surf, (100, 100, 100), (cx - 220, 368), (cx + 220, 368), 1)
180
+ verdict = "Perfect run - no restarts!" if race.attempts == 1 else \
181
+ f"Finished in {race.attempts} attempts"
182
+ centre(verdict, C_YELLOW, font, 378)
183
+ centre("R = retry N/P = change track ESC = quit",
184
+ (150, 150, 150), small, 418)
185
+
186
+
187
+ # ── Race state ────────────────────────────────────────────────────────────────
188
+
189
+ class RaceState:
190
+ def __init__(self, track):
191
+ self.track = track
192
+ self.car = Car(track)
193
+ self.state = RACING
194
+ self.attempts = 1
195
+ self.show_rays = False # toggled with V
196
+
197
+ self._lap_timer_started = False # starts on first key press per attempt
198
+ self._total_timer_started = False # starts on first key press ever
199
+ self.total_start = None
200
+ self.lap_start = None
201
+
202
+ self.lap_time = 0.0 # locked on finish
203
+ self.lap_dist = 0.0 # locked on finish
204
+ self.total_time = 0.0 # locked on finish
205
+ self.lap_max_spd = 0.0 # locked on finish (px/s)
206
+ self.lap_avg_spd = 0.0 # locked on finish (px/s)
207
+
208
+ self.prev_side = track.gate_side(self.car.x, self.car.y)
209
+ self._lap_armed = False # True once car is clearly past the gate
210
+
211
+ # Path trace + speed β€” cleared on every reset
212
+ self.current_path = []
213
+ self.current_distance = 0.0 # px covered this attempt
214
+ self._max_spd = 0.0 # peak speed this attempt (px/s)
215
+ self._spd_sum = 0.0 # for rolling average
216
+ self._spd_count = 0
217
+ self._frame = 0
218
+ self._prev_x = self.car.x
219
+ self._prev_y = self.car.y
220
+
221
+ # ── helpers ──────────────────────────────────────────────────────────────
222
+
223
+ def lap_elapsed(self):
224
+ if not self._lap_timer_started:
225
+ return 0.0
226
+ return (pygame.time.get_ticks() - self.lap_start) / 1000.0
227
+
228
+ def total_elapsed(self):
229
+ if self.state == DONE:
230
+ return self.total_time
231
+ if not self._total_timer_started:
232
+ return 0.0
233
+ return (pygame.time.get_ticks() - self.total_start) / 1000.0
234
+
235
+ def _record(self):
236
+ """Accumulate distance + speed stats every frame; record path every N frames."""
237
+ dx = self.car.x - self._prev_x
238
+ dy = self.car.y - self._prev_y
239
+ self.current_distance += math.hypot(dx, dy)
240
+ self._prev_x, self._prev_y = self.car.x, self.car.y
241
+
242
+ pps = abs(self.car.speed) * FPS
243
+ if pps > self._max_spd:
244
+ self._max_spd = pps
245
+ self._spd_sum += pps
246
+ self._spd_count += 1
247
+
248
+ self._frame += 1
249
+ if self._frame % PATH_SAMPLE_EVERY == 0:
250
+ self.current_path.append((self.car.x, self.car.y))
251
+
252
+ def _reset_attempt(self):
253
+ """Clear trace, distance, speed stats, and reset car to start."""
254
+ self.current_path = []
255
+ self.current_distance = 0.0
256
+ self._max_spd = 0.0
257
+ self._spd_sum = 0.0
258
+ self._spd_count = 0
259
+ self._frame = 0
260
+ self.car.reset()
261
+ self._prev_x = self.car.x
262
+ self._prev_y = self.car.y
263
+ self.attempts += 1
264
+ self.lap_start = None
265
+ self._lap_timer_started = False
266
+ self.prev_side = self.track.gate_side(self.car.x, self.car.y)
267
+ self._lap_armed = False
268
+
269
+ def manual_reset(self):
270
+ self._reset_attempt()
271
+
272
+ # ── main step ─────────────────��───────────────────────────────────────────
273
+
274
+ def step(self, accel, steer):
275
+ if self.state == DONE:
276
+ return
277
+
278
+ if (accel != 0 or steer != 0) and not self._lap_timer_started:
279
+ now = pygame.time.get_ticks()
280
+ self.lap_start = now
281
+ self._lap_timer_started = True
282
+ if not self._total_timer_started:
283
+ self.total_start = now
284
+ self._total_timer_started = True
285
+
286
+ self.car.update(accel, steer)
287
+ self._record()
288
+
289
+ # Fence hit β†’ OUT, clear trace
290
+ if not self.track.on_track(self.car.x, self.car.y):
291
+ self._reset_attempt()
292
+ return
293
+
294
+ # Finish line detection (same line as start).
295
+ # Phase 1 β€” arm: wait until car is 50 px ahead of gate going forward.
296
+ # Phase 2 β€” trigger: detect when gate_side crosses from negative β†’ positive.
297
+ # This avoids the < -5 threshold bug where fast cars skip the window.
298
+ curr_side = self.track.gate_side(self.car.x, self.car.y)
299
+ if not self._lap_armed and curr_side > 50:
300
+ self._lap_armed = True
301
+ if self._lap_armed and self.prev_side < 0 and curr_side >= 0 and self.car.speed > 0.3:
302
+ self.lap_time = self.lap_elapsed()
303
+ self.lap_dist = self.current_distance
304
+ self.total_time = self.total_elapsed()
305
+ self.lap_max_spd = self._max_spd
306
+ self.lap_avg_spd = (self._spd_sum / self._spd_count
307
+ if self._spd_count else 0.0)
308
+ self.state = DONE
309
+ self.prev_side = curr_side
310
+
311
+ # ── draw ──────────────────────────────────────────────────────────────────
312
+
313
+ def draw(self, surf, fonts):
314
+ surf.blit(self.track.surface, (0, 0))
315
+
316
+ # Current attempt path in blue (cleared after every reset)
317
+ _draw_path(surf, self.current_path, C_BLUE, width=2)
318
+
319
+ draw_headlights(surf, self.car.x, self.car.y, self.car.angle)
320
+ if self.show_rays:
321
+ draw_raycasts(surf, self.track, self.car)
322
+ draw_car(surf, self.car.x, self.car.y, self.car.angle)
323
+
324
+ if self.state == RACING:
325
+ draw_hud(surf, self.track, self.car, self, fonts)
326
+ else:
327
+ draw_summary(surf, self, fonts)
328
+
329
+
330
+ # ── Main loop ─────────────────────────────────────────────────────────────────
331
+
332
+ def run(start_track=1):
333
+ pygame.init()
334
+ screen = pygame.display.set_mode((SCREEN_W, SCREEN_H))
335
+ clock = pygame.time.Clock()
336
+ fonts = (pygame.font.SysFont("consolas", 20, bold=True),
337
+ pygame.font.SysFont("consolas", 14))
338
+
339
+ track_idx = max(0, min(start_track - 1, len(TRACKS) - 1))
340
+
341
+ def new_race(idx):
342
+ t = TRACKS[idx]
343
+ t.build()
344
+ pygame.display.set_caption(f"Curriculum Racer Lv{t.level}: {t.name}")
345
+ return RaceState(t)
346
+
347
+ race = new_race(track_idx)
348
+
349
+ running = True
350
+ while running:
351
+ clock.tick(FPS)
352
+
353
+ for event in pygame.event.get():
354
+ if event.type == pygame.QUIT:
355
+ running = False
356
+
357
+ if event.type == pygame.KEYDOWN:
358
+ if event.key == pygame.K_ESCAPE:
359
+ running = False
360
+
361
+ elif event.key == pygame.K_r:
362
+ # After finish: full retry (attempt counter resets to 1)
363
+ # While racing: counts as an attempt
364
+ if race.state == DONE:
365
+ race = new_race(track_idx)
366
+ else:
367
+ race.manual_reset()
368
+
369
+ elif event.key == pygame.K_v:
370
+ race.show_rays = not race.show_rays
371
+
372
+ elif event.key == pygame.K_n:
373
+ track_idx = (track_idx + 1) % len(TRACKS)
374
+ race = new_race(track_idx)
375
+
376
+ elif event.key == pygame.K_p:
377
+ track_idx = (track_idx - 1) % len(TRACKS)
378
+ race = new_race(track_idx)
379
+
380
+ else:
381
+ for ki, key in enumerate([
382
+ pygame.K_1, pygame.K_2, pygame.K_3, pygame.K_4,
383
+ pygame.K_5, pygame.K_6, pygame.K_7, pygame.K_8, pygame.K_9
384
+ ]):
385
+ if event.key == key and ki < len(TRACKS):
386
+ track_idx = ki
387
+ race = new_race(track_idx)
388
+ break
389
+
390
+ if race.state == RACING:
391
+ keys = pygame.key.get_pressed()
392
+ accel = (1 if keys[pygame.K_UP] else 0) - (1 if keys[pygame.K_DOWN] else 0)
393
+ steer = (1 if keys[pygame.K_RIGHT] else 0) - (1 if keys[pygame.K_LEFT] else 0)
394
+ race.step(accel, steer)
395
+
396
+ race.draw(screen, fonts)
397
+ pygame.display.flip()
398
+
399
+ pygame.quit()
400
+
401
+
402
+ if __name__ == "__main__":
403
+ import sys
404
+ level = int(sys.argv[1]) if len(sys.argv) > 1 else 1
405
+ run(start_track=level)
game/oval_racer.py ADDED
@@ -0,0 +1,246 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """
2
+ Oval Car Racer
3
+ Controls: Arrow keys to drive, R to reset, ESC to quit.
4
+ """
5
+
6
+ import math
7
+ import pygame
8
+
9
+ # ── Screen ──────────────────────────────────────────────────────────────────
10
+ SCREEN_W, SCREEN_H = 900, 600
11
+ FPS = 60
12
+
13
+ # ── Oval geometry ────────────────────────────────────────────────────────────
14
+ CX, CY = SCREEN_W // 2, SCREEN_H // 2
15
+ OUTER_RX, OUTER_RY = 380, 240
16
+ INNER_RX, INNER_RY = 290, 155
17
+ MID_RY = (OUTER_RY + INNER_RY) // 2 # 197
18
+
19
+ START_X = float(CX)
20
+ START_Y = float(CY + MID_RY)
21
+
22
+ # ── Colours ─────────────────────────────────────────────────────────────────
23
+ C_GRASS = ( 45, 110, 45)
24
+ C_TRACK = ( 52, 52, 52)
25
+ C_WHITE = (255, 255, 255)
26
+ C_YELLOW = (255, 215, 0)
27
+ C_CAR = (220, 50, 50)
28
+ C_WIND = (160, 210, 255)
29
+ C_HUD = (230, 230, 230)
30
+ C_WARN = (255, 70, 70)
31
+
32
+ # ── Car physics ──────────────────────────────────────────────────────────────
33
+ MAX_SPEED = 4.5
34
+ ACCEL = 0.13
35
+ BRAKE_DECEL = 0.22
36
+ FRICTION = 0.038
37
+ STEER_DEG = 2.7
38
+
39
+
40
+ # ────────────────────────────────────────────────────────────────────────────
41
+ # Track geometry
42
+ # ────────────────────────────────────────────────────────────────────────────
43
+
44
+ def _in_ellipse(x, y, cx, cy, rx, ry):
45
+ return ((x - cx) / rx) ** 2 + ((y - cy) / ry) ** 2 <= 1.0
46
+
47
+
48
+ def on_track(x, y):
49
+ return (_in_ellipse(x, y, CX, CY, OUTER_RX, OUTER_RY) and
50
+ not _in_ellipse(x, y, CX, CY, INNER_RX, INNER_RY))
51
+
52
+
53
+ def _rect(cx, cy, rx, ry):
54
+ return pygame.Rect(cx - rx, cy - ry, rx * 2, ry * 2)
55
+
56
+
57
+ # ────────────────────────────────────────────────────────────────────────────
58
+ # Drawing
59
+ # ────────────────────────────────────────────────────────────────────────────
60
+
61
+
62
+ def build_track_surface():
63
+ surf = pygame.Surface((SCREEN_W, SCREEN_H))
64
+ surf.fill(C_GRASS)
65
+
66
+ # Tarmac
67
+ pygame.draw.ellipse(surf, C_TRACK, _rect(CX, CY, OUTER_RX, OUTER_RY))
68
+ pygame.draw.ellipse(surf, C_GRASS, _rect(CX, CY, INNER_RX, INNER_RY))
69
+
70
+ # White borders
71
+ bw = 3
72
+ pygame.draw.ellipse(surf, C_WHITE, _rect(CX, CY, OUTER_RX, OUTER_RY), bw)
73
+ pygame.draw.ellipse(surf, C_WHITE, _rect(CX, CY, INNER_RX, INNER_RY), bw)
74
+
75
+ # Finish line β€” vertical white line at bottom of track
76
+ line_y = CY + MID_RY
77
+ track_w = OUTER_RX - INNER_RX
78
+ line_x = CX
79
+ pygame.draw.line(surf, C_WHITE, (line_x, line_y - track_w // 2), (line_x, line_y + track_w // 2), 3)
80
+
81
+ return surf
82
+
83
+
84
+ def draw_headlights(surf, x, y, angle_deg):
85
+ CONE_LEN = 60 # pixels ahead
86
+ HALF_ANG = 30 # half of 60-degree spread
87
+ STEPS = 12 # arc smoothness
88
+ # Build cone polygon: origin + arc points
89
+ pts = [(x, y)]
90
+ for i in range(STEPS + 1):
91
+ a = math.radians(angle_deg - HALF_ANG + (2 * HALF_ANG) * i / STEPS)
92
+ pts.append((x + math.cos(a) * CONE_LEN,
93
+ y + math.sin(a) * CONE_LEN))
94
+
95
+ # Draw on alpha surface so it blends with track
96
+ cone = pygame.Surface((SCREEN_W, SCREEN_H), pygame.SRCALPHA)
97
+ pygame.draw.polygon(cone, (255, 255, 180, 160), pts) # yellow fill β€” visible to CNN
98
+ pygame.draw.lines(cone, (255, 255, 200, 220), False, pts[1:], 2) # bright edge
99
+ surf.blit(cone, (0, 0))
100
+
101
+
102
+ def draw_car(surf, x, y, angle_deg):
103
+ w, h = 26, 12
104
+ img = pygame.Surface((w, h), pygame.SRCALPHA)
105
+ pygame.draw.rect(img, C_CAR, (0, 0, w, h), border_radius=3)
106
+ pygame.draw.rect(img, C_WIND, (w - 9, 2, 7, h - 4), border_radius=2)
107
+ pygame.draw.rect(img, (255, 200, 0), (w - 3, 3, 3, h - 6))
108
+ rot = pygame.transform.rotate(img, -angle_deg)
109
+ surf.blit(rot, rot.get_rect(center=(int(x), int(y))))
110
+
111
+
112
+ def draw_hud(surf, speed, lap, best, last, off_track, lap_done):
113
+ font = pygame.font.SysFont("consolas", 20, bold=True)
114
+ small = pygame.font.SysFont("consolas", 15)
115
+
116
+ panel = pygame.Surface((240, 85), pygame.SRCALPHA)
117
+ panel.fill((0, 0, 0, 170))
118
+ surf.blit(panel, (10, 10))
119
+
120
+ def put(text, color, row):
121
+ surf.blit(font.render(text, True, color), (18, 16 + row * 28))
122
+
123
+ put(f"Speed : {abs(speed) * 65:5.1f} km/h", C_HUD, 0)
124
+ put(f"Lap : {lap}", C_HUD, 1)
125
+ best_s = f"{best:.2f}s" if best < 1e8 else "--"
126
+ last_s = f"{last:.2f}s" if last < 1e8 else "--"
127
+ put(f"Last:{last_s} Best:{best_s}", C_HUD, 2)
128
+
129
+ if off_track:
130
+ msg = font.render("! OFF TRACK !", True, C_WARN)
131
+ surf.blit(msg, (SCREEN_W // 2 - msg.get_width() // 2, 12))
132
+
133
+ if lap_done:
134
+ msg = font.render("LAP COMPLETE!", True, C_YELLOW)
135
+ surf.blit(msg, (SCREEN_W // 2 - msg.get_width() // 2, 52))
136
+
137
+ hint = small.render("Arrows = drive R = reset ESC = quit", True, (150, 150, 150))
138
+ surf.blit(hint, (SCREEN_W // 2 - hint.get_width() // 2, SCREEN_H - 22))
139
+
140
+
141
+ # ────────────────────────────────────────────────────────────────────────────
142
+ # Car
143
+ # ────────────────────────────────────────────────────────────────────────────
144
+
145
+ class Car:
146
+ def __init__(self):
147
+ self.reset()
148
+
149
+ def reset(self):
150
+ self.x = START_X
151
+ self.y = START_Y
152
+ self.angle = 180.0
153
+ self.speed = 0.0
154
+
155
+ def update(self, accel, steer):
156
+ speed_ratio = min(abs(self.speed) / MAX_SPEED, 1.0)
157
+ self.angle += steer * STEER_DEG * max(0.3, speed_ratio)
158
+
159
+ if accel > 0:
160
+ self.speed = min(self.speed + ACCEL, MAX_SPEED)
161
+ elif accel < 0:
162
+ self.speed = max(self.speed - BRAKE_DECEL, -MAX_SPEED * 0.4)
163
+
164
+ if self.speed > 0:
165
+ self.speed = max(0.0, self.speed - FRICTION)
166
+ elif self.speed < 0:
167
+ self.speed = min(0.0, self.speed + FRICTION)
168
+
169
+ rad = math.radians(self.angle)
170
+ self.x += self.speed * math.cos(rad)
171
+ self.y += self.speed * math.sin(rad)
172
+
173
+ if not on_track(self.x, self.y):
174
+ self.speed *= 0.80
175
+
176
+
177
+ # ────────────────────────────────────────────────────────────────────────────
178
+ # Main
179
+ # ────────────────────────────────────────────────────────────────────────────
180
+
181
+ def main():
182
+ pygame.init()
183
+ screen = pygame.display.set_mode((SCREEN_W, SCREEN_H))
184
+ pygame.display.set_caption("Oval Car Racer")
185
+ clock = pygame.time.Clock()
186
+
187
+ track_surf = build_track_surface()
188
+ car = Car()
189
+
190
+ lap = 0
191
+ best_time = float("inf")
192
+ last_time = float("inf")
193
+ lap_start = pygame.time.get_ticks()
194
+ flash = 0
195
+ prev_y = car.y
196
+
197
+ running = True
198
+ while running:
199
+ clock.tick(FPS)
200
+
201
+ for event in pygame.event.get():
202
+ if event.type == pygame.QUIT:
203
+ running = False
204
+ if event.type == pygame.KEYDOWN:
205
+ if event.key == pygame.K_ESCAPE:
206
+ running = False
207
+ if event.key == pygame.K_r:
208
+ car.reset()
209
+ prev_y = car.y
210
+ lap_start = pygame.time.get_ticks()
211
+ flash = 0
212
+
213
+ keys = pygame.key.get_pressed()
214
+ accel = (1 if keys[pygame.K_UP] else 0) - (1 if keys[pygame.K_DOWN] else 0)
215
+ steer = (1 if keys[pygame.K_RIGHT] else 0) - (1 if keys[pygame.K_LEFT] else 0)
216
+
217
+ car.update(accel, steer)
218
+
219
+ # Lap: car crosses start/finish line (y ~ START_Y) moving left, near CX
220
+ near_x = abs(car.x - CX) < (OUTER_RX - INNER_RX) // 2 + 10
221
+ crossed = prev_y < START_Y <= car.y # crossed going downward
222
+ if near_x and crossed and car.speed > 0.5:
223
+ lap += 1
224
+ elapsed = (pygame.time.get_ticks() - lap_start) / 1000.0
225
+ last_time = elapsed
226
+ best_time = min(best_time, elapsed)
227
+ lap_start = pygame.time.get_ticks()
228
+ flash = FPS * 2
229
+
230
+ prev_y = car.y
231
+ if flash > 0:
232
+ flash -= 1
233
+
234
+ screen.blit(track_surf, (0, 0))
235
+ draw_headlights(screen, car.x, car.y, car.angle)
236
+ draw_car(screen, car.x, car.y, car.angle)
237
+ draw_hud(screen, car.speed, lap, best_time, last_time,
238
+ not on_track(car.x, car.y), flash > 0)
239
+
240
+ pygame.display.flip()
241
+
242
+ pygame.quit()
243
+
244
+
245
+ if __name__ == "__main__":
246
+ main()
game/rl_splits.py ADDED
@@ -0,0 +1,625 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """
2
+ rl_splits.py β€” Curriculum tracks for RL training.
3
+
4
+ 10 tracks across 3 difficulty groups (all used for training):
5
+
6
+ Group A β€” Easy ovals : tracks 1-4
7
+ Group B β€” Rectangular shapes : tracks 5-8
8
+ Group C β€” Hairpins & chicanes: tracks 9-10
9
+
10
+ TRAIN (10) : [1,2,3,4, 5,6,7,8, 9,10] — curriculum progression easy→hard
11
+ VAL (0) : []
12
+ TEST (0) : []
13
+
14
+ Training stops when the agent passes greedy eval on all 10 tracks simultaneously.
15
+
16
+ Usage
17
+ -----
18
+ from game.rl_splits import TRAIN, make_env, CurriculumSampler
19
+
20
+ sampler = CurriculumSampler(TRAIN)
21
+ while True:
22
+ env = make_env(sampler.sample())
23
+ reward = run_episode(env, agent)
24
+ sampler.record(reward)
25
+ if sampler.should_advance():
26
+ sampler.advance()
27
+ """
28
+
29
+ import os
30
+ import math
31
+ import random
32
+ import statistics
33
+ from collections import deque
34
+
35
+ import numpy as np
36
+
37
+ # ── Lazy pygame initialisation (avoids import-time display requirement) ──────
38
+ _pygame_ready = False
39
+
40
+ def _ensure_pygame():
41
+ global _pygame_ready
42
+ if not _pygame_ready:
43
+ import pygame
44
+ if not pygame.get_init():
45
+ pygame.init()
46
+ _pygame_ready = True
47
+
48
+
49
+ # ── Track splits ─────────────────────────────────────────────────────────────
50
+
51
+ def _get_splits():
52
+ from .tracks import TRACKS # TRACKS is 0-indexed, levels are 1-indexed
53
+ by_level = {t.level: t for t in TRACKS}
54
+
55
+ train_levels = [1, 2, 3, 4, 5, 6, 7, 8, 9, 10] # all 10, easy→hard
56
+ val_levels = []
57
+ test_levels = []
58
+
59
+ train = [by_level[l] for l in train_levels]
60
+ val = [by_level[l] for l in val_levels ]
61
+ test = [by_level[l] for l in test_levels ]
62
+ return train, val, test
63
+
64
+
65
+ TRAIN, VAL, TEST = _get_splits()
66
+
67
+ # Convenience: all tracks in curriculum order (for inspection / logging)
68
+ ALL_ORDERED = sorted(TRAIN + VAL + TEST, key=lambda t: t.level)
69
+
70
+
71
+ # ── Difficulty metadata ───────────────────────────────────────────────────────
72
+
73
+ DIFFICULTY = {
74
+ "A-easy": {"tracks": [1, 2, 3, 4], "description": "Full ovals"},
75
+ "B-medium-easy": {"tracks": [5, 6, 7, 8], "description": "Rectangular shapes"},
76
+ "C-medium-hard": {"tracks": [9, 10], "description": "Hairpins & chicanes"},
77
+ }
78
+
79
+
80
+ def difficulty_of(track):
81
+ """Return the difficulty tier label for a track."""
82
+ for tier, info in DIFFICULTY.items():
83
+ if track.level in info["tracks"]:
84
+ return tier
85
+ return "unknown"
86
+
87
+
88
+ # ── Environment factory ───────────────────────────────────────────────────────
89
+
90
+ class CarEnv:
91
+ """
92
+ Minimal gym-style wrapper around TrackDef + Car physics.
93
+
94
+ Observation (7 floats):
95
+ [angular_velocity, speed/max_speed, rayΓ—5]
96
+
97
+ All from real sensors: gyroscope, speedometer, 5 proximity rays, camera image.
98
+ No map or waypoint information in the observation.
99
+
100
+ Action (2 floats, each clamped to [-1, 1]):
101
+ [accel, steer]
102
+ accel > 0 β†’ accelerate, < 0 β†’ brake
103
+ steer > 0 β†’ right, < 0 β†’ left
104
+
105
+ Reward:
106
+
107
+ Per step
108
+ - 0.1 base step penalty (efficiency pressure)
109
+ + (1+wp_cos)/2 * 2.0 dense heading alignment reward every step
110
+ (β‰ˆ +2 when aimed straight, 0 when perpendicular)
111
+ + (1+wp_cos)/2 * 20 bonus heading reward when advancing waypoints
112
+ - 10 distance penalty when moving backward through
113
+ waypoints (moving away from target)
114
+
115
+ Terminal (episode ends immediately)
116
+ - 300 off track β†’ done (high penalty to strongly deter leaving track)
117
+ - 300 car leaves screen bounds
118
+ + 200 lap completed (target reached)
119
+
120
+ Complexity (track.complexity) scales the curriculum threshold only.
121
+
122
+ Done conditions:
123
+ * car leaves screen
124
+ * max_steps exceeded
125
+ * laps_target laps completed
126
+ """
127
+
128
+ # Physics (same as curriculum_game.py)
129
+ ACCEL = 0.13
130
+ BRAKE_DECEL = 0.22
131
+ FRICTION = 0.038
132
+ STEER_DEG = 2.7
133
+
134
+ # Dense progress reward: one full lap of forward waypoint advances β‰ˆ +15 total.
135
+ PROGRESS_SCALE = 15.0
136
+
137
+ def __init__(self, track, max_steps=3000, laps_target=3):
138
+ _ensure_pygame()
139
+ self.track = track
140
+ self.max_steps = max_steps
141
+ self.laps_target = laps_target
142
+ track.build()
143
+
144
+ # Pre-compute waypoint arrays (numpy) for fast nearest-wp lookup.
145
+ # Waypoints are centreline points generated by TrackDef.build().
146
+ # Used only for the internal progress reward β€” NOT exposed in observations.
147
+ wps = track.waypoints
148
+ self._n_wps = len(wps)
149
+ self._wp_x = np.array([w[0] for w in wps], dtype=np.float32)
150
+ self._wp_y = np.array([w[1] for w in wps], dtype=np.float32)
151
+ self._progress_per_wp = self.PROGRESS_SCALE / self._n_wps
152
+
153
+ self._x = self._y = self._angle = self._speed = 0.0
154
+ self._prev_side = 0.0
155
+ self._gate_armed = False # True once car is 50px past start line
156
+ self._laps = 0
157
+ self._step = 0
158
+ self._angle_delta = 0.0
159
+ self._wp_idx = 0 # nearest centreline waypoint index
160
+ self._lap_dist = 0.0
161
+ self._lap_prev_x = 0.0
162
+ self._lap_prev_y = 0.0
163
+ self._crash_count = 0
164
+
165
+ # ── Public API ──────────────────────────────────────────────────────────
166
+
167
+ @property
168
+ def obs_size(self):
169
+ # angular_velocity, speed, rayΓ—5
170
+ return 7
171
+
172
+ @property
173
+ def action_size(self):
174
+ return 2
175
+
176
+ @property
177
+ def laps(self):
178
+ return self._laps
179
+
180
+ def reset(self):
181
+ self._x = float(self.track.start_pos[0])
182
+ self._y = float(self.track.start_pos[1])
183
+ self._angle = float(self.track.start_angle)
184
+ self._speed = self.track.max_speed * 0.2
185
+ self._angle_delta = 0.0
186
+ self._prev_side = self.track.gate_side(self._x, self._y)
187
+ self._gate_armed = False
188
+ self._laps = 0
189
+ self._step = 0
190
+ self._wp_idx = self._nearest_wp(self._x, self._y)
191
+ self._lap_dist = 0.0
192
+ self._lap_prev_x = self._x
193
+ self._lap_prev_y = self._y
194
+ self._crash_count = 0
195
+ return self._obs()
196
+
197
+
198
+ def step(self, action):
199
+ accel = float(max(-1.0, min(1.0, action[0])))
200
+ steer = float(max(-1.0, min(1.0, action[1])))
201
+
202
+ prev_angle = self._angle
203
+ self._update_physics(accel, steer)
204
+ self._angle_delta = self._angle - prev_angle
205
+ self._step += 1
206
+
207
+ on = self.track.on_track(self._x, self._y)
208
+ curr_side = self.track.gate_side(self._x, self._y)
209
+
210
+ # Lap distance accumulation
211
+ dx = self._x - self._lap_prev_x
212
+ dy = self._y - self._lap_prev_y
213
+ self._lap_dist += math.hypot(dx, dy)
214
+ self._lap_prev_x = self._x
215
+ self._lap_prev_y = self._y
216
+
217
+ # ── Reward ───────────────────────────────────────────────────────────
218
+ #
219
+ # Principle: reward what we actually want β€” going forward along the track.
220
+ #
221
+ # reward = -0.005 step penalty
222
+ # crash β†’ -15, done off-track penalty
223
+ # forward speed speed_norm * 0.10 (up to +0.1/step)
224
+ # reversing speed_norm * 0.10 (negative, up to -0.04/step)
225
+ # waypoint advance (forward) +0.25 per waypoint crossed
226
+ # waypoint regress (backward) -0.25 per waypoint lost
227
+ # lap completed +10
228
+ #
229
+ # All constants are 1/20 of the original scale to keep value targets
230
+ # in [-15, +10] range. This prevents value_loss explosion and allows
231
+ # log_std (policy exploration) to receive meaningful gradients.
232
+ #
233
+ reward = -0.005
234
+
235
+ obs_now = self._obs()
236
+
237
+ # Off-track: terminal penalty
238
+ if not on:
239
+ self._crash_count += 1
240
+ return obs_now, -15.0, True, {
241
+ "lap": self._laps,
242
+ "on_track": False,
243
+ "step": self._step,
244
+ "crashes": self._crash_count,
245
+ "lap_dist": self._lap_dist,
246
+ "out_of_bounds": False,
247
+ }
248
+
249
+ # Forward speed reward β€” primary learning signal.
250
+ # Positive when moving forward, negative when reversing.
251
+ # This alone is enough to stop the spinning: spinning gives speed β‰ˆ 0 β†’ reward β‰ˆ 0.
252
+ speed_norm = self._speed / self.track.max_speed # [-0.4, 1.0]
253
+ reward += speed_norm * 0.10
254
+
255
+ # Waypoint progress: flat bonus/penalty per waypoint crossed.
256
+ # Drives the policy to steer toward the track rather than drive in a
257
+ # straight line off it β€” steering toward wp is the only way to advance.
258
+ new_wp = self._nearest_wp(self._x, self._y)
259
+ diff = new_wp - self._wp_idx
260
+ n = self._n_wps
261
+ if diff > n // 2:
262
+ diff -= n
263
+ elif diff < -n // 2:
264
+ diff += n
265
+
266
+ if diff > 0:
267
+ reward += 0.25 * diff # +0.25 per waypoint advanced forward
268
+ elif diff < 0:
269
+ reward -= 0.25 * abs(diff) # -0.25 per waypoint lost going backward
270
+ self._wp_idx = new_wp
271
+
272
+ # Lap completion β€” two-phase arm/trigger to reliably detect crossings.
273
+ # Phase 1 (arm): car must travel 50px past the gate going forward.
274
+ # Phase 2 (trigger): car crosses back through the gate (prev<0 β†’ curr>=0).
275
+ # Anti-shortcut gate: must have traveled 80% of optimal lap distance.
276
+ if not self._gate_armed and curr_side > 50.0:
277
+ self._gate_armed = True
278
+ lap_done = (self._gate_armed
279
+ and self._prev_side < 0.0 and curr_side >= 0.0
280
+ and self._speed > 0.3
281
+ and self._lap_dist >= self.track.optimal_dist * 0.8)
282
+ if lap_done:
283
+ self._laps += 1
284
+ self._gate_armed = False # re-arm for next lap
285
+ reward += 10.0 # lap bonus
286
+ self._lap_dist = 0.0
287
+ self._lap_prev_x = self._x
288
+ self._lap_prev_y = self._y
289
+
290
+ self._prev_side = curr_side
291
+
292
+ out_of_bounds = not (0 <= self._x < 900 and 0 <= self._y < 600)
293
+ if out_of_bounds:
294
+ reward = -15.0
295
+
296
+ done = (out_of_bounds
297
+ or self._laps >= self.laps_target
298
+ or self._step >= self.max_steps)
299
+
300
+ return self._obs(), reward, done, {
301
+ "lap": self._laps,
302
+ "on_track": True,
303
+ "step": self._step,
304
+ "crashes": self._crash_count,
305
+ "lap_dist": self._lap_dist,
306
+ "out_of_bounds": out_of_bounds,
307
+ }
308
+
309
+ # ── Internal ─────────────────────────────────────────────────────────────
310
+
311
+ def _nearest_wp(self, x, y):
312
+ """Return index of the nearest centreline waypoint to (x, y)."""
313
+ dx = self._wp_x - x
314
+ dy = self._wp_y - y
315
+ return int(np.argmin(dx * dx + dy * dy))
316
+
317
+ def _update_physics(self, accel, steer):
318
+ ms = self.track.max_speed
319
+ ratio = min(abs(self._speed) / ms, 1.0) if ms > 0 else 1.0
320
+ self._angle += steer * self.STEER_DEG * max(0.3, ratio)
321
+
322
+ if accel > 0:
323
+ self._speed = min(self._speed + self.ACCEL * accel, ms)
324
+ elif accel < 0:
325
+ self._speed = max(self._speed + self.BRAKE_DECEL * accel,
326
+ -ms * 0.4)
327
+ if self._speed > 0:
328
+ self._speed = max(0.0, self._speed - self.FRICTION)
329
+ elif self._speed < 0:
330
+ self._speed = min(0.0, self._speed + self.FRICTION)
331
+
332
+ if not self.track.on_track(self._x, self._y):
333
+ self._speed *= 0.80
334
+
335
+ rad = math.radians(self._angle)
336
+ self._x += self._speed * math.cos(rad)
337
+ self._y += self._speed * math.sin(rad)
338
+
339
+ # Ray angles relative to heading (degrees). Covers lateral + diagonal + forward.
340
+ _RAY_ANGLES = [-90, -45, 0, 45, 90]
341
+ _RAY_MAX = 120 # max ray length in px (normalise distances to 0..1)
342
+ _RAY_STEP = 2 # step size in px
343
+
344
+ def _raycast(self):
345
+ """
346
+ Cast 5 rays from the car at fixed angles relative to heading.
347
+ Returns list of 5 floats in [0, 1]:
348
+ 1.0 = boundary is MAX px away (clear road)
349
+ 0.0 = boundary is right at the car (on the edge / off track)
350
+ Left/right rays give lateral clearance; diagonal/front give lookahead.
351
+ """
352
+ results = []
353
+ for rel_deg in self._RAY_ANGLES:
354
+ abs_rad = math.radians(self._angle + rel_deg)
355
+ dx = math.cos(abs_rad) * self._RAY_STEP
356
+ dy = math.sin(abs_rad) * self._RAY_STEP
357
+ px, py = self._x, self._y
358
+ dist = 0.0
359
+ while dist < self._RAY_MAX:
360
+ px += dx
361
+ py += dy
362
+ dist += self._RAY_STEP
363
+ if not self.track.on_track(px, py):
364
+ break
365
+ results.append(dist / self._RAY_MAX)
366
+ return results
367
+
368
+ def _obs(self):
369
+ t = self.track
370
+ rays = self._raycast() # 5 floats: left, front-left, front, front-right, right
371
+ ang_vel = self._angle_delta / self.STEER_DEG # β‰ˆ [-1, 1]
372
+
373
+ # GPS: direction to the NEXT waypoint relative to the car's current heading.
374
+ # sin < 0 β†’ waypoint is to the left (steer left)
375
+ # sin > 0 β†’ waypoint is to the right (steer right)
376
+ # cos β‰ˆ 1 β†’ waypoint is straight ahead (keep going)
377
+ next_idx = (self._wp_idx + 10) % self._n_wps
378
+ dx = self._wp_x[next_idx] - self._x
379
+ dy = self._wp_y[next_idx] - self._y
380
+ world_angle_rad = math.atan2(dy, dx)
381
+ rel_angle_rad = world_angle_rad - math.radians(self._angle)
382
+ wp_sin = math.sin(rel_angle_rad)
383
+ wp_cos = math.cos(rel_angle_rad)
384
+
385
+ return [
386
+ ang_vel,
387
+ self._speed / t.max_speed,
388
+ *rays,
389
+ wp_sin, # GPS direction sin component
390
+ wp_cos, # GPS direction cos component
391
+ ]
392
+
393
+
394
+ def make_env(track, **kwargs):
395
+ """Factory: return a fresh CarEnv for the given TrackDef."""
396
+ return CarEnv(track, **kwargs)
397
+
398
+
399
+ # ── Curriculum sampler ────────────────────────────────────────────────────────
400
+
401
+ class CurriculumSampler:
402
+ """
403
+ Manages which train track to sample next.
404
+
405
+ Strategy: performance-gated with anti-forgetting replay.
406
+ * 70% of episodes β†’ current frontier track
407
+ * 30% of episodes β†’ random track from already-mastered ones
408
+ Advance to the next track when the rolling mean reward over
409
+ `window` episodes exceeds `threshold`.
410
+
411
+ Parameters
412
+ ----------
413
+ tracks : ordered list of TrackDef (easy β†’ hard)
414
+ threshold : mean episode reward required to advance
415
+ window : rolling window size for reward averaging
416
+ replay_frac : fraction of episodes sampled from mastered tracks
417
+ """
418
+
419
+ def __init__(self, tracks, threshold=30.0, window=50, replay_frac=0.3):
420
+ self.tracks = tracks
421
+ self.threshold = threshold
422
+ self.window = window
423
+ self.replay_frac = replay_frac
424
+ self._idx = 0 # current frontier index
425
+ self._replay_counter = 0 # round-robin index into mastered tracks
426
+ self._rewards = deque(maxlen=window)
427
+ self._crashes = deque(maxlen=window) # crashes per episode (all)
428
+ self._laps = deque(maxlen=window) # laps completed per episode (all)
429
+ self._is_frontier = deque(maxlen=window) # True when episode was on frontier track
430
+ # Dedicated frontier-only deques so replay episodes never take up slots.
431
+ self._frontier_crashes = deque(maxlen=window)
432
+ self._frontier_laps = deque(maxlen=window)
433
+
434
+ @property
435
+ def current_level(self):
436
+ return self._idx # 0-based index into self.tracks
437
+
438
+ @property
439
+ def current_track(self):
440
+ return self.tracks[self._idx]
441
+
442
+ @property
443
+ def mastered(self):
444
+ return self.tracks[:self._idx]
445
+
446
+ @property
447
+ def frontier_track(self):
448
+ return self.tracks[self._idx]
449
+
450
+ def sample(self):
451
+ """Return the TrackDef to use for the next episode.
452
+ Replay uses round-robin so every mastered track gets equal coverage,
453
+ preventing early tracks from being starved as the curriculum grows.
454
+ """
455
+ if self._idx > 0 and random.random() < self.replay_frac:
456
+ track = self.mastered[self._replay_counter % self._idx]
457
+ self._replay_counter += 1
458
+ return track
459
+ return self.frontier_track
460
+
461
+ def record(self, episode_reward, episode_crashes=0, episode_laps=0, is_frontier=True):
462
+ """Call after each episode with the total reward, crash count, and lap count."""
463
+ self._rewards.append(episode_reward)
464
+ self._crashes.append(episode_crashes)
465
+ self._laps.append(episode_laps)
466
+ self._is_frontier.append(is_frontier)
467
+ if is_frontier:
468
+ self._frontier_crashes.append(episode_crashes)
469
+ self._frontier_laps.append(episode_laps)
470
+
471
+ def should_advance(self):
472
+ """
473
+ True when every episode in the frontier window (last `window` frontier
474
+ episodes) completed a lap with zero crashes. Replay episodes have their
475
+ own slots and never displace frontier entries from the window.
476
+ """
477
+ if self._idx >= len(self.tracks) - 1:
478
+ return False
479
+ if len(self._frontier_crashes) < self.window:
480
+ return False
481
+ return all(l >= 1 and c == 0
482
+ for l, c in zip(self._frontier_laps, self._frontier_crashes))
483
+
484
+ def advance(self):
485
+ """Move to the next track. Clears all rolling buffers."""
486
+ if self._idx < len(self.tracks) - 1:
487
+ self._idx += 1
488
+ self._rewards.clear()
489
+ self._crashes.clear()
490
+ self._laps.clear()
491
+ self._is_frontier.clear()
492
+ self._frontier_crashes.clear()
493
+ self._frontier_laps.clear()
494
+ return True
495
+ return False
496
+
497
+ @property
498
+ def rolling_crashes(self):
499
+ """Mean crashes per episode over the current window."""
500
+ return statistics.mean(self._crashes) if self._crashes else float("nan")
501
+
502
+ @property
503
+ def rolling_laps(self):
504
+ """Mean laps per episode over the current window."""
505
+ return statistics.mean(self._laps) if self._laps else float("nan")
506
+
507
+ def status(self):
508
+ mean = statistics.mean(self._rewards) if self._rewards else float("nan")
509
+ crashes = statistics.mean(self._crashes) if self._crashes else float("nan")
510
+ t = self.frontier_track
511
+ effective = self.threshold * t.complexity
512
+ crash_free = all(c == 0 for c in self._crashes) if self._crashes else False
513
+ return (f"Frontier: track {t.level} '{t.name}' "
514
+ f"[{self._idx+1}/{len(self.tracks)}] "
515
+ f"rolling_mean={mean:.2f} threshold={effective:.2f} "
516
+ f"crashes/ep={crashes:.2f} crash_free={crash_free}")
517
+
518
+
519
+ # ── Evaluator ─────────────────────────────────────────────────────────────────
520
+
521
+ class Evaluator:
522
+ """
523
+ Runs a fixed number of greedy episodes on a list of tracks
524
+ and returns per-track and aggregate metrics.
525
+
526
+ agent_fn : callable(obs) β†’ action (e.g. your policy's greedy forward pass)
527
+ """
528
+
529
+ def __init__(self, n_episodes=20, max_steps=3000, laps_target=3):
530
+ self.n_episodes = n_episodes
531
+ self.max_steps = max_steps
532
+ self.laps_target = laps_target
533
+
534
+ def run(self, agent_fn, tracks):
535
+ """
536
+ Returns dict:
537
+ {
538
+ "per_track": [ { "level", "name", "tier", "mean_reward",
539
+ "mean_laps", "completion_rate" }, ... ],
540
+ "mean_reward": float,
541
+ "mean_laps": float,
542
+ "completion_rate": float, # fraction of episodes with β‰₯1 lap
543
+ }
544
+ """
545
+ per_track = []
546
+ all_rewards, all_laps, all_complete = [], [], []
547
+
548
+ for track in tracks:
549
+ ep_rewards, ep_laps = [], []
550
+
551
+ for _ in range(self.n_episodes):
552
+ env = make_env(track, max_steps=self.max_steps,
553
+ laps_target=self.laps_target)
554
+ obs = env.reset()
555
+ done = False
556
+ total_r = 0.0
557
+
558
+ while not done:
559
+ action = agent_fn(obs)
560
+ obs, r, done, _ = env.step(action)
561
+ total_r += r
562
+
563
+ ep_rewards.append(total_r)
564
+ ep_laps.append(env.laps)
565
+
566
+ completion = sum(1 for l in ep_laps if l >= 1) / self.n_episodes
567
+
568
+ per_track.append({
569
+ "level": track.level,
570
+ "name": track.name,
571
+ "tier": difficulty_of(track),
572
+ "mean_reward": statistics.mean(ep_rewards),
573
+ "std_reward": statistics.stdev(ep_rewards) if len(ep_rewards) > 1 else 0.0,
574
+ "mean_laps": statistics.mean(ep_laps),
575
+ "completion_rate": completion,
576
+ })
577
+
578
+ all_rewards.extend(ep_rewards)
579
+ all_laps.extend(ep_laps)
580
+ all_complete.extend([l >= 1 for l in ep_laps])
581
+
582
+ return {
583
+ "per_track": per_track,
584
+ "mean_reward": statistics.mean(all_rewards),
585
+ "mean_laps": statistics.mean(all_laps),
586
+ "completion_rate": sum(all_complete) / len(all_complete),
587
+ }
588
+
589
+ @staticmethod
590
+ def print_report(metrics, title="Evaluation"):
591
+ print(f"\n{'='*60}")
592
+ print(f" {title}")
593
+ print(f"{'='*60}")
594
+ print(f" {'Lvl':<4} {'Name':<24} {'Tier':<16} "
595
+ f"{'Reward':>8} {'Laps':>6} {'Done%':>6}")
596
+ print(f" {'-'*66}")
597
+ for r in metrics["per_track"]:
598
+ print(f" {r['level']:<4} {r['name']:<24} {r['tier']:<16} "
599
+ f"{r['mean_reward']:>8.1f} {r['mean_laps']:>6.2f} "
600
+ f"{r['completion_rate']*100:>5.0f}%")
601
+ print(f" {'-'*66}")
602
+ print(f" {'AGGREGATE':<44} "
603
+ f"{metrics['mean_reward']:>8.1f} {metrics['mean_laps']:>6.2f} "
604
+ f"{metrics['completion_rate']*100:>5.0f}%")
605
+ print(f"{'='*60}\n")
606
+
607
+
608
+ # ── Split summary (run as script) ─────────────────────────────────────────────
609
+
610
+ if __name__ == "__main__":
611
+ print("\n20-Track Curriculum Splits")
612
+ print("=" * 60)
613
+
614
+ for split_name, split_tracks in [("TRAIN", TRAIN), ("VAL", VAL), ("TEST", TEST)]:
615
+ print(f"\n{split_name} ({len(split_tracks)} tracks)")
616
+ print(f" {'Lvl':<4} {'Name':<24} {'Tier':<16} {'Width':>6} {'MaxSpd':>7}")
617
+ print(f" {'-'*58}")
618
+ for t in split_tracks:
619
+ print(f" {t.level:<4} {t.name:<24} {difficulty_of(t):<16} "
620
+ f"{t.width:>6} {t.max_speed:>7.1f}")
621
+
622
+ print("\nSplit rationale:")
623
+ print(" TRAIN - 2 tracks per difficulty tier, ordered easy->hard for curriculum")
624
+ print(" VAL - 1 track per tier (within-tier generalisation check)")
625
+ print(" TEST - 1 track per tier (held out entirely; final evaluation only)")
game/test_tracks.py ADDED
@@ -0,0 +1,83 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """
2
+ Headless automated test for all 16 tracks.
3
+ Exit 0 if all pass, 1 if any fail.
4
+ """
5
+
6
+ import os
7
+ os.environ['SDL_VIDEODRIVER'] = 'dummy'
8
+ os.environ['SDL_AUDIODRIVER'] = 'dummy'
9
+
10
+ import sys
11
+ import math
12
+ import pygame
13
+
14
+ pygame.init()
15
+ pygame.display.set_mode((1, 1))
16
+
17
+ from game.tracks import TRACKS, SCREEN_W, SCREEN_H
18
+
19
+ ACCEL = 0.13
20
+ STEER_DEG = 2.7
21
+
22
+ all_pass = True
23
+
24
+ for track in TRACKS:
25
+ name = f"Lv{track.level}: {track.name}"
26
+ try:
27
+ # 1. Build must not raise
28
+ track.build()
29
+
30
+ # 2. surface not None, correct size
31
+ assert track.surface is not None, "surface is None"
32
+ assert track.surface.get_size() == (SCREEN_W, SCREEN_H), \
33
+ f"surface size {track.surface.get_size()} != ({SCREEN_W},{SCREEN_H})"
34
+
35
+ # 3. mask not None
36
+ assert track.mask is not None, "mask is None"
37
+
38
+ # 4. start_pos is on track
39
+ sx, sy = track.start_pos
40
+ assert track.on_track(sx, sy), \
41
+ f"start_pos {track.start_pos} not on track"
42
+
43
+ # 5. gate_side at start_pos β‰ˆ 0
44
+ gs = track.gate_side(sx, sy)
45
+ assert abs(gs) < 2.0, \
46
+ f"gate_side at start_pos = {gs:.4f}, expected < 2.0"
47
+
48
+ # 6. Simulate 150 steps
49
+ x = float(sx)
50
+ y = float(sy)
51
+ angle = float(track.start_angle)
52
+ speed = 0.0
53
+ max_speed = track.max_speed
54
+
55
+ for step in range(150):
56
+ accel = 1 # constant throttle
57
+ steer = math.sin(step * 0.15) * 0.5 # gentle sinusoidal steer
58
+
59
+ speed_ratio = min(abs(speed) / max_speed, 1.0) if max_speed > 0 else 0
60
+ angle += steer * STEER_DEG * max(0.3, speed_ratio)
61
+ speed = min(speed + ACCEL, max_speed)
62
+ speed = max(0.0, speed - 0.038) # friction
63
+
64
+ rad = math.radians(angle)
65
+ x += speed * math.cos(rad)
66
+ y += speed * math.sin(rad)
67
+
68
+ # on_track check (no crash required)
69
+ _ = track.on_track(x, y)
70
+
71
+ print(f"PASS {name}")
72
+
73
+ except Exception as e:
74
+ print(f"FAIL {name}: {e}")
75
+ all_pass = False
76
+
77
+ print()
78
+ if all_pass:
79
+ print("All 16 tracks PASSED.")
80
+ sys.exit(0)
81
+ else:
82
+ print("Some tracks FAILED.")
83
+ sys.exit(1)
game/tracks.py ADDED
@@ -0,0 +1,397 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """
2
+ tracks.py β€” Track definitions for the curriculum car racer.
3
+
4
+ Angle convention (pygame y-down):
5
+ 0Β° = right (+x)
6
+ 90Β° = down (+y)
7
+ 180Β° = left (-x)
8
+ 270Β° = up (-y)
9
+ """
10
+
11
+ import math
12
+ import pygame
13
+
14
+ SCREEN_W, SCREEN_H = 900, 600
15
+
16
+ # Colours
17
+ C_GRASS = (45, 110, 45)
18
+ C_TRACK = (52, 52, 52)
19
+ C_WHITE = (255, 255, 255)
20
+
21
+
22
+ # ────────────────────────────────────────────────────────────────────────────
23
+ # Geometry helpers
24
+ # ────────────────────────────────────────────────────────────────────────────
25
+
26
+ def _arc(cx, cy, rx, ry, a0_deg, a1_deg, n=24):
27
+ """Return n+1 points along an elliptical arc from a0_deg to a1_deg."""
28
+ pts = []
29
+ for i in range(n + 1):
30
+ t = a0_deg + (a1_deg - a0_deg) * i / n
31
+ rad = math.radians(t)
32
+ x = cx + rx * math.cos(rad)
33
+ y = cy + ry * math.sin(rad)
34
+ pts.append((x, y))
35
+ return pts
36
+
37
+
38
+ def _full_ellipse(cx, cy, rx, ry, n=80, start_deg=90):
39
+ """Return n+1 points of a full ellipse starting at start_deg."""
40
+ return _arc(cx, cy, rx, ry, start_deg, start_deg + 360, n)
41
+
42
+
43
+ def _dense_poly(corners, step=20, segment_widths=None):
44
+ """
45
+ Sample a closed straight-segment polygon at ~step-px intervals.
46
+ Analogous to _arc() for polygon tracks: produces dense waypoints so the
47
+ +10 lookahead in CarEnv._obs() gives meaningful corner anticipation.
48
+
49
+ If segment_widths (one value per corner segment) is provided, returns
50
+ (waypoints, expanded_widths) with widths broadcast to the dense point list.
51
+ Otherwise returns just the waypoints list.
52
+ """
53
+ result = []
54
+ expanded_sw = [] if segment_widths is not None else None
55
+ n = len(corners)
56
+ for i in range(n):
57
+ x0, y0 = corners[i]
58
+ x1, y1 = corners[(i + 1) % n]
59
+ seg_len = math.hypot(x1 - x0, y1 - y0)
60
+ n_pts = max(2, int(seg_len / step))
61
+ for k in range(n_pts):
62
+ t = k / n_pts
63
+ result.append((x0 + t * (x1 - x0), y0 + t * (y1 - y0)))
64
+ if expanded_sw is not None:
65
+ expanded_sw.extend([segment_widths[i]] * n_pts)
66
+ if segment_widths is not None:
67
+ return result, expanded_sw
68
+ return result
69
+
70
+
71
+ def _ipts(pts):
72
+ """Convert float point list to integer tuples."""
73
+ return [(int(round(x)), int(round(y))) for x, y in pts]
74
+
75
+
76
+ # ────────────────────────────────────────────────────────────────────────────
77
+ # TrackDef
78
+ # ────────────────────────────────────────────────────────────────────────────
79
+
80
+ class TrackDef:
81
+ def __init__(self, level, name, waypoints, width, start_pos, start_angle, max_speed,
82
+ segment_widths=None):
83
+ self.level = level
84
+ self.name = name
85
+ self.waypoints = waypoints # list of (x,y) floats
86
+ self.width = width
87
+ # Per-segment widths for variable-width tracks (one value per waypoint,
88
+ # applied to the segment FROM that waypoint TO the next).
89
+ # None = uniform self.width everywhere.
90
+ self.segment_widths = segment_widths
91
+ self.start_pos = start_pos # (x, y) floats
92
+ self.start_angle = start_angle # degrees
93
+ self.max_speed = max_speed
94
+
95
+ self.surface = None
96
+ self.mask = None
97
+ self.hud_corner = (8, 8) # default; updated after build()
98
+
99
+ # Unit vector in start_angle direction (for gate_side)
100
+ rad = math.radians(start_angle)
101
+ self._gate_dx = math.cos(rad)
102
+ self._gate_dy = math.sin(rad)
103
+
104
+ # ── Reward metadata (computed once here, used by CarEnv) ─────────────
105
+ # Perimeter of the waypoint polygon = approximate track centerline length
106
+ self.optimal_dist = sum(
107
+ math.hypot(waypoints[(i + 1) % len(waypoints)][0] - waypoints[i][0],
108
+ waypoints[(i + 1) % len(waypoints)][1] - waypoints[i][1])
109
+ for i in range(len(waypoints))
110
+ )
111
+
112
+ # Expected lap time (frames) at 70 % of max speed β€” accounts for corners
113
+ self.par_time_steps = self.optimal_dist / (max_speed * 0.70)
114
+
115
+ # Difficulty multiplier: narrow + fast = harder.
116
+ # For variable-width tracks the *choke* (minimum segment) sets difficulty.
117
+ _BASE_WIDTH = 115.0
118
+ _BASE_SPEED = 3.0
119
+ eff_w = min(segment_widths) if segment_widths else width
120
+ self.complexity = (_BASE_WIDTH / eff_w) * (max_speed / _BASE_SPEED)
121
+
122
+ # Road width at the start/finish line (for checkered flag rendering).
123
+ # For variable-width tracks, find the width of the segment nearest to start.
124
+ if segment_widths is not None:
125
+ sx, sy = start_pos
126
+ nearest = min(range(len(waypoints)),
127
+ key=lambda i: math.hypot(waypoints[i][0] - sx,
128
+ waypoints[i][1] - sy))
129
+ self._start_road_width = segment_widths[nearest]
130
+ else:
131
+ self._start_road_width = width
132
+
133
+ def _best_hud_corner(self, panel_w, panel_h, margin=8):
134
+ """Return (x, y) of the screen corner with fewest track pixels under the HUD panel."""
135
+ corners = [
136
+ (margin, margin),
137
+ (SCREEN_W - panel_w - margin, margin),
138
+ (margin, SCREEN_H - panel_h - margin),
139
+ (SCREEN_W - panel_w - margin, SCREEN_H - panel_h - margin),
140
+ ]
141
+ best_pos, best_count = corners[0], float('inf')
142
+ for cx, cy in corners:
143
+ count = sum(
144
+ 1
145
+ for px in range(cx, cx + panel_w, 6)
146
+ for py in range(cy, cy + panel_h, 6)
147
+ if self.mask.get_at((px, py))[0] > 128
148
+ )
149
+ if count < best_count:
150
+ best_count, best_pos = count, (cx, cy)
151
+ return best_pos
152
+
153
+ def build(self):
154
+ """Draw the track onto self.surface and build self.mask."""
155
+ BORDER = 6 # white border thickness on each edge (pixels)
156
+
157
+ surf = pygame.Surface((SCREEN_W, SCREEN_H))
158
+ surf.fill(C_GRASS)
159
+
160
+ ipts_list = _ipts(self.waypoints)
161
+ n = len(ipts_list)
162
+
163
+ if self.segment_widths is None:
164
+ # ── Uniform-width path (original behaviour) ──────────────────────
165
+ r = self.width // 2
166
+ r_out = r + BORDER
167
+ pygame.draw.lines(surf, C_WHITE, True, ipts_list, self.width + BORDER * 2)
168
+ for pt in ipts_list:
169
+ pygame.draw.circle(surf, C_WHITE, pt, r_out)
170
+ pygame.draw.lines(surf, C_TRACK, True, ipts_list, self.width)
171
+ for pt in ipts_list:
172
+ pygame.draw.circle(surf, C_TRACK, pt, r)
173
+ else:
174
+ # ── Variable-width path ───────────────────────────────────────────
175
+ # At each waypoint junction the circle radius is the max of the
176
+ # incoming and outgoing segment widths, ensuring no gaps at
177
+ # wide→narrow or narrow→wide transitions.
178
+ sw = self.segment_widths
179
+
180
+ # Pass 1: white outer strip
181
+ for i in range(n):
182
+ j = (i + 1) % n
183
+ w = sw[i] + BORDER * 2
184
+ w_p = sw[(i - 1) % n] + BORDER * 2
185
+ pygame.draw.line(surf, C_WHITE, ipts_list[i], ipts_list[j], w)
186
+ pygame.draw.circle(surf, C_WHITE, ipts_list[i], max(w, w_p) // 2)
187
+
188
+ # Pass 2: grey tarmac
189
+ for i in range(n):
190
+ j = (i + 1) % n
191
+ w = sw[i]
192
+ w_p = sw[(i - 1) % n]
193
+ pygame.draw.line(surf, C_TRACK, ipts_list[i], ipts_list[j], w)
194
+ pygame.draw.circle(surf, C_TRACK, ipts_list[i], max(w, w_p) // 2)
195
+
196
+ # Checkered start / finish line across the full road width
197
+ self._draw_start_finish(surf)
198
+ self.surface = surf
199
+
200
+ # Mask: covers the full road width (including border) so on_track
201
+ # returns True all the way to the white edge lines.
202
+ mask_surf = pygame.Surface((SCREEN_W, SCREEN_H))
203
+ mask_surf.fill((0, 0, 0))
204
+ if self.segment_widths is None:
205
+ r_out = self.width // 2 + BORDER
206
+ pygame.draw.lines(mask_surf, C_WHITE, True, ipts_list,
207
+ self.width + BORDER * 2)
208
+ for pt in ipts_list:
209
+ pygame.draw.circle(mask_surf, C_WHITE, pt, r_out)
210
+ else:
211
+ sw = self.segment_widths
212
+ for i in range(n):
213
+ j = (i + 1) % n
214
+ w = sw[i] + BORDER * 2
215
+ w_p = sw[(i - 1) % n] + BORDER * 2
216
+ pygame.draw.line(mask_surf, C_WHITE, ipts_list[i], ipts_list[j], w)
217
+ pygame.draw.circle(mask_surf, C_WHITE, ipts_list[i], max(w, w_p) // 2)
218
+ self.mask = mask_surf
219
+ self.hud_corner = self._best_hud_corner(330, 175)
220
+
221
+ def _draw_start_finish(self, surf):
222
+ """
223
+ Checkered black/white flag pattern across the track at start_pos,
224
+ perpendicular to the driving direction. 2 rows Γ— N columns of 10 px cells.
225
+ """
226
+ CELL = 10
227
+ ROWS = 2
228
+ sx, sy = self.start_pos
229
+
230
+ # Unit vectors: across the track (perp) and along the track (along)
231
+ perp_rad = math.radians(self.start_angle + 90)
232
+ along_rad = math.radians(self.start_angle)
233
+ perp = (math.cos(perp_rad), math.sin(perp_rad))
234
+ along = (math.cos(along_rad), math.sin(along_rad))
235
+
236
+ n_cols = self._start_road_width // CELL + 4 # slightly wider than road
237
+ half = n_cols / 2.0
238
+
239
+ for row in range(ROWS):
240
+ v = (row - ROWS / 2.0 + 0.5) * CELL # offset along driving dir
241
+ for col in range(-int(half) - 1, int(half) + 2):
242
+ u = col * CELL # offset across track
243
+ color = (255, 255, 255) if (row + col) % 2 == 0 else (0, 0, 0)
244
+ # Four corners of this cell in screen space
245
+ pts = []
246
+ for du, dv in [(-CELL/2, -CELL/2), (CELL/2, -CELL/2),
247
+ (CELL/2, CELL/2), (-CELL/2, CELL/2)]:
248
+ px = sx + (u + du) * perp[0] + (v + dv) * along[0]
249
+ py = sy + (u + du) * perp[1] + (v + dv) * along[1]
250
+ pts.append((int(px), int(py)))
251
+ pygame.draw.polygon(surf, color, pts)
252
+
253
+ def on_track(self, x, y):
254
+ """Return True if pixel (x, y) is on the track mask."""
255
+ if self.mask is None:
256
+ return False
257
+ ix, iy = int(round(x)), int(round(y))
258
+ if ix < 0 or iy < 0 or ix >= SCREEN_W or iy >= SCREEN_H:
259
+ return False
260
+ color = self.mask.get_at((ix, iy))
261
+ # White = on track
262
+ return color[0] > 128
263
+
264
+ def gate_side(self, x, y):
265
+ """
266
+ Dot product of (pos - start_pos) with start direction unit vector.
267
+ Positive = ahead of gate, negative = behind gate.
268
+ """
269
+ dx = x - self.start_pos[0]
270
+ dy = y - self.start_pos[1]
271
+ return dx * self._gate_dx + dy * self._gate_dy
272
+
273
+
274
+ # ────────────────────────────────────────────────────────────────────────────
275
+ # Track builders
276
+ # ────────────────────────────────────────────────────────────────────────────
277
+
278
+ def _build_all_tracks():
279
+ tracks = []
280
+
281
+ # ── GROUP 1: Full ellipses ───────────────────────────────────────────────
282
+
283
+ # 1. Wide Oval
284
+ wp = _full_ellipse(450, 300, 370, 215, n=80, start_deg=90)
285
+ tracks.append(TrackDef(
286
+ level=1, name="Wide Oval",
287
+ waypoints=wp, width=115,
288
+ start_pos=(450, 515), start_angle=180, max_speed=3.0
289
+ ))
290
+
291
+ # 2. Standard Oval
292
+ wp = _full_ellipse(450, 300, 330, 195, n=80, start_deg=90)
293
+ tracks.append(TrackDef(
294
+ level=2, name="Standard Oval",
295
+ waypoints=wp, width=85,
296
+ start_pos=(450, 495), start_angle=180, max_speed=3.5
297
+ ))
298
+
299
+ # 3. Narrow Oval
300
+ wp = _full_ellipse(450, 300, 320, 185, n=80, start_deg=90)
301
+ tracks.append(TrackDef(
302
+ level=3, name="Narrow Oval",
303
+ waypoints=wp, width=58,
304
+ start_pos=(450, 485), start_angle=180, max_speed=3.5
305
+ ))
306
+
307
+ # 4. Superspeedway
308
+ wp = _full_ellipse(450, 300, 395, 160, n=80, start_deg=90)
309
+ tracks.append(TrackDef(
310
+ level=4, name="Superspeedway",
311
+ waypoints=wp, width=85,
312
+ start_pos=(450, 460), start_angle=180, max_speed=4.5
313
+ ))
314
+
315
+ # ── GROUP 2: Rounded rectangles ─────────────────────────────────────────
316
+
317
+ # 5. Rounded Rectangle
318
+ # TL corner at (250,230), TR at (650,230), BR at (650,370), BL at (250,370), r=130
319
+ # BUT with r=130, bottom of BR arc = 370+130=500, BL bottom = 370+130=500
320
+ # arcs: TL 180β†’270, TR 270β†’360, BR 0β†’90, BL 90β†’180
321
+ tl_arc = _arc(250, 230, 130, 130, 180, 270, 24) # (120,230)β†’(250,100) wait...
322
+ # TL center (250,230): 180Β° β†’ (250-130,230)=(120,230), 270Β° β†’ (250,230-130)=(250,100)
323
+ tr_arc = _arc(650, 230, 130, 130, 270, 360, 24) # (650,100)β†’(780,230)
324
+ br_arc = _arc(650, 370, 130, 130, 0, 90, 24) # (780,370)β†’(650,500)
325
+ bl_arc = _arc(250, 370, 130, 130, 90, 180, 24) # (250,500)β†’(120,370)
326
+ wp = tl_arc + tr_arc + br_arc + bl_arc
327
+ tracks.append(TrackDef(
328
+ level=5, name="Rounded Rectangle",
329
+ waypoints=wp, width=90,
330
+ start_pos=(450, 500), start_angle=180, max_speed=3.5
331
+ ))
332
+
333
+ # 6. Stadium Oval
334
+ left_arc = _arc(200, 300, 120, 120, 90, 270, 24) # (200,420)β†’(200,180)
335
+ right_arc = _arc(700, 300, 120, 120, 270, 450, 24) # (700,180)β†’(700,420)
336
+ wp = left_arc + right_arc
337
+ tracks.append(TrackDef(
338
+ level=6, name="Stadium Oval",
339
+ waypoints=wp, width=80,
340
+ start_pos=(450, 420), start_angle=180, max_speed=4.0
341
+ ))
342
+
343
+ # 7. Tight Rectangle
344
+ # TL=(185,195), TR=(715,195), BR=(715,405), BL=(185,405), r=65
345
+ tl_arc = _arc(185, 195, 65, 65, 180, 270, 24)
346
+ tr_arc = _arc(715, 195, 65, 65, 270, 360, 24)
347
+ br_arc = _arc(715, 405, 65, 65, 0, 90, 24)
348
+ bl_arc = _arc(185, 405, 65, 65, 90, 180, 24)
349
+ wp = tl_arc + tr_arc + br_arc + bl_arc
350
+ tracks.append(TrackDef(
351
+ level=7, name="Tight Rectangle",
352
+ waypoints=wp, width=65,
353
+ start_pos=(450, 470), start_angle=180, max_speed=3.5
354
+ ))
355
+
356
+ # 8. Small Oval
357
+ wp = _full_ellipse(450, 300, 265, 165, n=80, start_deg=90)
358
+ tracks.append(TrackDef(
359
+ level=8, name="Small Oval",
360
+ waypoints=wp, width=60,
361
+ start_pos=(450, 465), start_angle=180, max_speed=3.2
362
+ ))
363
+
364
+ # ── GROUP 3: Two half-arcs ───────────────────────────────────────────────
365
+
366
+ # 9. Hairpin Track
367
+ # Counter-clockwise to match all other tracks (start_angle=180Β°, facing left).
368
+ # arc2_rev: left tight hairpin (220,440)β†’(140,300)β†’(220,160)
369
+ arc2_rev = _arc(220, 300, 80, 140, 90, 270, 24)
370
+ # arc1_rev: right gentle (700,160)β†’(820,300)β†’(700,440)
371
+ arc1_rev = _arc(700, 300, 120, 140, -90, 90, 24)
372
+ wp = arc2_rev + arc1_rev
373
+ tracks.append(TrackDef(
374
+ level=9, name="Hairpin Track",
375
+ waypoints=wp, width=75,
376
+ start_pos=(460, 440), start_angle=180.0, max_speed=3.5
377
+ ))
378
+
379
+ # 10. Chicane Track
380
+ # Rounded rect with chicane on bottom
381
+ tl_arc = _arc(250, 240, 100, 100, 180, 270, 24)
382
+ tr_arc = _arc(650, 240, 100, 100, 270, 360, 24)
383
+ br_arc = _arc(650, 360, 100, 100, 0, 90, 24) # ends at (650,460)
384
+ bl_arc = _arc(250, 360, 100, 100, 90, 180, 24) # starts at (250,460)
385
+ # Chicane inserted between br_arc end and bl_arc start
386
+ chicane = [(650, 460), (575, 460), (545, 498), (450, 498), (355, 498), (325, 460), (250, 460)]
387
+ wp = tl_arc + tr_arc + br_arc + chicane + bl_arc
388
+ tracks.append(TrackDef(
389
+ level=10, name="Chicane Track",
390
+ waypoints=wp, width=70,
391
+ start_pos=(450, 498), start_angle=180, max_speed=3.5
392
+ ))
393
+
394
+ return tracks
395
+
396
+
397
+ TRACKS = _build_all_tracks()