Compare commits

...

39 Commits

Author SHA1 Message Date
cd866cc511 feat: initial implementation of image capture 2025-10-27 18:41:55 +01:00
9f86d060f0 chore: change logo in README 2025-10-27 17:39:08 +01:00
e2f4a0d2a5 chore: add README 2025-10-27 17:23:04 +01:00
bc96cea2b9 feat: change record file export to use LZMA 2025-10-27 17:22:08 +01:00
8d35f76b56 fix: correctly receive all chunks in recorder 2025-10-24 22:31:58 +02:00
97f9765705 fix: make bot initializable without recorder 2025-10-24 22:28:50 +02:00
fa61e27825 chore: add record files to gitignore 2025-10-24 20:44:06 +02:00
b60a0aba4f feat: add bot class 2025-10-24 20:43:41 +02:00
ae02ddefb0 fix: rename "Reset" button to "Rollback" 2025-10-24 19:42:36 +02:00
f1fadd123f feat: add rollback button 2025-10-24 19:27:58 +02:00
8b7927a3c5 fix: correctly shutdown QTimers 2025-10-24 19:03:49 +02:00
62de92e7a2 feat: add control by key through recorder 2025-10-24 19:03:28 +02:00
8ad97785b8 feat: add record saving 2025-10-24 18:15:10 +02:00
db112ada4c feat: add snapshot recording 2025-10-24 18:14:08 +02:00
8542ee81e7 feat: add connected indicator on car 2025-10-22 23:57:41 +02:00
f91a4e8d61 feat: implement controller interface 2025-10-22 23:57:23 +02:00
bf1935fd7e feat: add remote controller server 2025-10-22 22:17:30 +02:00
784d594a3b feat: add Command classes 2025-10-22 22:17:05 +02:00
14c9fcdedb fix: minor issues in Snapshot class 2025-10-22 22:11:28 +02:00
73d815b625 feat: add recorder UI 2025-10-21 23:25:57 +02:00
eb06c114d2 feat: add Snapshot class 2025-10-21 23:15:48 +02:00
54f33b6572 fix: move car lower on screen 2025-10-20 19:34:04 +02:00
91e93759e8 feat: add reset shortcut 2025-10-20 00:07:40 +02:00
04ac674982 feat: add raycasts 2025-10-19 02:46:38 +02:00
8ca15eaa78 feat: add strips on road 2025-10-19 02:18:50 +02:00
8989341714 feat: add delta time 2025-10-18 22:06:19 +02:00
dde690818b feat: add simple track generator 2025-10-18 21:56:14 +02:00
f1ae12d0ec feat: add speedometer 2025-10-18 21:51:33 +02:00
ff61a7fad6 feat: add simple track 2025-10-18 21:03:49 +02:00
09f70223b8 feat: add collisions 2025-10-18 21:02:24 +02:00
45ed1c85c8 refactor: move car rendering to Car 2025-10-18 21:01:52 +02:00
2b20582b87 refactor: improve rendering process 2025-10-18 15:10:13 +02:00
6805e69509 feat: add basic controls 2025-10-18 02:02:37 +02:00
9c5f39b669 feat: render car 2025-10-18 01:49:40 +02:00
adb25e6ef6 feat: draw track borders 2025-10-18 01:43:40 +02:00
6276f97cce feat: add basic game loop 2025-10-18 01:32:18 +02:00
da8c64624f feat: add track classes 2025-10-18 01:32:04 +02:00
e154a1fde9 feat: add camera class 2025-10-18 01:31:36 +02:00
eb10933f4b feat: add car class 2025-10-18 01:31:08 +02:00
32 changed files with 2563 additions and 6 deletions

5
.gitignore vendored
View File

@@ -9,4 +9,7 @@ wheels/
# Virtual environments
.venv
.vscode
.vscode
records
*.rec

View File

