Skip to content

Commit e2c8bb4

Browse files
committed
animation
1 parent 31e1c28 commit e2c8bb4

File tree

4 files changed

+291
-34
lines changed

4 files changed

+291
-34
lines changed

apps/demo/tile_animation_group.py

Lines changed: 84 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,84 @@
1+
"""
2+
Animated Tile Group Demo using Pygame and Pyscroll
3+
4+
This script displays a set of animated tokens on a Pygame window. Each token cycles
5+
between colored frames to demonstrate tile-based animation using Pyscroll's animation
6+
system.
7+
8+
Core features:
9+
- Creates randomized token positions within the screen bounds
10+
- Assigns each token a color-based animation sequence
11+
- Continuously updates and renders tokens each frame
12+
"""
13+
14+
import random
15+
16+
import pygame
17+
from pygame.locals import QUIT
18+
19+
from pyscroll.animation import AnimationFrame, AnimationToken
20+
21+
22+
def make_color_frames(colors, size=(32, 32), duration=0.4) -> list[AnimationFrame]:
23+
return [
24+
AnimationFrame(image=pygame.Surface(size).convert(), duration=duration)
25+
for color in colors
26+
]
27+
28+
29+
def create_tokens(count, tile_size, screen_size) -> list[AnimationToken]:
30+
tokens = []
31+
for _ in range(count):
32+
x = random.randint(0, screen_size[0] - tile_size)
33+
y = random.randint(0, screen_size[1] - tile_size)
34+
position = {(x, y, 0)}
35+
36+
# Alternate colors for demonstration
37+
colors = [random.choice([(255, 0, 0), (0, 255, 0), (0, 0, 255)]), (0, 0, 0)]
38+
frames = [
39+
AnimationFrame(image=pygame.Surface((tile_size, tile_size)), duration=0.4)
40+
for color in colors
41+
]
42+
for frame, color in zip(frames, colors):
43+
frame.image.fill(color)
44+
45+
token = AnimationToken(position, frames, loop=True)
46+
tokens.append(token)
47+
return tokens
48+
49+
50+
def main():
51+
pygame.init()
52+
screen_size = (640, 480)
53+
screen = pygame.display.set_mode(screen_size)
54+
pygame.display.set_caption("Animated Tile Group Demo")
55+
56+
clock = pygame.time.Clock()
57+
tile_size = 32
58+
tokens = create_tokens(12, tile_size, screen_size)
59+
60+
current_time = 0.0
61+
62+
running = True
63+
while running:
64+
elapsed = clock.tick(60) / 1000.0
65+
current_time += elapsed
66+
67+
for event in pygame.event.get():
68+
if event.type == QUIT:
69+
running = False
70+
71+
screen.fill((30, 30, 30))
72+
73+
for token in tokens:
74+
frame = token.update(current_time, elapsed)
75+
for pos in token.positions:
76+
screen.blit(frame.image, (pos[0], pos[1]))
77+
78+
pygame.display.flip()
79+
80+
pygame.quit()
81+
82+
83+
if __name__ == "__main__":
84+
main()

apps/demo/tile_animation_simple.py

Lines changed: 65 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,65 @@
1+
"""
2+
Simple Pygame animation demo using Pyscroll's AnimationToken
3+
4+
This script demonstrates how to animate a single tile on screen using
5+
Pyscroll's AnimationFrame and AnimationToken classes. The tile alternates
6+
between two colors in a loop and updates each frame at a fixed interval.
7+
8+
Purpose:
9+
- Shows how to manually create animation frames using colored surfaces
10+
- Illustrates how AnimationToken manages time-based frame transitions
11+
"""
12+
13+
import pygame
14+
from pygame.locals import QUIT
15+
16+
from pyscroll.animation import AnimationFrame, AnimationToken
17+
18+
19+
def make_frames(color1, color2, size=(32, 32)) -> list[AnimationFrame]:
20+
surface1 = pygame.Surface(size)
21+
surface1.fill(color1)
22+
surface2 = pygame.Surface(size)
23+
surface2.fill(color2)
24+
return [
25+
AnimationFrame(image=surface1, duration=0.5),
26+
AnimationFrame(image=surface2, duration=0.5),
27+
]
28+
29+
30+
def main() -> None:
31+
pygame.init()
32+
screen = pygame.display.set_mode((640, 480))
33+
pygame.display.set_caption("AnimationToken Demo")
34+
35+
clock = pygame.time.Clock()
36+
37+
# Create animation
38+
frames = make_frames((255, 0, 0), (0, 255, 0))
39+
positions = {(100, 100, 0)}
40+
anim = AnimationToken(positions, frames, loop=True)
41+
42+
current_time = 0.0 # simulated time
43+
44+
running = True
45+
while running:
46+
elapsed = clock.tick(60) / 1000.0 # Seconds per frame
47+
current_time += elapsed
48+
49+
for event in pygame.event.get():
50+
if event.type == QUIT:
51+
running = False
52+
53+
screen.fill((30, 30, 30))
54+
55+
# Update and draw current animation frame
56+
frame = anim.update(current_time, elapsed)
57+
screen.blit(frame.image, (100, 100))
58+
59+
pygame.display.flip()
60+
61+
pygame.quit()
62+
63+
64+
if __name__ == "__main__":
65+
main()

