From db3fd5aac9c66e64aa56fabc1a865fb64a65fc1c Mon Sep 17 00:00:00 2001
From: 3gg <3gg@shellblade.net>
Date: Mon, 1 Sep 2025 14:50:07 -0700
Subject: Add support for single-image tile sets (not yet tested).
---
tools/mkasset.py | 195 +++++++++++++++++++++++++++++++++++++++----------------
1 file changed, 138 insertions(+), 57 deletions(-)
(limited to 'tools/mkasset.py')
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 @@
import argparse
import ctypes
import sys
+from typing import Generator
from xml.etree import ElementTree
from PIL import Image
@@ -26,7 +27,7 @@ def drop_extension(filepath):
return filepath[:filepath.rfind('.')]
-def to_char_array(string, length):
+def to_char_array(string, length) -> bytes:
"""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
@@ -38,58 +39,134 @@ def to_char_array(string, length):
return chars + nulls
+def load_image(path) -> bytes:
+ """Load an image as an array of RGBA bytes."""
+ with Image.open(path) as im:
+ return im.convert('RGBA').tobytes()
+
+
+def carve_image(rgba_bytes, tile_width, tile_height, columns) -> Generator[bytearray]:
+ """Carve out individual tiles from a single-image tile set."""
+ # Image dimensions in pixels.
+ image_width = columns * tile_width
+ image_height = len(rgba_bytes) // image_width // 4
+
+ tiles_x = image_width // tile_width
+ tiles_y = image_height // tile_height
+
+ tile_bytes = bytearray(tile_width * tile_height * 4)
+ for i in range(tiles_y):
+ image_y0 = i * tile_height # y-origin of tile inside image
+ for j in range(tiles_x):
+ image_x0 = j * tile_width # x-origin of tile inside image
+ for y in range(tile_height):
+ image_y = image_y0 + y # y of current pixel inside image
+ for x in range(tile_width):
+ image_x = image_x0 + x # x of current pixel inside image
+ tile_bytes[(y * tile_width + x) * 4] = (
+ rgba_bytes)[(image_y * image_width + image_x) * 4]
+ yield tile_bytes.copy()
+
+
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()
+ tileset = xml.getroot()
+ assert (tileset.tag == "tileset")
- tile_count = int(root.attrib["tilecount"])
- max_tile_width = int(root.attrib["tilewidth"])
- max_tile_height = int(root.attrib["tileheight"])
+ # Header.
+ tileset_tile_count = int(tileset.attrib["tilecount"])
+ tileset_tile_width = int(tileset.attrib["tilewidth"])
+ tileset_tile_height = int(tileset.attrib["tileheight"])
- print(f"Tile count: {tile_count}")
- print(f"Max width: {max_tile_width}")
- print(f"Max height: {max_tile_height}")
+ print(f"Tile count: {tileset_tile_count}")
+ print(f"Tile width: {tileset_tile_width}")
+ print(f"Tile height: {tileset_tile_height}")
pixels = [] # List of byte arrays
pixels_offset = 0
+ def output_tile(output, tile_width, tile_height, tile_image_bytes):
+ # Expecting RGBA pixels.
+ assert (len(tile_image_bytes) == (tile_width * tile_height * 4))
+
+ nonlocal pixels_offset
+ tile_pixels_offset = pixels_offset
+ pixels.append(tile_image_bytes)
+ pixels_offset += len(tile_image_bytes)
+
+ output.write(ctypes.c_uint16(tile_width))
+ output.write(ctypes.c_uint16(tile_height))
+ output.write(ctypes.c_uint32(tile_pixels_offset))
+
with open(output_filepath, 'bw') as output:
# Write the header.
- output.write(ctypes.c_uint16(tile_count))
- # output.write(ctypes.c_uint16(max_tile_width))
- # output.write(ctypes.c_uint16(max_tile_height))
+ output.write(ctypes.c_uint16(tileset_tile_count))
+ output.write(ctypes.c_uint16(tileset_tile_width))
+ output.write(ctypes.c_uint16(tileset_tile_height))
output.write(ctypes.c_uint16(0)) # Pad.
+ # A tileset made up of multiple images contains various 'tile' children,
+ # each with their own 'image':
+ #
+ #
+ #
+ #
+ #
+ #
+ #
+ #
+ # A tileset made up of a single image contains a single 'image' child:
+ #
+ #
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"]
-
- assert (tile_width > 0)
- assert (tile_height > 0)
-
- tile_pixels_offset = pixels_offset
- with Image.open(tile_path) as im:
- bytes = im.convert('RGBA').tobytes()
- pixels.append(bytes)
- pixels_offset += len(bytes)
-
- # Write the tile.
- output.write(ctypes.c_uint16(tile_width))
- output.write(ctypes.c_uint16(tile_height))
- output.write(ctypes.c_uint32(tile_pixels_offset))
+ for child in tileset:
+ if child.tag == "image":
+ # This is a single-image tileset.
+ image = child
+ image_path = image.attrib["source"]
+ image_bytes = load_image(image_path)
+
+ # We expect the 'columns' attribute to be >0 for a single-image
+ # tile set.
+ columns = int(tileset.attrib["columns"])
+ assert (columns > 0)
+
+ # Tile dimensions are those given by the tileset's baseline width and height.
+ tile_width = tileset_tile_width
+ tile_height = tileset_tile_height
+
+ # Cut each of the WxH tiles, and store them in the output in "tile order".
+ for tile_bytes in carve_image(image_bytes, tile_width, tile_height, columns):
+ output_tile(output, tile_width, tile_height, tile_bytes)
+
+ # Make sure not to process 'tile' elements, which may exist in
+ # a single-image tileset to define probabilities, for example.
+ break
+
+ elif child.tag == "tile":
+ # This is a tile image in a tileset made of a collection of images.
+ tile = child
+
+ # We assume that tiles are numbered 0..N-1. Assert this.
+ 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"])
+ image_path = image.attrib["source"]
+
+ # Tile dimensions must be a multiple of the tileset's baseline
+ # tile width and height.
+ assert (tile_width > 0)
+ assert (tile_height > 0)
+ assert ((tile_width % tileset_tile_width) == 0)
+ assert ((tile_height % tileset_tile_height) == 0)
+
+ image_bytes = load_image(image_path)
+ output_tile(output, tile_width, tile_height, image_bytes)
# Write the pixel data.
for bytes in pixels:
@@ -273,6 +350,8 @@ def convert_sprite_sheet(input_file_paths, sprite_width, sprite_height,
# getpalette() also returns a flattened list, which is why we must *4.
num_colours = len(im.getcolors())
colours = im.getpalette(rawmode="RGBA")[:4 * num_colours]
+ # TODO: This palette list does not seem really necessary.
+ # Define palette = bytearray(im.getpalette(...))
palette = []
for i in range(0, 4 * num_colours, 4):
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,
def main():
- # TODO: Use subparser for each type of input file.
+ # TODO: Use a subparser for each type of input file for clarity of arguments.
parser = argparse.ArgumentParser()
- parser.add_argument("input",
- nargs="+",
- help="Input file (.tsx, .tmx) or path regex (sprite sheets)")
- parser.add_argument("-W", "--width", type=int, help="Sprite width in pixels")
- parser.add_argument("-H", "--height", type=int, help="Sprite height in pixels")
- parser.add_argument("-o", "--out", help="Output file (sprite sheets)")
+ subparsers = parser.add_subparsers(dest="command")
+ # Tile sets.
+ tileset_parser = subparsers.add_parser("tileset")
+ tileset_parser.add_argument("input", help="Input file (.tsx)")
+ # Tile maps.
+ tilemap_parser = subparsers.add_parser("tilemap")
+ tilemap_parser.add_argument("input", help="Input file (.tmx)")
+ # Sprite sheet.
+ sprite_parser = subparsers.add_parser("sprite")
+ sprite_parser.add_argument("input", nargs="+", help="Input files")
+ sprite_parser.add_argument("-W", "--width", type=int, required=True, help="Sprite width in pixels")
+ sprite_parser.add_argument("-H", "--height", type=int, required=True, help="Sprite height in pixels")
+ sprite_parser.add_argument("-o", "--out", help="Output file")
args = parser.parse_args()
- # TODO: Add support for TSX files made from a single image. Currently, only collections are supported.
- if ".tsx" in args.input[0]:
- args.input = args.input[0] # TODO: Remove this.
+ if args.command == "tileset":
+ print(f"Processing: {args.input}")
output_filepath_no_ext = drop_extension(args.input)
output_filepath = output_filepath_no_ext + ".ts"
convert_tsx(args.input, output_filepath)
- elif ".tmx" in args.input[0]:
- args.input = args.input[0] # TODO: Remove this.
+ elif args.command == "tilemap":
+ print(f"Processing: {args.input}")
output_filepath_no_ext = drop_extension(args.input)
output_filepath = output_filepath_no_ext + ".tm"
convert_tmx(args.input, output_filepath)
- else:
- # Sprite sheets.
- if not args.width or not args.height:
- print("Sprite width and height must be given")
- return 1
+ elif args.command == "sprite":
+ print(f"Processing: {args.input}")
output_filepath = args.out if args.out else "out.ss"
convert_sprite_sheet(args.input, args.width, args.height,
output_filepath)
-
return 0
--
cgit v1.2.3