Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
10 changes: 6 additions & 4 deletions arcade/__init__.py
Original file line number Diff line number Diff line change
Expand Up @@ -158,10 +158,11 @@ def configure_logging(level: Optional[int] = None):
from .sprite import FACE_LEFT
from .sprite import FACE_RIGHT
from .sprite import FACE_UP
from .sprite import AnimatedTimeBasedSprite
from .sprite import TextureAnimationSprite
from .sprite import load_animated_gif
from .sprite import AnimatedWalkingSprite
from .sprite import AnimationKeyframe
from .sprite import TextureAnimation
from .sprite import TextureKeyframe
from .sprite import PyMunk
from .sprite import PymunkMixin
from .sprite import SpriteType
Expand Down Expand Up @@ -237,9 +238,10 @@ def configure_logging(level: Optional[int] = None):

__all__ = [
'AStarBarrierList',
'AnimatedTimeBasedSprite',
'AnimatedWalkingSprite',
'AnimationKeyframe',
'TextureAnimationSprite',
'TextureAnimation',
'TextureKeyframe',
'ArcadeContext',
'Camera',
'SimpleCamera',
Expand Down
43 changes: 43 additions & 0 deletions arcade/examples/sprite_animated_keyframes.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,43 @@
"""
Example using the TextureAnimationSprite class to animate a sprite using keyframes.
This sprite type is primarily used internally by tilemaps, but can be used for other purposes as well.

If Python and arcade are installed, this example can be run from the command line with:
python -m arcade.examples.sprite_animated_keyframes
"""
import arcade


class Animated(arcade.Window):

def __init__(self):
super().__init__(800, 600, "Time based animated sprite")

# Load the 8 frames for the walking animation
keyframes = [
arcade.TextureKeyframe(
texture=arcade.load_texture(
f":assets:/images/animated_characters/female_adventurer/femaleAdventurer_walk{i}.png"
),
duration=100,
)
for i in range(8)
]
anim = arcade.TextureAnimation(keyframes=keyframes)
self.sprite = arcade.TextureAnimationSprite(
animation=anim,
scale=1.0,
center_x=400,
center_y=300
)

def on_draw(self):
self.clear()
self.sprite.draw(pixelated=True)

def on_update(self, delta_time: float):
self.sprite.update_animation(delta_time)


if __name__ == "__main__":
Animated().run()
24 changes: 14 additions & 10 deletions arcade/sprite/__init__.py
Original file line number Diff line number Diff line change
Expand Up @@ -8,8 +8,9 @@
from .sprite import Sprite
from .mixins import PymunkMixin, PyMunk
from .animated import (
AnimatedTimeBasedSprite,
AnimationKeyframe,
TextureAnimationSprite,
TextureAnimation,
TextureKeyframe,
AnimatedWalkingSprite,
)
from .colored import SpriteSolidColor, SpriteCircle
Expand All @@ -21,9 +22,9 @@
)


def load_animated_gif(resource_name) -> AnimatedTimeBasedSprite:
def load_animated_gif(resource_name) -> TextureAnimationSprite:
"""
Attempt to load an animated GIF as an :class:`AnimatedTimeBasedSprite`.
Attempt to load an animated GIF as an :class:`TextureAnimationSprite`.

Many older GIFs will load with incorrect transparency for every
frame but the first. Until the Pillow library handles the quirks of
Expand All @@ -37,17 +38,19 @@ def load_animated_gif(resource_name) -> AnimatedTimeBasedSprite:
if not image_object.is_animated:
raise TypeError(f"The file {resource_name} is not an animated gif.")

sprite = AnimatedTimeBasedSprite()
sprite = TextureAnimationSprite()
keyframes = []
for frame in range(image_object.n_frames):
image_object.seek(frame)
frame_duration = image_object.info['duration']
image = image_object.convert("RGBA")
texture = Texture(image)
texture.file_path = file_name
sprite.textures.append(texture)
sprite.frames.append(AnimationKeyframe(0, frame_duration, texture))
# sprite.textures.append(texture)
keyframes.append(TextureKeyframe(texture, frame_duration))

sprite.texture = sprite.textures[0]
animation = TextureAnimation(keyframes=keyframes)
sprite.animation = animation
return sprite


Expand All @@ -56,8 +59,9 @@ def load_animated_gif(resource_name) -> AnimatedTimeBasedSprite:
"BasicSprite",
"Sprite",
"PyMunk",
"AnimatedTimeBasedSprite",
"AnimationKeyframe",
"TextureAnimationSprite",
"TextureAnimation",
"TextureKeyframe",
"AnimatedWalkingSprite",
"load_animated_gif",
"SpriteSolidColor",
Expand Down
196 changes: 169 additions & 27 deletions arcade/sprite/animated.py
Original file line number Diff line number Diff line change
@@ -1,12 +1,11 @@
from __future__ import annotations

import dataclasses
import bisect
import math
from typing import List
from typing import List, Optional, Tuple

from .sprite import Sprite
from arcade import Texture
from arcade.types import PathOrTexture
from .enums import (
FACE_LEFT,
FACE_RIGHT,
Expand All @@ -15,20 +14,132 @@
)


@dataclasses.dataclass
class AnimationKeyframe:
class TextureKeyframe:
"""
Keyframe for texture animations.

