Skip to content
Closed
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
84 changes: 84 additions & 0 deletions apps/demo/tile_animation_group.py
Original file line number Diff line number Diff line change
@@ -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()
65 changes: 65 additions & 0 deletions apps/demo/tile_animation_simple.py
Original file line number Diff line number Diff line change
@@ -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()
108 changes: 74 additions & 34 deletions pyscroll/animation.py
Original file line number Diff line number Diff line change
@@ -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
Expand Down
68 changes: 68 additions & 0 deletions tests/pyscroll/test_animation.py
Original file line number Diff line number Diff line change
@@ -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)