summaryrefslogtreecommitdiff
path: root/tools
diff options
context:
space:
mode:
Diffstat (limited to 'tools')
-rw-r--r--tools/mkasset.py218
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 @@
13import argparse 13import argparse
14import ctypes 14import ctypes
15import sys 15import sys
16from enum import IntEnum
17from typing import Generator
16from xml.etree import ElementTree 18from xml.etree import ElementTree
17 19
18from PIL import Image 20from PIL import Image
@@ -22,11 +24,17 @@ from PIL import Image
22MAX_PATH_LENGTH = 128 24MAX_PATH_LENGTH = 128
23 25
24 26
27class Orientation(IntEnum):
28 """Map orientation. Must match Tm_Orientation in asset.h"""
29 Orthogonal = 0
30 Isometric = 1
31
32
25def drop_extension(filepath): 33def drop_extension(filepath):
26 return filepath[:filepath.rfind('.')] 34 return filepath[:filepath.rfind('.')]
27 35
28 36
29def to_char_array(string, length): 37def 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
49def 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
55def 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.
41def convert_tsx(input_filepath, output_filepath): 76def 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
312def main(): 390def 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