Source code for build.lib.spritesheetlib.spritesheetlib

# -*- coding: utf-8 -*-
#
# New BSD license
#
# Copyright (c) DR0ID
# This file is part of spritesheetlib
# All rights reserved.
#
# Redistribution and use in source and binary forms, with or without
# modification, are permitted provided that the following conditions are met:
# * Redistributions of source code must retain the above copyright
#   notice, this list of conditions and the following disclaimer.
# * Redistributions in binary form must reproduce the above copyright
#   notice, this list of conditions and the following disclaimer in the
#   documentation and/or other materials provided with the distribution.
# * Neither the name of the <organization> nor the
#   names of its contributors may be used to endorse or promote products
#   derived from this software without specific prior written permission.
#
# THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS IS" AND
# ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE IMPLIED
# WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE ARE
# DISCLAIMED. IN NO EVENT SHALL DR0ID BE LIABLE FOR ANY
# DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR CONSEQUENTIAL DAMAGES
# (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR SERVICES;
# LOSS OF USE, DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER CAUSED AND
# ON ANY THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, OR TORT
# (INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE OF THIS
# SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE.

"""
The spritesheet lib is a class to manipulate the spritesheet definitions. It can validate,
create, transform (spacing and margin) and save spritesheet definitions. It also can generate a pygame surface with
the outline of the sprites drawn on it (as template to draw sprites).

The spritesheet definition format is a json structure that allows to define the outline of a sprite as a polygon.


Versioning scheme based on: http://en.wikipedia.org/wiki/Versioning#Designating_development_stage

::

      +-- api change, probably incompatible with older versions
      |     +-- enhancements but no api change
      |     |
    major.minor[.build[.revision]]
                   |        |
                   |        +-|* x for x bugfixes
                   |
                   +-|* 0 for alpha (status)
                     |* 1 for beta (status)
                     |* 2 for release candidate
                     |* 3 for (public) release

.. versionchanged:: 0.0.0.0
    initial version


"""
import logging
import os
import json
import sys
import itertools

__version__ = '1.0.3.0'

# for easy comparison as in sys.version_info but digits only
__version_info__ = tuple([int(d) for d in __version__.split('.')])

__author__ = "DR0ID"
__email__ = "dr0iddr0id {at} gmail [dot] com"
__copyright__ = "DR0ID @ 2014"
__credits__ = ["DR0ID", "Gummbum"]  # list of contributors
__maintainer__ = "DR0ID"
__license__ = "New BSD license"

# list of public visible parts of this module
__all__ = ['AABB',
           'DummyLogger',
           'FileInfo',
           'FileMode',
           'get_area_of_polygon',
           'get_needed_digits',
           'get_orientation',
           'get_signed_area_of_polygon',
           'move_points',
           'PolygonOrientation',
           'sign',
           'Sprite',
           'SpriteListException',
           'SpritesheetLib10',
           'SpritesList',
           '__version_info__',
           '__version__',
           ]

__python_version_required__ = (2, 7)
if sys.version_info < __python_version_required__:  # pragma: no cover
    raise Exception("At least python '{0}' is required, found {1}.".format(__python_version_required__, sys.version_info))


# logging string format support extension
# :TODO: move to an extension module?
try:
    # noinspection PyStatementEffect
    str
    _unicode = True
except NameError:
    _unicode = False

_get_message = logging.LogRecord.getMessage


def _get_format_message(self):
    try:
        return self.getMessage(self)
    except TypeError:
        if not _unicode:  # if no unicode support...
            msg = str(self.msg)
        else:
            msg = self.msg
            if not isinstance(msg, str):
                try:
                    msg = str(self.msg)
                except UnicodeError:
                    msg = self.msg  # Defer encoding till later
        if self.args:
            args = self.args
            msg = msg.format(*args)
        return msg


logging.LogRecord.getMessage = _get_format_message


