diff --git a/README.md b/README.md index 517177b..2e95b97 100644 --- a/README.md +++ b/README.md @@ -6,16 +6,14 @@ Wiki: [RLBot/python-interface/wiki](https://github.com/RLBot/python-interface/wi ## Quick Start -1. Install Python 3.11 or later +1. Install [Python 3.12 or later](https://www.python.org/) 1. Create a Python virtual environment - - `python3 -m venv venv` + - `python -m venv venv` 1. Activate the virtual environment - Windows: `.\venv\Scripts\activate` - Linux: `source venv/bin/activate` 1. Install the required packages - `pip install -r requirements.txt` -1. Download `RLBotServer.exe` and place it in the root directory - - 1. Modify `rlbot.toml` to your liking - Note: `dev.toml` also exists with a few changed settings that might be useful for development 1. Start a match with `python run.py` diff --git a/dev.toml b/dev.toml index 2c84cf8..c6e8d1d 100644 --- a/dev.toml +++ b/dev.toml @@ -1,5 +1,6 @@ +#:schema https://rlbot.org/schemas/match.json [rlbot] -launcher = "steam" +launcher = "Steam" # We'll run the bot ourself so we don't have restart the match # `python src/bot.py` in our case auto_start_agents = false diff --git a/rlbot.toml b/rlbot.toml index a95d3ce..dde9430 100644 --- a/rlbot.toml +++ b/rlbot.toml @@ -1,8 +1,9 @@ +#:schema https://rlbot.org/schemas/match.json [rlbot] # use this along with launcher = "custom" # launcher_arg = "legendary" # "Steam", "Epic", "Custom", "NoLaunch" -launcher = "steam" +launcher = "Steam" # Should RLBot start the bot processes automatically, or will a separate script start them auto_start_agents = true # Should RLBot wait for the bot processes to start before starting the match @@ -92,11 +93,11 @@ assist_goal_score = "Zero" input_restriction = "Default" [[cars]] -type = "human" -team = 0 +type = "Human" +team = "Blue" [[cars]] config_file = "src/bot.toml" -team = 1 +team = "Orange" # If RLBot should start this bot automatically with its specified `run_command` or not auto_start = true diff --git a/run.py b/run.py index 3f2e6f0..15cedca 100644 --- a/run.py +++ b/run.py @@ -10,7 +10,7 @@ root_dir = Path(__file__).parent # Start RLBotServer and the match - match_manager = MatchManager(root_dir) + match_manager = MatchManager() match_manager.start_match(root_dir / MATCH_CONFIG_FILE) sleep(5) diff --git a/run_only.py b/run_only.py index a219f9f..a1e55b8 100644 --- a/run_only.py +++ b/run_only.py @@ -13,7 +13,7 @@ match_manager.start_match(root_dir / MATCH_CONFIG_PATH, False) # wait - input("\nPress any enter to end the match: ") + _ = input("\nPress any enter to end the match: ") # end the match and disconnect, don't shutdown RLBotServer match_manager.stop_match() diff --git a/src/bot.py b/src/bot.py index d811cca..430d60e 100644 --- a/src/bot.py +++ b/src/bot.py @@ -1,5 +1,8 @@ +from typing import override + from rlbot.flat import BallAnchor, ControllerState, GamePacket from rlbot.managers import Bot +from rlbot_flatbuffers import CarAnchor from util.ball_prediction_analysis import find_slice_at_time from util.boost_pad_tracker import BoostPadTracker @@ -10,12 +13,14 @@ class MyBot(Bot): active_sequence: Sequence | None = None - boost_pad_tracker = BoostPadTracker() + boost_pad_tracker: BoostPadTracker = BoostPadTracker() + @override def initialize(self): # Set up information about the boost pads now that the game is active and the info is available self.boost_pad_tracker.initialize_boosts(self.field_info) + @override def get_output(self, packet: GamePacket) -> ControllerState: """ This function will be called by the framework many times per second. This is where you can @@ -33,9 +38,7 @@ def get_output(self, packet: GamePacket) -> ControllerState: # This is good to keep at the beginning of get_output. It will allow you to continue # any sequences that you may have started during a previous call to get_output. if self.active_sequence is not None and not self.active_sequence.done: - controls = self.active_sequence.tick(packet) - if controls is not None: - return controls + return self.active_sequence.tick(packet) # Gather some information about our car and the ball my_car = packet.players[self.index] @@ -46,6 +49,8 @@ def get_output(self, packet: GamePacket) -> ControllerState: # By default we will chase the ball, but target_location can be changed later target_location = ball_location + self.renderer.begin_rendering() + if car_location.dist(ball_location) > 1500: # We're far away from the ball, let's try to lead it a little bit # self.ball_prediction can predict bounces, etc @@ -65,10 +70,12 @@ def get_output(self, packet: GamePacket) -> ControllerState: ) # Draw some things to help understand what the bot is thinking - self.renderer.draw_line_3d(car_location, target_location, self.renderer.white) + self.renderer.draw_line_3d( + CarAnchor(self.index), target_location, self.renderer.white + ) self.renderer.draw_string_3d( f"Speed: {car_velocity.length():.1f}", - car_location, + CarAnchor(self.index), 1, self.renderer.white, ) @@ -78,9 +85,11 @@ def get_output(self, packet: GamePacket) -> ControllerState: self.renderer.cyan, ) + self.renderer.end_rendering() + if 750 < car_velocity.length() < 800: # We'll do a front flip if the car is moving at a certain speed. - return self.begin_front_flip(packet) # type: ignore + return self.begin_front_flip(packet) controls = ControllerState() controls.steer = steer_toward_target(my_car, target_location) @@ -89,7 +98,7 @@ def get_output(self, packet: GamePacket) -> ControllerState: return controls - def begin_front_flip(self, packet: GamePacket): + def begin_front_flip(self, packet: GamePacket) -> ControllerState: # Send some quickchat just for fun # There won't be any content of the message for other bots, # but "I got it!" will be display for a human to see! diff --git a/src/bot.toml b/src/bot.toml index b0ba0b3..01dddd5 100644 --- a/src/bot.toml +++ b/src/bot.toml @@ -1,6 +1,7 @@ +#:schema https://rlbot.org/schemas/agent.json [settings] # The name that will be displayed in game -name = "PyExampleBot" +name = "Python Example" # Path to loadout config from runner loadout_file = "loadout.toml" # (OPTIONAL) - what you want the working directory set to @@ -17,6 +18,10 @@ run_command_linux = "../venv/bin/python bot.py" # This must be a unqiue identifier for your bot # recommended format is "developer/botname" agent_id = "rlbot_community/python_example" +# This is the path to your bot's logo image, +# which will be displayed in the GUI +# The example bot does not have a logo +logo_file = "" # These values are optional but useful metadata for helper programs [details] @@ -33,7 +38,7 @@ source_link = "https://github.com/RLBot/python-example" developer = "The RLBot community" # Programming language language = "Python" -# ALL POSSIBLE TAGS: 1v1, teamplay, goalie, hoops, dropshot, snow-day, spike-rush, heatseeker, memebot +# ALL POSSIBLE TAGS: 1v1, teamplay, goalie, hoops, dropshot, snow-day, rumble, spike-rush, heatseeker, memebot # NOTE: Only add the goalie tag if your bot only plays as a goalie; this directly contrasts with the teamplay tag! # NOTE: Only add a tag for a special game mode if you bot properly supports it tags = [] diff --git a/src/loadout.toml b/src/loadout.toml index ca36b34..e4430b5 100644 --- a/src/loadout.toml +++ b/src/loadout.toml @@ -1,6 +1,6 @@ # You don't have to manually edit this file! # RLBotGUI has an appearance editor with a nice colorpicker, database of items and more! -# To open it up, simply click the (i) icon next to your bot's name and then click Edit Appearance +# To open it up, simply click the (i) icon next to your bot's name and then click Edit Loadout [blue_loadout] # Primary Color selection diff --git a/src/util/ball_prediction_analysis.py b/src/util/ball_prediction_analysis.py index 6f3b991..1c38d03 100644 --- a/src/util/ball_prediction_analysis.py +++ b/src/util/ball_prediction_analysis.py @@ -11,7 +11,9 @@ GOAL_SEARCH_INCREMENT = 40 -def find_slice_at_time(ball_prediction: BallPrediction, game_time: float): +def find_slice_at_time( + ball_prediction: BallPrediction, game_time: float +) -> PredictionSlice | None: """ This will find the future position of the ball at the specified time. The returned Slice object will also include the ball's velocity, etc. @@ -22,10 +24,9 @@ def find_slice_at_time(ball_prediction: BallPrediction, game_time: float): ) # We know that there are 120 slices per second. if 0 <= approx_index < len(ball_prediction.slices): return ball_prediction.slices[approx_index] - return None -def predict_future_goal(ball_prediction: BallPrediction): +def predict_future_goal(ball_prediction: BallPrediction) -> PredictionSlice | None: """ Analyzes the ball prediction to see if the ball will enter one of the goals. Only works on standard arenas. Will return the first ball slice which appears to be inside the goal, or None if it does not enter a goal. @@ -42,8 +43,8 @@ def find_matching_slice( ball_prediction: BallPrediction, start_index: int, predicate: Callable[[PredictionSlice], bool], - search_increment=1, -): + search_increment: int = 1, +) -> PredictionSlice | None: """ Tries to find the first slice in the ball prediction which satisfies the given predicate. For example, you could find the first slice below a certain height. Will skip ahead through the packet by search_increment @@ -59,4 +60,3 @@ def find_matching_slice( ball_slice = ball_prediction.slices[j] if predicate(ball_slice): return ball_slice - return None diff --git a/src/util/boost_pad_tracker.py b/src/util/boost_pad_tracker.py index b05b88a..f40f805 100644 --- a/src/util/boost_pad_tracker.py +++ b/src/util/boost_pad_tracker.py @@ -1,11 +1,11 @@ -from dataclasses import dataclass +from dataclasses import dataclass, field from rlbot.flat import FieldInfo, GamePacket from util.vec import Vec3 -@dataclass +@dataclass(slots=True) class BoostPad: location: Vec3 is_full_boost: bool @@ -13,6 +13,7 @@ class BoostPad: timer: float # Counts the number of seconds that the pad has been *inactive* +@dataclass(init=False, slots=True) class BoostPadTracker: """ This class merges together the boost pad location info with the is_active info so you can access it @@ -20,21 +21,18 @@ class BoostPadTracker: game has started, and then update_boost_status every frame so that it knows which pads are active. """ - def __init__(self): - self.boost_pads: list[BoostPad] = [] - self._full_boosts_only: list[BoostPad] = [] + boost_pads: list[BoostPad] = field(default_factory=lambda: []) + _full_boosts_only: list[BoostPad] = field(default_factory=lambda: []) def initialize_boosts(self, game_info: FieldInfo): - self.boost_pads: list[BoostPad] = [ + self.boost_pads = [ BoostPad(Vec3(rb.location), rb.is_full_boost, False, 0) for rb in game_info.boost_pads ] # Cache the list of full boosts since they're commonly requested. # They reference the same objects in the boost_pads list. - self._full_boosts_only: list[BoostPad] = [ - bp for bp in self.boost_pads if bp.is_full_boost - ] + self._full_boosts_only = [bp for bp in self.boost_pads if bp.is_full_boost] def update_boost_status(self, packet: GamePacket): for i, packet_pad in enumerate(packet.boost_pads): diff --git a/src/util/orientation.py b/src/util/orientation.py index 560490f..01bace4 100644 --- a/src/util/orientation.py +++ b/src/util/orientation.py @@ -1,9 +1,13 @@ import math +from dataclasses import dataclass + +from rlbot.flat import Rotator from util.vec import Vec3 # This is a helper class for calculating directions relative to your car. You can extend it or delete if you want. +@dataclass(init=False, slots=True) class Orientation: """ This class describes the orientation of an object from the rotation of the object. @@ -11,10 +15,18 @@ class Orientation: It can also be used to find relative locations. """ - def __init__(self, rotation): + yaw: float + roll: float + pitch: float + + forward: Vec3 + right: Vec3 + up: Vec3 + + def __init__(self, rotation: Rotator): + self.pitch = float(rotation.pitch) self.yaw = float(rotation.yaw) self.roll = float(rotation.roll) - self.pitch = float(rotation.pitch) cr = math.cos(self.roll) sr = math.sin(self.roll) diff --git a/src/util/sequence.py b/src/util/sequence.py index 691343b..f1d1473 100644 --- a/src/util/sequence.py +++ b/src/util/sequence.py @@ -1,15 +1,18 @@ -from dataclasses import dataclass +from abc import ABC, abstractmethod +from dataclasses import dataclass, field +from typing import override -from rlbot.flat import GamePacket, ControllerState +from rlbot.flat import ControllerState, GamePacket -@dataclass +@dataclass(slots=True) class StepResult: controls: ControllerState done: bool -class Step: +class Step(ABC): + @abstractmethod def tick(self, packet: GamePacket) -> StepResult: """ Return appropriate controls for this step in the sequence. If the step is over, you should @@ -20,17 +23,19 @@ def tick(self, packet: GamePacket) -> StepResult: raise NotImplementedError +@dataclass(slots=True) class ControlStep(Step): """ This allows you to repeat the same controls every frame for some specified duration. It's useful for scheduling the button presses needed for kickoffs / dodges / etc. """ - def __init__(self, duration: float, controls: ControllerState): - self.duration = duration - self.controls = controls - self.start_time: float | None = None + duration: float + controls: ControllerState + + start_time: float | None = field(default=None, init=False) + @override def tick(self, packet: GamePacket) -> StepResult: if self.start_time is None: self.start_time = packet.match_info.seconds_elapsed @@ -38,25 +43,26 @@ def tick(self, packet: GamePacket) -> StepResult: return StepResult(controls=self.controls, done=elapsed_time > self.duration) +@dataclass(slots=True) class Sequence: - def __init__(self, steps: list[Step]): - self.steps = steps - self.index = 0 - self.done = False + steps: list[Step] + + index: int = field(default=0, init=False) + done: bool = field(default=False, init=False) - def tick(self, packet: GamePacket) -> ControllerState | None: + def tick(self, packet: GamePacket) -> ControllerState: while self.index < len(self.steps): step = self.steps[self.index] result = step.tick(packet) - if result is None or result.controls is None or result.done: + if result.done: self.index += 1 if self.index >= len(self.steps): # The bot will know not to use this sequence next frame, even though we may be giving it controls. self.done = True - if result is not None and result.controls is not None: - # If the step was able to give us controls, return them to the bot. - return result.controls + # If the step was able to give us controls, return them to the bot. + return result.controls # Otherwise we will loop to the next step in the sequence. # If we reach here, we ran out of steps to attempt. self.done = True + return ControllerState() diff --git a/src/util/spikes.py b/src/util/spikes.py index 5295443..0155819 100644 --- a/src/util/spikes.py +++ b/src/util/spikes.py @@ -1,3 +1,6 @@ +import math +from dataclasses import dataclass + from rlbot.flat import GamePacket, PlayerInfo from util.vec import Vec3 @@ -11,16 +14,16 @@ MAX_DISTANCE_WHEN_SPIKED = 200 +@dataclass(init=False, slots=True) class SpikeWatcher: - def __init__(self): - self.carrying_car: PlayerInfo | None = None - self.spike_moment = 0 - self.carry_duration = 0 + carrying_car: PlayerInfo | None = None + spike_moment: float = 0 + carry_duration: float = 0 def read_packet(self, packet: GamePacket): ball_location = Vec3(packet.balls[0].physics.location) closest_candidate: PlayerInfo | None = None - closest_distance = 999999 + closest_distance = math.inf for car in packet.players: car_location = Vec3(car.physics.location) diff --git a/src/util/vec.py b/src/util/vec.py index f078842..73e1145 100644 --- a/src/util/vec.py +++ b/src/util/vec.py @@ -7,12 +7,12 @@ class Vec3(Vector3): """ This class should provide you with all the basic vector operations that you need, but feel free to extend its functionality when needed. - The vectors found in the GameTickPacket will be flatbuffer vectors. Cast them to Vec3 like this: + The vectors found in the GamePacket will be flatbuffer vectors. Cast them to Vec3 like this: `car_location = Vec3(car.physics.location)`. Remember that the in-game axis are left-handed. - When in doubt visit the wiki: https://github.com/RLBot/RLBot/wiki/Useful-Game-Values + When in doubt visit the wiki: https://wiki.rlbot.org/botmaking/useful-game-values/ """ def __new__( @@ -50,24 +50,24 @@ def __neg__(self): def __mul__(self, scale: float) -> "Vec3": return Vec3(self.x * scale, self.y * scale, self.z * scale) - def __rmul__(self, scale): + def __rmul__(self, scale: "float | Vec3") -> "Vec3": return self * scale def __truediv__(self, scale: float) -> "Vec3": scale = 1 / float(scale) return self * scale - def __str__(self): + def __str__(self) -> str: return f"Vec3({self.x:.2f}, {self.y:.2f}, {self.z:.2f})" - def __repr__(self): - return self.__str__() + def __repr__(self) -> str: + return f"Vec3({self.x}, {self.y}, {self.z})" - def flat(self): + def flat(self) -> "Vec3": """Returns a new Vec3 that equals this Vec3 but projected onto the ground plane. I.e. where z=0.""" return Vec3(self.x, self.y, 0) - def length(self): + def length(self) -> float: """Returns the length of the vector. Also called magnitude and norm.""" return math.sqrt(self.x**2 + self.y**2 + self.z**2) @@ -75,7 +75,7 @@ def dist(self, other: "Vec3") -> float: """Returns the distance between this vector and another vector using pythagoras.""" return (self - other).length() - def normalized(self): + def normalized(self) -> "Vec3": """Returns a vector with the same direction but a length of one.""" return self / self.length()