@@ -0,0 +1,59 @@
<p align="center">
<img src="logo.png" width="300">
</p>
# Rally Racer
This repository holds a sandbox driving simulation controllable via a network interface as a machine learning and data collection challenge.
# Installation
From the root of the repository, run
```sh
uv sync
```
To run the game, you can use
```sh
uv run main.py
```
# Generality
Launching [`main.py`](main.py) starts a race with a single car on the provided track.
This track can be controlled either by keyboard (*WASD*) or by a socket interface.
An example of such interface is included in the code in [*`scripts/recorder.py`*](scripts/recorder.py). To run it, simply use the following command:
```sh
uv run -m scripts.recorder
```
# Sensing
The car sensing is available in two commodities: **raycasts** and **images**. These sensing snapshots are sent at 10 Hertz (i.e. 10 times a second). Due to this fact, correct reception of snapshot messages has to be done regularly.
# Communication protocol
A remote controller can be impemented using TCP socket connecting on localhost on port 5000.
Different commands can be issued to the race simulation to control the car.
These commands are declared in [`src/command.py`](src/command.py)
## Car controls
```python
ControlCommand(control: CarControl, active: bool)
```
To simulate key press and control the car.
# Controls
- <kbd>W</kbd> Move forward
- <kbd>S</kbd> Brake / move backward
- <kbd>A</kbd> Turn left
- <kbd>D</kbd> Turn right
- <kbd>F</kbd> Toggle FPS indicator
- <kbd>V</kbd> Toggle speedometer
- <kbd>R</kbd> Reset car
- <kbd>C</kbd> Toggle raycasts visibility
- <kbd>Esc</kbd> Quit
# Credits
This project is based on the repository [https://github.com/ISC-HEI/RallyRobotPilot_2025](https://github.com/ISC-HEI/RallyRobotPilot_2025), which is in turn based on [https://github.com/mandaw2014/Rally](https://github.com/mandaw2014/Rally)

BIN
assets/fonts/Ubuntu-M.ttf Normal file

Binary file not shown.

BIN
assets/fonts/Ubuntu-R.ttf Normal file

Binary file not shown.

View File

@@ -0,0 +1,13 @@
{
"name": "Simple Track",
"start": {
"pos": [
30,
0
],
"direction": [
0,
1
]
}
}

View File

@@ -0,0 +1,259 @@
[
{
"type": "road",
"pts": [
[
30.0,
0.0,
1.0,
0.0,
1
],
[
29.544,
5.209,
0.985,
0.174,
1
],
[
28.191,
10.261,
0.94,
0.342,
1
],
[
25.981,
15.0,
0.866,
0.5,
1
],
[
22.981,
19.284,
0.766,
0.643,
1
],
[
19.284,
22.981,
0.643,
0.766,
1
],
[
15.0,
25.981,
0.5,
0.866,
1
],
[
10.261,
28.191,
0.342,
0.94,
1
],
[
5.209,
29.544,
0.174,
0.985,
1
],
[
0.0,
30.0,
0.0,
1.0,
1
],
[
-5.209,
29.544,
-0.174,
0.985,
1
],
[
-10.261,
28.191,
-0.342,
0.94,
1
],
[
-15.0,
25.981,
-0.5,
0.866,
1
],
[
-19.284,
22.981,
-0.643,
0.766,
1
],
[
-22.981,
19.284,
-0.766,
0.643,
1
],
[
-25.981,
15.0,
-0.866,
0.5,
1
],
[
-28.191,
10.261,
-0.94,
0.342,
1
],
[
-29.544,
5.209,
-0.985,
0.174,
1
],
[
-30.0,
0.0,
-1.0,
0.0,
1
],
[
-29.544,
-5.209,
-0.985,
-0.174,
1
],
[
-28.191,
-10.261,
-0.94,
-0.342,
1
],
[
-25.981,
-15.0,
-0.866,
-0.5,
1
],
[
-22.981,
-19.284,
-0.766,
-0.643,
1
],
[
-19.284,
-22.981,
-0.643,
-0.766,
1
],
[
-15.0,
-25.981,
-0.5,
-0.866,
1
],
[
-10.261,
-28.191,
-0.342,
-0.94,
1
],
[
-5.209,
-29.544,
-0.174,
-0.985,
1
],
[
-0.0,
-30.0,
-0.0,
-1.0,
1
],
[
5.209,
-29.544,
0.174,
-0.985,
1
],
[
10.261,
-28.191,
0.342,
-0.94,
1
],
[
15.0,
-25.981,
0.5,
-0.866,
1
],
[
19.284,
-22.981,
0.643,
-0.766,
1
],
[
22.981,
-19.284,
0.766,
-0.643,
1
],
[
25.981,
-15.0,
0.866,
-0.5,
1
],
[
28.191,
-10.261,
0.94,
-0.342,
1
],
[
29.544,
-5.209,
0.985,
-0.174,
1
]
]
}
]

143
car.svg Normal file
View File

@@ -0,0 +1,143 @@
<?xml version="1.0" encoding="UTF-8" standalone="no"?>
<!-- Created with Inkscape (http://www.inkscape.org/) -->
<svg
width="64"
height="64"
viewBox="0 0 64 64.000003"
version="1.1"
id="svg1"
inkscape:version="1.4.2 (ebf0e940d0, 2025-05-08)"
sodipodi:docname="car.svg"
xmlns:inkscape="http://www.inkscape.org/namespaces/inkscape"
xmlns:sodipodi="http://sodipodi.sourceforge.net/DTD/sodipodi-0.dtd"
xmlns="http://www.w3.org/2000/svg"
xmlns:svg="http://www.w3.org/2000/svg">
<sodipodi:namedview
id="namedview1"
pagecolor="#ffffff"
bordercolor="#000000"
borderopacity="0.25"
inkscape:showpageshadow="2"
inkscape:pageopacity="0.0"
inkscape:pagecheckerboard="0"
inkscape:deskcolor="#d1d1d1"
inkscape:document-units="mm"
showgrid="true"
inkscape:zoom="11.313709"
inkscape:cx="42.470601"
inkscape:cy="34.957591"
inkscape:window-width="1920"
inkscape:window-height="1016"
inkscape:window-x="0"
inkscape:window-y="27"
inkscape:window-maximized="1"
inkscape:current-layer="layer1">
<inkscape:grid
id="grid1"
units="px"
originx="0"
originy="0"
spacingx="1"
spacingy="1"
empcolor="#0099e5"
empopacity="0.30196078"
color="#0099e5"
opacity="0.14901961"
empspacing="8"
enabled="true"
visible="true" />
</sodipodi:namedview>
<defs
id="defs1">
<inkscape:path-effect
effect="fillet_chamfer"
id="path-effect3"
is_visible="true"
lpeversion="1"
nodesatellites_param="F,0,0,1,0,1.0000002,0,1 @ F,0,1,1,0,1.0000002,0,1 @ F,0,1,1,0,1.0000002,0,1 @ F,0,1,1,0,1.0000002,0,1"
radius="0"
unit="px"
method="auto"
mode="F"
chamfer_steps="1"
flexible="false"
use_knot_distance="true"
apply_no_radius="true"
apply_with_radius="true"
only_selected="false"
hide_knots="false" />
<inkscape:path-effect
effect="fillet_chamfer"
id="path-effect2"
is_visible="true"
lpeversion="1"
nodesatellites_param="F,0,1,1,0,1.0000001,0,1 @ F,0,1,1,0,1.0000001,0,1 @ F,0,0,1,0,0,0,1 @ F,0,0,1,0,0,0,1 @ F,0,0,1,0,1.0000001,0,1 @ F,0,1,1,0,1.0000001,0,1 @ F,0,0,1,0,0,0,1 @ F,0,0,1,0,0,0,1"
radius="0"
unit="px"
method="auto"
mode="F"
chamfer_steps="1"
flexible="false"
use_knot_distance="true"
apply_no_radius="true"
apply_with_radius="true"
only_selected="false"
hide_knots="false" />
</defs>
<g
inkscape:label="Calque 1"
inkscape:groupmode="layer"
id="layer1">
<g
id="g5"
inkscape:label="car"
transform="translate(-5.9999998,-7.9999997)">
<g
id="g4"
inkscape:label="wheels">
<path
style="fill:#000000;stroke-linecap:round;stroke-linejoin:round"
d="m 25.000001,31.999999 v -2 h 4 v 2 z"
id="path2"
sodipodi:nodetypes="ccccc" />
<path
style="fill:#000000;stroke-linecap:round;stroke-linejoin:round"
d="m 43.000001,31.999999 v -2 h 4 v 2 z"
id="path2-3"
sodipodi:nodetypes="ccccc" />
<path
style="fill:#000000;stroke-linecap:round;stroke-linejoin:round"
d="m 25.000001,49.999999 v -2 h 4 v 2 z"
id="path2-1"
sodipodi:nodetypes="ccccc" />
<path
style="fill:#000000;stroke-linecap:round;stroke-linejoin:round"
d="m 43.000001,49.999999 v -2 h 4 v 2 z"
id="path2-3-2"
sodipodi:nodetypes="ccccc" />
</g>
<path
style="fill:#e14324;fill-opacity:1;stroke-linecap:round;stroke-linejoin:round"
d="m 16,33 v 13.999999 a 1.1327823,1.1327823 48.562508 0 0 0.992278,1.124035 l 7.007723,0.875965 h 24 l 11.003454,-0.916955 a 1.0867997,1.0867997 132.61818 0 0 0.996546,-1.083045 v -14 A 1.0867996,1.0867996 47.381818 0 0 59.003455,31.916954 L 48.000001,31 h -24 l -7.007723,0.875965 A 1.1327823,1.1327823 131.43749 0 0 16,33 Z"
id="path1"
inkscape:path-effect="#path-effect2"
inkscape:original-d="m 16,32 v 15.999999 l 8.000001,1 h 24 l 12,-1 v -16 L 48.000001,31 h -24 z"
inkscape:label="body" />
<path
style="fill:#53170b;fill-opacity:1;stroke-linecap:round;stroke-linejoin:round"
d="m 50.000001,33.500001 v 13 a 1.0867994,1.0867994 132.61819 0 1 -0.996546,1.083045 l -4.006908,0.333908 A 0.92013337,0.92013337 42.618189 0 1 44.000001,46.999999 V 33 a 0.92013291,0.92013291 137.38183 0 1 0.996546,-0.916954 l 4.006908,0.333909 a 1.0867999,1.0867999 47.381826 0 1 0.996546,1.083046 z"
id="path3"
inkscape:path-effect="#path-effect3"
inkscape:original-d="m 50.000001,32.500001 v 15 l -6,0.499998 V 32 Z"
sodipodi:nodetypes="ccccc"
inkscape:label="windshield" />
<path
style="fill:#af3116;fill-opacity:1;stroke-linecap:round;stroke-linejoin:round"
d="m 29.000001,46.999999 c -4,0 -8.000001,0 -11.000001,-1 v -12 c 3,-1 7.000001,-1 11.000001,-1 z"
id="path4"
sodipodi:nodetypes="ccccc"
inkscape:label="back_window" />
</g>
</g>
</svg>

After

Width:  |  Height:  |  Size: 5.2 KiB

BIN
logo.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 30 KiB

202
logo.svg Normal file
View File

@@ -0,0 +1,202 @@
<?xml version="1.0" encoding="UTF-8" standalone="no"?>
<!-- Created with Inkscape (http://www.inkscape.org/) -->
<svg
width="64"
height="64"
viewBox="0 0 64 64.000003"
version="1.1"
id="svg1"
inkscape:version="1.4.2 (ebf0e940d0, 2025-05-08)"
sodipodi:docname="logo.svg"
inkscape:export-filename="logo.png"
inkscape:export-xdpi="768"
inkscape:export-ydpi="768"
xmlns:inkscape="http://www.inkscape.org/namespaces/inkscape"
xmlns:sodipodi="http://sodipodi.sourceforge.net/DTD/sodipodi-0.dtd"
xmlns="http://www.w3.org/2000/svg"
xmlns:svg="http://www.w3.org/2000/svg">
<sodipodi:namedview
id="namedview1"
pagecolor="#ffffff"
bordercolor="#000000"
borderopacity="0.25"
inkscape:showpageshadow="2"
inkscape:pageopacity="0.0"
inkscape:pagecheckerboard="0"
inkscape:deskcolor="#d1d1d1"
inkscape:document-units="mm"
showgrid="true"
inkscape:zoom="11.313709"
inkscape:cx="31.952386"
inkscape:cy="30.803338"
inkscape:window-width="1920"
inkscape:window-height="1016"
inkscape:window-x="0"
inkscape:window-y="27"
inkscape:window-maximized="1"
inkscape:current-layer="layer1"
inkscape:export-bgcolor="#ffffffff">
<inkscape:grid
id="grid1"
units="px"
originx="0"
originy="0"
spacingx="1"
spacingy="1"
empcolor="#0099e5"
empopacity="0.30196078"
color="#0099e5"
opacity="0.14901961"
empspacing="8"
enabled="true"
visible="true" />
</sodipodi:namedview>
<defs
id="defs1">
<inkscape:path-effect
effect="fillet_chamfer"
id="path-effect3"
is_visible="true"
lpeversion="1"
nodesatellites_param="F,0,0,1,0,1.0000002,0,1 @ F,0,1,1,0,1.0000002,0,1 @ F,0,1,1,0,1.0000002,0,1 @ F,0,1,1,0,1.0000002,0,1"
radius="0"
unit="px"
method="auto"
mode="F"
chamfer_steps="1"
flexible="false"
use_knot_distance="true"
apply_no_radius="true"
apply_with_radius="true"
only_selected="false"
hide_knots="false" />
<inkscape:path-effect
effect="fillet_chamfer"
id="path-effect2"
is_visible="true"
lpeversion="1"
nodesatellites_param="F,0,1,1,0,1.0000001,0,1 @ F,0,1,1,0,1.0000001,0,1 @ F,0,0,1,0,0,0,1 @ F,0,0,1,0,0,0,1 @ F,0,0,1,0,1.0000001,0,1 @ F,0,1,1,0,1.0000001,0,1 @ F,0,0,1,0,0,0,1 @ F,0,0,1,0,0,0,1"
radius="0"
unit="px"
method="auto"
mode="F"
chamfer_steps="1"
flexible="false"
use_knot_distance="true"
apply_no_radius="true"
apply_with_radius="true"
only_selected="false"
hide_knots="false" />
</defs>
<g
inkscape:label="Calque 1"
inkscape:groupmode="layer"
id="layer1">
<g
id="g14"
transform="matrix(1.12,0,0,1.12,-6.6400002,3.1600025)">
<path
id="path13"
style="fill:none;fill-opacity:1;stroke:#000000;stroke-linecap:round;stroke-linejoin:round;stroke-opacity:1"
d="m 38,43.499998 -11,-21 m 0,0 -9,20 M 50,32.749996 38,43.499998 M 53,16.749997 50,32.749996 M 40.000001,7.9999998 27,22.499998 M 16,7.9999998 27,22.499998"
sodipodi:nodetypes="cccccccccccc" />
<g
id="g13">
<circle
style="fill:#ffffff;fill-opacity:1;stroke:#000000;stroke-linecap:round;stroke-linejoin:round;stroke-opacity:1"
id="path5"
cx="16"
cy="8"
r="3" />
<circle
style="fill:#ffffff;fill-opacity:1;stroke:#000000;stroke-linecap:round;stroke-linejoin:round;stroke-opacity:1"
id="path5-3"
cx="40"
cy="7.9999995"
r="3" />
<circle
style="fill:#ffffff;fill-opacity:1;stroke:#000000;stroke-linecap:round;stroke-linejoin:round;stroke-opacity:1"
id="path5-1"
cx="27"
cy="22.499998"
r="3" />
<circle
style="fill:#ffffff;fill-opacity:1;stroke:#000000;stroke-linecap:round;stroke-linejoin:round;stroke-opacity:1"
id="path5-6"
cx="38"
cy="43.499996"
r="3" />
<circle
style="fill:#ffffff;fill-opacity:1;stroke:#000000;stroke-linecap:round;stroke-linejoin:round;stroke-opacity:1"
id="path5-18"
cx="18"
cy="42.499996"
r="3" />
<circle
style="fill:#ffffff;fill-opacity:1;stroke:#000000;stroke-linecap:round;stroke-linejoin:round;stroke-opacity:1"
id="path5-2"
cx="50"
cy="32.749996"
r="3" />
<circle
style="fill:#ffffff;fill-opacity:1;stroke:#000000;stroke-linecap:round;stroke-linejoin:round;stroke-opacity:1"
id="path5-22"
cx="53"
cy="16.749996"
r="3" />
</g>
<g
id="g5"
inkscape:label="car"
transform="matrix(0.1163734,0.24290774,-0.24290774,0.1163734,38.296019,19.616451)"
style="display:inline">
<g
id="g4"
inkscape:label="wheels">
<path
style="fill:#000000;stroke-linecap:round;stroke-linejoin:round"
d="m 25.000001,31.999999 v -2 h 4 v 2 z"
id="path2"
sodipodi:nodetypes="ccccc" />
<path
style="fill:#000000;stroke-linecap:round;stroke-linejoin:round"
d="m 43.000001,31.999999 v -2 h 4 v 2 z"
id="path2-3"
sodipodi:nodetypes="ccccc" />
<path
style="fill:#000000;stroke-linecap:round;stroke-linejoin:round"
d="m 25.000001,49.999999 v -2 h 4 v 2 z"
id="path2-1"
sodipodi:nodetypes="ccccc" />
<path
style="fill:#000000;stroke-linecap:round;stroke-linejoin:round"
d="m 43.000001,49.999999 v -2 h 4 v 2 z"
id="path2-3-2"
sodipodi:nodetypes="ccccc" />
</g>
<path
style="fill:#e14324;fill-opacity:1;stroke-linecap:round;stroke-linejoin:round"
d="m 16,33 v 13.999999 a 1.1327823,1.1327823 48.562508 0 0 0.992278,1.124035 l 7.007723,0.875965 h 24 l 11.003454,-0.916955 a 1.0867997,1.0867997 132.61818 0 0 0.996546,-1.083045 v -14 A 1.0867996,1.0867996 47.381818 0 0 59.003455,31.916954 L 48.000001,31 h -24 l -7.007723,0.875965 A 1.1327823,1.1327823 131.43749 0 0 16,33 Z"
id="path1"
inkscape:path-effect="#path-effect2"
inkscape:original-d="m 16,32 v 15.999999 l 8.000001,1 h 24 l 12,-1 v -16 L 48.000001,31 h -24 z"
inkscape:label="body" />
<path
style="fill:#53170b;fill-opacity:1;stroke-linecap:round;stroke-linejoin:round"
d="m 50.000001,33.500001 v 13 a 1.0867994,1.0867994 132.61819 0 1 -0.996546,1.083045 l -4.006908,0.333908 A 0.92013337,0.92013337 42.618189 0 1 44.000001,46.999999 V 33 a 0.92013291,0.92013291 137.38183 0 1 0.996546,-0.916954 l 4.006908,0.333909 a 1.0867999,1.0867999 47.381826 0 1 0.996546,1.083046 z"
id="path3"
inkscape:path-effect="#path-effect3"
inkscape:original-d="m 50.000001,32.500001 v 15 l -6,0.499998 V 32 Z"
sodipodi:nodetypes="ccccc"
inkscape:label="windshield" />
<path
style="fill:#af3116;fill-opacity:1;stroke-linecap:round;stroke-linejoin:round"
d="m 29.000001,46.999999 c -4,0 -8.000001,0 -11.000001,-1 v -12 c 3,-1 7.000001,-1 11.000001,-1 z"
id="path4"
sodipodi:nodetypes="ccccc"
inkscape:label="back_window" />
</g>
</g>
</g>
</svg>

After

Width:  |  Height:  |  Size: 7.5 KiB

View File

@@ -4,6 +4,7 @@ from src.game import Game
def main():
print("Welcome to Rally Racer !")
game: Game = Game()
game.mainloop()
if __name__ == "__main__":

View File

@@ -4,4 +4,9 @@ version = "0.1.0"
description = "Rally racing game for ML"
readme = "README.md"
requires-python = ">=3.13"
dependencies = ["pygame>=2.6.1"]
dependencies = [
"numpy>=2.3.4",
"pygame>=2.6.1",
"pyqt6>=6.9.1",
"qasync>=0.28.0",
]

41
scripts/example_bot.py Normal file
View File

@@ -0,0 +1,41 @@
from PyQt6.QtWidgets import QApplication
from src.bot import Bot
from src.command import CarControl
from src.recorder import RecorderWindow
from src.snapshot import Snapshot
class ExampleBot(Bot):
def nn_infer(self, snapshot: Snapshot) -> list[tuple[CarControl, bool]]:
# Do smart NN inference here
return [(CarControl.FORWARD, True)]
def on_snapshot_received(self, snapshot: Snapshot):
controls: list[tuple[CarControl, bool]] = self.nn_infer(snapshot)
for control, active in controls:
self.recorder.on_car_controlled(control, active)
def main():
import sys
def except_hook(cls, exception, traceback):
sys.__excepthook__(cls, exception, traceback)
sys.excepthook = except_hook
app: QApplication = QApplication(sys.argv)
recorder: RecorderWindow = RecorderWindow("localhost", 5000)
bot: ExampleBot = ExampleBot()
bot.set_recorder(recorder)
app.aboutToQuit.connect(recorder.shutdown)
recorder.register_bot(bot)
recorder.show()
app.exec()
if __name__ == "__main__":
main()

23
scripts/recorder.py Normal file
View File

@@ -0,0 +1,23 @@
from PyQt6.QtWidgets import QApplication
from src.recorder import RecorderWindow
def main():
import sys
def except_hook(cls, exception, traceback):
sys.__excepthook__(cls, exception, traceback)
sys.excepthook = except_hook
app = QApplication(sys.argv)
window = RecorderWindow("localhost", 5000)
app.aboutToQuit.connect(window.shutdown)
window.show()
app.exec()
if __name__ == "__main__":
main()

40
scripts/track_gen.py Normal file
View File

@@ -0,0 +1,40 @@
import json
from math import radians
from pathlib import Path
from src.utils import ROOT
from src.vec import Vec
def gen_circle(
folder: Path, center: Vec, radius: float, n_sides: int, width: float = 1
):
with open(folder / "track.json", "w") as f:
pts: list[tuple[float, ...]] = []
for i in range(n_sides):
angle: float = radians(i / n_sides * 360)
v: Vec = Vec(1, 0).rotate(angle)
pos: Vec = center + v * radius
normal: Vec = v
pts.append((pos.x, pos.y, normal.x, normal.y, width))
for i, pt in enumerate(pts):
pts[i] = tuple(round(v, 3) for v in pt)
json.dump([{"type": "road", "pts": pts}], f, indent=4)
with open(folder / "meta.json", "r") as f:
meta: dict = json.load(f)
meta["start"] = {"pos": [radius, 0], "direction": [0, 1]}
with open(folder / "meta.json", "w") as f:
json.dump(meta, f, indent=4)
def main():
folder: Path = ROOT / "assets" / "tracks" / "simple"
gen_circle(folder, Vec(0, 0), 30, 36)
if __name__ == "__main__":
main()

26
src/bot.py Normal file
View File

@@ -0,0 +1,26 @@
from __future__ import annotations
from typing import TYPE_CHECKING, Optional
from src.snapshot import Snapshot
if TYPE_CHECKING:
from src.recorder import RecorderWindow
class Bot:
def __init__(self):
self._recorder: Optional[RecorderWindow] = None
@property
def recorder(self) -> RecorderWindow:
if self._recorder is None:
raise RuntimeError(
"Bot does not have a recorder. Call Bot.set_recorder to set one")
return self._recorder
def set_recorder(self, recorder: RecorderWindow):
self._recorder = recorder
def on_snapshot_received(self, snapshot: Snapshot):
pass

50
src/camera.py Normal file
View File

@@ -0,0 +1,50 @@
from src.vec import Vec
class Camera:
UNIT_RATIO = 150
def __init__(self) -> None:
self.pos: Vec = Vec()
self.up: Vec = Vec(0, -1)
self.size: Vec = Vec(600, 600)
self.zoom: float = 1
def set_pos(self, pos: Vec):
self.pos = pos
def set_direction(self, up: Vec):
self.up = up.normalized
def set_size(self, size: Vec):
self.size = size
@property
def center(self) -> Vec:
return self.size / 2
@property
def car_screen_pos(self) -> Vec:
return Vec(self.size.x / 2, 3 * self.size.y / 4)
def screen2world(self, screen_pos: Vec) -> Vec:
delta: Vec = screen_pos - self.car_screen_pos
delta /= self.zoom * self.UNIT_RATIO
dx: float = delta.x
dy: float = delta.y
v1: Vec = self.up.perp * dx
v2: Vec = self.up * dy
return self.pos + v1 + v2
def world2screen(self, world_pos: Vec) -> Vec:
delta: Vec = world_pos - self.pos
dy: float = -delta.dot(self.up)
dx: float = delta.dot(self.up.perp)
screen_delta: Vec = Vec(dx, dy) * self.zoom * self.UNIT_RATIO
screen_pos: Vec = self.car_screen_pos + screen_delta
return screen_pos
def size2screen(self, size: float) -> float:
return size * self.zoom * self.UNIT_RATIO

181
src/car.py Normal file
View File

@@ -0,0 +1,181 @@
from __future__ import annotations
from math import radians
from typing import TYPE_CHECKING, Optional
import pygame
from src.camera import Camera
from src.remote_controller import RemoteController
from src.utils import get_segments_intersection, segments_intersect
from src.vec import Vec
if TYPE_CHECKING:
from src.game import Game
def sign(x): return 0 if x == 0 else (-1 if x < 0 else 1)
class Car:
MAX_SPEED = 5
MAX_BACK_SPEED = -3
ROTATE_SPEED = 1
COLOR = (230, 150, 80)
CTRL_COLOR = (80, 230, 150)
WIDTH = 0.4
LENGTH = 0.6
COLLISION_MARGIN = 0.4
ACCELERATION = 2
FRICTION = 2.5
N_RAYS = 15
RAYS_FOV = 180
RAYS_MAX_DIST = 100
def __init__(self, game: Game, pos: Vec, direction: Vec) -> None:
self.game: Game = game
self.initial_pos: Vec = pos.copy()
self.initial_dir: Vec = direction.copy()
self.pos: Vec = pos
self.direction: Vec = direction
self.speed: float = 0
self.forward: bool = False
self.backward: bool = False
self.left: bool = False
self.right: bool = False
self.colliding: bool = False
self.rays: list[float] = [0] * self.N_RAYS
self.rays_end: list[Vec] = [Vec() for _ in range(self.N_RAYS)]
self.controller: RemoteController = RemoteController(self.game, self)
self.controller.start_server()
def update(self, dt: float):
if self.forward:
self.speed += self.ACCELERATION * dt
self.speed = min(self.MAX_SPEED, self.speed)
if self.backward:
self.speed -= self.ACCELERATION * 2 * dt
self.speed = max(self.MAX_BACK_SPEED, self.speed)
rotate_angle: float = 0
if self.left:
rotate_angle -= self.ROTATE_SPEED * dt
if self.right:
rotate_angle += self.ROTATE_SPEED * dt
# if self.backward:
# rotate_angle *= -1
if rotate_angle != 0:
self.direction = self.direction.rotate(rotate_angle)
if not self.forward and not self.backward:
fn = max if self.speed >= 0 else min
self.speed -= sign(self.speed) * self.FRICTION * dt
self.speed = fn(0, self.speed)
if abs(self.speed) < 1e-4:
self.speed = 0
self.pos += self.direction * self.speed * dt
def render(self, surf: pygame.Surface, camera: Camera, show_raycasts: bool = False):
if show_raycasts:
pos: Vec = camera.world2screen(self.pos)
for p in self.rays_end:
pygame.draw.line(surf, (255, 0, 0), pos,
camera.world2screen(p), 2)
pts: list[Vec] = self.get_corners()
pts = [camera.world2screen(p) for p in pts]
pygame.draw.polygon(surf, self.COLOR, pts)
if self.controller.is_connected:
pygame.draw.circle(
surf,
self.CTRL_COLOR,
camera.world2screen(self.pos),
camera.size2screen(self.WIDTH / 4),
)
def get_corners(self) -> list[Vec]:
u: Vec = self.direction * self.LENGTH / 2
v: Vec = self.direction.perp * self.WIDTH / 2
pt: Vec = self.pos
p1: Vec = pt + u + v
p2: Vec = pt - u + v
p3: Vec = pt - u - v
p4: Vec = pt + u - v
return [p1, p2, p3, p4]
def check_collisions(self, polygons: list[list[Vec]]):
self.cast_rays(polygons)
self.colliding = False
corners: list[Vec] = self.get_corners()
sides: list[tuple[Vec, Vec]] = [
(corners[i], corners[(i + 1) % 4]) for i in range(4)
]
for polygon in polygons:
n_pts: int = len(polygon)
for i in range(n_pts):
pt1: Vec = polygon[i]
pt2: Vec = polygon[(i + 1) % n_pts]
d: Vec = pt2 - pt1
for s1, s2 in sides:
if segments_intersect(s1, s2, pt1, pt2):
self.colliding = True
self.direction = d.normalized
n: Vec = self.direction.perp
dist: float = (self.pos - pt1).dot(n)
if dist < 0:
n *= -1
dist = -dist
self.speed = 0
self.pos = self.pos + n * \
(self.COLLISION_MARGIN - dist)
return
def cast_rays(self, polygons: list[list[Vec]]):
for i in range(self.N_RAYS):
angle: float = radians(
(i / (self.N_RAYS - 1) - 0.5) * self.RAYS_FOV)
p: Optional[Vec] = self.cast_ray(angle, polygons)
self.rays[i] = self.RAYS_MAX_DIST if p is None else (
p - self.pos).mag()
self.rays_end[i] = self.pos if p is None else p
def cast_ray(self, angle: float, polygons: list[list[Vec]]) -> Optional[Vec]:
v: Vec = self.direction.normalized.rotate(angle)
segments: list[tuple[Vec, Vec]] = []
for polygon in polygons:
n_pts: int = len(polygon)
for i in range(n_pts):
pt1: Vec = polygon[i]
pt2: Vec = polygon[(i + 1) % n_pts]
segments.append((pt1, pt2))
p1: Vec = self.pos
p2: Vec = p1 + v * self.RAYS_MAX_DIST
dist: float = self.RAYS_MAX_DIST
closest: Optional[Vec] = None
for q1, q2 in segments:
p: Optional[Vec] = get_segments_intersection(p1, p2, q1, q2)
if p is not None:
d: float = (p - p1).mag()
if d < dist:
dist = d
closest = p
return closest
def reset(self):
self.pos = self.initial_pos.copy()
self.direction = self.initial_dir.copy()
self.speed = 0

109
src/command.py Normal file
View File

@@ -0,0 +1,109 @@
from __future__ import annotations
import abc
from enum import IntEnum
import struct
from typing import Type
from src.snapshot import Snapshot
class CommandType(IntEnum):
CAR_CONTROL = 0
RECORDING = 1
APPLY_SNAPSHOT = 2
RESET = 3
class CarControl(IntEnum):
FORWARD = 0
BACKWARD = 1
LEFT = 2
RIGHT = 3
class Command(abc.ABC):
TYPE: CommandType
REGISTRY: dict[CommandType, Type[Command]] = {}
def __init_subclass__(cls) -> None:
super().__init_subclass__()
if cls.TYPE in Command.REGISTRY:
raise ValueError(
f"Command type {cls.TYPE} already registered by {Command.REGISTRY[cls.TYPE]}"
)
Command.REGISTRY[cls.TYPE] = cls
def get_payload(self) -> bytes:
return b""
def pack(self) -> bytes:
payload: bytes = self.get_payload()
return struct.pack(">B", self.TYPE) + payload
@staticmethod
def unpack(data: bytes) -> Command:
type: CommandType = CommandType(data[0])
return Command.REGISTRY[type].from_payload(data[1:])
@classmethod
def from_payload(cls, payload: bytes) -> Command:
return cls()
class ControlCommand(Command):
TYPE = CommandType.CAR_CONTROL
__match_args__ = ("control", "active")
def __init__(self, control: CarControl, active: bool) -> None:
super().__init__()
self.control: CarControl = control
self.active: bool = active
def get_payload(self) -> bytes:
return struct.pack(">B", (self.control << 1) | self.active)
@classmethod
def from_payload(cls, payload: bytes) -> Command:
value: int = payload[0]
active: bool = (value & 1) == 1
control: int = value >> 1
return ControlCommand(CarControl(control), active)
class RecordingCommand(Command):
TYPE = CommandType.RECORDING
__match_args__ = ("state",)
def __init__(self, state: bool) -> None:
super().__init__()
self.state: bool = state
def get_payload(self) -> bytes:
return struct.pack(">B", self.state)
@classmethod
def from_payload(cls, payload: bytes) -> Command:
state: bool = struct.unpack(">B", payload)[0]
return RecordingCommand(state)
class ApplySnapshotCommand(Command):
TYPE = CommandType.APPLY_SNAPSHOT
__match_args__ = ("snapshot",)
def __init__(self, snapshot: Snapshot) -> None:
super().__init__()
self.snapshot: Snapshot = snapshot
def get_payload(self) -> bytes:
return self.snapshot.pack()
@classmethod
def from_payload(cls, payload: bytes) -> Command:
snapshot: Snapshot = Snapshot.unpack(payload)
return ApplySnapshotCommand(snapshot)
class ResetCommand(Command):
TYPE = CommandType.RESET

View File

@@ -1,9 +1,145 @@
from math import cos, radians, sin
import pygame
from src.camera import Camera
from src.car import Car
from src.track import Track
from src.utils import ROOT
from src.vec import Vec
class Game:
DEFAULT_SIZE = (1280, 720)
BACKGROUND_COLOR = (80, 80, 80)
MAX_FPS = 60
FPS_COLOR = (255, 0, 0)
def __init__(self):
def __init__(self) -> None:
pygame.init()
self.win: pygame.Surface = pygame.display.set_mode(self.DEFAULT_SIZE)
self.win: pygame.Surface = pygame.display.set_mode(
self.DEFAULT_SIZE, pygame.RESIZABLE
)
self.game_surf: pygame.Surface = pygame.Surface(self.DEFAULT_SIZE)
pygame.display.set_caption("Rally Racer")
self.running: bool = True
self.track: Track = Track.load("simple")
self.car: Car = Car(self, self.track.start_pos, self.track.start_dir)
self.camera: Camera = Camera()
self.clock: pygame.time.Clock = pygame.time.Clock()
self.font: pygame.font.Font = pygame.font.Font(
str(ROOT / "assets" / "fonts" / "Ubuntu-M.ttf"), 20
)
self.show_fps: bool = True
self.show_speed: bool = True
self.show_raycasts: bool = True
def mainloop(self):
while self.running:
dt: float = self.clock.get_time() / 1000
self.process_pygame_events()
self.car.controller.process_commands()
self.car.update(dt)
self.car.check_collisions(self.track.get_collision_polygons())
self.update_camera()
self.render()
self.clock.tick(60)
def process_pygame_events(self):
for event in pygame.event.get():
if event.type == pygame.QUIT:
self.quit()
elif event.type == pygame.VIDEORESIZE:
self.game_surf = pygame.Surface((event.w, event.h))
self.camera.set_size(Vec(event.w, event.h))
elif event.type == pygame.KEYDOWN:
if event.key == pygame.K_ESCAPE:
self.quit()
else:
self.on_key_down(event)
elif event.type == pygame.KEYUP:
self.on_key_up(event)
def update_camera(self):
self.camera.set_pos(self.car.pos)
self.camera.set_direction(self.car.direction)
self.camera.set_size(Vec(*self.win.get_size()))
def quit(self):
self.running = False
self.car.controller.close()
def render(self):
self.game_surf.fill(self.BACKGROUND_COLOR)
self.track.render(self.game_surf, self.camera)
self.car.render(self.game_surf, self.camera, self.show_raycasts)
self.win.blit(self.game_surf, (0, 0))
if self.show_fps:
self.render_fps()
if self.show_speed:
self.render_speedometer()
pygame.display.flip()
def on_key_down(self, event: pygame.event.Event):
if event.key == pygame.K_w:
self.car.forward = True
elif event.key == pygame.K_s:
self.car.backward = True
elif event.key == pygame.K_a:
self.car.left = True
elif event.key == pygame.K_d:
self.car.right = True
elif event.key == pygame.K_f:
self.show_fps = not self.show_fps
elif event.key == pygame.K_v:
self.show_speed = not self.show_speed
elif event.key == pygame.K_c:
self.show_raycasts = not self.show_raycasts
elif event.key == pygame.K_r:
self.reset()
def on_key_up(self, event: pygame.event.Event):
if event.key == pygame.K_w:
self.car.forward = False
elif event.key == pygame.K_s:
self.car.backward = False
elif event.key == pygame.K_a:
self.car.left = False
elif event.key == pygame.K_d:
self.car.right = False
def render_fps(self):
txt: pygame.Surface = self.font.render(
f"{self.clock.get_fps():.1f}", True, self.FPS_COLOR
)
self.win.blit(txt, (self.win.get_width() - txt.get_width(), 0))
def render_speedometer(self):
if self.car.speed == 0:
return
angle: float = self.car.speed / self.car.MAX_SPEED * 180
pts1: list[tuple[float, float]] = []
pts2: list[tuple[float, float]] = []
n: int = 30
r: float = 50
ox: float = r + 10
oy: float = r + 10
thickness: float = 5
r2: float = r - thickness
for i in range(n):
a: float = radians(angle * i / (n - 1))
dx: float = -cos(a)
dy: float = -sin(a)
pts1.append((ox + r * dx, oy + r * dy))
pts2.append((ox + r2 * dx, oy + r2 * dy))
pygame.draw.polygon(self.win, (200, 200, 200), pts1 + pts2[::-1])
def reset(self):
self.car.pos = self.track.start_pos
self.car.direction = self.track.start_dir
self.car.speed = 0

0
src/objects/__init__.py Normal file
View File

119
src/objects/road.py Normal file
View File

@@ -0,0 +1,119 @@
from __future__ import annotations
import pygame
from src.camera import Camera
from src.track_object import TrackObject, TrackObjectType
from src.vec import Vec
class Road(TrackObject):
type = TrackObjectType.Road
STRIP_LENGTH = 0.5
STRIP_GAP = 0.5
def __init__(self, pts: list[RoadPoint]) -> None:
super().__init__()
self.pts: list[RoadPoint] = pts
self.strips: list[tuple[Vec, Vec]] = []
self.compute_strips()
@classmethod
def load(cls, data: dict) -> Road:
return Road([RoadPoint.load(pt) for pt in data["pts"]])
def render(self, surf: pygame.Surface, camera: Camera):
side1: list[Vec] = []
side2: list[Vec] = []
for i, pt in enumerate(self.pts):
p1: Vec = pt.pos
p2: Vec = p1 + pt.normal * pt.width
p3: Vec = p1 - pt.normal * pt.width
side1.append(camera.world2screen(p2))
side2.append(camera.world2screen(p3))
n: int = len(self.pts)
for i in range(n):
pygame.draw.polygon(
surf,
(100, 100, 100),
[side1[i], side1[(i + 1) % n], side2[(i + 1) % n], side2[i]],
)
pygame.draw.lines(surf, (255, 255, 255), True, side1)
pygame.draw.lines(surf, (255, 255, 255), True, side2)
for p1, p2 in self.strips:
pygame.draw.line(
surf,
(255, 255, 255),
camera.world2screen(p1),
camera.world2screen(p2),
6,
)
def get_collision_polygons(self) -> list[list[Vec]]:
side1: list[Vec] = []
side2: list[Vec] = []
for pt in self.pts:
p1: Vec = pt.pos
p2: Vec = p1 + pt.normal * pt.width
p3: Vec = p1 - pt.normal * pt.width
side1.append(p2)
side2.append(p3)
return [side1, side2]
def compute_strips(self):
n: int = len(self.pts)
vecs: list[Vec] = [
self.pts[(i + 1) % n].pos - pt.pos for i, pt in enumerate(self.pts)
]
lengths: list[float] = [v.mag() for v in vecs]
cum_sums: list[float] = [0]
for l in lengths:
cum_sums.append(cum_sums[-1] + l)
self.strips = []
total_length: float = sum(lengths)
def get_pt(length: float) -> tuple[int, float]:
length %= total_length
for i, cs in list(enumerate(cum_sums))[::-1]:
if cs <= length:
return (i, (length - cs) / lengths[i])
raise ValueError()
l0: float = 0
while l0 < total_length:
l1: float = l0 + self.STRIP_LENGTH
i0, t0 = get_pt(l0)
i1, t1 = get_pt(l1)
p0: Vec = self.pts[i0].pos + vecs[i0] * t0
p1: Vec = self.pts[i1].pos + vecs[i1] * t1
if i0 == i1:
self.strips.append((p0, p1))
elif (i0 + 1) % n == i1:
pm: Vec = self.pts[i1].pos
self.strips.append((p0, pm))
self.strips.append((pm, p1))
else:
self.strips.append((p0, self.pts[(i0 + 1) % n].pos))
i = (i0 + 1) % n
while i != i1:
i2 = (i + 1) % n
self.strips.append((self.pts[i].pos, self.pts[i2].pos))
i = i2
self.strips.append((self.pts[i1].pos, p1))
l0 = l1 + self.STRIP_GAP
class RoadPoint:
def __init__(self, pos: Vec, normal: Vec, width: float) -> None:
self.pos: Vec = pos
self.normal: Vec = normal.normalized
self.width: float = width
@staticmethod
def load(data: list[float]) -> RoadPoint:
return RoadPoint(Vec(data[0], data[1]), Vec(data[2], data[3]), data[4])

50
src/record_file.py Normal file
View File

@@ -0,0 +1,50 @@
import lzma
from pathlib import Path
import struct
import time
from typing import Literal
from src.snapshot import Snapshot
class RecordFile:
VERSION = 1
def __init__(self, path: str | Path, mode: Literal["w", "r"]) -> None:
self.path: str | Path = path
self.mode: Literal["w", "r"] = mode
self.file: lzma.LZMAFile = lzma.LZMAFile(self.path, self.mode)
def __enter__(self):
return self
def __exit__(self, type, value, traceback):
self.file.close()
def write_header(self, n_snapshots: int):
data: bytes = struct.pack(">IId", self.VERSION, n_snapshots, time.time())
self.file.write(data)
def write_snapshots(self, snapshots: list[Snapshot]):
self.write_header(len(snapshots))
for snapshot in snapshots:
data: bytes = snapshot.pack()
self.file.write(struct.pack(">I", len(data)) + data)
def read_snapshots(self) -> list[Snapshot]:
version: int = struct.unpack(">I", self.file.read(4))[0]
if version != self.VERSION:
raise ValueError(
f"Cannot parse record file with format version {version} (current version: {self.VERSION})"
)
n_snapshots: int
timestamp: float
n_snapshots, timestamp = struct.unpack(">Id", self.file.read(12))
snapshots: list[Snapshot] = []
for _ in range(n_snapshots):
size: int = struct.unpack(">I", self.file.read(4))[0]
snapshots.append(Snapshot.unpack(self.file.read(size)))
return snapshots

281
src/recorder.py Normal file
View File

@@ -0,0 +1,281 @@
import os
from pathlib import Path
import socket
import struct
from typing import Optional
from PyQt6 import uic
from PyQt6.QtCore import QObject, QThread, QTimer, pyqtSignal, pyqtSlot
from PyQt6.QtGui import QKeyEvent
from PyQt6.QtWidgets import QMainWindow
from src.bot import Bot
from src.command import ApplySnapshotCommand, CarControl, Command, ControlCommand, RecordingCommand, ResetCommand
from src.record_file import RecordFile
from src.recorder_ui import Ui_Recorder
from src.snapshot import Snapshot
class RecorderClient(QObject):
DATA_CHUNK_SIZE = 65536
data_received: pyqtSignal = pyqtSignal(Snapshot)
def __init__(self, host: str, port: int) -> None:
super().__init__()
self.host: str = host
self.port: int = port
self.socket: socket.socket = socket.socket(
socket.AF_INET, socket.SOCK_STREAM)
self.timer: Optional[QTimer] = None
self.connected: bool = False
self.buffer: bytes = b""
@pyqtSlot()
def start(self):
self.socket.connect((self.host, self.port))
self.socket.setblocking(False)
self.connected = True
self.timer = QTimer(self)
self.timer.timeout.connect(self.poll_socket)
self.timer.start(50)
print("Connected to server")
def poll_socket(self):
if not self.connected:
return
try:
while True:
chunk: bytes = self.socket.recv(self.DATA_CHUNK_SIZE)
if not chunk:
return
self.buffer += chunk
while True:
if len(self.buffer) < 4:
break
msg_len: int = struct.unpack(">I", self.buffer[:4])[0]
msg_end: int = 4 + msg_len
if len(self.buffer) < msg_end:
break
message: bytes = self.buffer[4:msg_end]
self.buffer = self.buffer[msg_end:]
self.on_message(message)
except BlockingIOError:
pass
except Exception as e:
print(f"Socket error: {e}")
self.shutdown()
def on_message(self, message: bytes):
snapshot: Snapshot = Snapshot.unpack(message)
self.data_received.emit(snapshot)
@pyqtSlot(object)
def send_command(self, command):
if self.connected:
try:
payload: bytes = command.pack()
self.socket.sendall(struct.pack(">I", len(payload)) + payload)
except Exception as e:
print(f"An exception occured: {e}")
self.shutdown()
else:
print("Not connected")
@pyqtSlot()
def shutdown(self):
print("Shutting down client")
if self.timer is not None:
self.timer.stop()
self.timer = None
self.connected = False
self.socket.close()
class ThreadedSaver(QThread):
def __init__(self, path: str | Path, snapshots: list[Snapshot]):
super().__init__()
self.path: str | Path = path
self.snapshots: list[Snapshot] = snapshots
def run(self):
with RecordFile(self.path, "w") as f:
f.write_snapshots(self.snapshots)
class RecorderWindow(Ui_Recorder, QMainWindow):
close_signal: pyqtSignal = pyqtSignal()
send_signal: pyqtSignal = pyqtSignal(object)
SAVE_DIR: Path = Path(__file__).parent.parent / "records"
COMMAND_DIRECTIONS: dict[str, CarControl] = {
"w": CarControl.FORWARD,
"s": CarControl.BACKWARD,
"d": CarControl.RIGHT,
"a": CarControl.LEFT,
}
def __init__(self, host: str, port: int) -> None:
super().__init__()
self.host: str = host
self.port: int = port
self.client_thread: QThread = QThread()
self.client: RecorderClient = RecorderClient(self.host, self.port)
self.client.data_received.connect(self.on_snapshot_received)
self.client.moveToThread(self.client_thread)
self.client_thread.started.connect(self.client.start)
self.close_signal.connect(self.client.shutdown)
self.send_signal.connect(self.client.send_command)
uic.load_ui.loadUi("src/recorder.ui", self)
self.forwardButton.pressed.connect(
lambda: self.on_car_controlled(CarControl.FORWARD, True)
)
self.forwardButton.released.connect(
lambda: self.on_car_controlled(CarControl.FORWARD, False)
)
self.backwardButton.pressed.connect(
lambda: self.on_car_controlled(CarControl.BACKWARD, True)
)
self.backwardButton.released.connect(
lambda: self.on_car_controlled(CarControl.BACKWARD, False)
)
self.rightButton.pressed.connect(
lambda: self.on_car_controlled(CarControl.RIGHT, True)
)
self.rightButton.released.connect(
lambda: self.on_car_controlled(CarControl.RIGHT, False)
)
self.leftButton.pressed.connect(
lambda: self.on_car_controlled(CarControl.LEFT, True)
)
self.leftButton.released.connect(
lambda: self.on_car_controlled(CarControl.LEFT, False)
)
self.recordDataButton.clicked.connect(self.toggle_record)
self.resetButton.clicked.connect(self.rollback)
self.bot: Optional[Bot] = None
self.autopiloting = False
self.autopilotButton.clicked.connect(self.toggle_autopilot)
self.autopilotButton.setDisabled(True)
self.saveRecordButton.clicked.connect(self.save_record)
self.saving_worker: Optional[ThreadedSaver] = None
self.recording = False
self.snapshots: list[Snapshot] = []
self.client_thread.start()
def on_car_controlled(self, control: CarControl, active: bool):
self.send_command(ControlCommand(control, active))
def keyPressEvent(self, event): # type: ignore
if event.isAutoRepeat():
return
if isinstance(event, QKeyEvent):
key_text = event.text()
ctrl: Optional[CarControl] = self.COMMAND_DIRECTIONS.get(key_text)
if ctrl is not None:
self.on_car_controlled(ctrl, True)
def keyReleaseEvent(self, event): # type: ignore
if event.isAutoRepeat():
return
if isinstance(event, QKeyEvent):
key_text = event.text()
ctrl: Optional[CarControl] = self.COMMAND_DIRECTIONS.get(key_text)
if ctrl is not None:
self.on_car_controlled(ctrl, False)
def toggle_record(self):
self.recording = not self.recording
self.recordDataButton.setText(
"Recording..." if self.recording else "Record")
self.send_command(RecordingCommand(self.recording))
def rollback(self):
rollback_by: int = self.forgetSnapshotNumber.value()
rollback_by = max(0, min(rollback_by, len(self.snapshots) - 1))
self.snapshots = self.snapshots[:-rollback_by]
self.nbrSnapshotSaved.setText(str(len(self.snapshots)))
if len(self.snapshots) == 0:
self.send_command(ResetCommand())
else:
self.send_command(ApplySnapshotCommand(self.snapshots[-1]))
if self.recording:
self.toggle_record()
def toggle_autopilot(self):
self.autopiloting = not self.autopiloting
self.autopilotButton.setText(
"AutoPilot:\n" + ("ON" if self.autopiloting else "OFF")
)
def save_record(self):
if self.saving_worker is not None:
print("Already saving !")
return
if len(self.snapshots) == 0:
print("No data to save !")
return
if self.recording:
self.toggle_record()
self.saveRecordButton.setText("Saving ...")
self.SAVE_DIR.mkdir(exist_ok=True)
record_name: str = "record_%d.rec.xz"
fid = 0
while os.path.exists(self.SAVE_DIR / (record_name % fid)):
fid += 1
self.saving_worker = ThreadedSaver(
self.SAVE_DIR / (record_name % fid), self.snapshots)
self.snapshots = []
self.nbrSnapshotSaved.setText("0")
self.saving_worker.finished.connect(self.on_record_save_done)
self.saving_worker.start()
def on_record_save_done(self):
if self.saving_worker is None:
return
print("Recorded data saved to", self.saving_worker.path)
self.saving_worker = None
self.saveRecordButton.setText("Save")
@pyqtSlot(Snapshot)
def on_snapshot_received(self, snapshot: Snapshot):
self.snapshots.append(snapshot)
self.nbrSnapshotSaved.setText(str(len(self.snapshots)))
if self.autopiloting and self.bot is not None:
self.bot.on_snapshot_received(snapshot)
def shutdown(self):
self.close_signal.emit()
def send_command(self, command: Command):
self.send_signal.emit(command)
def register_bot(self, bot: Bot):
self.bot = bot
self.autopilotButton.setDisabled(False)

157
src/recorder.ui Normal file
View File

@@ -0,0 +1,157 @@
<?xml version="1.0" encoding="UTF-8"?>
<ui version="4.0">
<class>Recorder</class>
<widget class="QWidget" name="Recorder">
<property name="geometry">
<rect>
<x>0</x>
<y>0</y>
<width>303</width>
<height>233</height>
</rect>
</property>
<property name="windowTitle">
<string>Recorder</string>
</property>
<widget class="QWidget" name="gridLayoutWidget">
<property name="geometry">
<rect>
<x>0</x>
<y>0</y>
<width>301</width>
<height>231</height>
</rect>
</property>
<layout class="QGridLayout" name="gridLayout" columnstretch="0,0,0">
<item row="0" column="1">
<widget class="QPushButton" name="forwardButton">
<property name="sizePolicy">
<sizepolicy hsizetype="Expanding" vsizetype="Expanding">
<horstretch>0</horstretch>
<verstretch>0</verstretch>
</sizepolicy>
</property>
<property name="text">
<string>Forward (W)</string>
</property>
</widget>
</item>
<item row="3" column="0">
<widget class="QPushButton" name="leftButton">
<property name="sizePolicy">
<sizepolicy hsizetype="Expanding" vsizetype="Expanding">
<horstretch>0</horstretch>
<verstretch>0</verstretch>
</sizepolicy>
</property>
<property name="text">
<string>Left (A)</string>
</property>
</widget>
</item>
<item row="3" column="2">
<widget class="QPushButton" name="rightButton">
<property name="sizePolicy">
<sizepolicy hsizetype="Expanding" vsizetype="Expanding">
<horstretch>0</horstretch>
<verstretch>0</verstretch>
</sizepolicy>
</property>
<property name="text">
<string>Right (D)</string>
</property>
</widget>
</item>
<item row="0" column="2">
<layout class="QVBoxLayout" name="verticalLayout">
<item>
<widget class="QPushButton" name="recordDataButton">
<property name="autoFillBackground">
<bool>false</bool>
</property>
<property name="text">
<string>Record</string>
</property>
</widget>
</item>
<item>
<layout class="QHBoxLayout" name="horizontalLayout">
<item>
<widget class="QCheckBox" name="saveImgCheckBox">
<property name="text">
<string>Imgs</string>
</property>
</widget>
</item>
</layout>
</item>
<item>
<widget class="QPushButton" name="saveRecordButton">
<property name="text">
<string>Save</string>
</property>
</widget>
</item>
</layout>
</item>
<item row="0" column="0">
<layout class="QVBoxLayout" name="verticalLayout_2">
<item>
<widget class="QPushButton" name="resetButton">
<property name="text">
<string>Rollback</string>
</property>
</widget>
</item>
<item>
<widget class="QSpinBox" name="forgetSnapshotNumber">
<property name="minimum">
<number>10</number>
</property>
</widget>
</item>
<item>
<widget class="QLabel" name="nbrSnapshotSaved">
<property name="text">
<string>0</string>
</property>
<property name="alignment">
<set>Qt::AlignCenter</set>
</property>
</widget>
</item>
</layout>
</item>
<item row="3" column="1">
<widget class="QPushButton" name="autopilotButton">
<property name="sizePolicy">
<sizepolicy hsizetype="Expanding" vsizetype="Expanding">
<horstretch>0</horstretch>
<verstretch>0</verstretch>
</sizepolicy>
</property>
<property name="text">
<string>AutoPilot
OFF</string>
</property>
</widget>
</item>
<item row="4" column="1">
<widget class="QPushButton" name="backwardButton">
<property name="sizePolicy">
<sizepolicy hsizetype="Expanding" vsizetype="Expanding">
<horstretch>0</horstretch>
<verstretch>0</verstretch>
</sizepolicy>
</property>
<property name="text">
<string>Backward (S)</string>
</property>
</widget>
</item>
</layout>
</widget>
</widget>
<resources/>
<connections/>
</ui>

109
src/recorder_ui.py Normal file
View File

@@ -0,0 +1,109 @@
# Form implementation generated from reading ui file 'recorder.ui'
#
# Created by: PyQt6 UI code generator 6.8.1
#
# WARNING: Any manual changes made to this file will be lost when pyuic6 is
# run again. Do not edit this file unless you know what you are doing.
from PyQt6 import QtCore, QtGui, QtWidgets
class Ui_Recorder(object):
def setupUi(self, Recorder):
Recorder.setObjectName("Recorder")
Recorder.resize(303, 233)
self.gridLayoutWidget = QtWidgets.QWidget(parent=Recorder)
self.gridLayoutWidget.setGeometry(QtCore.QRect(0, 0, 301, 231))
self.gridLayoutWidget.setObjectName("gridLayoutWidget")
self.gridLayout = QtWidgets.QGridLayout(self.gridLayoutWidget)
self.gridLayout.setContentsMargins(0, 0, 0, 0)
self.gridLayout.setObjectName("gridLayout")
self.forwardButton = QtWidgets.QPushButton(parent=self.gridLayoutWidget)
sizePolicy = QtWidgets.QSizePolicy(QtWidgets.QSizePolicy.Policy.Expanding, QtWidgets.QSizePolicy.Policy.Expanding)
sizePolicy.setHorizontalStretch(0)
sizePolicy.setVerticalStretch(0)
sizePolicy.setHeightForWidth(self.forwardButton.sizePolicy().hasHeightForWidth())
self.forwardButton.setSizePolicy(sizePolicy)
self.forwardButton.setObjectName("forwardButton")
self.gridLayout.addWidget(self.forwardButton, 0, 1, 1, 1)
self.leftButton = QtWidgets.QPushButton(parent=self.gridLayoutWidget)
sizePolicy = QtWidgets.QSizePolicy(QtWidgets.QSizePolicy.Policy.Expanding, QtWidgets.QSizePolicy.Policy.Expanding)
sizePolicy.setHorizontalStretch(0)
sizePolicy.setVerticalStretch(0)
sizePolicy.setHeightForWidth(self.leftButton.sizePolicy().hasHeightForWidth())
self.leftButton.setSizePolicy(sizePolicy)
self.leftButton.setObjectName("leftButton")
self.gridLayout.addWidget(self.leftButton, 3, 0, 1, 1)
self.rightButton = QtWidgets.QPushButton(parent=self.gridLayoutWidget)
sizePolicy = QtWidgets.QSizePolicy(QtWidgets.QSizePolicy.Policy.Expanding, QtWidgets.QSizePolicy.Policy.Expanding)
sizePolicy.setHorizontalStretch(0)
sizePolicy.setVerticalStretch(0)
sizePolicy.setHeightForWidth(self.rightButton.sizePolicy().hasHeightForWidth())
self.rightButton.setSizePolicy(sizePolicy)
self.rightButton.setObjectName("rightButton")
self.gridLayout.addWidget(self.rightButton, 3, 2, 1, 1)
self.verticalLayout = QtWidgets.QVBoxLayout()
self.verticalLayout.setObjectName("verticalLayout")
self.recordDataButton = QtWidgets.QPushButton(parent=self.gridLayoutWidget)
self.recordDataButton.setAutoFillBackground(False)
self.recordDataButton.setObjectName("recordDataButton")
self.verticalLayout.addWidget(self.recordDataButton)
self.horizontalLayout = QtWidgets.QHBoxLayout()
self.horizontalLayout.setObjectName("horizontalLayout")
self.saveImgCheckBox = QtWidgets.QCheckBox(parent=self.gridLayoutWidget)
self.saveImgCheckBox.setObjectName("saveImgCheckBox")
self.horizontalLayout.addWidget(self.saveImgCheckBox)
self.verticalLayout.addLayout(self.horizontalLayout)
self.saveRecordButton = QtWidgets.QPushButton(parent=self.gridLayoutWidget)
self.saveRecordButton.setObjectName("saveRecordButton")
self.verticalLayout.addWidget(self.saveRecordButton)
self.gridLayout.addLayout(self.verticalLayout, 0, 2, 1, 1)
self.verticalLayout_2 = QtWidgets.QVBoxLayout()
self.verticalLayout_2.setObjectName("verticalLayout_2")
self.resetButton = QtWidgets.QPushButton(parent=self.gridLayoutWidget)
self.resetButton.setObjectName("resetButton")
self.verticalLayout_2.addWidget(self.resetButton)
self.forgetSnapshotNumber = QtWidgets.QSpinBox(parent=self.gridLayoutWidget)
self.forgetSnapshotNumber.setMinimum(10)
self.forgetSnapshotNumber.setObjectName("forgetSnapshotNumber")
self.verticalLayout_2.addWidget(self.forgetSnapshotNumber)
self.nbrSnapshotSaved = QtWidgets.QLabel(parent=self.gridLayoutWidget)
self.nbrSnapshotSaved.setAlignment(QtCore.Qt.AlignmentFlag.AlignCenter)
self.nbrSnapshotSaved.setObjectName("nbrSnapshotSaved")
self.verticalLayout_2.addWidget(self.nbrSnapshotSaved)
self.gridLayout.addLayout(self.verticalLayout_2, 0, 0, 1, 1)
self.autopilotButton = QtWidgets.QPushButton(parent=self.gridLayoutWidget)
sizePolicy = QtWidgets.QSizePolicy(QtWidgets.QSizePolicy.Policy.Expanding, QtWidgets.QSizePolicy.Policy.Expanding)
sizePolicy.setHorizontalStretch(0)
sizePolicy.setVerticalStretch(0)
sizePolicy.setHeightForWidth(self.autopilotButton.sizePolicy().hasHeightForWidth())
self.autopilotButton.setSizePolicy(sizePolicy)
self.autopilotButton.setObjectName("autopilotButton")
self.gridLayout.addWidget(self.autopilotButton, 3, 1, 1, 1)
self.backwardButton = QtWidgets.QPushButton(parent=self.gridLayoutWidget)
sizePolicy = QtWidgets.QSizePolicy(QtWidgets.QSizePolicy.Policy.Expanding, QtWidgets.QSizePolicy.Policy.Expanding)
sizePolicy.setHorizontalStretch(0)
sizePolicy.setVerticalStretch(0)
sizePolicy.setHeightForWidth(self.backwardButton.sizePolicy().hasHeightForWidth())
self.backwardButton.setSizePolicy(sizePolicy)
self.backwardButton.setObjectName("backwardButton")
self.gridLayout.addWidget(self.backwardButton, 4, 1, 1, 1)
self.retranslateUi(Recorder)
QtCore.QMetaObject.connectSlotsByName(Recorder)
def retranslateUi(self, Recorder):
_translate = QtCore.QCoreApplication.translate
Recorder.setWindowTitle(_translate("Recorder", "Recorder"))
self.forwardButton.setText(_translate("Recorder", "Forward (W)"))
self.leftButton.setText(_translate("Recorder", "Left (A)"))
self.rightButton.setText(_translate("Recorder", "Right (D)"))
self.recordDataButton.setText(_translate("Recorder", "Record"))
self.saveImgCheckBox.setText(_translate("Recorder", "Imgs"))
self.saveRecordButton.setText(_translate("Recorder", "Save"))
self.resetButton.setText(_translate("Recorder", "Rollback"))
self.nbrSnapshotSaved.setText(_translate("Recorder", "0"))
self.autopilotButton.setText(_translate("Recorder", "AutoPilot\n"
"OFF"))
self.backwardButton.setText(_translate("Recorder", "Backward (S)"))

141
src/remote_controller.py Normal file
View File

@@ -0,0 +1,141 @@
from __future__ import annotations
import queue
import socket
import struct
import threading
from typing import TYPE_CHECKING, Optional
from src.command import ApplySnapshotCommand, CarControl, Command, ControlCommand, RecordingCommand, ResetCommand
from src.snapshot import Snapshot
from src.utils import RepeatTimer
if TYPE_CHECKING:
from src.car import Car
from src.game import Game
class RemoteController:
DEFAULT_PORT = 5000
DATA_CHUNK_SIZE = 65536
CONTROL_ATTRIBUTES: dict[CarControl, str] = {
CarControl.FORWARD: "forward",
CarControl.BACKWARD: "backward",
CarControl.LEFT: "left",
CarControl.RIGHT: "right",
}
SNAPSHOT_INTERVAL = 0.1
def __init__(self, game: Game, car: Car, port: int = DEFAULT_PORT) -> None:
self.game: Game = game
self.car: Car = car
self.port: int = port
self.server: socket.socket = socket.socket(
socket.AF_INET, socket.SOCK_STREAM)
self.server_thread: threading.Thread = threading.Thread(
target=self.wait_for_connections, daemon=True
)
self.running: bool = False
self.queue: queue.Queue[Command] = queue.Queue()
self.client_thread: Optional[threading.Thread] = None
self.client: Optional[socket.socket] = None
self.snapshot_timer: RepeatTimer = RepeatTimer(
interval=self.SNAPSHOT_INTERVAL, function=self.take_snapshot)
self.snapshot_timer.start()
self.recording: bool = False
@property
def is_connected(self) -> bool:
return self.client is not None
def wait_for_connections(self):
self.server.bind(("", self.port))
self.server.listen(1)
print(f"Remote control server listening on port {self.port}")
while self.running:
conn, addr = self.server.accept()
print(f"Remote connection from {addr}")
self.on_client_connected(conn)
def start_server(self):
self.running = True
self.server_thread.start()
def close(self):
if self.client:
self.client.close()
self.server.close()
self.snapshot_timer.cancel()
self.running = False
def on_client_connected(self, conn: socket.socket):
if self.client:
print("A client is already connected")
conn.close()
return
self.client = conn
self.client_thread = threading.Thread(target=self.client_loop)
self.client_thread.start()
def client_loop(self):
buffer: bytes = b""
while self.running and self.client:
chunk: bytes = self.client.recv(self.DATA_CHUNK_SIZE)
if not chunk:
print("Client disconnected")
break
buffer += chunk
while True:
if len(buffer) < 4:
break
msg_len: int = struct.unpack(">I", buffer[:4])[0]
msg_end: int = 4 + msg_len
if len(buffer) < msg_end:
break
message: bytes = buffer[4:msg_end]
buffer = buffer[msg_end:]
self.on_message(message)
if self.client:
self.client.close()
self.client = None
self.client_thread = None
def on_message(self, message: bytes):
command: Command = Command.unpack(message)
self.queue.put(command)
def process_commands(self):
while not self.queue.empty():
command: Command = self.queue.get()
self.process_command(command)
def process_command(self, command: Command):
match command:
case ControlCommand(control, active):
self.set_control(control, active)
case RecordingCommand(state):
self.recording = state
case ApplySnapshotCommand(snapshot):
snapshot.apply(self.car)
case ResetCommand():
self.car.reset()
def set_control(self, control: CarControl, active: bool):
setattr(self.car, self.CONTROL_ATTRIBUTES[control], active)
def take_snapshot(self):
if self.client is None:
return
if not self.recording:
return
snapshot: Snapshot = Snapshot.from_car(self.car)
snapshot.add_image(self.game)
payload: bytes = snapshot.pack()
self.client.sendall(struct.pack(">I", len(payload)) + payload)

106
src/snapshot.py Normal file
View File

@@ -0,0 +1,106 @@
from __future__ import annotations
import struct
from dataclasses import dataclass, field
from typing import TYPE_CHECKING, Optional
import numpy as np
import pygame
from src.vec import Vec
if TYPE_CHECKING:
from src.car import Car
from src.game import Game
def iter_unpack(format, data):
nbr_bytes = struct.calcsize(format)
return struct.unpack(format, data[:nbr_bytes]), data[nbr_bytes:]
@dataclass
class Snapshot:
controls: tuple[bool, bool, bool, bool] = (False, False, False, False)
position: Vec = field(default_factory=Vec)
direction: Vec = field(default_factory=Vec)
speed: float = 0
raycast_distances: list[float] | tuple[float, ...] = field(
default_factory=list)
image: Optional[np.ndarray] = None
def pack(self):
data: bytes = b""
data += struct.pack(">BBBB", *self.controls)
data += struct.pack(
">fffff",
self.position.x,
self.position.y,
self.direction.x,
self.direction.y,
self.speed,
)
nbr_raycasts: int = len(self.raycast_distances)
data += struct.pack(f">B{nbr_raycasts}f",
nbr_raycasts, *self.raycast_distances)
if self.image is not None:
data += struct.pack(">II",
self.image.shape[0], self.image.shape[1])
data += self.image.tobytes()
else:
data += struct.pack(">II", 0, 0)
return data
@staticmethod
def unpack(data: bytes) -> Snapshot:
controls, data = iter_unpack(">BBBB", data)
(x, y, dx, dy, s), data = iter_unpack(">fffff", data)
position = Vec(x, y)
direction = Vec(dx, dy)
speed = s
(nbr_raycasts,), data = iter_unpack(">B", data)
raycast_distances, data = iter_unpack(f">{nbr_raycasts}f", data)
(h, w), data = iter_unpack(">II", data)
if h * w > 0:
image = np.frombuffer(data, np.uint8).reshape(h, w, 3)
else:
image = None
return Snapshot(
controls=controls,
position=position,
direction=direction,
speed=speed,
raycast_distances=raycast_distances,
image=image,
)
@staticmethod
def from_car(car: Car) -> Snapshot:
return Snapshot(
controls=(
car.forward,
car.backward,
car.left,
car.right
),
position=car.pos.copy(),
direction=car.direction.copy(),
speed=car.speed,
raycast_distances=car.rays.copy(),
image=None
)
def apply(self, car: Car):
car.pos = self.position.copy()
car.direction = self.direction.copy()
car.speed = 0
def add_image(self, game: Game):
self.image = pygame.surfarray.array3d(game.game_surf)

54
src/track.py Normal file
View File

@@ -0,0 +1,54 @@
from __future__ import annotations
import json
import pygame
from src.camera import Camera
from src.track_object import TrackObject
from src.utils import ROOT
from src.vec import Vec
TrackObject.init()
class Track:
TRACKS_DIRECTORY = ROOT / "assets" / "tracks"
def __init__(self, id: str, name: str, start_pos: Vec, start_dir: Vec) -> None:
self.id: str = id
self.name: str = name
self.start_pos: Vec = start_pos
self.start_dir: Vec = start_dir
self.objects: list[TrackObject] = []
self.load_objects()
@staticmethod
def load(name: str) -> Track:
with open(Track.TRACKS_DIRECTORY / name / "meta.json", "r") as f:
meta: dict = json.load(f)
return Track(
name,
meta["name"],
Vec(*meta["start"]["pos"]),
Vec(*meta["start"]["direction"]),
)
def load_objects(self):
with open(Track.TRACKS_DIRECTORY / self.id / "track.json", "r") as f:
data: list = json.load(f)
self.objects = []
for obj_data in data:
self.objects.append(TrackObject.load(obj_data))
def render(self, surf: pygame.Surface, camera: Camera):
for object in self.objects:
object.render(surf, camera)
def get_collision_polygons(self) -> list[list[Vec]]:
polygons: list[list[Vec]] = []
for obj in self.objects:
polygons.extend(obj.get_collision_polygons())
return polygons

46
src/track_object.py Normal file
View File

@@ -0,0 +1,46 @@
import importlib
import pkgutil
from enum import StrEnum
from typing import Optional, Self
import pygame
import src.objects
from src.camera import Camera
from src.vec import Vec
class TrackObjectType(StrEnum):
Road = "road"
Unknown = "unknown"
class TrackObject:
REGISTRY = {}
type: TrackObjectType = TrackObjectType.Unknown
@staticmethod
def init():
package = src.objects
for _, modname, _ in pkgutil.walk_packages(
package.__path__, package.__name__ + "."
):
importlib.import_module(modname)
def __init_subclass__(cls, **kwargs) -> None:
super().__init_subclass__(**kwargs)
TrackObject.REGISTRY[cls.type] = cls
@classmethod
def load(cls, data: dict) -> Self:
obj_type: Optional[TrackObjectType] = data.get("type")
if obj_type not in cls.REGISTRY:
raise ValueError(f"Unknown object tyoe: {obj_type}")
return cls.REGISTRY[obj_type].load(data)
def render(self, surf: pygame.Surface, camera: Camera):
pass
def get_collision_polygons(self) -> list[list[Vec]]:
return []

69
src/utils.py Normal file
View File

@@ -0,0 +1,69 @@
import os
from pathlib import Path
from threading import Timer
from typing import Optional
from src.vec import Vec
ROOT = Path(os.path.abspath(os.path.join(
os.path.dirname(__file__), os.pardir)))
def orientation(a: Vec, b: Vec, c: Vec) -> float:
return (b - a).cross(c - a)
def segments_intersect(a1: Vec, a2: Vec, b1: Vec, b2: Vec) -> bool:
o1 = orientation(a1, a2, b1)
o2 = orientation(a1, a2, b2)
o3 = orientation(b1, b2, a1)
o4 = orientation(b1, b2, a2)
# General case: segments straddle each other
if (o1 * o2 < 0) and (o3 * o4 < 0):
return True
# Special cases: Collinear overlaps
if o1 == 0 and b1.within(a1, a2):
return True
if o2 == 0 and b2.within(a1, a2):
return True
if o3 == 0 and a1.within(b1, b2):
return True
if o4 == 0 and a2.within(b1, b2):
return True
return False
def get_segments_intersection(a1: Vec, a2: Vec, b1: Vec, b2: Vec) -> Optional[Vec]:
da: Vec = a2 - a1
db: Vec = b2 - b1
dp: Vec = a1 - b1
dap: Vec = da.perp
denom: float = dap.dot(db)
if abs(denom) < 1e-9:
o1: float = da.cross(-dp)
if abs(o1) < 1e-9:
for p in [b1, b2]:
if p.within(a1, a2):
return p
for p in [a1, a2]:
if p.within(b1, b2):
return p
return None
return None
num: float = dap.dot(dp)
t: float = num / denom
intersection: Vec = b1 + db * t
if intersection.within(a1, a2) and intersection.within(b1, b2):
return intersection
return None
class RepeatTimer(Timer):
def run(self):
while not self.finished.wait(self.interval):
self.function(*self.args, **self.kwargs)

View File

@@ -1,6 +1,6 @@
from __future__ import annotations
from math import sqrt
from math import cos, sin, sqrt
class Vec:
@@ -8,6 +8,9 @@ class Vec:
self.x: float = x
self.y: float = y
def copy(self) -> Vec:
return Vec(self.x, self.y)
def __add__(self, other: float | Vec) -> Vec:
if isinstance(other, Vec):
return Vec(self.x + other.x, self.y + other.y)
@@ -24,6 +27,9 @@ class Vec:
def __truediv__(self, value: float) -> Vec:
return Vec(self.x / value, self.y / value)
def __neg__(self) -> Vec:
return Vec(-self.x, -self.y)
def dot(self, other: Vec) -> float:
return self.x * other.x + self.y * other.y
@@ -55,3 +61,14 @@ class Vec:
def __repr__(self) -> str:
return f"Vec({self.x}, {self.y})"
def rotate(self, angle: float) -> Vec:
return Vec(
cos(angle) * self.x - sin(angle) * self.y,
sin(angle) * self.x + cos(angle) * self.y,
)
def within(self, p1: Vec, p2: Vec) -> bool:
x1, x2 = min(p1.x, p2.x), max(p1.x, p2.x)
y1, y2 = min(p1.y, p2.y), max(p1.y, p2.y)
return (x1 <= self.x <= x2) and (y1 <= self.y <= y2)

119
uv.lock generated
View File

@@ -2,6 +2,58 @@ version = 1
revision = 3
requires-python = ">=3.13"
[[package]]
name = "numpy"
version = "2.3.4"
source = { registry = "https://pypi.org/simple" }
sdist = { url = "https://files.pythonhosted.org/packages/b5/f4/098d2270d52b41f1bd7db9fc288aaa0400cb48c2a3e2af6fa365d9720947/numpy-2.3.4.tar.gz", hash = "sha256:a7d018bfedb375a8d979ac758b120ba846a7fe764911a64465fd87b8729f4a6a", size = 20582187, upload-time = "2025-10-15T16:18:11.77Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/57/7e/b72610cc91edf138bc588df5150957a4937221ca6058b825b4725c27be62/numpy-2.3.4-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:c090d4860032b857d94144d1a9976b8e36709e40386db289aaf6672de2a81966", size = 20950335, upload-time = "2025-10-15T16:16:10.304Z" },
{ url = "https://files.pythonhosted.org/packages/3e/46/bdd3370dcea2f95ef14af79dbf81e6927102ddf1cc54adc0024d61252fd9/numpy-2.3.4-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:a13fc473b6db0be619e45f11f9e81260f7302f8d180c49a22b6e6120022596b3", size = 14179878, upload-time = "2025-10-15T16:16:12.595Z" },
{ url = "https://files.pythonhosted.org/packages/ac/01/5a67cb785bda60f45415d09c2bc245433f1c68dd82eef9c9002c508b5a65/numpy-2.3.4-cp313-cp313-macosx_14_0_arm64.whl", hash = "sha256:3634093d0b428e6c32c3a69b78e554f0cd20ee420dcad5a9f3b2a63762ce4197", size = 5108673, upload-time = "2025-10-15T16:16:14.877Z" },
{ url = "https://files.pythonhosted.org/packages/c2/cd/8428e23a9fcebd33988f4cb61208fda832800ca03781f471f3727a820704/numpy-2.3.4-cp313-cp313-macosx_14_0_x86_64.whl", hash = "sha256:043885b4f7e6e232d7df4f51ffdef8c36320ee9d5f227b380ea636722c7ed12e", size = 6641438, upload-time = "2025-10-15T16:16:16.805Z" },
{ url = "https://files.pythonhosted.org/packages/3e/d1/913fe563820f3c6b079f992458f7331278dcd7ba8427e8e745af37ddb44f/numpy-2.3.4-cp313-cp313-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:4ee6a571d1e4f0ea6d5f22d6e5fbd6ed1dc2b18542848e1e7301bd190500c9d7", size = 14281290, upload-time = "2025-10-15T16:16:18.764Z" },
{ url = "https://files.pythonhosted.org/packages/9e/7e/7d306ff7cb143e6d975cfa7eb98a93e73495c4deabb7d1b5ecf09ea0fd69/numpy-2.3.4-cp313-cp313-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:fc8a63918b04b8571789688b2780ab2b4a33ab44bfe8ccea36d3eba51228c953", size = 16636543, upload-time = "2025-10-15T16:16:21.072Z" },
{ url = "https://files.pythonhosted.org/packages/47/6a/8cfc486237e56ccfb0db234945552a557ca266f022d281a2f577b98e955c/numpy-2.3.4-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:40cc556d5abbc54aabe2b1ae287042d7bdb80c08edede19f0c0afb36ae586f37", size = 16056117, upload-time = "2025-10-15T16:16:23.369Z" },
{ url = "https://files.pythonhosted.org/packages/b1/0e/42cb5e69ea901e06ce24bfcc4b5664a56f950a70efdcf221f30d9615f3f3/numpy-2.3.4-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:ecb63014bb7f4ce653f8be7f1df8cbc6093a5a2811211770f6606cc92b5a78fd", size = 18577788, upload-time = "2025-10-15T16:16:27.496Z" },
{ url = "https://files.pythonhosted.org/packages/86/92/41c3d5157d3177559ef0a35da50f0cda7fa071f4ba2306dd36818591a5bc/numpy-2.3.4-cp313-cp313-win32.whl", hash = "sha256:e8370eb6925bb8c1c4264fec52b0384b44f675f191df91cbe0140ec9f0955646", size = 6282620, upload-time = "2025-10-15T16:16:29.811Z" },
{ url = "https://files.pythonhosted.org/packages/09/97/fd421e8bc50766665ad35536c2bb4ef916533ba1fdd053a62d96cc7c8b95/numpy-2.3.4-cp313-cp313-win_amd64.whl", hash = "sha256:56209416e81a7893036eea03abcb91c130643eb14233b2515c90dcac963fe99d", size = 12784672, upload-time = "2025-10-15T16:16:31.589Z" },
{ url = "https://files.pythonhosted.org/packages/ad/df/5474fb2f74970ca8eb978093969b125a84cc3d30e47f82191f981f13a8a0/numpy-2.3.4-cp313-cp313-win_arm64.whl", hash = "sha256:a700a4031bc0fd6936e78a752eefb79092cecad2599ea9c8039c548bc097f9bc", size = 10196702, upload-time = "2025-10-15T16:16:33.902Z" },
{ url = "https://files.pythonhosted.org/packages/11/83/66ac031464ec1767ea3ed48ce40f615eb441072945e98693bec0bcd056cc/numpy-2.3.4-cp313-cp313t-macosx_10_13_x86_64.whl", hash = "sha256:86966db35c4040fdca64f0816a1c1dd8dbd027d90fca5a57e00e1ca4cd41b879", size = 21049003, upload-time = "2025-10-15T16:16:36.101Z" },
{ url = "https://files.pythonhosted.org/packages/5f/99/5b14e0e686e61371659a1d5bebd04596b1d72227ce36eed121bb0aeab798/numpy-2.3.4-cp313-cp313t-macosx_11_0_arm64.whl", hash = "sha256:838f045478638b26c375ee96ea89464d38428c69170360b23a1a50fa4baa3562", size = 14302980, upload-time = "2025-10-15T16:16:39.124Z" },
{ url = "https://files.pythonhosted.org/packages/2c/44/e9486649cd087d9fc6920e3fc3ac2aba10838d10804b1e179fb7cbc4e634/numpy-2.3.4-cp313-cp313t-macosx_14_0_arm64.whl", hash = "sha256:d7315ed1dab0286adca467377c8381cd748f3dc92235f22a7dfc42745644a96a", size = 5231472, upload-time = "2025-10-15T16:16:41.168Z" },
{ url = "https://files.pythonhosted.org/packages/3e/51/902b24fa8887e5fe2063fd61b1895a476d0bbf46811ab0c7fdf4bd127345/numpy-2.3.4-cp313-cp313t-macosx_14_0_x86_64.whl", hash = "sha256:84f01a4d18b2cc4ade1814a08e5f3c907b079c847051d720fad15ce37aa930b6", size = 6739342, upload-time = "2025-10-15T16:16:43.777Z" },
{ url = "https://files.pythonhosted.org/packages/34/f1/4de9586d05b1962acdcdb1dc4af6646361a643f8c864cef7c852bf509740/numpy-2.3.4-cp313-cp313t-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:817e719a868f0dacde4abdfc5c1910b301877970195db9ab6a5e2c4bd5b121f7", size = 14354338, upload-time = "2025-10-15T16:16:46.081Z" },
{ url = "https://files.pythonhosted.org/packages/1f/06/1c16103b425de7969d5a76bdf5ada0804b476fed05d5f9e17b777f1cbefd/numpy-2.3.4-cp313-cp313t-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:85e071da78d92a214212cacea81c6da557cab307f2c34b5f85b628e94803f9c0", size = 16702392, upload-time = "2025-10-15T16:16:48.455Z" },
{ url = "https://files.pythonhosted.org/packages/34/b2/65f4dc1b89b5322093572b6e55161bb42e3e0487067af73627f795cc9d47/numpy-2.3.4-cp313-cp313t-musllinux_1_2_aarch64.whl", hash = "sha256:2ec646892819370cf3558f518797f16597b4e4669894a2ba712caccc9da53f1f", size = 16134998, upload-time = "2025-10-15T16:16:51.114Z" },
{ url = "https://files.pythonhosted.org/packages/d4/11/94ec578896cdb973aaf56425d6c7f2aff4186a5c00fac15ff2ec46998b46/numpy-2.3.4-cp313-cp313t-musllinux_1_2_x86_64.whl", hash = "sha256:035796aaaddfe2f9664b9a9372f089cfc88bd795a67bd1bfe15e6e770934cf64", size = 18651574, upload-time = "2025-10-15T16:16:53.429Z" },
{ url = "https://files.pythonhosted.org/packages/62/b7/7efa763ab33dbccf56dade36938a77345ce8e8192d6b39e470ca25ff3cd0/numpy-2.3.4-cp313-cp313t-win32.whl", hash = "sha256:fea80f4f4cf83b54c3a051f2f727870ee51e22f0248d3114b8e755d160b38cfb", size = 6413135, upload-time = "2025-10-15T16:16:55.992Z" },
{ url = "https://files.pythonhosted.org/packages/43/70/aba4c38e8400abcc2f345e13d972fb36c26409b3e644366db7649015f291/numpy-2.3.4-cp313-cp313t-win_amd64.whl", hash = "sha256:15eea9f306b98e0be91eb344a94c0e630689ef302e10c2ce5f7e11905c704f9c", size = 12928582, upload-time = "2025-10-15T16:16:57.943Z" },
{ url = "https://files.pythonhosted.org/packages/67/63/871fad5f0073fc00fbbdd7232962ea1ac40eeaae2bba66c76214f7954236/numpy-2.3.4-cp313-cp313t-win_arm64.whl", hash = "sha256:b6c231c9c2fadbae4011ca5e7e83e12dc4a5072f1a1d85a0a7b3ed754d145a40", size = 10266691, upload-time = "2025-10-15T16:17:00.048Z" },
{ url = "https://files.pythonhosted.org/packages/72/71/ae6170143c115732470ae3a2d01512870dd16e0953f8a6dc89525696069b/numpy-2.3.4-cp314-cp314-macosx_10_15_x86_64.whl", hash = "sha256:81c3e6d8c97295a7360d367f9f8553973651b76907988bb6066376bc2252f24e", size = 20955580, upload-time = "2025-10-15T16:17:02.509Z" },
{ url = "https://files.pythonhosted.org/packages/af/39/4be9222ffd6ca8a30eda033d5f753276a9c3426c397bb137d8e19dedd200/numpy-2.3.4-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:7c26b0b2bf58009ed1f38a641f3db4be8d960a417ca96d14e5b06df1506d41ff", size = 14188056, upload-time = "2025-10-15T16:17:04.873Z" },
{ url = "https://files.pythonhosted.org/packages/6c/3d/d85f6700d0a4aa4f9491030e1021c2b2b7421b2b38d01acd16734a2bfdc7/numpy-2.3.4-cp314-cp314-macosx_14_0_arm64.whl", hash = "sha256:62b2198c438058a20b6704351b35a1d7db881812d8512d67a69c9de1f18ca05f", size = 5116555, upload-time = "2025-10-15T16:17:07.499Z" },
{ url = "https://files.pythonhosted.org/packages/bf/04/82c1467d86f47eee8a19a464c92f90a9bb68ccf14a54c5224d7031241ffb/numpy-2.3.4-cp314-cp314-macosx_14_0_x86_64.whl", hash = "sha256:9d729d60f8d53a7361707f4b68a9663c968882dd4f09e0d58c044c8bf5faee7b", size = 6643581, upload-time = "2025-10-15T16:17:09.774Z" },
{ url = "https://files.pythonhosted.org/packages/0c/d3/c79841741b837e293f48bd7db89d0ac7a4f2503b382b78a790ef1dc778a5/numpy-2.3.4-cp314-cp314-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:bd0c630cf256b0a7fd9d0a11c9413b42fef5101219ce6ed5a09624f5a65392c7", size = 14299186, upload-time = "2025-10-15T16:17:11.937Z" },
{ url = "https://files.pythonhosted.org/packages/e8/7e/4a14a769741fbf237eec5a12a2cbc7a4c4e061852b6533bcb9e9a796c908/numpy-2.3.4-cp314-cp314-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:d5e081bc082825f8b139f9e9fe42942cb4054524598aaeb177ff476cc76d09d2", size = 16638601, upload-time = "2025-10-15T16:17:14.391Z" },
{ url = "https://files.pythonhosted.org/packages/93/87/1c1de269f002ff0a41173fe01dcc925f4ecff59264cd8f96cf3b60d12c9b/numpy-2.3.4-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:15fb27364ed84114438fff8aaf998c9e19adbeba08c0b75409f8c452a8692c52", size = 16074219, upload-time = "2025-10-15T16:17:17.058Z" },
{ url = "https://files.pythonhosted.org/packages/cd/28/18f72ee77408e40a76d691001ae599e712ca2a47ddd2c4f695b16c65f077/numpy-2.3.4-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:85d9fb2d8cd998c84d13a79a09cc0c1091648e848e4e6249b0ccd7f6b487fa26", size = 18576702, upload-time = "2025-10-15T16:17:19.379Z" },
{ url = "https://files.pythonhosted.org/packages/c3/76/95650169b465ececa8cf4b2e8f6df255d4bf662775e797ade2025cc51ae6/numpy-2.3.4-cp314-cp314-win32.whl", hash = "sha256:e73d63fd04e3a9d6bc187f5455d81abfad05660b212c8804bf3b407e984cd2bc", size = 6337136, upload-time = "2025-10-15T16:17:22.886Z" },
{ url = "https://files.pythonhosted.org/packages/dc/89/a231a5c43ede5d6f77ba4a91e915a87dea4aeea76560ba4d2bf185c683f0/numpy-2.3.4-cp314-cp314-win_amd64.whl", hash = "sha256:3da3491cee49cf16157e70f607c03a217ea6647b1cea4819c4f48e53d49139b9", size = 12920542, upload-time = "2025-10-15T16:17:24.783Z" },
{ url = "https://files.pythonhosted.org/packages/0d/0c/ae9434a888f717c5ed2ff2393b3f344f0ff6f1c793519fa0c540461dc530/numpy-2.3.4-cp314-cp314-win_arm64.whl", hash = "sha256:6d9cd732068e8288dbe2717177320723ccec4fb064123f0caf9bbd90ab5be868", size = 10480213, upload-time = "2025-10-15T16:17:26.935Z" },
{ url = "https://files.pythonhosted.org/packages/83/4b/c4a5f0841f92536f6b9592694a5b5f68c9ab37b775ff342649eadf9055d3/numpy-2.3.4-cp314-cp314t-macosx_10_15_x86_64.whl", hash = "sha256:22758999b256b595cf0b1d102b133bb61866ba5ceecf15f759623b64c020c9ec", size = 21052280, upload-time = "2025-10-15T16:17:29.638Z" },
{ url = "https://files.pythonhosted.org/packages/3e/80/90308845fc93b984d2cc96d83e2324ce8ad1fd6efea81b324cba4b673854/numpy-2.3.4-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:9cb177bc55b010b19798dc5497d540dea67fd13a8d9e882b2dae71de0cf09eb3", size = 14302930, upload-time = "2025-10-15T16:17:32.384Z" },
{ url = "https://files.pythonhosted.org/packages/3d/4e/07439f22f2a3b247cec4d63a713faae55e1141a36e77fb212881f7cda3fb/numpy-2.3.4-cp314-cp314t-macosx_14_0_arm64.whl", hash = "sha256:0f2bcc76f1e05e5ab58893407c63d90b2029908fa41f9f1cc51eecce936c3365", size = 5231504, upload-time = "2025-10-15T16:17:34.515Z" },
{ url = "https://files.pythonhosted.org/packages/ab/de/1e11f2547e2fe3d00482b19721855348b94ada8359aef5d40dd57bfae9df/numpy-2.3.4-cp314-cp314t-macosx_14_0_x86_64.whl", hash = "sha256:8dc20bde86802df2ed8397a08d793da0ad7a5fd4ea3ac85d757bf5dd4ad7c252", size = 6739405, upload-time = "2025-10-15T16:17:36.128Z" },
{ url = "https://files.pythonhosted.org/packages/3b/40/8cd57393a26cebe2e923005db5134a946c62fa56a1087dc7c478f3e30837/numpy-2.3.4-cp314-cp314t-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:5e199c087e2aa71c8f9ce1cb7a8e10677dc12457e7cc1be4798632da37c3e86e", size = 14354866, upload-time = "2025-10-15T16:17:38.884Z" },
{ url = "https://files.pythonhosted.org/packages/93/39/5b3510f023f96874ee6fea2e40dfa99313a00bf3ab779f3c92978f34aace/numpy-2.3.4-cp314-cp314t-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:85597b2d25ddf655495e2363fe044b0ae999b75bc4d630dc0d886484b03a5eb0", size = 16703296, upload-time = "2025-10-15T16:17:41.564Z" },
{ url = "https://files.pythonhosted.org/packages/41/0d/19bb163617c8045209c1996c4e427bccbc4bbff1e2c711f39203c8ddbb4a/numpy-2.3.4-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:04a69abe45b49c5955923cf2c407843d1c85013b424ae8a560bba16c92fe44a0", size = 16136046, upload-time = "2025-10-15T16:17:43.901Z" },
{ url = "https://files.pythonhosted.org/packages/e2/c1/6dba12fdf68b02a21ac411c9df19afa66bed2540f467150ca64d246b463d/numpy-2.3.4-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:e1708fac43ef8b419c975926ce1eaf793b0c13b7356cfab6ab0dc34c0a02ac0f", size = 18652691, upload-time = "2025-10-15T16:17:46.247Z" },
{ url = "https://files.pythonhosted.org/packages/f8/73/f85056701dbbbb910c51d846c58d29fd46b30eecd2b6ba760fc8b8a1641b/numpy-2.3.4-cp314-cp314t-win32.whl", hash = "sha256:863e3b5f4d9915aaf1b8ec79ae560ad21f0b8d5e3adc31e73126491bb86dee1d", size = 6485782, upload-time = "2025-10-15T16:17:48.872Z" },
{ url = "https://files.pythonhosted.org/packages/17/90/28fa6f9865181cb817c2471ee65678afa8a7e2a1fb16141473d5fa6bacc3/numpy-2.3.4-cp314-cp314t-win_amd64.whl", hash = "sha256:962064de37b9aef801d33bc579690f8bfe6c5e70e29b61783f60bcba838a14d6", size = 13113301, upload-time = "2025-10-15T16:17:50.938Z" },
{ url = "https://files.pythonhosted.org/packages/54/23/08c002201a8e7e1f9afba93b97deceb813252d9cfd0d3351caed123dcf97/numpy-2.3.4-cp314-cp314t-win_arm64.whl", hash = "sha256:8b5a9a39c45d852b62693d9b3f3e0fe052541f804296ff401a72a1b60edafb29", size = 10547532, upload-time = "2025-10-15T16:17:53.48Z" },
]
[[package]]
name = "pygame"
version = "2.6.1"
@@ -17,13 +69,78 @@ wheels = [
{ url = "https://files.pythonhosted.org/packages/7e/11/17f7f319ca91824b86557e9303e3b7a71991ef17fd45286bf47d7f0a38e6/pygame-2.6.1-cp313-cp313-win_amd64.whl", hash = "sha256:813af4fba5d0b2cb8e58f5d95f7910295c34067dcc290d34f1be59c48bd1ea6a", size = 10620084, upload-time = "2024-09-29T11:48:51.587Z" },
]
[[package]]
name = "pyqt6"
version = "6.9.1"
source = { registry = "https://pypi.org/simple" }
dependencies = [
{ name = "pyqt6-qt6" },
{ name = "pyqt6-sip" },
]
sdist = { url = "https://files.pythonhosted.org/packages/32/1b/567f46eb43ca961efd38d7a0b73efb70d7342854f075fd919179fdb2a571/pyqt6-6.9.1.tar.gz", hash = "sha256:50642be03fb40f1c2111a09a1f5a0f79813e039c15e78267e6faaf8a96c1c3a6", size = 1067230, upload-time = "2025-06-06T08:49:30.307Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/18/c4/fc2a69cf3df09b213185ef5a677c3940cd20e7855d29e40061a685b9c6ee/pyqt6-6.9.1-cp39-abi3-macosx_10_14_universal2.whl", hash = "sha256:33c23d28f6608747ecc8bfd04c8795f61631af9db4fb1e6c2a7523ec4cc916d9", size = 59770566, upload-time = "2025-06-06T08:48:20.331Z" },
{ url = "https://files.pythonhosted.org/packages/d5/78/92f3c46440a83ebe22ae614bd6792e7b052bcb58ff128f677f5662015184/pyqt6-6.9.1-cp39-abi3-manylinux_2_28_x86_64.whl", hash = "sha256:37884df27f774e2e1c0c96fa41e817a222329b80ffc6241725b0dc8c110acb35", size = 37804959, upload-time = "2025-06-06T08:48:39.587Z" },
{ url = "https://files.pythonhosted.org/packages/5a/5e/e77fa2761d809cd08d724f44af01a4b6ceb0ff9648e43173187b0e4fac4e/pyqt6-6.9.1-cp39-abi3-manylinux_2_39_aarch64.whl", hash = "sha256:055870b703c1a49ca621f8a89e2ec4d848e6c739d39367eb9687af3b056d9aa3", size = 40414608, upload-time = "2025-06-06T08:49:00.26Z" },
{ url = "https://files.pythonhosted.org/packages/c4/09/69cf80456b6a985e06dd24ed0c2d3451e43567bf2807a5f3a86ef7a74a2e/pyqt6-6.9.1-cp39-abi3-win_amd64.whl", hash = "sha256:15b95bd273bb6288b070ed7a9503d5ff377aa4882dd6d175f07cad28cdb21da0", size = 25717996, upload-time = "2025-06-06T08:49:13.208Z" },
{ url = "https://files.pythonhosted.org/packages/52/b3/0839d8fd18b86362a4de384740f2f6b6885b5d06fda7720f8a335425e316/pyqt6-6.9.1-cp39-abi3-win_arm64.whl", hash = "sha256:08792c72d130a02e3248a120f0b9bbb4bf4319095f92865bc5b365b00518f53d", size = 25212132, upload-time = "2025-06-06T08:49:27.41Z" },
]
[[package]]
name = "pyqt6-qt6"
version = "6.9.2"
source = { registry = "https://pypi.org/simple" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/14/6f/fe2cd9cb2201c685be2f50c8c915df97848cac3dca4bad44bc3aed56fc63/pyqt6_qt6-6.9.2-py3-none-macosx_10_14_x86_64.whl", hash = "sha256:183b62be49216da80c7df1931d74885610a88f74812489d29610d13b7c215a1c", size = 66568266, upload-time = "2025-09-01T11:43:31.339Z" },
{ url = "https://files.pythonhosted.org/packages/db/1d/47dc51b4383b350f4ff6b1db461b01eba580030683ffa65475b4fdd9b80d/pyqt6_qt6-6.9.2-py3-none-macosx_11_0_arm64.whl", hash = "sha256:7897fb74ee21bdc87b5ccf84e94f4a551377e792fd180a9211c17eb41c3338a3", size = 60859706, upload-time = "2025-09-01T11:43:36.624Z" },
{ url = "https://files.pythonhosted.org/packages/a8/07/21f7dc188e35b46631707f3b40ace5643a0e03a8e1e446854826d08a04ae/pyqt6_qt6-6.9.2-py3-none-manylinux_2_28_x86_64.whl", hash = "sha256:9abfc0ee4a8293a6442128ae3f87f68e82e2a949d7b9caabd98c86ba5679ab48", size = 82322871, upload-time = "2025-09-01T11:43:41.685Z" },
{ url = "https://files.pythonhosted.org/packages/0c/c0/da658e735817feaa35ddfddb4c5d699291e8b8e3138e69ad7ae1a38a7db8/pyqt6_qt6-6.9.2-py3-none-manylinux_2_39_aarch64.whl", hash = "sha256:940aac6462532578e8ddefe0494cd17e33a85e0f3cfb21c612f56ab9ad7bc871", size = 80826693, upload-time = "2025-09-01T11:43:46.823Z" },
{ url = "https://files.pythonhosted.org/packages/63/3a/d811ed1aa579b93ab56188d1371b05eacb4188599d83e72b761263a10f92/pyqt6_qt6-6.9.2-py3-none-win_amd64.whl", hash = "sha256:f9289768039bef4a63e5949b7f8cfbbddc3b6d24bd58c21ba0f2921bed8d1c08", size = 74147171, upload-time = "2025-09-01T11:43:53.468Z" },
{ url = "https://files.pythonhosted.org/packages/57/59/7db6c5ddcb60ef3ecca2040274a30e8bc35b569c49e25e1cf2ef9f159426/pyqt6_qt6-6.9.2-py3-none-win_arm64.whl", hash = "sha256:8f82944ef68c8f8c78aa8eca4832c7bc05116c6de00a3bad8af5a0d63d1caafb", size = 54534019, upload-time = "2025-09-01T11:43:58.763Z" },
]
[[package]]
name = "pyqt6-sip"
version = "13.10.2"
source = { registry = "https://pypi.org/simple" }
sdist = { url = "https://files.pythonhosted.org/packages/2f/4a/96daf6c2e4f689faae9bd8cebb52754e76522c58a6af9b5ec86a2e8ec8b4/pyqt6_sip-13.10.2.tar.gz", hash = "sha256:464ad156bf526500ce6bd05cac7a82280af6309974d816739b4a9a627156fafe", size = 92548, upload-time = "2025-05-23T12:26:49.901Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/a1/1e/979ea64c98ca26979d8ce11e9a36579e17d22a71f51d7366d6eec3c82c13/pyqt6_sip-13.10.2-cp313-cp313-macosx_10_13_universal2.whl", hash = "sha256:8b5d06a0eac36038fa8734657d99b5fe92263ae7a0cd0a67be6acfe220a063e1", size = 112227, upload-time = "2025-05-23T12:26:38.758Z" },
{ url = "https://files.pythonhosted.org/packages/d9/21/84c230048e3bfef4a9209d16e56dcd2ae10590d03a31556ae8b5f1dcc724/pyqt6_sip-13.10.2-cp313-cp313-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:ad376a6078da37b049fdf9d6637d71b52727e65c4496a80b753ddc8d27526aca", size = 322920, upload-time = "2025-05-23T12:26:39.856Z" },
{ url = "https://files.pythonhosted.org/packages/b0/1e/c6a28a142f14e735088534cc92951c3f48cccd77cdd4f3b10d7996be420f/pyqt6_sip-13.10.2-cp313-cp313-manylinux_2_5_x86_64.manylinux1_x86_64.whl", hash = "sha256:3dde8024d055f496eba7d44061c5a1ba4eb72fc95e5a9d7a0dbc908317e0888b", size = 303833, upload-time = "2025-05-23T12:26:41.075Z" },
{ url = "https://files.pythonhosted.org/packages/89/63/e5adf350c1c3123d4865c013f164c5265512fa79f09ad464fb2fdf9f9e61/pyqt6_sip-13.10.2-cp313-cp313-win_amd64.whl", hash = "sha256:0b097eb58b4df936c4a2a88a2f367c8bb5c20ff049a45a7917ad75d698e3b277", size = 53527, upload-time = "2025-05-23T12:26:42.625Z" },
{ url = "https://files.pythonhosted.org/packages/58/74/2df4195306d050fbf4963fb5636108a66e5afa6dc05fd9e81e51ec96c384/pyqt6_sip-13.10.2-cp313-cp313-win_arm64.whl", hash = "sha256:cc6a1dfdf324efaac6e7b890a608385205e652845c62130de919fd73a6326244", size = 45373, upload-time = "2025-05-23T12:26:43.536Z" },
{ url = "https://files.pythonhosted.org/packages/23/57/74b4eb7a51b9133958daa8409b55de95e44feb694d4e2e3eba81a070ca20/pyqt6_sip-13.10.2-cp314-cp314-macosx_10_15_universal2.whl", hash = "sha256:8a76a06a8e5c5b1f17a3f6f3c834ca324877e07b960b18b8b9bbfd9c536ec658", size = 112354, upload-time = "2025-10-08T08:44:00.22Z" },
{ url = "https://files.pythonhosted.org/packages/f2/cb/fdef02e0d6ee8443a9683a43650d61c6474b634b6ae6e1c6f097da6310bf/pyqt6_sip-13.10.2-cp314-cp314-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:9128d770a611200529468397d710bc972f1dcfe12bfcbb09a3ccddcd4d54fa5b", size = 323488, upload-time = "2025-10-08T08:44:01.965Z" },
{ url = "https://files.pythonhosted.org/packages/8c/5b/8ede8d6234c3ea884cbd097d7d47ff9910fb114efe041af62b4453acd23b/pyqt6_sip-13.10.2-cp314-cp314-manylinux_2_5_x86_64.manylinux1_x86_64.whl", hash = "sha256:d820a0fae7315932c08f27dc0a7e33e0f50fe351001601a8eb9cf6f22b04562e", size = 303881, upload-time = "2025-10-08T08:44:04.086Z" },
{ url = "https://files.pythonhosted.org/packages/be/44/b5e78b072d1594643b0f1ff348f2bf54d4adb5a3f9b9f0989c54e33238d6/pyqt6_sip-13.10.2-cp314-cp314-win_amd64.whl", hash = "sha256:3213bb6e102d3842a3bb7e59d5f6e55f176c80880ff0b39d0dac0cfe58313fb3", size = 55098, upload-time = "2025-10-08T08:44:08.943Z" },
{ url = "https://files.pythonhosted.org/packages/e2/91/357e9fcef5d830c3d50503d35e0357818aca3540f78748cc214dfa015d00/pyqt6_sip-13.10.2-cp314-cp314-win_arm64.whl", hash = "sha256:ce33ff1f94960ad4b08035e39fa0c3c9a67070bec39ffe3e435c792721504726", size = 46088, upload-time = "2025-10-08T08:44:10.014Z" },
]
[[package]]
name = "qasync"
version = "0.28.0"
source = { registry = "https://pypi.org/simple" }
sdist = { url = "https://files.pythonhosted.org/packages/ec/b2/5be08597dbbf331edb69478eae2f8dd511834cebf56a183b442e7437f8e0/qasync-0.28.0.tar.gz", hash = "sha256:6f7f1f18971f59cb259b107218269ba56e3ad475ec456e54714b426a6e30b71d", size = 14010, upload-time = "2025-08-28T01:31:36.785Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/e5/84/0ce4cd946f6e958428c87d5accac35df70f81607e45ba4919947d0762d63/qasync-0.28.0-py3-none-any.whl", hash = "sha256:21faba8d047c717008378f5ac29ea58c32a8128528629e4afd57c59b768dba0f", size = 16188, upload-time = "2025-08-28T01:31:35.591Z" },
]
[[package]]
name = "rally-racer"
version = "0.1.0"
source = { virtual = "." }
dependencies = [
{ name = "numpy" },
{ name = "pygame" },
{ name = "pyqt6" },
{ name = "qasync" },
]
[package.metadata]
requires-dist = [{ name = "pygame", specifier = ">=2.6.1" }]
requires-dist = [
{ name = "numpy", specifier = ">=2.3.4" },
{ name = "pygame", specifier = ">=2.6.1" },
{ name = "pyqt6", specifier = ">=6.9.1" },
{ name = "qasync", specifier = ">=0.28.0" },
]