diff options
Diffstat (limited to 'tools')
-rw-r--r-- | tools/mkasset.py | 218 |
1 files changed, 149 insertions, 69 deletions
diff --git a/tools/mkasset.py b/tools/mkasset.py index ae27ead..a402e3c 100644 --- a/tools/mkasset.py +++ b/tools/mkasset.py | |||
@@ -13,6 +13,8 @@ | |||
13 | import argparse | 13 | import argparse |
14 | import ctypes | 14 | import ctypes |
15 | import sys | 15 | import sys |
16 | from enum import IntEnum | ||
17 | from typing import Generator | ||
16 | from xml.etree import ElementTree | 18 | from xml.etree import ElementTree |
17 | 19 | ||
18 | from PIL import Image | 20 | from PIL import Image |
@@ -22,11 +24,17 @@ from PIL import Image | |||
22 | MAX_PATH_LENGTH = 128 | 24 | MAX_PATH_LENGTH = 128 |
23 | 25 | ||
24 | 26 | ||
27 | class Orientation(IntEnum): | ||
28 | """Map orientation. Must match Tm_Orientation in asset.h""" | ||
29 | Orthogonal = 0 | ||
30 | Isometric = 1 | ||
31 | |||
32 | |||
25 | def drop_extension(filepath): | 33 | def drop_extension(filepath): |
26 | return filepath[:filepath.rfind('.')] | 34 | return filepath[:filepath.rfind('.')] |
27 | 35 | ||
28 | 36 | ||
29 | def to_char_array(string, length): | 37 | def to_char_array(string, length) -> bytes: |
30 | """Convert a string to a fixed-length ASCII char array. | 38 | """Convert a string to a fixed-length ASCII char array. |
31 | 39 | ||
32 | The length of str must be at most length-1 so that the resulting string can | 40 | The length of str must be at most length-1 so that the resulting string can |
@@ -38,58 +46,132 @@ def to_char_array(string, length): | |||
38 | return chars + nulls | 46 | return chars + nulls |
39 | 47 | ||
40 | 48 | ||
49 | def load_image(path) -> bytes: | ||
50 | """Load an image as an array of RGBA bytes.""" | ||
51 | with Image.open(path) as im: | ||
52 | return im.convert('RGBA').tobytes() | ||
53 | |||
54 | |||
55 | def carve_image(rgba_bytes, tile_width, tile_height, columns) -> Generator[bytearray]: | ||
56 | """Carve out individual tiles from a single-image tile set.""" | ||
57 | # Image dimensions in pixels. | ||
58 | image_width = columns * tile_width | ||
59 | image_height = len(rgba_bytes) // image_width // 4 | ||
60 | |||
61 | tile_bytes = bytearray(tile_width * tile_height * 4) | ||
62 | for image_y0 in range(0, image_height, tile_height): # y-origin of tile inside image | ||
63 | for image_x0 in range(0, image_width, tile_width): # x-origin of tile inside image | ||
64 | for y in range(tile_height): | ||
65 | image_y = image_y0 + y # y of current pixel inside image | ||
66 | for x in range(tile_width): | ||
67 | image_x = image_x0 + x # x of current pixel inside image | ||
68 | for c in range(4): | ||
69 | tile_bytes[((y * tile_width + x) * 4) + c] = ( | ||
70 | rgba_bytes)[((image_y * image_width + image_x) * 4) + c] | ||
71 | yield tile_bytes.copy() | ||
72 | |||
73 | |||
74 | # TODO: Palettize it like we do for sprites. Use 2-byte indices to allow up to | ||
75 | # 65k colours. | ||
41 | def convert_tsx(input_filepath, output_filepath): | 76 | def convert_tsx(input_filepath, output_filepath): |
42 | """Converts a Tiled .tsx tileset file to a .TS tile set file.""" | 77 | """Converts a Tiled .tsx tileset file to a .TS tile set file.""" |
43 | xml = ElementTree.parse(input_filepath) | 78 | xml = ElementTree.parse(input_filepath) |
44 | root = xml.getroot() | 79 | tileset = xml.getroot() |
80 | assert (tileset.tag == "tileset") | ||
45 | 81 | ||
46 | tile_count = int(root.attrib["tilecount"]) | 82 | # Header. |
47 | max_tile_width = int(root.attrib["tilewidth"]) | 83 | tileset_tile_count = int(tileset.attrib["tilecount"]) |
48 | max_tile_height = int(root.attrib["tileheight"]) | 84 | tileset_tile_width = int(tileset.attrib["tilewidth"]) |
85 | tileset_tile_height = int(tileset.attrib["tileheight"]) | ||
49 | 86 | ||
50 | print(f"Tile count: {tile_count}") | 87 | print(f"Tile count: {tileset_tile_count}") |
51 | print(f"Max width: {max_tile_width}") | 88 | print(f"Tile width: {tileset_tile_width}") |
52 | print(f"Max height: {max_tile_height}") | 89 | print(f"Tile height: {tileset_tile_height}") |
53 | 90 | ||
54 | pixels = [] # List of byte arrays | 91 | pixels = [] # List of byte arrays |
55 | pixels_offset = 0 | 92 | pixels_offset = 0 |
56 | 93 | ||
94 | def output_tile(output, tile_width, tile_height, tile_image_bytes): | ||
95 | # Expecting RGBA pixels. | ||
96 | assert (len(tile_image_bytes) == (tile_width * tile_height * 4)) | ||
97 | |||
98 | nonlocal pixels_offset | ||
99 | tile_pixels_offset = pixels_offset | ||
100 | pixels.append(tile_image_bytes) | ||
101 | pixels_offset += len(tile_image_bytes) | ||
102 | |||
103 | output.write(ctypes.c_uint16(tile_width)) | ||
104 | output.write(ctypes.c_uint16(tile_height)) | ||
105 | output.write(ctypes.c_uint32(tile_pixels_offset)) | ||
106 | |||
57 | with open(output_filepath, 'bw') as output: | 107 | with open(output_filepath, 'bw') as output: |
58 | # Write the header. | 108 | # Write the header. |
59 | output.write(ctypes.c_uint16(tile_count)) | 109 | output.write(ctypes.c_uint16(tileset_tile_count)) |
60 | # output.write(ctypes.c_uint16(max_tile_width)) | 110 | output.write(ctypes.c_uint16(tileset_tile_width)) |
61 | # output.write(ctypes.c_uint16(max_tile_height)) | 111 | output.write(ctypes.c_uint16(tileset_tile_height)) |
62 | output.write(ctypes.c_uint16(0)) # Pad. | 112 | output.write(ctypes.c_uint16(0)) # Pad. |
63 | 113 | ||
114 | # A tileset made up of multiple images contains various 'tile' children, | ||
115 | # each with their own 'image': | ||
116 | # | ||
117 | # <tile id="0"> | ||
118 | # <image width="32" height="32" source="separated images/tile_000.png"/> | ||
119 | # </tile> | ||
120 | # <tile id="1"> | ||
121 | # <image width="32" height="32" source="separated images/tile_001.png"/> | ||
122 | # </tile> | ||
123 | # | ||
124 | # A tileset made up of a single image contains a single 'image' child: | ||
125 | # | ||
126 | # <image source="tileset.png" width="416" height="368"/> | ||
64 | num_tile = 0 | 127 | num_tile = 0 |
65 | for tile in root: | 128 | for child in tileset: |
66 | # Skip the "grid" and other non-tile elements. | 129 | if child.tag == "image": |
67 | if not tile.tag == "tile": | 130 | # This is a single-image tileset. |
68 | continue | 131 | image = child |
69 | 132 | image_path = image.attrib["source"] | |
70 | # Assuming tiles are numbered 0..N. | 133 | image_bytes = load_image(image_path) |
71 | tile_id = int(tile.attrib["id"]) | 134 | |
72 | assert (tile_id == num_tile) | 135 | # We expect the 'columns' attribute to be >0 for a single-image |
73 | num_tile += 1 | 136 | # tile set. |
74 | 137 | columns = int(tileset.attrib["columns"]) | |
75 | image = tile[0] | 138 | assert (columns > 0) |
76 | tile_width = int(image.attrib["width"]) | 139 | |
77 | tile_height = int(image.attrib["height"]) | 140 | # Tile dimensions are those given by the tileset's baseline width and height. |
78 | tile_path = image.attrib["source"] | 141 | tile_width = tileset_tile_width |
79 | 142 | tile_height = tileset_tile_height | |
80 | assert (tile_width > 0) | 143 | |
81 | assert (tile_height > 0) | 144 | # Cut each of the WxH tiles, and store them in the output in "tile order". |
82 | 145 | for tile_bytes in carve_image(image_bytes, tile_width, tile_height, columns): | |
83 | tile_pixels_offset = pixels_offset | 146 | output_tile(output, tile_width, tile_height, tile_bytes) |
84 | with Image.open(tile_path) as im: | 147 | |
85 | bytes = im.convert('RGBA').tobytes() | 148 | # Make sure not to process 'tile' elements, which may exist in |
86 | pixels.append(bytes) | 149 | # a single-image tileset to define probabilities, for example. |
87 | pixels_offset += len(bytes) | 150 | break |
88 | 151 | ||
89 | # Write the tile. | 152 | elif child.tag == "tile": |
90 | output.write(ctypes.c_uint16(tile_width)) | 153 | # This is a tile image in a tileset made of a collection of images. |
91 | output.write(ctypes.c_uint16(tile_height)) | 154 | tile = child |
92 | output.write(ctypes.c_uint32(tile_pixels_offset)) | 155 | |
156 | # We assume that tiles are numbered 0..N-1. Assert this. | ||
157 | tile_id = int(tile.attrib["id"]) | ||
158 | assert (tile_id == num_tile) | ||
159 | num_tile += 1 | ||
160 | |||
161 | image = tile[0] | ||
162 | tile_width = int(image.attrib["width"]) | ||
163 | tile_height = int(image.attrib["height"]) | ||
164 | image_path = image.attrib["source"] | ||
165 | |||
166 | # Tile dimensions must be a multiple of the tileset's baseline | ||
167 | # tile width and height. | ||
168 | assert (tile_width > 0) | ||
169 | assert (tile_height > 0) | ||
170 | assert ((tile_width % tileset_tile_width) == 0) | ||
171 | assert ((tile_height % tileset_tile_height) == 0) | ||
172 | |||
173 | image_bytes = load_image(image_path) | ||
174 | output_tile(output, tile_width, tile_height, image_bytes) | ||
93 | 175 | ||
94 | # Write the pixel data. | 176 | # Write the pixel data. |
95 | for bytes in pixels: | 177 | for bytes in pixels: |
@@ -106,11 +188,13 @@ def convert_tmx(input_filepath, output_filepath): | |||
106 | base_tile_width = int(root.attrib["tilewidth"]) | 188 | base_tile_width = int(root.attrib["tilewidth"]) |
107 | base_tile_height = int(root.attrib["tileheight"]) | 189 | base_tile_height = int(root.attrib["tileheight"]) |
108 | num_layers = 1 | 190 | num_layers = 1 |
191 | flags = Orientation.Isometric if (root.attrib["orientation"] == "isometric") else Orientation.Orthogonal | ||
109 | 192 | ||
110 | print(f"Map width: {map_width}") | 193 | print(f"Map width: {map_width}") |
111 | print(f"Map height: {map_height}") | 194 | print(f"Map height: {map_height}") |
112 | print(f"Tile width: {base_tile_width}") | 195 | print(f"Tile width: {base_tile_width}") |
113 | print(f"Tile height: {base_tile_height}") | 196 | print(f"Tile height: {base_tile_height}") |
197 | print(f"Orientation: {flags}") | ||
114 | 198 | ||
115 | tileset_path = None | 199 | tileset_path = None |
116 | 200 | ||
@@ -133,6 +217,7 @@ def convert_tmx(input_filepath, output_filepath): | |||
133 | output.write(ctypes.c_uint16(base_tile_width)) | 217 | output.write(ctypes.c_uint16(base_tile_width)) |
134 | output.write(ctypes.c_uint16(base_tile_height)) | 218 | output.write(ctypes.c_uint16(base_tile_height)) |
135 | output.write(ctypes.c_uint16(num_layers)) | 219 | output.write(ctypes.c_uint16(num_layers)) |
220 | output.write(ctypes.c_uint16(flags)) | ||
136 | elif child.tag == "layer": | 221 | elif child.tag == "layer": |
137 | layer = child | 222 | layer = child |
138 | layer_id = int(layer.attrib["id"]) | 223 | layer_id = int(layer.attrib["id"]) |
@@ -272,23 +357,16 @@ def convert_sprite_sheet(input_file_paths, sprite_width, sprite_height, | |||
272 | # that. getcolors() returns the number of unique colors. | 357 | # that. getcolors() returns the number of unique colors. |
273 | # getpalette() also returns a flattened list, which is why we must *4. | 358 | # getpalette() also returns a flattened list, which is why we must *4. |
274 | num_colours = len(im.getcolors()) | 359 | num_colours = len(im.getcolors()) |
275 | colours = im.getpalette(rawmode="RGBA")[:4 * num_colours] | 360 | palette = bytearray(im.getpalette(rawmode="RGBA")[:4 * num_colours]) |
276 | palette = [] | 361 | assert (num_colours == (len(palette) // 4)) |
277 | for i in range(0, 4 * num_colours, 4): | ||
278 | palette.append((colours[i], colours[i + 1], colours[i + 2], | ||
279 | colours[i + 3])) | ||
280 | 362 | ||
281 | output.write(ctypes.c_uint16(len(palette))) | 363 | output.write(ctypes.c_uint16(num_colours)) |
282 | output.write(bytearray(colours)) | 364 | output.write(palette) |
283 | 365 | ||
284 | print(f"Sprite width: {sprite_width}") | 366 | print(f"Sprite width: {sprite_width}") |
285 | print(f"Sprite height: {sprite_height}") | 367 | print(f"Sprite height: {sprite_height}") |
286 | print(f"Rows: {len(rows)}") | 368 | print(f"Rows: {len(rows)}") |
287 | print(f"Colours: {len(palette)}") | 369 | print(f"Colours: {num_colours}") |
288 | |||
289 | # print("Palette") | ||
290 | # for i, colour in enumerate(palette): | ||
291 | # print(f"{i}: {colour}") | ||
292 | 370 | ||
293 | for row, num_columns in enumerate(rows): | 371 | for row, num_columns in enumerate(rows): |
294 | output.write(ctypes.c_uint16(num_columns)) | 372 | output.write(ctypes.c_uint16(num_columns)) |
@@ -310,36 +388,38 @@ def convert_sprite_sheet(input_file_paths, sprite_width, sprite_height, | |||
310 | 388 | ||
311 | 389 | ||
312 | def main(): | 390 | def main(): |
313 | # TODO: Use subparser for each type of input file. | 391 | # TODO: Use a subparser for each type of input file for clarity of arguments. |
314 | parser = argparse.ArgumentParser() | 392 | parser = argparse.ArgumentParser() |
315 | parser.add_argument("input", | 393 | subparsers = parser.add_subparsers(dest="command") |
316 | nargs="+", | 394 | # Tile sets. |
317 | help="Input file (.tsx, .tmx) or path regex (sprite sheets)") | 395 | tileset_parser = subparsers.add_parser("tileset") |
318 | parser.add_argument("-W", "--width", type=int, help="Sprite width in pixels") | 396 | tileset_parser.add_argument("input", help="Input file (.tsx)") |
319 | parser.add_argument("-H", "--height", type=int, help="Sprite height in pixels") | 397 | # Tile maps. |
320 | parser.add_argument("-o", "--out", help="Output file (sprite sheets)") | 398 | tilemap_parser = subparsers.add_parser("tilemap") |
399 | tilemap_parser.add_argument("input", help="Input file (.tmx)") | ||
400 | # Sprite sheet. | ||
401 | sprite_parser = subparsers.add_parser("sprite") | ||
402 | sprite_parser.add_argument("input", nargs="+", help="Input files") | ||
403 | sprite_parser.add_argument("-W", "--width", type=int, required=True, help="Sprite width in pixels") | ||
404 | sprite_parser.add_argument("-H", "--height", type=int, required=True, help="Sprite height in pixels") | ||
405 | sprite_parser.add_argument("-o", "--out", help="Output file") | ||
321 | args = parser.parse_args() | 406 | args = parser.parse_args() |
322 | 407 | ||
323 | # TODO: Add support for TSX files made from a single image. Currently, only collections are supported. | 408 | if args.command == "tileset": |
324 | if ".tsx" in args.input[0]: | 409 | print(f"Processing: {args.input}") |
325 | args.input = args.input[0] # TODO: Remove this. | ||
326 | output_filepath_no_ext = drop_extension(args.input) | 410 | output_filepath_no_ext = drop_extension(args.input) |
327 | output_filepath = output_filepath_no_ext + ".ts" | 411 | output_filepath = output_filepath_no_ext + ".ts" |
328 | convert_tsx(args.input, output_filepath) | 412 | convert_tsx(args.input, output_filepath) |
329 | elif ".tmx" in args.input[0]: | 413 | elif args.command == "tilemap": |
330 | args.input = args.input[0] # TODO: Remove this. | 414 | print(f"Processing: {args.input}") |
331 | output_filepath_no_ext = drop_extension(args.input) | 415 | output_filepath_no_ext = drop_extension(args.input) |
332 | output_filepath = output_filepath_no_ext + ".tm" | 416 | output_filepath = output_filepath_no_ext + ".tm" |
333 | convert_tmx(args.input, output_filepath) | 417 | convert_tmx(args.input, output_filepath) |
334 | else: | 418 | elif args.command == "sprite": |
335 | # Sprite sheets. | 419 | 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" | 420 | output_filepath = args.out if args.out else "out.ss" |
340 | convert_sprite_sheet(args.input, args.width, args.height, | 421 | convert_sprite_sheet(args.input, args.width, args.height, |
341 | output_filepath) | 422 | output_filepath) |
342 | |||
343 | return 0 | 423 | return 0 |
344 | 424 | ||
345 | 425 | ||