:param texture: Texture to display for this keyframe.
:param duration: Duration in milliseconds to display this keyframe.
:param tile_id: Tile ID for this keyframe (only used for tiled maps)
"""
tile_id: int
duration: int
texture: Texture
def __init__(
self,
texture: Texture,
duration: int = 100,
tile_id: Optional[int] = 0,
**kwargs
):
#: The texture to display for this keyframe.
self.texture = texture
#: Duration in milliseconds to display this keyframe.
self.duration = duration
#: Tile ID for this keyframe (only used for tiled maps)
self.tile_id = tile_id


class AnimatedTimeBasedSprite(Sprite):
class TextureAnimation:
"""
Sprite for platformer games that supports animations. These can
be automatically created by the Tiled Map Editor.
Animation class that holds a list of keyframes.
The animation should not store any state related to the current time
so it can be shared between multiple sprites.

:param keyframes: List of keyframes for the animation.
:param loop: If the animation should loop.
"""
def __init__(self, keyframes: Optional[List[TextureKeyframe]] = None):
self._keyframes = keyframes or []
self._duration_ms = 0
self._timeline: List[int] = self._create_timeline(self._keyframes) if self._keyframes else []

@property
def keyframes(self) -> Tuple[TextureKeyframe, ...]:
"""
A tuple of keyframes in the animation.
Keyframes should not be modified directly.
"""
return tuple(self._keyframes)

@property
def duration_seconds(self) -> float:
"""
Total duration of the animation in seconds.
"""
return self._duration_ms / 1000

@property
def duration_ms(self) -> int:
"""
Total duration of the animation in milliseconds.
"""
return self._duration_ms

@property
def num_frames(self) -> int:
"""
Number of frames in the animation.
"""
return len(self._keyframes)

def _create_timeline(self, keyframes: List[TextureKeyframe]) -> List[int]:
"""
Create a timeline of the animation.
This is a list of timestamps for each frame in seconds.
"""
timeline: List[int] = []
current_time_ms = 0
for frame in keyframes:
timeline.append(current_time_ms)
current_time_ms += frame.duration

self._duration_ms = current_time_ms
return timeline

def append_keyframe(self, keyframe: TextureKeyframe) -> None:
"""
Add a keyframe to the animation.

:param keyframe: Keyframe to add.
"""
self._keyframes.append(keyframe)
self._timeline.append(self._duration_ms)
self._timeline = self._create_timeline(self._keyframes)

def remove_keyframe(self, index: int) -> None:
"""
Remove a keyframe from the animation.

:param index: Index of the keyframe to remove.
"""
del self._keyframes[index]
self._timeline = self._create_timeline(self._keyframes)

def get_keyframe(self, time: float, loop: bool = True) -> Tuple[int, TextureKeyframe]:
"""
Get the frame at a given time.

:param time: Time in seconds.
:param loop: If the animation should loop.
:return: Tuple of frame index and keyframe.
"""
if loop:
time_ms = int(time * 1000) % self._duration_ms
else:
time_ms = int(time * 1000)

# Find the right insertion point for the time: O(log n)
index = bisect.bisect_right(self._timeline, time_ms) - 1
index = max(0, min(index, len(self._keyframes) - 1))
return index, self._keyframes[index]

def __len__(self) -> int:
return len(self._keyframes)


# Old name: AnimatedTimeBasedSprite
class TextureAnimationSprite(Sprite):
"""
Animated sprite based on keyframes.
Primarily used internally by tilemaps.

:param path_or_texture: Path to the image file, or a Texture object.
:param center_x: Initial x position of the sprite.
Expand All @@ -37,38 +148,69 @@ class AnimatedTimeBasedSprite(Sprite):
"""
def __init__(
self,
path_or_texture: PathOrTexture = None,
center_x: float = 0.0,
center_y: float = 0.0,
scale: float = 1.0,
animation: Optional[TextureAnimation] = None,
**kwargs,
):
super().__init__(
path_or_texture,
scale=scale,
center_x=center_x,
center_y=center_y,
)
self.cur_frame_idx = 0
self.frames: List[AnimationKeyframe] = []
self.time_counter = 0.0
self._time = 0.0
self._animation = None
if animation:
self.animation = animation
self._current_keyframe_index = 0

def update_animation(self, delta_time: float = 1 / 60) -> None:
@property
def time(self) -> float:
"""
Get or set the current time of the animation in seconds.
"""
return self._time

@time.setter
def time(self, value: float) -> None:
self._time = value

@property
def animation(self) -> TextureAnimation:
"""
Animation object for this sprite.
"""
if self._animation is None:
raise RuntimeError("No animation set for this sprite.")
return self._animation

@animation.setter
def animation(self, value: TextureAnimation) -> None:
"""
Set the animation for this sprite.

:param value: Animation to set.
"""
self._animation = value
# TODO: Forcing the first frame here might not be the best idea.
self.texture = value._keyframes[0].texture
self.sync_hit_box_to_texture()

def update_animation(self, delta_time: float = 1 / 60, **kwargs) -> None:
"""
Logic for updating the animation.

:param delta_time: Time since last update.
"""
self.time_counter += delta_time
while self.time_counter > self.frames[self.cur_frame_idx].duration / 1000.0:
self.time_counter -= self.frames[self.cur_frame_idx].duration / 1000.0
self.cur_frame_idx += 1
if self.cur_frame_idx >= len(self.frames):
self.cur_frame_idx = 0
# source = self.frames[self.cur_frame].texture.image.source
cur_frame = self.frames[self.cur_frame_idx]
# print(f"Advance to frame {self.cur_frame_idx}: {cur_frame.texture.name}")
self.texture = cur_frame.texture
if self._animation is None:
raise RuntimeError("No animation set for this sprite.")

self.time += delta_time
index, keyframe = self._animation.get_keyframe(self.time)
if index != self._current_keyframe_index:
self._current_keyframe_index = index
self.texture = keyframe.texture


class AnimatedWalkingSprite(Sprite):
Expand Down
Loading