Source code for arcade.hitbox.base

from __future__ import annotations

from math import cos, radians, sin
from typing import Any

from PIL.Image import Image
from typing_extensions import Self

from arcade.types import EMPTY_POINT_LIST, Point2, Point2List

__all__ = ["HitBoxAlgorithm", "HitBox", "RotatableHitBox"]


[docs] class HitBoxAlgorithm: """ 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. """ #: Whether points for this algorithm should be cached cache = True def __init__(self): self._cache_name = self.__class__.__name__ @property def cache_name(self) -> str: """ A string representation of the parameters used to create this algorithm. 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 self._cache_name
[docs] def calculate(self, image: Image, **kwargs) -> Point2List: """ 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. Args: image: The image to calculate hitbox points for kwargs: keyword arguments """ raise NotImplementedError
[docs] def __call__(self, *args: Any, **kwds: Any) -> Self: """ Shorthand allowing any instance to be used identically to the base type. Args: args: The same positional arguments as `__init__` kwds: The same keyword arguments as `__init__` Returns: A new HitBoxAlgorithm instance """ return self.__class__(*args, **kwds) # type: ignore
[docs] def create_bounding_box(self, image: Image) -> Point2List: """ Create points for a simple bounding box around an image. This is often used as a fallback if a hit box algorithm doesn't manage to figure out any reasonable points for an image. Args: image: The image to create a bounding box for. """ size = image.size return ( (-size[0] / 2, -size[1] / 2), (size[0] / 2, -size[1] / 2), (size[0] / 2, size[1] / 2), (-size[0] / 2, size[1] / 2), )
[docs] 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`. Args: points: The unmodified points bounding the hit box position: The center around which the points will be offset scale: The X and Y scaling factors to use when offsetting the points """ def __init__( self, points: Point2List, position: Point2 = (0.0, 0.0), scale: Point2 = (1.0, 1.0), ): self._points = points self._position = position self._scale = scale # This empty tuple will be replaced the first time # get_adjusted_points is called self._adjusted_points: Point2List = EMPTY_POINT_LIST self._adjusted_cache_dirty = True @property def points(self) -> Point2List: """ 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) -> Point2: """ The center point used to offset the final adjusted positions. """ return self._position @position.setter def position(self, position: Point2): 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) -> 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) -> 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) -> 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) -> 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) -> 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 def scale(self, scale: tuple[float, float]): self._scale = scale self._adjusted_cache_dirty = True
[docs] 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 deep copied, so care should be taken if using a mutable internal representation. Args: angle: The angle to rotate points by (0 by default) """ return RotatableHitBox( self._points, position=self._position, scale=self._scale, angle=angle )
[docs] def get_adjusted_points(self) -> Point2List: """ 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 # type: ignore position_x, position_y = self._position scale_x, scale_y = self._scale def _adjust_point(point) -> Point2: x, y = point x *= scale_x y *= scale_y return (x + position_x, y + position_y) self._adjusted_points = [_adjust_point(point) for point in self._points] self._adjusted_cache_dirty = False return self._adjusted_points
[docs] 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. Args: points: The unmodified points bounding the hit box position: The translation to apply to the points angle: The angle to rotate the points by scale: The X and Y scaling factors """ def __init__( self, points: Point2List, *, position: tuple[float, float] = (0.0, 0.0), angle: float = 0.0, scale: Point2 = (1.0, 1.0), ): super().__init__(points, position=position, scale=scale) self._angle: float = angle @property 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
[docs] def get_adjusted_points(self) -> Point2List: """ 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. """ if not self._adjusted_cache_dirty: return self._adjusted_points rad = radians(-self._angle) scale_x, scale_y = self._scale position_x, position_y = self._position rad_cos = cos(rad) rad_sin = sin(rad) def _adjust_point(point) -> Point2: x, y = point x *= scale_x y *= scale_y if rad: rot_x = x * rad_cos - y * rad_sin rot_y = x * rad_sin + y * rad_cos x = rot_x y = rot_y return ( x + position_x, y + position_y, ) self._adjusted_points = [_adjust_point(point) for point in self._points] self._adjusted_cache_dirty = False return self._adjusted_points