mirror of
https://github.com/Xeeynamo/sotn-decomp.git
synced 2024-11-23 13:09:44 +00:00
8287841ccd
Ubuntu/Debian and Python recommend using virtual environments for project-specific Python dependencies and as of Ubuntu 24 this is lightly enforced when installing packages via pip. This is due to pip and the system package manager installing files to the same location which may cause either's internal state to no longer reflect what is actually installed. This updates the project to use a Python `virtualenv` for project dependencies and updates internal scripts to support both global and virtualenvs, but favors virtualenvs for new workspaces. All tools that hardcode `/usr/bin/python3` now use `env(1)` to find the first `python3` in the path. For those with a virtualenv configured, this will be the Python managed there. For everyone else, this should be the system Python or whatever other scheme they may have used previously (assuming `python3` already existed in their `PATH`). The `Makefile` has been updated to prepend `.venv/bin` to the `PATH` and use `python3` from there if it exists and has been configured. It also has a new `python-dependencies` target which will configure the venv and install all python dependnecies there. The `Dockerfile` has been updated to create an external `.venv` outside of the project directory. Python's `virtualenv`s are not relocatable and may hardcode paths which are mounted differently in the container and host. To deal with differences in paths between the container (which mounts the host's project directory to `/sotn`) host which may be at an arbitarary directory the `VENV_PATH` environment variable is used to override paths in the `Makefile`. GitHub workflows have been updated to use `.venv`.
166 lines
5.5 KiB
Python
Executable File
166 lines
5.5 KiB
Python
Executable File
#!/usr/bin/env 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)
|