Compare commits
	
		
			14 Commits
		
	
	
		
			8542ee81e7
			...
			feat/image
		
	
	| Author | SHA1 | Date | |
|---|---|---|---|
| cd866cc511 | |||
| 9f86d060f0 | |||
| e2f4a0d2a5 | |||
| bc96cea2b9 | |||
| 8d35f76b56 | |||
| 97f9765705 | |||
| fa61e27825 | |||
| b60a0aba4f | |||
| ae02ddefb0 | |||
| f1fadd123f | |||
| 8b7927a3c5 | |||
| 62de92e7a2 | |||
| 8ad97785b8 | |||
| db112ada4c | 
							
								
								
									
										5
									
								
								.gitignore
									
									
									
									
										vendored
									
									
								
							
							
						
						
									
										5
									
								
								.gitignore
									
									
									
									
										vendored
									
									
								
							| @@ -9,4 +9,7 @@ wheels/ | ||||
| # Virtual environments | ||||
| .venv | ||||
|  | ||||
| .vscode | ||||
| .vscode | ||||
|  | ||||
| records | ||||
| *.rec | ||||
							
								
								
									
										59
									
								
								README.md
									
									
									
									
									
								
							
							
						
						
									
										59
									
								
								README.md
									
									
									
									
									
								
							| @@ -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
									
								
							
							
						
						
									
										143
									
								
								car.svg
									
									
									
									
									
										Normal 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 | 
							
								
								
									
										202
									
								
								logo.svg
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										202
									
								
								logo.svg
									
									
									
									
									
										Normal 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
									
								
							
							
						
						
									
										41
									
								
								scripts/example_bot.py
									
									
									
									
									
										Normal 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
									
								
							
							
						
						
									
										26
									
								
								src/bot.py
									
									
									
									
									
										Normal 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 | ||||
							
								
								
									
										34
									
								
								src/car.py
									
									
									
									
									
								
							
							
						
						
									
										34
									
								
								src/car.py
									
									
									
									
									
								
							| @@ -1,5 +1,7 @@ | ||||
| from __future__ import annotations | ||||
|  | ||||
| from math import radians | ||||
| from typing import Optional | ||||
| from typing import TYPE_CHECKING, Optional | ||||
|  | ||||
| import pygame | ||||
|  | ||||
| @@ -8,7 +10,11 @@ from src.remote_controller import RemoteController | ||||
| from src.utils import get_segments_intersection, segments_intersect | ||||
| 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: | ||||
| @@ -26,7 +32,10 @@ class Car: | ||||
|     RAYS_FOV = 180 | ||||
|     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.direction: Vec = direction | ||||
|         self.speed: float = 0 | ||||
| @@ -39,7 +48,7 @@ class Car: | ||||
|         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) | ||||
|         self.controller: RemoteController = RemoteController(self.game, self) | ||||
|         self.controller.start_server() | ||||
|  | ||||
|     def update(self, dt: float): | ||||
| @@ -77,7 +86,8 @@ class Car: | ||||
|         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) | ||||
|                 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] | ||||
| @@ -127,14 +137,17 @@ class Car: | ||||
|                             n *= -1 | ||||
|                             dist = -dist | ||||
|                         self.speed = 0 | ||||
|                         self.pos = self.pos + n * (self.COLLISION_MARGIN - dist) | ||||
|                         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) | ||||
|             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[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]: | ||||
| @@ -161,3 +174,8 @@ class Car: | ||||
|                     dist = d | ||||
|                     closest = p | ||||
|         return closest | ||||
|  | ||||
|     def reset(self): | ||||
|         self.pos = self.initial_pos.copy() | ||||
|         self.direction = self.initial_dir.copy() | ||||
|         self.speed = 0 | ||||
|   | ||||
| @@ -5,9 +5,14 @@ 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): | ||||
| @@ -29,8 +34,8 @@ class Command(abc.ABC): | ||||
|             ) | ||||
|         Command.REGISTRY[cls.TYPE] = cls | ||||
|  | ||||
|     @abc.abstractmethod | ||||
|     def get_payload(self) -> bytes: ... | ||||
|     def get_payload(self) -> bytes: | ||||
|         return b"" | ||||
|  | ||||
|     def pack(self) -> bytes: | ||||
|         payload: bytes = self.get_payload() | ||||
| @@ -42,8 +47,8 @@ class Command(abc.ABC): | ||||
|         return Command.REGISTRY[type].from_payload(data[1:]) | ||||
|  | ||||
|     @classmethod | ||||
|     @abc.abstractmethod | ||||
|     def from_payload(cls, payload: bytes) -> Command: ... | ||||
|     def from_payload(cls, payload: bytes) -> Command: | ||||
|         return cls() | ||||
|  | ||||
|  | ||||
| class ControlCommand(Command): | ||||
| @@ -64,3 +69,41 @@ class ControlCommand(Command): | ||||
|         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 | ||||
|   | ||||
							
								
								
									
										11
									
								
								src/game.py
									
									
									
									
									
								
							
							
						
						
									
										11
									
								
								src/game.py
									
									
									
									
									
								
							| @@ -19,10 +19,11 @@ class Game: | ||||
