summaryrefslogtreecommitdiff
path: root/gfx-iso/tools/mkasset.py
diff options
context:
space:
mode:
Diffstat (limited to 'gfx-iso/tools/mkasset.py')
-rw-r--r--gfx-iso/tools/mkasset.py324
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#
13import argparse
14import ctypes
15import os
16from PIL import Image
17import sys
18from xml.etree import ElementTree
19
20# Maximum length of path strings in .TS and .TM files.
21# Must match the engine's value.
22MAX_PATH_LENGTH = 128
23
24
25def drop_extension(filepath):
26 return filepath[:filepath.rfind('.')]
27
28
29def 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
41def 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
83def 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
145def 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
168def 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
206def 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
221def 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
293def 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
323if __name__ == '__main__':
324 sys.exit(main())