#!/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)