#!/usr/bin/env python3 # Part of JoeyLib, Copyright (c) 2026 Scott Duensing, under the MIT License. # See LICENSE.md for license information. # SPDX-License-Identifier: MIT """tiled2joey.py - a Tiled map as JoeyLib tiles and tile maps. tiled2joey.py [--header OUT.h] MAP SHEET.png MAPDIR MAP is a Tiled map, TMX (XML) or TMJ (JSON): orthogonal, finite, with 8x8 tiles. Its tilesets, embedded or external (TSX or TSJ), are single images, indexed PNGs whose colours make one palette of at most 16 between them: the first tileset's colours keep their indices (so its index 0 stays the background), and the others' are matched by colour or given a slot the first does not use. SHEET.png gets the tiles the map's tile layers use, in a row, in the order they first appear: what assetbake.py --type tile bakes into a tile bank for each port. A tile Tiled draws flipped or rotated is baked as a tile of its own (JoeyLib draws no tile flipped), so the map can use up to 255 distinct tiles and orientations between all its layers. MAPDIR gets one LAYER.map for each tile layer (its name, letters, digits, - and _ kept): width x height bytes, row after row, each a tile's index in the bank or 0xFF (JL_TILE_MAP_SKIP) where the layer is empty, as jlTileMapPaste takes them. The files are the same on every port. --header writes a C header naming the map's size, its tile count, and each layer's file. Object and image layers, tile animations, and tile properties are left out. """ import base64 import gzip import json import os import re import struct import sys import xml.etree.ElementTree as ElementTree import zlib from PIL import Image TILE_SIDE = 8 MAX_TILES = 255 SKIP = 0xFF MAX_COLOURS = 16 FLIP_H = 0x80000000 FLIP_V = 0x40000000 FLIP_D = 0x20000000 ROTATE_HEX = 0x10000000 GID_MASK = 0x0FFFFFFF def fail(message): sys.exit("tiled2joey: " + message) def isXml(path): with open(path, "rb") as f: return f.read(64).lstrip().startswith(b"<") def decodeData(text, encoding, compression): """A layer's gids from its data: csv, or base64 of little-endian 32-bit words, raw, zlib or gzip.""" if encoding in (None, "", "csv"): if isinstance(text, list): return [int(v) for v in text] return [int(v) for v in re.split(r"[\s,]+", text.strip()) if v] if encoding != "base64": fail("layer encoding %r is not supported" % encoding) raw = base64.b64decode(text.strip()) if compression == "zlib": raw = zlib.decompress(raw) elif compression == "gzip": raw = gzip.decompress(raw) elif compression not in (None, ""): fail("layer compression %r is not supported (use zlib, gzip, or none)" % compression) return list(struct.unpack("<%dI" % (len(raw) // 4), raw)) def loadTilesetXml(element, base): if element.find("image") is None: fail("tileset %r is a collection of images; use one image" % element.get("name")) image = element.find("image") return { "name": element.get("name"), "tilewidth": int(element.get("tilewidth")), "tileheight": int(element.get("tileheight")), "margin": int(element.get("margin", 0)), "spacing": int(element.get("spacing", 0)), "columns": int(element.get("columns", 0)), "image": os.path.join(base, image.get("source")), "animated": any(t.find("animation") is not None for t in element.findall("tile")), } def loadTilesetJson(data, base): if "image" not in data: fail("tileset %r is a collection of images; use one image" % data.get("name")) return { "name": data.get("name"), "tilewidth": data["tilewidth"], "tileheight": data["tileheight"], "margin": data.get("margin", 0), "spacing": data.get("spacing", 0), "columns": data.get("columns", 0), "image": os.path.join(base, data["image"]), "animated": any("animation" in t for t in data.get("tiles", [])), } def loadExternalTileset(path): base = os.path.dirname(path) if isXml(path): return loadTilesetXml(ElementTree.parse(path).getroot(), base) with open(path) as f: return loadTilesetJson(json.load(f), base) def loadMap(path): """(width, height, tilesets as (firstgid, tileset), tile layers as (name, gids)) from a TMX or TMJ map.""" base = os.path.dirname(path) tilesets = [] layers = [] if isXml(path): root = ElementTree.parse(path).getroot() header = {k: root.get(k) for k in ("orientation", "infinite", "tilewidth", "tileheight", "width", "height")} for element in root.findall("tileset"): if element.get("source"): tileset = loadExternalTileset(os.path.join(base, element.get("source"))) else: tileset = loadTilesetXml(element, base) tilesets.append((int(element.get("firstgid")), tileset)) for element in root.iter("layer"): data = element.find("data") if data.find("chunk") is not None: fail("layer %r is in chunks (an infinite map); make the map finite" % element.get("name")) if data.find("tile") is not None: gids = [int(t.get("gid", 0)) for t in data.findall("tile")] else: gids = decodeData(data.text or "", data.get("encoding"), data.get("compression")) layers.append((element.get("name"), gids)) else: with open(path) as f: root = json.load(f) header = {k: root.get(k) for k in ("orientation", "infinite", "tilewidth", "tileheight", "width", "height")} for entry in root.get("tilesets", []): if "source" in entry: tileset = loadExternalTileset(os.path.join(base, entry["source"])) else: tileset = loadTilesetJson(entry, base) tilesets.append((entry["firstgid"], tileset)) def walk(items): for layer in items: if layer.get("type") == "group": walk(layer.get("layers", [])) elif layer.get("type") == "tilelayer": if "chunks" in layer: fail("layer %r is in chunks (an infinite map); make the map finite" % layer.get("name")) layers.append((layer.get("name"), decodeData(layer["data"], layer.get("encoding"), layer.get("compression")))) walk(root.get("layers", [])) if header["orientation"] != "orthogonal": fail("the map is %s; only orthogonal maps are supported" % header["orientation"]) if str(header["infinite"]).lower() in ("1", "true"): fail("the map is infinite; make it finite") if int(header["tilewidth"]) != TILE_SIDE or int(header["tileheight"]) != TILE_SIDE: fail("the map's tiles are %sx%s; JoeyLib's are 8x8" % (header["tilewidth"], header["tileheight"])) width = int(header["width"]) height = int(header["height"]) if not layers: fail("the map has no tile layers") for name, gids in layers: if len(gids) != width * height: fail("layer %r holds %d cells, not %d" % (name, len(gids), width * height)) tilesets.sort(key=lambda entry: entry[0]) return width, height, tilesets, layers def tilesetPixels(tileset, palette, first): """The tileset's tiles as 8x8 grids of indices into palette, a list of MAX_COLOURS colours (None where free). The first tileset's colours take their own indices; another's are matched by colour, or take the first free slot.""" if tileset["tilewidth"] != TILE_SIDE or tileset["tileheight"] != TILE_SIDE: fail("tileset %r has %dx%d tiles; JoeyLib's are 8x8" % (tileset["name"], tileset["tilewidth"], tileset["tileheight"])) if tileset["animated"]: sys.stderr.write("tiled2joey: tileset %r animates tiles; only the tiles the map places are kept\n" % tileset["name"]) image = Image.open(tileset["image"]) if image.mode != "P": fail("%s is mode %s; tilesets must be indexed PNGs (mode P)" % (tileset["image"], image.mode)) source = image.getpalette() used = sorted({index for _, index in image.getcolors(maxcolors=256)}) remap = {} for index in used: rgb = tuple(source[index * 3:index * 3 + 3]) if first: if index >= MAX_COLOURS: fail("%s uses palette index %d; tiles have %d colours" % (tileset["image"], index, MAX_COLOURS)) palette[index] = rgb elif rgb not in palette: if None not in palette: fail("the tilesets' colours make more than %d" % MAX_COLOURS) palette[palette.index(None)] = rgb remap[index] = palette.index(rgb) columns = tileset["columns"] or (image.width - 2 * tileset["margin"] + tileset["spacing"]) // (TILE_SIDE + tileset["spacing"]) rows = (image.height - 2 * tileset["margin"] + tileset["spacing"]) // (TILE_SIDE + tileset["spacing"]) pixels = image.load() tiles = [] for row in range(rows): for column in range(columns): x0 = tileset["margin"] + column * (TILE_SIDE + tileset["spacing"]) y0 = tileset["margin"] + row * (TILE_SIDE + tileset["spacing"]) tiles.append([[remap[pixels[x0 + x, y0 + y]] for x in range(TILE_SIDE)] for y in range(TILE_SIDE)]) return tiles def orient(tile, flags): """A tile as Tiled draws it: the diagonal flip (x and y swapped) first, then the horizontal, then the vertical.""" if flags & FLIP_D: tile = [[tile[x][y] for x in range(TILE_SIDE)] for y in range(TILE_SIDE)] if flags & FLIP_H: tile = [list(reversed(row)) for row in tile] if flags & FLIP_V: tile = list(reversed(tile)) return tile def layerFileName(name, taken): base = re.sub(r"[^A-Za-z0-9_-]+", "_", name).strip("_") or "layer" candidate = base n = 2 while candidate in taken: candidate = "%s_%d" % (base, n) n += 1 taken.add(candidate) return candidate + ".map" def writeHeader(path, mapPath, width, height, count, files): guard = re.sub(r"[^A-Za-z0-9]", "_", os.path.basename(path)).upper() prefix = re.sub(r"[^A-Za-z0-9]", "_", os.path.splitext(os.path.basename(mapPath))[0]).upper() lines = [ "// Generated by tools/tiled2joey.py from %s: do not edit." % os.path.basename(mapPath), "#ifndef %s" % guard, "#define %s" % guard, "", "#define %s_WIDTH %du // tiles across" % (prefix, width), "#define %s_HEIGHT %du // tiles down" % (prefix, height), "#define %s_TILES %du // tiles in the bank" % (prefix, count), "", "// The layers' map files, in the map's order:", ] lines += ["// %s (%s)" % (fileName, name) for name, fileName in files] lines += ["", "#endif", ""] with open(path, "w") as f: f.write("\n".join(lines)) def main(argv): header = None args = argv[1:] if len(args) >= 2 and args[0] == "--header": header = args[1] args = args[2:] if len(args) != 3: sys.stderr.write(__doc__) return 2 mapPath, sheetPath, mapDir = args width, height, tilesets, layers = loadMap(mapPath) palette = [None] * MAX_COLOURS sources = [(firstgid, tilesetPixels(tileset, palette, n == 0)) for n, (firstgid, tileset) in enumerate(tilesets)] order = {} tiles = [] maps = [] for name, gids in layers: cells = bytearray() for gid in gids: if gid & ROTATE_HEX: fail("layer %r rotates a tile 120 degrees (hexagonal maps only)" % name) flags = gid & (FLIP_H | FLIP_V | FLIP_D) gid &= GID_MASK if gid == 0: cells.append(SKIP) continue firstgid, source = max((entry for entry in sources if entry[0] <= gid), key=lambda entry: entry[0]) local = gid - firstgid if local >= len(source): fail("layer %r places gid %d, past its tileset" % (name, gid)) key = (firstgid, local, flags) if key not in order: if len(tiles) == MAX_TILES: fail("the map uses more than %d distinct tiles and orientations" % MAX_TILES) order[key] = len(tiles) tiles.append(orient(source[local], flags)) cells.append(order[key]) maps.append((name, cells)) if not tiles: fail("the map's tile layers are empty") sheet = Image.new("P", (TILE_SIDE * len(tiles), TILE_SIDE)) flat = [c for rgb in palette for c in (rgb or (0, 0, 0))] sheet.putpalette(flat + [0] * (3 * 256 - len(flat))) sheet.putdata([tile[y][x] for y in range(TILE_SIDE) for tile in tiles for x in range(TILE_SIDE)]) sheet.save(sheetPath) os.makedirs(mapDir, exist_ok=True) taken = set() files = [] for name, cells in maps: fileName = layerFileName(name, taken) with open(os.path.join(mapDir, fileName), "wb") as f: f.write(bytes(cells)) files.append((name, fileName)) if header: writeHeader(header, mapPath, width, height, len(tiles), files) colours = sum(1 for rgb in palette if rgb is not None) print("tiled2joey: %dx%d map, %d tile%s, %d colour%s, layers: %s" % (width, height, len(tiles), "" if len(tiles) == 1 else "s", colours, "" if colours == 1 else "s", ", ".join(f for _, f in files))) return 0 if __name__ == "__main__": sys.exit(main(sys.argv))