Source code for build.lib.spritesheetlib.spritesheetmaskgeneratorlib

#!/usr/bin/env python
# -*- 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_mask_generator is a tool to generate a spritesheet definition. It also can create a corresponding
image that can be used as a spritesheet template (for drawing your own sprites). Internally it uses the spritesheet
lib for all manipulations. What follows are the command line arguments that are implemented so far.

::

        usage: SpriteSheetMaskGenerator [-h] [-s SPACING] [-m MARGIN] [-v | -q]
                                        [-f | --dry-run] [-i] [-p PROPERTIES]
                                        [--version]
                                        {create_from_file,create_grid} ... filename

        Create a sprite definition file and optionally a image file, e.g. 'x.png.sdef'
        and 'x.png'. It uses pygame (>= 1.8) to create the file.Sub command help
        available, e.g. 'spritesheet_mask_generator.py create_grid -h'. Place the
        arguments at the same position as described in this help.

        positional arguments:
          {create_from_file,create_grid}
                                This are the available commands. Either create a
                                sprite definition file with a checkers pattern or
                                generate a new sprite definition from a existing
                                sprite definition file. In both cases optionally an
                                image can be created too.
            create_from_file    This command creates a new sprite definition based on
                                the given sprite definition file. Using the spacing
                                and margin arguments it can be transformed. If none is
                                set, then a copy will be generated.
            create_grid         Creates a checkers board grid pattern according to the
                                parameters.
          filename              A filename to store the generated sprite sheet mask.
                                Should be in the form of 'out.png.sdef' or
                                'out.bmp.sdef'. Follow formats are supported by
                                pygame: BMP, TGA, PNG, JPEG. If the filename extension
                                is unrecognized it will default to TGA. Both TGA, and
                                BMP file formats create uncompressed files.

        optional arguments:
          -h, --help            show this help message and exit
          -s SPACING, --spacing SPACING
                                The spacing in pixels between the sprites, greater or
                                equal to 0. If set to None, no sprites are moved
                                because spacing is then disabled.Otherwise if used
                                with an existing sprite definition file it may
                                separate sprites. (default: None)
          -m MARGIN, --margin MARGIN
                                The margin around the tiles in pixels in this image,
                                greater or equal to 0. (default: 0)
          -v, --verbose         Verbose (debug) logging. no arg: logging.ERROR, '-v':
                                logging.WARNING,'-vv': logging.INFO,'-vvv':
                                logging.DEBUG '--verbose': this is the same as '-v'
                                which is logging.WARNING. The default log level is
                                logging.ERROR. (default: 0)
          -q, --quiet           Silent mode, only log warnings. (default: None)
          -f, --force           Try to override existing file. (default: False)
          --dry-run             Do not write anything. (default: False)
          -i, --save_to_image   Generates and saves the image file. (default: False)
          -p PROPERTIES, --properties PROPERTIES
                                Provide a string describing a dictionary or a file
                                name of a json file. In both cases they should provide
                                a dictionary containing the properties that should be
                                set for some or all sprites. The string (dictionary)
                                has following structure:
                                {'gid_key':{'prop_key':'prop_value'}}. MAKE SURE TO
                                REMOVE ANY WHITESPACES! (otherwise you get a strange
                                arg parse error). In a big spritesheet it might be a
                                long list therefore the slice or range notation
                                (start, stop, step) is accepted for the gid_key (think
                                of it as a slice of range(N)[slice]), e.g.:'1' => [1];
                                '0:' => [0, 1, 2, 3, ..., N-1]; '0:3' => [0, 1, 2];
                                '0:10:2' => [0, 2, 4, 6, 8]; '0::2' => [0, 2, 4, 6,
                                ..., N-2] (if N even, otherwise N-1); ':5' => [0, 1,
                                2, 3, 4]; '::2' => see '0::2'; ':' => [0, 1, 2, ...,
                                N-1]; Negative steps are not allowed as ':' in keys
                                other than gid range notation.Note that when putting
                                the dict into a json file, the gid_key need to be
                                strings. Also the same gid might be addressed in
                                multiple keys with following limitation:the the
                                defined dictionaries will be merged as long they don't
                                have the same key defined. Here an example:props={'1':
                                {'k1': v1}, '0:': {'k2' : v2}} will generate
                                internally following: {'1': {'k1': v1, 'k2': v2}, '2':
                                {'k2': v2}, '2': {'k2': v2}, ...'N': {'k2': v2}. The
                                following will all generate errors: props={'1': {'k1':
                                v1}, '0:': {'k1': v3, 'k2' : v2}} <= 'k1' is
                                duplicated for gid '1' or props={'1': {'k1': v1, 'k1':
                                vx}} <= duplicated key 'k1' for gid '1' (default:
                                None)
          --version             show program's version number and exit







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 json
import argparse
import logging

from .spritesheetlib import FileInfo, FileMode, DummyLogger

__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 @ 2015"
__credits__ = ["DR0ID"]  # list of contributors
__maintainer__ = "DR0ID"
__license__ = "New BSD license"

__all__ = ['SpriteSheetMaskGenerator', 'DictParseError']  # list of public visible parts of this module


[docs]class DictParseError(Exception): """ The exception raised when parsing a dictionary from string fails. """ pass
class _ParseKeyError(Exception): pass
[docs]class SpriteSheetMaskGenerator(object): """ The application class of the spritesheet mask generator. Contains the main logic. Use -h for all options. """ # TODO: generate grid --> in.png.sdef --> create out.png and out.png.sdef from in.png.sdef GRID_COMMAND = "create_grid" CREATE_FROM_FILE_COMMAND = "create_from_file" def __init__(self, spritesheet_lib, logger=DummyLogger(), json_module=None): """ The SpriteSheetMaskGenerator. :param spritesheet_lib: The spritesheetlib instance to use. :param logger: The logger instance to use. :param json_module: The json module to use. Only used to mock json module. """ self.logger = logger self.spritesheet_lib = spritesheet_lib self.json_module = json_module if self.json_module is None: self.json_module = json @staticmethod def _create_grid_cmd_parser(subparsers): """ Adds the grid command command line parameters to the sub-parser. :param subparsers: sub-parsers of the command line parsing framework. """ grid_cmd_parser = subparsers.add_parser(SpriteSheetMaskGenerator.GRID_COMMAND, help="Creates a checkers board grid pattern according to the " "parameters.", description="Creates a checkers board grid pattern according to" " the parameters. The iteration direction is currently " "only from left to right, " "from top to bottom.", formatter_class=argparse.ArgumentDefaultsHelpFormatter) grid_cmd_parser.add_argument("num_tiles_x", type=int, help="Number of tiles in x direction, greater than 0.") grid_cmd_parser.add_argument("num_tiles_y", type=int, help="Number of tiles in y direction, greater than 0.") grid_cmd_parser.add_argument("--tile_width", type=int, default=32, help="The width of the tiles in pixels, greater than 0.") grid_cmd_parser.add_argument("--tile_height", type=int, default=32, help="The height of the tiles in pixels, greater than 0.") # grid_cmd_parser.add_argument("--direction", default='H', # help="The direction to traverse the grid, H horizontal, V vertical.") # grid_cmd_parser.add_argument("--prefix", default=None, # help="Prefixes that should be used in the name. Can be a " # "comma separated list of name parts to be used with " # "the linebreak, e.g. walk,run,jump. " # "If used with linebreaks it should contain enough " # "entries for all columns or rows.") # grid_cmd_parser.add_argument("--postfix", default=None, # help="Postfixes that should be used in the name. Can be a " # "comma separated list of name parts to be used with " # "the linebreak, e.g. walk,run,jump. " # "If used with linebreaks it should contain enough " # "entries for all columns or rows.") # grid_cmd_parser.add_argument("--linebreak", default=None, help="?") # grid_cmd_parser.add_argument("--origin", default="topleft", help="The point where the first coordinates " # "are. Allowed values: topleft, topright," # " bottemleft, bottomright, midleft, " # "midright, midtop, midbottom") # grid_cmd_parser.add_argument("--anchor", default=None, # help="The anchor point, where the sprite should be relative" # "to the blit position on screen. Allowed values: " # "None: anchor point is the same as the origin. " # "coordinate: e.g. 2,5, relative to origin. " # "one of: topleft, topright, bottomleft, bottomright, midleft, midtop, " # "midright, midbottom") @staticmethod def _create_create_from_file_cmd_parser(subparsers): """ Adds the from file command command line parameters to the sub-parser. :param subparsers: sub-parsers of the command line parsing framework. """ create_cmd_parser = subparsers.add_parser(SpriteSheetMaskGenerator.CREATE_FROM_FILE_COMMAND, help="This command creates a new sprite definition " "based on the given sprite definition file. Using the " "spacing and margin arguments it can be transformed. If " "none is set, then a copy will be generated.", description="Creates a new sprite definition file based on the " "given one. Using the spacing or margin arguments" "it can be transformed, if none is set then a " "copy is made.", formatter_class=argparse.ArgumentDefaultsHelpFormatter) create_cmd_parser.add_argument("from_file", help="The source sprite definition file, e.g. 'x.png.sdef'") def _parse_arguments(self, commandline_args=None): parser = argparse.ArgumentParser( description="Create a sprite definition file and optionally a image file, e.g. 'x.png.sdef' " "and 'x.png'. It uses pygame (>= 1.8) to create the file." "Sub command help available, e.g. 'spritesheet_mask_generator.py create_grid -h'. " "Place the arguments at the same position as described in this help.", formatter_class=argparse.ArgumentDefaultsHelpFormatter, prog=SpriteSheetMaskGenerator.__name__ ) # commands subparsers = parser.add_subparsers(dest="command", help="This are the available commands. Either create a sprite " "definition file with a checkers pattern or " "generate a new sprite definition from a existing " "sprite definition file. In both cases optionally an " "image can be created too.\n") self._create_create_from_file_cmd_parser(subparsers) self._create_grid_cmd_parser(subparsers) # other arguments parser.add_argument("filename", help="A filename to store the generated sprite sheet mask. Should be in the" " form of 'out.png.sdef' or 'out.bmp.sdef'. " "Follow formats are supported by pygame: BMP, TGA, PNG, JPEG. " "If the filename extension is unrecognized it will default to TGA. " "Both TGA, and BMP file formats create uncompressed files.") parser.add_argument("-s", "--spacing", type=int, default=None, help="The spacing in pixels between the sprites, greater or equal to 0. If set to " "None, no sprites are moved because spacing is then disabled." "Otherwise if used with an existing sprite definition file it may " "separate sprites.") parser.add_argument("-m", "--margin", type=int, default=0, help="The margin around the tiles in pixels in this image, greater or equal to 0.") # parser.add_argument("-n", "--named", type=bool, default=False, # help="Adds the name to each sprite, makes the resulting image bigger by some pixels.") # debug_level_excl_group = parser.add_mutually_exclusive_group() debug_level_excl_group.add_argument("-v", "--verbose", action="count", default=0, dest="log_level", help="Verbose (debug) logging. " "no arg: logging.ERROR, " "'-v': logging.WARNING," "'-vv': logging.INFO," "'-vvv': logging.DEBUG " "'--verbose': this is the same as '-v' " "which is logging.WARNING. " "The default log level is logging.ERROR.") debug_level_excl_group.add_argument("-q", "--quiet", action="store_const", const=1, dest="log_level", help="Silent mode, only log warnings.") write_excl_group = parser.add_mutually_exclusive_group() write_excl_group.add_argument("-f", "--force", action="store_true", help="Try to override existing file.") write_excl_group.add_argument("--dry-run", action="store_true", help="Do not write anything.") # parser.add_argument('--image_width', type=int, default=None, # help="the with of the image, has to be bigger than" # " (tile_width + spacing) * num_tiles_x - spacing + 2 * margin" # ", otherwise warning is logged. Should be greater than 0.") # parser.add_argument('--image_height', type=int, default=None, # help="the height of the image, has to be bigger than " # "(tile_height + spacing) * num_tiles_y - spacing + 2 * margin" # ", otherwise a warning is logged. Should be greater than 0.") # parser.add_argument('--back_ground_color', default="FF00FFFF", # help="The color used for the margin and the spacing between the tiles, format is 'rrggbbaa'") # parser.add_argument('--color', default="000000FF", # help="Color of the tiles, format is 'rrggbbaa'") # parser.add_argument("--alt_color", default=None, # help="The alternate color for the tiles, format is 'rrggbbaa'") parser.add_argument("-i", "--save_to_image", action="store_true", help="Generates and saves the image file.") # # TODO: just change the orientation since no absolute orientation can be given (display configuration) # parser.add_argument("-c", "--cc_wise", type=bool, default=False, help="Saves the points of the sprites " # "in " # "counter clock wise order " # "(independent how they where " # "provided).") # TODO: better help for properties argument # TODO: document merge! _properties_help = "Provide a string describing a dictionary or a file name of a json file. " \ "In both cases they should provide a " \ "dictionary containing the properties that should be " \ "set for some or all sprites. The string (dictionary) has following " \ "structure: {'gid_key':{'prop_key':'prop_value'}}. MAKE SURE TO REMOVE ANY " \ "WHITESPACES! (otherwise you get a strange arg parse error)." \ " In a big spritesheet it might be a " \ "long list therefore the slice or range notation (start, stop, step) is accepted for " \ "the gid_key " \ "(think of it as a slice of range(N)[slice]), e.g.:" \ "'1' => [1]; " \ "'0:' => [0, 1, 2, 3, ..., N-1]; " \ "'0:3' => [0, 1, 2]; " \ "'0:10:2' => [0, 2, 4, 6, 8]; " \ "'0::2' => [0, 2, 4, 6, ..., N-2] (if N even, otherwise N-1); " \ "':5' => [0, 1, 2, 3, 4]; " \ "'::2' => see '0::2'; " \ "':' => [0, 1, 2, ..., N-1]; " \ "Negative steps are not allowed as ':' in keys other than gid range notation." \ "Note that when putting the dict into a json file, the gid_key need to be strings. " \ "Also the same gid might be addressed in multiple keys with following limitation:" \ "the the defined dictionaries will be merged as long they don't have the same " \ "key defined. Here an example:" \ "props={'1': {'k1': v1}, '0:': {'k2' : v2}} will generate internally following: " \ "{'1': {'k1': v1, 'k2': v2}, '2': {'k2': v2}, '2': {'k2': v2}, ...'N': {'k2': v2}. The " \ "following will all generate errors: " \ "props={'1': {'k1': v1}, '0:': {'k1': v3, 'k2' : v2}} <= 'k1' is duplicated for " \ "gid '1' or props={'1': {'k1': v1, 'k1': vx}} <= duplicated key 'k1' for gid '1'" \ "" parser.add_argument("-p", "--properties", default=None, help=_properties_help) parser.add_argument("--version", action="version", version="%(prog)s " + str(__version__)) parsed_args = parser.parse_args(commandline_args) log_levels = [logging.ERROR, logging.WARNING, logging.INFO, logging.DEBUG] if parsed_args.dry_run: parsed_args.log_level = 3 if parsed_args.log_level >= len(log_levels): parsed_args.log_level = len(log_levels) - 1 self.logger.level = log_levels[parsed_args.log_level] or self.logger.level self.logger.debug("Passed in commandline arguments: \n" + str(commandline_args)) self.logger.debug("Parsed arguments: \n" + str(parsed_args)) return parsed_args
[docs] def save_to_disk(self, image_file_info, sprite_sheet_mask, sprite_definition_file_info, target_sprite_definition, dry_run, force, save_image): """ Saves the sprite definition and image to disk. :param image_file_info: The file info for the image. :param sprite_sheet_mask: The surface to save. :param sprite_definition_file_info: The file info for the sprite definition. :param target_sprite_definition: The sprite definition to save. :param dry_run: If True then only the log entries are written, otherwise do the real work (on disk). :param force: If True then overwrite existing files, otherwise nothing is written. :param save_image: If True then the image is written to disk, otherwise not. """ if dry_run: self.logger.warn("dry run, nothing written to disk") return else: if image_file_info.exists or sprite_definition_file_info.exists: if force: self.logger.info("Forcing override of files: {0}, {1}", image_file_info.full_name, sprite_definition_file_info.full_name) else: self.logger.error("No files not overridden, specify -f to override.") return else: self.logger.debug("Writing new files to disk: {0}, {1}", image_file_info.full_name, sprite_definition_file_info.full_name) self.spritesheet_lib.save_sprite_definition_to_disk(target_sprite_definition, sprite_definition_file_info) if not save_image: self.logger.debug("save image: False ") return self.spritesheet_lib.save_image_to_disk(sprite_sheet_mask, image_file_info) # noinspection PyMethodMayBeStatic
def _parse_properties_to_dict(self, properties_string): # this is needed since json expects double quotes but only single quotes can be used on the command line properties_string = properties_string.replace("'", '"') def _parse_object_pairs(pairs): dct = {} for key, value in pairs: if key in dct: current = dct[key] if isinstance(value, dict): for sub_key in list(value.keys()): if sub_key in current: _message = "Merge for '{0}' failed because of duplicated key '{1}:{2}'" raise DictParseError(_message.format(current, sub_key, value[sub_key])) current.update(value) dct[key] = current else: raise DictParseError("Key '{0}:{1}' already exists in dict: {2}".format(key, value, dct)) else: dct[key] = value return dct # noinspection PyUnusedLocal try: with FileInfo(properties_string).open(FileMode.Read) as fp: arg_properties = self.json_module.load(fp, object_pairs_hook=_parse_object_pairs) return arg_properties except IOError as ie: arg_properties = self.json_module.loads(properties_string, object_pairs_hook=_parse_object_pairs) return arg_properties # noinspection PyMethodMayBeStatic def _parse_key(self, arg_key, sprite_count): if len(arg_key) == 0: raise DictParseError("Properties: empty key found!") try: start = 0 stop = sprite_count step = 1 if arg_key != ":" and arg_key != "::": parts = arg_key.split(":") if len(parts[0]) != 0: start = int(parts[0]) if len(parts) == 1: if start < 0: start += stop stop = start + 1 if len(parts) >= 2: if len(parts[1]) != 0: stop = int(parts[1]) if len(parts) == 3: if len(parts[2]) != 0: step = int(parts[2]) if len(parts) > 3: raise DictParseError("Properties: Malformed start:stop:step {0}".format(arg_key)) if step < 0: raise DictParseError("Properties: Negative step is not allowed: {0}".format(arg_key)) gid_keys = list(range(start, stop, step)) except ValueError as ve: raise _ParseKeyError(ve) return gid_keys def _parse_properties(self, properties_string, source_sprite_def): if properties_string is None: return None arg_properties = self._parse_properties_to_dict(properties_string) properties = {} for arg_key, arg_item in list(arg_properties.items()): try: sprite_count = len(source_sprite_def[self.spritesheet_lib.ELEMENTS.SPRITES]) gid_keys = self._parse_key(arg_key, sprite_count) # noinspection PyUnusedLocal # slicing arg_key for gid_key in gid_keys: if gid_key in properties: # raise DictParseError("Properties: gid already in properties dict: {0}".format(gid_key)) current = properties[gid_key] for sub_key in list(arg_item.keys()): if sub_key in current: _message = "For GID '{0}' property '{1}:{2}' already exists and " \ "cannot be replaced with '{1}:{3}'." _message = _message.format(gid_key, sub_key, current[sub_key], arg_item[sub_key]) raise DictParseError(_message) current.update(arg_item) properties[gid_key] = current # properties.update() else: properties[gid_key] = dict(arg_item) except _ParseKeyError: # non gid or gid-slice keys if ":" in arg_key: raise DictParseError("Properties: ':' is not allowed in non gid keys: {0}".format(arg_key)) properties[arg_key] = arg_item return properties
[docs] def main(self, args=None): """ The main function of the program (the entry point). :param args: The command line arguments. When None the sys.argv arguments are used, otherwise the passed list of strings. For a description of the arguments use the help. """ arguments = self._parse_arguments(args) sprite_definition_file_info = FileInfo(arguments.filename) if arguments.command == self.GRID_COMMAND: source_sprite_def = self.spritesheet_lib.create_grid(arguments.num_tiles_x, arguments.num_tiles_y, arguments.tile_width, arguments.tile_height, sprite_definition_file_info ) elif arguments.command == self.CREATE_FROM_FILE_COMMAND: source_sprite_def = self.spritesheet_lib.load_sdef_from_file(FileInfo(arguments.from_file)) else: msg = "Unknown command: " + arguments.command self.logger.error(msg) raise SystemExit(msg) arguments_properties = self._parse_properties(arguments.properties, source_sprite_def) self.spritesheet_lib.update_sprite_properties(source_sprite_def, arguments_properties) # TODO: validate before updating properties?? or just valid also the changes of the properties? if not self.spritesheet_lib.is_sprite_def_valid(source_sprite_def): msg = "Invalid sprite definition from command " + arguments.command self.logger.error(msg) raise SystemExit(msg) target_sprite_definition = self.spritesheet_lib.adjust_spacing(source_sprite_def, arguments.spacing) target_sprite_definition = self.spritesheet_lib.adjust_margin(target_sprite_definition, arguments.margin) if not self.spritesheet_lib.is_sprite_def_valid(target_sprite_definition): msg = "Invalid sprite definition after converting" self.logger.error(msg) raise SystemExit(msg) sprite_sheet_mask = None if arguments.save_to_image: sprite_sheet_mask = self.spritesheet_lib.create_image(target_sprite_definition) image_file_name = self.spritesheet_lib.get_image_name_from_sdef_name(arguments.filename) image_file_info = FileInfo(image_file_name) self.save_to_disk(image_file_info, sprite_sheet_mask, sprite_definition_file_info, target_sprite_definition, arguments.dry_run, arguments.force, arguments.save_to_image)