mirror of
https://github.com/Xeeynamo/sotn-decomp.git
synced 2024-12-01 00:40:51 +00:00
313841f9a6
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 ```
166 lines
5.5 KiB
Python
Executable File
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)
|