diff options
Diffstat (limited to 'gfx-iso/tools')
-rw-r--r-- | gfx-iso/tools/mkasset.py | 324 |
1 files changed, 324 insertions, 0 deletions
diff --git a/gfx-iso/tools/mkasset.py b/gfx-iso/tools/mkasset.py new file mode 100644 index 0000000..3ca8a1d --- /dev/null +++ b/gfx-iso/tools/mkasset.py | |||
@@ -0,0 +1,324 @@ | |||
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()) | ||