From f2d525a05c88cda393da8083ea9117e3e475ae2a Mon Sep 17 00:00:00 2001 From: Jamie Bliss Date: Fri, 5 Apr 2019 21:15:18 -0400 Subject: [PATCH 01/10] First stab at features --- ppb/features/animation.py | 78 +++++++++++++++++++++++++++++++++++++++ setup.cfg | 1 + 2 files changed, 79 insertions(+) create mode 100644 ppb/features/animation.py diff --git a/ppb/features/animation.py b/ppb/features/animation.py new file mode 100644 index 00000000..37ca10c8 --- /dev/null +++ b/ppb/features/animation.py @@ -0,0 +1,78 @@ +""" +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. + """ + clock = time.monotonic + + def __init__(self, filename, frames_per_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 _clock(self): + return type(self).clock() + + 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( + self._filename, + '{:0%dd}' % numdigits + ) + self._frames = [ + template.format(n) + for n in range(start, end + 1) + ] + + def pause(self): + if not self._pause_level: + self._paused_time = self._clock() + self._paused_frame = self.current_frame + self._pause_level += 1 + + def unpause(self): + 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): + 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): + return self._frames[self.current_frame] diff --git a/setup.cfg b/setup.cfg index 8c56440c..e0e90bdc 100644 --- a/setup.cfg +++ b/setup.cfg @@ -28,6 +28,7 @@ classifiers = packages = ppb ppb.systems + ppb.features setup_requires = pytest-runner From 81035d1ffa0604fca981c3068bc1887962e6949f Mon Sep 17 00:00:00 2001 From: Jamie Bliss Date: Fri, 5 Apr 2019 21:35:26 -0400 Subject: [PATCH 02/10] systems.py: Cast __image__() to str --- ppb/systems/__init__.py | 5 +++-- 1 file changed, 3 insertions(+), 2 deletions(-) diff --git a/ppb/systems/__init__.py b/ppb/systems/__init__.py index d52500ff..cc660a30 100644 --- a/ppb/systems/__init__.py +++ b/ppb/systems/__init__.py @@ -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 @@ -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) From f389288bf6f9c5375f6be713238e0b15e8281cd5 Mon Sep 17 00:00:00 2001 From: Jamie Bliss Date: Fri, 5 Apr 2019 21:37:16 -0400 Subject: [PATCH 03/10] animation.py: Fix arguments of .sub() --- ppb/features/animation.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/ppb/features/animation.py b/ppb/features/animation.py index 37ca10c8..918ca5e6 100644 --- a/ppb/features/animation.py +++ b/ppb/features/animation.py @@ -36,8 +36,8 @@ def _compile_filename(self): start = int(start) end = int(end) template = FILE_PATTERN.sub( + '{:0%dd}' % numdigits, self._filename, - '{:0%dd}' % numdigits ) self._frames = [ template.format(n) From 34b0ad8aeccb6aeafb882e50436b8808a30b23d9 Mon Sep 17 00:00:00 2001 From: Jamie Bliss Date: Fri, 5 Apr 2019 22:00:13 -0400 Subject: [PATCH 04/10] Automatically copy if used at the class level --- ppb/features/animation.py | 19 +++++++++++++++++++ 1 file changed, 19 insertions(+) diff --git a/ppb/features/animation.py b/ppb/features/animation.py index 918ca5e6..6e7c4f71 100644 --- a/ppb/features/animation.py +++ b/ppb/features/animation.py @@ -26,6 +26,12 @@ def __init__(self, filename, frames_per_second): self._offset = -self._clock() self._compile_filename() + def __repr__(self): + return f"{type(self).__name__}({self._filename!r}, {self.frames_per_second!r})" + + def copy(self): + return type(self)(self._filename, self.frames_per_second) + def _clock(self): return type(self).clock() @@ -76,3 +82,16 @@ def current_frame(self): def __str__(self): 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): + v = vars(obj) + if self._prop_name not in v: + v[self._prop_name] = self.copy() + return v[self._prop_name] + + def __set_name__(self, owner, name): + self._prop_name = name From 45e3b4a1796c36f44345454c87b482d5ddae4aca Mon Sep 17 00:00:00 2001 From: Jamie Bliss Date: Sat, 6 Apr 2019 16:25:24 -0400 Subject: [PATCH 05/10] Fix (un)pause offset calculation. --- ppb/features/animation.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/ppb/features/animation.py b/ppb/features/animation.py index 6e7c4f71..f0eb4128 100644 --- a/ppb/features/animation.py +++ b/ppb/features/animation.py @@ -52,7 +52,7 @@ def _compile_filename(self): def pause(self): if not self._pause_level: - self._paused_time = self._clock() + self._paused_time = self._clock() + self._offset self._paused_frame = self.current_frame self._pause_level += 1 From 4635b6f92a591699d8f02aa9f101f903283ba588 Mon Sep 17 00:00:00 2001 From: Jamie Bliss Date: Thu, 18 Apr 2019 20:46:06 -0400 Subject: [PATCH 06/10] Fix a bug, document a bunch of stuff. --- ppb/features/animation.py | 13 +++++++++++++ 1 file changed, 13 insertions(+) diff --git a/ppb/features/animation.py b/ppb/features/animation.py index f0eb4128..42d6aeba 100644 --- a/ppb/features/animation.py +++ b/ppb/features/animation.py @@ -16,6 +16,10 @@ class Animation: clock = time.monotonic def __init__(self, filename, frames_per_second): + """ + * filename: A path containing a {2..4} indicator the frame number + * frames_per_second: The number of frames to show each second + """ self._filename = filename self.frames_per_second = frames_per_second @@ -29,6 +33,7 @@ def __init__(self, filename, frames_per_second): 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): return type(self)(self._filename, self.frames_per_second) @@ -72,6 +77,9 @@ def _current_frame(self, time): @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) @@ -88,10 +96,15 @@ def __str__(self): _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 From 6ebc639e4691ccc76407291be926c9aa6701f431 Mon Sep 17 00:00:00 2001 From: Jamie Bliss Date: Thu, 18 Apr 2019 21:30:40 -0400 Subject: [PATCH 07/10] Write a pile of docs --- docs/features/animation.rst | 51 +++++++++++++++++++++++++++++++++++++ docs/features/index.rst | 12 +++++++++ docs/index.rst | 1 + ppb/features/animation.py | 18 +++++++++++-- 4 files changed, 80 insertions(+), 2 deletions(-) create mode 100644 docs/features/animation.rst create mode 100644 docs/features/index.rst diff --git a/docs/features/animation.rst b/docs/features/animation.rst new file mode 100644 index 00000000..8f411698 --- /dev/null +++ b/docs/features/animation.rst @@ -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__ + diff --git a/docs/features/index.rst b/docs/features/index.rst new file mode 100644 index 00000000..595c9d53 --- /dev/null +++ b/docs/features/index.rst @@ -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 \ No newline at end of file diff --git a/docs/index.rst b/docs/index.rst index 112811cb..b6ac602d 100644 --- a/docs/index.rst +++ b/docs/index.rst @@ -82,3 +82,4 @@ first game in a few hours, and continue exploring beyond that. events scenes sprites + features/index diff --git a/ppb/features/animation.py b/ppb/features/animation.py index 42d6aeba..30d2b30a 100644 --- a/ppb/features/animation.py +++ b/ppb/features/animation.py @@ -13,12 +13,13 @@ 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): """ - * filename: A path containing a {2..4} indicator the frame number - * frames_per_second: The number of frames to show each 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 @@ -35,6 +36,10 @@ def __repr__(self): # 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): @@ -56,12 +61,18 @@ def _compile_filename(self): ] 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() @@ -89,6 +100,9 @@ def current_frame(self): 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 From c55e24ba86225d77a2438a96029a31db5775ef66 Mon Sep 17 00:00:00 2001 From: Jamie Bliss Date: Thu, 18 Apr 2019 21:51:27 -0400 Subject: [PATCH 08/10] Add some animation tests --- tests/test_animation.py | 60 +++++++++++++++++++++++++++++++++++++++++ 1 file changed, 60 insertions(+) create mode 100644 tests/test_animation.py diff --git a/tests/test_animation.py b/tests/test_animation.py new file mode 100644 index 00000000..cf2278d6 --- /dev/null +++ b/tests/test_animation.py @@ -0,0 +1,60 @@ +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 str(anim) == '2' + + time = 1 + assert str(anim) == '3' + + time = 3 + assert str(anim) == '5' + + time = 4 + 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' From bd30993f31ab0e903cf2394b95424afc51b1719c Mon Sep 17 00:00:00 2001 From: Jamie Bliss Date: Thu, 18 Apr 2019 21:56:35 -0400 Subject: [PATCH 09/10] Animation: add .filename --- ppb/features/animation.py | 9 +++++++++ tests/test_animation.py | 25 +++++++++++++++++++++++++ 2 files changed, 34 insertions(+) diff --git a/ppb/features/animation.py b/ppb/features/animation.py index 30d2b30a..f4b448ef 100644 --- a/ppb/features/animation.py +++ b/ppb/features/animation.py @@ -45,6 +45,15 @@ def copy(self): 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() diff --git a/tests/test_animation.py b/tests/test_animation.py index cf2278d6..5a70a6d5 100644 --- a/tests/test_animation.py +++ b/tests/test_animation.py @@ -58,3 +58,28 @@ class FakeAnimation(Animation): 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' From 0764bd5ec81b0aabc5dfd2e7fe48fefb40acfaa7 Mon Sep 17 00:00:00 2001 From: Jamie Bliss Date: Thu, 18 Apr 2019 21:56:50 -0400 Subject: [PATCH 10/10] Animation: test .current_frame --- tests/test_animation.py | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/tests/test_animation.py b/tests/test_animation.py index 5a70a6d5..8ae65ad4 100644 --- a/tests/test_animation.py +++ b/tests/test_animation.py @@ -14,15 +14,19 @@ class FakeAnimation(Animation): 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'