RogueLife/assets-cg/converters.py

477 lines
15 KiB
Python

import fxconv
import re
import os.path
import xml.etree.ElementTree
from PIL import Image, ImageChops
def convert(input, output, params, target):
recognized = True
if params["custom-type"] == "level":
o = convert_level(input, params)
elif params["custom-type"] == "animation":
o = convert_animation(input, params)
elif params["custom-type"] == "aseprite-anim":
o = convert_aseprite_anim(input, output, params)
elif params["custom-type"] == "tiled-tileset":
o = convert_tiled_tileset(input, output, params)
elif params["custom-type"] == "tiled-map":
o = convert_tiled_map(input, output, params)
else:
recognized = False
if recognized:
fxconv.elf(o, output, "_" + params["name"], **target)
return 0
return 1
def convert_level(input, params):
RE_VALUES = {
"map": re.compile(r'[a-zA-Z_][a-zA-Z0-9-]*'),
"spawner": re.compile(r'\d+,\d+'),
"wave": re.compile(r'\d+s(\s+\d+\*[a-z]+/\d+)+'),
"delay": re.compile(r'\d+s')
}
with open(input, "r") as fp:
lines = [l for l in fp.read().splitlines() if l]
for i, l in enumerate(lines):
if ":" not in l:
raise fxconv.FxconvError(f"invalid line {l}: not a key/value")
key, value = l.split(":", 1)
key = key.strip()
value = value.strip()
if key not in RE_VALUES:
raise fxconv.FxconvError(f"unknown key '{key}'")
if not RE_VALUES[key].fullmatch(value):
raise fxconv.FxconvError(f"invalid value for '{key}': '{value}'")
lines[i] = (key, value)
# Determine map name
map_name = None
for key, value in lines:
if key == "map":
map_name = value
if map_name is None:
raise fxconv.FxconvError(f"no map name in {input}!")
# Get list of spawners
spawner_x = bytes()
spawner_y = bytes()
for key, value in lines:
if key == "spawner":
x, y = value.split(",")
spawner_x += bytes([int(x)])
spawner_y += bytes([int(y)])
# Get list of waves; each wave is (duration, delay_after, monster list)
waves = []
for key, value in lines:
if key == "delay":
delay = int(value[:-1])
duration, delay_after, monsters = waves[-1]
waves[-1] = (duration, delay_after+delay, monsters)
if key == "wave":
duration, *monsters = value.split()
duration = int(duration[:-1])
delay_after = 0
for i, desc in enumerate(monsters):
count, identity = desc.split("*")
if identity == "slime/1":
identity = 1
elif identity == "bat/2":
identity = 2
elif identity == "slime/4":
identity = 3
elif identity == "gunslinger/8":
identity = 4
else:
raise fxconv.FxconvError(f"unknown monster {identity}")
monsters[i] = (identity, int(count))
waves.append((duration, delay_after, monsters))
# Turns waves into bytes
w = fxconv.Structure()
for duration, delay_after, monsters in waves:
m = bytes()
for identity, amount in monsters:
m += bytes([identity, amount])
w += fxconv.u32(len(monsters))
w += fxconv.ptr(m)
w += fxconv.u32(duration)
w += fxconv.u32(delay_after)
o = fxconv.Structure()
o += fxconv.ptr(f"map_{map_name}")
o += fxconv.u32(len(spawner_x))
o += fxconv.ptr(spawner_x)
o += fxconv.ptr(spawner_y)
o += fxconv.u32(len(waves))
o += fxconv.ptr(w)
return o
def convert_animation(input, params):
img = Image.open(input).convert("RGBA")
fsize = [0,0]
center = (0,0)
durations = None
frame_count = None
next_anim = None
# Read parameters
if "frame_duration" in params:
durations = [int(s) for s in params["frame_duration"].split(",")]
else:
durations = []
if "frame_size" in params:
fsize = list(map(int, params["frame_size"].split("x")))
if "frame_height" in params:
fsize[1] = int(params["frame_height"])
elif not fsize[1]:
fsize[1] = img.height
if "frame_width" in params:
fsize[0] = int(params["frame_width"])
elif not fsize[0] and durations:
fsize[0] = img.width // len(durations)
else:
raise fxconv.FxconvError("frame_width, frame_size, and frame_duration"+
" are all unspecified - tell me more!")
if "center" in params:
center = tuple(map(int, params["center"].split(",")))
else:
center = (fsize[0] // 2, fsize[1] // 2)
next_anim = params.get("next")
name = params["name"]
# Read and minimize frames
grid = fxconv.Grid({ "width": fsize[0], "height": fsize[1] })
frame_count = grid.size(img)
bg = Image.new(img.mode, fsize, (0, 0, 0, 0))
frames = []
total_width = 0
total_height = 0
i = 0
for rect in grid.iter(img):
frame = img.crop(rect)
nonzero = ImageChops.difference(frame, bg)
bbox = nonzero.getbbox()
if bbox:
ox, oy = bbox[0], bbox[1]
frame = frame.crop(bbox)
else:
ox, oy = 0, 0
total_width += frame.width
total_height = max(total_height, frame.height)
frames.append((frame, ox, oy))
i += 1
# Create compact spritesheet and data arrays
if total_width > 256:
raise fxconv.FxconvError("uh total_width>256, call Lephe' x_x")
sheet = Image.new("RGBA", (total_width, total_height))
o = fxconv.ObjectData()
sizeof_frame = 16
x = 0
for i, (frame, ox, oy) in enumerate(frames):
o += fxconv.ref(f"{name}_sheet")
o += fxconv.u8(x) + fxconv.u8(0)
o += fxconv.u8(frame.width) + fxconv.u8(frame.height)
o += fxconv.u8(center[0] - ox)
o += fxconv.u8(center[1] - oy)
o += fxconv.u16(durations[i])
if i < len(frames) - 1:
o += fxconv.ref(name, (i+1) * sizeof_frame)
elif next_anim:
o += fxconv.ref(next_anim)
else:
o += fxconv.u32(0)
sheet.paste(frame, (x, 0))
x += frame.width
o += fxconv.sym(f"{name}_sheet")
o += fxconv.convert_bopti_cg(sheet, {
"profile": params.get("profile", "p8"),
})
return o
def _aseprite_render_cel(c):
if c.chunk_type != 0x2005: # Cel
raise fxconv.FxconvError("Rendering a Cel without a Cel chunk")
if c.cel_type not in [0,2]:
raise fxconv.FxconvError("Rendering a Cel that is linked or tilemap")
img = Image.new("RGBA", (c.data['width'], c.data['height']))
pixels = []
offset = 0
for y in range(img.height):
for x in range(img.width):
r, g, b, a = c.data['data'][offset:offset+4]
pixels.append((r, g, b, a))
offset += 4
img.putdata(pixels)
return img
def convert_aseprite_anim(input, output, params):
from aseprite import AsepriteFile
#---
# Perform checks for support on the Aseprite file
#---
with open(input, "rb") as fp:
ase = AsepriteFile(fp.read())
if ase.header.color_depth != 32:
raise fxconv.FxconvError("Only RGBA supported yet, sorry x_x")
# Find tags that give names to animations
tags = None
for c in ase.frames[0].chunks:
if c.chunk_type == 0x2018: # Tags
tags = [(t["name"], t["from"], t["to"]) for t in c.tags]
if tags is None:
tags = [(None, 0, ase.header.num_frames-1)]
#---
# Print summary of animations
#---
print(f"{os.path.basename(input)} ({ase.header.width}x{ase.header.height})",
f"has {len(tags)} animations:")
for (name, from_, to) in tags:
name = f"'{name}'" if name else "(untagged)"
print(f" {name}: Frames {from_} to {to}", end="")
durations = [ase.frames[i].frame_duration for i in range(from_, to+1)]
print(" (" + ", ".join(f"{d} ms" for d in durations) + ")")
#---
# Generate PIL images for each frame
#---
pil_frames = []
for i, f in enumerate(ase.frames):
img = Image.new("RGBA", (ase.header.width, ase.header.height))
for chunk in f.chunks:
if chunk.chunk_type == 0x2005: # Cel
# Render only visible layers
if ase.layers[chunk.layer_index].flags & 1:
# Resolve linked cells
if chunk.cel_type == 1: # Linked cel
x = ase.frames[chunk.data['link'][0]].chunks
x = [c for c in x if c.chunk_type == 0x2005]
chunk = x[chunk.layer_index]
cel = _aseprite_render_cel(chunk)
img.paste(cel, (chunk.x_pos, chunk.y_pos))
pil_frames.append(img)
#---
# Parse parameters
#---
if "center" in params:
center = tuple(map(int, params["center"].split(",")))
else:
center = (ase.header.width // 2, ase.header.height // 2)
if "next" in params:
next_anim = [s.strip() for s in params["next"].split(",")]
next_anim = dict([s.split("=", 1) for s in next_anim])
else:
next_anim = dict()
#---
# Generate compact sheets and object data for each animation
#---
bg = Image.new("RGBA", (ase.header.width, ase.header.height), (0, 0, 0, 0))
# This will hold all animations in sequence
o = fxconv.ObjectData()
sizeof_frame = 16
for (name, from_, to) in tags:
total_width = 0
total_height = 0
for i in range(from_, to+1):
bbox = ImageChops.difference(pil_frames[i], bg).getbbox()
# pil_frames[i] becomes (offset_x, offset_y, cropped image)
if bbox:
pil_frames[i] = (bbox[0], bbox[1], pil_frames[i].crop(bbox))
else:
pil_frames[i] = (0, 0, pil_frames[i])
total_width += pil_frames[i][2].width
total_height = max(total_height, pil_frames[i][2].height)
# Define a new symbol for ech animation (except if there is only one)
symname = params["name"] + ("_" + name if name is not None else "")
s = fxconv.Structure()
sheet = Image.new("RGBA", (total_width, total_height), (0, 0, 0, 0))
x = 0
for i in range(from_, to+1):
s += fxconv.ref(f"{symname}_sheet")
s += fxconv.u8(x) + fxconv.u8(0)
s += fxconv.u8(pil_frames[i][2].width)
s += fxconv.u8(pil_frames[i][2].height)
s += fxconv.u8(center[0] - pil_frames[i][0])
s += fxconv.u8(center[1] - pil_frames[i][1])
s += fxconv.u16(ase.frames[i].frame_duration)
if i < to:
s += fxconv.ref(symname, (i-from_+1) * sizeof_frame)
elif name in next_anim:
s += fxconv.ref(params["name"] + "_" + next_anim[name])
else:
s += fxconv.u32(0)
sheet.paste(pil_frames[i][2], (x, 0))
x += pil_frames[i][2].width
o += fxconv.sym(symname)
o += s
o += fxconv.sym(f"{symname}_sheet")
o += fxconv.convert_bopti_cg(sheet, {
"profile": params.get("profile", "p8"),
})
return o
def print_xml_tree(node, indent=0):
print(indent*" " + f"<{node.tag}> {node.attrib}")
for child in node:
print_xml_tree(child, indent+2)
def convert_tiled_tileset(input, output, params):
tree = xml.etree.ElementTree.parse(input)
tileset = tree.getroot()
assert tileset.tag == "tileset"
# We only support single-source tilesets
images = tileset.findall("image")
assert len(images) == 1
image = images[0]
tilewidth = int(tileset.attrib["tilewidth"])
tileheight = int(tileset.attrib["tileheight"])
# In Rogue Life there is only 16x16 :)
assert tilewidth == 16 and tileheight == 16
# Current we just convert the image, but we could do more later (especially
# if this converter gets reused in other projects)
source = os.path.join(os.path.dirname(input), image.attrib["source"])
return fxconv.convert_bopti_cg(source, params)
def convert_tiled_map(input, output, params):
tree = xml.etree.ElementTree.parse(input)
map = tree.getroot()
assert map.tag == "map"
width = int(map.attrib["width"])
height = int(map.attrib["height"])
tilesets = map.findall("tileset")
assert len(tilesets) == 1
tileset = tilesets[0]
tileset_base = int(tileset.attrib["firstgid"])
# Grab tileset variable name from file path
tileset_var = os.path.basename(tileset.attrib["source"])
tileset_var = "tileset_" + os.path.splitext(tileset_var)[0]
layers = map.findall("layer")
assert len(layers) == 2
assert all(l.find("data").attrib["encoding"] == "csv" for l in layers)
data1 = [int(x) for x in layers[0].find("data").text.split(",")]
data2 = [int(x) for x in layers[1].find("data").text.split(",")]
assert len(data1) == width * height
assert len(data2) == width * height
#---
tileset = xml.etree.ElementTree.parse(
os.path.join(os.path.dirname(input), tileset.attrib["source"]))
tileprops = dict()
for tile in tileset.findall("tile"):
tile_id = int(tile.attrib["id"])
p = dict()
for prop in tile.find("properties").findall("property"):
name = prop.attrib["name"]
type = prop.attrib.get("type", "string")
value = prop.attrib["value"]
if type == "bool":
value = (value == "true")
elif type == "float":
value = float(value)
elif type == "file":
pass
elif type == "int":
value = int(value)
elif type == "string":
pass
else: # including "color" and "object"
raise Exception(f"unknown tile property type {type}")
p[name] = value
tileprops[tile_id] = p
#---
tiles = bytes()
for i in range(width * height):
t1 = data1[i] & 0x0fffffff
t2 = data2[i] & 0x0fffffff
# We expect tileset index 0 to be empty
t1 = 0 if t1 < tileset_base else t1 - tileset_base
t2 = 0 if t2 < tileset_base else t2 - tileset_base
solid = 0
plane = "FLOOR"
if t1 in tileprops:
solid = (tileprops[t1].get("solid", False) == True)
plane = tileprops[t1].get("plane", "FLOOR")
plane = ["WALL", "FLOOR", "CEILING"].index(plane)
tiles += bytes([solid, plane, t1, t2])
o = fxconv.Structure()
o += fxconv.u16(width)
o += fxconv.u16(height)
o += fxconv.ptr(tileset_var)
o += fxconv.ptr(tiles)
return o