Source code for rubato.classes.components.hitbox

"""Various hitbox components that enable collisions"""
from __future__ import annotations
from typing import Callable, List, Union
import math
import sdl2
import sdl2.sdlgfx
from ctypes import c_int16

from . import Component, RigidBody
from ... import Math, Display, Vector, Defaults, Color, Error, SideError, Game


[docs]class Hitbox(Component): """ A hitbox superclass. Do not use this to attach hitboxes to your game objects. Instead, use Polygon, Rectangle, or Circle. Attributes: debug (bool): Whether to draw a green outline around the Polygon or not. trigger (bool): Whether this hitbox is just a trigger or not. scale (int): The scale of the polygon on_collide (Callable): The on_collide function to call when a collision happens with this hitbox. color (Color) The color to fill this hitbox with. tag (str): The tag of the hitbox (can be used to identify hitboxes) """ hitboxes: List[Hitbox] = []
[docs] def __init__(self, options: dict = {}): """ Initializes a Hitbox. Args: options: A Hitbox config. Defaults to the :ref:`Hitbox defaults <hitboxdef>`. """ params = Defaults.hitbox_defaults | options super().__init__() self.debug: bool = params["debug"] self.trigger: bool = params["trigger"] self._pos = lambda: Vector(0, 0) self.scale: int = params["scale"] self.on_collide: Callable = params["on_collide"] self.color: Color = params["color"] self.singular: bool = False self.tag: str = params["tag"] self.offset: Vector = params["offset"]
@property def pos(self) -> Vector: """The getter method for the position of the hitbox's center""" return self._pos() + self.offset
[docs] def update(self): self.draw()
[docs] def bounding_box_dimensions(self) -> Vector: """ Returns the dimensions of the bounding box surrounding the polygon. Returns: Vector: A vector with the x variable holding the width and the y variable holding the height. """ return Vector(0, 0)
[docs] def overlap(self, other: Hitbox) -> Union[ColInfo, None]: """Wraps the SAT collide function. Returns a ColInfo manifold if a collision occurs but does not resolve.""" return SAT.overlap(self, other)
[docs] def collide(self, other: Hitbox) -> Union[ColInfo, None]: """ Collides two hitboxes and resolves the collision using RigidBody impulse momentum if applicable. Args: other: The other rigidbody to collide with. on_collide: The function to run when a collision is detected. Defaults to None. Returns: Union[ColInfo, None]: Returns a collision info object if a collision is detected or nothing if no collision is detected. """ if (col := SAT.overlap(self, other)) is None: return if not (self.trigger or other.trigger): RigidBody.handle_collision(col) self.on_collide(col) other.on_collide(col.flip())
[docs]class Polygon(Hitbox): """ A polygon Hitbox subclass with an arbitrary number of vertices. Attributes: verts (List[Vector]): A list of the vertices in the Polygon, in either clockwise or anticlockwise direction. scale (Union[float, int]): The scale of the polygon. """
[docs] def __init__(self, options: dict = {}): """ Initializes a Polygon. Args: options: A Polygon config. Defaults to the :ref:`Polygon defaults <polygondef>`. """ super().__init__(options) params = Defaults.polygon_defaults | options self.verts: List[Vector] = params["verts"] self.rotation: float = params["rotation"]
[docs] def clone(self) -> Polygon: """Clones the Polygon""" return Polygon( { "debug": self.debug, "trigger": self.trigger, "scale": self.scale, "on_collide": self.on_collide, "color": self.color, "tag": self.tag, "offset": self.offset, "verts": self.verts, "rotation": self.rotation, } )
[docs] def transformed_verts(self) -> List[Vector]: """Maps each vertex with the Polygon's scale and rotation""" return [v.transform(self.scale, self.rotation) for v in self.verts]
[docs] def real_verts(self) -> List[Vector]: """Returns the a list of vertices in absolute coordinates""" return [self.pos + v for v in self.transformed_verts()]
def __str__(self): return f"{[str(v) for v in self.verts]}, {self.pos}, " + f"{self.scale}, {self.rotation}"
[docs] def bounding_box_dimensions(self) -> Vector: """ Returns the width and height of the smallest x, y axis aligned bounding box that fits around the polygon. Returns: Vector: The vector representation of the width and height. """ real_verts = self.real_verts() x_dir = SAT.project_verts(real_verts, Vector(1, 0)) y_dir = SAT.project_verts(real_verts, Vector(0, 1)) return Vector(x_dir.y - x_dir.x, y_dir.y - y_dir.x)
[docs] def draw(self): list_of_points: List[tuple] = [Game.camera.transform(v).tuple_int() for v in self.real_verts()] x_coords, y_coords = zip(*list_of_points) vx = (c_int16 * len(x_coords))(*x_coords) vy = (c_int16 * len(y_coords))(*y_coords) if self.color is not None: sdl2.sdlgfx.filledPolygonRGBA( Display.renderer.sdlrenderer, vx, vy, len(list_of_points), self.color.r, self.color.g, self.color.b, self.color.a, ) sdl2.sdlgfx.aapolygonRGBA( Display.renderer.sdlrenderer, vx, vy, len(list_of_points), self.color.r, self.color.g, self.color.b, self.color.a, ) if self.debug or Game.debug: for i in range(len(list_of_points)): sdl2.sdlgfx.thickLineRGBA( Display.renderer.sdlrenderer, list_of_points[i][0], list_of_points[i][1], list_of_points[(i + 1) % len(list_of_points)][0], list_of_points[(i + 1) % len(list_of_points)][1], int(2 * Display.display_ratio.x), 0, 255, 0, 255 )
[docs] @staticmethod def generate_polygon(num_sides: int, radius: Union[float, int] = 1) -> List[Vector]: """ Creates a normal polygon with a specified number of sides and an optional radius. Args: num_sides: The number of sides of the polygon. radius: The radius of the polygon. Defaults to 1. Raises: SideError: Raised when the number of sides is less than 3. Returns: List[Vector]: The vertices of the polygon. """ if num_sides < 3: raise SideError("Can't create a polygon with less than three sides") rotangle = 2 * math.pi / num_sides angle, verts = 0, [] for i in range(num_sides): angle = (i * rotangle) + (math.pi - rotangle) / 2 verts.append(Vector(math.cos(angle) * radius, math.sin(angle) * radius)) return verts
[docs]class Rectangle(Hitbox): """ A rectangle implementation of the Hitbox subclass. Attributes: width (int): The width of the rectangle height (int): The height of the rectangle rotation (float): The rotation of the rectangle """
[docs] def __init__(self, options: dict): """ Initializes a Rectangle. Args: options: A Rectangle config. Defaults to the :ref:`Rectangle defaults <rectangledef>`. """ super().__init__(options) params = Defaults.rectangle_defaults | options self.width: int = int(params["width"]) self.height: int = int(params["height"]) self.rotation: float = params["rotation"]
@property def top_left(self): """ The top left corner of the rectangle. Note: This can only be accessed and set after the Rectangle has been added to a Game Object. """ if self.gameobj: return self.pos - Vector(self.width / 2, self.height / 2) else: raise Error("Tried to get rect property before game object assignment.") @top_left.setter def top_left(self, new: Vector): if self.gameobj: self.gameobj.pos = new + Vector(self.width / 2, self.height / 2) self.gameobj.pos = self.gameobj.pos.to_int() else: raise Error("Tried to set rect property before game object assignment.") @property def bottom_left(self): """ The bottom left corner of the rectangle. Note: This can only be accessed and set after the Rectangle has been added to a Game Object. """ if self.gameobj: return self.pos - Vector(self.width / 2, self.height / -2) else: raise Error("Tried to get rect property before game object assignment.") @bottom_left.setter def bottom_left(self, new: Vector): if self.gameobj: self.gameobj.pos = new + Vector(self.width / 2, self.height / -2) self.gameobj.pos = self.gameobj.pos.to_int() else: raise Error("Tried to set rect property before game object assignment.") @property def top_right(self): """ The top right corner of the rectangle. Note: This can only be accessed and set after the Rectangle has been added to a Game Object. """ if self.gameobj: return self.pos - Vector(self.width / -2, self.height / 2) else: raise Error("Tried to get rect property before game object assignment.") @top_right.setter def top_right(self, new: Vector): if self.gameobj: self.gameobj.pos = new + Vector(self.width / -2, self.height / 2) self.gameobj.pos = self.gameobj.pos.to_int() else: raise Error("Tried to set rect property before game object assignment.") @property def bottom_right(self): """ The bottom right corner of the rectangle. Note: This can only be accessed and set after the Rectangle has been added to a Game Object. """ if self.gameobj: return self.pos - Vector(self.width / -2, self.height / -2) else: raise Error("Tried to get rect property before game object assignment.") @bottom_right.setter def bottom_right(self, new: Vector): if self.gameobj: self.gameobj.pos = new + Vector(self.width / -2, self.height / -2) self.gameobj.pos = self.gameobj.pos.to_int() else: raise Error("Tried to set rect property before game object assignment.") @property def bottom(self): """ The bottom side of the rectangle. Note: This can only be accessed and set after the Rectangle has been added to a Game Object. """ if self.gameobj: return self.pos.y - self.height / -2 else: raise Error("Tried to get rect property before game object assignment.") @bottom.setter def bottom(self, new: float): if self.gameobj: self.gameobj.pos.y += new + self.height / -2 self.gameobj.pos = self.gameobj.pos.to_int() else: raise Error("Tried to set rect property before game object assignment.")
[docs] def vertices(self) -> List[Vector]: """ Generates a list of the rectangle's vertices with no transformations applied. Returns: List[Vector]: The list of vertices """ return [ Vector(-self.width / 2, -self.height / 2), Vector(self.width / 2, -self.height / 2), Vector(self.width / 2, self.height / 2), Vector(-self.width / 2, self.height / 2) ]
[docs] def transformed_verts(self) -> List[Vector]: """ Generates a list of the rectangle's vertices, scaled and rotated. Returns: List[Vector]: The list of vertices """ return [v.transform(self.scale, self.rotation) for v in self.vertices()]
[docs] def real_verts(self) -> List[Vector]: """ Generates a list of the rectangle's vertices, relative to its position. Returns: List[Vector]: The list of vertices """ return [self.pos + v for v in self.vertices()]
[docs] def draw(self): x_1, y_1 = Game.camera.transform(self.top_right).tuple_int() x_2, y_2 = Game.camera.transform(self.bottom_left).tuple_int() if self.color is not None: sdl2.sdlgfx.boxRGBA( Display.renderer.sdlrenderer, x_1, y_1, x_2, y_2, self.color.r, self.color.g, self.color.b, self.color.a, ) if self.debug or Game.debug: verts = [(x_1, y_1), (x_1, y_2), (x_2, y_2), (x_2, y_1)] for i in range(len(verts)): sdl2.sdlgfx.thickLineRGBA( Display.renderer.sdlrenderer, verts[i][0], verts[i][1], verts[(i + 1) % len(verts)][0], verts[(i + 1) % len(verts)][1], int(2 * Display.display_ratio.x), 0, 255, 0, 255 )
[docs] def clone(self) -> Rectangle: return Rectangle( { "width": self.width, "height": self.height, "rotation": self.rotation, "debug": self.debug, "trigger": self.trigger, "scale": self.scale, "on_collide": self.on_collide, "color": self.color, "tag": self.tag, "offset": self.offset, } )
[docs]class Circle(Hitbox): """ A circle Hitbox subclass defined by a position, radius, and scale. Attributes: radius (int): The radius of the circle. scale (int): The scale of the circle. """
[docs] def __init__(self, options: dict = {}): """ Initializes a Circle. Args: options: A Circle config. Defaults to the :ref:`Circle defaults <circledef>`. """ super().__init__(options) params = Defaults.circle_defaults | options self.radius = params["radius"]
[docs] def transformed_radius(self) -> int: """Gets the true radius of the circle""" return self.radius * self.scale
[docs] def draw(self): relative_pos = Game.camera.transform(self.pos) scaled_rad = Game.camera.scale(self.radius) if self.color is not None: sdl2.sdlgfx.filledCircleRGBA( Display.renderer.sdlrenderer, int(relative_pos.x), int(relative_pos.y), int(scaled_rad), self.color.r, self.color.g, self.color.b, self.color.a, ) sdl2.sdlgfx.aacircleRGBA( Display.renderer.sdlrenderer, int(relative_pos.x), int(relative_pos.y), int(scaled_rad), self.color.r, self.color.g, self.color.b, self.color.a, ) if self.debug or Game.debug: sdl2.sdlgfx.aacircleRGBA( Display.renderer.sdlrenderer, int(relative_pos.x), int(relative_pos.y), int(scaled_rad), 0, 255, 0, 255, )
[docs] def clone(self) -> Circle: return Circle( { "debug": self.debug, "trigger": self.trigger, "scale": self.scale, "on_collide": self.on_collide, "color": self.color, "tag": self.tag, "offset": self.offset, "radius": self.radius, } )
[docs]class ColInfo: """ A class that represents information returned in a successful collision Attributes: shape_a (Union[Circle, Polygon, None]): A reference to the first shape. shape_b (Union[Circle, Polygon, None]): A reference to the second shape. seperation (Vector): The vector that would separate the two colliders. """
[docs] def __init__(self, shape_a: Union[Hitbox, None], shape_b: Union[Hitbox, None], sep: Vector = Vector()): """ Initializes a Collision Info manifold. This is used internally by :func:`SAT <rubato.classes.components.hitbox.SAT>`. """ self.shape_a = shape_a self.shape_b = shape_b self.sep = sep
[docs] def flip(self) -> ColInfo: """ Flips the reference shape in a collision manifold Returns: ColInfo: a reference to self. """ self.shape_a, self.shape_b = self.shape_b, self.shape_a self.sep *= -1 return self
[docs]class SAT: """ A general class that does the collision detection math between different hitboxes. """
[docs] @staticmethod def overlap(shape_a: Hitbox, shape_b: Hitbox) -> Union[ColInfo, None]: """ Checks for overlap between any two shapes (Polygon or Circle) Args: shape_a: The first shape. shape_b: The second shape. Returns: Union[ColInfo, None]: If a collision occurs, a ColInfo is returned. Otherwise None is returned. """ if isinstance(shape_a, Circle): if isinstance(shape_b, Circle): return SAT.circle_circle_test(shape_a, shape_b) return SAT.circle_polygon_test(shape_a, shape_b) if isinstance(shape_b, Circle): r = SAT.circle_polygon_test(shape_b, shape_a) return None if r is None else r.flip() return SAT.polygon_polygon_test(shape_a, shape_b)
[docs] @staticmethod def circle_circle_test(circle_a: Circle, circle_b: Circle) -> Union[ColInfo, None]: """Checks for overlap between two circles""" t_rad = circle_a.radius + circle_b.radius d_x, d_y = circle_a.pos.x - circle_b.pos.x, circle_a.pos.y - circle_b.pos.y dist = (d_x * d_x + d_y * d_y)**.5 if dist > t_rad: return None return ColInfo(circle_a, circle_b, Vector((t_rad - dist) * d_x / dist, (t_rad - dist) * d_y / dist))
[docs] @staticmethod def circle_polygon_test(circle: Circle, polygon: Polygon) -> Union[ColInfo, None]: """Checks for overlap between a circle and a polygon""" result = ColInfo(circle, polygon) shortest = Math.INF verts = polygon.transformed_verts() offset = polygon.pos - circle.pos closest = Vector() for v in verts: dist = (circle.pos - polygon.pos - v).magnitude if dist < shortest: shortest = dist closest = polygon.pos + v axis = closest - circle.pos axis.magnitude = 1 poly_range = SAT.project_verts(verts, axis) + axis.dot(offset) circle_range = Vector(-circle.transformed_radius(), circle.transformed_radius()) if poly_range.x > circle_range.y or circle_range.x > poly_range.y: return None dist_min = poly_range.x - circle_range.y shortest = abs(dist_min) result.sep = axis * dist_min for i in range(len(verts)): axis = SAT.perpendicular_axis(verts, i) poly_range = SAT.project_verts(verts, axis) + axis.dot(offset) if poly_range.x > circle_range.y or circle_range.x > poly_range.y: return None dist_min = poly_range.x - circle_range.y if abs(dist_min) < shortest: shortest = abs(dist_min) result.sep = axis * dist_min return result
[docs] @staticmethod def polygon_polygon_test(shape_a: Polygon, shape_b: Polygon) -> Union[ColInfo, None]: """Checks for overlap between two polygons""" test_a_b = SAT.poly_poly_helper(shape_a, shape_b) if test_a_b is None: return None test_b_a = SAT.poly_poly_helper(shape_b, shape_a) if test_b_a is None: return None return test_a_b if test_a_b.sep.mag_squared < test_b_a.sep.mag_squared else test_b_a.flip()
[docs] @staticmethod def poly_poly_helper(poly_a: Polygon, poly_b: Polygon) -> Union[ColInfo, None]: """Checks for half overlap. Don't use this by itself unless you know what you are doing.""" result = ColInfo(poly_a, poly_b) shortest = Math.INF verts_a = poly_a.transformed_verts() verts_b = poly_b.transformed_verts() offset = poly_a.pos - poly_b.pos for i in range(len(verts_a)): axis = SAT.perpendicular_axis(verts_a, i) a_range = SAT.project_verts(verts_a, axis) + axis.dot(offset) b_range = SAT.project_verts(verts_b, axis) if a_range.x > b_range.y or b_range.x > a_range.y: return None min_dist = b_range.x - a_range.y if abs(min_dist) < shortest: shortest = abs(min_dist) result.sep = axis * min_dist return result
[docs] @staticmethod def perpendicular_axis(verts: List[Vector], index: int) -> Vector: """Finds a vector perpendicular to a side""" pt_1, pt_2 = verts[index], verts[(index + 1) % len(verts)] axis = Vector(pt_1.y - pt_2.y, pt_2.x - pt_1.x) axis.magnitude = 1 return axis
[docs] @staticmethod def project_verts(verts: List[Vector], axis: Vector) -> Vector: """ Projects the vertices onto a given axis. Returns as a vector x is min, y is max """ minval, maxval = Math.INF, -Math.INF for v in verts: temp = axis.dot(v) minval, maxval = min(minval, temp), max(maxval, temp) return Vector(minval, maxval)