"""Primitive shapes integrated into the physics engine."""
from __future__ import annotations
from typing import Callable, TYPE_CHECKING
from .. import Component
from .... import Vector, Color, Game, Draw, Math, Camera, Input, Surface
if TYPE_CHECKING:
from . import Manifold
[docs]class Hitbox(Component):
"""
A hitbox superclass. Do not use this class to attach hitboxes to your game objects.
Instead, use Polygon or Circle, which inherit Hitbox properties.
Args:
color: The color of the hitbox. Set to None to not show the hitbox. Defaults to None.
tag: A string to tag the hitbox. Defaults to "".
trigger: Whether the hitbox is a trigger. Defaults to False.
should_collide: A function to call to determine whether the hitbox should collide with another hitbox.
Defaults to lambda self, other: True.
on_collide: A function to call when the hitbox collides with another hitbox. Defaults to lambda manifold: None.
on_enter: A function to call when the hitbox enters another hitbox. Defaults to lambda manifold: None.
on_exit: A function to call when the hitbox exits another hitbox. Defaults to lambda manifold: None.
offset: The offset of the hitbox from the gameobject. Defaults to (0, 0).
rot_offset: The rotation offset of the hitbox. Defaults to 0.
z_index: The z-index of the hitbox. Defaults to 0.
scale: The scale of the hitbox. Defaults to (1, 1).
debug: Whether to draw the hitbox. Defaults to False.
hidden: Whether the hitbox is hidden. Defaults to False.
"""
def __init__(
self,
color: Color | None = None,
tag: str = "",
trigger: bool = False,
should_collide: Callable[[Hitbox, Hitbox], bool] | None = None,
on_collide: Callable[[Manifold], None] | None = None,
on_enter: Callable[[Manifold], None] | None = None,
on_exit: Callable[[Manifold], None] | None = None,
offset: Vector | tuple[float, float] = (0, 0),
rot_offset: float = 0,
z_index: int = 0,
scale: Vector | tuple[float, float] = (1, 1),
debug: bool = False,
hidden: bool = False,
):
super().__init__(offset=offset, rot_offset=rot_offset, z_index=z_index, hidden=hidden)
self.debug: bool = debug
"""Whether to draw a green outline around the hitbox or not."""
self.trigger: bool = trigger
"""Whether this hitbox is just a trigger or not."""
self.scale: Vector = Vector.create(scale)
"""The scale of the hitbox."""
self.should_collide: Callable[[Hitbox, Hitbox],
bool] = should_collide if should_collide else lambda self, other: True
"""The should_collide function to call to determine whether two hitboxes should collide."""
self.on_collide: Callable[[Manifold], None] = on_collide if on_collide else lambda manifold: None
"""The on_collide function to call when a collision happens with this hitbox."""
self.on_enter: Callable[[Manifold], None] = on_enter if on_enter else lambda manifold: None
"""The on_enter function to call when collision begins with this hitbox."""
self.on_exit: Callable[[Manifold], None] = on_exit if on_exit else lambda manifold: None
"""The on_exit function to call when a collision ends with this hitbox."""
self.singular: bool = False
"""Whether this hitbox is singular or not."""
self.tag: str = tag
"""The tag of the hitbox (can be used to identify hitboxes in collision callbacks)"""
self.colliding: set[Hitbox] = set()
"""An unordered set of hitboxes that the Hitbox is currently colliding with."""
self.color: Color | None = color
"""The color of the hitbox."""
self._image: Surface = Surface()
self._debug_image: Surface = Surface()
self.uptodate: bool = False
"""Whether the hitbox image is up to date or not."""
self._old_rot_offset: float = self.rot_offset
self._old_offset: Vector = self.offset.clone()
self._old_scale: Vector = self.scale
self._old_color: Color | None = self.color.clone() if self.color is not None else None
[docs] def regen(self):
"""
Regenerates internal hitbox information.
"""
return
[docs] def contains_pt(self, pt: Vector | tuple[float, float]): # pylint: disable=unused-argument
"""
Checks if a point is inside the Hitbox.
Args:
pt: The point to check, in game-world coordinates.
Returns:
Whether the point is inside the Hitbox.
"""
return False
[docs] def redraw(self):
"""
Regenerates the image of the hitbox.
"""
self._image.clear()
self._debug_image.clear()
[docs] def get_aabb(self) -> tuple[Vector, Vector]:
"""
Gets bottom left and top right corners of the axis-aligned bounding box of the hitbox in world coordinates.
Returns:
tuple[Vector, Vector]:
The bottom left and top right right corners of the bounding box as Vectors as a tuple.
(bottom left, top right)
"""
true_pos = self.true_pos()
return true_pos, true_pos
[docs] def update(self):
if self.scale != self._old_scale:
self.uptodate = False
if not self.uptodate or self.rot_offset != self._old_rot_offset or self.offset != self._old_offset:
self.regen()
self._old_rot_offset = self.rot_offset
self._old_offset = self.offset.clone()
if not self.uptodate or self.color != self._old_color:
self.redraw()
self._old_color = self.color.clone() if self.color is not None else None
if not self.uptodate:
self.uptodate = True
self._old_scale = self.scale
[docs] def draw(self, camera: Camera):
if self.color:
self._image.rotation = self.true_rotation()
Draw.queue_surface(self._image, self.true_pos(), self.true_z(), camera)
if self.debug or Game.debug:
self._debug_image.rotation = self.true_rotation()
Draw.queue_surface(self._debug_image, self.true_pos(), Math.INF, camera=camera)
[docs]class Polygon(Hitbox):
"""
A Polygonal Hitbox component.
Danger:
If creating vertices by hand, make sure you generate them in a CLOCKWISE direction.
Otherwise, polygons may not behave or draw properly.
We recommend using Vector.poly() to generate the vertex list for regular polygons.
Warning:
rubato does not currently support concave polygons.
Creating concave polygons will result in undesired behavior.
Args:
verts: The vertices of the polygon. Defaults to [].
color: The color of the hitbox. Set to None to not show the hitbox. Defaults to None.
tag: A string to tag the hitbox. Defaults to "".
trigger: Whether the hitbox is a trigger. Defaults to False.
should_collide: A function to call to determine whether the hitbox should collide with another hitbox.
Defaults to lambda self, other: True.
on_collide: A function to call when the hitbox collides with another hitbox. Defaults to lambda manifold: None.
on_enter: A function to call when the hitbox enters another hitbox. Defaults to lambda manifold: None.
on_exit: A function to call when the hitbox exits another hitbox. Defaults to lambda manifold: None.
offset: The offset of the hitbox from the gameobject. Defaults to (0, 0).
rot_offset: The rotation offset of the hitbox. Defaults to 0.
z_index: The z-index of the hitbox. Defaults to 0.
scale: The scale of the hitbox. Defaults to (1, 1).
debug: Whether to draw the hitbox. Defaults to False.
hidden: Whether the hitbox is hidden. Defaults to False.
"""
def __init__(
self,
verts: list[Vector] | list[tuple[float, float]] = [],
color: Color | None = None,
tag: str = "",
trigger: bool = False,
should_collide: Callable[[Hitbox, Hitbox], bool] | None = None,
on_collide: Callable[[Manifold], None] | None = None,
on_enter: Callable[[Manifold], None] | None = None,
on_exit: Callable[[Manifold], None] | None = None,
offset: Vector | tuple[float, float] = (0, 0),
rot_offset: float = 0,
z_index: int = 0,
scale: Vector | tuple[float, float] = (1, 1),
debug: bool = False,
hidden: bool = False,
):
super().__init__(
offset=offset,
rot_offset=rot_offset,
debug=debug,
trigger=trigger,
scale=scale,
should_collide=should_collide,
on_collide=on_collide,
on_enter=on_enter,
on_exit=on_exit,
color=color,
tag=tag,
z_index=z_index,
hidden=hidden,
)
self._verts: list[Vector] = [Vector.create(v) for v in verts]
self.regen()
@property
def verts(self) -> list[Vector]:
"""A list of the vertices in the Polygon."""
return self._verts
@verts.setter
def verts(self, new: list[Vector]):
self._verts = new
self.uptodate = False
@property
def radius(self) -> float:
"""The radius of the Polygon. (get-only)"""
verts = self.offset_verts()
max_dist = -Math.INF
for vert in verts:
dist = vert.dist_to(self.offset)
if dist > max_dist:
max_dist = dist
return round(max_dist, 10)
[docs] def get_aabb(self) -> tuple[Vector, Vector]:
verts = self.true_verts()
bottom, top, left, right = Math.INF, -Math.INF, Math.INF, -Math.INF
for vert in verts:
if vert.y > top:
top = vert.y
elif vert.y < bottom:
bottom = vert.y
if vert.x > right:
right = vert.x
elif vert.x < left:
left = vert.x
return Vector(left, bottom), Vector(right, top)
[docs] def offset_verts(self) -> list[Vector]:
"""The list of polygon vertices offset by the Polygon's offsets."""
return self._offset_verts
[docs] def true_verts(self) -> list[Vector]:
"""
Returns a list of the Polygon's vertices in world coordinates. Accounts for gameobject position and rotation.
"""
return [v.rotate(self.gameobj.true_rotation()) + self.gameobj.true_pos() for v in self.offset_verts()]
[docs] def regen(self):
self._offset_verts = [(vert * self.scale).rotate(self.rot_offset) + self.offset for vert in self.verts]
[docs] def redraw(self):
super().redraw()
w = round(self.radius * self.scale.x * 2)
h = round(self.radius * self.scale.y * 2)
if w != self._image.width or h != self._image.height:
self._image = Surface(w, h)
self._debug_image = Surface(w, h)
if self.color is not None:
self._image.draw_poly(self.verts, (0, 0), fill=self.color, aa=True, blending=False)
self._debug_image.draw_poly(self.verts, (0, 0), Color.debug, 2, blending=False)
[docs] def contains_pt(self, pt: Vector | tuple[float, float]) -> bool:
return Input.pt_in_poly(pt, self.true_verts())
[docs] def clone(self) -> Polygon:
"""Clones the Polygon"""
return Polygon(
verts=[v.clone() for v in self.verts],
color=self.color.clone() if self.color is not None else None,
tag=self.tag,
debug=self.debug,
trigger=self.trigger,
scale=self.scale,
should_collide=self.should_collide,
on_collide=self.on_collide,
on_enter=self.on_enter,
on_exit=self.on_exit,
offset=self.offset.clone(),
rot_offset=self.rot_offset,
z_index=self.z_index,
)
[docs]class Rectangle(Hitbox):
"""
A Rectangular Hitbox component.
Args:
width: The width of the rectangle. Defaults to 0.
height: The height of the rectangle. Defaults to 0.
color: The color of the hitbox. Set to None to not show the hitbox. Defaults to None.
tag: A string to tag the hitbox. Defaults to "".
trigger: Whether the hitbox is a trigger. Defaults to False.
should_collide: A function to call to determine whether the hitbox should collide with another hitbox.
Defaults to lambda self, other: True.
on_collide: A function to call when the hitbox collides with another hitbox. Defaults to lambda manifold: None.
on_enter: A function to call when the hitbox enters another hitbox. Defaults to lambda manifold: None.
on_exit: A function to call when the hitbox exits another hitbox. Defaults to lambda manifold: None.
offset: The offset of the hitbox from the gameobject. Defaults to (0, 0).
rot_offset: The rotation offset of the hitbox. Defaults to 0.
scale: The scale of the hitbox. Defaults to (1, 1).
debug: Whether to draw the hitbox. Defaults to False.
z_index: The z-index of the hitbox. Defaults to 0.
hidden: Whether the hitbox is hidden. Defaults to False.
"""
def __init__(
self,
width: int | float = 0,
height: int | float = 0,
color: Color | None = None,
tag: str = "",
trigger: bool = False,
should_collide: Callable[[Hitbox, Hitbox], bool] | None = None,
on_collide: Callable[[Manifold], None] | None = None,
on_enter: Callable[[Manifold], None] | None = None,
on_exit: Callable[[Manifold], None] | None = None,
offset: Vector | tuple[float, float] = (0, 0),
rot_offset: float = 0,
z_index: int = 0,
scale: Vector | tuple[float, float] = (1, 1),
debug: bool = False,
hidden: bool = False
):
super().__init__(
offset=offset,
rot_offset=rot_offset,
debug=debug,
trigger=trigger,
scale=scale,
should_collide=should_collide,
on_collide=on_collide,
on_enter=on_enter,
on_exit=on_exit,
color=color,
tag=tag,
z_index=z_index,
hidden=hidden,
)
if width < 0 or height < 0:
raise ValueError("Width and height cannot be negative")
self._width: int | float = width
self._height: int | float = height
self._verts: list[Vector] = []
self.regen()
@property
def width(self) -> int | float:
"""The width of the Rectangle."""
return self._width
@width.setter
def width(self, value: int | float):
self._width = value
self.uptodate = False
@property
def height(self) -> int | float:
"""The height of the Rectangle."""
return self._height
@height.setter
def height(self, value: int | float):
self._height = value
self.uptodate = False
@property
def verts(self) -> list[Vector]:
"""The list of Rectangle vertices. (get-only)"""
return self._verts
@property
def radius(self) -> float:
"""The radius of the Rectangle. (get-only)"""
verts = self.offset_verts()
max_dist = -Math.INF
for vert in verts:
dist = vert.dist_to(self.offset)
if dist > max_dist:
max_dist = dist
return round(max_dist, 10)
@property
def top_left(self):
"""
The top left corner of the AABB surrounding the rectangle.
Setting to this value changes the gameobject's position, not the hitbox offset.
"""
aabb = self.get_aabb()
return Vector(aabb[0].x, aabb[1].y)
@top_left.setter
def top_left(self, new: Vector):
self.top = new.y
self.left = new.x
@property
def bottom_left(self):
"""
The bottom left corner of the AABB surrounding the rectangle.
Setting to this value changes the gameobject's position, not the hitbox offset.
"""
return self.get_aabb()[0]
@bottom_left.setter
def bottom_left(self, new: Vector):
self.bottom = new.y
self.left = new.x
@property
def top_right(self):
"""
The top right corner of the AABB surrounding the rectangle.
Setting to this value changes the gameobject's position, not the hitbox offset.
"""
return self.get_aabb()[1]
@top_right.setter
def top_right(self, new: Vector):
self.top = new.y
self.right = new.x
@property
def bottom_right(self):
"""
The bottom right corner of the AABB surrounding the rectangle.
Setting to this value changes the gameobject's position, not the hitbox offset.
"""
aabb = self.get_aabb()
return Vector(aabb[1].x, aabb[0].y)
@bottom_right.setter
def bottom_right(self, new: Vector):
self.bottom = new.y
self.right = new.x
@property
def top(self):
"""
The y value of the top side of the AABB surrounding the rectangle.
Setting to this value changes the gameobject's position, not the hitbox offset.
"""
return self.get_aabb()[1].y
@top.setter
def top(self, new: float):
self.gameobj.pos.y += new - self.get_aabb()[1].y
@property
def left(self):
"""
The x value of the left side of the AABB surrounding the rectangle.
Setting to this value changes the gameobject's position, not the hitbox offset.
"""
return self.get_aabb()[0].x
@left.setter
def left(self, new: float):
self.gameobj.pos.x += new - self.get_aabb()[0].x
@property
def bottom(self):
"""
The y value of the bottom side of the AABB surrounding the rectangle.
Setting to this value changes the gameobject's position, not the hitbox offset.
"""
return self.get_aabb()[0].y
@bottom.setter
def bottom(self, new: float):
self.gameobj.pos.y += new - self.get_aabb()[0].y
@property
def right(self):
"""
The x value of the right side of the AABB surrounding the rectangle.
Setting to this value changes the gameobject's position, not the hitbox offset.
"""
return self.get_aabb()[1].x
@right.setter
def right(self, new: float):
self.gameobj.pos.x += new - self.get_aabb()[1].x
[docs] def get_aabb(self) -> tuple[Vector, Vector]:
verts = self.true_verts()
bottom, top, left, right = Math.INF, -Math.INF, Math.INF, -Math.INF
for vert in verts:
if vert.y > top:
top = vert.y
elif vert.y < bottom:
bottom = vert.y
if vert.x > right:
right = vert.x
elif vert.x < left:
left = vert.x
return Vector(left, bottom), Vector(right, top)
[docs] def offset_verts(self) -> list[Vector]:
"""The list of rectangle vertices offset by the Rectangles's offsets."""
return self._offset_verts
[docs] def true_verts(self) -> list[Vector]:
"""
Returns a list of the Rectangle's vertices in world coordinates. Accounts for gameobject position and rotation.
"""
return [v.rotate(self.gameobj.true_rotation()) + self.gameobj.true_pos() for v in self.offset_verts()]
[docs] def regen(self):
w = self.width / 2
h = self.height / 2
self._verts = [Vector(-w, -h), Vector(w, -h), Vector(w, h), Vector(-w, h)]
self._offset_verts = [(vert * self.scale).rotate(self.rot_offset) + self.offset for vert in self._verts]
[docs] def redraw(self):
self._debug_image.clear()
w = round(self.width * self.scale.x)
h = round(self.height * self.scale.y)
if w != self._image.width or h != self._image.height:
self._image = Surface(w, h)
self._debug_image = Surface(w, h)
if self.color is not None:
self._image.fill(self.color)
self._debug_image.draw_rect((0, 0), (w, h), Color.debug, 2, blending=False)
[docs] def contains_pt(self, pt: Vector | tuple[float, float]) -> bool:
return Input.pt_in_poly(pt, self.true_verts())
[docs] def clone(self) -> Rectangle:
return Rectangle(
offset=self.offset.clone(),
rot_offset=self.rot_offset,
debug=self.debug,
trigger=self.trigger,
scale=self.scale,
should_collide=self.should_collide,
on_collide=self.on_collide,
on_enter=self.on_enter,
on_exit=self.on_exit,
color=self.color.clone() if self.color is not None else None,
tag=self.tag,
width=self.width,
height=self.height,
z_index=self.z_index,
)
[docs]class Circle(Hitbox):
"""
A Circular Hitbox component.
Args:
radius: The radius of the circle. Defaults to 0.
color: The color of the hitbox. Set to None to not show the hitbox. Defaults to None.
tag: A string to tag the hitbox. Defaults to "".
trigger: Whether the hitbox is a trigger. Defaults to False.
should_collide: A function to call to determine whether the hitbox should collide with another hitbox.
Defaults to lambda self, other: True.
on_collide: A function to call when the hitbox collides with another hitbox. Defaults to lambda manifold: None.
on_enter: A function to call when the hitbox enters another hitbox. Defaults to lambda manifold: None.
on_exit: A function to call when the hitbox exits another hitbox. Defaults to lambda manifold: None.
offset: The offset of the hitbox from the gameobject. Defaults to (0, 0).
rot_offset: The rotation offset of the hitbox. Defaults to 0.
z_index: The z-index of the hitbox. Defaults to 0.
scale: The scale of the hitbox. Note that only the largest value will determine the scale. Defaults to (1, 1).
debug: Whether to draw the hitbox. Defaults to False.
hidden: Whether the hitbox is hidden. Defaults to False.
"""
def __init__(
self,
radius: int | float = 0,
color: Color | None = None,
tag: str = "",
trigger: bool = False,
should_collide: Callable[[Hitbox, Hitbox], bool] | None = None,
on_collide: Callable[[Manifold], None] | None = None,
on_enter: Callable[[Manifold], None] | None = None,
on_exit: Callable[[Manifold], None] | None = None,
offset: Vector | tuple[float, float] = (0, 0),
rot_offset: float = 0,
z_index: int = 0,
scale: Vector | tuple[float, float] = (1, 1),
debug: bool = False,
hidden: bool = False,
):
super().__init__(
offset=offset,
rot_offset=rot_offset,
debug=debug,
trigger=trigger,
scale=scale,
should_collide=should_collide,
on_collide=on_collide,
on_enter=on_enter,
on_exit=on_exit,
color=color,
tag=tag,
z_index=z_index,
hidden=hidden,
)
if radius < 0:
raise ValueError("Radius cannot be negative")
self._radius = radius
@property
def radius(self) -> int | float:
"""The radius of the circle."""
return self._radius
@radius.setter
def radius(self, value: int | float):
self._radius = value
self.uptodate = False
@property
def center(self) -> Vector:
"""The center of the circle. Equivalent to true_pos. Setting to this will change the Gameobject position."""
return self.true_pos()
[docs] def get_aabb(self) -> tuple[Vector, Vector]:
offset = self.true_radius()
true_pos = self.true_pos()
return true_pos - offset, true_pos + offset
[docs] def true_radius(self) -> int | float:
"""Gets the true radius of the circle"""
return self.radius * self.scale.max()
[docs] def redraw(self):
super().redraw()
int_r = round(self.radius * self.scale.max())
size = int_r * 2 + 1
if size != self._image.width:
self._image = Surface(size, size)
self._debug_image = Surface(size, size)
if self.color is not None:
self._image.draw_circle((0, 0), int_r, fill=self.color, aa=True, blending=False)
self._debug_image.draw_circle((0, 0), int_r, Color.debug, 2, blending=False)
[docs] def contains_pt(self, pt: Vector | tuple[float, float]) -> bool:
r = self.true_radius()
return (pt - self.true_pos()).mag_sq <= r * r
[docs] def clone(self) -> Circle:
return Circle(
offset=self.offset.clone(),
rot_offset=self.rot_offset,
debug=self.debug,
trigger=self.trigger,
scale=self.scale,
should_collide=self.should_collide,
on_collide=self.on_collide,
on_enter=self.on_enter,
on_exit=self.on_exit,
color=self.color.clone() if self.color is not None else None,
tag=self.tag,
radius=self.radius,
z_index=self.z_index,
)