sotn-decomp/tools/spritesheet.py
Luciano Ciccariello 313841f9a6
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 07:14:14 +01:00

166 lines
5.5 KiB
Python
Executable File

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