diff --git a/arcade/__init__.py b/arcade/__init__.py index 6b95706e0..c6c99657e 100644 --- a/arcade/__init__.py +++ b/arcade/__init__.py @@ -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 @@ -237,9 +238,10 @@ def configure_logging(level: Optional[int] = None): __all__ = [ 'AStarBarrierList', - 'AnimatedTimeBasedSprite', 'AnimatedWalkingSprite', - 'AnimationKeyframe', + 'TextureAnimationSprite', + 'TextureAnimation', + 'TextureKeyframe', 'ArcadeContext', 'Camera', 'SimpleCamera', diff --git a/arcade/examples/sprite_animated_keyframes.py b/arcade/examples/sprite_animated_keyframes.py new file mode 100644 index 000000000..7b8bf901a --- /dev/null +++ b/arcade/examples/sprite_animated_keyframes.py @@ -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() diff --git a/arcade/sprite/__init__.py b/arcade/sprite/__init__.py index 5a18af804..f15859e2f 100644 --- a/arcade/sprite/__init__.py +++ b/arcade/sprite/__init__.py @@ -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 @@ -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 @@ -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 @@ -56,8 +59,9 @@ def load_animated_gif(resource_name) -> AnimatedTimeBasedSprite: "BasicSprite", "Sprite", "PyMunk", - "AnimatedTimeBasedSprite", - "AnimationKeyframe", + "TextureAnimationSprite", + "TextureAnimation", + "TextureKeyframe", "AnimatedWalkingSprite", "load_animated_gif", "SpriteSolidColor", diff --git a/arcade/sprite/animated.py b/arcade/sprite/animated.py index 25f86303e..95f89049e 100644 --- a/arcade/sprite/animated.py +++ b/arcade/sprite/animated.py @@ -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, @@ -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. @@ -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): diff --git a/arcade/tilemap/tilemap.py b/arcade/tilemap/tilemap.py index a0b9dcae1..221c927ce 100644 --- a/arcade/tilemap/tilemap.py +++ b/arcade/tilemap/tilemap.py @@ -21,8 +21,9 @@ from pytiled_parser import Color from arcade import ( - AnimatedTimeBasedSprite, - AnimationKeyframe, + TextureAnimationSprite, + TextureKeyframe, + TextureAnimation, Sprite, SpriteList, get_window, @@ -442,13 +443,13 @@ def _create_sprite_from_tile( if tile.animation: if not custom_class: - custom_class = AnimatedTimeBasedSprite - elif not issubclass(custom_class, AnimatedTimeBasedSprite): + custom_class = TextureAnimationSprite + elif not issubclass(custom_class, TextureAnimationSprite): raise RuntimeError( f""" Tried to use a custom class {custom_class.__name__} for animated tiles - that doesn't subclass AnimatedTimeBasedSprite. - Custom classes for animated tiles must subclass AnimatedTimeBasedSprite. + that doesn't subclass TextureAnimationSprite. + Custom classes for animated tiles must subclass TextureAnimationSprite. """ ) # print(custom_class.__name__) @@ -629,15 +630,15 @@ def _create_sprite_from_tile( texture = _may_be_flip(tile, texture) - key_frame = AnimationKeyframe( # type: ignore - frame.tile_id, frame.duration, texture + key_frame = TextureKeyframe( # type: ignore + texture=texture, duration=frame.duration, tile_id=frame.tile_id ) key_frame_list.append(key_frame) if len(key_frame_list) == 1: my_sprite.texture = key_frame.texture - cast(AnimatedTimeBasedSprite, my_sprite).frames = key_frame_list + cast(TextureAnimationSprite, my_sprite).animation = TextureAnimation(keyframes=key_frame_list) # type: ignore return my_sprite diff --git a/doc/tutorials/crt_filter/crt_filter_example.py b/doc/tutorials/crt_filter/crt_filter_example.py index 23bba6835..3aeee7ec7 100644 --- a/doc/tutorials/crt_filter/crt_filter_example.py +++ b/doc/tutorials/crt_filter/crt_filter_example.py @@ -50,19 +50,14 @@ def __init__(self, width, height, title): my_sprite.center_x = 800 my_sprite.center_y = 200 - my_sprite = arcade.AnimatedTimeBasedSprite() - texture = arcade.load_texture( - "pac_man_sprite_sheet.png", x=4, y=1, width=13, height=15) - frame = arcade.AnimationKeyframe(tile_id=0, - duration=150, - texture=texture) - my_sprite.frames.append(frame) - texture = arcade.load_texture( - "pac_man_sprite_sheet.png", x=20, y=1, width=13, height=15) - frame = arcade.AnimationKeyframe(tile_id=1, - duration=150, - texture=texture) - my_sprite.frames.append(frame) + keyframes = [] + texture = arcade.load_texture("pac_man_sprite_sheet.png", x=4, y=1, width=13, height=15) + frame = arcade.TextureKeyframe(texture, duration=150) + keyframes.append(frame) + texture = arcade.load_texture("pac_man_sprite_sheet.png", x=20, y=1, width=13, height=15) + frame = arcade.TextureKeyframe(texture, duration=150) + keyframes.append(frame) + my_sprite = arcade.TextureAnimationSprite(animation=arcade.TextureAnimation(keyframes)) my_sprite.change_x = 1 self.sprite_list.append(my_sprite) diff --git a/tests/unit/sprite/test_sprite_animated_old.py b/tests/unit/sprite/test_sprite_animated_walking.py similarity index 98% rename from tests/unit/sprite/test_sprite_animated_old.py rename to tests/unit/sprite/test_sprite_animated_walking.py index b86c454fc..baaf15134 100644 --- a/tests/unit/sprite/test_sprite_animated_old.py +++ b/tests/unit/sprite/test_sprite_animated_walking.py @@ -59,5 +59,5 @@ def update(delta_time): character_list.update_animation(delta_time) window.on_draw = on_draw - window.update = update + window.on_update = update window.test(150) diff --git a/tests/unit/sprite/test_sprite_gif.py b/tests/unit/sprite/test_sprite_gif.py index e3a45cda4..919515b26 100644 --- a/tests/unit/sprite/test_sprite_gif.py +++ b/tests/unit/sprite/test_sprite_gif.py @@ -3,4 +3,5 @@ def test_sprite_gif(): sprite = arcade.load_animated_gif(":resources:images/test_textures/anim.gif") - assert len(sprite.textures) == 8 + assert len(sprite.animation) == 8 + assert sprite.texture == sprite.animation.keyframes[0].texture diff --git a/tests/unit/sprite/test_sprite_texture_animation.py b/tests/unit/sprite/test_sprite_texture_animation.py new file mode 100644 index 000000000..5aca5afff --- /dev/null +++ b/tests/unit/sprite/test_sprite_texture_animation.py @@ -0,0 +1,88 @@ +import math +import pytest +import arcade + + +@pytest.fixture(scope="module") +def keyframes(): + """Create a list of keyframes.""" + return [ + arcade.TextureKeyframe( + texture=arcade.load_texture( + f":assets:/images/animated_characters/female_adventurer/femaleAdventurer_walk{i}.png" + ), + duration=1000, + ) + for i in range(8) + ] + + +def test_create(keyframes): + """Test creation and initial state""" + sprite = arcade.TextureAnimationSprite() + anim = arcade.TextureAnimation(keyframes=keyframes) + sprite.animation = anim + + assert sprite.animation == anim + assert sprite.time == 0.0 + + assert anim.num_frames == 8 + assert anim.duration_ms == 8000 + assert anim.duration_seconds == 8.0 + + # Was the initial texture set? + assert sprite.texture == keyframes[0].texture + + +def test_animation(keyframes): + """Test animation class""" + anim = arcade.TextureAnimation(keyframes=keyframes) + + # Add keyframe + anim.append_keyframe(arcade.TextureKeyframe(keyframes[0].texture, 1000)) + assert anim.num_frames == 9 + assert anim.duration_ms == 9000 + assert anim.duration_seconds == 9.0 + + # Remove keyframe + anim.remove_keyframe(8) + assert anim.num_frames == 8 + assert anim.duration_ms == 8000 + assert anim.duration_seconds == 8.0 + + # Get keyframes at specific times (0.5s increments) + for i in range(16): + time = i / 2 + index = i // 2 + assert anim.get_keyframe(time) == (index, keyframes[index]) + + # Looping + assert anim.get_keyframe(8.0) == (0, keyframes[0]) + # Not looping (should clamp to last frame) + assert anim.get_keyframe(10.0, loop=False) == (7, keyframes[7]) + + +def test_animating_sprite(keyframes): + """Test animating sprite using time""" + sprite = arcade.TextureAnimationSprite() + anim = arcade.TextureAnimation(keyframes=keyframes) + sprite.animation = anim + assert sprite.time == 0.0 + + steps = 16 + delta_time = 0 + for i in range(steps): + index = i // 2 + if i > 0: + delta_time = 0.5 + sprite.update_animation(delta_time) + assert sprite.texture == keyframes[index].texture + + # Looping + sprite.time = 8.0 + sprite.update_animation(0.0) + assert sprite.texture == keyframes[0].texture + + # Not looping + sprite.time = 8.0 + sprite.update_animation(0.0, loop=False) diff --git a/tests/unit/tilemap/test_animation.py b/tests/unit/tilemap/test_animation.py index 528769895..83cd1d76a 100644 --- a/tests/unit/tilemap/test_animation.py +++ b/tests/unit/tilemap/test_animation.py @@ -12,12 +12,12 @@ def test_rotation_mirror(): assert len(wall_list) == 1 sprite = wall_list[0] - assert isinstance(sprite, arcade.AnimatedTimeBasedSprite) - assert len(sprite.frames) == 2 - assert sprite.frames[0].duration == 500 - assert sprite.frames[0].texture.file_path.name == "torch1.png" - assert sprite.frames[1].duration == 500 - assert sprite.frames[1].texture.file_path.name == "torch2.png" + assert isinstance(sprite, arcade.TextureAnimationSprite) + assert len(sprite.animation) == 2 + assert sprite.animation.keyframes[0].duration == 500 + assert sprite.animation.keyframes[0].texture.file_path.name == "torch1.png" + assert sprite.animation.keyframes[1].duration == 500 + assert sprite.animation.keyframes[1].texture.file_path.name == "torch2.png" sprite.update_animation(0.501) assert sprite.texture.file_path.name == "torch2.png"