327 lines
13 KiB
Python
Executable file
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))
|