summaryrefslogtreecommitdiff
diff options
context:
space:
mode:
author3gg <3gg@shellblade.net>2025-09-01 14:50:07 -0700
committer3gg <3gg@shellblade.net>2025-09-01 14:50:27 -0700
commitdb3fd5aac9c66e64aa56fabc1a865fb64a65fc1c (patch)
tree36f84f93e37b517dfd6bab7fc322695e96fe0181
parent6ca52695de52c432feafbe3d8c8e25cbd8d8ec34 (diff)
Add support for single-image tile sets (not yet tested).
-rw-r--r--include/isogfx/asset.h19
-rw-r--r--tools/mkasset.py195
2 files changed, 151 insertions, 63 deletions
diff --git a/include/isogfx/asset.h b/include/isogfx/asset.h
index 9aeb55d..6050500 100644
--- a/include/isogfx/asset.h
+++ b/include/isogfx/asset.h
@@ -25,8 +25,15 @@ typedef struct Ts_Tile {
25 uint32_t pixels; // Byte offset into the Ts_TileSet's 'pixels'. 25 uint32_t pixels; // Byte offset into the Ts_TileSet's 'pixels'.
26} Ts_Tile; 26} Ts_Tile;
27 27
28// Tileset.
29//
30// Tile dimensions may be larger than the baseline tile dimensions of the
31// tileset if the tileset contains supertiles. Regardless, tile dimensions are
32// always a multiple of baseline dimensions along each axis.
28typedef struct Ts_TileSet { 33typedef struct Ts_TileSet {
29 uint16_t num_tiles; 34 uint16_t num_tiles;
35 uint16_t tile_width; // Baseline tile width.
36 uint16_t tile_height; // Baseline tile height.
30 uint16_t _pad; 37 uint16_t _pad;
31 Ts_Tile tiles[1]; // Count: num_tiles. 38 Ts_Tile tiles[1]; // Count: num_tiles.
32 Pixel pixels[]; // Count: sum_i(tile[i].width * tile[i].height). 39 Pixel pixels[]; // Count: sum_i(tile[i].width * tile[i].height).
@@ -64,8 +71,8 @@ typedef struct Tm_Map {
64/// 71///
65/// Pixels are 8-bit indices into the sprite sheet's colour palette. 72/// Pixels are 8-bit indices into the sprite sheet's colour palette.
66typedef struct Ss_Row { 73typedef struct Ss_Row {
67 uint16_t num_cols; /// Number of columns in this row. 74 uint16_t num_cols; // Number of columns in this row.
68 uint8_t pixels[1]; /// Count: num_cols * sprite_width * sprite_height. 75 uint8_t pixels[1]; // Count: num_cols * sprite_width * sprite_height.
69} Ss_Row; 76} Ss_Row;
70 77
71typedef struct Ss_Palette { 78typedef struct Ss_Palette {
@@ -77,11 +84,11 @@ typedef struct Ss_Palette {
77/// 84///
78/// Sprite width and height are assumed constant throughout the sprite sheet. 85/// Sprite width and height are assumed constant throughout the sprite sheet.
79typedef struct Ss_SpriteSheet { 86typedef struct Ss_SpriteSheet {
80 uint16_t sprite_width; /// Sprite width in pixels. 87 uint16_t sprite_width; // Sprite width in pixels.
81 uint16_t sprite_height; /// Sprite height in pixels. 88 uint16_t sprite_height; // Sprite height in pixels.
82 uint16_t num_rows; 89 uint16_t num_rows;
83 Ss_Palette palette; /// Variable size. 90 Ss_Palette palette; // Variable size.
84 Ss_Row rows[1]; /// Count: num_rows. Variable offset. 91 Ss_Row rows[1]; // Count: num_rows. Variable offset.
85} Ss_SpriteSheet; 92} Ss_SpriteSheet;
86 93
87// ----------------------------------------------------------------------------- 94// -----------------------------------------------------------------------------
diff --git a/tools/mkasset.py b/tools/mkasset.py
index ae27ead..9b9dc76 100644
--- a/tools/mkasset.py
+++ b/tools/mkasset.py
@@ -13,6 +13,7 @@
13import argparse 13import argparse
14import ctypes 14import ctypes
15import sys 15import sys
16from typing import Generator
16from xml.etree import ElementTree 17from xml.etree import ElementTree
17 18
18from PIL import Image 19from PIL import Image
@@ -26,7 +27,7 @@ def drop_extension(filepath):
26 return filepath[:filepath.rfind('.')] 27 return filepath[:filepath.rfind('.')]
27 28
28 29
29def to_char_array(string, length): 30def to_char_array(string, length) -> bytes:
30 """Convert a string to a fixed-length ASCII char array. 31 """Convert a string to a fixed-length ASCII char array.
31 32
32 The length of str must be at most length-1 so that the resulting string can 33 The length of str must be at most length-1 so that the resulting string can
@@ -38,58 +39,134 @@ def to_char_array(string, length):
38 return chars + nulls 39 return chars + nulls
39 40
40 41
42def load_image(path) -> bytes:
43 """Load an image as an array of RGBA bytes."""
44 with Image.open(path) as im:
45 return im.convert('RGBA').tobytes()
46
47
48def carve_image(rgba_bytes, tile_width, tile_height, columns) -> Generator[bytearray]:
49 """Carve out individual tiles from a single-image tile set."""
50 # Image dimensions in pixels.
51 image_width = columns * tile_width
52 image_height = len(rgba_bytes) // image_width // 4
53
54 tiles_x = image_width // tile_width
55 tiles_y = image_height // tile_height
56
57 tile_bytes = bytearray(tile_width * tile_height * 4)
58 for i in range(tiles_y):
59 image_y0 = i * tile_height # y-origin of tile inside image
60 for j in range(tiles_x):
61 image_x0 = j * tile_width # x-origin of tile inside image
62 for y in range(tile_height):
63 image_y = image_y0 + y # y of current pixel inside image
64 for x in range(tile_width):
65 image_x = image_x0 + x # x of current pixel inside image
66 tile_bytes[(y * tile_width + x) * 4] = (
67 rgba_bytes)[(image_y * image_width + image_x) * 4]
68 yield tile_bytes.copy()
69
70
41def convert_tsx(input_filepath, output_filepath): 71def convert_tsx(input_filepath, output_filepath):
42 """Converts a Tiled .tsx tileset file to a .TS tile set file.""" 72 """Converts a Tiled .tsx tileset file to a .TS tile set file."""
43 xml = ElementTree.parse(input_filepath) 73 xml = ElementTree.parse(input_filepath)
44 root = xml.getroot() 74 tileset = xml.getroot()
75 assert (tileset.tag == "tileset")
45 76
46 tile_count = int(root.attrib["tilecount"]) 77 # Header.
47 max_tile_width = int(root.attrib["tilewidth"]) 78 tileset_tile_count = int(tileset.attrib["tilecount"])
48 max_tile_height = int(root.attrib["tileheight"]) 79 tileset_tile_width = int(tileset.attrib["tilewidth"])
80 tileset_tile_height = int(tileset.attrib["tileheight"])
49 81
50 print(f"Tile count: {tile_count}") 82 print(f"Tile count: {tileset_tile_count}")
51 print(f"Max width: {max_tile_width}") 83 print(f"Tile width: {tileset_tile_width}")
52 print(f"Max height: {max_tile_height}") 84 print(f"Tile height: {tileset_tile_height}")
53 85
54 pixels = [] # List of byte arrays 86 pixels = [] # List of byte arrays
55 pixels_offset = 0 87 pixels_offset = 0
56 88
89 def output_tile(output, tile_width, tile_height, tile_image_bytes):
90 # Expecting RGBA pixels.
91 assert (len(tile_image_bytes) == (tile_width * tile_height * 4))
92
93 nonlocal pixels_offset
94 tile_pixels_offset = pixels_offset
95 pixels.append(tile_image_bytes)
96 pixels_offset += len(tile_image_bytes)
97
98 output.write(ctypes.c_uint16(tile_width))
99 output.write(ctypes.c_uint16(tile_height))
100 output.write(ctypes.c_uint32(tile_pixels_offset))
101
57 with open(output_filepath, 'bw') as output: 102 with open(output_filepath, 'bw') as output:
58 # Write the header. 103 # Write the header.
59 output.write(ctypes.c_uint16(tile_count)) 104 output.write(ctypes.c_uint16(tileset_tile_count))
60 # output.write(ctypes.c_uint16(max_tile_width)) 105 output.write(ctypes.c_uint16(tileset_tile_width))
61 # output.write(ctypes.c_uint16(max_tile_height)) 106 output.write(ctypes.c_uint16(tileset_tile_height))
62 output.write(ctypes.c_uint16(0)) # Pad. 107 output.write(ctypes.c_uint16(0)) # Pad.
63 108
109 # A tileset made up of multiple images contains various 'tile' children,
110 # each with their own 'image':
111 #
112 # <tile id="0">
113 # <image width="32" height="32" source="separated images/tile_000.png"/>
114 # </tile>
115 # <tile id="1">
116 # <image width="32" height="32" source="separated images/tile_001.png"/>
117 # </tile>
118 #
119 # A tileset made up of a single image contains a single 'image' child:
120 #
121 # <image source="tileset.png" width="416" height="368"/>
64 num_tile = 0 122 num_tile = 0
65 for tile in root: 123 for child in tileset:
66 # Skip the "grid" and other non-tile elements. 124 if child.tag == "image":
67 if not tile.tag == "tile": 125 # This is a single-image tileset.
68 continue 126 image = child
69 127 image_path = image.attrib["source"]
70 # Assuming tiles are numbered 0..N. 128 image_bytes = load_image(image_path)
71 tile_id = int(tile.attrib["id"]) 129
72 assert (tile_id == num_tile) 130 # We expect the 'columns' attribute to be >0 for a single-image
73 num_tile += 1 131 # tile set.
74 132 columns = int(tileset.attrib["columns"])
75 image = tile[0] 133 assert (columns > 0)
76 tile_width = int(image.attrib["width"]) 134
77 tile_height = int(image.attrib["height"]) 135 # Tile dimensions are those given by the tileset's baseline width and height.
78 tile_path = image.attrib["source"] 136 tile_width = tileset_tile_width
79 137 tile_height = tileset_tile_height
80 assert (tile_width > 0) 138
81 assert (tile_height > 0) 139 # Cut each of the WxH tiles, and store them in the output in "tile order".
82 140 for tile_bytes in carve_image(image_bytes, tile_width, tile_height, columns):
83 tile_pixels_offset = pixels_offset 141 output_tile(output, tile_width, tile_height, tile_bytes)
84 with Image.open(tile_path) as im: 142
85 bytes = im.convert('RGBA').tobytes() 143 # Make sure not to process 'tile' elements, which may exist in
86 pixels.append(bytes) 144 # a single-image tileset to define probabilities, for example.
87 pixels_offset += len(bytes) 145 break
88 146
89 # Write the tile. 147 elif child.tag == "tile":
90 output.write(ctypes.c_uint16(tile_width)) 148 # This is a tile image in a tileset made of a collection of images.
91 output.write(ctypes.c_uint16(tile_height)) 149 tile = child
92 output.write(ctypes.c_uint32(tile_pixels_offset)) 150
151 # We assume that tiles are numbered 0..N-1. Assert this.
152 tile_id = int(tile.attrib["id"])
153 assert (tile_id == num_tile)
154 num_tile += 1
155
156 image = tile[0]
157 tile_width = int(image.attrib["width"])
158 tile_height = int(image.attrib["height"])
159 image_path = image.attrib["source"]
160
161 # Tile dimensions must be a multiple of the tileset's baseline
162 # tile width and height.
163 assert (tile_width > 0)
164 assert (tile_height > 0)
165 assert ((tile_width % tileset_tile_width) == 0)
166 assert ((tile_height % tileset_tile_height) == 0)
167
168 image_bytes = load_image(image_path)
169 output_tile(output, tile_width, tile_height, image_bytes)
93 170
94 # Write the pixel data. 171 # Write the pixel data.
95 for bytes in pixels: 172 for bytes in pixels:
@@ -273,6 +350,8 @@ def convert_sprite_sheet(input_file_paths, sprite_width, sprite_height,
273 # getpalette() also returns a flattened list, which is why we must *4. 350 # getpalette() also returns a flattened list, which is why we must *4.
274 num_colours = len(im.getcolors()) 351 num_colours = len(im.getcolors())
275 colours = im.getpalette(rawmode="RGBA")[:4 * num_colours] 352 colours = im.getpalette(rawmode="RGBA")[:4 * num_colours]
353 # TODO: This palette list does not seem really necessary.
354 # Define palette = bytearray(im.getpalette(...))
276 palette = [] 355 palette = []
277 for i in range(0, 4 * num_colours, 4): 356 for i in range(0, 4 * num_colours, 4):
278 palette.append((colours[i], colours[i + 1], colours[i + 2], 357 palette.append((colours[i], colours[i + 1], colours[i + 2],
@@ -310,36 +389,38 @@ def convert_sprite_sheet(input_file_paths, sprite_width, sprite_height,
310 389
311 390
312def main(): 391def main():
313 # TODO: Use subparser for each type of input file. 392 # TODO: Use a subparser for each type of input file for clarity of arguments.
314 parser = argparse.ArgumentParser() 393 parser = argparse.ArgumentParser()
315 parser.add_argument("input", 394 subparsers = parser.add_subparsers(dest="command")
316 nargs="+", 395 # Tile sets.
317 help="Input file (.tsx, .tmx) or path regex (sprite sheets)") 396 tileset_parser = subparsers.add_parser("tileset")
318 parser.add_argument("-W", "--width", type=int, help="Sprite width in pixels") 397 tileset_parser.add_argument("input", help="Input file (.tsx)")
319 parser.add_argument("-H", "--height", type=int, help="Sprite height in pixels") 398 # Tile maps.
320 parser.add_argument("-o", "--out", help="Output file (sprite sheets)") 399 tilemap_parser = subparsers.add_parser("tilemap")
400 tilemap_parser.add_argument("input", help="Input file (.tmx)")
401 # Sprite sheet.
402 sprite_parser = subparsers.add_parser("sprite")
403 sprite_parser.add_argument("input", nargs="+", help="Input files")
404 sprite_parser.add_argument("-W", "--width", type=int, required=True, help="Sprite width in pixels")
405 sprite_parser.add_argument("-H", "--height", type=int, required=True, help="Sprite height in pixels")
406 sprite_parser.add_argument("-o", "--out", help="Output file")
321 args = parser.parse_args() 407 args = parser.parse_args()
322 408
323 # TODO: Add support for TSX files made from a single image. Currently, only collections are supported. 409 if args.command == "tileset":
324 if ".tsx" in args.input[0]: 410 print(f"Processing: {args.input}")
325 args.input = args.input[0] # TODO: Remove this.
326 output_filepath_no_ext = drop_extension(args.input) 411 output_filepath_no_ext = drop_extension(args.input)
327 output_filepath = output_filepath_no_ext + ".ts" 412 output_filepath = output_filepath_no_ext + ".ts"
328 convert_tsx(args.input, output_filepath) 413 convert_tsx(args.input, output_filepath)
329 elif ".tmx" in args.input[0]: 414 elif args.command == "tilemap":
330 args.input = args.input[0] # TODO: Remove this. 415 print(f"Processing: {args.input}")
331 output_filepath_no_ext = drop_extension(args.input) 416 output_filepath_no_ext = drop_extension(args.input)
332 output_filepath = output_filepath_no_ext + ".tm" 417 output_filepath = output_filepath_no_ext + ".tm"
333 convert_tmx(args.input, output_filepath) 418 convert_tmx(args.input, output_filepath)
334 else: 419 elif args.command == "sprite":
335 # Sprite sheets. 420 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" 421 output_filepath = args.out if args.out else "out.ss"
340 convert_sprite_sheet(args.input, args.width, args.height, 422 convert_sprite_sheet(args.input, args.width, args.height,
341 output_filepath) 423 output_filepath)
342
343 return 0 424 return 0
344 425
345 426