pyscroll/animation.py

Lines changed: 74 additions & 34 deletions
Original file line numberDiff line numberDiff line change
@@ -1,87 +1,127 @@
11
from __future__ import annotations
22

33
from collections.abc import Sequence
4-
from typing import NamedTuple, Union
4+
from dataclasses import dataclass
5+
from typing import Set, Tuple, Union
56

67
from pygame import Surface
78

9+
TimeLike = Union[float, int]
810

9-
class AnimationFrame(NamedTuple):
10-
image: Surface
11-
duration: float
11+
__all__ = ("AnimationFrame", "AnimationToken")
1212

1313

14-
TimeLike = Union[float, int]
14+
@dataclass(frozen=True)
15+
class AnimationFrame:
16+
"""
17+
Represents a single frame in an animation.
1518
16-
__all__ = ("AnimationFrame", "AnimationToken")
19+
Attributes:
20+
image: The image surface to display.
21+
duration: Duration this frame should be shown, in seconds.
22+
"""
23+
24+
image: Surface
25+
duration: float
1726

1827

1928
class AnimationToken:
20-
__slots__ = ["next", "positions", "frames", "index"]
29+
"""
30+
Manages tile-based animation logic including frame timing, looping, and updates.
31+
32+
Attributes:
33+
positions: Set of (x, y, layer) map coordinates where this animation is active.
34+
frames: Tuple of AnimationFrame instances.
35+
index: Current frame index.
36+
next: Time value when the next frame should appear.
37+
loop: If True, animation loops; if False, plays once.
38+
done: Indicates whether a non-looping animation has completed.
39+
"""
40+
41+
__slots__ = ("positions", "frames", "next", "index", "loop", "done")
2142

2243
def __init__(
2344
self,
24-
positions: set[tuple[int, int, int]],
45+
positions: Set[Tuple[int, int, int]],
2546
frames: Sequence[AnimationFrame],
2647
initial_time: float = 0.0,
48+
loop: bool = True,
2749
) -> None:
2850
"""
29-
Constructor
51+
Initializes an AnimationToken instance.
3052
3153
Args:
32-
positions: Set of positions where the tile is on the map
33-
frames: Sequence of frames that compromise the animation
34-
initial_time: Used to compensate time between starting and changing animations
54+
positions: Set of map positions for the animation tile.
55+
frames: Sequence of AnimationFrame instances.
56+
initial_time: Optional time offset for smoother transitions.
57+
loop: If False, the animation stops at the last frame.
3558
3659
Raises:
37-
ValueError: If the frames sequence is empty
60+
ValueError: If the frames sequence is empty.
3861
"""
3962
if not frames:
40-
raise ValueError("Frames sequence cannot be empty")
63+
raise ValueError("Frames sequence cannot be empty.")
4164

42-
frames = tuple(AnimationFrame(*frame_data) for frame_data in frames)
4365
self.positions = positions
44-
self.frames = frames
45-
self.next = frames[0].duration + initial_time
66+
self.frames = tuple(frames)
4667
self.index = 0
68+
self.next = self.frames[0].duration + initial_time
69+
self.loop = loop
70+
self.done = False
4771

4872
def advance(self, last_time: TimeLike) -> AnimationFrame:
4973
"""
50-
Advance the frame, and set timer for next frame
51-
52-
Timer value is calculated by adding last_time and the
53-
duration of the next frame
54-
55-
The next frame is returned
56-
57-
* This API may change in the future
74+
Advances to the next frame in the animation sequence.
5875
5976
Args:
60-
last_time: Duration of the last frame
77+
last_time: Time since the last frame update.
6178
6279
Returns:
63-
AnimationFrame: The next frame in the animation
80+
The next AnimationFrame in the sequence.
6481
"""
65-
# advance the animation frame index, looping by default
82+
if self.done:
83+
return self.frames[self.index]
84+
6685
if self.index == len(self.frames) - 1:
67-
self.index = 0
86+
if self.loop:
87+
self.index = 0
88+
else:
89+
self.done = True
6890
else:
6991
self.index += 1
7092

