Compare commits

...

10 Commits

16 changed files with 608 additions and 47 deletions

5
.gitignore vendored
View File

@@ -9,4 +9,7 @@ wheels/
# Virtual environments # Virtual environments
.venv .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)

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

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()

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

View File

@@ -1,5 +1,7 @@
from __future__ import annotations
from math import radians from math import radians
from typing import Optional from typing import TYPE_CHECKING, Optional
import pygame import pygame
@@ -8,7 +10,11 @@ from src.remote_controller import RemoteController
from src.utils import get_segments_intersection, segments_intersect from src.utils import get_segments_intersection, segments_intersect
from src.vec import Vec from src.vec import Vec
sign = lambda x: 0 if x == 0 else (-1 if x < 0 else 1) 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: class Car:
@@ -26,7 +32,10 @@ class Car:
RAYS_FOV = 180 RAYS_FOV = 180
RAYS_MAX_DIST = 100 RAYS_MAX_DIST = 100
def __init__(self, pos: Vec, direction: Vec) -> None: 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.pos: Vec = pos
self.direction: Vec = direction self.direction: Vec = direction
self.speed: float = 0 self.speed: float = 0
@@ -39,7 +48,7 @@ class Car:
self.rays: list[float] = [0] * self.N_RAYS self.rays: list[float] = [0] * self.N_RAYS
self.rays_end: list[Vec] = [Vec() for _ in range(self.N_RAYS)] self.rays_end: list[Vec] = [Vec() for _ in range(self.N_RAYS)]
self.controller: RemoteController = RemoteController(self) self.controller: RemoteController = RemoteController(self.game, self)
self.controller.start_server() self.controller.start_server()
def update(self, dt: float): def update(self, dt: float):
@@ -77,7 +86,8 @@ class Car:
if show_raycasts: if show_raycasts:
pos: Vec = camera.world2screen(self.pos) pos: Vec = camera.world2screen(self.pos)
for p in self.rays_end: for p in self.rays_end:
pygame.draw.line(surf, (255, 0, 0), pos, camera.world2screen(p), 2) pygame.draw.line(surf, (255, 0, 0), pos,
camera.world2screen(p), 2)
pts: list[Vec] = self.get_corners() pts: list[Vec] = self.get_corners()
pts = [camera.world2screen(p) for p in pts] pts = [camera.world2screen(p) for p in pts]
@@ -127,14 +137,17 @@ class Car:
n *= -1 n *= -1
dist = -dist dist = -dist
self.speed = 0 self.speed = 0
self.pos = self.pos + n * (self.COLLISION_MARGIN - dist) self.pos = self.pos + n * \
(self.COLLISION_MARGIN - dist)
return return
def cast_rays(self, polygons: list[list[Vec]]): def cast_rays(self, polygons: list[list[Vec]]):
for i in range(self.N_RAYS): for i in range(self.N_RAYS):
angle: float = radians((i / (self.N_RAYS - 1) - 0.5) * self.RAYS_FOV) angle: float = radians(
(i / (self.N_RAYS - 1) - 0.5) * self.RAYS_FOV)
p: Optional[Vec] = self.cast_ray(angle, polygons) 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[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 self.rays_end[i] = self.pos if p is None else p
def cast_ray(self, angle: float, polygons: list[list[Vec]]) -> Optional[Vec]: def cast_ray(self, angle: float, polygons: list[list[Vec]]) -> Optional[Vec]:
@@ -161,3 +174,8 @@ class Car:
dist = d dist = d
closest = p closest = p
return closest return closest
def reset(self):
self.pos = self.initial_pos.copy()
self.direction = self.initial_dir.copy()
self.speed = 0

View File

@@ -5,10 +5,14 @@ from enum import IntEnum
import struct import struct
from typing import Type from typing import Type
from src.snapshot import Snapshot
class CommandType(IntEnum): class CommandType(IntEnum):
CAR_CONTROL = 0 CAR_CONTROL = 0
RECORDING = 1 RECORDING = 1
APPLY_SNAPSHOT = 2
RESET = 3
class CarControl(IntEnum): class CarControl(IntEnum):
@@ -30,8 +34,8 @@ class Command(abc.ABC):
) )
Command.REGISTRY[cls.TYPE] = cls Command.REGISTRY[cls.TYPE] = cls
@abc.abstractmethod def get_payload(self) -> bytes:
def get_payload(self) -> bytes: ... return b""
def pack(self) -> bytes: def pack(self) -> bytes:
payload: bytes = self.get_payload() payload: bytes = self.get_payload()
@@ -43,8 +47,8 @@ class Command(abc.ABC):
return Command.REGISTRY[type].from_payload(data[1:]) return Command.REGISTRY[type].from_payload(data[1:])
@classmethod @classmethod
@abc.abstractmethod def from_payload(cls, payload: bytes) -> Command:
def from_payload(cls, payload: bytes) -> Command: ... return cls()
class ControlCommand(Command): class ControlCommand(Command):
@@ -82,3 +86,24 @@ class RecordingCommand(Command):
def from_payload(cls, payload: bytes) -> Command: def from_payload(cls, payload: bytes) -> Command:
state: bool = struct.unpack(">B", payload)[0] state: bool = struct.unpack(">B", payload)[0]
return RecordingCommand(state) 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

