sotn-decomp/tools/spritesheet.py

166 lines
5.5 KiB
Python
Raw Normal View History

Add spritesheet tool (#441) Creates a tool in python that assembles a spritesheet descriptor (e.g. `assets/ric/richter.spritesheet.json`) and its individual sprites into a single spritesheet as PNG. Values such as horizontal and vertical alignment are preserved and the palette of the spritesheet is copied from the very first sprite. This can be achieved by running `./tools/spritesheet.py merge assets/ric/richter.spritesheet.json richter.png`. The tool is also able to do a round trip, by splitting the spritesheet into their individual sprites and recrearting the spritesheet descriptor with `/tools/spritesheet.py split richter.png assets/ric/`. Note this will not re-create the `padding` field as that seems to be some kind of left-over. I do not think it is important at all as this tool is for modding and not for matching. Last, but not least, I made minor tweaks to the script to build back the individual frames. I noticed I was performing extra sanity checks purely based on the specific output I was initially getting from the original game files. For instance I was previously forcing the sprites to be exactly 16 colours for a 8-bit PNG. The change makes the checks more lax by allowing 4-bit PNGs and less than 16 colours. --- How to mod: ``` make extract_ric ./tools/spritesheet.py merge assets/ric/richter.spritesheet.json richter.png ``` Now you can modify your `richter.png` spritesheet. To test it back in-game do: ``` ./tools/spritesheet.py split richter.png assets/ric/ make ric make disk ```
2023-08-06 06:14:14 +00:00
#!/usr/bin/python3
import argparse
import json
import os
from PIL import Image
import sys
max_sprite_per_row = 8
max_width = 96
max_height = 96
def is_spritesheet_desc_sane(spritesheet_desc):
for entry in spritesheet_desc:
if os.path.isfile(entry["name"]) == False:
return False, entry["name"]
return True, None
def merge(spritesheet_desc, out_path):
# performs an integrity check before committing to the algorithm
is_sane, missing_file_path = is_spritesheet_desc_sane(spritesheet_desc)
if is_sane == False:
raise ValueError(f"one or more files are missing: '{missing_file_path}'")
sprite_count = len(spritesheet_desc)
img_width = max_width * max_sprite_per_row
img_height = (
(sprite_count + max_sprite_per_row - 1) // max_sprite_per_row * max_height
)
spritesheet = Image.new("P", (img_width, img_height))
with Image.open(spritesheet_desc[0]["name"]) as first_sprite:
spritesheet.putpalette(first_sprite.palette)
spritesheet.info["transparency"] = 0
for n_sprite, entry in enumerate(spritesheet_desc):
sprite = Image.open(entry["name"])
width, height = sprite.size
if width > max_width or height > max_height:
raise ValueError(
f"All images must be {max_width}x{max_height} or smaller: '{entry['name']}' is {width}x{height}"
)
offset_x = int(n_sprite % max_sprite_per_row) * max_width
offset_y = n_sprite // max_sprite_per_row * max_height
center_x = entry["x"]
center_y = entry["y"]
if center_x + width > max_width or center_y + height > max_height:
raise ValueError(
(
f"Sprite '{entry['name']}' is out of bounds:\n"
f"cx:{center_x} + w:{width} > mw:{max_width}\n"
f"cy:{center_y} + h:{height} > mh:{max_height}"
)
)
spritesheet.paste(sprite, (offset_x + center_x, offset_y + center_y))
sprite.close()
spritesheet.save(out_path)
spritesheet.close()
def align_bbox(bbox):
sx, sy, ex, ey = bbox
w = ex - sx
h = ey - sy
x_excess = sx & 1
sx -= x_excess
w += x_excess
w = ((w + 3) // 4) * 4
return (sx, sy, sx + w, sy + h)
def split_sprite(spritesheet, x, y):
sprite = spritesheet.crop((x, y, x + max_width, y + max_height))
bbox = sprite.getbbox()
if bbox == None:
return (None, 0, 0)
aligned_bbox = align_bbox(bbox)
x, y, _, _ = aligned_bbox
return (sprite.crop(aligned_bbox), x, y)
def split(name, spritesheet, out_path):
width, height = spritesheet.size
if width != max_width * max_sprite_per_row:
raise ValueError(
f"The spritesheet must have a width of {max_width * max_sprite_per_row} but found {width}"
)
if (height % max_height) > 0:
raise ValueError(
f"The spritesheet must have a height multiple of {max_height} but found {height}"
)
sprite_count = (width // max_width) * (height // max_height)
spritesheet_desc = []
for n_sprite in range(0, sprite_count):
offset_x = int(n_sprite % max_sprite_per_row) * max_width
offset_y = n_sprite // max_sprite_per_row * max_height
sprite, x, y = split_sprite(spritesheet, offset_x, offset_y)
if sprite == None:
break
out_sprite_path = os.path.join(out_path, f"{name}_{n_sprite}.png")
sprite.save(out_sprite_path)
spritesheet_desc.append(
{
"x": x,
"y": y,
"name": out_sprite_path,
}
)
with open(os.path.join(out_path, f"{name}.spritesheet.json"), "w") as f:
json.dump(spritesheet_desc, f, indent=4)
if __name__ == "__main__":
parser = argparse.ArgumentParser(description="Manipulate a spritesheet for modding")
subparsers = parser.add_subparsers(dest="command")
merge_parser = subparsers.add_parser(
"merge", description="Merge all the individual sprites into a single PNG."
)
merge_parser.add_argument(
"json_file",
type=argparse.FileType("r"),
help="The JSON file path that contains the spritesheet info (eg. 'assets/ric/richter.spritesheet.json')",
)
merge_parser.add_argument(
"output_path",
help="File path where to store the assembled spritesheet",
)
split_parser = subparsers.add_parser(
"split",
description=(
"Split a spritesheet PNG back to their individual sprites.\n"
"The individual sprites will be stored to the same path of "
"<json_file> and their names will follow the name of the input_path."
"\ne.g. sat_ric.png as input will produce sat_ric_0.png and so on."
),
)
split_parser.add_argument(
"input_file_path",
help="Spritesheet file path to split",
)
split_parser.add_argument(
"output_path",
help="Path where to store the JSON descriptor and the spliited sprites",
)
args = parser.parse_args()
if args.command == "merge":
with args.json_file as file_in:
merge(json.loads(file_in.read()), args.output_path)
elif args.command == "split":
file_name, ext = os.path.splitext(os.path.basename(args.input_file_path))
with Image.open(args.input_file_path) as spritesheet:
if os.path.exists(args.output_path) == False:
os.mkdir(args.output_path)
split(file_name, spritesheet, args.output_path)