Spaces:
Sleeping
Sleeping
Upload 7 files
Browse files- game/README.md +144 -0
- game/__init__.py +1 -0
- game/curriculum_game.py +405 -0
- game/oval_racer.py +246 -0
- game/rl_splits.py +625 -0
- game/test_tracks.py +83 -0
- game/tracks.py +397 -0
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()
|