from __future__ import absolute_import import os import sys import logging import argparse from Tkinter import ( Tk, Button, Canvas, Scrollbar, VERTICAL, HORIZONTAL, RIGHT, LEFT, TOP, BOTTOM, BOTH, Y, X, N, S, E, W, TclError, Menu, ) import tkFileDialog from ttk import ( Frame, Style, Combobox, ) # This is why requirements.txt says to install pillow instead of the original # PIL. from PIL import ( Image, ImageTk, ) from . import gfx from . import wram from . import preprocessor from . import configuration config = configuration.Config() def config_open(self, filename): return open(os.path.join(self.path, filename)) configuration.Config.open = config_open def setup_logging(): """ Temporary function that configures logging to go straight to console. """ formatter = logging.Formatter('%(asctime)s - %(name)s - %(levelname)s - %(message)s') console = logging.StreamHandler(sys.stdout) console.setLevel(logging.DEBUG) console.setFormatter(formatter) root = logging.getLogger() root.addHandler(console) root.setLevel(logging.DEBUG) def read_incbin_in_file(label, filename='main.asm', config=config): asm = config.open(filename).read() return read_incbin(asm, label) def read_incbin(asm, label): incbin = asm_at_label(asm, label) filename = read_header_macros_2( incbin, [('filename', 'INCBIN')] )[0]['filename'] filename = filename.split('"')[1] return filename def red_gfx_name(tset): if type(tset) is int: return [ 'overworld', 'redshouse1', 'mart', 'forest', 'redshouse2', 'dojo', 'pokecenter', 'gym', 'house', 'forestgate', 'museum', 'underground', 'gate', 'ship', 'shipport', 'cemetery', 'interior', 'cavern', 'lobby', 'mansion', 'lab', 'club', 'facility', 'plateau', ][tset] elif type(tset) is str: return tset.lower().replace('_', '') def configure_for_pokered(config=config): """ Sets default configuration values for pokered. These should eventually be moved into the configuration module. """ attrs = { "version": "red", "map_dir": os.path.join(config.path, 'maps/'), "gfx_dir": os.path.join(config.path, 'gfx/tilesets/'), "to_gfx_name": red_gfx_name, "block_dir": os.path.join(config.path, 'gfx/blocksets/'), # not used "block_ext": '.bst', # not used "palettes_on": False, "constants_filename": os.path.join(config.path, 'constants.asm'), "time_of_day": 1, } return attrs def configure_for_pokecrystal(config=config): """ Sets default configuration values for pokecrystal. These should eventually be moved into the configuration module. """ attrs = { "version": "crystal", "map_dir": os.path.join(config.path, 'maps/'), "gfx_dir": os.path.join(config.path, 'gfx/tilesets/'), "to_gfx_name": lambda x : '%.2d' % x, "block_dir": os.path.join(config.path, 'tilesets/'), "block_ext": '_metatiles.bin', "palettes_on": True, "palmap_dir": os.path.join(config.path, 'tilesets/'), "palette_dir": os.path.join(config.path, 'tilesets/'), "asm_dir": os.path.join(config.path, 'maps/'), "constants_filename": os.path.join(config.path, 'constants.asm'), "header_dir": os.path.join(config.path, 'maps/'), "time_of_day": 1, } return attrs def configure_for_version(version, config=config): """ Overrides default values from the configuration with additional attributes. """ if version == "red": attrs = configure_for_pokered(config) elif version == "crystal": attrs = configure_for_pokecrystal(config) else: # TODO: pick a better exception raise Exception( "Can't configure for this version." ) for (key, value) in attrs.iteritems(): setattr(config, key, value) # not really needed since it's modifying the same object return config def get_constants(config=config): bss = wram.BSSReader() bss.read_bss_sections(open(config.constants_filename).readlines()) config.constants = bss.constants return config.constants class Application(Frame): def __init__(self, master=None, config=config): self.config = config self.log = logging.getLogger("{0}.{1}".format(self.__class__.__name__, id(self))) self.display_connections = True Frame.__init__(self, master) self.pack(fill=BOTH, expand=True) Style().configure("TFrame", background="#444") self.paint_tile = 1 self.init_ui() def init_ui(self): self.connections = {} self.button_frame = Frame(self) self.button_frame.grid(row=0, column=0, columnspan=2) self.map_frame = Frame(self) self.map_frame.grid(row=1, column=0, padx=5, pady=5, sticky=N+S+E+W) self.picker_frame = Frame(self) self.picker_frame.grid(row=1, column=1) self.button_new = Button(self.button_frame) self.button_new["text"] = "New" self.button_new["command"] = self.new_map self.button_new.grid(row=0, column=0, padx=2) self.menubar = Menu(self) menu = Menu(self.menubar, tearoff=0) self.menubar.add_cascade(label="File", menu=menu) menu.add_command(label="New") menu.add_command(label="Open") menu.add_command(label="Save") self.open = Button(self.button_frame) self.open["text"] = "Open" self.open["command"] = self.open_map self.open.grid(row=0, column=1, padx=2) self.save = Button(self.button_frame) self.save["text"] = "Save" self.save["command"] = self.save_map self.save.grid(row=0, column=2, padx=2) self.get_map_list() self.map_list.grid(row=0, column=3, padx=2) def get_map_list(self): self.available_maps = sorted(m for m in get_available_maps(config=self.config)) self.map_list = Combobox(self.button_frame, height=24, width=24, values=self.available_maps) if len(self.available_maps): self.map_list.set(self.available_maps[0]) def new_map(self): self.map_name = None self.init_map() self.map.map.blockdata = bytearray([self.paint_tile] * 20 * 20) self.map.map.width = 20 self.map.map.height = 20 self.draw_map() self.init_picker() def open_map(self): self.map_name = self.map_list.get() self.init_map() self.draw_map() self.init_picker() def save_map(self): if hasattr(self, 'map'): if self.map.map.blk_path: initial = self.map.map.blk_path else: initial = self.config.path filename = tkFileDialog.asksaveasfilename(initialfile=initial) if filename: with open(filename, 'wb') as save: save.write(self.map.map.blockdata) self.log.info('blockdata saved as {}'.format(filename)) else: self.log.info('nothing to save') def init_map(self): if hasattr(self, 'map'): self.map.kill_canvas() self.map = MapRenderer(self.config, parent=self.map_frame, name=self.map_name) self.init_map_connections() def draw_map(self): self.map.init_canvas(self.map_frame) self.map.canvas.pack() #.grid(row=1,column=1) self.map.draw() self.map.canvas.bind('', self.paint) self.map.canvas.bind('', self.paint) def init_picker(self): """This should really be its own class.""" self.current_tile = MapRenderer(self.config, parent=self.button_frame, tileset=Tileset(id=self.map.map.tileset.id)) self.current_tile.map.blockdata = [self.paint_tile] self.current_tile.map.width = 1 self.current_tile.map.height = 1 self.current_tile.init_canvas() self.current_tile.draw() self.current_tile.canvas.grid(row=0, column=4, padx=4) if hasattr(self, 'picker'): self.picker.kill_canvas() self.picker = MapRenderer(self.config, parent=self, tileset=Tileset(id=self.map.map.tileset.id)) self.picker.map.blockdata = range(len(self.picker.map.tileset.blocks)) self.picker.map.width = 4 self.picker.map.height = len(self.picker.map.blockdata) / self.picker.map.width self.picker.init_canvas(self.picker_frame) if hasattr(self.picker_frame, 'vbar'): self.picker_frame.vbar.destroy() self.picker_frame.vbar = Scrollbar(self.picker_frame, orient=VERTICAL) self.picker_frame.vbar.pack(side=RIGHT, fill=Y) self.picker_frame.vbar.config(command=self.picker.canvas.yview) self.picker.canvas.config(scrollregion=(0,0,self.picker.canvas_width, self.picker.canvas_height)) self.map_frame.update() # overwriting a property is probably a bad idea self.picker.canvas_height = self.map_frame.winfo_height() self.picker.canvas.config(yscrollcommand=self.picker_frame.vbar.set) self.picker.canvas.pack(side=LEFT, expand=True) self.picker.canvas.bind('<4>', lambda event : self.scroll_picker(event)) self.picker.canvas.bind('<5>', lambda event : self.scroll_picker(event)) self.picker_frame.vbar.bind('<4>', lambda event : self.scroll_picker(event)) self.picker_frame.vbar.bind('<5>', lambda event : self.scroll_picker(event)) self.picker.draw() self.picker.canvas.bind('', self.pick_block) def scroll_picker(self, event): if event.num == 4: self.picker.canvas.yview('scroll', -1, 'units') elif event.num == 5: self.picker.canvas.yview('scroll', 1, 'units') def pick_block(self, event): block_x = int(self.picker.canvas.canvasx(event.x)) / (self.picker.map.tileset.block_width * self.picker.map.tileset.tile_width) block_y = int(self.picker.canvas.canvasy(event.y)) / (self.picker.map.tileset.block_height * self.picker.map.tileset.tile_height) i = block_y * self.picker.map.width + block_x self.paint_tile = self.picker.map.blockdata[i] self.current_tile.map.blockdata = [self.paint_tile] self.current_tile.draw() def paint(self, event): block_x = event.x / (self.map.map.tileset.block_width * self.map.map.tileset.tile_width) block_y = event.y / (self.map.map.tileset.block_height * self.map.map.tileset.tile_height) i = block_y * self.map.map.width + block_x if 0 <= i < len(self.map.map.blockdata): self.map.map.blockdata[i] = self.paint_tile self.map.draw_block(block_x, block_y) def init_map_connections(self): if not self.display_connections: return for direction in self.map.map.connections.keys(): if direction in self.connections.keys(): if hasattr(self.connections[direction], 'canvas'): self.connections[direction].kill_canvas() if self.map.map.connections[direction] == {}: self.connections[direction] = {} continue self.connections[direction] = MapRenderer(self.config, parent=self, name=self.map.map.connections[direction]['map_name']) attrs = self.map.map.connections[direction] if direction in ['north', 'south']: if direction == 'north': x1 = 0 if self.config.version == 'red': y1 = eval(attrs['other_height'], self.config.constants) - 3 elif self.config.version == 'crystal': y1 = eval(attrs['map'] + '_HEIGHT', self.config.constants) - 3 else: # south x1 = 0 y1 = 0 x2 = x1 + eval(attrs['strip_length'], self.config.constants) y2 = y1 + 3 else: if direction == 'east': x1 = 0 y1 = 0 else: # west x1 = -3 y1 = 1 x2 = x1 + 3 y2 = y1 + eval(attrs['strip_length'], self.config.constants) self.connections[direction].init_canvas(self.map_frame) self.connections[direction].canvas.pack(side={'north':TOP, 'south':BOTTOM, 'west':LEFT,'east':RIGHT}[direction]) self.connections[direction].map.crop(x1, y1, x2, y2) self.connections[direction].draw() class MapRenderer: def __init__(self, config=config, **kwargs): self.config = config self.__dict__.update(kwargs) self.map = Map(**kwargs) @property def canvas_width(self): return self.map.width * self.map.block_width @property def canvas_height(self): return self.map.height * self.map.block_height def init_canvas(self, parent=None): if parent == None: parent = self.parent if hasattr(self, 'canvas'): pass else: self.canvas = Canvas(parent) self.canvas.xview_moveto(0) self.canvas.yview_moveto(0) def kill_canvas(self): if hasattr(self, 'canvas'): self.canvas.destroy() def draw(self): self.canvas.configure(width=self.canvas_width, height=self.canvas_height) for i in xrange(len(self.map.blockdata)): block_x = i % self.map.width block_y = i / self.map.width self.draw_block(block_x, block_y) def draw_block(self, block_x, block_y): # the canvas starts at 4, 4 for some reason # probably something to do with a border index, indey = 4, 4 # Draw one block (4x4 tiles) block = self.map.blockdata[block_y * self.map.width + block_x] # Ignore nonexistent blocks. if block >= len(self.map.tileset.blocks): return for j, tile in enumerate(self.map.tileset.blocks[block]): try: # Tile gfx are split in half to make vram mapping easier if tile >= 0x80: tile -= 0x20 tile_x = block_x * self.map.block_width + (j % 4) * 8 tile_y = block_y * self.map.block_height + (j / 4) * 8 self.canvas.create_image(index + tile_x, indey + tile_y, image=self.map.tileset.tiles[tile]) except: pass def crop(self, *args, **kwargs): self.map.crop(*args, **kwargs) self.draw() class Map: width = 20 height = 20 block_width = 32 block_height = 32 def __init__(self, config=config, **kwargs): self.parent = None self.name = '' self.blk_path = '' self.tileset = Tileset(config=config) self.blockdata = [] self.connections = {'north': {}, 'south': {}, 'west': {}, 'east': {}} self.__dict__.update(kwargs) self.config = config self.log = logging.getLogger("{0}.{1}".format(self.__class__.__name__, id(self))) if not self.blk_path and self.name: self.blk_path = os.path.join(self.config.map_dir, self.name + '.blk') if os.path.exists(self.blk_path) and self.blockdata == []: self.blockdata = bytearray(open(self.blk_path).read()) if self.config.version == 'red': if self.name: attrs = map_header(self.name, config=self.config) self.tileset = Tileset(id=attrs['tileset_id'], config=self.config) self.height = eval(attrs['height'], self.config.constants) self.width = eval(attrs['width'], self.config.constants) self.connections = attrs['connections'] elif self.config.version == 'crystal': asm_filename = '' if self.name: asm_filename = os.path.join(self.config.asm_dir, self.name + '.asm') if os.path.exists(asm_filename): for props in [ map_header(self.name, config=self.config), second_map_header(self.name, config=self.config) ]: self.__dict__.update(props) self.asm = open(asm_filename, 'r').read() self.events = event_header(self.asm, self.name) self.scripts = script_header(self.asm, self.name) self.tileset = Tileset(id=self.tileset_id, config=self.config) self.width = eval(self.width, self.config.constants) self.height = eval(self.height, self.config.constants) def crop(self, x1=0, y1=0, x2=None, y2=None): if x2 is None: x2 = self.width if y2 is None: y2 = self.height start = y1 * self.width + x1 width = x2 - x1 height = y2 - y1 blockdata = [] for y in xrange(height): index = start + y * self.width blockdata.extend( self.blockdata[index : index + width] ) self.blockdata = bytearray(blockdata) self.width = width self.height = height class Tileset: def __init__(self, config=config, **kwargs): if config.version == 'red': self.id = 0 elif config.version == 'crystal': self.id = 2 self.tile_width = 8 self.tile_height = 8 self.block_width = 4 self.block_height = 4 self.alpha = 255 self.__dict__.update(kwargs) self.id = eval(str(self.id), config.constants) self.config = config self.log = logging.getLogger("{0}.{1}".format(self.__class__.__name__, id(self))) if self.config.palettes_on: self.get_palettes() self.get_palette_map() self.get_blocks() self.get_tiles() def read_header(self): if self.config.version == 'red': tileset_headers = self.config.open('data/tileset_headers.asm').readlines() tileset_header = map(str.strip, tileset_headers[self.id + 1].split('\ttileset')[1].split(',')) return tileset_header def get_tileset_gfx_filename(self): filename = None if self.config.version == 'red': gfx_label = self.read_header()[1] filename = read_incbin_in_file(gfx_label, filename='main.asm', config=self.config) filename = filename.replace('.2bpp','.png') filename = os.path.join(self.config.path, filename) if not filename: # last resort filename = os.path.join( self.config.gfx_dir, self.config.to_gfx_name(self.id) + '.png' ) return filename def get_tiles(self): filename = self.get_tileset_gfx_filename() if not os.path.exists(filename): # Crystal still isn't ready for pngs. if self.config.version == 'crystal': gfx.convert_to_png([filename.replace('.png', '.2bpp.lz')]) self.img = Image.open(filename) self.img.width, self.img.height = self.img.size self.tiles = [] cur_tile = 0 for y in xrange(0, self.img.height, self.tile_height): for x in xrange(0, self.img.width, self.tile_width): tile = self.img.crop((x, y, x + self.tile_width, y + self.tile_height)) if hasattr(self, 'palette_map') and hasattr(self, 'palettes'): # Palette maps are padded to make vram mapping easier. pal = self.palette_map[cur_tile + 0x20 if cur_tile >= 0x60 else cur_tile] & 0x7 tile = self.colorize_tile(tile, self.palettes[pal]) self.tiles += [ImageTk.PhotoImage(tile)] cur_tile += 1 def colorize_tile(self, tile, palette): width, height = tile.size tile = tile.convert("RGB") px = tile.load() for y in xrange(height): for x in xrange(width): # assume greyscale which_color = 3 - (px[x, y][0] / 0x55) r, g, b = [v * 8 for v in palette[which_color]] px[x, y] = (r, g, b) return tile def get_blocks(self): if self.config.version == 'crystal': filename = os.path.join( self.config.block_dir, self.config.to_gfx_name(self.id) + self.config.block_ext ) elif self.config.version == 'red': block_label = self.read_header()[0] filename = read_incbin_in_file(block_label, 'main.asm', config=self.config) self.blocks = [] block_length = self.block_width * self.block_height blocks = bytearray(open(filename, 'rb').read()) for block in xrange(len(blocks) / (block_length)): i = block * block_length self.blocks += [blocks[i : i + block_length]] def get_palette_map(self): filename = os.path.join( self.config.palmap_dir, str(self.id).zfill(2) + '_palette_map.bin' ) self.palette_map = [] palmap = bytearray(open(filename, 'rb').read()) for i in xrange(len(palmap)): self.palette_map += [palmap[i] & 0xf] self.palette_map += [(palmap[i] >> 4) & 0xf] def get_palettes(self): filename = os.path.join( self.config.palette_dir, ['morn', 'day', 'nite'][self.config.time_of_day] + '.pal' ) self.palettes = get_palettes(filename) def get_palettes(filename): lines = open(filename, 'r').readlines() colors = gfx.read_rgb_macros(lines) palettes = [colors[i:i+4] for i in xrange(0, len(colors), 4)] return palettes def get_available_maps(config=config): for root, dirs, files in os.walk(config.map_dir): for filename in files: base_name, ext = os.path.splitext(filename) if ext == '.blk': yield base_name def map_header(name, config=config): if config.version == 'crystal': headers = open(os.path.join(config.header_dir, 'map_headers.asm'), 'r').read() label = name header = asm_at_label(headers, '\tmap_header ' + label, colon=',') attributes = [ ('label', 'map_header'), ('tileset_id', 'db'), ('permission', 'db'), ('world_map_location', 'db'), ('music', 'db'), ('time_of_day', 'db'), ('fishing_group', 'db'), ] attrs, l = read_header_macros_2(header, attributes) return attrs elif config.version == 'red': header = config.open('data/mapHeaders/{0}.asm'.format(name)).read() header = split_comments(header.split('\n')) attributes = [ ('tileset_id', 'db'), ('height', 'db'), ('width', 'db'), ('blockdata_label', 'dw'), ('text_label', 'dw'), ('script_label', 'dw'), ('which_connections', 'db'), ] attrs, l = read_header_macros_2(header, attributes) attrs['connections'], l = connections(attrs['which_connections'], header, l, config=config) attributes = [('object_label', 'dw')] more_attrs, l = read_header_macros_2(header[l:], attributes) attrs.update(more_attrs) return attrs return {} def second_map_header(name, config=config): if config.version == 'crystal': headers = open(os.path.join(config.header_dir, 'second_map_headers.asm'), 'r').read() label = '\tmap_header_2 ' + name header = asm_at_label(headers, label, colon=',') attributes = [ ('second_label', 'map_header_2'), ('dimension_base', 'db'), ('border_block', 'db'), ('which_connections', 'db'), ] attrs, l = read_header_macros_2(header, attributes) # hack to use dimension constants, eventually dimensions will be here for real attrs['height'] = attrs['dimension_base'] + '_HEIGHT' attrs['width'] = attrs['dimension_base'] + '_WIDTH' attrs['connections'], l = connections(attrs['which_connections'], header, l, config=config) return attrs return {} def connections(which_connections, header, l=0, config=config): directions = { 'north': {}, 'south': {}, 'west': {}, 'east': {} } if config.version == 'crystal': attributes = [ ('map', 'map'), ('strip_pointer', 'dw'), ('strip_destination', 'dw'), ('strip_length', 'db'), ('map_width', 'db'), ('y_offset', 'db'), ('x_offset', 'db'), ('window', 'dw'), ] elif config.version == 'red': conn_attrs = { 'north': ['map_id', 'other_width', 'other_height', 'x_offset', 'strip_offset', 'strip_length', 'other_blocks'], 'south': ['map_id', 'other_width', 'x_offset', 'strip_offset', 'strip_length', 'other_blocks', 'width', 'height'], 'east': ['map_id', 'other_width', 'y_offset', 'strip_offset', 'strip_length', 'other_blocks', 'width'], 'west': ['map_id', 'other_width', 'y_offset', 'strip_offset', 'strip_length', 'other_blocks', 'width'], } for d in ['north', 'south', 'west', 'east']: if d.upper() in which_connections: if config.version == 'crystal': attrs, l2 = read_header_macros_2(header[l:], attributes) l += l2 directions[d] = attrs directions[d]['map_name'] = directions[d]['map'].title().replace('_','') elif config.version == 'red': attrs, l2 = read_header_macros_2(header[l:], zip(conn_attrs[d], [d.upper() + '_MAP_CONNECTION'] * len(conn_attrs[d]))) l += l2 directions[d] = attrs directions[d]['map_name'] = directions[d]['map_id'].lower().replace('_','') return directions, l def read_header_macros_2(header, attributes): values, l = read_header_macros(header, [x[0] for x in attributes], [x[1] for x in attributes]) return dict(zip([x[0] for x in attributes], values)), l def read_header_macros(header, attributes, macros): values = [] i = 0 l = 0 for l, (asm, comment) in enumerate(header): if asm.strip() != '': mvalues = macro_values(asm, macros[i]) values += mvalues i += len(mvalues) if len(values) >= len(attributes): l += 1 break return values, l def event_header(asm, name): return {} def script_header(asm, name): return {} def macro_values(line, macro): values = macro.join(line.split(macro)[1:]).split(',') #values = line[line.find(macro) + len(macro):].split(',') values = [v.replace('$','0x').strip() for v in values] if values[0] == 'w': # dbw values = values[1:] return values def asm_at_label(asm, label, colon=':'): label_def = label + colon lines = asm.split('\n') for i, line in enumerate(lines): if label_def in line: lines = lines[i:] break return split_comments(lines) def split_comments(lines): content = [] for line in lines: l, comment = preprocessor.separate_comment(line + '\n') # skip over labels? this should be in macro_values while ':' in l: l = l[l.index(':') + 1:] content += [[l, comment]] return content def main(config=config): """ Creates an application instance. """ root = Tk() root.columnconfigure(0, weight=1) root.wm_title("ayy lmap") app = Application(master=root, config=config) return app def init(config=config, version='crystal'): """ Launches a map editor instance. """ setup_logging() configure_for_version(version, config) get_constants(config=config) return main(config=config) if __name__ == "__main__": ap = argparse.ArgumentParser() ap.add_argument('version', nargs='?', default='crystal') args = ap.parse_args() app = init(config=config, version=args.version) app.mainloop()