From db3fd5aac9c66e64aa56fabc1a865fb64a65fc1c Mon Sep 17 00:00:00 2001 From: 3gg <3gg@shellblade.net> Date: Mon, 1 Sep 2025 14:50:07 -0700 Subject: Add support for single-image tile sets (not yet tested). --- tools/mkasset.py | 195 +++++++++++++++++++++++++++++++++++++++---------------- 1 file changed, 138 insertions(+), 57 deletions(-) (limited to 'tools/mkasset.py') diff --git a/tools/mkasset.py b/tools/mkasset.py index ae27ead..9b9dc76 100644 --- a/tools/mkasset.py +++ b/tools/mkasset.py @@ -13,6 +13,7 @@ import argparse import ctypes import sys +from typing import Generator from xml.etree import ElementTree from PIL import Image @@ -26,7 +27,7 @@ def drop_extension(filepath): return filepath[:filepath.rfind('.')] -def to_char_array(string, length): +def to_char_array(string, length) -> bytes: """Convert a string to a fixed-length ASCII char array. The length of str must be at most length-1 so that the resulting string can @@ -38,58 +39,134 @@ def to_char_array(string, length): return chars + nulls +def load_image(path) -> bytes: + """Load an image as an array of RGBA bytes.""" + with Image.open(path) as im: + return im.convert('RGBA').tobytes() + + +def carve_image(rgba_bytes, tile_width, tile_height, columns) -> Generator[bytearray]: + """Carve out individual tiles from a single-image tile set.""" + # Image dimensions in pixels. + image_width = columns * tile_width + image_height = len(rgba_bytes) // image_width // 4 + + tiles_x = image_width // tile_width + tiles_y = image_height // tile_height + + tile_bytes = bytearray(tile_width * tile_height * 4) + for i in range(tiles_y): + image_y0 = i * tile_height # y-origin of tile inside image + for j in range(tiles_x): + image_x0 = j * tile_width # x-origin of tile inside image + for y in range(tile_height): + image_y = image_y0 + y # y of current pixel inside image + for x in range(tile_width): + image_x = image_x0 + x # x of current pixel inside image + tile_bytes[(y * tile_width + x) * 4] = ( + rgba_bytes)[(image_y * image_width + image_x) * 4] + yield tile_bytes.copy() + + def convert_tsx(input_filepath, output_filepath): """Converts a Tiled .tsx tileset file to a .TS tile set file.""" xml = ElementTree.parse(input_filepath) - root = xml.getroot() + tileset = xml.getroot() + assert (tileset.tag == "tileset") - tile_count = int(root.attrib["tilecount"]) - max_tile_width = int(root.attrib["tilewidth"]) - max_tile_height = int(root.attrib["tileheight"]) + # Header. + tileset_tile_count = int(tileset.attrib["tilecount"]) + tileset_tile_width = int(tileset.attrib["tilewidth"]) + tileset_tile_height = int(tileset.attrib["tileheight"]) - print(f"Tile count: {tile_count}") - print(f"Max width: {max_tile_width}") - print(f"Max height: {max_tile_height}") + print(f"Tile count: {tileset_tile_count}") + print(f"Tile width: {tileset_tile_width}") + print(f"Tile height: {tileset_tile_height}") pixels = [] # List of byte arrays pixels_offset = 0 + def output_tile(output, tile_width, tile_height, tile_image_bytes): + # Expecting RGBA pixels. + assert (len(tile_image_bytes) == (tile_width * tile_height * 4)) + + nonlocal pixels_offset + tile_pixels_offset = pixels_offset + pixels.append(tile_image_bytes) + pixels_offset += len(tile_image_bytes) + + output.write(ctypes.c_uint16(tile_width)) + output.write(ctypes.c_uint16(tile_height)) + output.write(ctypes.c_uint32(tile_pixels_offset)) + with open(output_filepath, 'bw') as output: # Write the header. - output.write(ctypes.c_uint16(tile_count)) - # output.write(ctypes.c_uint16(max_tile_width)) - # output.write(ctypes.c_uint16(max_tile_height)) + output.write(ctypes.c_uint16(tileset_tile_count)) + output.write(ctypes.c_uint16(tileset_tile_width)) + output.write(ctypes.c_uint16(tileset_tile_height)) output.write(ctypes.c_uint16(0)) # Pad. + # A tileset made up of multiple images contains various 'tile' children, + # each with their own 'image': + # + # + # + # + # + # + # + # + # A tileset made up of a single image contains a single 'image' child: + # + # num_tile = 0 - for tile in root: - # Skip the "grid" and other non-tile elements. - if not tile.tag == "tile": - continue - - # Assuming tiles are numbered 0..N. - tile_id = int(tile.attrib["id"]) - assert (tile_id == num_tile) - num_tile += 1 - - image = tile[0] - tile_width = int(image.attrib["width"]) - tile_height = int(image.attrib["height"]) - tile_path = image.attrib["source"] - - assert (tile_width > 0) - assert (tile_height > 0) - - tile_pixels_offset = pixels_offset - with Image.open(tile_path) as im: - bytes = im.convert('RGBA').tobytes() - pixels.append(bytes) - pixels_offset += len(bytes) - - # Write the tile. - output.write(ctypes.c_uint16(tile_width)) - output.write(ctypes.c_uint16(tile_height)) - output.write(ctypes.c_uint32(tile_pixels_offset)) + for child in tileset: + if child.tag == "image": + # This is a single-image tileset. + image = child + image_path = image.attrib["source"] + image_bytes = load_image(image_path) + + # We expect the 'columns' attribute to be >0 for a single-image + # tile set. + columns = int(tileset.attrib["columns"]) + assert (columns > 0) + + # Tile dimensions are those given by the tileset's baseline width and height. + tile_width = tileset_tile_width + tile_height = tileset_tile_height + + # Cut each of the WxH tiles, and store them in the output in "tile order". + for tile_bytes in carve_image(image_bytes, tile_width, tile_height, columns): + output_tile(output, tile_width, tile_height, tile_bytes) + + # Make sure not to process 'tile' elements, which may exist in + # a single-image tileset to define probabilities, for example. + break + + elif child.tag == "tile": + # This is a tile image in a tileset made of a collection of images. + tile = child + + # We assume that tiles are numbered 0..N-1. Assert this. + tile_id = int(tile.attrib["id"]) + assert (tile_id == num_tile) + num_tile += 1 + + image = tile[0] + tile_width = int(image.attrib["width"]) + tile_height = int(image.attrib["height"]) + image_path = image.attrib["source"] + + # Tile dimensions must be a multiple of the tileset's baseline + # tile width and height. + assert (tile_width > 0) + assert (tile_height > 0) + assert ((tile_width % tileset_tile_width) == 0) + assert ((tile_height % tileset_tile_height) == 0) + + image_bytes = load_image(image_path) + output_tile(output, tile_width, tile_height, image_bytes) # Write the pixel data. for bytes in pixels: @@ -273,6 +350,8 @@ def convert_sprite_sheet(input_file_paths, sprite_width, sprite_height, # getpalette() also returns a flattened list, which is why we must *4. num_colours = len(im.getcolors()) colours = im.getpalette(rawmode="RGBA")[:4 * num_colours] + # TODO: This palette list does not seem really necessary. + # Define palette = bytearray(im.getpalette(...)) palette = [] for i in range(0, 4 * num_colours, 4): palette.append((colours[i], colours[i + 1], colours[i + 2], @@ -310,36 +389,38 @@ def convert_sprite_sheet(input_file_paths, sprite_width, sprite_height, def main(): - # TODO: Use subparser for each type of input file. + # TODO: Use a subparser for each type of input file for clarity of arguments. parser = argparse.ArgumentParser() - parser.add_argument("input", - nargs="+", - help="Input file (.tsx, .tmx) or path regex (sprite sheets)") - parser.add_argument("-W", "--width", type=int, help="Sprite width in pixels") - parser.add_argument("-H", "--height", type=int, help="Sprite height in pixels") - parser.add_argument("-o", "--out", help="Output file (sprite sheets)") + subparsers = parser.add_subparsers(dest="command") + # Tile sets. + tileset_parser = subparsers.add_parser("tileset") + tileset_parser.add_argument("input", help="Input file (.tsx)") + # Tile maps. + tilemap_parser = subparsers.add_parser("tilemap") + tilemap_parser.add_argument("input", help="Input file (.tmx)") + # Sprite sheet. + sprite_parser = subparsers.add_parser("sprite") + sprite_parser.add_argument("input", nargs="+", help="Input files") + sprite_parser.add_argument("-W", "--width", type=int, required=True, help="Sprite width in pixels") + sprite_parser.add_argument("-H", "--height", type=int, required=True, help="Sprite height in pixels") + sprite_parser.add_argument("-o", "--out", help="Output file") args = parser.parse_args() - # TODO: Add support for TSX files made from a single image. Currently, only collections are supported. - if ".tsx" in args.input[0]: - args.input = args.input[0] # TODO: Remove this. + if args.command == "tileset": + print(f"Processing: {args.input}") output_filepath_no_ext = drop_extension(args.input) output_filepath = output_filepath_no_ext + ".ts" convert_tsx(args.input, output_filepath) - elif ".tmx" in args.input[0]: - args.input = args.input[0] # TODO: Remove this. + elif args.command == "tilemap": + print(f"Processing: {args.input}") output_filepath_no_ext = drop_extension(args.input) output_filepath = output_filepath_no_ext + ".tm" convert_tmx(args.input, output_filepath) - else: - # Sprite sheets. - if not args.width or not args.height: - print("Sprite width and height must be given") - return 1 + elif args.command == "sprite": + print(f"Processing: {args.input}") output_filepath = args.out if args.out else "out.ss" convert_sprite_sheet(args.input, args.width, args.height, output_filepath) - return 0 -- cgit v1.2.3