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 = "" 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