Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Animation framework #230

Merged
merged 10 commits into from
Apr 21, 2019
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
51 changes: 51 additions & 0 deletions docs/features/animation.rst
Original file line number Diff line number Diff line change
@@ -0,0 +1,51 @@
Animation
=========

This is a simple animation tool, allowing individual frame files to be composed
into a sprite animation, like so:

.. code-block:: python

import ppb
from ppb.features.animation import Animation

class MySprite(ppb.BaseSprite):
image = Animation("sprite_{1..10}.png", 4)


Multi-frame files, like GIF or APNG, are not supported.

Pausing
~~~~~~~
Animations support being paused and unpaused. In addition, there is a "pause
level", where multiple calls to :py:meth:`pause` cause the animation to become
"more paused". This is useful for eg, pausing on both scene pause and effect.

.. code-block:: python

import ppb
from ppb.features.animation import Animation

class MySprite(ppb.BaseSprite):
image = Animation("sprite_{1..10}.png", 4)

def on_scene_paused(self, event, signal):
self.image.pause()

def on_scene_continued(self, event, signal):
self.image.unpause()

def set_status(self, frozen):
if frozen:
self.image.pause()
else:
self.image.unpause()


Reference
~~~~~~~~~
.. autoclass:: ppb.features.animation.Animation
:members:
:special-members:
:exclude-members: clock, __weakref__, __repr__

12 changes: 12 additions & 0 deletions docs/features/index.rst
Original file line number Diff line number Diff line change
@@ -0,0 +1,12 @@
Features
========

Features are additional libraries included with PursuedPyBear. They are not
"core" in the sense that you can not write them youself, but they are useful
tools to have when making games.

.. toctree::
:maxdepth: 2
:caption: Included Features

animation
1 change: 1 addition & 0 deletions docs/index.rst
Original file line number Diff line number Diff line change
Expand Up @@ -82,3 +82,4 @@ first game in a few hours, and continue exploring beyond that.
events
scenes
sprites
features/index
133 changes: 133 additions & 0 deletions ppb/features/animation.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,133 @@
"""
A system for producing animated sprites.

Only supports frame-by-frame, not gif, apng, or full motion video.
"""
import time
import re

FILE_PATTERN = re.compile(r'\{(\d+)\.\.(\d+)\}')


class Animation:
"""
An "image" that actually rotates through numbered files at the specified rate.
"""
# Override this to change the clock used for frames.
clock = time.monotonic

def __init__(self, filename, frames_per_second):
"""
:param str filename: A path containing a ``{2..4}`` indicating the frame number
:param number frames_per_second: The number of frames to show each second
"""
self._filename = filename
self.frames_per_second = frames_per_second

self._paused_frame = None
self._pause_level = 0
self._frames = []

self._offset = -self._clock()
self._compile_filename()

def __repr__(self):
return f"{type(self).__name__}({self._filename!r}, {self.frames_per_second!r})"

# Do we need pickle/copy dunders?
def copy(self):
"""
Create a new Animation with the same filename and framerate. Pause
status and starting time are reset.
"""
return type(self)(self._filename, self.frames_per_second)

def _clock(self):
return type(self).clock()

@property
def filename(self):
return self._filename

@filename.setter
def filename(self, value):
self._filename = value
self._compile_filename()

def _compile_filename(self):
match = FILE_PATTERN.search(self._filename)
start, end = match.groups()
numdigits = min(len(start), len(end))
start = int(start)
end = int(end)
template = FILE_PATTERN.sub(
'{:0%dd}' % numdigits,
self._filename,
)
self._frames = [
template.format(n)
for n in range(start, end + 1)
]

def pause(self):
"""
Pause the animation.
"""
if not self._pause_level:
self._paused_time = self._clock() + self._offset
self._paused_frame = self.current_frame
self._pause_level += 1

def unpause(self):
"""
Unpause the animation.
"""
self._pause_level -= 1
if not self._pause_level:
self._offset = self._paused_time - self._clock()

def _current_frame(self, time):
if not self._pause_level:
return (
int((time + self._offset) * self.frames_per_second)
% len(self._frames)
)
else:
return self._paused_frame

@property
def current_frame(self):
"""
Compute the number of the current frame (0-indexed)
"""
if not self._pause_level:
return (
int((self._clock() + self._offset) * self.frames_per_second)
% len(self._frames)
)
else:
return self._paused_frame

def __str__(self):
"""
Get the current frame path.
"""
return self._frames[self.current_frame]

# This is so that if you assign an Animation to a class, instances will get
# their own copy, so their animations run independently.
_prop_name = None

def __get__(self, obj, type=None):
if obj is None:
return self
v = vars(obj)
if self._prop_name not in v:
v[self._prop_name] = self.copy()
return v[self._prop_name]

# Don't need __set__() or __delete__(), additional accesses will be via
# __dict__ directly.

def __set_name__(self, owner, name):
self._prop_name = name
5 changes: 3 additions & 2 deletions ppb/systems/__init__.py
Original file line number Diff line number Diff line change
Expand Up @@ -74,10 +74,11 @@ def prepare_resource(self, game_object):
image_name = game_object.__image__()
if image_name is flags.DoNotRender:
return None
image_name = str(image_name)
if image_name not in self.resources:
self.register_renderable(game_object)

source_image = self.resources[game_object.image]
source_image = self.resources[image_name]
resized_image = self.resize_image(source_image, game_object.size)
rotated_image = self.rotate_image(resized_image, game_object.rotation)
return rotated_image
Expand All @@ -103,7 +104,7 @@ def register(self, resource_path, name=None):
self.resources[name] = resource

def register_renderable(self, renderable):
image_name = renderable.__image__()
image_name = str(renderable.__image__())
source_path = renderable.__resource_path__()
self.register(source_path / image_name, image_name)

Expand Down
1 change: 1 addition & 0 deletions setup.cfg
Original file line number Diff line number Diff line change
Expand Up @@ -28,6 +28,7 @@ classifiers =
packages =
ppb
ppb.systems
ppb.features

setup_requires =
pytest-runner
Expand Down
89 changes: 89 additions & 0 deletions tests/test_animation.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,89 @@
from ppb.features.animation import Animation


def test_frames():
time = 0

def mockclock():
nonlocal time
return time

class FakeAnimation(Animation):
clock = mockclock

anim = FakeAnimation("{2..5}", 1)

time = 0
assert anim.current_frame == 0
assert str(anim) == '2'

time = 1
assert anim.current_frame == 1
assert str(anim) == '3'

time = 3
assert anim.current_frame == 3
assert str(anim) == '5'

time = 4
assert anim.current_frame == 0
assert str(anim) == '2'


def test_pause():
time = 0

def mockclock():
nonlocal time
return time

class FakeAnimation(Animation):
clock = mockclock

anim = FakeAnimation("{0..9}", 1)

time = 0
assert str(anim) == '0'

time = 5
assert str(anim) == '5'

anim.pause()
assert str(anim) == '5'

time = 12
assert str(anim) == '5'

anim.unpause()
assert str(anim) == '5'

time = 16
assert str(anim) == '9'

time = 18
assert str(anim) == '1'


def test_filename():
time = 0

def mockclock():
nonlocal time
return time

class FakeAnimation(Animation):
clock = mockclock

anim = FakeAnimation("spam{0..9}", 1)

time = 0
assert str(anim) == 'spam0'

time = 5
assert str(anim) == 'spam5'

anim.filename = 'eggs{0..4}'
assert str(anim) == 'eggs0'

time = 7
assert str(anim) == 'eggs2'