1#!/usr/bin/env python3
2
3# decompose.py
4# Split a gfx tile_config.json into 1000s of little directories,
5# each with their own config file and tile image.
6
7"""Split a tileset's tile_config.json into one directory per tile
8containing the tile data and png.
9"""
10
11import argparse
12import json
13import math
14import os
15import subprocess
16import sys
17
18try:
19    import pyvips
20    Vips = pyvips
21except ImportError:
22    import gi
23    gi.require_version('Vips', '8.0')
24    from gi.repository import Vips
25
26
27def write_to_json(pathname, data, prettify=False):
28    with open(pathname, "w") as fp:
29        json.dump(data, fp)
30
31    json_formatter = "./tools/format/json_formatter.cgi"
32    if prettify and os.path.isfile(json_formatter):
33        cmd = [json_formatter, pathname]
34        subprocess.call(cmd)
35
36
37def find_or_make_dir(pathname):
38    try:
39        os.stat(pathname)
40    except OSError:
41        os.mkdir(pathname)
42
43
44class TileSheetData(object):
45    def __init__(self, tilesheet_data, refs):
46        self.ts_filename = tilesheet_data.get("file", "")
47        #print("working on {}".format(self.ts_filename))
48        self.tile_entries = {}
49        self.sprite_height = tilesheet_data.get(
50            "sprite_height", refs.default_height)
51        self.sprite_width = tilesheet_data.get(
52            "sprite_width", refs.default_width)
53        self.sprite_offset_x = tilesheet_data.get("sprite_offset_x", 0)
54        self.sprite_offset_y = tilesheet_data.get("sprite_offset_y", 0)
55        self.write_dim = self.sprite_width != refs.default_width
56        self.write_dim |= self.sprite_height != refs.default_height
57        self.write_dim |= self.sprite_offset_x or self.sprite_offset_y
58        self.ts_pathname = refs.tileset_pathname + "/" + self.ts_filename
59        self.ts_image = Vips.Image.pngload(self.ts_pathname)
60        self.ts_width = self.ts_image.width
61        self.ts_tiles_per_row = int(
62            math.floor(self.ts_width / self.sprite_width))
63        self.ts_height = self.ts_image.height
64        self.ts_rows = int(math.floor(self.ts_height / self.sprite_height))
65        self.pngnum_min = refs.last_pngnum
66        self.pngnum_max = \
67            refs.last_pngnum + self.ts_tiles_per_row * self.ts_rows - 1
68        #print("\t{}x{}; {} rows; spans {} to {}".format(
69        #    self.ts_width, self.ts_height, self.ts_rows,
70        #    self.pngnum_min, self.pngnum_max))
71        self.expansions = []
72        self.fallback = tilesheet_data.get("ascii")
73        refs.last_pngnum = self.pngnum_max + 1
74        refs.tspathname_to_tsfilename.setdefault(
75            self.ts_pathname, self.ts_filename)
76
77    def check_for_expansion(self, tile_entry):
78        if tile_entry.get("fg", -10) == 0:
79            self.expansions.append(tile_entry)
80            return True
81        return False
82
83    def check_id_valid(self, tile_id):
84        if not tile_id:
85            return True
86        # wielded terrain isn't valid
87        if tile_id.startswith("overlay_wielded_t_"):
88            return False
89        if tile_id.startswith("overlay_wielded_mon_"):
90            return False
91        if tile_id.startswith("overlay_wielded_fd_"):
92            return False
93        if tile_id.startswith("overlay_wielded_f_"):
94            return False
95        # wielding or wearing overlays makes no sense
96        if tile_id.startswith("overlay_wielded_overlay"):
97            return False
98        if tile_id.startswith("overlay_worn_overlay"):
99            return False
100        return True
101
102    def parse_id(self, tile_entry):
103        all_tile_ids = []
104        read_tile_ids = tile_entry.get("id")
105        valid = True
106        if isinstance(read_tile_ids, list):
107            for tile_id in read_tile_ids:
108                valid &= self.check_id_valid(tile_id)
109                if tile_id and valid and tile_id not in all_tile_ids:
110                    all_tile_ids.append(tile_id)
111        else:
112            valid &= self.check_id_valid(read_tile_ids)
113            if read_tile_ids and valid and read_tile_ids not in all_tile_ids:
114                all_tile_ids.append(read_tile_ids)
115        if not valid:
116            return None, None
117        if not all_tile_ids:
118            return "background", ["background"]
119        #print("tile {}".format(all_tile_ids[0]))
120        return all_tile_ids[0], all_tile_ids
121
122    def parse_index(self, read_pngnums, all_pngnums, refs):
123        local_pngnums = []
124        if isinstance(read_pngnums, list):
125            for pngnum in read_pngnums:
126                if isinstance(pngnum, dict):
127                    sprite_ids = pngnum.get("sprite", -10)
128                    if isinstance(sprite_ids, list):
129                        for sprite_id in sprite_ids:
130                            if (sprite_id < 0 or
131                                    sprite_id in refs.delete_pngnums):
132                                continue
133                            if sprite_id not in all_pngnums:
134                                all_pngnums.append(sprite_id)
135                            if sprite_id not in local_pngnums:
136                                local_pngnums.append(sprite_id)
137                    else:
138                        if sprite_ids < 0 or sprite_ids in refs.delete_pngnums:
139                            continue
140                        if sprite_ids not in all_pngnums:
141                            all_pngnums.append(sprite_ids)
142                        if sprite_ids not in local_pngnums:
143                            local_pngnums.append(sprite_ids)
144                else:
145                    if pngnum < 0 or pngnum in refs.delete_pngnums:
146                        continue
147                    if pngnum not in all_pngnums:
148                        all_pngnums.append(pngnum)
149                    if pngnum not in local_pngnums:
150                        local_pngnums.append(pngnum)
151        elif read_pngnums >= 0 and read_pngnums not in refs.delete_pngnums:
152            if read_pngnums not in all_pngnums:
153                all_pngnums.append(read_pngnums)
154            if read_pngnums not in local_pngnums:
155                local_pngnums.append(read_pngnums)
156        return all_pngnums
157
158    def parse_png(self, tile_entry, refs):
159        all_pngnums = []
160        fg_id = tile_entry.get("fg", -10)
161        all_pngnums = self.parse_index(fg_id, all_pngnums, refs)
162
163        bg_id = tile_entry.get("bg", -10)
164        all_pngnums = self.parse_index(bg_id, all_pngnums, refs)
165
166        add_tile_entrys = tile_entry.get("additional_tiles", [])
167        for add_tile_entry in add_tile_entrys:
168            add_pngnums = self.parse_png(add_tile_entry, refs)
169            for add_pngnum in add_pngnums:
170                if add_pngnum not in all_pngnums:
171                    all_pngnums.append(add_pngnum)
172        # print("\tpngs: {}".format(all_pngnums))
173        return all_pngnums
174
175    def parse_tile_entry(self, tile_entry, refs):
176        if self.check_for_expansion(tile_entry):
177            return None
178        tile_id, all_tile_ids = self.parse_id(tile_entry)
179        if not tile_id:
180            # print("no id for {}".format(tile_entry))
181            return None
182        tile_id = tile_id.replace("/", "_")
183        all_pngnums = self.parse_png(tile_entry, refs)
184        offset = 0
185        for i in range(0, len(all_pngnums)):
186            pngnum = all_pngnums[i]
187            if pngnum in refs.pngnum_to_pngname:
188                continue
189            pngname = "{}_{}_{}".format(pngnum, tile_id, i + offset)
190            while pngname in refs.pngname_to_pngnum:
191                offset += 1
192                pngname = "{}_{}_{}".format(pngnum, tile_id, i + offset)
193            try:
194                refs.pngnum_to_pngname.setdefault(pngnum, pngname)
195                refs.pngname_to_pngnum.setdefault(pngname, pngnum)
196                refs.add_pngnum_to_tsfilepath(pngnum)
197            except TypeError:
198                print("failed to parse {}".format(
199                    json.dumps(tile_entry, indent=2)))
200                raise
201        return tile_id
202
203    def summarize(self, tile_info, refs):
204        #debug statement to verify pngnum_min and pngnum_max
205        #print("{} from {} to {}".format(
206        #    self.ts_filename, self.pngnum_min, self.pngnum_max))
207        if self.fallback:
208            refs.ts_data[self.ts_filename] = self
209            ts_tile_info = {
210                "fallback": True
211            }
212            tile_info.append({self.ts_filename: ts_tile_info})
213            return
214        if self.pngnum_max > 0:
215            refs.ts_data[self.ts_filename] = self
216            ts_tile_info = {
217                "//": "indices {} to {}".format(
218                    self.pngnum_min, self.pngnum_max)
219            }
220            if self.write_dim:
221                ts_tile_info["sprite_offset_x"] = self.sprite_offset_x
222                ts_tile_info["sprite_offset_y"] = self.sprite_offset_y
223                ts_tile_info["sprite_width"] = self.sprite_width
224                ts_tile_info["sprite_height"] = self.sprite_height
225            #print("{}: {}".format(
226            #    self.ts_filename, json.dumps(ts_tile_info, indent=2)))
227            tile_info.append({self.ts_filename: ts_tile_info})
228
229
230class ExtractionData(object):
231    def __init__(self, ts_filename, refs):
232        self.ts_data = refs.ts_data.get(ts_filename)
233        self.valid = False
234        if not self.ts_data.sprite_width or not self.ts_data.sprite_height:
235            return
236
237        self.valid = True
238
239        ts_base = ts_filename.split(".png")[0]
240        geometry_dim = "{}x{}".format(
241            self.ts_data.sprite_width, self.ts_data.sprite_height)
242        pngs_dir = "/pngs_" + ts_base + "_{}".format(geometry_dim)
243        self.ts_dir_pathname = refs.tileset_pathname + pngs_dir
244        find_or_make_dir(self.ts_dir_pathname)
245        self.tilenum_in_dir = 256
246        self.dir_count = 0
247        self.subdir_pathname = ""
248
249    def write_expansions(self):
250        for expand_entry in self.ts_data.expansions:
251            expansion_id = expand_entry.get("id", "expansion")
252            if not isinstance(expansion_id, str):
253                continue
254            expand_entry_pathname = "{}/{}.json".format(
255                self.ts_dir_pathname, expansion_id)
256            write_to_json(expand_entry_pathname, expand_entry)
257
258    def increment_dir(self):
259        if self.tilenum_in_dir > 255:
260            self.subdir_pathname = "{}/images{}".format(
261                self.ts_dir_pathname, self.dir_count)
262            find_or_make_dir(self.subdir_pathname)
263            self.tilenum_in_dir = 0
264            self.dir_count += 1
265        else:
266            self.tilenum_in_dir += 1
267        return self.subdir_pathname
268
269    def extract_image(self, png_index, refs):
270        if not png_index or refs.extracted_pngnums.get(png_index):
271            return
272        if png_index not in refs.pngnum_to_pngname:
273            return
274        pngname = refs.pngnum_to_pngname[png_index]
275        ts_pathname = refs.pngnum_to_tspathname[png_index]
276        ts_filename = refs.tspathname_to_tsfilename[ts_pathname]
277        self.increment_dir()
278        tile_data = refs.ts_data[ts_filename]
279        file_index = png_index - tile_data.pngnum_min
280        y_index = math.floor(file_index / tile_data.ts_tiles_per_row)
281        x_index = file_index - y_index * tile_data.ts_tiles_per_row
282        tile_off_x = max(0, tile_data.sprite_width * x_index)
283        tile_off_y = max(0, tile_data.sprite_height * y_index)
284        tile_image = tile_data.ts_image.extract_area(tile_off_x, tile_off_y,
285                                                     tile_data.sprite_width,
286                                                     tile_data.sprite_height)
287        tile_png_pathname = self.subdir_pathname + "/" + pngname + ".png"
288        tile_image.pngsave(tile_png_pathname)
289        refs.extracted_pngnums[png_index] = True
290
291    def write_images(self, refs):
292        for pngnum in range(
293                self.ts_data.pngnum_min, self.ts_data.pngnum_max + 1):
294            out_data.extract_image(pngnum, refs)
295
296
297class PngRefs(object):
298    def __init__(self):
299        # dict of png absolute numbers to png names
300        self.pngnum_to_pngname = {}
301        # dict of pngnames to png numbers; used to control uniqueness
302        self.pngname_to_pngnum = {}
303        # dict of png absolute numbers to tilesheet paths,
304        # used to know where to extract images
305        self.pngnum_to_tspathname = {}
306        self.tspathname_to_tsfilename = {}
307        # list of pngs written out
308        self.extracted_pngnums = {}
309        # list of pngs to not use
310        self.delete_pngnums = []
311        # misc data
312        self.tileset_pathname = ""
313        self.default_width = 16
314        self.default_height = 16
315        self.last_pngnum = 0
316        self.ts_data = {}
317
318    def get_all_data(self, tileset_dirname, delete_pathname):
319        self.tileset_pathname = tileset_dirname
320
321        try:
322            os.stat(self.tileset_pathname)
323        except KeyError:
324            print("cannot find a directory {}".format(self.tileset_pathname))
325            sys.exit(-1)
326
327        tileset_confname = refs.tileset_pathname + "/" + "tile_config.json"
328
329        try:
330            os.stat(tileset_confname)
331        except KeyError:
332            print("cannot find a directory {}".format(tileset_confname))
333            sys.exit(-1)
334
335        if delete_pathname:
336            with open(delete_pathname) as del_file:
337                del_ranges = json.load(del_file)
338                for delete_range in del_ranges:
339                    if not isinstance(delete_range, list):
340                        continue
341                    min_png = delete_range[0]
342                    max_png = min_png
343                    if len(delete_range) > 1:
344                        max_png = delete_range[1]
345                    for i in range(min_png, max_png + 1):
346                        self.delete_pngnums.append(i)
347
348        with open(tileset_confname) as conf_file:
349            return(json.load(conf_file))
350
351    def add_pngnum_to_tsfilepath(self, pngnum):
352        if not isinstance(pngnum, int):
353            return
354        if pngnum in self.pngnum_to_tspathname:
355            return
356        for ts_filename, ts_data in self.ts_data.items():
357            if pngnum >= ts_data.pngnum_min and pngnum <= ts_data.pngnum_max:
358                self.pngnum_to_tspathname.setdefault(
359                    pngnum, ts_data.ts_pathname)
360                return
361        raise KeyError("index %s out of range", pngnum)
362
363    def convert_index(self, read_pngnums, new_index):
364        if isinstance(read_pngnums, list):
365            for pngnum in read_pngnums:
366                if isinstance(pngnum, dict):
367                    sprite_ids = pngnum.get("sprite", -10)
368                    if isinstance(sprite_ids, list):
369                        new_sprites = []
370                        for sprite_id in sprite_ids:
371                            if (sprite_id >= 0 and
372                                    sprite_id not in self.delete_pngnums):
373                                new_sprites.append(
374                                    self.pngnum_to_pngname[sprite_id])
375                        pngnum["sprite"] = new_sprites
376                    else:
377                        if (sprite_ids >= 0 and
378                                sprite_ids not in self.delete_pngnums):
379                            pngnum["sprite"] = \
380                                self.pngnum_to_pngname[sprite_ids]
381                    new_index.append(pngnum)
382                elif pngnum >= 0 and pngnum not in self.delete_pngnums:
383                    new_index.append(self.pngnum_to_pngname[pngnum])
384        elif read_pngnums >= 0 and read_pngnums not in self.delete_pngnums:
385            new_index.append(self.pngnum_to_pngname[read_pngnums])
386
387    def convert_pngnum_to_pngname(self, tile_entry):
388        new_fg = []
389        fg_id = tile_entry.get("fg", -10)
390        self.convert_index(fg_id, new_fg)
391        bg_id = tile_entry.get("bg", -10)
392        new_bg = []
393        self.convert_index(bg_id, new_bg)
394        add_tile_entrys = tile_entry.get("additional_tiles", [])
395        for add_tile_entry in add_tile_entrys:
396            self.convert_pngnum_to_pngname(add_tile_entry)
397        tile_entry["fg"] = new_fg
398        tile_entry["bg"] = new_bg
399
400        new_id = ""
401        entity_id = tile_entry["id"]
402        if isinstance(entity_id, list):
403            # we have to truncate the name since it could get really long,
404            # which means this still may not be unique in the case of a
405            # bunch instances like this but it's better than nothing...
406            new_id = "_".join(entity_id)[0:150]
407        else:
408            new_id = entity_id
409
410        return new_id, tile_entry
411
412    def report_missing(self):
413        for pngnum in self.pngnum_to_pngname:
414            if not self.extracted_pngnums.get(pngnum):
415                print("missing index {}, {}".format(
416                    pngnum, self.pngnum_to_pngname[pngnum]))
417
418
419if __name__ == '__main__':
420    args = argparse.ArgumentParser(description=__doc__)
421    args.add_argument(
422        "tileset_dir", action="store",
423        help="local name of the tileset directory")
424    args.add_argument(
425        "--delete_file", dest="del_path", action="store",
426        help="local name of file containing" +
427        " lists of ranges of indices to delete")
428    argsDict = vars(args.parse_args())
429
430    tileset_dirname = argsDict.get("tileset_dir", "")
431    delete_pathname = argsDict.get("del_path", "")
432
433    refs = PngRefs()
434    all_tiles = refs.get_all_data(tileset_dirname, delete_pathname)
435
436    all_tilesheet_data = all_tiles.get("tiles-new", [])
437    tile_info = all_tiles.get("tile_info", {})
438    if tile_info:
439        refs.default_width = tile_info[0].get("width")
440        refs.default_height = tile_info[0].get("height")
441
442    overlay_ordering = all_tiles.get("overlay_ordering", [])
443    ts_sequence = []
444    for tilesheet_data in all_tilesheet_data:
445        ts_data = TileSheetData(tilesheet_data, refs)
446        ts_data.summarize(tile_info, refs)
447        ts_sequence.append(ts_data.ts_filename)
448
449    for tilesheet_data in all_tilesheet_data:
450        ts_filename = tilesheet_data.get("file", "")
451        ts_data = refs.ts_data[ts_filename]
452        if ts_data.fallback:
453            continue
454        tile_entries = {}
455        all_tile_entry = tilesheet_data.get("tiles", [])
456        for tile_entry in all_tile_entry:
457            tile_id = ts_data.parse_tile_entry(tile_entry, refs)
458            if tile_id:
459                tile_entries.setdefault(tile_id, [])
460                tile_entries[tile_id].append(tile_entry)
461        ts_data.tile_entries = tile_entries
462
463    #debug statements to verify pngnum_to_pngname and pngname_to_pngnum
464    #print("pngnum_to_pngname: {}".format(
465    #    json.dumps(refs.pngnum_to_pngname, sort_keys=True, indent=2)))
466    #print("pngname_to_pngnum: {}".format(
467    #    json.dumps(refs.pngname_to_pngnum, sort_keys=True, indent=2)))
468    #print("{}".format(json.dumps(file_tile_id_to_tile_entrys, indent=2)))
469    #print("{}".format(json.dumps(refs.pngnum_to_tspathname, indent=2)))
470
471    for ts_filename in ts_sequence:
472        out_data = ExtractionData(ts_filename, refs)
473
474        if not out_data.valid:
475            continue
476        out_data.write_expansions()
477
478        for tile_id, tile_entrys in out_data.ts_data.tile_entries.items():
479            #print("tile id {} with {} entries".format(
480            #    tile_id, len(tile_entrys)))
481            for idx, tile_entry in enumerate(tile_entrys):
482                subdir_pathname = out_data.increment_dir()
483                tile_entry_name, tile_entry = \
484                    refs.convert_pngnum_to_pngname(tile_entry)
485                if not tile_entry_name:
486                    continue
487                tile_entry_pathname = "{}/{}_{}.json".format(
488                    subdir_pathname, tile_entry_name, idx)
489
490                #if os.path.isfile(tile_entry_pathname):
491                #    print("overwriting {}".format(tile_entry_pathname))
492                write_to_json(tile_entry_pathname, tile_entry)
493        out_data.write_images(refs)
494
495    if tile_info:
496        tile_info_pathname = refs.tileset_pathname + "/" + "tile_info.json"
497        write_to_json(tile_info_pathname, tile_info, True)
498
499    refs.report_missing()
500