@@ -19,10 +19,11 @@ class Game:
self.win: pygame.Surface = pygame.display.set_mode( self.win: pygame.Surface = pygame.display.set_mode(
self.DEFAULT_SIZE, pygame.RESIZABLE self.DEFAULT_SIZE, pygame.RESIZABLE
) )
self.game_surf: pygame.Surface = pygame.Surface(self.DEFAULT_SIZE)
pygame.display.set_caption("Rally Racer") pygame.display.set_caption("Rally Racer")
self.running: bool = True self.running: bool = True
self.track: Track = Track.load("simple") self.track: Track = Track.load("simple")
self.car: Car = Car(self.track.start_pos, self.track.start_dir) self.car: Car = Car(self, self.track.start_pos, self.track.start_dir)
self.camera: Camera = Camera() self.camera: Camera = Camera()
self.clock: pygame.time.Clock = pygame.time.Clock() self.clock: pygame.time.Clock = pygame.time.Clock()
@@ -49,6 +50,7 @@ class Game:
if event.type == pygame.QUIT: if event.type == pygame.QUIT:
self.quit() self.quit()
elif event.type == pygame.VIDEORESIZE: elif event.type == pygame.VIDEORESIZE:
self.game_surf = pygame.Surface((event.w, event.h))
self.camera.set_size(Vec(event.w, event.h)) self.camera.set_size(Vec(event.w, event.h))
elif event.type == pygame.KEYDOWN: elif event.type == pygame.KEYDOWN:
if event.key == pygame.K_ESCAPE: if event.key == pygame.K_ESCAPE:
@@ -68,9 +70,10 @@ class Game:
self.car.controller.close() self.car.controller.close()
def render(self): def render(self):
self.win.fill(self.BACKGROUND_COLOR) self.game_surf.fill(self.BACKGROUND_COLOR)
self.track.render(self.win, self.camera) self.track.render(self.game_surf, self.camera)
self.car.render(self.win, self.camera, self.show_raycasts) self.car.render(self.game_surf, self.camera, self.show_raycasts)
self.win.blit(self.game_surf, (0, 0))
if self.show_fps: if self.show_fps:
self.render_fps() self.render_fps()
if self.show_speed: if self.show_speed:

View File