71-
# set the timer for the next advance
7293
next_frame = self.frames[self.index]
7394
self.next = next_frame.duration + last_time
7495
return next_frame
7596

76-
def __lt__(self, other):
97+
def update(self, current_time: TimeLike, elapsed_time: TimeLike) -> AnimationFrame:
98+
"""
99+
Updates the animation frame based on simulated elapsed time.
100+
101+
Args:
102+
current_time: The current time used to evaluate frame progression.
103+
elapsed_time: Simulated time passed since last update.
104+
105+
Returns:
106+
The active AnimationFrame.
107+
"""
108+
if self.done:
109+
return self.frames[self.index]
110+
111+
while current_time >= self.next:
112+
self.advance(self.next)
113+
current_time -= elapsed_time
114+
return self.frames[self.index]
115+
116+
def __lt__(self, other: Union[AnimationToken, float, int]) -> bool:
77117
"""
78-
Compare the animation token with another object based on the next frame time
118+
Compares this token's next frame time with another.
79119
80120
Args:
81-
other: The object to compare with
121+
other: Another AnimationToken or time value.
82122
83123
Returns:
84-
bool: True if the next frame time is less than the other object's time
124+
True if this token's next frame time is earlier.
85125
"""
86126
try:
87127
return self.next < other.next

tests/pyscroll/test_animation.py

Lines changed: 68 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,68 @@
1+
import unittest
2+
from unittest.mock import MagicMock
3+
4+
from pyscroll.animation import AnimationFrame, AnimationToken
5+
6+
7+
class TestAnimationToken(unittest.TestCase):
8+
9+
def setUp(self):
10+
self.mock_surface1 = MagicMock()
11+
self.mock_surface2 = MagicMock()
12+
self.frame1 = AnimationFrame(image=self.mock_surface1, duration=0.5)
13+
self.frame2 = AnimationFrame(image=self.mock_surface2, duration=1.0)
14+
self.frames = [self.frame1, self.frame2]
15+
self.positions = {(1, 2, 0), (3, 4, 1)}
16+
17+
def test_initial_state(self):
18+
token = AnimationToken(self.positions, self.frames)
19+
self.assertEqual(token.index, 0)
20+
self.assertEqual(token.next, self.frame1.duration)
21+
22+
def test_advance_looping(self):
23+
token = AnimationToken(self.positions, self.frames, loop=True)
24+
next_frame = token.advance(0.5)
25+
self.assertEqual(next_frame, self.frame2)
26+
self.assertEqual(token.index, 1)
27+
28+
next_frame = token.advance(1.0)
29+
self.assertEqual(next_frame, self.frame1)
30+
self.assertEqual(token.index, 0)
31+
32+
def test_advance_non_looping(self):
33+
token = AnimationToken(self.positions, self.frames, loop=False)
34+
token.advance(0.5)
35+
final_frame = token.advance(1.0)
36+
self.assertEqual(final_frame, self.frame2)
37+
self.assertTrue(token.done)
38+
self.assertEqual(token.index, 1)
39+
repeat_frame = token.advance(1.0)
40+
self.assertEqual(repeat_frame, self.frame2)
41+
42+
def test_update_with_elapsed_time(self):
43+
token = AnimationToken(self.positions, self.frames, loop=True)
44+
frame = token.update(current_time=0.6, elapsed_time=0.1)
45+
self.assertEqual(frame, self.frame2)
46+
self.assertEqual(token.index, 1)
47+
48+
def test_update_non_looping_stop(self):
49+
token = AnimationToken(self.positions, self.frames, loop=False)
50+
token.update(current_time=0.6, elapsed_time=0.1)
51+
token.update(current_time=1.6, elapsed_time=0.5)
52+
frame = token.update(current_time=2.1, elapsed_time=0.5)
53+
self.assertEqual(frame, self.frame2)
54+
self.assertTrue(token.done)
55+
56+
def test_empty_frame_list_raises(self):
57+
with self.assertRaises(ValueError):
58+
AnimationToken(self.positions, [], loop=True)
59+
60+
def test_lt_comparison_with_number(self):
61+
token = AnimationToken(self.positions, self.frames)
62+
self.assertTrue(token < 10.0)
63+
self.assertFalse(token < 0.1)
64+
65+
def test_lt_comparison_with_other_token(self):
66+
token1 = AnimationToken(self.positions, self.frames)
67+
token2 = AnimationToken(self.positions, self.frames, initial_time=1.0)
68+
self.assertTrue(token1 < token2)

0 commit comments

Comments
 (0)