import json import os.path import random # noqa: F401 -- art scripts exec'd in this scope use it import time import traceback import numpy as np from edit_command import CommandStack, EntireArtCommand from image_export import write_thumbnail # X, Y, Z VERT_LENGTH = 3 # 4 verts in a quad VERT_STRIDE = 4 * VERT_LENGTH # elements: 3 verts per tri * 2 tris in a quad ELEM_STRIDE = 6 # UVs: 2 floats per vert * 4 verts in a quad UV_STRIDE = 2 * 4 # starting document defaults DEFAULT_CHARSET = "c64_petscii" DEFAULT_PALETTE = "c64_original" DEFAULT_WIDTH, DEFAULT_HEIGHT = 40, 25 DEFAULT_ART_FILENAME = "new" DEFAULT_FRAME_DELAY = 0.1 DEFAULT_LAYER_Z = 0 DEFAULT_LAYER_Z_OFFSET = 0.5 ART_DIR = "art/" ART_FILE_EXTENSION = "psci" THUMBNAIL_CACHE_DIR = "thumbnails/" ART_SCRIPT_DIR = "artscripts/" SCRIPT_FILE_EXTENSION = "arsc" # flip/rotate UV constants UV_NORMAL = 0 UV_ROTATE90 = 1 UV_ROTATE180 = 2 UV_ROTATE270 = 3 UV_FLIPX = 4 UV_FLIPY = 5 # (flip X & Y is identical to rotate 180) # not sure what to call these, but - results for X and Y flip of rotated chars: UV_FLIP90 = 6 UV_FLIP270 = 7 uv_names = { UV_NORMAL: "Normal", UV_ROTATE90: "Rotate 90", UV_ROTATE180: "Rotate 180", UV_ROTATE270: "Rotate 270", UV_FLIPX: "Flip X", UV_FLIPY: "Flip Y", UV_FLIP90: "Flipped90", UV_FLIP270: "Flipped270", } uv_types = { UV_NORMAL: (0, 0, 1, 0, 0, 1, 1, 1), UV_ROTATE90: (0, 1, 0, 0, 1, 1, 1, 0), UV_ROTATE180: (1, 1, 0, 1, 1, 0, 0, 0), UV_ROTATE270: (1, 0, 1, 1, 0, 0, 0, 1), UV_FLIPX: (1, 0, 0, 0, 1, 1, 0, 1), UV_FLIPY: (0, 1, 1, 1, 0, 0, 1, 0), UV_FLIP90: (0, 0, 0, 1, 1, 0, 1, 1), UV_FLIP270: (1, 1, 1, 0, 0, 1, 0, 0), } # reverse dict for easy (+ fast?) lookup in eg get_char_transform_at uv_types_reverse = { uv_types[UV_NORMAL]: UV_NORMAL, uv_types[UV_ROTATE90]: UV_ROTATE90, uv_types[UV_ROTATE180]: UV_ROTATE180, uv_types[UV_ROTATE270]: UV_ROTATE270, uv_types[UV_FLIPX]: UV_FLIPX, uv_types[UV_FLIPY]: UV_FLIPY, uv_types[UV_FLIP90]: UV_FLIP90, uv_types[UV_FLIP270]: UV_FLIP270, } class Art: """ Art asset: Contains the data that is modified by user edits and gets saved to disk. Data stored as arrays that TileRenderables use to populate their buffers. assumptions: - an Art contains 1 or more frames - each frame contains 1 or more layers - each layer is a rectangular grid of Width * Height tiles - each tile has: character, foreground color, background color, & transform - char/color tile values are expressed as indices into charset / palette - all layers in an Art are the same dimensions """ quad_width, quad_height = 1.0, 1.0 "size of each tile in world space" log_size_changes = False recalc_quad_height = True log_creation = False def __init__(self, filename, app, charset, palette, width, height): "Creates a new, blank document with given parameters." self.valid = False if filename and not filename.endswith(".{}".format(ART_FILE_EXTENSION)): filename += ".{}".format(ART_FILE_EXTENSION) self.filename = filename self.app = app # save "time loaded" for menu sorting self.time_loaded = 0 self.charset, self.palette = charset, palette self.command_stack = CommandStack(self) self.unsaved_changes = False self.width, self.height = width, height # selected char/fg/bg/xform self.selected_char = self.charset.get_char_index("A") or 2 self.selected_fg_color = self.palette.lightest_index self.selected_bg_color = self.palette.darkest_index self.selected_xform = UV_NORMAL # camera position - updated in Art.update, saved in .psci self.update_saved_camera(self.app.camera) # set True when camera "zoom extents" toggles on self.camera_zoomed_extents = False # cached camera position from before "zoom extents" invoked self.non_extents_camera_x = self.non_extents_camera_y = 0 self.non_extents_camera_z = self.app.camera.start_zoom # list of char/fg/bg arrays, one for each frame self.chars, self.fg_colors, self.bg_colors = [], [], [] # char transforms: UV coords, plus map (unused by renderer) for fast access self.uv_mods, self.uv_maps = [], [] # table of {frame_number: bool} changed frames, processed each update() self.char_changed_frames, self.uv_changed_frames = {}, {} self.fg_changed_frames, self.bg_changed_frames = {}, {} self.renderables = [] "List of TileRenderables using us - each new Renderable adds itself" self.instances = [] "List of ArtInstances using us as their source" # init frames and layers - ArtFromDisk has its own logic for this self.init_layers() self.init_frames() # support non-square characters: # derive quad_height from chars aspect; quad_width always 1.0 if self.recalc_quad_height: self.quad_height *= self.charset.char_height / self.charset.char_width # running scripts and timing info self.scripts = [] self.script_rates = [] self.scripts_next_exec_time = [] # tell renderables to rebind vert and element buffers next update self.geo_changed = True # run update once before renderables initialize so they have # something to bind self.first_update() if self.log_creation and not self.app.game_mode: self.log_init() self.valid = True def log_init(self): self.app.log("created new document:") self.app.log(" character set: {}".format(self.charset.name)) self.app.log(" palette: {}".format(self.palette.name)) self.app.log(" width/height: {} x {}".format(self.width, self.height)) self.app.log(" frames: {}".format(self.frames)) self.app.log(" layers: {}".format(self.layers)) def init_layers(self): self.layers = 1 # current layer being edited self.active_layer = 0 # lists of layer Z values and names self.layers_z = [DEFAULT_LAYER_Z] self.layers_visibility = [True] self.layer_names = ["Layer 1"] def init_frames(self): self.frames = 0 # current frame being edited self.active_frame = 0 # list of frame delays self.frame_delays = [] # add one frame to start self.add_frame_to_end(DEFAULT_FRAME_DELAY, False) # clear our single layer to a sensible BG color self.clear_frame_layer(0, 0, bg_color=self.palette.darkest_index) def first_update(self): self.update() def insert_frame_before_index(self, index, delay=DEFAULT_FRAME_DELAY, log=True): "Add a blank frame at the specified index (len+1 to add to end)." self.frames += 1 self.frame_delays.insert(index, delay) shape = (self.layers, self.height, self.width, 4) fg, bg = 0, 0 if self.app.ui: fg = self.app.ui.selected_fg_color bg = self.app.ui.selected_bg_color new_char = np.zeros(shape, dtype=np.float32) new_fg = np.full(shape, fg, dtype=np.float32) new_bg = np.full(shape, bg, dtype=np.float32) new_uv = np.full(shape, UV_NORMAL, dtype=np.uint32) self.chars.insert(index, new_char) self.fg_colors.insert(index, new_fg) self.bg_colors.insert(index, new_bg) self.uv_maps.insert(index, new_uv) # UV init is more complex than just all zeroes self.uv_mods.insert(index, self.new_uv_layers(self.layers)) # all but lowest layer = transparent for l in range(1, self.layers): self.clear_frame_layer(index, l, 0, fg) self.mark_all_frames_changed() # set new frame as active if self.app.ui and self is self.app.ui.active_art: self.app.ui.set_active_frame(index) if log: self.app.log("Created new frame at index {}".format(str(index))) def add_frame_to_end(self, delay=DEFAULT_FRAME_DELAY, log=True): "Add a blank frame at the end of the current animation." self.insert_frame_before_index(self.frames, delay, log) def duplicate_frame(self, src_frame_index, dest_frame_index=None, delay=None): "Create a duplicate of given frame at given index." # stick new frame at end if no destination index given dest_frame_index = ( dest_frame_index if dest_frame_index is not None else self.frames ) # copy source frame's delay if none given delay = delay or self.frame_delays[src_frame_index] self.frames += 1 self.frame_delays.insert(dest_frame_index, delay) # copy source frame's char/color arrays self.chars.insert(dest_frame_index, self.chars[src_frame_index].copy()) self.uv_mods.insert(dest_frame_index, self.uv_mods[src_frame_index].copy()) self.uv_maps.insert(dest_frame_index, self.uv_maps[src_frame_index].copy()) self.fg_colors.insert(dest_frame_index, self.fg_colors[src_frame_index].copy()) self.bg_colors.insert(dest_frame_index, self.bg_colors[src_frame_index].copy()) self.mark_all_frames_changed() # set new frame as active if self is self.app.ui.active_art: self.app.ui.set_active_frame(dest_frame_index - 1) self.app.log( "Duplicated frame {} at frame {}".format( src_frame_index + 1, dest_frame_index ) ) def delete_frame_at(self, index): "Delete frame at given index." self.chars.pop(index) self.fg_colors.pop(index) self.bg_colors.pop(index) self.uv_mods.pop(index) self.uv_maps.pop(index) self.frames -= 1 self.mark_all_frames_changed() if self is self.app.ui.active_art: self.app.ui.set_active_frame(index) def move_frame_to_index(self, src_index, dest_index): "Move frame at given index to new given index." char_data = self.chars.pop(src_index) fg_data = self.fg_colors.pop(src_index) bg_data = self.bg_colors.pop(src_index) uv_data = self.uv_mods.pop(src_index) uv_map_data = self.uv_maps.pop(src_index) self.chars.insert(dest_index, char_data) self.fg_colors.insert(dest_index, fg_data) self.bg_colors.insert(dest_index, bg_data) self.uv_mods.insert(dest_index, uv_data) self.uv_maps.insert(dest_index, uv_map_data) self.mark_all_frames_changed() def add_layer(self, z=None, name=None): "Add a new layer with given Z with given name." # offset Z from last layer's Z if none given z = z if z is not None else self.layers_z[-1] + DEFAULT_LAYER_Z_OFFSET # index isn't user-facing, z is what matters index = self.layers - 1 # duplicate_layer increases self.layers by 1 self.duplicate_layer(index, z, name) for frame in range(self.frames): self.clear_frame_layer(frame, self.layers - 1, 0) # set new layer as active if self is self.app.ui.active_art: self.app.ui.set_active_layer(index + 1) def duplicate_layer(self, src_index, z=None, new_name=None): "Duplicate layer with given index. Duplicate uses given Z and name." def duplicate_layer_array(array): src_data = np.array([array[src_index]]) return np.append(array, src_data, 0) for frame in range(self.frames): self.chars[frame] = duplicate_layer_array(self.chars[frame]) self.fg_colors[frame] = duplicate_layer_array(self.fg_colors[frame]) self.bg_colors[frame] = duplicate_layer_array(self.bg_colors[frame]) self.uv_mods[frame] = duplicate_layer_array(self.uv_mods[frame]) self.uv_maps[frame] = duplicate_layer_array(self.uv_maps[frame]) self.layers += 1 z = z if z is not None else self.layers_z[src_index] self.layers_z.append(z) self.layers_visibility.append(True) new_name = new_name or "Copy of {}".format(self.layer_names[src_index]) self.layer_names.append(new_name) # rebuild geo with added verts for new layer self.geo_changed = True # set new layer as active if self is self.app.ui.active_art: self.app.ui.set_active_layer(self.layers - 1) # don't log new layers created on the fly in game mode if not self.app.game_mode: self.app.log("Added new layer {}".format(new_name)) self.set_unsaved_changes(True) def clear_frame_layer(self, frame, layer, bg_color=0, fg_color=None): "Clear given layer of given frame to transparent BG + no characters." # "clear" UVs to UV_NORMAL for y in range(self.height): for x in range(self.width): self.uv_mods[frame][layer][y][x] = uv_types[UV_NORMAL] self.uv_maps[frame][layer][y][x] = UV_NORMAL self.chars[frame][layer][y][x] = 0 self.fg_colors[frame][layer][y][x] = fg_color or 0 self.bg_colors[frame][layer][y][x] = bg_color # tell this frame to update self.char_changed_frames[frame] = True self.fg_changed_frames[frame] = True self.bg_changed_frames[frame] = True self.uv_changed_frames[frame] = True def delete_layer(self, index): "Delete layer at given index." for frame in range(self.frames): self.chars[frame] = np.delete(self.chars[frame], index, 0) self.fg_colors[frame] = np.delete(self.fg_colors[frame], index, 0) self.bg_colors[frame] = np.delete(self.bg_colors[frame], index, 0) self.uv_mods[frame] = np.delete(self.uv_mods[frame], index, 0) self.uv_maps[frame] = np.delete(self.uv_maps[frame], index, 0) self.layers_z.pop(index) self.layers_visibility.pop(index) self.layer_names.pop(index) self.layers -= 1 self.geo_changed = True self.mark_all_frames_changed() if self.active_layer > self.layers - 1: self.app.ui.set_active_layer(self.layers - 1) self.set_unsaved_changes(True) def set_charset(self, new_charset, log=False): "Set Art to use given character set (referenced by object, not name)." if new_charset is self.charset: return self.charset = new_charset if self.recalc_quad_height: self.quad_width = 1.0 self.quad_height = 1.0 * ( self.charset.char_height / self.charset.char_width ) self.set_unsaved_changes(True) self.geo_changed = True if log: self.app.ui.message_line.post_line( "Character set changed to {}".format(self.charset.name) ) def set_charset_by_name(self, new_charset_name): charset = self.app.load_charset(new_charset_name) self.set_charset(charset) self.app.ui.popup.set_active_charset(charset) def set_palette(self, new_palette, log=False): "Set Art to use given color palette (referenced by object, not name)." if new_palette is self.palette: return self.palette = new_palette self.set_unsaved_changes(True) if log: self.app.ui.message_line.post_line( "Color palette changed to {}".format(self.palette.name) ) def set_palette_by_name(self, new_palette_name): palette = self.app.load_palette(new_palette_name) self.set_palette(palette) self.app.ui.popup.set_active_palette(palette) def set_active_frame(self, new_frame): "Set frame at given index for active editing in Art Mode." new_frame %= self.frames # bail if frame is still the same, eg we only have 1 frame if new_frame == self.active_frame: # return whether or not we actually changed frames return False self.active_frame = new_frame # update our renderables for r in self.renderables: r.set_frame(self.active_frame) return True def set_active_layer(self, new_layer): "Set layer at given index for active editing in Art Mode." self.active_layer = min(max(0, new_layer), self.layers - 1) def crop(self, new_width, new_height, origin_x=0, origin_y=0): x0, y0 = origin_x, origin_y x1, y1 = x0 + new_width, y0 + new_height crop_x = new_width < self.width crop_y = new_height < self.height for frame in range(self.frames): for array in [ self.chars, self.fg_colors, self.bg_colors, self.uv_mods, self.uv_maps, ]: if crop_x: array[frame] = array[frame].take(range(x0, x1), axis=2) if crop_y: array[frame] = array[frame].take(range(y0, y1), axis=1) def expand(self, new_width, new_height, bg_fill): x_add = new_width - self.width y_add = new_height - self.height # print('%s expand: %sw + %s = %s, %sh + %s = %s' % (self.filename, # self.width, x_add, new_width, self.height, y_add, new_height)) def expand_array(array, fill_value, stride): # add columns (increasing width) if x_add > 0: # before height has changed, take care not to append # incorrectly sized columns h = new_height if new_height < self.height else self.height add_shape = (self.layers, h, x_add, stride) add = np.full(add_shape, fill_value, dtype=np.float32) array = np.append(array, add, 2) # add rows (increasing height) if y_add > 0: add_shape = (self.layers, y_add, new_width, stride) add = np.full(add_shape, fill_value, dtype=np.float32) array = np.append(array, add, 1) # can't modify passed array in-place return array for frame in range(self.frames): self.chars[frame] = expand_array(self.chars[frame], 0, 4) fg, bg = 0, 0 if self.app.ui: fg = self.app.ui.selected_fg_color # fill with BG color? ResizeArtDialog can specify this # if not, blank bg for all new tiles (original default behavior) if bg_fill: bg = self.app.ui.selected_bg_color self.fg_colors[frame] = expand_array(self.fg_colors[frame], fg, 4) self.bg_colors[frame] = expand_array(self.bg_colors[frame], bg, 4) self.uv_mods[frame] = expand_array( self.uv_mods[frame], uv_types[UV_NORMAL], UV_STRIDE ) self.uv_maps[frame] = expand_array(self.uv_maps[frame], UV_NORMAL, 4) def mark_frame_changed(self, frame): "Given frame at given index as changed for next render." self.char_changed_frames[frame] = True self.fg_changed_frames[frame] = True self.bg_changed_frames[frame] = True self.uv_changed_frames[frame] = True def mark_all_frames_changed(self): "Mark all frames as changed for next render." for frame in range(self.frames): self.mark_frame_changed(frame) def resize(self, new_width, new_height, origin_x=0, origin_y=0, bg_fill=False): """ Crop and/or expand Art to new given dimensions, with optional new top left tile if cropping. Calls crop() and expand(), so no need to call those directly. """ if new_width < self.width or new_height < self.height: self.crop(new_width, new_height, origin_x, origin_y) if new_width > self.width or new_height > self.height: self.expand(new_width, new_height, bg_fill) self.width, self.height = new_width, new_height # tell all frames they've changed, rebind buffers self.geo_changed = True self.mark_all_frames_changed() def build_geo(self): """ (Re)build the vertex and element arrays used by all layers. Run if the Art has untracked changes to size or layer count. """ shape = (self.layers, self.height, self.width, VERT_STRIDE) self.vert_array = np.empty(shape, dtype=np.float32) all_elems_size = self.layers * self.width * self.height * ELEM_STRIDE self.elem_array = np.empty(shape=all_elems_size, dtype=np.uint32) # generate geo according to art size # vert_index corresponds to # of verts, loc_index to position in array # (given that each vert has 3 components) vert_index = 0 elem_index = 0 for layer in range(self.layers): for tile_y in range(self.height): for tile_x in range(self.width): # vertices left_x = tile_x * self.quad_width top_y = tile_y * -self.quad_height right_x = left_x + self.quad_width bottom_y = top_y - self.quad_height x0, y0 = left_x, top_y x1, y1 = right_x, top_y x2, y2 = left_x, bottom_y x3, y3 = right_x, bottom_y # Z of all layers is 0, layer Z set in shader verts = [x0, y0, 0] verts += [x1, y1, 0] verts += [x2, y2, 0] verts += [x3, y3, 0] self.vert_array[layer][tile_y][tile_x] = verts # vertex elements elements = [vert_index, vert_index + 1, vert_index + 2] elements += [vert_index + 1, vert_index + 2, vert_index + 3] self.elem_array[elem_index : elem_index + ELEM_STRIDE] = elements elem_index += ELEM_STRIDE # 4 verts in a quad vert_index += 4 def new_uv_layers(self, layers): "Return given # of layer's worth of vanilla UV array data." shape = (layers, self.height, self.width, UV_STRIDE) array = np.zeros(shape, dtype=np.float32) # default new layer of UVs to "normal" transform uvs = uv_types[UV_NORMAL] for layer in range(layers): for y in range(self.height): for x in range(self.width): array[layer][y][x] = uvs return array def is_tile_inside(self, x, y): "Return True if given x,y tile coord is within our bounds." return 0 <= x < self.width and 0 <= y < self.height # get methods def get_char_index_at(self, frame, layer, x, y): "Return character index for given frame/layer/x,y tile." return int(self.chars[frame][layer][y][x][0]) def get_fg_color_index_at(self, frame, layer, x, y): "Return foreground color index for given frame/layer/x,y tile." return int(self.fg_colors[frame][layer][y][x][0]) def get_bg_color_index_at(self, frame, layer, x, y): "Return background color index for given frame/layer/x,y tile." return int(self.bg_colors[frame][layer][y][x][0]) def get_char_transform_at(self, frame, layer, x, y): "Return character transform enum for given frame/layer/x,y tile." # read from mapping, rather than casting raw UV coords for tuple key return self.uv_maps[frame][layer][y][x][0] def get_tile_at(self, frame, layer, x, y): """ Return (char index, fg color index, bg color index, transform) tuple for given frame/layer/x,y tile. """ char = self.get_char_index_at(frame, layer, x, y) fg = self.get_fg_color_index_at(frame, layer, x, y) bg = self.get_bg_color_index_at(frame, layer, x, y) xform = self.get_char_transform_at(frame, layer, x, y) return char, fg, bg, xform # set methods def set_char_index_at(self, frame, layer, x, y, char_index): "Set character index for given frame/layer/x,y tile." self.chars[frame][layer][y][x] = char_index # next update, tell renderables on the changed frame to update buffers self.char_changed_frames[frame] = True def set_color_at(self, frame, layer, x, y, color_index, fg=True): """ Set (fg or bg) color index for given frame/layer/x,y tile. Foreground or background specified with "fg" boolean. """ if color_index is None: return # modulo to resolve any negative indices if 0 < color_index >= len(self.palette.colors): color_index %= len(self.palette.colors) # no functional differences between fg and bg color update, # so use the same code path with different parameters update_array = self.fg_colors[frame] if fg else self.bg_colors[frame] update_array[layer][y][x] = color_index self.fg_changed_frames[frame] = True self.bg_changed_frames[frame] = True def set_all_non_transparent_colors(self, new_color_index): """ Set color index for all non-transparent (index 0) colors on all tiles on all frames and layers. """ for frame, layer, x, y in TileIter(self): # non-transparent color could be FG or BG char, fg, bg, xform = self.get_tile_at(frame, layer, x, y) change_fg = bg == 0 self.set_color_at(frame, layer, x, y, new_color_index, change_fg) def set_all_bg_colors(self, new_color_index, exclude_layers=[]): "Set background color index for all tiles on all frames and layers." for frame, layer, x, y in TileIter(self): # exclude all layers named in list if self.layer_names[layer] in exclude_layers: continue self.set_color_at(frame, layer, x, y, new_color_index, fg=False) def set_char_transform_at(self, frame, layer, x, y, transform): """ Set character transform (X/Y flip, 0/90/180/270 rotate) for given frame/layer/x,y tile. """ self.uv_mods[frame][layer][y][x] = uv_types[transform] # keep mapping, used only for quick access, in sync self.uv_maps[frame][layer][y][x] = transform self.uv_changed_frames[frame] = True def set_tile_at( self, frame, layer, x, y, char_index=None, fg=None, bg=None, transform=None, set_all=False, ): """ Convenience function for setting all tile attributes (character index, foreground and background color, and transofmr) at once. """ # speedier path if we know all tile attributes will change, # eg command undo/apply if set_all: self.set_char_index_at(frame, layer, x, y, char_index) self.set_color_at(frame, layer, x, y, fg, True) self.set_color_at(frame, layer, x, y, bg, False) self.set_char_transform_at(frame, layer, x, y, transform) return if char_index is not None: self.set_char_index_at(frame, layer, x, y, char_index) if fg is not None: self.set_color_at(frame, layer, x, y, fg, True) if bg is not None: self.set_color_at(frame, layer, x, y, bg, False) if transform is not None: self.set_char_transform_at(frame, layer, x, y, transform) def flip_all_xforms(self, flip_dict): # kinda ugly brute force approach: individually set all tiles in all # layers in all frames :/ for frame in range(self.frames): for layer in range(self.layers): for y in range(self.height): for x in range(self.width): self.set_char_transform_at( frame, layer, x, y, flip_dict[self.get_char_transform_at(frame, layer, x, y)], ) def flip_horizontal(self, frame, layer): "Mirrors Art left-to-right." command = EntireArtCommand(self) command.save_tiles(before=True) for a in [ self.chars, self.fg_colors, self.bg_colors, self.uv_mods, self.uv_maps, ]: a[frame][layer] = np.fliplr(a[frame][layer]) if self.app.ui.flip_affects_xforms: flips = { UV_NORMAL: UV_FLIPX, UV_FLIPX: UV_NORMAL, UV_FLIPY: UV_ROTATE180, UV_ROTATE180: UV_FLIPY, UV_ROTATE90: UV_FLIP90, UV_FLIP90: UV_ROTATE90, UV_ROTATE270: UV_FLIP270, UV_FLIP270: UV_ROTATE270, } self.flip_all_xforms(flips) self.mark_frame_changed(frame) self.set_unsaved_changes(True) command.save_tiles(before=False) self.command_stack.commit_commands([command]) def flip_vertical(self, frame, layer): "Flips Art upside down." command = EntireArtCommand(self) command.save_tiles(before=True) for a in [ self.chars, self.fg_colors, self.bg_colors, self.uv_mods, self.uv_maps, ]: a[frame][layer] = np.flipud(a[frame][layer]) if self.app.ui.flip_affects_xforms: flips = { UV_NORMAL: UV_FLIPY, UV_FLIPY: UV_NORMAL, UV_FLIPX: UV_ROTATE180, UV_ROTATE180: UV_FLIPX, # vertical flip of rotated characters: use inverted (180) "flip" UV_ROTATE90: UV_FLIP270, UV_FLIP270: UV_ROTATE90, UV_ROTATE270: UV_FLIP90, UV_FLIP90: UV_ROTATE270, } self.flip_all_xforms(flips) self.mark_frame_changed(frame) self.set_unsaved_changes(True) command.save_tiles(before=False) self.command_stack.commit_commands([command]) def shift(self, frame, layer, amount_x, amount_y): "Shift + wrap art on given frame and layer by given amount in X and Y." for a in [ self.chars, self.fg_colors, self.bg_colors, self.uv_mods, self.uv_maps, ]: a[frame][layer] = np.roll(a[frame][layer], amount_x, 1) a[frame][layer] = np.roll(a[frame][layer], amount_y, 0) self.mark_frame_changed(frame) def shift_all_frames(self, amount_x, amount_y): "Shift + wrap art in X and Y on all frames and layers." for frame in range(self.frames): for layer in range(self.layers): self.shift(frame, layer, amount_x, amount_y) def update_saved_camera(self, camera): self.camera_x, self.camera_y, self.camera_z = camera.x, camera.y, camera.z def update_selected_tile_attributes(self): self.selected_char = self.app.ui.selected_char self.selected_fg_color = self.app.ui.selected_fg_color self.selected_bg_color = self.app.ui.selected_bg_color self.selected_xform = self.app.ui.selected_xform def changed_this_frame(self): return ( self.geo_changed or True in self.char_changed_frames.values() or True in self.fg_changed_frames.values() or True in self.bg_changed_frames.values() or True in self.uv_changed_frames.values() ) def update(self): self.update_scripts() # update our camera if we're active if not self.app.game_mode and self.app.ui and self.app.ui.active_art is self: self.update_saved_camera(self.app.camera) self.update_selected_tile_attributes() # update our renderables if they're on a frame whose char/colors changed if self.geo_changed: self.build_geo() for r in self.renderables: if self.geo_changed: r.update_geo_buffers() self.geo_changed = False do_char = self.char_changed_frames[r.frame] do_uvs = self.uv_changed_frames[r.frame] do_fg = self.fg_changed_frames[r.frame] do_bg = self.bg_changed_frames[r.frame] if do_char or do_fg or do_bg or do_uvs: r.update_tile_buffers(do_char, do_uvs, do_fg, do_bg) # update instances if we chaned if self.changed_this_frame() and self.instances: for instance in self.instances: if instance.update_when_source_changes: instance.restore_from_source() # empty table of changed frames for f in range(self.frames): self.char_changed_frames[f] = False self.fg_changed_frames[f] = False self.bg_changed_frames[f] = False self.uv_changed_frames[f] = False self.updated_this_tick = True def save_to_file(self): """ Write this Art to disk. Build a dict serializing all this art's data and write it to a file. """ # if trying to save to a dir that doesn't exist, switch file to the # Art Mode documents dir, which we should assume does exist filedir = os.path.dirname(self.filename) if not os.path.exists(filedir): # self.app.log('Tried to save to directory %s which does not exist!' % filedir, error=True) new_path = ( self.app.documents_dir + ART_DIR + os.path.basename(self.filename) ) self.set_filename(new_path) # cursor might be hovering, undo any preview changes for edit in self.app.cursor.preview_edits: edit.undo() d = { "width": self.width, "height": self.height, "charset": self.charset.name, "palette": self.palette.name, "active_frame": self.active_frame, "active_layer": self.active_layer, "camera": (self.camera_x, self.camera_y, self.camera_z), "selected_char": int(self.selected_char), "selected_fg_color": int(self.selected_fg_color), "selected_bg_color": int(self.selected_bg_color), "selected_xform": int(self.selected_xform), } # preferred character set and palette, default used if not found # remember camera location # frames and layers are dicts w/ lists of their data + a few properties frames = [] for frame_index in range(self.frames): frame = {"delay": self.frame_delays[frame_index]} layers = [] for layer_index in range(self.layers): layer = { "z": self.layers_z[layer_index], "visible": int(self.layers_visibility[layer_index]), "name": self.layer_names[layer_index], } tiles = [] for y in range(self.height): for x in range(self.width): char = int(self.chars[frame_index][layer_index][y][x][0]) fg = int(self.fg_colors[frame_index][layer_index][y][x][0]) bg = int(self.bg_colors[frame_index][layer_index][y][x][0]) # use get method for transform, data's not simply an int xform = int( self.get_char_transform_at(frame_index, layer_index, x, y) ) tiles.append({"char": char, "fg": fg, "bg": bg, "xform": xform}) layer["tiles"] = tiles layers.append(layer) frame["layers"] = layers frames.append(frame) d["frames"] = frames # MAYBE-TODO: below gives not-so-pretty-printing, find out way to control # formatting for better output json.dump(d, open(self.filename, "w"), sort_keys=True, indent=1) self.set_unsaved_changes(False) # self.app.log('saved %s to disk in %.5f seconds' % (self.filename, end_time - start_time)) self.app.log("saved {}".format(self.filename)) # remove old thumbnail thumb_dir = self.app.cache_dir + THUMBNAIL_CACHE_DIR if os.path.exists(self.filename): old_thumb_filename = ( thumb_dir + self.app.get_file_hash(self.filename) + ".png" ) if os.path.exists(old_thumb_filename): os.remove(old_thumb_filename) # write thumbnail new_thumb_filename = thumb_dir + self.app.get_file_hash(self.filename) + ".png" write_thumbnail(self.app, self.filename, new_thumb_filename) # thumbnail write process actually sets active frame! set it back for r in self.renderables: r.set_frame(self.active_frame) def ALT_save_to_file(self): # TEST alt save format research # main idea: save flat lists of char/fg/bg/xform per-frame-per-layer # savetest.psci: 500x300x5, 38s vs 8.6s save, 63MB vs 33MB (vs 11MB w/ indent=None) # TODO: # - test read perf too # - save version checking (if none found in PSCI, assume v1) # - support multiple save+load code paths for different save versions def get_flat_int_list(layer_array): return list(map(int, layer_array.flatten()))[::4] start_time = time.time() d = { "width": self.width, "height": self.height, "charset": self.charset.name, "palette": self.palette.name, "active_frame": self.active_frame, "active_layer": self.active_layer, "camera": (self.camera_x, self.camera_y, self.camera_z), } frames = [] for frame_index in range(self.frames): frame = {"delay": self.frame_delays[frame_index]} layers = [] for layer_index in range(self.layers): layer = { "z": self.layers_z[layer_index], "visible": int(self.layers_visibility[layer_index]), "name": self.layer_names[layer_index], } # compile lists-of-ints for chars, colors, xforms layer["chars"] = get_flat_int_list(self.chars[frame_index][layer_index]) layer["fgs"] = get_flat_int_list( self.fg_colors[frame_index][layer_index] ) layer["bgs"] = get_flat_int_list( self.bg_colors[frame_index][layer_index] ) layer["xforms"] = get_flat_int_list( self.uv_maps[frame_index][layer_index] ) layers.append(layer) frame["layers"] = layers frames.append(frame) d["frames"] = frames json.dump(d, open(self.filename + "2", "w"), sort_keys=True, indent=None) end_time = time.time() self.app.log( "ALT saved {} to disk in {:.5f} seconds".format( self.filename, end_time - start_time ) ) def set_unsaved_changes(self, new_status): "Mark this Art as having unsaved changes in Art Mode." if new_status == self.unsaved_changes: return self.unsaved_changes = new_status self.app.update_window_title() def set_filename(self, new_filename): "Change Art's filename to new given string." # append extension if missing if not new_filename.endswith("." + ART_FILE_EXTENSION): new_filename += "." + ART_FILE_EXTENSION # if no dir given, assume documents/art/ dir if os.path.basename(new_filename) == new_filename: new_dir = self.app.documents_dir # documents/game/art if game loaded if self.app.gw.game_dir is not None: new_dir = self.app.gw.game_dir new_dir += ART_DIR new_filename = new_dir + new_filename # TODO: check if file already exists? warn user? # (probably better to do this in new art / save as self.filename = new_filename def run_script(self, script_filename, log=True, allow_undo=True): """ Run a script on this Art. Scripts contain arbitrary python expressions, executed within Art's scope. Don't run art scripts you don't trust! """ script_filename = self.get_valid_script_filename(script_filename) if not script_filename: return # remove any cursor-hover changes to art in memory for edit in self.app.cursor.preview_edits: edit.undo() # create a command for undo/redo stack if allow_undo: command = EntireArtCommand(self) command.save_tiles(before=True) # catch and log any exception try: # run script exec(open(script_filename).read()) # (assume script changed art) self.unsaved_changes = True logline = "Executed {}".format(script_filename) if log: self.app.log(logline) error = False except Exception: error = True logline = "Error executing {}:".format(script_filename) self.app.log(logline) # skip first 3 lines of callstack before artscript exec for line in traceback.format_exc().split("\n")[3:]: if line.strip(): self.app.log(line.rstrip()) # write "after" state of command and commit if allow_undo: command.save_tiles(before=False) self.command_stack.commit_commands([command]) self.app.ui.message_line.post_line(logline, error=error) def is_script_running(self, script_filename): "Return True if script with given filename is currently running." script_filename = self.get_valid_script_filename(script_filename) return script_filename and script_filename in self.scripts def get_valid_script_filename(self, script_filename): if type(script_filename) is not str: return None return self.app.find_filename_path( script_filename, ART_SCRIPT_DIR, SCRIPT_FILE_EXTENSION ) def run_script_every(self, script_filename, rate=0.1): "Start a script running on this Art at a regular rate." script_filename = self.get_valid_script_filename(script_filename) if not script_filename: return if script_filename in self.scripts: self.app.log("script {} is already running.".format(script_filename)) return # add to "scripts currently running" list self.scripts.append(script_filename) self.script_rates.append(rate) # set next time next_run = (self.app.get_elapsed_time() / 1000) + rate self.scripts_next_exec_time.append(next_run) def stop_script(self, script_filename): "Halt this Art's execution of script with given filename." # remove from running scripts, rate list, next_exec list script_filename = self.get_valid_script_filename(script_filename) if not script_filename: return if script_filename not in self.scripts: self.app.log("script {} exists but isn't running.".format(script_filename)) return script_index = self.scripts.index(script_filename) self.scripts.pop(script_index) self.script_rates.pop(script_index) self.scripts_next_exec_time.pop(script_index) def stop_all_scripts(self): "Halt all art scripts executing on this Art." for script_filename in self.scripts: self.stop_script(script_filename) def update_scripts(self): if len(self.scripts) == 0: return # don't run on game art while paused if self.app.game_mode and self.app.gw.paused: return for i, script in enumerate(self.scripts): if (self.app.get_elapsed_time() / 1000) > self.scripts_next_exec_time[i]: # execute script directly; don't use formal safeguards of run_script exec(open(script).read()) self.unsaved_changes = True self.scripts_next_exec_time[i] += self.script_rates[i] def clear_line( self, frame, layer, line_y, fg_color_index=None, bg_color_index=None ): "Clear characters on given horizontal line, to optional given colors." # TODO: use numpy slicing to do this much more quickly! for x in range(self.width): self.set_char_index_at(frame, layer, x, line_y, 0) if fg_color_index: self.set_color_at(frame, layer, x, line_y, fg_color_index) if bg_color_index: self.set_color_at(frame, layer, x, line_y, bg_color_index, False) def write_string( self, frame, layer, x, y, text, fg_color_index=None, bg_color_index=None, right_justify=False, ): """ Write given string starting at given frame/layer/x,y tile, with optional given colors, left-justified by default. Any characters not in character set's mapping data are ignored. """ if y >= self.height: return x %= self.width if right_justify: x_offset = -len(text) else: x_offset = 0 # never let string drawing go out of bounds text = text[: self.width - (x + x_offset)] for char in text: idx = self.charset.get_char_index(char) self.set_char_index_at(frame, layer, x + x_offset, y, idx) if fg_color_index is not None: self.set_color_at(frame, layer, x + x_offset, y, fg_color_index, True) if bg_color_index is not None: self.set_color_at(frame, layer, x + x_offset, y, bg_color_index, False) x_offset += 1 def composite_to( self, src_frame, src_layer, src_x, src_y, width, height, dest_art, dest_frame, dest_layer, dest_x, dest_y, ): for y in range(src_y, src_y + height): for x in range(src_x, src_x + width): # never try to write out of bounds on dest art; let user be lazy if x < 0 or x >= dest_art.width or y < 0 or y >= dest_art.height: continue # only consider non-blank characters if self.get_char_index_at(src_frame, src_layer, x, y) == 0: continue # skip tiles with transparent FG # TODO: decide if there's a reasonable behavior here if self.get_fg_color_index_at(src_frame, src_layer, x, y) == 0: continue ch, fg, bg, xform = self.get_tile_at(src_frame, src_layer, x, y) # get coords in dest art space dx = dest_x + (x - src_x) dy = dest_y + (y - src_y) # transparent bg -> keep dest bg, else use entire src tile if self.get_bg_color_index_at(src_frame, src_layer, x, y) == 0: bg = dest_art.get_bg_color_index_at(dest_frame, dest_layer, dx, dy) dest_art.set_tile_at(dest_frame, dest_layer, dx, dy, ch, fg, bg, xform) def composite_from( self, src_art, src_frame, src_layer, src_x, src_y, width, height, dest_frame, dest_layer, dest_x, dest_y, ): src_art.composite_to( src_frame, src_layer, src_x, src_y, width, height, self, dest_frame, dest_layer, dest_x, dest_y, ) def get_filtered_tiles(self, frame, layer, char_value, invert_filter=False): "Return list of (x,y) tile coords that match (or don't) a char value." tiles = [] for y in range(self.height): for x in range(self.width): char = self.get_char_index_at(frame, layer, x, y) if (not invert_filter and char == char_value) or ( invert_filter and char != char_value ): tiles.append((x, y)) return tiles def get_blank_tiles(self, frame, layer): "Return a list of (x,y) tile coords whose character is blank (0)." return self.get_filtered_tiles(frame, layer, 0) def get_nonblank_tiles(self, frame, layer): "Return a list of (x,y) tile coords whose character is NOT blank (0)." return self.get_filtered_tiles(frame, layer, 0, invert_filter=True) def get_simple_name(self): return os.path.splitext(os.path.basename(self.filename))[0] class ArtFromDisk(Art): "Subclass of Art that loads from a file. Main difference is initialization." def __init__(self, filename, app): self.valid = False try: d = json.load(open(filename)) except Exception: return width = d["width"] height = d["height"] charset = app.load_charset(d["charset"]) if not charset: app.log("Character set {} not found!".format(d["charset"])) return palette = app.load_palette(d["palette"]) if not palette: app.log("Palette {} not found!".format(d["palette"])) return # store loaded data for init_layers/frames self.loaded_data = d # base Art class initializes all vars, thereafter we just populate Art.__init__(self, filename, app, charset, palette, width, height) # still loading... self.valid = False if not self.app.override_saved_camera: cam = d["camera"] self.camera_x, self.camera_y, self.camera_z = cam[0], cam[1], cam[2] else: self.update_saved_camera(self.app.camera) # read saved tile attributes, which won't exist in pre-0.9.6 PSCI files if "selected_char" in d: self.selected_char = d["selected_char"] if "selected_fg_color" in d: self.selected_fg_color = d["selected_fg_color"] if "selected_bg_color" in d: self.selected_bg_color = d["selected_bg_color"] if "selected_xform" in d: self.selected_xform = d["selected_xform"] # update renderables with new data self.update() # signify to app that this file loaded successfully self.valid = True def log_init(self): self.app.log("Loaded {} from disk:".format(filename)) self.app.log(" character set: {}".format(self.charset.name)) self.app.log(" palette: {}".format(self.palette.name)) self.app.log(" width/height: {} x {}".format(self.width, self.height)) self.app.log(" frames: {}".format(self.frames)) self.app.log(" layers: {}".format(self.layers)) def init_layers(self): frames = self.loaded_data["frames"] # number of layers should be same for all frames self.layers = len(frames[0]["layers"]) self.layers_z, self.layers_visibility, self.layer_names = [], [], [] for i, layer in enumerate(frames[0]["layers"]): self.layers_z.append(layer["z"]) self.layers_visibility.append(bool(layer.get("visible", 1))) layer_num = str(i + 1) self.layer_names.append(layer.get("name", "Layer {}".format(layer_num))) active_layer = self.loaded_data.get("active_layer", 0) self.set_active_layer(active_layer) def init_frames(self): frames = self.loaded_data["frames"] self.frames = len(frames) self.active_frame = 0 self.frame_delays = [] # build tile data arrays from frame+layer lists shape = (self.layers, self.height, self.width, 4) for frame in frames: self.frame_delays.append(frame["delay"]) chars = np.zeros(shape, dtype=np.float32) uvs = self.new_uv_layers(self.layers) uv_maps = np.zeros(shape, dtype=np.uint32) fg_colors = chars.copy() bg_colors = chars.copy() for layer_index, layer in enumerate(frame["layers"]): x, y = 0, 0 for tile in layer["tiles"]: chars[layer_index][y][x] = tile["char"] fg_colors[layer_index][y][x] = tile["fg"] bg_colors[layer_index][y][x] = tile["bg"] uvs[layer_index][y][x] = uv_types[tile.get("xform", UV_NORMAL)] uv_maps[layer_index][y][x] = tile.get("xform", UV_NORMAL) x += 1 if x >= self.width: x = 0 y += 1 self.chars.append(chars) self.fg_colors.append(fg_colors) self.bg_colors.append(bg_colors) self.uv_mods.append(uvs) self.uv_maps.append(uv_maps) # set active frame properly active_frame = self.loaded_data.get("active_frame", 0) self.set_active_frame(active_frame) def first_update(self): # do nothing on first update during Art.init; we update after loading pass class ArtInstance(Art): """ Deep copy / clone of a source Art that can hold unique changes and be restored to its source. """ update_when_source_changes = True "Set False if you want to manually update this Art." def __init__(self, source): self.source = source # unique(?) filename self.filename = f"{source.filename}_Instance{time.time()}" self.app = source.app self.instances = None self.char_changed_frames, self.uv_changed_frames = {}, {} self.fg_changed_frames, self.bg_changed_frames = {}, {} # init lists that should be retained across refreshes self.scripts = [] self.script_rates = [] self.scripts_next_exec_time = [] self.renderables = [] # instances shouldn't have instances; cause user problems if attempted self.instances = None self.restore_from_source() self.source.instances.append(self) def set_unsaved_changes(self, new_status): pass def restore_from_source(self): "Restore ArtInstance to its source Art's new values." # copy common references/values for prop in [ "app", "width", "height", "charset", "palette", "quad_width", "quad_height", "layers", "frames", ]: setattr(self, prop, getattr(self.source, prop)) # copy lists self.layers_z = self.source.layers_z[:] self.layers_visibility = self.source.layers_visibility[:] self.layer_names = self.source.layer_names[:] self.frame_delays = self.source.frame_delays[:] # deep copy tile data lists self.chars, self.fg_colors, self.bg_colors = [], [], [] self.uv_mods, self.uv_maps = [], [] for frame_chars in self.source.chars: self.chars.append(frame_chars.copy()) for frame_uvs in self.source.uv_mods: self.uv_mods.append(frame_uvs.copy()) for frame_uv_maps in self.source.uv_maps: self.uv_maps.append(frame_uv_maps.copy()) for frame_fg_colors in self.source.fg_colors: self.fg_colors.append(frame_fg_colors.copy()) for frame_bg_colors in self.source.bg_colors: self.bg_colors.append(frame_bg_colors.copy()) self.geo_changed = True self.mark_all_frames_changed() self.update() class TileIter: "Iterator for iterating over all tiles in all layers and frames in an Art." def __init__(self, art): self.width, self.height = art.width, art.height self.frames, self.layers = art.frames, art.layers def __iter__(self): self.frame, self.layer = 0, 0 self.x, self.y = -1, 0 return self def __next__(self): self.x += 1 if self.x >= self.width: self.x = 0 self.y += 1 if self.y >= self.height: self.y = 0 self.layer += 1 if self.layer >= self.layers: self.layer = 0 self.frame += 1 if self.frame >= self.frames: raise StopIteration return self.frame, self.layer, self.x, self.y