@@ -1,3 +1,4 @@
import lzma
from pathlib import Path from pathlib import Path
import struct import struct
import time import time
@@ -12,7 +13,7 @@ class RecordFile:
def __init__(self, path: str | Path, mode: Literal["w", "r"]) -> None: def __init__(self, path: str | Path, mode: Literal["w", "r"]) -> None:
self.path: str | Path = path self.path: str | Path = path
self.mode: Literal["w", "r"] = mode self.mode: Literal["w", "r"] = mode
self.file = open(self.path, self.mode + "b") self.file: lzma.LZMAFile = lzma.LZMAFile(self.path, self.mode)
def __enter__(self): def __enter__(self):
return self return self
@@ -21,8 +22,7 @@ class RecordFile:
self.file.close() self.file.close()
def write_header(self, n_snapshots: int): def write_header(self, n_snapshots: int):
data: bytes = struct.pack( data: bytes = struct.pack(">IId", self.VERSION, n_snapshots, time.time())
">IId", self.VERSION, n_snapshots, time.time())
self.file.write(data) self.file.write(data)
def write_snapshots(self, snapshots: list[Snapshot]): def write_snapshots(self, snapshots: list[Snapshot]):
@@ -35,7 +35,8 @@ class RecordFile:
version: int = struct.unpack(">I", self.file.read(4))[0] version: int = struct.unpack(">I", self.file.read(4))[0]
if version != self.VERSION: if version != self.VERSION:
raise ValueError( raise ValueError(
f"Cannot parse record file with format version {version} (current version: {self.VERSION})") f"Cannot parse record file with format version {version} (current version: {self.VERSION})"
)
n_snapshots: int n_snapshots: int
timestamp: float timestamp: float

View File

@@ -9,14 +9,15 @@ from PyQt6.QtCore import QObject, QThread, QTimer, pyqtSignal, pyqtSlot
from PyQt6.QtGui import QKeyEvent from PyQt6.QtGui import QKeyEvent
from PyQt6.QtWidgets import QMainWindow from PyQt6.QtWidgets import QMainWindow
from src.command import CarControl, Command, ControlCommand, RecordingCommand from src.bot import Bot
from src.command import ApplySnapshotCommand, CarControl, Command, ControlCommand, RecordingCommand, ResetCommand
from src.record_file import RecordFile from src.record_file import RecordFile
from src.recorder_ui import Ui_Recorder from src.recorder_ui import Ui_Recorder
from src.snapshot import Snapshot from src.snapshot import Snapshot
class RecorderClient(QObject): class RecorderClient(QObject):
DATA_CHUNK_SIZE = 4096 DATA_CHUNK_SIZE = 65536
data_received: pyqtSignal = pyqtSignal(Snapshot) data_received: pyqtSignal = pyqtSignal(Snapshot)
def __init__(self, host: str, port: int) -> None: def __init__(self, host: str, port: int) -> None:
@@ -27,6 +28,7 @@ class RecorderClient(QObject):
socket.AF_INET, socket.SOCK_STREAM) socket.AF_INET, socket.SOCK_STREAM)
self.timer: Optional[QTimer] = None self.timer: Optional[QTimer] = None
self.connected: bool = False self.connected: bool = False
self.buffer: bytes = b""
@pyqtSlot() @pyqtSlot()
def start(self): def start(self):
@@ -39,27 +41,27 @@ class RecorderClient(QObject):
print("Connected to server") print("Connected to server")
def poll_socket(self): def poll_socket(self):
buffer: bytes = b""
if not self.connected: if not self.connected:
return return
try: try:
chunk: bytes = self.socket.recv(self.DATA_CHUNK_SIZE)
if not chunk:
return
buffer += chunk
while True: while True:
if len(buffer) < 4: chunk: bytes = self.socket.recv(self.DATA_CHUNK_SIZE)
break if not chunk:
msg_len: int = struct.unpack(">I", buffer[:4])[0] return
msg_end: int = 4 + msg_len self.buffer += chunk
if len(buffer) < msg_end:
break
message: bytes = buffer[4:msg_end] while True:
buffer = buffer[msg_end:] if len(self.buffer) < 4:
self.on_message(message) 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: except BlockingIOError:
pass pass
except Exception as e: except Exception as e:
@@ -162,9 +164,11 @@ class RecorderWindow(Ui_Recorder, QMainWindow):
self.recordDataButton.clicked.connect(self.toggle_record) self.recordDataButton.clicked.connect(self.toggle_record)
self.resetButton.clicked.connect(self.rollback) self.resetButton.clicked.connect(self.rollback)
self.bot: Optional[Bot] = None
self.autopiloting = False self.autopiloting = False
self.autopilotButton.clicked.connect(self.toggle_autopilot) self.autopilotButton.clicked.connect(self.toggle_autopilot)
self.autopilotButton.setDisabled(True)
self.saveRecordButton.clicked.connect(self.save_record) self.saveRecordButton.clicked.connect(self.save_record)
@@ -203,7 +207,19 @@ class RecorderWindow(Ui_Recorder, QMainWindow):
self.send_command(RecordingCommand(self.recording)) self.send_command(RecordingCommand(self.recording))
def rollback(self): def rollback(self):
pass 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): def toggle_autopilot(self):
self.autopiloting = not self.autopiloting self.autopiloting = not self.autopiloting
@@ -227,7 +243,7 @@ class RecorderWindow(Ui_Recorder, QMainWindow):
self.SAVE_DIR.mkdir(exist_ok=True) self.SAVE_DIR.mkdir(exist_ok=True)
record_name: str = "record_%d.rec" record_name: str = "record_%d.rec.xz"
fid = 0 fid = 0
while os.path.exists(self.SAVE_DIR / (record_name % fid)): while os.path.exists(self.SAVE_DIR / (record_name % fid)):
fid += 1 fid += 1
@@ -251,8 +267,15 @@ class RecorderWindow(Ui_Recorder, QMainWindow):
self.snapshots.append(snapshot) self.snapshots.append(snapshot)
self.nbrSnapshotSaved.setText(str(len(self.snapshots))) 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): def shutdown(self):
self.close_signal.emit() self.close_signal.emit()
def send_command(self, command: Command): def send_command(self, command: Command):
self.send_signal.emit(command) self.send_signal.emit(command)
def register_bot(self, bot: Bot):
self.bot = bot
self.autopilotButton.setDisabled(False)

