joeylib2/tools/tiled2joey.py

327 lines
13 KiB
Python
Executable file

#!/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))