Skip to content

Add basic type annotations & docstrings to arcade.hitbox.base #1777

New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Merged
merged 11 commits into from
May 18, 2023
154 changes: 132 additions & 22 deletions arcade/hitbox/base.py
Original file line number Diff line number Diff line change
Expand Up @@ -11,34 +11,81 @@
__all__ = ["HitBoxAlgorithm", "HitBox", "RotatableHitBox"]


# Speed / typing workaround:
# 1. Eliminate extra allocations
# 2. Allows HitBox & subclass typing annotation to work cleanly
_EMPTY_POINT_LIST: PointList = tuple()


class HitBoxAlgorithm:
"""
Base class for hit box algorithms. Hit box algorithms are used to calculate the
points that make up a hit box for a sprite.
The base class for hit box algorithms.

Hit box algorithms are intended to calculate the points which make up
a hit box for a given :py:class:`~PIL.Image.Image`. However, advanced
users can also repurpose them for other tasks.
"""

#: The name of the algorithm
name = "base"
#: Should points for this algorithm be cached?

#: Whether points for this algorithm should be cached
cache = True

@property
def param_str(self) -> str:
"""
Return a string representation of the parameters used to create this algorithm.
A string representation of the parameters used to create this algorithm.

This is used in caching.
It will be incorporated at the end of the string returned by
:py:meth:`Texture.create_cache_name <arcade.Texture.create_cache_name>`.
Subclasses should override this method to return a value which allows
distinguishing different configurations of a particular hit box
algorithm.
"""
return ""

def calculate(self, image: Image, **kwargs) -> PointList:
"""
Calculate hit box points for a given image.

.. warning:: This method should not be made into a class method!

Although this base class does not take arguments
when initialized, subclasses use them to alter how
a specific instance handles image data by default.

:param image: The image to calculate hitbox points for
:param kwargs: keyword arguments
:return: A list of hit box points.
"""
raise NotImplementedError

def __call__(self, *args: Any, **kwds: Any) -> "HitBoxAlgorithm":
"""
Shorthand allowing any instance to be used identically to the base type.

:param args: The same positional arguments as `__init__`
:param kwds: The same keyword arguments as `__init__`
:return: A new HitBoxAlgorithm instance
"""
return self.__class__(*args, **kwds)


class HitBox:
"""
A basic hit box class supporting scaling.

It includes support for rescaling as well as shorthand properties
for boundary values along the X and Y axes. For rotation support,
use :py:meth:`.create_rotatable` to create an instance of
:py:class:`RotatableHitBox`.

:param points: The unmodified points bounding the hit box
:param position: The center around which the points will be offset
:param scale: The X and Y scaling factors to use when offsetting the
points
"""
def __init__(
self,
points: PointList,
Expand All @@ -49,53 +96,81 @@ def __init__(
self._position = position
self._scale = scale

self._left = None
self._right = None
self._bottom = None
self._top = None

self._adjusted_points = None
# This empty tuple will be replaced the first time
# get_adjusted_points is called
self._adjusted_points: PointList = _EMPTY_POINT_LIST
self._adjusted_cache_dirty = True

@property
def points(self):
def points(self) -> PointList:
"""
The raw, unadjusted points of this hit box.

These are the points as originally passed before offsetting, scaling,
and any operations subclasses may perform, such as rotation.
"""
return self._points

@property
def position(self):
def position(self) -> Point:
"""
The center point used to offset the final adjusted positions.
:return:
"""
return self._position

@position.setter
def position(self, position: Point):
self._position = position
self._adjusted_cache_dirty = True

# Per Clepto's testing as of around May 2023, these are better
# left uncached because caching them is somehow slower than what
# we currently do. Any readers should feel free to retest /
# investigate further.
@property
def left(self):
def left(self) -> float:
"""
Calculates the leftmost adjusted x position of this hit box
"""
points = self.get_adjusted_points()
x_points = [point[0] for point in points]
return min(x_points)

@property
def right(self):
def right(self) -> float:
"""
Calculates the rightmost adjusted x position of this hit box
"""
points = self.get_adjusted_points()
x_points = [point[0] for point in points]
return max(x_points)

@property
def top(self):
def top(self) -> float:
"""
Calculates the topmost adjusted y position of this hit box
"""
points = self.get_adjusted_points()
y_points = [point[1] for point in points]
return max(y_points)

@property
def bottom(self):
def bottom(self) -> float:
"""
Calculates the bottommost adjusted y position of this hit box
"""
points = self.get_adjusted_points()
y_points = [point[1] for point in points]
return min(y_points)

@property
def scale(self):
def scale(self) -> Tuple[float, float]:
"""
The X & Y scaling factors for the points in this hit box.

These are used to calculate the final adjusted positions of points.
"""
return self._scale

@scale.setter
Expand All @@ -107,11 +182,30 @@ def create_rotatable(
self,
angle: float = 0.0,
) -> RotatableHitBox:
"""
Create a rotatable instance of this hit box.

The internal ``PointList`` is transferred directly instead of
deepcopied, so care should be taken if using a mutable internal
representation.

:param angle: The angle to rotate points by (0 by default)
:return:
"""
return RotatableHitBox(
self._points, position=self._position, scale=self._scale, angle=angle
)

def get_adjusted_points(self):
def get_adjusted_points(self) -> PointList:
"""
Return the positions of points, scaled and offset from the center.

Unlike the boundary helper properties (left, etc), this method will
only recalculate the values when necessary:

* The first time this method is called
* After properties affecting adjusted position were changed
"""
if not self._adjusted_cache_dirty:
return self._adjusted_points

Expand All @@ -129,6 +223,12 @@ def _adjust_point(point) -> Point:


class RotatableHitBox(HitBox):
"""
A hit box with support for rotation.

Rotation is separated from the basic hitbox because it is much
slower than offsetting and scaling.
"""
def __init__(
self,
points: PointList,
Expand All @@ -138,18 +238,28 @@ def __init__(
scale: Tuple[float, float] = (1.0, 1.0),
):
super().__init__(points, position=position, scale=scale)
self._angle = angle
self._angle: float = angle

@property
def angle(self):
def angle(self) -> float:
"""
The angle to rotate the raw points by in degrees
"""
return self._angle

@angle.setter
def angle(self, angle: float):
self._angle = angle
self._adjusted_cache_dirty = True

def get_adjusted_points(self):
def get_adjusted_points(self) -> PointList:
"""
Return the offset, scaled, & rotated points of this hitbox.

As with :py:meth:`.HitBox.get_adjusted_points`, this method only
recalculates the adjusted values when necessary.
:return:
"""
if not self._adjusted_cache_dirty:
return self._adjusted_points

Expand Down