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/mkasset.py | |
parent | 6ca52695de52c432feafbe3d8c8e25cbd8d8ec34 (diff) |
Add support for single-image tile sets (not yet tested).
Diffstat (limited to 'tools/mkasset.py')
-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 | ||