From e2c8bb4328f37977caa8c32f0a43e795c5e3530e Mon Sep 17 00:00:00 2001 From: JaskRendix Date: Fri, 25 Jul 2025 12:37:12 +0200 Subject: [PATCH] animation --- apps/demo/tile_animation_group.py | 84 ++++++++++++++++++++++ apps/demo/tile_animation_simple.py | 65 +++++++++++++++++ pyscroll/animation.py | 108 ++++++++++++++++++++--------- tests/pyscroll/test_animation.py | 68 ++++++++++++++++++ 4 files changed, 291 insertions(+), 34 deletions(-) create mode 100644 apps/demo/tile_animation_group.py create mode 100644 apps/demo/tile_animation_simple.py create mode 100644 tests/pyscroll/test_animation.py diff --git a/apps/demo/tile_animation_group.py b/apps/demo/tile_animation_group.py new file mode 100644 index 0000000..9f1b824 --- /dev/null +++ b/apps/demo/tile_animation_group.py @@ -0,0 +1,84 @@ +""" +Animated Tile Group Demo using Pygame and Pyscroll + +This script displays a set of animated tokens on a Pygame window. Each token cycles +between colored frames to demonstrate tile-based animation using Pyscroll's animation +system. + +Core features: +- Creates randomized token positions within the screen bounds +- Assigns each token a color-based animation sequence +- Continuously updates and renders tokens each frame +""" + +import random + +import pygame +from pygame.locals import QUIT + +from pyscroll.animation import AnimationFrame, AnimationToken + + +def make_color_frames(colors, size=(32, 32), duration=0.4) -> list[AnimationFrame]: + return [ + AnimationFrame(image=pygame.Surface(size).convert(), duration=duration) + for color in colors + ] + + +def create_tokens(count, tile_size, screen_size) -> list[AnimationToken]: + tokens = [] + for _ in range(count): + x = random.randint(0, screen_size[0] - tile_size) + y = random.randint(0, screen_size[1] - tile_size) + position = {(x, y, 0)} + + # Alternate colors for demonstration + colors = [random.choice([(255, 0, 0), (0, 255, 0), (0, 0, 255)]), (0, 0, 0)] + frames = [ + AnimationFrame(image=pygame.Surface((tile_size, tile_size)), duration=0.4) + for color in colors + ] + for frame, color in zip(frames, colors): + frame.image.fill(color) + + token = AnimationToken(position, frames, loop=True) + tokens.append(token) + return tokens + + +def main(): + pygame.init() + screen_size = (640, 480) + screen = pygame.display.set_mode(screen_size) + pygame.display.set_caption("Animated Tile Group Demo") + + clock = pygame.time.Clock() + tile_size = 32 + tokens = create_tokens(12, tile_size, screen_size) + + current_time = 0.0 + + running = True + while running: + elapsed = clock.tick(60) / 1000.0 + current_time += elapsed + + for event in pygame.event.get(): + if event.type == QUIT: + running = False + + screen.fill((30, 30, 30)) + + for token in tokens: + frame = token.update(current_time, elapsed) + for pos in token.positions: + screen.blit(frame.image, (pos[0], pos[1])) + + pygame.display.flip() + + pygame.quit() + + +if __name__ == "__main__": + main() diff --git a/apps/demo/tile_animation_simple.py b/apps/demo/tile_animation_simple.py new file mode 100644 index 0000000..c80e544 --- /dev/null +++ b/apps/demo/tile_animation_simple.py @@ -0,0 +1,65 @@ +""" +Simple Pygame animation demo using Pyscroll's AnimationToken + +This script demonstrates how to animate a single tile on screen using +Pyscroll's AnimationFrame and AnimationToken classes. The tile alternates +between two colors in a loop and updates each frame at a fixed interval. + +Purpose: +- Shows how to manually create animation frames using colored surfaces +- Illustrates how AnimationToken manages time-based frame transitions +""" + +import pygame +from pygame.locals import QUIT + +from pyscroll.animation import AnimationFrame, AnimationToken + + +def make_frames(color1, color2, size=(32, 32)) -> list[AnimationFrame]: + surface1 = pygame.Surface(size) + surface1.fill(color1) + surface2 = pygame.Surface(size) + surface2.fill(color2) + return [ + AnimationFrame(image=surface1, duration=0.5), + AnimationFrame(image=surface2, duration=0.5), + ] + + +def main() -> None: + pygame.init() + screen = pygame.display.set_mode((640, 480)) + pygame.display.set_caption("AnimationToken Demo") + + clock = pygame.time.Clock() + + # Create animation + frames = make_frames((255, 0, 0), (0, 255, 0)) + positions = {(100, 100, 0)} + anim = AnimationToken(positions, frames, loop=True) + + current_time = 0.0 # simulated time + + running = True + while running: + elapsed = clock.tick(60) / 1000.0 # Seconds per frame + current_time += elapsed + + for event in pygame.event.get(): + if event.type == QUIT: + running = False + + screen.fill((30, 30, 30)) + + # Update and draw current animation frame + frame = anim.update(current_time, elapsed) + screen.blit(frame.image, (100, 100)) + + pygame.display.flip() + + pygame.quit() + + +if __name__ == "__main__": + main() diff --git a/pyscroll/animation.py b/pyscroll/animation.py index 5ca1bae..4188077 100644 --- a/pyscroll/animation.py +++ b/pyscroll/animation.py @@ -1,87 +1,127 @@ from __future__ import annotations from collections.abc import Sequence -from typing import NamedTuple, Union +from dataclasses import dataclass +from typing import Set, Tuple, Union from pygame import Surface +TimeLike = Union[float, int] -class AnimationFrame(NamedTuple): - image: Surface - duration: float +__all__ = ("AnimationFrame", "AnimationToken") -TimeLike = Union[float, int] +@dataclass(frozen=True) +class AnimationFrame: + """ + Represents a single frame in an animation. -__all__ = ("AnimationFrame", "AnimationToken") + Attributes: + image: The image surface to display. + duration: Duration this frame should be shown, in seconds. + """ + + image: Surface + duration: float class AnimationToken: - __slots__ = ["next", "positions", "frames", "index"] + """ + Manages tile-based animation logic including frame timing, looping, and updates. + + Attributes: + positions: Set of (x, y, layer) map coordinates where this animation is active. + frames: Tuple of AnimationFrame instances. + index: Current frame index. + next: Time value when the next frame should appear. + loop: If True, animation loops; if False, plays once. + done: Indicates whether a non-looping animation has completed. + """ + + __slots__ = ("positions", "frames", "next", "index", "loop", "done") def __init__( self, - positions: set[tuple[int, int, int]], + positions: Set[Tuple[int, int, int]], frames: Sequence[AnimationFrame], initial_time: float = 0.0, + loop: bool = True, ) -> None: """ - Constructor + Initializes an AnimationToken instance. Args: - positions: Set of positions where the tile is on the map - frames: Sequence of frames that compromise the animation - initial_time: Used to compensate time between starting and changing animations + positions: Set of map positions for the animation tile. + frames: Sequence of AnimationFrame instances. + initial_time: Optional time offset for smoother transitions. + loop: If False, the animation stops at the last frame. Raises: - ValueError: If the frames sequence is empty + ValueError: If the frames sequence is empty. """ if not frames: - raise ValueError("Frames sequence cannot be empty") + raise ValueError("Frames sequence cannot be empty.") - frames = tuple(AnimationFrame(*frame_data) for frame_data in frames) self.positions = positions - self.frames = frames - self.next = frames[0].duration + initial_time + self.frames = tuple(frames) self.index = 0 + self.next = self.frames[0].duration + initial_time + self.loop = loop + self.done = False def advance(self, last_time: TimeLike) -> AnimationFrame: """ - Advance the frame, and set timer for next frame - - Timer value is calculated by adding last_time and the - duration of the next frame - - The next frame is returned - - * This API may change in the future + Advances to the next frame in the animation sequence. Args: - last_time: Duration of the last frame + last_time: Time since the last frame update. Returns: - AnimationFrame: The next frame in the animation + The next AnimationFrame in the sequence. """ - # advance the animation frame index, looping by default + if self.done: + return self.frames[self.index] + if self.index == len(self.frames) - 1: - self.index = 0 + if self.loop: + self.index = 0 + else: + self.done = True else: self.index += 1 - # set the timer for the next advance next_frame = self.frames[self.index] self.next = next_frame.duration + last_time return next_frame - def __lt__(self, other): + def update(self, current_time: TimeLike, elapsed_time: TimeLike) -> AnimationFrame: + """ + Updates the animation frame based on simulated elapsed time. + + Args: + current_time: The current time used to evaluate frame progression. + elapsed_time: Simulated time passed since last update. + + Returns: + The active AnimationFrame. + """ + if self.done: + return self.frames[self.index] + + while current_time >= self.next: + self.advance(self.next) + current_time -= elapsed_time + return self.frames[self.index] + + def __lt__(self, other: Union[AnimationToken, float, int]) -> bool: """ - Compare the animation token with another object based on the next frame time + Compares this token's next frame time with another. Args: - other: The object to compare with + other: Another AnimationToken or time value. Returns: - bool: True if the next frame time is less than the other object's time + True if this token's next frame time is earlier. """ try: return self.next < other.next diff --git a/tests/pyscroll/test_animation.py b/tests/pyscroll/test_animation.py new file mode 100644 index 0000000..4677251 --- /dev/null +++ b/tests/pyscroll/test_animation.py @@ -0,0 +1,68 @@ +import unittest +from unittest.mock import MagicMock + +from pyscroll.animation import AnimationFrame, AnimationToken + + +class TestAnimationToken(unittest.TestCase): + + def setUp(self): + self.mock_surface1 = MagicMock() + self.mock_surface2 = MagicMock() + self.frame1 = AnimationFrame(image=self.mock_surface1, duration=0.5) + self.frame2 = AnimationFrame(image=self.mock_surface2, duration=1.0) + self.frames = [self.frame1, self.frame2] + self.positions = {(1, 2, 0), (3, 4, 1)} + + def test_initial_state(self): + token = AnimationToken(self.positions, self.frames) + self.assertEqual(token.index, 0) + self.assertEqual(token.next, self.frame1.duration) + + def test_advance_looping(self): + token = AnimationToken(self.positions, self.frames, loop=True) + next_frame = token.advance(0.5) + self.assertEqual(next_frame, self.frame2) + self.assertEqual(token.index, 1) + + next_frame = token.advance(1.0) + self.assertEqual(next_frame, self.frame1) + self.assertEqual(token.index, 0) + + def test_advance_non_looping(self): + token = AnimationToken(self.positions, self.frames, loop=False) + token.advance(0.5) + final_frame = token.advance(1.0) + self.assertEqual(final_frame, self.frame2) + self.assertTrue(token.done) + self.assertEqual(token.index, 1) + repeat_frame = token.advance(1.0) + self.assertEqual(repeat_frame, self.frame2) + + def test_update_with_elapsed_time(self): + token = AnimationToken(self.positions, self.frames, loop=True) + frame = token.update(current_time=0.6, elapsed_time=0.1) + self.assertEqual(frame, self.frame2) + self.assertEqual(token.index, 1) + + def test_update_non_looping_stop(self): + token = AnimationToken(self.positions, self.frames, loop=False) + token.update(current_time=0.6, elapsed_time=0.1) + token.update(current_time=1.6, elapsed_time=0.5) + frame = token.update(current_time=2.1, elapsed_time=0.5) + self.assertEqual(frame, self.frame2) + self.assertTrue(token.done) + + def test_empty_frame_list_raises(self): + with self.assertRaises(ValueError): + AnimationToken(self.positions, [], loop=True) + + def test_lt_comparison_with_number(self): + token = AnimationToken(self.positions, self.frames) + self.assertTrue(token < 10.0) + self.assertFalse(token < 0.1) + + def test_lt_comparison_with_other_token(self): + token1 = AnimationToken(self.positions, self.frames) + token2 = AnimationToken(self.positions, self.frames, initial_time=1.0) + self.assertTrue(token1 < token2)