# Converts tile sets and tile maps to binary formats (.TS, .TM) for the engine. # # Currently handles Tiled's .tsx and .tmx file formats. # # The output is a binary tile set file (.TS) or a binary tile map file (.TM). import argparse import ctypes from PIL import Image import sys from xml.etree import ElementTree # Maximum length of path strings in .TS and .TM files. MAX_PATH_LENGTH = 128 def drop_extension(filepath): return filepath[:filepath.rfind('.')] def to_char_array(string, length): """Convert a string to a fixed-length ASCII char array. The length of str must be at most length-1 so that the resulting string can be null-terminated. """ assert (len(string) < length) chars = string.encode("ascii") nulls = ("\0" * (length - len(string))).encode("ascii") return chars + nulls def convert_tsx(input_filepath, output_filepath): """Converts a Tiled .tsx tileset file to a .TS tile set file.""" xml = ElementTree.parse(input_filepath) root = xml.getroot() tile_count = int(root.attrib["tilecount"]) max_tile_width = int(root.attrib["tilewidth"]) max_tile_height = int(root.attrib["tileheight"]) print(f"Tile count: {tile_count}") print(f"Max width: {max_tile_width}") print(f"Max height: {max_tile_height}") with open(output_filepath, 'bw') as output: output.write(ctypes.c_uint16(tile_count)) output.write(ctypes.c_uint16(max_tile_width)) output.write(ctypes.c_uint16(max_tile_height)) num_tile = 0 for tile in root: # Skip the "grid" and other non-tile elements. if not tile.tag == "tile": continue # Assuming tiles are numbered 0..N. tile_id = int(tile.attrib["id"]) assert (tile_id == num_tile) num_tile += 1 image = tile[0] tile_width = int(image.attrib["width"]) tile_height = int(image.attrib["height"]) tile_path = image.attrib["source"] output.write(ctypes.c_uint16(tile_width)) output.write(ctypes.c_uint16(tile_height)) with Image.open(tile_path) as im: bytes = im.convert('RGBA').tobytes() output.write(bytes) def convert_tmx(input_filepath, output_filepath): """Converts a Tiled .tmx file to a .TM tile map file.""" xml = ElementTree.parse(input_filepath) root = xml.getroot() map_width = int(root.attrib["width"]) map_height = int(root.attrib["height"]) base_tile_width = int(root.attrib["tilewidth"]) base_tile_height = int(root.attrib["tileheight"]) num_layers = 1 print(f"Map width: {map_width}") print(f"Map height: {map_height}") print(f"Tile width: {base_tile_width}") print(f"Tile height: {base_tile_height}") with open(output_filepath, 'bw') as output: output.write(ctypes.c_uint16(map_width)) output.write(ctypes.c_uint16(map_height)) output.write(ctypes.c_uint16(base_tile_width)) output.write(ctypes.c_uint16(base_tile_height)) output.write(ctypes.c_uint16(num_layers)) tileset_path = None for child in root: if child.tag == "tileset": tileset = child tileset_path = tileset.attrib["source"] print(f"Tile set: {tileset_path}") tileset_path = tileset_path.replace("tsx", "ts") elif child.tag == "layer": layer = child layer_id = int(layer.attrib["id"]) layer_width = int(layer.attrib["width"]) layer_height = int(layer.attrib["height"]) print(f"Layer: {layer_id}") print(f"Width: {layer_width}") print(f"Height: {layer_height}") assert (tileset_path) output.write(to_char_array(tileset_path, MAX_PATH_LENGTH)) # Assume the layer's dimensions matches the map's. assert (layer_width == map_width) assert (layer_height == map_height) data = layer[0] # Handle other encodings later. assert (data.attrib["encoding"] == "csv") csv = data.text.strip() rows = csv.split('\n') for row in rows: tile_ids = [x.strip() for x in row.split(',') if x] for tile_id in tile_ids: output.write(ctypes.c_uint16(int(tile_id))) def main(): parser = argparse.ArgumentParser() parser.add_argument("input", help="Input file (.tsx, .tmx)") args = parser.parse_args() output_filepath_no_ext = drop_extension(args.input) if ".tsx" in args.input: output_filepath = output_filepath_no_ext + ".ts" convert_tsx(args.input, output_filepath) elif ".tmx" in args.input: output_filepath = output_filepath_no_ext + ".tm" convert_tmx(args.input, output_filepath) else: print(f"Unhandled file format: {args.input}") return 0 if __name__ == '__main__': sys.exit(main())