View File

@@ -99,7 +99,7 @@
<item> <item>
<widget class="QPushButton" name="resetButton"> <widget class="QPushButton" name="resetButton">
<property name="text"> <property name="text">
<string>Reset</string> <string>Rollback</string>
</property> </property>
</widget> </widget>
</item> </item>

View File

@@ -1,6 +1,6 @@
# Form implementation generated from reading ui file 'recorder.ui' # Form implementation generated from reading ui file 'recorder.ui'
# #
# Created by: PyQt6 UI code generator 6.8.0 # Created by: PyQt6 UI code generator 6.8.1
# #
# WARNING: Any manual changes made to this file will be lost when pyuic6 is # 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. # run again. Do not edit this file unless you know what you are doing.
@@ -102,7 +102,7 @@ class Ui_Recorder(object):
self.recordDataButton.setText(_translate("Recorder", "Record")) self.recordDataButton.setText(_translate("Recorder", "Record"))
self.saveImgCheckBox.setText(_translate("Recorder", "Imgs")) self.saveImgCheckBox.setText(_translate("Recorder", "Imgs"))
self.saveRecordButton.setText(_translate("Recorder", "Save")) self.saveRecordButton.setText(_translate("Recorder", "Save"))
self.resetButton.setText(_translate("Recorder", "Reset")) self.resetButton.setText(_translate("Recorder", "Rollback"))
self.nbrSnapshotSaved.setText(_translate("Recorder", "0")) self.nbrSnapshotSaved.setText(_translate("Recorder", "0"))
self.autopilotButton.setText(_translate("Recorder", "AutoPilot\n" self.autopilotButton.setText(_translate("Recorder", "AutoPilot\n"
"OFF")) "OFF"))

