diff options
Diffstat (limited to 'gfx-iso/tools')
| -rw-r--r-- | gfx-iso/tools/mkasset.py | 324 |
1 files changed, 0 insertions, 324 deletions
diff --git a/gfx-iso/tools/mkasset.py b/gfx-iso/tools/mkasset.py deleted file mode 100644 index 3ca8a1d..0000000 --- a/gfx-iso/tools/mkasset.py +++ /dev/null | |||
| @@ -1,324 +0,0 @@ | |||
| 1 | # Converts assets to binary formats (.ts, .tm, .ss) for the engine. | ||
| 2 | # | ||
| 3 | # Input file formats: | ||
| 4 | # - Tiled tile set (.tsx) | ||
| 5 | # - Tiled tile map (.tmx) | ||
| 6 | # - Sprite sheets (.jpg, .png, etc), 1 row per animation. | ||
| 7 | # | ||
| 8 | # Output file formats: | ||
| 9 | # - Binary tile set file (.ts) | ||
| 10 | # - Binary tile map file (.tm) | ||
| 11 | # - Binary sprite sheet file (.ss) | ||
| 12 | # | ||
| 13 | import argparse | ||
| 14 | import ctypes | ||
| 15 | import os | ||
| 16 | from PIL import Image | ||
| 17 | import sys | ||
| 18 | from xml.etree import ElementTree | ||
| 19 | |||
| 20 | # Maximum length of path strings in .TS and .TM files. | ||
| 21 | # Must match the engine's value. | ||
| 22 | MAX_PATH_LENGTH = 128 | ||
| 23 | |||
| 24 | |||
| 25 | def drop_extension(filepath): | ||
| 26 | return filepath[:filepath.rfind('.')] | ||
| 27 | |||
| 28 | |||
| 29 | def to_char_array(string, length): | ||
| 30 | """Convert a string to a fixed-length ASCII char array. | ||
| 31 | |||
| 32 | The length of str must be at most length-1 so that the resulting string can | ||
| 33 | be null-terminated. | ||
| 34 | """ | ||
| 35 | assert (len(string) < length) | ||
| 36 | chars = string.encode("ascii") | ||
| 37 | nulls = ("\0" * (length - len(string))).encode("ascii") | ||
| 38 | return chars + nulls | ||
| 39 | |||
| 40 | |||
| 41 | def convert_tsx(input_filepath, output_filepath): | ||
| 42 | """Converts a Tiled .tsx tileset file to a .TS tile set file.""" | ||
| 43 | xml = ElementTree.parse(input_filepath) | ||
| 44 | root = xml.getroot() | ||
| 45 | |||
| 46 | tile_count = int(root.attrib["tilecount"]) | ||
| 47 | max_tile_width = int(root.attrib["tilewidth"]) | ||
| 48 | max_tile_height = int(root.attrib["tileheight"]) | ||
| 49 | |||
| 50 | print(f"Tile count: {tile_count}") | ||
| 51 | print(f"Max width: {max_tile_width}") | ||
| 52 | print(f"Max height: {max_tile_height}") | ||
| 53 | |||
| 54 | with open(output_filepath, 'bw') as output: | ||
| 55 | output.write(ctypes.c_uint16(tile_count)) | ||
| 56 | output.write(ctypes.c_uint16(max_tile_width)) | ||
| 57 | output.write(ctypes.c_uint16(max_tile_height)) | ||
| 58 | |||
| 59 | num_tile = 0 | ||
| 60 | for tile in root: | ||
| 61 | # Skip the "grid" and other non-tile elements. | ||
| 62 | if not tile.tag == "tile": | ||
| 63 | continue | ||
| 64 | |||
| 65 | # Assuming tiles are numbered 0..N. | ||
| 66 | tile_id = int(tile.attrib["id"]) | ||
| 67 | assert (tile_id == num_tile) | ||
| 68 | num_tile += 1 | ||
| 69 | |||
| 70 | image = tile[0] | ||
| 71 | tile_width = int(image.attrib["width"]) | ||
| 72 | tile_height = int(image.attrib["height"]) | ||
| 73 | tile_path = image.attrib["source"] | ||
| 74 | |||
| 75 | output.write(ctypes.c_uint16(tile_width)) | ||
| 76 | output.write(ctypes.c_uint16(tile_height)) | ||
| 77 | |||
| 78 | with Image.open(tile_path) as im: | ||
| 79 | bytes = im.convert('RGBA').tobytes() | ||
| 80 | output.write(bytes) | ||
| 81 | |||
| 82 | |||
| 83 | def convert_tmx(input_filepath, output_filepath): | ||
| 84 | """Converts a Tiled .tmx file to a .TM tile map file.""" | ||
| 85 | xml = ElementTree.parse(input_filepath) | ||
| 86 | root = xml.getroot() | ||
| 87 | |||
| 88 | map_width = int(root.attrib["width"]) | ||
| 89 | map_height = int(root.attrib["height"]) | ||
| 90 | base_tile_width = int(root.attrib["tilewidth"]) | ||
| 91 | base_tile_height = int(root.attrib["tileheight"]) | ||
| 92 | num_layers = 1 | ||
| 93 | |||
| 94 | print(f"Map width: {map_width}") | ||
| 95 | print(f"Map height: {map_height}") | ||
| 96 | print(f"Tile width: {base_tile_width}") | ||
| 97 | print(f"Tile height: {base_tile_height}") | ||
| 98 | |||
| 99 | with open(output_filepath, 'bw') as output: | ||
| 100 | output.write(ctypes.c_uint16(map_width)) | ||
| 101 | output.write(ctypes.c_uint16(map_height)) | ||
| 102 | output.write(ctypes.c_uint16(base_tile_width)) | ||
| 103 | output.write(ctypes.c_uint16(base_tile_height)) | ||
| 104 | output.write(ctypes.c_uint16(num_layers)) | ||
| 105 | |||
| 106 | tileset_path = None | ||
| 107 | |||
| 108 | for child in root: | ||
| 109 | if child.tag == "tileset": | ||
| 110 | tileset = child | ||
| 111 | tileset_path = tileset.attrib["source"] | ||
| 112 | |||
| 113 | print(f"Tile set: {tileset_path}") | ||
| 114 | |||
| 115 | tileset_path = tileset_path.replace("tsx", "ts") | ||
| 116 | elif child.tag == "layer": | ||
| 117 | layer = child | ||
| 118 | layer_id = int(layer.attrib["id"]) | ||
| 119 | layer_width = int(layer.attrib["width"]) | ||
| 120 | layer_height = int(layer.attrib["height"]) | ||
| 121 | |||
| 122 | print(f"Layer: {layer_id}") | ||
| 123 | print(f"Width: {layer_width}") | ||
| 124 | print(f"Height: {layer_height}") | ||
| 125 | |||
| 126 | assert (tileset_path) | ||
| 127 | output.write(to_char_array(tileset_path, MAX_PATH_LENGTH)) | ||
| 128 | |||
| 129 | # Assume the layer's dimensions matches the map's. | ||
| 130 | assert (layer_width == map_width) | ||
| 131 | assert (layer_height == map_height) | ||
| 132 | |||
| 133 | data = layer[0] | ||
| 134 | # Handle other encodings later. | ||
| 135 | assert (data.attrib["encoding"] == "csv") | ||
| 136 | |||
| 137 | csv = data.text.strip() | ||
| 138 | rows = csv.split('\n') | ||
| 139 | for row in rows: | ||
| 140 | tile_ids = [x.strip() for x in row.split(',') if x] | ||
| 141 | for tile_id in tile_ids: | ||
| 142 | output.write(ctypes.c_uint16(int(tile_id))) | ||
| 143 | |||
| 144 | |||
| 145 | def get_num_cols(image, sprite_width): | ||
| 146 | """Return the number of non-empty columns in the image. | ||
| 147 | |||
| 148 | Assumes no gaps in the columns. | ||
| 149 | """ | ||
| 150 | assert (image.width % sprite_width == 0) | ||
| 151 | num_cols = image.width // sprite_width | ||
| 152 | |||
| 153 | # Start the search from right to left. | ||
| 154 | for col in reversed(range(1, num_cols)): | ||
| 155 | left = (col - 1) * sprite_width | ||
| 156 | right = col * sprite_width | ||
| 157 | rect = image.crop((left, 0, right, image.height)) | ||
| 158 | min_max = rect.getextrema() | ||
| 159 | for (channel_min, channel_max) in min_max: | ||
| 160 | if channel_min != 0 or channel_max != 0: | ||
| 161 | # 'col' is the rightmost non-empty column. | ||
| 162 | # Assuming no gaps, col+1 is the number of non-empty columns. | ||
| 163 | return col + 1 | ||
| 164 | |||
| 165 | return 0 | ||
| 166 | |||
| 167 | |||
| 168 | def get_sprite_sheet_rows(im, sprite_width, sprite_height): | ||
| 169 | """Gets the individual rows of a sprite sheet. | ||
| 170 | |||
| 171 | The input sprite sheet can have any number of rows. | ||
| 172 | |||
| 173 | Returns a list of lists [[sprite]], one inner list for the columns in each | ||
| 174 | row. | ||
| 175 | """ | ||
| 176 | # Sprite sheet's width and height must be integer multiples of the | ||
| 177 | # sprite's width and height. | ||
| 178 | assert (im.width % sprite_width == 0) | ||
| 179 | assert (im.height % sprite_height == 0) | ||
| 180 | |||
| 181 | num_rows = im.height // sprite_height | ||
| 182 | |||
| 183 | rows = [] | ||
| 184 | for row in range(num_rows): | ||
| 185 | # Get the number of columns. | ||
| 186 | upper = row * sprite_height | ||
| 187 | lower = (row + 1) * sprite_height | ||
| 188 | whole_row = im.crop((0, upper, im.width, lower)) | ||
| 189 | num_cols = get_num_cols(whole_row, sprite_width) | ||
| 190 | assert (num_cols > 0) | ||
| 191 | |||
| 192 | # Crop the row into N columns. | ||
| 193 | cols = [] | ||
| 194 | for i in range(num_cols): | ||
| 195 | left = i * sprite_width | ||
| 196 | right = (i + 1) * sprite_width | ||
| 197 | sprite = im.crop((left, upper, right, lower)) | ||
| 198 | cols.append(sprite) | ||
| 199 | |||
| 200 | assert (len(cols) == num_cols) | ||
| 201 | rows.append(cols) | ||
| 202 | |||
| 203 | return rows | ||
| 204 | |||
| 205 | |||
| 206 | def make_image_from_rows(rows, sprite_width, sprite_height): | ||
| 207 | """Concatenate the rows into a single RGBA image.""" | ||
| 208 | im_width = sprite_width * max(len(row) for row in rows) | ||
| 209 | im_height = len(rows) * sprite_height | ||
| 210 | im = Image.new('RGBA', (im_width, im_height)) | ||
| 211 | y = 0 | ||
| 212 | for row in rows: | ||
| 213 | x = 0 | ||
| 214 | for sprite in row: | ||
| 215 | im.paste(sprite.convert('RGBA'), (x, y)) | ||
| 216 | x += sprite_width | ||
| 217 | y += sprite_height | ||
| 218 | return im | ||
| 219 | |||
| 220 | |||
| 221 | def convert_sprite_sheet(input_file_paths, sprite_width, sprite_height, | ||
| 222 | output_filepath): | ||
| 223 | """Converts a set of sprite sheet images into a binary sprite sheet file | ||
| 224 | (.ss). | ||
| 225 | |||
| 226 | The input sprite sheets can have any number of rows, one row per animation. | ||
| 227 | All rows from all sprite sheets are concatenated in the output file. | ||
| 228 | |||
| 229 | The sprite's width and height is assumed constant throughout the input | ||
| 230 | sprite sheets. | ||
| 231 | """ | ||
| 232 | rows = [] | ||
| 233 | for input_filepath in input_file_paths: | ||
| 234 | with Image.open(input_filepath) as sprite_sheet: | ||
| 235 | rows.extend( | ||
| 236 | get_sprite_sheet_rows(sprite_sheet, sprite_width, | ||
| 237 | sprite_height)) | ||
| 238 | |||
| 239 | im = make_image_from_rows(rows, sprite_width, sprite_height) | ||
| 240 | im = im.convert(mode="P", palette=Image.ADAPTIVE, colors=256) | ||
| 241 | |||
| 242 | # The sprite data in 'rows' is no longer needed. | ||
| 243 | # Keep just the number of columns per row. | ||
| 244 | rows = [len(row) for row in rows] | ||
| 245 | |||
| 246 | with open(output_filepath, 'bw') as output: | ||
| 247 | output.write(ctypes.c_uint16(sprite_width)) | ||
| 248 | output.write(ctypes.c_uint16(sprite_height)) | ||
| 249 | output.write(ctypes.c_uint16(len(rows))) | ||
| 250 | |||
| 251 | # Write palette. | ||
| 252 | # getpalette() returns 256 colors, but the palette might use less than | ||
| 253 | # that. getcolors() returns the number of unique colors. | ||
| 254 | # getpalette() also returns a flattened list, which is why we must *4. | ||
| 255 | num_colours = len(im.getcolors()) | ||
| 256 | colours = im.getpalette(rawmode="RGBA")[:4 * num_colours] | ||
| 257 | palette = [] | ||
| 258 | for i in range(0, 4 * num_colours, 4): | ||
| 259 | palette.append((colours[i], colours[i + 1], colours[i + 2], | ||
| 260 | colours[i + 3])) | ||
| 261 | |||
| 262 | output.write(ctypes.c_uint16(len(palette))) | ||
| 263 | output.write(bytearray(colours)) | ||
| 264 | |||
| 265 | print(f"Sprite width: {sprite_width}") | ||
| 266 | print(f"Sprite height: {sprite_height}") | ||
| 267 | print(f"Rows: {len(rows)}") | ||
| 268 | print(f"Colours: {len(palette)}") | ||
| 269 | |||
| 270 | # print("Palette") | ||
| 271 | # for i, colour in enumerate(palette): | ||
| 272 | # print(f"{i}: {colour}") | ||
| 273 | |||
| 274 | for row, num_columns in enumerate(rows): | ||
| 275 | output.write(ctypes.c_uint16(num_columns)) | ||
| 276 | upper = row * sprite_height | ||
| 277 | lower = (row + 1) * sprite_height | ||
| 278 | for col in range(num_columns): | ||
| 279 | left = col * sprite_width | ||
| 280 | right = (col + 1) * sprite_width | ||
| 281 | sprite = im.crop((left, upper, right, lower)) | ||
| 282 | sprite_bytes = sprite.tobytes() | ||
| 283 | |||
| 284 | assert (len(sprite_bytes) == sprite_width * sprite_height) | ||
| 285 | output.write(sprite_bytes) | ||
| 286 | |||
| 287 | # if (row == 0) and (col == 0): | ||
| 288 | # print(f"Sprite: ({len(sprite_bytes)})") | ||
| 289 | # print(list(sprite_bytes)) | ||
| 290 | # sprite.save("out.png") | ||
| 291 | |||
| 292 | |||
| 293 | def main(): | ||
| 294 | parser = argparse.ArgumentParser() | ||
| 295 | parser.add_argument("input", | ||
| 296 | nargs="+", | ||
| 297 | help="Input file (.tsx, .tmx) or path regex (sprite sheets)") | ||
| 298 | parser.add_argument("--width", type=int, help="Sprite width in pixels") | ||
| 299 | parser.add_argument("--height", type=int, help="Sprite height in pixels") | ||
| 300 | parser.add_argument("--out", help="Output file (sprite sheets)") | ||
| 301 | args = parser.parse_args() | ||
| 302 | |||
| 303 | if ".tsx" in args.input: | ||
| 304 | output_filepath_no_ext = drop_extension(args.input) | ||
| 305 | output_filepath = output_filepath_no_ext + ".ts" | ||
| 306 | convert_tsx(args.input, output_filepath) | ||
| 307 | elif ".tmx" in args.input: | ||
| 308 | output_filepath_no_ext = drop_extension(args.input) | ||
| 309 | output_filepath = output_filepath_no_ext + ".tm" | ||
| 310 | convert_tmx(args.input, output_filepath) | ||
| 311 | else: | ||
| 312 | # Sprite sheets. | ||
| 313 | if not args.width or not args.height: | ||
| 314 | print("Sprite width and height must be given") | ||
| 315 | return 1 | ||
| 316 | output_filepath = args.out if args.out else "out.ss" | ||
| 317 | convert_sprite_sheet(args.input, args.width, args.height, | ||
| 318 | output_filepath) | ||
| 319 | |||
| 320 | return 0 | ||
| 321 | |||
| 322 | |||
| 323 | if __name__ == '__main__': | ||
| 324 | sys.exit(main()) | ||