|         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.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.clock: pygame.time.Clock = pygame.time.Clock() | ||||
| @@ -49,6 +50,7 @@ class Game: | ||||
|             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: | ||||
| @@ -68,9 +70,10 @@ class Game: | ||||
|         self.car.controller.close() | ||||
|  | ||||
|     def render(self): | ||||
|         self.win.fill(self.BACKGROUND_COLOR) | ||||
|         self.track.render(self.win, self.camera) | ||||
|         self.car.render(self.win, self.camera, self.show_raycasts) | ||||
|         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: | ||||
|   | ||||
							
								
								
									
										50
									
								
								src/record_file.py
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										50
									
								
								src/record_file.py
									
									
									
									
									
										Normal 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 | ||||
							
								
								
									
										172
									
								
								src/recorder.py
									
									
									
									
									
								
							
							
						
						
									
										172
									
								
								src/recorder.py
									
									
									
									
									
								
							| @@ -1,58 +1,67 @@ | ||||
| import os | ||||
| from pathlib import Path | ||||
| import socket | ||||
| import struct | ||||
| from typing import Optional | ||||
|  | ||||
| from PyQt6 import uic | ||||
| from PyQt6.QtCore import QObject, Qt, QThread, QTimer, pyqtSignal, pyqtSlot | ||||
| from PyQt6.QtCore import QObject, QThread, QTimer, pyqtSignal, pyqtSlot | ||||
| from PyQt6.QtGui import QKeyEvent | ||||
| from PyQt6.QtWidgets import QMainWindow | ||||
|  | ||||
| from src.command import CarControl, Command, ControlCommand | ||||
| 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 = 4096 | ||||
|     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: QTimer = QTimer(self) | ||||
|         self.timer.timeout.connect(self.poll_socket) | ||||
|         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(f"Connected to server") | ||||
|         print("Connected to server") | ||||
|  | ||||
|     def poll_socket(self): | ||||
|         buffer: bytes = b"" | ||||
|         if not self.connected: | ||||
|             return | ||||
|  | ||||
|         try: | ||||
|             chunk: bytes = self.socket.recv(self.DATA_CHUNK_SIZE) | ||||
|             if not chunk: | ||||
|                 return | ||||
|             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 | ||||
|                 chunk: bytes = self.socket.recv(self.DATA_CHUNK_SIZE) | ||||
|                 if not chunk: | ||||
|                     return | ||||
|                 self.buffer += chunk | ||||
|  | ||||
|                 message: bytes = buffer[4:msg_end] | ||||
|                 buffer = buffer[msg_end:] | ||||
|                 self.on_message(message) | ||||
|                 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: | ||||
| @@ -78,15 +87,37 @@ class RecorderClient(QObject): | ||||
|     @pyqtSlot() | ||||
|     def shutdown(self): | ||||
|         print("Shutting down client") | ||||
|         self.timer.stop() | ||||
|         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__() | ||||
|  | ||||
| @@ -102,13 +133,6 @@ class RecorderWindow(Ui_Recorder, QMainWindow): | ||||
|  | ||||
|         uic.load_ui.loadUi("src/recorder.ui", self) | ||||
|  | ||||
|         self.command_directions = { | ||||
|             "w": CarControl.FORWARD, | ||||
|             "s": CarControl.BACKWARD, | ||||
|             "d": CarControl.RIGHT, | ||||
|             "a": CarControl.LEFT, | ||||
|         } | ||||
|  | ||||
|         self.forwardButton.pressed.connect( | ||||
|             lambda: self.on_car_controlled(CarControl.FORWARD, True) | ||||
|         ) | ||||
| @@ -140,25 +164,62 @@ class RecorderWindow(Ui_Recorder, QMainWindow): | ||||
|         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.recorded_data = [] | ||||
|         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): | ||||
|         pass | ||||
|         self.recording = not self.recording | ||||
|         self.recordDataButton.setText( | ||||
|             "Recording..." if self.recording else "Record") | ||||
|         self.send_command(RecordingCommand(self.recording)) | ||||
|  | ||||
|     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): | ||||
|         self.autopiloting = not self.autopiloting | ||||
| @@ -167,15 +228,54 @@ class RecorderWindow(Ui_Recorder, QMainWindow): | ||||
|         ) | ||||
|  | ||||
|     def save_record(self): | ||||
|         pass | ||||
|         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.recorded_data.append(snapshot) | ||||
|         self.nbrSnapshotSaved.setText(str(len(self.recorded_data))) | ||||
|         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) | ||||
|   | ||||
| @@ -99,7 +99,7 @@ | ||||
|       <item> | ||||
|        <widget class="QPushButton" name="resetButton"> | ||||
|         <property name="text"> | ||||
|          <string>Reset</string> | ||||
|          <string>Rollback</string> | ||||
|         </property> | ||||
|        </widget> | ||||
|       </item> | ||||
|   | ||||
| @@ -1,6 +1,6 @@ | ||||
| # 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 | ||||
| # 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.saveImgCheckBox.setText(_translate("Recorder", "Imgs")) | ||||
|         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.autopilotButton.setText(_translate("Recorder", "AutoPilot\n" | ||||
| "OFF")) | ||||
|   | ||||
| @@ -6,15 +6,18 @@ import struct | ||||
| import threading | ||||
| from typing import TYPE_CHECKING, Optional | ||||
|  | ||||
| from src.command import CarControl, Command, ControlCommand | ||||
| 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 = 4096 | ||||
|     DATA_CHUNK_SIZE = 65536 | ||||
|  | ||||
|     CONTROL_ATTRIBUTES: dict[CarControl, str] = { | ||||
|         CarControl.FORWARD: "forward", | ||||
| @@ -23,10 +26,14 @@ class RemoteController: | ||||
|         CarControl.RIGHT: "right", | ||||
|     } | ||||
|  | ||||
|     def __init__(self, car: Car, port: int = DEFAULT_PORT) -> None: | ||||
|     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: socket.socket = socket.socket( | ||||
|             socket.AF_INET, socket.SOCK_STREAM) | ||||
|         self.server_thread: threading.Thread = threading.Thread( | ||||
|             target=self.wait_for_connections, daemon=True | ||||
|         ) | ||||
| @@ -34,6 +41,10 @@ class RemoteController: | ||||
|         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: | ||||
| @@ -56,6 +67,7 @@ class RemoteController: | ||||
|         if self.client: | ||||
|             self.client.close() | ||||
|         self.server.close() | ||||
|         self.snapshot_timer.cancel() | ||||
|         self.running = False | ||||
|  | ||||
|     def on_client_connected(self, conn: socket.socket): | ||||
| @@ -107,6 +119,23 @@ class RemoteController: | ||||
|         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) | ||||
|   | ||||
| @@ -2,12 +2,17 @@ from __future__ import annotations | ||||
|  | ||||
| import struct | ||||
| from dataclasses import dataclass, field | ||||
| from typing import Optional | ||||
| 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) | ||||
| @@ -20,7 +25,8 @@ class Snapshot: | ||||
|     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) | ||||
|     raycast_distances: list[float] | tuple[float, ...] = field( | ||||
|         default_factory=list) | ||||
|     image: Optional[np.ndarray] = None | ||||
|  | ||||
|     def pack(self): | ||||
| @@ -36,10 +42,12 @@ class Snapshot: | ||||
|         ) | ||||
|  | ||||
|         nbr_raycasts: int = len(self.raycast_distances) | ||||
|         data += struct.pack(f">B{nbr_raycasts}f", nbr_raycasts, *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 += struct.pack(">II", | ||||
|                                 self.image.shape[0], self.image.shape[1]) | ||||
|             data += self.image.tobytes() | ||||
|         else: | ||||
|             data += struct.pack(">II", 0, 0) | ||||
| @@ -57,7 +65,7 @@ class Snapshot: | ||||
|         (nbr_raycasts,), data = iter_unpack(">B", 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: | ||||
|             image = np.frombuffer(data, np.uint8).reshape(h, w, 3) | ||||
| @@ -72,3 +80,27 @@ class Snapshot: | ||||
|             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) | ||||
|   | ||||
							
								
								
									
										10
									
								
								src/utils.py
									
									
									
									
									
								
							
							
						
						
									
										10
									
								
								src/utils.py
									
									
									
									
									
								
							| @@ -1,10 +1,12 @@ | ||||
| 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))) | ||||
| ROOT = Path(os.path.abspath(os.path.join( | ||||
|     os.path.dirname(__file__), os.pardir))) | ||||
|  | ||||
|  | ||||
| def orientation(a: Vec, b: Vec, c: Vec) -> float: | ||||
| @@ -59,3 +61,9 @@ def get_segments_intersection(a1: Vec, a2: Vec, b1: Vec, b2: Vec) -> Optional[Ve | ||||
|     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) | ||||
|   | ||||
| @@ -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) | ||||
|   | ||||
		Reference in New Issue
	
	Block a user