View File

@@ -6,17 +6,18 @@ import struct
import threading import threading
from typing import TYPE_CHECKING, Optional from typing import TYPE_CHECKING, Optional
from src.command import CarControl, Command, ControlCommand, RecordingCommand from src.command import ApplySnapshotCommand, CarControl, Command, ControlCommand, RecordingCommand, ResetCommand
from src.snapshot import Snapshot from src.snapshot import Snapshot
from src.utils import RepeatTimer from src.utils import RepeatTimer
if TYPE_CHECKING: if TYPE_CHECKING:
from src.car import Car from src.car import Car
from src.game import Game
class RemoteController: class RemoteController:
DEFAULT_PORT = 5000 DEFAULT_PORT = 5000
DATA_CHUNK_SIZE = 4096 DATA_CHUNK_SIZE = 65536
CONTROL_ATTRIBUTES: dict[CarControl, str] = { CONTROL_ATTRIBUTES: dict[CarControl, str] = {
CarControl.FORWARD: "forward", CarControl.FORWARD: "forward",
@@ -27,7 +28,8 @@ class RemoteController:
SNAPSHOT_INTERVAL = 0.1 SNAPSHOT_INTERVAL = 0.1
def __init__(self, car: Car, port: int = DEFAULT_PORT) -> None: def __init__(self, game: Game, car: Car, port: int = DEFAULT_PORT) -> None:
self.game: Game = game
self.car: Car = car self.car: Car = car
self.port: int = port self.port: int = port
self.server: socket.socket = socket.socket( self.server: socket.socket = socket.socket(
@@ -119,6 +121,10 @@ class RemoteController:
self.set_control(control, active) self.set_control(control, active)
case RecordingCommand(state): case RecordingCommand(state):
self.recording = state self.recording = state
case ApplySnapshotCommand(snapshot):
snapshot.apply(self.car)
case ResetCommand():
self.car.reset()
def set_control(self, control: CarControl, active: bool): def set_control(self, control: CarControl, active: bool):
setattr(self.car, self.CONTROL_ATTRIBUTES[control], active) setattr(self.car, self.CONTROL_ATTRIBUTES[control], active)
@@ -130,5 +136,6 @@ class RemoteController:
return return
snapshot: Snapshot = Snapshot.from_car(self.car) snapshot: Snapshot = Snapshot.from_car(self.car)
snapshot.add_image(self.game)
payload: bytes = snapshot.pack() payload: bytes = snapshot.pack()
self.client.sendall(struct.pack(">I", len(payload)) + payload) self.client.sendall(struct.pack(">I", len(payload)) + payload)

View File

@@ -5,11 +5,13 @@ from dataclasses import dataclass, field
from typing import TYPE_CHECKING, Optional from typing import TYPE_CHECKING, Optional
import numpy as np import numpy as np
import pygame
from src.vec import Vec from src.vec import Vec
if TYPE_CHECKING: if TYPE_CHECKING:
from src.car import Car from src.car import Car
from src.game import Game
def iter_unpack(format, data): def iter_unpack(format, data):
@@ -63,7 +65,7 @@ class Snapshot:
(nbr_raycasts,), data = iter_unpack(">B", data) (nbr_raycasts,), data = iter_unpack(">B", data)
raycast_distances, data = iter_unpack(f">{nbr_raycasts}f", data) raycast_distances, data = iter_unpack(f">{nbr_raycasts}f", data)
(h, w), data = iter_unpack(">ii", data) (h, w), data = iter_unpack(">II", data)
if h * w > 0: if h * w > 0:
image = np.frombuffer(data, np.uint8).reshape(h, w, 3) image = np.frombuffer(data, np.uint8).reshape(h, w, 3)
@@ -94,3 +96,11 @@ class Snapshot:
raycast_distances=car.rays.copy(), raycast_distances=car.rays.copy(),
image=None 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)