# noinspection PyMethodMayBeStatic
[docs]class DummyLogger(object): """ A dummy logger implementation. The methods are no-operations. Could be used as a basis to implement an interface for another logging framework. """
[docs] def debug(self, msg, *args, **kwargs): """ The debug message to log. :param msg: The message to log. :param args: Arguments if msg is a format string. :param kwargs: Keyword arguments. """ pass # pragma: no cover
[docs] def info(self, msg, *args, **kwargs): """ The info message to log. :param msg: The message to log. :param args: Arguments if msg is a format string. :param kwargs: Keyword arguments. """ pass # pragma: no cover
[docs] def error(self, msg, *args, **kwargs): """ The error message to log. :param msg: The message to log. :param args: Arguments if msg is a format string. :param kwargs: Keyword arguments. """ pass # pragma: no cover
[docs] def warn(self, msg, *args, **kwargs): """ The warn message to log. :param msg: The message to log. :param args: Arguments if msg is a format string. :param kwargs: Keyword arguments. """ pass # pragma: no cover
[docs]class SpritesheetLib10(object): """ The spritesheet lib. For loading, saving, creating and manipulating a sprite definition v1.0. sprite definition file format (xml):: <version value="1.0"/> <spritesheet filename="out.png.sdef" [width="" height=""] backgroundcolor="rrggbbaa"> <sprite name="" anchor="" points="" > [<Transformations> <alpha value=""/> </Transformations>] </sprite> </spritesheet> # minimal (json):: { "version": "1.0", "filename": "out.png.sdef", "sprites": [ { "name": "000", "points": [(x1,y1), ], }, { "label": "" "action": "", "direction": "-1", "gid": "", # unique number within sprite sheet "row": "-1", "col":"-1", "points": [(x1,y1), ], } ], } # full (json with <optional parts>):: { "version": "1.0", "filename": "out.png.sdef", "<backgroundcolor": [r, g, b, a],<255, 0, 255, 255>> "sprites": [ { "gid": "000", <"anchor": (x,y),<default: top left of AABB, otherwise relative coordinates to topleft of AABB>> "points": [(x1,y1), ], <"transformations": [ {"type": "alpha", "value": 200}, {"type": "translation", "x": 200, "y": 200, "z": 200}, {"type": "rotation", "angle": 200, "axis-x": 0.0, "axis-y": 0.0, "axis-z": 1.0}, {"type": "scale", "x": 1.0, "y": 1.0, "z": 1.0}, {"type": "flip", "x": true, "y": false}, {"type": "shear", "?": 1.0}, {"type": "matrix", "?": 1.0}, ]<None>> <"properties": {'key': 'value', ...}> }, { "gid": "", "anchor": (x,y), "points": [(x1,y1), ], "transformations": [ {"name":alpha, "value":200}, ] <"properties": {'column': 2, 'row': 3, 'key': 'value', ...}> } ] } :param logger: The logger instance to use. """
[docs] class ELEMENTS(object): """ The elements of the version 1 of the sprite definition. """ BACKGROUND = "backgroundcolor" FILENAME = "filename" VERSION = "version" ANCHOR = "anchor" GID = "gid" SPRITES = "sprites" POINTS = "points" PROPERTIES = "properties"
[docs] class PROPERTIES(object): """ The properties that may be used by this library code. update: just make an update of the properties, overwriting and extending update_strict: additionally to update check if for every sprite there is an entry extend: only add additional properties, do not overwrite extend_strict: same as extend but checking if for every sprite there is an entry """ #: just make an update of the properties, overwriting and extending MODE_UPDATE = "update" #: additionally to update check if for every sprite there is an entry MODE_UPDATE_STRICT = "update_strict" #: only add additional properties, do not overwrite MODE_EXTEND = "extend" #: same as extend but checking if for every sprite there is an entry MODE_EXTEND_STRICT = "extend_strict" MODE = "mode" ROW = "row" COLUMN = "col" FACING = "facing" ACTION = "action"
def __init__(self, logger=DummyLogger()): self.logger = logger @staticmethod
[docs] def get_aabb_of_sprite_definition(sprite_definition): """ Calculates the AABB of the entire sprite definition (including all sprites). :param sprite_definition: The sprite definition to calculate the AABB from. :return: The AABB containing all sprites from the definition. """ aabb = AABB.from_points( sprite_definition[SpritesheetLib10.ELEMENTS.SPRITES][0][SpritesheetLib10.ELEMENTS.POINTS]) for sprite in sprite_definition[SpritesheetLib10.ELEMENTS.SPRITES]: aabb = aabb.union(AABB.from_points(sprite[SpritesheetLib10.ELEMENTS.POINTS])) return aabb
@staticmethod
[docs] def create_empty_sprite_definition(file_name, background_color=None): """ Creates a empty sprite definition with some default values set. :param file_name: The file name of the sprite definition. :param background_color: :return: A empty sprite definition in json format: a dictionary """ # if not background_color: # background_color = [0, 0, 0, 255] return {SpritesheetLib10.ELEMENTS.VERSION: "1.0", SpritesheetLib10.ELEMENTS.FILENAME: file_name, # ELEMENT_BACKGROUND: background_color, SpritesheetLib10.ELEMENTS.SPRITES: []}
[docs] def update_sprite_properties(self, sprite_definition, properties): """ Updates the properties of the sprites. The new properties need an additional key-value pair called 'mode'. The value of 'mode' can be one of the following: update, update_strict, extend, extend_strict - 'update' does take what is in the properties dictionary and puts it into the existing one. - 'update_strict' does the same as 'update' but checks that there is an entry for every gid. - 'extend' does ignore the entries from the new dictionary if the key already exists in the sprites properties. - 'extend_strict' does the same as extend but checks that there is an entry for every key :param sprite_definition: The sprites to update. :param properties: The properties to use for updating. :return: None """ if properties: # noinspection PyPep8Naming PROPS = SpritesheetLib10.PROPERTIES if PROPS.MODE not in properties: raise KeyError("Properties: missing key: {0} ".format(PROPS.MODE)) mode = properties[PROPS.MODE] if mode not in ( PROPS.MODE_UPDATE, PROPS.MODE_UPDATE_STRICT, PROPS.MODE_EXTEND, PROPS.MODE_EXTEND_STRICT ): raise ValueError("Properties: value '{0}' of '{1}' is unknown!".format( mode, PROPS.MODE)) for sprite in sprite_definition.get(SpritesheetLib10.ELEMENTS.SPRITES, []): sprite_gid = sprite[SpritesheetLib10.ELEMENTS.GID] if mode in (PROPS.MODE_UPDATE_STRICT, PROPS.MODE_EXTEND_STRICT): if sprite_gid not in properties: gid_error_msg = "Properties: Completeness '{0}' requires that all gid are defined, " \ "missing gid: {1}" raise KeyError( gid_error_msg.format(PROPS.MODE_UPDATE_STRICT, sprite_gid)) new_props = properties.get(sprite_gid, {}) sprite_props = sprite[SpritesheetLib10.ELEMENTS.PROPERTIES] if mode in (PROPS.MODE_UPDATE, PROPS.MODE_UPDATE_STRICT): sprite_props.update(new_props) elif mode in (PROPS.MODE_EXTEND, PROPS.MODE_EXTEND_STRICT): for prop_key, prop_value in list(new_props.items()): if prop_key not in sprite_props: sprite_props[prop_key] = prop_value
[docs] def create_grid(self, num_tiles_x, num_tiles_y, tile_width, tile_height, file_info): """ Creates a sprite definition that contains a grid according to the arguments. :param num_tiles_x: Number of tiles in x direction. :param num_tiles_y: Number of tiles in y direction. :param tile_width: The tile width in pixels. :param tile_height: The tile height in pixels. :param file_info: A FileInfo instance describing the filename to use in the sprite definition. :return: New sprite definition with sprites arranged in a grid. :raise ValueError: if number of digits is too small to hold all sprites. """ # TODO: should this method already respect the margin and spacing? self.logger.debug("create_grid command: num_tiles: {0}/{1} size: {2}/{3} num_digits: {4} name: {5}") sprite_definition = SpritesheetLib10.create_empty_sprite_definition(file_info.name) gid = 0 for idx_y, y in enumerate(list(range(0, tile_height * num_tiles_y, tile_height))): for idx_x, x in enumerate(list(range(0, tile_width * num_tiles_x, tile_width))): points = [] for vx, vy in [(0, 0), (tile_width, 0), (tile_width, tile_height), (0, tile_height)]: points.append([x + vx, y + vy]) properties = {SpritesheetLib10.PROPERTIES.COLUMN: idx_x, SpritesheetLib10.PROPERTIES.ROW: idx_y} sprite_def = { SpritesheetLib10.ELEMENTS.GID: gid, SpritesheetLib10.ELEMENTS.POINTS: points, SpritesheetLib10.ELEMENTS.PROPERTIES: properties } sprite_definition[SpritesheetLib10.ELEMENTS.SPRITES].append(sprite_def) gid += 1 self.logger.debug('grid sprite definition created:\n\n{0}\n'.format(sprite_definition)) return sprite_definition
[docs] def adjust_spacing(self, source_sprite_def, spacing): """ Adjusts sprite definition to fulfill the spacing requirement. The margin is set to 0 (so no negative coordinates exists for any sprites, this is important for the image). :param source_sprite_def: The source sprite definition. :param spacing: The spacing between the sprites in pixels. None: don't move the sprites, [0...) space between sprites in pixels. :return: New sprite definition respecting spacing. :raise ValueError: If spacing or the margin is negative, ValueError is raised. """ self.logger.debug("converting sprite definition") new_sdef = self.clone_sprite_definition(source_sprite_def) if spacing is not None: if spacing < 0: err_msg = "Invalid spacing, should be greater than 0 instead of " + str(spacing) self.logger.error(err_msg) raise ValueError(err_msg) else: self.logger.debug("spacing: " + str(spacing)) boxes = [] for idx, sprite in enumerate(new_sdef[SpritesheetLib10.ELEMENTS.SPRITES]): points = sprite[SpritesheetLib10.ELEMENTS.POINTS] aabb = AABB.from_points(points) self.logger.debug("generated aabb (index {2}) {0} from points {1} ".format(aabb, points, idx)) aabb.index = idx boxes.append(aabb) tiles = new_sdef[SpritesheetLib10.ELEMENTS.SPRITES] self.logger.info("move sprites") self._remove_collisions(tiles, boxes, spacing) self.logger.info("move sprites done") else: self.logger.debug("no spacing: " + str(spacing)) new_sdef = self.adjust_margin(new_sdef, 0) return new_sdef
def _remove_collisions(self, sprites, boxes, spacing): # expand until no collisions are present colliding, colliding_box = _has_collisions(boxes) while colliding: # double distance for idx, box in enumerate(colliding): dx = box.min_x dy = box.min_y sprite = sprites[box.index] sprite[SpritesheetLib10.ELEMENTS.POINTS] = move_points(sprite[SpritesheetLib10.ELEMENTS.POINTS], dx, dy) box.move_ip(dx, dy) self.logger.debug("has more collisions?") colliding, colliding_box = _has_collisions(boxes) self.logger.debug("expand done") # shrink x direction boxes.sort(key=lambda b: (b.min_y, b.min_x)) for fix_idx, fixed_aabb in enumerate(boxes): row = AABB(fixed_aabb.max_x, fixed_aabb.min_y + 1, sys.maxsize, fixed_aabb.max_y - 1) colliding = row.collide_all(boxes[fix_idx + 1:]) if colliding: box = colliding.pop(0) dx = fixed_aabb.max_x + spacing - box.min_x dy = 0 sprite = sprites[box.index] sprite[SpritesheetLib10.ELEMENTS.POINTS] = move_points(sprite[SpritesheetLib10.ELEMENTS.POINTS], dx, dy) box.move_ip(dx, dy) for box in colliding: sprite = sprites[box.index] sprite[SpritesheetLib10.ELEMENTS.POINTS] = move_points(sprite[SpritesheetLib10.ELEMENTS.POINTS], dx, dy) box.move_ip(dx, dy) self.logger.debug("adjust x done") # shrink y direction boxes.sort(key=lambda b: (b.min_x, b.min_y)) for fix_idx, fixed_aabb in enumerate(boxes): row = AABB(fixed_aabb.min_x + 1, fixed_aabb.max_y, fixed_aabb.max_x - 1, sys.maxsize) colliding = row.collide_all(boxes[fix_idx + 1:]) if colliding: box = colliding.pop(0) dx = 0 dy = fixed_aabb.max_y + spacing - box.min_y sprite = sprites[box.index] sprite[SpritesheetLib10.ELEMENTS.POINTS] = move_points(sprite[SpritesheetLib10.ELEMENTS.POINTS], dx, dy) box.move_ip(dx, dy) for box in colliding: sprite = sprites[box.index] sprite[SpritesheetLib10.ELEMENTS.POINTS] = move_points(sprite[SpritesheetLib10.ELEMENTS.POINTS], dx, dy) box.move_ip(dx, dy) self.logger.debug("adjust y done")
[docs] def create_image(self, sprite_definition, pygame_module=None): """ Creates a image as pygame surface. :param sprite_definition: The source sprite definition which the image is based on. :param pygame_module: Default is None, therefore the pygame module is imported. Otherwise it should provide the same functionality used in this method. Mainly used for testing with a mock object. :return: pygame.Surface """ if pygame_module is None: import pygame as pygame_module aabb = SpritesheetLib10.get_aabb_of_sprite_definition(sprite_definition) margin = min(aabb.min_x, aabb.min_y) width = aabb.max_x + margin height = aabb.max_y + margin self.logger.debug("creating image of size {0}, {1}", width, height) surf = pygame_module.Surface((width, height)) surf.fill((0, 0, 0)) for sprite in sprite_definition[SpritesheetLib10.ELEMENTS.SPRITES]: self.logger.debug("drawing sprite {0}", sprite[SpritesheetLib10.ELEMENTS.GID]) points = sprite[SpritesheetLib10.ELEMENTS.POINTS] # pygame.draw.polygon(surf, (255, 255, 255), points, 0) pygame_module.draw.lines(surf, (255, 255, 255), True, points, 1) return surf
[docs] def is_sprite_def_valid(self, sprite_definition): # TODO: mandatory vs optional attributes # mandatory: version, sprites, gid (unique), points # optional: filename(not empty? or should be just same as filename?), # backgroundcolor ([255, 0, 0, 255, 255]), anchor, transformations # TODO: save/reload sdef, it should be the same afterwards # serialized_sdef = self._serialize_to_json_string(sprite_definition) # json.dumps(sprite_def) # reloaded_sdef = self._de_serialize_from_json_string(serialized_sdef) # json.loads(j) # if sprite_def != reloaded_sdef: # return False # TODO: change to exceptions for failing validation # TODO: check orientation, ccw vs cw -> use the signed area -> should a sprite have a orientation attribute? # TODO: better doc string? """ Checks if the sprite definition version 1.0 is valid. :param sprite_definition: The sprite definition to check. :return: True if it validates, otherwise False. """ self.logger.debug("validating sprite definition") # mandatory part if sprite_definition is None: self.logger.error("Invalid sprite definition: it was None") return False if SpritesheetLib10.ELEMENTS.VERSION not in sprite_definition: self.logger.error("Invalid sprite definition: should have a 'version' attribute.") return False if sprite_definition[SpritesheetLib10.ELEMENTS.VERSION] != "1.0": self.logger.error( "Invalid sprite definition: should be version 1.0, found instead: " + sprite_definition[ SpritesheetLib10.ELEMENTS.VERSION]) return False if SpritesheetLib10.ELEMENTS.FILENAME not in sprite_definition: self.logger.error("Invalid sprite definition: should have a 'filename' attribute. ") return False if len(sprite_definition[SpritesheetLib10.ELEMENTS.FILENAME]) == 0: self.logger.error("Invalid sprite definition: should have a filename.") return False if SpritesheetLib10.ELEMENTS.SPRITES not in sprite_definition: self.logger.error("Invalid sprite definition: should have at least one sprite.") return False if len(sprite_definition[SpritesheetLib10.ELEMENTS.SPRITES]) == 0: self.logger.error("Invalid sprite definition: should have at least one sprite.") return False orientation = sign(get_signed_area_of_polygon( sprite_definition[SpritesheetLib10.ELEMENTS.SPRITES][0][SpritesheetLib10.ELEMENTS.POINTS])) gids = [] for sprite in sprite_definition[SpritesheetLib10.ELEMENTS.SPRITES]: if SpritesheetLib10.ELEMENTS.GID not in sprite: self.logger.error("Invalid sprite definition: should have a gid for each sprite") return False gid = sprite[SpritesheetLib10.ELEMENTS.GID] if gid in gids: self.logger.error( "Invalid sprite definition: sprite gid should be unique, duplicate found: " + str(gid)) return False gids.append(gid) if SpritesheetLib10.ELEMENTS.POINTS not in sprite: self.logger.error("Invalid sprite definition: sprite should have points") return False if len(sprite[SpritesheetLib10.ELEMENTS.POINTS]) < 3: self.logger.error( "Invalid sprite definition: at least 3 coordinates are expected in points of sprite: " + gid) return False # check if the (three) points are not colinear (e.g. they form a polygon and don't lie on a line) signed_area = get_signed_area_of_polygon(sprite[SpritesheetLib10.ELEMENTS.POINTS]) if signed_area == 0: self.logger.error( "Invalid sprite definition: sprite '" + str(gid) + "' has no area for polygon: " + str( sprite[SpritesheetLib10.ELEMENTS.POINTS])) return False if sign(signed_area) != orientation: self.logger.error("Invalid sprite definition: inconsistent orientation in sprite: " + gid) return False for idx, point in enumerate(sprite[SpritesheetLib10.ELEMENTS.POINTS]): px, py = point # test for negative coordinates if px < 0 or py < 0: self.logger.error( "Invalid sprite definition: sprite should not have negative coordinates: " + str( gid) + " " + str((px, py))) return False # test for points with same coordinates for qx, qy in sprite[SpritesheetLib10.ELEMENTS.POINTS][idx + 1:]: if px == qx and py == qy: self.logger.warn( "Two overlapping points found in sprite '" + gid + "': " + str((px, py)) + "==" + str( (qx, qy))) # optional parts # TODO validate optional parts return True
[docs] def clone_sprite_definition(self, source_sprite_def): # TODO: use the serialization methods # serialized_sdef = self._serialize_to_json_string(sprite_definition) # json.dumps(sprite_def) # reloaded_sdef = self._de_serialize_from_json_string(serialized_sdef) # json.loads(j) """ Makes a deep copy (using json serialization). :param source_sprite_def: The source sprite definition. :return: Copy of source sprite definition. """ self.logger.debug("cloning definition") json_string = json.dumps(source_sprite_def) new_sdef = json.loads(json_string) return new_sdef
[docs] def adjust_margin(self, sdef, margin): """ Creates a new sprite definition with added margin. :param sdef: Sprite definition to manipulate. :param margin: The margin to add in pixels. :return: New sprite definition with adjusted margin. :raise ValueError: ValueError is raised if the margin is negative. """ new_sdef = self.clone_sprite_definition(sdef) if margin < 0: err_msg = "Invalid margin, should be greater than 0 instead of " + str(margin) self.logger.error(err_msg) raise ValueError(err_msg) else: self.logger.debug("using margin " + str(margin)) aabb = SpritesheetLib10.get_aabb_of_sprite_definition(new_sdef) dx = margin - aabb.min_x dy = margin - aabb.min_y for sprite in new_sdef[SpritesheetLib10.ELEMENTS.SPRITES]: sprite[SpritesheetLib10.ELEMENTS.POINTS] = move_points(sprite[SpritesheetLib10.ELEMENTS.POINTS], dx, dy) return new_sdef
[docs] def save_sprite_definition_to_disk(self, sprite_definition, file_info): """ Saves the sprite definition to disk to the file from file_info. :param sprite_definition: The sprite definition to be saved. :param file_info: The file info to save the sprite definition to. """ if file_info.exists: self.logger.info("deleting already existing file '{0}' to replace it!".format(file_info.full_name)) file_info.delete() self.logger.debug("saving sdef to disk as {0}", file_info) with file_info.open(FileMode.WriteBinary) as stream: json.dump(sprite_definition, stream, indent=4, separators=(',', ': '), sort_keys=True)
[docs] def save_image_to_disk(self, sprite_sheet_mask, file_info, pygame_module=None): """ Saves the image to disk to the file from the file_info. :param pygame_module: The module to use as pygame. If None, pygame will be imported and used. :param sprite_sheet_mask: The pygame.surface to save. :param file_info: The file info to save the image to. """ self.logger.debug("saving image to disk as {0}", file_info) if pygame_module is None: # pragma: no cover import pygame as pygame_module # pragma: no cover if file_info.exists: self.logger.info("deleting already existing file '{0}' to replace it!".format(file_info.full_name)) file_info.delete() pygame_module.image.save(sprite_sheet_mask, file_info.name)
[docs] def load_sdef_from_file(self, file_info, json_module=None): """ Loads a sprite definition from the file system. :param json_module: The json module, mainly for mocking purposes. :param file_info: The file info object containing the file name to load. :raise ValueError: ValueError is raised if the file name element has not the same name as the file it contains. """ class _MyError(Exception): def __init__(self, message): Exception.__init__(self, message) if json_module is None: # pragma: no cover import json as json_module # TODO: unittest with read binary should fail json_file = file_info.open(FileMode.ReadText) try: sdef = json_module.load(json_file) filename_from_sdef = sdef[SpritesheetLib10.ELEMENTS.FILENAME] if filename_from_sdef != file_info.name: # case sensitive compare!! raise _MyError("Sprite definitions element filename '{0}' " "differs from loaded filename '{1}' ".format(filename_from_sdef, file_info.name)) # TODO: add optional default values!! # TODO: unittests! # for sprite in sdef[SpritesheetLib10.ELEMENTS.SPRITES]: # if SpritesheetLib10.ELEMENTS.ROW not in sprite: # sprite[SpritesheetLib10.ELEMENTS.ROW] = -1 # if SpritesheetLib10.ELEMENTS.COLUMN not in sprite: # sprite[SpritesheetLib10.ELEMENTS.COLUMN] = -1 return sdef except ValueError as ve: # nicer formatting and better info about which file failed raise ValueError("{0} from file '{1}' " "(probably not a sprite definition file)!".format(ve.args, file_info.full_name)) except _MyError as me: raise ValueError(me.args)
[docs] def get_image_name_from_sdef(self, sprite_definition): """ Returns the image name according to the sprite definition. :param sprite_definition: The sprite definition file. :return: The image name. """ return self.get_image_name_from_sdef_name(sprite_definition[SpritesheetLib10.ELEMENTS.FILENAME]) # noinspection PyMethodMayBeStatic
[docs] def get_image_name_from_sdef_name(self, sdef_file_name): """ Returns the image name according to the sprite definition name. It actually just chops of the last extension. :param sdef_file_name: The sprite definition name. :return: The image name. """ image_name, ext = os.path.splitext(sdef_file_name) if image_name.rfind(os.extsep) == -1: raise ValueError("sprite definition name is not of the form " "[name]{1}[image extension]{1}[sprite definition extension]:" " {0}".format(sdef_file_name, os.extsep)) return image_name
[docs] def load_spritesheet(self, sprite_definition_file_info, pygame_module=None): """ Loads the spritesheet and returns a dictionary containing the sprites keyed by their name. :param sprite_definition_file_info: The sprite definition file info to load. :param pygame_module: Default is None, therefore the pygame module is imported. Otherwise it should provide the same functionality used in this method. Mainly used for testing with a mock object. :return: SpritesList instance containing Sprite instances. """ sdef = self.load_sdef_from_file(sprite_definition_file_info) # TODO: validate sdef?? image_file_name = os.path.join(sprite_definition_file_info.directory_name, self.get_image_name_from_sdef(sdef)) image_file_info = FileInfo(image_file_name) return self.load_spritesheet_from_sdef(sdef, image_file_info, pygame_module)
[docs] def load_spritesheet_from_sdef(self, sprite_definition, image_file_info, pygame_module=None): """ Loads the sprites using the given sprite definition and the image file path. :param sprite_definition: :param image_file_info: The name of the image as file info to load. :param pygame_module: Default is None, therefore the pygame module is imported. Otherwise it should provide the same functionality used in this method. Mainly used for testing with a mock object. :return: SpritesList instance containing Sprite instances. """ if pygame_module is None: # pragma: no cover import pygame as pygame_module # TODO: check if file exists, if not raise nice error message sprite_sheet_surf = pygame_module.image.load(image_file_info.full_name) return self.load_spritesheet_from_sdef_and_surface(sprite_definition, sprite_sheet_surf, pygame_module) # noinspection PyMethodMayBeStatic
[docs] def load_spritesheet_from_sdef_and_surface(self, sprite_definition, sprite_sheet_surf, pygame_module=None): """ Loads the sprites from the sprite sheet using the sprite definition. :param sprite_sheet_surf: The surface to use as the spritesheet. :param sprite_definition: The sprite definition (json dict). :param pygame_module: Default is None, therefore the pygame module is imported. Otherwise it should provide the same functionality used in this method. Mainly used for testing with a mock object. :return: SpritesList instance containing Sprite instances. """ if pygame_module is None: # pragma: no cover import pygame as pygame_module # TODO: add unittest to check if sdef fits on sprite_sheet_surface # sprite_sheet_surf = pygame_module.image.load(sprite_sheet_surf.full_name) sprites = SpritesList() for sprite_def in sprite_definition[SpritesheetLib10.ELEMENTS.SPRITES]: points = sprite_def[SpritesheetLib10.ELEMENTS.POINTS] aabb = AABB.from_points(points) rect = pygame_module.Rect(aabb.to_rect_tuple()) # noinspection PyArgumentList mask = pygame_module.Surface(rect.size, pygame_module.SRCALPHA, 32) # alpha? flags? mask.fill((0, 0, 0, 0)) local_points = move_points(points, -aabb.min_x, -aabb.min_y) # need to move the points to local coordinates pygame_module.draw.polygon(mask, (255, 255, 255, 255), local_points, 0) # noinspection PyArgumentList image = pygame_module.Surface(rect.size, pygame_module.SRCALPHA, 32) # alpha? flags? image.blit(sprite_sheet_surf, (0, 0), rect) image.blit(mask, (0, 0), None, pygame_module.BLEND_RGBA_MULT) name = sprite_def[SpritesheetLib10.ELEMENTS.GID] anchor = [0, 0] if SpritesheetLib10.ELEMENTS.ANCHOR in sprite_def: anchor = sprite_def[SpritesheetLib10.ELEMENTS.ANCHOR] # TODO: use correct anchor point properties = {} if SpritesheetLib10.ELEMENTS.PROPERTIES in sprite_def: properties = dict(sprite_def[SpritesheetLib10.ELEMENTS.PROPERTIES]) sprites.append(Sprite(image, anchor, name, properties)) return sprites
_missing_property = None
[docs]class SpriteListException(Exception): """ The SpriteListException class. Makes this exceptions catchable. """ pass
def _sort_func(spr, prop_name): try: return spr.properties[prop_name] except KeyError: raise SpriteListException("KeyError: Sprite (gid: {0}) has no '{1}' property!".format(spr.gid, prop_name))
[docs]class SpritesList(list): """ The SpriteList class. It is basically a list with some convenience methods to sort and group the sprites. :param iterable: Initial items if not None. """ # noinspection PyTypeChecker def __init__(self, iterable=None): if iterable is None: list.__init__(self) else: list.__init__(self, iterable)
[docs] def get_columns(self): """ Returns a list of columns, e.g. [[sprites of 1st column], [2.column], ...] if 'col' and 'row' properties are defined, otherwise that sprite is left out (may result in an empty list). The inner lists are instance of SpritesList. This will fail if any sprite that has a 'col' property is missing the 'row' property. """ # the inner_sort_key lambda will fail if not 'row' property exists, this is intentional! groups = self.get_grouped_by_property(SpritesheetLib10.PROPERTIES.COLUMN, group_sort_key=lambda spr: _sort_func(spr, SpritesheetLib10.PROPERTIES.ROW)) if _missing_property in groups: del groups[_missing_property] columns = list(groups.values()) columns.sort(key=lambda cols: cols[0].properties[SpritesheetLib10.PROPERTIES.COLUMN]) return columns
[docs] def get_rows(self): """ Returns a list of rows, e.g. [[sprites of 1st row], [2. row], ...] if 'col' and 'row' properties are defined, otherwise that sprite is left out (may result in an empty list). The inner lists are instance of SpritesList. This will fail if any sprite that has a 'row' property is missing the 'col' property. """ # the inner_sort_key lambda will fail if not 'col' property exists, this is intentional! groups = self.get_grouped_by_property(SpritesheetLib10.PROPERTIES.ROW, group_sort_key=lambda spr: _sort_func(spr, SpritesheetLib10.PROPERTIES.COLUMN)) if _missing_property in groups: del groups[_missing_property] rows = list(groups.values()) rows.sort(key=lambda cols: cols[0].properties[SpritesheetLib10.PROPERTIES.ROW]) return rows
[docs] def get_grouped_by_property(self, property_name, group_sort_key=None): """ Returns a dict containing the groups indexed by key, e.g. {key: [spr1, spr2, ...], ...} :param property_name: The name of the property that should be grouped by. :param group_sort_key: The key function used to sort the groups. :return: Dictionary. """ grouped_sprites = {} # TODO: maybe the property needs a type conversion? Or should this be done before, in a separate step? key_func = lambda spr: (spr.properties.get(property_name, _missing_property) is None, spr.properties.get(property_name, _missing_property)) sprites = sorted(self, key=key_func) grouped = itertools.groupby(sprites, key=key_func) for key, group in grouped: sprites_group = SpritesList(group) if key == (True, _missing_property): grouped_sprites[_missing_property] = sprites_group else: # TODO: default sort should be by GID if group_sort_key is not None: sprites_group.sort(key=group_sort_key) grouped_sprites[key[1]] = sprites_group return grouped_sprites
[docs] def get_grouped_by_facing_and_action(self): """ Returns a dict containing the sprites sorted as follows:: { facing1:{action1:[sprites], action2:[sprites], ...}, facing2:{action1:[sprites], action2:[sprites], ...}, facing3:{action1:[sprites], action3:[sprites], ...}, ... } :return: New dict containing the sprites with key by 'facing' then by 'action'. """ grouped_by_facing = self.get_grouped_by_property(SpritesheetLib10.PROPERTIES.FACING, group_sort_key=lambda spr: _sort_func(spr, SpritesheetLib10.PROPERTIES.ACTION)) result = {} for facing, sprites in list(grouped_by_facing.items()): # TODO: here gid order is assumed! make it changeable? custom group_sort_key for sorting sprites? result[facing] = SpritesList(sprites).get_grouped_by_property(SpritesheetLib10.PROPERTIES.ACTION, group_sort_key=lambda spr: spr.gid) return result
[docs] def get_grouped_by_action_and_facing(self): """ Returns a dict containing the sprites sorted as follows:: { action1:{facing1:[sprites], facing2:[sprites], ...}, action2:{facing1:[sprites], facing2:[sprites], ...}, action3:{facing1:[sprites], facing2:[sprites], ...}, ... } This allows to easily find out what actions are available in the spritesheet. :return: New dict containing the sprites with key by 'action' then by 'facing'. """ grouped_by_action = self.get_grouped_by_property(SpritesheetLib10.PROPERTIES.ACTION, group_sort_key=lambda spr: _sort_func(spr, SpritesheetLib10.PROPERTIES.FACING)) result = {} for action, sprites in list(grouped_by_action.items()): # TODO: here gid order is assumed! make it changeable? custom group_sort_key for sorting sprites? result[action] = SpritesList(sprites).get_grouped_by_property(SpritesheetLib10.PROPERTIES.FACING, group_sort_key=lambda spr: spr.gid) return result
[docs] def get_gid_slice(self, start_gid, end_gid=sys.maxsize): """ Returns a SpritesList containing all sprites with start <= gid <= end. May be empty or the sequence of gid could have gaps (if some gid is missing). :param start_gid: The first gid that should be considered. :param end_gid: The last gid that should be considered. If not set, all sprites to the end will be returned. """ return SpritesList([spr for spr in (sorted(self, key=lambda spr: spr.gid)) if start_gid <= spr.gid <= end_gid])
[docs]class Sprite(object): """ The sprite class. :param image: The pygame surface containing the image data. :param anchor: The anchor point. :param gid: The global id of this image (from the sprite definition). :param properties: The properties of this Sprite as defined in the sprite definition as dictionary {'name': value}. """ def __init__(self, image, anchor, gid, properties): self.image = image self.anchor = anchor self.gid = gid self.properties = properties
[docs]class AABB(object): """ Axis aligned bounding box. :param min_x: the min x coordinate :param min_y: the min y coordinate :param max_x: the max x coordinate :param max_y: the max y coordinate """ def __init__(self, min_x, min_y, max_x, max_y): # TODO: convert asserts to if with value switch?? assert min_x <= max_x assert min_y <= max_y self.min_x = min_x self.min_y = min_y self.max_x = max_x self.max_y = max_y @property def center_x(self): """ Returns the calculated center x value. :return: calculated center in x. """ return self.min_x + (self.max_x - self.min_x) // 2 @property def center_y(self): """ Returns the calculated center y value. :return: calculated center in y. """ return self.min_y + (self.max_y - self.min_y) // 2
[docs] def to_rect_tuple(self): """ Converts the AABB to a tuple representing a rect in the format (x, y, w, h). :return: tuple with following coordinates: (x, y, width, height) """ return self.min_x, self.min_y, self.max_x - self.min_x, self.max_y - self.min_y # TODO: rename?
[docs] def copy_from(self, other): """ Copies the values from the other instance. :param other: Other AABB instance. """ self.min_x = other.min_x self.min_y = other.min_y self.max_x = other.max_x self.max_y = other.max_y
[docs] def copy(self): """ Creates a new AABB instance with the same values. :return: New AABB with same values. """ return AABB(self.min_x, self.min_y, self.max_x, self.max_y)
[docs] def collide(self, other): """ Check if this AABB collides with other AABB. :param other: other AABB to collide with. :return: True if they have a common area, otherwise False. """ if self.min_x > other.max_x or self.max_x < other.min_x or self.min_y > other.max_y or self.max_y < other.min_y: return False return True
[docs] def collide_all(self, others): """ Checks if any of the other AABBs collide. :param others: list or iterable of other AABB. :return: List of colliding AABB. May be empty. """ colliding = [] colliding_append = colliding.append for other in others: if self.min_x > other.max_x or self.max_x < other.min_x or \ self.min_y > other.max_y or self.max_y < other.min_y: continue colliding_append(other) return colliding
[docs] def move(self, dx, dy): """ Creates a new instance of an AABB that is moved by dx, dy :param dx: the distance to move in x direction. :param dy: the distance to move in y direction. :return: a new AABB instance that is moved by dx, dy. """ return AABB(self.min_x + dx, self.min_y + dy, self.max_x + dx, self.max_y + dy)
[docs] def move_ip(self, dx, dy): """ Move this AABB instance about dx, dy. :param dx: the distance to move in x direction. :param dy: the distance to move in y direction. """ self.min_x += dx self.min_y += dy self.max_x += dx self.max_y += dy
@staticmethod
[docs] def from_points(points): """ Creates a AABB from a bunch of points in the form [(px, py),...]. :param points: a list of points in px, py coordinates, e.g. [(px, py), ....]. :return: The AABB containing all points. """ aabb = AABB(points[0][0], points[0][1], points[0][0], points[0][1]) for px, py in points: if px < aabb.min_x: aabb.min_x = px if py < aabb.min_y: aabb.min_y = py if px > aabb.max_x: aabb.max_x = px if py > aabb.max_y: aabb.max_y = py return aabb
def __str__(self): return self.__repr__() def __repr__(self): return "<AABB({0}, {1}, {2}, {3})>".format(self.min_x, self.min_y, self.max_x, self.max_y) def __eq__(self, other): if isinstance(other, AABB): return self.min_x == other.min_x and self.min_y == other.min_y and \ self.max_x == other.max_x and self.max_y == other.max_y return NotImplemented def __ne__(self, other): result = self.__eq__(other) if result is NotImplemented: return result return not result
[docs] def union_all(self, others): """ Creates a new instance which contains all other AABB instances. :param others: iterable of other AABB instances. :return: an AABB containing all other AABB. """ aabb = self.copy() for other in others: if other.min_x < aabb.min_x: aabb.min_x = other.min_x if other.min_y < aabb.min_y: aabb.min_y = other.min_y if other.max_x > aabb.max_x: aabb.max_x = other.max_x if other.max_y > aabb.max_y: aabb.max_y = other.max_y return aabb
[docs] def union(self, other): """ Creates a new instances containing the other AABB. :param other: another AABB instance. :return: an AABB containing the other AABB. """ aabb = self.copy() if other.min_x < aabb.min_x: aabb.min_x = other.min_x if other.min_y < aabb.min_y: aabb.min_y = other.min_y if other.max_x > aabb.max_x: aabb.max_x = other.max_x if other.max_y > aabb.max_y: aabb.max_y = other.max_y return aabb
[docs]class FileMode(object): """ This is a 'enum' enumerating the file modes. WriteUpdate corresponds to 'w+' and truncates the file! """ # TODO: document values #: same as ReadText, read text mode Read = 'r' Write = "w" Append = 'a' ReadBinary = 'rb' WriteBinary = 'wb' AppendBinary = 'ab' ReadUpdate = 'r+' #: Truncates the file! WriteUpdate = 'w+' AppendUpdate = 'a+' ReadUpdateBinary = 'r+b' #: Truncates the file to 0 bytes! WriteUpdateBinary = 'w+b' AppendUpdateBinary = 'a+b' ReadUniversalNewLines = 'rU' ReadText = 'rt' WriteText = 'wt'
[docs]class FileInfo(object): """ This is an object for easier file manipulation. :param file_path: the path to the file, it must be a file. :raise ValueError: ValueError is raised if the file path ends with either os.sep, os.altsep, os.curdir or the file path does contain a os.pathsep or the file path equals os.devnull. """ def __init__(self, file_path, os_module=os): if file_path.endswith(os.sep) or \ (os.altsep is not None and file_path.endswith(os.altsep)) or \ file_path.endswith(os.curdir) or \ file_path.find(os.pathsep) > -1 or \ file_path == os.devnull or \ os_module.path.isdir(file_path): raise ValueError("Given path is not a file: " + file_path) if os.altsep is not None: # hack to enforce forward slash on windows file_path = file_path.replace(os.sep, os.altsep) self._file_path = file_path self._os_module = os_module @property def exists(self): """ The exists property indicates if the file exists on the file system. :return: True, if the file exists on the file system, otherwise False. """ return self._os_module.path.exists(self.full_name) # and not self.is_casing_different @property def is_casing_different(self): """ Returns true if the files on the filesystem differs in the casing. Only relevant on windows. Otherwise it returns False, as if the file does not exist. :return: True if the casing is different than the one on the filesystem, otherwise False. """ if self._os_module.path.exists(self.directory_name) and self._os_module.path.exists(self.full_name): files = self._os_module.listdir(self.directory_name) try: files.index(self.name) # TODO: check if path has same casing too except ValueError: return True return False @property def name(self): """ Name of the file. :return: The name of the file. """ return self._os_module.path.split(self.full_name)[1] @property def full_name(self): """ The full name of the file, containing the path. :return: The path and filename of the file. """ return self._file_path @property def directory_name(self): """ The directory name where the file is. :return: The directory of the file. """ return self._os_module.path.dirname(self.full_name)
[docs] def open(self, mode, buffering=-1): """ Opens the file for manipulation. :param mode: the file open mode. See FileMode. :param buffering: the buffering. :return: a file descriptor. """ if self.is_casing_different: raise IOError("File with different casing " + self.full_name + " detected. Delete or rename it!") return open(self.full_name, mode, buffering)
[docs] def delete(self): """ Deletes the file. """ self._os_module.remove(self._file_path)
def __eq__(self, other): if isinstance(other, FileInfo): return self.full_name == other.full_name return NotImplemented def __ne__(self, other): result = self.__eq__(other) if result is NotImplemented: return result return not result def __str__(self): return self.__repr__() def __repr__(self): return "<FileInfo('{0}')>".format(self.full_name)
[docs]def get_needed_digits(integer_number): """ Calculates the number of digits that are needed to represent a number as string. :param integer_number: The number to get the digits from. :return: :raise ValueError: if its not of type int of it is negative, ValueError is raised. """ if not isinstance(integer_number, int): raise TypeError("should be of type int but was " + str(type(integer_number))) if integer_number < 0: raise ValueError("should be equal or greater than zero, but was ", str(integer_number)) # count = 0 # while value > 0: # count += 1 # value /= 10 # return count if count > 0 else 1 # this seems pretty elegant, but is it fast? return len(str(integer_number)) # TODO: move this into SpritesheetLib10
[docs]def move_points(points, dx, dy): """ Moves the points about dx, dy. :param points: iterable of points, e.g. [(p1x, p1y), (p2x, p2y),....] :param dx: Distance to move in x direction, positive or negative. :param dy: Distance to move in y direction, positive or negative. :return: returns a new list of moved points as list of lists, e.g. [[p1x, p1y], [p2x, p2y],....] """ moved_points = [] for px, py in points: moved_points.append([px + dx, py + dy]) return moved_points # TODO: move this into SpritesheetLib10
def _has_collisions(boxes): """ Checks if any AABB collide. Stops when first collision is detected. :param boxes: iterable of AABB instances. :return: if collisions: tuple of list of colliding AABB and the AABB they collide with otherwise (None, None) """ for idx, box in enumerate(boxes): colliding = box.collide_all(boxes[idx + 1:]) if colliding: return colliding, box return None, None # TODO: move this functions into a math lib?
[docs]def sign(v): # TODO: is this a step function """ Sign function. :param v: Value to return the sign. :return: -1 for negative values, 0 for 0, 1 for positive values. """ if v < 0: return -1 elif v > 0: return 1 return 0
[docs]class PolygonOrientation(object): """ This is an 'enum' to define Clockwise and CounterClockwise. Clockwise=1 CounterClockwise=-1 """ Clockwise = 1 CounterClockwise = -1
[docs]def get_orientation(points): """ Finds the orientation of a list of points. :param points: list of points, e.g. [(p1x, p1y), (p2x, p2y),...] :return: Returns 1 for Clockwise orientation, otherwise -1 for Counterclockwise. """ return sign(get_signed_area_of_polygon(points))
[docs]def get_signed_area_of_polygon(points, epsilon=sys.float_info.epsilon): """ Calculates the signed area of a polygon. :param points: List of points, e.g. [(p1x, p1y), (p2x, p2y), ...] :param epsilon: Precision, any absolute area value smaller than epsilon will be treated as 0. :return: The signed area of the polygon. Positive for a clockwise order of points, otherwise negative. """ points = list(points) # handle iterators assert len(points) >= 3 # Shoelace formula, this implementation isn't probably the fastest nor the most accurate # area = 0.5 * sum([p[0] * points[idx - 1][1] - p[1] * points[idx - 1][0] for idx, p in enumerate(points)]) area = 0.5 * sum([(p[0] - points[idx - 1][0]) * (p[1] + points[idx - 1][1]) for idx, p in enumerate(points)]) if abs(area) <= epsilon: return 0.0 return area
[docs]def get_area_of_polygon(points, epsilon=sys.float_info.epsilon): """ Calculates the (unsigned) area of a polygon. :param points: List of points, e.g. [(p1x, p1y), (p2x, p2y), ....] :param epsilon: Precision, any absolute area value smaller than epsilon will be treated as 0. :return: The area of the polygon. Always positive or 0. """ return abs(get_signed_area_of_polygon(points, epsilon))