TetrArcade/arcade/sprite.py

901 lines
31 KiB
Python

"""
This module manages all of the code around Sprites.
For information on Spatial Hash Maps, see:
https://www.gamedev.net/articles/programming/general-and-gameplay-programming/spatial-hashing-r2697/
"""
import math
import dataclasses
import PIL.Image
from arcade.draw_commands import load_texture
from arcade.draw_commands import draw_texture_rectangle
from arcade.draw_commands import Texture
from arcade.draw_commands import rotate_point
from arcade.arcade_types import RGB, Point
from typing import Sequence
from typing import Tuple
FACE_RIGHT = 1
FACE_LEFT = 2
FACE_UP = 3
FACE_DOWN = 4
class Sprite:
"""
Class that represents a 'sprite' on-screen.
Attributes:
:alpha: Transparency of sprite. 0 is invisible, 255 is opaque.
:angle: Rotation angle in degrees.
:radians: Rotation angle in radians.
:bottom: Set/query the sprite location by using the bottom coordinate. \
This will be the 'y' of the bottom of the sprite.
:boundary_left: Used in movement. Left boundary of moving sprite.
:boundary_right: Used in movement. Right boundary of moving sprite.
:boundary_top: Used in movement. Top boundary of moving sprite.
:boundary_bottom: Used in movement. Bottom boundary of moving sprite.
:center_x: X location of the center of the sprite
:center_y: Y location of the center of the sprite
:change_x: Movement vector, in the x direction.
:change_y: Movement vector, in the y direction.
:change_angle: Change in rotation.
:color: Color tint the sprite
:collision_radius: Used as a fast-check to see if this item is close \
enough to another item. If this check works, we do a slower more accurate check.
:cur_texture_index: Index of current texture being used.
:guid: Unique identifier for the sprite. Useful when debugging.
:height: Height of the sprite.
:force: Force being applied to the sprite. Useful when used with Pymunk \
for physics.
:left: Set/query the sprite location by using the left coordinate. This \
will be the 'x' of the left of the sprite.
:points: Points, in relation to the center of the sprite, that are used \
for collision detection. Arcade defaults to creating points for a rectangle \
that encompass the image. If you are creating a ramp or making better \
hit-boxes, you can custom-set these.
:position: A list with the (x, y) of where the sprite is.
:repeat_count_x: Unused
:repeat_count_y: Unused
:right: Set/query the sprite location by using the right coordinate. \
This will be the 'y=x' of the right of the sprite.
:sprite_lists: List of all the sprite lists this sprite is part of.
:texture: `Texture` class with the current texture.
:textures: List of textures associated with this sprite.
:top: Set/query the sprite location by using the top coordinate. This \
will be the 'y' of the top of the sprite.
:scale: Scale the image up or down. Scale of 1.0 is original size, 0.5 \
is 1/2 height and width.
:velocity: Change in x, y expressed as a list. (0, 0) would be not moving.
:width: Width of the sprite
It is common to over-ride the `update` method and provide mechanics on
movement or other sprite updates.
"""
def __init__(self,
filename: str = None,
scale: float = 1,
image_x: float = 0, image_y: float = 0,
image_width: float = 0, image_height: float = 0,
center_x: float = 0, center_y: float = 0,
repeat_count_x: int = 1, repeat_count_y: int = 1):
"""
Create a new sprite.
Args:
filename (str): Filename of an image that represents the sprite.
scale (float): Scale the image up or down. Scale of 1.0 is none.
image_x (float): Scale the image up or down. Scale of 1.0 is none.
image_y (float): Scale the image up or down. Scale of 1.0 is none.
image_width (float): Width of the sprite
image_height (float): Height of the sprite
center_x (float): Location of the sprite
center_y (float): Location of the sprite
"""
if image_width < 0:
raise ValueError("Width of image can't be less than zero.")
if image_height < 0:
raise ValueError("Height entered is less than zero. Height must be a positive float.")
if image_width == 0 and image_height != 0:
raise ValueError("Width can't be zero.")
if image_height == 0 and image_width != 0:
raise ValueError("Height can't be zero.")
self.sprite_lists = []
if filename is not None:
self._texture = load_texture(filename, image_x, image_y,
image_width, image_height)
self.textures = [self._texture]
self._width = self._texture.width * scale
self._height = self._texture.height * scale
self._texture.scale = scale
else:
self.textures = []
self._texture = None
self._width = 0
self._height = 0
self.cur_texture_index = 0
self._scale = scale
self._position = [center_x, center_y]
self._angle = 0.0
self.velocity = [0, 0]
self.change_angle = 0
self.boundary_left = None
self.boundary_right = None
self.boundary_top = None
self.boundary_bottom = None
self.properties = {}
self._alpha = 255
self._collision_radius = None
self._color = (255, 255, 255)
self._points = None
self._point_list_cache = None
self.force = [0, 0]
self.guid = None
self.repeat_count_x = repeat_count_x
self.repeat_count_y = repeat_count_y
def append_texture(self, texture: Texture):
"""
Appends a new texture to the list of textures that can be
applied to this sprite.
:param Texture texture: Texture to add ot the list of available textures
"""
self.textures.append(texture)
def _get_position(self) -> (float, float):
"""
Get the center x coordinate of the sprite.
Returns:
(width, height)
"""
return self._position
def _set_position(self, new_value: (float, float)):
"""
Set the center x coordinate of the sprite.
Args:
new_value:
Returns:
"""
if new_value[0] != self._position[0] or new_value[1] != self._position[1]:
self.clear_spatial_hashes()
self._point_list_cache = None
self._position[0], self._position[1] = new_value
self.add_spatial_hashes()
for sprite_list in self.sprite_lists:
sprite_list.update_location(self)
position = property(_get_position, _set_position)
def set_position(self, center_x: float, center_y: float):
"""
Set a sprite's position
:param float center_x: New x position of sprite
:param float center_y: New y position of sprite
"""
self._set_position((center_x, center_y))
def set_points(self, points: Sequence[Sequence[float]]):
"""
Set a sprite's position
"""
self._points = points
def forward(self, speed: float = 1.0):
"""
Set a Sprite's position to speed by its angle
:param speed: speed factor
"""
self.change_x += math.cos(self.radians) * speed
self.change_y += math.sin(self.radians) * speed
def reverse(self, speed: float = 1.0):
self.forward(-speed)
def strafe(self, speed: float = 1.0):
"""
Set a sprites position perpendicular to its angle by speed
:param speed: speed factor
"""
self.change_x += -math.sin(self.radians) * speed
self.change_y += math.cos(self.radians) * speed
def turn_right(self, theta: float = 90):
self.angle -= theta
def turn_left(self, theta: float = 90):
self.angle += theta
def stop(self):
"""
Stop the Sprite's motion
"""
self.change_x = 0
self.change_y = 0
self.change_angle = 0
def get_points(self) -> Tuple[Tuple[float, float]]:
"""
Get the corner points for the rect that makes up the sprite.
"""
if self._point_list_cache is not None:
return self._point_list_cache
if self._points is not None:
point_list = []
for point in range(len(self._points)):
point = (self._points[point][0] + self.center_x,
self._points[point][1] + self.center_y)
point_list.append(point)
self._point_list_cache = tuple(point_list)
else:
x1, y1 = rotate_point(self.center_x - self.width / 2,
self.center_y - self.height / 2,
self.center_x,
self.center_y,
self.angle)
x2, y2 = rotate_point(self.center_x + self.width / 2,
self.center_y - self.height / 2,
self.center_x,
self.center_y,
self.angle)
x3, y3 = rotate_point(self.center_x + self.width / 2,
self.center_y + self.height / 2,
self.center_x,
self.center_y,
self.angle)
x4, y4 = rotate_point(self.center_x - self.width / 2,
self.center_y + self.height / 2,
self.center_x,
self.center_y,
self.angle)
self._point_list_cache = ((x1, y1), (x2, y2), (x3, y3), (x4, y4))
return self._point_list_cache
points = property(get_points, set_points)
def _set_collision_radius(self, collision_radius: float):
"""
Set the collision radius.
.. note:: Final collision checking is done via geometry that was
set in get_points/set_points. These points are used in the
check_for_collision function. This collision_radius variable
is used as a "pre-check." We do a super-fast check with
collision_radius and see if the sprites are close. If they are,
then we look at the geometry and figure if they really are colliding.
:param float collision_radius: Collision radius
"""
self._collision_radius = collision_radius
def _get_collision_radius(self):
"""
Get the collision radius.
.. note:: Final collision checking is done via geometry that was
set in get_points/set_points. These points are used in the
check_for_collision function. This collision_radius variable
is used as a "pre-check." We do a super-fast check with
collision_radius and see if the sprites are close. If they are,
then we look at the geometry and figure if they really are colliding.
"""
if not self._collision_radius:
self._collision_radius = max(self.width, self.height)
return self._collision_radius
collision_radius = property(_get_collision_radius, _set_collision_radius)
def __lt__(self, other):
return self._texture.texture_id.value < other.texture.texture_id.value
def clear_spatial_hashes(self):
"""
Search the sprite lists this sprite is a part of, and remove it
from any spatial hashes it is a part of.
"""
for sprite_list in self.sprite_lists:
if sprite_list.use_spatial_hash and sprite_list.spatial_hash is not None:
try:
sprite_list.spatial_hash.remove_object(self)
except ValueError:
print("Warning, attempt to remove item from spatial hash that doesn't exist in the hash.")
def add_spatial_hashes(self):
for sprite_list in self.sprite_lists:
if sprite_list.use_spatial_hash:
sprite_list.spatial_hash.insert_object_for_box(self)
def _get_bottom(self) -> float:
"""
Return the y coordinate of the bottom of the sprite.
"""
points = self.get_points()
my_min = points[0][1]
for point in range(1, len(points)):
my_min = min(my_min, points[point][1])
return my_min
def _set_bottom(self, amount: float):
"""
Set the location of the sprite based on the bottom y coordinate.
"""
lowest = self._get_bottom()
diff = lowest - amount
self.center_y -= diff
bottom = property(_get_bottom, _set_bottom)
def _get_top(self) -> float:
"""
Return the y coordinate of the top of the sprite.
"""
points = self.get_points()
my_max = points[0][1]
for i in range(1, len(points)):
my_max = max(my_max, points[i][1])
return my_max
def _set_top(self, amount: float):
""" The highest y coordinate. """
highest = self._get_top()
diff = highest - amount
self.center_y -= diff
top = property(_get_top, _set_top)
def _get_width(self) -> float:
""" Get the width of the sprite. """
return self._width
def _set_width(self, new_value: float):
""" Set the width in pixels of the sprite. """
if new_value != self._width:
self.clear_spatial_hashes()
self._point_list_cache = None
self._width = new_value
self.add_spatial_hashes()
for sprite_list in self.sprite_lists:
sprite_list.update_position(self)
width = property(_get_width, _set_width)
def _get_height(self) -> float:
""" Get the height in pixels of the sprite. """
return self._height
def _set_height(self, new_value: float):
""" Set the center x coordinate of the sprite. """
if new_value != self._height:
self.clear_spatial_hashes()
self._point_list_cache = None
self._height = new_value
self.add_spatial_hashes()
for sprite_list in self.sprite_lists:
sprite_list.update_position(self)
height = property(_get_height, _set_height)
def _get_scale(self) -> float:
""" Get the scale of the sprite. """
return self._scale
def _set_scale(self, new_value: float):
""" Set the center x coordinate of the sprite. """
if new_value != self._height:
self.clear_spatial_hashes()
self._point_list_cache = None
self._scale = new_value
if self._texture:
self._width = self._texture.width * self._scale
self._height = self._texture.height * self._scale
self.add_spatial_hashes()
for sprite_list in self.sprite_lists:
sprite_list.update_position(self)
scale = property(_get_scale, _set_scale)
def _get_center_x(self) -> float:
""" Get the center x coordinate of the sprite. """
return self._position[0]
def _set_center_x(self, new_value: float):
""" Set the center x coordinate of the sprite. """
if new_value != self._position[0]:
self.clear_spatial_hashes()
self._point_list_cache = None
self._position[0] = new_value
self.add_spatial_hashes()
for sprite_list in self.sprite_lists:
sprite_list.update_location(self)
center_x = property(_get_center_x, _set_center_x)
def _get_center_y(self) -> float:
""" Get the center y coordinate of the sprite. """
return self._position[1]
def _set_center_y(self, new_value: float):
""" Set the center y coordinate of the sprite. """
if new_value != self._position[1]:
self.clear_spatial_hashes()
self._point_list_cache = None
self._position[1] = new_value
self.add_spatial_hashes()
for sprite_list in self.sprite_lists:
sprite_list.update_location(self)
center_y = property(_get_center_y, _set_center_y)
def _get_change_x(self) -> float:
""" Get the velocity in the x plane of the sprite. """
return self.velocity[0]
def _set_change_x(self, new_value: float):
""" Set the velocity in the x plane of the sprite. """
self.velocity[0] = new_value
change_x = property(_get_change_x, _set_change_x)
def _get_change_y(self) -> float:
""" Get the velocity in the y plane of the sprite. """
return self.velocity[1]
def _set_change_y(self, new_value: float):
""" Set the velocity in the y plane of the sprite. """
self.velocity[1] = new_value
change_y = property(_get_change_y, _set_change_y)
def _get_angle(self) -> float:
""" Get the angle of the sprite's rotation. """
return self._angle
def _set_angle(self, new_value: float):
""" Set the angle of the sprite's rotation. """
if new_value != self._angle:
self.clear_spatial_hashes()
self._angle = new_value
self._point_list_cache = None
self.add_spatial_hashes()
for sprite_list in self.sprite_lists:
sprite_list.update_angle(self)
angle = property(_get_angle, _set_angle)
def _to_radians(self) -> float:
"""
Converts the degrees representation of self.angle into radians.
:return: float
"""
return self.angle / 180.0 * math.pi
def _from_radians(self, new_value: float):
"""
Converts a radian value into degrees and stores it into angle.
"""
self.angle = new_value * 180.0 / math.pi
radians = property(_to_radians, _from_radians)
def _get_left(self) -> float:
"""
Left-most coordinate.
"""
points = self.get_points()
my_min = points[0][0]
for i in range(1, len(points)):
my_min = min(my_min, points[i][0])
return my_min
def _set_left(self, amount: float):
""" The left most x coordinate. """
leftmost = self._get_left()
diff = amount - leftmost
self.center_x += diff
left = property(_get_left, _set_left)
def _get_right(self) -> float:
"""
Return the x coordinate of the right-side of the sprite.
"""
points = self.get_points()
my_max = points[0][0]
for point in range(1, len(points)):
my_max = max(my_max, points[point][0])
return my_max
def _set_right(self, amount: float):
""" The right most x coordinate. """
rightmost = self._get_right()
diff = rightmost - amount
self.center_x -= diff
right = property(_get_right, _set_right)
def set_texture(self, texture_no: int):
"""
Sets texture by texture id. Should be renamed because it takes
a number rather than a texture, but keeping
this for backwards compatibility.
"""
if self.textures[texture_no] == self._texture:
return
texture = self.textures[texture_no]
self.clear_spatial_hashes()
self._point_list_cache = None
self._texture = texture
self._width = texture.width * texture.scale
self._height = texture.height * texture.scale
self.add_spatial_hashes()
for sprite_list in self.sprite_lists:
sprite_list.update_texture(self)
def _set_texture2(self, texture: Texture):
""" Sets texture by texture id. Should be renamed but keeping
this for backwards compatibility. """
if texture == self._texture:
return
self.clear_spatial_hashes()
self._point_list_cache = None
self._texture = texture
self._width = texture.width * texture.scale
self._height = texture.height * texture.scale
self.add_spatial_hashes()
for sprite_list in self.sprite_lists:
sprite_list.update_texture(self)
def _get_texture(self):
return self._texture
texture = property(_get_texture, _set_texture2)
def _get_color(self) -> RGB:
"""
Return the RGB color associated with the sprite.
"""
return self._color
def _set_color(self, color: RGB):
"""
Set the current sprite color as a RGB value
"""
self._color = color
for sprite_list in self.sprite_lists:
sprite_list.update_position(self)
color = property(_get_color, _set_color)
def _get_alpha(self) -> int:
"""
Return the alpha associated with the sprite.
"""
return self._alpha
def _set_alpha(self, alpha: int):
"""
Set the current sprite color as a value
"""
self._alpha = alpha
for sprite_list in self.sprite_lists:
sprite_list.update_position(self)
alpha = property(_get_alpha, _set_alpha)
def register_sprite_list(self, new_list):
"""
Register this sprite as belonging to a list. We will automatically
remove ourselves from the the list when kill() is called.
"""
self.sprite_lists.append(new_list)
def draw(self):
""" Draw the sprite. """
draw_texture_rectangle(self.center_x, self.center_y,
self.width, self.height,
self._texture, self.angle, self.alpha, # TODO: review this function
repeat_count_x=self.repeat_count_x,
repeat_count_y=self.repeat_count_y)
def update(self):
"""
Update the sprite.
"""
self.position = [self._position[0] + self.change_x, self._position[1] + self.change_y]
self.angle += self.change_angle
def update_animation(self):
"""
Override this to add code that will change
what image is shown, so the sprite can be
animated.
"""
pass
def remove_from_sprite_lists(self):
"""
Remove the sprite from all sprite lists.
"""
for sprite_list in self.sprite_lists:
if self in sprite_list:
sprite_list.remove(self)
self.sprite_lists.clear()
def kill(self):
"""
Alias of `remove_from_sprite_lists`
"""
self.remove_from_sprite_lists()
def collides_with_point(self, point: Point) -> bool:
"""Check if point is within the current sprite.
Args:
self: Current sprite
point: Point to check.
Returns:
True if the point is contained within the sprite's boundary.
"""
from arcade.geometry import is_point_in_polygon
x, y = point
return is_point_in_polygon(x, y, self.points)
def collides_with_sprite(self, other: 'Sprite') -> bool:
"""Will check if a sprite is overlapping (colliding) another Sprite.
Args:
self: Current Sprite.
other: The other sprite to check against.
Returns:
True or False, whether or not they are overlapping.
"""
from arcade.geometry import check_for_collision
return check_for_collision(self, other)
def collides_with_list(self, sprite_list: list) -> list:
"""Check if current sprite is overlapping with any other sprite in a list
Args:
self: current Sprite
sprite_list: SpriteList to check against
Returns:
SpriteList of all overlapping Sprites from the original SpriteList
"""
from arcade.geometry import check_for_collision_with_list
return check_for_collision_with_list(self, sprite_list)
class AnimatedTimeSprite(Sprite):
"""
Sprite for platformer games that supports animations.
"""
def __init__(self, scale: float = 1,
image_x: float = 0, image_y: float = 0,
center_x: float = 0, center_y: float = 0):
super().__init__(scale=scale, image_x=image_x, image_y=image_y,
center_x=center_x, center_y=center_y)
self.state = FACE_RIGHT
self.cur_texture_index = 0
self.texture_change_frames = 5
self.frame = 0
def update_animation(self):
"""
Logic for selecting the proper texture to use.
"""
if self.frame % self.texture_change_frames == 0:
self.cur_texture_index += 1
if self.cur_texture_index >= len(self.textures):
self.cur_texture_index = 0
self.set_texture(self.cur_texture_index)
self.frame += 1
@dataclasses.dataclass
class AnimationKeyframe:
tile_id: int
duration: int
image: PIL.Image
class AnimatedTimeBasedSprite(Sprite):
"""
Sprite for platformer games that supports animations.
"""
def __init__(self,
filename: str = None,
scale: float = 1,
image_x: float = 0, image_y: float = 0,
image_width: float = 0, image_height: float = 0,
center_x: float = 0, center_y: float = 0,
repeat_count_x=1, repeat_count_y=1):
super().__init__(filename=filename, scale=scale, image_x=image_x, image_y=image_y,
image_width=image_width, image_height=image_height,
center_x=center_x, center_y=center_y)
self.cur_frame = 0
self.frames = []
self.time_counter = 0.0
def update_animation(self, delta_time: float):
"""
Logic for selecting the proper texture to use.
"""
self.time_counter += delta_time
while self.time_counter > self.frames[self.cur_frame].duration / 1000.0:
self.time_counter -= self.frames[self.cur_frame].duration / 1000.0
self.cur_frame += 1
if self.cur_frame >= len(self.frames):
self.cur_frame = 0
source = self.frames[self.cur_frame].image.source
# print(f"Advance to frame {self.cur_frame}: {source}")
self.texture = load_texture(source, scale=self.scale)
class AnimatedWalkingSprite(Sprite):
"""
Sprite for platformer games that supports animations.
"""
def __init__(self, scale: float = 1,
image_x: float = 0, image_y: float = 0,
center_x: float = 0, center_y: float = 0):
super().__init__(scale=scale, image_x=image_x, image_y=image_y,
center_x=center_x, center_y=center_y)
self.state = FACE_RIGHT
self.stand_right_textures = None
self.stand_left_textures = None
self.walk_left_textures = None
self.walk_right_textures = None
self.walk_up_textures = None
self.walk_down_textures = None
self.cur_texture_index = 0
self.texture_change_distance = 20
self.last_texture_change_center_x = 0
self.last_texture_change_center_y = 0
def update_animation(self):
"""
Logic for selecting the proper texture to use.
"""
x1 = self.center_x
x2 = self.last_texture_change_center_x
y1 = self.center_y
y2 = self.last_texture_change_center_y
distance = math.sqrt((x2 - x1) ** 2 + (y2 - y1) ** 2)
texture_list = []
change_direction = False
if self.change_x > 0 \
and self.change_y == 0 \
and self.state != FACE_RIGHT \
and self.walk_right_textures \
and len(self.walk_right_textures) > 0:
self.state = FACE_RIGHT
change_direction = True
elif self.change_x < 0 and self.change_y == 0 and self.state != FACE_LEFT \
and self.walk_left_textures and len(self.walk_left_textures) > 0:
self.state = FACE_LEFT
change_direction = True
elif self.change_y < 0 and self.change_x == 0 and self.state != FACE_DOWN \
and self.walk_down_textures and len(self.walk_down_textures) > 0:
self.state = FACE_DOWN
change_direction = True
elif self.change_y > 0 and self.change_x == 0 and self.state != FACE_UP \
and self.walk_up_textures and len(self.walk_up_textures) > 0:
self.state = FACE_UP
change_direction = True
if self.change_x == 0 and self.change_y == 0:
if self.state == FACE_LEFT:
self.texture = self.stand_left_textures[0]
elif self.state == FACE_RIGHT:
self.texture = self.stand_right_textures[0]
elif self.state == FACE_UP:
self.texture = self.walk_up_textures[0]
elif self.state == FACE_DOWN:
self.texture = self.walk_down_textures[0]
elif change_direction or distance >= self.texture_change_distance:
self.last_texture_change_center_x = self.center_x
self.last_texture_change_center_y = self.center_y
if self.state == FACE_LEFT:
texture_list = self.walk_left_textures
if texture_list is None or len(texture_list) == 0:
raise RuntimeError("update_animation was called on a sprite that doesn't have a "
"list of walk left textures.")
elif self.state == FACE_RIGHT:
texture_list = self.walk_right_textures
if texture_list is None or len(texture_list) == 0:
raise RuntimeError("update_animation was called on a sprite that doesn't have a list of "
"walk right textures.")
elif self.state == FACE_UP:
texture_list = self.walk_up_textures
if texture_list is None or len(texture_list) == 0:
raise RuntimeError("update_animation was called on a sprite that doesn't have a list of "
"walk up textures.")
elif self.state == FACE_DOWN:
texture_list = self.walk_down_textures
if texture_list is None or len(texture_list) == 0:
raise RuntimeError(
"update_animation was called on a sprite that doesn't have a list of walk down textures.")
self.cur_texture_index += 1
if self.cur_texture_index >= len(texture_list):
self.cur_texture_index = 0
self.texture = texture_list[self.cur_texture_index]
if self._texture is None:
print("Error, no texture set")
else:
self.width = self._texture.width * self.scale
self.height = self._texture.height * self.scale
def get_distance_between_sprites(sprite1: Sprite, sprite2: Sprite) -> float:
"""
Returns the distance between the center of two given sprites
:param Sprite sprite1: Sprite one
:param Sprite sprite2: Sprite two
:return: Distance
:rtype: float
"""
distance = math.sqrt((sprite1.center_x - sprite2.center_x) ** 2 + (sprite1.center_y - sprite2.center_y) ** 2)
return distance