# SPDX-License-Identifier: GPL-3.0
# Copyright (c) 2014-2023 William Edwards <shadowapex@gmail.com>, Benjamin Bean <superman2k5@gmail.com>
# Based on pyganim: A sprite animation module for Pygame.
# By Al Sweigart al@inventwithpython.com
# http://inventwithpython.com/pyganim
# Released under a "Simplified BSD" license
from __future__ import annotations
import bisect
import itertools
from typing import (
Any,
Final,
List,
Literal,
Mapping,
Optional,
Sequence,
Tuple,
TypeVar,
Union,
)
# TODO: Feature idea: if the same image file is specified, re-use the Surface
import pygame
# setting up constants
from pygame.rect import Rect
PLAYING: Final = "playing"
PAUSED: Final = "paused"
STOPPED: Final = "stopped"
State = Literal["playing", "paused", "stopped"]
[docs]class SurfaceAnimation:
"""
Animation of Pygame surfaces. Starts off in the STOPPED state.
Parameters:
frames: A list of tuples (image, duration) for each frame of
animation, where image can be either a Pygame surface or a
path to an image, and duration is the duration in seconds.
Note that the images and duration cannot be changed. A new
SurfaceAnimation object will have to be created.
loop: Tells the animation object to keep playing in a loop.
"""
def __init__(
self,
frames: Sequence[Tuple[Union[str, pygame.surface.Surface], float]],
loop: bool = True,
) -> None:
# Obtain constant precision setting the initial value to 2^32:
# https://randomascii.wordpress.com/2012/02/13/dont-store-that-in-a-float/
self._internal_clock = float(2**32)
# _images stores the pygame.Surface objects of each frame
self._images = []
# _durations stores the durations (in seconds) of each frame.
# e.g. [1, 1, 2.5] means the first and second frames last one second,
# and the third frame lasts for two and half seconds.
self._durations = []
self._state: State = STOPPED
self._loop = loop
self._rate = 1.0
self._visibility = True
# The time that the play() function was last called.
self._playing_start_time = 0.0
# The time that the pause() function was last called.
self._paused_start_time = 0.0
self.num_frames = len(frames)
assert self.num_frames > 0, "Must contain at least one frame."
# Load each frame of animation into _images
for i in range(self.num_frames):
frame = frames[i]
assert (
isinstance(frame, tuple) and len(frame) == 2
), f"Frame {i} has incorrect format."
assert type(frame[0]) in (
str,
pygame.Surface,
), f"Frame {i} image must be a string filename or a pygame.Surface"
assert (
frame[1] > 0
), f"Frame {i} duration must be greater than zero."
frame_img = (
pygame.image.load(frame[0])
if isinstance(frame[0], str)
else frame[0]
)
self._images.append(frame_img)
self._durations.append(frame[1])
# _start_times shows when each frame begins. len(self._start_times)
# will always be one more than len(self._images), because the last
# number will be when the last frame ends, rather than when it starts.
# The values are in seconds.
# So self._start_times[-1] tells you the length of the entire
# animation. e.g. if _durations is [1, 1, 2.5], then _start_times will
# be [0, 1, 2, 4.5]
self._start_times = (0.0,) + tuple(
itertools.accumulate(self._durations),
)
[docs] def get_frame(self, frame_num: int) -> pygame.surface.Surface:
"""Return the pygame.Surface object of the frame_num-th frame."""
from tuxemon.sprite import dummy_image
if frame_num >= len(self._images):
return dummy_image
return self._images[frame_num]
[docs] def get_current_frame(self) -> pygame.surface.Surface:
"""Return the current frame."""
return self.get_frame(self.current_frame_num)
[docs] def is_finished(self) -> bool:
"""Return ``True`` if this animation has finished playing."""
return not self.loop and self.elapsed >= self._start_times[-1]
[docs] def play(self, start_time: Optional[float] = None) -> None:
"""Start playing the animation."""
if start_time is None:
start_time = self._internal_clock
if self._state == PLAYING:
if self.is_finished():
# if the animation doesn't loop and has already finished, then
# calling play() causes it to replay from the beginning.
self._playing_start_time = start_time
elif self._state == STOPPED:
# if animation was stopped, start playing from the beginning
self._playing_start_time = start_time
elif self._state == PAUSED:
# if animation was paused, start playing from where it was paused
self._playing_start_time = start_time - (
self._paused_start_time - self._playing_start_time
)
self._state = PLAYING
[docs] def pause(self, start_time: Optional[float] = None) -> None:
"""Stop having the animation progress."""
if start_time is None:
start_time = self._internal_clock
if self._state == PAUSED:
return # do nothing
elif self._state == PLAYING:
self._paused_start_time = start_time
elif self._state == STOPPED:
rightNow = self._internal_clock
self._playing_start_time = rightNow
self._paused_start_time = rightNow
self._state = PAUSED
[docs] def stop(self) -> None:
"""Reset the animation to the beginning frame, and stop."""
if self._state == STOPPED:
return # do nothing
self._state = STOPPED
[docs] def update(self, time_delta: float) -> None:
"""
Update the internal clock with the elapsed time.
Parameters:
time_delta: Time elapsed since last call to update.
"""
self._internal_clock += time_delta
[docs] def flip(self, flip_axes: str) -> None:
"""Flip all frames of an animation along the X-axis and/or Y-axis."""
# Empty string - animation won't be flipped
flip_x = "x" in flip_axes
flip_y = "y" in flip_axes
self._images = [
pygame.transform.flip(image, flip_x, flip_y)
for image in self._images
]
def _get_max_size(self) -> Tuple[int, int]:
"""
Get the maximum size of the animation.
Goes through all the Surface objects in this animation object
and returns the max width and max height that it finds, as these
widths and heights may be on different Surface objects.
Returns:
Max size in the form (width, height).
"""
frame_widths = []
frame_heights = []
for i in range(len(self._images)):
frameWidth, frameHeight = self._images[i].get_size()
frame_widths.append(frameWidth)
frame_heights.append(frameHeight)
maxWidth = max(frame_widths)
maxHeight = max(frame_heights)
return (maxWidth, maxHeight)
[docs] def get_rect(self) -> pygame.rect.Rect:
"""
Returns a Rect object for this animation object.
The top and left will be set to 0, 0, and the width and height
will be set to the maximum size of the animation.
Returns:
Rect object of maximum size.
"""
maxWidth, maxHeight = self._get_max_size()
return Rect(0, 0, maxWidth, maxHeight)
@property
def rate(self) -> float:
return self._rate
@rate.setter
def rate(self, rate: float) -> None:
rate = float(rate)
if rate < 0:
raise ValueError("rate must be greater than 0.")
self._rate = rate
@property
def loop(self) -> bool:
return self._loop
@loop.setter
def loop(self, loop: bool) -> None:
if self.state == PLAYING and self._loop and not loop:
# If we are turning off looping while the animation is playing,
# we need to modify the _playing_start_time so that the rest of
# the animation will play, and then stop. Otherwise, the
# animation will immediately stop playing if it has already looped.
self._playing_start_time = self._internal_clock - self.elapsed
self._loop = bool(loop)
@property
def state(self) -> State:
if self.is_finished():
# If finished playing, then set state to STOPPED.
self._state = STOPPED
return self._state
@state.setter
def state(self, state: State) -> None:
if state not in (PLAYING, PAUSED, STOPPED):
raise ValueError(
"state must be one of surfanim.PLAYING, surfanim.PAUSED, or "
"surfanim.STOPPED",
)
if state == PLAYING:
self.play()
elif state == PAUSED:
self.pause()
elif state == STOPPED:
self.stop()
@property
def visibility(self) -> bool:
return self._visibility
@visibility.setter
def visibility(self, visibility: bool) -> None:
self._visibility = bool(visibility)
@property
def elapsed(self) -> float:
# NOTE: Do to floating point rounding errors, this doesn't work
# precisely.
# To prevent infinite recursion, don't use the self.state property,
# just read/set self._state directly because the state getter calls
# this method.
# Find out how long ago the play()/pause() functions were called.
if self._state == STOPPED:
# if stopped, then just return 0
return 0
if self._state == PLAYING:
# If playing, then draw the current frame (based on when the
# animation started playing). If not looping and the animation
# has gone through all the frames already, then draw the last
# frame.
elapsed = (
self._internal_clock - self._playing_start_time
) * self.rate
elif self._state == PAUSED:
# If paused, then draw the frame that was playing at the time the
# SurfaceAnimation object was paused
elapsed = (
self._paused_start_time - self._playing_start_time
) * self.rate
if self._loop:
elapsed = elapsed % self._start_times[-1]
else:
elapsed = clip(elapsed, 0, self._start_times[-1])
elapsed += 0.00001 # done to compensate for rounding errors
return elapsed
@elapsed.setter
def elapsed(self, elapsed: float) -> None:
# NOTE: Do to floating point rounding errors, this doesn't work
# precisely.
elapsed += 0.00001 # done to compensate for rounding errors
# TODO - I really need to find a better way to handle the floating
# point thing.
# Set the elapsed time to a specific value.
if self._loop:
elapsed = elapsed % self._start_times[-1]
else:
elapsed = clip(elapsed, 0, self._start_times[-1])
rightNow = self._internal_clock
self._playing_start_time = rightNow - (elapsed * self.rate)
if self.state in (PAUSED, STOPPED):
self.state = PAUSED # if stopped, then set to paused
self._paused_start_time = rightNow
@property
def current_frame_num(self) -> int:
# Return the frame number of the frame that will be currently
# displayed if the animation object were drawn right now.
return bisect.bisect(self._start_times, self.elapsed) - 1
@current_frame_num.setter
def current_frame_num(self, frame_num: int) -> None:
# Change the elapsed time to the beginning of a specific frame.
if self.loop:
frame_num = frame_num % len(self._images)
else:
frame_num = clip(frame_num, 0, len(self._images) - 1)
self.elapsed = self._start_times[frame_num]
[docs]class SurfaceAnimationCollection:
def __init__(
self,
*animations: Union[
SurfaceAnimation,
Sequence[SurfaceAnimation],
Mapping[Any, SurfaceAnimation],
],
) -> None:
self._animations: List[SurfaceAnimation] = []
if animations:
self.add(*animations)
self._state: State = STOPPED
[docs] def add(
self,
*animations: Union[
SurfaceAnimation,
Sequence[SurfaceAnimation],
Mapping[Any, SurfaceAnimation],
],
) -> None:
if isinstance(animations[0], Mapping):
for k in animations[0].keys():
self._animations.append(animations[0][k])
elif isinstance(animations[0], Sequence):
for i in range(len(animations[0])):
self._animations.append(animations[0][i])
else:
for i in range(len(animations)):
anim = animations[i]
assert isinstance(anim, SurfaceAnimation)
self._animations.append(anim)
@property
def animations(self) -> Sequence[SurfaceAnimation]:
return self._animations
@property
def state(self) -> State:
if self.is_finished():
self._state = STOPPED
return self._state
[docs] def is_finished(self) -> bool:
return all(a.is_finished() for a in self._animations)
[docs] def play(self, start_time: Optional[float] = None) -> None:
for anim_obj in self._animations:
anim_obj.play(start_time)
self._state = PLAYING
[docs] def pause(self, start_time: Optional[float] = None) -> None:
for anim_obj in self._animations:
anim_obj.pause(start_time)
self._state = PAUSED
[docs] def stop(self) -> None:
for anim_obj in self._animations:
anim_obj.stop()
self._state = STOPPED
[docs] def update(self, time_delta: float) -> None:
"""
Update the internal clock with the elapsed time.
Parameters:
time_delta: Time elapsed since last call to update.
"""
for anim_obj in self._animations:
anim_obj.update(time_delta)
T = TypeVar("T", bound=float)
[docs]def clip(value: T, lower: T, upper: T) -> T:
"""Clip value to [lower, upper] range."""
return lower if value < lower else upper if value > upper else value