622 lines
19 KiB
Python
622 lines
19 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"] == "aseprite-anim-variation":
|
|
o = convert_aseprite_anim_variation(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
|
|
|
|
MONSTER_IDS = {
|
|
"slime/1": 1,
|
|
"bat/2": 2,
|
|
"fire_slime/4": 3,
|
|
"albinos_bat/6": 4,
|
|
"gunslinger/8": 5,
|
|
"water_slime/8": 6,
|
|
"chemical_slime/10": 7,
|
|
}
|
|
ITEM_IDS = {
|
|
"life": 0,
|
|
"potion_atk": 1,
|
|
"potion_cooldown": 2,
|
|
"potion_def": 3,
|
|
"potion_freeze": 4,
|
|
"potion_hp": 5,
|
|
"potion_speed": 6,
|
|
"scepter1": 101,
|
|
"scepter2": 102,
|
|
"sword1": 103,
|
|
"sword2": 104,
|
|
"armor1": 105,
|
|
}
|
|
|
|
def convert_level(input, params):
|
|
RE_VALUES = {
|
|
"name": re.compile(r'.*'),
|
|
"map": re.compile(r'[a-zA-Z_][a-zA-Z0-9-]*'),
|
|
"spawner": re.compile(r'\d+,\d+'),
|
|
"player_spawn": re.compile(r'\d+,\d+'),
|
|
"wave": re.compile(r'\d+s(\s+\d+\*[a-z_]+/\d+)+'),
|
|
"delay": re.compile(r'\d+s'),
|
|
"item": re.compile(r'[a-zA-Z_][a-zA-Z0-9_]*'),
|
|
}
|
|
with open(input, "r") as fp:
|
|
lines = [l for l in fp.read().splitlines() if l
|
|
and not l.startswith("#")]
|
|
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 level name
|
|
level_name = "<Untitled>"
|
|
for key, value in lines:
|
|
if key == "name":
|
|
level_name = 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}!")
|
|
|
|
# Determine player spawn
|
|
player_spawn = None
|
|
for key, value in lines:
|
|
if key == "player_spawn":
|
|
x, y = value.split(",")
|
|
player_spawn = bytes([int(x), int(y)])
|
|
if player_spawn is None:
|
|
raise fxconv.FxconvError(f"no player spawn 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 events; waves are (duration, monster list)
|
|
LEVEL_EVENT_DELAY = 0
|
|
LEVEL_EVENT_WAVE = 1
|
|
LEVEL_EVENT_ITEM = 2
|
|
event_count = 0
|
|
events = fxconv.Structure()
|
|
|
|
for key, value in lines:
|
|
if key == "delay":
|
|
delay = int(value[:-1])
|
|
events += fxconv.u32(LEVEL_EVENT_DELAY)
|
|
events += fxconv.u32(delay * 65536)
|
|
events += bytes(4)
|
|
event_count += 1
|
|
|
|
elif key == "wave":
|
|
duration, *monsters = value.split()
|
|
duration = int(duration[:-1])
|
|
for i, desc in enumerate(monsters):
|
|
count, identity = desc.split("*")
|
|
if identity not in MONSTER_IDS:
|
|
raise fxconv.FxconvError(f"unknown monster {identity}")
|
|
monsters[i] = (MONSTER_IDS[identity], int(count))
|
|
|
|
m = bytes()
|
|
for identity, amount in monsters:
|
|
m += bytes([identity, amount])
|
|
|
|
w = fxconv.Structure()
|
|
w += fxconv.u32(len(monsters))
|
|
w += fxconv.ptr(m)
|
|
|
|
events += fxconv.u32(LEVEL_EVENT_WAVE)
|
|
events += fxconv.u32(duration * 65536)
|
|
events += fxconv.ptr(w)
|
|
event_count += 1
|
|
|
|
elif key == "item":
|
|
events += fxconv.u32(LEVEL_EVENT_ITEM)
|
|
events += fxconv.u32(2 * 65536)
|
|
events += fxconv.u32(ITEM_IDS[value])
|
|
event_count += 1
|
|
|
|
o = fxconv.Structure()
|
|
o += fxconv.string(level_name)
|
|
o += fxconv.ptr(f"map_{map_name}")
|
|
o += player_spawn
|
|
o += bytes(2)
|
|
o += fxconv.u32(len(spawner_x))
|
|
o += fxconv.ptr(spawner_x)
|
|
o += fxconv.ptr(spawner_y)
|
|
o += fxconv.u32(event_count)
|
|
o += fxconv.ptr(events)
|
|
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, indexed=False, palette=None, newpalette=None):
|
|
if c.chunk_type != 0x2005: # Cel
|
|
raise fxconv.FxconvError("Rendering a Cel but it's not a Cel chunk")
|
|
if c.cel_type not in [0,2]:
|
|
raise fxconv.FxconvError("Rendering a Cel that is linked or tilemap")
|
|
|
|
colors = None
|
|
|
|
if indexed:
|
|
assert palette is not None and palette.chunk_type == 0x2019 # Palette
|
|
colors = [(c["red"], c["green"], c["blue"], c["alpha"])
|
|
for c in palette.colors]
|
|
if newpalette is not None:
|
|
assert indexed and newpalette.chunk_type == 0x2019 # Palette
|
|
newcolors = [(c["red"], c["green"], c["blue"], c["alpha"])
|
|
for c in newpalette.colors]
|
|
|
|
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):
|
|
if indexed:
|
|
i = c.data['data'][offset]
|
|
offset += 1
|
|
else:
|
|
r, g, b, a = c.data['data'][offset:offset+4]
|
|
offset += 4
|
|
|
|
# Remap palette if requested
|
|
if newpalette is not None:
|
|
r, g, b, a = newcolors[i]
|
|
elif indexed:
|
|
r, g, b, a = colors[i]
|
|
|
|
pixels.append((r, g, b, a))
|
|
|
|
img.putdata(pixels)
|
|
return img
|
|
|
|
def convert_aseprite_anim(input, output, params, newpalette=None):
|
|
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 == 8:
|
|
indexed = True
|
|
elif ase.header.color_depth == 32:
|
|
indexed = False
|
|
else:
|
|
raise fxconv.FxconvError("Only indexed/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) + ")")
|
|
|
|
#---
|
|
# Find palette
|
|
#---
|
|
|
|
palette_chunk = None
|
|
|
|
for c in ase.frames[0].chunks:
|
|
if c.chunk_type == 0x2019: # Palette
|
|
palette_chunk = c
|
|
|
|
#---
|
|
# 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, indexed, palette_chunk,
|
|
newpalette)
|
|
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 convert_aseprite_anim_variation(input, output, params):
|
|
from aseprite import AsepriteFile
|
|
|
|
if "base" in params:
|
|
base = params["base"]
|
|
elif "base_regex" in params:
|
|
regex = params["base_regex"].split(" ", 1)
|
|
base = re.sub(*regex, os.path.basename(input))
|
|
else:
|
|
raise fxconv.FxconvError(f"{input} lacks a 'base:' metadata")
|
|
|
|
if "palette" in params:
|
|
palette = params["palette"]
|
|
elif "palette_regex" in params:
|
|
regex = params["palette_regex"].split(" ", 1)
|
|
palette = re.sub(*regex, os.path.basename(input))
|
|
else:
|
|
raise fxconv.FxconvError(f"{input} lacks a 'palette:' metadata")
|
|
|
|
base_path = os.path.join(os.path.dirname(input), base)
|
|
palette_path = os.path.join(os.path.dirname(input), palette)
|
|
|
|
base_metadata = os.path.join(os.path.dirname(input),
|
|
"fxconv-metadata.txt")
|
|
base_metadata = fxconv.Metadata(base_metadata).rules_for(base)
|
|
|
|
params = { **base_metadata, **params }
|
|
|
|
with open(palette_path, "rb") as fp:
|
|
ase = AsepriteFile(fp.read())
|
|
|
|
newpalette = None
|
|
for c in ase.frames[0].chunks:
|
|
if c.chunk_type == 0x2019: # Palette
|
|
newpalette = c
|
|
if newpalette is None:
|
|
raise fxconv.FxconvError("f{input} has no palette chunk")
|
|
|
|
return convert_aseprite_anim(base_path, output, params, newpalette)
|
|
|
|
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"])
|
|
tilecount = int(tileset.attrib["tilecount"])
|
|
|
|
# In Rogue Life there is only 16x16 :)
|
|
assert tilewidth == 16 and tileheight == 16
|
|
|
|
def plane_id(plane_str):
|
|
return ["WALL", "FLOOR", "CEILING"].index(plane_str)
|
|
|
|
o = fxconv.Structure()
|
|
info = fxconv.Structure()
|
|
frames = fxconv.Structure()
|
|
frames_start = 0
|
|
|
|
# Tile sheet
|
|
source = os.path.join(os.path.dirname(input), image.attrib["source"])
|
|
sheet = Image.open(source)
|
|
o += fxconv.u32(sheet.width // 16)
|
|
o += fxconv.u32(sheet.height // 16)
|
|
o += fxconv.ptr(fxconv.convert_bopti_cg(sheet, params))
|
|
|
|
# Analyze tile metadata and animation
|
|
for i in range(tilecount):
|
|
tile = tileset.findall(f".//tile[@id='{i}']")
|
|
assert len(tile) in [0,1]
|
|
|
|
plane = "FLOOR"
|
|
anim_length = 0
|
|
anim_start = frames_start
|
|
solid = 0
|
|
|
|
if tile:
|
|
for prop in tile[0].findall("./properties/property"):
|
|
name = prop.attrib["name"]
|
|
type = prop.attrib.get("type", "string")
|
|
value = prop.attrib["value"]
|
|
|
|
if name == "plane" and type == "string":
|
|
plane = value
|
|
elif name == "solid" and type == "bool":
|
|
solid = int(value == "true")
|
|
else:
|
|
raise fxconv.FxconvError(
|
|
f"Unknown tile property '{name}' with type '{type}'")
|
|
|
|
for frame in tile[0].findall("./animation/frame"):
|
|
frames += fxconv.u16(int(frame.attrib["tileid"]))
|
|
frames += fxconv.u16(int(frame.attrib["duration"]))
|
|
frames_start += 1
|
|
anim_length += 1
|
|
|
|
info += fxconv.u8(plane_id(plane))
|
|
info += fxconv.u8(anim_length)
|
|
info += fxconv.u8(anim_start)
|
|
info += fxconv.u8(solid)
|
|
|
|
o += fxconv.ptr(info)
|
|
o += fxconv.ptr(frames)
|
|
return o
|
|
|
|
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
|
|
|
|
tiles = bytes()
|
|
for i in range(width * height):
|
|
t1 = data1[i] & 0x0fffffff
|
|
t2 = data2[i] & 0x0fffffff
|
|
# We turn empty tiles into index 0
|
|
t1 = 0 if t1 < tileset_base else t1 - tileset_base
|
|
t2 = 0 if t2 < tileset_base else t2 - tileset_base
|
|
|
|
tiles += fxconv.u8(t1)
|
|
tiles += fxconv.u8(t2)
|
|
|
|
o = fxconv.Structure()
|
|
o += fxconv.u16(width)
|
|
o += fxconv.u16(height)
|
|
o += fxconv.ptr(tileset_var)
|
|
o += fxconv.ptr(tiles)
|
|
return o
|