diff --git a/.gitignore b/.gitignore index 485dee6..7271969 100644 --- a/.gitignore +++ b/.gitignore @@ -1 +1,8 @@ .idea + +**/__pycache__/ +test_rsim.py +/collision_meshes +/.vscode +*.egg-info/ +build/ \ No newline at end of file diff --git a/rlgym_compat/__init__.py b/rlgym_compat/__init__.py index 07d22c3..e43068e 100644 --- a/rlgym_compat/__init__.py +++ b/rlgym_compat/__init__.py @@ -1,4 +1,8 @@ +from .car import Car from .common_values import BLUE_TEAM, ORANGE_TEAM +from .game_config import GameConfig from .game_state import GameState from .physics_object import PhysicsObject -from .player_data import PlayerData +from .v1 import PhysicsObject as V1PhysicsObject +from .v1 import PlayerData as V1PlayerData +from .v1_game_state import V1GameState diff --git a/rlgym_compat/car.py b/rlgym_compat/car.py new file mode 100644 index 0000000..de66155 --- /dev/null +++ b/rlgym_compat/car.py @@ -0,0 +1,332 @@ +from collections import deque +from dataclasses import dataclass +from typing import Optional, Tuple + +import numpy as np +from rlbot.flat import AirState, BoxShape, GamePacket, PlayerInfo, Vector3 + +from .common_values import ( + BLUE_TEAM, + BREAKOUT, + DOMINUS, + DOUBLEJUMP_MAX_DELAY, + FLIP_TORQUE_TIME, + HYBRID, + JUMP_RESET_TIME_PAD, + MERC, + MIN_BOOST_TIME, + MIN_JUMP_TIME, + OCTANE, + ORANGE_TEAM, + PLANK, + POWERSLIDE_FALL_RATE, + POWERSLIDE_RISE_RATE, + TICK_TIME, + TICKS_PER_SECOND, +) +from .extra_info import ExtraPlayerInfo +from .physics_object import PhysicsObject +from .utils import compare_hitbox_shape, create_default_init + + +@dataclass(init=False) +class Car: + # Misc Data + team_num: int # the team of this car, constants in common_values.py + hitbox_type: int # the hitbox of this car, constants in common_values.py + ball_touches: int # number of ball touches since last state was sent + bump_victim_id: Optional[ + int + ] # The agent ID of the car you had car contact with if any + + # Actual State + demo_respawn_timer: float # time, in seconds, until respawn, or 0 if alive (in [0,3] unless changed in mutator config) + wheels_with_contact: Tuple[ + bool, bool, bool, bool + ] # front_left, front_right, back_left, back_right + supersonic_time: float # time, in seconds, since car entered supersonic state (reset to 0 when exited supersonic state) (in [0, infinity) but only relevant values are in [0,1] (1 comes from SUPERSONIC_MAINTAIN_MAX_TIME in RLConst.h)) + boost_amount: float # (in [0,100]) + boost_active_time: float # time, in seconds, since car started pressing boost (reset to 0 when boosting stops) (in [0, infinity) but only relevant values are in [0,0.1] (0.1 comes from BOOST_MIN_TIME in RLConst.h)) + handbrake: float # indicates the magnitude of the handbrake, which ramps up and down when handbrake is pressed/released (in [0,1]) + + # Jump Stuff + is_jumping: bool # whether the car is currently jumping (you gain a little extra velocity while holding jump) + has_jumped: bool # whether the car has jumped since last time it was on ground + is_holding_jump: bool # whether you pressed jump last tick or not + jump_time: float # time, in seconds, since jump was pressed while car was on ground, clamped to 0.2 (reset to 0 when car presses jump while on ground) (in [0,0.2] (0.2 comes from JUMP_MAX_TIME in RLConst.h)) + + # Flip Stuff + has_flipped: bool # whether the car has flipped since last time it was on ground + has_double_jumped: ( + bool # whether the car has double jumped since last time it was on ground + ) + air_time_since_jump: float # time, in seconds, since a jump off ground ended (reset to 0 when car is on ground or has not jumped or is jumping) (in [0, infinity) but only relevant values are in [0,1.25] (1.25 comes from DOUBLEJUMP_MAX_DELAY in RLConst.h)) + flip_time: float # time, in seconds, since flip (or stall) was initiated (reset to 0 when car is on ground) (in [0, infinity) but only relevant values are in [0, 0.95] (0.95 comes from FLIP_TORQUE_TIME + FLIP_PITCHLOCK_EXTRA_TIME in RLConst.h)) + flip_torque: ( + np.ndarray + ) # torque applied to the car for the duration of the flip (in [0,1]) + + # AutoFlip Stuff - What helps you recover from turtling + is_autoflipping: bool # changes to false after max autoflip time + autoflip_timer: float # time, in seconds, until autoflip force ends (in [0,0.4] (0.4 comes from CAR_AUTOFLIP_TIME in RLConst.h)) + autoflip_direction: float # 1 or -1, determines roll direction + + # Physics + physics: PhysicsObject + _inverted_physics: PhysicsObject # Cache for inverted physics + + # RLBot Compat specific fields + _next_action_tick_duration: int + _ball_touch_ticks: deque[bool] # history for past _tick_skip ticks + _prev_air_state: int + _game_seconds: float + _cur_tick: int + + __slots__ = tuple(__annotations__) + + exec(create_default_init(__slots__)) + + @property + def is_blue(self) -> bool: + return self.team_num == BLUE_TEAM + + @property + def is_orange(self) -> bool: + return self.team_num == ORANGE_TEAM + + @property + def is_demoed(self) -> bool: + return self.demo_respawn_timer > 0 + + @property + def is_boosting(self) -> bool: + return self.boost_active_time > 0 + + @property + def is_supersonic(self) -> bool: + return self.supersonic_time > 0 + + @property + def on_ground(self) -> bool: + return sum(self.wheels_with_contact) >= 3 + + @on_ground.setter + def on_ground(self, value: bool): + self.wheels_with_contact = (value, value, value, value) + + @property + def has_flip(self) -> bool: + return ( + not self.has_double_jumped + and not self.has_flipped + and self.air_time_since_jump < DOUBLEJUMP_MAX_DELAY + ) + + @property + def can_flip(self) -> bool: + return not self.on_ground and not self.is_holding_jump and self.has_flip + + @property + def is_flipping(self) -> bool: + return self.has_flipped and self.flip_time < FLIP_TORQUE_TIME + + @is_flipping.setter + def is_flipping(self, value: bool): + if value: + self.has_flipped = True + if self.flip_time >= FLIP_TORQUE_TIME: + self.flip_time = 0 + else: + self.flip_time = FLIP_TORQUE_TIME + + @property + def had_car_contact(self) -> bool: + return self.bump_victim_id is not None + + @property + def inverted_physics(self) -> PhysicsObject: + if self._inverted_physics is None: + self._inverted_physics = self.physics.inverted() + return self._inverted_physics + + # Octane: hitbox=BoxShape(length=118.00738, width=84.19941, height=36.159073), hitbox_offset=Vector3(x=13.87566, y=0, z=20.754988) + # Dominus: hitbox=BoxShape(length=127.92678, width=83.27995, height=31.3), hitbox_offset=Vector3(x=9, y=0, z=15.75) + # Batmobile: hitbox=BoxShape(length=128.81978, width=84.670364, height=29.394402), hitbox_offset=Vector3(x=9.008572, y=0, z=12.0942) + # Breakout: hitbox=BoxShape(length=131.49236, width=80.521, height=30.3), hitbox_offset=Vector3(x=12.5, y=0, z=11.75) + # Venom: hitbox=BoxShape(length=127.01919, width=82.18787, height=34.159073), hitbox_offset=Vector3(x=13.87566, y=0, z=20.754988) + # Merc: hitbox=BoxShape(length=120.72023, width=76.71031, height=41.659073), hitbox_offset=Vector3(x=11.37566, y=0, z=21.504988) + + @staticmethod + def detect_hitbox(hitbox_shape: BoxShape, hitbox_offset: Vector3): + if compare_hitbox_shape(hitbox_shape, 118.00738, 84.19941, 36.159073): + return OCTANE + if compare_hitbox_shape(hitbox_shape, 127.92678, 83.27995, 31.3): + return DOMINUS + if compare_hitbox_shape(hitbox_shape, 128.81978, 84.670364, 29.394402): + return PLANK + if compare_hitbox_shape(hitbox_shape, 131.49236, 80.521, 30.3): + return BREAKOUT + if compare_hitbox_shape(hitbox_shape, 127.01919, 82.18787, 34.159073): + return HYBRID + if compare_hitbox_shape(hitbox_shape, 120.72023, 76.71031, 41.659073): + return MERC + return OCTANE + + @staticmethod + def create_compat_car( + packet: GamePacket, player_index: int, action_tick_duration: int + ): + player_info = packet.players[player_index] + car = Car() + car.team_num = BLUE_TEAM if player_info.team == 0 else ORANGE_TEAM + car.hitbox_type = Car.detect_hitbox( + player_info.hitbox, player_info.hitbox_offset + ) + car.ball_touches = 0 + car.bump_victim_id = None + car.demo_respawn_timer = 0 + car.on_ground = player_info.air_state == AirState.OnGround + car.supersonic_time = 0 + car.boost_amount = player_info.boost + car.boost_active_time = 0 + car.handbrake = 0 + car.has_jumped = player_info.has_jumped + car.is_holding_jump = player_info.last_input.jump + car.is_jumping = False + car.jump_time = 0 + car.has_flipped = player_info.has_dodged + car.has_double_jumped = player_info.has_double_jumped + if player_info.dodge_timeout == -1: + car.air_time_since_jump = 0 + else: + car.air_time_since_jump = DOUBLEJUMP_MAX_DELAY - player_info.dodge_timeout + car.flip_time = player_info.dodge_elapsed + car.flip_torque = np.array( + [-player_info.dodge_dir.y, player_info.dodge_dir.x, 0] + ) + car.is_autoflipping = False + car.autoflip_timer = 0 + car.autoflip_direction = 0 + car.physics = PhysicsObject.create_compat_physics_object() + car._prev_air_state = int(player_info.air_state) + car._game_seconds = packet.match_info.seconds_elapsed + car._cur_tick = packet.match_info.frame_num + return car + + def reset_ball_touches(self): + self.ball_touches = 0 + + def update( + self, + player_info: PlayerInfo, + game_tick: int, + extra_player_info: Optional[ExtraPlayerInfo] = None, + ): + # Assuming hitbox_type and team_num can't change without updating spawn id (and therefore creating new compat car) + ticks_elapsed = game_tick - self._cur_tick + self._cur_tick = game_tick + time_elapsed = TICK_TIME * ticks_elapsed + self._game_seconds += time_elapsed + + if player_info.latest_touch is not None: + ticks_since_touch = int( + round( + (self._game_seconds - player_info.latest_touch.game_seconds) + * TICKS_PER_SECOND + ) + ) + if ticks_since_touch < ticks_elapsed: + self.ball_touches += 1 + self.demo_respawn_timer = ( + 0 + if player_info.demolished_timeout == -1 + else player_info.demolished_timeout + ) + if player_info.is_supersonic: + self.supersonic_time += time_elapsed + else: + self.supersonic_time = 0 + self.boost_amount = player_info.boost + # Taken from rocket sim + if self.boost_active_time > 0: + if ( + not player_info.last_input.boost + and self.boost_active_time >= MIN_BOOST_TIME + ): + self.boost_active_time = 0 + else: + self.boost_active_time += time_elapsed + else: + if player_info.last_input.boost: + self.boost_active_time = time_elapsed + + if player_info.last_input.handbrake: + self.handbrake += POWERSLIDE_RISE_RATE * time_elapsed + else: + self.handbrake -= POWERSLIDE_FALL_RATE * time_elapsed + self.handbrake = min(1, max(0, self.handbrake)) + + self.is_holding_jump = player_info.last_input.jump + + self.has_jumped = player_info.has_jumped + self.has_double_jumped = player_info.has_double_jumped + self.has_flipped = player_info.has_dodged + self.flip_time = player_info.dodge_elapsed + self.flip_torque[0] = -player_info.dodge_dir.y + self.flip_torque[1] = player_info.dodge_dir.x + if self.has_jumped or self.is_jumping: + self.jump_time += TICK_TIME * ticks_elapsed + if player_info.dodge_timeout == -1: + self.air_time_since_jump = 0 + else: + self.air_time_since_jump = DOUBLEJUMP_MAX_DELAY - player_info.dodge_timeout + + match player_info.air_state: + case AirState.OnGround: + self.on_ground = True + self.is_jumping = False + self.air_time_since_jump = 0 + case AirState.Jumping: + if self._prev_air_state == int(AirState.OnGround): + self.jump_time = 0 + # After pressing jump, it usually takes 6 ticks to leave the ground. This is the only air state where we are uncertain if we are on the ground or not. + self.on_ground = self.jump_time <= 6 * TICK_TIME + self.is_jumping = True + case AirState.InAir: + self.on_ground = False + self.is_jumping = False + case AirState.Dodging: + self.on_ground = False + self.is_jumping = False + case AirState.DoubleJumping: + self.on_ground = False + self.is_jumping = False + + self.physics.update(player_info.physics) + self._inverted_physics = self.physics.inverted() + + # Override with extra info if available + if extra_player_info is not None: + if extra_player_info.wheels_with_contact is not None: + self.wheels_with_contact = extra_player_info.wheels_with_contact + if extra_player_info.handbrake is not None: + self.handbrake = extra_player_info.handbrake + if extra_player_info.ball_touches is not None: + self.ball_touches = extra_player_info.ball_touches + if ( + extra_player_info.car_contact_id is not None + and extra_player_info.car_contact_cooldown_timer is not None + ): + self.bump_victim_id = ( + extra_player_info.car_contact_id + if extra_player_info.car_contact_cooldown_timer > 0 + else None + ) + if extra_player_info.is_autoflipping is not None: + self.is_autoflipping = extra_player_info.is_autoflipping + if extra_player_info.autoflip_timer is not None: + self.autoflip_timer = extra_player_info.autoflip_timer + if extra_player_info.autoflip_direction is not None: + self.autoflip_direction = extra_player_info.autoflip_direction + + self._prev_air_state = int(player_info.air_state) diff --git a/rlgym_compat/common_values.py b/rlgym_compat/common_values.py index e8659b7..7ed5561 100644 --- a/rlgym_compat/common_values.py +++ b/rlgym_compat/common_values.py @@ -1,5 +1,131 @@ -ORANGE_GOAL_CENTER = [0, 5120, 642.775 / 2] -BLUE_GOAL_CENTER = [0, -5120, 642.775 / 2] +SIDE_WALL_X = 4096 # +/- +BACK_WALL_Y = 5120 # +/- +CEILING_Z = 2044 +BACK_NET_Y = 6000 # +/- + +GOAL_HEIGHT = 642.775 +GOAL_CENTER_TO_POST = 892.755 + +ORANGE_GOAL_CENTER = (0, BACK_WALL_Y, GOAL_HEIGHT / 2) +BLUE_GOAL_CENTER = (0, -BACK_WALL_Y, GOAL_HEIGHT / 2) + +# Often more useful than center +ORANGE_GOAL_BACK = (0, BACK_NET_Y, GOAL_HEIGHT / 2) +BLUE_GOAL_BACK = (0, -BACK_NET_Y, GOAL_HEIGHT / 2) + +ORANGE_GOAL_TOP_LEFT = (GOAL_CENTER_TO_POST, BACK_WALL_Y, GOAL_HEIGHT) +BLUE_GOAL_TOP_LEFT = (-GOAL_CENTER_TO_POST, -BACK_WALL_Y, GOAL_HEIGHT) + +ORANGE_GOAL_TOP_RIGHT = (-GOAL_CENTER_TO_POST, BACK_WALL_Y, GOAL_HEIGHT) +BLUE_GOAL_TOP_RIGHT = (GOAL_CENTER_TO_POST, -BACK_WALL_Y, GOAL_HEIGHT) + +ORANGE_GOAL_BOTTOM_LEFT = (GOAL_CENTER_TO_POST, BACK_WALL_Y, 0) +BLUE_GOAL_BOTTOM_LEFT = (-GOAL_CENTER_TO_POST, -BACK_WALL_Y, 0) + +ORANGE_GOAL_BOTTOM_RIGHT = (-GOAL_CENTER_TO_POST, BACK_WALL_Y, 0) +BLUE_GOAL_BOTTOM_RIGHT = (GOAL_CENTER_TO_POST, -BACK_WALL_Y, 0) + +ORANGE_FIELD_TOP_LEFT = (SIDE_WALL_X, BACK_WALL_Y, CEILING_Z) +BLUE_FIELD_TOP_LEFT = (-SIDE_WALL_X, -BACK_WALL_Y, CEILING_Z) + +ORANGE_FIELD_TOP_RIGHT = (-SIDE_WALL_X, BACK_WALL_Y, CEILING_Z) +BLUE_FIELD_TOP_RIGHT = (SIDE_WALL_X, -BACK_WALL_Y, CEILING_Z) + +ORANGE_FIELD_BOTTOM_LEFT = (SIDE_WALL_X, BACK_WALL_Y, 0) +BLUE_FIELD_BOTTOM_LEFT = (-SIDE_WALL_X, -BACK_WALL_Y, 0) + +ORANGE_FIELD_BOTTOM_RIGHT = (-SIDE_WALL_X, BACK_WALL_Y, 0) +BLUE_FIELD_BOTTOM_RIGHT = (SIDE_WALL_X, -BACK_WALL_Y, 0) + +BALL_RADIUS = 91.25 +TICKS_PER_SECOND = 120 +CORNER_CATHETUS_LENGTH = 1152 +RAMP_HEIGHT = 256 +UNREAL_UNITS_PER_METER = 100 +SMALL_PAD_RECHARGE_SECONDS = 4 +BIG_PAD_RECHARGE_SECONDS = 10 +GRAVITY = 650 # uu/s^2 +BOOST_CONSUMPTION_RATE = 33.3 # per second +CAR_MASS = 180 +BALL_MASS = 30 +BOOST_ACCELERATION = 991.666 # uu/s^2 + +BALL_MAX_SPEED = 6000 +CAR_MAX_SPEED = 2300 +SUPERSONIC_THRESHOLD = 2200 +CAR_MAX_ANG_VEL = 5.5 + BLUE_TEAM = 0 ORANGE_TEAM = 1 -NUM_ACTIONS = 8 \ No newline at end of file +NUM_ACTIONS = 8 + +OCTANE = 0 +DOMINUS = 1 +PLANK = 2 +BREAKOUT = 3 +HYBRID = 4 +MERC = 5 + +DOUBLEJUMP_MAX_DELAY = 1.25 +FLIP_TORQUE_TIME = 0.65 + +BOOST_LOCATIONS = ( + (0.0, -4240.0, 70.0), + (-1792.0, -4184.0, 70.0), + (1792.0, -4184.0, 70.0), + (-3072.0, -4096.0, 73.0), + (3072.0, -4096.0, 73.0), + (-940.0, -3308.0, 70.0), + (940.0, -3308.0, 70.0), + (0.0, -2816.0, 70.0), + (-3584.0, -2484.0, 70.0), + (3584.0, -2484.0, 70.0), + (-1788.0, -2300.0, 70.0), + (1788.0, -2300.0, 70.0), + (-2048.0, -1036.0, 70.0), + (0.0, -1024.0, 70.0), + (2048.0, -1036.0, 70.0), + (-3584.0, 0.0, 73.0), + (-1024.0, 0.0, 70.0), + (1024.0, 0.0, 70.0), + (3584.0, 0.0, 73.0), + (-2048.0, 1036.0, 70.0), + (0.0, 1024.0, 70.0), + (2048.0, 1036.0, 70.0), + (-1788.0, 2300.0, 70.0), + (1788.0, 2300.0, 70.0), + (-3584.0, 2484.0, 70.0), + (3584.0, 2484.0, 70.0), + (0.0, 2816.0, 70.0), + (-940.0, 3310.0, 70.0), + (940.0, 3308.0, 70.0), + (-3072.0, 4096.0, 73.0), + (3072.0, 4096.0, 73.0), + (-1792.0, 4184.0, 70.0), + (1792.0, 4184.0, 70.0), + (0.0, 4240.0, 70.0), +) + + +OCTANE = 0 +DOMINUS = 1 +PLANK = 2 +BREAKOUT = 3 +HYBRID = 4 +MERC = 5 + +TICK_TIME = 1 / TICKS_PER_SECOND +MIN_BOOST_TIME = 0.1 +POWERSLIDE_RISE_RATE = 5 +POWERSLIDE_FALL_RATE = 2 +MIN_JUMP_TIME = 0.025 +JUMP_RESET_TIME_PAD = 0.025 +LARGE_BOOST_INDICES = [3, 4, 15, 18, 29, 30] +BOOST_RECHARGE_TIMES = [ + ( + BIG_PAD_RECHARGE_SECONDS + if idx in LARGE_BOOST_INDICES + else SMALL_PAD_RECHARGE_SECONDS + ) + for idx in range(len(BOOST_LOCATIONS)) +] diff --git a/rlgym_compat/extra_info.py b/rlgym_compat/extra_info.py new file mode 100644 index 0000000..9be8938 --- /dev/null +++ b/rlgym_compat/extra_info.py @@ -0,0 +1,28 @@ +from dataclasses import dataclass +from typing import List, Optional, Tuple + + +@dataclass +class ExtraPlayerInfo: + wheels_with_contact: Optional[Tuple[bool, bool, bool, bool]] + handbrake: Optional[float] + ball_touches: Optional[int] + car_contact_id: Optional[int] + car_contact_cooldown_timer: Optional[float] + is_autoflipping: Optional[bool] + autoflip_timer: Optional[float] + autoflip_direction: Optional[float] # 1 or -1, determines roll direction + + +@dataclass +class ExtraBallInfo: + # Net that the heatseeker ball is targeting (0 for none, 1 for orange, -1 for blue) + heatseeker_target_dir: Optional[int] + heatseeker_target_speed: Optional[float] + heatseeker_time_since_hit: Optional[float] + + +@dataclass +class ExtraPacketInfo: + players: Optional[List[ExtraPlayerInfo]] + ball: Optional[ExtraBallInfo] diff --git a/rlgym_compat/game_config.py b/rlgym_compat/game_config.py new file mode 100644 index 0000000..b5f35f6 --- /dev/null +++ b/rlgym_compat/game_config.py @@ -0,0 +1,14 @@ +from dataclasses import dataclass + +from .utils import create_default_init + + +@dataclass(init=False) +class GameConfig: + gravity: float + boost_consumption: float + dodge_deadzone: float + + __slots__ = tuple(__annotations__) + + exec(create_default_init(__slots__)) diff --git a/rlgym_compat/game_state.py b/rlgym_compat/game_state.py index be8aade..a6800ae 100644 --- a/rlgym_compat/game_state.py +++ b/rlgym_compat/game_state.py @@ -1,63 +1,173 @@ -import numpy as np -from typing import List - -from rlbot.utils.structures.game_data_struct import GameTickPacket, FieldInfoPacket, PlayerInfo +from dataclasses import dataclass +from typing import Dict, Optional +import numpy as np +from rlbot.flat import ( + FieldInfo, + GamePacket, + GravityMutator, + MatchConfiguration, + MatchPhase, +) + +from .car import Car +from .common_values import BOOST_LOCATIONS +from .extra_info import ExtraPacketInfo +from .game_config import GameConfig from .physics_object import PhysicsObject -from .player_data import PlayerData +from .utils import create_default_init +@dataclass(init=False) class GameState: - def __init__(self, game_info: FieldInfoPacket): - self.blue_score = 0 - self.orange_score = 0 - self.players: List[PlayerData] = [] - self._on_ground_ticks = np.zeros(64) - - self.ball: PhysicsObject = PhysicsObject() - self.inverted_ball: PhysicsObject = PhysicsObject() - - # List of "booleans" (1 or 0) - self.boost_pads: np.ndarray = np.zeros(game_info.num_boosts, dtype=np.float32) - self.inverted_boost_pads: np.ndarray = np.zeros_like(self.boost_pads, dtype=np.float32) - - def decode(self, packet: GameTickPacket, ticks_elapsed=1): - self.blue_score = packet.teams[0].score - self.orange_score = packet.teams[1].score - - for i in range(packet.num_boost): - self.boost_pads[i] = packet.game_boosts[i].is_active - self.inverted_boost_pads[:] = self.boost_pads[::-1] - - self.ball.decode_ball_data(packet.game_ball.physics) - self.inverted_ball.invert(self.ball) - - self.players = [] - for i in range(packet.num_cars): - player = self._decode_player(packet.game_cars[i], i, ticks_elapsed) - self.players.append(player) - - if player.ball_touched: - self.last_touch = player.car_id - - def _decode_player(self, player_info: PlayerInfo, index: int, ticks_elapsed: int) -> PlayerData: - player_data = PlayerData() - - player_data.car_data.decode_car_data(player_info.physics) - player_data.inverted_car_data.invert(player_data.car_data) - - if player_info.has_wheel_contact: - self._on_ground_ticks[index] = 0 + tick_count: int + goal_scored: bool + config: GameConfig + cars: Dict[int, Car] + ball: PhysicsObject + _inverted_ball: PhysicsObject + boost_pad_timers: np.ndarray + _inverted_boost_pad_timers: np.ndarray + + _first_update_call: bool + _tick_skip: int + # Unless something changes, this mapping will be [14,10,7,12,8,11,29,4,3,15,18,30,1,2,5,6,9,20,19,22,21,23,25,32,31,26,27,24,28,33,17,13,16,0] for the standard map. + _boost_pad_order_mapping: np.ndarray + + __slots__ = tuple(__annotations__) + + exec(create_default_init(__slots__)) + + @property + def scoring_team(self) -> Optional[int]: + if self.goal_scored: + return 0 if self.ball.position[1] > 0 else 1 + return None + + @property + def inverted_ball(self) -> PhysicsObject: + self._inverted_ball = self.ball.inverted() + return self._inverted_ball + + @property + def inverted_boost_pad_timers(self) -> np.ndarray: + self._inverted_boost_pad_timers = np.ascontiguousarray( + self.boost_pad_timers[::-1] + ) + return self._inverted_boost_pad_timers + + @staticmethod + def create_compat_game_state( + field_info: FieldInfo, + match_settings=MatchConfiguration(), + tick_skip=-1, + standard_map=True, + ): + assert ( + tick_skip == -1 + ), "`tick_skip` was passed as a parameter to `create_compat_game_state`. This parameter is no longer used and should be removed, but be warned - there has been a breaking change for Car ball_touches to better reflect how this variable relates to the number of game ticks your action parser returns engine actions for each time it is called (which is not necessarily constant). Users who care about `Car`s' ball_touches should take care to manually call the new method `reset_car_ball_touches` appropriately." + state = GameState() + state.tick_count = 0 + state.goal_scored = False + state.config = GameConfig() + state.config.boost_consumption = 1 # Not modifiable + state.config.dodge_deadzone = 0.5 # Not modifiable + if match_settings.mutators is not None: + match match_settings.mutators.gravity: + case GravityMutator.Low: + gravity = -325 + case GravityMutator.Default: + gravity = -650 + case GravityMutator.High: + gravity = -1137.5 + case GravityMutator.SuperHigh: + gravity = -3250 + case GravityMutator.Reverse: + gravity = 650 + state.config.gravity = gravity / -650.0 + else: + state.config.gravity = 1 + state.cars = {} + state.ball = PhysicsObject.create_compat_physics_object() + state.boost_pad_timers = np.zeros(len(field_info.boost_pads), dtype=np.float32) + if standard_map: + boost_locations = np.array(BOOST_LOCATIONS) + state._boost_pad_order_mapping = np.zeros( + len(field_info.boost_pads), dtype=np.int32 + ) + for rlbot_boost_pad_idx, boost_pad in enumerate(field_info.boost_pads): + loc = np.array( + [boost_pad.location.x, boost_pad.location.y, boost_pad.location.z] + ) + similar_vals = np.isclose(boost_locations[:, :2], loc[:2], atol=2).all( + axis=1 + ) + candidate_idx = np.argmax(similar_vals) + assert similar_vals[ + candidate_idx + ], f"Boost pad at location {loc} doesn't match any in the standard map (see BOOST_LOCATIONS in common_values.py)" + state._boost_pad_order_mapping[rlbot_boost_pad_idx] = candidate_idx + else: + state._boost_pad_order_mapping = [ + idx for idx in range(len(field_info.boost_pads)) + ] + state._first_update_call = True + return state + + def reset_car_ball_touches(self): + for car in self.cars.values(): + car.reset_ball_touches() + + def update( + self, + packet: GamePacket, + extra_info: Optional[ExtraPacketInfo] = None, + ): + doing_first_call = False + if self._first_update_call: + self.tick_count = packet.match_info.frame_num + self._first_update_call = False + doing_first_call = True + + # Initialize new players + for player_index, player_info in enumerate(packet.players): + if player_info.player_id not in self.cars: + self.cars[player_info.player_id] = Car.create_compat_car( + packet, player_index, self._tick_skip + ) + # Remove old players + packet_player_ids = [player.player_id for player in packet.players] + agent_ids_to_remove = [] + for agent_id in self.cars: + if agent_id not in packet_player_ids: + agent_ids_to_remove.append(agent_id) + for agent_id in agent_ids_to_remove: + self.cars.pop(agent_id) + + ticks_elapsed = packet.match_info.frame_num - self.tick_count + self.tick_count = packet.match_info.frame_num + # Nothing to do + if ticks_elapsed == 0 and not doing_first_call: + return + + self.goal_scored = packet.match_info.match_phase == MatchPhase.GoalScored + + if len(packet.balls) > 0: + ball = packet.balls[0] + self.ball.update(ball.physics) else: - self._on_ground_ticks[index] += ticks_elapsed - - player_data.car_id = index - player_data.team_num = player_info.team - player_data.is_demoed = player_info.is_demolished - player_data.on_ground = player_info.has_wheel_contact or self._on_ground_ticks[index] <= 6 - player_data.ball_touched = False - player_data.has_jump = not player_info.jumped - player_data.has_flip = not player_info.double_jumped # RLGym does consider the timer/unlimited flip, but i'm to lazy to track that in rlbot - player_data.boost_amount = player_info.boost / 100 - - return player_data + ball = None + + for player_index, player_info in enumerate(packet.players): + self.cars[player_info.player_id].update( + player_info, + packet.match_info.frame_num, + extra_player_info=( + None if extra_info is None else extra_info.players[player_index] + ), + ) + + for boost_pad_index, boost_pad in enumerate(packet.boost_pads): + self.boost_pad_timers[self._boost_pad_order_mapping[boost_pad_index]] = ( + boost_pad.timer + ) diff --git a/rlgym_compat/math.py b/rlgym_compat/math.py new file mode 100644 index 0000000..a6b5812 --- /dev/null +++ b/rlgym_compat/math.py @@ -0,0 +1,173 @@ +""" +A basic library for useful mathematical operations. +""" + +import numpy as np + + +def get_dist(x, y): + return np.subtract(x, y) + + +def vector_projection(vec, dest_vec, mag_squared=None): + if mag_squared is None: + norm = vecmag(dest_vec) + if norm == 0: + return dest_vec + mag_squared = norm * norm + + if mag_squared == 0: + return dest_vec + + dot = np.dot(vec, dest_vec) + projection = np.multiply(np.divide(dot, mag_squared), dest_vec) + return projection + + +def scalar_projection(vec, dest_vec): + norm = vecmag(dest_vec) + + if norm == 0: + return 0 + + dot = np.dot(vec, dest_vec) / norm + return dot + + +def squared_vecmag(vec): + x = np.linalg.norm(vec) + return x * x + + +def vecmag(vec): + norm = np.linalg.norm(vec) + return norm + + +def unitvec(vec): + return np.divide(vec, vecmag(vec)) + + +def cosine_similarity(a, b): + return np.dot(a / np.linalg.norm(a), b / np.linalg.norm(b)) + + +def quat_to_euler(quat): + w, x, y, z = quat + sinr_cosp = 2 * (w * x + y * z) + cosr_cosp = 1 - 2 * (x * x + y * y) + sinp = 2 * (w * y - z * x) + siny_cosp = 2 * (w * z + x * y) + cosy_cosp = 1 - 2 * (y * y + z * z) + + roll = np.arctan2(sinr_cosp, cosr_cosp) + if abs(sinp) > 1: + pitch = np.pi / 2 + else: + pitch = np.arcsin(sinp) + yaw = np.arctan2(siny_cosp, cosy_cosp) + + return np.array([-pitch, yaw, -roll]) + + +# From RLUtilities +def quat_to_rot_mtx(quat: np.ndarray) -> np.ndarray: + w = -quat[0] + x = -quat[1] + y = -quat[2] + z = -quat[3] + + theta = np.zeros((3, 3)) + + norm = np.dot(quat, quat) + if norm != 0: + s = 1.0 / norm + + # front direction + theta[0, 0] = 1.0 - 2.0 * s * (y * y + z * z) + theta[1, 0] = 2.0 * s * (x * y + z * w) + theta[2, 0] = 2.0 * s * (x * z - y * w) + + # left direction + theta[0, 1] = 2.0 * s * (x * y - z * w) + theta[1, 1] = 1.0 - 2.0 * s * (x * x + z * z) + theta[2, 1] = 2.0 * s * (y * z + x * w) + + # up direction + theta[0, 2] = 2.0 * s * (x * z + y * w) + theta[1, 2] = 2.0 * s * (y * z - x * w) + theta[2, 2] = 1.0 - 2.0 * s * (x * x + y * y) + + return theta + + +def rotation_to_quaternion(m: np.ndarray) -> np.ndarray: + trace = np.trace(m) + q = np.zeros(4) + + if trace > 0: + s = (trace + 1) ** 0.5 + q[0] = s * 0.5 + s = 0.5 / s + q[1] = (m[2, 1] - m[1, 2]) * s + q[2] = (m[0, 2] - m[2, 0]) * s + q[3] = (m[1, 0] - m[0, 1]) * s + else: + if m[0, 0] >= m[1, 1] and m[0, 0] >= m[2, 2]: + s = (1 + m[0, 0] - m[1, 1] - m[2, 2]) ** 0.5 + inv_s = 0.5 / s + q[1] = 0.5 * s + q[2] = (m[1, 0] + m[0, 1]) * inv_s + q[3] = (m[2, 0] + m[0, 2]) * inv_s + q[0] = (m[2, 1] - m[1, 2]) * inv_s + elif m[1, 1] > m[2, 2]: + s = (1 + m[1, 1] - m[0, 0] - m[2, 2]) ** 0.5 + inv_s = 0.5 / s + q[1] = (m[0, 1] + m[1, 0]) * inv_s + q[2] = 0.5 * s + q[3] = (m[1, 2] + m[2, 1]) * inv_s + q[0] = (m[0, 2] - m[2, 0]) * inv_s + else: + s = (1 + m[2, 2] - m[0, 0] - m[1, 1]) ** 0.5 + inv_s = 0.5 / s + q[1] = (m[0, 2] + m[2, 0]) * inv_s + q[2] = (m[1, 2] + m[2, 1]) * inv_s + q[3] = 0.5 * s + q[0] = (m[1, 0] - m[0, 1]) * inv_s + + # q[[0, 1, 2, 3]] = q[[3, 0, 1, 2]] + + return -q + + +def euler_to_rotation(pyr): + cp, cy, cr = np.cos(pyr) + sp, sy, sr = np.sin(pyr) + + theta = np.zeros((3, 3)) + + # front + theta[0, 0] = cp * cy + theta[1, 0] = cp * sy + theta[2, 0] = sp + + # left + theta[0, 1] = cy * sp * sr - cr * sy + theta[1, 1] = sy * sp * sr + cr * cy + theta[2, 1] = -cp * sr + + # up + theta[0, 2] = -cr * cy * sp - sr * sy + theta[1, 2] = -cr * sy * sp + sr * cy + theta[2, 2] = cp * cr + + return theta + + +def rand_uvec3(rng: np.random.Generator = np.random): + vec = rng.random(3) - 0.5 + return vec / np.linalg.norm(vec) + + +def rand_vec3(max_norm, rng: np.random.Generator = np.random): + return rand_uvec3(rng) * (rng.random() * max_norm) diff --git a/rlgym_compat/physics_object.py b/rlgym_compat/physics_object.py index dc385d7..0249ed6 100644 --- a/rlgym_compat/physics_object.py +++ b/rlgym_compat/physics_object.py @@ -1,102 +1,145 @@ -import math -import numpy as np -from rlbot.utils.structures.game_data_struct import Physics, Vector3, Rotator +from dataclasses import dataclass +from typing import Optional, Self +import numpy as np +from rlbot.flat import Physics -class PhysicsObject: - def __init__(self, position=None, euler_angles=None, linear_velocity=None, angular_velocity=None): - self.position: np.ndarray = position if position else np.zeros(3) - - # ones by default to prevent mathematical errors when converting quat to rot matrix on empty physics state - self.quaternion: np.ndarray = np.ones(4) - - self.linear_velocity: np.ndarray = linear_velocity if linear_velocity else np.zeros(3) - self.angular_velocity: np.ndarray = angular_velocity if angular_velocity else np.zeros(3) - self._euler_angles: np.ndarray = euler_angles if euler_angles else np.zeros(3) - self._rotation_mtx: np.ndarray = np.zeros((3,3)) - self._has_computed_rot_mtx = False - - self._invert_vec = np.asarray([-1, -1, 1]) - self._invert_pyr = np.asarray([0, math.pi, 0]) - - def decode_car_data(self, car_data: Physics): - self.position = self._vector_to_numpy(car_data.location) - self._euler_angles = self._rotator_to_numpy(car_data.rotation) - self.linear_velocity = self._vector_to_numpy(car_data.velocity) - self.angular_velocity = self._vector_to_numpy(car_data.angular_velocity) - - def decode_ball_data(self, ball_data: Physics): - self.position = self._vector_to_numpy(ball_data.location) - self.linear_velocity = self._vector_to_numpy(ball_data.velocity) - self.angular_velocity = self._vector_to_numpy(ball_data.angular_velocity) - - def invert(self, other): - self.position = other.position * self._invert_vec - self._euler_angles = other.euler_angles() + self._invert_pyr - self.linear_velocity = other.linear_velocity * self._invert_vec - self.angular_velocity = other.angular_velocity * self._invert_vec - - # pitch, yaw, roll - def euler_angles(self) -> np.ndarray: - return self._euler_angles +from .math import ( + euler_to_rotation, + quat_to_euler, + quat_to_rot_mtx, + rotation_to_quaternion, +) +from .utils import create_default_init, write_vector_into_numpy - def pitch(self): - return self._euler_angles[0] - def yaw(self): - return self._euler_angles[1] +@dataclass(init=False) +class PhysicsObject: + INV_VEC = np.array([-1, -1, 1], dtype=np.float32) + INV_MTX = np.array([[-1, -1, -1], [-1, -1, -1], [1, 1, 1]], dtype=np.float32) + + position: np.ndarray + linear_velocity: np.ndarray + angular_velocity: np.ndarray + _quaternion: Optional[np.ndarray] + _rotation_mtx: Optional[np.ndarray] + _euler_angles: Optional[np.ndarray] + + _rlbot_euler_angles: np.ndarray + + __slots__ = tuple(__annotations__) + + exec(create_default_init(__slots__)) + + def inverted(self) -> Self: + inv = PhysicsObject() + inv.position = self.position * PhysicsObject.INV_VEC + inv.linear_velocity = self.linear_velocity * PhysicsObject.INV_VEC + inv.angular_velocity = self.angular_velocity * PhysicsObject.INV_VEC + if ( + self._rotation_mtx is not None + or self._quaternion is not None + or self._euler_angles is not None + ): + inv.rotation_mtx = self.rotation_mtx * PhysicsObject.INV_MTX + return inv + + @property + def quaternion(self) -> np.ndarray: + if self._quaternion is None: + if self._rotation_mtx is not None: + self._quaternion = rotation_to_quaternion(self._rotation_mtx) + elif self._euler_angles is not None: + self._rotation_mtx = euler_to_rotation(self.euler_angles) + self._quaternion = rotation_to_quaternion(self._rotation_mtx) + else: + raise ValueError + return self._quaternion + + @quaternion.setter + def quaternion(self, val: np.ndarray): + self._quaternion = val + self._rotation_mtx = None + self._euler_angles = None + + @property + def rotation_mtx(self) -> np.ndarray: + if self._rotation_mtx is None: + if self._quaternion is not None: + self._rotation_mtx = quat_to_rot_mtx(self._quaternion) + elif self._euler_angles is not None: + self._rotation_mtx = euler_to_rotation(self._euler_angles) + else: + raise ValueError + return self._rotation_mtx - def roll(self): - return self._euler_angles[2] + @rotation_mtx.setter + def rotation_mtx(self, val: np.ndarray): + self._rotation_mtx = val + self._quaternion = None + self._euler_angles = None - def rotation_mtx(self) -> np.ndarray: - if not self._has_computed_rot_mtx: - self._rotation_mtx = self._euler_to_rotation(self._euler_angles) - self._has_computed_rot_mtx = True + @property + def euler_angles(self) -> np.ndarray: + if self._euler_angles is None: + if self._quaternion is not None: + self._euler_angles = quat_to_euler(self._quaternion) + elif self._rotation_mtx is not None: + self._quaternion = rotation_to_quaternion(self._rotation_mtx) + self._euler_angles = quat_to_euler(self._quaternion) + else: + raise ValueError + return self._euler_angles - return self._rotation_mtx + @euler_angles.setter + def euler_angles(self, val: np.ndarray): + self._euler_angles = val + self._quaternion = None + self._rotation_mtx = None + @property def forward(self) -> np.ndarray: - return self.rotation_mtx()[:, 0] + return self.rotation_mtx[:, 0] + @property def right(self) -> np.ndarray: - return self.rotation_mtx()[:, 1] * -1 # These are inverted compared to rlgym because rlbot reasons + return self.rotation_mtx[:, 1] + @property def left(self) -> np.ndarray: - return self.rotation_mtx()[:, 1] + return self.rotation_mtx[:, 1] * -1 + @property def up(self) -> np.ndarray: - return self.rotation_mtx()[:, 2] - - def _vector_to_numpy(self, vector: Vector3): - return np.asarray([vector.x, vector.y, vector.z]) - - def _rotator_to_numpy(self, rotator: Rotator): - return np.asarray([rotator.pitch, rotator.yaw, rotator.roll]) - - def _euler_to_rotation(self, pyr: np.ndarray): - CP = math.cos(pyr[0]) - SP = math.sin(pyr[0]) - CY = math.cos(pyr[1]) - SY = math.sin(pyr[1]) - CR = math.cos(pyr[2]) - SR = math.sin(pyr[2]) - - theta = np.empty((3, 3)) - - # front direction - theta[0, 0] = CP * CY - theta[1, 0] = CP * SY - theta[2, 0] = SP - - # left direction - theta[0, 1] = CY * SP * SR - CR * SY - theta[1, 1] = SY * SP * SR + CR * CY - theta[2, 1] = -CP * SR - - # up direction - theta[0, 2] = -CR * CY * SP - SR * SY - theta[1, 2] = -CR * SY * SP + SR * CY - theta[2, 2] = CP * CR - - return theta + return self.rotation_mtx[:, 2] + + @property + def pitch(self) -> float: + return self.euler_angles[0] + + @property + def yaw(self) -> float: + return self.euler_angles[1] + + @property + def roll(self) -> float: + return self.euler_angles[2] + + @staticmethod + def create_compat_physics_object(): + physics_object = PhysicsObject() + physics_object.position = np.zeros(3) + physics_object.linear_velocity = np.zeros(3) + physics_object.angular_velocity = np.zeros(3) + physics_object._rlbot_euler_angles = np.zeros(3) + return physics_object + + def update(self, physics: Physics): + write_vector_into_numpy(self.position, physics.location) + write_vector_into_numpy(self.linear_velocity, physics.velocity) + write_vector_into_numpy(self.angular_velocity, physics.angular_velocity) + # Need to do it like this so that the property setter gets called + self._rlbot_euler_angles[0] = physics.rotation.pitch + self._rlbot_euler_angles[1] = physics.rotation.yaw + self._rlbot_euler_angles[2] = physics.rotation.roll + self.euler_angles = self._rlbot_euler_angles diff --git a/rlgym_compat/player_data.py b/rlgym_compat/player_data.py deleted file mode 100644 index d2a2b8f..0000000 --- a/rlgym_compat/player_data.py +++ /dev/null @@ -1,15 +0,0 @@ -from .physics_object import PhysicsObject - - -class PlayerData(object): - def __init__(self): - self.car_id: int = -1 - self.team_num: int = -1 - self.is_demoed: bool = False - self.on_ground: bool = False - self.ball_touched: bool = False - self.has_jump: bool = False - self.has_flip: bool = False - self.boost_amount: float = -1 - self.car_data: PhysicsObject = PhysicsObject() - self.inverted_car_data: PhysicsObject = PhysicsObject() diff --git a/rlgym_compat/sim_extra_info.py b/rlgym_compat/sim_extra_info.py new file mode 100644 index 0000000..d3f52d4 --- /dev/null +++ b/rlgym_compat/sim_extra_info.py @@ -0,0 +1,327 @@ +from collections import deque +from typing import Dict + +import RocketSim as rsim +from rlbot.flat import ( + BallTypeMutator, + BoostAmountMutator, + BoostStrengthMutator, + DemolishMutator, + FieldInfo, + GameEventMutator, + GameMode, + GamePacket, + GameSpeedMutator, + GravityMutator, + MatchConfiguration, + MultiBallMutator, + PlayerInfo, + RespawnTimeMutator, + RumbleMutator, +) + +from .car import Car +from .extra_info import ExtraBallInfo, ExtraPacketInfo, ExtraPlayerInfo +from .math import euler_to_rotation +from .utils import rotator_to_numpy, vector_to_numpy + + +class SimExtraInfo: + def __init__( + self, field_info: FieldInfo, match_settings=MatchConfiguration(), tick_skip=8 + ): + match match_settings.game_mode: + case GameMode.Soccar: + mode = rsim.GameMode.SOCCAR + case GameMode.Hoops: + mode = rsim.GameMode.HOOPS + case GameMode.Heatseeker: + mode = rsim.GameMode.HEATSEEKER + case GameMode.Snowday: + mode = rsim.GameMode.SNOWDAY + case _: + raise NotImplementedError(match_settings.game_mode) + # TODO: ensure the boost pads are right + + # Ensure there are no mutators configured that we can't support + mutators = match_settings.mutators + if mutators is not None: + mutator_config = {} + assert ( + mutators.multi_ball == MultiBallMutator.One + ), "Can only use one ball with sim" + + assert ( + mutators.game_speed == GameSpeedMutator.Default + ), "Can only use default game speed with sim" + + match mutators.ball_type: + case BallTypeMutator.Default: + assert ( + match_settings.game_mode == GameMode.Soccar + ), "Cannot use non-soccer ball in soccer with sim" + case BallTypeMutator.Puck: + assert ( + match_settings.game_mode == GameMode.Snowday + ), "Cannot use non-puck ball in hockey with sim" + case BallTypeMutator.Basketball: + assert ( + match_settings.game_mode == GameMode.Hoops + ), "Cannot use non-basketball ball in hoops with sim" + case _: + raise NotImplementedError(mutators.ball_type) + + match mutators.boost_amount: + case BoostAmountMutator.NormalBoost: + pass + case BoostAmountMutator.UnlimitedBoost: + mutator_config["boost_used_per_second"] = 0 + mutator_config["car_spawn_boost_amount"] = 100 + case BoostAmountMutator.NoBoost: + mutator_config["boost_accel"] = 0 + print( + "Warning: No Boost boost option support is an experimental feature" + ) + case _: + raise NotImplementedError(mutators.boost_amount) + + assert mutators.rumble == RumbleMutator.Off, "Rumble is unsupported by sim" + + match mutators.boost_strength: + case BoostStrengthMutator.One: + pass + case BoostStrengthMutator.OneAndAHalf: + mutator_config["boost_accel"] = 21.2 * 1.5 + case BoostStrengthMutator.Two: + mutator_config["boost_accel"] = 21.2 * 2 + case BoostStrengthMutator.Five: + mutator_config["boost_accel"] = 21.2 * 5 + case BoostStrengthMutator.Ten: + mutator_config["boost_accel"] = 21.2 * 10 + + match mutators.gravity: + case GravityMutator.Default: + grav_z = -650 + case GravityMutator.Low: + grav_z = -325.0 + case GravityMutator.High: + grav_z = -1137.5 + case GravityMutator.SuperHigh: + grav_z = -3250 + case GravityMutator.Reverse: + grav_z = 650 + mutator_config["gravity"] = rsim.Vec(0, 0, grav_z) + + match mutators.demolish: + case DemolishMutator.Default: + pass + case DemolishMutator.Disabled: + mutator_config["demo_mode"] = rsim.DemoMode.DISABLED + case DemolishMutator.OnContact: + mutator_config["demo_mode"] = rsim.DemoMode.ON_CONTACT + case _: + raise NotImplementedError(mutators.demolish) + + match mutators.respawn_time: + case RespawnTimeMutator.ThreeSeconds: + pass + case RespawnTimeMutator.TwoSeconds: + mutator_config["respawn_delay"] = 2.0 + case RespawnTimeMutator.OneSecond: + mutator_config["respawn_delay"] = 1.0 + case _: + raise NotImplementedError(mutators.respawn_time) + + assert ( + mutators.game_event == GameEventMutator.Default + ), f"game event option {mutators.game_event} is unsupported by sim" + + # TODO: BallMaxSpeedOption + # TODO: BallWeightOption + # TODO: BallSizeOption + # TODO: BallBouncinessOption + self._ball_touched_on_tick: Dict[int, bool] = {} + self._touches: Dict[int, deque[int]] = {} + self._car_id_player_id_map: Dict[int, int] = {} + self._player_id_car_id_map: Dict[int, int] = {} + self._current_car_ids: set[int] = set() + self._tick_skip = tick_skip + self._first_call = True + self._tick_count = 0 + self._arena = rsim.Arena(mode) + self._arena.set_ball_touch_callback(self._ball_touch_callback) + if mutators: + self._arena.set_mutator_config(rsim.MutatorConfig(**mutator_config)) + + def _get_extra_ball_info(self) -> ExtraBallInfo: + ball_state = self._arena.ball.get_state() + return ExtraBallInfo( + heatseeker_target_dir=ball_state.heatseeker_target_dir, + heatseeker_target_speed=ball_state.heatseeker_target_speed, + heatseeker_time_since_hit=ball_state.heatseeker_time_since_hit, + ) + + def _get_extra_player_info(self, car) -> ExtraPlayerInfo: + car_state = car.get_state() + return ExtraPlayerInfo( + wheels_with_contact=car_state.wheels_with_contact, + handbrake=car_state.handbrake_val, + ball_touches=sum(self._touches[car.id]), + car_contact_id=( + 0 + if car_state.car_contact_id == 0 + else self._car_id_player_id_map[car_state.car_contact_id] + ), + car_contact_cooldown_timer=car_state.car_contact_cooldown_timer, + is_autoflipping=car_state.is_auto_flipping, + autoflip_timer=car_state.auto_flip_timer, + autoflip_direction=car_state.auto_flip_torque_scale, + ) + + def _get_extra_packet_info(self) -> ExtraPacketInfo: + players = [] + for car in self._arena.get_cars(): + players.append(self._get_extra_player_info(car)) + return ExtraPacketInfo(players=players, ball=self._get_extra_ball_info()) + + def get_extra_info(self, packet: GamePacket) -> ExtraPacketInfo: + self._update_sim_cars(packet) + if self._first_call: + self._first_call = False + self._tick_count = packet.match_info.frame_num + self._set_sim_state(packet) + return self._get_extra_packet_info() + + ticks_elapsed = packet.match_info.frame_num - self._tick_count + self._tick_count = packet.match_info.frame_num + player_id_player_info_map = { + player_info.player_id: player_info for player_info in packet.players + } + for car in self._arena.get_cars(): + car_controls = rsim.CarControls() + player_input = player_id_player_info_map[ + self._car_id_player_id_map[car.id] + ].last_input + car_controls.throttle = player_input.throttle + car_controls.steer = player_input.steer + car_controls.pitch = player_input.pitch + car_controls.yaw = player_input.yaw + car_controls.roll = player_input.roll + car_controls.boost = player_input.boost + car_controls.jump = player_input.jump + car_controls.handbrake = player_input.handbrake + car.set_controls(car_controls) + for _ in range(ticks_elapsed): + self._ball_touched_on_tick = {k: False for k in self._ball_touched_on_tick} + self._arena.step(1) + for k, v in self._ball_touched_on_tick.items(): + self._touches[k].append(v) + self._set_sim_state(packet) + return self._get_extra_packet_info() + + def _set_ball_state(self, packet: GamePacket): + if len(packet.balls) > 0: + ball = self._arena.ball + ball_state = ball.get_state() + packet_ball = packet.balls[0] + packet_ball_physics = packet_ball.physics + (latest_touch_player_idx, latest_touch_player_info) = max( + enumerate(packet.players), + key=lambda item: ( + -1 + if item[1].latest_touch is None + else item[1].latest_touch.game_seconds + ), + ) + if latest_touch_player_info.latest_touch is not None: + ball_state.last_hit_car_id = self._player_id_car_id_map[ + packet.players[latest_touch_player_idx].player_id + ] + ball_state.pos = rsim.Vec( + packet_ball_physics.location.x, + packet_ball_physics.location.y, + packet_ball_physics.location.z, + ) + ball_state.rot_mat = rsim.RotMat( + *euler_to_rotation(rotator_to_numpy(packet_ball_physics.rotation)) + .transpose() + .flatten() + ) + ball_state.vel = rsim.Vec(*vector_to_numpy(packet_ball_physics.velocity)) + ball_state.ang_vel = rsim.Vec( + *vector_to_numpy(packet_ball_physics.angular_velocity) + ) + ball.set_state(ball_state) + + def _set_car_state(self, player_info: PlayerInfo, new: bool): + if new: + car = self._arena.add_car( + player_info.team, + Car.detect_hitbox(player_info.hitbox, player_info.hitbox_offset), + ) + else: + car = self._arena.get_car_from_id( + self._player_id_car_id_map[player_info.player_id] + ) + car_state = car.get_state() + car_state.pos = rsim.Vec(*vector_to_numpy(player_info.physics.location)) + car_state.rot_mat = rsim.RotMat( + *euler_to_rotation(rotator_to_numpy(player_info.physics.rotation)) + .transpose() + .flatten() + ) + car_state.vel = rsim.Vec(*vector_to_numpy(player_info.physics.velocity)) + car_state.ang_vel = rsim.Vec( + *vector_to_numpy(player_info.physics.angular_velocity) + ) + car_state.boost = player_info.boost + car_state.is_supersonic = player_info.is_supersonic + car_state.is_demoed = player_info.demolished_timeout != -1 + car_state.demo_respawn_timer = ( + player_info.demolished_timeout * car_state.is_demoed + ) + car.set_state(car_state) + self._touches = { + car.id: deque([False] * self._tick_skip, self._tick_skip), + **self._touches, + } + self._car_id_player_id_map[car.id] = player_info.player_id + self._player_id_car_id_map[player_info.player_id] = car.id + self._current_car_ids.add(car.id) + + def _is_new_car(self, player_info: PlayerInfo): + return ( + player_info.player_id not in self._player_id_car_id_map + or self._player_id_car_id_map[player_info.player_id] + not in self._current_car_ids + ) + + def _update_sim_cars(self, packet: GamePacket): + # Add data cars that are newly in the packet + for player_info in packet.players: + if self._is_new_car(player_info): + self._set_car_state(player_info, True) + + # Remove data for cars that are no longer in the packet + packet_car_ids = [ + self._player_id_car_id_map[player_info.player_id] + for player_info in packet.players + ] + for car_id in list(self._current_car_ids): + if car_id not in packet_car_ids: + self._arena.remove_car(car_id) + self._current_car_ids.remove(car_id) + player_id = self._car_id_player_id_map.pop(car_id, None) + if player_id is not None: + self._player_id_car_id_map.pop(player_id, None) + self._touches.pop(car_id, None) + self._ball_touched_on_tick.pop(car_id, None) + + def _set_sim_state(self, packet: GamePacket): + for player_info in packet.players: + if not self._is_new_car(player_info): + self._set_car_state(player_info, False) + self._set_ball_state(packet) + + def _ball_touch_callback(self, arena: rsim.Arena, car: rsim.Car, data): + self._ball_touched_on_tick[car.id] = True diff --git a/rlgym_compat/utils.py b/rlgym_compat/utils.py new file mode 100644 index 0000000..87ab40e --- /dev/null +++ b/rlgym_compat/utils.py @@ -0,0 +1,27 @@ +import numpy as np +from rlbot.flat import BoxShape, Rotator, Vector3 + + +def create_default_init(slots): + func = "def __init__(self):" + for attr in slots: + func += " self.{}=None;".format(attr) + return func + + +def vector_to_numpy(vector: Vector3): + return np.asarray([vector.x, vector.y, vector.z]) + + +def rotator_to_numpy(rotator: Rotator): + return np.asarray([rotator.pitch, rotator.yaw, rotator.roll]) + + +def write_vector_into_numpy(np_vec: np.ndarray, vec: Vector3): + np_vec[0] = vec.x + np_vec[1] = vec.y + np_vec[2] = vec.z + + +def compare_hitbox_shape(shape: BoxShape, length, width, height): + return shape.length == length and shape.width == width and shape.height == height diff --git a/rlgym_compat/v1/__init__.py b/rlgym_compat/v1/__init__.py new file mode 100644 index 0000000..e5fcf37 --- /dev/null +++ b/rlgym_compat/v1/__init__.py @@ -0,0 +1,2 @@ +from .physics_object import PhysicsObject +from .player_data import PlayerData diff --git a/rlgym_compat/v1/physics_object.py b/rlgym_compat/v1/physics_object.py new file mode 100644 index 0000000..e67f187 --- /dev/null +++ b/rlgym_compat/v1/physics_object.py @@ -0,0 +1,106 @@ +import math + +import numpy as np +from rlbot import flat + +from ..math import euler_to_rotation, rotation_to_quaternion +from ..physics_object import PhysicsObject as V2PhysicsObject + + +class PhysicsObject: + def __init__( + self, + position=None, + euler_angles=None, + linear_velocity=None, + angular_velocity=None, + ): + self.position: np.ndarray = position if position else np.zeros(3) + + # ones by default to prevent mathematical errors when converting quat to rot matrix on empty physics state + self.quaternion: np.ndarray = np.ones(4) + + self.linear_velocity: np.ndarray = ( + linear_velocity if linear_velocity else np.zeros(3) + ) + self.angular_velocity: np.ndarray = ( + angular_velocity if angular_velocity else np.zeros(3) + ) + self._euler_angles: np.ndarray = euler_angles if euler_angles else np.zeros(3) + self._rotation_mtx: np.ndarray = np.zeros((3, 3)) + self._has_computed_rot_mtx = False + + self._invert_vec = np.asarray([-1, -1, 1]) + self._invert_pyr = np.asarray([0, math.pi, 0]) + + @staticmethod + def create_from_v2(v2_physics_object: V2PhysicsObject): + physics_object = PhysicsObject() + physics_object.position = v2_physics_object.position + physics_object.quaternion = v2_physics_object.quaternion + physics_object._euler_angles = v2_physics_object.euler_angles + physics_object._rotation_mtx = v2_physics_object.rotation_mtx + physics_object.linear_velocity = v2_physics_object.linear_velocity + physics_object.angular_velocity = v2_physics_object.angular_velocity + return physics_object + + def decode_car_data(self, car_data: flat.Physics): + self.position = self._vector_to_numpy(car_data.location) + self._euler_angles = self._rotator_to_numpy(car_data.rotation) + self.linear_velocity = self._vector_to_numpy(car_data.velocity) + self.angular_velocity = self._vector_to_numpy(car_data.angular_velocity) + self._rotation_mtx = self.rotation_mtx() + self.quaternion = rotation_to_quaternion(self._rotation_mtx) + + def decode_ball_data(self, ball_data: flat.Physics): + self.position = self._vector_to_numpy(ball_data.location) + self.linear_velocity = self._vector_to_numpy(ball_data.velocity) + self.angular_velocity = self._vector_to_numpy(ball_data.angular_velocity) + + def invert(self, other): + self.position = other.position * self._invert_vec + self._euler_angles = other.euler_angles() + self._invert_pyr + self.linear_velocity = other.linear_velocity * self._invert_vec + self.angular_velocity = other.angular_velocity * self._invert_vec + self._rotation_mtx = self.rotation_mtx() + self.quaternion = rotation_to_quaternion(self._rotation_mtx) + + # pitch, yaw, roll + def euler_angles(self) -> np.ndarray: + return self._euler_angles + + def pitch(self): + return self._euler_angles[0] + + def yaw(self): + return self._euler_angles[1] + + def roll(self): + return self._euler_angles[2] + + def rotation_mtx(self) -> np.ndarray: + if not self._has_computed_rot_mtx: + self._rotation_mtx = euler_to_rotation(self._euler_angles) + self._has_computed_rot_mtx = True + + return self._rotation_mtx + + def forward(self) -> np.ndarray: + return self.rotation_mtx()[:, 0] + + def right(self) -> np.ndarray: + return ( + self.rotation_mtx()[:, 1] * -1 + ) # These are inverted compared to rlgym because rlbot reasons + + def left(self) -> np.ndarray: + return self.rotation_mtx()[:, 1] + + def up(self) -> np.ndarray: + return self.rotation_mtx()[:, 2] + + def _vector_to_numpy(self, vector: flat.Vector3): + return np.asarray([vector.x, vector.y, vector.z]) + + def _rotator_to_numpy(self, rotator: flat.Rotator): + return np.asarray([rotator.pitch, rotator.yaw, rotator.roll]) diff --git a/rlgym_compat/v1/player_data.py b/rlgym_compat/v1/player_data.py new file mode 100644 index 0000000..9d1abed --- /dev/null +++ b/rlgym_compat/v1/player_data.py @@ -0,0 +1,54 @@ +from rlbot.flat import PlayerInfo + +from ..car import Car +from ..common_values import DOUBLEJUMP_MAX_DELAY +from .physics_object import PhysicsObject + + +class PlayerData(object): + def __init__(self): + self.car_id: int = -1 + self.player_id: int = -1 + self.team_num: int = -1 + self.match_goals: int = -1 + self.match_saves: int = -1 + self.match_shots: int = -1 + self.match_demolishes: int = -1 + self.boost_pickups: int = -1 + self.is_demoed: bool = False + self.on_ground: bool = False + self.ball_touched: bool = False + self.has_jump: bool = False + self.has_flip: bool = False + self.boost_amount: float = -1 + self.car_data: PhysicsObject = PhysicsObject() + self.inverted_car_data: PhysicsObject = PhysicsObject() + + @staticmethod + def create_base(player_info: PlayerInfo): + player = PlayerData() + player.player_id = player_info.player_id + player.match_goals = player_info.score_info.goals + player.match_saves = player_info.score_info.saves + player.match_shots = player_info.score_info.shots + player.match_demolishes = player_info.score_info.demolitions + return player + + def update_from_v2( + self, car: Car, car_id: int, boost_pickups: int, ball_touched: bool + ): + self.car_id = car_id + self.team_num = car.team_num + self.is_demoed = car.is_demoed + self.on_ground = car.on_ground + self.ball_touched = ball_touched # v2 does this subtly differently so we ignore car.ball_touches + self.has_jump = not car.has_jumped + self.has_flip = ( + not car.has_flipped + and not car.has_double_jumped + and car.air_time_since_jump < DOUBLEJUMP_MAX_DELAY + ) + self.boost_amount = car.boost_amount / 100 + self.boost_pickups = boost_pickups + self.car_data = PhysicsObject.create_from_v2(car.physics) + self.inverted_car_data = PhysicsObject.create_from_v2(car.inverted_physics) diff --git a/rlgym_compat/v1_game_state.py b/rlgym_compat/v1_game_state.py new file mode 100644 index 0000000..cfb85b6 --- /dev/null +++ b/rlgym_compat/v1_game_state.py @@ -0,0 +1,119 @@ +from typing import Dict, List, Optional + +import numpy as np +from rlbot.flat import FieldInfo, GamePacket, MatchConfiguration, MatchPhase + +from .common_values import BLUE_TEAM, ORANGE_TEAM, TICKS_PER_SECOND +from .extra_info import ExtraPacketInfo +from .game_state import GameState +from .v1.physics_object import PhysicsObject as V1PhysicsObject +from .v1.player_data import PlayerData as V1PlayerData + + +class V1GameState: + def __init__( + self, + field_info: FieldInfo, + match_settings=MatchConfiguration(), + tick_skip=8, + standard_map=True, + sort_players_by_car_id=False, + ): + self._game_state = GameState.create_compat_game_state( + field_info, match_settings, standard_map=standard_map + ) + self.game_type = int(match_settings.game_mode) + self.blue_score = 0 + self.orange_score = 0 + self.last_touch: Optional[int] = -1 + self.players: List[V1PlayerData] = [] + self.ball: V1PhysicsObject = None + self.inverted_ball: V1PhysicsObject = None + self.boost_pads: np.ndarray = None + self.inverted_boost_pads: np.ndarray = None + self._sort_players_by_car_id = sort_players_by_car_id + self._boost_pickups: Dict[int, int] = {} + self._car_ball_touched: Dict[int, bool] = {} + self._tick_skip = tick_skip + + def _recalculate_fields(self): + player_id_spectator_id_map = {} + blue_spectator_id = 1 + for player_id, car in self._game_state.cars.items(): + if car.team_num == BLUE_TEAM: + player_id_spectator_id_map[player_id] = blue_spectator_id + blue_spectator_id += 1 + orange_spectator_id = max(5, blue_spectator_id) + for player_id, car in self._game_state.cars.items(): + if car.team_num == ORANGE_TEAM: + player_id_spectator_id_map[player_id] = orange_spectator_id + orange_spectator_id += 1 + for player_data in self.players: + player_id = player_data.player_id + player_data.update_from_v2( + self._game_state.cars[player_id], + player_id_spectator_id_map[player_id], + self._boost_pickups[player_id], + self._car_ball_touched[player_id], + ) + if self._sort_players_by_car_id: + self.players.sort(key=lambda p: p.car_id) + self.ball = V1PhysicsObject.create_from_v2(self._game_state.ball) + self.inverted_ball = V1PhysicsObject.create_from_v2( + self._game_state.inverted_ball + ) + self.boost_pads = (self._game_state.boost_pad_timers == 0).astype(np.float32) + self.inverted_boost_pads = ( + self._game_state.inverted_boost_pad_timers == 0 + ).astype(np.float32) + + def update(self, packet: GamePacket, extra_info: Optional[ExtraPacketInfo] = None): + self.blue_score = packet.teams[BLUE_TEAM].score + self.orange_score = packet.teams[ORANGE_TEAM].score + (latest_touch_player_idx, latest_touch_player_info) = max( + enumerate(packet.players), + key=lambda item: ( + -1 + if item[1].latest_touch is None + else item[1].latest_touch.game_seconds + ), + ) + self.last_touch = ( + -1 + if latest_touch_player_info.latest_touch is None + else latest_touch_player_idx + ) + old_boost_amounts = { + **{p.player_id: p.boost / 100 for p in packet.players}, + **{k: v.boost_amount for (k, v) in self._game_state.cars.items()}, + } + self._game_state.update(packet, extra_info) + # We don't want this number to grow too big, but we don't care about it otherwise because we track this separately (see below) + self._game_state.reset_car_ball_touches() + self.players: List[V1PlayerData] = [] + for player_info in packet.players: + player_id = player_info.player_id + if player_id not in self._boost_pickups: + self._boost_pickups[player_id] = 0 + if player_id not in self._car_ball_touched: + self._car_ball_touched[player_id] = False + # We can't use the RLGym v2's car ball touches since those are tracked per action sequence (with some offset based on delay usage) instead of based on tick skip, so calculate them here + if player_info.latest_touch is not None: + ticks_since_touch = int( + round( + ( + packet.match_info.seconds_elapsed + - player_info.latest_touch.game_seconds + ) + * TICKS_PER_SECOND + ) + ) + if ticks_since_touch < self._tick_skip: + self._car_ball_touched[player_id] = True + if ( + packet.match_info.match_phase in (MatchPhase.Active, MatchPhase.Kickoff) + and old_boost_amounts[player_info.player_id] < player_info.boost / 100 + ): # This isn't perfect but with decent fps it'll work + self._boost_pickups[player_info.player_id] += 1 + self.players.append(V1PlayerData.create_base(player_info)) + self._recalculate_fields() diff --git a/rlgym_compat/version.py b/rlgym_compat/version.py index 91c14d9..cb90608 100644 --- a/rlgym_compat/version.py +++ b/rlgym_compat/version.py @@ -5,29 +5,48 @@ # 3) we can import it into your module module # https://stackoverflow.com/questions/458550/standard-way-to-embed-version-into-python-package -__version__ = '1.1.0' +__version__ = "2.2.0" release_notes = { - '1.1.0': """ + "2.2.0": """ + - update to RLBot v5 release 0.7.x + - update ball touch tracking mechanism, requiring users to manually reset ball touches when appropriate (based on the results of the action parser and the delay used) when using the RLGym v2 compat object + """, + "2.1.0": """ + - Update car state to match RLGym 2.0.1 + """, + "2.0.2": """ + - Fix rename of BoostMutator to BoostAmountMutator in sim extra info + """, + "2.0.1": """ + - Fix can_flip property when on ground to better match RLGym + """, + "2.0.0": """ + - Modify for RLGym v2 and RLBot v5 + """, + "1.1.1": """ + - Added additional properties, make has_jump more accurate + """, + "1.1.0": """ - Added has_jump """, - '1.0.2': """ + "1.0.2": """ - Fixed car_id """, - '1.0.1': """ + "1.0.1": """ - Fixed on_ground bug """, - '1.0.0': """ + "1.0.0": """ Initial Release - Tested with RLGym 0.4.1 - """ + """, } def get_current_release_notes(): if __version__ in release_notes: return release_notes[__version__] - return '' + return "" def print_current_release_notes(): diff --git a/setup.py b/setup.py index e26de5c..b565481 100644 --- a/setup.py +++ b/setup.py @@ -1,32 +1,31 @@ -from setuptools import setup, find_packages +from setuptools import find_packages, setup __version__ = None # This will get replaced when reading version.py -exec(open('rlgym_compat/version.py').read()) +exec(open("rlgym_compat/version.py").read()) -with open('README.md', 'r') as readme_file: +with open("README.md", "r") as readme_file: long_description = readme_file.read() setup( - name='rlgym_compat', + name="rlgym_compat", packages=find_packages(), version=__version__, - description='A library of RLBot compatibility objects for RLGym Agents', + description="A library of RLBot compatibility objects for RLGym Agents", long_description=long_description, - long_description_content_type='text/markdown', - author='Lucas Emery and Matthew Allen', - url='https://rlgym.github.io', + long_description_content_type="text/markdown", + author="Lucas Emery, Matthew Allen, Jonathan Keegan", + url="https://rlgym.github.io", install_requires=[ - 'rlbot==1.*', - 'numpy>=1.19', + "numpy<3.0", ], - python_requires='>=3.7', - license='Apache 2.0', - license_file='LICENSE', - keywords=['rocket-league', 'gym', 'reinforcement-learning'], + python_requires=">=3.11", + license="Apache 2.0", + license_file="LICENSE", + keywords=["rocket-league", "gym", "reinforcement-learning"], classifiers=[ - 'Development Status :: 4 - Beta', - 'License :: OSI Approved :: Apache Software License', - 'Programming Language :: Python :: 3', + "Development Status :: 4 - Beta", + "License :: OSI Approved :: Apache Software License", + "Programming Language :: Python :: 3", "Operating System :: Microsoft :: Windows", ], )