# -*- coding: utf-8 -*-
# Copyright (c) 2017-2022 Richard Hull and contributors
# See LICENSE.rst for details.
"""
Simplified sprite animation framework.
.. note:: This module is an evolving "work-in-progress" and should be treated
as such until such time as this notice disappears.
"""
from time import sleep
from PIL import Image
from luma.core.util import perf_counter
[docs]
class dict_wrapper(object):
"""
Helper class to turn dictionaries into objects.
"""
def __init__(self, d):
for a, b in d.items():
if isinstance(b, (list, tuple)):
setattr(self, a, [dict_wrapper(x) if isinstance(x, dict) else x for x in b])
else:
setattr(self, a, dict_wrapper(b) if isinstance(b, dict) else b)
[docs]
class spritesheet(object):
"""
A sprite sheet is a series of images (usually animation frames) combined
into a larger image. A dictionary is usually spread into the object
constructor parameters with the following top-level attributes:
:param image: A path to a sprite map image.
:type image: str
:param frames: A dictionary of settings that defines how to extract
individual frames from the supplied image, as follows
- ``width`` & ``height`` are required and specify the dimensions of
the frames
- ``regX`` & ``regY`` indicate the registration point or "origin" of
the frames
- ``count`` allows you to specify the total number of frames in the
spritesheet; if omitted, this will be calculated based on the
dimensions of the source images and the frames. Frames will be
assigned indexes based on their position in the source images
(left to right, top to bottom).
:type frames: dict
:param animations: A dictionary of key/value pairs where the key is the
name of of the animation sequence, and the value are settings that
defines an animation sequence as follows:
- ``frames`` is a list of frame to show in sequence. Usually this
comprises of frame numbers, but can refer to other animation
sequences (which are handled much like a subroutine call).
- ``speed`` determines how quickly the animation frames are cycled
through compared to the how often the animation sequence yields.
- ``next`` is optional, but if supplied, determines what happens when
the animation sequence is exhausted. Typically this can be used to
self-reference, so that it forms an infinite loop, but can hand off
to any other animation sequence.
:type animations: dict
Loosely based on https://www.createjs.com/docs/easeljs/classes/SpriteSheet.html
"""
def __init__(self, image, frames, animations):
with open(image, 'rb') as fp:
self.image = Image.open(fp)
self.image.load()
self.frames = dict_wrapper(frames)
self.animations = dict_wrapper(animations)
# Reframe the sprite map in terms of the registration point (if set)
regX = self.frames.regX if hasattr(self.frames, "regX") else 0
regY = self.frames.regY if hasattr(self.frames, "regY") else 0
self.image = self.image.crop((regX, regY, self.image.width - regX, self.image.height - regY))
self.width, self.height = self.image.size
assert self.width % self.frames.width == 0
assert self.height % self.frames.height == 0
self.frames.size = (self.frames.width, self.frames.height)
if not hasattr(self.frames, 'count'):
self.frames.count = (self.width * self.height) // (self.frames.width * self.frames.height)
self.cache = {}
def __getitem__(self, frame_index):
"""
Returns (and caches) the frame for the given index.
:param frame_index: The index of the frame.
:type frame_index: int
:returns: A Pillow image cropped from the main image corresponding to
the given frame index.
:raises TypeError: if the ``frame_index`` is not numeric
:raises IndexError: if the ``frame_index`` is less than zero or more
than the largest frame.
"""
if not isinstance(frame_index, int):
raise TypeError("frame index must be numeric")
if frame_index < 0 or frame_index > self.frames.count:
raise IndexError("frame index out of range")
cached_frame = self.cache.get(frame_index)
if cached_frame is None:
offset = frame_index * self.frames.width
left = offset % self.width
top = (offset // self.width) * self.frames.height
right = left + self.frames.width
bottom = top + self.frames.height
bounds = [left, top, right, bottom]
cached_frame = self.image.crop(bounds)
self.cache[frame_index] = cached_frame
return cached_frame
def __len__(self):
"""
The number of frames in the sprite sheet
"""
return self.frames.count
[docs]
def animate(self, seq_name):
"""
Returns a generator which "executes" an animation sequence for the given
``seq_name``, inasmuch as the next frame for the given animation is
yielded when requested.
:param seq_name: The name of a previously defined animation sequence.
:type seq_name: str
:returns: A generator that yields all frames from the animation
sequence.
:raises AttributeError: If the ``seq_name`` is unknown.
"""
while True:
index = 0
anim = getattr(self.animations, seq_name)
speed = anim.speed if hasattr(anim, "speed") else 1
num_frames = len(anim.frames)
while index < num_frames:
frame = anim.frames[int(index)]
index += speed
if isinstance(frame, int):
yield self[frame]
else:
for subseq_frame in self.animate(frame):
yield subseq_frame
if not hasattr(anim, "next"):
break
seq_name = anim.next
[docs]
class framerate_regulator(object):
"""
Implements a variable sleep mechanism to give the appearance of a consistent
frame rate. Using a fixed-time sleep will cause animations to be jittery
(looking like they are speeding up or slowing down, depending on what other
work is occurring), whereas this class keeps track of when the last time the
``sleep()`` method was called, and calculates a sleep period to smooth out
the jitter.
:param fps: The desired frame rate, expressed numerically in
frames-per-second. By default, this is set at 16.67, to give a frame
render time of approximately 60ms. This can be overridden as necessary,
and if no FPS limiting is required, the ``fps`` can be set to zero.
:type fps: float
"""
def __init__(self, fps=16.67):
if fps == 0:
fps = -1
self.max_sleep_time = 1.0 / fps
self.total_transit_time = 0
self.called = 0
self.start_time = None
self.last_time = None
def __enter__(self):
self.enter_time = perf_counter()
if not self.start_time:
self.start_time = self.enter_time
self.last_time = self.enter_time
return self
def __exit__(self, *args):
"""
Sleeps for a variable amount of time (dependent on when it was last
called), to give a consistent frame rate. If it cannot meet the desired
frame rate (i.e. too much time has occurred since the last call), then
it simply exits without blocking.
"""
self.called += 1
self.total_transit_time += perf_counter() - self.enter_time
if self.max_sleep_time >= 0:
elapsed = perf_counter() - self.last_time
sleep_for = self.max_sleep_time - elapsed
if sleep_for > 0:
sleep(sleep_for)
self.last_time = perf_counter()
[docs]
def effective_FPS(self):
"""
Calculates the effective frames-per-second - this should largely
correlate to the desired FPS supplied in the constructor, but no
guarantees are given.
:returns: The effective frame rate.
:rtype: float
"""
if self.start_time is None:
self.start_time = 0
elapsed = perf_counter() - self.start_time
return self.called / elapsed
[docs]
def average_transit_time(self):
"""
Calculates the average transit time between the enter and exit methods,
and return the time in milliseconds.
:returns: The average transit in milliseconds.
:rtype: float
"""
return self.total_transit_time * 1000.0 / self.called