diff options
| author | 3gg <3gg@shellblade.net> | 2025-09-01 14:50:07 -0700 |
|---|---|---|
| committer | 3gg <3gg@shellblade.net> | 2025-09-01 14:50:27 -0700 |
| commit | db3fd5aac9c66e64aa56fabc1a865fb64a65fc1c (patch) | |
| tree | 36f84f93e37b517dfd6bab7fc322695e96fe0181 /tools | |
| parent | 6ca52695de52c432feafbe3d8c8e25cbd8d8ec34 (diff) | |
Add support for single-image tile sets (not yet tested).
Diffstat (limited to 'tools')
| -rw-r--r-- | tools/mkasset.py | 195 |
1 files changed, 138 insertions, 57 deletions
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 @@ | |||
| 13 | import argparse | 13 | import argparse |
| 14 | import ctypes | 14 | import ctypes |
| 15 | import sys | 15 | import sys |
| 16 | from typing import Generator | ||
| 16 | from xml.etree import ElementTree | 17 | from xml.etree import ElementTree |
| 17 | 18 | ||
| 18 | from PIL import Image | 19 | from PIL import Image |
| @@ -26,7 +27,7 @@ def drop_extension(filepath): | |||
| 26 | return filepath[:filepath.rfind('.')] | 27 | return filepath[:filepath.rfind('.')] |
| 27 | 28 | ||
| 28 | 29 | ||
| 29 | def to_char_array(string, length): | 30 | def to_char_array(string, length) -> bytes: |
| 30 | """Convert a string to a fixed-length ASCII char array. | 31 | """Convert a string to a fixed-length ASCII char array. |
| 31 | 32 | ||
| 32 | The length of str must be at most length-1 so that the resulting string can | 33 | 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): | |||
| 38 | return chars + nulls | 39 | return chars + nulls |
| 39 | 40 | ||
| 40 | 41 | ||
| 42 | def load_image(path) -> bytes: | ||
| 43 | """Load an image as an array of RGBA bytes.""" | ||
| 44 | with Image.open(path) as im: | ||
| 45 | return im.convert('RGBA').tobytes() | ||
| 46 | |||
| 47 | |||
| 48 | def carve_image(rgba_bytes, tile_width, tile_height, columns) -> Generator[bytearray]: | ||
| 49 | """Carve out individual tiles from a single-image tile set.""" | ||
| 50 | # Image dimensions in pixels. | ||
| 51 | image_width = columns * tile_width | ||
| 52 | image_height = len(rgba_bytes) // image_width // 4 | ||
| 53 | |||
| 54 | tiles_x = image_width // tile_width | ||
| 55 | tiles_y = image_height // tile_height | ||
| 56 | |||
| 57 | tile_bytes = bytearray(tile_width * tile_height * 4) | ||
| 58 | for i in range(tiles_y): | ||
| 59 | image_y0 = i * tile_height # y-origin of tile inside image | ||
| 60 | for j in range(tiles_x): | ||
| 61 | image_x0 = j * tile_width # x-origin of tile inside image | ||
| 62 | for y in range(tile_height): | ||
| 63 | image_y = image_y0 + y # y of current pixel inside image | ||
| 64 | for x in range(tile_width): | ||
| 65 | image_x = image_x0 + x # x of current pixel inside image | ||
| 66 | tile_bytes[(y * tile_width + x) * 4] = ( | ||
| 67 | rgba_bytes)[(image_y * image_width + image_x) * 4] | ||
| 68 | yield tile_bytes.copy() | ||
| 69 | |||
| 70 | |||
| 41 | def convert_tsx(input_filepath, output_filepath): | 71 | def convert_tsx(input_filepath, output_filepath): |
| 42 | """Converts a Tiled .tsx tileset file to a .TS tile set file.""" | 72 | """Converts a Tiled .tsx tileset file to a .TS tile set file.""" |
| 43 | xml = ElementTree.parse(input_filepath) | 73 | xml = ElementTree.parse(input_filepath) |
| 44 | root = xml.getroot() | 74 | tileset = xml.getroot() |
| 75 | assert (tileset.tag == "tileset") | ||
| 45 | 76 | ||
| 46 | tile_count = int(root.attrib["tilecount"]) | 77 | # Header. |
| 47 | max_tile_width = int(root.attrib["tilewidth"]) | 78 | tileset_tile_count = int(tileset.attrib["tilecount"]) |
| 48 | max_tile_height = int(root.attrib["tileheight"]) | 79 | tileset_tile_width = int(tileset.attrib["tilewidth"]) |
| 80 | tileset_tile_height = int(tileset.attrib["tileheight"]) | ||
| 49 | 81 | ||
| 50 | print(f"Tile count: {tile_count}") | 82 | print(f"Tile count: {tileset_tile_count}") |
| 51 | print(f"Max width: {max_tile_width}") | 83 | print(f"Tile width: {tileset_tile_width}") |
| 52 | print(f"Max height: {max_tile_height}") | 84 | print(f"Tile height: {tileset_tile_height}") |
| 53 | 85 | ||
| 54 | pixels = [] # List of byte arrays | 86 | pixels = [] # List of byte arrays |
| 55 | pixels_offset = 0 | 87 | pixels_offset = 0 |
| 56 | 88 | ||
| 89 | def output_tile(output, tile_width, tile_height, tile_image_bytes): | ||
| 90 | # Expecting RGBA pixels. | ||
| 91 | assert (len(tile_image_bytes) == (tile_width * tile_height * 4)) | ||
| 92 | |||
| 93 | nonlocal pixels_offset | ||
| 94 | tile_pixels_offset = pixels_offset | ||
| 95 | pixels.append(tile_image_bytes) | ||
| 96 | pixels_offset += len(tile_image_bytes) | ||
| 97 | |||
| 98 | output.write(ctypes.c_uint16(tile_width)) | ||
| 99 | output.write(ctypes.c_uint16(tile_height)) | ||
| 100 | output.write(ctypes.c_uint32(tile_pixels_offset)) | ||
| 101 | |||
| 57 | with open(output_filepath, 'bw') as output: | 102 | with open(output_filepath, 'bw') as output: |
| 58 | # Write the header. | 103 | # Write the header. |
| 59 | output.write(ctypes.c_uint16(tile_count)) | 104 | output.write(ctypes.c_uint16(tileset_tile_count)) |
| 60 | # output.write(ctypes.c_uint16(max_tile_width)) | 105 | output.write(ctypes.c_uint16(tileset_tile_width)) |
| 61 | # output.write(ctypes.c_uint16(max_tile_height)) | 106 | output.write(ctypes.c_uint16(tileset_tile_height)) |
| 62 | output.write(ctypes.c_uint16(0)) # Pad. | 107 | output.write(ctypes.c_uint16(0)) # Pad. |
| 63 | 108 | ||
| 109 | # A tileset made up of multiple images contains various 'tile' children, | ||
| 110 | # each with their own 'image': | ||
| 111 | # | ||
| 112 | # <tile id="0"> | ||
| 113 | # <image width="32" height="32" source="separated images/tile_000.png"/> | ||
| 114 | # </tile> | ||
| 115 | # <tile id="1"> | ||
| 116 | # <image width="32" height="32" source="separated images/tile_001.png"/> | ||
| 117 | # </tile> | ||
| 118 | # | ||
| 119 | # A tileset made up of a single image contains a single 'image' child: | ||
| 120 | # | ||
| 121 | # <image source="tileset.png" width="416" height="368"/> | ||
| 64 | num_tile = 0 | 122 | num_tile = 0 |
| 65 | for tile in root: | 123 | for child in tileset: |
| 66 | # Skip the "grid" and other non-tile elements. | 124 | if child.tag == "image": |
| 67 | if not tile.tag == "tile": | 125 | # This is a single-image tileset. |
| 68 | continue | 126 | image = child |
| 69 | 127 | image_path = image.attrib["source"] | |
| 70 | # Assuming tiles are numbered 0..N. | 128 | image_bytes = load_image(image_path) |
| 71 | tile_id = int(tile.attrib["id"]) | 129 | |
| 72 | assert (tile_id == num_tile) | 130 | # We expect the 'columns' attribute to be >0 for a single-image |
| 73 | num_tile += 1 | 131 | # tile set. |
| 74 | 132 | columns = int(tileset.attrib["columns"]) | |
| 75 | image = tile[0] | 133 | assert (columns > 0) |
| 76 | tile_width = int(image.attrib["width"]) | 134 | |
| 77 | tile_height = int(image.attrib["height"]) | 135 | # Tile dimensions are those given by the tileset's baseline width and height. |
| 78 | tile_path = image.attrib["source"] | 136 | tile_width = tileset_tile_width |
| 79 | 137 | tile_height = tileset_tile_height | |
| 80 | assert (tile_width > 0) | 138 | |
| 81 | assert (tile_height > 0) | 139 | # Cut each of the WxH tiles, and store them in the output in "tile order". |
| 82 | 140 | for tile_bytes in carve_image(image_bytes, tile_width, tile_height, columns): | |
| 83 | tile_pixels_offset = pixels_offset | 141 | output_tile(output, tile_width, tile_height, tile_bytes) |
| 84 | with Image.open(tile_path) as im: | 142 | |
| 85 | bytes = im.convert('RGBA').tobytes() | 143 | # Make sure not to process 'tile' elements, which may exist in |
| 86 | pixels.append(bytes) | 144 | # a single-image tileset to define probabilities, for example. |
| 87 | pixels_offset += len(bytes) | 145 | break |
| 88 | 146 | ||
| 89 | # Write the tile. | 147 | elif child.tag == "tile": |
| 90 | output.write(ctypes.c_uint16(tile_width)) | 148 | # This is a tile image in a tileset made of a collection of images. |
| 91 | output.write(ctypes.c_uint16(tile_height)) | 149 | tile = child |
| 92 | output.write(ctypes.c_uint32(tile_pixels_offset)) | 150 | |
| 151 | # We assume that tiles are numbered 0..N-1. Assert this. | ||
| 152 | tile_id = int(tile.attrib["id"]) | ||
| 153 | assert (tile_id == num_tile) | ||
| 154 | num_tile += 1 | ||
| 155 | |||
| 156 | image = tile[0] | ||
| 157 | tile_width = int(image.attrib["width"]) | ||
| 158 | tile_height = int(image.attrib["height"]) | ||
| 159 | image_path = image.attrib["source"] | ||
| 160 | |||
| 161 | # Tile dimensions must be a multiple of the tileset's baseline | ||
| 162 | # tile width and height. | ||
| 163 | assert (tile_width > 0) | ||
| 164 | assert (tile_height > 0) | ||
| 165 | assert ((tile_width % tileset_tile_width) == 0) | ||
| 166 | assert ((tile_height % tileset_tile_height) == 0) | ||
| 167 | |||
| 168 | image_bytes = load_image(image_path) | ||
| 169 | output_tile(output, tile_width, tile_height, image_bytes) | ||
| 93 | 170 | ||
| 94 | # Write the pixel data. | 171 | # Write the pixel data. |
| 95 | for bytes in pixels: | 172 | for bytes in pixels: |
| @@ -273,6 +350,8 @@ def convert_sprite_sheet(input_file_paths, sprite_width, sprite_height, | |||
| 273 | # getpalette() also returns a flattened list, which is why we must *4. | 350 | # getpalette() also returns a flattened list, which is why we must *4. |
| 274 | num_colours = len(im.getcolors()) | 351 | num_colours = len(im.getcolors()) |
| 275 | colours = im.getpalette(rawmode="RGBA")[:4 * num_colours] | 352 | colours = im.getpalette(rawmode="RGBA")[:4 * num_colours] |
| 353 | # TODO: This palette list does not seem really necessary. | ||
| 354 | # Define palette = bytearray(im.getpalette(...)) | ||
| 276 | palette = [] | 355 | palette = [] |
| 277 | for i in range(0, 4 * num_colours, 4): | 356 | for i in range(0, 4 * num_colours, 4): |
| 278 | palette.append((colours[i], colours[i + 1], colours[i + 2], | 357 | 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, | |||
| 310 | 389 | ||
| 311 | 390 | ||
| 312 | def main(): | 391 | def main(): |
| 313 | # TODO: Use subparser for each type of input file. | 392 | # TODO: Use a subparser for each type of input file for clarity of arguments. |
| 314 | parser = argparse.ArgumentParser() | 393 | parser = argparse.ArgumentParser() |
| 315 | parser.add_argument("input", | 394 | subparsers = parser.add_subparsers(dest="command") |
| 316 | nargs="+", | 395 | # Tile sets. |
| 317 | help="Input file (.tsx, .tmx) or path regex (sprite sheets)") | 396 | tileset_parser = subparsers.add_parser("tileset") |
| 318 | parser.add_argument("-W", "--width", type=int, help="Sprite width in pixels") | 397 | tileset_parser.add_argument("input", help="Input file (.tsx)") |
| 319 | parser.add_argument("-H", "--height", type=int, help="Sprite height in pixels") | 398 | # Tile maps. |
| 320 | parser.add_argument("-o", "--out", help="Output file (sprite sheets)") | 399 | tilemap_parser = subparsers.add_parser("tilemap") |
| 400 | tilemap_parser.add_argument("input", help="Input file (.tmx)") | ||
| 401 | # Sprite sheet. | ||
| 402 | sprite_parser = subparsers.add_parser("sprite") | ||
| 403 | sprite_parser.add_argument("input", nargs="+", help="Input files") | ||
| 404 | sprite_parser.add_argument("-W", "--width", type=int, required=True, help="Sprite width in pixels") | ||
| 405 | sprite_parser.add_argument("-H", "--height", type=int, required=True, help="Sprite height in pixels") | ||
| 406 | sprite_parser.add_argument("-o", "--out", help="Output file") | ||
| 321 | args = parser.parse_args() | 407 | args = parser.parse_args() |
| 322 | 408 | ||
| 323 | # TODO: Add support for TSX files made from a single image. Currently, only collections are supported. | 409 | if args.command == "tileset": |
| 324 | if ".tsx" in args.input[0]: | 410 | print(f"Processing: {args.input}") |
| 325 | args.input = args.input[0] # TODO: Remove this. | ||
| 326 | output_filepath_no_ext = drop_extension(args.input) | 411 | output_filepath_no_ext = drop_extension(args.input) |
| 327 | output_filepath = output_filepath_no_ext + ".ts" | 412 | output_filepath = output_filepath_no_ext + ".ts" |
| 328 | convert_tsx(args.input, output_filepath) | 413 | convert_tsx(args.input, output_filepath) |
| 329 | elif ".tmx" in args.input[0]: | 414 | elif args.command == "tilemap": |
| 330 | args.input = args.input[0] # TODO: Remove this. | 415 | print(f"Processing: {args.input}") |
| 331 | output_filepath_no_ext = drop_extension(args.input) | 416 | output_filepath_no_ext = drop_extension(args.input) |
| 332 | output_filepath = output_filepath_no_ext + ".tm" | 417 | output_filepath = output_filepath_no_ext + ".tm" |
| 333 | convert_tmx(args.input, output_filepath) | 418 | convert_tmx(args.input, output_filepath) |
| 334 | else: | 419 | elif args.command == "sprite": |
| 335 | # Sprite sheets. | 420 | print(f"Processing: {args.input}") |
| 336 | if not args.width or not args.height: | ||
| 337 | print("Sprite width and height must be given") | ||
| 338 | return 1 | ||
| 339 | output_filepath = args.out if args.out else "out.ss" | 421 | output_filepath = args.out if args.out else "out.ss" |
| 340 | convert_sprite_sheet(args.input, args.width, args.height, | 422 | convert_sprite_sheet(args.input, args.width, args.height, |
| 341 | output_filepath) | 423 | output_filepath) |
| 342 | |||
| 343 | return 0 | 424 | return 0 |
| 344 | 425 | ||
| 345 | 426 | ||
